引言
之前写过 关于如何调用 DeepSeek 开放平台 API 接口的文章。但是当时只是基于 fetch 的一个调用, 但其实对于大模型, 当我们和它对话, 它需要将我们输入的内容进行 Token 化、之后再进行推理、最后输出答案, 而这整个过程中是需要一定时间的, 随着我们 Token 量的增加, 大模型推理输出时间也会随着增加。所以市面上大模型对话基本都是流式输出的, 也就是一点点回吐内容, 这样相比最后一次性给出答案, 可以给用户一个比较好的体验。
下面我们来通过一个简单 DEMO 来演示下作为前端我们要如何处理大模型的流式输出:
一、什么是流式输出
流式输出 是一种像水流一样, 一边传输、一边消费的方式。就类比于视频观看, 在观看视频期间我们是一遍下载视频一遍观看, 并不需要等待视频全部加载完成后才能观看。
举个实际的流式输出例子: 在 ChatGPT 中 AI 的回答是一行一行地 "打" 出来的, 这就是所谓的 "流式输出"。GPT 模型一遍推理生成回复(文字), 一边将内容推送给你阅读。因为不用等完整回答完成才展示给用户, 所以即便模型回答得很慢, 也不会 "卡住" 用户体验。相比之下, "非流式输出" 就是等模型把完整回答生成完, 一次性传回前端, 如此慢、无反馈、也不友好。
二、对话交互实现(非流式)
项目情况说明:
- 项目是
NextJS项目, 这里我是直接通过官方脚手架搭建起来的, 关于NextJS搭建可以看我之前写的这篇文章 Next 项目搭建指南(写着写着就 3 万多字了~~~) - 组件的话我这边用的是
HeroUI, 其实就是之前的NextUI只是它后面改名了 - 样式这边使用了
Tailwind
2.1 整体框架布局
如下代码:
- 状态
value和文本输入框Textarea进行双向绑定, 目的是为了实时获取用户输入的内容 - 状态
messages则是一个列表, 存储所有消息, 包括用户发送的消息、大模型回复的消息, 这里通过role来区分 - 整体布局的话, 输入框部分采用定位, 定位到屏幕的底部
- 消息列表则采用
flex布局, 同时根据role字段, 区分出用户消息、大模型消息, 并采用不同的对齐方式
'use client';
import clsx from 'clsx';
import Icon from '@/components/Icon';
import { Button } from '@heroui/react';
import { Textarea } from '@heroui/input';
import { useCallback, useState } from 'react';
const Page = () => {
const [value, setValue] = useState('');
const [messages, setMessages] = useState<{ role: string; content: string }[]>([
{
role: 'user',
content: '你好啊',
},
{
role: 'assistant',
content: '好呀!😊 很高兴见到你~今天',
},
]);
const handleSend = useCallback(async () => {}, []);
return (
<div className="flex min-h-screen w-screen justify-center overflow-y-auto bg-[#080c22]">
<div className="mt-10 w-1/2 pb-80">
{messages.map((message, index) => (
<div
key={index}
className={clsx('mb-3 flex', message.role === 'user' ? 'justify-end' : 'justify-start')}>
<div
className={clsx(
message.role === 'user' ? 'bg-[#f9e4df]' : 'bg-[#f5d797]',
'flex size-9 flex-none items-center justify-center rounded-full',
)}>
{message.role === 'user' ? '👤' : '🤖'}
</div>
<div className="ml-2 flex rounded-lg bg-[#232325] p-2">{message.content}</div>
</div>
))}
</div>
<div className="fixed bottom-7 w-1/2 rounded-lg bg-[#2a2a2a]">
<Textarea
value={value}
color="default"
placeholder="输入你想说的话..."
onChange={(e) => setValue(e.target.value)}
classNames={{
input: 'group-data-[has-value=true]:text-white/80',
inputWrapper: 'bg-transparent group-data-[focus]:bg-[#29272e] data-[hover]:bg-[#29272e]',
}}
/>
<div className="absolute bottom-2 right-2">
<Button
isIconOnly
size="sm"
radius="full"
onPress={handleSend}>
<Icon
name="icon-arrdown"
className="text-xl text-white/50"
/>
</Button>
</div>
</div>
</div>
);
};
export default Page;
最后整体效果如下:
2.2 交互实现
上面我们实现了一个静态的效果, 我们先把交互加上...
- 开始前先把写个请求函数: 调用
DeepSeek开发接口, 这里根据 官方文档 要求设置相应请求头、请求体即可, 注意这里我关闭了流式输出即stream: false
const ENDPOINT = 'https://api.deepseek.com/chat/completions';
const API_KEY = process.env.NEXT_PUBLIC_API_KEY; // 环境变量, 其实就是 DeepSeek 的 API Key
const sendMessage = async (message: { role: string; content: string }) => {
// 组装请求头
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`, // 需要通过请求头(Authorization)设置 API Keys
};
// 组装请求体
const payload = {
model: 'deepseek-chat', // 选择模型
messages: [message], // 消息体
stream: false, // 是否开启流式输出
};
// 发送请求(其实就是正常发个 POST 请求)
const response = await fetch(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
// 等待结果的返回
const res = await response.json();
console.log(res);
};
export default sendMessage;
- 在
handleSend函数中, 调用请求函数sendMessage目的是为了随便发送一个请求, 我们来看一眼接口返回情况
...
+ import sendMessage from './sendMessage';
const Page = () => {
....
+ const handleSend = useCallback(async () => {
+ sendMessage({
+ role: 'user',
+ content: '你好啊',
+ });
+ }, []);
return (
<div className="flex min-h-screen w-screen justify-center overflow-y-auto bg-[#080c22]">
....
</div>
);
};
export default Page;
- 看一眼接口返回情况: 主要就是看下请求响应的数据结构, 如下我们只需要
choices[0].message字段
- 接下来不将整个逻辑串起来, 交互细节如下:
- 用户输入内容后, 点击发送按钮
- 会将用户输入消息存储起来, 同时调用
DeepSeek接口 - 调用
DeepSeek期间, 用户将会有一个提示
sendMessage 函数直接接口响应中我们所需的字段
const ENDPOINT = 'https://api.deepseek.com/chat/completions';
const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
const sendMessage = async (message: { role: string; content: string }) => {
...
+ return res.choices[0].message;
};
export default sendMessage;
页面交互逻辑这边也做了调整, 具体看下面代码:
'use client';
....
const Page = () => {
const [value, setValue] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
+ const list = useMemo(() => {
+ const data = [...messages];
+ if (isLoading) {
+ data.push({
+ role: 'assistant',
+ content: '正在思考...',
+ });
+ }
+ return data;
+ }, [messages, isLoading]);
+ const handleSend = useCallback(async () => {
+ const currentMessage = {
+ role: 'user',
+ content: value,
+ };
+
+ setValue('');
+ setMessages((pre) => [...pre, currentMessage]);
+
+ setIsLoading(true);
+ const message = await sendMessage(currentMessage);
+ setMessages((pre) => [...pre, message]);
+ setIsLoading(false);
+ }, [value]);
return (
<div className="flex min-h-screen w-screen justify-center overflow-y-auto bg-[#080c22]">
<div className="mt-10 w-1/2 pb-80">
+ {list.map((message, index) => (
<div
key={index}
className={clsx('mb-3 flex', message.role === 'user' ? 'justify-end' : 'justify-start')}>
....
</div>
))}
</div>
....
</div>
);
};
export default Page;
下面是完整效果(剪辑了点, 速度快了点)
三、流式(Fetch)
DeepSeek 开放接口是支持流式输出的, 我们先把它开启看下效果, 这里我还把一些逻辑先注释掉了, 这些逻辑也要改的要不然会报错的
sendMessage函数代码
const sendMessage = async (message: { role: string; content: string }) => {
...
// 组装请求体
const payload = {
model: 'deepseek-chat', // 选择模型
messages: [message], // 消息体
+ stream: true, // 是否开启流式输出
};
// 发送请求(其实就是正常发个 POST 请求)
const response = await fetch(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
+ // 等待结果的返回
+ // const res = await response.json();
+ // return res.choices[0].message;
};
export default sendMessage;
- 页面交互代码
'use client';
import clsx from 'clsx';
....
const Page = () => {
const [value, setValue] = useState('');
....
const handleSend = useCallback(async () => {
const currentMessage = {
role: 'user',
content: value,
};
setValue('');
setMessages((pre) => [...pre, currentMessage]);
+ // setIsLoading(true);
+ await sendMessage(currentMessage);
+ // setMessages((pre) => [...pre, message]);
+ // setIsLoading(false);
}, [value]);
...
};
export default Page;
然后我们随便在输入框中输入点内容, 然后点击发送, 看下请求的情况
这里接口返回的是一个 Stream, 每一次返回数据 data: JSON 字符串, 最后返回一个 data: [DONE] 表示数据推送结束, 而在 fetch 中我们可以通过 getReader 读取流数据, 同时可通过 TextDecoder 来对数据流中的文本内容进行解码, 下面我们来看代码: 代码逻辑并不完整, 只是通过 while 来循环, 通过变量 i 来限制循环次数, 同时将相关数据打印出来来看下
const sendMessage = async (message: { role: string; content: string }) => {
....
// 发送请求(其实就是正常发个 POST 请求)
const response = await fetch(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
+ const reader = response.body?.getReader();
+ const decoder = new TextDecoder();
+ let i = 0;
+
+ while (true) {
+ i++;
+ const { value, done } = await reader?.read();
+ const text = decoder.decode(value, { stream: true });
+ console.log('%c [ done ]-30', 'font-size:13px; background:pink; color:#bf2c9f;', done);
+ console.log('%c [ value ]-30', 'font-size:13px; background:pink; color:#bf2c9f;', value);
+ console.log('%c [ text ]-33', 'font-size:13px; background:pink; color:#bf2c9f;', text);
+
+ if (i === 30) break;
+ }
};
export default sendMessage;
最后查看打印内容, 得到如下结论:
- 一次返回内容可能包含多条数据
- 每条数据都是以
data:作为开头, 后面则紧跟着具体的内容, - 内容部分除了最后一条, 其他的都是一个
JSON字符串, 即有效的数据, 也是我们要的数据 - 最后一条内容都是
[DONE]表示, 流数据推送结束 done字段表示流数据是否推送结束- 所以这里我们有两个方法可以判断流数据是否推送结束, 根据
done字段或者推送内容是否为[DONE]
最后我们改写下 sendMessage 函数:
- 函数参数做了改造, 新增了
onMessage回调, 通过它我们希望可以实时监听到新的数据 - 根据返回数据结构, 针对数据进行处理, 获取到有效数据后, 依次调用回调函数
onMessage
....
+ interface Message {
+ role: string;
+ content: string;
+ }
+
+ interface SendMessageParams {
+ message: Message;
+ onMessage: (message: Message) => void;
+ }
+ const sendMessage = async ({ message, onMessage }: SendMessageParams) => {
....
// 发送请求(其实就是正常发个 POST 请求)
const response = await fetch(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
+ const reader = response.body?.getReader();
+ const decoder = new TextDecoder();
+
+ while (true && reader) {
+ const { value, done } = await reader?.read();
+ const text = decoder.decode(value, { stream: true });
+
+ text.split('\n').forEach((line) => {
+ const data = line.slice(6).trim(); // 获取数据内容, 并清除前后空格
+
+ // 如果数据是有效内容, 则调用回调函数 onMessage
+ if (data !== '[DONE]' && data !== '') {
+ const message = JSON.parse(data).choices[0].delta;
+ onMessage(message);
+ }
+ });
+
+ if (done) break;
+ }
};
export default sendMessage;
最后我们再改造下交互逻辑:
- 删除了
loading状态 - 用户发送消息事件函数逻辑做了比较大的调整, 在用户发送时直接新增两条消息, 一条是用户发送的消息、一条则是大模型消息, 然后再每次流数据推送时, 去修改最后一条消息的内容即可
...
+ const loadingMessage = '正在思考...';
const Page = () => {
const [value, setValue] = useState('');
const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
const handleSend = useCallback(async () => {
const currentMessage = {
role: 'user',
content: value,
};
setValue('');
+ setMessages((pre) => [
+ ...pre,
+ currentMessage,
+ {
+ role: 'assistant',
+ content: loadingMessage,
+ },
+ ]);
+ await sendMessage({
+ message: currentMessage,
+ onMessage: (message) =>
+ setMessages((pre) => {
+ const list = [...pre];
+ const lastMessage = list[list.length - 1];+
+
+ if (lastMessage.content === loadingMessage) {
+ lastMessage.content = message.content;
+ } else {
+ lastMessage.content += message.content;
+ }
+
+ return list;
+ }),
+ });
+ }, [value]);
return (
<div className="flex min-h-screen w-screen justify-center overflow-y-auto bg-[#080c22]">
<div className="mt-10 w-1/2 pb-80">
+ {messages.map((message, index) => (
<div
key={index}
className={clsx('mb-3 flex', message.role === 'user' ? 'justify-end' : 'justify-start')}>
....
</div>
))}
</div>
<div className="fixed bottom-7 w-1/2 rounded-lg bg-[#2a2a2a]">
....
</div>
);
};
export default Page;
最后效果如下:
四、原生 SSE
上面我们调用 DeepSeek 是采用原生的 fetch 进行的, 而实际上 Deepseek API 和其他大部分大模型 AI 返回的流式输出数据都是符合标准的 Server-Sent Events(SSE) 规范的, 并且现代浏览器几乎都支持更简单的 SSE API
只是可惜的是我们目前暂时无法直接在前端使用它。这是因为根据标准, SSE 的底层只支持 HTTP GET, 并且不能携带自定义的 Header, 然而 DeepSeek 的授权却需要将 API Key 通过 Header 中 Authorization 进行传输, 而且 API 也要求必须使用 POST 请求。
那么如果前端一定要使用标准的 SSE 来处理流式输出可以吗? 当然是可以的啦, 只是我们就需要多一层 BFF 服务, 其实就是写一个简单的服务端, 专门为前端来做接口的转发, 将 DeepSeek 接口转为标准的 SSE。
4.1 BFF
在项目 APP 目录下新建路由(src/app/api/ds-chat/route.ts):
完整代码如下:
- 新增一个
GET接口, 接口允许传一个question参数 - 接口内逻辑比较简单, 就是获取了下请求参数 -> 调用
DS接口 -> 将DS请求响应包了一层, 并设置正确的响应头即可
const ENDPOINT = 'https://api.deepseek.com/chat/completions';
const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
export async function GET(req: Request) {
// 接收接口参数
const { searchParams } = new URL(req.url);
const question = searchParams.get('question');
// 发起 DS 请求
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
'Content-Encoding': 'utf-8',
},
body: JSON.stringify({
stream: true,
model: 'deepseek-chat',
messages: [{ role: 'user', content: question }],
}),
});
// 直接将 DS 返回内容, 包一层返回出去, 同时修改了下请求响应头 `Content-Type`
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
最后我们在浏览器访问 http://localhost:3000/api/ds-chat?question=你好啊, 页面中将缓慢输出如下内容
到此我们 BFF 部分也算是完成了
4.2 打通
下面我们就改造下页面逻辑部分, 这里只需要改 sendMessage 部分即可
interface Message {
role: string;
content: string;
}
interface SendMessageParams {
message: Message;
onMessage: (message: Message) => void;
}
const sendMessage = async ({ message, onMessage }: SendMessageParams) => {
// 发送请求(其实就是正常发个 POST 请求)
const eventSource = new EventSource(`/api/ds-chat?question=${message.content}`);
eventSource.addEventListener('message', (event) => {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
if (event.data !== '') {
const message = JSON.parse(event.data).choices[0].delta;
onMessage(message);
}
});
eventSource.addEventListener('end', () => {
eventSource.close();
});
};
export default sendMessage;
4.3 为啥选择 SSE & 适用场景
原生 SSE(Server-Sent Events) 是浏览器原生支持的一种服务器推送数据到客户端的技术, 基于 HTTP 协议。相较于 WebSocket 和 轮询 等方案, 原生 SSE 有以下优点:
- 实现简单:
SSE是基于HTTP协议的, 前端只需要一个EventSource实例, 端只需输出text/event-stream格式的数据即可。 - 自动重连机制: 浏览器原生支持断线重连(自动尝试重新连接服务器)需要额外处理断线逻辑。
- 支持事件
ID& 恢复机制:SSE支持Last-Event-ID头, 结合服务端id字段, 可以实现断线后的数据恢复, 避免数据丢失。 - 单向推送足够时是更轻量的选择: 如果只是服务端向客户端单向推送(如通知、状态更新、日志流等),
SSE比WebSocket更轻量、稳定, 且资源占用更低。 - 天然支持文本数据: 支持
UTF-8编码的文本数据, 使用简单直观。 - 浏览器支持较好: 主流浏览器(
Chrome、Firefox、Safari、Edge)均支持EventSource使用门槛也低。
当然局限性也是有的:
- 只支持单向通信(服务端 -> 客户端), 不能像
WebSocket那样双向通信。 - 不支持二进制数据(如
Blob、ArrayBuffer), 仅支持UTF-8文本。 - 受浏览器最大连接数限制, 尤其是在多
Tab多连接场景下。 - 部分老旧浏览器(如
IE) 不支持。
单向通信的特性也也限制了它的适用场景:
- 实时日志输出(如构建状态、
CI日志) - 消息通知(系统消息、订单状态)
- 股票、天气等实时数据流
- 聊天室中只需要服务器广播消息的情况