>{ "service_id": "doc-summarizer", "capabilities": [ { "name": "file_summary", "description": "输入本地文件路径或文件内容,输出摘要和关键要点", "input": { "type": "object", "properties": { "file_path": {"type": "string"}, "content_type": {"type": "string", "enum": ["report", "email", "code"]} }, "required": ["content_type"] }, "output": { "type": "object", "properties": { "summary": {"type": "string"}, "keywords": {"type": "array", "items": {"type": "string"}} } }, "cost_level": "medium", "rate_limit": 10 } ] }能力表里最容易被忽视的是 cost_level 和 rate_limit。这两个字段在路由时非常有用:如果一个任务对时延不敏感、但是量很大,我们可以优先路由给 cost_level 低的节点;如果一个任务必须尽快返回,那我们就要避开那些已经接近 rate_limit 的节点。这一步相当于给路由加了一个"成本感知"的维度,让触达不只会转发,还会选路。
2.3 路由规则:人工与自动的边界 能力表提供了匹配的可能,但真正决定一个具体任务发给谁的是路由规则。我们把路由规则做成了一组声明式配置,支持三种基本策略:精确匹配、模糊匹配和加权的动态分配。规则文件长这样:
routes: - id: data_query_route match: capability: data_query tags: ["prod"] strategy: type: weighted_random weights: >services: etcd: image: quay.io/coreos/etcd:v3.5.14 command: etcd -advertise-client-urls http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 ports: - "2379:2379" agent-reach: image: agent-reach:latest environment: REGISTRY_ENDPOINTS: "http://etcd:2379" LISTEN_ADDR: ":8080" ROUTE_FILE: "/config/routes.yaml" ports: - "8080:8080" depends_on: - etcd dashboard: image: agent-reach-dashboard:latest environment: REACH_API: "http://agent-reach:8080" ports: - "9090:9090"这套东西我建议不要在单体架构里,把存储、调度和面板拆成三个独立进程。因为智能体任务往往需要长时间运行,如果调度进程重启,至少注册表和面板还能继续观察状态,不至于整个链路全黑。
4.2 智能体接入步骤:注册、处理、回执 智能体接入的过程我总结为三步:初始化 SDK、注册功能、实现处理函数并启动。以 Python 为例,接入代码的骨架如下:
from agent_reach import AgentClient, Capability client = AgentClient( service_id="doc-summarizer", reach_addr="http://agent-reach:8080", heartbeat_interval=15, ) client.register_capability( Capability( name="file_summary", input_schema={"type": "object", "properties": {...}}, output_schema={"type": "object", "properties": {...}}, cost_level="medium", ) ) @client.handle("file_summary") async def handle_file_summary(payload, ctx): # 这里写具体的智能体逻辑 result = await do_summary(payload["path"]) return {"summary": result.summary, "keywords": result.keywords} client.start()这里有几个细节你第一次写时很容易错。第一,注册能力时填的 input_schema 必须和实际处理函数对得上,否则触达层可能把一个根本不符合 schema 的任务转发过来,处理函数直接解析失败。第二,handle 函数里一定要接收 ctx,因为 ctx 里带着 call_id、deadline 等信息;你的内部日志、调用外部系统的 trace_id 都需要跟 ctx 里的 call_id 串起来,不然出问题的时候根本没法追踪。第三,心跳间隔不要太长也不要太短,15 秒是一个比较合理的值。间隔太长,节点挂掉不能被及时感知;间隔太短,每个节点的心跳请求会对触达层造成无意义压力。
启动以后,智能体会周期性地把注册信息上报到触达层。如果注册表里已经能看到服务名和节点,下一步就可以测试一次简单的触达调用了。
4.3 一个综合配置示例和调整思路 我再给一个相对完整的路由配置示例,把前面提到的几种策略串起来:
routes: - id: summary_main_route match: capability: file_summary input.content_type: "report" strategy: type: latency_aware services: [doc-summarizer, llm-light] priority: 90 - id: summary_general_route match: capability: file_summary strategy: type: weighted_random weights: doc-summarizer: 40 llm-light: 60 priority: 50 fallback_rules: - condition: "no_available_node" action: "return_503" wait_before_retry_ms: 500路由规则的 priority 是另一个很容易踩坑的点。我们一开始没设置优先级,结果按顺序匹配的时候,一条通用规则把特异性强的任务提前抢走了,导致某些文件走了错误的处理链路。后来我们明确:优先维度越细、条件越多的规则,优先级越高。这跟防火墙规则的设计思路很像,具体规则永远排在兜底规则前面。
配置文件修改后不需要重启调度进程,走动态加载。不过动态加载有个坏处:规则变更太频繁容易让线上行为难以预期。我们内部约定,路由规则的变更必须走一次评审,至少要有一个人确认改变后的影响范围。这是运维纪律的问题,跟技术实现关系不大,但我觉得有必要在这里说清楚。
5. 实测碰到的四个坑与完整排查链路 这部分是我不想藏私的内容。Agent-Reach 上线后,我们在灰度期和稳定运行期踩过不少坑,有些坑非常隐蔽,我想把完整的排查链路写出来,方便大家复现和规避。
5.1 坑一:能力声明过宽引发路由风暴 最初我们给一个"通用文本处理"智能体注册了很多能力,比如文本总结、情感分析、语言翻译、关键词抽取,而且每项能力的 input_schema 都写得比较宽松,比如允许传入任意字符串。这不是为了偷懒,而是我们希望这个智能体什么都能做,尽量多接任务。结果上线后发现触达层几乎把所有文本类任务都路由给了它,而其他一些专业智能体完全闲着。更严重的是,这个通用智能体的处理时延很高,任务一多就开始排队,排队超过超时时间后,部分任务被重试路由,重试又打到同一批节点,形成了恶性循环。
排查思路是这样的:我们先把触达层的路由命中日志拉出来,按 service_id 聚合,发现通用文本处理的命中率占了 80% 以上;接着对比它的能力声明和其他智能体的声明,发现通用文本处理的能力 schema 描述覆盖了其他智能体的所有输入类型。根因很清楚:schema 描述的自由度太高,导致规则匹配时优先级没有拉开差距。修复方法是给每项能力声明增加明确的特征字段和互斥约束,比如"情感分析"必须要求输入里有text和target_lang两个字段,并且text长度限制在 2000 字以内。这样能避免一个任务同时匹配多个能力。
5.2 坑二:重试风暴差点打挂执行单元 第二个坑是我们自己种下的。当时为了追求高可用,我们对所有失败任务执行最多 5 次重试,指数退避设置得也比较激进,结果在某次上游批量推送任务时,因为下游一个节点重启,大量任务在短时间内连续失败。失败后重试,重试又撞上同一个节点重启,造成重试风暴,整个执行单元 CPU 被打到 90% 以上,正常任务也进不来了。
排查时我们做了三件事:第一,检查触达层的重试日志,发现同一条 idempotency_key 在 1 分钟内被复制出了 6 条记录;第二,对比下游节点的健康状态,发现它在这段时间里处于"进程活着但任务处理超时"的半死不活状态;第三,我们确认了问题不在网络层,而是下游的处理线程池被占满了。
修复分两个层面。一是把重试策略从"最大重试次数 5 次"改成"总耗时不超过 30 秒,期间最多重试 2 次",并且每次重试前计算retry_base_delay_ms乘以随机因子,让重试分布更均匀。二是在触达层加了一个简单的熔断器:如果某个 service_id 的错误率在 10 秒窗口内超过 50%,直接熔断 5 秒,不再往该节点转发任何任务。熔断的意义不是解决故障本身,而是防止故障被无限放大,给下游恢复争取时间。
5.3 坑三:长上下文任务的超时时间设置处处别扭 智能体不同于普通 API,很多任务非常耗时。比如让一个智能体阅读一个 50 页 PDF 然后总结,光读取解析可能就要几十秒,如果再涉及多轮推理,一分钟以上很正常。我们一开始设置的执行超时是 20 秒,导致很多长任务被触达层误判为失败,白白消耗了计算资源。
排查过程很典型:回执数据里出现大量 timeout 错误,但查看智能体日志发现任务其实一直在处理中。当时我们先怀疑是心跳回执没生效,检查后发现心跳是正常的,问题就出在硬性超时上。这里我的建议是,把超时按任务复杂度拆档位:轻量任务 10 秒,中等任务 60 秒,重量任务 10 分钟。并且执行节点必须在处理过程中持续发送心跳回执,否则触达层依然会按最终超时判断。另外一个更隐蔽的问题:代码里所有执行节点必须在收到 deadline_millis 之后,用自己的本地时间算一个本地截止时刻;如果发送方和接收方存在时钟偏差,就可能导致任务还没开始就被判定过期。解决办法是触达层在信封上附带一个"转发时戳",执行节点以这个时戳为准做倒计时,而不是完全依赖本地时钟。
5.4 坑四:健康检查不完善,活着的节点被摘掉 最后一个坑是关于健康检查的。智能体节点的进程可能活得很好,但它依赖的模型推理服务已经卡住了,这时候如果触达层只检查进程端口,它依然会把任务发过来,导致大量失败。更麻烦的是反向情况:某些节点的健康检查太激进,检查一次失败就把节点摘掉,结果节点恢复之后好几分钟没有被重新加入路由,出现大段真空期。
我们最终的方案是把健康检查分成两层:Liveness Check 和 Ready Check。Liveness 检查只确认进程还在、端口还在监听;Ready Check 则用最小的探针任务去验证节点能否真正处理一个零成本任务,比如"返回当前时间"。只有 Ready Check 通过的节点才会被放进路由池。探针频率不能太高,我建议 10 秒一次,同时探针不能和正常业务任务抢资源,要给探针打上最低优先级。
6. 这套设计的实际适用边界与个人体验 最后说点偏向个人判断的内容。Agent-Reach 不是银弹,它解决的是"智能体数量多了之后怎么协作"的问题,但并不是所有协作场景都需要这么重的一层中间件。我的经验是,如果智能体数量在五六个以下,而且彼此调用关系固定,那还不如用最朴素的直连,加一个公共配置中心管理地址,比引入一套触达层简单得多。只有当调用关系存在明显的动态性,比如一个任务的执行链路可能需要临时选择能力提供方、智能体节点经常上下线、或者你需要精细的灰度路由和全链路审计时,Agent-Reach 这类设计才真正值得投入。
我在实际部署中最大的体会是,"触达"这件事的价值不在于转发本身,而在于它把智能体之间的协作从"点对点契约"转变成了"注册与声明"。你不需要在代码里关心对方是谁、部署在哪、能力如何,只需要声明自己能做什么、需要什么,剩下的事情交给触达层。这个思维转变带来的维护成本下降,比任何具体技术都要有价值。
如果让我给后来者一个最核心的建议,那就是别急着把路由规则做得很复杂。先把名称注册表和回执机制这两件事做扎实,让所有智能体能被找到、调用过程能被看到,就足够解决大多数协作问题了。等到真的出现路由选择不合理、并发控制不精准这些高级问题时,再逐步把能力表和路由规则的细节补上,你会比我当初少走很多弯路。
RELATED READING 延伸阅读 更多一线实战笔记与深度复盘,助您持续精进