一、前言
1.1 为什么逐个字生成
可以发现现在所有的 AI 应用的对话交互中,大模型的回复都是逐个字渲染的。这样的效果,就好像 AI 在编辑内容时 “打字输入” 的过程。
其实这样设计,主要有两个原因:
生成方式的限制:LLM 通常基于 Transformer 架构,采用自回归生成方式。在生成文本时,模型会根据前面的文本内容预测下一个最可能出现的 token,并将其输出,然后将该字或词添加到生成文本的末尾,再用这个扩展后的文本预测下一个字或词,如此循环,直到生成所需长度的文本。
实时交互体验: 逐字显示可以让用户更快看到部分内容,提升交互体验。如果等待整个回答生成完毕后再一次性展示,用户可能会在等待过程中感到焦虑,不知道系统是否在正常运行。而逐个字生成并显示,能让用户实时看到 LLM 的思考过程,感觉对话更加自然流畅,仿佛是与人在进行实时交流。
1.2 如何实现
知道了背景原因,现在作为一个前端我们来思考一下,这种“打字机”的交互是如何实现的。它的两个主要核心就是:
- Server-Sent Events(SSE)接口的支持
- 打字机效果的实现
二、Server-Sent Events
2.1 什么 SSE
Server-Sent Events(SSE)是一种基于 HTTP 的轻量级协议,允许服务器向客户端(如浏览器)单向推送实时数据流。它的核心目的是实现服务器到客户端的实时通信,特别适合需要持续接收更新但不需要双向交互的场景(例如 AI 生成内容、实时通知、股票行情等)。
通俗的说就是:服务端跟客户端会保持一个长连接,将数据片段逐个推送给客户端,传输完毕就终止这个连接。 例如下面的截图,会发现 SSE 的接口会有一个 EventStream 菜单栏。这个里面可以看到,后台逐个返回的数据。
2.2 代码实现
I. 后端代码
基于 NodeJS 的代码实现如下,不管是 GET 还是 POST 基本没有啥区别。
当然,这里更推荐使用 POST 来实现
其核心就是三个:
- 设置请求头
- Content-Type:text/event-stream 「设置 SSE 协议」
- Connection:keep-alive 「保持 TCP 连接处于打开状态,允许服务器持续发送数据」
- 使用 res.write 将数据逐个返回
- 传输完毕使用 res.end 断开连接
const express = require('express');
const app = express();
const port = 3000;
// 解析 JSON 请求体
app.use(express.json());
// SSE POST 接口
app.post('/sse-stream', (req, res) => {
// 1. 获取 POST 请求参数
const { message } = req.body;
// 2. 设置 SSE 响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*', // 按需配置
});
// 3. 初始化计数器
let counter = 0;
const maxChunks = 5;
const sendChunk = () => {
// 检查连接是否已关闭
if (res.writableEnded) {
console.log('Connection closed by client');
return;
}
// 构建 SSE 格式数据
const data = {
chunk: `Processing "${message}" - Part ${counter + 1}`,
timestamp: new Date().toISOString(),
};
// SSE 数据格式要求(注意末尾的双换行)
res.write(`data: ${JSON.stringify(data)}\n\n`);
counter++;
// 模拟 LLM 终止回复
if (counter >= maxChunks) {
res.end();
}
// 模拟 LLM 逐个返回
setTimeout(sendChunk, 500);
};
// 4. 开始发送数据
sendChunk();
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
II. 前端代码
Get 接口对接
const eventSource = new EventSource('http://localhost:3000/sse-stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
};
eventSource.onerror = (error) => {
console.error('SSE 错误:', error);
eventSource.close();
};
Post 接口对接
const response = await fetch('http://localhost:3000/sse-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.error) {
addMessage(data.error);
return;
}
if (data.content) {
fullResponse += data.content;
console.log('fullResponse >>>', fullResponse);
// 渲染内容……
}
}
}
}
三、打字机效果实现
在有了后端不断返回的数据的 SSE 接口后,我们来现实如何控制逐个文字的输出。
3.1 流程设计
整体的流程如下:
- 接受 SSE 接口返回的数据
- 将这些字符串,拆分成单个字符塞入到一个队列中
- 通过一个控制器,控制单个字符输出的速度
- 然后将单个字符不断拼接起来,最后进行渲染
3.2 具体实现
I. 接受、处理数据
首先要做的第一件事就是,就是 SSE 接口不断返回的数据接受并存储起来
代码实现:
II. 控制速度
然后我们还希望通过一个 speed 属性,来控制每个字符的打出的速度。例如 speed = 100,就希望每 100ms 打一个字。
核心就是在每一次 requestAnimationFrame 的帧动画回调中,计算当前时间和上次打字时间的间隔,用间隔时间除以 speed 就等于每一帧要打多少个字。下面是代码的大致实现:
III. 渲染内容
渲染内容的核心就是不断将输出的文字拼接,然后交给一个 markdown 渲染器进行渲染。
目前大部分的 LLM,返回的都是 markdown 的格式
四、最后
完成的体验代码如下:
Typewriter 的完整在 GitHub 仓库:github.com/zixingtangm…