☰
DeepSeek Harness 解析(五):Session 与 Event Log——从事件溯源到历史重建
2026/10/29 20:03:30 网站建设 项目流程

在前四篇中,我们已经逐步理解了

1.DSH 的核心执行架构

2.Cordis 负责组织插件与服务

3.Agent Loop 负责推动 Turn 和 Step

4.Tool Runtime 负责查找、检查、调度并执行工具

但到这里,还有一个问题没有解决。

当 Agent 执行了多个步骤,甚至调用工具修改了真实文件之后,系统怎样知道之前究竟发生了什么?

假设用户提出

读取项目配置,将 Spring Boot 端口修改成 9090,并确认项目正常启动。

Agent 开始工作:

用户提出任务 ↓ 读取 application.yml ↓ 模型分析配置 ↓ 修改 application.yml ↓ 运行项目 ↓ 项目启动失败

如果程序此时突然崩溃,重新启动后又该怎么办?

是重新读取配置?

重新执行修改?

还是根据之前的执行结果分析问题?

如果我们只有一份简单的聊天记录,足以作出这些判断吗?

如果不能,那什么样的记录可以支撑我们做出判断?

这正是第五篇要研究的核心:Session 与 Event Log。


01|为什么不能只用 messages 数组保存 Agent 历史?

首先,考虑一个最简单的聊天程序。

我们可以使用:

const messages = [ { role: 'user', content: '你好' }, { role: 'assistant', content: '你好,有什么可以帮助你?' } ]

这种结构能够保存基本对话。

但是,当普通聊天程序升级成 Agent 后,执行过程中还会产生其他事实。

例如:

用户要求读取文件 ↓ 模型提出 read Tool Call ↓ Harness 记录工具调用 ↓ Tool Runtime 开始执行 ↓ 工具返回文件内容 ↓ Agent Loop 进入下一 Step ↓ 模型生成最终回答

如果我们只记录最终对话:

user:读取 README.md,并总结项目架构 assistant:这是项目的架构总结……

那么中间的执行细节就无法从这两条消息中还原。

例如:

  • 模型调用了哪些工具?

  • 哪次工具调用失败了?

  • 工具结果是否已经记录?

  • 程序崩溃时处于哪个 Step?

  • 当时模型有哪些工具可以使用?

需要注意,现代 LLM 消息协议本身也可能支持 Tool Call 和 Tool Result。

所以这里并不是说messages数组天然不能保存工具消息,而是说:模型消息历史与完整的 Agent 执行历史,表达的范围不同。

模型消息历史负责描述模型看到了什么。

完整的 Agent 执行历史需要描述运行过程中记录了哪些事实。

因此,DSH 采用了事件溯源设计。


02|Session 与 Event Sourcing:为什么保存事件,而不是只保存当前状态?

先看两种设计方案。

方案 A:保存当前状态

假设 Agent 正在执行第 3 个 Step。

{ "turn": 1, "step": 3, "status": "failed" }

这种方式能够告诉我们当前保存的状态。

但是,单凭这些字段无法判断:

Step 1 执行了什么。

Step 2 有没有调用工具。

Step 3 为什么失败。

如果想回答这些问题,就需要额外保存更多历史信息。

方案 B:保存事件历史

另一种方案是,每发生一件事情,就追加一条事件。

seq=0 turn/start seq=1 step/start seq=2 user/message seq=3 assistant/message seq=4 tool/call seq=5 tool/result seq=6 step/end seq=7 step/start ...

与方案 A 相比,它能够告诉我们:

发生了哪些事情,才形成当前状态。

这就是 Event Sourcing(事件溯源)的核心思想。

2.1 DSH 源码怎样体现事件溯源?

文件位置:

packages/core/session/src/index.ts

DSH 真实源码关键节选:

export class Session { // ① 保存已经接纳的 SessionEvent // 这是一个只追加的事件日志 private log: SessionEvent[] = [] // ② SurfaceManager 负责维护 // 从事件日志派生的模型可见消息视图 private readonly surfaceManager: SurfaceManager // ③ 暴露当前的 Surface get surface(): SessionSurface { return this.surfaceManager } // 其他成员与方法省略 }

Session 源码

这里最重要的是:

private log: SessionEvent[] = []

它说明 Session 内部维护的是SessionEvent日志,而不是单纯的聊天消息数组。

以后需要模型历史时,可以从日志派生。

需要检查工具调用时,可以检查相应事件。

需要恢复请求头时,可以对请求头事件进行折叠计算。

所以:

Session Event Log │ ┌───────────┼───────────┐ │ │ │ ▼ ▼ ▼ 消息历史 请求头状态 执行历史分析

这些信息可以基于同一份事件事实来源重建。

这里也要注意:

Session 是内存中的事件溯源对象。若要在进程崩溃后仍然保留日志,需要配置相应的持久化后端,并满足必要的持久化检查点条件。

事件溯源本身不等于已经写入磁盘。


03|SessionEventMap:为什么需要不同的事件类型?

现在我们知道 Session 保存事件。

新的问题是:

怎样区分一次执行中发生的不同事情?

如果全部记录为:

{ type: 'event', message: '发生了一件事情' }

以后程序仍然很难可靠识别这件事情属于什么类型。

因此,DSH 定义了类型化的事件集合。

文件位置:

packages/core/session/src/types.ts

DSH 真实源码关键节选:

export interface SessionEventMap { // ① 一次 Turn 开始 'turn/start': { turn: number } // ② 一次 Step 开始 'step/start': { turn: number step: number } // ③ 用户消息 'user/message': UserMessage // ④ 模型产生的完整消息 'assistant/message': { turn: number step: number message: AssistantMessage stream: AssistantStreamRecord[] usage?: TokenUsage interrupted?: true } // ⑤ 一次未形成模型可见消息的模型尝试 // 可以保留失败、取消或重试等尝试的流记录 'assistant/attempt': { turn: number step: number stream: AssistantStreamRecord[] } // ⑥ 模型提出的一次工具调用 'tool/call': { turn: number step: number callId: ToolCallId name: string arguments: string } // ⑦ 工具结果 'tool/result': { turn: number step: number message: ToolResultMessage error?: { name: string code: string reason?: string } meta?: JsonValue } // ⑧ 一个 Step 结束 'step/end': { turn: number step: number } // ⑨ 一个 Turn 结束,带有结束原因 'turn/end': { turn: number reason: TurnEndReason } // request/header、request/context 等事件省略 }

SessionEventMap 源码

这里可以发现,每一种事件都有明确职责。

例如:

事件保存什么事实
turn/start一个轮次已经开始
step/start一个步骤已经开始
assistant/message模型形成的消息及相关流记录
assistant/attempt一次未形成正式消息的模型尝试
tool/call某次工具调用请求被记录
tool/result某次工具调用的结果被记录
turn/end一个轮次已按某种原因结束

为什么要这样设计?

因为不同事件代表的事实不同,程序可以对不同事件采用不同的处理规则。

例如:

// 教学伪代码:说明按事件类型处理历史 switch (event.type) { case 'tool/call': // 识别工具调用 break case 'tool/result': // 识别工具结果 break case 'turn/end': // 根据 reason 判断轮次如何结束 break }

需要特别注意:

tool/call被记录,不代表工具的execute()一定已经运行。

tool/result没有出现,也不代表工具一定没有执行。

程序可能在记录调用之后、实际执行之前崩溃,也可能在外部操作已经成功之后、记录结果之前崩溃。

所以:

事件能证明它所记录的事实,但不能证明所有没有记录的事情都没有发生。

这为后面学习故障恢复建立了基础。


04|Session.append():事件怎样真正进入日志?

现在我们已经认识了不同的事件类型。

接下来研究:

一次事件怎样被正式记录?

假设 Agent Loop 执行:

session.append('step/start', { turn: 1, step: 2 })

是否只需要把这个对象放进数组?

实际并没有这么简单。

4.1 先看真实源码

文件位置:

packages/core/session/src/index.ts

DSH 真实源码关键节选:

append<T extends SessionEventType>( type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : [] ): SessionEvent<T> { // ① 读取 Surface 操作选项 const surfaceOpts: SurfaceIntent | undefined = opts[0] // ② 构造 Surface 元数据 // 例如 sourceEventSeqs、surfaceOp const surfaceMetadata = { ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs }, ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp }, } // ③ 为事件数据制作独立快照 const dataSnapshot = snapshotJsonValue(data) // ④ 无法无损表示为 JSON 的数据不予接纳 if (dataSnapshot === undefined) { throw new Error( `session event "${type}" carries non-JSON-serializable data` ) } // ⑤ 对 Surface 元数据也制作快照 const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) if (surfaceMetadataSnapshot === undefined) { throw new Error( `session event "${type}" carries non-JSON-serializable surface metadata` ) } const entry = this.publicationEntry() // ⑥ 构造不可变的事件对象 const event = deepFreeze({ type, // ⑦ 使用当前日志长度生成下一条事件的序号 seq: SessionSeq(this.log.length), // ⑧ 记录事件时间 time: Date.now(), // ⑨ 保存已经快照化的数据 data: dataSnapshot, ...(surfaceMetadataSnapshot as { surfaceOp?: unknown sourceEventSeqs?: unknown }), } as unknown as SessionEvent<T>) // ⑩ 验证事件是否满足相应规则 validateSessionEventData( event, `session event "${type}" at seq ${event.seq}` ) // ⑪ 正式提交事件 this.commit(event as SessionEvent, entry) return event }

Session.append() 源码

这段代码有三个非常重要的设计。

4.2 为什么使用 snapshotJsonValue()?

假设:

const data = { turn: 1, step: 2 } session.append('step/start', data)

之后插件又修改:

data.step = 999

如果 Session 只是保存原对象引用,那么原始对象的变化就可能影响已经记录的历史内容。

因此:

const dataSnapshot = snapshotJsonValue(data)

会制作一个独立的数据快照。

让 Session 保存事件接纳时的数据,而不是长期依赖外部对象的可变引用。

4.3 为什么需要 deepFreeze()?

快照能够隔离原始对象的后续修改。

但如果别的代码拿到了 Session 中的事件对象,又尝试直接修改呢?

例如:

event.data.step = 999

deepFreeze()用于冻结事件对象及其嵌套数据,阻止这类直接修改。

外部数据 │ ▼ snapshotJsonValue() │ ▼ 独立快照 │ ▼ deepFreeze() │ ▼ 已记录的不可变事件

这与事件溯源的设计原则一致。

过去已经记录的事实,不应该因为别的代码继续修改某个对象而发生变化。

4.4 为什么有了 Turn、Step,还需要 seq?

这是一个容易混淆的地方。

例如:

seq=10 turn=1 step=2 tool/call seq=11 turn=1 step=2 tool/result

两条事件都属于 Turn 1、Step 2。

但它们在 Session 中是两条不同的记录。

我们可以这样理解:

字段回答的问题
turn属于哪个任务轮次?
step属于哪个执行步骤?
seq在 Session 日志中位于什么位置?
type记录了哪一种事实?
data这件事情的具体数据是什么?

所以,seq并不是记录“工具成功还是失败”。

它负责标识事件在 Session 日志中的顺序和位置。


05|Tool Call 与 Tool Result:如何建立事件之间的关联?

到这里,我们需要把第四篇的 Tool Runtime 与第五篇的 Session 联系起来。

假设模型同时提出:

read("README.md") read("package.json")

它们都是read。

也可能发生在同一个 Turn 和 Step 中。

那么,系统怎样知道哪个结果属于哪个调用?

5.1 三种标识的区别

我们先看一个简化日志:

seq=10 tool/call name=read callId=call_A file=README.md seq=11 tool/call name=read callId=call_B file=package.json seq=12 tool/result callId=call_A seq=13 tool/result callId=call_B

这里:

name标识工具类型。

callId标识一次具体调用。

seq标识一条具体的 Session 事件。

因此,即使 A 和 B 调用同一种工具,也可以通过不同callId区分。

5.2 DSH 怎样记录 Tool Call?

源码位置:

packages/core/agent-loop/src/tool-calls.ts

DSH 真实源码:

function appendToolCall( session: Session, turn: number, step: number, block: ToolCallBlock ): SessionSeq { // ① 追加 tool/call 事件 const event = session.append('tool/call', { turn, step, // ② 本次工具调用的独立标识 callId: block.id, // ③ 工具名称,例如 read name: block.name, // ④ 模型产生的原始参数 JSON 字符串 arguments: block.arguments, }) // ⑤ 返回这条调用事件的日志序号 // 供后续工具结果引用 return event.seq }

这里的:

callId: block.id

负责标识具体调用。

而:

return event.seq

返回的是调用事件在 Session 日志中的位置。

5.3 Tool Result 怎样关联原始调用?

继续看同一文件。

// ① 构造模型可见的工具结果消息 const message = createToolResultMessage({ // ② 使用与原始调用相同的 callId callId: block.id, content: result.content, isError: result.isError, }) // ③ 追加工具结果事件 session.append('tool/result', { turn, step, message, // 失败信息和展示元数据的处理省略 }, { // ④ 把结果加入模型可见的 Surface surfaceOp: 'append', // ⑤ 将结果关联到原始工具调用事件 sourceEventSeqs: [callSeq], })

以上为源码局部节选。

tool-calls.ts 源码

注意:

sourceEventSeqs: [callSeq]

这里建立了工具结果与原始调用事件之间的来源关系。

因此:

tool/call seq=10 callId=A │ │ 关联 ▼ tool/result seq=12 callId=A sourceEventSeqs=[10]

Session 不仅记录事件先后顺序,还能够保存事件之间的关联关系。

但仍然不能忘记:

工具调用记录存在,不等于外部操作已确认成功;最终成功与否需要结合可用的结果记录以及外部事实判断。


06|Surface 与 deriveMessages():为什么模型看不到全部 Event Log?

现在我们已经知道,Session 保存了大量事件。

但新的问题是:

每次请求模型时,是否需要把全部 SessionEvent 都交给 LLM?

例如:

turn/start step/start user/message assistant/message tool/call tool/result step/end turn/end

模型真正需要的是相应的对话、工具调用与工具结果。

它不需要把每条内部生命周期事件都作为聊天消息读取。

所以 DSH 将完整 Event Log 与模型可见历史分开。

6.1 Surface 是什么?

可以暂时理解为:

Surface 是从 Event Log 派生出来的、用于组织模型可见消息的有序视图。

它并不是另一份独立维护的原始聊天事实来源。

Session Event Log │ ▼ Surface │ ▼ deriveMessages() │ ▼ Message[] │ ▼ LLM

6.2 deriveEventMessage() 怎样筛选消息?

源码位置:

packages/core/session/src/surface.ts

DSH 真实源码关键节选:

export function deriveEventMessage( event: SessionEvent, projectedMessages?: ReadonlyMap<SessionSeq, Message>, ): Message | null { // ① 如果该事件存在已经计算好的消息投影 // 优先使用投影结果 const projected = projectedMessages?.get(event.seq) if (projected !== undefined) { return projected } // ② 根据事件类型决定是否产生模型消息 switch (event.type) { // ③ 用户消息进入模型历史 case 'user/message': { return event.data } // ④ 系统、开发者、助手消息可产生模型消息 case 'system/message': case 'developer/message': case 'assistant/message': { return event.data.message } // ⑤ 其他生命周期事件不直接产生模型消息 default: return null } }

deriveEventMessage() 源码

这里最重要的是:

default: return null

这意味着:

turn/start、step/end等日志事件仍然存在,但不会直接转换成模型消息。

模型提出的 Tool Call 则已经包含在相应的 Assistant 消息中,因此不需要再把内部tool/call事件额外转换成重复的模型消息。

6.3 deriveMessages() 怎样组织完整历史?

继续看:

packages/core/session/src/index.ts

deriveMessages(): Message[] { // ① 获取当前 Surface const surface = this.surface // ② 获取当前有效的消息节点序号 const nodes = surface.nodes // ③ 获取当前消息投影的变更版本 const generation = surface.generation // ④ 按节点顺序派生模型消息 // 具体投影与缓存逻辑省略 return nodes.map((seq) => { const event = this.log[seq]! return deriveEventMessage(event, surface.projectedMessages) }).filter((message): message is Message => message !== null) }

deriveMessages() 源码

因此:

Event Log 保存完整历史 │ ▼ Surface 确定有效消息节点 │ ▼ deriveMessages() 派生 Message[] │ ▼ Agent Loop 获取模型历史

这里还有一个很值得讨论的设计:

6.4 如果模型历史太长,应该修改旧事件吗?

假设原始tool/result保存了很长的 README.md 内容。

现在希望模型下一次只看到摘要。

是否应该直接修改旧工具结果?

不应该。

因为原始事件是执行历史的一部分。

更合理的方式是:

保留旧事件,通过新的压缩与替换事件改变 Surface。

DSH 的 Compaction 机制就采用了类似的设计。

源码位置:

packages/compaction/compaction-basic/src/region.ts

// ① 记录压缩摘要及关联信息 const summaryEvent = session.append( 'compaction/summary', { // 其他字段省略 summary, shadowedRange: { start, end }, shadowedSeqs: [...shadowedSeqs], }, { // ② 用摘要替换被遮蔽的消息节点 surfaceOp: 'replace', sourceEventSeqs: [...shadowedSeqs], }, )

此处为真实代码的教学节选,省略了压缩事务标识、可选字段和其他处理。

Compaction 源码

最终可以理解成:

原始 Event Log │ ├── 原始用户消息 ├── 原始工具调用 └── 原始工具结果 │ ▼ Compaction 生成摘要 │ ▼ 追加新的摘要与替换事件 │ ▼ Surface 更新有效消息视图 │ ▼ 模型看到压缩后的历史

原始事件仍然保留。

需要注意,Compaction 是可选的上下文管理能力,Surface 本身并不负责调用 LLM 生成摘要。

有关压缩策略和 Token 管理,我们将在第七篇详细分析。


07|Request Header:为什么有了消息历史,还要记录模型请求配置?

前面我们解决了一个问题:

模型的 Messages 怎样从 Session 派生?

但一次完整模型请求不只有 Messages。

还可能包含:

Provider Model 模型参数 Tool Schema System Prompt Messages

假设:

第一次请求:

Model A 可用工具: read write grep

第二次请求:

Model B 可用工具: read grep

如果以后我们回放历史,发现模型没有调用write。

是因为模型没有选择它?

还是因为当时模型根本没有被提供这个工具接口?

如果没有记录当时的请求条件,我们就很难区分。

因此,DSH 使用request/header事件保存请求头快照。

7.1 EpochHeader 保存什么?

源码位置:

packages/core/session/src/types.ts

export interface EpochHeader { // ① 模型调用配置 // 包括 Provider、Model、请求参数等 config: LlmCallConfig // ② 记录某些有效配置值是否由适配器补全 adapterDefaults?: LlmCallConfigAdapterDefaults // ③ 模型可见的工具 Schema tools: ToolSchema[] // 其他字段省略 }

EpochHeader 源码

这里有一个关键细节:

tools保存的是模型可见的 Tool Schema。

而不是工具真实的execute()函数。

所以:

旧 Session 中有 write Tool Schema │ ▼ 可以知道过去模型拥有这个工具接口 但不能证明当前 Tool Registry 仍然注册着 write.execute()

这就是第五篇最后综合理解题所涉及的能力边界。

7.2 DSH 怎样记录 Header 的变化?

文件:

packages/core/agent-loop/src/agent.ts

DSH 真实源码关键节选:

// ① 构造规范化的请求头 const header = canonicalHeader({ config, ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults }, ...tools.length === 0 ? {} : { tools }, }) // ② 判断是否开启新的消息序列 const startsSeries = ... // ③ 如果 Header 发生变化 if ( headerSeq === undefined || !headerEquals(baseline, header) ) { // 记录一个新的完整快照 headerSeq = this.session.append('request/header', { header, reason: 'change', ...startsSeries ? { startsSeries: true } : {}, }).seq } // ④ 如果 Header 未变,但开启新消息序列 else if (startsSeries) { this.session.append('request/header', { header, reason: 'series', }) }

buildRequest() 源码

这里可以看到:

DSH 不需要为每个完全没有变化的普通后续步骤重复记录同一份 Header。

但在首次请求、恢复、新配置或新消息序列等情况下,会根据不同规则记录新的快照。

7.3 怎样从历史中重新获取 Header?

源码:

packages/core/session/src/request-header.ts

export function foldRequestHeader( events: readonly SessionEvent[], from?: EpochHeader ): EpochHeader | undefined { // ① 使用之前的 Header 作为初始状态 let state = from // ② 按顺序遍历事件 for (const event of events) { // ③ 只关注 request/header 事件 if (event.type === 'request/header') { // ④ 将最近遇到的完整 Header 作为当前状态 state = canonicalHeader(event.data.header) } } // ⑤ 返回遍历范围内最后生效的 Header return state }

request-header.ts 源码

这里最重要的思想是:

从已记录的请求头事件重新计算某个历史位置所使用的模型请求头。

例如:

seq=0 request/header Model A read、write seq=1 user/message seq=2 assistant/message seq=3 request/header Model B read

如果我们使用截至seq=2的历史前缀:

有效 Header = Model A + read、write

如果使用截至seq=3的历史前缀:

有效 Header = Model B + read

这就是日志折叠的实际用途。

同时要注意,System Prompt 以system/message的形式参与 Surface;路由容量等信息则通过独立的request/context事件记录。

因此完整模型请求是由不同来源的信息共同组装的。

能够重建这些请求条件,可以帮助我们解释和排查模型行为,但不意味着能够完全还原模型内部的推理过程,也不保证重新请求时一定得到完全相同的输出。


08|Replay 与 Fork:历史出了问题,我们该怎么办?

这一节我们从一个实际开发问题出发。

用户要求:

将 Spring Boot 服务端口改为 9090,并确保应用正常启动。

原始配置文件:

server: port: 8080 spring: application: name: interview-studio

Agent 完成以下操作:

Step 1:读取 application.yml 成功 Step 2:修改 application.yml 误删了 application.name Step 3:启动 Spring Boot 失败

后来我们发现项目启动失败。

此时可能有两种需求。

8.1 需求 A:想知道过去发生了什么

我们想检查:

模型当时读取了什么配置。

模型提出了什么write参数。

工具返回了什么结果。

测试为什么失败。

但我们不希望重新执行一次write。

这种情况下,需要的是 Replay。

Replay 的重点是根据已有事件重新派生历史与状态,而不是重新执行全部工具。

Session Event Log │ ▼ 历史重建 │ ├── 模型消息历史 ├── 请求头状态 └── 已记录的执行事实

这里不需要为了查看历史,再调用一次:

write("application.yml")

否则可能再次改变真实文件。

Replay 更像重新查看一段已有记录,而不是让实际操作重新发生一次。

8.2 需求 B:想重新尝试另一种修改方式

现在我们已经知道第二步修改有问题。

如果希望:

从第一次读取配置之后开始,尝试一条新的修改路线。

那么需要的就是 Fork。

Session A │ 读取配置成功 │ 历史分叉点 │ ┌─────────┴─────────┐ │ │ ▼ ▼ Session A Session B │ │ ▼ ▼ 原来的错误修改 尝试新的修改策略 │ │ ▼ ▼ 测试失败 重新验证

Session B 可以继承 Session A 在分叉点以前的历史。

但分叉点以后的新事件属于 Session B 自己。

8.3 DSH 怎样创建 Fork?

源码位置:

packages/core/session/src/fork.ts

DSH 真实源码:

export function buildForkSeed( events: readonly SessionEvent[], boundary: SessionSeqType ): SessionEvent[] { // ① 复制原 Session 从开始到指定边界的事件 // boundary 是包含边界的序号 const prefix = events.slice(0, boundary + 1) // ② 添加继承边界标记 prefix.push({ type: 'session/end-seed', seq: SessionSeq(boundary + 1), // ③ 使用原始边界事件的时间 time: events[boundary]!.time, data: { inherited: true }, }) // ④ 对继承前缀中尚未关闭的执行尾部 // 生成必要的恢复结果和结束事件 return prefix.concat( openTurnClosers(prefix, { kind: 'forked' }) ) }

fork.ts 源码

这里的核心是:

events.slice(0, boundary + 1)

它负责获取历史前缀。

例如:

Session A: seq=0 seq=1 seq=2 seq=3 seq=4 seq=5

假设:

boundary = 2

那么子 Session 会继承:

seq=0 seq=1 seq=2

然后追加自己的继承标记和必要的结束事件。

session/end-seed用于区分继承历史与新的生命周期边界。

如果分叉点刚好位于开放的 Step 或 Turn 中,openTurnClosers()可以按照相应规则补充结束信息。

8.4 最容易忽略的问题:Fork 不会自动回滚真实文件

这是本节最重要的工程边界。

假设 Session A 已经执行:

write("application.yml")

并且真实磁盘文件被修改。

之后我们从修改之前的位置创建 Session B。

虽然:

Session B 的历史 停留在修改前

但:

真实 application.yml 仍然可能已经被 Session A 修改

因为 DSH 的 Session Fork 操作的是事件历史,并不是文件系统快照。

如果 Session A 与 Session B 使用同一个工作目录,那么它们仍然可能修改同一份文件。

因此,如果要真正比较两种代码修改方案,还应根据需要结合 Git Worktree、独立目录或其他执行环境隔离机制。

这让我们得到三个清晰的结论:

机制主要目标
Replay根据已有事件重建历史
Fork从某个历史位置创建新的会话分支
Compaction缩减当前模型可见的历史规模

它们关注的问题不同。


09|完整世界地图:Session 怎样与 Agent Loop、Tool Runtime 协作?

经过前面八节,我们已经认识了第五篇的主要模块。

现在把它们连接起来。

9.1 DSH 统一架构图

用户请求 │ ▼ ┌──────────────┐ │ Agent Loop │ │ Turn / Step │ └──────┬───────┘ │ ▼ 组装模型请求 │ ┌─────────────┼──────────────┐ │ │ │ ▼ ▼ ▼ Request Header Surface Tool Registry │ │ │ │ ▼ ▼ │ deriveMessages() Tool Schema │ │ │ └─────────────┼──────────────┘ │ ▼ LLM │ 模型提出 Tool Call │ ▼ Agent Loop │ ▼ executeToolCalls() │ ▼ Tool Runtime │ ┌───────────┴───────────┐ │ │ ▼ ▼ 执行前检查 工具调度 │ │ └───────────┬───────────┘ │ ▼ tool.execute() │ ▼ Tool Result │ ▼ Agent Loop 提交 │ ▼ Session Event Log │ ┌───────────────┼────────────────┐ │ │ │ ▼ ▼ ▼ Surface Request Header 执行记录 │ │ │ ▼ ▼ ▼ deriveMessages() foldRequestHeader() Replay │ │ │ └───────────────┤ ▼ │ 历史分析 ▼ 下一次模型请求 Session Event Log │ ▼ Fork │ ▼ 新 Session 继承历史前缀 │ ▼ 新的后续执行

这张图可以理解为第一篇到第五篇知识的整合。

Cordis 提供模块化能力与生命周期机制。

Agent Loop 驱动执行流程。

Tool Runtime 负责工具调用。

Session Event Log 保存已经接纳的执行事实。

Surface 派生模型可见历史。

Request Header 保留相应的模型请求配置。

Replay 和 Fork 则利用已经记录的历史进行重建或创建分支。

9.2 从一次完整 Tool Call 再看 Session 的作用

假设模型调用:

{ "name": "read", "arguments": { "file_path": "README.md" } }

整个执行链可以概括为:

① LLM 提出 read Tool Call │ ▼ ② Agent Loop 识别 Tool Call │ ▼ ③ 记录 tool/call │ ▼ ④ Tool Runtime 准备并执行 read │ ▼ ⑤ 工具返回结果 │ ▼ ⑥ 结果按调度规则提交 │ ▼ ⑦ 记录 tool/result │ ▼ ⑧ Session Surface 更新 │ ▼ ⑨ deriveMessages() 派生历史 │ ▼ ⑩ Agent Loop 发起下一 Step │ ▼ ⑪ 模型基于工具结果继续推理

这里最重要的区别是:

Tool Runtime 负责执行工具,而 Session 负责记录和组织执行历史。

即使 Tool Runtime 以后卸载了某个工具,过去已经记录的工具结果也不会因为插件卸载而自动消失。

但是,能看到过去的调用,并不意味着当前仍然具备重新执行它的能力。

9.3 最终理解:历史事实与当前执行能力必须分开

假设昨天 DSH 注册:

read write grep

今天重新启动,只注册:

read grep

那么:

旧 Session │ └── 曾经记录 write 调用与结果 │ ▼ 可以用于历史重建 当前 Tool Registry │ ├── read └── grep │ ▼ 没有 write 实现 │ ▼ 不能仅凭旧 Schema 执行 write

Session 中记录过某项能力,与当前运行环境是否仍具备这项能力,是两件不同的事。

这也解释了为什么 DSH 要同时保留 Tool Registry、Tool Schema、Agent Loop 和 Session,而不是简单把所有东西放在同一个对象里。


10|第五篇总结:Session 是 Agent 的执行历史基础

这一篇我们从一个真实问题出发:

Agent 修改文件失败后,程序怎样知道之前执行过什么,并为后续恢复提供依据?

沿着这个问题,我们学习了九个核心知识点。

章节核心知识解决的问题
01Messages 与执行历史聊天消息为什么不足以表达全部执行事实
02Event Sourcing为什么要保存历史事件
03SessionEventMap怎样区分不同执行事实
04Session.append()怎样安全地追加事件
05Tool Call / Result怎样关联一次调用和结果
06Surface怎样从日志派生模型可见历史
07Request Header怎样记录并重建模型请求配置
08Replay / Fork怎样重建历史和创建会话分支
09完整架构Session 怎样参与整个 Agent 执行过程

通过这一篇,我们应该建立以下认识:

第一,Session 不是普通聊天记录。

它保存类型化的执行事件,包括轮次、步骤、模型消息、工具调用、工具结果及请求配置等信息。

第二,Event Log 是事实来源,模型历史是派生结果。

DSH 并不需要把所有事件直接发送给模型,而是通过 Surface 与deriveMessages()获得相应历史。

第三,历史记录应该稳定,但模型可见历史可以变化。

Compaction 可以通过追加压缩及替换事件来改变 Surface,而不直接修改原始执行事件。

第四,Replay 与 Fork 不能混为一谈。

Replay 重建已有历史。

Fork 继承历史前缀并创建新分支。

但二者都不会自动恢复真实文件系统的旧状态。

第五,记录历史不能代替执行安全与故障恢复机制。

日志能够帮助我们判断哪些事实已经确认,但如果外部操作结果未知,仍然需要通过额外检查、幂等机制等方式处理。

最后,我们再回到整个系列:

第一篇:Agent 与 Harness 为什么需要 Harness? ↓ 第二篇:Cordis 插件怎样组织系统能力? ↓ 第三篇:Agent Loop 谁负责推动任务执行? ↓ 第四篇:Tool Runtime 工具怎样被查找、调度、检查与执行? ↓ 第五篇:Session 与 Event Log 执行历史怎样被记录、派生和重建? ↓ 第六篇:System Prompt 与 Context Assembly 模型真正看到的上下文怎样组装?

理解 Session 以后,我们就可以进一步研究:

模型每次请求时,究竟从哪些来源收集上下文?

System Prompt 怎样生成和更新?

文件内容、插件注入的信息、Session 历史怎样组合?

这些内容将在第六篇继续分析。


参考源码

本文以 DeepSeek Harness 官方仓库固定提交687f282e8625c6b8d11ff58211a90524be2e4317为基础。

文件主要职责
packages/core/session/src/index.tsSession 类、事件追加、历史派生、Fork 入口
packages/core/session/src/types.tsSessionEventMap、EpochHeader、事件类型
packages/core/session/src/surface.tsSurface 投影及消息派生规则
packages/core/session/src/request-header.ts请求头比较与重建
packages/core/session/src/fork.ts从历史前缀构造子 Session
packages/core/session/src/repair.ts开放步骤与工具结果的恢复辅助逻辑
packages/core/agent-loop/src/agent.ts模型请求组装、请求头记录
packages/core/agent-loop/src/tool-calls.ts工具调用和结果事件的记录
packages/compaction/compaction-basic/src/region.ts历史摘要及 Surface 替换

官方文档:

  • Session 子系统

  • Session 包说明

  • Compaction 子系统

版本与验证说明:本文结论来自上述固定版本源码及官方文档,场景示例用于解释架构设计,不代表已经完成对应场景的本地运行测试。代码中的中文注释为本文添加,局部节选不能直接当作完整源码文件运行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询