给 Agent 挂工具,最容易被低估的不是提示词,是失败处理。人写的后端代码调一个支付接口,调用方是确定的、次数是确定的、语义是确定的。Agent 不是:它可能把同一个工具在一轮里调两次,可能在超时之后自己再试一遍,可能一次任务跑二十分钟中间重试了四次,而你在日志里只看到一条"天气查询失败"。

这篇讲的是一套能直接抄的写法,分三件事:超时怎么切、重试该给谁、幂等键放哪一层。

先说结论

三条规则,后面展开为什么:

一是超时至少分两层,连接超时和读取超时必须分开设,再在外面套一个总时限。只有一个"超时 30 秒"的参数,等于把"对方没响应"和"对方在慢慢吐数据"混成一件事,排查时完全看不出是哪一头出了问题。

二是只对幂等的方法自动重试。GET 可以随便重试,POST 要看接口语义。第三方接口里大量 POST 其实不幂等(下单、提交工单、发消息),重试一次就是重复一次业务副作用。

三是幂等键不要放在工具内部生成,要由编排层按"这次任务 + 这个工具 + 这组参数"算出来,一路传下去。工具自己每次调用都生成新 key,重试等于没做幂等。

超时切三层

第一层是连接超时(connect timeout),管 TCP 握手和 TLS 协商。这一层超时通常意味着网络不通、DNS 慢、对端挂了,一般设 3 到 5 秒就够,超过这个数大概率不是慢,是断。

第二层是读取超时(read timeout),指连接建立之后,两次收到数据之间的最大间隔。它管的是"对方接了但不回",或者"对方回了一半卡住"。这一层要看接口性质:普通查询接口 10 到 30 秒,流式接口要按 token 间隔算,不能拿总时长当超时。

第三层是总时限(deadline),从发起调用开始算的硬上限,包含重试等待。这一层最容易被忘,但没有它,重试和退避会把单次工具调用拖到几分钟,Agent 那边早就超时放弃了,你这边还在傻等。


import asyncio
import httpx

CONNECT_TIMEOUT = 3.0
READ_TIMEOUT = 12.0
TOTAL_DEADLINE = 20.0

async def call_weather(city: str, client: httpx.AsyncClient) -> dict:
    timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT)
    try:
        async with asyncio.timeout(TOTAL_DEADLINE):
            resp = await client.get("/v1/weather", params={"city": city}, timeout=timeout)
            resp.raise_for_status()
            return resp.json()
    except (asyncio.TimeoutError, httpx.TimeoutException) as exc:
        raise ToolTimeout(city=city, stage="total") from exc

`asyncio.timeout` 是 3.11 起内置的写法,比 `wait_for` 好在不会在取消时把取消信号吞掉。`httpx.Timeout` 支持把 connect / read / write / pool 分开配,这是它比 requests 的单值 timeout 更适合工具层的原因。

一个具体的坑:很多人把 read timeout 设成 30 秒,觉得"网络慢点也能过"。实际效果是,对端真挂死的时候,每个工具调用白等 30 秒,Agent 一轮 5 个工具就是 150 秒,用户那边早就刷新页面了。宁可早失败、快降级,也不要长等。

重试只给幂等的

HTTP 方法里幂等的只有 GET、HEAD、PUT、DELETE、OPTIONS,POST 和 PATCH 默认不幂等。这不是学术定义,直接决定你的重试逻辑:


IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}

def should_retry(method: str, status: int | None, attempt: int) -> bool:
    if attempt >= MAX_ATTEMPTS:
        return False
    if status is None:                    # 网络层异常,连接都没成
        return True
    if status in (429, 502, 503, 504):    # 明确的"过一会儿再来"
        return True
    if 500 <= status < 600:
        return method.upper() in IDEMPOTENT_METHODS
    return False

注意 429 的处理要和别的分开:它带 `Retry-After` 头的时候,等它的值,别自己算退避。对方明确告诉你等 30 秒,你按 2、4、8 秒连撞三次,只会让限流更持久。

退避必须带抖动。多个 Agent 实例同时撞上同一个限流窗口,纯指数退避会让它们同步重试,形成新的尖峰:


import random

def backoff_delay(attempt: int, base: float = 0.8, cap: float = 12.0) -> float:
    raw = min(cap, base * (2 ** attempt))
    return raw * random.uniform(0.5, 1.0)

`uniform(0.5, 1.0)` 是 full jitter 的简化版,实测比"固定值加一点随机"稳。别用 `random.random()` 直接乘,那样抖动下限是 0,会出现几乎零间隔的重试。

幂等键由编排层生成

这一条最容易做错。工具函数里写 `key = str(uuid4())`,看着像做了幂等,其实每次重试都是新 key,服务端看到的是两个不同的请求,该重复照样重复。

正确的切法是:幂等键描述的是"业务意图",不是"这一次调用"。同一轮任务里,同一个工具、同一组参数,算出来必须是同一个 key。


import hashlib
import json

def idempotency_key(task_id: str, tool: str, args: dict) -> str:
    canonical = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    digest = hashlib.sha256(f"{task_id}|{tool}|{canonical}".encode()).hexdigest()
    return f"{task_id[:8]}-{digest[:24]}"

三个细节:`sort_keys=True` 保证参数顺序不影响结果;`separators` 去掉多余空格,避免同一个参数序列化出两种字符串;`ensure_ascii=False` 让中文参数保持原样,否则不同编码路径会算出不同 key。

这个 key 要作为请求头传下去(多数平台用 `Idempotency-Key`),重试时原样带上。服务端第一次处理时存下 key 和响应,key 再来直接返回上次的结果。所以重试发生在"请求已到达但响应丢了"这种最常见的故障上时,业务只执行一次。

还有一层:模型自己会重复调工具。同一个工具在同一个任务里被调两次,参数完全一样,这时候幂等键天然相同,第二次直接命中缓存。这比在提示词里叮嘱"不要重复调用同一个工具"可靠得多。

踩过的几个坑

第一个坑是把总时限设在重试外面但没算退避。`TOTAL_DEADLINE=20`,三次重试的退避合计 0.8+1.6+3.2 秒,加上每次请求本身耗时,实际很容易撞上限,表现是最后一次重试刚发出就被取消——日志里只有一条 timeout,看不出是总时限杀掉的。把退避时间也计入预算,或者干脆让总时限小于"重试次数 × 单次超时"之和时直接减少重试次数。

第二个坑是流式接口用总超时。SSE 和 token 流的特点是首包慢、中间可能长时间没新块。这种情况读超时要比总时限更宽松,靠心跳或者最后一个 token 的时间来判断,不能用"总共 20 秒没结束就杀"。

第三个坑是异常类型吞太多。`except Exception` 会把参数校验错误、鉴权失败也吞进去当网络问题重试,结果是鉴权错重试三次、日志里三条一模一样的 401。要重试的是传输层异常和 5xx,4xx 里的 400、401、403、404 一个都不该重试。

什么场景别这么写

这套写法假设你有能力改调用侧、也有能力要求服务端支持幂等键。如果第三方接口不认 `Idempotency-Key`,那"重试"这件事就只能靠业务侧对账兜着:先查状态再决定补不补,而不是盲重试。这一点在给 Agent 接开放平台时尤其要注意,很多平台的写接口既不幂等、也不提供查询回执,那种场景下正确的做法是不给模型自动重试的权限,失败就上报等人。

另一个边界是延迟敏感的对话型工具。3 秒连接超时、20 秒总时限,对后台任务是合理的,对用户在对话框里等结果的场景就太长。这类工具的正确做法是同步只做快速查,慢的部分转异步任务,把 task_id 返回给模型,后续用查询工具轮询。

工具层的可靠性不靠模型聪明,靠这几点确定性:超时分得清、重试给对人、幂等键算得稳。三件事做完,Agent 的失败率会明显降,日志也终于能看懂了。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐