首页 / 资讯中心 / 文章详情

Antigravity Agent Manager任务管理中心:状态机与调度实践解析

Antigravity Agent Manager任务管理中心:状态机与调度实践解析 ★ FEATURED ARTICLE
在 Antigravity 平台入门系列的第三篇里我打算把 Agent Manager 任务管理中心单独拉出来聊。很多人第一次接触 Antigravity 的代码库时都会盯着这个模块发愣任务管理中心到底是管任务的还是管 Agent 的为什么已经有了任务队列还要再增加一层管理这篇文章会基于我实际搭建和运维这套系统的经验把它的职责边界、核心状态机、Agent 注册与心跳、任务完整流转链路、重试与优先级控制、存储选型、监控告警以及那些在线上才踩得到的坑完整过一遍。如果你正在做任务调度、异步执行引擎、智能体编排或者类似的后端基础设施这篇文章可以直接当一份参考笔记用。1. 为什么 Antigravity 要单独设计一个 Agent Manager1.1 最初的“任务散养”模式Antigravity 早期是没有独立管理中心的。各业务方想跑定时任务就在自己的服务里塞一个调度器想并发执行就自己拉几台机器做联动执行完的结果写进各自的数据库互不感知。听起来灵活但没过多久问题全冒出来了谁在跑哪个任务没人说得清任务卡死半天没人发现新业务接入时要重新做一遍调度、重试、日志这些基础功能。最典型的线上事故是某个报表任务因为依赖的机器重启状态一直停在“运行中”第二天业务方拿着截图来找我们我们却查不到任何可用的记录。这种“任务散养”的模式本质上把调度的复杂度分摊到了每个业务方头上。每个团队都在重复造轮子却没有一个地方能回答三个基本问题当前有多少任务在跑、跑到哪一步了、失败之后谁来负责恢复。后来我们痛定思痛决定把调度相关的公共能力收敛到一个模块里这就是 Agent Manager 的雏形。1.2 Agent Manager 的职责边界经过几轮迭代Agent Manager 的职责被收敛成四件事管理 Agent 的生命周期包括注册、心跳、上下线、禁用与恢复管理任务状态流转任务的每个状态变化都由它统一记录和推动进行派单与负载控制决定某个任务交给哪个 Agent 执行沉淀事件日志与审计信息方便事后回溯。这里要特别强调一点Agent Manager 不负责实际执行业务逻辑。真正计算数据、跑模型、调外部接口的是分布在各处的 Agent。管理中心更像一个机场塔台航班起飞、落地、延误、取消都要经过它但飞机本身不是塔台开的。把管理和执行分开是这套系统能横向扩展的关键。1.3 为什么叫“任务管理中心”而不是“任务执行中心”命名这件事看似小事其实会影响架构走向。我们内部曾经讨论过要不要叫“执行中心”后来否决了。“执行中心”听起来像要把所有执行都收拢到一个服务里这既违背分布式设计也容易变成性能瓶颈。而“管理中心”的定位是状态编排和调度决策不承担具体运算。边界一旦划清楚后续加 Worker、加消息队列、加新业务场景的时候才不会把 Agent Manager 变成一个无所不包的巨型服务。很多人问为什么 Antigravity 的任务能分布在不同机器上还不出乱子答案就在这个边界设计里。2. 任务状态机整个任务管理中心的“宪法”2.1 任务状态与事件清单Agent Manager 最核心、也最容易改坏的部分就是状态机。我们最终沉淀下来的状态如下状态含义进入方式PENDING任务刚写入还没进入调度视野创建任务READY通过预检可以被派发调度器扫描DISPATCHED已经派给某个 Agent派单完成RUNNINGAgent 确认开始执行Agent 上报 ackSUCCEEDED执行成功并回报结果Agent 上报结果FAILED执行失败且不再重试超过最大重试次数RETRY_WAIT执行失败等待下次重试失败且未超过最大重试TIMEOUT超过预期执行时间被系统判定超时超时检测任务CANCELED被人为或上游系统取消取消操作状态之间不是随意跳转的。READY 只能从 PENDING 进入RUNNING 可以到 SUCCEEDED、FAILED、TIMEOUT也可以被取消。难点在于任务在执行过程中可能同时触发多个事件比如 Agent 上报了完成结果但超时检测也恰好触发了。我们通过版本号加乐观锁保证只有一个事件能成功改状态另外一个会因版本冲突而丢弃并重新处理。2.2 数据模型落地方案任务相关表我们最终简化为三张tasks 表存任务主数据agents 表存 Agent 信息task_event_log 表存状态变更流水。CREATE TABLE tasks ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(64) NOT NULL, biz_id VARCHAR(128) NOT NULL, payload TEXT, status TINYINT NOT NULL, priority TINYINT DEFAULT 5, max_retry INT DEFAULT 3, retry_count INT DEFAULT 0, next_retry_at DATETIME DEFAULT NULL, expect_agent_id BIGINT DEFAULT NULL, actual_agent_id BIGINT DEFAULT NULL, timeout_seconds INT DEFAULT 300, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, version INT DEFAULT 0, UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ); CREATE TABLE agents ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, hostname VARCHAR(128), status TINYINT NOT NULL, slot_limit INT DEFAULT 4, current_slots INT DEFAULT 0, last_heartbeat_at DATETIME, registered_at DATETIME, version INT DEFAULT 0 ); CREATE TABLE task_event_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, from_status TINYINT, to_status TINYINT, operator VARCHAR(64), message TEXT, created_at DATETIME NOT NULL );这里 biz_type 和 biz_id 组成了业务幂等键。比如数据同步任务biz_type 是 sync_userbiz_id 是 batch_20250101。如果业务方重复提交会被唯一键拦住从源头避免重复执行。2.3 预留扩展字段与幂等设计很多同学会问payload 为什么不拆成结构化字段我们的经验是任务内容差异太大有的是 SQL 语句有的是 JSON 配置有的只是几个 ID。与其设计一堆通用列不如保留 TEXT 类型的 payload由各业务方自己定义契约。Agent Manager 只关心任务的调度属性也就是状态、优先级、重试次数、超时时间这些通用字段。版本号字段也值得展开讲。调度器在把任务从 READY 改为 DISPATCHED 时会带上 version 条件执行UPDATE tasks SET status 2, version version 1 WHERE id ? AND version ?。如果两个调度线程同时抢同一个任务只有一个能更新成功另一个会拿到 version 冲突并退出。这套乐观锁机制比分布式锁轻量得多对于单库场景完全够用。3. Agent 注册、心跳与派单管理中心怎么“管”住 Agent3.1 注册与租约机制Agent 不是写进配置就能工作的它必须先向 Agent Manager 注册。注册时会上报自己的唯一标识、部署机器、运行版本、可承载并发槽位等元数据。管理中心收到注册请求后将 Agent 状态置为 online并分配一个租约。租约的意思是Agent 必须在规定时间内续约否则会被判定为失联。我们最初设计的租约时间是心跳间隔的三倍后续根据网络情况做了动态调整。注册表里的 slot_limit 是个容易被忽略的字段。它表示这个 Agent 最多同时执行多少个任务。为什么不用 CPU 核数自动计算因为不同任务的资源消耗差异太大有的是纯 IO有的要占用大量内存。我们允许每个 Agent 在启动配置里声明自己的槽位上限运维可以针对不同机器做微调。3.2 心跳与失联判定Agent 注册后会启动一个后台线程每隔 5 秒向管理中心发送一次心跳。心跳包内容很简单Agent ID、当前正在执行的任务数、最近一次任务结束时间。管理中心收到心跳会更新 agents 表的 last_heartbeat_at 和 current_slots 字段。失联判定不能只看当前时间。如果管理中心把阈值设成 10 秒那么一次网络抖动或者 GC 停顿就可能误杀健康的 Agent。我们的计算公式是失联阈值 心跳间隔 x 3 网络重试余量默认情况下心跳间隔是 5 秒网络重试余量取 5 秒所以 20 秒没有心跳才会判定失联。判定为失联后管理中心会把该 Agent 名下的 RUNNING 任务重新标记为 READY并重新派发给其他可用 Agent。这一步必须放到一个独立的事务里做否则会出现任务还在 Agent 上跑、调度中心却已经重新派单的情况。3.3 拉模型派单与并发槽位派单方式上我们最终选择了“拉模型”而不是“推模型”。推模型是管理中心主动把任务推给 Agent好处是实时性高坏处是 Agent 忙不过来时任务会堆积在本地内存里。拉模型则是 Agent 空闲只后主动向管理中心请求任务天然自带背压Agent 说自己能接收几个管理中心就最多派几个。具体流程是Agent 在心跳里带上 current_slots如果当前正在执行的任务数小于 slot_limit它就顺带请求一批任务。管理中心从 READY 状态的任务里按优先级高低和创建时间先后取出任务分配给该 Agent并把任务状态改为 DISPATCHED。这种拉模型让每个 Agent 都能根据自己的真实处理速度消费任务不会因为某个 Agent 变慢而影响全局。4. 一条任务从提交到归档的完整流转过程4.1 提交与预检以报表生成任务为例。业务系统调用 Agent Manager 的 API 提交任务核心代码大概是这样的def create_task(biz_type, biz_id, payload, priority5, max_retry3): task_id db.insert_task( biz_typebiz_type, biz_idbiz_id, payloadpayload, statusPENDING, prioritypriority, max_retrymax_retry, created_atnow(), ) db.insert_event_log( task_idtask_id, from_statusNone, to_statusPENDING, operatorapi, messagetask created, ) return task_id注意任务写入后状态是 PENDING 而不是 READY。这是因为还要进行一轮预检检查业务幂等键是否重复校验 payload 大小是否超限确认是否有可用的 Agent。预检通过后才由调度器批量把 PENDING 改为 READY。预检和状态更新放在同一事务里可以避免一个半初始化任务被调度器提前派发出去。4.2 调度扫描与派单调度器是一个常驻进程每 200 毫秒扫描一次就绪任务。扫描条件是这样的while True: now datetime.now() ready_tasks db.fetch_tasks( statusREADY, next_retry_atNone or next_retry_at now, limit100 ) for task in ready_tasks: agent pick_lowest_load_agent(task) if not agent: continue updated db.try_claim_task( task_idtask.id, agent_idagent.id, expected_versiontask.version ) if updated: notify_agent(agent.id, task.id, task.payload) time.sleep(0.2)try_claim_task 就是前面说的乐观锁更新。如果多个调度器实例同时运行只有一个实例能成功把任务状态从 READY 改为 DISPATCHED其他的更新都会因版本号不匹配而失败。这里没有用分布式锁主要是为了减少对协调组件的依赖。4.3 执行确认与结果回报Agent 收到派单消息后先做一层本地校验然后向管理中心发送 ack任务进入 RUNNING。这个 ack 很重要因为只有进入 RUNNING超时计时器才开始工作。Agent 执行完业务逻辑后调用回报接口传入任务 ID 和结果数据。管理中心校验版本号后把任务状态改为 SUCCEEDED 并保存结果。如果 Agent 执行失败会上报失败原因。管理中心收到失败事件后会判断当前重试次数是否小于 max_retry。如果还有重试机会就把任务状态改为 RETRY_WAIT并把 next_retry_at 设置为当前时间加退避延迟如果重试次数已用完就直接进入 FAILED。4.4 归档与事件留存任务到达终态SUCCEEDED、FAILED、TIMEOUT、CANCELED后会被移入归档流程。归档不是简单删除而是把任务主数据保留下来同时保留完整的 task_event_log 流水。我们遇到过好几次业务方来问“三天前那个任务到底跑没跑过”如果没有事件流水这种问题根本没法回答。事件流水是排查问题最有力的抓手推荐大家无论如何都不要省。5. 重试、超时、优先级与并发控制5.1 指数退避与抖动任务执行失败重试时如果每次都立即重试很容易在依赖的下游系统恢复前形成重试风暴。我们采用的是指数退避策略重试延迟 基础延迟 x 2^retry_count 随机抖动基础延迟默认 30 秒。第一次失败后延迟约 30 秒第二次约 60 秒第三次约 120 秒。随机抖动的上限是基础延迟的一半也就是最多随机增加 15 秒。抖动的作用是让大量任务的重试时间错开避免它们同时醒来打爆数据库或下游系统。重试次数基础延迟实际延迟范围030s30s - 45s160s60s - 75s2120s120s - 135s3240s240s - 255s5.2 优先级队列与饥饿控制任务表加了一个 priority 字段范围 0 到 9。调度器扫描时就按 priority 倒序、创建时间正序排序。这里有个经典问题如果高优先级任务源源不断地进来低优先级任务可能永远得不到执行也就是饥饿。我们的解决办法是动态提升任务在 READY 状态下等待超过 10 分钟每过 10 分钟 priority 自动加 1直到升到最高级。这样既保证了紧急任务优先又给了长尾任务执行的机会。5.3 “不丢不重”怎么实现任务调度领域最核心的诉求就是“不丢不重”。不丢依靠的是每张任务表的状态持久化服务重启后调度器可以从数据库恢复现场不重则要设计幂等。实际上要做到全局严格不重需要 Agent 端配合实现幂等执行。举个例子Agent 执行完同步任务并写入了目标数据库但在回报结果的时候网络超时了。管理中心等不到回报会认为任务还卡在 RUNNING超时后重新派发。如果 Agent 拿到同一个任务再执行一次就会产生重复数据。我们的兜底方案是Agent 在执行每个任务前先检查本地最近一段时间的执行记录如果发现同一业务幂等键已经成功执行过就直接返回上次的结果。业务幂等键在任务创建时就带上了这个设计贯穿了任务生命周期的始终。6. 存储选型与可观测性建设6.1 为什么任务状态用关系型数据库持久化Agent Manager 初版有过一段“把所有状态都放缓存”的尝试结果线上出了几次状态丢失的问题。后来我们统一改成了关系型数据库持久化。原因很简单任务状态的变更需要事务保证需要版本号做乐观锁同时还可能被多个服务并发读写。这些问题用带事务和行级锁的关系型数据库来处理成本最低也最容易排查问题。缓存里的数据说丢就丢这对调度系统是致命伤。实际的部署里任务主表用 MySQL队列消费使用消息队列。MySQL 不承担高吞吐的消息推送只负责存状态和心跳压力可控。对于大量任务同时提交的场景我们通过批量插入和索引优化来支撑单库单表跑日均百万级任务量是没问题的。6.2 索引设计与分表策略任务表的索引设计直接决定调度器的扫描速度。我们常用的查询是按状态和下次重试时间捞可派发的任务关联条件加优先级排序。索引就放在 (status, next_retry_at, priority desc) 上。为了不让历史任务拖慢扫描我们增加了分区策略按创建时间做月度分区只保留最近三个月的热分区历史数据定时迁移到归档表。Agent 表的数据量很小但 last_heartbeat_at 会被高频更新。如果没有索引失联扫描会发现全表扫描很吃力加上 (status, last_heartbeat_at) 索引后判定失联的查询就变成了索引查找。6.3 监控指标体系与告警阈值可观测性是最容易等到出事了才想起来补的部分。Agent Manager 上线后我强烈建议至少把下面这些指标接入监控平台指标含义建议告警阈值queue_depthREADY 状态任务数大于 1000 且持续 5 分钟dispatch_latency从 PENDING 到 DISPATCHED 耗时均值大于 10 秒heartbeat_miss_count失联 Agent 数量大于 0task_timeout_rate超时任务占比大于 1%retry_rate任务重试占比大于 5%告警阈值不是固定的要根据业务规模调整。比如只有几十个 Agent 的小集群heartbeat_miss_count 等于 0 这个阈值就太苛刻了一次发布重启就能触发告警。我觉得更合理的做法是先跑两周记录正常基线再把阈值定在基线的两倍以上。7. 线上踩过的坑与完整排查链路7.1 时钟偏差导致的心跳误判某次线上告警显示大批 Agent 被判定为失联但登录到机器上看进程活着任务也都在正常跑。第一反应是网络问题但排查了一圈发现网络完全正常。后来怀疑是时钟问题。对比之后发现这批机器的系统时间和中心服务器的系统时间差了将近 30 秒。Agent 在机器 A 上每 5 秒发一次心跳但管理中心根据中心时间判断已经 20 秒没收到直接把 Agent 标记为失联。根因是容器宿主机没启用时间同步时钟慢慢漂移出了阈值范围。修复方案分两步基础设施侧强制所有节点启用 NTP 同步Agent Manager 侧放宽失联阈值并加入人工触发 Agent 主动重注册的接口。这里也提醒大家心跳判活不能只依赖时间戳关键时刻要允许 Agent 通过主动续租来“喊冤”。7.2 大量任务集中到点导致写风暴业务方习惯把任务统一定在整点执行结果每天整点时调度器会突然涌入几十万条任务。刚开始 MySQL 直接被写入了不少慢查询连带着其他业务都受影响。排查过程是这样的先看慢 SQL发现主要是任务状态批量更新的语句锁等待严重再看调度器日志发现每次扫描返回的数据量过大处理不过来。修复动作有三个。一是限制调度器每轮扫描的任务数改成分批处理模式二是任务提交接口加了一级缓存削峰请求先进入消息队列由消费者匀速写入数据库三是把整点任务的创建时间加上随机偏移量让提交方在整点前后分散提交。三层下来整点写风暴基本消失了。7.3 失联 Agent 上的任务重复执行心跳误判带来的连锁反应里最隐蔽的是重复执行。一个 Agent 被误判为失联后管理中心会把它的 RUNNING 任务重新标记为 READY 并派给其他 Agent。但原 Agent 其实还在继续执行任务就被跑了两遍。这个问题的排查链路比较长。先看任务事件流水发现同一个任务出现了两次 DISPATCHED 事件而且分派给了不同 Agent再翻看心跳记录确认原 Agent 只是因时钟偏差被判失联。修复方案分成两层第一层是前面说的修时钟和阈值避免误判第二层是 Agent 端在做任何任务前先向管理中心确认任务是否仍然归自己执行。也就是说执行确认不再是单纯的本地决策而是一次远程校验。多一次 RPC 虽然引入一点延迟但能换来回执行的确定性值得。7.4 内存队列堆积导致任务“假死”还有一个容易踩的坑是 Agent 本地内存队列堆积。Agent 从管理中心拉了一堆任务存在本地但执行线程池的大小设置不合理导致任务看似已派发、实际迟迟没被执行。管理中心这边因为 Agent 已经 ack 了就把任务保持为 RUNNING 状态也不会超时检测因为超时时间还没到。任务就像“假死”了一样。查这个问题的典型特征是Agent 心跳显示 current_slots 很小但任务表里大量 RUNNING。我们把派单逻辑改了Agent 每次心跳只接受和当前空闲槽位数量一致的任务不再本地囤积任务。同时给所有 RUNNING 状态的任务加上超时检测无论 Agent 有没有 ack只要超过 timeout_seconds 没有结束事件就会进入超时重试流程。8. 什么时候不必自研以及后续扩展方向8.1 判断是否需要自研的两个标准很多人看完会问这整套逻辑能不能直接拿开源方案替代我的判断标准就两条一是业务模型是否足够通用二是对调度过程的可观测性要求有多高。如果只是定时跑批、异步处理消息这种通用场景市面上成熟的任务中间件已经够用完全没必要自研一套 Agent Manager。但如果你需要高度定制任务状态机、需要把 Agent 资源模型和业务强绑定或者像我们一样要做多类智能体编排自研的灵活度会明显占优。Antigravity 选择自研还有一个原因是我们的任务和 Agent 之间有大量软路由规则某些任务只能跑在有特定数据权限的 Agent 上某些任务要按业务方隔离资源。通用中间件很难表达这种路由策略自研管理中心可以很自然地在派单前加上过滤规则。8.2 什么情况下直接用中间件更合理也有不少场景我会劝人别折腾。比如任务量在日均十万以下任务类型单一失败重试靠自带功能就能满足那直接套用成熟中间件是性价比最高的选择。自研看起来酷但后续的状态迁移、数据备份、版本升级、多租户隔离全都要自己维护。我之前见过一个团队因为自研调度器连续三个版本都在改状态机兼容性问题业务方却一直在等排期。所以“能不能自研”和“要不要自研”是两个问题。8.3 后续三个扩展方向Antigravity 的 Agent Manager 目前稳定运行但我们已经在规划几个扩展方向。第一个是 DAG 编排让一个任务的完成能自动触发下一批任务的创建而不是依赖业务方自己回调第二个是多集群化让一个管理中心可以管理跨多个网络区域的 Agent 集群心跳逻辑要做分区域聚合第三个是任务配额管理给不同业务方设置不同的最大并行任务数防止某个业务的任务突然占用全部 Agent 资源。这三个方向里我个人最看好 DAG 编排。因为任务一旦变多人工去维护任务之间的先后关系会非常痛苦把依赖关系下沉到管理中心业务方的接入成本会低很多。当然了每加一个特性状态机就会复杂一分。我的建议是扩展之前先把状态流转图和事件清单维护清楚否则改到后面没人能说清任务到底是怎么从创建走到终态的。最后分享一个我自己的体会Agent Manager 这种系统刚开始看会觉得状态机复杂、流程绕但真正让它变得难维护的往往不是代码而是对边界理解得不清楚。只要时刻记得“管理中心只负责调度不负责执行”很多取舍都会变得自然很多。如果你也在做类似的任务调度体系建议先画出自己的状态机再开始写第一行代码。
阅读完成 · 觉得有帮助?
咨询建站