← 返回资讯

Agent 工具调用设计:定义、管理与安全边界

2026-06-29

你给 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_websearch_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 无缝对接。