DeepSeek Harness 的会话投影:事件流怎么变成客户端看到的那个值

2026-08-17

先把问题摆具体一点:一个会话跑到一半,客户端手上要有一份「当前待办清单」「当前 token 用量」「当前标题」。这些东西都不是某条事件本身,而是整条日志折叠出来的结果。谁来折?折出来的值凭什么在断线重连、页面翻历史、进程重启之后不会倒退回旧版本?

DeepSeek Harness 仓库里回答这个问题的那一层叫会话投影(session projection)。先说清限定:该项目 README 自述处于开发者预览阶段(Developer preview),并明确写着会有破坏兼容性的变更,下面提到的字段名、默认值、配置项随时可能变,请以仓库最新内容为准。本文所有说法来自仓库源码与 docs/ 文档,我们没有安装、也没有运行过它。

答案的第一句:客户端不折叠

packages/client/runtime/src/client/sessions/projection-store.ts 文件头的注释把话说死了:host 是唯一的计算点,客户端只持有每个 key 的成品全量值,形态是 key → { value, seq }。同一段注释还写明「不存在客户端侧的领域折叠」——一个领域要支持投影,可以做到零客户端代码。

这一句决定了后面所有设计。客户端不折叠,就意味着 host 侧必须有个东西替所有领域折,而且折出来的值得能自描述、能比较新旧。

一个 unit 长什么样

领域交给框架的东西叫 ProjectionDefinition,定义在 packages/session/session-projection/src/index.ts。它有六个成员:keyschemainit()apply(state, event)view(state)stateVersion。文档里反复强调这是「三个纯同步函数加上声明」,而不是一个不透明的 getter。

拿最好懂的一个看,packages/todo/tool-todo/src/index.ts 注册的 todos 单元:init 返回 nullapply 只认两种事件,遇到 todo/write 就返回 event.data.todos,遇到 turn/start 就返回 null,其余一律 return stateview 直接把 state 原样吐出去;stateVersion 写的是 2

那个「其余一律 return state」不是偷懒,是硬约束。包 README(packages/session/session-projection/README.md)把它列为契约的一条:apply 对不关心的事件必须返回同一个引用,驱动侧用 Object.is 把变更流卡住,于是不相关的事件只花掉一次函数调用,下游一点活都不用干。这行判断就在 index.tsdrive() 里(const changed = !Object.is(next, cell.state))。

另一条承重规则是「全量值事件规则」:携带状态的日志事件必须携带变更后的完整状态,绝不是裸增量。todo/write 携带的是整份替换列表,所以折叠就是 last-wins。文档自述这条规则的作用是让每次状态转移都足够廉价、让每个被供给的值自描述。

驱动权在框架手里

SessionProjectionRegistry 的构造函数里只有一件事:ctx.on('session/event', …) 订阅一次,然后把事件丢进私有的 drive()。领域插件自己不持有任何订阅——它们在 ctx.inject(['sessionProjections'], …) 下注册,所以没装这个注册表的 headless 组装完全不受影响。

cells 是惰性建的。每个「会话 × 单元」有一个 UnitCell,里面是 stateobservedSeq,用 WeakMapSession 为键存着。如果一个单元是在事件已经流过之后才注册的,或者这个会话比注册表还老,第一次触达(无论是来事件还是被读)时才从 init 出发在内存日志上折一遍。这里有个细节值得记:drive() 里中途补建 cell 用的是 session.events.slice(0, event.seq),注释直说依据是「seq 就是日志下标,所以这个前缀切片是精确的」。

快照的 asOfSeq 是怎么来的

snapshot(session) 完全同步,返回 { asOfSeq, values }asOfSeq 的值直接写在源码里:session.seq - 1;空日志是 -1,文档说这是刻意与 session/subscribed.lastSeq 对齐的。

同步这件事是承重的。载体(packages/host/apiproxy)在切出历史页面切片的同一个 tick 里读快照,所以两次读用的是同一个序号,asOfSeq 才敢称作一致性切口。反过来,如果谁把 view 误写成 async,它会返回一个 Promise——snapshot 里每个值返回前都要过 schema.parse,Promise 会在这里被拒掉。包的 invariant 伴生插件(src/invariant.ts)里干脆写明:本包没有运行期不变量检查,同步纪律只能靠边界上的 schema.parse 尽可能拦住。

值往客户端走有两条路:历史尾页带一个 projections 块(packages/host/apiproxy/src/api/sessions.ts 里的 SessionProjectionsBlock,形状同样是 asOfSeq + values),以及变更流转成的 session/projection 推送帧。后者在 api-proxy.ts 里就是一句 onChanged(...)broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })。注意 Service Definition 包自己不含任何 wire 词汇,帧是载体铸的,README 把这条单列成一项契约。

客户端只认一条规则:seq 大的赢

回到 projection-store.tsapply(key, value, seq) 的第一句判断是 if (row !== undefined && seq <= row.seq) return,注释写着「higher seq wins;重放与过期帧丢弃」。基线播种走 seed(baseline),把块里每个 key 按同一条规则灌进去,然后把块里没有、且 row.seq <= baseline.asOfSeq 的行删掉——也就是说,一份过期基线既不能覆盖更新的值,也不能清掉它。

还有个容易被忽略的 truncate(lastSeq):当 session/subscribed 帧带来 host 自己的持久基线时,凡是 seq 高于它的行都要丢。注释给的理由是这些行骑着一次重启已经丢掉的状态,在 last-wins 下会永远压过 host 重算出来的低 seq 值。

冷路径:checkpoint、restoreFloor 与那个「低一格」的锚点

会话不在内存里的时候,重新折全量日志显然不划算,所以有 packages/session/session-projection-cache。持久行的形状是 (sessionId, key, ver, seq, val),域声明在 src/spec.ts:域名 session_projcacheversion: 3,注释自述随附组装的 json 后端会把它落在 <root>/session_projcache.json,与 workspace.json 并排。

写入节奏在 installWritePath() 里:turn/endsession/disposed(live 转 cold 的那一刻)是两个强制写点,源码注释称它们是策略而非可调项;其余事件走计数与定时两个节流阈值,分别是 Config 里的 writeEveryEventswriteIntervalMs。这两个值源码里没有内置默认,Config 只声明了 z.natural().min(1).required(),实际取值由组装给出——随附的 packages/bundle/web-app/cordis.patch.yml 里写的是 writeEveryEvents: 200writeIntervalMs: 5000。这是那份组装的部署选择,不是「你用起来会怎样」的保证。

读侧是一道阶梯。restoreFloor(checkpoint) 算出 tail 读的起点,它刻意取「比最低可用水位线还低一格」(Math.max(floor - 1, 0))。注释把这个锚点称作承重设计:读回来的 tail 因此能证明存储日志到底还延伸到哪,restore 就能识别出日志被崩溃修复截短到某行水位线之下的情况,而不是把过期行当成当前值端上去。restore 里判定一行可用要同时满足三条:ver 与活单元的 stateVersion 相等、row.seq >= baseSeq - 1row.seq <= endSeq;不可用且 baseSeq > 0 就直接抛错,把「从 seq 0 全量重读」这个决定交还给调用方——coldSnapshot 接住这个异常后确实会再读一次整条日志。

顺带一提,checkpoint(session) 返回的每个 val 都是 structuredClone 出来的脱离副本。源码注释说得很直白:水位线缓存是注册表自己的权威可变状态,调用方要是摸到活引用,能顺着它污染之后每一次快照与每一帧。

一处文档与源码不一致,说完就停

packages/session/session-projection/README.md 第 11 行写的是「Duplicate keys and invalid stateVersion throw」,docs/subsystems/session-projection.md 的注册表小节也写了 duplicate keys throw。但同一个文档页下半部分由脚本从源码生成的 Cordis API 小节里写的是另一回事:共享同一个 key 的注册方共用一个单元并且被计数,同一个工具包挂在 N 个 agent preset 上就注册 N 次,key 活到最后一个卸载为止。

src/index.tsregister() 的实现与后一种说法一致:key 已存在时,只有 stateVersion 不同才抛错,相同则 existing.refs += 1;disposer 递减 refs,减到 0 才删 key。两处口径不一致,以我们实读的源码为准;这里只陈述差异。

用之前值得先知道的几条边界

包 README 的「Known Limitations and Deferred Work」里有几条是自己承认的,比翻源码省事:其一,每个尾页会带上全部已注册的 key,目前没有按 key 退订或按需请求的形状;其二,单元表是进程级的,key 出现与否不是「这个会话有没有这个能力」的信号——任一 agent preset 注册过的 key 会出现在每个会话的快照里,所以客户端应该读值(比如 plan.active、空的 todo 列表)而不是把 key 缺失当功能缺失;其三,注册表的 cells 只在内存里,重启后靠首次触达时折日志重建,挂了投影缓存的组装才会改从持久行起折。

另外有一个被明确「特许」的例外:api-proxy.ts 里注册的 imageLimits 单元,init 返回 nullapply 永远返回同一个引用(所以它永远不会推变更帧,只靠基线携带),view 读的是活服务而不是 state。注释自述这个例外只针对「进程内恒定」的单元。你要照着写自己的单元,先确认自己的值真的是启动期恒定的。

想动手看的话,入口就三个文件:packages/session/session-projection/src/index.ts 看驱动与折叠,packages/session/session-projection-cache/src/index.ts 看写入节奏与冷读阶梯,packages/client/runtime/src/client/sessions/projection-store.ts 看客户端那条 higher-seq-wins。中间那段折叠数学在 packages/todo/tool-todo/src/index.ts 里只有十来行,是最短的样本。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。