从零构建Agent-Reach:多智能体统一触达与动态路由的工程实践 1. 为什么我会从零写一个 Agent-Reach智能体碎片化下的真实痛点过去大半年我所在的团队同时维护着三条业务线一个面向内部运营的智能问答助手、一个给客服坐席用的工单自动分类 Agent、还有一个正在灰度测试的自动化报表生成智能体。三条线一开始都是各自为战负责的同学按自己熟悉的套路接大模型 API有的直接调厂商原生 SDK有的绕了一层 HTTP 封装有的干脆在业务代码里拼 prompt。表面上看每个项目跑得都还行但等到我们要做统一体验、统一计费、统一观测的时候问题全冒出来了。最直接的问题是调用风格四分五裂。A 项目用流式 SSE 输出B 项目只接受同步 JSON 返回C 项目走的是 WebSocket 长连接。模型厂商也在变今天主力是某家的旗舰模型明天因为成本或效果原因要切到另一个开源模型的自部署服务结果每个项目都要改一遍底层调用代码。更麻烦的是不同 Agent 之间偶尔还需要互相调用——比如报表 Agent 发现用户问的是工单相关的问题它要把上下文转给工单 Agent。由于没有一套统一的协议这种转发全靠硬编码链路一深就断。当时也考虑过直接用市面上的 Agent 编排框架调研了一圈发现它们解决的是单体 Agent 的内部工具调用和规划问题很少有专门解决多个 Agent、多个模型服务之间如何统一触达和调度这个层面的事。我们要的不是又一套 Agent 运行时而是一个把所有 Agent 和模型服务串起来的触达层。这个层要做的事情很明确统一所有下游模型的接入协议、提供一套 Agent 之间互相转交任务的通用格式、把路由、重试、限流、成本统计全部收口到一处。于是就有了 Agent-Reach。这个名字里的 Reach 有两层意思一是触达——所有模型服务和 Agent 都能通过这一层被稳定触达二是可达到——不管底层模型怎么换上层业务代码不需要跟着改。项目定位不是替代现有的大模型框架而是在它们底下垫一层通用管道。这套东西适合谁如果你也遇到下面任一场景可以参考这篇文章的经验团队里同时接入多个大模型供应商有不止一个 Agent 应用在跑而且它们未来可能要互相协作想对模型调用的成本、延迟、成功率做精细化管理不想每次换模型都要翻出所有业务代码做手术。下面我把自己从设计到落地踩过的坑、绕过的弯连同关键代码思路一起整理出来你可以直接把它当成一份设计笔记来读。2. Agent-Reach 的整体架构接入层、路由层、编排层三层分离画架构图很容易真正难的是划清楚每一层到底该干什么。Agent-Reach 我改过三版最开始的版本试图把调度逻辑全部塞进一个进程里结果耦合严重后来推倒重来最终确定成三层接入层、路由层、编排层。2.1 接入层用统一适配器屏蔽所有模型服务商的差异接入层负责和具体的模型服务打交道。不管是 OpenAI 兼容接口、国内大模型厂商的开放式接口还是自己用 vLLM 部署的开源模型通通归这一层管。核心是一个 Provider 适配器接口。每个模型供应商实现这个接口向上一层暴露统一的chat_completion和stream_chat方法。上层不关心底层是 HTTP 还是 gRPC不关心鉴权方式不关心 token 计费公式。Python 伪代码大概是这个思路class BaseProvider(ABC): name: str # 供应商唯一标识如 openai / qwen / vllm-local model: str # 当前使用的模型名 abstractmethod async def chat_completion( self, request: UnifiedRequest ) - UnifiedResponse: 同步返回完整回复非流式 abstractmethod def stream_chat( self, request: UnifiedRequest ) - AsyncIterator[UnifiedStreamChunk]: 流式返回回复片段这套适配器设计里有一个当年踩完坑才加上的约定必须由 Provider 内部负责把供应商的错误码转换成统一错误码。举个例子A 厂商限流返回 429B 厂商因为网络抖动也返回 429但 C 厂商限流返回 503。如果错误码不归一路由层做重试和熔断时会误判。统一之后所有上游代码只需要认四类错误RateLimited、Timeout、BadRequest、ProviderUnavailable。2.2 路由层按意图、成本和可用性做动态分发路由层解决的是这次请求到底发给谁。在 Agent-Reach 里路由层不是一个简单的 if-else 分发器而是一个带评分机制的决策器。每个模型服务注册进来时可以配置若干评分维度能力维度模型擅长什么任务类型代码、数学、通用对话、结构化抽取成本维度每千 token 的价格质量维度历史请求的成功率、平均延迟业务约束某些业务线只允许把数据发到内部部署的模型服务一次请求进来后路由层根据请求元信息任务类型、预算上限、合规要求、期望延迟对候选模型服务打分取最高分者。分数相同则按照权重轮询。配置大概是这个样子routers: default: strategy: weighted_score candidates: - provider: vllm-local model: qwen2.5-72b-instruct score_bonus: 10 allowed_tasks: [general, code, extract] - provider: openai model: gpt-4o-mini score_bonus: -5 allowed_tasks: [general, extract] max_cost_per_call: 0.02 fallback_chain: - provider: openai model: gpt-4o-mini这里的fallback_chain很重要——当首选候选连续失败或超时时请求会自动沿降级链往下走。比如本地自部署服务挂了自动切到云端轻量模型保证业务不中断。我见过不少团队的方案是让业务代码自己写 try-except 切换模型在 Agent-Reach 里这个逻辑收进路由层后业务侧干干净净。2.3 编排层Agent 之间转交任务的通用契约编排层是 Agent-Reach 和普通 API 网关最大的区别所在。API 网关只管转发请求和响应但 Agent-Reach 需要处理一个 Agent 处理到一半发现应该由另一个 Agent 接棒的场景。为此我设计了一套 Agent Transfer Protocol核心是一个带handoff标记的消息信封。一个 Agent 发起转交时需要把目标 Agent 名称、当前上下文摘要、未完成目标、需要带过去的用户原始请求全部装进信封里。{ protocol_version: 1.0, message_id: msg_20931adf, flow_id: flow_9f0a12, source_agent: report-agent, target_agent: ticket-agent, reason: 用户问题超出报表范围需要工单系统信息, context: { truncated_dialog: [], extracted_entities: {user_intent: 查询工单}, tokens_used: 1120 }, payload: { user_request: 帮我查一下工单 T-1024 的处理进度, additional_hints: {} }, created_at: ... }flow_id是整个协作流程的唯一标识从第一个 Agent 收到请求起生成后续所有转交都复用这个 ID。这个字段救了我无数次——排查线上问题全靠它把分散在多个 Agent 里的日志串成一根线。编排层还要负责工具调用的循环控制。Agent 调工具、工具返回结果、Agent 再思考、再调工具这本身是正常的。但模型偶发会陷入死循环比如反复调用一个查询工具而不给用户结论。Agent-Reach 在编排层强制设置了max_iterations和max_tool_call_seconds两个值推荐配置分别是 8 次和 120 秒。为什么是 8我们统计过线上 90% 以上的有效 Agent 任务工具调用次数在 5 次以内8 次已经留了足够余量超过 8 次还不收尾大概率是模型逻辑跑偏了不如直接停下来。3. Agent-Reach 核心实现的四个关键细节架构只是骨架真正决定一个触达层好不好用的是细节。这一节我挑四个在实际使用中影响最大的设计点每一个都是真金白银换来的经验。3.1 统一消息协议的字段设计少而全够用且不膨胀链路里每一层都要解析消息所以协议字段不能太多太多增加解析成本但也不能太少少了下游拿不到关键信息。Agent-Reach 的消息体最终只保留了六个必填字段和三个可选字段。必填字段字段含义说明message_id消息唯一 ID全局唯一用于追踪单次请求flow_id流程 ID跨 Agent 协作同一流程agent_id发送方 Agent标记消息来源session_id会话 ID用于多轮对话记忆type消息类型user/agent/tool/systemcontent消息内容结构化文本内容或工具调用指令可选字段主要是attachments、context_metadata、handoff_info。我特别想提醒的是不要在协议里塞业务字段。最初我把租户 ID、业务线 ID、用户等级这些都塞进了协议体结果协议体越来越大每经手一个 Agent 都要重新组装和透传非常容易漏。后来统一改为只放context_metadata这个 kv 容器业务属性往里面填协议本体保持稳定。3.2 动态路由的评分机制把拍脑袋变成可量化决策路由不能只靠人工配置权重因为模型服务的效果是在变化的。今天 A 模型在企业知识库问答上表现好明天可能因为版本迭代就变差了。Agent-Reach 的评分器会持续收集最近十分钟的成功率、平均首字延迟、p95 延迟、每千 token 成本按以下公式计算综合分综合分 0.35 * 成功率得分 0.25 * 延迟得分 0.20 * 成本得分 0.20 * 任务匹配得分每一项的原始值都会先做归一化处理。比如延迟得分是这样算的假设当前候选服务 p95 延迟为 2.8 秒池子里所有服务的历史 p95 中位数为 2.0 秒就以 2.0 秒为基准算相对得分。这个公式最大的特点是允许业务方调权重。比如内部知识助手更看中准确率可以把成功率权重调高如果是面向用户的聊天产品延迟权重拉高会更合适。权重实时修改路由层热加载配置不会中断请求。建议首次上线时先别开自动路由把所有服务都跑一段时间的旁路模式——请求仍按原规则分发但路由层同步计算每个候选的得分并记录日志。跑个三到五天你会看到某些模型服务的实际表现和业务预期完全相反这时候再根据真实数据调整权重会踏实很多。3.3 工具调用循环控制别让模型钻空子我前面提到了max_iterations这里展开讲讲实现时容易忽略的细节。每次工具调用返回后必须重新进入模型推理。这个循环在 Agent-Reach 里由一个ToolLoopManager管理它会检查以下三个终止条件模型返回给用户的最终回复而非工具调用指令累计工具调用次数达到max_iterations工具执行总时长达到max_tool_call_seconds第三个条件非常关键。有些工具本身执行就很慢比如报表 Agent 要查几千万行的数仓数据一个 SQL 跑 40 秒很正常。如果只限制调用次数不限制总时长Agent 会在这一个工具上干等很久。Agent-Reach 的做法是给每次工具调用单独设超时超时后返回一个特殊的tool_timeout结果给模型让模型决定是换一种方式查询还是告知用户暂时无法获取。为了让这个限制更有效我还加了重复工具调用检测。模型偶尔会傻傻地用一个完全相同的入参反复调用同一个工具Agent-Reach 会对比本次调用和上一次调用的参数签名如果完全相同且已经有结果直接拦截并提示模型该工具已用相同参数调用过结果为 X请勿重复调用。这个机制上线后无效工具调用占比下降了约 30%。3.4 上下文窗口管理与 Token 预算长会话不崩的底层保障Agent 做多轮对话时上下文会越来越长。如果不加控制要么爆掉模型的上下文窗口要么费用飙升。Agent-Reach 在编排层有一个ContextWindowManager它的职责不是简单截断而是分层压缩。具体策略是系统提示词和 Agent 的角色设定永远保留这是不可压缩层最近两轮用户消息和 Agent 回复完整保留这是信息密度最高的层中间历史消息滑入压缩层——调用一次轻量模型做摘要把摘要替换进上下文超过保留阈值的更早消息直接淘汰但完整消息会持久化到存储用户要求回溯时可以加分页加载这里容易踩的坑是压缩时机的选择。如果每轮都做摘要单次延迟多出几百毫秒体验很差。Agent-Reach 的做法是设定一个summarize_threshold默认 12 轮只有当历史超过 12 轮且剩余 token 预算不足 30% 时才触发压缩。压缩摘要本身也走路由层所以可以选择便宜的小模型来做控制成本。Token 预算在请求刚进入时就锁定。比如总预算 8000 token系统提示词占 1500历史保留占 3000留给当前轮模型的只有 3500。模型如果在这一轮里还要调工具工具返回结果也要占预算。预算耗尽时Agent 会直接停止新的工具调用用已有信息生成回复。我建议把预算锁定逻辑做成显式的因为模型本身不知道自己的 token 额度如果不告诉它它会一直尝试调用导致超限。4. 上线前踩过的坑从协议嵌套到并发风暴Agent-Reach 不是一次就稳的。上线前测试阶段我们准备了很完整的用例结果还是在灰度第一周炸了四个幺蛾子。这里把排查链路完整记录下来比直接给方案更有价值。4.1 嵌套 Agent 丢掉链路 ID灰度第二天值班同学发现日志里出现大量flow_id is null的报错。第一反应是编排层生成 ID 的代码有问题查了一圈发现生成逻辑没问题问题出在嵌套转发。场景是这样的A Agent 转交任务给 B AgentB 在内部又调了 C AgentC 处理完返回给 BB 再返回给 A。真相是 B Agent 转发给 C 时没有透传flow_id而是自己重新生成了一个。看起来是小问题但链路追踪直接断掉——从 A 的角度看B 返回的响应完全没有上下文关联。排查链路从告警平台拉出报错请求的 message_id用 message_id 反查网关日志确认请求确实进了编排层发现编排层分配给 B 的 flow_id 是flow_9f0a12但 B 返回时带的 flow_id 是另一个值定位到 B Agent 内部转 C Agent 的代码发现它调用的是 C 的独立入口而不是走 Agent-Reach 的transfer接口根因Agent 之间既有通过 Agent-Reach 的协作调用也有直接调用对方 HTTP 接口的死代码路径。修复方式是在接入层加了一条规定所有 Agent 对外暴露的能力都必须注册成可转交技能外部 Agent 只能通过技能调用入口访问禁止直接调 Agent 的业务接口。同时给 B Agent 的代码加了一行强校验——如果发现请求头里没有 flow_id直接拒收。这样问题立刻暴露而不是传递到下游。4.2 模型返回非法 JSON 的工具调用自主 Agent 离不开工具调用主流的模型服务商会以 JSON 块返回工具调用参数。测试时一切正常但灰度期间收到大量Failed to parse function arguments报错。一开始怀疑是模型厂商返回格式变了抓了几个现场请求对比发现确实是模型偶尔会返回截断的 JSON——参数多的时候中间某个嵌套层级被截断导致 JSON 不合法。模型自己是不会主动告诉你我输出坏了的。这个问题的修复分两步。第一步在适配器里对工具调用参数做容错解析def safe_parse_function_args(raw: str) - dict: # 第一次尝试标准解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 尝试补齐缺失的右括号 fixed raw.rstrip() } * estimate_missing_braces(raw) try: return json.loads(fixed) except json.JSONDecodeError: # 最兜底的策略提取最外层大括号内容 start, end raw.find({), raw.rfind(}) if start 0 and end start: return json.loads(raw[start:end1]) raise ToolCallFormatError(raw)第二步是前端兜底。如果解析失败且无法修复编排层会向模型返回一条错误消息工具调用参数格式错误请重新给出完整参数并允许模型重新生成。实测大约一半的模型会在下一轮修正输出。这个方案不是完美的——模型修正过程中会额外消耗 token但相比直接让整个任务失败这点成本是值得的。后来我也在配置里加了可选项对严格生产场景工具参数解析失败时直接切换候选模型重试一次因为有些模型对复杂 JSON 的生成稳定性就是差一些。4.3 路由预热与多区域延迟第三周做了一次 10 倍流量压测压测前信心满满结果流量刚上来路由层的 p99 延迟从 30ms 飙升到 900ms。后来定位到是路由层的模型服务健康检测拖了后腿。路由层每隔 5 秒会对候选服务做一次健康探测。设计时图省事健康探测用的是同步 HTTP 请求而且探测的等待时长设置得很长3 秒。流量低时没什么影响流量一上来路由线程全被健康探测阻塞了。修复方案健康探测改为异步并发所有候选服务同时探测而不是一个一个排队探测超时从 3 秒降为 1 秒超过 1 秒未响应视为不健康健康状态改为本地缓存 后台定时刷新的模式路由转发线程永远只读缓存不直接发起探测改完之后压测 p99 回到 35ms 左右。另有一个隐藏收益因为健康状态有缓存路由决策本身变成了纯内存计算即使下游模型服务集体抖动路由层的响应时间也纹丝不动为降级争取了时间。4.4 并发重试导致的雪崩这个问题排在所有坑的第一名处理不好会直接把整个服务打挂。某天晚上模型供应商那边网络抖动大量请求超时。Agent-Reach 路由层自带重试机制超时自动换候选服务重试。一切设计得挺好但当晚我们发现核心服务的 CPU 和内存齐刷刷往上飙最终触发保护性熔断一大片业务不可用。事后分析根因是重试风暴第一批请求超时后每个请求都触发了 3 次重试重试又赶上供应商网络抖动持续重试也超时。这一个请求在路由层最多会产生 4 次真实的模型调用而路由层对总并发没有做限制导致下游被打穿。修复核心是两板斧第一板斧是重试改成指数退避 全局限流。退避时间基数为 500ms每次退避乘 2最多重试 3 次。同时引入信号量控制路由层的整体在途请求数和单候选服务的并发上限。class RouteSemaphore: def __init__(self, max_inflight200, max_per_provider80): self._global asyncio.Semaphore(max_inflight) self._providers defaultdict(lambda: asyncio.Semaphore(max_per_provider))第二板斧是熔断半开机制。当某个候选服务连续错误次数超过阈值比如 20 次路由层进入熔断态直接把请求分发到别的候选不再尝试该服务。熔断持续 30 秒30 秒后允许少量请求比如 10%试探测探测成功则逐步恢复正常流量。这套机制上线后再也没有出现过重试风暴。5. 可观测性建设Agent-Reach 的请求全链路追踪落地Agent-Reach 本身是一个中间层如果它对业务透出的观测能力不好用大家用几天就会抛弃它。我在设计之初就把可观测性当成一等公民而不是事后补丁。5.1 结构化日志AgentEvent 事件模型传统业务日志按行记录但 Agent 系统是异步的、多跳的一条线性的 error 日志根本没法看。Agent-Reach 的日志全部改成结构化事件AgentEvent{ event_type: route_selected, flow_id: flow_9f0a12, message_id: msg_20931adf, provider: vllm-local, model: qwen2.5-72b-instruct, score: 87.5, cost_usd: 0.0031, latency_ms: 812, timestamp: ... }每个事件只描述一个原子事实比如路由选择完成、工具调用开始、Token 压缩触发、转交发起。查询问题时用 flow_id 把所有事件拉出来整个思考过程一目了然。5.2 四个核心指标成功率、延迟、成本、压缩率Dashboard 上我只保留了四个核心指标这是踩过指标过多反而没用的坑后精简出来的请求成功率按 Agent、按模型服务、按任务类型三个维度拆解端到端延迟除了总延迟还要分首字延迟TTFT和完整回复延迟。Agent 工具调用多的任务完整回复延迟高是正常的首字延迟高才是需要报警的单次任务成本除了模型 token 费用还要把工具调用涉及的内部服务成本折进去上下文压缩率模型实际使用的 token 数 e 如果不压缩会使用的 token 数之比这个指标直接反映 ContextWindowManager 的效果这四个指标每个都接了告警。成功率低于 99% 告警p99 首字延迟超过 2 秒告警单任务成本超过预设阈值告警。压缩率暂时不接告警但每月看一次趋势连续走低说明摘要模型效果在退化。5.3 Debug 回放模式让 AI 自己解释为什么这么决策这是 Agent-Reach 最让我意外的真香功能。有一次某个 Agent 莫名其妙把请求路由到了一个我们不希望它选的模型当时看日志半天没看明白。后来我给路由决策加了 debug 回放把一次请求从进入 Agent-Reach 到最终路由的所有输入因子候选服务实时得分、权重配置、任务类型标签、成本预算全部记录下来在排查平台里生成一个决策回放卡片。排查的时候打开卡片就能看到这次路由是因为 A 服务延迟得分 82 被选中B 服务质量为 95 但成本得分只有 40 因此落选。写这个功能的初衷是给路由调参提供依据结果成了线上问题排查最有用的工具。现在无论哪个 Agent 出了诡异问题第一反应都是先拉回放卡片。实现并不复杂路由评分器每次打分后把每个候选的每个维度的得分、权重、归一化中间值都序列化到事件里挂到同一个 flow_id 下面。就是多写几十行日志的问题收益却非常大。6. 接入 Agent-Reach 前后的对比这些数据让我觉得重构值得项目上线三个月后我做了一次系统性的效果复盘挑几个有代表性的数据维度接入前接入后新增一个模型服务接入耗时2~3 人日每个业务都要改0.5 人日写一个适配器模型切换影响范围所有调用方代码都要改改一行配置线上出错定位耗时30 分钟~半天靠猜5 分钟内看 flow_id 回放模型调用成本无统一统计月度账单对不上按 Agent、任务、部门逐层拆解单任务工具调用次数无限制偶发死循环有上限和拦截无效调用降 30%高峰期下游故障影响直接打满业务超时熔断降级成功率维持在 99.5%7. 后续规划多模态触达、费用治理与联邦场景Agent-Reach 目前已经稳定支撑了五个内部 Agent但我很清楚它还能往哪走。7.1 多模态触达把图片、语音纳入统一消息模型现在的消息模型基本还是围绕文本设计的content字段装的是文本。报表 Agent 下一步要生成图表客服 Agent 要接收用户发送的截图这些都需要在协议层面支持附件和媒体对象。计划是在attachments字段上做结构化扩展每种附件类型图片、音频、表格文件定义统一的元数据和内容访问接口底层模型服务由适配器决定怎么消费——有些模型原生支持多模态输入有些需要走 OCR 预处理。7.2 费用治理从统计到干预目前的费用统计是事后的。下一步要做的预算是预花费预估请求进入时路由层根据候选模型的 token 单价和当前上下文长度提前计算这次请求的预计花费。如果超出业务线预算直接在入口拒绝或者在路由评分时压缩成本权重把请求导向更便宜的模型。这样费用治理就从月底看账单懊悔变成每个请求都花在明处。7.3 联邦 Agent 场景跨团队智能体协作的权限边界多个团队各自维护自己的 Agent未来不可避免地要互相调用。Agent-Reach 下一步要补充跨团队协作的权限模型每个 Agent 在注册时要声明对外可暴露的技能和允许访问的 flow 范围协议层在 transHandle 时强制校验权限而不是把安全完全寄托在调用方自觉。个人经验是权限模型要尽早做。我们之所以能把 Agent-Reach 稳定跑起来很大程度是最开始就确定了 flow_id 贯穿所有协作链路。往后接入跨团队能力时只要在这个链路上挂权限校验钩子就行不需要推倒重来。如果你正在做类似的事情我还是那句话不要一上来就写复杂的编排逻辑先把统一接入做扎实。一条干净的适配器、一套稳定的错误码、一个贯穿全局的流程 ID这三样东西做对了后面的路由、编排、观测都只是叠加。反过来如果这三样基础没打好任何花哨的功能都会变成线上事故的温床。