
几乎所有"手把手教你构建AI Agent"的教程,结尾都是同一个套路:模型调用工具,工具返回数据,模型用数据回复。演示效果拔群。
但教程没告诉你的是:工具超时了怎么办?模型连续三次调用同一个工具怎么办?模型调用了一个有副作用的工具但用户根本没这个意图怎么办?工具返回了错误但模型还是硬编了一个答案怎么办?
这些不是边缘情况——这就是生产环境Agent的日常。分享五个我在每个上线的Agent里都会用的模式。
默认情况下,大多数Agent框架会让模型无限调用工具,直到它决定停下来回复为止。演示环境没问题。生产环境里,一个行为异常的Agent可以在任何人察觉之前循环调用几十次API,把账单刷爆。
解法是每个回合的硬性工具调用预算。
import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic(); async function runAgentWithBudget( messages: Anthropic.MessageParam[], tools: Anthropic.Tool[], maxToolCalls = 5
): Promise<{ content: string; toolCallCount: number; hitBudget: boolean }> { let toolCallCount = 0; let currentMessages = [...messages]; while (true) { const response = await client.messages.create({ model: "claude-sonnet-4-5", max_tokens: 2048, tools, messages: currentMessages, }); // 模型已停止调用工具 if (response.stop_reason === "end_turn") { const text = response.content .filter((b): b is Anthropic.TextBlock => b.type === "text") .map(b => b.text) .join(""); return { content: text, toolCallCount, hitBudget: false }; } // 模型想要调用工具 if (response.stop_reason === "tool_use") { const toolUseBlocks = response.content.filter( (b): b is Anthropic.ToolUseBlock => b.type === "tool_use" ); toolCallCount += toolUseBlocks.length; // 超出预算 — 停止并告知模型 if (toolCallCount > maxToolCalls) { const budgetMessage: Anthropic.MessageParam = { role: "user", content: [{ type: "tool_result", tool_use_id: toolUseBlocks[0].id, content: "Tool call budget exceeded. Please respond with what you know so far.", is_error: true, }], }; // 最后一次不带工具的完成调用 const finalResponse = await client.messages.create({ model: "claude-sonnet-4-5", max_tokens: 1024, messages: [...currentMessages, { role: "assistant", content: response.content }, budgetMessage ], }); const text = finalResponse.content .filter((b): b is Anthropic.TextBlock => b.type === "text") .map(b => b.text) .join(""); return { content: text, toolCallCount, hitBudget: true }; } // 执行工具并继续 const toolResults = await Promise.all( toolUseBlocks.map(async (block) => ({ type: "tool_result" as const, tool_use_id: block.id, content: await executeToolSafely(block.name, block.input), })) ); currentMessages = [ ...currentMessages, { role: "assistant", content: response.content }, { role: "user", content: toolResults }, ]; } }
}
默认的 maxToolCalls = 5 比较保守。根据你的Agent实际用途调整。对于简单的查询Agent,3次就够。对于做多步综合的研究型Agent,10-15次可能更合适。重点是要有一个限制。
一个常见的Agent失效模式:模型在同一个回合(或者跨回合)里用相同参数多次调用同一个工具。最好的情况是浪费资源,最坏的情况是有危险——想象一下同一个内容调用了两次 send_email。
class ToolCallDeduplicator { private seen = new Map<string, unknown>(); private readonly ttlMs: number; constructor(ttlMs = 60_000) { this.ttlMs = ttlMs; } private makeKey(toolName: string, input: unknown): string { return `${toolName}:${JSON.stringify(input)}`; } async callOnce<T>( toolName: string, input: unknown, fn: () => Promise<T> ): Promise<{ result: T; wasCached: boolean }> { const key = this.makeKey(toolName, input); if (this.seen.has(key)) { return { result: this.seen.get(key) as T, wasCached: true }; } const result = await fn(); this.seen.set(key, result); // 清理缓存条目 setTimeout(() => this.seen.delete(key), this.ttlMs); return { result, wasCached: false }; }
} // 在工具执行器中的使用
const deduplicator = new ToolCallDeduplicator(); async function executeToolSafely(toolName: string, input: unknown): Promise<string> { const { result, wasCached } = await deduplicator.callOnce( toolName, input, () => dispatchTool(toolName, input) ); if (wasCached) { console.log(`[dedup] Tool ${toolName} returned cached result`); } return typeof result === "string" ? result : JSON.stringify(result);
}
对于幂等的读操作(搜索、查询),缓存结果是安全的,还能省钱。对于写操作(发邮件、创建记录、调用webhook),你可能想用错误拒绝重复调用,而不是静默返回缓存结果——在你的工具定义里把这种区别说清楚。
当工具失败时,最糟糕的做法是把错误藏起来不让模型知道。先看一个常见的反模式:
// 不好:吞掉错误
async function executeToolBad(name: string, input: unknown): Promise<string> { try { return await dispatchTool(name, input); } catch { return ""; // 模型得到空结果,往往会编造内容 }
}
模型收到空字符串,完全不知道工具失败了。它经常基于预期返回值编造一个听起来合理的回复。这就是Agent里出现幻觉数据的根源——不是模型的训练问题,而是Agent框架把失败藏起来了。
// 好:结构化错误传播
async function executeToolGood(name: string, input: unknown): Promise<string> { try { const result = await dispatchTool(name, input); return typeof result === "string" ? result : JSON.stringify(result); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; // 返回模型可以推理的结构化错误字符串 return JSON.stringify({ error: true, tool: name, message, suggestion: getErrorSuggestion(name, err), }); }
} function getErrorSuggestion(toolName: string, err: unknown): string { const msg = err instanceof Error ? err.message : ""; if (msg.includes("timeout")) return "服务响应慢,请让用户重试。"; if (msg.includes("not found")) return "请求的资源不存在,请确认标识符是否正确。"; if (msg.includes("rate limit")) return "触发了限流,稍等重试。"; return "发生了意外错误,请告知用户并提供替代方案。";
}
有了结构化错误响应,模型可以推理出哪里出了问题,给用户建议恢复路径,而不是编造一个假答案。
同时拥有读工具(搜索、查询、读文件)和写工具(发邮件、创建记录、删除、调用API)的Agent,需要对每个类别设置不同的安全级别。模型应该可以自由调用读工具,但在调用写工具之前应该更谨慎——并且可选项是请求确认。
const READ_TOOLS = new Set(["search", "lookup_user", "get_document", "read_calendar"]);
const WRITE_TOOLS = new Set(["send_email", "create_record", "delete_file", "call_webhook"]);
const DESTRUCTIVE_TOOLS = new Set(["delete_file", "cancel_subscription"]); interface ToolCallDecision { allowed: boolean; requiresConfirmation: boolean; reason?: string;
} function classifyToolCall( toolName: string, context: { userConfirmedWrite: boolean; sessionTrusted: boolean }
): ToolCallDecision { if (READ_TOOLS.has(toolName)) { return { allowed: true, requiresConfirmation: false }; } if (DESTRUCTIVE_TOOLS.has(toolName)) { if (!context.userConfirmedWrite) { return { allowed: false, requiresConfirmation: true, reason: `${toolName} 是不可逆操作,需要用户明确确认。`, }; } return { allowed: true, requiresConfirmation: false }; } if (WRITE_TOOLS.has(toolName)) { if (context.sessionTrusted && context.userConfirmedWrite) { return { allowed: true, requiresConfirmation: false }; } return { allowed: false, requiresConfirmation: true, reason: `${toolName} 会进行修改,请先与用户确认。`, }; } // 未知工具 — 默认拒绝 return { allowed: false, requiresConfirmation: false, reason: `未知工具: ${toolName},不在白名单中。`, };
}
关键决策点:当分类返回 requiresConfirmation: true 时,不调用工具,而是把模型的拟操作返回给用户界面,请求明确批准后再继续。Agent在写操作边界暂停。
工具Schema定义了期望的输入格式。模型不总是精确交付。即使有严格的JSON Schema,你仍然会看到:Schema要求枚举但收到字符串、数字被当成字符串传、数组里只有一个元素而不是直接传元素本身、缺失可选字段、多出了模型自己发明的字段。
在工具边界处加一层强制转换,处理这些可预见的格式不匹配,而不是直接失败:
import { z } from "zod"; const SearchInputSchema = z.object({ query: z.string().min(1), max_results: z.coerce.number().int().min(1).max(50).default(10), // 模型有时会把布尔值传成 "true"/"false" 字符串 include_archived: z.preprocess( val => val === "true" ? true : val === "false" ? false : val, z.boolean().default(false) ), // 模型有时传单个字符串而不是数组 filters: z.preprocess( val => typeof val === "string" ? [val] : val, z.array(z.string()).default([]) ),
}); async function handleSearchTool(rawInput: unknown): Promise<string> { const parseResult = SearchInputSchema.safeParse(rawInput); if (!parseResult.success) { const errors = parseResult.error.errors.map(e => `${e.path.join(".")}: ${e.message}` ).join(", "); return JSON.stringify({ error: true, message: `搜索参数无效: ${errors}`, suggestion: "请修正参数后重试。", }); } const { query, max_results, include_archived, filters } = parseResult.data; return await performSearch(query, { max_results, include_archived, filters });
}
z.coerce 和 z.preprocess 处理了常见的格式不匹配(字符串转数字、字符串转布尔、字符串转数组)。Schema定义契约,强制转换层处理模型实际的输出。
这五个模式不是孤立的——它们组合在一起发挥作用:
它们共同组成了一个可预测、成本可控、可以无人值守运行的工具执行器。没有它们,你有的只是一个演示。有它们,你才有真正能部署上线的Agent。
生产版本的实现(Python或TypeScript)大约200行代码。演示版本只需要30行。差距就在这里——大多数AI Agent项目就卡在这个差距里。