ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

用 zod schema 在入口拦截 LLM 函数调用参数幻觉,保障 AI Agent 工具调用可靠

用 zod schema 在入口拦截 LLM 函数调用参数幻觉,保障 AI Agent 工具调用可靠

1. 先还原事故:Function Calling 的参数幻觉是怎么发生的

1.1 一次典型的“参数越界”故障

先说一个我线上真实遇到的事故,你听完就知道标题这事到底有多邪门。

我们的智能客服机器人接了一个“查询订单详情”的工具,函数签名写得清清楚楚:只允许customer_id(string)和status(枚举值只能是pending、paid、cancelled)两个参数。工具声明用标准 JSON Schema 格式传给了模型,模型也每次都能正确地说“我要调用查询订单详情工具”。

但有一天,线上监控突然报警:订单查询服务的 SQL 执行时间暴涨,紧接着出现一批“订单不存在”的错误。我去翻日志,当场就愣住了——模型那次调用返回的参数是这样的:

{ "customer_id": "17000000001", "status": "completed", "sort_by": "create_time", "page_size": 50 }

里面status的completed压根不在我声明的枚举里,sort_by和page_size这两个字段我在工具定义里提都没提过。更离谱的是另一条日志,模型给一个order_date参数传了"2025-02-30"——二月三十号,这种日子在日历上根本不存在的日期,模型照样一本正经地生成出来了。

参数被模型“编造”这件事,最坑的地方就在这里:它不是网络传输错误,不是 JSON 语法错误,而是模型用概率生成的方式,产出了一个语法合法、但语义和约束完全不合规的参数集合。下游代码可不管你是模型生成的还是人写的,拿到completed就按已完结状态去查,拿到二月三十号就去查,结果自然全是空数据和异常。

我当时的第一反应是“是不是工具声明没传对”,检查了一遍,JSON Schema 写得没有任何毛病。后来才意识到问题不在“传没传对”,而在于:模型对 JSON Schema 的理解,本质上是对一段自然语言描述的注意力分配,它不是真的在严格按照 Schema 做对象实例化。

1.2 模型为什么“明明有 schema 还是会编造”

搞 LLM 应用的人容易陷入一个误区:觉得只要在工具定义里写清楚了参数约束,模型就一定会遵守。这个直觉在大多数时候是对的,但“大多数时候”恰恰是线上事故的温床。

要从根上讲:LLM 本质上是一个概率性的 token 采样器。它生成每一个 token 时,都是根据上下文计算下一个 token 的概率分布,然后在这个分布上做采样。所以你给它的 JSON Schema 再严谨、注释再详细,也只是给它多提供了一些“倾向于生成合法参数”的上下文证据,而不是给它加了一道物理闸门。

打个比方:你给一个打字员贴了一张“此处只能输入数字”的纸条,他正常情况下确实会输入数字,但如果他某天手滑、或者纸条被风吹到了一边,他照样可能敲出字母来。模型没有“状态寄存器”,它不会像传统程序那样确保“这个参数必须符合这个约束”——它只是在模仿它见过的文本分布。

主要诱因有三个,我按杀伤力排序:

  • 温度参数过高。温度越高,采样越随机,低概率 token 被选中的机会越大。我们当时客服系统为了“对话更自然”,把温度调到了 0.8,结果就是工具参数里频繁出现低概率值。
  • 上下文压力与指令混排。用户对话里有大量口语表达,模型在长上下文中容易把用户说的某个词直接填进参数,而不是严格参考工具定义。比如用户说“帮我查一下完结的订单”,模型就可能把status填成"完结"或者"completed"。
  • 训练数据中的高先验模式。如果模型在训练语料里见过大量completed这个状态值,而你的业务枚举值是cancelled,它在某些采样路径下就会“想当然”地把completed当作合法值输出来。

这些东西叠加起来,结论只有一个:凡是模型自由生成的参数,都存在一定概率的越界行为,多低的概率在千百万次调用里都会被放大成事故。

1.3 平台自带的校验为什么不够

很多读者可能会问:市面上主流的大模型 API,在 Function Calling 时不是会自己校验参数吗?我当时也是这么想的,结果被现实教育了。

平台的校验分为两类。第一类是请求侧校验,也就是你组装工具描述时的结构校验——如果你的 JSON Schema 本身语法错误、字段类型写错了,平台会直接拒绝请求。提示词里提到的provider rejected the request schema or tool payload这个报错,通常就属于这一类:它告诉你请求体不合法,而不是模型输出不合法。这类校验只能保证“你发给模型的 Schema 是合法的”,它管不住“模型输出的参数是否合法”。

第二类是输出侧校验。部分平台确实会在返回工具调用前做一次 JSON 解析,但绝大多数平台只会做浅校验——也就是确认这段输出能被JSON.parse解析成一个对象。至于对象里的字段名是否在 Schema 范围内、枚举值是否匹配、日期格式是否正确,平台是不管的。原因也很简单:平台要兼顾各种业务场景,如果它强制枚举校验,那模型对新增枚举的泛化能力就会被砍掉,这对平台来说是吃力不讨好。

所以结论很清晰:平台侧校验是一条粗线,真正的参数精确校验必须由应用层自己补上。

我那段时间被这种“平台说没问题,业务跑出问题”的落差折磨得不轻。后面想明白了一件事:模型是一个庞大的、概率性的黑盒,你能控制的最可靠环节不是“祈祷它生成正确的参数”,而是在它输出之后、进入业务逻辑之前,加一道确定性代码组成的闸门。这也是这篇博文标题的由来——我最终选择用 schema 把这道闸门修在了入口处。

2. 入口拦截方案:把 zod schema 变成模型输出的“海关”

2.1 为什么要选 zod 而不是手写 if/else

要把参数挡住,最朴素的做法是写一堆 if/else:判断字段存不存在、类型对不对、枚举值在不在列表里。我一开始也是这么干的,写了大约两百行之后放弃了——太脆了。每加一个工具、每改一个字段,就得改一遍判断逻辑,而且错误信息拼出来给模型看时也乱七八糟。

后来换成了 zod schema,原因有三个:

第一,zod 的声明式写法与 Function Calling 的参数天然同构。函数要什么参数、什么类型、什么枚举,直接用z.object({...})写出来即可,读代码的人一眼就能看出“这个工具允许哪些参数”,比散落的 if/else 清晰得多。

第二,zod 自带结构化错误信息。safeParse返回的错误对象里包含了详细的路径(比如status字段在哪个层级)、期望类型、收到值。这些信息可以直接格式化后喂回给模型,让它知道自己错在哪、怎么改。

第三,zod schema 可以反向转换成 JSON Schema 直接发给模型。这一点非常关键,稍后在第 4 章我会细讲——它保证了你给模型看的约束和入口校验的约束是同源一体的,不会出现两套规则互相打架的情况。

我当时选型时也对比过ajv和joi。ajv性能更强,但需要维护 JSON Schema 本身,手写起来有点重;joi也不错,但对 TypeScript 的类型推导支持不如 zod 流畅。考虑到我们的技术栈是 TypeScript,而且希望校验器能和类型定义联动,最终选了 zod。

2.2 落地示例:一套完整的 Function Calling 参数校验

下面这套模式,是我在实际项目里沉淀下来的核心骨架,你可以直接抄。

先定义工具时带上运行时 schema:

import { z } from "zod"; const queryOrderTool = { name: "query_order", description: "根据订单ID查询订单详情,仅支持查询买家本人订单", // 这个 schema 是给模型看的 JSON Schema,从 zod 反向生成 parameters: queryOrderSchema.toJsonSchema(), // 这个 schema 是运行时校验用的,同一个对象 runtimeSchema: queryOrderSchema, }; const queryOrderSchema = z .object({ customer_id: z.string().min(1).max(32), status: z.enum(["pending", "paid", "cancelled"]).optional(), }) .strict(); // 禁止未声明字段

然后在调用模型的出口上统一拦截:

import { z } from "zod"; interface ToolCallResult { name: string; args: Record<string, unknown>; } function validateToolCall(tool: ToolDefinition, rawArgs: unknown): ToolCallResult { const result = tool.runtimeSchema.safeParse(rawArgs); if (!result.success) { // 把 zod 错误格式化为模型能看懂的修正指令 const hint = formatZodError(result.error); throw new ToolParamValidationError(tool.name, hint); } return { name: tool.name, args: result.data as Record<string, unknown>, }; }

关键点来了——校验失败之后,给模型的机会只有一次,不要无限重试。我的做法是:把格式化后的错误信息作为一条tool角色消息塞回对话,让模型重新生成参数:

messages.push({ role: "tool", tool_call_id: toolCallId, content: `参数校验失败:${hint}。请严格按照约束重新生成完整的工具调用参数,不要复读本错误信息。`, }); // 重新调用 LLM,最多重复 1 次 const retryResponse = await llm.chat({ messages, tools });

这套流程跑起来之后,拦截率非常可观。我统计过一周的数据,大约 4.6% 的原始调用会被拦下,经过一次重试后,其中九成以上能修正通过,剩余不到一成再走降级兜底。

2.3 safeParse 与 parse 的选择,以及 errorMap 的妙用

这里特别提醒:一定要用safeParse,不要用parse。

parse在校验失败时会同步抛异常,虽然你可以 try/catch,但流程控制会变得很绕。safeParse永远不抛异常,统一返回{ success: true, data }或{ success: false, error },配合上面的失败提示重试逻辑,写起来顺手得多。

另一个容易被忽略的细节是errorMap。zod 默认的错误信息是为开发者阅读设计的,比如“Expected enum, received string”。这种信息直接丢给模型,模型虽然也能看懂,但往往会把注意力放在错误报文的措辞上,而不是去修正参数本身。我见过模型返回“好的,我重新生成,错误信息是……”然后真的把错误信息一字不差地当作参数内容输出——这就是典型的“模型被错误文案带着跑了”。

我后来给 zod 配置了一个自定义 errorMap,把错误信息压成精简指令:

const toolParamErrorMap: z.ZodErrorMap = (issue, ctx) => { switch (issue.code) { case z.ZodIssueCode.invalid_type: return { message: `字段 ${issue.path.join(".")} 应为 ${issue.expected},实际收到 ${issue.received}` }; case z.ZodIssueCode.invalid_enum_value: return { message: `字段 ${issue.path.join(".")} 的取值只能是 ${issue.options.join(" / ")},实际收到 ${issue.received}` }; case z.ZodIssueCode.unrecognized_keys: return { message: `存在未声明的字段:${issue.keys.join(", ")},请移除` }; default: return { message: `字段 ${issue.path.join(".")} 不合法:${ctx.defaultError}` }; } }; const queryOrderSchema = z.object({...}).strict().errorMap(toolParamErrorMap);

格式化之后的错误信息大概是这样的效果:

字段 status 的取值只能是 pending / paid / cancelled,实际收到 completed

这种明确到字段、明确到期望值的指令,模型修正起来极其精准。相比把一长串 zod 默认错误堆给它,重试成功率至少提升了三成以上。

3. 校验能力和边界:schema 挡得住格式,挡不住语义

3.1 两层校验分工:结构校验 vs 业务规则校验

如果你以为加了一个 zod schema 就万事大吉,我得赶紧泼一盆冷水。schema 校验能挡住的,是结构层问题——字段缺失、类型错误、枚举越界、多余字段、格式异常。这些都是模型“编造”参数时最常犯的错,也是杀伤力最大的一类。

但它挡不住语义层问题。举个例子:

  • customer_id格式合法,字符串、长度也 OK,但这个用户账号在三天前注销了;
  • order_date确实是有效日期,但它晚于今天,是个未来时间;
  • status枚举值合法,是paid,但这笔订单其实是货到付款,根本不存在paid流转节点。

这些校验都需要**业务规则(rules)**来兜底。我经常跟团队说一句话:zod 管“参数是不是人话”,业务规则管“参数在业务里是不是真话”。

我没有把这两层混在一起。结构校验放在入口,用 zod;语义校验放在服务层,用一套独立的规则函数:

interface OrderQueryRules { customerExists(id: string): Promise<boolean>; orderStatusMatchesLifecycle(orderId: string, status: Status): Promise<boolean>; } async function enforceBusinessRules(toolName: string, args: any): Promise<void> { switch (toolName) { case "query_order": { const { customerExists } = rules; if (!(await customerExists(args.customer_id))) { throw new ToolParamValidationError(toolName, "该用户不存在或已注销,请核实后重新查询"); } break; } } }

这种分层设计,好处是每一层职责单一,后续加校验规则不用动入口逻辑,改起来心智负担很小。我强烈建议你不要把业务规则一股脑塞进 zod 的 superRefine 里——因为 zod 的定位是声明式结构校验,塞太多异步业务查询会让校验函数变得非常重,错误信息也会混杂不清。

3.2 被拦下来的请求去哪了:重试与降温策略

校验失败之后,请求不是简单地报个错就完了。我在生产环境里采用的是“一次重试 + 一次降级”的策略,总结成下表:

环节动作触发条件目的
第一次校验失败把错误指令塞回对话,让模型重生成参数zod 校验不通过利用模型的自纠错能力
重试后仍失败调用兜底模板或直接返回用户友好提示重试后再次校验不通过快速终止,避免死循环
兜底也失败记录日志,进入降级流程兜底动作本身出错防止故障传染

重试次数我建议最多一次,不要贪。原因很实际:每重试一次,token 消耗就会翻倍,延迟也会成倍增加。客服系统要求首响控制在两秒以内,两次重试基本就到了三秒多,用户早就没耐心了。

“降温”也很重要。如果一个模型调用连续三次校验失败,说明当前这个工具的参数规格可能超出了模型当前能力的稳定区。这时候不该继续让模型硬试,而是应该直接剪掉这个工具,本轮对话降级为“不带工具”的普通问答,避免模型反复横跳浪费资源。

3.3 流式输出下的校验时机

现在很多链路为了响应速度,开启stream = true。流式场景下,模型返回工具调用参数是一个 token 一个 token 吐出来的,这给校验带来了一个麻烦:你没法在参数还没吐完的时候做完整校验,只能等聚合完成。

我在流式架构里的做法是:维护一个toolCallBuffer,把每个 chunk 里的delta.tool_calls[0].arguments字符串切片不断拼接。当流结束时,把完整拼接好的 JSON 字符串JSON.parse一次,传给 zod 做校验。

let buffer = ""; for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; if (delta?.tool_calls?.[0]?.arguments) { buffer += delta.tool_calls[0].arguments; } } const rawArgs = JSON.parse(buffer); validateToolCall(tool, rawArgs); // 流结束后统一校验

这里有一个必须接受的现实:流式链路下,如果校验失败,模型可能已经把一段完整的“错误参数”展示给用户了,你没法在流中间撤回它。所以在流式场景里,我建议对工具调用类的响应做一个两段式:先给用户一个“正在查询”的过渡话术,等工具参数校验通过后再输出真实结果。

顺带说一句,如果你用了一些封装好的 Agent 框架,它们内部可能已经自带 schema 校验,但我不建议完全依赖框架默认行为——大概率它们用的 schema 与你业务声明的 schema 不是同一个实例,规则漂移往往就是这么产生的。用自己的 schema 显式校验,永远没错。

3.4 校验器本身也要做可观测性

校验逻辑上生产之后,监控比业务代码更敏感。我给校验层加了三组指标,缺一不可:

  • 校验失败率:按工具维度统计,比如query_order今天失败率多少。超过 3% 就会告警——这个数字如果持续走高,说明工具的声明描述和模型能力不匹配,需要调整 schema 的措辞,或者拆分工具职责。
  • 重试率与重试成功修正率:用来判断“错误提示给模型看之后,模型到底能不能听懂”。如果重试修正率很低,说明 errorMap 生成的提示不够明确,需要迭代提示模板。
  • 兜底触发率:重试后仍然失败的次数占比。这个指标一旦明显上升,多半是业务侧枚举值调整了,但工具描述没同步,会出现“模型怎么生成都过不了检”的死局。

这些指标落到日志系统后,我还加了一个“失败样本自动存档”功能:每次校验失败,把原始参数、zod 报错、模型重试后的参数一起存下来。后续改进 prompt 或调整枚举时,这些存档就是最好的训练素材,比看什么都直观。

4. 上线之后踩过的坑:校验器本身也会成为新故障点

4.1 模型把错误提示当成“正确输出”复读

第一个坑,来自重试机制本身。我们把 zod 的错误信息塞回tool角色消息后,模型有时候会做出一种令人哭笑不得的行为:它把错误提示原封不动地当作新的参数重写出来。

比如错误提示是“字段 status 的取值只能是 pending / paid / cancelled”,模型重试时返回的工具参数变成了:

{ "customer_id": "字段 status 的取值只能是 pending / paid / cancelled", "status": "pending" }

它把 customer_id 直接填成了错误提示的那段话。这是相对激进的现象,背后的原因是错误信息被放到了工具调用的上下文中,模型在上下文压力比较大的时候,会把指令文本误认为待处理数据。

解决办法是在错误提示的开头加一行强指令:“以下内容仅作为修正参考,绝不可写入任何参数”。同时,给customer_id这类字段加一道格式正则,比如只允许数字开头,这样即使模型犯错,也会被同一道闸再次挡住。

4.2 zod 的默认报错路径太啰嗦,需要做文本压缩

第二个坑出现在嵌套对象参数上。有些工具的参数是嵌套结构,比如:

const orderFilterSchema = z.object({ filters: z.object({ date_range: z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }), }), });

一旦date_range里的start格式错误,zod 默认错误信息会变成类似这样的路径描述:

filters.date_range.start: Invalid regex match,expected string matching /^\d{4}-\d{2}-\d{2}$/ ...

这种信息直接组装进重试提示里,模型虽然能看懂,但会浪费很多 token 去处理正则表达式本身。我在实践中做了一个压缩处理,只提取三层信息:顶层字段名、期望的类型/枚举、实际收到的值。正则这种东西,在提示里直接说人话:“日期格式应为 YYYY-MM-DD”,而不是把正则表达式翻译成文本。

4.3 给模型的 schema 和入口校验的 schema 漂移了

这个坑是我踩得最疼的一个。早期开发时,我们给模型看的工具参数是手写 JSON Schema,入口校验用的是另一个 zod schema。本来是同一个东西,但因为手写的 JSON Schema 写得太宽松,入口校验却用了非常严格的.strict(),结果出现了一种诡异的情况:模型按它的描述生成了一套多字段参数,跑到入口这里全部被拦下,连续重试也过不了。

后来我彻底改成单一数据源:用 zod schema 作为唯一真源,通过zod-to-json-schema这个库反向生成给模型的 JSON Schema。改完之后,给模型看的约束和入口校验的约束完全同源,再没出现过两套规则打架的事。

import { zodToJsonSchema } from "zod-to-json-schema"; const queryOrderSchema = z.object({ customer_id: z.string(), status: z.enum(["pending", "paid", "cancelled"]), }).strict(); const jsonSchemaForLLM = zodToJsonSchema(queryOrderSchema, "query_order");

这里还有两个小细节值得注意:zod-to-json-schema默认会给枚举值输出数组,这个没问题;但有些 provider 对 JSON Schema 的$schema版本有要求,比如只支持 2020-12 或 draft-07,需要在转换时显式指定 target 版本,否则可能在请求侧直接被拒——也就是前面提过的provider rejected the request schema or tool payload。

4.4 不要在生产环境现 build schema

zod 的 schema 定义看起来只是对象声明,但其实每次z.object({...})调用都会创建新的校验器实例。如果你的代码在每次请求里都重新声明一遍 schema,并发一高,性能损耗就显露出来了——虽然单个实例创建很快,但乘以几千 QPS 就不可忽视了。

我的做法是把 schema 定义为模块级常量,经过模块初始化后全局复用。配合缓存工具描述,尽量降低热路径上的重复计算。

性能方面有个直观对比:单纯校验一个十几个字段的扁平对象,zod 的耗时是微秒级的,完全不是瓶颈。真正可能成为瓶颈的是字段超多、嵌套超深的 schema——比如一个工具三四十个字段、还有两层嵌套,这时候建议拆分成多个子工具,每个工具的 schema 小一些。这不仅是为了性能,更重要的是让模型的生成更聚焦,参数幻觉的概率也会随之下降。


其实把 schema 校验挡在入口,这一步本身不难,真正的难点在于把它做成一个跟业务分层清晰、可观测、可迭代的体系。我个人经验是多做实验:把失败样本留下来,看得多了,你就能总结出你们业务场景下模型最容易在哪些字段上撒谎,然后针对性地在 schema 描述里加一句人话注释——比如在status字段的描述里明确写“仅支持 query_order 工具中已定义的枚举值,不要自行推断其他状态”。模型效果不会百分百归零,但配合入口校验双保险,起码能保证即使它乱说,也挡在进业务之前。

如果你们的工具链路还没加这道闸,我建议趁早补上。参数校验不是事后救火,它应该是 Function Calling 架构的一部分——是你和模型之间最可靠的契约执行者。等事故真发生了再补,代价一定是双倍的。

RELATED READING

延伸阅读

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