在开发一个大模型聊天模块时,我们最常被问到的问题不是“用了哪个模型”,而是——
“为什么我发完消息要等好几秒才出结果?能不能快一点?”
其实答案很明确:模型生成本身无法瞬间完成。但我们可以换一种方式呈现——让用户感觉“它正在思考”。这就是 流式输出 的意义所在。
我们的实战项目继续聚焦于一个核心场景:构建一个支持流式响应的大模型聊天功能。我们将从实际需求出发,层层剖析流式输出的实现过程、设计思想、用户体验优化,以及如何借助 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 实时转发给前端。
核心步骤如下:
- 接收前端传来的
messages数组 - 调用大模型 API,设置
stream: true - 获取其
response.body(ReadableStream) - 使用
TextDecoder解码二进制流 - 按行解析 SSE 格式(
data: {...}) - 提取
delta.content字段,写入前端响应流 - 遇到
[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 时代,最快的响应不是“立刻给出答案”,而是“立刻告诉用户:我听见了,正在想。”
流式输出,正是这句话的技术实现。
如果你正在做聊天功能,不妨现在就加上流式支持。当你看到第一个字符缓缓浮现时,你就知道——这才是智能对话该有的样子。