AI Agent 工具系统实现
对话和 RAG 解决的是"回答问题",而 Agent 解决的是"执行任务"。Agent 的核心能力是 工具调用(Tool Use)——LLM 不只是生成文本,它还能调用你预定义的函数来查询数据库、发送邮件、创建工单、搜索网页。这让 AI 从被动的问答机器变成主动的任务执行者。本章基于 Vercel AI SDK 的 Tool 系统,实现一个可扩展的 Agent 框架。
1. Agent 核心原理
1.1 什么是 AI Agent
Agent 本质上是一个 LLM + 工具 + 循环 的系统:
用户指令:"帮我查一下上个月的销售数据,生成报告并发给 CEO"
│
▼
┌─ LLM 思考:我需要分三步完成 ──────────────────┐
│ │
│ Step 1: 调用 querySalesData(month: 'last') │
│ ↓ 返回数据 │
│ Step 2: 调用 generateReport(data: ...) │
│ ↓ 返回报告 URL │
│ Step 3: 调用 sendEmail(to: 'ceo@...', ...) │
│ ↓ 发送成功 │
│ │
│ 最终回答:"已完成!报告已发送给 CEO。" │
└────────────────────────────────────────────────┘
关键特征:
- 自主决策:模型自己决定调用哪个工具、传什么参数、调用几次
- 多步推理:一个任务可能需要多次工具调用,前一步的结果作为后一步的输入
- 循环执行:每次工具调用后,模型重新评估是否需要继续,直到任务完成
1.2 Tool Use 的工作机制
Vercel AI SDK 的工具调用遵循 OpenAI 的 Function Calling 协议:
- 开发者定义工具的名称、描述和参数 Schema(Zod)
- 将工具列表随 messages 一起发给模型
- 模型决定是否调用工具——如果是,返回
tool_calls而不是文本 - 开发者执行工具函数,把结果作为
tool角色的消息返回 - 模型基于工具结果继续生成(可能继续调用其他工具,或生成最终回答)
1.3 Agent 与普通 Chat 的区别
| 维度 | 普通 Chat | Agent |
|---|---|---|
| 输出 | 纯文本 | 文本 + 动作(API 调用、数据库操作) |
| 推理 | 单轮 | 多轮循环(ReAct) |
| 确定性 | 同一输入→相似输出 | 同一输入→可能不同路径 |
| 风险 | 低(只生成文字) | 高(可能执行破坏性操作) |
| 延迟 | 2-10s | 10s-数分钟(多步串行) |
| 成本 | 一次 LLM 调用 | 多次 LLM 调用 + 工具执行 |
2. 工具定义与注册
2.1 Vercel AI SDK 的 Tool 定义
AI SDK 使用 Zod Schema 定义工具参数,提供类型安全的工具系统:
// lib/ai/tools.ts
import { tool } from 'ai'
import { z } from 'zod'
// 天气查询工具(示例)
export const weatherTool = tool({
description: 'Get current weather for a location',
parameters: z.object({
city: z.string().describe('City name'),
unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
execute: async ({ city, unit }) => {
const res = await fetch(`https://api.weather.com/v1?city=${city}&unit=${unit}`)
return res.json()
},
})tool() 函数的三个组成部分:
- description:告诉模型这个工具做什么——这是模型决定是否调用它的依据,写得越清晰越好
- parameters:Zod Schema,模型会生成符合 Schema 的参数 JSON
- execute:实际执行函数,接收类型安全的参数,返回工具结果
2.2 业务工具集
在 SaaS 产品中,Agent 的价值在于能操作业务数据。以下是一组典型的业务工具:
// lib/ai/tools/project-tools.ts
export const listProjects = tool({
description: 'List all projects in the current organization, optionally filtered by status',
parameters: z.object({
status: z.enum(['active', 'completed', 'archived']).optional()
.describe('Filter by project status'),
limit: z.number().max(20).default(10)
.describe('Maximum number of projects to return'),
}),
execute: async ({ status, limit }, { tenantId }) => {
const query = db.select({
id: projects.id,
name: projects.name,
status: projects.status,
createdAt: projects.createdAt,
})
.from(projects)
.where(and(
eq(projects.tenantId, tenantId),
status ? eq(projects.status, status) : undefined,
))
.orderBy(desc(projects.createdAt))
.limit(limit)
return query
},
})
export const createTask = tool({
description: 'Create a new task in a project',
parameters: z.object({
projectId: z.string().uuid().describe('The project ID to add the task to'),
title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
assigneeEmail: z.string().email().optional()
.describe('Email of the person to assign the task to'),
}),
execute: async ({ projectId, title, description, priority, assigneeEmail }, { tenantId, userId }) => {
// 验证项目归属
const [project] = await db.select().from(projects)
.where(and(eq(projects.id, projectId), eq(projects.tenantId, tenantId)))
if (!project) return { error: 'Project not found' }
let assigneeId: string | undefined
if (assigneeEmail) {
const [user] = await db.select().from(users).where(eq(users.email, assigneeEmail))
assigneeId = user?.id
}
const [task] = await db.insert(tasks).values({
projectId, title, description, priority, assigneeId,
createdBy: userId,
}).returning()
return { success: true, taskId: task.id, title: task.title }
},
})
export const queryMetrics = tool({
description: 'Query business metrics like revenue, user count, or project stats for a given time range',
parameters: z.object({
metric: z.enum(['revenue', 'activeUsers', 'newSignups', 'projectCount'])
.describe('The metric to query'),
period: z.enum(['today', 'thisWeek', 'thisMonth', 'lastMonth', 'last30days'])
.describe('Time period for the metric'),
}),
execute: async ({ metric, period }, { tenantId }) => {
const dateRange = getDateRange(period)
switch (metric) {
case 'revenue':
return queryRevenue(tenantId, dateRange)
case 'activeUsers':
return queryActiveUsers(tenantId, dateRange)
case 'newSignups':
return queryNewSignups(tenantId, dateRange)
case 'projectCount':
return queryProjectCount(tenantId, dateRange)
}
},
})2.3 工具注册表
将工具按类别组织,便于按权限和场景动态加载:
// lib/ai/tools/registry.ts
interface ToolCategory {
name: string
description: string
tools: Record<string, any>
requiredPermission?: string
}
const TOOL_REGISTRY: ToolCategory[] = [
{
name: 'projects',
description: 'Project and task management',
tools: { listProjects, createTask, getProjectDetails },
requiredPermission: 'ai:agent:use',
},
{
name: 'analytics',
description: 'Business metrics and reporting',
tools: { queryMetrics, generateChart },
requiredPermission: 'ai:agent:use',
},
{
name: 'communication',
description: 'Send emails and notifications',
tools: { sendEmail, sendNotification },
requiredPermission: 'ai:agent:manage', // 更高权限
},
{
name: 'search',
description: 'Web search and knowledge base query',
tools: { webSearch, queryKnowledgeBase },
requiredPermission: 'ai:agent:use',
},
]
export function getAvailableTools(userPermissions: string[]): Record<string, any> {
const tools: Record<string, any> = {}
for (const category of TOOL_REGISTRY) {
if (!category.requiredPermission || userPermissions.includes(category.requiredPermission)) {
Object.assign(tools, category.tools)
}
}
return tools
}3. 多步推理(ReAct 模式)
3.1 maxSteps:自动循环
Vercel AI SDK 的 maxSteps 参数让模型自动循环——每次工具调用后,结果自动回传给模型,模型继续推理直到给出文本回答或达到步数上限:
// app/api/agent/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const session = await getSession()
if (!session) return new Response('Unauthorized', { status: 401 })
const tenantId = await getCurrentTenantId()
const { messages } = await req.json()
const tools = getAvailableTools(session.user.permissions)
const result = streamText({
model: openai('gpt-4o'), // Agent 推荐用更强的模型
system: buildAgentSystemPrompt(),
messages,
tools,
maxSteps: 5, // 最多 5 轮工具调用
// 每个工具调用都会注入上下文
toolChoice: 'auto', // 让模型自己决定是否调用工具
onStepFinish: async ({ stepType, toolCalls, toolResults, usage }) => {
// 记录每一步的执行情况
if (stepType === 'tool-result') {
await logAgentStep(tenantId!, session.user.id, {
toolCalls,
toolResults,
usage,
})
}
},
onFinish: async ({ text, usage, steps }) => {
await recordUsage(tenantId!, session.user.id, 'gpt-4o', {
promptTokens: steps.reduce((sum, s) => sum + (s.usage?.promptTokens || 0), 0),
completionTokens: steps.reduce((sum, s) => sum + (s.usage?.completionTokens || 0), 0),
})
},
})
return result.toDataStreamResponse()
}maxSteps: 5 意味着模型最多可以进行 5 轮"思考 → 工具调用 → 获取结果"的循环。这个值需要权衡——太小可能完成不了复杂任务,太大会导致成本失控(每步都是一次完整的 LLM 调用)。
3.2 Agent System Prompt
Agent 的 System Prompt 比普通 Chat 更重要——它定义了 Agent 的行为模式和安全边界:
function buildAgentSystemPrompt(): string {
return `You are an AI assistant with access to tools that can perform actions in the user's workspace.
BEHAVIOR:
- Think step by step. Before taking action, briefly explain what you're going to do and why.
- If a task requires multiple steps, plan them out first, then execute one by one.
- After executing tools, summarize the results clearly for the user.
- If a tool returns an error, explain what went wrong and suggest alternatives.
SAFETY:
- Never perform destructive actions (delete, modify) without explicit user confirmation.
- If the user's request is ambiguous, ask for clarification before acting.
- Do not call the same tool with the same parameters more than twice (avoid infinite loops).
- Respect rate limits: do not call more than 3 tools in a single response.
LIMITATIONS:
- You can only access data within the user's current organization.
- Email sending requires explicit user consent.
- You cannot access external systems not exposed as tools.`
}3.3 执行流可视化
Agent 的多步执行过程应该对用户透明——显示每一步在做什么,而不是让用户面对漫长的等待:
// components/agent-message.tsx
import type { Message } from 'ai'
export function AgentMessage({ message }: { message: Message }) {
return (
<div className="space-y-3">
{/* 工具调用步骤 */}
{message.toolInvocations?.map((invocation, i) => (
<ToolInvocationCard key={i} invocation={invocation} />
))}
{/* 最终文本回答 */}
{message.content && (
<div className="rounded-lg bg-muted px-4 py-3">
<MarkdownRenderer content={message.content} />
</div>
)}
</div>
)
}
function ToolInvocationCard({ invocation }: { invocation: any }) {
const isComplete = invocation.state === 'result'
return (
<div className="rounded-lg border bg-card p-3 text-sm">
<div className="flex items-center gap-2">
{isComplete ? (
<span className="text-green-500">✓</span>
) : (
<span className="animate-spin">⟳</span>
)}
<span className="font-medium">{formatToolName(invocation.toolName)}</span>
</div>
{/* 工具参数 */}
<details className="mt-2">
<summary className="cursor-pointer text-muted-foreground text-xs">
Parameters
</summary>
<pre className="mt-1 rounded bg-muted p-2 text-xs overflow-x-auto">
{JSON.stringify(invocation.args, null, 2)}
</pre>
</details>
{/* 工具结果 */}
{isComplete && invocation.result && (
<details className="mt-2">
<summary className="cursor-pointer text-muted-foreground text-xs">
Result
</summary>
<pre className="mt-1 rounded bg-muted p-2 text-xs overflow-x-auto">
{JSON.stringify(invocation.result, null, 2)}
</pre>
</details>
)}
</div>
)
}
function formatToolName(name: string): string {
return name.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim()
}4. 联网搜索
4.1 Web Search 工具
让 Agent 能搜索互联网是最常见的需求——回答实时问题、查找最新信息:
// lib/ai/tools/web-search.ts
import { tool } from 'ai'
import { z } from 'zod'
export const webSearch = tool({
description: 'Search the web for current information. Use this when the user asks about recent events, real-time data, or topics that may have changed since your training cutoff.',
parameters: z.object({
query: z.string().describe('The search query'),
maxResults: z.number().max(5).default(3)
.describe('Maximum number of results to return'),
}),
execute: async ({ query, maxResults }) => {
// 使用 Tavily API(专为 AI Agent 设计的搜索 API)
const res = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: process.env.TAVILY_API_KEY,
query,
max_results: maxResults,
include_answer: true,
}),
})
const data = await res.json()
return {
answer: data.answer,
results: data.results?.map((r: any) => ({
title: r.title,
url: r.url,
snippet: r.content?.slice(0, 300),
})),
}
},
})4.2 知识库查询工具
让 Agent 能查询 RAG 知识库(复用第 77 章的检索逻辑):
export const queryKnowledgeBase = tool({
description: 'Search the organization knowledge base for internal documents and policies. Use this for questions about company processes, product documentation, or team guidelines.',
parameters: z.object({
query: z.string().describe('The search query for the knowledge base'),
knowledgeBaseId: z.string().uuid().optional()
.describe('Specific knowledge base ID. If omitted, searches all knowledge bases.'),
}),
execute: async ({ query, knowledgeBaseId }, { tenantId }) => {
if (knowledgeBaseId) {
const results = await retrieveRelevantChunks(query, knowledgeBaseId, { topK: 3 })
return formatResults(results)
}
// 搜索所有知识库
const kbs = await db.select().from(knowledgeBases)
.where(eq(knowledgeBases.tenantId, tenantId))
const allResults = []
for (const kb of kbs) {
const results = await retrieveRelevantChunks(query, kb.id, { topK: 2 })
allResults.push(...results.map(r => ({ ...r, knowledgeBaseName: kb.name })))
}
return formatResults(allResults.sort((a, b) => b.similarity - a.similarity).slice(0, 5))
},
})
function formatResults(results: any[]) {
return results.map(r => ({
content: r.content,
source: r.documentName,
relevance: Math.round(r.similarity * 100) + '%',
}))
}5. 安全与防护
5.1 人机确认(Human-in-the-Loop)
对于破坏性操作(删除数据、发送邮件、修改配置),Agent 不应该自动执行——必须让用户确认。AI SDK 支持将工具标记为"需要确认":
// 需要确认的工具:不提供 execute,客户端处理确认流程
export const deleteProject = tool({
description: 'Delete a project permanently. This action cannot be undone.',
parameters: z.object({
projectId: z.string().uuid().describe('The project ID to delete'),
confirm: z.literal(true).describe('Must be true to confirm deletion'),
}),
// 注意:没有 execute 函数!
})当工具没有 execute 时,AI SDK 会在客户端触发确认流程:
// components/tool-confirmation.tsx
const { messages, addToolResult } = useChat({
api: '/api/agent',
maxSteps: 5,
})
// 检测需要确认的工具调用
const pendingConfirmation = messages
.flatMap(m => m.toolInvocations || [])
.find(t => t.state === 'call' && !t.result)
if (pendingConfirmation) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="font-medium">⚠️ Confirmation Required</p>
<p className="mt-1 text-sm text-muted-foreground">
The AI wants to: <strong>{formatToolName(pendingConfirmation.toolName)}</strong>
</p>
<pre className="mt-2 text-xs">{JSON.stringify(pendingConfirmation.args, null, 2)}</pre>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
// 执行操作并返回结果
executeConfirmedTool(pendingConfirmation).then(result => {
addToolResult({ toolCallId: pendingConfirmation.toolCallId, result })
})
}}
className="rounded bg-red-500 px-3 py-1.5 text-sm text-white"
>
Confirm
</button>
<button
onClick={() => {
addToolResult({
toolCallId: pendingConfirmation.toolCallId,
result: { error: 'User cancelled the operation' },
})
}}
className="rounded border px-3 py-1.5 text-sm"
>
Cancel
</button>
</div>
</div>
)
}5.2 速率限制与成本控制
Agent 的多步执行会快速消耗 token。需要在多个层面限制:
// lib/ai/agent-limits.ts
interface AgentLimits {
maxStepsPerRequest: number // 单次请求最多步数
maxToolCallsPerDay: number // 每天最多工具调用次数
maxCostPerRequest: number // 单次请求最大成本(美元)
allowedTools: string[] // 允许使用的工具列表
}
const PLAN_LIMITS: Record<string, AgentLimits> = {
free: {
maxStepsPerRequest: 2,
maxToolCallsPerDay: 10,
maxCostPerRequest: 0.05,
allowedTools: ['listProjects', 'queryMetrics', 'queryKnowledgeBase'],
},
pro: {
maxStepsPerRequest: 5,
maxToolCallsPerDay: 100,
maxCostPerRequest: 0.50,
allowedTools: ['listProjects', 'createTask', 'queryMetrics', 'webSearch', 'queryKnowledgeBase'],
},
business: {
maxStepsPerRequest: 10,
maxToolCallsPerDay: 500,
maxCostPerRequest: 5.00,
allowedTools: ['*'], // 所有工具
},
}
export async function checkAgentLimits(tenantId: string, plan: string) {
const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free
// 检查每日调用次数
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const [usage] = await db.select({ count: sql<number>`count(*)` })
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
eq(aiUsage.operationType, 'tool'),
gte(aiUsage.createdAt, todayStart),
))
if (usage.count >= limits.maxToolCallsPerDay) {
throw new Error(`Daily tool call limit reached (${limits.maxToolCallsPerDay})`)
}
return limits
}5.3 工具执行沙箱
工具函数执行时,需要确保租户隔离——工具只能访问当前租户的数据:
// lib/ai/tools/context.ts
interface ToolContext {
tenantId: string
userId: string
plan: string
permissions: string[]
}
export function createToolsWithContext(
tools: Record<string, any>,
context: ToolContext
): Record<string, any> {
const wrappedTools: Record<string, any> = {}
for (const [name, t] of Object.entries(tools)) {
wrappedTools[name] = {
...t,
execute: async (args: any) => {
// 注入租户上下文
try {
const result = await t.execute(args, context)
// 审计日志
await logToolExecution(context.tenantId, context.userId, name, args, result)
return result
} catch (error) {
return { error: error instanceof Error ? error.message : 'Tool execution failed' }
}
},
}
}
return wrappedTools
}6. 结构化输出
6.1 generateObject:强制 JSON 输出
有时 Agent 需要生成结构化数据(而不是自由文本)。generateObject() 强制模型输出符合 Zod Schema 的 JSON:
// lib/ai/structured.ts
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
// 从自然语言生成结构化任务
export async function parseTaskFromText(text: string) {
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: z.object({
title: z.string().describe('Task title'),
description: z.string().describe('Detailed task description'),
priority: z.enum(['low', 'medium', 'high', 'urgent']),
estimatedHours: z.number().optional().describe('Estimated hours to complete'),
tags: z.array(z.string()).describe('Relevant tags'),
}),
prompt: `Extract a structured task from this message:\n\n${text}`,
})
return object // 类型安全:{ title: string, description: string, ... }
}6.2 流式结构化输出
对于大型结构化输出(如报告),可以用 streamObject() 逐字段流式返回:
import { streamObject } from 'ai'
export async function streamReport(data: any) {
const result = streamObject({
model: openai('gpt-4o'),
schema: z.object({
title: z.string(),
summary: z.string(),
sections: z.array(z.object({
heading: z.string(),
content: z.string(),
insights: z.array(z.string()),
})),
recommendations: z.array(z.string()),
}),
prompt: `Generate a business report based on this data:\n${JSON.stringify(data)}`,
})
// 客户端可以实时看到报告的各部分逐步生成
return result.toTextStreamResponse()
}本章小结
- Agent = LLM + 工具 + 循环:模型自主决定调用哪些工具、以什么顺序、传什么参数
- 工具定义:
tool()函数 = description + Zod parameters + execute,描述质量决定调用准确率 - 工具注册表:按类别组织工具,基于用户权限动态加载
- maxSteps 循环:AI SDK 自动管理"调用工具 → 获取结果 → 继续推理"的循环
- 执行可视化:显示每步工具调用的参数和结果,Agent 过程对用户透明
- 联网搜索:Tavily API 提供专为 Agent 设计的搜索能力
- 安全防护:破坏性操作需要人机确认(Human-in-the-Loop),无 execute 的工具在客户端处理
- 速率控制:按套餐限制步数、每日调用次数、单次成本上限
- 租户隔离:工具执行注入 tenantId 上下文,所有数据访问受租户边界约束
- 结构化输出:
generateObject()强制 JSON Schema 输出,streamObject()支持流式