01. 一次工具调用的完整协议
Agent 的第一块地基是 tool calling。很多教程把它讲成“给模型一个函数列表,模型会选择函数”。这句话没有错,但太粗糙。真正需要理解的是:工具调用是一种消息协议。模型并没有直接执行函数,它只是在 assistant 消息里声明“我想调用这个工具,参数是这些”。你的运行时读取这条声明,执行本地工具,再把结果作为新消息发回模型。
朴素调用为什么不够
普通 LLM 调用的输入是 messages,输出是一段文本。它适合回答“解释这段代码”或“写一个函数”,但不适合回答“帮我修这个仓库里的 bug”。修 bug 需要观察文件、运行命令、根据失败继续修改。模型无法直接访问文件系统,也不能自己运行测试。你必须给它一组受控工具,并在每一步把工具结果重新纳入上下文。
如果把文件内容一次性塞进 prompt,会遇到三个问题:
- 文件太多,超出上下文窗口。
- 模型无法验证自己写的改动是否通过测试。
- 用户无法审计模型到底读了什么、改了什么、执行了什么。
工具协议解决的是“让模型请求行动”,不是“让模型拥有系统权限”。
一次 tool use 往返
一次完整工具调用至少包含四个阶段:
- 你把工具 schema 和当前 messages 发给模型。
- 模型返回 assistant 消息,内容中包含 tool call。
- 运行时按 tool call id 执行对应工具。
- 运行时追加 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 难以可靠渲染。
练习
实现一个只读工具协议检查点:
- 定义
UserMessage、AssistantMessage、ToolResultMessage。 - 定义
read_file的工具 schema。 - 写一个函数接收 assistant 消息,提取所有 tool call。
- 当参数不是
{ path: string }时,返回isError: true的 tool result。 - 用固定 transcript 验证 tool call id 能正确回传。
验收标准:给定一个缺少 path 的 tool call,模型下一轮上下文里能看到一条错误 tool result,而不是运行时直接崩溃。