我的实战项目(十二)- 丝滑对话体验:大模型流式输出聊天模块

17 阅读8分钟

在开发一个大模型聊天模块时,我们最常被问到的问题不是“用了哪个模型”,而是——

“为什么我发完消息要等好几秒才出结果?能不能快一点?”

其实答案很明确:模型生成本身无法瞬间完成。但我们可以换一种方式呈现——让用户感觉“它正在思考”。这就是 流式输出 的意义所在。

我们的实战项目继续聚焦于一个核心场景:构建一个支持流式响应的大模型聊天功能。我们将从实际需求出发,层层剖析流式输出的实现过程、设计思想、用户体验优化,以及如何借助 ai-sdk 这类第三方封装提升开发效率。


一、目标明确:我们要做什么?

假设你正在做一个类似 ChatGPT 或 DeepSeek 的网页聊天应用,基本功能包括:

  • 用户输入问题
  • 发送给后端
  • 后端调用大模型 API(如 DeepSeek、通义千问)
  • 实时返回生成内容,逐字显示
  • 支持多轮对话历史展示

其中最关键的一步就是:如何让 AI 的回复像“打字机”一样一行行冒出来?

这背后的技术,叫做 流式输出(Streaming)


二、流式输出的本质:不是“更快”,而是“更及时”

很多人误以为“流式 = 更快”。其实不然。

模型生成 100 个 token 的总耗时不会因为流式而减少。真正改变的是 用户对延迟的感知

对比两种模式:

模式响应方式用户感受
普通请求等待全部生成完毕后一次性返回黑屏等待,怀疑是否卡住
流式输出边生成边返回,前端逐步渲染看见文字不断出现,知道系统在工作

就像看视频时,“缓冲完再播” 和 “边下边播” 的体验差异。

所以,流式的本质是:

把计算的时间成本,转化为可感知的交互反馈。


三、技术实现路径:从前端到后端的完整链路

我们来拆解整个流程中的关键环节。

1. 前端:用 useChat() 快速接入流式能力

直接操作 fetch + ReadableStream 是可行的,但代码复杂且容易出错。更好的做法是使用成熟的 SDK。

Vercel 提供的 @ai-sdk/react 就是一个典型例子。它通过 useChat() 提供了开箱即用的流式聊天能力。

const {
  messages,
  input,
  handleInputChange,
  handleSubmit,
  isLoading
} = useChat({
  api: '/api/ai/chat'
});

这个 Hook 内部已经处理了:

  • 消息列表管理(自动追加用户和 AI 消息)
  • 输入框状态同步
  • 表单提交逻辑
  • 流式数据接收与拼接
  • 加载状态控制
  • 错误捕获

你只需要专注 UI 展示即可:

{messages.map((m) => (
  <div key={m.id} className={`message ${m.role}`}>
    {m.content}
  </div>
))}

更重要的是,它定义了一套通用的数据协议(如 0:"text" 表示文本),使得前后端可以统一通信格式,便于后期扩展工具调用、函数执行等功能。

✅ 使用建议:不要重复造轮子。对于标准聊天场景,优先使用 useChat;只有在需要高度定制时才自己实现底层逻辑。


2. 后端:做“管道”而非“仓库”

很多开发者一开始会这样写后端:

// ❌ 错误做法:收集所有内容后再返回
const response = await fetch('https://api.deepseek.com/...', {
  body: JSON.stringify({ stream: true })
});

let fullText = '';
for await (const chunk of parseStream(response)) {
  fullText += chunk;
}
res.json({ reply: fullText }); // 等全部结束才返回

这完全失去了流式的意义。

正确的做法是:建立一条“数据管道”,将 LLM 输出的每一个 token 实时转发给前端。

核心步骤如下:

  1. 接收前端传来的 messages 数组
  2. 调用大模型 API,设置 stream: true
  3. 获取其 response.body(ReadableStream)
  4. 使用 TextDecoder 解码二进制流
  5. 按行解析 SSE 格式(data: {...}
  6. 提取 delta.content 字段,写入前端响应流
  7. 遇到 [DONE] 结束连接

关键代码逻辑:

req.on('end', async () => {

当接收到前端发送的完整请求体后,开始处理。HTTP 请求体是分块传输的,req.on('end') 表示所有数据已经接收完毕,此时才能安全地解析内容。

  const { messages } = JSON.parse(body);

解析前端传来的 JSON 数据,提取 messages 字段。这是聊天上下文的核心,包含用户和 AI 的历史对话,用于模型生成有上下文的回答。

  const upstreamRes = await fetch('https://api.deepseek.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages,
      stream: true
    })
  });

向 DeepSeek 的大模型 API 发起请求:

  • 使用 fetch 调用远程接口;
  • 设置认证头 Authorization,确保身份合法;
  • Content-Type: application/json 告知对方发送的是 JSON;
  • 请求体中指定模型名称、对话历史,并关键设置 stream: true —— 这是开启流式输出的开关,告诉 API 不要等全部生成完再返回,而是边生成边推送。
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');

设置响应头,声明返回的内容类型为纯文本、UTF-8 编码。虽然实际传输的是结构化数据片段,但这里不使用 application/json 是因为我们要持续写入多个 chunk,而不是一次性返回一个 JSON 对象。

  res.setHeader('Transfer-Encoding', 'chunked');

启用 HTTP 分块传输编码(Chunked Transfer Encoding)。这意味着服务器可以在不知道总长度的情况下持续发送数据块,浏览器会一边接收一边处理,不会等待整个响应结束。

  const reader = upstreamRes.body.getReader();

获取来自 DeepSeek API 响应体的可读流(ReadableStream)的读取器。这是实现流式转发的关键:我们不需要等待全部数据到达,而是通过 reader.read() 主动拉取每一个数据块(chunk)。

  const decoder = new TextDecoder();

创建一个 TextDecoder 实例,用于将二进制数据(如 Uint8Array)解码为 UTF-8 字符串。因为网络传输的数据是字节流,必须解码才能得到人类可读的文本。

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

进入循环读取模式:

  • 每次调用 reader.read() 会返回一个 Promise,解析出 { done, value }
  • done: true 表示流已结束(即模型完成生成);
  • value 是当前收到的二进制数据块;
  • 只要没结束,就继续读取下一个 chunk。
    const text = decoder.decode(value);

将二进制 value 解码为字符串 text。注意:这里可能只解码一部分,后续 chunk 中可能还有未完成的字符(如中文被截断),但 TextDecoder 默认行为是容错处理,保留残缺部分直到下一次补全。

    const lines = text.split('\n');

DeepSeek 的流式响应采用 SSE(Server-Sent Events)格式,每一行是一个独立的消息单元,以换行符 \n 分隔。因此按行切割,便于逐条处理。

    for (const line of lines) {
      if (line.startsWith('data: ') && line !== 'data: [DONE]') {

遍历每一行数据:

  • 只处理以 data: 开头的行(标准 SSE 格式);
  • 排除 data: [DONE] —— 它表示流的终结信号,无需转发给前端。
        try {
          const data = JSON.parse(line.slice(6));

去掉前缀 data: (共6个字符),然后将剩余部分解析为 JSON 对象。这个对象通常包含 choices[0].delta.content 字段,代表新增的文本片段。

          const content = data.choices[0]?.delta?.content;

提取增量内容(delta content)。由于是流式生成,每次只返回新生成的一个或几个 token(比如一个词、一个字),这就是 delta 的含义。

          if (content) {
            res.write(`0:${JSON.stringify(content)}\n`);
          }

如果存在新内容,则将其写入响应流:

  • 0:@ai-sdk 定义的协议前缀,表示这是一段普通文本;
  • 使用 JSON.stringify 确保特殊字符(如引号、换行)被正确转义;
  • \n 作为分隔符,让前端能逐条读取;
  • res.write() 不会关闭连接,允许后续继续写入。
        } catch (e) {}
      }
    }
  }

解析失败时静默处理(例如空行或非 JSON 数据)。这类异常常见于流式传输中的中间状态,不影响整体流程。

  res.end();

所有数据转发完成后,显式关闭响应连接。这会通知前端“流已结束”,触发 useChat 中的加载状态关闭,并完成本次交互。


总结一句话:

这段代码的本质,是构建一条从大模型到用户的“信息管道”:前端一发问,后端立刻向 LLM 转发请求;LLM 每生成一个 token,后端就立刻解码、提取、封装并回传给浏览器;整个过程零缓存、低延迟,让用户看到文字像打字一样逐字浮现——这才是现代 AI 聊天应有的体验。


四、用户体验设计:不只是技术,更是心理

流式输出的价值最终体现在用户体验上。

1. 视觉反馈:让用户知道“它没死”

当用户发送消息后,立即显示一个“正在输入…”的动画或省略号:

{isLoading && (
  <div className="ai-message">
    <span className="typing">...</span>
  </div>
)}

哪怕第一个 token 还没回来,也要先给个回应。

2. 渐进式呈现:模拟人类打字节奏

虽然模型可能每 100ms 输出一个 token,但我们不必每个都立刻渲染。可以适当节流,模拟自然打字速度,避免“闪屏”。

也可以加入轻微延迟,增强“思考感”。

3. 中断机制:允许用户喊停

提供“停止生成”按钮,在长回答场景非常实用。

实现原理也很简单:前端 abort 请求,后端关闭连接即可。

const { stop } = useChat();
<Button onClick={stop}>停止</Button>

五、为什么要用 ai-sdk?封装带来的价值

也许你会想:“我自己也能实现流式,何必依赖第三方?”

确实可以,但代价是你需要处理大量边缘情况:

问题ai-sdk 已解决
消息 ID 管理自动生成唯一 id
滚动到底部自动 scrollIntoView
多轮上下文维护自动拼接 messages
浏览器兼容性兼容各种 ReadableStream 实现
错误重试可配置 retry 机制
协议扩展支持 tool_call、function calling 等未来特性

更重要的是,它提供了一个 标准化接口,让你可以在不同项目间复用逻辑,甚至更换底层模型也不影响前端代码。

类比:就像你不需每次写 HTTP 客户端,而是用 axios;同理,你不该每次都手写流式解析。


六、总结:流式输出是现代 AI 应用的基本功

回到最初的目标:你在做大模型聊天模块。那么,请记住以下几点:

✅ 正确的做法:

  • 使用 useChat 等成熟 Hook 快速搭建前端逻辑
  • 后端充当“流式代理”,不缓存、低延迟转发
  • 设置正确的响应头(chunked 编码)
  • 解析 SSE 数据,提取 delta 内容
  • 注重用户体验:加载态、中断、滚动

❌ 避免的误区:

  • 把整个响应体收集完再返回(失去流的意义)
  • 忽视编码问题导致中文乱码
  • 不做错误处理,流中断后无提示
  • 过度追求“极致性能”而牺牲可维护性

最后一句话

在 AI 时代,最快的响应不是“立刻给出答案”,而是“立刻告诉用户:我听见了,正在想。”

流式输出,正是这句话的技术实现。

如果你正在做聊天功能,不妨现在就加上流式支持。当你看到第一个字符缓缓浮现时,你就知道——这才是智能对话该有的样子。