开发一个带流式输出(Streaming)的聊天机器人是目前 AI 应用的基础。为了让你能循序渐进地掌握,我将这个过程拆解为 10 个篇章。
这个系列会使用 Next.js (App Router) + OpenAI API + Tailwind CSS,侧重于实战,去掉废话。
🚀 教程大纲:Next.js + OpenAI 流式聊天机器人
第一篇:环境搭建与项目初始化
- 核心内容:安装 Next.js 最新版,配置项目结构。
- 重点 :选择 App Router 模式,安装
lucide-react(图标库)和clsx(类名处理)。 - 目标:跑通一个空的 Next.js 页面。
第二篇:获取并配置 OpenAI API Key
- 核心内容 :如何获取 API Key,并在
.env.local中安全地配置环境变量。 - 重点 :区分环境变量的客户端与服务端访问权限(
NEXT_PUBLIC_的使用边界)。
第三篇:基础 UI 设计:聊天窗口与输入框
- 核心内容:使用 Tailwind CSS 绘制一个典型的聊天界面。
- 重点:响应式布局、固定底部的输入框、可滚动的消息区域。
第四篇:状态管理:处理消息列表
- 核心内容 :使用
useState管理对话数组(Role: user/assistant, Content)。 - 重点:定义消息对象的 TypeScript 类型,编写更新消息列表的逻辑。
第五篇:服务端:创建 Edge Runtime API 接口
- 核心内容 :在
app/api/chat/route.ts编写后端逻辑。 - 重点 :Edge Runtime 是流式输出的关键,学习如何调用 OpenAI SDK 并开启
stream: true。
第六篇:前端逻辑:实现 Fetch 流式读取
- 核心内容 :编写前端
fetch请求,处理ReadableStream。 - 重点 :如何通过
TextDecoder逐块读取数据并实时更新 UI,而不是等待全部返回。
第七篇:Markdown 渲染与代码高亮
- 核心内容 :集成
react-markdown。 - 重点 :让机器人输出的代码块带高亮,支持表格和数学公式渲染(使用
remark-gfm)。
第八篇:UI 体验优化:自动滚动与加载态
- 核心内容:解决"内容过长需要手动滚动"的问题。
- 重点 :使用
useRef和useEffect实现新消息自动探底,增加打字机样式的 Loading 动画。
第九篇:进阶:多轮对话与上下文管理
- 核心内容:如何让机器人"记住"上一句话。
- 重点:管理 Token 长度,裁剪过长的历史记录,确保不会超出模型窗口限制。
第十篇:部署与线上环境优化
- 核心内容:发布到 Vercel。
- 重点:配置线上环境变量,解决 Vercel 免费版超时限制(Function Timeout)对流式传输的影响。
第一篇:环境搭建与项目初始化
我们直接从最基础的开始。
1. 创建项目
打开终端,运行以下命令:
bash
npx create-next-app@latest my-ai-chatbot
选项建议:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
src/directory: Yes- App Router: Yes
- Import alias: Yes (@/*)
2. 安装必要依赖
进入项目文件夹,安装后面要用到的工具库:
bash
cd my-ai-chatbot
npm install openai lucide-react clsx tailwind-merge
openai: 官方 SDK。lucide-react: 图标库。clsx&tailwind-merge: 方便我们更灵活地写 Tailwind 类名。
3. 清理代码
删除 src/app/page.tsx 里的默认内容,改成一个简单的容器,确保项目能跑起来:
tsx
export default function Page() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<h1 className="text-2xl font-bold">我的 AI 聊天机器人</h1>
</main>
)
}
运行 npm run dev,浏览器访问 localhost:3000,看到文字即代表环境 OK。
第二篇:获取并配置 OpenAI API Key
1. 获取 OpenAI API Key
如果你还没有 Key,需要去 OpenAI 官网(platform.openai.com)申请。
- 登录后,找到 API Keys 栏目。
- 点击 Create new secret key。
- 注意:Key 只会出现一次,请务必找个地方存好。
2. 安全地存储环境变量
在 Next.js 中,我们绝不能把 API Key 直接写在代码里,否则上传到 GitHub 后别人就能盗刷你的余额。
在项目根目录下创建一个名为 .env.local 的文件:
text
OPENAI_API_KEY=你的sk-xxxxxx键值
关键知识点:
- 在 Next.js 中,默认情况下,环境变量只能在服务端(Server Side)访问。
- 只要变量名不加
NEXT_PUBLIC_前缀,它就不会被暴露给浏览器,这是安全的。
3. 创建 OpenAI 客户端实例
为了方便复用,我们单独封装一个配置文件。在 src/ 目录下(或者根目录)新建 lib/openai.ts:
typescript
import OpenAI from 'openai';
// 检查环境变量是否存在
if (!process.env.OPENAI_API_KEY) {
throw new Error('Missing OpenAI API Key in environment variables');
}
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
4. 验证配置
我们可以写一个简单的临时脚本测试一下 Key 是否有效。在 src/app/api/test/route.ts 新建一个接口:
typescript
import { openai } from '@/lib/openai';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const response = await openai.models.list();
return NextResponse.json({ data: response.data });
} catch (error) {
return NextResponse.json({ error: 'Key 无效或网络异常' }, { status: 500 });
}
}
访问 http://localhost:3000/api/test,如果能看到一串模型列表,说明配置成功。
第三篇:基础 UI 设计:聊天窗口与输入框
有了后端配置,现在我们来搭个"壳子"。
1. 修改全局样式
在 src/app/globals.css 中,清理掉多余的背景色设置,保持页面清爽:
css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 255, 255, 255;
--background-end-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: white;
}
2. 编写页面结构
修改 src/app/page.tsx。我们需要一个顶部的标题栏、中间的对话展示区和底部的固定输入框。
tsx
import { Send, User, Bot } from "lucide-react";
export default function ChatPage() {
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* 顶部标题 */}
<header className="p-4 border-b bg-white flex justify-between items-center">
<h1 className="text-xl font-semibold">AI Assistant</h1>
</header>
{/* 消息展示区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 用户消息示例 */}
<div className="flex justify-end">
<div className="bg-blue-600 text-white p-3 rounded-lg max-w-[80%]">
你好,帮我写个代码。
</div>
</div>
{/* 机器人消息示例 */}
<div className="flex justify-start">
<div className="bg-white border p-3 rounded-lg max-w-[80%] shadow-sm text-gray-800">
没问题,你想写什么样的代码?
</div>
</div>
</div>
{/* 底部输入框 */}
<footer className="p-4 bg-white border-t">
<div className="max-w-4xl mx-auto flex gap-2">
<input
type="text"
placeholder="输入你的问题..."
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button className="bg-blue-600 text-white p-2 rounded-md hover:bg-blue-700 transition">
<Send size={20} />
</button>
</div>
</footer>
</div>
);
}
3. 为什么这么写?
- flex-col h-screen: 强制页面撑满屏幕高度,不出现多余滚动条。
- flex-1 overflow-y-auto: 让中间的消息区占据所有剩余空间,内容多了会自动出现内部滚动。
- max-w-[80%]: 防止消息太长直接铺满全屏,保持对话的呼吸感。
第四篇:状态管理:处理消息列表
在聊天应用里,核心数据就是一张"对话列表"。每一条消息都有个身份(是你说还是 AI 说)和内容。
1. 定义消息类型
在 src/app/page.tsx 顶部,先定义好消息的结构。
typescript
type Message = {
role: 'user' | 'assistant';
content: string;
};
2. 初始化状态
我们需要两个主要状态:messages(存放所有对话)和 input(当前的输入框内容)。
tsx
"use client"; // 别忘了加上这个,因为我们要用到 hooks
import { useState } from "react";
import { Send } from "lucide-react";
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
// 发送消息的方法(下一篇填充逻辑)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
// 逻辑占位...
};
return (
// ... UI 代码 ...
);
}
3. 让 UI 动态渲染
把之前写死的 div 替换成 messages.map:
tsx
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m, index) => (
<div key={index} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`p-3 rounded-lg max-w-[80%] ${
m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border text-gray-800'
}`}>
{m.content}
</div>
</div>
))}
{isLoading && <div className="text-gray-400 text-sm">AI 正在思考...</div>}
</div>
第五篇:服务端:创建 Edge Runtime API 接口
Next.js 的路由处理程序(Route Handlers)非常适合做中转。我们要用 Edge Runtime,因为它对流式传输支持更好,响应也更快。
新建 src/app/api/chat/route.ts:
typescript
import { openai } from '@/lib/openai';
export const runtime = 'edge'; // 必须指定为 edge 运行环境
export async function POST(req: Request) {
const { messages } = await req.json();
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // 或者 gpt-4-turbo
stream: true, // 开启流式输出的核心
messages,
});
// 将 OpenAI 的流转化为前端能读取的 ReadableStream
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
controller.enqueue(new TextEncoder().encode(content));
}
}
controller.close();
},
});
return new Response(stream);
}
关键点:
stream: true: 告诉 OpenAI 不要等全写完再发,而是一边写一边发。ReadableStream: 这是一个标准 Web API,允许我们把数据像自来水一样源源不断地推向前端。
第六篇:前端逻辑:实现 Fetch 流式读取
这是最"黑科技"的部分。我们要像接水管一样,把后端的流数据一点点接住。
回到 src/app/page.tsx,完善 handleSubmit 函数:
tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage: Message = { role: 'user', content: input };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
// 预备一条空的 AI 消息,用于承接流式内容
setMessages((prev) => [...prev, { role: 'assistant', content: "" }]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages: [...messages, userMessage] }),
});
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let lastContent = "";
// 循环读取流
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
lastContent += chunkValue;
// 实时更新最后一条(即 AI 消息)的内容
setMessages((prev) => {
const newMsgs = [...prev];
newMsgs[newMsgs.length - 1].content = lastContent;
return newMsgs;
});
}
} catch (error) {
console.error("读取失败", error);
} finally {
setIsLoading(false);
}
};
这几步发生了什么?
- getReader(): 拿到了数据的"读取器"。
- while 循环: 只要数据没发完,就一直读。
- TextDecoder: 电脑看的是二进制,我们要把它转成人类能看的文字。
- 状态更新 : 每次读到一点点新字,我们就用
lastContent替换列表里最后那条消息的内容。用户就会看到字是一个个蹦出来的。
第七篇:Markdown 渲染与代码高亮
AI 的输出通常是 Markdown 格式。我们需要让它像 GitHub 或 ChatGPT 那样,能漂亮地展示代码块、表格和加粗文字。
1. 安装渲染库
我们需要 react-markdown 处理文档,remark-gfm 处理 GitHub 风格的表格/列表,以及 react-syntax-highlighter 处理代码高亮。
bash
npm install react-markdown remark-gfm react-syntax-highlighter
npm install -D @types/react-syntax-highlighter
2. 封装 Markdown 组件
在 src/components/Markdown.tsx(如果没有文件夹就建一个)中编写:
tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
export function Markdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={oneLight}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
className="prose prose-sm max-w-none break-words"
>
{content}
</ReactMarkdown>
);
}
3. 应用到页面
回到 page.tsx,把之前渲染 {m.content} 的地方换成 <Markdown content={m.content} />。
第八篇:UI 体验优化:自动滚动与加载态
当 AI 输出长文本时,用户必须手动往下划才能看到新字,这体验太差了。我们需要实现"自动探底"。
1. 使用 useRef 锚定底部
在 page.tsx 中增加一个隐藏的 div 作为滚动锚点:
tsx
import { useEffect, useRef } from "react";
// ... 在组件内部 ...
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// 只要消息列表变动,就滚动到底部
useEffect(() => {
scrollToBottom();
}, [messages]);
2. 放置锚点
在 messages.map 循环的最下方放置这个 ref:
tsx
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m, i) => ( /* ... */ ))}
<div ref={messagesEndRef} /> {/* 自动滚动目标 */}
</div>
第九篇:进阶:多轮对话与上下文管理
现在的机器人是"鱼的记忆",问它第二句时,它不记得第一句说了什么。我们要把历史记录传给 API。
1. 修改前端发送逻辑
在 handleSubmit 中,确保我们把整个 messages 数组(加上当前新消息)都发过去:
tsx
// page.tsx
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
messages: [...messages, userMessage] // 发送完整历史
}),
});
2. 后端 Token 裁剪(防爆单)
如果对话了几百轮,Token 会非常贵且可能超出模型上限。我们需要在 api/chat/route.ts 中做个简单的截断:
typescript
// api/chat/route.ts
const { messages } = await req.json();
// 只保留最近的 10 条对话,避免上下文过长
const limitedMessages = messages.slice(-10);
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
stream: true,
messages: limitedMessages,
});
第十篇:部署与线上环境优化
最后一步,我们要把这个机器人发布到公网。
1. 部署到 Vercel
- 把代码推送到 GitHub。
- 在 Vercel 官网关联这个仓库。
- 关键 :在 Vercel 的项目设置中,找到 Environment Variables ,添加你在
.env.local里的OPENAI_API_KEY。
2. 解决超时问题
Vercel 的免费版 Serverless Function 有 10 秒超时限制。如果 AI 思考太久,请求会被强行中断。
-
对策 1 :我们在第五篇使用了
runtime = 'edge',Edge Function 的超时限制更宽松。 -
对策 2 :在
route.ts中显式声明最大时长(需配合 Vercel 特定配置):typescriptexport const maxDuration = 30; // 仅限 Pro 计划以上,免费版需优化流速
3. 恭喜!
你现在拥有了一个完整的、带流式输出、支持代码高亮、具备上下文记忆的 AI 聊天机器人。