Vòng lặp AI agent: để chatbot quán Mộc tự làm nhiều bước

Vòng lặp AI agent là gì và viết thế nào bằng TypeScript: gọi mô hình, chạy công cụ, gửi kết quả lại, lặp tới khi xong, kèm giới hạn để agent không chạy mãi.

Vòng lặp AI agent là gì? Đó là đoạn code gọi mô hình, chạy công cụ mà mô hình xin, gửi kết quả lại rồi gọi tiếp, cho tới khi mô hình trả lời bằng chữ, kèm một giới hạn để agent không chạy mãi. Bạn muốn xem chatbot quán Mộc tự làm bốn bước không? Cùng mình viết nhé!

Chatbot đang kẹt ở câu hỏi nào?

Ở bài trước, chatbot 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) đã biết tra đơn bằng tool calling. Khách hỏi "Đơn 1041 của em giao chưa ạ?" thì chatbot gọi công cụ xem_don, nhận về trạng thái thật và trả lời đúng.

Nhưng có một câu nó dừng giữa chừng. Khách nhắn "Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001". Mô hình gọi tim_don_theo_sdt, nhận về mã đơn 1041, rồi xin gọi tiếp xem_don để biết trạng thái. Code của bài trước chỉ chuẩn bị đúng một vòng gọi công cụ, nên nó đành in ra dòng "mô hình còn muốn gọi xem_don, nhưng code này chỉ chạy một vòng nên dừng ở đây".

Bài này sửa chỗ đó. Cuối bài, chatbot tự làm một yêu cầu cần bốn lần gọi mô hình: tìm đơn theo số điện thoại, xem trạng thái, thêm món nếu đơn chưa giao, rồi báo lại cho khách.

Thêm một khối if cho vòng thứ hai có được không?

Cách sửa đầu tiên mình nghĩ tới là chép thêm một đoạn. Sau lần gọi thứ hai, nếu mô hình vẫn xin công cụ thì chạy công cụ, gửi kết quả và gọi lần thứ ba. Câu hỏi theo số điện thoại sẽ chạy được ngay.

Vấn đề là quán Mộc không chỉ có câu hỏi hai bước. Chủ quán muốn chatbot nhận thêm yêu cầu kiểu "Em đặt sáng nay, sđt 0901000001. Nếu đơn chưa giao thì thêm giúp em một bạc xỉu nhé". Câu này cần ba công cụ nối nhau: tìm mã đơn, xem đơn còn đang pha chế không, rồi mới thêm món.

Tức là thêm một khối if nữa. Rồi sẽ có câu cần năm bước, và code thành một bậc thang if lồng nhau mà mỗi bậc gần như giống hệt bậc trên.

Thật ra số bước không do code của mình quyết định. Mô hình đọc câu hỏi, đọc kết quả công cụ, rồi tự chọn bước tiếp theo. Code chỉ cần làm đúng một việc lặp đi lặp lại, nên nó phải là một vòng lặp.

Nhưng vòng lặp while (true) cũng có cái giá của nó. Nếu một ngày mô hình cứ xin gọi công cụ mãi, chẳng hạn vì công cụ liên tục báo lỗi và mô hình thử đi thử lại, chương trình sẽ không bao giờ dừng, và mỗi vòng là một lần gọi API tính tiền. Vì vậy vòng lặp agent luôn đi kèm một giới hạn.

Vòng lặp agent gồm những bước nào?

Theo tài liệu tool use của Anthropic, mỗi lần mô hình xin dùng công cụ, nó trả về stop_reason là "tool_use". Khi đã đủ thông tin để trả lời, stop_reason là "end_turn". Vòng lặp agent dựa đúng vào tín hiệu đó:

  1. Gửi mảng messages hiện tại cho mô hình.
  2. Thêm nguyên văn phản hồi của mô hình vào messages.
  3. Nếu stop_reason không phải "tool_use", đây là câu trả lời cuối, trả về luôn.
  4. Nếu là "tool_use", chạy mọi khối tool_use trong phản hồi, gom các tool_result vào một tin nhắn vai user, thêm vào messages.
  5. Quay lại bước 1, trừ khi đã chạm giới hạn số lần gọi.

Bạn để ý bước 3 viết là "không phải tool_use" chứ không phải "là end_turn". Mô hình còn có thể dừng vì lý do khác, ví dụ "max_tokens" khi câu trả lời dài quá giới hạn token. Viết như vậy giúp vòng lặp không bao giờ đi tiếp khi không có công cụ nào để chạy.

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

Bài này viết tiếp chatbot của bài trước. Mình thêm một công cụ mới là them_mon, một file agent.ts chứa vòng lặp, và hai script nhỏ để thử giới hạn và thử hai tin nhắn liên tiếp. Mọi file đều có trong bài để bạn gõ lại được:

Cấu trúc thư mục
04-vong-lap-agent/
  package.json
  src/
    thong-tin-quan.ts   giờ mở cửa, phí ship, bảng giá (giữ nguyên)
    don-hang.ts         dữ liệu đơn hàng, hai hàm tra cứu và hàm thêm món (mới)
    cong-cu.ts          mô tả 3 công cụ 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)
    agent.ts            vòng lặp agent (mới)
    hoi-moc.ts          hỏi 6 câu mẫu
    gioi-han.ts         thử chạm giới hạn số lần gọi
    hai-tin-nhan.ts     thử hai tin nhắn liên tiếp

Dự án chỉ cần SDK chính thức, chạy thẳng file .ts bằng Node.js 22.18 trở lên:

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

Thông tin cố định của quán giữ nguyên như hai 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");
}

Công cụ thêm món khác gì hai công cụ tra cứu?

Hai công cụ cũ chỉ đọc dữ liệu. them_mon thì thay đổi đơn hàng thật, nên mình đặt quy tắc ngay trong hàm themMon: chỉ thêm được món có trong bảng giá, và chỉ khi đơn còn đang pha chế. Đơn đã lên xe mà còn thêm món thì shipper không biết đâu mà lấy.

src/don-hang.ts
import { MENU } from "./thong-tin-quan.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);
}

// Thêm món vào đơn. Chỉ được thêm khi đơn còn đang pha chế: đơn đã lên xe thì không sửa được nữa.
export function themMon(maDon: string, mon: string, soLuong: number): { ok: true; don: Don } | { ok: false; lyDo: string } {
  const don = timDonTheoMa(maDon);
  if (!don) return { ok: false, lyDo: `Không có đơn ${maDon}.` };
  if (!(mon in MENU)) return { ok: false, lyDo: `Quán không bán "${mon}".` };
  if (don.trangThai !== "đang pha chế") return { ok: false, lyDo: `Đơn ${maDon} ${don.trangThai}, không thêm món được nữa.` };
  for (let i = 0; i < soLuong; i++) don.mon.push(mon);
  return { ok: true, don };
}

Vì sao kiểm tra trạng thái ở đây mà không chỉ dặn trong prompt? Vì prompt là lời dặn, còn code là luật. Mô hình có thể hiểu sai câu hỏi hoặc bỏ qua bước xem_don, nhưng hàm themMon không bao giờ cho thêm món vào đơn đang giao, dù ai gọi nó.

File cong-cu.ts thêm mô tả cho them_mon và một nhánh trong chayCongCu. Phần mô tả nói rõ khi nào được gọi ("chỉ gọi khi khách nói rõ muốn thêm món nào, bao nhiêu phần"), vì mô hình chọn công cụ chủ yếu dựa vào đoạn này:

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

import { themMon, 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,
    },
  },
  {
    name: "them_mon",
    description:
      "Thêm món vào một đơn còn đang pha chế. Chỉ gọi khi khách nói rõ muốn thêm món nào, bao nhiêu phần. " +
      "Đơn đang giao hoặc đã giao thì công cụ sẽ báo lỗi.",
    strict: true,
    input_schema: {
      type: "object",
      properties: {
        ma_don: { type: "string", description: "Mã đơn gồm 4 chữ số" },
        mon: { type: "string", description: "Tên món đúng như trong bảng giá, ví dụ: bạc xỉu" },
        so_luong: { type: "integer", description: "Số phần, từ 1 đến 5" },
      },
      required: ["ma_don", "mon", "so_luong"],
      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 };
  }
  if (ten === "them_mon") {
    const ma = String(input.ma_don ?? "").trim();
    const mon = String(input.mon ?? "").trim().toLowerCase();
    const soLuong = Number(input.so_luong);
    if (!/^\d{4}$/.test(ma)) return { noiDung: `Mã đơn "${ma}" không hợp lệ, cần đúng 4 chữ số.`, loi: true };
    if (!Number.isInteger(soLuong) || soLuong < 1 || soLuong > 5) return { noiDung: `Số lượng ${input.so_luong} không hợp lệ (1 đến 5).`, loi: true };
    const kq = themMon(ma, mon, soLuong);
    if (!kq.ok) return { noiDung: kq.lyDo, loi: true };
    return { noiDung: JSON.stringify({ ma_don: kq.don.maDon, mon: kq.don.mon, trang_thai: kq.don.trangThai }), loi: false };
  }
  return { noiDung: `Không có công cụ tên "${ten}".`, loi: true };
}

so_luong có kiểu integer trong schema, nên với strict: true mô hình luôn gửi một số nguyên. Nhưng schema không biết quán chỉ nhận tối đa 5 phần một lần, nên code vẫn tự kiểm tra khoảng 1 đến 5, đúng như cách bài trước kiểm tra mã đơn.

System prompt thêm một dòng cho việc thêm món, còn lại giữ nguyên:

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

export const SYSTEM_V4 = `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ề.
Chỉ thêm món khi khách nói rõ món nào và bao nhiêu phần; sau khi thêm, đọc lại cho khách các món trong đơn.
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.`;

Viết vòng lặp agent bằng TypeScript thế nào?

Hàm gọi API giữ nguyên như bài trước. Nó nhận system prompt và mảng messages, gửi kèm danh sách công cụ, và dùng mô hình giả lập khi chưa có API key:

src/goi-llm.ts
// Gửi cuộc hội thoại kèm danh sách công cụ cho mô hình, nhận về phản hồi.
// Có ANTHROPIC_API_KEY: gọi Claude thật. Không có key: dùng chế độ offline (giả lập).
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, // giờ có ba công cụ, gồm cả them_mon
    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 chính của bài, hàm chayAgent. Nó nhận câu hỏi của khách và số lần gọi mô hình tối đa, rồi trả về câu trả lời, số lần đã gọi và một nhật ký các công cụ đã chạy:

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

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

export type KetQuaAgent = { traLoi: string; soLanGoi: number; nhatKy: string[] };

// Vòng lặp agent: gọi mô hình, chạy công cụ nó xin, gửi kết quả lại, lặp tới khi nó trả lời bằng chữ.
export async function chayAgent(cauHoi: string, maxLanGoi = 6): Promise<KetQuaAgent> {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: cauHoi }];
  const nhatKy: string[] = [];

  for (let lan = 1; lan <= maxLanGoi; lan++) {
    const phanHoi = await goiMoHinh(SYSTEM_V4, messages);
    messages.push({ role: "assistant", content: phanHoi.content }); // luôn gửi lại nguyên văn

    // Không xin công cụ nữa: đây là câu trả lời cuối.
    if (phanHoi.stop_reason !== "tool_use") return { traLoi: layChu(phanHoi.content), soLanGoi: lan, nhatKy };

    // Chạy MỌI khối tool_use của lượt này, gom kết quả vào MỘT tin nhắn.
    const ketQua: Anthropic.ToolResultBlockParam[] = [];
    for (const block of phanHoi.content) {
      if (block.type !== "tool_use") continue;
      const kq = chayCongCu(block.name, block.input);
      nhatKy.push(`lần ${lan}: ${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: "user", content: ketQua });
  }

  // Chạm giới hạn: dừng lại và nói thật, thay vì lặp mãi.
  return {
    traLoi: "Dạ yêu cầu này Mộc chưa xử lý xong, nhân viên sẽ kiểm tra và nhắn lại bạn ngay ạ.",
    soLanGoi: maxLanGoi,
    nhatKy: [...nhatKy, `dừng: đã gọi mô hình ${maxLanGoi} lần mà chưa có câu trả lời cuối`],
  };
}

Vòng lặp này ngắn hơn bạn nghĩ, nhưng có vài chỗ mình viết có chủ đích.

Mình đếm số lần gọi mô hình chứ không đếm số công cụ. Một lần gọi có thể trả về nhiều khối tool_use cùng lúc, ví dụ khách hỏi hai đơn trong một tin nhắn, và vòng for bên trong chạy hết các khối đó. Thứ tốn tiền và tốn thời gian là mỗi lần gọi API, nên giới hạn đặt trên đó.

Phản hồi của mô hình được thêm vào messages ngay sau khi nhận, kể cả khi nó là câu trả lời cuối. Ở bài này việc đó chưa cần, nhưng nó giữ cho mảng messages luôn là lịch sử đầy đủ của cuộc trao đổi, và bạn sẽ thấy ở cuối bài vì sao lịch sử này quan trọng.

Khi chạm giới hạn, hàm không ném lỗi mà trả về một câu xin lỗi kèm lời hứa nhân viên sẽ kiểm tra. Với khách, một câu trả lời lịch sự tốt hơn một màn hình lỗi. Với quán, dòng "dừng" trong nhật ký là dấu hiệu cần xem lại câu hỏi đó.

Con số 6 là mình tự chọn. Câu khó nhất trong bài cần 4 lần gọi, nên 6 cho dư một chút. Bạn nên chọn theo câu khó nhất mà agent của bạn cần làm, rồi theo dõi nhật ký xem có câu nào chạm giới hạn không.

Mô hình giả lập trả lời nhiều bước thế nào?

Để chạy được mà không cần API key, offline.ts là một kịch bản viết tay, trả về đúng hình dạng phản hồi của API cho sáu câu hỏi mẫu. Kịch bản lần này phức tạp hơn bài trước, vì nó phải biết công cụ vừa chạy là công cụ nào để chọn bước tiếp theo:

src/offline.ts
// Mô hình giả lập: một kịch bản viết tay trả về đúng hình dạng phản hồi của API, để chạy được khi chưa có key.
// Nó quyết định bằng regex, không hiểu câu như mô hình thật.
import type Anthropic from "@anthropic-ai/sdk";

import { MENU } from "./thong-tin-quan.ts";

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

let dem = 0;
function goiCongCu(name: string, input: Record<string, string | number>): 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 };
}

const monTrongCau = (cau: string) => Object.keys(MENU).find((mon) => cau.includes(mon));
const muonThem = (cau: string) => /\bthêm\b/.test(cau) && monTrongCau(cau) !== undefined;

// Tên công cụ của lượt gọi gần nhất: tìm khối tool_use có id trùng với tool_use_id của kết quả.
function tenCongCu(messages: Anthropic.MessageParam[], toolUseId: string): string {
  const truoc = messages.at(-2)!.content as Anthropic.ContentBlock[];
  return (truoc.find((b) => b.type === "tool_use" && b.id === toolUseId) as Anthropic.ToolUseBlock).name;
}

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 && muonThem(cauHoi)) return goiCongCu("them_mon", { ma_don: ma, mon: monTrongCau(cauHoi)!, so_luong: 1 });
    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 (muonThem(cauHoi)) return traLoi("Dạ bạn cho Mộc xin mã đơn hoặc số điện thoại đặt hàng để Mộc thêm món nhé!");
    if (/giờ|mở cửa/.test(cauHoi)) {
      return traLoi(`Dạ, ${(system.split("\n").find((d) => /mở cửa từ/.test(d)) ?? "").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 rất tiếc, ${noiDung.charAt(0).toLowerCase()}${noiDung.slice(1)} Bạn cần Mộc hỗ trợ gì thêm không ạ?`);

  const don = JSON.parse(noiDung) as { ma_don: string; mon?: string[]; trang_thai?: string; dat_luc?: string };
  const congCu = tenCongCu(messages, ketQua.tool_use_id);
  if (congCu === "tim_don_theo_sdt") return goiCongCu("xem_don", { ma_don: don.ma_don });
  if (congCu === "xem_don" && muonThem(cauHoi) && don.trang_thai === "đang pha chế") {
    return goiCongCu("them_mon", { ma_don: don.ma_don, mon: monTrongCau(cauHoi)!, so_luong: 1 });
  }
  if (congCu === "them_mon") return traLoi(`Dạ Mộc đã thêm vào đơn ${don.ma_don}. Đơn giờ gồm: ${don.mon!.join(", ")}, vẫn đang pha chế ạ.`);
  return traLoi(`Dạ đơn ${don.ma_don} (${don.mon!.join(", ")}) bạn đặt ${don.dat_luc} hiện ${don.trang_thai} ạ.`);
}

Mình nói rõ giới hạn của nó. Kịch bản này chọn bước tiếp theo bằng regex và vài câu if, còn mô hình thật chọn dựa trên ý nghĩa câu hỏi và phần description của công cụ. Mô hình thật cũng có thể hỏi lại khách thay vì gọi công cụ ngay, hoặc gọi xem_don hai lần cho chắc. Vì vậy output bên dưới cho bạn thấy đúng luồng dữ liệu đi qua vòng lặp, nhưng không phải văn phong hay lựa chọn của Claude.

Điều đáng nói là file agent.ts không biết mình đang nói chuyện với kịch bản hay với mô hình thật. Vòng lặp chỉ nhìn stop_reason và các khối tool_use, nên nó chạy y hệt trong cả hai trường hợp.

Chạy thử sáu câu hỏi

File hoi-moc.ts gửi lần lượt sáu câu, mỗi câu một lần chayAgent, rồi in nhật ký công cụ và số lần gọi mô hình. Bốn câu đầu giống bài trước, hai câu cuối dùng công cụ mới:

src/hoi-moc.ts
import { chayAgent } from "./agent.ts";
import { coKey, MODEL } from "./goi-llm.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",
  "Em đặt sáng nay, sđt 0901000001. Nếu đơn chưa giao thì thêm giúp em một bạc xỉu nhé",
  "Thêm giúp em một bánh mì trứng vào đơn 1042 nhé",
];

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");

for (const cauHoi of CAU_HOI) {
  const kq = await chayAgent(cauHoi);
  console.log(`Khách: ${cauHoi}`);
  for (const dong of kq.nhatKy) console.log(`  ${dong}`);
  console.log(`Mộc:   ${kq.traLoi}`);
  console.log(`       (${kq.soLanGoi} lần gọi mô hình)\n`);
}

Mình chạy mà không đặt API key, nên chương trình dùng kịch bản giả lập:

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.
       (1 lần gọi mô hình)

Khách: Đơn 1041 của em giao chưa ạ?
  lần 1: 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 đang pha chế ạ.
       (2 lần gọi mô hình)

Khách: Đơn #1099 sao chưa thấy ạ
  lần 1: xem_don({"ma_don":"1099"}) → Không có đơn 1099.  (is_error)
Mộc:   Dạ Mộc rất tiếc, không có đơn 1099. Bạn cần Mộc hỗ trợ gì thêm không ạ?
       (2 lần gọi mô hình)

Khách: Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001
  lần 1: tim_don_theo_sdt({"so_dien_thoai":"0901000001"}) → {"ma_don":"1041"}
  lần 2: 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 đang pha chế ạ.
       (3 lần gọi mô hình)

Khách: Em đặt sáng nay, sđt 0901000001. Nếu đơn chưa giao thì thêm giúp em một bạc xỉu nhé
  lần 1: tim_don_theo_sdt({"so_dien_thoai":"0901000001"}) → {"ma_don":"1041"}
  lần 2: 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"}
  lần 3: them_mon({"ma_don":"1041","mon":"bạc xỉu","so_luong":1}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng","bạc xỉu"],"trang_thai":"đang pha chế"}
Mộc:   Dạ Mộc đã thêm vào đơn 1041. Đơn giờ gồm: cà phê sữa, bánh mì trứng, bạc xỉu, vẫn đang pha chế ạ.
       (4 lần gọi mô hình)

Khách: Thêm giúp em một bánh mì trứng vào đơn 1042 nhé
  lần 1: them_mon({"ma_don":"1042","mon":"bánh mì trứng","so_luong":1}) → Đơn 1042 đang giao, không thêm món được nữa.  (is_error)
Mộc:   Dạ Mộc rất tiếc, đơn 1042 đang giao, không thêm món được nữa. Bạn cần Mộc hỗ trợ gì thêm không ạ?
       (2 lần gọi mô hình)

Bạn nhìn câu thứ tư trước. Đây là câu bị dừng giữa chừng ở bài trước, giờ nó đi hết hai công cụ và trả lời được trạng thái. Không có dòng code nào viết riêng cho câu này, vòng lặp chỉ đơn giản là chạy thêm một lượt.

Câu thứ năm là câu khó nhất, với ba công cụ và bốn lần gọi mô hình. Thứ tự "tìm đơn, xem trạng thái, rồi mới thêm" không nằm trong code của mình. Nó đến từ mô tả công cụ ("chỉ trả về mã đơn; muốn biết trạng thái thì gọi tiếp xem_don") và từ điều kiện khách đặt ra ("nếu đơn chưa giao").

Câu cuối cho thấy luật trong themMon làm việc. Mô hình gọi them_mon cho đơn 1042, hàm từ chối vì đơn đang giao, và lỗi đó đi qua is_error để mô hình xin lỗi khách. Vòng lặp không cần biết gì về chuyện này.

Một chi tiết nhỏ là mỗi câu hỏi về đơn hàng giờ tốn từ 2 tới 4 lần gọi API, và lần sau gửi lại cả lịch sử của lần trước. Với Claude thật, câu thứ năm sẽ tốn nhiều token đầu vào hơn câu đầu tiên vài lần. Các bài viết sau của series sẽ đo chuyện chi phí này bằng số thật.

Chuyện gì xảy ra khi chạm giới hạn?

Để xem nhánh dừng hoạt động thế nào, gioi-han.ts chạy lại câu thứ năm nhưng chỉ cho phép 2 lần gọi mô hình, trong khi câu này cần 4:

src/gioi-han.ts
// Chạy câu hỏi cần 4 lần gọi mô hình, nhưng chỉ cho phép 2 lần, để xem agent dừng thế nào.
import { chayAgent } from "./agent.ts";

const cauHoi = "Em đặt sáng nay, sđt 0901000001. Nếu đơn chưa giao thì thêm giúp em một bạc xỉu nhé";
const kq = await chayAgent(cauHoi, 2);
console.log(`Khách: ${cauHoi}`);
for (const dong of kq.nhatKy) console.log(`  ${dong}`);
console.log(`Mộc:   ${kq.traLoi}`);
node src/gioi-han.ts
Khách: Em đặt sáng nay, sđt 0901000001. Nếu đơn chưa giao thì thêm giúp em một bạc xỉu nhé
  lần 1: tim_don_theo_sdt({"so_dien_thoai":"0901000001"}) → {"ma_don":"1041"}
  lần 2: 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"}
  dừng: đã gọi mô hình 2 lần mà chưa có câu trả lời cuối
Mộc:   Dạ yêu cầu này Mộc chưa xử lý xong, nhân viên sẽ kiểm tra và nhắn lại bạn ngay ạ.

Agent dừng sau hai lần gọi, trước khi kịp thêm món. Khách nhận một câu trả lời thật thà thay vì im lặng, và dòng "dừng" trong nhật ký cho quán biết đơn 1041 cần người kiểm tra.

Bạn để ý là món chưa được thêm. Đây là lý do mình thích đặt giới hạn sớm hơn là muộn. Một agent dừng giữa chừng thì chỉ cần người làm nốt, còn một agent chạy mãi có thể vừa tốn tiền vừa làm thêm những việc không ai muốn.

Agent quên những gì khách vừa nói

Vòng lặp đã chạy tốt với từng tin nhắn riêng lẻ. Nhưng khách thật không nhắn một lần là xong. Họ hỏi, đọc câu trả lời, rồi nhắn tiếp. File hai-tin-nhan.ts thử đúng chuyện đó, mỗi tin nhắn là một lần gọi chayAgent:

src/hai-tin-nhan.ts
// Khách nhắn hai tin liên tiếp. Mỗi tin là một lần chayAgent mới, với mảng messages mới.
import { chayAgent } from "./agent.ts";

for (const tinNhan of ["Đơn 1041 của em giao chưa ạ?", "Vậy thêm cho em một bạc xỉu nữa nhé"]) {
  const kq = await chayAgent(tinNhan);
  console.log(`Khách: ${tinNhan}`);
  for (const dong of kq.nhatKy) console.log(`  ${dong}`);
  console.log(`Mộc:   ${kq.traLoi}\n`);
}
node src/hai-tin-nhan.ts
Khách: Đơn 1041 của em giao chưa ạ?
  lần 1: 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 đang pha chế ạ.

Khách: Vậy thêm cho em một bạc xỉu nữa nhé
Mộc:   Dạ bạn cho Mộc xin mã đơn hoặc số điện thoại đặt hàng để Mộc thêm món nhé!

Khách vừa nói mã đơn ở tin nhắn trước, vậy mà agent hỏi lại. Lý do nằm ở dòng đầu tiên của chayAgent, nơi mỗi lần gọi tạo một mảng messages mới chỉ chứa đúng tin nhắn hiện tại. API không nhớ gì giữa hai lần gọi, nên tin nhắn thứ hai đến với mô hình như thể đó là câu đầu tiên của một khách lạ. Mô hình thật cũng sẽ hỏi lại như vậy, vì nó không có cách nào biết "đơn" ở đây là đơn nào.

Cách sửa nghe đơn giản, là giữ lại mảng messages giữa các tin nhắn. Nhưng một khách nói chuyện cả buổi sáng sẽ làm mảng đó dài mãi, mỗi lần gọi gửi lại toàn bộ, và mô hình có giới hạn về độ dài nó đọc được trong một lần. Giữ bao nhiêu, bỏ bớt cái gì là chủ đề của các bài viết sau. Bạn theo dõi cả series ở Xây AI agent đầu tiên, và nếu đang ôn phỏng vấn, các câu hỏi về AI agent 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.