← 返回资讯

结构化输出稳定性的成本账:格式失败率每降一档能省多少

2026-08-07

半夜被叫起来看一个抽取服务的告警,日志里没有一条 5xx,接口全是 200,模型也老老实实返回了内容。问题出在下一行:json.loads 抛了个 Expecting value: line 1 column 1 (char 0)。翻出原始返回一看,模型在 JSON 前面加了一句「好的,以下是提取结果:」。

这就是结构化输出场景最典型的故障形态——它不是”报错”,是返回了一段看起来对、但机器解析不了的东西。人眼扫过去甚至觉得挺完美,字段都在、值也合理,就是外面多了两行客套话,或者裹了一层 markdown 代码围栏。

麻烦的是,这类失败你没法忽略。业务要的是结构化数据,拿不到就得重跑。重跑就要重新付一次输入的钱、重新付一次输出的钱。而这笔钱不在任何人的预算表里:做成本估算的时候,大家算的是「月请求量 × 单次 token 消耗」,没人给”返工”留一栏。

我见过不少团队,线上跑了几个月才发现自己实际调用量比业务量高出百分之十几,查了半天以为是重复提交,最后定位到是格式失败重试。在结构化输出场景里,格式失败的返工是最大的一笔隐性开销,而且它的性质和大多数人想象的不一样——它是乘性的,不是加性的。

一、格式失败的七种形态,和各自的识别特征

先把敌人认清楚。这七种是我在各类抽取、分类、打标签的活儿里反复见到的,按排查顺序排列:

形态长什么样识别特征
前后缀解释文字好的,结果如下:{...} 或末尾加「希望对你有帮助」解析在第 0 个字符就炸,首字符不是 {[
markdown 代码围栏三个反引号包住 JSON原始串以反引号开头,去掉围栏后能正常解析
字段缺失或多出少了 required 字段,或凭空多了 confidenceJSON 解析成功,schema 校验失败
类型不对数字给成 "42",布尔给成 "是",空值给成 ""解析成功、字段齐全,炸在下游类型转换
枚举值自由发挥high/medium/low,返回 较高medium-high字段在、类型对,但值不在白名单里
嵌套层级跑偏该是数组给了对象,或多包一层 {"result": {...}}解析成功但按路径取不到值,取出来是 None
截断导致不闭合输出到一半没了,缺右括号或字符串没收尾Unterminated string / Expecting ',' delimiter,且位置在串尾;返回体里的结束原因字段会指示是长度触顶(字段名各家不同,以官方文档为准)

还有两种出现频率低一点但很坑的:一是转义问题,字段值里有换行、引号或反斜杠没转义好,报错位置在串的中段而不是末尾,很容易被误判成截断;二是多个 JSON 连着吐,模型把 prompt 里的示例也一起返回了,报 Extra data: line X column Y

把这张表变成你解析层的分类逻辑,是后面所有优化的地基。因为你必须先能分类,才能知道该修哪儿——同样是解析失败,围栏问题该靠字符串处理解决,截断问题该靠调大输出上限解决,枚举跑偏该靠改 schema 解决,把它们混成一个「JSON 解析失败」计数器,你只会得到一个没法行动的数字。

关于提示写法层面怎么把这几类问题按下去,我在让大模型稳定输出 JSON里展开过;schema 设计和校验管道那一层的做法,可以看结构化输出与 schema 校验。这篇专门算钱。

二、成本机制:一次失败等于输入重付加输出重付

先说一个很多人搞错的点:格式失败的那一次调用,是照常计费的。模型确实生成了输出,token 确实产生了,只是你解析不了而已。有人以为”没拿到有效结果就不算钱”,这个直觉在 API 计费里不成立。

所以一次格式失败的代价是完整的一发:输入 token 重付一遍,输出 token 重付一遍。如果你还用了定向修正重试(把上次的错误输出喂回去),那次重试的输入还会比原始请求更长。

失败率的影响是乘性的

假设首次调用的格式失败率是 p,失败后原样重试、每次失败率仍是 p,那么拿到一个可用结果平均需要 1/(1−p) 次调用。这个式子的形状很关键:

首次格式失败率 p每个成功结果平均调用次数 1/(1−p)相对零失败的额外开销
3%1.031+3.1%
10%1.111+11.1%
15%1.176+17.6%
30%1.429+42.9%
50%2.000+100%

看最后一行:失败率 50%,成本翻倍,不是多 50%。失败率 30% 时多的是 42.9% 而不是 30%。失败率带来的额外开销是 p/(1−p),不是 p。p 小的时候两者差不多,p 一大就急剧发散——这就是”乘性”的含义,也是为什么把失败率从 30% 压到 15% 的收益,远大于从 15% 压到 3%。

一个推论:如果你的服务已经在 30% 以上的格式失败率上跑,任何模型层面的降价谈判,收益都比不上先把解析稳住。

一笔完整的假设算例

下面这笔账全部基于假设参数,不代表任何厂商的真实价格,它演示的是算术结构,你要把自己的实际数字代进去重算。

假设条件:

  • 业务上每月需要拿到 100,000 个成功的结构化结果
  • 每次调用输入 800 token、输出 400 token,合计 1,200 token
  • 记一次完整调用的综合花费为 1 个成本单位(这样不牵扯任何具体价格)
  • 失败后原样重试,重试的失败率与首次相同

场景 A:格式失败率 15%

需要的实际调用次数 = 100,000 ÷ 0.85 = 117,647 次 折合 token = 117,647 × 1,200 = 141,176,400 token 折合成本 = 117,647 个成本单位

场景 B:格式失败率 3%

需要的实际调用次数 = 100,000 ÷ 0.97 = 103,093 次 折合 token = 103,093 × 1,200 = 123,711,600 token 折合成本 = 103,093 个成本单位

差额:117,647 − 103,093 = 14,554 个成本单位,占场景 A 的 14,554 ÷ 117,647 = 12.4%。token 口径少了 17,464,800 个。

也就是说,把格式失败率从 15% 压到 3%,在这组假设下省掉约一成二的调用量。这个比例不依赖单价,所以它对你同样成立——把你自己的月成功结果数和单次成本代进去,就是你能省下的绝对金额。

顺带说一句,实际系统里重试都会有次数上限。若上限设为 3 次,p=15% 时平均调用次数是 (1−0.15³)/(1−0.15) = 1.1725,和无上限的 1.176 几乎没差别;但残余失败率从 0 变成了 0.15³ = 0.34%——这 0.34% 是三次全挂、业务上彻底拿不到结果的部分。p=30% 时这个残余是 2.7%,p=50% 时是 12.5%。所以高失败率不只是多花钱,它还会漏单,而漏单的代价通常比 token 贵得多。

三、降低失败率的手段,按性价比排序

这个顺序是按「投入产出比」排的,从上往下做,做到失败率可接受就停。

1. 用平台自带的结构化输出约束。 多数平台提供某种形式的结构化输出/JSON 模式能力,能在解码层面就把输出限制成合法 JSON,甚至按你给的 schema 约束字段。参数名和具体行为以官方文档为准,各家差别不小,有的只保证语法合法不保证 schema 匹配,有的会对 schema 的写法有限制(比如不支持某些嵌套或某些关键字)。这一条排第一,是因为它几乎不用改代码逻辑,就能把”前后缀解释文字”和”代码围栏”这两类最高频的失败直接归零。

2. 把 schema 改扁、改窄。 schema 复杂度和格式失败率是正相关的。三条具体做法:

  • 层级压平。三层以上的嵌套明显更容易跑偏。能拆成两个字段就别嵌套,能用扁平的 author_name 就别写 {"author": {"name": ...}}
  • 字段名带语义dvalf1 这种名字,模型只能靠 description 猜;改成 publish_dateunit_price_cny,模型填错的概率明显下降,而且字段名本身就是一段免费的提示。
  • 枚举少而清晰。枚举项超过七八个、或者选项之间语义重叠(比如同时有”较高”和”偏高”),模型就会开始自由发挥。选项控制在五个以内、互斥、且用英文短标识(high/medium/low),比中文长描述稳。

3. 在 prompt 里给一个完整的正例。 这一条的性价比被严重低估。写十条规则(“不要输出解释”、“不要加代码块”、“日期用 ISO 格式”……)不如直接贴一个完整的、格式正确的输出样例。模型对示例的模仿能力远强于对规则的遵守能力。注意示例要完整——包含所有字段、包括那些可能为空的字段,并且明确演示空值应该怎么表示(null 还是空数组),否则模型会自己发明一种。

4. 输出长度留足余量。 很多被归类成”JSON 不闭合”的失败,真凶就是输出被截断了。抽取任务的输出长度方差很大:一篇短文抽出三个实体,一篇长文抽出三十个,后者可能是前者的十倍长。按平均值设的输出上限,会稳定地打死那条长尾。做法是按你见过的最长样本再留 30% 到 50% 的余量,然后把结束原因字段监控起来——一旦发现有请求是因为长度触顶结束的,立刻查。

5. 解析层做容错。 这一条放在最后不是因为不重要,恰恰相反,它是唯一零边际成本的手段:本地几毫秒的字符串处理,不烧一个 token。

关键在于先容错、再重试,顺序反了就等于白扔钱:

import json, re

FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)

def tolerant_parse(text: str):
    # 1) 直接试
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    # 2) 剥代码围栏
    m = FENCE.search(text)
    if m:
        try:
            return json.loads(m.group(1))
        except json.JSONDecodeError:
            pass
    # 3) 截取第一个到最后一个花括号之间的部分
    s, e = text.find("{"), text.rfind("}")
    if s != -1 and e > s:
        try:
            return json.loads(text[s:e + 1])
        except json.JSONDecodeError:
            pass
    return None  # 到这一步才值得花钱重试

这三步能覆盖掉「前后缀文字」和「代码围栏」这两大类。算一下收益:如果这两类占首次失败的一半,那么原始失败率 15% 经容错后变成计费意义上的 7.5%,平均调用次数从 1/0.85 = 1.176 降到 1/0.925 = 1.081,额外开销从 +17.6% 直接掉到 +8.1%——只靠二十行本地代码,没多花一分钱

四、重试要有策略,而不是简单再来一遍

先说结论:定向修正重试通常比原样重试划算,但要用条件算一遍才知道

所谓定向修正,就是把上一次的输出和具体的错误信息一起喂回去:“你上次返回的内容里 status 字段的值是 较高,不在允许的枚举 high/medium/low 中,请修正后重新输出完整 JSON。”

它的代价是输入变长。沿用前面的假设参数:原始输入 800 token,加上上次输出 400 token,再加错误说明 60 token,重试的输入变成 1,260 token,比首次多了 57.5%;这次调用总共 1,260 + 400 = 1,660 token,而原样重试是 1,200 token,贵了 38.3%。

那什么时候值?关键在于一个很多人不愿承认的事实:对已经失败过一次的输入,原样重试的成功率远低于全局平均。失败往往不是随机抖动,而是这条输入本身触发了模型的某种倾向——同样的输入再问一遍,它大概率还这么答。

假设原样重试对这类”已失败输入”的成功率只有 50%,定向修正的成功率是 q,算期望 token:

  • 原样重试:1,200 ÷ 0.5 = 2,400 token
  • 定向修正:1,660 ÷ q

令两者相等,q = 1,660 ÷ 2,400 = 69.2%。也就是说,只要定向修正的成功率能超过约 69%,它就比原样重试便宜。以我的经验,带明确错误信息的修正请求,命中率通常显著高于这个线——因为你已经把问题精确指出来了,模型要做的只是改一个字段。

但有三条约束必须一起上:

次数上限必须有。 我一般给 2 到 3 次。超过 3 次还不行的,基本不会靠再试一次变好,只会持续烧钱。上限之外的请求应该走降级路径:转人工、写进待处理队列、或者退回一个部分结果,而不是无限循环。

必须区分”格式错”和”内容错”。 这是最容易出事的地方。格式错是 schema 校验能发现的:字段缺了、类型不对、枚举越界——这类重试有效,因为错误可定位、可描述。内容错是 schema 校验根本发现不了的:字段齐全、类型正确、枚举合法,但抽出来的金额是错的、日期张冠李戴、把甲公司的信息安到了乙公司头上。这种情况下重试毫无意义——模型只会换个说法给你同样的错,而你为每一次”重试”付了钱却什么都没改善。内容正确性得靠评测体系兜,这是另一条完全不同的路,可以参考大模型应用评测 Eval里的做法。

重试要退避。 如果失败原因里混了限流或者超时,紧接着重试只会加剧问题。加个指数退避,顺带也给下游一点喘息。

五、把格式失败率打成一个指标

前面所有的优化,都需要一个数字来验证有没有效。这个数字就是格式失败率,而且它值得被当成一级指标看待。

分子分母要定义清楚。 我建议同时打两个:

  • 原始格式失败率 = 首次调用返回的原始文本无法直接 json.loads 或 schema 校验不通过的比例
  • 净格式失败率 = 经过本地容错解析后仍然失败、必须重试的比例

前者反映模型和 prompt 的真实状态,后者反映你实际要为之付钱的部分。只看后者,你会以为一切安好,实际上模型可能已经悄悄退化,只是被你的容错层扛住了——容错层是有上限的,它扛得住围栏,扛不住字段缺失

打标签的维度。 至少按这四个维度分组:模型标识、prompt 版本、schema 版本、失败形态(就是第一节那张表的分类)。没有这些标签,指标涨了你只能干瞪眼;有了标签,一眼就能看出是”某个模型标识下的截断类失败激增”还是”改了 prompt 之后枚举跑偏变多了”。

它是一个非常灵敏的回归信号。 格式失败率突然上涨,原因基本逃不出这几个:

  • 模型版本变了(有些服务的版本别名会滚动指向新版本,你没改代码但底下换了)
  • prompt 被人改了(尤其是有人”顺手优化了一下措辞”,把那个完整正例删了)
  • 输入分布变了(开始处理更长的文档,触发截断)
  • schema 被扩展了(加了字段、加了嵌套、加了枚举项)

这四类里有三类是你自己团队的改动。所以把格式失败率接进发布流程,改完 prompt 或 schema 后跑一批固定样本、比对失败率再上线,比任何评审都管用。它比端到端质量指标反应快得多——质量退化要看好几天的业务数据,格式失败率跑一百条样本、几分钟就出来了。

别忘了给残余失败也打点。 重试到上限仍然失败的请求,是直接的业务损失。这个数在低失败率下会小到看着像噪声(前面算过,p=3%、三次上限,残余是 0.0027%),但它一旦抬头,说明主失败率已经涨到相当高了——它是一个非线性的警报器。

上线前的自查清单

  1. 解析层有容错,且排在重试之前——至少覆盖代码围栏剥离和首个合法 JSON 块提取,确认失败路径上”先本地修、修不了才发请求”。
  2. schema 过一遍瘦身——嵌套不超过两层、字段名带语义、每个枚举不超过五项且互斥,能拆平的都拆平。
  3. prompt 里有一个完整正例——包含全部字段、演示了空值的表示方式,并且这个正例被写进了不可随意删改的约定里。
  4. 输出长度上限按最长样本留了 30% 以上余量,并且返回体的结束原因字段已经接入监控,长度触顶会告警。
  5. 重试有次数上限(建议 2 到 3 次)和退避,超限走降级路径而不是继续循环。
  6. 格式错和内容错被分开对待——schema 校验失败才进重试队列,内容可疑的走评测和抽检,不靠重试解决。
  7. 原始失败率和净失败率都在打点,并按模型标识、prompt 版本、schema 版本、失败形态四个维度分组。
  8. 拿自己的真实数字重算一遍第二节的算例——把月成功结果数、单次 token 消耗、当前失败率代进去,得出一个具体金额。有了这个数,你才知道该在稳定性上投多少工。

相关阅读