只读审计 origin/staging @ 6b3248bf 后出的四份任务书:
- engine-memoization(BUG-721,纯 Python 可并行):一次重算 45% CPU 在重复
算同一份 Shadbala,过境盘按候选算了 2196 次(应 36),鉴别探针算两遍,
_cached_rows 是死代码。本机等价实验 3358 → 1604 ms,candidate_scores 与
decision_receipt 逐字相同。只做记忆化,不改算法。
- failure-attribution(BUG-722/723/724,独占 route.ts):分类器失败被说成
用户说不清且丢证据;引擎 429 被当成引擎坏、不重试不打日志(复发自
BUG-715);attempt 210s × 2 > maxDuration 240s(复发自 BUG-059)。
- settled-render-split(BUG-725,前端可并行):校正会话流式每帧重渲整条
对话并重跑已结算消息的 Markdown;BUG-473 的咨询面拆分没有跟过来。
- request-dossier-cache(BUG-726,串行在 failure-attribution 之后):一轮
取 3.44 次整份 Case 档案,改成写即失效的请求作用域缓存,零调用点改动。
纯文档推送,不触发门禁、不发布镜像、不部署。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
9.1 KiB
TASK · 一轮对话把整份 Case 档案从数据库取 3.4 次
- 日期:2026-09-15
- 基线 commit:
origin/staging@6b3248bf - 执行分支:
codex/rectification-request-dossier-cache-20260915 - 串行在
TASK-rectification-failure-attribution-20260915之后:两单都要改frontend/src/app/api/rectification/agent/route.ts,本单必须以那一单合入后的 staging 为基线,不得并行 - 主要落点:
frontend/src/lib/rectification-agentic/v9/tool-service.ts(或新建同目录的缓存模块)、route.ts一处接线 - 规模:一个请求内缓存层 + 一处接线。零调用点改动、零行为变化。
1. 事故实证
loadV9CaseDossier / loadV9CaseCompute(tool-service.ts)每次调用都是一次 Postgres RPC,没有任何缓存。全仓约 40 个调用点:rectification-v9-tools.ts 13 + 3、answer-choice.ts 6 + 4、block-scan-answer.ts 3、agent-run.ts 3 + 1、score-persist.ts 2 + 1、route.ts 2 + 2,以及 regenerate-turn.ts、refresh-discriminator-probes.ts、三条 GET 路由各一。
用现有测试套件的 RPC 计数器实测(在 fakeAccounting 上按场景聚合):
| 路径 | 场景数 | get_..._case_dossier |
每场景均值 | get_..._case_compute |
均值 |
|---|---|---|---|---|---|
Agent 轮(rectification-v9-stream.test.ts) |
39 | 134 | 3.44 | 25 | 0.64 |
点选题(rectification-answer-choice.test.ts) |
15 | 31 | 2.07 | 17 | 1.13 |
同一轮里还有 insert_agentic_rectification_run_phase 7.05 次、set_agentic_rectification_conversation_focus 2.46 次——那些是真写入,不在本单范围。
这份档案不小。get_agentic_rectification_case_dossier(supabase/migrations/20260907020000_rectification_declared_uncertainty.sql 里的最新定义)单次要做:
- 取
agentic_rectification_cases一行 - 取最近 50 轮 turns,
cross join lateral展开成 user/assistant 两条消息再jsonb_agg - 取该 Case 的全部 evidence 行
jsonb_agg - 两次
count(*)(evidence、turns) - 取 conversation summary(不存在时还要先
refresh_..._conversation_summary) - 取最新 result,并调
compose_agentic_rectification_decision_receipt合成收据(内含 candidates 数组)
在 2 vCPU 的生产主机上,这段 jsonb 构建与 Next.js、Python 引擎抢的是同一批核。
界面侧还会放大:questionGap === "preparing" 时 useVisibilityAwarePoll 每 RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2000 毫秒打一次 GET /api/rectification/cases/[caseId],每次又是一份完整档案(有重试次数上限,不是无限轮询)。
2. 根因
档案在一次请求内是不变的(除非本请求自己写了东西),但每个需要它的地方都独立重查。没有请求作用域的概念,于是「读一次用多处」退化成「用几处读几次」。
3. 决策记录
产品 2026-09-15 授权本单,口径:
- 缓存必须是「写即失效」的,不是「请求内固定」。 档案在一次请求里会变——写证据、设焦点、落候选、切状态都会改它。任何「整个请求只读一次」的实现都会让下游拿到陈旧档案,那比多查几次严重得多。
- 不得改动那 40 个调用点。 逐点传缓存参数既大又容易漏一处,漏的那一处就是陈旧数据。缓存必须挂在客户端对象上,对调用点完全透明。
- 不引入跨请求缓存。 档案里有出生资料派生数据与会话内容,跨请求持有违反 AGENTS §8;而且并发校正之间必然串味。
- 本单不动轮询间隔、不动 GET 路由的投影形状。 「档案能不能瘦一点」「GET 能不能只返回下一问」是另一个话题,需要先确认哪些字段真的有人读,本单不碰。
4. 硬红线
- 写操作后必须能读到写后的档案。这是本单唯一的成败判据。
- 缓存生命周期严格等于一次 HTTP 请求(含流式响应体执行期间),不得挂在模块作用域、
globalThis或任何跨请求容器上。 - 缓存键必须包含
userId与caseId。同一请求内不会出现第二个用户,但键里带上它是防止将来被误用的最低成本。 route.ts只允许新增一处接线(包装createAdminSupabaseClient()的返回值)。不得在别处零散加缓存。- 不得改
get_agentic_rectification_case_dossier/_compute的 SQL 定义(本轮不动数据库结构,AGENTS §7.6)。 - 不得顺手升级依赖、不得顺手修不在本单里的 warning。
5. 任务分解
5.1 请求作用域的缓存包装器
新增 withRectificationRequestCache(accounting),返回一个与 RectificationRpcClient 结构相同的包装对象:
rpc("get_agentic_rectification_case_dossier" | "get_agentic_rectification_case_compute", args):按(fn, p_user_id, p_case_id)命中则返回缓存的 in-flight promise,未命中则实际调用并缓存该 promise(缓存 promise 而不是结果,可以顺带把并发的重复读合并成一次)。rpc(其它任何函数名, args):先整体清空缓存,再转发。不需要区分读写——除这两个只读投影外的 RPC 一律按可能写处理,这是保守且正确的一侧。- 任何其它属性(例如某些调用点用
accounting as never走的.from(table).update(...))必须透传,并且在被访问时同样清空缓存。用Proxy或显式转发都行,选一个并在注释里写明为什么。
注意:agent-run.ts、case-service.ts、session.ts、regenerate-turn.ts 里有若干 accounting.rpc(...) 的直接调用(不走 tool-service.rpc 这个私有 helper)。正因为如此,缓存必须挂在客户端对象上——挂在 tool-service 的 helper 里会漏掉这些。
- 验收:单元测试覆盖——连续两次读同一
(userId, caseId)只产生一次底层 RPC;中间插入任意一次其它 RPC 后,第三次读必须重新打底层;两个不同caseId互不命中;并发两次读只打一次底层。 - 验收:
.from(...)透传后缓存被清空。
5.2 在路由入口接上
route.ts 里 accounting = createAdminSupabaseClient() 之后立刻包一层,后续一切原样。regenerate 路由与三条 cases/[caseId]/* GET/POST 路由同样处理(每条也只允许一处接线)。
- 验收:源码合同断言这几条路由里
createAdminSupabaseClient()的返回值都经过包装器,没有裸用。
5.3 用现有套件量出前后差
把 §1 那张表的测法固化下来:在 rectification-v9-test-support.ts 的 fakeAccounting 上加一个可选的计数导出(默认关闭,靠显式开关启用,不得影响既有断言),新增一条测试断言「同一场景下 get_..._case_dossier 的调用次数不超过 N」。N 取改动后的实测值,不是拍的。
- 验收:进度记录里贴出改前/改后两组计数(Agent 轮与点选题各一组)。
- 验收:既有 34 + 47 条断言一条不改仍全绿。
5.4 Bug 历史
预占 BUG-726,状态可写 resolved(有针对性回归)。这不是回归,是自 V9 运行时引入以来的分层遗漏。防复发写成:Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。
6. 让步顺序
- 5.1 + 5.2 是一体,必须一起做。只做 5.1 不接线等于没做。
- 5.3 的计数断言可以只覆盖 Agent 轮一条路径,点选题那条砍掉时在进度记录里写明。
- 5.4 不得砍。
- 如果 5.1 的「其它属性透传」在类型上过不去(
AccountingClient只声明了rpc,多处调用点用as never绕过),宁可缩小范围:只包装rpc,并在记录里写明.from(...)路径未覆盖、可能读到陈旧档案的具体位置。不得为了让类型通过去改业务代码(这是产品明确的偏好)。
7. 开工前置命令
git fetch origin --prune
# 确认 failure-attribution 单已合入 staging 再开工
git log --oneline origin/staging | head -5
git worktree add -b codex/rectification-request-dossier-cache-20260915 \
.worktrees/rectification-request-dossier-cache-20260915 origin/staging
cd .worktrees/rectification-request-dossier-cache-20260915/frontend
git status -sb | head -1
npm ci
验收命令:
./node_modules/.bin/tsc --noEmit
npm run lint
npx tsx --test tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
npm run build
8. BUG 编号起点
基线 6b3248bf 上最大号 BUG-720。本单预占 BUG-726。因为串行在 failure-attribution(722–724)之后,开工时那几号大概率已落库;以当时实际最大号 +1 为准。
9. 不在本单范围
- 档案投影瘦身(哪些字段真的有人读、50 轮 turns 是不是必要)
useVisibilityAwarePoll的 2 秒间隔与重试上限insert_agentic_rectification_run_phase(每轮 7 次)的批量化- 引擎耗时、前端渲染、故障归因(各有专单)