第四章 Tool Calling最容易断的手——Agent和外部世界的接口,也是最脆弱的接口

你的Agent调了10次工具,7次参数格式错误。

不是夸张。这是我亲眼见过的生产事故:一个数据分析Agent,连续3天在凌晨批量任务里静默失败,原因不是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风格

python 复制代码
# 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:JSON风格(已统一)

Claude早期用XML格式返回工具调用,这是很多人对Claude的旧印象。现在Claude已经全面切换到JSON格式,和OpenAI的差异大幅缩小:

python 复制代码
# 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之后:

scss 复制代码
┌─────────────┐     MCP协议      ┌─────────────┐
│  Agent/LLM  │ ←──────────────→ │  MCP Server │
│  (任何模型)  │   标准化JSON-RPC  │  (工具提供方) │
└─────────────┘                   └─────────────┘

MCP的核心设计

  1. Server端 :工具提供方实现一个MCP Server,暴露tools/listtools/call两个标准接口
  2. Client端:Agent框架通过MCP Client连接Server,获取工具列表、调用工具
  3. 协议层:JSON-RPC 2.0,传输层支持stdio和HTTP+SSE

一个最简MCP Server

python 复制代码
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协议让工具定义可以复用

可运行的适配层代码

python 复制代码
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原生支持):

python 复制代码
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支持):

python 复制代码
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%)

python 复制代码
{
    "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%)

python 复制代码
{
    "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%)

python 复制代码
{
    "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"更清晰。

  4. 说清楚错误行为"超过1000的值会被自动截断为1000"让LLM知道边界在哪,不会试探。

  5. 控制总长度在15-30行:太短信息不足,太长浪费token且LLM可能忽略关键信息。

法则用Structured Output替代描述约束 。之前你需要在描述里写"age必须是整数",现在直接在JSON Schema里定义"type": "integer",LLM 100%遵守。描述只需要关注"语义",不需要关注"格式"。

可运行的描述优化工具

python 复制代码
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)

适用场景:步骤之间有严格依赖------上一步的输出是下一步的输入。

python 复制代码
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)

适用场景:步骤之间无依赖,可以同时执行。

python 复制代码
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), "available": False}
        elif not result.success:
            output[task_name] = {"error": result.error, "available": False}
        else:
            output[task_name] = {**result.data, "available": True}
    
    return output

:并行调用要控制并发数。10个工具同时调用可能打爆下游API。用asyncio.Semaphore限流:

python 复制代码
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)

适用场景:根据上一步的结果决定下一步走哪条路。

python 复制代码
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模式。

python 复制代码
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 重试策略:不是所有错误都值得重试

python 复制代码
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 降级方案:主工具失败时切换备选

python 复制代码
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 熔断机制:连续失败时停止调用

python 复制代码
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 组合防御:重试 + 降级 + 熔断

生产环境不是三选一,而是三层叠加:

python 复制代码
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的记忆系统要么记不住,要么记太多,以及如何设计一个真正好用的记忆架构。


相关推荐
HLAIA光子2 小时前
LLM缓存机制:你的API账单可以砍掉75%
后端·llm·ai编程
清欢渡hb2 小时前
一人 AI 软件公司 · Claude Code 插件架构设计
人工智能·ai编程·claude·一人公司
摸鱼同学3 小时前
09-Agent 设计三阶段:ReAct → Memory → Reflection
ai·chatgpt·agent·ai编程
linge_sun3 小时前
SpringAI 功能体验之SQL智能助手:用自然语言查询数据库
java·人工智能·ai编程
沉默王二4 小时前
刚上线就斩获 2.3K 星标!AnySearch 搜索能力拉满!
agent·ai编程·claude
咖啡星人k4 小时前
开源AI编程的安全性:MonkeyCode 容器沙箱隔离方案深度解析
ai编程
黏刚5 小时前
2025 最新 Claude Code 教程:从安装部署到 SpringBoot 项目实战(附完整 Java 示例)
java·ai编程·claude
lwyingdao5 小时前
Codex接入国产大模型,三步配置,无需OpenAI账号
人工智能·ai编程·ai工具
wuhen_n5 小时前
实战!前端开发完整 LangChain AI 智能体(附源码)
前端·langchain·ai编程