你有没有遇到过这种情况:对接 Function Calling 时,模型一本正经地返回一段 JSON 参数,格式规整、字段齐全,但里面的值根本没法用——日期是2024-13-45,城市是"北京"后面跟着看不见的换行符,数量是-3。模型参数被编造,不是偶发现象,而是概率生成的必然副作用。我的解决思路非常朴素:既然不能保证模型永远不出错,那就在函数入口用 schema 校验把这些非法参数拦下来,让脏数据连函数体都摸不到。
这篇文章不讲空理论,只聊我在实际 agent 项目里是怎么想、怎么选型、怎么落地的。适合正在做大模型应用开发、自己写过 Function Calling 分发逻辑、被模型"一本正经地胡说八道"坑过的工程师。如果你还没遇到过,那我先说一句:早早晚晚的事,看完这篇再去检查你的工具注册代码,大概率能救你一次。
1. 为什么模型会一本正经地编造参数
1.1 一次让我差点回滚上线的故障
先说一个我真实踩过的坑。前阵子我做一个订单助手,模型需要调用create_order工具,入参包括product_id、quantity、remark三个字段。第一轮联调挺顺利,直到测试同学传了一句话:"帮我买三杯美式,备注少冰"。
模型返回的参数长这样:
{ "product_id": "americano_x3", "quantity": "three", "remark": "少冰" }当时 handler 里直接用quantity做乘法计算,JS 的隐式转换把"three"变成了NaN,等到数据库写入报错我才发现。更气人的是product_id根本没有这个 SKU,americano_x3是模型自己拼接出来的。这一个 request 让整个 agent 流程卡了整整一分钟,还污染了一条订单流水。
那次之后我养成了一个习惯:任何从模型返回的入参,先当"不可信输入"处理。你从外部 API 拿的数据会做校验,为什么从模型拿的数据反而裸奔?
1.2 编造参数不是偶发,是机制决定的
我一开始也天真过,以为是 prompt 写得不够清楚,加了一句"请严格按照函数签名返回参数",结果只是从"经常出错"变成"偶尔出错"。后来想明白了,这根本不是 prompt 能根治的问题。
大模型的本质是概率性 token 生成,它擅长的是"看起来合理",而不是"绝对精确"。Function Calling 要求模型把语义理解压缩成结构化 JSON,这个过程里它需要做数值推断、枚举选择、格式匹配,每一步都有概率误差。尤其是整数、日期、枚举这类"离散约束",模型内部并没有一个"类型系统"在约束它,全靠训练时见过的分布去猜。
举个例子。你定义了unit: "celsius" | "fahrenheit",模型大概率能选对,但它不知道"摄氏"这种中文写法不在枚举里。你定义了date: "YYYY-MM-DD",模型大概率会写对,但当对话上下文很长、它需要从一段历史消息里提取日期时,就可能把"2024年3月15日"翻译成2024-03-5。这不是它蠢,而是转换路径太长,中间任何一步注意力漂移都会导致输出偏移。
还有一类情况是上下文污染。如果对话历史里出现过类似的参数值,模型会不自觉地去"复用"那些模式。比如某次工具调用传了city: "北京市",后面所有查询都跟着出现"北京市",甚至用户明确说"查上海"它也可能先输出北京。这不是逻辑错误,是概率偏好。
1.3 编造参数的三种常见面孔
我把实际开发和测试中收集到的模型编造参数场景,整理成了三个大类,大家可以对照一下自己有没有遇过:
| 类型 | 典型表现 | 后果 |
|---|---|---|
| 类型错误 | 数字写成字符串、布尔写成"true" | 计算产生 NaN、数据库字段类型冲突 |
| 格式错误 | 日期写成2024/03/15、邮箱少个后缀 | 下游服务直接报 400 |
| 枚举越界 | status 传了"openned"、city 传了不存在的值 | 查询结果为空或返回不该返回的数据 |
还有一种更隐蔽的:模型往参数里塞了一个 schema 里根本不存在的字段,比如你的工具只需要name和age,它额外塞了个hobby。JavaScript 的 JSON.parse 不会报错,函数内部如果没做严格白名单,这个多余字段会被静默丢弃,也可能被意外使用,行为就偏了。
这三种面孔的共同点是:模型自己觉得"我填得很对啊",但你的业务系统不这么认为。所以问题的核心不是"如何让模型不出错",而是"如何让错误在进入业务逻辑之前被识别"。
2. 入口校验,为什么必须挡在函数前面
2.1 别在每个函数内部做防御
很多人第一反应是:那我在每个函数开头if判断一下不就行了?比如:
if (typeof params.quantity !== 'number') throw new Error('quantity must be number'); if (params.quantity <= 0) throw new Error('quantity must be positive');这种写法不是说不行,而是当你的工具数量超过五个,你会发现三件事:
第一,校验代码量远超业务代码量。一个create_order的判断要写十几行,来十个工具就是一百多行重复劳动。而且项目里不同的人写法还不一样,有人用typeof,有人用instanceof,有人直接 null 判断,规范完全靠自觉。
第二,校验散落在各个函数里,等于没有一个统一的"失败反馈出口"。模型调用工具失败后,你需要把错误信息回传给模型让它自我纠正,这时候你希望对所有工具都用同一种错误响应格式。如果每个函数的报错文案都是手写的,回传质量完全不可控。
第三,也是最容易忽视的一点:函数内部校验通常发生在业务代码已经执行了一部分的时刻。比如你在写日志的时候才发现参数不对,那参数已经参与过日志拼接了,虽然不至于出严重事故,但排查的时候你会分不清"到底哪一步开始出了问题"。
所以我的选择是把校验统一收敛到一个分发层,在调用具体 handler 之前完成,做成一个对所有工具生效的"门卫"。
2.2 校验的本质是建立契约
做过前后端联调的人应该都熟悉"接口契约"这个概念:前端按文档传参,后端按文档校验。Function Calling 也是一样的逻辑,只不过"前端开发者"变成了一个不怎么听话的模型。
schema 在这里承担的就是"契约"角色,而且是双份契约:
一份是给模型看的。你把参数的结构、类型、枚举、描述都写清楚,注入到 Function Calling 的工具定义里,让模型在生成时参考。相当于给模型一张标准答案表。
另一份是给运行时看的。同一个 schema,在入口处做严格解析,任何不符合定义的值直接挡下。相当于查票员。
这里有个非常关键的原则:两份契约必须同源。你手写在工具定义里的 JSON Schema 和你在代码里手写的校验逻辑,如果分别是两份文档,那么它们迟早会不一致。比如你更新了工具定义里的枚举值,但忘了改校验函数,就会把模型明明可以正确返回的合法值给拦下来。
所以我倾向于用同一个 zod schema 作为唯一事实源,既用它定义类型,也用它做校验,再通过代码生成的方式把它转成 tool definition 喂给模型。
2.3 错误信息本身就是模型的"作业反馈"
入口校验的价值还不止"防止脏数据",它还能把失败变成一个可逆转的环节。
做过 AI Agent 的朋友都知道,模型调用工具失败后,你完全可以把失败信息作为 tool 消息回传给模型,让模型自己修改参数,然后发起新一轮调用。很多模型在拿到"quantity 必须是数字,你传的是字符串 'three'"这样的反馈后,能够自己修正为 3。
这个自我纠错的循环,前提就是校验层的错误信息是结构化、可读的。zod 的error.issues天然就是数组结构:
[ { "path": ["quantity"], "message": "Expected number, received string" } ]我把这些 issues 精简成自然语言后回传给模型,它就能知道具体是哪个字段错了、为什么错了、该怎么改。这个机制跑起来之后,我甚至觉得"模型偶尔犯错"变得没那么可怕了——只要你给它一个明确的纠正通道。
3. schema 选型:从 Zod 到 JSON Schema
3.1 手写 if 校验的致命问题
很多从 Java 或 Node 走过来的同事,看到 zod 的第一反应是"又是一个轮子"。但等你真正手写过一段参数校验,你就会发现它的痛点。
手写 if 校验的问题在于:它没有类型推导。同一个参数在三个函数里被定义成三种写法,你根本不知道哪个是对的。等你开始做前端和后端共享类型定义时,手写 if 更是彻底崩溃——你就得维护两套代码,一套 TS 类型,一套运行时校验。
另一个问题是边界条件极难想全。比如你校验一个日期字符串,typeof === 'string'通过了,但new Date('2024-02-30')在 JS 里不会抛错,它会自动帮你纠正成 3 月 1 日。你写 if 判断的时候根本想不到这一层,而 schema 库天然把这些边界收敛好了。
3.2 为什么我选了 Zod schema
Zod 作为 TypeScript 生态里最流行的校验库,它的三个特性让我觉得它就是干这个活的:
一是safeParse不会抛异常,返回的是带success标记的联合类型。这非常适合在分发层做"成功走成功逻辑、失败返回失败信息"的流程控制,不用像 try/catch 那样接住异常再判断异常类型。
二是z.infer可以直接从 schema 推导出 TS 类型。定义完 schema,handler 的参数类型就有了,不用另外手写 interface,天然避免"类型文档和校验逻辑不一致"。
三是describe方法可以把字段说明直接挂在 schema 上,后续转成 JSON Schema 给模型时,这些描述会变成模型可以阅读的字段说明。等于一份定义,两种消费方式。
当然,JSON Schema 本身也不是不行,但手写 JSON Schema 可读性实在太差。你写一个嵌套对象要十几行,而且编辑器没有类型提示,写错了要到运行时才炸。用 zod 写,再通过zod-to-json-schema转成 JSON Schema,是最舒服的路径。
3.3 把 schema 描述注入到函数注册表里
实际操作中,我维护了一个工具注册表,每个工具的结构是统一的:
import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const WeatherQuerySchema = z.object({ city: z.string().min(1).max(50).describe('城市名称,使用中文全称,例如:北京'), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('查询日期,格式为 YYYY-MM-DD'), unit: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('温度单位'), }); export const weatherTool = { name: 'get_weather', description: '查询指定城市在指定日期的天气情况', schema: WeatherQuerySchema, jsonSchema: zodToJsonSchema(WeatherQuerySchema, 'get_weather'), handler: async (args: z.infer<typeof WeatherQuerySchema>) => { // 真实业务逻辑 return `...`; }, };这样的好处是:注册工具时写一次 schema,运行时校验用它,生成给模型的 function definition 也用它,永远不会出现两套定义打架的情况。模型在生成参数时看到的就是你校验用的同一套约束,等于"标准答案"和"阅卷标准"是同一份。
4. 实操:把校验层织进 Function Call 调度器
4.1 先画清楚一次完整的调用链
在写代码之前,我建议你先在心里走一遍标准流程。我的实现分成这么几步:
- 模型返回一个
tool_calls数组,里面包含工具名name和原始参数arguments。 - 框架把
arguments从 JSON 字符串解析成对象。 - 分发器根据
name找到对应的工具定义。 - 用工具的
schema.safeParse对解析后的对象做校验。 - 如果解析失败或校验失败,构造一条错误信息作为 tool 消息回传给模型,让模型自我修正。
- 如果校验通过,才调用真正的
handler。
注意第 2 步很容易被忽略:模型返回的arguments是一个 JSON 字符串,它经常会在字符串前后带多余字符,或者把布尔值写成字符串。JSON.parse 本身就有可能失败。所以我在解析阶段也放了兜底逻辑,这会在后面第 5 节展开。
4.2 核心代码:一个 SafeTool 包装器
我写了一个统一的调度逻辑,所有工具都走同一个方法,不单独写每个函数的校验头。核心代码如下:
type ToolDefinition<TSchema extends z.ZodTypeAny> = { name: string; description: string; schema: TSchema; handler: (args: z.infer<TSchema>) => Promise<string>; }; async function executeToolCall(tool: ToolDefinition<any>, rawArguments: string): Promise<string> { // 1. 先将原始字符串解析为对象 const parsed = tryParseJSON(rawArguments); if (!parsed.ok) { return `无法解析参数 JSON:${parsed.reason}。请输出合法的 JSON 对象。`; } // 2. 在入口处用 schema 严格校验 const result = tool.schema.safeParse(parsed.value); if (!result.success) { const detail = result.error.issues .map((issue) => `${issue.path.join('.') || 'root'}: ${issue.message}`) .join('; '); return `参数校验未通过:${detail}`; } // 3. 全部通过,才进入真实业务逻辑 return tool.handler(result.data); }这个方法最大的价值在于:你新增一个工具时,只需要定义好 schema 和 handler,校验逻辑是完全自动挂上去的,根本不用记得写 if。我后来把二三十个工具都迁到这个结构下,代码量肉眼可见地减少。
tryParseJSON是我封装的修复函数,稍后单独讲。这里先假设它返回{ ok: true, value }或{ ok: false, reason }两种结构。
4.3 错误信息回传给模型的具体写法
很多人在第一步就掉坑了:他们只把错误信息打印到日志,而没有回传给模型。模型完全不知道自己的参数有问题,下次要么重试同样的错误参数,要么干脆放弃调用。正确的做法是把它作为 tool 消息放进 messages 数组,让模型看到失败原因后发起新一轮修正。
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify({ ok: false, message: `工具 ${call.name} 参数校验未通过:${detail}`, }), });你会发现模型收到这条消息后,通常会在下一轮调用里生成新的参数,并且很可能会把错误点都改正。这比你在日志里骂"模型真蠢"要管用得多。当然,返回给模型的错误信息要经过清理,不能带上内部堆栈,这个细节我放在排查实录里讲。
5. 掉过的坑与排查实录
5.1 zod 默认会把多余字段剥掉
我第一次用z.object()时,以为解析失败就只会因为类型不对。后来发现 zod 的默认行为是strip:如果模型传了一个 schema 里不存在的字段,它不会报错,而是默默把那个字段删掉,校验照样通过。
一开始我还觉得这是好事,"至少不报错啊"。直到有次我写了一个工具,schema 里只有一个keyword字段,模型自作主张传了keyword和filters两个字段。zod 把filters剥掉了,校验通过了,搜索出来的结果自然不对。我排查了半天才意识到:模型原本想表达的搜索意图,因为字段被静默丢弃而流失了。
解决方案是:在关键的 object schema 上调用.strict(),让未知字段直接触发校验失败,至少让模型知道"你多传了不支持的参数"。
const Schema = z.object({...}).strict();如果你希望支持额外字段但又想显式处理,可以用.catchall(z.unknown())让它们保留下来,但这种情况我觉得不太适合 Function Calling 场景,因为未知字段通常意味着模型理解偏了,不如直接暴露问题。
5.2 枚举校验在测试阶段救了我一次
有一次做一个状态流转工具,我定义了status: z.enum(['pending', 'approved', 'rejected'])。测试时我故意让模型处理一条"把订单置为已驳回"的消息,模型返回的是status: "驳回"。如果没有 zod 的枚举约束,这个值会直接进入业务层的 switch 分支,匹配不到任何 case,最后静默失败——订单既没有驳回,也没有报错,用户会以为成功了。
加入枚举校验后,错误信息直接指明"status 只能是 pending/approved/rejected 之一",模型下一轮就修正成了rejected。一件事让我意识到:枚举字段是模型幻觉的高发区,能枚举就一定要枚举,不要用字符串一把梭。
5.3 别把内部错误堆栈喂给模型
校验通过之后,handler 内部的错误也有可能发生。我一开始图省事,捕获到 handler 异常后直接把error.message和堆栈一起返回给模型,心想"让模型帮我分析分析"。结果模型确实分析了,然后把堆栈里的内部文件路径原样输出给了用户,我只好连夜改逻辑。
现在我的原则是:任何回传给模型的内容,都要经过"脱敏和精简"。内部异常只记录到日志,回传的消息最多包含"工具执行失败,请稍后重试"这类信息,具体错误细节只在开发调试模式下暴露。校验错误虽然可以给模型看路径和原因,但也只给出字段名 + 简短原因,不要把整个 error 对象序列化出去。
5.4 被 JSON.parse 卡住的格式问题
最后说说tryParseJSON的必要性。模型返回的arguments字符串偶尔不是标准 JSON,常见情况有:字符串被反引号包住、内容前后有说明文字、JSON 里出现尾逗号。我写了一个轻量兜底:
function tryParseJSON(raw: string): { ok: true; value: unknown } | { ok: false; reason: string } { try { return { ok: true, value: JSON.parse(raw) }; } catch { // 常见兜底:截取第一个 { 到最后一个 } 之间的内容再试一次 const start = raw.indexOf('{'); const end = raw.lastIndexOf('}'); if (start !== -1 && end !== -1 && end > start) { try { return { ok: true, value: JSON.parse(raw.slice(start, end + 1)) }; } catch { // 忽略 } } return { ok: false, reason: `不是合法 JSON:${raw.slice(0, 80)}` }; } }这个函数不完美,但能兜住大部分模型输出不规范的情况。如果连兜底都失败,那就直接让模型重新生成,不需要再尝试 "修复" 了,因为它大概率是生成路径出了大问题。
最后再分享一个我的日常习惯:现在每次新建工具,我不先写 handler,而是先把 schema 定义出来,再让注册表自动把 schema 转成工具定义。这样从源头避免了"定义和校验不一致"的问题。我最近在做的一个 agent 项目里,模型编造参数的事件基本清零了,剩下偶尔的错误也能在模型自我修正环节解决,脏数据很少有机会流进业务代码。如果你也在被 Function Calling 的不可控参数困扰,先别急着给 prompt 上强度,把 schema 挡在入口,成本最低,收益最直接。