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 };
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、发生在什么时候。
练习
实现一个只读工具协议检查点:
- 定义
UserMessage、AssistantMessage、ToolResultMessage,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 都有对应的结果消息。