☰
多智能体调度中间件Agent-Reach:语义路由与稳定性设计
2026/10/29 8:05:37 网站建设 项目流程

最近这大半年,我一直在和几个同事折腾一个内部项目,名字叫 Agent-Reach。起因很现实:公司里的客服、销售、工单、供应链这几个团队,陆陆续续接了好几套 AI 智能体,结果越接越乱。有的团队用一个 Agent 同时处理售前和售后,另外几个 Agent 互相不知道对方存在,重复开发不说,用户消息到了系统里经常不知道该派给谁,偶尔派错了还要人工重新转一遍,效率反而比纯人工还低。Agent-Reach 就是在这个背景下出现的。

先说明一下它是什么。Agent-Reach 本质上是一个面向多智能体系统的请求接入与调度中间件。它解决的核心问题就三个:这条请求到底谁能处理、怎么把请求平稳地送过去、送过去之后如果处理失败了怎么收场。适用对象很明确——手上有多个独立开发的大模型应用,想在不推倒重来的情况下把它们统一收口,做语义调度、会话延续和故障降级。如果你刚接触智能体开发,这篇也能帮你理解 Agent 落地时最容易踩的坑。

1. 项目概述:Agent-Reach 到底想解决什么问题

1.1 多智能体落地时的三个真实痛点

先说第一个痛点:路由混乱。业务方往往不会只有一个 Agent。售前咨询、售后退换、物流查询、工单报修、内部知识问答,每个场景都可能有一个独立部署的智能体。问题在于,这些 Agent 之间没有任何协作关系,前端也不知道用户这句话该调哪个。最常见的做法就是写一堆 if-else 关键词匹配,比如看见"退款"两个字就扔给售后 Agent。但用户真实表达里,很少会乖乖把关键词说得那么规整。比如"我那个包裹是不是卡住了?我要投诉"——这到底算物流查询还是客诉?关键词匹配很容易答非所问。

第二个痛点是会话状态割裂。用户今天上午在网页上问了订单问题,下午换成小程序继续追问,如果两边的 Agent 不共享状态,下午的对话只能从头开始。用户必须重复一遍订单号、姓名、问题描述。这种体验放在现在的大模型应用里,基本属于不可接受。但很多团队在做智能体时,只顾着把提示词写好,根本没人设计会话存储,导致每一次交互都是失忆状态。

第三个痛点是稳定性缺失。大模型应用天然是慢接口,生成回答动辄几秒甚至十几秒。一旦并发上来,超时、限流、服务雪崩这些经典问题全部出现。更麻烦的是,大模型只是"看起来能理解",实际翻车率并不低,连续调用失败时如果没有任何兜底,用户只会得到一个报错页面或者一个转圈圈按钮。

这三个痛点叠加在一起,最典型的表现就是:每个 Agent 单独演示都挺好,一上线就崩,或者业务人员根本不知道把消息往哪儿发。

1.2 Agent-Reach 的核心定位与使用对象

Agent-Reach 的定位是一条"总入口 + 调度大脑 + 状态仓库"的中间层。它不是某一个具体的业务 Agent,也不打算替代大模型推理能力,而是负责把业务请求统一接入、理解、路由、转发、跟踪和降级。

说得更直白一点:业务 Agent 只关心自己擅长的那一小块,Agent-Reach 关心的是整个系统怎么把请求送到正确的地方,并且保证全链路可观测、可控制。

这个设计思路决定了它的使用对象。如果你只是一个 Demo 项目,只有一个 Agent,那完全不需要 Agent-Reach,直接写一个 FastAPI 接住前端请求就够了。但如果你所在的公司或者团队,已经有两套以上独立部署的智能体,或者未来明确会横向扩展不同场景的 Agent,那这个中间层就是刚需。它尤其适合那些想逐步收敛散乱智能体、但又不想一次性推翻重写的团队。

1.3 一个可以马上理解的小例子

我拿一个最简单的场景举例。用户发来一句消息:"我昨天买的鞋,想改成今天送到,来得及吗?"

这条消息到了 Agent-Reach,会经历这么几步:

  • 接入层接收到原始文本,附加上用户身份、渠道来源、会话 ID。
  • 语义路由模块把这句话转成向量,在路由表里召回最匹配的 Agent,发现它更接近"订单履约 Agent",而不是"售前咨询 Agent"。
  • 调度层检查订单履约 Agent 当前并发是否已满,如果满了就放进等待队列,而不是直接拒绝。
  • 转发前,调度层从 Redis 里把该用户最近 5 轮对话摘要取出,一起发给订单履约 Agent。
  • Agent 返回结果后,Agent-Reach 再把回答写回给用户,同时更新会话状态。

整个过程对用户来说只是一次普通对话,但背后的消息已经经过了分类、排队、记忆加载、限流熔断多道工序。这就是 Agent-Reach 真正在做的事情。

2. 整体架构与关键技术选型

2.1 分层设计的总体视图

Agent-Reach 的架构我按职责分成六块,每一块只做一件明确的事。很多团队刚起步时喜欢把代码揉在一个服务里,短期看着方便,一旦流量和 Agent 数量上来,排查问题会非常痛苦。

先看这张分层职责表:

分层核心职责典型实现
接入层统一接收 HTTP/WebSocket/渠道回调,做协议转换Gateway 服务
路由层语义识别、意图分类、Agent 选择语义路由服务
调度层并发控制、排队、重试、超时、熔断Dispatcher 服务
状态层会话存储、上下文摘要、幂等去重Redis + Redis Stream
治理层健康检查、注册发现、路由配置、观测Registry + Monitor
Agent 节点实际执行业务逻辑的大模型应用各类业务 Agent

整个请求链路是这样的:用户消息先到接入层,接入层不关心业务,只负责解析并生成统一格式的请求对象。然后消息进入路由层,路由层判断该交给谁。接着调度层真正执行转发,转发过程会查状态层,看有没有历史上下文,同时把这次调用的结果写回状态层。Agent 处理完后,通过同步响应或者异步回调把结果交回接入层,由接入层返回给用户。

这套分层看上去比单体多跳了几步,但换来的是每一个环节都可以独立扩容、独立降级。业务 Agent 挂了,至少路由和接入还能正常工作,前端不会直接白屏。

2.2 为什么选"网关 + 注册中心 + 编排器"这个组合

在技术选型上,我们对比过不少方案。最一开始有人提议直接用消息队列把各个 Agent 串起来,但消息队列天然不适合做在线请求的同步返回,除非你额外实现一个回调查询机制,复杂度会明显上升。也有人提议每个 Agent 自己维护一份路由表,但这样每个 Agent 都要感知其他 Agent 的存在,违背了解耦的原则。

最后我们选了"网关 + 注册中心 + 编排器"的组合。

  • 网关负责统一入口和协议适配。业务方不用关心背后的 Agent 是 HTTP 接口还是 gRPC 接口,也不关心它部署在哪台机器上。
  • 注册中心负责动态维护可用的 Agent 列表。每个 Agent 启动后向注册中心上报自己的名称、版本、健康状态、能力标签。网关和编排器只认注册中心,不认具体 IP。
  • 编排器负责真正的转发策略。它决定并发上限、超时时间、重试次数和熔断阈值,相当于整个系统的交通警察。

还有一个容易被忽视的点:异步化。有些 Agent 的处理时间非常长,比如生成一份完整分析报告,可能要 30 秒以上。这个时候同步等待用户体验很差。Agent-Reach 在调度层引入了异步模式,使用 Redis Stream 作为待处理队列,请求先入队,Agent Worker 消费后回调结果。 这样网关可以立刻给前端返回一个"处理中"的响应,避免 HTTP 连接长时间占用。

2.3 通信协议与消息格式设计

Agent-Reach 对外和对内使用统一的消息格式。这个格式我们迭代了三个版本才定下来,核心是保证可追踪、可回溯、可幂等。

统一消息格式长这样:

{ "request_id": "req_20250317_8f3a", "session_id": "sess_10086", "ts": 1742198400, "intent_hint": null, "payload": { "text": "我昨天买的鞋,想改成今天送到,来得及吗?", "user_id": "u_10086", "extra": {} }, "meta": { "source": "wechat_mini", "tenant": "demo_shop", "callback_url": "https://gateway.example.com/callback" } }

这里有几个字段是经验和教训换来的。

request_id必须全局唯一。它既用于日志追踪,也用于幂等去重。调度层在转发前会检查这个 ID 是否已经被处理过,如果处理过就直接返回上次结果,避免因为超时重试导致同一个操作被执行两次。

session_id是会话聚合的关键。状态层以它为主键存储上下文摘要,同一个用户在不同渠道发起会话时,只要前端能把同一个 session_id 传进来,Agent-Reach 就能把不同渠道的对话拼接成连贯会话。

meta.tenant是为了多租户隔离。我们当时做多业务线接入,采购、售后、物流各是一套 Agent,如果不加租户字段,路由层很容易把采购问题发给售后 Agent。

meta.callback_url是异步模式的回调地址。同步调用可以忽略它,但异步调用必须靠它拿到最终结果。

3. 核心实现细节

3.1 语义路由:从关键词匹配到向量召回

路由是整个 Agent-Reach 里技术含量最高的部分,也是我们花了最多时间调优的地方。

一开始我们用的是关键词 + 正则,确实简单,也能覆盖大多数规范表达,但很快就暴露问题。同一个意思的表达方式太多,比如"我东西还没到"和"我的快递显示签收了但没收到"都表示物流异常,关键词却完全不同。 后来我们接入了 embedding 向量召回,才真正把路由准确率拉上来。

具体做法是:每个 Agent 在注册时声明一批意图模板和对应示例,这些示例会被离线向量化,存进向量数据库。线上请求进来后,先把用户消息转成向量,在向量库里做 Top-K 检索,再按相似度分数排序。

路由判断的参考逻辑:

# agent_reach/routing.py def route_request(query: str, top_k: int = 3) -> str: query_vector = embed(query) hits = vector_store.search(query_vector, top_k=top_k) for hit in hits: if hit.score >= 0.82: return hit.agent_name # 兜底规则 for pattern, agent_name in fallback_rules.items(): if pattern.search(query): return agent_name return "default_agent"

0.82 这个阈值不是拍脑袋定的。我们用 5000 条历史对话做了标注后,画出相似度分数的分布曲线,发现正确路由的样本分数大多集中在 0.85 以上,错误路由的样本大多在 0.75 以下。取 0.82 作为阈值,可以在保证召回率的前提下尽量降低误判。不同业务场景这个阈值会有差异,建议每个团队都跑一轮自己的历史数据再定。

还要注意一个坑:embedding 模型是有"偏科"问题的。我们最开始用通用领域的中文向量模型处理电商场景,发现像"退款"这种词召回挺好,但"改签""退差价"这种组合词效果就差一些。解决方法是每个垂直场景定期增量补充意图模板,重新生成索引。我们目前是每周跑一次离线任务,把上一周线上路由失败但人工处理成功的对话捞出来,加入模板库。

3.2 会话状态与上下文管理

路由解决的是"找对人",会话状态解决的则是"不忘事"。

Agent-Reach 使用 Redis 做会话状态存储,每个 session 对应一个 Hash 结构,里面存三个关键部分:最近对话摘要、最近几轮原始消息、必要的业务上下文。

我用一个结构化描述来说明:

session:10086 -> { summary: "用户咨询订单20250317的改签问题,已确认可改,价格差12元,待用户确认", recent_messages: '["用户:我想改签", "Agent:可以,差价为12元"]', context: "order_id=20250317,ticket_status=待确认", updated_at: 1742198400, expire_at: 1742202000 }

这里的summary是关键。很多团队在做多轮对话时,喜欢把全部历史消息一股脑塞给大模型,结果 token 越用越多,回答反而越来越飘。Agent-Reach 的做法是:每次会话更新时,用一个大模型做一次轻量摘要,只保留最新的核心信息。 这样即便对话有几十轮,发给业务 Agent 的上下文也控制在几百个 token 内。

Redis 的使用上还有几个细节。

第一,必须设置过期时间。我们默认 30 分钟无操作就清空会话,但任何新操作都会刷新 TTL。这个值可以根据业务调整,聊到一半去吃个午饭回来还能接上,但隔天早上就不用保留了。

第二,序列化格式要统一。我们早期出现过前端传过来是 JSON 字符串,后端存进去却是 Python dict,等再取出来时字段对不上,排查了很久。现在统一规定:Redis 里只存 JSON 字符串,各服务自己解析。

第三,注意并发写入冲突。用户连续发两条消息,可能同时触发两个请求去更新同一个 session。我们在写入前用WATCH或版本号做乐观锁,版本不一致就直接丢弃后写的更新,保证会话不会被覆盖成一半新一半旧。

3.3 并发控制、限流与熔断

智能体系统和高并发 Web 系统最大的不一样,在于 Agent 处理时间极不稳定。普通接口可能 200ms 就返回,一个复杂的 Agent 调用可能要 8 秒。如果不做并发保护,几十个用户同时提问就能把 Agent 服务打垮。

Agent-Reach 的并发控制分了三层。

第一层是接入层的令牌桶限流。我们按租户维度配置 QPS 上限,比如免费租户 50 QPS,付费租户 200 QPS。超出上限的请求直接返回 429,不会进入后续队列。令牌桶的好处是允许一定程度的突发流量,又不至于让系统一直跑在满载状态。

第二层是调度层的信号量控制,按 Agent 维度限制最大并发数。比如售后 Agent 配置了最大并发 20,那么同时最多只能有 20 个请求在执行,第 21 个请求会进入等待队列,而不是立刻转发给 Agent。这个最大并发数不是拍脑袋定的,我们通过压测得到 Agent 服务的最大安全并发,然后留出 30% 余量。

第三层是熔断器。当一个 Agent 在 10 秒内连续失败超过 5 次,熔断器状态从"关闭"变为"打开",后续请求不再发往这个 Agent,直接进入降级流程。熔断状态每隔 30 秒尝试放行一个探测请求,如果成功,状态恢复为"关闭"。 这个机制救了我们很多次,当业务 Agent 因为外部 API 故障大面积超时时,Agent-Reach 至少能保证不会把整个链路拖死。

3.4 可靠性设计:重试、超时与降级

AI Agent 的可靠性设计比普通接口苛刻得多。普通接口超时重试没什么心理负担,但大模型接口经常是"慢但最终会成功"或者"表面成功但实际上逻辑错了",重试策略要格外小心。

Agent-Reach 的超时设定分为两级。

第一级是调用 Agent 的最大等待时间。生成式任务默认 10 秒,简单查询类任务默认 3 秒。为什么不用统一的 5 秒?因为生成式 Agent 如果限制太紧,经常被强制截断,返回一段没说完的话。我们后来改成按任务类型配置超时,路由层在判断 Agent 时会把意图类型一并传给调度层,调度层据此选择超时策略。

第二级是重试策略。我们默认最多重试 2 次,采用指数退避:第一次重试等待 500ms,第二次等待 1.5 秒。 超过重试次数后不再继续,直接进入降级流程,避免重试风暴把已经过载的 Agent 彻底压垮。

降级流程是整个可靠性设计里最核心的部分。我们设计了三个级别的降级:

  • 初级降级:切换到一个兜底 Agent。比如订单履约 Agent 宕机,可以临时切给一个轻量版查询 Agent,用规则匹配给出基础答复。
  • 中级降级:返回可理解的缓冲话术。比如告知用户"系统正在处理中,请稍后再试",同时把请求标记为待处理,延迟重试。
  • 高级降级:转入人工。前端收到降级信号后自动创建人工工单,用户排队等待客服介入。

实际运行中,这些降级策略不会一棒子打死。我们会根据熔断状态和错误码决定降级到哪一级。比如 Agent 返回 500,说明服务端逻辑有问题,直接走高级降级;返回 429,说明 Agent 过载,走中级降级比较合理。

4. 部署、压测与调优纪实

4.1 一套最小可运行部署方案

如果你想复现 Agent-Reach,最小部署只需要四个容器:Gateway、Router、Dispatcher、Redis。业务 Agent 单独跑,通过注册中心接入。

这里给一套我们可以直接跑的 docker-compose 配置:

version: "3.9" services: redis: image: redis:7-alpine ports: - "6379:6379" command: redis-server --appendonly yes --maxmemory 1gb gateway: image: agent-reach/gateway:1.0.0 ports: - "8080:8080" environment: REDIS_URL: redis://redis:6379/0 ROUTER_URL: http://router:8081 DISPATCHER_URL: http://dispatcher:8082 depends_on: - redis router: image: agent-reach/router:1.0.0 ports: - "8081:8081" environment: REDIS_URL: redis://redis:6379/1 EMBEDDING_MODEL: shibing624/text2vec-base-chinese VECTOR_DB_URL: http://milvus:19530 dispatcher: image: agent-reach/dispatcher:1.0.0 ports: - "8082:8082" environment: REDIS_URL: redis://redis:6379/2 AGENT_REGISTRY_URL: http://registry:8083 MAX_CONCURRENCY_PER_AGENT: "20" DEFAULT_TIMEOUT_MS: "3000" GENERATION_TIMEOUT_MS: "10000" registry: image: agent-reach/registry:1.0.0 ports: - "8083:8083"

text2vec-base-chinese是我们当时用的中文向量模型,如果你有自己的垂直领域模型,可以替换。需要注意的一点是 embedding 模型的内存占用。text2vec 这个模型加载后大概占 400MB 左右,Router 服务至少需要分配 1GB 内存,否则频繁触发 GC,延迟会跳动得很明显。

4.2 压测结果与参数调整过程

我们上线前做了一轮完整的压测,结论值得拿出来分享一下。测试场景是模拟用户咨询售后问题,共 1000 个真实脱敏请求,并发从 10 逐步升到 100。

第一轮压测的结果非常难看:

并发数平均 RTP95 RT成功率备注
10820ms1450ms99.6%基本可用
502300ms5200ms94.1%开始超时
1004800ms9600ms82.3%大面积超时

排查后发现,第一轮的问题主要出在 Router 的向量检索上。每次请求都要实时调用 embedding 模型 + 向量数据库,在并发升高后,向量库连接池被打满,请求只能排队等待连接。 我们做了一轮优化:给 Router 增加连接池大小,同时把 embedding 计算改成批量预处理和 LRU 缓存,高频的问题模板直接命中缓存,不重复计算。

优化后跑第二轮,结果好很多:

并发数平均 RTP95 RT成功率备注
50780ms1400ms99.4%稳定
1001200ms2300ms98.8%轻微超时
2002500ms4800ms93.5%需要限流

这个结果基本达到了我们的预期。调度层在 200 并发时开始拦截超过 Agent 承载能力的请求,触发排队,所以成功率还保持在 93% 以上。如果一定要在 200 并发下做到 99% 成功率,方案不是调 Agent-Reach 参数,而是给业务 Agent 扩容。 这也是中间件设计里一个很重要的理念:调度层把流量治理好,容量问题交给横向扩容去解决,两者不能互相替代。

4.3 灰度发布与回滚策略

Agent-Reach 支持按租户、按用户比例、按渠道三种方式做灰度。最常见的做法是按用户比例灰度:新版本 Agent 先接 5% 流量,跑一天没问题,再逐步提高到 20%、50%、100%。

灰度切换的入口在路由表配置。我们有一个简单的管理接口,可以动态调整一个 Agent 版本的路由权重:

curl -X POST https://registry.example.com/v1/router/update \ -H "Content-Type: application/json" \ -d '{ "agent": "after_sale_agent", "rules": [ {"version": "v2.1", "weight": 0.05, "status": "active"}, {"version": "v2.0", "weight": 0.95, "status": "active"} ] }'

发布新版本时,新版本 Agent 启动后先向注册中心上报,注册中心并不会立刻给它分配流量,而是等路由配置更新后按权重分发。 回滚就更简单了,把 v2.1 的权重改成 0,流量自动全部回到 v2.0,不需要重启任何服务。

这里一个比较重要的感受是:灰度发布一定要监控成功率,而不能只看错误率。大模型 Agent 特别容易出现"嘴上说成功、其实回答得乱七八糟"的情况。 我们加了一个自动评价模块,用 LLM 对 Agent 的回答做质量打分,灰度期间如果质量分比老版本低超过 5%,会自动暂停灰度并告警。这个机制比单纯看 HTTP 状态码可靠得多。

5. 常见问题排查与实操心得

5.1 我踩过的坑

第一坑:会话上下文序列化反序列化不一致。我们早期在 Redis 里存 Python dict,后来又改了部分字段类型,结果老会话加载时直接抛异常,用户连续对话一多就报错。后来把逻辑改成启动时统一做一次兼容性迁移,并且所有会话字段都声明了严格类型,这个坑才算填上。

第二坑:重试风暴。最初我们把超时时间设成 1 秒,Agent 偶尔响应慢一点,调度层就开始重试。高峰期会出现一个请求被打到 3 遍,Agent 服务直接被打挂。后来把超时时间和重试次数按场景配置,并增加了熔断器,这个连锁反应才止住。

第三坑:幂等性设计不到位。我们的订单变更 Agent 支持"修改收货地址"操作。第一次压测时发现,由于前端超时自动重发,同一个修改动作被 Agent 执行了两次,导致用户地址被改了又改回去。加上 request_id 去重逻辑后,这种重复执行问题彻底消失了。

第四坑:内存泄漏。在压测时发现 Dispatcher 的内存持续上升,排查后发现是 HTTP 客户端没有复用连接池,每次请求都新建连接,大量 TIME_WAIT 连接堆积。改成全局复用 HTTP Client 后,内存曲线恢复平稳。

5.2 问题速查表

这里整理一份我实际排查过的问题速查表,按团队优先级排列:

症状可能原因处理方式
所有请求都超时Agent 服务过载或宕机检查熔断器状态;摘除异常节点;扩容 Agent
某个 Agent 恢复后仍被拒熔断器处于打开状态等待自动恢复,或调用管理接口手动关闭熔断
路由命中率低意图模板库覆盖不足导出近期误判样本,离线补充模板并重建索引
同一用户会话串台session_id 未从上游正确传递检查接入层是否覆盖了 session_id 透传逻辑
消息重复处理缺少幂等检查确认 request_id 在调度层有去重判断
Redis 内存暴涨会话 TTL 设置过长或没设置检查过期策略,按业务场景缩短 TTL
响应内容变得很短Agent 侧超时截断调大生成式任务的超时时间
向量检索延迟突增向量库连接池打满增加连接池大小;为高频模板加本地缓存

5.3 Agent-Reach 后续可以扩展的方向

项目当前状态已经能稳定支撑我们内部三套业务 Agent,日均请求量在两万左右。不过我觉得这个方向还有几个值得继续做的点。

一是路由层面的多级决策。现在的语义路由还比较"单跳",用户一句话只选一个 Agent 去处理。但真实业务里,一条消息可能同时涉及订单查询和退款申请。下一步计划做成意图组合,让一条消息能拆分出多个子任务,分别路由到不同 Agent,最后再汇总生成一个统一回复。

二是会话记忆的长期化。现在 30 分钟 TTL 对大部分业务够用,但像供应链这种长周期业务,用户隔几天回来追问,之前的上下文可能已经失效。项目后续可以引入会话摘要归档,把重要历史摘要放到文档型数据库里,需要时再加载。

三是更细粒度的质量评估。现在我们只对灰度期间的回答做质量打分,全量上线后就没有持续的评估机制。理想状态下,Agent-Reach 应该对每一条线上请求做自动质量抽检,把低质量回答及时反馈给 Agent 团队优化提示词或模型参数。

我个人在实际操作中最大的体会是,做智能体中间件最忌讳一开始就把系统设想得太庞大。先从"路由 + 会话 + 超时重试 + 熔断降级"这四件事做起,稳定之后再去扩展能力标签和复杂编排。Agent-Reach 之所以现在还能顺利迭代,就是因为我们把最基础的地基做到了足够扎实。如果你也在做多 Agent 的接入整合,希望这篇里那些踩坑的记录,能让你少花几个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询