确定性兜底:让 AI 产对象,让脚本装契约
LLM 天然不确定,交付物必须确定——解法不在模型层,在架构层:模型只产带来源锚点的语义对象,终态交付物由确定性脚本装配、契约校验把关。
让模型更可靠是个伪命题
团队接手 LLM 项目时的标准动作是:挑更强的模型、写更详细的 prompt、加几轮自我反思。三个月后,线上照样每周出格式事故和内容事故,复盘结论永远是那句——「模型这次没发挥好」。
问题出在这条路本身。LLM 的本质是一个采样器:同一个输入跑两次,都可能产出不同的合法输出。这不是工程债,是机制决定的。你可以把温度调到 0、把 prompt 写到五千字,也只能降低方差,消灭不了方差。所以「让模型变得可靠」是一个没有终点的目标——你在试图修好一个设计上就不确定的部件。
这一章的反直觉观点就一句话:
可靠性问题的解法不在模型层,在架构层。你要做的不是修好模型,而是设计一个「模型再怎么波动,交付物都不会坏」的系统。
把不确定的部件关进确定性的框架,是工程史上反复上演的老戏码:物理信道有噪声,于是有了纠错码;网络会丢包乱序,于是有了 TCP;磁盘会写到一半断电,于是有了日志和原子写。LLM 会乱说话,于是需要属于它的那一层——这一章把它讲透:AI 产对象,脚本装契约。
核心范式:AI 产对象,脚本装契约
一句话定义:把 LLM 从「直接产交付物」降级为「产中间语义层」。
最贴切的类比是编译器。没有人让编译器前端直接吐机器码:前端负责理解源码、产出 AST(抽象语法树),后端负责把 AST 变成目标代码。前端的活是「理解」,允许试错;后端的活是「生成」,必须逐位正确。映射到 AI 系统:
- 模型是前端:读需求上下文,产出语义对象——它只需要「理解了什么」,不负责「交付长什么样」
- 脚本是后端:装配逻辑把语义对象变成终态交付物,契约校验脚本把关放行与否
语义对象怎么设计?实践沉淀下来的结构是两层:
type FactKind = "endpoint" | "field" | "constraint" | "dependency";
interface Fact {
kind: FactKind; // 封闭枚举:模型只能从中选择,不许发明新 kind
key: string;
value: string;
source: { docId: string; span: [number, number] }; // 必须锚到原文位置
}
interface Finding {
category: string; // 开放类别:允许出现新发现类型
statement: string;
evidenceFactIds: string[]; // 必须挂在已有 facts 上,不许悬空
}
interface SemanticBundle {
facts: Fact[];
findings: Finding[];
}
三个设计决策每一个都有讲究:
- facts 的 kind 是封闭枚举。封闭枚举意味着模型的「发挥空间」被压缩到选择而非创造——它可以说「这里有一个 endpoint」,但不能发明一个你没定义过的对象类型。封闭的部分进类型系统,编译期就拦住一大类错误
- findings 保持开放。完全封闭会把模型的洞察能力也阉割掉,开放的 findings 承接那些「你没预料到但值得记录」的发现——但它必须引用 facts 作为证据,防止自由发挥变成自由编造
- 每条记录带 source 锚点。锚点指向输入文档的具体区间,校验脚本可以回放验证:这个 span 里真的有这段文字吗?没有锚点的事实视为编造,在门禁处直接拒收
装配脚本拿到的就是这样一个 SemanticBundle:它做权威排序、按模板渲染、产出交付物。契约校验脚本最后把关:schema 合法吗?每条 fact 的锚点回放得通吗?每条 finding 的证据链闭合吗?全过,放行;任何一条不过,整个批次拒收。
为什么脚本兜底比端到端可靠
端到端方案(模型直接产交付物)和语义层方案(模型产对象、脚本装交付物)的区别,不是「谁写得快」,而是三个决定性的工程性质。
可证伪。 校验脚本挂了就是挂了——CI 变红,门禁拒收,错误有一个明确的、二值的信号。端到端方案的失败是灰色的:「这次产出好像不太对?」不对到什么程度算不对?谁来判断?没有判断标准的错误无法进入任何工程流程。可证伪性是一切质量工程的前提——波普尔说科学的标准是可证伪,工程质量的标准也是。
可 diff。 中间语义层进 git。模型行为的变化、prompt 的调整、上游文档的更新,全部体现为语义对象的一行一行 diff,可以 review、可以 bisect、可以回滚。端到端方案里 prompt 改一个字,产出面目全非,你根本无法审查「这次变化到底改了什么」。没有 diff 就没有评审,没有评审就没有质量。
可追责。 出错时知道是哪一环:fact 内容错了——模型理解问题,改 prompt 或换模型;装配逻辑错了——脚本 bug,写测试修掉;校验漏放了——契约缺口,补校验规则。每一环的错误都有明确的归属和对应的修复动作。而端到端方案的错误归因只有一句话:「模型今天状态不好。」这不是一个可修的 bug,是一种无法行动的情绪。
用数字说话。假设模型产单条 fact 的准确率是 97%(这已经是很乐观的估计),一批 100 条 fact 的错误期望就是 3 条。没有语义层时,这 3 条直接混进交付物,由用户发现。有语义层和锚点校验时,编造的 fact 在门禁处被拦下——错误不进交付物,只进重试队列。97% 的部件造出接近 100% 的系统,靠的不是部件变强,而是架构把残缺的 3% 挡在了边界上。
字段级权威排序
多来源冲突是常态不是异常:PRD 写这个字段是 A,用户需求文档写是 B,模型的「常识」觉得是 C。谁来裁决?
答案是写死在装配逻辑里的优先级表,例如:PRD > 用户需求文档 > 模型常识。
const AUTHORITY: SourceKind[] = ["prd", "userReq", "modelPrior"];
function resolve(key: string, candidates: Fact[]): Fact | undefined {
const bySource = new Map(candidates.map((f) => [f.source.kind, f]));
for (const kind of AUTHORITY) {
const hit = bySource.get(kind);
if (hit) return hit; // 高权威命中,低权威直接丢弃
}
return undefined; // 宁缺毋滥:没有任何来源支撑的字段不产出
}
关键不是这个顺序本身——每个团队可以有自己的排序——而是裁决权不在模型手里。把排序写进 prompt(「请以 PRD 为准」)只是建议,模型会自由发挥:同一个冲突,上午判给 PRD,下午判给用户文档,你无法 diff 它的判决理由,因为它根本没有可审查的理由。写进代码的排序才是法律:可测试、可 review、改一次全量生效。
| 裁决方式 | 可复现 | 可 diff | 修改成本 | 失败模式 |
|---|---|---|---|---|
| 写进 prompt 让模型裁决 | 否,逐次波动 | 否 | 看似低,实则每次回归都要重验 | 同一冲突判决不一致 |
| 写死进装配代码 | 是 | 是 | 一次改动全量生效 | 排序本身定错——但这是可讨论、可修正的 |
一个附带收益:装配日志里的每一次「高权威覆盖低权威」都值得记录。冲突频发的地方,往往是上游文档自身矛盾的最早信号——你的装配层顺便成了文档质量的探针。
流式场景:结构化输出与逐 token 渲染的矛盾
上面的方案在批处理场景顺理成章,但一到流式场景就撞上一个结构性矛盾:用户要看到内容一个个蹦出来(体验要求),而 JSON 要到最后一个括号才合法(正确性要求)。等完整 JSON 再渲染,首字延迟难以接受;逐 token 渲染原始 JSON,用户看到的是一堆括号和转义符。
工程上的解法是自定义文本协议。例如 @@字段名: 前缀协议——每个字段独立成行,模型顺序输出,前端按行增量解析,不需要等整个结构闭合:
@@标题: 支付超时重试机制
@@摘要: 超时的扣款请求必须通过幂等键重试……
@@正文: ## 问题背景
线上 P99……
现实比协议脏得多。各家模型的输出变体极多:全角冒号、markdown 围栏、截断标识、多重花括号、行首多出的空格……所以协议解析器前面必须站一个格式修复器做归一化:
const REPAIR_RULES: Array<[RegExp, string]> = [
[/:/g, ":"], // 全角冒号归一
[/^```[a-z]*$/gm, ""], // 剥掉 markdown 围栏行
[/\{\{+/g, "{"],
[/\}\}+/g, "}"], // 多重花括号收敛
[/<truncated>$|…$/i, ""], // 截断标识清理
];
function normalize(raw: string): string {
let s = raw;
for (const [re, to] of REPAIR_RULES) s = s.replace(re, to);
return s;
}
增量解析的纪律是只提交完整的一行,未闭合的尾巴留在缓冲区:
function drainBuffer(buf: string) {
const lines = buf.split("\n");
const rest = lines.pop() ?? ""; // 最后一行可能没写完,留在缓冲
const fields = lines
.filter((l) => l.startsWith("@@"))
.map((l) => {
const i = l.indexOf(":");
return [l.slice(2, i).trim(), l.slice(i + 1).trim()] as const;
});
return { fields, rest };
}
同理,字段值内部的渲染也要过一道渲染缓冲器:markdown 语法来了一半(** 只到了一个星号、表格行还没画完)时先不渲染,等闭合了再放出——否则用户会看到格式疯狂闪烁,体验比延迟更糟。
最后一条纪律,也是最容易被违反的一条:修复器不是无底洞。每加一条修复规则,必须同时回头修源头——改 prompt、换协议、或者升级约束解码。只加不修的修复器,半年后比协议本身还复杂,成为没人敢动的祖传代码(它的命运和规则库一样,后面会讲)。修复器的终极目标,是让自己无事可做。
门禁思维:落库前的最后一道防线
语义对象装配完、要导入平台或落库时,还有最后一道防线:门禁校验。
一个真实反复上演的反例:后端接口对缺字段的请求只打一条 warn 日志,然后照常落库。数据是进去了,但下游消费到的是空壳——列表页渲染出一片空白,排查半天才发现源头是三个月前一批缺字段的脏数据。后端宽容 + 前端无校验 = 空壳数据合法化。
门禁的两条设计原则:
校验要 O(1) 全量,而不是 O(n) 抽检。 「抽 10% 的数据看看」的信任模型,意味着漏掉的 90% 就是事故池。门禁的逻辑是不信任:每条数据都过同一个固定成本的全量校验——schema 校验、锚点回放、引用闭合,单次毫秒级。校验代码必须 cheap 到没有理由不全量跑;如果你的校验贵到只能抽检,该优化的是校验,不是放弃全量。
失败的动作是拒收,不是标记。 「标记一下然后放行」的命运是可以预测的:标记越积越多,没人看,最后连标记本身都被忽略,门禁退化成一道装饰品。拒收 + 明确的结构化报错(哪条数据、哪个字段、违反哪条契约),让失败在发生的那一刻就变得可行动。
确定性操作不给模型留入口
有一类操作天然应该由脚本独占,并且要在系统层面明文禁止模型手工做——批量替换、事务写入、批量删除。原因用复杂度就能说清:
- 模型手工做批量替换,是 O(n) 次出错机会——每一处替换点都可能改错、漏改、多改,且每个错误长得都不一样
- 脚本做批量替换,出错机会压缩为一次,且替换前后可以 O(1) 校验:diff 行数对不对、语法解析过不过、不相关的行有没有动
多文件一致性写入(本地事务)的严谨做法是三件套:
- 文件锁:防止两个进程并发写同一批文件,锁不住就不开始
- 原子写:先写临时文件,写完 flush,再 rename。rename 在 POSIX 上是原子的——读者要么看到旧版本,要么看到新版本,永远看不到半个文件
- 内容指纹漂移检测:事务开始时记录每个目标文件的 hash,提交前先校验 hash 没变(期间没有外部修改),变了就整个事务中止
async function atomicWrite(path: string, content: string) {
const tmp = `${path}.tmp.${process.pid}`;
await fs.writeFile(tmp, content);
await fs.rename(tmp, path); // POSIX 原子:读者只见旧版或新版
}
async function commitTxn(ops: WriteOp[]) {
for (const op of ops) {
if ((await fingerprint(op.path)) !== op.baseHash) {
throw new Error(`漂移:${op.path} 在事务期间被外部修改,中止`);
}
}
for (const op of ops) await atomicWrite(op.path, op.content);
}
这套东西没有一行涉及 AI——它是数据库和文件系统领域几十年的成熟经验。这恰恰是本章的核心论点:确定性从来不缺解决方案,缺的是「不让不确定性碰到它」的架构边界。
规则还是模型:一个被低估的决策
语义层的「装配」和「校验」环节还有个更根本的决策:这段逻辑用规则写,还是用模型做?
对于小数据量、领域固定的抽取和校验任务,40 条带置信度的规则常常强过一个微调模型:零延迟、零推理成本、行为完全可解释、badcase 当天修当天上线。Google 的《Rules of Machine Learning》第一条就是「不要急着上机器学习」——在你能用启发式解决问题的时候,启发式就是更好的工程。
判断口诀:能把「正确答案长什么样」写成断言的任务,先写规则;写不出断言、只能「看了才知道对不对」的任务,才交给模型。
| 维度 | 规则 | 微调模型 |
|---|---|---|
| 领域稳定性 | 固定领域首选 | 领域漂移快时更划算 |
| 数据量 | 样本少也能写 | 需要足够标注数据 |
| 可解释性 | 完全可解释,每条命中可查 | 黑盒 |
| 误判修复 | 当天改当天上 | 等下一轮训练 |
| 维护成本 | 随规则数超线性增长 | 重训成本为主,边际稳定 |
但规则有一个必须正视的命运:规则会腐烂。补丁叠补丁而不治理,三年后这套规则库没人敢动——每条规则都可能在某个角落救过某个 badcase,删任何一条都怕触发未知回归,最后只能整体重写。规则库需要的治理和代码一样:明确的 owner、每条规则的命中率监控(命中率归零的规则就是尸体,应该火化)、定期的合并清理。没有 owner 的规则库,不如一开始就用模型。
争议:Structured Outputs 普及后,这套还需要吗
随着约束解码(Structured Outputs / JSON Schema 约束)成为各家模型的标配,一个真实的质疑出现了:模型输出本来就保证是合法 JSON 了,「产对象、装契约」这套架构是不是过度设计?
正方:还需要,而且更需要了。
- Schema 保证形状,不保证语义。字段类型对不等于值是对的;权威排序、来源锚点、跨字段一致性、引用闭合,这些都不在 schema 的表达能力之内
- 装配逻辑是业务逻辑的载体,与输出格式无关——就算模型 100% 输出合法 JSON,「这个字段该取哪个来源」依然要有人裁决,裁决者依然必须是代码
- 中间层可 diff、可追责的价值,不依赖模型输出有多规整。约束解码消灭的是「形状问题」,反而让剩下的语义问题暴露得更清晰——而语义问题只能用确定性逻辑治理
反方:部分组件确实在快速贬值。
- 格式修复器这类「给格式擦屁股」的组件,随着约束解码普及,全角括号、markdown 围栏这类变体会越来越少,修复规则一条条变成死代码。这是好事——修复器的终极目标是让自己无事可做
- 简单场景(单模型、单轮、交付物本身就是 JSON)直接 Structured Outputs + schema 校验就够了,不必搬整套语义对象架构。架构复杂度应该跟着风险走,不跟着信仰走
两边的共识其实是:schema 把形状问题消灭了,于是剩下的全是语义问题——这恰恰是「AI 产对象、脚本装契约」要解决的问题域。
常见误区
- 让模型直接产最终交付物:每次生成都是一次无法复现的发布。中间语义层把「创作的随机性」和「交付的确定性」隔离在两层
- 校验也交给模型:用不确定性检查不确定性。模型校验器今天放行明天拦截,没有可 diff 的判断标准。校验必须是代码——代码才能被 review、被测试、被 blame
- 格式修复器写成无底洞:只加规则不修源头,半年后修复器比协议本身还复杂。每加一条规则,同步修 prompt 或升级约束解码
- 抽检代替门禁:「抽 10% 看看」的信任模型,漏掉的 90% 就是事故池。校验要么全量,要么等于没有
- 把权威排序写进 prompt:prompt 里的优先级是建议,模型会自由发挥;装配代码里的优先级才是法律
- 规则库没有 owner:没有 owner 和清理机制的规则库,三年后没人敢动,只能整体重写
- 让模型手工做批量操作:O(n) 次出错机会换不来任何收益,确定性操作必须在系统层面不给模型留入口
术语表
| 名词 | 定义 | 一句话直觉 | 常见混淆 |
|---|---|---|---|
| 中间语义层 | 模型产出的结构化对象,介于输入和交付物之间 | 编译器的 AST | 不是「中间格式的交付物」,是不面向最终消费者的内部表示 |
| 封闭枚举 kind | fact 类型只能从预定义集合中选择 | 选择题而非作文题 | 封闭管事实分类,开放(findings)管洞察发现,两者分工 |
| source 锚点 | 每条记录指向输入原文的位置 | 每句话带脚注 | 没有锚点的事实视为编造,不是「可信度较低」 |
| 装配脚本 | 把语义对象变成终态交付物的确定性代码 | 编译器后端 | 不含任何模型调用,纯函数 |
| 契约校验 | 放行前的全量校验:schema、锚点回放、引用闭合 | 海关,不是抽检 | 失败动作是拒收,不是标记放行 |
| 字段级权威排序 | 多来源冲突时写死在代码里的优先级 | PRD 是宪法 | 写进 prompt 的排序只是建议 |
| 约束解码 | 解码阶段限制模型只输出合法结构 | 形状保险 | 只保证形状合法,不保证内容正确 |
| 格式修复器 | 归一化模型输出变体的规则集 | 翻译腔矫正器 | 不是无底洞:加规则必须同步修源头 |
| 渲染缓冲器 | 未闭合语法暂不渲染的流式缓冲 | 攒够一句再放 | 缓冲的是「完整性」,不是「延迟」 |
| 门禁 | 落库前的 O(1) 全量校验防线 | 每个包裹都过安检 | 抽检的信任模型不适用 |
| 原子写 | 临时文件 + rename 的写入方式 | 换牌而不是涂改 | rename 原子性依赖文件系统,跨设备不成立 |
| 漂移检测 | 事务提交前校验目标文件指纹未变 | 动笔前确认纸没被换 | 防的是外部并发修改,不是自己写错 |
| 规则腐烂 | 规则库补丁叠补丁失去治理能力 | 祖传代码的规则版 | 对策是 owner + 命中率监控 + 定期火化,不是「写更好的规则」 |
参考材料
- OpenAI Structured Outputs 文档 — 约束解码的实践入口,注意其「形状合法 ≠ 内容正确」的边界
- JSON Schema 规范 — 契约校验的事实标准
- Instructor — 「校验即交互」封装库,语义对象管道的参考实现
- JSONSchemaBench — 约束解码效果的基准评测,schema 约束对模型能力的真实影响
- Rules of Machine Learning — Google 的工程规则集,「规则优先于模型」的权威出处
- Crafting Interpreters — 前端/后端分层的编译器架构,本章核心类比的技术背景
- Files are hard — Dan Luu 谈文件写入的一致性问题,原子写必要性的经典论证
- rename(2) - Linux man page — 原子替换的系统调用语义
小结
四句话总结这一章:
- 别修模型,修架构——LLM 的不确定是机制属性,「让模型可靠」没有终点;把不确定部件关进确定性框架才是工程解法
- AI 产对象,脚本装契约——模型只产带 source 锚点的语义对象(封闭枚举 facts + 开放 findings),交付物由装配脚本生成、契约校验把关,换来可证伪、可 diff、可追责
- 裁决权归代码——字段级权威排序写死在装配逻辑里,确定性操作(批量替换、事务写入)不给模型留入口,门禁校验全量且拒收制
- 规则先于模型,但规则需要治理——能写成断言的任务先写规则;规则库要有 owner、命中率监控和定期火化,否则三年后没人敢动
让模型做它擅长的理解,让脚本做它擅长的确定——不确定性被关在边界之内,交付物才能立在边界之外。