工具是 Agent 的手和眼
前三篇我们讲了 Agent 的思维框架——ReAct 如何边想边做,Plan-and-Solve 如何先规划再执行。但思维框架再好,Agent 如果只能和 LLM 自己对话,能做的事情极其有限。
工具(Tool)是 Agent 突破语言模型边界的关键。有了工具,Agent 可以:
- 查询实时数据(股价、天气、新闻)
- 操作文件系统
- 调用外部 API
- 执行代码计算
但工具设计得好不好,直接决定了 Agent 的可靠性。一个设计糟糕的工具,会让 Agent 在错误中打转,甚至产生安全漏洞。
这篇文章,我们从零拆解工具调用的全貌:设计、验证、安全、并行调用、错误处理——五个维度,配合实际运行结果。
好工具 vs 坏工具:同一任务,截然不同的 Agent 行为
先做一个对比实验,把这个结论建立在真实数据上。
同样是"查股价"的功能,我们写两个版本:
坏工具(三个典型缺陷):
@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}" # ← 只返回数字,没有单位、货币、上下文
好工具(三个对应改进):
@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 的价格"
坏工具的执行轨迹:
[工具调用] bad_stock_tool(x='AAPL')
[工具返回] 189.5
[工具调用] bad_stock_tool(x='XYZ999')
[工具返回] Error: KeyError('XYZ999')
Please fix your mistakes.
[最终答案]
AAPL 的价格是 189.5 美元,但是 XYZ999 这个股票不存在,无法查询其价格。
好工具的执行轨迹:
[工具调用] 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。
一个完整的工具文档应该包含:
@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:
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 的拦截效果:
# 正常请求
[工具调用] 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 的优势在于:
- 错误信息可读:
Input should be greater than 0比ValueError: invalid amount清晰得多 - 自动类型转换:LLM 传来字符串
"1000",Pydantic 会自动转成float - 逻辑集中:验证规则和业务逻辑分离,互不干扰
要素三:安全(Security)——不信任任何输入
这是最容易被忽视的一环。工具是 Agent 和外部系统之间的桥梁,如果不做好边界检查,Agent 可能被操纵去做危险的事。
三大安全威胁与防护
威胁一:路径遍历攻击
用户(或恶意提示词)要求 Agent 读取 ../../../etc/passwd:
@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()物理路径验证(防止符号链接绕过前两层)
实测路径遍历被正确拦截:
# 正常读取
[工具调用] read_file("report.txt")
[工具返回] Q1 销售报告:总营收 1200 万元,同比增长 15%。
# 路径遍历攻击
[工具调用] read_file("../../../etc/passwd")
[工具返回] 安全拒绝:文件名不允许包含路径字符('../../../etc/passwd')
威胁二:SQL/命令注入
攻击者尝试构造特殊输入破坏查询逻辑:
@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 注入被拦截:
# 正常查询
提问:查询用户 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,让它主动传递恶意参数。
威胁三:调用频率滥用
简单的令牌桶限流防止工具被过度调用:
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 个城市的天气 + 空气质量,理想情况是:
LLM 响应:
→ 并行调用 [get_weather("北京"), get_weather("上海"), get_weather("成都"), get_air_quality("北京"), get_air_quality("上海"), get_air_quality("成都")]
所有 6 个工具同时执行 → 1 轮完成
但实测中,GLM-4-Flash 不支持并行工具调用,即使被明确要求"同时查询":
[工具调用] 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 支持并行调用,但不是所有模型都行。使用国内开源或第三方模型时,需要实际测试,不能想当然。
从代码层面,检测是否真正发生了并行调用:
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 如何响应:
@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 行为的影响:
# 格式错误(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 可靠性的基础。几个核心观点:
- 工具崩溃 ≠ Agent 崩溃:框架会捕获异常,但捕获的异常信息质量决定了 Agent 的最终答案质量
- 文档即合约:LLM 通过 docstring 理解工具,文档写得好,LLM 才能用得好
- 永远不信任输入:在工具层做验证,不依赖 Agent 的"理性",提示词注入随时可能劫持 Agent
- 并行调用依赖模型能力:LangGraph 支持框架层面的并行,但模型不支持就是串行——测试后再下结论
- 错误要分类:
RETRY:vsERROR:让 Agent 做出正确的下一步决策
下一篇:意图识别与路由——当 Agent 面对不同类型的用户请求,如何识别意图并把任务分发给对应的专门工具或子 Agent。
参考资料
- LangChain Tool 文档
- Pydantic Field 验证器文档
- LangGraph 并行工具调用
- OWASP LLM Top 10
- 本系列完整代码:
agent-03-tool-calling/tool_calling_demo.py
欢迎来我的个人主页找到更多有用的知识和有趣的产品