让AI对话更生动:手把手教你实现打字机效果

1,596 阅读4分钟

一、前言

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 来实现

其核心就是三个:

  1. 设置请求头
    1. Content-Type:text/event-stream 「设置 SSE 协议」
    2. Connection:keep-alive 「保持 TCP 连接处于打开状态,允许服务器持续发送数据」
  2. 使用 res.write 将数据逐个返回
  3. 传输完毕使用 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 流程设计

画板

整体的流程如下:

  1. 接受 SSE 接口返回的数据

  1. 将这些字符串,拆分成单个字符塞入到一个队列中
  2. 通过一个控制器,控制单个字符输出的速度
  3. 然后将单个字符不断拼接起来,最后进行渲染

3.2 具体实现

I. 接受、处理数据

首先要做的第一件事就是,就是 SSE 接口不断返回的数据接受并存储起来

代码实现:

II. 控制速度

然后我们还希望通过一个 speed 属性,来控制每个字符的打出的速度。例如 speed = 100,就希望每 100ms 打一个字。

核心就是在每一次 requestAnimationFrame 的帧动画回调中,计算当前时间和上次打字时间的间隔,用间隔时间除以 speed 就等于每一帧要打多少个字。下面是代码的大致实现:

III. 渲染内容

渲染内容的核心就是不断将输出的文字拼接,然后交给一个 markdown 渲染器进行渲染。

目前大部分的 LLM,返回的都是 markdown 的格式

image.png

四、最后

完成的体验代码如下:

Typewriter 的完整在 GitHub 仓库:github.com/zixingtangm…