上一章里,我们反复看到 session.append(...)turn/startuser/messageassistant/chunktool/resultturn/end

第一次读到这里,很容易产生一个朴素的问题:

不就是聊天记录吗?为什么不直接维护一个 messages: Message[],每次用户说话就 push,模型回复就 push?

对于一次性 Demo,这样做完全够用。但一个可恢复、可压缩、可审计的 Agent 会很快遇到问题:

  • 模型流式输出到一半,页面如何实时展示,重启后又如何回放?
  • 一次工具调用包含原始参数、模型可见结果和 UI 专属展示数据,它们都应该塞进同一条 message 吗?
  • 系统提示词、工具 schema、模型路由不在 messages 里,恢复后怎样知道那次请求到底发了什么?
  • 上下文压缩后,模型不该再看到旧消息;但用户界面和审计又不能假装旧消息从未发生。
  • 任务在中途崩溃,重启后的系统如何辨别“历史已完成”和“最后一个 Turn 没有收口”?

DeepSeek Harness 的答案不是维护一个可变消息数组,而是建立一条仅追加的 Session Event Log,然后从这条日志派生不同视图。

本篇的核心命题是:

日志保存发生过什么;Surface 决定模型现在看什么;Projection 把二者转换成某个消费者需要的视图。

本文结构

本文从“为什么不直接维护 Message[]”这个问题出发,按以下顺序逐层展开:

章节核心问题答案
第 1 节为什么需要 Log → Surface → Messages 三层?Message[] 装不下过程、边界、环境、替换历史四类信息
第 2 节往 Log 里写事件有什么规则?四层验证:JSON 可序列化、序号连续、Surface 合法、先提交再通知
第 3 节模型到底应该看到哪些事件?只有三类:user/messageassistant/messagetool/result
第 4 节Surface 如何变成模型 API 的 Message[]?deriveMessages() 投影 + 增量缓存
第 5 节完整的模型请求还需要什么?request/header(包络)+ request/context(容量)
第 6 节所有概念如何串起来?“查天气”完整例子 + 工具结果崩溃恢复的持久事实
第 7 节进程崩溃后如何恢复 Session?五层检查 + session/end-seed + checkpoint policy
第 8 节如何从一个已有 Session 分支?fork 复制稳定日志前缀,不能落在 open Turn 内
第 9 节全篇总结六层含义 + 系列回顾

它不是独立的一篇:前四篇在这里如何汇合?

Session 不是在第四篇 Agent Loop 之后额外加上的“存储模块”。它是前四篇工程选择自然推导出的结果:

前文已经建立的结论到本篇变成的问题
第 1 篇:研究对象Agent Runtime 需要可运行、可扩展、可恢复、可治理“可恢复”不能只靠 UI 还保留着聊天文本
第 2 篇:Profile / Bundledsh-base 把 Session、持久化与 checkpoint policy 作为可组装能力装入产品不同产品可以选不同后端或策略,但不能绕过统一日志语义
第 3 篇:CordisService、Event、Effect 和 Fiber 让能力有依赖与所有权ctx.sessions 是能力 seam;持久化监听器与会话生命周期必须可撤销、可等待
第 4 篇:Agent LoopInbox、Turn、Step 驱动任务;工具结果进入 next-step 才能续跑崩溃后怎样知道哪些输入、工具结果和步骤仍然有效,且能继续推进?

所以本篇研究的不是“聊天记录怎么存”,而是:第 4 篇的执行状态如何成为可验证、可恢复的事实。

第四篇中的 Agent Loop 决定“任务如何推进”:何时领取消息、何时请求模型、何时调用工具、何时结束 Turn。Session 不负责替 Loop 做这些决策;它负责把已经发生的事实以稳定、可验证、可重放的形式留下来。

Agent Loop 中的动作Session 中留下的事实以后谁会使用
打开任务turn/start恢复、审计、状态投影
放行一批输入user/message下一次模型历史、Transcript
接收流式输出assistant/chunkUI 流式回放、调试
汇总一次模型结果assistant/message模型历史、Transcript
执行工具tool/call / tool/result下一 Step、UI、回放
结束或失败step/end / turn/end恢复、错误定位、任务状态

这里需要先建立一个边界:

agent/*、tools/* 等实时事件:控制正在发生的运行
SessionEvent:已经提交、可恢复的历史事实

例如 agent/pre-step 可以拒绝一个候选输入,但它本身不是历史事实;若拒绝最终导致 Turn 被阻断,turn/end { kind: 'blocked' } 才是需要留下的结果。

DOCdocs/architecture.zh.md 明确区分持久 Session Event 与实时 Agent/能力事件。
SOURCEpackages/core/agent-loop/src/agent.ts 推进流程并调用 session.append()packages/core/session/src/index.ts 定义日志接受、投影与恢复边界。

从第 2 篇的组装视角看,基础组合中同时出现 dsh-session、持久化后端和 dsh-session-checkpoint-policy 并不是重复配置:前者定义真源,后者决定落盘实现,最后一个决定哪些业务边界必须等待耐久确认。第 7 节会把这三层拆开。


1. 先看全貌:同一份历史,三种不同视图

1.0 为什么不能只存 Message[]?——先回答这个问题

第一次接触 Session 设计时,很容易有一个直觉的想法:

“Agent 不就是来回对话吗?为什么不直接维护一个 messages: Message[] 数组,用户发一条就 push 一条,模型回复了就 push 一条,需要的时候直接取出来?”

但是你得先理解一点:

Message[] 这个数据结构的“设计容量”只够装“角色 + 内容”(比如 { role: “user”, content: “你好” })。它装不下“过程”“边界”“环境”“替换历史”这四类信息——不是因为系统没有记录,而是因为 Message[] 本身没有字段去放这些东西。

但对于一个需要在生产环境中长期运行的 Agent,这个方案会丢掉至少四类信息:

  1. 过程:流式 chunk 的到达顺序、工具请求的原始 arguments、一次请求的使用量;
  2. 边界:某个 Turn/Step 是否已经开始、结束、失败或被取消;
  3. 请求环境:模型路由、系统提示词、工具 schema;
  4. 替换历史:压缩前模型看过什么、压缩后现在应看什么。

所以,Message[] 只能满足“当前这一次模型请求需要什么”,但无法满足“UI 回放”“审计追溯”“进程恢复”“压缩后溯源”这四个同样重要的需求。

1.1 DSH 的做法:Log → Surface → Messages 三层分离

DSH 的答案是:不直接维护 Message[],而是建立一条仅追加的 Event Log,然后从这条日志派生出不同的视图。

这三层的名字和职责分别是:

层级名字它是什么存储什么内容谁在用
第 1 层Log(事件日志)唯一的真实事实源,存所有发生的事turn/startuser/messageassistant/chunkassistant/messagetool/calltool/resultrequest/headerstep/endturn/endagent/inbox/spliced 等全部事件所有下游(UI、模型、审计、恢复)
第 2 层Surface(模型可见序列)模型看到的事件序列,是 Log 的受控投影只取三类事件:user/messageassistant/messagetool/resultderiveMessages()
第 3 层Messages(消息数组)大模型 API 要求的 Message[] 格式从 Surface 节点按规则转换成 API Message模型请求

用一句话记忆就是:

Log 是事实库,Surface 是模型上下文的目录,Messages 是目录投影出的请求材料。

1.2 同一份 Log,如何派生出不同视图

下图展示了 Log 如何同时服务于多种需求:

投影到模型可见序列

回放完整过程

持久化与分支

指标与搜索

替换 Surface 视图

旧事件仍保留在 Log

Append-only Session Event Log
完整事实:边界、chunk、调用、结果、header

Session Surface
当前模型可见的事件序列

Transcript / UI 回放
保留用户已经看过的过程

Persistence / Resume / Fork
完整恢复与分支

Telemetry / Projection
业务状态、指标、搜索

deriveMessages()
模型请求的 Message[]

Compaction summary

图中有几条关键路径:

路径用途典型消费者
Log → Surface → Messages下一次模型请求需要的历史LLM Adapter
Log → Transcript / UI用户看到的完整对话过程Web UI、客户端
Log → Persistence / Resume / Fork进程恢复、会话分支Session.fromRestore()fork()
Log → Telemetry / Projection业务指标、搜索、审计监控面板、日志查询

还要特别注意一条虚线:Compaction summary -.-> Log。它的意思是:

压缩生成的摘要替换了 Surface 视图(模型从此看到摘要),但旧事件本身仍然留在 Log 中,没有被删除。

这是“可审计”和“可压缩”两个需求同时被满足的关键。模型少看了内容,但审计者仍然能看到完整的历史。

1.3 小结

用两句话总结这节的全部内容:

  1. DSH 不直接维护 messages 数组,而是维护一条完整的 Event Log,然后从 Log 派生 Surface,再从 Surface 派生 Messages。
  2. 之所以要三层,是因为同一个历史事实需要服务于模型、UI、审计、恢复这四种不同的消费者,而它们各自需要的内容和格式都不一样。

2. Session Event Log:什么可以写入,什么不能写入

上一节回答了“为什么要存 Log”。这一节回答一个更具体的问题:往 Log 里写入事件时,有哪些规则和边界?

2.0 Session 在架构中的位置:一个关键区分

先建立一个基础认知:Session 是一个普通类,不是 Cordis Service。

它内部维护一条私有的 append-only log,对外只暴露冻结的只读快照。你无法修改已写入的事件,只能追加新事件。

那 Cordis 扮演什么角色?

  • ctx.sessions 是一个 Service Definition,它的实现(SessionStore)是一个 Provider
  • Agent Loop、持久化插件、UI 投影都是这个 Service 的 Consumer
  • Session 只负责在内存中组织和维护事件,本身不负责把事件存到磁盘 I/O;Session 不写磁盘 = 它只负责内存中的日志组织。谁负责写磁盘 = 持久化插件。用哪一个持久化插件 = 由 Profile/Patch 配置决定。 三个职责分开,互不干扰。

这意味着:你可以在 Profile 里替换持久化后端(比如从文件换成数据库),而不需要改动 Agent Loop 的任何代码。这正是第 2 篇讨论的 Profile/Bundle 组合能力的体现——Session 的设计从一开始就预留了这个 seam。

2.1 事件的数据结构

每个写入 Session 的事件都包含三个固定字段:

{
  type: 'assistant/message',  // 事件类型,决定 data 的结构
  seq: 17,                    // 连续递增的序号,等于写入时的 log.length
  time: 1710000000000,        // 写入时间戳
  data: { /* 类型对应的事实 */ }
}

seq 是恢复时最重要的字段——它保证事件顺序可验证。如果恢复时发现 seq 不连续,说明日志中间有缺失,系统会拒绝这个会话的恢复。

2.2 事件分类:哪些能写,哪些不能写?

DSH 的事件类型不是固定写死的。通过 TypeScript 声明合并,插件可以扩展自己的事件类型。但核心事件按用途分为六类:

类别代表事件说明
执行边界turn/startstep/startstep/endturn/end任务推进到哪里,属于框架内部控制信息
模型可见消息user/messageassistant/messagetool/result三类事件可以进入 Surface,成为模型历史
过程保真assistant/chunktool/callUI 回放和诊断需要,但不直接进入模型历史
请求状态request/headerrequest/context还原请求的配置与路由信息,不进入模型历史
调度状态agent/inbox/spliced待办输入的插入、领取、取消,不进入模型历史
插件扩展compaction/*hook/*插件自定事件,默认只是日志事实,由插件自己定义用途

核心区分只有两类:进入 Surface 的(模型可见)和不进入 Surface 的(模型不可见)。

DSH 允许插件定义自己的事件类型,但框架对它们不做任何“猜测”——不会自动塞进模型历史,也不会自动显示在 UI 上。插件写了什么事件,由插件自己决定怎么用。框架只负责“存起来”,不负责“替它做决定”。它只是被存下来了。插件如果想要这个事件影响模型(比如变成一条消息)、或者显示在 UI 上、或者影响某个业务流程——需要插件自己写逻辑来处理。框架不替它做决定。

2.3 写入前的四层验证:为什么“先检查再写入”

Session.append() 的执行顺序不是简单的“收到数据 → 写入日志”。它经历了一个严格的验证链:

session/event observersappend-only logSurfaceManagerSession.append()Agent / Tool / Pluginsession/event observersappend-only logSurfaceManagerSession.append()Agent / Tool / Pluginappend(type, data, surface intent?)快照并验证 JSON 可序列化validateNext(candidate)可追加 / 拒绝push(frozen event)session/event(提交后通知)返回带 seq 的已提交事件

图中的四层保护,每一层解决一个具体问题:

第一层:值必须可无损 JSON 化

函数、BigInt、循环引用、MapDateundefined、非有限数字(NaNInfinity)都不能进入日志。

为什么?因为这些值无法被标准 JSON 序列化。如果允许它们写入,后续恢复时要么报错,要么得到与写入时不同的数据(比如 Date 变成字符串)。append() 在写入前就阻止它们进入,是为了把问题暴露在“写入时”,而不是“恢复时才崩溃”。

第二层:事件序号必须连续

下一条 seq 永远等于当前 log.length,不能跳号。

恢复时,Session.fromRestore() 会校验每个事件的 seq 是否连续。如果发现中间缺了一个,说明日志有丢失或损坏,系统拒绝恢复这个会话。这比“静默跳过缺失事件”更安全——因为后者可能导致模型上下文不完整而不自知。

第三层:Surface 元数据必须合法

进入 Surface 的事件必须说清楚“是追加还是替换”。如果是替换,框架会验证“替换的范围存在”“来源关系完整”“没有伪造历史”。这样才能保证压缩操作是可追溯、可审计的。

第四层:先提交再通知观察者

事件先成功写入 Log,然后再通过 session/event 事件通知外部观察者(如持久化插件)。

如果监听器失败,不会回滚已写入的事件。这意味着一个插件的错误不会导致整个 Log 写入失败——历史事实已经提交,观察者的失败是独立的。

这也意味着:append() 返回成功,只代表事件已写入内存中的 Log,不代表已持久化到磁盘。持久化的耐久性由 flush() 保证(详见第 7 节)。

2.4 为什么这些验证很重要

一句话总结:把持久化错误尽量前移到生产者。

如果一个工具的 meta 字段里塞进了不可 JSON 化的对象,应该在它试图写 tool/result 时失败,而不是数秒后由 JSONL 后端写盘时报错。后者的后果是:内存中的 Log 以为事件已经写入,但磁盘上却没有——两者状态分叉,后续恢复时既不能完全信任内存,也不能完全信任磁盘。

通过在写入时执行严格的验证,DSH 保证了内存日志中每个事件都是“可恢复的”(即使持久化失败,至少内存日志是一致的)。这是用同步校验换长期可靠性。

2.5 本节结论

  • Session 是一个普通类,它的“工厂”(SessionStore)通过 ctx.sessions 这个 Cordis Service 提供
  • 每个事件有 typeseqtimedata 四个固定字段
  • 六类核心事件中,只有三类(user/messageassistant/messagetool/result)进入 Surface
  • 写入前经过四层验证:JSON 可序列化、序号连续、Surface 合法、先提交再通知
  • 这些验证的目的是把错误暴露在“写入时”,而不是“恢复时”

SOURCEpackages/core/session/src/index.tsSession.append() 先执行 snapshotJsonValue()surfaceManager.validateNext(),再 log.push()session/event 发布。
SOURCESession.events 返回冻结的数组快照,事件及嵌套数据在接受时被冻结,外部不能回写历史。


3. Surface:什么才算“模型现在看得见”

上一节回答了“什么可以写入 Session Event Log”。这一节回答另一个问题:Log 里的事件这么多,模型到底应该看到哪些?

3.0 先明确一个前提:模型需要什么?

模型每次请求需要的不是“所有事实”,而是足以让下一次推理延续的最小上下文

从模型下一次请求反推,它必须知道三类信息:

模型必须知道对应事实为什么不能省略
用户或系统刚刚要求什么user/message否则模型不知道当前要做什么任务
自己此前已经说过或调用过什么assistant/message否则会重复回答或重复调用工具
外部世界给了什么结果tool/result否则模型只知道“曾发起调用”,不知道结果

反过来推,有些事件绝对不应该进入模型历史:

事件若放进模型历史会怎样正确去处
assistant/chunk一条完整回答被拆成碎片,模型看到“我今天”“天气晴”这种碎片Event Log,供 UI 流式回放
tool/call模型看到“我调用了工具”,但看不到执行结果——会把“请求”当成“事实”Event Log,供审计和配对
turn/startstep/end给模型注入框架内部信息,类似于对话里插入“第三章开始”Event Log,供恢复与状态判断
request/header把“用了什么模型”塞进对话内容,非常奇怪请求包络,第 5 节展开

结论:只有三类事件能成为模型历史的一部分。

user/message
assistant/message
tool/result

3.1 进入 Surface 的事件必须声明意图

这三类事件写入 Session 时,必须带一个 surfaceOp 声明,告诉框架“你打算怎么处理我”。

DSH 支持两种操作:

操作一:append——追加到末尾

{ surfaceOp: 'append' }

普通对话追加。用户发消息、模型输出完整回答、工具返回结果——都走这条路径,按顺序追加到 Surface 末尾。

操作二:replace——替换掉一段旧内容

{
  surfaceOp: { op: 'replace', start: 2, end: 5 },
  sourceEventSeqs: [2, 3, 4, 5]
}

只在上下文压缩时发生。对话太长,生成摘要后,用摘要替换掉 Surface 上一段连续节点。

注意:replace 不会删除 Event Log 里的旧事件。 它只改变模型看到的 Surface 序列:

完整 Log:  用户问 A → 助手答 A → 用户问 B → 助手答 B → summary
旧 Surface: A问, A答, B问, B答
新 Surface: summary
Transcript: 仍可读取 A问, A答, B问, B答

模型从此只看摘要,节省上下文空间。但 UI 回放和审计仍然能看到完整历史。

3.2 replace 的四条验证规则

replace 不是随意操作。SurfaceManager 会验证替换的范围和来源关系:

验证规则目的若不限制会发生什么
start / end 必须是当前 Surface 节点替换对象有唯一含义指向已经被替换的旧节点时,摘要到底覆盖谁将变得不可判定
sourceEventSeqs 必须完整列出被替换的节点摘要与原始历史之间的来源链审计时无法证明摘要是否遗漏关键信息,也无法追溯错误来源
引用只能指向更早、连续、无重复的 seq派生关系无环且可重放自引用会让事件声称“由自己产生”;跳号会把不存在的事件带入关系图
tool-result replacement 只能改内容,不能改 callId工具结果仍属于同一次外部行动若能把工具 A 的结果改成工具 B,后续模型和审计都会错误配对

这些验证保证一件事:压缩不是“删掉历史”,而是“换掉模型视图,但保留审计线索”。 模型少看了内容,但审计者仍然能通过 sourceEventSeqs 追溯到完整原始历史。

3.3 本节结论

  1. 模型只应该看到三类事件:user/messageassistant/messagetool/result
  2. 这三类事件进入 Surface 时,必须声明 surfaceOpappend(追加)或 replace(替换)
  3. replace 用于上下文压缩——用摘要替换旧内容,但 Log 里的旧事件不动
  4. replace 经过四条验证,保证摘要来源可追溯,不可伪造历史

4. deriveMessages():从 Surface 到模型请求历史

上一节确定了 Surface 包含哪些节点(user/messageassistant/messagetool/result 三类)。这一节回答:Surface 节点如何转换成大模型 API 要求的 Message[] 格式?

当 Agent Loop 要构造下一次模型请求时,它调用:

session.deriveMessages()

它不扫描全部 Event Log,也不把 Surface 节点原样输出。它按当前 surface.nodes 的顺序,逐个投影成 Message 对象。

完整 Event Log

SurfaceManager

当前 nodes: seq 1, 4, 7

deriveEventMessage

Message 数组

下一次 LLM request.messages

4.1 单条事件如何投影

deriveEventMessage() 的规则很明确:

Event type投影结果
user/message原样返回 user message
assistant/message内容非空时返回 assistant message
tool/result返回 tool-result message
其他类型(chunk、边界、header 等)null,直接跳过

特别注意 assistant/chunk 它必须留在日志中,用于 UI 流式回放,但绝不能进入 deriveMessages()。如果 chunk 进入了模型历史,模型会看到一条完整回答被拆成无数碎片,破坏语义。最终的 assistant/message 才是模型历史的稳定单元。

另一个细节: 模型可能输出空内容但携带 usage 信息(例如到达 max-tokens 时)。DSH 仍记录这条 assistant/message,让使用量绑定到那次调用。但 deriveEventMessage() 会跳过空 content,避免给下游模型注入一个没有内容的 assistant turn——这会让后续模型产生困惑。

4.2 为什么要有派生缓存

每一步都从头回放整个日志当然正确,但长会话成本会持续增长。DSH 的 deriveMessages() 利用两项状态做增量更新:

derivedNodes        已经投影到第几个 surface node
replaceGeneration   surface 是否发生过位置替换
  • 如果 Surface 只是追加了新节点(append),只投影新增部分
  • 如果发生了 replace(压缩替换),replaceGeneration 变化,重建派生数组

每次返回的数组本身都是一个新快照,但里面的 Message 对象复用已冻结的日志数据——不做深拷贝。

这带来三个好处:

  1. 避免每次请求都全量重建
  2. 避免调用方持有的旧数组在后续 append 时悄悄变长(每次都返回新数组)
  3. 避免为投影做第二份可变深拷贝,防止污染真源

4.3 性能边界:缓存解决什么,没有解决什么

这里需要澄清两个容易产生的误解:

误解一:“每次调用都扫描全部事件,长会话很慢”

不对。普通 append 路径下,缓存只投影尚未处理的 Surface node。大量 assistant/chunk、Turn/Step 边界和 log-only 插件事件根本不进入 Surface,因此不会逐条转换为 Message。

误解二:“有缓存就足够快了”

也不对。缓存降低的是运行时对象投影成本,但模型本身仍需处理最终 messages 的 token 量。它不会消除模型上下文长度、网络传输或摘要生成成本。

因此准确的表述是:

纯追加:投影工作随新增 Surface node 线性增长
发生 replace:重新投影当前 Surface
日志总事件数:不等于模型历史长度,也不等于本次投影成本

4.4 本节结论

  1. deriveMessages() 把 Surface 节点转换成 Message[],只有三类事件参与:user/messageassistant/messagetool/result
  2. assistant/chunk 永远不进入模型历史——它在 Log 里供 UI 回放用
  3. 增量缓存避免每次全量重建,但只解决投影成本,不解决模型处理 token 的成本
  4. 如果发生 replace,缓存重建;调用方持有的旧数组不会随着新 append 而改变

SOURCESession.deriveMessages() 使用 surface.nodesderivedNodesreplaceGeneration 做增量投影;surface.tsderiveEventMessage() 定义单节点规则。
TESTED(上游测试证据,非本机执行)tests/derived-cache.spec.ts 将增量结果与从头 replay 的结果逐步对比,并验证 replace 后重建、调用方持有的旧数组不变化。


5. 模型请求的另一半:request/headerrequest/context

到第 4 节为止,我们构造出了 request.messages。但一次真实的模型请求,远不止 messages

它还依赖:

  • 用哪个 provider、哪个 model
  • system prompt 是什么内容
  • 暴露了哪些 tool schemas
  • 上下文窗口还有多少容量

这些信息不进入 messages,但模型请求离开 Agent Loop 之前,它们必须被确定下来。DSH 用两个独立的事件类型记录它们:request/headerrequest/context

不作为消息输入

Session Surface

deriveMessages
生成 messages

request/header

请求包络

request/context

上下文治理信息

LLM request

5.1 两者不能合并,因为职责不同

request/headerrequest/context 都与模型相关,但记录的东西不同、用途也不同。

维度request/headerrequest/context
记录什么provider、model、system prompt、tool schemas、adapter 默认项已解析路由的 provider、model、contextWindow
回答什么问题“这次请求实际带了什么输入?”“这个路由能承载多大上下文?”
什么时候写入请求包络变化时(换模型、改 system prompt、tools 变了)路由或容量变化时
谁会用请求重建、header 比较、回放诊断Compaction、token 预算、上下文治理
是否进入 Surface

5.2 一个场景:为什么必须分开

运营方把 deepseek-v4-flash 的上下文窗口从 128K 调整为 64K,但当前请求的 system prompt、tools、model 名称都没有变化。

此时应该发生的事情:

  • request/context.contextWindow 更新为 64K → 上下文治理知道预算变了,触发更早的压缩
  • request/header 不变 → 请求内容没有变化,恢复时不应误以为“用户和模型的包络变了”

如果两者合并,一次容量变化会被错误地解释为请求包络变化,header equality 的语义也会被污染——同样一份请求包络,只是容量变了,为什么要被视为不同的 header?

反过来,如果插件在 agent/request 中切换了模型,或 system prompt 被改写了,那么 request/header 必须变化,因为真正发出的请求包络已经不同。

5.3 为什么存完整 header,而不是差量

DSH 记录完整 request/header,而不是“这次变了什么”。

如果只记录差量,恢复时需要按顺序重放一长串 patch 才能重建当前 header。只要中间有一条缺失或损坏,整个重建就失败了。

存完整 header 的好处是:恢复时只需要取最后一个快照,不依赖任何 patch 链。用冗余换可靠性,在 Agent 恢复场景下是值得的。

foldRequestHeader() 负责从多个 header 事件中取最后一个有效快照——如果有多个 header,说明请求包络曾经变化过;恢复时只需要最新那个,不需要知道中间经历了什么。

5.4 本节结论

  • deriveMessages() 只构造了模型请求的对话历史(messages
  • 完整的模型请求还需要:模型路由、system prompt、tool schemas(request/header)和上下文容量信息(request/context
  • 两者分开记录,因为变化频率和用途不同
  • request/header 存完整快照而非差量,保证恢复时不依赖 patch 链

6. 一个完整例子:日志、Surface 与完整请求如何分叉

前五节分别讲了 Log、Surface、deriveMessages()request/headerrequest/context。这一节用一个完整的“查天气后给建议”例子,把所有概念串起来,看同一个事件在不同视图中如何分叉。

6.0 完整事件序列

以下是“查天气后给建议”这个任务产生的简化事件序列:

seq事件进入 messages属于请求的哪一层
0turn/start执行边界
1user/message: 查北京天气messages 的用户输入
2request/headerStep 1 的 provider/model/system/tools 包络
3assistant/chunk: 我来查询流式过程
4assistant/message: tool-call(get_weather)messages 的稳定助手输出
5tool/call原始工具调用审计事实
6tool/result: 晴,25°Cmessages 的工具结果;也可能产生下一 Step 上下文
7step/endStep 1 边界
8request/headerStep 2 的包络快照,仅在与上一份不同才追加
9assistant/chunk: 建议穿短袖流式过程
10assistant/message: 建议短袖+薄外套messages 的最终助手输出
11turn/endTurn 边界

这张表回答了两个关键问题:

第一,哪些事件进入了 messages
只有三类:user/message(seq 1)、assistant/message(seq 4、seq 10)、tool/result(seq 6)。其他全部被过滤。

第二,其他事件去了哪里?

事件去处用途
turn/startstep/endturn/endLog恢复时判断边界是否平衡
assistant/chunkLogUI 打字机回放
tool/callLog审计时查看原始调用参数
request/headerLog恢复时重建模型配置

因此,下一次模型请求的 deriveMessages() 只会导出:

用户:查北京天气
助手:调用 get_weather
工具:晴,25°C
助手:建议短袖+薄外套

但 UI 回放仍可读取 seq 3 和 seq 9,显示逐字输出效果;审计仍可读取 seq 5,知道模型原始调用的 name 与 arguments;恢复逻辑仍能读取 seq 0、7、11,判断 Turn/Step 边界是否平衡。

同一个 Event Log,同时服务了模型、UI、审计、恢复四个不同消费者。

6.1 回到第四篇:工具结果为什么能在崩溃后继续驱动下一 Step?

第四篇已经追过这条运行链:executeToolCalls() 产生 tool/result,并把工具给出的 additionalContexts 写入 Inbox.nextStep;随后 Loop 领取 next-step,开始同一 Turn 的下一 Step。

本篇补上此前未展开的恢复部分:这不是一个只靠内存数组的“工具返回后继续 while 循环”。它有两条必须同时存在的持久事实:

tool/result                 = 外部执行最终给模型的结果是什么
agent/inbox/spliced         = 哪些 additionalContexts 已经进入 next-step、尚未被领取

假设工具成功返回后、下一次模型请求前进程崩溃:

恢复时看到的情况结果
只有 tool/result,没有持久化 Inbox splice恢复后能看到结果,却不知道它是否已排队进入下一 Step
只有 Inbox splice,没有 tool/result恢复后知道有待办上下文,却无法解释这个上下文来自哪次工具执行
两者都在连续日志中恢复路径能重建模型结果和未领取工作,Agent Loop 安全续跑

这就是第 4 篇的“工具结果入队”依赖本篇 Session 设计的原因。

更准确地说,Session 并不替 Inbox 自动持久化;Inbox.mutate() 本身通过 session.append('agent/inbox/spliced', ...) 把调度变化写进同一真源。Session 为 Loop、Inbox、Tool Runtime 提供了共享的恢复语言——让“工具结果入队”这个动作,成为可恢复的持久事实,而不仅仅是内存中的一次操作。

6.2 本节结论

  1. 同一个 Log 事件,根据消费者不同,走向完全不同的路径:模型看 messages,UI 看 chunk,审计看 tool/call,恢复看边界
  2. tool/resultagent/inbox/spliced 必须同时存在,才能保证工具结果在进程崩溃后可恢复续跑
  3. Session 为 Agent Loop、Inbox、Tool Runtime 提供了共享的恢复语言,让“工具结果入队”成为可恢复的持久事实,而不仅仅是内存中的一次操作

下一节:恢复和 Fork——如何从持久化的 Log 中重建 Session,以及为什么 fork boundary 不能落在一个 open Turn 内。

SOURCEpackages/core/agent-loop/src/tool-calls.ts 将工具结果写为 tool/result 并把 additionalContexts 交回 Loop;packages/core/agent/src/inbox.ts 将入队/领取操作写为 agent/inbox/spliced
SOURCE:第四篇已追踪 next-step 在同一 Turn 中被下一 Step 领取;本篇补充它跨进程恢复所需的持久边界。


7. 恢复:不是把旧数组读回来,而是重新建立可信边界

进程恢复时,不能盲目相信磁盘上的数据就是完整的、可用的。Session 恢复时会执行五层检查,只有全部通过,才接受这段历史。

7.0 恢复时的五层检查

Session.fromRestore() 在构造时执行以下检查:

  1. header 与事件都是可用的 JSON 数据
  2. seq 从 0 开始连续,没有缺失或跳号
  3. 每个已知核心事件的形状符合要求
  4. Surface 的 append/replace 转换能完整重放
  5. 请求 header 等关键兼容性规则没有被破坏

如果任意一项检查失败,系统拒绝恢复这个 Session。

“不完整的数据不如没有数据”——如果恢复了残缺的历史,后续模型请求可能建立在错误的上下文上,导致不可预测的行为。宁可拒绝恢复,也不让不可信数据进入系统。

7.1 session/end-seed:旧历史与新动作的分界线

先排除一个错误理解:session/end-seed 不是跨进程锁、租约或心跳。它不证明父进程或另一个 writer 是否存活,也不解决并发写入竞争。

它是一条本次构造的历史边界

当 Session 从历史恢复或被 fork 时,构造函数在 seed 末尾追加 session/end-seed,标记“这之前是继承来的历史,这之后是本次生命周期的新写入”。

这个标记解决了一个真实歧义:若 seed 最后有一个插件自己的 start 标记,却没有对应 end,它在新 Session 中是“历史遗留的未闭合动作”,还是“当前进程刚刚开始的动作”?

session/end-seed 让拥有该事件族的插件可以明确判断:位于 marker 之前的未闭合动作属于旧生命周期,不应继续等待;位于 marker 之后的动作属于当前进程。

7.2 持久化与内存日志是两件不同的事

Session.append() 的热路径不等待磁盘 I/O。SessionStore 发布 session/event 事件,持久化插件订阅并异步缓冲。

这意味着两个不同的承诺:

append 成功 = 事件已进入本次运行的可信内存日志
flush 成功  = 持久化 listener 已完成耐久检查点

如果混为一谈,要么每个 token chunk 都阻塞 I/O,要么调用方错误地以为“内存里有就等于存好了”。

7.3 崩溃窗口:append 后、flush 前可能丢失数据

这是必须说清楚的边界:单次 Session.append() 成功后,如果进程在后台批处理真正持久化前硬崩溃,该事件可能只存在于内存,重启后丢失。

持久化插件采用 write-behind 批处理:首个待写事件开启固定窗口,窗口内事件合并成一个批次;flush() 取消等待、排空当前和后续批次,并等待所有 session/flush listener 完成。

这不是 DSH 的漏洞,而是吞吐量与耐久性的主动取舍。 如果每个 assistant/chunk 都同步刷盘,Agent 响应速度会被严重拖慢。

但 checkpoint policy 在关键语义边界上建立了 fail-closed 屏障:

边界先 flush 什么为什么值得等待
模型请求前已进入本次请求的输入与 header崩溃后不能丢失“模型已经看到的上下文”
顶层工具 body 执行前对应工具调用事实外部副作用开始前,日志必须先留下可审计意图
agent/pre-step 进入下一步前上一响应与有序工具结果下一次模型请求不能建立在未持久化的前一步之上

如果 checkpoint 失败,策略在这些边界上 fail-closed:不发模型请求、不进入工具 body,而不是抱着“内存里还有”继续执行。

这又回到了第三篇的 Fiber/Effect:持久化插件的事件订阅、后台 controller 与 dispose 都有明确的 Fiber owner。Session 或插件树卸载时,持久化实现需要排空已接纳的批次;它不是依赖某个全局守护线程“恰好还活着”。

另一类崩溃发生在数据已经落盘、但 Turn 尚未关闭时。恢复后端不会删除已提交前缀,而是保留已持久的事件,并为未闭合的尾部补上 { kind: 'interrupted' }turn/end;若工具调用已有 tool/call 却没有 result,恢复逻辑会明确标识结果未知,要求根据工具幂等性或外部状态决定是否重试。

7.4 本节结论

用准确的承诺总结本节:

未到 checkpoint 的内存事件:可能在硬崩溃中丢失
已成功 flush 的连续批次:后端必须可恢复
已落盘但未闭合的执行尾部:保留并以 interrupted 语义修复
关键模型/副作用边界:第一方 checkpoint policy 选择先 flush、失败则拒绝继续

SOURCESession.fromRestore() 与构造函数验证 seed;SessionStore.flush() 统一分派 session/flush
DOCdocs/subsystems/persistence.zh.md 描述后台批处理、flush 的完全停稳语义与 interrupted tail 修复;packages/session/session-checkpoint-policy/README.zh.md 定义模型、工具和 pre-step 的语义 checkpoint。


8. Fork:分支的不是可变内存,而是一个稳定日志前缀

Fork 是 DSH 中另一个与恢复相关的概念。恢复(restore)是从磁盘读取当前 Session 的历史;fork 则是从另一个已存在的 Session 复制一份历史,生成一个新的 Session。

8.0 fork 解决什么问题?

想象一个场景:父会话进行到一半,你想让一个子 Agent 或另一个分支从同一个历史点继续,而不影响父会话的状态。

  • 直接复制 messages 数组不够,因为 messages 只包含对话内容,不包含边界、请求 header、待办状态
  • 让子会话从磁盘重新读取整个历史也不对,因为子会话需要的是“当前这个时间点之前的历史”,而不是全部原始日志

DSH 的 fork 做的就是这件事:从一个已有 Session 复制一份从 seq 0 到某个稳定边界的事件前缀,作为新 Session 的种子。 新 Session 从那个历史点开始,继续写自己的新事件,完全不影响父 Session。

复制 0..boundary

父 Session 完整日志

子 Session seed

session/end-seed

子会话自己的新事件

8.1 fork 的两个核心限制

限制一:boundary 不能落在 open Turn 内

fork 的边界必须是一个已经闭合的 Turn(即有 turn/start 也有对应的 turn/end)。

如果 fork 复制了一段没有收尾的 Turn(有 turn/start 但没有 turn/end 或对应的 step/end),子 Session 会遇到两个问题:

问题后果
无法确定模型上下文是否完整不知道是否有工具调用还没返回结果
无法安全决定该继续还是修复既不能假设 Turn 已完成(可能确实没完成),也不能假设它还在进行(进程已崩溃)

因此,DSH 会在 fork 时检查 boundary 之前最后一个 Turn 的边界:如果最后看到的是 turn/start 且没有对应的 turn/end,拒绝 fork 并抛出 OPEN_TURN

限制二:fork 不继承运行时能力

fork 复制的是历史事件(Log),但不会复制:用哪个模型、有哪些工具、system prompt 是什么、权限策略是什么。

历史继承和能力继承是两件不同的事。子 Session 可能用不同的工具集、不同的模型来处理同一段历史。

DSH 把 agentPreset 等组装相关元数据放在 Session header 中,正是为了避免子会话在不同能力组合下盲目重放旧历史。第 8 篇讨论 Subagent 时会继续展开“历史继承”与“能力继承”为什么是两件不同的事。

8.2 与第 7 节的关联

fork 和 restore 都是“从已记录的历史构造 Session”,但来源不同:

操作从哪来用途
restore(恢复)从磁盘读取当前 Session 的历史进程重启后继续原来的对话
fork(分支)从另一个已存在的 Session 复制历史从一个中间点“分叉”出新的对话分支

fork 和 restore 共享同一个底层机制:Session.fromRestore()_forkSeed() 都执行相同的五层检查(seq 连续性、事件形状、Surface 可重放性等),确保恢复和分支都得到同样可信的种子历史。

8.3 本节结论

  • fork 是从一个已有 Session 复制一份“自洽的、已闭合的 Turn 前缀”作为新 Session 的种子
  • 新 Session 从那个历史点开始,继续写自己的新事件,不影响父 Session
  • fork boundary 不能落在 open Turn 内——子 Session 需要一段明确收尾的历史,才能安全地继续推进
  • fork 不继承运行时能力(模型、工具、prompt、权限)——这些由 Profile/Bundle 独立决定

SOURCEpackages/core/session/src/index.tsSessionStore.fork()_forkSeed() 检查 boundary、连续 seq 和 open Turn。
TESTED(上游测试证据,非本机执行)tests/fork.spec.ts 覆盖从较早 closed boundary fork、拒绝 open Turn 内边界、以及 child header 中的 parentSession / seedLength


9. 本篇结论

现在可以更精确地解释 DSH 的核心原则:

所有会影响模型请求的内容,都必须能由 Session Log 与已记录的 request header 重建;但不是所有日志事件都应该重新发给模型。

这句话拆开包含六层含义:

  1. Event Log 是唯一真源:追加后不可回写,所有事实都基于它派生
  2. Surface 是受控目录:它决定模型“现在能看到什么”,不等于完整日志
  3. deriveMessages() 只投影三类事件user/message、非空 assistant/messagetool/result
  4. 非模型事件保留在 Log 中assistant/chunktool/call、边界、header 服务于回放、诊断与恢复
  5. Compaction 用 replace 改变视图,不删除历史:被替换的旧事件仍保留在 Log 中,来源关系可追溯
  6. 恢复与 fork 只接受验证通过的历史:连续性、结构完整、Surface 不变量——不通过则拒绝

如果你能在纸上画出 Log → Surface → Messages → Request 这条链,并说清楚 chunk、tool-call、tool-result、summary 分别落在哪一层,就已经掌握了 DSH Session 设计中最值得迁移的部分。

10. 闭卷复述

不看文章,回答下面五题:

  1. 为什么 assistant/chunk 必须写入日志,却不能进入 deriveMessages()
  2. Log、Surface、Message[] 三者分别解决什么问题?
  3. surfaceOp: 'append'surfaceOp: { op: 'replace' } 的区别是什么?replace 是否删除旧事件?
  4. request/header 为什么不直接塞进 system message,而要独立记录?
  5. 为什么 fork boundary 不能落在一个 open Turn 内?

能完整回答后,再回看这四个源码入口:

packages/core/session/src/index.ts       Session.append() / deriveMessages()
packages/core/session/src/surface.ts     SurfaceManager / deriveEventMessage()
packages/core/session/tests/session.spec.ts
packages/core/session/tests/fork.spec.ts

下一篇:工具、权限和沙箱,Agent 如何被允许,也如何被拒绝

有了 Agent Loop 与 Session,我们已经知道任务怎样推进、事实怎样留下。下一篇进入行动边界:模型提出一次工具调用后,Tool Runtime 如何验证 schema、进入 policy、申请审批、执行或拒绝,并最终把结果写回 Session。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐