上个月我们团队在做一个内部 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 大概率需要优化。