← 文章

DeepSeek Harness

关掉窗口以后,Agent 还记得我们做过什么吗?

从第二天重新打开会话出发,追踪 DeepSeek Harness 怎样保存事件、重建模型上下文、区分中断状态,并从稳定边界分叉出新的研究方向。

假设昨天的仓库分析已经有了进展。几个子 Agent 分别读完认证、存储和任务队列,你正在比较两种重构方向,忽然合上了电脑。第二天打开会话,你想接着问:“昨天那个判断,是根据哪次工具返回得出的?”

如果系统只保存最后一段总结,它可能记得结论,却找不回证据。如果只保存界面上的聊天气泡,它又可能不知道某次工具是否已经执行、哪些上下文被压缩过,以及模型当时使用了什么设置。

这也是接着研究 DeepSeek Harness 的好位置。上一篇解决“怎样安排多个 Agent 工作”,这一篇追问:工作留下的记录,如何支撑恢复、重新理解和尝试另一条路线?本文继续固定在 dsh-v0.1.1-rc.2 对应源码。开头的场景是便于讨论的假设,不是一次真实故障复盘。Session 实现

先记发生了什么,再决定显示什么

在这套实现里,Session 首先是一份只追加的事件日志。用户输入、模型完整回答、工具调用与结果,以及轮次开始、步骤结束、流式片段,都可以留下各自的记录。它们共享连续的 seq:下一条事件的序号就是当前日志长度,从零开始。

这比“把聊天记录存成数组”多了一层。聊天是给人看的表现形式,事件则描述执行中发生的事实。一个流式片段需要保留,方便重放或统计,但不应该因为被记录,就再次作为一条独立回答发给模型。事件结构、消息投影

Session.append() 也不直接保留调用者传进来的可变对象。它先对数据做无损 JSON 快照,再冻结被接受的事件。假设工具先把一个结果对象交给日志,随后复用这个对象修改内容,已经记下来的历史不应跟着变。数据、序号和投影元信息不合法时,追加要在提交前失败,而不是等写磁盘时才发现无法保存。追加入口、JSON 快照

读取 session.events 得到的是冻结的数组快照。之后有新事件进来,先前拿到的数组也不会悄悄变长。这让“我正在分析哪一段历史”有一个稳定答案。这里的冻结是对象使用约束,不是密码学防篡改,也不替代存储权限或备份。

同一本日志,可以有两种读法

现在问题来了:日志一直增长,模型上下文却不能无限增长。直接删除早期消息会丢失依据,一股脑全发又会越来越重。Harness 在原始事件上维护了一层 surface,可以把它理解为模型当前应该阅读的消息目录。

这个目录保存事件序号,而不是复制另一套聊天历史。当前实现允许三类事件产生消息:user/message、assistant/message 和 tool/result。它们必须说明怎样进入 surface;轮次边界和流式片段则不进入这份目录。deriveMessages() 沿目录取出消息,空内容的 assistant 消息会被跳过。Surface 规则

原始日志保留所有事件,模型读取可替换的surface,人类记录读取原始追加消息
日志保存来路;模型消息目录决定下一次带上什么。两者不必完全相同。

假如早期有两段很长的模块分析,压缩插件可以在日志末尾追加一条总结,同时声明它在 surface 中替换哪一段现有消息。旧事件仍在原来的位置,新总结也有自己的新序号;改变的是目录,不是把过去改写成“我们一直只说过这段总结”。

替换范围的两端必须存在于当前 surface,顺序必须正确,而且 sourceEventSeqs 要覆盖所有被替换的节点。这留下了总结引用哪些旧记录的结构关系。不过,覆盖检查只能证明引用完整,不能证明总结忠实,内容质量仍需要另外验证。对于工具结果替换,规则还收紧为一次只能改一个当前工具结果的内容,不能顺手换掉调用身份等其他字段。替换校验

这也解释了为什么不能直接拿 surface 当人类聊天记录:压缩一落地,用户昨天看过的内容就会从页面上“消失”。源码提供 isAppendSurfaceEvent() 来区分原始追加消息与替换副本;人类记录应从前者投影,模型继续读当前 surface。相同事件源,服务于两种不同的阅读目的。

有缓存,也不代表每次读取都是常数成本

deriveMessages() 会记住已经处理了多少个 surface 节点。没有替换时,只投影新节点,避免每一轮从原始日志头部重新解析。发生替换后,replaceGeneration 改变,旧投影缓存清空,再按新目录重建。增量派生

但这里有一个容易被“增量”两字掩盖的成本:函数最终返回 [...this.derived]。即使只有一条新消息,也要创建覆盖完整当前历史的新数组。如果当前可见消息数为 M,单看这次返回的数组复制仍有 O(M) 成本;增量优化省下的是重复投影,而不是让整个调用都变成常数时间。

替换也有代价。实现用 indexOf 查找目录中的范围,再用 splice 修改节点;它不是一棵支持任意历史编辑的平衡树。好处是数据结构直接,普通追加路径容易维护,代价是范围查找、移动和投影重建都要按实际规模评估。这个版本选择了清楚的顺序目录,没有承诺所有历史操作都便宜。目录更新算法

找回对话,还不等于找回当时的请求

回到第二天的追问。即使消息逐条找回,仍然少了系统提示、工具定义和模型调用配置。同样一句“帮我分析”,放在不同工具集合或模型设置下,含义可能不同。

因此,日志还有 request/header。它保存非历史部分的规范化请求快照;foldRequestHeader() 顺着记录选择最近一次快照。消息走 surface,系统文本、工具 schema 和调用配置走 header,两部分各有明确来源。完整 assistant 消息自身也保留产生它的 provider、model 等来源信息。请求头折叠、Session 数据说明

这提供的是重建输入依据的能力,不是承诺模型会再次给出逐字相同的答案。恢复之后的新一轮还会组合当前的请求配置;工具、模型或环境发生变化,都可能影响后续行为。日志可以帮助解释“当时输入了什么”,却不会把外部世界冻结在昨天。

记进内存,与真正保存下来之间

前面一直在说“日志”,但 append() 的成功首先意味着事件进入了内存中的 Session。它不会为每一条流式片段等待磁盘 I/O。持久化插件订阅 session/event,缓冲事件,再交给后端写入。

调用者需要一个明确的保存检查点时,走 ctx.sessions.flush(session)。它启动所有相关监听器,等待它们全部结束,然后再报告失败;不是某个监听器先报错就让其余写入失去等待。返回值还告诉调用者是否至少有一个持久化监听器参与,因此无监听器时顺利返回,也不能解释成“磁盘肯定有副本”。flush 实现

JSONL 后端把多个事件合成批次写入,默认等待窗口为 200 毫秒。第一条待写事件启动窗口,后来的事件加入但不把截止点不断往后推。这个配置约束主动等待的时间,不是“最多 200 毫秒一定持久化”的总延迟保证,后端写入和事件循环调度仍需要时间。持久化设计

还有个名称上的小陷阱:默认磁盘文件是压缩的 .jsonl.zstd,逻辑上是 JSONL,物理上不一定能直接按文本逐行读取。它与无压缩模式共用逻辑事件格式。文章不把存储压缩比例当作性能结论,也没有进行断电或磁盘故障实验。JSONL 后端

昨天那次写操作,究竟做完没有

假如中断发生在工具执行期间,恢复不能简单把残缺会话当成一轮完整对话继续发送。interruptedTurnClosers() 扫描加载的日志,跟踪未结束的 turn、step,以及模型请求过但还没有结果的工具调用。需要补齐时,先生成工具错误结果,再补步骤结束,最后补一个标记为 interrupted 的轮次结束。恢复算法

恢复区分未记录启动与结果未知,分叉只能选取轮次外的稳定前缀
恢复先诚实说明未知;分叉再从稳定前缀出发。它们都不会替你重做外部操作。

其中最重要的不是补了几条记录,而是两种不同的未知。如果 assistant 消息已经请求工具,却没有对应的 tool/call,合成结果标为 TOOL_NOT_STARTED。如果启动已被记录,却没有持久化的结果,则标为 TOOL_OUTCOME_UNKNOWN。

想象那是一次创建远程工单的操作。没有结果,不代表工单不存在;也许远端已经创建成功,只是本地没来得及记录返回值。恢复消息因此会提醒:按工具语义决定是否重试,对有副作用的操作先核实外部状态。这不是恼人的保守,而是日志诚实表达自己所能证明的范围。

修复事件沿用最后真实事件的时间戳,序号接着往后排,已平衡的日志不再生成修复项。JSONL 后端的 load() 会落实需要的修复;inspect() 则可以只在内存里给出平衡后的视图,不修改文件。所以“查看历史”和“恢复为可继续运行的状态”也需要分开设计。修复函数、加载与检查

从昨天的岔路口,再走一次

最后,你决定保留原结论,另开一个方向研究存储层。SessionStore.fork() 做的是选取一个活跃 Session 的日志前缀,以它为 seed 创建子会话,并记录 parentSession 和 seedLength 等来源信息。指定的 boundary 是包含该事件的序号,不是数组切片的排他终点。fork 实现

这个前缀必须结束在 turn 之外。比如一轮工具调用还在进行,直接从当前尾部切出去会带走没有闭合的执行关系,因此会被拒绝。不过原会话即使正在执行新一轮,也能明确选一个更早、已经闭合的边界进行分叉;它不要求整个父会话此刻都空闲。

父子随后各自追加,子会话的记录不会长进父会话里。但这不是进程检查点:不会复制仍在运行的子 Agent,也不会把数据库、工作目录或远程副作用一起分叉。这个版本还要求来源是 store 中的活跃实例,不支持直接拿一个尚未加载的磁盘会话 ID 来完成这一步。它提供了稳定前缀的分叉,不是任意节点编辑的会话树。分叉边界与限制

这一篇执行了固定源码中的 Session、surface、JSON 快照、header 折叠和修复逻辑,共 22 项检查,覆盖不可变快照、替换引用、重建、分叉边界和两种中断结果。测试替换了框架与冻结辅助函数,并使用简化的 store 发布适配,没有运行完整 Agent、真实模型、文件故障恢复或端到端续聊。

再回到那句“还记得吗”,现在可以问得更具体:原始事实是否还在,当前上下文怎样选出来,哪些已经保存,哪些只能承认未知?下一篇走进 Agent 循环,看这些记录如何与执行、取消和权限衔接。

继续阅读