ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Function Calling 参数校验实战:用 Zod Schema 构建可靠的工具调用闭环

Function Calling 参数校验实战:用 Zod Schema 构建可靠的工具调用闭环

做了一整年 Function Calling 相关的落地项目,我最深的感受是:模型调用工具的“意图识别”已经不太会出错,真正让人头疼的永远是参数。一个日期写成“2025-6-1”,一个数字被模型当字符串输出,一个可选字段被塞进 null,这些问题在 demo 里无伤大雅,一旦接了真实业务接口,就是连环报错。后来我彻底换了个思路:不再指望大模型“自觉”遵守字段约束,而是把参数契约全部交给 schema,再用校验层兜住所有意外。今天就把这套方案完整拆开讲。

这套方案主要解决三类问题:模型返回参数缺字段、类型漂移、取值范围越界。适合那些正在做 Agent、RPA、业务系统自动化的人参考。你能看到的不只是“用 zod 校验一下”,而是一整套从函数定义、参数校验、容错回退化到排查思路的闭环打法,算是把 Function Calling 拉到生产环境可用的层面。

1. 参数错误的真实形态与根因

1.1 模型输出参数的常见异常类型

先说结论:大模型在 Function Calling 里输出的参数错误,基本逃不出这几类。

第一类是缺字段。定义函数的时候要求必须传user_id和room_id,模型只给了一个room_id,另一个直接不传。常见于多轮对话里,用户没有明确提供某个必要信息,模型自作主张省略了字段,或者干脆把必填参数当成可选参数处理。

第二类是类型漂移。代码里明明定义start_time是 integer 时间戳,模型返回的是字符串"1718006400";定义tags是 string 数组,模型返回的是逗号分隔的字符串"a,b,c"。这类问题出现频率非常高,尤其是当模型从用户原话里抽取信息时,它会按照用户口头表达的自然语言形式输出,而不是严格按照你给的 JSON Schema 类型输出。

第三类是取值范围越界。比如枚举字段,你定义了priority只能是low | medium | high,模型输出了urgent;再比如数值范围,temperature定义成 0 到 1 之间的浮点数,模型直接输出2.5。模型对 JSON Schema 里enum、minimum、maximum这些约束的理解是概率性的,不像本地代码那样会被硬性拦截。

第四类是格式错误。常见于日期、时间、邮箱、手机号这类有明确格式要求的字段。模型可能把2025-06-01输出成2025/06/01,把13800138000输出成138 0013 8000。这些在视觉上“看着正常”,但传给下游接口就是校验失败。

第五类是多余字段。模型在生成参数时,偶尔会凭空多出几个函数定义里不存在的属性。可能来自用户上下文里的偏好信息,也可能来自模型的“过度服务”。如果不做处理,这些多余字段会被 JSON Schema 的默认行为静默丢弃,或者在某些严格模式的下游接口里直接报错。

第六类是嵌套结构错误。参数不是扁平对象,而是有嵌套的address: { city, zip },模型可能漏掉zip,也可能把address直接输出成字符串。这类错误最难发现,因为顶层字段都在,只有把 JSON 展开到第二层、第三层才能看到问题。

我在项目里统计过一套线上日志:持续一个月、日均两千次函数调用,参数校验失败率约为 3.7%。其中缺字段占了四成,类型漂移占三成,取值范围和格式错误占两成,剩余是嵌套和多出来的字段。也就是说,每 27 次调用就会有一次参数异常,这个概率放在生产环境完全不可接受。

1.2 为什么不能只靠“提示词约束”解决

很多团队遇到参数错乱的第一反应,是在函数描述里反复强调:“必须严格遵循参数类型”“不要输出多余字段”“日期格式必须是 YYYY-MM-DD”。老实说,这种提示词在实际效果上非常不稳定。

大模型的输出本质上是对下一个 token 的概率采样,不是对着约束做确定性计算。JSON Schema 对它来说是一个隐式的软约束,它能“理解”,但不能“保证”。我在同样提示词下换了不同模型测试,表现差异非常大:有的模型能把枚举控制得干干净净,有的模型十个调用里能错三个。同一个模型在长上下文场景下,函数定义被压缩或忽略的概率也会增加,参数错误率会随上下文长度显著上升。

另一个很难绕过的问题是,模型对“边界”的理解经常出错。比如你要求count必须是 1 到 100 的整数,模型可能输出的确实是整数,但不是 1 到 100,而是 500,因为用户说“我要全部五百条”。这类表面合法、实质越界的错误,提示词根本约束不住。

所以我把这类问题定性为“结构性风险”:只要模型参与参数生成,就一定存在概率性的输出偏差。正确的工程姿势是承认风险存在,然后在校验层把它兜住,而不是绕着提示词下功夫。这也是我在方案选型时坚持引入 schema 校验的根本原因。

2. Schema 先行的整体设计思路

2.1 用一个 schema 做契约的三重职责

我的核心设计思路,是把“函数参数定义”提升为整个链路的契约中心,同一个 zod schema 承担三个职责。

第一个职责是给大模型看。Function Calling 的 API 需要传入一个符合 JSON Schema 规范的parameters对象,告诉模型这个函数接收哪些字段、每个字段的类型和约束是什么。这是模型生成参数时唯一的“参考手册”。

第二个职责是给运行时校验用。模型输出之后,在真正调用业务函数之前,用同一个 schema 对参数做解析校验。这一步拦截所有不符合契约的异常参数。

第三个职责是给下游接口做数据映射。schema 校验通过之后,得到的是一个类型安全、结构干净的数据对象,可以直接传给内部函数,不需要再次做手工字段整理。

三个职责共用一份 schema,最大的好处是消除“定义漂移”。假使你给模型的定义是一套字段,校验逻辑里写的是另一套字段,那模型输出就永远是“按模型的理解来”,而校验逻辑却是“按校验人员自己的理解来”,两套理解不一致,参数错误率只会更高。我把 schema 作为唯一事实来源之后,给模型的定义和本地校验的规则永远指向同一份代码,这类问题从根源上消失了。

实际项目里我还做了一个约定:业务代码里不直接使用JSON.parse之后的原始arguments对象,而是强制要求通过 schema 校验之后才能进入业务层。这么做的好处是,业务工程师在写内部逻辑时,可以放心依赖字段的类型和必填性,不用每一行代码都担心“这里是字符串还是数组”。

2.2 为什么选择 zod 而不是手写 JSON Schema

提到用 schema 做校验,有的同学第一反应是直接手写 JSON Schema 文件。JSON Schema 本身当然可以做到同样的效果,但我试过之后很快放弃了,原因有三个。

第一,JSON Schema 写起来太冗长。一个只有五个字段的嵌套对象,手写 JSON Schema 可能要六七十行,而且可读性差,团队里的人都不太愿意去改。zod 用链式 API 表达同样的约束只需要十几行,代码即文档,一目了然。

第二,JSON Schema 是纯数据,没法在 Node.js / TypeScript 环境里获得类型推断。而 zod schema 天然和 TypeScript 类型互通,z.infer<typeof BookRoomSchema>可以直接得到一个类型安全的 TypeScript interface,不需要维护两份类型定义。

第三,zod 的错误信息结构化程度高。校验失败时,error.issues数组里包含path、code、expected、received等字段,能够精确告诉我们是哪个路径下的哪个字段出了问题,这对接日志、定位问题、以及把错误反馈给模型要求重新生成都极其方便。手写 JSON Schema 也能做,但需要额外搭建一套错误解析器。

不过,我给大模型的定义最终还是要以 JSON Schema 形式传出去。这里我直接用zod-to-json-schema这个库做转换,一行代码把 zod schema 变成标准 JSON Schema。需要注意,zod 的某些高级校验能力(比如refine里的业务规则)是无法被转换到 JSON Schema 里的。这是因为 JSON Schema 标准只能表达结构约束,不能表达任意函数逻辑。所以在设计 schema 时,我会把“结构约束”写在 zod 的基本类型和校验器里,把“业务规则”写在refine或superRefine里,并且心里清楚:refine 部分的规则只在本地方效,模型并不能看到。

一个具体例子是“结束时间必须晚于开始时间”。这条规则我写在refine里,模型在生成参数时完全看不到这个约束,所以它可能输出一个endHour = 8, startHour = 10的荒谬组合。本地校验会把这个结果拦下来,然后我可以用错误信息触发一次模型修正,或者走兜底逻辑。这就是 schema 双层的价值:一层是模型能看到的硬约束,一层是模型看不到的业务底线。

3. 核心实现:从 schema 定义到完整校验链路

3.1 用 zod 定义函数参数并生成模型可用定义

先看一个完整的示例。假设我们要给 AI 做一个“预订会议室”的函数,需要接收会议室 ID、使用日期、开始时间、结束时间、预订人姓名和备注。直接看代码:

import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const BookRoomParams = z .object({ roomId: z.string().min(1, 'roomId 不能为空'), date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式必须为 YYYY-MM-DD'), startHour: z.number().int().min(0).max(23), endHour: z.number().int().min(0).max(23), booker: z.string().min(1).max(20), memo: z.string().max(200).optional() }) .refine((data) => data.endHour > data.startHour, { message: 'endHour 必须大于 startHour', path: ['endHour'] }); const functionDefinition = { name: 'book_room', description: '预订指定日期的会议室,需要提供会议室 ID、使用日期、起止小时数。', parameters: zodToJsonSchema(BookRoomParams, 'BookRoomParams') };

这里有几个点值得展开。

z.string().regex(...)这样的写法,会被zod-to-json-schema转换成 JSON Schema 里的pattern字段,模型在生成参数时是有机会看到这个正则的。虽然模型不会像正则引擎那样精确执行,但至少它是一个显式提示。同理,.min()会转成minLength,.max()会转成maxLength,z.number().int()会转成type: "integer",.min(0).max(23)会转成minimum和maximum。这部分是模型可以感知的硬约束。

.refine()则不同,zod-to-json-schema会直接忽略它,因为这是任意函数逻辑,没法用 JSON Schema 标准表达。我在代码注释里会特别标出来,防止团队同事误以为模型已经“看到”了这条规则。

另外,memo: z.string().max(200).optional()这个可选字段,转换到 JSON Schema 后会表现为required数组里不包含memo,并且type: ["string", "null"]之类的宽松定义。模型输出时可以不传它,但如果传了就必须是字符串,不能是数字。

生成好的parameters放进 OpenAI / 通义千问等模型服务商要求的tools参数里,这部分各家接口格式略有差异,但大同小异。核心一定是我们这里生成的parameters对象,它最终会随请求发给模型。

3.2 模型返回后的解析、校验与容错

模型返回的tool_calls里,arguments字段是一个 JSON 字符串。第一件要做的事是安全解析,因为模型还真有可能输出一段不合法的 JSON,比如多余的逗号、截断的字符串。这里我直接用JSON.parse包一层 try/catch,解析失败就重启一次模型请求。这部分逻辑不复杂就不贴代码了。

解析成功之后,立刻走 zod 校验:

const parsedArgs = JSON.parse(toolCall.function.arguments); const checkResult = BookRoomParams.safeParse(parsedArgs); if (!checkResult.success) { console.error('[function-call-invalid]', { rawArgs: toolCall.function.arguments, issues: checkResult.error.issues }); // 容错策略一:尝试做一次模型修正 const correctedArgs = await retryWithErrorFeedback( toolCall.function.name, parsedArgs, checkResult.error.issues ); if (correctedArgs) { return callBookingApi(correctedArgs); } // 容错策略二:返回给用户一个明确错误 return { type: 'function_call_error', message: '会议室参数不合法:' + formatIssues(checkResult.error.issues) }; } const safeArgs = checkResult.data; return callBookingApi(safeArgs);

.safeParse()是 zod 里非常实用的方法,它不会抛异常,而是返回一个带success标志的结果对象。失败时,error.issues里每条错误信息都包含path字段,精确指向出错的字段路径,比如[ 'date' ]或[ 'address', 'zip' ]。这套信息对调试和自动纠错都极其有价值。

我在容错策略上,先说“模型修正”这条路。具体做法是把错误信息拼进一条新的 system 消息,连同之前的对话记录一起重新发给模型,告诉它“你生成的参数有问题:date 字段格式不对,应为 YYYY-MM-DD,实际收到的是 2025/6/1,请修正后重新调用函数”。zz假设模型本身能力还行,这个方法的成功率大概在七成左右。缺点是会增加一次请求延时,我一般只允许重试一次,防止出现反复横跳。

再说降级策略。如果模型修正失败,或者重试次数用完,就返回一个用户可读的错误。这里我把错误信息用表格或列表形式整理出来,让前端能明确知道是哪个字段出了问题。而不是简单地说“参数错误”,否则用户完全不知道该怎么修正输入。

容错策略的核心原则是:宁可让一次调用失败,也不能把脏参数传给下游业务接口。我会在项目验收标准里明确一条铁律:业务函数的入参,必须是已经通过 schema 校验的类型安全对象。

3.3 完整接入 Function Calling 的调用链路

把上面的逻辑串起来,整个调用链路是这样的:

  1. 程序定义 zod schema,并用zodToJsonSchema生成函数定义。
  2. 用户对话消息 + 函数定义一起发给模型。
  3. 模型返回tool_calls,里面包含函数名和argumentsJSON 字符串。
  4. 本地解析 JSON,用同一份 zod schema 做safeParse。
  5. 校验通过,把类型安全的数据传给真实业务函数。
  6. 校验失败,记录日志,尝试用错误信息引导模型重新生成,至多一次。
  7. 仍失败则返回用户可读的错误提示。

我用一个工具类把第 3 到第 7 步统一封装,避免每个业务函数都要重复写解析和校验代码:

async function executeFunctionCall(toolCall: ToolCall) { const schema = functionSchemas[toolCall.function.name]; if (!schema) { throw new Error(`未注册的函数: ${toolCall.function.name}`); } const rawArgs = JSON.parse(toolCall.function.arguments); const checked = schema.safeParse(rawArgs); if (checked.success) { return callFunction(toolCall.function.name, checked.data); } const issues = checked.error.issues; const retryArgs = await requestModelCorrection(toolCall.function.name, rawArgs, issues); if (retryArgs) { const corrected = schema.safeParse(retryArgs); if (corrected.success) { return callFunction(toolCall.function.name, corrected.data); } } return { status: 'error', issues: issues.map(formatIssue) }; }

这块封装做完之后,业务团队接入新函数的工作量变得非常小:只需要定义一个 zod schema、一个真实业务函数、注册进一张表。剩下的解析、校验、重试、错误处理全部走公共逻辑。平均新增一个可用函数的时间从一天缩短到了两三个小时。

我遇到过一个问题,在这里顺便提醒大家:工具类的 key 和函数名绑定的时候,一定要用统一注册表来维护,不要让业务代码里到处散落着字符串比较。否则一旦函数改名,就会出现模型已经调用了新名字,本地却找不到对应 schema 的尴尬情况。

4. 线上常见问题与排查实录

4.1 高频校验错误的处理对照表

在实际运行中,有几个错误场景出现频率特别高。我把它们整理成一张对照表,方便大家直接对照处理。

错误现象典型 zod issues推荐处理方式
必填字段缺失code: 'invalid_type', expected: 'string', received: 'undefined'如果是必要信息缺失,引导模型向用户追问,不要硬猜一个默认值
数字被输出成字符串code: 'invalid_type', expected: 'number', received: 'string'在重试消息里明确要求“不要加引号”,必要时可做一次宽松转换再校验
日期 / 时间格式不符合正则code: 'invalid_string', validation: 'regex'把“实际收到的值”和“期望格式的示例”都放在重试消息里,示例比规则更好用
枚举值越界code: 'invalid_enum_value',错误信息会列出合法选项直接允许模型从合法选项里重新选一个,不要让它自由发挥
额外字段默认 zod 会剥离多余字段,不报错如果想报错,用.strict();如果不想报错,默认.strip()就好,但要在日志里记录
嵌套对象缺字段path: ['address', 'zip']对应报错重试时把嵌套字段的完整路径写清楚,模型才能准确知道错在哪一层
数组字段混入 nullcode: 'invalid_type', expected: 'string', received: 'null'如果业务允许空数组,在 schema 里用.nullable()显式声明,否则直接重试
开始时间晚于结束时间path: ['endHour'],来自 refine 消息让模型重新生成合理的时段,别让业务层做“自动翻转”

这里我最想强调是“不要偷偷替模型改参数”。很多同学在校验失败之后,会选择直接改掉错误的值然后继续跑,比如把字符串数字"10"转成10,把日期分隔符替换一下。短期看起来“省事”,但长期看这是在教坏系统——模型不会知道自己的错误被悄悄修复了,下一次它还会犯同样的错。正确做法是带着错误信息去让模型修正,让它从“生成侧”意识到问题。

4.2 定位参数错误的三板斧

当我们面对线上日志里一堆“参数校验失败”时,不要慌,我常用的排查顺序基本是这三步。

第一步,看原始arguments字符串。不要只看校验错误信息,一定要把模型输出的原始 JSON 打印出来。因为error.issues只能告诉你“哪里不符合”,很多结构性问题的原因(比如模型把数组整个裁掉了一半)必须看原始输出才能发现。我会在日志系统里把rawArgs和issues作为两个独立的字段保存。

第二步,看issues里的path分布。如果错误路径集中在一两个字段上,那大概率是 schema 定义和用户输入之间的“翻译”出了问题;如果错误路径分布非常广,各个字段都有问题,说明模型整体没有理解函数的用途,或者函数描述写得太模糊。后者的问题是描述层面的,不是校验层面的。

第三步,看模型当时的上下文。这一点容易忽略,但我排查发现,绝大多数参数错误都发生在用户表达不完整的对话里。比如用户说“帮我订明天的会议室”,但没说具体时间、没选房间,模型只能“编”一个参数。在这类场景里,与其靠校验兜底,不如在函数调用前先做一个“必填信息检查”,发现缺关键信息就先向用户提问,而不是硬调函数。

另外分享一个调试小技巧:我会在本地写一个“回放工具”,输入任意模型输出的 JSON 字符串,立刻显示它能不能通过 schema 校验,以及所有 issue 的明细路径。这个工具看起来简单,但在积累样本、调整 schema 规则时非常高效。我经常用它在正式发版之前,把历史日志里所有校验失败的样本批量跑一遍,验证新版的 schema 是否能够覆盖旧错误。

4.3 关于 schema 调整的时机与原则

校验规则也不是越严越好。我见过有团队给每个字段都加了一大堆.min().max().regex(),结果模型生成参数的失败率飙升到 20% 以上。这里有一个平衡:schema 对模型来说是输出约束,约束越细,模型越容易碰线。我的经验法是分两级:一级是必选的、不能出错的结构约束,比如类型、必填、枚举范围;二级是可选的、可以靠校验排查的业务规则,比如正则表达式细节、字段长度,这些放在本地校验里处理,不要全部压到模型头上。

调整 schema 的频率也不用太高。每个版本上线前我都会做一次“历史失败样本回归”,看看新 schema 在旧样本上的拦截率和错杀率。如果发现新增的校验规则拦截的其实是一些“历史上从没出现过”的边界情况,我会考虑把它降级,避免它徒增模型出错概率。毕竟我们引入校验是为了提高整体成功率,不是在每个字段上设卡。

还有个小原则:schema 中加注释。写 zod schema 的一行卡片,旁边写上“给模型看的约束”还是“本地强校验”,这个信息对后续维护的人非常友好。看起来是小事,但团队协作时这点明确沟通能少踩很多坑。

5. 从单函数校验到多函数协同的扩展

5.1 多函数场景下的 schema 注册表

当系统里的 function 从两三个增长到十几个时,schema 的管理就不只是定义加校验那么简单了。我现在的做法是建一个统一的注册表文件,用 Map 把函数名、zod schema、实际执行函数、错误恢复策略四样东西绑定在一起。

type RegisteredFunction = { name: string; description: string; schema: z.ZodTypeAny; executor: (args: any) => Promise<Result>; recoverStrategy?: 'retry' | 'ask-user' | 'fail'; }; const functionRegistry: Record<string, RegisteredFunction> = { book_room: { name: 'book_room', description: '预订会议室', schema: BookRoomParams, executor: callBookingApi, recoverStrategy: 'retry' } };

公共执行器只认这个注册表,不关心具体业务。新加一个函数就是往注册表里塞一项。子模块各自导出一个注册项,再由主模块汇总,然后生成最终发给模型的tools数组。这样既保持了每个函数的独立性,又不会出现 schema 散落在各处导致重复定义的问题。我对比过用数组和用 Map 两种方案,最后选了 Map,因为在函数多的情况下,查找效率高,而且天然规避了重复注册的问题。

5.2 校验反馈也可以用来优化模型表现

除了兜底拦截,校验结果的长期积累还有一个额外价值:它是调整函数描述和 schema 边界的重要数据来源。我会定期统计每个函数的校验失败率、失败原因分布,然后针对最高频的错误重写description里的示例和约束表达。

举个实际例子:我们的会议室函数刚开始描述里只写了“输入使用日期”,模型经常把用户说的“明天”直接原样传成“明天”而不是换算成具体日期,导致日期格式校验大量失败。后来我在 description 里加了完整示例:“如果用户说‘明天’,请根据当前日期换算成 YYYY-MM-DD 格式的字符串,例如当天是 2025-06-01,则明天是 2025-06-02”。这个改动之后,日期格式错误率直接降了六成。这就是“描述侧优化”和“校验侧兜底”双管齐下的效果。

数据驱动的描述优化,比反复在系统提示词里喊“你必须严格遵守格式”有效得多。每一次校验失败其实都是模型在为你标注一个它没有理解清楚的定义,不要只把它当成错误去处理。这也是我把所有校验失败日志单独存储的原因,它们是大模型与应用schema之间的“沟通错位报告”,价值不在业务日志之下。

5.3 再聊聊重试策略的取舍

最后想聊聊重试。自动请求模型修正看似强大,但我必须提醒一句代价:每次重试都会增加整体响应时间,极端情况下一次函数调用可能要等上 10 秒以上。所以我在设计里把重试策略分了三档。

第一档是“结构尚可、只需小修”的错误,比如日期格式、枚举值不对。这种重试成功率最高,值得消耗一次请求。第二档是“骨架明显错误”的错误,比如必填字段缺失好几个、嵌套结构错乱。这种重试成功率很低,直接走询问用户或明确失败更划算。第三档是校验一直失败、同一函数多次重复。这种系统会自动把函数从可使用列表里临时禁用,把请求切回普通对话模式,避免卡死在循环里。

为了区分这几档,我实现了一个轻量级的错误分类函数:根据issues的数量和类型做判断。比如只有一两个字段的invalid_string或invalid_enum_value,归为可重试;出现三个及以上字段的invalid_type,归为不可重试。这个分类规则刚开始很粗糙,后来随着线上表现不断优化,现在准确率已经能用了。这类细节看起来很零碎,但恰是这些边缘分支处理得好了,整体成功率才拉得起来。

我个人如今对 Function Calling 的核心体感就是:“模型负责生成可能性,校验负责收敛确定性。”不要纠结于让模型永不犯错,那是不可能的;但你可以用一套结构化的防线,把错误阻断在业务入口之前。每一次校验失败都不值得沮丧,把它当一次模型和你之间的信息反馈循环,整套系统会越跑越稳。

如果你正在做类似的 Function Calling 集成,建议先从一份 zod schema 做起,把解析、校验、错误记录这三件事串起来,跑几天看一下失败样本,再决定要不要增加重试和自动修正。别一上来就加各种花哨的降级策略,先让兜底机制老老实实工作,才是这个方案的精髓。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进