并发、队列与扩缩容

O2
运维 · 智能体运维:部署与运营

并发、队列与扩缩容:智能体是作业,不是请求。

一个为 40 分钟的智能体循环把连接挂着不放的 HTTP 处理器,是一个你会在压力下才发现的设计错误。智能体是长时运行、突发、有状态、单位工作昂贵的——这副画像就是批处理作业,而能在生产中存活下来的运维形态是:队列加 worker、按租户限流、以及你负担得起的扇出。本文讲的就是这副形态。

STEP 1

请求/响应这套框架在第一次并发尖峰就会崩。

同步的"POST /agent,等结果"耦合了三个时间常数天差地别的东西:客户端的耐心(秒级)、智能体的运行时(分钟级)、你的容量(固定)。一旦突发,每个慢智能体都钉住一个 worker 线程和一个 socket;线程池耗尽;健康的短请求排在 30 分钟的请求后面;负载均衡器超时,客户端开始重试,把正在压垮你的那股负载又翻了一倍。解法不是更大的线程池,而是把"受理"与"执行"解耦。

持久提交模式:API 调用入队一个运行并立即返回一个 run_id。worker 从队列拉取并基于持久日志执行(见 durable-state-and-resumability)。客户端轮询或订阅完成事件。受理是 O(毫秒) 的,且永远不被执行时长阻塞。

STEP 2

队列 + worker 是参考架构。

# api: accept fast, never block on the loop
def submit(req):
    run_id = new_id()
    journal.record(run_id, 0, "PLAN", req)
    queue.enqueue("agent-runs", run_id,
                  tenant=req.tenant, priority=req.tier)
    return {"run_id": run_id, "status": "queued"}

# worker: bounded concurrency, lease + heartbeat
async def worker(slot):
    while True:
        job = await queue.lease("agent-runs", ttl=120)
        async with heartbeat(job):       # renew lease while alive
            await run_loop(job.run_id)   # resumable; safe to redeliver
        queue.ack(job)

不可妥协的几条性质:带心跳的租约(死掉的 worker 的作业变为可重投递而非丢失——而且因为运行可恢复,重投递是正确的,不是重复)、有界的 worker 并发(每个 worker 固定数量的在途循环,按提供商速率上限与内存来定,而非"来多少跑多少")、以及可观测性(队列深度与最老消息年龄才是你真正的负载信号——不是 CPU)。

队列深度与年龄扩缩 worker,而不是 CPU。智能体 worker 大部分时间阻塞在模型和工具延迟上;延迟爆炸时 CPU 仍然很低。按 CPU 自动扩缩会恰恰在你被淹没时供给不足。对最老消息年龄越过 SLA 这一事件告警。

STEP 3

按租户的并发上限,否则一个客户就是所有人的故障。

单一全局队列就是一桩等着发生的吵闹邻居事故:一个租户提交 5000 个运行,会饿死其他所有租户、烧光共享的提供商速率配额,把他们孤立的突发变成你全平台的延迟尖峰。你需要公平性,以按租户的在途上限来强制,而不仅仅是一个全局上限。

# fair dispatch: cap concurrent runs per tenant
def dispatchable(job):
    inflight = counter.get("inflight", job.tenant)
    cap = plan_limit(job.tenant)        # e.g. free=2, pro=20
    if inflight >= cap:
        queue.requeue(job, delay=backoff)  # yield, don't drop
        return False
    counter.incr("inflight", job.tenant)
    return True

把它实现为跨按租户子队列的工作窃取,或如上一个全局队列加准入闸门。这个上限既是产品决策(套餐分层),是安全装置:它限定了单个租户失控循环的爆炸半径(见 incident-response-for-agents)。

STEP 4

有状态才是真正的扩缩税——用日志来交这笔税。

无状态服务横向扩缩很容易,智能体却很难,因为智能体循环有状态:计划、历史、草稿本。陷阱是把这些状态留在 worker 内存里,这会把一个运行粘在某个 Pod 上,使每次重启都变成数据丢失。换来横向扩缩的纪律是:worker 无状态;日志才是状态。任何 worker 都能通过重放日志接管任何运行。这一个决定把智能体从粘连、无法再平衡的负载,转变为可像其他任何东西一样自动扩缩、排空、装箱的可互换作业。

为"目前为止的对话"做内存缓存,是最常见的扩缩回归。它在开发环境(单进程)能用、能过评审,然后就把每个运行都粘连路由、击败自动扩缩、在部署时丢状态。如果它必须活过一个进程,它就该住在日志里——而不是进程本地的 dict 里。

STEP 5

扇出是没有天然背压的成本倍增器。

多智能体与并行子任务模式会为每个父节点派生 N 个子节点。这笔账毫不留情:一个顶层智能体扇出到 8 个研究员、每个做 15 次模型调用,就是一个用户请求 120 次调用——而每层都扇出的递归规划器是指数级的。扇出没有内建背压:没有什么能阻止一个规划器决定它需要 200 个子智能体。

  • 从结构上约束扇出因子(每节点最大子节点数)与递归深度——两者都作为硬运行时上限,而非模型可以无视的提示请求。
  • 让子节点扣减父节点的预算,而非各自一份新预算,使扇出消耗一个共享的按任务上限(见 cost-control-in-the-loop)。
  • 把子节点作为作业入队,走同一个公平派发器——这样 200 个子智能体会排在按租户上限后面,而不是一齐砸向提供商。
STEP 6

什么时候队列是错的工具。

队列会增加延迟(入队、租约、轮询)和运维面(死信处理、重投递语义、轮询 API)。一个真正交互式、人就盯着屏幕等一个 4 秒回复的智能体,应当同步流式返回——在那里加队列只是凭空多出一秒静默,换不来用户在意的任何持久性。本文这套架构在运行突发扇出时才值回它的复杂度。当工作的寿命超过请求者的注意力跨度时用队列;不超过时用流式——而且永远不要让一个请求线程阻塞在一个寿命可能超过负载均衡器超时的循环上。