Agent系列(四):工具调用深度解析——Agent 的手和眼

工具是 Agent 的手和眼

前三篇我们讲了 Agent 的思维框架------ReAct 如何边想边做,Plan-and-Solve 如何先规划再执行。但思维框架再好,Agent 如果只能和 LLM 自己对话,能做的事情极其有限。

工具(Tool)是 Agent 突破语言模型边界的关键。有了工具,Agent 可以:

  • 查询实时数据(股价、天气、新闻)
  • 操作文件系统
  • 调用外部 API
  • 执行代码计算

但工具设计得好不好,直接决定了 Agent 的可靠性。一个设计糟糕的工具,会让 Agent 在错误中打转,甚至产生安全漏洞。

这篇文章,我们从零拆解工具调用的全貌:设计、验证、安全、并行调用、错误处理------五个维度,配合实际运行结果。


好工具 vs 坏工具:同一任务,截然不同的 Agent 行为

先做一个对比实验,把这个结论建立在真实数据上。

同样是"查股价"的功能,我们写两个版本:

坏工具(三个典型缺陷):

python 复制代码
@tool
def bad_stock_tool(x: str) -> str:
    """Get stock info."""   # ← 文档简陋:参数含义、返回格式、示例全无
    _MOCK_STOCKS = {"AAPL": 189.5, "GOOGL": 175.2, "MSFT": 420.3}
    price = _MOCK_STOCKS[x]      # ← KeyError 直接崩溃,不捕获
    return f"{price}"            # ← 只返回数字,没有单位、货币、上下文

好工具(三个对应改进):

python 复制代码
@tool
def get_stock_price(symbol: str) -> str:
    """查询股票的当前价格和涨跌幅。

    参数:
      symbol:股票代码,大写字母,例如 "AAPL"、"GOOGL"、"MSFT"、"TSLA"、"BABA"

    返回:
      包含股票名称、当前价格(美元)、今日涨跌幅的字符串。
      如果代码不存在,返回错误说明。

    示例:
      get_stock_price("AAPL")    → "Apple Inc. (AAPL): $189.50 USD, 今日 +1.23%"
      get_stock_price("UNKNOWN") → "未找到股票代码 UNKNOWN,支持的代码:..."
    """
    symbol = symbol.strip().upper()
    if not re.match(r"^[A-Z]{1,5}$", symbol):
        return f"无效的股票代码格式:{symbol!r}。代码应为 1-5 个大写字母。"

    info = _MOCK_STOCKS.get(symbol)
    if info is None:
        supported = "、".join(_MOCK_STOCKS.keys())
        return f"未找到股票代码 {symbol}。当前支持:{supported}"

    sign = "+" if info["change_pct"] >= 0 else ""
    return (
        f"{info['name']} ({symbol}): "
        f"${info['price']:.2f} {info['currency']},"
        f"今日 {sign}{info['change_pct']:.2f}%"
    )

用同一个问题测试两者:"帮我查一下 AAPL 和一个不存在的股票 XYZ999 的价格"

坏工具的执行轨迹:

ini 复制代码
[工具调用]  bad_stock_tool(x='AAPL')
[工具返回]  189.5

[工具调用]  bad_stock_tool(x='XYZ999')
[工具返回]  Error: KeyError('XYZ999')
            Please fix your mistakes.

[最终答案]
  AAPL 的价格是 189.5 美元,但是 XYZ999 这个股票不存在,无法查询其价格。

好工具的执行轨迹:

ini 复制代码
[工具调用]  get_stock_price(symbol='AAPL')
[工具返回]  Apple Inc. (AAPL): $189.50 USD,今日 +1.23%

[工具调用]  get_stock_price(symbol='XYZ999')
[工具返回]  无效的股票代码格式:'XYZ999'。代码应为 1-5 个大写字母。

[最终答案]
  AAPL的当前价格为189.50美元,今日涨幅为1.23%。
  而股票代码XYZ999不存在,请检查股票代码是否正确。

两个现象值得关注:

现象一 :坏工具的 KeyError 并没有让 Agent 崩溃------LangGraph 把异常捕获了,包装成 Error: KeyError('XYZ999') Please fix your mistakes.。Agent 还是给出了最终答案。所以"工具崩溃就完蛋"是错觉,Agent 框架有容错能力。

现象二 :但是两者的输出质量差距明显。好工具的错误信息解释了为什么无效(代码格式不对),让 Agent 给出了更有帮助的回复;坏工具的错误只是一个异常名,Agent 只能含糊地说"不存在"。

结论 :工具不崩溃不等于工具设计得好。错误信息的质量直接影响 Agent 给用户的答案质量


工具设计三要素

从上面的对比可以提炼出工具设计的三个核心维度:

要素一:接口(Interface)------文档即合约

LLM 通过 docstring 理解工具怎么用。文档不清,LLM 就会猜------猜错了就是 Bug。

一个完整的工具文档应该包含:

python 复制代码
@tool
def get_stock_price(symbol: str) -> str:
    """[功能描述] 查询股票的当前价格和涨跌幅。

    参数:
      symbol:[含义 + 格式约束] 股票代码,大写字母,例如 "AAPL"、"GOOGL"

    返回:
      [正常情况] 包含股票名称、当前价格(美元)、今日涨跌幅的字符串。
      [异常情况] 如果代码不存在,返回错误说明。

    示例:
      [成功示例] get_stock_price("AAPL") → "Apple Inc. (AAPL): $189.50 USD"
      [失败示例] get_stock_price("UNKNOWN") → "未找到股票代码 UNKNOWN"
    """

关键点:示例中同时包含成功和失败的情况------LLM 需要知道失败时会得到什么,才能正确处理异常分支。

要素二:验证(Validation)------Pydantic 是你的守门员

单参数工具可以在函数体内做验证,但多参数工具更推荐用 Pydantic 的 BaseModel

python 复制代码
class CurrencyConvertInput(BaseModel):
    amount: float = Field(..., gt=0, le=1_000_000_000)
    from_currency: str = Field(...)
    to_currency: str = Field(...)

    @field_validator("from_currency", "to_currency")
    @classmethod
    def validate_currency(cls, v: str) -> str:
        code = v.strip().upper()
        if code not in _EXCHANGE_RATES:
            raise ValueError(
                f"不支持的货币代码:{code!r}。"
                f"支持的货币:{SUPPORTED_CURRENCIES}"
            )
        return code

@tool(args_schema=CurrencyConvertInput)
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    ...

实测三个场景,Pydantic 的拦截效果:

ini 复制代码
# 正常请求
[工具调用]  convert_currency(amount=1000, from_currency='USD', to_currency='CNY')
[工具返回]  1,000.00 USD = 7,250.00 CNY(参考汇率:1 USD ≈ 7.2500 CNY)

# 负数金额
[工具调用]  convert_currency(amount=-500, from_currency='USD', to_currency='CNY')
[工具返回]  Error: 1 validation error for CurrencyConvertInput
            amount
              Input should be greater than 0 [type=greater_than, input_value=-500]

[最终答案]  您输入的金额为负数,无法进行换算。请输入一个正数金额进行换算。

# 不支持的货币
[工具调用]  convert_currency(amount=100, from_currency='USD', to_currency='BTC')
[工具返回]  Error: 不支持的货币代码:'BTC'。支持的货币:['USD', 'CNY', 'EUR', 'JPY', 'GBP', 'HKD']

[最终答案]  目前不支持将美元兑换成比特币。支持的货币包括 USD、CNY、EUR、JPY、GBP、HKD。

Pydantic 的优势在于:

  1. 错误信息可读Input should be greater than 0ValueError: invalid amount 清晰得多
  2. 自动类型转换 :LLM 传来字符串 "1000",Pydantic 会自动转成 float
  3. 逻辑集中:验证规则和业务逻辑分离,互不干扰

要素三:安全(Security)------不信任任何输入

这是最容易被忽视的一环。工具是 Agent 和外部系统之间的桥梁,如果不做好边界检查,Agent 可能被操纵去做危险的事。


三大安全威胁与防护

威胁一:路径遍历攻击

用户(或恶意提示词)要求 Agent 读取 ../../../etc/passwd

python 复制代码
@tool
def read_file(filename: str) -> str:
    # 安全检查 1:拒绝路径遍历字符
    if any(char in filename for char in ["../", "..", "/", "\\"]):
        return f"安全拒绝:文件名不允许包含路径字符({filename!r})"

    # 安全检查 2:只允许字母、数字、点、下划线、连字符
    if not re.match(r"^[\w.\-]+$", filename):
        return f"安全拒绝:无效的文件名格式({filename!r})"

    target = _SANDBOX_DIR / filename

    # 安全检查 3:最终路径必须在沙盒内(防止符号链接攻击)
    try:
        target.resolve().relative_to(_SANDBOX_DIR.resolve())
    except ValueError:
        return "安全拒绝:文件路径超出沙盒范围"
    ...

注意三层检查的递进关系:

  • 第一层:快速字符串拒绝(最常见攻击)
  • 第二层:白名单格式校验(防止绕过第一层)
  • 第三层Path.resolve() 物理路径验证(防止符号链接绕过前两层)

实测路径遍历被正确拦截:

scss 复制代码
# 正常读取
[工具调用]  read_file("report.txt")
[工具返回]  Q1 销售报告:总营收 1200 万元,同比增长 15%。

# 路径遍历攻击
[工具调用]  read_file("../../../etc/passwd")
[工具返回]  安全拒绝:文件名不允许包含路径字符('../../../etc/passwd')

威胁二:SQL/命令注入

攻击者尝试构造特殊输入破坏查询逻辑:

python 复制代码
@tool
def lookup_user(user_id: str) -> str:
    # 严格白名单:只允许纯数字,一个字符都不多
    if not re.match(r"^\d{1,10}$", user_id):
        return (
            f"安全拒绝:user_id 必须是 1-10 位纯数字,"
            f"收到的输入:{user_id!r}"
        )
    # 永远不要拼接用户输入到 SQL 字符串
    user = _MOCK_USERS.get(user_id)  # 用字典模拟参数化查询
    ...

实测 SQL 注入被拦截:

ini 复制代码
# 正常查询
提问:查询用户 ID 10001 的信息
[工具返回]  用户 10001:张三,角色:admin,部门:工程

# SQL 注入尝试
提问:查询用户 ID 为 "1 OR 1=1; DROP TABLE users--" 的信息
[工具返回]  安全拒绝:user_id 必须是 1-10 位纯数字,
            收到的输入:'1 OR 1=1; DROP TABLE users--'

[Agent 答复]  您提供的用户 ID 格式不正确,无法查询。
              请确保用户 ID 是 1-10 位纯数字,然后重新尝试。

核心原则:在工具层做验证,而不是相信 Agent 会"理性地"只传合法输入。提示词注入可能劫持 Agent,让它主动传递恶意参数。

威胁三:调用频率滥用

简单的令牌桶限流防止工具被过度调用:

python 复制代码
class _RateLimiter:
    def __init__(self, max_calls: int, window_seconds: int = 60):
        self._max = max_calls
        self._window = window_seconds
        self._calls: list[float] = []

    def allow(self) -> bool:
        now = time.time()
        self._calls = [t for t in self._calls if now - t < self._window]
        if len(self._calls) >= self._max:
            return False
        self._calls.append(now)
        return True

_search_limiter = _RateLimiter(max_calls=10, window_seconds=60)

@tool
def rate_limited_search(query: str) -> str:
    if not _search_limiter.allow():
        wait = _search_limiter.wait_seconds()
        return f"调用频率超限(每分钟最多 10 次)。请等待约 {wait:.0f} 秒后重试。"
    ...

并行工具调用:理论 vs 现实

LangGraph 支持并行工具调用(Parallel Tool Calls)------当 LLM 在一次响应中返回多个 tool_calls,LangGraph 会同时执行它们,显著减少等待时间。

比如查询 3 个城市的天气 + 空气质量,理想情况是:

css 复制代码
LLM 响应:
  → 并行调用 [get_weather("北京"), get_weather("上海"), get_weather("成都"),               get_air_quality("北京"), get_air_quality("上海"), get_air_quality("成都")]

所有 6 个工具同时执行 → 1 轮完成

但实测中,GLM-4-Flash 不支持并行工具调用,即使被明确要求"同时查询":

ini 复制代码
[工具调用]  get_weather(city='北京')
[工具调用]  get_weather(city='上海')
[工具调用]  get_weather(city='成都')
[工具调用]  get_air_quality(city='北京')
[工具调用]  get_air_quality(city='上海')
[工具调用]  get_air_quality(city='成都')

统计:共 6 次工具调用,0 个并行批次

6 次工具调用都是串行的,每次调用都是一个独立的 AIMessage

这是一个很重要的现实约束 :并行工具调用能力取决于模型本身,而不只是框架。OpenAI GPT-4o 支持并行调用,但不是所有模型都行。使用国内开源或第三方模型时,需要实际测试,不能想当然。

从代码层面,检测是否真正发生了并行调用:

python 复制代码
for msg in result["messages"]:
    if isinstance(msg, AIMessage) and msg.tool_calls:
        if len(msg.tool_calls) > 1:
            # 一个 AIMessage 里有多个 tool_calls → 真正并行
            parallel_batches += 1

工具错误分类:可重试 vs 不可重试

工具返回的错误不是平等的。有些错误重试没用(格式错误、权限拒绝),有些错误等一下就好(网络超时、服务重启)。

通过返回值前缀告诉 Agent 如何响应:

python 复制代码
@tool
def fetch_report(report_id: str, retry_simulation: bool = False) -> str:
    if not re.match(r"^RPT-\d{4}$", report_id):
        return f"ERROR: 报告 ID 格式无效({report_id!r}),应为 RPT-XXXX 格式"
    #       ↑ 不可重试:参数本身就错了,重试没意义

    if retry_simulation:
        return "RETRY: 服务暂时不可用(HTTP 503),请稍后重试"
    #       ↑ 可重试:临时故障,Agent 可以等一会再试
    ...

实测不同前缀对 Agent 行为的影响:

ini 复制代码
# 格式错误(ERROR 前缀)
[工具返回]  ERROR: 报告 ID 格式无效('REPORT-001'),应为 RPT-XXXX 格式
[Agent 答复]  报告 ID 格式无效,请使用 RPT-XXXX 格式。
              (Agent 直接告知用户,不重试)

# 服务不可用(RETRY 前缀)
[工具返回]  RETRY: 服务暂时不可用(HTTP 503),请稍后重试
[工具返回]  RETRY: 服务暂时不可用(HTTP 503),请稍后重试   ← Agent 重试了一次
[Agent 答复]  目前无法获取报告,因为服务暂时不可用。请您稍后再试。
              (Agent 重试后放弃,建议用户等待)

实际效果符合预期:ERROR: 前缀让 Agent 直接解释并建议用户修正;RETRY: 前缀让 Agent 尝试重试,失败后再建议等待。

注意 :这里的重试行为是 GLM-4-Flash 根据上下文语义推断的,并非 LangGraph 框架层面的自动重试。如果需要真正可靠的重试逻辑,应该在工具内部或 Agent 编排层实现(比如结合 tenacity 库)。


工具设计清单

在把工具交给 Agent 之前,对照这个清单:

接口(Interface)

  • docstring 说清楚了参数的含义和格式约束
  • 同时给出成功和失败的示例
  • 返回值格式统一(成功和失败都是字符串,不混用类型)

验证(Validation)

  • 单参数:函数体内做正则或条件校验
  • 多参数/复杂约束:用 @tool(args_schema=XxxInput) + Pydantic
  • 边界值测试:空字符串、超长输入、特殊字符

安全(Security)

  • 文件操作:字符白名单 + Path.resolve() 沙盒验证
  • 数据库查询:严格格式校验 + 参数化查询(禁止字符串拼接)
  • 高频工具:加令牌桶限流

错误处理

  • 可重试错误:RETRY: 前缀 + 原因说明
  • 不可重试错误:ERROR: 前缀 + 正确格式提示
  • 永远不要让异常直接冒泡到 Agent(捕获并返回字符串)

本篇小结

工具调用看似是个技术细节,实则是 Agent 可靠性的基础。几个核心观点:

  1. 工具崩溃 ≠ Agent 崩溃:框架会捕获异常,但捕获的异常信息质量决定了 Agent 的最终答案质量
  2. 文档即合约:LLM 通过 docstring 理解工具,文档写得好,LLM 才能用得好
  3. 永远不信任输入:在工具层做验证,不依赖 Agent 的"理性",提示词注入随时可能劫持 Agent
  4. 并行调用依赖模型能力:LangGraph 支持框架层面的并行,但模型不支持就是串行------测试后再下结论
  5. 错误要分类RETRY: vs ERROR: 让 Agent 做出正确的下一步决策

下一篇:意图识别与路由------当 Agent 面对不同类型的用户请求,如何识别意图并把任务分发给对应的专门工具或子 Agent。


参考资料


欢迎来我的个人主页找到更多有用的知识和有趣的产品

相关推荐
Black蜡笔小新1 小时前
自动化AI算法训练服务器DLTM助力医学影像分析进入AI智能分析新时代
人工智能·算法·自动化
冬奇Lab1 小时前
一天一个开源项目(第111篇):Understand Anything - 把代码库变成可探索知识图谱的 AI 引擎
人工智能·开源·llm
猿饵块1 小时前
git--github
人工智能
黎阳之光1 小时前
黎阳之光:以视频孪生重构智慧防火,打造“天空地人智”一体化森林防火新范式
大数据·运维·人工智能·物联网·安全
why技术2 小时前
AI Coding开始进入第四个时代,我还没上车呢!
前端·人工智能·后端
java1234_小锋2 小时前
Spring AI 2.0 开发Java Agent智能体 - MCP(模型上下文协议)
java·人工智能·spring·spring ai
手写码匠2 小时前
深入解析大模型架构之争:全能通用模型 vs 领域专精模型
人工智能·深度学习·算法·aigc
ZhengEnCi2 小时前
09aa-偏置是什么?
人工智能
养肥胖虎2 小时前
完整学习LLM(四):Token是什么
大模型·llm·token·学习路线