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

工具(Tool)是 Agent 突破语言模型边界的关键。有了工具,Agent 可以:
但工具设计得好不好,直接决定了 Agent 的可靠性。一个设计糟糕的工具,会让 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 给用户的答案质量。
从上面的对比可以提炼出工具设计的三个核心维度:
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 需要知道失败时会得到什么,才能正确处理异常分支。
单参数工具可以在函数体内做验证,但多参数工具更推荐用 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 清晰得多"1000",Pydantic 会自动转成 float这是最容易被忽视的一环。工具是 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')
攻击者尝试构造特殊输入破坏查询逻辑:
@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} 秒后重试。"
...
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
工具返回的错误不是平等的。有些错误重试没用(格式错误、权限拒绝),有些错误等一下就好(网络超时、服务重启)。
通过返回值前缀告诉 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 尝试重试,失败后再建议等待。
在把工具交给 Agent 之前,对照这个清单:
接口(Interface)
验证(Validation)
@tool(args_schema=XxxInput) + Pydantic安全(Security)
Path.resolve() 沙盒验证错误处理
RETRY: 前缀 + 原因说明ERROR: 前缀 + 正确格式提示工具调用看似是个技术细节,实则是 Agent 可靠性的基础。几个核心观点:
RETRY: vs ERROR: 让 Agent 做出正确的下一步决策下一篇:意图识别与路由——当 Agent 面对不同类型的用户请求,如何识别意图并把任务分发给对应的专门工具或子 Agent。
agent-03-tool-calling/tool_calling_demo.py欢迎来我的个人主页找到更多有用的知识和有趣的产品