给 DeepSeek Harness 的 Web 端加一个会话节点:前端这条链怎么接

2026-08-17

先把限定说在前面: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-webwebweb-fetch-httpweb-search-deepseekweb-search-exaweb-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.tsConversationNodeDefinition<State> 里必填的只有四项:kindmatchstartupdate;可选的是 targetpublicationbuildLocationDatabuildViewNode

match(event) 的注释写得很直白:它只拿到当前这一条原始事件,「no Context or history access is available」。它的返回值也只有两个字段——idrole'start' | 'update')。也就是说,match 是身份提取器,不是折叠函数。你不能在这里判断「这条 update 属于最近那个没结束的任务」,你必须让每条事件自己带上稳定业务 id。

id 拿到之后,引擎用同一个文件末尾的 conversationContextKey(kind, id) 算 key,实现是这一行:

return `${kind.length}:${kind}${id}`

长度前缀在前,所以 kindid 的边界不会被拼接歧义吃掉。这个细节值得记住:Context 的 key 是引擎算的,不是你给的,后面校验会用到它。

如果你的业务只有孤零零一条事件,文档说可以直接拿 event.seq 当 Definition 内部 id。仓库里就有现成的:packages/client/ui-conversation/src/client/conversation-nodes/inbox.tsmatch 写的是 { id: String(event.seq), role: 'start' }

第二站:注册时就会挡住你的两条硬规矩

注册入口在 packages/client/runtime/src/client/conversation/event-registry.ts。这个文件很短,但两条校验都在里面。

一是 assertDefinitionTargettargetbuildViewNode 必须同时有、或者同时没有,否则抛 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,rolestart 就调一次 start,是 update 就把当前 State 交给 update。两个函数都必须返回 State——返回 undefined 会被 requireState 拦下,抛 conversation Definition "..." returned undefined from start()(或 update())。

这里有一串写死的顺序约束,全都是直接 throw,值得抄下来当自查清单:

触发条件错误文案(节选)
同一个 (kind, id) 出现第二条 startreceived more than one start Match
start 之前先来了 update,且合并后首条不是 startreceived an update before its start Match
新 Match 的 seq 不大于已有最后一条received non-appended Match
同一个 seq 被重复并入同一 Contextreceived duplicate Match
同一个 key 上出现不一致的 Definition/idreceived 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 里,它没有 targetupdate 直接返回原 State、publication 恒为 'none'——一个纯粹只攒状态、不渲染任何东西的 Context。这是这套设计里比较反直觉的一点:Definition 不一定要产出 Node。

调用 previous() 的代价不是白拿的。readerFor 会把这次查询记成一条依赖,字段有四个:kindkeyrevisionwindowGap。其中 windowGap 的取值是 predecessor === undefined && this.hasMore——没找到前序、并且还有更早的历史没加载,才算 gap。之后 replayDependencies 会拿这三项(key、revision、windowGap)逐个比对,任意一项变了就把依赖方 Context 从 start 整个重跑一遍,再按 seq 升序回放它的 update。所以你写在 start 里的逻辑必须是可重入的,它被跑第二次、第三次是常态。

第五站:发布节奏与那个降级

publication 的三档在契约里定义为 'none' | 'animation-frame' | 'immediate',装配器里给了权重:none: 0'animation-frame': 1immediate: 2,同一批里取最大值。不写 publication 的默认值是 immediate——代码里是 definition.publication?.(match) ?? 'immediate',契约注释也写明「omission defaults to immediate」。

animation-frame 落到 packages/client/runtime/src/client/sessions/notifier.tsmarkFrameDirty,里面有一行值得注意:它先判断 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.9interruptedFollowup: -0.8maxTokensNotice: 0.05finalizedFollowup: 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.tsbuildViewNode 里,当 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 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

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