dsh 插件开发学习

23-会话上下文的压缩、裁剪与排水

读完这篇你能:说清「会话一长就爆」背后其实是五件不同的事,知道每件什么时候发生、动了哪份数据、能不能撤;会注册一个自己的投影单元,把会话里发生过什么折成一本账读出来。

前置:22-危险操作的审批通道。约 35 分钟。

示例代码:dsh-session-digest

DSH 版本:0.2.0-rc.2

「会话一长就爆」这句需求,其实混了五件互不相同的事。它们共享同一条事件日志,但触发的时机、动的是哪份数据、可逆不可逆都不一样。把它们当成一件事,后面每个判断都会错。

一句话总纲:会话日志是唯一真相,其它全部是围绕它的落盘、派生读模型与上下文收缩。

目标

  • 认识会话的真相层,以及围绕它的四层结构各是什么。
  • 分清压缩、裁剪、排水:三者时机不同、落点不同、可逆性不同。
  • 知道”checkpoint”在仓库里指的是四种完全不同的东西。
  • 给会话加一个自己的投影单元,并提供一个工具读它。

DSH 设计

要谈收缩,先要知道会话的数据长什么样。DSH 给每个会话记一份只追加的事件日志:每条事件是一件确实发生过的事,编号连续、写下不改。模型看到的对话历史不是单独存储的,是从这份日志现算出来的;回放,就是把同一组事件重新算一遍。这种「从日志现算另一份形态」的纯计算叫投影(projection)。持久化(把日志落到磁盘)物化的也是同一份日志,「没有平行的持久化事件类型」(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/docs/subsystems/persistence.zh.md)。所以日志是唯一真相:围绕它的派生物——折叠出来的状态、检索索引、用量数字——坏了都可以从日志重算,因为真相不在它们手里。

投影不只是框架的内部机制,也是留给插件的正式扩展点。架构文档专门记了一条「投影」扩展点:任何人都可以注册一个折叠单元,也就是一个把每条已提交事件折进自己状态的纯函数;框架负责把事件逐个喂给它,读的人拿到的是折好的成品(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/docs/architecture.zh.md 的「会话日志」一节)。举个例子:一个单元把日志里每条「工具被调用」事件折成计数,读到的就是 { calls: 42 } 这样一份状态;把这份状态删掉,从日志重折一遍,还是 42。会话能折出什么账,不由框架限定,只由注册了哪些单元决定。

收缩上下文的三个机制,地位同样松散:全是可选能力,不在主循环里。压缩文档写明自己「不属于 agent loop 主干」(agent loop:驱动「问模型—调工具—再问」的那圈主循环)。排水(spill:把过长的工具结果落成文件,模型只看预览和取回办法)、裁剪(prune:挖掉工具结果的中段)、压缩(compaction:把一段历史换成摘要)各自挂在独立的扩展点上,各有自己的服务。缺了任何一个,主循环照跑。

三者里最重的是压缩,它做的事需要掰开看。压缩记下的三条账本事件只进日志、永远不进模型上下文,文档特意说明,只有产生消息的事件才到达模型。摘要本身则通过一条带「替换」标记的普通消息,落进模型看得见的上下文。所以压缩做的是「遮蔽」:把那段旧内容从模型的视野里换下来,日志一个字没删。「发生过」与「看得见」从此是两个集合。这就是压缩之后回查日志仍能看到全部历史、而记账结果仍然对得上的原因。

日志另一头要面对的是崩溃,而崩尾不截断,因为崩溃前的事件已经是事实。持久化文档写明:在轮次(turn:从收下一条输入到不再欠下工作的一段时间)中途崩溃的日志,不会被截断或修复。长任务里一个轮次可能很大,而那些事件在崩溃前已被持久写下,是确凿发生过的。被丢弃的只有「撕裂的物理尾巴」:一次从未写完的写入留下的碎片。缺失的收尾(比如没合上的轮次)由恢复方在读日志时补记,作为普通新事件追加。「这个轮次后来断了」是读时补上的结论,不是写时的记录。

三个机制之间还有一条排序纪律:按成本与有损度排先后。排水是无损的,原文原样落盘,发生在结果写进会话之前。裁剪是确定性的纯字符操作,压缩提供方在选定范围之前先调用它、再重新测量。压缩是唯一需要一次模型调用、唯一有损的手段,留在最后。先花便宜的、确定性的,量完还超,才动用贵的、有损的。这是成本结构,不是巧合。

mermaid 图表

「遮蔽」那条虚线只落在上面那条投影路上:它改的是「给模型看什么」,不是「日志里有什么」。所以无论视野被换成摘要多少次,下面那条派生路读到的仍是真实发生过的全部。

唯一真相与四层派生

mermaid 图表

真相层是 SessionEvent 的追加序列:序号从 0 连续,永不改写。当前的 SESSION_FORMAT_VERSION 是 4,而这个版本号写在头记录里——头(版本、cwd、血缘、是否 seeded)与日志正文是分开存的。

下面四层都可以从这份日志重新算出来(除了物理落盘本身),所以它们坏掉不会损坏真相:投影缓存旧了可以重算,全文索引可以直接丢掉重建。

落盘

落盘是一条标准的服务 seam:ctx.sessionPersistence 是抽象服务(create / open / stat / list),返回一个 handle(read / append / flush / close)。出厂只有一个后端实现。

几个值得记住的物理事实:

  • 一会话一文件:路径是 <root>/--<项目slug>--/<编码后的 sessionId>/session.v4.jsonl.zstd。项目 slug 与编码后的 id 一起保证了”同一个项目、同一个会话”能稳定定位。
  • 首行是头记录(type: 'session'),之后一行一个事件。所以”读会话”不是读一个大 JSON,而是逐行解析。
  • 默认逐帧 Zstd 压缩。append 是分批 fsync 的,而 flush 是一个真正的持久性屏障——它返回就意味着数据落住了。
  • 文件名里的 v4 跟头里的格式版本是配套的。

flush 这个屏障有一个专门的消费者:持久性检查点插件。它在 llm/stream 之前、tools/execute 之前、以及 agent/pre-step 里调用 ctx.sessions.flush(),而且失败即 fail-closed——模型请求或工具根本不会被调用。这条策略的意思是:宁可什么都不做,也不要让”做过的事”丢在缓冲区里。

崩尾恢复

进程被 kill 的时候,最后一行可能只写了一半。这个后端的处理是截断撕裂的尾巴:只丢写坏的那一小段,前面完整记录救回来之后重写文件。

而”最后一个 turn 没有收尾事件”(比如崩溃时 turn 才进行到一半)不是靠写入方补的——是读方补 closers。也就是说:恢复出来的会话,其”结局”是读的时候推导出来的,不是当时写下的。

这个设计对插件有一层含义:不要假设日志里每个 turn 都有配对的结束事件。要写”成对”的消费者,得自己兜住缺失。

两类损坏,两种修法:

mermaid 图表

派生读模型

三条路,互相不知道对方存在:

路 服务 是什么
投影 ctx.sessionProjections 把事件折成一组纯函数算出来的状态
检索 ctx.sessionQuery 精确读、过滤、血缘、事件关系
计量 ctx.tokenMeter 重放事件折出压力与花费(24-会话用量与成本的计量)

投影是三条路里唯一给插件留了正式扩展点的:你注册一个”单元”,写一个纯函数 apply(state, event),订阅 session/event 是框架的事。框架订一次,把每个事件折过每个单元;客户端只收成品值。

这正好把 09-订阅 DSH 事件与五种分发模式的东西框架化了:09-订阅 DSH 事件与五种分发模式你手写”自己订阅、自己累加”,这里你只写折叠函数,不写订阅。而注册本身是 effect(register() 返回 disposer),插件卸载后这个 key 就从快照里消失,客户端把它读成”这个能力不在”。

投影还分两边:

  声明位置 需要客户端那一半吗
wire 单元 SessionProjectionMap 需要:值要发给页面
host-only 单元 SessionProjectionStateMap 不需要

这个区分对本篇的 demo 很关键:只写 host-only 单元,就不用碰 14-把插件配置搬进设置页那套浏览器半的构建。

检索这边有两处现状要知道,因为它们直接影响”想回查几天前聊过什么”这个需求:

  • 出厂把全文检索关掉了——索引行的配置是 openAt: never,意思是”永远不要为它开那个索引”。
  • 给模型的 5 个检索工具不在任何出厂配置里:它们是一个独立的工具包,谁想要谁自己挂。

所以这个需求在出厂状态下是不成立的,需要你自己补两处配置。这不是遗漏,是刻意把成本留到有人真的要的时候——索引会占空间,打开就会产生开销。

上下文收缩:三件不同的事

这一层是最容易混的地方。三个机制的输入、落点、时机都不一样:

机制 输入 落点 什么时候发生
排水 spill 刚产生的工具结果文本 模型看到的结果被换成预览 + 定位符 结果进日志之前
裁剪 prune 表面上的 tool/result 节点 新追加一条替换节点 + 前一条标价 结果已经在日志里之后
压缩 compaction 表面上的一段事件区间 一条摘要 user/message 压力越过阈值时

所以正确的因果链是:

排水(产生时) → 进日志 → 裁剪(压力逼近时) → 压缩(越阈值时)

排水是”太长了,落成文件,模型只看预览和怎么去拿”。它的服务和实现都极小——ctx.spillStore 只有一个方法 saveText;策略由另一个插件消费:结果里估算的 token 数超过 maxInlineTokens 就排水(字段省略则整个策略不启用)。

裁剪是”这段工具结果的中段没用,挖掉”。它的阈值是按字符数的:超过 8192 就保留头 4096、尾 1024,中间换掉。注意它发生在压缩之前、表面稳定快照上,并且会给前一条 tool/result 追加一条 compaction/prune 标价事件——所以它是可追溯的。

压缩是”这一大段换成一段摘要”。它把表面上的一段区间替换成一条 user/message(摘要本身),日志里留下 compaction/start、compaction/summary、compaction/end 三条事件。默认阈值是”用掉 80% 就压”,压完保留约 16%。

三个机制里,压缩是唯一”读者能感觉到丢东西”的——因为摘要是有损的。

但原事件一条都不会删。 压缩只是在表面上用 surfaceOp: replace 把那段事件遮蔽掉。所以:

  • --dump-config 或读日志仍然能看到全部历史;
  • 投影这类只读消费者算出来的计数,反映的是真实发生过的事,不是”表面上还剩什么”。

这条性质值得记牢:会话日志里”发生过”和”模型看得见”是两个不同的集合,压缩只动后者。

四个 checkpoint

checkpoint 这个词在仓库里出现在四个完全不同的地方。写文档时如果不点名区分,读者一定会把它们当成一个词:

叫法 是什么
压缩的 checkpoint 来源标记 压缩产生的那条替换用 user/message 的来源标记,让消费者认出”这条消息是一次压缩的检查点”。它是表面节点,不是文件
持久性检查点 前面提过的那个插件:在关键动作前 flush(),失败 fail-closed
投影检查点 ctx.sessionProjectionCache 把每个投影单元的状态按会话落盘
投影缓存的强制写入点 会话创建、turn/end、会话销毁这三个时刻一定写

第三条和第四条是配套的:缓存是折叠捷径,用它可以跳过重放历史,代价是可能旧——但因为写入点被钉在三个确定的时刻,它永不错。少算的部分用日志补齐就行。

这个”可能旧但永不错”的性质,正是所有缓存型派生的通用契约。

会话账本

demo 注册一个 host-only 投影单元,记”这个会话被压缩过几次、工具结果被裁剪过几次”,再开一个工具让模型能读这本账。

单元的定义就是一个纯函数加一点元信息:

const definition = {
    key: 'sessionDigest' as const,
    // 折叠语义一变就把版本号加一:旧的持久化行会被丢掉,而不是接着用。
    stateVersion: 1,
    stateSchema: digestSchema,
    init: (): DigestState => zero,
    // 纯函数:不关心的事件必须返回同一个引用,下游才不会白算。
    apply: (state: DigestState, event: SessionEvent): DigestState => {
        if (event.type === 'compaction/end' && event.data.error === undefined) {
            return { ...state, compactions: state.compactions + 1 }
        }
        if (event.type === 'compaction/prune') {
            return {
                ...state,
                prunes: state.prunes + 1,
                lastShadowedTokens: event.data.shadowedTokenCount,
            }
        }
        return state
    },
}

三个细节值得留意:

  • stateVersion 是给持久化用的闸门。 折叠语义一改就加一,旧缓存行会被丢弃、从日志重算——而不是拿旧数据接着用,算出一个安静错误的结果。
  • 不关心的事件必须返回同一个引用(return state)。这是”纯折叠”的约定:返回新对象会让下游白算,累积起来就是性能问题。
  • stateSchema 用 zod,而插件的 Config 用 Schemastery。这是仓库的惯例分工,两者别混。

注册和读取分居两处:

export function apply(ctx: Context): void {
    // 注册本身就是 effect:卸载时这个 key 从快照里消失,不用自己记账。
    ctx.sessionProjections.register(definition)
    …
}

        execute: (_args, exec) => {
            const session = exec.agent?.session
            if (session === undefined) return 'no agent-bound caller'
            // stateOf 会把该单元当场补算到会话当前游标,所以注册晚于事件也能读到正确值。
            const state = ctx.sessionProjections.stateOf(session, 'sessionDigest')
            return JSON.stringify(state ?? zero)
        },

stateOf() 的语义是”把该单元当场补算到会话当前游标,再返回”。所以单元注册晚于事件也没关系——它会懒建 cell、把历史补上。这一点对”插件热重载之后账本还对”很重要。

也正因为它是 host-only 单元,这里用的是 stateOf(读单个单元的状态),而 snapshot() 只返回 wire 单元——那是发给页面的那份。

类型上要做一次模块增强,让 stateOf 的返回类型对得上:

declare module '@deepseek-ai/dsh-session-projection/types' {
    interface SessionProjectionStateMap {
        /** 本会话的压缩 / 裁剪流水账。 */
        sessionDigest: DigestState
    }
}

验证

cd code/dsh_plugin/dsh-session-digest && ./install.sh

四步,从配置一路走到账本:

# 1. 先看配置对不对
dsh --profile test --dump-config | grep -A3 'id: dsh-session-digest'

# 2. 造一个大工具结果,看排水是否生效
#    (base 配置里 maxInlineTokens 是 12500,可以先在 profile 里把它调小)
dsh --profile test "读一下仓库里最大的那个文件,把前 200 行贴出来"

# 3. 在会话里输入 /compact,看命令回执
#    应当出现类似:Compacted 12 history items (~34567 tokens).

# 4. 让模型读账本
dsh --profile test "调 session_digest 看看这个会话被压缩过几次"

第四步应当返回 {"compactions":1,"prunes":0,"lastShadowedTokens":…}——账本记下了第三步那次压缩。

有一处看起来矛盾但其实正确的现象值得专门看一眼:压缩之后,表面上那段事件没了,但账本里的 compactions 是 1。因为 compaction/end 是真实发生过的事件,它一直在日志里;而 compaction/summary、compaction/prune 这类事件是 log-only 的——它们永远不进模型上下文,只给记账和审计用。

这就是”日志是真相、表面是投影”最具象的一个例子。

注意事项

  • 别把压缩当成删除。 原事件只在表面上被 surfaceOp: replace 遮蔽,日志里一条不少。要”回查原始内容”,读日志就行;要”模型看得见什么”,看的是表面。
  • 出厂状态查不了历史。 全文检索的索引默认 openAt: never,给模型的 5 个检索工具也不在任何出厂配置里。要这个能力得自己补两处配置,并且清楚它会带来索引开销。
  • 别假设 turn 有成对的结束事件。 崩溃恢复时中断的 turn 由读方补 closers,日志里可能没有配对的收尾。
  • 持久性检查点失败是 fail-closed。 flush() 失败时模型请求和工具体根本不会被调用。这是刻意的:宁可不动,也不要做了却没落住。
  • 投影单元必须是纯函数。 不关心的事件返回同一个引用;有副作用或返回新对象都会让下游白算。另外改语义就加 stateVersion——这不是版本洁癖,是防止旧缓存行被当成新语义接着用。
  • host-only 与 wire 单元别写混。 前者进 SessionProjectionStateMap、用 stateOf 读、不需要客户端那一半;后者进 SessionProjectionMap、用 snapshot 读、要写浏览器半。写错了的表现是”值算出来了但页面上没有”或者反过来。
  • 落盘后端是第一层,不要绕过它写文件。 直接往磁盘写事件会破坏”seq 连续、可重放”的前提,而压缩、投影、检索全都建立在这个前提上。