第 3 章:工具调用基础

工具让 LLM 从"会说"变成"能做"——但工具不是万能药:description 写烂了,LLM 照样选错工具、填错参数。


3.1 LLM 的根本问题

先看一个真实案例:

2023 年底,我在一家金融科技公司部署了一个客服 Agent。上线第一周,它回答得很流畅,用户满意度不错。

到了第二周,问题来了。一个用户问:"最新的贷款利率是多少?"

Agent 回答:"根据我的训练数据,当前贷款基准利率为 4.35%。"

客户打电话投诉:这是 2020 年的利率!现在已经 3.65% 了!

这就是 LLM 的根本问题——它只能基于训练数据"编",不能查实时信息。 模型知识截止到训练时间,它不知道今天的利率、今天的天气、今天的新闻。

一周后,我们给 Agent 接入了利率查询 API。同样的问题,它会先调用工具查询,再返回准确答案。同一个模型,加了工具就像换了脑子。

没有工具的 LLM 能做什么?

LLM 有一个致命缺陷:它只能编,不能查

你问它:"今天北京天气怎么样?"

它会回答:"根据我的训练数据,北京的气候..."

这是编的。 它的知识截止到训练时间,它不知道今天的天气。

问题类型LLM 的表现为什么
实时信息编造或拒绝训练数据是历史的
精确计算经常算错它是语言模型,不是计算器
外部系统无能为力它不能发请求、读文件
私有数据完全不知道训练时没见过你的数据

要解决这些问题,LLM 必须能"动手"——去调 API 查天气、去搜网页找信息、去读文件看数据。

这些"动手"的能力,就是工具。


3.2 Function Calling 是什么?

很多人被这个术语搞晕了,其实很简单:

Function Calling = 让 LLM 不直接回答问题,而是说"我需要调用某个工具"

传统 LLM:

用户:5 的阶乘是多少?
LLM:5 的阶乘是 120。

这看起来对,但如果问的是大数,LLM 可能算错。试试问它 13 的阶乘,它有一定概率给你一个错误答案。

Function Calling 的 LLM:

用户:5 的阶乘是多少?
LLM:{
  "tool": "calculator",
  "parameters": { "expression": "5!" }
}

它不直接算,而是输出一个 JSON,说"我要调用 calculator 工具"。

然后程序解析这个 JSON,真正调用计算器,把结果喂回给 LLM,LLM 再回答用户。

这就是 Function Calling 的本质:让 LLM 学会"请求帮助"而不是"硬着头皮编"。

为什么要输出 JSON?

因为程序需要解析。如果 LLM 输出的是自然语言:

我需要调用计算器,计算 5 的阶乘

程序怎么知道"计算器"对应哪个函数?"5 的阶乘"怎么转成参数?

JSON 是结构化的,程序可以直接解析:

{"tool": "calculator", "parameters": {"expression": "5!"}}

tool 是工具名,parameters 是参数。一目了然。


3.3 一个工具长什么样?

用 Shannon 的实现来举例。一个工具有三部分:

第一部分:元数据(这工具是干什么的)

实现参考 (Shannon): tools/base.py - ToolMetadata 类

@dataclass
class ToolMetadata:
    name: str               # 工具名
    version: str            # 版本号
    description: str        # 干什么的(给 LLM 看)
    category: str           # 分类:search, calculation, file...

    # 生产必备字段
    requires_auth: bool = False      # 需要认证吗
    rate_limit: Optional[int] = None # 每分钟最多调几次
    timeout_seconds: int = 30        # 最多跑多久
    memory_limit_mb: int = 512       # 内存限制
    sandboxed: bool = True           # 要不要沙箱隔离
    dangerous: bool = False          # 是不是危险操作
    cost_per_use: float = 0.0        # 每次调用花多少钱

为什么需要这些字段?

字段防什么真实案例
timeout_seconds工具卡死拖垮整个 Agent某个网页加载 30 秒没响应
rate_limitAgent 疯狂调用烧光 API 额度一分钟调了 200 次搜索 API
dangerous危险操作需要用户确认删除文件、发送邮件
cost_per_use追踪成本做预算控制调了 100 次 GPT-4,账单爆了
sandboxed防止恶意代码逃逸用户输入包含 shell 命令

这些不是可选的"高级功能",这是生产环境的必备项

我见过一个案例:Agent 在调试一个网络问题,它不停地 ping 同一个地址,一分钟内调用了几百次网络工具,直接触发了云服务商的 DDoS 保护,IP 被封了。如果有 rate_limit,这事就不会发生。

第二部分:参数定义(这工具需要什么输入)

@dataclass
class ToolParameter:
    name: str                    # 参数名
    type: ToolParameterType      # 类型:STRING, INTEGER, FLOAT, BOOLEAN, ARRAY, OBJECT
    description: str             # 说明(给 LLM 看)
    required: bool = True        # 必填吗
    default: Any = None          # 默认值
    enum: Optional[List[Any]] = None      # 枚举值
    min_value: Optional[Union[int, float]] = None  # 最小值
    max_value: Optional[Union[int, float]] = None  # 最大值
    pattern: Optional[str] = None  # 正则校验

重要:description 非常重要。我见过最多的问题就是:LLM 不知道怎么填参数,因为 description 写得太模糊。

写法效果
"The query"LLM 不知道填什么格式
"搜索关键词,包含具体的公司名、产品名或话题。例如:'OpenAI GPT-4 定价 2024'"LLM 知道该怎么填

LLM 是根据 description 来理解怎么填参数的。description 越清晰,LLM 填得越准。

这是我用来检验 description 质量的方法:

把 description 给一个不懂技术的人看,问他"你知道该填什么吗"。如果他说"不知道",那 LLM 大概率也不知道。

第三部分:执行逻辑(这工具具体干什么)

async def _execute_impl(self, session_context=None, **kwargs) -> ToolResult:
    expression = kwargs["expression"]
    try:
        result = safe_eval(expression)  # 安全计算,不是直接 eval()
        return ToolResult(success=True, output=result)
    except Exception as e:
        return ToolResult(success=False, error=str(e))

注意几个点:

  1. 异步:用 async,不阻塞其他请求
  2. 安全:不直接 eval(),用 AST 白名单
  3. 结构化返回ToolResultsuccess 字段,Agent 知道成功还是失败

ToolResult 的结构:

@dataclass
class ToolResult:
    success: bool              # 成功还是失败
    output: Any                # 返回结果
    error: Optional[str] = None            # 错误信息
    metadata: Optional[Dict[str, Any]] = None  # 额外元数据
    execution_time_ms: Optional[int] = None    # 执行时间
    tokens_used: Optional[int] = None          # 消耗的 token(如果工具内部调用了 LLM)

3.4 工具怎么被 LLM 理解?

LLM 不能直接读 Python 代码。它需要一份"说明书"——JSON Schema。

Shannon 会自动把工具定义转成这样的 JSON:

{
  "name": "calculator",
  "description": "计算数学表达式,支持加减乘除、幂运算、三角函数等",
  "parameters": {
    "type": "object",
    "properties": {
      "expression": {
        "type": "string",
        "description": "要计算的数学表达式,如 '2 + 2''sqrt(16)''sin(3.14/2)'"
      }
    },
    "required": ["expression"]
  }
}

这个 JSON 会被塞进 LLM 的 prompt 里。LLM 看到这个"说明书",就知道有一个叫 calculator 的工具,需要一个 expression 参数。

所以工具定义的质量直接影响 LLM 的调用质量。

转换逻辑在 base.pyget_schema() 方法里:

def get_schema(self) -> Dict[str, Any]:
    properties = {}
    required = []

    for param in self.parameters:
        prop = {
            "type": param.type.value,
            "description": param.description,
        }
        if param.enum:
            prop["enum"] = param.enum
        if param.min_value is not None:
            prop["minimum"] = param.min_value
        # ... 其他字段

        properties[param.name] = prop
        if param.required:
            required.append(param.name)

    return {
        "name": self.metadata.name,
        "description": self.metadata.description,
        "parameters": {
            "type": "object",
            "properties": properties,
            "required": required,
        },
    }

3.5 LLM 填参数不准怎么办?

这是真实会遇到的问题。

比如你定义了一个整数参数,LLM 可能输出 "10" 而不是 10——字符串而不是数字。

Shannon 做了类型强转(在 _coerce_parameters 方法里):

def _coerce_parameters(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
    out = dict(kwargs)
    spec = {p.name: p for p in self.parameters}

    for name, param in spec.items():
        if name not in out:
            continue
        val = out[name]

        # 整数:接受浮点数(如 3.0)和数字字符串
        if param.type == ToolParameterType.INTEGER:
            if isinstance(val, float) and float(val).is_integer():
                out[name] = int(val)
            elif isinstance(val, str) and val.strip().isdigit():
                out[name] = int(val.strip())

        # 布尔:接受常见字符串形式
        elif param.type == ToolParameterType.BOOLEAN:
            if isinstance(val, str):
                s = val.strip().lower()
                if s in ("true", "1", "yes", "y"):
                    out[name] = True
                elif s in ("false", "0", "no", "n"):
                    out[name] = False

    return out

这叫容错。你不能假设 LLM 总是输出完美的数据格式。

还有一个细节:如果参数有 min_valuemax_value,强转时会自动 clamp:

# 如果用户传了 999,但 max_value  100,自动截断到 100
if param.max_value is not None and out[name] > param.max_value:
    out[name] = param.max_value

这样可以避免后续的校验失败。


3.6 工具调用的真实流程

把前面的知识串起来,完整流程是这样的:

工具调用完整流程

这就是一次完整的工具调用。

多轮工具调用

复杂任务可能需要多次工具调用:

多轮工具调用

这就是上一章讲的 ReAct 循环:思考 -> 行动 -> 观察 -> 再思考 -> 再行动...


3.7 管理多个工具

真实场景下,Agent 不止一个工具。可能有搜索、计算、读文件、发请求...

实现参考 (Shannon): tools/registry.py - ToolRegistry 类

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Type[Tool]] = {}      # 工具类
        self._instances: Dict[str, Tool] = {}         # 工具实例(单例)
        self._categories: Dict[str, List[str]] = {}   # 按类别索引

    def register(self, tool_class: Type[Tool], override: bool = False) -> None:
        """注册一个工具类"""
        temp_instance = tool_class()
        metadata = temp_instance.metadata

        if metadata.name in self._tools and not override:
            raise ValueError(f"Tool '{metadata.name}' is already registered")

        self._tools[metadata.name] = tool_class

        # 更新类别索引
        if metadata.category not in self._categories:
            self._categories[metadata.category] = []
        self._categories[metadata.category].append(metadata.name)

    def get_tool(self, name: str) -> Optional[Tool]:
        """获取工具实例(单例模式)"""
        if name not in self._tools:
            return None
        if name not in self._instances:
            self._instances[name] = self._tools[name]()
        return self._instances[name]

根据任务过滤工具

def filter_tools_for_agent(
    self,
    categories: Optional[List[str]] = None,
    exclude_dangerous: bool = True,
    max_cost: Optional[float] = None,
) -> List[str]:
    """根据条件过滤工具"""
    filtered = []

    for name in self._tools:
        tool = self.get_tool(name)
        metadata = tool.metadata

        # 类别过滤
        if categories and metadata.category not in categories:
            continue

        # 危险工具过滤
        if exclude_dangerous and metadata.dangerous:
            continue

        # 成本过滤
        if max_cost is not None and metadata.cost_per_use > max_cost:
            continue

        filtered.append(name)

    return filtered

为什么要过滤?

因为工具太多,LLM 会懵。

我测试过,给 LLM 20 个工具,它的选择准确率明显下降。给 5 个相关工具,准确率高得多。

这不是 LLM 的问题,是人也会犯的错误——选项太多会导致决策疲劳。

所以要根据任务类型,只暴露相关的工具:

# 研究任务:只需要搜索和读取
if task_type == "research":
    tools = registry.filter_tools_for_agent(categories=["search", "web"])

# 数据分析:只需要计算和数据库
elif task_type == "analysis":
    tools = registry.filter_tools_for_agent(categories=["calculation", "database"])

# 代码任务:只需要文件操作
elif task_type == "coding":
    tools = registry.filter_tools_for_agent(categories=["file", "shell"])

3.8 限流机制

Shannon 有两层限流:

第一层:工具级别限流

Tool.execute() 里,每次调用前检查:

async def execute(self, session_context=None, **kwargs) -> ToolResult:
    # 获取限流 key(基于 session  agent)
    tracker_key = self._get_tracker_key(session_id, agent_id)

    # 检查是否超过限制
    if self.metadata.rate_limit and self.metadata.rate_limit < 100:
        retry_after = self._get_retry_after(tracker_key)
        if retry_after is not None:
            return ToolResult(
                success=False,
                error=f"Rate limit exceeded. Retry after {retry_after:.1f}s",
                metadata={"retry_after_seconds": int(retry_after) + 1},
            )

    # 执行工具...

第二层:基于滑动窗口

def _get_retry_after(self, tracker_key: str) -> Optional[float]:
    if tracker_key not in self._execution_tracker:
        return None

    last_execution = self._execution_tracker[tracker_key]
    min_interval = timedelta(seconds=60.0 / self.metadata.rate_limit)
    elapsed = datetime.now() - last_execution

    if elapsed >= min_interval:
        return None  # 可以调用

    return (min_interval - elapsed).total_seconds()  # 还要等多久

注意一个细节:高吞吐工具(rate_limit >= 100)会跳过限流检查,因为每次检查也有开销。


3.9 常见的坑

坑 1:description 写得太烂

症状:LLM 不知道什么时候该用这个工具,或者填错参数。

案例

# 
ToolParameter(
    name="query",
    description="The query"  # LLM:???
)

# 
ToolParameter(
    name="query",
    description="搜索关键词。应包含具体的实体名(公司、人名、产品)和时间范围。"
                "示例:'OpenAI GPT-4 发布时间 2023''特斯拉 Q3 财报'"
)

解决:description 要写清楚使用场景、格式要求和示例。

坑 2:不处理失败

症状:工具会失败——网络超时、API 报错、参数非法。如果不处理,Agent 会崩。

案例

# 烂:异常直接抛出
async def _execute_impl(self, **kwargs) -> ToolResult:
    result = requests.get(url)  # 可能超时
    return ToolResult(success=True, output=result.json())

# 好:捕获并返回结构化错误
async def _execute_impl(self, **kwargs) -> ToolResult:
    try:
        result = await httpx.get(url, timeout=10)
        return ToolResult(success=True, output=result.json())
    except httpx.TimeoutException:
        return ToolResult(success=False, error="请求超时,请稍后重试")
    except httpx.HTTPStatusError as e:
        return ToolResult(success=False, error=f"HTTP {e.response.status_code}")

解决:永远返回 ToolResult,包含 successerror 字段。

坑 3:忘记安全

症状:工具执行的是用户输入,可能被注入恶意代码。

案例

# 危险:直接 eval 用户输入
async def _execute_impl(self, **kwargs) -> ToolResult:
    result = eval(kwargs["expression"])  # 用户输入 "__import__('os').system('rm -rf /')"
    return ToolResult(success=True, output=result)

# 安全:用 AST 解析 + 白名单
import ast
import operator

SAFE_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    # ...
}

def safe_eval(expr: str):
    tree = ast.parse(expr, mode='eval')
    # 遍历 AST,只允许白名单里的操作
    return _eval_node(tree.body)

解决:永远不要直接 eval() 用户输入。用 AST 解析 + 白名单。

坑 4:给太多工具

症状:LLM 选择困难,调错工具。

案例:给了 15 个工具,其中 3 个都能搜索(web_search、google_search、bing_search),LLM 不知道用哪个。

解决:按任务类型过滤,每次只给 3-5 个相关工具。功能重复的工具只保留一个。

坑 5:没有超时控制

症状:某个工具卡住,整个 Agent 就卡住了。

解决:在 metadata 里设置 timeout_seconds,在执行时强制超时:

async def execute_with_timeout(tool, **kwargs):
    try:
        return await asyncio.wait_for(
            tool.execute(**kwargs),
            timeout=tool.metadata.timeout_seconds
        )
    except asyncio.TimeoutError:
        return ToolResult(success=False, error="执行超时")

3.10 其他框架怎么做?

工具调用是通用模式,各家都有实现:

框架工具定义方式特点
OpenAIJSON Schema in API原生支持,最简单
AnthropicJSON Schema in API支持 input_examples 提升准确率
LangChain@tool 装饰器生态丰富,有大量预置工具
LangGraph继承 BaseTool与状态图集成
CrewAI继承 BaseTool面向多 Agent 场景

核心思想都一样:

  1. 用结构化格式(JSON Schema)描述工具
  2. LLM 输出调用请求
  3. 程序解析并执行
  4. 结果返回给 LLM

差别在于:

  • 定义语法(装饰器 vs 类继承 vs JSON)
  • 生态集成(预置工具、监控、持久化)
  • 生产特性(限流、沙箱、审计)

本章要点回顾

  1. 工具让 LLM 能"动手",不只是"动嘴"——解决实时信息、精确计算、外部系统访问的问题
  2. Function Calling 是结构化输出,让 LLM 说"我需要调用什么"而不是瞎编答案
  3. 工具定义要清晰,尤其是 description,直接影响 LLM 的选择和填参准确率
  4. 生产环境必须考虑:超时、限流、安全(不要 eval)、成本追踪
  5. 工具数量要控制,太多会让 LLM 选择困难,按任务类型过滤到 3-5 个

Shannon Lab(10 分钟上手)

本节帮你在 10 分钟内把本章概念对应到 Shannon 源码。

必读(1 个文件)

  • tools/base.py:看 Tool 基类的三个抽象方法(_get_metadata_get_parameters_execute_impl),理解工具的核心结构

选读深挖(2 个,按兴趣挑)


练习

练习 1:分析工具设计

读 Shannon 的 web_search.py,回答:

  1. 它的 description 写了什么?为什么要这样写?
  2. 它有哪些参数?哪些是必填的?
  3. 它的 rate_limit 是多少?为什么设这个值?

练习 2:设计一个工具

设计一个"汇率查询"工具,写出:

  1. ToolMetadata(name, description, category, rate_limit, timeout_seconds)
  2. ToolParameter 列表(source_currency, target_currency, amount)
  3. 考虑:应该设置 dangerous=True 吗?为什么?

练习 3(进阶):改进 description

找一个你用过的 API(天气、股票、翻译等),为它写一个工具的 description。

要求:

  • 让不懂这个 API 的人看完也知道该怎么填参数
  • 包含 2-3 个具体的使用示例
  • 说明什么情况下应该用这个工具,什么情况下不应该

延伸阅读


下一章预告

工具解决了"Agent 能做什么"的问题。但如果每个项目都要重新写一遍 GitHub 工具、Slack 工具、数据库工具...是不是太累了?

有没有办法让不同系统的工具可以互通、复用?

这就是下一章的内容——MCP 协议

MCP 是 2024 年 Anthropic 开源的协议,现在已经是 Agent 工具集成的事实标准。Cursor、Windsurf、ChatGPT 都在用它。

下一章见。

引用本文 / Cite
Zhang, W. (2026). 第 3 章:工具调用基础. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/第03章-工具调用基础
@incollection{zhang2026aiagent_第03章_工具调用基础,
  author = {Zhang, Wayland},
  title = {第 3 章:工具调用基础},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/第03章-工具调用基础}
}