第 3 课 · 它说「我要用工具」时,到底发生了什么
读这一课前你需要会什么:读过第 1、2 课。 你需要知道:它一圈一圈地写、它脑子里什么都不存、 以及我们能靠改那段送进去的文字来改变它的行为(第 2 课的上下文学习)。 出现的每一个新词都会当场用大白话讲清。讲不清楚的地方就是我的问题,请直接标出来。
这一课结束时你会
- 指着一段真实的请求内容说出每一块是干什么的;
- 说得出「它要调工具」这件事,我的代码凭什么认得出来——以及一共有几种认法;
- 说得出查到的结果为什么不能随便塞回去,以及正确的形状是什么。
1. 先看第 2 课留下的那个问题
第 2 课说:我们能靠在那段文字里写规矩来指挥它。
那就该问了:「你可以查天气」这句规矩,写下去之后会发生什么?
它不会去查。它办不到。 第 1 课说过,它只会读一段文字、往后面接一点。
它能做的只有一件事:把「我想查天气」这个意思,写成一段有固定形状的文字。
剩下的全是我们的活:
认出这段文字是一次调用请求 → 我们的代码
真的去查天气 → 我们的代码
把查到的结果放回它眼前 → 我们的代码
这一课讲的就是这三件事,以及它们各自的坑。
2. 顶层全景:一轮长什么样
先给这一课最重要的那个词:
一轮 = 从「把文字发给它」到「拿到它的回复」这一个来回。
一次对话里可能有很多轮。这一课只讲一轮,而且是最典型的那一轮:带工具的那种。
┌── 第 1 次请求 ────────────────────────────┐
│ 系统提示 + 工具定义 + 用户那句话 │
└────────────────┬──────────────────────────┘
▼
它没有直接回答,而是写出:
「我要用 get_weather,参数是北京」
│
▼
┌── 我们的代码 ─────────────────────────────┐
│ ① 认出这是一次调用请求 │
│ ② 查表:有没有叫 get_weather 的工具 │
│ ③ 真的去查 │
│ ④ 把结果包成它认识的形状 │
└────────────────┬──────────────────────────┘
▼
┌── 第 2 次请求 ────────────────────────────┐
│ 上面那些全部 + 它的调用 + 我们的结果 │
└────────────────┬──────────────────────────┘
▼
它这次给出人话回答:
「北京今天多云,26 度。」
图说:一轮里模型被调用了两次。
中间那一段「我们的代码」,就是这整个课题要造的东西。
注意第 2 次请求那一行:它包含了第 1 次请求的全部内容。 这就是第 1 课那条「它脑子里什么都不存」的直接后果——上一次说过的话,这次要原样再发一遍。
3. 全程例子:一句话,完整跑完一轮
这一节是这一课的核心。 盯住同一句话,把每一步的真实内容都摊开。
下面这段内容的形状是真的(照资料里的写法), 但具体的数值(温度、时间)是我编的演示。
第 1 步:我们拼出第一次请求
送出去的东西有三块:
┌─ 消息 ──────────────────────────────────────┐
│ 系统:你是一个乐于助人的助手。需要实时信息时 │
│ 用提供的工具。 │
│ 用户:北京今天天气怎么样? │
└──────────────────────────────────────────────┘
┌─ 工具定义 ──────────────────────────────────┐
│ 名字:get_weather │
│ 说明:查询指 定城市的当前天气 │
│ 参数:city(字符串)——城市名,如北京、上海 │
└──────────────────────────────────────────────┘
这两块是分开送的。 消息是一串对话,工具定义是另一份清单。
有一件事这时候必须知道,不然后面会想不通: 工具定义最后还是变成了文字。 有一份资料把这件事讲穿了—— 工具调用不是一套新机制,就是「训过的模型 + 接口层的一层糖」; 那份工具清单会被拼进系统消息里,用 markdown 排版,按类型化函数声明的样子写 (依据:04-making-it-work/prompt-engineering-for-llms)。
两个后果: ① 工具定义要占地方、要花钱——有本书专门提醒把它的体积算进预算 (依据:04-making-it-work/ai-agent-kai-fa-shi-zhan); ② 工具的说明怎么写,直接影响它用得对不对——因为它看到的就是那段文字。
第 2 步:它没有回答,它写出了一次调用
回来的东西大意是这样:
这次为什么停: 要调工具
正文: (空的)
它要调的:
├ 编号:call_abc123
├ 工具:get_weather
└ 参数:{"city": "北京"}
三个地方值得停下来看:
| 看什么 | 为什么重要 |
|---|---|
| 正文是空的 | 它这一轮什么话都没说,只提了个要求 |
| 有一个「这次为什么停」的字段 | 它明确告诉你:我停下来是因为要调工具,不是因为说完了 |
那个编号 call_abc123 | 这是后面配对用的钥匙。 第 5 步会用到 |
第 3 步:我们的代码接手
① 认出来: 「这次为什么停」= 要调工具 → 走执行分支
② 查表: 工具表里有没有 get_weather? → 有
③ 校验参数:city 是字符串吗? → 是
④ 真去执行:调天气接口 → 拿到 {"温度": 26, "天气": "多云"}
⑤ 包起来: 变成它认识的形状
第 ②③ 步不是多余的。 有一份协议侧的资料把这一整套写成了固定的五步: 查表 → 校验入参 → 执行 → 校验出参 → 变成它能读的形式 (依据:04-making-it-work/mcp-typescript-sdk)。
第 ② 步为什么必须有:它会点不存在的工具。 这不是罕见情况—— 有本书贴了同一个问题跑两次的真实记录,第二次它凭空点了一个叫「阅读和收集信息」的工具 (依据:04-making-it-work/dong-shou-zuo-ai-agent)。
第 4 步:结果按什么形状放回去
不能只把 {"温度": 26} 当成一句话塞进对话里。 正确的形状是这样:
系统:你是一个乐于助人的助手。…… ← 原样重发
用户:北京今天天气怎么样? ← 原样重发
助手:(空正文)+ 调用 call_abc123 ← 把它上一轮说的也放回去
工具:call_abc123 → {"温度": 26, "天气": "多云"} ← 新增这一条
第 3、4 两行是这一步的全部要点:
- 第 3 行:它自己上一轮的话也要放回去。 不放,它就不知道自己申请过什么;
- 第 4 行:结果要带上那个编号。 这样它才知道这个结果是回应哪一次申请的。
第 5 步:第二次请求,它给出人话
同一段内容再发一次,这次它回:
北京今天多云,气温 26 度。
这次「为什么停」的字段变成了「说完了」。 我们的代码看到这个,就知道这一轮结束了。
停一下:刚才发生了什么
| 谁干的 | |
|---|---|
| 决定「要查天气」 | 模型 |
| 决定「参数填北京」 | 模型 |
| 真的去查 | 我们的代码 |
| 决定「查到之后怎么办」 | 模型(它看完结果才决定要不要再调、还是直接答) |
| 把话重新贴一遍、把结果摆成正确形状 | 我们的代码 |
这张表就是这个课题的分工书。
4. 拆开看:五件事
4.1 消息与角色:对话是怎么表示的
你看到的「对话」,在代码里是一个列表,每条带一个角色。
| 角色 | 谁写的 | 干什么 |
|---|---|---|
| 系统 | 你(开发者) | 定规矩、定身份。通常只有一条,放在最前面 |
| 用户 | 用户 | 提要求 |
| 助手 | 模型 | 它的回答,或者它的调用申请 |
| 工具 | 你的代码 | 工具执行的结果 |
第四行是这一课的重点:「工具」这个角色是我们写进去的,不是模型写的。
但「用哪个角色写回结果」其实是一个选择,不是天条。 各家不一样:
| 做法 | 理由 | 出处 |
|---|---|---|
| 专门的「工具」角色 | 主流,配对清楚 | (依据:04-making-it-work/mcp-spec) |
| 伪装成「用户」说的话 | 不需要专门角色,任何最简陋的对话接口都能跑 | (依据:04-making-it-work/agenticseek) |
| 同上 | 那份资料明确标注这是一个取舍,不是最优解 | (依据:04-making-it-work/tongyi-deepresearch) |
| 自己造一个新角色 | 因为它的模型是配套训练出来的,认得 | (依据:04-making-it-work/deepanalyze) |
这里我判断错了一次,而且是跑起来才发现的
第一版讲义我写的是:「我们用第一种。第二种是接口不支持时的退路。」
这句话是错的。
我们真正接上去跑的那家厂商,提供三条不同的接口。其中一条的原生设计, 工具结果就是作为「用户」消息回填的——不是退路,是它本来的样子 (依据:本库实验 · 04-making-it-work/baseline-01)。
三条接口的回填形状实测长这样:
| 接口 | 结果放在哪 | 配对的钥匙叫什么 |
|---|---|---|
| 甲 | 一条「用户」消息,里面装一组结果块 | tool_use_id |
| 乙 | 一条一条的「工具」消息 | tool_call_id |
| 丙 | 不带角色的结果块 | call_id |
三种都是原生设计,没有谁是退路。
这件事对你有一个更一般的用处: 看到「主流做法是 A,B 是退路」这种说法时,先问一句「谁的主流」。 我当时读了 67 份资料,大多数用的是乙那一套,于是我把甲当成了变通。 读得再多,也可能只是读到了同一个圈子。
我们的原型对这三种一视同仁——第 4 课 §4.5 讲的分层,就是为了让这件事不影响循环本身。
4.2 工具定义:一份工具说明该写什么
它的权威形状只有三样:名字、一句说明、一 份参数格式(依据:04-making-it-work/mcp-spec)。 那份资料还提到可以再配一份返回格式的说明,让结果从一坨文字升级成能校验的结构。
难点不在字段,在那句「说明」怎么写。 有一份资料把这件事说得最透:
工具说明的核心是让它知道「什么时候用」,而不只是「能做什么」。 写「搜索相关内容」远不如写「当需要获取实时信息或查找未知事实时使用」。 (依据:04-making-it-work/shen-ru-li-jie-ai-agent)
同一份资料还给了一条更反直觉的:
清楚列出边界(做不到什么、不接受什么输入),往往比描述能力更重要—— 因为大多数调用失败的根因不是它不知道工具能做什么,而是不知道工具不能做什么。
几条能直接照做的:
| 怎么写 | 出处 |
|---|---|
| 参数用具体的例子代替抽象规范:写出一个真实取值,它可以直接套用 | (依据:04-making-it-work/shen-ru-li-jie-ai-agent) |
| 注明执行代价:「大型网站可能要 5 到 10 秒,只要元信息的话用另一个工具」 | (依据:04-making-it-work/shen-ru-li-jie-ai-agent) |
| 名字要能自己说明用途;别用全小写连写,它更难被切开理解 | (依据:04-making-it-work/prompt-engineering-for-llms) |
| 别把一个网页接口原样搬进来——参数多、响应复杂,占地方而且它调不对 | (依据:04-making-it-work/prompt-engineering-for-llms) |
| 如果它本来就熟悉某个公开接口,沿用那套命名和风格 | (依据:04-making-it-work/prompt-engineering-for-llms) |
还有一条省事的做法:工具定义别手写,从函数签名自动生成。 有一份资料就是这么干的——读函数的参数类型、没有默认值的算必填、函数的注释直接当说明 (依据:04-making-it-work/ai-agent-kai-fa-shi-zhan)。
判断(无锚): 自动生成这条要抄,但它有个陷阱—— 工具说明的质量会等于函数注释的质量,而随手写的注释很少会写边界。 所以自动生成之后,那句「什么时候用」和那句「做不到什么」还是要人手补。 如果错,会错在: 如果团队本来就有「注释必须写清用途和边界」的规矩, 那自动生成就是纯赚,不用再补一遍。
4.3 它怎么说「我要调」:一共有七种通道
这是这一课分歧最大的地方。 全程例子里用的是最省事的那一种,但它不是唯一的。
| # | 通道 | 怎么做 | 代价 | 出处 |
|---|---|---|---|---|
| 1 | 厂商原生 | 它走一个专门的结构化字段回传,我们直接读 | 绑一家的格式;不是所有模型都支持 | (依据:04-making-it-work/vercel-ai-sdk) |
| 2 | 自定义标签 | 约定四种文字标签,调用写在其中一对里 | 要自己设停止词、自己防它编造结果 | (依据:04-making-it-work/tongyi-deepresearch) |
| 3 | 代码围栏 | 每个工具认领一个代码块标签,写在块里就执行 | 工具没有参数结构,没法校验 | (依据:04-making-it-work/agenticseek) |
| 4 | 让它写代码 | 一步的行动就是一整段代码,一步内能连调好几个工具 | 需要沙箱 | (依据:04-making-it-work/smolagents) |
| 5 | 补丁文本 | 干脆不用工具调用,让它在回复里夹一段约定格式的补丁 | 要处理分隔符冲突、缩进、解析报错 | (依据:04-making-it-work/aider) |
| 6 | 结构化输出模拟 | 厂商不支持「必须调工具」时,把可选工具编成一个格式约束逼出合法调用 | 只在支持结构化输出的厂商上可用 | (依据:04-making-it-work/beeai-framework) |
| 7 | 训进模型里 | 控制标签是加进词表的真 token,提示里干脆没有工具说明 | 换模型就得重训 | (依据:04-making-it-work/deepanalyze) |
还有第八种,它反过来了:不让它选。 把每个工具自带的判定说明拿去并发地问模型「这个工具适不适合这句话,打个分」,谁分高用谁 (依据:04-making-it-work/nlweb)。代价是工具数 × 一次模型调用。
这么多种,该怎么选
三条判据,按顺序问:
- 模型支持原生工具调用吗? 支持就用第 1 种,别折腾;
- 不支持的话,它在训练时见惯的是哪种格式? ——第 2 课说过,它对某些格式有肌肉记忆。有一份资料 把这件事做成了一整层: 把工具调用编进/解出纯文字流,而上层完全看不出区别 (依据:04-making-it-work/oh-my-pi);
- 要不要留退路? 有的实现两条路都备着,原生那条报「不支持」就当场切文字协议 (依据:04-making-it-work/crewai)。
自己发明格式,一定会撞上的三笔税
这三条是第 2 到第 7 种通道共同的代价,值得单列:
| 税 | 是什么 |
|---|---|
| 它会自己编出「执行结果」 | 不拦的话,它会一口气把工具的返回值也写出来。解法有两种:设一个停止词让它写到那儿就停,或者事后把那一段切掉 |
| 标记会被切碎 | 它是一个字一个字吐出来的,一个标签可能在中间断开。所以扫描器必须是有状态的——先算出末尾有多长可能是标记的开头,那一截先留着不吐(依据:04-making-it-work/onyx) |
| 分隔符会和内容撞车 | 你用三个反引号包代码,而文件内容里正好有三个反引号。有的实现的做法是:扫一遍所有内容,挑一个没出现过的分隔符(依据:04-making-it-work/aider) |
而且兜底本身也要有上限。 有一份资料的第三层兜底就一句话: 整轮只允许兜底一次,挖不到就认输(依据:04-making-it-work/onyx)。