Command Palette

Search for a command to run...

0GitHub stars
Blog

合法 JSON 只是第一层

传输成功、JSON 能解析、字段符合 Schema、值说的是真的 —— 这是四件不同的事。分清它们,才会知道 Repair 和传输重试为什么不能混用,以及校验失败时最坏的选择是什么。

让模型按格式返回数据,很容易产生一种「已经结构化了」的错觉。请求返回 200,JSON.parse 没抛异常,字段名也都对 —— 看起来可以放心往业务里用了。

但只要把这类输出真的接进下游,很快会撞上另一类问题:格式完全正确,内容不能用。

原因是「结构化」这个词把好几件事混成了一件。拆开看,从模型输出到可用的数据,中间至少隔着四层。

四层,不是一个东西

第一层,传输成功。 请求没超时、没被限流、返回了 200。这一层失败的原因和模型无关,是网络和供应商的事。

第二层,JSON 能解析。 语法正确,括号配平,不是一个被截断的字符串。JSON.parse 这一关过了,说明它是 JSON,仅此而已。

第三层,字段符合领域 Schema。 类型对、必填项在、枚举值在允许集合里、该是数组的地方不是对象。这一层管的是契约 —— 你声明了要什么形状,它给你的是不是那个形状。

第四层,值说的是真的。 字段叫 company,里面填的公司确实存在;startDate 是个合法日期,也确实是入职日期。这一层管的是内容,而代码到这一层就管不了了。

为什么要把这四层分开?因为它们各自需要完全不同的处理手段。传输层的问题要重发请求,契约层的问题要校验和修复,内容层的问题要靠上一篇文章讲的那种证据绑定 —— 用错工具,就是在错误的地方使劲。

而且这四层失败的表现完全不同:第一层会报错,第二层会抛异常,第三层最危险 —— 它常常不报错,只是静静地少一个字段,或者给一个你没预料到的枚举值。 代码以为拿到了完整数据,一路跑到渲染才崩,或者更糟:没崩,产出了一份缺东西的简历。

三种具体的失败形态

契约层的失败通常长这样:

必填字段缺失。 模型觉得这个信息不重要就省略了。JSON 合法,Schema 不合法。

枚举取了非法值。 你要求 level 只能是三个值之一,它给了第四个看起来更贴切的词。

多包了一层。 你要的是一个对象,它返回 { "data": { ... } }。语法没错,形状错了,而且错得很「合理」—— 很多 API 确实长这样。

这三种在测试里是三个不同的用例,失败位置也不一样。分开覆盖它们,比笼统地测「格式错的时候怎么办」有用得多。

Repair 不是重试

这是整篇里最值得单独记的一点:

传输重试是重发同一个请求。 同一个输入,再来一次。它解决的是网络抖动、供应商抽风、超时这类问题 —— 模型没问题,是链路有问题。

Repair 是把校验失败的信息放进一个新请求。 输入变了:多了「你刚才给的输出违反了这些约束」的反馈。模型看着错误改一次。

两者的前提完全不同。传输失败时请求内容是对的,所以原样重发;校验失败时请求内容不够,所以必须带上新信息。对校验失败做传输重试,等于让模型在没有任何新信息的情况下重新猜一次 —— 它可能碰对,但那不是修复,是抽奖。

实际实现的顺序是这样的:先直接按 Schema 校验;不过,再尝试几个已知的包装层和保守归一化(比如外面多包一层这种可识别的形状);再不过,才进入 Repair;Repair 之后仍不过,就走稳定失败。

为什么要给 Repair 设上限

默认最多一次。

无上限的 Repair 表面上很稳健 —— 一直试到对为止嘛。但代价是延迟不可控(每一轮都是一次完整的模型调用)、成本不可控,而且更根本的问题:如果一个输出需要修五次才能通过,说明这个任务的约束和模型的默认行为之间有系统性偏差,多修几次只是把这个问题掩盖过去。

一次上限的含义是:允许修一次拼写级别的偏差,但不接受需要反复纠正的输出。前者是噪声,后者是设计问题。

两次都错,最坏的选择是放行

那 Repair 之后还是错,怎么办?

最坏的做法是把错数据放行 —— 要么把不合法的字段默默丢掉,要么给个默认值填上,要么放宽校验让它过。这么做的诱惑在于「至少流程没断」,但它把失败从一个已知的、带错误码的位置,推迟到了一个未知的、下游的位置。等它在下游爆出来,你已经失去了「错误发生在哪一层」这个信息。

正确的做法是返回一个稳定、可诊断的失败:错误码固定、能指出违反了哪条约束、能被上层捕获并决定降级。宁可这次任务失败,也不要让一份形状不对的数据流进产物。

这里的关键是「稳定」两个字。同一个失败原因,任何时候都以同样的方式失败 —— 这样它才能被断言、被监控、被处理,而不是变成一个偶发的、需要人工翻日志的怪现象。

一个反模式:为了让测试变绿而放宽 Schema

最后说一个很容易发生的滑坡。

校验失败的时候,改 Schema 让它通过,比查清楚模型为什么不合规要快得多。必填改选填、枚举加一个值、类型从严格改成宽松 —— 测试立刻绿了。

但校验层报错不是障碍,是信号:它说明你声明的契约和实际拿到的数据之间有差距。差距的来源可能是 prompt 写得不够明确、可能是归一化没覆盖某种包装、也可能是重试策略不够。这几种可能都不指向「契约写错了」。

把闸门开大,等于把一个会在入口拦住的错误,变成了一个会在下游出现的问题。测试绿了,故障只是换了地方。

判断标准很简单:放宽约束之后,那个失败的输入现在能通过校验了 —— 它带来的数据是你可以接受的吗? 如果答案是不能,那你要修的不是 Schema。

边界在哪

整套东西的目标可以一句话概括:把不可靠的模型输出,收敛成可靠的类型。

「收敛」这个词是准确的 —— 它不承诺模型不出错,它承诺的是模型的错误会在一个确定的位置被拦下来,并且以确定的方式报告。第四层(值是否真实)不在这套机制的保证范围里,那要靠证据绑定去解决;这两层防线管的是不同的失败,不能互相替代。

Command Palette

Search for a command to run...