
1. 项目缘起当团队里堆了十几个 Agent 之后先说说这个项目是怎么来的。半年前我所在的团队在做客服机器人和内部运维助手本来各自为政客服机器人管售前咨询运维助手盯告警和发工单后来又加了销售线索筛选 Agent、招聘初筛 Agent、日报总结 Agent……到第四五个的时候还没觉得有什么问题等到了七八个痛点就压不住了每个 Agent 都有自己的 API、自己的鉴权方式、自己的工具调用规范业务方想在一个页面里同时触发两个 Agent 做协同根本做不到。有人提过干脆每个团队各自维护算了但老板要的是统一的数据看板、统一的成本核算、统一的访问入口这活儿绕不开。于是就有了 Agent-Reach。这个名字我自己起的核心想法很简单Reach触达。让任何 Agent 都能被统一触达、被调度、被连接。它可以理解为一个面向 AI 智能体的轻量级触达与编排运行时把所有散落的 Agent 统一注册、统一路由、统一监控。第一版从设计到上线用了三周到现在已经在内部跑了两千多次真实调用接入 13 个业务 Agent稳定性和可观测性都达到了可以对外分享的水平。这个项目适合谁参考我觉得有三类人会很需要一是团队里已经有多个 Agent 但缺乏统一入口的二是准备把 Agent 能力开放给其他业务线但不知道怎么设计协议和路由的三是对 Agent 编排感兴趣、想自己搭一套跑通的开发者。下文会把我踩过的坑、权衡过的方案、最后落地的代码结构全部倒出来你可以直接抄作业。2. 整体设计与架构中心化注册分布式运行2.1 为什么不是把所有 Agent 都收编进来跑一开始团队里有人提了一个方案把每个 Agent 都重写成统一框架跑在一个共享进程里。我直接否了。原因不复杂我们现存的 Agent 有的是 Python 写的有的是 Node.js 写的有的干脆是外部采购的 SaaS 服务只有一个 HTTPS Webhook。强行收编意味着重写、迁移、风险全压在项目组身上业务方不会买账。所以 Agent-Reach 采用了“中心化控制面 分布式执行面”的设计。控制面只存元数据这个 Agent 叫什么、提供什么能力、用哪种协议接通、健康状态如何。真正干活的时候调用请求被路由到目标 Agent 自己的运行环境它还在原来的机器、原来的技术栈里跑Agent-Reach 只负责把请求送过去、把结果拿回来、把过程记下来。打个比方老式公司里每个部门都有独立的业务系统Agent-Reach 做的事情不替换这些系统而是建了一层“统一前台”——窗口就一个后面该找哪个部门还是找哪个部门。这个取舍换来两个很实际的好处接入成本极低新 Agent 上线只需要注册一个描述文件故障隔离天然存在某个 Agent 挂了不会连坐拖垮整个平台。2.2 为什么不用现成的 MCP 或模型网关调研阶段我们认真比较过两条现成路线。第一是 MCPModel Context Protocol它解决的问题是让模型获得标准上下文和工具调用能力本质上是“模型 ↔ 工具”的协议。但我们需要的不是模型去调用工具而是业务系统去调用一个完整 Agent 服务——这两个层次不一样。Agent-Reach 需要处理的是并发额度、健康检查、按调用方计费、路由降级这些 MCP 协议里根本没有对应概念强行扩展等于自己造协议不如直面问题。第二是模型网关像传统 LLM Gateway 那一类。它们擅长统一管理 GPT、Claude、国产大模型这些底座的 Key 和请求转发但管不到“一个有状态、有工具、有记忆的 Agent”。客服机器人不是一次 prompt 完成的它内部有对话状态机、会查订单库、会调用工单系统网关视角下的“单次 token 计费”覆盖不了这种场景。两相对比Agent-Reach 站在 Agent 层而不是模型层这是定位上最大的差异。2.3 核心数据模型Agent Descriptor所有设计里最先定下来的是 Agent 的描述文件Descriptor。它是注册 Agent 的“身份证简历”控制面基于它做路由、鉴权、计量。我强烈建议你把这个数据结构想清楚再动手写代码因为后面所有机制都是围绕它展开的。一个最小可用的 Agent Descriptor 包含下面这些字段。字段用途示例name全局唯一名称crm-lead-filterversion语义化版本1.2.0capabilities能力声明供路由匹配lead_qualify, company_enrichendpoints接入地址可多个grpc://10.0.8.5:9001health_check健康检查方式/healthz, 30s 间隔auth_type相对简化的认证方式token / mtls / noneconcurrency单实例并发上限8meta扩展属性cost_model, team_ownercapabilities 是我们路由的核心依据。它最好用带命名空间的动词来组织比如lead_qualify、ticket_create尽量不要搞成自由文本否则后续做匹配时语义不好收敛。我在第一版吃了这个亏有人填“处理线索”有人填“qualify_leads”路由匹配规则怎么调都别扭后来干脆在注册时强制校验词表。3. 核心机制实现协议、路由、背压与计量3.1 Wire Protocol基于 JSON-RPC 2.0 的触达协议Agent-Reach 的通信协议没有另起炉灶直接基于 JSON-RPC 2.0 做了两层扩展。第一层是统一信封所有请求都带request_id、target_agent、timeout_ms、trace_id这四个公共字段第二层是方法约定调用方不需要关心目标 Agent 内部是 REST 还是 gRPC统一面向agent.invoke和agent.health这两个方法。为什么选 JSON-RPC理由有三一是简单没有 WSDL 和复杂 schema 那一套新团队半天就能上手二是跨语言友好Python、Go、Node 都有现成实现不搞平台绑定三是它天然支持 request/response 语义适合我们的同步调用模型。早期也考虑过 GraphQL但我们的调用模式远没有复杂到需要查询语言的程度反而是一来一回的 RPC 更直观。统一信封的设计带来一个额外好处全平台可以做到请求级全链路追踪。业务侧传过来一个trace_id从网关到 Agent 运行时到工具调用每一跳都打点。排障的时候打开链路面板一眼看到底是卡在了路由排队、Agent 内部还是外部工具调用上。这个能力后来成了我们最常用的功能比调用量面板还常用。3.2 路由与能力匹配打分制还是规则制路由模块是最早开发也最反复的部分。第一版是纯规则匹配调用方声明需要的 capability控制面建一张静态映射表。问题很快暴露了——同一类能力有两个 Agent 都声明了怎么办规则表只能写“优先级”但不同时间点两个 Agent 的负载和健康度是动态的固定优先级根本反映不了实际情况。第二版改成了加权打分。当控制面收到调用请求时先按能力声明筛出候选集合再对每个候选计算一个综合分数分数最高且健康检查通过的 Agent 胜出。score capability_match * 0.7 health_score * 0.15 success_rate * 0.10 load_factor * 0.05capability_match 是请求意图和 Descriptor 中 capabilities 的语义相似度我们初期用关键词权重后来引入了简单的 embedding 匹配health_score 来自心跳和主动健康检查的综合结果success_rate 是近一个小时的调用成功率load_factor 是当前并发占用率归一化后的惩罚项。这套公式在内部跑了两版迭代权重调整过三次最终的结果是冷启动新 Agent 时给 success_rate 一个默认值 0.9否则新 Agent 一上来成功率是空值会直接被算成 0永远没流量这是第一个要避开的坑。另外还要加一层“软路由”兜底如果打分最高的 Agent 超时或返回错误控制面自动尝试候选列表里的第二个类似多级降级。这个机制在其中一个 Agent 依赖的外部系统宕机时救了我们一次详情放在排障章节里讲。3.3 并发控制与背压Agent 不是无限吞流量的黑盒每个实例都有并发上限。我们把每个 Agent 的concurrency字段作为控制面做准入控制的依据控制面维护每个 Agent 的已占用额度当新的调用到达时如果额度已满直接返回429 Too Many Requests而不是继续往里塞。这个决定一开始有人反对觉得应该用队列缓冲。我的观点是对于 Agent 这种可能阻塞数秒甚至数十秒的服务队列缓冲只会造成“看起来在排队实际已死”的假象。调用方看到请求一直挂起也不知道该不该重试反而更容易拖垮下游。直接快速失败、让调用方根据Retry-After头自己退避是更诚实的做法。实现上我们给每个 Agent 维护一个原子计数器每次进入请求 1完成时 -1再加一个略微宽松的等待队列长度不超过并发数的 2 倍。超过等待队列的直接拒绝。背压不仅保护了 Agent也保护了接入的各个调用方——不至于一个慢 Agent 把全部调用线程池都占满。3.4 可观测性与成本计量Agent-Reach 分两个维度做观测技术维度和业务维度。技术维度就是前面说的 trace metrics用 Prometheus 暴露路由延迟、成功率、排队长度、并发饱和度这几个指标告警规则很简单排队长度持续超过阈值 2 分钟就告警成功率低于 95% 就告警当时排查下来大部分是依赖工具的外部服务慢少部分是我们路由权重访问了健康度低的节点。业务维度是做调用计量。每个调用方注册时有唯一的app_id控制面在每次请求完成后记录一条计量日志哪个调用方、调了哪个 Agent、耗时多久、占用多少额度。按天汇总后可以直接对内部部门出账单。这个功能上线前完全没人提但上线后是业务方用得最勤的因为“谁使用了谁的能力”这个问题在跨部门协作时非常敏感有客观数据能减少大量扯皮。4. 实操接入5 分钟跑通一个最小案例4.1 注册 Agent一条命令的事接入 Agent-Reach 的第一步是让控制面认识你的 Agent。假设我要接入一个负责销售线索筛选的 Agent名字叫 lead-filter它提供一个 HTTP 接口那么我先写好描述文件再调用控制面注册接口。POST /v1/agents/register { name: lead-filter, version: 1.0.0, capabilities: [lead_qualify, company_enrich], endpoints: [ {protocol: http, url: http://10.0.8.5:9001/invoke, weight: 100} ], health_check: { type: http_get, url: http://10.0.8.5:9001/healthz, interval_seconds: 30 }, concurrency: 8, auth_type: token }注册成功后控制面会返回一个agent_id和一对用于签名的密钥。这里有一个细节健康检查的路径最好是一个真正的 liveness 探针能够反映这个 Agent 是否真的能处理业务请求而不仅仅是进程还活着。我见过有人把/healthz直接实现成return ok结果进程活着但内部依赖的数据库已经断连了控制面还在傻乎乎地往里路由请求最后是成功率告警才发现问题。4.2 编写 Agent 侧运行时接入的最小实现Agent 侧不要求你用任何特定框架只需要实现两个 HTTP 接口一个是健康检查一个是 invoke。invoke 接口收到的是统一信封格式的请求内部处理完后返回统一响应格式。以 Python 的 FastAPI 为例核心逻辑就是一个函数app.post(/invoke) async def invoke(request: AgentInvokeRequest): # 校验签名防止控制面之外的人伪造请求 verify_signature(request.headers[X-Agent-Reach-Sign], request.body) # 执行业务逻辑这里可以调模型、调工具 result await my_lead_qualify_pipeline(request.params) return { result: result, trace_id: request.trace_id, agent_name: lead-filter, cost_ms: compute_cost_ms() }接入时最容易忽略的是“并发额度占用的释放”。如果 Agent 内部处理抛了异常必须确保在 finally 块里把额度归还给控制面——因为控制面等不到 Agent 的完成通知就会一直把这个 Agent 的占用额度算满后面的所有请求都会被 429 拒掉。这是我在生产上遇到最多的接坑点十次里得有七八次是这类泄漏导致的间歇性不可用。4.3 从调用方发起触达一个 curl 就能验证接入方这边更简单。业务系统只需要拿到一个触达地址然后按统一信封发请求即可。curl -X POST https://agent-reach.internal/v1/invoke \ -H Content-Type: application/json \ -H X-App-ID: crm-backend \ -H X-Api-Key: sk_live_xxxx \ -d { target_agent: lead-filter, capability: lead_qualify, params: {company_name: 某科技公司, contact_email: opsexample.com}, timeout_ms: 8000, trace_id: t-20240612-001 }控制面返回的结果是统一 JSON除了业务数据本身还包含agent_name实际上路由给了谁、attempts内部尝试了几次、fallback_used是否走了降级分支。这三个附加字段对调用方感知“服务质量”非常重要比如fallback_used是 true 时虽然结果也返回了但业务方应该在日志里打一个弱化告警说明主路径已经不可用。4.4 超时与重试的正确姿势调用 Agent 不比调用数据库一个 Agent 内部往往串联了多个工具、多次模型调用耗时很容易超过常规 HTTP 超时。我们把“超时控制”设计成两层控制面到 Agent 的超时、调用方到控制面的超时。调用方传的timeout_ms是上层超时控制面会根据它自动计算留给下游的时间并在下游超时前返回一个带错误码的响应而不是让上游无限等待。重试策略上我给调用方定的规范是429 可以立即重试一次5xx 退避 200ms 重试一次超时错误不自动重试因为超时的请求可能已经在下游执行了重试会造成重复处理。如果你在业务上不能接受“最多重试一次”那就应当给请求体带上幂等键我们内部用request_id做幂等Agent 侧会对相同request_id的重复请求返回上一次的结果。这个机制在定时任务场景里尤其重要避免每条工单被创建两次。5. 常见问题与排障实录5.1 三个最经典的故障案例这套系统上线跑了三个月我把遇到的典型问题整理成一个速查表先看故障再给对症方案。症状根因解决方案大量 429Agent 实际负载不高额度占用未释放异常路径泄漏必须在 finally 中归还额度给控制面加 60 秒占用自动过期兜底新 Agent 注册后永远没有流量success_rate 冷启动为空打分被算成 0冷启动默认值 0.9或注册后先手动引流 10 分钟某个 Agent 间歇性失败健康检查显示正常liveness 探针只检查进程存活没检查依赖healthz 里加数据库连接检查和模型服务连通性检查这三个案例是有共性的都是“表面正常实则异常”的情况。第一类问题最难查因为通过指标面板看Agent 的 CPU 和内存都很低控制面却在拒绝请求最终是通过打印额度占用日志才发现是异常分支没有归还。第二类问题体现的是默认值的艺术——冷启动下 0 和 0.9 的差异决定了新 Agent 是被饿死还是被快速验证。第三类问题告诉我们探针的真实性远比探针的频率重要。5.2 心跳风暴与抖动冲击问题健康检查模块早期用的是固定间隔心跳每个 Agent 每 30 秒上报一次。理论上没问题但当我们接入 13 个 Agent、且监控系统做了一次重启后出现了一次心跳风暴大量 Agent 在同一时间重连控制面的心跳处理队列瞬间被冲高导致正常的调用请求被排队拖慢。这不是控制面处理能力不足而是“重连抖动”没有做平滑。解决办法是给心跳加上随机抖动每个 Agent 在interval_seconds * [0.8, 1.2]范围内随机错峰上报。另外控制面在处理心跳时只更新内存状态不落库磁盘写入通过异步批量完成——心跳这层数据是允许丢失几秒的为了可靠性和强一致去牺牲延迟完全不值当。5.3 路由误判相似能力的 Agent 被指错另一个让我头疼的问题是能力语义附近。我们有“客服机器人”和“工单助手”客服机器人声明了ticket_query查工单工单助手也声明了ticket_query但它主要是查状态、不是回复客户。关键词路由只看字面导致一部分客服请求被路由到了工单助手结果业务方收到的答案明显不对劲。后来的解法是在 Descriptor 的 capabilities 上增加一个scenario字段说明适用上下文同时提供“强制指定”入口调用方如果明确指定了target_agent则不做路由打分直接透传。这实际上是在灵活匹配和确定性之间做了一个平衡。我现在的建议是默认路由解决 80% 的模糊请求剩下的 20% 关键路径必须走显式指定不要让系统替你猜。6. 一点心得与后续扩展方向Agent-Reach 做到现在这个版本我自己觉得最有价值的设计不是某个算法而是“统一信封 能力声明 快速失败”这三板斧。它们本身都不复杂组合起来却解决了团队里最痛的碎片化问题。很多人在做 Agent 平台时容易一上来就堆功能编排引擎、状态机、知识库、多模态……我建议先小再大先把“同一个入口调通所有 Agent”这件事做透再去想上层花活。扩展方向我目前锁定了三个。一是把路由打分里的capability_match升级成一个小模型在线推理服务让语义匹配更准同时对匹配度低于阈值的请求直接抛错而不是硬路由二是做跨集群部署让控制面能管理分布在多个机房的 Agent这需要把心跳和注册信息抽象出来单独做成一个可水平扩展的存储层三是做一个“成本预算配额”控制器让每个部门可以给自己设定月度 Agent 调用预算超预算自动降级为最低成本 Agent。最后分享一个非常小的技巧在控制面的请求入口统一打印慢日志阈值定在调用方的超时时间一半左右比如超时 8000ms 就在 4000ms 处先打一条 WARN。这样你不需要等 8 秒超时才拿到一条失败日志而是 4 秒时就能看到“这个请求可能要出问题”提前定位瓶颈。就是这类细节让我在实际排障时的平均定位时间从小时级降到了分钟级。项目后面还会继续迭代如果这套经验对你有帮助动手搭一套自己的 Agent-Reach 时记得从最小的闭环开始。