哈喽, 今天咱们手把手教大家实现 AI 对话的「打字机效果」——也就是流式输出,这是目前所有AI产品(ChatGPT、豆包等)的核心交互体验,学会这套,你就能轻松搭建自己的AI对话应用。
一、先搞懂:什么是AI流式输出?
在学实现之前,咱们先弄明白「流式输出」到底是什么,和普通接口有啥区别,用最通俗的话讲清楚,不搞专业名词堆砌。
1. 普通接口 vs 流式输出
| 对比维度 | 普通接口 | 流式输出 |
|---|---|---|
| 响应方式 | AI思考完,一次性返回全部内容 | AI边思考边返回,逐字/逐句推送 |
| 等待体验 | 等待时间长,容易以为程序卡了 | 实时看到内容,交互流畅,像真人打字 |
| 技术实现 | 普通HTTP请求,一次请求一次响应 | SSE协议(服务器向客户端持续推送数据) |
2. 流式输出核心原理(图文拆解)
不用记复杂概念,记住这个「流水线」流程,就能理解所有逻辑:
关键核心:后端不缓存完整响应,而是将AI返回的内容「分片」,持续推送给前端;前端不等待全部内容,收到一片就渲染一片,这就是打字机效果的本质。
二、前期准备
在写代码之前,咱们先准备好所需的工具和环境。
1. 技术栈说明
本文使用的技术栈都是目前最主流、最容易上手的,0基础也能快速适应:
- 框架:Next.js 16+(App Router)—— 前后端一体化,不用单独搭建后端服务,新手友好
- 流式协议:SSE(Server-Sent Events)—— 专门用于服务器向客户端持续推送数据,比WebSocket简单
- AI模型:DeepSeek Chat —— 便宜好用,接口简单,适合新手调试(也可替换成ChatGPT、豆包等)
- 样式:TailwindCSS —— 不用写复杂CSS,复制类名就能实现美观样式
- 其他:TypeScript —— 类型提示,减少报错。
2. 环境准备步骤
- 创建Next.js项目:终端输入命令
npx create-next-app@latest ai-stream-demo,一路回车(记得选TypeScript、TailwindCSS)。 - 获取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文件后,需要重启项目才能生效)。