大模型的HTTP接口通常默认返回完整JSON,但当输出长度超过几百token时,首字等待时间会长到难以接受。流式输出(Streaming)让服务端边生成边返回,前端收到分片后立即展示,从而把可感知的首字延迟降到几十毫秒。实现打字机效果本质上是把网络层的分批数据转换成DOM中逐步增长的文本,并控制渲染节奏。

一、流式输出的基础:选择合适的传输方式
大模型流式接口通常有两种:Server-Sent Events(SSE)和HTTP分块传输。SSE是浏览器原生EventSource支持,但只支持GET且无法自定义请求头;fetch的ReadableStream可以处理POST请求并读取分块数据,更适合带Authorization的API。这里以fetch读取流为例。
调用时需要在请求体中开启stream参数。以OpenAI兼容接口为例,请求体中加入 stream: true,响应头的Content-Type通常是text/event-stream。拿到response后,用 response.body.getReader() 获取读取器,循环读取Uint8Array,再用TextDecoder按UTF-8解码。
async function* streamChat(messages) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true })
});
if (!res.ok || !res.body) throw new Error('请求失败');
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content ?? '';
if (delta) yield delta;
} catch (e) {
// 忽略半包JSON,等待下一个分片
}
}
}
}
二、打字机效果的核心实现
获得增量文本后,直接追加到DOM并不是严格意义的打字机效果,因为网络分块粒度不可控,可能一次吐出几十个字符。要实现逐字显示,需要在前端维护一个显示队列和一个定时器,以固定速度消费队列。
设计上可以把服务端增量写入一个待渲染字符串,渲染循环每16毫秒或每30毫秒取出一到两个字符,追加到DOM节点。这样无论底层数据到达多快,视觉上都会保持平滑稳定。使用 requestAnimationFrame 或 setInterval 都可以,后者更容易控制速度。
class Typewriter {
constructor(el, speed = 20) {
this.el = el;
this.speed = speed;
this.queue = '';
this.timer = null;
}
push(text) {
this.queue += text;
if (!this.timer) this.start();
}
start() {
this.timer = setInterval(() => {
if (!this.queue) {
clearInterval(this.timer);
this.timer = null;
return;
}
const step = Math.min(this.queue.length, 2);
const chunk = this.queue.slice(0, step);
this.queue = this.queue.slice(step);
this.el.textContent += chunk;
}, this.speed);
}
}
这个实现简单但有效:push 方法把新内容放入队列,定时器每20毫秒从队列头部取两个字符写入DOM。速度参数可根据产品需要调整,通常20到40毫秒比较自然。若要更强的真实感,可以加入随机抖动,让每次取字符数在1到3之间变化,避免机械节奏。
注意这里直接修改 textContent,适合纯文本阶段。如果一边打字机一边渲染Markdown,会因为Markdown不完整导致闪烁或解析错误,这部分在后面单独说明。还需要考虑停止条件:当服务端结束且队列清空时,要触发完成回调,以便恢复光标、显示操作按钮等。
三、处理UTF-8多字节字符和增量边界
流式传输中一个非常容易忽视的问题是UTF-8编码边界。汉字、emoji等字符在UTF-8中占3到4个字节,如果网络分片恰好把某个字符的字节序列切开,用 TextDecoder 一次性解码可能会得到乱码替换符。好在 TextDecoder 支持 { stream: true } 选项,会把不完整字节保留在内部,待下一块数据到达后再拼接解码。
但即便解码正确,逐字符切片时也要注意Unicode代理对。例如emoji在JavaScript字符串中由两个UTF-16码元组成,如果按 slice(0,1) 切到代理对的一半,DOM中会显示为乱码。更稳妥的做法是用 Array.from(queue) 把它按码点拆分,或者使用 for...of 迭代字符,再按字符数量取出。
function takeChars(str, count) {
const chars = Array.from(str);
return {
chunk: chars.slice(0, count).join(''),
rest: chars.slice(count).join('')
};
}
// 在渲染循环中使用
const { chunk, rest } = takeChars(this.queue, step);
this.queue = rest;
this.el.textContent += chunk;
这样即使遇到emoji或组合字符,也不会从中间截断。另一个可选方案是使用 Intl.Segmenter 按字形簇分割,但兼容性和性能需要评估。对于中文聊天场景,按码点处理已经足够。
增量边界还体现在JSON解析上。SSE数据行按换行分隔,但负载JSON可能被截断,所以解析失败时不能中断循环,要把半包数据留在缓冲区,和下一个分片拼接后再试。前面代码中的 buffer 变量就是为这个目的保留的。
四、与Markdown渲染、自动滚动和性能结合
聊天产品中,模型回答往往包含代码块、列表、标题等Markdown语法。如果在打字机过程中实时解析Markdown,会频繁出现未闭合的代码块导致页面跳动。推荐采用两阶段策略:输出过程中先以纯文本方式展示打字机效果,待模型输出结束后,再将完整文本一次性交给Markdown渲染器,最后替换DOM内容。
如果一定要在输出过程中保留Markdown格式,可以降低渲染频率,例如每200毫秒才把当前文本交给渲染器,同时给代码块设置最小高度,减少跳动。但体验上不如完成后渲染稳定。自动滚动方面,在每次追加字符或渲染更新后,将容器滚动到底部:container.scrollTop = container.scrollHeight。如果用户手动向上翻阅,可以暂停自动滚动,避免打断阅读。
function appendAndScroll(text) {
outputEl.textContent += text;
if (shouldAutoScroll) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
chatContainer.addEventListener('scroll', () => {
const nearBottom =
chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < 40;
shouldAutoScroll = nearBottom;
});
性能方面,频繁修改 textContent 会触发浏览器重排,但每20毫秒修改一次文本节点在现代浏览器中开销很小。为了更优,可以使用 DocumentFragment 或把文本拆成多个span,但收益有限。更需要注意的是不要在每次追加时都重新解析整个Markdown或调用高亮库,这会成倍增加CPU消耗。可以在完成后统一高亮,或者使用Web Worker隔离。
最后,光标闪烁动画可以放在输出容器末尾,用一个span元素模拟,通过CSS动画控制透明度。在流输出结束后隐藏光标,或替换为静态提示。这样整体效果就接近ChatGPT等产品的逐字输出体验。