前言
这几天我在尝试实现给我的项目接入的ai实现流式输出,也是遇到不少问题;所以在这里总计一下我整个方案设计和实现的过程,希望有大佬来批评指正
技术分析
为什么要做流式
传统请求
来认识流式之前先来回顾一下传统请求的流程:
请求 → 服务端处理完 → 一次性返回 → 前端渲染
传统流程一次性返回数据,适用于普通请求;
但在 AI 场景下,这种模式完全不可行:
- AI 生成时间长(3~10 秒)
- 用户长时间白屏
- 无实时反馈,体验极差
流式渲染
流式渲染完美解决 AI 场景的痛点:
请求 → 服务端边生成边返回 → 前端边收边渲染
核心思想是:让数据一边生成,一边展示,而不是等全部完成再渲染。
效果就像 ChatGPT:字是一点点蹦出来的,不是一次性出现。
实现原理
流式输出依赖两个底层技术:
1️⃣ HTTP 分块传输(Chunked)
- 响应头:Transfer-Encoding: chunked
- 不需要 Content-Length
- 服务端生成一点,发送一点
2️⃣ HTTP 长连接 keep-alive
请求建立后不断开,持续推送数据直到结束。
实现流式的方式
1. SSE(Server-Sent Events)
标准、轻量的服务端主动推送方案。
- 单向:服务器 → 客户端
- 基于 HTTP,原生支持重连
- 简单、轻量
缺点:单向通信、仅支持文本、不适合复杂流控
适合:AI 打字机、通知、日志实时推送
工作流程:
- 浏览器发请求
- 服务端返回 text/event-stream
- 持续推送 data: xxx
- 前端 onmessage 接收
示例:
const es = new EventSource('/api/sse');
// 接收消息
es.onmessage = (e) => {
console.log(e.data);
};
// 错误
evtSource.onerror = (err) => {
console.error("SSE 错误", err);
};
// 关闭
// evtSource.close()
服务端响应头
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
2. HTTP Streaming(fetch + ReadableStream)
目前 AI 对话、AI 生成 最主流、最可控的方案。
特点:
- 基于 fetch + ReadableStream,
- 双向:客户端先发请求 → 服务端流式回包
- 支持 POST、header、body
- 灵活,支持文本 / JSON / 二进制流
- 支持中断(AbortController)
- 现代浏览器全支持(兼容性好)
- 前端完全可控
缺点:
- 不能自动重连
- 代码比 SSE 稍多
- 需要手动处理流
适用:自定义流协议、AI 流式输出
代码示例:
const res = await fetch('/api/stream');
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(new TextDecoder().decode(value));
}
3. WebSocket(全双工通信)
更强但更复杂
特点:
- 基于 ws 协议
- 双向实时通信(client ↔ server)
- 长连接、低延迟
缺点:
- 比 SSE 重
- 服务端需要单独支持 WS 协议
- 无原生重连(需自己写)
适用于: 聊天、协同编辑、直播、游戏、高频交互等需要实时反馈场景
const ws = new WebSocket("ws://localhost:3000/ws");
// 连接成功
ws.onopen = () => {
ws.send("客户端发送消息");
};
// 接收消息
ws.onmessage = (e) => {
console.log("收到:", e.data);
};
// 关闭
ws.onclose = () => {};
nextjs内置流式
由于我的项目用的是nextjs,内置有两种不同层面的流式,感觉容易搞混,这里特意整理一下
Route Handler 流式(API 流式)
Next.js 提供的底层流能力:
- 创建 ReadableStream
- 分块返回数据
- 前端 fetch → 一段一段读
本质:底层传输能力,不是业务级解决方案。
// app/api/ai/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode('{"a":1}'))
await sleep(1000)
controller.enqueue(encoder.encode('{"b":2}'))
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'application/json'
}
})
}
const res = await fetch('/api/ai')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
console.log('当前buffer:', buffer)
}
React Streaming SSR(页面流式)
这是页面加载优化,基于 <Suspense>,与今天的ai流式本质不同,无关,只做区分
用途:
- 优化页面加载速度
- 先出导航、骨架→ 再出慢数据
- SSR 分段渲染 HTML
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
补充:数据格式
⚠️ 需要注意,DeepSeek 返回的并不是“纯文本流”,而是类似 SSE 的结构:
data: {"choices":[{"delta":{"content":"字段"}}]}
data: {"choices":[{"delta":{"content":"继续"}}]}
data: [DONE]
因此前端不能直接拼接 chunk,而需要:
1. 去掉 data: 前缀
2. 过滤 [DONE]
3. 逐行解析 JSON
4. 提取 delta.content 字段
否则会导致 JSON.parse 失败或数据污染。
实现
方案设计
1.方案对比
输出(后端)
- nextjs流式
虽然 Next.js 已经提供了 Route Handler 流式能力(ReadableStream),但在我的项目场景下(
AI 返回 JSON 结构 → 解析 → 渲染组件 → 预览 → 确认 → 进入画布)
涉及:
JSON 增量解析、
结构容错、
异常兜底、
可中断生成、
预览
直接使用存在明显问题:
-
AI 返回的不是纯文本,而是半完整 JSON 片段
例如:
{"type": "input", "lab这时JSON.parse(chunk) // ❌ 直接报错 -
场景不是文本流,组件可能跨多个 chunk,无法直接渲染
导致:UI 抖动 / 渲染错误 / 状态污染
-
无渲染控制,频繁 setState 导致卡顿
-
AI 输出不稳定(核心问题)
导致:JSON 结构错误 字段缺失 顺序不固定 甚至中途断流
Next.js 不会帮我兜底
因此:Next.js 负责“流的传输”,但业务必须自己实现“流的消费策略”
接收
-
SSE❌
- 只支持文本流,我是JSON(结构化数据)
- 无法chunk 级别控制(更底层);
-
WebSocket 太重,维护复杂,不适合单向生成场景 成本高、没必要。❌
-
axios 不支持读取 response.body❌
- 无法获取 ReadableStream,只能拿到最终完整数据。无法做流式分段读取。
-
fetch + ReadableStream✅
- 基于 HTTP,兼容性好
- 可以完全控制 chunk 的读取、拼接和解析逻辑
- 可配合 AbortController 中断
- 非常适合 AI 返回的非稳定结构化数据流
流式渲染方案
- 组件级渲染:
- 边解析边渲染组件,
- 复杂、性能差、不稳定
- 结束后一次性渲染:简单、稳定、符合项目轻量化定位
考虑到成本,复杂程度,项目轻量级;最终选择第二种:
流式仅展示生成过程,不实时渲染组件;点击预览再统一解析渲染
避免组件级流式渲染带来的复杂度和不稳定问题
2. 最终方案
根据上面总结,暂时设计出了以下方案:
选型
- 传输:nextjs内置输出+ (fetch + ReadableStream)
- 解析:TextDecoder + 安全 JSON 解析
- 展示:(打字机过程展示 → 结束统一渲染)
- 流 → 仅用于“生成过程展示”
- 最终 JSON → 一次性解析 → 渲染组件
实现功能:
- 取消请求功能
- json容错
- Schema 校验
- 数据兜底
- 节流优化
- 错误提示
- ...
错误处理策略
- 流异常处理
- 网络中断 → try/catch 捕获
- 用户取消 → AbortController
- JSON 解析容错
- safeParseJSON
- 正则提取 JSON
- Schema 校验
- 防止非法结构进入渲染层
- 默认值兜底
type: item.type || 'input' - UI 兜底
-
loading 状态
-
skeleton 占位
-
错误提示
-
3. 具体流程
流 → 过程展示
最终 JSON → 一次性渲染组件
用户输入 → 前端发起请求
↓
Next 路由转发 → AI 流式返回
↓
前端读取流 → 缓冲区拼接 → 打字机展示
↓
流结束 → 获取完整 JSON
↓
安全解析 → 校验 → 数据标准化
↓
用户预览 → 确认 → dispatch修改状态
方案实现
1. 后端实现(Next.js + DeepSeek 流式)
流程:接收前端请求 → 转发给 DeepSeek → 把 AI 的流原样传回前端
// app/api/ai/route.ts
// 系统提示词(强约束)
const systemPrompt = `你是专业的问卷生成器,必须。。。`;
export async function POST(req: Request) {
// 1.拿到主题
const { topic } = await req.json();
try {
// 2. 请求 DeepSeek 大模型,开启流式输出 stream: true
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.DEEPSEEK_KEY, // API密钥
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "deepseek-chat",
stream: true, // ⚠️开启流式输出(关键)
messages: [
{ role: "system", content: systemPrompt }, // 强约束格式
{ role: "user", content: `生成【${topic}】问卷JSON` },
],
}),
});
// 3. 创建 ReadableStream,把 AI 的流 原封不动 传给前端
const stream = new ReadableStream({
async start(controller) {
// 获取DeepSeek返回的流读取器
const reader = response.body!.getReader();
while (true) {
// 循环读取流
const { done, value } = await reader.read();
if (done) break; // 流结束则退出
// 把数据块直接塞给前端(透传,不修改)
controller.enqueue(value);
}
// 流关闭
controller.close();
},
});
// 4. 返回流给前端
// 虽然使用 fetch 流读取,但仍采用 SSE 规范的响应头,保证流式传输的兼容性和稳定性。
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
return new Response("服务异常", { status: 500 });
}
}
2. 前端实现(流读取 + 打字机展示)
只接收流、拼字符串、实时显示 → 不解析、不渲染
// 全局存储当前请求的控制器,用于取消生成
let currentController = null;
async function generate(topic) {
// 1. 创建新的中断控制器,管理本次AI请求
const controller = new AbortController();
currentController = controller;
try {
// 2. 发起流式请求,绑定中断信号
const res = await fetch('/api/ai', {
method: 'POST',
body: JSON.stringify({ topic }),
signal: controller.signal,
});
// 3. 获取流读取器 + 文本解码器(处理中文)
const reader = res.body.getReader();
const decoder = new TextDecoder();
// buffer:临时存储不完整行,防止解析报错
let buffer = '';
// fullText:最终拼接完整的JSON字符串
let fullText = '';
// 4. 循环读取流数据块
while (true) {
const { done, value } = await reader.read();
if (done) break; // 流结束,退出循环
// 5. 解码当前数据块
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 6. 按换行符分割(SSE/大模型流式标准格式)
const lines = buffer.split('\n');
// 最后一行可能不完整,保留到下一轮继续拼接
buffer = lines.pop() || '';
// 7. 遍历每一行完整数据
for (const line of lines) {
// 去掉大模型返回的 data: 前缀
const clean = line.replace(/^data:\s*/, '').trim();
// 空行 或 结束标记 [DONE] 跳过
if (!clean || clean === '[DONE]') continue;
try {
// 8. 解析流式JSON
const parsed = JSON.parse(clean);
// 提取AI返回的内容
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
// 拼接到完整文本
fullText += content;
// 实时更新UI → 打字机效果
setGeneratingText(fullText);
}
} catch {}
}
}
// 9. 流结束 → 解析最终完整JSON并渲染
handleFinalResult(fullText);
} catch (err) {
console.log("请求已取消或异常:", err);
}
}
3. JSON 解析 + 容错(防止 AI 输出异常)
由于ai输出可能包含额外文本或格式污染,因此不能直接 JSON.parse,需要增加容错解析。
// 安全解析 JSON(核心容错)
function safeParseJSON(text) {
try {
// 1. 直接解析(理想情况)
return JSON.parse(text);
} catch {
// 2. 尝试提取最外层 JSON
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try {
return JSON.parse(match[0]);
} catch {}
}
// 3. 彻底失败
return null;
}
}
4. Schema 校验 + 数据兜底(保证页面不崩)
作用:把 AI 返回的脏数据 → 变成标准、安全的组件结构
// 校验数据格式是否合法
function validateSchema(data: any) {
// 必须是对象
if (!data || typeof data !== 'object') return false;
// 必须包含 components
if (!data.components || typeof data.components !== 'object') return false;
return true;
}
// 数据标准化:缺什么补什么,保证页面不崩溃
function normalizeSchema(schema: any) {
const result: any = {};
// 遍历组件,补全默认值
Object.entries(schema.components || {}).forEach(([id, item]: any) => {
result[id] = {
type: item.type || 'input', // 缺类型 → 默认输入框
props: item.props || {}, // 缺配置 → 空对象
style: item.style || {}, // 缺样式 → 空对象
children: item.children || [], // 缺子元素 → 空数组
};
});
return {
components: result, // 标准化后的组件
order: schema.order || Object.keys(result), // 渲染顺序
};
}
5.取消生成
let currentController = null;
// 取消生成
function cancelGenerate() {
if (currentController) {
currentController.abort();
console.log('用户取消生成');
}
}
6. 最终结果处理(预览 + 确认)
/**
* 流结束后处理最终 JSON
*/
function handleFinalResult(fullText) {
// 1. 安全解析
const parsed = safeParseJSON(fullText);
// 2. 格式校验
if (!parsed || !validateSchema(parsed)) {
console.error('AI 返回数据格式错误');
return;
}
// 3. 数据标准化(兜底)
const standardSchema = normalizeSchema(parsed);
// 4. 存入预览状态
setPreviewSchema(standardSchema);
}
// 点击预览
function handlePreview() {
setIsPreview(true);
}
// 确认加入画布
function handleConfirm() {
dispatch({
type: 'ADD_COMPONENTS',
payload: previewSchema,
});
}
后续进阶方向
- 实现增量组件流式渲染,支持批处理调度,每 3~5 个组件批量渲染
- 增加超时/失败重试
- 请求缓存(相同 topic 复用结果)
- 支持生成历史、重新生成
总结
总之在此次方案设计与实现中,我没有采用组件级流式渲染,而是综合进行了工程化取舍,将流式能力用于“生成过程可视化”,最终基于完整 JSON 一次性渲染组件。
同时设计并实现了一套完整的AI输出稳定机制,包括:
- Prompt 强约束
- 流式数据解析
- JSON 容错处理
- Schema 校验与数据兜底
通过这种方式,实现了生成与渲染的解耦,在保证用户体验的同时,也降低了系统复杂度与不确定性。