Skip to content

附录 E|把"模型看见的必须能重建"做成运行时错误 ​

先别把纪律当成文档口号 ​

第 05 章讲"模型看见的,等于已经被记下的"这条纪律时,说过一句:这条纪律如果只写在文档里,循环里塞旁路是几分钟的事,所以这个项目把它做成了发出请求之前必须通过的一道检查。这里把那道检查拆开。

先给判断:"模型看见的必须能从日志重建"不是一句约定,是一条每次请求都要跑的门禁。 违规不是"以后可能出问题",是第一次请求当场失败。

这类检查的总原则很明确:运行时不变量断言的是"拥有关系"——查权威的事件流或可变数据,不查"某个服务或方法是否存在、插件元数据是否齐全、或者一个固定不变的纯粹例子"。这个项目还有一条配套纪律:每个包都拥有一份自己的不变量检查,要么真正查一条事件/数据关系,要么给一个空的安装逻辑一个明确写清楚的"这个包没有运行时不变量需要检查"的理由。

默认循环实现这份检查是全书最硬的一个,因为它守的是最贵的那件事——重建。


1. 这道检查是发出请求这个必经环节上的一道门禁 ​

先给判断:检查不发生在"组装请求"的地方(那里是各个生产者自己写的代码),发生在必经的消费点上——所以没有哪条旁路能绕开它。

这道检查完整的安装逻辑挂在真正发起流式请求这一步。它对循环造出来的每一次请求逐条验证:

  1. 确认这是循环自己发出的请求——不是循环发出的请求不管,避免误伤其它调用方。
  2. 请求必须冻住、且带着活着的会话标识——能被随意改动的请求不能当证据,没有会话就无从重建。
  3. 日志里已经有对应的 step 开始记录——没开 step 就去调模型,是一条旁路,直接判定失败。
  4. 能从日志里折叠出完整的请求信封——系统提示词、工具描述、模型路由信息都要能在日志里找到出处。
  5. 消息数组逐字节相等:这次请求携带的消息数组,序列化后必须和"从日志投影出来的消息数组"序列化结果完全一致。对不上,失败信息明确指向"日志重建失配"。
  6. 信封字段逐一核对:模型名称、系统提示词、温度、最大 token 数、停止词、工具列表这些字段,要跟从日志折叠出来的信封逐字段比对。

这就是第 05 章那句"模型看见的 = 历史投影 + 信封,两半都在日志里"的运行时版本。历史投影和信封折叠都是日志的纯函数;组装请求时用了什么,检查时就用同样的纯函数重算一遍,比一次结果。

这个闭环的另一半在构造端:真正组装请求的逻辑,从落盘的请求头信息起步,用一套规范化流程组装出信封,消息数组来自历史投影——请求只从落盘的头信息和投影构造出来,不是从某个内存旁路凑出来的。构造端只认日志,校验端再独立重算一次,两头夹住,中间没有缝隙。

如果没有这道门禁会怎样?第一次图省事的人会在组装请求时偷偷往消息数组里塞一段秘密内容。单测绿——因为单测看着同一份内存。进程一重启,或另一个插件从日志重放,秘密消失,模型行为变成玄学。门禁让这份玄学在第一次请求时就炸成一条能读懂的报错,而不是在用户投诉"怎么跟刚才不一样"时才炸。


2. "排在最外层"不是性能优化,是防止被静音 ​

先给判断:这条检查要跑在最外层,因为可拦截链里任何一个短路的监听器不委托下去,就会把后面整条链作废——包括这道门禁。

第 02 章讲过拦截链的语义:谁不委托下去,后面包括默认行为都看不见。这道检查在安装时特别注明:排在最外层,是为了防止一个短路的重放监听器把检查静音。它把自己钉在"发起流式请求"这条链的最外层,真正的流式请求本身是最内层的默认行为。

这和第 07 章"检查跑在最外层,默认行为是最内层"是同一招用在不同的拦截链上。这道检查本身也是一个拦截链上的监听器——它必须主动委托下去,才能把请求真正放行给流式请求逻辑,但它有资格在放行之前把整个请求判定失败并否决掉。

如果没有这个"最外层"的位置会怎样?一个重放插件(重放已经记录过的响应,不真的调用模型)为了避免重复花钱,可能会短路掉整条"发起请求"的链——顺带把重建检查也短路了。旁路又开了。排在最外层,让检查在任何短路发生之前先跑完。这个风险不是假设出来的——测试辅助设施里确实存在一个从不委托下去的重放监听器,专门用于重放录制好的响应而不真正调用模型;如果检查没有排在最外层,重建检查就会被它悄悄静音。这套行为已经被写成了一条防回归的测试,锁死不能再犯。


3. 每个包都有一份自己的不变量检查,这不是循环独享的特权 ​

先给判断:重建检查只是"运行时不变量"这棵树上最显眼的一颗。这条纪律本身是仓库级的——每个包都要交一份。

每个包拥有一份自己的不变量检查,注册自己的名字,查一条事件/数据关系;生成出来却没人认领的伴生文件、没写清楚理由的空检查、被忽略的检查报告,都会让专门的校验门禁失败。

这和第 02 章"注册是可逆效果"是同一种纪律的另一种形态:一个包要在运行时断言自己拥有的关系,就得像注册服务一样注册一条检查,卸载时一起撤。默认循环实现的这份检查靠向一个统一的不变量服务注册自己来挂上去,同时声明自己需要这个不变量服务作为运行时依赖。

如果没有"每包一份"这层会怎样?重建检查会变成循环实现的私有魔法,其它包想查自己的关系(比如"这类事件必须被某个 turn 包住")无处可挂。统一的不变量服务让"断言"和"服务"一样,成为可组合、可卸载的贡献——第 02 章说的一切,原样适用于"检查"本身。


4. 不变量查的是"关系",不是"存在" ​

先给判断:这是最容易读错的一条。总原则明确说:查权威事件流或可变数据,不查"服务或方法是否存在、插件元数据是否齐全、固定不变的纯粹例子"。

为什么?因为"存在"几乎永远为真,查了等于没查。某个注册表有没有这个方法?有。某个插件注册了没有?注册了。这些不变量能通过测试,但在运行时,一条旁路都拦不住。真正的"拥有关系"是:"请求里的消息数组"和"日志投影出来的消息数组"这两个可变数据必须相等——这是会变的、会被破坏的、破坏时能看出明确后果的关系。

会话能力自己的不变量就是这种关系的另一个例子:它维护一套 turn/step 的闭合状态机,强制某些核心执行事件(比如任务清单写入、请求信封记录)必须被一个已经打开的 turn 包住——在没有打开的 turn 之外追加这类事件,会直接判定失败;而对那些"允许被扩展合并进来"的事件类型,它默认不做这类判断——这类事件的归属关系判给拥有它的那个插件自己去检查。这正是"查关系不查存在"的操作版本:核心事件的关系归核心检查,扩展事件的关系归各自的插件检查,谁也不越界。

第 05 章提到的"读到就必须认识"是同一思路的另一半:不认识的事件类型会让读日志的构建流程直接拒收,除非这个事件被显式标记为"可忽略"。写侧不变量管"这次请求能不能重建",读侧不变量管"这份日志我认不认得全"——两侧都在断言一个"拥有关系",而不是一个空壳式的"存在"。

如果没有"查关系不查存在"这条纪律会怎样?仓库里会长满几百条"确认某个服务真的挂上了"的检查,全部通过、一个真问题都抓不住。真正值钱的不变量数量不多,但它们每次命中,抓到的都是第 05 章说的那种"不炸就是玄学"的问题。


5. 结语:带走一句话 ​

把纪律做成运行时不变量,靠的是三件事:查关系不查存在、挂在必经的消费点而不是生产者自己写的组装代码上、用"排在最外层"防止被短路——于是"模型看见的必须能重建"从一句说明文档,变成每次请求都会兑现的一条可读报错。