从零构建 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 };
};

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

这里有几个关键点。toolCallId 必须原样回传,否则模型无法把结果和请求对应起来。isError 不是 UI 装饰,它会告诉模型这次工具调用失败了,应该换一种方式继续。stopReason 是下一步控制流的依据:如果是 toolUse,运行工具;如果是 stop,本轮结束;如果是 length,通常需要压缩或要求模型收束。

工具 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 工程的一部分,但它必须和运行时真实行为一致。否则模型会学到错误的操作方式。

运行观察

下面是一段理想 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。

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

生产化取舍

成熟 Agent 不应该把工具结果原样无限制地塞回模型。工具输出要为模型服务:明确成功或失败,必要时截断,并告诉模型如何继续。grep 输出很多结果时,只返回前 N 条并说明“还有更多,请缩小查询范围”。bash 输出很长时,保留开头和结尾,因为错误常在末尾,命令上下文常在开头。

同时,工具结果和 UI 详情要分开。模型需要简洁文本;用户界面可能需要结构化 diff、耗时、退出码、完整 stdout 路径。把这两者混在一起,会让 prompt 变胖,也会让 UI 难以可靠渲染。

练习

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

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

验收标准:给定一个缺少 path 的 tool call,模型下一轮上下文里能看到一条错误 tool result,而不是运行时直接崩溃。