AI SDK 6 与前端 AI 工程化
问题
如何使用 AI SDK 6 构建类型安全的 AI 应用?如何正确处理 UIMessage、SSE 流、结构化输出、工具调用、多 Provider、持久化与错误恢复?
AI SDK 的核心价值不是“少写一个 fetch”,而是提供三层统一抽象:
- AI SDK Core:
generateText、streamText、Output、tool,统一不同模型供应商。 - AI SDK UI:
useChat、UIMessage.parts、UI Message Stream,统一聊天状态和流协议。 - Provider 层:把统一的消息、工具和输出描述转换成各厂商协议。
AI SDK 6 的关键变化是:结构化输出并入 generateText / streamText 的 output;前端消息使用 UIMessage.parts;聊天发送使用 sendMessage;服务端返回 toUIMessageStreamResponse();多步工具循环使用 stopWhen。
本文以 AI SDK 6 为准。旧教程中的 generateObject、streamObject、LanguageModelV1、append()、Message.content、toDataStreamResponse() 和数字前缀 DataStream 都不应继续作为新项目模板。
一、整体架构
| 层 | 负责什么 | 不应该负责什么 |
|---|---|---|
| UI | 输入、渲染、取消、重试、可访问性 | API Key、权限判断、工具执行 |
| BFF/API Route | 鉴权、限流、上下文组装、模型调用 | 信任模型输出、跳过业务校验 |
| Provider | 协议转换、能力适配 | 业务路由和用户权限 |
| Tool | 确定性业务操作 | 自己决定是否有权操作 |
二、最小可用聊天应用
1. 安装
- npm
- Yarn
- pnpm
- Bun
npm install ai @ai-sdk/react @ai-sdk/openai zod
yarn add ai @ai-sdk/react @ai-sdk/openai zod
pnpm add ai @ai-sdk/react @ai-sdk/openai zod
bun add ai @ai-sdk/react @ai-sdk/openai zod
模型 ID 和能力变化很快,应放在服务端配置中心或环境变量中。下面使用 process.env.OPENAI_MODEL,避免把某个时点的“最佳模型”固化进业务代码。
2. 服务端路由
import { openai } from '@ai-sdk/openai';
import {
convertToModelMessages,
streamText,
type UIMessage,
} from 'ai';
export const maxDuration = 60;
export async function POST(request: Request): Promise<Response> {
// 生产环境应先完成鉴权、限流、输入长度与预算检查。
const body = (await request.json()) as { messages: UIMessage[] };
const result = streamText({
model: openai(process.env.OPENAI_MODEL!),
system: '你是一个严谨的中文技术助手。',
// UIMessage 面向渲染;ModelMessage 面向模型调用,二者不要混用。
messages: await convertToModelMessages(body.messages),
abortSignal: request.signal,
});
return result.toUIMessageStreamResponse({
originalMessages: body.messages,
onError: () => '生成失败,请稍后重试',
});
}
3. React 客户端
'use client';
import { useChat } from '@ai-sdk/react';
import { FormEvent, useState } from 'react';
export default function ChatPage() {
const [input, setInput] = useState('');
const {
messages,
sendMessage,
status,
error,
stop,
regenerate,
} = useChat();
const isRunning = status === 'submitted' || status === 'streaming';
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = input.trim();
if (!text || isRunning) return;
setInput('');
await sendMessage({ text });
}
return (
<main aria-busy={isRunning}>
<section aria-live="polite">
{messages.map(message => (
<article key={message.id} data-role={message.role}>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <p key={index}>{part.text}</p>;
}
if (part.type === 'reasoning') {
return (
<details key={index}>
<summary>推理摘要</summary>
<p>{part.text}</p>
</details>
);
}
return null;
})}
</article>
))}
</section>
{error && (
<p role="alert">
请求失败。<button onClick={() => regenerate()}>重试</button>
</p>
)}
<form onSubmit={onSubmit}>
<label htmlFor="prompt">消息</label>
<textarea
id="prompt"
value={input}
onChange={event => setInput(event.target.value)}
/>
{isRunning ? (
<button type="button" onClick={stop}>停止</button>
) : (
<button type="submit">发送</button>
)}
</form>
</main>
);
}
关键点:
- 不再依赖单个
message.content,而是按message.parts渲染文本、工具、来源、文件和自定义数据。 submitted表示请求已发出但尚未开始流式返回,streaming表示正在接收。- “停止”必须把取消信号传到服务端和 Provider,而不只是停止 DOM 更新。
- UI 中的
reasoning应理解为供应商允许返回的推理摘要或可见块,不等于模型的原始隐藏思维链。
三、UI Message Stream 协议
AI SDK 6 的 UI 流采用 SSE,每个事件的 data 是带类型的 JSON。自定义后端必须返回:
Content-Type: text/event-stream
x-vercel-ai-ui-message-stream: v1
典型事件如下:
data: {"type":"start","messageId":"msg_1"}
data: {"type":"text-start","id":"text_1"}
data: {"type":"text-delta","id":"text_1","delta":"你好"}
data: {"type":"text-end","id":"text_1"}
data: {"type":"finish"}
一次 reader.read() 可能包含半个 SSE 事件,也可能包含多个事件。协议解析必须基于缓冲区和事件边界,不能把一个 TCP chunk 当成一个 token 或一个 JSON 对象。
流式自定义数据
状态、引用、进度和业务组件应该作为类型化 Data Part 发送,而不是塞进 Markdown:
import type { UIMessage } from 'ai';
export type AppUIMessage = UIMessage<
{ model?: string; totalTokens?: number },
{
progress: { stage: string; percent: number };
citation: { title: string; url: string };
}
>;
使用相同 id 更新 Data Part,可以让“检索中 → 重排中 → 生成中”在同一组件中平滑更新;临时通知则不应写入持久消息历史。
四、结构化输出
AI SDK 6 将结构化输出统一到 generateText 和 streamText。
非流式对象
import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';
const ticketSchema = z.object({
category: z.enum(['bug', 'feature', 'question']),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().max(120),
});
export async function extractTicket(input: string) {
const { output } = await generateText({
model: openai(process.env.OPENAI_MODEL!),
output: Output.object({ schema: ticketSchema }),
prompt: `提取工单字段:${input}`,
});
// output 已经过 Schema 解析,但仍需做业务规则和权限校验。
return output;
}
流式对象
import { openai } from '@ai-sdk/openai';
import { Output, streamText } from 'ai';
import { z } from 'zod';
const dashboardSchema = z.object({
title: z.string(),
cards: z.array(z.object({
label: z.string(),
value: z.number(),
})),
});
const result = streamText({
model: openai(process.env.OPENAI_MODEL!),
output: Output.object({ schema: dashboardSchema }),
prompt: '根据销售数据生成仪表盘摘要',
});
for await (const partial of result.partialOutputStream) {
// partial 可能缺少字段,渲染层必须允许“不完整状态”。
console.log(partial);
}
const completeDashboard = await result.output;
结构化输出解决的是“形状”,不是“事实”和“权限”。仍需处理拒答、截断、供应商错误、Schema 支持差异,并对 URL、金额、资源 ID、枚举间约束等做确定性校验。
五、工具调用与多步执行
import { openai } from '@ai-sdk/openai';
import { stepCountIs, streamText, tool } from 'ai';
import { z } from 'zod';
const getWeather = tool({
description: '查询指定城市的实时天气',
inputSchema: z.object({ city: z.string().min(1) }),
execute: async ({ city }) => {
// 工具内部仍要鉴权、限时、校验响应,不能信任模型参数。
return { city, temperature: 26, condition: '晴' };
},
});
export async function POST(request: Request) {
const { prompt } = (await request.json()) as { prompt: string };
const result = streamText({
model: openai(process.env.OPENAI_MODEL!),
prompt,
tools: { getWeather },
// 代替旧版 maxSteps;避免 Agent 无限循环和成本失控。
stopWhen: stepCountIs(5),
abortSignal: request.signal,
});
return result.toUIMessageStreamResponse();
}
生产环境还需要:
- 工具允许列表和按用户授权。
- 输入 Schema 之外的业务校验。
- 网络、文件和数据库操作的超时与资源配额。
- 写操作的幂等键和审计记录。
- 转账、删除、发布等高风险操作的人工确认。
六、Provider 与模型路由
不要让业务代码散落模型 ID。模型选择应该面向能力和服务等级:
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';
import { openai } from '@ai-sdk/openai';
type ModelRole = 'fast' | 'reasoning' | 'longContext';
const models = {
fast: openai(process.env.MODEL_FAST!),
reasoning: anthropic(process.env.MODEL_REASONING!),
longContext: google(process.env.MODEL_LONG_CONTEXT!),
} satisfies Record<ModelRole, unknown>;
export function selectModel(role: ModelRole) {
return models[role];
}
路由时至少考虑:工具支持、结构化输出、多模态、上下文长度、区域、延迟、限额、数据政策和实测任务质量。Provider Fallback 只对限流、超时、服务端错误等可重试故障生效,不能把认证失败或业务校验失败切到另一个供应商重放。
七、持久化与恢复
推荐分别存储:
interface ConversationRecord {
id: string;
userId: string;
title: string;
createdAt: string;
}
interface MessageRecord {
id: string;
conversationId: string;
role: 'user' | 'assistant';
parts: unknown; // 数据库中保存经版本化校验的 UIMessage parts
status: 'streaming' | 'completed' | 'failed' | 'aborted';
model?: string;
usage?: { inputTokens: number; outputTokens: number };
}
- 服务端分配消息 ID,避免刷新和重试导致重复消息。
- 保存输入后再开始生成;生成完成后原子更新状态和用量。
- 中断时保留已生成内容并标为
aborted,允许“继续生成”,不要假装已完成。 - 保存协议版本或自行定义稳定的领域消息格式,避免 SDK 升级后历史消息无法读取。
- 原始消息、附件和日志应设置最小保留期限,并对 PII、密钥和授权头脱敏。
八、错误、重试与可观测性
错误至少分为四类:
| 类型 | 示例 | 处理方式 |
|---|---|---|
| 用户可修复 | 上下文过长、内容不合规 | 明确提示如何修改,不盲目重试 |
| 临时故障 | 429、超时、供应商 5xx | 有上限的指数退避与抖动 |
| 配置故障 | 无效 Key、错误模型 ID | 立即失败并告警 |
| 流中断 | 网络断开、用户取消 | 保留部分结果,区分取消与失败 |
每次调用至少记录:requestId、用户/租户、功能、模型角色、供应商、首块延迟、总耗时、输入/输出 token、工具步骤数、结束原因和错误类型。日志不要保存原始密钥,也不要默认记录完整敏感 Prompt。
九、AI SDK RSC 怎么看
ai/rsc 的 streamUI 能演示服务端组件流,但官方仍将 AI SDK RSC 视为实验性方案。稳定生产项目优先采用:
- 模型输出受限的工具调用或结构化数据。
- 服务端校验并转换为类型化
UIMessage.parts。 - 前端从白名单组件注册表中选择渲染器。
这比让模型生成任意 JSX 更容易测试、持久化、回放、跨框架和做权限控制。
常见面试问题
Q1: AI SDK Core 和 AI SDK UI 有什么区别?
答案:
Core 面向模型调用,提供文本生成、结构化输出、工具与 Provider 抽象;UI 面向交互状态,提供 useChat、UIMessage 和流协议。服务端用 Core,浏览器通常用 UI,两层通过 UI Message Stream 连接。
Q2: 为什么 UIMessage 和 ModelMessage 要分开?
答案:
UIMessage 保存渲染所需的 parts、metadata 和交互状态;ModelMessage 是发送给模型的规范化上下文。分开后可以在 UI 中保留进度、来源和组件状态,同时只把必要信息发给模型,降低 token、隐私和协议耦合。
Q3: toUIMessageStreamResponse() 解决了什么?
答案:
它把模型输出转换成 AI SDK UI 能识别的 SSE 事件,并处理文本块、工具、来源、错误、结束和取消等状态。旧版 toDataStreamResponse() 及数字前缀协议不应继续用于 AI SDK 6 新项目。
Q4: 为什么不能把一次网络 chunk 当成一次 token?
答案:
HTTP/TCP 分块与模型 token 没有一一对应关系。一个 chunk 可能包含半个 UTF-8 字符或多个 SSE 事件,因此必须用 TextDecoder 流式解码并按协议边界解析。
Q5: 结构化输出是否保证内容正确?
答案:
不保证。它主要提高 Schema 形状的合规性,字段值仍可能失实、越权或不满足跨字段业务约束。服务端必须继续做业务验证、权限检查和失败降级。
Q6: AI SDK 6 如何实现多步工具调用?
答案:
定义带 inputSchema 的 tool,通过 stopWhen: stepCountIs(n) 限制步骤。每个工具内部做确定性授权和校验,高风险写操作需要人工确认。
Q7: 为什么模型 ID 不应散落在代码里?
答案:
模型版本、价格、区域和限额变化很快。业务应依赖 fast、reasoning、vision 等能力角色,由配置中心映射到实际模型,升级时只修改一处并经过评测门禁。
Q8: 流式请求如何处理取消?
答案:
客户端调用 stop(),传输层中止请求,服务端把 request.signal 传给模型调用和工具。数据库将消息标记为 aborted,保留部分输出但不当作完整成功结果。
Q9: 为什么 Generative UI 推荐白名单组件?
答案:
白名单组件把模型权限限制为“选择组件和提供参数”,不允许执行任意代码;它更容易做 Schema 校验、可访问性、持久化、埋点、回放和安全审查。
Q10: Provider Fallback 最大的坑是什么?
答案:
不同供应商在工具、Schema、多模态和安全策略上并不完全等价。Fallback 前要做能力检查和错误分类,还要考虑请求重放是否会造成重复扣款或重复写入。
Q11: 消息持久化为什么要保存版本?
答案:
SDK 的消息 parts 和协议会升级。保存领域格式或附带 schemaVersion,才能在升级时迁移旧会话,避免历史记录因类型变化而无法渲染。
Q12: 如何评估 AI SDK 升级风险?
答案:
先看迁移指南,再针对聊天、工具、结构化输出、取消、错误和持久化建立集成测试;录制协议样本做回放;灰度观察失败率、首块延迟、token 和业务成功率,而不是只看 TypeScript 能否通过。