Agent_07_Tool设计

zbhgis 浩瀚地学15358 分钟
创建于 更新于

前面的专题讲了 Agent 架构、执行模式、上下文管理。这些都围绕一个问题:LLM 怎么和外部世界交互?答案是工具。

大多数 Agent 项目的问题不是"没给工具",而是"工具设计得不好"。Anthropic 总结过一句话——Agent 工具的质量,比 Agent 架构的质量更重要。一个设计精良的工具配合简单循环,胜过复杂架构配上设计粗糙的工具。

工具设计不是"写函数",而是"写 LLM 能理解和正确使用的函数"。人类调用函数时可以看文档、试参数、调试错误,LLM 只能靠工具名、描述和 schema 来决定怎么用。工具对 LLM 来说必须是自解释的。


一、好工具 vs 坏工具

先看同一个功能的三种写法。场景是查询天气:

python
# 写法一:坏工具

def get_weather(city: str):
    """查天气。"""
    import requests
    resp = requests.get(f"https://api.weather.com/{city}")
    return resp.json()

# 问题:
# 1. 描述太短——"查天气"没有告诉 LLM 返回格式、单位、什么时候用
# 2. 没有参数描述——LLM 不知道 city 应该是中文名还是拼音
# 3. 没有错误处理——API 失败时抛异常,LLM 不知道发生了什么
# 4. 输出格式不固定——LLM 无法可靠解析返回结果


# 写法二:普通工具

def get_weather_v2(city: str, unit: str = "celsius") -> dict:
    """
    查询指定城市的当前天气。

    :param city: 城市名称(中文,如"北京")
    :param unit: 温度单位,"celsius"(摄氏度)或"fahrenheit"(华氏度),默认 celsius
    :return: 包含天气信息的字典:{"city": str, "temperature": float,
             "condition": str, "humidity": int, "unit": str}
    """
    import requests
    try:
        resp = requests.get(
            f"https://api.weather.com/{city}",
            params={"unit": unit},
            timeout=10,
        )
        resp.raise_for_status()
        data = resp.json()
        return {
            "city": data["name"],
            "temperature": data["temp"],
            "condition": data["condition"],
            "humidity": data["humidity"],
            "unit": unit,
        }
    except requests.RequestException as e:
        return {"error": f"天气查询失败: {str(e)}"}

# 改进:描述变长了,有参数描述,有错误处理,输出格式固定
# 还差什么?使用场景说明


# 写法三:生产级工具

def get_weather_v3(city: str, unit: str = "celsius") -> dict:
    """
    查询指定城市的当前天气。

    使用场景:
    - 用户询问某个城市的天气
    - 用户需要对比多个城市的天气
    - 用户计划出行,需要了解目的地天气

    不使用场景:
    - 用户问天气预报/未来几天的天气(使用 forecast_weather 工具)
    - 用户问历史天气数据(使用 historical_weather 工具)

    :param city: 城市名称。支持中文名(如"北京")、拼音(如"beijing")、
                 英文(如"New York")。如果不确定,使用中文名。
    :param unit: 温度单位。"celsius"(摄氏度,默认)或"fahrenheit"(华氏度)。
                 中国大陆用户默认 celsius。
    :return: 成功时返回 {"city": str, "temperature": float,
             "condition": str, "humidity": int, "wind_speed": float,
             "unit": str}
             失败时返回 {"error": str, "city": str, "suggestion": str}

    示例:
    >>> get_weather_v3("北京")
    {"city": "北京", "temperature": 22.0, "condition": "晴",
     "humidity": 45, "wind_speed": 3.2, "unit": "celsius"}

    >>> get_weather_v3("unknown_city_xyz")
    {"error": "未找到城市 unknown_city_xyz", "city": "unknown_city_xyz",
     "suggestion": "请检查城市名称是否正确"}
    """
    import requests

    # 参数验证
    if not city or len(city.strip()) < 2:
        return {
            "error": f"城市名称无效: '{city}'",
            "city": city,
            "suggestion": "请输入有效的城市名称(至少 2 个字符)",
        }

    if unit not in ("celsius", "fahrenheit"):
        return {
            "error": f"温度单位无效: '{unit}'",
            "city": city,
            "suggestion": "请使用 'celsius' 或 'fahrenheit'",
        }

    try:
        resp = requests.get(
            f"https://api.weather.com/{city}",
            params={"unit": unit},
            timeout=10,
        )
        resp.raise_for_status()
        data = resp.json()
        return {
            "city": data["name"],
            "temperature": data["temp"],
            "condition": data["condition"],
            "humidity": data["humidity"],
            "wind_speed": data.get("wind_speed", 0),
            "unit": unit,
        }
    except requests.Timeout:
        return {
            "error": "天气查询超时",
            "city": city,
            "suggestion": "请稍后重试",
        }
    except requests.ConnectionError:
        return {
            "error": "无法连接天气服务",
            "city": city,
            "suggestion": "请检查网络连接",
        }
    except requests.HTTPError as e:
        if e.response.status_code == 404:
            return {
                "error": f"未找到城市: {city}",
                "city": city,
                "suggestion": "请检查城市名称是否正确",
            }
        return {
            "error": f"天气服务返回错误 {e.response.status_code}",
            "city": city,
            "suggestion": None,  # 服务端错误,LLM 无法处理
        }

生产级工具的关键在于 docstring 不只是给人类看的,更是给 LLM 看的。使用场景和不使用场景告诉 LLM 什么时候该用、什么时候不该用。参数描述告诉 LLM 参数的格式和约束。返回示例让 LLM 知道结果的格式。错误信息加建议让 LLM 在失败时能指导下一步行动。


二、Tool Schema 自动生成

手动写工具 schema 容易和实际代码不同步。用 Python 的 inspect 模块从函数签名和 docstring 自动提取,保证 schema 始终与函数定义一致:

python
import inspect
import json
import re
from typing import get_type_hints, Any


def extract_tool_schema(func) -> dict:
    """
    从 Python 函数自动提取工具 schema

    提取内容:
    1. 函数名 -> tool name
    2. docstring -> description + parameters description
    3. 函数签名 -> parameters + types + defaults + required
    """
    sig = inspect.signature(func)
    docstring = inspect.getdoc(func) or ""
    hints = get_type_hints(func)

    description = _extract_description(docstring)

    properties = {}
    required = []

    for name, param in sig.parameters.items():
        if name in ("self", "cls"):
            continue

        param_info = _extract_param_info(name, param, hints, docstring)
        properties[name] = param_info["schema"]

        if param.default is inspect.Parameter.empty:
            required.append(name)

    return {
        "type": "function",
        "function": {
            "name": func.__name__,
            "description": description,
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


def _extract_description(docstring: str) -> str:
    """从 docstring 提取主描述(第一行,不含参数部分)"""
    param_idx = docstring.find(":param")
    if param_idx > 0:
        return docstring[:param_idx].strip()
    return docstring.split("\n")[0].strip()


def _extract_param_info(name: str, param, hints: dict, docstring: str) -> dict:
    """提取单个参数的完整信息"""
    type_mapping = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
        list: "array",
        dict: "object",
    }
    param_type = hints.get(name, Any)
    json_type = type_mapping.get(param_type, "string")

    param_desc = _extract_param_description(docstring, name)

    schema = {"type": json_type}
    if param_desc:
        schema["description"] = param_desc

    if param.default is not inspect.Parameter.empty:
        schema["default"] = param.default

    return {"schema": schema, "required": param.default is inspect.Parameter.empty}


def _extract_param_description(docstring: str, param_name: str) -> str:
    """从 docstring 提取某个参数的描述"""
    pattern = rf":param\s+{param_name}:\s*(.+?)(?=\n:param|\n:return|$)"
    match = re.search(pattern, docstring, re.DOTALL)
    if match:
        return match.group(1).strip()
    return ""


# 使用示例

def analyze_data(
    data_source: str,
    metrics: list = None,
    group_by: str = None,
    output_format: str = "json",
) -> dict:
    """
    对数据源执行统计分析。

    使用场景:用户要求分析数据、计算统计指标、对比不同维度的数据。

    :param data_source: 数据源路径或标识符(如 "sales.csv" 或 "db:sales_table")
    :param metrics: 要计算的指标列表,如 ["mean", "sum", "count"]。默认计算所有常用指标
    :param group_by: 分组字段,如 "region" 或 "month"。不指定则不分组
    :param output_format: 输出格式,"json"(默认)或 "csv"
    """
    pass


schema = extract_tool_schema(analyze_data)
print(json.dumps(schema, indent=2, ensure_ascii=False))
# 输出:
# {
#   "type": "function",
#   "function": {
#     "name": "analyze_data",
#     "description": "对数据源执行统计分析。",
#     "parameters": {
#       "type": "object",
#       "properties": {
#         "data_source": {
#           "type": "string",
#           "description": "数据源路径或标识符(如 \"sales.csv\" 或 \"db:sales_table\")"
#         },
#         "metrics": {
#           "type": "array",
#           "description": "要计算的指标列表,如 [\"mean\", \"sum\", \"count\"]。默认计算所有常用指标"
#         },
#         "group_by": {
#           "type": "string",
#           "description": "分组字段,如 \"region\" 或 \"month\"。不指定则不分组"
#         },
#         "output_format": {
#           "type": "string",
#           "description": "输出格式,\"json\"(默认)或 \"csv\"",
#           "default": "json"
#         }
#       },
#       "required": ["data_source"]
#     }
#   }
# }

改了函数参数,schema 自动更新。改了 docstring,描述自动更新。工具有 10+ 个时这尤其重要——手动维护的 schema 一定会和实际代码不同步。


三、工具错误处理

工具一定会失败——API 超时、文件不存在、数据格式不对。重点不是"避免失败",而是让 LLM 能从失败信息中指导下一步行动。

python
from dataclasses import dataclass
from typing import Any


@dataclass
class ToolResponse:
    """工具响应——统一的成功/失败格式

    无论成功还是失败,都返回同样的结构。
    LLM 只需要检查 success 字段,就能判断是否需要采取行动。
    """
    success: bool
    data: Any = None
    error: str = ""
    suggestion: str = ""  # 关键:告诉 LLM 下一步该做什么

    def to_dict(self) -> dict:
        result = {"success": self.success}
        if self.success:
            result["data"] = self.data
        else:
            result["error"] = self.error
            if self.suggestion:
                result["suggestion"] = self.suggestion
        return result


class ErrorClassifier:
    """错误分类器——根据错误类型生成不同的 suggestion

    不同错误类型需要不同的修复建议。
    "文件不存在"和"权限不足"的修复方式完全不同。
    """

    @staticmethod
    def classify(error_type: str, context: dict = None) -> str:
        """
        根据错误类型生成建议

        error_type: 错误类型标识符
        context: 额外上下文(如文件路径、API 地址等)
        """
        suggestions = {
            "file_not_found": (
                f"请检查文件路径是否正确。"
                f"{'当前路径: ' + context['path'] if context and 'path' in context else ''}"
            ),
            "permission_denied": (
                "权限不足。请使用正确的凭证或联系管理员。"
            ),
            "timeout": (
                "操作超时。可能是网络问题或服务响应慢。"
                "建议:1) 重试一次 2) 检查网络连接 3) 尝试其他方法"
            ),
            "invalid_format": (
                f"数据格式不正确。期望格式: {context.get('expected', '未知')}。"
                f"请检查输入数据是否符合格式要求。"
            ),
            "api_error": (
                f"API 调用失败,状态码: {context.get('status_code', '未知')}。"
                f"请检查 API 凭证和请求参数。"
            ),
            "validation_error": (
                f"参数验证失败: {context.get('details', '未知')}。"
                f"请检查参数的格式和取值范围。"
            ),
            "unknown": (
                "发生了未知错误。请尝试其他方法或联系技术支持。"
            ),
        }
        return suggestions.get(error_type, suggestions["unknown"])


def read_file(path: str, encoding: str = "utf-8") -> dict:
    """
    读取文件内容。

    :param path: 文件路径
    :param encoding: 文件编码,默认 utf-8
    """
    import os

    if not path:
        return ToolResponse(
            success=False,
            error="文件路径不能为空",
            suggestion=ErrorClassifier.classify("validation_error", {"details": "path 为空"}),
        ).to_dict()

    if not os.path.exists(path):
        return ToolResponse(
            success=False,
            error=f"文件不存在: {path}",
            suggestion=ErrorClassifier.classify("file_not_found", {"path": path}),
        ).to_dict()

    try:
        with open(path, "r", encoding=encoding) as f:
            content = f.read()
        return ToolResponse(success=True, data={"content": content, "size": len(content)}).to_dict()
    except PermissionError:
        return ToolResponse(
            success=False,
            error=f"没有读取权限: {path}",
            suggestion=ErrorClassifier.classify("permission_denied"),
        ).to_dict()
    except UnicodeDecodeError:
        return ToolResponse(
            success=False,
            error=f"文件编码不匹配: 期望 {encoding}",
            suggestion=ErrorClassifier.classify(
                "invalid_format", {"expected": f"编码为 {encoding} 的文本文件"}
            ),
        ).to_dict()
    except Exception as e:
        return ToolResponse(
            success=False,
            error=f"读取文件时发生错误: {str(e)}",
            suggestion=ErrorClassifier.classify("unknown"),
        ).to_dict()

LLM 收到错误后,需要知道下一步该怎么做。如果错误信息只说"出错了",LLM 只能盲目重试或放弃。如果错误信息说"文件不存在,请检查路径是否正确",LLM 可以请求用户确认路径。suggestion 字段是错误处理里最重要的部分。


四、工具测试

工具写好了,怎么知道 LLM 能不能正确使用?需要专门的测试框架:

python
"""
工具测试 —— 评估 LLM 能否正确使用工具

工具测试不是"函数单元测试",而是"LLM 理解测试"。
你需要测试的不是工具本身能不能跑,而是 LLM 能不能:
1. 根据描述选对工具
2. 根据参数 schema 传对参数
3. 根据错误信息采取正确的修复行动
"""

import json


class ToolEvalSuite:
    """工具评估套件

    三类测试:
    1. 工具选择测试——给 LLM 一个场景,看它选不选得对工具
    2. 参数传递测试——给 LLM 一个工具,看它传不传得对参数
    3. 错误恢复测试——给 LLM 一个错误,看它能不能修复
    """

    def __init__(self, llm, tools: dict):
        self.llm = llm
        self.tools = tools

    def test_tool_selection(self, test_cases: list[dict]) -> dict:
        """测试一:工具选择

        每个用例:给 LLM 一个场景,看它选不选得对工具。

        test_cases: [
            {"scenario": "用户想知道北京今天的天气", "expected_tool": "get_weather"},
            {"scenario": "用户想读取 sales.csv 的内容", "expected_tool": "read_file"},
            ...
        ]

        返回:{"total": N, "correct": M, "accuracy": 0.X, "wrong_cases: [...]}
        """
        results = {"total": len(test_cases), "correct": 0, "wrong_cases": []}

        for case in test_cases:
            tool_list = "\n".join(
                f"- {name}: {tool.__doc__.split(chr(10))[0] if tool.__doc__ else '无描述'}"
                for name, tool in self.tools.items()
            )

            prompt = f"""
用户场景: {case['scenario']}

可用工具:
{tool_list}

请选择合适的工具。只返回工具名称,不要解释。
"""
            response = self.llm.generate(prompt).strip().lower()
            expected = case["expected_tool"].lower()

            if response == expected or expected in response:
                results["correct"] += 1
            else:
                results["wrong_cases"].append({
                    "scenario": case["scenario"],
                    "expected": case["expected_tool"],
                    "got": response,
                })

        results["accuracy"] = results["correct"] / results["total"] if results["total"] else 0
        return results

    def test_parameter_passing(self, test_cases: list[dict]) -> dict:
        """测试二:参数传递

        每个用例:给 LLM 一个工具和场景,看它传不传得对参数。

        test_cases: [
            {
                "tool": "get_weather",
                "scenario": "查询上海的天气,用华氏度",
                "expected_params": {"city": "上海", "unit": "fahrenheit"},
            },
            ...
        ]
        """
        results = {"total": len(test_cases), "correct": 0, "wrong_cases": []}

        for case in test_cases:
            tool = self.tools[case["tool"]]
            schema = extract_tool_schema(tool)

            prompt = f"""
用户场景: {case['scenario']}

工具定义: {json.dumps(schema, ensure_ascii=False, indent=2)}

请调用合适的工具并传入参数。返回 JSON 格式:
{{"tool": "工具名", "parameters": {{参数}}}}
"""
            response = self.llm.generate(prompt)
            try:
                data = json.loads(response)
                params = data.get("parameters", {})

                all_correct = True
                for key, expected_val in case["expected_params"].items():
                    if params.get(key) != expected_val:
                        all_correct = False
                        results["wrong_cases"].append({
                            "scenario": case["scenario"],
                            "param": key,
                            "expected": expected_val,
                            "got": params.get(key),
                        })
                        break

                if all_correct:
                    results["correct"] += 1
            except (json.JSONDecodeError, KeyError):
                results["wrong_cases"].append({
                    "scenario": case["scenario"],
                    "error": "LLM 返回格式不正确",
                    "response": response[:200],
                })

        results["accuracy"] = results["correct"] / results["total"] if results["total"] else 0
        return results

    def test_error_recovery(self, test_cases: list[dict]) -> dict:
        """测试三:错误恢复

        每个用例:给 LLM 一个工具调用结果(错误),看它能不能采取正确的修复行动。

        test_cases: [
            {
                "tool_call": {"tool": "read_file", "params": {"path": "missing.csv"}},
                "error_response": {"success": false, "error": "文件不存在: missing.csv",
                                   "suggestion": "请检查文件路径是否正确。"},
                "expected_action": "询问用户确认文件路径",
            },
            ...
        ]
        """
        results = {"total": len(test_cases), "correct": 0, "wrong_cases": []}

        for case in test_cases:
            prompt = f"""
你调用了工具:
工具: {case['tool_call']['tool']}
参数: {case['tool_call']['params']}

工具返回:
{json.dumps(case['error_response'], ensure_ascii=False)}

你应该采取什么行动?请简要描述。
"""
            response = self.llm.generate(prompt).lower()
            expected = case["expected_action"].lower()

            if any(kw in response for kw in expected.split()):
                results["correct"] += 1
            else:
                results["wrong_cases"].append({
                    "tool_call": case["tool_call"],
                    "expected_action": case["expected_action"],
                    "got_action": response[:200],
                })

        results["accuracy"] = results["correct"] / results["total"] if results["total"] else 0
        return results


def evaluate_tools(llm, tools: dict) -> dict:
    """运行完整评估,返回报告"""
    suite = ToolEvalSuite(llm, tools)

    selection_results = suite.test_tool_selection([
        {"scenario": "用户想知道北京今天天气", "expected_tool": "get_weather"},
        {"scenario": "用户想读取 sales.csv", "expected_tool": "read_file"},
        {"scenario": "用户想分析销售数据", "expected_tool": "analyze_data"},
    ])

    param_results = suite.test_parameter_passing([
        {
            "tool": "get_weather",
            "scenario": "查询上海天气,用华氏度",
            "expected_params": {"city": "上海", "unit": "fahrenheit"},
        },
    ])

    return {
        "tool_selection": selection_results,
        "parameter_passing": param_results,
    }

工具测试测的不只是函数能不能跑,而是 LLM 能不能理解和使用这个函数。一个在人类看来"描述清楚"的工具,在 LLM 看来可能"完全不知道什么时候用"。这是 Agent 工程里最容易被忽视但最重要的质量保障环节。


五、工具组合模式

原子工具是好的起点,但实际场景中 LLM 经常需要组合多个工具来完成复杂任务。三种常见模式:

python
# 模式一:管道模式

class ToolPipeline:
    """管道模式——多个工具串联执行

    适用场景:步骤固定、顺序固定的操作链。
    优点:LLM 只需要调用一次,不需要管理中间步骤。
    缺点:灵活性低,中间步骤失败整个管道终止。
    """

    def __init__(self, steps: list[dict], name: str = "", description: str = ""):
        """
        steps: [
            {"tool": "read_csv", "args_mapper": lambda x: {"path": x}},
            {"tool": "clean_data", "args_mapper": lambda x: {"data": x}},
            {"tool": "analyze", "args_mapper": lambda x: {"data": x}},
        ]
        """
        self.steps = steps
        self.name = name
        self.description = description
        self._docstring = description

    def execute(self, tool_registry, initial_input) -> dict:
        current = initial_input
        history = []

        for step in self.steps:
            args = step["args_mapper"](current)
            result = tool_registry.execute(step["tool"], **args)

            history.append({"step": step["tool"], "success": result.success, "data": result.data})

            if not result.success:
                return {
                    "success": False,
                    "error": f"管道在 '{step['tool']}' 步骤失败",
                    "history": history,
                    "suggestion": f"检查 '{step['tool']}' 的输入数据是否正确",
                }

            current = result.data

        return {"success": True, "data": current, "history": history}


# 模式二:分支模式

class ToolBranch:
    """分支模式——根据输入条件选择不同的工具

    适用场景:不同类型的输入需要不同的处理工具。
    优点:LLM 只需要调用一个入口工具,自动路由到正确的处理工具。
    缺点:路由逻辑内嵌在工具中,LLM 不了解内部决策过程。
    """

    def __init__(self, name: str, description: str, routes: dict):
        """
        routes: {
            "csv": {"tool": "read_csv", "condition": lambda x: x.endswith('.csv')},
            "excel": {"tool": "read_excel", "condition": lambda x: x.endswith(('.xlsx', '.xls'))},
            "json": {"tool": "read_json", "condition": lambda x: x.endswith('.json')},
        }
        """
        self.name = name
        self.description = description
        self.routes = routes

    def execute(self, tool_registry, path: str) -> dict:
        for route_name, route_info in self.routes.items():
            if route_info["condition"](path):
                return tool_registry.execute(route_info["tool"], path=path)

        return {
            "success": False,
            "error": f"不支持的文件类型: {path}",
            "suggestion": f"支持的文件类型: {', '.join(self.routes.keys())}",
        }


# 模式三:聚合模式

class ToolAggregator:
    """聚合模式——并行调用多个工具,合并结果

    适用场景:需要多角度获取信息的场景。
    优点:一次调用获取多方面信息。
    缺点:所有工具都成功才算成功,一个失败可能影响整体质量。
    """

    def __init__(self, name: str, description: str, tools: list[dict]):
        """
        tools: [
            {"tool": "get_current_weather", "args": {"city": "{{city}}"}},
            {"tool": "get_weather_forecast", "args": {"city": "{{city}}"}},
            {"tool": "get_air_quality", "args": {"city": "{{city}}"}},
        ]
        """
        self.name = name
        self.description = description
        self.tools = tools

    def execute(self, tool_registry, **kwargs) -> dict:
        results = {}
        errors = []

        for tool_info in self.tools:
            args = {}
            for key, value in tool_info["args"].items():
                args[key] = value.replace("{{", "").replace("}}", "")
                if args[key] in kwargs:
                    args[key] = kwargs[args[key]]

            result = tool_registry.execute(tool_info["tool"], **args)
            if result.success:
                results[tool_info["tool"]] = result.data
            else:
                errors.append({"tool": tool_info["tool"], "error": result.error})

        return {
            "success": len(errors) == 0,
            "data": results,
            "partial_errors": errors if errors else None,
            "suggestion": (
                f"部分工具执行失败: {', '.join(e['tool'] for e in errors)}"
                if errors else ""
            ),
        }

什么时候用原子工具,什么时候用复合工具?看使用频率和复杂度。如果某个工具组合("读取 -> 清洗 -> 分析")被调用的频率很高,封装成复合工具可以减少 LLM 的调用次数和出错概率。但如果组合内部逻辑很复杂(比如需要根据中间结果分支),保留原子工具让 LLM 自己组合更灵活。


六、实践中的几个问题

工具描述到底应该多长?

不是越长越好,也不是越短越好——看信息密度。

text
# 太短(信息不足):
"分析数据"——LLM 不知道分析什么、怎么分析、返回什么

# 刚好(信息密度高):
"对数值型数据执行描述性统计分析,返回均值、标准差、最大最小值、四分位数。
 不适用于文本数据或时间序列。"

# 太长(信息稀释):
"分析数据。这个工具可以对数据进行各种各样的统计分析,
 包括但不限于均值、中位数、众数、标准差、方差、偏度、峰度、
 四分位数、百分位数等。还可以进行分组分析、时间序列分析、
 相关性分析、回归分析等等。使用时需要注意数据的质量……"
(后面还有 500 字——LLM 的注意力已经散了)

经验法则:工具描述 1-3 句话,包含"做什么"+"适用场景"+"不适用场景"+"返回格式"。

一个 Agent 应该有多少个工具?

工具数量效果建议
1-3 个LLM 选择准确适合简单任务
4-8 个LLM 选择良好大多数场景的甜点
9-15 个LLM 选择开始下降需要工具分组或按需加载
16+ 个LLM 选择准确率明显下降需要分层注入、工具搜索、或复合工具
优先把工具控制在 10 个以内。如果超过 10 个,考虑用复合工具减少数量,或用条件注入按需加载。

工具设计和 API 设计有什么区别?

维度API 设计Agent 工具设计
调用者人类开发者(可以看文档、试参数、调试)LLM(只能看描述和 schema)
错误处理抛异常、HTTP 状态码返回结构化错误 + suggestion
文档OpenAPI/Swagger(人类可读)docstring + schema(LLM 可读)
参数可以省略、可以有默认值必填参数必须明确,默认值要合理
测试单元测试(输入->输出)LLM 理解测试(LLM 是否能选对、传对、处理错误)
什么时候算过度设计?工具只有 1-2 个时不需要自动生成 schema 和评估套件;工具只给人类开发者用时不需要 LLM 理解导向的设计;工具是内部使用不需要错误 suggestion。

七、框架中的实际应用

框架/协议工具设计实践说明
MCP (Model Context Protocol)标准化协议——定义工具接口、参数、错误格式Anthropic 推动的开放协议,统一工具描述和调用方式
OpenAI Function CallingJSON Schema 定义函数签名SDK 自动处理函数调用->执行->返回的循环
Anthropic Tool Use工具描述直接注入 promptClaude 原生支持,不需要额外协议
LangChain Tools@tool 装饰器自动生成 schema从 docstring 提取描述,但需要手动补充使用场景
Google ADKTool 作为 Agent 的一等公民ADK 中每个 Agent 有一组绑定的工具

八、写在最后

工具设计是 Agent 工程中最被低估的环节。大多数人关注 Agent 架构、执行模式、prompt 工程,但忽略了工具才是 LLM 真正"动手做事"的接口。一个设计精良的工具,能让简单的 ReAct 循环表现优异;一个设计粗糙的工具,再复杂的架构也跑不起来。

记住四原则:单一职责、自描述、容错输出、可测试。写好工具之后,再考虑工具组合(管道、分支、聚合)和工具评估(LLM 能否选对、传对、处理错误)。