DeepSeek Harness Agent 为什么不能只存聊天记录:Session、上下文与恢复
上一章里,我们反复看到 session.append(...):turn/start、user/message、assistant/chunk、tool/result、turn/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/message、assistant/message、tool/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 / Bundle | dsh-base 把 Session、持久化与 checkpoint policy 作为可组装能力装入产品 | 不同产品可以选不同后端或策略,但不能绕过统一日志语义 |
| 第 3 篇:Cordis | Service、Event、Effect 和 Fiber 让能力有依赖与所有权 | ctx.sessions 是能力 seam;持久化监听器与会话生命周期必须可撤销、可等待 |
| 第 4 篇:Agent Loop | Inbox、Turn、Step 驱动任务;工具结果进入 next-step 才能续跑 | 崩溃后怎样知道哪些输入、工具结果和步骤仍然有效,且能继续推进? |
所以本篇研究的不是“聊天记录怎么存”,而是:第 4 篇的执行状态如何成为可验证、可恢复的事实。
第四篇中的 Agent Loop 决定“任务如何推进”:何时领取消息、何时请求模型、何时调用工具、何时结束 Turn。Session 不负责替 Loop 做这些决策;它负责把已经发生的事实以稳定、可验证、可重放的形式留下来。
| Agent Loop 中的动作 | Session 中留下的事实 | 以后谁会使用 |
|---|---|---|
| 打开任务 | turn/start | 恢复、审计、状态投影 |
| 放行一批输入 | user/message | 下一次模型历史、Transcript |
| 接收流式输出 | assistant/chunk | UI 流式回放、调试 |
| 汇总一次模型结果 | assistant/message | 模型历史、Transcript |
| 执行工具 | tool/call / tool/result | 下一 Step、UI、回放 |
| 结束或失败 | step/end / turn/end | 恢复、错误定位、任务状态 |
这里需要先建立一个边界:
agent/*、tools/* 等实时事件:控制正在发生的运行
SessionEvent:已经提交、可恢复的历史事实
例如 agent/pre-step 可以拒绝一个候选输入,但它本身不是历史事实;若拒绝最终导致 Turn 被阻断,turn/end { kind: 'blocked' } 才是需要留下的结果。
DOC:docs/architecture.zh.md 明确区分持久 Session Event 与实时 Agent/能力事件。
SOURCE:packages/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,这个方案会丢掉至少四类信息:
- 过程:流式 chunk 的到达顺序、工具请求的原始 arguments、一次请求的使用量;
- 边界:某个 Turn/Step 是否已经开始、结束、失败或被取消;
- 请求环境:模型路由、系统提示词、工具 schema;
- 替换历史:压缩前模型看过什么、压缩后现在应看什么。
所以,Message[] 只能满足“当前这一次模型请求需要什么”,但无法满足“UI 回放”“审计追溯”“进程恢复”“压缩后溯源”这四个同样重要的需求。
1.1 DSH 的做法:Log → Surface → Messages 三层分离
DSH 的答案是:不直接维护 Message[],而是建立一条仅追加的 Event Log,然后从这条日志派生出不同的视图。
这三层的名字和职责分别是:
| 层级 | 名字 | 它是什么 | 存储什么内容 | 谁在用 |
|---|---|---|---|---|
| 第 1 层 | Log(事件日志) | 唯一的真实事实源,存所有发生的事 | turn/start、user/message、assistant/chunk、assistant/message、tool/call、tool/result、request/header、step/end、turn/end、agent/inbox/spliced 等全部事件 | 所有下游(UI、模型、审计、恢复) |
| 第 2 层 | Surface(模型可见序列) | 模型看到的事件序列,是 Log 的受控投影 | 只取三类事件:user/message、assistant/message、tool/result | deriveMessages() |
| 第 3 层 | Messages(消息数组) | 大模型 API 要求的 Message[] 格式 | 从 Surface 节点按规则转换成 API Message | 模型请求 |
用一句话记忆就是:
Log 是事实库,Surface 是模型上下文的目录,Messages 是目录投影出的请求材料。
1.2 同一份 Log,如何派生出不同视图
下图展示了 Log 如何同时服务于多种需求:
图中有几条关键路径:
| 路径 | 用途 | 典型消费者 |
|---|---|---|
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 小结
用两句话总结这节的全部内容:
- DSH 不直接维护
messages数组,而是维护一条完整的 Event Log,然后从 Log 派生 Surface,再从 Surface 派生 Messages。 - 之所以要三层,是因为同一个历史事实需要服务于模型、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/start、step/start、step/end、turn/end | 任务推进到哪里,属于框架内部控制信息 |
| 模型可见消息 | user/message、assistant/message、tool/result | 三类事件可以进入 Surface,成为模型历史 |
| 过程保真 | assistant/chunk、tool/call | UI 回放和诊断需要,但不直接进入模型历史 |
| 请求状态 | request/header、request/context | 还原请求的配置与路由信息,不进入模型历史 |
| 调度状态 | agent/inbox/spliced | 待办输入的插入、领取、取消,不进入模型历史 |
| 插件扩展 | compaction/*、hook/* 等 | 插件自定事件,默认只是日志事实,由插件自己定义用途 |
核心区分只有两类:进入 Surface 的(模型可见)和不进入 Surface 的(模型不可见)。
DSH 允许插件定义自己的事件类型,但框架对它们不做任何“猜测”——不会自动塞进模型历史,也不会自动显示在 UI 上。插件写了什么事件,由插件自己决定怎么用。框架只负责“存起来”,不负责“替它做决定”。它只是被存下来了。插件如果想要这个事件影响模型(比如变成一条消息)、或者显示在 UI 上、或者影响某个业务流程——需要插件自己写逻辑来处理。框架不替它做决定。
2.3 写入前的四层验证:为什么“先检查再写入”
Session.append() 的执行顺序不是简单的“收到数据 → 写入日志”。它经历了一个严格的验证链:
图中的四层保护,每一层解决一个具体问题:
第一层:值必须可无损 JSON 化
函数、BigInt、循环引用、Map、Date、undefined、非有限数字(NaN、Infinity)都不能进入日志。
为什么?因为这些值无法被标准 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 提供 - 每个事件有
type、seq、time、data四个固定字段 - 六类核心事件中,只有三类(
user/message、assistant/message、tool/result)进入 Surface - 写入前经过四层验证:JSON 可序列化、序号连续、Surface 合法、先提交再通知
- 这些验证的目的是把错误暴露在“写入时”,而不是“恢复时”
SOURCE:packages/core/session/src/index.ts 的 Session.append() 先执行 snapshotJsonValue()、surfaceManager.validateNext(),再 log.push() 与 session/event 发布。
SOURCE:Session.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/start、step/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 本节结论
- 模型只应该看到三类事件:
user/message、assistant/message、tool/result - 这三类事件进入 Surface 时,必须声明
surfaceOp:append(追加)或replace(替换) replace用于上下文压缩——用摘要替换旧内容,但 Log 里的旧事件不动replace经过四条验证,保证摘要来源可追溯,不可伪造历史
4. deriveMessages():从 Surface 到模型请求历史
上一节确定了 Surface 包含哪些节点(user/message、assistant/message、tool/result 三类)。这一节回答:Surface 节点如何转换成大模型 API 要求的 Message[] 格式?
当 Agent Loop 要构造下一次模型请求时,它调用:
session.deriveMessages()
它不扫描全部 Event Log,也不把 Surface 节点原样输出。它按当前 surface.nodes 的顺序,逐个投影成 Message 对象。
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 对象复用已冻结的日志数据——不做深拷贝。
这带来三个好处:
- 避免每次请求都全量重建
- 避免调用方持有的旧数组在后续 append 时悄悄变长(每次都返回新数组)
- 避免为投影做第二份可变深拷贝,防止污染真源
4.3 性能边界:缓存解决什么,没有解决什么
这里需要澄清两个容易产生的误解:
误解一:“每次调用都扫描全部事件,长会话很慢”
不对。普通 append 路径下,缓存只投影尚未处理的 Surface node。大量 assistant/chunk、Turn/Step 边界和 log-only 插件事件根本不进入 Surface,因此不会逐条转换为 Message。
误解二:“有缓存就足够快了”
也不对。缓存降低的是运行时对象投影成本,但模型本身仍需处理最终 messages 的 token 量。它不会消除模型上下文长度、网络传输或摘要生成成本。
因此准确的表述是:
纯追加:投影工作随新增 Surface node 线性增长
发生 replace:重新投影当前 Surface
日志总事件数:不等于模型历史长度,也不等于本次投影成本
4.4 本节结论
deriveMessages()把 Surface 节点转换成Message[],只有三类事件参与:user/message、assistant/message、tool/resultassistant/chunk永远不进入模型历史——它在 Log 里供 UI 回放用- 增量缓存避免每次全量重建,但只解决投影成本,不解决模型处理 token 的成本
- 如果发生
replace,缓存重建;调用方持有的旧数组不会随着新 append 而改变
SOURCE:Session.deriveMessages() 使用 surface.nodes、derivedNodes、replaceGeneration 做增量投影;surface.ts 的 deriveEventMessage() 定义单节点规则。
TESTED(上游测试证据,非本机执行):tests/derived-cache.spec.ts 将增量结果与从头 replay 的结果逐步对比,并验证 replace 后重建、调用方持有的旧数组不变化。
5. 模型请求的另一半:request/header 与 request/context
到第 4 节为止,我们构造出了 request.messages。但一次真实的模型请求,远不止 messages。
它还依赖:
- 用哪个 provider、哪个 model
- system prompt 是什么内容
- 暴露了哪些 tool schemas
- 上下文窗口还有多少容量
这些信息不进入 messages,但模型请求离开 Agent Loop 之前,它们必须被确定下来。DSH 用两个独立的事件类型记录它们:request/header 和 request/context。
5.1 两者不能合并,因为职责不同
request/header 和 request/context 都与模型相关,但记录的东西不同、用途也不同。
| 维度 | request/header | request/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/header 和 request/context。这一节用一个完整的“查天气后给建议”例子,把所有概念串起来,看同一个事件在不同视图中如何分叉。
6.0 完整事件序列
以下是“查天气后给建议”这个任务产生的简化事件序列:
| seq | 事件 | 进入 messages? | 属于请求的哪一层 |
|---|---|---|---|
| 0 | turn/start | 否 | 执行边界 |
| 1 | user/message: 查北京天气 | 是 | messages 的用户输入 |
| 2 | request/header | 否 | Step 1 的 provider/model/system/tools 包络 |
| 3 | assistant/chunk: 我来查询 | 否 | 流式过程 |
| 4 | assistant/message: tool-call(get_weather) | 是 | messages 的稳定助手输出 |
| 5 | tool/call | 否 | 原始工具调用审计事实 |
| 6 | tool/result: 晴,25°C | 是 | messages 的工具结果;也可能产生下一 Step 上下文 |
| 7 | step/end | 否 | Step 1 边界 |
| 8 | request/header | 否 | Step 2 的包络快照,仅在与上一份不同才追加 |
| 9 | assistant/chunk: 建议穿短袖 | 否 | 流式过程 |
| 10 | assistant/message: 建议短袖+薄外套 | 是 | messages 的最终助手输出 |
| 11 | turn/end | 否 | Turn 边界 |
这张表回答了两个关键问题:
第一,哪些事件进入了 messages?
只有三类:user/message(seq 1)、assistant/message(seq 4、seq 10)、tool/result(seq 6)。其他全部被过滤。
第二,其他事件去了哪里?
| 事件 | 去处 | 用途 |
|---|---|---|
turn/start、step/end、turn/end | Log | 恢复时判断边界是否平衡 |
assistant/chunk | Log | UI 打字机回放 |
tool/call | Log | 审计时查看原始调用参数 |
request/header | Log | 恢复时重建模型配置 |
因此,下一次模型请求的 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 本节结论
- 同一个 Log 事件,根据消费者不同,走向完全不同的路径:模型看
messages,UI 看 chunk,审计看tool/call,恢复看边界 tool/result和agent/inbox/spliced必须同时存在,才能保证工具结果在进程崩溃后可恢复续跑- Session 为 Agent Loop、Inbox、Tool Runtime 提供了共享的恢复语言,让“工具结果入队”成为可恢复的持久事实,而不仅仅是内存中的一次操作
下一节:恢复和 Fork——如何从持久化的 Log 中重建 Session,以及为什么 fork boundary 不能落在一个 open Turn 内。
SOURCE:packages/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() 在构造时执行以下检查:
- header 与事件都是可用的 JSON 数据
seq从 0 开始连续,没有缺失或跳号- 每个已知核心事件的形状符合要求
- Surface 的 append/replace 转换能完整重放
- 请求 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、失败则拒绝继续
SOURCE:Session.fromRestore() 与构造函数验证 seed;SessionStore.flush() 统一分派 session/flush。
DOC:docs/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。
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 独立决定
SOURCE:packages/core/session/src/index.ts 的 SessionStore.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 重建;但不是所有日志事件都应该重新发给模型。
这句话拆开包含六层含义:
- Event Log 是唯一真源:追加后不可回写,所有事实都基于它派生
- Surface 是受控目录:它决定模型“现在能看到什么”,不等于完整日志
deriveMessages()只投影三类事件:user/message、非空assistant/message、tool/result- 非模型事件保留在 Log 中:
assistant/chunk、tool/call、边界、header 服务于回放、诊断与恢复 - Compaction 用 replace 改变视图,不删除历史:被替换的旧事件仍保留在 Log 中,来源关系可追溯
- 恢复与 fork 只接受验证通过的历史:连续性、结构完整、Surface 不变量——不通过则拒绝
如果你能在纸上画出 Log → Surface → Messages → Request 这条链,并说清楚 chunk、tool-call、tool-result、summary 分别落在哪一层,就已经掌握了 DSH Session 设计中最值得迁移的部分。
10. 闭卷复述
不看文章,回答下面五题:
- 为什么
assistant/chunk必须写入日志,却不能进入deriveMessages()? - Log、Surface、
Message[]三者分别解决什么问题? surfaceOp: 'append'与surfaceOp: { op: 'replace' }的区别是什么?replace 是否删除旧事件?request/header为什么不直接塞进 system message,而要独立记录?- 为什么 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。
更多推荐



所有评论(0)