上个月我接了个私活,甲方要做一个"能自己查天气、查数据库、发邮件"的客服机器人。说白了就是个 AI Agent。一开始我用 GPT-5.5 的 function calling 搞了个原型,效果还行但推理链经常断——模型会莫名其妙跳过某个工具调用步骤。后来换成 Claude Opus 4.7 试了下,extended thinking 配合 tool_use 的效果好得离谱,复杂任务的完成率从 68% 直接拉到 91%。
这篇文章就把我这套 Agent 的搭建过程完整写一遍。不是那种"Hello World 级别的 demo",是真正能跑在生产环境里、支持多轮工具调用和错误恢复的方案。
先说结论
| 维度 | 说明 |
|---|---|
| 模型选择 | Claude Opus 4.7(复杂推理)/ Claude Sonnet 4.6(日常任务,便宜一半多) |
| Agent 框架 | 不用 LangChain,纯 Python 手撸 Agent Loop,可控性更强 |
| 工具调用 | Anthropic 原生 tool_use 协议,比 OpenAI 的 function calling 在多步推理上更稳 |
| 错误恢复 | 工具执行失败后把报错原文喂回模型,让它自己决定重试还是换方案 |
| 实测效果 | 5 个工具、3 轮平均调用深度,P95 延迟约 4.2s(含 extended thinking) |
架构长什么样
整个 Agent 的核心就是一个循环:用户说话 → 模型思考要不要调工具 → 调工具 → 把结果喂回去 → 模型继续思考 → 直到它觉得可以给最终回复了。
graph TD
A[用户输入] --> B[Claude Opus 4.7]
B -->|stop_reason: tool_use| C{工具路由}
C --> D[查天气 API]
C --> E[查数据库]
C --> F[发邮件]
D --> G[工具结果]
E --> G
F --> G
G --> B
B -->|stop_reason: end_turn| H[最终回复给用户]
这个循环看着简单,但魔鬼在细节里。
环境准备
Python 3.11+,装两个包就够了:
pip install anthropic httpx
我没用 LangChain。之前用过一次,光是调试 LangChain 内部的 callback chain 就花了大半天,最后发现它帮我做的事情我自己 200 行代码就能搞定。Agent 这种需要精细控制每一步的场景,手撸反而更快。
方案一:基础 Agent Loop(单工具调用)
先搞一个最小可用版本。定义一个天气查询工具,让 Claude 自己决定什么时候调它。
import anthropic
import json
# 用聚合平台统一管理多个模型的 Key,省得每家单独申请
client = anthropic.Anthropic(
api_key="your-key",
base_url="https://api.ofox.ai/v1"
)
# 定义工具
tools = [
{
"name": "get_weather",
"description": "查询指定城市的当前天气,返回温度和天气状况",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如 Beijing、Tokyo"
}
},
"required": ["city"]
}
}
]
# 模拟工具执行
def execute_tool(name: str, params: dict) -> str:
if name == "get_weather":
# 实际项目这里调真实天气 API
fake_data = {"Beijing": "28°C 晴", "Tokyo": "22°C 多云"}
city = params.get("city", "")
result = fake_data.get(city, f"未找到 {city} 的天气数据")
return json.dumps({"weather": result}, ensure_ascii=False)
return json.dumps({"error": f"未知工具: {name}"})
def agent_loop(user_message: str):
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model="claude-sonnet-4-6-20260401",
max_tokens=4096,
tools=tools,
messages=messages
)
# 检查是否需要调用工具
if response.stop_reason == "tool_use":
# 把模型的完整回复(含 tool_use block)加到对话
messages.append({"role": "assistant", "content": response.content})
# 找到所有 tool_use block 并执行
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"[Agent] 调用工具: {block.name}({block.input})")
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})
elif response.stop_reason == "end_turn":
# 模型觉得可以回复了
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
return final_text
# 测试
answer = agent_loop("东京今天天气怎么样?适合出门吗?")
print(answer)
跑起来的输出大概是这样的:
[Agent] 调用工具: get_weather({"city": "Tokyo"})
东京今天 22°C,多云。温度挺舒服的,适合出门,不过建议带把伞以防万一。
这个基础版已经能用了。但真实场景里问题多得多。
方案二:生产级 Agent(多工具 + 错误恢复 + 调用深度限制)
私活甲方那边的需求是 5 个工具混着用,用户经常问一些需要连续调 2-3 个工具才能回答的问题,比如"帮我查一下上海明天的天气,如果下雨就给我发个邮件提醒"。
这时候有几个坑必须处理:
坑 1:无限循环。 模型有时候会反复调同一个工具,我测试的时候遇到过一次它连续调了 11 次 get_weather,每次传的参数都一样。必须加调用深度限制。
坑 2:工具报错后模型不知道怎么办。 比如数据库查询超时,如果你只返回一个空结果,模型会编造数据。正确做法是把完整报错信息喂回去。
坑 3:并行工具调用。 Claude 有时候会在一个回复里塞两个 tool_use block,意思是"这俩我都要调"。你得同时执行再一起返回。
import anthropic
import json
import traceback
client = anthropic.Anthropic(
api_key="your-key",
base_url="https://api.ofox.ai/v1"
)
MAX_TOOL_ROUNDS = 8 # 最多 8 轮工具调用,防止无限循环
tools = [
{
"name": "get_weather",
"description": "查询城市天气",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"date": {"type": "string", "description": "日期,格式 YYYY-MM-DD,默认今天"}
},
"required": ["city"]
}
},
{
"name": "send_email",
"description": "发送邮件",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"]
}
},
{
"name": "query_database",
"description": "用 SQL 查询业务数据库,返回 JSON 结果",
"input_schema": {
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SELECT 语句,禁止 DELETE/UPDATE"}
},
"required": ["sql"]
}
}
]
def execute_tool(name: str, params: dict) -> str:
"""执行工具,出错时返回完整报错而不是静默失败"""
try:
if name == "get_weather":
# 模拟:明天上海有雨
return json.dumps({"city": params["city"], "weather": "小雨", "temp": "19°C"})
elif name == "send_email":
# 模拟发邮件
if "@" not in params.get("to", ""):
raise ValueError(f"邮箱格式错误: {params['to']}")
return json.dumps({"status": "sent", "message_id": "msg_abc123"})
elif name == "query_database":
sql = params.get("sql", "")
if any(kw in sql.upper() for kw in ["DELETE", "UPDATE", "DROP"]):
raise PermissionError(f"禁止执行写操作: {sql}")
return json.dumps({"rows": [{"id": 1, "name": "测试数据"}]})
else:
return json.dumps({"error": f"未知工具: {name}"})
except Exception as e:
# 关键:把完整报错返回给模型,让它自己决定怎么处理
return json.dumps({
"error": str(e),
"traceback": traceback.format_exc()[-500:] # 截断,别太长
})
def agent_loop(user_message: str, system_prompt: str = None):
messages = [{"role": "user", "content": user_message}]
system = system_prompt or (
"你是一个智能助手,可以调用工具完成任务。"
"如果工具返回错误,分析错误原因并尝试修正参数重试,最多重试 2 次。"
"如果无法完成,诚实告知用户原因。"
)
for round_num in range(MAX_TOOL_ROUNDS):
response = client.messages.create(
model="claude-sonnet-4-6-20260401",
max_tokens=4096,
system=system,
tools=tools,
messages=messages
)
if response.stop_reason == "end_turn":
return "".join(b.text for b in response.content if hasattr(b, "text"))
if response.stop_reason != "tool_use":
# 意外的 stop_reason,比如 max_tokens 截断
print(f"[警告] 意外停止: {response.stop_reason}")
return "".join(b.text for b in response.content if hasattr(b, "text"))
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"[Round {round_num+1}] {block.name}({json.dumps(block.input, ensure_ascii=False)})")
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 "抱歉,工具调用轮次超限,请简化你的问题重试。"
# 测试多步骤任务
result = agent_loop(
"帮我查一下上海明天天气,如果有雨就给 test@example.com 发个提醒邮件"
)
print(result)
跑出来是这样的:
[Round 1] get_weather({"city": "上海", "date": "2026-04-26"})
[Round 2] send_email({"to": "test@example.com", "subject": "上海明日天气提醒", "body": "明天上海预计小雨,19°C,记得带伞。"})
两轮工具调用,模型自己判断了"有雨→需要发邮件"这个逻辑。没有写任何 if-else。
踩坑记录
1. tool_use_id 不匹配会 400
一开始我手动拼 tool_result 的时候把 tool_use_id 写错了,Claude API 直接返回:
anthropic.BadRequestError: 400 {"type":"error","error":{"type":"invalid_request_error","message":"tool_use_id not found in previous assistant message: toolu_xxx"}}
报错信息倒是挺清楚的。每个 tool_result 的 id 必须严格对应前一条 assistant message 里的 tool_use block id。
2. extended thinking 和 tool_use 一起用的坑
后来想给 Opus 4.7 开 extended thinking 提升复杂推理质量,结果发现一个问题:开了 thinking 之后,max_tokens 要设得够大,不然 thinking 占了大部分 token 预算,实际回复被截断。我设了 16384 才稳定。
这个行为我也不确定是 bug 还是设计如此,反正多给 token 预算就对了。
3. Sonnet 4.6 vs Opus 4.7 的选择
不是所有场景都需要 Opus。我测下来,工具调用链路在 2 步以内的话,Sonnet 4.6 的表现和 Opus 差不多,价格便宜太多了。只有那种"查数据库 → 分析结果 → 决定下一步查什么 → 再查 → 汇总"这种 3 步以上的链路,Opus 的成功率才明显高出来。
我现在的做法是先让 Sonnet 跑,如果第一轮工具调用后模型回复里出现"我不确定"之类的犹豫表述,自动 fallback 到 Opus 重跑。折腾了两天才把这个降级逻辑调顺。
4. 工具描述写得烂 = Agent 废了一半
这个真的是血泪教训。我一开始 query_database 的 description 只写了"查询数据库"四个字,模型根本不知道数据库里有什么表、什么字段,生成的 SQL 全是瞎猜。后来我在 description 里加了表结构摘要("可用表:users(id,name,email), orders(id,user_id,amount,created_at)"),准确率直接翻倍。
工具描述就是 Agent 的说明书,写得越具体,模型越不容易瞎搞。
小结
搭一个能用的 AI Agent 不需要什么复杂框架,核心就是一个 while 循环 + 工具定义 + 错误处理。Claude 的 tool_use 协议在多步推理上确实比较稳,特别是 Opus 4.7 开了 extended thinking 之后,复杂任务链的完成率能到 90% 以上。
我目前还没找到比"把报错原文喂回模型"更好的错误恢复方案,如果你有更优雅的做法欢迎评论区聊。代码已经在跑了,甲方那边反馈还行,就是偶尔 Opus 的响应慢了点——P95 大概 4.2 秒,不过对客服场景来说还能接受。