
第 4 章 会话日志整个系统唯一的真相来源本章围绕「日志是唯一真源」这一核心设计展开会话日志以仅追加方式记录全部交互事件模型可见的消息历史由日志派生而非单独存储。文中梳理了十三种核心事件、surface 投影层、纯消息投影机制、轮次结束的七种原因以及日志持久化与崩溃恢复的边界设计。标签会话日志事件溯源仅追加日志surface投影消息派生持久化崩溃恢复如果第 3 章是「流程」这一章就是「账本」。理解了这个账本你就理解了这套架构里一半的设计决策——因为几乎所有看起来很绕的规则根源都在「日志是唯一真源」这一条上。4.1 一句话说清日志是唯一真源官方原文Session是一份由类型化SessionEvent组成的仅追加日志是 agent智能体完整交互历史的唯一真源。LLM 消息历史从日志派生而来从不单独存储回放用同一份记录重新走一遍过程得到同样的结果。它是排查问题的终极手段即从同一组事件重新派生。这段话里有三个要点每一个都推翻了一种常见的做法要点它推翻了什么带来了什么好处仅追加「修改一条消息的历史记录」这种操作不存在日志天然可审计、可回放并发读写不会撕碎状态历史是派生的不再单独维护一个messages[]数组不可能出现「日志和内存里的对话不一致」回放即重新派生不需要为「恢复」写一套单独的代码路径恢复和正常运行走同一条逻辑bug 面大幅缩小这套做法有个专业名词叫「事件溯源事件溯源系统的当前状态不直接存而是存下所有发生过的事件需要时再推算出来」。它最大的好处是你永远不需要回答「现在到底是什么状态」因为答案永远是从事件重新算出来的。图 15日志进消息出。每次请求模型之前这套推算都会重新跑一遍。4.2 日志里都有哪些事件官方文档在核心包一页里列出了十三种核心事件变体。它们是日志的词汇表。下面这张表把它们逐个翻译成人话。事件会变成消息吗它在记录什么turn/start不会打开轮次turn。注意它没有 trigger 字段——轮次是怎么开始的由后面的 user/message 批次说明turn/end不会关闭轮次并带上结束原因。没有进入步骤的轮次不会有 step/start 和 step/endstep/start不会打开第turn轮的第step步——一次模型调用加上它请求的那些工具执行step/end不会关闭该步骤user/message会模型可见界面上的 user-role 消息。它有三种来源你直接打的提示词、合成注入的上下文文件变更通知、子目录 AGENTS.md、skill 内容、定时任务通知……、以及进入的目标续跑轮次。source字段用来说明是哪一种system/message会渲染后的系统提示词。循环把第一条作为「surface 第 0 号节点」追加位置在该步骤第一条user/message之前。详见第 5 章assistant/message会一个步骤组装好的助手消息。它还内嵌了产生它的那段精确的带时间模型流所以模型输出和它的证据是一起走的。内容为空的不会进入派生历史assistant/attempt不会一次没有产出可见消息的模型尝试。内嵌的 stream 保留了失败、重试、取消、stream error 的证据但不虚构模型可见的历史tool/call不会间接参与模型请求一次工具调用。注意它记录在「执行之前」。而且arguments保存的是模型产出的原始 JSON 字符串未解析——这是为了保真tool/result会一次完成的工具调用面向模型的结果。它还可以带一个「内部失败身份 面向用户的理由」这对内容不在模型消息里以及一个工具私有的meta展示数据对核心不透明但必须是可 JSON 序列化把内存里的数据结构变成能存进文件、能发到网络的字节流的这样才能在回放时重现同一张卡片request/header不会下一次请求的完整信封。它是仅日志的——最新的快照某一瞬间的完整状态副本。拿到快照之后别人再改也不会影响你手上这一份用来重建请求头request/context不会下一次请求所解析到的「路由元数据」。只在路由、容量或系统提示词更新模式发生变化时才记录session/end-seed不会把「继承或恢复来的历史」和「之后由本会话生命周期产生的工作」分开的一条分界线此外官方还提到事件表是可以通过声明合并扩展的。举例压缩 seam 添加了compaction/start、compaction/summary、compaction/end钩子桥接添加了仅日志的hook/invoked和hook/result。还有后来的developer/message它记录「增量的 agent 会话变更」——比如中途新增或移除一个工具定义。关于事件数量的一个说明核心包文档写作时统计的是「十三种核心事件变体」即上表前十三行。而会话子系统的事件表里还登记了developer/message它是之后加入的合并扩展项。所以你会看到两种数字它们不矛盾——一个说的是「核心」一个说的是「当前全部」。本书以「核心十三种」为准其余按扩展项说明。4.3 「模型可见即已记录」这是本书记得住就值回票价的一条不变量一条必须永远成立的规则。运行时还会主动检查它一旦违反就报错而不是先默默出问题。官方原文用了六个字模型可见即已记录Model-visible means logged。展开说运行时不变量检查模型请求是否可从日志重建。新增模型可见输入需要会话事件。这句话的直接后果有三个每一个都影响你写插件的方式后果一想让模型「看到」新的信息你就必须往日志里写一个事件。往一个内存变量里塞东西是没用的因为请求是从日志派生的。后果二想修改已有消息的内容你要注册一个「纯消息投影」第 4.5 节而不是直接改历史数组。后果三日志里的每条data都必须是可无损 JSON 序列化的。Session.append会在源头校验并抛异常所以坏事件绝不会进入日志。为什么这条规则很值得因为它把「模型看到了什么」这个问题从「取决于运行时的一堆可变状态」变成了「一个可以读的日志」。这意味着**可排查。**模型行为诡异时你可以把那一刻的日志摊开逐条重建出它当时看到的东西——不是「猜」是「重建」。**可回放。**同一个日志重新派生出来的请求是一样的因为请求是日志的纯函数。**可验证。**仓库里有运行时不变式在主动检查这件事所以你在开发阶段就会撞到墙而不是上线后才出问题。4.4 surface日志里「真正会变成消息」的那一层上一节说了「十三种核心事件」但表里有一列写着「会不会」。这一列背后就是 surfacesurface日志里真正会变成模型消息的那一层有序视图 的概念。官方的定义是产生消息的类型SurfaceEventType携带 surface 元数据用来声明它们如何加入有序的派生 surface。目前有五种typeSurfaceEventType|system/message|developer/message|user/message|assistant/message|tool/result每个 surface 事件都必须声明它的surfaceOp只有两种可能surfaceOp含义append加到末尾。这是常规路径——user / assistant / tool 消息都走这里{ op: replace, startSeq, endSeq }用这个节点替换从startSeq到endSeq都是闭区间之间的所有 surface 节点。压缩compaction用的就是它为什么要有 replace因为上下文会变长。当一段对话太长压缩机制会把早期的一批消息「归纳成一条摘要」然后在原位置用摘要替换掉那批消息。这样既缩短了历史又保持了对话的连贯叙事。图 16surface 让「日志」和「模型看到的东西」能分离日志保留一切surface 只暴露当前的。官方在文档里写了一句极精确的话来解释这个设计的用意面向人类的 transcript文本记录是另一个投影读取的是日志中追加来源的事件因为 surface 会有意遮蔽替换所概括的范围。所以同一份日志至少有两个读者模型读 surface看到的是压缩后的紧凑版人读 transcript看到的是完整的原始对话。它们不是同一个视图也不应该是。两个「代次」计数器为了让增量消费方能区分「只是尾部变长了」和「历史被重写了」surface 暴露两个计数器计数器什么时候增长replaceGeneration每次提交一次位置替换它的计数加一contentGeneration每次提交位置替换或插件拥有的消息内容变更它的计数加一为什么需要这个因为deriveMessages()是带缓存的纯粹尾部增长的开销是 O(新增节点数)不需要重算一旦发生替换或内容变更就得重建。这两个计数器就是「该重建了」的信号。一个能省你很多时间的性能事实官方明确写了deriveMessages()的缓存行为**纯尾部增长只花 O(新节点) 的开销一次替换或消息投影会触发重建。**所以你设计插件时如果能让改动落在「追加」而不是「替换」上性能会好很多。这也是为什么大量功能被设计成「注入一条新的 user/message」而不是「改写旧消息」。4.5 插件怎么合法地修改消息内容「日志只增不改」听起来像是禁止了一切修改。那插件想改一条已有消息怎么办官方给了一条正规路径纯消息投影。流程是这样的插件在自己的事件声明上用messageProjection标记。通过ctx.sessions.registerMessageProjection()注册一个纯处理器。Session 在提交前通过处理器校验完整的决策应用它给出的不可变消息更新并推进contentGeneration。官方对「纯处理器」给了一个很具体的接口约定其中几个要求很值得注意它必须在返回任何更新之前先校验完整的持久决策——也就是「要么全做要么不做」。它必须保持消息的身份并发布不可变的副本而不是原地修改输入。如果某个持久决策没法应用到当前历史就抛异常不要静默吞掉。还有两条容易忽略的运维后果缺少处理器时操作会被拒绝——包括恢复和独立折叠把一串按顺序发生的事件累加成一个「当前状态」。也就是说卸掉某个插件之后读了它事件的会话就读不出来了。这是刻意的宁可明确失败也不要得出错误的历史。独立读取器必须把自己手上的处理器显式传给foldSurface(events, projections)不能指望全局注册表。官方举了一个真实例子图片省略插件拥有图片专用的事件及解释逻辑。也就是说「把历史里的图片替换成一段占位文本」这件事是插件用自己的投影规则实现的。4.6 轮次为什么结束七种原因turn/end带一个结束原因。这个原因不是随便写的字符串而是一个可合并扩展的类型。七种情况如下原因什么时候出现为什么需要单独区分completed正常结束—aborted取消请求打断了活跃轮次带上富类型的reasonuser / parent / hook / disposedblocked被拦住了—error轮次失败error永远是结构化的失败事实不是一个裸字符串max-tokens只要轮次内有任何一步以max-tokens结束即使之后插件让轮次继续执行了截断这个事实仍然优先让消费方能区分「正常停」和「被截断停」interrupted崩溃遗留下来的轮次被事后关闭**循环从不实时发出它。**是恢复流程补上的。崩溃前已记录的事件完好保留forkedfork 种子构造时关闭了源会话里还开着的轮次只有 fork 种子会带它循环也不发它反直觉max-tokens优先于completed。你可能觉得「既然插件让它继续跑了那就是正常完成」。设计者的判断相反被截断是一个必须被记录的事实因为它解释了很多下游现象比如为什么模型漏掉了指令的后半段。宁可让消费方多看到一点信息也不要让它以为一切正常。4.7 持久化日志怎么落到磁盘上内存里的日志写得再漂亮进程一崩就没了。持久化这一层有几个设计点很值得学。约定后端要保证什么持久日志必须无损保存每个事件。每个 assistant 尝试都是一个assistant/message或assistant/attempt它们内嵌的紧凑 stream 保留原始带时间的 chunk。seq在这些结算事件和所有交错事件之间保持连续。后端可以为事件批次选择自己的存储方式只要句柄的read()返回的事件和追加时完全一致。文件名与「代次」generation当前的 JSONL 后端把每个事件写一行。文件命名有一个很讲究的规则版本文件名v0session.jsonl[.zstd]v1 及之后session.vN.jsonl[.zstd]小写 v以及一条铁律已提交代次的路径绝不重命名、替换或删除。如果要改格式怎么办做法是排他地发布一个新代次写操作打开时会先编码、校验然后在「未改变源文件」的旁边排他地发布一个以最终版本命名的后继文件。每个相邻的迁移包只负责一个vN → vN1步骤。图 17「永不改写已提交的文件」这条规则让崩溃恢复从「可能损坏数据」变成「最多丢最后一次写入」。崩溃恢复的边界很窄这是刻意的官方描述了几种尾部情况的处理未被后续事件封住的普通中断尾部仍由句柄消费方修复。只有在后续turn/start已经「封住」了一种有限的已发布 restart 时迁移才会插入缺失的 interruptedturn/end。崩溃修复只关闭轮次、步骤、工具边界从不处理compaction/*。为什么最后一条很重要因为「种子历史」和「实时工作」在字节层面是完全一样的。一条没有配对compaction/end的compaction/start读起来可能是「写入方当时正在压缩」也可能是「写入方压缩到一半崩了」。日志本身分不清。所以框架加了一条明确的界线session/end-seed在session/end-seed之前的开启标记来自构造种子并且属于一个已结束的生命周期无关于结束原因崩溃、进程接替或从仍在运行的父会话 fork 出来因此其所有方可以视之为已死。并且官方诚实地说明了它覆盖不到的范围这只覆盖本会话继承的括号。另一个并发存活的会话可能在同一段历史上持有开放括号而它自己的边界在别处——所以「容忍并发写入方」还需要日志之外的存活信号而这超出了日志的能力。反直觉看「这个会话最近有没有人在用」不能用日志尾部排序。官方专门说明按真人活动排序的消费方会排除session/end-seed边界——接手会话不算工作所以按日志尾部排序会把每个「曾经打开过」的会话顶到最前面完全没法用。4.8 fork在轮次边界切一份会话fork分叉是一个很常用的功能从某一点开始复制出一份新的会话。官方的做法有几个细节很讲究。底层原语是ctx.sessions.create(id, { seed, meta })——用一组种子事件可选和一个元数据创建会话。策略 API 是SessionStore.fork()接受一个活跃的Session对象或SessionId。选取到boundary含为止的源事件默认是当前最后一个事件。要求所选前缀结束时没有开放轮次——否则直接拒绝而不是静默截断。然后创建一个活跃的子会话深克隆的 seed 事件、parentSession、isSeeded: true、精确的inheritedEventCount以及继承的cwd。关于边界官方还说明显式boundary允许你从任意稳定的轮次间位置分叉包括之前的turn/end或更晚的独立纯日志事件——即使源会话有更新的事件、或者正在进行的轮次也行。fork 与 subagent 的边界差别官方指出dsh-subagent-fork-in-process保留了自己的「已完成前缀截断」逻辑因为工具调用时的委派通常是在父轮次仍然打开的时候启动的而普通的会话分支应该显式指定请求的边界。这是一个很典型的「同一功能因为使用场景不同而需要不同默认值」的例子。fork 还有一个附带产物如果源会话在边界处有一个仍然开着的轮次子会话会补上合成的事件把它关掉结束原因是forked。4.9 谁来检查日志的合法性日志这种核心数据结构必须有守卫。官方把它们分成两类守卫它强制什么Session.append自身的校验每个事件的data必须可无损 JSON 化。遇到 BigInt、函数、symbol、undefined、负零、非有限数、循环引用、稀疏数组、或 Map/Set/Date 这类奇异对象直接抛异常。坏事件绝不允许进入日志可选的dsh-session/invariant配套插件强制核心拥有的关系轮次与步骤编号、执行事件封闭、同一步骤内的工具调用结果配对官方还说明了一个很周到的设计可合并扩展事件的关系由声明它的插件拥有因此核心不会仅因「没有开放轮次」就拒绝未知事件。换句话说核心不越界管插件的事。另外每个事件还有一个ignorable标记它的语义设计得很巧妙值得抄下来省略意味着「必需」读取方遇到一个不认识的、又没有这个标记的类型时「必须拒绝重建会话」而不是静默丢弃事件——因为一个不认识的必需事件可能改变日志其余部分的解读方式。写入方只在纯粹信息性的记录上设true默认必需意味着「忘记标记」的后果是多拒绝一次一点不便而不是静默地恢复出一个被掏空的会话。反直觉「默认严格」在工程上是一个反直觉但正确的选择。多数系统默认宽容不认识就跳过结果是一个字段缺失悄悄变成数据丢失。这里把默认值反过来不认识就拒绝宁可让你发现也不让你悄悄丢数据。4.10 这一步做什么用投影 seam日志很强但它是「事件流」。界面要的是一个「当前状态」——比如待办列表现在有哪些项、这个会话的标题是什么。这两者之间需要一次转换。官方为此提供了ctx.sessionProjections由dsh-session-projection提供角色做什么已注册单元增量地把已提交事件「折叠」成一个状态host 消费方用stateOf()读单个类型化状态载体客户端用snapshot()批量取得裁剪后的客户端视图官方有一条对插件作者的硬约束很值得注意贡献方可以保留注册但不能为缺失的 host 值静默提供默认值。host 读取方要么在激活时要求这个服务要么在注册表或必需 key 缺席时明确失败。为什么这么严因为一个「看起来合理」的默认值会让整个系统的行为变得不可预测——你不知道屏幕上显示的是真状态还是一个默认值。宁可报错也不要显示假数据。还有一个配套设施ctx.sessionProjectionCache按会话持久保存投影单元状态的检查点节流检查点以及轮次结束分离时的必选检查点用来加速恢复。4.11 这一章要带走的三句话这一章要带走的三句话**日志只增历史派生。**要让模型看到新东西就写事件不要试图改内存里的历史数组。**surface 是日志的一个视图。**压缩不是删日志而是在 surface 上做一次 replace。**默认严格优于默认宽容。**不认识的事件就拒绝重建宁可多报错也不要静默丢数据。