首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >DeepSeek Harness 的会话不是消息数组:先记下事件,再决定模型看到什么

DeepSeek Harness 的会话不是消息数组:先记下事件,再决定模型看到什么

原创
作者头像
术哥
发布2026-09-09 21:55:50
发布2026-09-09 21:55:50
110
举报
文章被收录于专栏:运维有术运维有术

🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 202 篇,DeepSeek Harness最佳实战「2026」系列第 05

大家好,欢迎来到 术哥无界 | ShugeX | 运维有术

我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者

Talk is cheap, let's explore。无界探索,有术而行。

封面:事件账本投影出模型视图——事件是事实,消息是派生物
封面:事件账本投影出模型视图——事件是事实,消息是派生物

把 agent 会话实现成 messages[],几乎是最自然的冲动:用户消息 push 进去,模型消息 push 进去,工具结果再 push 进去。要 fork,就复制数组;要恢复,就把数组从磁盘读回来;要压缩,就删掉旧消息。

我翻 DeepSeek Harness 的 packages/core/session 源码时,发现它刻意没有走这条路。这里的 Session 是一份只追加的、类型化的 SessionEvent 日志。deriveMessages() 才负责把日志投影成模型历史。文档说得很直接:日志是唯一真源,LLM 消息历史是派生物。

这个差别不在命名。它决定了什么算事实,什么只是一个视图。

1. 可变数组最麻烦的不是丢数据,而是出现两份真相

两份真相:可变消息数组与只追加事件日志的分歧风险
两份真相:可变消息数组与只追加事件日志的分歧风险

如果消息数组是状态,事件日志只是通知,那么一次模型请求至少有两条路径:数组要更新,日志也要发事件。压缩可以改数组,持久化插件却可能只收到事件;恢复又可能从事件重建出另一种数组。在这种替代设计下,某次监听器早了一步、某次异常漏了一条,系统仍可能继续运行,只是以后不知道该相信谁。

事件溯源架构 note 记录过被否决的替代方案:可变消息数组加“事件仅作通知”。它看起来更简单,但状态与日志可能分歧。采用事件溯源后,日志本身就是状态,分歧不再靠测试运气避免,而是被结构约束。

所以 dsh 的主发现不是“它用了事件溯源”这么泛。更准确的说法是:它把模型可见的消息降级成视图,把可回放的事件升级成事实。

2. 一条事件怎样变成模型消息

事件如何投影成模型消息:原始 SessionEvent → surface → transcript message
事件如何投影成模型消息:原始 SessionEvent → surface → transcript message

Session.append() 做的第一件重要事情,不是通知插件,而是给事件分配连续序号:seq = this.log.length。事件数据先做 JSON 快照,再校验 surface 元数据,确认它能合法加入当前表层,随后才 log.push(event),最后发布 session/eventpackages/core/session/src/index.ts)。

这里有两个层次容易混在一起。

第一层是原始日志。turn/startstep/start、流分片和工具调用是运行记录,不直接投影为 transcript message。但 tool/result 是例外:packages/core/session/src/types.ts 把它列为 SurfaceEventType,而 surface.tsderiveEventMessage() 直接返回其 event.data.message,所以它投影为 tool message。

第二层是 surface。每个消息生产事件通过 surfaceOp 标记自己在模型历史中的位置,sourceEventSeqs 说明它从哪些更早事件派生。deriveMessages() 沿着 surface 的有序节点走,只把能投影成消息的节点交给 deriveEventMessage()。流分片可以留在原始账本里,却不单独进入 transcript;空内容的 assistant message 也可能不产生消息。

这就是“消息是派生的”的白话版:模型看到的数组不是某个地方偷偷维护的数组,而是对事件日志做了一次有规则的折叠。普通追加只投影新节点;surface 发生 replacement 时,缓存按新的 generation 重建;调用者拿到的是新数组,消息对象冻结,后续追加不会悄悄改变旧快照。

由此推断:新增模型可见输入却没有对应事件,就无法由日志重建,这一设计前提会失效。

3. fork、恢复、回放:不是复制当前数组

fork、恢复与压缩共享同一事件前缀:replacement 改变当前 surface,不删除历史
fork、恢复与压缩共享同一事件前缀:replacement 改变当前 surface,不删除历史

fork() 也因此不是复制一份 messages。它按边界取得源会话的连续事件 seed,要求边界存在、不能落在一个尚未闭合的 turn 里面;子会话以这段 seed 初始化,并记录继承长度(packages/core/session/src/index.ts)。

子会话拥有同一段历史,却不需要把“父会话当时的消息数组”当成特殊数据。它拿到的是事件前缀,之后继续追加自己的事件。inheritedEventCount 划出父历史和子会话自有事件的边界,lineage 则由 header 中的 parent 信息表达。

恢复的思路相同,只是 seed 来自持久化边界。持久化 note 规定 JSONL 直接保存 SessionEvent,不再发明一套“持久化消息”类型。读取后,事件经过格式和回放校验,再注入 Session;中断的轮次由恢复逻辑补齐可解析的收尾,而不是把已刷写的历史截断。

恢复的目标是重建完整事件状态,再重新得到当前 surface。它保留了 assistant attempt 中的 provider stream、usage、timing 和失败诊断。看起来多存了东西,实际是在拒绝把调试事实压缩成一条漂亮的 assistant 文本。

4. 同步的不是磁盘,是事实提交

同步提交与异步持久化:内存事实先提交,flush 检查点观察耐久性
同步提交与异步持久化:内存事实先提交,flush 检查点观察耐久性

有人看到“持久化异步”会担心:模型已经用了消息,磁盘还没写完,崩溃后岂不是不一致?这里需要把两个动作拆开。

append() 在内存日志中的提交是同步的。事件先进入 log,session/event 再作为 post-commit 的观察通知发出;观察者失败不会撤销已提交事件。源码注释还明确说,热路径从不阻塞于 I/O。

JSONL 持久化插件(session-persistence-jsonl/src/storage.ts)订阅 session/event,把事件放进自己的 writer buffer;遇到 session/flush,才 drain live buffer 并 flush;轮次边界的 checkpoint 负责等待这些监听器。

所以它的契约不是“每次 append 都已经落盘”,而是三句话:内存事实先提交;持久化是可替换插件;检查点负责观察耐久性失败。这个分界画得清楚:异步 I/O 的复杂度没有消失,只是没有污染核心事件接受路径,而是在 flush 时集中暴露。

顺序约定也在这里发挥作用。事件溯源架构 note 描述了 agent loop 的顺序:先领取 inbox 消息,再运行 agent/pre-step;只有作出 enter 决策才打开 step/start,并在请求派生前追加本轮返回的 user message 批次;提供方输出组装为 assistant/message 并追加后,才分派工具。于是工具看到的持久日志,至少按这个约定,包含模型实际看到的消息以及已提交的 assistant 调用。

顺序不是排版偏好。把 assistant/message 放到工具分发之后,恢复时可能看到工具结果,却找不到它对应的模型调用;把 user message 放到请求派生之后,模型可能已经看过一条日志中不存在的输入。dsh 的做法,是让这些竞态在事件序列上有明确位置。

5.压缩不是删历史,而是追加一次“替换”

到这里,消息数组方案又会显得很诱人。上下文太长,删掉前面的消息,再塞一条摘要,事情结束。

但只要日志是事实源,直接删除旧事件就会破坏回放。dsh 的压缩选择是追加 compaction 事件:开始、摘要、剪枝、结束等事件记录压缩过程;它们改变当前 surface,让一段旧节点变成 shadowed,却不把规范日志抹掉。

deriveMessages() 看到的是 replacement 后的 surface,模型因此可以使用摘要和保留尾部。这些关系没有丢:SessionEventTracereplacedBy/replacedEventSeqs/sourceEventSeqs/derivedEventSeqs 记下被谁替换、替换了哪些、引用了哪些来源、谁又引用它,compaction/prune 的 shadowed 数据标记被遮蔽的节点。查询/回放实现正是据此解释哪些事件被替换、哪些被遮蔽、摘要引用了哪些来源。

这也带来一个成本:日志查询不能只问“这条消息还在不在”。session-query 文档把事件 surface 分成 currentshadowedlog-only。一条被压缩遮蔽的事件可能不再进入当前模型历史,却仍然是原始日志和 provenance 的一部分。

压缩失败时,系统也不接受“返回了摘要就算成功”。压缩相关的架构 note 把 replaceGeneration 作为进展证明:只有模型可见 surface 真正发生替换,才授权重试。一个自定义后端嘴上说完成了,但没有改变可见状态,不能让 agent 盲目再试。

这是一种我倾向于赞成、但不会轻易照搬的取舍。它保护了可回放性,却要求所有读取方理解 replacement、shadowing、generation 和 provenance。对只想存聊天文本的应用,这个账本可能过重。

6. 查询与血缘:可搜索的会话,不等于可回放的日志

session-query 是抽象服务,提供查询表面;承担全文搜索的是 session-query-sqlite provider,它用 SQLite FTS5 建索引,且搜索可选、默认关闭(packages/session-query/session-query-sqlite/README.zh.md)。跨会话搜索按命中最强的事件分组;单会话搜索返回事件命中和 bounded snippet。查询文本按数据处理,不把用户的输入直接注入 FTS 语法。

更关键的是,索引文本不是简单地把所有 JSON stringify 一遍。文档规定,消息、工具调用和工具结果、待办、失败与状态详情会进入语义文本;reasoning block、被阻止提示词、结构事件和流分片不进入。这个过滤决定了“可搜索的会话”与“完整可回放的日志”不是同一个表面。

血缘追踪同样不是在猜两条消息的相似度。traceEvent() 区分四类关系:目标事件被谁替换、它替换了哪些事件、它直接引用了哪些来源事件、后来的哪些事件又直接把它当来源。traceSession() 追踪的是会话 parent 和 descendant;父节点不在可见语料库时,结果标记为不完整,而不是伪造一个 root。

换句话说,provenance 追踪的是事件变换关系。压缩摘要不是“凭空出现的新消息”,它应当能指向被它概括的来源范围;替换不是删除,它留下了可解释的关系边。

7. 统计、遥测、标题:旁路不再另造事实

事件日志还有一个容易低估的用途:旁路能力可以共享同一条提交流。

projection service(session-projection/src/index.ts)只订阅一次 session/event;每个已注册的 projection unit 都对提交事件执行自己的 fold,客户端视图只有状态引用实际变化时才收到变化。

session-stats 因此不是到处数 UI 当前页上的消息。它维护 whole-log 的 turn、step、LLM、工具、首 token 和 decode wall time 等统计,源码注释里写明:分页和 compaction 不应改变全会话数字。

telemetry coordinator 和 title service 都订阅已提交的 session/event,再按各自规则折叠、过滤或上报;相关 provider 可以挂载或替换。我的判断是:它们可以更换、可以卸载,所以更适合保持为旁路,而不是 Session 的第二个状态机。好处是所有消费者面对同一份事实;坏处是事件词汇一旦变更,投影、查询、统计、标题和迁移都要跟着维护。

文档写得含蓄,源码很诚实:所谓“插件化”,不是把复杂度消灭,而是把复杂度放在边界清楚的地方。

8. 这套设计适合谁,不适合谁

Coding agent 同时需要处理中断、恢复、fork、上下文压缩、工具追责,以及 UI、检索、统计和遥测共享历史时,“事件是事实,消息是派生物”值得认真研究。它把最危险的分歧风险前置成 append 不变量和回放校验,代价是事件模型、格式版本、迁移与 projection contract 都得长期维护。

如果你的需求只是保存用户和模型的几轮文本,且没有 fork、恢复、工具执行证据和复杂投影,我不建议为了架构姿态引入完整事件溯源。可变数组加一个可靠的存储层,也许更便宜。

还要留一条边界:我这次没有启动 dsh,也没有读取本会话的实际 session 文件,未实测 JSONL 的追加形状、FTS5 性能或崩溃恢复。以上关于运行顺序、恢复和压缩的判断,依据是仓库源码与 architecture notes,不是亲测结论。

真正值得带走的判断是:先问一句,当模型看到的状态、磁盘保存的状态、UI 展示的状态发生分歧时,你准备让谁拥有最终解释权?dsh 的答案很明确——先把事件记下来,其他东西都从它重新算。

好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 可变数组最麻烦的不是丢数据,而是出现两份真相
  • 2. 一条事件怎样变成模型消息
  • 3. fork、恢复、回放:不是复制当前数组
  • 4. 同步的不是磁盘,是事实提交
  • 5.压缩不是删历史,而是追加一次“替换”
  • 6. 查询与血缘:可搜索的会话,不等于可回放的日志
  • 7. 统计、遥测、标题:旁路不再另造事实
  • 8. 这套设计适合谁,不适合谁
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档