最近在做一条跨业务的智能化流程改造,最开始只是让几个独立的Agent各管一段流程,跑着跑着发现不对劲:单看每个Agent都挺聪明,但只要涉及接力协作,就全靠人肉中转——把上一个Agent的输出复制粘贴到下一个Agent的输入框里。原本设想的智能体流水线,硬生生变成了"智能打字员"。
被这个问题折磨了大半个月之后,我搭了一套内部代号叫Agent-Reach的轻量级Agent触达协同层。它不搞大而全的框架,只解决一件事:Agent之间怎么互相找到、怎么传任务、怎么拿结果。这篇文章把它的核心设计、落地细节和踩坑记录完整拆出来,适合正在做多Agent协作、智能体编排,或者正被Agent间通信问题搞到头秃的人参考。我不会只给结论,会把每个决定背后的理由也讲清楚,这样你们拿到自己的场景里,才知道哪些能直接抄,哪些要改。
1. 单Agent跑得好好的,多Agent一协作就崩:问题出在哪
1.1 每个Agent都会干活,但谁都不认识谁
我先描述一下当时的具体处境。项目里有四个Agent各自负责一个环节:订单解析Agent负责从工单文本里提取订单号和问题类型;物流查询Agent负责对接物流接口返回轨迹;处理建议Agent根据订单信息和物流状态给处置方案;话术生成Agent最后把方案翻译成给用户的回复。
单独看每一个,效果都合格。但一碰到真实工单,整个流程就走不动了。原因特别朴素:四个Agent部署在不同的服务里,彼此之间既没有调用关系,也没有共享状态。订单解析Agent输出的JSON,只能由人把它粘到物流查询Agent的输入框里。一开始我以为是自己工程化没做好,后来仔细一想,根本问题在于——我们的Agent是"单体式"搭建的,脑子里就没有"触达"这个概念。
所谓Agent-Reach,按我自己的定义,就是一套让Agent具备"触达其他Agent能力"的连接机制。它不管Agent内部怎么思考、用哪个模型,只管三件事:目标定位(找谁)、任务传递(传什么)、结果回传(怎么收)。这个思路类似我们平时调API,只是被调用的对象变成了Agent,请求和响应都带有一定的不可预测性。
1.2 主流编排框架的问题:要么太重,要么绑得太死
在动手写Agent-Reach之前,我其实调研过一圈现成的Agent编排方案。有些框架确实功能齐全,自带记忆、规划、工具调用、多Agent对话,社区也活跃。但我实际试下来发现,它们有两个共性特征让我不太敢用到生产环境里。
第一个是重。为了跑通最简单的A调B,需要先完成一堆概念的学习——什么是Plan-and-Execute、什么是GroupChat、消息是怎么在内部走的。大部分框架有自己的一套抽象,一旦用上,业务代码就得长在框架的模型里,后面想替换组件、想定制路由,都变得很难。
第二个是隐含绑定。不少框架的Agent协作,实际是"对话式"的,靠一个中心协调者把消息转来转去。听起来很美,但到生产环境里就会遇到现实问题:如果某个Agent需要串行处理50个订单,协调者要被阻塞多久?如果一个Agent返回了不符合预期格式的内容,协调者要重新解释多久?
我需要的是"最少规则"的协同方式——Agent之间不搞复杂的对话,而是像发工单一样同步/异步地触达,把任务交给对方,约定好格式,按超时机制处理失败。Agent-Reach本质上就是围绕这个思路做的一层极薄封装,而且它不绑定你的Agent内部结构,你完全可以让LangChain的Agent、自研的函数调用Agent、甚至一个简单的规则脚本,共存在同一套触达网络里。
1.3 Agent-Reach的定位:既不是框架,也不是平台
如果你去GitHub搜Agent-Reach,大概率搜不到什么官方仓库,因为这是我们内部的代号。我认为这种命名方式对开发团队有一个隐含好处:大家会把它当做一个约定来遵守,而不是当做黑盒组件来依赖。
它的定位如果想用一个类比说清楚,就是"公司内部的通讯录+工单系统"。每个Agent入职时,先到通讯录里登记:我叫什么、我有什么能力、我接收什么格式的任务、我的处理时限是怎样的。别的Agent要找人干活,不直接拨电话,而是通过工单系统发出一个任务信封,由系统负责投递、催办、记录状态,最后把回执返回给发起方。这个过程就是Agent-Reach的全部。
所以,下文里讲到的所有组件——Agent注册中心、触达路由、任务信封、状态存储、结果归一——都是围绕"通讯录+工单系统"这个心智模型展开的。你后续自己实现,也建议先保持住这个模型,不要在早期为了追求"智能"把架构做复杂。
2. 触达层核心设计:注册、路由、任务信封一个都不能少
2.1 Agent注册中心:每个Agent先报个到
Agent-Reach的起点是注册中心(Registry)。我在设计它时只保留了四个字段:agent_name、capabilities、endpoint、health_check。
{ "agent_name": "order-summarizer", "capabilities": ["order.query", "order.summarize"], "endpoint": "http://agent-order-summarizer:8081", "health_check": "/healthz" }这里有个很容易被忽略的设计点:能力(capability)的命名。刚开始我把能力名取得很随意,比如"订单总结"、"物流查询",结果下游Agent在发起触达时根本不知道该怎么写目标。后来统一改成"领域.动作"的两段式命名,例如order.query、logistics.query、policy.recommend。这样做的好处是路由逻辑可以按前缀做粗粒度匹配,比如外部工单系统只想找处理"订单"相关能力的Agent,就匹配order.*。
注册信息我存在Redis里,设置了60秒的TTL,每个Agent每30秒续期一次。用TTL而不是手动注销,主要考虑Agent可能异常崩溃,与其等一个"下线通知"不如让过期机制自己兜底。这个方案不完美,但真的很省心,我实测下来,一个Agent宕机之后最迟60秒就会被触达层剔除出可用列表。
2.2 触达路由:找得到,还要选得对
有了注册信息之后,触达路由(Router)要回答的问题就是:请求里的capability,对应到哪个Agent?如果一个能力有多个Agent声明了,选谁?
我最初做成的是精确匹配:capability字段必须和注册时完全一致,不然就报错。上线第一天就翻车了——有个Agent注册时写的是logistics.query.track,调用方写的是logistics.query,精确匹配找不到。后来我把匹配规则改成"最长前缀优先",即先按完整字符串匹配,找不到就逐级缩短,logistics.query.track匹配不到时尝试logistics.query,还匹配不到就尝试logistics。从效果看,这套规则的灵活性比精确匹配高很多,又不会像模糊匹配那样出现误命中。
当多个Agent都能处理同一个capability时,我的选择策略很简单:按注册时的优先级字段排序,默认取第一个。优先级的取值来自各业务线的协商——比如自研Agent比第三方脚本优先级高,因为你更可能控制它的行为和格式。这个策略看起来不够"智能",但在没有明显调度约束时,简单稳定比花哨重要。
2.3 任务信封:触达参数的标准化封装
触达层最关键的一个约定,是把所有Agent间请求包成一个标准信封。我把它叫做Reach Envelope,结构如下:
{ "reach_version": "1.0", "task_id": "reach_c9f2a1d3_20240517_001", "trace_id": "trace_8e19b0c4", "source_agent": "intent-router", "target_agent": "order-summarizer", "capability": "order.summarize", "payload": { "order_ids": ["20240517001", "20240517002"], "include_status": true }, "deadline": "2024-05-17T12:30:00Z", "idempotency_key": "order-summarize-20240517-001", "callback": { "type": "http", "url": "http://intent-router/callback" } }最初写这版时,我只放了source、target、payload三个字段,后来生产环境的故障逼着我一个一个补齐。
deadline字段是为了解决"到底该等多久"的问题。Agent和普通API不一样,一个LLM推理操作动不动就要几十秒,如果所有触达请求都按传统的3秒超时来处理,回调里只会收到一堆失败。我给每个Agent注册时都设置了timeout_policy,比如查询类默认60秒,总结类120秒,重分析类300秒,而信封里的deadline会覆盖这个默认值,允许调用方按具体任务微调。
idempotency_key字段则是为了解决重试时的重复执行问题。这个我在后文的踩坑章节会展开,总之没有它之前,我差点因为一次网络抖动的重试,给客户发了两遍相同的补偿方案。
2.4 同步、异步和回调:这层没有标准答案
确定任务信封之后,紧接着要决策的是通信模式。Agent与Agent之间到底是同步等结果,还是异步发出去就不管了?两种都试过之后,我的结论是:按任务性质拆分,不要一刀切。
对于耗时长、且发起方不需要立即拿结果的任务,比如"批量总结1000条评论",我走纯异步:发完信封,状态记成PROCESSING,Agent处理完成后通过callback通知发起方。对于耗时中等、发起方需要等结果做后续判断的任务,比如"查询订单状态后再决定下一步动作",我用同步等待,但设置上限时长,超时就按可降级方案处理。
从实现角度来看,同步等待本质上就是异步通信+阻塞收结果,所以我统一在底层走异步,只在外层暴露同步接口。这样后面加WebSocket、加流式返回,底层不用推倒重来。
3. 状态追踪与结果汇聚:触达之后的链路才是真正考验
3.1 把每次触达变成可观测的状态机
Agent-Reach跑起来的第二天,我就意识到一个问题:信封发出去了,但中间发生了什么,全黑盒。任务到底是被路由到了目标Agent?还是目标Agent处理了一半崩了?还是结果回传时丢了?要回答这些问题,必须给每次触达定义一个状态机。
我定义的状态流转是:CREATED -> ROUTED -> ACCEPTED -> PROCESSING -> COMPLETED/FAILED/TIMEOUT。
每个状态都会在Redis里更新,并且和task_id、trace_id关联。这套机制看起来很简单,但给了我三个非常大的好处:第一,触达层可以准确判断一个任务是否卡住了,超过deadline还没到COMPLETED就直接标记TIMEOUT并触发降级;第二,发起方Agent可以通过查询状态拿到最新的position,自己决定是继续等等还是换一条路径;第三,巡检脚本可以直接扫Redis里的状态分布,一眼看出哪个Agent的失败率异常。
我还顺手在触达层的日志里统一打印了trace_id,这样从工单系统发起,到订单解析Agent,再到物流查询Agent,整条链路的日志可以用一个trace_id串起来。排查问题时不用再靠时间戳大海捞针。
3.2 结果归一的兼容处理:大模型输出不可控是常态
状态追踪做完后,下一个硬骨头是结果汇聚。在传统API调用里,接口返回的字段是固定的,但在Agent场景里,目标Agent的"输出"往往掺着自然语言、JSON片段、Markdown表格,甚至还有一段代码。如果触达层不做事先的格式约束,发起方Agent每次都要猜对方返回的是什么。
我的方案是在触达层加了一道"结果归一"的转换逻辑。每个Agent在注册时声明它返回结果的content_type,目前支持json、text、html、csv四种。触达层拿到结果后会做两件事:一是检查声明的content_type和实际返回是否一致,不一致就做一次修复尝试;二是把结果封装成统一Resp信封再回传。
统一Resp信封的样例如下:
{ "task_id": "reach_c9f2a1d3_20240517_001", "source_agent": "order-summarizer", "result": { "content_type": "json", "data": { "summary_text": "...", "total_orders": 2, "avg_delivery_days": 3.5 } }, "status": "COMPLETED", "elapsed_ms": 8472 }这里要强调的一点是:触达层不去理解"结果语义"是否正确,只保证格式可解析、结构一致。语义对不对、质量高不高,是发起方Agent的事。边界划清楚之后,触达层的代码量得以保持精简,没有陷入"帮Agent改答案"的无底洞。
3.3 一个完整的触达链路案例
我把一个真实工单的触达过程放出来,方便大家对照理解整条链路。
用户提交工单:订单20240517001一直没发货,申请退款。此时工单系统调用Agent-Reach,往订单解析Agent发capability=order.extract的信封。订单解析Agent提取出订单号、问题类型、客户诉求后,返回归一化的JSON。随后工单系统继续发起第二轮触达,capability=logistics.query,信封里带上order_id,目标物流查询Agent返回物流轨迹。第三轮触达,capability=policy.recommend,由处理建议Agent综合订单信息给出处置意见。最后第四轮触达,capability=reply.generate,话术生成Agent产出最终回复。
整个过程里,Agent-Reach像交换机一样坐在中间,每一轮触达都有task_id关联,任何一个环节超时或失败,状态机都会暴露问题,而不是让整条工单卡死在某个Agent的输入框里。这套链路跑通之后,我最大的感受是:多Agent协作能不能落地,考验的不是某个Agent的"智商",而是系统能不能把"触达"这个动作标准化。
4. 上线后踩过的四个坑:超时、幂等、上下文冲突、数据校验
4.1 超时配置:LLM推理速度逼我重写超时策略
第一版Agent-Reach里,所有触达请求默认超时时间是30秒。上线不到半天,我就收到了物流查询Agent的超时告警,查日志发现Agent实际处理只花了35秒,但触达层在30秒的时候就已经报TIMEOUT了。也就是说,任务并没有失败,是被触达层强行"判死"了。
这个问题本质上是因为我把传统微服务调用的超时习惯带了过来。微服务之间的纯计算接口确实该给短超时,但Agent背后是LLM,一次生成可能就要20秒,整个处理链路里还可能有多次模型调用。30秒远远不够。
后来我把超时设计改成分级:注册时每个Agent声明自己的timeout_policy,触达层优先使用信封里的deadline,如果deadline没写,就按Agent的默认策略执行。我实测下来的分类是:查询类60秒、总结类120秒、深度分析类300秒。注意这不是通用标准,只是在我这套模型组合和硬件条件下的经验值,大家可以根据自己用的模型和接口情况调。
这个坑的教训是:Agent触达层的超时配置不能一刀切,一定要按Agent能力和任务类型分开设置,否则一定会在某个夜里被误报刷屏。
4.2 幂等缺失:一次网络抖动,客户收到了两条回复
这个坑是我最不想再经历的一次。某天网络抖动,话术生成Agent已经处理完任务并回调了结果,但回调请求在网络上丢了。触达层按照重试策略重新投递了同一个信封,话术生成Agent没做去重,又生成了一遍,最后客户收到了两条几乎一样的回复。
排查过程其实也不复杂:看状态机发现同一task_id出现了两次COMPLETED记录,顺着trace_id查到是重试导致的重复处理。但这个问题的根子在于触达层设计重试机制时,只考虑了"消息有没有送达",没有考虑"任务是否已经被处理过"。
修复方式是两层:触达层在每个信封里带上idempotency_key,Agent侧在业务入口处按这个key做唯一性校验,比如存一张处理记录表,已经存在同样的key就直接返回上一次结果,不再执行实际逻辑。第一次收到重复投递后,实际处理耗时降为了0,效果很明显。
幂等设计这件事,建议在触达层设计的第一天就想清楚。因为一旦线上已经出现过重复处理,再回头补数据清洗会非常痛苦。
4.3 上下文隔离:下游Agent被不相关的记忆污染
初版Agent-Reach有一个看似聪明的设计:为了让下游Agent更懂业务,发起方可以把整个对话历史都塞进payload里传过去。结果上线后,话术生成Agent开始在回复里"脑补"上游Agent的判断过程,甚至把别的用户的订单信息混进了回复里。
我当时很困惑,因为话术生成Agent的prompt里没有让它参考其他信息。后来逐个字段排查,发现问题就出在全量上下文上——大模型在上下文里看到了太多和当前任务无关的信息,它并不具备"我只该看这部分"的稳定判断能力。这个和上下文窗口的容量没关系,纯粹是信息边界问题。
修法很直接:取消全量传递,改成白名单字段模式。每个Agent在注册时声明accepts_fields列表,只有列表里的字段会被触达层允许放进payload。比如话术生成Agent只接收customer_name、order_id、recommended_action、reply_tone这几个字段。这样一来,上下文被强行切到了任务必需的最小集合,下游Agent的输出质量稳定了很多,也没有再出现串数据的情况。
4.4 结果格式漂移:JSON修复层成了刚需
在Agent-Reach上线前,我以为Agent返回合法JSON不是问题,毕竟那么多模型都已经训练到很听话了。实际跑起来才发现,带处理建议的Agent偶尔会在JSON外面包一层Markdown代码块,有时会在JSON末尾多一个逗号,有时干脆中间夹一段"以下是结果"的自然语言。
这在单Agent场景里问题不大,因为人工可以容忍这些格式不干净。但在触达层里,发起方Agent是拿代码解析结果的,只要JSON解析失败,整条链路就断。
我做了两层兜底。第一层是结果归一器:拿到Agent返回的原始内容后,先尝试提取最外层的JSON片段,去掉Markdown标记,再做json.loads,失败后走一个容错解析器,把常见的尾逗号、单引号问题修复掉。第二层是schema校验:注册时声明结果的JSON Schema,每次返回都校验一次,字段缺失或类型不符会在触达层直接拦截并让Agent重跑一次。
这两层会让触达层多出10%左右的计算开销,但换来的稳定性收益远超成本。记住一个原则:LLM输出永远有漂移的可能,触达层要在漂移发生时守住边界,而不是祈祷它不发生。
5. 回看这套架构:Agent-Reach适合谁,不适合谁,以及还能怎么扩展
5.1 我发现它真正解决问题的场景
跑了一段时间后,我对Agent-Reach的适用边界有了比较明确的判断。它真正好用的情况有这么几类。
第一类是已有多个独立Agent、希望用最低改造成本把它们连起来的场景。因为触达层不关心Agent内部实现,只要Agent能提供HTTP接口,按约定注册能力和收发信封就行。第二类是任务链路长、天然分阶段的场景,比如工单处理、内容生产审核流、数据分析流程,这种场景下每个Agent只做好一小段事,通过触达层串成流水线,比把所有逻辑塞进一个大Agent要可控得多。第三类是希望保留人工介入位置、而不是让Agent自主决定一切的场景,Agent-Reach的状态机和task_id让每个环节都看得见摸得着,你可以在任意两个节点之间插入人工审批,而不会破坏整体链路。
5.2 不适合Agent-Reach的情况
如果只有两个Agent,而且就是简单的A调B拿结果,那我不建议用Agent-Reach,直接写个HTTP调用反而更省事。引入注册中心和任务信封反而会把简单问题复杂化。
如果一个任务需要多个Agent像开圆桌会议一样反复讨论、互相修正,那Agent-Reach也不是合适的选择。它设计的目标是"接力赛",不是"研讨会"。开圆桌会议需要更灵活的消息传递和共享记忆机制,那是另一个领域的课题。
另外,如果业务强依赖严格的工作流编排,比如有明确的分支判断、回滚、人工提交等条件,Agent-Reach的轻量状态机也不够。这种场景更适合直接用流程编排框架,或者在工作流引擎里调用Agent,让编排引擎负责顺序控制,触达层只做底层通信。
5.3 后续扩展方向和我个人的判断
当前Agent-Reach的路由策略还比较原始,基本是前缀匹配+优先级排序。我计划下一步把路由升级成可编程的策略库,让Agent可以按负载、延迟、成功率动态选择目标。另一个方向是给触达层加一个轻量观测面板,用已有的状态机数据渲染出实时的Agent触达拓扑图,排查问题时能直接看到是哪一环掉了链子。
至于这层是不是要跟大模型深度融合,比如引入语义路由、让Agent自己学习该触达谁,我个人持保守态度。很多问题其实是"确定性工程问题",用语义路由这种不确定手段去解决,反而会引入新的不确定性。除非未来Agent的数量和协作复杂度已经远远超过人能穷举路由规则的程度,到时候再考虑语义路由也不迟。
Agent-Reach这套东西做完,我最大的体会是:多Agent协作的瓶颈,往往不在模型聪明不聪明,而在工程层有没有把"谁、怎么、何时、结果如何"这套基础的触达语义整理清楚。希望这篇记录能帮正在被同类问题困住的人省下几周的排查时间。