上周在联调环境里看到一条报错,我盯着日志愣了半天:工具函数get_weather收到了一个wind_speed参数,但我给 Function Calling 准备的 schema 里压根没有这个字段。更离谱的是,unit传进来一个"Kelvin",不在我定义的枚举值里。也就是说,模型在生成 Function Calling 参数时,自己"脑补"了一个它觉得合理、但实际上不存在于工具定义中的参数。
这类问题其实不是偶发,而是 LLM 应用的固有风险。Function Calling 的参数不是模型从配置文件"查"出来的,而是一个 token 一个 token 按概率预测出来的文本。既然是预测,就有编造的可能。把 schema 校验挡在入口处,把模型输出当成"不可信的外部输入"来对待,才能在参数层面拦截大部分幻觉,保住下游函数和业务系统的稳定性。这篇文章就围绕这件事展开:先复盘事故现场,再拆解模型编造参数的几种典型形态,然后聊聊为什么提示词约束和"再问一次"都不靠谱,最后给出用 schema 校验拦在入口的完整落地方法。
1. 事故现场:一次"No such field"背后的模型幻觉
1.1 从一条 TypeError 说起
那天的场景很朴素。我在本地跑一个多工具 Agent,其中一个工具是查询天气,工具定义大致长这样:
{ "name": "get_weather", "description": "查询指定城市未来若干天的天气情况", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "城市名称,例如上海" }, "unit": { "enum": ["celsius", "fahrenheit"], "description": "温度单位" }, "forecast_days": { "type": "integer", "minimum": 1, "maximum": 14, "description": "查询天数" } }, "required": ["location", "unit"] } }用户问了一句"上海接下来三天冷不冷,风大不大",模型给出的参数是:
{ "location": "上海市浦东新区", "unit": "celsius", "forecast_days": 3, "wind_speed": 15 }我底层调用的是一个 Python 写的函数包装器,内部用的是**kwargs接参,直接报TypeError: get_weather() got an unexpected keyword argument 'wind_speed'。
一开始我还以为是模型最新版本改了行为,把一些隐含的地理纬度信息塞进了参数。翻了几条日志才发现,真正的问题不是模型"聪明过头",而是它在完成文本补全任务时,从训练分布里"联想"出了wind_speed这个字段——因为大量公开 API 文档里天气接口确实会带风速,但它不知道我这个工具定义里没有这个字段。它只是在模仿"一个天气工具应该有的参数长什么样"。
1.2 为什么会凭空多出一个参数:概率生成不是数据库查询
这是理解整个问题的关键前提:模型调用 Function Calling 的时候,并不是在"执行"你的代码,也不是在"读取"你的 JSON Schema 之后做精确的填充。它做的事情,可以简化理解成:
结合你的 system prompt、对话历史、工具定义,再根据当前上下文推出"接下来最可能出现的参数序列"。整个过程其实是 token 级别的概率采样。所以它会:
- 填上你定义的字段,因为它们出现在上下文里,概率高;
- 也会填上它"觉得"合理的字段,因为训练语料里大量类似接口长那样,概率也不低;
- 甚至会在必填字段缺失时继续输出一个"看起来完整"的调用。
这就像让一个没有实权的小助理照着模板填写请假单:他大概率能填对名字和日期,但也可能凭印象把"部门"写成"事业部B组",再把负责人编成"张总",即使公司根本没有这个部门、也没有张总。模型不会像程序一样发现"哦,我写了个未定义字段,应该停下来",它只会沿着概率继续生成。
我们把这类问题叫"参数编造"或"参数幻觉"。一旦并发量上来、业务覆盖面变广,这是必然出现的,而不是小概率事件。所以我后来做的第一件事,就是在入口处加一层 schema 校验,把所有模型输出当"不靠谱的外部输入"统一处理。
2. 模型编造 Function Calling 参数的三种典型形态
我梳理了线上和实验中最常遇到的参数编造,大致可以分为三类。理解形态比单纯加一个 try/catch 更有效,因为只有知道它怎么错,才知道校验规则该怎么设计。
2.1 形态一:凭空多出的参数名
这就是上面wind_speed的例子。模型在你的参数结构之外,额外生成了一个字段。常见情况还包括:
- 明明没有
city_code字段,模型传了city_code: "021"; - 给一个只接收
email的发送接口补上了name字段; - 给一个查询订单的接口补上
sort_order或page_size。
这类错误最隐蔽,因为参数名看起来"合理",在下游不严格的地方可能会被悄悄使用,但更多时候会直接导致程序崩溃,或者让内部逻辑出现意料之外的分支。
2.2 形态二:枚举值、常量被"合理化"
这个形态更常见,也更难排查。比如unit字段明明只允许celsius或fahrenheit,模型传了kelvin——从科学角度没错,但不符合你的业务约定。再比如:
- 状态字段只允许
pending、approved、rejected,模型传了confirmed; - 语言字段只允许
zh-CN、en-US,模型传了chinese; - 排序方向字段只允许
asc、desc,模型传了"from_latest"。
模型本质上是在做"语义联想",它觉得Kelvin和温度单位在语义上相关,于是填了进去。它不知道你的系统里只有两种单位。
2.3 形态三:类型漂移、必填项失踪与嵌套结构残缺
第三类属于更"硬"的语法层面错误:
- 类型漂移:
forecast_days是整数,模型给了字符串"3";enabled是布尔值,模型给了"true"字符串;甚至数组字段给了单个对象。 - 必填项失踪:你的 schema 里把
location标为必填,但模型用一个空对象调用,或者跳过了必填字段。 - 嵌套结构残缺:参数里有一个
address对象,包含city和street,模型只给city,不给street;甚至把address直接传成字符串。
这类错误在简单的单层参数里不太明显,一旦工具入参变成两三层嵌套,概率就会显著上升。我见过最夸张的一次,模型把一个数组字段传成了"[]"字符串,导致下游业务解析时直接拿到空字符而不是空数组。
3. 为什么"再问一次"和提示词约束都不可靠
有人可能会说:我在 prompt 里写清楚"必须严格按照参数定义输出,不得编造"不就行了吗?答案是可以降低概率,但不能杜绝。原因有几个层面,值得展开说明。
3.1 LLM 的"自信错误"是固有属性
提示词对模型来说是软性引导,不是硬性契约。模型没有一个"回头对照 schema 检查自己输出"的执行机制。你要求它"别编造字段",它下一个 token 分布里依然存在编造字段的概率,只是你可能把这个概率压低了一点。
更麻烦的是,当第一次调用因为参数错误被拦下来后,很多人会顺手让模型"重新生成一次"。模型的第二版输出通常不是"冷静纠错",而是"在已有输出基础上重新采样"。实测下来,它经常会把wind_speed改成wind_speed_kmh,或者干脆把整个参数结构重写一遍,错误形态可能变化但未必消除。
3.2 肉眼审查和人工兜底不适用于规模化
还有一个常见误区:Demo 阶段模型总是能跑对,于是觉得"不需要校验,出了问题人看一眼就行"。但当你有几十个工具、每天几千次调用时,不可能人肉盯住每一次 Function Calling 的输出。模型调用参数是一个不可信输入源,应该像处理用户上传数据一样处理它:先验证,再使用。
用一句话概括:提示词约束是"道德劝告",schema 校验是"入口闸机"。道德劝告能降低不文明行为,但闸机能保证不文明行为进不了核心区域。实际产品里两者叠加使用,效果最好,但你必须先有闸机。
3.3 工具内部散落 if 判断同样不可取
也有人走另一条路线:不在入口校验,而是在每个业务函数内部加一堆 if 判断,处理参数是否合法。这种做法的问题在于:
- 校验逻辑和业务逻辑耦合,工具函数被迫关心"这个字段是不是模型编造的";
- 几十个工具的校验规则各自为政,错误信息不一致;
- 一旦参数结构变更,要改的点位太多,漏掉一处又是线上事故。
所以正确思路是:把校验单独抽出来,放在模型参数进入业务函数之前,所有工具统一走同一条校验链路。
4. 入口守卫选型:从手写 if/else 到 schema 校验
把校验前置之后,下一个问题就是用什么来实现校验。我先后试过三种方案,体验差异非常大。
4.1 方案一:手写 if/else 校验
最笨但直观的做法:
def validate_weather_args(args): if "location" not in args: return False, "missing location" if not isinstance(args["location"], str): return False, "location must be str" if args.get("unit", "celsius") not in ("celsius", "fahrenheit"): return False, "unit invalid" # 几十个工具就要写几十份这样的代码 return True, args短期能跑,长期必崩。每个工具都要手写一套字段遍历、类型判断、可选值判断。模型一旦输出一个新类型的错误,就要改代码。错误信息格式不统一,反馈给模型的文本也很难设计成稳定的恢复指令。
4.2 方案二:Zod schema 校验
后来我在 TypeScript 项目里切到了 Zod。它最大的优点是把"声明式规则"和"结构化错误"合在了一起:
import { z } from "zod"; const weatherArgsSchema = z.object({ location: z.string().min(1).max(200), unit: z.enum(["celsius", "fahrenheit"]).default("celsius"), forecast_days: z.coerce.number().int().min(1).max(14).optional(), });用safeParse解析模型输出,不需要 try/catch,也不需要手写 if:
const result = weatherArgsSchema.safeParse(rawArgs); if (!result.success) { console.log(result.error.issues); }result.error.issues会明确指出哪个字段、什么原因失败,例如Expected 'celsius' | 'fahrenheit', received 'kelvin'。这个信息可以直接反馈给模型,让它基于错误信息自我修正。Zod 还支持嵌套对象、数组约束、transform、refine,覆盖 Function Calling 参数的绝大部分场景。
4.3 方案三:JSON Schema + Ajv
如果你的团队是跨语言协作,或者已经有现成的 OpenAPI 定义,也可以先用 JSON Schema 描述工具参数,再在每门语言里找对应的校验器。Node 端常用 Ajv:
import Ajv from "ajv"; const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(toolSchema); const valid = validate(rawArgs); if (!valid) { console.log(validate.errors); }JSON Schema 的好处是语言无关、和工具定义天然一致;缺点是错误信息可读性一般,需要对validate.errors做二次格式化,才能变成适合反馈给模型的文本。所以我的经验是:同构 TypeScript 项目优先 Zod,追求跨语言通用或者已有 OpenAPI 生态就选 JSON Schema + Ajv。两者没有绝对优劣,核心是必须有"声明式、结构化、可复用"的校验规则,而不是散落的 if。
| 维度 | 手写 if/else | Zod | JSON Schema + Ajv |
|---|---|---|---|
| 声明式约束 | 否,逻辑散落 | 是 | 是 |
| 结构化错误信息 | 手动拼装 | 自带 issues | 需二次格式化 |
| 跨语言复用 | 每语言重写 | TypeScript 生态 | 通用 |
| 与 OpenAPI 联动 | 无 | 需自行转换 | 天然匹配 |
| 嵌套与组合 | 痛苦 | 强 | 中等偏强 |
5. 落地:为 Function Call 写一套可复用的校验守卫
选型完成后,关键是设计一套"所有工具统一走一遍"的入口守卫。我把它拆成四块来落地。
5.1 给每个工具注册自己的 schema
首先把工具定义和校验规则放在一起注册。这样做的好处是:当你想调整某个工具的参数约束时,改一处就行。
import { z } from "zod"; const toolRegistry = { get_weather: { schema: z.object({ location: z.string().min(1).max(200).describe("城市名称,例如上海"), unit: z.enum(["celsius", "fahrenheit"]).default("celsius"), forecast_days: z.coerce.number().int().min(1).max(14).optional(), }), async handler(params) { return await fetchWeather(params.location, params.unit, params.forecast_days); }, }, send_email: { schema: z.object({ to: z.string().email(), subject: z.string().min(1).max(200), body: z.string().max(5000).optional(), }), async handler(params) { return await emailService.send(params); }, }, };5.2 统一执行入口:校验通过才 dispatch
我封装了一个callTool函数,所有模型发起的工具调用都必须经过它:
function callTool(toolName: string, rawArgs: unknown) { const tool = toolRegistry[toolName]; if (!tool) { return { ok: false, error: `Unknown tool: ${toolName}`, }; } const parsed = tool.schema.safeParse(rawArgs); if (!parsed.success) { return { ok: false, error: formatIssues(parsed.error), }; } return { ok: true, result: await tool.handler(parsed.data), }; }这里有一个容易被忽略的点:rawArgs可能已经由底层 SDK 解析成对象了,也可能还是一个 JSON 字符串(取决于不同模型供应商)。所以我通常先判断类型,是字符串就JSON.parse一次,再交给safeParse。解析失败时返回独立的错误类型,方便后续恢复流程区分是"JSON 语法错误"还是"参数校验错误"。
5.3 校验失败的恢复流程:把错误反馈给模型
这是我认为整个方案里最有价值的一步。很多人的做法是校验失败就抛异常,然后整个 Agent 流程崩溃,或者返回一句笼统的"工具调用失败"。但模型本身能感知的反馈通道只有工具调用结果,所以你完全可以把校验错误变成一次"修正机会"。
我封装了一个formatIssues,把 Zod 的 issues 转成模型容易理解的文本:
function formatIssues(error: z.ZodError) { return error.issues.map((issue) => { return `参数 ${issue.path.join(".")} 不合法:${issue.message}`; }).join(";"); }然后在 Tool Loop 里,当校验失败时,我把这段错误信息作为role: "tool"的返回内容继续交给模型:
{ "role": "tool", "tool_call_id": "call_xxx", "content": "参数 unit 不合法:Invalid enum value. Expected 'celsius' | 'fahrenheit', received 'kelvin';请根据错误信息修正参数后重新调用工具。" }实测下来,大部分模型在收到这种明确提示后,能在下一轮把unit改成celsius或主动请求用户确认。如果连续两三轮还是同样的错误,我才会中断并转人工处理。
这里要特别注意:错误信息应该只包含"当前这批参数哪里不对",不要把内部堆栈、完整 schema 定义、或者业务函数的日志一股脑塞进去。信息太多反而会干扰模型定位错误。
5.4 针对"JSON 字符串模型输出"的预清洗
有些模型在 Function Calling 参数输出时不稳定,偶尔会输出多余注释、Markdown 代码块,或者把 JSON 对象包装在反引号里。在进入 schema 校验前,我会做一个最小化的预清洗:
function normalizeArgs(rawArgs: unknown): unknown { if (typeof rawArgs !== "string") return rawArgs; const trimmed = rawArgs.trim(); try { return JSON.parse(trimmed); } catch { // 去掉常见 markdown 代码块包裹 const cleaned = trimmed.replace(/^```(json)?\s*/i, "").replace(/```$/, ""); return JSON.parse(cleaned); } }这种清洗不要做得太激进,能处理掉最常见的 markdown 包裹就够了。深层的格式歪曲应该交给模型自己根据错误信息修正,不要在入口硬折腾字符串,否则会掩盖真正的问题。
6. 生产环境里继续踩坑:预转换、未知字段与追踪策略
方案跑通之后,我以为事情结束了,没想到生产环境给了我更多教训。这些坑很有代表性,分享出来可以少走弯路。
6.1 数字与布尔的"字符串化"问题
模型非常容易把整数和布尔值输出成字符串。最典型的是forecast_days: "3"、enabled: "true"。所以我上面的 schema 里用了z.coerce.number(),它会把"3"转成3。但这个函数有个副作用:如果输入是"abc",转换结果是NaN,Zod 默认会认为校验失败吗?实际上NaN是 number 类型,直接z.coerce.number()就通过了。必须再补一个 refine:
const forecastDaysSchema = z.coerce.number().int().min(1).max(14).refine( (val) => Number.isFinite(val), { message: "forecast_days 必须是有限数字" } );布尔值同理,可以用z.coerce.boolean(),但要小心"false"会被转成true。如果工具定义里布尔字段很关键,我倾向于严格校验:只接受true或false,不接受字符串,让模型看到错误后自己修正。
6.2 未知字段到底该 strip、strict 还是 passthrough
Zod 的z.object默认会剥离未知字段。这意味着模型编造的wind_speed会在校验通过之后被直接丢掉,下游不会收到。这种行为在大多数场景下是安全的,但也有隐患:wind_speed被静默剥离后,你不会意识到模型一直在编造字段。
所以我建议根据场景区别对待:
- 关键入口工具:明确用
.strict(),未知字段直接让校验失败,促使模型看到错误、修正行为。 - 一般工具:保留默认 strip,但在日志里记录被剥离的字段名,做统计监控。
- 兼容旧模型的工具:可以用
.passthrough(),把所有字段原样透传给下游,但下游要有防御。
真实环境里,我最终采用的是"strip + 记录被剥离字段"。原因是发现启用 strict 后,模型的重试率明显上升,一些原本可以正确执行的调用也因为多一个无害字段而被迫重试,反而增大了延迟。但如果没有日志监控,你又很难发现"模型是不是一直在编造参数"。
6.3 嵌套对象是重灾区
多层嵌套参数的校验非常容易被低估。比如一个create_order工具,参数里有个shipping_address对象,包含city、street、zip。模型经常出现:
- 只传
city和street,漏掉zip; - 把
shipping_address直接传成字符串"北京市朝阳区xx路"; - 多加一个
phone字段,觉得快递需要联系手机号。
所以只要是嵌套结构,每一层都要用独立的 Zod schema 约束,不要用z.object({ shipping_address: z.unknown() })偷懒。z.unknown()等于放弃了那一层的校验。正确的写法是:
const shippingAddressSchema = z.object({ city: z.string().min(1), street: z.string().min(1), zip: z.string().regex(/^\d{6}$/).optional(), }).strict(); const createOrderArgsSchema = z.object({ items: z.array(z.object({ sku: z.string(), quantity: z.int().min(1) })).min(1), shipping_address: shippingAddressSchema, });6.4 用日志监控"幻觉密度",而不是只看报错
入口校验最大的价值不只是阻止错误,还有积累数据。我在日志里给每次校验失败记录了两个维度:
raw_args:模型原始输出的参数内容;issues:校验器给出的错误明细。
然后按周统计"哪些工具被编造参数最频繁""哪些字段最容易出错""哪些枚举值被错误纳入"。
举个例子:我观测到某个工具里unit字段经常被传成kelvin,后来发现是工具描述里提到了温度,模型把训练语料中常见的温度单位联想进来了。通过在工具描述里显式写明"本工具只支持摄度和华氏度,不支持其他单位",编造率从大约 3% 降到了 0.5% 以下。这个优化不是入口校验本身提供的,而是入口校验的统计数据引导出来的。没有入口日志,你很难发现这个模式。
6.5 重试上限与熔断
给模型恢复机会不等于无限循环。我在 Tool Loop 里设置了重试计数,同一轮对话中同一个工具调用连续校验失败三次就停止,并返回用户友好的文案:"我无法按现有条件完成这个操作,请确认后重试"。这样做是为了避免模型陷入"反复编造参数-反复校验失败"的死循环,白白浪费 token。生产环境的超时和成本控制同样重要。
一些额外的心得
Function Calling 参数的 schema 校验,本质上是在承认一个事实:模型输出永远存在幻觉概率,而我们无法用提示词完全消灭它。与其寄希望于"模型这次跑对了",不如提前假设"模型总会出错",然后用一层机制把错误挡在关键业务之外。
整个方案最核心的收益是:让工具函数本身回归业务逻辑,不用再操心"参数是不是模型编的"。模型输出先经过 JSON 解析、schema 校验、错误反馈,只有干净、合法、符合业务约束的参数才能进入 handler。
如果你正在开发依赖 Function Calling 的 Agent 或自动化工具,我建议你从今天开始就把入口校验当作标准配置,而不是等线上出了事故再补救。写上这一层,你会明显感觉到系统的鲁棒性提升了一个档次,至少我后来再没被unexpected keyword argument这种日志惊出冷汗。