"Agent-Reach"这个词第一次出现在我面前的时候,我正在为一套多Agent协作系统反复头疼。说白了,当时遇到的问题特别现实:Agent之间的任务消息发出去了,但根本不确定对方到底收到没有、执行没有、结果什么状态。早期的方案就是用HTTP回调加个超时重试,结果系统一忙起来就到处都是僵尸任务和重复执行。后来我花了大概三周时间,从需求梳理、架构设计到核心链路跑通,自己做了一个专门解决" Agent触达确认"问题的轻量框架,代号就叫Agent-Reach。这篇博文就把整个项目的设计思路、核心实现、压测数据和踩坑过程完整记录下来,给同样在折腾多Agent系统的朋友一条可以少走弯路的参考路径。
1. 为什么我决定自己动手做一套Agent-Reach触达层
1.1 多Agent协作的真正瓶颈不在模型能力,在"触达"
这两年大家都在强调Agent的规划能力、工具调用能力,仿佛只要模型够聪明,一套复杂的多Agent业务就能顺滑跑起来。但真正写过多Agent系统的人心里都清楚,部署阶段折磨人的往往不是Prompt写不好,而是一个最基本的工程问题:Agent A向Agent B发出指令后,怎么确认B真的拿到了,并且拿到了完整的那条指令。
传统做法无外乎同步HTTP接口或异步消息队列。同步接口的问题在于超时语义含糊——你调用了B的接口,但B在等大模型流式输出,5秒没返回,你算它失败还是算它成功?重试的话B那边可能已经执行了一半,轻则重复消费,重则污染下游数据。异步队列稍微好一点,但消息从Queue里被取走之后,如果Worker崩溃了,消息就丢失了,你在业务层根本感知不到——除非每次都在消费逻辑里自己维护"取到了正在处理"和"处理完成了"两个额外状态。
也就是说,市面上现成组件(消息队列、RPC框架、任务调度平台)确实提供了消息投递能力,但没有人专门站在"Agent与Agent之间"的角度做一套带确认链路、带优先级、带全局并发控制、还能优雅处理失败的调度层。这正是Agent-Reach想补齐的空位。
1.2 现有方案的痛点,以及我对"Reach"这个词的重新定义
在做Agent-Reach之前,我认真列过一个对比表格,把几种主流备选方案在一套典型多Agent场景下的表现捋了一遍。这个场景很简单:有三个Agent协作处理一批用户工单,A负责分类,B负责生成回复草案,C负责人工审核以及必要时的追问,每天流入的工单量波动极大,高峰期能到几千条,而且C的响应速度天然比A、B慢很多,因为涉及到人工审核环节。
| 方案类型 | 优点 | 致命痛点 |
|---|---|---|
| 同步HTTP调用 | 实现简单,链路上每个节点清晰 | 超时语义模糊,长耗时Agent场景下线程阻塞严重,重试会造成下游重复执行 |
| 普通消息队列 | 削峰填谷能力强 | 消费端崩溃后消息漂移不可见,没有针对Agent协作的优先调度,重试策略要自己造轮子 |
| 分布式任务调度平台 | 有重试有追踪,功能全面 | 太重了,部署一个调度中心就要好几台机器,小团队根本养不起,调度侧不感知业务方的并发水位 |
| 自己硬编码状态表 | 灵活 | 每个Agent对都要写一套状态管理,代码重复度极高,状态散落各处,排查问题时快疯了 |
表格里最后一种方案其实是我最初的写法,也是压垮我的最后一根稻草。我当时的系统里,每个Agent对之间都有一张task_status表,6张表逻辑基本一样但字段各有不同,排查一个问题要在6张表之间来回切换。后来我彻底想明白了,"Reach"不应该只是"把消息投递到"的意思,而应该是完整的三段式触达确认:请求成功送达(Sent/Received)— 节点确认接受(Acknowledged)— 任务最终完成(Completed)。Agent-Reach这个名字,就是从这段链路里提炼出来的。
1.3 Agent-Reach要解决的核心场景和设计目标
基于上面的分析,我在动手前给Agent-Reach定下了三个明确的设计目标,后面的每一行代码都是围绕这些目标展开的。
第一,必达语义。从A到B的每一次触达请求,无论中间发生Worker崩溃、网络抖动还是进程重启,都不会静默丢失。丢一条任务就像丢一张客户工单,结果不是技术问题,是业务事故。
第二,可控并发。多个Agent共享底层的计算资源,如果某个Agent因为收到大量子任务开始疯狂消费资源,其他Agent会被饿死。所以Agent-Reach必须具备"按Agent维度做并发配额"的能力——类似水龙头,谁也别想一次性把整根水管的水都抽走。
第三,可观测且能快速定位。一条触达请求从出生到完结的每一步状态变化、耗时、失败原因都要能被追踪。排查问题的时候,我能指着时间线告诉你:"这批任务在B那里卡了3分钟,原因是B的回复内容超长,Worker GC了两次。"这种粒度才算真正可观测。
后来实测下来,这三个目标基本都实现了。接下来我把Agent-Reach的核心架构和我在实现过程中的关键决策讲清楚,这部分是整个项目里最值得抄作业的地方。
2. Agent-Reach的核心架构:三个角色加一条确认链
2.1 三个核心组件:Gate、Dispatcher、Worker
Agent-Reach在逻辑上被拆成三个角色,它们可以部署在同一个进程里(单机轻量模式),也可以拆开独立部署(大规模分布式模式)。这三种角色我在代码里分别命名为Gate、Dispatcher、Worker,命名上刻意选用了网络链路里面的既有术语,让团队成员一看到名字就能猜到职责。
Gate是Agent-Reach的唯一入口。所有Agent对外发出的触达请求,不管目标是另一个Agent还是某个外部服务,都必须经过Gate。Gate只做三件事:一是校验请求的合法性(目标节点是否存在、请求体大小是否超限、幂等键是否带上);二是把请求写入可持久化、可回放的待触达队列;三是立即给调用方返回一个带有任务ID的受理回执。注意这个回执只代表"我收到了你的请求",不代表"目标Agent已经执行了",这种语义分离是Agent-Reach跟HTTP同步调用的本质区别——发起方再也不需要长时间占着一个线程等结果了。
Dispatcher是Agent-Reach的大脑。它维护着一张全局路由表,知道哪个Agent在哪台机器、哪个队列上,也知道每个Agent当前的忙闲程度和并发配额。Dispatcher从待触达队列里拉取任务后,会根据目标节点的负载、优先级、依赖关系做调度决策,把任务派发给对应的Worker。Dispatcher同时负责跟踪每个任务的生命周期:哪些任务派发超过30秒还没有收到Worker的确认回执,就要进入待重试队列;哪些任务确认执行了但超过业务方约定的大时间窗口还没完成,就要触发超时告警。这个模块是整个系统里状态最复杂的地方,也是后面所有坑的源头,稍后我会专门拆开讲。
Worker是Agent-Reach的触手,运行在每个Agent旁边。Worker向上对接Agent的统一接入接口,向下对接Agent-Reach的消息通道。它的工作逻辑理论上很单纯:从通道里拿到任务、把任务转交给Agent实体去执行、执行完把结果回报给Dispatcher。但这里有个非常关键的实现细节——Worker拿到任务后,必须先回一个ACK确认包,再开始调用Agent的推理逻辑。如果不先回ACK,执行Agent的耗时就会占用整个链路的心跳窗口,一旦Agent执行超过超时阈值,Dispatcher会以为Worker没收到消息而重复派发同一个任务。我看过不少自研组件的实现,80%的重复执行问题都是栽在这个小细节上。
2.2 触达确认链:请求、确认、完成三段式状态机
整个Agent-Reach的运行逻辑,可以浓缩成一张三段式状态机。
- 第一个阶段叫
REACHED,Gate收到请求写入持久化队列后进入此状态,代表"请求已经稳妥地躺在Agent-Reach系统内部",还没出发。 - 第二个阶段叫
ACKED,Dispatcher把任务派发给Worker,且Worker回包确认收到后进入此状态,代表"目标Agent已确认收到请求,正在用自己的节奏处理"。 - 第三个阶段叫
DONE,Worker回报任务结果,Dispatcher确认结果完整后进入此状态,代表"一次完整触达闭环彻底结束,结果可查可回放"。
从REACHED到ACKED这段,Dispatcher会等待最长15秒(可配置),超时未收到ACK就重新投递,最多投递3次。从ACKED到DONE这段,Dispatcher不设自动重投,因为这个阶段Worker可能正在执行一个耗时2分钟的大模型推理任务,强行重投只会造成A和B两边同时干同一件事的重复执行灾难。这个阶段靠的是业务方主动上报心跳或者超时告警,由人工或上层编排系统决定要不要做补偿处理。
这个三段式的核心设计意图,是把"消息投递的可靠性"和"任务执行的幂等性"彻底分开。消息投递阶段由Agent-Reach保证不丢、不重、不乱序;任务执行阶段由业务方通过幂等键保证重复执行不引发副作用。两边各管一摊,职责清晰,排查的时候也不会互相甩锅。
2.3 为什么要用持久化队列做触达,而不是直接在内存里转
我在开发Agent-Reach的第一个版本时,是从内存队列起步的,用Go的channel天然就支持并发安全。但后来做了个简单的故障注入测试,直接把Worker进程kill掉再重启,内存里没消费完的任务全部消失。那一刻我意识到,Agent-Reach这种定位的系统,可靠性是它的生命线,内存队列绝对不能作为生产级别的存储。
后来我换成在Gate这一侧用一个落盘日志文件先记录下每个入站请求的完整内容和状态快照,Dispatcher消费时定期清理已完成的日志条目。这个设计看起来有点笨,但好处非常实在:进程重启之后,Agent-Reach能通过重放那段文件把还没DONE的任务全部捞回来,再次派发,实现一次完整的必达闭环。这套机制后来在正式环境里真救过我一次——某次发布时调度进程意外退出了三百毫秒,恢复之后所有在途任务一条没丢,我翻日志看到完整的重放记录,那感觉直接值回票价。
3. 调度算法与必达策略:从贪心调度到约束优化的演化
3.1 初始版本的贪心调度为什么总是"漏活"和"饿死"
Agent-Reach的调度策略经历了一个非常典型的演化过程。第一个版本我写得很天真:Dispatcher每次从队列头部拉取20个任务,然后遍历路由表,看到哪个Agent当前没活儿就塞给谁,塞满为止。这个贪心策略的原理很简单,想让每个Agent时时刻刻都处于饱和工作状态,利用率最大化。
但压力测试一跑,问题就暴露了。场景里Agent B是负责生成大模型的回复草稿,耗时波动极大,碰上长文本可能一次执行要40秒,短文本只需3秒。贪心调度发现B空闲就去塞活儿,结果B手里同时堆积了十几个任务,每个都要跑几十秒,整体吞吐率反而不如每次只让B手里同时待一个或两个任务。典型的"看起来忙碌,实际上全堵在路口"。
更致命的问题是"饿死"。当分类Agent A产出一批快速任务时,贪心调度会优先把任务全部派给响应快的Agent,而把这些快速任务后面跟着的长耗时审核任务C永远排在队列底部,等前面的活儿清完才开始处理。业务方发现C的队列积压超过20分钟,大惊失色跑过来问我是不是C挂了,实际上C全程闲着——是调度器根本没轮到它。
3.2 关键性能瓶颈:重试风暴和死信积压
修正了贪心问题之后,调度器引入了基于负载水位的动态分配逻辑。这个版本跑通了,但也把一个隐藏问题彻底暴露了出来:重试风暴。场景是有个下游Agent依赖的第三方API临时抽风,返回风控异常,导致它发出的触达请求全部执行超时并进入重试队列。重试队列很快被撑到上千条,每条在投递失败后都会等15秒再次派发,而对同一个下游节点的投递请求同时堆积了大量并发,反而把本来就脆弱的第三方打得更死,形成了一个慢速重试风暴。
我后来查监控记录,那个下午的调度的重试计数曲线几乎是45度角往上冲。最离谱的时刻,某个Worker进程同时收到了26条针对同一目标环境的重复请求,目标环境直接拒绝服务了。重试本意是增加触达成功的概率,最后却变成了主动制造故障的放大器。
而重试风暴的伴生恶果是死信激增。我一开始没有设置最大重试次数,理论上一条任务可以无限重试下去,就像一封永远送不到的信,邮局还会无限次尝试。日志里积累了上万条永远重试不下去的死信任务,挤占队列空间,拖慢Dispatcher的扫描速度。短期看只是占存储,长期看整个系统都会因为一堆永远没有结果的任务逐渐瘫掉。
3.3 最终方案:优先级时间片加退避组合调度
被这些问题教育了一轮之后,我把Agent-Reach的调度器改成了一套三模块协同的调度算法:优先级队列、时间片轮转、指数退避。这套方案算不上一鸣惊人的原创,但组合在一起的效果相当稳。
优先级队列很简单,每个触达任务在入队时被打了三个级别:P0是全局依赖的强一致关键操作,P1是紧急业务,P2是普通批处理。Dispatcher的每次拉取窗口里,P0最多占有全部配额的60%,P1占30%,P2占10%,这是为了防止P0任务一多直接把P2彻底饿死——哪怕再低优先级的任务,系统也要保证它在可接受时间范围内有执行机会。
时间片轮转则针对"单个Agent被连续堆活"的问题。每个Agent每轮调度周期内最多接收N个任务(这个N根据Agent的历史平均耗时可自动调节),一旦达到配额,本轮的分配窗口关闭,下一轮周期再开启。这样后面排队的其他Agent至少每周转周期都能分到处理机会,系统的总吞吐虽然单看没提高多少,但各Agent任务完成时长的P99从原来的860秒降到了210秒,这说明"调度公平性"直接转化成了终端的响应体验。
指数退避解决的是重试风暴。每次投递失败,任务的退避等级加1,下一次再投递的时间等于baseInterval * 2^等级,同时加上一个0到5秒的随机抖动。第三次重投的间隔就已经长达40-60秒,大量失败任务的重试不会在同一时间段内同时涌向同一个目标节点,相当于给故障节点留出了恢复喘息的时间。配上最大重投上限(我设置的是5次,5次仍失败则转入死信表),系统的重试风暴彻底成为历史。
调度这一块的教训,用一句话总结:调度器不能只追求单个环节的"忙",而是要追求整个系统端到端的"稳"。单点利用率再高,全局链路被打爆照样是零。这个认知直接影响了我对Agent-Reach的定位——它不是加速器,而更接近一个交通警察,在高峰期做合理的车流疏导。
4. 轻量落地实践:2000行代码内跑通Agent-Reach核心链路
4.1 数据结构设计:任务对象和路由表
Agent-Reach的核心数据结构没有想象中复杂。任务对象Task包含六个核心字段:task_id是全局唯一键;source_agent和target_agent标识触达方向;payload是真正要给目标Agent看的指令内容(我用JSON字符串存);status是上述的三段式状态机;retry_count记录当前重试次数;created_at和updated_at是两个时间戳,用于超时判断和调度排序。
路由表的实现我参考了DNS的分层思路。它由两条表组成:第一条是静态注册表,每个Agent注册自己的默认Worker地址,相当于A记录;第二条是动态状态表,记录每个Worker当前的负载水位、最近心跳时间、是否在退避窗口期,相当于实时健康检查结果。Dispatcher派发前先去动态状态表做一次过滤,把处于退避期的目标例外掉,再走静态注册表找到实际地址。
这两个数据结构加起来不超过150行代码,但它们解决了Agent-Reach的存储基础问题。后续任何需要查询"某个Agent的任务最近什么状态"的场景,都只需要在任务对象上做条件筛选,不需要再维护六张逻辑相同的表了——这直接把我之前最烦的心结解开了。
4.2 状态机流转与超时重投的实现要点
状态机的流转我用一个极其朴素的函数实现,核心逻辑就一个有限状态表的查表动作。每一步操作前先做幂等键校验:如果一个到达的任务ID在已完成表里能查到,就直接忽略;如果查到它已经在ACKED状态,说明同一个ID的重复投递发生了,也直接忽略。这个幂等键的作用面是整条触达链路,而不仅仅是存储层——它同时拦截了调用方的重复入队和Dispatcher的重复派发。
超时协程是另一个关键实现。Dispatcher有一个每5秒触发一次的扫描协程,遍历所有处于REACHED或ACKED状态但长时间没有进展的任务。这里有一个取舍:扫描把内存里的全部在途任务遍历一遍,在任务量超过10万条规模时会消耗过多的CPU循环。后来我加了一个倒排时间索引,把所有任务的next_check_at时间戳放进一个小顶堆里,每次只取堆顶前500条判断即可,扫描任务复杂度从O(N)降到了O(logN + K),实测在5万条在途任务下扫描协程的CPU占用率从8%降到了0.4%。
4.3 用Go实现核心状态机的代码片段
虽然Agent-Reach的整体技术栈我选择了Go来写主调度模块,因为它的并发模型非常适合处理大量待触达任务的并发扫描,但状态机本身的语言无关性很强。下面是我实际在用的核心函数结构,逻辑简单到任何语言的背景都能看懂:
func (d *Dispatcher) advanceTask(task *Task, event string) error { transitions := map[string]map[string]string{ "REACHED": {"dispatch": "ACKING"}, "ACKING": {"ack": "ACKED", "timeout": "REACHED"}, "ACKED": {"success": "DONE", "timeout": "FAILED"}, } next, ok := transitions[task.Status][event] if !ok { return fmt.Errorf("非法状态流转: %s + %s", task.Status, event) } task.Status = next task.UpdatedAt = time.Now() switch task.Status { case "ACKING": return d.persistTaskStatus(task, "ack_pending") case "ACKED": return d.persistTaskStatus(task, "acked") case "DONE": return d.completeAndClean(task) case "FAILED": return d.pushToDeadLetter(task) } return nil }这里有一个值得留意的点:状态机的每个合法转换都伴随一个持久化动作,而且持久化动作放在状态更新之后,绝对不能反过来。如果先持久化了"DONE"再更新内存状态,一旦持久化失败,内存里任务还停在"ACKING",另一个协程可能又把它投递到Worker那边,造成重复执行。状态一致性的关键是"状态变更和持久化是一个原子动作,或者至少要在变更失败时回滚内存状态"。
4.4 部署形态:单机全进程模式与分布式扩展
Agent-Reach的代码结构天然支持两种部署形态。小规模团队或者是开发环境,可以把Gate、Dispatcher、Worker放在同一个进程里跑,配置全部通过一个YAML文件控制,连接同一台本机上的SQLite或PostgreSQL做持久化,启动一条命令:./agent-reach --config config.yaml。这种模式的好处是零基础设施要求,非常适合本地调试。
到生产环境需要提高吞吐时,可以把三个进程拆开独立部署。Gate可以水平扩展多个实例,因为它无状态;Dispatcher最好保持单实例运行,因为调度决策要全局一致,多个调度器同时决策会导致同一个任务被两个人派发;Worker天然支持横向扩缩容,哪个Agent的负载高就给它的Worker队列多开几个副本。模板化的部署用Docker Compose就能编排全套,不需要上K8s。
如果项目规模再大一个量级,我建议在Agent-Reach和底层存储之间加一层Redis做缓存与锁,核心目的是控制多Dispatcher实例的互斥,但那是上到很大规模才需要考虑的优化。我现在的生产环境,单实例Dispatcher配合两块SSD和16GB内存,已经可以稳定支撑每秒450个Agent触达任务的调度,日常业务量连它一半都用不到。
5. 压测数据与参数调优:Agent-Reach关键时刻的真实记录
5.1 压测场景和测量指标
顺手测一下Agent-Reach的极限数据,这也是我最常被同行问到的部分。我的压测环境是单台8核16G的云主机,存储用本机的PostgreSQL,业务模拟是这样的:11个Agent互相发起触达任务,其中1个Agent故意被设置了50%的概率执行失败,以观察重试链路的反应;另1个Agent被设置了8秒的慢响应延迟,来模拟大模型推理耗时的抖动。
测量指标我重点看四个:任务吞吐量(每秒成功完成DONE的任务数)、P50/P99任务端到端耗时(从入队到DONE)、重试比例(总投递次数/总任务数-1)、死信率(进入死信表的任务占总数的比例,这个值在业务里代表真正需要人工介入的坏账任务数)。
5.2 基线压测:300 QPS下成功率超过99.7%
第一轮压测配置比较保守,目标负载300 QPS,调度协程数设为4个,Worker并发上限8个,退避参数base 5秒。跑30分钟后,结果令人满意:总投递量约54万次,成功完成约53.8万次,端到端P50耗时240毫秒,P99耗时1.8秒,重试比例只有2.2%,死信率0.03%,系统内存占用稳定在3.2GB。
这个结果说明Agent-Reach在中等负载下,必达链路本身几乎没有弱点。触达动作的耗时主要消耗在落盘持久化和状态字段更新上,调度器自身的开销几乎可以忽略。Gateway到Dispatcher之间的确认回执响应在微秒级,所以端到端耗时基本等于目标Agent本身的真实处理时长,这说明Agent-Reach的链路开销几乎没有给业务增加多余的延迟负担。
5.3 峰值压测和故障注入:找到性能悬崖和瓶颈
第二轮压测更激进,目标负载直接拉到800 QPS,相当于给日常流量来了个洗牌式的极限施压。结果毫不意外地触顶了:每秒完成的DONE任务数峰值到约460,之后就再也上不去,CPU使用率已经到85%以上,P99端到端耗时飙到14秒。这次压测暴露的瓶颈很清晰——不是Dispatcher的调度计算,而是PostgreSQL的写入压力。
我盯着详细的火焰图分析之后,定位到问题出在状态转换持久化的写放大:每个任务从REACHED到DONE一共要写三条状态记录,800 QPS意味着每秒要向数据库写入2400条状态变更,单库明显扛不住。解决思路也很直白:状态写操作放到本地持久化里,采用异步批量刷盘的方式,PostgreSQL只存任务核心元数据。改造后实测,在同样800 QPS压力下,吞吐量涨到每秒720次DONE,P99降到3.1秒,PostgreSQL的CPU占用率从70%降到了11%。
当然这里要说明,异步刷盘会牺牲一丁点可恢复性(进程掉电时最多丢失几秒内的状态),但在我的业务场景里,这属于可接受的和不可接受的之间的一个清醒权衡。如果要绝对不丢,可以加WAL日志,代价是写入性能再降一点。
5.4 各参数对性能的真实影响和调优建议
压测做多了,自然积累出几个关键参数的手感。我把调优经验总结成了一张表,方便有类似场景的人按图索骥:
| 参数 | 过小的影响 | 过大的影响 | 我最终的生产配置 |
|---|---|---|---|
| 调度协程数 | 队列扫描不及时,任务排队时间变长 | CPU争抢加剧,跑调度协程本身的开销抵消收益 | 4个协程 + 最大队列深度10万 |
| Worker并发配额 | Agent算力闲置,吞吐低 | 单个Agent垄断资源,其他Agent体验恶化 | 按Agent历史平均耗时的1.5倍动态调整 |
| ACK等待超时 | 稍慢的Agent会被频繁误判为未收到,引发大量重投 | 故障Agent故障后任务停留时间变长 | 15秒,慢Agent单独配置30秒 |
| 重试投递最大次数 | 偶发抖动就会死信,业务被迫频繁人工介入 | 故障Agent持续被重试压垮,死信堆积越多 | 5次,之后自动进死信表 |
| 退避基础间隔 | 太小衰减不了重试风暴 | 太大导致故障恢复后的任务响应迟滞 | 5秒,指数倍率2,抖动上限5秒 |
这些参数的取值当然不是铁律,但它们反映出的是一个共性思路:Agent-Reach的调度参数,本质上是在"快速恢复触发"和"避免故障放大"之间做折中。任何一个参数单独调大或调小都往往有副作用,必须在整套参数上做全局微调。我每次上线新Agent节点时,都会先以这套基准参数跑一套小规模压测,再根据业务方对P99耗时的要求做上下微调。
6. 一路踩过的坑:Agent-Reach在真实环境里的翻车教训
6.1 一致性窗口导致的重复投递,以及幂等键的设计
Agent-Reach上线运行大概半个月后,业务方偶然发现,有极少数任务出现了"Worker明明已经执行完成,但系统里又出现了一条一模一样的新任务"。我查了Task对象的updated_at,发现这两条记录时间戳几乎重合——典型的"重复投递窗口"问题。
原因是Worker执行完成后,先给Dispatcher回报了DONE,但在回报消息到达Dispatcher之前,Dispatcher那个调度协程正好在做超时扫描,发现这个任务还停在ACKING状态,于是触发了重投策略,把任务再次派发给了Worker。两个动作几乎同时发生,首批的执行完成状态还没来得及落库,第二条新任务就已经生成了。
这个问题的根治方案是加宽幂等键的校验范围:Agent-Reach在收口回报消息时,不只是按task_id去重,而是按task_id + current_ack_token去重。ack_token是每次投递时生成的一次性随机串,Worker收到任务时把它原样保存,回报完成时原样带上。这样哪怕DONE消息和第二次投递消息同时到达,由于Token不同,也绝对不会把新任务当作旧任务去重掉。这个细节看似简单,但如果不加,重复执行会像幽灵一样在高层级业务逻辑里潜伏很久。
6.2 状态清理与死信表的运维实践
死信表的处理,一开始我的设计就三个字:放着呗。结果生产运行一周后,死信表的磁盘占用接近20GB,约几万条永远不动弹的任务挤在那。虽然查询它们的性能并不差,但每次做全表统计分析时,都会把这堆"僵尸数据"算进去,给了运维团队一个虚假的"系统健康度"数据。
代理修复死信的方式我后来坚持两条原则:第一条是死信不自动清理,必须人工确认后归档,因为每条死信背后对应的都可能是真实业务事故,自动清掉等于把问题藏起来;第二条是死信表只保存核心元数据,不保存payload大字段,payload挪到对象存储并按日期分桶,这样死信表的体积会被控制得很小,人工扫一眼做判断也不费劲。
现在这套运维流程跑下来,死信每周顶多新增20条左右,每周一早上我花五分钟点开死信页面逐条标记原因,然后按周打包归档。能处理成这样的状态,恰恰说明系统大部分时候是健康的,死信的存在并没有让我焦虑,反而变成了一个观察系统稳定性的晴雨表。
6.3 日志可观测性与关键链路追踪的取舍
Agent-Reach运行过程中,真正最消耗我时间的是排查"某条任务为什么卡住了"。最初版本的日志是分散打的,Gate打一条、Dispatcher打一条、Worker打一条,关键信息散见在三四个组件的日志文件里,每次都要靠时间戳拼凑现场。这让我意识到,Agent-Reach这种系统的可观测性不能靠"日志多"解决,要靠"一条链路一次追踪"解决。
所以我在Agent-Reach里加了一个trace_id字段,在Gate入口处生成并伴随任务的一生,所有组件的日志都必须携带这个ID。配合一个简单的日志聚合器(我用的是ELK的半套精简配置),就能在界面上直接输入trace_id把一条触达任务从入队到DONE的所有日志按时间顺序拉成一条时间线。排查问题的效率,从原来平均45分钟解析日志缩短到大概3分钟。
这个追加可观测性的代价是日志量总数提升了三成,但换来的是从"出事后抓瞎"到"出事时立刻定位"的体验跃迁。特别是在生产环境同时运行几十个Agent的情况下,没有这个链路追踪能力,几乎不可能做好Agent系统的日常运维。
Agent-Reach这套东西,大概就是这段时间我自己实践的全部沉淀。它从最开始一套简单的轮子,经过调度演化、可靠性加固、可观测性补全,最终变成了一套能应付真实业务压力的轻量框架。整个开发过程中最深的感受是,Agent与Agent之间的协作问题,表面看拼的是智能,实际上拼的恰恰是这些笨功夫——消息确不确认、状态跟不跟得上、重试急不急、日志能不能追到源,每一条都是实打实的工程活。希望这份实战记录,能帮正在搭多Agent系统的人少踩几个我已经踩平的坑。