☰
Function Calling参数校验:用Zod守住大模型工具调用的入口防线
2026/11/3 23:30:20 网站建设 项目流程

如果你正在做一个接入了大模型的工具调用平台,那你大概率迟早会遇见这么个场面:模型信誓旦旦地输出了一段格式完美的 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 整体流程划分:生成、解析、校验、执行

先把我最终落地的执行链路画出来,你们就清楚“入口”到底指哪个位置了:

  1. 模型根据用户问题生成一个 function call 请求,里面包含 toolName 和一个参数对象。
  2. 平台拿到这个请求后,先做基础解析:确认工具存在、参数是一个 JSON 对象。
  3. 进入入口校验层:用对应工具的 Zod schema 对参数做safeParse。
  4. 校验通过,进入业务 handler 执行;校验失败,进入自纠流程。
  5. 自纠流程将错误信息回传给模型,模型重新生成 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 源码高。这个细节我反复测过多次,属于那种你知道了就立刻能用的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询