Agent工具调用总失败?我用四层防御把成功率拉到99%
专栏第5篇:第四篇我们搞懂了Agent的记忆系统,但光"记得住"还不够——Agent还得"做得到"。工具调用是Agent与现实世界交互的唯一通道,但LLM的输出天生不稳定:格式错乱、参数缺失、调用超时……今天我们从工具调用为什么会失败出发,逐层构建防御机制,让工具调用从"经常失败"变成"几乎不会失败"。
目录
一、工具调用为什么总失败?
第三篇我们手写了 ReAct Agent,LLM 输出 Action: Search[今天天气],我们用正则解析。但实际生产中,LLM 的输出花样百出:
你期望的格式:Action: Search[今天天气]
LLM 实际输出的格式:
1. Action: search[今天天气] ← 大小写不一致
2. Action: Search (今天天气) ← 用了圆括号
3. Action: Search 今天天气 ← 没有括号
4. Action: Search["今天天气"] ← 多了引号
5. 我应该先搜索一下:Action: Search[今天天气] ← 前面多了一堆话
6. Action: None ← 没有工具
7. {"name": "search", "params": ...} ← 输出了JSON而非Action格式
这就是工具调用失败的根本原因:LLM 是概率模型,不是确定性程序。它"大概率"按格式输出,但不是"一定"按格式输出。
工具调用的失败不只是格式问题,完整的失败链路如下:
二、工具的定义:不只是写个函数
在讲防御机制之前,先搞清楚"工具"到底由什么组成。很多初学者以为工具就是写个函数,但其实工具定义有三个关键要素:
2.1 工具的三要素
| 要素 | 作用 | 示例 |
|---|---|---|
| name | 让 LLM 知道叫什么 | search_weather |
| description | 告诉 LLM 什么时候用 | “查询指定城市的天气信息” |
| args_schema | 告诉 LLM 传什么参数 | {"city": "城市名,如北京", "date": "日期,格式YYYY-MM-DD"} |
💡 description 是最容易被忽视但最关键的要素。LLM 选择工具时,唯一依据就是 description——描述写不好,LLM 就会选错工具。
2.2 好的描述 vs 差的描述
❌ 差的描述:
"搜索工具" → LLM不知道搜什么,什么都往这塞
✅ 好的描述:
"搜索互联网获取实时信息,如新闻、天气、股价等。
输入:搜索关键词。
适用:当用户问题涉及实时数据或训练截止日期之后的信息时使用。"
2.3 用 Pydantic 做参数约束
不仅要有参数定义,还要有类型约束和校验规则:
from pydantic import BaseModel, Field
class WeatherInput(BaseModel):
city: str = Field(description="城市名称,如'北京'、'上海'")
date: str = Field(description="日期,格式:YYYY-MM-DD")
@tool(args_schema=WeatherInput)
def get_weather(city: str, date: str) -> str:
"""获取指定城市和日期的天气信息"""
...
有了 Schema,LLM 就知道参数的格式要求,校验层也有了判断依据。
三、四层防御机制
理解了失败链路,我们逐层构建防御——从 Prompt 到降级,每一层都拦截一部分错误:
3.1 第一层:Prompt 层——预防错误
思路:在 Prompt 里就告诉 LLM “格式不对会执行失败”,并用 Few-shot 示例引导正确格式。
可用工具:
1. Search[关键词] - 搜索实时信息
2. Calculator[数学表达式] - 数学计算
⚠️ 必须严格按照以下格式,否则工具无法执行:
Action: 工具名[参数]
正确示例:Action: Search[北京天气]
错误示例:Action: Search 北京天气(缺少括号)
错误示例:action: search(北京天气)(大小写和括号都不对)
效果:Prompt 层能解决 60-70% 的格式问题,是最划算的防御手段。
3.2 第二层:解析层——宽容解析
即使 Prompt 写得再好,LLM 仍然可能输出"不那么标准"的格式。解析层的策略是:只要能提取出工具名和参数,就尽量解析。
def parse_action_robust(text: str):
"""宽松解析:多种正则模式依次尝试"""
patterns = [
r"Action\s*:\s*(\w+)\s*[\[\(\"'](.*?)[\]\)\"']", # 标准格式
r"(\w+)\s*\[(.*?)\]", # 宽松格式
r"调用\s*(\w+)\s*[::]\s*(.*)", # 中文格式
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
tool_name = match.group(1).lower() # 大小写不敏感
tool_input = match.group(2).strip()
return tool_name, tool_input
return None, None
原则:宁可多解析出来,也不要漏掉。即使格式不标准,只要能提取出有用信息就算成功。
3.3 第三层:校验层——拦截无效参数
解析出来了,不代表参数是对的。校验层确保参数类型、取值范围、业务逻辑都正确:
from pydantic import BaseModel, Field, ValidationError
class SearchInput(BaseModel):
query: str = Field(min_length=1, description="搜索关键词")
def validate_and_execute(tool_name: str, tool_input: str):
try:
# Pydantic 校验参数类型和约束
args = SearchInput(query=tool_input)
except ValidationError as e:
# 校验失败 → 把错误信息返回给 LLM,让它自纠错
return f"参数校验失败:{e},请检查参数格式后重试"
# 业务校验
if tool_name not in available_tools:
return f"工具「{tool_name}」不存在,可选工具:{list(available_tools.keys())}"
return execute_tool(tool_name, args)
关键设计:校验失败时不是直接报错,而是把错误信息返回给 LLM,让它自己修正。这是"自纠错"的核心思路——LLM 看到具体错误信息后,大概率能修正参数。
3.4 第四层:执行层——重试、超时、降级
工具执行也可能失败(网络超时、API 限流、服务宕机),需要三道保险:
保险一:自动重试 + 指数退避
def execute_with_retry(tool, args, max_retries=3):
for attempt in range(max_retries):
try:
return tool.run(args)
except Exception as e:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 1s, 2s, 4s
else:
return f"工具执行失败(已重试{max_retries}次):{str(e)}"
保险二:超时控制
import signal
def execute_with_timeout(tool, args, timeout=30):
"""超过 timeout 秒自动终止"""
try:
return signal.timeout(timeout, lambda: tool.run(args))
except TimeoutError:
return f"工具执行超时({timeout}秒),请尝试简化参数或换个工具"
保险三:降级策略
工具调用彻底失败了怎么办?
Level 1:换个类似工具(搜索A失败 → 试搜索B)
Level 2:直接告诉用户"我暂时无法获取这个信息"
Level 3:用 LLM 的内置知识给一个"参考答案",并标注"未经实时验证"
四、Function Calling:让LLM输出结构化
前三层防御都在"修补"LLM 的非结构化输出。有没有办法让 LLM 直接输出结构化数据?
有,这就是 Function Calling——OpenAI 提出的标准协议,让 LLM 直接输出 JSON 格式的工具调用。
4.1 ReAct 模式 vs Function Calling 模式
| 对比 | ReAct(文本解析) | Function Calling(结构化输出) |
|---|---|---|
| LLM输出 | Action: Search[北京天气] | {"name": "search", "arguments": {"query": "北京天气"}} |
| 解析方式 | 正则匹配 | JSON 反序列化 |
| 可靠性 | 低(格式不稳定) | 高(模型原生支持) |
| 适用模型 | 所有模型 | 支持 Function Calling 的模型 |
💡 第一篇讲 Agent 自主性分级时,L3(主动调用)的关键技术就是 Function Calling——LLM 自己决定需要调哪个工具、传什么参数,以 JSON 格式输出,不需要我们写正则解析。
4.2 Function Calling 的工作流程
⚠️ 重要澄清:LLM 本身不能执行任何外部操作——它没有网络访问能力,不能调用 API,不能运行代码。LLM 做的只有一件事:根据工具描述,输出一段 JSON 文本,表示"如果要解决这个问题,我需要调用这个工具、传这些参数"。真正的工具执行(发 HTTP 请求、查数据库、运行代码等)是由应用层代码完成的。
换句话说:LLM 是"出谋划策"的军师,应用层代码是"动手执行"的士兵。军师说"去查一下北京天气",士兵(代码)实际去调用天气 API。
4.3 代码示例
# 定义工具 Schema
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"},
},
"required": ["city"]
}
}
}]
# LLM 自动决定是否需要调用工具
response = llm.chat("北京今天天气怎么样?", tools=tools)
# 如果 LLM 决定需要调用工具,response 中会包含结构化的工具调用信息
if response.tool_calls:
for call in response.tool_calls:
result = execute_tool(call.name, call.arguments)
# 把结果返回给 LLM,让它生成最终回答
关键优势:不需要自己写正则,LLM 原生输出 JSON,解析可靠性大幅提升。
4.4 LangChain 的 bind_tools:声明式工具绑定
上面是原生 API 的写法,需要手动构造 Schema。LangChain 提供了更简洁的方式:
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
# 用装饰器定义工具,自动提取参数类型和文档
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气信息"""
return f"{city}:晴天,25°C"
# 绑定到 LLM
llm = ChatOpenAI(model="gpt-4")
llm_with_tools = llm.bind_tools([get_weather])
# LLM 自动决定是否需要调用工具
response = llm_with_tools.invoke("北京今天天气怎么样?")
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_xxx'}]
bind_tools() 会自动把工具的名称、描述、参数格式注入到 LLM 的 System Prompt 中。@tool 装饰器则从函数签名和文档字符串中提取 Schema,省去了手写 JSON Schema 的麻烦。
4.5 并行工具调用:一次决策,多个执行
Function Calling 的一个重要特性是并行调用——LLM 可以在一次响应中返回多个 tool_calls,代码并行执行:
# 用户问题需要多个工具
response = llm_with_tools.invoke("北京和上海分别有多少人口?天气怎么样?")
# LLM 可能返回 4 个并行调用:
# 1. get_population(city="北京")
# 2. get_population(city="上海")
# 3. get_weather(city="北京")
# 4. get_weather(city="上海")
# 并行执行所有工具调用
for tc in response.tool_calls:
result = tool_map[tc["name"]].invoke(tc["args"])
# ...
优势:
- 一次 LLM 调用决定多个工具需求
- 工具并行执行,节省时间
- ReAct 文本解析方式无法实现这种并行(一次只能解析一个 Action)
4.6 tool_call_id:多工具结果的对应机制
并行调用多个工具时,返回的结果需要和原始的 tool_call 一一对应。Function Calling 通过 tool_call_id 实现这个映射:
from langchain_core.messages import ToolMessage
# LLM 返回多个 tool_calls,每个都有唯一 id
tool_calls = [
{"name": "get_weather", "args": {"city": "北京"}, "id": "call_001"},
{"name": "get_population", "args": {"city": "北京"}, "id": "call_002"}
]
# 执行后,返回 ToolMessage 时必须带上对应的 id
tool_messages = []
for tc in response.tool_calls:
result = tool_map[tc["name"]].invoke(tc["args"])
tool_messages.append(ToolMessage(
content=str(result),
tool_call_id=tc["id"] # 必须与 tool_call 的 id 匹配
))
# LLM 根据 id 匹配,知道哪个结果对应哪个调用
⚠️ 为什么需要 id? 因为多个工具可能返回相同格式的内容(比如都是字符串),LLM 无法仅凭内容判断这个结果来自哪个调用。id 是唯一的对应凭证。
4.7 StructuredTool:复杂参数的 Schema 定义
对于参数较多的工具,可以用 Pydantic 定义详细的 Schema,让 LLM 更准确地理解每个参数:
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from typing import Optional
class SearchInput(BaseModel):
"""知识库搜索的参数定义"""
query: str = Field(description="搜索关键词")
category: Optional[str] = Field(
default=None,
description="分类过滤,可选值:会计、管家"
)
k: int = Field(
default=3,
description="返回结果数量",
ge=1,
le=10
)
def search_knowledge(query: str, category: str = None, k: int = 3) -> dict:
"""从知识库中检索相关信息"""
return {"results": [f"结果{i+1}" for i in range(k)]}
# 创建结构化工具
search_tool = StructuredTool.from_function(
func=search_knowledge,
name="search_knowledge",
description="从知识库检索信息,支持分类过滤和结果数量控制",
args_schema=SearchInput
)
Schema 的好处:
- LLM 理解每个参数的含义、类型、默认值
- 自动验证参数范围(如
k必须在 1-10 之间) - 可选参数有默认值,LLM 知道哪些可以省略
五、超时与安全:工具的"安全带"
工具调用不只是"能不能成功"的问题,还有"安不安全"的问题。
5.1 代码执行工具的危险性
如果 Agent 有一个"执行 Python 代码"的工具,LLM 可能会生成:
# LLM 生成的代码(它并不理解后果)
import os
os.system("rm -rf /") # 删库!
必须做的防护:
| 防护措施 | 说明 |
|---|---|
| Docker 沙箱 | 在隔离容器中执行代码 |
| 禁用危险函数 | 禁用 eval、exec、os.system 等 |
| 超时控制 | 代码执行不超过 30 秒 |
| 资源限制 | 限制内存和 CPU 使用 |
5.2 外部 API 调用的防护
| 防护措施 | 说明 |
|---|---|
| 超时 + 重试 | API 调用设 10 秒超时,最多重试 3 次 |
| 断路器 | 连续失败 N 次后暂停调用,避免雪崩 |
| 降级方案 | 主 API 失败 → 备用 API → 返回缓存结果 |
| 权限控制 | 只允许调用白名单内的 API |
5.3 工具权限控制
不是所有 Agent 都应该能调用所有工具:
普通对话 Agent:只能搜索和查询
运维 Agent:可以执行命令、重启服务
管理员 Agent:可以修改配置、删除数据
原则:最小权限——给 Agent 它完成任务所需的最少工具
六、总结
本文从工具调用为什么失败出发,梳理了:
- 失败链路:解析层 → 校验层 → 执行层,每一层都可能出错
- 工具三要素:name + description + args_schema,description 最容易被忽视但最关键
- 四层防御:Prompt 层预防 → 解析层宽容 → 校验层拦截 → 执行层兜底
- Function Calling:让 LLM 直接输出 JSON,从根源解决格式不稳定问题;包括 bind_tools 声明式绑定、并行调用、tool_call_id 结果匹配、StructuredTool 复杂参数定义
- 安全防护:沙箱隔离、超时控制、断路器、最小权限
参考资源:
- 《ReAct: Synergizing Reasoning and Acting in Language Models》(Yao et al., 2023)
- OpenAI Function Calling Documentation
- LangChain Tools Documentation
更多推荐



所有评论(0)