从 0 到 1:Next.js + Vercel AI SDK 实战全攻略
本文基于 Next.js (App Router) 和 Vercel AI SDK,带你打通从前端界面到后端流式响应的全流程。我们将涵盖 DeepSeek(标准 OpenAI 协议)和 Qianwen/内网模型(兼容协议)的接入实战,并深入解析 SDK 的核心概念。
一、项目初始化与依赖安装
首先,我们需要一个标准的 Next.js 环境。
-
创建项目
npx create-next-app@latest my-ai-app # 选择 TypeScript, Tailwind CSS, App Router cd my-ai-app -
安装核心依赖
Vercel AI SDK 是核心库,同时我们需要安装对应的 Provider(适配器)。# 安装核心库和 React 钩子 npm install ai @ai-sdk/react@latest # 安装 OpenAI 适配器 (用于 DeepSeek 或 标准 OpenAI) npm install @ai-sdk/openai@latest npm install @ai-sdk/deepseek"@latest # 安装兼容协议适配器 (用于阿里云/内网模型) npm install @ai-sdk/openai-compatible@latest版本信息
"@ai-sdk/deepseek": "^2.0.26",
"@ai-sdk/openai": "^3.0.48",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/react": "^3.0.143",
"ai": "^6.0.141",
二、实战 A:接入 DeepSeek(标准 OpenAI 协议)
DeepSeek 的接口完全兼容 OpenAI 格式,我们使用 @ai-sdk/openai 并修改 baseURL 即可。
1. 配置环境变量 (.env.local)
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1/chat
2. 后端路由 (app/api/chat-deepseek/route.ts)
import {createDeepSeek} from '@ai-sdk/deepseek';
import {streamText,convertToModelMessages} from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const coreMessages = await convertToModelMessages(messages);
const deepseek = createDeepSeek({
apiKey: process.env.OPENAI_API_KEY
});
const result = streamText({
model: deepseek('deepseek-chat'),
messages: coreMessages,
});
return result.toUIMessageStreamResponse();
}
3. 前端页面 (app/api/chat-deepseek/page.tsx)
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
export default function Chat() {
const [input, setInput] = useState('');
// 什么都不传,直接用默认值
// 默认就会请求 /api/chat,默认就有 messages 和 sendMessage
const { messages, sendMessage, status } = useChat();
return (
<div className="flex flex-col h-screen bg-gradient-to-br from-gray-50 to-gray-100">
{/* 头部 */}
<header className="bg-white shadow-sm py-4 px-6 border-b border-gray-200">
<div className="max-w-md mx-auto">
<h1 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Qwen AI 助手
</h1>
<p className="text-sm text-gray-500 mt-1">智能对话,随时为您服务</p>
</div>
</header>
{/* 聊天消息区域 */}
<div className="flex-1 overflow-y-auto max-w-md mx-auto w-full px-4 py-6 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p className="text-lg">开始对话...</p>
<p className="text-sm mt-2">输入消息与 AI 助手交流</p>
</div>
)}
{messages.map((message, index) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-fadeIn`}
style={{
animationDelay: `${index * 0.1}s`
}}
>
<div className={`max-w-[80%] ${message.role === 'user' ? 'ml-4' : 'mr-4'}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${message.role === 'user' ? 'text-blue-600' : 'text-gray-600'}`}>
{message.role === 'user' ? '我' : 'Qwen AI'}
</span>
<span className="text-xs text-gray-400">
{new Date().toLocaleTimeString()}
</span>
</div>
<div className={`p-4 rounded-2xl shadow-sm ${message.role === 'user' ? 'bg-blue-500 text-white' : 'bg-white text-gray-800 border border-gray-200'}`}>
<div className="whitespace-pre-wrap leading-relaxed">
{message.parts?.map((part: any, partIndex: number) => (
<div key={partIndex}>
{part.type === 'text' && part.text}
</div>
))}
</div>
</div>
</div>
</div>
))}
{/* 加载状态 */}
{(status === 'submitted' || status === 'streaming') && (
<div className="flex justify-start animate-fadeIn">
<div className="max-w-[80%] mr-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-600">Qwen AI</span>
<span className="text-xs text-gray-400">{new Date().toLocaleTimeString()}</span>
</div>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
</div>
</div>
)}
</div>
{/* 输入区域 */}
<form
onSubmit={e => {
e.preventDefault();
if (!input.trim()) return;
// 发送消息使用对象格式
sendMessage({ text: input });
setInput('');
}}
className="bg-white border-t border-gray-200 py-4 px-6"
>
<div className="max-w-md mx-auto flex gap-3">
<input
className="flex-1 border border-gray-300 rounded-full px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
value={input}
placeholder="问点什么..."
onChange={e => setInput(e.currentTarget.value)}
/>
<button
type="submit"
disabled={!input.trim() || status === 'submitted' || status === 'streaming'}
className="bg-blue-600 text-white rounded-full px-6 py-3 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<span>发送</span>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</form>
</div>
);
}
三、实战 B:接入 Qianwen/内网模型(兼容协议)
针对阿里云百炼或公司内网网关,它们的 URL 结构可能不标准,必须使用 @ai-sdk/openai-compatible。
1. 配置环境变量 (.env.local)
# 内网/阿里云的 Key
OPENAI_API_KEY=sk_your_custom_key
# 注意:这里只写到 v1 层级,不要带 chat/completions
OPENAI_BASE_URL= 基础路径
2. 后端路由 (app/api/chat-qianwen/route.ts)
import { streamText, convertToModelMessages } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
// 1. 初始化兼容客户端
const qianwen = createOpenAICompatible({
name: 'custom-gateway', // 自定义名称
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const coreMessages = await convertToModelMessages(messages);
const result = streamText({
// 2. 指定具体的模型名称(如 Qwen3.5...)
model: qianwen('Qwen3.5-35B-A3B-FP8'),
messages: coreMessages,
});
return result.toUIMessageStreamResponse();
}
四、核心概念深度解析
在开发过程中,我们遇到了几个关键函数,它们的作用如下:
1. streamText
这是 AI SDK 的核心函数。它负责编排整个对话流程:接收消息 -> 调用模型 -> 处理流式数据。它返回一个 Result 对象,包含了处理流响应的所有工具。
2. convertToModelMessages (原 convertToCoreMessages)
- 作用:格式转换器。
- 背景:前端
useChat发送的消息格式(UIMessage)包含parts数组(用于支持多模态、附件等复杂结构),而大模型通常只需要简单的content字符串。 - 注意:这是一个异步函数,必须使用
await,否则会导致类型错误(Promise is not assignable to Array)。
3. toUIMessageStreamResponse() vs toDataStreamResponse()
这是新手最容易混淆的地方:
toUIMessageStreamResponse():推荐用于 Next.js App Router。它返回的数据格式专门配合前端的useChat钩子,能自动处理消息的追加、加载状态和 ID 管理。toDataStreamResponse():更底层的原始流响应。如果你不使用useChat,而是自己手写fetch和useEffect来解析流,或者在非 Next.js 环境下,可以使用这个。
4. createOpenAI vs createOpenAICompatible
createOpenAI:专为 OpenAI 官方设计。它会强制在baseURL后拼接/chat/completions。适用于 DeepSeek、OpenAI 官方。createOpenAICompatible:专为“类 OpenAI”接口设计。它不会随意修改 URL 路径,完全信任你传入的baseURL。适用于阿里云、Azure(部分情况)、以及公司内网私有网关。
五、常见问题排查
- 报错
Not Found(404):- 检查
baseURL是否重复。如果 SDK 自动拼接了路径,而你传入的 URL 已经包含了/chat/completions,就会导致 404。 - 解决:使用
createOpenAICompatible或者精简baseURL。
- 检查
- 报错
Unauthorized(401):- 检查
.env.local是否生效(重启服务器)。 - 检查 API Key 是否正确,是否把模型名错填到了 Key 的位置。
- 检查
- 类型报错
Promise<ModelMessage[]>:- 忘记在
convertToModelMessages前加await。
- 忘记在
通过以上步骤,你能构建一个支持多模型、流式响应的现代化 AI 聊天应用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)