"解析失败率不到 1%,可以上生产了。"错的不是数字,是口径。1% 的调用级失败率,在 12 次调用的任务里会放大成约 11% 的任务级失败率;失败那步若已写下游,损失再乘一次。
结构化输出的可靠性,不是"怎么让模型输出对",而是在哪兜、兜到什么程度、兜不住谁接手——三件都是架构决策。
1. 先换口径:调用级失败率没有决策价值
| 口径 | 算法 | 决策用途 |
|---|---|---|
| 调用级失败率 | 失败调用 / 总调用 | 只能看趋势 |
| 任务级成功率 | 一任务内全部调用成功的比例 | 唯一可上线的口径 |
| 失败放大系数 | 1-(1-p)^n |
判断该不该上重试 |
| 静默错误率 | 解析成功但语义错的比例 | 决定要不要加语义校验 |
| 每任务调用数 n | p=1% | p=3% |
|---|---|---|
| 3 | ≈3% | ≈9% |
| 8 | ≈8% | ≈22% |
| 20 | ≈18% | ≈46% |
二项近似,实际随任务结构变化。表只说明一件事:n 大时,减少 n 或加兜底的收益远大于优化 p。
2. 三种实现的可靠性上限
| 实现 | 上限由谁决定 | 能保证 | 保证不了 |
|---|---|---|---|
| 提示约束 | 模型服从度 | 大致格式 | 字段完整、类型、嵌套 |
| JSON mode | 平台解码器 | 语法合法 JSON | 你的 schema、字段语义 |
| schema 校验的函数调用 | 平台 + 你的 schema | 结构形状 | 值是否合理、该不该调此工具 |
没有一条能保证语义正确。选型判据不是"哪个最可靠",而是"哪层的失败我能自动兜住"。函数调用把失败从解析期推到校验期——这才是它的价值:位置更靠前、错误结构化、兜底好写。
3. "合法 JSON 但不合我的 schema"
这类问题全部发生在"解析成功"之后:
| 形态 | 例子 | 检出手段 |
|---|---|---|
| 类型漂移 | 数字给成 "42" |
类型校验 |
| 枚举外值 | "urgent " 带空格或给同义词 |
归一化后再校验 |
| 必填缺失 | 省略语义必需的字段 | 业务校验 |
| 语义越界 | amount 合法但为负 |
领域规则 |
| 结构合法但意图错 | 调 refund 指向错单 |
交叉校验、对账 |
前三类靠校验器与领域规则;第五类只能靠外部不变量(退款额不得超原单)。当 schema 问题处理,是最贵的一处误判。
4. schema 形状就是失败率参数
嵌套、可选、枚举、联合类型各自贡献失败概率,且是乘性叠加:
| 形状 | 影响 | 设计判据 |
|---|---|---|
| 嵌套 > 3 层 | 高,内层最易漏 | 压平成并列字段,或拆两次调用 |
| 可选字段多 | 中,倾向省略 | 改"必填 + 显式 null" |
| 枚举 > 10 个值 | 高,长尾易错 | 拆两级,或改自由文本 + 代码映射 |
| 联合类型 oneOf | 高,判别字段常丢 | 加显式 type 字段,禁靠存在性推断 |
| 长数组 > 20 项 | 高,尾部截断或合并 | 分页,或只返 id |
判据:每加一层结构,就在兜底预算里加一笔。 表达力与生成成功率是一对取舍,决策要做的是给预算标价,不是追求最精确 schema。
5. 校验失败的三级处理与责任归属
| 级别 | 动作 | 成本 | 责任方 | 适用 |
|---|---|---|---|---|
| L1 重试 | 带校验错误重问一次 | 一次调用 | 编排层 | 结构性违规、可定位 |
| L2 宽松解析 | 修补/默认值/丢字段 | 低,引脏数据 | 代码 | 非关键字段、下游容忍 |
| L3 转人工 | 挂起并交接 | 人力 + 延迟 | 业务方 | 关键字段、有副作用 |
# contract-policy.yaml —— 失败由哪层兜,写在配置而不是散在代码里
fields:
order_id: {required: true, on_invalid: L1_then_L3}
amount: {required: true, on_invalid: L3} # 涉钱,禁自动修补
remark: {required: false, on_invalid: L2_default_null}
tags: {required: false, on_invalid: L2_drop}
retry:
max: 1
feedback: validation_errors # 回灌错误清单,不是原样重发
归属要写下来。散在代码里的兜底,半年后没人知道某字段为何被静默置空。
6. 重试的提示设计
原样重发等于赌同一颗骰子。有效重试至少要带三样:
你上一次的输出被校验拒绝,原因如下:
- $.items[2].price: 期望 number,实际 "12.5元"
- $.items[3]: 缺少必填字段 sku
只修正上述问题,其余字段保持不变。
错误路径、期望与实际、修正范围。 少了"其余保持不变",模型会重写整份输出,把正确字段也改坏——长输出里的常见二次故障。
7. 流式与结构化的冲突
流式只保证"增量可解析",不保证"增量可校验":字段流到一半永远不完整。
| 需求 | 可行做法 |
|---|---|
| 边流边展示结构 | 只展示已闭合字段 |
| 边流边执行工具调用 | 不做,参数必须完整 |
| 长输出的进度感 | 流式展示摘要,结构部分最后统一校验 |
判据:凡"读到就能用"的假设,在流式下都不成立。 所以两条通道通常要拆开。
8. 参数校验必须由代码做
模型给的参数是输入数据,不是可信调用:
| 层 | 校验什么 | 由谁 | 失败后 |
|---|---|---|---|
| 结构层 | 类型、必填、枚举、范围 | schema 校验器 | 回灌重试一次 |
| 语义层 | 权限、归属、状态机前置条件 | 业务代码 | 直接拒绝,不回灌 |
语义层回灌等于说"换个参数再试",会把校验失败变成越权探测。结构层可重试,语义层硬拒。
9. 工具集合规模与选择准确率
| 工具数 | 典型表现 | 治理手段 |
|---|---|---|
| < 10 | 基本稳定 | 描述写清即可 |
| 10–30 | 相近工具开始混淆 | 分组 + 描述写"何时不用" |
| 30–100 | 明显退化,误选集中在少数几对 | 两级路由:先选组再选工具 |
| > 100 | 不可靠 | 按任务裁剪工具集,只暴露子集 |
判据:工具集按任务上下文裁剪,而不是一次全暴露。 代价不止准确率,还有每次多付的 schema token。
10. 工具描述是接口文档
| 写法 | 影响 |
|---|---|
| 只写功能 | 与相近工具混淆 |
| 写"何时用 + 何时不用" | 显著降低误选 |
| 写清返回结构与失败语义 | 减少二次调用 |
| 塞实现细节 | 噪声,降低准确率 |
最值钱的是否定条件("别用它做 X,用 Y"):把边界交给模型,成本最低。
11. 多工具返回格式不一致的治理
{
"ok": true,
"data": { "...": "工具自身结构" },
"error": {"code": "RATE_LIMITED", "retryable": true, "message": "..."},
"meta": {"tool": "refund.create", "latency_ms": 231, "idem_key": "..."}
}
外层统一信封(ok/data/error/meta),内层保留工具结构。编排层只处理一种壳,工具演进不影响调用方;代价是多层包装,换错误处理只写一次。
12. 幂等:模型会重复调用同一工具
重复调用是常态:重试、纠错、并行分支都会产生。按副作用代价分档:
| 副作用 | 处置 | 实现 |
|---|---|---|
| 只读 | 允许重复 | 无 |
| 幂等写(upsert) | 允许重复 | 自然键 |
| 非幂等写 | 幂等键 + 去重窗口 | 会话+工具+规范化参数哈希 |
| 不可撤销(发信、转账) | 二次确认 + 单次令牌 | 令牌一次性 |
tool_side_effects:
refund.create: {class: non_idempotent, idem_key: required}
refund.notify: {class: irreversible, single_use_token: true}
order.get: {class: read_only, retry: free}
表与工具注册表放一起,编排层调用前读到档位。事后判断"是否重复"成本高得多。
13. 可观测:三个必看指标
| 指标 | 定义 | 指向 |
|---|---|---|
| 任务级成功率 | 全链无失败占比 | 唯一对用户可见的指标 |
| schema 违规分布 | 按字段/类型聚合 | 具体 schema 设计缺陷 |
| 工具选择错误率 | 标注或下游异常反推 | 描述与工具集规模 |
{"run":"r-0412","step":3,"tool":"refund.create","schema_ok":false,"violations":[{"path":"$.amount","type":"type_mismatch"}],"handled":"L1","retry":1,"ok":true}
handled 必须记。只统计失败次数,回答不了"兜底是否有效"。
14. 测试策略
| 集合 | 规模(按版本核对) | 维护成本 | 抓什么 |
|---|---|---|---|
| 黄金回归集 | 50–200 条固定输入 | 低 | 改动后的行为漂移 |
| 对抗样本 | 长尾、超长、歧义 | 中 | 边界崩溃 |
| 历史失败回放 | 线上真实失败样本 | 低(自动沉淀) | 同类复发 |
{"id":"c-017","input":"把订单 8821 的 300 元退款","expect":{"tool":"refund.create","args":{"amount":300}}}
{"id":"c-018","input":"订单 8821 退一半","expect":{"tool":"refund.create","args":{"amount":190}},"note":"歧义,允许提问"}
第三类最划算也最常被忽略:线上失败都进回归集,那是唯一反映真实分布的数据。
15. 排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 解析一直成功,下游偶发脏数据 | 只校结构不校语义 | 补领域规则,升到 L3 |
| 重试后字段变差 | 提示没限定修正范围 | 加"其余保持不变" |
| 某字段违规率特别高 | 该字段 optional 或嵌套过深 | 改必填、压平层级 |
| 两个工具偶发选错同一个 | 描述高度重合 | 补"何时不用"或合并 |
| 同一订单被退两次 | 非幂等且无幂等键 | 上 key + 去重窗口 |
| p 没变但投诉变多 | 看的是调用级口径 | 改看任务级成功率 |
16. 边界
| 场景 | 判断 |
|---|---|
| 输出仅用于展示,人眼会扫 | 不必严格 schema,宽松解析 |
| 下游本就容忍缺失字段 | 只校"关键字段存在" |
| 有稳定人工复核环节 | 校验可放宽,成本转给复核 |
| 单次调用、无副作用 | 重试一次足够,不要三级兜底 |
| 输出驱动不可逆动作 | 反过来:严 schema + 二次确认 + 单次令牌 |
这套设计有一条对称性:可靠性预算应与失败的不可逆程度成正比,而不是与"业务重要性"这种模糊说法挂钩。
自测
- 为什么调用级失败率不能作为上线判据?用 n=20 说明差距。
- "合法 JSON 但不合 schema"的五种形态里,哪种校验器永远抓不到?
- 为什么"函数调用更可靠"要打折扣?它把失败推到了哪一层?
- 嵌套从 2 层增到 4 层,兜底预算该怎么变?依据是什么?
- 工具数超过 30 后,为什么"按任务裁剪工具集"比"优化单个描述"更有效?
- 可靠性预算与"不可逆程度"对齐、与"业务重要性"对齐,会得出哪两种不同设计?