Files
Jyotisha/docs/tasks/PROGRESS-consult-answer-truncation-20260926.md
T
Jesse_ChenandClaude Opus 5.5 e8195783f8
Independent Staging Quality Gate / validate (push) Successful in 12m0s
Independent Staging Quality Gate / publish (push) Successful in 3m39s
docs(tasks): consult truncation acceptance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 22:28:34 +08:00

97 lines
9.1 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.
# PROGRESS · 普通咨询回答被掐断仍扣点(BUG-1051)· 2026-09-26
- 执行方式:直接执行(产品负责人授权子代理执行;Claude 事后独立验收)
- 基线:`origin/staging` `e53052a2` 开工;提交前变基到 `16f4600d`(BUG-1049/1050 校正开场单,只与本单的文档文件重叠,已并存)
- 分支:`codex/consult-answer-truncation-20260926`(本地提交,未推送)
- BUG 编号:1051(1049/1050 由并行的校正开场单占用)
- 复发自:BUG-305;相关 BUG-944 / BUG-612 / BUG-280
## 结论
| 项 | 结果 |
| --- | --- |
| 根因 | 确认,三层:① 工具循环与写回答共用一个 110 秒 `AbortSignal`;② Mastra 1.50.1 超时不抛错,发 `abort` 块 + `finish(tripwire)` 后正常关流,结算段只认抛错和 `length`;③ `content-filter` / `tool-calls` / `other` / `unknown` / `error` 等非 `stop` 结束也被当完成 |
| D1 写回答自有时钟 | 完成:`CONSULTATION_COMPOSE_TIMEOUT_MS = 70_000`,首用才起算,compose / 续写 / Pass 4 重写 / 回答重试共用 |
| D2 非 `stop` 不结算 | 完成:`run.failed` / `answer_truncated`,不调 `onComplete`、账务走 `cancel`,记 `compose-abort` / `tool-abort` 与 `answer-truncated` 步;被掐时不冲出 Pass 4 半句;`length` 续写不变 |
| D3 观测 | 完成:`composeFinishReason`、`composeAborted`、`answerVisibleChars`,只有枚举 / 布尔 / 计数;公开回执不变 |
| 超时层逐层核对 | 无阻塞项,见下表;未改 `deploy/` 与 `.gitea/workflows` |
## 为什么是 70 秒
| 依据 | 数字 |
| --- | --- |
| staging 同供应商默认模型的实测吞吐(`PROGRESS-report-writer-failure-20260902.md` Run A telemetry,端到端含首字延迟) | 560 tok / 5.7 s、1240 / 13.6 s、816 / 8.1 s、759 / 7.4 s → 约 90–100 tok/s |
| 70 秒可写 | 按实测 ≈ 6,300–7,000 可见 token;按一半吞吐仍 ≈ 3,000 token |
| BUG-305 保留的可见预算 | 8,192 token(现 compose 上限 16,384,thinking 关闭) |
| 一次四标题回答的常见长度 | 约 1,500–2,500 token,70 秒有 3–4 倍余量 |
| 最坏总等待 | 工具循环 110 s + 写回答 70 s = 180 s(产品接受「约 3 分钟」) |
写不完 70 秒的回答不会再被当成完成:按截断提示、不扣点。实际 compose 耗时分布需部署后看 `composeFinishReason` / `run.total` 复核;若 `tripwire` 频繁出现,再按数据调整这个值。
## 会不会有别的层先掐断 3 分钟的流
| 层 | 核对结果 | 处理 |
| --- | --- | --- |
| 路由 `maxDuration` | Next 16 文档写明它只给部署平台读(`node_modules/next/dist/docs/.../route-segment-config/maxDuration.md`);生产是自托管 `node server.js`(`deploy/railway-web.Dockerfile`),不执行 | 120 → 240,与校正路由同值,作上限说明;源码合同锁 110 + 70 < 240 |
| Node HTTP 服务器 | `server.timeout` 默认 0;`requestTimeout`(300 s)只管接收请求体;Next 只设 `keepAliveTimeout`(连接空闲,不影响进行中的响应) | 无需改 |
| Caddy(`deploy/Caddyfile.*`) | 只有 `reverse_proxy web:3000`,未配任何 timeout;Caddy 默认无响应读 / 写超时。先例:校正路由 `maxDuration = 240`、整轮预算 210 s 经同一条链路运行 | 无需改,未动 `deploy/` |
| CDN | AGENTS §1:公网边缘只有 Caddy,无 CDN | — |
| 客户端 | `use-consultation-run.ts` 的 `AbortController` 只在用户取消时触发,无超时;恢复轮询 `consultation-recovery-poll.ts` 每 1.75 s 一次、无上限;`/api/consult/status` 无时间阈值 | 无需改 |
| 账务预扣 | `cancel_consultation_credit` / 完成 RPC 没有分钟级过期;过期清理是 1 天 | 无需改 |
| NDJSON 心跳 | 写回答阶段一直有 `answer.delta`;长时间无字节只可能在工具计算阶段,本单未改变该阶段时长 | 无需改 |
| 断线 | 三条路径都是 `continueAfterDisconnect: true`,客户端断开后服务端继续写完并结算 | 不变 |
## 各种结束方式的行为
| 写回答的最后一个流 | 有正文 | 无正文 |
| --- | --- | --- |
| `stop` | `run.completed`,扣点 | 既有 `answer-retry` → 仍空则 `empty_answer` |
| `length` | 续写一次;续写 `stop` → 完成;续写仍 `length`、被掐或其他 → `answer_truncated` | 同左 |
| Mastra `abort` + `finish(tripwire)` | `answer_truncated`,记 `compose-abort`,半句不外发 | `empty_answer` 路径(不扣点) |
| `content-filter` / `tool-calls` / `other` / `unknown` / `error` | `answer_truncated` | `empty_answer` 路径 |
| 供应商没发 finish(Mastra 补 reason 为空 → `unknown`) | `answer_truncated` | `empty_answer` 路径 |
| 流里完全没有 finish 块(未在 Mastra 观测到,防传输提前关闭) | `answer_truncated`(`composeFinishReason = missing`) | `empty_answer` 路径 |
工具循环(本命路径里被「drain」、不进正文的那一段)自己的结束原因不参与结算判定;它被掐时记 `tool-abort`,写回答照常在自己的时钟上进行。
## 改动文件
| 文件 | 改动 |
| --- | --- |
| `frontend/src/mastra/consultation-tools.ts` | `CONSULTATION_COMPOSE_TIMEOUT_MS`、`createConsultationAnswerClock()`;运行态加三项观测字段并进 `consultationModelStepTelemetry` |
| `frontend/src/app/api/consult/route.ts` | `answerPhaseSignal`;compose、3 处续写、3 处回答重试改用;`maxDuration` 240 |
| `frontend/src/lib/stream-agent-response.ts` | 每次 attempt 的结束记录、`abort` 块留痕、结算判定、被掐不冲半句、观测写回 |
| `frontend/src/lib/agent-observability.ts` | 严格 schema 加三项字段 |
| `frontend/tests/consult-answer-truncation-20260926.test.ts` | 新增 15 条,真实 Mastra `Agent` + 假模型 |
| `frontend/tests/consultation-agentic-runtime.test.ts` | BUG-305 旧用例加三栏说明 + abort 步断言;11 条 fixture 补 `finish(stop)` |
## 既有测试改动(三栏)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 「a timeout after partial visible text…」(BUG-305) | 手工 `throw DOMException("TimeoutError")` 代表超时半截,是唯一超时回归 | 保留,只代表「真的抛出」的 catch 分支,补断言 abort 运行步;真实超时形状由新文件覆盖 | 手造形状不是 Mastra 行为,旧测试一直绿而线上照样扣点(§7.4) |
| 11 条既有 fixture(contract/degraded/window precompute/answer-retry/reasoning 等) | 生成器只 yield `text-delta` 就结束 | 末尾补 `yield STOP_FINISH`(Mastra 正常结束必有的 `finish(stop)`) | 无 finish 的流现在按截断处理;断言一条未改 |
## 验证(Node 22.14)
| 项 | 结果 |
| --- | --- |
| 新回归文件 | 15 / 15 通过,连跑 3 次稳定;修复前代码上 11 / 15 失败 |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(128 warning;本单涉及文件里的 2 条 warning 均为基线已有) |
| `npm test` 全量 | 变基前(`e53052a2` 上)4060 条,pass 4008 / fail 24 / skip 28;变基后(`16f4600d` 上)4069 条,pass 4017 / fail 24 / skip 28。对照基线 `cs-test.log` 4045 条、fail 25 / skip 28:失败名单 0 新增;基线的「Gitea quality gate validates before publishing an immutable ACR manifest」通过(基线日志早于 `e53052a2` 恢复 upload-artifact action);其余 24 条逐条一致(均为 Docker / DB)。测试名 0 消失;新增 24 条 = 本单 15 + 校正开场单 9 |
| Python 门禁集 | 948 passed / 1 skipped(与基线一致) |
| `npm run build -- --webpack` | `/` 仍 `○ Static`;rootMainFiles gzip 130,933 B(基线 130,933,0.000%) |
| stray `frontend/frontend/` | 已删除 |
## 让步与未做
- 活动区标题「已完成 N 步」按时间线行数计,截断回复上仍这样写;未完成的信号是输入框上方的提示。改标题属于 UI 改动(要同步 DESIGN.md),不在本单范围,建议另开小单。
- 真实供应商下的 compose 耗时分布、`tripwire` 占比需部署后看 `[agent-observability]` 复核(环境缺口:本地无模型凭据)。
- 真机走查见 `docs/testing/consult-answer-truncation-20260926.md`(环境缺口:无登录态)。
- 无出生分钟路径里,若模型正文被 Pass 4 全部拦下、服务端补拒答句,而写正文的流恰好被掐,本单会按截断处理(显示拒答句 + 未完成提示、不扣点)。概率很低,保持诚实优先。
## 验收(Claude,2026-09-26)
独立复跑(Node 22.14,基于 `16f4600d`):tsc 0;lint 0 error;`npm test` 4069 / 24 fail / 28 skip,失败名单与 opening-plain 分支逐条一致(全为 Docker/DB),无消失测试名;Python 门禁集退出 0;`/` ○ Static,rootMainFiles gzip 130933 B(±0%);未碰 `.gitea/`、`vendor/`、`deploy/`,无删除。代码抽查:`streamAgentResponse` 只有咨询路由调用,「非 stop 即截断」不影响其他功能;判定取写答案那一段而非排干的工具循环。遗留(认可,另开小单):截断时步骤栏标题仍写「已完成 N 步」,以输入框上方的未完成提示为准。真实组答耗时分布与 tripwire 频率待部署后看 `[agent-observability]`。