3.4 工具调用(Tool Calling)
本章解决什么问题:到上一章为止,我们手里的大语言模型(LLM,Large Language Model)只有一种本事——根据消息历史生成文本。可是「帮我看看这个文件写了什么」「把测试跑一遍」这类要求,光生成文本是完不成的。本章讲清楚业界统一的解法:工具调用(Tool Calling)——一套让模型「请求」、让应用「执行」、再把结果送回模型的消息协议。
前置知识:3.1 LLM 与模型 API(模型 API 的输入输出长什么样)、3.2 消息、上下文与 token(消息历史每轮都要完整重发)、2.3 union、字面量与可辨识联合(内容块与消息都是可辨识联合)、2.1 类型入门(类型只存在于编译期)。
学习目标:读完本章后你能
- 说出工具调用协议里的三种数据——工具声明、Tool Call、工具结果——分别由谁产生、装在哪种消息里;
- 画出一次工具调用的完整往返,并指出「模型其实什么都没执行」这一关键事实;
- 解释为什么一条 assistant 消息里可以同时有文本和工具调用;
- 说明模型生成的参数为什么是不可信数据,以及为什么必须做运行时校验(schema 校验);
- 在 Pi 源码里找到
Tool、ToolCall、ToolResultMessage三个类型定义,逐字段对上本章讲的协议。
建立直觉:一个被关在房间里的顾问
它是什么。想象你雇了一位见多识广的顾问,但他被关在一间没有窗、没有网络的房间里,只有门缝可以塞纸条。你写问题递进去,他写答案递出来。他知识渊博,可你问「我硬盘上那份合同第三条写了什么」,他答不上来——他看不见你的硬盘。
于是你和他约法三章:如果你需要外面的信息,或者需要我替你做事,就在纸条上写一行固定格式的话,比如「请执行:读文件,路径是 contract.md」。你看到这种纸条就出门照做,把结果原样写在新纸条上递回去。他读完结果,接着往下想。
这套约定就是工具调用。房间里的顾问是模型,你是应用(也就是 Agent Harness),纸条是消息,「固定格式的那行话」就是 Tool Call。
为什么需要它。模型的能力边界很清楚:它的知识停在训练截止日期,它读不到你的文件、访问不了网络、算不准大数乘法,更不可能修改任何东西。而 Agent 要做的事——读代码、跑命令、改文件——全部落在这条边界之外。工具调用是把边界外的能力接进来的通道,而且是唯一的通道。
没有它会怎样。模型面对「读一下 config.json」只有两条路:老实说「我做不到」,或者顺着语言模式编一份看起来很像 config.json 的内容出来——后者更常见也更危险。退一步说,即使让用户手工把文件内容粘贴进对话,模型也只能读、不能写,一切改动仍要用户自己动手。工具调用把「粘贴」和「照做」这两个动作自动化了,而这正是 Agent 与聊天机器人的分界线。
协议里的三种数据
整套协议只有三种数据。看清它们各自由谁产生,就看清了协议本身。
一、工具声明(Tool):应用 → 模型。应用在每次请求里告诉模型「你有这些工具可用」。一个工具声明只有三样东西:
interface Tool {
name: string; // 调用时用的名字,如 "calc"
description: string; // 一句话说明它干什么、什么时候该用
parameters: Schema; // 参数结构:有哪些字段、什么类型、哪些必填
}模型对这个工具的全部认知就来自这三样,没有别的渠道——它看不到你的实现代码。所以描述写得含糊,模型就调得含糊;这是一份写给模型看的文档。另外要记住 3.2 的结论:模型没有记忆,每一轮请求都要把消息历史和工具清单完整重发一遍。
二、Tool Call:模型 → 应用。模型决定用工具时,回复里出现的不是普通文本,而是一个结构化的请求:
interface ToolCall {
type: "toolCall";
id: string; // 本次请求的编号
name: string; // 要调用哪个工具
arguments: Record<string, unknown>; // 参数,由模型生成的 JSON
}这里有一个必须记牢的事实:模型并没有执行任何东西。它只是生成了一段结构化文本,说「我想调用 calc,参数是这些」。执行发生在你的程序里,因此权限、超时、错误处理的责任也全在你这边。id 用于配对——模型可以在一条消息里一口气请求三次调用,回填结果时靠 id 说清哪条结果对应哪次请求。
三、工具结果(Tool Result):应用 → 模型。应用执行完毕,把结果包成一条新消息追加到历史末尾:
interface ToolResultMessage {
role: "toolResult";
toolCallId: string; // 指回是哪次请求的结果
toolName: string;
content: TextContent[]; // 给模型看的结果文本
isError: boolean; // 失败了也是一条正常消息,只是打上标记
}注意 isError:工具失败(文件不存在、命令返回非零)在 Agent 运行中是常态,失败同样作为一条正常消息回填,模型下一轮读到原因就有机会改正。只有应用自身出故障时才该中断整轮对话。
图解:一次完整的往返
图 3.4-1 一次工具调用的完整往返
阅读顺序:从上到下。请注意三件事。第一,模型 API 被调用了**两次**,中间隔着一次工具执行——「一问一答」在有工具的世界里变成了「一问、多轮往返、才有答」。第二,两次请求发给模型的都是完整历史,第二次只是比第一次多了两条消息(assistant 的请求和 toolResult 的结果)。第三,箭头 A->>T 是本图唯一真正「做事」的一步,而它发生在应用一侧,模型全程没有碰过工具。图中 assistant 消息与 toolResult 消息的形状,对应 Pi 源码里的 AssistantMessage 与 ToolResultMessage,本章末尾会读到。
最小示例:手工走一遍协议
下面用配套实验(labs/agent-concepts/03-tool-call)的代码把上图走一遍。模型是一个脚本化的假模型(FakeModel)——它不联网、不需要任何 API Key,只是按事先写好的剧本吐出回复,好让我们专心看协议本身。
工具声明和执行函数分开写:
export const calcTool: Tool = {
name: "calc",
description: "对两个数字做一次四则运算。需要算术结果时调用它,不要自己心算。",
parameters: {
type: "object",
properties: {
a: { type: "number", description: "左操作数" },
b: { type: "number", description: "右操作数" },
op: { type: "string", description: "运算符", enum: ["add", "sub", "mul", "div"] },
},
required: ["a", "b", "op"],
},
};主流程只有六步:组装上下文(系统提示词 + 消息历史 + 工具清单)→ 取模型回复并先追加进历史 → 校验参数 → 执行 → 把结果包成消息追加进历史 → 停。真实运行的输出节选如下(完整输出见实验目录的 expected-output.txt):
[2] 模型回复:一条 assistant 消息,里面有 2 个内容块
内容块 0 text 我算一下。
内容块 1 toolCall id=call_1 name=calc arguments={"a":128,"b":64,"op":"mul"}
stopReason=toolUse —— 模型停下来等工具结果,这一轮还没结束。
[5] 把工具结果作为一条新消息塞回历史
历史现在有 3 条:
[user] 128 乘以 64 是多少?
[assistant] text("我算一下。") + toolCall(calc {"a":128,"b":64,"op":"mul"}) stopReason=toolUse
[toolResult] calc#call_1 isError=false "8192"两个细节值得停一下。
第一,模型的回复是一个内容块数组,文本和工具调用可以并存。上面这条 assistant 消息里,第 0 块是文本「我算一下。」,第 1 块才是工具调用请求。界面上你看到助手先说一句话再开始干活,底层就是这么来的。所以处理模型回复时,正确的姿势是遍历内容块、按 type 分派(2.3 的可辨识联合派上用场),而不是假设「一条消息要么是文本、要么是工具调用」。
第二,assistant 消息必须先追加进历史,再去执行工具。否则下一轮模型看不到自己请求过什么,只看到一条凭空出现的 toolResult——它会困惑,甚至重复请求同一个工具。
模型生成的参数不可信
现在兑现 2.1 埋下的伏笔。那一章的结论是:类型只存在于编译期,运行时才到场的数据类型系统一概管不了。工具参数正是这类数据的典型代表——arguments 是模型在运行时生成的 JSON,编译器在你写代码时对它一无所知。
所以 ToolCall.arguments 的类型只能写成 Record<string, unknown>:一个「键是字符串、值不知道是什么」的对象。它没有也不可能保证里面有 a、b、op 三个键。模型出错的方式远比想象中多:把数字写成字符串 "128"、少给一个必填字段、给一个不在枚举里的 op: "pow",或者在文件工具里给出 ../../etc/passwd 这种越界路径。
实验的第 [6] 步演示了这个分支,真实输出是:
[6] 换一个不守规矩的模型:参数是编出来的
模型给出的 arguments={"a":"128","b":64,"op":"pow"}
校验失败:参数 a 必须是数字,收到 "128"
应用不执行工具,改为构造一条 isError=true 的工具结果:
[toolResult] calc#call_9 isError=true "参数校验失败:参数 a 必须是数字,收到 "128""图 3.4-2 工具参数的信任边界
阅读顺序:从左到右。左边那个方框是整条链路上唯一的不可信数据源,中间的菱形是唯一的关卡。请注意底部那条虚线:类型标注在这里帮不上任何忙,因为它在运行时已经被擦除——挡住脏数据的只能是真正执行的检查代码。还要注意两条分支最后**汇合到同一个终点**:校验失败不是抛异常中断,而是同样产出一条工具结果消息。
那么校验代码怎么写?实验里是手写的 if 判断,能用,但重复:parameters 里已经声明过「a 是 number、必填」,校验函数里又照抄了一遍,两处一旦不同步就出 bug。schema 校验的思路正是消除这份重复:用一份结构声明(schema)同时产出两样东西——发给模型看的参数说明,和运行时使用的校验函数。声明只写一次,两边永远一致。
从源码结构看,Pi 走的就是这条路:它引入 typebox 库,每个工具用它声明一次参数结构,既转成发给模型的 schema,也在执行前逐字段校验模型发来的参数。这也解释了下一节 Tool 类型上那个泛型参数的来历(TSchema 正是 typebox 的类型)。完整机制在 6.4 工具系统:定义、校验与执行 展开。
const args = call.arguments as CalcArgs,然后直接用。as 只是让编译器闭嘴,不做任何真实检查(见 [2.1](/foundations/types-basics))。作者实测:跳过校验、把 { a: "128", b: 64, op: "pow" } 直接喂给实验里的 runCalc,函数的 switch 一个分支都不匹配,返回 undefined,程序不报错继续往下跑。工具参数是外部数据,和 JSON.parse 的结果、网络响应属于同一类,必须有真正执行的校验代码。 Pi 中哪里用到了它
Pi 把这套协议的类型定义集中放在 packages/ai 包(负责和模型 API 打交道的 package)的类型文件里。四段源码正好对应本章的四个概念,字段几乎可以逐个对上。
先是工具声明:
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
}Tool再是模型发回的调用请求:
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
}ToolCall「文本与工具调用并存」这件事,在 assistant 消息的类型里写得明明白白:
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
// …(省略:api、provider、model 等来源信息与 usage)
stopReason: StopReason;
// …(省略:errorMessage、timestamp 等)
}AssistantMessage最后是工具结果:
export interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
// …(省略:usage、addedToolNames 及其文档注释)
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}ToolResultMessage一个值得注意的设计:工具结果在 Pi 里是独立的一条消息,而不是塞进 assistant 消息里的某个字段。一种看法是,这让消息历史保持「一条消息一个来源」的整齐结构,存档、回放、以及 5.3 一次 Tool Call 的完整循环 里的重放都更简单;代价是发给各家模型服务之前需要做一次格式转换——不同厂商的 API 对工具结果放在哪里各有各的规定。
实践任务
labs/agent-concepts/03-tool-call目标:不用任何 API Key,把「声明工具 → 模型请求 → 校验 → 执行 → 结果回填」六步亲手跑一遍,并看清校验失败时发生了什么。实验目录:labs/agent-concepts/03-tool-call。
步骤:
进入实验目录,安装依赖并运行:
shcd labs/agent-concepts/03-tool-call npm install npm start对照输出与目录里的
expected-output.txt,逐段回答:这一步的数据是模型产生的,还是应用产生的?打开
src/types.ts,把ToolCall、ToolResultMessage与上一节的 Pi 源码逐字段对照,找出实验版省略了哪些字段;加练一:把第 [6] 步的
arguments改成{ a: 128, op: "mul" }(少一个b),观察校验失败的另一个分支;加练二:给 calc 增加
pow(乘方)运算——要改enum、CalcArgs的联合和runCalc的switch,试试漏改一处编译器会不会提醒你。
预期现象:npm start 全程无异常;第 [2] 步显示一条 assistant 消息里有 2 个内容块;第 [5] 步历史变成 3 条消息,toolCallId 与 call_1 对上;第 [6] 步校验失败并生成 isError=true 的工具结果。
如何判断成功:输出与 expected-output.txt 完全一致,并且你能不看代码说出——这一轮里哪些数据由模型产生、哪些由应用产生。
常见错误:tsx: command not found 说明还没在实验目录里 npm install;改动 enum 后忘了同步改 CalcArgs,运行 npx tsc --noEmit 会告诉你哪里对不上。
对应源码位置:packages/ai/src/types.ts 的 Tool(第 480–485 行)、ToolCall(第 360–366 行)、ToolResultMessage(第 415–431 行)。
本章小结
- 模型只会生成文本;工具调用是它影响外部世界的唯一通道,也是 Agent 与聊天机器人的分界线。
- 协议只有三种数据:工具声明(应用→模型,name + description + parameters)、Tool Call(模型→应用,id + name + arguments)、工具结果(应用→模型,一条独立消息,靠 toolCallId 配对,isError 标记失败)。
- 模型全程不执行任何代码,它只是生成了一个结构化请求;执行、权限与错误处理的责任全在应用一侧。
- 一条 assistant 消息的
content是内容块数组,文本与工具调用可以并存;处理时应遍历并按type分派。 - 执行工具前必须把 assistant 消息追加进历史,否则下一轮模型看不到自己请求过什么。
arguments是模型生成的运行时数据,不可信;TypeScript 类型在运行时已被擦除,只有真正执行的校验代码能挡住它。用一份 schema 同时产出「给模型看的说明」和「运行时校验」是标准解法,Pi 用的是 typebox。- 工具失败不是异常,而是一条
isError: true的正常消息——模型读到原因才有机会自我纠正。
关键术语:工具调用(Tool Calling)、工具声明(Tool)、Tool Call、工具结果(Tool Result)、参数结构(Schema)、schema 校验、toolCallId、内容块(Content Block)、stopReason: "toolUse"、isError
关键源码索引:packages/ai/src/types.ts 的 ToolCall(第 360–366 行)、AssistantMessage(第 399–413 行)、ToolResultMessage(第 415–431 行)、Tool(第 480–485 行)
自测问题:
- 用户问「128 乘以 64 是多少」,在有工具的情况下模型 API 一共被调用了几次?两次请求的输入差别是什么?
- 一条 assistant 消息里既有文本又有工具调用请求,程序应该怎么处理?为什么不能只看第一个内容块?
ToolCall.arguments已经有类型标注了,为什么还必须在运行时校验?如果不校验,最坏会发生什么?- 工具执行时出错(比如文件不存在),应该把异常抛给用户、中断整轮对话吗?为什么?
下一章预告:3.5 Agent 与 Agent Loop——本章刻意停在「工具结果进了历史」这一步。可是模型拿到结果后还要再说一轮,那一轮里它可能又要调工具……什么时候停?谁负责转圈?把这个「转圈」写成代码,就得到了 Agent 的核心:Agent Loop(Agent 循环)。