answer-streaming.md 4.8 KB

正文真实增量流式输出(F073)— 实施计划

目标

按契约 ../../reference/token-streaming.md(F073), 把正文展示从「done 时整段到达 → 打字机逐字播放」改为真实增量即时渲染: 后端 answer_delta 一到就上屏,用户等待 ≈ 模型生成速度本身(不再额外加打字机时长)。

后端已实测部署(2026-09-20 两份真实载荷):流目标均为 summary;一轮 delta 235~746 片、 平均 1.7 字/片;answer/summary 完整事件与 result.response同一份正文的兼容副本

用户决策(2026-09-20)

  1. 流式正文立即渲染。打字机保留:①旧后端(无流式事件)②mock 测试按钮 (不经过协调器、依赖「Sending 态追加仍打字」)③历史消息本就立即渲染。
  2. 用户主动停止 → 保留已流出文字;后端 error / 断流 / 无 done 的 EOF → 撤草稿
  3. 思考中卡片文字一到就退(沿用现有机制,不改)。

两个关键设计决策

① 新增「增量 markdown 规整器」 normalizeChatMarkdown插入空行,而它的每处决策只依赖相邻两行(行结束才稳定)。 若不做增量规整,answer_end 的「整块替换」在多段落回答上必然与已流文本分歧 → 触发整段重建 → <scope> 标记被重解析 → 思考中卡片每轮闪现 + 退场动画重播。 规整器:已结束行立即按完整决策发出;当前未结束行扣住,直到能判定「已是列表项前缀」 (LIST_ITEM_RE$ 锚,命中即稳定)或「不可能成为列表项」。 不变式:每步 concat(已返回增量) === normalizeChatMarkdown(concat(已输入)) → 正常路径上 answer_end 退化为纯尾缀追加(零行重建)。

contentStreaming 标记在 answerStreamStart 置真、只在 finishTask 清除 (end/abort 都不清)——end 之后 flushContent 追加的卡片/参考资料仍即时渲染; mock 与旧后端轮次从未置位。用户停止走 stopAiMessage(state=Stop)canAnimateAiText 已排除 Stop。

步骤

  1. 新建 src/components/answer-stream.ts(纯函数):normalizeChatMarkdown/LIST_ITEM_RE 移入、 事件解析 parseAnswerStreamEvent、状态机 applyAnswerStreamEvent、增量规整器
  2. 协调器:ApiChatEventMap 增 3 个事件(answerStreamStart/End/Abort);4 个新 case (delta 过规整器后走既有 message 通道吃 rAF 批处理);progress/heartbeat 在流开始后停发 scope; flushContent 重构(流已完成时跳过正文、totalResponse 仍拼最终全文); discardActiveStream 挂到 error / failTurn / EOF 三条路径(abort 必须先于 error/close 发出
  3. hook:per-task 流状态 + 3 个监听器(start 记 base、end 尾缀追加或重建、abort 截回 base); finishTaskcontentStreamingclose 的 DMS 补写判据改 hasVisibleMessageContentsaveHistory 按 500ms 节流(流式期每 rAF 全量序列化整个 chatHistory)
  4. BusinessRecord.vueshouldDriveTypewriter&& !record.contentStreaming(唯一改动点)
  5. harness:_entry-coordinator.ts 导出 answer-stream;新增 verify-answer-stream.mjsprobe-answer-stream.mjs

验证

  • 纯函数:规整器前缀不变式(固定用例 + 种子伪随机 ≥500 轮 + 逐字/整段等价 + finish()); reducer(缺口/重复序号/过期流/end 后 delta/abort 复位/非法负载不抛)
  • 协调器级假流:happy path(正文只出现一份、流中 heartbeat 不发 scope、End.text=规整后全文、 totalResponse 以最终全文开头)、无卡片轮不丢正文、整块替换、abort 先于 error、断流撤稿+兼容回退、 旧路径逐字节回归、跨轮隔离、双实例并行不串台
  • 真后端探针probe-answer-stream.mjs 打两类问题,统计事件序/片数/首 delta 耗时/ concat(deltas)===answer_end.text
  • 浏览器验收:逐帧增长非逐字、首字到达 scope 即退、段间空行正确、结束卡片即时追加、 拔网撤草稿、停止保留文字、mock 按钮仍打字机、A 流式中切 B 独立、刷新无残影
  • 回归:全部 tools 脚本 + npm run build + validate-harness

风险 / 未决

  • 单流假设:状态机只维护一条活动流(实测后端每轮只流一个 target);将来双目标并发需改 per-target
  • 每帧 marked.parse:长文或有开销,先观察;卡顿则隔帧 flush 或限单帧字节
  • 罕见分歧分支:实现时顺手剥掉 baseText 里的 <scope> 块再拼,消除卡片闪现
  • 未决(需后端确认,不阻塞):流式轮的 summary.notices 是否仍输出(本方案保留)