给 DeepSeek Harness 的 Web 端加一个会话节点:前端这条链怎么接
先把限定说在前面:DeepSeek Harness 的 README 自述处于开发者预览阶段,原文里那句话是全大写的「未来将出现破坏兼容性的变更」。下面提到的每一个字段名、每一条错误文案、每一个默认值,都是从版本 0.1.0-rc.5 的仓库快照里读出来的,它们随时可能变。你照着写之前,先去仓库里对一遍。
起点:一行进度条,为什么不能直接塞进 Chat 组件
假设你有个业务:一个 review 任务在后台跑,你想让它在 Web 端的 Chat 流里占一行,显示标题和进度,跑完换成摘要。
最偷懒的做法是拿到 Session 事件流,自己扫一遍,找出 review/start 和后面的 review/progress,拼成一个对象丢给组件。仓库里的 cookbook 文档 docs/cookbook/adding-a-conversation-node.md(同目录有 .zh.md 中文版)开篇第一段就把这条路堵死了:它要求你产出的插件「不扫描 Session 窗口或其他已渲染节点」。文档还写明,完整的引擎模型与设计理由在 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md 那份决策记录里,cookbook 本身只讲实现路径。
先纠一个容易走岔的地方。这套东西不在 packages/web/ 下。packages/web/ 里是 tool-web、web、web-fetch-http、web-search-deepseek、web-search-exa、web-search-perplexity 六个目录,其中 packages/web/web/package.json 的包名是 @deepseek-ai/dsh-web,描述里写的是 web 访问能力(search/fetch provider 注册表)。Web Client 这条会话装配链在 packages/client/ 下:契约与引擎在 @deepseek-ai/dsh-client-runtime,Chat 的业务 Definition 在 @deepseek-ai/dsh-client-ui-conversation。数包的时候注意 packages/*/* 是两层目录,web 是组名不是包名。
第一站:Definition 的契约
契约文件是 packages/client/runtime/src/client/contract/conversation.ts。ConversationNodeDefinition<State> 里必填的只有四项:kind、match、start、update;可选的是 target、publication、buildLocationData、buildViewNode。
match(event) 的注释写得很直白:它只拿到当前这一条原始事件,「no Context or history access is available」。它的返回值也只有两个字段——id 和 role('start' | 'update')。也就是说,match 是身份提取器,不是折叠函数。你不能在这里判断「这条 update 属于最近那个没结束的任务」,你必须让每条事件自己带上稳定业务 id。
id 拿到之后,引擎用同一个文件末尾的 conversationContextKey(kind, id) 算 key,实现是这一行:
return `${kind.length}:${kind}${id}`
长度前缀在前,所以 kind 与 id 的边界不会被拼接歧义吃掉。这个细节值得记住:Context 的 key 是引擎算的,不是你给的,后面校验会用到它。
如果你的业务只有孤零零一条事件,文档说可以直接拿 event.seq 当 Definition 内部 id。仓库里就有现成的:packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts 的 match 写的是 { id: String(event.seq), role: 'start' }。
第二站:注册时就会挡住你的两条硬规矩
注册入口在 packages/client/runtime/src/client/conversation/event-registry.ts。这个文件很短,但两条校验都在里面。
一是 assertDefinitionTarget:target 和 buildViewNode 必须同时有、或者同时没有,否则抛 must declare target and buildViewNode together。声明了 target 却忘了写 builder,注册那一刻就炸,不会拖到渲染时才变成一行空白。
二是重名。kind 在注册表里唯一,撞了抛 conversation Definition "..." is already registered。另外 fallback 是单例,registerFallback 要求必须声明 target,且只能装一个。
顺带说清 fallback 的触发条件,它藏在装配器的 dispatchInput 里:一条事件先让所有普通 Definition 各跑一次 match,命中的 Definition 如果声明了 target,就把这个 target 记进 matchedTargets;只有当 fallback 的 target 没有被任何普通 Definition 占住时,fallback 的 match 才会被调用。所以 fallback 不是「兜底再补一刀」,是「这个 target 这轮没人认领才轮到它」。
这些注册点在仓库里有多少?我们在 packages/client/ 下(不含 tests)数出 22 处 ctx.conversationEvents.register( 调用、1 处 ctx.conversationEvents.registerFallback(、2 处 ctx.conversationViews.register((分别是 Chat 和 Trajectory 两个 target)。这里都带上 ctx. 前缀数,否则会把注册表自身用于日志的同名模板字符串也算进去。其中 packages/client/ui-conversation/src/client/conversation-nodes/ 这一个目录就有 14 个文件,register.ts 里按固定顺序把它们串起来。
第三站:Assembler 把你的四个函数怎么调
装配器是 packages/client/runtime/src/client/sessions/conversation-assembler.ts,808 行,是这条链上最厚的一块。
它维护一张 contexts map,key 就是上面那个 conversationContextKey。事件进来后按 (kind, id) 找到 Context,role 是 start 就调一次 start,是 update 就把当前 State 交给 update。两个函数都必须返回 State——返回 undefined 会被 requireState 拦下,抛 conversation Definition "..." returned undefined from start()(或 update())。
这里有一串写死的顺序约束,全都是直接 throw,值得抄下来当自查清单:
| 触发条件 | 错误文案(节选) |
|---|---|
同一个 (kind, id) 出现第二条 start | received more than one start Match |
| start 之前先来了 update,且合并后首条不是 start | received an update before its start Match |
新 Match 的 seq 不大于已有最后一条 | received non-appended Match |
同一个 seq 被重复并入同一 Context | received duplicate Match |
| 同一个 key 上出现不一致的 Definition/id | received inconsistent Definition identity |
「start 之前只有 update」并不等于报错。文档第 1 节说的是另一种情况:如果当前历史窗口里只有 update,Assembler 会保留一个 pending Context,不构造 State,等更早的分页把 start 补上。上面那条 received an update before its start Match 是合并完成后首条仍不是 start 才抛的。这两件事经常被混在一起理解。
顺便说一下分页粒度:packages/client/runtime/src/client/sessions/session.ts 第 32 行导出 PAGE_MESSAGES = 50,历史请求、gap 修复几处都用这个值。它是源码里的默认常量,不是对你实际会加载多少事件的承诺。
第四站:reader.previous(),以及它记了什么
start 的第三个参数是 ConversationContextReader,只有一个方法 previous<State>(kind):返回当前 start 的 seq 之前、最近一个已经有 State的同 kind Context。实现里 previousContext 用二分找到插入位,再往前逐个跳过 state === undefined 的 pending Context。
真实用例在 message.ts。它的 Definition kind 是 input-message,在 start 里这样判断一条用户消息到底是普通输入还是插队:
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
被上一个 inbox-next-step Context 认领过的,就归类成 steering,否则是 user。而 inbox-next-step 那个 Definition 就在 inbox.ts 里,它没有 target、update 直接返回原 State、publication 恒为 'none'——一个纯粹只攒状态、不渲染任何东西的 Context。这是这套设计里比较反直觉的一点:Definition 不一定要产出 Node。
调用 previous() 的代价不是白拿的。readerFor 会把这次查询记成一条依赖,字段有四个:kind、key、revision、windowGap。其中 windowGap 的取值是 predecessor === undefined && this.hasMore——没找到前序、并且还有更早的历史没加载,才算 gap。之后 replayDependencies 会拿这三项(key、revision、windowGap)逐个比对,任意一项变了就把依赖方 Context 从 start 整个重跑一遍,再按 seq 升序回放它的 update。所以你写在 start 里的逻辑必须是可重入的,它被跑第二次、第三次是常态。
第五站:发布节奏与那个降级
publication 的三档在契约里定义为 'none' | 'animation-frame' | 'immediate',装配器里给了权重:none: 0、'animation-frame': 1、immediate: 2,同一批里取最大值。不写 publication 的默认值是 immediate——代码里是 definition.publication?.(match) ?? 'immediate',契约注释也写明「omission defaults to immediate」。
animation-frame 落到 packages/client/runtime/src/client/sessions/notifier.ts 的 markFrameDirty,里面有一行值得注意:它先判断 typeof globalThis.requestAnimationFrame === 'function',是函数才排 'frame',否则退回 'microtask'。文件顶部注释写的是「N 次 markFrameDirty 合并成一次 animation-frame flush」。也就是说,在没有 requestAnimationFrame 的运行环境里,这档的合帧行为会退化成微任务。这一点写测试的时候尤其容易踩。
要提醒的是,节奏只影响视图物化的频率,不影响状态推进。文档原话是引擎仍会按日志顺序应用每条 update,publication 只合并发布。
第六站:Node 怎么排到那一行上
Chat 的快照构建在 packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts。排序逻辑就一行:先 filter(node => node.visibility === 'visible'),再 sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))。
所以 anchorSeq 决定你这行落在哪,key 只是同值时的稳定兜底。anchorSeq 必须来自持久的排序证据(通常是某条事件的 seq),不能临时编。仓库里还有个更细的用法:common.ts 导出的 CHAT_SYNTHETIC_SEQ_OFFSETS 是一组小数偏移——interruptedAssistant: -0.9、interruptedFollowup: -0.8、maxTokensNotice: 0.05、finalizedFollowup: 0.1。合成节点靠在真实 seq 旁边插队,用的就是这几个小数。同一个文件里的 chatNode() 帮你补默认值:location 默认取 contextLocation(context),visibility 默认 'visible'。
两条渲染期的强制校验在装配器的 buildNode 里:返回的 node.key 必须等于 context.key,否则抛 returned unstable key "..."; expected "...";node.target 也必须和正在构建的 target 一致。
最狠的一条在 flush() 的增量分支:如果某个 target 之前已经物化过 Node,这次却返回 null,直接抛——
conversation Definition "..." withdrew materialized target "..."; return the same key with hidden visibility instead
文档里那句「不要用 null 撤回,用 visibility: 'hidden'」不是建议,是运行时会拦你的规则。
一处放在一起看的地方
文档第 4 节写明,正常 append 热路径「不得遍历完整事件窗口、所有 Context、context.matches 或已渲染 Node 集合」。而 compaction.ts 的 buildViewNode 里,当 context.state === undefined 时会调用 fallbackState(context),这个函数对 context.matches 连做两次 find。两处白纸黑字就是这样,触发条件是 Context 尚未构造出 State(也就是 start 不在已加载窗口内)。我把它们并排放在这里,不替作者解释哪一处才算数。
还有一件事:Location data 是独占的
如果你不想自己出一个 Node,只想把值挂到当前 Turn 或 Step 上给别人读,用 buildLocationData(context, scope)。装配器里的 LOCATION_DATA_SCOPES 是 ['step', 'turn'],顺序固定,先 step 后 turn。现成例子是 packages/client/ui-deliverables/src/client/turn-deliverables.ts:它的 buildLocationData 判断条件写的是 scope !== 'turn' || context.state === undefined,两者任意成立就返回 null——也就是说不是 turn 这一档、或者 State 还没构造出来,就什么都不发;真正发布时用的 key 是 'deliverables',而整个 Definition 没有 target。
同一个 Location 上,一个 key 只能有一个主人。conversation-location-index.ts 里对应的错误是 conversation Location data "..." is already owned by ...。所以 key 撞车不会静默覆盖,会直接抛。
最后
这条链走下来,可复用的判断其实就三条:id 必须由事件自己带、start 必须可重入、Node 一旦发布就不能靠 null 撤回。剩下的大部分约束,仓库都写成了带明确文案的异常——真出问题时,把错误信息原样搜一遍源码,比对着文档猜要快得多。
再说一次那层限定:以上全部来自 0.1.0-rc.5 快照,该项目 README 自述处于开发者预览阶段并明写会有破坏兼容性的变更。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。