nextjs接入AI实现流式输出

72 阅读10分钟

前言

这几天我在尝试实现给我的项目接入的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 打字机、通知、日志实时推送

工作流程:

  1. 浏览器发请求
  2. 服务端返回 text/event-stream
  3. 持续推送 data: xxx
  4. 前端 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 生成 最主流、最可控的方案。

特点:

  1. 基于 fetch + ReadableStream,
  2. 双向:客户端先发请求 → 服务端流式回包
  3. 支持 POST、header、body
  4. 灵活,支持文本 / JSON / 二进制流
  5. 支持中断(AbortController)
  6. 现代浏览器全支持(兼容性好)
  7. 前端完全可控

缺点:

  • 不能自动重连
  • 代码比 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 提供的底层流能力:

  1. 创建 ReadableStream
  2. 分块返回数据
  3. 前端 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 增量解析、 结构容错、 异常兜底、 可中断生成、 预览

直接使用存在明显问题:

  1. AI 返回的不是纯文本,而是半完整 JSON 片段

    例如:{"type": "input", "lab这时JSON.parse(chunk) // ❌ 直接报错

  2. 场景不是文本流,组件可能跨多个 chunk,无法直接渲染

    导致:UI 抖动 / 渲染错误 / 状态污染

  3. 无渲染控制,频繁 setState 导致卡顿

  4. AI 输出不稳定(核心问题)

    导致:JSON 结构错误 字段缺失 顺序不固定 甚至中途断流

    Next.js 不会帮我兜底

因此:Next.js 负责“流的传输”,但业务必须自己实现“流的消费策略”

接收
  • SSE❌

    • 只支持文本流,我是JSON(结构化数据)
    • 无法chunk 级别控制(更底层);
  • WebSocket 太重,维护复杂,不适合单向生成场景 成本高、没必要。❌

  • axios 不支持读取 response.body❌

    • 无法获取 ReadableStream,只能拿到最终完整数据。无法做流式分段读取。
  • fetch + ReadableStream✅

    • 基于 HTTP,兼容性好
    • 可以完全控制 chunk 的读取、拼接和解析逻辑
    • 可配合 AbortController 中断
    • 非常适合 AI 返回的非稳定结构化数据流
流式渲染方案
  • 组件级渲染:
    • 边解析边渲染组件,
    • 复杂、性能差、不稳定
  • 结束后一次性渲染:简单、稳定、符合项目轻量化定位

考虑到成本,复杂程度,项目轻量级;最终选择第二种:

流式仅展示生成过程,不实时渲染组件;点击预览再统一解析渲染

避免组件级流式渲染带来的复杂度和不稳定问题

2. 最终方案

根据上面总结,暂时设计出了以下方案:

选型
  1. 传输:nextjs内置输出+ (fetch + ReadableStream)
  2. 解析:TextDecoder + 安全 JSON 解析
  3. 展示:(打字机过程展示 → 结束统一渲染)
    • 流 → 仅用于“生成过程展示”
    • 最终 JSON → 一次性解析 → 渲染组件

实现功能:

  • 取消请求功能
  • json容错
  • Schema 校验
  • 数据兜底
  • 节流优化
  • 错误提示
  • ...
错误处理策略
  1. 流异常处理
    • 网络中断 → try/catch 捕获
    • 用户取消 → AbortController
  2. JSON 解析容错
    • safeParseJSON
    • 正则提取 JSON
  3. Schema 校验
    • 防止非法结构进入渲染层
  4. 默认值兜底 type: item.type || 'input'
  5. 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 校验与数据兜底

通过这种方式,实现了生成与渲染的解耦,在保证用户体验的同时,也降低了系统复杂度与不确定性。