跳到主要内容

1 篇博文 含有标签「Function Calling」

查看所有标签

LLM Function Calling 静默失败?枚举漂移与 Prompt 膨胀

· 阅读需 13 分钟

在生产环境给记账助手发一句补录指令时,模型明明返回了工具调用、参数也是合法 JSON,功能却没有执行——用户只看到一句「没识别到」。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,说人话就能记,AI 负责抽取金额、类目与账户。排查清楚后发现,这不是一次偶发故障,而是两类都会静默发生的结构性问题,分开写成本文的两个场景。

TL;DR​

  • 场景一:工具定义里的枚举与服务端校验的枚举是两处手写的常量,发生了漂移。模型按「说明书」发了合法值,校验端却拒绝它;又因为数量校验挂在数组层,一个非法候选拖垮整卡。修法是枚举单源引用 + 逐项容错。
  • 场景二:为了减少无效候选,我们在 prompt 里教模型「每个候选必须带全参数」。教学让输出变长,小模型的坏 JSON 率从约 6% 升到 12-28%。修法是撤回教学、校验放宽,缺参交给下游兜底。
  • 两条规则:成对出现的枚举必须同一常量单源;教学只教路由、不教抽参。

先分清两张面:模型看到「定义面」,代码守着「校验面」​

Function calling 里,工具定义与参数校验是两套独立代码,模型只见到前者——所有静默失败都发生在这两套代码的失配处。

模型并不真正执行函数。每次调用,你的服务端把工具定义(JSON Schema 格式)发给模型,模型返回一个工具名加一段参数 JSON,真正执行前由你的代码做校验。这意味着同一个「合法取值集合」至少存在于两个地方:发给模型的定义里,和服务端的校验代码里。

我们说的「静默失败」,指的是这类症状:接口不报错、调用有返回、参数看起来是合法 JSON,但功能就是没执行;用户侧只收到一句兜底话术,换一种说法就是「工具调用没生效」「功能偶发失灵」。错误细节只在服务端日志里,如果没有日志埋点,这类问题可能长期存在而不被发现。

LLM 工具调用两类静默失败的修复前后链路对比

场景一:模型按 Schema 发的合法枚举值,为什么被整卡拒绝?​

当工具定义里的枚举与服务端校验枚举不是同一个常量时,模型按定义面发的「合法值」一定被校验面拒绝,而数组层的数量校验会把单项非法放大成整卡作废。

现象:一条只在日志里可见的失败链​

我们的路由模型有一类「消歧」工具:当用户输入存在歧义时,模型不直接执行,而是调用一个元工具输出 2-4 个候选意图,渲染成可点选的卡片让用户确认。每个候选里有一个 tool 字段,取值必须是真实存在的动作工具名。

某天生产日志里出现一条路由失败:用户说「请缺失日期,批量写入」,模型确实调用了消歧工具,arguments 是合法 JSON——但整张候选卡被判非法,用户收到的是「没识别到」的兜底话术。更迷惑的是对照现象:同一句话,用户在第六轮换了个说法,路由就通过了。这说明不是模型抽风,而是某个特定取值触发的确定性失败。

根因:两处手写的枚举,和挂在数组层的数量校验​

排查后找到两个叠加的成因。

成因一:枚举漂移。 工具定义里,候选的 tool 字段枚举用的是「全量工具名集合」;而服务端校验用的枚举,是从全量集合里剔除了一个仅限图片链路的批量工具后的子集——因为这个工具不该被模型提名,提名它会绕过图片链路的专属契约。问题是这两个集合写在两处、各自维护:

// 定义面:发给模型的 JSON Schema(修复前)
tool: { type: 'string', enum: [...ALL_TOOLS] } // 全集

// 校验面:服务端 zod(修复前,另一处手写)
const ALLOWED = ALL_TOOLS.filter((t) => t !== 'batch_split') // 子集
tool: z.enum(ALLOWED)

模型看到的「说明书」说 batch_split 是合法值,就老老实实地用了它;校验面说不合法。模型按你给的 Schema 行事,却被你的校验拒绝——这不是模型犯错,是两张面在说谎。

成因二:数组级校验的整卡作废。 候选列表的数量约束挂在数组层:

options: z.array(OptionSchema).min(2).max(4)

zod 对数组的校验是每个元素都过、任何一个失败整个数组就失败。于是一个非法候选(比如那个 batch_split)会拖垮整张卡——其余三个完全合法的候选陪葬。上层只留了一句兜底话术,真相只在日志里。

修复:枚举单源,然后逐项容错​

第一步,让两张面引用同一个常量(这个修复只有一行):

const DISAMBIGUABLE_TOOLS = ALL_TOOLS.filter((t) => t !== 'batch_split')

// 定义面
tool: { type: 'string', enum: [...DISAMBIGUABLE_TOOLS] }
// 校验面
tool: z.enum(DISAMBIGUABLE_TOOLS)

第二步,把「一个坏元素废整个数组」改成「坏元素只剔自己」:先用 preprocess 做逐项校验,非法项剔除并记日志,剔完再做数量下限判断:

import { z } from 'zod'

const OptionSchema = z.object({
tool: z.enum(DISAMBIGUABLE_TOOLS),
label: z.string().min(1).max(30),
args: z.record(z.unknown()).optional(),
})

// 逐项容错:形状非法的候选剔除,不拖垮整卡;剔除动作记日志便于观测
function filterOptions(raw: unknown): unknown {
if (!Array.isArray(raw)) return raw
return raw.filter((item) => OptionSchema.safeParse(item).success)
}

const ArgsSchema = z.object({
// min 变成「剔后」下限:剔光才整卡拒绝,走兜底话术
options: z.preprocess(filterOptions, z.array(OptionSchema).min(1).max(4)),
})

这里有个边界要交代:逐项容错会静默丢弃非法候选,所以剔除动作必须记 warn 日志,否则坏候选消失得无声无息,观测面就瞎了。另外 min 的语义从此变成「剔后下限」——全部候选被剔光时才整卡拒绝,那时确实没有可展示的东西,走兜底是合理的。

这个「一个多余字段/取值拖垮整条输出」的问题,和我们之前踩过的 Zod .strict() 校验 LLM 输出静默失败 是同族:对完全受控的客户端输入,严格校验是对的;对模型输出,容错要给到单项粒度。

场景二:加了「参数必填」教学后,坏 JSON 率从 6% 翻到 12-28%​

对负载长度敏感的元工具,强制候选带全参数的教学会让输出变长,直接推高小模型的坏 JSON 率——同一组用例 A/B 实测,从约 6% 升到 12-28%。

出发点合理的教学​

场景一修复后,我们又发现新问题:有些候选上了卡,用户点选后才发现缺少必填参数,只能再追问一轮。于是很自然地加了一条教学——「每个候选的 args 必须带全该工具的必填参数,无法预填完整参数的候选不要提名」——并且在三处同时注入:系统提示的规则、工具定义的参数描述、few-shot 示例的注释。

// few-shot 示例(后来撤回的教学)
{
input: '删掉它',
tool: 'suggest_options',
note: '指代不明 → 消歧;每个候选 args 必须带全该 tool 必填参数(如 delete_record 需 domain),
无法预填完整必填 args 的候选不提名',
}

现象:验证闸三连红​

我们有一条验证闸:固定的一组路由用例,每次改动后批量重跑,失败率超阈值就红灯。教学上线后闸连续三轮失败,失败形态高度一致——全部是消歧工具调用的 JSON 格式错误(截断、多余的闭合括号),不是参数校验失败;错误频次从每轮 2 例涨到 5 例,逐轮递增。

归因:旧构建 A/B 对照,把变量钉死​

一开始的怀疑方向是「教学文案本身误导了模型」。但真要下结论,得做对照:新旧两个构建,同一组 4 句用例,每句各跑 16 次,统计坏 JSON 率。

构建坏 JSON 率
旧构建(无 args 必填教学)约 6%(16 次中 1 次;全量闸上同样偶发 1 例)
新构建(三处 args 必填教学)12-28%

6% 那一例值得说一句:旧构建在全量闸上也会概率性飘红,这是小模型的既有底噪,不是新改动引入的。真正的信号是 6% 到 12-28% 的跳变——教学让每个候选都背上了完整参数,消歧工具的输出显著变长,而小模型在更长输出上的 JSON 格式错误率会放大。这个变量关系,单看一轮红绿是看不出来的。

修复:撤教学,校验放宽,下游兜底​

裁决是三步:

  1. 三处 args 必填教学全部剥离,few-shot 只教路由方向(什么输入该走消歧、候选收敛到哪几个),不教抽参。
  2. 校验放宽为「仅校有 args 的候选」:候选带了 args 就用该工具自身的 schema 预演校验,值非法照旧剔除;args 缺失则容忍上卡。
  3. 缺参交给下游确定性出口兜底:用户点选缺参候选时,服务端返回 422 并附引导文案,原卡保留,用户补一句即可。
// 仅对已给出 args 的候选做同源校验:值非法仍剔,缺失容忍上卡
if (option.args !== undefined && !parseToolCall(option.tool, JSON.stringify(option.args))) {
dropped.push(option.tool) // 剔除 + warn 日志
continue
}

为什么敢容忍缺参上卡?因为下游兜底是确定性的——它不依赖模型自觉,而是服务端代码的固定分支。教学想预防的问题已经被代码接住了,教学本身就只剩成本(更长的输出、更高的坏 JSON 率)。这和思考模型吃满输出预算导致空回复是同一族问题:小模型的结构化输出对负载长度敏感,别的静默坑见 DeepSeek/Qwen 结构化调用返回空的排查。

沉淀成两条规则​

LLM 工具调用的可靠性靠两条规则保障:成对出现的枚举必须同一常量单源引用,prompt 教学只教路由方向不教参数抽取。

规则一:成对出现的枚举,必须同一常量单源引用。 只要一个取值集合同时出现在「发给模型的定义」和「服务端的校验」里,就禁止两处各自手写——从同一个常量展开。这条对枚举之外的任何成对约束(字段列表、数量上限)同样成立。

规则二:教学只教路由,不教抽参;判定教学去留用旧构建对照。 给模型的每一条教学都在为输出加长度,而小模型的格式错误率随输出长度上升。路由方向(什么输入走什么工具)是低成本的引导,值得教;参数完整性这类能被确定性校验接住的问题,交给代码兜底,别用 prompt 教。判断一条教学是否该留,别拿单轮测试的红绿下结论——新旧构建对同一组用例各跑十几轮,看坏 JSON 率的跳变才有说服力。

注意事项

逐项容错不是免费的:剔除动作必须记日志(我们用 warn 级别带上被剔的工具名),否则非法候选静默消失,观测面就瞎了。同理,「剔后数量下限」的语义要在代码注释里写清楚,避免后来者把 min 值当原始数量约束改回去——整卡校验一旦回归,场景一的症状会原样复发。

常见问题​

大模型工具调用失败怎么办?​

先查两类静默失败:一是工具定义里的枚举与服务端校验枚举是否引用同一个常量,漂移时模型按 schema 发的合法值会被整卡拒绝;二是 prompt 是否强制模型输出完整参数,负载变长会推高小模型坏 JSON 率。实测案例中坏 JSON 率从约 6% 升到 12-28%,枚举单源加逐项容错后回落。

function calling 的工作原理是什么?​

模型不真正执行函数:它只输出符合工具 JSON Schema 的工具名和参数 JSON,执行与校验都发生在你的服务端。定义 Schema 与校验 Schema 是两套代码,本文两个场景——枚举漂移导致整卡拒绝、教学加码导致坏 JSON 率从 6% 涨到 12-28%——都发生在这两套代码的失配处。

怎么提高大模型工具调用的成功率?​

三件事:数组类参数用逐项校验,单项非法剔除该项而不是整卡作废;枚举等成对出现的取值集合用同一常量单源引用;prompt 教学只教路由方向不教参数抽取,缺参交给下游确定性校验兜底。同一组用例 A/B 对照实测,这三条能把坏 JSON 率从 12-28% 拉回 6% 基线。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询