
如果你也在做 Agent 相关项目大概率遇到过这个问题大模型能聊天能写代码但真要让 Agent 去把某个外部系统里的事办了——发一个工单、推一条通知、触发一次构建——你就得自己从头搭一套调用、鉴权、重试、超时、日志的链路。Agent-Reach 这个项目就是我在这个场景下折腾出来的一个触达层方案。它的思路很直接把 Agent 和外部世界之间的连接从每个 Agent 各写各的收敛成统一规划、统一调度、统一观测。这篇文章我打算把这套东西的架构思路、核心代码、关键参数设定和踩坑记录全部摊开说给大家一个可以直接参考的落地版本。1. Agent-Reach 到底在解决什么问题1.1 从能对话到能办事的鸿沟现在聊 Agent很多演示停留在对话很流畅的阶段。但实际生产环境里Agent 的最终价值取决于它到底能触达多少系统、完成多少闭环动作。比如我们内部做过一个售后场景 Agent用户说我要改地址Agent 得听懂语义然后去 CRM 改订单、去物流系统通知仓库、再往企业微信回一条确认消息。这三步里任何一步断了整个用户体验就崩了。问题在于接一个系统就要写一套代码。接 CRM 要研究它的 SOAP 接口接物流系统要处理它的表单签名接 IM 要处理 webhook 频率限制。一个 Agent 接三个系统代码已经乱得不行了。接五个系统基本上没人敢重构。这还不算后续的鉴权过期、接口升级、限流报错——每一处都是劝退点。Agent-Reach 解决的就是这一层把触达做成一个独立的基础设施。Agent 只需要说帮我触达某某系统、做某件事剩下的鉴权、协议转换、重试、超时、幂等、日志全部交给中间层处理。这样业务 Agent 只关心语义触达层只关心连通性各司其职。1.2 它的核心思路统一触达协议Agent-Reach 的核心是定义了一套触达请求的统一格式。不管你背后接的是 REST API、gRPC、还是内部的 RPC 框架外部的 Agent 看到的永远是一个标准化的请求结构。这套结构包含三个核心字段target目标系统标识比如crm、wms、feishu-webhookaction动作名称比如update_address、create_ticketpayload业务数据一个 JSON 对象随 action 不同而变化Agent 端不用关心target底层是 HTTP 还是别的它只需要知道target action payload这三个维度。触达层拿到请求后先查注册表找到该 target 对应的适配器再做协议转换、动态鉴权、超时控制最后把响应标准化返回给 Agent。这套抽象的价值不在于设计得多优雅而在于让接入成本变得可控。新接一个系统的时候你不需要动任何一个上层 Agent只需要在 Agent-Reach 里新增一个适配器然后挂到 target 映射表上。这对多 Agent 共存的系统尤其重要——你不是在给一个 Agent 做集成是在给所有 Agent 做集成。1.3 为什么取名 Agent-Reach名字我很早就定了Reach是触达、覆盖的意思。Agent 再聪明触达不到世界也是白搭。这个项目的本质就是给 Agent 装一张手和脚让它能把想法变成动作。另外从架构演进的角度看Agent-Reach代表的是一层中间能力它不属于某个 Agent而是服务于所有 Agent是一种水平扩展的基础设施。这个定位决定了它的设计优先级必须是可插拔的、可观测的、可独立部署的。2. 核心架构我把触达层拆成了四块2.1 消息接收器Agent 怎么把请求交进来第一块是入口。Agent-Reach 的消息接收器同时暴露了两类入站协议一类是 HTTP REST 接口供同步请求使用另一类是消息队列消费供异步任务使用。同步接口适合Agent 等结果的场景比如查询库存异步队列适合发出去就不管的场景比如发通知、开工单。我在设计的时候特意让两者共用同一套请求模型。也就是说Agent 发送的报文格式是一样的只是投递方式不同。同步模式走 HTTP 接口拿返回值异步模式投递到 Redis Stream之后通过回调或者轮询拿结果。这样做的好处是上层 Agent 在切换场景时不需要改序列化逻辑只是改一个调用的传输层。Redis Stream 是我用下来比较顺手的选择。当时也考虑过 Kafka但部署维护成本对一个小团队来说偏高而且这个量级的数据不足以用到 Kafka 的分区能力。Redis Stream 的优势在于轻量可靠消费组机制足够扒得住多 Agent 并发的场景。2.2 路由与调度引擎怎么找到对的处理器第二块是路由。这是 Agent-Reach 里花心思最多的地方。每个传入的请求都要经历两个阶段的匹配第一阶段是 target 匹配。触达层拿到请求里的target字段先去注册表里查找对应的目标配置。如果匹配不到直接返错。第二阶段是 action 匹配。同一个 target 下可能有多个 action每个 action 会绑定一个具体的处理器模板里面写清楚该调哪个接口、参数怎么映射、超时多少秒。这个两段式路由的灵感来自 HTTP 路由器设计URL 路径先匹配 Controller再匹配 Method。Agent-Reach 的映射关系也存在本地缓存里配置变更后通过版本号触发热更新不需要重启服务。路由引擎的另一个职责是条件分支。比如一个订单系统可能有普通订单和加急订单两种处理链路触达层允许在 action 的配置里加when条件用类似 JSONPath 的表达式去匹配 payload 里的字段然后动态决定走哪条适配器。这样 Agent 不需要自己写 if-else把判断逻辑下沉到配置层。2.3 适配器层每种系统一个翻译官第三块是适配器。这是和外部系统真正打交道的地方也是所有脏活累活集中营。每个适配器是一个独立的 Python 模块约定好输入输出接口。输入的永远是一个标准触达请求输出的永远是一个统一响应对象。至于内部经过了什么——签名、拼 XML、上传文件、轮询异步任务结果——都是适配器内部的自由发挥。我维护过的一个典型适配器是crm-http它封装了 CRM 系统的 REST 接口内部处理 token 获取、过期刷新、请求签名、错误码翻译。另一个是feishu-msg封装了飞书 webhook 的发送逻辑负责处理限流和消息切割。这样拆分之后新增一个系统就是写一个新的适配器然后注册到配置表里不碰任何上层代码。适配器层还承担了一个重要任务响应标准化。外部系统返回的千奇百怪——有的返回 200 但实际业务失败有的返回错误码但藏着业务数据——适配器统一收敛成三态结构success、business_failed、system_error。Agent 拿到结果后只需要看这个状态不需要解析各家系统的错误格式。2.4 观测模块看不见的东西往往最致命第四块是观测。Agent 触达外部系统的过程中最痛苦的排查场景是什么Agent 说自己做完了但下游系统说没收到。两边对不上中间那一层如果又没有日志那就真的变成悬案了。Agent-Reach 从第一版起就在观测上做了三件事。第一全链路追踪每一次触达请求生成一个 trace_id从进入系统到适配器调用结束全程打点。Agent 的请求参数、路由命中结果、适配器耗时、返回状态全部记录在结构化日志里。第二指标采集触达成功率、平均耗时、P95 耗时、超时次数、重试次数这些数据每分钟聚合一次打到 Prometheus。第三审计留存谁在什么时间触达了哪个系统、传了什么参数完整记录满足内部合规要求。这里的经验是观测能力一定要在第一天就做不能等出问题再补。没有 trace_id 的历史日志事后很难还原现场。Agent-Reach 在观测上的投入后来在 N 次排查中都成了救命稻草——这个下面会细说。3. 跟着我做一个最小可用的 Agent 触达流程3.1 环境准备一把梭子搭起来Agent-Reach 的运行依赖不复杂Python 3.10、Redis用于 Stream 消费和缓存、一个可选的 PostgreSQL用于审计日志落库。我通常在本地用 Docker Compose 把这套拉起来日常调试非常方便。核心依赖只有几个# requirements.txt fastapi0.110.0 uvicorn[standard]0.29.0 redis5.0.3 pydantic2.6.4 pyyaml6.0.1 httpx0.27.0 prometheus-client0.20.0FastAPI 负责同步 HTTP 入口Redis 的 Stream 负责异步队列httpx 是适配器层发起外呼的 HTTP 客户端异步方式避免阻塞事件循环。这里有几个选型原因值得说用 httpx 而不是 requests是因为 Agent-Reach 的瓶颈几乎全部在 IO 等待上——网络调用、外部系统响应——异步 IO 能让单进程撑住很高的并发量用 Redis Stream 而不是直接用 List是为了天然支持消费组和消息 ACK做失败重试更方便。启动入口文件保持极简# main.py import uvicorn from fastapi import FastAPI from agent_reach.entry import router as reach_router app FastAPI(titleAgent-Reach) app.include_router(reach_router, prefix/reach) if __name__ __main__: uvicorn.run(main:app, host0.0.0.0, port8000, workers2)两个 worker 是因为这个服务本身是轻量的CPU 主要消耗在路由匹配和序列化上不是真正的瓶颈。真正吃资源的适配器调用都在 IO 等待上异步模型下两个进程足够应付初期压力。3.2 注册 Agent 和配置目标一切皆 YAML所有接入配置我用 YAML 管理放在targets/目录下。新建一个目标系统的配置就是新建一个文件。以一开头提到的售后场景里的 CRM 为例# targets/crm.yaml name: crm description: 客户管理系统的统一触达入口 actions: - name: update_address adapter: http_json endpoint: https://crm.example.com/api/v2/customer/address method: PUT timeout_sec: 5 retry: max_attempts: 3 backoff_sec: [1, 2, 4] when: payload.priority normal - name: update_address_urgent adapter: http_json endpoint: https://crm.example.com/api/v2/customer/address/urgent method: PUT timeout_sec: 3 retry: max_attempts: 2 backoff_sec: [1, 1] when: payload.priority urgent这里有几个参数我要重点说下。adapter: http_json是内置的通用 HTTP 适配器它只做一件事把请求转成 JSON 发出去再把响应转成标准三态结构。这是目前使用频率最高的适配器。遇到特殊协议时你可以写自定义适配器再在 YAML 里填成自己的名字框架会自动加载对应 Python 类。timeout_sec的设定有讲究。一开始我把所有接口的超时都设为 10 秒后来发现一个问题某些慢接口会占用大量连接资源导致服务整体被拖慢。现在我的策略是宁可先超时也不要等太久。普通查询接口 5 秒写操作接口看场景 5-10 秒超过这个值直接就降级了。retry.backoff_sec用的是指数退避加抖动。为什么用到[1, 2, 4]而不是固定 1 秒因为外部系统抖挂通常不是单机问题而是网络或配置类的瞬时故障如果所有请求都同时重试会给下游造成一波集中的重放流量。错开重试时机能有效减少这种冲击。注意新增改地址这类非幂等写操作谨慎开启重试或者必须在接收端做幂等校验否则一次重试可能产生两条重复工单。Agent-Reach 的通用 HTTP 适配器默认只在以下情况重试网络连接异常、HTTP 502/503、超时。收到 4xx 业务错误一律不重试避免放大问题。3.3 写一个自定义适配器承接私有协议如果目标系统不走常规的 JSON HTTP就需要自定义适配器。举一个典型的例子内部的工单系统接口要求先在 Header 里带时间戳签名签名算法是 HMAC-SHA256直接用它封装一个定制适配器。# agent_reach/adapters/ticket_ws.py import hashlib import hmac import time from typing import Any import httpx from agent_reach.base import BaseAdapter, Request, Response, Outcome class TicketWSAdapter(BaseAdapter): 工单系统的自定义适配器处理 HMAC 签名与轮询逻辑。 async def invoke(self, request: Request) - Response: # 第一步带签名发起创建工单请求 ts str(int(time.time() * 1000)) payload { title: request.payload.get(title), desc: request.payload.get(desc), assignee: request.payload.get(assignee), } sign hmac.new( byour-secret-key, f{ts}{payload[title]}.encode(utf-8), hashlib.sha256, ).hexdigest() headers {X-Timestamp: ts, X-Signature: sign} async with httpx.AsyncClient() as client: resp await client.post( https://ticketing.example.com/api/ticket/create, jsonpayload, headersheaders, timeout5, ) if resp.status_code ! 200: return Response(outcomeOutcome.SYSTEM_ERROR, error上游返回非200) ticket_id resp.json().get(ticket_id) # 第二步工单系统是异步受理的需要轮询状态 for _ in range(10): await asyncio.sleep(1) async with httpx.AsyncClient() as client: status_resp await client.get( fhttps://ticketing.example.com/api/ticket/{ticket_id}/status, timeout3, ) if status_resp.json().get(status) created: return Response(outcomeOutcome.SUCCESS, data{ticket_id: ticket_id}) return Response(outcomeOutcome.BUSINESS_FAILED, error工单创建超时未确认)这块代码的核心是你怎么处理的外部系统特殊逻辑Agent 完全不知情。Agent 发一个ticket.create请求适配器内部去签名、发请求、轮询、确认。如果这个过程中断了Agent 收到的只是一个标准化的业务失败响应不涉及任何协议细节。自定义适配器要遵循三个约定类名要和 YAML 里填的adapter字段一致要继承BaseAdapter并实现invoke(self, request: Request) - Response函数内部保证异常捕获并返回标准的Response三态结果。这三点保证了插拔的流畅性。3.4 异步链路投递消息队列不阻塞主流程比较重的动作比如批量通知、导出报告不要让 Agent 一直等到结果用异步模式更舒服。Agent-Reach 把异步链路封装成了一条独立 APIAgent 先把触达请求 POST 到/reach/async服务端立刻返回一个task_id然后内部把请求发到 Redis Stream 的reach_tasks队列。# agent_reach/entry.py router.post(/async) async def submit_async(request: ReachRequest): task_id str(uuid.uuid4()) payload request.model_dump() payload[_task_id] task_id await redis_client.xadd(reach_tasks, payload) return {task_id: task_id, status: queued}消费端跑一个常驻 worker从队列里拉取任务执行# agent_reach/worker.py async def consume(): while True: messages await redis_client.xreadgroup( reach_workers, reach_tasks, count10, block5000, ) for msg_id, data in messages: try: req ReachRequest(**data) response await dispatcher.dispatch(req) await store_result(data[_task_id], response) await redis_client.xack(reach_tasks, reach_workers, msg_id) except Exception as exc: # 记录异常并另行处理不能阻塞消费循环 logger.exception(task execution failed: %s, exc)这里要注意的一点消费循环里如果某条消息处理失败不要让整个 worker 挂掉。我的做法是把异常信息单独存到一个失败缓冲区后续通过补偿任务重放。消费循环本身始终保持存活这样才能保证队列不积压。3.5 幂等与去重消息不丢不重才算数异步系统里重复消费和丢失消息是两个极端都必须防。丢消息靠 ACK 机制兜底重复消息靠幂等设计兜底。Agent-Reach 的幂等策略是外部业务标识 内部执行记录双层保证。Agent 在请求里带一个idempotency_key触达层先把(target, action, idempotency_key)作为组合键写入 Redis SETNX如果写入成功说明是第一次执行正常处理如果写入失败直接返回上一次的执行结果。这套机制我第一次上线的时候没做结果某个通知类 action 在 Redis 消费重启期间产生了大约 1.2% 的重复消息用户收到了两条一模一样的通知投诉立刻来了。后来补上 SETNX 幂等键之后重复问题基本清零。注意幂等键的过期时间要根据业务设置我通常设 24 小时——超过这个时间如果同一条消息再进来应该视为新请求。4. 实测踩坑记录这些问题文档里绝对不会写4.1 超时参数不能一刀切要按 action 分级第一个坑来自超时设置的粗放。一开始所有 action 都用 10 秒超时想着外部系统慢一点没关系。结果某次流量高峰CRM 的查询接口响应 8 秒几十个并发请求占满了整个连接池后面的消息全部排队。因为超时时间太长慢系统成了拖垮全局的系统。现在的做法是给每个 action 单独定义超时和降级策略。关键业务查询给 5 秒非关键路径给 3 秒超过直接返回暂不可用的系统错误。同时连接池大小设置了上限避免慢请求无限制占用。这个改动之后整体成功率反而提升了——因为快速失败让上游 Agent 可以立刻走备选方案比如提示用户稍后再试而不是无辜地等 10 秒后失败。4.2 业务失败和系统错误必须分清楚第二个坑和响应状态的定义有关。最初 Agent-Reach 的响应只有两种状态成功和失败。结果排查问题时发现很多失败其实是业务侧的拒绝——比如 CRM 返回该订单已关闭禁止修改地址。这个业务错误被适配器包装成了统一的失败状态Agent 收到后只能反馈操作失败了但根本不知道是系统故障还是业务规则导致的。后来我引入了三态结构success操作成功、business_failed业务逻辑拒绝比如权限不足、状态不允许、system_error系统异常比如超时、连不上。这三态在日志和返回报文里有明显的区分标识。Agent 收到business_failed时可以基于失败原因给用户一句解释该订单已关闭不能修改地址收到system_error则提示系统繁忙请稍后再试。这个改动极大减少了错误信息不可用的尴尬场景。4.3 下游重放考验的是你的幂等不是网络第三个坑是外围协作团队带来的。下游系统抱怨 Agent-Reach 在同一个时间点发了两个一样的请求。查监控确实看到重试记录但翻日志看根本不是网络原因——是我们自己的重试策略误判了。问题出在超时判定上某个接口实际处理了 4.8 秒我们的超时时间设了 5 秒请求在等待响应时被判超时触发了第一次重试但其实服务端已经处理完了只是响应晚了一点。于是下游就收到了第二份相同请求。解决办法有两个方向一是把所有写操作的重试次数降为 1 次二是必须在适配器端依赖下游的幂等接口。如果你的下游系统不提供幂等接口那么宁可不要重试也不能承担重复订单等业务风险。现在我在配置里对写类操作默认max_attempts: 1只有查询类操作才保留重试。4.4 慢 Agent 比慢接口更隐蔽最后一个坑来自 Agent 自身。Agent 在调用 Agent-Reach 之前通常会先调大模型做意图解析这一步可能耗掉 2-3 秒。如果 Agent 同步等待大模型返回后再来调 Agent-Reach整条链路会显得特别慢。这不是触达层的锅但用户感知是整体变慢了。我现在的方案是在 Agent 侧做意图预判 并行调用。Agent 先通过轻量分类模型判断意图同时异步触发大模型的详细推理如果需要调用系统直接在轻量分类给出高置信度时提前发触达请求大模型返回后再做校验。这个策略把售后场景的整体响应时间从 6 秒左右压到了 3 秒以内。Agent-Reach 这一侧能做的配合是把入口 API 的响应时间压到 10 毫秒以内不成为链路瓶颈。4.5 常见问题速查表症状可能原因排查手段解决方案触达成功率偏低超时时间过短看适配器耗时分布按 action 调整超时区分读写消息重复执行重试策略过于激进查重试日志、下游流水号写操作禁用重试或依赖幂等Agent 报系统繁忙连接池被打满看连接复用率限制并发、增加缓压策略日志里找不到请求链路缺少 trace_id 贯穿检查请求入口日志所有日志统一带上 trace_id 字段异步任务丢失消费失败后未 ACK查 Stream 的 pending 列表失败进入补偿队列延迟重放5. 后续聚合从单项目工具到团队基础设施Agent-Reach 目前已经不是一个单点工具。我把它的演进方向分成了三个阶段第一阶段是能用第二阶段是好用第三阶段是可治理。能用就是我前面讲到的完整触达流程好用已经在做了核心是让接入方成本进一步降低比如通过可视化配置界面去生成适配器模板可治理是更长期的目标。所谓治理首先是权限管理。生产环境里不是所有 Agent 都有权限触达所有系统。比如一个面向 C 端的售前 Agent不应该允许它去改内部财务系统。Agent-Reach 未来准备在请求入口加一层策略校验每个 Agent 有一个 tokens 身份标识策略引擎根据(agent_id, target, action)三元组判断是否放行。这个能力做出来后Agent 之间的权限边界才真正清晰。另一个方向是支持更动态的路由。当前的路由是配置文件写死的无法适应运行时的系统迁移和灰度发布。比如上线新版本的 CRM 系统希望在流量低峰期灰度切一部分请求到新系统。这就需要路由引擎支持按权重分流、按调用方分流并且在切换时记录审计日志。Agent-Reach 的路由设计可以做到给每个 target 加一个 active 标记和权重参数路由时按权重随机选择一个实例组。灰度发布期间一旦发现异常指标可以在不重启服务的情况下动态把权重降为零实现秒级回切。还有一个我最近在关注的 Agent 之间的触达。现在 Agent-Reach 处理的是 Agent 到系统但很多业务场景要求 Agent 之间传递任务。比如一个客服 Agent 发现客诉需要技术团队介入它应该能把任务转给技术支持 Agent。这种 Agent-to-Agent 的编排触达层可以提供一个agent_dispatch的内置 action把消息转交给另一个 Agent 的消息队列。这块做出来后Agent-Reach 就从系统连接器进化成智能体协作总线了。不过扩展归扩展我的原则是保持核心层的简单稳定。触达协议、路由、适配器这三层是地基不能频繁改动所有的新能力都通过新适配器和配置项去加而不是改变核心抽象。Agent-Reach 做了一段时间我的体会是Agent 的应用瓶颈从来不在模型聪明不聪明而在触达能力稳不稳。把触达这件事做成一个可靠的平台比给 Agent 换个更强的基座模型带来的实际体验提升要明显得多。希望这些踩坑和设计方案能帮同行们少走几步弯路。