AI Agent 总是不调工具?Function Calling 的 5 个坑我替你踩过了

7 阅读7分钟

上个月我们团队在做一个内部 AI 助手,核心功能是让模型能查数据库、发邮件、调第三方 API。原型跑起来挺顺,但到了集成测试,各种奇怪的问题开始冒出来——模型有时候明明应该查数据库,结果直接编了个答案;有时候并行调 5 个工具,实际只跑了 2 个;最离谱的是,同一段代码在 GPT-4o 上好好的,换成 Claude 就开始乱来。前前后后调了将近两周,踩了一堆坑,这篇文章把最容易掉进去的 5 个坑全记下来了。

Function Calling 失效通常是这 5 类原因:工具 description 没有写清楚触发条件、tool_choice 参数设置让模型悄悄绕过了工具、并行调用时 tool_call_id 对应出错、上下文太长工具定义被截断、以及 Agent 没有退出条件陷入递归。找对方向,每个问题都不难修。

坑一:工具描述太烂,模型看不懂该用哪个

写过 API 文档的都知道,描述写得烂用户就不会用。Function Calling 也是同样的道理——模型决定要不要调某个工具,靠的就是 description 字段。

踩坑案例,当时我们是这么写的:

{
    "name": "query_database",
    "description": "Query the database"
}

结果:用户问「订单 12345 现在是什么状态」,模型大概 70% 的概率直接编一个答案,根本不去调这个工具。

修复方案:

{
    "name": "query_database",
    "description": "当用户需要查询订单状态、用户信息、库存数量等实时业务数据时必须调用此工具。此工具连接生产数据库,返回最新数据。不要用于回答通用知识问题,只用于查询本系统的业务数据。",
    "parameters": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "SQL 查询语句,只允许 SELECT 操作"
            }
        },
        "required": ["query"]
    }
}

改完之后召回率从 30% 直接上到 95%+。关键是三点:明确触发条件(什么时候必须用)、明确排除条件(什么时候不用)、参数 description 也不能省(模型填参数时需要这些提示来猜格式和含义)。

坑二:tool_choice 设错,模型悄悄绕过了工具

默认的 tool_choice: "auto" 让模型自己决定要不要调工具。但很多场景下你明确需要强制调用,这时候要显式指定。

import openai

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

# 错误写法:以为 auto 会一直调工具
response = client.chat.completions.create(
    model="claude-opus-4-7",
    messages=[{"role": "user", "content": "查一下订单 12345 的状态"}],
    tools=tools,
    tool_choice="auto"  # 模型可能不调,直接回答
)

# 正确写法:需要强制调用时显式指定工具名
response = client.chat.completions.create(
    model="claude-opus-4-7",
    messages=[{"role": "user", "content": "查一下订单 12345 的状态"}],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "query_database"}}
)

反过来,如果你的场景是通用对话加可选工具,千万别设成 required,不然用户问「今天天气好不好」,模型也会硬去调你的数据库工具然后卡住。auto 适合工具是可选的场景,强制指定适合工具是必须的场景,要搞清楚用哪个。

坑三:并行调用返回顺序处理出错

Claude 和 GPT-4 都支持并行 Function Calling,一次可以返回多个 tool_calls。但处理返回结果时,顺序必须严格按 tool_call_id 对应,不能自作主张地重新排序。

# 危险写法:对结果排序后 id 对不上
def process_tool_results_wrong(tool_calls, results):
    # 按工具名排序,导致 tool_call_id 和 result 位置错位
    pairs = sorted(zip(tool_calls, results), key=lambda x: x[0].function.name)
    tool_messages = []
    for tool_call, result in pairs:
        tool_messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": str(result)
        })
    return tool_messages

# 正确写法:严格按原始顺序对应
def process_tool_results(tool_calls, results):
    tool_messages = []
    for tool_call, result in zip(tool_calls, results):
        tool_messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,  # 必须和 assistant 消息里的 id 一一对应
            "content": str(result)
        })
    return tool_messages

我们遇到过一个神奇 bug:并行查了用户信息和订单信息,因为按工具名字母序排序后两个结果位置互换,AI 拿着用户名当订单号去处理,输出一堆乱码。找了半天才定位到这里。

坑四:上下文太长,工具定义被偷偷截断

这个问题随着对话轮次增加会越来越明显。模型有上下文窗口限制,当历史消息太长时,最先被截掉的往往是 system prompt 里的工具定义。

症状:前几轮对话工具调用完全正常,对话轮次多了之后开始不调工具,直接回答或者输出奇怪的内容。

def safe_truncate_context(messages, tools, model_limit=160000):
    # 粗估 token 数(精确计算建议用 tiktoken)
    tool_tokens = len(str(tools)) // 3
    available = model_limit - tool_tokens - 2000  # 留 buffer

    msg_tokens = sum(len(str(m)) // 3 for m in messages)

    if msg_tokens > available:
        system_msgs = [m for m in messages if m["role"] == "system"]
        other_msgs = [m for m in messages if m["role"] != "system"]

        kept = []
        current = sum(len(str(m)) // 3 for m in system_msgs)

        for msg in reversed(other_msgs):
            t = len(str(msg)) // 3
            if current + t < available:
                kept.insert(0, msg)
                current += t
            else:
                break

        return system_msgs + kept

    return messages

工具定义复杂的项目还可以考虑按场景动态加载工具——对话开始只加载通用工具,用户意图明确后再加载专用工具,把 tool_tokens 的开销控制在合理范围。

多模型测试:统一接口排查模型差异

做完上面这些修复,我们发现一个新问题:同一套代码在不同模型上行为差异挺大,Claude 对 schema 格式更严格,某些 GPT 能接受的写法 Claude 会拒绝。

ofox.ai 是一个 AI 模型聚合平台,一个 API Key 可以调用 Claude Opus 4.7、GPT-5、Gemini 2.5 Pro、DeepSeek V3 等 50+ 模型,兼容 OpenAI SDK 协议,低延迟直连,支持支付宝按量计费。我们用它来做多模型兼容性测试,切换模型只改一行代码:

import openai

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

# 快速测试同一 schema 在不同模型上的兼容性
for model in ["claude-opus-4-7", "gpt-4o", "gemini-2.5-pro"]:
    try:
        response = client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": "查一下订单 12345"}],
            tools=tools,
            tool_choice="auto"
        )
        tool_calls = response.choices[0].message.tool_calls
        names = [t.function.name for t in tool_calls] if tool_calls else "no tool call"
        print(f"{model}: {names}")
    except Exception as e:
        print(f"{model}: ERROR - {e}")

ofox.ai 多供应商冗余备份(Azure/Bedrock/阿里云/火山引擎),某一路抖动了自动切换,对 Agent 类项目这点很实用——Agent 跑到一半因为模型服务临时 5xx 整条链路挂掉,体验很差。

坑五:Agent 没有退出条件,递归停不下来

这是 Agent 开发里最容易写出死循环的地方。模型调工具 → 拿到结果 → 继续调工具 → 拿到结果 → 没完没了。

# 危险写法:没有最大轮次限制
def run_agent_unsafe(messages, tools):
    while True:
        response = client.chat.completions.create(
            model="claude-opus-4-7",
            messages=messages,
            tools=tools
        )
        if response.choices[0].finish_reason == "stop":
            return response.choices[0].message.content
        # 如果模型一直返回 tool_calls,这里是死循环

# 安全写法:加最大轮次 + 异常状态处理
def run_agent(messages, tools, max_rounds=10):
    for round_num in range(max_rounds):
        response = client.chat.completions.create(
            model="claude-opus-4-7",
            messages=messages,
            tools=tools
        )

        finish_reason = response.choices[0].finish_reason

        if finish_reason == "stop":
            return response.choices[0].message.content

        if finish_reason != "tool_calls":
            raise ValueError(f"Unexpected finish_reason: {finish_reason}")

        assistant_msg = response.choices[0].message
        messages.append(assistant_msg)
        tool_results = execute_tools(assistant_msg.tool_calls)
        messages.extend(tool_results)

    raise RuntimeError(f"Agent 超出最大轮次 {max_rounds},可能陷入循环")

我们上线后真遇到过:模型查了订单发现缺货,去调库存工具,库存数据有误差,又回去查订单,循环了 37 次直到触发 API 速率限制才停。加了 max_rounds=10 之后就不会了。

快速排查 checklist

问题现象优先排查方向
模型不调工具,直接回答检查工具 description 是否有明确触发条件
调了工具但参数全填错检查参数的 description,是否说清了格式和含义
并行调用结果对不上检查 tool_call_id 对应逻辑,是否有排序操作
前几轮正常,后面乱检查上下文长度,工具定义是否被截断
Agent 停不下来加 max_rounds 限制,检查退出条件
换模型就不工作用统一接口测多个模型,对比 schema 兼容性

小结

Function Calling 看起来简单,真正用于生产坑不少。核心规律就一句:模型不是 IDE,它靠描述理解意图,工具描述越清楚,行为越可预测。

调试技巧:开发阶段把每次工具调用的 finish_reason、工具名、入参都打 log,遇到奇怪行为直接看 log,比加 print 快得多。线上跑了一段时间之后也建议统计各工具的实际被调用频率,和你预期差太多的工具,description 大概率需要优化。