多智能体系统跑了大半年我最深的体会是让几十个 agent 各司其职地“干活”不难难的是让任务准确“触达”那个最该干活的 agent。有一阵子我们内部系统频繁收到“无法处理请人工介入”的反馈一开始我以为是某个 agent 挂了后来一查才发现任务派错了对象——一个用户问“看一下 Nginx 日志里最近的 403 错误”路由层却把请求丢给了文档问答 agent。这个项目后来被我整理成一个独立的开源中间件取名 Agent-Reach。Agent 就是智能体Reach 本意是“到达、覆盖”合在一起就是“让每一个 agent 都能被正确触达让每个请求都能到达能处理它的 agent”。这篇文章不打算讲那些花哨的 agent 框架 API而是想把我从零搭建这套触达与路由系统时踩过的坑、定下来的设计取舍、以及凌晨排查故障的完整过程写下来。如果你也在做多 agent 系统或者正被“任务评分低、agent 互相推诿”折磨这篇应该对你有用。1. 多智能体系统里最容易被忽略的“能力触达”问题1.1 一次“任务派错人”的事故引入先说那次让我下定决心写 Agent-Reach 的事故。我们的系统当时接了一堆 agent文档问答、代码审查、日志分析、定时报表、告警处置……每个 agent 都是独立服务注册到一个共享的注册表里。路由层用的是一个很原始的逻辑任务进来按关键词匹配 tag匹配到哪个 agent 就发给哪个匹配不到就广播给所有 agent谁抢到算谁的。听起来好像没什么问题对吧但实际运行起来状况百出。日志分析 agent 明明存在但用户的问题写法稍微变一下比如“统计最近一小时 5xx 状态码”而不是“看日志”关键词就匹配不上了。广播给所有 agent 之后文档问答 agent 会接单然后给出一个看似合理但实际上根本没有检索任何日志的回答。用户以为系统能力不行实际上是有能力的 agent 压根没被找到。当时后台接了一大堆投诉工单每一单都要人工去“翻译”用户请求再手动指定 agent。修复一次两次还能忍受但这类问题根本没有尽头——因为任务和 agent 之间的映射关系本就不是用几条关键词规则能描述的。我最初以为这是 NLP 问题后来才意识到这本质上是一个路由协议问题。1.2 “能做”和“能被找到”之间隔着什么一个 agent 有处理某项任务的能力和它能被路由系统正确找到中间隔着三层信息缺口。第一层是能力描述缺口。当时注册表里每个 agent 只有名字、版本、tag 列表。tag 像“日志”“文档”“告警”这样的粗粒度标签根本表达不了“我擅长从 ELK 里查 Nginx 访问日志参数是时间范围和状态码输出格式是 Markdown 表格”这种信息。一个 agent 的真实能力远比几个 tag 复杂但注册表根本没有字段让它说清楚自己的输入输出格式。第二层是匹配策略缺口。关键词匹配只能处理字面重合语义相近但表达不同的请求就失效了。广播策略虽然能用“所有 agent 抢单”的方式兜底但抢单带来的是随机性同一个问题这次 A 答下次 B 答用户体验完全不可控。而且大部分 agent 看到请求就先接单接了再判断自己行不行效果就是在推高无效调用。第三层是反馈闭环缺口。一次错误路由发生后系统只知道“任务完成了”或“任务失败了”不知道“这个任务本来应该由另一个 agent 处理”。也就是说路由错误没有被标记成一种可量化的故障而只是被当成一次普通的失败重试。这三层缺口叠在一起表现出来的症状就是系统里明明有“会做”的 agent但请求就是“够不着”它。1.3 为什么说这是架构问题而不是单纯调优问题排查那几天我翻遍了路由层代码发现整个分发逻辑加起来不到三百行而且没有任何格式化的路由决策记录。我想复盘一次错误路由是怎么发生的翻日志只能看到“send task to agent_xxx, response: ok”根本看不到决策过程——比如当时有多少个候选 agent、为什么选了它、置信度是多少。那一刻我意识到这个问题不是换个更好的 embedding 模型、加几条匹配规则就能解决的。它需要的是给整个系统补上一整套显式的“触达机制”agent 如何声明自己的能力、路由层如何计算能力与任务的匹配度、决策过程如何被记录和审计、错误路由如何被快速发现并自我修正。所以我把 Agent-Reach 定位成多智能体系统里的“触达中间层”而不是另一个 agent 框架。它不负责 agent 怎么思考、怎么规划只负责回答一个问题一个能力请求应该被交给哪个 agent以及这个决策凭什么成立。2. Agent-Reach 的能力协议与覆盖半径设计2.1 Agent 清单格式让每个 agent 写清楚“会什么、擅长什么”做 Agent-Reach 的第一件事是设计 agent 能力描述协议。这个协议的核心是一份统一的 agent 清单文件每个接入系统的 agent 都要提供。我们用的格式是 YAML 加 JSON Schema 校验把能力描述拆成结构化字段和自由文本两部分。结构化字段负责机器可读自由文本负责语义可理解。举个例子日志分析 agent 的清单核心部分长这样id: log-analyzer name: 日志分析助手 version: 2.1.0 health_check: /healthz timeout_ms: 15000 capabilities: - id: log_query name: 日志检索与统计 description: 支持从 ELK 检索 Nginx、Gateway 等访问日志可按时间范围、状态码、来源 IP 过滤统计 QPS、错误率、Top URL。 input_schema: type: object properties: time_start: { type: string, description: ISO8601 起始时间 } time_end: { type: string, description: ISO8601 结束时间 } filter: { type: object, description: 过滤条件如 status_code、host } aggregate: { type: string, enum: [qps, error_rate, top_url] } output_format: markdown_table tags: [log, elk, query, statistics] mutually_exclusive_tags: [code_review, doc_qa] permission_domains: [ops]我为什么强调用 YAML JSON Schema而不是纯自然语言两个原因。第一结构化的 input_schema 让路由层在派发前就能做参数校验。任务如果连参数都填错了根本不用发到 agent 那边路由层直接拒绝并给出提示大大减少无效调用。这个改动上线后agent 侧收到的“非法请求”数量下降了近一半。第二mutually_exclusive_tags 字段是我们踩坑后补上去的它让 agent 明确声明“哪些任务我是绝对不处理的”。这个字段后面在第四部分会讲到它直接解决了一次严重故障。这里有个补充说明这套格式我们迭代了三版才稳定最初版本里只有 tags 数组没有 input_schema。后来发现只有标签根本没法做参数级路由才把每个 capability 的输入输出结构补全。所以如果你要自己设计类似协议建议一开始就把“能力”“输入”“输出”“互斥边界”分开声明不要偷懒只写标签。2.2 三层过滤的覆盖半径算法“覆盖半径”是 Agent-Reach 的核心概念。我在设计时没有简单地把任务丢给向量数据库做相似度召回因为纯 embedding 匹配有两个致命问题它对数值型条件不敏感对权限边界无感知。直接上相似度很容易把“数据库连不上”这个属于 DBA 能力域的问题配给一个负责“数据库文档问答”的 agent——字面上太像了但前者需要操作后者只需要解释。所以 Agent-Reach 的匹配不是单层计算而是三层过滤叠加每一层都有硬性的通过条件第一层是硬过滤也叫不可协商层。这里检查的是互斥标签和权限域。如果任务语义落在某个 agent 的 mutually_exclusive_tags 里不管相似度多高直接剔除。如果任务涉及的业务域不在 agent 的 permission_domains 内也直接剔除。这一层不计算分数只做二值判断。第二层是语义召回层。经过第一层过滤后剩下的候选 agent 用 embedding 做相似度计算。这里的阈值我建议不要拍脑袋定而是根据历史路由日志统计出一个分位数。我们在测试环境跑了两周之后把阈值定在了相似度大于 0.72 才算候选0.60 到 0.72 之间只作提示不直接路由。第三层是行为反馈层。这也是后来加的。每个 agent 在上线后会自动上报近 7 天的任务成功率、平均时延、拒绝率。覆盖率计算最终得分时会把行为分乘进去成功率低于 90% 的 agent路由分会被乘以 0.8 的惩罚系数拒绝率超过 20% 的 agent直接降权到几乎不可能被选中。这套机制的目标是让“能力声明得漂亮但实际干活不行”的 agent 逐渐淡出候选列表。最终的匹配分可以简单理解成final_score semantic_score * behavior_factorsemantic_score 来自 embedding 相似度归一化behavior_factor 来自行为反馈层。分数超过阈值并且排在 Top-K 的 agent 才会进入最终的派发池。这个设计是实际跑出来之后才逐渐清晰的。最开始的版本只有第二层语义召回结果就是频繁出现“高相似但任务做不了”的情况。后来补上第一层做边界控制再补上第三层做优胜劣汰系统才稳定下来。三层缺一不可这是我反复验证过的结论。2.3 路由决策链路精确匹配优先语义召回兜底有了能力清单和覆盖半径算法之后路由决策链路就清晰了。我给它起了个内部叫法三级路由。第一级是精确匹配。任务进来时如果请求参数里已经显式声明了要调用的 capability id比如调用方直接说“用 log_query 查日志”那么路由层不做任何语义推断直接校验参数然后派发给对应 agent。这适用于系统内其他组件的程序化调用它们知道自己要什么。第二级是语义召回。自然语言请求走这一级。先做意图识别、参数抽取然后套用上面说的三层过滤产出候选 agent 列表并按最终分数排序。这一级负责绝大多数用户请求。第三级是人工兜底。如果 Top-K 的候选分数全部低于最低阈值路由层不会硬塞给谁而是生成一个“未匹配工单”进入人工处理队列同时记录完整请求上下文。这一步看起来增加了一点人工成本但它的价值是让“不知道派给谁”的请求有明确出口而不是在 agent 之间踢皮球。我曾经纠结过要不要用“广播兜底”代替“人工兜底”因为广播在技术上更简单。但后来实践告诉我广播会掩盖匹配算法的缺陷一旦出现低质量候选广播就会让所有 agent 都尝试处理一个它们根本处理不好的请求结果比不处理更糟。宁可人工介入也不要把烂摊子同时丢给所有 agent。3. 路由网关的稳定性机制与可观测性设计3.1 为什么自研路由网关而不是复用消息中间件团队里有人建议直接用消息队列加多组 topic 做分发说 rabbitmq、kafka 都有现成的 topic 路由能力。我当时花了两天时间评估最后否决了这个方案。消息中间件能解决“消息怎么送到”的问题但解决不了“消息该送给谁”的问题。kafka 的 topic 路由本质上还是基于标签匹配做不到三层过滤这种带分数计算的能力匹配。更重要的是消息系统的事件通常是“持久化、异步、可重复消费”的而 agent 路由需求是高一致性、需要感知 agent 实时负载和健康状态的。把健康检查和熔断逻辑塞进消息队列配置里既别扭又难以维护。所以 Agent-Reach 的路由网关是一个独立的无状态服务。它内部维护了 agent 注册表、覆盖半径模型和路由决策引擎对外只暴露一个 HTTP 接口加一个可选的 WebSocket 推送通道。无状态设计让它可以横向扩展注册表状态统一放在 Redis 里。我们线上跑了三个副本分别部署在不同的可用区挂掉任何一个都不影响整体路由。这个网关的定位是“决策中心”而不是“传输管道”。它决定把任务交给谁实际任务数据通过 agent 之间已有的传输通道走。也就是说路由网关不接管大 payload 的搬运它更像个导航员告诉你该走哪条路。3.2 心跳、超时、幂等派发三个不起眼但会搞崩系统的参数路由系统跑起来之后最先出问题的反而是一些“听上去很普通”的参数。这里挑三个最典型的说。第一个是心跳与注册表过期。开始我们让 agent 每 30 秒上报一次心跳但有一次网络抖动大量 agent 的心跳丢失路由网关就把它们全部标成“下线”于是所有请求都涌向剩下为数不多的 agent把它们全部打挂。这个事故告诉我们不能因为一次心跳丢失就立刻淘汰一个 agent。后来我们改成连续 3 次心跳丢失才标记不健康并且在下线前会先发一个探测请求确认。第二个是超时阈值。每个 agent 的标称超时我们写死在清单里了但 agent 的实际响应时间会随负载上下浮动。有一个 agent 的 p99 时延是 8 秒标称超时是 10 秒高峰期偶尔飙到 11 秒于是所有超过 10 秒的请求都被路由网关判定为失败然后触发重试重试又叠加到已经过载的 agent 上形成恶性循环。这个问题的解法是“动态超时”网关记录每个 agent 的滚动 p99 时延超时阈值设为 p99 的 1.5 倍而不是用固定值。第三个是恰好一次派发。任务在重试过程中有可能被同时派发给同一个 agent 两次也可能在“本地缓存队列”和“主路由表”之间出现不一致。我们的方案是给每个任务生成全局唯一的 trace_id在派发时用 Redis 的 SETNX 做幂等控制同一个 trace_id 在 60 秒内只能被派发一次。这个机制虽然简单但帮我们堵住了大量因重试导致的双重执行问题。下面这张表是我在测试环境中最终的参数建议你可以根据自己系统的规模调整参数初始建议值调整逻辑心跳上报间隔15 秒网络抖动频繁时放宽到 30 秒不健康判定连续 3 次心跳丢失高于 3 次会延迟恢复速度路由超时系数p99 时延的 1.5 倍p99 波动大时改为 2 倍幂等控制窗口60 秒长任务改为 300 秒语义相似度阈值0.72按历史路由日志的分位数调整3.3 触达日志与链路追踪没有记录就没有复盘Agent-Reach 里我认为最有长期价值的组件不是路由算法而是触达日志。每一次路由决策不管成功失败都会落一条触达日志。字段包括请求 trace_id、用户意图、抽取的参数、三层过滤的结果、候选 agent 列表及其得分、最终选中的 agent、agent 执行的响应结果、时延、拒绝原因。最初我想省掉这些日志因为写全量日志有点费磁盘但第一次排查错误路由时我就发现没日志根本没法复盘。触达日志让我能回答这些原本回答不了的问题为什么这个问题派给了它当时的候选有哪些它的得分为什么比别的 agent 高agent 拒绝之后路由层做了什么配合 trace_id可以串联起整条调用链用户请求到路由层、路由层到 agent 执行、agent 内部处理外部工具调用。每个环节都带上同一个 trace_id排查故障时 find by trace_id 就能把全链路日志拉齐。这套可观测性设计带来的效率提升远超我预期至少在故障定位时间上从平均半小时缩短到五分钟以内。4. 凌晨告警实录一起“路由看似正常但任务无人认领”的排查4.1 现象与第一反应agent 拒绝任务但负载正常有一次凌晨两点告警系统突然把我吵醒。看告警内容是报表 agent“报表生成器”连续三次拒绝接收任务。我第一反应是它挂了赶紧看它的监控面板结果 CPU、内存、QPS 全部正常健康检查也返回 200。那就怪了。一个健康的 agent 为什么连续拒绝任务我登录服务器看它的应用日志发现拒绝原因字段写的是“invalid capability request: capability not recognized”。翻译过来就是它收到了一个自己根本不认识的 capability 请求。我立刻意识到问题大概率出在路由层。路由层把一个不属于它的任务派给了它它发现自己没有对应的处理能力所以拒绝。拒绝本身是正确的但问题是为什么路由层会做出这样的派发决策。4.2 触达日志暴露了第一个问题标签字段对不上这是触达日志第一次帮上大忙。我查了报表 agent 对应的触达日志发现路由决策过程记录了一个奇怪的候选 agent——不是我预期的日志分析 agent而是报表 agent 本身而且它的分数还很高。进一步对比请求和 agent 清单原因浮现了任务的 user_group 字段标记为“ops_daily”而报表 agent 的清单里注册的是 owner_group字段名叫“biz_line”。两个字段在语义上明明是一个意思但因为命名不一致路由层的硬过滤第一层把“权限域匹配”写死了判断的时候用的是 user_group 去对比 permission_domains所以对应的业务线匹配根本就没生效。这是一个典型的协议一致性 bug接口字段没对齐导致权限域过滤形同虚设。原来的路由逻辑把这个任务划到“无权限域限制”的分组然后它就去参与语义匹配匹配到报表 agent 的某段能力描述因为用户请求里提到了“报表”两个字分数一高就派出去了。修复方案很简单也很笨统一所有 agent 清单里的字段命名把 owner_group 改成 user_group同时给网关加了一条校验规则——凡是清单里出现未知字段加载时直接报错不允许静默忽略。这能保证以后协议变更时第一时间就暴露出来而不是带病运行。4.3 语义候选反而添乱没有互斥标签的教训修复字段命名之后我以为问题解决了结果第二天晚上同样的事又冒出来了。这次更特殊用户的请求里明确写了“生成运维日报”报表 agent 应该接单但派发的却是代码审查 agent。我看触达日志发现候选列表一共有三个 agent按分数排序分别是报表 agent0.89、代码审查 agent0.81、告警处置 agent0.74。从分数上看报表 agent 本来应该赢但路由层最终选了代码审查 agent。原因出在路由层的一个“锦上添花”逻辑上当时为了降低派发风险我在路由决策里加了一条规则——如果 Top-K 的得分差异小于 0.1就优先选择“历史成功率更高”的 agent。代码审查 agent 因为功能简单成功了高分数被行为系数拉得很高而报表 agent 因为要处理更复杂的报表任务偶尔失败几次行为分被惩罚了。结果就是排名反转。这确实是个设计失误。初次修订时我给不同 agent 使用同一个行为系数公式却忽略了“任务难度差异越大成功率可比性越差”。更关键的是代码审查 agent 的清单里没有声明互斥标签它没有明确说“我不处理报表任务”所以语义匹配时它的相似度也能参与进来给反转提供了可能。这次故障带来两个直接改动第一给所有 agent 强制要求 mutually_exclusive_tags清单里没有这个字段的直接拒绝接入第二行为反馈层的惩罚系数改成按 capability 级别分池计算而不是按 agent 级别统一计算。4.4 协议不一致的同类坑与统一方案两轮排查下来我总结出 Agent-Reach 协议设计里最容易出“领域名词歧义”的几类问题顺带在这里列一下给你们做清单审查时参考时间字段命名不一致有人用 time_start有人用 start_at有人用 from解析时很容易错位。超时单位不一致有人按毫秒有人按秒跨 agent 对接时容易默认错单位。标签大小写不一致ops、Ops、OPS 三个 tag 在匹配时如果不统一会产生大量无声失败。同义词未归约报表、report、daily_report在 tag 层面是三个值但能力描述里其实指向同一个意思。我们的统一方案只有一个原则所有协议字段在接入时必须经过 Agent-Reach 的 schema 校验器校验失败就拒绝接入并输出明确错误信息。同时增加了一个“标签归约表”在注册阶段把同义词自动归一化避免运行时才发现匹配错位。但我还要强调schema 校验治标不治本根子上还是要让 agent 清单的编写者有清晰的字段参照。我们后来干脆写了一份“能力清单编写规范”每个字段都配了示例和常见错误让新增 agent 的整理时间从一个小时压缩到十分钟。这个投入非常值得。5. 部署形态、实测数据与什么时候不要用它5.1 三种部署形态与适用场景Agent-Reach 我用下来觉得最舒服的是它的部署方式很灵活。同一个代码仓库提供三种形态嵌入式库形式适合单体应用里跑多个 agent 的场景。进程内的路由调用没有网络开销配置最简单直接 new 一个 Router 实例传入 agent 清单路径就能跑。缺点是不能跨语言只能被 Java 或 Python 等项目直接嵌入。旁路网关形式适合微服务化的 agent 集群。就是前面说的无状态 HTTP 服务加 Redis 存储注册表状态。每个 agent 独立部署通过 Agent-Reach SDK 上报心跳和执行回调。这是我们线上正在用的形态。命令行工具形式适合本地调试。我写了一个 CLI能手动执行一次路由决策输出候选列表和分数明细还能把一次触达日志打印成人类可读的树形结构。调试路由匹配问题时我基本都是先用它把问题复现出来再上测试环境验证。5.2 自测环境的规模与观测数据我在测试环境里跑了一个约 20 个 agent 的集群覆盖文档问答、日志分析、代码审查、报表生成、告警处置等场景。放了一个周末的模拟流量之后几个观测数据我觉得可以给你们参考路由决策 p99 时延约 8 毫秒。这个数字是加了语义召回和三层过滤之后的满足绝大多数业务场景的实时性要求。准确率方面人工抽检了 200 条日志路由命中率约 95%。剩余 5% 里有 2% 是语义边界模糊3% 是参数抽取错误导致的后续失败。注册表内存占用约 4MB对 20 个 agent、每个 agent 平均 4 个 capability 的场景或者更大规模也压力不大。资源开销大头其实在 embedding 服务上路由网关本身很轻。所以如果你要扩展优先关注 embedding 服务的容量。这里要说清楚我的测试环境规模不大这些数据只能说明 Agent-Reach 在中小规模下有不错的性能和稳定性。大规模场景我预设了扩展方向但还没有足够的时间做压测。实际接入时可以先用这些数字做容量估算再在你们自己的流量形态下做验证。5.3 演进方向自适应半径、Agent 嵌套、外部系统对接Agent-Reach 目前还缺几个让我惦记的功能我列在 Roadmap 里也简单讲讲设计思路。自适应覆盖半径是当前最想做的。现在的行为反馈层用的是固定惩罚系数虽然比没有好但没法精准刻画“某个 agent 最近两天因为依赖的外部服务不稳定而成功率下降但能力本身没问题”这类动态场景。理想方案是给每个 capability 算一个随时间衰减的成功率曲线对近期表现突变的 agent 做缓慢降权而不是一刀切。这里涉及时间窗口和衰减因子的配合我还在做实验。Agent 嵌套是另一个方向。现在的路由粒度是 agent但现实中经常是一个 agent 内部还要路由给不同的子能力或下游工具。如果 Agent-Reach 支持“agent 内嵌能力路由”就可以把路由能力逐层下放让复杂 agent 内部也能复用同一套覆盖率计算逻辑。这个功能对超大单体 agent 尤其有用。外部系统对接也在计划中。我们内部有些异步任务需要通过消息队列投递而不是走 HTTP 同步调用。Agent-Reach 现在以同步为主但网关的核心决策引擎跟底层传输方式解耦了理论上可以加一个 MQ 适配层把一个路由决策结果封装成标准事件发布到指定队列。5.4 我个人对这个项目的体会什么时候不该用最后说点返璞归真的东西。Agent-Reach 解决的是多 agent 系统的能力触达问题但它不是万能的。有些场景里引入它反而是负担。如果系统只有两三个 agent而且调用链是固定的比如 A 总是调 B那完全不需要路由层。写死调用关系比引入一套动态路由系统简单可靠得多。如果所有请求都会发给所有 agent让它们各自判断能不能处理那也没有路由的必要——预置的广播策略已经够了。还有如果你的“路由”只是提示词里让大模型选一下 agent临时试验可以但一旦涉及权限控制、熔断、审计、幂等等生产级要求用提示词做路由就会捉襟见肘。我的实际体会是Agent-Reach 这类触达中间层最大的价值不在于“匹配”这个动作本身而在于它把 agent 的能力边界、路由决策过程、任务执行反馈全部显式化了。每次故障排查我都能精准回答“为什么派给它”每次优化我都能用触达日志验证效果而不是靠感觉堆规则。这套“显式化”的思路比 Agent-Reach 这个名字本身更值得借鉴。如果你也打算在系统里引入类似机制我的建议是先别急着写代码把 agent 能力清单的规范定义好——字段命名、同义词归约、互斥边界——这些占一半的工程量。协议定清楚了路由算法的外挂再怎么调都不会把系统带偏。
阅读完成 · 觉得有帮助?