别再无脑堆 Function Calling 了,这 5 个坑我替你踩完了

5 阅读8分钟

上个月接了个需求,让 AI 帮用户查订单、改收货地址、触发退款,三个工具,听起来很简单。结果上线第一天就出了事:用户说"帮我退掉昨天那个订单",AI 先查了订单,然后直接触发退款,跳过了地址修改的确认步骤。客服那边炸了,我也跟着一起炸。

排查了半天,问题出在 Function Calling 的工具定义上——描述写得太模糊,模型自己"脑补"了调用顺序。这才意识到,Function Calling 这东西,入门门槛低,但真正用好,坑多到离谱。

坑一:工具描述写得像废话

很多人写工具定义的时候,description 就随便填一句,比如:

{
    "name": "get_order",
    "description": "获取订单信息",
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string", "description": "订单ID"}
        },
        "required": ["order_id"]
    }
}

这种描述对模型来说几乎没有信息量。模型不知道什么时候该调这个工具,也不知道调完之后该干什么。

正确做法是把调用时机、前置条件、返回值含义都写清楚:

{
    "name": "get_order",
    "description": "根据订单ID查询订单详情,包括商品列表、金额、状态、收货地址。在执行任何修改或退款操作之前,必须先调用此工具确认订单存在且状态允许操作。",
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
                "description": "订单ID,格式为 ORD-XXXXXXXX,可从用户消息或上下文中提取"
            }
        },
        "required": ["order_id"]
    }
}

加了前置条件之后,模型就知道退款前必须先查订单,不会乱跳步骤了。

坑二:并行工具调用顺序乱掉

Claude 和 GPT-4o 都支持一次返回多个工具调用(parallel tool use),这本来是个好特性,但如果工具之间有依赖关系,就会出问题。

比如用户说"帮我查一下北京和上海明天的天气",模型会同时返回两个 get_weather 调用,这没问题。但如果用户说"帮我查订单然后退款",模型有时候也会把这两个调用一起返回——这就完蛋了,因为退款需要先拿到订单信息。

处理方式是在工具描述里明确写依赖关系,同时在代码层面做顺序校验:

import anthropic
import json

client = anthropic.Anthropic(
    base_url="https://api.ofox.ai/v1",
    api_key="sk-xxx"
)

TOOL_DEPENDENCIES = {
    "refund_order": ["get_order"],
    "update_address": ["get_order"],
}

def execute_tools_in_order(tool_calls: list, context: dict) -> dict:
    """按依赖顺序执行工具调用"""
    results = {}
    pending = list(tool_calls)
    max_iterations = len(pending) * 2
    iteration = 0

    while pending and iteration < max_iterations:
        iteration += 1
        for call in list(pending):
            tool_name = call["name"]
            deps = TOOL_DEPENDENCIES.get(tool_name, [])
            # 检查依赖是否已执行
            if all(dep in results for dep in deps):
                result = dispatch_tool(tool_name, call["input"], context)
                results[tool_name] = result
                pending.remove(call)

    return results

def dispatch_tool(name: str, args: dict, context: dict):
    # 实际工具调用逻辑
    if name == "get_order":
        return {"order_id": args["order_id"], "status": "paid", "amount": 299}
    elif name == "refund_order":
        order = context.get("get_order", {})
        if order.get("status") != "paid":
            return {"error": "订单状态不允许退款"}
        return {"success": True, "refund_amount": order["amount"]}
    return {"error": "unknown tool"}

坑三:工具报错了模型不知道怎么办

工具调用失败的时候,很多人直接把异常信息原样返回给模型,或者干脆不返回。这两种做法都会让模型陷入困惑,要么无限重试,要么直接胡说。

正确做法是返回结构化的错误信息,并且告诉模型下一步该怎么做:

def safe_tool_call(tool_name: str, args: dict) -> dict:
    try:
        result = execute_tool(tool_name, args)
        return {"success": True, "data": result}
    except OrderNotFoundError:
        return {
            "success": False,
            "error_code": "ORDER_NOT_FOUND",
            "message": "订单不存在,请让用户确认订单号是否正确",
            "suggestion": "ask_user_to_confirm_order_id"
        }
    except PermissionError:
        return {
            "success": False,
            "error_code": "PERMISSION_DENIED",
            "message": "该订单不属于当前用户,无法操作",
            "suggestion": "stop_and_inform_user"
        }
    except Exception as e:
        return {
            "success": False,
            "error_code": "UNKNOWN_ERROR",
            "message": "操作失败,请稍后重试",
            "suggestion": "retry_once_or_escalate"
        }

suggestion 字段是关键,模型会根据这个字段决定下一步行为,而不是自己乱猜。

坑四:工具定义太多,token 爆炸

这个坑很隐蔽。工具定义是放在 system prompt 里的,每次请求都会消耗 token。如果你定义了 20 个工具,每个工具的 schema 平均 200 token,光工具定义就吃掉 4000 token,还没算对话历史。

解决方案是动态工具加载——根据对话上下文,只加载当前可能用到的工具:

TOOL_REGISTRY = {
    "order": ["get_order", "refund_order", "update_address"],
    "product": ["search_product", "get_product_detail"],
    "user": ["get_user_profile", "update_user_info"],
}

def get_relevant_tools(user_message: str, conversation_history: list) -> list:
    """根据上下文动态选择工具集"""
    relevant_categories = []

    order_keywords = ["订单", "退款", "收货", "发货", "物流"]
    product_keywords = ["商品", "价格", "库存", "搜索"]
    user_keywords = ["账号", "密码", "个人信息", "地址"]

    text = user_message + " ".join(
        m["content"] for m in conversation_history[-3:]
        if isinstance(m.get("content"), str)
    )

    if any(kw in text for kw in order_keywords):
        relevant_categories.append("order")
    if any(kw in text for kw in product_keywords):
        relevant_categories.append("product")
    if any(kw in text for kw in user_keywords):
        relevant_categories.append("user")

    if not relevant_categories:
        relevant_categories = ["order"]  # 默认加载订单工具

    tool_names = []
    for cat in relevant_categories:
        tool_names.extend(TOOL_REGISTRY[cat])

    return [TOOLS[name] for name in tool_names if name in TOOLS]

实测下来,动态加载可以把工具相关的 token 消耗降低 60% 以上,在高并发场景下成本差异非常明显。

坑五:多轮对话里工具结果丢失

这个坑最坑。Function Calling 的多轮对话需要把工具调用结果完整地放回 messages 里,格式必须严格对应,否则模型会出现"失忆"——明明上一轮查到了订单,这一轮又去查一遍。

标准的多轮 Function Calling 循环应该这样写:

import anthropic
import json

client = anthropic.Anthropic(
    base_url="https://api.ofox.ai/v1",  # 我用的这个,低延迟直连
    api_key="sk-xxx"
)

def run_agent(user_message: str, tools: list) -> str:
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            tools=tools,
            messages=messages
        )

        # 把 assistant 的完整响应加入历史
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            # 提取最终文本回复
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text
            return ""

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = safe_tool_call(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,  # 必须对应,否则报错
                        "content": json.dumps(result, ensure_ascii=False)
                    })

            # tool_result 必须放在 user role 里
            messages.append({"role": "user", "content": tool_results})
        else:
            break

    return ""

有几个细节容易出错:

  • tool_use_id 必须和 response 里的 block.id 完全对应
  • tool_result 必须放在 role: user 的消息里,不能放在 assistant 里
  • content 字段建议序列化成字符串,避免嵌套结构引发解析问题

关于多模型调用的一点补充

我在这个项目里同时用了 Claude 和 GPT-4o 做对比测试,发现两个模型在 Function Calling 上的行为差异挺大的:Claude 更倾向于在不确定的时候先问用户,GPT-4o 更倾向于直接猜测并调用工具。对于需要严格确认的业务场景(比如退款),Claude 的保守策略反而更安全。

我现在用 ofox.ai 统一管理多个模型的 API Key,一个接口同时跑 Claude 和 GPT-4o 的对比实验,省去了维护多套配置的麻烦。它支持 50+ 模型,兼容 OpenAI SDK 协议,切换模型只需要改 model 参数,其他代码不用动。

完整可运行示例

把上面的坑都规避掉之后,一个相对健壮的订单助手大概长这样:

import anthropic
import json
from typing import Any

client = anthropic.Anthropic(
    base_url="https://api.ofox.ai/v1",
    api_key="sk-xxx"
)

ORDER_TOOLS = [
    {
        "name": "get_order",
        "description": "查询订单详情。在执行退款或修改地址之前必须先调用此工具,确认订单存在且状态允许操作。",
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "订单ID,格式 ORD-XXXXXXXX"
                }
            },
            "required": ["order_id"]
        }
    },
    {
        "name": "refund_order",
        "description": "对已支付订单发起退款。必须在 get_order 确认订单状态为 paid 之后才能调用。退款前需向用户确认。",
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string"},
                "reason": {"type": "string", "description": "退款原因"}
            },
            "required": ["order_id", "reason"]
        }
    }
]

def mock_get_order(order_id: str) -> dict:
    if order_id == "ORD-12345678":
        return {"order_id": order_id, "status": "paid", "amount": 299, "items": ["商品A"]}
    return {"error": "ORDER_NOT_FOUND", "message": "订单不存在,请确认订单号"}

def mock_refund_order(order_id: str, reason: str) -> dict:
    return {"success": True, "refund_id": "REF-99999", "amount": 299}

def dispatch(name: str, args: dict) -> Any:
    if name == "get_order":
        return mock_get_order(args["order_id"])
    elif name == "refund_order":
        return mock_refund_order(args["order_id"], args.get("reason", ""))
    return {"error": "unknown tool"}

def chat(user_input: str) -> str:
    messages = [{"role": "user", "content": user_input}]
    while True:
        resp = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system="你是订单助手。执行任何修改操作前必须先查询订单确认状态,退款前必须向用户明确确认。",
            tools=ORDER_TOOLS,
            messages=messages
        )
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason == "end_turn":
            return next((b.text for b in resp.content if hasattr(b, "text")), "")
        if resp.stop_reason == "tool_use":
            results = []
            for b in resp.content:
                if b.type == "tool_use":
                    out = dispatch(b.name, b.input)
                    results.append({
                        "type": "tool_result",
                        "tool_use_id": b.id,
                        "content": json.dumps(out, ensure_ascii=False)
                    })
            messages.append({"role": "user", "content": results})

if __name__ == "__main__":
    print(chat("帮我退掉订单 ORD-12345678"))

小结

回头看这 5 个坑,其实都有一个共同根源:把 Function Calling 当成简单的函数映射来用,而不是把它当成一个需要精心设计的对话协议

工具描述是给模型看的文档,写得越清晰,模型的行为就越可预测。依赖关系、错误处理、token 控制,这些都是工程问题,不是 AI 问题。

踩完这些坑之后,我们的订单助手上线两周没再出过类似事故。希望这篇文章能帮你少踩几个。