1. 真实场景:模型给出的参数,你敢直接拿去用吗
1.1 一次让我措手不及的线上事故
先讲一个真实经历。上个月我在调一个企业知识库问答助手,核心链路并不复杂:用户提问,模型决定调用哪些工具,工具返回结果,模型整理成回答。功能调用走的是标准的 Function Calling 流程,模型在接口里返回tool_calls,里面包含函数名和一个arguments字符串。我当时的处理很简单——拿到arguments直接JSON.parse,然后原样传给后端查询函数。
直到某天线上开始出现怪异的查询失败。用户问“帮我查一下北京分公司上个月的销售额”,工具调用明明触发了,后端却报错说找不到对应主体。我把日志里的原始arguments拉出来一看,也愣住了:模型传入的是{"company_name": "北京分公司", "month": "2025-2"},其中month根本不是我们系统约定的YYYY-MM格式,而且公司全称在系统里存的是“北京第一分公司”,简称只有在特定场景下才允许匹配。
我最初以为是自己 prompt 写得不够清楚,又或者模型偶发抽风。但连续几天统计下来,这类“参数看似正常、实则不合规”的调用居然占到了总调用量的 5% 左右,而且大多发生在月末月初切换、用户表述含糊、上下文比较长这几类场景里。这时候我才意识到,Function Calling 给出的参数天然存在不确定性,不能假设它一定会返回符合系统契约的数据。
1.2 Function Calling 的信任边界
很多人对 Function Calling 有个误解,觉得它像传统 RPC 一样,方法签名定好了,参数就必须符合类型约束。实际上不是这样。模型返回的arguments只是一个通过文本生成出来的 JSON 片段,本质上跟你让模型“写一个 JSON”没有任何区别。它只是根据系统提示词、工具描述、对话上下文,一个词一个词“猜”出来的。
我用买菜类比给同事解释过这件事:你把购物清单交给一个靠谱的人,他大概率能买回来正确的菜,但你永远不能保证他在看到“土豆”这个条目时,不会买回一个超预算的有机土豆,或者把半斤记成一斤。模型的生成过程也是如此,它见过海量文本,知道公司名、日期、金额大概长什么样,但并不知道你的数据库里到底有哪些主体、ID 有没有对应记录、日期范围是否真的允许。它能保证的是“形式像”,不能保证“内容真”。
这就是 Function Calling 的信任边界:模型负责理解意图、挑选工具、生成候选参数,应用层负责确认候选参数是否真的满足业务约束。你说“帮我查下周三的天气”,模型生成{"city": "北京", "date": "2025-03-19"},这里语法和格式都对,但 city 如果传“北京”而你的天气服务只认“北京市”,或者用户口中的“北京”其实指的是“北京朝阳区”,那信任边界就破了。
把校验放在入口之前,是我踩完坑之后总结出的第一原则:绝不直接信任模型生成的结构化参数,必须有一层代码在函数执行前做强制约束。
1.3 这类幻觉为什么会反复出现
参数被模型编造,不是偶发 bug,而是一种结构性现象。我总结下来主要有四类触发原因。
第一类是枚举和主键类字段。模型不是在查你的数据库,而是在做概率预测。对于业务主键、内部代号、规范名称,它记不住也没地方记,一旦用户表述模糊,模型就会基于训练语料里的“常识”来猜,很容易猜成相近但错误的值。比如用户说“华北区”,模型可能传“华北地区”,但系统里的枚举值只有“华北”。
第二类是格式类字段。日期、金额、百分比、手机号、身份证号这类字段,模型理解的是语义而非精确格式。同一个日期,用户说“下个月一号”,系统期望YYYY-MM-DD,模型可能生成一个不存在的“2025-02-31”,也可能生成带时间部分的2025-02-01 00:00:00。格式问题用正则或 schema 一拦一个准。
第三类是长上下文下的注意力漂移。当对话轮次很长、工具定义很多时,模型在处理当前这轮时可能把前面某个工具的参数要求记混,或者干脆漏读当前工具的约束。我线上遇到过模型在调用send_email时,把send_sms的字段名带过来的情况。
第四类是多模型、多版本混用时的行为差异。不是所有模型对 Function Calling 的遵循程度都一样,同一套工具描述,有的模型判若两人。如果你的系统做了模型路由,网关层不做统一校验,那几乎必然会遇到某一两个模型源稳定产出坏参数。
这几类问题靠调 prompt 很难根治,能做到的是“降低概率”。要兜住剩下的那部分,必须在代码层做结构性约束。
2. 为什么偏偏是 schema 校验拦在入口
2.1 校验层应该放在哪个位置
聊方案之前,先说清楚一个架构问题:校验逻辑到底放哪里。最常见的做法有三种,我实际对比过之后强烈建议第三种。
第一种是把校验写在每个工具函数内部。每个工具各自判断参数对不对,好处是灵活,坏处是二十个工具就得写二十套逻辑,而且每个工具的校验风格很可能不一致。这个工具用if (!name) throw,那个工具用assert,维护起来非常痛苦。
第二种是散落在调用方。谁发起 Function Calling,谁就在调工具之前手动 judge 一下。这种方式的问题在于,业务代码会大量被参数判断污染,而且一旦某个地方漏写了对于新工具的校验,坏参数照样能漏进去。
第三种是我推荐的统一入口校验:模型返回tool_calls之后,先经过一个独立的校验分发层,校验通过才进入真正的工具执行器。这个入口就像机场安检,所有旅客都要过同一道闸机,规则集中在闸机上,而不是依赖每个登机口各自检查。
入口校验还有一个容易被忽略的好处:它能统一错误返回格式。不管哪个工具校验失败,返回给模型和调用方的错误包都是同样的结构。后续无论是做重试、做降级,还是做日志分析,都可以按同一套契约来处理,不用针对每个工具单独适配。
2.2 选 Zod 而不是手写 if:一份 Schema 两头吃
确定了要把校验放在入口之后,下一步是选型。我一开始想过手写校验逻辑,然后很快被劝退了。原因很简单,工具数量一多,手写校验的代码量会爆炸式增长,而且逻辑极容易和工具定义不同步。你今天给get_weather加了一个unit字段描述,忘了在validateWeatherParams里同步加判断,第二天线上就给你表演一个“参数校验通过但接口返回异常”。
我最后选择了 Zod。选择它的理由不是因为它“流行”,而是它解决了一个最要命的问题:让模型的工具描述和运行时校验共用一份定义。
具体做法是,先用 Zod 定义一套运行时 schema,再用zod-to-json-schema这个库把 Zod schema 转成符合 Function Calling 要求的 JSON Schema,填到模型接口的tools参数里。这样维护的是单一事实来源:字段、必填项、枚举值、格式约束,都只定义一次,模型可见的入参说明和运行时校验用的是同一套规则。
Zod 本身提供的safeParse也足够好用。它不会直接抛异常打断流程,而是返回一个带success标记的结果对象。成功时你从data里取清洗后的参数,失败时你从error.issues里拿到所有校验问题的结构化列表。这套设计天生适合做入口拦截。
我还对比过其它方案。如果你的技术栈是 Python,那么 Pydantic 是完全对等的选择;如果团队跨语言,直接用 JSON Schema + 对应语言的 validator 也不差。关键是不要在几个体系之间来回摇摆。选型不需要追逐热度,需要的是跟技术栈贴合、有一份 schema 能同时驱动模型侧和运行时侧。
2.3 “挡在入口”到底挡住了什么
入口拦截看起来只是多了一层校验,但它的实际收益比表面上大得多。
第一层收益是挡住无效参数对下游系统的污染。线上系统一旦被模型传进来的坏参数触发,轻则查询报错,重则把脏数据写进数据库。尤其是有状态更新的工具,比如“创建工单”“发送消息”,一次坏的参数传递可能造成不可逆的影响。校验放在入口,等于在模型和业务系统之间加了一道防火墙。
第二层收益是给模型留了自纠的机会。参数校验失败并不代表整个对话要终止。你把校验错误组织成一条消息回传给模型,模型读完之后往往能自己修正参数,重新发起一次正确的调用。我实测下来,第一次校验失败后模型自行修正的成功率大概在 70% 上下,这比直接给用户报错要体面得多。
第三层收益是可以做问题归因。如果没有入口校验,工具执行报错时你很难分清到底是模型生成了坏参数,还是工具本身出了 bug,还是数据问题。有了统一校验层,绝大部分由模型参数引发的故障会在最早的位置被标记出来,日志里能清楚看到“模型参数不合法”这个独立类别。排查线上问题的速度完全不一样。
入口拦截不是要证明模型不可信,而是给整个系统画出一条清晰的边界:模型负责生成候选,校验层负责确认候选,业务系统只接受确认后的结果。
3. 落地实操:手把手接入 Zod 校验拦截
3.1 定义工具与 Schema
接下来是一段可以直接抄走的实操流程,我以一个天气查询工具为例。先安装依赖:
npm install zod zod-to-json-schema然后定义一个 Zod schema,用来约束模型传进来的参数:
import { z } from 'zod'; const WeatherParamsSchema = z.object({ city: z.string().trim().min(1).max(20, '城市名过长'), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD') });这里trim()会去掉首尾空格,regex会强制日期格式。接下来把这个 schema 转成模型接口能识别的 JSON Schema 参数描述:
import { toJsonSchema } from 'zod-to-json-schema'; const weatherToolDefinition = { type: 'function', function: { name: 'get_weather', description: '查询某个城市某一天的天气情况', parameters: toJsonSchema(WeatherParamsSchema) } };注意一点:toJsonSchema默认会生成包含$schema等元信息的完整 JSON Schema,某些模型接口对额外字段比较敏感。所以我会在转换时把target调整为标准 OpenAPI 兼容格式,或者在后处理阶段精简掉多余字段。这一步不同模型要求不一样,上线前最好先拿真实模型接口试一遍。
3.2 在调用链路上插入校验器
现在处理核心链路。模型返回的tool_calls里有一个arguments字符串,我的做法是把它解析成对象,交给统一的分发函数去校验和派发:
async function dispatchToolCall(toolName: string, rawArguments: string) { const rawArgs = JSON.parse(rawArguments); const schema = getToolSchema(toolName); const parsed = schema.safeParse(rawArgs); if (!parsed.success) { return buildToolError(toolName, parsed.error); } return executeTool(toolName, parsed.data); }getToolSchema里维护一个name -> ZodSchema的映射,保证所有工具都能在入口走同样的流程。这样做的好处是,以后每新增一个工具,只需要往映射里添加 schema,不用改分发逻辑。
我在实际项目中把所有工具的校验错误统一打包成如下结构:
{ "type": "tool_error", "tool_name": "get_weather", "code": "INVALID_TOOL_PARAMS", "message": "参数校验失败:date 格式不正确,期望 YYYY-MM-DD" }这个结构同时满足两个消费方:模型侧能根据message自行修正;日志侧能根据code快速归类统计。
3.3 校验失败后,如何引导模型修正
参数校验失败后,处理策略是把它当作一种“工具运行结果”回传给模型,而不是直接中断。
以 OpenAI 系接口为例,模型如果返回了tool_call_id,校验失败后我会构造一条tool类型的对话消息,把校验错误作为内容放入:
messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(toolError) });然后带上这条消息再向模型发起一次新请求。模型看到工具返回了一个错误,通常会自动调整参数,重新生成一次调用。
这里需要提前设计好“重试上限”。我会在调用状态机里记一个attemptCount,同一个任务最多重试 2 次。如果第二次请求模型依然返回校验失败的参数,就不再继续了,而是切到兜底回复,比如告诉用户“我这边的系统暂时没有找到匹配的信息,请换个说法再试试”。
有人说既然模型能自纠,那多试几次不就行了。我劝你别这么做。重试成本不只是多一次模型调用的费用,还伴随着响应延迟上升和用户体感变差。把上限设置为 2 次,既能拿到模型自纠的收益,又不会让用户感受到系统陷入死循环。
3.4 校验的同时顺手清洗数据
Zod 的另一个价值在于它可以在校验的同时做数据转换。比如日期字符串,校验通过后直接转成Date对象给工具函数,工具函数内部就不用再解析了:
const WeatherParamsSchema = z.object({ city: z.string().trim().min(1).max(20), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).transform((v) => new Date(`${v}T00:00:00Z`)) });再比如数字类字段,如果模型给出的值带了货币符号或千分位逗号,可以用z.coerce.number()或refine做清洗。不过要提醒一句:转换逻辑要克制。只做厂规范化、格式转换这类无歧义的清洗,不要替用户做语义补全。比如模型传“北京”,你可以统一成“北京市”,但如果你擅自把月销售额"5万"转换成50000,这种推断就可能改变原始语义,属于危险的过度清洗。
清洗类 transform 应该尽量让函数执行层保持简单,同时保留一份原始arguments的归档。清洗后的数据给工具用,原始数据给日志用,两边都不耽误。
4. 踩坑实录:参数校验最常翻车的三类情况
4.1 枚举值不在白名单里
有一种错误最隐蔽,就是模型传了个“看起来对”的值,但不在你业务的枚举白名单里。我有一次做区域筛选工具,系统里枚举是“华东”“华北”“华南”,模型传了个“华东区”。如果你 schema 里只写了z.string(),这个值能直接通过校验,然后打到下游数据系统查出空结果。
针对这类问题,我的方案是“校验 + 归一化 + 错误反馈”三步走。先在 schema 里用枚举或refine约束可选值,如果业务允许简单映射,则在transform里做归一化。再不行,就把可枚举的候选值列表放到错误消息里,让模型知道自己传的值不在白名单中,看到候选项后它改用正确值概率很大。
错误消息里带上候选值非常管用。比如:“参数校验失败:region 的值‘华东区’不在可选范围内,可选值为 华东、华北、华南、西南”。模型看到具体候选值,基本能一次修正。
4.2 嵌套结构被截断或缺失
多次调用聚合场景下,模型的arguments常常是一个嵌套很深的 JSON 对象,比如{"filters": {"date_range": {"start": "...", "end": "..."}}}。模型在生成长 JSON 时偶尔会丢嵌套字段,或者把整个filters对象漏掉。
这种错误的难点在于,Zod 的safeParse虽然会报错,但默认错误提示里常常把缺的字段列成一串很长的路径。如果直接把原始 issues 丢给模型,模型看到一大段如filters.date_range.start的错误信息,反而容易越改越乱。
我的处理是把错误信息简化成两句式:先告诉模型“哪个字段缺失或无效”,再给一个最小示例。比如直接拼接:“缺少必填字段:filters.date_range.end。完整参数示例:{"filters": {"date_range": {"start": "2025-03-01", "end": "2025-03-31"}}}”。模型读到示例后,可以很快把结构补齐。
这也是我推荐在入口统一处理错误格式的原因。如果是每个工具各自拼错误信息,格式不可能这样统一,也就很难给模型稳定的修正上下文。
4.3 版本漂移与工具描述不同步
第三种坑属于开发流程问题,比前两种更麻烦。工具接口升级了、参数加了新必填项,但模型侧的工具描述没有同步更新。结果模型还在按旧 schema 传参,而你的运行时校验已经换成了新 schema,于是大量调用在校验层集中失败。
我吃过一次比较大的亏:某次给搜索工具加了team_id必填参数,业务接口和校验代码先上线了,模型工具描述更新延迟了几个小时。结果那段时间入口校验疯狂拦截,模型一直传不出team_id,还以为是模型坏了。
现在我的团队约定是:涉及工具 schema 变更,必须模型描述、Zod 校验、实际执行函数三处同时更新,并且放在同一个发布单里。上线之后还要在灰度环境跑一组包含旧调用方式回归用例,确认模型能按新 schema 生成参数。
可以用一张表总结这三类高风险场景:
| 错误场景 | 现象 | 根因 | 处理方案 |
|---|---|---|---|
| 枚举值越界 | 值不在白名单中,校验层没发现或发现后模型仍乱传 | 模型在猜测业务主键和规范名 | 校验加枚举约束,错误消息附可选项列表 |
| 嵌套结构缺失 | JSON 缺失深层字段,校验报错不友好 | 长参数下生成稳定性下降 | 简化错误提示,提供完整示例 |
| 版本漂移 | 工具升级但描述未同步,全量调用校验失败 | 发布流程缺少同步机制 | 工具 schema、校验、执行三处强制同批发布 |
4.4 排查工具:日志是命根子
最后单独说日志。加了入口校验之后,最值得记的两类日志是:原始tool_calls完整内容和校验错误结构化结果。我一般会在dispatchToolCall入口处同时记两条日志:
[RAW_TOOL_CALL] name=get_weather raw_args={"city":"北京","date":"2025-02-31"} [SCHEMA_VALIDATION_FAILED] name=get_weather issues=date: 日期格式不正确排查线上问题时,只要把RAW_TOOL_CALL和后续对应时间点的模型补发请求拉出来,重放一遍调用流,就能很快确认是参数问题、prompt 问题还是模型源行为差异。
我有一个小习惯:所有RAW_TOOL_CALL日志都落一份到独立的文件或索引里,不跟普通业务日志混在一起。这样后续想做离线分析和失败样本收集时,能直接拿到一批原始数据,不用大海捞针。
5. 一点边界思考:校验不是终点,而是闭环起点
5.1 允许模型“不调用”
参数校验层做严谨之后,容易走进另一个极端:默认模型必须通过工具调用来完成任务。但实际上,很多用户问题并不一定需要工具介入。你问天气助手“今天适合穿什么”,模型如果硬调用get_weather查了一通再回答当然合理,但如果问“天气预报是怎么做出来的”,它直接基于知识回答更好。
在 Function Calling 链路里,我建议明确允许模型返回空工具调用。系统只处理模型实际发起的tool_calls,如果没有,就正常走纯对话流程。校验失败的兜底也是一样,重试超过上限后,不要强行再让模型调用工具,而是让模型用已有上下文给用户一个诚实的回答。
“承认自己查不到”是模型行为里很重要的一个能力。你在系统提示词里把这一条写清楚,反而能减少很多校验失败的无效调用。
5.2 定期复盘校验失败样本
入口校验运行一段时间后,你会沉淀出一批校验失败记录。这些记录价值极高,是优化整个 Agent 系统的第一手素材。我建议每个月抽一个固定时间,按错误码分组看一遍失败样本,然后把高频错误模式整理出一条优化动作。
如果错误集中在某一类格式上,比如日期格式频繁出错,那就在工具描述和 schema 里把格式示例写得再具体一点;如果错误集中在某个字段总是传错枚举值,那就要考虑是不是业务命名和用户习惯差异太大,需要在上游加一层同义词改写。校验规则不是一成不变的,它应该跟着业务和模型的实际行为持续迭代。
不要把 schema 当成永远不需要改动的静态契约,它是活的业务规则映射。业务变了、模型变了、用户习惯变了,入口校验规则就得跟着调整。
5.3 从入口拦截到数据质量闭环
真正把这套机制跑顺之后,入口校验就不再只是一段防御代码,而是整条数据处理链路的起点。校验层产生的失败数据、重试数据、模型自纠成功数据,都应该回流到一套评估体系里。我会用一个简单表格跟踪每周的数据:
| 指标 | 上周 | 本周 | 变化 |
|---|---|---|---|
| 工具调用总次数 | 4500 | 5200 | +15% |
| 参数校验失败率 | 5.2% | 3.1% | -2.1% |
| 模型自纠成功率 | 66% | 73% | +7% |
| 最终失败率(重试后仍失败) | 1.7% | 0.9% | -0.8% |
通过这些数字能直观看到每一步优化带来的实际效果。我个人最深的体会是:校验不是要给模型上刑,而是把边界画清楚,让模型在确定的时候干活,在不确定的时候认怂。这个认知一旦建立起来,整套系统都会稳很多。如果你正在做 Function Calling 相关功能,入口校验这一层建议早做,越早做,后面省的事越多。