游乐游手机版
首页/AI教程/文章详情

Agent与外部世界接口Tool Calling:最易断裂的脆弱环节

时间:2026-06-07 15:44
ToolCalling是Agent与外部世界交互的脆弱接口,常因参数格式错误(如枚举值大小写)和各家AI协议差异导致调用失败。MCP协议统一了工具定义与调用标准,成为事实标准但非银弹,存在性能开销与调试复杂性。跨模型迁移需通过适配层抹平差异,实践表明可大幅缩短迁移时间。
# Agent的手,到底该怎么长? 来说个真事。 你猜怎么着?你的Agent调了10次工具,结果7次都因为参数格式错误挂了。 不是夸张。生产环境中真实发生过:一个数据分析Agent,连续三天在凌晨的批量任务里静默失败。原因不是LLM“笨”,而是工具描述里某个`enum`值从`"ASC"`改成了`"asc"`,LLM还在按旧描述传参,API直接甩了个400回来。 Tool Calling,就是Agent伸向外部世界的那只“手”。这只手如果没训练好,Agent充其量只是个会想不会做的空壳——而且,你永远会在最意想不到的地方,看到它断掉。 下面这几条,都是生产环境里的血泪教训:协议差异的坑与MCP的统一之路、Structured Output带来的质变、工具描述的艺术,以及工具编排的模式和如何防御。每一条,都是真金白银换来的经验。 ---

1. Function Calling协议真相:从三家混战到MCP统一

三家格式,三种痛苦

你是不是以为Function Calling已经标准化了?天真。 OpenAI、Anthropic、Google这三家,Function Calling的协议格式各不相同。不是“大同小异”,而是“根本性差异”。当你想从OpenAI迁移到Claude,或者想做一套跨模型兼容的Agent框架时,这些差异会翻出来咬你一口。 (见配图0:三家Function Calling协议差异对比) 先说OpenAI,它用的是JSON Schema风格: ``` # OpenAI Function Calling 格式 tools = [{ "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气信息", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如'北京'、'上海'"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位"} }, "required": ["city"] } } }] # LLM返回的tool_call # { # "id": "call_abc123", # "type": "function", # "function": { # "name": "get_weather", # "arguments": "{\"city\": \"北京\", \"unit\": \"celsius\"}" # } # } ``` 注意了:这里的`arguments`是一个JSON字符串,不是JSON对象。这就是第一个坑——你非得强行`json.loads()`一下,而LLM偶尔还会给你返回格式错误的JSON。 再看Anthropic,现在Claude已经统一到了JSON格式,和OpenAI的差异大幅缩小: ``` # Anthropic tool 定义格式 tools = [{ "name": "get_weather", "description": "获取指定城市的天气信息", "input_schema": { # 注意:不是 parameters,是 input_schema "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["city"] } }] # Claude返回的tool_use block # content: [ # { # "type": "tool_use", # "id": "toolu_abc123", # "name": "get_weather", # "input": {"city": "北京", "unit": "celsius"} # 注意:这里是dict,不是字符串 # } # ] ``` 关键的差异点放在一张表里看会更清楚: | 差异点 | OpenAI | Claude | Gemini | | :--- | :--- | :--- | :--- | | 工具定义字段 | `function.parameters` | `input_schema` | `parameters`(proto) | | 参数类型系统 | JSON Schema string | JSON Schema string | Proto enum | | LLM返回格式 | JSON字符串 | dict对象 | Proto结构 | | 多工具调用 | `tool_calls`数组 | `tool_use` content blocks | 重复`function_call` | | 工具结果回传 | `tool` role message | `tool_result` content block | `function_response` part | | 并行调用支持 | ✅ 原生 | ✅ 原生 | ⚠️ 有限 | | 强制调用某工具 | `tool_choice: {function: name}` | `tool_choice: {type: "tool", name: name}` | `function_calling_config` | 好消息是:Claude现在也支持parallel tool calling了,之前最大的短板已经补齐。三家的并行调用现在基本站在了同一起跑线上。

解法:MCP协议

Anthropic发布**MCP(Model Context Protocol)**后,它已经成为Agent工具调用领域事实上的标准。 MCP解决的核心问题是什么?就是工具定义和调用协议的碎片化。 想象一下没有MCP之前的日子:每个Agent框架都要自己写一套工具适配层——OpenAI格式、Claude格式、Gemini格式各写一套。有了MCP之后,事情就变成了这样: ``` ┌─────────────┐ MCP协议 ┌─────────────┐ │ Agent/LLM │ ←──────────────→ │ MCP Server │ │ (任何模型) │ 标准化JSON-RPC │ (工具提供方) │ └─────────────┘ └─────────────┘ ``` MCP的核心设计非常清晰: 1. **Server端**:工具提供方实现一个MCP Server,暴露`tools/list`和`tools/call`两个标准接口 2. **Client端**:Agent框架通过MCP Client连接Server,获取工具列表、调用工具 3. **协议层**:JSON-RPC 2.0,传输层支持stdio和HTTP+SSE 一个最简的MCP Server长这样: ``` from mcp.server import Server from mcp.types import Tool, TextContent server = Server("weather-server") @server.list_tools() async def list_tools() -> list[Tool]: return [Tool( name="get_weather", description="获取指定城市的天气信息", inputSchema={ "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["city"] } )] @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: if name == "get_weather": city = arguments["city"] # 实际调用天气API result = await fetch_weather(city) return [TextContent(type="text", text=f"{city}:25°C,晴")] raise ValueError(f"Unknown tool: {name}") ``` 那么,MCP为什么重要? - **写一次,到处用**:一个MCP Server可以被Claude、GPT-5.1、Gemini任何模型调用,不需要写三套适配代码 - **生态已经爆发**:已有数百个开源的MCP Server——GitHub、Slack、数据库、文件系统、浏览器……很多时候你甚至不需要自己写工具,直接拿来用就行 - **Claude原生支持**:Claude Desktop和Claude API已经直接支持MCP,不需要中间层 - **OpenAI也跟进了**:是的,OpenAI也已经支持MCP协议 但必须警惕的是,MCP不是银弹: - **性能开销**:MCP Server是独立进程,通过stdio/HTTP通信,比直接函数调用要慢 - **调试更复杂**:Server进程崩溃、通信超时、JSON-RPC错误——多了一层故障点 - **不是所有场景都适合**:如果你的Agent只用3-5个内部工具,直接function calling反而更简单。MCP更适合工具多、需要跨Agent共享工具的场景

跨模型迁移的真实代价

之前带过一个项目:从OpenAI迁移到Claude,本以为1天就能搞定,结果实际花了5天。 但现在做同样的迁移,时间可以缩短到1天——原因有三: 1. Claude格式已经统一为JSON,不再需要XML解析 2. Claude支持parallel tool calling,不需要改造串行逻辑 3. MCP协议让工具定义可以复用 这里放一段实际可用的适配层代码: ``` from abc import ABC, abstractmethod from dataclasses import dataclass import json @dataclass class ToolCall: """统一的工具调用表示""" id: str name: str arguments: dict # 统一为dict,不是JSON字符串 @dataclass class ToolResult: """统一的工具结果表示""" call_id: str name: str output: str is_error: bool = False class ToolCallAdapter(ABC): """工具调用适配器——抹平三家API差异""" @abstractmethod def parse_tool_calls(self, response) -> list[ToolCall]: """从LLM响应中提取工具调用""" pass @abstractmethod def format_tool_result(self, result: ToolResult) -> dict: """将工具结果格式化为LLM需要的格式""" pass @abstractmethod def format_tool_definitions(self, tools: list[dict]) -> list[dict]: """将统一格式的工具定义转为各家格式""" pass class OpenAIAdapter(ToolCallAdapter): def parse_tool_calls(self, response) -> list[ToolCall]: calls = [] for tc in response.choices[0].message.tool_calls: try: args = json.loads(tc.function.arguments) except json.JSONDecodeError: args = {"_raw": tc.function.arguments, "_parse_error": True} calls.append(ToolCall( id=tc.id, name=tc.function.name, arguments=args )) return calls def format_tool_result(self, result: ToolResult) -> dict: return { "role": "tool", "tool_call_id": result.call_id, "content": result.output } def format_tool_definitions(self, tools: list[dict]) -> list[dict]: return [{ "type": "function", "function": { "name": t["name"], "description": t["description"], "parameters": t["input_schema"] } } for t in tools] class ClaudeAdapter(ToolCallAdapter): def parse_tool_calls(self, response) -> list[ToolCall]: calls = [] for block in response.content: if block.type == "tool_use": calls.append(ToolCall( id=block.id, name=block.name, arguments=block.input # 直接是dict )) return calls def format_tool_result(self, result: ToolResult) -> dict: return { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": result.call_id, "content": result.output, "is_error": result.is_error }] } def format_tool_definitions(self, tools: list[dict]) -> list[dict]: return [{ "name": t["name"], "description": t["description"], "input_schema": t["input_schema"] } for t in tools] def create_adapter(provider: str) -> ToolCallAdapter: adapters = { "openai": OpenAIAdapter, "claude": ClaudeAdapter, } if provider not in adapters: raise ValueError(f"Unsupported provider: {provider}") return adapters[provider]() ``` 建议是: - **新项目**:直接用MCP。工具定义写一次,所有模型通用 - **老项目迁移**:先写适配层,再逐步迁移到MCP - **纯内部工具**:如果工具不超过5个且不跨Agent共享,直接function calling更简单 ---

2. Structured Output:最大的质变

Agent开发者最头疼的问题之一:LLM返回的JSON格式不稳定。你让它返回`{"name": "张三", "age": 25}`,它可能给你返回`{"name": "张三", "age": "25"}`——age从数字变成了字符串。 这个问题,现在已经被Structured Output彻底解决了。

什么是Structured Output

Structured Output让LLM严格按你定义的JSON Schema输出,不是“尽量遵守”,而是“100%遵守”。如果Schema说`age`是integer,LLM就不可能返回string。 来看看OpenAI的实现(GPT-5.1原生支持): ``` from openai import OpenAI from pydantic import BaseModel class UserInfo(BaseModel): name: str age: int email: str client = OpenAI() response = client.responses.parse( model="gpt-5.1", input=[{"role": "user", "content": "张三,28岁,邮箱zhangsan@example.com"}], text_format=UserInfo, # 直接传Pydantic模型 ) user = response.output_parsed print(user.name) # "张三" print(user.age) # 28 (int,不是"28") print(user.email) # "zhangsan@example.com" ``` Claude的实现(Sonnet 4.6/Opus 4.8支持)也类似: ``` import anthropic from pydantic import BaseModel class UserInfo(BaseModel): name: str age: int email: str client = anthropic.Anthropic() response = client.messages.create( model="claude-sonnet-4-6-20250514", max_tokens=1024, tools=[{ "name": "structured_output", "description": "输出结构化用户信息", "input_schema": UserInfo.model_json_schema() }], tool_choice={"type": "tool", "name": "structured_output"}, messages=[{"role": "user", "content": "张三,28岁,邮箱zhangsan@example.com"}] ) ```

Structured Output对Agent的影响

这可不是什么小改进,而是质变: 1. **工具参数100%格式正确**:之前LLM偶尔会返回格式错误的JSON,你不得不`json.loads()`加try-catch再加fallback。现在不需要了。 2. **输出解析从“正则匹配”变成了“类型安全”**:之前你用正则从LLM输出里提取结构化信息,现在直接用Pydantic模型接收。 3. **多步Agent的可靠性飞跃**:Agent的每一步输出都是结构化的,下一步的输入就有了保证。整个链条的可靠性从“每步95%”变成了“每步99.9%”。 一个真实的对比: 假设某Agent需要5步工具调用,每步都需要解析上一步的JSON输出。 | 方案 | 单步成功率 | 5步链路成功率 | | :--- | :---: | :---: | | 无Structured Output | 95% | 77% (0.95^5) | | 有Structured Output | 99.9% | 99.5% (0.999^5) | 链路越长,Structured Output的价值就越大。

什么时候用Structured Output,什么时候用Function Calling

两者并不冲突,但适用场景不同: | 场景 | 推荐方式 | 理由 | | :--- | :--- | :--- | | 调用外部工具/API | Function Calling | 需要执行动作,不只是生成数据 | | 生成结构化数据 | Structured Output | 不需要执行,只需要格式正确 | | Agent中间步骤输出 | Structured Output | 保证下一步输入格式正确 | | 用户意图解析 | Structured Output | 把自然语言转成结构化意图 | 最佳实践:Agent的“思考”用Structured Output保证格式,“行动”用Function Calling执行工具。 ---

3. 工具描述的艺术:3行描述准确率40%,30行描述准确率85%

工具描述是LLM理解工具的唯一信息源。写得好,LLM精准调用;写得差,LLM只能瞎猜参数。 这不是理论,是真金白银测出来的。 (见配图1:工具描述长度vs准确率曲线)

实测数据

用同一个工具(查询数据库),写了不同长度的描述,测了100次调用准确率: **3行描述(准确率42%):** ``` { "name": "query_database", "description": "查询数据库", "input_schema": { "type": "object", "properties": { "sql": {"type": "string"}, "limit": {"type": "integer"} }, "required": ["sql"] } } ``` LLM的典型错误: - 传了`"SELECT * FROM users"`(没有limit,返回10万行) - 传了`"sql": "查一下最近的订单"`(把自然语言当成了SQL) - 没传`limit`,导致超时 **10行描述(准确率68%):** ``` { "name": "query_database", "description": "执行SQL查询并返回结果。仅支持SELECT语句,不支持INSERT/UPDATE/DELETE。查询结果默认限制100行。", "input_schema": { "type": "object", "properties": { "sql": { "type": "string", "description": "要执行的SQL SELECT语句。必须是合法SQL,不支持自然语言。" }, "limit": { "type": "integer", "description": "返回结果的最大行数,默认100。建议不超过1000。" } }, "required": ["sql"] } } ``` 错误是减少了,但仍有问题: - 传了`"sql": "SELECT * FROM orders WHERE date > '2024-01-01'"`(没考虑时区) - 传了`"limit": 999999`(“建议不超过1000”只是建议,LLM不一定遵守) **30行描述(准确率85%):** ``` { "name": "query_database", "description": """ 执行只读SQL查询,返回结果集。 支持的操作:仅SELECT语句。 禁止的操作:INSERT、UPDATE、DELETE、DROP、ALTER、CREATE。 注意事项: 1. SQL必须是标准SQL语法,不支持自然语言查询 2. 日期格式统一使用 'YYYY-MM-DD',时区为UTC 3. 表名和列名区分大小写,请参考schema信息 4. 大表查询务必使用WHERE条件过滤,避免全表扫描 5. limit参数硬性上限为1000,超过会被截断 6. 查询超时时间为30秒,复杂查询请优化SQL 7. 返回结果为JSON数组,每行一个对象 8. 如果查询失败,会返回error字段和错误信息 """, "input_schema": { "type": "object", "properties": { "sql": { "type": "string", "description": "合法的SQL SELECT语句。禁止DDL和DML操作。日期用'YYYY-MM-DD'格式,时区UTC。" }, "limit": { "type": "integer", "description": "结果行数上限。范围1-1000,默认100。超过1000的值会被自动截断为1000。", "minimum": 1, "maximum": 1000, "default": 100 } }, "required": ["sql"] } } ``` **50行描述(准确率86%):** 边际收益几乎为零,但token消耗增加了67%。

描述的黄金法则

从3行到30行,准确率从42%跳到了85%。从30行到50行,只涨了1%。这说明存在一个甜点区。 5条黄金法则: 1. **说清楚“做什么”和“不做什么”**:LLM不知道边界在哪,除非你明确告诉它。`"仅支持SELECT"`比`"支持SELECT"`要有效得多。 2. **给参数加约束,不要只加描述**:`"minimum": 1, "maximum": 1000`比`"建议不超过1000"`有效10倍。JSON Schema的约束是硬约束,描述里的“建议”是软约束。 3. **给反面示例**:`"不支持自然语言查询,如'查一下最近的订单'是无效输入"`比`"必须是合法SQL"`更清晰,也更不容易让LLM产生误解。 4. **说清楚错误行为**:`"超过1000的值会被自动截断为1000"`让LLM知道边界在哪,它就不会再去试探了。 5. **控制总长度在15-30行**:太信息息不足,太长浪费token且LLM可能忽略关键信息。 一个关键法则:用Structured Output替代描述约束。之前你需要在描述里写“age必须是整数”,现在直接在JSON Schema里定义`"type": "integer"`,LLM就会100%遵守。描述只需要关注“语义”,不需要关注“格式”。 这里放一个可运行的描述优化工具: ``` from pydantic import BaseModel, Field import json class QueryDatabaseInput(BaseModel): """用Pydantic模型生成高质量工具描述""" sql: str = Field( ..., description="合法的SQL SELECT语句。禁止DDL和DML操作。日期用'YYYY-MM-DD'格式,时区UTC。", min_length=10, ) limit: int = Field( default=100, description="结果行数上限。范围1-1000,超过1000自动截断。", ge=1, le=1000, ) def pydantic_to_tool_schema(model: type[BaseModel], description: str) -> dict: """将Pydantic模型转为统一的工具描述格式""" schema = model.model_json_schema() schema.pop("title", None) for prop in schema.get("properties", {}).values(): prop.pop("title", None) return { "name": model.__name__.replace("Input", "").lower(), "description": description, "input_schema": schema } # 使用 tool_def = pydantic_to_tool_schema( QueryDatabaseInput, description="执行只读SQL查询,返回结果集。仅支持SELECT,禁止DDL/DML。limit上限1000,超时30秒。" ) print(json.dumps(tool_def, indent=2, ensure_ascii=False)) ``` 用Pydantic的好处很明显:约束是代码的一部分,不是注释。`ge=1, le=1000`既生成了JSON Schema约束,又在运行时校验参数——双重保险。 ---

4. 工具编排4种模式:串行/并行/条件分支/动态选择

工具不是调一次就完事了。真实场景里,Agent需要编排多个工具的调用顺序、依赖关系和错误处理。 (见配图2:4种工具编排模式流程图)

模式1:串行编排(Sequential)

适用场景:步骤之间有严格依赖——上一步的输出是下一步的输入。 ``` import asyncio from dataclasses import dataclass @dataclass class ToolOutput: success: bool data: dict error: str | None = None async def sequential_orchestration(query: str) -> dict: """串行编排:查用户→查订单→查物流""" # Step 1: 查用户ID user_result = await call_tool("search_user", {"query": query}) if not user_result.success: return {"error": f"用户查询失败: {user_result.error}"} user_id = user_result.data["user_id"] # Step 2: 查用户订单(依赖Step 1的user_id) order_result = await call_tool("get_orders", {"user_id": user_id}) if not order_result.success: return {"error": f"订单查询失败: {order_result.error}"} order_id = order_result.data["latest_order_id"] # Step 3: 查物流信息(依赖Step 2的order_id) tracking_result = await call_tool("get_tracking", {"order_id": order_id}) if not tracking_result.success: return {"error": f"物流查询失败: {tracking_result.error}"} return { "user": user_result.data, "order": order_result.data, "tracking": tracking_result.data } async def call_tool(name: str, args: dict) -> ToolOutput: """模拟工具调用——生产环境替换为真实实现""" print(f"? 调用工具: {name}({args})") await asyncio.sleep(0.1) return ToolOutput(success=True, data={"mock": True, **args}) ``` 坑在于:串行编排的延迟是累加的。3个工具各200ms,总延迟就是600ms+。如果步骤之间没有依赖,不要用串行。

模式2:并行编排(Parallel)

适用场景:步骤之间无依赖,可以同时执行。 ``` async def parallel_orchestration(user_id: str) -> dict: """并行编排:同时查用户资料、订单、积分""" tasks = [ call_tool("get_user_profile", {"user_id": user_id}), call_tool("get_orders", {"user_id": user_id}), call_tool("get_points", {"user_id": user_id}), ] results = await asyncio.gather(*tasks, return_exceptions=True) output = {} for i, (task_name, result) in enumerate(zip(["profile", "orders", "points"], results)): if isinstance(result, Exception): output[task_name] = {"error": str(result), "a vailable": False} elif not result.success: output[task_name] = {"error": result.error, "a vailable": False} else: output[task_name] = {**result.data, "a vailable": True} return output ``` 坑在于:并行调用要控制并发数。10个工具同时调用,可能会打爆下游API。用`asyncio.Semaphore`限流: ``` CONCURRENCY_LIMIT = 3 semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT) async def call_tool_with_limit(name: str, args: dict) -> ToolOutput: async with semaphore: return await call_tool(name, args) async def parallel_with_limit(user_id: str) -> dict: tasks = [ call_tool_with_limit("get_user_profile", {"user_id": user_id}), call_tool_with_limit("get_orders", {"user_id": user_id}), call_tool_with_limit("get_points", {"user_id": user_id}), call_tool_with_limit("get_coupons", {"user_id": user_id}), call_tool_with_limit("get_addresses", {"user_id": user_id}), ] results = await asyncio.gather(*tasks, return_exceptions=True) return results ```

模式3:条件分支编排(Conditional)

适用场景:根据上一步的结果决定下一步走哪条路。 ``` async def conditional_orchestration(query: str) -> dict: """条件分支:根据用户类型走不同路径""" user_result = await call_tool("get_user", {"query": query}) if not user_result.success: return {"error": "用户查询失败"} user = user_result.data user_type = user.get("type", "normal") if user_type == "vip": vip_tasks = [ call_tool("get_vip_benefits", {"user_id": user["id"]}), call_tool("get_vip_agent", {"vip_level": user["vip_level"]}), ] vip_results = await asyncio.gather(*vip_tasks) return { "user": user, "benefits": vip_results[0].data, "agent": vip_results[1].data, "path": "vip" } elif user_type == "enterprise": contract_result = await call_tool("get_enterprise_contract", {"company_id": user["company_id"]}) return { "user": user, "contract": contract_result.data, "path": "enterprise" } else: normal_tasks = [ call_tool("get_orders", {"user_id": user["id"]}), call_tool("get_recommendations", {"user_id": user["id"]}), ] normal_results = await asyncio.gather(*normal_tasks) return { "user": user, "orders": normal_results[0].data, "recommendations": normal_results[1].data, "path": "normal" } ``` 坑在于:条件分支很容易变成“意大利面条代码”。分支超过3层时,就要考虑用状态机或策略模式来重构了。

模式4:动态选择编排(Dynamic)

适用场景:LLM根据上下文自主决定调哪个工具、调几次。这才是真正的Agent模式。 ``` from typing import Any class DynamicOrchestrator: """动态编排:LLM自主决定工具调用序列""" def __init__(self, llm_client, tools: dict, max_steps: int = 10): self.llm = llm_client self.tools = tools self.max_steps = max_steps async def run(self, query: str) -> dict: messages = [{"role": "user", "content": query}] tool_defs = self._build_tool_definitions() step = 0 final_answer = None while step < self.max_steps: step += 1 response = await self.llm.complete(messages, tools=tool_defs) if not response.tool_calls: final_answer = response.content break for tool_call in response.tool_calls: tool_name = tool_call.name tool_args = tool_call.arguments print(f"Step {step}: 调用 {tool_name}({tool_args})") if tool_name not in self.tools: tool_result = f"错误:未知工具 '{tool_name}'" else: try: tool_result = await self.tools[tool_name](**tool_args) except Exception as e: tool_result = f"工具执行错误: {type(e).__name__}: {e}" messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": str(tool_result) }) if final_answer is None: final_answer = "达到最大步数限制,未能完成任务。" return { "answer": final_answer, "steps": step, "truncated": step >= self.max_steps } def _build_tool_definitions(self) -> list[dict]: return [{ "name": name, "description": func.__doc__ or name, "input_schema": {} } for name, func in self.tools.items()] ``` 坑在于:动态编排最大的风险是失控——LLM可能死循环、调用无关工具、反复调同一个工具。`max_steps`是必须的兜底,但还不够。后面会讲防御性编程。

4种模式的选择指南

| 模式 | 适用场景 | 延迟 | 可控性 | 复杂度 | | :--- | :--- | :---: | :---: | :---: | | 串行 | 步骤有严格依赖 | 高(累加) | 高 | 低 | | 并行 | 步骤无依赖 | 低(取最长) | 高 | 低 | | 条件分支 | 路径依赖上一步结果 | 中 | 中 | 中 | | 动态选择 | 路径需LLM运行时决定 | 不确定 | 低 | 高 | 核心原则:能用串行/并行就不用条件分支,能用条件分支就不用动态选择。每多一层“智能”,就多一层不可控。 ---

5. 防御性编程:工具失败时Agent不能崩溃

工具调用一定会失败。API会超时、服务会宕机、参数会错误、权限会不足。 如果你的Agent在工具失败时就崩溃或直接返回错误,那它不是Agent,只是一个脆弱的API调用链。

5.1 重试策略:不是所有错误都值得重试

``` import asyncio import time from enum import Enum from functools import wraps class RetryCategory(Enum): TRANSIENT = "transient" # 暂时性错误,值得重试 PERMANENT = "permanent" # 永久性错误,不值得重试 UNKNOWN = "unknown" # 未知错误,保守重试一次 def classify_error(error: Exception) -> RetryCategory: if isinstance(error, (asyncio.TimeoutError, ConnectionError, OSError)): return RetryCategory.TRANSIENT status = getattr(error, "status_code", None) or getattr(getattr(error, "response", None), "status_code", None) if status: if status == 429: return RetryCategory.TRANSIENT if status in (400, 401, 403, 404, 422): return RetryCategory.PERMANENT if status >= 500: return RetryCategory.TRANSIENT if isinstance(error, (ValueError, TypeError, KeyError)): return RetryCategory.PERMANENT return RetryCategory.UNKNOWN def resilient_tool_call( max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0, ): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): last_error = None for attempt in range(max_retries + 1): try: return await func(*args, **kwargs) except Exception as e: last_error = e category = classify_error(e) if category == RetryCategory.PERMANENT: print(f"❌ {func.__name__} 永久性错误,不重试: {e}") raise if attempt >= max_retries: print(f"❌ {func.__name__} 重试{max_retries}次后仍失败: {e}") raise delay = min(base_delay * (2 ** attempt), max_delay) jitter = delay * 0.1 * (hash(str(time.time())) % 10 / 10) actual_delay = delay + jitter print(f"⚠️ {func.__name__} 第{attempt+1}次失败({category.value})," f"{actual_delay:.1f}s后重试: {e}") await asyncio.sleep(actual_delay) raise last_error return wrapper return decorator @resilient_tool_call(max_retries=3, base_delay=1.0) async def call_weather_api(city: str) -> dict: import random if random.random() < 0.3: raise asyncio.TimeoutError("天气API超时") return {"city": city, "temp": 25, "condition": "晴"} ```

5.2 降级方案:主工具失败时切换备选

``` class ToolWithFallback: """带降级方案的工具""" def __init__(self, primary, fallbacks: list = None, name: str = ""): self.primary = primary self.fallbacks = fallbacks or [] self.name = name or primary.__name__ async def __call__(self, **kwargs): try: result = await self.primary(**kwargs) return result except Exception as e: print(f"⚠️ 主工具 {self.name} 失败: {e}") for i, fallback in enumerate(self.fallbacks): try: result = await fallback(**kwargs) print(f"✅ 降级方案{i+1}成功") return result except Exception as e: print(f"⚠️ 降级方案{i+1}也失败: {e}") print(f"? 所有方案失败,返回兜底结果") return self._default_result(**kwargs) def _default_result(self, **kwargs): return { "error": "所有数据源不可用", "fallback": True, "message": "抱歉,相关服务暂时不可用,请稍后重试" } ```

5.3 熔断机制:连续失败时停止调用

``` import time from dataclasses import dataclass, field @dataclass class CircuitBreaker: failure_threshold: int = 5 recovery_timeout: float = 60.0 half_open_max_calls: int = 1 failure_count: int = field(default=0, init=False) last_failure_time: float = field(default=0.0, init=False) state: str = field(default="closed", init=False) half_open_calls: int = field(default=0, init=False) async def call(self, func, *args, **kwargs): if self.state == "open": if time.time() - self.last_failure_time >= self.recovery_timeout: self.state = "half_open" self.half_open_calls = 0 else: raise CircuitOpenError(f"熔断器开启中,剩余{self.recovery_timeout - (time.time() - self.last_failure_time):.0f}s") if self.state == "half_open": if self.half_open_calls >= self.half_open_max_calls: raise CircuitOpenError("半开状态试探次数已用完") self.half_open_calls += 1 try: result = await func(*args, **kwargs) self._on_success() return result except Exception as e: self._on_failure() raise def _on_success(self): self.failure_count = 0 if self.state == "half_open": self.state = "closed" def _on_failure(self): self.failure_count += 1 self.last_failure_time = time.time() if self.state == "half_open": self.state = "open" elif self.failure_count >= self.failure_threshold: self.state = "open" class CircuitOpenError(Exception): pass ```

5.4 组合防御:重试 + 降级 + 熔断

生产环境不是三选一,而是三层叠加: ``` class ResilientToolExecutor: """弹性工具执行器:重试 + 降级 + 熔断 三层防御""" def __init__(self, name: str): self.name = name self.breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self.fallbacks: list = [] self.retry_config = {"max_retries": 2, "base_delay": 1.0} def add_fallback(self, func): self.fallbacks.append(func) return self async def execute(self, func, **kwargs): # 第1层:熔断检查 try: # 第2层:重试 result = await self._retry_call(func, **kwargs) return result except CircuitOpenError: pass except Exception as e: print(f"⚠️ {self.name} 主工具失败: {e}") # 第3层:降级 for i, fallback in enumerate(self.fallbacks): try: result = await fallback(**kwargs) print(f"✅ {self.name} 降级方案{i+1}成功") return result except Exception as e: print(f"⚠️ {self.name} 降级方案{i+1}失败: {e}") return { "error": f"{self.name} 所有方案不可用", "suggestion": "请稍后重试或联系人工客服" } async def _retry_call(self, func, **kwargs): last_error = None for attempt in range(self.retry_config["max_retries"] + 1): try: return await self.breaker.call(func, **kwargs) except CircuitOpenError: raise except Exception as e: last_error = e category = classify_error(e) if category == RetryCategory.PERMANENT: raise if attempt < self.retry_config["max_retries"]: delay = self.retry_config["base_delay"] * (2 ** attempt) print(f"⚠️ {self.name} 第{attempt+1}次失败,{delay}s后重试") await asyncio.sleep(delay) raise last_error ```

防御策略速查表

| 错误类型 | 防御策略 | 示例 | | :--- | :--- | :--- | | 网络超时 | 重试(指数退避) | API调用200ms超时 | | 限流429 | 重试(等待后) | OpenAI rate limit | | 参数错误400 | 不重试,记录日志 | LLM传了错误参数格式 | | 服务宕机500 | 重试→降级→熔断 | 下游服务挂了 | | 权限不足403 | 不重试,走降级 | API key过期 | | 连续失败 | 熔断,定时试探 | 下游服务持续不可用 | 核心原则:工具失败是常态,不是异常。你的Agent必须假设工具随时可能失败,并在架构层面做好防御,而不是在每次调用时临时try-catch。 ---

小结

Tool Calling是Agent伸向外部世界的“手”。这只手最容易断,但现在,我们有了更好的接骨术: 1. **协议走向统一**:MCP协议让工具定义写一次、到处用。Claude格式已统一为JSON,parallel tool calling也补上了。跨模型迁移成本从5天降到了1天。 2. **Structured Output是质变**:从“尽量遵守格式”到“100%遵守格式”。5步链路成功率从77%提升到了99.5%。这是Agent可靠性最大的单一提升,没有之一。 3. **描述仍然是关键**:3行描述准确率42%,30行就能到85%。记得用Pydantic生成高质量描述,把约束写进代码而不是注释。Structured Output让描述只需关注语义,不用再操心格式。 4. **编排有模式可循**:串行/并行/条件分支/动态选择,从简单到复杂。能用确定性的,就尽量别用动态的。 5. **防御是必须的**:重试、降级、熔断三层叠加。工具失败是常态,不是异常。 下一章,我们来聊聊Agent的“记忆”问题——为什么大多数Agent的记忆系统要么记不住,要么记太多,以及如何设计一个真正好用的记忆架构。
来源:https://juejin.cn/post/7647819908642553899
上一篇AI Agent规范治理:从可用到可靠的工程实践 下一篇零基础工具链使用从入门到顺手完整指南
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
Sentieon DNAscope Hybrid长短读长混合分析流程详解评测
AI教程 · 2026-06-07

Sentieon DNAscope Hybrid长短读长混合分析流程详解评测

一、前言 基因组学研究已进入下半场,精度与全面性成为临床诊断及群体研究的核心需求。然而,单一测序技术常常让人陷入选择困境:短读长测序(如 Illumina)准确性高、成本低廉,但在面对结构变异、重复序列和复杂区域时显得力不从心;长读长测序(如 Oxford Nanopore)虽能轻松跨越这些障碍,超

腾讯混元Hy3 preview 295B/21B MoE架构与上下文详解
AI教程 · 2026-06-07

腾讯混元Hy3 preview 295B/21B MoE架构与上下文详解

摘要: 295B 21B MoE 是腾讯 2026 年 4 月发布的混元 Hy3 preview 的核心架构标识。本文解释参数总量与激活参数的含义、MoE 的工作机制、为什么 Hy3 preview 能原生支持 256K 上下文,并说明它在 TokenHub 上的完整能力支持与价格档位。 一、读懂

腾讯云AI业务流架构师训练营重塑编程与业务的新范式
AI教程 · 2026-06-07

腾讯云AI业务流架构师训练营重塑编程与业务的新范式

AI业务流架构师训练营:在腾讯云上重塑编程与业务的新范式 到2026年,企业AI竞争的核心已不再是“拥有AI”,而是“谁的AI业务流架构更为高效”。这一转变彻底颠覆了传统编程模式。对于技术从业者而言,AI业务流架构师已成为舞台中央的关键角色——他们不再仅仅编写代码,而是将业务需求转化为自主运行的数字

推荐一款免费使用谷歌最新NanoBanana 2插件
AI教程 · 2026-06-07

推荐一款免费使用谷歌最新NanoBanana 2插件

谷歌近期推出了重磅更新——NanoBanana2模型正式登场。无论是在知识储备、图像生成质量、推理能力还是主体一致性方面,这一版本都实现了全面升级,堪称当前地表最强的AI生图模型之一。 生成速度直接减半,价格也同步腰斩,性价比表现极为突出。不过,国内用户想直接访问官方渠道依然困难重重,大部分路径都绕

企业生产管理系统选型排行榜
AI教程 · 2026-06-07

企业生产管理系统选型排行榜

企业在进行生产管理系统选型时,往往容易陷入一个常见的思维误区:首先问“哪家功能更全面”。但从实际部署与落地效果来看,真正决定系统价值的,往往不是模块数量的简单堆叠,而是它是否真正贴合实际生产流程、能否支撑高效的跨部门协作、以及是否具备随业务变化持续迭代升级的能力。迈入2026年,制造企业对生产管理系