多Agent系统触达层设计:从服务发现到语义路由的实践
1. 为什么“触达”成了多 Agent 系统里最容易被低估的一环做多 Agent 项目之前我一直觉得 Agent 之间的通信不是什么大问题——模型能输出结构化 JSONHTTP 能互相调用再加个消息队列链路不就通了吗直到我把第一个多 Agent 原型拆成独立服务部署才意识到自己错得有多离谱。单机环境下的 Agent 协作本质上就是函数调用主循环里调一个run(),内部再去调几个工具函数返回值直接拿到当前进程里处理。这种模式下不存在“另一个 Agent 服务崩了怎么办”“这个 Agent 当前负载高了怎么限流”“对方接口升级了调用方怎么感知”的问题因为所有东西共享一个进程、一套内存、一个生命周期。但一旦进入分布式环境一切假设都被打破了。我当时接手的场景是五个 Agent 服务分别负责需求解析、代码生成、测试执行、文档撰写、风险评审它们需要互相调用。第一个版本我直接硬编码了各个服务的 URL然后写了一大堆try...except来处理连接失败。结果呢每次上线都要手工改配置某个 Agent 扩容了 IP 变了其它几个的调用方就集体报错某个 Agent 偶发超时重试直接把对方打挂。整个系统能用但脆弱得像纸糊的房子。这个阶段最折磨我的问题不是“模型回答质量不行”而是“下一个 Agent 到底能不能被稳定地触达”。“触达”这个词看着简单真做起来远不是 TCP 能连上这么简单网络层连通了但对方进程已经假死端口还开着请求发过去石沉大海目标服务在线但它当前上下文窗口已满需要排队调用方不知道什么时候该放弃服务刚完成滚动升级旧版本实例还在销毁中新版本实例还没注册完请求刚好打到了正在退出的节点上。这些问题单独看每个都不复杂但叠加在一起会让多 Agent 系统的稳定性断崖式下跌。更麻烦的是Agent 和普通微服务还不一样普通微服务的接口语义是固定的你传订单号它返回订单详情Agent 的调用往往带自然语言意图同一个 Agent 可能同时承接“总结这段代码”和“给这段代码补注释”两种意图而且它的输出格式可能随模型版本变化。这给“触达”加了新维度——不只要把请求送到还要把请求翻译成目标 Agent 能理解的任务语义。我就是在这个背景下动手做了 Agent-Reach。它不是一个模型也不是一个新的 Agent 框架而是一层轻量级的触达基础设施。简单说它解决三件事Agent 启动后如何注册自己的能力和地址让其它 Agent 能发现它调用方如何按“能力语义”而非“硬编码地址”找到合适的 Agent并完成路由触达不同传输协议HTTP、WebSocket、gRPC甚至本地子进程如何在内部统一成一种触达模型。当时我给自己定了个目标Agent-Reach 要像服务网格对于微服务那样把“找到对方、连上对方、把话说清、把结果带回来”这条链路从业务代码里剥离出来。这样业务团队只需要关心 Agent 的逻辑本身不需要一遍遍重复实现服务发现和重试。2. Agent-Reach 的三层架构注册中心、语义路由和接入适配2.1 注册中心Agent 的通讯录但记的不只是地址Agent-Reach 的注册中心说白了就是一个带“能力描述”的服务通讯录。传统微服务注册中心记的是服务名、IP、端口、健康检查路径Agent-Reach 的注册信息要更丰富一些因为调用方往往不是凭服务名找 Agent而是凭“它能干什么”来找。我的设计里每个 Agent 启动后要上报一份注册信息结构大概是这样的{ agent_id: code-reviewer-v1, service: agent-reach-registry, version: 1.2.0, transport: { type: http, endpoint: http://10.0.1.18:8080/reach, health_check: /healthz }, capabilities: [ { name: code_review, description: 接收代码 diff 和评审规则返回评审意见列表, input_schema: {type: object}, output_schema: {type: array}, expected_latency_ms: 8000, max_concurrency: 20 }, { name: risk_assessment, description: 基于代码变更评估发布风险等级, input_schema: {type: object}, output_schema: {type: object}, expected_latency_ms: 5000, max_concurrency: 10 } ] }capabilities这个字段是我比照传统注册中心多设计出来的东西。它表面上是给调用方看的“菜单”实际上价值有两层第一层有了能力描述路由不再是“按服务名找实例”而是“按能力找服务”。调用方写reach(agentcode_review, payload...)Agent-Reach 会在注册表里找出所有声明了code_review能力的 Agent再根据策略选一个。这在 Agent 生态快速增长时特别有用——你发布一个新的代码评审 Agent不需要让所有上游把地址改成你注册进来即可被自动发现。第二层expected_latency_ms和max_concurrency这些元信息会参与触达策略的计算。比如调度器发现目标 Agent 当前并发已经接近上限就会选择另一个能力相同但负载低的实例或者直接告知调用方“该能力当前繁忙建议稍后重试”。这种基于能力和负载的触达比无脑轮询要优雅得多。注册中心的心跳机制我也做了调整。普通服务健康检查 3 秒一轮、连续 3 次失败就摘除但 Agent 经常有长时间思考的情况请求处理中可能几十秒没有新心跳这不代表 Agent 死了。所以 Agent-Reach 的心跳分成两层进程级心跳证明 Agent 活着和任务级回执证明当前请求还在处理中。只有两层都超时才会把实例标记为不健康。2.2 语义路由调用方请求的是“意图”而不是“接口”路由层是整个 Agent-Reach 最核心的部分。它的职责是把调用方的请求映射到具体 Agent 实例上并且这个映射要能感知网络状况、负载情况和能力匹配度。我遇到的第一版路由设计是纯规则的一个请求带target_agent字段路由器直接查表转发。这个方案的问题在真实场景里很快就暴露了——调用方必须明确知道要找谁但如果调用方只知道“我想评审这段代码”却不知道当前系统里哪个 Agent 能评审最合适呢语义路由解决的正是这个问题。一个典型的路由规则长这样我用 YAML 做配置方便非开发同学一起维护routes: - name: code_review_by_diff match: intent: code_review required_tags: [diff, priority] target: capability: code_review strategy: least_concurrency fallback: capability: code_review_lite strategy: round_robin这里有个细节值得展开路由器的match不是做关键词匹配而是对调用方传入的意图字段做归一化。Agent-Reach 约定的触达请求里有一个intent字段调用方在发起调用时声明“我要做什么”而不是“我要找谁”。路由器拿着这个意图去和能力注册表比对找到语义最匹配的 Agent。有人会问这不就是一个关键词倒排索引吗其实没那么简单。我的第二版路由引入了能力评分每个候选 Agent 会按照“输入 schema 匹配度”“输出格式吻合度”“历史成功率”“当前负载”四个维度打分最后加权选优。例如一个 Agent 声明输入必须包含repo_url而请求里没带这个字段它的匹配度就会被打低分另一个 Agent 支持从 diff 内容推断仓库结构评分就会更高。这套评分逻辑让“看起来什么都能干的 Agent”不会霸占所有流量也让调用方少了很多参数不匹配的报错。还有一类边缘情况我得提一下多个 Agent 提供了同一种能力但不同实例的版本不同。Agent-Reach 的路由层允许按版本灰度——注册信息里带version路由时可以指定min_version或者按比例切流量。我当时做代码评审 Agent 升级时就是用这个能力先放 10% 流量到 v2观察评审意见的采纳率之后再逐步放量整个过程不需要改任何调用方代码。2.3 接入适配让不同传输协议的 Agent 都能被触达如果说注册中心和路由层解决的是“找到谁”接入层解决的就是“怎么连上”。这一步比预想的要磨人因为 Agent 团队的背景差异太大了有的 Agent 基于 FastAPI 暴露 HTTP 接口请求响应是 JSON有的 Agent 是长连接服务用 WebSocket 收发消息因为它的响应是流式的要边生成边推送有的 Agent 跑在 GPU 容器里对外只提供 gRPC 接口还有极少数场景Agent 是本地子进程通过 stdin/stdout 通信。Agent-Reach 的接入层做了一件事把所有这些传输方式统一包装成内部触达协议。# 内部统一触达消息结构 class ReachMessage: msg_id: str # 全局唯一消息 ID intent: str # 语义意图 payload: dict # 业务数据 timeout_ms: int callback: str | None # 异步回调地址 trace_id: str # 全链路追踪 IDHTTP 的 Agent 收到内部消息后接入适配器把它转成一个 POST 请求WebSocket 的 Agent 则被注册为一个消息订阅者适配器建立长连接后把消息推过去gRPC 的 Agent 相对省事一些因为 proto 定义清晰适配器做字段映射就行。这个统一并不是为了统一而统一。它带来的实际好处是调用方永远只需要面向ReachMessage编程不用关心目标 Agent 用的是 HTTP 还是 WebSocket。调用方也不需要自己维护 N 套客户端连接池。我自己在接入第三个 Agent 的时候才真正体会到这个抽象的价值——那个 Agent 团队只给了我一个 WebSocket 地址如果调用方全都要自己连 WebSocket 并管理心跳光是接入成本就能劝退一拨人。接入层还有一张“协议能力表”记录每种传输方式的特性。比如 WebSocket 支持主动推送HTTP 只支持请求-响应WebSocket 可以断点续传流式消息HTTP 则需要靠 SSE 才能模拟流式。路由层在选目标实例时也会参考调用方的response_mode偏好——你要流式返回路由器就会优先挑支持 WebSocket 或 SSE 的实例。这块功能我一开始没做后来发现有些 Agent 明明支持流式但调用方拿到的却是一个完整的 JSON 数组体验差很多。补上协议能力匹配之后流式调用才算真正顺了。3. 实操落地把 Agent-Reach 跑起来的完整流程3.1 部署与基础配置Agent-Reach 采用中心化部署核心组件包括注册中心、路由器和接入代理。我当时的部署方式比较朴素三个组件各跑一个容器注册中心配一个独立存储路由器无状态可以横向扩容接入代理跟 Agent 服务同处一个内网。这个部署形态一开始就能支持几十个 Agent 的量级团队规模再大也可以平滑扩展。配置文件里最常改的是路由策略和超时参数。给出一个通用的最小配置示例server: port: 8500 registry: storage: redis://10.0.0.5:6379/2 heartbeat_ttl: 30s router: default_strategy: least_concurrency score_weights: schema_match: 0.4 history_success: 0.3 current_load: 0.2 version_penalty: 0.1 adaptor: http_connect_timeout_ms: 2000 http_read_timeout_ms: 30000 ws_ping_interval: 15sscore_weights这一行的四个权重建议在系统早期不要调得太激进。schema 匹配度永远是第一位的因为参数都对不上后面三个再优秀也没用。等你的 Agent 数量多了开始出现同能力多实例竞争的时候再把current_load和history_success的权重调高。3.2 接入一个 Agent 服务我拿一个 Python 写的代码评审 Agent 举例。假设它本身是一个 FastAPI 服务接收 POST 请求接收{diff: ..., rules: [...]}返回评审意见列表。接入 Agent-Reach 有两种方式推荐先用 SDK 方式from agent_reach import ReachAgent app FastAPI() review_agent ReachAgent( agent_idcode-reviewer-v1, transport_typehttp, capabilities[ { name: code_review, input_schema: {diff: string, rules: array}, expected_latency_ms: 8000, } ] ) app.post(/reach) async def reach_handler(msg: ReachMessage): # 接入层已经完成了协议统一这里直接拿内部消息 result await run_code_review(msg.payload[diff], msg.payload.get(rules)) return await review_agent.reply(msg, result) app.on_event(startup) async def start(): await review_agent.register()这段代码背后的流程是review_agent.register()启动时向注册中心上报能力FastAPI 启动后接入代理会把统一触达消息转成 HTTP POST 发到/reach端点业务函数拿到payload后正常处理最后通过reply(msg, result)把结果原路返回。如果 Agent 不是 HTTP 服务比如是一个独立的 Python 进程通过 stdin/stdout 通信那也简单。接入层提供一个SubprocessTransport它会把ReachMessage序列化成一行 JSON 写入子进程 stdin然后从 stdout 读回结果。对很多原型阶段的 Agent 来说这种接入方式最省事——不需要为了接入 Agent-Reach 硬包一层 HTTP 服务。3.3 发起一次触达调用调用方的接入方式走 Agent-Reach 的客户端 SDK。核心 API 是reachfrom agent_reach import ReachClient client ReachClient(router_addrhttp://router:8500) result await client.reach( intentcode_review, payload{diff: diff_text, rules: [style, security]}, response_modesync, timeout_ms15000, )需要强调一点这里的intent不是指 Agent 的 capability 名称而是一个语义意图。如果系统里恰好有多个 Agent 都能做代码评审路由器会按配置的策略选一个。如果你的业务场景确实想指定某个 Agent可以在参数里加一个prefer_agent字段。不过我一般不建议调用方这样做因为一旦开始指定路由层的能力调度就退化成硬编码了Agent 扩容、新版本上线带来的灵活性都会打折。调用时有两个参数是我建议认真设置的timeout_ms建议设为目标 Agentexpected_latency_ms的 1.5~2 倍留出排队和路由消耗的时间response_mode如果你要的是一边生成一边返回的流式结果就设成streamAgent-Reach 会建立长连接并持续推送消息片段。4. 从“能通”到“稳定”我在生产环境踩过的四个坑4.1 超时重试不是“SQL 重试”我最初给触达调用写重试逻辑时下意识套了数据库连接重试的思路连接超时重试 3 次每次间隔 2 秒。结果发现完全不对。Agent 处理请求是消耗时间并且可能产生副作用——你让一个代码生成 Agent 生成一段函数第一次调用其实已经执行到一半了只是返回超时重试后又触发了一次完整执行等于让同一个 Agent 同时处理两个同样的生成任务既浪费算力又可能产生数据不一致。后来我把重试策略改成了触达场景专用的方案第一优先级是“幂等去重”触达消息带全局msg_id接入代理发现重复 ID 直接返回上一次的结果如果有缓存的话第二优先级是“有限重试只针对特定错误”只有连接失败、路由错误这类触达层错误才重试业务处理超时不重试而是给调用方返回“任务已接收处理中”的状态让业务方自己决定是查询还是放弃第三优先级是“指数退避 抖动”退避间隔从 500ms 开始乘以 2 的指数再加一个随机抖动避免多个调用方同时重试打爆同一个 Agent。这三个策略落地之后我系统的“超时风暴”才算真正缓解。压力测试下同时 100 个调用触发超时重试流量不再像以前那样瞬间打满目标 Agent 的线程池。4.2 流式响应下的背压问题Agent-Reach 支持流式响应之后我遇到的新问题是背压backpressure。模型生成是以 token 为节奏的每秒可能推 20~40 个片段但如果调用方消费得慢或者中间链路某个环节处理不过来这些片段就会在缓冲区里堆积。一个不注意进程内存直接涨上去。我在接入层给流式传输加了两个机制。第一个是滑动窗口限流接入代理不会把所有流式片段全都塞给调用方而是维护一个窗口比如 16 个片段未确认前不再发送下一个批次调用方收到一批、处理完一批、确认一批整个过程平滑可控。第二个是丢弃策略如果调用方的消费速率持续低于 Agent 产生速率的 30%触达层会主动降级为“聚合推送”——不再流式转发而是攒成一个完整结果再一次性返回并通知调用方当前已切换模式。这种做法看起来是“退步”但实际上保全了调用方的体验与其丢数据不如等完整结果。这个坑在 Demo 阶段完全不会暴露因为演示环境下数据和消费速率都处在理想状态。一上生产只要有一个调用方是用低性能服务器跑推理背压问题立刻显现。我补上这两个机制之后再也没出现过流式链路撑爆内存的情况。4.3 协议适配层最容易踩的“语义映射”坑前面我说接入层统一了传输协议但有一类问题传输层解决不了语义不一致。举个例子Agent A 返回的错误码是E40001,含义是“输入参数缺失”Agent B 的错误码是INVALID_INPUT,含义相同。调用方如果同时接了 A 和 B就得各自翻译一遍。Agent-Reach 的解决方案是在接入层做一套标准错误码映射每个 Agent 注册时可以在capabilities里附带一份error_mapping把 Agent 自定义错误码映射到标准错误码上比如E40001 - INVALID_ARGUMENT。调用方永远只面向标准错误码编程。这个设计看起来非常不起眼但实际效果拔群——至少省了我一半的异常处理代码。另一个语义映射的典型场景是时间格式和枚举值。有的 Agent 返回camelCase的字段名有的返回snake_case,接入适配器会根据 schema 自动做字段风格转换。这块如果用手工写映射维护成本非常高Agent-Reach 的 schema 对齐能力是直接读注册表里的input_schema/output_schema自动生成的映射关系所以 Agent 只要把 schema 声明清楚适配层就能自动处理大部分转换。4.4 触达链路必须从头就能“看到底”多 Agent 系统出了问题时最大的难题是定位到底断在哪一环。调用方说“我调了代码评审 Agent 没返回”但你不知道是路由没选到合适的实例、接入代理转发失败、Agent 本身崩溃还是结果返回链路断了。Agent-Reach 从第一版就内置了trace_id字段每个触达消息从调用方发起那一刻就生成一个 ID贯穿整个链路。每个处理节点都会上报一条带有时间戳的 trace 记录路由决策选了哪个实例、接入代理发出请求耗时多少、Agent 开始处理耗时多少、结果返回耗时多少。把这几条日志串起来链路里哪一段慢了、哪一段断了一目了然。除了追踪我还给每个 Agent 实例加了连续指标成功率、平均延迟、并发数、排队数。这些指标不仅用于监控还会回流到路由层的history_success评分里。比如某个 Agent 最近成功率只有 60%路由评分会自动调低它的权重把流量引向更健康的实例。这相当于一个简单的自适应负载保护不用人工干预就能缓解单点故障。5. 做 Agent 触达层最该先想清楚的几件事Agent-Reach 这套东西做下来我最深的体会是触达层的设计决策往往不是技术选型问题而是“边界”问题。想清楚下面几件事能帮你少走很多弯路。第一触达层不应该承载业务逻辑。Agent-Reach 只负责把请求送到、把结果带回至于请求里的内容代表什么意图、结果怎么被调用方理解都是 Agent 和调用方自己的事。一旦触达层开始试着理解业务语义就会变成一个巨大的不可维护的怪物。第二能力描述要接近“接口契约”但也要保留语义弹性。输入输出 schema 太严格会让 Agent 的灵活性受限毕竟 Agent 本来就是要处理模糊意图的但 schema 太宽松路由匹配和参数校验又会形同虚设。我的建议是schema 声明“必须字段”和“可选字段”,必须字段严格校验可选字段允许缺省这样双方都有空间。第三先做好观测再优化性能。Agent-Reach 上线初期我花在调试链路可观测性上的时间比写路由逻辑的时间还多。没有全链路追踪任何性能优化都像是在黑屋子里做手术。有了 trace 和指标之后一次卡顿到底发生在模型推理还是网络传输一查便知优化才有针对性。第四容错机制要“面向业务可接受度”设计。不是所有触达失败都需要重试也不是所有超时都该报错。有些场景比如风险评审要求结果完整且准确宁可等久一点也不能丢有些场景比如实时问答则要求尽快返回哪怕结果稍微不完整也可以接受。Agent-Reach 允许在触达请求里带quality_of_service参数路由和接入层会据此调整超时和重试策略。这是我觉得最实用但最容易被忽略的设计。我做这个项目过程中有朋友问过这看起来不就是微服务那套服务发现和网关吗Agent 场景有必要重新造一遍吗我的回答是表面相似但语义层差得很远。微服务路由按服务名Agent 路由按能力意图微服务重试关心幂等Agent 重试还得关心模型调用的上下文成本微服务的协议几乎是 HTTP 一统天下Agent 场景里长连接、流式、子进程五花八门。Agent-Reach 不是为了取代服务网格而是把 Agent 特有的这些需求收敛成一层独立的基础设施。如果你正准备做一个多 Agent 系统我建议不要一上来就铺开做触达层先用硬编码把链路跑通把业务痛点记下来。等你真的被“调用方不知道该找谁”“超时重试打爆 Agent”“异构协议接入成本高”这三个问题反复折磨时再动手做 Agent-Reach 这样的触达层不迟。到那时你对边界的理解会比看了任何架构文章都清晰得多。