尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多智能体协作中的触达机制:从服务发现到语义路由的工程实践

多智能体协作中的触达机制:从服务发现到语义路由的工程实践 我们团队最近在跑一套多智能体协作系统最初大家各自调用API、各自维护上下文结果数据到处都是Agent之间互不相认用户提一个跨模块的需求系统要绕好几圈才能找到真正该干活的模块。后来我们把“让Agent之间能触达彼此”这件事抽出来做了专门的一层这套东西我们自己内部叫它“Agent-Reach”。简单说它解决的不是“单个Agent怎么变聪明”而是“一堆Agent怎么找到对方、把消息送过去、还能安全地拿到结果”。这篇文章就把我们设计这套机制时的思考、踩过的坑和最终落地的方案完整写出来希望对正在做类似事情的你有点帮助。1. 真正难的不是对话而是“找到并触达”1.1 先给“Agent”和“Reach”定个坐标在开始之前有必要先统一一下概念。我这里说的Agent不单指某个大语言模型对话机器人而是泛指一切能独立完成某个任务单元的软件实体——它可以是一个调用外部API的脚本、一个封装了业务逻辑的微服务、一个带记忆和规划的LLM应用甚至是一套自动化工作流。它们共同的特点是具有一定自主性能接收指令能产生行为并且返回结果。而“Reach”在这个语境下就是“触达”的意思。触达不等于通信。通信只是把字节从A搬到B触达是让A能按需发现B的存在、确认B的能力边界、将消息按B能理解的方式送达并且在B异常时知道如何处理。换句话说Agent-Reach是把“消息传递”这个动作升级成“能力匹配与业务交付”这个完整链路。1.2 为什么传统的服务发现和网关不够用很多朋友第一反应是这不就是注册中心加网关吗我们用Nacos、Consul再加一层Gateway不就都有了吗老实说最初我们也这么想但真正一跑就发现不对劲。传统微服务发现解决的是“哪个实例存活的IP和端口”它是面向静态接口的。而Agent触达面对的是“某类任务应该由哪个Agent来处理”它是面向语义能力的。举个例子用户说“帮我总结一下这份合同的风险点”传统网关只能把请求路由到固定的服务URL但我们的系统里可能有三个Agent都声称自己能处理“合同分析”可它们的输入格式、上下文要求、擅长的合同类型完全不同。让网关根据URL做路由解决不了这个选择问题。此外Agent之间通常需要多轮来回甚至互相嵌套调用——Agent A发现自己需要Agent B的能力B又需要C这种动态形成的调用链不可能在部署的时候靠静态配置全部写死。触达必须是运行时动态发生的。1.3 三种基本触达模型点对点、广播、共识协调在设计Agent-Reach时我们先把触达模式收敛成三种避免每个场景都造一个轮子点对点触达调用方明确知道要找谁只需按Agent身份精确投递。适合日志上报、定时任务分发、已知依赖调用。广播触达调用方不知道谁合适先把任务发给某个分组的所有Agent由收到消息的Agent自己判断是否响应。适合任务分配、选举类场景。共识触达多个Agent都参与处理同一个任务最后对结果做裁决或融合。适合需要交叉验证的生成任务、多角度分析类场景。这三种模式的差异主要体现在路由策略和结果汇聚逻辑上。点对点最简单广播和共识则需要引入分组、优先级和投票机制。后面我们会详细讲每一种在落地时要注意的细节。2. 核心设计把触达拆成四个层次在我们内部Agent-Reach不是一个独立的大平台而是一套分层机制。我们把它分成了身份、注册、寻址、投递这四个层次每一层解决一个独立的子问题这样各自的演进和容错都能独立进行。2.1 身份层Agent-ID不是简单的名字让Agent可触达的第一步是给每个Agent一个稳定的身份标识。有些开发者习惯直接用“agent_name”比如“contract_analysis_agent”但这样管理一旦规模上来就会乱套——重名、改名、多版本并行全都会变成灾难。我们采用的是三元组身份结构字段示例说明Namespacelegal用于隔离不同业务域避免权限越界Agent-Typecontract-reviewAgent的能力类型用于语义匹配Instance-IDuuid-v7每个运行实例的唯一标识这样即使同一个Agent-Type启动了多个实例每个实例也有独立的身份。路由时我们可以按Namespace限制可见范围按Agent-Type做能力匹配按Instance-ID做精确投递。同时这个三元组也会体现在日志和链路追踪里排查问题的时候一眼就能看出是哪个域、哪类Agent的哪个实例出了问题。在设计身份时最好提前考虑版本问题。不同版本的同一个Agent能力会有细微差别比如v1版本不支持PDF解析v2版本支持。我们会在Agent-Type后面附加一个可选的version字段匹配时优先找最新版本找不到则回退到旧版本并给调用方打一个warning。2.2 注册层能力、状态与有效期身份解决“我是谁”注册层解决“谁在、谁能干什么、状态如何”。2.2.1 能力描述与会话入口每当一个Agent启动它就需要向所在群组内的注册中心提交一份能力描述文件。这里我们不建议填写自然语言长文本而是用结构化的JSON Schema来声明自己的输入、输出、可执行动作和约束条件。举一个例子{ agent_id: legal:contract-review:ins-01, capabilities: [ { name: summarize_risk_points, input_schema: { contract_text: { type: string, max_length: 100000 }, language: { type: string, enum: [zh, en] } }, output_schema: { risk_items: { type: array } }, execution_duration_hint: 30s } ], lease_time: 120, availability: online }有了这套结构化描述路由层才能做匹配而不是靠猜测。另外还要声明一个重要的字段——lease_time租约时间。这个字段决定了Agent需要多久续约一次。现实中一个Agent可能因为负载过高而失联如果注册信息长期有效路由层就会把任务发给一个已经失联的实例造成不必要的等待。2.2.2 状态模型我们定义了四种状态ONLINE可接受新任务BUSY当前负载饱和可接受紧急任务但会降速DRAINING正在处理完存量任务不再接受新任务OFFLINE不可触达路由层在做任务分配时会优先过滤掉DRAINING和OFFLINE状态的实例BUSY状态则视任务优先级决定是否分配。这套状态模型在后续实现流量控制和优雅下线时非常有用。否则直接从注册中心摘除实例会丢掉正在处理的任务上下文。2.3 寻址层语义路由不是关键词匹配寻址是Agent-Reach最核心的一层。它的任务是给定一个消息找到最合适的Agent实例。2.3.1 从分组到打分很多初学者会先做分组比如“合同分析组的Agent都试试”但这相当粗糙。我们在分组之上加了一层多因子打分路由。每个候选Agent会接受四个维度的评估能力匹配度消息中的任务类型与Agent声明的能力是否吻合权重最高。上下文相关度当前消息与Agent已有的会话记忆是否相关比如如果Agent之前已经在处理同一合同新消息交给它就不用重新加载上下文。负载因子实时压力与租约剩余时间避免把任务塞给快掉线的节点。业务亲和性例如同租户、同地区、同语言的偏好。最终总分就是四个维度的加权和。具体权重需要结合业务调整初期建议按 0.5 / 0.2 / 0.2 / 0.1 起步。2.3.2 基于Embedding的能力匹配纯靠人工维护“消息类型 - 能力”的映射表也能跑但覆盖度有限。后来我们引入了Embedding方案——将消息文本向量化同时把每个能力描述也向量化先算一遍余弦相似度筛选出TopN候选再结合上面的四维打分做精排。这样系统面对没有见过的说法时也能从语义上匹配到可能合适的能力而不是只能命中关键词。这并不是说关键词完全没用。在寻址层里我们刻意保留了“关键词硬规则”作为护栏。比如涉密任务只能在声明了相应安全级别的Agent里找即使Embedding相似度再高越权的都不能选。语义做召回规则做约束两者互补效果比较稳。2.4 投递层消息的可靠性与副作用控制寻址完成后剩下的问题是消息怎么送过去以及怎么保证送过去之后的结果可控。2.4.1 同步与异步的选择Agent触达不全是HTTP请求/响应那套模型。有些任务耗时几毫秒直接同步调用就行有些任务比如“生成一份行业调研报告”可能要跑几分钟这时候同步调用就是灾难。我们约定realtime同步模式适用于单次交互、零散问答调用方阻塞等待。task异步模式适用于耗时任务、批量任务调用方先拿task_id之后回调或轮询。stream流式模式适用于输出长文本或持续推送状态。2.4.2 幂等与副作用Agent调Agent和人类调API还不太一样Agent可能会因为模型幻觉而在失败后重试但重试不能导致业务上的副作用重复执行。比如一个Agent要做“扣款”操作如果网络抖动导致它重复投递用户就被扣了两次钱。这个问题的解法其实很传统——在消息头上加全局唯一的message_id并让接收方做去重表。非技术读者可以这样理解你给对方寄快递时在包裹上贴一个唯一的运单号收件人那边登记“这个运单号我签收过了”下次再收到同一个运单号就直接拒收这样同一批货就不会被收两次。此外当一个Agent触达另一个Agent并产生修改类操作时建议设计补偿动作。我们在消息schema里增加了compensable标志若为true接收方需要同时暴露一个“撤销”入口允许发起方在后续发现结果异常时发起回滚。3. 轻量落地从零到可用的Reach协议实现讲完整体架构说说我们到底是怎么实现这套机制的。我们没有一开始就上重型中间件而是先用一套轻量协议跑通流程再逐步加固。这套实现方式非常适合中小团队参考。3.1 注册与心跳基于TTL过期和租约机制每个Agent实例启动后向注册中心发送注册请求并在自己的进程内开一个后台任务做心跳续约。心跳间隔设置为租约时间的1/3比如租约120秒心跳就40秒一次留足网络抖动的缓冲余地。# 伪代码Agent 端心跳续约 import asyncio async def heartbeat_loop(agent_client, agent_id, interval40): while True: try: await agent_client.lease_renew(agent_id) except Exception: # 续约失败可能是注册中心不可达进入降级模式 pass await asyncio.sleep(interval)如果注册中心超过租约时间没收到续约就主动将该实例标记为OFFLINE。这里要注意网络分区的情况——Agent没挂只是注册中心联系不上。所以我们会区分“宕机”和“临时失联”失联实例先标记为SUSPICIOUS任务调度暂时跳过但保留上下文一段时间等恢复后可以继续承接原本的会话。3.2 路由策略分层打分与递归触达路由模块接收消息后执行流程如下根据Namespace做第一层过滤。校验硬规则安全级别、数据合规约束。基于Embedding召回Top10的候选Agent。对Top10做四维打分排序。取Top1直接投递如果Top1超过一定时间未响应则按候选队列顺序顺延投递。递归触达是这套机制里比较有趣的地方。当Agent A收到任务后如果发现还需要Agent B的支持它可以以同样的协议向路由层发起一次触达请求并把A自己的身份作为上下文附带上。这天然支持了多Agent协作但随之而来的风险是调用链可能变得很深甚至出现A-B-C-A这种循环。所以在每次递归触达时消息头里要带上深度阈值和各节点累积的痕迹标识超过深度直接拒绝并返回可读错误。3.3 重试、幂等与补偿Agent与Agent之间的通信不是HTTP那样简单这部分是我们在测试环境里踩坑最多的。3.3.1 重试策略Agent触达失败的原因可能有很多但不一定真的需要重试。我们采用的是自适应重试网络超时或瞬时错误最多重试2次间隔递增1秒、3秒。业务上明确拒绝不重试直接转人工或降级。接收端返回“BUSY”重试1次并降低发送频率。一个比较重要的经验是重试要带随机抖动不然多个Agent同时失败大家一起在同一秒重试能把注册中心打满。给重试间隔加random(0, 50ms)的抖动就能有效避免这问题。3.3.2 补偿设计异步task模式下如果发起方认为结果不可信它可以调用接收方暴露的cancel_task或rollback_action接口进行补偿。我们给所有修改类能力都加了标识没有这个标识的Agent不能申请执行写操作。这样才能守住数据一致的底线。3.4 安全边界白名单、令牌与资源隔离Agent之间互相信任是危险的。我们在触达协议层面做了几道防线白名单只有注册在案的Agent才能参与触达任何未注册实体一律拒绝。短时效令牌每次触达请求附加token令牌绑定调用方Agent身份、目标Agent身份和有效期。令牌最好使用JWT这类自包含格式减少注册中心在每次调用时的在线签发压力。资源配额给不同Agent设定每分钟的触达配额防止某个异常Agent无限广播拖垮整体。4. 实测路上的四个坑从碰撞到稳定的排查与修复方案听起来不错但真正跑起来时我们先后遇到四个比较典型的坑。说说完整的排查思路避免你重复走弯路。4.1 循环触达与消息风暴第一次全量联调时我们发现系统整体吞吐异常CPU飙升。排查时先看了链路追踪平台发现大量A触达B、B又触达A、A又触达B的循环消息消息量以指数级增长。根因是业务上A和B互相需要对方的能力但在路由打分时双方的能力描述过于宽泛导致互相匹配时分数一直很高。单纯靠深度阈值虽然能防止无限循环但消息风暴已经产生了大量无效负载。修复方案有两步。第一步在能力描述中增加“触发条件”字段明确什么语义下该能力适用。第二步在嵌套触达时增加消息血缘校验——如果一条消息的操作链上已经出现了同一种类型的Agent两次后续触达直接拒绝并返回“需要人工干预”的提示。4.2 部分失败Agent返回了但不是你要的东西有一次我们让一个Agent去另一个Agent那里取“合同中的违约金额”结果接收方返回了“合同已归档”。业务上它确实响应了但并没有执行我们想要的动作这属于部分失败。这个问题很难完全靠协议层面解决但可以通过输出Schema的校验层来拦截明显不合格的结果。在投递层里加一个输出校验组件把接收方的返回结果与调用方声明的期望Schema做结构比对不通过则自动重路由到下一个候选Agent。这牺牲了一点点延迟但大幅减少了坏结果的传播。4.3 注册信息滞后导致的寻址漂移Agent因为异常被重启后新实例注册成功但路由层缓存中还保留着旧实例的信息。这个时间窗内部分消息仍然被路由到旧实例造成404或超时。排查后发现我们的缓存时间设置得比租约时间长。解决办法很简单——让路由层的本地缓存时间严格小于注册中心的租约时间并且收到404时主动清除本地缓存条目。另外在Agent重启时尽量复用相同的Instance-ID会让下游的上下文关联更容易。4.4 没有贯穿的Trace排查问题两眼一抹黑多Agent链路的复杂度比单体调用高一个量级。一次任务可能经过四五个Agent任何一个环节慢了整体表现都会拉胯。最初我们没有做统一Trace结果每次出问题只能挨个看日志效率极低。后面我们强制所有Agent在触达时带上trace_id并把它透传到所有下游调用。每个Agent启动时初始化一个全局Trace上下文在任何日志、任何上报数据里都带上这串ID。现在排查一条慢调用只需要在可视化系统里按trace_id搜一次就能看到全程的耗时分布和每一步的出入参摘要。5. 与现有生态共存的落地路径不少团队已经有一部分Agent能力跑在现有框架里完全推到重建不现实。我们当时的落地路径是“增量接入”边跑边改效果比较平滑。5.1 对接MCP把工具语义包进注册资料如果你的Agent通过MCP协议暴露工具那么Agent-Reach和MCP并不冲突。我们做了一个适配层定期扫描MCP server中声明的tools自动转换成Reach的能力描述并注册到能力目录里。这样原本走MCP的工具也能参与语义路由。这个操作可以想象成给一个已有的工具箱贴上网购式的商品标签——工具还是那些工具但有了统一分类、统一描述和统一检索入口别的Agent才能精准地找到它们。5.2 对接事件总线广播与订阅的折中团队里还有一些Agent依赖Kafka或Redis Pub/Sub进行消息通信它们的模型是“发布-订阅”和Reach的“点对点寻址”不太一样。我们的处理是保留事件总线的通道但在事件头上增加一个reach_id字段。当某个订阅方Agent收到数据后如果发现自己需要更专业的处理它可以把这个reach_id再投递给更合适的另一个Agent这样就完成了从总线模式向寻址模式的平滑切换。5.3 网关模式与点对点模式的取舍很多人会问既然有了Agent-Reach还需要API网关吗我们目前的答案是都需要但分工不同。网关继续负责对外部客户端提供统一入口、鉴权和限流Agent-Reach负责内部Agent之间的动态触达。外部请求进入网关后网关只把事情交给一个初始Agent后续的Agent间协作全部由Reach层接管。6. 未来的演进方向从按能力路由走向业务意图感知最后说几个我们接下来想优化的点也算给这个机制留出进化空间。第一从“按能力路由”升级到“按意图规划”。现在路由是根据当前消息去匹配能力但复杂任务往往需要多个能力的组合。未来我们计划在路由层引入目标分解能力收到一条高层指令后先拆出子任务清单再为每个子任务寻找Agent触达。第二加入信任反馈机制。目前的打分因子没有“历史完成质量”这个维度。同一个Agent可能上次完成得不错这次就不太行。后续我们会在寻址打分中加入长期信任评分和近期表现评分让系统学会“谁更靠谱就把活儿派给谁”。第三异步触达的补偿机制要做得更完整。当前我们支持基础的取消操作但跨多个Agent的长链路回滚还需要一套编排机制来保证一致性这是我们下个里程碑的重点。我在实际开发和调试里最深刻的体会是Agent-Reach这类机制成败往往不在“通信”本身而是在“语义”和“信任”这两件软性的事上——你怎么描述能力决定了别人能不能找到你你怎么记录可信度决定了别人敢不敢用你。所以建议你从第一天起就把能力描述写得严谨一点、把日志记录做得完整一点后面会省下非常多半夜查问题的精力。
返回列表