
字节笔记本
2026年10月6日 · 约 8 分钟读完
DeepSeek Harness 会话投影子系统解析
DeepSeek Harness 是 DeepSeek 开源、以 MIT 协议发布的 agent 框架,主打一切皆插件,本文素材取自其官方文档的子系统篇。这个框架的内部真相是一份只会追加的会话日志:用户说了什么、模型答了什么、工具跑了什么,全部以事件的形式落进日志。但界面上真正需要的是状态:当前待办长什么样,某个面板该显示什么值。从日志到状态的换算如果交给每个客户端自己做,每个消费方都得重放一遍日志,逻辑重复,还容易算错。
会话投影(session projection)就是把这一步收进框架的机制。它是框架里一项可选的能力接缝服务,不在 agent loop 主干上,职责只有一条:向客户端载体供给按会话的日志派生状态的当前全量值。分工写得非常清楚,框架负责驱动,领域负责计算。

三方角色与一条铁律
整条链路有三方参与。服务定义与注册表由 dsh-session-projection 包提供,插件通过 ctx.sessionProjections 拿到它;领域贡献方是每个领域注册的一个纯计算单元;载体则是 apiproxy 包的历史尾页与 session/projection 推送帧,负责把值送到客户端。
运行规则只有一条铁律:注册表只订阅一次 session/event,把每个已提交事件折叠进每个单元。领域自己不持有任何订阅,客户端也从不折叠领域事件,它们拿到的永远是算好的成品值。
投影单元:三个纯函数加两份声明
领域为每个投影 key 贡献一个 ProjectionDefinition,接口约定如下(简化示意):
interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
key: K
schema: ZodType<SessionProjectionMap[K]> // 线上载荷出站前校验
init(): S // 空日志的初始状态
apply(state: S, event: SessionEvent): S // 纯转移
view(state: S): SessionProjectionMap[K] // 状态到全量值
stateVersion: number // 持久缓存失效版本
}三个函数必须是同步函数,状态必须是纯 JSON。前者是载体一致性切面的前提,异步单元会撕开这道切口;后者是持久缓存能整体结构化克隆的前提。init 给出空日志的初始状态;apply 是纯转移,对不感兴趣的事件必须原样返回同一个状态引用,引用未变(Object.is 相等)就不会产生任何下游工作;view 把状态映射成对外的全量值,出站之前会被单元自己的 schema 校验一遍。这个校验还顺带防住一类实现错误:如果 view 被误写成异步函数,返回的 Promise 会被 schema 直接拒绝。
stateVersion 是持久缓存的失效版本号:序列化字段或折叠语义一变就升位,旧版本留下的缓存行会被整行丢弃,而不是被新代码向前套用出垃圾。
全量值:日志里没有裸增量
这套设计的承重结构是全量值事件规则:携带状态的日志事件,携带的是变更后的完整状态,绝不是裸增量。好处有两条。其一,每次状态转移始终足够廉价,不用先追历史再计算;其二,每个被供给的值自描述,对消费方而言就是 last-wins,晚到的值直接覆盖早到的值,不需要按序合并。
快照与变更流
客户端读状态走 snapshot(session)。这次读取完全同步,载体在切出页面切片的同一个 tick 内完成,快照里的 asOfSeq 是共享水位线:所有值与这个序号反映的是同一个日志位置,空日志时为 -1。变更流则是推的一侧:对每个已提交事件,每个状态引用发生变化的单元触发一次监听回调,携带 schema 校验后的值与该单元的水位线。状态没变,apply 返回同一引用,变更流就一声不吭。
注册表的驱动权
注册表拥有驱动权:一份 session/event 订阅、对每个已注册单元的即时 apply 调用,以及每会话每单元一个水位线 cell。cell 惰性构建:事件流过之后才注册的单元,或者比注册表更老的会话,都在第一次被触达时从 init 出发在内存日志上补折叠。
注册是一个 effect,释放器随调用方 fiber 走:领域插件卸载后,它的 key 连同缓存的 cell 从后续驱动与快照里消失,客户端把这种缺席读作能力缺失。key 重复注册直接抛错;同一个工具包挂在 N 个 agent 预设下会注册 N 次,注册表按计数管理,最后一个卸载后 key 才真正消失。领域插件在 ctx.inject 依赖下注册,所以不带注册表的 headless 组装完全不受影响。
持久缓存与冷读阶梯
会话关掉之后,状态从哪来?ctx.sessionProjectionCache 负责这件事。它在初始化时打开 session_projcache 持久域,对活跃会话按节流的 write-behind 策略做检查点,触发条件是次数或间隔,来自配置;另有两个强制检查点:turn/end 与会话销毁,也就是从活到冷的那一刻。行格式是 (sessionId, key, ver, seq, val),val 是彻底剥离的 structured clone,绝不是注册表里的活引用,防止调用方从缓存摸到权威可变状态。

冷读走一条四级阶梯。第一级 cachedSnapshot 是零 I/O 的列表读,直接从存储行取全量值,只取版本匹配的 key,调用方传入的会话头就是身份凭证,绝不串到无关日志;新鲜度以最后一次检查点为界,但绝不出错。第二级 coldSnapshot 不加载全量日志:拿缓存行,再从 restoreFloor 给出的位置起读一段持久层尾段,交回注册表重折叠。restoreFloor 的锚点设计很讲究,它锚在最低可用水位线的下一位,尾段由此能证明存储日志到底延伸到哪;没有行或版本不匹配,地板直接拉到 0,该 key 必须重折全量日志。第三级 restore 用可用行做种子折叠尾段,行的可用性看三条:版本匹配,序号落在尾段窗口之内,且不声称超过日志末尾。日志被崩溃修复截短的情况会在这里被当场识破,而不是把过期行当成现值端出去。第四级写回是 fail-soft 的:持久写失败只记警告,下次写入或冷读时自愈,越折越近。版本不匹配或日志截短时,代价只是从 seq 0 全量重读一次,阶梯变慢,但永不读错。
小结
会话投影的每个约定都在回答同一个问题:怎么让多端看到同一份状态,又不让任何一方偷偷持有逻辑。领域只写纯数学,框架独占驱动,客户端只收自描述的全量值,缓存用版本号与水位线兜底。对想研究 agent 框架内部设计的人来说,这份子系统的取舍清单相当值得细读,源码就在仓库的 packages/session/session-projection 包里。



