Agent-Reach:为多Agent协作打造可靠触达层,解决路由与上下文丢失

发布时间:2026/10/26 7:43:03
Agent-Reach:为多Agent协作打造可靠触达层,解决路由与上下文丢失

做多Agent系统做得越久,越觉得“让每个Agent都能被找到、被调用、被信任”这件事,比单个Agent本身的模型选型还要重要。我最近把这块单独抽出来,做了一个叫Agent-Reach的轻量级触达组件,专门解决多智能体协作里“任务派不出去、上下文传着传着就断、Agent突然失联”这些让人头大的问题。如果你也在搭多Agent应用,恰好被路由不准、上下文丢失、节点超时这些事折磨过,这篇东西应该能帮你省下不少弯路。

Agent-Reach的定位不是一个“什么都能干”的Agent框架,它只做一件事:在多Agent之间建立一个可靠、可观测、可兜底的触达通道。下面我把它的设计思路、核心机制、从零搭建过程,以及上线之后踩过的坑一次性讲清楚。

1. Agent-Reach是什么:多智能体协作的“触达层”

1.1 我为什么从0写一个Agent触达组件

先说背景。我手上有几个复杂的业务场景,需要多个Agent协同完成:一个Agent负责理解用户的模糊意图,一个负责查业务数据,一个负责生成回复,还有一个专门做质检和纠偏。最开始我用的是现成编排框架,Agent之间通过内部消息直接互相调用,Demo阶段跑得挺欢,一上真实业务就全露馅了。

问题集中在三处:第一,Agent一多,任务不知道到底该交给谁,经常出现两个Agent都能处理同一个任务,但选错了导致结果南辕北辙;第二,上下文在一个Agent处理完传给下一个时,经常莫名其妙少了关键信息,用户上一句刚说的需求,传到第三个Agent那里已经没影了;第三,某个下游Agent在升级或崩溃时,上游还在傻乎乎地重试,直接把整个链路拖垮。

我最初只是想给项目加个“统一调度入口”,结果改着改着发现,真正缺的其实是一个位于所有Agent之下的触达层。它要负责三件事:发现“谁在”、判断“谁能干”、保证“消息完整送到”。这就是Agent-Reach的起点,一个专门做触达的轻量层,不绑定任何具体Agent框架,也不强迫你改写已有的Agent逻辑。

1.2 Reach这个名字想表达的三件事

名字里的Reach,在英文里同时有“触达”和“可达范围”两层意思。我取这个名字,是想让它承载三个设计目标:触达、可回退、可跟踪。

第一,触达指“消息一定能找到对的Agent”。这里不只靠简单地广播“谁能处理就谁上”,而是让每个Agent维护一份能力清单,路由层根据任务语义做匹配。第二,可回退指“找不到或Agent挂了,链路不能死”。Reach层内置了降级策略,比如降级到默认Agent、排队等待或者直接返回可读错误,保证用户不会对着一个长时间不响应的界面干等。第三,可跟踪指“每个触达动作都能被还原”。每个任务携带一个触达信封,里面记录了途经了哪些Agent、每次调用的耗时、状态码,方便事后排查“这一单到底是在哪一环丢的”。

相比大而全的Agent框架,Reach刻意保持克制。它不干预Agent内部怎么思考、用哪个模型、写多少轮工具调用,只关注Agent之间互通的这条“路”。路修好了,车怎么造是各Agent自己的事。

1.3 不和LangChain/CrewAI抢饭碗,我只做编排链路最薄弱的一环

市面上已经有了LangChain、CrewAI这类成熟的编排框架,很多人会问:既然它们能搞定Agent之间的协作,为什么还要自己造轮子?

我用下来的感受是:这些框架解决的是“Agent内部怎么做决策”和“一个流程按什么顺序跑”,但对“跨Agent的消息触达可靠性”这一层,处理得相对粗。它们默认所有Agent都在同一个进程里,通过内存对象互相传参。可真实业务往往是分布式部署:订单Agent在A服务,用户画像Agent在B服务,你不可能让它们共享一个内存队列。

Agent-Reach只做编排链路最薄弱的那一环——跨进程、跨语言甚至跨地域的多Agent触达。它提供注册、路由、上下文透传、重试熔断这一套基础能力,上面可以接LangChain写的Agent,也可以接你手写的纯Python函数,甚至可以通过网关接Node.js写的Agent节点。框架之间不是替代关系,而是上下层关系。

2. 核心机制拆解:路由、上下文与可达性

2.1 智能体注册与能力清单

Agent-Reach的一等公民是“能力清单”(Skill Manifest)。每个Agent在启动时,需要向路由中心注册自己,说明我是什么、我能干什么、我的输入输出长什么样。这一步是后续一切路由判断的基础。

一份典型的能力清单大概长这样:

{ "agent_id": "order_handler_v2", "name": "订单处理Agent", "version": "2.1.0", "skills": [ { "name": "order_query", "description": "查询订单状态、物流信息、支付信息,支持按订单号或用户ID查询", "input_schema": { "order_id": "string", "user_id": "string" }, "output_schema": { "order_status": "string", "logistics": "string" } }, { "name": "refund_apply", "description": "根据订单号和退款原因发起退款申请,需要用户确认后才能执行", "input_schema": { "order_id": "string", "reason": "string" }, "output_schema": { "refund_id": "string", "status": "string" } } ], "timeout_ms": 5000, "fallback_agent_id": "customer_service_default" }

注册机制本身很简单,Agent在启动时调用register_agent接口把这份清单推送到路由中心。但这里的细节在于:清单里不只写“能力名称”,还要写清楚“输入输出约束”和“超时时间”。我一开始没写输入Schema,结果路由层只能靠描述文本去猜任务匹配度,判断极不稳定。补充了Schema之后,除了语义匹配,还能做一次规则校验,比如任务里要求退款,但目标Agent根本不支持退款,直接跳过,不用浪费一次embedding计算。

2.2 语义路由:让任务自己找Agent

路由是Agent-Reach里最核心也最容易做砸的部分。我的第一版是纯规则匹配,靠关键词命中Agent描述,准确率惨不忍睹。用户说“我买的东西怎么还没到?”,订单Agent有“物流”关键词能命中,但“帮我看看有没有退款入口”这种话就完全废了。

后来换成了“embedding语义匹配 + 规则预筛”的双层结构,效果才稳定下来。

具体流程是这样的:任务进来后,先把自然语言做一次向量化,得到任务向量;同时,每个Agent的每项Skill也提前算好了向量,存在内存索引里。路由层计算任务向量和所有Skill向量的余弦相似度,取Top-3候选。然后进入规则层:对这3个候选做输入Schema校验,凡是任务信息缺失、或者功能范围明显不符的直接排除。最后剩下的那个就是最终路由目标。

这里有两个细节值得说。第一,向量化模型的选择直接影响路由上限。我用的是通用的中文embedding模型,对口语化的业务描述效果一般,后来在通用模型的基础上用历史任务日志微调了一版,路由准确率从80%出头提到了93%左右。第二,Top-3候选里经常出现相似度很接近的情况,这时不要硬选,而是加一个“置信度闸门”:最高分和第二名的分差小于阈值时,任务进入“人工选择队列”或者推给默认兜底Agent,绝对不要自作聪明去猜。猜错一次,用户对整条链路的信任就没了。

2.3 上下文信封:触达时怎么保证不丢信息

多Agent系统里,上下文丢失是最隐蔽、也最破坏体验的问题。用户跟客服Agent说了一个包含订单号和不满意的原因,客服Agent判断需要退款,把任务转给退款Agent。如果退款Agent只收到了“用户要退款”,不知道具体订单号和原因,整个会话就得重新问一遍。

Agent-Reach解决这个问题的方式是给每个任务绑定一个“触达信封”(Reach Envelope)。信封的结构固定,包含了几个关键字段:

字段作用说明
context_id会话上下文唯一标识关联Redis中存储的完整对话记忆
trace_id全链路追踪ID方便日志串联,排查问题
source_agent_id来源Agent记录谁发起的这次触达
target_agent_id目标Agent路由后的目标
payload业务数据结构化传递本次任务的关键信息
ttl信封过期时间防止上下文无限堆积,单位秒

核心机制是:路由中心不把整个对话历史一股脑塞给目标Agent,而是把context_id和本次任务相关的payload递给它。目标Agent如果觉得信息不够,可以拿着context_id去上下文存储里拉取完整历史。这样既避免了token浪费,又保证了重要信息不丢。TTL默认设成1800秒,超过30分钟的会话需要重新确认用户意图,防止上下文陈旧导致误判。

实际操作中,我发现很多Agent实例压根没有传递context_id的习惯。它们只把自己处理的文本结果传给下一个Agent,下一跳就断链了。所以Agent-Reach在网关层强制要求:每个出站消息必须携带context_id,没有的请求直接打回。这个硬约束一开始被团队吐槽“多此一举”,后来排查线上问题时,全靠trace_id一把梭找到断点,大家才明白这套信封设计不是过度设计。

3. 实操:从零搭一套Agent-Reach

3.1 环境要求和基础安装

Agent-Reach核心运行时是Python 3.9+,依赖组件有两个可选:一个是Redis,用来做上下文存储和分布式锁;一个是WebSocket网关进程,用于跨语言Agent节点接入。如果所有Agent都在同一个Python进程内运行,Redis也可以省掉,直接用内存存储,但我不推荐在生产环境这么干。

安装很简单:

pip install agent-reach

装完之后,先初始化路由中心:

from agent_reach import ReachCenter center = ReachCenter( context_store_url="redis://localhost:6379/0", router_model="your-embedding-model", fallback_agent_id="assistant_default", ) await center.start()

这里router_model填的是你自己部署好的embedding模型服务地址。如果用默认的离线小模型也能跑,但业务名词一多就认不太准。我建议有条件的话,至少用一套针对业务语料微调过的模型。后面我会单独说怎么用路由日志持续优化它。

3.2 配置第一个Agent节点

假设我有个订单查询Agent,接入过程分两步:定义处理函数,然后注册。

from agent_reach import AgentNode, Skill async def handle_order_query(context: dict, payload: dict) -> dict: """处理订单查询的Agent逻辑""" order_id = payload.get("order_id") # 这里做真实的业务查询 result = {"order_status": "发货中", "logistics": "已到达杭州转运中心"} return result order_agent = AgentNode( agent_id="order_handler", skills=[ Skill( name="order_query", description="查询订单状态、物流信息、支付信息", input_schema={"order_id": "string", "user_id": "string"}, ) ], handler=handle_order_query, ) await register_agent(order_agent)

注册成功之后,Agent节点会自动向路由中心发心跳,默认间隔30秒。心跳不仅用来做存活检测,还负责同步能力清单的版本。如果你的Agent改动了Skill描述,记得把版本号+1,否则路由中心可能还用的是旧缓存,导致明明升级了,路由结果却还是老样子。

3.3 核心调用流程示例

配置完成后的调用方式非常直观。上层业务只需要把用户的原始输入抛给路由中心,路由中心会自动完成从语义匹配到结果返回的全过程:

result = await center.route( user_input="帮我查一下订单T10086现在到哪了", context_id="conv_20240621_01", ) print(result.final_answer)

这一行背后实际跑了四步:向量化用户输入,候选Skill召回,Schema规则过滤,把任务封装成触达信封发给目标Agent。全程耗时在200ms到500ms之间,大头都在embedding计算上,网络开销反而很小。

如果任务需要同时多个Agent协作,可以并行发出多个触达请求,每个请求各自绑定同一个context_id,目标Agent可以并发处理互不干扰。我在实际项目里就是这么做的:用户一句话同时触发“订单查询Agent”和“优惠券查询Agent”,最后再汇总。这种模式下,日志里能清晰地看到两条触达链路,可观测性比串行好很多。

3.4 把Agent-Reach接到现有Agent框架

很多人已经有现成的LangChain Agent,不想重写。Agent-Reach对这种情况很友好:你不需要把Agent逻辑改成Reach的回调函数,只需要写一个适配器。

from agent_reach import AgentNode def langchain_adapter(context, payload): # 把Reach的payload映射成LangChain的输入 lc_input = { "question": payload.get("user_input"), "history": context.get("history"), } # 调用你自己的LangChain Agent answer = my_langchain_agent.run(lc_input) return {"answer": answer} agent = AgentNode( agent_id="langchain_wrapper", skills=[Skill(name="general_chat", description="通用对话能力")], handler=langchain_adapter, )

跨语言节点的接入走WebSocket网关。网关进程监听一个端口,任何语言写的Agent只要遵循Reach的消息协议就能接入。消息协议是一个JSON信封,包含trace_id、context_id、payload、target_agent_id这几个字段。Agent处理完把结果通过同一个WebSocket连接回传,网关会把它转换回Reach内部的消息格式。这一步的好处是,订单服务用Java写、推荐服务用Go写,都能无缝接入同一条触达链路。

4. 上线后踩过的坑:排查实录与避坑清单

4.1 节点注册成功却路由不到

我在测试环境第一次接真实业务时,碰到的第一个诡异问题是:Agent明明显示注册成功,心跳也正常,但真实任务进来就是路由不到。把能力清单打出来跟日志里的候选结果一对比,就发现问题了。

罪魁祸首是embedding模型对新加的Skill描述根本没有区分度。“订单查询”和“订单修改”两个Skill描述里都有“订单”两个字,语义向量相似度超过0.95,路由层老把任务分错。这不是Agent-Reach的问题,是能力描述本身的区分度不够。

解决办法是重写Skill描述,把边界写得更明确。比如“订单查询”的描述改为“按订单号或用户ID查询订单状态、物流轨迹、支付结果,只读不改”,加一句“如需修改请进order_modify”。这样向量表示就有了区分度。另外,我还在描述里刻意用了触发词映射的技巧:把用户最常说的口语词埋进描述里,比如“我的东西到哪了”对应物流查询,“能不能退了”对应退款申请。实测下来,这个小改动比换模型带来的收益还明显。

4.2 上下文传着传着被截断了

有段时间,客服链路偶尔出现用户投诉“前面说的问题,转给人工后又问了一遍”。我们一开始怀疑是Agent内部大模型的上下文窗口太小,把历史给截了,排查半天没结果。后来追trace_id发现,问题出在中间一个非核心Agent上:它在处理完自己的任务后,没往context_id对应的存储里写更新,而是另起了一个新的context_id,后续Agent拿不到完整历史。

这种“上下文换号”的操作,在分布式场景下尤其致命。Reach网关的强制校验能拦住一部分,但拦不住那些“中间结果也要存一份”的自作主张型Agent。我的最终做法是:在路由中心的代码里加了一条硬规则——同一个业务会话的触达请求,如果目标Agent返回的context_id与入参不一致,直接把本次结果标记为异常,并触发告警。规则生效后,这类问题基本绝迹。

还有一类截断是因为TTL设太短。早期TTL设了300秒,用户稍微在多轮对话里停留几分钟,上下文就被清空了。后来统一改成1800秒,并定时清理过期的连接,才算稳定下来。

4.3 多个Agent同时触达同一个节点时的并发问题

当用户一次性并行触达多个Agent时,容易出现多个请求同时写同一个context_id的竞争问题。最原始的状态是“后写入覆盖先写入”,导致上下文里的内容互相踩踏,最终结果像是几个Agent在驴头不对马嘴地聊天。

我给上下文存储加了一把“版本号锁”。每个context_id维护一个版本号,Agent在写入上下文时必须带上当前版本号,写入时版本号+1。如果发现版本号过期,说明有另一个Agent先写入了,本次写入升级为“合并写入”,把新增信息追加到对应字段后面,而不是整体覆盖。

# 伪代码示意 version = await redis.get(f"ctx:{context_id}:version") ok = await redis.compare_and_set( key=f"ctx:{context_id}:version", expected=version, new_version=version + 1, ) if not ok: # 产生冲突,走合并逻辑 merge_context(context_id, incoming_data)

这个方案不是万能的,但能解决95%以上的写冲突。剩下那5%是同一个Agent实例内部并发写自己的上下文,那是Agent自身实现的问题,Reach管不到。

4.4 重试风暴和超时熔断

线上发生过一次事故:上游访问量突然增大,订单查询Agent响应变慢,超时时间到了,上游直接重试。重试又打到同一个过载的Agent上,形成重试风暴,最终把数据库连接池打爆。复盘时发现,问题出在两个地方:重试策略没有退避,重试次数没有上限。

Agent-Reach的默认重试策略后来改成了“指数退避 + 随机抖动”,每次重试间隔递增,并加上一个随机数防止多个请求同时重试。

重试次数等待时间范围
第1次0.5s - 1s
第2次1s - 2s
第3次2s - 4s
上限最多5次,超过走兜底Agent

同时加了熔断机制:如果某个Agent节点连续失败达到10次,路由中心会把它标记为“不健康”,后续请求直接绕过它,走兜底Agent,而不是继续干等。这个熔断状态每30秒探测一次,节点恢复后自动重新加入路由池。

这套机制上线以后,再没出现过因为单个Agent过载导致整条链路雪崩的情况。说实话,多Agent系统最大的性能瓶颈往往不是模型算力,而是这些默默的调度细节。

我自己的体会是,Agent-Reach这类触达层组件,看着不起眼,但它是多Agent系统里最值得花精力的部分。Agent能力再强,一旦触达层不可靠,整个系统就变成一盘散沙。如果你也遇到类似问题,不妨先别急着堆更多Agent,回头看看“Agent之间到底能不能稳定地找到对方、递上完整的信息”。把这条链路理顺了,系统的稳定性会有一个质的提升。