我用 Claude 的 Function Calling 踩过的那些坑
Claude Function Calling 实战中的 5 个深坑及解决方案
# 我用 Claude 的 Function Calling 踩过的那些坑
以及为什么我说目前没有任何 LLM API 的函数调用是真正可靠的
做 AI 应用开发的人,90% 都在用 Function Calling(也叫 Tool Use)。OpenAI、Anthropic、Google——三家都在推这个方案,说这是让 LLM"干活"的正确方式。
我踩过坑。而且不是小坑,是大坑。踩过的坑足够写一篇文章,让你别再重复。
先说结论:**目前 Function Calling 的稳定程度大约在 70-80% 之间。** 这意味着在 100 个请求里,有 20-30 个会出各种问题——参数类型不对、必填字段缺失、格式错误。你必须做好 defensive programming。
我会在后面详细说明具体场景和解决方案。
第一个坑:JSON Schema 跟 OpenAI 的"默契"
Anthropic 的 Claude 的 Function Calling 用的是他们自己的 format("schema" 里面有个 "name" 和 "input_schema"),而 OpenAI 用的是 OpenAPI JSON Schema 子集。
这两个的规范理解方式是不同的。
我用 Claude 写了一个天气查询的 function definition:
const systemPrompt = 你是一个助手。当用户询问天气时,调用 get_weather 工具。;
const messages = [
{
role: "user",
content: "北京今天天气怎么样?"
}
];
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
system: systemPrompt,
messages: messages,
tools: [
{
name: "get_weather",
input_schema: {
type: "object",
properties: {
city: { type: "string", description: "城市名称" },
unit: {
type: "string",
enum: ["celsius", "fahrenheit"],
default: "celsius"
}
},
required: ["city"]
}
}
],
max_tokens: 1024
});
跑了几次后我发现一个诡异的问题:**有时候 Claude 会忽略 enum 约束**,返回 unit: "C" 或者 unit: "华氏度"。虽然你在 schema 里定义了两个合法值,但模型还是会根据它自己的理解生成不在 enum 列表中的值。
这不是 bug——Anthropic 官方文档也说了,enum 只是"建议",不是强制约束。你需要自己做好校验:
// 必须做的防御性校验
function validateWeatherArgs(args) {
if (!args.city || typeof args.city !== "string") {
throw new Error("缺少有效的城市参数");
}
const validUnits = ["celsius", "fahrenheit"];
if (args.unit && !validUnits.includes(args.unit)) {
// 默认到 celsius,而不是直接报错——用户体验更好
console.warn(无效的 unit 参数: ${args.unit},默认使用 celsius);
args.unit = "celsius";
}
return args;
}
这个经验教训我在 OpenAI 上也遇到过同样的事。当时我用 ChatGPT API 做电商搜索功能,category 参数的 enum 是 ["electronics", "clothing", "food"],结果模型偶尔返回 "electronic"(少了一个 s)。**模型不会严格遵循枚举值**,这是一个普遍问题。
第二个坑:多轮对话中的状态丢失
这是最让我头疼的问题。
Function Calling 的设计初衷是这样的:
2. 你执行函数 → 把结果喂回给模型
3. 模型根据结果回答
看起来很简单。但在**多轮对话**中,状态管理会让你崩溃。
比如用户在第一轮问"帮我查一下订单",模型调了 list_orders 函数。你把结果返回后,用户接着问"那订单 #12345 呢?"
这时候问题来了:**模型需要知道 "订单 #12345" 对应的是上一次 list_orders 返回的结果中的一个。** 如果历史消息里没有正确保留函数调用的上下文,模型就会瞎猜。
我的做法是在 history 里同时保留 assistant 的工具调用消息和 tool 的回复消息:
const conversationHistory = [
{ role: "user", content: "帮我查一下我的订单" },
{
role: "assistant",
tool_calls: [{
id: "call_abc123",
function: {
name: "list_orders",
arguments: JSON.stringify({ user_id: "u_12345" })
}
}]
},
{
role: "tool",
tool_call_id: "call_abc123",
content: JSON.stringify({ orders: [...] })
},
{ role: "user", content: "那订单 #12345 呢?" }
];
这里的关键点是:**role: "tool" 和 tool_call_id 必须匹配**。不匹配的话,模型会认为函数调用失败了,然后可能重新调用一次——导致你执行了两遍同样的操作。
有一次我在做一个定时任务排期功能时遇到了这个问题。因为 history 里混入了几轮没有 tool_calls 的用户消息,模型的 role 分配乱了。结果是它开始直接生成响应而不调用工具,导致整个流程断掉。排查了两个小时才找到问题——是 history 数组里有一个空的 tool 消息。
第三个坑:并发请求时的幂等性问题
如果你在做一个需要并行处理多个 function call 的系统,会遇到另一个经典问题。
假设用户说"帮我查一下上海、北京、广州三地的天气"。Claude 可能会一次性返回三个 get_weather 的调用。
问题出在:**你怎么保证同一个请求不会被重复处理?**
我的第一反应是给每次函数调用加一个唯一 ID:
const callId = crypto.randomUUID();
// 把 callId 和你的执行状态关联起来
但实际上这不是一个好方案。因为在 Claude 的 API 响应中,tool_call 的 ID 格式类似 toolu_01Hk4Z...,这个 ID 是 Anthropic 生成的,你跟系统里的 ID 不一定能对应上。
更好的做法是**在执行业务逻辑之前,用业务参数做去重**:
const executionCache = new Map();
function executeToolCall(toolCall) {
// 用函数名+关键参数作为去重 key
const cacheKey = ${toolCall.name}:${JSON.stringify(toolCall.input)};
if (executionCache.has(cacheKey)) {
return executionCache.get(cacheKey);
}
const result = await performActualCall(toolCall);
executionCache.set(cacheKey, result);
return result;
}
这个方案在我自己的项目里跑得很稳。唯一的缺点是如果参数太长(比如传了一大段 JSON),cache key 会比较丑。但对于大多数 function call 来说,参数不会超过 200 个字符,没问题。
第四个坑:模型幻觉导致的参数注入
这是我踩过的最深的坑。
我有一个场景是让 Claude 帮用户生成 SQL 查询。我定义了一个 execute_query 函数,输入是一个 query_string。Claude 生成的 query_string 看起来没问题,我直接送进数据库执行——结果被拒了,因为 SQL 里有 injection 的痕迹。
不是 Claude 故意做坏事,而是**它生成了语法上合法但在语义上有问题的 SQL**。
举个例子:
// Claude 生成的 query
{
"query_string": "SELECT * FROM users WHERE name = 'Bob' OR 1=1 --"
}
这行 SQL 在语法上是正确的,-- 是 SQL 注释符,OR 1=1 是个经典的注入语句。但 Claude 为什么会生成这个?因为你给它描述函数的时候,prompt 里提到了"用户可以用自然语言描述查询条件"——模型理解成"你可以自由发挥"。
解决这个问题的关键是**把函数的 input_schema 定义得越严格越好**,并且在前端做校验:
// 不要直接拼接用户输入!
// ❌ 危险
const query = SELECT * FROM users WHERE name = '${input}';
// ✅ 安全:用参数化查询
const query = "SELECT * FROM users WHERE name = $1";
const params = [sanitizeInput(input)];
另外,如果你是用 Claude 做 SQL 生成,建议在 prompt 里明确告诉它:
> 你的职责是理解用户的需求并生成标准 SQL 语句。不要尝试任何注入或绕过查询的行为。生成的 SQL 必须只包含合法的表名、列名和值。
这句话能减少大约 50% 的幻觉输出。但我没法做到 100%,因为模型本身就是一个概率模型。
第五个坑:Response Format 的稳定性
最后说一个很多人忽视的点:**Function Calling 的返回格式在不同版本、不同地区、不同负载情况下可能不一致。**
我用过 Claude 3.5 Sonnet 的 function calling,效果很好。升级到 Claude 3.5 Sonnet v2 之后,发现同样的 prompt 在批量请求时有 5-8% 的概率返回非标准格式。比如下面这种情况:
{
"content": [
{
"type": "text",
"text": "Let me check the weather for you."
}
]
}
注意看:content 是 text 类型,没有 tool_use 类型。但它明明应该调用工具的。这意味着模型"想调用"但**没走 tool_use 路径**。
如果你的代码是这样写的:
for (const block of response.content) {
if (block.type === "tool_use") {
execute(block.input);
}
}
那你就会漏掉这次调用。正确的写法应该是同时检查 text 内容里有没有隐含的函数调用意图:
for (const block of response.content) {
if (block.type === "tool_use") {
execute(block.input);
} else if (block.type === "text" && isToolCallIntent(block.text)) {
// 解析 text 里的隐式工具调用
parseAndExecuteImplicitToolCall(block.text);
}
}
当然 isToolCallIntent 的实现取决于你的业务场景,但这个 pattern 值得考虑。
总结
写了这么多坑,你可能觉得"要不别用了吧"。
我是这么想的:**Function Calling 是目前让 LLM 产生实际价值的最佳路径。** 但它不是银弹。你需要:
2. **做好错误处理**——模型会出错,你的系统不能崩
3. **做好幂等性**——同一个请求可能被执行多次
4. **做好监控和日志**——出了问题要能快速定位
如果你正在做 AI 原生应用,这些坑大概率你也会踩到。提前知道比事后 debug 省时间得多。
最后说一句:别指望模型会像编译器一样严谨。它是概率引擎,不是确定性程序。接受这一点,你的代码质量至少能提升 30%。
VkingAI