Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
13 KiB
PROGRESS · 普通咨询回答改由看过星盘的那一步写(BUG-1053)· 2026-09-27
- 执行方式:直接执行(产品负责人授权子代理执行;Claude 事后独立验收)
- 基线:
origin/staging5f2e007f开工;提交前变基到03c7c0a9(BUG-1052 首页落点单与数据卡调研任务书,只与本单的CHANGELOG.md/docs/BUG_HISTORY.md/docs/tasks/README.md/frontend/DESIGN.md重叠,已并存) - 分支:
codex/consult-single-pass-answer-20260927(本地提交,未推送) - BUG 编号:1053(开工与提交时核对,1052 由并行的首页落点单占用)
- 引入:
04463e9a(2026-08-23);相关 BUG-612 / BUG-942–944 / BUG-1051
结论
| 项 | 结果 |
|---|---|
| 缺陷复核 | 确认。用同一套真实 getJyotishAgent + 记录提示词的假模型跑修复前代码:3 次模型调用,只有第 2 次(主循环工具结果之后)的提示里有工具结果和 golden 盘面数值;用户看到的正文是第 3 次(compose)写的,它的提示里两者都没有 |
| D1 删掉第三段 | 完成:删 composeAnswer / interpretFindings / publishFindings / composeOnce / drainSpoken / ThinkFinding、consultationComposePrompt / consultationSectionPrompt、AGENT_SLICE_MAX_STEPS;主循环最后一步的正文就是回答 |
| 取答边界 | stepScopedAnswer:按步扣住正文,这一步调用了工具就整段丢;见下文 |
| 写作要求 | 搬进主循环看到的用户轮:natalAnswerShapeInstruction() / dailyAnswerShapeInstruction() |
| 续写有据 | continueAfterLength(output, evidence) + consultationContinueMessages(),三条路线共用一个构造函数 |
| 时间预算 | createConsultationRunClock():工具 110 秒不变,循环拿到计算结果后交给 70 秒答案钟;最坏 180 秒 |
| BUG-1051 | 保持:结算只认写回答那一步的 stop;length 续写;其余截断不扣点 |
| D2 观测 | 名字不变、含义改为写回答那一步(见下文) |
| D3 | 未碰:工具结果瘦身、家庭拆分、审计表、Python 引擎、Skill 路由 |
回答怎么和工具前后的过程说明隔开
Mastra 的 fullStream 每个模型步都有 step-start → text-delta… → tool-call(可选)→ tool-result → step-finish(reason)。consumeAttempt 在 stepScopedAnswer 下:
| 情况 | 处理 |
|---|---|
| 计算结果到手之前的正文 | 与原来一样:不进回答,只存进 uncontractedText 作降级材料(BUG-961) |
| 计算结果到手之后,这一步的正文 | 先扣住;出现 Markdown 标题(#–###)或满 ANSWER_RELEASE_CHARS = 160 字才放出,之后本步照常流式(Pass 4 按句放行) |
这一步出现 tool-call |
扣住的正文整段丢弃,本步后续正文也不收 |
step-finish 时还扣着 |
结束原因不是 tool-calls 就放出(短回答),是就丢 |
| 残余风险 | 一步先写了 ≥160 字或一个标题、再调用工具:那段已放出、无法撤回。系统提示要求不重读已交付的方法段,实测形状里过程说明都是一句话,未见此情况;部署后留意 |
160 字的依据:工具前的过程说明通常一句(「我再读一下参考」);本命开场规定 3–6 句、≤400 字,160 字大约是开场的两三句,第一段字晚一两秒出现。
写作要求现在在哪里
| 位置 | 内容 |
|---|---|
| 系统提示(未改) | productConversationVoice、natalSpokenReportContract、VOICE §7 开场形态、consultationSpokenHeadingRule("natal") 四个 H2 |
用户轮 natalInstruction(改) |
原句「事业/财富/婚恋/家庭先给口语开场,再按四个标题写结论…」换成 natalAnswerShapeInstruction():拿到本轮计算结果后直接写回答、只用结果里的盘面事实、工具前后不写过程说明;开场无标题;一次写完四个 ## 标题;不写统一参数 / 技法审计表 / 思考过程清单 / 内部 JSON。仍以「如需新的个人星盘结论,必须调用服务器绑定的排盘工具」开头,不与工具调用要求冲突(旧 compose 提示的「不要再调用排盘工具」没有搬过来) |
| 「深入看今日」入口 | dailyAnswerShapeInstruction():三节写法原文保留,加同一句取证要求。旧 compose 提示强制本命四标题,与入口三节矛盾,这次一并消除 |
时间预算与每一层超时
| 层 | 修复前 | 修复后 |
|---|---|---|
| 工具 / 申报时段预计算 | agentAbortSignal 110 秒 |
不变:runClock.toolSignal 110 秒;每领域 31 秒、领域墙钟 65 秒不变 |
| 主循环模型流 | 110 秒(与工具共用) | runClock.loopSignal:计算结果到手前跟随工具阶段 110 秒;到手后(onAnswerPhase)只受 70 秒答案钟约束 |
| 写回答 | compose 另起 70 秒答案钟(第一次写回答流打开时起算) | 同一只 70 秒答案钟,从计算结果到手那一刻起算,覆盖主循环剩余步骤 |
| 续写 / 回答重试 | 共用答案钟 | 不变 |
| 竞态 | — | 计算刚在 110 秒前完成、消费端还没读到 tool-result 时,answerReady()(state.consultationToolCompleted)让循环交接而不是被掐 |
| 最坏总等待 | 110 + 70 = 180 秒 | 仍是 180 秒:计算必须在 110 秒内完成,答案钟最晚 110 秒起算 |
路由 maxDuration |
240 | 240 不变(源码合同锁 110 + 70 < 240 且 ≤ 180) |
| Node / Caddy / 客户端 / 账务 / 断线 | BUG-1051 已逐层核对(PROGRESS-consult-answer-truncation-20260926.md) |
本单没有拉长最坏时长,结论不变;未改 deploy/、.gitea/workflows |
为什么选「计算结果到手时起算的答案钟」而不是「整条主循环 180 秒」:两者都保证写回答至少 70 秒,但整条 180 秒时,主循环若到 180 秒才停在 length,续写再拿 70 秒会到 250 秒,超过 maxDuration;答案钟方案让写回答、续写、重试共享同一个 70 秒,总时长封顶 180 秒。
写回答那一步开着 thinking(它是主循环的一步),思考 token 也从这 70 秒里出;修复前主循环工具结果后那一步同样在 110 秒里思考并写完一遍(然后被丢弃),再由 compose 重写。现在少一次整篇写作,正常情况应更快。
申报时段路线:预计算完成即合同就绪,写回答从流开始就走答案钟(修复前是 110 秒减去预计算的剩余时间)。无出生分钟路线:没有工具、不交接,主流仍 110 秒,续写 / 重试 70 秒,行为不变。
各种结束方式的行为
结束原因取「最后一个写出正文的步」的 step-finish 原因;没有分步信息时取整条流的 finish。原因:Mastra 循环在一步以 other / unknown / 无工具调用的 tool-calls 结束后会再跑一步,如果那一步什么也没写、以 stop 结束,整条流的 finish 就是 stop,半截回答会被当成完成(测试里实测到)。
| 写回答的那一步 | 有正文 | 无正文 |
|---|---|---|
stop |
run.completed,扣点一次 |
answer-retry(保留工具、命中同请求缓存,不重复宣布计算)→ 仍空 empty_answer |
length |
续写一次,带计算结果;续写 stop → 完成;续写仍 length / 被掐 / 其他 → answer_truncated |
同左 |
答案钟掐断(Mastra abort + finish(tripwire)) |
answer_truncated,记 compose-abort,半句不外发 |
不扣点 |
content-filter / tool-calls / other / unknown / error |
answer_truncated,不扣点 |
empty_answer 路径 |
| Pass 4 把整篇都拒了 | — | answer-retry 带 PASS4_RETRY_HINT |
| Pass 4 拒了部分句子 | 就地丢弃,不重写(BUG-950) | — |
计算结果到手之前被掐记 tool-abort,之后记 compose-abort(名字沿用 BUG-1051,现指写回答那一步)。
D2 观测字段
| 字段 | 修复前含义 | 修复后含义 |
|---|---|---|
composeFinishReason |
compose 流的结束原因 | 写回答那一步的结束原因(续写 / 重试时取最后一次写回答的流) |
composeAborted |
compose 流是否被掐 | 写回答那一步是否被掐 |
answerVisibleChars |
回答字数 | 不变 |
modelFinishReason(只进日志) |
最后一个流的 finish |
不变(整条流的 finish,可能与上面不同) |
名字保留是为了和 BUG-1051 刚部署的埋点连续;仍只有枚举 / 布尔 / 计数,公开回执不带。
删掉了什么
| 删除 | 说明 |
|---|---|
composeAnswer(路由 + 流选项) |
看不到计算结果的第二个流 |
interpretFindings / publishFindings / ThinkFinding |
只回计划 id、文本恒空;think.step 只是把行从 running 翻到 done。计划行现在由第一条 answer.delta 或 run.* 收口;事件 schema 与客户端 reducer 不动(兼容) |
composeOnce 与 phase.started/completed(interpret / compose) |
同上 |
drainSpoken |
丢弃主循环正文的开关 |
consultationComposePrompt、consultationSectionPrompt、ConsultationSectionPromptReason |
前者被写作要求取代;后者自 BUG-944 起已是死代码 |
AGENT_SLICE_MAX_STEPS |
只有 compose 用 |
createConsultationAnswerClock、CONSULTATION_COMPOSE_TIMEOUT_MS |
并进 createConsultationRunClock,常量改名 CONSULTATION_ANSWER_TIMEOUT_MS(值 70 秒不变) |
未删:think-step-gate.ts(public-thinking.test.ts 与 rectification-step-answer.test.ts 仍在用);consultationSliceGenerationSettings 与 sliceAddedVisibleText(开工前就已无调用,不在本单范围)。
测试
| 项 | 结果 |
|---|---|
新回归 consult-single-pass-answer-20260927.test.ts |
15 条全绿:真实 getJyotishAgent(真实 skill 绑定、真实计算工具、真实 prepareStep)+ 记录提示词的假模型,计算数据来自 fixtures/consultation-workflow-report-blocked-repairs-golden.json(引擎真实输出,虚构身份) |
| 修复前对照 | 同一套真实 Agent 跑修复前的 stream-agent-response + compose 接线:3 次调用,工具结果与 golden 月亮度数只出现在第 2 次提示里,用户正文来自第 3 次 |
| 改写的既有用例 | 全部附「原值 / 新值 / 原因」:consultation-agentic-runtime 6 条(其中 5 条改名,旧名写在原值栏)、consult-answer-truncation-20260926 全文件(名字不变,文件头统一说明 + 3 条单独说明)、consultation-thinking-plan 2 条(名字保留)、consultation-workflow-contract 3 处、consultation-stream-recovery 1 处、application-billing-contract 1 处 |
tsc --noEmit |
0 错 |
npm run lint |
0 error(127 warning,均为既有;本单改动文件里的 2 条 warning 开工前就有) |
全量 npm test(Node 22.14,变基前) |
4084 条,fail 24 / skip 28;失败名单与基线 ct-test.log(4069 / 24 / 28)逐条一致,0 新增;消失的 5 个名字均为上面改名的用例(原值栏记旧名),新增 20 个名字 |
全量 npm test(变基到 03c7c0a9 后) |
4093 条(多出的 9 条是 BUG-1052 单自带的),fail 24 / skip 28;失败名单与基线逐条一致,0 新增 |
| Python 门禁集 | 948 passed / 1 skipped(与基线一致;本单未改 Python,也没有 Python 合同读这几份前端文件) |
npm run build -- --webpack |
通过,/ 为 ○ Static |
| 首屏 gzip(rootMainFiles) | 131,253 B,基线 130,933 B,+0.24%(±2% 内) |
| 杂项 | 已删构建留下的 frontend/frontend/ |
没做 / 留给真机
- 真实供应商下:写回答那一步的耗时、
composeFinishReason分布、引用的上升 / 月亮 / 宫位是否与星盘页一致——无模型凭据,按docs/testing/consult-single-pass-answer-20260927.md部署后走。 - 已知取舍:第一段字要等标题或 160 字;「一步先写长段再调工具」时那段会留在正文里(见上文残余风险)。
- D3 范围外(另行决策):约 4 万 token 工具结果瘦身与按领域选技法、家庭拆父母 / 子女、43 行审计表。
验收(Claude,2026-09-27)
独立复跑(Node 22.14,基于 03c7c0a9):tsc 0;lint 0 error;npm test 4093 / 24 fail / 28 skip,失败名单与 home-landing 分支逐条一致;消失的 5 个测试名即 compose 时代的 5 条改名(原名在原值注释里);Python 门禁集退出 0;/、/chart、/ephemeris、/people ○ Static;rootMainFiles gzip(level 9)130933 B,±0%。未碰 .gitea/、vendor/、deploy/,无删除文件。代码抽查:答案写作指令移入本命用户轮(natalAnswerShapeInstruction),「深入看今日」三节不再被强加四标题;按步截留(有标题或满 160 字才放出,调工具的步整段丢弃);续写带上计算结果。部署后要看:真机引用的上升/月亮/宫位与星盘页是否一致;composeFinishReason 分布(思考 token 计入 70 秒答题预算,可能多出不扣点的截断)。