首页 / 资讯中心 / 文章详情

AI SDK 6开发实战:文本生成、流式对话与多模型切换

AI SDK 6开发实战:文本生成、流式对话与多模型切换 ★ FEATURED ARTICLE
去年我接了一个客服工单分类的项目需求很简单把用户提交的工单内容自动打上“退款”“账号问题”“投诉”之类的标签。一开始我用最朴素的办法直接fetch大模型的API自己拼请求体、自己解析SSE流、自己维护多轮上下文。第一版确实能跑但两周后我就受不了了——换模型要改一堆硬编码流式输出纯靠手写解析器前端接消息还要自己发明协议。后来换成Vercel AI SDK代码量直接砍掉一大半才明白这类工具存在的意义它把“接入大模型”这件事从拼API变成了写业务逻辑。这篇是第一篇基础入门我默认你已经有基础的 React 和 Node.js 经验甚至可能之前用裸 API 调过模型但被各种细节折磨过。学完这篇你能独立搭出一个带流式打字机效果、支持多轮对话、可以随时切换模型的完整 Web 应用并且知道每一步为什么这么设计而不是照着文档复制粘贴。AI SDK 6 最新的包结构和注册方式我会重点讲因为网上很多教程还停留在老版本的写法照着抄容易踩版本坑。1. 从一次裸调 API 到选中 AI SDK 6我为什么在第一篇教程里推荐它1.1 裸调大模型 API 到底痛苦在哪先回顾一下我最开始的做法这样才能体会 SDK 帮你省掉的是哪些事。用fetch直接调 OpenAI 兼容接口大概是这样的const resp await fetch(https://api.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.API_KEY}, }, body: JSON.stringify({ model: gpt-4o-mini, messages: [{ role: user, content: 我的订单三天没发货 }], }), }); const data await resp.json(); console.log(data.choices[0].message.content);看着不复杂对吧但你接着往下想要支持流式输出就得把body里的stream设为true然后手动处理text/event-stream格式按行拆、按data:前缀解析、处理[DONE]标志位。要换一家供应商比如从 OpenAI 换到 Anthropic请求路径、消息格式、流式事件结构全不一样你得重写一遍。要做结构化输出就得自己写 JSON 校验逻辑模型偶尔多输出一个逗号你的解析器就炸了。这些活不是说不能干而是跟你的业务没有半点关系。你关心的是工单分类准不准而不是 SSE 流的解析正则写对没有。1.2 AI SDK 6 的核心设计把“模型接入”收敛成三个层面AI SDK 6 做的事情是把所有跟模型交互的细节抽象成三层用多了你会发现它的函数边界很清晰模型层通过ai-sdk/openai、ai-sdk/anthropic这样的独立包统一各家模型的调用方式和返回结构。你写的是openai(gpt-4o)但切换成anthropic(claude-3-5-sonnet)时代码骨架几乎不动。消息层用generateText和streamText两个核心函数分别对应非流式和流式生成。它们内部帮你处理了请求组装、错误重试、流解析、token 用量统计。UI 层用useChat这类 React hook把对话状态、消息增删、流式数据拉取全部封装好。后端返回的流协议也是 SDK 定义的前后端天然配套。这么说可能有点抽象。换个生活化的类比裸调 API 就像你每次打车都要先学会修发动机——你只需要去目的地但得自己保证车能跑。AI SDK 就是专车司机你报个地址剩下的交给他。当然你也可以自己租车自己开只是对于绝大多数应用来说这属于不必要的精力消耗。1.3 为什么新项目我直接推 AI SDK 6而不是继续用 4 或 5我在这个系列里直接讲 6是因为它的包结构比之前更干净注册多模型的方式也终于收敛成了标准做法。老版本那种experimental_createProviderRegistry前缀在 6 里转正了ai包和各个 provider 包的版本对齐逻辑也更严格报错信息比之前明确很多。如果你之前用过 4 或 5看这篇时重点关注第 6 章的注册方式还有我最后列的升级坑如果你是纯新人,那更简单,直接按 6 的写法来,别绕弯路了。2. 环境准备与最小可运行项目30分钟跑通第一个AI应用2.1 开发环境清单和版本对齐AI SDK 6 要求 Node.js 20 以上建议直接上 22 LTS避免后面用到一些新语法时报错。项目脚手架我用的是 Next.js App Router因为 AI SDK 的前端 hook 和后端 route handler 在 Next.js 里配合最顺教程也最多。如果你用 Vite 或 Remix核心 API 一样只是服务端暴露接口的地方不同。先创建一个项目pnpm create next-applatest ai-sdk-demo --ts --app --eslint创建完进入目录安装核心依赖cd ai-sdk-demo pnpm add ai ai-sdk/openai注意这里我故意没有装zod后面讲到结构化输出时再单独装。**只装ai和ai-sdk/openai两个包足够跑通第一个案例。**另外提醒一句ai包的版本必须是 6.x你可以用pnpm list ai确认如果锁到了老版本后面很多 API 会不一样。2.2 环境变量的配置时机以及一个很容易被忽视的细节接下来创建.env.local写入你的 API KeyOPENAI_API_KEYsk-xxxx这里有个细节AI SDK 的 provider 包默认会读取OPENAI_API_KEY这个变量名但我建议你在代码里显式传一次 baseURL 和 apiKey尤其是当你用中转网关或本地代理模型时。比如这样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, });后面在多模型切换时这个方式能让你在环境变量层面就控制走哪条线路不用改代码。2.3 第一个可运行的接入点服务端组件里的非流式调用先用最简单的方式验证环境没问题。在app/page.tsx里直接写一个服务端调用import { generateText } from ai; import { openai } from ai-sdk/openai; export const dynamic force-dynamic; export default async function Home() { const { text } await generateText({ model: openai(gpt-4o-mini), prompt: 用一个比喻解释什么是流式输出不超过50个字。, }); return ( main p{text}/p /main ); }跑pnpm dev打开页面你能看到模型生成的文字。这一步的意义是确认 Key 有效、网络通、SDK 能正常工作。如果你在这里报了 401 或超时优先检查环境变量是否生效因为 Next.js 只会把.env.local里的变量注入服务端代码。如果页面显示的是缓存版本的旧内容那就要检查force-dynamic这个我们第 7 章细说。3. generateText 解构非流式生成的单元测试与文本分类实践3.1 为什么先学非流式调试成本最低的执行单元很多人一上来就追求流式打字机效果但我的建议恰恰相反**先从非流式的generateText开始。**非流式的本质是“发一个请求等待完整结果返回”它像普通的异步函数一样有明确的输入输出方便你验参数、调提示词、写单元测试。流式是它的超集理解非流式之后流式只是把返回结果从“一个字符串”变成了“一串字符串”。generateText完整形态大概是这个样子import { generateText } from ai; import { openai } from ai-sdk/openai; const result await generateText({ model: openai(gpt-4o-mini), system: 你是一个客服工单分类器只输出工单类别不要解释。, prompt: 用户说我昨天买的东西一直没发货客服也不回我我要退款。, temperature: 0.3, maxOutputTokens: 50, });每个参数都有自己的作用system是给模型设定人设和约束的系统提示词它不影响对话历史但会持续约束输出风格。上例中“只输出工单类别”就是利用 system 来压缩输出范围。temperature控制随机性分类任务建议调低到 0.2~0.4让输出稳定。写文案或创意内容才需要 0.7 以上。maxOutputTokens限制输出长度防止模型啰嗦也方便控制成本。注意它计算的是 token 而不是字符中文文本一个汉字大约对应 1~2 个 token。3.2 返回结果里值得关注的四个字段generateText返回的对象通常包含这些字段字段含义实际用途text最终生成的纯文本大多数时候你只需要它usage输入和输出的 token 用量做成本统计、监控报警finishReason生成结束的原因如stop、length、tool-calls判断是不是被截断了toolCalls/toolResults工具调用相关信息做 Agent 时最有用基础篇先略过实际项目中finishReason length是一个高概率出现的状况说明输出被 token 上限截断了。你要是发现结果不完整第一反应应该是调大maxOutputTokens而不是怀疑模型变笨了。3.3 实战结合 zod 做结构化分类输出文本分类任务如果只让模型输出“退款”“投诉”这样的词直接用result.text匹配就行。但更常见的需求是输出包含多个字段的信息比如“类别 置信度 建议处理人”。这时靠字符串解析很容易出 bug正确做法是让模型按 JSON Schema 输出再用zod在本地校验。先安装zodpnpm add zod然后这样写import { generateText, tool } from ai; import { openai } from ai-sdk/openai; import { z } from zod; const classificationTool tool({ description: 对客服工单进行分类, parameters: z.object({ category: z.enum([退款, 账号问题, 物流投诉, 其他]), confidence: z.number().min(0).max(1), handler: z.string(), }), }); const result await generateText({ model: openai(gpt-4o-mini), prompt: 用户说我账号突然登不上了, tools: { classify: classificationTool }, }); console.log(JSON.parse(result.toolCalls?.[0].toolInput ?? ));这里的思想是**不让模型自由发挥输出格式而是提供工具的 schema让它生成符合 schema 的参数。**你可以把tool当作“强制结构化输出”的通道。实际跑上面例子会得到类似{ category: 账号问题, confidence: 0.9, handler: 账号组 }的 JSON。很多团队不知道这个技巧还在用“请输出 JSON 格式”碰运气结果遇到中文引号、多余换行就崩。如果你不需要工具调用这种重型机制也可以直接用output: json配合response_format但工具方式的兼容性更好尤其在切换不同厂商模型时它比各家自定义的 structured output 参数更通用。4. streamText 与流式响应的正确打开方式打字机效果背后的协议4.1 非流式和流式本质只是返回类型不同streamText和generateText的入参几乎一样区别在返回generateText返回的是一个 promiseresolve 后直接拿到完整文本streamText返回的是一个流对象里面最核心的属性是textStream——一个异步可迭代对象你可以用for await循环每次拿到一段增量文本。对照代码理解// 非流式一次性拿到全部结果 const { text } await generateText({ model, prompt }); // 流式分多段拿到最终拼接起来的结果 const result streamText({ model, prompt }); for await (const chunk of result.textStream) { console.log(chunk); // 每次输出一小段文本 }现实中这个流式输出会长得像这样for await (const chunk of result.textStream) { process.stdout.write(chunk); }打字机效果的本质就是前端不断接收这种“增量文本块”并追加到页面上用户看到文字一个字一个字蹦出来。相比等几秒后一次性展示全文这种方式在用户体验上有两个明显优势用户能立刻感知到系统在响应等待焦虑大幅降低网络传输也被摊薄首字节时间更快。4.2 在 Next.js Route Handler 中把流交给前端前端不能直接消费 Node.js 里那个普通异步流所以 AI SDK 定义了统一的 UI 消息流协议。在后端把流包装成 Response 返回给前端是它最标准、最省心的用法。在 Next.js App Router 下写成这样// app/api/chat/route.ts import { streamText } from ai; import { openai } from ai-sdk/openai; export const maxDuration 30; export const runtime nodejs; export async function POST(req: Request) { const { messages } await req.json(); const result streamText({ model: openai(gpt-4o-mini), messages, }); return result.toUIMessageStreamResponse(); }这段代码里有三个点值得展开第一messages直接从请求体里拿里面包含role和content两个字段是 OpenAI 风格消息数组。AI SDK 会自动把这些消息传给模型你不需要自己拼字符串。第二toUIMessageStreamResponse()不是简单地把文本流塞进 Response它输出的是一串携带消息 ID、角色、工具调用等元信息的协议数据。前端useChat之所以能准确切分每一轮消息、区分流里哪段是文本哪段是工具调用靠的就是这个协议。如果你自己拼 SSE这些事全得重写。第三runtime nodejs和maxDuration 30是我的经验之谈。Edge Runtime 对流式长连接支持不如 Node Runtime 稳定而且 Node Runtime 可以完整支持 AI SDK 的所有功能。Vercel 免费版默认最长执行时间是 10 秒做流式很容易超时所以我在本地开发时一般先把它改成 30再配合本地测试确认跑通。4.3 背压与中断流式开发最容易忽略的两个概念流式代码写起来简单但你接手真实项目后一定会碰到两个问题消费速度跟不上生产速度以及用户点击“停止”时怎么正确取消。第一个问题叫背压。textStream是一个异步迭代器每次next()拉取下一段数据天然具备“不被消费就不继续拉”的机制。所以在服务端如果你打算把流转发给中间件或缓存不要一次for await读完再转发应该边读边写否则流式就退化成非流式了。有些新手直接await stream.text把流一次性收集完等于自己把打字机效果弄没了。第二个问题是中断。前端调用abortController.abort()切断请求后服务端的streamText会抛出一个错误你必须捕获并优雅退出try { const result streamText({ model, messages }); return result.toUIMessageStreamResponse(); } catch (error) { console.error(stream interrupted:, error); return new Response(interrupted, { status: 499 }); }前端配合useChat的stop()方法能把“用户停止生成”从接口层到 UI 层完整打通。别嫌这个错误处理啰嗦我在生产环境里见过不止一次因为没捕获中断异常导致服务器日志里刷满红色堆栈。5. useChat 前端集成的完整链路从后端流到React状态5.1 一个 hook 接管所有对话状态后端准备好了前端如果还是自己写useState管理消息数组那前面省下的力气又白费了。AI SDK 提供了一个 React hook它帮我处理了以下这些繁琐环节维护messages数组、发送请求时承载上下文、接收增量流并逐段追加、加载状态、错误状态、用户停止生成代码只有几行use client; import { useChat } from ai/react; export default function Chat() { const { messages, input, handleSubmit, isLoading, stop } useChat({ api: /api/chat, }); return ( div classNamep-4 div classNamespace-y-4 {messages.map((m) ( div key{m.id} className{m.role user ? text-right : text-left} div classNameinline-block rounded-lg bg-gray-100 p-3 {m.content} /div /div ))} /div form onSubmit{handleSubmit} classNamemt-4 flex gap-2 input value{input} onChange{(e) setInput(e.target.value)} classNameflex-1 rounded-lg border p-2 placeholder输入你的问题... / button typesubmit disabled{isLoading} classNamerounded-lg bg-blue-600 px-4 py-2 text-white 发送 /button {isLoading ( button typebutton onClick{stop} classNamerounded-lg bg-red-500 px-4 text-white 停止 /button )} /form /div ); }等等这个代码里我没给你setInput的定义。useChat的返回值里其实包含setInput,所以你要在解构里加上它const { messages, input, setInput, handleSubmit, isLoading, stop } useChat({ api: /api/chat, });5.2 多轮对话的上下文到底在哪维护很多新手搞不清多轮对话的上下文是谁维护的。这里说清楚**上下文不在前端硬编码而是前端把messages数组原样 POST 给后端后端再把整个数组传给模型。**模型本身没有记忆它只是根据你提供的全部历史消息来生成回复。所以useChat里的messages既用于 UI 渲染也作为请求体里的对话历史。后端 route handler 里的const { messages } await req.json()拿到的就是前端传来的完整消息数组。你可以在后端正则过滤掉超长历史、截断以前的对话再传给streamText做上下文压缩。如果你想清空对话直接setMessages([]);这样前端消息清空了下一次请求也就不会携带旧历史了。简单直接。5.3 messages 的结构演进从字符串到 partsAI SDK 5 之后消息结构开始从单纯的内容字符串向parts结构化演进。新版中一条消息可能长这样{ id: msg-123, role: assistant, parts: [ { type: text, text: 这是回复文本 }, { type: tool-call, toolCallId: call-1, toolName: classify, args: JSON.stringify({ category: 退款 }) }, ], }如果你是新手直接渲染m.content是最省事的因为它是 plain text 的快捷方式。但一旦你的应用开始使用工具调用或者流中混入了非文本内容比如思考过程、工具结果继续依赖content可能取不到完整内容。到那个阶段就改用遍历parts的方式渲染每条消息渲染文本块、工具调用卡片等对应组件。基础篇你只要知道这个演进方向别在项目初期就把数据结构写死即可。6. Provider 注册体系与多模型切换配置6.1 从固定一个模型到统一注册多个 Provider用 AI SDK 6 之前如果我只接 OpenAI 一家直接import { openai } from ai-sdk/openai就完事。但如果项目要支持多家模型比如国内访问 OpenAI 不稳定需要备用 Anthropic 或本地模型而且要在界面上提供模型选择下拉框那就要用 Provider 注册体系统一管理。先安装对应 provider 包pnpm add ai-sdk/anthropic然后创建一个统一的注册表文件我在项目里通常放在lib/ai.tsimport { createOpenAI } from ai-sdk/openai; import { createAnthropic } from ai-sdk/anthropic; import { createProviderRegistry } from ai; const openai createOpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL, }); const anthropic createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); export const registry createProviderRegistry({ openai, anthropic, });使用时就变成了带上 provider 前缀的字符串import { registry } from /lib/ai; const result await generateText({ model: registry.languageModel(openai/gpt-4o-mini), prompt: 你好, });要切换到 Anthropic只需把模型 ID 换成anthropic/claude-3-5-sonnet-latest其他代码不动。**这就是注册体系最值钱的地方模型供应商从代码里被解耦出去变成一个可配置的参数。**如果你用环境变量控制这个字符串那甚至可以在不改代码的情况下切换不同厂商的模型。6.2 注册表选型时的取舍有人会问直接写死openai(gpt-4o)不也很快吗为什么非要注册表我的观点是小 demo 确实不需要但凡是准备上线、有预算控制、有降级预案的项目注册表几乎必选。因为它把“模型选择”变成了业务配置项而不是一段改完要重新部署的代码。注册表的另一个好处是能让 provider 实例复用配置。比如baseURL、apiKey这类敏感信息集中创建一次避免散落在各文件里。团队里有人误把 Key 提交到 Git 的风险也会小一些。不过也要提醒一句createProviderRegistry 在 AI SDK 6 里已经是正式 API 了但在更早的 5.x 里它叫experimental_createProviderRegistry。如果你网上搜到老代码记得去掉experimental_前缀或者直接升级到 6。6.3 多模型切换的一个完整实践价格优先降级给你一个可以直接抄的用法。假设你的产品允许用户选择快速模型和便宜模型而后端要根据当前某个状态自动降级const modelId process.env.MODEL_LEVEL cheap ? openai/gpt-4o-mini : openai/gpt-4o; const result streamText({ model: registry.languageModel(modelId), messages, temperature: 0.6, });这样你就获得了一个“配置驱动”的模型策略。后续要接入新的厂商唯一的成本是写一个 provider 实例、在注册表里加一行。这个扩展性很值得。7. 我在SDK 6实测中踩过的坑版本匹配、流中断与Next.js缓存7.1 版本不匹配报错信息不一定指向真凶我见过最多的问题来自ai主包和 provider 包版本不一致。比如ai是 6.x但ai-sdk/openai还是从老项目里带过来的 4.x运行时会出现一些奇怪的类型错误或“无法读取 undefined 的 xxx”。排查方法很简单pnpm list ai ai-sdk/openai如果版本不是同一个大版本执行pnpm up ai ai-sdk/openai把主包和 provider 包一起升到最新。它们实际上是紧密耦合的一套代码分开装、分开升很容易出现问题。7.2 流式请求在服务端组件里不动了这里必须先明确一个场景如果你在 Next.js 的页面组件Server Component里调用streamText然后想直接把流返回给浏览器这是不行的。页面组件渲染阶段不适合处理流式响应它更适合做非流式数据获取。要暴露流式接口必须走 Route Handler 或者 Server Action。如果你在 Server Component 里硬写常见现象是页面一直转圈等全部生成完才一次性渲染出来。正确做法是第 4 章那样单独建app/api/chat/route.ts由前端 hook 去请求。这一条看起来是常识但我在技术社区见过太多人把代码写在页面里然后排错半天。7.3 Next.js 默认缓存的“幽灵”问题Next.js 的 App Router 在绝大多数场景下默认启用静态渲染缓存。如果你在页面里调用了generateText但忘记标记动态渲染那么同样的请求可能会被当成静态内容缓存下来后续用户访问时拿到的是第一次的旧结果看起来就像是模型“变笨了”或“回复不更新了”。解决方式路由页面加export const dynamic force-dynamic;Route Handler 里的 POST 请求本身是动态的一般不需要额外设置但如果你用了 GET 请求做的接口记得注意缓存问题。这类问题最坑的地方在于本地开发环境通常正常部署到线上才会暴露排查时很难往缓存方向想。7.4 流只能消费一次以及中断后的资源释放result.textStream是一个单次可迭代对象。如果你在一次请求里既想记录完整文本用于后续保存又想转发给前端必须先手动把每个 chunk 同时写入一个数组再转发而不是事后去再次遍历。一个稳妥的模板const result streamText({ model, messages }); const chunks: string[] []; for await (const chunk of result.textStream) { chunks.push(chunk); // 这里把 chunk 继续转发到前端 } const fullText chunks.join();同理如果用户中断请求流会被提前终止你要在finally里做清理工作比如关闭数据库连接、释放缓存锁。不要依赖流正常结束来做这些事中断时它根本不会正常走到末尾。7.5 本地模型和中转网关的 baseURL 配置AI SDK 的 provider 包默认连接官方 API如果你想接本地部署的模型或者第三方中转网关千万记得在创建 provider 时传baseURL。我见过有人用环境变量OPENAI_API_KEY指向中转的 Key但忘记设置OPENAI_BASE_URL结果请求一直打到官方地址报 401 怎么都排查不出来。const openai createOpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL ?? https://api.openai.com/v1, });这种问题一旦发生日志里只会看到“Invalid API key”但真实原因是地址不对。把 baseURL 打出来看一眼十秒钟就能定位。最后说两句我的体会第一篇基础篇写到这里最核心的知识点其实就两个generateText做非流式、streamText配合useChat做流式交互再加上 provider 注册表解决多模型问题。掌握了这三件事你已经能应付大多数企业内部工具、客服机器人、内容生成功能的开发需求了。记得动手照着代码跑一遍环境配置和流式链路这些事光看不练真的会踩很多隐形坑。按照我的计划这个系列的下一篇会讲工具调用的完整链路——不只是后端让模型调用函数还包括前端把工具调用渲染成可交互的卡片、多步 Agent 循环的状态机设计以及流式输出过程中工具调用和文本混合时的正确解析姿势。这一块我在做客服机器人时踩过不少坑到时候我会把完整的排查思路和代码模板一起放出来。你要是照着这第一篇搭好了项目下一篇可以直接接着跑。
阅读完成 · 觉得有帮助?
咨询建站