《AI Agent 工程实战》7.1 任务队列:run 丢给 worker 异步执行

把一次 run 从同步请求改造成异步任务:API 只写库并入队、立刻返回 202,worker 在另一个进程消费。本节讲清裸 LPUSH/BRPOP 为什么会丢任务,改用 BLMOVE 把任务原子搬到 processing 做可靠队列,并真连本机 Redis 7.4.11 与 Postgres 17.11,跑出完整的 run 生命周期日志与同步/异步取舍表。

7.1 任务队列:run 丢给 worker 异步执行

第 6 章给 ResearchCopilot 配好了缓存与限流,服务能扛住更多请求了,但有一个更根本的问题一直没动:run 还是在 HTTP 请求里同步跑完的。用户 POST 一个 run,连接就挂在那里等——一次多跳检索加模型生成,几秒到几十秒不等。请求超时、网关断连、部署重启,任何一个环节抖动,用户就白等一场,而那个 run 的状态也随进程一起蒸发。

本节把 ResearchCopilot 推进到 researchcopilot/queue.py:POST /runs 只写库、入队、立刻返回 202,真正的执行搬到独立 worker 进程;用 Redis 的 BLMOVE 做一条可靠队列,真连本机 Redis 与 Postgres 跑出一次完整生命周期。

7.1.1 为什么要把 run 从请求里搬走

同步执行的问题不是「慢」,而是慢的代价由连接承担。一次 run 要几秒到几十秒,这段时间里:

  • 客户端得一直挂着连接,移动网络一切换就断;
  • 反向代理 / 网关有超时(常见 30–60s),run 一超时,网关先返回 504,用户看到失败,可 worker 其实还在跑;
  • 服务要扩容时,一个实例同时只能跑有限个同步 run,水平扩展被连接数绑死;
  • 进程一重启,正在跑的所有 run 全部丢失,没有任何痕迹。

把执行搬进队列,这四条同时消失:请求变成一次「投递」,几十毫秒返回;执行在 worker 侧独立进行,进程崩了任务还在队列里。

7.1.2 裸队列为什么会丢任务

最省事的队列写法是 LPUSH 生产、BRPOP 消费:worker 从列表右端取一条,取到就执行。它在 demo 里跑得通,却有一个致命缺陷——任务在「取走」和「执行完」之间没有任何保护:

LPUSH queue run-1        # API 入队
BRPOP queue              # worker 取走 run-1(此刻 run-1 已不在队列里)
   ↓ worker 崩了 / 被 kill / 机器断电
run-1 永久丢失           # 队列里没有它,内存里也没有它

BRPOP 是「取出即删除」,任务一旦被取走就从队列消失。worker 若在取走之后、完成之前崩溃,这条任务既不在队列、也没有结果,静默丢失。研究助理这种场景里,丢一个 run 意味着用户等了一个永远不来的报告。

取任务方式语义worker 崩溃时
BRPOP queue取出即删除任务丢失
RPOPLPUSH src dst取出并搬到备份列表任务留在 dst,可重投
BLMOVE src dst LEFT RIGHT同上,阻塞版任务留在 dst,可重投

正解是可靠队列:取任务时不是「删除」,而是「原子搬到另一个列表」。Redis 的 LMOVE(阻塞版 BLMOVE)就是这个动作——从 queue:runs 头取一条,同时推到 queue:runs:processing 尾。任务在 processing 里等 worker 确认;worker 跑完再从 processing 删掉,才算真正消化。崩溃时任务静静躺在 processing 里,由恢复程序重新投递。

7.1.3 环境:起 Redis 与 Postgres

本机两个服务映射在非默认端口(5432 与 6379 已被占用),等价容器命令:

docker run -d --name ab-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=research -p 15435:5432 postgres:17-alpine
docker run -d --name ab-redis -p 16380:6379 redis:7-alpine

连接串分别是 postgresql://postgres:postgres@localhost:15435/research(实测 PostgreSQL 17.11,驱动 psycopg 3.3.6)与 redis://localhost:16380/0(服务端实测 Redis 7.4.11,客户端 redis 8.1.0)。连不上先 docker ps | grep -E 'ab-pg|ab-redis'。本节复用第 5 章的 sessions / runs / messages 三张表,run 的输入输出存在 messages.payload(jsonb),runs 只存状态与时间戳。

7.1.4 接口改造:写库 → 入队 → 立刻 202

第 3 章的 POST /sessions/{id}/runs 原本直接调 execute_run();现在改成「先写库、再入队、马上返回」。顺序不能反——必须先把 run 落成 queued 再入队,否则 worker 可能先拿到任务、却发现库里还没有这条 run:

def enqueue(r, conn, run_id, session_id, workspace_id, inp) -> None:
    # 1) 先写库:session + run(queued) + user 消息,一个事务
    with conn.cursor() as cur:
        cur.execute(
            "INSERT INTO sessions (id, workspace_id, title, created_at, updated_at) "
            "VALUES (%s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING",
            (session_id, workspace_id, "研究:向量数据库选型", now(), now()),
        )
        cur.execute(
            "INSERT INTO runs (id, workspace_id, session_id, status, created_at) "
            "VALUES (%s, %s, %s, 'queued', %s)",
            (run_id, workspace_id, session_id, now()),
        )
        cur.execute(
            "INSERT INTO messages (workspace_id, session_id, role, payload, created_at) "
            "VALUES (%s, %s, 'user', %s, %s)",
            (workspace_id, session_id,
             json.dumps({"content": inp}, ensure_ascii=False), now()),
        )
    conn.commit()
    # 2) 再入队:顺序不能反,否则 worker 可能读到不存在的 run
    r.lpush(QUEUE, json.dumps({"run_id": run_id, "input": inp}))

接口因此从「跑完才返回」变成「受理即返回」——这正是第 3 章定下的 202 Accepted 语义:创建与执行是两件事,202 诚实地说「我收下了,但还没跑完」,返回体里带上 run_id 供客户端轮询。

7.1.5 worker:原子认领 + 执行 + 写回

worker 是一个独立进程,循环从队列里取任务。它有三步,每步都对应一个数据库动作:

def main(limit: int) -> None:
    r = connect_redis()
    conn = connect_pg()
    log("worker 启动,等待任务(BLMOVE 阻塞)")
    for _ in range(limit):
        # 可靠取任务:原子地从 QUEUE 头移到 PROCESSING 尾,崩溃也不丢
        raw = r.blmove(QUEUE, PROCESSING, 5, "LEFT", "RIGHT")
        if raw is None:
            log("空闲超时,退出")
            return
        task = json.loads(raw)
        rid = task["run_id"]
        log(f"取到 run={rid}:{QUEUE} -> {PROCESSING}(可靠队列)")
        # 原子认领:只有 queued 才能变 running,attempts 自增
        with conn.cursor() as cur:
            cur.execute(
                "UPDATE runs SET status='running', started_at=%s, "
                "attempts=attempts+1 "
                "WHERE id=%s AND status='queued' RETURNING attempts",
                (now(), rid),
            )
            row = cur.fetchone()
        conn.commit()
        if row is None:
            log(f"  run={rid} 非 queued,跳过(已被其它 worker 领走)")
            r.lrem(PROCESSING, 1, raw)
            continue
        log(f"  状态迁移 queued -> running(原子认领,attempts={row[0]})")
        answer = run_minagent(task["input"])          # 真实项目调卷一的 minagent.run
        with conn.cursor() as cur:
            cur.execute(
                "UPDATE runs SET status='succeeded', finished_at=%s WHERE id=%s",
                (now(), rid),
            )
            cur.execute(
                "INSERT INTO messages (workspace_id, session_id, role, payload, "
                "created_at) SELECT workspace_id, session_id, 'assistant', %s, %s "
                "FROM runs WHERE id=%s",
                (json.dumps({"content": answer}, ensure_ascii=False), now(), rid),
            )
        conn.commit()
        # 确认完成:从 PROCESSING 移除,任务才算真正消化
        r.lrem(PROCESSING, 1, raw)
        r.lpush(DONE, raw)

两个「原子」值得说透。取任务是原子的:BLMOVE 一条命令完成「弹出 + 推入」,中间没有可乘之机,不会出现「弹出了但没推进去」的窗口。认领也是原子的:UPDATE ... WHERE id=%s AND status='queued' 依赖数据库的行级锁——如果两个 worker 同时拿到同一个 run(重投时可能发生),只有一个的 WHERE status='queued' 能命中,另一个 RETURNING 拿到空、直接跳过。幂等从这里就开始建立:同一个 run 无论被投递几次,状态机只允许它从 queued 迁移一次。

还有一处刻意的顺序:先更新状态、再写结果消息,两步在同一个事务里提交。这样要么「run 变 succeeded 且 assistant 消息落库」一起发生,要么都不发生;不会出现「状态说成功了、但消息还没写」的中间态。相比之下,把状态更新放在消息写入之后、又不包事务,就会给客户端制造一个「查状态是 running、查消息是空」的窗口——状态和它的产物必须同生共死。

7.1.6 真跑:一次完整的 run 生命周期

先起 worker(后台),再从 driver 侧入队三个 run,最后核对终态:

/tmp/agentbook/venv/bin/python worker.py 3 > worker.log 2>&1 &
/tmp/agentbook/venv/bin/python driver.py
wait; cat worker.log

driver 侧输出(Python 3.14.6):

[2026-10-10T19:05:52+00:00] 入队 run=6fb23a51-82fa-4d69-ae2e-427af8491a54  写库(queued) -> LPUSH -> 返回 202
[2026-10-10T19:05:52+00:00] 入队 run=8c3b8f4f-aed3-40d3-bb3a-f103a5ec2523  写库(queued) -> LPUSH -> 返回 202
[2026-10-10T19:05:52+00:00] 入队 run=0644afb4-2024-4d7c-819e-276e24e919a3  写库(queued) -> LPUSH -> 返回 202
[2026-10-10T19:05:52+00:00] API 立刻返回:3 个 run 已受理(202),不等执行结果

worker 侧生命周期日志:

[2026-10-10T19:05:51+00:00] worker 启动,等待任务(BLMOVE 阻塞)
[2026-10-10T19:05:52+00:00] 取到 run=6fb23a51-82fa-4d69-ae2e-427af8491a54:queue:runs -> queue:runs:processing(可靠队列)
[2026-10-10T19:05:52+00:00]   状态迁移 queued -> running(原子认领,attempts=1)
[2026-10-10T19:05:52+00:00]   执行完成(56 ms)running -> succeeded
[2026-10-10T19:05:52+00:00]   output: 围绕「对比 pgvector 与 Qdrant」,命中 3 条本地语料,结论见引用。
[2026-10-10T19:05:52+00:00] 取到 run=0644afb4-2024-4d7c-819e-276e24e919a3:queue:runs -> queue:runs:processing(可靠队列)
[2026-10-10T19:05:52+00:00]   状态迁移 queued -> running(原子认领,attempts=1)
[2026-10-10T19:05:52+00:00]   执行完成(60 ms)running -> succeeded
[2026-10-10T19:05:52+00:00]   output: 围绕「RAG 的召回率怎么评」,命中 3 条本地语料,结论见引用。
[2026-10-10T19:05:52+00:00] 取到 run=8c3b8f4f-aed3-40d3-bb3a-f103a5ec2523:queue:runs -> queue:runs:processing(可靠队列)
[2026-10-10T19:05:52+00:00]   状态迁移 queued -> running(原子认领,attempts=1)
[2026-10-10T19:05:52+00:00]   执行完成(60 ms)running -> succeeded
[2026-10-10T19:05:52+00:00]   output: 围绕「GIL 对并发的影响」,命中 3 条本地语料,结论见引用。
[2026-10-10T19:05:52+00:00] 处理完指定数量,退出

注意三点。其一,driver 的四行在 19:05:52 内全部打完——API 只做了写库和入队,不等执行,几十毫秒返回。其二,worker 每个 run 都走完 取到 → queued→running → succeeded 三段,状态是显式迁移的,不是「跑完直接写结果」。其三,attempts=1 说明每个 run 只被认领了一次,没有重复执行。最后核对终态(限定本 workspace):

runs: [('succeeded', 3, 3)]            # 3 个 run 全部 succeeded,attempts 合计 3
messages: [('assistant', 3), ('user', 3)]
queue:runs = 0 | processing = 0 | done = 3

queue:runs 和 processing 都归零、done 是 3——任务既执行了、也被确认了。如果 worker 在某个 run 执行途中崩溃,processing 里会留下那一条,而 queue:runs 已空——这正是可靠队列留下的「未完成任务」痕迹,恢复程序据此重投即可。

顺带一提,DONE 这个列表在生产里通常不会真的留着(任务执行完就可以丢了),本节保留它只是为了在演示里能直观看到「有几个任务走到了终点」。真实系统更关心的是 processing 里有没有堆积——那是 worker 跟不上或正在崩溃的直接信号。

7.1.7 同步执行 vs 异步队列

是不是所有 run 都该异步?不一定。两者各有适用面:

维度同步执行异步队列
返回时机跑完才返回受理即返回 202
客户端复杂度直接拿结果需轮询 / SSE 订阅
长任务(>30s)网关超时风险高天然支持
进程崩溃任务丢失任务留在 processing,可重投
水平扩展被连接数绑死加 worker 即可
结果时效强一致(拿到的就是最终)最终一致(要等状态翻转)
调试简单,栈一路到底跨进程,要日志/追踪串联
适用短任务、交互式问答长任务、后台 digest、批处理

判断标准其实很朴素:任务时长是否超过你能接受的连接时长。交互式的一问一答(几百毫秒)同步更简单;而 ResearchCopilot 的多跳检索报告、周期 digest 都是分钟级,必须异步。工程上常见的是两条路都留:POST /runs 默认异步返回 202,客户端再决定是轮询还是订阅第 3.2 节的 SSE 事件流。

还有一条容易忽略的代价:异步把「一次调用」拆成了「两个状态」。同步模式下,「请求成功」等于「任务成功」;异步模式下,请求成功只代表「入队成功」,任务本身可能还在 queued、可能 running、也可能最终 failed。客户端必须学会读 run.status,后端必须保证每个 run 都有确定的终态——没有终态的队列比同步调用更糟,因为用户连「失败了」都不知道。这也是下一节要补死信的原因:一个 run 绝不能永远卡在 running。

7.1.8 真实环境会怎样

真实系统里,这套骨架的形状一行不改,但四处会变重:

  • 队列不该只用 Redis 列表。Redis 列表适合「量不大、要低延迟」的场景;任务量大或要持久保障时,用 RabbitMQ / Kafka / 云 SQS,它们内建了重投、死信、可见性超时。本节用 Redis 是因为它把「可靠队列」的机制暴露得最清楚。
  • processing 要有超时回收。worker 崩溃后任务留在 processing,得有个 janitor 扫描「躺太久」的任务重新投递——这需要给每条任务记一个领取时间,第 8 章讲超时与泄漏时会补上。
  • worker 要能优雅退出。收到 SIGTERM 时应停止取新任务、把手头这个跑完再退,否则又制造一条留在 processing 的任务。
  • 队列要有可观测性。queue:runs 长度、processing 长度、任务等待时长、执行时长都该进第 13 章的看板——队列积压是容量问题最早的信号。

本节验收清单:

  • POST /runs 只写库并入队,立即返回 202 与 run_id
  • worker 用 BLMOVE 把任务从 queue:runs 原子搬到 processing
  • run 状态迁移是原子的:WHERE status='queued' 认领,重复投递不会重复执行
  • 执行完从 processing 确认移除,任务不再残留
  • 崩溃时未完成的任务留在 processing,可被重新投递

到这里,run 已经能安全地「投出去、跑起来、写回来」了。但现实里 worker 会遇到上游抖动——模型 503、网络超时,一次失败就判死太脆弱。下一节 7.2 重试、死信与幂等 会给这条队列加上「失败重试、重试耗尽进死信、重试必须幂等」的三件套。想回看 run 状态机与 202 的来历,翻 3.1 REST 设计:session / run / message 资源建模 。

阅读导航:上一节:6.3 限流与配额:按租户 token 预算的令牌桶 · 下一节:7.2 重试、死信与幂等 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距