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
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。