资讯详情

资讯详情

DeepSeek Harness 解析(五):Session 与 Event Log——从事件溯源到历史重建

在前四篇中我们已经逐步理解了1.DSH 的核心执行架构2.Cordis 负责组织插件与服务3.Agent Loop 负责推动 Turn 和 Step4.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 采用了事件溯源设计。02Session 与 Event Sourcing为什么保存事件而不是只保存当前状态先看两种设计方案。方案 A保存当前状态假设 Agent 正在执行第 3 个 Step。{ turn: 1, step: 3, status: failed }这种方式能够告诉我们当前保存的状态。但是单凭这些字段无法判断Step 1 执行了什么。Step 2 有没有调用工具。Step 3 为什么失败。如果想回答这些问题就需要额外保存更多历史信息。方案 B保存事件历史另一种方案是每发生一件事情就追加一条事件。seq0 turn/start seq1 step/start seq2 user/message seq3 assistant/message seq4 tool/call seq5 tool/result seq6 step/end seq7 step/start ...与方案 A 相比它能够告诉我们发生了哪些事情才形成当前状态。这就是 Event Sourcing事件溯源的核心思想。2.1 DSH 源码怎样体现事件溯源文件位置packages/core/session/src/index.tsDSH 真实源码关键节选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 是内存中的事件溯源对象。若要在进程崩溃后仍然保留日志需要配置相应的持久化后端并满足必要的持久化检查点条件。事件溯源本身不等于已经写入磁盘。03SessionEventMap为什么需要不同的事件类型现在我们知道 Session 保存事件。新的问题是怎样区分一次执行中发生的不同事情如果全部记录为{ type: event, message: 发生了一件事情 }以后程序仍然很难可靠识别这件事情属于什么类型。因此DSH 定义了类型化的事件集合。文件位置packages/core/session/src/types.tsDSH 真实源码关键节选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没有出现也不代表工具一定没有执行。程序可能在记录调用之后、实际执行之前崩溃也可能在外部操作已经成功之后、记录结果之前崩溃。所以事件能证明它所记录的事实但不能证明所有没有记录的事情都没有发生。这为后面学习故障恢复建立了基础。04Session.append()事件怎样真正进入日志现在我们已经认识了不同的事件类型。接下来研究一次事件怎样被正式记录假设 Agent Loop 执行session.append(step/start, { turn: 1, step: 2 })是否只需要把这个对象放进数组实际并没有这么简单。4.1 先看真实源码文件位置packages/core/session/src/index.tsDSH 真实源码关键节选appendT extends SessionEventType( type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntentT] : [] ): SessionEventT { // ① 读取 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 SessionEventT) // ⑩ 验证事件是否满足相应规则 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这是一个容易混淆的地方。例如seq10 turn1 step2 tool/call seq11 turn1 step2 tool/result两条事件都属于 Turn 1、Step 2。但它们在 Session 中是两条不同的记录。我们可以这样理解字段回答的问题turn属于哪个任务轮次step属于哪个执行步骤seq在 Session 日志中位于什么位置type记录了哪一种事实data这件事情的具体数据是什么所以seq并不是记录“工具成功还是失败”。它负责标识事件在 Session 日志中的顺序和位置。05Tool Call 与 Tool Result如何建立事件之间的关联到这里我们需要把第四篇的 Tool Runtime 与第五篇的 Session 联系起来。假设模型同时提出read(README.md) read(package.json)它们都是read。也可能发生在同一个 Turn 和 Step 中。那么系统怎样知道哪个结果属于哪个调用5.1 三种标识的区别我们先看一个简化日志seq10 tool/call nameread callIdcall_A fileREADME.md seq11 tool/call nameread callIdcall_B filepackage.json seq12 tool/result callIdcall_A seq13 tool/result callIdcall_B这里name标识工具类型。callId标识一次具体调用。seq标识一条具体的 Session 事件。因此即使 A 和 B 调用同一种工具也可以通过不同callId区分。5.2 DSH 怎样记录 Tool Call源码位置packages/core/agent-loop/src/tool-calls.tsDSH 真实源码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 seq10 callIdA │ │ 关联 ▼ tool/result seq12 callIdA sourceEventSeqs[10]Session 不仅记录事件先后顺序还能够保存事件之间的关联关系。但仍然不能忘记工具调用记录存在不等于外部操作已确认成功最终成功与否需要结合可用的结果记录以及外部事实判断。06Surface 与 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.tsDSH 真实源码关键节选export function deriveEventMessage( event: SessionEvent, projectedMessages?: ReadonlyMapSessionSeq, 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.tsderiveMessages(): 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 管理我们将在第七篇详细分析。07Request 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.tsexport 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.tsDSH 真实源码关键节选// ① 构造规范化的请求头 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.tsexport 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 源码这里最重要的思想是从已记录的请求头事件重新计算某个历史位置所使用的模型请求头。例如seq0 request/header Model A read、write seq1 user/message seq2 assistant/message seq3 request/header Model B read如果我们使用截至seq2的历史前缀有效 Header Model A read、write如果使用截至seq3的历史前缀有效 Header Model B read这就是日志折叠的实际用途。同时要注意System Prompt 以system/message的形式参与 Surface路由容量等信息则通过独立的request/context事件记录。因此完整模型请求是由不同来源的信息共同组装的。能够重建这些请求条件可以帮助我们解释和排查模型行为但不意味着能够完全还原模型内部的推理过程也不保证重新请求时一定得到完全相同的输出。08Replay 与 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.tsDSH 真实源码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 seq0 seq1 seq2 seq3 seq4 seq5假设boundary 2那么子 Session 会继承seq0 seq1 seq2然后追加自己的继承标记和必要的结束事件。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 修改文件失败后程序怎样知道之前执行过什么并为后续恢复提供依据沿着这个问题我们学习了九个核心知识点。章节核心知识解决的问题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从历史前缀构造子 Sessionpackages/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 子系统版本与验证说明本文结论来自上述固定版本源码及官方文档场景示例用于解释架构设计不代表已经完成对应场景的本地运行测试。代码中的中文注释为本文添加局部节选不能直接当作完整源码文件运行。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →