给 AI 流式回答装一台打字机:一次自适应节奏的工程实践 | Mosu
一个可交互的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const now = Date.now();
const duration = now - startTime.current;
const theoreticalSpeed = duration ? queue.current.length / duration : Infinity;
const elapsedTime = now - lastTime.current;
const expectedIntervals = Math.floor(elapsedTime / speedRef.current);
const theoreticalWords = theoreticalSpeed * elapsedTime;
let adjustedWords = Math.max(
expectedIntervals * wordsRef.current,
lastWords.current,
);
if (adjustedWords < theoreticalWords - 2) {
adjustedWords += 2;
} else if (adjustedWords > theoreticalWords) {
adjustedWords = Math.round(theoreticalWords / 2);
} else {
adjustedWords = Math.round(theoreticalWords);
}
翻译成人话:
- 理论平均速度用整段流的总长度除以总耗时算,单位是
chars/ms。这等价于“如果模型现在停止推送,需要把当前已积压的全部内容刚好打完”的速度。 - 把这个理论速度乘上本次 tick 的实际间隔,得到“理论上这一格应该吐多少字”。
- 当前步长远低于理论速度,差距大于 2,则
+2平滑加速;超过理论速度,则主动减速到theoreticalWords / 2,避免一冲到底;接近理论速度,则直接锚到理论值。 - 用
lastWords.current做下界,保证速度不会回退。视觉上“越来越慢”是非常违和的。
为什么是 +2 而不是更激进?因为打字机是给人眼看的,每秒 16 到 30 字符是中英文阅读的舒适区,过快就会变成“贴段落”,失去了打字机本来的意义。/2 减速则是为了避免追赶完毕后的过冲。
skipRules:边界保护
如果只考虑速度,光标随时可能停在  的中间,渲染层就会拿到 : number => {
let step = defaultLength;
for (const rule of skipRulesRef.current) {
const matchObj = rule.exec(queue.current.slice(cursorIndex.current));
if (matchObj && matchObj.index + matchObj[0].length > 0) {
if (step > matchObj.index && step < matchObj[0].length + matchObj.index) {
step = matchObj.index + matchObj[0].length;
} else if (
step > matchObj.index &&
step > matchObj.index + matchObj[0].length
) {
}
}
}
return step;
};
默认 skipRules 包含:
/[A-Za-z]+\b/:不切坏英文单词。/!\[.*]\(.+\)/:不切坏 markdown 图片。/\s+/:不切坏连续空白。/\d\./、/-\s./、/#*\s/:不切坏列表或标题前缀。
第二个分支,也就是“步长完全覆盖一次匹配”的场景,容易写挂。原始实现里特意写了一个内嵌 while 来处理多个匹配相互堆叠的情况:步长可能横跨好几个英文单词,每个都得依次校验是否需要再扩展。这一段我在移植时逐行保留,没有重写。
enqueue 的小心思:替换 vs. 追加
1
2
3
4
5
6
7
8
9
10
const enqueue = useCallback((val = "") => {
if (!val || typeof val !== "string") return;
if (!val.startsWith(queue.current)) {
cursorIndex.current = 0;
startTime.current = Date.now();
lastTime.current = startTime.current;
lastWords.current = 0;
}
queue.current = val;
}, []);
业务里 enqueue 是被父组件每次 SSE chunk 触发后用“累积串”调用的:
- 正常流式:每次新串 = 旧串 + delta,
startsWith命中,cursor 继续推进。 - 流被中断重置或用户重发:新串完全不同,
startsWith失败,把 cursor 和时间窗一起重置,相当于打字机重新进入“冷启动”状态。
这种“用 startsWith 判断是替换还是追加”的小技巧很优雅:调用方不用关心状态,只管把“当前完整内容”丢进来即可,hook 自己分辨。
配套:SSE 模拟器
要可视化打字机的行为,必须能模拟“块大小不规则 + 间隔不规则”的流。我写了一个 useSSESimulator:
1
2
3
4
5
useSSESimulator({
chunkSizeRange: [4, 24],
intervalRange: [80, 220],
onChunk: (accumulated) => enqueue(accumulated),
});
实现非常朴素:递归地 setTimeout,再用 randomInt(min, max) 决定切片大小和延迟,每次产出累积串回调出去。把它和 useTypewriter 串起来,就能复刻真实 SSE 流的形态。
把 chunkSizeRange 拉到 [80, 200],intervalRange 拉到 [300, 600],就能很直观地看到打字机的“追赶”行为:刚收到一大块时步长悄悄加大,等积压消化完后又自然回落到匀速节奏。
Playground 设计
布局参考 Ant Design / MUI 的组件文档页:左侧大块的 preview card 跑实时聊天,右侧 360px 的 props 控制面板,preview 下方一个折叠式代码片段框,整体看起来像是个组件 demo 页。
1
2
3
4
5
6
7
flowchart LR
Sidebar[控制面板] --> Sim[useSSESimulator]
Sim -->|chunk| Acc[累积字符串]
Acc -->|enqueue| Hook[useTypewriter]
Hook -->|text| Bubble[Bot 消息气泡]
Bubble --> Preview[聊天预览卡片]
Sidebar -.pause/resume/clear.-> Hook
控制面板暴露的参数:
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
2
3
4
5
6
7
useEffect(() => {
if (paused) return;
queueTimer.current = setInterval(() => {
}, speedRef.current);
return () => clearInterval(queueTimer.current);
}, [paused]);
所以只有 paused 状态变化时,effect 才会重启。其它选项,也就是 speed、words、skipRules,我用 ref 同步进去,不重启 interval。这是一个 trade-off:避免打字过程中频繁拆建定时器导致节奏抖动。
但这导致 clear() 函数的最初实现埋了个雷:
1
2
3
4
5
6
7
const clear = () => {
clearInterval(queueTimer.current);
setText("");
queue.current = "";
setPaused(false);
};
setPaused(false) 在 paused 已经是 false 时会被 React 合并成 no-op,effect 不会重新运行。于是前面杀掉的定时器永远回不来,之后任何 enqueue 都只往 queue.current 里写,但没人读。playground 表现就是“点发送,没字上屏,有效速度恒为 0”。
修复也很简单:clear() 不要主动 clearInterval。主循环开头本来就有 if (!queue.current.length) return; 的空跑保护,让定时器空转完全无害;而 pause/resume 路径走 [paused] 依赖会自然销毁和重建定时器,不会泄漏。
1
2
3
4
5
6
7
8
9
const clear = useCallback(() => {
setText("");
queue.current = "";
cursorIndex.current = 0;
lastWords.current = 0;
startTime.current = Date.now();
lastTime.current = startTime.current;
setPaused(false);
}, []);
业务侧之所以一直没人发现,是因为业务上 clear() 只在卸载或场景切换时调用一次,调用完整个组件树都重建了,根本走不到“clear 后再 enqueue”的路径。但在 playground 里,发送、清理、再发送是非常常用的循环,bug 立刻露馅。
这种“业务里活得好好的代码,到了 playground 里立刻爆炸”的情况,恰恰是写 playground 的副产品收益之一。
怎么跑
1
2
3
4
cd typewriter
pnpm install
pnpm dev
推荐玩法:
- 选「长段落 · 压力测试」,点击发送,观察自适应节奏。
- 把 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 产品调打字机节奏的人。