工具调用设计详解:为什么你的 Agent 总是调错工具

作者:

本文是《从零到上线:Agent 应用开发完整路线图》系列第二篇。上一篇讲了 Agent 的整体路线,这一篇深入第一篇里一笔带过的关键环节——工具调用设计。

如果你照着上一篇手写过那个 60 行的最小 Agent,大概率会遇到这些情况:模型该调工具时不调,不该调时瞎调,或者调用了但参数填错。这些问题十有八九不是模型能力不够,而是工具定义写得太差

这篇文章把工具调用拆开讲清楚:怎么写好一个工具的 schema、怎么设计多工具场景下的边界、工具执行失败了怎么办,以及怎么系统性地排查”模型为什么调错了”。

一、工具描述决定一切

先看一个真实的反面教材:

# 反例:模型经常调错
{
    "name": "get_data",
    "description": "获取数据",
    "input_schema": {
        "type": "object",
        "properties": {
            "id": {"type": "string"}
        },
        "required": ["id"]
    }
}

这个工具的名字、描述、参数说明全部模糊不清。模型在决策”要不要调用它”以及”传什么参数”时,唯一的依据就是这段 JSON 里的文字——它没有源码可看,不知道 get_data 内部到底干了什么。

对比一个写得好的版本:

{
    "name": "get_order_status",
    "description": (
        "根据订单号查询订单的当前状态(待发货/已发货/已签收/已取消)。"
        "仅用于查询已存在的订单,不能用于创建或修改订单。"
        "如果用户没有提供订单号,应该先询问用户订单号,而不是猜测或编造。"
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
                "description": "订单号,格式通常为 'ORD-' 开头加 8 位数字,例如 ORD-20240615"
            }
        },
        "required": ["order_id"]
    }
}

这里有几个关键差异:

  1. 名字本身就是文档:get_order_statusget_data 包含的信息量大得多
  2. 明确边界:写清楚”不能用于创建或修改订单”,防止模型在用户想改订单时误调这个查询工具
  3. 写清楚异常情况怎么处理:”如果用户没提供订单号该怎么做”——这一句能大幅减少模型编造参数的情况
  4. 参数给出格式示例:order_id 的格式说明,能让模型在用户说”帮我查一下 ORD20240615″(少了横杠)时也能正确处理或至少不瞎编

一条好记的判断标准:把工具描述当成是写给一个刚入职、不了解系统内部实现的新同事的任务说明。新同事不知道函数内部代码,只能靠这段文字判断”这是不是我该用的工具”以及”该传什么参数”。

二、多工具场景下的边界设计

工具一多,真正的难点就出现了:模型开始混淆该用哪个。

反例:职责重叠的工具集

tools = [
    {"name": "search", "description": "搜索信息"},
    {"name": "search_web", "description": "在网上搜索"},
    {"name": "query_database", "description": "查询数据库获取信息"},
]

三个工具描述高度相似,模型大概率会选错,或者每次随机选一个。

改进:用描述明确划清边界

tools = [
    {
        "name": "search_internal_docs",
        "description": (
            "搜索公司内部知识库,包括产品文档、内部流程、历史工单。"
            "适用于:用户询问公司政策、产品功能细节、内部流程相关问题。"
            "不适用于:需要实时/最新信息的问题(用 search_web),"
            "或需要查询具体某个用户/订单数据的问题(用 query_database)。"
        ),
        # ...
    },
    {
        "name": "search_web",
        "description": (
            "搜索互联网获取实时公开信息,比如新闻、行业动态、第三方产品信息。"
            "不适用于:公司内部信息(用 search_internal_docs),"
            "不适用于:用户账号/订单等私有数据(用 query_database)。"
        ),
        # ...
    },
    {
        "name": "query_database",
        "description": (
            "查询特定用户的账户数据、订单记录、使用量统计等结构化私有数据。"
            "不适用于:产品文档类问题(用 search_internal_docs),"
            "不适用于:公开的第三方信息(用 search_web)。"
        ),
        # ...
    },
]

每个工具的描述里都交叉引用了其他工具,明确说明”这种情况不归我管,该用谁”。这比单独优化每个工具的描述更有效——边界是相对的,孤立地写好一个工具的描述,解决不了工具之间互相抢戏的问题。

工具数量本身也是问题

工具超过 15-20 个之后,即使每个描述都写得很好,模型选择正确率也会下降。这时候有两个方向:

  • 分层:先用一个”路由”工具/逻辑判断属于哪个大类,再在小范围工具集里选具体工具
  • 按需加载:不是每轮对话都把全部工具塞进去,根据当前任务上下文动态决定暴露哪些工具

三、工具执行失败:把错误信息还给模型

新手常犯的错误是工具调用失败时直接抛异常或返回空值,模型拿到的反馈信息为零,只能瞎猜下一步。

# 反例
def get_order_status(order_id: str) -> str:
    order = db.query(order_id)
    return order.status  # 如果 order 不存在,这里直接抛 AttributeError,整个程序崩溃
# 改进:把可操作的错误信息返回给模型,而不是让程序崩溃
def get_order_status(order_id: str) -> str:
    order = db.query(order_id)
    if order is None:
        return (
            f"未找到订单号 {order_id}。可能的原因:订单号输入有误,"
            f"或者订单不存在于当前系统。请向用户确认订单号是否正确。"
        )
    return f"订单 {order_id} 当前状态:{order.status}"

这里的关键不是”防止报错”,而是把错误转换成模型能理解、能据此做出下一步合理决策的自然语言。在我们的 Agent 循环里,这段文字会作为 tool_result 喂回模型,模型看到后大概率会去追问用户而不是继续编造数据。

完整的容错模式建议在执行层统一处理:

def execute_tool(tool_name: str, tool_input: dict) -> str:
    try:
        if tool_name == "get_order_status":
            return get_order_status(**tool_input)
        elif tool_name == "search_web":
            return search_web(**tool_input)
        else:
            return f"未知工具:{tool_name}"
    except TypeError as e:
        # 参数不匹配,通常是模型传错了参数名/类型
        return f"调用 {tool_name} 时参数有误:{str(e)}。请检查参数是否符合 schema 要求。"
    except Exception as e:
        return f"执行 {tool_name} 时出错:{str(e)}。可以尝试换一种方式,或告知用户暂时无法完成此操作。"

注意这里没有让异常往上抛,而是统一转成字符串塞回对话——这是让 Agent 循环具备自我纠错能力的关键一步。

四、防止模型”自由发挥”参数

有些场景模型会在用户没给够信息时,自己编一个参数往工具里塞。比如用户说”帮我查一下订单”,没给订单号,模型可能直接编一个 ORD-00000000 去调用工具。

两个有效的对抗手段:

1. 在工具描述里明确禁止(上面已经示范过):”如果用户没有提供订单号,应该先询问用户,而不是猜测或编造。”

2. 用 system prompt 加一层兜底规则:

system_prompt = """
你是一个订单查询助手。

重要规则:
- 调用工具前,如果必需参数(如订单号、用户ID)用户没有明确提供,
  必须先询问用户,绝不能编造或猜测参数值。
- 如果工具返回"未找到"类的错误,直接如实告知用户,不要重试编造其他可能的参数值。
"""

把这类规则同时放在工具描述和 system prompt 两个地方,是因为模型在决策时会综合参考二者,双重约束比单一约束更可靠。

五、一个可运行的完整示例

把上面几条原则整合到一起,这是一个带有边界清晰的多工具、统一错误处理的 Agent:

import anthropic

client = anthropic.Anthropic()

tools = [
    {
        "name": "get_order_status",
        "description": (
            "根据订单号查询订单当前状态。仅用于查询已存在订单,"
            "不能创建或修改订单。如果用户未提供订单号,应先询问用户,不能编造。"
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "订单号,格式如 ORD-20240615"
                }
            },
            "required": ["order_id"]
        }
    },
    {
        "name": "search_faq",
        "description": (
            "搜索常见问题库,适用于退换货政策、配送时间等通用问题。"
            "不适用于查询具体某个订单的状态(用 get_order_status)。"
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "问题关键词"}
            },
            "required": ["query"]
        }
    }
]

# 模拟数据库
FAKE_ORDERS = {"ORD-20240615": "已发货,预计明天送达"}
FAKE_FAQ = {"退货": "支持7天无理由退货,需保持商品完好"}

def get_order_status(order_id: str) -> str:
    status = FAKE_ORDERS.get(order_id)
    if status is None:
        return f"未找到订单号 {order_id},请确认订单号是否正确。"
    return f"订单 {order_id} 状态:{status}"

def search_faq(query: str) -> str:
    for keyword, answer in FAKE_FAQ.items():
        if keyword in query:
            return answer
    return f"未找到与'{query}'相关的常见问题,建议转人工客服。"

def execute_tool(name: str, tool_input: dict) -> str:
    try:
        if name == "get_order_status":
            return get_order_status(**tool_input)
        elif name == "search_faq":
            return search_faq(**tool_input)
        return f"未知工具:{name}"
    except TypeError as e:
        return f"参数错误:{str(e)}"
    except Exception as e:
        return f"执行出错:{str(e)}"

def run_agent(user_message: str, max_turns: int = 5) -> str:
    system_prompt = (
        "你是订单客服助手。调用工具前,如果必需参数用户没有明确提供,"
        "必须先询问用户,不能编造参数值。"
    )
    messages = [{"role": "user", "content": user_message}]

    for _ in range(max_turns):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=system_prompt,
            tools=tools,
            messages=messages
        )

        if response.stop_reason != "tool_use":
            return response.content[0].text

        messages.append({"role": "assistant", "content": response.content})

        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                result = execute_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result
                })
        messages.append({"role": "user", "content": tool_results})

    return "达到最大轮次仍未完成"

if __name__ == "__main__":
    # 测试1:信息完整,应正确调用工具
    print(run_agent("帮我查一下订单 ORD-20240615 的状态"))

    # 测试2:信息缺失,模型应该反问而不是编造订单号
    print(run_agent("帮我查一下我的订单状态"))

    # 测试3:应该路由到 FAQ 而不是订单查询
    print(run_agent("你们支持退货吗"))

可以拿这三个测试 case 直接跑,观察模型在”信息缺失该反问”和”该用哪个工具”两个场景下的实际表现——这也是你排查自己项目里工具调用问题时最有效的方法:不要凭感觉改 prompt,而是固定几个边界 case,改完跑一遍,对比前后行为差异。

六、排查清单

如果你的 Agent 工具调用表现不稳定,按这个顺序检查,基本能定位问题:

  1. 描述是否清晰到”新同事能看懂”的程度 —— 大部分问题出在这一步
  2. 多个工具之间是否有职责重叠 —— 检查描述里是否互相划清了边界
  3. 必需参数缺失时,工具描述/system prompt 是否明确写了”该怎么办”
  4. 工具执行失败时,返回的是可操作的错误信息,还是程序崩溃/空值
  5. 工具数量是否超过 15-20 个 —— 考虑分层或动态加载

工具调用设计本质上是一个”沟通问题”——你在用自然语言向一个没有源码访问权限的协作者解释”这是什么、什么时候该用、什么时候不该用”。把这件事做扎实,比换框架或者换更大的模型,往往带来更明显的可靠性提升。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

湘ICP备2026010540号