第十一周讲工具调用。这是大模型从「会说」走向「会做」的那一跃。
课程内容是三层:Function Call、MCP、CLI。作业只有一句话——把天气查询的工具调用改造为循环调用。
看起来就是把一个 if 改成 while。但真做起来发现,光改这一个字,跑出来的结果和没改一模一样。这篇主要讲这个。
一、为什么需要工具
大模型的边界很清楚,三条:
- 知识时效性 —— 训练数据有截止日期,不知道今天天气、最新股价
- 确定性计算 —— 算术、计数常常出错,而且自信地给出错答案
- 行动能力 —— 本身无法订票、发邮件、写文件
大模型的「语言智能」需要一个「身体」去触碰真实世界——这就是工具调用。
一个公式:LLM(语言智能)+ 工具(API / 命令 / 服务)= 会行动的智能体。
二、三种思路,是分层不是三选一
这是这周结构上最重要的一张图:
| 层次 | 解决什么 | |
|---|---|---|
| Function Call | 意图生成层 · 模型能力 | 模型生成结构化调用指令 name + arguments |
| MCP | 协议接入层 · 开放标准 | 统一工具的注册、发现、通信与鉴权 |
| CLI | 执行实现层 · 通用工具 | 用命令行这个万能工具完成具体操作 |
典型组合:Function Call 生成意图 → 经 MCP 发现并鉴权 → 由 CLI 或具名工具执行。
一句话记忆:Function Call 是指令,MCP 是协议与门禁,CLI 是双手。
Function Call 的三步闭环
Step 1 用户提问 → 模型解析意图,判断是否需要工具
Step 2 输出调用 → 生成结构化指令 name + arguments(JSON)
Step 3 执行回传 → 宿主执行工具,结果回灌模型,模型生成最终回复
模型负责「想」,宿主负责「做」,结果再回到模型「说」。
工具定义的三要素里,description 最容易被低估——它直接影响模型的调用决策,写不好模型就选错工具。
MCP:把 M × N 降成 M + N
没有 MCP 时,每个宿主 × 每个工具都要单独对接。加一条协议总线后,宿主接一次、工具接一次,集成量从乘法变成加法。
MCP 相对裸 Function Call 的决定性优势不是省事,是安全由协议层兜底:统一鉴权、能力协商(最小权限)、human-in-the-loop 强制确认。这些如果放在应用层,每个工具都要重写一遍。
CLI:最大灵活性,最重安全负担
与其为每个操作写一个工具,不如把整个 shell 交给模型,让它像工程师一样敲命令。
杠杆在于直接复用人类几十年积累的命令行工具链——grep/git/curl/docker 全都现成可用。
代价也很直接:命令行天然无结构、有副作用。原则是把命令行当成「有副作用的高权限用户」对待——沙箱隔离、命令白名单、超时限制、人工确认、注入防范,一样都不能少。
三、作业:改造成循环调用
老师的代码长什么样
resp = create(messages, tools) # 第 1 次请求
if msg.tool_calls: # ← 注意是 if,不是 while
执行所有工具,结果回填
resp = create(messages, tools) # 第 2 次请求
answer = msg.content or ""写死一个来回就收工。 而笔记里讲的是:「宿主只需忠实地『执行 → 回传 → 再问』,循环到模型不再发 tool_call」。
老师在文档里点名了这个缺口,而且是故意留的——多轮 ReAct 循环属于后面 Agent 课的内容。所以这次作业就是补上他点名没做的那一块。
关键发现:光改 while 是看不出区别的
这是动手前最容易忽略的一点,也是我这周最大的收获。
老师的 get_weather(city) 内部一口气做完两件事:Geocoding(城市名→经纬度)+ Forecast(经纬度→天气)。对模型来说这是一次调用就出结果,所以循环跑第一轮就退出了——改完和没改一样。
要让循环有意义,工具本身必须是可组合的。
所以我把它拆成两个原子工具:
geocode_city(city) → 返回候选地点及坐标,不替模型做选择
get_weather_by_coords(lat, lon) → 只认坐标,不认地名
前一个工具的结果成了后一个工具的输入——这就是笔记里说的「链式 / 嵌套调用」,宿主不循环就跑不完。
拆开还带来一个额外好处:消歧从写死的规则变成了模型的判断。老师原版用启发式解决地名歧义(PPLA 优先 → 人口排序 → 补「市」字重查),拆开后 geocode_city 把候选原样返回,由模型结合上下文挑。
这批数据里的歧义有多真实
「宁德」 → 只有 1 个候选:中国 西藏 那曲市,类型 PPL(一般居民点,可能是村)
「宁德市」 → 中国 福建省 宁德市,PPLA2(地级市驻地),人口 429,260
「北京」 → 3 个候选:首都 PPLC(1896 万)+ 重庆和成都的两个同名村
「宁德」这个案例最有价值:Open-Meteo 对裸「宁德」根本返回不了福建宁德市,必须换个写法重查。单轮宿主没有第二次机会。
四、循环要连带补三件事
只把 if 换成 while 会出新问题:
① 轮数上限 MAX_ROUNDS
单轮天然不会失控(就一轮),改成循环后模型可能反复调同一个工具,没上限就是无限循环 + 无限烧 token。
② ★ 最后一轮必须撤掉 tools
这一条最容易漏,也是我觉得最精妙的一处。跑满轮数时,最后一次请求如果还带着 tools,模型可能又输出 tool_calls,于是 msg.content 是 None——单轮版那个空答案的毛病原样复发。
所以最后一轮不传 tools,逼模型用已有信息作答。
③ 重复调用检测 模型有时拿到结果没看懂,用完全相同的参数再调一次。连续几轮签名一致就判定卡死,提前收尾。
for rounds in range(1, max_rounds + 1):
is_last = rounds == max_rounds
kwargs = {} if is_last else {"tools": tools, "tool_choice": "auto"} # ← ②
msg = create(messages, **kwargs).choices[0].message
if not msg.tool_calls: # 收敛,正常退出
return msg.content
messages.append(assistant_msg(msg))
signatures = 执行所有 tool_call 并回填
if signatures == last_signatures: # ③ 卡死检测
return force_answer(messages)
last_signatures = signatures五、2×2 对比实验
四个格子各说明一件事,两列都必须跑:只跑右边只能说明「循环能干成单轮干不成的事」,说明不了「循环是安全的替换」。
4 道题 × 4 个组合,模型 qwen-plus。判定标准:答案里有没有带单位的具体数值——模型不调工具也能瞎说「会下雨」,但编不出「降水 2.6 毫米」这种精确值。
一体化工具(oneshot) 原子工具(atomic)
single 4/4 0/4
loop 4/4 4/4
组合 平均轮数 工具调用 耗时(s)
single+oneshot 2.0 1.5 8.9
loop+oneshot 2.0 1.5 9.1 ← 和单轮几乎完全一致
single+atomic 2.0 1.5 6.9
loop+atomic 3.2 3.2 12.4
左列是「安全替换」的证据:任务只要一轮就够时,循环版第一轮拿到结果、第二轮就不再发 tool_call,不会画蛇添足。
右列是价值所在:单轮 0/4,循环 4/4。
六、单轮失效的两种形态
single + atomic 四道题全军覆没,但失败方式有两种,第二种更值得警惕。
形态 A:空答案(2/4)
问:北京今天多少度? 调用链 [geocode_city] 答案:(空)
模型第二轮还想调 get_weather_by_coords,于是 msg.content 是 None,answer = msg.content or "" 得到空串。程序不报错、不警告,就是返回一个空答案。
形态 B:永远不会兑现的承诺(2/4)
问:宁德时代总部所在城市今天天气怎么样?
答:刚才的候选地点是西藏那曲市的一个小村落,显然不是宁德时代总部所在的
福建省宁德市。我将重新查询,明确指定"宁德市"以提高准确性。
这种比空答案更危险:它看起来像个正常回复,实际上一个天气数据都没有,那句「我将重新查询」永远不会发生——宿主已经收工了。
★ 值得注意的是:模型自己完全知道该怎么做(它准确判断出西藏那个是错的、也说了要重查),是宿主没给它机会。
同样四道题,循环版:
问题 轮数 调用 调用链
宁德时代总部所在城市 4 3 geocode → geocode → weather
北京今天多少度 3 2 geocode → weather
北京、上海、广州哪个最热 3 6 geocode×3 → weather×3
上海明天会下雨吗 3 2 geocode → weather
宁德那道题的第 2 轮是关键:
第 1 轮 geocode_city("宁德") → 西藏那曲的村
第 2 轮 geocode_city("宁德市") ← ★ 模型自我纠错,换了个写法
第 3 轮 get_weather_by_coords(26.6617, 119.5228)
第 4 轮 不再调工具 → 最终回答
第 2 轮那次重查,就是单轮版永远做不到的事。
八次循环运行的停止原因全是 converged(模型自己不再发 tool_call),一次都没触发 repeated 或 max_rounds——说明上限留得够宽松。
七、并行和串行是两个正交的维度
「北京、上海、广州哪个最热」这题跑出 3 轮 6 次调用,把两件事同时演示了:
第 1 轮 geocode_city×3 ← 一轮内并行发 3 个 tool_call
第 2 轮 get_weather_by_coords×3 ← 又是一轮 3 个
第 3 轮 不再调工具 → 作答
- 并行 = 一轮之内发多个
tool_call(老师的单轮版已经支持) - 串行 = 多轮之间前后依赖(这次补的)
老师的单轮版只有并行没有串行,所以它能同时查 3 个城市,但没法「先 geocode 再 weather」。两个维度叠加,才是完整的工具调用闭环。
八、循环的代价,和一次差点注水的实验
代价
loop+atomic 相对 single+atomic:1.8 倍耗时、2 倍调用,换来 0/4 → 4/4
loop+oneshot 相对 single+oneshot:9.1s vs 8.9s,几乎零成本
循环不是无脑更好,它的价值取决于工具是否需要串联。
两个坑,都让我的方法显得更好
第一次跑出来是 single+oneshot 2/4、loop 3/4,看着挺漂亮,但复核时发现两处偏差——都是往有利于我的方向偏的:
① 复刻对照组时漏抄了一段逻辑。 老师的 get_weather 里有一段「补『市』字重查」,我第一版漏掉了。结果 single+oneshot 在「宁德」上被判失败,而老师的原版其实答得对。拿一个被自己削弱过的基线去对比,等于放水。 补回之后从 2/4 变成 4/4。
② 判定标准太窄。 第一版只认温度(°C),于是「上海明天会下雨吗 → 有毛毛雨,降水约 2.6 毫米」被判成失败——问下雨的题本来就不需要温度,这是假阴性。
两处都修完,结论反而更干净了(4/4 vs 0/4 比原先的 2/4 vs 3/4 清楚得多)。但如果不复核就直接写报告,报出去的就是一组注水的数字。
这周的收获
1. 工具的粒度决定了系统的能力上限。
这是我这周最大的认知更新。同样一个 while 循环,工具是一体化的就毫无价值,工具是原子的就成了刚需。「把工具拆细」不是洁癖,是让模型有机会自己编排。
2. 失败要看得见。
msg.content or "" 这行代码把一个「模型还想继续但没机会」的状态,静默降级成了空字符串。更糟的是形态 B——一段流畅的、承诺了却永远不会兑现的回复。 做 Agent 最怕的就是这种「看起来正常的失败」。
3. 对照组必须忠实。
削弱基线是最容易犯的自我欺骗。我这次是因为随手复核才发现的,如果直接写报告,那组数字会一路带到简历上。基线要用对方最好的版本,不是我复刻出来的版本。
下周正式进入 Agent——把这周的工具调用循环,扩展成完整的 ReAct 智能体。