前言
在日常的一些ai工具,例如ChatGPT、豆包等在使用时,能够实时地、连续地输出文本内容,而不是等待整个回答完全生成后再一次性输出,同时用户能够接受到数据就像打印机一样输出,这种方式使得ChatGPT的响应更加迅速,用户体验上更加流畅。那么这个流程中用到了哪些技术方案来实现呢?
1. ChatGPT流式输出的原理、SSE技术和typed.js的使用
SSE技术是一种基于HTTP协议的服务器推送技术,它允许服务器主动向客户端发送数据和信息,实现了服务器到客户端的单向通信,ChatGPT就是采用SSE技术实现流式输出(这里需要和websocket区别开来,websocket是双工通信,SSE属于服务端向客户端单向发送消息):
- 建立连接 —— 当用户与ChatGPT进行对话时,客户端与服务器之间会建立一个基于HTTP的长连接。这个连接通过SSE机制保持打开状态,允许服务器随时向客户端发送数据。
- 分步生成与实时推送 —— ChatGPT根据用户的输入和当前的上下文信息,逐步生成回答的一部分。每当有新的内容生成时,服务器就会通过SSE连接将这些内容作为事件推送给客户端。
- 客户端接收与展示 —— 客户端通过JavaScript的
EventSource对象监听SSE连接上的事件。一旦接收到服务器推送的数据,客户端会立即将其展示给用户,实现流式输出的效果。
typed.js 是一个前端工具库,效果是用打字机的方式显示一段话,可以自定义任何字符串、显示速度等,易用、可配置、无依赖并且支持主流的浏览器,包括最新的版本。
2. typed.js的使用
可以通过npm包管理安装并使用:
npm install typed.js
或者使用cdn链接:
<script src="https://cdn.jsdelivr.net/npm/typed.js@2.0.11"></script>
我们通过实现一个简单的demo看一下,typed.js 是怎么使用的。我们直接通过一个原生js来实现,因此通过cdn地址或者本地的方式引入typed.js 源码,其html代码如下,其中建一个 span 标签来展示输入文本内容(文字放在span标签里面,输入的光标才会正常显示):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<script src="./typed.js"></script>
<body>
<span id="typed" style="white-space: pre-wrap; line-height: 30px"></span>
</body>
</html>
js代码中实现核心功能,相关的配置说明也可以看见。其中 options里面定义了打印效果相关的一些基础参数,实际的参数配置可以参考官方文档:mattboldt.github.io/typed.js/do… ,下面代码中是通过实现前端的ajax请求获取一个段demo诗句(v2.jinrishici.com/one.json 可以获取随机诗句),然后将获取的demo数据进行打印效果的展示。
<script>
// 封装打印效果的方法
function printFn(data) {
const options = {
strings: [data],
typeSpeed: 50, // 打印速度
startDelay: 300, // 开始之前的延迟300毫秒
loop: true, // 是否循环
loopCount: 1, // 循环次数,Infinity 为无限循环。
showCursor: false, // 是否显示光标
contentType: "html", // 明文的'html'或'null'
onComplete: (data) => {
// 所有打字都已完成调用的回调函数
console.log("onComplete", data);
},
onStringTyped: (index, data) => {
// 输入每个字符串后调用的回调函数
console.log("onStringTyped", index, data);
},
};
const typed = new Typed("#typed", options);
}
var xhr = new XMLHttpRequest();
xhr.open("get", "https://v2.jinrishici.com/one.json"); // 生成随机诗句
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
let data = JSON.parse(xhr.responseText);
let content = `<h4>${data.data.content}</h4>`;
printFn(content)
}
};
xhr.send();
</script>
3. 流式输出和打印效果的结合实现
前端用原生js实现一个简单的文本打印区域,后端使用nodejs结合 SSE 技术实现一个简单的流式传输的服务。如下代码是服务端的实现,通过他引入 http 模块实现一个服务,并且监听 8080 端口,模拟ChatGPT的流式输出是通过每隔5秒钟写入一个文本内容实现(模拟后台接口主动向前端推送消息)。(注意服务端跨域的设置应该放开)
// package.json 中增加了 "type": "module", 可以通过es模式导入模块
import http from 'http'
const server = http.createServer((req, res) => {
if (req.url === '/stream') {
res.setHeader('Access-Control-Allow-Origin', '*'); // 设置跨域允许
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// 模拟ChatGPT的流式输出
let counter = 0;
res.write(`ChatGPT says: Hello World, this is message ${++counter}\n\n`);
const interval = setInterval(() => {
const data = `ChatGPT says: Hello World, this is message ${++counter}\n\n`;
res.write(data);
}, 5000); // 每秒发送一次数据
req.on('close', () => {
clearInterval(interval);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(8080, () => {
console.log('Server listening on port 8080');
});
如下代码是前端的实现,前端通过new EventSource("http://localhost:8080/stream") 的方式连到服务端 SSE 服务,在通过这个实例子来 onopen 监听连接状态,通过 onmessage 接收服务端消息。每接收到一次服务端消息就将消息在文本区域打印出来。当前前端代码通过本地服务跑起来(可以通过live-server插件实现),在服务端放开跨域限制的条件下,可以看到正常的打印机效果。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE Output</title>
</head>
<script src="./typed.js"></script>
<body>
<div id="main" style="width: 400px;">
</div>
</body>
<script>
function printFn(domId, data) {
const options = {
strings: [data],
typeSpeed: 50, // 打印速度
startDelay: 300, // 开始之前的延迟300毫秒
loop: true, // 是否循环
loopCount: 1, // 循环次数,Infinity 为无限循环。
showCursor: false, // 是否显示光标
contentType: "html", // 明文的'html'或'null'
onComplete: (data) => {
// 所有打字都已完成调用的回调函数
console.log("onComplete", data);
},
onStringTyped: (index, data) => {
// 输入每个字符串后调用的回调函数
console.log("onStringTyped", index, data);
},
};
const typed = new Typed(`#${domId}`, options);
}
const eventSource = new EventSource("http://localhost:8080/stream"); // 连接到SSE服务器
// 连接建立时的操作
eventSource.onmessage = (event) => {
const data = event.data;
if (data === "[done]") {
eventSource.close();
return;
}
// 每次接受到新的消息,就新建一个dom标签展示新接受的消息
const div = document.getElementById('main');
const span = document.createElement("span");
const domId = 'type'+Date.now();
span.id = domId;
span.style= "white-space: pre-wrap; line-height: 30px; display: block; color: #338af6; font-size: 16px";
div.appendChild(span);
printFn(domId, data) // 展示接收到的数据
};
// 连接建立时的操作
eventSource.onopen = (event) => {
console.log("EventSource onopen:", event);
};
// 错误处理
eventSource.onerror = (event) => {
console.error("EventSource failed:", event);
};
</script>
</html>