
第一次写出 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
看起来只有一行,但从逻辑上可能经历:读取旧值、计算新值、写回新值。两个线程交错执行时,就可能都读到同一个旧值,然后互相覆盖结果。
这就是为什么线程里经常需要 Lock、RLock、Semaphore 等同步原语。
说到 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(请求合并)。
它解决的是:
相同工作没有必要重复执行。
所以遇到并发问题时,我现在更习惯先问三个问题:
- 哪一段业务逻辑必须具有原子性?
- 共享状态最小可以缩到 request、task、key 还是 resource?
- 我需要的到底是互斥、限流,还是合并重复工作?
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 需要做到的是:
即使很多事情同时发生,即使任何一步都可能失败,我们仍然知道系统已经做过什么、正在做什么,以及下一步还能不能安全地继续。
这才是从"会写并发代码"走向"会设计并发系统"的分界线。