上个月接了个需求,让 AI 帮用户查订单、改收货地址、触发退款,三个工具,听起来很简单。结果上线第一天就出了事:用户说"帮我退掉昨天那个订单",AI 先查了订单,然后直接触发退款,跳过了地址修改的确认步骤。客服那边炸了,我也跟着一起炸。
排查了半天,问题出在 Function Calling 的工具定义上------描述写得太模糊,模型自己"脑补"了调用顺序。这才意识到,Function Calling 这东西,入门门槛低,但真正用好,坑多到离谱。
坑一:工具描述写得像废话
很多人写工具定义的时候,description 就随便填一句,比如:
python
{
"name": "get_order",
"description": "获取订单信息",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单ID"}
},
"required": ["order_id"]
}
}
这种描述对模型来说几乎没有信息量。模型不知道什么时候该调这个工具,也不知道调完之后该干什么。
正确做法是把调用时机、前置条件、返回值含义都写清楚:
python
{
"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 调用,这没问题。但如果用户说"帮我查订单然后退款",模型有时候也会把这两个调用一起返回------这就完蛋了,因为退款需要先拿到订单信息。
处理方式是在工具描述里明确写依赖关系,同时在代码层面做顺序校验:
python
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"}
坑三:工具报错了模型不知道怎么办
工具调用失败的时候,很多人直接把异常信息原样返回给模型,或者干脆不返回。这两种做法都会让模型陷入困惑,要么无限重试,要么直接胡说。
正确做法是返回结构化的错误信息,并且告诉模型下一步该怎么做:
python
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,还没算对话历史。
解决方案是动态工具加载------根据对话上下文,只加载当前可能用到的工具:
python
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 循环应该这样写:
python
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 参数,其他代码不用动。
完整可运行示例
把上面的坑都规避掉之后,一个相对健壮的订单助手大概长这样:
python
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 问题。
踩完这些坑之后,我们的订单助手上线两周没再出过类似事故。希望这篇文章能帮你少踩几个。