我来做
全栈开发 基础

Next.js + Vercel AI SDK 实战(一):零基础搭建流式 AI 聊天界面

💡 本教程技术要点

深入剖析 HTTP 流式传输(SSE)底层协议与 Vercel AI SDK 的 Data Stream Protocol 帧格式,手把手搭建支持 Markdown 渲染、加载骨架屏和错误重试的工业级 AI 聊天界面,并详解 API Key 泄露防护、Edge Runtime 选型、国内网络代理和 Token 计费感知等生产环境必知陷阱。

🎯 问题引入:8 秒的沉默 vs 200ms 的响应

假设你正在为公司搭建一个 AI 客服聊天机器人。用户输入"如何退换货?"后,大模型需要约 8 秒才能生成完整回复。如果你使用传统的 HTTP 请求-响应模式,用户会面对一个长达 8 秒的空白等待——没有任何视觉反馈,界面像是"死掉了"。这在产品体验上是灾难性的:用户焦虑、反复点击、甚至直接关闭页面。

但如果换成流式传输 (Streaming),情况完全不同。用户在发送消息后仅 200ms,就能看到第一个字开始出现,文字像打字机一样逐字流出。虽然总生成时间依然是 8 秒,但用户的感知延迟从 8 秒降到了 0.2 秒——这就是流式输出的核心价值。

本教程将带你使用 Next.js App RouterVercel AI SDK,从零搭建一个生产级的流式 AI 聊天界面。你将理解流式传输的底层原理,而不仅仅是复制粘贴代码。

🧠 原理解析:流式传输是如何工作的

三种实时通信方案对比

在实现"服务端向客户端持续推送数据"这一需求时,业界有三种主流方案。理解它们的差异,才能理解 Vercel AI SDK 为何选择了 SSE:

方案 协议 方向 适用场景 复杂度
Long Polling HTTP 单向(模拟) 兼容性要求极高
SSE (Server-Sent Events) HTTP 服务端 → 客户端 AI 流式输出、通知推送
WebSocket WS/WSS 全双工 实时协作、游戏、聊天室

AI 聊天场景的数据流是严格单向的:用户发送一条消息(一个完整的 HTTP POST),服务端持续返回 token 流。这正好符合 SSE 的设计——基于标准 HTTP,无需 WebSocket 握手,天然兼容 CDN 和 Edge Runtime,部署成本几乎为零。

Vercel AI SDK 的 Data Stream Protocol

Vercel AI SDK 并不是直接使用原始 SSE 格式,而是定义了一套自定义的 Data Stream Protocol。每一行数据都有一个前缀标识符来区分类型,浏览器端的 useChat 会自动解析这些帧。以下是实际的 SSE 帧格式:

# 文本 token(前缀 0)
0:"你"
0:"好"
0:","
0:"我是"
0:"AI"
0:" 助手"

# 工具调用请求(前缀 9)
9:{"toolCallId":"call_abc","toolName":"getWeather","args":{"city":"北京"}}

# 工具调用结果(前缀 a)
a:{"toolCallId":"call_abc","result":{"temp":28,"weather":"晴"}}

# 完成信号(前缀 e)
e:{"finishReason":"stop","usage":{"promptTokens":32,"completionTokens":128}}

# 错误信号(前缀 3)
3:"Rate limit exceeded"

注意前缀 0 代表文本 delta、e 代表结束信号。客户端的 useChat hook 会逐行读取这些帧,将 0: 类型的数据拼接成完整的消息文本,遇到 e: 时标记生成完成。这比原始 SSE 的 data: 格式更紧凑,也更容易携带结构化元数据。

整体架构流程

┌─────────────────────────────────────────────────────────────────┐
│                        浏览器 (Browser)                         │
│                                                                 │
│  ┌──────────────┐    状态管理     ┌──────────────────────────┐  │
│  │  <ChatPage>  │ ←──────────→  │  useChat() 内部状态机     │  │
│  │  React 组件   │    messages   │  idle → loading →        │  │
│  │  渲染消息列表  │    input      │  streaming → complete    │  │
│  └──────────────┘    isLoading   └───────────┬──────────────┘  │
│                                              │                  │
└──────────────────────────────────────────────│──────────────────┘
                                               │ HTTP POST
                                               │ { messages: [...] }
                                               ▼
┌──────────────────────────────────────────────────────────────────┐
│                   Next.js Route Handler                          │
│                   app/api/chat/route.ts                           │
│                                                                  │
│   1. 解析 messages                                               │
│   2. 调用 streamText({ model, messages, system })                │
│   3. 返回 result.toDataStreamResponse()                          │
│                           │                                      │
└───────────────────────────│──────────────────────────────────────┘
                            │ HTTP Request (带 Authorization header)
                            ▼
┌──────────────────────────────────────────────────────────────────┐
│                     OpenAI API (或兼容 API)                       │
│                                                                  │
│   逐 token 生成并返回 SSE 流                                      │
│   ← stream: data: {"choices":[{"delta":{"content":"你"}}]}       │
│   ← stream: data: {"choices":[{"delta":{"content":"好"}}]}       │
│   ← stream: data: [DONE]                                        │
└──────────────────────────────────────────────────────────────────┘

useChat 内部状态机

useChat 不仅仅是一个简单的 fetch 封装,它内部维护了一个完整的状态机。理解这些状态的切换时机,对于构建健壮的 UI 至关重要:

  • idle(空闲):初始状态,用户可以输入和发送消息。UI 应显示输入框和发送按钮。
  • loading(加载中):用户点击发送后,请求已发出但尚未收到第一个 token。此时应禁用发送按钮,显示加载动画。
  • streaming(流式接收中):收到第一个 token 后进入此状态,消息内容持续更新。UI 应展示逐字出现的效果。
  • error(错误):请求失败或流式传输中断。UI 应显示错误信息和重试按钮。

在 Vercel AI SDK v4 中,你可以通过 status 字段精确判断当前处于哪个阶段(submittedstreamingreadyerror),从而为每个阶段渲染不同的 UI 元素。

💻 动手实现:从零到一搭建流式聊天

Step 1:初始化 Next.js 项目

我们使用 Next.js 官方的 create-next-app 脚手架,并启用 TypeScript 和 Tailwind CSS。App Router 是当前 Next.js 的默认路由模式,也是 Vercel AI SDK 推荐的搭配方式。

# 创建项目(交互式,推荐选择默认配置)
npx create-next-app@latest my-ai-chat --typescript --tailwind --app

# 进入项目并安装核心依赖
cd my-ai-chat
npm install ai @ai-sdk/openai

其中 ai 是 Vercel AI SDK 的核心包,提供 streamTextuseChat 等 API;@ai-sdk/openai 是 OpenAI 模型的适配器。SDK 采用适配器架构,如果你之后要切换到 Anthropic 或 Google Gemini,只需要更换适配器包和模型名即可,业务代码几乎不变。

我们还需要安装 Markdown 渲染库来正确显示 AI 回复中的格式化内容:

# Markdown 渲染 + 代码高亮(可选但推荐)
npm install react-markdown remark-gfm

Step 2:配置环境变量

在项目根目录创建 .env.local 文件。Next.js 会自动在服务端加载该文件中的变量,且不会暴露给客户端(除非变量名以 NEXT_PUBLIC_ 开头)。这一点对安全性至关重要。

# .env.local
# OpenAI API Key — 只在服务端生效,不会泄露到浏览器
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx

# [可选] 国内用户如果直连 OpenAI 有网络问题,可配置代理地址
# Vercel AI SDK 的 @ai-sdk/openai 适配器会自动读取此变量
# OPENAI_BASE_URL=https://your-proxy-domain.com/v1

# [可选] 如果使用 Azure OpenAI
# AZURE_OPENAI_API_KEY=your-azure-key
# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com

关于代理配置的补充说明:@ai-sdk/openai 会优先读取 OPENAI_BASE_URL 环境变量。如果你使用的是第三方兼容 API(如 DeepSeek、智谱、Moonshot),只需将 OPENAI_BASE_URL 指向对应的 API 地址,并传入对应的 API Key,即可无缝切换——因为它们都兼容 OpenAI 的 Chat Completions 接口协议。

Step 3:创建后端 API 路由

这是整个应用的核心。Next.js 的 Route Handler 让我们可以在同一个项目中编写 API,无需另起后端服务。创建文件 app/api/chat/route.ts

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, type CoreMessage } from 'ai';

// 允许流式响应最长持续 30 秒(默认是 10 秒,生成长回复可能不够)
export const maxDuration = 30;

export async function POST(req: Request) {
  // 1. 从请求体中解析对话历史
  //    useChat 会自动将完整的 messages 数组发送过来
  const { messages }: { messages: CoreMessage[] } = await req.json();

  // 2. 调用 streamText —— 这是 Vercel AI SDK 的核心 API
  //    它会向 OpenAI 发起流式请求,并返回一个可消费的流
  const result = streamText({
    model: openai('gpt-4o-mini'), // 模型选择:gpt-4o-mini 性价比最高
    system: '你是一个专业的技术顾问。回答时使用 Markdown 格式,代码用代码块包裹。回答简洁专业。',
    messages, // 传入完整对话历史,让模型理解上下文

    // [可选] 控制生成参数
    maxTokens: 2048,       // 限制单次回复的最大 token 数
    temperature: 0.7,      // 控制创造性,0 最确定,1 最随机
  });

  // 3. 将流转换为 Vercel AI SDK 的 Data Stream Response
  //    这会自动设置正确的 HTTP headers(Content-Type: text/plain; charset=utf-8)
  //    并按照 Data Stream Protocol 格式编码每个 token
  return result.toDataStreamResponse();
}

这里有几个关键设计决策值得解释。首先,streamText 而非 generateText——前者返回流式响应,后者等待完整生成后一次性返回。其次,toDataStreamResponse() 而非手动构造 ReadableStream——SDK 封装了所有流控制、错误处理和协议编码的细节。最后,maxDuration = 30 是 Vercel 平台的限制配置;如果你自部署到 Node.js 服务器上则无需此项。

注意我们没有设置 export const runtime = 'edge'。Edge Runtime 的启动速度更快(冷启动约 0ms vs Node.js 的 250ms),但它不支持 Node.js 原生模块(如 fscrypto)。对于纯 AI 聊天这种不依赖 Node.js API 的场景,两者都可以正常工作。

Step 4:构建前端聊天界面

前端是用户直接接触的部分。我们将使用 useChat hook 管理所有聊天状态,配合 react-markdown 渲染 AI 回复中的 Markdown 格式内容。替换 app/page.tsx 文件:

// app/page.tsx
'use client'; // 使用 useChat hook 需要客户端组件

import { useChat } from 'ai/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

export default function ChatPage() {
  const {
    messages,          // 完整对话历史 { id, role, content }[]
    input,             // 输入框的受控值
    handleInputChange, // 绑定到 input 的 onChange
    handleSubmit,      // 绑定到 form 的 onSubmit
    status,            // 'submitted' | 'streaming' | 'ready' | 'error'
    error,             // 错误对象(如果有)
    reload,            // 重新发送最后一条消息(用于重试)
    stop,              // 中断当前流式生成
  } = useChat({
    api: '/api/chat',  // 指向我们创建的 Route Handler
  });

  const isLoading = status === 'submitted' || status === 'streaming';

  return (
    <div className="flex flex-col h-screen max-w-3xl mx-auto">
      {/* 顶部导航 */}
      <header className="border-b px-6 py-4">
        <h1 className="text-xl font-bold text-gray-800">🤖 AI 技术助手</h1>
        <p className="text-sm text-gray-500">基于 Vercel AI SDK 的流式聊天演示</p>
      </header>

      {/* 消息列表 */}
      <div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
        {messages.length === 0 && (
          <div className="text-center text-gray-400 mt-32">
            <p className="text-4xl mb-4">💬</p>
            <p>发送一条消息开始对话</p>
          </div>
        )}

        {messages.map((m) => (
          <div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-[85%] rounded-2xl px-4 py-3 ${
              m.role === 'user'
                ? 'bg-blue-600 text-white'
                : 'bg-gray-100 text-gray-800'
            }`}>
              {m.role === 'assistant' ? (
                <ReactMarkdown
                  remarkPlugins={[remarkGfm]}
                  components={{
                    // 自定义代码块样式
                    code({ className, children, ...props }) {
                      return className ? (
                        <pre className="bg-gray-900 text-green-300 rounded-lg p-3 my-2 overflow-x-auto">
                          <code {...props}>{children}</code>
                        </pre>
                      ) : (
                        <code className="bg-gray-200 px-1 rounded text-sm" {...props}>
                          {children}
                        </code>
                      );
                    },
                  }}
                >
                  {m.content}
                </ReactMarkdown>
              ) : (
                <p className="whitespace-pre-wrap">{m.content}</p>
              )}
            </div>
          </div>
        ))}

        {/* 加载骨架动画 —— 仅在已提交但还未收到任何 token 时显示 */}
        {status === 'submitted' && (
          <div className="flex justify-start">
            <div className="bg-gray-100 rounded-2xl px-4 py-3 flex gap-1">
              <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
              <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
              <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
            </div>
          </div>
        )}
      </div>

      {/* 错误提示 + 重试 */}
      {error && (
        <div className="mx-6 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
          <span className="text-red-700 text-sm">
            ⚠️ 请求失败:{error.message || '未知错误'}
          </span>
          <button
            onClick={() => reload()}
            className="text-red-600 hover:text-red-800 text-sm font-medium underline"
          >
            重试
          </button>
        </div>
      )}

      {/* 输入区域 */}
      <form onSubmit={handleSubmit} className="border-t px-6 py-4 flex gap-3">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入你的问题..."
          disabled={isLoading}
          className="flex-1 border border-gray-300 rounded-xl px-4 py-2.5
                     focus:outline-none focus:ring-2 focus:ring-blue-500
                     disabled:opacity-50 disabled:cursor-not-allowed"
        />
        {isLoading ? (
          <button
            type="button"
            onClick={stop}
            className="px-4 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors"
          >
            停止
          </button>
        ) : (
          <button
            type="submit"
            disabled={!input.trim()}
            className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl
                       transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
          >
            发送
          </button>
        )}
      </form>
    </div>
  );
}

上面的代码有几个值得注意的细节。首先是 status 字段的使用——我们用它来区分"已提交等待中"和"正在流式接收"两种不同的加载状态。前者显示跳动的圆点动画(skeleton),后者则由消息内容的实时更新来提供视觉反馈。

其次是 stop() 函数。当用户发现 AI 的回答偏题时,可以点击"停止"按钮中断流式生成,避免浪费 token。这个功能在生产环境中非常重要——它会立即关闭 HTTP 连接,OpenAI 也会停止后续 token 的计费。

最后是 Markdown 渲染的处理。我们通过 react-markdowncomponents 属性自定义了代码块的样式。区分了行内代码(inline code)和多行代码块的渲染方式。remarkGfm 插件则提供了对 GitHub 风格 Markdown 的支持,包括表格、任务列表、删除线等。

现在你可以启动开发服务器来测试了:

npm run dev
# 访问 http://localhost:3000

在浏览器中打开后,输入任何问题,你应该能看到 AI 的回复像打字机一样逐字出现。打开浏览器 DevTools 的 Network 面板,查看 /api/chat 请求,你可以在 EventStream 标签中看到实际的 Data Stream Protocol 帧数据。

⚠️ 生产陷阱:四个必须避开的坑

陷阱 1:API Key 泄露——最危险的新手错误

这是最常见也是后果最严重的错误。一些开发者为了"简化架构",直接在前端调用 OpenAI API:

// ❌ 绝对不要这样做!API Key 会暴露在浏览器 Network 面板中
const response = await fetch('https://api.openai.com/v1/chat/completions', {
  headers: {
    'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}` // 灾难!
  }
});

任何以 NEXT_PUBLIC_ 开头的环境变量都会被打包到客户端 JavaScript 中。一旦 Key 泄露,攻击者可以用你的额度无限调用 API,你可能在一夜之间收到上万美元的账单。正确做法:始终通过 Next.js Route Handler 做中间代理,API Key 只在服务端使用。这也是 Vercel AI SDK 的设计哲学——useChat 只向你自己的 /api/chat 发送请求,永远不直接接触 OpenAI。

陷阱 2:Edge Runtime vs Node.js Runtime 选择

在 Route Handler 中添加 export const runtime = 'edge' 可以让 API 跑在 Edge Runtime 上。Edge 的优势是冷启动几乎为 0、部署到全球边缘节点延迟更低。但它有严格限制:

  • 不能使用 Node.js 原生模块(fsnetchild_process
  • 不能使用依赖原生模块的 npm 包(如 bcryptsharp
  • 执行时间上限通常是 30 秒(Vercel 免费版)
  • 没有持久化文件系统

如果你的 AI 路由只是调用 OpenAI API,Edge Runtime 完全够用且性能更好。但如果你需要访问数据库(Prisma)、操作文件、或使用需要 Node.js API 的库,就必须使用 Node.js Runtime(默认值,不加 runtime 声明即可)。

陷阱 3:国内网络问题——代理、超时与降级

国内直连 OpenAI API 通常会遇到网络不可达的问题。常见的解决方案有三层:

// 方案 1:使用代理地址(最简单,在 .env.local 中配置即可)
OPENAI_BASE_URL=https://your-proxy.com/v1

// 方案 2:在代码中配置自定义 provider(更灵活)
import { createOpenAI } from '@ai-sdk/openai';

const customOpenAI = createOpenAI({
  baseURL: process.env.OPENAI_PROXY_URL || 'https://api.openai.com/v1',
  apiKey: process.env.OPENAI_API_KEY,
});

// 然后在 streamText 中使用
const result = streamText({
  model: customOpenAI('gpt-4o-mini'),
  messages,
});

// 方案 3:超时 + 重试处理
const result = streamText({
  model: openai('gpt-4o-mini'),
  messages,
  abortSignal: AbortSignal.timeout(15000), // 15 秒超时
});

生产环境中建议同时配置超时和错误监控。当代理服务不稳定时,考虑配置多个代理地址做故障切换,或者直接使用国内大模型(如 DeepSeek、通义千问)作为降级方案。

陷阱 4:Token 用量感知——不知不觉花了多少钱

大模型 API 按 token 计费,但很多开发者对实际消耗没有概念。useChat 每次请求都会发送完整的对话历史给 API——这意味着对话越长,每次请求的 token 消耗越大。一轮 20 条消息的对话,最后一条请求可能包含上万个 prompt token。

// 在后端获取 token 使用信息
const result = streamText({
  model: openai('gpt-4o-mini'),
  messages,
  onFinish({ usage }) {
    // usage.promptTokens: 输入 token 数
    // usage.completionTokens: 输出 token 数
    console.log(`Token 消耗: 输入 ${usage.promptTokens}, 输出 ${usage.completionTokens}`);

    // 生产环境:写入数据库做用量统计
    // await db.insert(tokenUsage).values({ ... });
  },
});

成本估算公式(以 gpt-4o-mini 为例):输入约 $0.15/百万 token,输出约 $0.60/百万 token。一条普通的多轮对话完整处理下来大约花费 $0.001-$0.005。看似不多,但如果日活用户上千,每天产生上万次对话,成本会快速累积。建议在早期就做好用量监控和告警。

陷阱 5:速率限制 (Rate Limiting)

OpenAI 对每个 API Key 有速率限制(RPM: 请求/分钟,TPM: token/分钟)。当你的应用上线后,多个用户同时使用同一个 API Key 很容易触发限制,返回 429 Too Many Requests 错误。应对策略:

  • 在 Route Handler 中实现用户级别的速率限制(使用 upstash/ratelimit 等库)
  • 捕获 429 错误并返回友好提示,而非直接抛出异常
  • 申请提高 OpenAI 的速率配额(需要在 OpenAI 后台提交申请)
  • 使用消息队列做削峰填谷(适合高并发场景)

🔗 扩展阅读

流式 AI 方案对比

维度 Vercel AI SDK LangChain.js 直接使用 OpenAI SDK
流式 UI 集成 ⭐⭐⭐ 开箱即用(useChat) ⭐⭐ 需手动对接 ⭐ 完全手写
多模型支持 ⭐⭐⭐ 统一适配器接口 ⭐⭐⭐ 生态最丰富 ⭐ 仅 OpenAI
Tool Calling ⭐⭐⭐ 内置 + UI 渲染 ⭐⭐⭐ 内置 + Agent 链 ⭐⭐ 内置但需手动处理
学习曲线 ⭐⭐⭐ 简单直接 ⭐ 概念多、抽象重 ⭐⭐ 简单但功能少
包体积 轻量(~50KB) 较重(~200KB+) 最轻(~30KB)
适用场景 Next.js/React 全栈应用 复杂 AI Agent/RAG 管道 简单脚本、后端服务

选择建议:如果你的项目是 React/Next.js 应用并需要流式聊天 UI,Vercel AI SDK 是最优选择。如果你需要构建复杂的多步骤 Agent、RAG 管道或链式推理,LangChain.js 提供了更多编排工具。两者也可以结合使用。

推荐资源

下一篇预告

在本系列的第二篇教程中,我们将为这个聊天界面添加 Tool Calling(工具调用) 能力。你将学到:如何用 Zod 定义工具的参数 Schema,如何让大模型自动决策何时调用工具,以及如何在前端根据工具调用的状态(调用中 → 返回结果)渲染动态 React 组件——比如实时图表、天气卡片等交互式 UI。这是从"聊天机器人"进化到"AI Agent"的关键一步。

💻 核心参考代码 (Reference Implementation)
// 典型实现逻辑 / Code outline
// 如需获取该场景下完整可运行的代码库与技术顾问指导,请联系我们
console.log("Loading module: $全栈开发...");
console.log("Configuring agent pipeline: $Next.js + Vercel AI SDK 实战(一):零基础搭建流式 AI 聊天界面...");
console.log("Dependencies active. Pipeline initializing...");
// TODO: Custom code hooks for wolaizuo solutions.

* 本文为“我来做”动手开发实战教程。如果您不想亲自编写代码,或者需要更深入的企业系统(ERP/CRM)对接与私有化部署,欢迎点击下方按钮预约我们的免费诊断服务。

联系我们代为开发
返回教程列表