Series 2 - Bài 1: LLM không gọi tool, nó chỉ đề xuất

Misconception phổ biến nhất về Tool Calling: LLM không execute gì cả. Nó chỉ trả về structured data. Bài này phân tích toàn bộ request/response cycle và tại sao sự phân biệt này quan trọng.

Nghe bài viết này dưới dạng podcast

Khi developer lần đầu làm việc với AI agent, hầu hết đều có cùng mental model: LLM nhìn thấy tool, quyết định dùng tool, và gọi tool đó. Như một function call.

Mental model này sai hoàn toàn — và sự sai này có consequences thực tế trong thiết kế hệ thống.

Cơ chế thực tế

┌────────────────────────────────────────────────────────────────┐
│                   Your Application                            │
│                                                               │
│  Step 1: Gửi request đến LLM                               │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  POST /v1/messages                                     │ │
│  │  {                                                      │ │
│  │    messages: [...],                                    │ │
│  │    tools: [                                            │ │
│  │      {                                                  │ │
│  │        name: "read_file",                              │ │
│  │        description: "Read file content",              │ │
│  │        input_schema: { path: string }                 │ │
│  │      }                                                  │ │
│  │    ]                                                    │ │
│  │  }                                                      │ │
│  └──────────────────────────────────────────────────────┘ │
│                            │                               │
│                            ▼                               │
│  Step 2: LLM quyết định cần read_file                     │
│  LLM trả về:                                               │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  {                                                      │ │
│  │    stop_reason: "tool_use",                            │ │
│  │    content: [{                                          │ │
│  │      type: "tool_use",                                 │ │
│  │      id: "toolu_01ABC",                                │ │
│  │      name: "read_file",                               │ │
│  │      input: { path: "src/loan.service.ts" }           │ │
│  │    }]                                                   │ │
│  │  }                                                      │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                               │
│  Step 3: BẠN thực thi read_file                             │
│    → fs.readFile("src/loan.service.ts")                     │
│    → Kiểm tra quyền, validate path                          │
│    → Đọc file                                               │
│                                                               │
│  Step 4: Gửi tool_result về LLM                             │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  messages: [                                             │ │
│  │    ...previous messages...,                             │ │
│  │    { role: "assistant", content: [tool_use block] },   │ │
│  │    { role: "user", content: [{                         │ │
│  │      type: "tool_result",                              │ │
│  │      tool_use_id: "toolu_01ABC",                       │ │
│  │      content: "[file content here]"                    │ │
│  │    }]},                                                 │ │
│  │  ]                                                      │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                               │
│  Step 5: LLM tiếp tục reasoning với file content            │
└────────────────────────────────────────────────────────────────┘

Ba điểm quan trọng từ cơ chế này

1. LLM không có khả năng execute

LLM chỉ generate text — kể cả khi text đó là JSON structure mô tả một tool call. Nó không có network access, không có file system access, không có process spawning. Toàn bộ execution xảy ra trong application code của bạn.

2. Bạn luôn có control

Giữa step 2 (LLM đề xuất tool) và step 3 (execution), bạn có thể làm bất cứ điều gì:

  • Validate input
  • Check permissions
  • Ask for confirmation
  • Log the request
  • Transform input
  • Reject outright

Không có magic. Không có gì xảy ra mà bạn không explicitly cho phép.

3. Tool result là just text trong messages[]

Khi bạn trả tool result về LLM, bạn chỉ đang append text vào messages[]. LLM không nhận được gì đặc biệt — nó đọc text đó như nó đọc mọi text khác.

Stop Reason là signal quan trọng

LLM response luôn có stop_reason. Hai giá trị bạn cần biết:

stop_reason: "end_turn"
  → LLM có text response, task có thể xong
  → Kiểm tra content có tool_use blocks không
  → Nếu không → return text cho user, kết thúc turn

stop_reason: "tool_use"
  → LLM yêu cầu một hoặc nhiều tools
  → Execute tất cả tools trong content
  → Append results vào messages[]
  → Gọi LLM lại (loop)

Một lỗi phổ biến: chỉ check stop_reason mà không check content. LLM đôi khi trả cả text và tool_use trong cùng một response — bạn phải xử lý cả hai.

Parallel Tool Calls

LLM có thể request nhiều tools trong cùng một response:

LLM response content:
[
  { type: "tool_use", name: "read_file", input: { path: "a.ts" } },
  { type: "tool_use", name: "read_file", input: { path: "b.ts" } },
  { type: "tool_use", name: "read_file", input: { path: "c.ts" } }
]

Đây là optimization mà LLM tự quyết định: thay vì đọc từng file một (3 round trips), nó request cả 3 cùng lúc.

Execut parallel → nhanh hơn:

read_file(a.ts) ─┐
                ├─ đợi tất cả xong ─▶ gửi results về LLM
read_file(b.ts) ─┤
read_file(c.ts) ─┘

Thay vì sequential (3x latency):
read_file(a.ts) ─▶ result ─▶ read_file(b.ts) ─▶ result ─▶ ...

Nếu tools độc lập nhau, execute parallel. Nếu có dependencies, serialize. Application code chịu trách nhiệm quyết định cái này.

Tool_use ID: Tại sao quan trọng

Mỗi tool_use block có một id duy nhất:

{ type: "tool_use", id: "toolu_01ABC", name: "read_file", ... }

Khi trả result, bạn phải reference đúng id này:

{ type: "tool_result", tool_use_id: "toolu_01ABC", content: "..." }

Nếu parallel call mà result dùng sai ID, LLM sẽ match sai file content với sai tool call. Đây là silent failure nguy hiểm — không có error, chỉ có LLM reasoning trên wrong data.

Control flow nằm ở phía bạn: Implications

Vì execution luôn nằm ở application code, bạn có full control:

Security filtering:
  LLM request: write_file(path="/etc/passwd", ...)
  Application: Path ngoài allowed directory → Reject
  Return: { error: "Path not allowed: /etc/passwd" }

Confirmation flow:
  LLM request: delete_file(path="important.ts")
  Application: Destructive tool → Ask user
  User: "No"
  Return: { error: "User denied deletion" }

Transformation:
  LLM request: read_file(path="./config")
  Application: Resolve relative path → "/project/config"
  Execute: readFile("/project/config")

Mocking for testing:
  LLM request: read_file(path="test.ts")
  Test env: Return mocked content instead of real file
  → Test agent behavior mà không cần real files

Trade-off: Control có một cost

Control hoàn toàn cũng có nghĩa là responsibility hoàn toàn. Nếu agent gây ra damage, đó là vì application code của bạn cho phép. LLM không có "free will" — nó được trao khả năng execute thông qua code của bạn.

Cũng có nghĩa là debugging luôn bắt đầu từ code của bạn, không phải từ LLM. Khi agent làm sai, câu hỏi đầu tiên là: "Application code của tôi đã validate và authorize cái gì?"

Kết luận

  • LLM không gọi tool — nó trả về JSON structure đề xuất tool call.
  • Application code thực thi tool và trả kết quả lại cho LLM thông qua messages[].
  • stop_reason: "tool_use" là signal để execute và loop lại.
  • Parallel tool calls là optimization — execute concurrently khi tools độc lập.
  • tool_use_id phải match chính xác khi trả results — sai ID là silent failure.
  • Control nằm ở application code — điều này vừa là sức mạnh vừa là trách nhiệm.

Chưa có bình luận

Để lại bình luận

Bình luận sẽ được phê duyệt trước khi hiển thị.

Nhận bài viết mới

Mình sẽ gửi email khi có bài mới. Không spam.