Next.js 实现AI流式输出(打字机效果)

0 阅读7分钟

哈喽, 今天咱们手把手教大家实现 AI 对话的「打字机效果」——也就是流式输出,这是目前所有AI产品(ChatGPT、豆包等)的核心交互体验,学会这套,你就能轻松搭建自己的AI对话应用。

一、先搞懂:什么是AI流式输出?

在学实现之前,咱们先弄明白「流式输出」到底是什么,和普通接口有啥区别,用最通俗的话讲清楚,不搞专业名词堆砌。

1. 普通接口 vs 流式输出

对比维度普通接口流式输出
响应方式AI思考完,一次性返回全部内容AI边思考边返回,逐字/逐句推送
等待体验等待时间长,容易以为程序卡了实时看到内容,交互流畅,像真人打字
技术实现普通HTTP请求,一次请求一次响应SSE协议(服务器向客户端持续推送数据)

2. 流式输出核心原理(图文拆解)

不用记复杂概念,记住这个「流水线」流程,就能理解所有逻辑:

image.png 关键核心:后端不缓存完整响应,而是将AI返回的内容「分片」,持续推送给前端;前端不等待全部内容,收到一片就渲染一片,这就是打字机效果的本质。

二、前期准备

在写代码之前,咱们先准备好所需的工具和环境。

1. 技术栈说明

本文使用的技术栈都是目前最主流、最容易上手的,0基础也能快速适应:

  • 框架:Next.js 16+(App Router)—— 前后端一体化,不用单独搭建后端服务,新手友好
  • 流式协议:SSE(Server-Sent Events)—— 专门用于服务器向客户端持续推送数据,比WebSocket简单
  • AI模型:DeepSeek Chat —— 便宜好用,接口简单,适合新手调试(也可替换成ChatGPT、豆包等)
  • 样式:TailwindCSS —— 不用写复杂CSS,复制类名就能实现美观样式
  • 其他:TypeScript —— 类型提示,减少报错。

2. 环境准备步骤

  1. 创建Next.js项目:终端输入命令npx create-next-app@latest ai-stream-demo,一路回车(记得选TypeScript、TailwindCSS)。
  2. 获取DeepSeek API Key:去DeepSeek官网(www.deepseek.com/)注册账号,进入控制台… Key

3. 环境变量配置

在项目根目录,创建一个 .env.local 文件(注意前面有个点),复制下面的内容,把你的DeepSeek API Key填进去,保存即可:

DEEPSEEK_API_KEY=你的DeepSeek API Key(替换成自己的)
DEEPSEEK_MODEL=deepseek-chat

⚠️ 注意:API Key不要泄露,不要提交到代码仓库,.env.local文件会自动被Next.js忽略,放心填写。

三、后端实现

后端的作用:接收前端的问题,校验用户登录(可选),转发请求到DeepSeek AI,开启流式输出,再把AI返回的数据流「透传」给前端。

咱们用Next.js的Route Handler(API路由)来写后端,不用单独搭建Node.js服务,非常方便。

1. 创建后端API路由

在项目中创建路径:app/api/ai/stream/route.ts(文件夹一层层创建,不要错),然后复制下面的完整代码,每一行都有详细注释,看不懂的地方看注释就好。

// app/api/ai/stream/route.ts
import { NextResponse } from "next/server";
// 导入用户登录校验方法(后面会补充,暂时不用管,没有可以先注释)
import { getCurrentUser } from "@/lib/current-user";

// 关键配置:必须用nodejs运行时,Edge运行时不支持流式透传
export const runtime = "nodejs";
// 禁用缓存,每次请求都重新处理,保证流式实时性
export const dynamic = "force-dynamic";

// 定义聊天角色类型(限制只能是system、user、assistant,避免脏数据)
type ChatRole = "system" | "user" | "assistant";
// 定义聊天消息格式(角色+内容)
type ChatMessage = {
  role: ChatRole;
  content: string;
};

// 处理前端POST请求(前端发送问题,后端接收并处理)
export async function POST(request: Request) {
  // 1. 登录校验(可选,不想做可以注释掉,直接跳过)
  // const user = await getCurrentUser();
  // if (!user) {
  //   // 未登录,返回401错误,前端会跳转到登录页
  //   return NextResponse.json({ code: 1, msg: "未登录" }, { status: 401 });
  // }

  // 2. 校验DeepSeek API Key是否配置(避免报错)
  const apiKey = process.env.DEEPSEEK_API_KEY;
  if (!apiKey) {
    return NextResponse.json(
      { code: 1, msg: "服务端未配置DEEPSEEK_API_KEY,请检查.env.local文件" },
      { status: 500 }
    );
  }

  // 3. 接收前端传递的参数(问题内容+历史对话)
  const body = await request.json();
  // 提取前端发送的问题,去除前后空格
  const message = typeof body?.message === "string" ? body.message.trim() : "";
  // 提取历史对话(用于上下文连贯,比如问完“什么是低碳”,再问“怎么做到”,AI能记住上一个问题)
  const historyInput = Array.isArray(body?.history) ? body.history : [];

  // 4. 校验问题是否为空(避免无效请求)
  if (!message) {
    return NextResponse.json(
      { code: 1, msg: "消息不能为空,请输入你的问题" },
      { status: 400 }
    );
  }

  // 5. 过滤历史对话,防止脏数据(比如前端传递的格式错误)
  const history: ChatMessage[] = historyInput
    .map((item: unknown) => {
      // 校验每一条历史消息的格式:必须是对象,有role和content字段
      if (!item || typeof item !== "object") return null;
      const role = (item as { role?: unknown }).role;
      const content = (item as { content?: unknown }).content;
      // 角色只能是system、user、assistant,内容必须是字符串
      if (
        (role !== "system" && role !== "user" && role !== "assistant") ||
        typeof content !== "string"
      ) {
        return null;
      }
      return { role: role as ChatRole, content: content.trim() } as ChatMessage;
    })
    // 过滤掉无效的历史消息,只保留最近20条(避免上下文过长,AI接口报错)
    .filter((item: ChatMessage | null): item is ChatMessage => !!item && !!item.content)
    .slice(-20);

  // 6. 调用DeepSeek AI接口,开启流式输出
  const upstream = await fetch("https://api.deepseek.com/chat/completions", {
    method: "POST",
    headers: {
      // 身份验证:用DeepSeek API Key
      Authorization: `Bearer ${apiKey}`,
      // 数据格式:JSON
      "Content-Type": "application/json",
      // 声明接收流式数据(SSE)
      Accept: "text/event-stream",
    },
    body: JSON.stringify({
      model: process.env.DEEPSEEK_MODEL || "deepseek-chat", // AI模型
      stream: true, // 关键:开启流式输出,让AI逐字返回
      temperature: 0.7, // 随机性:0-1,越小越严谨,越大越灵活
      messages: [
        // 系统提示:定义AI的角色(这里是绿色生活助手,可自定义)
        {
          role: "system",
          content: "你是绿色生活助手,回答需准确、简洁、可执行,语言亲切,适合新手理解。",
        },
        ...history, // 历史对话(上下文连贯)
        { role: "user", content: message }, // 前端当前发送的问题
      ],
    }),
  });

  // 7. 处理AI接口错误(比如API Key无效、余额不足)
  if (!upstream.ok || !upstream.body) {
    // 尝试获取错误信息
    const text = await upstream.text().catch(() => "");
    let msg = "DeepSeek AI请求失败,请稍后重试";

    try {
      // 解析AI返回的错误信息(如果是JSON格式)
      const parsed = JSON.parse(text) as { error?: { message?: string } };
      const upstreamMsg = parsed?.error?.message?.trim();
      if (upstreamMsg) {
        msg = upstreamMsg;
      }
    } catch {
      // 如果不是JSON格式,直接用返回的文本作为错误信息
      if (text.trim()) {
        msg = text.trim();
      }
    }

    // 特殊处理:余额不足的提示(更友好)
    if (/insufficient\s*balance/i.test(msg)) {
      msg = "DeepSeek API余额不足,请充值后重试或更换可用的API Key。";
    }

    // 返回错误信息给前端
    return NextResponse.json(
      {
        code: 1,
        msg,
      },
      { status: upstream.status || 500 }
    );
  }

  // 8. 关键:将AI返回的数据流,直接透传给前端(最高效的流式实现)
  // 不需要自己解析数据,直接把上游的流返回给前端,减少后端压力
  return new Response(upstream.body, {
    headers: {
      // 声明响应格式是SSE(流式)
      "Content-Type": "text/event-stream; charset=utf-8",
      // 禁止缓存,保证实时性
      "Cache-Control": "no-cache, no-transform",
      // 保持长连接,让服务器能持续推送数据
      "Connection": "keep-alive",
      // 禁止Nginx缓冲(部署时必须加,否则流式会失效)
      "X-Accel-Buffering": "no",
    },
  });
}

2. 后端核心知识点拆解

不用死记代码,记住这5个关键知识点,就能理解后端流式的核心:

  • runtime = "nodejs" :必须设置,因为Edge运行时不支持完整的ReadableStream(数据流)透传,只有Node.js运行时才能稳定实现流式输出。
  • stream: true:给DeepSeek API传递的参数,开启这个,AI才会逐字返回,而不是一次性返回全部内容。
  • upstream.body:DeepSeek返回的数据流,我们直接透传给前端,不用自己解析,这样性能最高、延迟最低。
  • SSE响应头:4个响应头缺一不可,尤其是X-Accel-Buffering: no,部署到Nginx时如果没有这个,流式会失效(后面会讲部署注意事项)。
  • 历史对话过滤:slice(-20)是为了限制上下文长度,避免AI接口因为上下文过长报错,新手可以直接沿用这个设置。

3. 可选:用户登录校验(补充)

如果想做登录校验(比如只允许登录用户使用AI),创建lib/current-user.ts文件,复制下面的代码(简单模拟登录,实际项目可替换成自己的登录逻辑):

// lib/current-user.ts
// 简单模拟获取当前登录用户(实际项目可替换成JWT、Session等)
export async function getCurrentUser() {
  // 这里模拟已登录,返回一个用户信息(实际项目中需要从cookie、token中获取)
  return {
    id: "123",
    displayName: "AI流式初学者",
    username: "stream-demo",
  };

  // 模拟未登录:return null;
}

四、前端实现(打字机效果,核心交互)

前端的作用:展示对话界面、接收用户输入、发送请求给后端、接收后端推送的数据流、逐字渲染内容,实现打字机效果,还要处理加载状态、错误提示等交互。

前端分为两个部分:服务端页面(负责权限校验)和客户端组件(负责流式接收和渲染),咱们一步步来写。

1. 服务端页面(权限校验 + 页面布局)

创建路径:app/ai/page.tsx,这个页面是服务端渲染的,负责校验用户登录状态,未登录则跳转到登录页,登录后展示AI对话界面。

// app/ai/page.tsx
import { redirect } from "next/navigation";
// 导入AI对话客户端组件(后面会写)
import AiClient from "@/app/ai/AiClient";
// 导入导航栏组件(简单模拟,可自定义)
import AppNav from "@/components/AppNav";
// 导入登录校验方法
import { getCurrentUser } from "@/lib/current-user";

// 禁用缓存,每次访问都重新校验登录
export const dynamic = "force-dynamic";

// 服务端组件:校验登录,渲染页面布局
export default async function AiPage() {
  // 获取当前登录用户
  const user = await getCurrentUser();
  // 未登录,跳转到登录页(实际项目中可替换成自己的登录页路径)
  if (!user) redirect("/login");

  return (
    
        {/* 页面标题卡片 */}
        Eco CompanionAI 环保助手
            用温和、可靠的方式陪你完成每一次绿色行动。
          
        {/* AI对话客户端组件(核心) */}
        <AiClient />
        
  );
}

2. 导航栏组件(简单模拟,可选)

创建路径:components/AppNav.tsx,简单实现一个导航栏,显示当前登录用户,新手可以直接复制:

// components/AppNav.tsx
"use client";

interface AppNavProps {
  username: string;
}

export default function AppNav({ username }: AppNavProps) {
  return (
    AI 环保助手
        欢迎,{username}
      
  );
}

3. 客户端组件(流式接收 + 打字机渲染,核心中的核心)

创建路径:app/ai/AiClient.tsx,这个是客户端组件(必须加"use client"),负责所有交互逻辑,也是流式输出的核心。

// app/ai/AiClient.tsx
// 1. 客户端组件标识(面试必说:标记为客户端,才能用useState/useRef等交互API)
"use client";

// 导入核心依赖(面试可提及:明确依赖,体现专业性)
import { useState, useRef, useEffect } from "react";
import VirtualList from "@/components/VirtualList"; // 长列表性能优化核心组件

// 核心1:消息类型约束(避免类型错误,面试可提“做了类型规范”)
type Msg = {
  id: string; // 唯一ID,用于列表渲染,防止错乱
  role: "user" | "assistant"; // 约束发送者,避免类型异常
};

// 核心2:流式对话主组件(面试重点,直接背)
export default function AIChat() {
  // 状态管理:存储消息列表(打字机效果核心)
  const [messages, setMessages] = useState<Msg[]>([
    { id: "welcome", role: "assistant", content: "你好!请输入你的环保相关问题~" }
  ]);
  const [input, setInput] = useState(""); // 输入框内容
  const listRef = useRef<VirtualList>(null); // 虚拟列表引用(性能优化)

  // 核心3:流式请求配置(面试必说)
  const sendMsg = async () => {
    if (!input.trim()) return; // 空输入兜底,避免无效请求
    // 1. 添加用户消息
    const userMsg: Msg = { id: `u_${Date.now()}`, role: "user", content: input };
    setMessages(prev => [...prev, userMsg]);
    setInput("");

    // 2. 发起SSE流式请求(面试必说:Accept设置)
    const res = await fetch("/api/ai/stream", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Accept": "text/event-stream" // 关键:开启流式返回
      },
      body: JSON.stringify({
        message: input,
        history: messages.filter(m => m.id !== "welcome") // 只带有效历史,避免超限
      })
    });

    // 3. 流式解析(面试核心,必背)
    const reader = res.body?.getReader();
    if (!reader) return; // 容错:无流则兜底,不崩页面

    const decoder = new TextDecoder(); // 解码二进制流
    let buffer = ""; // 缓存:解决流截断导致的JSON解析错误

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

      // 解析SSE格式:data: 开头的增量内容
      buffer += decoder.decode(value);
      // 按行拆分,只取有效内容(避免解析报错)
      const lines = buffer.split("\n");
      buffer = lines.pop() || ""; // 未完成的行留到下一次解析

      // 提取AI回复,实时更新(打字机效果关键)
      lines.forEach(line => {
        if (line.startsWith("data:")) {
          const data = JSON.parse(line.slice(5));
          const aiMsg: Msg = {
            id: `a_${Date.now()}`,
            role: "assistant",
            content: data.content // 增量渲染,实现打字机效果
          };
          setMessages(prev => [...prev, aiMsg]);
        }
      });
    }
  };

  // 核心4:自动滚动(面试必说:用requestAnimationFrame保证精准)
  useEffect(() => {
    if (listRef.current) {
      listRef.current.scrollToEnd(); // 结合虚拟列表,避免卡顿
    }
  }, [messages]);

  // 核心5:虚拟列表渲染(面试必说:解决长列表卡顿)
  return (
    <div>
      {/* 虚拟列表:只渲染可视区域,优化性能(面试重点) */}
      <VirtualList items={messages} itemKey={item => item.id}>
        {msg => (
          <div key={msg.id} className={`msg ${msg.role}`}>
            {msg.content}
          </div>
        )}
      </VirtualList>

      {/* 输入区域 */}
      <input
        value={input}
        onChange={e => setInput(e.target.value)}
        onKeyDown={e => e.key === "Enter" && sendMsg()}
        placeholder="输入环保相关问题..."
      />
      <button onClick={sendMsg} disabled={!input.trim()}>发送</button>
    </div>
  );
}

4. 前端核心知识点拆解

前端流式输出的核心的是「接收数据流 + 逐字渲染」,记住这6个关键点,就能理解所有逻辑:

  • "use client" :必须加在客户端组件顶部,否则无法使用useState、useRef等React钩子,无法实现交互。
  • 数据流读取:用res.body.getReader()获取数据流读取器,循环reader.read()接收数据片段,直到读取完成(done为true)。
  • SSE格式解析:后端推送的数据都是data: {"choices":...}格式,需要去掉data:前缀,再解析JSON,获取AI返回的增量文本(delta)。
  • 逐字渲染:用accumulated变量逐字拼接文本,每次拼接后更新AI消息的content,React会自动重新渲染,实现打字机效果。
  • 自动滚动:用useEffect监听messages和loading变化,每次变化时自动滚动到列表底部,提升用户体验。
  • 异常处理:处理请求失败、解析失败等情况,给用户友好的错误提示,避免程序崩溃。

五、常见问题排查

新手写代码,难免会遇到问题,下面整理了最常见的5个问题,以及解决方案,遇到问题直接对照排查:

1. 流式不生效,一直显示「生成中」,没有逐字效果?

原因及解决方案:

  • 忘记开启stream: true:检查后端代码中,调用DeepSeek API时,是否设置了stream: true
  • 响应头缺失:检查后端返回的响应头,是否包含4个必要的SSE头(尤其是X-Accel-Buffering: no)。
  • runtime设置错误:后端代码必须设置export const runtime = "nodejs",不能用Edge运行时。

2. 报错「DEEPSEEK_API_KEY is not defined」?

原因:.env.local文件未配置API Key,或配置错误。

解决方案:检查.env.local文件,确保DEEPSEEK_API_KEY的值是正确的,并且重启项目(修改.env文件后,需要重启项目才能生效)。