# Agent Note: 基于日志的会话标题

Status: implemented

[English](2026-07-21-log-backed-session-titles.md) | 中文

## 问题

会话需要一个面向用户的简短标题，编辑器、终端或查询消费方才能有效呈现它。成本最低的实现可以从第一条提示词派生标题，质量更高的实现则可以让模型处理第一条提示词或整个对话。这些策略在延迟、成本、路由和重试行为上各有不同，但所有消费方都需要一个持久的真源。

会话身份元数据不可变，事件日志是回放和 fork 的边界。模型生成的标题往往在主轮次结束后才完成，因此同步写入会延迟 agent（智能体）响应，而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR（热模块替换）、取消以及被忽略的中止信号，还可能让未受版本校验约束的后台结果覆盖更新的标题。

## 决策

[`session-title` 能力包族](../../../../packages/session/README.zh.md)负责标题状态和生成策略。`@deepseek-ai/dsh-session-title` 提供 `ctx.sessionTitle`、确定性的首消息回退方案，以及一个至多接受单个可选异步提供方的注册表。`@deepseek-ai/dsh-session-title-llm` 负责通用的辅助模型请求策略；首消息插件和全部用户消息插件分别选择输入调度方式。共享 agent 主干只挂载回退服务。Web host 会挂载该服务和首消息模型提供方，并显式设置可覆盖的限制，因此新建的 Web 会话会立即获得回退标题，随后在不阻塞主响应的情况下获得模型摘要。其他组合需显式选择任一模型提供方。

### 事件归属与折叠

每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq，以及回退来源 kind，或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前，共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件，其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限；即使后续生成失败，这次请求仍可审计。发送的请求信封经过深度冻结，以确保其与该记录精确一致，但它有意不携带进程本地的 agent loop（智能体循环）请求身份，因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件，并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。

标题服务会在检查当前修订和确切的实时会话后，直接追加 `session/title`；随附模型辅助函数同样会在发起调用前追加其字面量 `session/title-llm-request` 记录。两类记录都可以位于轮次之间，而无需虚构执行边界。持久化会将它们接纳到有界后台批次中，并通过常规检查点和生命周期 teardown 排空；标题发布不会强制逐事件 flush。事件所有方与 `Session.append()` 之间不存在通用标记、类型断言或结算队列。这是[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)在特定领域中的应用。

### 输入与异步时序

只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制内容的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成，随后规范化空白和控制序列，应用已配置的单词数和 UTF-8 字节限制且不拆分代码点，并记录第一条消息的 seq。

仅当主循环存在已记录在日志中的当前提供方/模型路由时，自动提供方工作才会启动。`request/header` 新追加到日志时，会直接启动待执行工作；如果请求头没有变化，则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配，再启动该工作。随后，生成工作独立于 agent 响应运行；完成结果会追加一个独立事件，而不改变轮次状态。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方；没有提供方时则返回回退标题。调用方取消不会回滚已接受的回退事件，`refresh()` 会在返回成功前重新检查信号。并发刷新会在提供方工作之前预留会话本地修订号，因此较新的调用会在任何调用有机会造成提供方完成顺序倒置之前中止并取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise，因此首次回退只会创建一个标题事件。异步压缩（compaction）期间接受的标题仍是纯日志事件，因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败；并发的表层变更仍会使替换失效。

首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后，后续提示词不会重新调度；`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度，并传入截至该修订的所有合格消息，包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。

### 注册、路由与失败策略

`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数，然后返回一个可等待完成的 effect dispose（资源释放）函数。第二个活跃注册会立即抛出错误。对提供方注册执行 dispose 时，会将注册标记为正在关闭，中止其待执行和活跃工作，并等待所有调用结束后才移除注册，因此替代提供方不会与忽略取消的旧提供方重叠运行。会话 dispose 会中止其活跃工作。服务卸载时，会阻止排队中的回退和提供方微任务启动，中止活跃工作，并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和确切的注册身份；接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态，因此陈旧输出无法提交。

模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供；两者均未提供时，辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下，将选中的消息封装为 JSON。发出的 `GenerateOptions` 携带 `purpose: 'session-title'`；DeepSeek 适配器将该用途映射为禁用思考且省略推理强度设置的请求，使受限输出成为可见的标题文本，而主对话仍沿用已配置的思考模式。输入字节数按最终形成的用户提示词计算，其中包括包装文本、seq 字段和 JSON 转义；系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断，因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间，因此即使拦截器或适配器忽略中止信号，超时后返回的成功结果也不会被接受。

自动提供方故障只会发出非致命警告，并保留最新标题。显式刷新失败则会使调用方的调用被拒绝。输出必须是非空文本，并包含来自固定请求、唯一且有序的 seq；服务会在日志接受前对其进行规范化并施加字节限制。

### 显式重命名

`rename(session, title)` 同步接受用户标题：按已接受标题的字节上限规范化文本、取代在途自动工作，并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话：只要它还在，`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题，它就在被钉住的标题之上追加提供方或回退事件（推导不出回退标题时，例如字节上限过小，钉住状态保持不变）。Web host 将其暴露为 `session.rename` unary 方法（冷会话先恢复），并返回规范化后的标题及其事件 seq，使 client 在推送帧到达前就结算自己的 `title` 投影格。

### Fork 与消费方

与源日志的其他部分相同，fork 会原样继承作为种子的标题事件——被钉住（user 来源）的标题在子会话中保持钉住，直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订，并使用继承的合格消息和新增的合格消息。

`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志，而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题，并在完成终端安全渲染后，将终端窗口标题设置为 `<session title> — <configured product title>`。Web host 会在每个已附加会话的订阅基线之后，以及转发实时原始标题事件后立即，将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达，浏览器也只保留标题事件 seq 较新的版本；侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据，因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退，直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 `<session title> — <existing HTML title>`，否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `foldConsumedWork()` 折叠逻辑，因此后续的轮次间标题记录无法取代此前由消息触发的结果。

## 考虑过的替代方案

- **可变 `SessionHeader` 或独立元数据**：不予采纳，因为这会创建第二套持久化变更协议，削弱不可变身份元数据，让崩溃原子性因后端而异，并使 fork 的复制或引用行为产生歧义。仅追加日志已经负责可回放的后写覆盖状态。
- **返回 agent 响应前等待标题生成**：不予采纳，因为辅助提供方的延迟和故障会进入主交互的关键路径。确定性回退方案可以立即提供可用状态，质量更高的标题则可稍后到达。
- **将标题放入派生历史记录或请求前缀**：不予采纳，因为 UI 元数据会消耗 token、改变缓存标识，并让主模型观察到自己的标签。纯日志事件既保持可重建，又不会变得对模型可见。
- **允许注册多个提供方，并在完成后解析优先级**：不予采纳，因为完成顺序并不等于产品优先级，而且会让重试、HMR 和已记录的提供方变得不确定。需要组合策略的部署可以注册一个自行负责该策略的提供方。
- **静默截断过大的辅助输入**：不予采纳，因为提供方结果会引用源消息 seq，却没有收到这些消息的完整文本。保留原有标题并发出警告，可以保留准确的输入记录。
- **立即在 `listSessions()` 中索引标题**：不予采纳，因为现有的轻量元数据列表将需要逐后端同步派生索引。精确的 `readTitle()` 建立了读取约定，而没有提前锁定搜索或索引策略。
- **让 Web host 只使用回退标题**：不予采纳，因为 UI 虽会显示持久标题，却始终无法将第一条提示词的前缀改进为更好的标题。首消息提供方在主响应路径之外运行，并让模型摘要成为 Web 的默认结果。

## 后果

- 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义，而无需单独的可变记录。
- Web 标题仍以增量方式从日志交付，无需标题索引或扫描持久化列表；冷会话的列表项会在会话附加后改用标题。
- 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用；其他组合可以自行决定更优标题是否值得模型成本，以及后续提示词是否需要重新生成会话标题。
- 辅助请求记录和延迟接受的标题会占用事件 seq，但不会占用轮次编号，因此持久化会同时呈现尝试发起的调用与已接受的更新，尽管模型历史和 KV Cache 标识保持不变。
- 单个提供方和每会话单调递增的修订号让 dispose、取代和陈旧结果拒绝行为明确可见，但多策略优先级必须由复合提供方负责。
- 删除（不经显式 refresh 的解钉）、搜索和列表索引不在此能力范围内。
