ARTICLE · 1235556
DeepSeek-Harness 源码专题(1):模型看到的历史被换了,日志一个字没动
摘要:压缩不删消息:surface replace 让摘要在模型可见的表面顶替一段原文区间,append-only 日志只增不减。sourceEventSeqs 强制引用每个被遮蔽节点,可追溯由折叠校验器保证;compaction/start→end 事务括号给出崩溃语义,foldSurface 纯函数让任意时刻的模型视图可精确重放。
依赖升级的会话没有散场。迁移做完、测试跑绿之后,那个 every 30 分钟的定时提醒还在运转,会话一直活着。连跑了几天,日志涨过 token 阈值,压缩触发了:模型「忘掉」了前半段对话,接手的是一段结构化摘要。
很多读者的直觉在这里拐弯:摘要顶替了原文,那原文是不是被删了?这篇专门回答这个问题,先把结论放前面:一条都没删。 物理日志从第一个事件到现在只增不减,被「忘掉」的只是模型可见的那份视图。我们顺着这次压缩走一遍,看看摘要是怎么顶替原文的,原文又被藏在了哪里。
一、真相只有一份,视图可以替换
先把两个东西劈开,不然后面全是糊的:
- 日志(log):append-only 的事件序列,唯一事实源。《守不变量的不是测试,是编译器和 Object.freeze》拆过的 session 内核,写路径只有
Session.append()(index.ts:604),函数体里对日志的唯一写操作是 push。全代码库不存在任何「移除事件」的路径,压缩也不例外。 - 表面(surface):叠在日志上的有序视图,决定模型请求里出现哪些消息。surface 模块头注释就是设计宣言(surface.ts:1-9):The append-only log remains the source of truth。
能进表面的只有三种事件:user/message、assistant/message、tool/result(surface.ts:15-19),且每个事件必须带一个 surfaceOp 标记,只有两种取值(types.ts:376-378):
export type SurfaceOp = | 'append' | { op: 'replace'; start: number; end: number }这段是表面事件的入场券类型。'append' 是正常尾部追加;replace 携带 start/end 两个 seq,意思是「遮蔽表面上这段区间,让我自己顶替进去」。折叠规则就一行核心代码(surface.ts:362-379):
if (plan?.kind === 'append') { state.nodes.push(plan.seq) } else if (plan?.kind === 'replace') { state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq) state.replaceGeneration += 1 }这段是 replace 的全部机制(applySurfacePlan,surface.ts:362-379)。注意看这里:state.nodes 只是 seq 的有序列表。replace 做的事情是把列表里 [start, end] 这段序号挖掉、把自己的 seq 塞进去。物理日志一个字节都没动,被「删掉」的只是视图里的几个序号。
这也就意味着:被遮蔽的 seq 依然是合法日志下标,session.events[原始 seq] 随时可读。摘要顶替原文,顶替的是位置,不是数据。
二、模型请求是从表面投影出来的
模型每轮请求的历史怎么来?deriveMessages() 按表面序号逐个回查日志(index.ts:726-748):
deriveMessages(): Message[] { const surface = this.surface const nodes = surface.nodes const generation = surface.replaceGeneration if (generation !== this.derivedGeneration) { this.derived = [] this.derivedNodes = 0 this.derivedGeneration = generation } for (const seq of nodes.slice(this.derivedNodes)) { // …(oxlint 注释与空消息过滤略) const msg = this.deriveEventMessage(this.log[seq]!) if (msg) this.derived.push(msg) } this.derivedNodes = nodes.length return [...this.derived] }这段是模型历史的投影循环。压缩落地后,被遮蔽的 seq 不在 nodes 里,模型看到的历史自然只剩摘要加近期对话;而 this.log[原始 seq] 仍然完整躺在那。视图换、真相不动,两条路各走各的。
缓存策略也贴着这个模型走:replaceGeneration 是单调递增的替换计数(surface.ts:141),任何一次 replace 都让缓存整体作废重建,普通 append 只需增量投影新节点。replace 是低频事件,全量重建的代价可以接受;append 是每轮都有的高频路径,必须保持 O(新增)。
这个投影没有自己的持久化状态,表面只是日志的纯函数。这和《一个订阅都不写,todo 列表和 token 账单从哪来》里 projection 的立场一致:派生视图不持有独立状态,「视图和日志不一致」这类 bug 没有藏身之处,折叠时立刻抛错,不会悄悄越走越偏。
三、压缩写回:一次事务,两条追加
摘要生成后怎么落盘?《砍掉一半对话,日志反而多了四条事件》拆过压缩引擎的触发与重试,这里不再重复引擎,只看写回这一刻。commitCompactionBody(region.ts:427-478)连续追加两条事件,中间不让出执行权。
先把写回前的日志钉在 seq 上。压缩触发那一刻,会话已经跑了几千条事件,日志最前面正是几天前的迁移对话,把头部放大看:
seq 0 user/message 「把 axios 迁到 19」seq 1 assistant/message 读 pnpm-lock.yaml(tool_call)seq 2 tool/result 文件内容,20000 tokensseq 3 assistant/message 分析依赖树(tool_call)seq 4 tool/result 编辑结果seq 5 assistant/message 「迁移完成,请跑测试」seq 6 user/message 「跑一下测试」seq 7 assistant/message 「测试通过」token 触顶,选区算法保留最近一段尾部、不拆散 tool_call/tool_result 配对,选出区间 [0,5](selectCompactableRange,region.ts:98)。事务开始,第一条追加是 compaction/summary,纯日志记录,不进表面:
const summaryEvent = session.append('compaction/summary', { compactionId: startEvent.data.compactionId, ...startEvent.data.sourceCommandId === undefined ? {} : { sourceCommandId: startEvent.data.sourceCommandId }, summary, ...callProvenance, shadowedRange: { start, end }, shadowedSeqs: [...shadowedSeqs], shadowedTokenCount, provider, model, // …(maxTokens、usage 按存在性条件展开,省略) })这段是给人和回放器看的记录(region.ts:447-461)。摘要全文、被遮蔽区间、全部被遮蔽 seq 的显式列表、token 数、调用的模型与用量,全在这一条里。注意看 shadowedSeqs: [...shadowedSeqs],原文的每一个 seq 都被点名存档。
第二条才是模型看见的「摘要消息」:
session.append('user/message', checkpointMessage, { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], })这段是替换型表面事件本体(region.ts:462-465)。一条 user/message 带着 replace 型 surfaceOp 进场,内容上 frameSummary(summarizer.ts:189-195)给它套上 CHECKPOINT_PREAMBLE 和 标签,模型读到的就是「这是一段既成背景,接着干」。外层还套着 compaction/start 和 compaction/end 两条事务括号,失败也会写带 error 的闭合标记,所以日志的任意时刻都能回答「压缩是否正在进行」。
一个细节值得停一下:被遮蔽的 20000 token 文件内容,此刻仍原样躺在 seq 2 的 data 里。JSONL 文件该多大还多大,省下的只是模型请求里的位置。
四、可追溯不是约定,是校验
「原文可追溯」如果只停在注释里,那就是一句愿望。surface 层把它写成了硬校验:assertProvenance(surface.ts:211-243)在每次折叠时验证替换事件的来源引用,三条规则——不许指向未来(230-236)、不许重复、以及这条:
const missing = shadowedSeqs.filter(seq => !sources.has(seq)) if (missing.length > 0) { throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) }这段是遮蔽节点的完整性检查(surface.ts:239-242)。replace 事件必须显式引用它顶替掉的每一个节点,缺一个直接抛错。我们回看下 §三 那条 sourceEventSeqs:[startEvent.seq, summaryEvent.seq, ...shadowedSeqs],开头两条是事务与摘要记录,后面是被遮蔽节点的完整名单。「可追溯」在代码层面就是这样:不是建议携带,而是缺一即拒。
replace 的射程也有限制:replacementRange(surface.ts:246-266)要求 start 和 end 都还得在当前表面上,跨已压缩区域做二次折叠是不允许的,多层压缩的摘要合并交给模型完成。COMPACTION_INSTRUCTION 最后一条规则(summarizer.ts:65)写得很直白:对话里已有 块时,把它当作上一轮 checkpoint,保留仍然成立的事实、丢掉过期的,合并进一份新摘要,不许原样照抄。越压越大的摘要,靠这条规则兜住。
还有一条姊妹校验 assertToolResultRewrite(surface.ts:287-318):tool/result 的 replace 只许改 content。compaction 之外还有第二个用户在用 replace 通道——compaction-tool-result-pruner,它把臃肿的工具输出原地改写成精简版。同一套机制,两类用户,同一套校验。
五、崩溃语义:括号没合上,就是中断信号
压缩过程中进程死了怎么办?这套设计把崩溃语义直接做进了日志形状里。compactSurfaceRegion 的函数注释(region.ts:136-151)把话说死了:compaction/start 与校验同步相邻,它是摘要让出执行权之前的持久锁;之后每次失败恰好补一条 compaction/end,失败的关闭会故意留下未闭合的 start 可查。
所以日志本身就能回答状态问题:看到 compaction/start 而没有配对的 end,就是「压缩中断」的持久信号,下次 assertCompactionInactive(region.ts:286)检测到它便拒绝并发压缩。没有单独的状态文件,没有锁表,括号本身就是状态。
另一道防线在摘要质量上:摘要框架化后的 token 数必须严格小于被遮蔽内容,否则整个事务报错拒绝(region.ts:373-378):
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) if (framedSummaryTokenCount >= prepared.shadowedTokenCount) { throw new Error( `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`, ) }这段是收敛检查(region.ts:373-378)。「压缩完反而变大」在这个事务里不可能落地。注意这是全文唯一的硬质量闸:语义保真不保,token 收敛必保,靠提示词的固定八段结构撑语义面。
六、任意时刻的模型视图,都能精确重放
replace 语义还有个隐藏受益者:重放。foldSurface(surface.ts:387-395)是一个纯函数,把整段日志折过一遍,返回当前表面序号和全部替换历史:
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { const replacement = applySurfaceEvent(state, event, index, events, 0) if (replacement !== undefined) replacements.push(replacement) } return { nodes: [...state.nodes], replacements }}这段是全量重放的表面折叠(surface.ts:387-395)。fork、llm-replay 快照测试、任何「重建历史某一刻的模型请求」的工具,都拿它精确复现视图。replace 在重放中自然生效:折到那条事件时 splice 掉区间,和当时在线的会话看到的一模一样。
增量路径和全量路径共用同一套纯函数。live append 在入日志前走 validateNext 预演(index.ts:634),seed/restore 加载种子日志时走的也是同一个校验入口(index.ts:532),注释里写明了理由:种子和 live 追加接受的是同一条迁移。因此持久化重载后的会话与从未落盘的会话,拥有逐字节一致的表面。
《进程崩了一夜,提醒一条不补、一条不丢》里 schedule 的 foldScheduleEvents 和这里是同一个哲学:状态只有一种载体,重放即恢复,纯函数对表。那条线对的是墙钟,这条线对的是模型视图。
七、人类不看表面
还有一个坑,代码里专门写了注释(surface.ts:40-50):人类可见转录必须用 append 起源事件(isAppendSurfaceEvent),不能用模型表面。原因是 replace 已经生效的会话,拿模型视图给人看,等于把用户亲眼见过的对话从屏幕上抹掉。所以两边各看各的:模型看 replace 之后的表面,人类看 append 起源的完整转录,消费方选错视图就是呈现 bug。
八、对账
| 问题 | 答案 | 落点 |
|---|---|---|
| 原文删了吗 | 没删,日志只增不减 | append 只做 push(index.ts:604) |
| 模型怎么忘掉的 | surface replace 顶替视图区间 | applySurfacePlan(surface.ts:362-379) |
| 可追溯靠什么 | sourceEventSeqs 缺一即拒 | assertProvenance(surface.ts:239-242) |
| 崩了什么形状 | 未闭合 start = 中断信号 | 事务注释(region.ts:136-151) |
| 历史能重建吗 | foldSurface 纯函数重放 | surface.ts:387-395 |
边界同样摆在明处。日志只增不减,长期会话的 JSONL 线性增长,被遮蔽的大 tool result 照样占磁盘,体积靠持久化层的 chunk 打包控制,不靠删事件。replace 只能作用当前表面上的连续区间,二次折叠的摘要合并靠模型。语义保真没有客观校验,token 收敛是唯一硬闸,这是所有 LLM 摘要方案的共同弱点。
结语
回到那个连跑数天的会话。压缩落地那一刻:seq 8 写下 compaction/start,seq 9 的 compaction/summary 点名了 shadowedSeqs [0,1,2,3,4,5],seq 10 的 user/message 带着 replace 把 nodes 从八项 splice 成 [10, 6, 7]。模型下一轮请求看到的是 checkpoint 加最近两轮对话,而 JSONL 里 seq 0 到 seq 5 原封不动,seq 2 的 data 里还是那 20000 token 的文件内容。哪天我们想还原压缩前的对话,fold 一遍日志就行。
深读线收官时说专题要挑横切的面再切几刀,这是第一刀:同一个压缩动作,从引擎视角看是触发与重试,从表面视角看是视图替换与溯源校验。专题第二篇切 fresh-agent iteration——每回合都是新代理的迭代到底怎么延续工作,下一篇见。
你被压缩过的会话坑过吗,模型「忘掉」的东西后来找回来过吗?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































