如果你正在做一个接入了大模型的工具调用平台,那你大概率迟早会遇见这么个场面:模型信誓旦旦地输出了一段格式完美的 Function Calling 请求,你满怀期待地把参数丢给业务代码,然后程序在两秒钟之内炸给你看。我是在一次给客户做企业知识库 AI 助手时,连续三天被这种“模型编造参数”的事情折腾到怀疑人生的。当时整个平台接了十几个工具,包括查订单、查库存、发审批、写工单,几乎每个工具都出现过参数被模型随意篡改、凭空捏造的情况。后来我做了一个决定:所有从模型侧过来的函数参数,一律必须先过 schema 校验,校验不通过就拦在入口,绝对不让脏数据进入业务逻辑。这篇文章就是把这段经验完整拆开讲一遍,包括我踩过的坑、为什么选 schema 方案、具体怎么落地,以及那些常规文档里不会写出来的细节。
1. Function Calling 参数被模型编造,到底是怎么回事
1.1 我踩坑的那个项目长什么样
先交代一下背景。那个项目是一个企业内部的 AI 助手,核心能力是让用户用自然语言查询和操作系统里的数据。底层用的就是大模型的 Function Calling 能力:模型根据用户的问题,决定调用哪个工具、填入哪些参数,然后我们这边拿到参数去执行真实操作。
听起来很顺是不是?但实际跑起来就完全是另一回事了。我印象最深的一次,是模型调用一个查天气的工具,用户问“北京明天冷不冷”,模型给出的参数是:
{ "city": "北.京", "date": "2023-13-45", "unit": "celsious" }city 里多了个点,date 是根本不存在的月份和日期,unit 更是把 celsius 拼错了。这种参数要是直接拿去调天气接口,大概率就是 400 报错;如果数据再脏一点,比如调用写数据库的工具,就有可能把错误数据写进去。
当时团队里还有人安慰我,说“大模型偶尔出点错很正常”。但问题是:Function Calling 如果出错,不是偶发,而是体系性的问题。我们后来做过一个粗略统计,在完全不做任何校验的情况下,工具调用参数有问题的比例能占到 15% 到 20%。这个数字在客户现场是绝对没法交代的。
1.2 模型为什么会“一本正经地编参数”
要从根上理解这件事,得搞清楚大模型生成 Function Calling 参数的机制。模型在生成文本时,是逐字逐字地按概率采样,它并没有一个“数据库字段合法性检查器”在背后兜底。所谓 Function Calling,本质上只是让模型按照预设的 JSON Schema 格式去生成一段 JSON,模型只是“尽力遵循格式”,而不是“严格校验内容”。
举个例子。对于date这个字段,模型知道应该填一个字符串,但它不知道 2023-13-45 这个日期在日历上是不存在的。因为它见过的文本里,“13月45日”这种组合出现的频率很低,但表示日期的模式“数字-数字-数字”到处都是,它只要把“看起来像日期”的东西填进去就行了。这就是为什么日期、枚举值、数字范围这类强约束字段最容易出错。
还有一类编造来源于系统提示词和上下文的干扰。我试过在 system prompt 里同时塞进一大堆业务规则,结果模型理解偏了,把参数的语义搞错。比如用户问“最近一周的订单”,模型在调用订单查询工具时,把status参数填成了“最近的”,而 schema 里要求的是枚举值pending、shipped、completed中的一种。模型不是坏,是它把自然语言里的语义直接投射到了参数值上。
1.3 编造参数的类型总结
我把那段时间遇到的参数问题归了一下类,主要有这几种,你们可以对号入座:
| 编造类型 | 典型表现 | 后果 |
|---|---|---|
| 拼写错误 | 枚举值celsius写成celsious | 接口报错 |
| 格式错误 | 日期2023-13-45、时间多了空格 | 接口报错或静默解析失败 |
| 类型错误 | 数字字段填成字符串 “5.0” | 强类型后端直接 500 |
| 值域越界 | ID 填负数、数量填 99999 | 业务逻辑异常 |
| 额外字段 | 凭空加city_code等未定义字段 | 严格校验失败 |
| 缺字段 | 必填的user_id直接没填 | 业务无法执行 |
看到这个列表你就明白了,这根本不是“偶尔出错”的问题,而是需要系统性防御的问题。我当时的第一反应,就是在入口处给所有函数参数加一道校验。
2. 为什么我在入口用 schema 校验而不是手写判断
2.1 手写 if/else 三天后我放弃了
踩坑初期,我其实先试的是手写判断函数。毕竟是“几个字段而已”,感觉没必要上什么框架。结果三天之后我就彻底放弃了。原因很简单:工具一多,判断逻辑就开始指数级膨胀。
比如一个工单创建工具,参数有三层嵌套:
{ "title": "网络故障报修", "priority": "high", "contact": { "name": "张三", "phone": "13800138000" }, "tags": ["网络", "紧急"] }手写校验的话,你得检查 title 非空且长度小于某个值,priority 在白名单里,contact.name 非空,contact.phone 符合手机号正则,tags 是数组且数组元素都是字符串且去重后数量不超过限制……这还只是一个工具。当时平台里有十几个工具,如果每个都手写一套,维护成本直接起飞。
更要命的是,手写校验和模型侧的 JSON Schema 是两份东西。你在平台上给模型描述了参数规则,又在本地手写了一套校验逻辑,两边一旦不同步,就会出现“平台说可以、本地说不行”之类的奇怪问题。
2.2 Zod 让“模型看到的约束”和“系统校验的约束”统一
后来我切到了 TypeScript 生态里一个很成熟的库:Zod。选它的核心原因有三个。
第一,它把类型定义和运行时校验合并了。你写一个z.object既能作为 TypeScript 类型推导,又能在运行时做校验。这样业务代码里拿到的就是已经校验通过的、类型安全的对象,压根不需要再写一堆if (typeof x === "string")这种防御代码。
第二,它和 JSON Schema 能互相转换。大模型平台在配置 Function 时通常要求提供 JSON Schema,Zod 社区里有现成的zod-to-json-schema这个库,可以从同一个 Zod 定义导出平台需要的 JSON Schema。这样“模型看到的规则”和“本地执行的规则”是同一份,不存在两套标准打架的问题。
第三,错误结构化。Zod 的safeParse会返回一个包含具体错误路径和原因的对象,我可以很轻松地把错误信息格式化后回传给模型,让它自己修正。这个能力在做自纠机制时极其重要,后面会详细讲。
2.3 其他方案我要不要用:JSON Schema + ajv、Pydantic
有朋友可能会问,直接用 JSON Schema 加 ajv 校验不行吗?其实也行。我在早期对比过,ajv 性能很好,生态也成熟,但它在 TypeScript 项目的开发体验上不如 Zod。因为 JSON Schema 本身只是一个 JSON 文件,你在 TypeScript 里还得额外写类型定义,两边要手动保持同步。Zod 是“一份代码,类型和校验全都有”。
如果你用的是 Python 后端,那我会推荐 Pydantic。它和 Zod 的思路类似,都是“声明式 schema + 运行时校验”。不过这个项目里我们是 Node.js 技术栈,所以最终选了 Zod。选型这种事,始终要贴合团队的技术栈,不要盲目追求“看起来最流行”的。
3. 完整实操:把 schema 校验挡在 Function Calling 入口
3.1 整体流程划分:生成、解析、校验、执行
先把我最终落地的执行链路画出来,你们就清楚“入口”到底指哪个位置了:
- 模型根据用户问题生成一个 function call 请求,里面包含 toolName 和一个参数对象。
- 平台拿到这个请求后,先做基础解析:确认工具存在、参数是一个 JSON 对象。
- 进入入口校验层:用对应工具的 Zod schema 对参数做
safeParse。 - 校验通过,进入业务 handler 执行;校验失败,进入自纠流程。
- 自纠流程将错误信息回传给模型,模型重新生成 function call,最多重试两轮。
这个链路里最核心的就是第 3 步和第 4 步之间的那个闸门。我要强调一下:这一步必须在进入任何业务代码之前完成,因为一旦参数穿过了闸门,业务层就会认为它是“可信的”,后面的代码不会再做任何字段级防御。
3.2 定义工具 schema:从 JSON Schema 到 Zod
我在项目里定义工具的写法大概是这样的。每个工具都包含name、description、parameters三件套:
import { z } from "zod"; const weatherTool = { name: "get_weather", description: "查询指定城市在指定日期的天气情况", parameters: z.object({ city: z.string().min(1, "城市名不能为空"), date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式必须为 YYYY-MM-DD") .refine((v) => { const d = new Date(v); return !isNaN(d.getTime()); }, "日期必须真实存在"), unit: z.enum(["celsius", "fahrenheit"]), }), };这里有几个我反复调整过的细节。日期校验我坚持用“正则 + 真实日期判断”两道关卡,因为只靠正则拦不住 2023-13-45 这种不存在的日期,只靠Date.parse又可能对格式过宽泛。两个一起用,基本就没有漏网之鱼了。
unit这种枚举字段必须用z.enum,不要用z.string()加后续判断。因为z.enum在报错的时候能明确指出“期望的值是这些,你给的是那个”,这个报错信息对模型自纠非常友好。
3.3 统一执行入口的代码实现
光有 schema 还不够,得有一个统一的执行入口。我封装了一个executeTool方法:
async function executeTool(toolName: string, rawArgs: unknown) { const tool = tools[toolName]; if (!tool) { return { ok: false, error: "tool_not_found" }; } const parsed = tool.parameters.safeParse(rawArgs); if (!parsed.success) { const digest = formatZodError(parsed.error); console.error(`[tool-executor] invalid args for ${toolName}`, { rawArgs, digest, }); return { ok: false, error: "invalid_tool_args", digest }; } try { const result = await tool.handler(parsed.data); return { ok: true, data: result }; } catch (e) { console.error(`[tool-executor] handler failed for ${toolName}`, e); return { ok: false, error: "handler_error", message: String(e?.message ?? e) }; } }这个入口的思路是:不管传入的rawArgs是什么牛鬼蛇神,一律走safeParse。注意我用的是safeParse而不是parse,因为parse在失败时会直接抛异常,而safeParse会返回一个包含错误信息的结果对象,方便我做流程控制。
所有业务 handler 的签名我都约束成了(args: z.infer<typeof tool.parameters>) => ...。也就是说,handler 内部拿到的参数一定是校验过的、类型安全的,不用再做任何防御式判断。如果你的 handler 里还在写“参数可能是 undefined,所以我要兜底”,那说明校验层没做到位。
3.4 校验失败后的自纠机制实现
校验失败之后,很多人第一反应是直接给用户报错“工具调用失败”。这个做法我试过,用户体验很差。后来我改成了“让模型自己修正”的方式,效果好了不止一个档次。
具体做法是:当executeTool返回invalid_tool_args时,我把错误摘要拼接成一条 assistant 消息,追加到对话里,再让模型重新生成一次 function call。这段消息我写成了一个模板:
你上一次发起了一个工具调用,但参数未能通过系统的合法性校验。 工具名称:{{toolName}} 参数原文:{{rawArgs}} 校验错误:{{digest}} 请根据错误信息修正参数后重新发起调用,不要修改用户原本的意图。这里有个关键点:错误信息不能太“程序员化”。比如 Zod 默认的错误 message 是Invalid enum value. Expected 'celsius' | 'fahrenheit', received 'celsious',这种给模型看是没问题的,但如果你把整个 error 对象 JSON 序列化后塞给模型,它反而会被大量无关字段干扰。所以formatZodError我做了精简,只取 path 和 message:
function formatZodError(error: z.ZodError) { return error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; "); }实测下来,第一次自纠的成功率大概在 70% 左右。也就是说,100 次校验失败里,大约有 70 次模型看了错误信息就能输出正确的参数。第二次自纠还能再救回一部分,但边际效益已经很低,所以我把重试次数上限定在两轮。超过两轮就放弃自纠,直接返回给前端一个“工具调用失败”的提示,并附上模型最终想调用的参数原文,方便排查。
3.5 Prompt 层面的配合:减少编造概率
校验是兜底,但真正能减少编造概率的,还得是 prompt 层面的配合。我在实践中发现两个非常管用的招。
第一,在 system prompt 里注入当前日期时间。很多模型根本不知道“今天”是哪天,你如果不告诉它,它就会根据训练数据猜测一个日期出来。往 system prompt 里加一行:
当前日期:2025-04-08,当前时间:14:30。加了这个之后,日期类参数的出错率肉眼可见地下降,尤其是涉及“今天”“明天”“本周”这类相对时间表达时。
第二,在工具的 description 里写清楚每个参数的业务语义和边界。比如订单查询工具的status参数,我会在 description 里写:
status 可选值为 pending、shipped、completed。如果要查全部状态,不要传 status 字段。不要小看这句话。模型在决定填什么值的时候,会优先参考 description 里的示例和限制。description 写得越具体,模型瞎编的概率越低。
4. 落地过程中的常见问题与排查技巧
4.1 Schema 严格程度的设计
这里要提醒一个坑:schema 太严格会误伤,太宽松又拦不住脏数据。我在早期吃过“一刀切 strict”的亏。当时我把所有z.object都加了.strict(),也就是不允许出现 schema 之外的字段。结果模型偶尔会自作聪明在参数里多加一个city_code或者locale字段,然后就整个校验失败。
后来我调整了策略:核心业务工具用.strict(),因为模型的额外字段往往意味着它理解偏了,不如让它重新生成;但一些只读查询类的工具,如果必填字段都对,多出来的字段我可以 strip 掉,不阻断整个调用。你可以在z.object({...}).strip()和.strict()之间按工具的实际风险级别来选择,千万不要一把梭。
4.2 日期、枚举、数字这些高频雷区
我跟团队总结过一个“雷区优先级”清单,每次新增工具都对着检查一遍:
- 日期字段:必须正则加真实日期双重校验。
- 枚举字段:必须用
z.enum,且 description 里列出所有候选值。 - 数字字段:分清
z.number().int()和z.number(),ID 类字段要加positive()。 - 数组字段:要限制元素类型和最大长度,防止模型生成一个超大的数组。
- 嵌套对象:要注意
nullable()和optional()的区分,模型经常把null和“字段不存在”搞混。
另外,很多模型在输出数字时,偶尔会输出带引号的字符串,比如"5.0"。如果你在平台侧的 JSON Schema 里已经声明了"type": "number",大部分模型不会犯这种错,但保险起见,我会在入口对数字类型做一次宽松解析,把纯数字字符串转成 number 再交给 Zod 校验。这个宽容度是值得的,因为它的成本很低,但能救回不少调用。
4.3 日志与观测:怎么快速定位问题
校验这个事,光有拦截还不够,你得有日志能还原现场。我固定的做法是,在校验失败时打一条结构化日志,包含四个关键信息:toolName、rawArgs 原文、校验错误摘要、关联的对话 ID。这样一旦客户反馈某个操作失败,我能立刻从日志里看到“模型当时到底填了什么垃圾参数”,快速区分是模型问题还是 schema 配置问题。
日志里我还会把“校验失败”和“自纠成功”的次数做一个计数器上报。上线一段时间后,你就能看到每个工具的单次调用平均自纠率。如果某个工具的自纠率特别高,说明它的参数 schema 或者 description 写得不够清楚,需要回头优化 prompt,而不是单纯依赖校验兜底。
4.4 重试与限额:别让自纠变成无限烧钱
自纠机制好用,但它是要花钱的。每多一次自纠,就多一轮模型调用,token 成本直接翻倍甚至更多。如果不加限制,模型在极端情况下会陷入循环:改来改去都不过,然后一直重试。我在代码里用一个简单的计数变量控制,超过两轮就彻底打断,返回一个固定的错误给上层。这个限制必须写在入口层,而不是写在对话逻辑里,否则很容易漏掉某个分支。
我这里给一个参考配置:自纠上限 2 轮,每轮之间不做额外等待,单次工具调用整体超时 15 秒。这个配置在我的项目里兼顾了成功率和成本。
4.5 常见问题速查表
| 问题 | 现象 | 排查方向 |
|---|---|---|
| 校验总失败但参数看起来正常 | 可能是 schema 太严格 | 检查是否误用了.strict()或对可选字段用了必填约束 |
| 日期字段频繁出错 | 模型不知道今天日期 | system prompt 注入当前日期 |
| 枚举字段拼写错误多 | description 里没写清候选值 | 在 description 中列出所有枚举值和含义 |
| 模型多传了未知字段 | 校验直接失败 | 按工具风险决定用 strip 还是 strict |
| 自纠一直不成功 | 错误信息不够清晰 | 检查 formatZodError 是否把错误路径和期望值说清楚了 |
| 校验通过了但业务还是炸 | schema 覆盖不全 | 检查是否有日期真实性、数字范围等 refine 校验缺失 |
5. 效果与一些个人体会
这套方案上线之后,工具调用因为参数问题导致的失败率从 20% 左右降到了 3% 以内,而且剩下的 3% 基本都能在日志里快速定位。更重要的是,业务代码变得干净了,handler 里几乎没有if (data && typeof data.x === ...)这种防御代码。我个人的体会是,做 Function Calling 相关系统,一定要把大模型当成一个“不太靠谱但能力很强的新同事”来看。它给你的参数,本质上是一段不可信的外部输入,千万不要因为它的语气自信就放松警惕。schema 校验挡在入口,不是对模型的不信任,而是对整个系统的负责。
最后再分享一个小技巧:给校验失败设计的错误消息模板,不要写得太技术化。用大白话告诉模型“这个字段应该是什么格式、有哪些可选值”,它自纠的成功率会明显比给一串 zod 源码高。这个细节我反复测过多次,属于那种你知道了就立刻能用的经验。