Files
Jyotisha/docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md
T

11 KiB
Raw Blame History

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.ts ANSWER_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 的丢弃规则都不改。

硬红线

  1. 不得用正则或关键词判寒暄(BUG-976 防复发);分类仍是模型。
  2. 不新增第二种 live 标记、不加 spinner / 骨架(DESIGN §9);首行文案走 InlineSpinner + shimmer 既有样式。
  3. page.tsx 不增长;stream-agent-response.ts 的取答规则不动(D4)。
  4. 前端红线全套;改既有断言三栏;测试总数不低于开工实测。chat-stream-settle-contract.test.ts、stream-frame-buffer 相关测试、consultation-smalltalk.test.ts、consult-single-pass-answer-20260927.test.ts 大概率命中。
  5. 先建流后,任何原来靠 HTTP 状态码表达的失败都必须在流内有等价事件并被客户端同样处理;不得让预留扣点失败变成静默空回复。用真实 PostgreSQL 的既有寒暄结算测试(BUG-976 那组)必须仍过。
  6. 禁止 git stash;只 git add <具体路径>;不推送,回报后由 Claude 验收再推 staging。

任务分解

T1 客户端首帧(R1、D1)

  • chat-message-row.tsx:删 awaitingClassification 对时间线的屏蔽;quiet 只由 responseKind === "smalltalk" 决定。consultation-run-timeline.tsx QUEUED_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 字的回答能看到逐字写出,不是一帧出现;⑤ 点停止立即定格。

让步顺序

  1. D2 若 streamSmalltalkResponse 改造牵动 BUG-976 的真实 PG 结算测试超出一天工作量,先做 D1 + D3(客户端两项已能消掉大部分体感),D2 单独开单,PROGRESS 写明。
  2. D3 若 settle() 限时放完与 useConversationScrollAnchor / JumpToLatestButton 的结算滚动冲突(正文还在长、锚点已结算),把动画期间视为仍在流式(settled 延到放完那一帧),不得另写第二套滚动跟随。
  3. 任何一条做不到写 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。