# TASK · 对话上限一半被思考文本吃掉,十几轮就「已写满」 - 日期:2026-09-15 - 基线 commit:`origin/staging` @ `6b3248bf` - 执行分支:`codex/consultation-session-capacity-20260915` - 主要落点:新增一份迁移(`CREATE OR REPLACE FUNCTION public.append_consultation_question`)、`frontend/tests/` 下的合同测试 - **不改 `frontend/src/app/api/consult/route.ts`**:两档上限都继续返回同一个 `session_full`,路由与客户端一行都不用动 - 与 external-evidence-cache 单、context-memory 单无文件重叠,可并行 --- ## 1. 事故实证 问下一个问题之前,`append_consultation_question`(`supabase/migrations/20260901010000_append_consultation_question.sql`)先数这段对话: ```sql select coalesce(sum( length(coalesce(elem->>'text', '')) + length(coalesce(elem->>'thinkingText', '')) + case when elem ? 'thinkingSections' then length((elem->'thinkingSections')::text) else 0 end ), 0) into v_chars from jsonb_array_elements(...) as elem; if v_count >= 200 or (v_chars + v_new_chars) > 200000 then return query select false, 'session_full'::text; ``` 超了就 409,文案「这段对话已写满,开个新对话继续吧」。 每一轮一问一答实际吃掉多少: | 计入的内容 | 每轮字符 | 用户读得到吗 | | --- | ---: | --- | | 助手正文 `text` | 约 3,000 – 5,000 | 是,这才是对话 | | 思考文本 `thinkingText` | 最多 4,000(`route.ts` 里 `slice(0, 4_000)`) | 默认折叠 | | 思考分节 `thinkingSections` | **1,521 / 2,243 / 2,977**(实测 1 / 2 / 3 个问题域) | 只是步骤标签 | | 用户提问 | 约 20 – 200 | 是 | 分节那三个数是跑 `natalConsultationThinkingPlan` 实测的 JSON 长度(3 / 4 / 5 个 section,19 / 29 / 39 个 step)。合计每轮约 **9,000 – 12,000** 字符,**一半以上是模型内部状态**。 200,000 ÷ 每轮约 10,500 ≈ **19 轮**。另一档上限 200 条消息(100 轮)**永远碰不到**——真正卡住用户的是字符数,而字符数一半是思考。 口径本来就不一致:同一条助手消息里还存了 `techniqueTruth`、`workflowReceipt`、`agentExecutionReceipt`,这三个字段**照样入库却完全不计入**上限。 ## 2. 根因 这条上限身兼二职却一职没做好: - 想当「这段对话有多长」→ 不该数用户读不到、也无法控制的思考文本。 - 想当「护住数据库那一行」→ 那就该把 `techniqueTruth` / `workflowReceipt` / `agentExecutionReceipt` 一起数,可它没数。 两个目标挤在一个数字里,结果是两边都不成立:用户的对话额度被模型内部状态吃掉一半,而数据库行的真实大小从来没被这条上限约束过。 ## 3. 决策记录 产品 2026-09-15 拍板:**思考文本不计入对话上限**,并把两个目标拆成两条上限。 1. **对话额度只数用户读得到的东西**:助手正文 `text` + 用户提问 `text`。`thinkingText`、`thinkingSections` 不计入。额度数值 **200,000 不变**——变的只是数什么。预期从约 19 轮提到约 50 轮。 2. **另设一条物理上限护住数据库行**,把**全部字段**(含三个 receipt)都算进去。它的作用是防止单行异常膨胀,正常使用下不应该先于对话额度触发。 3. **两档都返回同一个 `session_full`**。用户看到的处置一样(开新对话),没必要让前端分两种文案,也就不用动路由和客户端。 4. **不删、不截断任何已存字段。** 「老轮次的思考文本要不要继续留着」是另一个产品问题,见 §9 观察项,本单不碰。 5. **200 条消息那档上限保留不动。** ## 4. 硬红线 1. `append_consultation_question` 必须保持 **advisory lock、`request_id` 幂等、满员拒绝** 三件事(BUG-464 防复发原文)。本单只改「数什么」和「加一档」,不得动这三条。 2. 迁移必须对**当前已部署的那一版代码向后兼容**(AGENTS §7.6):`CREATE OR REPLACE` 保持函数签名与返回列不变,`error_code` 仍只用既有的取值。staging 迁移在部署之前自动应用,旧代码必须能继续正常调用。 3. 不得新增列、不得删列、不得改类型(本轮不动表结构)。 4. 不得放宽 `char_length(v_text) > 16000` 那条单条提问长度校验。 5. 物理上限的数值必须由算术推出来并写进迁移注释,不得拍脑袋写一个整数。 6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 ## 5. 任务分解 ### 5.1 对话额度只数正文 新迁移里 `CREATE OR REPLACE` 该函数,把 `v_chars` 的求和改成只累加 `elem->>'text'`,`thinkingText` 与 `thinkingSections` 不再进这个和。上限常数 200,000 不变。 - 验收:`npm run test:db --prefix frontend`(需 Docker)新增用例——同一段会话,助手消息带 4,000 字 `thinkingText` 与 3,000 字 `thinkingSections` 时,**不再**影响还能问几轮;只有正文长度影响。 - 验收:无 Docker 时写成环境缺口进 `BLOCKED.md`,并用 SQL 静态合同断言(求和表达式里不含 `thinkingText` / `thinkingSections`)替代,**不得写成「通过」**。 - 验收:幂等分支(同 `request_id` 重入返回 `true, null`)、advisory lock、`session_missing` 三条既有行为的测试一条不改仍全绿。 ### 5.2 加一条把全部字段算上的物理上限 同一个函数里加第二次求和:对每条消息取 `length(elem::text)`(整条 JSON 的长度,字段增减都自动覆盖),超过物理上限同样返回 `session_full`。 数值按算术定,并把算式写进迁移注释:50 轮 × 每轮(正文约 4,000 + 思考 4,000 + 分节 3,000 + 三个 receipt 约 3,000)≈ 700,000,取 **1,000,000** 作为留有余量的物理上限。执行方若测得 receipt 实际更大,按实测调整并在进度记录里写明新算式。 - 验收:新增用例——正文很短但 receipt 极大的会话会被物理上限拒绝,且 `error_code` 仍是 `session_full`。 - 验收:正常形态的会话在触发对话额度之前**不会**先撞上物理上限(用 5.1 的夹具跑到额度边界,断言物理上限未触发)。 - 验收:迁移注释里有那行算式。 ### 5.3 量一次真实的详情接口体积 对话轮数上限从约 19 提到约 50,`GET /api/sessions/[id]` 返回的整份 `messages` 也会按比例变大(该接口的 `sessionSelect` 含 `messages`,打开一段长会话是整份取回)。本单不改这个接口,但必须把数字量出来: - 构造一段跑到新额度边界的会话(测试夹具即可,不需要真实用户数据),记录详情接口 JSON 的体积。 - 结论写进进度记录。如果在 2 vCPU 上明显偏大,追加到 §9 观察项并写进 `BLOCKED.md`,**不得默默略过**。 - 验收:进度记录里有改前(约 19 轮)与改后(约 50 轮)两组体积数字。 ### 5.4 Bug 历史 同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-732**。必须写明:**关联 BUG-464**(200 / 200,000 两档上限与 `append_consultation_question` 都是那一单立的),以及这不是回归,是那条上限从一开始就身兼二职。防复发写成: > 会话上限必须分成两条各司其职的口径:面向用户的对话额度只数用户读得到的正文;面向存储的物理上限必须把整条消息 JSON 算全。新增会存进 `messages` 的字段时,必须明确它进哪一条,不得默认落进对话额度。 ## 6. 让步顺序 1. 5.1 必须做,它是本单的全部意义。 2. 5.2 必须和 5.1 同轮——只放宽不设物理上限,等于把行大小的护栏整个拆掉。 3. 5.3 可以退化成「写成环境缺口」,但不得跳过不提。 4. 5.4 不得砍。 ## 7. 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/consultation-session-capacity-20260915 \ .worktrees/consultation-session-capacity-20260915 origin/staging cd .worktrees/consultation-session-capacity-20260915 git status -sb | head -1 cd frontend && npm ci ``` 开工前必读:`docs/BUG_HISTORY.md` 里的 **BUG-464**(这两档上限的来历与三条防复发),以及 AGENTS §7.6 关于 staging 自动迁移必须向后兼容的那一段。 验收命令: ```bash cd frontend ./node_modules/.bin/tsc --noEmit npm run lint npm run db:migrate:check # 迁移可应用性 npm run test:db # 需 Docker;无 Docker 写 BLOCKED.md npx tsx --test tests/consultation-*.test.ts tests/chat-session-*.test.ts npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单 ``` ## 8. BUG 编号起点 基线 `6b3248bf` 上最大号 **BUG-720**;校正四单预占 **721–726**,external-evidence-cache 单预占 **727 / 728**,context-memory 单预占 **729 / 730 / 731**。本单预占 **BUG-732**。开工时核对实际最大号,冲突顺延并写进进度记录。 ## 9. 观察项与不在本单范围 - **老轮次的思考文本要不要一直留着**:`thinkingText` 会在历史消息里渲染(`chat-message-row.tsx` 对已结算消息也判断 `message.thinkingText?.trim()`),所以它是个真功能,不是纯浪费。但它的价值随时间衰减,只保留最近 N 轮是个可选方向——**这是产品决策,本单不做,也不得顺手做。** - 详情接口分页 / 按需加载历史(5.3 只负责量数据) - 200 条消息那档上限 - 上下文记忆三条(见 `TASK-consultation-context-memory-20260915.md`) - 外网证据缓存(见 `TASK-consultation-external-evidence-cache-20260915.md`)