说实话,做智能体(Agent)应用最烦的不是模型能力不够,而是“够不着”——工具够不着、数据够不着、别的Agent也够不着。我在最近的工程实践里自己搓了一个小框架,叫 Agent-Reach,专门解决智能体的触达(Reach)问题。它不是一个像 LangChain 那样的通用编排工具,而是一个轻量的、偏底层的触达与调度协议。核心解决三件事:智能体如何被发现,请求如何被可靠送达,结果如何被正确回传。换成大白话说,就是给 Agent 之间造一套带路由、契约校验和重试机制的连接总线。
这套思路源于我在一个企业自动化项目里的真实痛点:六个智能体,分别管客服、库存、订单、物流、发票和售后,单个跑都没问题,拼在一起就天天出幺蛾子。不是模型答错,而是智能体之间互相找不到、参数传错、上下文串台。我后来把 Agent-Reach 抽成独立组件,主要面向做多智能体系统、工具调用和企业自动化集成的开发者。如果你正在为 Agent 之间“找不到、调不通、传不全”这些事头疼,这篇实操复盘应该能帮你省掉不少弯路。
1. Agent-Reach 到底解决什么问题?
1.1 从单智能体到多智能体的痛点
当只有一个 Agent 时,所有工具都是本地函数调用,你只需要把上下文塞进 prompt 就行。一旦有多个 Agent 协作,问题立刻变成三件套:怎么找到能处理任务的 Agent?怎么把请求和上下文交到它手里?怎么把结果拿回来并保持状态一致?传统微服务有服务发现、负载均衡、重试熔断,但智能体有特殊性:请求不是简单的 JSON,而是带意图和上下文的会话;返回不是固定 schema,而可能是半结构化的中间状态。如果直接搬 Spring Cloud 那套,服务名录倒是注册了,调用也能跑,但遇到迭代式推理(Agent 第一步调工具,第二步根据结果继续推理)就卡壳——因为普通 RPC 不保留中间的思维链和执行状态。
我举一个真实场景:客服 Agent 收到用户消息“帮我查一下 SKU-10086 的库存,如果小于 50 就下单补货”,它需要先调用库存 Agent 获取数量,再根据结果调用订单 Agent。拆成两次独立 RPC 的话,客服 Agent 必须自己在本地维护两次调用的关联状态。如果中间有一次超时,重试时客服 Agent 反复问库存,很容易造成订单 Agent 收到重复指令。Agent-Reach 正是针对这种场景设计的,它把“一次用户请求”定义为一条触达会话,而不是离散的消息;消息通过直达通道传递,状态由会话令牌统一管理。这样即使中间某个 Agent 挂掉,上游也可以通过会话令牌安全地重挂上下文,而不是盲目重发。
1.2 核心概念:触达(Reach)不只是网络可达性
很多人一听 Reach 第一反应是网络 ping 通。实际上在这里,Reach 是能力触达:一个 Agent 是否能真正调用另一个 Agent 所暴露能力。不只是“消息到了”,而是“对方用的模型、工具、上下文条件都满足,且能正确返回”。例如 A 让 B 查天气,网络通了,但 B 的 weather 工具需要城市编码,A 只给了城市名,这时技术上消息是可达的,但能力没有触达。Agent-Reach 引入了“能力契约(Capability Contract)”,每个 Agent 在注册时声明输入 schema、输出 schema、依赖的上下文字段、预估耗时。调度器在触达前先做一次契约校验,不匹配就直接告诉不可用,省得进去了再抛错。有点类似于 API 网关的请求校验,但多了一层“上下文要求”:比如 B 要求携带用户身份,若 A 没带,Agent-Reach 可以决定拒绝或自动从共享上下文库补齐。这个设计大大减少了多智能体之间因为参数认知不一致导致的返工。
为什么强调“上下文要求”?因为很多 Agent 调工具失败不是参数格式问题,而是上下文不对。比如发票 Agent 要求调用方携带“开票主体”字段,如果订单 Agent 没传,按老办法是订单 Agent 收到一个 400 错误再去翻文档,而在 Agent-Reach 里,注册中心早就保管了这个字段的元信息,触达前就会拦截并提示“缺少 context: invoice_subject”。这就是“触达”和“网络可达”最大的区别:触达层理解业务语义,而网络层只关心字节能否到达。理解了这个概念,后面所有设计就都顺了。
1.3 适合谁用?以及不该踩的坑
Agent-Reach 不是再造一个 LangChain 或 Semantic Kernel,它定位是更底层的“连接层”。适合三种人:一是多智能体系统遇到了协作稳定性的开发者;二是做企业内部自动化集成,需要统一调度各种 Agent 工具的技术负责人;三是在研究 Agent 通信协议、希望把触达逻辑从业务代码里剥离出来的研究者。不适合的也很明确:如果你的智能体只有一个,所有工具都是本地函数调用,那根本不需要 Agent-Reach,直接写 Python 脚本更快。如果你需要的是复杂的提示词编排、RAG 流程,那应该用编排框架,而不是触达层。顺便提醒一句,任何触达层都不是安全边界,Agent 自身的鉴权仍然要做,Reach 只是帮你把路由和契约管起来,千万不要把所有信任都丢给注册中心。
2. Agent-Reach 的整体设计与架构拆解
2.1 关键设计:任务分发与工具注册
Agent-Reach 采用了“注册中心 + 本地交换机”的混合模型。每个 Agent 启动后,向 ReachHub(注册中心)注册自己的能力、元数据、健康状态。但消息不会全走中心,而是通过一套基于 DHT 或 HTTP/2 流式传输的边到边通道直连,避免中心化瓶颈。DHT 在大型集群里可以避免单点,不过大多数场景用 HTTP/2 长连接就足够了。我个人在小规模实现里选择的是 HTTP/2 流式通道,因为部署简单,Nginx 就能做隧道,DHT 反而让人头大。注册中心只维护轻量的能力索引,不保存会话内容,所以即使 ReachHub 挂掉,已建立的 Agent 直连会话也不受影响。这个设计灵感来自服务网格的 control plane 与 data plane 分离,但实现更轻:一个内嵌 SQLite 加 HTTP 接口就能扛住百级智能体注册。
任务分发过程里,意图匹配器是核心。用户发出自然语言请求后,它会基于简单意图抽取(也可以接入 LLM),生成一个候选能力列表,然后由策略层选择最优目标。策略层支持多种选择算法:默认是最少调用次数、最短预估耗时、最高健康评分,也可以插件化自定义。我建议小团队不要一开始就上复杂算法,用轮询加最小连接数就够了。更多时候是业务侧要求固定路由,比如“订单类请求必须走订单 Agent”,这时可以给能力卡加一个 tag,策略层按 tag 过滤。实现这些,在 Agent-Reach 里都不需要改协议,只是给注册中心加几个字段的事。
2.2 为什么不用现成的消息队列 / RPC 框架?
我们早期试过 RabbitMQ 和 gRPC。RabbitMQ 的问题是消息模型偏异步事件,而智能体任务往往是请求-响应式,需要等结果,于是就得自己维护 correlation ID、超时表、结果缓存,非常繁琐。gRPC 则擅长强类型接口,但智能体之间的交互经常是开放式的:对方的输入输出 schema 可能实时变化(模型返回格式不稳定),gRPC 的强约束反而成负担。Agent-Reach 最终选择的是 JSON-RPC 2.0 风格 + 可选的二进制 payload(如向量数据),既保留了动态 schema 的灵活性,又支持大块数据的传输。同时,在传输层上封装了“会话令牌”(Session Token),用于在多轮触达中保持同一个推理任务的上下文。
这里有一个很多文章不会提的细节:消息队列通常假设消费者是匿名的,多个消费者竞争同一队列;但 Agent 触达要求每个请求都要明确路由到特定智能体的特定能力,而且可能有多个同名 Agent 做负载均衡,但你希望同一个会话的所有子请求都粘到同一个实例上,以保证状态一致。这种“会话粘性”在 RabbitMQ 里要做到必须自定义 header,在 gRPC 里也要自己做路由。Agent-Reach 直接把会话粘性内建到触达协议里:每次触达都会携带 reach_id 和 instance_id,注册中心只要看到 reach_id,就从会话表中取出之前选择的实例,后续直接路由过去。这比在业务层用 Redis 记录“上次选谁”要清爽得多。
2.3 一次触达请求的完整流转过程
用文字描述一下完整流程,方便你对框架有个整体认识。假设客户端要调用 assistant-agent 的 handle_query 能力。首先,客户端向 ReachHub 发送触达请求,ReachHub 校验鉴权 token,解析出请求中的 target、capability、params 和 context。第二步,ReachHub 从能力索引库中加载 assistant-agent 的注册信息,找到目标 Agent 实例地址,同时做输入 schema 和上下文缺失字段校验。校验通过后,ReachHub 会创建一个 Reach ID 并记录会话状态,然后把请求转发给 assistant-agent 的本地交换机。assistant-agent 的本地交换机收到请求后,能力函数执行 handle_query,在函数里通过 ctx.agent_reach.remote_call 再次发起“子触达”到 stock-agent。这个子触达同样会经过 ReachHub 的校验和路由,但消息体可能不经过 Hub,而是通过先前建立的 HTTP/2 直连通道发送给 stock-agent。stock-agent 查询数据库返回结果,assistant-agent 把结果包装后返回给 ReachHub,ReachHub 更新状态并回传客户端。
这个流程看起来比普通 HTTP 多了一步,实际上开销非常低。我测过,一次内网触达在正常情况下的协议开销大约在 2 毫秒左右,主要耗时都在业务处理里。为什么不让所有消息都经过 Hub?因为一旦所有流量都走注册中心,就会像所有 HTTP 请求都打在一个 Nginx 上一样,成为瓶颈。触达协议里控制消息走 Hub,数据消息走直连通道,这也是 Agent-Reach 能支撑较高并发的关键。
3. 核心实现细节与实操要点
3.1 智能体的身份注册与发现机制
每个 Agent 需要实现一个短小的 SDK 接入。核心方法就是 register()、reach()、on_response()。注册时不只是报个 URL,而是要提交一张“能力卡”。我建议能力卡用 JSON Schema 描述,例如这样:
{ "name": "weather-agent", "version": "1.2.0", "capabilities": [{ "id": "get_weather", "input_schema": { "type": "object", "required": ["city", "date"], "properties": { "city": {"type": "string"}, "date": {"type": "string", "format": "date"} } }, "output_schema": {"type": "object"}, "context_required": ["user_id", "geo"] }], "health": "online" }这个能力卡里,name 是 Agent 唯一标识,capabilities 是能力数组。每个能力有 id、输入输出 schema、context_required 和 idempotent 标记。调度器会定期拉取注册表,并在本地缓存一份。触达时先根据能力卡做契约校验。这个机制看起来简单,但很管用。它有几个需要注意的地方:一是注册信息的版本管理,能力卡变动要发 version bump,否则旧消费者会按老契约调用。二是健康检查不能只监听端口,要做一次真实的“最小自检”,比如检查一下关键工具是否可用、模型 API key 是否过期。很多系统挂掉不是因为服务没起来,而是服务起来了但依赖的第三方 API 已经失效。Agent-Reach 支持注册时可携带 readiness 回调,由调度器按配置间隔调用,一旦失败就自动把该 Agent 标记为不可达,并触发降级策略。
一个很容易踩的坑:注册的 target 名称和本地交换机监听的地址不一致。有的团队会把能力卡里的 name 写成业务域(比如order-agent),但实际部署的服务名是order-agent-v2,导致触达请求到达 Hub 之后无法匹配。Agent-Reach 要求 name 必须是全局唯一的、稳定的逻辑标识,物理地址放在 endpoint 字段里单独维护。这样服务迁移后只需要改 endpoint,不需要改业务代码。
3.2 上下文传递与状态隔离的坑
多智能体协作最大的坑是上下文串线。想象一下,Agent A 同时发起了两个触达任务给 Agent B,一个问天气,一个查机票,如果不做隔离,B 容易把两个会话的上下文混在一起。Agent-Reach 的做法是:每次触达都生成一个全局唯一的 Reach ID,并在该 Reach ID 的命名空间下保存上下文。B 的所有处理逻辑都要基于这个 ID 读写上下文,不直接从全局变量取。另外,上下文会包含“用户会话上下文”(比如操作者身份)和“任务上下文”(比如中间推理结果)这两类,二者必须分开存储。否则低权限的任务可能意外读到高权限的用户数据,这在企业环境里是非常严重的合规隐患。
我们在实现上用了两层 map:ReachContextHolder 持有用户级上下文,TaskContextHolder 持任务级上下文。触达请求里只传有权限的用户上下文子集,其余由 Agent 向共享上下文服务按需请求。这样即使用户管家 Agent 和数据分析 Agent 在同一套系统里,也不会出现数据越权。这里还有一个容易被忽略的细节:上下文传递不是无限大的。早期我在每个 Reach ID 里塞了完整的聊天历史,结果一个长会话消耗几百 KB 内存,Agent 之间的 HTTP/2 帧也被撑得很大。后来改成“最近 N 轮 + 压缩摘要 + 关键实体”的传递策略,开销骤降。Agent-Reach 在协议上支持 context 的分层命名空间,比如context.user、context.task、context.summary,你可以自由决定哪些字段共享、哪些字段隔离。
3.3 超时与重试策略:实测参数怎么调
智能体处理时间普遍比传统接口长很多,因为里面有大模型推理、多轮工具调用。超时设置不能照搬 HTTP 服务那套 500ms 或者 1s。我实测的合理范围是:简单工具调用给 10s,涉及大模型推理给 60s,复杂的多跳任务给 120s。为什么差距这么大?因为大模型首 token 返回往往在 1 到 5 秒,但如果模型排队严重,10 秒都未必开始。如果你按传统微服务的 3 秒超时来调,智能体任务几乎必然失败。重试策略更讲究:一次触达失败时,如果是超时,重试只能对“幂等能力”执行,否则重复扣款或重复下单都是灾难。Agent-Reach 在能力卡里有一个"idempotent": true/false字段。对于非幂等操作,不自动重试,而是抛给上层决策;对于幂等操作,可以用指数退避重试,初始 2s,倍增,封顶 30s,最大重试 3 次。同时,每次重试都要更新 Reach ID 的 attempt 字段,以便接收方识别重复请求。
我分享一个踩过的坑:调用第三方支付接口时,网络抖动导致超时,但第三方其实已经扣款成功了。如果用普通重试,用户会被扣两次款。我们在 Agent-Reach 里给支付能力标记为非幂等,同时让超时后的自动处理流程改为“查询订单状态”而不是直接重试调扣款。这个逻辑看起来简单,但如果你没有标准化的幂等标记,每次都在业务代码里临时写,迟早会漏。还有一个经验:不要把超时和重试写在能力函数内部,而要写在触达配置里。这样运营同学可以通过配置中心动态调整,不用重新发布 Agent。
3.4 安全与鉴权:防止 Agent 被乱调
多智能体系统的安全问题比单体应用更棘手,因为 Agent 之间互相信任的边界很模糊。Agent-Reach 提供了一套轻量级的安全模型:第一层是 Hub 级别的认证,每个 Agent 启动注册时使用 API key 或 JWT 进行身份认证,获取一个带 scope 的访问令牌;第二层是触达请求的签名,调用方对 target、capability、params 的 hash 用私钥签名,接收方验证签名后再处理;第三层是上下文权限,能力卡里声明的 context_required 字段就是权限清单,比如库存 Agent 声明需要tenant_id而调用方的 scope 没有该字段,Hub 会直接拒绝。这套模型和 OAuth 2.0 的 client credentials 模式类似,但没有那么复杂,核心就是一个 access token 加上一组 scope 映射。
实际操作中,我建议把密钥管理交给专门的密钥服务(如 Vault),不要放在 Agent 的环境变量里。Agent-Reach 的能力卡支持secret_ref字段,引用的是密钥服务中的路径,而不是明文。这样即使注册表被读取,黑客也拿不到底层 API key。另外,Agent 之间的通信建议开启 TLS,ReachHub 和 Agent 之间的通道至少要用自签名证书,别裸奔。很多团队觉得内网没问题,但一旦某个 Agent 被注入恶意提示词,它本地的 secret 就会被泄露,网络隔离并不能防止这种“内部钓鱼”,鉴权才是一道真的防线。
4. 实操过程:从零搭建一个 Agent-Reach 演示环境
4.1 环境准备与最小配置
不需要大集群。只需要一台 2C4G 的 Linux 服务器(或者本机 Mac),装上 Python 3.10+,Docker 可选。Agent-Reach 提供了 Python SDK,你可以理解为一个小型库。安装命令:
pip install agent-reach然后初始化一个 ReachHub:
agent-reach-hub --listen 0.0.0.0:7800 --db ./reach.dbReachHub 其实就是一个轻量进程,维护能力注册表、Session Token 状态,并转发控制消息。它默认只暴露 HTTP/JSON API,生产环境建议放到内网,通过反向代理加 mTLS。配置文件可以用 YAML:
hub: port: 7800 db_path: ./reach.db health_check_interval: 15s agent: default_timeout: 60s retry_policy: max_attempts: 3 backoff: exponential initial_delay: 2s max_delay: 30s配置里最重要的是health_check_interval,我建议设成 15 到 30 秒,太频繁会白白消耗 Agent 资源,太慢会导致故障发现滞后。default_timeout是全局兜底超时,设置为 60 秒比较合理,而具体能力可以在能力卡里覆盖这个值。retry_policy中我建议max_attempts不超过 3 次,次数多了对下游系统影响很大,尤其是非幂等操作。另外,db_path一定要用持久化磁盘路径,别用 SQLite 的:memory:,否则 ReachHub 重启后注册信息全丢,所有 Agent 都要重新上线,这个坑我踩过一次。
4.2 定义两个智能体(工具调用 + 信息查询)
我们做一个实用的 demo:一个“产品助手 Agent”,它负责理解用户的查询意图,并调用“库存 Agent”获取库存信息。先定义库存 Agent:
from agent_reach import Agent, capability stock_agent = Agent(name="stock-agent", hub_url="http://localhost:7800") @capability( stock_agent, id="query_stock", input_schema={"type":"object","required":["sku"],"properties":{"sku":{"type":"string"}}}, output_schema={"type":"object"}, context_required=["tenant_id"], idempotent=True ) def query_stock(ctx, params): # 模拟库存查询 sku = params["sku"] # 这里可以接数据库 stock = {"sku": sku, "available": 42} return stock stock_agent.register()这里有三点要说明。第一,@capability装饰器里的字段,必须和 Hub 配置文件中的全局默认值协调好,比如ctx对象是由 SDK 注入的,你不需要自己创建。第二,query_stock函数里ctx参数带有一个agent_reach对象和session_token,这让你可以在任意能力函数里发起新的触达。第三,context_required里写了tenant_id,这意味着库存 Agent 会要求调用方必须携带租户信息,否则会在触达前被拦截。这个 demo 里的同名函数如果被多个 Agent 使用,建议用capability_id区分,避免路由混淆。
产品助手 Agent 则需要接收触达请求,并在内部再触达库存 Agent:
from agent_reach import Agent, capability assistant_agent = Agent(name="assistant-agent", hub_url="http://localhost:7800") @capability( assistant_agent, id="handle_query", input_schema={"type":"object","required":["query"],"properties":{"query":{"type":"string"}}}, output_schema={"type":"object"}, context_required=["user_id"] ) def handle_query(ctx, params): # 解析意图,这里可以换成 LLM if "库存" in params["query"]: sku = params["query"].split(":")[-1] result = ctx.agent_reach.remote_call( target="stock-agent", capability="query_stock", params={"sku": sku}, session_token=ctx.session_token ) return {"status": "ok", "data": result} return {"status": "unknown"} assistant_agent.register()注意这里的解析逻辑写得很粗糙,实际项目中应当用 LLM 做意图识别,但为了演示触达链路,就直接用字符串匹配了。ctx.agent_reach.remote_call(...)是 Agent-Reach 最重要的 API,它会经过目标 Agent 的能力卡校验、路由、发送、等待回执。代码里我传了session_token=ctx.session_token,这是为了让子触达和父触达在同一会话下执行,共享状态。这样即使库存 Agent 在处理过程中发生了重试,也不会重复创建新的会话上下文。
4.3 跑通一次完整的“触达-响应-返回”流程
建立好两个 Agent 后,从客户端发起一次触达。客户端不需要注册,只需要拿到一个 session token(可以通过登录鉴权获得)。用 curl 模拟:
curl -X POST http://localhost:7800/reach \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{ "target": "assistant-agent", "capability": "handle_query", "params": {"query": "查询库存:SKU-10086"}, "context": {"user_id": "u_123"} }'ReachHub 返回一个 reach_id,例如 "rch_1a2b3c"。这时异步流程已经启动。你可以在客户端轮询:
curl http://localhost:7800/reach/rch_1a2b3c最终会得到类似:
{ "reach_id": "rch_1a2b3c", "status": "fulfilled", "result": { "status": "ok", "data": {"sku": "SKU-10086", "available": 42} }, "took_ms": 1280 }这个过程中,ReachHub 只负责路由和状态记录,实际的消息流转是从 assistant-agent 直接到 stock-agent 的,所以并发高起来之后 Hub 不会成为瓶颈。我们在 50 个并发会话、每个会话 3 次触达的压力测试中,Hub 的 CPU 占用稳定在 5% 以下。需要提醒的是,上面这个返回结果是同步等待的简化版。真实场景中,如果任务耗时超过 2 秒,客户端应该使用异步模式:先 POST 拿到 reach_id,再用轮询或者 Webhook 接收回执。Agent-Reach 的响应对象里有status字段,可能的值是pending、fulfilled、rejected。遇到rejected时,error字段里会有机器可读的错误码,方便你写自动化处理逻辑。
4.4 多轮会话与状态保持:从 stateless 到 session
很多智能体应用不是单次一问一答,而是多轮来回。比如用户先问“有货吗”,再问“那给我下单”,这两个请求必须共享上下文。Agent-Reach 的 Session Token 就是为了这个设计的。当客户端先发一个 POST 时,如果在请求体里不传 session_token,ReachHub 会生成一个新的 Token;如果传了旧的 Token,后续所有触达都会附着在同一个会话下。会话状态可以保存在 Hub 里,也可以由 Agent 自取。我建议默认把状态存在 Agent 本地交换机,而不是 Hub,因为 Hub 进程不应该感知业务对象。Agent 本地交换机有一个session_store,默认是内存实现,重启即丢;生产环境可以换成 Redis 或数据库。每个能力函数可以从 ctx.session_state 读取上一个步骤的结果,从而实现“查询库存 → 生成订单”这种多跳流程。
这里有个实际例子:客服 Agent 第一次触达库存 Agent 得到“库存 42 件”,第二次触达订单 Agent 时需要把“42 件”作为依据传给订单 Agent。如果两次触达都在同一个 session 下,订单 Agent 可以读取到库存 Agent 写入 session 的状态字段,而不需要客服 Agent 再传递一次。这看起来像是有状态服务,好处是大幅减少了上下文重复传输,坏处是 session 生命周期管理变复杂。Agent-Reach 默认给 Session Token 设了一个 30 分钟的过期时间,并支持空闲超时、最大步数限制。超过步数或时间,会话会被自动回收,防止内存泄漏。
5. 常见问题与排查技巧实录
5.1 智能体之间消息丢失
我遇到过 Agent A 发送触达后,B 没反应。排查思路:第一步看 ReachHub 上是否生成了 reach_id。如果没有,说明是路由失败,多半是 A 没有正确注册,或者 target 名称不匹配。第二步看 B 的日志是否收到请求。如果收到但挂起,可能是 B 内部在等待下游工具,但下游工具超长。我们可以通过 Agent-Reach 的 trace 命令查看:agent-reach-cli trace rch_1a2b3c,它会输出每个阶段的时间戳:A 请求到 Hub、Hub 路由到 B、B 开始处理、B 返回、A 收到。定位到具体环节。还有一个技巧:在 B 的本地交换机里加一个 debug hook,把原始 JSON 请求打印出来,看看是不是契约校验被拦了。
我记得有一次查到问题出在 B 的能力卡 network 地址写成了localhost,导致 Hub 把请求转发到了 B 所在容器自身,而不是宿主机上监听的端口。像这种容器的网络模式问题很常见,排查时一定要看 trace 里实际连接的 endpoint 是什么。后来我把所有注册 endpoint 都显式配置为对外可达的 IP 或域名,问题彻底消失。
5.2 任务超时但日志里没有错误
这种情况很多。表现为客户端等结果到超时,但服务端没有任何异常日志。原因往往是 B 内部阻塞了,比如 B 的 Python 主线程在处理其他同步请求,导致新触达排队。Agent-Reach 的 HTTP 通道默认是异步的,但如果你在能力函数里写了同步阻塞代码(比如 requests.get),就会卡住事件循环。解决办法是在能力函数里改用异步客户端,或者把阻塞调用放到线程池。我在项目里强制要求:所有网络 IO 必须显式 await,否则视为 bug。另一个原因是 B 的模型调用没有设置 stream timeout,用 SDK 默认的话会无限等。给模型调用加上 stream_read_timeout 参数就能立即恢复。
还有一种低调的坑:ReachHub 的default_timeout设了 120 秒,但 B 的负载均衡器(如 Nginx)的 proxy_read_timeout 只有 60 秒,数据先被 Nginx 切断,而 Agent 还在继续处理,直到完成时发现连接已断开,随后把错误抛给上层。这种情况下日志里没有 B 的错误,只有 Nginx 的超时记录。排查技巧是看客户端报错时间,再看 Nginx access log 的 upstream_status,如果出现 499,十有八九是它。解决方式很简单:把反向代理的 timeouts 调到大于触达配置的最大超时即可。
5.3 工具调用权限校验失败的场景
有时触达请求到达了,但被 Agent 拒绝。我的经验是权限校验字段经常缺失。例如库存 Agent 的 context_required 里声明了 tenant_id,但调用方只传了 user_id,Agent-Reach 会按配置决定拒绝或补齐。如果设置的是拒绝,日志里会有REACH_ERR_AUTH_CONTEXT_MISSING。解决方式:在客户端构造请求时统一由 ContextBuilder 注入标准字段,不要手动传 context。另外,跨租户的触达请求应该由 Hub 层根据 token 的 scope 做一次预检,避免 Agent 自己逐个校验,这样集中管理更不容易漏。
这里特别提醒:不要把所有的鉴权都在 Agent 内部做,否则一旦新 Agent 上线忘了加校验,就会出现越权触达。Agent-Reach 的 Hub 层会强制检查 scope 和 context_required,相当于一道分布式主防线。如果你用的是多租户系统,建议给每个租户分配独立的 reach namespace,比如tenant_a.stock-agent和tenant_b.stock-agent,这样即便 agent 名称相同也不会互相干扰。
5.4 真避坑清单
整理几条我踩过的坑,供参考:
- 注册信息即使带密,也不要直接打在配置里。Agent-Reach 支持在能力卡里放
secret_ref,通过密钥服务动态获取。我早期把 API key 放在 JSON 里,差点泄露。 - 不要把 ReachHub 的数据库放在内存里,重启会丢失注册信息,导致所有 Agent 需要重新注册。用 SQLite 持久化,再定期备份。
- 能力函数如果是有状态的,比如需要在内存里维护缓存,一定要保证并发安全。我用过普通 dict 加锁,后来发现多进程部署下字典不共享,改成 Redis 缓存才稳定。
- 对回执结果做 schema 校验:即使输出 schema 声明了,也要在运行时用快速校验器(如 jsonschema)验证,否则劣质数据会一路冒泡到上层用户界面,很难排查。
- 不要给
remote_call设置过大的同步阻塞等待时间,否则一个耗时的 Agent 会把整个事件循环占住。高级用法是把触达操作丢给后台任务,再用 Webhook 收结果。
这些坑,基本上都是分布式系统常见问题在智能体场景下的变体。Agent-Reach 虽然不是银弹,但把这些问题收敛成了有框架支撑的边界,比在业务代码里到处打补丁强得多。
6. 工程化落地建议
6.1 部署拓扑与可用性考虑
Agent-Reach 的最小部署是两个进程:ReachHub + Agent。对于一个有几十个 Agent 的系统,建议把 ReachHub 做成双副本。副本身边没有独立状态,它只是能力索引和会话状态的一个一致性副本。如果你不想引入太复杂的共识算法,使用 etcd 做分布式锁或者直接用 PostgreSQL 存会话状态都可行。ReachHub 本身是无状态的,主要是状态存储。因此在 PostgreSQL 里存会话表,ReachHub 的水平扩展就基本无痛了。每个 Agent 实例可以注册多个 endpoint,ReachHub 会维护一个instance_id到 endpoint 的映射。当一个实例失联时,ReachHub 会将其标记为不可达,后续触达只路由到健康实例。这里建议不要只依赖 TCP 探测,要启动一个专门的健康检查,就是前面说的 readiness 回调。
多智能体系统的可用性还有一层是能力降级。比如主库存 Agent 挂了,可以降级到只读缓存 Agent。这种降级策略可以写在注册中心的元数据里,触达失败时由策略层自动切换。我在项目里配置过一个三档降级链:主库存 Agent → 缓存 Agent → 静态 JSON 文件。虽然缓存可能不是最新数据,但至少不会让客服流程中断。这个思路体系化之后,整个系统的韧性比单个 Agent 的稳定性高得多。
6.2 从单体到多智能体的演进路线
如果你现在还是一个单体智能体应用,不要急着拆。先找出真正的边界在哪里。比如你有一个客服机器人,里面既做意图识别,又查库存,又生成订单,那么至少有三个稳定子域:意图识别、库存查询、订单操作。Agent-Reach 的切入点就是按这些子域拆成独立 Agent。拆的时候不需要一次到位,可以先让所有能力跑在同一个 Agent 进程里,然后用 Agent-Reach 的“虚拟能力”把它们在协议层拆开。这样业务代码几乎不变,但已经拿到了触达层的路由和契约能力。接下来再逐个把能力抽成独立服务。这个过程我在团队里叫“协议先行,部署后置”:先通过注册表建立契约,再动物理拓扑。这么做的好处是,比如客服 Agent 调用库存 Agent 的接口,在架构上从一开始就是触达语义,而不是函数导入,后续迁移只需要改 endpoint,不用改业务逻辑。
我个人体会最深的是:Agent 系统最难的不是写 Agent,而是给 Agent 之间划边界。Agent-Reach 把边界下沉为可配置的契约后,业务团队可以用文档化方式协作,模型团队也可以回归测试每个 Agent 的触达能力。如果你正在做类似项目,建议从一次最小的触达开始,先跑通两个 Agent 之间的调用,再慢慢加策略。别在一开始就铺复杂架构,等踩到性能瓶颈再演进也不迟。