Agent 任务调度器设计:从队列到优先级的工程取舍
当一个 Agent 系统从”单任务单会话”演进到”同时处理几十上百个任务”时,第一个绕不开的工程问题就是调度器。谁先跑、谁该等、谁被打断、失败了怎么重来——这些决策如果没有清晰的模型支撑,系统很快会退化成一堆 setTimeout 和全局变量的缝合怪。这篇文章聚焦调度器本身的设计取舍,不涉及具体框架代码,目标是帮你在动手之前想清楚几个关键分叉点。
一、先分清楚”调度”要解决的三类问题
很多团队一上来就讨论”用什么队列中间件”,但调度器要处理的问题其实分三层,混在一起讨论容易失焦:
- 排序问题:多个待执行任务,先跑哪个?这是优先级和公平性的问题。
- 并发问题:同时能跑多少个?跑太多会打爆下游 API 限速或者本地资源(参考 Agent 并行工具调用的正确姿势)。
- 生命周期问题:一个任务从提交到完成中间可能失败、超时、被取消、被重试,调度器要不要管这些状态?
大部分”调度器写着写着就乱了”的项目,根源是把这三层问题揉进了一个函数里。建议在设计阶段就用三个独立的组件对应它们:排序器(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 可观测性设计。
七、小结:一个可落地的最小调度器
如果要在现有系统里补一个调度器,建议按这个最小集合起步,而不是一次性做完所有功能:
- 三层优先级队列(高/中/低)+ 简单老化机制
- 按资源类型拆分的信号量限流
- 固定次数的指数退避重试 + 死信队列
- 暴露排队延迟和执行延迟两个核心指标
这四项做扎实,已经能覆盖绝大多数 Agent 生产环境的调度需求。抢占、加权公平队列这类进阶功能,等到实际观测数据显示”简单策略下某类任务被系统性地拖延”时再引入,不要提前优化一个还不存在的问题。
调度器的复杂度应该由观测到的真实瓶颈驱动,而不是由”别的团队是这么做的”驱动——这也是工程实战里最容易被忽视但最重要的一条原则。