Tool calling là gì? Cho chatbot quán Mộc tra đơn hàng thật

Tool calling là gì và chạy thế nào: mô tả công cụ cho LLM, nhận khối tool_use, chạy hàm thật rồi gửi tool_result lại. Ví dụ TypeScript tra đơn hàng quán Mộc.

Tool calling là gì? Đó là cách cho LLM xin gọi một hàm trong code của bạn. Bạn mô tả công cụ, mô hình trả về tên công cụ và đối số, code của bạn chạy hàm thật rồi gửi kết quả lại. Mô hình không tự chạy gì. Cùng mình cho chatbot quán Mộc tra đơn nhé!

Chatbot của quán Mộc đang dừng ở đâu?

Ở bài trước, mình gọi API của Claude bằng TypeScript để trả lời các câu hỏi thường gặp của quán Mộc, một quán cà phê nhỏ bán online (quán và dữ liệu trong series là minh họa). Chatbot trả lời đúng giờ mở cửa và bảng giá. Khi khách hỏi "Đơn 1041 của em giao chưa ạ?", nó đã thôi bịa, nhưng chỉ nói được "nhân viên sẽ kiểm tra rồi nhắn lại".

Thông tin đơn 1041 nằm ngay trong hệ thống của quán. Bài này nối hai thứ đó lại với nhau. Cuối bài, chatbot tự tra được đơn theo mã, và bạn sẽ thấy nó vẫn còn kẹt ở một kiểu câu hỏi khác.

Sao không đưa hết đơn hàng vào system prompt cho nhanh?

Đó là ý mình nghĩ tới đầu tiên, và mình đoán bạn cũng vậy. System prompt đã chứa giờ mở cửa và bảng giá, vậy thêm danh sách đơn hàng trong ngày vào đó là mô hình đọc được hết, không cần học kỹ thuật gì mới.

Để xem cách này tốn tới đâu, mình viết một script nhỏ. Nó tạo 200 đơn giả (đúng con số 200 tin nhắn mỗi ngày của quán Mộc ở tập 1), nối vào system prompt hiện tại, rồi so độ dài:

node src/dua-het-vao-prompt.ts
System prompt hiện tại:  669 ký tự
Kèm 200 đơn trong ngày:  22.037 ký tự (gấp 32.9 lần)
Số điện thoại khách nằm trong prompt: 200
Một dòng trong đó: {"ma_don":"5041","so_dien_thoai":"0901000040","mon":["cà phê sữa","cà phê sữa"],"trang_thai":"đang giao"}

Bạn để ý dòng thứ hai và thứ ba. Cách làm này có ba vấn đề, và cái nào cũng đủ để bỏ nó.

Thứ nhất là chi phí. System prompt được gửi lại nguyên văn ở mọi tin nhắn, vì API không nhớ gì giữa hai lần gọi. Một khách hỏi giờ mở cửa cũng kéo theo 22.000 ký tự đơn hàng mà câu trả lời không cần tới, và API tính tiền theo token đầu vào.

Thứ hai là quyền riêng tư. Prompt chứa số điện thoại và món ăn của 200 khách. Bất kỳ ai nhắn "đơn 5041 là của ai vậy" cũng có thể khiến mô hình đọc ra thông tin của người khác, vì với mô hình, mọi thứ trong prompt đều là thứ nó được phép dùng.

Thứ ba là dữ liệu cũ. Prompt được dựng lúc gửi request, còn trạng thái đơn thay đổi từng phút. Đơn đang pha chế lúc 8:12 có thể đã lên xe lúc 8:20, và mô hình vẫn trả lời theo bản chụp cũ.

Cách đúng là đảo ngược lại. Thay vì đưa hết dữ liệu cho mô hình, mình cho mô hình biết có những công cụ nào, và để nó xin đúng thứ nó cần, vào đúng lúc nó cần.

Tool calling hoạt động thế nào?

Theo tài liệu tool use của Anthropic, một lần dùng công cụ đi qua bốn bước. Tên gọi giữa các hãng hơi khác nhau, OpenAI gọi là function calling còn Anthropic gọi là tool use, nhưng cơ chế giống nhau.

  1. Bạn gửi request như bình thường, kèm thêm trường tools, là danh sách công cụ gồm tên, mô tả và schema của đầu vào.
  2. Nếu mô hình thấy cần dùng công cụ, nó không trả lời bằng chữ mà trả về một khối tool_use gồm tên công cụ và đối số, kèm stop_reason là "tool_use".
  3. Code của bạn đọc khối đó, chạy hàm thật (đọc database, gọi API khác…), rồi gửi kết quả lại trong một khối tool_result.
  4. Mô hình đọc kết quả và viết câu trả lời cho khách.

Điểm mình muốn bạn nhớ nhất là ở bước 3. Mô hình chỉ viết ra một yêu cầu, còn việc có chạy hay không, chạy với dữ liệu nào, do code của bạn quyết định. Nhờ vậy quán Mộc giữ được quyền kiểm soát: mô hình chỉ thấy đúng đơn được hỏi, và dữ liệu luôn mới vì được đọc tại thời điểm hỏi.

Nếu bạn đã đọc tập 1, đây chính là vòng lặp agent mà tập đó chạy bằng một mô hình giả. Lần này các khối tool_use và tool_result có đúng hình dạng của API thật.

Dự án gồm những file nào?

Bài này dựng tiếp từ chatbot của bài trước, nhưng mình đưa đủ mọi file vào đây để bạn gõ lại được mà không cần mở bài cũ. Cấu trúc thư mục như sau:

Cấu trúc thư mục
03-tool-calling/
  package.json
  src/
    thong-tin-quan.ts       giờ mở cửa, phí ship, bảng giá
    don-hang.ts             dữ liệu đơn hàng và hai hàm tra cứu
    cong-cu.ts              mô tả công cụ gửi cho mô hình + code chạy công cụ
    prompts.ts              system prompt
    offline.ts              mô hình giả lập, để chạy được khi chưa có API key
    goi-llm.ts              gọi Claude thật (hoặc bản giả lập)
    hoi-moc.ts              hỏi 4 câu mẫu
    xem-request.ts          in toàn bộ cuộc trao đổi
    dua-het-vao-prompt.ts   script đo độ dài ở mục trước

File package.json chỉ cần SDK chính thức. Mình chạy thẳng file .ts bằng Node.js 22.18 trở lên, nên không cần cài thêm công cụ biên dịch:

package.json
{
  "name": "moc-ai-agent-03",
  "private": true,
  "type": "module",
  "dependencies": {
    "@anthropic-ai/sdk": "1.5.0"
  }
}

Thông tin cố định của quán giữ nguyên như bài trước:

src/thong-tin-quan.ts
// Thông tin cố định của quán Mộc (minh họa), giống dữ liệu ở tập 1.
export const MENU: Record<string, number> = {
  "cà phê đen": 25000,
  "cà phê sữa": 29000,
  "bạc xỉu": 32000,
  "cà phê muối": 35000,
  "bánh mì trứng": 25000,
};

export const GIO_MO_CUA = "Mộc mở cửa từ 6:30 đến 22:00 mỗi ngày.";
export const PHI_SHIP = "Mộc giao trong bán kính 5 km, phí 15.000đ; đơn từ 150.000đ được miễn phí.";

export function bangGia(): string {
  return Object.entries(MENU)
    .map(([mon, gia]) => `- ${mon}: ${gia.toLocaleString("vi-VN")}đ`)
    .join("\n");
}

Dữ liệu đơn hàng lấy từ tập 1, gồm hai đơn. Ngoài đời, hai hàm tra cứu sẽ đọc database; ở đây chúng đọc một mảng cho dễ chạy:

src/don-hang.ts
export type Don = {
  maDon: string;
  soDienThoai: string;
  mon: string[];
  trangThai: "đang pha chế" | "đang giao" | "đã giao";
  datLuc: string;
};

const DON_HANG: Don[] = [
  { maDon: "1041", soDienThoai: "0901000001", mon: ["cà phê sữa", "bánh mì trứng"], trangThai: "đang pha chế", datLuc: "hôm nay 8:12" },
  { maDon: "1042", soDienThoai: "0901000002", mon: ["bạc xỉu"], trangThai: "đang giao", datLuc: "hôm nay 9:40" },
];

export function timDonTheoMa(maDon: string): Don | undefined {
  return DON_HANG.find((don) => don.maDon === maDon);
}

export function timDonTheoSdt(soDienThoai: string): Don | undefined {
  return DON_HANG.find((don) => don.soDienThoai === soDienThoai);
}

Mô tả một công cụ cho mô hình như thế nào?

Đây là file quan trọng nhất của bài. Nó có hai phần: mảng CONG_CU là mô tả gửi cho mô hình, còn hàm chayCongCu là code thật chạy ở máy của quán.

src/cong-cu.ts
import type Anthropic from "@anthropic-ai/sdk";

import { timDonTheoMa, timDonTheoSdt } from "./don-hang.ts";

export const CONG_CU: Anthropic.Tool[] = [
  {
    name: "xem_don",
    description:
      "Xem món và trạng thái hiện tại của một đơn hàng theo mã đơn (4 chữ số, ví dụ 1041). " +
      "Gọi công cụ này mỗi khi khách hỏi đơn đã giao chưa, đang ở đâu, khi nào tới.",
    strict: true,
    input_schema: {
      type: "object",
      properties: { ma_don: { type: "string", description: "Mã đơn gồm 4 chữ số, không có dấu #" } },
      required: ["ma_don"],
      additionalProperties: false,
    },
  },
  {
    name: "tim_don_theo_sdt",
    description:
      "Tìm mã đơn gần nhất của khách theo số điện thoại (10 chữ số, bắt đầu bằng 0). " +
      "Chỉ trả về mã đơn; muốn biết trạng thái thì gọi tiếp xem_don.",
    strict: true,
    input_schema: {
      type: "object",
      properties: { so_dien_thoai: { type: "string", description: "Số điện thoại 10 chữ số" } },
      required: ["so_dien_thoai"],
      additionalProperties: false,
    },
  },
];

export type KetQuaCongCu = { noiDung: string; loi: boolean };

// Đầu vào do mô hình viết ra: luôn kiểm tra lại trước khi dùng, như với dữ liệu người dùng gửi lên.
export function chayCongCu(ten: string, dauVao: unknown): KetQuaCongCu {
  const input = (dauVao ?? {}) as Record<string, unknown>;
  if (ten === "xem_don") {
    const ma = String(input.ma_don ?? "").trim();
    if (!/^\d{4}$/.test(ma)) return { noiDung: `Mã đơn "${ma}" không hợp lệ, cần đúng 4 chữ số.`, loi: true };
    const don = timDonTheoMa(ma);
    if (!don) return { noiDung: `Không có đơn ${ma}.`, loi: true };
    return { noiDung: JSON.stringify({ ma_don: don.maDon, mon: don.mon, trang_thai: don.trangThai, dat_luc: don.datLuc }), loi: false };
  }
  if (ten === "tim_don_theo_sdt") {
    const sdt = String(input.so_dien_thoai ?? "").replace(/\s/g, "");
    if (!/^0\d{9}$/.test(sdt)) return { noiDung: `Số điện thoại "${sdt}" không hợp lệ.`, loi: true };
    const don = timDonTheoSdt(sdt);
    if (!don) return { noiDung: "Không tìm thấy đơn nào với số này.", loi: true };
    return { noiDung: JSON.stringify({ ma_don: don.maDon }), loi: false };
  }
  return { noiDung: `Không có công cụ tên "${ten}".`, loi: true };
}

Phần description không chỉ nói công cụ làm gì mà còn nói khi nào nên gọi nó ("mỗi khi khách hỏi đơn đã giao chưa…"). Mô hình chọn công cụ chủ yếu dựa vào đoạn mô tả này, và tài liệu của Anthropic khuyên viết rõ điều kiện gọi chứ không chỉ chức năng. Mô tả của tim_don_theo_sdt còn nói thẳng là nó chỉ trả về mã đơn, để mô hình biết phải gọi tiếp xem_don nếu khách hỏi trạng thái. Câu này sẽ quan trọng ở cuối bài.

strict: true cùng additionalProperties: false yêu cầu API bảo đảm đầu vào của khối tool_use khớp đúng schema, nghĩa là luôn có ma_don kiểu chuỗi và không có trường lạ nào.

Vậy sao hàm chayCongCu vẫn kiểm tra lại? Vì schema chỉ bảo đảm kiểu dữ liệu, không bảo đảm giá trị hợp lý. Chuỗi "#1099" hay "một không bốn một" vẫn là chuỗi hợp lệ. Mình coi đầu vào do mô hình viết ra giống như dữ liệu người dùng gửi lên form: tin được về hình dạng, nhưng phải kiểm tra nội dung trước khi đưa vào database.

Khi có lỗi, hàm không ném exception mà trả về loi: true cùng một câu mô tả lỗi. Mô hình sẽ đọc câu đó và tự xin lỗi khách hoặc hỏi lại, thay vì cả chương trình dừng giữa chừng.

Code xử lý một vòng gọi công cụ trông ra sao?

System prompt giống bài trước, chỉ thay đoạn "bạn không xem được đơn hàng" bằng hướng dẫn dùng công cụ:

src/prompts.ts
import { GIO_MO_CUA, PHI_SHIP, bangGia } from "./thong-tin-quan.ts";

export const SYSTEM_V3 = `Bạn là nhân viên chăm sóc khách hàng của quán cà phê Mộc. Trả lời ngắn gọn, thân thiện, bằng tiếng Việt.

Chỉ dùng thông tin trong thẻ <thong_tin_quan> để trả lời câu hỏi chung.
<thong_tin_quan>
${GIO_MO_CUA}
${PHI_SHIP}
Bảng giá:
${bangGia()}
</thong_tin_quan>

Khi khách hỏi về một đơn hàng, hãy dùng công cụ để tra, và chỉ nói trạng thái đúng như kết quả công cụ trả về.
Nếu công cụ báo lỗi hoặc không tìm thấy đơn, hãy nói thật và xin khách kiểm tra lại mã đơn hoặc số điện thoại.`;

Hàm gọi API khác bài trước ở hai chỗ. Nó nhận cả mảng messages thay vì một câu hỏi, vì cuộc trao đổi giờ dài hơn một lượt. Nó cũng gửi kèm tools: CONG_CU. Khi chưa có API key, hàm dùng mô hình giả lập trong offline.ts, mình sẽ nói về file đó ở mục sau:

src/goi-llm.ts
import Anthropic from "@anthropic-ai/sdk";

import { CONG_CU } from "./cong-cu.ts";
import { moHinhOffline, type PhanHoi } from "./offline.ts";

export const MODEL = "claude-opus-5-5";

export function coKey(): boolean {
  return Boolean(process.env.ANTHROPIC_API_KEY) && process.env.MOC_OFFLINE !== "1";
}

export async function goiMoHinh(system: string, messages: Anthropic.MessageParam[]): Promise<PhanHoi> {
  if (!coKey()) return moHinhOffline(system, messages);
  const client = new Anthropic(); // tự đọc ANTHROPIC_API_KEY từ biến môi trường
  const response = await client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    output_config: { effort: "low" },
    system,
    tools: CONG_CU, // khác bài trước: gửi kèm mô tả công cụ
    messages,
  });
  return {
    content: response.content,
    stop_reason: response.stop_reason,
    inputTokens: response.usage.input_tokens,
    outputTokens: response.usage.output_tokens,
  };
}

export function layChu(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b) => b.type === "text")
    .map((b) => b.text)
    .join("");
}

Còn đây là phần xử lý chính. Với mỗi câu hỏi, nó gọi mô hình một lần. Nếu mô hình xin dùng công cụ, nó chạy công cụ, gửi kết quả lại và gọi mô hình lần thứ hai để viết câu trả lời:

src/hoi-moc.ts
import type Anthropic from "@anthropic-ai/sdk";

import { chayCongCu } from "./cong-cu.ts";
import { coKey, goiMoHinh, layChu, MODEL } from "./goi-llm.ts";
import { SYSTEM_V3 } from "./prompts.ts";

const CAU_HOI = [
  "Quán mở cửa mấy giờ vậy ạ?",
  "Đơn 1041 của em giao chưa ạ?",
  "Đơn #1099 sao chưa thấy ạ",
  "Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001",
];

console.log(coKey() ? `Gọi Claude thật (${MODEL})\n` : "Chế độ OFFLINE (giả lập, không phải mô hình thật)\n");

async function hoi(cauHoi: string): Promise<string> {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: cauHoi }];
  const lan1 = await goiMoHinh(SYSTEM_V3, messages);
  if (lan1.stop_reason !== "tool_use") return layChu(lan1.content);

  // Mô hình xin gọi công cụ: chạy từng khối tool_use, gom mọi kết quả vào MỘT tin nhắn.
  const ketQua: Anthropic.ToolResultBlockParam[] = [];
  for (const block of lan1.content) {
    if (block.type !== "tool_use") continue;
    const kq = chayCongCu(block.name, block.input);
    console.log(`  [công cụ] ${block.name}(${JSON.stringify(block.input)}) → ${kq.noiDung}${kq.loi ? "  (is_error)" : ""}`);
    ketQua.push({ type: "tool_result", tool_use_id: block.id, content: kq.noiDung, is_error: kq.loi });
  }
  messages.push({ role: "assistant", content: lan1.content }); // gửi lại nguyên văn phản hồi có khối tool_use
  messages.push({ role: "user", content: ketQua });

  const lan2 = await goiMoHinh(SYSTEM_V3, messages);
  if (lan2.stop_reason === "tool_use") {
    const muonGoi = lan2.content.filter((b) => b.type === "tool_use").map((b) => `${b.name}(${JSON.stringify(b.input)})`);
    return `(mô hình còn muốn gọi ${muonGoi.join(", ")}, nhưng code này chỉ chạy một vòng nên dừng ở đây)`;
  }
  return layChu(lan2.content);
}

for (const cauHoi of CAU_HOI) {
  console.log(`Khách: ${cauHoi}`);
  const traLoi = await hoi(cauHoi);
  console.log(`Mộc:   ${traLoi}\n`);
}

Có ba chi tiết dễ làm sai, nên mình nói rõ từng cái.

Dòng messages.push({ role: "assistant", content: lan1.content }) gửi lại nguyên văn phản hồi của mô hình, kể cả khối tool_use. Nếu bạn chỉ gửi tool_result mà thiếu lượt này, API sẽ trả lỗi 400, vì mỗi kết quả phải đi ngay sau lượt có lời gọi tương ứng. Gửi nguyên văn content thay vì tự dựng lại cũng an toàn hơn, vì phản hồi còn có thể chứa các khối khác (như khối suy nghĩ của mô hình) mà API cần thấy lại.

tool_use_id phải trùng với id của khối tool_use tương ứng. Mô hình có thể xin gọi nhiều công cụ trong cùng một phản hồi, và id là cách nó biết kết quả nào trả lời cho lời gọi nào.

Mọi tool_result của một lượt nằm chung trong một tin nhắn user. Tách chúng ra nhiều tin nhắn thì vẫn chạy, nhưng tài liệu của Anthropic lưu ý rằng làm vậy dạy mô hình bớt gọi song song nhiều công cụ trong những lượt sau.

Chạy thử với bốn câu hỏi

Mình chạy file trên mà không đặt API key, nên chương trình dùng mô hình giả lập (nói kỹ ở mục sau):

node src/hoi-moc.ts
Chế độ OFFLINE (giả lập, không phải mô hình thật)

Khách: Quán mở cửa mấy giờ vậy ạ?
Mộc:   Dạ, quán mở cửa từ 6:30 đến 22:00 mỗi ngày.

Khách: Đơn 1041 của em giao chưa ạ?
  [công cụ] xem_don({"ma_don":"1041"}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng"],"trang_thai":"đang pha chế","dat_luc":"hôm nay 8:12"}
Mộc:   Dạ đơn 1041 (cà phê sữa, bánh mì trứng) bạn đặt hôm nay 8:12 hiện vẫn đang pha chế, chưa giao ạ. Mộc sẽ báo bạn ngay khi đơn lên đường nhé!

Khách: Đơn #1099 sao chưa thấy ạ
  [công cụ] xem_don({"ma_don":"1099"}) → Không có đơn 1099.  (is_error)
Mộc:   Dạ Mộc chưa tìm thấy đơn này (Không có đơn 1099). Bạn kiểm tra lại mã đơn hoặc gửi Mộc số điện thoại đặt hàng nhé!

Khách: Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001
  [công cụ] tim_don_theo_sdt({"so_dien_thoai":"0901000001"}) → {"ma_don":"1041"}
Mộc:   (mô hình còn muốn gọi xem_don({"ma_don":"1041"}), nhưng code này chỉ chạy một vòng nên dừng ở đây)

Câu đầu tiên không có dòng [công cụ] nào, vì giờ mở cửa đã có trong system prompt. Có công cụ không có nghĩa là lần nào cũng phải gọi.

Câu thứ hai là kết quả mình chờ từ bài trước. Trạng thái "đang pha chế" lấy từ dữ liệu của quán, không phải do mô hình đoán.

Câu thứ ba cho thấy vì sao lỗi nên đi qua is_error chứ không ném exception. Khách gõ "#1099", đơn này không tồn tại, và khách nhận được một lời đề nghị kiểm tra lại thay vì một trang lỗi.

Câu cuối thì dừng giữa chừng, và đó là điều mình cố ý để lại. Mình quay lại nó ở mục cuối bài.

Bên trong cuộc trao đổi thực sự có gì?

Để thấy rõ bốn bước ở trên, file xem-request.ts dựng lại cuộc trao đổi về đơn 1041 rồi in ra định nghĩa công cụ và cả mảng messages:

src/xem-request.ts
import type Anthropic from "@anthropic-ai/sdk";

import { CONG_CU, chayCongCu } from "./cong-cu.ts";
import { moHinhOffline } from "./offline.ts";
import { SYSTEM_V3 } from "./prompts.ts";

console.log("Công cụ đầu tiên trong danh sách gửi kèm mỗi request:");
console.log(JSON.stringify(CONG_CU[0], null, 2));

const messages: Anthropic.MessageParam[] = [{ role: "user", content: "Đơn 1041 của em giao chưa ạ?" }];
const lan1 = moHinhOffline(SYSTEM_V3, messages);
const toolUse = lan1.content.find((b) => b.type === "tool_use")!;
const kq = chayCongCu(toolUse.name, toolUse.input);
messages.push({ role: "assistant", content: lan1.content });
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: toolUse.id, content: kq.noiDung }] });
const lan2 = moHinhOffline(SYSTEM_V3, messages);
messages.push({ role: "assistant", content: lan2.content });

console.log(`\nLần gọi 1: stop_reason = "${lan1.stop_reason}". Lần gọi 2: stop_reason = "${lan2.stop_reason}".`);
console.log("Mảng messages sau hai lần gọi:");
for (const m of messages) console.log(`${m.role}: ${JSON.stringify(m.content)}`);

Kết quả, mình bỏ đi phần định nghĩa công cụ vì nó giống hệt mảng CONG_CU ở trên:

node src/xem-request.ts (phần cuối)
Lần gọi 1: stop_reason = "tool_use". Lần gọi 2: stop_reason = "end_turn".
Mảng messages sau hai lần gọi:
user: "Đơn 1041 của em giao chưa ạ?"
assistant: [{"type":"tool_use","id":"toolu_offline_1","name":"xem_don","input":{"ma_don":"1041"}}]
user: [{"type":"tool_result","tool_use_id":"toolu_offline_1","content":"{\"ma_don\":\"1041\",\"mon\":[\"cà phê sữa\",\"bánh mì trứng\"],\"trang_thai\":\"đang pha chế\",\"dat_luc\":\"hôm nay 8:12\"}"}]
assistant: [{"type":"text","text":"Dạ đơn 1041 (cà phê sữa, bánh mì trứng) bạn đặt hôm nay 8:12 hiện vẫn đang pha chế, chưa giao ạ. Mộc sẽ báo bạn ngay khi đơn lên đường nhé!"}]

Bạn đọc bốn dòng cuối từ trên xuống. Khách hỏi. Mô hình không trả lời mà xin gọi xem_don với mã 1041. Code của quán gửi kết quả lại trong một tin nhắn vai user, vì với API, mọi thứ không phải do mô hình viết đều đến từ phía người dùng. Cuối cùng mô hình mới viết câu trả lời.

Một hệ quả bạn nên biết sớm là mỗi câu hỏi về đơn hàng tốn hai lần gọi API thay vì một. Lần thứ hai còn gửi lại cả lịch sử, và định nghĩa công cụ cũng được tính vào token đầu vào ở mọi request. Với hai công cụ thì không đáng kể, nhưng một agent có hai mươi công cụ thì khác. Các bài viết sau của series sẽ đo chuyện này bằng số thật.

Mô hình giả lập trong offline.ts là gì?

Khi chưa có API key, goiMoHinh gọi hàm moHinhOffline. Đây không phải mô hình thật. Nó là một kịch bản viết tay, trả về đúng hình dạng phản hồi của API (khối tool_use, stop_reason: "tool_use") cho bốn câu hỏi mẫu, để bạn chạy được toàn bộ luồng xử lý mà không tốn tiền:

src/offline.ts
import type Anthropic from "@anthropic-ai/sdk";

export type PhanHoi = Pick<Anthropic.Message, "content" | "stop_reason"> & { inputTokens: number; outputTokens: number };

let dem = 0;
function goiCongCu(name: string, input: Record<string, string>): PhanHoi {
  dem += 1;
  const block = { type: "tool_use", id: `toolu_offline_${dem}`, name, input } as Anthropic.ToolUseBlock;
  return { content: [block], stop_reason: "tool_use", inputTokens: 0, outputTokens: 0 };
}

function traLoi(text: string): PhanHoi {
  return { content: [{ type: "text", text } as Anthropic.TextBlock], stop_reason: "end_turn", inputTokens: 0, outputTokens: 0 };
}

function dongCo(system: string, tuKhoa: RegExp): string | undefined {
  return system.split("\n").find((dong) => tuKhoa.test(dong));
}

export function moHinhOffline(system: string, messages: Anthropic.MessageParam[]): PhanHoi {
  const cauHoi = String(messages[0].content).toLowerCase();
  const cuoi = messages.at(-1)!;

  // Lượt đầu: chỉ có câu hỏi của khách.
  if (typeof cuoi.content === "string") {
    const ma = cauHoi.match(/\b\d{4}\b/)?.[0];
    if (ma) return goiCongCu("xem_don", { ma_don: ma });
    const sdt = cauHoi.match(/\b0\d{9}\b/)?.[0];
    if (sdt) return goiCongCu("tim_don_theo_sdt", { so_dien_thoai: sdt });
    if (/giờ|mở cửa/.test(cauHoi)) return traLoi(`Dạ, ${(dongCo(system, /mở cửa từ/) ?? "").replace("Mộc mở", "quán mở")}`);
    return traLoi("Dạ Mộc chưa có thông tin này ạ.");
  }

  // Lượt sau: code của mình gửi lại kết quả công cụ (trong một tin nhắn vai "user").
  const ketQua = (cuoi.content as Anthropic.ToolResultBlockParam[]).find((b) => b.type === "tool_result")!;
  const noiDung = String(ketQua.content);
  if (ketQua.is_error) {
    return traLoi(`Dạ Mộc chưa tìm thấy đơn này (${noiDung.replace(/\.$/, "")}). Bạn kiểm tra lại mã đơn hoặc gửi Mộc số điện thoại đặt hàng nhé!`);
  }
  const don = JSON.parse(noiDung) as { ma_don: string; mon?: string[]; trang_thai?: string; dat_luc?: string };
  // Mới có mã đơn mà khách hỏi trạng thái: cần gọi thêm xem_don.
  if (!don.trang_thai) return goiCongCu("xem_don", { ma_don: don.ma_don });
  return traLoi(`Dạ đơn ${don.ma_don} (${don.mon!.join(", ")}) bạn đặt ${don.dat_luc} hiện vẫn ${don.trang_thai}, chưa giao ạ. Mộc sẽ báo bạn ngay khi đơn lên đường nhé!`);
}

Mình nói thẳng giới hạn của nó. Kịch bản này quyết định gọi công cụ bằng regex, còn mô hình thật quyết định dựa trên ý nghĩa câu hỏi và đoạn description. Câu chữ của mô hình thật cũng mỗi lần một khác, và nó có thể hỏi lại khách thay vì gọi công cụ ngay. Vì vậy, các khối output ở trên cho bạn thấy đúng luồng dữ liệu, nhưng không phải văn phong của Claude.

Muốn chạy với mô hình thật, bạn đặt biến môi trường rồi chạy lại cùng lệnh:

Chạy với Claude thật
npm install
export ANTHROPIC_API_KEY="sk-ant-..."   # Windows PowerShell: $env:ANTHROPIC_API_KEY="sk-ant-..."
node src/hoi-moc.ts

Code xử lý vòng gọi công cụ không đổi một dòng nào khi chuyển từ giả lập sang thật. Đó cũng là lý do mình tách goiMoHinh ra một file riêng ngay từ đầu.

Câu hỏi cần hai bước thì sao?

Quay lại câu thứ tư: "Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001". Khách không nhớ mã đơn, chỉ có số điện thoại. Mô hình làm đúng như mô tả công cụ hướng dẫn. Nó gọi tim_don_theo_sdt, nhận về mã 1041, rồi xin gọi tiếp xem_don để biết trạng thái.

Code của mình thì chỉ chuẩn bị đúng một vòng. Sau lần gọi thứ hai, nó thấy mô hình vẫn còn muốn dùng công cụ và đành dừng lại. Bạn có thể nghĩ tới chuyện thêm một khối if cho vòng thứ hai, nhưng rồi sẽ có câu cần ba bước, chẳng hạn khách nhờ tra đơn theo số điện thoại rồi thêm một bánh mì trứng nếu đơn chưa giao.

Cách giải quyết là cho code lặp lại cho tới khi mô hình trả lời bằng chữ, kèm một giới hạn số bước để không lặp mãi. Đó chính là vòng lặp agent, phần còn thiếu để chatbot này thật sự trở thành agent, và là chủ đề của bài viết tiếp theo. Bạn theo dõi cả series ở Xây AI agent đầu tiên. Nếu đang ôn phỏng vấn, câu hỏi về tool calling có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.