Function Calling参数校验实战:用JSON Schema拦截模型编造参数
1. 模型为什么会“编造”参数从一次线上事故说起先说一个我亲身踩过的坑。去年做一个智能客服工单系统用户输入“帮我查一下上周北京到上海的高铁票”我把这句话连同工具定义一起丢给模型工具定义里有个search_train_tickets函数参数是from_city、to_city、date、passenger_count。测试环境跑得好好的上线第二天运营就来找我有用户查“明天广州到深圳的票”结果系统返回了“2023年11月32日”的查询结果——对11月32日一个根本不存在的日期。更离谱的是有用户问“帮我订两张票”模型把passenger_count填成了two字符串而我的函数签名要的是整数。这就是Function Calling 参数被模型编造的典型现场。模型不是故意骗你它是在做概率生成——它根据上下文“猜”下一个 token 最可能是什么而不是在“计算”一个合法值。你给它一个 JSON Schema它大概率会遵守结构但值的合法性它管不了。日期格式对不对、枚举值在不在范围内、数字有没有超界、必填字段有没有漏这些它都可能“顺手编一个看起来像那么回事的值”。所以我的结论很直接不要把模型当成参数校验器它只是个参数生成器。校验这件事必须由你自己的代码在入口处挡下来。这篇文章就围绕这个思路展开讲清楚 schema 校验到底该怎么做、用什么工具、踩过哪些坑以及怎么把校验层设计得既严格又不影响正常调用。2. 先搞清楚Function Calling 的参数到底长什么样2.1 模型返回的参数本质是一段 JSON不管你用的是哪家的模型Function Calling 的返回结构基本都长这样模型决定调用哪个函数然后给你一段 JSON 字符串作为参数。以常见的结构为例{ name: search_train_tickets, arguments: { from_city: 广州, to_city: 深圳, date: 2023-11-32, passenger_count: two } }注意两个细节第一arguments里的值类型完全由模型决定它可能给你字符串、数字、布尔甚至嵌套对象第二这段 JSON 在传输过程中可能是字符串形式需要你先JSON.parse一次。很多新手直接把这个对象透传给业务函数结果就是各种TypeError和脏数据入库。2.2 模型编造参数的四种典型模式我把实际遇到过的编造行为归了类基本逃不出这四种格式幻觉日期给你2023-11-32、2023/13/01手机号给你138-0000-0000带横杠邮箱给你abc。模型知道“这里应该是个日期”但它不检查日历。类型漂移该给数字给了字符串two、2张该给布尔给了true字符串该给数组给了单个对象。枚举越界你定义了status只能是pending、paid、cancelled它给你返回processing或者已完成。字段捏造你 schema 里根本没定义remark字段它自作主张加了一个或者必填的user_id它直接省略因为它“觉得”上下文里有。这四种里格式幻觉和类型漂移最常见字段捏造最危险——因为多出来的字段如果直接透传给下游可能触发意料之外的逻辑。2.3 为什么必须在“入口”校验有人会问我在业务函数内部做校验不行吗行但代价大。业务函数一旦被调用可能已经产生了副作用——写日志、发消息、扣库存。等你在函数内部发现参数非法再抛异常副作用已经发生了。入口校验的核心价值是“零副作用拦截”在参数还没碰到任何业务逻辑之前就把它挡在门外返回一个明确的错误让模型重新生成。提示入口校验的另一个好处是错误信息可以结构化返回给模型让它有机会自我修正。这比在业务层抛一个ValueError然后整个链路崩掉要优雅得多。3. 选型jsonschema、zod 还是手写校验3.1 三种主流方案的对比校验工具的选择直接决定了你后面维护的成本。我把常见的几种方案拉出来对比一下方案语言生态优点缺点适用场景jsonschema跨语言标准统一模型侧工具定义可直接复用错误信息偏底层嵌套校验写起来啰嗦多语言混合、需要和模型工具定义保持一致zodTypeScript/JS类型推导强链式 API 好写错误信息友好仅限 JS 生态和 JSON Schema 需要转换Node/前端为主的团队手写校验任意完全可控无依赖重复劳动容易漏字段难维护参数极简、临时脚本我的实际选择是如果工具定义本身就是 JSON Schema大多数模型平台都要求这样那就直接用 jsonschema 库校验一份定义两处用模型侧和校验侧完全对齐不会出现“模型以为的 schema”和“你校验的 schema”不一致的问题。如果是 TypeScript 项目zod 写起来更爽但要注意把 zod schema 转成 JSON Schema 给模型否则两边定义会漂移。3.2 为什么我最终选了 jsonschema说个具体的理由。我之前的项目里工具定义是用 zod 写的然后手动维护了一份 JSON Schema 给模型。结果有一次改字段zod 改了但 JSON Schema 忘了改模型按旧 schema 生成参数校验按新 schema 拦截两边打架排查了半天。后来我统一成JSON Schema 作为唯一事实来源模型侧用它校验侧也用它zod 只在需要类型推导的地方做一层薄封装。这样改一处两边同步再没出过不一致的问题。3.3 校验库的版本坑jsonschema 这个库Python 生态里叫jsonschemaNode 生态里叫ajv有个坑要注意不同版本对 JSON Schema draft 的支持不一样。模型平台给的 schema 通常是 draft-07 或 2020-12如果你用的校验库默认走的是老 draft某些关键字比如const、if/then可能不生效。我建议显式指定 draftfrom jsonschema import Draft7Validator, Draft202012Validator # 明确用哪个 draft别让它自己猜 validator Draft202012Validator(schema)Node 侧用 ajv 的话要显式new Ajv({ strict: false })否则它会对 schema 里一些“非标准但模型平台在用”的写法报错。4. 核心实操把 schema 校验挡在入口的完整实现4.1 第一步定义一份“严格模式”的 schema模型平台给的 schema 往往是“宽松”的——它只告诉模型有哪些字段但不强制约束。你要在校验侧把它收紧。关键是在 schema 里加上这些约束{ type: object, properties: { from_city: { type: string, minLength: 1, maxLength: 50 }, to_city: { type: string, minLength: 1, maxLength: 50 }, date: { type: string, pattern: ^\\d{4}-\\d{2}-\\d{2}$ }, passenger_count: { type: integer, minimum: 1, maximum: 5 } }, required: [from_city, to_city, date], additionalProperties: false }几个关键点解释一下pattern只能保证格式是YYYY-MM-DD但保证不了2023-11-32这种“格式对但日期不存在”的情况。所以正则之后还要加一层语义校验这个后面讲。additionalProperties: false是拦截“字段捏造”的关键。模型多给的字段会直接导致校验失败而不是被静默忽略。required里没放passenger_count因为它是可选的但一旦给了就必须是 1 到 5 的整数。4.2 第二步封装一个统一的校验入口不要在每个工具函数里各写一遍校验逻辑抽一个统一的入口函数import json from jsonschema import Draft202012Validator, ValidationError from datetime import datetime def validate_tool_args(tool_name: str, raw_args: str, schema: dict) - dict: # 1. 先解析 JSON模型可能返回字符串 try: args json.loads(raw_args) if isinstance(raw_args, str) else raw_args except json.JSONDecodeError as e: raise ToolArgError(tool_name, JSON_PARSE_FAILED, str(e)) # 2. schema 结构校验 validator Draft202012Validator(schema) errors sorted(validator.iter_errors(args), keylambda e: e.path) if errors: raise ToolArgError(tool_name, SCHEMA_VALIDATION_FAILED, format_errors(errors)) # 3. 语义校验schema 管不到的部分 semantic_check(tool_name, args) return args这个函数做了三件事解析、结构校验、语义校验。顺序不能反因为语义校验依赖结构已经正确。4.3 第三步补上 schema 管不了的语义校验这是最容易被忽略的一环。JSON Schema 能校验格式但校验不了“这个日期是否真实存在”“这个城市名是否在服务范围内”。我一般会针对每个工具写一个语义校验函数def semantic_check(tool_name: str, args: dict): if tool_name search_train_tickets: # 日期真实性校验 try: datetime.strptime(args[date], %Y-%m-%d) except ValueError: raise ToolArgError(tool_name, INVALID_DATE, f日期不存在: {args[date]}) # 城市白名单校验 valid_cities load_city_whitelist() for key in (from_city, to_city): if args[key] not in valid_cities: raise ToolArgError(tool_name, CITY_NOT_SUPPORTED, f暂不支持: {args[key]})语义校验的粒度要把握好太松等于没校验太严会把正常请求也拦掉。比如城市白名单我一开始只放了几个大城市结果用户查“佛山到东莞”直接被拒体验很差。后来改成“先查白名单不在白名单的走模糊匹配匹配不到再拒”拦截率降下来了脏数据也没进来。4.4 第四步把校验失败的信息结构化返回给模型校验失败不要直接抛异常让链路崩掉而是返回一个结构化的错误让模型有机会重新生成参数def build_retry_message(tool_name: str, error: ToolArgError) - dict: return { role: tool, tool_call_id: error.call_id, content: json.dumps({ status: error, error_code: error.code, message: error.message, hint: 请根据错误信息修正参数后重新调用 }, ensure_asciiFalse) }这样模型收到错误后大概率会重新生成一版参数。我实测下来格式类错误日期、类型模型一次修正成功率在 80% 以上枚举类错误大概 60%。所以重试机制要设上限一般 2 次就够了超过就降级到人工或默认值。5. 参数校验的进阶技巧与性能考量5.1 用oneOf处理多形态参数有些工具的参数支持多种形态比如“查询条件”既可以是城市名也可以是城市 ID。这时候用oneOf{ query: { oneOf: [ { type: string, pattern: ^[\\u4e00-\\u9fa5]{2,10}$ }, { type: integer, minimum: 1 } ] } }但oneOf有个坑如果两个分支都能匹配校验会失败。比如字符串123既匹配 string 分支如果 pattern 允许数字又可能被尝试匹配 integer 分支。所以分支之间要互斥或者用anyOf放宽。5.2 校验性能别让校验成为瓶颈jsonschema 的校验本身很快但有两个地方容易拖慢每次调用都重新编译 validator。Draft202012Validator(schema)这个动作有开销应该把编译好的 validator 缓存起来按工具名做 key。语义校验里的远程调用。比如城市白名单如果每次都查数据库QPS 一高就顶不住。我一般用本地缓存 定时刷新白名单这种数据变更频率低缓存 5 分钟完全够用。实测数据一个包含 8 个字段、3 层嵌套的 schema编译一次约 0.5ms校验一次约 0.1ms。缓存 validator 后单次校验总耗时稳定在 0.2ms 以内对整体链路基本无感。5.3 校验规则的版本管理schema 是会变的。今天加个字段明天改个枚举值。如果 schema 直接硬编码在代码里每次改都要发版。我的做法是把 schema 抽成独立的配置文件JSON 或 YAML代码启动时加载配合一个版本号。这样改 schema 只需要更新配置重启服务即可不用改代码逻辑。注意schema 变更要向后兼容。加字段可以删字段和改类型要谨慎因为模型侧的工具定义可能还没同步更新贸然收紧会导致大量正常请求被拦。6. 常见问题与排查技巧实录6.1 校验失败排查速查表现象可能原因排查方向所有请求都校验失败schema 本身写错了或 draft 不匹配打印 schema用在线校验器验证偶发失败重试就好模型生成不稳定看失败样本补语义校验或加提示词约束报additionalProperties错误模型捏造了字段确认是否要放开或在校验前剔除多余字段日期格式对但业务报错语义校验缺失补strptime之类的真实性校验数字被当成字符串模型类型漂移schema 里加type约束或做类型转换6.2 三个我踩过的坑坑一additionalProperties: false太激进。有次模型返回的参数里多了一个_reason字段它自己加的“理由”结果被拦了。后来我改成校验前先按 schema 的properties过滤一遍只保留合法字段多余的直接丢弃而不是报错。这样既防了脏数据又不会因为模型的“好心”而误伤。坑二枚举值大小写敏感。我定义status枚举是pending、paid模型返回Pending校验失败。后来在语义校验里加了一层lower()归一化问题解决。枚举校验前先做归一化是个低成本高收益的操作。坑三嵌套对象的校验信息不友好。深层嵌套的 schema 校验失败时jsonschema给的错误路径是[items, 0, price]这种直接返回给模型它看不懂。我写了个format_errors把路径转成items[0].price这种人类可读格式模型修正成功率明显提升。6.3 一个反直觉的经验校验不是越严越好。我一开始追求“零脏数据”把所有能加的约束都加上了结果拦截率飙升到 15%大量正常请求被误伤。后来我调整策略结构校验从严类型、必填、枚举语义校验从宽能归一化的就归一化能兜底的就兜底。拦截率降到 3% 左右脏数据依然没进来。这个平衡点需要根据你的业务容忍度来调没有标准答案。7. 把校验层做成可复用的基础设施7.1 抽象成独立的校验模块如果你的系统里有多个工具、多个模型调用点校验逻辑一定要抽成独立模块而不是散落在各处。我的模块结构大概是这样validator/ __init__.py core.py # validate_tool_args 主入口 schemas/ # 各工具的 schema 配置 search_train.json book_hotel.json semantic.py # 语义校验函数 errors.py # 错误类型定义 cache.py # validator 缓存这样新增一个工具只需要加一份 schema 配置和一个语义校验函数主流程完全不用动。7.2 监控与告警校验层是观察模型行为的最佳窗口。我一般会埋几个指标校验失败率按工具、按错误类型分组。失败率突然升高说明模型侧可能变了或者 schema 改出问题了。重试成功率模型收到错误后重新生成的成功率。这个指标低说明错误信息不够清晰或者模型能力不够。字段捏造频率additionalProperties触发的次数。频率高说明提示词需要加强约束。这些指标我一般接到现有的监控系统里设个阈值告警。有一次模型平台悄悄更新了版本某个工具的失败率从 2% 涨到 20%告警及时触发我们当天就定位到了问题。7.3 和提示词约束的配合schema 校验是“事后拦截”提示词约束是“事前引导”。两者要配合用。我在工具定义的 description 里会明确写清楚约束比如“date 必须是 YYYY-MM-DD 格式且为真实存在的日期”“passenger_count 必须是 1 到 5 的整数”。实测下来提示词写清楚约束能把格式类错误降低一半以上剩下的再靠 schema 兜底。但提示词不能替代校验。模型再听话也有概率“发挥”。所以我的原则始终是提示词负责降低错误率schema 校验负责保证零脏数据两者缺一不可。8. 关于这套方案的一些个人体会这套“schema 校验挡在入口”的方案我在三个项目里落地过从最初的纯 jsonschema 到后来加上语义校验、缓存、监控前后迭代了大概半年。最大的体会是Function Calling 的可靠性不取决于模型多聪明而取决于你的工程约束多严密。模型编造参数是它的本性你没法改变但你可以决定这些编造出来的参数能不能进入你的系统。另一个体会是校验层的错误信息质量直接决定了整个链路的自愈能力。错误信息写得越清楚、越结构化模型自我修正的成功率越高人工介入的次数就越少。我在这上面花的功夫比写校验逻辑本身还多。最后分享一个小技巧如果你不确定某个 schema 约束会不会误伤正常请求可以先把它设成“只记录不拦截”模式跑一周看看命中率再决定要不要真正开启拦截。这个灰度过程能帮你找到那个“严而不误伤”的平衡点。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →