尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Function Calling 参数校验实战:用 JSON Schema 拦截模型编造

Function Calling 参数校验实战:用 JSON Schema 拦截模型编造 1. 模型返回的参数为什么不能直接信Function Calling 刚上手的时候很多人会有一个错觉既然工具调用的参数是模型按我给的 JSON Schema 生成的那它应该八九不离十。我一开始也这么想直到线上跑了一周发现日志里开始出现一些鬼东西——用户明明问的是北京天气模型给工具塞了个city: 北京市朝阳区建国路88号用户要查订单模型把order_id填成了ORD-12345推测更离谱的一次amount字段本该是数字模型返回了字符串一百二十块。这些参数如果直接透传给下游业务函数轻则查询报错重则写库污染数据。问题的根源在于Function Calling 的参数生成本质上是语言模型在续写文本而不是程序在做类型转换。模型看到的是 schema 的文本描述它尽力去猜你想让它填什么但它的输出是概率性的不是确定性的。schema 在模型眼里更像一份参考建议而不是编译期约束。所以正确的姿势是把 schema 校验放在工具真正执行之前当成一道入口闸机。模型可以编但编出来的东西过不了校验就直接打回绝不让脏参数流进业务逻辑。这篇文章就把我这套入口拦截的完整做法拆开讲包括 schema 怎么写才不容易被钻空子、用 Python 的 jsonschema 怎么落地、遇到模型编造时怎么优雅地让它重试以及几个我踩过的坑。适合谁看如果你正在做 Agent、工具调用、LLM 应用后端或者只是想让模型稳定地吐出结构化数据这篇都能直接抄作业。不需要你是 schema 专家我会把每个设计决策背后的为什么讲清楚。2. 先搞清楚模型会在哪些地方编在动手写校验之前得先知道敌人长什么样。我把过去几个月遇到的模型编造参数的情况归了类基本逃不出下面这几种。理解这些模式你写 schema 的时候才知道该在哪儿加约束。2.1 类型漂移数字变字符串布尔变是这是最高频的一类。schema 里写的是type: integer模型返回3字符串或者三。原因很简单模型在生成 token 的时候数字和字符串在它眼里没有本质区别它只是在续写一个看起来合理的值。尤其是当字段名带点语义比如count、age、price模型很容易把中文数字或者带单位的字符串塞进去。我实测下来integer和number字段是重灾区boolean次之模型会返回true、yes、是。string反而最稳因为什么都能塞进字符串。2.2 枚举越界给了选项还自己发挥schema 里明明写了enum: [pending, paid, cancelled]模型返回已完成或者PAID大小写不一致。这种情况通常发生在字段描述不够明确或者模型觉得用户的意思应该是已完成。它不是在遵守枚举而是在翻译用户意图。2.3 必填字段缺失或塞空required里列了user_id模型返回的 JSON 里压根没这个 key或者给了个null、。这多半是因为模型在对话上下文里没找到明确的值又不想拒绝回答于是干脆省略或者填空。2.4 格式幻觉日期、ID、邮箱全靠编format: date的字段模型返回2024年13月45日order_id返回一个格式对但根本不存在的编号。这类最危险因为格式看起来像那么回事如果不做格式校验直接拿去查库就是一次无效查询甚至误命中。2.5 嵌套结构错位当参数是对象或数组时模型可能把嵌套层级搞错比如该是{address: {city: 北京}}它返回{address: 北京}。或者数组里混进了不符合 item schema 的元素。把这五类记在心里接下来写 schema 和校验逻辑时每一条都要有对应的拦截手段。3. 写一份防编造的 JSON Schema很多人写 schema 就是随手把字段名和类型列一下然后指望模型自觉。这等于把闸机拆了还怪人闯红灯。一份合格的、面向 Function Calling 的 schema应该把模型可能编造的空间尽可能压缩。下面是我总结的几个关键设计原则。3.1 类型要收紧别给模糊地带能用integer就别用number能用enum就别用string。每放宽一层模型就多一分发挥空间。比如金额字段如果业务上只接受整数分那就写type: integer而不是type: number。日期字段优先用type: string, format: date而不是让模型自由发挥。{ type: object, properties: { order_id: { type: string, pattern: ^ORD-[0-9]{8}$, description: 订单编号格式固定为 ORD- 加 8 位数字例如 ORD-20240115 }, amount_cents: { type: integer, minimum: 1, description: 订单金额单位为分必须是正整数 }, status: { type: string, enum: [pending, paid, cancelled], description: 订单状态只能是这三个值之一 } }, required: [order_id, amount_cents, status], additionalProperties: false }注意最后那个additionalProperties: false这是很多人会漏掉的一招。它禁止模型塞进 schema 里没定义的字段。我遇到过模型自作主张加了个note: 用户可能还想退款这种字段虽然不致命但会让下游解析逻辑变得不可预测。关掉它世界清净很多。3.2 description 是给模型看的使用说明别把 description 当成注释随便写。模型在生成参数时description 是它最重要的参考。写得越具体模型编造的概率越低。对比一下差的写法description: 城市名称好的写法description: 城市名称只填城市名不要带省市区或街道例如北京、上海、广州我实测过把 description 从模糊改成具体某个字段的编造率从大约 15% 降到了 3% 以内。这不是玄学是因为模型有了更明确的填空指引。3.3 用 pattern 和 format 兜住格式类字段凡是格式固定的字段ID、手机号、日期、邮箱一律加pattern或format。这既是给模型的提示也是校验时的第一道防线。常见的字段类型推荐约束示例订单号pattern^ORD-[0-9]{8}$手机号pattern^1[3-9][0-9]{9}$日期formatdate邮箱formatemail枚举enum[a,b,c]正整数typeminimuminteger,minimum: 13.4 required 要诚实别把可选字段硬塞进去有些字段业务上确实可能没有那就别放进required。硬塞进去的结果是模型为了满足 required编一个值出来。宁可让字段可选然后在业务层做默认值处理也不要逼模型编造。提示schema 的设计目标不是描述得最全而是让模型最难编错。约束越精确模型的行为越可预测。4. 用 jsonschema 在入口做拦截schema 写好了接下来是落地校验。Python 生态里做 JSON Schema 校验jsonschema库是最成熟的选择安装简单规则覆盖全报错信息也够用。4.1 安装与基础用法pip install jsonschema基础校验就三行import jsonschema def validate_args(args: dict, schema: dict) - tuple[bool, str]: try: jsonschema.validate(instanceargs, schemaschema) return True, except jsonschema.ValidationError as e: return False, e.messagejsonschema.validate会在第一个错误处抛出ValidationErrore.message是给人看的错误描述e.path是出错字段的路径。这个信息非常关键后面让模型重试时要靠它。4.2 为什么选 jsonschema 而不是手写 if-else有人会说我就几个字段手写if not isinstance(args[amount], int)不就行了小规模确实可以但一旦字段多起来、有嵌套结构、有格式约束手写校验会迅速变成一坨难以维护的代码。而且手写校验很难覆盖pattern、format、additionalProperties这些细节。用 jsonschema 的另一个好处是schema 本身就是文档。它既能喂给模型做 Function Calling 定义又能直接拿来校验一份定义两处用不会出现模型看到的 schema和校验用的规则不一致的情况。这个一致性非常重要我后面会专门讲。4.3 把校验封装成工具调用的前置中间件在实际的 Agent 流程里工具调用通常长这样模型返回tool_calls你解析出function.name和function.arguments然后调用对应的 Python 函数。校验就应该插在解析出参数和调用函数之间。import json import jsonschema TOOL_SCHEMAS { query_order: { ... }, # 上面那份 schema } def dispatch_tool_call(tool_call) - dict: name tool_call.function.name raw_args tool_call.function.arguments # 第一步JSON 解析本身就可能失败 try: args json.loads(raw_args) except json.JSONDecodeError as e: return {ok: False, reason: f参数不是合法 JSON: {e}} # 第二步schema 校验 schema TOOL_SCHEMAS.get(name) if schema is None: return {ok: False, reason: f未知工具: {name}} try: jsonschema.validate(instanceargs, schemaschema) except jsonschema.ValidationError as e: return {ok: False, reason: f参数校验失败: {e.message}, path: list(e.path)} # 第三步校验通过才真正执行 return {ok: True, result: execute_tool(name, args)}这段代码里有个容易被忽略的点json.loads本身也会失败。模型偶尔会返回带尾逗号、单引号、或者干脆截断的 JSON。所以 JSON 解析和 schema 校验是两道独立的关卡缺一不可。4.4 校验失败后怎么让模型重试校验失败不是终点而是给模型一次改过的机会。我的做法是把错误信息结构化地回传给模型让它重新生成参数。关键是错误信息要具体别只说校验失败。def build_retry_message(reason: str, path: list) - str: field ..join(str(p) for p in path) if path else 参数 return ( f你上一次生成的参数在字段 {field} 上不符合要求{reason}。 f请严格按照工具定义重新生成不要添加未定义的字段 f数字字段必须是数字类型枚举字段只能从给定值中选择。 )实测下来把e.path和e.message一起回传模型第二次生成的成功率能到 90% 以上。如果连续两次都失败就别再让它重试了直接返回一个友好的错误给用户避免陷入无限循环烧 token。注意重试次数一定要设上限我一般设 2 次。超过就降级处理这是防止死循环的关键。5. 那些让我熬夜的坑schema 校验这套东西原理不复杂但真正上线后坑都在细节里。下面这几个是我实际踩过的写出来帮你省点时间。5.1 模型看到的 schema 和校验用的 schema 不是同一份这是最隐蔽的坑。我一开始图省事给模型定义工具时手写了一份 schema校验时又手写了一份结果两份的required字段不一致。模型按 A 生成校验按 B 检查天天误报。后来我改成单一数据源一份 schema 定义既序列化后喂给模型又直接用于校验。这样永远不会漂移。5.2 中文数字和全角字符模型返回amount: 全角数字或者count: 三jsonschema的类型校验会直接判失败这是对的。但有时候模型返回的是amount: 120字符串形式的数字这种如果业务上能接受可以在校验前做一次温和的归一化。我的做法是先尝试归一化再校验但归一化只做安全的转换比如字符串数字转数字不做语义猜测。def normalize_args(args: dict) - dict: for key, value in list(args.items()): if isinstance(value, str) and value.isdigit(): args[key] int(value) return args注意这里只处理纯数字字符串像一百二这种绝不猜直接让它校验失败回退给模型重试。5.3 嵌套对象里的 additionalPropertiesadditionalProperties: false只对当前层级生效。如果你的 schema 有嵌套对象每一层都要单独加。我踩过一次外层关了内层没关模型在内层对象里塞了个额外字段校验居然通过了。后来我写了个小工具递归遍历 schema确保每一层 object 都带上这个约束。5.4 数组元素的校验数组字段要特别注意items的定义。如果items没写数组里塞什么都能过。我见过模型往tags数组里塞了一个对象因为items没约束。正确写法{ tags: { type: array, items: {type: string}, maxItems: 5 } }maxItems也建议加上防止模型一口气生成几十个元素把上下文撑爆。5.5 校验通过不等于业务合法schema 校验只能保证结构合法保证不了业务合法。比如order_id格式对了但这个订单在数据库里根本不存在。所以 schema 校验是入口闸机业务校验是第二道关。两者职责不同别指望 schema 把业务逻辑也管了。我的分层是schema 校验管类型、格式、枚举、必填业务层管存在性、权限、状态流转。6. 让校验和重试形成闭环单次校验只是拦截真正让系统稳定的是校验—反馈—重试这个闭环。这一节讲讲怎么把这个闭环做得既稳又省。6.1 错误信息要可操作回传给模型的错误信息必须包含三要素哪个字段、错在哪、正确格式是什么。只给校验失败等于没说。我一般会从ValidationError里提取path和message再拼上一句格式提示。比如字段amount_cents校验失败一百二 is not of type integer。该字段必须是整数单位为分例如 12000。这种信息模型一看就懂第二次基本能改对。6.2 重试要有次数上限和降级策略我设的是最多重试 2 次。第一次失败回传错误让它改第二次还失败就不再重试直接返回一个兜底响应比如抱歉我没能正确理解你的请求请换个说法试试。这样既避免了无限循环也给了用户一个明确的反馈而不是卡在那里。6.3 记录失败样本反哺 schema 优化每次校验失败我都会把工具名 原始参数 错误信息记一条日志。攒一段时间后回看会发现某些字段反复出问题。这时候要么改 description 写得更清楚要么加更严的 pattern。这是一个持续优化的过程schema 不是写完就不管的。我统计过经过三轮 schema 优化后整体校验失败率从最初的 12% 降到了 2% 以下。剩下的 2% 大多是用户输入本身就有歧义属于正常范围。6.4 用 zod 做前端或 TS 侧的对称校验如果你的工具调用链路里有 TypeScript 环节比如前端也要校验一遍可以考虑用 zod。zod 的 schema 定义和 JSON Schema 可以互相转换思路是一样的定义一次多处校验。Python 侧用 jsonschemaTS 侧用 zod两边规则对齐就不会出现后端拦住了前端没拦住的尴尬。7. 几个可以直接抄的实践建议最后这部分是我把上面所有经验浓缩成的几条可执行建议你拿去就能用。第一schema 单一数据源。给模型的定义和校验用的规则必须是同一份用代码生成别手写两遍。这是所有实践里最重要的一条。第二校验前先做 JSON 解析保护。json.loads用 try 包住模型返回的 JSON 不合法是常态不是异常。第三类型能紧则紧。integer 优于 numberenum 优于 stringpattern 优于自由文本。每收紧一层模型编造的空间就小一分。第四description 当说明书写。把格式、示例、禁忌都写进去这是降低编造率性价比最高的手段。第五重试设上限失败要降级。别让模型无限重试2 次封顶失败给用户明确反馈。第六校验通过后还有业务校验。schema 管结构业务管语义两层分开各司其职。第七记录失败样本。失败日志是优化 schema 的金矿定期回看持续迭代。我个人在实际操作中的体会是Function Calling 的稳定性八成靠 schema 设计两成靠校验和重试。很多人把精力花在 prompt 调优上却忽略了 schema 这个更根本的抓手。把入口这道闸机做扎实后面的事情会顺很多。这套做法我从单工具场景一路用到十几个工具的复杂 Agent基本没翻过车你可以放心参考。
返回列表