DeepSeek API 流式输出实战:打造流畅的 AI 对话体验

1,183 阅读6分钟

引言

之前写过 关于如何调用 DeepSeek 开放平台 API 接口的文章。但是当时只是基于 fetch 的一个调用, 但其实对于大模型, 当我们和它对话, 它需要将我们输入的内容进行 Token 化、之后再进行推理、最后输出答案, 而这整个过程中是需要一定时间的, 随着我们 Token 量的增加, 大模型推理输出时间也会随着增加。所以市面上大模型对话基本都是流式输出的, 也就是一点点回吐内容, 这样相比最后一次性给出答案, 可以给用户一个比较好的体验。

下面我们来通过一个简单 DEMO 来演示下作为前端我们要如何处理大模型的流式输出:

ScreenFlow

一、什么是流式输出

流式输出 是一种像水流一样, 一边传输、一边消费的方式。就类比于视频观看, 在观看视频期间我们是一遍下载视频一遍观看, 并不需要等待视频全部加载完成后才能观看。

举个实际的流式输出例子: 在 ChatGPT 中 AI 的回答是一行一行地 "打" 出来的, 这就是所谓的 "流式输出"GPT 模型一遍推理生成回复(文字), 一边将内容推送给你阅读。因为不用等完整回答完成才展示给用户, 所以即便模型回答得很慢, 也不会 "卡住" 用户体验。相比之下, "非流式输出" 就是等模型把完整回答生成完, 一次性传回前端, 如此慢、无反馈、也不友好。

image

二、对话交互实现(非流式)

项目情况说明:

  1. 项目是 NextJS 项目, 这里我是直接通过官方脚手架搭建起来的, 关于 NextJS 搭建可以看我之前写的这篇文章 Next 项目搭建指南(写着写着就 3 万多字了~~~)
  2. 组件的话我这边用的是 HeroUI, 其实就是之前的 NextUI 只是它后面改名了
  3. 样式这边使用了 Tailwind

2.1 整体框架布局

如下代码:

  1. 状态 value 和文本输入框 Textarea 进行双向绑定, 目的是为了实时获取用户输入的内容
  2. 状态 messages 则是一个列表, 存储所有消息, 包括用户发送的消息、大模型回复的消息, 这里通过 role 来区分
  3. 整体布局的话, 输入框部分采用定位, 定位到屏幕的底部
  4. 消息列表则采用 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;

最后整体效果如下:

image

2.2 交互实现

上面我们实现了一个静态的效果, 我们先把交互加上...

  1. 开始前先把写个请求函数: 调用 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;
  1. 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;
  1. 看一眼接口返回情况: 主要就是看下请求响应的数据结构, 如下我们只需要 choices[0].message 字段

image

  1. 接下来不将整个逻辑串起来, 交互细节如下:
  • 用户输入内容后, 点击发送按钮
  • 会将用户输入消息存储起来, 同时调用 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;

下面是完整效果(剪辑了点, 速度快了点)

ScreenFlow

三、流式(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;

然后我们随便在输入框中输入点内容, 然后点击发送, 看下请求的情况

image image

这里接口返回的是一个 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;

最后查看打印内容, 得到如下结论:

  1. 一次返回内容可能包含多条数据
  2. 每条数据都是以 data: 作为开头, 后面则紧跟着具体的内容,
  3. 内容部分除了最后一条, 其他的都是一个 JSON 字符串, 即有效的数据, 也是我们要的数据
  4. 最后一条内容都是 [DONE] 表示, 流数据推送结束
  5. done 字段表示流数据是否推送结束
  6. 所以这里我们有两个方法可以判断流数据是否推送结束, 根据 done 字段或者推送内容是否为 [DONE]

image

最后我们改写下 sendMessage 函数:

  1. 函数参数做了改造, 新增了 onMessage 回调, 通过它我们希望可以实时监听到新的数据
  2. 根据返回数据结构, 针对数据进行处理, 获取到有效数据后, 依次调用回调函数 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;

最后我们再改造下交互逻辑:

  1. 删除了 loading 状态
  2. 用户发送消息事件函数逻辑做了比较大的调整, 在用户发送时直接新增两条消息, 一条是用户发送的消息、一条则是大模型消息, 然后再每次流数据推送时, 去修改最后一条消息的内容即可
...

+ 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;

最后效果如下:

ScreenFlow

四、原生 SSE

上面我们调用 DeepSeek 是采用原生的 fetch 进行的, 而实际上 Deepseek API 和其他大部分大模型 AI 返回的流式输出数据都是符合标准的 Server-Sent Events(SSE) 规范的, 并且现代浏览器几乎都支持更简单的 SSE API

只是可惜的是我们目前暂时无法直接在前端使用它。这是因为根据标准, SSE 的底层只支持 HTTP GET, 并且不能携带自定义的 Header, 然而 DeepSeek 的授权却需要将 API Key 通过 HeaderAuthorization 进行传输, 而且 API 也要求必须使用 POST 请求。

那么如果前端一定要使用标准的 SSE 来处理流式输出可以吗? 当然是可以的啦, 只是我们就需要多一层 BFF 服务, 其实就是写一个简单的服务端, 专门为前端来做接口的转发, 将 DeepSeek 接口转为标准的 SSE

4.1 BFF

在项目 APP 目录下新建路由(src/app/api/ds-chat/route.ts):

image

完整代码如下:

  1. 新增一个 GET 接口, 接口允许传一个 question 参数
  2. 接口内逻辑比较简单, 就是获取了下请求参数 -> 调用 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=你好啊, 页面中将缓慢输出如下内容

image

到此我们 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 有以下优点:

  1. 实现简单: SSE 是基于 HTTP 协议的, 前端只需要一个 EventSource 实例, 端只需输出 text/event-stream 格式的数据即可。
  2. 自动重连机制: 浏览器原生支持断线重连(自动尝试重新连接服务器)需要额外处理断线逻辑。
  3. 支持事件 ID & 恢复机制: SSE 支持 Last-Event-ID 头, 结合服务端 id 字段, 可以实现断线后的数据恢复, 避免数据丢失。
  4. 单向推送足够时是更轻量的选择: 如果只是服务端向客户端单向推送(如通知、状态更新、日志流等), SSEWebSocket 更轻量、稳定, 且资源占用更低。
  5. 天然支持文本数据: 支持 UTF-8 编码的文本数据, 使用简单直观。
  6. 浏览器支持较好: 主流浏览器(ChromeFirefoxSafariEdge)均支持 EventSource 使用门槛也低。

当然局限性也是有的:

  1. 只支持单向通信(服务端 -> 客户端), 不能像 WebSocket 那样双向通信。
  2. 不支持二进制数据(如 BlobArrayBuffer), 仅支持 UTF-8 文本。
  3. 受浏览器最大连接数限制, 尤其是在多 Tab 多连接场景下。
  4. 部分老旧浏览器(如 IE) 不支持。

单向通信的特性也也限制了它的适用场景:

  1. 实时日志输出(如构建状态、CI 日志)
  2. 消息通知(系统消息、订单状态)
  3. 股票、天气等实时数据流
  4. 聊天室中只需要服务器广播消息的情况

五、参考