Tool Calling Deep Dive: 让 LLM 成为可编程接口
Tool Calling Deep Dive: 让 LLM 成为可编程接口
这是 Agentic 系列的第 05 篇。在前几篇中我们建立了 Agent 的概念模型、控制循环、以及 Agent 与 Workflow 的边界。本篇聚焦于 Agent 能力的核心支点——Tool Calling。
Tool Calling 不是"让 AI 调 API"这么简单。它是 LLM 从 Text-in/Text-out 的生成模型 变成 可编程接口 的关键转折点。理解它的工作原理、设计约束和工程实践,是构建任何 Agentic 系统的前提。
1. 为什么 Tool Calling 是关键转折点
一个纯粹的 LLM 只能做一件事:接受文本,生成文本。它无法查询数据库、无法读取文件、无法发送邮件、无法获取实时天气。它的知识冻结在训练数据的截止日期,它的能力边界就是 token 序列的排列组合。
Tool Calling 改变了这一切。
它的本质不是"让 LLM 调用工具",而是 让 LLM 生成结构化的调用意图,由外部运行时代为执行。这个区分至关重要——LLM 从未真正"执行"过任何工具,它只是学会了在恰当的时机,输出一段符合约定格式的 JSON,表达"我需要调用某个工具,参数是这些"。
这意味着:
- LLM 变成了一个 决策引擎:决定调用什么、传什么参数
- Runtime 变成了一个 执行引擎:负责真正的 I/O 操作
- 两者之间的契约是 JSON Schema
这种分离,让 LLM 从一个封闭的文本生成器,变成了一个可以与外部世界交互的可编程接口。
2. Tool Calling 的工作原理
2.1 完整流程
┌──────────────────────────────────────────────────────────────────────┐
│ Tool Calling 完整序列图 │
└──────────────────────────────────────────────────────────────────────┘
User LLM (API) Runtime Tool (Function)
│ │ │ │
│ "北京今天天气" │ │ │
├────────────────>│ │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ 推理: │ │ │
│ │ │ 用户想查天气 │ │ │
│ │ │ 需要调用 │ │ │
│ │ │ get_weather │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ │ Tool Call JSON │ │
│ │ ────────────────>│ │
│ │ { │ │
│ │ "name": │ │
│ │ "get_weather" │ │
│ │ "arguments": │ │
│ │ {"city": │ │
│ │ "北京"} │ │
│ │ } │ │
│ │ │ get_weather("北京") │
│ │ ├────────────────────>│
│ │ │ │
│ │ │ {"temp": 28, │
│ │ │ "condition": │
│ │ │ "晴"} │
│ │ │<────────────────────┤
│ │ │ │
│ │ Tool Result │ │
│ │ <────────────────│ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ 推理: │ │ │
│ │ │ 根据工具返回 │ │ │
│ │ │ 组织回答 │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ "北京今天28°C,晴"│ │ │
│<────────────────│ │ │
│ │ │ │
2.2 关键洞察
从上面的序列图中,可以提炼出几个核心事实:
LLM 发起两次推理。第一次决定是否调用工具、调用哪个、传什么参数;第二次基于工具返回的结果生成最终回答。这意味着每次 Tool Calling 至少消耗两轮 LLM 调用的 token。
LLM 的输出不是自然语言,而是结构化 JSON。这是模型经过专门训练(fine-tuning)才获得的能力。并非所有 LLM 都支持 Tool Calling——它需要模型在训练阶段就学会"在特定上下文下输出 JSON 而非自然语言"。
Runtime 是不可或缺的中间层。它负责:解析 LLM 返回的 Tool Call、校验参数、路由到正确的函数、执行函数、收集结果、将结果注入下一轮对话。没有 Runtime,Tool Calling 就是一段无人执行的 JSON。
整个过程对用户透明。用户看到的只是"问了一个问题,得到了回答"。中间的 Tool Call 调度过程完全由系统内部完成。
3. JSON Schema 作为契约
3.1 工具定义的结构
每个工具的定义由三部分组成:
tool_definition = {
"type": "function",
"function": {
"name": "get_weather", # 工具的唯一标识
"description": "...", # 给 LLM 看的"接口文档"
"parameters": { # JSON Schema 格式的参数约束
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如 '北京'、'上海'"
}
},
"required": ["city"]
}
}
}
这里的 parameters 遵循 JSON Schema 规范(Draft 2020-12 子集),它不仅定义了参数的类型,还定义了参数的约束、默认值、枚举范围等。JSON Schema 就是 LLM 与 Runtime 之间的 契约。
3.2 好的描述 vs 差的描述
description 是整个工具定义中最容易被低估的字段。它不是给人类看的注释,而是 给 LLM 看的接口文档。LLM 完全依赖 description 来决定是否调用这个工具、以及如何填充参数。
差的描述:
{
"name": "query_db",
"description": "查询数据库", # 太模糊:查什么数据库?返回什么?
"parameters": {
"type": "object",
"properties": {
"q": { # 参数名不直观
"type": "string"
}
}
}
}
好的描述:
{
"name": "query_user_orders",
"description": (
"根据用户 ID 查询该用户的历史订单列表。"
"返回最近 30 天内的订单,包含订单号、金额、状态。"
"如果用户不存在,返回空列表。"
"不支持模糊查询,user_id 必须精确匹配。"
),
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "用户的唯一标识符,格式为 'U' + 8位数字,如 'U00012345'"
},
"status_filter": {
"type": "string",
"enum": ["all", "pending", "completed", "cancelled"],
"description": "按订单状态过滤,默认返回所有状态的订单"
}
},
"required": ["user_id"]
}
}
两者之间的差异在于:
| 维度 | 差的描述 | 好的描述 |
|---|---|---|
| 功能边界 | 不清楚能做什么 | 明确说明查询范围和返回内容 |
| 参数语义 | q 是什么? |
user_id 含义清晰,且给出格式示例 |
| 约束条件 | 无 | 明确说明不支持模糊查询 |
| 异常行为 | 未提及 | 说明了用户不存在时的返回 |
| 枚举约束 | 无 | 用 enum 限定合法值 |
3.3 参数设计原则
- 简单优先:参数数量尽量少。一个工具如果需要 10 个参数,说明它的职责太大,应该拆分。
- 类型明确:用
enum约束离散值,用pattern约束格式,用minimum/maximum约束数值范围。 - 必选与可选分明:
required字段只放真正必须的参数,可选参数给默认值。 - 命名即文档:
user_id比uid好,start_date比sd好。LLM 会从参数名推断语义。 - 避免嵌套过深:LLM 生成深层嵌套 JSON 的准确率会显著下降。尽量用扁平结构。
4. Structured Output vs Free-form Output
4.1 为什么结构化输出更可靠
在 Tool Calling 出现之前,让 LLM 调用工具的常见做法是:在 Prompt 中要求 LLM "用特定格式输出",然后用正则或字符串解析提取调用意图。
# 旧做法(Prompt Hacking)
请用以下格式回答:
Action: <工具名>
Action Input: <参数 JSON>
# LLM 可能的输出(不可靠)
"我觉得应该查一下天气。Action: get_weather Action Input: {"city": "北京"}"
^^ 前面混入了自然语言,解析会出错
这种方式的根本问题是:LLM 的输出是 非确定性的自由文本,它可能在格式中混入自然语言、遗漏字段、搞错 JSON 语法。
Structured Output(结构化输出)通过 约束解码(Constrained Decoding) 从根本上解决了这个问题。模型在生成 token 时,解码器会强制输出符合预定义 JSON Schema 的 token 序列,从而保证输出 100% 可解析。
4.2 三种机制的区别
| 机制 | 原理 | 可靠性 | 适用场景 |
|---|---|---|---|
| JSON Mode | 告诉模型"输出必须是合法 JSON",但不约束 schema | 中等。JSON 语法正确,但字段可能不对 | 简单的数据提取 |
| Function Calling / Tool Use | 模型经过 fine-tuning,能在特定上下文下输出 tool call 结构 | 高。模型专门训练过 | Agent 工具调用 |
| Structured Output | 约束解码 + JSON Schema 验证,输出严格匹配 schema | 极高。解码层面保证 | 需要严格 schema 的场景 |
4.3 各大模型的实现差异
不同模型提供商对 Tool Calling 的 API 设计不尽相同,但核心思想一致:
OpenAI(GPT-4 系列):
- 使用
tools参数传递工具定义 - 返回
tool_calls数组,支持并行调用 - 支持
strict: true开启 Structured Output 模式
Anthropic(Claude 系列):
- 使用
tools参数传递工具定义 - Tool Call 以
tool_usecontent block 返回 - Tool 结果以
tool_resultcontent block 传回 - 原生支持并行工具调用
Google(Gemini 系列):
- 使用
tools+function_declarations结构 - 支持
function_calling_config控制调用模式(AUTO / ANY / NONE) - 返回
function_callpart
虽然 API 格式不同,但抽象层面是一致的:定义工具 → LLM 决定调用 → 返回结构化调用请求 → 外部执行 → 结果回传。这也是为什么我们强调框架无关的原理理解——API 会变,原理不会。
5. 工具注册与发现(Tool Registry)
5.1 静态注册
最简单的方式是在代码中硬编码工具列表:
TOOLS = [
get_weather_tool,
query_db_tool,
send_email_tool,
]
response = client.chat.completions.create(
model="gpt-4",
messages=messages,
tools=TOOLS,
)
优点是简单直接,缺点是每次新增或修改工具都需要改代码、重新部署。适合工具数量少且稳定的场景。
5.2 动态注册
当工具数量增多或需要根据上下文动态调整时,需要一个 Tool Registry:
┌────────────────────────────────────────────────┐
│ Tool Registry │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ weather │ │ database │ │ email │ │
│ │ tool │ │ tool │ │ tool │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ calc │ │ file │ │
│ │ tool │ │ tool │ │
│ └──────────┘ └──────────┘ │
│ │
│ register(tool) / unregister(name) │
│ get_tools(filter?) -> List[Tool] │
│ get_tool(name) -> Tool │
│ get_definitions() -> List[Dict] │
└────────────────────────────────────────────────┘
│
│ get_definitions()
▼
┌───────────┐ tools=[...] ┌───────────┐
│ Runtime │ ──────────────────> │ LLM API │
└───────────┘ └───────────┘
5.3 工具选择问题
当工具数量超过一定阈值(经验值:15-20 个),LLM 的工具选择准确率会明显下降。原因有两个:
- Context 膨胀:每个工具定义占用数百 token,20 个工具就是数千 token 的 system prompt,挤占了有效上下文空间。
- 选择困难:工具越多,语义越可能重叠,LLM 越难区分应该调用哪个。
5.4 Tool Selection 策略
策略一:全量传递
所有工具 ──全部传递──> LLM
适用场景:工具少于 10 个。简单暴力,无额外开销。
策略二:语义过滤
用户输入 ──Embedding──> 向量
│
工具描述 ──Embedding──> 向量库 ──Top-K 相似──> 候选工具 ──> LLM
用 Embedding 计算用户输入与工具描述的语义相似度,只传递 Top-K 最相关的工具。缺点是可能漏掉正确工具。
策略三:两阶段选择
阶段 1:所有工具名 + 简短描述 ──> LLM ──> 选出候选工具 (3-5 个)
阶段 2:候选工具的完整定义 ──> LLM ──> 执行 Tool Call
第一阶段只传递工具名和一行描述(token 消耗少),让 LLM 先做粗筛;第二阶段只传递选中工具的完整定义。这种方式在工具数量 50+ 的场景下效果最好,代价是多一轮 LLM 调用。
6. 完整代码示例
6.1 工具定义
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass
class Tool:
"""工具的统一抽象"""
name: str
description: str
parameters: dict # JSON Schema
function: Callable # 实际执行的函数
requires_confirmation: bool = False # 是否需要用户确认
def to_openai_schema(self) -> dict:
"""转换为 OpenAI API 格式"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
}
# ── 工具实现 ──────────────────────────────────────────────
def get_weather(city: str, unit: str = "celsius") -> dict:
"""模拟天气查询"""
# 实际场景中调用天气 API
mock_data = {
"北京": {"temp": 28, "condition": "晴", "humidity": 45},
"上海": {"temp": 32, "condition": "多云", "humidity": 78},
}
data = mock_data.get(city, {"temp": 20, "condition": "未知", "humidity": 50})
if unit == "fahrenheit":
data["temp"] = data["temp"] * 9 / 5 + 32
return {"city": city, **data}
def query_database(sql: str, database: str = "default") -> dict:
"""模拟数据库查询"""
# 实际场景中执行 SQL
return {
"database": database,
"query": sql,
"rows": [
{"id": 1, "name": "Alice", "amount": 100.0},
{"id": 2, "name": "Bob", "amount": 200.0},
],
"row_count": 2,
}
def calculate(expression: str) -> dict:
"""安全的数学计算"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "表达式包含非法字符"}
try:
result = eval(expression) # 生产环境应使用 ast.literal_eval 或专用解析器
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
def read_file(file_path: str, encoding: str = "utf-8") -> dict:
"""读取文件内容"""
try:
with open(file_path, "r", encoding=encoding) as f:
content = f.read(10000) # 限制读取大小
return {"path": file_path, "content": content, "size": len(content)}
except FileNotFoundError:
return {"error": f"文件不存在: {file_path}"}
except Exception as e:
return {"error": str(e)}
def send_email(to: str, subject: str, body: str) -> dict:
"""模拟发送邮件"""
# 实际场景中调用邮件服务
return {"status": "sent", "to": to, "subject": subject}
# ── 工具注册 ──────────────────────────────────────────────
weather_tool = Tool(
name="get_weather",
description=(
"查询指定城市的当前天气信息,包括温度、天气状况和湿度。"
"支持国内主要城市。如果城市不在数据库中,返回默认值。"
),
parameters={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "要查询的城市名称,如 '北京'、'上海'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认摄氏度"
}
},
"required": ["city"],
},
function=get_weather,
)
database_tool = Tool(
name="query_database",
description=(
"执行 SQL 查询并返回结果。仅支持 SELECT 语句,"
"不允许执行 INSERT/UPDATE/DELETE 等写操作。"
"返回结果包含行数据和总行数。"
),
parameters={
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "要执行的 SQL SELECT 语句"
},
"database": {
"type": "string",
"enum": ["default", "analytics", "users"],
"description": "目标数据库名称,默认为 'default'"
}
},
"required": ["sql"],
},
function=query_database,
)
calculator_tool = Tool(
name="calculate",
description=(
"执行数学计算。支持加减乘除和括号。"
"输入为数学表达式字符串,如 '(3 + 5) * 2'。"
"不支持变量和函数调用,仅限纯数值运算。"
),
parameters={
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式,如 '(3 + 5) * 2'"
}
},
"required": ["expression"],
},
function=calculate,
)
file_tool = Tool(
name="read_file",
description=(
"读取指定路径的文本文件内容。最多读取 10000 字符。"
"仅支持文本文件,不支持二进制文件。"
"如果文件不存在,返回错误信息。"
),
parameters={
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "文件的绝对路径或相对路径"
},
"encoding": {
"type": "string",
"description": "文件编码,默认 utf-8"
}
},
"required": ["file_path"],
},
function=read_file,
)
email_tool = Tool(
name="send_email",
description=(
"向指定收件人发送一封电子邮件。"
"需要提供收件人地址、邮件主题和正文。"
"正文支持纯文本格式。"
),
parameters={
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "收件人邮箱地址"
},
"subject": {
"type": "string",
"description": "邮件主题"
},
"body": {
"type": "string",
"description": "邮件正文,纯文本格式"
}
},
"required": ["to", "subject", "body"],
},
function=send_email,
requires_confirmation=True, # 发邮件需要用户确认
)
6.2 Tool Registry 实现
import json
from typing import Optional
class ToolRegistry:
"""工具注册中心"""
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
if tool.name in self._tools:
raise ValueError(f"工具 '{tool.name}' 已注册")
self._tools[tool.name] = tool
def unregister(self, name: str) -> None:
self._tools.pop(name, None)
def get_tool(self, name: str) -> Optional[Tool]:
return self._tools.get(name)
def get_all_tools(self) -> list[Tool]:
return list(self._tools.values())
def get_definitions(self, names: list[str] | None = None) -> list[dict]:
"""获取工具定义列表(用于传递给 LLM API)"""
tools = self._tools.values()
if names:
tools = [t for t in tools if t.name in names]
return [t.to_openai_schema() for t in tools]
def get_summary(self) -> str:
"""获取工具摘要(用于两阶段选择的第一阶段)"""
lines = []
for tool in self._tools.values():
# 只取 description 的第一句
short_desc = tool.description.split("。")[0] + "。"
lines.append(f"- {tool.name}: {short_desc}")
return "\n".join(lines)
# 初始化 Registry
registry = ToolRegistry()
for tool in [weather_tool, database_tool, calculator_tool, file_tool, email_tool]:
registry.register(tool)
6.3 Tool Dispatcher 实现
import json
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
class ToolDispatcher:
"""
工具调度器:解析 LLM 返回的 tool calls,执行对应工具,收集结果。
"""
def __init__(self, registry: ToolRegistry, max_parallel: int = 5):
self.registry = registry
self.max_parallel = max_parallel
def validate_arguments(self, tool: Tool, arguments: dict) -> list[str]:
"""基础参数验证(生产环境建议使用 jsonschema 库)"""
errors = []
schema = tool.parameters
required = schema.get("required", [])
properties = schema.get("properties", {})
# 检查必填参数
for param in required:
if param not in arguments:
errors.append(f"缺少必填参数: {param}")
# 检查参数类型和枚举
for param, value in arguments.items():
if param not in properties:
errors.append(f"未知参数: {param}")
continue
prop_schema = properties[param]
if "enum" in prop_schema and value not in prop_schema["enum"]:
errors.append(
f"参数 '{param}' 的值 '{value}' "
f"不在允许范围内: {prop_schema['enum']}"
)
return errors
def execute_single(self, tool_call: dict) -> dict:
"""执行单个工具调用"""
name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"]
call_id = tool_call.get("id", "unknown")
# 1. 查找工具
tool = self.registry.get_tool(name)
if not tool:
return {
"tool_call_id": call_id,
"role": "tool",
"content": json.dumps({"error": f"工具 '{name}' 不存在"}),
}
# 2. 解析参数
try:
arguments = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except json.JSONDecodeError as e:
return {
"tool_call_id": call_id,
"role": "tool",
"content": json.dumps({"error": f"参数 JSON 解析失败: {e}"}),
}
# 3. 验证参数
errors = self.validate_arguments(tool, arguments)
if errors:
return {
"tool_call_id": call_id,
"role": "tool",
"content": json.dumps({"error": "参数验证失败", "details": errors}),
}
# 4. 执行工具
try:
result = tool.function(**arguments)
return {
"tool_call_id": call_id,
"role": "tool",
"content": json.dumps(result, ensure_ascii=False),
}
except Exception as e:
return {
"tool_call_id": call_id,
"role": "tool",
"content": json.dumps({
"error": f"工具执行失败: {type(e).__name__}: {e}",
"traceback": traceback.format_exc()[-500:], # 截断过长的堆栈
}),
}
def execute_parallel(self, tool_calls: list[dict]) -> list[dict]:
"""并行执行多个工具调用"""
if len(tool_calls) == 1:
return [self.execute_single(tool_calls[0])]
results = []
with ThreadPoolExecutor(max_workers=self.max_parallel) as executor:
future_to_call = {
executor.submit(self.execute_single, tc): tc
for tc in tool_calls
}
for future in as_completed(future_to_call):
results.append(future.result())
# 按原始顺序排列结果
id_to_result = {r["tool_call_id"]: r for r in results}
ordered = []
for tc in tool_calls:
call_id = tc.get("id", "unknown")
ordered.append(id_to_result.get(call_id, results.pop(0)))
return ordered
dispatcher = ToolDispatcher(registry)
6.4 完整对话循环
from openai import OpenAI
def run_agent_loop(
client: OpenAI,
user_message: str,
registry: ToolRegistry,
dispatcher: ToolDispatcher,
max_iterations: int = 10,
) -> str:
"""
完整的 Agent 对话循环,支持多轮 Tool Calling。
"""
messages = [
{"role": "system", "content": "你是一个有用的助手,可以使用工具来回答用户的问题。"},
{"role": "user", "content": user_message},
]
tools = registry.get_definitions()
for i in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4",
messages=messages,
tools=tools if tools else None,
)
choice = response.choices[0]
message = choice.message
# 如果 LLM 没有调用工具,直接返回文本回答
if not message.tool_calls:
return message.content
# 将 LLM 的回复(含 tool_calls)加入消息历史
messages.append(message.model_dump())
# 执行所有工具调用(支持并行)
tool_calls = [tc.model_dump() for tc in message.tool_calls]
results = dispatcher.execute_parallel(tool_calls)
# 将工具执行结果加入消息历史
for result in results:
messages.append(result)
# 继续循环,让 LLM 基于工具结果做下一步决策
return "达到最大迭代次数,对话终止。"
# 使用示例
# client = OpenAI()
# answer = run_agent_loop(client, "北京今天天气怎么样?然后帮我算一下 28 * 9/5 + 32", registry, dispatcher)
# print(answer)
7. 错误处理与验证
Tool Calling 中的错误来源比常规 API 调用更多,因为链条更长:用户输入 → LLM 推理 → 参数生成 → 参数验证 → 工具执行 → 结果回传 → LLM 再推理。每一环都可能出错。
7.1 参数验证
LLM 生成的参数并不总是合法的。常见问题:
# LLM 可能生成的"有问题"的参数
# 1. 类型错误:期望 string,给了 number
{"city": 123}
# 2. 枚举越界:给了不在 enum 中的值
{"unit": "kelvin"} # enum 里只有 celsius / fahrenheit
# 3. 格式错误:JSON 语法不对
'{"city": "北京",}' # 尾部多余逗号(严格 JSON 不允许)
# 4. 幻觉参数:编造了不存在的参数
{"city": "北京", "forecast_days": 7} # 工具根本没有这个参数
# 5. 语义错误:参数值表面合法但语义错误
{"sql": "DROP TABLE users"} # 传了一条 DELETE 语句给 SELECT-only 工具
应对策略是 分层验证:
def validate_and_execute(tool: Tool, raw_arguments: str) -> dict:
# 第一层:JSON 语法
try:
args = json.loads(raw_arguments)
except json.JSONDecodeError:
return {"error": "参数不是合法的 JSON"}
# 第二层:Schema 验证(使用 jsonschema 库)
from jsonschema import validate, ValidationError
try:
validate(instance=args, schema=tool.parameters)
except ValidationError as e:
return {"error": f"参数验证失败: {e.message}"}
# 第三层:业务规则验证
if tool.name == "query_database":
sql = args.get("sql", "").strip().upper()
if not sql.startswith("SELECT"):
return {"error": "仅支持 SELECT 查询"}
# 执行
return tool.function(**args)
7.2 工具执行失败的反馈
当工具执行失败时,最重要的原则是:将错误信息回传给 LLM,让它决定下一步。
# 不要这样做 —— 对用户抛出原始异常
raise RuntimeError("Connection timeout to weather API")
# 应该这样做 —— 将错误包装为工具结果,回传给 LLM
{
"tool_call_id": "call_abc123",
"role": "tool",
"content": json.dumps({
"error": "天气 API 连接超时,请稍后重试或尝试查询其他城市",
"error_type": "timeout",
"retryable": True
})
}
LLM 拿到这个错误信息后,可能会:
- 换一种方式重试(比如换个参数)
- 告知用户当前无法完成
- 尝试用其他工具达成目标
7.3 重试策略
┌──────────────────────────┐
│ Tool Call 失败 │
└──────────┬───────────────┘
│
┌─────────▼─────────┐
│ 错误类型判断 │
└─────────┬─────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ 可重试 │ │ 参数错误 │ │ 不可恢复 │
│(超时/限流) │ │(类型/格式) │ │(权限/404) │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Runtime │ │ 回传 LLM │ │ 回传 LLM │
│ 自动重试 │ │ 让它修正 │ │ 让它放弃 │
│ (指数退避) │ │ 参数 │ │ 或换方案 │
└───────────┘ └───────────┘ └───────────┘
核心原则:可重试的错误由 Runtime 处理,不可重试的错误交给 LLM 决策。
- 瞬时错误(网络超时、限流):Runtime 自动重试,设置退避策略和最大重试次数,不需要浪费 LLM 的 token。
- 参数错误:回传给 LLM,它可能会修正参数重新调用。
- 永久错误(权限不足、资源不存在):回传给 LLM,让它换一种方案或如实告知用户。
7.4 幂等性考量
当重试机制存在时,幂等性就变得至关重要。
# 幂等操作 —— 重试安全
get_weather("北京") # 多次调用结果相同
query_database("SELECT ...") # 只读查询,天然幂等
# 非幂等操作 —— 重试危险
send_email(to="a@b.com", ...) # 重试 = 发两封邮件
create_order(item="iPhone") # 重试 = 创建两个订单
对于非幂等操作,要么禁止自动重试,要么引入幂等 key:
def send_email_idempotent(to: str, subject: str, body: str, idempotency_key: str) -> dict:
"""带幂等 key 的邮件发送"""
if is_already_sent(idempotency_key):
return {"status": "already_sent", "message": "该请求已处理,跳过重复发送"}
result = _do_send_email(to, subject, body)
mark_as_sent(idempotency_key)
return result
8. 安全性
Tool Calling 打开了 LLM 与外部世界的通道,也同时打开了攻击面。
8.1 工具权限控制
不是所有工具都应该对所有用户开放。一个合理的权限模型:
from enum import Enum
class ToolPermission(Enum):
READ = "read" # 只读操作:查询天气、读文件
WRITE = "write" # 写操作:发邮件、创建记录
ADMIN = "admin" # 管理操作:删除数据、修改配置
class SecureToolRegistry(ToolRegistry):
"""带权限控制的工具注册中心"""
def __init__(self):
super().__init__()
self._permissions: dict[str, ToolPermission] = {}
def register(self, tool: Tool, permission: ToolPermission = ToolPermission.READ):
super().register(tool)
self._permissions[tool.name] = permission
def get_definitions(
self,
names: list[str] | None = None,
max_permission: ToolPermission = ToolPermission.READ,
) -> list[dict]:
"""只返回用户权限范围内的工具"""
permission_levels = {
ToolPermission.READ: 0,
ToolPermission.WRITE: 1,
ToolPermission.ADMIN: 2,
}
max_level = permission_levels[max_permission]
allowed = [
t for t in self._tools.values()
if permission_levels[self._permissions.get(t.name, ToolPermission.ADMIN)] <= max_level
]
if names:
allowed = [t for t in allowed if t.name in names]
return [t.to_openai_schema() for t in allowed]
8.2 参数注入风险
LLM 的参数生成可以被 Prompt Injection 操纵。考虑以下场景:
用户输入: "帮我查一下订单,user_id 是 U00012345; DROP TABLE orders; --"
如果 query_database 工具直接拼接 SQL,这就变成了一次经典的 SQL 注入。防护措施:
- 参数化查询:工具内部必须使用参数化 SQL,绝不拼接。
- 白名单校验:用正则或枚举限制参数值的格式。
- 最小权限原则:数据库连接使用只读账号。
8.3 Sandbox 执行
对于高风险工具(如代码执行、文件操作),应在隔离环境中执行:
┌──────────────────────────────────────────────┐
│ Host Runtime │
│ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Safe Tools │ │ Sandbox │ │
│ │ (天气/计算) │ │ ┌────────────┐ │ │
│ │ 直接执行 │ │ │ Risky Tools│ │ │
│ └─────────────┘ │ │ (代码/文件) │ │ │
│ │ │ 隔离执行 │ │ │
│ │ └────────────┘ │ │
│ │ - 网络受限 │ │
│ │ - 文件系统隔离 │ │
│ │ - 执行时间限制 │ │
│ │ - 资源配额 │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────┘
Sandbox 的实现方式取决于部署环境:
- Docker 容器:最常见,隔离性好
- gVisor / Firecracker:更强的隔离,适合多租户
- WASM:轻量级沙箱,启动快
- 子进程 + seccomp:Linux 下的轻量方案
9. Trade-off 分析
9.1 工具数量 vs 选择准确率
选择准确率
100% │ ****
│ ****
90% │ ****
│ ****
80% │ ****
│ ****
70% │ ****
│ ****
60% │ ****
├───┬───┬───┬───┬───┬───┬───┬───┬───── 工具数量
0 5 10 15 20 25 30 35 40
|<-- 全量传递 -->|<- 需要过滤策略 ->|
- < 10 个工具:全量传递,不需要过滤。
- 10-20 个工具:准确率开始下降,可通过优化 description 缓解。
- > 20 个工具:必须引入 Tool Selection 策略(语义过滤或两阶段选择)。
- > 50 个工具:两阶段选择几乎是唯一可行方案,或者按领域拆分为多个 Agent。
9.2 工具描述详细度 vs Token 消耗
每个工具定义大约占用 100-500 token(取决于描述长度和参数数量)。20 个工具就是 2000-10000 token 的系统开销,这是每次 API 调用都要付出的 固定成本。
描述详细度
低 ◄──────────────► 高
│ │
Token 消耗 低 │ ⚡ 省钱但模糊 │
│ LLM 可能误选工具 │
│ │
高 │ │ 📖 精确但昂贵
│ │ LLM 选择更准确
│ │
实践建议:
- 工具
name起好名字(零额外 token 成本,但信息量大) description控制在 2-3 句话- 参数的
description控制在 1 句话 + 1 个示例 - 用
enum和required代替冗长的文字约束
9.3 确定性执行 vs LLM 灵活性
确定性 灵活性
│ │
│ 硬编码工作流 Agent Tool Calling │
│ if/else 分支 LLM 自由选择工具 │
│ 规则引擎 自动组合工具链 │
│ │
│ ✅ 可预测 ✅ 处理模糊意图 │
│ ✅ 可审计 ✅ 适应新场景 │
│ ✅ 低延迟 ✅ 用户体验自然 │
│ ❌ 不灵活 ❌ 不可预测 │
│ ❌ 维护成本高 ❌ 调试困难 │
│ ❌ 无法处理长尾 ❌ 成本高 │
决策框架:
| 场景特征 | 推荐方案 |
|---|---|
| 流程固定、合规要求高 | 硬编码工作流 + Tool Calling 作为执行层 |
| 意图模糊、工具组合多变 | 完全由 LLM 驱动的 Tool Calling |
| 核心路径固定、边缘场景多 | 混合方案:主流程硬编码,长尾交给 LLM |
关键洞察:Tool Calling 不是非此即彼的选择。你可以让 LLM 决定 是否 调用工具,但用代码控制 调用后的流程。比如 LLM 决定"需要查天气",但查完天气后的处理逻辑是确定性的代码。
10. 常见陷阱
在实际工程中,以下几个坑值得提前规避:
1. 工具描述与实际行为不一致
工具描述说"返回最近 30 天的订单",但实际实现返回所有订单。LLM 会基于描述做出错误假设,导致下游逻辑出错。描述就是契约,必须与实现严格一致。
2. 忽略工具结果的 Token 消耗
工具返回的结果会作为下一轮消息传给 LLM。如果一个数据库查询返回了 1000 行数据,这些数据全部变成 input token。务必在工具层面限制返回数据量。
def query_database(sql: str, database: str = "default") -> dict:
results = _execute_query(sql, database)
# 限制返回行数,避免 token 爆炸
if len(results) > 50:
return {
"rows": results[:50],
"total_count": len(results),
"truncated": True,
"message": f"结果共 {len(results)} 行,仅返回前 50 行"
}
return {"rows": results, "total_count": len(results)}
3. 缺少 stop condition
如果 LLM 反复调用同一个工具(比如因为错误一直重试),而没有最大迭代次数限制,系统会陷入无限循环。前面代码中的 max_iterations 参数就是为此设计的。
4. 并行调用的顺序依赖
LLM 可能在一次回复中请求并行调用两个工具,但这两个工具之间有隐含的顺序依赖(比如先查用户 ID,再用这个 ID 查订单)。Runtime 需要能识别这种情况,或者在工具描述中引导 LLM 分步调用。
11. 总结与展望
Tool Calling 的本质是一个精心设计的 协议:
┌───────────┐ JSON Schema ┌───────────┐ Function ┌───────────┐
│ │ (契约) │ │ (执行) │ │
│ LLM │ ◄───────────────► │ Runtime │ ◄────────────► │ Tools │
│ (决策层) │ Tool Call JSON │ (调度层) │ Function │ (能力层) │
│ │ Tool Result │ │ Call/Return │ │
└───────────┘ └───────────┘ └───────────┘
- LLM 负责理解意图、选择工具、生成参数——它是决策者。
- Runtime 负责验证、路由、执行、错误处理——它是执行者。
- Tools 是具体的能力——它们是能力的载体。
- JSON Schema 是三者之间的契约——它定义了什么可以做、怎么做。
理解了这个架构,你就能在任何框架(LangChain、LlamaIndex、Semantic Kernel,或者自己写的 Runtime)上实现 Tool Calling,因为底层原理是相同的。
但 Tool Calling 只是让 Agent 有了"手"。要让 Agent 真正好用,还需要精心设计的 Prompt 来引导 LLM 的决策——什么时候该调工具、什么时候该直接回答、遇到错误该怎么处理、多个工具之间如何协调。这就是下一篇 Prompt Engineering for Agents 要深入讨论的主题。
系列导航:本文是 Agentic 系列的第 05 篇。