AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

解析失败、重试与回退:超时不等于失败

我的学习 →

正在读取学习记录…

重试是程序在一次尝试没有得到可用结果后,按事先约定再尝试一次。回退是重试不合适或达到上限时的下一步,例如返回明确错误、查询已有结果或交给人工。上一站的输出校验给了我们“是否可用”的判断,这一篇再决定怎样恢复。

先接住前一站的解析错误:收到不可用的候选,有限次数重新取得回答;达到上限就交人工。再把场景换成内存订单服务,模拟“订单已创建、回执却丢了”。这时重放的已经是写操作,恢复方式必须依赖业务键和服务契约。

1. 从重新生成候选,到重新发送订单

先保存下面完整程序为 candidate_retry.py,运行 python -X utf8 candidate_retry.py。next 依次取准备好的回答,相当于替模型接口准备两张答卷:

Python
# 按固定输入观察解析失败、重试与回退的处理过程和返回结果。
import json

responses = iter(['不是JSON', '{"city":"上海"}'])
for attempt in range(1, 3):
    try:
        value = json.loads(next(responses))
    except ValueError:
        print(f"attempt={attempt} parse_failed")
        continue
    if isinstance(value, dict) and set(value) == {"city"} and value["city"] == "上海":
        print(f"attempt={attempt} accepted city={value['city']}")
        break
    print(f"attempt={attempt} rejected")
else:
    print("needs_review")

预期第1次 parse_failed,第2次 accepted city=上海。把第二个回答也改为 不是JSON,再跑会在两次失败后打印 needs_review。这里尚未调用天气或订单工具;换成真实模型接口时,每次重新生成会消耗请求时间与费用。

正常的订单创建

客户提交订单时,程序给这笔业务操作分配一个稳定的键,例如 checkout-1,并提交商品和数量。服务端创建 order-1,保存“键、参数、结果”的对应关系,再把 order-1 返回给客户。此时只调用一次,客户拿到了回执,流程就结束。下一次买另一件商品应使用新键;同一笔订单因网络问题重新发送时才沿用旧键。

这里的 OrderService 用内存模拟这段正常流程。先理解它保存了什么,再看回执丢失时,客户端为什么不能凭超时推断订单没有创建。

2. 把“失败”拆成不同证据

现象已知事实下一步
JSON 无法解析拿到的文本不符合接口记录格式错误,有限重新生成,校验通过后再调用工具
参数结构不合法字段或类型不满足契约按字段规则重新取参数
读请求暂时超时未拿到响应按接口约定设置次数和等待上限
写请求超时结果未知用稳定幂等键查询或重试;无保证则人工确认
明确的权限拒绝请求不允许停止本次动作,显示需要的授权

对天气摘要,回退可以显示“当前无法确认”;对扣款或下单,未知结果要留下核查入口。就像付款后丢了小票,应查原订单是否已经付过款。退避(backoff)指每次重试前等一会儿;下面程序只把计划等待的0.1、0.2秒记录进列表,方便观察次数。

订单已提交但响应丢失,客户端超时后不能推断写入失败
图1:失去的是响应,不一定是写入。红色问号是客户端的未知状态,不是服务端回滚。

3. 决策顺序:先契约,后重试

幂等是“重复同一业务操作,最终效果与做一次相同”的接口性质。幂等键是调用方提供的稳定操作标识;同一次下单的所有尝试必须沿用同一键和相同参数。加一个键并不自动创造幂等性:服务端还需要保存结果,拒绝同键不同参数,并正确处理并发与持久化。AWS 的重试设计指南也把幂等作为重试的前提之一。

按是否可能产生副作用和是否具备幂等保证选择重试或人工核查
图2:二维决策图。右下角是最危险区域:可能已写入却没有幂等保证。

具体决策先问三件事:本次操作会写入吗?服务契约是否承诺相同 key 与相同参数只产生一次效果?重试预算还剩多少?若三项任一未知,先停止自动写入并查询状态或人工确认。对明确不可重试的结构、权限、业务错误,要让错误向上暴露。生产端还需总截止时间、单请求超时、抖动和全链路共享预算;多个软件层各自重试会放大调用量。

4. 在 PowerShell 复现“已提交但丢响应”

把 retry_lab.py、test_retry_lab.py 按下方代码逐个保存到同一个新文件夹。在资源管理器打开文件夹,地址栏输入 powershell,再运行:

macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。

完整代码:retry_lab.py
Python
"""模拟创建订单后回执丢失,用同一业务编号重试并检查订单数量。"""

from copy import deepcopy
import json
import time


class OrderService:
    """按业务编号保存订单参数和结果,重复收到同一请求时返回已有结果。"""

    def __init__(self):
        self._records = {}
        self.effects = 0

    def create_order(self, operation_key, payload, *, lose_response=False):
        if operation_key in self._records:
            saved_payload, result = self._records[operation_key]
            if payload != saved_payload:
                raise ValueError("operation key reused with different payload")
            return deepcopy(result)

        self.effects += 1
        result = {"order_id": f"order-{self.effects}"}
        self._records[operation_key] = (deepcopy(payload), result)
        # 订单已经创建;响应超时仍需核对业务结果。
        if lose_response:
            raise TimeoutError("order committed, response lost")
        return deepcopy(result)


def retry_operation(operation, *, idempotent, max_attempts=3, sleep=time.sleep):
    """仅重试 TimeoutError;次数包含第一次调用,示例不加入随机抖动。

    idempotent=True 表示调用者已按业务契约确认可安全重试,
    这个标志本身不能让任意操作自动具备幂等性。
    """
    if type(max_attempts) is not int or max_attempts < 1:
        raise ValueError("max_attempts must be a positive integer")
    for attempt in range(1, max_attempts + 1):
        try:
            return operation()
        except TimeoutError:
            if not idempotent:
                return {"status": "manual_review"}
            if attempt == max_attempts:
                raise
            sleep(0.1 * 2 ** (attempt - 1))


def run_demo():
    """四个固定场景;注入等待函数,只记录间隔而不实际等待。"""
    results = []
    for scenario in ("retry-safe", "unsafe", "invalid", "exhausted"):
        service = OrderService()
        attempts = 0
        unsafe_effects = 0
        delays = []
        payload = {"item": "book", "quantity": 1}
        if scenario == "invalid":
            service.create_order("checkout-1", payload)

        def operation():
            nonlocal attempts, unsafe_effects
            attempts += 1
            if scenario == "unsafe":
                unsafe_effects += 1
                raise TimeoutError("non-idempotent order committed, response lost")
            if scenario == "exhausted":
                raise TimeoutError("service unavailable before commit")
            if scenario == "invalid":
                return service.create_order("checkout-1", {"item": "pen", "quantity": 1})
            return service.create_order("checkout-1", payload, lose_response=True)

        try:
            result = retry_operation(operation, idempotent=scenario != "unsafe",
                                     sleep=delays.append)
            status = result.get("status", "succeeded")
        except ValueError:
            status = "rejected"
        except TimeoutError:
            status = "exhausted"
        results.append({"scenario": scenario, "status": status, "attempts": attempts,
                        "effects": service.effects + unsafe_effects,
                        "backoff_seconds": delays})
    return results


if __name__ == "__main__":
    print(json.dumps(run_demo(), indent=2))
完整代码:test_retry_lab.py
Python
# 核对解析失败、重试与回退的正常结果和错误分支;断言失败时会显示对应的测试。
"""Offline tests: python -B -m unittest -v test_retry_lab.py"""

import importlib.util
import unittest
from pathlib import Path


class RetryLabTests(unittest.TestCase):
    def setUp(self):
        path = Path(__file__).with_name("retry_lab.py")
        self.assertTrue(path.is_file(), "retry_lab.py must implement the lab")
        spec = importlib.util.spec_from_file_location("retry_lab", path)
        self.lab = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(self.lab)

    def test_same_key_same_payload_returns_original_order(self):
        service = self.lab.OrderService()
        first = service.create_order("op-1", {"item": "book", "quantity": 1})
        second = service.create_order("op-1", {"quantity": 1, "item": "book"})
        self.assertEqual(first, second)
        self.assertEqual(service.effects, 1)

    def test_conflicting_payload_is_rejected_without_effect(self):
        service = self.lab.OrderService()
        service.create_order("op-1", {"item": "book"})
        with self.assertRaises(ValueError):
            service.create_order("op-1", {"item": "pen"})
        self.assertEqual(service.effects, 1)

    def test_different_keys_create_different_orders(self):
        service = self.lab.OrderService()
        self.assertNotEqual(service.create_order("a", {"item": "book"}),
                            service.create_order("b", {"item": "book"}))
        self.assertEqual(service.effects, 2)

    def test_input_and_output_mutation_cannot_change_saved_record(self):
        service = self.lab.OrderService()
        payload = {"items": ["book"]}
        result = service.create_order("a", payload)
        payload["items"].append("pen")
        result["order_id"] = "tampered"
        self.assertEqual(service.create_order("a", {"items": ["book"]}),
                         {"order_id": "order-1"})
        with self.assertRaises(ValueError):
            service.create_order("a", payload)

    def test_lost_response_after_commit_is_safe_to_retry(self):
        service = self.lab.OrderService()
        attempts = []
        delays = []

        def operation():
            attempts.append(1)
            return service.create_order("a", {"item": "book"}, lose_response=True)

        result = self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
        self.assertEqual(result, {"order_id": "order-1"})
        self.assertEqual(len(attempts), 2)
        self.assertEqual(service.effects, 1)
        self.assertEqual(delays, [0.1])

    def test_unsafe_unknown_outcome_requires_manual_review(self):
        calls, delays = [], []

        def operation():
            calls.append(1)
            raise TimeoutError("response lost; outcome unknown")

        self.assertEqual(self.lab.retry_operation(operation, idempotent=False,
                                                 sleep=delays.append),
                         {"status": "manual_review"})
        self.assertEqual(len(calls), 1)
        self.assertEqual(delays, [])

    def test_non_timeout_error_is_not_retried(self):
        for error_type in (ValueError, RuntimeError):
            with self.subTest(error_type=error_type):
                calls, delays = [], []

                def operation():
                    calls.append(1)
                    raise error_type("rejected")

                with self.assertRaises(error_type):
                    self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
                self.assertEqual(len(calls), 1)
                self.assertEqual(delays, [])

    def test_exhaustion_has_three_attempts_and_two_backoffs(self):
        calls, delays = [], []

        def operation():
            calls.append(1)
            raise TimeoutError("still unavailable")

        with self.assertRaises(TimeoutError):
            self.lab.retry_operation(operation, idempotent=True, sleep=delays.append)
        self.assertEqual(len(calls), 3)
        self.assertEqual(delays, [0.1, 0.2])

    def test_one_attempt_does_not_sleep_after_failure(self):
        delays = []

        def operation():
            raise TimeoutError("unavailable")

        with self.assertRaises(TimeoutError):
            self.lab.retry_operation(operation, idempotent=True, max_attempts=1,
                                     sleep=delays.append)
        self.assertEqual(delays, [])

    def test_success_returns_immediately(self):
        delays = []
        self.assertEqual(self.lab.retry_operation(lambda: "ok", idempotent=False,
                                                 sleep=delays.append), "ok")
        self.assertEqual(delays, [])

    def test_invalid_attempt_budget_is_rejected_before_call(self):
        for budget in (0, -1, 1.5, "3", True, None):
            with self.subTest(budget=budget):
                calls = []
                with self.assertRaises(ValueError):
                    self.lab.retry_operation(lambda: calls.append(1), idempotent=True,
                                             max_attempts=budget, sleep=lambda _: None)
                self.assertEqual(calls, [])

    def test_demo_reports_four_scenarios(self):
        results = self.lab.run_demo()
        self.assertEqual([row["scenario"] for row in results],
                         ["retry-safe", "unsafe", "invalid", "exhausted"])
        self.assertEqual([row["attempts"] for row in results], [2, 1, 1, 3])
        self.assertEqual(results[0]["effects"], 1)
        self.assertEqual(results[1]["status"], "manual_review")
        self.assertEqual(results[2]["status"], "rejected")
        self.assertEqual(results[3]["status"], "exhausted")


if __name__ == "__main__":
    unittest.main()
Bash
ls
python retry_lab.py
python -m unittest -v test_retry_lab.py

找不到 Python 时按 Python 环境篇检查安装;找不到测试模块时运行 Get-ChildItem 检查两个文件是否同目录。预期输出四行场景:retry-safe 尝试 2 次但副作用 1 次;unsafe 尝试 1 次后 manual_review;invalid 拒绝同键不同参数;exhausted 尝试 3 次后停止。测试文件覆盖成功、参数冲突、超时、预算非法等 12 项。

先读 OrderService.create_order:它把 (operation_key, payload) 与结果存入内存,第二次同键同参数直接返回旧结果;lose_response=True 在提交后抛 TimeoutError。再读 retry_operation:只捕获 TimeoutError,且只有调用方声明契约幂等才自动重试。max_attempts=3 包含第一次;最后一次失败后不再等待。无需修改函数默认值,直接运行下面已有的单次尝试测试;它传入 max_attempts=1,检查只调用一次、等待列表为空:

Bash
python -m unittest -v test_retry_lab.RetryLabTests.test_one_attempt_does_not_sleep_after_failure

最后对比 unsafe:代码故意用非幂等操作模拟已经产生一次效果但响应丢失,程序返回 manual_review 而不是再次执行。这个值代表“需要核查”,绝不是业务成功。若你把 idempotent=True 强行用于未知写操作,调用方的一个布尔值不会给下游带来去重能力。

按时间顺序读一次“安全重试”

本地程序的 retry-safe 场景适合暂停下来逐步追踪。下面按时间顺序看内存订单服务里的记录:

时刻调用方看到什么服务端发生什么下一步为什么安全
第 1 次请求前准备键 checkout-1 与原始 payload尚无记录键表示这一次业务操作
第 1 次请求后收到 TimeoutError已写入 order-1,效果计数为 1不能把超时解释成“没有下单”
重试前记录一次退避,沿用同一键同一参数保存了键、参数和结果服务契约明确同键同参返回原结果
第 2 次请求后得到 order-1效果计数仍为 1返回的是首次效果,不是新订单

如果第 2 次把数量从 1 改为 2,下游拒绝同键不同参数;不能悄悄把它视为同一次订单。反过来,如果第 2 次换了一个新键,即使 payload 相同,服务也会把它看成新业务操作。稳定的业务操作 ID 与“每次 HTTP 请求一个新 ID”是不同概念。带写入动作的 Agent 应由确定的业务流程产生这个 ID,而不是让模型自由填写。

两层预算:次数和时间

三次重试预算包含首次请求,仍需总截止时间;应用与 SDK 叠加重试可能把三次放大为九次下游请求
图 3:最多三次尝试包含首次请求,两个间隔也消耗总截止时间。

先用图数一遍:max_attempts=3 包含第一次,所以最多只有两次重试和两段退避。再想最坏情况:如果每次应用尝试内部又由 SDK 发三次,下游可能收到九次请求。限制应用层次数并没有自动关闭 SDK 的重试。

次数上限之外,还要有从任务开始计算的总截止时间:单次请求可等30秒时,即使只试三次也会很慢。核对 SDK 和网关的重试配置,统一谁负责恢复,并记录实际尝试次数。随机抖动(jitter)是在计划等待时间上加入随机量,缓和大量客户端同时重试;它属于真实调用器配置,随文程序未实现。

亲手改坏一次,再恢复

在学习副本的 run_demo 中找到 operation() 最后的这一行:

Python
return service.create_order("checkout-1", payload, lose_response=True)

先只替换为下列两行,再运行主程序:

Python
key = "checkout-2" if attempts == 2 else "checkout-1"
return service.create_order(key, payload, lose_response=True)

retry-safe 应变成 attempts=3, status=succeeded, effects=2。第2次用了新键,创建第二笔订单且同样丢回执;第3次回到旧键,查回首笔订单。因此“最终成功”掩盖了重复效果。

随后把这两行替换成下面两行,保持原键,只在第2次改数量:

Python
changed = {**payload, "quantity": 2} if attempts == 2 else payload
return service.create_order("checkout-1", changed, lose_response=True)

retry-safe 应变成 attempts=2, status=rejected, effects=1:同键不同参数被拒绝。两轮都只改最后的调用,其他分支不动。恢复原行,再运行12项测试。

5. 回执丢失时还需要确认什么

真实服务的持久化、并发与取消

OrderService 是单进程、顺序执行的内存替身。进程重启会丢记录,并发写入没有锁;它不具备事务、持久化、跨实例去重或 exactly-once 保证。真正的 Agent 还要区分:模型输出解析错时不要触发工具;工具请求发出后超时时先查服务状态;用户取消时停止新尝试,但仍核查已经发出的写入。接入真实服务时,按供应商契约补持久化与故障恢复测试。

自查与下一站

超时只说明客户端没有及时收到响应。重试写操作前,先确认服务端怎样识别“同一笔业务”;同键同参可以查回原结果,换新键可能重复创建订单。解释为什么 retry-safe 可返回原结果,而 unsafe 要转人工核对。

上一站:约束生成。下一站:SSE、WebSocket 与流式响应。

现有练习

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“解析失败、重试与回退”,哪种做法更稳妥?

对应知识点:L1-retry

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L1-retry

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L1-retry

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L1-retry

资料

参考资料:AWS Builders' Library:超时、重试与抖动、AWS 重试退避模式、Python TimeoutError。

图片查看器
100%

文章配图 · 按 Esc 关闭