Agent-Reach:智能体触达层架构设计与工程落地
2026/9/18 3:25:38 网站建设 项目流程

Agent-Reach 这个词第一次出现在我视野里的时候,我以为是某个爬虫库的分支,点进去才发现方向完全不一样。它要解决的是智能体(Agent)世界里最容易被忽略、却最先卡住工程化落地的一段路:你的团队里可能已经躺着七八个能跑通 demo 的 Agent,它们分散在不同机器、不同语言栈、不同框架上,有的用 Python 写的,有的被塞在别人服务的某个角落,还有的只是一个带 Flask 壳的脚本。这时候调用方想知道"谁能处理这批发票识别任务",得到的回答往往是"去群里问一下"。Agent-Reach 就是把这段从"能跑"到"能被找到、被调用、被观测"的空白补上的一层触达框架。

这篇文章我打算按我自己实际落地这套东西的顺序来讲:先拆清楚它到底解决哪几类具体断层,再讲架构上为什么要把触达层做薄而不是做厚,然后深入到能力描述、健康租约、路由匹配、流式返回、幂等去重这些真正吃时间的细节,最后给一份可以照着跑的最小实现和一份踩坑速查表。不管你是刚开始接触智能体工程,还是已经在维护一堆 Agent 服务的后端同学,应该都能从里面挑到能直接用的东西。

1. Agent-Reach 到底解决什么问题:从"能跑"到"能被找到"

1.1 三个真实的触达断层

第一个断层是寻址断层。当你只有一个 Agent 的时候,它的地址就是一行写死的 URL,没什么好说的。但当 Agent 数量超过五个,问题立刻变了:调用方不知道该请求谁。有人用自然语言描述任务"帮我审一下这份合同的付款条款",有人用结构化参数描述"task_type=contract_review, domain=payment"。如果没有任何中间层做语义和能力标签的映射,那就只能靠人肉路由,写死在代码里,改一次要发一次版。

第二个断层是生命周期断层。Agent 进程是会挂的,GPU 显存是会爆的,模型服务是会限流的。如果没有一个统一的健康检查机制,调用方只能靠超时报错来感知下游已经死了,而这个感知延迟可能是几十秒甚至几分钟。更麻烦的是灰度场景:新版本 Agent 上线只承接 5% 流量,这种能力如果没有触达层统一承载,每个业务线都要自己写一套权重逻辑。

第三个断层是观测断层。这是最容易被低估的。一次触达调用跨了三四个服务,其中两个是流式返回的,最后统计出来的 P99 延迟到底是哪一段拖的?调用方看到的是一次失败,而下层日志里可能既有超时也有重试成功的记录,两边对不上账。没有统一的 trace 和指标埋点,排查一次线上问题要靠猜。

Agent-Reach 的设计目标,说白了就是把这三件事收到一个薄层里统一处理:注册与发现解决寻址,健康租约与权重路由解决生命周期,统一埋点解决观测。

1.2 为什么不做成又一个 Agent 编排框架

这是我被问得最多的问题。市面上编排框架已经不少了,为什么还要单独搞一层触达?

因为编排和触达是两个正交的关注点。编排关心的是"任务怎么拆、步骤怎么串、记忆怎么存、失败怎么回滚";触达关心的是"这一步交给谁、它现在活着吗、超时怎么算、失败了要不要重试"。前者是业务逻辑,后者是基础设施。把它们揉在一个框架里,最直接的后果就是:业务团队想换编排方案的时候,发现触达逻辑也在里面,换不动;反过来基础设施团队想升级路由策略,又怕动到业务代码。

我在实际项目里吃过这个亏。最早我们把路由逻辑写在一个自研编排器里,后来发现有三分之一的 Agent 根本不在这个编排器管辖范围内,它们是被别的系统直接调用的。这时候你只有两条路:要么让编排器变成所有 Agent 的强制入口(组织上很难推动),要么把这部分逻辑抽出来做成独立的一层。Agent-Reach 走的是第二条路。

具体到设计原则,我总结成三条,后面所有细节都是围绕这三条展开的:

  • 薄协议优先:能用 HTTP 讲清楚的事情,不要引入新的协议栈。引入一个新协议意味着调用方要装新的 SDK、CI 里要多配一个依赖、新人要重新学一遍。
  • 描述与实现分离:Agent 的"能力描述"是一份独立的数据,不是藏在代码里的 if-else。描述可以在注册中心被检索、被过滤、被版本化。
  • 失败是一等公民:超时、熔断、降级、重试这些不是异常处理,是主流程的一部分。设计之初就要给出每种失败的默认行为。

注意:如果你的 Agent 总数长期稳定在三个以内、且调用方只有一个,那真的不需要引入触达层。过度设计带来的复杂度,会远超它省下的那点胶水代码。

2. 架构设计与关键选型:把触达层做薄

2.1 四层拆解:注册中心、网关、适配器、会话状态

Agent-Reach 的骨架我拆成四块,每块的职责边界尽量不重叠。

注册中心(Registry)负责存 Agent 的能力描述和健康状态。它本质上是一个带语义检索能力的小型键值库,写入方是 Agent 自己(或它的 sidecar),读取方是网关。这里有个关键决策:注册中心不存业务数据,只存元数据。我见过有人把 Agent 的输入输出样例也塞进去,结果元数据表涨到几百万行,检索性能直接崩掉。

**网关(Gateway)**是所有触达请求的入口,它做三件事:鉴权、路由匹配、把请求转发给选中的 Agent。注意它不做业务逻辑,也不做参数校验(校验交给 Agent 自己,因为只有 Agent 才知道自己的参数约束)。

**适配器(Adapter)**是协议转换层。有的 Agent 用 HTTP 暴露,有的只支持 gRPC,有的干脆是个消息队列消费者。适配器把这些差异吃掉,对上统一暴露一种接口。适配器是分层里最"脏"的一层,也是最该被隔离的一层——因为它变动的频率最高。

**会话状态(Session Store)**单独拎出来讲,是因为流式场景绕不开它。当一个 Agent 返回的是 SSE 流,网关需要维护"哪个客户端对应哪条下游流"的映射,以及断线重连后的续传位置。这块用 Redis 就够了,键设计成session:{trace_id},TTL 设成比最长任务时间略长即可。

2.2 协议选型:HTTP + SSE 打底,WebSocket 只留给双向流

选型这块我踩过两次坑,说下结论和理由。

对外接口用纯 HTTP + SSE(Server-Sent Events)。理由是:SSE 本质上是长连接的 HTTP 响应,所有主流语言的标准库都能处理,不需要引入额外的协议库;它对中间代理友好,重连语义由浏览器或客户端库自动处理;单向推送的场景(Agent 输出 token、进度、日志)它完全够用。实测下来,SSE 在跨机房调用时的心跳维持成本,比 WebSocket 低不少。

WebSocket 我只在一种情况下用:Agent 需要中途接收客户端的补充输入。比如一个需要人工确认的审批流,Agent 跑到一半要问"这单要不要放行",这时候单向推送就不够了。但这种场景在实际业务里占比很低,为了它把所有链路换成 WebSocket 不划算,我的做法是在适配器层单独开一条通道,走独立端口,和主链路隔离。

gRPC 在内部 Agent 之间通信时是可以用的,延迟和序列化效率确实更好,但不要把它暴露给外部调用方。原因是生态割裂:业务方可能用 Java、Go、Node 各种语言,让他们都去生成 stub、管理 proto 版本,协调成本远大于收益。

协议适用位置优势主要代价
HTTP + JSON对外统一入口生态最广,调试方便流式支持要额外约定
HTTP + SSE流式输出场景标准库支持好,兼容代理单向,需要心跳保活
WebSocket双向交互场景真正的全双工连接管理复杂,网关成本高
gRPC内部 Agent 间调用序列化高效,强类型对外生态割裂

2.3 三个关键取舍:为什么自研网关而不是套现成组件

第一个取舍:用现成的 API 网关还是自己写路由层。我一开始想直接套 Nginx 或 Kong,后来放弃了。原因是 Agent 的路由规则不是传统的路径匹配,它需要按能力标签做加权选择,还要读注册中心的实时健康状态。现成网关的插件机制能做,但每次规则变化都要 reload,而 Agent 上下线的频率可能就是分钟级的。结论是:用现成网关做接入和 TLS 终止,路由决策自己写在一个轻量服务里。这样各司其职,Nginx 那层几乎不用动。

第二个取舍:用消息队列还是同步调用。长任务(超过 30 秒)我建议走异步:网关返回一个task_id,结果通过回调或轮询获取。短任务走同步。这里的边界值不要拍脑袋定,我一般按 P95 延迟的 3 倍来划,如果 P95 是 8 秒,那 24 秒以上的一律异步化。

第三个取舍:注册中心用 etcd/Consul 还是自己写。我的建议是别自己写,用现成的键值存储加 TTL 能力即可。但要注意一点:这些组件的健康检查是基于连接活跃度的,而 Agent 的"健康"往往是基于业务状态的(比如模型服务限流了,进程还活着但不能接活)。所以要做两层健康:进程级用组件自带的租约,业务级由 Agent 主动上报一个ready字段。

3. 核心实现:从注册到一次完整调用

3.1 能力描述文件:字段怎么定才不返工

能力描述(我习惯叫 Agent Card)是整套系统的地基,字段一旦上线就很难改,所以值得多花半天时间想清楚。下面是我最终定下来的字段集合,以及每个字段为什么这么定。

{ "agent_id": "invoice-extractor-v2", "display_name": "发票信息提取", "version": "2.3.1", "owner": "finance-platform", "endpoint": "http://10.20.3.11:8080/invoke", "protocol": "http-sse", "capabilities": [ {"tag": "invoice.extract", "weight": 1.0}, {"tag": "ocr.table", "weight": 0.6} ], "input_schema_ref": "schema://invoice-extract/input@2", "constraints": { "max_payload_bytes": 5242880, "supported_langs": ["zh", "en"], "timeout_hint_ms": 45000 }, "runtime": { "max_concurrency": 16, "avg_latency_ms": 3200, "cost_per_call": 0.012 }, "lease_ttl_s": 30, "ready": true }

几个字段的取舍值得单独说。

capabilities标签加权重而不是自由文本。原因很实际:自由文本检索在几十个 Agent 的时候靠关键词还能用,上百个之后误召回率会飙升。标签是有层级的(invoice.extract),检索时可以前缀匹配,也能做精确路由。权重用来表达"这个 Agent 的主业是发票提取,OCR 表格只是顺带能接"。

input_schema_ref引用而不是内联。schema 可能有几百行,内联到 Card 里会让注册中心的数据量膨胀十几倍,而且 schema 更新频率比 Card 高得多。用 URI 引用的方式,schema 单独存一份,带版本号,两边可以独立演进。

runtime里的avg_latency_mscost_per_call不由 Agent 自己填,而是由网关定期回写。自己填的数据基本不可信,人都有报喜不报忧的倾向。网关按最近 5 分钟的滑动窗口计算,写到 Card 的另一个分区里,和 Agent 自报的字段物理隔离。

lease_ttl_s定成 30 秒是有讲究的。太短会导致网络抖动时频繁摘除健康节点,太长会让故障节点存活过久。30 秒是我在三次故障演练里试出来的平衡点:Agent 崩溃后最长 30 秒被摘除,而正常网络抖动(通常 3 秒内恢复)不会触发摘除。

3.2 心跳与租约:TTL 该设多久,怎么续

心跳机制看起来简单,但细节全是坑。核心问题有三个:用什么频率续约、续约失败几次算故障、恢复后怎么重新入池。

频率上我选TTL 的三分之一,即 10 秒续一次。这样即使连续两次续约失败(20 秒),租约还没到期,还有一次补救机会。如果设成 TTL 的二分之一,容错空间就只有一次,实际跑下来误摘率明显偏高。

故障判定上,我用了连续失败计数 + 抖动退避的组合。第一次续约失败不标记异常,只记录;第二次失败标记为suspect,从路由池里降权但仍然可被选中(权重打三折);第三次失败才真正摘除。为什么要留一个suspect中间态?因为真实世界里大量故障是瞬时的,比如一次 GC 停顿、一次 DNS 解析超时。直接摘除会让下游流量瞬间打到剩余节点上,引发雪崩。

恢复入池更要小心。Agent 重启后如果立刻以全权重接流量,很容易出现"刚起来就被打满然后再次挂掉"的循环。我的做法是渐进式放量:恢复后的前 60 秒权重固定为 0.2,60 到 120 秒升到 0.5,之后才回到正常权重。这个策略在同一周内把一次灰度事故的影响面压到了原来的十分之一。

# 心跳续约的核心逻辑(伪代码,去掉业务细节) class LeaseManager: def __init__(self, ttl_seconds=30): self.ttl = ttl_seconds self.interval = ttl_seconds // 3 self.fail_count = 0 self.state = "healthy" # healthy / suspect / removed def renew_loop(self): while True: ok = self._try_renew() if ok: if self.fail_count >= 2: self._enter_warmup() # 渐进放量 self.fail_count = 0 self.state = "healthy" else: self.fail_count += 1 if self.fail_count == 2: self.state = "suspect" elif self.fail_count >= 3: self.state = "removed" # 退避:失败越多,下次续约稍晚一点,避免打爆注册中心 time.sleep(self.interval * (1 + 0.1 * min(self.fail_count, 5)))

提示:心跳续约一定要用独立的线程或协程,不要挂在请求处理路径上。我见过把续约写在每次业务请求里的实现,结果空闲的 Agent 因为长时间没请求,租约过期被摘除了,非常典型。

3.3 路由匹配:能力标签怎么算分

路由是触达层的核心算法。输入是一个调用请求(带能力需求、优先级、可能的偏好约束),输出是选中的 Agent 实例。我用的策略是三因子加权打分,不是简单的随机或轮询。

三个因子分别是:能力匹配度实时健康分成本因子。能力匹配度来自标签的精确匹配(1.0)和前缀匹配(0.6);健康分来自最近 5 分钟的成功率和延迟分位数,成功率 99% 以上给 1.0,95% 到 99% 给 0.7,低于 95% 给 0.3;成本因子是1 / (1 + cost_per_call * 100),让便宜且效果相当的实例有微弱优势。

最终得分是三者相乘,再乘以实例权重(灰度权重),然后做带权随机而不是取最高分。为什么不用取最高分?因为那样会导致所有流量都打到同一个实例上,其他实例永远拿不到流量,也就永远得不到健康数据,形成正反馈死循环。带权随机的做法是:把得分归一化后当作概率分布来抽样。这样高分配置拿到的流量多,但低分的也有机会被选中,保持数据新鲜度。

import random def pick_agent(candidates, required_tag): scored = [] for c in candidates: if not c["ready"]: continue cap = max((m["weight"] for m in c["capabilities"] if m["tag"] == required_tag or required_tag.startswith(m["tag"])), default=0) if cap <= 0: continue health = c["health_score"] # 0.3 / 0.7 / 1.0 cost = 1 / (1 + c["runtime"]["cost_per_call"] * 100) score = cap * health * cost * c["traffic_weight"] scored.append((c, score)) if not scored: raise NoAvailableAgent(required_tag) total = sum(s for _, s in scored) r = random.random() * total acc = 0 for c, s in scored: acc += s if r <= acc: return c return scored[-1][0]

这里有个容易忽略的细节:标签匹配要支持传递性。比如请求要invoice.extract,而某个 Agent 只声明了invoicedocument.ocr,它其实是有能力接的。硬匹配会漏掉它。我的做法是在注册时维护一张标签继承表(invoice.extract继承自invoice继承自document),匹配时沿着继承链往上看。这张表不用很大,手工维护几十条就够了,比搞一套自动语义推断靠谱得多。

3.4 流式返回与超时控制:SSE 的三个实现要点

流式这块看起来简单,写起来翻车率很高。我把关键点列一下。

第一,首字节超时和整体超时要分开设。一个 Agent 可能在 200 毫秒内就返回了第一段内容,然后推理跑了 40 秒。如果你只设一个整体超时,短任务和长任务就没办法用同一套配置。我的配置是:首字节超时 3 秒(超过就认为下游有问题,可以换实例重试),整体超时按任务的timeout_hint_ms来,但要加一个 20% 的缓冲。

第二,每条 SSE 消息都要带id字段。客户端断线重连时会带上Last-Event-ID请求头,网关据此从会话状态里找到续传位置,继续往下推。没有id的话,重连就只能从头开始,用户会看到内容重复出现,体验很糟。

第三,心跳注释帧必须加。长时间没有数据输出的流(比如 Agent 在做长推理),中间的网络设备可能会主动断开空闲连接。每 15 秒推一条:ping注释帧,成本几乎为零,但能避免大量莫名其妙的断流。这个坑我在跨区域调用时踩过一次,排查了两天才定位到。

def stream_proxy(upstream_resp, session_id): seq = 0 last_beat = time.time() for chunk in upstream_resp.iter_content(chunk_size=None): seq += 1 store.save(session_id, seq, chunk) # 存一份,供断线续传 yield f"id: {seq}\ndata: {chunk}\n\n" last_beat = time.time() # 没有数据时也要发心跳,见下方 while 逻辑 yield "data: [DONE]\n\n"

3.5 幂等与重试:request_id 怎么设计才有效

重试是必须的,但重试带来的重复执行是灾难——尤其是 Agent 会调用外部工具、产生实际副作用的时候。解决办法是幂等键 + 去重表,但幂等键的设计有几个细节。

键不能只用request_id,因为同一个request_id在不同租户下应该被视作不同请求。我的键是hash(tenant_id + request_id + capability_tag)。加上能力标签是因为同一个请求 ID 可能被复用到不同的能力上(客户端复用了 ID 生成器的场景)。

去重表存三样东西:键、状态(进行中/已完成)、结果。「进行中」这个状态必须显式存,否则并发重试的时候两个请求会同时执行。做法是用 Redis 的SET NX抢锁,抢到的执行,没抢到的等待结果。等待要有超时,超时后返回"处理中,请稍后查询"。

重试策略上,我只对幂等且可安全重放的失败重试,具体分三类:连接失败、首字节超时,这两类可以安全重试;业务返回 5xx 且带retryable: true标记的,可以重试;已经产生部分流式输出的,绝对不重试(除非目标是另一个实例且明确支持续传)。这个规则一定要写进网关代码,不能靠调用方自觉。

重试次数默认两次,退避用指数加抖动,基数是 200 毫秒:第一次 200±50ms,第二次 400±100ms。抖动很重要,没有抖动的话多个并发请求会在同一时刻齐刷刷重试,把下游再来一次打爆。

4. 落地实操:本地起一个最小可用版本

4.1 环境准备与依赖说明

最小版本我建议用这些组件,都是成熟稳定的:Redis 用来存注册信息和去重表,FastAPI 用来写网关(异步支持好,SSE 支持直接),uvicorn 作为服务容器。Python 版本建议 3.11 以上,主要是为了asyncio.TaskGroup这个语法糖,写并发清理逻辑会干净很多。

pip install fastapi==0.110.* uvicorn[standard] redis==5.* httpx==0.27.* pydantic==2.* docker run -d --name agent-reach-redis -p 6379:6379 redis:7-alpine

选 Redis 而不是自己写内存字典,是因为注册信息必须能跨进程共享。单机内存存的话,网关一开多副本就全乱了,而且进程重启后所有 Agent 的注册信息全部丢失,要等一个心跳周期才能恢复。这个代价在灰度发布时特别明显。

4.2 注册一个 Agent:完整可运行代码

下面这段是我实际用的注册客户端,去掉了一些监控埋点,核心逻辑都在。

import asyncio, json, time, httpx, redis.asyncio as aioredis REGISTRY_KEY = "areach:registry" class AgentRegistrar: def __init__(self, card: dict, redis_url="redis://localhost:6379/0"): self.card = card self.r = aioredis.from_url(redis_url) self.ttl = card.get("lease_ttl_s", 30) self.fail = 0 async def register(self): await self._write() asyncio.create_task(self._renew_loop()) async def _write(self): payload = json.dumps(self.card, ensure_ascii=False) # 用 hash 结构存,方便后续单独更新 runtime 字段 await self.r.hset(REGISTRY_KEY, self.card["agent_id"], payload) await self.r.expire(REGISTRY_KEY, 0) # 不设整体过期 async def _renew_loop(self): while True: await asyncio.sleep(self.ttl // 3) try: # 单独维护一个租约键,TTL 就是存活证明 await self.r.setex( f"areach:lease:{self.card['agent_id']}", self.ttl, str(time.time()), ) self.fail = 0 except Exception: self.fail += 1 # 连续失败只记录,进程级存活由注册中心的租约键判断

这里有个设计选择值得说:存活状态和卡片信息分开存areach:registry存卡片(长期有效),areach:lease:{agent_id}存租约(TTL 30 秒)。这样做的好处是,Agent 进程崩溃后卡片信息还在,运维能查到"这个 Agent 是什么时候消失的、它的配置是什么",便于事后排查。如果揉在一个键里,进程一挂信息全没了,事后什么都查不到。

4.3 发起一次触达调用

网关侧的调用接口我设计成两个:同步的/invoke和流式的/stream。下面给出流式版本的简化实现。

from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse import httpx, json app = FastAPI() @app.post("/stream") async def stream(req: Request, body: dict): tag = body["capability"] agent = await pick_agent_from_registry(tag) async def gen(): async with httpx.AsyncClient(timeout=httpx.Timeout(connect=3.0, read=60.0)) as c: async with c.stream("POST", agent["endpoint"], json=body["payload"]) as resp: seq = 0 async for line in resp.aiter_lines(): if not line: continue seq += 1 yield f"id: {seq}\ndata: {line}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

调用侧用 curl 验证最直接,注意-N参数关掉缓冲,否则你看不到流式效果,会误以为下游没返回。

curl -N -X POST http://localhost:8000/stream \ -H "Content-Type: application/json" \ -d '{"capability":"invoice.extract","payload":{"file_url":"s3://demo/1.pdf"}}'

X-Accel-Buffering: no这个响应头必须加。它告诉前置的 Nginx 不要缓冲响应,否则 Nginx 会把整个流攒完再一次性吐给客户端,流式就变成了"等 40 秒然后瞬间出结果",完全失去意义。

4.4 压测与三个必须盯的指标

压测不要一上来就堆并发,先做阶梯加压:从 10 并发开始,每 30 秒加 10,直到错误率超过 1%。这样才能找到真实的拐点,而不是只有一个"扛不住"的结论。

要盯的指标有三个,缺一不可:

指标含义健康阈值参考
首字节延迟 P95从请求发出到收到第一段内容小于 3 秒
完整流时长 P99整个流式响应的耗时不超过 timeout_hint 的 1.2 倍
路由失败率找不到可用 Agent 的比例小于 0.1%

第一个指标反映下游健康度,第二个反映任务复杂度,第三个反映注册中心的一致性。我最开始只盯了第二个,结果有一次注册中心的 Redis 主从切换,大量请求路由失败,但因为失败很快返回,完整流时长的指标反而变好看了,完全没预警。这个教训很值钱。

注意:压测时一定要用真实长度的流式响应。用假数据返回一个很短的流,会把所有缓冲相关的瓶颈都掩盖掉,测出来的数字没有任何参考价值。

5. 踩坑记录与排查速查表

5.1 常见问题速查

下面这张表是我在三个项目里积累下来的,基本都是文档里不会写、但现场一定会遇到的。

现象大概率原因处理方式
流式调用变成一次性返回中间层缓冲,缺少X-Accel-Buffering: no检查所有代理层的缓冲配置
Agent 频繁被摘除又恢复心跳续约线程被业务阻塞续约放独立线程,检查 GIL 争用
同一请求被执行两次幂等键设计不当或去重表未加锁SET NX抢锁,键加租户前缀
新实例上线后立刻过载恢复后直接给全权重加渐进放量逻辑
整体超时正常但偶发大批失败重试无抖动,形成同步洪峰加重试抖动,错开时间窗口
标签匹配漏召缺少标签继承关系表维护继承表,匹配时沿链上溯

5.2 三个反直觉的经验

第一,注册中心的数据量比你想象的重要。我一开始觉得几十个 Agent 的元数据能有多大,随便存。后来发现网关每次路由都要全量拉取一遍做打分,QPS 上去之后光是反序列化就吃掉不少 CPU。解决方式是网关本地做一层带版本号的缓存,注册中心的键加一个revision计数器,网关只对比版本号,变了才重新拉。这个优化把路由环节的 CPU 占用降了大概四成。

第二,"健康"的定义要按业务来,不是按进程。进程活着但模型服务限流了,这种情况应该算是"部分健康"。我后来加了一个degraded状态,处于这个状态的实例仍然可以被选中,但权重降到 0.1,只承接极少量流量用于探活。这样既不影响整体成功率,又能在它恢复的第一时间感知到。

第三,日志里一定要带 trace_id,而且要贯穿到 Agent 内部。这件事听起来是常识,但实践中最容易断在适配器层。我的做法是在网关生成 trace_id 后,通过 HTTP 头X-Trace-Id透传,并在适配器里强制要求下游把这个头再透传一层。少透一层,排查时就要在两个系统之间手工对账。

5.3 安全边界:鉴权、限流、审计各管什么

这三件事经常被混在一起做,但职责其实很清楚。

鉴权解决"你是谁、你能调什么"。我用的方案是租户级 API Key 加能力白名单。Key 存在网关侧,每个 Key 绑定一组允许调用的能力标签。这里要注意白名单要支持通配,比如invoice.*允许调用所有发票相关能力,否则每次新增能力都要改配置。

限流解决"你能调多快"。分两级:租户级总量限流,防止单个租户打爆整体;Agent 级并发限流,防止下游被压垮。算法上我用了令牌桶加并发计数的组合,令牌桶控速率,并发计数控同时占用。只做令牌桶在流式场景下会失效,因为一次流式请求占用连接的时间可能长达几十秒,速率限制完全挡不住。

审计解决"你调了什么"。每条触达记录要落库,至少包含 trace_id、租户、能力标签、目标 Agent、开始结束时间、状态。存储上建议做冷热分离,最近 7 天放 Redis 或热库用于实时查询,更早的落对象存储。审计日志的写入必须异步,绝对不能同步写在请求路径上,否则一旦存储抖动,整个调用链路都会被阻塞。

提示:审计日志里的 payload 建议只记录摘要和哈希值,不要存全量原文。一是存储成本,二是好多业务数据本身就不适合落盘。

这套东西我从最早的两三个 Agent 一路用到几十个,中间重构过两次路由逻辑,改过三次心跳参数。最大的体会是:触达层的价值不在于功能多,而在于它的行为可预测。调用方知道超时会怎样、重试会怎样、失败会怎样,才敢放心地把核心业务链路挂上来。至于后续还能往哪扩,我目前在看的方向是给路由加一层基于历史调用质量的反馈调节,让那些长期稳定的实例慢慢拿到更多流量,不过这属于锦上添花,现有的三因子打分已经够用了。

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

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

立即咨询