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
ai7.x (Node ≥22, ESM-only): vòng lặp điều khiển bằngstopWhen: isStepCount(n)(stepCountIslà alias), tool khai báo bằnginputSchema;generateObject/streamObjectdeprecated (chưa xoá) →generateText({ output }). MCP: revision hiện hành 2026-07-28 (bỏinitializevà session); TS SDK@modelcontextprotocol/server2.x là dòng ổn định cho spec mới,@modelcontextprotocol/sdk1.x vẫn là npmlatestnhưng thuộc spec 2025-11-25. Ví dụ trong file đã type-check vớiai7.0 +zod4 +@modelcontextprotocol/server2.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,resourceRFC 8707, kiểm audience, 401 cho token sai, không token passthrough, token không đi trong query string; 403insufficient_scopevà 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óiai7.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).
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).
- Bạn gửi messages + danh sách tool schemas cho model.
- Model trả về một tool call request:
{ name: "getWeather", args: { city: "Hanoi" } }(chưa chạy gì cả — chỉ là "ý định"). - Backend nhận request, validate args, thực thi function thật.
- Backend feed kết quả trở lại model như một message có role
tool. - Model đọc kết quả → sinh text cuối, hoặc gọi tool tiếp.
Ví dụ code (Vercel AI SDK + zod).
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).
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).
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).
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).
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.stopWhenlo 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).
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):
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/initializedvà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ó RPCserver/discoverđể khai báo phiên bản, capabilities, danh tính. Thông báo thay đổi (list tool đổi…) đi quasubscriptions/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).
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).
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).
Đã 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):
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).
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.
Đã 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_urikhớ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á).
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ế.
- 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_jsonlưu lúc tạo yêu cầu; lúc chạy lấy từ DB và soargs_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òngEXECUTINGquá 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).
Đã 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):
Đã 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.
Đã 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.
StopConditionchạ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à đặtmaxOutputTokensđể 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ềnabortSignalvàofetch); 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).
(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).
- 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,
refundLedgercòn rỗng không. - Lặp mỗi case N lần;
pass rate = số lần đạt / N. - 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.
- 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).
Đã 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).
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.
-
Scaffold (Node/TS, Node ≥22):
npm i ai zod @ai-sdk/openai(hoặc anthropic). Tạoagent.ts.Đáp án
Dùng
npm i ai zod(cộng@ai-sdk/openaihoặc@ai-sdk/anthropickhi có key). Chạy thẳngnode agent.tsbằng type stripping của Node 24 (import dùng đuôi.ts). Khi chưa có key, dùng model giảMockLanguageModelV4từai/testnhư ở Khung và mã dùng chung. -
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 }. -
Tool 2 — call API:
getOrderStatus(orderId)gọi REST API thật (hoặc mock trả JSON), validateorderIdbằng zod. (Mục 2, 4.)Đáp án
Dùng
getOrderStatusở Khung dùng chung:orderIdvalidate bằng regex^ORD-\d{4}$, bọcwithTimeout, không thấy đơn thì trả{ error }có hướng dẫn. Kiểm tra:orderId: "1001"chotool-errorvà vòng lặp vẫn chạy tiếp, không throw. -
Tool 3 — action có guardrail:
refundOrder(orderId, confirmed)— nếu!confirmedtrảNEEDS_CONFIRMATION; cóidempotencyKey; log ra stderr. (Mục 4, 10.)Đáp án
Dùng
refundOrdervàapproveRefundở Khung dùng chung. Tool không nhậnconfirmedtừ model; nó tạo yêu cầuPENDINGvà trảNEEDS_CONFIRMATION, cóidempotencyKey, log ra stderr. Kết quả mong đợi:refundLedgerrỗng sau agent, có 1 dòng sau khiapproveRefundchạy, lần hai trảalready_done. -
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
generateTextvớistopWhen: isStepCount(6)và bọc tool gọi API bằngwithTimeoutnhưrun.tsvàtools.tsở Khung dùng chung. Kiểm tra hai chốt chặn: cho tool luôn lỗi và đặtisStepCount(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.racekhông huỷ việc đang chạy phía sau, nên với HTTP thật hãy truyền thêmAbortSignal.timeout(ms)vàofetch. -
Observability: log mỗi bước (tool name, args, ms, tokens) — thủ công hoặc Langfuse. (Mục 11.)
Đáp án
Dùng
onStepFinishnhư đoạnrun.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ồiconsole.table(trace). Che PII trước khi ghi. -
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ọisearchDocsrồi trích đoạn d1; (b) "Đơn ORD-1001 tới đâu rồi?" gọigetOrderStatus, 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). -
(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. -
MCP qua HTTP có token: dựng
mcp-auth.ts(mục 9b), tự sinh khoá RSA và ký JWT bằngjose, rồi gọifetchHandlervới các token: không có, saiaud, saiiss, hết hạn, thiếumcp:tools, cómcp:toolsnhưng thiếuorders:refund, đủ scope. Ghi status và header.Đáp án
Dùng
generateKeyPair("RS256"),exportJWK(thêmkidvàalg),createLocalJWKSet({ keys: [jwk] })làmkeyschocreateApp, 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-Authenticatecóresource_metadatatrỏ tới/.well-known/oauth-protected-resource/mcp); thiếumcp:toolscho 403insufficient_scope; thiếuorders:refundcho 200 vớiisError: true; đủ scope cho 200 vàPENDING ORD-1002 by user-42. Nếusai audlại qua được, bạn đã quênaudiencehoặcexpectedResource. Chưa kiểm với authorization server thật. -
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, haiexecuteApprovedđồng thời, sửaargs_jsonsau khi duyệt, crash sau khi gọi provider rồiredriveStuck.Đáp án
Kết quả mong đợi, đã quan sát trên
node:sqlite: tự duyệt và duyệt quá hạn đềurejected; hai lần thực thi đồng thời cho đúng mộtdonevà mộtnot_claimable, provider chỉ thấy mộtprovider_key; sửa args chotamperedvà dòng chuyểnDENIED; sau crash dòng kẹt ởEXECUTING,redriveStuckxử lý 1 dòng, thànhDONE, không có key thứ hai. Để chạy trên Postgres, thayDbbằng adapter{ query: async (s, p) => (await pool.query(s, p)).rows }và dùngPromise.allvới hai kết nối khác nhau cho test đồng thời; phần Postgres này chưa chạy. -
Budget và eval gate: thêm
tokenBudget(150)vàostopWhencủa agent chạy tool treo, rồi chạyrunEvalshai 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; đặttimeout: { totalMs }nhỏ hơn thời gian tool thìgenerateTextnémTimeoutError, nên bọctry/catch. Eval: model đúng cho ba dòngPASS 100%; model hay sai (1/4 lần gọi nhầmsearchDocs) choFAIL 75%ở hai case cuối. Cuối script đặtprocess.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
- 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. refundOrderkhông nhậnconfirmedtừ model: nó chỉ tạo yêu cầuPENDINGvà trảNEEDS_CONFIRMATION. HàmapproveRefund(approvalId, humanId)nằm ngoài tập tool, do người/hệ thống duyệt gọi (mục 10, "HITL giả").- Bọc tool gọi API bằng timeout (
Promise.race), và đặtstopWhen: isStepCount(6). - Trace bằng
onStepFinish: mỗi bước ghi tool, args, kết quả, token, thời gian luỹ kế. - 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)
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.)
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.
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,getOrderStatustrảSHIPPEDqua 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ònwithTimeout(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, sinhtool-errorrồ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: booleantrong schema do model điền: model tự settrue. 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.racekhông huỷ việc đang chạy phía sau; với HTTP thật hãy truyền thêmAbortSignal.timeout(ms)vàofetch. - 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: [...] }).
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 dodescriptionmơ 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
orderIdsai định dạng và với mã không tồn tại; cả hai phải ratool-errorhoặc{ error: "..." }có hướng dẫn sửa, loop vẫn chạy tiếp, không có exception thoát khỏigenerateText. Sai thường gặp:throwtrongexecute, hoặcreturn {}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,finishReasonlàtool-calls. Cho tool chậm hơn timeout: phải nhận lỗiTool quá ...mstrong 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/calltrả đú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, toolvớitoolCallIdkhớp thứ tự, hai call lỗi chứaerror, 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, sinhprovider_keymớ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-callsvà không có text cuối; code gọi trả thông báo "chưa xong" và log sự kiện. VớitotalMsnhỏ, bắt đượcTimeoutError. 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
FAILvớ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 (
modellà 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
isStepCountvà 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.