DeepSeek Harness 的会话标题是谁起的:自动命名的触发时机与来源

2026-08-17

会话列表里那一行标题,看着不起眼,出问题的时候却很烦:有时候它是你第一句话的前几个词,有时候它像是模型总结过的一句话,改完名以后又不会再变了。想弄清楚到底谁在写它,得沿着 packages/session/session-title 这条路径走一遍。

先说清楚前提:本文讲的是 deepseek-harness 仓库快照 47f9438(版本 0.1.0-rc.5)里的源码与文档。该仓库 README 在 “Developer preview” 一节自述项目处于开发者预览阶段,并明确写着会有破坏兼容性的变更,下面出现的字段名、默认值和配置行随时可能变。

标题不是一个字段,是一串事件

第一个反直觉的点:标题不存在于任何可变的元数据里。packages/session/session-title/src/index.ts 里的 foldSessionTitle() 做的事情,是在会话事件流里 findLast 一条 session/title 事件,把它的 titlemessageSeqssource 连同事件自身的 seq 和时间戳一起冻结成 SessionTitleSnapshot 返回。ctx.sessionTitle.get(session) 就是直接调它。

所以“当前标题”永远是最后一条 session/title 事件,最新者胜。这条事件在包 README 里被明确标注为只写日志:它不会进入会话接口、deriveMessages()、系统提示词、工具 schema 或请求前缀,也就是说给会话起标题这件事不会往主 agent 请求里加一个 token。

source 字段是判断“谁写的”的唯一依据,它是一个三选一的联合类型:{ kind: 'fallback' }{ kind: 'provider', provider, model? }{ kind: 'user' }。排查标题问题时,先看这个字段,再往下追。

第一条消息落地时发生了什么

服务在构造函数里挂了 ctx.on('session/event', ...),只关心两种事件类型:user/messagerequest/header

来一条 user/message,走进 onUserMessage()。这里有几道过滤:事件的 data.source.kind 必须是 'user'(也就是真人消息,不是插件或工具塞进去的);文本块拼起来经过规范化后不能为空;如果当前标题的 source.kind 已经是 'user',直接 return——用户改过名就钉死了,后面所有自动生成都不再排期。

过了这几道,函数做两件事,顺序值得留意。它先看有没有注册提供方,有的话按节奏决定是否把这次修订记成 state.pending;然后才 defer() 一个异步任务去 ensureFallback()。回退这一条是纯本地按规则派生的,不涉及任何模型调用;模型那一条还要等路由条件满足才会启动(下面细说)。

回退标题的算法在 packages/session/session-title/src/normalize.tsfallbackSessionTitle():先 cleanTitleText() 洗掉 OSC / CSI / ESC 转义序列、C0/C1 控制字符和一批方向性与不可见控制符(源码注释写的理由是这些字符会让显示出来的标题具有欺骗性),把连续空白压成一个空格并 trim,然后按空格切词、取前 fallbackMaxWords 个,最后用 truncateTitleUtf8()fallbackMaxBytes 截断。截断这一步是按码点逐个累加字节的,不会把一个字符劈成两半。

那三个数字来自哪里

session-title 这个包自己不带默认值——Config 的三个字段 fallbackMaxWordsfallbackMaxBytesmaxTitleBytes 在 schema 里全是 required(),构造函数还会逐个 assertPositiveInteger,并且校验 fallbackMaxBytes 不得大于 maxTitleBytes,违反就直接抛错。

真正的数值写在 packages/bundle/base/cordis.patch.yml 里:id: session-title 那一行的 config 是 fallbackMaxWords: 5fallbackMaxBytes: 40maxTitleBytes: 80。紧挨着的 id: session-title-llm 行挂的是 @deepseek-ai/dsh-session-title-first-prompt-llm,config 为 targetWords: 5targetCjkCharacters: 10maxInputBytes: 4096maxOutputTokens: 64timeoutMs: 60000。这些是配置里的默认值,不是对结果的承诺。

这里有个中文用户会先撞上的口径问题。fallbackMaxWords: 5 是按空格切词的,中文句子里没有空格,整句往往就是一个“词”,这个上限基本不会生效;真正起作用的是 fallbackMaxBytes: 40。按 UTF-8 里常见汉字占 3 字节做算术,40 字节大约只装得下十几个汉字——这个换算是我们按代码里的字节预算算出来的,源码和文档都没有把这个数字写出来。所以中文会话的回退标题看着像是被硬切了一刀,切点来自字节预算而不是词数。

模型那条路径为什么会“不启动”

注册了提供方,不等于每条消息都会触发一次模型调用。SessionTitleProviderautomatic 只有两个取值:'first-prompt''all-prompts'onUserMessage() 里的判断是:all-prompts 一律排期;否则要同时满足 session.header.parentSession === undefined、截至这条事件的合格消息数正好为 1、且当前还没有任何标题。

也就是说默认那套(bundle 里挂的是 first-prompt 那个包)只在整段会话的第一条真人消息上排一次期,而且 fork 出来的子会话因为 parentSession 不是 undefined,第一消息节奏根本不会为它重新生成标题——包 README 也是这么写的:fork 出的会话原样继承种子里的标题事件,全消息节奏才可能在子会话收到后续提示词后追加新修订。

排上期只是记了个 pending,真正启动还要等路由。onRequestHeader() 要求 pending.throughSeq < event.seq 才启动;另一条入口 onMainRequest() 挂在 ctx.on('llm/stream', ..., { global: true, prepend: true }) 上,条件更严:最后一个边界事件必须是 step/start 且其 seq 大于 throughSeq,同时 session.requestHeader()?.config 的 provider 与 model 要和这次请求完全一致。README 对应的说法是,只有带标记、由循环构建的请求,其确切路由与当前已记录的 request/header 匹配时,提供方才会启动。

这条对排查很有用:如果一次会话只写进了用户消息、没有真正发起主 agent 请求,标题就会一直停在 fallbacksource.kind 永远不会变成 provider。这不是模型生成失败,是根本没被触发。

辅助请求长什么样,以及它在哪留痕

真的走到模型这一步,实现在 packages/session/session-title-llm/src/index.tsgenerateSessionTitleWithLlm()。路由解析规则是:配置里的 providermodel 必须成对出现(只给一个会抛 provider and model must be supplied together);没给这对值就用 request.route,也就是从已记录的 request/header 捕获的那条路由;两者都没有就抛错,提示你把 provider 和 model 一起配上。

用户提示词是 frameMessages() 拼的:Generate the session title from this JSON array of human messages: 加上 JSON.stringify(messages)。用 JSON 包一层的作用写在函数注释里——让用户文本没法破坏结构分隔符。系统提示词里带着 targetWordstargetCjkCharacters 两个目标值,并要求只返回一行纯文本、不许有引号前缀解释 Markdown XML 与终端控制码。

输入大小是按最终封装后的 JSON 字节数对 maxInputBytes 做检查的,超了直接报错,不截断;README 明说这个尺寸包含 seq 字段、包装层和 JSON 转义。

分发之前,会话里会先追加一条 session/title-llm-request 事件,把 titleProvidermessageSeqsroute、完整的 system、完整的 messagesmaxTokens 全记下来。这是排查“标题为什么起成这样”最直接的抓手:模型看到的东西一字不差地躺在日志里,而且 README 写明后续模型失败会保留这条记录,只有从未成为可分发请求的验证失败才不会留痕。

再补一个容易被忽略的联动:packages/llm/llm-deepseek/src/serialize.tsresolveThinking() 的第一行就是 if (options.purpose === 'session-title') return { thinking: 'disabled' }。而 purpose 这个字段在 packages/llm/llm/src/types.ts 里的取值只有 'compaction' | 'session-title'。README 自述这样做是为了让那点很小的输出预算全部用在可见标题文本上;其它适配器的用途专用行为由各自负责。

结果不是模型说了算

提供方返回来的东西要过 validateResult():标题必须是字符串,经 normalizeSessionTitle()maxTitleBytes 规范化后不能为空;messageSeqs 必须非空、每个值都得来自这次请求给它的那批消息,而且顺序必须严格递增,否则抛 messageSeqs must be unique, ordered seqs from the requestmodel 如果给了,providermodel 两个字符串都不能为空。全过了才 session.append('session/title', ...)source.kind 记成 'provider'

失败呢?startPending() 的 catch 里是 this.ctx.logger.warn(...),消息形如 automatic title generation failed:;回退那条失败则是 fallback title update failed:。两条都只是警告,保留最新标题,不打断主流程。日志里 grep 这两个字符串比猜要快。

改名与解钉

rename() 是同步的:校验会话在库里是活的,规范化文本,空的话抛 SessionTitleInvalidError,然后 supersede() 掉在途的自动工作,追加一条 source: { kind: 'user' } 的事件。从此 onUserMessage() 那道 if (this.get(session)?.source.kind === 'user') return 就会一直挡着,后续消息不再排期。

想解钉只有一条路:显式调 refresh()。它里面分两支:没有注册提供方(或提供方正在关闭、没有可用消息)时,若当前标题是 user 来源且存在首条合格消息,就走 appendFallback() 直接把回退标题盖上去——appendFallback() 的注释写得很直白,覆盖被钉住的用户标题正是这条路径存在的意义,而且它是刻意写成同步的,派生与追加之间不允许插入 await;有提供方时则重新排一次修订、走 startProvider()。包 README 也把「不经显式 refresh() 的解钉」列在本服务范围之外。

走 RPC 的话,packages/host/apiproxy/src/api/sessions.tsrename 的注释写明:返回规范化后的标题和事件 seq,标题规范化后为空返回 title-invalid,会话型 subagent 则以 agent-busy 拒绝。packages/host/apiproxy/src/api-proxy.ts 里还有一个分支:部署没挂 session-title 服务时,返回 internal 并带上 “renaming is unavailable” 这句话。

想换成每条消息都重命名,改哪一行

register() 有意只收一个实现,第二次注册立刻抛 is already registered。包 README 把这条列在“已知限制与暂缓事项”里:要组合多种标题策略,得自己写一个负责优先级的提供方。

packages/bundle/base/cordis.patch.yml 已经用 id: session-title-llm 这一行把位子占了。该文件顶部的注释写明,后续 bundle patch 与用户 profile 的 cordis.patch.yml 按 id 定位这些行、每行以最后一次写入为准,且 patch 替换的是整个 config 而不是往里合并。换句话说,要换成 @deepseek-ai/dsh-session-title-all-prompts-llm,动的是同一个 id 的那一行,并且得把 config 五个必填字段整套写全——这个包同样不提供任何默认值。仓库里 packages/bundle 下另外两个 bundle(headless、web-app)都没有再碰这两行。

最后一处,列表里显示的那个纯字符串标题走的是另一条道:构造函数里 ctx.inject(['sessionProjections'], ...) 注册了一个 key 为 title 的投影,apply 就是遇到 session/title 就取 event.data.titleinit 返回 nullstateVersion: 1。源码注释说明这个子单元只在组合了投影注册表时才激活,无头装配不受影响。所以如果你的客户端列表标题不刷新而 get() 拿得到值,该看的是投影这一侧,不是标题服务本身。


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

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