本文是《从零到上线:Agent 应用开发完整路线图》系列第二篇。上一篇讲了 Agent 的整体路线,这一篇深入第一篇里一笔带过的关键环节——工具调用设计。
如果你照着上一篇手写过那个 60 行的最小 Agent,大概率会遇到这些情况:模型该调工具时不调,不该调时瞎调,或者调用了但参数填错。这些问题十有八九不是模型能力不够,而是工具定义写得太差。
这篇文章把工具调用拆开讲清楚:怎么写好一个工具的 schema、怎么设计多工具场景下的边界、工具执行失败了怎么办,以及怎么系统性地排查”模型为什么调错了”。
一、工具描述决定一切
先看一个真实的反面教材:
# 反例:模型经常调错
{
"name": "get_data",
"description": "获取数据",
"input_schema": {
"type": "object",
"properties": {
"id": {"type": "string"}
},
"required": ["id"]
}
}
这个工具的名字、描述、参数说明全部模糊不清。模型在决策”要不要调用它”以及”传什么参数”时,唯一的依据就是这段 JSON 里的文字——它没有源码可看,不知道 get_data 内部到底干了什么。
对比一个写得好的版本:
{
"name": "get_order_status",
"description": (
"根据订单号查询订单的当前状态(待发货/已发货/已签收/已取消)。"
"仅用于查询已存在的订单,不能用于创建或修改订单。"
"如果用户没有提供订单号,应该先询问用户订单号,而不是猜测或编造。"
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单号,格式通常为 'ORD-' 开头加 8 位数字,例如 ORD-20240615"
}
},
"required": ["order_id"]
}
}
这里有几个关键差异:
- 名字本身就是文档:
get_order_status 比 get_data 包含的信息量大得多
- 明确边界:写清楚”不能用于创建或修改订单”,防止模型在用户想改订单时误调这个查询工具
- 写清楚异常情况怎么处理:”如果用户没提供订单号该怎么做”——这一句能大幅减少模型编造参数的情况
- 参数给出格式示例:
order_id 的格式说明,能让模型在用户说”帮我查一下 ORD20240615″(少了横杠)时也能正确处理或至少不瞎编
一条好记的判断标准:把工具描述当成是写给一个刚入职、不了解系统内部实现的新同事的任务说明。新同事不知道函数内部代码,只能靠这段文字判断”这是不是我该用的工具”以及”该传什么参数”。
二、多工具场景下的边界设计
工具一多,真正的难点就出现了:模型开始混淆该用哪个。
反例:职责重叠的工具集
tools = [
{"name": "search", "description": "搜索信息"},
{"name": "search_web", "description": "在网上搜索"},
{"name": "query_database", "description": "查询数据库获取信息"},
]
三个工具描述高度相似,模型大概率会选错,或者每次随机选一个。
改进:用描述明确划清边界
tools = [
{
"name": "search_internal_docs",
"description": (
"搜索公司内部知识库,包括产品文档、内部流程、历史工单。"
"适用于:用户询问公司政策、产品功能细节、内部流程相关问题。"
"不适用于:需要实时/最新信息的问题(用 search_web),"
"或需要查询具体某个用户/订单数据的问题(用 query_database)。"
),
# ...
},
{
"name": "search_web",
"description": (
"搜索互联网获取实时公开信息,比如新闻、行业动态、第三方产品信息。"
"不适用于:公司内部信息(用 search_internal_docs),"
"不适用于:用户账号/订单等私有数据(用 query_database)。"
),
# ...
},
{
"name": "query_database",
"description": (
"查询特定用户的账户数据、订单记录、使用量统计等结构化私有数据。"
"不适用于:产品文档类问题(用 search_internal_docs),"
"不适用于:公开的第三方信息(用 search_web)。"
),
# ...
},
]
每个工具的描述里都交叉引用了其他工具,明确说明”这种情况不归我管,该用谁”。这比单独优化每个工具的描述更有效——边界是相对的,孤立地写好一个工具的描述,解决不了工具之间互相抢戏的问题。
工具数量本身也是问题
工具超过 15-20 个之后,即使每个描述都写得很好,模型选择正确率也会下降。这时候有两个方向:
- 分层:先用一个”路由”工具/逻辑判断属于哪个大类,再在小范围工具集里选具体工具
- 按需加载:不是每轮对话都把全部工具塞进去,根据当前任务上下文动态决定暴露哪些工具
三、工具执行失败:把错误信息还给模型
新手常犯的错误是工具调用失败时直接抛异常或返回空值,模型拿到的反馈信息为零,只能瞎猜下一步。
# 反例
def get_order_status(order_id: str) -> str:
order = db.query(order_id)
return order.status # 如果 order 不存在,这里直接抛 AttributeError,整个程序崩溃
# 改进:把可操作的错误信息返回给模型,而不是让程序崩溃
def get_order_status(order_id: str) -> str:
order = db.query(order_id)
if order is None:
return (
f"未找到订单号 {order_id}。可能的原因:订单号输入有误,"
f"或者订单不存在于当前系统。请向用户确认订单号是否正确。"
)
return f"订单 {order_id} 当前状态:{order.status}"
这里的关键不是”防止报错”,而是把错误转换成模型能理解、能据此做出下一步合理决策的自然语言。在我们的 Agent 循环里,这段文字会作为 tool_result 喂回模型,模型看到后大概率会去追问用户而不是继续编造数据。
完整的容错模式建议在执行层统一处理:
def execute_tool(tool_name: str, tool_input: dict) -> str:
try:
if tool_name == "get_order_status":
return get_order_status(**tool_input)
elif tool_name == "search_web":
return search_web(**tool_input)
else:
return f"未知工具:{tool_name}"
except TypeError as e:
# 参数不匹配,通常是模型传错了参数名/类型
return f"调用 {tool_name} 时参数有误:{str(e)}。请检查参数是否符合 schema 要求。"
except Exception as e:
return f"执行 {tool_name} 时出错:{str(e)}。可以尝试换一种方式,或告知用户暂时无法完成此操作。"
注意这里没有让异常往上抛,而是统一转成字符串塞回对话——这是让 Agent 循环具备自我纠错能力的关键一步。
四、防止模型”自由发挥”参数
有些场景模型会在用户没给够信息时,自己编一个参数往工具里塞。比如用户说”帮我查一下订单”,没给订单号,模型可能直接编一个 ORD-00000000 去调用工具。
两个有效的对抗手段:
1. 在工具描述里明确禁止(上面已经示范过):”如果用户没有提供订单号,应该先询问用户,而不是猜测或编造。”
2. 用 system prompt 加一层兜底规则:
system_prompt = """
你是一个订单查询助手。
重要规则:
- 调用工具前,如果必需参数(如订单号、用户ID)用户没有明确提供,
必须先询问用户,绝不能编造或猜测参数值。
- 如果工具返回"未找到"类的错误,直接如实告知用户,不要重试编造其他可能的参数值。
"""
把这类规则同时放在工具描述和 system prompt 两个地方,是因为模型在决策时会综合参考二者,双重约束比单一约束更可靠。
五、一个可运行的完整示例
把上面几条原则整合到一起,这是一个带有边界清晰的多工具、统一错误处理的 Agent:
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "get_order_status",
"description": (
"根据订单号查询订单当前状态。仅用于查询已存在订单,"
"不能创建或修改订单。如果用户未提供订单号,应先询问用户,不能编造。"
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单号,格式如 ORD-20240615"
}
},
"required": ["order_id"]
}
},
{
"name": "search_faq",
"description": (
"搜索常见问题库,适用于退换货政策、配送时间等通用问题。"
"不适用于查询具体某个订单的状态(用 get_order_status)。"
),
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "问题关键词"}
},
"required": ["query"]
}
}
]
# 模拟数据库
FAKE_ORDERS = {"ORD-20240615": "已发货,预计明天送达"}
FAKE_FAQ = {"退货": "支持7天无理由退货,需保持商品完好"}
def get_order_status(order_id: str) -> str:
status = FAKE_ORDERS.get(order_id)
if status is None:
return f"未找到订单号 {order_id},请确认订单号是否正确。"
return f"订单 {order_id} 状态:{status}"
def search_faq(query: str) -> str:
for keyword, answer in FAKE_FAQ.items():
if keyword in query:
return answer
return f"未找到与'{query}'相关的常见问题,建议转人工客服。"
def execute_tool(name: str, tool_input: dict) -> str:
try:
if name == "get_order_status":
return get_order_status(**tool_input)
elif name == "search_faq":
return search_faq(**tool_input)
return f"未知工具:{name}"
except TypeError as e:
return f"参数错误:{str(e)}"
except Exception as e:
return f"执行出错:{str(e)}"
def run_agent(user_message: str, max_turns: int = 5) -> str:
system_prompt = (
"你是订单客服助手。调用工具前,如果必需参数用户没有明确提供,"
"必须先询问用户,不能编造参数值。"
)
messages = [{"role": "user", "content": user_message}]
for _ in range(max_turns):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=system_prompt,
tools=tools,
messages=messages
)
if response.stop_reason != "tool_use":
return response.content[0].text
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "user", "content": tool_results})
return "达到最大轮次仍未完成"
if __name__ == "__main__":
# 测试1:信息完整,应正确调用工具
print(run_agent("帮我查一下订单 ORD-20240615 的状态"))
# 测试2:信息缺失,模型应该反问而不是编造订单号
print(run_agent("帮我查一下我的订单状态"))
# 测试3:应该路由到 FAQ 而不是订单查询
print(run_agent("你们支持退货吗"))
可以拿这三个测试 case 直接跑,观察模型在”信息缺失该反问”和”该用哪个工具”两个场景下的实际表现——这也是你排查自己项目里工具调用问题时最有效的方法:不要凭感觉改 prompt,而是固定几个边界 case,改完跑一遍,对比前后行为差异。
六、排查清单
如果你的 Agent 工具调用表现不稳定,按这个顺序检查,基本能定位问题:
- 描述是否清晰到”新同事能看懂”的程度 —— 大部分问题出在这一步
- 多个工具之间是否有职责重叠 —— 检查描述里是否互相划清了边界
- 必需参数缺失时,工具描述/system prompt 是否明确写了”该怎么办”
- 工具执行失败时,返回的是可操作的错误信息,还是程序崩溃/空值
- 工具数量是否超过 15-20 个 —— 考虑分层或动态加载
工具调用设计本质上是一个”沟通问题”——你在用自然语言向一个没有源码访问权限的协作者解释”这是什么、什么时候该用、什么时候不该用”。把这件事做扎实,比换框架或者换更大的模型,往往带来更明显的可靠性提升。