Agent-Reach:解决 Agent 工具调用可达性断层的工程实践
2026/9/18 3:23:53 网站建设 项目流程

前阵子有个做企业内部助手的朋友找我吐槽,说他的 Agent 在演示环境里表现得像个全能选手,一问天气、查订单、发通知全都行;结果一接进真实内网,十个工具调用里挂了六个,剩下四个卡在超时重试里打转,用户等四十秒只等到一句"抱歉,我暂时无法完成"。这不是模型的问题,模型本身没变,变的是它脚下的路——那些 API 网关、鉴权服务、老系统接口,在演示环境里没人碰,在真实环境里全是坑。我把这类问题统称为"可达性断层":Agent 的决策能力已经够用了,但它够不着目标系统,或者说够着了却不知道对方已经半死不活。Agent-Reach 就是我为这件事抽出的一层东西,它不负责思考,只负责让 Agent 的手稳稳地伸出去、并且在伸不出去的时候知道该怎么退。如果你正在做 Agent 落地、工具调用编排、或者在维护一堆对内对外接口的中台,这篇内容应该能省你不少试错时间。

1. Agent-Reach 的项目定位与需求拆解

1.1 一个被低估的断层:模型会思考,但不代表它够得着

大部分 Agent 项目的演进路径都差不多:先接一个大模型,跑通对话;再接几个工具,跑通函数调用;然后做提示词工程,让模型学会在多工具之间选择。这条路径的问题在于,前三步全都发生在"模型视角"里——模型看到的是工具描述文本,它以为只要输出正确的参数就能拿到结果。可现实是,工具描述文本和真正的网络调用之间,隔着认证、限流、序列化、超时、重试、幂等、降级这一整套东西。模型不知道对端服务昨晚刚重启,也不知道这个接口在高峰期会从 200ms 抖到 8s,更不知道同一个业务能力其实有三个供应商可以互相顶替。

我在实际项目里做过统计,一次失败的工具调用里,真正因为模型参数给错导致的,占比不到两成;剩下八成是链路问题:证书过期、Token 没刷新、服务限流、连接池打满、上游返回了非预期的 HTML 错误页导致 JSON 解析炸掉。这些问题的共同点是,它们跟"智能"无关,纯粹是工程问题。而市面上大部分 Agent 框架把这块处理得非常轻,给个超时就完事了,重试策略、熔断、降级基本靠使用者自己写业务代码兜。结果是每个 Agent 项目都要重新踩一遍同样的坑,代码里塞满了try/exceptif status_code != 200

Agent-Reach 要解决的,就是把这八成的工程问题收敛到一个独立的、可复用、可观测的层里。它对外只暴露一件事:给我一个能力名和参数,我负责把它送到,并如实告诉你送到了没有、花了多久、走的哪条路。至于这个能力背后是 HTTP 接口、数据库查询、消息队列还是一次外部脚本调用,对上层 Agent 完全透明。

1.2 边界划清:Agent-Reach 做什么,不做什么

一个项目能不能长期维护下去,很大程度上取决于作者有没有把边界写清楚。我在设计 Agent-Reach 的时候给自己列了两张清单,第一张是"必须做":能力注册与发现、调用前的可达性判断、多供应商路由选择、超时与重试的统一策略、熔断与降级、调用链路的指标采集、失败原因的结构化返回。第二张是"坚决不做":不做提示词管理、不做对话状态维护、不做模型选择、不做业务语义校验。

第二张清单里最容易被误解的是最后一条,我特意解释一下。什么叫业务语义校验?举个例子,查订单接口要求传入的订单号必须是 18 位数字,这个校验应该在哪做?很多人第一反应是放在 Agent-Reach 里,因为它是统一的入口。但我坚持不放在这里,原因是业务规则变化太快,今天 18 位明天可能变 20 位,把它塞进一个基础层里,基础层就要跟着业务天天发版,这层就废了。我的做法是,Agent-Reach 只做"结构校验"——参数是不是 JSON 能解析、必填字段在不在、类型对不对,这些从能力描述符里就能推导出来的东西。至于这个订单号是不是真实存在、是不是属于当前用户,那是业务服务自己的事。

这种边界划分带来的一个直接好处是,Agent-Reach 的能力描述符可以完全由工具提供方自己维护。业务团队改了自己的接口,只要同步更新描述符文件,路由和校验逻辑自动跟着变,不需要改一行框架代码。我在三个不同规模的团队里推过这套东西,能不能让业务方自己维护描述符,基本决定了这层是活水还是死水。

注意:边界感是这个项目最容易被后来的维护者破坏的地方。一旦有人开始往这层里塞业务规则,半年之后它就会变成一个谁都不敢改的巨型中间件,那时候还不如当初不做。

2. 架构设计与关键选型考量

2.1 三层拆分:注册层、路由层、执行层各管什么

Agent-Reach 的内部结构我拆成了三层,拆分的依据是"变化频率"。变化最慢的放在最底层,变化最快的放在最上层。

注册层负责维护能力清单,也就是"系统里现在有哪些能力可用、每个能力由谁提供、怎么调用、有什么约束"。这一层的核心产物是一个能力描述符文件,我用 YAML 写,因为业务方看得懂、改得动,不需要他们会写代码。描述符的变更频率很低,通常一个能力上线之后几个月不动。

路由层负责在多个候选供应商之间做选择,并维护每个供应商的健康状态。这层的变化频率中等——熔断阈值、权重配置、优先级规则会随着容量变化调整,可能每季度动一次。它不关心具体的协议细节,只关心"这个供应商现在能不能用、用它的代价是多少"。

执行层负责把统一的调用请求翻译成具体的协议动作,HTTP 就是发请求、数据库就是拼 SQL 参数、消息队列就是发消息。这层的变化频率最高,因为总有新的协议要接,但它的每个适配器都很薄,通常一两百行代码就能搞定一个。

这么拆的好处是,新增一个协议类型的时候,只需要在执行层加一个适配器,注册层和路由层完全不动。而调整容量策略的时候,只改路由层的配置,另外两层也不用碰。我见过不少项目把这三件事揉在一个invoke()函数里,一开始很爽,等到要加第四个供应商的时候,那个函数已经八百行了。

2.2 为什么没有直接上现成的编排框架

这个决定我犹豫了很久,最后还是选择自己写。理由有三个,我觉得值得展开说说,因为很多人会在这里走弯路。

第一是依赖体积。主流编排框架为了覆盖各种场景,会带进来一大堆传递依赖,装完之后镜像大了两三百兆。Agent-Reach 本身的定位是一个常驻在业务进程旁边的轻量层,如果它自己就比业务服务还重,运维的第一个问题就是"这玩意儿能不能瘦一点"。我最后实现的版本,核心逻辑加上 YAML 解析,压缩后不到 200KB。

第二是语义匹配度。现成框架大多围绕"工作流"这个概念设计,节点、边、状态机,适合表达确定性的流程。但 Agent 的调用模式是高度动态的——模型可能这次调 A 下次调 B,可能一次调五个工具再综合。用工作流框架去套这种场景,会写出一堆动态生成节点的恶心代码,调试的时候根本看不懂跑的是什么。

第三是可观测性。这个是最关键的。我需要知道每一次调用的完整链路:从能力名开始,经过哪次路由决策,选中了哪个供应商,实际耗时多少,失败在哪一步,重试了几次,最后返回了什么。现成框架的埋点粒度通常到节点级别,而我要的是到"单次供应商尝试"级别的粒度。自己写,埋点想埋多细就埋多细。

当然自己写也有代价,最明显的是并发控制和连接池复用要自己处理。我踩的坑在第五节会详细讲。

2.3 能力描述符:把"能干什么"写成机器读得懂的清单

描述符是整个系统的地基,它长什么样直接决定了上层能玩出什么花样。我用的字段不多,但每个字段都有明确用途,下面这张表是核心的几个。

字段类型作用是否必填
namestring能力唯一标识,全局唯一,路由的入口
providerstring供应商标识,同一个能力可以有多个
protocolenumhttp / sql / mq / script
endpointstring具体地址或连接标识
priorityint优先级,数字越小越优先
timeout_msint单次调用的超时时间
retryobject重试策略,含次数与退避基数
params_schemaobject参数结构定义,用于结构校验
cost_weightfloat成本权重,用于同优先级下的选择
health_probeobject健康探测配置

这里有两个字段值得单独说一下。cost_weight是我后来加的,起因是发现有些能力同时有内部接口和外部付费接口,内部接口快但容量有限,外部接口慢但可以无限扩。光靠优先级没法表达"能用内部就用内部、内部扛不住就溢到外部"这种弹性需求,所以加了成本权重,让路由层能按权重做加权随机,而不是简单的顺序 fallback。

health_probe也不是一开始就有的。最初我以为调用失败本身就是最好的健康信号,后来发现不行。有些服务会"半死"——它能接受连接、能返回 200,但内部逻辑已经卡住了,每次返回的都是空结果。这种情况下按调用失败率来统计,熔断器永远不会触发。所以必须有一个独立的、轻量的探针,主动去戳它的核心依赖,看它是不是真的活着。

3. 核心模块的实现细节与坑点

3.1 能力注册:让业务方自己维护描述符

注册层我实现得非常薄,本质上就是"扫描目录下所有 YAML 文件,解析成描述符对象,存进一个内存索引"。但这个简单的动作里有几个细节决定了好不好用。

第一个细节是热加载。业务方改完描述符之后肯定不想重启服务,所以我做成了文件变更监听加 5 秒延迟合并的模式。为什么是 5 秒而不是立即?因为编辑器保存文件经常是多次写入,立即加载会读到半截文件然后报解析错误。给一个短暂的合并窗口,能挡掉绝大部分误报。加载失败的时候不要清空原有索引,保留上一份可用配置并打出告警,这个原则我在项目里叫"失败不降级到空"。

第二个细节是重名检测。同一份配置里如果出现两个name完全相同的描述符,必须在加载阶段就报错拒绝,而不是让后面的静默覆盖前面的。我刚开始没做这个检查,结果有个团队的两个小组各自写了一份同名描述符,优先级一个是 10 一个是 20,上线后路由行为变得完全不可预测,排查了整整两天。现在加载器里有一条硬规则:重名直接拒绝整份文件,日志里明确指出冲突的两个文件路径。

第三个细节是params_schema的校验强度。我选了一个折中方案,只校验顶层字段的存在性和类型,不递归校验嵌套结构。原因还是那句老话,校验越强,业务方改起来越痛苦。顶层的存在性校验能挡掉八成的低级错误,比如参数名拼错、忘了传必填项,这些错误一旦漏过去,往往是请求发出去了、对端返回 400,然后被当成服务故障统计,污染了健康数据。

# registry.py 核心加载逻辑(简化版) import yaml, hashlib, logging from pathlib import Path logger = logging.getLogger("reach.registry") class Registry: def __init__(self, conf_dir: str): self.conf_dir = Path(conf_dir) self._index = {} # name -> {provider: descriptor} self._fingerprint = "" def load(self) -> bool: files = sorted(self.conf_dir.glob("*.yaml")) staging = {} for fp in files: try: raw = yaml.safe_load(fp.read_text(encoding="utf-8")) except Exception as e: logger.error("parse failed, keep old config: %s err=%s", fp, e) return False for item in raw.get("capabilities", []): name = item["name"] provider = item["provider"] if name in staging and provider in staging[name]: logger.error("duplicate capability %s/%s at %s", name, provider, fp) return False staging.setdefault(name, {})[provider] = item fp_new = hashlib.md5(str(sorted(staging.keys())).encode()).hexdigest() if fp_new == self._fingerprint: return True self._index, self._fingerprint = staging, fp_new logger.info("registry reloaded, %d capabilities", len(staging)) return True

上面这段代码里有个容易被忽略的小设计:指纹比对。如果这次加载出来的能力集合和上次完全一样,就跳过替换索引的动作。这么做是为了避免热加载时正在进行的调用看到半新半旧的索引。虽然 Python 的字典替换是原子操作,但如果在替换的瞬间有其他线程正在遍历旧索引,理论上还是可能出现不一致。加个指纹判断,能把无意义的替换降到零。

3.2 可达性探测:探针怎么设计才不会误报

探针这块我改了三版,每一版都是被误报逼出来的。

第一版是纯 TCP 连通性探测,端口能连上就算健康。这版的问题是几乎所有场景都是"假健康",因为绝大部分服务的端口永远在监听,哪怕内部线程池已经打满。

第二版改成 HTTP 层的健康检查接口,约定每个服务提供一个/healthz。这版进步很大,但引入了新问题:有些团队的/healthz写得太重,内部会去查数据库、查缓存、查下游,结果健康检查本身就成了压垮服务的最后一根稻草。我遇到过一次线上雪崩,排查到最后发现是三个客户端同时在以 1 秒间隔轮询一个重量级健康接口。

第三版是我现在用的,叫"分级探测"。每个能力可以配置多个探针,分为livenessreadiness两级。liveness只做最轻量的存活判断,通常是 TCP 连接或者一个几乎无逻辑的 ping 接口,间隔短、开销小;readiness才去检查核心依赖,间隔长、开销大,而且只在liveness通过之后才执行。这样既能在服务彻底挂掉时快速发现,又不会因为探测本身造成压力。

探针间隔怎么定?我用的经验公式是:探测间隔 = 该能力 P95 响应时间 × 3,同时设一个 5 秒的下限。举个例子,某个查询接口的 P95 是 120ms,那探测间隔就是 360ms,但按公式应该取 5 秒的下限,所以实际是 5 秒。为什么要有下限?因为如果探测间隔小于正常业务请求间隔,探针的流量占比会明显上升,对于低频能力来说,探针流量甚至可能超过业务流量,这在成本核算上很难看。

超时怎么定?这个更简单,探测超时 = 该能力 P99 响应时间 × 2,下限 500ms。为什么要用 P99 而不是 P95?因为探针的目的是判断"这个服务现在还能不能服务",如果按 P95 设超时,会有 5% 的正常请求被判为超时,误报率太高,熔断器会频繁开关,比不熔断还糟糕。

还有一条经验:探测结果不要用单次判断,用滑动窗口。我默认用 10 次探测为一个窗口,窗口内失败超过 6 次才标记为不健康,标记为不健康之后要连续 5 次成功才恢复。这个"快降慢升"的策略是刻意设计的,宁可多熔断一会儿,也不要在服务还没缓过来的时候把流量打回去。恢复太快的代价通常比熔断久一点更大。

3.3 路由与降级:优先级、权重和熔断怎么组合

路由的决策顺序是这样的:先按priority分组,取优先级最高的一组候选;如果这一组全部不健康,降到下一优先级;在选中组内,按cost_weight做加权随机;如果加权随机选中的供应商调用失败且是可重试错误,进入组内下一个候选;组内全部失败,才降到下一优先级。

这个顺序里有几个反直觉的地方,值得单独解释。

为什么不直接按优先级顺序 fallback,而要在组内做加权随机?因为顺序 fallback 会让优先级最高的那个供应商承担全部流量,它一挂,流量瞬间全部涌向第二顺位,第二顺位如果没有为这个流量峰值做好准备,会跟着挂,然后第三顺位接着挂。这就是典型的级联故障。加权随机能把流量提前分散开,让每个供应商都处于"热身"状态,切换的时候冲击小得多。我在一个日调用量两千万的系统上验证过,改成加权随机之后,供应商切换导致的错误尖峰从千分之三降到了万分之四。

可重试错误和不可重试错误怎么区分?我的分类是:连接超时、连接被拒、502、503、504 归为可重试;400、401、403、404、422 归为不可重试;剩下的一律归为不可重试,宁可不重试也别乱重试。为什么这么保守?因为很多写操作不是幂等的,重试一次可能就创建了两条订单。对于写操作,我在描述符里加了idempotent: false标记,带这个标记的能力一律不重试,失败直接降级。

重试的退避怎么算?我用的是指数退避加抖动:第 n 次重试等待 = base × 2^(n-1) × random(0.5, 1.5)base默认 100ms。加了随机抖动的原因是防止多个客户端同时重试造成的重试风暴。这个坑我在一次大促里吃过,两千个客户端实例的重试时间点几乎完全对齐,每一波重试都是两千个并发请求同时到达,把刚恢复的服务又打挂了一次。加抖动之后,重试时间点被摊开在 50ms 到 150ms 之间,峰值压力降了一个数量级。

熔断器我用的是错误率加最小请求数的双条件。错误率 > 50%窗口内请求数 >= 20才触发熔断。为什么要有最小请求数?因为低频能力可能整个窗口只有 2 个请求,其中 1 个失败就是 50% 错误率,这时候熔断完全是误判。20 这个数字是我拍的,你可以根据自己业务的 QPS 调整,原则是"窗口内至少有足够多的请求,让错误率这个统计量有意义"。

3.4 执行适配器:把协议差异吃掉

执行层我实现了一个统一的接口,每个协议一个适配器,适配器只做三件事:把统一请求翻译成协议动作、把协议响应翻译成统一结果、把协议异常翻译成统一错误码。

# adapters/base.py from abc import ABC, abstractmethod from dataclasses import dataclass @dataclass class ReachResult: ok: bool data: object = None error_code: str = "" error_msg: str = "" latency_ms: int = 0 provider: str = "" attempt: int = 1 class Adapter(ABC): @abstractmethod async def invoke(self, descriptor: dict, params: dict, timeout_s: float) -> ReachResult: ... @abstractmethod def classify(self, exc: BaseException) -> tuple[str, bool]: """返回 (错误码, 是否可重试)""" ...

统一错误码是我觉得最值得投入的一个设计。我定了一组很短的枚举:TIMEOUTCONN_REFUSEDUPSTREAM_5XXUPSTREAM_4XXSCHEMA_INVALIDRATE_LIMITEDUNKNOWN。所有适配器必须把原生异常映射到这七个里,映射不了的一律UNKNOWN

为什么这个映射这么重要?因为它让上层的决策逻辑变成了纯查表,不需要知道任何协议细节。同时它也让可观测性变得极其清晰——你按错误码做聚合,一眼就能看出当前系统的失败主要来自哪一类。我在实际运维中最常看的一张图就是错误码分布,UPSTREAM_5XX占比升高通常意味着下游有问题,TIMEOUT占比升高通常意味着网络或者容量问题,SCHEMA_INVALID升高一般是自己的参数生成有问题。这三种情况的处理动作完全不同。

HTTP 适配器里有个坑我必须提醒:一定要设连接超时和读取超时两个值,而且连接超时应该远小于读取超时。连接超时默认我设 1 秒,读取超时按描述符配置。原因是一个健康的服务在建连阶段不应该慢,如果建连都超过 1 秒了,说明网络或者对端 accept 队列已经出问题了,再等下去没有意义。而读取超时要宽松一些,因为有些查询确实就是慢。我见过只设总超时的实现,结果一个慢查询把连接池占满,后续所有请求都在等连接,整个服务被一个接口拖死。

4. 从零跑通 Agent-Reach 的实操过程

4.1 目录结构与依赖准备

我建议的目录结构是这样,简单到不用解释:

agent-reach/ ├── reach/ │ ├── registry.py # 能力注册与热加载 │ ├── router.py # 路由决策 │ ├── breaker.py # 熔断器 │ ├── probe.py # 健康探测 │ ├── metrics.py # 指标采集 │ └── adapters/ │ ├── base.py │ ├── http.py │ ├── sql.py │ └── mq.py ├── conf/ │ └── capabilities/ │ ├── order.yaml │ └── notify.yaml └── main.py

依赖我刻意控制在四个以内:httpx(异步 HTTP)、pyyaml(配置解析)、prometheus-client(指标,可选)、watchfiles(文件监听,可选)。数据库适配器如果需要,再接对应的驱动,但不要放进核心依赖里,按需安装。

这里有个取舍值得说:为什么用httpx而不是aiohttp或者requestsrequests是同步的,在异步的 Agent 框架里会阻塞事件循环,绝对不能用。aiohttp性能很好但 API 偏底层,连接池配置写起来啰嗦。httpx的 API 更接近requests,团队上手成本低,而且连接池配置项的语义很清晰。性能上我实测过,在每秒三千请求的量级上,两者差距在 5% 以内,对绝大多数场景来说这个差距不重要,可维护性更重要。

4.2 配置文件怎么写:一个完整的描述符示例

# conf/capabilities/order.yaml capabilities: - name: order.query provider: order-center-primary protocol: http endpoint: http://order-center.internal/api/v2/orders/{order_id} method: GET priority: 10 cost_weight: 1.0 timeout_ms: 1200 idempotent: true retry: max_attempts: 2 base_ms: 100 params_schema: order_id: type: string required: true health_probe: liveness: type: tcp target: order-center.internal:80 interval_s: 5 timeout_s: 1 readiness: type: http target: http://order-center.internal/readyz interval_s: 30 timeout_s: 2 - name: order.query provider: order-center-standby protocol: http endpoint: http://order-center-dr.internal/api/v2/orders/{order_id} method: GET priority: 20 cost_weight: 1.0 timeout_ms: 2000 idempotent: true params_schema: order_id: type: string required: true

这份配置里有几个参数是我反复调过之后定下来的。主供应商的timeout_ms设 1200,备供应商设 2000,这个不对称是故意的。主供应商在正常情况下 P99 是 400ms 左右,1200 已经是三倍余量,再给更多只会让故障时的等待更久。备供应商是跨机房调用,网络 RTT 天然更高,P99 大概 900ms,所以给 2000 才合理。如果用同一个超时值,要么主供应商的容错太宽松,要么备供应商会被大量误杀。

priority用 10 和 20 而不是 1 和 2,是留出插入空间。后来确实用上了——我们在中间插了一个priority: 15的区域节点,不用改动已有的两个配置。这种小技巧看起来无所谓,但配置项一旦上线被几十个团队引用,改起来就是协调成本。

max_attempts: 2表示总共尝试两次,也就是最多重试一次。为什么不设成 3?算一笔账:主供应商超时 1200ms,重试一次加上退避 100ms,最坏情况 2500ms;如果设成 3 次,最坏就是 3800ms 加上两次退避,接近 4 秒。而 Agent 场景下用户能接受的等待通常是 3 到 5 秒,如果单个能力就吃掉 4 秒,后面还有别的能力要调用,总时长必然超。所以重试次数的上限不是由技术决定,是由用户能忍多久决定的。我的经验值是把单个能力的重试预算控制在总预算的 40% 以内。

4.3 一次完整调用的链路复盘

下面这段是调用入口,逻辑非常短,因为复杂度都在被调用的几个模块里。

# main.py import asyncio from reach.registry import Registry from reach.router import Router from reach.metrics import Metrics async def reach_call(name: str, params: dict, trace_id: str = ""): metric = Metrics.get() candidates = registry.lookup(name) if not candidates: return {"ok": False, "error_code": "SCHEMA_INVALID", "error_msg": f"capability {name} not found"} last = None for level in router.group_by_priority(candidates): healthy = [c for c in level if breaker.allow(c["provider"], name)] if not healthy: continue for desc in router.weighted_order(healthy): start = metric.now() result = await adapter_for(desc).invoke(desc, params, desc["timeout_ms"] / 1000) metric.observe(name, desc["provider"], result.ok, metric.now() - start) breaker.record(desc["provider"], name, result.ok) if result.ok: return {"ok": True, "data": result.data, "provider": desc["provider"], "latency_ms": result.latency_ms} last = result _, retryable = adapter_for(desc).classify(result) if not retryable: break return {"ok": False, "error_code": last.error_code, "error_msg": last.error_msg} registry = Registry("conf/capabilities") registry.load() router = Router() breaker = Breaker()

这段代码短,但每一步都有讲究。group_by_priority返回的是一个按优先级排好序的列表的列表,外层遍历是降级,内层遍历是同优先级内的候选切换。breaker.allow是熔断判断,返回 False 表示这个供应商当前处于熔断状态,直接跳过。注意这里跳过的动作发生在遍历之前,也就是一次熔断的供应商完全不会产生网络请求,这很重要——熔断的意义就是快速失败,如果还要发请求去探一下,那熔断就没意义了。

retryable判断为 False 的时候我用了break而不是continue,这个决定挺关键的。含义是,如果一个供应商返回的是不可重试的错误,比如 400 参数错误,那就没必要再试同优先级的其他供应商了,因为参数是错的,换谁都一样。这时候应该直接向上返回错误。如果这里写成continue,就会拿同样的错误参数去打扰所有供应商,既浪费资源又污染健康统计,还可能触发不必要的熔断。这个小地方我在 code review 里见过太多次写错的。

调用完成后返回的结果里带了providerlatency_ms,这个信息要一路透传到 Agent 的日志里。不要小看这两个字段,线上排查的时候最常问的问题就是"这次调用走的哪个节点、花了多久",如果日志里没有,你只能靠猜。

4.4 可观测性:日志、指标、追踪三件套怎么埋

日志我坚持用结构化格式,一行一个 JSON,字段固定为:trace_idcapabilityproviderattemptokerror_codelatency_msdegrade_level。其中degrade_level表示这次调用降到了第几优先级,正常是 0,降到第二优先级就是 1。这个字段是运维最关心的,因为降级次数直接反映了系统的健康水位。

指标我埋了四个:调用计数(按能力、供应商、结果打标签)、延迟直方图(按能力打标签)、熔断状态(按供应商打标签)、探测成功率(按供应商打标签)。其中延迟直方图的分桶我按经验设成[0.05, 0.1, 0.2, 0.4, 0.8, 1.6, 3.2]秒,因为 Agent 场景下的正常调用基本集中在 50ms 到 1s 之间,分桶太粗看不出变化,太细又浪费存储。这套分桶在三个不同的系统上都够用。

追踪这块我做得比较轻,没有引入完整的链路追踪系统,只在日志里带trace_id,然后在 Agent 层生成,一路透传到最底层的 HTTP 头里。这样做的原因是,Agent 场景的调用链经常是动态的、非线性的,用标准的 span 模型表达会很不自然,而且链路追踪系统的成本对中小团队来说偏高。带个 trace_id 已经能解决九成的排查需求了,把同一个 trace_id 的日志捞出来按时间排一下,一目了然。

5. 常见问题与排查技巧实录

5.1 高频故障速查表

下面这张表是我这一年遇到过的真实问题,按出现频率排序,每一条都对应一次至少半小时的排查。

现象大概率原因排查动作处理方式
大量 TIMEOUT,但下游监控显示正常连接池被慢请求占满看连接池等待队列长度拆分为独立连接池,设总连接数上限
熔断器频繁开关探针间隔太短或阈值太敏感看熔断日志的时间间隔拉长探测间隔,提高最小请求数
切换供应商后错误率反而升高备用供应商容量不足或参数不兼容对比两个供应商的出参结构先灰度 5% 流量,验证后再全量
重试后出现重复数据写操作没有被标记为非幂等查描述符里 idempotent 字段补上标记,禁止写操作重试
某个能力突然全部失败描述符被改错或服务下线看注册层加载日志回滚描述符,加载器加字段校验
内存持续增长指标标签基数过高看指标的 label 组合数禁止把参数值拼进标签
只在高峰期出现 SCHEMA_INVALID上游返回了错误页而非 JSON打印原始响应前 500 字符适配器里加内容类型判断

表里最后一条我要展开讲,因为它特别隐蔽。正常情况下接口返回 JSON,但服务被打挂的时候,前面的网关会返回一个 HTML 错误页,HTTP 状态码还是 200。解析器拿到这个 HTML 去json.loads,抛异常,被归类成SCHEMA_INVALID。这时候你会以为是参数生成有问题,去查模型的输出,查半天查不出毛病。正确做法是在适配器里先判断Content-Type,不是 JSON 就归类成UPSTREAM_5XX,因为绝大多数情况下这确实是对端故障。我在适配器的响应处理里加了这个判断之后,一类长期存在的误判问题直接消失了。

5.2 几个反直觉的坑

第一个坑:健康检查本身会成为故障源。前面提过一次,这里补充一个更隐蔽的版本。探针用的是独立的 HTTP 客户端,如果没限制并发,当被探测的服务响应变慢时,探针请求会堆积,堆积到一定程度会把探针客户端自己的连接池占满,然后探针开始超时,然后熔断器触发,把本来只是变慢的服务直接判定为不可用,流量全部切走。这个链条一旦触发,会形成"越慢越熔断、越熔断越慢"的循环。我的处理是给探针设了严格的并发上限,每个供应商最多两个并发探测请求,超出的直接丢弃并记录一次丢弃计数。丢弃计数本身也是个很好的健康信号。

第二个坑:加权随机的权重不能只看成本。我最初的设计只考虑成本,结果发现低成本的供应商经常是容量最小的那个,权重一高就被打爆。后来改成权重由成本和容量共同决定,容量的量化方式是取该供应商过去一个小时的 P99 延迟,延迟越高权重越低。这个改动之后,流量分布明显更均衡了。

第三个坑:热加载的时候要保持正在进行的调用不受影响。前面代码里的指纹比对只能挡住"内容完全没变"的情况,如果内容真的变了,替换索引的瞬间,已经在执行中的调用可能引用的是新的描述符对象,而它的连接池还是旧的。我的做法是让连接池按provider + protocol做键,只要这两个字段没变就复用连接池,换了 endpoint 也不重建。这样即使描述符换了,正在跑的调用也不会突然失去连接。

第四个坑:日志采样。Agent 的调用量可能非常大,全量打日志会拖垮磁盘。但采样又不能均匀采样,因为失败样本恰恰是最需要看的。我的策略是失败必打、成功按 1% 采样,同时采样率可动态调整。这个策略在两次线上事故里救过我,因为失败日志一条没丢。

第五个坑:不要把所有错误都往上抛。有些错误对上层 Agent 来说是完全无意义的,比如CONN_REFUSEDUNKNOWN。这类错误往上抛,模型看到之后会尝试"修复",比如换个参数再试一次,结果又是一样的错误,白白浪费一轮对话。我的做法是,这两类错误在 Agent-Reach 内部就转成一句人类可读的话,比如"该功能当前不可用,请稍后再试",并且标记为不可重试。让模型知道这事它修不了,它就会去走别的路径或者直接告诉用户。这个改动让模型在故障场景下的无效重试次数下降了大概七成。

6. 我在这套东西上学到的几件事

做 Agent-Reach 的这段时间,最大的收获其实不是技术上的,而是对"可靠性"这件事的理解变了。以前我觉得可靠性是靠冗余堆出来的,多一个备份节点、多一次重试,就多一分可靠。现在我认为可靠性是靠"快速知道发生了什么"堆出来的。你可以在三秒内定位到是哪条链路、哪个供应商、哪类错误,那这套系统的实际可用性会比一个节点多一倍但排查靠猜的系统高得多。我为此把大量精力放在了埋点和错误分类上,事实证明这个投入回报最高。

第二个体会是,配置即接口。描述符文件一旦被多个团队引用,它就变成了一个事实上的接口契约,改动它需要像改代码一样谨慎。我现在的做法是给描述符加版本号,并且提供一份契约测试,任何描述符变更都要先跑一遍契约测试,确认必填字段没丢、endpoint 格式合法、超时值在合理区间。这个流程看起来重,但它挡掉的问题都是会直接导致线上故障的那类。

第三个可能有点反常识:不要把降级路径做得太顺滑。我一开始把降级做得非常自然,主供应商挂了就静默切到备用,上层完全无感。结果问题来了——没人知道主供应商已经挂了三个星期。后来我在降级结果里强制加了一个degraded: true标记,并且要求 Agent 在连续多次拿到降级结果时,在回复里加一句轻量的提示。不是为了打扰用户,是为了让问题被看见。隐藏故障的降级策略,本质上是在帮故障积累。

这套东西后面我还打算往下做两件事,一件是把探测结果做成一个可查询的健康地图,让业务方自己在页面上看到每个能力的实时状态和历史曲线;另一件是把描述符的生成接到接口文档上,从 OpenAPI 定义自动生成初版描述符,减少人工维护。这两件事都不复杂,但都需要时间,等做完了再找机会单独写一篇。

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

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

立即咨询