在前四篇中,我们已经逐步理解了
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 = 999deepFreeze()用于冻结事件对象及其嵌套数据,阻止这类直接修改。
外部数据 │ ▼ 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[] │ ▼ LLM6.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-studioAgent 完成以下操作:
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 执行 writeSession 中记录过某项能力,与当前运行环境是否仍具备这项能力,是两件不同的事。
这也解释了为什么 DSH 要同时保留 Tool Registry、Tool Schema、Agent Loop 和 Session,而不是简单把所有东西放在同一个对象里。
10|第五篇总结:Session 是 Agent 的执行历史基础
这一篇我们从一个真实问题出发:
Agent 修改文件失败后,程序怎样知道之前执行过什么,并为后续恢复提供依据?
沿着这个问题,我们学习了九个核心知识点。
| 章节 | 核心知识 | 解决的问题 |
|---|---|---|
| 01 | Messages 与执行历史 | 聊天消息为什么不足以表达全部执行事实 |
| 02 | Event Sourcing | 为什么要保存历史事件 |
| 03 | SessionEventMap | 怎样区分不同执行事实 |
| 04 | Session.append() | 怎样安全地追加事件 |
| 05 | Tool Call / Result | 怎样关联一次调用和结果 |
| 06 | Surface | 怎样从日志派生模型可见历史 |
| 07 | Request Header | 怎样记录并重建模型请求配置 |
| 08 | Replay / 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.ts | Session 类、事件追加、历史派生、Fork 入口 |
packages/core/session/src/types.ts | SessionEventMap、EpochHeader、事件类型 |
packages/core/session/src/surface.ts | Surface 投影及消息派生规则 |
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 子系统
版本与验证说明:本文结论来自上述固定版本源码及官方文档,场景示例用于解释架构设计,不代表已经完成对应场景的本地运行测试。代码中的中文注释为本文添加,局部节选不能直接当作完整源码文件运行。