如果你正在做一个接入了大模型的工具调用平台那你大概率迟早会遇见这么个场面模型信誓旦旦地输出了一段格式完美的 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 SchemaZod 社区里有现成的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.infertypeof 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 和 messagefunction 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 源码高。这个细节我反复测过多次属于那种你知道了就立刻能用的经验。
阅读完成 · 觉得有帮助?