第 3 章:工具调用基础
工具让 LLM 从"会说"变成"能做"——但工具不是万能药:description 写烂了,LLM 照样选错工具、填错参数。
3.1 LLM 的根本问题
先看一个真实案例:
2023 年底,我在一家金融科技公司部署了一个客服 Agent。上线第一周,它回答得很流畅,用户满意度不错。
到了第二周,问题来了。一个用户问:"最新的贷款利率是多少?"
Agent 回答:"根据我的训练数据,当前贷款基准利率为 4.35%。"
客户打电话投诉:这是 2020 年的利率!现在已经 3.65% 了!
这就是 LLM 的根本问题——它只能基于训练数据"编",不能查实时信息。 模型知识截止到训练时间,它不知道今天的利率、今天的天气、今天的新闻。
一周后,我们给 Agent 接入了利率查询 API。同样的问题,它会先调用工具查询,再返回准确答案。同一个模型,加了工具就像换了脑子。
没有工具的 LLM 能做什么?
LLM 有一个致命缺陷:它只能编,不能查。
你问它:"今天北京天气怎么样?"
它会回答:"根据我的训练数据,北京的气候..."
这是编的。 它的知识截止到训练时间,它不知道今天的天气。
| 问题类型 | LLM 的表现 | 为什么 |
|---|---|---|
| 实时信息 | 编造或拒绝 | 训练数据是历史的 |
| 精确计算 | 经常算错 | 它是语言模型,不是计算器 |
| 外部系统 | 无能为力 | 它不能发请求、读文件 |
| 私有数据 | 完全不知道 | 训练时没见过你的数据 |
要解决这些问题,LLM 必须能"动手"——去调 API 查天气、去搜网页找信息、去读文件看数据。
这些"动手"的能力,就是工具。
3.2 Function Calling 是什么?
很多人被这个术语搞晕了,其实很简单:
Function Calling = 让 LLM 不直接回答问题,而是说"我需要调用某个工具"
传统 LLM:
用户:5 的阶乘是多少?
LLM:5 的阶乘是 120。
这看起来对,但如果问的是大数,LLM 可能算错。试试问它 13 的阶乘,它有一定概率给你一个错误答案。
Function Calling 的 LLM:
用户:5 的阶乘是多少?
LLM:{
"tool": "calculator",
"parameters": { "expression": "5!" }
}
它不直接算,而是输出一个 JSON,说"我要调用 calculator 工具"。
然后程序解析这个 JSON,真正调用计算器,把结果喂回给 LLM,LLM 再回答用户。
这就是 Function Calling 的本质:让 LLM 学会"请求帮助"而不是"硬着头皮编"。
为什么要输出 JSON?
因为程序需要解析。如果 LLM 输出的是自然语言:
我需要调用计算器,计算 5 的阶乘
程序怎么知道"计算器"对应哪个函数?"5 的阶乘"怎么转成参数?
JSON 是结构化的,程序可以直接解析:
{"tool": "calculator", "parameters": {"expression": "5!"}}
tool 是工具名,parameters 是参数。一目了然。
3.3 一个工具长什么样?
用 Shannon 的实现来举例。一个工具有三部分:
第一部分:元数据(这工具是干什么的)
实现参考 (Shannon): tools/base.py - ToolMetadata 类
@dataclass
class ToolMetadata:
name: str # 工具名
version: str # 版本号
description: str # 干什么的(给 LLM 看)
category: str # 分类:search, calculation, file...
# 生产必备字段
requires_auth: bool = False # 需要认证吗
rate_limit: Optional[int] = None # 每分钟最多调几次
timeout_seconds: int = 30 # 最多跑多久
memory_limit_mb: int = 512 # 内存限制
sandboxed: bool = True # 要不要沙箱隔离
dangerous: bool = False # 是不是危险操作
cost_per_use: float = 0.0 # 每次调用花多少钱
为什么需要这些字段?
| 字段 | 防什么 | 真实案例 |
|---|---|---|
timeout_seconds | 工具卡死拖垮整个 Agent | 某个网页加载 30 秒没响应 |
rate_limit | Agent 疯狂调用烧光 API 额度 | 一分钟调了 200 次搜索 API |
dangerous | 危险操作需要用户确认 | 删除文件、发送邮件 |
cost_per_use | 追踪成本做预算控制 | 调了 100 次 GPT-4,账单爆了 |
sandboxed | 防止恶意代码逃逸 | 用户输入包含 shell 命令 |
这些不是可选的"高级功能",这是生产环境的必备项。
我见过一个案例:Agent 在调试一个网络问题,它不停地 ping 同一个地址,一分钟内调用了几百次网络工具,直接触发了云服务商的 DDoS 保护,IP 被封了。如果有 rate_limit,这事就不会发生。
第二部分:参数定义(这工具需要什么输入)
@dataclass
class ToolParameter:
name: str # 参数名
type: ToolParameterType # 类型:STRING, INTEGER, FLOAT, BOOLEAN, ARRAY, OBJECT
description: str # 说明(给 LLM 看)
required: bool = True # 必填吗
default: Any = None # 默认值
enum: Optional[List[Any]] = None # 枚举值
min_value: Optional[Union[int, float]] = None # 最小值
max_value: Optional[Union[int, float]] = None # 最大值
pattern: Optional[str] = None # 正则校验
重要:description 非常重要。我见过最多的问题就是:LLM 不知道怎么填参数,因为 description 写得太模糊。
| 写法 | 效果 |
|---|---|
"The query" | LLM 不知道填什么格式 |
"搜索关键词,包含具体的公司名、产品名或话题。例如:'OpenAI GPT-4 定价 2024'" | LLM 知道该怎么填 |
LLM 是根据 description 来理解怎么填参数的。description 越清晰,LLM 填得越准。
这是我用来检验 description 质量的方法:
把 description 给一个不懂技术的人看,问他"你知道该填什么吗"。如果他说"不知道",那 LLM 大概率也不知道。
第三部分:执行逻辑(这工具具体干什么)
async def _execute_impl(self, session_context=None, **kwargs) -> ToolResult:
expression = kwargs["expression"]
try:
result = safe_eval(expression) # 安全计算,不是直接 eval()
return ToolResult(success=True, output=result)
except Exception as e:
return ToolResult(success=False, error=str(e))
注意几个点:
- 异步:用
async,不阻塞其他请求 - 安全:不直接
eval(),用 AST 白名单 - 结构化返回:
ToolResult有success字段,Agent 知道成功还是失败
ToolResult 的结构:
@dataclass
class ToolResult:
success: bool # 成功还是失败
output: Any # 返回结果
error: Optional[str] = None # 错误信息
metadata: Optional[Dict[str, Any]] = None # 额外元数据
execution_time_ms: Optional[int] = None # 执行时间
tokens_used: Optional[int] = None # 消耗的 token(如果工具内部调用了 LLM)
3.4 工具怎么被 LLM 理解?
LLM 不能直接读 Python 代码。它需要一份"说明书"——JSON Schema。
Shannon 会自动把工具定义转成这样的 JSON:
{
"name": "calculator",
"description": "计算数学表达式,支持加减乘除、幂运算、三角函数等",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "要计算的数学表达式,如 '2 + 2'、'sqrt(16)'、'sin(3.14/2)'"
}
},
"required": ["expression"]
}
}
这个 JSON 会被塞进 LLM 的 prompt 里。LLM 看到这个"说明书",就知道有一个叫 calculator 的工具,需要一个 expression 参数。
所以工具定义的质量直接影响 LLM 的调用质量。
转换逻辑在 base.py 的 get_schema() 方法里:
def get_schema(self) -> Dict[str, Any]:
properties = {}
required = []
for param in self.parameters:
prop = {
"type": param.type.value,
"description": param.description,
}
if param.enum:
prop["enum"] = param.enum
if param.min_value is not None:
prop["minimum"] = param.min_value
# ... 其他字段
properties[param.name] = prop
if param.required:
required.append(param.name)
return {
"name": self.metadata.name,
"description": self.metadata.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
}
3.5 LLM 填参数不准怎么办?
这是真实会遇到的问题。
比如你定义了一个整数参数,LLM 可能输出 "10" 而不是 10——字符串而不是数字。
Shannon 做了类型强转(在 _coerce_parameters 方法里):
def _coerce_parameters(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
out = dict(kwargs)
spec = {p.name: p for p in self.parameters}
for name, param in spec.items():
if name not in out:
continue
val = out[name]
# 整数:接受浮点数(如 3.0)和数字字符串
if param.type == ToolParameterType.INTEGER:
if isinstance(val, float) and float(val).is_integer():
out[name] = int(val)
elif isinstance(val, str) and val.strip().isdigit():
out[name] = int(val.strip())
# 布尔:接受常见字符串形式
elif param.type == ToolParameterType.BOOLEAN:
if isinstance(val, str):
s = val.strip().lower()
if s in ("true", "1", "yes", "y"):
out[name] = True
elif s in ("false", "0", "no", "n"):
out[name] = False
return out
这叫容错。你不能假设 LLM 总是输出完美的数据格式。
还有一个细节:如果参数有 min_value 和 max_value,强转时会自动 clamp:
# 如果用户传了 999,但 max_value 是 100,自动截断到 100
if param.max_value is not None and out[name] > param.max_value:
out[name] = param.max_value
这样可以避免后续的校验失败。
3.6 工具调用的真实流程
把前面的知识串起来,完整流程是这样的:
这就是一次完整的工具调用。
多轮工具调用
复杂任务可能需要多次工具调用:
这就是上一章讲的 ReAct 循环:思考 -> 行动 -> 观察 -> 再思考 -> 再行动...
3.7 管理多个工具
真实场景下,Agent 不止一个工具。可能有搜索、计算、读文件、发请求...
实现参考 (Shannon): tools/registry.py - ToolRegistry 类
class ToolRegistry:
def __init__(self):
self._tools: Dict[str, Type[Tool]] = {} # 工具类
self._instances: Dict[str, Tool] = {} # 工具实例(单例)
self._categories: Dict[str, List[str]] = {} # 按类别索引
def register(self, tool_class: Type[Tool], override: bool = False) -> None:
"""注册一个工具类"""
temp_instance = tool_class()
metadata = temp_instance.metadata
if metadata.name in self._tools and not override:
raise ValueError(f"Tool '{metadata.name}' is already registered")
self._tools[metadata.name] = tool_class
# 更新类别索引
if metadata.category not in self._categories:
self._categories[metadata.category] = []
self._categories[metadata.category].append(metadata.name)
def get_tool(self, name: str) -> Optional[Tool]:
"""获取工具实例(单例模式)"""
if name not in self._tools:
return None
if name not in self._instances:
self._instances[name] = self._tools[name]()
return self._instances[name]
根据任务过滤工具
def filter_tools_for_agent(
self,
categories: Optional[List[str]] = None,
exclude_dangerous: bool = True,
max_cost: Optional[float] = None,
) -> List[str]:
"""根据条件过滤工具"""
filtered = []
for name in self._tools:
tool = self.get_tool(name)
metadata = tool.metadata
# 类别过滤
if categories and metadata.category not in categories:
continue
# 危险工具过滤
if exclude_dangerous and metadata.dangerous:
continue
# 成本过滤
if max_cost is not None and metadata.cost_per_use > max_cost:
continue
filtered.append(name)
return filtered
为什么要过滤?
因为工具太多,LLM 会懵。
我测试过,给 LLM 20 个工具,它的选择准确率明显下降。给 5 个相关工具,准确率高得多。
这不是 LLM 的问题,是人也会犯的错误——选项太多会导致决策疲劳。
所以要根据任务类型,只暴露相关的工具:
# 研究任务:只需要搜索和读取
if task_type == "research":
tools = registry.filter_tools_for_agent(categories=["search", "web"])
# 数据分析:只需要计算和数据库
elif task_type == "analysis":
tools = registry.filter_tools_for_agent(categories=["calculation", "database"])
# 代码任务:只需要文件操作
elif task_type == "coding":
tools = registry.filter_tools_for_agent(categories=["file", "shell"])
3.8 限流机制
Shannon 有两层限流:
第一层:工具级别限流
在 Tool.execute() 里,每次调用前检查:
async def execute(self, session_context=None, **kwargs) -> ToolResult:
# 获取限流 key(基于 session 或 agent)
tracker_key = self._get_tracker_key(session_id, agent_id)
# 检查是否超过限制
if self.metadata.rate_limit and self.metadata.rate_limit < 100:
retry_after = self._get_retry_after(tracker_key)
if retry_after is not None:
return ToolResult(
success=False,
error=f"Rate limit exceeded. Retry after {retry_after:.1f}s",
metadata={"retry_after_seconds": int(retry_after) + 1},
)
# 执行工具...
第二层:基于滑动窗口
def _get_retry_after(self, tracker_key: str) -> Optional[float]:
if tracker_key not in self._execution_tracker:
return None
last_execution = self._execution_tracker[tracker_key]
min_interval = timedelta(seconds=60.0 / self.metadata.rate_limit)
elapsed = datetime.now() - last_execution
if elapsed >= min_interval:
return None # 可以调用
return (min_interval - elapsed).total_seconds() # 还要等多久
注意一个细节:高吞吐工具(rate_limit >= 100)会跳过限流检查,因为每次检查也有开销。
3.9 常见的坑
坑 1:description 写得太烂
症状:LLM 不知道什么时候该用这个工具,或者填错参数。
案例:
# 烂
ToolParameter(
name="query",
description="The query" # LLM:???
)
# 好
ToolParameter(
name="query",
description="搜索关键词。应包含具体的实体名(公司、人名、产品)和时间范围。"
"示例:'OpenAI GPT-4 发布时间 2023'、'特斯拉 Q3 财报'"
)
解决:description 要写清楚使用场景、格式要求和示例。
坑 2:不处理失败
症状:工具会失败——网络超时、API 报错、参数非法。如果不处理,Agent 会崩。
案例:
# 烂:异常直接抛出
async def _execute_impl(self, **kwargs) -> ToolResult:
result = requests.get(url) # 可能超时
return ToolResult(success=True, output=result.json())
# 好:捕获并返回结构化错误
async def _execute_impl(self, **kwargs) -> ToolResult:
try:
result = await httpx.get(url, timeout=10)
return ToolResult(success=True, output=result.json())
except httpx.TimeoutException:
return ToolResult(success=False, error="请求超时,请稍后重试")
except httpx.HTTPStatusError as e:
return ToolResult(success=False, error=f"HTTP {e.response.status_code}")
解决:永远返回 ToolResult,包含 success 和 error 字段。
坑 3:忘记安全
症状:工具执行的是用户输入,可能被注入恶意代码。
案例:
# 危险:直接 eval 用户输入
async def _execute_impl(self, **kwargs) -> ToolResult:
result = eval(kwargs["expression"]) # 用户输入 "__import__('os').system('rm -rf /')"
return ToolResult(success=True, output=result)
# 安全:用 AST 解析 + 白名单
import ast
import operator
SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
# ...
}
def safe_eval(expr: str):
tree = ast.parse(expr, mode='eval')
# 遍历 AST,只允许白名单里的操作
return _eval_node(tree.body)
解决:永远不要直接 eval() 用户输入。用 AST 解析 + 白名单。
坑 4:给太多工具
症状:LLM 选择困难,调错工具。
案例:给了 15 个工具,其中 3 个都能搜索(web_search、google_search、bing_search),LLM 不知道用哪个。
解决:按任务类型过滤,每次只给 3-5 个相关工具。功能重复的工具只保留一个。
坑 5:没有超时控制
症状:某个工具卡住,整个 Agent 就卡住了。
解决:在 metadata 里设置 timeout_seconds,在执行时强制超时:
async def execute_with_timeout(tool, **kwargs):
try:
return await asyncio.wait_for(
tool.execute(**kwargs),
timeout=tool.metadata.timeout_seconds
)
except asyncio.TimeoutError:
return ToolResult(success=False, error="执行超时")
3.10 其他框架怎么做?
工具调用是通用模式,各家都有实现:
| 框架 | 工具定义方式 | 特点 |
|---|---|---|
| OpenAI | JSON Schema in API | 原生支持,最简单 |
| Anthropic | JSON Schema in API | 支持 input_examples 提升准确率 |
| LangChain | @tool 装饰器 | 生态丰富,有大量预置工具 |
| LangGraph | 继承 BaseTool | 与状态图集成 |
| CrewAI | 继承 BaseTool | 面向多 Agent 场景 |
核心思想都一样:
- 用结构化格式(JSON Schema)描述工具
- LLM 输出调用请求
- 程序解析并执行
- 结果返回给 LLM
差别在于:
- 定义语法(装饰器 vs 类继承 vs JSON)
- 生态集成(预置工具、监控、持久化)
- 生产特性(限流、沙箱、审计)
本章要点回顾
- 工具让 LLM 能"动手",不只是"动嘴"——解决实时信息、精确计算、外部系统访问的问题
- Function Calling 是结构化输出,让 LLM 说"我需要调用什么"而不是瞎编答案
- 工具定义要清晰,尤其是 description,直接影响 LLM 的选择和填参准确率
- 生产环境必须考虑:超时、限流、安全(不要 eval)、成本追踪
- 工具数量要控制,太多会让 LLM 选择困难,按任务类型过滤到 3-5 个
Shannon Lab(10 分钟上手)
本节帮你在 10 分钟内把本章概念对应到 Shannon 源码。
必读(1 个文件)
tools/base.py:看Tool基类的三个抽象方法(_get_metadata、_get_parameters、_execute_impl),理解工具的核心结构
选读深挖(2 个,按兴趣挑)
tools/builtin/calculator.py:一个简单但完整的工具实现,看它怎么做安全计算tools/registry.py:理解工具注册、发现、过滤的机制
练习
练习 1:分析工具设计
读 Shannon 的 web_search.py,回答:
- 它的
description写了什么?为什么要这样写? - 它有哪些参数?哪些是必填的?
- 它的
rate_limit是多少?为什么设这个值?
练习 2:设计一个工具
设计一个"汇率查询"工具,写出:
- ToolMetadata(name, description, category, rate_limit, timeout_seconds)
- ToolParameter 列表(source_currency, target_currency, amount)
- 考虑:应该设置
dangerous=True吗?为什么?
练习 3(进阶):改进 description
找一个你用过的 API(天气、股票、翻译等),为它写一个工具的 description。
要求:
- 让不懂这个 API 的人看完也知道该怎么填参数
- 包含 2-3 个具体的使用示例
- 说明什么情况下应该用这个工具,什么情况下不应该
延伸阅读
- OpenAI Function Calling 官方文档 - Function Calling 的原始规范
- Anthropic Tool Use 文档 - Claude 的工具调用实现
- JSON Schema 规范 - 工具参数定义的底层格式
下一章预告
工具解决了"Agent 能做什么"的问题。但如果每个项目都要重新写一遍 GitHub 工具、Slack 工具、数据库工具...是不是太累了?
有没有办法让不同系统的工具可以互通、复用?
这就是下一章的内容——MCP 协议。
MCP 是 2024 年 Anthropic 开源的协议,现在已经是 Agent 工具集成的事实标准。Cursor、Windsurf、ChatGPT 都在用它。
下一章见。