# RAG 会话自动标题生成与重命名实施方案

> 最后整理：2026-05-19
> 适用模块：`/chat-with-llm/chat`
> 对应前端：`legal-platform-frontend/components/dify-chat/*`
> 对应后端：`fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py`

## 1. 结论先行

当前“会话自动重命名”能力并不是真正可用的自动标题生成，而是前端在首轮回答完成后又走了一次“重命名接口”，并且现有实现路径存在以下问题：

1. 标题生成职责放在前端，天然不稳定。
2. 会话真实 ID 与临时 ID 切换期间，自动重命名容易打到错误对象。
3. 前端切换页面、断网、刷新、关闭标签页时，自动重命名流程可能直接丢失。
4. 现有 `generateConversationName()` 实际并没有真正“根据问答内容生成标题”的可靠后端闭环。
5. 手动重命名与自动重命名没有状态隔离，后续极易互相覆盖。

建议采用：

**后端主导、异步生成、首轮回答完成后触发、手动重命名永不被覆盖**。

不建议继续沿用当前前端 `autoRename()` / `generateConversationName()` 这条链路做增强。

---

## 2. 当前实现现状

## 2.1 前端现状

当前前端存在两条与“自动重命名”有关的路径：

1. `legal-platform-frontend/hooks/use-chat-message.ts`
   在首轮回答 `onCompleted` 后调用 `generateConversationName(tempNewConversationId)`。
2. `legal-platform-frontend/components/dify-chat/index.tsx`
   在 `onConversationIdChange(..., { syncName: true })` 时通过 `sidebarRef.current.autoRename(conversationId)` 再触发一次自动重命名。

而 `legal-platform-frontend/components/dify-chat/sidebar.tsx` 中的 `autoRename()` 目前本质上只是：

- 调 `renameConversation(conversationId, '新对话', false)`

这不是“生成标题”，只是再次把标题写成“新对话”。

## 2.2 前端 API 现状

`legal-platform-frontend/lib/api/legacy/dify-chat/client.ts`

- `generateConversationName(id)` 实际是 `renameConversation(id, '', true)`
- 但 `difyClient.renameConversation()` 最终只是：
  - 把空标题兜底为 `'新对话'`
  - `PATCH /api/v3/rag/chat/conversations/{conversationId}`
  - 请求体只有 `{ name: finalName }`

这说明当前所谓 `auto_generate=true` 在后端并没有真正形成“让模型生成标题”的能力闭环。

## 2.3 后端现状

`fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py`

当前会话创建逻辑：

```sql
INSERT INTO rag_conversation (conversation_id, user_id, app_id, name, introduction)
VALUES (:conversation_id, :user_id, :app_id, '新对话', '')
```

当前重命名逻辑：

```sql
UPDATE rag_conversation
SET name = :name, updated_at = NOW()
WHERE conversation_id = :conversation_id
```

也就是说：

1. 会话创建时标题恒为 `新对话`
2. 后端没有“自动标题生成任务”
3. 后端没有“标题来源状态”
4. 后端无法区分：
   - 默认标题
   - 自动生成标题
   - 用户手动标题

---

## 3. 问题本质

自动标题本质不是一个前端 UI 小功能，而是一个会话元数据状态机问题。

如果设计不对，会持续出现这些 bug：

1. 前端刚发完首问就切走，标题没生成。
2. 用户断网后端其实已经答完，但标题没改。
3. 用户手动改名后，异步自动标题结果又把它覆盖掉。
4. 同一个会话出现两次标题更新，列表排序抖动。
5. 首轮回答失败或中断，却错误生成了标题。

所以这件事必须满足两个原则：

1. **标题生成结果必须由后端持久化并裁决**
2. **手动重命名优先级必须高于自动生成**

---

## 4. 目标

本方案的目标是：

1. 新会话在首轮有效回答完成后，自动生成一个简短、可读、稳定的标题。
2. 用户刷新页面、切换会话、切换应用、前端断流时，标题生成不丢。
3. 手动重命名后，后续自动任务不能再覆盖。
4. 标题更新不应破坏会话列表选择态、排序和缓存一致性。
5. 方案应尽量复用现有 RAG 聊天主链路，不引入过重基础设施。

非目标：

1. 不在本期做多轮动态标题持续改写。
2. 不在本期做“每次问答都重新概括标题”。
3. 不在本期做复杂工作流引擎或独立任务队列系统。

---

## 5. 推荐方案

## 5.1 总体方案

推荐采用：

**方案 C：后端异步自动标题生成，前端只负责显示结果**

它和之前“会话 ID 提前同步方案”并不冲突，反而是互补关系：

1. 会话 ID 提前同步解决的是 `temp -> real` 的绑定竞态。
2. 自动标题生成解决的是 `real conversation` 的元数据完善问题。

完整链路应为：

1. 前端发送首问。
2. 后端创建真实会话。
3. SSE 第一帧返回 `conversation_created`，前端立即完成 temp 升 real。
4. 后端继续生成主回答。
5. 主回答真正完成并持久化后，后端异步触发“自动标题生成任务”。
6. 标题写回 `rag_conversation`。
7. 前端通过轮询刷新或专门事件获知新标题并更新列表。

## 5.2 为什么不建议继续把标题生成放前端

因为前端无法可靠保证以下场景：

1. 用户切走当前会话
2. 浏览器刷新
3. 页面关闭
4. SSE 中断
5. 多标签页并发
6. 首轮回答完成但前端回调未触发

而这些情况在当前聊天系统里都是真实存在的。

自动标题如果依赖前端触发，就一定会丢边界。

---

## 6. 数据模型设计

## 6.1 `rag_conversation` 建议新增字段

建议新增以下字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `title_source` | `varchar(20)` | 标题来源：`default / auto / manual` |
| `title_generation_status` | `varchar(20)` | 生成状态：`idle / pending / running / succeeded / failed` |
| `title_generated_at` | `timestamp with time zone` | 自动标题最终生成时间 |
| `first_question_message_id` | `varchar(100)` | 首条用户消息 ID |
| `first_answer_message_id` | `varchar(100)` | 首条回答消息 ID |
| `title_generation_error` | `text` | 最近一次失败原因，便于排障 |
| `last_message_at` | `timestamp with time zone` | 会话最后一次消息完成时间，可选 |

最小可落地集合其实只需要：

1. `title_source`
2. `title_generation_status`
3. `title_generated_at`

但从排障和防重角度，建议把 `first_question_message_id / first_answer_message_id / title_generation_error` 一并加上。

## 6.2 字段语义

### `title_source`

- `default`
  - 当前仍是系统默认标题，例如 `新对话`
- `auto`
  - 已由系统自动生成
- `manual`
  - 已被用户手动修改

### `title_generation_status`

- `idle`
  - 默认状态，尚未进入自动生成流程
- `pending`
  - 已满足触发条件，等待后台执行
- `running`
  - 正在生成
- `succeeded`
  - 已成功生成
- `failed`
  - 生成失败，但不影响主会话正常使用

## 6.3 迁移 SQL 草案

```sql
ALTER TABLE rag_conversation
ADD COLUMN IF NOT EXISTS title_source VARCHAR(20) NOT NULL DEFAULT 'default',
ADD COLUMN IF NOT EXISTS title_generation_status VARCHAR(20) NOT NULL DEFAULT 'idle',
ADD COLUMN IF NOT EXISTS title_generated_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS first_question_message_id VARCHAR(100) NULL,
ADD COLUMN IF NOT EXISTS first_answer_message_id VARCHAR(100) NULL,
ADD COLUMN IF NOT EXISTS title_generation_error TEXT NULL,
ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ NULL;

CREATE INDEX IF NOT EXISTS idx_rag_conversation_title_generation_status
ON rag_conversation(title_generation_status)
WHERE deleted_at IS NULL;
```

如果本期想尽量收敛改动，也可以拆成两批迁移。

---

## 7. 触发时机设计

## 7.1 唯一推荐触发点

**首轮 assistant 消息状态从 `running` 变为 `completed` 且内容非空时触发。**

这比“前端收到 `onCompleted`”更可靠。

## 7.2 为什么必须是“回答完成后”

原因很直接：

1. 只用首个用户问题生成标题，标题质量偏差较大。
2. 很多用户首问比较短，例如“这个是什么”“帮我解释一下”。
3. 加上首轮回答后，标题更容易稳定落到真实主题。

示例：

- 首问：`烟草是什么？`
- 首答：围绕《中华人民共和国烟草专卖法》的定义和专卖属性展开

最终标题应更像：

- `烟草专卖法中的烟草定义`

而不是：

- `烟草是什么`

## 7.3 不应触发的情况

以下情况不应自动生成标题：

1. 用户发送后立即停止，回答为空。
2. 后端异常，assistant 最终状态为 `error`。
3. 标题来源已经是 `manual`。
4. 标题来源已经是 `auto` 且当前标题非默认值。
5. 会话已被删除。

---

## 8. 标题生成策略

## 8.1 输入材料

建议只使用以下材料：

1. 首条用户问题 `query`
2. 首条 assistant 有效回答 `answer`

不要直接把整段上下文、多轮历史、引用来源、思考链全部塞进去。

原因：

1. 标题生成目标很小，不需要太多上下文。
2. 输入越多，标题越容易发散。
3. 可以降低调用成本和失败面。

## 8.2 输出要求

建议标题约束为：

1. 12 到 24 个中文字符优先
2. 最长不超过 40 个字符
3. 不加句号
4. 不加“用户问了什么”这种废话
5. 不使用“关于”“浅谈”“分析一下”等空泛词
6. 以主题名词或主题短句为主

可接受示例：

1. `烟草专卖法中的烟草定义`
2. `政府采购法适用范围说明`
3. `合同违约责任条款审查要点`

不可接受示例：

1. `新对话`
2. `关于烟草是什么的回答`
3. `用户询问烟草相关内容`
4. `烟草是什么？`

## 8.3 兜底策略

如果模型生成失败，建议兜底顺序：

1. 从首问做规则截断摘要
2. 如果首问太短，仍保留 `新对话`

规则摘要示例：

- 原文：`请结合中华人民共和国烟草专卖法解释烟草在法律上的定义及监管属性`
- 兜底标题：`烟草的法律定义及监管属性`

---

## 9. 后端实现设计

## 9.1 责任归属

后端负责：

1. 判定是否应该生成标题
2. 防重复调度
3. 执行标题生成
4. 持久化最终标题
5. 保证不覆盖手动标题

前端只负责：

1. 展示当前标题
2. 用户手动重命名
3. 在标题变化后刷新列表

## 9.2 推荐实现方式

建议在 `RagChatServiceImpl` 中新增以下私有方法：

1. `_maybe_schedule_auto_title(...)`
2. `_run_auto_title_task(...)`
3. `_generate_conversation_title(query: str, answer: str) -> str`
4. `_build_fallback_title(query: str, answer: str) -> str`

## 9.3 建议调用时机

在主回答最终落库成功、assistant 消息状态改为 `completed` 后：

1. 更新 `rag_message`
2. 更新 `rag_conversation.last_message_at`
3. 调 `_maybe_schedule_auto_title(...)`

## 9.4 `_maybe_schedule_auto_title(...)` 规则

该方法只做“判定 + 抢占式设置 pending/running”，不做实际生成。

必须同时满足：

1. 当前会话存在且未删除
2. `title_source = 'default'`
3. 当前标题为空或等于 `新对话`
4. 首条回答状态为 `completed`
5. 回答内容非空
6. 当前未处于 `running`
7. 当前未成功生成过

如果满足，则：

1. 把 `title_generation_status` 更新为 `pending`
2. 记录 `first_question_message_id / first_answer_message_id`
3. 启动后台异步任务

## 9.5 `_run_auto_title_task(...)` 规则

执行步骤建议如下：

1. 再次查库确认会话仍存在
2. 再次确认 `title_source != 'manual'`
3. 将 `title_generation_status` 置为 `running`
4. 调标题生成函数
5. 清洗标题文本
6. 二次查库确认用户没有在这期间手动改名
7. 仅在 `title_source = 'default'` 时执行更新：
   - `name = generated_title`
   - `title_source = 'auto'`
   - `title_generation_status = 'succeeded'`
   - `title_generated_at = NOW()`
8. 如果失败：
   - `title_generation_status = 'failed'`
   - `title_generation_error = ...`

## 9.6 为什么必须二次查库

因为存在这个时序：

1. 后端开始生成自动标题
2. 用户在前端手动把标题改成 `烟草法问答`
3. 自动任务稍后返回 `烟草专卖法中的烟草定义`

如果没有二次查库保护，就会把用户手动标题覆盖掉。

这是不能接受的。

---

## 10. 手动重命名规则

## 10.1 规则定义

用户手动重命名时，后端必须：

1. 更新 `name`
2. 设置 `title_source = 'manual'`
3. 可选把 `title_generation_status` 保留原值，或重置为 `succeeded`
4. 清空 `title_generation_error`

推荐直接：

- `title_source = 'manual'`
- `title_generation_status` 不再参与后续覆盖判定

## 10.2 接口行为

当前接口：

`PATCH /api/v3/rag/chat/conversations/{ConversationId}`

当前 DTO 只有：

```python
class RagConversationRenameDTO(BaseModel):
    name: str
```

本期其实不需要改手动重命名接口入参，后端只要在 `RenameConversation()` 里补充：

```sql
UPDATE rag_conversation
SET
  name = :name,
  title_source = 'manual',
  updated_at = NOW()
WHERE conversation_id = :conversation_id
```

即可满足主需求。

## 10.3 是否保留 `auto_generate` 参数

建议：

1. 不再让前端显式调用 `auto_generate=true`
2. 后续可以废弃该语义

因为自动生成应该是后端内生行为，而不是前端额外指令。

---

## 11. 前端同步策略

## 11.1 推荐方案

推荐采用：

**列表刷新 + 局部状态更新**

即：

1. 会话主链路仍由 SSE 负责回答流
2. 标题更新不强依赖新增 SSE 事件
3. 在以下时机刷新会话列表即可：
   - 首轮回答完成后
   - 用户切回该应用时
   - 定时轻量刷新

原因：

1. 标题不是强实时核心信息
2. 不值得为了标题专门把主流协议继续复杂化
3. 当前项目已经存在会话列表刷新逻辑，接入成本更低

## 11.2 如果想更丝滑

可以额外新增一个可选 SSE 事件：

```json
{
  "event": "conversation_renamed",
  "conversation_id": "xxx",
  "name": "烟草专卖法中的烟草定义",
  "source": "auto"
}
```

但这不是本期必需。

## 11.3 前端必须删除的错误路径

建议清理以下逻辑：

1. `use-chat-message.ts` 中首轮完成后主动 `generateConversationName(...)`
2. `index.tsx` 中通过 `sidebarRef.current.autoRename(...)` 再次发起“自动重命名”
3. `sidebar.tsx` 中把 `autoRename()` 重命名成“仅保留手动重命名能力”，或直接删除 ref 暴露

否则后续会形成双写：

1. 后端自动生成一版
2. 前端又发起一次旧式 rename

结果会继续乱。

---

## 12. 与“对话中断”场景的关系

这是本方案里必须明确的一点。

## 12.1 前端切走当前会话

如果用户在回答过程中切到其他会话：

1. 后端生成不应停止
2. 会话标题生成也不应停止
3. 前端只是暂时不再消费当前流
4. 回来时应能从后端历史恢复完整回答与最终标题

这也是为什么标题生成不能放前端。

## 12.2 前端断网 / SSE 断开

如果网络中断，但后端任务实际还在跑并最终成功：

1. 消息应继续在后端落库
2. 标题自动生成任务也应照常执行
3. 用户刷新后应直接看到完整回答和最终标题

## 12.3 用户主动停止回答

如果用户点了“停止回答”：

1. assistant 消息可能为不完整内容
2. 这类会话默认不建议自动生成标题
3. 除非后续有完整回答完成，再考虑生成

结论：

**自动标题触发依据必须是后端最终消息状态，而不是前端是否还在线。**

---

## 13. 会话列表排序影响

自动标题更新时，建议：

1. 更新标题名称
2. 不额外刷新 `updated_at`

原因：

如果自动标题生成把 `updated_at` 也改掉，会造成：

1. 用户明明没发新消息
2. 会话却突然跳到列表最上方
3. 视觉上像“又收到一条新消息”

这不符合直觉。

推荐做法：

1. 聊天消息完成时更新 `updated_at`
2. 自动标题只改 `name / title_source / title_generated_at`
3. 不改 `updated_at`

这样排序稳定。

---

## 14. 失败与重试策略

## 14.1 失败影响面

自动标题失败不应影响：

1. 主回答返回
2. 会话可见性
3. 消息历史读取
4. 手动重命名

标题生成只是增强能力，不是主链路。

## 14.2 是否自动重试

本期建议：

1. 单次失败记为 `failed`
2. 不做无限重试
3. 可在后续加一个后台补偿脚本，扫描：
   - `title_source = 'default'`
   - `title_generation_status in ('failed', 'idle')`
   - 且首轮回答已完成

再进行补偿。

这样设计更稳，不会把在线请求链路搞复杂。

---

## 15. 实施步骤建议

## 15.1 第一阶段：先把状态能力补齐

1. 为 `rag_conversation` 增加标题来源和生成状态字段
2. 后端 `RenameConversation()` 改为写入 `title_source = 'manual'`
3. 会话列表查询结果继续返回 `name` 即可，本期不必先暴露所有状态给前端

## 15.2 第二阶段：接入后端自动标题

1. 在首轮回答完成落库后调用 `_maybe_schedule_auto_title(...)`
2. 异步生成并回写标题
3. 保证不覆盖手动标题

## 15.3 第三阶段：清理前端旧逻辑

1. 删除 `generateConversationName(...)` 自动调用
2. 删除 `sidebarRef.autoRename(...)` 自动调用
3. 仅保留用户主动重命名能力

## 15.4 第四阶段：补 UI 细节

可选：

1. 默认标题会话显示一个淡化文案，比如 `新对话`
2. 自动标题生成成功后列表静默更新
3. 手动重命名后可加一个标记字段供后续分析，但本期无需展示

---

## 16. 测试清单

## 16.1 基本流程

1. 新建会话，首轮回答完成后，标题由 `新对话` 自动变为语义标题
2. 刷新页面后，标题仍正确存在
3. 进入历史消息页，标题与会话内容匹配

## 16.2 手动重命名保护

1. 首轮回答刚完成，自动任务尚未回写前，用户手动重命名
2. 自动任务返回后，不得覆盖手动名称
3. 后续再次发消息，也不得重新自动改名

## 16.3 中断场景

1. 发送首问后立刻切到别的会话，等待后台完成
2. 再切回原会话，应看到完整回答和自动标题
3. 发送首问后断网，稍后恢复并刷新，应看到完整回答和自动标题

## 16.4 失败场景

1. 标题生成模型异常，主回答仍正常显示
2. 会话仍可手动重命名
3. 列表不应出现空标题

## 16.5 排序稳定性

1. 自动标题生成前后的会话排序不跳动
2. 只有真正发消息时会话才因 `updated_at` 变化上浮

## 16.6 并发与幂等

1. 同一会话只生成一次自动标题
2. 页面多开、多次刷新，不得生成多个不同标题反复覆盖
3. 重复触发 `_maybe_schedule_auto_title(...)` 不得造成重复更新

---

## 17. 推荐落地结论

推荐最终决策如下：

1. 自动标题只由后端生成和裁决
2. 触发时机锁定为“首轮 assistant 完整回答持久化后”
3. `rag_conversation` 增加 `title_source` 与 `title_generation_status`
4. 手动重命名立即把 `title_source` 切到 `manual`
5. 前端移除现有假自动重命名链路
6. 标题更新不改 `updated_at`

这是当前成本、稳定性、后续可维护性之间最平衡的方案。

---

## 18. 本期不建议做的事

1. 不建议继续扩展前端 `generateConversationName()` 语义
2. 不建议把标题生成塞进主 SSE 返回链路里阻塞回答结束
3. 不建议做“每轮消息都重算标题”
4. 不建议让自动标题更新触发会话重新排序
5. 不建议在没有 `title_source` 状态字段前直接上线自动改名

---

## 19. 后续可扩展项

如果后续要继续增强，可以再加：

1. 会话标题生成提示词版本号
2. 后台补偿任务
3. 管理员手动重跑标题生成
4. 标题质量审计日志
5. 基于 `last_message_at` 的更精确列表行为

但这些都应该在本期稳定方案落地之后再做。
