Function Calling 不稳定,多半是工具 schema 没设计好

举报
yd_232401777 发表于 2026/10/25 08:32:21 2026/10/25
【摘要】 工具调用出错时,大家习惯去调提示词、换模型、加 few-shot,但真正的问题常常在 schema 上:参数用 string 却期望模型只填三个枚举值、必填项缺失、描述写成给人看的、嵌套结构深到模型放弃。这篇用正反例对照,讲清工具定义要怎么设计才能让调用稳定。

前言

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": "根据条件查询订单信息"

这句话人看了能懂,但对模型没有决策价值。它没回答模型最需要知道的三个问题:

  1. 什么时候该用这个工具(触发条件)
  2. 什么时候不该用(排除条件)
  3. 它和相似工具的区别是什么

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)

其中"不该调工具"这类负例最容易被漏掉。只测"该调的时候调对了",不测"不该调的时候没乱调",上线后就会出现"用户随便聊两句它就去查数据库"的问题。

做了什么

  1. 把工具调用失败归成五类:选错工具、该调不调、参数值错、漏参数、结构错
  2. 指出用 string + 描述文本承载约束是最大的浪费,改用 enum / integer / minimum 做语法层约束
  3. 说明 required 声明不当会导致模型编造参数值,该可选的要显式标可选并注明"没提到就别传"
  4. 给出工具描述的三要素:触发条件、排除条件、与相似工具的边界
  5. 指出工具数量超过 20 个应做两级路由
  6. 建议嵌套不超过两层,能用并列字段就不要嵌套
  7. 强调 strict 结构化输出的优先级高于提示词优化,以及幂等键对写操作的必要性
  8. 给出包含负例的评测集写法

一些体会

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 的描述里那句"先换算成绝对时间再传"——凡是需要模型做一次推理才能填对的参数,都值得在描述里点明这一步。不点明的话,它会老老实实传 "上周" 这三个字。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。