专栏第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 是概率模型,不是确定性程序。它"大概率"按格式输出,但不是"一定"按格式输出。

工具调用的失败不只是格式问题,完整的失败链路如下:

解析失败

解析成功

校验失败

校验通过

执行超时/报错

执行成功

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 到降级,每一层都拦截一部分错误:

第一层 Prompt层
Few-shot示例 + 格式约束

第二层 解析层
宽松正则 + 多模式匹配

第三层 校验层
Pydantic Schema + 业务校验

第四层 执行层
重试 + 超时 + 降级

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 的工作流程

注册工具 Schema

LLM 决定需要调用工具
输出 JSON

应用层代码解析 JSON
提取工具名和参数

应用层代码执行工具

结果返回 LLM

LLM 生成最终回答

⚠️ 重要澄清: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 沙箱在隔离容器中执行代码
禁用危险函数禁用 evalexecos.system
超时控制代码执行不超过 30 秒
资源限制限制内存和 CPU 使用

5.2 外部 API 调用的防护

防护措施说明
超时 + 重试API 调用设 10 秒超时,最多重试 3 次
断路器连续失败 N 次后暂停调用,避免雪崩
降级方案主 API 失败 → 备用 API → 返回缓存结果
权限控制只允许调用白名单内的 API

5.3 工具权限控制

不是所有 Agent 都应该能调用所有工具:

普通对话 Agent:只能搜索和查询
运维 Agent:可以执行命令、重启服务
管理员 Agent:可以修改配置、删除数据

原则:最小权限——给 Agent 它完成任务所需的最少工具

六、总结

本文从工具调用为什么失败出发,梳理了:

  1. 失败链路:解析层 → 校验层 → 执行层,每一层都可能出错
  2. 工具三要素:name + description + args_schema,description 最容易被忽视但最关键
  3. 四层防御:Prompt 层预防 → 解析层宽容 → 校验层拦截 → 执行层兜底
  4. Function Calling:让 LLM 直接输出 JSON,从根源解决格式不稳定问题;包括 bind_tools 声明式绑定、并行调用、tool_call_id 结果匹配、StructuredTool 复杂参数定义
  5. 安全防护:沙箱隔离、超时控制、断路器、最小权限

参考资源

  • 《ReAct: Synergizing Reasoning and Acting in Language Models》(Yao et al., 2023)
  • OpenAI Function Calling Documentation
  • LangChain Tools Documentation
Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐