把 Tool Use 循环做到"生产级"——错误处理与可靠性五件套
不用任何 Agent 框架,只用 Anthropic SDK 手写一个 Tool Use 循环, 然后像对待一个真实后端服务那样,给它加上参数校验、结构化错误、超时、重试、日志。 目标读者:写过后端、刚上手 Agent 的工程师。文中所有坑都是真踩出来的。
很多人写 Agent 是从 bind_tools / @tool 开始的,香是香,但它把协议细节和可靠性策略全抹平了。一旦线上出问题——工具参数没回传、模型幻觉调用、某个工具卡死把整条对话拖垮——你会发现自己根本不知道框架在替你做什么。
这篇就反过来:先用 20 行裸 API 把循环跑通,再一件一件加"生产必需品"。每一件都讲清楚为什么,以及坑在哪。
一、起点:最朴素的 Tool Use 循环
Tool Use 的协议核心其实就一句话:
只要
stop_reason == "tool_use",就执行工具 → 把结果作为tool_result回传 → 再请求一次,直到end_turn。
翻成代码,最朴素的版本长这样:
def run(question):
client, model = build_client()
messages = [{"role": "user", "content": question}]
while True:
msg = client.messages.create(model=model, tools=TOOLS, messages=messages, max_tokens=1024)
if msg.stop_reason != "tool_use":
return "".join(b.text for b in msg.content if b.type == "text")
messages.append({"role": "assistant", "content": msg.content}) # ← 关键:原样回填
results = []
for block in msg.content:
if block.type == "tool_use":
out = DISPATCH[block.name](block.input) # 执行工具
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(out)})
messages.append({"role": "user", "content": results})
这版能跑,但它假设一切都不会出错:模型一定传对参数、工具一定不抛异常、网络一定秒回、工具一定不会卡死。线上没有"一定"。下面把这些假设一个个打掉。
第一个隐形坑就在上面那行注释:回传时必须把 assistant 的原始
msg.content整个带回去(里面含tool_use块)。只回传tool_result而丢掉 assistant 轮,模型会"失忆",对不上tool_use_id。
二、可靠性五件套
① 参数校验:schema 只是"建议",本地必须二次校验
在 TOOLS 里写的 input_schema 是给模型看的建议,不是强约束。模型完全可能少传字段、传错类型、或塞一堆多余字段。这跟后端的"永远不信任客户端输入"是同一条铁律——API 边界一定要用 validator 再校一遍 DTO。
用 pydantic 做这层二次校验:
class WeatherArgs(BaseModel):
city: str
date: str
Args_MODELS = {"get_weather": WeatherArgs, ...}
# 执行前:
args = Args_MODELS[name](**raw_input).model_dump() # 校验 + 类型强制
校验失败抛 ValidationError,我们不让它崩,而是转成一条结构化错误回给模型(见下一条)。注意:参数错误是"确定性错误"——同样的输入必然同样地失败,所以它不该重试,而应该让模型自己改参数再来。这条线后面讲重试时还会反复出现。
② 结构化错误回传:用 is_error + 错误码,而不是 raise
工具出错时,新手的本能是 raise。但在 Agent 循环里 raise = 掀桌:整个 run 直接挂掉,模型再也没机会补救。
正确做法是把错误翻译成对话里的一条消息回给模型,带上 is_error: True:
def make_error(tool_use_id, error_type, message):
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"is_error": True,
"content": json.dumps({"ok": False, "error_type": error_type, "message": message}, ensure_ascii=False),
}
两个设计要点:
content用 JSON 而非自由文本。error_type就是错误码,等同 RPC handler 返回{code, message}信封而不是裸 500 字符串。模型能据此分支:invalid_params→ 改参数;unknown_tool→ 换工具;timeout→ 也许换思路。is_error: True让对话存活。模型看到错误,会自我纠正后重试——这正是 ReAct 的纠错回路。
我把所有失败归成 5 个错误码,而且从码就能看出"是否重试过":
| error_type | 性质 | 是否重试 |
|---|---|---|
invalid_params | 确定性(参数非法) | ❌ |
unknown_tool | 确定性(模型幻觉调用了没注册的工具) | ❌ |
tool_runtime_error | 确定性(如 1/0、eval 语法错) | ❌ |
timeout | 瞬时(工具卡住) | ✅ |
connection_error | 瞬时(网络抖动) | ✅ |
别小看"幻觉调用未注册工具"这一类:模型有时会一本正经地调一个你压根没定义的工具。如果直接
DISPATCH[name]就是KeyError掀桌,所以要先if name not in DISPATCH拦下来,回unknown_tool。
③ 超时:其实有两层,别搞混
这是最容易想偏的一点。"超时"在 Agent 里有两层,防的是不同东西:
client.messages.create(..., timeout=API_TIMEOUT_S) ← 第一层:LLM 网络请求挂起
│
├─ stop_reason == "tool_use"
▼
future.result(timeout=_timeout_for(name)) ← 第二层:本地工具卡死
A. API 层超时——防 LLM 请求本身网络挂起/不返回。直接用 SDK 原生参数,它底层是 httpx:
msg = client.messages.create(..., timeout=60.0) # SDK 默认 600s,太松,收紧
生产里这一层 hang 才是最常见的。SDK 还自带重试 2 次(连接错误/429/5xx),所以这层很多时候 SDK 已经替你兜了。
B. 工具层超时——防本地工具(比如真去调外部 HTTP 的 get_weather)卡死。同步工具丢线程池,主线程设闹钟:
_POOL = ThreadPoolExecutor(max_workers=4)
future = _POOL.submit(DISPATCH[name], args)
result = future.result(timeout=_timeout_for(name)) # 到点抛 TimeoutError
这段等价于 Go 里的:
ch := make(chan Result, 1)
go func() { ch <- fn(args) }()
select {
case result = <-ch: // 拿到结果
case <-time.After(timeout): // 超时
}
必须讲清的真相:这是"软超时"。
future.result(timeout=)到点只让主线程放弃等待,但那个工作线程杀不掉(CPython 不能强制 kill 线程),它会在后台继续跑完。所以它保护的是"主循环不被钉死",不是"真的掐断了工具"。真要硬掐断,得换ProcessPoolExecutor(另起进程,可 kill),代价更大。学习/轻量场景软超时够用,但你得知道它的边界。
④ 重试:重试之前,必须先分类
"加个重试"听着简单,但无脑对所有错误重试是新手陷阱:确定性错误重试 3 次,只是把同一个错误慢放三遍,白白拖慢响应。
所以核心原则是:只对"瞬时错误"重试。
MAX_RETRIES = 3
RETRY_BACKOFF_S = 0.5
RETRYABLE_EXC = (FutureTimeout, ConnectionError) # ← 只有命中这些才重试
def invoke_tool(name, raw_input):
args = Args_MODELS[name](**raw_input).model_dump() # 校验在循环外:确定性,不重试
last_err = None
for attempt in range(1, MAX_RETRIES + 1):
try:
future = _POOL.submit(DISPATCH[name], args)
return str(future.result(timeout=_timeout_for(name)))
except RETRYABLE_EXC as e: # 瞬时:值得重试
last_err = ("timeout", ...) if isinstance(e, FutureTimeout) else ("connection_error", ...)
if attempt < MAX_RETRIES:
time.sleep(RETRY_BACKOFF_S * attempt) # 线性退避
except Exception as e: # 确定性:立刻放弃
raise ToolFailed("tool_runtime_error", f"{type(e).__name__}: {e}")
raise ToolFailed(*last_err)
注意三个细节:
- 参数校验放在
for循环外——它是确定性错误,放进去重试纯属浪费。 except RETRYABLE_EXC在前,except Exception在后——只有超时/连接类进重试分支,其余(除零、语法错……)直接raise,一次就放弃。- 退避(backoff)——第 n 次失败后睡
n * base秒,给瞬时故障一点恢复时间。生产里通常还会加 jitter(随机抖动),防止大量请求同时重试踩踏;这里从简没加。
顺带一个真实修过的 bug:
except RETRYABLE_EXC的非超时分支里,能进来的只可能是ConnectionError,所以它的错误码该是connection_error;一开始我顺手写成了tool_runtime_error,结果和下面那个确定性分支撞了码——同一个tool_runtime_error一会儿表示"重试过的瞬时错"、一会儿表示"没重试的崩溃",错误码的意义就废了。分类要分干净。
⑤ 日志:每一步都打,但别和"产品输出"混为一谈
把散落的 print 升级成 logging,流程里每一步(请求模型 / 调用工具 / 成功 / 失败 / 重试 / 结束)都打点:
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(), # 级别可由环境变量控制
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout, # 见下方 Windows 坑
)
log = logging.getLogger("tooluse")
级别约定:INFO 正常流程 / WARNING 重试 / ERROR 最终失败。两个习惯:
- 用
%s惰性格式化(log.info("调用 %s", name)而非 f-string),级别关掉时不付格式化开销。 - 日志 ≠ 产品输出。内部执行轨迹走
logging;给用户看的最终答案(demo 里的Q:/A:)仍用print。这是两条流——类比后端的"服务日志 vs HTTP response body",不该混。
三、跑起来:一条真实的日志轨迹
问一句"今天北京天气怎么样?适合穿什么?",日志长这样(模型自己决定先查时间、再查天气、最后给建议):
17:54:16 [INFO] run 开始,问题: 今天北京天气怎么样?适合穿什么衣服?
17:54:16 [INFO] 第 0 轮,消息数=1,请求模型…
17:54:18 [INFO] 模型请求工具: ['get_current_time', 'get_weather']
17:54:18 [INFO] → 调用工具 get_current_time args={}
17:54:18 [INFO] ✓ 工具 get_current_time 成功 → 2026-06-09T17:54:18
17:54:18 [INFO] → 调用工具 get_weather args={'city': '北京', 'date': 'today'}
17:54:18 [INFO] ✓ 工具 get_weather 成功 → 12°C, 晴, 风力 3 级
17:54:18 [INFO] 第 1 轮,消息数=3,请求模型…
17:54:23 [INFO] ✓ 对话结束(stop_reason=end_turn)
故意注入一个会超时的工具,能看到重试三次后放弃——而注入一个除零(确定性)错误,一次就放弃、零重试:
[INFO] → 调用工具 slow args={}
[WARNING] ✗ 工具 slow 第 1/3 次失败(timeout),重试中
[WARNING] ✗ 工具 slow 第 2/3 次失败(timeout),重试中
[WARNING] ✗ 工具 slow 第 3/3 次失败(timeout),已达上限放弃
[INFO] → 调用工具 boom args={} ← 除零:下面没有任何 ↻ 重试行
"确定性错误不重试"这条原则,在日志里一眼可验。
四、两个和"环境"较劲的坑
这两个跟 Agent 逻辑无关,但真能让你卡半天,记下来省事:
1. DeepSeek 兼容端点拒绝空参数工具的 schema。 用 DeepSeek 的 Anthropic 兼容端点时,无参数工具({"type":"object","properties":{}})会报 400 ... null is not of types "boolean","object"。它走 OpenAI 风格校验,缺失的 additionalProperties 被判成 null。修复:给空参数工具补一行 "additionalProperties": False。(官方 Anthropic 端点不报此错,所以换端点时才暴露。)
2. Windows 终端默认 GBK,遇模型输出的 emoji 直接崩。 模型回复常带 🌤 之类,print 往 GBK 终端写就 UnicodeEncodeError。脚本顶部加一行根治:
sys.stdout.reconfigure(encoding="utf-8")
顺带:logging 默认写 stderr(也是 GBK),所以上面 basicConfig 里我特意把 stream=sys.stdout 指到已经 reconfigure 过的 stdout,免得日志里的中文/emoji 再炸一次。
五、小结:这一圈手撸,到底学到了什么
把五件套连起来看,其实就是把后端的工程素养平移到 Agent 上:
- 参数校验 = 不信任输入,边界二次校验。
- 结构化错误 = 错误信封 + 错误码,而不是裸字符串/裸
raise。 - 超时分层 = 区分网络超时和业务超时,各用各的机制。
- 重试分类 = 先判"瞬时 vs 确定性",只重试值得重试的。
- 日志分级 = 可观测性,且日志与产品输出分流。
这些 bind_tools / @tool 都替你做了——但只有自己撸过一遍,你才知道它们替你做了什么、边界在哪、出问题时该往哪看。框架是省力工具,不是黑箱借口。
完整代码
from __future__ import annotations
import sys
import os
import json
import time
import logging
import datetime as _dt
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
# Windows 终端默认 GBK,模型回复带 emoji(如 🌤)会让 print 崩 UnicodeEncodeError;
# 强制 stdout 走 UTF-8,省得每次都要手设 PYTHONIOENCODING。
sys.stdout.reconfigure(encoding="utf-8")
# 添加目录
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__),'..','..')))
from common.client import build_client
from pydantic import BaseModel, Field, ValidationError
# ---- 日志:每一步都打,级别用环境变量 LOG_LEVEL 控制(默认 INFO) -----------
# handler 指到 stdout(上面已 reconfigure UTF-8),避免 Windows stderr GBK 再踩
# emoji/中文 编码坑。用法:LOG_LEVEL=WARNING python -m ... 只看告警以上。
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout,
)
log = logging.getLogger("tooluse")
def _preview(s, n: int = 80) -> str:
"""日志里截断长结果,避免一条天气全文刷屏。"""
s = str(s).replace("\n", " ")
return s if len(s) <= n else s[:n] + "…"
# 入参模型校验
class CalaArgs(BaseModel):
expression: str
class TimeArgs(BaseModel):
pass
class WeatherArgs(BaseModel):
city: str
date: str
Args_MODELS = {
"calculate": CalaArgs,
"get_current_time": TimeArgs,
"get_weather": WeatherArgs
}
TOOLS = [
{
"name": "calculate",
"description": "执行科学计算,并返回计算结果,支持加减乘除,幂运算,阶乘运算。",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "合法的 Python 数学表达式,如 '3**7 + 89'"
}
},
"required": ["expression"]
}
},
{
"name": "get_current_time",
"description": "获取当前时间",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": False,
"required": [],
}
},
{
"name": "get_weather",
"description": "查询城市的天气,并且根据天气给出穿衣建议",
"input_schema":{
"type": "object",
"properties":{
"city": {
"type": "string",
},
"date": {
"type": "string",
}
},
"required": ["city", "date"],
}
}
]
# 工具执行函数
# 科学计算
def calculate(expression: str)-> float:
return eval(expression, {"__builtins__": {}}, {})
#获取当前时间
def get_current_time()-> str:
return _dt.datetime.now().isoformat(timespec="seconds")
# 获取城市天气
def get_weather(city: str,date: str)-> str:
fake = {"Beijing": "12°C, 晴, 风力 3 级", "北京": "12°C, 晴, 风力 3 级"}
return fake.get(city, f"{city}: 20°C, 多云")
DISPATCH = {
"calculate": lambda i: calculate(i["expression"]),
"get_current_time": lambda i: get_current_time(),
"get_weather": lambda i: get_weather(i["city"], i.get("date")),
}
# 把同步工具丢进线程池跑,主线程用 future.result(timeout=) 实现超时。
# 注意:这是"软超时"——到点只让等待方放弃、回错给模型,被卡住的工作线程
# 杀不掉(CPython 不能强制 kill 线程),会在后台继续跑完。真·硬超时需 ProcessPool。
_POOL = ThreadPoolExecutor(max_workers=4)
TOOL_TIMEOUT_S = 10.0 # 默认超时
TIMEOUTS = {"calculate": 5.0} # 个别工具的覆盖值(eval 理论上可能写出慢表达式)
# API 网络超时:和"工具超时"是两层。这层防的是 LLM 请求本身网络挂起/不返回。
# anthropic SDK 底层 httpx,原生支持 timeout,默认 600s,这里收紧到 60s。
# 注意 SDK 默认还会自动重试 2 次(连接错误/429/5xx),所以超时也会被它重试。
API_TIMEOUT_S = 60.0
# 重试:只对"瞬时错误"重试(超时/连接类)。确定性错误(参数非法/未知工具/
# eval 语法错)重试纯属徒劳——同样输入必然同样失败,应立刻放弃并回报模型。
MAX_RETRIES = 3
RETRY_BACKOFF_S = 0.5 # 线性退避基数:第 n 次失败后睡 n*base 秒
RETRYABLE_EXC = (FutureTimeout, ConnectionError) # 命中这些才重试,其余立刻放弃
def _timeout_for(name: str) -> float:
return TIMEOUTS.get(name, TOOL_TIMEOUT_S)
def make_error(tool_use_id: str, error_type: str, message: str) -> dict:
"""构造一个"结构化"的 is_error tool_result。
content 用 JSON 字符串而非自由文本,两个好处:
- error_type 是错误码,模型/日志能按类别分支处理(像 RPC 的错误信封)
- is_error=True 让对话继续,模型可据此自我纠正,而不是 raise 掀掉整个循环
"""
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"is_error": True,
"content": json.dumps(
{"ok": False, "error_type": error_type, "message": message},
ensure_ascii=False,
),
}
class ToolFailed(Exception):
"""工具重试用尽 / 确定性失败后的最终错误,携带分类好的 error_type 供回传。"""
def __init__(self, error_type: str, message: str):
super().__init__(message)
self.error_type = error_type
self.message = message
def invoke_tool(name: str, raw_input: dict) -> str:
"""校验 →(超时 + 重试)执行,成功返回结果字符串。
失败时抛出已分类的异常,由调用方转成结构化错误:
- ValidationError → invalid_params(确定性,**不重试**,校验一次即放弃)
- ToolFailed → timeout / tool_runtime_error(瞬时错误已重试到上限)
"""
# 1) 参数校验:确定性错误,重试无意义——只校验一次,失败直接往外抛
args = Args_MODELS[name](**raw_input).model_dump()
log.info("→ 调用工具 %s args=%s", name, args)
# 2) 执行:仅 RETRYABLE_EXC 才重试,其余确定性异常立刻放弃
last_err: tuple[str, str] | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
future = _POOL.submit(DISPATCH[name], args)
result = str(future.result(timeout=_timeout_for(name)))
log.info("✓ 工具 %s 成功 → %s", name, _preview(result))
return result
except RETRYABLE_EXC as e:
if isinstance(e, FutureTimeout):
last_err = ("timeout", f"工具 {name} 超时(>{_timeout_for(name)}s)")
else:
# 走到这只可能是 ConnectionError(RETRYABLE_EXC 的另一个成员),
# 是瞬时连接错——给它独立 error_type,别和下面确定性的 runtime_error 混用
last_err = ("connection_error", f"{type(e).__name__}: {e}")
tail = ",重试中" if attempt < MAX_RETRIES else ",已达上限放弃"
log.warning("✗ 工具 %s 第 %d/%d 次失败(%s)%s", name, attempt, MAX_RETRIES, last_err[0], tail)
if attempt < MAX_RETRIES:
time.sleep(RETRY_BACKOFF_S * attempt) # 线性退避,给瞬时故障恢复时间
except Exception as e:
# 确定性运行异常(如 1/0、eval 语法错):重试也是同样结果,立刻放弃
raise ToolFailed("tool_runtime_error", f"{type(e).__name__}: {e}") from e
# 重试用尽仍失败
raise ToolFailed(*last_err)
def run(question: str,*, max_turn: int = 8):
log.info("=" * 50)
log.info("run 开始,问题: %s", question)
client, model = build_client()
messages = [{"role": "user", "content": question}]
for turn in range(max_turn + 1):
log.info("第 %d 轮,消息数=%d,请求模型…", turn, len(messages))
msg = client.messages.create(
model=model,
tools=TOOLS,
messages=messages,
max_tokens=1024,
timeout=API_TIMEOUT_S, # API 层超时,与工具超时分开
)
if msg.stop_reason != "tool_use":
log.info("✓ 对话结束(stop_reason=%s)", msg.stop_reason)
return "".join(b.text for b in msg.content if b.type == "text")
# 数据回填
messages.append(
{"role": "assistant", "content": msg.content}
)
log.info("模型请求工具: %s", [b.name for b in msg.content if b.type == "tool_use"])
# 一轮里可能有多个 tool_use 块——全部执行,结果放进同一个 user 消息
tool_result = []
for block in msg.content:
if block.type != "tool_use":
continue
# 0) 模型幻觉调用未注册的工具:结构化告知,别让 KeyError 掀桌
if block.name not in DISPATCH:
log.error("✗ 未注册工具(幻觉调用): %s", block.name)
tool_result.append(
make_error(block.id, "unknown_tool", f"未注册的工具: {block.name}")
)
continue
try:
# 校验/超时/重试都收进 invoke_tool,这里只管把成功结果回填
content = invoke_tool(block.name, block.input)
tool_result.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": content,
})
except ValidationError as e:
# 参数不合法(确定性,未重试):让模型据此修参再试
log.error("✗ 工具 %s 参数校验失败: %s", block.name, _preview(str(e)))
tool_result.append(make_error(block.id, "invalid_params", str(e)))
except ToolFailed as e:
# 超时/运行异常:error_type 已在 invoke_tool 里分好类
log.error("✗ 工具 %s 最终失败(%s): %s", block.name, e.error_type, e.message)
tool_result.append(make_error(block.id, e.error_type, e.message))
messages.append({
"role": "user",
"content": tool_result
})
log.warning("达到 max_turn=%d 上限仍未结束,可能在兜圈子", max_turn)
return f"[达到 max_turns={max_turn} 上限仍未结束,可能在兜圈子]"
if __name__ == "__main__":
q = "今天北京天气怎么样?适合穿什么衣服?"
print(f"Q: {q}")
print(f"A: {run(q)}")