Files
Jyotisha/docs/tasks/TASK-consultation-context-and-cache-20260906.md
T

114 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 普通对话上下文窗口与模型缓存(2026-09-06)
- 基线:`origin/staging` @ `d8a0f615`
- 分支:`codex/consultation-context-and-cache-20260906`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/consultation-session-history.ts``frontend/src/app/api/consult/route.ts``frontend/src/lib/agent-generation-settings.ts``frontend/src/lib/model-catalog.ts``frontend/src/lib/consultation-methodology.ts``frontend/src/mastra/skill-binding.ts``frontend/src/mastra/index.ts``frontend/src/app/api/admin/usage/aggregate/route.ts``frontend/src/components/admin/pricing-simulator.tsx`(或承载用量聚合表的组件)、新文件 `frontend/src/lib/session-context-summary.ts`、新迁移 `frontend/supabase/migrations/20260906010000_chat_session_context_summary.sql`。**不改**生时校正链路(`rectification-agentic/**``rectification-v9-tools.ts`)、不改 Skill 正文、不改引擎、不改 `page.tsx`
- BUG 编号起点:**BUG-555**BUG-551/552 归 composer 单、553 归会话列表单、554 归设置弹窗单、542 归 api-not-configured 单;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
- 串行:本单与 `TASK-session-list-title-and-order-20260906.md`BUG-553)都改 `consult/route.ts` 的结算路径与 `chat_sessions` 行,**在 BUG-553 合入 staging 之后开工**;与 composer 单、设置弹窗单无文件交集。
- 本单动表,必须真跑 `npm run test:db --prefix frontend`;本机无 Docker 时按 §8 写 `BLOCKED.md`
## 1. 事实(基线代码实测,按字节估算 token,未用分词器)
| 部分 | 普通对话每轮 | 生时校正每轮(对照,本单不动) |
| --- | --- | --- |
| 系统提示 | 产品指令约 10.7 KB + Skill 运行时节选约 28 KB`skill-binding.ts::boundMethod` 从 51 KB 的 `SKILL.md` 绑 9 段) | Skill 10.0.14 全文约 18.5 KB |
| 聊天历史 | `consultationHistoryFromStoredMessages`:最近 `CONSULTATION_HISTORY_LIMIT = 12` 条,每条 `slice(0, 4_000)` | 不带;模型调 `rectification-read-case` 读投影(最近 8 轮 × 1,600 字 + 最近 20 条证据) |
| 工具结果 | `run-jyotish-consultation`:每领域约 15–18 KB 排盘 + `methodology``METHODOLOGY_TOTAL_MAX_CHARS = 24_000` | 档案投影 |
| 动态内容位置 | 时间、称呼、模式说明都在最后一条用户消息(`consult/route.ts` L735800 | 时间、Case ID 在用户消息 |
| 最坏总量 | 约 89 万 tokens | 约 34 万 tokens |
四个问题:
1. **报告尾巴被砍且无标记**`slice(0, 4_000)` 取每条消息开头。Level 2 报告通常远超 4,000 字,模型在下一轮看不到自己上一轮的应期、综合判断与技法审计表,也没有任何"已截断"提示。用户追问"你上次说的应期"时模型是真的没看到。
2. **没有溢出保护**:后台模型配置有 `model_config_versions.context_window``api/admin/models/route.ts` L47/L96/L141),但 `model-catalog.ts::resolveRow` 不读它,`ResolvedLanguageModel` 没有这个字段,历史窗口是写死的常数;超限时供应商报错会走普通失败路径,没有任何地方识别 `context_length_exceeded` 一类错误。
3. **缓存只对 Anthropic 显式生效,且不覆盖历史**`agent-generation-settings.ts::cachedSystemMessage` 只在 `providerId === "anthropic"` 时加 `cacheControl: ephemeral`,位置在系统块之后(指令 + Skill + 工具定义命中),历史消息不在任何断点内,每轮全价重算。OpenAI / OpenAI 兼容供应商靠自动前缀缓存,代码已把动态内容放在最后一条消息,前缀稳定——这部分是对的,但滑窗一旦启动(第 13 条起)历史前缀每轮变化,只剩系统块能命中。
4. **命中率无人可见**`complete_usage` RPC 把 `metadata.cache {readTokens, writeTokens, noCacheTokens, hit}` 写进 `usage_events``usage_ledger.metadata`consult 路由 L572、rectification 路由 L682),但 `api/admin/usage/aggregate` 与用量列表都不读它,后台没有任何一列显示缓存。另外 `costMicrousd` 按全价输入单价计算,不区分缓存读价——这是账务口径问题,本单不改(见 §7)。
另有一处已经做对、本单要保住的:`skill-binding.ts` 已把 129 KB 的 Skill 激活清单压成 28 KB 运行时节选(注释写明),是当前最大的一次上下文优化,不得回退。
## 2. 根因
- 历史窗口是"固定条数 × 固定头部字数"的一刀切,既不知道模型有多大,也不保留结论。
- 缓存能力只对一家供应商写了显式标记,其余靠供应商默认;没有数据回流到后台,无法判断是否真的命中。
- `methodology` 里跨领域不变的两段(Full-spectrum invocation 约 1.9 KB、Event judgment skeleton 约 4.5 KB)每次随工具结果重发,工具结果不在任何缓存前缀里。
## 3. 决策记录(产品已授权,2026-09-06"都要"
1. **会话滚动摘要,按检查点而不是按轮**`chat_sessions` 新增 `context_summary jsonb null`,形状 `{ version: 1, text, throughRequestId, throughMessageIndex, messageCount, updatedAt }`。历史 = **摘要之后的全部消息(append-only 尾巴)**;只有当尾巴总字数超过 `CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000` 时,在本轮结算后做一次检查点:把"旧摘要 + 尾巴中除最后一对问答外的消息"压成新摘要,`through*` 前移到最后一对之前。两个检查点之间尾巴只增不减,所以前缀稳定、自动前缀缓存与 Anthropic 增量缓存都能延续命中;这是选"检查点"而不是"每轮摘要"的唯一理由。
2. **摘要放在最后一条用户消息里**(与时间、称呼同位置,标题固定为 `【会话摘要(服务端维护)】`),不放在历史前面——摘要一变就会让它后面的所有历史失去前缀,放在末尾则只影响本轮动态部分。
3. **每条消息的截断上限从 4,000 提到 `CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000`,超出时在截断处写明** `……(以下省略 N 字,结论已并入会话摘要)`,不再静默。
4. **摘要生成不扣用户点数、不阻塞回答**:在 `complete_consultation_response` 成功之后触发,15 s 超时用 ref 的计时器(不得用裸 `AbortSignal.timeout()`,它的计时器始终 unref,空事件循环会在 abort 前排空——BUG-523),失败只 `console.warn`,下一轮再试。模型用目录默认模型(`defaultModelId`),默认模型不可用时用会话模型。摘要输出 ≤ 800 汉字,固定四段:已问过的问题 / 已给出的结论(含应期、置信度、blocked 项)/ 用户补充的事实 / 未决与待追问;不得包含出生日期、出生时间、出生地、姓名、邮箱(服务端只传问答文本,不传资料;生成后正则清洗日期时间形态的字段属于二道保险)。
5. **写入用乐观并发**`update … set context_summary = $new where id = $id and (context_summary is null or context_summary->>'updatedAt' = $seenUpdatedAt)`;两轮并发结算时后到者放弃。
6. **按模型上下文算预算**:目录读 `context_window`(列已存在,不动表);`context_window` 为空时视作 128k。历史预算 `historyBudgetChars = clamp((context_window 60_000) × 1.5, 4_000, 40_000)`——60k 为系统块 + 三领域工具结果 + 输出预算的保留量,1.5 为汉字/token 粗系数;尾巴超预算时**从最旧一端整条丢**(不砍单条)直到不超,摘要始终保留。128k 模型结果与决策 1 一致;64k 模型历史约 6,000 字;32k 模型只剩摘要 + 最后一对。
7. **溢出识别与一次降级重试**:把供应商错误文本 / code 中的 `context_length_exceeded``maximum context length``prompt is too long``input is too long``too many tokens``max_tokens` 与上下文相关的 400 归为 `context_overflow`;命中时**同一请求内**把历史缩到"摘要 + 最后一对"重试一次(复用现有 `attempt.reset` 事件,不新增等待态),仍失败才按现有失败路径结算。用户看到的仍是一次等待一次揭幕。
8. **Anthropic 第二个缓存断点**:历史尾巴非空时,在最后一条历史消息上加 `providerOptions.anthropic.cacheControl`(保持系统块断点不变,总断点数 ≤ 4)。OpenAI / 兼容供应商不加任何标记(它们的自动缓存无需也不接受标记),继续靠前缀稳定。
9. **`methodology` 中跨领域不变的两段搬进系统块**Full-spectrum invocation 与 Event judgment skeleton 由 `skill-binding.ts` 绑进 `jyotishSkillMethodBlock`(可缓存),工具结果的 `methodology.sections` 只保留领域专属段(strict 路由 + 对应 `event_judgment_*.md`)与 `further_reading` / `domains_without_strict_checklist``mastra/index.ts` 中"methodology 段落已交付"的措辞相应改为"共享基线在系统块、领域清单随工具结果"。**不**把整份 `strict-workflow-router.md`22.6 KB)与三份 event judgment(约 15 KB)都搬进系统块——那会让无缓存供应商每轮多付 12–27 KB,是否值得等 §5.2 的命中率数据出来再定。
10. **后台可见**:用量聚合接口按 `actual_model_id` × 近 7 天 / 近 30 天给出:有缓存数据的运行数、命中率(`readTokens > 0` 的运行 ÷ 有缓存数据的运行)、`readTokens` / `writeTokens` / `noCacheTokens` 合计、缓存占比(read ÷ (read + write + noCache));用量列表加一列"缓存读 tokens"。全部从 `usage_ledger.metadata->'cache'` 读,不动表、不加索引(30 天窗口走现有 `usage_ledger_created_idx`)。
11. 生时校正链路一行不动:它已是档案 + 投影的正确形态。
## 4. 硬红线
- `jyotishSkillMethodBlock` 的 9 段节选与 `providesSkillDiscovery: "on-demand"` 机制不得回退;系统块字节数变化写进进度记录(预期 +6.4 KB)。
- 摘要与历史都只含问答文本;出生资料仍只在服务端工具内部,不进 prompt。摘要文本不进 `usage_ledger.metadata`、不进日志。
- 现有 `consultation-session-history.test.ts``admin-usage-aggregate-contract.test.ts` 的断言若改,三栏(原值 / 新值 / 原因)说明;测试总数不低于开工时 `origin/staging` 实测。
- 不改 `page.tsx`;客户端零改动(溢出重试复用服务端既有事件)。
- 不给摘要、缓存加任何开关或环境变量;不加"加载中"态。
- 不改 `costMicrousd` 的计价口径(§7)。
- 迁移只加一列,无回填、无索引;`npm run test:db` 真跑或写 `BLOCKED.md`
## 5. 任务分解
### 5.1 摘要 + 尾巴历史 + 按模型预算(决策 1–7)
- 迁移 `20260906010000_chat_session_context_summary.sql``alter table public.chat_sessions add column if not exists context_summary jsonb null check (context_summary is null or jsonb_typeof(context_summary) = 'object')`
- `model-catalog.ts``queryCatalog` 选出 `v.context_window``ResolvedLanguageModel.contextWindow: number | null`
- `consultation-session-history.ts`:新导出 `consultationHistoryWindow(messages, summary, { contextWindow, excludeRequestId })``{ tail: ConsultationHistoryMessage[], summaryText: string | null, droppedCount }``historyBudgetChars(contextWindow)`;截断标记;旧函数保留为兼容包装或删除(删除则更新调用方与测试)。
- 新文件 `lib/session-context-summary.ts``shouldCheckpoint(messages, summary)``buildSummaryPrompt(previous, messages)``generateSessionContextSummary(...)`(ref 计时器超时、清洗、长度上限)、`writeSessionContextSummary(...)`(乐观并发 SQL)。
- `consult/route.ts`:读 `chat_sessions.context_summary``baseMessages` 用尾巴 + 摘要块;`completeResponse``complete_consultation_response` 成功后 `void checkpoint()``context_overflow` 识别与一次降级重试;`sliced` / `continue` / 重试路径复用同一窗口。
- 验收:
- `tests/consultation-session-history.test.ts`:① 20 条消息、无摘要、128k → 尾巴按预算从最旧丢整条;② 有摘要 `throughMessageIndex = 14` → 尾巴只含 15 起;③ 单条 20,000 字 → 12,000 字 + 省略标记且标记含省略字数;④ `contextWindow = 64_000``32_000` 的预算数值;⑤ `contextWindow = null` 等价 128k。
-`tests/session-context-summary.test.ts`:① 尾巴 15,999 字不触发、16,001 字触发;② 触发时最后一对不进摘要输入;③ 摘要清洗掉 `1990-01-01` / `08:30` / 邮箱形态;④ 空事件循环下超时仍会 abort、用例不得被 `cancelledByParent`(照 BUG-523 的验收口径看 `# cancelled` 与退出码);⑤ 并发写 `updatedAt` 不匹配时放弃。
- 消息装配测试(现有 consult 路由合同测试或新增):摘要块出现在最后一条用户消息、位于时间行之后问题之前;`context_overflow` 错误触发一次重试且重试消息只含摘要 + 最后一对;第二次失败按原路径结算。
- `npm run test:db`Docker)通过,或 `BLOCKED.md` 记录 + 迁移 SQL 在 psql 语法检查(`psql --set ON_ERROR_STOP=1 -f` 对空库)通过。
### 5.2 后台缓存可见(决策 10)
- `api/admin/usage/aggregate/route.ts`:新增 `cache` 段(按 `actual_model_id`,两个窗口),SQL 只用 `metadata->'cache'``api/admin/usage/route.ts` 列表加 `cacheReadTokens`
- 前端聚合表加"缓存命中"表格(模型 / 运行数 / 命中率 / 缓存占比 / 读 / 写 / 未命中),列表加一列。
- 验收:`tests/admin-usage-aggregate-contract.test.ts` 扩展——metadata 无 `cache` 的行不计入分母;`readTokens = 0` 计为未命中;两个窗口互不串;权限仍为 `billing.orders.read`
### 5.3 Anthropic 历史断点(决策 8
- `agent-generation-settings.ts` 新导出 `cachedHistoryMessage(message, model)`(非 Anthropic 返回原消息);`consult/route.ts` 尾巴最后一条套用。
- 验收:单测——Anthropic 模型时最后一条历史带 `providerOptions.anthropic.cacheControl`,其余历史与 OpenAI / 兼容模型的所有消息都不带;系统块断点仍在。
### 5.4 共享方法段搬进系统块(决策 9)
- `skill-binding.ts``jyotishSkillMethodBlock` 追加 `<jyotish-shared-method>`Full-spectrum invocation + Event judgment skeleton,仍从包内文件实时读取);`consultation-methodology.ts::consultationMethodologyForDomains` 不再 push 这两段;`mastra/index.ts` 指令措辞同步。
- 验收:现有 methodology 合同测试更新(三栏说明);新断言——单领域 `methodology.sections` 只含该领域两段;系统块含两段且与包内文件逐字一致;进度记录写系统块前后字节数与单领域 / 三领域工具结果前后字节数。
### 5.5 记录
- `docs/BUG_HISTORY.md`:BUG-555(历史头部截断丢结论、无溢出识别)、BUG-556(缓存命中数据写库但后台不可见;Anthropic 历史无断点)。
- `CHANGELOG.md` 一条;`docs/tasks/PROGRESS-consultation-context-and-cache-20260906.md`(含 §4 要求的字节数表);`docs/testing/consultation-context-and-cache-20260906.md`(真实环境:同一会话问 6 轮以上后追问"你前面说的应期是哪年",答案与前文一致;后台用量页缓存表在 staging 跑过 3 轮后命中率 > 0DeepSeek / OpenAI / Anthropic 各一条对照)。
- `BLOCKED.md`:无 Docker 时 `test:db` 条目。
## 6. 让步顺序
5.1 → 5.2 → 5.3 → 5.4 → 5.5。5.1 内部:迁移 + 尾巴历史 + 截断标记不可拆;摘要生成与溢出重试可以各自后置但必须在同一分支完成后再验收。5.5 不可省。
## 7. 不在本单
- **缓存读折扣计价**`costMicrousd` 目前按全价输入单价算,缓存读实际按供应商折扣计费(Anthropic / DeepSeek 约 0.1×,OpenAI 约 0.5×)。要改需在 `model_config_versions` 加缓存读单价列并改后台模型表单,产品另定。
- 整份 strict router / event judgment 搬进系统块:等 5.2 的命中率数据。
- 生时校正链路的缓存断点(工具结果在 agent 循环内,AI SDK 不便打断点)。
## 8. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consultation-context-and-cache-20260906 .worktrees/consultation-context-and-cache-20260906 origin/staging
cd .worktrees/consultation-context-and-cache-20260906
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && npx tsx --test tests/consultation-session-history.test.ts tests/admin-usage-aggregate-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)|^not ok"
npx tsx --test tests/*.test.ts 2>&1 | grep -E "^# (tests|pass|fail)" # 记下总数作为下限
```
收尾:`tsc --noEmit``npm run lint`0 error)、`npm test` fail=0 且总数不低于开工值、`npm run build``/` 仍 Static,首屏 gzip ±2%)、`npm run test:db`(或 `BLOCKED.md`)。部署前产品负责人需先跑 Gitea `Migrate Staging Database`(本迁移与已积压的 `5010000_personal_report_longform_appendices.sql` 一起)。