# 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]`。