
字节笔记本
2026年10月6日 · 约 9 分钟读完
DeepSeek Harness 消息反馈子系统解析
DeepSeek Harness 是 DeepSeek 开源的 agent 框架,MIT 协议,主打一切皆插件,底层由 Cordis 插件框架驱动,一条 npx @deepseek-ai/dsh web 就能在本地跑起 Web 界面。它的官方文档按子系统逐一成文,消息反馈(message feedback)是其中篇幅不长、但设计密度很高的一篇:用户对单条 AI 回复点好评或差评、可选附一句备注,这个最常见的产品功能,在这套框架里被拆成了存储域、版本令牌、Remote 约定和 UI 插件四层。本文把它拆开看一遍。
反馈为什么不进会话日志
实现包是 @deepseek-ai/dsh-message-feedback。设计上它有一个明确立场:单条消息的反馈与 Session 级的 feedback/record 事件是两回事。后者追加进会话日志,不可变;前者是本地存储域里的伴随记录(sidecar),保存在独立的 message_feedback 存储域中,不是会话日志的内容或投影,也不做遥测交接。
一句话概括:日志负责记录发生过什么,伴随记录负责保存现在怎么评价。两者分开之后,反馈可以随意编辑和删除,而不会污染不可变的审计日志。
数据模型与不透明版本号
每个持久化 Session 对应一条伴随记录,头部是身份信息 {createdAt, cwd},正文是以 MessageId 为键的反馈条目。每个条目包含五样东西:messageId,所属助手消息的稳定标识;rating,positive 或 negative 二选一;note,可选备注,通过校验后逐字保留;version,条目自己的版本令牌,每次实质性创建或更新都会换新;createdAt 与 updatedAt,由 Host 分配的 Unix 毫秒时间戳。
关键在 version。它是品牌化(branded)的不透明令牌,只能做相等比较,而且只跟目标消息的当前条目比;调用方不能对它排序,也不能自己合成一个。它是后面所有并发控制的唯一凭据。
三个操作,五类失败
对外只有三个操作,经 TypertRemoteService 与 @Remote 装饰器发布成 messageFeedback.list、put、delete 的一元 Remote 约定:list 读出一个 Session 生命周期内的全部条目,按首次创建顺序返回;put 为一条助手消息创建或整体替换反馈,请求必须携带调用方观察到的 ifVersion,即使这次写入不会改变任何值;delete 先观察版本再删除,条目本来就不存在时同样返回成功,响应是恒定的 absent: true,天然幂等。
所有操作返回统一的 Success 或 Rejected 结构,失败码共五种:session-not-found 表示 Session 头不存在,target-not-found 表示目标不是合法的助手消息,version-conflict 表示版本不匹配,note-blank 表示备注全是空白,note-too-large 表示备注超出 UTF-8 字节上限。version-conflict 的响应里会带回权威的当前条目(不存在时为 null),调用方不用再读一次就能对账。

乐观并发怎么落地
put 采用严格的乐观并发控制:针对已有条目的每次请求都必须匹配当前 ifVersion,包括看起来什么都没改的请求。落地手段是按 Session 划分的串行队列,检查、读取、冲突判断和整行写入都在队列里完成,因此单个 Host 进程内的并发调用有一致性保证。
文档同时把边界写得很清楚:队列只在进程内生效,存储域没有跨进程条件写。多个 Host 进程写同一个存储根目录时,没有 compare-and-swap,也不防丢失更新。
目标与生命周期的权威判定
不是每条消息都能被评分。put 只接受 append-origin 的 assistant/message;replacement-origin 的替换消息、只承载用量统计的空记录、非 assistant 记录,都不是合法的反馈目标。
生命周期上由 SessionPersistence.inspect() 提供权威观测:它只检查已持久化的 Session,不会发布或恢复 Agent,也不触发 cold repair。伴随记录里存的 {createdAt, cwd} 必须与检查所得的头部身份一致,不一致按不存在处理:list 返回空条目,put 则可以写入绑定当前身份的新记录,替换掉陈旧行。fork 出来的新 Session 拿到的是新身份,即使种子消息完全相同,也不会继承伴随记录的副本。
日志先行:落盘顺序的硬保证
这是整篇文档里最讲究的一段。写入伴随记录之前,live 目标先经过权威的 ctx.sessions.flush 检查点;随后无论 live 还是 cold 路径,都会用 SessionPersistence.readFrom 从序列零开始物理复读,写入前再校验一次观测结果。由此保证一条铁律:目标日志的持久提交,永远先于它的伴随记录。
配套约束还有两条:maxNoteBytes 是必填参数,按 UTF-8 字节限制备注长度,Web Host 组合把它设为 8192;插件 disposal 时先关闭变更接纳、排空各 Session 队列,最后才关闭存储域。

Web 端:一个 Session 一个控制器
浏览器侧的消费者是 @deepseek-ai/dsh-client-ui-message-feedback 插件,它通过 ctx.remote.messageFeedback 调用服务,不接触传输层。
控件挂在 conversation.chat.assistant-actions 插槽的 feedback 条目上(order 10),渲染在已定稿助手消息的操作行里。有个容易忽略的细节:操作栏每个 Turn 只渲染一次,落在收尾那条助手消息上;多步骤 Turn 里较早的步骤展示的是工具行而非可评分正文,所以 UI 暴露的可评分范围比 Host 约定允许的更窄。为了抵达这个渲染点,消息管道也做了配套改动:AssistantMessageNode 现在携带可选的 messageId 字段,被中断冻结的半截消息没有这个字段,渲染点会直接跳过。
数据层面,每个 Session 一个 MessageFeedbackController:一次 list 就填充整段对话,而且延迟到首次 hover 或 focus 才发起,不在挂载时请求。每次变更都把控制器最后观察到的版本作为 ifVersion 发出去,收到 version-conflict 就用响应里的权威条目就地对账,不重新拉取;变更按 Session 串行排队。connection/reset 只刷新读取过的 Session。
值得记住的边界
文档末尾列了一串限制,几条尤其值得记:Session 持久化没有删除接口,服务也不把 session/disposed 或 host/session-removed 当删除信号,带外移除日志后孤儿伴随记录可能继续存在;live detach 之后、持久化目录物化头部之前的极短窗口内,请求可能收到 session-not-found,调用方应在物化完成后重试;持久化没有按 id 读元数据的操作,cold 请求要扫描完整的 snapshot 目录,单条 Session 行也没有条目数或总字节上限;头部身份只有 {createdAt, cwd},识别不了保留相同身份的克隆日志,约定也不记录审计身份,默认调用方边界可信;反馈控件只在对话视图出现,trajectory 和 waterfall 视图不渲染;sidecar 不发布实时帧,另一个标签页里的评分要等重连或下次冲突响应才可见;备注编辑器不做输入期预校验,超长备注要到保存时才报 note-too-large。
整体看,这个子系统把人类反馈当成一等数据来建模:不透明版本令牌、幂等删除、日志先行的落盘顺序、懒加载的 UI 控制器,所有约定指向同一个目标:反馈记录可以随时改、随时删,但永远不说谎。如果你在给自己的 agent 产品设计反馈功能,这套 sidecar 加乐观并发的组合,是一份可以直接对照的参考实现。



