背景:崩掉程序的往往不是答案错,而是格式
我们那个桌面工具的主流程很朴素:用户在聊天窗口里发一句自然语言,程序把它交给模型,要求返回一段 JSON,说明"用户想干什么、涉及哪些参数",然后本地代码读这段 JSON 去执行动作。模型只负责把话翻译成结构,执行、落库、权限判断全在本地做。上线 3 天,我收到的反馈不是"答得不准",而是"点了没反应"和"弹了个看不懂的报错框"。我把日志捞出来逐条看。答案内容本身的错误很少,真正让程序中断的是格式问题:返回体里混进一段解释文字、字段名换了个写法、本该是数字的位置给了字符串、输出被截断导致 JSON 少一个右括号。这些问题在模型那边算"小瑕疵",在程序这边是"直接抛异常"。举几个当天真实发生过的例子:模型在 JSON 前面加了一句"好的,我理解为:“;把amount写成"128.5元";把布尔字段写成"true"字符串;要输出一段 300 字的商品描述时,输出到一半被长度上限切断,括号没闭上。每一条都能让下游取值的代码抛异常。所以我后来把这件事拆成三层:约束输出、校验并分类、重试与降级。三层各解决一部分,合起来把"格式异常导致业务失败"压到了千分之一以下。口径先说明白,后文数字都按它算:观察窗口 21 天,共 9,473 次结构化调用;“格式异常"指返回内容无法解析或校验不通过;“格式异常导致业务失败"指异常发生且重试与降级都没救回来,用户这次操作没有完成。### 崩溃点不在模型输出,在 json.loads 那一行异常栈看起来很像"代码 bug”,其实不是。栈顶是执行动作的那一层,中间是字段取值,底下才藏着解析失败。一个典型的报错长这样:KeyError: 'action_type',但真实原因是模型把action_type写成了actionType,而解析本身没报错——那段文本依然是合法 JSON,只是键名不同。这就带来一个麻烦:解析成功不等于数据可用。我一开始只判断"能不能解析”,于是大部分问题被漏过去,等到下游取值时才炸,排查一次要 20 多分钟,因为异常位置离原因太远。后来我粗算过一笔账:那 3 天里 41 次线上异常,从收到反馈到定位到根因,平均耗时 8.6 分钟,其中大部分时间花在反推"这个字段到底是哪一步变成这样的”。如果解析后立刻校验一遍并把字段路径记下来,这 8.6 分钟可以压到 1 分钟以内。这个对比是我决定做第二层的主要原因。### 我先猜错了方向:以为是提示词问题我一开始的判断是"指令写得不够狠"。我改了 6 版提示词,加了"只输出 JSON"“不要任何解释”“不要用代码块包裹”,异常率从 11.9% 降到 11.2%。几乎没动。改到第 4 版我才意识到问题:提示词是概率性约束,它对格式的"遵守程度"跟输出长度、字段复杂度、内容里的特殊字符都相关。比如模型要在某个字段里放一段含换行的文案,它就得做一次转义决策,而这类决策在采样时是不稳定的。靠嘴说解决不了工程问题,得靠协议和代码。我还试过一个更笨的办法:把提示词里的示例从 1 个加到 4 个(few-shot 堆例子)。异常率降到 9.6%,但输入词元涨了 2.7 倍,单次成本翻番。这个思路在成本上不成立,被我否掉了。图:结构化输出的四层防线## 第 1 层:约束输出能解决多少问题第 1 层的目标很单纯:在请求侧尽力让输出天然合法。我用了两样东西,一样是协议层的能力,一样是模板。### 结构化输出参数 + 模板,我用了两样东西协议层能声明"这次返回必须是某个 JSON Schema 描述的对象",由推理服务在采样阶段就约束输出词元的合法集合,而不是返回后再让你去解析。这一层能力的效果很直接:括号配对、引号转义、字面量拼写这三类问题基本清零。这里有个细节值得说:结构化输出约束的是"语法形状",不约束"语义内容"。也就是说它保证你拿到的是合法 JSON 对象、字段名和类型都对,但字段查出来的值是对是错它管不了。我早期误以为开了这个参数就万事大吉,结果头一次跑测试就发现
intent字段返回了一个枚举里不存在的值——语法完全合法。这就是第 2 层必须存在的理由。模板这一侧我做的是收窄自由度:把字段名、枚举取值、输出顺序全部固定,不给模型自由发挥的空间。字段名不是它想出来的,是模板里写死的占位;枚举值也写死在模板里,模型只需要从给定集合里选。这样做还有一个副作用——同一批请求的输出长度方差变小了,后面要讲的截断问题也跟着减少。text字段名固定:{"intent": ..., "slots": {...}, "confidence": 0~1}枚举固定:intent ∈ {query_price, create_order, cancel_order, other}禁止:解释文字、代码块围栏、注释、多余键顺序固定:intent → slots → confidence(便于截断时优先丢掉尾部)### 约束失效的四种场景与占比约束不是万能的。我把加了约束之后仍然异常的那 407 条(占 4.3%)全部人工看过一遍并打了标签:| 失效场景 | 条数 | 占异常 | 原因 || — | — | — | — || 输出被长度上限截断 | 253 | 62.2% | 尾部字段缺失,JSON 未闭合 || 长文本字段内的换行与引号 | 86 | 21.1% | 采样时转义决策偶发失控 || 未闭合的字符串里混入控制字符 | 41 | 10.1% | 上游文本本身带不可见字符 || 其他(服务端超时返回半截) | 27 | 6.6% | 连接中断,响应不完整 |截断占了六成以上,这解释了我早期的困惑:短请求几乎不出问题,出问题的都是要输出长槽位的那几类意图。长度上限和格式合法性之间是强相关的——输出到达上限那一刻,模型没有机会"把话说完"。我后来的做法是:给输出留出 1.6 倍余量(按观察到的字段长度 P95 反推),同时在提示词里要求把可选字段排在后面。这两条一起把截断类异常压到 0.4% 以下。## 第 2 层:把 Schema 变成可执行的校验器第 2 层的思路是:不假设输出是对的,拿到手先验一遍,验不过就当成一次可处理的失败,而不是让它流到下游变成崩溃。### Schema 用普通字典描述就够了我没引入任何依赖,用普通字典描述结构,理由是这套描述要能同时被三处使用:校验器、提示词生成、文档。用字典就不用在三个地方重复维护字段清单。pythonSCHEMA = { "type": "object", "required": ["intent", "slots", "confidence"], "fields": { "intent": {"type": "str", "enum": ["query_price", "create_order", "cancel_order", "other"]}, "confidence": {"type": "float", "min": 0.0, "max": 1.0}, "slots": { "type": "object", "required": ["raw_text"], "fields": { "raw_text": {"type": "str", "min_len": 1, "max_len": 500}, "amount": {"type": "float", "min": -1e9, "max": 1e9, "optional": True}, "sku_id": {"type": "str", "pattern": "^[A-Za-z0-9_-]{4,32}$", "optional": True}, }, }, },}写 Schema 的时候我踩过一个小坑:一开始把required写成列表、把字段定义写成另一个平铺字典,结果改一个字段要在两处动。后来改成现在这样"字段定义自带 optional 标记",required只保留必填清单,两边互为补充,改字段只需动一处。### 校验器实现:类型、必填、范围、枚举校验器本身不长,难的是把它做成"分层返回":每一层错误都要能定位到字段路径,并且明确是哪一类,因为下游处理方式完全不同。pythonimport json, reclass E: SYNTAX, STRUCT, SEMANTIC = "syntax", "struct", "semantic"def parse(raw: str): """先做一次宽松裁剪,再做严格解析。返回 (obj, error)。""" text = raw.strip() if text.startswith("“): # 剥掉可能的围栏 text = re.sub(r”^```[a-zA-Z]\s", “”, text) text = re.sub(r"\s*```KaTeX parse error: Expected 'EOF', got '#' at position 147: …xt) - 8 #̲ 位置贴近末尾,基本可判定截断…“, “msg”: ex.msg, “pos”: ex.pos, “truncated”: truncated}def validate(obj, schema, path=”KaTeX parse error: Expected group after '_' at position 212: …got {type(obj)._̲_name__}"}] ….slots.sku_id", “msg”: “pattern mismatch”, “extra”: {“enum”: null, “pos”: 412, “truncated”: false}}```这个设计是被逼出来的——早期版本只返回一个字符串消息,重试请求里没法精准说明"错在哪",模型只能猜,修复率因此低了约 12 个百分点。把路径和合法取值放进重试请求之后,修复率明显上来了。这算是整件事里我很想推荐的一个小改动:别把错误降级成一句人话,保留它的结构。## 第 3 层:把校验错误回传给模型,让它自己改第 3 层是补救层。校验没过的请求,不直接判死,而是带着错误说明再问一次。这里的关键全在"怎么问"。### 把错误信息拼进重试请求我的重试请求包含四部分:原始用户输入、上一次的错误输出、结构化错误清单、以及一份收窄后的输出要求。错误清单不写人话解释,写机器可读的路径和取值,因为模型对"字段路径 + 允许值"的响应比"麻烦改一下"稳定得多。```pythondef build_repair_prompt(user_input: str, bad_output: str, errors: list) -> str: lines = [] for e in errors[:5]: # 只回传前 5 条,多了反而干扰 if e[“cls”] == “syntax”: if e.get(“truncated”): lines.append(f"- 上次输出在第 {e[‘pos’]} 字符处被截断," f"请缩短 slots 中的文本字段,只保留要点,总长控制在 200 字内") else: lines.append(f"- 第 {e[‘pos’]} 字符处 JSON 不合法:{e[‘msg’]}“) elif e[“cls”] == “struct”: lines.append(f”- 缺少必填字段 {e[‘path’]}“) else: allow = e.get(“enum”) lines.append(f”- {e[‘path’]} 取值不合法" + (f",只能是:{', '.join(allow)}" if allow else “”)) err_block = “\n”.join(lines) or “- 输出必须是合法 JSON 对象” return ( “上一次的输出不满足要求,请重新输出。\n” f"原始输入:{user_input}\n" f"上次输出(仅供对照,不要复述):{bad_output[:600]}\n" f"需要修正的问题:\n{err_block}\n" “只输出修正后的 JSON 对象,不要任何解释文字。” )```### 重试请求要带的三样东西三样东西是必需的:错误定位(哪个路径错)、正确取值域(合法选项或范围)、约束提示(长度或格式要求)。缺任何一样,修复率都会掉。我做过对照实验,把同 407 条失败样本分成三组重试:| 重试请求内容 | 修复率 | 平均耗时 || — | — | — || 只给"格式不对,请重新输出" | 48.0% | 2.1s || 加上错误定位与字段路径 | 62.9% | 2.3s || 定位 + 取值域 + 长度约束 | 76.7% | 2.4s |差距全部来自"模型不需要猜"。带完整信息的那组比只给模糊提示的那组只多花了 0.3 秒,却多修好 28.7 个百分点,这笔钱花得很值。### 两次重试的修复率数据样本是第 1、2 层之后仍然异常的 407 次请求,每次都带完整错误信息重发:| 尝试次数 | 进入本轮的失败数 | 本轮修复数 | 本轮修复率 | 累计完成率 | 单次平均耗时 || — | — | — | — | — | — || 第 1 次重试 | 407 | 312 | 76.7% | 76.7% | 2.4s || 第 2 次重试 | 95 | 61 | 64.2% | 91.6% | 3.1s || 第 3 次重试 | 34 | 11 | 32.4% | 94.3% | 4.8s || 第 4 次重试 | 23 | 3 | 13.0% | 95.0% | 6.2s |### 为什么第三次开始不划算看两列就够了:修复率从 64.2% 掉到 32.4%,而单次耗时从 3.1 秒涨到 4.8 秒。原因不难解释——需要第 3 次才能修好的,基本都是"输入本身就不支持这个输出结构"的情况,上下文里堆了两轮错误信息之后,模型更容易被前文带偏,甚至开始复述错误。成本侧也算过账:每多一轮重试,请求上下文平均多 1.6 倍输入词元,第 3 轮之后的单位修复成本是第 1 轮的 5.3 倍。所以我把上限卡在 2 次,并给整条重试链加了时间预算:单条消息从首次请求到末次重试,总耗时不超过 12 秒,超预算就直接进降级。预算用配置写死,不放在业务代码里,这样改的时候只动一个数字。## 降级:修不好也要给出确定的结果前两层加上重试,异常率已经很低,但不是零。降级层的作用是把"低概率的异常"变成"确定的、可解释的结果"。### 四级降级路径按成本从低到高,我准备了四条路,依次尝试:1. 宽松解析:正则从文本里抠关键字与数字,只要能定位意图就放行,标记confidence=0.3。2. 模板兜底:命中本地意图规则表(关键词到意图的映射,共 47 条)时直接按模板组装结构,跳过模型。3. 默认值:填默认结构,intent="other",把原文放进slots.raw_text。4. 显式交回用户:以上都失败时不让程序静默继续,而是回一句"这句我没理解,能换个说法吗",并把原始输入落盘用于复盘。```pythonDEGRADE = [ (“loose_parse”, 0.30), # 宽松解析,置信度上限 0.30 (“local_rule”, 0.45), # 本地规则表命中 (“default_fill”, 0.10), # 默认结构 (“ask_again”, 0.00), # 交回用户,不猜]def degrade(user_input, metrics): for name, conf in DEGRADE: obj = _try(name, user_input) if obj is not None: obj[“confidence”] = conf metrics.inc(f"fallback.{name}“) return obj return None```第四级是我犹豫过的:直接回一句"没理解"用户体验并不好。但这比静默给一个错误结果强——错误结果在后面某一环才会暴露,而"没理解"当场就能让用户补一句话。### 降级必须可观测:三个计数器降级危险的地方不是它存在,而是"降级率悄悄上涨”。用户看到的是"能用了",但因为降级结果往往是低置信度的,业务质量在无声地退化。所以我盯三个计数器,每天看一次曲线:```pythonimport timeclass Metrics: definit(self): self.c = {} # 计数器 self.t = {} # 耗时样本 def inc(self, key, n=1): self.c[key] = self.c.get(key, 0) + n def obs(self, key, ms): s = self.t.setdefault(key, [0, 0]) s[0] += ms s[1] += 1 def report(self): total = self.c.get(“call.total”, 1) return { “format_error_rate”: self.c.get(“parse.fail”, 0) / total, “retry_rate”: self.c.get(“retry.total”, 0) / total, “retry_success_rate”: self.c.get(“retry.ok”, 0) / max(1, self.c.get(“retry.total”, 1)), “fallback_rate”: self.c.get(“fallback.total”, 0) / total, “fallback_by_level”: {k: v for k, v in self.c.items() if k.startswith(“fallback.”)}, “deadline_exceeded”: self.c.get(“deadline.exceed”, 0), “deadline_first”: self.c.get(“deadline.first”, 0), }```三条告警线:降级率连续 3 小时高于 0.2%、重试成功率低于 60%、时间预算超限次数单小时超过 5 次。这三条都在"格式异常率"还很好看的时候就能预警,比等用户来反馈早了几个小时。## 上线前后对照:口径、数字与我的两个误判### 端到端对照表| 阶段 | 结构化调用 | 格式异常 | 异常率 | 端到端失败 | 失败率 | P95 耗时 || — | — | — | — | — | — | — || 裸调用(只有提示词) | 9,473 | 1,106 | 11.68% | 1,106 | 11.68% | 1.9s || 加约束输出 | 9,473 | 407 | 4.30% | 407 | 4.30% | 1.9s || 加校验 + 1 次重试 | 9,473 | 407 | 4.30% | 95 | 1.00% | 2.5s || 加第 2 次重试 | 9,473 | 407 | 4.30% | 34 | 0.36% | 2.8s || 加降级 | 9,473 | 407 | 4.30% | 8 | 0.08% | 2.8s |口径说明:格式异常率是"解析或校验未通过的次数 / 结构化调用总数",它不随重试下降,重试是另算的调用;端到端失败率是"三层全走完仍未拿到可用结构的次数 / 结构化调用总数",这才是用户能感知的指标。两个指标必须分开看,我早期只看前者,得出过"异常率 4.3%,还很差"的错误结论。反直觉的结论是:重试让总调用量涨了 4.6%,但 P95 耗时只涨了 0.9 秒(从 1.9 到 2.8),因为重试只发生在 4.3% 的请求上,对分位数几乎没影响。真正拉高延迟的不是重试本身,是并发被重试请求挤占的那部分——这条在下面踩坑 1 里细说。## 踩坑 1:重试写成死循环,一天烧掉三成额度### 事故经过早期版本的重试循环是这么写的:while True: 调用;如果校验通过就 break。我当时觉得"反正模型总能修好",没设上限。某天上游推理服务出现一次持续 40 分钟的性能抖动,返回内容时好时坏。有一条请求进了循环:失败 → 重发 → 失败 → 重发。日志里这条请求连续重试了 137 次,中间没有任何停顿。后果是两个:当天调用额度被这类请求吃掉 31%;因为循环是在持锁的情况下跑的,处理队列卡住,堆积到 2,400 条待处理消息。问题在监控上还不可见——单条请求一直在"进行中",既没有报错也没有超时,直到有人发现回复变慢才被注意到。### 三处修复修复 1:硬上限。重试次数上限 2 次,写死在配置里,不允许调用方覆盖。```pythonMAX_RETRY = 2DEADLINE_MS = 12_000def call_with_retry(client, user_input, schema, metrics): started = time.monotonic() prompt = user_input last_err = None for attempt in range(MAX_RETRY + 1): if (time.monotonic() - started) * 1000 > DEADLINE_MS: metrics.inc(“deadline.exceed”) return None, {“cls”: “deadline”, “path”: “KaTeX parse error: Expected 'EOF', got '}' at position 28: …udget exceeded"}̲ raw = c…”): “”“只对声明为数值的字段做字符串到数字的转换,其余一律不动。”“” if schema.get(“type”) == “float” and isinstance(obj, str): try: return float(obj.replace(“,”, “”)), [] except ValueError: return obj, [{“cls”: “semantic”, “path”: path, “msg”: “not a number”}] if isinstance(obj, dict): errs = [] for k, sub in schema.get(“fields”, {}).items(): if k in obj: obj[k], e = coerce_numbers(obj[k], sub, f"{path}.{k}“) errs.extend(e) return obj, errs return obj, []```分界线我定得很死:展示类字段宽容,取值类字段严格。文案、摘要、原文这类字段允许拼写变体和多余空白;金额、ID、枚举、时间戳走严格校验,宁可重试也不用错值——一个被误判的金额比一次失败重试的代价高得多。归一化上线后,结构错从 34% 降回 9%,重试率回到 4.6%,端到端失败率 0.11%。## 复盘:三层防线买的是"可预期”### 三层各自的价值第 1 层(约束)降低问题总量,它把异常率从 11.68% 压到 4.30%,是投入产出比很高的一层,因为它几乎不花钱:同样的调用,只是把要求写清楚一点。第 2 层(校验)把"不可见的错误"变成"可见的失败"。它的直接收益是失败率下降,但更重要的收益是——从这一层开始,系统里每一次格式问题都会被记录、被分类、被计数。之前那些"用户说点了没反应但日志里什么都没有"的问题,从这时起消失了。第 3 层(重试与降级)买的是尾部行为。它处理的是剩下的 4.3%,把端到端失败率从 4.30% 压到 0.08%。这一层的成本比前两层加起来还高一倍,收益在数字上看着不大,但它是"今晚能不能睡着"的分界线:上游抖动、长度超限、输入乱码这些不可控因素发生时,系统有确定的行为,而不是随机崩。### 如果只保留一层,我留校验如果要砍到只有一层,我留第 2 层。理由是它把一个概率问题变成了确定性判断:模型可以不听指令,但校验器不会漏掉任何一个不合格的输出。约束能减少问题,重试能补救问题,只有校验能让问题"被看见"。这也是我做完这件事之后对"稳定"的理解变化:稳定的意思不是不出错,而是坏情况下的行为可预测。知道那 4.3% 的请求会走重试、0.36% 会走降级、0.08% 会明确回退给用户,并且这四类路径都有日志和计数器,这件事本身比把失败率再降 0.05 个百分点更重要。## 相关实现上面这套三层防线不是设计文档里的推演,它来自一台 Windows 电脑上运行的桌面工具。这个工具挂在一款个人聊天软件上做自动应答:收到消息后先用模型把自然语言翻成结构,再按结构去查数据、拼回复,前面讲的约束、校验、重试、降级就在这条链路上跑。工具是单人维护的,没有值班同学,所以链路里所有不确定的地方都被换成了确定的分支:能重试的带错误重试,修不好的按模板或默认值兜底,兜底也失败就明确回一句"没理解"。日均调用量不大,但格式异常这条线上的每一次失败都能在本地计数器里找到痕迹。想了解这套东西还做了什么,可以从下面这个入口进去看看。dingdang.asia/microai/