Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
11 KiB
11 KiB
TASK · 普通对话:发出即有反馈,正文按打字节奏放出(2026-09-28)
基线
origin/staging=56ca8b26(2026-09-28)。开工时git fetch origin --prune后以最新origin/staging为基线,写进 PROGRESS。- 分支
codex/consult-first-frame-and-pacing-20260928,工作树.worktrees/consult-first-frame-and-pacing-20260928。 - 与
TASK-cost-accounting-gaps-20260927无文件交集。改route.ts的轮次要串行:本单动frontend/src/app/api/consult/route.ts的分类与开流段,若同期有别的单改这个文件,本单在后。
事故实证(2026-09-28 产品 staging 真机,deepseek-v4-flash)
产品原话:「用户发完信息后没有立马就有思考中或者步骤栏出现,会卡一两秒之后再出;而且答案也是一下全出来,没有流式输出的动画。」
两个现象各有确定的代码来源,都不是网络或模型慢。
根因(按符号定位,基线 56ca8b26)
现象一:发出后一两秒什么都没有
- R1 客户端故意静默到分类结果回来。
frontend/src/components/chat-message-row.tsx第 57 行起:awaitingClassification = state === "thinking" && !text && !activity && !thinkingText && timeline.length === 0,命中即quiet:不渲染步骤栏、不渲染活动面板、头衔只写「Jyotisha」。这是 BUG-976 寒暄快路(539d4dae)加的,目的是「你好」的回复不要带步骤栏。副作用是每一轮在服务端第一个事件到达之前都是空白:consultation-run-timeline.tsx里本来有QUEUED_TIMELINE_ROW(「正在处理…」,BUG-475 为"首事件前无反馈"加的),被quiet挡住不显示。 - R2 服务端在开流之前做完分类。
frontend/src/app/api/consult/route.ts:请求进来先读会话(第 17 行附近)、解析模型、prepareConsultationRoute(资料 / 区间 / 时区,第 411 行)、reserve_consultation_usage预留扣点(RPC)、append_consultation_question(RPC),然后第 636 行classifyConsultationTurn——用会话模型做一次结构化分类(consultation-smalltalk.ts,3 秒 fail-open)。分类完才走到第 670 行(寒暄流)或第 1150 / 1252 / 1388 行streamAgentResponse建流。浏览器在这之前收不到任何字节。所以一两秒 = 几次数据库往返 + 一次模型分类。 - 两条叠加:客户端等首事件,服务端首事件要等分类。校正面在 BUG-1047 已按「先建流、首帧确定性进度句」修过(
agent-route-agent-turn.ts第 149 行new ReadableStream内做分类,turn.progress received),普通对话没有同步。
现象二:正文一下全出来
- R3 服务端按步截留。
frontend/src/lib/stream-agent-response.tsANSWER_RELEASE_CHARS = 160、readsAsAnswer():一步里的正文出现 Markdown 标题或满 160 字才放出,不到就扣到这一步结束(settleStep)再整段放出。这是 BUG-1053 取答边界(防止工具前的过程话进正文)。首轮开场 ≥ 160 字,所以前 160 字是一坨、之后才流;追问轮(本单之前的TASK-consult-answer-the-question后正文 ≤ 200 字、无标题)多数不满 160 字,整段在步结束时才放出,而步结束几乎就是流结束。 - R4 客户端收尾一帧吐完。
frontend/src/lib/stream-frame-buffer.ts:正常时每帧放max(2, backlog/12)字,一坨 160 字约 12 帧(0.2 秒)吐完;settle()把releasedAnswer = targetAnswer一帧全放。use-consultation-run.ts第 952 行读完流立刻frames.settle()。追问轮的正文在流结束前一刻才到,于是 R3 + R4 = 整段一帧出现,没有任何流动。 - 首轮之所以也感觉「一下出来」:160 字一坨 + 之后模型快(deepseek-flash)每次到达几十字、12 帧清完,看起来是一块一块跳,不像打字。
不是根因的
- 网络与模型:分类耗时有埋点(
classification.<outcome>的durationMs),执行方开工时从 staging 观测日志取一周分位数写进 PROGRESS;即便分类只要 300 ms,R1 也会让这 300 ms 是空白。 createVisibleTextTransformer(stream-text-response.ts)只处理隐藏块,不按句截留。
决策记录(产品 2026-09-28 已确认 D1–D3,Claude 直接执行)
- D1 发出那一帧就有步骤栏:去掉
awaitingClassification的静默,每轮从发送起显示时间线,首行是确定性文案「收到,正在看你的问题…」(改QUEUED_TIMELINE_ROW的 label;文案对照 VOICE)。寒暄轮:服务端一旦判定寒暄,客户端收到responseKind: "smalltalk"即按现有逻辑转quiet,步骤栏消失、只剩一句回复。代价是寒暄前会闪约一秒「收到…」,Claude 认为可接受;若产品不接受,退而求其次是 D1':首行不进时间线,只在头衔旁放一个InlineSpinner(DESIGN §9 已有的唯一 live 标记),寒暄回来一样消失。 - D2 服务端先建流(同 BUG-1047 D3 的做法):
route.ts在鉴权与请求体校验之后就返回流;会话读取、准备、预留扣点、分类、以及最终的streamAgentResponse/ 寒暄流全部在start()里跑,首帧推一条确定性activity事件(label 同 D1 文案)。分类为寒暄时,在同一条流里写寒暄回复与免费结算(现在streamSmalltalkResponse自建 Response,需改成可写入外层 controller 的形式)。x-jyotish-response-kind响应头在先建流后无法再设,客户端改为只认run.completed事件里的responseKind(use-consultation-run.ts第 838 / 924 行已有事件路径,删头部路径)。错误路径(预留失败、会话不存在等原本返回 4xx/5xx JSON 的分支)改为流内run.failed事件,文案不变;客户端对这些 code 的处理保持一致,写进测试。 - D3 客户端按打字节奏放:
stream-frame-buffer.ts加每帧上限STREAM_RELEASE_MAX_CHARS = 4(60 fps ≈ 240 字/秒,高于模型平均产出,不会越积越多);只有积压超过STREAM_RELEASE_CATCHUP_CHARS = 600时才回到现在的backlog/12追赶。settle()不再一帧全放:改为「按节奏放完剩余、但总时长不超过 1.5 秒」(剩余 / 1.5 秒 与 4 字/帧 取大),放完再emit(true);结算、扣点、消息入库不等这段动画(数据层照旧立即结算,只是显示层慢慢写完)。停止 / 断线 / 失败仍立即全放。 - D4 服务端 160 字阈值不动(BUG-1053 的过程话防线)。追问轮正文短,靠 D3 的节奏放出即可有流动感;若真机后仍觉得首字太慢,再单独立单讨论按「首个句号」放出。
- D5 排盘工具调用、扣点、结算、Bug-976 的分类模型与 3 秒 fail-open、
stepScopedAnswer的丢弃规则都不改。
硬红线
- 不得用正则或关键词判寒暄(BUG-976 防复发);分类仍是模型。
- 不新增第二种 live 标记、不加 spinner / 骨架(DESIGN §9);首行文案走
InlineSpinner+ shimmer 既有样式。 page.tsx不增长;stream-agent-response.ts的取答规则不动(D4)。- 前端红线全套;改既有断言三栏;测试总数不低于开工实测。
chat-stream-settle-contract.test.ts、stream-frame-buffer相关测试、consultation-smalltalk.test.ts、consult-single-pass-answer-20260927.test.ts大概率命中。 - 先建流后,任何原来靠 HTTP 状态码表达的失败都必须在流内有等价事件并被客户端同样处理;不得让预留扣点失败变成静默空回复。用真实 PostgreSQL 的既有寒暄结算测试(BUG-976 那组)必须仍过。
- 禁止
git stash;只git add <具体路径>;不推送,回报后由 Claude 验收再推 staging。
任务分解
T1 客户端首帧(R1、D1)
chat-message-row.tsx:删awaitingClassification对时间线的屏蔽;quiet只由responseKind === "smalltalk"决定。consultation-run-timeline.tsxQUEUED_TIMELINE_ROW.label改「收到,正在看你的问题…」。frontend/docs/VOICE.md、frontend/DESIGN.md§9 同步。- 验收:组件测试:loading 且无事件时渲染该行;收到 smalltalk 标记后行消失;
chat-stream-settle-contract.test.ts三栏。
T2 服务端先建流(R2、D2)
route.ts:鉴权 + 请求体校验后即new Response(stream);其余搬进start();首帧activity事件;寒暄在流内完成(改stream-smalltalk-response.ts为写 controller 的函数);错误分支改流内run.failed。use-consultation-run.ts:删响应头判寒暄,只认事件;失败事件 code → 现有文案映射不变。- 观测:
logAgentObservability加first_byte阶段(从请求进入到首帧写出的毫秒数),不含原文。 - 验收:假模型 route 测试:① 首帧在分类完成前写出(用挂起的分类 Promise 证明首帧不等它);② 寒暄轮流内完成 + 免费结算调用一次;③ 预留失败 →
run.failed事件且客户端映射文案与原 JSON 一致;④ 咨询轮事件序列与现在相同(首帧多一条 activity)。真实 PG 寒暄结算测试仍过。
T3 客户端节奏(R4、D3)
stream-frame-buffer.ts:每帧上限、追赶阈值、settle()限时放完;停止 / 失败 / 断线走立即全放的旧路径(新参数immediate)。- 验收:纯函数测试:160 字一坨在 ≥ 40 帧内放完;600 字以上积压回到追赶;settle 剩余 300 字在 ≤ 1.5 秒内放完且最后一帧
settled: true;immediate一帧全放。use-consultation-run的结算测试确认消息入库时间不受动画影响。
T4 记录
docs/BUG_HISTORY.md从 BUG-1074 起(开工核对最大号):BUG-1074 发出后到分类完成前整行空白(R1+R2,关联 BUG-475、BUG-976、BUG-1047);BUG-1075 短正文在流结束时一帧全出(R3+R4,关联 BUG-1053)。状态fixed_pending_acceptance。CHANGELOG.md一条;PROGRESS(含分类耗时分位数、首帧耗时前后对比);docs/testing/consult-first-frame-and-pacing-20260928.md真机清单:① 发送瞬间步骤栏出现「收到,正在看你的问题…」;② 「你好」一秒内换成一句寒暄、无步骤栏;③ 首轮正文从第一坨开始按打字速度出现,不跳块;④ 追问轮 100~200 字的回答能看到逐字写出,不是一帧出现;⑤ 点停止立即定格。
让步顺序
- D2 若
streamSmalltalkResponse改造牵动 BUG-976 的真实 PG 结算测试超出一天工作量,先做 D1 + D3(客户端两项已能消掉大部分体感),D2 单独开单,PROGRESS 写明。 - D3 若
settle()限时放完与useConversationScrollAnchor/JumpToLatestButton的结算滚动冲突(正文还在长、锚点已结算),把动画期间视为仍在流式(settled延到放完那一帧),不得另写第二套滚动跟随。 - 任何一条做不到写
BLOCKED.md。
开工前置命令
cd /workspace/Jyotisha && git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/consult-first-frame-and-pacing-20260928 .worktrees/consult-first-frame-and-pacing-20260928 origin/staging
cd .worktrees/consult-first-frame-and-pacing-20260928/frontend && export PATH=/exec-daemon:$PATH && node -v # v22
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5
grep -rhoE "BUG-1[0-9]{3}" ../docs/BUG_HISTORY.md ../docs/tasks/*.md | sort -u | tail -1
BUG 编号起点
BUG-1074。