从 async/await 到幂等恢复:把 Python 并发和 Agent Runtime 一次讲清楚

第一次写出 async def 的时候,我其实有一种很强的错觉:只要把同步函数改成异步函数,再在耗时操作前面加上 await,程序就算"会并发"了。

后来真正把异步代码放进任务执行服务、Agent Runtime(Agent 运行时)和多 Worker 系统里,这个想法很快就被打碎了。

一个请求被拆成三个 Tool Call(工具调用),它们到底应该并发还是串行?两个协程同时修改同一个状态,明明 Python 一次只跑一个协程,为什么还是会出问题?任务已经 cancel() 了,为什么远端接口还在继续执行?数据库里明明写着 RUNNING,进程重启以后为什么不能直接把它重新跑一遍?RabbitMQ 已经 ACK 了,为什么系统仍然要做幂等?

这些问题看起来跨了 Python、操作系统、消息队列和分布式系统好几个领域,但它们其实都在回答同一件事:

当系统同时推进很多任务,而且任何一步都可能等待、失败、超时、重复执行甚至进程崩溃时,我们怎么保证状态仍然是可信的?

这篇文章不打算把"进程、线程、协程、锁、队列、幂等"拆成一堆孤立的名词。更有用的方式,是沿着一个真实系统逐层往外扩:先理解单机里的并发,再看共享状态怎么保护,最后再走到多进程、消息队列、幂等和恢复。


一、先把最容易混在一起的几件事说清楚:并发、并行、进程、线程和协程

很多并发问题之所以越学越乱,不是因为概念本身有多难,而是因为我们经常把不同层级的东西放在一起比较。

先从两个最基础的词开始。

**并发(Concurrency,并发)**描述的是"同一段时间内有多个任务在推进"。它强调的是调度能力,不要求这些任务在同一个瞬间真的一起执行。

**并行(Parallelism,并行)**描述的是"同一个时刻有多个任务真正执行"。它强调的是计算资源,通常需要多个 CPU 核心、多个 GPU 执行单元或者多台机器。

一个人一边烧水、一边切菜,水没开的时候去切菜,这是并发;三个人同时烧水、切菜、炒菜,这是并行。

text 复制代码
并发:

任务 A:运行 ── 等待 ───── 运行 ── 完成
任务 B:──── 运行 ── 等待 ───── 完成

并行:

CPU 1:任务 A ───────────────── 完成
CPU 2:任务 B ───────────────── 完成

这两个词区分清楚以后,再看进程、线程和协程就容易很多。

进程解决的是"隔离"和"真正利用多核"

一个进程可以理解为一个独立运行的程序实例。不同进程拥有彼此隔离的地址空间,所以进程 A 中的普通 Python 字典、锁、Task,都不会自动出现在进程 B 中。

text 复制代码
进程 A                          进程 B
├── globals                    ├── globals
├── cache                      ├── cache
├── asyncio event loop         ├── asyncio event loop
└── running tasks              └── running tasks

这件事非常重要,因为它意味着:

python 复制代码
running_requests[request_id] = task

这种代码只在当前进程里有意义。

如果服务启动了 4 个 Worker,实际上就有 4 份 running_requests。Worker A 知道请求 R1 正在执行,并不代表 Worker B 也知道。

进程的好处是边界清晰,而且对于 CPU 密集型任务,可以比较直接地利用多核;代价则是创建和切换成本更高,进程之间不能像线程那样随手共享对象。

线程解决的是"共享进程内存中的执行"

同一个进程里的多个线程共享堆内存、全局变量和大部分进程资源,但每个线程有自己的调用栈和执行位置。

共享内存让线程之间交换数据很方便,但也带来了经典的竞态条件(Race Condition,竞态条件)。

例如:

python 复制代码
balance = balance - 100

看起来只有一行,但从逻辑上可能经历:读取旧值、计算新值、写回新值。两个线程交错执行时,就可能都读到同一个旧值,然后互相覆盖结果。

这就是为什么线程里经常需要 LockRLockSemaphore 等同步原语。

说到 Python 线程,还有一句很容易被说得过头的话:

"Python 多线程不能并行。"

更准确的说法是:在常规 CPython 构建中,GIL(Global Interpreter Lock,全局解释器锁)使得同一时刻通常只有一个线程执行 Python 字节码,所以纯 Python 的 CPU 密集型任务很难靠线程获得线性多核加速。

但线程在网络、磁盘、数据库等阻塞 I/O 场景里仍然非常有价值。

从 Python 3.13 开始,CPython 已经提供 free-threaded build(自由线程构建),可以在运行时禁用 GIL;Python 3.14 又继续完善了这条路线。

但这并不等于"以后再也不用锁了"。

即使解释器允许多个线程同时执行,业务层的多步读---改---写仍然可能发生竞态;第三方 C 扩展是否完全兼容自由线程,也需要单独确认。

协程解决的是"等待期间别浪费执行机会"

如果线程的调度主要由操作系统负责,那么 asyncio 里的协程更像是一种用户态的协作式并发

一个 Event Loop(事件循环)不断运行当前可以执行的 Task(任务)。某个 Task 遇到 await,而等待的操作还没有完成时,它就把执行权交回 Event Loop;Event Loop 再去推进其他 Task。

python 复制代码
async def query_user():
    return await http_client.get("/user")


async def query_orders():
    return await http_client.get("/orders")


async def main():
    user_task = asyncio.create_task(query_user())
    order_task = asyncio.create_task(query_orders())

    user = await user_task
    orders = await order_task

这里并不是开了两个 CPU 核心同时执行 Python 代码,而是两个 HTTP 请求都存在大量"等网络"的时间。

事件循环在其中一个请求等待时去推进另一个请求,所以总体时间被压缩了。

这也是理解异步最关键的一句话:

协程提高的不是单个任务的计算速度,而是系统利用等待时间的能力。

因此,async def 里面写阻塞代码仍然会卡住整个事件循环:

python 复制代码
async def bad_example():
    time.sleep(5)

应该改成:

python 复制代码
await asyncio.sleep(5)

如果调用的是一个只能同步执行的 SDK,可以暂时把它放进线程池:

python 复制代码
result = await asyncio.to_thread(blocking_function)

如果是纯 Python 的重 CPU 计算,则更适合进程池或独立计算服务。

到这里其实已经能得到一个很实用的判断:

I/O 密集看协程或线程,CPU 密集看进程,多服务实例看多进程或容器;它们不是三选一,而是经常一起出现。

一个典型的 Agent 服务完全可能是:

text 复制代码
Kubernetes / 多进程
        ↓
每个进程一个 Event Loop
        ↓
大量 asyncio Task
        ↓
HTTP / DB / LLM / Tool Call
        ↓
必要时进入线程池或进程池

真正复杂的地方,也从这里开始。


二、asyncio 真正在调度什么:Coroutine、Task、Future,以及为什么 await 不只是"等一下"

很多人会写 asyncio,但对 Coroutine(协程对象)、Task(任务)和 Future(未来结果)的关系仍然比较模糊。

代码一复杂,这种模糊就会直接变成生命周期管理问题。

调用一个 async def

python 复制代码
coro = query_user()

得到的是 coroutine object(协程对象)。

此时它只是"可以被执行的一段异步计算",通常还没有真正独立调度起来。

直接:

python 复制代码
result = await coro

表示当前协程等待它执行完成。

而:

python 复制代码
task = asyncio.create_task(coro)

则是把这个协程包装成 Task,并交给 Event Loop 调度。

Task 可以运行、暂停、再次恢复、正常完成、异常结束或者被取消。

Future 更底层一些,它表达的是:

某个结果现在还没有,但未来某个时刻会完成。

Task 本身具有 Future 的语义,所以你可以:

python 复制代码
result = await task

最终拿到返回结果。

如果 Task 内部异常退出,异常也会在 await 时重新抛出。

这也解释了一个很常见的问题:

python 复制代码
done, pending = await asyncio.wait(tasks)

既然 Task 已经在 done 里了,为什么还需要:

python 复制代码
result = await task

因为 done 只说明 Task 已经进入终态,并不代表"业务结果已经自动被你取出来"。

await task 会把 Task 内保存的结果取出来;如果 Task 是异常结束,也会在这里重新抛出异常。

create_task() 只是开始,不是并发控制

最容易写出来的异步并发代码大概是这样:

python 复制代码
tasks = [
    asyncio.create_task(call_model(item))
    for item in items
]

results = await asyncio.gather(*tasks)

几十个任务时可能没问题。

但如果 items 突然变成十万条呢?

程序可能在很短时间里:

  • 创建十万个 Task;
  • 同时占用大量内存;
  • 向数据库发起海量请求;
  • 把模型 API 的并发限制直接打满;
  • 把 HTTP 连接池和数据库连接池耗尽。

这时你会发现:

异步只是让"等待"变便宜了,并没有让下游资源变成无限。

所以生产系统真正需要的是 Bounded Concurrency(有界并发)。

最简单的方式之一是 Semaphore(信号量):

python 复制代码
model_slots = asyncio.Semaphore(8)


async def call_model_safely(prompt):
    async with model_slots:
        return await call_model(prompt)

这里的 8 不是:

Python 最多只能运行 8 个协程。

而是:

业务最多允许 8 个模型请求同时处于 in-flight(在途)状态。

数据库连接、浏览器实例、MCP Tool、GPU 推理任务,都可以有类似的资源预算。

对于生产者不断生成任务、消费者逐渐处理的场景,则更适合有界 Queue:

python 复制代码
queue = asyncio.Queue(maxsize=100)


async def producer(items):
    for item in items:
        await queue.put(item)


async def worker():
    while True:
        item = await queue.get()

        try:
            await process(item)
        finally:
            queue.task_done()

队列满以后:

python 复制代码
await queue.put(item)

不会继续无脑塞任务,而是暂时等待消费者腾出空间。

这就是 Backpressure(反压)。

它的核心思想不是:

"我要尽可能快地把任务创建出来。"

而是:

"上游的生产速度必须服从下游真实的处理能力。"

TaskGroup 的意义不是语法更漂亮,而是给并发任务划生命周期边界

假设一次订单查询需要同时执行三个请求:

python 复制代码
async def query_order_view(order_id):
    async with asyncio.TaskGroup() as tg:
        status_task = tg.create_task(query_status(order_id))
        route_task = tg.create_task(query_route(order_id))
        eta_task = tg.create_task(query_eta(order_id))

    return {
        "status": status_task.result(),
        "route": route_task.result(),
        "eta": eta_task.result(),
    }

TaskGroup 最重要的地方不是少写了几行代码。

真正重要的是:

这些 Task 明确属于同一个父作用域。

当上下文结束时,组内任务会被统一等待和收尾。

如果其中一个子任务以非取消异常失败,其余还没有完成的任务也会进入取消和收尾流程。

这就是 Structured Concurrency(结构化并发)的核心思想:

子任务应该拥有明确的父任务和生命周期,而不是随手 create_task() 后就扔在事件循环里。

对 Agent Runtime 来说,这件事尤其重要。

一次用户请求可能拆出:

text 复制代码
查询用户信息
查询订单
查询物流
检索知识库
调用模型

如果整个用户请求已经失败或者用户已经取消,就不应该继续留下一堆无人管理的后台 Task。

但即使 Task 生命周期管理得很好,并发仍然没有真正安全。

因为接下来面对的是共享状态。


三、并发真正难的不是"同时跑",而是共享状态、资源上限和取消语义

在单线程 asyncio 中,一个很有迷惑性的说法是:

"Event Loop 一次只执行一个 Task,所以协程之间不需要锁。"

这句话只对了一半。

如果一段代码从开始到结束完全没有让出执行权,那么同一个事件循环线程里的其他 Task 确实插不进来。

但只要中间出现一个可能挂起的 await,另一条协程就可能在这段时间里修改共享状态。

例如:

python 复制代码
if key not in cache:
    value = await load_from_database(key)
    cache[key] = value

假设此时来了两个协程 A 和 B。

A:

text 复制代码
检查 cache
↓
发现 key 不存在
↓
await 数据库

A 等数据库期间让出执行权。

于是 B 进来:

text 复制代码
检查 cache
↓
仍然不存在
↓
也 await 数据库

最终两个请求都会打到数据库。

如果这里只是查缓存,最多浪费一次 I/O。

但如果逻辑变成:

text 复制代码
检查订单不存在
↓
await 创建订单
↓
记录订单

重复执行就可能产生真实业务副作用。

所以 Lock(锁)解决的不是"多个 CPU 同时写"这么简单。

它真正解决的是:

某一组业务操作必须被看成一个不能任意交错的 Critical Section(临界区)。

例如:

python 复制代码
async with lock:
    if key not in cache:
        cache[key] = await load_from_database(key)

不过,锁也不能乱加。

这个写法虽然避免了重复加载,但数据库请求期间锁一直被占着。

如果数据库查询用了 3 秒,其他完全无关的 key 也可能一起等 3 秒。

因此更合理的设计通常会进一步缩小锁粒度:

python 复制代码
locks: dict[str, asyncio.Lock] = {}


def get_lock(key: str) -> asyncio.Lock:
    return locks.setdefault(key, asyncio.Lock())


async def get_or_load(key: str):
    async with get_lock(key):
        if key in cache:
            return cache[key]

        value = await load_from_database(key)
        cache[key] = value
        return value

这样:

text 复制代码
user:1

和:

text 复制代码
user:2

可以同时加载。

只有访问同一个 key 的请求才互斥。

如果一个热点 key 同时来了 100 个请求,更进一步的做法甚至不是让 100 个请求依次拿锁,而是:

text 复制代码
第一个请求真正执行 I/O
        ↓
其余 99 个请求等待同一个 Future

这类模式常被称为 Singleflight(请求合并)。

它解决的是:

相同工作没有必要重复执行。

所以遇到并发问题时,我现在更习惯先问三个问题:

  1. 哪一段业务逻辑必须具有原子性?
  2. 共享状态最小可以缩到 request、task、key 还是 resource?
  3. 我需要的到底是互斥、限流,还是合并重复工作?

Lock、Semaphore、Event 解决的是三个不同的问题

它们经常都被放在"并发控制"下面,但实际含义完全不同。

Lock 回答:

同一时刻谁可以进入临界区?

Semaphore 回答:

同一时刻最多允许多少个执行者占用某类资源?

Event 回答:

某个状态发生变化后,怎样通知所有等待者?

例如:

python 复制代码
stop_event = asyncio.Event()


async def monitor():
    await stop_event.wait()
    print("停止接收新任务")


stop_event.set()

set() 会把 Event 内部状态改为真,并唤醒等待它的协程。

但它并不会自动取消已经运行的 Task。

真正取消 Task,通常需要:

python 复制代码
task.cancel()

取消不是"一脚刹车"

asyncio 的 Cancellation(取消)是协作式的。

task.cancel() 并不是把协程瞬间从内存里删除,而是在合适的挂起点向 Task 注入 CancelledError

所以任务仍然应该正确释放资源:

python 复制代码
async def worker():
    try:
        await long_operation()
    finally:
        await release_resources()

这里有一个对于 Agent 和任务服务非常关键的问题:

取消本地 Task,不等于撤销远端副作用。

假设:

python 复制代码
await create_order()

HTTP 请求已经发到了订单服务。

此时用户点击取消,本地执行:

python 复制代码
task.cancel()

你最多只能知道:

本地不准备继续等待这次请求。

你并不能由此推断:

远端订单一定没有创建。

Timeout(超时)也是同样的道理:

python 复制代码
async with asyncio.timeout(3):
    result = await create_order()

三秒到了,只能证明:

客户端三秒内没有得到结果。

远端实际上可能:

text 复制代码
情况 1:根本没执行
情况 2:执行失败
情况 3:已经成功,只是响应没有回来

所以 Timeout(超时)、Cancellation(取消)和 Retry(重试)绝对不能当成一回事。

在单进程中,我们至少还能依赖内存里的 Task、Lock、Event。

一旦系统扩展到多个 Worker,这套假设就彻底变了。


四、从单进程走到多 Worker:内存不再是真相,数据库和消息系统开始接管任务生命周期

假设一个请求进入服务以后,我们在内存保存:

python 复制代码
running_requests[request_id] = task

同一个进程再次收到 R1,可以找到对应 Task。

用户要求取消时,也可以:

python 复制代码
running_requests["R1"].cancel()

这在单进程里没有问题。

但如果我们启动三个 Worker:

text 复制代码
Worker A
running_requests = {
    R1: Task(...)
}

Worker B
running_requests = {}

Worker C
running_requests = {}

R1 实际运行在 Worker A。

下一次 HTTP 请求却可能被负载均衡器分给 Worker B。

B 的内存里没有这个 Task。

数据库即使告诉 B:

text 复制代码
R1 = RUNNING

B 仍然无法:

python 复制代码
await worker_a_task

更无法:

python 复制代码
worker_a_task.cancel()

因为那个 Task 根本不存在于自己的地址空间。

因此,服务一旦进入多进程,就必须明确区分:

text 复制代码
"系统事实"

和:

text 复制代码
"当前进程的运行时对象"

一个比较清楚的职责划分是:

text 复制代码
Worker Local Memory
│
├── asyncio Task
├── Future
├── Lock
└── 当前 Event Loop 状态


Database
│
├── request_id
├── task_id
├── status
├── result
├── attempt
├── worker_id
├── lease
└── idempotency_key


Message Queue / Redis
│
├── 待执行任务
├── 完成通知
├── 取消通知
└── Worker 间消息

不要试图把 coroutine、Task、Future、数据库连接序列化以后塞进 Redis,再让另一个进程继续运行。

跨进程真正需要共享的是:

任务身份和可持久化状态,而不是运行时对象。

数据库写着 RUNNING,也不代表 Worker 真的还活着

假设数据库中存在:

text 复制代码
task_id   = T17
status    = RUNNING
worker_id = worker-3

这只能证明:

worker-3 某个时刻领取过这个任务。

它不能证明:

worker-3 现在仍然正常运行。

Worker 可能已经:

text 复制代码
进程崩溃
网络隔离
机器宕机
容器被重建
卡在某个系统调用

所以持久化任务系统通常还会增加 Lease(租约):

text 复制代码
status           = RUNNING
worker_id        = worker-3
lease_expires_at = 12:00:30
attempt          = 2

Worker 执行期间不断续租。

恢复器发现:

text 复制代码
lease_expires_at < now

才能判断:

原 Worker 很可能已经失去执行能力。

然后考虑重新分配任务。

注意,这仍然不是绝对安全。

因为旧 Worker 可能只是暂时卡住。

它恢复以后,新 Worker 已经重新领取 T17。

于是:

text 复制代码
Worker A:旧执行者
Worker B:新执行者

可能同时修改结果。

更严格的系统会再使用 Fencing Token(栅栏令牌):

text 复制代码
旧 Worker:token = 41
新 Worker:token = 42

下游只接受最新版本的写入。

这样即使旧 Worker 恢复:

text 复制代码
token = 41

也不能覆盖:

text 复制代码
token = 42

产生的新结果。

消息队列只是负责"把工作送过去"

任务数量继续增长以后,Scheduler(调度器)没必要自己不停寻找空闲 Worker。

更自然的结构是:

text 复制代码
Scheduler
    ↓
Message Queue
    ↓
Worker 1
Worker 2
Worker 3

消息队列负责:

  • 生产者和消费者解耦;
  • 高峰流量缓冲;
  • Worker 间负载分配;
  • 消费者失败后的重新投递;
  • 不同任务类型的路由。

但有一点非常重要:

消息队列不负责保证你的业务副作用只执行一次。

以 RabbitMQ 手动 ACK 为例。

假设:

text 复制代码
1. Worker 收到消息 M
2. 成功给用户增加 100 积分
3. 还没 ACK
4. Worker 崩溃

RabbitMQ 无法知道:

你的积分业务到底执行成功没有。

它只知道:

这条消息还没有收到 ACK。

所以消息可能再次投递。

新的 Worker 又执行:

python 复制代码
await points_service.add(user_id, 100)
await message.ack()

用户就可能再多 100 积分。

因此 At-least-once Delivery(至少一次投递)实际上意味着:

系统为了避免消息静默丢失,允许同一消息被重复交付。

它并不意味着:

业务逻辑天然只会执行一次。

于是我们终于走到了并发系统最难处理的部分:

重复执行。


五、幂等和恢复:真正难的不是"失败后再试一次",而是不知道上一次到底做没做成

如果一个操作只是读数据,失败以后 Retry(重试)通常比较简单。

例如:

python 复制代码
await query_weather()

超时以后再查询一次,大多数情况下不会改变外部世界。

但下面这些操作完全不同:

text 复制代码
创建订单
扣款
发送短信
发送邮件
修改文件
启动云服务器
提交审批

它们都有 Side Effect(副作用)。

这时判断"是否可以重试",最实用的问题不是:

这个异常是不是 TimeoutError?

而是:

同一次业务意图执行两次,结果会不会和执行一次不同?

最麻烦的是"结果未知"

考虑:

python 复制代码
result = await remote_service.create_order()
await local_db.save_result(result)

假设发生:

text 复制代码
create_order()
      ↓
远端创建订单成功
      ↓
Worker Crash
      ↓
save_result() 没执行

进程重新启动以后,本地只能看到:

text 复制代码
任务没有 SUCCESS
本地没有 order_id

但这并不能推出:

创建订单失败了。

因为远端可能已经成功。

如果 Recovery Worker(恢复器)直接重新调用:

python 复制代码
create_order()

可能创建第二个订单。

如果它完全不重试,也可能让一个实际失败的任务永远卡住。

所以可靠系统通常必须接受一个状态:

text 复制代码
UNKNOWN

或者:

text 复制代码
UNCERTAIN

含义是:

这次调用发生过,但目前无法仅凭本地状态确认远端最终结果。

很多系统设计里不喜欢 UNKNOWN,因为它看起来"不够确定"。

实际上它恰恰非常重要。

一个真实的"不知道",通常比一个错误的:

text 复制代码
FAILED

更安全。

幂等键给"一次业务意图"一个稳定身份

对于有副作用的操作,更可靠的设计是生成稳定的 Idempotency Key(幂等键)。

例如:

text 复制代码
create-order:{operation_id}

第一次请求:

json 复制代码
{
  "idempotency_key": "create-order-8f31",
  "sku": "A100",
  "quantity": 2
}

如果请求超时,第二次 Retry 仍然使用:

text 复制代码
create-order-8f31

而不是重新生成:

python 复制代码
uuid.uuid4()

否则服务端看到的就是两个完全不同的业务请求。

真正产生副作用的一方应该记录:

text 复制代码
idempotency_key
        ↓
request parameters
        ↓
execution status
        ↓
resource_id / response

第二次收到相同 key 时:

text 复制代码
key 已存在
    ↓
检查请求参数
    ↓
相同参数
    ↓
返回第一次结果

而不是再次创建资源。

这里还有一个很重要的细节。

第一次:

json 复制代码
{
  "key": "pay-1001",
  "amount": 100
}

第二次:

json 复制代码
{
  "key": "pay-1001",
  "amount": 1000
}

这种情况下不能简单认为:

"key 一样,所以这是同一次请求。"

更合理的处理是 Parameter Mismatch(参数冲突)。

因为调用方实际上可能错误地把两次不同业务意图使用了同一个 key。

所以真正的幂等记录更像:

text 复制代码
idempotency_key
request_hash
status
resource_id
response
created_at
expires_at

而不是:

text 复制代码
key -> true

换句话说:

幂等不是一个布尔值,而是一份"业务意图和执行结果之间的契约"。

幂等最好落在真正拥有副作用的服务里

假设 Agent Runtime 本地做了:

text 复制代码
if idempotency_key not in redis:
    call_payment_service()

这仍然不能完全解决:

text 复制代码
Payment Service 扣款成功
        ↓
Runtime 还没写 Redis
        ↓
Runtime Crash

因为最危险的时间窗口仍然存在。

所以最可靠的位置通常是:

副作用由谁真正产生,幂等就尽量由谁保证。

支付服务负责扣款,那么支付服务应该支持支付幂等。

订单服务负责创建订单,那么订单服务应该支持创建订单的幂等键。

如果远端服务无法提供幂等能力,至少应该提供:

text 复制代码
query_by_business_id()

让恢复系统能够 Reconcile(对账 / 确认)。

而不是超时以后直接再执行一次。

Outbox 解决的是另一个常见的故障窗口

考虑:

python 复制代码
await db.mark_order_paid(order_id)
await mq.publish(
    "order.paid",
    {"order_id": order_id}
)

数据库提交成功以后、MQ publish 之前:

text 复制代码
Worker Crash

会出现:

text 复制代码
Database:PAID
Message Queue:没有 order.paid

下游永远不知道订单已经支付。

把两句反过来一样有问题:

python 复制代码
await mq.publish(...)
await db.mark_order_paid(order_id)

消息发出成功,数据库却回滚:

text 复制代码
Message Queue:order.paid
Database:仍然 UNPAID

这就是典型的 Dual Write(双写)问题。

Transactional Outbox(事务性发件箱)会把:

text 复制代码
订单状态更新

和:

text 复制代码
待发送事件

放在同一个数据库事务里。

text 复制代码
BEGIN

UPDATE orders
SET status = 'PAID'

INSERT INTO outbox(
    event_id,
    type,
    payload
)

COMMIT

之后单独的 Publisher:

text 复制代码
扫描 Outbox
    ↓
Publish Message
    ↓
标记 sent

这样:

text 复制代码
业务状态

和:

text 复制代码
需要发送的事件

至少会一起提交或一起回滚。

不过 Publisher 仍然可能重复发布。

因此 Consumer(消费者)仍然需要幂等。

Outbox 并没有神奇地制造 Exactly Once(恰好一次)。

它只是把一个无法观察的双写故障窗口,变成一个:

可持久化、可重试、可恢复的状态机。

持久化不等于自动恢复

假设系统已经保存:

text 复制代码
request.started
task.started
tool.called
tool.succeeded
task.succeeded

这些事件非常重要。

但是进程重启以后,数据库并不会自动替你重新创建:

python 复制代码
asyncio.Task

真正的 Recovery Worker 还需要:

text 复制代码
扫描所有非终态任务
       ↓
恢复任务 DAG
       ↓
保留已经 SUCCESS 的节点
       ↓
检查 RUNNING 节点租约
       ↓
判断任务有没有副作用
       ↓
决定 Retry / Query Remote / Manual
       ↓
重新调度

对于遗留任务:

text 复制代码
RUNNING

更合理的恢复逻辑是:

text 复制代码
                     RUNNING
                        │
          ┌─────────────┼──────────────┐
          │             │              │
        只读          幂等写         非幂等副作用
          │             │              │
        Retry       Retry / Query    Query Remote
                                        │
                           ┌────────────┼────────────┐
                           │            │            │
                         已成功       未执行        无法确认
                           │            │            │
                        SUCCESS     RETRY_WAIT     UNKNOWN

到这里以后再回头看,会发现:

Lock、Queue、Lease、ACK、Idempotency、Recovery 并不是几个突然冒出来的分布式系统术语。

它们其实都围绕着一件事:

怎么管理一个任务从创建到最终结束的完整生命周期。


六、把前面的东西放进 Agent:LLM 负责决策,Harness / Runtime 负责让它真的可靠执行

Agent 是一个非常适合观察这些问题的场景。

因为 Agent 天然会把一个用户目标拆成多个子任务,而这些任务既可能有查询,也可能有真实副作用。

例如用户说:

查询订单当前状态、物流位置和预计送达时间;如果预计延迟超过一天,给用户发送提醒。

Planner(规划器)可能得到:

text 复制代码
T1 查询订单状态 ─────┐
                     │
T2 查询物流位置 ─────┼── T4 判断是否延迟 ── T5 发送提醒
                     │
T3 查询预计到达时间 ─┘

这个 DAG 看起来很简单,但整篇文章讲到的问题其实全部出现了。

T1、T2、T3 为什么可以并发

三个任务没有相互依赖:

text 复制代码
T1 ─┐
T2 ─┼─> T4
T3 ─┘

所以可以一起调度。

它们主要等待:

text 复制代码
订单数据库
物流 API
ETA 服务

因此非常适合 asyncio。

Runtime 可以:

python 复制代码
async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(query_status())
    t2 = tg.create_task(query_location())
    t3 = tg.create_task(query_eta())

但是"可以并发"不等于"无限并发"。

如果物流 API 只允许 20 QPS:

python 复制代码
logistics_slots = asyncio.Semaphore(20)

如果模型服务只允许 8 个请求:

python 复制代码
llm_slots = asyncio.Semaphore(8)

所以 Planner 决定:

哪些任务逻辑上可以同时做。

Runtime 决定:

系统实际上允许同时做多少。

这两个职责不是一回事。

T4 为什么不能提前执行

T4:

text 复制代码
判断是否延迟

依赖 T3 的 ETA。

如果 Planner 已经明确:

text 复制代码
T3 -> T4

Runtime 就不能把所有 Task 一次性:

python 复制代码
asyncio.create_task(...)

然后谁先跑完算谁。

它必须维护 Dependency Constraint(依赖约束)。

所以 Agent Runtime 本质上已经开始像一个小型 Workflow Engine(工作流引擎)。

T5 为什么和前面四个任务不一样

T5:

text 复制代码
发送提醒

会改变外部世界。

它有 Side Effect(副作用)。

因此它应该拥有稳定的 Idempotency Key,例如:

text 复制代码
notify:{order_id}:{delay_event_id}

假设:

text 复制代码
短信已经成功发送
        ↓
Worker 在 ACK 前崩溃
        ↓
Message Redelivery

第二次执行应该继续携带同一个幂等键。

否则用户可能收到两条相同提醒。

用户取消以后发生什么

假设用户此时点了:

text 复制代码
Cancel

Runtime 可以:

text 复制代码
停止调度尚未开始的节点
取消仍在运行的本地 Task
写入 request.cancelled
通知其他 Worker

但是如果 T5 已经向短信平台发送 HTTP 请求:

text 复制代码
task.cancel()

不等于:

text 复制代码
短信撤回

所以 Runtime 还必须记录:

text 复制代码
Tool Call 到底有没有发出去
是否已经得到结果
是不是具有副作用
能不能查询远端状态

Worker 崩溃以后怎么办

新的 Worker 没办法恢复:

python 复制代码
old_asyncio_task

它只能从持久化状态恢复:

text 复制代码
request_id
task_id
dependency_ids
status
attempt
tool_name
tool_arguments
idempotency_key
result

然后重新构建 DAG。

已经 SUCCESS 的 T1、T2:

text 复制代码
不再重复执行

租约过期的 T3:

text 复制代码
可以重新调度

结果未知的 T5:

text 复制代码
不能直接再发一次通知

而应该先查询或根据幂等键确认。

这时候就能看清 Agent 里 LLM 和 Harness / Runtime 真正的职责边界。

text 复制代码
LLM / Planner

负责:
用户想做什么
下一步做什么
调用哪个 Tool
Tool 参数是什么


Harness / Runtime

负责:
任务依赖
并发调度
并发上限
Tool 参数校验
权限控制
超时
取消
重试
幂等
状态持久化
Worker 协调
故障恢复
Trace

也就是说:

模型负责决定"想怎么做",Harness 负责保证"这件事能不能安全地这么做"。

这也是为什么一个 Agent 从 Demo 走向产品以后,代码复杂度经常并不主要增长在 Prompt 上,而是增长在 Runtime 上。

Trace 不是为了看"模型内心活动",而是为了还原执行事实

如果生产环境只留下一句:

text 复制代码
task failed

基本没有定位价值。

至少应该记录:

text 复制代码
request_id
session_id
task_id
parent_task_id
attempt
worker_id

planner_step

tool_call_id
tool_name
tool_arguments

idempotency_key

started_at
finished_at

status
error_type
retry_reason

这样才能回答:

text 复制代码
为什么同一个 Tool 调用了两次?
第二次是 Planner 主动决定,还是 Runtime Retry?
哪个 Worker 执行了这次 Tool Call?
用户取消后为什么还有一个 Tool 完成了?
重复副作用有没有复用同一个幂等键?
最终结果来自第几个 attempt?

所以 Trace / Observability(轨迹追踪 / 可观测性)真正重要的不是把所有模型输出都存下来。

更重要的是:

让我们能够重新构建系统到底发生了什么。

并发正确性必须靠故障注入测

最后还有一件特别容易忽略的事情。

正常路径测试:

text 复制代码
T1 SUCCESS
T2 SUCCESS
T3 SUCCESS
T4 SUCCESS
T5 SUCCESS

对于并发系统其实价值很有限。

真正应该主动制造的是:

text 复制代码
两个相同 Request 同时到达

Worker 在副作用成功后、落库前崩溃

数据库写成功后、ACK 前崩溃

Lease 刚过期时旧 Worker 恢复

Tool 本地 Timeout,但远端实际执行成功

用户 Cancel 时 Task 正停在 await

同一 MQ Message 重复投递

相同 Idempotency Key 携带不同参数

TaskGroup 某一个 Child Failure

Downstream API 突然变慢

然后去验证:

text 复制代码
No Duplicate Side Effect
(无重复副作用)

Correct State Transition
(状态迁移正确)

Idempotency
(幂等)

Cancellation Cleanup
(取消清理)

Retry Safety
(重试安全)

Backpressure
(反压)

这类 Fault Injection(故障注入)测试,往往比再写几十个 Happy Path(正常路径)单元测试更容易暴露真正的并发 Bug。


最后:真正要建立的不是"会写 asyncio",而是任务生命周期思维

回头看,进程、线程和协程其实都不难。

进程提供隔离和多核执行边界。

线程提供共享内存中的执行能力。

协程负责把 I/O 等待时间重新利用起来。

Lock 保护临界区。

Semaphore 控制资源并发。

Queue 提供缓冲和 Backpressure。

Event 负责状态通知。

真正让系统变难的是:

任务一旦开始并发,就会产生完整的生命周期。

它可能:

text 复制代码
正在运行
正在等待
已经取消
已经超时
正在重试
远端成功但本地不知道
Worker 已经死亡
租约已经过期
消息正在重新投递

因此生产级并发系统最终都在回答几个问题:

谁拥有这个任务?
它现在是什么状态?
哪些信息只存在进程内存里?
哪些事实必须持久化?
失败以后是否允许重试?
重复执行是否安全?
结果无法确认时怎么办?

如果这些问题没有答案,那么:

python 复制代码
await asyncio.gather(...)

写得再漂亮,也只是:

"很多事情同时跑起来了。"

而一个真正可靠的 Runtime 需要做到的是:

即使很多事情同时发生,即使任何一步都可能失败,我们仍然知道系统已经做过什么、正在做什么,以及下一步还能不能安全地继续。

这才是从"会写并发代码"走向"会设计并发系统"的分界线。

相关推荐
一只积极向上的小咸鱼1 小时前
pytorch 与资源核算
人工智能·pytorch·python
reasonsummer1 小时前
【办公类110-08】20260807园园通-“小班“户籍地址和居住地址补充完整+外省市所在“省市区”两个按钮手工填写(Python+EXCEL)
python·excel·园园通
whcyhhh1 小时前
头歌实践教学平台:数据科学与大数据技术导论(五)
大数据·数据库·python
overmind1 小时前
oeasy python 139 字典排序_快速生成_sorted
python
大鹏说大话1 小时前
用 Python 与 DeviceAtlas 构建自动化设备数据库
数据库·python·自动化
青 春 记 忆1 小时前
LeetCode 206. 反转链表|Python 解法详解
python·leetcode·链表
智购科技无人售货机工厂1 小时前
2026自动售货机触摸屏驱动开发:从I2C协议到多点触控校准的工程实践~YH
android·驱动开发·python·单片机·云原生·django
楠楠子呀2 小时前
独立站聊天转化全链路:从二维码引流到数据复盘
java·javascript·数据仓库·python·c#·自动化·etl
空堂与归2 小时前
单机 Python 跑不动?Ray 分布式计算框架让 AI 训练提速 10 倍
开发语言·人工智能·python