
前阵子线上一个自动工单系统连续报了几次错翻日志发现根本不是我们工单服务的问题而是 Function Calling 这层传进来的参数本身就离谱用户明明说周五之前处理模型往 deadline 字段里填了个下周五优先级枚举约束了 low/medium/high模型偏偏传了个 High更狠的是有一次必填的 userId 直接不填了。这类问题在大模型应用里实在太典型了——模型生成参数本质是概率采样自带随机性而下游业务函数是强约束的两者中间缺了一层边界守卫。我的解法是给所有函数调用统一套一层 schema 校验用 zod 定义参数结构模型返回先过校验再执行不合法就直接拦下、让模型重新生成。今天把这套方案完整拆开讲包括为什么选 Zod、schema 怎么设计、校验失败后的重试与降级以及落地过程踩过的真实坑。如果你也在做 Agent 或者工具调用类的开发这套思路可以直接抄。1. 先看清楚问题大模型填参数为什么会填错1.1 每个顺手一填背后都藏着一次风险Function Calling 的工作机制大家都不陌生把函数的名字、描述、参数结构告诉模型模型根据自己的理解生成一个 JSON我们再拿这个 JSON 去调真实函数。问题就出在这句根据自己的理解上。模型生成参数的过程不是查数据库不是读一个确定的枚举表而是逐 token 做概率采样。你说它不靠谱吧大多数时候填得确实像模像样你说它靠谱吧它真的会在细节上给你来一个措手不及。我自己的项目里模型折腾出来的参数错误类型超乎想象地多。日期字段填下周五算温柔的了还有填2024/8/6Aug 6nd, 2024的枚举字段把 high 填成 High 或者 priority1 的用户根本没提创建时间模型自作主张补一个当前时间进去的甚至出现过模型把字符串数组字段填成整个句子、把 JSON 输出到一半截断的情况。我把这些错误大致归类了一下错误类型实际案例发生原因日期/时间格式混用用户说八月一号模型填 2024/08/01模型不知道下游解析器到底要什么格式又懒得换算枚举值大小写/表述混乱priority 填 High、1、甚至 high priority模型把枚举当语义理解而不是当成精确语法必填字段缺失模型直接跳过 userIdrequired 约束被忽略或模型认为该字段没必要多余字段凭空加 status:open模型根据上下文过度推导JSON 截断arguments 少右括号max_tokens 不够或输出被打断空字符串/nullarguments 为 某些异常分支模型根本没走正常输出注意这里面有好几类即便再优化提示词也只能降低概率不可能消除概率——尤其是日期换算和枚举表述这一类模型自己都不理解什么叫精确的枚举它只会模仿训练数据里的写法。所以纯靠提示词压错误是不现实的必须有一个运行时环节去强制校验。1.2 不拦截时错误参数会在系统深处爆炸我曾经觉得反正函数内部有防御性判断传错了再抛异常也不迟这个想法害我多加了两个通宵的班。因为不拦截的错误参数出现的时机和地点都很刁钻。最轻的情况是函数抛异常用户看到错误提示但根本不知道是自己哪句话触发的问题。中等的情况是函数宽容地接收了错误参数比如日期字段传了个下周五我们的解析库一解析直接覆盖成今天后端存了一条错误日期的工单——这种脏数据最可怕的地方在于你从函数内部看一切正常没有任何报错问题会在很久以后统计报表时才浮出水面。更严重的是 Agent 自动化场景。模型在工具调用循环里会连续做好几个动作一旦中间某个函数调用非法它很可能会拿上一个函数的错误输出继续推理然后把后续操作全部带偏。等到你发现时链路已经执行了好几环甚至执行了不可逆的操作。而且还有一个很容易被忽视的排查成本当函数内部抛异常你从错误堆栈往上追得跨过业务代码、跨过调用层、跨过模型输出层才能定位到原来是 Function Calling 参数错了。如果一层层日志没打全光靠猜就能耗掉一整天。所以我在项目里定了一个规矩所有外部模型传入的参数一律在进入业务代码之前完成 schema 校验校验不过就地拦截绝对不把问题留给下游。2. 为什么我把校验方案押在Zod上而不是手写一堆if-else2.1 手写校验代码是怎么一步步失控的最早我没用任何库就是每个函数入口处写几行 if。一个函数两三个参数时完全能扛住但函数数量一多就露馅了。首先是校验代码和业务逻辑混在一起读代码的人分不清哪些是防御、哪些是核心处理逻辑。其次是嵌套对象、数组元素这类结构手写起来又臭又长很容易漏判。第三是 TypeScript 类型收窄的问题——手动判断完之后TS 推断出来的类型依然可能是 any类型保护等于没有。反正我当时的体感是为了让一个参数校验通过我写的防御代码比业务代码还多几倍这显然不是合理的方向。更麻烦的是写的时候容易漏改的时候容易错。函数定义一变校验逻辑经常忘了同步更新于是出现类型里说允许、校验里实际拒绝这种让人抓狂的状态。2.2 一份schema同时管住模型定义和运行时校验后来换成 Zod核心原因是它的模型足够自洽。一个 schema 可以通过z.infer直接推导出对应的 TypeScript 类型类型和运行时校验规则共享同一份定义不会出现类型里写 string、校验逻辑里写 number 判断这种低级失配。更重要的是Zod 的 schema 可以通过zod-to-json-schema转换成 JSON Schema而 Function Calling 的函数参数定义本身就是一个 JSON Schema。这意味着我可以做到一份 schema 两头用请求时把转换后的 JSON Schema 发给模型告诉它参数的合法结构返回时用同一个 schema 做运行时校验看模型实际交出来的参数是不是符合结构。这个两端同源太重要了。如果 model definition 和校验规则是两套东西你很难保证它们永远一致——模型看到的是日期格式任意实际校验却要求严格 YYYY-MM-DD模型当然会反复撞墙。而用同一个 schema等于模型和我看到的是同一份契约。我当时实际用的 schema 大概是这样的import { z } from zod; const createTicketSchema z.object({ title: z.string().min(1).max(128), priority: z.enum([low, medium, high]), tags: z.array(z.string().max(32)).max(20).optional(), deadline: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, { message: deadline必须使用YYYY-MM-DD格式例如2024-08-06 }) .optional(), userId: z.string().min(1) }); type CreateTicketInput z.infertypeof createTicketSchema;看着不复杂但它做完了几件事必填字段只有 title、priority、userId枚举值严格限定deadline 可选但格式被正则卡死tags 数组数量上限和元素长度都有限制类型推断自动完成handler 里拿到的参数天然是强类型。2.3 设计schema时我遵循的几条原则schema 不是把字段列出来就完了设计取向直接决定模型撞墙率。我踩过几轮后总结出几条第一必填字段宁少勿多。模型的逻辑很简单你标了 required它就认为自己必须填哪怕用户对话里没有对应信息。结果就是模型开始编数据——用户没提截止时间它填个当前日期交差用户没提单号它填个空字符串。这种事比字段缺失更难防因为它能悄无声息地通过所有校验。所以我现在只把业务上绝对离不开的字段设必填其余全部 optional然后在 handler 里提供合理的默认值。第二枚举字段必须把可选值写进描述。z.enum 能拦住错误值不假但模型看到的是 JSON Schema 里的 enum 数组有时候照样填出变体。我一般会在 description 里再补一句可选值只能是 low/medium/high不要写其他形式。这样错误率能肉眼可见地降一截。第三深层嵌套结构能拆就拆。模型对超过两层的嵌套 JSON 输出质量会明显下降更容易搞出 null 或丢失内层字段。我宁可把一个嵌套对象拆成两个函数也不让模型输出三层结构。核心思路是让模型传递扁平、简单的信息把复杂的组装逻辑放到代码里完成。3. 加一道拦参数的关卡从字符串到schema校验的完整链路3.1 第一层先把arguments字符串安全地变成JSON很多人拿到 Function Calling 的返回后下意识就是JSON.parse(arguments)但这一步远没那么简单。模型返回的tool_calls[].function.arguments在绝大多数 SDK 里是一个字符串而且这个字符串质量参差不齐有时候前后会多出 json 标记和反引号有时候整个 arguments 是空字符串或者 null更常见的是输出被 max_tokens 截断JSON 根本不完整。所以我的解析函数比大多数版本的都要皮实function extractJsonFromArguments(raw: unknown): unknown | null { if (typeof raw ! string || raw.trim() ) return null; let candidate raw.trim(); // 去掉首尾的 json 包裹 candidate candidate.replace(/^(?:json)?/i, ).replace(/$/, ).trim(); try { return JSON.parse(candidate); } catch { // 尝试截取第一个{到最后一个}之间的内容 const start candidate.indexOf({); const end candidate.lastIndexOf(}); if (start 0 end start) { try { return JSON.parse(candidate.slice(start, end 1)); } catch { return null; } } return null; } }这段代码解决的是解析失败的场景。注意我没有在 parse 失败时立刻抛错而是返回 null把后续判断统一交给上层错误处理。实测下来模型偶尔在 JSON 前后掺解释性文字、偶尔包上 markdown 代码块这个小提取逻辑能救回不少本可以成功的调用。它解决不了的截断场景只能进重试循环绝不能硬顶。3.2 第二层用zod校验把问题参数拦在执行函数之前JSON 解析之后才是正菜用 zod 的safeParse做结构化校验。为什么用 safeParse 而不是 parse因为 parse 失败会抛异常safeParse 则返回一个包含 success 和 issues 的结果对象错误信息是结构化数据方便拼成给模型看的内容也方便打日志。import { z } from zod; function validateArgumentsT(raw: unknown, schema: z.ZodTypeT) { const result schema.safeParse(raw); if (result.success) return { ok: true as const, data: result.data }; const message result.error.issues .map((issue) { const path issue.path.join(.) || (root); return ${path}: ${issue.message}; }) .join(; ); return { ok: false as const, error: message }; }错误信息我用最朴素的方式拼字段路径加上问题描述。比如priority: 期望值为 low | medium | high实际得到 High。这段文字有两个用途一个是给开发者看日志另一个是直接回传给模型让它修正。所以拼错的时候要刻意保持人话别整那些机器内部码。你在日志里能看懂模型也能看懂这比什么都重要。这里有一个很关键的位置选择校验一定放在函数执行前而不是放在真实函数入口里。我最早把校验放到 handler 内部吃过一次亏校验失败时函数已经打开数据库连接了不能安全地放弃最后只能靠事务回滚兜底。现在所有函数都通过一个注册表统一暴露注册表里同时存 schema 和 handler执行前统一校验校验不过根本不碰 handler干净利落。type FunctionDefinitionTArgs { name: string; description: string; schema: z.ZodTypeTArgs; handler: (args: TArgs) Promisestring; }; async function executeToolCallTArgs(def: FunctionDefinitionTArgs, rawArgs: string) { const json extractJsonFromArguments(rawArgs); if (json null) return { ok: false, error: arguments无法解析为合法JSON }; const check validateArguments(json, def.schema); if (!check.ok) return { ok: false, error: check.error }; return { ok: true, output: await def.handler(check.data) }; }3.3 为什么校验层要独立成一个工具而不是散落在各个函数里把校验逻辑收拢到一个统一入口之后最大的好处是重试、日志、监控全都有一处可挂的点。每个 Function Calling 的调用都会经过同一段代码我能在日志里看到哪个函数被调了、参数是什么、校验通过没、失败原因是什么。这些数据不只是排查用后面优化模型描述全靠它。另外独立校验层还能让你在不动业务代码的前提下单独调整校验规则。比如某次上线后模型频繁在一个字段上报错你只需要改 schema 里的 description 或者 regex不需要动任何 handler 代码。这个改动的风险面就小很多。如果校验散落在各个函数内部你想调整规则就不得不去翻每一个 handler改完还得重新跑一遍全量测试成本完全不是一个量级。4. 校验失败之后不是直接报错重试循环与降级策略4.1 把错误信息回传给模型让它自己修正这里要说一个 Function Calling 特有的机会也是其他 API 场景没有的模型还记得刚才的对话我们可以把校验失败的信息作为一条额外消息回传让模型在下一轮重新输出 tool_calls 参数。传统接口的校验失败只能等用户改但模型可以自我修正不利用这个能力就太亏了。我实现了一个简单的重试循环最多尝试两到三轮const MAX_FIX_ATTEMPTS 2; async function callWithRetry(messages, tools, toolCall) { let currentToolCall toolCall; for (let attempt 0; attempt MAX_FIX_ATTEMPTS; attempt) { const def toolRegistry[currentToolCall.name]; if (!def) return { ok: false, error: 未知函数: ${currentToolCall.name} }; const result await executeToolCall(def, currentToolCall.arguments); if (result.ok) return result; if (attempt MAX_FIX_ATTEMPTS) { return { ok: false, error: result.error }; } // 把校验错误塞回对话要求模型重新输出参数 messages.push({ role: user, content: 你刚才调用${currentToolCall.name}时参数不合法${result.error}。请根据原始诉求重新生成正确的参数不要解释。 }); const retryResponse await chat.completions.create({ messages, tools, tool_choice: { type: function, function: { name: currentToolCall.name } } }); currentToolCall retryResponse.choices[0].message.tool_calls[0]; } return { ok: false, error: unreachable }; }两轮这个数字不是拍脑袋定的。我统计过自己项目里的失败样本大约 60% 的错误在第一次重试时就纠正了再给它一次机会又有 30% 左右能改对但到了第三轮模型基本开始胡编乱造——它已经忘记为什么失败只是在生硬地猜一个能通过校验的参数。所以两轮重试是性价比最优的阈值超过这个轮数重试只是浪费 token 和时间。4.2 重试还是失败按函数类型走不同的兜底策略重试耗尽后不能直接给用户甩一个系统错误要根据函数类型做降级。我目前按操作性质分成两套处理查询类函数的兜底逻辑比较宽松返回一个参数不合法的结构化错误由上游把错误展示给用户同时附上提示请重新描述需求用户重新说一遍往往就能解决。写入类函数就严格得多校验不通过宁可请求失败也不执行绝不能把一个不确定的参数写进数据库这是我给自己定的红线。比如创建订单、变更状态、扣减库存这类操作脏参数写进去比不写更麻烦。注意这里是宁可失败也不写错的场景。写入类操作校验不过就是不过不要用默认值去凑。一旦用了默认值你等于在改动业务语义模型和用户看到的结果都和原意不一致后续排查反而更难。没有中间态可以选时我选择保守失败并打日志把完整的 arguments、校验 issues、重试过程全部记录下来方便后续人工介入。4.3 每次校验失败都是一份免费的训练样本这是我认为整个方案里最值钱的部分甚至比拦截本身值钱。每次校验失败我都把原始参数、失败字段、重试后的修正结果存进一张日志表。比如函数名 createTicket字段 deadline 格式错误模型第一次填下周五重试后填2024-08-06函数名 sendReminder字段 targetUserId 缺失重试后补上123456函数名 queryOrder字段 status 超出枚举重试后填paid这些样本攒下来就是一份非常具体的模型行为报告。你会清楚地看到模型在哪些函数、哪些字段上犯糊涂而不是靠猜去优化提示词。下一节具体说怎么用这些样本反哺模型定义。5. 落地踩坑实录schema方案里的几个反模式5.1 一次arguments为空引发的排查过程这是最近踩的一个坑也是把错误日志做细之后才暴露出来的。线上有一阵子总是出现函数调用失败arguments解析异常的警报但正常测试又复现不出来。一开始我怀疑是模型偶发问题加了临时日志硬等结果发现这根本不是模型输出错误而是用户的最后一条消息被某种重试机制重复发送模型第二次响应时直接返回了一段纯文本而不带 tool_callsSDK 在读取tool_calls[0].function.arguments时取了个 undefined走到 JSON.parse 就炸了。排查链路大概是这样先看业务日志发现失败点集中在 JSON.parse再往前翻发现传入的 arguments 不是字符串而是 undefined再往前翻发现message.tool_calls数组本身就是空的——模型压根没走工具调用分支。根因出来之后修复反而是最简单的在解析函数开头加一行if (typeof raw ! string || raw.trim() ) return null。但这行代码只有把整条链路打通之后才会想到加因为问题不在你以为的那一层。这个坑提醒我凡是处理模型输出的代码默认输入永远是任何可能的情况不要相信 SDK 的类型定义说 arguments 是 string 就一定是 string。边界防御多写一行后面能少排查很久。尤其是不同厂商的 Function Calling 实现细节还有差异你今天处理了 OpenAI 的返回形态明天接 Claude 或 Qwen 可能就是另一种形态统一入口的好处就在这里。5.2 日期格式看起来对了实际还是错的正则校验能拦住2024/08/06这种明显的格式问题但拦不住2024-13-01这种月份出界的值。有一阵子我的 deadline 字段校验全过了下游解析却报错原因就是模型填了个不存在的日期正则却放行了。后来我加了 superRefine 做二次检查const dateOnlySchema z.string().regex(/^\d{4}-\d{2}-\d{2}$/).superRefine((val, ctx) { const [year, month, day] val.split(-).map(Number); const d new Date(year, month - 1, day); if ( d.getFullYear() ! year || d.getMonth() ! month - 1 || d.getDate() ! day ) { ctx.addIssue({ code: custom, message: 日期不存在请使用真实存在的YYYY-MM-DD日期 }); } });注意不要直接用new Date(val)来判断因为 JavaScript 对new Date(2024-02-30)这类输入会自动进位不会抛出异常。先手动拆字符串再逐一比对年月日才能真的拦住不存在的日期。这个坑藏得挺深属于一看就会、一写就错的高频问题。5.3 枚举字段的模型式表达会让统计炸掉模型特别擅长输出明显在语义上正确、但形式上完全不符合枚举的字段。priority 我定义成 low/medium/high模型给你填High and urgent或者填一个数组[high]——schema 每次都拦得住但重试率上去了用户的等待时间也上去了。这类问题靠校验本身解决不了要给描述加约束。我现在给每个枚举字段的 description 都写一句可选值只能是从下面列表中选一个不要写其他形式然后直接在 schema 转给模型的 JSON Schema 里保留 enum。两段约束叠加之后这类错误的频率会显著下降。说到底schema 是裁判description 才是教练裁判只能判罚教练能教会球员别犯规。两个角色都得有人扮演只靠其中任何一个效果都不理想。5.4 校验通过不代表语义正确要分清边界最后要泼一盆冷水schema 校验解决的是结构合法不是语义正确。用户在对话里说周五之前处理模型换算后填了 2024-08-06这个日期格式合法、字段必填也满足但换算结果错了就是错了。这种错误不在 schema 的管辖范围内。我的应对思路是不要把复杂的换算推理交给模型去心算宁可把换算动作设计成一个专门的工具函数让模型调用它去获取下一个工作日之类的结果再把结果传给目标函数。让模型老老实实传递信息而不是在参数里做计算——这是 Function Calling 架构设计里比参数校验更深一层的经验。参数校验能兜住格式但语义正确性必须在架构层面把计算从模型手里拿走。6. 从兜底到预防用失败样本反向优化Function定义6.1 失败日志里藏着最诚实的模型行为报告前面说过每次校验失败我都会记录完整样本攒到一定量之后做的工作其实非常简单粗暴按函数名统计失败次数再按字段路径统计失败占比。哪个函数失败多哪个字段老是错一翻就有结论。我印象很深的一个案例createTicket 的 deadline 字段高频失败。打开原来的定义description 就俩字截止时间。模型能怎么办它只能凭借训练数据里的常识去猜一个格式猜来猜去就猜出各种格式。后来我把 description 改成截止日期使用 YYYY-MM-DD 格式例如 2024-08-06如果用户说的不是具体日期请先使用日期工具换算同一字段的失败率肉眼可见地掉了。这不是什么玄学就是模型对含糊描述必然会自由发挥你把规则写清楚它就照着来。这里我还有一个习惯每当新函数上线前三天我会每天扫一遍校验失败日志专门看有没有新错误形态。不用多每天十分钟基本就能在问题规模化之前把描述修正到位。等过了活跃期每周再扫一次就够了。6.2 用schema塑造模型行为而不是单纯惩罚schema 的角色其实可以再往前进一层它不只是拦截器还在潜移默化地塑造模型的输出习惯。比如必填字段的问题我早期的 schema 把所有字段都设成必填模型就养成了一个习惯——不管用户给没给信息它都强行塞一个值进来。这种创造性填空比缺字段更难发现因为它能通过所有校验。所以我在 2.3 节说的原则在这里要再强调一遍可选的字段就用 optional 表示让模型诚实地不知道就不填代码里再对缺省值做兜底。校验规则不是越严越好是越贴近业务真实语义越好。严到让模型开始编数据反而制造了新的脏数据来源。这就像管孩子规则太多太死孩子会学着撒谎而不是学着守规则。6.3 从个人实践看这套方案最终带给我什么收益我把接入 schema 校验前后的数据做了一次对比在没有校验拦截的年代函数调用最终因为参数错误失败的占比不算高但每一次失败都要靠人肉排查链路平均耗时很长。接入双层校验加重试循环后真正漏到 handler 里的问题参数变成了零剩余失败要么是重试耗尽被明确拦截要么是函数内部的业务异常——两类的错误定位都变得极快因为日志里已经明确写了是参数问题还是函数问题。落到体验上用户看到的不再是系统开小差了而是请求参数有误请重新描述需求语义清晰得多。这个收益没法用数字完全量化但它让整个调用链的确定性上升了一个台阶。最后再说一个收尾级的提醒每个新增函数上线前问自己一个问题——如果模型传来一个完全符合 schema 但语义完全不对的参数我的代码会出现什么结果如果答案是可能产生脏数据那说明你还需要一层显式的语义校验或者干脆换个拆函数的方式把语义计算放到模型可控范围之外。校验是安全网但最好的安全网不是你织得越密越好而是你根本不需要它接住那么多东西。