Agent 工具调用设计:定义、管理与安全边界
你给 Agent 挂了十几个工具,联调时发现模型死活不调用 search_orders,宁可瞎编一个订单号也不去搜;或者反过来,明明该查库存却跑去调了发邮件的工具。排查半天发现代码逻辑没问题,问题出在你写工具定义的方式上——这是几乎所有人第一次接 Function Calling 都会踩的坑。
Agent 的工具调用能力上限由两个因素决定:工具本身的能力,以及模型对工具的理解。后者完全取决于你怎么写工具定义(description)。工具描述写不好,模型要么不用工具,要么用错工具,Agent 的实际效果会远低于预期。
工具定义的关键字段
OpenAI Function Calling 格式是目前的事实标准,兼容 Claude、DeepSeek、通义等主流模型:
{
"type": "function",
"function": {
"name": "query_order", # 清晰动词+名词,反映操作意图
"description": ( # 关键:告诉模型什么时候用、不用时用什么替代
"查询指定订单号的详细信息,包括状态、金额、收货地址。"
"仅适合已有确定订单号的查询;如果用户没有提供订单号,"
"先用 search_orders 搜索。"
),
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号,格式为 ORD-XXXXXXXXXX,如 ORD-20240601001"
},
"include_items": {
"type": "boolean",
"description": "是否返回商品明细,默认 false",
"default": False
}
},
"required": ["order_id"]
}
}
}
这段定义里最容易被忽略、也最影响效果的是 description 字段里那句”仅适合已有确定订单号的查询;如果用户没有提供订单号,先用 search_orders 搜索”。模型在决定调不调用某个工具时,本质上是在做一次基于自然语言描述的分类判断——它没法读你的代码,只能读这段文字。如果你只写”查询指定订单号的详细信息”,模型看到用户说”我上周买的那双鞋物流到哪了”,因为没有订单号,很可能会强行编一个订单号塞进去调用,或者干脆放弃调用直接瞎答。加上”何时不用、不用时用什么替代”这句话之后,模型才知道要先兜底走搜索路径。这也是为什么工具描述不是写给人看的注释,而是模型做决策时唯一能依赖的输入。
parameters 里的 description 同样重要,尤其是格式类字段。“订单编号”和”订单编号,格式为 ORD-XXXXXXXXXX,如 ORD-20240601001”看起来只差几个字,但前者会导致模型偶尔把用户随口说的”第三个订单”当成参数直接传进去,触发下游校验报错;后者相当于给了模型一个可以对照的正则范式,出错率会明显下降。如果你的接口对参数格式有强约束(比如手机号、日期、金额精度),务必把示例值写进 description,而不是只在后端做校验后把错误甩回去让模型自己猜。
工具描述的写法规范
| 要素 | 好的写法 | 差的写法 |
|---|---|---|
| 何时使用 | ”当用户询问订单状态、金额时使用" | "查询订单信息” |
| 何时不用 | ”如无订单号,先用 search_orders” | (不写) |
| 参数格式 | ”格式为 ORD-XXXXXXXXXX,如 ORD-20240601001" | "订单编号” |
| 返回内容 | ”返回订单状态、金额、地址” | (不写) |
| 边界情况 | ”查不到时返回 null,不要报错” | (不写) |
核心原则:description 是给模型的”选择题提示”,不是给人类看的文档注释。要描述模型需要知道的判断逻辑,而不是实现细节。
有个反直觉的地方:description 也不是越详细越好。曾经见过团队把内部接口文档整段复制进 description,结果模型选择工具的准确率反而下降了——因为噪音信息(内部字段名、数据库表结构、历史版本说明)稀释了模型真正需要判断的那几句话。写工具描述时可以先问自己一个问题:“如果我是模型,只看这段文字,我能不能准确判断什么时候该用、什么时候不该用?“能想清楚这一点,字数自然会收敛到合理范围。
工具集管理策略
生产环境工具数量一旦超过 15 个,模型选择准确率会显著下降。这不是玄学,本质原因是每个工具的 name、description、parameters schema 全部要塞进 system prompt 或者专门的 tools 字段一起发给模型,工具越多,模型在做选择判断时要对比的候选就越多,加上不少工具功能上有交叉重叠(比如 search_web 和 search_kb 语义相近),模型混选的概率也跟着上升。实际表现通常是:调用了功能相近但不对的工具、同一轮里调用了两个冗余工具、或者干脆不调用直接瞎答。推荐分层管理:
主 Agent(路由层)
│
├─ 信息检索 Sub-Agent(search_web / search_kb / get_weather)
├─ 数据操作 Sub-Agent(query_order / update_order / query_inventory)
└─ 代码执行 Sub-Agent(run_python / run_sql / read_file)
动态工具加载:对超大工具集,可以用 RAG 动态匹配用户意图,只把相关工具注入当前 prompt:
def get_relevant_tools(user_query: str, top_k: int = 8) -> list[dict]:
"""
把所有工具描述建向量索引,按用户查询检索最相关的 top_k 个工具
"""
query_vec = embed(user_query)
tool_ids = vector_search(query_vec, top_k=top_k)
return [TOOL_REGISTRY[tid] for tid in tool_ids]
这里有个取舍:分层路由(主 Agent + Sub-Agent)和动态工具加载(RAG 检索)不是二选一,而是分别应对两种规模。工具数量在 1550 个之间,分层路由通常就够用,把工具按业务域拆到不同 Sub-Agent,每个 Sub-Agent 只看到自己域内的几个工具,路由层只需要判断”这个请求属于哪个域”,这是个更简单的分类问题。工具数量上到几十甚至上百(比如对接了几十个内部系统的企业级 Agent),分层也拆不干净的时候,才需要上动态检索——按用户 query 实时召回最相关的 top_k 个工具,本质上是把”工具选择”这个问题转化成了”语义检索”问题。要注意的是动态加载会引入新的失败模式:如果向量检索没召回真正需要的工具,模型连”选错”的机会都没有,只能瞎答或者报”无法处理”,所以 top_k 通常要留一点冗余(812 个而不是刚好够用的 3~5 个),并且要单独监控召回命中率。
| 工具规模 | 推荐策略 | 典型问题 |
|---|---|---|
| ≤ 15 个 | 全部平铺注入 | 无 |
| 15~50 个 | 分层路由(Sub-Agent) | 路由层判断错域 |
| 50+ 个 | RAG 动态检索注入 | 召回不到目标工具 |
工具执行层的健壮设计
import json
import time
from typing import Any
TOOL_MAP = {
"query_order": query_order_impl,
"search_orders": search_orders_impl,
}
def execute_tool(name: str, arguments: str, timeout: float = 10.0) -> str:
"""
工具执行的统一入口:参数校验、超时控制、错误格式化
"""
# 1. 校验工具是否存在(防止幻觉调用)
if name not in TOOL_MAP:
return json.dumps({
"error": f"工具 '{name}' 不存在",
"available_tools": list(TOOL_MAP.keys())
}, ensure_ascii=False)
# 2. 解析参数
try:
args = json.loads(arguments)
except json.JSONDecodeError as e:
return json.dumps({"error": f"参数 JSON 解析失败: {e}"}, ensure_ascii=False)
# 3. 执行工具(带超时)
try:
start = time.time()
result = TOOL_MAP[name](**args)
elapsed = time.time() - start
return json.dumps({
"result": result,
"elapsed_ms": round(elapsed * 1000)
}, ensure_ascii=False)
except TypeError as e:
# 参数名错误或缺少必须参数
return json.dumps({"error": f"参数错误: {e}"}, ensure_ascii=False)
except Exception as e:
# 把错误返回给模型,让它自行决定重试或换方案
return json.dumps({
"error": f"{type(e).__name__}: {str(e)}",
"suggestion": "请尝试换用其他工具或调整参数"
}, ensure_ascii=False)
关键设计:把错误信息结构化后返回给模型,而不是抛出异常中断 Agent 循环。模型收到错误后通常能自行纠错,这是让 Agent 健壮的核心技巧。
不过要提醒一个容易被忽略的坑:上面这段代码的 timeout 参数其实是”摆设”——函数签名里声明了 timeout: float = 10.0,但函数体里从头到尾没有真正拿它去限制 TOOL_MAP[name](**args) 这一行的执行时间。Python 里同步函数没法从外部”打断”,如果 query_order_impl 内部卡在一个慢查询上 30 秒不返回,这个 timeout=10.0 完全不会生效,Agent 的整个对话循环会跟着一起卡住。想要真正的超时控制,通常有两种做法:一是用 concurrent.futures.ThreadPoolExecutor 把工具函数包一层,用 future.result(timeout=10) 拿到真正的超时异常;二是如果工具本身是异步实现的,直接用 asyncio.wait_for(tool_coro, timeout=10)。生产环境里建议把这层超时包装做成统一装饰器加在 TOOL_MAP 注册的地方,而不是每个工具各自处理,否则很容易漏掉。
另外 elapsed_ms 这个字段不要嫌多余——它是排查”Agent 为什么这轮特别慢”的第一手线索。实践中把每次工具调用的耗时打进日志(哪怕不返回给模型),配合工具名做个简单的 P95 耗时统计,基本就能定位到是哪个工具拖慢了整条链路,比事后靠猜快得多。
危险操作的防护机制
不可逆操作(删除、发送邮件、支付、执行代码)必须设置审批步骤:
# 方案一:确认工具模式
# 定义 confirm_delete_order 确认工具,模型先调用确认,收到人工授权信号才执行实际删除
{
"name": "confirm_delete_order",
"description": (
"发起删除订单的确认请求。这是危险操作,调用此工具后系统会暂停等待人工确认,"
"确认通过后才会执行实际删除。请在用户明确要求删除时才调用。"
),
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"reason": {"type": "string", "description": "删除原因,必填"}
},
"required": ["order_id", "reason"]
}
}
# 方案二:工具分级权限
SAFE_TOOLS = {"query_order", "search_orders", "get_weather"}
DANGEROUS_TOOLS = {"delete_order", "send_email", "execute_code"}
def execute_tool(name, arguments, user_role="user"):
if name in DANGEROUS_TOOLS and user_role != "admin":
return json.dumps({"error": "权限不足,此操作需要管理员授权"})
# ...
这两种方案不是互斥关系,实际项目里经常一起用:权限分级负责”这个身份能不能碰这类操作”这种粗粒度的静态判断,确认工具模式负责”这一次具体操作要不要执行”这种细粒度的动态判断。二者的选择取决于操作的可逆性和影响范围:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 操作不可逆(删除、支付、发邮件) | 确认工具模式 | 每次都要人工看到具体参数再放行 |
| 操作可逆但影响范围大(批量修改) | 确认工具模式 + 权限分级 | 双保险,管理员也要二次确认 |
| 操作可逆且影响范围小(改备注、改状态) | 权限分级即可 | 走审批反而拖慢体验 |
| 纯只读操作 | 都不需要 | 无风险 |
确认工具模式在真实产品里通常还要配一步”人工介入”(Human-in-the-Loop):confirm_delete_order 被调用后,后端把这次请求写入待审批队列,同时给用户返回一段确认文案(“确认要删除订单 ORD-xxx 吗?”),只有用户在界面上点了”确认”按钮,前端才会带着一个一次性 token 再发起真正的删除请求。这一步千万不要图省事直接让模型自己决定”用户这句话算不算确认”——模型对模糊表述(比如”嗯”、“可以吧”)的判断并不稳定,把确认动作交给确定性的 UI 交互,比让模型去猜用户意图安全得多。同时别忘了给所有 DANGEROUS_TOOLS 的调用加审计日志,记录谁在什么时间通过什么理由触发了删除/支付类操作,出问题时能追溯,这也是很多合规场景里的硬性要求。
进阶:并发调用与重试策略
GPT-4o、Claude、DeepSeek 目前都支持在同一轮回复里一次性返回多个工具调用(parallel tool calls),比如用户问”帮我查一下订单 A 和订单 B 的状态”,模型可能一次性给你两个 query_order 调用请求。这种情况下顺序执行没问题,但如果两个工具之间没有依赖关系,用 asyncio.gather 并发执行能明显缩短总耗时:
import asyncio
async def execute_tool_calls(tool_calls: list[dict]) -> list[dict]:
"""并发执行一轮里返回的多个工具调用,按 tool_call_id 关联结果"""
tasks = [
execute_tool_async(tc["function"]["name"], tc["function"]["arguments"])
for tc in tool_calls
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [
{"tool_call_id": tc["id"], "content": str(r)}
for tc, r in zip(tool_calls, results)
]
这里有两个容易踩的坑:一是模型返回结果时必须严格按 tool_call_id 一一对应塞回去,顺序错了模型会把 A 订单的结果当成 B 订单来理解,产生看起来”没报错但结果全错”的诡异现象;二是 asyncio.gather 默认某个任务抛异常会让整体提前失败,一定要传 return_exceptions=True,否则一个工具超时会连累其他本来能正常返回的工具结果也丢失。
工具调用失败后要不要重试也值得单独考虑清楚。像网络抖动、下游服务瞬时 429/503 这类瞬时错误,适合做指数退避重试(比如 0.5s、1s、2s,最多 3 次);但像”参数校验失败""订单不存在”这类业务性错误,重试没有任何意义,应该直接把结构化错误信息返回给模型,让它换个参数或者换个工具,而不是浪费调用次数和 token 在明知会失败的重试上。判断标准很简单:如果换个时间点重试大概率还是同样的输入同样的结果,就不该重试,该把决策权交还给模型。
常见问题
工具描述写多长合适? 50~150 字之间,包含”何时用、何时不用、返回什么”三个要素。过短(<30 字)信息不足,模型无法正确判断;过长(>300 字)会消耗大量 token 且模型不一定完整阅读。
工具调用参数解析失败怎么办? 把解析失败信息原样返回给模型,通常模型会自行修正参数格式后重试。如果连续两次失败,在错误信息中附上参数 schema 示例帮助模型修正。
能给 Agent 访问数据库的工具吗? 可以,但必须遵循最小权限原则:只开放只读查询,不直接开放 INSERT/UPDATE/DELETE;对可读取的表和字段建白名单;所有 SQL 语句参数化,防止注入。
工具调用会不会明显增加 token 成本? 会,而且经常被低估。每一轮对话只要挂载了工具,所有工具的 name、description、parameters schema 都要作为输入 token 发给模型,工具集越大这部分固定开销越高;一次完整的工具调用(模型发起调用请求 + 拿到工具返回结果再生成最终回答)相当于至少多跑一轮模型推理,输入输出 token 都要再算一遍。粗略估算的方法是:把工具集的 JSON schema 丢进 tokenizer 数一下 token 数,乘以平均每轮对话触发的工具调用次数,再乘以模型单价,就是这部分的边际成本。工具数量到了 30 个以上、且调用频繁的场景,这笔开销经常比想象中高出不少,这也是前面提到的”分层路由/动态检索减少单轮工具数量”在成本上的另一层价值——不只是准确率,还能实打实省 token。
多个模型之间切换,工具调用格式对不上怎么办?
主流模型基本都兼容 OpenAI 的 Function Calling schema,但字段细节和返回格式并不完全一致,比如有的模型用 tool_calls,有的历史版本用 function_call;流式返回时工具调用参数是分片吐出来的,拼接逻辑也各家不同。如果只接一两个模型,手写适配层问题不大;一旦要在 GPT-4o、Claude、DeepSeek 之间来回切换测试效果,自己维护多套适配代码的成本会迅速上升。
← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题
相关阅读:ReAct 模式实现 · Agent 记忆机制 · AI Agent 开发实战
多家模型切换时工具调用格式不兼容?力达云聚合 API 统一接口层,GPT-4o / Claude / DeepSeek 的 Function Calling 无缝对接。