把ToolUse循环做到生产级-错误处理与可靠性五件套

把 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

翻成代码,最朴素的版本长这样:

python 复制代码
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 做这层二次校验:

python 复制代码
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:

python 复制代码
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/0eval 语法错)
timeout 瞬时(工具卡住)
connection_error 瞬时(网络抖动)

别小看"幻觉调用未注册工具"这一类:模型有时会一本正经地调一个你压根没定义的工具。如果直接 DISPATCH[name] 就是 KeyError 掀桌,所以要先 if name not in DISPATCH 拦下来,回 unknown_tool

③ 超时:其实有两层,别搞混

这是最容易想偏的一点。"超时"在 Agent 里有两层,防的是不同东西:

ini 复制代码
client.messages.create(..., timeout=API_TIMEOUT_S)   ← 第一层:LLM 网络请求挂起
        │
        ├─ stop_reason == "tool_use"
        ▼
future.result(timeout=_timeout_for(name))            ← 第二层:本地工具卡死

A. API 层超时------防 LLM 请求本身网络挂起/不返回。直接用 SDK 原生参数,它底层是 httpx:

python 复制代码
msg = client.messages.create(..., timeout=60.0)   # SDK 默认 600s,太松,收紧

生产里这一层 hang 才是最常见的。SDK 还自带重试 2 次(连接错误/429/5xx),所以这层很多时候 SDK 已经替你兜了。

B. 工具层超时 ------防本地工具(比如真去调外部 HTTP 的 get_weather)卡死。同步工具丢线程池,主线程设闹钟:

python 复制代码
_POOL = ThreadPoolExecutor(max_workers=4)

future = _POOL.submit(DISPATCH[name], args)
result = future.result(timeout=_timeout_for(name))   # 到点抛 TimeoutError

这段等价于 Go 里你最熟的:

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 次,只是把同一个错误慢放三遍,白白拖慢响应。

所以核心原则是:只对"瞬时错误"重试

python 复制代码
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)

注意三个细节:

  1. 参数校验放在 for 循环外------它是确定性错误,放进去重试纯属浪费。
  2. except RETRYABLE_EXC 在前,except Exception 在后 ------只有超时/连接类进重试分支,其余(除零、语法错......)直接 raise,一次就放弃。
  3. 退避(backoff) ------第 n 次失败后睡 n * base 秒,给瞬时故障一点恢复时间。生产里通常还会加 jitter(随机抖动),防止大量请求同时重试踩踏;这里从简没加。

顺带一个真实修过的 bug:except RETRYABLE_EXC 的非超时分支里,能进来的只可能是 ConnectionError ,所以它的错误码该是 connection_error;一开始我顺手写成了 tool_runtime_error,结果和下面那个确定性分支撞了码 ------同一个 tool_runtime_error 一会儿表示"重试过的瞬时错"、一会儿表示"没重试的崩溃",错误码的意义就废了。分类要分干净。

⑤ 日志:每一步都打,但别和"产品输出"混为一谈

把散落的 print 升级成 logging,流程里每一步(请求模型 / 调用工具 / 成功 / 失败 / 重试 / 结束)都打点:

python 复制代码
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",不该混。

三、跑起来:一条真实的日志轨迹

问一句"今天北京天气怎么样?适合穿什么?",日志长这样(模型自己决定先查时间、再查天气、最后给建议):

ini 复制代码
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)

故意注入一个会超时的工具,能看到重试三次后放弃------而注入一个除零(确定性)错误,一次就放弃、零重试:

ini 复制代码
[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。脚本顶部加一行根治:

python 复制代码
sys.stdout.reconfigure(encoding="utf-8")

顺带:logging 默认写 stderr (也是 GBK),所以上面 basicConfig 里我特意把 stream=sys.stdout 指到已经 reconfigure 过的 stdout,免得日志里的中文/emoji 再炸一次。


五、小结:这一圈手撸,到底学到了什么

把五件套连起来看,其实就是把后端的工程素养平移到 Agent 上:

  • 参数校验 = 不信任输入,边界二次校验。
  • 结构化错误 = 错误信封 + 错误码,而不是裸字符串/裸 raise
  • 超时分层 = 区分网络超时和业务超时,各用各的机制。
  • 重试分类 = 先判"瞬时 vs 确定性",只重试值得重试的。
  • 日志分级 = 可观测性,且日志与产品输出分流。

这些 bind_tools / @tool 都替你做了------但只有自己撸过一遍,你才知道它们替你做了什么、边界在哪、出问题时该往哪看。框架是省力工具,不是黑箱借口。

完整代码见仓库 week3/practice/test/test_tool_20.py。运行:python -m week3.practice.test.test_tool_20(从仓库根,以包方式运行);想只看告警以上:LOG_LEVEL=WARNING python -m week3.practice.test.test_tool_20

相关推荐
掘金者阿豪1 小时前
全维度拆解具身智能:底层技术 + 实战落地 + 全球产业竞争
后端
秋天的一阵风1 小时前
✨ 代码秒跳转、自动补全?全靠 LSP 和 AST!
前端·后端·ai编程
用户298698530142 小时前
Java 中的 HTML 解析:从文件读取、URL 抓取到数据提取
java·后端
AskHarries2 小时前
ZJF.AI:简单、稳定、免费的图片托管与外链分享平台
后端
百珏2 小时前
流量没暴涨,网关却挂了:Spring Cloud Gateway 从 500 QPS 优化到 4200 QPS
后端·spring cloud·架构
ICT系统集成阿祥2 小时前
什么是AI ECN?
后端
XovH2 小时前
Redis 从入门到精通:数据结构Hash 与 List
后端
Cache技术分享2 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
XovH2 小时前
Redis 从入门到精通:初识 Redis
后端