从零构建 Coding Agent
English
本章目录

01. 一次工具调用的完整协议

Agent 的第一块地基是 tool calling。很多教程把它讲成“给模型一个函数列表,模型会选择函数”。这句话没有错,但太粗糙。真正需要理解的是:工具调用是一种消息协议。模型并没有直接执行函数,它只是在 assistant 消息里声明“我想调用这个工具,参数是这些”。你的运行时读取这条声明,执行本地工具,再把结果作为新消息发回模型。

朴素调用为什么不够

普通 LLM 调用的输入是 messages,输出是一段文本。它适合回答“解释这段代码”或“写一个函数”,但不适合回答“帮我修这个仓库里的 bug”。修 bug 需要观察文件、运行命令、根据失败继续修改。模型无法直接访问文件系统,也不能自己运行测试。你必须给它一组受控工具,并在每一步把工具结果重新纳入上下文。

如果把文件内容一次性塞进 prompt,会遇到三个问题:

  • 文件太多,超出上下文窗口。
  • 模型无法验证自己写的改动是否通过测试。
  • 用户无法审计模型到底读了什么、改了什么、执行了什么。

工具协议解决的是“让模型请求行动”,不是“让模型拥有系统权限”。

一次 tool use 往返

一次完整工具调用至少包含四个阶段:

  1. 你把工具 schema 和当前 messages 发给模型。
  2. 模型返回 assistant 消息,内容中包含 tool call。
  3. 运行时按 tool call id 执行对应工具。
  4. 运行时追加 tool result 消息,再次请求模型继续。

可以把最小消息类型写成这样:

type TextBlock = {
  type: "text";
  text: string;
};

type ToolCallBlock = {
  type: "toolCall";
  id: string;
  name: string;
  input: unknown;
};

type UserMessage = {
  role: "user";
  content: TextBlock[];
};

type AssistantMessage = {
  role: "assistant";
  content: Array<TextBlock | ToolCallBlock>;
  stopReason: "stop" | "toolUse" | "length" | "error" | "aborted";
  model: string;
  usage: { inputTokens: number; outputTokens: number };
  errorMessage?: string;
};

type ToolResultMessage = {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  isError: boolean;
  content: TextBlock[];
};

这里有几个关键点。toolCallId 必须原样回传,否则模型无法把结果和请求对应起来。isError 不是 UI 装饰,它会告诉模型这次工具调用失败了,应该换一种方式继续。真正触发工具执行的是 assistant 消息里的 tool call;stopReason 解释本轮为什么停在这里,并帮助运行时处理没有 tool call 时的状态。它的五个取值各自对应一类语义:

  • toolUse: 模型请求工具,通常会同时出现一个或多个 tool call。
  • stop: 模型认为本轮完成,任务收束或等待用户。
  • length: 输出被 max tokens 截断或上下文超限,通常触发压缩或要求模型收束。
  • error: provider 或网络失败,errorMessage 说明原因,由重试策略决定下一步。
  • aborted: 用户主动中止,运行时要把已收到的部分内容和状态写入日志。

后三种经常被教学实现忽略,但真实系统里它们出现的频率远比想象中高:网络会断,流会中断,用户会按下停止键。协议里没有它们的位置,错误处理就只能靠异常穿透,日志和 UI 都无法解释“刚才发生了什么”。

Tool call id 不是你想的那种 id

不同厂商生成的 tool call id 差异巨大。有的 API 生成超过 450 个字符、包含 | 等特殊符号的 id;有的 API 则要求 id 必须匹配 ^[a-zA-Z0-9_-]+$ 且不超过 64 个字符。只要你的系统支持会话中途切换模型,旧消息里的 tool call id 就可能不被新 provider 接受。

成熟系统的做法是在 provider 边界做 id 归一化:把历史消息里的 id 映射成目标 API 能接受的形式,同时更新对应 tool result 的引用,保持配对关系。教学项目不需要立刻实现它,但协议设计时要记住:tool call id 是“运行时内部的关联键”,不要假设它有任何特定格式,也不要用它承载业务含义。

举个具体例子。会话前半段用模型 A,它给某次 read 生成了一个超长、带 | 的 id;切到只接受 ^[a-zA-Z0-9_-]{1,64}$ 的模型 B 后,adapter 在发请求前必须成对重写:

历史(模型 A 生成,日志里原样保留):
  toolCall   id         = "call_a1b2c3...d9|verbose"
  toolResult toolCallId = "call_a1b2c3...d9|verbose"

发给模型 B 前(adapter 归一化,仅本次请求生效):
  toolCall   id         = "call_0"
  toolResult toolCallId = "call_0"

关键是配对要么一起改、要么都不改。只改 assistant 里的 id 而漏掉对应的 tool result,就会造出一条“有 tool call 却对不上结果”的非法请求,provider 直接拒绝。映射表只活在这一次转换里,日志中始终是原始 id,所以再切回模型 A 也不会丢失关联。

工具 schema 是运行时契约

工具描述一般包括名称、自然语言说明和参数 schema。说明写给模型,schema 写给运行时。模型会读说明来决定何时调用工具;运行时必须用 schema 校验参数,因为模型可能生成缺字段、错类型、路径格式不对或多余字段。

一个 read_file 工具的描述应该同时告诉模型能力和边界:

const readFileTool = {
  name: "read_file",
  description: "Read a UTF-8 text file under the current workspace. Use this before editing a file you have not inspected.",
  parameters: {
    type: "object",
    properties: {
      path: { type: "string", description: "Workspace-relative file path." },
    },
    required: ["path"],
    additionalProperties: false,
  },
};

不要把描述写成“读取文件”。模型需要知道什么时候该用、什么时候不该用、路径如何表达、输出可能有截断。工具描述是 prompt 工程的一部分,但它必须和运行时真实行为一致。否则模型会学到错误的操作方式。

还有一个容易忽略的细节:参数不是模型“调用函数”时传的对象,而是模型逐字生成的 JSON 文本。它可能包含非法转义、未配对的 Unicode 代理对,甚至在流式传输里被截成半个 JSON。运行时在拿到“完整参数”之前,永远把它当成不可信文本。第五章讲流式时会回到这个问题。

运行观察

下面是一段理想 transcript,重点看消息角色,而不是文本内容:

user: 修复 src/math.ts 里的除零 bug
assistant(stopReason=toolUse):
  toolCall read_file { "path": "src/math.ts" }
toolResult(read_file, isError=false):
  export function divide(a: number, b: number) { return a / b; }
assistant(stopReason=toolUse):
  toolCall edit_file { "path": "src/math.ts", "oldText": "...", "newText": "..." }
toolResult(edit_file, isError=false):
  edited src/math.ts
assistant(stopReason=stop):
  已处理除零输入,现在 b 为 0 时会抛出 RangeError。

这段流程里,模型没有“访问文件”。它只提出请求。运行时才是真正的执行者、记录者和权限边界。

另外注意一个提供商层面的硬约束:assistant 消息里的每个 tool call,都必须在后续消息中有对应的 tool result,多数 API 才接受下一次请求。这意味着即使工具执行失败、被权限拒绝或被用户中止,运行时也必须为每个已发出的 tool call 生成一条结果消息(哪怕内容只是“aborted”)。“悄悄跳过失败的工具”会让整个会话无法继续。

生产化取舍

成熟 Agent 不应该把工具结果原样无限制地塞回模型。工具输出要为模型服务:明确成功或失败,必要时截断,并告诉模型如何继续。参考一组真实系统在用的默认值:grep 最多返回 100 条匹配,单行超过 500 字符会被截断;bash 输出保留最后 2000 行或 50KB(错误信息几乎总在末尾);read 则保留开头,因为文件头部通常是导入和类型声明。具体数值可以调整,重要的是每个工具都有明确的截断策略,并在输出里告诉模型“被截断了、如何取回更多”。

同时,工具结果和 UI 详情要分开。协议上可以给 ToolResultMessage 加一个不进入模型上下文的 details 字段:模型看到“替换成功,修改了 2 行”,UI 拿到结构化 diff、耗时、退出码。工具结果的 content 也不必只有文本——支持图片块之后,截图类、绘图类工具可以把图像直接交给具备视觉能力的模型。把这两层混在一起,会让 prompt 变胖,也会让 UI 难以可靠渲染。

最后,给每条消息加上 timestamp,给 assistant 消息保留 model 和 usage。会话是长生命周期数据,三个月后调查“这次任务为什么失败”时,你会需要知道每条回答来自哪个模型、花了多少 token、发生在什么时候。

练习

实现一个只读工具协议检查点:

  • 定义 UserMessageAssistantMessageToolResultMessage,stop reason 覆盖全部五种取值。
  • 定义 read_file 的工具 schema。
  • 写一个函数接收 assistant 消息,提取所有 tool call。
  • 当参数不是 { path: string } 时,返回 isError: true 的 tool result。
  • 用固定 transcript 验证 tool call id 能正确回传。

验收标准:给定一个缺少 path 的 tool call,模型下一轮上下文里能看到一条错误 tool result,而不是运行时直接崩溃;给定一条 stopReason: "aborted" 的 assistant 消息,其中每个 tool call 都有对应的结果消息。