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

22 阅读15分钟

把 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/0eval 语法错)
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)

注意三个细节:

  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,流程里每一步(请求模型 / 调用工具 / 成功 / 失败 / 重试 / 结束)都打点:

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)}")