05|模型看见的必须能从日志重建:会话为什么是只增不改的事件流
先别再养一份"消息数组"
第 04 章走到 step 时,历史是"从日志投影出来的"。第一次看见这个说法的人,通常会做两件事里的一件——
- 把会话理解成审计日志:真对话还是循环里那份可变的消息数组,日志只是事后抄写;
- 或者反过来,把"投影"理解成"把杂乱事件翻译成模型格式"的适配器,翻译规则可以随调用方改。
两件事都错在同一个地方:它们假定存在第二份真相。
会话被定位成 Agent 全部交互历史的只增不改的源头,模型消息历史是从它派生出来的。这条规则被写成一句纪律:模型看见的,等于已经被记下的。 任何到达模型请求的东西,都必须能从日志重建,一条运行时检查会守着这件事(附录 E 拆这道检查)。所以你要让模型看见一种新输入,先扩展事件词表,再从日志渲染——不许在循环里塞一个只有内存知道的旁路。
这条纪律是双向的:没进日志的,模型不准看;进了日志、能投影成消息的,才是模型看见的历史。
1. 真相是事件流,消息列表只是一种读法
先给判断:会话不是"对话记录加一些元数据"。它是一条只准追加的事件流。消息、界面、恢复、分叉、压缩,全是这条流上的投影。
这里有一个容易混的地方,第 01 章提过一次:内存里的事件日志本身,和更外层的"持久化数据面"(负责落盘、投影、生成标题、上报遥测的那一整套能力)不是一回事。持久化故意不在事件日志这一层实现——插件监听事件广播,在特定的"落盘屏障"时刻才真正写盘。这一章只关心:即便磁盘上什么都没有,循环面对的那份历史,也已经是日志的投影。
追加是同步提交的:把要写入的数据做一次快照(非法值当场报错,不等落盘)、冻住、校验一遍格式契约、推进日志、再通知观察者。观察者出错被逐个隔离处理,已经进日志的事件不会被撤回。同一时刻重复追加会被直接拒绝。调用方事后改自己那份输入,改不到日志里——拿到的返回值是一份快照,不是原始引用。
事件分两层,别混。
原始日志什么都收:turn 边界、step 边界、模型返回的每个片段、队列裁剪记录、请求信封快照,以及各插件按需扩展进来的各种记账事件。这是界面重放、取消原因、用量记账、请求信封的家。
表面层只收能变成模型消息的三种:用户消息、助手消息、工具结果。只有这三种可以带"表面操作"标记。其它类型的事件想带表面元数据,会在类型层面被直接拦住。
表面操作目前有两种:追加到尾巴上,或者替换——用一个新节点盖住一段旧的表面记录。压缩走后一种。原始日志里被盖住的事件还在,模型再读时看不见它们。这不是"改历史",是"再追加一条声明:这段投影作废"。
会话的磁盘格式目前处于早期版本,版本号不一致时后端会直接拒收,不做迁移。预览期选地基优先于垫片——第 00 章说过。日志格式是这个项目不肯偷偷兼容的东西之一。
如果没有"只有一份事件流"会怎样?循环里一份消息数组,界面一份 transcript,持久化再抄一份。注入进了 A 没进 B,压缩改了 B 没改 A,恢复读 C。三个月后你分不清模型当时到底看见了什么,问题只能靠"当时打印出来的数组"去猜。只有一份事件流,把这份对账取消掉:对账对象就是日志本身。
2. 投影是投影,没有原始日志回退
判断:模型历史不是"扫描全部事件,猜哪些算消息"。它是把表面层每个节点,用同一条纯函数折一遍。
对每个新的表面节点投影一次,返回这些节点里已经冻住的完整消息。表面层被改写,投影重建。没有原始日志回退这个选项。
投影结果会做缓存,只对还没算过的新节点重新计算;返回值是新数组,里面的消息对象与日志共享内容且深度冻结。调用方推不进这个数组,也改不了消息内容。
那条纯函数本身很短,是故意的:
- 用户消息 → 原样取出内容
- 助手消息 → 取出消息内容;内容为空则视为空节点(只为挂用量统计的锚点,不准进模型的对话记录)
- 工具结果 → 取出结果消息
- 其余,包括返回片段、turn 边界、任何只写日志用的事件 → 都不参与投影
这条函数不做任何"智能翻译":人话、系统注入、长期任务续跑,三种用户消息的内容都原样进模型,只靠一个来源标记来区分它们。
同一份日志,人和模型读的不是同一条投影。人类可读的记录必须投影追加起源的事件,故意绕开被替换覆盖后的表面层——如果读了表面层,已经落地的替换会盖掉读者已经看过的历史,人类记录因此特意不走这条路。模型面对的消费者继续读表面层。界面上人看见压缩前的话,模型看见压缩后的话,两件事同时成立,因为原始日志没被改。
如果投影可以"智能翻译",每个调用方都会发明自己的翻译。重建请求时你无法证明两份历史相等。把投影收成一个函数、禁止原始日志回退,恢复、分叉、运行时检查、测试回放才有同一个答案。
3. 一条运行时检查把"两份真相"变成运行时错误
判断:这条纪律如果只写在文档里,循环里塞旁路是几分钟的事。这个项目把它做成了一次请求发出前必须通过的检查。
这道检查挂在真正发出请求的那个必经环节上,还要求自己跑在最外层——防止一个短路的监听器把检查静音。对循环造出来的请求,它要求:
- 请求本身冻住,带活着的会话标识
- 日志里已经有对应的 step 开始记录(没开 step 就去叫模型,是旁路)
- 日志里能折叠出完整的请求信封(系统提示、工具描述、模型路由)
- 请求里的消息数组,逐字节等于日志投影出来的消息数组
- 请求上的模型、系统提示、工具、采样参数等字段,跟折叠出来的信封逐字段一致
对不上,失败信息明确指向"日志重建失配"。第 02 章的拦截链在这里被用来做门禁:检查跑在最外层,默认行为(真的去发起流式请求)是最内层的委托终点。
请求信封补的是历史投影管不到的那一半:它记录非历史部分的完整快照——为什么会有这张信封(初始创建、恢复、发生变化),取最新一张即可还原当次请求的非历史部分。模型看见的 = 历史投影 + 这张信封。两半都在日志里,才叫重建。
如果没有这道检查会怎样?第一次图省事的人会在组装请求时偷偷往消息数组里塞一段秘密内容。单测绿,因为单测看着同一份内存。进程一重启,或另一个插件从日志重放,秘密消失,模型行为变成玄学。这道检查让这份玄学在第一次请求时就炸,而不是在用户投诉"怎么跟刚才不一样"时才炸。
4. 所以新的模型可见输入,必须是一种新事件
判断:扩展模型上下文的合法动作是扩展事件词表并追加。在循环里给请求数组加一项,是在制造第二份真相。
事件词表靠声明式扩展打开,压缩、重试、钩子都是这样加进来的。插件自己负责这些新事件的关系是否成立,包括一种"仅写日志"的事件能不能出现在 turn 之间。需要耐久记录就正式追加一条事件,再确认落盘完成,不要为了记账去假造一轮执行。
读侧还有一条配套纪律:事件词表的成员默认"读到就必须认识"——不认识这个类型的构建会拒收这条日志,除非事件显式标记为"可忽略"。只有结构格式真正变了才升级磁盘格式版本号。你加了一种模型看得见的新事件,旧构建应当倒霉地拒绝,而不是静默跳过;你加了一种可以忽略的遥测,才标记为可忽略。
分叉会话也吃同一条流。分叉操作按序号切一段前缀当种子,前缀必须停在一个未闭合的 turn 之外。孩子会话不是"拷贝当前消息数组",它是"这段日志再投影一次"。第 04 章的待办队列重放,用的是同一前缀上的裁剪记录。
压缩也不例外。它不是把旧的用户消息改短,而是追加一条带"替换"标记的表面事件,声明哪一段投影被盖住。原始片段还在,人类可读记录还能走追加起源那条路,模型下次投影时看到的是盖住之后的表面层。算法细节留给附录 A。这里只需看见:压缩仍然服从只增不改。
如果没有"新输入 = 新事件"会怎样?每个功能都会在请求组装处开后门:工作区规则塞字符串、技能说明塞字符串、子代理交接塞字符串。表面上都能跑。直到你要恢复、要分叉、要解释模型为什么看见了不该看见的、要让界面和模型对齐——那些字符串不在日志里,故事讲不圆。事件流逼你在写入的那一刻就决定:这件事是不是模型可见的。是,就留下一种别人能投影的事件;不是,就别碰消息数组。
5. 结语:带走一句话
会话不是对话的审计日志,它就是对话——投影只是模型被允许使用的那种读法,任何绕过它塞进请求的东西,都是第二份真相。