产品 2026-09-15 对审计单里挂着的两个待拍板项给出结论,两项都是「不改」: - 意图分类继续用会话选定的模型,不引入便宜快模型,模型目录不新增 「工具模型」角色。failure-attribution 单 §4.6 立为决策记录,§10 从 「待产品拍板后另开单」改为已决定不做;本单只改归因,不改用哪个模型。 - 只给年份的事件继续采满 12 个月,降采样不做、研究单也不立。 engine-memoization 单 §4.2 / §11 同步,并写明后续不得以性能为由重提。 两条都加了「要重提必须先拿到产品新的授权」,避免下一轮被当成遗漏又提一次。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
16 KiB
TASK · 三处把系统故障说成别的东西(校正轮次链路)
- 日期:2026-09-15
- 基线 commit:
origin/staging@6b3248bf - 执行分支:
codex/rectification-failure-attribution-20260915 - 独占文件:
frontend/src/app/api/rectification/agent/route.ts、frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts、frontend/src/lib/rectification-agentic/v9/engine-client.ts、frontend/src/lib/rectification-agentic/v9/agent-run.ts、frontend/src/lib/rectification-activity-labels.ts route.ts由本单独占。TASK-rectification-request-dossier-cache-20260915也要碰同一文件,必须串行在本单之后。- 与 engine-memoization 单、settled-render-split 单无文件重叠,可并行
1. 三条缺陷的共同点
都不是算错,是归因错:后端出了故障,但对用户显示成「你没说清楚」「已经记下了」或者干脆断流。用户没有任何线索知道要重试,也不知道刚才那句话有没有算数。
| # | 用户看到 | 实际发生 | 预占 BUG |
|---|---|---|---|
| 1 | 「我不太确定这句是不是在回答上面的问题」 | 意图分类器两次都异常,这句话里的经历直接丢弃 | BUG-722 |
| 2 | 「记下了:2016 年 3 月……」但范围一动不动 | 引擎 429(算不过来),重算静默失败 | BUG-723 |
| 3 | 等三五分钟,无错误码断流 | 两次尝试 420 秒 > 路由预算 240 秒 | BUG-724 |
2. 事故实证
2.1 BUG-722 · 分类器失败被说成用户表达不清
route.ts 的 POST 里,action === "message" 且当前焦点是点选题(parseAgentChoiceCopy(focus.expectedAnswerSchema) 非空)时:
const intent = await classifyTurnIntentWithRetry(resolvedModel, {...});
const classified = intent.classified;
...
if (!classified || classified.intent === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply; // 「我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。」
const turn = await persistV9DeterministicTurn(...); // 本轮落库
return completedMessageResponse(narration, ...); // 直接返回,不进 Agent
}
classifyTurnIntentWithRetry(turn-intent-classifier.ts)的契约是:两次尝试都抛异常 → 返回 { classified: null, expectedWrite: "unknown" }。也就是说 classified === null 表示「模型没答上来」,classified.intent === "unclear" 表示「用户确实说不清」,这是两件性质完全相反的事,路由把它们合并进了同一条分支。
后果:模型超时、供应商 5xx、网络抖动时,用户被回一句质疑他表达能力的话;这一轮正常落库,但没有任何证据写入尝试,他刚讲的那件事就此消失,只能自己再说一遍。
同一文件里采集题分支(isCollectFocusSchema 为真)已经处理对了:classified 为 null 时只记 collectIntent = "unclassified",不短路,继续往下进 Agent,由 expectedWrite = "unknown" 的 fail-open 守卫接管。这是 BUG-643 的成果。点选题分支没有跟上。
第三处同类:decision.nextAction === "ask_candidate_discriminator" 分支里的 classifyRectificationTurnIntent 包在 try { } catch { classified = null } 里,随后落 RECTIFICATION_USER_COPY.choicePrompt。危害小(那条路本来就要出卡),但归因同样错,一并处理。
关联记录:BUG-643(分类器 null 不得回退到关键词;collectIntent=unclassified 就是那一单加的)、BUG-635(证据轮只说「记下了」却没写入)、BUG-522(其记录末尾原文写着「意图分类器超时仍是既有缺口,本单不修」——这条缺口从 2026-09-04 挂到今天)。
2.2 BUG-723 · 引擎「忙」被当成引擎「坏」
engine-client.ts → readEngineJson:
if (!response.ok) {
... throw new RectificationEngineError("engine_request_failed", message);
}
所有非 2xx 一视同仁。而 Python 侧 jyotish_api_server.py 的 except HeavyComputeBusy 明确返回 429 + Retry-After 头 + ERR_COMPUTE_BUSY,语义是「现在满了,过几秒再来」,不是「坏了」。重算闸门 api_heavy_compute_gate.py 并发默认 2、饱和 fail-fast 不排队——这是设计,不要改它,要改的是调用侧。
失败之后:rectification-v9-tools.ts 的 autoRescoreAfterEvidenceChange 整个包在 try/catch 里,返回 { status: "failed", errorCode, openQuestion: null },不打任何日志。这个结果进 record-evidence-batch 的 projection 交给模型,但系统提示词明确要求「工具执行保持静默……不叙述工具或内部状态」,所以模型不会说。于是:证据入库成功 → 重算静默失败 → 模型照常写「记下了:2016 年 3 月……」→ 范围一动不动 → 也没有下一问被盖戳。
这正是历史上反复出现的「说记下了但范围没变」的一个来源,而且它只在两个人同时校正时出现,本机永远复现不了。
复发自 BUG-715(星盘页同一天刚修完同一个错误)。BUG-715 的防复发原文:「引擎调用不得用 catch {} 吞掉原因,失败必须留服务端日志且用户文案按原因分档。」那一单只改了 chart-view-engine.ts,校正链路的 engine-client.ts 是同样的写法,没有被扫到。
2.3 BUG-724 · 超时预算自相矛盾
| 位置 | 值 |
|---|---|
route.ts export const maxDuration |
240 s |
regenerate/route.ts export const maxDuration |
240 s |
agent-run.ts RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS |
210 s |
agent-run.ts MAX_ATTEMPTS |
2 |
单次尝试 210 s < 240 s,满足 BUG-388 防复发的字面要求(「attempt 超时必须小于路由 maxDuration」)。但两次加起来 420 s > 240 s,违反 BUG-059 的防复发:「两次模型尝试的总预算必须显式小于路由 maxDuration」。
只要发生一次重试(empty_stream、evidence_not_written 都会触发),这一轮必然撞上路由预算或边缘代理超时,用户等三五分钟拿到一个连错误码都没有的断流。
另有一处隐患:RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000 在仓库里定义了两遍——agent-run.ts:123(服务端真正用的)和 rectification-activity-labels.ts:40(前端「即将超时」提示用的)。改一个不改另一个,提示时机就会漂。
复发自 BUG-059;关联 BUG-388(把 105 s 提到 210 s、把 120 s 提到 240 s 的那一单,它的防复发只写了单次尝试,没写总预算,所以这次没拦住)。
3. 根因
三条共用一个根因:故障的机器语义在传递过程中被压平。分类器把「抛异常」和「答了 unclear」压成同一个 null;引擎客户端把 429/500/超时/坏 JSON 压成同一个 engine_request_failed;运行器把「单次尝试的预算」当成「整轮的预算」。压平之后,上层再想按原因分档就没有信息可用了。
4. 决策记录
产品 2026-09-15 授权本单,并明确以下口径:
- 分类器失败必须让用户知道「是我们这边的事」,并且要能把这句话捡回来。 允许的做法是提示重试;不允许用年份正则、关键词表或任何模式匹配去猜用户意图——这是 BUG-643 的防复发红线,本单不推翻。
- 429 不是错误,是排队信号。 允许在重算链路上按
Retry-After做有限次退避重试。退避重试仍失败时,必须让用户知道这次没有重算,不得让模型继续说「记下了」而范围不动。具体文案由执行方按frontend/docs/VOICE.md拟,评审在验收轮。 - 超时按「整轮一个预算」重构,不是简单调数字。 不接受把 attempt 砍到 110 s——那会把 BUG-388 重新打开(带引擎重算的轮次实测就要超过 105 s)。也不接受把
maxDuration提到 430 s——让用户等七分钟不是产品。 - 并发闸门(默认 2)、
_SWISSEPH_LOCK、fail-fast 不排队三项一律不动。 那是主机只有 2 vCPU 的保护,不是 bug。吞吐问题由TASK-rectification-engine-memoization-20260915解决。 - 不改计费口径。 现在这三条失败路径都发生在
billing.reserve()之前或走 release,用户不扣点;改完必须仍然不扣点。 - 意图分类继续用会话选定的模型,不引入便宜快模型。 产品 2026-09-15 明确决定:分类用贵的。因此模型目录不新增「工具模型」角色,
classifyRectificationTurnIntent/classifyTurnIntentWithRetry继续走resolveSessionLanguageModel的返回值。本单不得以「省钱 / 提速」为由改模型选择;后续轮次要重提必须先拿到产品新的授权。本单只改归因,不改用哪个模型。
5. 硬红线
- 不得引入任何关键词/正则/词表兜底去替代分类器(BUG-643 防复发)。
- 不得放宽
expectedWrite守卫、不得改evidence_not_written的重试语义、不得用确定性业务模板伪装成模型生成成功(BUG-059 防复发)。 - 引擎调用失败必须留服务端日志,只含路径、状态码或错误名、耗时;不得含出生资料、案例 ID、用户原文、模型原文、JWT(BUG-715 防复发 + AGENTS §8)。
- 超时改动后,「两次尝试总预算 < 路由
maxDuration」必须由一条测试断言钉死,而不是靠注释。 - 超时后若本轮已盖戳
open_question,仍不得把整轮打成空run_timeout(BUG-388 防复发,现状行为,保持)。 frontend/src/app/page.tsx一行不许动(1951/2000,AGENTS §6)。- 不得顺手升级依赖、不得顺手修不在本单里的 warning。
6. 任务分解
6.1 分类器区分「模型没答上来」与「用户说不清」
classifyTurnIntentWithRetry 的返回值加一个判别字段(例如 outcome: "classified" | "unclear" | "classifier_unavailable"),classifier_unavailable 专指两次尝试都抛异常。classified: null 仍然保留给调用方兼容,但路由不再据此分档。
route.ts 三处改为:
outcome === "unclear"→ 维持现状,回unclearFocusReply。outcome === "classifier_unavailable"→ 走一条新文案(大意:这边没接上,把刚才那句再发一次就行),并且这一轮不得被记成用户已经答过当前焦点;焦点保持 active,下一次重发能正常进入同一条路径。- 同一改动覆盖点选题分支、
ask_candidate_discriminator分支的try/catch。采集题分支已经正确,只需保证它的collectIntent语义不被本次重构改坏。
另加一条服务端日志(只含 case 前缀无关的机器码与耗时,不含原文),让这类失败在日志里可数。
- 验收:
rectification-turn-intent-classifier.test.ts新增断言——分类器抛异常两次时outcome === "classifier_unavailable";模型正常返回intent: "unclear"时outcome === "unclear";两者的路由回复文案不同。 - 验收:源码合同断言
route.ts不存在!classified || classified.intent === "unclear"这种合并判断。 - 验收:
classifier_unavailable路径不写任何证据、不推进焦点状态、不扣点。
6.2 引擎失败按原因分档
engine-client.ts 的 readEngineJson 改为按原因产出判别码:busy(429)/ http_error / timeout / bad_payload,并读取 Retry-After。每一种非 ok 打一条 console.warn(路径、状态码或错误名、耗时)。对齐 BUG-715 在 chart-view-engine.ts 里已经落地的那套形状,不要另发明一套。
scoreAndPersistCurrentEvidence / autoRescoreAfterEvidenceChange 链路上:busy 按 Retry-After(上限取一个显式常量,建议 ≤ 2 次、总退避 ≤ 6 s,写成具名常量并在测试里钉死)退避重试;仍失败时把失败原因显式带回 projection,并让本轮的主持人正文告诉用户这次没有重算、经历已经记下、稍后会再比一次。
- 验收:新增测试,桩出 429 +
Retry-After: 2、500、超时、坏 JSON 四种,断言四种分别产出不同判别码、各有可区分日志、只有 429 触发退避重试。 - 验收:断言退避总时长有上限,且上限是具名常量不是字面量散落。
- 验收:
busy重试成功后,rescore.status必须是completed,与从未失败过的那条路径逐字相同。 - 验收:
busy最终失败时,用户可见正文里必须出现「这次没有重新比较」这一语义(具体文案对照frontend/docs/VOICE.md),且不得出现「范围在收窄」这类进度句。
6.3 超时改成整轮一个预算
在 runV9AgentTurn 里引入一个整轮 deadline(建议 RECTIFICATION_RUN_BUDGET_MS,取值必须显式小于 maxDuration,例如 225 s),单次尝试仍保留 210 s 上限,但实际超时取 min(单次上限, 剩余预算)。重试条件从 attemptNumber < MAX_ATTEMPTS 改为 attemptNumber < MAX_ATTEMPTS && 剩余预算 ≥ 最小可用尝试时长(同样具名常量)。预算不够重试时,走现有的 hostFallbackUsed 优雅路径,而不是启动一次注定被砍断的尝试。
同时把 RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS 收敛成一处定义,rectification-activity-labels.ts 从那一处 import,消除两份 210_000 漂移的可能。
- 验收:新增断言
RECTIFICATION_RUN_BUDGET_MS < maxDuration,且maxDuration从路由模块读取而不是重写一遍字面量(agent与regenerate两条路由都要覆盖)。 - 验收:
rectification-v9-stream.test.ts新增用例——第一次尝试耗尽大部分预算后返回 retryable,运行器不得发起第二次尝试,必须走 host fallback 并给出可见正文。 - 验收:源码合同断言全仓只有一个
210_000的定义点。 - 验收:已盖戳
open_question的超时轮仍然落题干、不空失败(BUG-388 现状行为回归)。
6.4 三条 Bug 历史
同一变更内写进 docs/BUG_HISTORY.md,编号连续,每条都要有「复发自 / 关联记录」:
- BUG-722:关联 BUG-643、BUG-635、BUG-522(522 里写明的既有缺口本单关闭)。
- BUG-723:复发自 BUG-715,说明 715 的防复发为什么没覆盖到
engine-client.ts(那一单的范围写死在星盘页三个文件里)。防复发要升级成仓库级:任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码,429 必须单独成档。 - BUG-724:复发自 BUG-059,说明 BUG-388 的防复发只约束了单次尝试、没约束总预算,因此没拦住。
7. 让步顺序
- 6.1 必须做,它是唯一会丢用户数据的一条。
- 6.3 次之,改动最小、风险最低,而且它是重试链路的前提。
- 6.2 的「分档 + 日志」必须做;「按
Retry-After退避重试」可以砍到下一轮,但那样的话用户可见的「这次没有重算」提示不得砍——宁可不重试也不许静默。 - 6.4 不得砍。
8. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-failure-attribution-20260915 \
.worktrees/rectification-failure-attribution-20260915 origin/staging
cd .worktrees/rectification-failure-attribution-20260915/frontend
git status -sb | head -1
npm ci
验收命令:
./node_modules/.bin/tsc --noEmit
npm run lint
npx tsx --test tests/rectification-turn-intent-classifier.test.ts \
tests/rectification-v9-stream.test.ts \
tests/rectification-answer-choice.test.ts \
tests/rectification-spoken-collect.test.ts \
tests/rectification-unwritten-evidence.test.ts \
tests/rectification-v9-agent.test.ts \
tests/chart-view-engine.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
npm run build # `/` 仍须 ○ Static,首屏 gzip ±2%
9. BUG 编号起点
基线 6b3248bf 上最大号 BUG-720。本单预占 BUG-722 / 723 / 724(BUG-721 留给 engine-memoization 单)。开工时核对当时的实际最大号;同日四单并行,先落库者先占号,冲突时顺延并在进度记录里写明。
10. 不在本单范围
- 分类改用便宜快模型:产品 2026-09-15 已决定不改,分类继续用会话选定的贵模型,不另开单(见 §4.6)。
- 并发闸门、
_SWISSEPH_LOCK、加机器。 - 引擎本身的耗时(见
TASK-rectification-engine-memoization-20260915)。 - 请求内 Case 档案缓存(见
TASK-rectification-request-dossier-cache-20260915,串行在本单之后)。