# TASK · 普通聊天的三条记忆缺口:丢了不留痕、阈值追不上预算、写满不继承 - 日期:2026-09-15 - 基线 commit:`origin/staging` @ `6b3248bf` - 执行分支:`codex/consultation-context-memory-20260915` - 独占文件:`frontend/src/lib/consultation-session-history.ts`、`frontend/src/lib/session-context-summary.ts`、`frontend/src/app/api/consult/route.ts`、`frontend/src/app/api/sessions/route.ts`、`frontend/src/lib/chat-session-write-contract.ts`、`frontend/src/hooks/use-session-management.ts`、`frontend/src/hooks/use-consultation-run.ts` - 与 external-evidence-cache 单、session-capacity 单无文件重叠,可并行 - 规模:一处留痕、一处算式、一次服务端复制。**不改摘要机制本身。** --- ## 1. 背景:机制是对的,坏在三个边界 BUG-555 建立的检查点摘要机制本身没问题:尾巴超过 16,000 字就在结算后异步让模型写一份 ≤800 汉字的滚动摘要存进 `chat_sessions.context_summary`,下一轮把摘要挂在最后一条用户消息里,历史只带摘要之后的部分。本单不动这套机制,只修它的三个边界。 ## 2. 事故实证 ### 2.1 BUG-729 · 丢掉整轮历史时不留任何痕迹 `consultation-session-history.ts` 的 `consultationHistoryWindow`: ``` let kept = clipped; while (kept.length > 0 && kept.reduce(...) > budget) { kept = kept.slice(1); droppedCount += 1; } ... return { tail: kept, summaryText, droppedCount }; ``` `droppedCount` 被算出来了,然后——`route.ts` 只取 `.tail`: ``` const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {...}); const storedHistory = historyWindow.tail; ``` 全仓检索 `droppedCount`,**除了定义处没有任何读取点**。对照同一个文件里的 `clipConsultationHistoryText`:单条消息被截断时会追加 `omissionMarker()`——「……(以下省略 N 字,结论已并入会话摘要)」。**单条被砍有标记,整轮被丢没有。** 模型不知道自己少看了几轮,只会当成没发生过;用户问「刚才你说的那个时间」时,模型会以为自己没说过。 这是 BUG-555 防复发那句话的漏网之鱼:原文写的是「咨询历史不得再按固定 12 条 × **头部截断**静默砍结论」,整轮丢弃不属于「头部截断」,所以没被拦住。 ### 2.2 BUG-730 · 「什么时候写摘要」和「能带多少历史」是两个各写各的数 | 常量 | 值 | 谁决定 | | --- | --- | --- | | `historyBudgetChars(contextWindow)` | `clamp((窗口 − 60,000) × 1.5, 4,000, 40,000)` 字符 | 跟着模型上下文窗口走 | | `CONSULTATION_HISTORY_TAIL_MAX_CHARS` | **16,000** 字符(写死) | 与窗口无关 | `shouldCheckpoint()` 用后者,`consultationHistoryWindow()` 用前者。分界线是 **70,667**: | 上下文窗口 | 历史预算 | 写摘要阈值 | 后果 | | ---: | ---: | ---: | --- | | 200 k | 40,000 | 16,000 | 正常,摘要先于丢弃发生 | | 128 k(`DEFAULT_MODEL_CONTEXT_WINDOW`,未配置时) | 40,000 | 16,000 | 正常 | | 64 k | **6,000** | 16,000 | 预算低于阈值 → 每轮静默丢最老的几轮 | | 32 k | **4,000**(下限) | 16,000 | 同上,且只剩一两轮历史 | `context_window` 来自后台模型目录(`model-catalog.ts` 的 `PublishedModelRow.context_window`),是运营可配置的值。**后台上架一个中等窗口的模型就会触发**,代码里没有任何断言拦这件事。注意这条与 2.1 是叠加的:预算追不上阈值 → 每轮丢 → 丢了还不留痕。 ### 2.3 BUG-731 · 写满时服务端有摘要,新对话一个字不带 `use-consultation-run.ts` 收到 409 `session_full` 后弹提示并给出「开新对话」按钮,走 `use-session-management.ts` 的: ``` async function continueInNewChat(prompt: { question: string; theme: Theme }) { setSessionFullPrompt(null); const created = await startNewChat(); if (!created) return; setDraft(prompt.question); setDraftTheme(prompt.theme); } ``` 新建一个空会话,只把用户那句问题填回输入框。`context_summary` 既没被带过去,而且 `SESSION_LIST_COLUMNS` 与 `sessions/[id]` 的 `sessionSelect` **都不含这个列**——前端想带也拿不到。用户在最需要延续的那一刻被要求从零开始。 ## 3. 决策记录 产品 2026-09-15 授权本单,并明确: 1. **新对话继承摘要是「静默继承」。** 服务端把 `context_summary` 复制进新会话即可,**不加任何「接着上次聊」之类的提示**,界面看不出差别。产品原话:静默继承。 2. **摘要文本永远不许由客户端提供。** `chatSessionCreateSchema` 现在是 `.strict()` 且不含 `context_summary`,本单不得为了省事把它加进去——那等于开一个把任意文本注入模型提示词的口子。客户端只能传**来源会话的 uuid**,由服务端校验归属后自己复制。 3. **丢历史留痕用的是既有的摘要位置,不是新开一个字段。** 和 `omissionMarker` 同一套说法,不发明第二种表达。 4. **不动摘要机制本身**:不改 800 汉字上限、不改 15 秒 ref 计时器、不改乐观并发写、不改「结算后异步做」。 ## 4. 硬红线 1. **摘要超时不得用 `AbortSignal.timeout()`**(BUG-555 防复发;BUG-523 的教训是它始终 unref,会让进程提前退出而不是等)。现有 `composedAbortSignal` 里那个 ref 住的 `setTimeout` 不得替换。 2. **溢出重试不得新增用户可见等待态**(BUG-555 防复发)。本单不碰那条路径。 3. **不得把 `messages` 加回列表 GET**(BUG-464 防复发)。本单要读 `context_summary`,只能按会话单取,不得顺手把列表接口的列加宽。 4. 摘要与留痕文本里不得出现出生日期、出生时间、出生地、姓名、邮箱(`sanitizeSessionContextSummary` 已在做,本单不得放宽)。 5. `frontend/src/app/page.tsx` 一行不许动(1951/2000,AGENTS §6)。 6. 本轮**不改数据库结构**(`context_summary` 列已存在),因此不得顺带动迁移(AGENTS §7.6)。 7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 ## 5. 任务分解 ### 5.1 丢整轮必须留痕(BUG-729) `consultationHistoryWindow` 的 `droppedCount > 0` 时,在交给模型的摘要位置追加一句与 `omissionMarker` 同风格的说明(例如「更早的 N 轮问答已并入上面的会话摘要」)。落点在 `consultationUserTurnContent` 的 `summaryText` 段,`route.ts` 把 `droppedCount` 传进去。没有摘要却发生了丢弃时(见 5.2,理论上应被消除)也必须留痕,措辞要诚实——那种情况下结论**没有**并入摘要。 - 验收:新增断言——构造一段超预算历史,断言模型可见文本里出现丢弃说明,且句中的轮数等于 `droppedCount`。 - 验收:`droppedCount === 0` 时不得出现这句话(不许无条件加)。 - 验收:源码合同断言 `route.ts` 读取了 `droppedCount`(防止再次只取 `.tail`)。 ### 5.2 写摘要的阈值跟着预算走(BUG-730) `CONSULTATION_HISTORY_TAIL_MAX_CHARS` 从写死常量改成由 `historyBudgetChars()` 派生的函数(取预算的一个比例,比例写成具名常量并说明依据),并加一条**断言**:任何合法上下文窗口下,`阈值 < 预算`。 - 验收:新增表驱动测试,覆盖 200 k / 128 k / 64 k / 32 k / null 五种窗口,逐条断言 `checkpointThreshold(w) < historyBudgetChars(w)`。 - 验收:128 k 下的行为与改前逐字一致(阈值仍应落在 16,000 附近;若比例导致它变化,必须在进度记录里写「原值 / 新值 / 原因」三栏)。 - 验收:`shouldCheckpoint` 与 `tailCharCount` 的调用方全部改用新函数,没有残留的字面量 16,000。 ### 5.3 写满时静默继承摘要(BUG-731) 三处小改,按此顺序: 1. `chatSessionCreateSchema` 增加可选字段 `continued_from_session_id: z.string().uuid().optional()`(保持 `.strict()`,**不加 `context_summary`**)。 2. `POST /api/sessions`:拿到该字段后,用当前用户身份读源会话的 `context_summary`(`.eq("user_id", user.id)`),只有读到才写进新行;读不到、不属于该用户、或为空都静默跳过,不报错。 3. `continueInNewChat` 把当前会话 id 传给 `startNewChat`,由它带进创建请求。 界面不得出现任何新提示(产品定的静默继承)。 - 验收:新增合同测试——带 `continued_from_session_id` 创建会话时,新行的 `context_summary` 等于源会话的;源会话属于**别的用户**时新行为 null 且接口仍返回 201。 - 验收:源码合同断言 `chatSessionCreateSchema` 里没有 `context_summary` 字段(拒绝客户端提供摘要文本)。 - 验收:UI 断言——`session_full` 后点「开新对话」,界面上不出现任何新增提示文案。 ### 5.4 三条 Bug 历史 同一变更内写进 `docs/BUG_HISTORY.md`: - **BUG-729**:关联 **BUG-555**,写明它的防复发只写了「头部截断」,整轮丢弃不在字面里所以没拦住。防复发升级为:**咨询历史任何形式的丢弃(截断单条、丢整轮)都必须在模型可见文本里留痕。** - **BUG-730**:关联 BUG-555。防复发:**触发摘要的阈值必须由历史预算派生,并由一条断言钉死「阈值 < 预算」在所有合法上下文窗口下成立。** - **BUG-731**:关联 BUG-464(`session_full` 的来源)、BUG-555。防复发:**会话满员后的「开新对话」出口必须由服务端继承 `context_summary`;摘要文本任何时候都不得由客户端提供。** ## 6. 让步顺序 1. 5.2(阈值跟预算)最先做——它是另外两条的上游,做完之后 5.1 的触发面会小很多。 2. 5.1 次之,改动最小。 3. 5.3 最后,它跨的文件最多。砍了要在进度记录里写明,并说明用户仍会在写满时丢掉记忆。 4. 5.4 不得砍。 ## 7. 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/consultation-context-memory-20260915 \ .worktrees/consultation-context-memory-20260915 origin/staging cd .worktrees/consultation-context-memory-20260915/frontend git status -sb | head -1 npm ci ``` 验收命令: ```bash ./node_modules/.bin/tsc --noEmit npm run lint # 0 error npx tsx --test tests/consultation-context.test.ts tests/consultation-*.test.ts \ tests/chat-session-*.test.ts tests/session-*.test.ts npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红) npm run build # `/` 仍须 ○ Static,首屏 gzip ±2% ``` ## 8. BUG 编号起点 基线 `6b3248bf` 上最大号 **BUG-720**;校正四单预占 **721–726**,external-evidence-cache 单预占 **727 / 728**。本单预占 **BUG-729 / 730 / 731**。开工时核对实际最大号,冲突顺延并写进进度记录。 ## 9. 不在本单范围 - 摘要机制本身(800 汉字上限、15 秒计时器、乐观并发、结算后异步) - 对话字符上限与思考文本的计入口径(见 `TASK-consultation-session-capacity-20260915.md`) - 外网证据缓存(见 `TASK-consultation-external-evidence-cache-20260915.md`) - 会话列表 / 详情接口的列宽(BUG-464 红线)