AI agent là gì? Bài toán 200 tin nhắn mỗi ngày của quán Mộc

AI agent là gì, khác chatbot và workflow ở đâu? So sánh ba cách tự động trả lời khách cho một quán cà phê, có code TypeScript chạy được.

AI agent khác chatbot ở đâu? AI agent là chương trình dùng mô hình ngôn ngữ lớn để tự quyết định bước tiếp theo, như gọi công cụ nào và khi nào thì dừng. Chatbot chỉ trả lời, còn workflow chạy các bước viết sẵn. Hãy cùng mình so sánh cả ba trên một bài toán cụ thể nhé!

Quán Mộc và 200 tin nhắn mỗi ngày

Đây là bài đầu tiên của series Xây AI agent đầu tiên. Trong series này, mình sẽ cùng bạn xây một trợ lý chăm sóc khách hàng cho Mộc, một quán cà phê nhỏ bán hàng online. Mộc là quán minh họa, nhưng bài toán thì rất thật, vì cửa hàng nhỏ nào bán qua tin nhắn cũng gặp.

Giả sử mỗi ngày Mộc nhận khoảng 200 tin nhắn, và chủ quán vừa pha chế vừa trả lời. Nếu đọc hết tin nhắn của một ngày, bạn sẽ thấy chúng lặp lại rất nhiều. Phần lớn là câu hỏi về thông tin cố định như giờ mở cửa, có giao quận 7 không, phí ship bao nhiêu. Sau đó là câu hỏi về giá món và câu hỏi đơn của em đã giao chưa.

Ít hơn nhưng phiền hơn là các yêu cầu thay đổi đơn hàng như thêm món, đổi món hay hủy đơn. Phần còn lại là lời cảm ơn, lời khen, và thỉnh thoảng là lời phàn nàn.

Khách hàng cũng viết đủ kiểu: có dấu, không dấu, viết tắt, hoặc gộp hai yêu cầu vào một tin nhắn. Mục tiêu của series không phải là thay thế chủ quán. Mục tiêu là tự động trả lời đúng phần lớn số tin nhắn này, còn những việc máy không nên tự quyết thì chuyển cho người thật.

Bài này chưa cần API key. Cả ba cách bên dưới đều là TypeScript thuần, chạy được trên máy với Node.js 22.18 trở lên mà không phải cài thêm gì, vì từ bản này Node chạy thẳng được file .ts. Bạn tạo một thư mục với các file sau, mỗi file mình đưa đầy đủ ở bên dưới:

Cấu trúc thư mục
moc-agent/
  package.json           # chỉ cần một dòng "type": "module"
  src/
    messages.ts          # 8 tin nhắn mẫu kèm ý định đúng, để chấm điểm
    shop.ts              # dữ liệu của quán và các hàm tra đơn, thêm món, xem giá
    1-chatbot-luat.ts    # cách 1: dò từ khóa
    1b-them-luat.ts      # cách 1, sau khi "vá" thêm luật
    2-workflow.ts        # cách 2: workflow
    3-agent.ts           # cách 3: agent
package.json
{
  "name": "moc-agent",
  "private": true,
  "type": "module"
}

Dòng "type": "module" cho Node biết các file dùng cú pháp import. Sau đó, bạn chạy từng cách bằng lệnh node src/<tên-file>.ts.

Để có cơ sở chấm điểm, mình chuẩn bị 8 tin nhắn mẫu. Mỗi tin nhắn có ghi kèm ý định đúng của khách, tức là khách thật sự đang hỏi chuyện gì. Có năm loại ý định: hỏi giờ mở cửa, hỏi phí ship, hỏi giá món, tra đơn, và "khác" cho lời cảm ơn hay lời khen. Một cách trả lời được tính là đúng khi nó hiểu ra đúng ý định này:

src/messages.ts
export type Intent = "gio-mo-cua" | "phi-ship" | "gia-mon" | "tra-don" | "khac";

export const messages: { text: string; intent: Intent }[] = [
  { text: "Quán mở cửa mấy giờ vậy ạ?", intent: "gio-mo-cua" },
  { text: "co ship qua quan 7 ko shop", intent: "phi-ship" },
  { text: "Giá cà phê muối bao nhiêu?", intent: "gia-mon" },
  { text: "Bạc xỉu bao nhiêu tiền 1 ly vậy", intent: "gia-mon" },
  { text: "Đơn 1041 của em giao chưa ạ?", intent: "tra-don" },
  { text: "Mấy giờ thì đơn 1042 tới nơi?", intent: "tra-don" },
  { text: "Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001", intent: "tra-don" },
  { text: "Cảm ơn quán, cà phê ngon lắm!", intent: "khac" },
];

Ba tin nhắn về đơn hàng được viết theo ba kiểu khác nhau, có mã đơn, có giờ, và chỉ có số điện thoại, vì khách thật cũng hỏi đủ kiểu như vậy.

File thứ hai là "quán Mộc thu nhỏ". Nó chứa menu, hai câu trả lời có sẵn về giờ mở cửa và phí ship, hai đơn hàng mẫu, và bốn hàm mà ba cách bên dưới dùng chung.

Hàm timDonTheoSdt tìm đơn theo số điện thoại. Hàm xemDon trả về một câu mô tả đơn. Hàm themMon thêm món vào đơn, nhưng chỉ khi đơn còn đang pha chế. Hàm giaMon trả về giá một món:

src/shop.ts
export type OrderStatus = "dang-pha" | "dang-giao" | "da-giao";
export type Order = {
  id: number;
  phone: string;
  items: string[];
  status: OrderStatus;
  createdAt: string;
};

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 FAQ = {
  gioMoCua: "Mộc mở cửa từ 6:30 đến 22:00 mỗi ngày.",
  phiShip: "Mộc giao trong bán kính 5 km, phí 15.000đ; đơn từ 150.000đ được miễn phí.",
};

const orders: Order[] = [
  { id: 1041, phone: "0901000001", items: ["cà phê sữa", "bánh mì trứng"], status: "dang-pha", createdAt: "hôm nay 8:12" },
  { id: 1042, phone: "0901000002", items: ["bạc xỉu"], status: "dang-giao", createdAt: "hôm nay 9:40" },
];

const STATUS_TEXT: Record<OrderStatus, string> = {
  "dang-pha": "đang pha chế",
  "dang-giao": "đang giao",
  "da-giao": "đã giao",
};

export function timDonTheoSdt(phone: string): Order | undefined {
  return orders.find((order) => order.phone === phone);
}

export function xemDon(id: number): string {
  const order = orders.find((item) => item.id === id);
  if (!order) return `Không có đơn ${id}.`;
  return `Đơn ${order.id} (${order.items.join(", ")}) đặt lúc ${order.createdAt}: ${STATUS_TEXT[order.status]}.`;
}

export function themMon(id: number, item: string): string {
  const order = orders.find((entry) => entry.id === id);
  if (!order) return `Không có đơn ${id}.`;
  if (order.status !== "dang-pha") return `Đơn ${id} đã ${STATUS_TEXT[order.status]}, không thêm món được.`;
  if (!(item in MENU)) return `Menu không có "${item}".`;
  order.items.push(item);
  return `Đã thêm ${item} vào đơn ${id}.`;
}

export function giaMon(item: string): string {
  const price = MENU[item];
  return price ? `${item}: ${price.toLocaleString("vi-VN")}đ` : `Menu không có "${item}".`;
}

Cách thứ nhất: chatbot dò từ khóa

Đây là cách mà ai cũng nghĩ ra đầu tiên, và cũng là cách nhiều fanpage vẫn đang dùng. Khi thấy chữ "giờ" trong tin nhắn, chatbot gửi giờ mở cửa. Khi thấy chữ "ship", chatbot gửi phí ship.

Trong code, mỗi luật là một cặp gồm biểu thức chính quy và ý định. Hàm ruleBot duyệt các luật từ trên xuống và lấy luật đầu tiên khớp. Không luật nào khớp thì trả về "khac". Phần cuối file chạy hàm trên 8 tin nhắn mẫu, in ✓ hoặc ✗ cho từng tin, rồi đếm số câu đúng:

src/1-chatbot-luat.ts
import { messages, type Intent } from "./messages.ts";

const rules: { pattern: RegExp; intent: Intent }[] = [
  { pattern: /giờ|mở cửa/i, intent: "gio-mo-cua" },
  { pattern: /ship|giao/i, intent: "phi-ship" },
  { pattern: /giá|bao nhiêu/i, intent: "gia-mon" },
  { pattern: /đơn/i, intent: "tra-don" },
];

export function ruleBot(text: string): Intent {
  return rules.find((rule) => rule.pattern.test(text))?.intent ?? "khac";
}

let correct = 0;
for (const message of messages) {
  const guess = ruleBot(message.text);
  const ok = guess === message.intent;
  if (ok) correct += 1;
  console.log(`${ok ? "✓" : "✗"} ${message.text}\n    → hiểu là "${guess}"${ok ? "" : `, đúng ra là "${message.intent}"`}`);
}
console.log(`\nĐúng ${correct}/${messages.length} tin nhắn.`);

Khi chạy với 8 tin nhắn mẫu, kết quả như sau. Mỗi tin nhắn có hai dòng: dòng đầu là tin nhắn, dòng sau là ý định mà chatbot hiểu ra, kèm ý định đúng nếu nó hiểu sai:

node src/1-chatbot-luat.ts
✓ Quán mở cửa mấy giờ vậy ạ?
    → hiểu là "gio-mo-cua"
✓ co ship qua quan 7 ko shop
    → hiểu là "phi-ship"
✓ Giá cà phê muối bao nhiêu?
    → hiểu là "gia-mon"
✓ Bạc xỉu bao nhiêu tiền 1 ly vậy
    → hiểu là "gia-mon"
✗ Đơn 1041 của em giao chưa ạ?
    → hiểu là "phi-ship", đúng ra là "tra-don"
✗ Mấy giờ thì đơn 1042 tới nơi?
    → hiểu là "gio-mo-cua", đúng ra là "tra-don"
✗ Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001
    → hiểu là "khac", đúng ra là "tra-don"
✓ Cảm ơn quán, cà phê ngon lắm!
    → hiểu là "khac"

Đúng 5/8 tin nhắn.

Chatbot trả lời đúng 5 trên 8 tin nhắn. Điều đáng lo hơn con số này là cả ba câu sai đều hỏi về đơn hàng, tức là loại câu hỏi mà khách sốt ruột nhất. Câu "Đơn của em giao chưa" có chữ "giao", nên khách nhận được bảng phí ship. Câu "Mấy giờ thì đơn tới" có chữ "giờ", nên khách nhận được giờ mở cửa. Còn câu "Sáng nay em đặt mà chưa thấy ai gọi" không khớp với từ khóa nào, nên chatbot không trả lời gì cả.

Phản xạ tự nhiên của lập trình viên là vá lại bộ luật: đưa luật "đơn" lên đầu và thêm chữ "đặt". File dưới đây là bản vá. Nó chạy bộ luật mới trên 8 tin nhắn cũ cộng với 2 tin nhắn mới, và lần này chỉ in ra những tin bị hiểu sai:

src/1b-them-luat.ts
import { messages, type Intent } from "./messages.ts";

const patchedRules: { pattern: RegExp; intent: Intent }[] = [
  { pattern: /đơn|đặt/i, intent: "tra-don" }, // vá: ưu tiên câu hỏi về đơn
  { pattern: /giờ|mở cửa/i, intent: "gio-mo-cua" },
  { pattern: /ship|giao/i, intent: "phi-ship" },
  { pattern: /giá|bao nhiêu/i, intent: "gia-mon" },
];

const bot = (text: string): Intent => patchedRules.find((rule) => rule.pattern.test(text))?.intent ?? "khac";

const newMessages: { text: string; intent: Intent }[] = [
  { text: "Quán có giao đơn lẻ 1 ly không ạ?", intent: "phi-ship" },
  { text: "Bánh mì trứng có đặt trước được không, giá sao ạ?", intent: "gia-mon" },
];

let correct = 0;
const all = [...messages, ...newMessages];
for (const message of all) {
  const guess = bot(message.text);
  if (guess === message.intent) correct += 1;
  else console.log(`✗ ${message.text}\n    → hiểu là "${guess}", đúng ra là "${message.intent}"`);
}
console.log(`\nĐúng ${correct}/${all.length} tin nhắn (8 tin cũ + 2 tin mới).`);

Sau khi vá, cả 8 tin nhắn cũ đều được trả lời đúng, nên không tin nào trong số đó bị in ra. Nhưng hai tin nhắn mới thì sai:

node src/1b-them-luat.ts
✗ Quán có giao đơn lẻ 1 ly không ạ?
    → hiểu là "tra-don", đúng ra là "phi-ship"
✗ Bánh mì trứng có đặt trước được không, giá sao ạ?
    → hiểu là "tra-don", đúng ra là "gia-mon"

Đúng 8/10 tin nhắn (8 tin cũ + 2 tin mới).

"Đơn lẻ" không phải là đơn hàng, và "đặt trước" là câu hỏi về giá và cách đặt, không phải câu hỏi tra đơn. Bản vá sửa được ba câu cũ nhưng lại làm sai hai câu mới. Nếu bạn tiếp tục vá, những chỗ khác sẽ lại sai, và vòng lặp này không có điểm dừng, vì tiếng Việt có quá nhiều cách nói cùng một ý và có nhiều chữ mang hai, ba nghĩa.

Nguyên nhân gốc rễ là chatbot dò từ khóa không hiểu câu, nó chỉ nhìn thấy chữ. Với một biểu thức chính quy, chữ "đơn" trong "đơn hàng" và chữ "đơn" trong "đơn lẻ" hoàn toàn giống nhau.

LLM thay đổi được gì và không thay đổi được gì?

Các mô hình ngôn ngữ lớn (LLM) như Claude, GPT hay Gemini hiểu được ý của câu, kể cả khi người viết không dùng dấu hoặc viết tắt. Mình tin rằng nếu đưa 10 tin nhắn ở trên cho một LLM phân loại, kết quả sẽ tốt hơn hẳn bộ luật, và các bài viết sau của series sẽ chạy thử thật để bạn không phải tin lời mình.

Tuy nhiên, một LLM đứng một mình chỉ có thể sinh ra chữ. Nó không biết đơn 1041 đang ở đâu, vì thông tin đó nằm trong hệ thống của quán chứ không nằm trong mô hình. Nếu bạn hỏi "đơn 1041 giao chưa", LLM sẽ hoặc trả lời là không biết, hoặc tệ hơn, bịa ra một câu trả lời nghe rất hợp lý.

Anthropic có một bài viết rất đáng đọc về chủ đề này, Building effective agents. Trong bài, họ gọi nền tảng của các hệ thống như vậy là "LLM được tăng cường", tức một LLM được gắn thêm khả năng tra cứu thông tin, gọi công cụ và ghi nhớ. Sau đó họ chia các hệ thống này thành hai loại là workflow và agent, cũng chính là cách thứ hai và cách thứ ba dưới đây.

Cách thứ hai: workflow với đường đi viết sẵn

Workflow là hệ thống mà LLM và các công cụ được nối với nhau theo một đường đi do lập trình viên viết sẵn trong code. Với quán Mộc, đường đi tự nhiên nhất là phân loại tin nhắn trước, sau đó chạy nhánh xử lý tương ứng với từng loại.

Trong code, hàm workflow nhận tin nhắn và ý định của nó, rồi dùng switch để chọn nhánh. Mỗi nhánh làm một việc cố định: trả câu có sẵn, tìm tên món trong tin nhắn để báo giá, hoặc tìm mã đơn (bốn chữ số) hay số điện thoại (mười chữ số bắt đầu bằng 0) để tra đơn. Phần cuối file chạy workflow trên 8 tin nhắn mẫu, rồi thử thêm một tin nhắn khó:

src/2-workflow.ts
import { messages, type Intent } from "./messages.ts";
import { FAQ, giaMon, MENU, timDonTheoSdt, xemDon } from "./shop.ts";

export function workflow(text: string, intent: Intent): string {
  switch (intent) {
    case "gio-mo-cua":
      return FAQ.gioMoCua;
    case "phi-ship":
      return FAQ.phiShip;
    case "gia-mon": {
      const item = Object.keys(MENU).find((name) => text.toLowerCase().includes(name));
      return item ? giaMon(item) : "Bạn muốn hỏi giá món nào ạ?";
    }
    case "tra-don": {
      const id = text.match(/\b(\d{4})\b/)?.[1];
      if (id) return xemDon(Number(id));
      const phone = text.match(/\b0\d{9}\b/)?.[0];
      const order = phone ? timDonTheoSdt(phone) : undefined;
      return order ? xemDon(order.id) : "Bạn cho Mộc xin mã đơn hoặc số điện thoại nhé.";
    }
    default:
      return "Mộc cảm ơn bạn! Bạn cần gì cứ nhắn nhé.";
  }
}

for (const message of messages) {
  console.log(`Khách: ${message.text}\nMộc:   ${workflow(message.text, message.intent)}\n`);
}

// Một tin nhắn không thuộc nhánh nào đã viết sẵn:
const hard = "Đơn của em sđt 0901000001 giao chưa, nếu chưa thì thêm 1 bánh mì trứng nhé";
console.log(`Khách: ${hard}\nMộc:   ${workflow(hard, "tra-don")}`);
console.log("       (chỉ trả lời được nửa đầu — nhánh 'tra-don' không biết thêm món)");

Bạn để ý vòng lặp truyền vào message.intent, tức đáp án có sẵn trong dữ liệu mẫu. Nghĩa là mình giả sử bước phân loại luôn đúng, để chỉ chấm phần xử lý. Trong thực tế, bước phân loại thường được giao cho LLM. Với giả định đó, kết quả như sau:

node src/2-workflow.ts
Khách: Quán mở cửa mấy giờ vậy ạ?
Mộc:   Mộc mở cửa từ 6:30 đến 22:00 mỗi ngày.

Khách: co ship qua quan 7 ko shop
Mộc:   Mộc giao trong bán kính 5 km, phí 15.000đ; đơn từ 150.000đ được miễn phí.

Khách: Giá cà phê muối bao nhiêu?
Mộc:   cà phê muối: 35.000đ

Khách: Bạc xỉu bao nhiêu tiền 1 ly vậy
Mộc:   bạc xỉu: 32.000đ

Khách: Đơn 1041 của em giao chưa ạ?
Mộc:   Đơn 1041 (cà phê sữa, bánh mì trứng) đặt lúc hôm nay 8:12: đang pha chế.

Khách: Mấy giờ thì đơn 1042 tới nơi?
Mộc:   Đơn 1042 (bạc xỉu) đặt lúc hôm nay 9:40: đang giao.

Khách: Sáng nay em đặt mà chưa thấy ai gọi, sđt 0901000001
Mộc:   Đơn 1041 (cà phê sữa, bánh mì trứng) đặt lúc hôm nay 8:12: đang pha chế.

Khách: Cảm ơn quán, cà phê ngon lắm!
Mộc:   Mộc cảm ơn bạn! Bạn cần gì cứ nhắn nhé.

Khách: Đơn của em sđt 0901000001 giao chưa, nếu chưa thì thêm 1 bánh mì trứng nhé
Mộc:   Đơn 1041 (cà phê sữa, bánh mì trứng) đặt lúc hôm nay 8:12: đang pha chế.
       (chỉ trả lời được nửa đầu — nhánh 'tra-don' không biết thêm món)

Workflow trả lời đúng cả 8 câu mẫu. Nó chạy nhanh, chi phí thấp, và quan trọng nhất là dễ đoán, vì cùng một câu hỏi thì luôn đi cùng một đường, nên khi có lỗi bạn biết ngay lỗi nằm ở nhánh nào. Thật lòng mà nói, với phần lớn tin nhắn của quán Mộc, dừng lại ở đây là đủ.

Vấn đề nằm ở tin nhắn cuối cùng, tin nhắn gộp hai yêu cầu. Workflow chỉ trả lời được nửa đầu, còn yêu cầu thêm món thì bị bỏ qua.

Yêu cầu này có điều kiện. Hệ thống phải tìm đơn theo số điện thoại, xem trạng thái đơn, nếu đơn chưa giao thì thêm món, rồi báo lại cho khách.

Bạn có thể viết thêm một nhánh "tra đơn rồi thêm món". Nhưng ngày mai khách sẽ muốn "tra đơn rồi đổi món", ngày kia là "hủy đơn nếu quá 30 phút chưa giao", rồi đến "thêm món vào đơn của chị tôi". Số kiểu kết hợp tăng nhanh hơn tốc độ bạn viết nhánh mới, nên sớm muộn gì workflow cũng không theo kịp.

Cách thứ ba: agent tự chọn bước tiếp theo

Với agent, chính LLM là bên điều khiển. Nó quyết định gọi công cụ nào, theo thứ tự nào và khi nào thì dừng. Lập trình viên không viết đường đi nữa mà viết hai thứ: các công cụ và một vòng lặp.

Vòng lặp của mọi agent đều có cùng một hình dạng. Đầu tiên, bạn gửi cho mô hình tin nhắn của khách, danh sách công cụ và những gì đã xảy ra từ đầu đến giờ. Mô hình trả về một trong hai thứ: hoặc là yêu cầu gọi một công cụ kèm tham số, hoặc là câu trả lời cuối cùng.

Nếu mô hình yêu cầu gọi công cụ, code của bạn chạy công cụ đó, ghi kết quả vào lịch sử, rồi hỏi lại mô hình thêm một lần nữa. Nếu mô hình trả về câu trả lời, bạn gửi câu đó cho khách và kết thúc.

File dưới đây có ba phần, theo thứ tự từ trên xuống. Phần đầu là tools, danh sách công cụ, mỗi công cụ chỉ là một hàm gọi lại các hàm trong shop.ts. Phần giữa là mohinhGiaLap, một mô hình giả lập đóng vai LLM: nó nhìn lịch sử và trả về hoặc một yêu cầu gọi công cụ, hoặc câu trả lời cuối cùng. Phần cuối là runAgent, vòng lặp, chỉ dài khoảng mười mấy dòng:

src/3-agent.ts
import { themMon, timDonTheoSdt, xemDon } from "./shop.ts";

type ToolCall = { type: "tool"; name: string; input: Record<string, string | number> };
type FinalAnswer = { type: "text"; text: string };
type Step = ToolCall | FinalAnswer;
type HistoryEntry = { role: "khach" | "mo-hinh" | "cong-cu"; content: string };

// Các công cụ agent được phép dùng.
const tools: Record<string, (input: Record<string, string | number>) => string> = {
  tim_don_theo_sdt: ({ phone }) => {
    const order = timDonTheoSdt(String(phone));
    return order ? `Tìm thấy đơn ${order.id}.` : "Không tìm thấy đơn nào với số này.";
  },
  xem_don: ({ id }) => xemDon(Number(id)),
  them_mon: ({ id, item }) => themMon(Number(id), String(item)),
};

// Mô hình giả lập: nhìn lịch sử và quyết định bước tiếp theo, theo kịch bản cho đúng một tin nhắn.
function mohinhGiaLap(history: HistoryEntry[]): Step {
  const last = history.at(-1)!;
  const toolResults = history.filter((entry) => entry.role === "cong-cu").length;
  if (toolResults === 0) return { type: "tool", name: "tim_don_theo_sdt", input: { phone: "0901000001" } };
  if (toolResults === 1) {
    const id = last.content.match(/\d{4}/)?.[0] ?? "";
    return { type: "tool", name: "xem_don", input: { id } };
  }
  if (toolResults === 2 && last.content.includes("đang pha chế")) {
    return { type: "tool", name: "them_mon", input: { id: 1041, item: "bánh mì trứng" } };
  }
  return {
    type: "text",
    text: "Đơn 1041 của bạn vẫn đang pha chế nên chưa giao ạ. Mộc đã thêm 1 bánh mì trứng vào đơn, sẽ giao cùng lúc nhé!",
  };
}

// Vòng lặp: hỏi mô hình, chạy công cụ nó yêu cầu, ghi kết quả vào lịch sử, hỏi tiếp.
export function runAgent(message: string, maxSteps = 5): string {
  const history: HistoryEntry[] = [{ role: "khach", content: message }];
  for (let step = 1; step <= maxSteps; step += 1) {
    const decision = mohinhGiaLap(history);
    if (decision.type === "text") {
      console.log(`[bước ${step}] trả lời khách`);
      return decision.text;
    }
    const result = tools[decision.name](decision.input);
    console.log(`[bước ${step}] gọi ${decision.name}(${JSON.stringify(decision.input)}) → ${result}`);
    history.push({ role: "mo-hinh", content: `${decision.name} ${JSON.stringify(decision.input)}` });
    history.push({ role: "cong-cu", content: result });
  }
  return "Xin lỗi, Mộc cần nhân viên hỗ trợ thêm cho yêu cầu này."; // giới hạn số bước
}

const message = "Đơn của em sđt 0901000001 giao chưa, nếu chưa thì thêm 1 bánh mì trứng nhé";
console.log(`Khách: ${message}\n`);
const answer = runAgent(message);
console.log(`\nMộc: ${answer}`);

Bây giờ mình đưa cho agent đúng tin nhắn mà workflow không xử lý được. Mỗi dòng [bước N] là một vòng lặp: mô hình yêu cầu gọi công cụ nào, với tham số gì, và công cụ trả về gì:

node src/3-agent.ts
Khách: Đơn của em sđt 0901000001 giao chưa, nếu chưa thì thêm 1 bánh mì trứng nhé

[bước 1] gọi tim_don_theo_sdt({"phone":"0901000001"}) → Tìm thấy đơn 1041.
[bước 2] gọi xem_don({"id":"1041"}) → Đơn 1041 (cà phê sữa, bánh mì trứng) đặt lúc hôm nay 8:12: đang pha chế.
[bước 3] gọi them_mon({"id":1041,"item":"bánh mì trứng"}) → Đã thêm bánh mì trứng vào đơn 1041.
[bước 4] trả lời khách

Mộc: Đơn 1041 của bạn vẫn đang pha chế nên chưa giao ạ. Mộc đã thêm 1 bánh mì trứng vào đơn, sẽ giao cùng lúc nhé!

Trong vòng lặp này có hai điểm đáng chú ý. Điểm thứ nhất là bước 3 chỉ xảy ra vì bước 2 trả về trạng thái "đang pha chế". Nếu đơn đã được giao, agent sẽ không thêm món mà báo lại cho khách.

Bạn để ý hàm runAgent không có dòng if nào viết điều đó. Ở bài này, quyết định nằm trong mô hình giả lập (dòng last.content.includes("đang pha chế")), còn khi thay bằng LLM thật, chính LLM đọc kết quả của bước 2 rồi tự quyết định. Vòng lặp không đổi một dòng nào.

Điểm thứ hai là giới hạn maxSteps = 5. Agent không được phép lặp mãi; nếu quá 5 bước mà vẫn chưa xong, yêu cầu sẽ được chuyển cho người thật. Giới hạn này nghe có vẻ phụ, nhưng nếu thiếu nó, một agent bị rối có thể gọi công cụ hàng trăm lần, và bạn phải trả tiền cho từng lần gọi.

Đặt ba cách cạnh nhau thì khác nhau thế nào?

Cách dễ nhớ nhất mà mình biết là trả lời câu hỏi: ai quyết định bước tiếp theo?

Với chatbot dò từ khóa, bộ luật viết tay quyết định bước tiếp theo. Cách này rẻ và chạy ở đâu cũng được, nhưng không hiểu câu, và càng vá thì bộ luật càng rối. Nó phù hợp với những chỗ mà cách hỏi rất cố định, ví dụ menu có nút bấm sẵn.

Với workflow, lập trình viên quyết định đường đi, còn LLM chỉ làm từng việc nhỏ trên đường như phân loại hay viết câu trả lời. Cách này dễ đoán, dễ kiểm thử và chi phí thấp, nhưng chỉ đi được những đường đã viết sẵn. Nó phù hợp khi các loại yêu cầu đã biết trước và loại nào cũng có cách xử lý rõ ràng.

Với agent, LLM quyết định từng bước trong vòng lặp. Cách này xử lý được những yêu cầu nhiều bước, có điều kiện, kể cả những kiểu yêu cầu chưa gặp bao giờ. Đổi lại, agent chạy chậm hơn, tốn tiền hơn vì mỗi bước là một lần gọi mô hình, khó đoán hơn, và cần được giới hạn quyền cẩn thận. Nó phù hợp khi số bước và thứ tự các bước phụ thuộc vào dữ liệu, nên không thể viết trước được.

Khi nào không nên dùng agent?

Lời khuyên mà mình thích nhất trong bài viết của Anthropic là hãy tìm giải pháp đơn giản nhất có thể, và chỉ tăng độ phức tạp khi thật sự cần. Họ cũng nói rõ rằng với nhiều ứng dụng, chỉ cần một lần gọi LLM kèm thông tin phù hợp là đủ.

Áp dụng vào quán Mộc thì mọi thứ rất rõ ràng. Với câu hỏi về giờ mở cửa, phí ship hay giá món, workflow là đủ, còn dùng agent chỉ khiến khách phải chờ lâu hơn và quán phải trả nhiều tiền hơn. Với câu hỏi tra trạng thái một đơn hàng, workflow vẫn đủ. Chỉ những yêu cầu nhiều bước có điều kiện, như tra đơn rồi thêm món, tra đơn rồi đổi món hoặc tra đơn rồi hủy, mới là chỗ mà agent thật sự đáng giá.

Vì vậy, thiết kế hợp lý thường là kết hợp cả hai. Workflow đứng ở cửa vào và xử lý gọn các câu hỏi đơn giản, còn những câu phức tạp mới được chuyển vào agent. Với những việc liên quan đến tiền như hoàn tiền hay giảm giá, agent không tự làm mà chuyển cho người thật. Các bài viết sau của series sẽ cho bạn thấy vì sao mình kiên quyết với điểm này, khi có một khách hàng tìm cách "dụ" agent hứa hoàn tiền.

Nếu bạn đang dùng AI để viết code chứ chưa định xây agent, bài một workflow dùng AI mà vẫn giữ được tư duy có cùng tinh thần với bài này: dùng AI đúng chỗ và giữ những quyết định quan trọng cho mình.

Từ bản demo đến một agent chạy thật cần những gì?

Bản demo hôm nay còn thiếu khá nhiều thứ, và những thứ còn thiếu đó cũng chính là lộ trình của series. Đầu tiên, chúng ta cần gọi được một LLM thật từ code. Tiếp theo, chúng ta dạy LLM trả về yêu cầu gọi công cụ theo đúng định dạng thay vì trả lời bằng chữ tự do, kỹ thuật này gọi là tool calling.

Sau đó, agent cần một vòng lặp thật và khả năng ghi nhớ để biết khách vừa nói gì. Rồi agent cần đọc được chính sách và menu của quán bằng kỹ thuật RAG. Phần cuối của series dành cho những việc biến một bản demo thành sản phẩm mà bạn dám cho khách thật sử dụng: chống bị lừa, đo chất lượng, kiểm soát chi phí, chuyển cho người thật và đưa lên production.

Nếu muốn kiểm tra xem mình đã nắm được bài chưa, bạn hãy thử trả lời câu hỏi "agent khác workflow ở đâu" trong ngân hàng câu hỏi phỏng vấn trước khi xem đáp án.

Điều gì xảy ra khi thay bằng một LLM thật?

Cả ba cách trong bài hôm nay đều chạy được mà không cần đến một mô hình thật nào. Cách nhanh nhất để thấy giới hạn thật của chúng là gắn một LLM thật vào và xem chuyện gì xảy ra.

Bài viết tiếp theo của series sẽ viết đoạn TypeScript đầu tiên gọi LLM qua API, và cho LLM trả lời những câu hỏi thường gặp của quán Mộc. Khi được hỏi giờ mở cửa, LLM trả lời rất trôi chảy. Nhưng khi được hỏi "đơn 1041 giao chưa", nó sẽ tự tin bịa ra một câu trả lời. Bạn có thể theo dõi cả series ở Xây AI agent đầu tiê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.