17 KiB
17 KiB
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 L735–800) |
时间、Case ID 在用户消息 |
| 最坏总量 | 约 8–9 万 tokens | 约 3–4 万 tokens |
四个问题:
- 报告尾巴被砍且无标记:
slice(0, 4_000)取每条消息开头。Level 2 报告通常远超 4,000 字,模型在下一轮看不到自己上一轮的应期、综合判断与技法审计表,也没有任何"已截断"提示。用户追问"你上次说的应期"时模型是真的没看到。 - 没有溢出保护:后台模型配置有
model_config_versions.context_window(api/admin/models/route.tsL47/L96/L141),但model-catalog.ts::resolveRow不读它,ResolvedLanguageModel没有这个字段,历史窗口是写死的常数;超限时供应商报错会走普通失败路径,没有任何地方识别context_length_exceeded一类错误。 - 缓存只对 Anthropic 显式生效,且不覆盖历史:
agent-generation-settings.ts::cachedSystemMessage只在providerId === "anthropic"时加cacheControl: ephemeral,位置在系统块之后(指令 + Skill + 工具定义命中),历史消息不在任何断点内,每轮全价重算。OpenAI / OpenAI 兼容供应商靠自动前缀缓存,代码已把动态内容放在最后一条消息,前缀稳定——这部分是对的,但滑窗一旦启动(第 13 条起)历史前缀每轮变化,只剩系统块能命中。 - 命中率无人可见:
complete_usageRPC 把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:"都要")
- 会话滚动摘要,按检查点而不是按轮:
chat_sessions新增context_summary jsonb null,形状{ version: 1, text, throughRequestId, throughMessageIndex, messageCount, updatedAt }。历史 = 摘要之后的全部消息(append-only 尾巴);只有当尾巴总字数超过CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000时,在本轮结算后做一次检查点:把"旧摘要 + 尾巴中除最后一对问答外的消息"压成新摘要,through*前移到最后一对之前。两个检查点之间尾巴只增不减,所以前缀稳定、自动前缀缓存与 Anthropic 增量缓存都能延续命中;这是选"检查点"而不是"每轮摘要"的唯一理由。 - 摘要放在最后一条用户消息里(与时间、称呼同位置,标题固定为
【会话摘要(服务端维护)】),不放在历史前面——摘要一变就会让它后面的所有历史失去前缀,放在末尾则只影响本轮动态部分。 - 每条消息的截断上限从 4,000 提到
CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000,超出时在截断处写明……(以下省略 N 字,结论已并入会话摘要),不再静默。 - 摘要生成不扣用户点数、不阻塞回答:在
complete_consultation_response成功之后触发,15 s 超时用 ref 的计时器(不得用裸AbortSignal.timeout(),它的计时器始终 unref,空事件循环会在 abort 前排空——BUG-523),失败只console.warn,下一轮再试。模型用目录默认模型(defaultModelId),默认模型不可用时用会话模型。摘要输出 ≤ 800 汉字,固定四段:已问过的问题 / 已给出的结论(含应期、置信度、blocked 项)/ 用户补充的事实 / 未决与待追问;不得包含出生日期、出生时间、出生地、姓名、邮箱(服务端只传问答文本,不传资料;生成后正则清洗日期时间形态的字段属于二道保险)。 - 写入用乐观并发:
update … set context_summary = $new where id = $id and (context_summary is null or context_summary->>'updatedAt' = $seenUpdatedAt);两轮并发结算时后到者放弃。 - 按模型上下文算预算:目录读
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 模型只剩摘要 + 最后一对。 - 溢出识别与一次降级重试:把供应商错误文本 / 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事件,不新增等待态),仍失败才按现有失败路径结算。用户看到的仍是一次等待一次揭幕。 - Anthropic 第二个缓存断点:历史尾巴非空时,在最后一条历史消息上加
providerOptions.anthropic.cacheControl(保持系统块断点不变,总断点数 ≤ 4)。OpenAI / 兼容供应商不加任何标记(它们的自动缓存无需也不接受标记),继续靠前缀稳定。 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 的命中率数据出来再定。- 后台可见:用量聚合接口按
actual_model_id× 近 7 天 / 近 30 天给出:有缓存数据的运行数、命中率(readTokens > 0的运行 ÷ 有缓存数据的运行)、readTokens/writeTokens/noCacheTokens合计、缓存占比(read ÷ (read + write + noCache));用量列表加一列"缓存读 tokens"。全部从usage_ledger.metadata->'cache'读,不动表、不加索引(30 天窗口走现有usage_ledger_created_idx)。 - 生时校正链路一行不动:它已是档案 + 投影的正确形态。
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 轮后命中率 > 0;DeepSeek / 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. 开工前置命令
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 一起)。