做"Agent-Reach"这个项目的起因,其实特别朴素。2024年下半年我一直在折腾多智能体协作,团队里的Agent越来越多——有做需求分析的、写代码的、跑测试的、盯运维指标的,每个都能独立干活,但把它们凑在一起的时候,问题全暴露出来了:A模型生成的结果要给B模型消费,中间全靠人肉转发;一个Agent执行失败,其他几个傻等半天;想查某个任务到底跑到哪一步,只能去翻好几个终端窗口的日志。总之就是乱。我下定决心搞一个统一调度和触达的基础设施,让所有Agent通过一套标准协议注册、被发现、被路由、被监控,这个平台就是"Agent-Reach"。它不是某个具体的业务Agent,而是承载Agent运转的"神经中枢",专门解决多Agent场景下的"找得到、调得动、看得见、管得住"这四个核心问题。这篇文章我会把Agent-Reach从需求分析、架构设计、核心模块实现到踩坑排查的过程完整拆一遍,重点是落地方案和取舍逻辑,正在做Agent编排、工作流平台或者消息路由的朋友,应该能拿走不少可复用的思路。
1. 项目整体设计与思路拆解
1.1 先想清楚要解决什么问题
Agent-Reach这个名字,拆开看就是"让Agent触手可及"。早期我们内部也讨论过要不要直接用现成的编排框架,比如LangGraph或者CrewAI,但实测下来都有点别扭:这些框架对"Agent聊Agent"支持得不错,可一旦要接入企业内部系统——工单、监控、数据库、审批流——就显得力不从心。它们解决的问题偏向"如何编排逻辑",而我们的痛点其实是"如何组织分布式Agent运行时"。
所以我当时给Agent-Reach定下的核心能力边界是五个:
- 注册与发现:每个Agent上线时主动注册自己的能力、地址、状态,下线时自动摘除,不用写死配置。
- 触达路由:消息进来后,根据内容语义和Agent能力描述,把消息分发给最合适的Agent,而不是靠硬编码的if-else。
- 统一上下文:不同Agent协作时能读取一段共享记忆,避免每个Agent都带一套完整prompt上下文,省token也省时间。
- 可观测性:每一次消息流转、调用耗时、成功失败、token消耗都有轨迹可查,方便复盘。
- 安全边界:Agent之间不能随意互相调,必须有权限校验和敏感信息过滤。
这五个点其实就是Agent-Reach的命根子,后面所有的代码和配置都围绕它们展开。
1.2 核心方案选型与权衡
技术栈上,我选择了Python + FastAPI作为主框架,因为团队内部Agent大多是Python写的,集成便宜。消息通信没有直接上Kafka或者RabbitMQ这种重型MQ,而是先用了Redis Streams。原因很简单:初期Agent量级在几十个左右,QPS也没那么夸张,Redis Streams足以支撑,而且部署简单——本来就有Redis,不用额外引入一个中间件。等到量级真上去了,再把Redis Streams替成Kafka,接口层做好抽象,替换成本是可控的。
注册中心也没用什么高深的服务注册框架,直接在Redis里维护了一个Agent元信息Hash表,外加一个有序集合存心跳。原因更直白:Agent数量有限,ZooKeeper或者Consul的运维成本对我们来说偏重,而且这些框架的核心能力是"服务发现",但我们需要的核心能力其实是"能力发现"——不光要知道这个Agent在哪,还要知道它能干什么,它擅长哪种类型的任务,它的输入输出schema是什么。这部分如果不自己建模,光靠现成框架很难满足。
语音到文本的转换节可以用Pyannote或Whisper,但当时我们用的是whisper API,因为准确率优先,同时将文本切分为合理的段落以匹配生成节奏。后面如果做实时字幕再换更快但准确率稍低的local whisper模型。
2. 核心模块解析与关键技术细节
2.1 Agent元信息模型与注册流程
Agent-Reach里最核心的数据结构是这个AgentMeta,它决定了整个平台能不能聪明地做路由。
class AgentMeta(BaseModel): agent_id: str name: str version: str description: str # 自然语言描述能力,用于语义匹配 endpoints: dict # {"chat": "http://agent-a:8001/chat", "health": "/health"} input_schema: dict # JSON Schema,声明自身接受的输入 output_schema: dict # JSON Schema,声明自身返回的结果 tags: list[str] # 如 ["code_review", "python", "fastapi"] load: int # 当前并发数,用于负载均衡 max_load: int = 10 enabled: bool last_heartbeat: float每条Agent上线时调用POST /register,把上面这个JSON提交给Agent-Reach,平台会先做几件事:
- 对input_schema做合法性校验,格式必须严格符合JSON Schema规范,否则注册直接拒绝。这个限制很关键——后面做消息校验全靠它。
- 把agent_id作为Redis Hash的field,整个meta存成Hash结构。
- 把agent_id按tags分别插入多个索引集合,方便按标签快速筛选。
- 启动后台心跳监听,如果90秒没收到心跳,自动把
enabled置为False。
为了让你直观理解,我贴一段注册接口的核心逻辑:
@app.post("/register") async def register_agent(meta: AgentMeta): # 校验input_schema是否是合法的JSON Schema try: Draft7Validator.check_schema(meta.input_schema) except Exception as e: raise HTTPException(status_code=400, detail=f"invalid schema: {e}") key = f"agent:meta:{meta.agent_id}" r.hset(key, mapping=meta.dict()) for tag in meta.tags: r.sadd(f"tag:{tag}", meta.agent_id) r.zadd("agent:heartbeats", {meta.agent_id: time.time()}) return {"status": "ok", "agent_id": meta.agent_id}这里有一个值得展开讲的设计:为什么注册时的"能力描述"要用自然语言,而不是用结构化标签?因为我们实测过,靠严格的枚举标签,Agent能力稍微变一点就得改代码,维护成本太高。而你用description自然语言描述,再配合embedding向量检索,Agent就可以非常灵活地声明自己的能力边界,比如"我擅长处理关于CPU使用率异常的排查建议",路由模块可以拿用户请求去算cosine相似度,匹配最合适的Agent。当然,tags作为硬过滤条件,description作为软匹配,两者结合效果最好——先用tags缩小范围,再用向量语义排序。
2.2 任务路由:从规则到语义的双层路由策略
路由模块是Agent-Reach的大脑。一开始我天真地觉得,搞一个关键词表映射到Agent,又快又简单。后来发现完全不行,因为用户说"帮我看看为啥服务老报500",和Agent描述的"擅长分析HTTP错误日志并给出修复建议",关键词重叠度很低,但语义上是匹配的。所以最终我做了双层路由策略:
第一层是硬性过滤,通过tags和负载状态把不可能接单的Agent剔除掉;
第二层是语义排序,对候选Agent的description和用户请求分别做embedding,计算相似度,取Top N。同时会综合当前负载做一个加权,公式大概长这样:
score = 0.8 * semantic_sim + 0.2 * (1 - load / max_load)这个公式的逻辑很直观:大多数情况优先选语义最匹配的Agent,但如果这个Agent已经快打满了,就让一个语义稍差但比较空闲的Agent顶上,整体吞吐更平稳。
embedding模型用的是text-embedding-3-small,便宜且快。每天大概跑几万次embedding,成本几乎可以忽略。相似度计算没有用向量数据库,直接在内存里用numpy算余弦相似度,候选集合通常只有几十个,速度完全不是瓶颈。
路由接口的核心实现长这样:
@app.post("/route") async def route(request: RouteRequest): # 1. 根据tags硬性过滤 candidates = set() for tag in request.required_tags: ids = r.smembers(f"tag:{tag}") candidates.update(ids) if not candidates: return {"status": "no_agent_found"} # 2. 过滤不可用Agent alive_agents = [] for agent_id in candidates: meta = get_agent_meta(agent_id) if meta and meta.enabled and meta.load < meta.max_load: alive_agents.append(meta) # 3. 语义排序 req_vec = embed(request.user_query) scored = [] for meta in alive_agents: desc_vec = embed(meta.description) sim = cosine_sim(req_vec, desc_vec) load_factor = 1 - meta.load / meta.max_load scored.append((0.8 * sim + 0.2 * load_factor, meta)) scored.sort(reverse=True) return {"agent_id": scored[0][1].agent_id, "score": scored[0][0]}这套双层路由跑下来效果很好,准确率大概在90%左右。剩下的10%误判,基本都是两个Agent能力描述太接近,比如"代码审计Agent"和"安全扫描Agent"在某些请求上确实会重叠。解决方式是加了人工确认兜底:当Top 1和Top 2分数差小于0.05时,平台会把这请求标记为"需确认",进入一个排队池,由调度管理员手动指定,或者直接随机分配。这种混合策略在工程上很实用,不是所有事情都要强上全自动AI。
2.3 Redis Streams实现可靠触达与上下文管理
任务路由找到了Agent,下一步就是把消息可靠地送过去并拿到结果。这里用Redis Streams做了标准的生产者-消费者模式。每条消息会经历四个状态:pending、running、completed、failed。状态流转通过一个独立的result_hash存结果,再用stream的ACK机制保障消息不丢。
关键点在于"至少一次"还是"精确一次"的取舍。Agent-Reach选的是至少一次交付,因为Agent处理本身带有幂等性设计——每个任务带一个全局唯一的task_id,Agent执行前会先查一下这个task_id是否处理过,处理过就直接返回历史结果。这样即使消息重复投递,也不会造成副作用。
# 生产者侧:投递消息到指定Agent的队列 async def deliver(agent_id: str, task: dict): stream_key = f"agent:task:{agent_id}" r.xadd(stream_key, {"task": json.dumps(task)}, maxlen=10000) r.hset(f"task:state:{task['task_id']}", mapping={"status": "pending", "agent_id": agent_id})消费者的逻辑是Agent自己实现的,只需要遵守一个约定:从stream读取消息,处理,然后把结果POST /callback回传给Agent-Reach。Agent-Reach收到回调后把状态改成completed,再把结果写入一个任务结果表。
为什么不用同步HTTP调用?我试过,最大的坑是超时不可控。Agent的LLM推理经常要几十秒甚至几分钟,HTTP同步调用很容易把网关的连接池打满。用Streams把触达变成异步,Agent-Reach接住消息就可以返回"已接收",这对调用方来说体验反而更好——你不需要傻等一个长时间的HTTP请求,而是拿到task_id之后轮询或订阅结果。
上下文管理这块,Agent-Reach专门设计了一个ContextStore,本质就是Redis里按会话ID存了消息历史,每次Agent处理完把输出追加进去,下一个Agent读取时自然就有了上文。为了防止无限膨胀,对每条消息做了截断,超过4096个token之后会用摘要替代,这个摘要也是调用LLM生成的。这一点很费token,所以只在会话超过20轮之后才启动。
3. 实操过程与核心环节实现
3.1 最小可用版本的落地过程
项目启动第一周,我给自己定的目标是"跑通两个Agent协作改bug的完整链路",具体场景是:用户提交一个报错信息,Agent-Reach路由给"日志分析Agent"定位根因,然后把根因交给"代码修复Agent"输出补丁。这里的链路涉及注册、路由、消息传递、回调、结果聚合,是检验平台核心能力的理想样例。
首先搭建项目骨架,目录结构如下:
agent-reach/ ├── app/ │ ├── main.py │ ├── models.py │ ├── registry.py │ ├── router.py │ ├── deliver.py │ └── context.py ├── agents/ │ ├── log_analyzer/ │ └── code_fixer/ ├── tests/ └── pyproject.toml依赖只有FastAPI、Redis客户端、numpy和一个embedding库。
然后写两个演示Agent。每个Agent是一个独立的FastAPI服务,启动时向Agent-Reach注册,之后定时发心跳。日志分析Agent的注册信息大概是这样的:
{ "agent_id": "log_analyzer_v1", "name": "日志分析Agent", "description": "擅长分析应用日志中的错误堆栈,定位异常根因,并给出初步排查建议", "endpoints": {"chat": "http://localhost:8101/chat"}, "input_schema": { "type": "object", "properties": { "error_message": {"type": "string", "description": "原始报错信息"} }, "required": ["error_message"] }, "tags": ["log", "troubleshooting", "python"] }代码修复Agent的描述是"擅长根据错误根因生成代码修复补丁",tags是["code", "patch", "python"]。
第三步是串通消息链路。当用户往Agent-Reach的/submit_task接口扔一个报错信息时,平台先路由到log_analyzer,log_analyzer处理完通过回调把根因分析写回ContextStore,紧接着平台再路由给code_fixer,并携带前面分析出的根因文本作为输入。整个过程如下:
用户 -> /submit_task -> Agent-Reach 路由 -> log_analyzer -> 回调写ContextStore | v Agent-Reach 再次路由 -> code_fixer -> 回调返回补丁 -> 用户这里的难点在于"如何串联多个Agent"。我最初的方案是写死编排,log_analyzer完成后立刻调code_fixer。后来发现这个思路只对单条流程适用,一旦流程变复杂,比如一个根因要同时分发给安全Agent和代码Agent,写死编排就崩了。最后落地的是基于规则的事件驱动:每个Agent的处理结果会携带一个next_routes字段,由Agent自己建议下一步路由给谁,Agent-Reach会校验这个建议是否在合理的范围,合法则触发后续投递。这样编排逻辑下放给了各个Agent,平台的灵活性大幅提升,同时主角还是Agent-Reach这个平台。
上面这个过程的代码,注册到投递的核心链路大约240行,不算复杂,真正费心的是边界情况的处理,下面一节会专门展开。
3.2 关键参数配置与计算过程参考
Agent-Reach有一些参数是经过反复调优才最终定下来的,我在这里列一份配置参考,方便你复现时直接参考。
首先是心跳超时和回收机制。Redis里的agent:heartbeats有序集合中,每个Agent每10秒更新一次分数为当前时间戳。Agent-Reach后台每分钟扫描一次,把now - score > 90的Agent标记为enabled=False,然后从路由候选里剔除。为什么是90秒而不是更长?因为我们压测发现,业务Agent处理单任务偶尔会超过60秒,如果心跳间隔也设成60秒,Agent可能在执行完任务前错误地下线。90秒给了一定余量,同时又能较快回收故障节点。如果你想更精确,可以把心跳间隔设为15秒,超时设为60秒,这样恢复速度更快,但心跳网络开销会增加一些。
其次是语义相似度阈值。这个参数是调的最久的。最初定为0.85,期望非常精准地匹配,结果大量请求落到了"no_agent_found",召回率只有60%左右。后来降到0.6,召回率上去了,但匹配错误明显增多,比如"内存溢出排查"会被路由给"数据库调优Agent"。最终结合双层路由的修正,硬过滤后语义阈值定在了0.7,同时在Top 1与Top 2分差小于0.05时进入人工确认池。这个0.7和0.05是拿我们线上一个月的历史请求回放反复测出来的最优组合。
再就是Redis Stream的maxlen和消费并发。限定每个Agent队列的maxlen为10000,防止积压过多导致消费者产生内存问题。消费者并发数按Agent自身的处理能力公式来定:
并发数 = 预估单任务耗时秒数 / 目标响应秒数如果一个Agent平均单任务要30秒,你希望任务90秒内能被消费,那并发数就是3。这个公式来自排队论里的一个简化推演,虽然不一定精确,但作为初始量非常实用。后面可以压测微调,强烈建议不要盲目把并发调很高——LLM推理接口不像普通HTTP接口,并发高了token输出速度和稳定性都会变差。
3.3 安全边界和权限校验的设计
Agent之间相互触达,如果不加控制,很容易出现"一个Agent被攻破全部沦陷"的局面。Agent-Reach里的安全控制围绕三块展开。
第一块是API Key认证。每个Agent注册时平台会下发一个agent_key,后续Agent调用任何回调和触达API都要带这个key。Redis里存储了agent_id -> key的映射,校验时用恒定时间比较,避免时序攻击。
第二块是能力白名单。AgentMeta里加了一个可选字段allowed_peers,声明这个Agent允许被哪些Agent调用。路由模块在分发消息前会检查发起方的agent_id是否在目标Agent的allowed_peers里,不在的直接拒绝。这个机制避免了Agent之间"越权互喊"。
第三块是敏感信息过滤。路由消息里如果检测到疑似密码、密钥、身份证号等敏感信息,平台默认会做脱敏处理再投递,除非该队列显式声明了"需要原始信息"。这个设计是因为LLM调用第三方接口时,提示词和输出可能会被模型服务商记录,脱敏是降低数据泄露风险最有效的一道防线。我建议做Agent平台的朋友,这条一定要加上,迟早会有数据合规审计找上门。
4. 常见问题与排查技巧实录
4.1 问题速查表
整个开发周期里我踩的坑比较集中,整理一个速查表,你在跑的时候如果遇到类似现象可以直接对号入座。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Agent注册成功但路由永远找不到 | Agent心跳未上报,enabled被置为False | 检查心跳线程是否随业务执行被阻塞,建议心跳放独立协程 |
| 消息投递成功但Agent没反应 | Stream消费组没创建或者消费者没正确重试 | 检查消费者ACL权限,确认stream key和消费者组一致 |
| 语义路由匹配结果离谱 | 描述文本太抽象,和实际请求ingredients不匹配 | 重写description,多带几个具体场景例子,越具体越好 |
| 会话上下文过大,token成本飙升 | 上下文无限累积 | 设置20轮后启用摘要模式,或直接截断旧消息 |
| 两个Agent循环触发停不下来 | 事件驱动编排缺少环检测 | 在Agent-Reach层增加每会话步数上限,默认20步 |
| 回调丢失导致任务悬挂 | 回调没有实现幂等或没有重试 | 回调接口幂等处理,配合死信队列 |
4.2 两个典型问题的排查复盘
第一个典型问题是"消息重复消费导致Agent执行了两次"。当时现象是用户报告任务执行结果不稳定,有时候正常有时报错,一查发现同一task_id被执行了两次。根因是消费端处理完任务但回调超时,Agent-Reach认为消息未处理又重新入队。修复方式有两层:第一层是消费端执行任务前先查task_id去重表,处理过的直接返回缓存结果;第二层是回调接口增加超时重试上限,最多重试3次,重试还失败就进入死信队列,由管理员人工介入。这两层加完,重复执行的问题就绝迹了。
第二个典型问题是"路由分数不错但Agent实际能力不匹配"。映射到具体场景是,"日志Agent"和"运维Agent"分数都在阈值线上,结果日志Agent把运维问题也揽走了,处理结果完全跑偏。排查后发现不是Agent-Reach的问题,而是Agent的描述写得太宽泛了,什么"支持各类IT问题排查"。我后来在注册规范里强制要求description必须包含至少两个典型的用户请求示例,给Agent做校准。比如日志Agent的描述是"给定一段nginx error.log,定位5xx错误来源;给定Python堆栈,分析是内存问题还是IO问题"。这个改动让语义检索质量明显上了一个台阶。核心经验是:路由准不准,一半取决于路由算法的语义模型,另一半取决于Agent自己有没有把自己的边界描述清楚。
5. 实测效果与经验复盘
5.1 线上压测数据
项目进入稳定期后我们做了一轮压测,配置是2核4G的容器,Redis独立部署。结果大概是这样:单机Agent-Reach实例稳定支撑了400 QPS的消息路由请求,P99响应时间120ms左右,路由命中率96.7%,任务完成率99.2%。在500 QPS时P99明显攀升,主要瓶颈出现在embedding接口的调用耗时上。后来我们做了embedding结果的本地缓存,把重复描述和重复请求的向量缓存住,P99降到70ms,能力直接翻倍。
这里有一个优化点值得提:embedding虽然便宜,但高并发下它的网络延迟就是瓶颈。我们的解法是按description文本做MD5缓存,把每个Agent的描述向量和每个用户请求的前缀向量都缓存起来,实际运行中请求相似性很高,缓存命中率达到65%。如果你也在做类似系统,这招强烈推荐。
5.2 Agent-Reach的模式可以迁移到哪些场景
做完这个项目之后的感受是,Agent-Reach作为一个"多Agent触达中枢",这套思路不仅适用于AI平台,在很多有"多系统协作"需求的地方都能用上。比如跨部门流程自动化、企业内部的工单分流系统、数据中台的任务编排,本质都是"把正确的事情交给正确的执行者,并追踪执行过程"。Agent-Reach里面的注册中心、语义路由、异步消息投递、幂等消费、可视化追踪,每个都能单独拿出来复用。
单纯从技术角度看,这套方案最耗时间的是数据建模和边界设计,而不是写代码。你只要把AgentMeta定义清楚,把消息的状态机界定清楚,把路由的取舍逻辑想清楚,后面就是纯粹的体力活了。这也是为什么我在前面花了那么大力气讲Agent注册模型和双层路由——这些是Agent-Reach的灵魂。
我自己经验体会最深的一点,其实不是技术选型,而是"做平台要想清楚给谁用"。Agent-Reach最初我做得特别通用,什么功能都想加,后来发现只有真正在线上被调用之后才知道哪些是刚需。比如死信队列就是一个连续踩坑才加上的功能,而它现在反而是运维最依赖的一环。如果你正在构建类似的Agent基础设施,我建议你先把一条最核心的链路跑通,让它真实地承接业务,再去补齐周边的能力,千万不要一上来就追求大而全。
最后想补充一个额外的小建议:Agent-Reach的可观测性一定要从第一天就开始埋点,不要等出了问题再补。因为Agent之间的调用链天然分散,没有全链路追踪,排查问题的成本会高得吓人。我们早期就是靠结构化日志硬扛,后来加上了OpenTelemetry链路追踪,才彻底把Agent调用的来龙去脉看清楚。工具本身不复杂,难的是形成习惯。