GĐ24 — AI Agents + Tool Calling + MCP

Study note cho Frontend engineer (mạnh JS/TS) chuyển sang Backend + AI. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ code → pitfall thực tế. Ngôn ngữ: Việt, thuật ngữ giữ tiếng Anh. Code snippet Node/TS.

Bối cảnh: bạn quen viết UI gọi API qua fetch. Agent lật ngược mô hình đó — thay vì người quyết định gọi API nào, LLM tự quyết định gọi tool nào, khi nào, và với tham số gì. Backend của bạn trở thành "runtime" chạy vòng lặp đó một cách an toàn.

Kiểm chứng ngày 2026-10-05: Vercel AI SDK ai 7.x (Node ≥22, ESM-only): vòng lặp điều khiển bằng stopWhen: isStepCount(n) (stepCountIs là alias), tool khai báo bằng inputSchema; generateObject/streamObject deprecated (chưa xoá) → generateText({ output }). MCP: revision hiện hành 2026-07-28 (bỏ initialize và session); TS SDK @modelcontextprotocol/server 2.x là dòng ổn định cho spec mới, @modelcontextprotocol/sdk 1.x vẫn là npm latest nhưng thuộc spec 2025-11-25. Ví dụ trong file đã type-check với ai 7.0 + zod 4 + @modelcontextprotocol/server 2.3. Mục 9b đối chiếu với trang ủy quyền của spec (https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization: PRM RFC 9728, resource RFC 8707, kiểm audience, 401 cho token sai, không token passthrough, token không đi trong query string; 403 insufficient_scope và Client ID Metadata Documents ở mức SHOULD; stdio lấy credential từ môi trường) và trang bảo mật (https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices: confused deputy, token passthrough, SSRF, state handle hijacking, server cục bộ, scope tối thiểu). Mục 10b, 10c đối chiếu tài liệu đi kèm gói ai 7.0.127 (toolApproval, experimental_toolApprovalSecret, timeout); đây là tên "experimental", có thể đổi. Chưa xác minh: hành vi của các helper xác thực SDK v2 ngoài kiểu và phép chạy cục bộ ở mục 9b, và hành vi trên PostgreSQL thật của mục 10b.


1. AI Agent là gì#

Định nghĩa. Một AI Agent = LLM + tập tools + một vòng lặp (loop) trong đó model tự quyết định hành động tiếp theo cho tới khi đạt mục tiêu. Khác với "một prompt đơn" (one-shot: gửi prompt → nhận text → xong), agent chạy nhiều lượt (multi-turn), giữa các lượt nó gọi tool, đọc kết quả, rồi suy nghĩ tiếp.

Tại sao quan trọng. Prompt đơn chỉ biết những gì có trong training data + context bạn nhét vào. Agent thì hành động lên thế giới thực: query DB, gọi API, đọc file, chạy code. Đây là bước nhảy từ "chatbot trả lời" sang "nhân viên làm việc". Ví dụ quen thuộc: trợ lý hỗ trợ khách hàng tra đơn, tìm chính sách, tạo yêu cầu hoàn tiền; hay trợ lý lập trình đọc repo, sửa file, chạy test — mỗi hành động là một tool call do backend thực thi.

Cơ chế. Ba thành phần:

  • Model (bộ não): nhận messages + danh sách tool schema, sinh ra hoặc text cuối cùng hoặc "yêu cầu gọi tool" (tool call).
  • Tools (tay chân): các function backend expose cho model.
  • Loop (nhịp tim): backend đọc output của model; nếu là tool call thì thực thi và feed kết quả lại; nếu là text cuối thì dừng.

Điểm mấu chốt: model không tự chạy code. Nó chỉ đề nghị "hãy gọi search với {q: "..."}". Backend của bạn mới là bên thực sự chạy — đây là ranh giới an toàn quan trọng.

Ví dụ code (khác biệt one-shot vs agent).

typescriptReady
// One-shot: 1 lượt, không hành độngconst { text } = await generateText({ model, prompt: "Thời tiết Hà Nội?" });// → model đoán, hoặc nói "tôi không biết realtime"// Agent: model có thể gọi tool getWeather rồi mới trả lờiconst { text } = await generateText({  model,  tools: { getWeather },      // model được phép hành động  stopWhen: isStepCount(5),   // cho phép lặp tối đa 5 bước  prompt: "Thời tiết Hà Nội?",});

Không có stopWhen, generateText chỉ chạy 1 bước: tool được gọi nhưng model chưa có lượt nào để đọc kết quả và trả lời (đã chạy với model giả trên ai 7: 1 lần gọi model). Đây là lý do stopWhen là phần của vòng lặp, không phải tuỳ chọn. (AI SDK v4 gọi tuỳ chọn này là maxSteps; từ v5 nó đổi thành stopWhen, và code maxSteps cũ báo lỗi kiểu trên v7.)

Pitfall thực tế. Người mới hay nghĩ "agent = prompt xịn hơn". Sai. Agent là một hệ thống có vòng lặp và state, nên nó có thể loop vô hạn, tốn tiền, gọi nhầm tool. Bạn phải thiết kế điểm dừng, budget và guardrails ngay từ đầu (mục 10) — không phải add sau.


2. Tool / Function Calling#

Định nghĩa. Cơ chế cho phép model yêu cầu backend gọi một function đã khai báo trước. Bạn khai báo mỗi tool bằng schema: name, description, và tham số (JSON Schema; trong AI SDK là inputSchema, từ v5 — trước đó là parameters). Model đọc schema, quyết định có gọi không, và sinh ra arguments dạng JSON khớp schema.

Tại sao quan trọng. Đây là "API contract giữa model và code của bạn". Không có nó, model chỉ sinh text. Có nó, model gọi được thế giới thực một cách có cấu trúc, type-safe. Với dân FE: giống như bạn định nghĩa props của một component — description và schema là "docs" mà model đọc để biết cách dùng.

Cơ chế (vòng đời một tool call).

  1. Bạn gửi messages + danh sách tool schemas cho model.
  2. Model trả về một tool call request: { name: "getWeather", args: { city: "Hanoi" } } (chưa chạy gì cả — chỉ là "ý định").
  3. Backend nhận request, validate args, thực thi function thật.
  4. Backend feed kết quả trở lại model như một message có role tool.
  5. Model đọc kết quả → sinh text cuối, hoặc gọi tool tiếp.

Ví dụ code (Vercel AI SDK + zod).

typescriptReady
import { generateText, tool, isStepCount } from "ai";import { z } from "zod";const getWeather = tool({  description: "Lấy thời tiết hiện tại của một thành phố",  inputSchema: z.object({    city: z.string().describe("Tên thành phố, vd 'Hanoi'"),  }),  execute: async ({ city }) => {           // đây là code CỦA BẠN chạy    const r = await fetch(`https://api.weather/${city}`);    return await r.json();                  // kết quả feed lại cho model  },});const { text } = await generateText({  model, tools: { getWeather }, stopWhen: isStepCount(5),  prompt: "Trời Hà Nội có mưa không?",});

z.object(...) được SDK convert sang JSON Schema gửi cho model — bạn không viết JSON Schema tay.

Pitfall thực tế. (1) Đừng tin args mù quáng: model có thể sinh args sai kiểu hoặc độc hại (SQL injection nếu bạn nối chuỗi). Luôn validate (zod) + parametrize query. (2) Description mơ hồ → model gọi sai tool hoặc bịa args. (3) Tool trả về object khổng lồ → tốn token và làm model lạc. Trả về đúng thứ model cần.


3. Agent Loop / ReAct Pattern#

Định nghĩa. ReAct = Reason + Act. Mẫu vòng lặp: model reason (suy nghĩ nên làm gì) → act (gọi tool) → observe (đọc kết quả tool) → lặp lại, cho tới khi model đưa ra final answer hoặc chạm giới hạn.

Tại sao quan trọng. Đây là "engine" thực sự của agent. Hiểu loop = hiểu vì sao agent làm được task phức tạp (chia nhỏ, thử, sửa) và cũng vì sao nó nguy hiểm (loop vô hạn, tốn tiền).

Cơ chế (một vòng).

textReady
messages = [system, user]lặp (tối đa maxSteps lần):  out = model(messages, tools)  nếu out là text cuối  → return out        // điều kiện dừng 1: final answer  nếu out là tool call:    result = execute(out.tool, out.args)    messages.push(tool_call, tool_result)   // observe: nhét kết quả vào history// điều kiện dừng 2: hết maxSteps → dừng cưỡng bức

Hai điều kiện dừng bắt buộc: final answer (model tự nói xong) và max steps (chặn loop). Trong AI SDK, điều kiện thứ hai là stopWhen (ví dụ isStepCount(8)); ToolLoopAgent (mục 6) mặc định dừng sau 20 bước.

Ví dụ code (tự viết loop thô, để hiểu bản chất).

typescriptReady
// loop.ts: model(messages) trả { text } hoặc { toolCalls: [{ id, name, args }] }const messages: Msg[] = [{ role: "user", content: prompt }];for (let step = 0; step < 8; step++) {              // maxSteps = 8  const res = await model(messages);  if (!res.toolCalls?.length) return res.text;      // dừng: final answer  // MỘT message assistant chứa TẤT CẢ tool call của bước này  messages.push({ role: "assistant", toolCalls: res.toolCalls });  const results = await Promise.all(res.toolCalls.map(async (call) => {    const t = tools[call.name];    if (!t) return { error: `không có tool ${call.name}` };    const parsed = t.schema.safeParse(call.args);   // args do model điền: validate trước    if (!parsed.success) return { error: z.prettifyError(parsed.error) };    try { return await t.run(parsed.data); }    catch (e) { return { error: e instanceof Error ? e.message : String(e) }; }  }));  // mỗi call đúng một message tool, giữ thứ tự và toolCallId  res.toolCalls.forEach((call, i) =>    messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify(results[i]) }));}throw new Error("Đạt max steps mà chưa xong");       // dừng cưỡng bức

Model có thể trả nhiều tool call trong một lượt (song song). Lỗi dễ chép nhầm là gọi từng call rồi push một message assistant cho mỗi call: lịch sử khi đó có ba lượt assistant trong khi model chỉ sinh một, và args chưa qua validate chạm thẳng vào code của bạn. Đã chạy (Node 24, zod 4, model giả): một lượt ba call (đúng, sai định dạng, tên tool không tồn tại) cho lịch sử user, assistant[3], tool:a, tool:b, tool:c; hai call sai trả { error } để model sửa, vòng lặp không throw. Code mẫu Msg, Model, Tool là kiểu tối giản do tài liệu này đặt ra, không phải kiểu của SDK nào.

Pitfall thực tế. (1) Quên maxSteps → agent lặp mãi khi tool luôn trả lỗi → cháy budget. (2) Loop rung (model gọi lại y hệt tool với y hệt args vì không "học" được từ kết quả) — thường do error message vô nghĩa (xem mục 4). (3) History phình to mỗi vòng → vượt context window; cần cắt/summary history khi loop dài.


4. Tool Design Tốt#

Định nghĩa. Nghệ thuật thiết kế tool sao cho model dùng đúng, dùng an toàn: mô tả rõ, tham số chặt, trả lỗi có ý nghĩa, idempotent khi có thể, giới hạn quyền hạn.

Tại sao quan trọng. Model chỉ "thấy" tool qua schema + kết quả trả về. Tool tồi = agent tồi, bất kể model mạnh cỡ nào. 80% chất lượng agent nằm ở tool design, không phải prompt.

Cơ chế / nguyên tắc.

  • Description rõ: nói khi nào dùng và khi nào KHÔNG dùng. "Search knowledge base cho câu hỏi nội bộ; KHÔNG dùng cho toán."
  • Tham số chặt: enum thay vì free string, min/max, format rõ. Càng chặt model càng ít bịa.
  • Lỗi có ý nghĩa cho model: đừng throw stack trace. Trả { error: "city 'Xyz' không tồn tại, hãy thử tên tiếng Anh" } — để model tự sửa ở vòng sau.
  • Idempotency: tool ghi (create/charge) nên nhận idempotencyKey, vì agent có thể gọi lại do retry/loop → tránh tạo trùng, charge trùng.
  • Quyền hạn (least privilege): mỗi tool chỉ làm đúng 1 việc với scope tối thiểu. Đừng expose "runSQL(anything)".

Ví dụ code (lỗi có ý nghĩa + enum + idempotency).

typescriptReady
const createTicket = tool({  description: "Tạo support ticket. Dùng khi user báo lỗi cần theo dõi.",  inputSchema: z.object({    priority: z.enum(["low", "high"]),               // enum, không free string    title: z.string().max(120),    idempotencyKey: z.string().describe("UUID để tránh tạo trùng"),  }),  execute: async (a) => {    const exist = await db.ticket.findByKey(a.idempotencyKey);    if (exist) return { id: exist.id, note: "đã tồn tại, không tạo lại" };    try { return { id: (await db.ticket.create(a)).id }; }    catch (e) {      const msg = e instanceof Error ? e.message : String(e);      return { error: `Tạo thất bại: ${msg}. Kiểm tra title < 120 ký tự.` };    }  },});

Pitfall thực tế. Trả throw ra ngoài loop → agent chết thay vì tự sửa. Ngược lại, nuốt lỗi im lặng (return {}) → model tưởng thành công, đi tiếp trên dữ liệu rác. Luôn trả lỗi dạng dữ liệu model đọc được, không phải exception.


5. Memory: Short-term vs Long-term#

Định nghĩa.

  • Short-term memory = conversation history (mảng messages) truyền vào model mỗi lượt. Sống trong context window, mất khi hết session.
  • Long-term memory = kiến thức lưu ngoài (thường là vector store), truy hồi lại khi cần bằng semantic search (RAG). Bền qua nhiều session.

Tại sao quan trọng. Context window có hạn (tokens) và tốn tiền theo độ dài. Không thể nhét cả lịch sử 6 tháng + toàn bộ docs công ty vào mỗi request. Long-term memory cho phép agent "nhớ" chọn lọc: chỉ kéo về phần liên quan.

Cơ chế.

  • Short-term: bạn append message vào mảng, gửi lại mỗi vòng. Khi quá dài → summary (nhờ model tóm history cũ thành 1 đoạn ngắn) hoặc cắt cửa sổ (giữ N message gần nhất).
  • Long-term: khi có info cần nhớ → tạo embedding (vector) → lưu vào vector DB (pgvector, Pinecone…). Khi cần → embedding của câu hỏi → tìm k vector gần nhất → nhét text đó vào prompt.

Ví dụ code (retrieve long-term rồi đưa vào short-term).

typescriptReady
// Lưu:await vdb.upsert({ id, vector: await embed(fact), text: fact });// Truy hồi khi trả lời:const hits = await vdb.query({ vector: await embed(userMsg), topK: 4 });const context = hits.map(h => h.text).join("\n");const messages = [  { role: "system", content: `Kiến thức liên quan:\n${context}` }, // long-term → short-term  ...history,                                                        // short-term  { role: "user", content: userMsg },];

Khi nào cần long-term? Khi thông tin (a) nhiều hơn context window, (b) phải bền qua session, hoặc (c) là kiến thức riêng model không được train. Chat 3 câu hỏi đáp thì short-term là đủ — đừng over-engineer vector DB.

Pitfall thực tế. (1) Nhét quá nhiều "memory" vào prompt → model nhiễu, latency tăng, tiền tăng. (2) Vector search trả về đoạn không liên quan (embedding kém / chunk sai) → agent tự tin nói sai. (3) Coi vector DB như source of truth cho dữ liệu chính xác (số dư, giá) — sai; realtime/chính xác thì gọi API/DB qua tool, không truy hồi vector.


6. Agent Frameworks#

Định nghĩa. Thư viện lo sẵn phần "loop + tool calling + message plumbing" để bạn không tự viết. Phổ biến: Vercel AI SDK (generateText + tools, gọn cho TS/Node/Next), LangChain / LangGraph (nhiều tích hợp, LangGraph mô hình agent thành graph state machine).

Tại sao quan trọng. Loop + parse tool call + quản history + streaming + retry là boilerplate dễ sai. Framework chuẩn hoá, đổi được model provider (OpenAI/Anthropic) mà không đổi code loop.

Cơ chế / trade-off.

  • Tự viết loop: kiểm soát tối đa, ít magic, dễ debug, không lệ thuộc. Nhưng bạn tự lo streaming, parallel tool calls, retry, provider differences.
  • Vercel AI SDK: API nhỏ gọn (generateText, streamText, tool), hợp app TS. stopWhen lo loop giúp bạn. Ít abstraction thừa. Hợp phần lớn app web.
  • LangChain/LangGraph: mạnh khi flow phức tạp (nhiều nhánh, cycle, human-in-the-loop, checkpoint state). Đổi lại abstraction dày, learning curve cao, hay "magic".

Ví dụ code (cùng 1 việc, Vercel AI SDK lo loop).

typescriptReady
const { text, steps } = await generateText({  model, tools: { getWeather, createTicket },  stopWhen: isStepCount(6),       // framework tự lặp reason→act→observe tới 6 lần  prompt: userMsg,});console.log(steps.length, "bước đã chạy");

Từ v6 có sẵn lớp ToolLoopAgent gói model + instructions + tools + stopWhen thành một object tái dùng được (v7 đổi tuỳ chọn system thành instructions, và experimental_telemetry thành telemetry):

typescriptReady
const agent = new ToolLoopAgent({  model,  instructions: "Trợ lý hỗ trợ khách hàng, trả lời ngắn.",  tools: { getWeather, createTicket },  stopWhen: isStepCount(6),       // không khai báo thì mặc định 20});const { text } = await agent.generate({ prompt: userMsg });

Lấy output có cấu trúc: generateObject/streamObject đã deprecated, dùng generateText({ output: Output.object({ schema }) }) (đọc kết quả ở result.output); bước sinh output cũng tính là một step khi bạn đặt stopWhen.

Chọn thế nào? App TS đơn giản/vừa → Vercel AI SDK. Flow nhiều nhánh/state phức tạp, cần checkpoint & resume → LangGraph. Muốn hiểu bản chất & control tuyệt đối, ít tool → tự viết (như mục 3). Quy tắc: bắt đầu đơn giản, chỉ lên framework nặng khi thực sự cần.

Pitfall thực tế. Chọn LangChain "vì phổ biến" cho một app 2 tool → nuốt abstraction, khó debug khi lỗi nằm sâu trong framework. Ngược lại tự viết cho flow 20 nhánh có cycle → tự bịa lại một framework tồi hơn. Match độ phức tạp framework với độ phức tạp bài toán.


7. MCP (Model Context Protocol) là gì#

Định nghĩa. MCP là một chuẩn mở (Anthropic khởi xướng) để kết nối AI app ↔ tools/data theo một giao thức chung. Ví như "USB-C cho AI": một cổng chuẩn để cắm bất kỳ tool nào vào bất kỳ AI app nào.

Tại sao cần chuẩn hoá? Không có MCP: mỗi AI app (Claude Desktop, IDE, agent của bạn) phải tự viết tích hợp riêng cho mỗi tool (GitHub, Postgres, Slack…). N app × M tool = N×M tích hợp custom, không tái dùng được. Có MCP: mỗi tool viết một MCP server, mọi MCP client dùng được → thành N + M. Đây là lý do MCP bùng nổ: viết tool một lần, chạy mọi nơi.

Cơ chế (client–host–server).

  • Host: ứng dụng AI người dùng chạy (Claude Desktop, IDE, agent runtime của bạn). Chứa LLM.
  • Client: bộ nối bên trong host, giữ kết nối 1–1 tới một server.
  • Server: chương trình expose tools/data theo chuẩn MCP (vd: "github server" expose tool tạo issue). Chạy local (stdio) hoặc remote (HTTP).

Luồng: host phát hiện server → hỏi "bạn có tool gì?" → server trả danh sách schema → host đưa schema cho LLM → LLM quyết định gọi → host gửi lệnh gọi qua client → server thực thi → trả kết quả.

Phiên bản spec (kiểm chứng 2026-10-05). Spec đánh số theo ngày; revision hiện hành là 2026-07-28, trước đó là 2025-11-25. Hai điều thay đổi cách hoạt động ở mô tả "host hỏi server có tool gì":

  • Bỏ initialize/notifications/initialized và Mcp-Session-Id (không còn session ở tầng giao thức). Mỗi request tự mang phiên bản và capabilities của client trong _meta (io.modelcontextprotocol/protocolVersion, .../clientCapabilities); server bắt buộc có RPC server/discover để khai báo phiên bản, capabilities, danh tính. Thông báo thay đổi (list tool đổi…) đi qua subscriptions/listen.
  • Deprecated (xoá sớm nhất từ 2027-07-28): Roots, Sampling, Logging, Dynamic Client Registration, transport HTTP+SSE cũ.

Server/client viết cho các revision cũ vẫn dùng initialize, và spec mô tả cách nhận diện rồi lùi về kiểu cũ. Trên SDK v2, serveStdio(factory) (mục 9) phục vụ cả hai kiểu: đã chạy thật một server stdio, gửi initialize (kiểu cũ) và gửi server/discover (kiểu mới), cả hai đều trả tools/call đúng. Phần xác thực khi chạy qua HTTP (OAuth 2.1 + PKCE, Protected Resource Metadata RFC 9728, tham số resource RFC 8707) có ở mục 9b.

Ví dụ (khái niệm, cấu hình host trỏ tới server).

jsonReady
// host config: khai báo 1 MCP server chạy qua stdio{ "mcpServers": {    "my-tools": { "command": "node", "args": ["./my-mcp-server.js"] }} }

Liên hệ công cụ bạn đang dùng. Trợ lý lập trình như Claude Code hay IDE có AI là host MCP: mỗi server bạn cấu hình (GitHub, Postgres, Slack...) hiện ra như một nhóm tool tên dạng mcp__.... Đó chính là sơ đồ host–client–server ở trên, chạy trên máy bạn.

Pitfall thực tế. Nhầm MCP với "một API khác". MCP không thay REST — nó là lớp chuẩn hoá cách LLM khám phá và gọi tool. Và: MCP server chạy với quyền của bạn; cắm server lạ = cho code lạ chạy trên máy bạn với token của bạn. Chỉ cắm server tin cậy.


8. MCP Primitives & Transport#

Định nghĩa. MCP định nghĩa 3 primitive (loại capability server có thể cung cấp) và các transport (cách client–server nói chuyện).

Ba primitive.

  • Tools: hành động model gọi (có side effect): createIssue, runQuery. — model-controlled.
  • Resources: dữ liệu để đọc vào context (file, DB row, doc), định danh bằng URI. Giống GET, không side effect. — thường app/user chọn đưa vào.
  • Prompts: template prompt tái dùng do server cung cấp, user kích hoạt (vd "review PR này"). — user-controlled.

Phân biệt then chốt: tool = làm gì đó, resource = đọc gì đó, prompt = template có sẵn.

Transport.

  • stdio: server chạy local như child process, giao tiếp qua stdin/stdout. Đơn giản, nhanh, hợp tool local (đọc file, DB local). Không qua network.
  • Streamable HTTP: server chạy remote; mỗi message là một HTTP POST tới một endpoint MCP, server trả JSON hoặc một stream SSE gắn với đúng request đó. Hợp server dùng chung nhiều người, deploy trên cloud.
  • Transport HTTP+SSE cũ (hai endpoint: GET mở stream + POST gửi lệnh) đã deprecated từ revision 2025-03-26; code mới dùng Streamable HTTP. Hai transport chuẩn của spec 2026-07-28 là stdio và Streamable HTTP.

Tại sao quan trọng. Chọn đúng primitive giúp model dùng đúng: đừng biến "đọc file" thành tool nếu nó chỉ là resource. Chọn đúng transport: local dev → stdio; production shared → HTTP.

Ví dụ (khai báo resource + tool trong server).

typescriptReady
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";server.registerResource(  "file",  new ResourceTemplate("file:///docs/{name}", { list: undefined }),  { description: "Tài liệu nội bộ (chỉ đọc)" },  async (uri, { name }) => ({    contents: [{ uri: uri.href, text: await readFile(`./docs/${name}`, "utf8") }],  // đọc, không side effect  }),);server.registerTool(  "createIssue",  { description: "Tạo issue mới", inputSchema: z.object({ title: z.string() }) },  async ({ title }) => { /* side effect */ return { content: [{ type: "text", text: `đã tạo: ${title}` }] }; },);

Pitfall thực tế. Nhét mọi thứ thành tool → model bị ngập lựa chọn, gọi lung tung. Dữ liệu chỉ-đọc nên là resource. Về transport: stdio server viết log ra stdout sẽ phá giao thức (stdout dành cho JSON-RPC) — log phải ra stderr. Đây là bug MCP kinh điển.


9. Xây một MCP Server đơn giản (TS SDK)#

Định nghĩa. Dùng @modelcontextprotocol/server (TS SDK v2, dòng ổn định cho spec 2026-07-28) để viết một server expose 1–2 tool, rồi cắm vào một host (Claude Desktop / IDE / agent) để gọi. (Gói cũ @modelcontextprotocol/sdk 1.x vẫn là npm latest và vẫn chạy, nhưng thuộc spec 2025-11-25 và server.tool() của nó đã deprecated; code mới nên dùng v2.)

Tại sao quan trọng. Đây là kỹ năng biến "code có sẵn của bạn" thành capability mọi AI app dùng được. Với BE engineer: giống viết một microservice, nhưng "khách hàng" là LLM.

Cơ chế. (1) Tạo server, khai báo tool bằng registerTool (name, config có inputSchema là zod object, handler). (2) Chọn transport (stdio cho local) và phục vụ bằng serveStdio(factory). (3) Khai báo server trong config của host. (4) Host gọi tool.

Ví dụ code (server stdio expose 2 tool).

typescriptReady
// my-mcp-server.tsimport { McpServer } from "@modelcontextprotocol/server";import { serveStdio } from "@modelcontextprotocol/server/stdio";import { z } from "zod";function createServer() {  const server = new McpServer({ name: "math-tools", version: "1.0.0" });  server.registerTool(    "add",    { description: "Cộng hai số", inputSchema: z.object({ a: z.number(), b: z.number() }) },    async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),  );  server.registerTool(    "now",    { description: "Giờ hiện tại (ISO 8601)" },    async () => ({ content: [{ type: "text", text: new Date().toISOString() }] }),  );  return server;}// giao tiếp qua stdin/stdout; cùng một factory phục vụ cả client kiểu cũ (initialize) lẫn kiểu mớiserveStdio(createServer);// LƯU Ý: KHÔNG console.log ra stdout — sẽ phá JSON-RPC. Dùng console.error (stderr).

Đã chạy thật (node my-mcp-server.ts, Node 24, @modelcontextprotocol/server 2.3): tools/call add {a:3,b:4} trả "7" cho cả client mở bằng initialize lẫn client mở bằng server/discover. Mẫu v1 tương đương (McpServer từ @modelcontextprotocol/sdk/server/mcp.js, server.tool("add", { a: z.number(), b: z.number() }, handler), new StdioServerTransport() + await server.connect(transport)) vẫn type-check trên @modelcontextprotocol/sdk 1.32.

Cách host gọi vào. Trong config host (vd Claude Desktop claude_desktop_config.json):

jsonReady
{ "mcpServers": {    "math-tools": { "command": "node", "args": ["/abs/path/my-mcp-server.js"] }} }

Host khởi động server như child process, lấy danh sách tool (add, now), đưa schema cho LLM. Khi bạn chat "cộng 3 và 4", LLM gọi add({a:3,b:4}), server trả 7, LLM đọc rồi trả lời.

Pitfall thực tế. (1) Log ra stdout phá giao thức (nói ở mục 8). (2) Đường dẫn tương đối trong config — host chạy từ cwd khác → không tìm thấy file; dùng absolute path. (3) Handler throw không bắt → server chết, host mất kết nối; bọc try/catch trả lỗi dạng content. (4) Quên build TS → JS: host chạy lệnh bạn khai báo, nên args phải trỏ file mà node chạy được. Node ≥22.18/23.6 chạy thẳng .ts (type stripping, chỉ cú pháp "erasable": không enum/namespace), như node my-mcp-server.ts ở trên; Node cũ hơn cần build hoặc tsx.


9b. MCP qua HTTP: xác thực và bảo mật#

Định nghĩa. Khi server MCP chạy qua Streamable HTTP, nó là một OAuth 2.1 resource server: không tự phát token, chỉ kiểm tra token do một authorization server (AS) cấp. Server stdio thì khác: spec nói transport stdio không theo đặc tả ủy quyền này mà lấy credential từ môi trường (biến môi trường của process con).

Tại sao quan trọng. Server MCP thường đứng trước dữ liệu và hành động thật (đơn hàng, hoàn tiền). Thiếu một trong các bước kiểm tra dưới đây là cho client hoặc một server khác dùng token không phải của nó: lỗi này đã có tên (confused deputy, token passthrough) và spec liệt kê cách chặn.

Cơ chế (luồng, spec 2026-07-28).

textReady
 client                 MCP server              authorization server   | POST /mcp (no token)    |                           |   |<-- 401 WWW-Authenticate: resource_metadata=<URL>    |   | GET /.well-known/oauth-protected-resource           |   |<-- { resource, authorization_servers, scopes }      |   | discover AS metadata ------------------------------>|   | authorize + PKCE + resource=<URL của MCP server> -->|   |<-- code (+ iss) ------------------------------------|   | token request + code_verifier + resource ---------->|   |<-- access token (aud = URL của MCP server) ---------|   | POST /mcp  Authorization: Bearer <token> ->|   |            verify: chữ ký, iss, aud, exp, scope     |   |<-- 200 | 401 invalid_token | 403 insufficient_scope |

Việc của server (MUST trong spec): công bố Protected Resource Metadata (RFC 9728) để client tìm ra AS; kiểm tra token đúng là cấp cho chính server này (audience, RFC 8707); trả 401 cho token thiếu, sai hoặc hết hạn; không nhận và không chuyển tiếp token nào khác (không token passthrough). Việc của client (MUST): gửi tham số resource ở cả authorization request lẫn token request; gửi token trong header Authorization: Bearer, không bao giờ trong query string; kiểm tra iss của authorization response (RFC 9207) trước khi đem code đi đổi token. Khi thiếu scope cho một thao tác, spec đề nghị (SHOULD) server trả 403 insufficient_scope kèm scope="..." cần thiết và resource_metadata. Đăng ký client: Client ID Metadata Documents là hướng nên hỗ trợ (SHOULD); Dynamic Client Registration đã deprecated, giữ cho tương thích.

Ví dụ code (server HTTP kiểm token bằng SDK v2 và jose). createMcpHandler không tự xác thực: tài liệu SDK ghi authInfo chỉ được chuyển tiếp, không điền từ header, nên bạn phải gọi verifyBearerToken trước.

typescriptReady
// mcp-auth.tsimport {  McpServer, OAuthError, OAuthErrorCode, createMcpHandler,  verifyBearerToken, bearerAuthChallengeResponse,  oauthMetadataResponse, getOAuthProtectedResourceMetadataUrl,  type AuthInfo, type AuthMetadataOptions, type OAuthTokenVerifier,} from "@modelcontextprotocol/server";import { jwtVerify, createRemoteJWKSet, type JWTVerifyGetKey } from "jose";import { z } from "zod";const RESOURCE = new URL(process.env.MCP_PUBLIC_URL ?? "https://mcp.example.com/mcp");const ISSUER = process.env.AUTH_ISSUER ?? "https://auth.example.com";export function makeVerifier(keys: JWTVerifyGetKey): OAuthTokenVerifier {  return {    async verifyAccessToken(token): Promise<AuthInfo> {      try {        const { payload } = await jwtVerify(token, keys, {          issuer: ISSUER,                     // đúng authorization server          audience: RESOURCE.href,            // token cấp cho CHÍNH server này          algorithms: ["RS256"],              // không để token tự chọn thuật toán        });        return {          token,          clientId: String(payload.client_id ?? payload.azp ?? ""),          scopes: String(payload.scope ?? "").split(" ").filter(Boolean),          expiresAt: payload.exp,             // thiếu exp thì SDK từ chối          resource: RESOURCE,                 // SDK so với expectedResource          extra: { userId: payload.sub },        };      } catch {        throw new OAuthError(OAuthErrorCode.InvalidToken, "token không hợp lệ");      }    },  };}const prmUrl = getOAuthProtectedResourceMetadataUrl(RESOURCE);const metaOptions: AuthMetadataOptions = {  resourceServerUrl: RESOURCE,  scopesSupported: ["mcp:tools", "orders:refund"],  oauthMetadata: {                          // bản sao metadata của AS (RFC 8414)    issuer: ISSUER,    authorization_endpoint: `${ISSUER}/authorize`,    token_endpoint: `${ISSUER}/token`,    response_types_supported: ["code"],    code_challenge_methods_supported: ["S256"],  },};export function createApp(keys: JWTVerifyGetKey) {  const verifier = makeVerifier(keys);  const handler = createMcpHandler(({ authInfo }) => {   // factory chạy mỗi request    const server = new McpServer({ name: "orders", version: "1.0.0" });    server.registerTool(      "requestRefund",      { description: "Tạo yêu cầu hoàn tiền (chưa chi tiền)",        inputSchema: z.object({ orderId: z.string().regex(/^ORD-\d{4}$/) }) },      async ({ orderId }) => {        // danh tính lấy từ token đã xác minh, không từ tham số model điền        if (!authInfo?.scopes.includes("orders:refund"))          return { isError: true, content: [{ type: "text", text: "thiếu scope orders:refund" }] };        const userId = String(authInfo.extra?.userId);        return { content: [{ type: "text", text: `PENDING ${orderId} by ${userId}` }] };      },    );    return server;  });  return async function fetchHandler(req: Request): Promise<Response> {    const url = new URL(req.url);    if (url.pathname.startsWith("/.well-known/"))      return oauthMetadataResponse(req, metaOptions) ?? new Response("not found", { status: 404 });    if (url.pathname !== RESOURCE.pathname) return new Response("not found", { status: 404 });    try {      const authInfo = await verifyBearerToken(req.headers.get("authorization"), {        verifier,        expectedResource: RESOURCE,           // aud phải khớp, nếu không 401        requiredScopes: ["mcp:tools"],        // thiếu scope này: 403 insufficient_scope        resourceMetadataUrl: prmUrl,          // đi vào WWW-Authenticate      });      return handler.fetch(req, { authInfo });    } catch (e) {      return bearerAuthChallengeResponse(e, { requiredScopes: ["mcp:tools"], resourceMetadataUrl: prmUrl });    }  };}export const remoteKeys = () => createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

Đã chạy (Node 24, @modelcontextprotocol/server 2.3, jose 6, khoá RSA tự sinh, gọi fetchHandler(new Request(...)) trực tiếp, không mở cổng, không có AS thật). Kết quả quan sát: không token cho 401 với WWW-Authenticate: Bearer error="invalid_token", ..., resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"; token sai aud, sai iss, hết hạn đều cho 401; token thiếu mcp:tools cho 403 insufficient_scope; token có mcp:tools nhưng thiếu orders:refund cho 200 với isError: true; token đủ scope cho PENDING ORD-1002 by user-42; /.well-known/oauth-protected-resource/mcp trả resource và authorization_servers. Chưa chạy với một AS thật (Keycloak, Auth0...) và chưa chạy một MCP client làm đủ luồng PKCE.

Mối đe doạ cần nhớ (trang Security Best Practices của spec):

  • Confused deputy: server MCP làm proxy tới API bên thứ ba dùng một client ID chung có thể bị lợi dụng để lấy code cho client khác. Chặn bằng consent riêng cho từng client và so redirect_uri khớp tuyệt đối.
  • Token passthrough: nhận token không cấp cho mình rồi đưa thẳng cho API phía sau. Cấm; nếu cần gọi API khác, xin token riêng (cho chính server) từ AS.
  • SSRF: client có thể bị dụ lấy URL do server từ xa chỉ định (metadata, AS). Chặn IP riêng và link-local (như 169.254.169.254), đòi HTTPS, kiểm cả bước redirect.
  • State handle hijacking: spec 2026-07-28 không còn session, nên nếu server tự sinh "handle" (id giỏ hàng, id job) và trả về trong kết quả tool, phải gắn handle với user đã xác minh và kiểm khi dùng lại; nếu không, ai đoán được handle là thao tác được trên dữ liệu người khác.
  • Server cục bộ độc hại và scope quá rộng: cấp scope tối thiểu cho từng tool (không *), chạy server cục bộ trong sandbox.

Pitfall thực tế. (1) Kiểm chữ ký mà quên aud: token cấp cho một server khác vẫn qua. (2) Cho jose tự theo thuật toán trong header token thay vì chốt algorithms. (3) Tin userId do model điền thay vì lấy từ authInfo. (4) Gom mọi thứ vào một scope "admin" cho tiện. (5) Gửi token trong query string vì "dễ debug": nó vào log proxy và lịch sử.


10. Orchestration & Guardrails#

Định nghĩa. Tập cơ chế kiểm soát agent để nó an toàn và trong ngân sách: giới hạn số bước, timeout, chi phí (token/tiền), human-in-the-loop cho hành động nguy hiểm, và sandbox cho code/tool rủi ro.

Tại sao quan trọng. Agent tự quyết định → có thể quyết định sai với hậu quả thật: xoá dữ liệu, charge tiền, gửi email nhầm, loop cháy budget. Guardrails là ranh giới giữa "trợ lý hữu ích" và "sự cố production". Ví dụ một guardrail điển hình: agent chỉ được tạo ticket ở trạng thái draft, không có tool nào chuyển sang approved — con người mới bấm nút.

Cơ chế.

  • Max steps: chặn loop vô hạn (mục 3).
  • Timeout: mỗi tool + toàn run có deadline; treo tool không được block mãi.
  • Budget: đếm token/tiền, dừng khi vượt ngưỡng.
  • Human-in-the-loop (HITL): tool nguy hiểm (xoá, chi tiền, gửi mail) không tự chạy — trả về "cần xác nhận", chờ người duyệt rồi mới thực thi.
  • Sandbox: chạy code/tool không tin cậy trong môi trường cô lập (container, VM) — không cho đụng filesystem/network thật.
  • Allowlist quyền: agent chỉ được gọi tập tool được phép cho task đó.

Ví dụ code (HITL cho hành động phá huỷ: tool chỉ tạo yêu cầu, không có đường tự xoá).

typescriptReady
// actor = user thật, lấy từ session/token của request, KHÔNG phải tham số của toolexport const makeDeleteUser = (db: Db, actor: { id: string }) => tool({  description: "Yêu cầu xoá user. Chỉ tạo yêu cầu; người có quyền duyệt ở nơi khác.",  inputSchema: z.object({ targetId: z.string().uuid() }),  execute: async ({ targetId }) => {    const approvalId = await requestApproval(db, {      tool: "deleteUser", args: { targetId }, userId: actor.id, ttlMs: 3_600_000, now: new Date(),    });    return { status: "NEEDS_CONFIRMATION", approvalId };      // chưa xoá gì cả  },});

requestApproval và phần duyệt/thực thi nằm ở mục 10b; đã type-check tsc --strict cùng approvals.ts.

Pitfall thực tế. (1) HITL "giả": thêm tham số confirmed: boolean vào schema rồi chỉ hỏi model "gọi lại với confirmed=true" — model tự set true là xong, không có người thật. Xác nhận nguy hiểm phải chặn ở con người/hệ thống, không phải tin field do model điền; tool của agent không được có đường nào dẫn tới hành động thật khi chưa có người duyệt. (2) Không timeout tool gọi API ngoài → một tool treo khoá cả run. (3) Chạy code do LLM sinh thẳng trên máy prod không sandbox → RCE.


10b. HITL đúng: trạng thái duyệt nằm trong DB#

Định nghĩa. Human-in-the-loop đúng nghĩa là trạng thái duyệt là dữ liệu của hệ thống: một dòng trong DB với state machine, người duyệt, hạn dùng và đúng những args đã được duyệt. Model chỉ đề nghị; việc chạy do hệ thống nhận (claim) sau khi dòng đó chuyển sang APPROVED.

Tại sao quan trọng. Mỗi cách làm tắt có một lỗ hổng cụ thể: field confirmed do model điền (model tự set true); một Map trong bộ nhớ (mất khi restart, mỗi instance một bản); người yêu cầu tự bấm duyệt; args bị đổi sau khi duyệt; hai worker cùng chạy một yêu cầu; process chết sau khi gọi provider nhưng trước khi ghi DONE. Tài liệu AI SDK còn nhắc một điểm khác: với mẫu useChat, server dựng lại hội thoại từ message client gửi lên, nên một tool-approval-response trong lịch sử không tự chứng minh là người thật đã duyệt.

Cơ chế.

textReady
 PENDING --approve (người khác, chưa hết hạn)--> APPROVED                                                    |              claim: UPDATE ... WHERE state='APPROVED' (một worker thắng)                                                    v                          DONE <--run + provider_key-- EXECUTING                                                    |                                       args_hash lệch -> DENIED
  • Mỗi bước chuyển trạng thái là một câu UPDATE ... WHERE state = ... RETURNING: kiểm tra và ghi cùng lúc, không có khoảng hở để hai request cùng thắng. Không đọc state rồi mới ghi (read-then-write).
  • args_json lưu lúc tạo yêu cầu; lúc chạy lấy từ DB và so args_hash, không lấy từ model hay client.
  • provider_key (idempotency key gửi sang provider) tạo sẵn lúc tạo yêu cầu: nếu process chết giữa chừng, job nền chạy lại dòng EXECUTING quá hạn với cùng key, provider dedupe nên tiền chỉ đi một lần (GĐ09 mục 14, GĐ10 mục 3).

Ví dụ code (Postgres; $1 là tham số, giá trị now truyền từ code để test được).

typescriptReady
// approvals.tsimport { createHash, randomUUID } from "node:crypto";// Giao diện DB tối thiểu: Postgres (pg) hoặc bất kỳ driver nào trả mảng hàngexport type Db = { query<T = any>(sql: string, params: unknown[]): Promise<T[]> };export const SCHEMA = `CREATE TABLE approvals (  id           uuid PRIMARY KEY,  tool         text NOT NULL,  args_json    text NOT NULL,            -- args được duyệt, lưu lúc tạo  args_hash    text NOT NULL,            -- sha256(tool + args_json)  requested_by text NOT NULL,            -- user thật, lấy từ session  state        text NOT NULL DEFAULT 'PENDING',  approved_by  text,  claimed_at   timestamptz,  expires_at   timestamptz NOT NULL,  provider_key text NOT NULL UNIQUE      -- idempotency key gửi sang provider)`;const hashOf = (tool: string, argsJson: string) =>  createHash("sha256").update(tool).update("\0").update(argsJson).digest("hex");export async function requestApproval(db: Db, a: { tool: string; args: object; userId: string; ttlMs: number; now: Date }) {  const id = randomUUID();  const argsJson = JSON.stringify(a.args);  await db.query(    `INSERT INTO approvals (id, tool, args_json, args_hash, requested_by, expires_at, provider_key)     VALUES ($1, $2, $3, $4, $5, $6, $7)`,    [id, a.tool, argsJson, hashOf(a.tool, argsJson), a.userId,     new Date(a.now.getTime() + a.ttlMs).toISOString(), `appr-${id}`],  );  return id;}// Chuyển PENDING -> APPROVED trong MỘT câu UPDATE: không có khoảng hở giữa kiểm tra và ghiexport async function approve(db: Db, id: string, approverId: string, now: Date) {  const rows = await db.query(    `UPDATE approvals SET state = 'APPROVED', approved_by = $2      WHERE id = $1 AND state = 'PENDING'        AND requested_by <> $2          -- người yêu cầu không tự duyệt        AND expires_at > $3      RETURNING id`,    [id, approverId, now.toISOString()],  );  return rows.length === 1 ? "approved" : "rejected";}type Run = (tool: string, args: any, providerKey: string) => Promise<unknown>;// Hệ thống (không phải model) nhận quyền chạy: APPROVED -> EXECUTING, đúng một worker thắngexport async function executeApproved(db: Db, id: string, now: Date, run: Run) {  const claimed = await db.query<{ tool: string; args_json: string; args_hash: string; provider_key: string }>(    `UPDATE approvals SET state = 'EXECUTING', claimed_at = $2      WHERE id = $1 AND state = 'APPROVED' AND expires_at > $2   -- duyệt xong để quá hạn thì không chạy nữa     RETURNING tool, args_json, args_hash, provider_key`,    [id, now.toISOString()],  );  if (claimed.length === 0) return { status: "not_claimable" };  return finish(db, id, claimed[0], run);}async function finish(  db: Db, id: string, c: { tool: string; args_json: string; args_hash: string; provider_key: string }, run: Run,) {  if (hashOf(c.tool, c.args_json) !== c.args_hash) {      // dữ liệu bị sửa sau khi duyệt    await db.query(`UPDATE approvals SET state = 'DENIED' WHERE id = $1`, [id]);    return { status: "tampered" };  }  const out = await run(c.tool, JSON.parse(c.args_json), c.provider_key);  await db.query(`UPDATE approvals SET state = 'DONE' WHERE id = $1 AND state = 'EXECUTING'`, [id]);  return { status: "done", out };}// Job nền: EXECUTING kẹt (process chết giữa chừng) thì chạy lại với CÙNG provider_key.// Nhận dòng kẹt bằng MỘT câu UPDATE (đẩy claimed_at lên now): hai job redrive không nhận cùng một dòngexport async function redriveStuck(db: Db, now: Date, staleMs: number, run: Run) {  const rows = await db.query<{ id: string; tool: string; args_json: string; args_hash: string; provider_key: string }>(    `UPDATE approvals SET claimed_at = $2      WHERE state = 'EXECUTING' AND claimed_at < $1      RETURNING id, tool, args_json, args_hash, provider_key`,    [new Date(now.getTime() - staleMs).toISOString(), now.toISOString()],  );  for (const r of rows) await finish(db, r.id, r, run);  return rows.length;}

Đã chạy logic này trên node:sqlite (SQLite thay Postgres, adapter đổi $1 thành ?1, kiểu uuid/timestamptz thành text; chưa chạy trên PostgreSQL thật, nên chưa kiểm RETURNING và hành vi khoá hàng của Postgres). Quan sát: người yêu cầu tự duyệt bị từ chối; duyệt sau hạn bị từ chối; hai lần duyệt thì lần hai bị từ chối; hai executeApproved đồng thời cho done và not_claimable (provider thấy một key); sửa args_json sau khi duyệt cho tampered; mô phỏng crash sau khi gọi provider thì dòng kẹt ở EXECUTING, redriveStuck chạy lại với cùng key và ra DONE, provider vẫn chỉ có một key cho dòng đó. Sau khi sửa claim: duyệt rồi để quá expires_at thì executeApproved trả not_claimable, provider 0 lần gọi; hai lần redriveStuck liên tiếp trên một dòng kẹt thì lần đầu nhận 1 dòng, lần hai nhận 0 (claimed_at đã bị đẩy lên). Hai worker thật sự song song trên PostgreSQL chưa chạy: SQLite một kết nối chỉ chứng minh thứ tự, không chứng minh khoá hàng.

Nối với AI SDK. ToolLoopAgent có sẵn toolApproval để dừng run chờ người duyệt, và experimental_toolApprovalSecret ký HMAC gắn chặt tên tool, call id và args (sai chữ ký thì bị từ chối trước khi tool chạy):

typescriptReady
const agent = new ToolLoopAgent({  model, tools: { refundOrder },  toolApproval: { refundOrder: "user-approval" },          // run dừng, trả tool-approval-request  experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,   // >= 32 byte, cùng giá trị ở mọi instance});

Đã chạy với model giả trên ai 7.0.127: kết quả có tool-call rồi tool-approval-request, execute chưa được gọi. Đây là cơ chế dừng/tiếp tục run cho UI chat. Với việc chi tiền hay xoá dữ liệu vẫn nên lưu quyết định ở bảng approvals như trên, vì approval trong lịch sử message do client giữ, còn bảng thì do hệ thống kiểm soát.

Pitfall thực tế. (1) Duyệt xong chạy ngay trong request của người duyệt: timeout giữa chừng là mất trạng thái; hãy claim rồi chạy bằng worker/queue. (2) Quên requested_by <> approver hoặc hết hạn: người duyệt "cho chính mình" hoặc duyệt một yêu cầu đã cũ. (3) Dùng confirmed làm "xác nhận lần hai" cho tiện: đây là HITL giả (mục 10). (4) provider_key sinh lại mỗi lần retry thay vì lưu sẵn: retry thành charge hai lần.


10c. Timeout ca run và budget token#

Định nghĩa. Hai lớp chặn ngoài isStepCount: timeout (thời gian: cả run, từng bước, từng tool) và budget (token hoặc tiền tích luỹ qua các bước). stopWhen nhận một mảng điều kiện và dừng khi điều kiện nào đúng trước.

Tại sao quan trọng. isStepCount(10) vẫn cho 10 bước, mỗi bước có thể là một tool treo hoặc một câu trả lời dài. Chặn theo bước không chặn được thời gian chờ của người dùng lẫn hoá đơn.

Cơ chế. AI SDK 7 nhận timeout: number | { totalMs, stepMs, toolMs, tools: { <tên tool>Ms } } (streaming thêm firstChunkMs, chunkMs). Hết totalMs thì run bị huỷ (ném TimeoutError); hết toolMs thì tool nhận abortSignal và kết quả là tool-error, loop vẫn chạy tiếp. Budget không có sẵn dưới dạng tuỳ chọn: tự viết một StopCondition, hàm nhận { steps } và trả true để dừng.

typescriptReady
// budget.tsimport { generateText, isStepCount, tool, type StopCondition, type ToolSet } from "ai";import { z } from "zod";import { scriptedModel } from "./mock-model.ts";// dừng khi tổng token các bước đã chạy >= max (kiểm tra SAU mỗi bước)export const tokenBudget = (max: number): StopCondition<ToolSet> =>  ({ steps }) => steps.reduce((sum, s) => sum + (s.usage.totalTokens ?? 0), 0) >= max;const slow = tool({  description: "Tool chậm",  inputSchema: z.object({ orderId: z.string() }),  execute: async (_a, { abortSignal }) => {    await new Promise((res, rej) => {      const t = setTimeout(res, 1500);      abortSignal?.addEventListener("abort", () => { clearTimeout(t); rej(abortSignal.reason); });    });    return { ok: true };  },});export async function runBudgeted(prompt: string, maxTokens: number) {  return generateText({    model: scriptedModel("stuck"),    tools: { getOrderStatus: slow },    stopWhen: [isStepCount(10), tokenBudget(maxTokens)],   // dừng khi điều kiện nào đúng trước    timeout: { totalMs: 20_000, toolMs: 200 },              // cả run, và từng tool    prompt,  });}

Đã chạy (Node 24, ai 7.0.127, model giả luôn gọi tool chậm hơn toolMs, 52 token mỗi bước): với budget 150 token, run dừng sau 3 bước (156 token), finishReason: tool-calls, không có text cuối, mỗi tool bị cắt ở 200 ms (tool-error, "The operation was aborted due to timeout"), tổng khoảng 620 ms. Với timeout: { totalMs: 300 } và tool chậm 1500 ms, generateText ném TimeoutError sau khoảng 300 ms (không trả kết quả), nên code gọi phải bắt lỗi này.

Lưu ý khi dùng.

  • StopCondition chạy sau mỗi bước nên có thể vượt budget một bước: đặt ngưỡng thấp hơn ngưỡng thật, và đặt maxOutputTokens để một bước không tự nó đốt hết.
  • Tool chỉ bị cắt thật khi nó tôn trọng abortSignal (như execute ở trên, hoặc truyền abortSignal vào fetch); nếu không, việc vẫn chạy ngầm.
  • Chạm budget không phải thành công: log một sự kiện riêng, trả cho người dùng câu "chưa xong" thay vì text rỗng.
  • Quy token ra tiền bằng bảng giá lấy từ cấu hình, vì giá đổi (GĐ25 mục 4 có ví dụ trừ quota).

11. Observability cho Agent#

Định nghĩa. Khả năng quan sát những gì agent làm: trace từng bước (mỗi tool call, args, kết quả, token dùng, latency, chi phí), gom thành một cây theo run. Công cụ: Langfuse, OpenTelemetry (chuẩn tracing chung), LangSmith…

Tại sao agent khó debug (và cần observability). Agent không tất định: cùng input, hai lần chạy có thể khác đường đi. Lỗi có thể ở: prompt, schema tool, kết quả tool, hay quyết định của model. Không có trace, bạn chỉ thấy "output cuối sai" mà không biết bước nào hỏng. Trace biến hộp đen thành hộp kính.

Cơ chế. Mỗi run = một trace. Mỗi bước = một span (LLM call span, tool call span) lồng nhau, ghi: input, output, tokens, latency, cost, lỗi. Bạn xem lại "phim quay chậm" của agent: nó reason gì, gọi tool nào, tool trả gì, vì sao rẽ nhánh đó.

Ví dụ code (trace thủ công + Langfuse ý niệm).

typescriptReady
const trace = langfuse.trace({ name: "support-agent", input: userMsg });for (const call of res.toolCalls) {  const span = trace.span({ name: call.name, input: call.args });  const t0 = Date.now();  const result = await tools[call.name](call.args);  span.end({ output: result, metadata: { ms: Date.now() - t0 } });   // latency mỗi tool}trace.update({ output: finalText, usage: res.usage });               // tokens/cost cả run

(Vercel AI SDK có tuỳ chọn telemetry xuất OpenTelemetry để Langfuse nuốt tự động; ở v7 tên cũ experimental_telemetry còn là alias deprecated.)

Pitfall thực tế. (1) Chỉ log output cuối → mù hoàn toàn khi lỗi nằm ở bước giữa. (2) Log cả PII/secret vào trace không mask → rò rỉ. (3) Không đo token/cost per run → phát hiện cháy tiền qua hoá đơn cuối tháng thay vì qua dashboard. Bật observability trước khi lên production, không phải sau khi có sự cố.


11b. Đánh giá agent (agent evals)#

Định nghĩa. Eval cho agent là một bộ case cố định, chạy lặp N lần mỗi case, chấm bằng code trên trace (tool nào được gọi, theo thứ tự nào) và tác dụng phụ (sổ cái, DB), rồi so tỉ lệ đạt với một ngưỡng. Khác unit test: cùng input, hai lần chạy có thể đi hai đường.

Tại sao quan trọng. Đổi một dòng description của tool hay đổi model có thể làm agent chọn sai tool mà không lỗi nào báo. Một lần chạy xanh không chứng minh gì; tỉ lệ đạt trên nhiều lần chạy, nối vào CI, mới chặn được hồi quy. Mục này là bản rút gọn cho agent; phần đo chất lượng câu trả lời LLM nói chung ở GĐ22 mục 11.

Cơ chế (bốn quy tắc).

  1. Chấm hành vi, không chấm câu chữ: tool đầu tiên là gì, có gọi tool cấm không, refundLedger còn rỗng không.
  2. Lặp mỗi case N lần; pass rate = số lần đạt / N.
  3. Ngưỡng theo mức rủi ro: case an toàn (không chi tiền khi chưa duyệt) đòi 100%, case chọn tool đòi thấp hơn.
  4. Trả mã thoát khác 0 khi có case dưới ngưỡng để CI dừng lại (GĐ13 mục 14).

Ví dụ code (dùng lại tools.ts và scriptedModel ở Khung dùng chung).

typescriptReady
// agent-evals.tsimport { generateText, isStepCount, type LanguageModel } from "ai";import { searchDocs, getOrderStatus, refundOrder, refundLedger } from "./tools.ts";type Run = { tools: string[]; text: string; ledgerRows: number };export type EvalCase = { name: string; prompt: string; check: (r: Run) => string | null };  // null = đạtexport const cases: EvalCase[] = [  { name: "hỏi chính sách -> searchDocs, không gọi API đơn",    prompt: "Chính sách hoàn tiền thế nào?",    check: (r) => r.tools[0] !== "searchDocs" ? `tool đầu là ${r.tools[0]}`      : r.tools.includes("getOrderStatus") ? "gọi nhầm getOrderStatus" : null },  { name: "hỏi trạng thái đơn -> getOrderStatus",    prompt: "Đơn ORD-1001 tới đâu rồi?",    check: (r) => r.tools[0] === "getOrderStatus" ? null : `tool đầu là ${r.tools[0]}` },  { name: "yêu cầu hoàn tiền -> chỉ tạo yêu cầu, sổ cái vẫn rỗng",    prompt: "Hoàn tiền cho đơn ORD-1002",    check: (r) => r.ledgerRows !== 0 ? "tiền đã đi khi chưa ai duyệt" : r.tools.includes("refundOrder") ? null : "không gọi refundOrder" },];// Chạy mỗi case N lần: agent không tất định nên 1 lần xanh không chứng minh gìexport async function runEvals(makeModel: (i: number) => LanguageModel, runs: number, minPassRate: number) {  let failed = false;  for (const c of cases) {    let pass = 0;    const reasons: string[] = [];    for (let i = 0; i < runs; i++) {      refundLedger.length = 0;      const r = await generateText({        model: makeModel(i), tools: { searchDocs, getOrderStatus, refundOrder },        stopWhen: isStepCount(6), prompt: c.prompt,      });      const run: Run = {        tools: r.steps.flatMap((s) => s.toolCalls.map((t) => t.toolName)),        text: r.text, ledgerRows: refundLedger.length,      };      const why = c.check(run);      if (why === null) pass++; else reasons.push(why);    }    const rate = pass / runs;    console.log(`${rate >= minPassRate ? "PASS" : "FAIL"} ${(rate * 100).toFixed(0)}%  ${c.name}`, reasons.slice(0, 1));    if (rate < minPassRate) failed = true;  }  return failed ? 1 : 0;}

Đã chạy (Node 24, ai 7.0.127, model giả): với scriptedModel() (luôn chọn đúng) 3 case đều PASS 100% sau 8 lần. Với model giả "hay sai" cứ 4 lần có 1 lần luôn gọi searchDocs, hai case cuối còn 75%, dưới ngưỡng 90% nên runEvals trả mã 1; case chính sách vẫn 100% vì lần sai ấy tình cờ trùng đáp án. Điều này kiểm chứng bộ máy chấm, không phải chất lượng của một model thật; với model thật, chưa có số trong tài liệu này, hãy đo trên trace của chính bạn.

Pitfall thực tế. (1) Chấm theo từ khoá trong câu trả lời: mong manh và bỏ lọt tác dụng phụ. (2) Chỉ chạy một lần mỗi case. (3) Ngưỡng 100% cho mọi case: CI đỏ ngẫu nhiên, đội tắt eval. (4) Không ghim phiên bản model/prompt cùng với kết quả, nên không biết thay đổi nào làm tụt tỉ lệ. (5) Eval gọi tool thật có tác dụng phụ: dùng tool giả hoặc DB cô lập.


12. Multi-Agent (tóm tắt)#

Định nghĩa. Thay vì một agent làm tất, chia thành nhiều agent chuyên môn, thường có một orchestrator (điều phối) giao việc cho các sub-agent (researcher, coder, reviewer…) rồi tổng hợp.

Tại sao / khi nào chia. Chia khi: (a) task tách được thành sub-task rõ ràng, ít phụ thuộc lẫn nhau (chạy song song được); (b) mỗi phần cần "persona"/tool set khác hẳn; (c) một agent với 40 tool trở nên lú (quá nhiều lựa chọn → chọn sai). Chia giúp mỗi agent context gọn, tool ít, chuyên biệt → chính xác hơn.

Cơ chế. Orchestrator nhận mục tiêu → phân rã thành sub-task → spawn sub-agent (mỗi cái là một agent loop riêng, tool riêng) → thu kết quả → tổng hợp/ra quyết định. Ví dụ: orchestrator giao "tóm tắt tài liệu A" cho một sub-agent chỉ có tool đọc, kèm định dạng đầu ra cố định và tiêu chí xong rõ ràng; sub-agent không có tool ghi nên không thể làm hỏng gì ngoài phần được giao.

Ví dụ (khái niệm).

typescriptReady
const research = await runAgent({ role: "researcher", tools: [search], task });const draft    = await runAgent({ role: "writer", tools: [], task: `Viết dựa trên: ${research}` });// orchestrator tổng hợp research → draft

Pitfall thực tế. Multi-agent thường bị lạm dụng: chia nhỏ khi một agent là đủ → tăng độ phức tạp, latency, chi phí, và lỗi "tam sao thất bản" khi truyền context giữa các agent. Quy tắc: mặc định một agent; chỉ multi-agent khi có lý do rõ (song song thật, tách persona, tool quá nhiều). File ownership giữa agent phải rõ để tránh ghi đè nhau.


Thực hành#

Mục tiêu: viết một agent có 2–3 tool, chạy được vòng lặp thật, có guardrail và trace.

  1. Scaffold (Node/TS, Node ≥22): npm i ai zod @ai-sdk/openai (hoặc anthropic). Tạo agent.ts.

    Đáp án

    Dùng npm i ai zod (cộng @ai-sdk/openai hoặc @ai-sdk/anthropic khi có key). Chạy thẳng node agent.ts bằng type stripping của Node 24 (import dùng đuôi .ts). Khi chưa có key, dùng model giả MockLanguageModelV4 từ ai/test như ở Khung và mã dùng chung.

  2. Tool 1 — search RAG (long-term memory): một hàm searchDocs(q) embed câu hỏi, query vector store (pgvector/local), trả top-k đoạn text. (Mục 5.)

    Đáp án

    Dùng searchDocs ở Khung dùng chung. Bản đã chạy dùng chấm điểm từ khoá trên 3 tài liệu thay cho vector search; khi có embedding thật, thay thân hàm bằng embed + truy vấn pgvector và giữ nguyên schema và dạng kết quả { hits } hoặc { hits: [], note }.

  3. Tool 2 — call API: getOrderStatus(orderId) gọi REST API thật (hoặc mock trả JSON), validate orderId bằng zod. (Mục 2, 4.)

    Đáp án

    Dùng getOrderStatus ở Khung dùng chung: orderId validate bằng regex ^ORD-\d{4}$, bọc withTimeout, không thấy đơn thì trả { error } có hướng dẫn. Kiểm tra: orderId: "1001" cho tool-error và vòng lặp vẫn chạy tiếp, không throw.

  4. Tool 3 — action có guardrail: refundOrder(orderId, confirmed) — nếu !confirmed trả NEEDS_CONFIRMATION; có idempotencyKey; log ra stderr. (Mục 4, 10.)

    Đáp án

    Dùng refundOrder và approveRefund ở Khung dùng chung. Tool không nhận confirmed từ model; nó tạo yêu cầu PENDING và trả NEEDS_CONFIRMATION, có idempotencyKey, log ra stderr. Kết quả mong đợi: refundLedger rỗng sau agent, có 1 dòng sau khi approveRefund chạy, lần hai trả already_done.

  5. Loop: dùng generateText({ tools, stopWhen: isStepCount(6) }) (hoặc tự viết loop mục 3). Đặt giới hạn bước, timeout mỗi tool.

    Đáp án

    Dùng generateText với stopWhen: isStepCount(6) và bọc tool gọi API bằng withTimeout như run.ts và tools.ts ở Khung dùng chung. Kiểm tra hai chốt chặn: cho tool luôn lỗi và đặt isStepCount(4) thì phải thấy đúng 4 bước, finishReason: tool-calls; cho tool chậm hơn timeout thì nhận { error: "Tool quá ...ms" } và agent vẫn kết thúc bình thường. Promise.race không huỷ việc đang chạy phía sau, nên với HTTP thật hãy truyền thêm AbortSignal.timeout(ms) vào fetch.

  6. Observability: log mỗi bước (tool name, args, ms, tokens) — thủ công hoặc Langfuse. (Mục 11.)

    Đáp án

    Dùng onStepFinish như đoạn run.ts ở Khung dùng chung để ghi tên tool, args, kết quả rút gọn, token, thời gian luỹ kế mỗi bước, rồi console.table(trace). Che PII trước khi ghi.

  7. Test 3 kịch bản: (a) câu hỏi cần RAG, (b) câu hỏi cần call API, (c) yêu cầu refund → phải dừng ở NEEDS_CONFIRMATION, không tự chạy.

    Đáp án

    Chạy node run.ts 2>tool.log. Mong đợi: (a) "Chính sách hoàn tiền thế nào?" gọi searchDocs rồi trích đoạn d1; (b) "Đơn ORD-1001 tới đâu rồi?" gọi getOrderStatus, trả SHIPPED; (c) "Hoàn tiền cho đơn ORD-1002" dừng ở NEEDS_CONFIRMATION, sổ cái rỗng. Đã quan sát với model giả; với model thật, chọn tool đúng phụ thuộc description (mục 4).

  8. (Optional) MCP server nhỏ: tách 1 tool (vd getOrderStatus) thành MCP server stdio (mục 9), cắm vào Claude Desktop, gọi thử từ chat. Nhớ: log ra stderr, absolute path trong config.

    Đáp án

    Dùng order-mcp-server.ts ở Khung dùng chung (cuối khối). Kiểm bằng script JSON-RPC thủ công trước khi cắm host: tools/call đúng kết quả, mọi dòng stdout parse được JSON. Trong config host dùng đường dẫn tuyệt đối. Chưa cắm Claude Desktop thật.

  9. MCP qua HTTP có token: dựng mcp-auth.ts (mục 9b), tự sinh khoá RSA và ký JWT bằng jose, rồi gọi fetchHandler với các token: không có, sai aud, sai iss, hết hạn, thiếu mcp:tools, có mcp:tools nhưng thiếu orders:refund, đủ scope. Ghi status và header.

    Đáp án

    Dùng generateKeyPair("RS256"), exportJWK (thêm kid và alg), createLocalJWKSet({ keys: [jwk] }) làm keys cho createApp, và new SignJWT({ scope, client_id }).setProtectedHeader({ alg: "RS256", kid }).setIssuer(...).setAudience(...).setSubject(...).setExpirationTime(...).sign(privateKey). Kết quả mong đợi, đã quan sát: bốn trường hợp đầu cho 401 (không token: WWW-Authenticate có resource_metadata trỏ tới /.well-known/oauth-protected-resource/mcp); thiếu mcp:tools cho 403 insufficient_scope; thiếu orders:refund cho 200 với isError: true; đủ scope cho 200 và PENDING ORD-1002 by user-42. Nếu sai aud lại qua được, bạn đã quên audience hoặc expectedResource. Chưa kiểm với authorization server thật.

  10. HITL qua DB: tạo bảng approvals (mục 10b) và viết năm test: người yêu cầu tự duyệt, duyệt sau hạn, hai executeApproved đồng thời, sửa args_json sau khi duyệt, crash sau khi gọi provider rồi redriveStuck.

    Đáp án

    Kết quả mong đợi, đã quan sát trên node:sqlite: tự duyệt và duyệt quá hạn đều rejected; hai lần thực thi đồng thời cho đúng một done và một not_claimable, provider chỉ thấy một provider_key; sửa args cho tampered và dòng chuyển DENIED; sau crash dòng kẹt ở EXECUTING, redriveStuck xử lý 1 dòng, thành DONE, không có key thứ hai. Để chạy trên Postgres, thay Db bằng adapter { query: async (s, p) => (await pool.query(s, p)).rows } và dùng Promise.all với hai kết nối khác nhau cho test đồng thời; phần Postgres này chưa chạy.

  11. Budget và eval gate: thêm tokenBudget(150) vào stopWhen của agent chạy tool treo, rồi chạy runEvals hai lần (model đúng, model hay sai) và làm tiến trình thoát mã 1 khi có case dưới ngưỡng.

    Đáp án

    Budget: với 52 token mỗi bước, run dừng sau 3 bước (156 token), finishReason: tool-calls; đặt timeout: { totalMs } nhỏ hơn thời gian tool thì generateText ném TimeoutError, nên bọc try/catch. Eval: model đúng cho ba dòng PASS 100%; model hay sai (1/4 lần gọi nhầm searchDocs) cho FAIL 75% ở hai case cuối. Cuối script đặt process.exitCode = await runEvals(...) để CI đỏ khi có case dưới ngưỡng. Kết quả chỉ chứng minh bộ máy chấm với model giả.

Khung và mã dùng chung

Đã chạy: Node 24.21, ai 7.0.127, zod 4.6, @modelcontextprotocol/server 2.3, với model giả MockLanguageModelV4 (từ ai/test) vì không có API key; chưa gọi LLM thật. Model giả chỉ thay phần "quyết định"; toàn bộ tool, validate, stopWhen, timeout và trace là code thật.

Hướng làm

  1. Viết 3 tool (searchDocs, getOrderStatus, refundOrder) với zod chặt, lỗi trả về dạng dữ liệu { error }, log ra stderr.
  2. refundOrder không nhận confirmed từ model: nó chỉ tạo yêu cầu PENDING và trả NEEDS_CONFIRMATION. Hàm approveRefund(approvalId, humanId) nằm ngoài tập tool, do người/hệ thống duyệt gọi (mục 10, "HITL giả").
  3. Bọc tool gọi API bằng timeout (Promise.race), và đặt stopWhen: isStepCount(6).
  4. Trace bằng onStepFinish: mỗi bước ghi tool, args, kết quả, token, thời gian luỹ kế.
  5. Test 3 kịch bản + 2 kịch bản lỗi (tool luôn lỗi, tool treo).

Sơ đồ (vòng lặp và điểm chặn)

textReady
 user ──► generateText ──► model ──► tool-call? ──không──► text cuối (dừng 1)              ▲                        │có              │                  zod validate args              │                   │sai        │đúng              │              tool-error     execute (timeout)              │              (dữ liệu)         │              │                   └──────┬─────┘              │                          ▼              └──── tool result vào history (observe)   bước thứ N = isStepCount(N) ──► dừng cưỡng bức (dừng 2)   refundOrder ──► PENDING + NEEDS_CONFIRMATION ──► (ngoài agent) người duyệt                                                     approveRefund() ──► sổ cái

Code tham chiếu (rút gọn: mỗi tool tách riêng; bản đã chạy gom 3 tool trong makeTools({ toolTimeoutMs }) để test đổi timeout. Bản rút gọn này đã được chạy lại và type-check tsc --strict: ba kịch bản (a)(b)(c), duyệt hai lần, tool luôn lỗi, args sai kiểu. Riêng kịch bản tool treo chưa chạy lại với bản rút gọn vì nó cần toolTimeoutMs. approvals ở đây là Map trong bộ nhớ, chỉ để demo; bản dùng DB ở mục 10b.)

typescriptReady
// tools.tsimport { tool } from "ai";import { z } from "zod";// Dữ liệu giả lập: kho tài liệu "long-term memory" và API đơn hàngconst DOCS = [  { id: "d1", text: "Chính sách hoàn tiền: hoàn tiền trong 7 ngày kể từ ngày nhận hàng." },  { id: "d2", text: "Thời gian giao hàng nội thành là 2 ngày, ngoại tỉnh là 5 ngày." },  { id: "d3", text: "Bảo hành thiết bị điện tử 12 tháng, không bảo hành rơi vỡ." },];const ORDERS: Record<string, { status: string; total: number }> = {  "ORD-1001": { status: "SHIPPED", total: 250000 },  "ORD-1002": { status: "DELIVERED", total: 990000 },};export const refundLedger: { orderId: string; approvedBy: string }[] = [];export const approvals = new Map<string, { orderId: string; state: "PENDING" | "DONE" }>();export function withTimeout<A, R>(ms: number, fn: (a: A) => Promise<R>) {  return async (a: A): Promise<R | { error: string }> => {    let timer: NodeJS.Timeout;    const t = new Promise<{ error: string }>((res) => {      timer = setTimeout(() => res({ error: `Tool quá ${ms}ms, hãy thử lại sau.` }), ms);    });    try { return await Promise.race([fn(a), t]); } finally { clearTimeout(timer!); }  };}// Chấm điểm theo từ khoá, thay cho embed + vector search khi chưa có keyexport const searchDocs = tool({  description: "Tìm trong tài liệu nội bộ (chính sách, giao hàng, bảo hành). KHÔNG dùng cho trạng thái đơn.",  inputSchema: z.object({ query: z.string().min(2).max(200) }),  execute: async ({ query }) => {    const words = query.toLowerCase().split(/\s+/);    const hits = DOCS      .map((d) => ({ ...d, score: words.filter((w) => d.text.toLowerCase().includes(w)).length }))      .filter((d) => d.score > 0).sort((a, b) => b.score - a.score).slice(0, 2)      .map(({ id, text }) => ({ id, text }));    console.error("[tool] searchDocs", query, "->", hits.map((h) => h.id));   // stderr    return hits.length ? { hits } : { hits: [], note: "không có đoạn phù hợp" };  },});export const getOrderStatus = tool({  description: "Tra trạng thái đơn theo mã ORD-xxxx qua API. KHÔNG suy từ tài liệu.",  inputSchema: z.object({ orderId: z.string().regex(/^ORD-\d{4}$/) }),  execute: withTimeout(2000, async ({ orderId }) => {    const o = ORDERS[orderId];                           // thay bằng API/DB thật    console.error("[tool] getOrderStatus", orderId, "->", o?.status ?? "NOT_FOUND");   // stderr    return o ? { orderId, ...o } : { error: `Không thấy đơn ${orderId}. Mã dạng ORD-1234.` };  }),});export const refundOrder = tool({  description: "Yêu cầu hoàn tiền. Chỉ tạo yêu cầu, người thật duyệt ở nơi khác.",  inputSchema: z.object({ orderId: z.string().regex(/^ORD-\d{4}$/), idempotencyKey: z.string().min(8) }),  execute: async ({ orderId, idempotencyKey }) => {    if (!approvals.has(idempotencyKey)) approvals.set(idempotencyKey, { orderId, state: "PENDING" });    console.error("[tool] refundOrder", orderId, "-> NEEDS_CONFIRMATION", idempotencyKey);   // stderr    return { status: "NEEDS_CONFIRMATION", approvalId: idempotencyKey };  },});// KHÔNG đưa vào tập tool của agentexport function approveRefund(approvalId: string, humanId: string) {  const a = approvals.get(approvalId);  if (!a) return { error: "approval không tồn tại" };  if (a.state === "DONE") return { status: "already_done" };   // idempotent  a.state = "DONE";  refundLedger.push({ orderId: a.orderId, approvedBy: humanId });  return { status: "refunded" };}

Model giả (scriptedModel): quyết định theo prompt và tool result đã có, thay cho LLM khi chưa có key. Đã chạy lại cùng các kịch bản ở trên.

typescriptReady
// mock-model.tsimport { MockLanguageModelV4 } from "ai/test";const usage = (i: number, o: number) => ({  inputTokens: { total: i, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },  outputTokens: { total: o, text: undefined, reasoning: undefined },});const call = (name: string, input: object, id: string) => ({  content: [{ type: "tool-call" as const, toolCallId: id, toolName: name, input: JSON.stringify(input) }],  finishReason: { unified: "tool-calls" as const, raw: "tool_calls" }, usage: usage(40, 12), warnings: [],});const text = (t: string) => ({  content: [{ type: "text" as const, text: t }],  finishReason: { unified: "stop" as const, raw: "stop" }, usage: usage(60, 20), warnings: [],});export function scriptedModel(mode: "normal" | "stuck" = "normal") {  let n = 0;  return new MockLanguageModelV4({    doGenerate: async (o: any) => {      const user = o.prompt.find((m: any) => m.role === "user")!.content.map((p: any) => p.text).join(" ");      const results = o.prompt.filter((m: any) => m.role === "tool").flatMap((m: any) => m.content);      const last = results.at(-1)?.output?.value;      n++;      if (mode === "stuck") return call("getOrderStatus", { orderId: "ORD-9999" }, `s${n}`);      const id = user.match(/ORD-\d{4}/)?.[0];      if (!results.length) {        if (/hoàn tiền cho đơn/i.test(user) && id)          return call("refundOrder", { orderId: id, idempotencyKey: `refund-${id}-user1` }, "c1");        return id ? call("getOrderStatus", { orderId: id }, "c1") : call("searchDocs", { query: user }, "c1");      }      if (last?.status === "NEEDS_CONFIRMATION") return text("Yêu cầu hoàn tiền đang chờ nhân viên duyệt.");      if (last?.hits) return text(`Theo tài liệu: ${last.hits[0]?.text ?? "không tìm thấy"}`);      if (last?.status) return text(`Đơn ${last.orderId} đang ở trạng thái ${last.status}.`);      return text(`Lỗi: ${last?.error ?? "không rõ"}`);    },  });}
typescriptReady
// run.ts: loop + trace (thay `scriptedModel()` bằng model thật khi có key)import { generateText, isStepCount } from "ai";import { searchDocs, getOrderStatus, refundOrder } from "./tools.ts";import { scriptedModel } from "./mock-model.ts";const model = scriptedModel();const prompt = "Chính sách hoàn tiền thế nào?";const trace: object[] = [];const t0 = Date.now();const r = await generateText({  model, tools: { searchDocs, getOrderStatus, refundOrder },  stopWhen: isStepCount(6), prompt,  onStepFinish: (s) => void trace.push({    step: trace.length + 1,    tools: s.toolCalls.map((c) => `${c.toolName}(${JSON.stringify(c.input)})`),    results: s.toolResults.map((x) => JSON.stringify(x.output).slice(0, 70)),    tokens: s.usage.totalTokens, ms: Date.now() - t0,  }),});console.table(trace);

Chạy trực tiếp bằng Node 24 (type stripping): node run.ts 2>tool.log (import dùng đuôi .ts, chỉ cú pháp erasable).

Kết quả mong đợi (đã quan sát với model giả)

  • (a) "Chính sách hoàn tiền thế nào?": 2 bước, bước 1 gọi searchDocs, câu trả lời trích đoạn d1; (b) "Đơn ORD-1001 tới đâu rồi?": 2 bước, getOrderStatus trả SHIPPED qua API; (c) "Hoàn tiền cho đơn ORD-1002": 2 bước, kết quả NEEDS_CONFIRMATION, refundLedger.length === 0.
  • Sau khi gọi approveRefund(..., "staff-7") lần 1: refunded; lần 2: already_done; sổ cái có đúng 1 dòng.
  • Tool luôn lỗi, isStepCount(4): đúng 4 bước, finishReason: tool-calls, không có text cuối (loop bị cắt cưỡng bức, không vô hạn).
  • Tool treo (chậm hơn 200 ms; bản đã chạy truyền toolTimeoutMs: 200 để thử nhanh, còn withTimeout(2000, ...) ở code tham chiếu là mặc định): kết quả { error: "Tool quá 200ms..." } sau khoảng 201 ms, agent vẫn kết thúc bình thường ở bước 2.
  • Args sai kiểu (orderId: "1001"): SDK không throw, sinh tool-error rồi vòng lặp tiếp tục, model thấy lỗi ở bước sau.
  • Log tool ra stderr (2>file) nên không lẫn với output.

Lỗi hay gặp

  • Để confirmed: boolean trong schema do model điền: model tự set true. Xác nhận phải đi qua người/hệ thống (mục 10).
  • Mock model chọn tool theo regex nên không chứng minh model thật "chọn đúng tool". Với model thật, bước (a)(b)(c) phụ thuộc description tool (mục 4); chạy lại 3 kịch bản và đọc trace.
  • Quên stopWhen: loop dừng theo mặc định của SDK (không như mong đợi) hoặc cháy token. Luôn đặt tường minh.
  • Timeout bằng Promise.race không huỷ việc đang chạy phía sau; với HTTP thật hãy truyền thêm AbortSignal.timeout(ms) vào fetch.
  • Log token/args chứa PII không mask (mục 11).

Phần tuỳ chọn: MCP server stdio. Chuyển getOrderStatus sang order-mcp-server.ts (mẫu mục 9: McpServer, registerTool, serveStdio(createServer); lỗi nghiệp vụ trả { isError: true, content: [...] }).

typescriptReady
// order-mcp-server.ts (rút gọn)import { McpServer } from "@modelcontextprotocol/server";import { serveStdio } from "@modelcontextprotocol/server/stdio";import { z } from "zod";const ORDERS: Record<string, string> = { "ORD-1001": "SHIPPED", "ORD-1002": "DELIVERED" };function createServer() {  const server = new McpServer({ name: "order-tools", version: "1.0.0" });  server.registerTool(    "getOrderStatus",    { description: "Tra trạng thái đơn theo mã ORD-xxxx", inputSchema: z.object({ orderId: z.string().regex(/^ORD-\d{4}$/) }) },    async ({ orderId }) => {      console.error("[mcp]", orderId);                       // stderr, không phải stdout      const s = ORDERS[orderId];      return s        ? { content: [{ type: "text", text: JSON.stringify({ orderId, status: s }) }] }        : { isError: true, content: [{ type: "text", text: `Không thấy đơn ${orderId}` }] };    },  );  return server;}serveStdio(createServer);

Cách kiểm không cần host: một script spawn node order-mcp-server.ts, gửi mỗi dòng một JSON-RPC (initialize + notifications/initialized cho kiểu cũ, hoặc server/discover với _meta mang io.modelcontextprotocol/protocolVersion: "2026-07-28" cho kiểu mới), rồi tools/call. Đã quan sát: cả hai kiểu đều trả {"orderId":"ORD-1001","status":"SHIPPED"}; đơn không tồn tại trả isError: true; mọi dòng stdout parse được JSON. Thêm một console.log("started") ở đầu file thì client thấy một dòng stdout không phải JSON (client thử của tôi bỏ qua dòng đó; host nghiêm ngặt có thể ngắt kết nối, đó là suy luận). Chưa cắm vào Claude Desktop thật.

Done khi#

  • Agent chạy loop reason→act→observe, tự chọn đúng tool cho mỗi trong 3 kịch bản.

    Đáp án

    Nhìn trace: ở mỗi kịch bản bước 1 phải là tool đúng (RAG: searchDocs; trạng thái đơn: getOrderStatus; refund: refundOrder), bước cuối là text. Với model thật, tool chọn sai gần như luôn do description mơ hồ: sửa mô tả (khi nào dùng, khi nào KHÔNG) trước khi sửa prompt. Xem mục 3 và mục 4.

  • Mỗi tool có description rõ, params validate bằng zod, trả lỗi dạng dữ liệu (không throw ra loop).

    Đáp án

    Tự kiểm: gọi tool với orderId sai định dạng và với mã không tồn tại; cả hai phải ra tool-error hoặc { error: "..." } có hướng dẫn sửa, loop vẫn chạy tiếp, không có exception thoát khỏi generateText. Sai thường gặp: throw trong execute, hoặc return {} khi lỗi (model tưởng thành công). Mục 4.

  • Giới hạn bước (stopWhen) + timeout hoạt động: chứng minh loop dừng khi tool luôn lỗi (không cháy vô hạn).

    Đáp án

    Cho tool luôn trả lỗi, đặt isStepCount(4): phải thấy đúng 4 bước rồi dừng, finishReason là tool-calls. Cho tool chậm hơn timeout: phải nhận lỗi Tool quá ...ms trong vài trăm ms. Giới hạn chỉ bảo vệ khi bạn đã chứng minh nó cắt được trường hợp xấu. Mục 3 và 10.

  • Hành động nguy hiểm (refund) không tự chạy — chặn ở NEEDS_CONFIRMATION / human approval.

    Đáp án

    Sau scenario (c), sổ cái hoàn tiền phải rỗng; chỉ sau khi hàm duyệt (ngoài tập tool) chạy mới có 1 dòng, gọi lần hai không thêm dòng. Sai thường gặp: cho model điền confirmed=true. Mục 10.

  • Có trace: đọc lại được từng bước (tool, args, kết quả, latency, token) của một run.

    Đáp án

    Mỗi bước có: tên tool, args, kết quả rút gọn, token, thời gian. Tự kiểm: chọn một run, chỉ ra bước nào đưa ra quyết định sai mà không phải chạy lại. Che PII trước khi ghi. Mục 11.

  • RAG tool truy hồi đúng đoạn liên quan; dữ liệu chính xác (order status) lấy qua API, KHÔNG qua vector.

    Đáp án

    Hỏi chính sách phải trích đúng đoạn tài liệu; hỏi trạng thái đơn phải gọi getOrderStatus, kết quả đến từ API/DB chứ không từ kho vector. Test đối chứng: sửa trạng thái đơn trong nguồn, hỏi lại, câu trả lời phải đổi ngay (vector không đổi). Mục 5.

  • (Optional) MCP server expose được ≥1 tool, host gọi vào chạy đúng, không phá stdout.

    Đáp án

    Chạy client thử bằng JSON-RPC thủ công: tools/call trả đúng kết quả, và mỗi dòng stdout parse được JSON (log chỉ ra stderr). Rồi mới cắm vào host với đường dẫn tuyệt đối. Mục 8 và 9.

  • Giải thích được: khi nào một agent là đủ vs khi nào cần multi-agent (không lạm dụng).

    Đáp án

    Mặc định một agent. Chỉ tách khi có ít nhất một lý do kiểm chứng được: sub-task độc lập chạy song song thật, persona/tool set khác hẳn, hoặc một agent có quá nhiều tool (khoảng hàng chục) khiến chọn sai. Cái giá: thêm latency, tiền, và mất ngữ cảnh khi truyền giữa agent. Mục 12.

  • Vòng lặp tự viết xử lý đúng nhiều tool call trong một lượt: một message assistant, mỗi call một message tool, args sai trả { error }.

    Đáp án

    Cho model giả trả ba call một lượt (một đúng, một sai định dạng, một tên tool không có) và in lịch sử bước sau: phải là user, assistant[3], tool, tool, tool với toolCallId khớp thứ tự, hai call lỗi chứa error, vòng lặp không throw. Mục 3.

  • Server MCP qua HTTP từ chối token sai aud/iss/hết hạn (401) và token thiếu scope (403), challenge có resource_metadata.

    Đáp án

    Chạy sáu token ở bài thực hành 9 và đối chiếu status. Tự kiểm: giải thích vì sao chỉ kiểm chữ ký chưa đủ (token cấp cho server khác vẫn hợp lệ về chữ ký) và vì sao server không được chuyển token này sang API phía sau. Mục 9b.

  • Hành động nguy hiểm đi qua bảng approvals: không ai duyệt thì không chạy, người yêu cầu không tự duyệt, chạy hai lần chỉ một tác dụng.

    Đáp án

    Năm test ở bài thực hành 10 đều qua, và chỉ ra được câu UPDATE ... WHERE state = ... nào chặn từng lỗi. Sai thường gặp: đọc state rồi mới ghi, sinh provider_key mới ở mỗi lần retry. Mục 10b.

  • Một run bị chặn bởi budget token và bởi timeout cả run; chạm budget được ghi nhận như "chưa xong", không như thành công.

    Đáp án

    Test: tool luôn chạy thêm bước, budget thấp, run dừng với finishReason: tool-calls và không có text cuối; code gọi trả thông báo "chưa xong" và log sự kiện. Với totalMs nhỏ, bắt được TimeoutError. Mục 10c.

  • Bộ eval 3 case chạy lặp N lần và làm CI đỏ khi pass rate dưới ngưỡng; chấm trên trace và sổ cái, không chấm câu chữ.

    Đáp án

    Giả lập một model hay sai và thấy FAIL với tỉ lệ dưới ngưỡng, mã thoát khác 0; case an toàn (sổ cái rỗng khi chưa duyệt) đặt ngưỡng 100%. Mục 11b.


Câu hỏi chưa chốt (tự trả lời khi thực hành)#

  • Vector store nào cho long-term memory: pgvector (đã có Postgres) hay managed (Pinecone)?

    Hướng trả lời hiện tại (suy luận, không khẳng định)

    Nếu đã có PostgreSQL thì bắt đầu với pgvector để giữ một nơi backup, một lớp phân quyền (RLS) và join được với dữ liệu quan hệ; chuyển sang managed khi số vector hoặc QPS vượt khả năng của DB hiện có. Ngưỡng cụ thể phải đo, tài liệu này chưa có số.

  • Provider model: OpenAI hay Anthropic? Ảnh hưởng cú pháp tool calling nhẹ, SDK che phần lớn.

    Hướng trả lời hiện tại (suy luận, không khẳng định)

    Bắt đầu với một provider, đặt model sau một lớp mỏng (model là tham số) để đổi được. Khác biệt nằm ở chất lượng chọn tool, giá và giới hạn; thử cùng 3 kịch bản trên cả hai rồi so trace.

  • Ngưỡng budget cụ thể (token/USD mỗi run) cho môi trường của bạn là bao nhiêu?

    Hướng trả lời hiện tại (suy luận, không khẳng định)

    Đặt theo phép tính ngược. Giá bán trừ biên lợi nhuận mong muốn cho ra chi phí LLM tối đa mỗi người dùng, chia cho số run dự kiến, rồi quy ra token mỗi run; dùng isStepCount và giới hạn token đầu ra làm chặn cứng, theo dõi p95 token mỗi run trên trace rồi chỉnh.