去年年底有个做独立开发的朋友找我,说想给自己的产品加一个 AI 对话功能,问我能不能用一个周末搭一个带打字机效果的聊天机器人。当时我第一个想到的就是 Next.js + OpenAI API 的组合。原因很直接:Next.js 的 App Router 天然能同时扛前端页面和后端接口,OpenAI 的 Node SDK 做流式输出几乎是开箱即用,一套代码部署到 Vercel 就能上线。折腾下来两天不到就完全跑通了,其中踩了几个比较隐蔽的坑,今天把完整过程拆开讲清楚,给想自己做 AI 聊天功能的朋友一个可以直接照着抄的参考。
这个项目适合谁?如果你对 React 有一点基础、知道基本的 useState / useEffect 是什么,想快速给网站接入一个能逐字回复的 AI 聊天窗口,那这篇博文就是为你准备的。我会从环境准备讲起,到后端如何接住 OpenAI 的流式响应,再到前端怎么把流式数据渲染成 Markdown,最后把我在实测中遇到的几个奇葩问题完整复盘一遍。
1. 为什么选 Next.js 而不是 Flask 或纯前端直连
先聊一个很多人纠结的问题:聊天机器人不是什么新鲜东西,Python 的 Flask + 模板也能做,纯前端直接 fetch OpenAI API 也能跑,为什么要绕一圈用 Next.js?
1.1 流式输出是 AI 聊天体验的底线
如果你用过 ChatGPT 官方页面,应该能感受到那个逐字蹦出来的效果。这背后是 SSE(Server-Sent Events)协议:服务端不是等整段话生成完再一次性返回,而是每生成一小段文本就推给前端。这样做有两个实际好处。第一是用户心理上的等待感会大幅降低,一个 500 字的回答如果等 20 秒一次性显示出来,大部分人会觉得系统卡死了;但如果 0.5 秒就开始出字,即使完整跑完还是 20 秒,用户会觉得"它在思考、在写",体验完全是两回事。第二是从业务角度看,AI 接口非常慢,如果前端拿到完整结果才渲染,中间一旦断网、超时、报错,用户什么都看不到,白等了;流式输出至少能让用户看到已经生成的那部分内容。
1.2 一个后端代理层是刚需,不是可选项
很多人会问:能不能纯前端直接调 OpenAI?技术上能调通,但千万别在生产环境这么干。原因有两个:CORS 限制和密钥安全。OpenAI 的 API 默认不会允许浏览器跨域请求,前端直连会直接报 CORS 错误,你要么去 OpenAI 后台配允许域名,要么用代理。更致命的是密钥问题——如果前端代码里硬编码 API Key,这个 Key 会被所有访问你网站的人从浏览器 DevTools 里扒出来,然后被拿去刷接口,账单直接爆炸。所以无论如何都要有一个后端代理层,由后端持有密钥、转发请求。既然必须写后端,那 Next.js 的 API Route 就是一个天然的代理层,不用额外部署一个 Python 服务,也不用操心 Nginx 转发,前后端放一个项目里,部署到 Vercel 一键搞定。
1.3 App Router 的 Route Handler 比 Pages Router 舒服不少
Next.js 13 之后的 App Router 在 app/api 目录下定义路由,文件即接口,比如 app/api/chat/route.ts 天然对应 POST /api/chat。Route Handler 可以直接导入 OpenAI SDK,设置 runtime = 'edge' 或者默认的 Node.js runtime 都行。它里面可以直接拿到 Request 对象、返回 Response 对象,意味着我可以完全控制响应头、流式传输方式。相比之前 Pages Router 的 /pages/api/* 那种写法,App Router 的 Route Handler 对流的控制更顺手,同时 TypeScript 支持更完整,写起来几乎不会出现"类型没有"的情况。所以接下来的代码全部基于 Next.js App Router。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把地基打好:环境准备与 API 可用性自检
这个阶段最容易被跳过,但恰恰是后面所有问题的根源。我见过太多人代码写完了才发现模型名打错、Key 没配好、Runtime 不支持,白白浪费半天。
2.1 初始化项目和依赖
用官方脚手架,一行命令:
bash复制npx create-next-app@latest ai-chat-demo
过程中会让你选 TypeScript、ESLint、Tailwind CSS、App Router 等选项。我的建议是:TypeScript 选 Yes,App Router 选 Yes,Tailwind 看个人喜好(后面渲染聊天界面用得上),src 目录优化可以选 Yes。
进入项目后安装必需的依赖包:
bash复制npm install openai ai react-markdown remark-gfm react-syntax-highlighter
简单说明一下每个包的定位:
openai:OpenAI 官方 Node SDK,负责调用chat.completions.create接口。ai:Vercel 官方 AI SDK,里面提供了OpenAIStream和StreamingTextResponse,可以大幅简化流式转发的代码。不过我会先演示不用它怎么实现,让你理解底层原理,再用它做简化。react-markdown+remark-gfm:把 OpenAI 返回的 Markdown 文本渲染成带样式的 HTML,remark-gfm用来支持表格、删除线等 GitHub 风格语法。react-syntax-highlighter:代码块高亮,聊天机器人很重要的功能。
2.2 密钥和模型可用性验证
API Key 是 OpenAI 平台里创建的,创建完之后是一个 sk-... 开头的字符串。注意:这个 Key 只会完整显示一次,创建完要立刻复制保存。接下来在项目根目录创建 .env.local 文件(这个文件默认被 .gitignore 忽略,不会提交到 Git):
bash复制OPENAI_API_KEY=sk-你的密钥
这里有一个新手特别容易踩的坑:环境变量名称千万不能写成 NEXT_PUBLIC_OPENAI_API_KEY。Next.js 中前缀为 NEXT_PUBLIC_ 的变量会被打包进浏览器端代码,任何访问网站的人都可以在 JS 文件里搜到这个 Key。不带前缀的变量只会在服务端(Node.js/Edge runtime)生效,浏览器拿不到,这才是我们需要的。
写一个快速自检脚本,验证 Key 和模型都可用。在项目根目录创建一个 scripts/test-openai.mjs:
javascript复制import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: '说一句话测试流式输出' }],
});
for await (const chunk of completion) {
process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
console.log('\n--- 流式输出测试通过 ---');
然后命令行里执行(注意要加载 .env.local 环境变量):
bash复制node --env-file=.env.local scripts/test-openai.mjs
如果控制台能逐字打印出一段话,说明密钥、网络、模型名全部没问题。这里我推荐使用 gpt-4o-mini 作为默认模型,它响应速度快、价格便宜,做测试和 MVP 场景完全够用。
2.3 关于 API 密钥获取的几句实在话
如果你还没有 OpenAI API Key,需要去 OpenAI 官网注册账号并在后台创建。注册流程本身不复杂,但有一个客观门槛:OpenAI 的付费接口要求绑定支付方式,通常需要一张支持外币的信用卡,国内发行的双币卡或全币种卡一般可以绑定,部分地区可能还需要验证手机号。这一步涉及具体的支付渠道策略,我不展开说,网上有大量详细教程。唯一要提醒的是:不要去买来路不明的"共享 API Key",那些 Key 很多是盗刷的,随时会被 OpenAI 风控封禁,到时候你的应用就突然不能用了,售后都没地方找。自己注册一个账号,充 5 美元,对开发测试来说足够了。
提示:OpenAI 的免费额度基本只够做非常小量的测试,如果要长期开发,建议预充小额费用。同时留意模型的计费方式,
gpt-4o-mini输入和输出每百万 token 的价格都很便宜,个人项目跑一个月也就几块钱。
3. 后端 API 路由:手动接住 OpenAI 的 SSE 流
这一步是整个项目的核心。很多人第一次接触流式输出的时候,以为 OpenAI 返回的就是一个 JSON,await 一下就拿到了。实际上 stream: true 模式下,OpenAI 返回的是一个 SSE 流,形如:
code复制data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}
data: {"choices":[{"delta":{"content":",有什么可以帮你的吗?"},"index":0}]}
data: [DONE]
每一行以 data: 开头,后面跟着一个 JSON 对象,最后以 data: [DONE] 结尾。delta.content 就是每次推送过来的增量文本。
3.1 用 OpenAI SDK 做流式请求
在 Next.js 的 App Router 里新建 app/api/chat/route.ts,先实现最原始的版本:
typescript复制import OpenAI from 'openai';
export const runtime = 'edge';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
messages,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
这段代码干了三件事。第一,从请求 body 里取出 messages,这是 OpenAI Chat Completions 接口要求的对话数组,格式是 [{ role: 'user', content: '你好' }]。第二,调用 chat.completions.create 并开启 stream: true,拿到一个异步可迭代对象 completion。第三,用 for await 遍历这个迭代对象,把每次拿到的 delta.content 编码后塞进 ReadableStream 里,前端拿到这个流,就能持续读到文本片段。
这里我刻意没有用 Vercel AI SDK,就是为了让你看清底层逻辑:ReadableStream + TextEncoder 是 Web 标准 API,与 Next.js 无关,你拿这套逻辑写到任何 JavaScript 服务端里都能跑通。
3.2 为什么手动拼接而不直接透传 OpenAI 原始流
你可能会问:为什么我不直接把 OpenAI 返回的响应体原封不动转发给前端?那样省事多了。
理论上是可行的,但有两个问题。第一,OpenAI 返回的原始 SSE 流里每一行都带 data: 前缀和一层 JSON 包装,前端拿到后还要自己解析 JSON 再提取 delta.content,多一道不必要的解析工序。第二,OpenAI 流里可能携带一些业务字段,比如 created、id、usage 等,直接透传会让前端处理逻辑变复杂,而且如果以后要接入其他模型(比如 Claude 或本地模型),它们的流式格式不一定兼容,你的前端处理逻辑就得重写。所以最稳妥的做法是:服务端统一解析 OpenAI 的流,只把纯文本内容转发给前端,这样前端永远只需要处理"纯文本流",无论后端接的是什么模型,前端代码都不用改。
3.3 引入 Vercel AI SDK 简化实现
手动版适合理解原理,但日常开发我更推荐用 Vercel 的 ai 包,代码量直接减少一半:
typescript复制import OpenAI from 'openai';
import { OpenAIStream, StreamingTextResponse } from 'ai';
export const runtime = 'edge';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
stream: true,
messages,
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
}
OpenAIStream 内部做的事情就是我刚才手写的那些逻辑:解析 SSE、提取 delta.content、包装成 Web 流。StreamingTextResponse 则是设置好了一组合适的响应头(包括 Content-Type: text/plain; charset=utf-8 和流式传输相关的头),并返回一个 Response 对象。这个版本既简洁又不丢失灵活度。所以我建议你第一次跑通用 SDK 版本,理解原理用手动版本,两边对照着看,效果最好。
4. 前端消费:从 fetch 读流到 Markdown 渲染
后端把流式文本接口给出来了,前端要做的就是从响应体里分段读取文本、拼接展示,再把最终内容渲染成 Markdown。
4.1 用 fetch 和 ReadableStream 读取流数据
在 Next.js 的 App Router 里,前端页面就是一个普通的 React 组件。新建 app/page.tsx,核心逻辑如下:
tsx复制'use client';
import { useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<{ role: string; content: string }[]>([
{ role: 'assistant', content: '你好,我是 AI 助手,有什么可以帮你?' },
]);
const [loading, setLoading] = useState(false);
async function handleSend() {
if (!input.trim() || loading) return;
const userMessage = { role: 'user', content: input };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput('');
setLoading(true);
// 先在消息列表末尾塞一个空的 assistant 消息占位
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: newMessages }),
});
if (!response.ok || !response.body) {
throw new Error('请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let assistantText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantText += decoder.decode(value, { stream: true });
// 将最新文本更新到最后一个 assistant 消息
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: assistantText };
return next;
});
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto p-4">
<div className="h-[400px] overflow-y-auto border rounded p-4 space-y-3">
{messages.map((msg, idx) => (
<div key={idx} className={msg.role === 'user' ? 'text-right' : ''}>
<div className="inline-block px-3 py-2 rounded bg-gray-100">
{msg.content}
</div>
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入消息"
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={handleSend}
disabled={loading}
className="bg-blue-500 text-white rounded px-4 py-2"
>
发送
</button>
</div>
</div>
);
}
这段代码的核心是 response.body.getReader()。浏览器从流式响应里读取数据,靠的就是 ReadableStream 上的 getReader() 方法。每次调用 reader.read() 会返回一个 { done, value },done 为 true 表示流结束,value 是 Uint8Array 类型的二进制块。拿到二进制块后必须用 TextDecoder 转成字符串,而且要注意 TextDecoder.decode(value, { stream: true }) 这个 stream: true 参数——它告诉解码器"这是一段流的中间片段,如果最后一个中文字符恰好被截断了,先缓存半个字符,等下一个片段来了再组合"。如果漏了这个参数,遇到中文字符跨块传输出现在边界处时,你会在界面上看到乱码。
4.2 前端状态管理的几个细节
我在代码里用了比较朴素的方式管理消息流:先在 messages 数组里塞一个空的 assistant 消息占位,然后每读到一段新文本,就更新最后一个元素。这种方式简单直接,但在消息很长、渲染很频繁时,React 会反复 setState,性能可能有一点浪费。优化方案是把流式文本存到一个 ref 里,渲染时再合并到 state,或者用 useReducer 管理消息队列。不过说实话,个人项目里 gpt-4o-mini 生成的文本通常几百字,我的实测是这种方式完全够用,没有明显的卡顿感。如果你的场景是超长回答、需要同时处理多个消息流,再考虑做优化。
还有一个重要的点是:在发送前保存一份 messages 快照。我代码里用 newMessages 保存发送时的消息数组,请求 body 用的是这份快照,而不是实时 state。因为 React 的 setState 是异步的,如果你直接使用 messages 变量,它拿到的是渲染之前的值,可能漏掉用户最后发送的那条消息。这个坑我在第一次实现时踩过,花了十分钟才排查出来。
4.3 流式输出下的 Markdown 渲染难题
如果只是把 msg.content 当普通文本显示,那这个聊天机器人就太粗糙了。OpenAI 默认会输出 Markdown 格式的内容,比如回答里带代码块、表格、列表。我们希望能像 ChatGPT 官方那样把这些 Markdown 渲染成漂亮的排版。通常做法是在组件里用 react-markdown:
tsx复制import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
{/* ... */}
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
但这里有一个流式输出特有的难题:当文本还在流式生成时,Markdown 语法是不完整的。比如 AI 正在写一个代码块:
text复制```javascript
console.log('hello')
在流的中间时刻,前端拿到的可能是:
code复制```javas
这段不完整的 Markdown 被 react-markdown 解析时,可能被当作普通文本显示或者渲染成奇怪的结构,导致界面闪烁。我实测中最常见的情况是:代码块语法开始的时候,那一瞬间会出现一个文本型的 "```",然后等闭合符号到达后才恢复正常渲染。
解决思路有三种:
- 流式传输阶段先显示纯文本,等整个消息流结束后,再做一次完整的 Markdown 渲染。这样最稳定,但牺牲了实时排版效果,AI 生成的代码在流式输出过程中会以纯文本形式显示,体验不那么完美。
- 流式过程中就渲染 Markdown,接受闪烁。实测体验是大部分情况下问题不大,因为模型生成时通常一句话就是一个完整的语义块,唯独代码块语法切换瞬间会有轻微闪烁。对于个人项目来说这个方案性价比最高,代码改动少。
- 自定义 Markdown 渲染器,对不完整的语法做容错处理,比如检测到
```没有闭合时,暂缓渲染代码块。这个方案效果最好,但实现成本偏高,要处理很多边界情况。
我个人的推荐是方案 2:先用 react-markdown 实时渲染,如果后面发现闪烁实在影响体验,再针对代码块做特殊处理。很多生产级的开源项目也是这么干的。
4.4 给代码块加高亮
聊天机器人的代码块如果不高亮,看起来就像一堆黑白文字,体验很差。react-markdown 本身不做代码高亮,需要配合 react-syntax-highlighter 使用:
tsx复制import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{msg.content}
</ReactMarkdown>
这个 components.code 是 react-markdown 提供的一个自定义渲染入口。当代码块是独立块级且带有语言标识(如 ```javascript)时,就走 SyntaxHighlighter 组件渲染;否则当成内联代码正常显示。
提示:
react-syntax-highlighter的 Prism 版本内置的样式很多,按需引入可以避免打包体积过大。如果不做代码高亮,直接import 'github-markdown-css'然后给容器加一个markdown-body类名,也能获得不错的阅读效果,更轻量。
5. 实测必踩的几个坑与排查链路
这个项目的代码量不大,但流式传输涉及的环节多(前端 fetch、后端流、OpenAI 服务),每一环都可能出问题。我把实际测试中遇到的最典型的几个坑完整复盘一下,附带排查思路。
5.1 字是一个字都不出,响应卡到结束
现象:点发送后页面空白,等十几秒后,整个回复一次性全部出现。
根因:后端返回的流没有真正"流式"传给前端。常见原因有三个。第一,代码里写了 await completion(注意 OpenAI SDK 在 stream: true 时不能 await 整个请求,要直接拿到异步迭代对象处理)。第二,响应头里缺少正确的 Content-Type 或者代码里不小心设置了 Content-Length,浏览器会等待缓冲整个响应。第三,runtime = 'nodejs' 时,某些 Node.js 的 Response 处理方式会默认缓冲,而 runtime = 'edge' 则天然支持流式。我的建议是:先确认代码是 for await 遍历的(版本 3.1 的写法),再确认 runtime = 'edge'。
排查链路:
- 用
curl -N直接调用后端接口:curl -N http://localhost:3000/api/chat -H "Content-Type: application/json" -d '{"messages":[{"role":"user","content":"hi"}]}' - 如果 curl 能逐字输出,说明后端没问题,问题在前端 fetch 处理。
- 如果 curl 也一次性输出,说明后端流就有问题,检查 route handler 里的流式逻辑。
- 前端用
console.log打印reader.read()的返回,确认每个value都会触发状态更新。
5.2 中文乱码
现象:英文正常,中文显示成乱码或者每隔几个字出现一个问号。
根因:TextDecoder 解码时没有指定 UTF-8,或者 Response 里没有设置 charset=utf-8。我手动版代码里特意写了 'Content-Type': 'text/plain; charset=utf-8',就是因为这个。如果去掉 charset=utf-8,部分浏览器会按照系统默认编码解析(比如 Windows 上的 GBK),中文就会乱。前端也必须用 new TextDecoder('utf-8') 而不是 new TextDecoder()(后者默认是 UTF-8,但显式指定更保险)。
5.3 Edge Runtime 读不到环境变量
现象:本地开发正常,部署到 Vercel 后接口报错,提示 API Key 无效或未定义。
根因:Edge Runtime 是一个精简的 JavaScript 运行时,和 Node.js 的 process.env 行为有细微差别。在 Vercel 上,环境变量需要在项目设置里手动配置 OPENAI_API_KEY,不能指望本地 .env.local 自动同步过去——.env.local 本来就不应该提交到 Git。其次,如果你在代码里用 process.env.OPENAI_API_KEY!,Edge Runtime 在构建时并不会校验这个值是不是存在,运行时报错才会暴露。最容易排查的方式:在 Vercel 项目后台的 Settings - Environment Variables 里添加 OPENAI_API_KEY,然后重新部署。
5.4 部署后接口 404 或走了错误路由
现象:本地 /api/chat 正常,线上 /api/chat 返回 404。
根因:Next.js 的路由文件位置错误,或者文件名不对。App Router 下 API 路由必须放在 app/api/chat/route.ts(或者 src/app/api/chat/route.ts),文件必须叫 route.ts,导出 POST 函数。如果你误建了 app/api/chat/page.tsx,那就不是 API 而是页面,部署后接口自然是 404。还有一个隐蔽的坑:如果你的项目里同时存在 pages/api/chat.ts(旧版 Pages Router)和 app/api/chat/route.ts,Next.js 的行为有时候会让人迷惑,尽量统一用一个 Router。
5.5 Token 消耗快得惊人
现象:明明只是测试了几个对话,账单扣费远超预期。
根因:很多人在流式输出过程中把完整的历史消息一遍又一遍地发送给 OpenAI。比如用户问一个问题,你就把最近 50 轮对话全部塞进 messages 数组,每轮请求的输入 token 量越来越大。OpenAI 计费是按输入 token 和输出 token 分别计算的,输入侧的历史消息越多越贵。
解决方案很直接:给 messages 数组设置一个窗口,比如只保留最近 10 轮对话(messages.slice(-20),每条消息算 2 个位置)。如果应用需要长期记忆,可以先把这段历史丢给一个摘要模型压缩成几句话,再把摘要塞进系统提示词里。这个优化对长期运行的聊天应用至关重要。
| 优化方向 | 做法 | 效果 |
|---|---|---|
| 限制历史长度 | 只保留最近 N 轮 | 直接降低每轮输入 token,省 50% 以上 |
| 压缩摘要 | 用摘要代替完整历史 | 支持超长对话但控制成本 |
| 模型降级 | gpt-4o-mini 替代 gpt-4o | 单价便宜一个数量级 |
| 超时中断 | 用户停止生成时中断流 | 避免模型继续输出产生费用 |
5.6 用户点击停止,模型还在偷偷输出
现象:前端点了"停止"按钮,界面停了,但账单显示还在扣费。
根因:聊天机器人需要支持停止生成功能。前端可以用 AbortController 取消 fetch 请求:
tsx复制const abortController = new AbortController();
async function handleSend() {
// ...
const response = await fetch('/api/chat', {
signal: abortController.signal,
// ...
});
}
function handleStop() {
abortController.abort();
setLoading(false);
}
但这里有一个关键点:前端 abort 了 fetch,后端的流不一定立刻中断。OpenAI 的服务端可能还在继续生成内容。在 Vercel AI SDK 的做法是,前端断开连接后,平台会感知到连接中断,并把中断信号传给 AI SDK 的流处理逻辑,最终让 OpenAI 的接口调用也停下来。如果你用的是手动实现而没有做任何连接中断的处理,那么建议在后端 route handler 里监听 req.signal:
typescript复制const controller = new AbortController();
req.signal.addEventListener('abort', () => {
controller.abort();
});
const completion = await openai.chat.completions.create({
// ...
signal: controller.signal,
});
这样前端断开时,后端会主动终止对 OpenAI 的请求,避免费用继续累计。别小看这个细节,长期运行下来差别很大。
6. 这个项目还能怎么继续长
跑通最小闭环之后,你会发现 Next.js + OpenAI API 这个组合特别适合往上叠加东西。这里列几个我实际做过或者看到过别人做得比较有意思的扩展方向。
6.1 给机器人加上多轮记忆和系统人设
现在这个 demo 里每次请求只发送当前 messages,没有系统提示词。你可以加一个 system 消息来设定人设。比如把机器人的"性格"定义放在系统消息里:
typescript复制const messages = [
{
role: 'system',
content: '你是一个专业的健身教练,回复要简洁、有行动力,善于给用户制定训练计划。',
},
...historyMessages,
];
这就是 ChatGPT 官方"自定义指令"功能的底层原理。想要产品层面的差异化,人设设计比技术实现重要得多。
6.2 多模型切换
OpenAI 的 Node SDK 只需要改一个 model 字段就能切换不同模型。可以在页面上放一个下拉框让用户选择模型,把选择的模型名跟请求一起发给后端。更进一步,如果想让机器人同时支持 Claude、Gemini 或者本地 Llama,可以抽象一个统一的"模型适配层"——后端收到的仍然是统一格式的 messages,内部根据模型类型分发到不同 SDK,再把流式输出统一成纯文本流返回给前端。这就是我之前说"服务端不要透传 OpenAI 原始流"的原因:统一转发纯文本流才能在模型间无缝切换。
6.3 流式渲染的性能优化
如果消息量大了,可以换更精细的渲染策略。我用过 react18 的 useTransition 配合流式更新,可以降低渲染阻塞。也可以对长文本做 window 截断——界面只渲染最近 2000 字符,历史部分用虚拟滚动或者"展开全文"按钮收起。这对移动端尤其重要,手机上一次性渲染 2000 行 Markdown 会非常卡。
6.4 接入向量数据库做知识库问答
这算是聊天机器人最实用的升级方向。把产品文档、常见问题等资料切成片段,用 Embedding API 向量化后存入向量数据库(如 pgvector、Pinecone)。用户提问时,先做语义检索,把最相关的几个片段塞进 Prompt,模型就能基于你的私域知识回答。整体架构就是在现有流程中增加一个检索步骤:
code复制用户消息 → 向量化 → 检索 top-K 相关片段
→ 拼进 prompt → OpenAI 流式生成 → 前端展示
这个扩展方向做出来的产品价值感非常强,很多企业内部的 AI 客服、知识库助手就是这么搭的。核心代码量并不大,主要工作集中在数据准备和切分策略调优上。
6.5 增加一个简单的对话日志
最后建议你在上线前给每个对话存一份日志。不需要复杂的数据库,用一条 JSON Lines 记录每次请求的时间、模型、输入 token、输出 token、用户消息和助手回复即可。这个日志的价值在出问题时才体现出来——哪天用户说你的 AI 回答得不对,你至少能复现他当时问了什么、模型返回了什么。如果用了 Vercel 部署,可以直接写到一个 Postgres 表里或者用 Vercel 的日志系统,成本都很低。
我在实测过程中还有一个很深刻的体会:流式聊天机器人项目的难点不在代码量,而在对"异步、流式、中断、边界条件"这些细节的把控。跑通一个 Hello World 只需要半小时,但把流式体验做顺滑、把资源消耗控制住、把各种异常情况处理干净,这些才是区分专业实现和玩具 demo 的分界线。建议你先把第 3 节的手动版代码亲手敲一遍,感受一下流式数据在服务端和前端之间流动的完整路径,再去用 SDK 简化,理解会扎实很多。
