拆.
工具——AI 怎么'做事' · 第 02

一次工具调用的完整流程

从 LLM 发起到结果回流的 5 阶段

4400读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:tool call 的 5 阶段

上一章我们看了 tool calling 协议。这一章我们看一次实际的 tool 调用——从 LLM 发起请求到结果回到 LLM 之间发生了什么。

一次 tool 调用看似简单——LLM 说"我要读文件",agent 读了把内容回去。但实际上中间有 5 个明确阶段,每个阶段都是设计点。

阶段 1:LLM 决定要调工具

LLM 在生成 response 时,根据当前对话内容判断"接下来该做什么"。如果它判断"应该读文件",它输出一个 tool_use block 而不是纯文本。

这个判断完全发生在 LLM 内部——agent 看不到决策过程。但LLM 的判断质量取决于几件事

  • tool 的 description 写得好不好(LLM 知道什么时候用这个 tool)
  • 当前对话历史是不是清晰(LLM 知道任务进展到哪了)
  • 模型本身的能力(推理强的模型决策更好)

阶段 2:Agent 接收 tool 请求 + 参数校验

Agent 拿到 tool_use block 后做几件事:

  • 验证 tool name 是不是合法(在注册的 tool 清单里)
  • 验证参数 schema(必填字段是不是都有、类型是不是对)
  • 解析参数(把 JSON 字符串变成结构化数据)

如果校验失败——返回错误给 LLM,不实际执行。错误信息要让 LLM 知道"哪里错了、怎么改"——不是"invalid params"那种泛泛的错。

阶段 3:权限检查

某些 tool 调用涉及"危险操作"——bash 跑命令可能改系统、edit 改文件用户可能不期望、webfetch 调外部 API 可能花钱。

opencode 在执行 tool 前会检查权限:

  • 这个工具在当前 mode 下允许吗(plan mode 禁所有写操作)
  • 当前 agent 配置里允许这个工具吗(subagent 可能禁某些 tool)
  • 是不是需要弹窗问用户("ask" 状态)

如果权限拒绝——返回 RejectedError 给 LLM,让 LLM 知道"用户不让我做这件事"。如果权限是 "ask" 状态——主循环暂停,等用户审批(这就是 Permission 系统的精妙处,第 7 篇会展开)。

阶段 4:实际执行

权限过了开始真正执行 tool:

  • read 调 fs.readFile
  • bash 启动 child_process
  • webfetch 发 HTTP 请求
  • task 派生子 agent

执行过程中可能:

  • 成功完成——拿到结果
  • 失败抛错——网络问题、文件不存在、命令 exit 非 0
  • 超时——bash 命令跑太久
  • 被中断——用户按 Ctrl+C

每种情况都要正确处理——成功的结果要包装、失败的要给清晰错误信息、超时要明确说"超时了,建议传更大 timeout"、中断要标记 interrupted: true

阶段 5:结果回流给 LLM

执行结果要包装成 LLM 能消化的格式塞回 messages:

  • 成功 → text content
  • 失败 → error message
  • 中断 → "Tool execution aborted"
  • 部分成功 → 已完成部分 + 警告

特别要注意——结果格式直接影响 LLM 下一步的决策。如果错误信息含糊"操作失败",LLM 会陷入盲目重试。如果错误信息明确"找到了 3 个匹配,请加更多上下文让匹配唯一",LLM 知道下一步怎么做。

5 个阶段加起来构成一次完整的 tool 调用周期。每个阶段都是 AI 产品设计的着力点。

二、案例:opencode processor.ts 的 tool 处理链

我们看 opencode 怎么在代码层把这 5 个阶段串起来。核心代码在 packages/opencode/src/session/processor.ts

opencode 的 tool 处理是个流式状态机。LLM 通过 SSE 流回响应——文本 chunk 和 tool_use chunk 混在一起。processor.ts 边收边处理。

我们看 tool 处理的关键代码段(简化版):

case "tool-result": {
  const toolCall = yield* readToolCall(value.id)
  if (!toolCall && value.result.type === "error") return
  if (value.result.type === "error") {
    yield* failToolCall(value.id, value.result.value)
    return
  }
  const rawOutput = toolResultOutput(value)
  // ... attachment processing ...
  yield* completeToolCall(value.id, output)
  return
}

case "tool-error": {
  const toolCall = yield* readToolCall(value.id)
  yield* failToolCall(value.id, value.error ?? new Error(value.message))
  return
}

注意几个细节:

细节 1:每个 tool call 有个独立的 state 对象——processor 维护一个 toolCallId → state 的 map。每次 tool_use 启动时创建 state(pending → running)、收到 result 时更新(completed/error/interrupted)。

细节 2:用 failToolCall 统一处理失败——不管是 schema 校验失败、权限拒绝、执行错误,都进入这个函数。它会更新 state 为 error、发布 ToolFailed 事件、把错误信息塞回 message 给 LLM。

细节 3:tool 执行是 forked fiber——每个 tool 调用被 fork 到独立 fiber。多个 tool call 可以并行执行(如果它们之间无依赖)。这跟 2.6 章讲的 fork 模式呼应。

细节 4:结果包装做了"附件分离"——大的结果(比如读了一个 5000 行文件)会拆成两部分:text content(给 LLM 看的、可能截断)+ attachment(完整保存到数据库供 UI 展示)。这样 LLM context 不爆、UI 还能看完整内容。

我们看一个具体的 read tool 调用例子。

假设 LLM 决定调用 read 工具读 src/main.py

Time 0:00.000  
LLM 输出 tool_use block:
{ name: "read", input: { file_path: "src/main.py" } }
toolCallId = "tool_001"

Time 0:00.005  
processor 收到 tool_use chunk
→ 创建 toolCall state (id=tool_001, status=pending)
→ 发布 Tool.Input.Started 事件
→ UI 显示 "Reading src/main.py..."

Time 0:00.010  
processor 完成 input 解析
→ 验证 schema (file_path 是 string ✓)
→ status: pending → running
→ 发布 Tool.Called 事件

Time 0:00.015  
权限检查
→ 当前 mode 是 build, read 允许
→ 检查 .opencode/permission.json 里有没有 deny "src/main.py" 这个文件 — 没有
→ 权限通过

Time 0:00.018  
fork tool execution fiber
→ 调用 packages/opencode/src/tool/read.ts 的实现
→ fs.readFile("src/main.py")

Time 0:00.025  
读取完成
→ 处理输出(加行号前缀、判断是否截断、生成 metadata)
→ output = "  1: import sys\n  2: \n  3: def main():\n..."

Time 0:00.026  
processor 完成 tool 执行
→ status: running → completed
→ 发布 Tool.Success 事件
→ 把 output 塞回 messages(作为 tool_result)

Time 0:00.027  
fiber 结束,主循环继续
→ LLM 看到 tool_result,继续生成下一步

整个过程不到 30 毫秒(读小文件)。如果文件大,read 实现本身可能慢一些。但 processor 的开销稳定——状态管理、事件发布、错误处理。

这个流程是 opencode 的"日常"——每个 session 跑几十次甚至几百次。它必须极致可靠——任何一步出问题都会破坏用户体验。

三、设计启示:观察 tool 调用能学到的设计选择

这一章的核心论点:每个 tool 调用都是一次完整的状态机循环——5 个阶段、几十种事件、多种异常路径都要正确处理

如果你做 AI 产品有自定义 tool,下面几条原则有用:

1. 显式状态机

每个 tool 调用应该有明确的 state:pending、running、completed、error、interrupted。状态转换要显式——updateStatus(toolCallId, "running")。不要让状态隐式存在变量里。

2. 状态变化要发事件

每次状态变化都通过事件总线发布。UI 端订阅事件实时更新——"正在读文件" → "已读完" → "失败重试中"。事件让用户感知"系统在做什么"。

3. 错误分类要清晰

至少要区分:

  • 参数错误(schema 校验失败)— 给 LLM 看的错误,LLM 改参数重试
  • 权限拒绝(permission denied)— 给 LLM 看的错误,LLM 知道用户不许
  • 执行错误(tool 实现里抛的)— 包装后给 LLM
  • 网络错误(HTTP 失败)— 自动重试,超过 N 次后给 LLM
  • 超时错误(tool 跑太久)— 终止并告诉 LLM 可以传更大 timeout

每类错误的处理路径不一样。

4. 大输出要分离

LLM 看的 text content 跟 UI 看的 attachment 分开。LLM 看截断版(最多 2000 行),UI 看完整版。这避免大文件输出吃光 context。

5. tool 执行要可中断

每个 tool 实现要支持 AbortSignal。fork 的 fiber 收到 cancel 信号要立刻停。bash 要 kill 子进程、webfetch 要 abort fetch、read 没法中断(读已经几乎同步完成)但小的中断窗口可以接受。

6. tool 调用要可观测

打日志、发指标、记历史。每次 tool 调用记录:name、input(摘要)、输出大小、耗时、是否失败。这是排查 AI 产品问题的关键数据。

7. 并行执行要小心

多个 tool 调用可以并行——如果它们之间没依赖。但要小心:

  • 同时写同一个文件(write 冲突)
  • 同时跑大量 bash(系统负载爆)
  • 同时调用同一个外部 API(rate limit)

要么内部加锁、要么限制并发数。

8. 部分成功要保留

一个 multiedit 改 5 个位置,第 3 个失败了——前 2 个的改动该不该回滚?opencode 的选择是保留前 2 个——partial success 是常态。LLM 看到错误后可以决定下一步(再试改第 3 个 vs 完全 abort)。

最后一个观察。一次 tool 调用的复杂度对应于"AI Agent 工程化"的复杂度。Demo 里你看到的"LLM 一调用 tool 就执行"是简化版——真实生产里要处理 schema 校验、权限、并发、错误、中断、可观测性、部分成功等等。

把这些都做好,你的 AI 产品才能用。做不好的产品 demo 看起来一样,但用半天就崩——因为某个 corner case 没处理。

下一章 3.3 我们看 tool 的"门面"——description。这是给 LLM 看的微型教材。