
搞 AI Infra 这几年我最深的感触是模型能力再强如果服务上路后“找不到人干活”一切白搭。去年团队从单体模型服务切到多 Agent 协作模式几十个 Agent 用服务网格管起来结果发现微服务那套注册、路由、故障转移逻辑放在 Agent 场景里完全水土不服。有的 Agent 是长时推理任务有的需要流式返回有的根本不适合作为无状态节点被反复拨号。我最后被逼着从零写了一套专门做 Agent 触达调度的中间层起名叫 Agent-Reach核心解决“任务如何稳定、快速、可追踪地送达给正确的 Agent并在它故障时自动转移”这一连串问题。这篇文章把整套架构、关键实现和踩坑过程完整复盘一遍适合正在做 Agent 编排系统、或者被 Agent 路由调度搞得头疼的同行参考。1. 为什么我非要做 Agent-Reach多 Agent 时代的“最后一公里”问题1.1 从单体调度到多 Agent 协作的转变先说背景。我们内部跑了不少 Agent有做代码生成的有做 SQL 查询转换的有做文档归纳的还有几个负责多轮对话记忆管理的。最早它们都是单体服务各自挂独立 API调用方自己管 endpoint、自己管重试。问题是 Agent 数量一多调用方代码里全是 if-else 和超时设置新 Agent 上线要改一堆调用方Agent 下线检修也要逐个通知。后来我用集中式调度解决这个问题一个 Dispatcher 统一接收上游任务按照类型分发给不同 Agent。这个阶段还算顺利最多算“佣金中心”但真正的问题在流量上来之后暴露了。1.2 我踩过的第一个坑天真的轮询一开始我把调度策略写得很简单同类型的 Agent 都是等价的用 round-robin 轮询分发。某个 Agent 处理慢任务就堆积处理快就空闲。更尴尬的是有些 Agent 加载了大模型权重冷启动要 30 秒以上轮询路由到一个刚重启的节点就直接超时。当时我很天真地以为加一层负载均衡就完事结果生产环境连续报警某个 Agent 节点内存泄漏但心跳还能返回轮询依然把任务发过去然后这个任务卡死直到调用方超时。这让我意识到Agent 调度的核心不是“随手选一个”而是时刻知道哪些 Agent 现在真的可用并根据它们的能力和状态做触达决策。1.3 Agent-Reach 要解决的具体场景我梳理了几个不得不自研的场景Agent 实例随时可能重启、扩缩容触达层必须拿到的是一份实时可用列表。不同 Agent 能力不同有的只接收纯文本有的接收多模态输入触达要求也不一样。同一个用户的多轮对话必须稳定触达同一个“会话 Agent”不能每次换人。长任务执行过程中Agent 可能短暂不可用不能直接判定失败需要合理的重试和转移策略。Agent-Reach 本质上就是一个面向 Agent 的触达网关做三件核心事维护 Agent 注册信息、感知节点存活状态、按规则把任务触达给正确节点。下面我拆开讲每一部分的实现。2. Agent-Reach 的核心架构拆解路由、会话与注册中心2.1 Agent 注册中心的实现所有 Agent 启动后都要向 Agent-Reach 注册注册信息我定为五元组agent_idAgent 的唯一标识。agent_type能力类别例如 “sql_agent”“code_agent”。endpoint实际服务地址用 gRPC 或 HTTP 统一封装。capabilities补充能力描述例如支持流式、支持多模态。权重同类型 Agent 间的初始路由权重可按优先级调整。注册表我用内存 定期快照的方式实现每次变更写操作日志节点重启后可以从快照恢复。没有上 etcd 一类的强一致组件因为注册信息丢失可重建Agent 重新上报即可保证最终一致性就够了。注册接口的幂等性很重要。Agent 崩溃恢复后重新注册如果注册中心里还有旧记录是覆盖还是拒绝我的策略是基于 agent_id 做覆盖更新但记录一个“实例代次”字段。旧实例代次低即使延迟注册包到达也不会覆盖新实例的状态。这个设计后来帮我避免了很多因网络延迟导致的“幽灵节点”问题。2.2 触达路由的两级策略我把触达路由拆成“先选类型再选实例”。第一步由 agent_type 过滤出候选集合第二步在候选集合里根据评分排序选具体实例。评分函数我当时试了好几版最后锁定为score availability_score * 0.5 latency_score * 0.3 capacity_score * 0.2availability_score 来自节点心跳反馈最近 3 分钟心跳全部成功为满分中间有缺失则扣分。latency_score 基于滑动窗口内的平均响应时间响应越快分越高。capacity_score 看当前在途请求数与实例声明最大并发数的比值越空闲的分越高。这个评分不是恒定不变的Agent-Reach 每 200ms 重新计算一次各节点分数。实际效果是短请求优先走低延迟节点长请求会自然避开已经满载的节点。我一度想引入机器学习预测负载后来发现规则评分在大部分场景已经能达到很好的均衡效果而且调试成本低就保持了这套方案。2.3 会话保持的必要性多轮 Agent 对话有一个硬需求同一个对话 ID 的所有轮次尽量由同一实例处理。这是因为会话状态如果存在 Agent 本地内存里切换到别的实例上下文就丢了。Agent-Reach 对会话触达做了个 sticky 路由用会话 ID 做了 hashhash 映射到固定实例。但实例故障时必须转移转移时 Agent-Reach 会尝试从该实例拉取会话快照给你一次冷启动重建上下文的机会。这里有个细节hash 环上每个实例前后各 50 个虚拟节点做了探活派生故障时优先转移到相邻虚拟节点对应的实例避免全局 hash 重排导致所有会话同时迁移。3. 心跳与健康检查如何做到不打扰 Agent 又能及时摘除故障节点3.1 三种探活方式的取舍Agent-Reach 刚做探活时我在“主动 ping”“被动观察”“业务探针”之间反复纠结。主动 ping 简单有效但高频请求会打扰到真正执行任务的 Agent被动观察只统计请求成功率信息量不够及时业务探针又要求每个 Agent 额外实现一套接口落地成本高。最终我采用了组合方式。底层用 5s 间隔的主动 ping 维持基础存活状态中层靠所有业务请求的成功率作为信号源上层再加一个“会话心跳”针对长时任务的 Agent由任务回调主动上报进度状态。这个三层结构让我既能快速发现问题又不会让探活本身成为额外负担。3.2 温心跳正常业务流量中夹带状态信息音频信号里有一个“导频音”的概念我一直觉得很好后来把它用在了 Agent-Reach 的健康检查里。与其单独发空请求去探测不如让正常业务请求在返回时额外携带节点状态信息。启动时我会给 Agent 侧 SDK 注入一个拦截器自动在响应里夹带当前内存水位、在途任务数、最近处理耗时、剩余会话槽位。这样每次业务调用都在刷新节点状态Agent-Reach 几乎实时掌握每个节点的真实健康度而不是“活着但已经快被压垮”的假健康状态。这个方案大幅减少了探活消耗也让误判率降了一个量级。3.3 故障转移的决策逻辑探活发现节点异常后触发转移。但这里不能急躁因为单一心跳丢失可能只是网络抖动。我设计了三级确认机制一次探测失败标记为“可疑”继续观察 2 个周期。连续 3 次失败状态变为“不健康”不再把新任务路由给它但在途任务仍然等待。5 分钟内未恢复状态变为“下线”允许重试的任务转移到副本节点。在途任务的等待有一个阈值默认 30 秒。如果 30 秒内节点恢复任务继续执行超过阈值则根据任务是否幂等决定转移或失败。长任务场景下我建议给 Agent 侧实现 checkpoint 上报这样转移后的实例能从头或最近检查点恢复而不是全部重跑。3.4 我在生产环境遇到的探活误杀案例讲一个真实事故。某个 Agent 承载了一段非常重的计算型任务单次耗时到 40 秒期间它的 event loop 被占用虽然底层服务还活着但无法响应 ping。结果它被标为“不健康”新的同类任务全部转移到了另一个低配实例上直接压垮了那个实例。这个事故让我深刻意识到探活的设计必须考虑 Agent 的任务类型差异。对长时任务节点我专门开了“慢节点保护”模式探活超时阈值动态拉长同时通过温心跳里的业务进度信息判断它是在正常干活还是卡死了。具体判断逻辑是如果节点持续上报进度数据即使 ping 不响应也不摘除只有进度数据也停止更新才真正判定异常。4. 触达协议与序列化设计让所有 Agent 说同一种语言4.1 协议边界定义Agent-Reach 的触达协议本质是定义“请求从网关到 Agent 之间如何规范化表达”。我一开始很排斥自造协议试过直接把不同 Agent 的原始接口透传结果网关变成了一个巨型 switch-case维护成本爆炸。后来定了三个边界触达请求、任务元数据、上下文附件。触达请求包含消息体和超时策略任务元数据包含任务 ID、会话 ID、优先级、可重试标记上下文附件则指对话历史、文档片段、模型参数覆盖等。协议层做了版本号字段Agent 升级到新协议后旧协议请求还能继续处理一段时间避免全链路同时部署的尴尬。4.2 序列化的坑JSON 还是 Protobuf选序列化格式时我首先排除了 XML太啰嗦。JSON 在调试期非常香直观、方便日志排查但生产环境面临两个问题一是序列化体积比较大二是动态类型在跨语言场景下容易出现解析不一致。我最终采用“双模方案”Agent 之间内部调用用 Protobuf网关对外提供 JSON 适配层。这样既保留了调试友好性又保证了核心链路的高效。如果你团队规模不大、Agent 全是 Python 写的直接用 JSON 也不是不行但一旦引入 Java/Go/Rust 的 Agent强烈建议换成 Protobuf。这里有一个很隐蔽的坑Protobuf 的字段语义。同一个数字标签在不同版本里被复用会导致解析错乱。我踩过升级 Agent 时旧网关解析新实例的 protobuf 消息字段错位结果把 task_id 当成 priority 来解析任务优先级全乱。从那以后我对 protobuf schema 的变更严格执行“字段只增不改删标签永不复用”。4.3 流式响应与非流式响应的统一封装做 Agent 触达时最头疼的是有的 Agent 一次性返回完整结果有的 Agent 用 SSE 流式吐字。网关如果分别处理调用方代码会同时出现两套逻辑非常割裂。Agent-Reach 的处理方式是在网关内部把非流式响应也转化为统一的事件流。非流式 Agent 的完整结果被包装成一个“complete”事件流式 Agent 的增量会被包装成“delta”事件打完收工后追加“complete”事件。这样对上游暴露出去的只有一条流式通道调用方只需要处理事件类型即可。这个方案带来的一个副作用是网关要维护流式连接的缓冲和回放状态。某个下游读流读到一半断开Agent-Reach 会缓冲最近 10 秒的增量事件等重连后先回放再续传。实际用下来这个能力非常受欢迎毕竟现实中网络断连是常态让上游感知不到断流才是真正的好设计。4.4 错误码的语义化设计Agent 调度错误处理如果不统一排查问题能让你崩溃。之前出现过一个 Agent 用 500 表示“模型超时”另一个 Agent 用 500 表示“参数不合法”。网关拿到 500 直接触发重试结果对参数不合法的请求重试了三次白白浪费资源。Agent-Reach 在协议层定义了五类错误无效请求、资源不足、节点不可用、任务超时、内部错误。每类错误都有明确的 retryable 标记。无效请求和参数问题绝对不重试资源不足和任务超时按策略有限重试节点不可用交给故障转移逻辑内部错误只记录并告警不盲目重试。这套语义化错误码后来被我们推广到了所有 Agent 的 SDK 里排查问题靠错误码就能定位是网关层还是 Agent 层。5. 实测数据与调优记录压测结果、参数配置和避坑清单5.1 压测环境和压测方法我先说压测环境方便你做横向参考服务端 Agent8 节点每节点 4 核 8G运行一个基于大模型的轻量 Agent平均响应 300ms。Agent-Reach 网关3 节点每节点 2 核 4Gconf 里直接放内存态。压测工具自研 Python 脚本模拟 500 路并发持续 10 分钟。网络环境同机房内网RTT 在 0.3ms 左右。压测方法分为三档第一档是全部命中可写缓存模拟纯路由压力第二档是 30% 请求触发温心跳和会话保持逻辑第三档是模拟节点动态故障每隔 1 分钟手工下线一个节点。5.2 三个关键参数调优过程压测结果出来QPS 不算差但 Latency P99 到了 1.8 秒我接受不了。排查后发现三个参数设计不合理。第一是窗口大小。我在路由评分里用了最近 3 分钟的滑动窗口但在高并发下这个窗口掩盖了瞬时故障——某个节点已经 10 秒无法响应了但因为之前 3 分钟表现良好它仍然被高分选中。我把窗口缩短到 30 秒并把最近 10 秒的数据单独加权P99 降到了 600ms 左右。第二是超时等级。最初所有任务用同一个触达超时5 秒。短任务对 5 秒完全没感知长任务却频繁触发。我改成多级超时普通问答 2 秒代码生成 15 秒文档分析 30 秒并支持单请求覆盖默认值。压测时明显看到误重试率下降。第三是重试策略。最早的失败重试是立即重试失败一次马上再打一次。压测发现某个节点刚被摘除时重试请求又会打到它导致的“同区雪崩”。改成了阶梯重试首次失败等待 300ms第二次 1.5s第三次 5s并且剔除失败节点后再选路由。最终 P99 稳定在 400ms 左右错误率控制在 0.3% 以下。5.3 我整理出来的避坑清单按实战经验我列一下 Agent-Reach 落地时最容易踩的坑Agent 节点注册后一定要等两到三个心跳周期再放量否则刚注册的节点可能内存还没加载完就被路由进来了。会话保持 hash 需要考虑热点问题部分会话 ID 会集中到某个节点建议会话 ID 生成时接入随机因子。修改序列化 schema 时旧版本 agent 和新版本 agent 同时在线必须保证新旧协议的兼容性否则灰度期间请求失败率飙升。温心跳的夹带数据量要有上限我限制每个响应里附加状态不超过 2KB避免宽带占用淹没业务数据。网关自身要监控内存和 GC因为要缓冲流式事件我曾因为缓冲不释放导致网关 OOM后来加了缓冲大小上限和过期清理机制。6. 从 Agent-Reach 到 Agent Mesh我接下来的扩展计划6.1 多租户隔离现在 Agent-Reach 的注册表是全局的所有调用方都能看到所有 Agent。随着接入团队增多我需要加租户隔离。方案是每个租户维护独立的虚拟路由表Agent 注册时可以声明允许访问的租户列表路由时先做租户过滤再做类型过滤。这样同一个 Agent 可以为多个租户服务也可以通过租户标记实现专属实例。这个改造最大的挑战是缓存隔离。如果不同租户的 Agent 列表混在一个缓存里A 租户下线的 Agent 可能还在 B 租户的查询结果里。我会为每个租户单独分配缓存分片并在租户路由变更时做主动失效。6.2 跨地域触达与边缘调度目前 Agent-Reach 部署在单地域跨地域调度时延是不可接受的。我的扩展计划是让网关节点的注册中心做订阅同步每个地域只保存本地 Agent 的完整信息远端 Agent 只保存摘要信息。跨地域任务默认路由到本地本地没有匹配类型时才尝试远端同时增加“地域得分”参与路由评分。这里有个必须想清楚的点远端触达的失败率会显著高于本地如果远端失败自动重试到第三个地域整体延迟会叠加得很高。我的对策是按任务类型区分延迟敏感度问答类任务绝不跨地域批量分析类和文档处理类允许跨地域重试。6.3 一个实用建议扩展计划说多了一点如果只看一个经验我会建议你把 Agent-Reach 这类网关的“可观测性”放在第一位。因为我踩过的所有坑最终都靠日志和指标定位而不是靠直觉。Agent-Reach 的每个路由决策都会打一条带 trace_id 的日志记录候选节点列表、各自评分和最终选择理由再加上本轮刷新的健康状态。这套日志让我能在故障发生后快速复盘也能在路由策略调整时对比效果。最后分享一个排查小技巧当任务触达失败又不知道去哪看时先查 Agent-Reach 里这条 trace_id 的“转移次数”。如果转移次数远高于正常值说明 Agent 节点的稳定性有问题而不是网关逻辑有 bug如果转移次数为零且还是失败那多半是请求在源头就出了问题。这个判断帮我节省了不知道多少个深夜的排查时间。