Agent-Reach:让 AI Agent 稳定可达的触达网关实践
发布时间:2026/9/18 11:49:04 锦皓数字建站

Agent 做完了Demo 也跑通了然后呢这是过去一年多我在团队里被问得最多的一句话。模型调用链、工具编排、检索增强这些环节社区里已经有一大堆成熟方案可以直接抄真正卡住工程落地的往往是更土、更不起眼的一层别人怎么找到你的 Agent怎么把一句话稳定地递进去怎么把结果拿回来中途它挂了又该怎么办。Agent-Reach 就是我为这一层起的代号——它不是某个现成的框架也不是一个能pip install的包而是一套关于让 Agent 可达的设计约定加实现骨架。我把它落到过内部平台、也拆掉重写过两次今天把这些东西摊开讲一遍包括四个必须自己定义的契约、一个能跑起来的网关实现路径以及我在联调和压测里踩过的那几个坑。如果你正在把 Agent 从笔记本推向多人共用的环境这篇东西应该能帮你省掉几周的返工。1. Agent-Reach 到底在解决什么问题1.1 从能跑起来到能被触达之间那条鸿沟大部分人第一次写 Agent都是在单个进程里读一个 prompt调几个工具打印结果。这时候没有可达性的概念因为在同一个进程里函数调用天然是可达的。问题从你把它拆出去的那一刻开始——Agent 跑在 A 服务里业务系统在 B 服务里运维的告警机器人想调它前端页面也想调它甚至另一个 Agent 想把它当成一个工具来用。这时候你会发现脑子里的问题清单突然变得很长这个 Agent 现在部署在哪台机器上它是不是还活着给它发消息应该用什么格式JSON 还是别的它执行一次要三秒还是三十秒我要同步等还是异步回调它返回的是一个完整结果还是一串中间过程如果我超时重发了它会不会重复执行扣了两次费这些问题没有一个是模型问题全是工程问题而且每一个都能让线上翻车。我把 Agent-Reach 的核心职责压缩成一句话给每一个 Agent 一个稳定的逻辑地址并为投递一次请求、拿回一次结果这件事提供可预期的语义。注意可预期三个字——它不是保证一定成功而是保证失败的方式是你能说清楚的超时是超时、限流是限流、对端不在线是对端不在线不会出现发了但不知道发生了什么。1.2 可达性的三个维度寻址、鉴权、会话连续性拆开看可达性其实是三件互相纠缠的事。第一件是寻址。物理地址IP、端口、Pod 名是易变的重启、扩缩容、迁移都会变而调用方需要的是一个稳定的东西。所以 Agent-Reach 里必须有一个逻辑地址层物理位置藏在后面。这跟微服务里的服务发现是同一类问题只不过 Agent 的地址还要额外承载版本和能力信息——同名 Agent 的 v1 和 v2 可能行为完全不同调用方有权指定要哪个。第二件是鉴权。Agent 是有副作用的它能读数据库、能发消息、能改工单。谁有资格触达它、触达时能带多大权限必须在触达层就解决而不是丢给 Agent 自己去判断。我在第一个版本里把鉴权放在 Agent 内部做结果每个 Agent 都要重复实现一遍而且一旦有人绕过网关直连鉴权就形同虚设。第三件是会话连续性。单次问答好办麻烦的是多轮和长任务用户先问一句Agent 反问一句用户再补充中间可能隔了几分钟或者 Agent 要跑十分钟中途要汇报进度。这些场景要求触达层能维护会话上下文并且在对端重连后能把上下文接回来。这一块是很多人一开始完全没设计、后期补得最痛苦的地方。1.3 一个反直觉的判断难点不在模型侧我复盘过线上将近两百个和 Agent 相关的告警其中跟模型质量、推理结果相关的不到两成剩下八成集中在连接断开、序列化丢字段、超时判定不一致、并发写注册表打架、重试导致重复执行。换句话说你花在 Prompt 调优上的时间可能远不如花在连接保活上的时间划算。这个结论听起来扫兴但它有个好处它把问题变成了一个纯粹的分布式系统工程问题而这类问题是有标准答案的。心跳、TTL、幂等键、指数退避、背压、链路追踪这些套路在微服务领域被验证了十几年搬过来改改就能用。所以别把 Agent-Reach 想得太玄它就是一层长得像服务网格、但语义上更偏消息投递的中间层。2. 拆开 Agent-Reach 的骨架四个必须自己定义的契约在写第一行代码之前有四份契约必须先定下来。它们的价值在于定完之后接入方、Agent 作者、运维三方就有了一份共同语言后面所有扯皮都能回到契约上解决。2.1 地址契约Agent 的身份怎么表示地址是整层的根。我用的是一个类 URI 的格式比纯 UUID 可读、比裸名字可控组成部分示例作用是否可变schemeagent固定前缀用于和 HTTP 等区分否tenantteam-search租户或业务域鉴权的基本单位否namedoc-summarizerAgent 逻辑名否versionv3或stable版本通道支持灰度是instancei-7f2a具体实例一般由内部路由使用是对外暴露的地址是前三段加版本形如agent://team-search/doc-summarizerv3实例段由网关自己解析调用方看不到也不需要看到。这里有个我坚持了很久的设计绝不允许调用方通过 IP 直连某个实例。一旦开了这个口子灰度、限流、审计全部失效而且你会发现半年后有一堆写死的 IP 散落在各种配置文件里没人敢动。另外stable 这种通道别名很有用。它指向当前该租户认可的稳定版本让上游不用每次发版都改配置。但要注意它带来的副作用一次通道切换等于对上游做了一次隐式变更所以通道切换必须走变更流程不能手抖。2.2 消息契约一次触达的最小信封请求信封是第二个契约。我的做法是定义一组固定的头部字段业务内容全部塞在payload里头部只放路由和治理需要的信息。这样网关不需要理解业务也能做限流、重试、审计。字段必填说明踩坑提示trace_id是全链路追踪标识网关生成不要信任调用方传的session_id否会话标识多轮场景必填空字符串和缺省要区分处理from是调用方身份用于鉴权和审计需签名校验to是目标 Agent 地址解析失败要返回明确错误码intent否语义意图标签用于路由到不同处理策略payload是业务内容大小上限要显式声明deadline_ms否端到端截止时间必须向下透传到 Agent 内部reply_mode是sync / async / stream决定返回通道类型idempotency_key否幂等键有副作用的请求强烈建议带上关于deadline_ms我特别想强调它必须是剩余的时间预算而不是调用方一开始设的总时长。网关转发时要把已经消耗的时间扣掉再往下传否则每一跳都以为自己有完整预算最终端到端超时会被放大好几倍。这个问题在我第一次压测时才暴露出来表现是单跳都很正常整体却大面积超时。2.3 能力契约Agent 会什么、愿意接什么能力清单是我认为最容易被低估的一份契约。它不只是文档而是调度决策的依据。我在里面放的信息包括支持的 intent 列表、输入输出的 JSON Schema、单实例并发上限、P95 耗时、是否幂等、是否支持流式、是否支持取消。有了这些网关就能做几件很有价值的事把不支持的请求在入口直接拒掉而不是转发进去让 Agent 报错根据并发上限做排队而不是硬压根据幂等标记决定能不能安全重试。尤其是是否幂等这一项直接决定了重试策略的激进程度——非幂等请求的重试必须极其保守超时就报错让人来处理宁可失败也不能重复执行。这里有个现实问题能力清单手写很容易过期代码改了清单没改路由就会按错误的信息做决策。我的做法是让清单由 Agent 启动时从代码注解或配置文件自动生成并注册人工只负责审核变更不负责手动维护。2.4 生命周期契约上下线、灰度、回收最后一个契约是状态机。我定义的状态不多但每个转换都有明确的触发条件状态含义进入条件是否接收新请求registering正在注册实例启动并上报清单否ready可服务健康检查通过是draining排空中收到下线指令否返回拒绝并提示重试offline已下线在途请求处理完毕或超时否revoked被吊销鉴权信息失效或人工干预否draining这个状态是关键。很多团队的下线就是直接把实例 kill 掉结果在途请求全部失败。正确的做法是先切到 draining让网关停止向它派发新请求等它把在途请求处理完或到超时上限再真正下线。这段时间通常只需要几秒到几十秒但能显著降低发布期间的错误率。灰度则通过版本通道实现把一小部分请求按比例引到新版本观察指标后再全量。3. 落地实现从零搭一个可用的触达网关契约定完接下来是把它做成能跑的东西。我下面讲的是一套我认为性价比最高的最小实现够用在中等规模场景也能作为后续扩展的地基。3.1 技术选型为什么是 Redis 加 Postgres 的组合注册表我用了两种存储配合而不是一个。Redis存实例的实时状态谁在线、心跳时间、连接地址因为它天然支持 TTL 和高速读写心跳这种高频写入用关系库会很难受。Postgres存能力清单和版本通道配置这类变更频率低、但需要审计和事务的数据。为什么不只用 Redis因为能力清单丢一次就很麻烦而 Redis 重启或淘汰策略出问题时数据可能就没了。为什么不用 etcd 或者类似的一致性存储在单机房、实例数不多的场景下它的运维复杂度换来的收益不明显——这是我的取舍你在多机房或者实例数上千的场景下应该重新评估。网关本身我用 Python 起原型、用 Go 写正式版前者迭代快后者在长连接场景下资源占用明显更友好。目录结构大致是这样重点是边界清晰agent-reach/ gateway/ # 网关主进程鉴权、路由、限流 registry/ # 注册与心跳接口 contracts/ # 四份契约的 schema 定义与校验代码 adapters/ # 各语言 Agent 的接入 SDK observability/ # 指标、日志、追踪的公共封装把contracts单独拎出来做成一个包是我吃过亏之后的决定。早期 schema 定义散在各处网关一套、SDK 一套、文档一套改一个字段要改三个地方必然对不齐。现在它只有一份定义其他所有地方都从这里生成。3.2 注册与心跳把 Agent 挂上来的完整路径Agent 侧的接入大概长这样核心就是启动注册、周期心跳、优雅下线三步import time import uuid import httpx GATEWAY http://reach-gateway.internal AGENT_ID agent://team-search/doc-summarizerv3 INSTANCE fi-{uuid.uuid4().hex[:6]} manifest { intents: [summarize, extract_keywords], input_schema: {type: object, required: [text]}, max_concurrency: 8, p95_ms: 1200, idempotent: False, streaming: True, } def register(): resp httpx.post(f{GATEWAY}/v1/instances, json{ agent_id: AGENT_ID, instance: INSTANCE, endpoint: 10.20.3.11:9100, manifest: manifest, started_at: int(time.time()), }, timeout3.0) resp.raise_for_status() def heartbeat_loop(interval5.0): while True: try: httpx.put(f{GATEWAY}/v1/instances/{INSTANCE}/heartbeat, timeout2.0) except Exception: # 心跳失败不退出但要在本地打点方便定位 pass time.sleep(interval) def drain(): httpx.post(f{GATEWAY}/v1/instances/{INSTANCE}/drain, timeout3.0)有两个细节值得单独说。第一心跳间隔和 TTL 的比例。我用的是 1:3也就是 5 秒心跳、15 秒 TTL。比例太小比如 1:1.2会因为一次网络抖动就误判离线比例太大比如 1:10会导致实例真的挂了之后网关还有很长时间在往死实例上派请求。第二心跳失败不要立刻自杀也不要完全不管正确做法是本地记录连续失败次数同时在响应里探测网关是否在要求自己下线。3.3 路由与分发同步、异步、流式三种触达模式这三种模式对应完全不同的返回通道和失败语义必须在网关层就分流不能混在一起。模式适用场景返回通道超时语义重试友好度sync1 秒内能出结果的短任务原 HTTP 连接端到端硬超时低需幂等async分钟级长任务回调或轮询任务 ID提交即返回结果另查高stream需要过程输出的交互SSE 或 WebSocket首包超时加空闲超时低流式模式有个容易忽略的点超时不能只有一种。你既要限制首包多久内必须到也要限制两包之间最多间隔多久。只设总超时的话一个卡住的流会一直占着连接不放只设首包超时的话一个慢速但健康的流会被误杀。我一般设首包 3 秒、空闲 30 秒具体值按业务调。异步模式的关键是任务 ID 的生成和结果存储。任务 ID 我会用trace_id加上一个随机后缀保证既好追溯又不会撞。结果要设过期时间否则存储会被历史任务撑爆——我见过有人把结果永久存下来半年后一个查询把库拖垮。3.4 幂等与重试最容易被偷懒的一段触达层最容易偷懒的地方就是重试。很多实现的逻辑是报错了就重发一次这在读操作上没问题在写操作上就是灾难。我的做法是把幂等做成显式的# 网关侧伪代码幂等键存在时直接返回缓存结果 cache_key fidem:{req.from}:{req.idempotency_key} if req.idempotency_key: cached store.get(cache_key) if cached: return cached # 注意这里返回的是原始结果不是已处理提示 # 用 SETNX 抢占避免并发重复执行 if not store.setnx(cache_key :lock, 1, ttl60): return err(409, DUPLICATE_IN_FLIGHT)重试策略上我分了三档连接层错误连不上对端可以立即重试一次超时错误只在幂等请求上重试且退避要带抖动业务错误一律不重试直接透传。退避一定要加随机抖动否则一批同时失败的请求会在同一时刻齐刷刷重试把刚恢复的对端再打挂一次。这个场景我在压测里复现过画面相当壮观。4. 我在联调和压测里踩过的五个坑前面是设计接下来是真实的血泪。这一节我尽量把排查链路写全因为怎么找到问题比问题是什么更有复用价值。4.1 心跳正常路由表却是脏的现象一个 Agent 明明已经下线了网关还在往它派请求报连接拒绝。查心跳记录最后一次心跳是二十分钟前的早就超过 TTL 了。排查第一步是看 Redis 里的实例键发现它确实过期消失了。第二步是看路由表——问题在这里我当初为了查询快建了一个按 Agent 名索引实例的集合实例键靠 TTL 自动过期了但这个集合里的成员是我手动维护的没人删。所以出现了数据没了索引还在的典型不一致。修复方案有两个方向一是改用 Redis 的过期事件通知来清理索引但通知本身不保证送达会漏二是彻底不维护二级索引路由时直接按前缀扫实例键。我选了第二个代价是每次路由多一次SCAN但在实例数几百的量级下完全可接受。这个坑的教训是任何依赖 TTL 自动过期的数据都不要在别处保留它的引用副本除非你能保证副本也同步清理。4.2 长连接被中间层悄悄断掉现象流式请求偶尔会突然中断客户端收到一个没有结束标记的响应日志里什么都看不到。这种什么都看不到的问题排查思路是从下往上逐层验证。我先在客户端加了一个计时器记录中断发生的时间间隔发现集中在 60 秒出头——这个数字太规整了几乎可以肯定不是应用逻辑导致的是某个中间层有 60 秒的空闲超时。于是我把应用层心跳从 30 秒缩到 15 秒问题频率大幅下降但没消失。继续查发现还有一层在 5 分钟时做连接轮换轮换时不发任何通知。这个就没办法从应用层规避了只能做重连和会话恢复客户端在断连后带session_id重连网关把会话上下文重新绑定到新连接上客户端从最后一个已确认的事件序号继续。这套机制后来还顺带解决了用户切换网络导致的断连问题算是意外收获。做这类系统把连接随时会断当成常态而不是异常设计会完全不同。4.3 上下文在网关层被截断了现象Agent 收到的 payload 少了几个字段但 Agent 自己单独测试完全正常网关日志里也看不出问题。这类问题的排查核心是比对原始报文。我在网关入口和出口各加了一处原始字节级别的日志只在采样模式下开启否则日志量爆炸对比之后发现入口报文里有attachments字段出口没有。原因是网关内部把请求反序列化成一个强类型的结构体而这个结构体的定义是早期写的没有attachments字段序列化回去时自然就丢了。这是强类型网关的经典陷阱它带来了类型安全和性能但代价是任何新增字段都必须同步改结构体否则会被静默丢弃。我的修复方案是在payload这一层保持不透明透传——网关不解析业务字段只做大小和格式校验把payload当字节块搬运。需要读字段的地方比如按 intent 路由单独做一次轻量解析不改原报文。这个改动之后接入方新增字段再也不用通知网关改了。4.4 多租户下的越权触达现象一个测试租户的请求成功触达到了另一个租户的 Agent而且执行了。这是所有坑里最严重的一个。排查下来问题出在地址解析的模糊匹配上我为了让上游少写点字支持了租户内可以省略 tenant 段的语法糖解析时用当前调用方的租户补全。但有一处代码路径是从运维后台调用的它的租户上下文是空的补全逻辑在空值时用了通配结果匹配到了第一个命中的实例。修复分三层第一去掉通配逻辑租户段缺失直接报错第二地址解析和鉴权彻底分离解析只负责把地址拆成结构化元组鉴权单独用元组里的租户和目标 Agent 的归属做比对任何不一致一律拒绝第三加一条兜底的自动化测试专门覆盖租户 A 触达租户 B的所有地址变体。这个坑让我彻底改变了对语法糖的态度——在安全边界上省掉的每一个字符后面都要用十倍的代价还回来。4.5 观测缺失导致的定位地狱前面四个坑每一个的排查都因为观测不足而多花了几天。最典型的是追踪断链网关生成了trace_id但转发给 Agent 时没带上Agent 内部的日志自成一套两边的日志根本对不上。补齐之后我定了几条硬性要求trace_id必须在所有跨进程调用中透传包括异步回调和流式连接日志必须结构化至少要能按trace_id、agent_id、instance、session_id四个维度检索关键路径上必须有耗时埋点尤其是进入网关到开始转发和收到首包这两段它们能区分网络问题和处理问题。这几条听起来很基础但真的做到位之后前面那种查了两天的问题基本都能在一小时内定位。5. 让 Agent-Reach 撑住真实业务量的调优清单5.1 连接层参数先解决资源瓶颈长连接场景下第一道墙往往不是 CPU 而是文件描述符和端口。每个实例一条连接几百个实例加上客户端连接很容易就到几千。要提前把进程的 nofile 限制调高并且注意系统级的全局限制——单改进程级限制而全局限制很小的话会出现看起来配好了但一到量就报错的诡异现象。TCP 层面我开了 keepalive 并把空闲探测时间调到比中间层超时更短这样连接被中间设备静默丢弃后应用层能更快发现。另外一个反直觉的经验网关的 worker 数不要简单设成 CPU 核数。这类服务大部分时间在等 IOworker 数适当上调能提升吞吐但调太高会加剧上下文切换我在压测里找到的甜点值是核数的两到三倍你的场景需要自己测。5.2 路由缓存与一致性哈希路由决策是每次请求都要做的事如果每次都查 RedisRedis 会先成为瓶颈。我在网关进程内做了一层本地缓存缓存内容是Agent 地址到实例列表的映射过期时间设得很短比如 2 秒并且订阅变更通知做主动失效。2 秒的窗口意味着最坏情况下有 2 秒的不一致对于实例刚下线这种场景是可以接受的因为请求失败后会重试到健康实例。实例选择上我用一致性哈希而不是随机。原因是在实例数量变化时一致性哈希只影响一小部分映射而随机会在扩缩容瞬间把流量重新洗牌导致缓存命中率骤降。如果你的 Agent 是无状态且没有本地缓存的随机也完全可以别为了技术而技术。5.3 限流、降级与背压限流要分维度按调用方限防止单个上游打爆所有下游、按目标 Agent 限尊重它声明的并发上限、按全局限保护网关自身。三层都要有缺一层就会出现某一种攻击面。比限流更重要的是背压。当队列开始堆积时正确的做法是尽早拒绝而不是让请求在队列里等到超时。等超时的请求会占用连接和内存最后返回的还是一个没用的失败结果纯属浪费资源。我的策略是给每个 Agent 维护一个水位超过阈值的请求直接快速失败并返回明确的错误码和重试建议时间。降级主要用于 Agent 不可用的场景。可以配置一个兜底 Agent 或者返回缓存的历史结果但一定要在响应里明确标注这是降级结果让上游能自己决定要不要用。5.4 必须盯住的指标指标含义我用的告警阈值参考注册实例数与心跳丢失率反映实例健康丢失率连续 1 分钟超过 5%路由失败率地址解析、无可用实例1 分钟内超过 1%端到端 P99 耗时用户体验超过业务 SLA 的 80%首包超时率流式流式可用性超过 2%幂等冲突率重复执行风险突增即告警队列水位背压状态持续 30 秒超过 70%这张表我强烈建议直接抄然后按自己的业务调阈值。指标的价值不在于全而在于每一项都对应一个明确的行动看到路由失败率涨了你就知道该去查注册表看到首包超时率涨了就该去看流式链路。没有行动的指标就是装饰。6. 边界与取舍什么情况下不该自己造这一层6.1 三种不建议自研的场景第一种Agent 数量少于五个调用方也只有一个。这时候直接在调用方和目标之间加一层薄薄的 HTTP 封装就够了你需要的只是把地址和超时统一不需要注册中心、不需要一致性哈希、不需要背压。第二种所有 Agent 都在同一个进程里或者同一台机器上。同机通信的成本远低于跨机你引入的每一层网络抽象都是在给自己增加故障点。第三种你的团队还没有稳定的运维能力。Agent-Reach 引入的注册表、网关、SDK 都需要有人值守如果没人能处理注册表挂了怎么办这种问题那它带来的风险会大于收益。6.2 什么时候值得自研反过来出现这几个信号时自研是值得的调用方超过三个且分布在不同的团队这时候统一的契约能省掉大量沟通有明确的租户隔离要求通用方案往往不满足你的合规细节需要在触达层做业务相关的决策比如按意图路由、按成本选模型这类需求很难直接套用现成方案以及对观测有硬性要求必须能按自己的维度追溯。一个中间路径是先用现成方案跑到规模上限同时把契约先定下来。契约是自研的真正价值所在代码反而是最容易替换的部分。把契约设计对了将来从现成方案切到自研或者从自研切到别的方案迁移成本都会小很多。6.3 演进方向协议标准化与能力市场再往后看我判断这层会逐渐走向标准化地址格式、消息信封、能力清单这些东西现在各家自定义但迟早会收敛到几套主流约定上。对做这一层的人来说现在把契约定义得干净、可扩展将来适配标准会容易得多如果一开始就往里塞满业务特化的字段将来会很痛苦。另一个方向是能力市场。当能力清单被统一描述之后网关就能做更智能的事根据请求的意图自动选一个合适的 Agent而不是由上游硬指定地址。这时候成本、时延、可用性就成了调度参数Agent 之间也能互相调用形成编排。我在内部做过一个小规模的实验效果不错但前提是能力清单足够准确——这也是我前面强调清单要自动生成的原因人工维护的清单撑不起自动调度。最后分享一个我个人在实际操作中的体会做 Agent-Reach 这类东西最大的诱惑是把它做得聪明。很容易就想加意图识别、加自动路由、加智能重试。但我两次重写之后发现真正让系统稳的是那些最笨的部分——严格的地址校验、显式的幂等键、明确的超时预算、诚实的错误码。聪明的部分可以后面慢慢加笨的部分一旦省了后面每一次出事都要重新疼一遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。