你有没有遇到过这种情况:照着官方文档写了个 AI 聊天页面,useChat 一跑,消息倒是能出来,但每次回复都会闪一下空白,用户体验直接拉胯?
翻了半天 issue 才发现,不是你代码写错了,是流式响应的状态管理有个隐藏的时序问题——而官方文档压根没提。
这就是 Vercel AI SDK 的典型体验:上手极快,但坑藏得极深。今天这篇,是我用它做了 3 个项目后,把所有踩过的坑、摸出的最佳实践,一次性讲清楚。
先搞明白一件事:AI SDK 现在到底是什么版本?
很多人上来就 npm install ai,装完也不看版本号就开干。但 2026 年的 AI SDK 生态已经不是一年前的样子了。
目前的情况是:稳定版 AI SDK 5,Beta 版 AI SDK 6。如果你是新项目,建议直接上 v6 Beta;如果是老项目维护,先别急着升——v4 到 v6 的 breaking changes 能让你改到怀疑人生。
举个真实的例子:maxTokens 改成了 maxOutputTokens,CoreMessage 改成了 ModelMessage,generateObject() 被废弃,tool 调用的 args 改成了 input。这些改动分散在十几个文件里,编译器不会提醒你,只有运行时才会崩。
一个血泪教训:升级前先锁版本,逐个文件排查 API 变更,别想着一把梭。
useChat 用起来爽,但这 3 个坑你一定会踩
useChat 是 AI SDK 最核心的 React Hook,一行代码就能搞定聊天界面的状态管理。但"能用"和"好用"之间,隔着三个大坑。
坑一:消息闪烁的元凶——状态更新时序
用 useChat 的时候,你大概率会写出这样的代码:
'use client';
import { useChat } from '@ai-sdk/react';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.content}</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</div>
);
}
看起来没毛病对吧?但实际运行时,AI 回复的第一帧会出现一个空消息气泡,然后才开始填充内容。原因是 useChat 在收到流的第一个 chunk 之前,就已经往 messages 数组里 push 了一条空的 assistant 消息。
解决方案很简单,渲染时过滤掉空内容:
{messages
.filter((m) => m.content.trim() !== '')
.map((m) => (
<div key={m.id}>{m.content}</div>
))}
一行代码的事,但不知道原因的话能调半天。
坑二:多轮对话的内存泄漏
useChat 默认会把所有历史消息都存在内存里,每次请求都带上完整的 messages 数组发给后端。聊个 20 轮,请求体就膨胀到几十 KB,token 消耗也跟着飙升。
我的做法是在后端做消息截断:
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
// 只保留最近 10 轮对话 + system prompt
const recentMessages = messages.slice(-20);
const result = await streamText({
model: openai('gpt-4-turbo'),
messages: recentMessages,
});
return result.toDataStreamResponse();
}
别在前端截断,因为 useChat 的内部状态和你手动改的 messages 会打架。后端截断是最干净的方案。
坑三:错误处理的"沉默失败"
useChat 默认的错误处理是——没有错误处理。API 返回 500,前端不报错,消息列表里也不会显示任何提示,用户只会看到"AI 不说话了"。
必须手动加上 onError 回调:
const { messages, input, handleSubmit } = useChat({
onError: (error) => {
toast.error('AI 开小差了,请稍后重试');
console.error('Chat error:', error);
},
});
这三个坑,官方文档一个都没重点提。但凡做过真实项目的人,100% 会遇到。
streamText vs generateText:选错了性能差 10 倍
AI SDK 提供了两种核心的文本生成方式:streamText(流式)和 generateText(一次性返回)。很多人觉得"流式更酷"就无脑用 streamText,但实际上选错场景会严重影响性能。
什么时候用 streamText:
- 聊天界面,需要逐字显示
- 长文本生成,用户需要看到进度
- 任何需要"打字机效果"的场景
候用 generateText:*
- 后台任务,比如批量生成摘要
- 结构化数据提取,比如从文本中提取 JSON
- 不需要实时展示的场景
我在一个项目里犯过的错:用 streamText 做批量内容审核,100 条内容串行流式处理,耗时 3 分钟。改成 generateText + Promise.all 并发,15 秒搞定。
// ❌ 错误:串行流式处理批量任务
for (const item of items) {
const result = await streamText({
model: openai('gpt-4-turbo'),
prompt: `审核以下内容:${item.text}`,
});
// 还得等流读完...
}
// ✅ 正确:并发一次性生成
const results = await Promise.all(
items.map((item) =>
generateText({
model: openai('gpt-4-turbo'),
prompt: `审核以下内容:${item.text}`,
})
)
);
记住:,不是给机器用的。
Provider 切换:一行代码的事,但 90% 的人架构都写错了
AI SDK 最大的卖点之一是"多 Provider 支持"——OpenAI、Anthropic、Google、DeepSeek,换模型只需要改一行代码。
理论上是这样:
// 从 GPT-4 切到 Claude
- model: openai('gpt-4-turbo'),
+ model: anthropic('claude-3-5-sonnet'),
但实际项目中,你的模型配置散落在 15 个文件里,每个 API 路由都硬编码了 provider。想切模型?得改 15 个文件,漏一个就是线上事故。
正确的做法是用 Adapter 模式做一层抽象:
// lib/ai-provider.ts
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
const providers = {
openai: (model: string) => openai(model),
anthropic: (model: string) => anthropic(model),
} as const;
type ProviderKey = keyof typeof providers;
export function getModel(
provider: ProviderKey = 'openai',
model?: string
) {
const defaultModels: Record<ProviderKey, string> = {
openai: 'gpt-4-turbo',
anthropic: 'claude-3-5-sonnet',
};
return providers[provider](model ?? defaultModels[provider]);
}
然后所有 API 路由统一调用:
import { getModel } from '@/lib/ai-provider';
const result = await streamText({
model: getModel(), // 默认 OpenAI
messages,
});
切模型?改一处配置,全局生效。这不是过度设计,这是基本的工程素养。
最近有篇案例:一个团队 15 个文件都直接依赖 SDK,每次升级都是噩梦。后来用 Ports and Adapters 模式重构,把 SDK 依赖收敛到 2 个文件,下次升级改动范围从 15+ 文件压缩到 2 个,28 个业务文件零变更。
结构化输出:generateObject + Zod 才是真正的杀手锏
很多人用 AI SDK 只用到了聊天功能,但其实 generateObject 才是生产环境里最值钱的 API。
传统做法:让 AI 返回 JSON,然后 JSON.parse 赌运气,解析失败了再重试。
AI SDK 的做法:用 Zod 定义 schema,generateObject 直接返回类型安全的对象。
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const { object } = await generateObject({
model: openai('gpt-4-turbo'),
schema: z.object({
ntiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
keywords: z.array(z.string()).max(5),
}),
prompt: '分析这段用户评价的情感倾向:...',
});
// object.sentiment 自动推导为 'positive' | 'negative' | 'neutral'
// object.confidence 自动推导为 number
// 写错字段名?编译期直接报错
这比运行时抓 JSON 解析错误体面多了。 尤其是把 AI 输出喂给下游系统时,类型安全等于少写一半防御代码。
我在一个内容管理项目里,用 generateObject 做文章标签提取、情感分析、摘要生成,准确率比手写 prompt + JSON.parse 高了一截,代码量反而少了 60%。
工具调用:让 AI 不只是"嘴强"
streamText 支持 tools 参数,这是构建 AI Agent 的基础能力。定义好工具的描述、参数 schema 和执行函数,模型会自己判断什么时候调用。
const result = await streamText({
model: openai('gpt-4-turbo'),
messages,
tools: {
getWeather: {
description: '获取指定城市的天气信息',
parameters: z.object({
city: z.string().describe('城市名称'),
}),
execute: async ({ city }) => {
const res = await fetch(`/api/weather?city=${city}`);
return res.json();
},
},
},
});
用户问"北京明天穿什么",AI 自动识别意图、填参数、调你的函数、拿结果、组织回复。整个过程对用户来说是无缝的。
但有个坑:工具调用的超时处理。如果你的工具函数调了一个慢接口(比如第三方 API),默认没有超时机制,AI 会一直等。建议给每个工具函数加上超时:
execute: async ({ city }) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`/api/weather?city=${city}`, {
signal: controller.signal,
});
return res.json();
} catch {
return { error: '天气服务暂时不可用' };
} finally {
clearTimeout(timeout);
}
},
写在最后
Vercel AI SDK 确实是目前前端接入大模型最省心的方案——统一的 Provider 抽象、开箱即用的 React Hooks、类型安全的结构化输出,这些设计都很优雅。
但"省心"不等于"无脑用"。真正的最佳实践,从来不是照着文档抄代码,而是理解每个 API 背后的设计意图,然后根据自己的场景做取舍。
你在用 AI SDK 的过程中踩过什么坑?评论区聊聊。
这是「Hans碎碎念」的文章,欢迎关注
夜雨聆风