← 返回资讯

Vercel AI SDK 前端流式接入大模型实战

2026-06-26

你大概率是这么撞上 Vercel AI SDK 的:产品经理要一个”打字机效果”的聊天框,你打开 OpenAI 的流式文档,看到一堆 ReadableStreamTextDecoder、手动拼 chunk 的代码,心里咯噔一下——这活儿在 React 里做状态管理要写一堆 useStateuseRef,稍不注意组件重渲染就把已经收到的字丢了。Vercel AI SDK(npm 包名就叫 ai)就是干这个的:一行 useChat hook 把流式追加、消息历史、loading 状态全包了,服务端 streamText 对接任意 OpenAI 兼容 API,你不需要自己解析 SSE(Server-Sent Events)协议里那些 data: {...}\n\n 的分块数据。

这篇文章不只是把官方 quick start 抄一遍,而是把我踩过的坑(401 到底是哪层报的、429 该怎么退避、useChat 为什么有时候消息顺序乱了)一并讲清楚,你跟着做完能直接上生产。

安装

npm install ai @ai-sdk/openai

@ai-sdk/openai 是官方 OpenAI provider,同时支持所有 OpenAI 兼容平台。这里要多说一句:它不是”只认 OpenAI 官方接口”,而是认 OpenAI 定义的这套请求/响应格式(/v1/chat/completions/v1/completions 这种路径和 JSON 结构)。凡是按这套格式实现的第三方平台,只要换个 baseURL 就能直接用,这也是为什么后面能无缝切到力达云这类兼容平台——本质上你换的只是”这套协议的实现方,不换协议本身”。

版本上要提醒一句:Vercel AI SDK 3.x 和 4.x 在 API 上有不小差异(比如 toDataStreamResponse 在 4.x 里改名成了 toUIMessageStreamResponseuseChat 的返回字段也调整过)。本文按 3.x 系列的稳定 API 写,如果你 npm install ai 装到的是 4.x,先看一眼 node_modules/ai/package.json 里的 version 字段,对不上就去官方 migration guide 过一遍,别对着老代码硬猜。

服务端:Route Handler(Next.js App Router)

app/api/chat/route.ts 创建流式接口:

import { streamText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";

const openai = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY!,
  baseURL: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
});

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

  const result = await streamText({
    model: openai("gpt-4o-mini"),
    messages,
    system: "你是一个简洁的技术助手。",
  });

  return result.toDataStreamResponse();
}

baseURL 指向力达云或其他兼容平台即可切换模型,前端代码无需改动。

这段代码里有几个新手容易忽略的细节,逐个说:

  • process.env.OPENAI_API_KEY! 末尾那个感叹号是 TypeScript 的非空断言,意思是”我保证这个环境变量一定存在”。但现实是它经常不存在——本地 .env.local 忘了配,或者 Vercel 部署时环境变量没同步到对应的 Environment(Production/Preview/Development 是分开配的),这时 createOpenAI 拿到的 apiKeyundefined,请求发出去后上游会返回 401。排查顺序:先在 Route Handler 里加一行 console.log(!!process.env.OPENAI_API_KEY) 确认变量真的注入了,再看 401 的响应体里 error.message 具体说的是 key 无效还是 key 缺失,这两种报错文案不一样,前者是 key 拼错或过期,后者是根本没传上去。
  • streamText 这一步实际发生了什么:它在内部帮你拼好符合 OpenAI Chat Completions 协议的请求体(messagesmodelstream: true 等字段),发出 HTTP 请求后拿到的是一个持续吐 chunk 的响应流,SDK 把这些 chunk 解析、拼装成统一的内部格式,再通过 toDataStreamResponse() 包装成浏览器能用 useChat 直接消费的 Response。也就是说你完全不用关心”上游返回的到底是逐字还是逐句的 chunk”,SDK 这层已经把协议差异抹平了。
  • 超时怎么设streamText 支持传 abortSignal,配合 AbortController 可以做请求级超时,比如:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000); // 30 秒超时

const result = await streamText({
  model: openai("gpt-4o-mini"),
  messages,
  abortSignal: controller.signal,
}).finally(() => clearTimeout(timeout));

流式接口本身没有”整体响应超时”的说法(因为它一直在吐字),这里设的是”首字节 + 流式过程中卡住”的兜底超时,避免上游卡死导致 Next.js 的 Serverless Function 一直挂着不释放。

  • 429 限流怎么处理streamText 抛出的错误对象里能拿到 HTTP 状态码,429 通常意味着你这个 API Key 的 RPM(每分钟请求数)或 TPM(每分钟 token 数)超限了。生产环境不建议无脑重试,至少要做指数退避(比如第一次等 1 秒,失败再等 2 秒、4 秒,最多退避 3 次),否则会在限流期间打出更多请求,越限越死。如果你的场景是高并发聊天(比如客服机器人),更稳的做法是在业务层做请求队列或者升级到更高并发额度的套餐,而不是指望重试兜底。

前端:useChat Hook

"use client";
import { useChat } from "ai/react";

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } =
    useChat({ api: "/api/chat" });

  return (
    <div>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>
            <strong>{m.role === "user" ? "你" : "AI"}:</strong>
            {m.content}
          </li>
        ))}
      </ul>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="输入问题..." />
        <button type="submit" disabled={isLoading}>发送</button>
      </form>
    </div>
  );
}

useChat 自动处理流式追加、消息历史、loading 状态,无需任何手动状态管理。你如果自己手写过这套逻辑就知道有多省心:手写版本至少要维护一个 messages 数组的 state、一个用来标记”正在收流”的 boolean、还要处理”收到新 chunk 时怎么把它追加到最后一条 assistant 消息而不是新建一条”——这个追加逻辑很容易写错,一不小心就变成每个 chunk 都新建一条消息,界面上刷出一排空气泡。useChat 内部帮你处理好了这个”追加到当前流式消息 vs 开始新消息”的判断,你只管拿 messages 数组渲染就行。

自检:把上面两段代码跑起来后,在输入框敲一句话点发送,你应该看到的现象是——按钮先变成 disabled(isLoading 为 true),紧接着页面上出现一条空的 AI 气泡,然后文字像打字机一样一个字一个字往外冒,全部吐完后按钮恢复可点击。如果你看到的是”转圈很久后一次性甩出一大段文字”,说明流式没生效,大概率是 toDataStreamResponse() 写成了 toTextStreamResponse(),或者中间有反向代理(比如 Nginx)把响应缓冲了没有透传流式数据,需要在代理层关掉 proxy_buffering

useChat 还有几个实战中常用但容易被忽略的能力:

  • onError 回调:处理网络错误或服务端抛出的异常,不加这个的话流式请求失败时界面上什么反馈都没有,用户以为卡住了。
  • onFinish 回调:流结束后触发,适合在这里做埋点、把最终消息存库、或者读取 token 用量。
  • stop() 方法useChat 返回值里还有一个 stop,绑定到”停止生成”按钮上,调用后会中断正在进行的流式请求,这个在长回答场景(比如让模型写一篇长文档)里是刚需体验,用户经常打到一半发现方向不对想立刻打断。
const { messages, input, handleInputChange, handleSubmit, isLoading, stop, error } =
  useChat({
    api: "/api/chat",
    onFinish: (message) => {
      console.log("生成结束,最终内容长度:", message.content.length);
    },
    onError: (err) => {
      console.error("聊天请求出错:", err.message);
    },
  });

加了 error 之后记得在 JSX 里判断一下渲染个提示,别让用户对着一个”发送了但没反应”的界面干瞪眼。

关键 API 对比

函数用途返回值
streamText服务端流式生成StreamTextResult,含 toDataStreamResponse()
generateText服务端非流式生成GenerateTextResult,含 text 字符串
streamObject流式生成结构化 JSON适合表单/数据提取场景
useChat前端对话 hook消息列表 + 提交函数
useCompletion前端文本补全 hook适合单轮生成场景

环境变量配置(.env.local

OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.lidayun.com/v1   # 可选,切换兼容平台

常见问题

Q:Vercel AI SDK 支持 Next.js 以外的框架吗? 支持。streamText/generateText 是纯服务端函数,可用于任何 Node.js 框架(Express、Hono、Fastify)。useChat 等 hook 依赖 React,但社区也有 Vue/Svelte 适配版本。

Q:toDataStreamResponse()toTextStreamResponse() 有何区别? 前者返回 AI SDK 协议格式(包含 metadata),配合 useChat hook 使用;后者返回纯文本 SSE,适合自定义前端解析。两者都是标准的 Response 对象。

Q:如何在流式响应中获取 token 用量? streamTextresult.usage 是一个 Promise,流结束后 resolve,包含 promptTokenscompletionTokens。这两个数字乘以你所用模型的单价(具体单价以官方定价页/所用平台计费页为准,截至 2026-06 各家模型单价差异较大,别拿旧数字估算),就是这一轮对话的实际成本,建议在 onFinish 或服务端拿到 usage 后落库,按用户/会话维度累计,不然月底账单一算你都不知道钱花哪儿了。想更细一步的,还可以把 promptTokenscompletionTokens 分开记,因为大部分平台的输入输出单价是不一样的(通常输出比输入贵),只记总量会让你误判成本构成。

Q:context 超限(比如报错里提到 context_length_exceeded 或类似字样)怎么办? 先搞清楚是”单次输入太长”还是”多轮历史攒太多了”。前者常见于你把一整篇长文档拼进 system 或用户消息里,后者常见于长对话场景里 messages 数组越攒越长,每一轮都把之前所有轮次原样带上。排查时先打印一下这次请求 messages 数组序列化后的大致字符数(中文按 1 字符约 1.5-2 token 粗估,不同 tokenizer 会有差异,精确值以模型方 tokenizer 为准),如果远超模型的上下文窗口,两个方向修:一是做滑动窗口只保留最近 N 轮 + 一份摘要,二是把长文档做检索(RAG)而不是整篇塞进去。useChat 本身不会帮你截断历史,这个逻辑要自己在传给 streamText 之前处理,比如在 Route Handler 里对 messages 做个长度裁剪:

const MAX_HISTORY = 20;
const trimmedMessages =
  messages.length > MAX_HISTORY ? messages.slice(-MAX_HISTORY) : messages;

这只是最朴素的”只留最近 N 条”,真要做长对话产品,通常还得给裁掉的历史生成一份摘要塞进 system,不然模型会”失忆”用户之前说过的关键信息。

Q:useChat 和自己手写 fetch + ReadableStream 该怎么选? 两者不是非此即彼,看场景:

场景推荐方案理由
标准聊天 UI,React/Next.js 技术栈useChat消息状态、流式追加、loading 全包,几十行代码搞定
需要自定义消息渲染逻辑(比如带卡片、按钮的富消息)useChat + 自定义 rendermessages 数组结构清晰,可以按 role/内容自己拼 UI
非 React 技术栈(Vue、纯 HTML)手写 fetch + ReadableStreamuseChat 依赖 React,其他框架要么用社区适配版本要么自己实现
需要对接非 OpenAI 协议的自研模型手写或自定义 providerAI SDK 的 provider 机制虽然可扩展,但协议差异大时自己控制反而更省心
后端到后端调用(无 UI),比如批处理任务直接用 generateText,不需要 useChat没有前端展示需求,用非流式的 generateText 逻辑更简单

我自己的经验是:只要项目本身就是 Next.js/React 技术栈,useChat 几乎没有理由不用——省下来的手写状态管理时间,够你多做两个业务功能了。真正要自己动手的场景,往往是协议本身就和 OpenAI 格式对不上(比如某些国产模型的自定义鉴权头),这时候硬套 AI SDK 的 provider 抽象反而增加理解成本,不如直接手写请求逻辑更直观。


延伸阅读:大模型 API 接入完全指南 · 接入教程 Hub · Node.js 调用大模型 API 完整示例 · 流式输出原理与实现