3829 字
约 12 分钟
19
4.1 Function Calling 的本质是一次结构化输出
2026-10-25
无标签

4.1 Function Calling 的本质是一次结构化输出

一句话结论:Function Calling 不是"模型调用了函数",而是模型输出了一段符合 schema 的结构化文本——和 3.2 讲的 JSON 输出是同一件事,只是多了一层"工具清单"作为上下文。

读这篇之前

需要先读 2.5(模型不能执行任何东西)和 3.2(结构化输出)。这一篇是第 4 章的地基。


一、先看清这条链路

一次"工具调用"的完整过程:

  1. 定义工具(你的代码):声明 name、description、parameters(JSON Schema)
  2. 调用模型:把工具清单通过 tools 参数一起传进去
  3. 模型返回:一段"函数调用请求",形如 { name: "get_weather", arguments: '{"city":"北京","date":"明天"}' }
  4. 你的代码解析并真的执行:这一步模型完全没参与

关键在第 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 沙箱 + 白名单

这个分类的价值在于它直接决定了三件事:

  1. 工具描述里要不要写"危险"警告
  2. 工具层要不要加审批
  3. 评测时要重点测哪一类(不可逆类出错代价最高)

一个实用的命名约定:用动词前缀暴露类别。

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 的价值不在"模型能调函数",而在"模型能表达它想做什么,由你决定做不做"。

这句话决定了整个工具层的设计方向:你写的是"守门人",不是"执行器"。


十四、下一篇

工具调用的可靠性,有一大半压在"工具描述怎么写"上面。这不是玄学,有具体的写法规律。

下一篇专门讲工具描述:怎么写才能让模型"该用的时候用、不该用的时候不用、用的时候参数填对"。

4.1 Function Calling 的本质是一次结构化输出
https://clxhxhhr.top/posts/4882/
作者
clxstart
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。