给 AI 流式回答装一台打字机:一次自适应节奏的工程实践
一个可交互的 React Hook playground:把一段经久不衰的打字机算法剥离出业务依赖,配上 SSE 模拟器与组件库式 demo,让“自适应节奏”看得见。
项目地址:mosuzi/typewriter
起因
最近在研究 AI 流式对话场景下的打字机实现,翻到一段在生产环境跑了很久的算法,越看越觉得里面藏了不少与“流式 + 视觉节奏”相关的思考。可惜原始代码里夹杂着 deepmerge、randomChars、useLastData 这些非核心的东西,把真正有趣的算法藏得很深。
于是我把它抽成一个独立 playground,剥掉所有业务杂质,再配一个 SSE 模拟器和一个仿组件库 demo 风格的聊天室页面,方便随时调参看波形。下面是这次重构和演示项目的一些笔记。
为什么需要打字机?
LLM 推理是 token-by-token 推送的,但底层 SSE 流的节奏其实非常糟糕:
- 大爆发:模型在 prefill 完成、KV cache 命中或者并发降下来时,会一次性吐出几百字。
- 慢空窗:网络抖动、tokenizer 拆词、服务端 batching 都可能把下一段延迟 300–800ms。
- 块大小和间隔都是高度不规则的。
如果界面照搬底层节奏,用户看到的就是“突然贴一大段 → 长时间空白 → 又贴一大段”的跳变,而不是“匀速地写出来”。打字机这一层的价值,就是在底层流和用户视觉之间加一个节流缓冲器:
- 积压不严重时,用一个让人舒服的速度匀速吐字。
- 积压变大时,悄悄加速但不至于突变。
- 流结束后,尾巴要快速但优雅地补完。
- 渲染中途,不能切坏 markdown 结构,否则上层 markdown parser 会瞎渲染。
核心算法:自适应反馈环
useTypewriter 内部维护两根指针:
queue.current:完整目标串,每次新 chunk 来时累加。cursorIndex.current:已经“打”出来的位置。
每个 setInterval(speed) tick 干这几件事:
1 | const now = Date.now(); |
翻译成人话:
- 理论平均速度用整段流的总长度除以总耗时算,单位是
chars/ms。这等价于“如果模型现在停止推送,需要把当前已积压的全部内容刚好打完”的速度。 - 把这个理论速度乘上本次 tick 的实际间隔,得到“理论上这一格应该吐多少字”。
- 当前步长远低于理论速度,差距大于 2,则
+2平滑加速;超过理论速度,则主动减速到theoreticalWords / 2,避免一冲到底;接近理论速度,则直接锚到理论值。 - 用
lastWords.current做下界,保证速度不会回退。视觉上“越来越慢”是非常违和的。
为什么是 +2 而不是更激进?因为打字机是给人眼看的,每秒 16 到 30 字符是中英文阅读的舒适区,过快就会变成“贴段落”,失去了打字机本来的意义。/2 减速则是为了避免追赶完毕后的过冲。
skipRules:边界保护
如果只考虑速度,光标随时可能停在  的中间,渲染层就会拿到 : number => { |
默认 skipRules 包含:
/[A-Za-z]+\b/:不切坏英文单词。/!\[.*]\(.+\)/:不切坏 markdown 图片。/\s+/:不切坏连续空白。/\d\./、/-\s./、/#*\s/:不切坏列表或标题前缀。
第二个分支,也就是“步长完全覆盖一次匹配”的场景,容易写挂。原始实现里特意写了一个内嵌 while 来处理多个匹配相互堆叠的情况:步长可能横跨好几个英文单词,每个都得依次校验是否需要再扩展。这一段我在移植时逐行保留,没有重写。
enqueue 的小心思:替换 vs. 追加
1 | const enqueue = useCallback((val = "") => { |
业务里 enqueue 是被父组件每次 SSE chunk 触发后用“累积串”调用的:
- 正常流式:每次新串 = 旧串 + delta,
startsWith命中,cursor 继续推进。 - 流被中断重置或用户重发:新串完全不同,
startsWith失败,把 cursor 和时间窗一起重置,相当于打字机重新进入“冷启动”状态。
这种“用 startsWith 判断是替换还是追加”的小技巧很优雅:调用方不用关心状态,只管把“当前完整内容”丢进来即可,hook 自己分辨。
配套:SSE 模拟器
要可视化打字机的行为,必须能模拟“块大小不规则 + 间隔不规则”的流。我写了一个 useSSESimulator:
1 | useSSESimulator({ |
实现非常朴素:递归地 setTimeout,再用 randomInt(min, max) 决定切片大小和延迟,每次产出累积串回调出去。把它和 useTypewriter 串起来,就能复刻真实 SSE 流的形态。
把 chunkSizeRange 拉到 [80, 200],intervalRange 拉到 [300, 600],就能很直观地看到打字机的“追赶”行为:刚收到一大块时步长悄悄加大,等积压消化完后又自然回落到匀速节奏。
Playground 设计
布局参考 Ant Design / MUI 的组件文档页:左侧大块的 preview card 跑实时聊天,右侧 360px 的 props 控制面板,preview 下方一个折叠式代码片段框,整体看起来像是个组件 demo 页。
1 | flowchart LR |
控制面板暴露的参数:
speed(10–200ms)/words(1–8):打字机本身。chunk size/interval区间滑块:SSE 模拟器节奏。skipRules三个开关:英文单词、markdown 图片、空白,直观验证截断保护。- 4 条预设回复:纯文本、含图片 markdown、代码块、长段落压力测试。
- 实时统计:
queue长度、已渲染长度、滞后字符数、有效 c·s⁻¹。
最有意思的玩法是关掉「Markdown 图片」开关,再发送一遍带图片的预设:你能亲眼看到  在中途被切碎,对应的 image 标签短暂渲染错乱,然后下一个 tick 才补完。打开开关,问题立刻消失。
工程踩坑:[paused] 依赖与 clear() 的隐性陷阱
这次重构里踩了一个原版就有但日常用不到的坑:
useEffect 创建定时器时,依赖数组只写了 [paused]:
1 | useEffect(() => { |
所以只有 paused 状态变化时,effect 才会重启。其它选项,也就是 speed、words、skipRules,我用 ref 同步进去,不重启 interval。这是一个 trade-off:避免打字过程中频繁拆建定时器导致节奏抖动。
但这导致 clear() 函数的最初实现埋了个雷:
1 | // 错误版本 |
setPaused(false) 在 paused 已经是 false 时会被 React 合并成 no-op,effect 不会重新运行。于是前面杀掉的定时器永远回不来,之后任何 enqueue 都只往 queue.current 里写,但没人读。playground 表现就是“点发送,没字上屏,有效速度恒为 0”。
修复也很简单:clear() 不要主动 clearInterval。主循环开头本来就有 if (!queue.current.length) return; 的空跑保护,让定时器空转完全无害;而 pause/resume 路径走 [paused] 依赖会自然销毁和重建定时器,不会泄漏。
1 | const clear = useCallback(() => { |
业务侧之所以一直没人发现,是因为业务上 clear() 只在卸载或场景切换时调用一次,调用完整个组件树都重建了,根本走不到“clear 后再 enqueue”的路径。但在 playground 里,发送、清理、再发送是非常常用的循环,bug 立刻露馅。
这种“业务里活得好好的代码,到了 playground 里立刻爆炸”的情况,恰恰是写 playground 的副产品收益之一。
怎么跑
1 | cd typewriter |
推荐玩法:
- 选「长段落 · 压力测试」,点击发送,观察自适应节奏。
- 把 chunk size 调成
[80, 200]、interval 调成[300, 600],模拟“大爆发 + 慢空窗”,能直观看到加速和减速的过渡。 - 流到一半点暂停,文字定格;再点恢复,继续追上 queue。
- 切到「Markdown · 含图片」预设,关掉「Markdown 图片」开关再发送一次,
![]()会被切坏;打开开关再来一次,丝滑无瑕疵。
一些不太重要但值得记的小事
- 不引入 deepmerge:原版选项是扁平结构,
{ ...DEFAULT, ...props }完全够用。生产代码里因为历史包袱保留了deepmerge,移植时果断扔掉。 - 不引入 randomChars:原版数组里塞了一堆
[]()_*~看起来像是为某种“随机打字机花字”准备的,但算法里从未读过。死代码。 words解释:每个 tick 至少前进的字符数。乘上expectedIntervals后作为adjustedWords的下界。值越大越激进。speed不响应运行时变化:依赖[paused]决定,speed 改了不会立即重启。想立刻生效,pause 一下再 resume 即可。这是实现的天然限制,没去打补丁。
整段算法不长,加上所有边界处理也就 200 行;但它把一个“流式渲染 + 视觉节流 + 结构感知”三合一的小问题做得相当扎实。希望这个 playground 能帮到任何一个正在为自家 AI 产品调打字机节奏的人。


Mosu is located on the shore of Mosu Lake, facing the vast Chu Sea, backed by the Yihan Mountains. Thousands of miles of Mosu Desert can not erode the Mosu Valley. Thus the Mosu Empire was established.
