4.1 Function Calling 的本质是一次结构化输出
一句话结论:Function Calling 不是"模型调用了函数",而是模型输出了一段符合 schema 的结构化文本——和 3.2 讲的 JSON 输出是同一件事,只是多了一层"工具清单"作为上下文。
读这篇之前
需要先读 2.5(模型不能执行任何东西)和 3.2(结构化输出)。这一篇是第 4 章的地基。
一、先看清这条链路
一次"工具调用"的完整过程:
- 定义工具(你的代码):声明 name、description、parameters(JSON Schema)
- 调用模型:把工具清单通过 tools 参数一起传进去
- 模型返回:一段"函数调用请求",形如
{ name: "get_weather", arguments: '{"city":"北京","date":"明天"}' } - 你的代码解析并真的执行:这一步模型完全没参与
关键在第 3 步和第 4 步之间。 模型到此就结束了,它给出的只是 name 和一个 JSON 字符串。[原文 713]
所以准确的说法是:
Function Calling = 结构化输出 + 一份"可用工具"的声明。
tools 参数做的事,就是把这几个函数的 name / description / schema 塞进上下文,让模型知道"可以请求哪些动作"。它输出的 JSON 之所以合法,是因为 3.2 讲的原生 schema 约束在起作用。
二、三代演进:为什么现在这么写
工具调用不是一开始就长这样。它经历了三代:[原文 713]
| 代 | 时间 | 做法 | 问题 |
|---|---|---|---|
| 第一代:提示词工程 | 2022-2023 | 在系统提示里描述工具格式,要求模型输出 Action: search(query),框架再正则解析 |
格式不稳定,模型经常多说一句、少个括号 |
| 第二代:JSON 模式 | 2023 | 要求输出 JSON,用 schema 校验 | 仍需自己设计多工具的路由协议 |
| 第三代:原生 Function Calling | 2023 末起 | API 直接支持 tools 参数,返回结构化 tool_calls |
依赖模型支持;多工具时仍需注意选择准确性 |
第一代的问题值得记住:它用自然语言描述格式,靠模型"照着做"。这正是 3.2 说的"请求配合"——可以做到,但不稳定。
第三代的本质改进不是"模型变聪明了",而是把格式约束从"提示词请求"移到了"API 协议层"。协议层的约束不会被模型忽略。
这个演进逻辑在全书反复出现:凡是需要保证的,就从提示层挪到代码/协议层(3.5 的核心结论)。
三、工具定义里的每个字段都在影响成功率
工具定义不是"填完就行"的模板。每个字段都有实际作用:
| 字段 | 作用 | 写差的后果 |
|---|---|---|
name |
模型的"选择依据" | 名字含糊 → 选错工具 |
description |
最重要的字段,告诉模型"什么时候该用它" | 写得太简 → 该用不用、不该用硬用 |
parameters |
参数结构(JSON Schema) | 结构松散 → 参数缺失或类型错 |
required |
必填项 | 漏标 → 模型以为可省 |
每个参数的 description |
告诉模型填什么 | 不写 → 模型瞎填(尤其日期、枚举) |
description 是被低估最严重的一个字段。 原文的写法值得学:
"description": "城市名称,如:北京、上海"
注意它给了示例。对模型来说,"城市名称"是抽象描述,"北京、上海"是可检索的模式(2.1 讲过它擅长检索而非推理)。
同理,参数说明要同时给格式和默认值:
差:"date": "日期"
好:"date": "日期,格式 YYYY-MM-DD,默认今天"
后者同时告诉了模型格式和默认值该填什么,能显著降低参数错误率。
四、一个反直觉的点:工具调用的可靠性上限
既然 Function Calling 就是结构化输出,那么它的可靠性上限,就受制于同一批因素(2.4):
| 因素 | 影响 |
|---|---|
| 温度 | 高温 → 工具名选错、参数格式漂移 |
| 工具数量 | 越多 → 选择越容易错(4.5 详述) |
| 描述清晰度 | 越含糊 → 越容易选错或填错 |
| 参数复杂度 | 嵌套越深 → 格式错误率越高 |
所以工具调用的优化,和提示词优化是同一套方法论:说清楚、给示例、降温度、减少选项。
五、工具的分类:按副作用分级
在设计工具集之前,先给工具分类。最有用的分类维度是副作用:[原文 713]
| 类别 | 特征 | 例子 | 处理方式 |
|---|---|---|---|
| 只读类 | 不改变任何状态 | 查天气、读文件、搜索、查询数据库 | 直接放行 |
| 写入类 | 改变内部状态 | 写文件、更新记录、创建任务 | 校验参数后执行 |
| 不可逆类 | 改变外部世界,难以撤销 | 发邮件、发帖、付款、部署 | 必须人工确认(第 14 章) |
| 执行类 | 运行任意代码 | 跑脚本、执行 shell | 沙箱 + 白名单 |
这个分类的价值在于它直接决定了三件事:
- 工具描述里要不要写"危险"警告
- 工具层要不要加审批
- 评测时要重点测哪一类(不可逆类出错代价最高)
一个实用的命名约定:用动词前缀暴露类别。
read_* / get_* / list_* / search_* → 只读
create_* / update_* → 写入
delete_* / drop_* / send_* / deploy_* → 不可逆
run_* → 执行
这样即便模型不看描述,也能从名称上感知风险;而你的权限层可以直接按前缀分级(2.5)。
另一个维度:参数复杂度
| 类型 | 特征 | 风险 |
|---|---|---|
| 单值参数 | 只接收一个值 | 低 |
| 结构化参数 | 多个字段的对象 | 中:字段可能漏或错 |
| 自由文本参数 | 一整段任意文本 | 高:无法静态校验 |
| 路径/命令参数 | 可指向系统任意位置 | 极高:越权入口 |
第四类最危险。任何接收路径或命令字符串的工具,都必须有白名单校验,否则等于把整个文件系统或 shell 交给模型。
六、多工具时:怎么让模型选对
当你有 10 个以上工具时,"选错工具"会变成主要错误来源。三个可用手段:
手段一:在描述里互相指路
search_code → 描述里写:"找文档请用 search_docs"
search_docs → 描述里写:"找代码请用 search_code"
这就是 4.2 会详述的"不适用场景 + 指名替代"。
手段二:分组 + 预筛选
不要在每一轮都把所有工具交给模型。按当前阶段只提供相关的一组:
| 阶段 | 提供的工具 |
|---|---|
| 理解需求 | 只读类(读文件、搜索、查库) |
| 制定方案 | 无工具(纯推理) |
| 执行修改 | 写入类 + 执行类 |
| 验证 | 只读类 + 测试执行 |
这个做法同时解决两个问题:工具选择更准(选项少),上下文更省(2.2)。
手段三:意图路由
如果工具数量确实很多且无法分组,可以加一层"先分类再分发":
第 1 次调用:判断用户意图属于哪一类(只输出类别,不给工具)
第 2 次调用:只把该类别下的工具交给模型
代价是多一轮调用;收益是选择准确率提升。这是 1.4 讲的"路由模式"在工具层面的应用。 [原文 713]
七、三个补充误区
误区七:工具不需要分类,反正模型会自己判断。
模型能判断"该用哪个",但判断不了"这个操作危不危险"。副作用分级是你的责任,不是模型的责任。
误区八:参数复杂度无所谓,反正会校验。
校验能拦住错误,但不能降低错误率。参数越复杂,需要重试的次数越多,成本和延迟都上升。
误区九:工具集一次设计好就不用改。
工具集要随能力增长调整。定期检查:有没有工具从来不被调用(该删)、有没有本该一个的被拆成三个(该并)。
八、本章要点自检
误区一:Function Calling 是模型在执行函数。
不是。模型只输出 name + arguments 字符串,执行在你的代码里(2.5)。
误区二:传了 tools 模型就一定会调用。
不一定。它可能直接回答、也可能判断"不需要工具"。你需要处理"它没调工具"这条分支。
误区三:arguments 一定是合法 JSON。
通常合法(原生模式有保证),但不代表参数值正确——可能缺必填项、类型不对、值超范围。必须校验(4.3)。
误区四:工具描述随便写写就行。
描述是模型选择工具的唯一依据。写得含糊,等于没写。
误区五:工具越多越强大。
工具越多,上下文越长(2.2),选择越容易错(4.5)。克制是设计原则。
误区六:并行工具调用能替代多轮。
只能减少决策轮次,结果回来后仍需再推理一次(1.3)。
九、动手:一个能跑的最小工具调用循环
把 4.1 的机制落成代码。
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气。只读。适用于:查天气、出行建议。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如:北京"},
"date": {"type": "string", "description": "YYYY-MM-DD,默认今天"}
},
"required": ["city"]
}
}
}]
def run(task, max_turns=6):
msgs = [{"role": "user", "content": task}]
for _ in range(max_turns):
r = client.chat(model=M, messages=msgs, tools=TOOLS, temperature=0)
msg = r.choices[0].message
# 模型可能不调工具(4.1 误区二)
if not msg.tool_calls:
return msg.content
msgs.append(msg) # 保留 assistant 的 tool_calls
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
# 参数校验(4.3 的必要性)
if "city" not in args:
result = {"error": "缺少必填参数 city"}
else:
result = get_weather(**args)
msgs.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False)})
return "达到最大轮数"
四个容易错的点
| # | 错误 | 后果 |
|---|---|---|
| 1 | 忘了把 assistant 消息加进 msgs | 下一轮模型不认识 tool_call_id |
| 2 | arguments 没解析就用了 |
它是字符串,不是对象 |
| 3 | 结果没序列化就塞进去 | 报错或格式错乱 |
| 4 | 没处理"模型没调工具" | 死循环或空返回 |
第 1 条最常见:很多"tool_call_id 不匹配"的报错就是这个原因。
第 4 条最隐蔽:模型有时会直接回答而不调工具,你的循环必须能退出。
一个扩展:并行工具调用
模型可以在一次回复里请求多个工具调用。你要做的是并发执行,然后一次性全部回填:
results = parallel_map(lambda tc: execute(tc), msg.tool_calls)
for tc, res in zip(msg.tool_calls, results):
msgs.append({"role": "tool", "tool_call_id": tc.id, "content": res})
注意:只能并行只读工具;写操作要串行(11.4 的并发判据)。
十、一个从零到可用的最小清单
如果你现在要动手做一个带工具调用的 Agent,按这个顺序做:
| # | 做什么 | 验收 |
|---|---|---|
| 1 | 定义 1 个工具,写完整 description(4.2 六要素) | 别人看了知道何时用 |
| 2 | 写 30 行循环(本篇第八节) | 能跑通一次调用 |
| 3 | 加参数校验与错误回填(4.3) | 传错参数能自修 |
| 4 | 降温度到 0 | 工具名与参数稳定 |
| 5 | 加轮数上限与 Token 预算 | 不会无限循环 |
| 6 | 记录每轮轨迹 | 出错能查 |
六步,每步都能独立验证。 跑完这六步,你就有一个"能用"的工具调用 Agent,而不是 demo。
第 6 步最容易被跳过,但它决定了后面所有调试的效率(10.5)。
十一、一个实战总结:工具调用层最容易踩的五个坑
| # | 坑 | 表现 | 修法 |
|---|---|---|---|
| 1 | 忘了回填 assistant 消息 | tool_call_id 不匹配 | 把 assistant 完整加进 messages |
| 2 | arguments 当对象用 | 报错或参数全空 | 先 json.loads |
| 3 | 没处理"模型不调工具" | 死循环或空返回 | 检查 tool_calls 为空则退出 |
| 4 | 错误信息只返回异常文本 | 模型无法自修 | 用四要素模板 |
| 5 | 没有轮数与预算上限 | 账单失控 | 双上限 |
这五个坑覆盖了大多数"工具调用跑不通"的情况。
一个排查顺序
① 报错是 tool_call_id 相关? → 坑 1
② 参数明明传了却是空? → 坑 2
③ 一直在转圈不结束? → 坑 3 或 5
④ 反复重试同一个错误? → 坑 4
十二、一个补充:什么时候不该用 Function Calling
并非所有工具都该做成 Function Calling。三种替代:
| 情况 | 替代 | 原因 |
|---|---|---|
| 只是拼接字符串 | 代码直接做 | 不需要模型决策 |
| 固定格式转换 | 代码 + 正则 | 确定性胜过模型 |
| 需要精确计算 | 代码执行 | 模型算不准(2.3) |
判据:
这一步需要"判断"吗? 不需要 → 用代码。
很多团队把"确定性的处理"也做成工具让模型调,结果是"多花 Token、多一次出错机会"。
一个典型例子:把"格式化日期"做成工具。完全没必要——代码里 strftime 就行。
十三、一句话收尾
Function Calling 的价值不在"模型能调函数",而在"模型能表达它想做什么,由你决定做不做"。
这句话决定了整个工具层的设计方向:你写的是"守门人",不是"执行器"。
十四、下一篇
工具调用的可靠性,有一大半压在"工具描述怎么写"上面。这不是玄学,有具体的写法规律。
下一篇专门讲工具描述:怎么写才能让模型"该用的时候用、不该用的时候不用、用的时候参数填对"。