AB
AiBoss
チュートリアル

Vercel AI SDK

チュートリアル

用 Vercel AI SDK 搭建流式 AI 聊天应用:从 API 路由到工具调用

面向 React 与 Next.js 开发者的实操教程:用 Vercel AI SDK 处理模型流式输出与工具调用,用 shadcn/ui 搭建消息列表、输入框与滚动区域,从零跑通一个可流式渲染、可扩展的聊天界面。

几乎每个 AI 产品打开后都是同一套界面:一列消息、底部一个输入框、文字一个 token 一个 token 地冒出来。看起来简单,做起来不简单——流式状态、半截 token、工具调用、重试、Markdown 渲染、滚动位置,还有一堆细碎的交互细节都要照顾到,同时还得保证可访问性和响应速度。工具选错,你花在跟状态 bug 搏斗上的时间会比做产品本身还多。

这篇教程用两个配合度很高的工具搭一个真正能用的 AI 聊天界面:Vercel AI SDK 负责流式传输与模型逻辑,shadcn/ui 负责界面本身。做完之后你会得到一个能流式输出、能渲染 Markdown、看起来像能直接上线的聊天页面,同时也会看到模型如何调用你代码里的真实函数。价格、配额、模型可用性这类信息变动频繁,请以官网当前信息为准。

准备工作

开始之前先确认环境与账号条件:

  • Node.js 18 或更高版本。低于这个版本,Next.js 与 AI SDK 的依赖安装可能直接失败。
  • React 与 Next.js 的基础知识,尤其是 App Router 的目录约定与客户端组件标记方式。教程里的代码全部基于 App Router。
  • 一个 LLM 服务商的 API Key,例如 OpenAI、Anthropic 或 Google。没有 Key 也能跟着做——后面会给出一个不依赖真实模型的回退方案,先把界面搭起来,之后再接模型。
  • 可用的网络与包管理器。示例使用 npm,换成 pnpm 或 yarn 时命令对应替换即可。

建议从一个空目录开始,一路做到可以拿给同事看的状态。整个项目几乎都在 app 目录内完成。

操作步骤

第一步:创建 Next.js 项目

用 TypeScript 与 Tailwind 初始化一个新项目:

npx create-next-app@latest ai-chat-app --typescript --tailwind --eslint --app
cd ai-chat-app

CLI 后续询问的其他选项保持默认即可。TypeScript 保证消息结构、工具入参这些地方有类型提示;Tailwind 是 shadcn/ui 组件样式的基础,两者都建议开启。

第二步:安装 Vercel AI SDK

AI SDK 承担了大部分重活:它提供一套统一 API 来调用不同模型服务商、流式输出文本与结构化数据、处理工具调用,这样切换模型时不需要重写聊天逻辑。

安装核心包、React 绑定,以及一个 OpenAI 兼容的 provider:

npm install ai @ai-sdk/react @ai-sdk/openai-compatible

其中 @ai-sdk/openai-compatible 值得单独说明:它不需要你为每个服务商安装单独的包,只要对方提供 OpenAI 风格的 API(OpenAI 本身、Gemini、Groq,以及大量自建部署),换一个 base URL 就能对接。配合一个 AI_PROVIDER 环境变量,就能在不改动路由处理函数的前提下切换服务商——这正是下一步要建立的模式。

第三步:为什么聊天界面适合用 shadcn/ui

在写任何界面代码之前,值得先理解为什么大量 AI 聊天产品选择 shadcn/ui,而不是传统组件库。

多数组件库交给你的是一个编译好的包,内部实现被 props 挡在后面。这对设置页没问题,对聊天界面却很糟。聊天场景下你需要精确控制:消息气泡在流式输出时怎么动、"思考中"指示器怎么表现、工具调用的渲染怎么和普通文本区分开。shadcn/ui 走的是另一条路——它不安装包,而是把组件的真实源码复制进你的项目。代码完全归你所有,不用跟抽象层较劲去适配自己的场景,也不用等维护者暴露某个你需要的 prop。

这种"拥有源码"的模式恰好是聊天界面需要的,因为几乎没有两个 AI 产品在消息、推理过程、工具输出的渲染方式上是一样的。也正因如此,围绕它长出了一整套生态,包括默认注册表之外的生产级区块与模板,覆盖仪表盘、营销区块和完整聊天界面。

第四步:在项目中配置 shadcn/ui

项目已经建好,直接应用预设:

npx shadcn@latest apply --preset b0

这一步会在现有项目里生成 components.json、Tailwind 配置以及 lib/utils.ts

接着拉取聊天界面需要的组件:

npx shadcn@latest add button input textarea scroll-area avatar separator

每次 add 都会把真实可读的组件源码复制到 components/ui/,可以像项目里其他文件一样导入和修改,之后不存在需要对抗的编译产物。

如果不想从单个基础组件拼装消息列表和输入区,也可以直接添加现成的 AI 聊天区块:

npx shadcn@latest add @shadcn-space/ai-chat-01
npx shadcn@latest add @shadcn-space/ai-chat-03

其中 ai-chat-01 提供对话界面本体:欢迎屏、建议提示词、可滚动的消息流,以及带附件与模型选择器的输入区。ai-chat-03 提供外围应用外壳:可折叠侧边栏、置顶与最近会话、搜索、顶栏。两者可以单独安装,也可以一起装,省去从零搭外围界面的时间。需要注意的是这两个区块属于付费内容,具体价格与授权方式请以官网当前信息为准。

如果想要免费的侧边栏,标准 shadcn 的 sidebar-07 是轻量且不收费的替代方案:

npx shadcn@latest add sidebar-07

第五步:编写流式 API 路由

创建 app/api/chat/route.ts。这个服务端文件负责与模型通信,并把响应流式传回浏览器。

// app/api/chat/route.ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { convertToModelMessages, streamText, type UIMessage } from "ai";

const PROVIDERS: Record<string, { baseURL: string; model: string }> = {
  openai: { baseURL: "", model: "gpt-4o-mini" },
  gemini: { baseURL: "", model: "gemini-2.5-flash" },
  groq: { baseURL: "", model: "llama-3.3-70b-versatile" },
};

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const providerName =
    process.env.AI_PROVIDER?.trim().toLowerCase() ?? "openai";
  const { baseURL, model } =
    PROVIDERS[providerName] ?? PROVIDERS.openai;

  const provider = createOpenAICompatible({
    name: providerName,
    baseURL: process.env.AI_BASE_URL ?? baseURL,
    apiKey: process.env.AI_API_KEY,
  });

  const result = streamText({
    model: provider(process.env.AI_MODEL ?? model),
    system: "You are a concise, helpful assistant.",
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

几处关键点:

  • createOpenAICompatible 给出一个可对接任意 OpenAI 风格 API 的 provider 实例。把 AI_PROVIDERopenaigeminigroq 之间切换,路由处理函数完全不用改。
  • convertToModelMessages 负责把客户端发来的 UI 消息格式,转换成模型服务商期望的格式。
  • streamText 启动模型生成,返回一个可以直接管道到客户端的流。
  • toUIMessageStreamResponse() 把这个流包装成客户端 useChat 钩子能逐 token 消费的响应。

.env 里配置服务商与密钥:

# .env
AI_PROVIDER=openai
AI_API_KEY=

如果暂时没有 Key,仍然可以先做界面。让这个路由返回一段预置的流式响应即可,下面的客户端代码并不关心流来自哪里。

第六步:用 useChat 接通客户端

创建 components/chat.tsx

// components/chat.tsx
"use client";

import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";

export function Chat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

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

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    sendMessage({ text: input });
    setInput("");
  };

  return (
    <div className="flex h-screen flex-col">
      <ScrollArea className="flex-1 p-4">
        <div className="mx-auto flex max-w-2xl flex-col gap-4">
          {messages.map((message) => (
            <div
              key={message.id}
              className={cn(
                "flex gap-3",
                message.role === "user" && "justify-end"
              )}
            >
              {message.role !== "user" && (
                <Avatar className="h-8 w-8">
                  <AvatarFallback>AI</AvatarFallback>
                </Avatar>
              )}
              <div
                className={cn(
                  "max-w-[75%] rounded-2xl px-4 py-2 text-sm",
                  message.role === "user"
                    ? "bg-primary text-primary-foreground"
                    : "bg-muted"
                )}
              >
                {message.parts.map((part, i) =>
                  part.type === "text" ? (
                    <span key={i}>{part.text}</span>
                  ) : null
                )}
              </div>
            </div>
          ))}
        </div>
      </ScrollArea>

      <form className="border-t p-4">
        <div className="mx-auto flex max-w-2xl items-end gap-2">
          <Textarea
            value={input} => setInput(e.target.value)}
            placeholder="Message the assistant..."
            className="min-h-11 flex-1 resize-none"
            disabled={isLoading}
          />
          <Button type="submit" disabled={isLoading || !input.trim()}>
            Send
          </Button>
        </div>
      </form>
    </div>
  );
}

<Chat /> 放进 app/page.tsx,运行 npm run dev,你就有了一个可用的流式聊天界面。模型返回的每条消息会逐字出现而不是一次性刷出,status 也提供了干净的方式在响应进行中禁用输入。

注意 useChat 在这里做了很多安静的工作:它持有消息列表、在数据块到达时处理流式重组,并管理 submittedstreamingready 这几个生命周期状态,这些都不需要你自己跟踪。

第七步:让模型调用工具

只能聊天的输入框是有局限的。AI SDK 允许模型调用你代码里的真实函数,并用一份 JSON schema 描述它被允许传入的输入。

在第五步的 provider 配置旁边,往路由处理函数里加入工具定义:

// app/api/chat/route.ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import {
  convertToModelMessages,
  jsonSchema,
  streamText,
  tool,
  type UIMessage,
} from "ai";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const provider = createOpenAICompatible({
    name: "openai",
    baseURL: "",
    apiKey: process.env.AI_API_KEY,
  });

  const result = streamText({
    model: provider("gpt-4o-mini"),
    messages: convertToModelMessages(messages),
    tools: {
      getWeather: tool({
        description: "Get the current weather for a city",
        inputSchema: jsonSchema<{ city: string }>({
          type: "object",
          properties: {
            city: {
              type: "string",
              description: "The city to get the weather for",
            },
          },
          required: ["city"],
        }),
        execute: async ({ city }) => {
          // 在这里调用你真实的天气数据源
          return { city, temperature: 22, condition: "clear" };
        },
      }),
    },
  });

  return result.toUIMessageStreamResponse();
}

要点在于:description 告诉模型这个工具是干什么的,inputSchema 用 JSON Schema 约束它能传什么参数,execute 是真正在服务端运行的函数。模型决定调用时,SDK 会执行它并把结果送回模型继续生成,整个过程仍然走同一条流式响应。

第八步:没有后端也能先做界面

界面开发往往比接模型更耗时,而两者并不需要串行。在没有 API Key 的阶段,可以让 app/api/chat/route.ts 返回一段预置的流式文本,客户端代码完全不用改。等 Key 到位,把路由换回真实的 streamText 调用即可。这样做的另一个好处是:调试界面时不会因为模型响应慢或额度不足而卡住。

一个完整示例

下面把前面的步骤串成一条从零到可运行的最短路径。

  1. 创建项目并进入目录:
    npx create-next-app@latest ai-chat-app --typescript --tailwind --eslint --app
    cd ai-chat-app
  2. 安装依赖:
    npm install ai @ai-sdk/react @ai-sdk/openai-compatible
  3. 配置 shadcn/ui 并拉取组件:
    npx shadcn@latest apply --preset b0
    npx shadcn@latest add button input textarea scroll-area avatar separator
  4. 写入 app/api/chat/route.ts,内容为第五步的完整代码。
  5. 写入 components/chat.tsx,内容为第六步的完整代码。
  6. app/page.tsx 中引入并渲染:
    import { Chat } from "@/components/chat";
    
    export default function Page() {
      return <Chat />;
    }
  7. 创建 .env 并填入服务商与密钥:
    AI_PROVIDER=openai
    AI_API_KEY=你的密钥
  8. 启动开发服务器:
    npm run dev

打开浏览器访问本地地址,输入一句话,应该能看到回复逐字出现。如果暂时没有密钥,把路由改成返回预置流式文本,界面部分同样可以完整验证。

注意事项

  • Node.js 版本:需要 18 或更高。版本过低会在安装或构建阶段直接报错。
  • API Key 不要写进客户端代码。密钥只在服务端路由中通过环境变量读取,客户端组件不应出现任何密钥。
  • 付费区块ai-chat-01ai-chat-03 属于付费内容,sidebar-07 是免费替代。价格与授权条款请以官网当前信息为准。
  • 模型名称与可用性会变:示例中的模型标识只是占位,实际可用型号、上下文长度、速率限制与计费方式请以对应服务商的官网当前信息为准。
  • baseURL 需要按服务商填写:示例中留空的位置要替换成对应服务商的接口地址,否则请求无法发出。
  • 工具调用的输入校验inputSchema 只约束模型能传什么,execute 内部仍应对参数做自己的校验,尤其是会触发外部请求或写操作的场景。
  • 流式状态与输入禁用:响应进行中应禁用输入与提交按钮,否则用户可能在流未结束时重复发送,导致消息顺序混乱。
  • 消息渲染按 parts 遍历:一条消息可能包含文本之外的部分,只渲染 text 类型会丢掉工具调用等其他内容,需要按需扩展渲染分支。