
做了一整年 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.infertypeof 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()会转成maxLengthz.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 的调用链路把上面的逻辑串起来整个调用链路是这样的程序定义 zod schema并用zodToJsonSchema生成函数定义。用户对话消息 函数定义一起发给模型。模型返回tool_calls里面包含函数名和argumentsJSON 字符串。本地解析 JSON用同一份 zod schema 做safeParse。校验通过把类型安全的数据传给真实业务函数。校验失败记录日志尝试用错误信息引导模型重新生成至多一次。仍失败则返回用户可读的错误提示。我用一个工具类把第 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) PromiseResult; recoverStrategy?: retry | ask-user | fail; }; const functionRegistry: Recordstring, 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 做起把解析、校验、错误记录这三件事串起来跑几天看一下失败样本再决定要不要增加重试和自动修正。别一上来就加各种花哨的降级策略先让兜底机制老老实实工作才是这个方案的精髓。