Function Calling 不稳定,多半是工具 schema 没设计好
前言
Agent 类应用上线后最常收到的反馈是"它有时候调错工具"、"参数填得乱七八糟"。
这类问题的排查顺序通常是:先改提示词,再换模型,再加 few-shot 示例,最后怀疑框架。折腾一圈之后,如果你运气好,成功率从 70% 提到 80%,剩下的 20% 就成了玄学。
但工具调用本质上是一次结构化的约束满足任务。模型要根据你的 schema 生成一个符合约束的 JSON。这里"约束写得够不够硬"直接决定了成功率,而绝大多数 schema 都写得太软。
这篇文章讲怎么把 schema 写硬。
一、先理解函数调用的失败模式
工具调用出错,可以归成五类,每类的修法不同:
| 失败模式 | 表现 | 根因位置 |
|---|---|---|
| 选错工具 | 该调 A 却调了 B | 工具描述、工具数量 |
| 该调不调 | 直接用参数知识回答了 | 工具描述、系统提示 |
| 参数值错 | 传了不存在的枚举值、格式不对 | 参数类型与约束 |
| 漏参数 | 必填项没填 | required 声明 |
| 结构错 | 嵌套层数不对、数组套错 | 结构深度 |
先分类,再动手,比盲目改提示词有效得多。
二、参数用 string 是最大的浪费
2.1 反例:什么都用 string
这是最常见的写法:
{
"name": "query_order",
"description": "查询订单",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "订单状态,只能是待支付、已支付、已发货、已完成、已取消其中之一"
},
"page": {
"type": "string",
"description": "页码,从 1 开始"
}
}
}
}
这里有两个问题。
第一,status 的约束写在描述文本里。模型需要在生成时"读到描述并遵守",这是软约束。它会理解,但也会偶发不遵守——尤其是在上下文很长、注意力被分散的时候。
第二,page 是字符串。模型可能传 "1",也可能传 "第一页"、"1.0"。类型是 string,这些在 schema 层面全都合法。
2.2 正例:把约束写进类型
{
"name": "query_order",
"description": "查询订单列表。当用户询问订单状态、物流进度、或需要按条件筛选订单时使用。",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending_payment", "paid", "shipped", "completed", "cancelled"],
"description": "订单状态。没提到状态时不要传此参数。"
},
"page": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
"required": [],
"additionalProperties": false
}
}
改动看着不大,但性质变了:
enum是语法层约束。模型如果生成了不在枚举里的值,这一步在结构化输出时就会被拒绝或修正,不需要靠"理解"integer+minimum让"第一页"这种值在类型层面就不成立additionalProperties: false阻止模型自作主张加字段required: []显式声明"都可以不传",避免模型为了凑齐参数而编造值
最后一条容易被忽略。如果把 status 写成 required,用户只说"查一下我的订单"时,模型就必须编一个状态填进去——它不会报错说"信息不足",它倾向于补全。所以该可选的一定要可选,且要在描述里明确说"没提到就不要传"。
三、工具描述是给模型看的,不是给人的
3.1 反例
"description": "根据条件查询订单信息"
这句话人看了能懂,但对模型没有决策价值。它没回答模型最需要知道的三个问题:
- 什么时候该用这个工具(触发条件)
- 什么时候不该用(排除条件)
- 它和相似工具的区别是什么
3.2 正例
"description": "按状态、时间范围、金额区间筛选订单,返回订单列表。 适用:用户要查看多个订单、按条件筛选、或不记得具体订单号时。 不适用:用户提供了明确的订单号时,应使用 get_order_detail。 返回字段:order_id, status, amount, created_at。不含物流轨迹。"
把"不适用"写出来这一点很关键。工具之间的边界模糊,是选错工具的主因。
如果你有 query_order 和 get_order_detail 两个工具,描述里各自只说"查订单",模型就只能靠猜。写上"有明确单号用 detail"这条边界,选错的概率立刻下降。
3.3 工具数量是个硬约束
工具数量增长会带来选择难度上升。经验上:
| 工具数 | 表现 |
|---|---|
| < 10 | 基本稳定 |
| 10-20 | 开始出现选错,需要清晰的描述边界 |
| 20-50 | 明显退化,需要分组或路由 |
| > 50 | 不建议直接全量暴露 |
超过 20 个工具时,更可靠的做法是两级路由:先让模型选一个类别(3-5 个),再在该类别内选具体工具。这样每次决策的候选集都保持在小规模。
四、结构深度:嵌套三层以上就开始出错
4.1 反例
{
"filters": {
"type": "object",
"properties": {
"time_range": {
"type": "object",
"properties": {
"start": {
"type": "object",
"properties": { "value": {"type": "string"}, "unit": {"type": "string"} }
},
"end": { "...": "..." }
}
}
}
}
}
四层嵌套。模型要在脑子里维持一个深层结构,出错率随深度快速上升。
4.2 正例:拍平
{
"start_time": { "type": "string", "format": "date-time" },
"end_time": { "type": "string", "format": "date-time" },
"time_granularity": { "type": "string", "enum": ["hour", "day", "week", "month"] }
}
扁平结构的推理负担小得多。原则是:能用并列字段表达的,就不要嵌套。嵌套只在表达真正的数组关系时才值得用(比如订单里的商品明细)。
五、结构化输出:从"应该"到"必须"
如果模型支持 strict 模式(强制输出符合 schema 的 JSON),一定要开。
不带 strict 时,schema 是"建议",模型可能输出:
好的,我来帮你查询订单。{"status": "已支付", "page": 1}
多了一句自然语言前缀,你的 JSON.parse 直接抛异常。
开了 strict 之后,输出被约束在 schema 内,解析成功率接近 100%。这一项能消掉的失败率,往往比所有提示词优化加起来还多。
同时要注意配套的两件事:
第一,错误处理还是要写。 strict 保证格式,不保证语义。order_id 格式正确但不存在,仍然要在业务层处理。
第二,参数校验不能省。 类型对了不代表值合理。page: 1 合法,page: -5 在 schema 层也被 integer 接受(除非设了 minimum)。业务层要再校验一遍。
六、一个容易被忽略的点:幂等
工具调用会重试。模型可能因为超时、因为解析失败、因为它自己觉得"没成功",把同一个调用重发一遍。
设计写操作的 schema 时,要带幂等键:
{
"name": "create_ticket",
"parameters": {
"properties": {
"title": { "type": "string" },
"content": { "type": "string" },
"idempotency_key": {
"type": "string",
"description": "幂等键。同一次用户请求内重复调用请使用相同的值。"
}
},
"required": ["title", "content", "idempotency_key"]
}
}
服务端按 idempotency_key 去重。没有这个,重试就会产生重复工单——而这个 bug 在测试环境很难复现,因为重试本身是概率事件。
七、改完之后的验证方法
不要用"感觉好像准了"来判断。构造一个固定的评测集:
CASES = [
# (用户输入, 期望工具, 期望关键参数)
("查一下我上周的订单", "query_order", {"start_time": "相对时间"}),
("订单 A1234 到哪了", "get_order_detail", {"order_id": "A1234"}),
("退款什么时候到账", "query_refund", {}),
("今天天气怎么样", None, {}), # 不该调工具
]
def score(agent, cases):
ok = 0
for text, want_tool, want_args in cases:
got_tool, got_args = agent.decide(text)
if got_tool != want_tool:
print(f"✗ 工具错:{text} → 期望 {want_tool},实际 {got_tool}")
continue
if want_tool and not all(k in got_args for k in want_args):
print(f"✗ 参数缺:{text} → {got_args}")
continue
ok += 1
return ok / len(cases)
其中"不该调工具"这类负例最容易被漏掉。只测"该调的时候调对了",不测"不该调的时候没乱调",上线后就会出现"用户随便聊两句它就去查数据库"的问题。
做了什么
- 把工具调用失败归成五类:选错工具、该调不调、参数值错、漏参数、结构错
- 指出用
string+ 描述文本承载约束是最大的浪费,改用enum/integer/minimum做语法层约束 - 说明
required声明不当会导致模型编造参数值,该可选的要显式标可选并注明"没提到就别传" - 给出工具描述的三要素:触发条件、排除条件、与相似工具的边界
- 指出工具数量超过 20 个应做两级路由
- 建议嵌套不超过两层,能用并列字段就不要嵌套
- 强调 strict 结构化输出的优先级高于提示词优化,以及幂等键对写操作的必要性
- 给出包含负例的评测集写法
一些体会
schema 是代码,不是文档。 写在 description 里的约束,模型"应该"遵守;写在 enum / type 里的约束,模型"必须"遵守。把该硬的东西写软,然后靠提示词补救,性价比极低。
模型不会说"信息不足",它会补。 这是设计工具参数时的核心认知。你把参数写成必填,用户没提供,模型就会编一个。所以必填项只在"业务上真的必须有"时才设,其余一律可选。
工具之间的边界比工具本身更重要。 加一个新工具时,要同时更新相似工具的描述,说明"这种情况请用另一个"。只加不改,边界就会越来越模糊。
strict 模式能省掉一整类调试。 格式解析失败这类问题,靠提示词"请只输出 JSON"是治不好的,因为模型是概率系统,它绝大部分时候会遵守,少部分时候不遵守——而那少部分正好落在线上。
代码
一个完整的工具定义,把上面几点都用上:
{
"name": "query_order",
"description": "按状态、时间范围筛选订单,返回订单列表(不含物流轨迹)。适用:用户想看多个订单或按条件筛选。不适用:用户给了明确订单号时,用 get_order_detail。",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending_payment", "paid", "shipped", "completed", "cancelled"],
"description": "订单状态。用户没明确提到状态时不要传此参数。"
},
"start_time": {
"type": "string",
"format": "date-time",
"description": "起始时间,ISO 8601。用户说'上周'这类相对时间时,先换算成绝对时间再传。"
},
"end_time": {
"type": "string",
"format": "date-time"
},
"page": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
"required": [],
"additionalProperties": false
}
}
注意 start_time 的描述里那句"先换算成绝对时间再传"——凡是需要模型做一次推理才能填对的参数,都值得在描述里点明这一步。不点明的话,它会老老实实传 "上周" 这三个字。
- 点赞
- 收藏
- 关注作者
评论(0)