工程实战

Agent 任务调度器设计:从队列到优先级的工程取舍

当一个 Agent 系统从”单任务单会话”演进到”同时处理几十上百个任务”时,第一个绕不开的工程问题就是调度器。谁先跑、谁该等、谁被打断、失败了怎么重来——这些决策如果没有清晰的模型支撑,系统很快会退化成一堆 setTimeout 和全局变量的缝合怪。这篇文章聚焦调度器本身的设计取舍,不涉及具体框架代码,目标是帮你在动手之前想清楚几个关键分叉点。

一、先分清楚”调度”要解决的三类问题

很多团队一上来就讨论”用什么队列中间件”,但调度器要处理的问题其实分三层,混在一起讨论容易失焦:

  1. 排序问题:多个待执行任务,先跑哪个?这是优先级和公平性的问题。
  2. 并发问题:同时能跑多少个?跑太多会打爆下游 API 限速或者本地资源(参考 Agent 并行工具调用的正确姿势)。
  3. 生命周期问题:一个任务从提交到完成中间可能失败、超时、被取消、被重试,调度器要不要管这些状态?

大部分”调度器写着写着就乱了”的项目,根源是把这三层问题揉进了一个函数里。建议在设计阶段就用三个独立的组件对应它们:排序器(Scheduler Policy)、并发限制器(Concurrency Limiter)、任务状态机(Task State Machine,可参考 Agent 状态机设计)。

二、排序策略:FIFO 不是默认答案

最简单的调度是先进先出,但对 Agent 系统来说这经常是错的。原因是 Agent 任务的”价值密度”差异很大——一个用户在等的交互式任务,和一个后台批量跑的数据清洗任务,优先级天然不同。

常见的排序策略有三种,各有适用场景:

策略原理适合场景风险
严格优先级队列高优先级任务永远先跑交互式请求 vs 后台批处理混跑低优先级任务可能”饿死”
加权公平队列(WFQ)按权重轮转分配执行槽位多租户共享同一套 Agent 资源实现复杂度高于前两者
时间片轮转 + 老化(Aging)等待越久优先级越高任务量大但优先级差异不极端需要定期扫描调整优先级,有额外开销

实践中最常用的组合是严格优先级 + 老化:默认按优先级分层排队,但每层队列内部按等待时间做老化衰减,避免低优先级任务无限期饿死。多租户场景则应该叠加限流协调(参考 Agent 速率限制协调),否则优先级再合理也会被某个租户的突发流量打穿。

三、并发控制:限的是”什么”而不是”多少”

很多人一上来就设一个全局并发数,比如”最多同时跑 20 个任务”。这个数字很快会失效,因为 Agent 任务消耗的资源是多维的:

  • LLM API 配额:按 tokens/分钟或请求数/分钟限速,不同模型独立计费
  • 工具调用并发:比如同时开太多浏览器实例、数据库连接
  • 本地计算资源:CPU/内存,尤其是本地跑向量检索或代码执行沙箱时

正确做法是把并发限制拆成多个独立的信号量(semaphore),每种资源一个,任务要同时拿到所有需要的信号量才能执行。伪代码结构大致是:

class ResourceLimiter:
    def __init__(self):
        self.llm_slots = Semaphore(10)      # LLM 调用并发
        self.tool_slots = Semaphore(5)      # 工具执行并发
        self.sandbox_slots = Semaphore(3)   # 沙箱执行并发

    async def acquire_for(self, task):
        needed = task.required_resources()  # 声明式:任务自己说要什么
        acquired = []
        try:
            for slot in needed:
                await slot.acquire()
                acquired.append(slot)
            return acquired
        except Exception:
            for slot in acquired:
                slot.release()
            raise

这里的关键设计是”任务声明自己需要的资源”,调度器不猜测。这样当某类资源(比如某个模型的 API 配额)临时紧张时,只有依赖它的任务会排队,其他任务不受影响。

四、失败任务怎么办:重试不是无脑循环

调度器必须内建重试逻辑,但重试策略如果设计不当,反而会放大故障。几个容易踩的坑:

  • 无退避的立即重试:下游服务本来就在过载,立即重试等于往火上浇油。应该用指数退避加抖动(exponential backoff + jitter)。
  • 无限重试:任务永久失败(比如输入本身有问题)也会被反复重试,浪费资源还掩盖了真实错误。必须设置最大重试次数,超过后转入死信队列(参考 Agent 死信队列设计)。
  • 重试丢失上下文:任务失败后重新入队,如果没有携带原始上下文(之前执行到哪一步、中间产物是什么),重试等于从零开始,浪费已经花掉的 tokens。

一个可用的重试状态记录结构:

{
  "task_id": "t-8842",
  "attempt": 2,
  "max_attempts": 3,
  "last_error": "rate_limited",
  "backoff_until": "2026-09-08T10:32:00Z",
  "checkpoint": {
    "completed_steps": ["fetch_data", "parse"],
    "next_step": "summarize"
  }
}

有了 checkpoint 字段,重试时可以跳过已完成的步骤,这对于长任务(尤其是多步骤 Agent 任务)能显著降低重试成本。

五、抢占:什么时候该打断正在跑的任务

排序解决的是”下一个跑谁”,但如果一个高优先级任务在低优先级任务执行过程中到达,要不要打断后者?这是抢占式调度和非抢占式调度的分野。

Agent 系统里抢占要谨慎,原因是打断一个正在执行的 LLM 调用或工具调用往往意味着:

  • 已经消耗的 tokens 白费
  • 如果工具调用有副作用(比如已经发起了一个 API 写请求),打断可能导致状态不一致

实用的折中方案是软抢占:不强制杀死正在执行的任务,而是在任务的每个”安全点”(比如一次工具调用完成之后)检查是否有更高优先级任务需要插队,如果有就把当前任务挂起而不是杀死,状态保存后重新入队。这需要任务本身支持挂起/恢复,也就是前面提到的状态机要显式建模”挂起”状态。

六、可观测性:调度器不是黑盒

调度器出问题时最常见的症状是”任务莫名其妙卡住了”,排查起来非常痛苦,因为问题可能出在排序、限流、重试任意一层。最低限度应该暴露这些指标:

  • 各优先级队列的当前长度和平均等待时间
  • 各类资源信号量的占用率
  • 任务重试次数分布、死信队列增长速率
  • 任务从入队到开始执行的延迟(排队延迟)、从开始到结束的延迟(执行延迟)

把排队延迟和执行延迟分开看很重要——如果排队延迟持续升高,说明并发上限设低了或者优先级策略有问题;如果执行延迟升高,问题在下游(LLM API 变慢、工具超时),这两类问题的处理方式完全不同。更系统的做法可以参考 Agent 可观测性设计。

七、小结:一个可落地的最小调度器

如果要在现有系统里补一个调度器,建议按这个最小集合起步,而不是一次性做完所有功能:

  1. 三层优先级队列(高/中/低)+ 简单老化机制
  2. 按资源类型拆分的信号量限流
  3. 固定次数的指数退避重试 + 死信队列
  4. 暴露排队延迟和执行延迟两个核心指标

这四项做扎实,已经能覆盖绝大多数 Agent 生产环境的调度需求。抢占、加权公平队列这类进阶功能,等到实际观测数据显示”简单策略下某类任务被系统性地拖延”时再引入,不要提前优化一个还不存在的问题。

调度器的复杂度应该由观测到的真实瓶颈驱动,而不是由”别的团队是这么做的”驱动——这也是工程实战里最容易被忽视但最重要的一条原则。

八、相关阅读