feat(chat): smooth streaming output
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# Chat 流式生成动画改造经验
|
||||
|
||||
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||
|
||||
## 目标
|
||||
|
||||
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||
|
||||
## 文本流式生成
|
||||
|
||||
### 经验结论
|
||||
|
||||
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||
|
||||
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||
|
||||
- token 先进入缓冲区。
|
||||
- 前端按固定节奏释放 chunk。
|
||||
- chunk 尽量按词、短语、标点边界切分。
|
||||
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||
|
||||
当前实现采用:
|
||||
|
||||
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||
- 小缓冲按较短 chunk 输出。
|
||||
- 大缓冲最多每帧释放 `160` 字符。
|
||||
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||
|
||||
## Markdown 动画
|
||||
|
||||
### 经验结论
|
||||
|
||||
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||
|
||||
典型例子:
|
||||
|
||||
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||
|
||||
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||
|
||||
当前策略:
|
||||
|
||||
- Markdown 仍完整解析,保证格式正确。
|
||||
- 在 rehype 阶段处理 AST。
|
||||
- 从 AST 尾部反向找最后的可见 text node。
|
||||
- 只给最后一段尾部文本加动画。
|
||||
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||
|
||||
## 当前文字动画建议
|
||||
|
||||
推荐保留轻量动画:
|
||||
|
||||
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||
- 使用 `clip-path` 做左到右 reveal。
|
||||
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||
- 时长控制在 `120ms - 260ms`。
|
||||
|
||||
不要做:
|
||||
|
||||
- 外层整段 `motion.div` 淡入。
|
||||
- 每次流式更新都改变 key。
|
||||
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||
- 在生成中对已有文本重复动画。
|
||||
|
||||
## 滚动和边框稳定
|
||||
|
||||
### 经验结论
|
||||
|
||||
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||
|
||||
当前策略:
|
||||
|
||||
- 生成中不再每个 token 精确贴底。
|
||||
- 底部保留生成缓冲区,当前约 `180px`。
|
||||
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||
- 用户离开底部附近后,不再强制自动跟随。
|
||||
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||
|
||||
## 图表生成
|
||||
|
||||
### 经验结论
|
||||
|
||||
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||
|
||||
当前策略:
|
||||
|
||||
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||
- ECharts 在 skeleton 下方淡入。
|
||||
- 容器尺寸保持一致,避免边框高度突变。
|
||||
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||
|
||||
图表类型动画建议:
|
||||
|
||||
- 折线图:平滑 enter,面积渐显。
|
||||
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||
- 饼图:使用 expansion/sweep 类进入动画。
|
||||
- update 动画要短于 enter 动画。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||
|
||||
## 状态提示
|
||||
|
||||
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||
|
||||
推荐:
|
||||
|
||||
- 状态放在“分析结果”标题行右侧。
|
||||
- 使用小尺寸、低干扰的 pulsing dots。
|
||||
- 不使用末尾光标,避免和业务文本混在一起。
|
||||
|
||||
## 验证建议
|
||||
|
||||
每次调整流式动画后至少跑:
|
||||
|
||||
```bash
|
||||
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||
npx tsc --noEmit
|
||||
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||
```
|
||||
|
||||
人工验证重点:
|
||||
|
||||
- 长中文回答是否明显落后后端真实速度。
|
||||
- Markdown 加粗、列表、代码块是否乱序。
|
||||
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||
- 用户手动上滚后是否停止强制跟随。
|
||||
|
||||
## 后续调整原则
|
||||
|
||||
1. 先调节 token playback,再调动画。
|
||||
2. 动画只作用于新增内容,已有内容不能重播。
|
||||
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||
Reference in New Issue
Block a user