我最早做 Agent-Reach 这个项目,是因为一个特别具体的痛点:我同时在用五六套 AI 工具,文件检索的、写代码的、做纪要的,还有几个定时任务在后台跑,它们各干各的,互不相通。每次想让它们接力完成一件事,我都得手动把上一步的结果复制粘贴给下一步,流程稍微复杂一点,人就成了一个传话的中间件。后来我干脆写了一个中间层,把请求统一收进来,按能力路由给不同的 Agent,再把结果从合适的通道推给我。这个项目就是 Agent-Reach。表面上看它是个任务分发器,实际上它解决的是三个问题:任务怎么标准化、Agent 怎么被发现和路由、结果怎么可靠地触达用户。如果你也经常在多套 AI 工具之间来回切换,或者想把重复的 AI 操作变成自动化流水线,这个项目的思路可以直接参考。
1. 项目概述与整体设计思路
1.1 从一个“传话人”的痛点说起
先描述一下最原始的使用场景。每天早上我需要生成一份项目日报,流程大概是这样:查 Git 提交记录,去内部知识库找相关资料,让大模型生成摘要,最后推到团队群。以前我得开四个工具,复制粘贴四遍,中间任何一步忘了一步,日报就缺内容,等于白做。
Agent-Reach 最开始就是为解决这种场景写的。它的定位非常明确:不做大模型推理,不提供任何具体的 AI 能力,只做智能体之间的调度中枢。你可以把它理解成一个配合多支外包团队干活的项目经理,它清楚每个 Agent 能干什么,知道哪个任务该交给谁,还负责盯着进度、催结果、最后把成果交到你手上。
这个定位让我少走了很多弯路。如果一上来就想把所有 AI 能力都塞进一个系统,那本质上是在重复造轮子。Agent-Reach 选择的是打通现有工具的环路,把已有的、分散的 Agent 能力统一纳管起来。谁擅长写代码就让它写代码,谁擅长检索就让它检索,中间层只关心分发、编排和触达。
1.2 它解决的几类核心问题
我总结下来,Agent-Reach 的价值主要体现在四个方面。
第一,入口统一。不管请求来自 HTTP 接口、命令行还是一个聊天机器人回调,进入系统后都变成同一种任务结构,后续处理逻辑不用为每种入口单独写一套。
第二,能力透明。Agent 在注册时要明确声明自己能处理什么、输入是什么、超时多久、调用地址是什么。有了这份注册表,系统才能做合理的路由决策,而不是靠硬编码。
第三,流程可编排。简单任务可以直接路由给一个 Agent 执行;复杂任务可以定义依赖关系,比如先检索、再总结、最后推送,Agent-Reach 会按顺序调度,并且记录每个阶段的执行状态。
第四,触达可靠。结果生成之后,需要发给谁、走哪个渠道、失败了要不要重试,这些都由触达层统一处理。邮件、群机器人、桌面通知都可以接入,并且支持降级策略。
对不同基础的人来说,这套设计有一个很明显的好处:你可以先只接两三个 Agent,手动测试跑通一条链路,再慢慢加路由规则和编排逻辑。它不是一个大而全的平台,而是一个可以渐进落地的框架。
1.3 整体架构与数据流
Agent-Reach 的架构分五层,每层只做一件事。
第一层是接入层,负责接收外部请求。我提供了 HTTP API、命令行工具和简单的消息回调接口,所有请求先做鉴权和基础校验。第二层是解析与标准化层,把用户原始输入转换成统一的 Task 对象,提取意图、参数、优先级和期望的触达渠道。第三层是调度与编排层,也是最核心的部分,它根据 Agent 注册表里的能力声明做路由匹配,并决定任务是串行还是并行。第四层是执行层,通过适配器调用实际的 Agent 服务,等待返回结果。第五层是触达层,把最终结果通过用户指定的渠道推送出去,并记录投递状态。
数据流是单向的,每一层之间只通过标准化的数据对象通信,不互相依赖。这样做的好处是,替换任何一层都不会影响其他层。比如我想把 FastAPI 换成别的框架,只要 Task 对象和接口协议不变,上下游都不用动。
日志方面,我在每一层都埋了 trace_id,从请求进来到最终推送出去,每个环节的耗时、状态、返回码都有记录。后面排查问题,90% 的情况靠这些日志就能定位。
2. 核心模块拆解与关键技术点
2.1 任务模型:先定义好“包裹面单”
路由系统要正常工作,第一步不是写路由算法,而是定义好任务的数据结构。我把它类比成快递分拣:分拣中心先看包裹上的面单,才知道它该上哪条线。Task 就是这个面单。
我最初设计的 Task 字段这样取值:
from dataclasses import dataclass, field from typing import Any @dataclass class Task: task_id: str intent: str payload: dict[str, Any] user_id: str = "default" priority: int = 0 timeout: float = 60.0 max_retries: int = 3 channels: list[str] = field(default_factory=lambda: ["webhook"]) callback_url: str = "" required_capabilities: list[str] = field(default_factory=list)每个字段背后都有具体考虑。task_id 用 UUID 生成,全链路追踪都依赖它。intent 表示任务的意图,比如 code_execution、document_summary、data_retrieval,路由时把它跟 Agent 声明的能力做匹配。payload 是实际参数,格式因任务而异,比如检索任务可能包含 query 和 top_k。
channels 是用户期望的结果触达渠道,可以传多个,例如 ["webhook", "email"],调度完成后按顺序尝试。required_capabilities 很重要,它是在下游 Agent 能力标签体系还不完善时,由上游业务方显式声明本次任务需要的技能,相当于给路由算法一个提示。timeout 和 max_retries 决定了任务失败时的容忍度,不同任务差别很大,写代码的任务可能 60 秒才返回,检索任务也许 10 秒就够了,统一放在 Task 上可以让调度层做更精细的控制。
2.2 Agent 注册表:让系统知道谁能干什么
Task 定义了“需要做什么”,Agent 注册表则回答“谁能做”。我设计了一个轻量级的注册模型,存在 Redis 和本地 JSON 配置里,启动时加载到内存。
一个 Agent 注册项大概长这样:
{ "name": "docs-retriever", "capabilities": ["retrieval", "pdf", "summary"], "endpoint": "http://127.0.0.1:9101/run", "timeout": 90, "weight": 1.0, "input_schema": { "query": "string", "top_k": "integer" } }这里最容易踩坑的是 capabilities 的粒度。一开始我图省事,给一个 Agent 打了个“nlp”标签,结果所有跟文本有一点点关系的任务全部路由到它身上,其他 Agent 直接饿死。后来我把宽标签彻底拆掉,改成类似 keyword_extract、sentiment_analysis、summarization 这种不可再拆的细标签,路由准确率才明显上来。
input_schema 的作用是双重的。第一,校验请求参数,避免把缺失字段的脏数据发给下游 Agent。第二,可以作为自动生成提示词的依据,让下游 Agent 知道应该按什么格式返回结果。我实际体验下来,加上 schema 校验之后,因参数类型错误导致的执行失败减少了大概三分之一。weight 是手动配置的权重,用于业务倾斜,比如某台机器性能更好,想让任务优先分配过去,就把权重调高一些。
2.3 路由算法:匹配,但不只靠关键词
Agent-Reach 的路由算法没有直接用大模型,因为大多数场景下,基于标签和配置的规则就已经足够。我算分的方式很简单,但很有效。
def route_task(task, registry): best_agent = None best_score = -1.0 for agent in registry: if not agent.get("available", True): continue matched = set(agent["capabilities"]) & set(task.required_capabilities) if not matched: continue score = len(matched) * 2.0 + agent.get("weight", 1.0) if task.priority > 5: score += 1.0 if task.timeout <= agent.get("timeout", 30): score += 0.5 if score > best_score: best_agent = agent best_score = score return best_agent算法背后有几个判断逻辑。能力交集是硬条件,完全没有能力匹配的直接跳过。得分里匹配数量占大头,保证“会的技能多”的 Agent 更容易被选中,但后面几项加的是业务层面的约束:高优先级任务加分,确保它优先被分配;任务超时时间比 Agent 上限短,说明这个 Agent 大概率能扛得住,也加分。
最关键的取舍是不能只靠匹配数量。一个声明了十个能力的全栈 Agent,跟一个只声明两个能力但对这两个特别精通的 Agent 比,在单一任务上,后者往往更靠谱。所以我才引入超时匹配度和权重来做平衡。实际使用中这套规则很稳,只有任务语义严重模糊时才需要人工介入。如果你希望更智能,可以再加一层基于历史成功率的贝叶斯调整,但那是后话,建议先把基础规则跑起来。
2.4 编排与状态机:任务不是一锤子买卖
单任务路由相对简单,真正让 Agent-Reach 有用的是编排能力。日报场景里,检索、总结、推送三个步骤有先后依赖,前一个 Agent 的输出是后一个 Agent 的输入,调度层必须把它们串成一条链路。
我的编排模型基于一个简单 DAG。每个节点是一个 Agent 任务,边表示依赖关系。调度器从入度为 0 的节点开始执行,执行完的节点释放下游依赖。串行任务最容易,并行任务则用 asyncio.gather 同时发起,整体耗时取决于最慢那个 Agent,而不是排队相加。
每个节点都有自己的状态机。我从一开始就定了五种状态:pending、running、succeeded、failed、partial。partial 是我特别加进去的,用来表示“任务部分成功但结果还能用”的情况。比如检索 Agent 返回了 5 条结果,但其中两条超时了,这不算完全失败,下游总结还能继续做。如果没有 partial 状态,整条链路就会被一次部分失败直接打断,非常浪费。
状态流转通过 Redis 记录,每个节点的开始时间、结束时间、返回值摘要、错误信息都会写入。这样做的好处是,用户随时能查到任务到底卡在哪一步,而不是对着一个整体失败结果发呆。
3. 实操落地:从零搭一个 Agent-Reach 最小可用版本
3.1 技术栈选型与项目骨架
我选 Python 3.11 加 FastAPI,因为生态里跟 AI Agent 相关的库最多,接起来省事。Web 框架用 FastAPI 主要是冲着异步支持和自动生成 OpenAPI 文档,整个调度层跑在单个进程内的 asyncio 事件循环上已经够用,没必要一上来就拆微服务。
存储层用了 Redis,一方面做任务的中间状态存储,一方面做分布式锁,防止多个调度实例同时消费同一个任务。Redis Streams 在这里充当轻量消息队列,替代 Celery,因为我在实践中发现,对于每天几千任务量级的内部系统,Celery 的依赖和配置成本偏高,维护起来并不轻。
项目目录结构很简单:
agent_reach/ ├── api/ # FastAPI 路由 ├── core/ # Task、AgentMeta、状态管理 ├── routes/ # 路由与编排 ├── agents/ # 各类 Agent 适配器 ├── channels/ # 触达通道适配器 └── storage/ # Redis 操作封装这个结构从一开始就定下来,后面加通道、加 Agent 都是往对应目录里加文件,基本没有大重构。
3.2 接入一个 Agent 的标准协议
为了不让接 Agent 的人学习成本太高,我给下游 Agent 定义了一个极简协议:收到一个 POST 请求,请求体里是 task_id 和 payload,返回体里是 status、result、error。
{ "task_id": "a1b2c3", "payload": {"query": "Agent-Reach 的架构设计", "top_k": 5} }Agent 返回格式:
{ "status": "succeeded", "result": {"docs": [...]}, "error": null }协议越简单,接入方越愿意配合。我一开始想过用流式 SSE 往回调地址推结果,但很快发现这要求每个 Agent 都实现一个回调服务,接入成本瞬间提高。后来统一为同步请求加超时,配合状态轮询,复杂度和可靠性都更可控。
对于不支持 HTTP 的 Agent,我在适配器里做兼容,比如命令行工具就用 subprocess 调用,同一类型封装成一个 agent 类,对外暴露同样的 run 方法。这样上层调度完全不用关心具体的 Agent 实现方式。
3.3 路由与重试逻辑的关键实现
路由逻辑本身不难,难的是失败处理。外部 Agent 毕竟是独立服务,随时可能超时、重启或者遇到上游依赖故障。我的重试策略是带抖动的指数退避:
import random def next_retry_delay(attempt: int, base: float = 0.5, cap: float = 30.0): delay = min(cap, base * (2 ** attempt)) jitter = random.uniform(0, 0.1 * delay) return delay + jitterattempt 从 0 开始,重试间隔依次为 0.5 秒、1 秒、2 秒、4 秒,加上最大 10% 的随机抖动,避免多个任务因为重试节奏相同而同时打爆下游服务。这个方案在突发故障时效果很明显,抖动让流量在时间轴上散开,下游不至于在一个瞬间收到几十个重试请求。
重试计数放在 Task 上,每重试一次递减 max_retries,同时更新 Redis 里的当前状态。达到上限后,节点进入 failed。如果有备用 Agent,我会触发降级路由,把这个任务重新放入调度队列,让下一个候选 Agent 尝试执行。降级逻辑默认关闭,需要业务方显式开启,因为不是所有任务都能无缝交给另一个 Agent 处理。
3.4 触达通道的适配与降级
结果触达是 Agent-Reach 的另一半价值。所有触达消息统一封装成 OutboundMessage,包含 message_id、task_id、channel、content 和 createdAt。每个通道就是一个 adapter,实现 send 方法。
我接了几个常用通道:钉钉自定义机器人、企业微信群机器人、飞书机器人、邮件和 ntfy.sh。每个 adapter 内部只有几十行代码,核心工作是做内容格式转换和鉴权参数注入。比如钉钉机器人要求 content 是特定结构,企业微信又略有不同,统一在 adapter 里处理。
比较重要的是降级顺序。我给每个用户或任务配置主通道和备用通道,例如主通道是钉钉群机器人,备选是邮件。当主通道发送失败,比如 access_token 失效或触达限流,调度层会自动切到备用通道,并额外记录一条“通道切换”日志,避免用户以为系统发了但实际没发出去。这个降级逻辑挽回了不知道多少次因为机器人 webhook 被误删导致的日报丢失。
4. 常见问题与排查技巧实录
4.1 路由老是命中错误的 Agent
这是最早遇到的坑。现象是某个 Agent 的执行率特别高,但返回结果跟任务需求对不上。查到最后发现,它给自己声明了一个很宽泛的能力标签“nlp”,而几乎任何文本类任务都带这个标签,路由算法自然每次都选中它。
解决办法是把宽标签拆成不可再拆的细分能力,比如 keyword_extract、sentiment_analysis、text_summary,同时把这些细分标签固化到 Task 的 required_capabilities 里。拆完之后,每个 Agent 的负载趋于均衡,命中准确率明显改善。建议做注册表时提前约定标签规范,大家按统一词表维护,避免每个人各写各的。
4.2 Agent 超时但系统显示成功
这个问题藏得很深。起初我注意到一些任务显示成功,但实际返回内容是空的。排查发现,我的 HTTP 客户端没有设置超时,请求发出去之后就一直挂着,直到底层连接异常才结束。有些框架在这种情况下会把异常吞掉,再把半截内容当成正常结果返回。
修复方式是给所有外部调用显式设置 httpx.Timeout,并在适配器里区分“连接超时”“读取超时”和“响应超时”,分别记录不同错误码。顺带发现一个大坑:如果响应体没有完全读取,连接不能复用,也会体现成请求变慢。所以在适配器里我强制先完整读取响应内容,再判断业务状态码,绝不能只看 HTTP 200。
4.3 多端推送乱序和重复
触达层出现重复消息,多数是重试造成的。重试发送时,第一次请求其实已经成功,但客户端没收到响应,于是调度器又发了一次。解决这个问题需要在两个环节做幂等。发送端给每条 OutboundMessage 生成唯一的 message_id,消费端如果发现相同 ID 就丢弃。如果涉及顺序要求,比如日报要按时间切片推送,每条消息还要带一个 sequence,消费端侧做排序和去重。
4.4 结果上下文越滚越大,token 预算失控
编排链路里,上游 Agent 的输出会被拼进下游 Agent 的 prompt。链路一长,往往第一个 Agent 就返回 20 页文档,到最后一个 Agent 时,上下文长度已经远超模型限制。
我用了一个简单的 token 预算方案:在处理每个任务前,根据下游 Agent 的模型上下文上限,估算当前可用的 token 额度,上游返回内容按“结论优先、细节次之、日志丢弃”的原则做摘要截断。检索结果只保留每条文档的标题和前 200 字,代码执行结果只保留输出尾部 500 字。这套规则不完美,但至少避免了整条链路因为超限直接失败。如果你用的模型支持长上下文,可以适当放宽预算,但一定要给触达前的最终结果做一个最大长度限制,防止把一堆没用的输出推到群里。
4.5 问题排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 任务一直 pending | Worker 没启动或 endpoint 不可达 | 检查健康检查接口和注册表状态 |
| 路由命中错误 | 能力标签粒度太粗 | 拆分细标签,统一标签词表 |
| Agent 超时无结果 | HTTP 客户端未设置超时 | 显式设置连接/读取/响应超时 |
| 推送消息重复 | 重试导致重复投递 | 消息 ID 幂等 + 消费端去重 |
| 多端结果乱序 | 各通道回传顺序不一致 | 消息携带 sequence,消费端排序 |
| 上下文超限 | 全量拼接上游结果 | token 预算 + 摘要截断 |
| 单点 Agent 故障 | 无降级机制 | 开启备用 Agent 降级路由 |
5. 经验总结与后续扩展方向
5.1 几个容易被忽略的细节
第一,先定协议再写代码。Agent-Reach 能顺利接出十几个 Agent,主要原因是统一了任务结构和回调协议。如果一开始每个人各接各的,后期整合成本会高到想推倒重来。
第二,所有外部调用都要有超时和限流。Agent 服务和触达通道都是外部依赖,任何一个挂掉,不能让主链路无限等待。我前面提到的超时配置,是所有外部调用的底线。
第三,能落盘就落盘。Redis 虽然快,但重启会丢数据。任务的关键状态、触达日志、最终结果我都会定期同步到 SQLite 做持久化,Redis 只承担实时状态查询,这样即使 Redis 闪断,也能从 SQLite 恢复。
第四,日志一定要打全。从请求进来、路由决策、Agent 返回、触达结果,每一层都要打 trace_id 和耗时。没有这些日志,排查问题时只能靠猜,效率极低。
我记得有一次线上日报任务失败,用户反馈没收到推送。我在日志里看到触达层返回了“群机器人已被移除”的错误码,之前根本想不到会是这个原因,但因为有完整日志,五分钟就定位了。
5.2 后续可以怎么扩展
Agent-Reach 现在足够满足我的日常需求,但它有明确的扩展空间。首先可以引入 Agent 的自动发现能力,让 Agent 服务启动时通过注册接口把自己的能力描述和健康状态上报上来,避免手工维护注册表。其次是可视化工作流编辑器,让非技术用户可以通过拖拽定义编排链路,而不是改 JSON。第三是引入用户反馈信号,在触达层加一个简单的点赞/点踩按钮,把反馈数据灌回路由评分模型,让路由越用越准。最后,如果部署规模上来,需要做多租户隔离和成本统计,记录每个 Agent 的 token 消耗、调用次数和失败率,方便做资源分配和优化。
还有一个我很想做的方向,是让 Agent-Reach 支持流式结果回传。目前所有 Agent 都是同步返回完整结果,但长耗时任务体验不够好。如果改成 WebSocket 或 SSE 把每个 Agent 的阶段结果实时推给用户,用户体验会有质的提升。
最后分享一点个人体会。整个项目做下来,最值钱的不是那些路由算法或者状态机,而是“用标准化协议把分散的 AI 能力组织成一条可靠流水线”这套思路。现在我再处理日报、周报、定期检索这类重复性任务,只需要在 Agent-Reach 里配置一次编排,之后每天自动执行。如果你也要做类似的调度层,建议先把日志打全,从第一行请求进来到最后一行推送出去,每个环节都记录清楚,等出了线上问题,你会感谢这些日志。