使用 Vercel AI SDK 编写 Agent

Author

华丽

PublishDate

2026-03-27

category

编程

前言:什么是 Agent?

传统的 LLM 调用是"一问一答"——你发一段文字,模型回一段文字。而 Agent 是一种更高级的模式:

Agent = LLM + 工具(Tool)+ 循环推理

Agent 能自主决定"下一步该做什么":它可以调用你预定义的工具(查天气、搜数据库、执行计算……),拿到结果后继续推理,直到任务完成。

Vercel AI SDK 解决的核心问题:用统一的 TypeScript API 屏蔽不同模型提供商(OpenAI、Anthropic、Google 等)的差异,让你专注于业务逻辑。

环境准备

pnpm add ai @ai-sdk/openai zod
  • ai:核心包,提供 generateTextstreamTexttool 等函数
  • @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 就知道之前聊了什么。

单轮对话:generateTextstreamText

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:文本,或多模态内容数组(TextPartImagePartFilePart
  • assistant:文本,或包含 ToolCallPart 的数组
  • tool:必须是 ToolResultPart,包含 toolCallId 和结果

SDK 在处理消息前会通过 validateUIMessages 校验结构,类型不对会直接抛出 AI_TypeValidationError

message 的构造需要满足什么条件?

顺序要求

  • tool 消息必须跟在包含对应 tool-callassistant 消息之后
  • 每个 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,纯后端可用。

闽 ICP 备 2021009779 号 - 1 | Copyright © 2020 华丽 | Powered By Hugo