前言:什么是 Agent?
传统的 LLM 调用是"一问一答"——你发一段文字,模型回一段文字。而 Agent 是一种更高级的模式:
Agent = LLM + 工具(Tool)+ 循环推理
Agent 能自主决定"下一步该做什么":它可以调用你预定义的工具(查天气、搜数据库、执行计算……),拿到结果后继续推理,直到任务完成。
Vercel AI SDK 解决的核心问题:用统一的 TypeScript API 屏蔽不同模型提供商(OpenAI、Anthropic、Google 等)的差异,让你专注于业务逻辑。
环境准备
pnpm add ai @ai-sdk/openai zod
ai:核心包,提供generateText、streamText、tool等函数@ai-sdk/openai:OpenAI 模型适配器(可替换为@ai-sdk/anthropic等)zod:用于定义工具的输入/输出 schema
import { openai } from '@ai-sdk/openai';
// 创建一个模型实例,后续传给 generateText / streamText
const model = openai('gpt-4o');
Provider 是可插拔的。换模型只需换一行 import,业务代码零改动。
核心概念:消息(Message)
AI SDK 使用 ModelMessage 表示对话中的每一条消息,有四种角色:
| 角色 | 说明 | content 类型 |
|---|---|---|
system |
系统指令,设定模型行为 | 纯文本 |
user |
用户输入 | 文本 / 图片 / 文件(多模态) |
assistant |
模型回复 | 文本 / 工具调用 |
tool |
工具执行结果 | 工具返回值 |
消息数组就是"对话记录"——你把它传给 LLM,LLM 就知道之前聊了什么。
单轮对话:generateText 与 streamText
import { openai } from '@ai-sdk/openai';
import { generateText, streamText } from 'ai';
// 方式一:一次性拿到完整结果
const { text, usage, finishReason } = await generateText({
model: openai('gpt-4o'),
prompt: '用一句话解释什么是 TypeScript',
});
// 方式二:流式输出(适合实时 UI)
const result = streamText({
model: openai('gpt-4o'),
prompt: '用一句话解释什么是 TypeScript',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
generateText适合后台任务、Agent 工具调用等不需要实时展示的场景streamText适合聊天界面,用户能看到逐字输出
多轮对话:管理消息历史(重点)
单轮对话只是"问一句答一句"。真实场景中,用户会连续对话,模型需要记住上下文。
核心思路很简单:手动维护一个 messages 数组,每次把新的用户消息和模型回复都追加进去。
完整示例:命令行多轮聊天
import * as readline from 'node:readline';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
// 消息历史——这就是"记忆"
const messages = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
async function chat(userInput) {
// 追加用户消息
messages.push({ role: 'user', content: userInput });
const { text } = await generateText({
model: openai('gpt-4o'),
system: '你是一个友好的助手,用中文回答。',
messages,
});
// 追加模型回复
messages.push({ role: 'assistant', content: text });
console.log(`\n助手: ${text}\n`);
}
function askQuestion() {
rl.question('你: ', async (input) => {
if (input === 'exit') {
rl.close();
return;
}
await chat(input);
askQuestion();
});
}
askQuestion();
要点
system消息单独传,不放在 messages 数组里(SDK 会自动处理位置)- messages 数组会越来越长 → 实际项目中需要做截断或摘要,否则会超出模型的 context window
- 每次调用都把完整的 messages 传给模型,模型本身不记忆,记忆全靠你维护
流式多轮对话
import { streamText } from 'ai';
async function chatStream(userInput) {
messages.push({ role: 'user', content: userInput });
const result = streamText({
model: openai('gpt-4o'),
system: '你是一个友好的助手',
messages,
});
// 流式收集完整回复
let fullResponse = '';
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
fullResponse += chunk;
}
// 流结束后追加到历史
messages.push({ role: 'assistant', content: fullResponse });
}
工具(Tool)系统(重点)
工具是 Agent 的核心能力——让 LLM 不只是"说",还能"做"。
定义一个工具
import { tool } from 'ai';
import { z } from 'zod';
const weatherTool = tool({
// 描述:告诉 LLM 这个工具是干什么的、什么时候该用
description: '查询指定城市的当前天气',
// 输入 schema:用 Zod 定义参数,LLM 会按这个结构生成参数
inputSchema: z.object({
city: z.string().describe('城市名称,如"北京"'),
}),
// 执行函数:工具的实际逻辑
execute: async ({ city }) => {
// 这里调用真实的天气 API
return { city, temperature: 22, condition: '晴' };
},
});
Zod 的作用
Zod 在这里做了两件事:
- 生成 JSON Schema:SDK 自动将 Zod schema 转成 JSON Schema,作为 API 请求的一部分发给 LLM,LLM 按此结构生成参数
- 运行时校验:LLM 返回的参数在执行前会被 Zod 校验,类型不对会报错
// 更复杂的 schema 示例
const searchTool = tool({
description: '搜索知识库',
inputSchema: z.object({
query: z.string().describe('搜索关键词'),
limit: z.number().min(1).max(20).default(5).describe('返回结果数量'),
category: z.enum(['tech', 'science', 'general']).optional().describe('筛选类别'),
}),
execute: async ({ query, limit, category }) => {
// 你的搜索逻辑
return { results: [`关于 ${query} 的结果...`] };
},
});
.describe() 很重要——它会被包含在发给 LLM 的 JSON Schema 中,帮助模型理解每个字段的含义。
带工具的单次调用
const { text, toolCalls, toolResults } = await generateText({
model: openai('gpt-4o'),
tools: { weather: weatherTool },
prompt: '北京今天天气怎么样?',
});
// toolCalls: [{ toolName: 'weather', args: { city: '北京' } }]
// toolResults: [{ toolName: 'weather', result: { city: '北京', temperature: 22, condition: '晴' } }]
// text: '北京今天天气晴,气温22度。' ← 模型基于工具结果生成的回答
outputSchema:约束输出
const calculatorTool = tool({
description: '数学计算',
inputSchema: z.object({
expression: z.string().describe('数学表达式,如 "2 + 3 * 4"'),
}),
// 约束工具返回值的类型
outputSchema: z.object({
result: z.number(),
}),
execute: async ({ expression }) => {
return { result: eval(expression) };
},
});
Agent 循环:让 LLM 自主调用工具(重点)
前面的例子中,LLM 只调用了一次工具。但真正的 Agent 需要循环:调用工具 → 看结果 → 决定下一步 → 可能再调工具 → 直到任务完成。
执行流程
用户提问
↓
LLM 思考 → 决定调用工具 A
↓
SDK 执行工具 A → 拿到结果
↓
结果回传给 LLM → LLM 继续思考
↓
决定再调用工具 B → SDK 执行 → 结果回传
↓
LLM 认为信息足够 → 生成最终回答
使用 stopWhen 控制循环
注意:旧版 SDK 使用
maxSteps参数,新版已改为stopWhen,提供更灵活的控制。
import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';
const { text, steps } = await generateText({
model: openai('gpt-4o'),
tools: {
search: tool({
description: '搜索网页',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
return { results: [`关于 ${query} 的搜索结果...`] };
},
}),
calculator: tool({
description: '数学计算',
inputSchema: z.object({ expression: z.string() }),
execute: async ({ expression }) => ({ result: eval(expression) }),
}),
},
// 停止条件:最多执行 5 步
stopWhen: stepCountIs(5),
prompt: '帮我查一下2024年中国GDP是多少万亿美元,然后算一下比2023年的17.79万亿增长了百分之几',
});
// steps 数组记录了每一步的详情
console.log(`共执行了 ${steps.length} 步`);
console.log(`最终回答: ${text}`);
stopWhen 的多种条件
import { hasToolCall, stepCountIs } from 'ai';
// 达到任一条件就停止
stopWhen: [
stepCountIs(10), // 最多 10 步
hasToolCall('finalAnswer'), // 调用了 finalAnswer 工具就停
];
使用 ToolLoopAgent(推荐方式)
对于复杂 Agent,SDK 提供了 ToolLoopAgent 类,封装了循环逻辑:
import { openai } from '@ai-sdk/openai';
import { stepCountIs, tool, ToolLoopAgent } from 'ai';
import { z } from 'zod';
const agent = new ToolLoopAgent({
model: openai('gpt-4o'),
system: '你是一个研究助手,善于搜索和分析信息。',
tools: {
search: tool({
description: '搜索信息',
inputSchema: z.object({
query: z.string().describe('搜索关键词'),
}),
execute: async ({ query }) => {
return { results: [`${query} 的搜索结果...`] };
},
}),
},
stopWhen: stepCountIs(20), // 默认值就是 20
});
// 使用 agent
const result = await agent.generate({
prompt: '帮我研究一下 Rust 语言的最新发展',
});
console.log(result.text);
// 也支持流式
const streamResult = agent.stream({
prompt: '帮我研究一下 Rust 语言的最新发展',
});
for await (const chunk of streamResult.textStream) {
process.stdout.write(chunk);
}
ToolLoopAgent vs 直接用 streamText + stopWhen
两者的 agent 循环能力完全一致——都基于 stopWhen 控制步数,都能自动执行工具并回传结果。区别在于封装粒度:
streamText + stopWhen |
ToolLoopAgent |
|
|---|---|---|
| 配置位置 | 每次调用时传入 model / tools / system | 构造时一次性配置,调用时只传 prompt |
| 复用性 | 每次调用都要重复配置 | 定义一次,到处 agent.generate() |
| 多轮消息管理 | 你自己维护 messages 数组 | 同样需要你自己维护 |
关键澄清:ToolLoopAgent 不会跨调用记忆消息。每次 generate() / stream() 是独立的。多轮对话仍需你手动传入完整历史:
// 多轮对话中使用 ToolLoopAgent
const messages = [];
// 第一轮
messages.push({ role: 'user', content: '北京天气怎么样?' });
const r1 = await agent.generate({ messages });
messages.push({ role: 'assistant', content: r1.text });
// 第二轮——必须传入完整历史
messages.push({ role: 'user', content: '那上海呢?' });
const r2 = await agent.generate({ messages });
简单说:ToolLoopAgent 解决的是"同一轮对话内"工具循环的封装问题,不是"跨轮对话"的记忆问题。跨轮记忆永远是你的责任。
常用回调函数
回调让你观测 Agent 的执行过程,用于日志、调试和计费。
const result = await agent.generate({
prompt: '帮我分析这个问题',
// 每一步完成后触发
onStepFinish({ stepNumber, usage, finishReason, toolCalls }) {
console.log(`步骤 ${stepNumber}:`, {
tokens: `${usage.inputTokens} in / ${usage.outputTokens} out`,
finishReason,
tools: toolCalls?.map(tc => tc.toolName),
});
},
// 所有步骤完成后触发
onFinish({ totalUsage, steps }) {
console.log('完成:', {
totalSteps: steps.length,
totalTokens: totalUsage.totalTokens,
});
},
});
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
onStepFinish |
每步完成 | 记录每步的 token 消耗和工具调用 |
onFinish |
全部完成 | 统计总消耗,记录最终结果 |
experimental_onToolCallStart |
工具执行前 | 记录工具调用日志 |
experimental_onToolCallFinish |
工具执行后 | 记录工具耗时和结果 |
experimental_onStart |
Agent 启动 | 初始化追踪 |
experimental_onStepStart |
每步开始前 | 记录步骤开始时间 |
补充答疑
不使用 AI SDK 可以做 Agent 吗?
完全可以。Agent 的本质是一个循环:
while (true) {
response = callLLM(messages)
if (response 包含工具调用) {
result = 执行工具(response.toolCall)
messages.push(toolResult)
} else {
break // 模型给出了最终回答
}
}
你可以直接用 fetch 调 OpenAI 的 REST API 实现这个循环。AI SDK 只是帮你封装了:
- 不同模型提供商的 API 差异
- 消息格式转换
- 工具调用的执行和结果回传
- 流式处理
- TypeScript 类型安全
message 里的内容可以随便放吗?
不可以。消息内容有明确的类型约束:
system:只能是纯文本字符串user:文本,或多模态内容数组(TextPart、ImagePart、FilePart)assistant:文本,或包含ToolCallPart的数组tool:必须是ToolResultPart,包含toolCallId和结果
SDK 在处理消息前会通过 validateUIMessages 校验结构,类型不对会直接抛出 AI_TypeValidationError。
message 的构造需要满足什么条件?
顺序要求:
tool消息必须跟在包含对应tool-call的assistant消息之后- 每个 tool call 必须有对应的 tool result,否则会抛出
MissingToolResultsError
中断场景处理:
如果网络中断导致工具调用不完整(比如 assistant 发出了 tool call 但还没拿到结果),有以下几种处理方式:
方式一:丢弃不完整的调用
import { convertToModelMessages } from 'ai';
const modelMessages = convertToModelMessages(uiMessages, {
ignoreIncompleteToolCalls: true, // 跳过未完成的工具调用
});
方式二:手动注入错误结果
不丢弃,而是补一个"失败"的工具结果,让模型知道发生了什么:
// 找到没有 result 的 tool call,补一个 error 状态的结果
messages.push({
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: '中断的 tool call id',
toolName: 'search',
result: { error: '网络中断,工具执行失败' },
}],
});
// 模型收到错误结果后,可以决定是否重试或换一种方式
实践建议:持久化 messages 时,确保每个 tool call 都有配对的 result。方式一最简单但会丢失上下文;方式二最灵活,模型能基于错误信息做出更好的决策。
tool 定义后是如何让 LLM 理解的?
工具定义不是什么"魔法",而是被直接塞进了 API 请求体。
你写的 Zod schema 会被 SDK 自动转成 JSON Schema,然后作为请求的一部分发给模型:
{
"model": "gpt-4o",
"messages": [{ "role": "user", "content": "北京天气怎么样" }],
"tools": [{
"type": "function",
"function": {
"name": "weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名称" }
},
"required": ["city"]
}
}
}]
}
模型经过训练,能理解 tools 字段中的 JSON Schema,并在需要时生成符合 schema 的参数来"调用"工具。这就是为什么 description 和 .describe() 写得好很重要——它们直接影响模型的决策。
Agent 循环中,如何判断 LLM 要停下来?
SDK 通过 finishReason 判断模型的意图:
| finishReason | 含义 |
|---|---|
tool-calls |
模型想调用工具 → 继续循环 |
stop |
模型生成了最终回答 → 停止 |
length |
达到 token 上限 → 被迫停止 |
content-filter |
内容审核触发 → 被迫停止 |
循环停止的完整条件(满足任一即停):
finishReason不是tool-calls(模型主动停下)stopWhen条件满足(如达到最大步数)- 调用的工具没有
execute函数(需要外部处理) - 工具设置了
needsApproval: true(等待用户确认)
needsApproval 完整流程示例:
// 定义需要审批的工具——可以是布尔值,也可以是函数(按条件审批)
const paymentTool = tool({
description: '处理付款',
inputSchema: z.object({
amount: z.number(),
recipient: z.string(),
}),
needsApproval: async ({ amount }) => amount > 100, // 超过 100 才需要审批
execute: async ({ amount, recipient }) => {
return { success: true, message: `已向 ${recipient} 转账 ${amount} 元` };
},
});
// 调用 agent
const result = await generateText({
model: openai('gpt-4o'),
tools: { payment: paymentTool },
prompt: '帮我给张三转账 500 元',
});
// 当 needsApproval 触发时,result.content 中会包含 tool-approval-request
// SDK 不会执行 execute,而是暂停并返回审批请求
for (const part of result.content) {
if (part.type === 'tool-approval-request') {
console.log(`工具 ${part.toolCall.toolName} 请求审批`);
console.log('参数:', part.toolCall.input); // { amount: 500, recipient: '张三' }
// 你的应用收集用户决策后,构造审批响应
const approved = await askUser('是否批准这笔转账?'); // 你的 UI 逻辑
// 把审批结果加入消息历史
messages.push({
role: 'tool',
content: [{
type: 'tool-approval-response',
approvalId: part.approvalId,
approved, // true 或 false
}],
});
}
}
// 带着审批结果再次调用——SDK 会执行被批准的工具,跳过被拒绝的
const finalResult = await generateText({
model: openai('gpt-4o'),
tools: { payment: paymentTool },
messages,
});
整个流程:LLM 想调工具 → SDK 发现需要审批 → 暂停,返回 tool-approval-request → 你收集用户决策 → 追加 tool-approval-response 到消息 → 再次调用 → SDK 执行/跳过工具 → LLM 基于结果继续。
不用 needsApproval 也能暂停——有两种方式,区别在于暂停时机:
方式 A:不写 execute——调用即暂停,你来处理
const askUser = tool({
description: '当你需要用户提供更多信息时调用此工具',
inputSchema: z.object({
question: z.string().describe('要问用户的问题'),
}),
// 不写 execute——LLM 调用时循环自动停止,你从 toolCalls 中取出参数
})
const { toolCalls, steps } = await generateText({
model: openai('gpt-4o'),
tools: { search: searchTool, askUser },
stopWhen: stepCountIs(10),
prompt: '帮我订一张明天去上海的机票',
})
const pendingCall = toolCalls.find(tc => tc.toolName === 'askUser')
if (pendingCall) {
const userAnswer = await getUserInput(pendingCall.args.question)
// 手动构造 tool result,把用户回答作为工具结果传回去
const resumeMessages = [
...steps.flatMap(s => s.messages),
{ role: 'tool', content: [{
type: 'tool-result',
toolCallId: pendingCall.toolCallId,
toolName: 'askUser',
result: userAnswer,
}] },
]
const next = await generateText({
model: openai('gpt-4o'),
tools: { search: searchTool, askUser },
messages: resumeMessages,
})
}
方式 B:有 execute + hasToolCall 停止条件——执行完再暂停
const askUser = tool({
description: '当你需要用户提供更多信息时调用此工具',
inputSchema: z.object({ question: z.string() }),
execute: async ({ question }) => {
// execute 会正常执行,比如可以记日志、发通知
return { pending: true, question }
},
})
const { toolResults } = await generateText({
model: openai('gpt-4o'),
tools: { search: searchTool, askUser },
stopWhen: hasToolCall('askUser'), // 执行完 askUser 后停止循环
prompt: '帮我订一张明天去上海的机票',
})
// execute 已执行,toolResults 中有结果,但循环不再继续
// 接下来等用户回复,构造新消息继续对话
选择哪种取决于场景:工具本身没有逻辑、只是个"信号"就用方式 A;工具有实际副作用(记日志、发通知)就用方式 B。两者都不依赖前端 hook,纯后端可用。