1. 为什么需要Agent-Reach:从"Agent的手够不到的地方"说起
1.1 我从三个失败项目里总结出来的痛点
上个月排查一个生产事故时,我突然意识到一件事:我们的Agent不是不够聪明,而是手太短了。那是一个客服场景的Agent,LLM本身已经能很好理解用户问题、生成回复,但真正让它去查订单、改备注、发消息时,要么接口还没接好,要么权限没控制住,要么请求超时后整个对话流程直接卡死。最后用户等了三分钟,得到的是一句"我还在思考中"。
这不是模型能力的问题,是触达层的问题。所谓触达层,就是Agent和外部世界之间的那层通道:HTTP接口、数据库、消息系统、文件、邮件、浏览器操作,等等。我翻看过去半年做过的三个Agent项目,踩过的坑几乎全部集中在同一个位置——LLM生成意图之后、真正执行业务动作之前的那条链路上。工具调用记录没打全、出参入参没留痕、重试导致重复扣款、权限配置在某个渠道上漏了一行……这些都是触达层设计不健壮的表现。
于是我开始重构,把这条链路单独抽象出来,做成一套可以复用的基础设施。这就是Agent-Reach的由来。它不是一个重量级Agent框架,也不是又一个"低代码平台",它的核心任务只有一件:让Agent的每一次外部触达都变得安全、可控、可追踪。
1.2 Agent-Reach的定位:它不是框架,而是一种触达层抽象
如果你去看市面上的Agent编排框架,比如LangChain、AutoGen那些,它们都会提供工具调用(Tool Calling)能力,但说句实在话,这些框架的工具调用和"生产可用"之间还隔着好几层:权限模型大多非常原始,有的甚至只靠一个allowlist名单;重试策略是全局统一的,做不到按动作定制;日志里没有request_id贯穿链路,出了问题压根没法把LLM的某一次决策和某一次HTTP请求对上号。
Agent-Reach解决的就是这个话题里最枯燥但最关键的部分。你可以理解成:Agent框架管的是"大脑怎么想",Agent-Reach管的是"手脚怎么动"。我把它设计成一套独立于具体Agent框架的触达层抽象,不管上层是LangChain还是自研的Agent循环,下层是REST API还是消息队列,中间都通过同一套动作描述、渠道适配器、策略引擎来流转。
这么做有个直接的好处:业务系统不必为了每个Agent改接口,Agent也不用关心每个外部系统长什么样。外部系统接个适配器,Agent只发结构化指令,中间所有的鉴权、限流、重试、超时、追溯由Agent-Reach统一处理。
1.3 核心概念:动作(Action)、渠道(Channel)、策略(Policy)
Agent-Reach的模型很简单,只有三个核心对象。
动作(Action)是Agent表达"我要做什么"的统一格式。它是一段结构化的JSON,描述动作名称、目标渠道、关键参数、期望的完成时限。LLM只负责产出这段JSON,不负责理解某个API的鉴权方式、不关心某个渠道是否可用。
渠道(Channel)是真正执行动作的适配器。HTTP是一个渠道,PostgreSQL是一个渠道,Slack是一个渠道,RDBS里的存储过程也可以是一个渠道。每个渠道做两件事:把动作翻译成底层系统能理解的操作,把底层系统的返回结果翻译成Agent能理解的结构化响应。
策略(Policy)是夹在动作与渠道之间的控制层。它决定这个动作允不允许执行、最多等多久、失败能不能重试、要不要记审计日志。策略不关心动作的具体业务含义,只做通用控制。
这三者的关系,类似前端里"组件、适配器、中间件"的分层思想。用这套抽象跑通一个端到端流程之后,我给自己的要求是:新接一个外部系统,不允许再改Agent的提示词业务逻辑,只加新渠道加新策略。
2. 触达层的核心抽象与最小实现
2.1 统一动作描述:让LLM只发指令,不碰实现
动作描述是整个Agent-Reach的接口契约。我参考了函数调用的思路,但做了一个重要的变化:函数调用通常是同步的、强类型的,而Agent的动作意图是异步的、可能带权重的。比如"发送一条促销短信给用户ID为1024的用户",可能触达短信服务、触发计费逻辑、写入运营报表,这不应该是一个简单的HTTP POST。
我最终定义的Action结构大概长这样:
{ "action": "send_sms", "channel": "sms_provider", "payload": { "user_id": "u_1024", "template_id": "promo_spring", "params": {"coupon": "SPRING30"} }, "metadata": { "request_id": "req_9f2c...", "agent_session": "sess_73a1...", "timeout_ms": 3000, "idempotency_key": "idem_2025...-0" } }字段很少,但每个都有讲究。action和channel分离,是为了让上层Agent无需感知底层渠道变更;payload是纯业务参数,与触达方式无关;metadata里带request_id、idempotency_key,是为了让后续的追踪和重试有抓手。
LLM端只和这个JSON打交道。我当时的做法是在系统提示词里塞了一个精简版动作列表,每个动作一行描述,再加两个few-shot样例,效果比让LLM直接调用函数稳定得多。因为LLM不需要去理解"token过期怎么刷新""分页参数怎么传",它只需要表达意图。
2.2 三层接口设计:入口、调度、适配器
接口我分了三层。入口层叫Executor,给上层Agent用的核心门面,接收Action对象返回ChannelResult对象;调度层叫Dispatcher,负责查策略、选适配器、做重试和熔断;适配器层叫Channel,真正执行外部交互。
# 入口层:提供给上层Agent的干净接口 class Executor: def __init__(self, dispatcher: Dispatcher): self.dispatcher = dispatcher async def execute(self, action: Action) -> ChannelResult: return await self.dispatcher.dispatch(action) def execute_batch(self, actions: list[Action]) -> list[ChannelResult]: # 按依赖关系做简单拆分,这里不展开 pass这里入口层不做任何业务校验,它只是一个薄壳,职责是把动作交给调度层。我见过很多设计喜欢在入口层堆逻辑,比如参数清洗、格式转换,结果每一层都改payload,出问题时都不知道动作原始长什么样。入口层越薄,线上排障越轻松。
调度层是整个触达层里最值钱的部分:
class Dispatcher: def __init__(self, registry: ChannelRegistry, policy_engine: PolicyEngine): self.registry = registry self.policy_engine = policy_engine async def dispatch(self, action: Action) -> ChannelResult: policy = self.policy_engine.evaluate(action) if not policy.allowed: return ChannelResult(status="DENIED", reason=policy.reason) channel = self.registry.get(action.channel) async with policy_guard(action, policy): result = await channel.invoke(action) return self._normalize(result, channel)核心之处在于它把策略判断和实际调用做了分离。在执行动作前,先问策略引擎"这个动作允不允许跑",再去注册表里取对应渠道。"先问后干"这四个字,帮我挡掉了无数低级事故。
2.3 适配器接口:一个最小但完整的Channel实现
每个渠道都要实现Channel接口。我定义得极简,只有两个方法:
class Channel(Protocol): name: str async def invoke(self, action: Action) -> ChannelRawResult: ... def normalize(self, raw: ChannelRawResult, action: Action) -> ChannelResult: ...invoke负责跟外部系统打交道,normalize负责把"外部系统说的语言"翻成"Agent-Reach统一语言"。拿HTTP渠道举例,它就是一个包装了aiohttp的适配器:
class HttpChannel(Channel): name = "http" def __init__(self, session: aiohttp.ClientSession, base_url: str, auth_provider: AuthProvider): self.session = session self.base_url = base_url.rstrip("/") self.auth_provider = auth_provider async def invoke(self, action: Action) -> ChannelRawResult: token = await self.auth_provider.get_token() async with self.session.request( method=action.payload.get("method", "POST"), url=f"{self.base_url}{action.payload['path']}", headers={"Authorization": f"Bearer {token}"}, json=action.payload.get("body"), timeout=aiohttp.ClientTimeout(total=action.metadata.get("timeout_ms", 3000) / 1000) ) as resp: body = await resp.text() return ChannelRawResult(status_code=resp.status, body=body) def normalize(self, raw, action): try: data = json.loads(raw.body) if raw.body else {} except json.JSONDecodeError: data = {"raw": raw.body[:500]} return ChannelResult( ok=200 <= raw.status_code < 300, status_code=raw.status_code, data=data, raw=raw.body[:1000] )之所以把invoke和normalize拆开,是为了让原始返回在打日志时完整保留。排障最怕的就是"适配器吞掉关键错误信息",比如把上游返回的500页面截断成一行"ERROR"。normalize里留raw原始片段,虽然只存前1000个字符,但配合日志系统足够定位绝大多数问题。
3. 渠道适配器的具体实现:HTTP、Slack、数据库与文件系统
3.1 HTTP渠道:最简单也最容易出问题的
HTTP渠道是Agent触达外部世界最常用的方式,也是最容易出问题的。倒不是HTTP协议本身难,而是鉴权方案五花八门:有Basic、有Bearer Token、有签名、有动态Token、还有的干脆走内网IP白名单。
我的HttpChannel设计里有一个AuthProvider抽象,就是为了对付这个。Token怎么取、怎么刷新、怎么缓存,全部隔离在AuthProvider里,HttpChannel本身只负责发请求。比如AWS签名版AuthProvider、钉钉机器人Token版AuthProvider,都是这个接口的不同实现。
有个细节要特别提醒:HTTP渠道的超时设置千万不能只依赖最外层的Executor。因为当Agent并发执行多个动作时,某一个慢请求会占住连接池里的连接,拖慢后续所有动作。我习惯在每个请求级别都设置时间限制,同时在连接池层面设置最大连接数。别把连接池开太大,否则上游系统会被Agent瞬时打挂,我踩过这个坑,给上游数据库造成过一次连接风暴。
3.2 消息渠道(以Slack为例):双向的麻烦
消息渠道比HTTP渠道难在"双向性"。Agent既要发消息,也要接收用户后来的回复,而且这两件事分属不同的入口:发消息走Action,收消息走Webhook/事件订阅。如果适配器只做了"发消息"这一半,Agent就无法感知用户Ack、追问或者取消指令,交互体验会非常断裂。
以Slack渠道为例,我当时拆成两块:OutboundSender负责postMessage,InboundHandler负责处理Events API回调。中间的关联靠一个字段:发送消息时把自己生成的request_id写进message的metadata里,用户回复的thread ts再带回来,这样一条触达链路就闭合了。
class SlackChannel(Channel): name = "slack" async def invoke(self, action: Action) -> ChannelRawResult: if action.action.endswith("_send"): result = await self.outbound.send( channel=action.payload["channel"], text=action.payload["text"], metadata={"request_id": action.metadata["request_id"]} ) return ChannelRawResult(status_code=200, body=result) raise NotImplementedError(f"Unsupported action: {action.action}")这里最需要留意的是:用户回复事件的解析不能和发送逻辑耦合在一起。因为Webhook的回调频率高、格式散、还有重复投递,耦合了之后,一次发送出错会影响接收链路。我后来把消息事件放进一个内部队列,再由处理器消费,并做了事件幂等,才稳定下来。
3.3 结构化存储渠道:数据库与文件系统
Agent经常需要查订单、更新用户状态、写报表,这些动作不一定要走HTTP。直接接一个数据库渠道,反而更小、更快、更安全。
SQL渠道的设计要稍微多想一步:不能简单地把LLM生成的SQL直接丢给数据库执行,那样等于把数据库的钥匙交出去。我采用的方案是模板限制:每个注册动作对应一个预定义的SQL模板,payload里的参数只能作为绑定变量填入,不允许自由拼SQL。这样LLM可以决定"查什么用户、改什么字段",但不能决定"查询语句怎么写"。
class SqlChannel(Channel): name = "sql" def __init__(self, pool: asyncpg.Pool, templates: dict[str, str]): self.pool = pool self.templates = templates async def invoke(self, action: Action) -> ChannelRawResult: template = self.templates.get(action.action) if not template: raise ChannelError(f"SQL template not found: {action.action}") sql = template % action.payload # 只允许具名占位符,禁止拼接 async with self.pool.acquire() as conn: rows = await conn.fetch(sql, *action.payload.values()) return ChannelRawResult(status_code=200, body=rows)文件系统渠道和数据库渠道的套路类似,核心是路径白名单和大小限制。我见过有人把Agent的文件操作做成"任意路径读写",结果Agent根据一个伪造提示词把配置文件给改了。文件路径必须经过一层规范化检查,把相对路径、软链接全部解析后再比对白名单前缀。
3.4 小知识:为什么渠道要实现normalize而不是直接返回业务对象
很多刚接触这个设计的同事问我:normalize这层是不是多余?直接在invoke内部返回ChannelResult不行吗?
不行。关键在于invoke是外部交互层,外部系统的返回格式可能包含加密字段、分页包装、状态码、错误码,这些信息需要先结构化保存。如果直接在invoke里马上转成业务对象,那么一旦后续要重新分析原始返回,就没有数据了。我推荐的策略是:原始返回无损保留,业务对象延迟转换。normalize放在最外层靠近用户的那一侧,转换之前原始数据已经落在日志里了,这就是可观测性的基础。
4. 权限与运行边界:Agent-Reach里的"门禁"
4.1 为什么必须做权限隔离
如果你让LLM直接调用一堆工具,最危险的不是它能力不够,而是它可能在一个"过度授权"的环境里执行动作。LLM的决策是基于概率的,不是基于理解的。给它一个能删库的Tool Calling权限,它可能在某次prompt injection攻击下就把删除动作执行了。这跟你给一个实习生分配了root权限是同一个道理,而且Agent的执行速度快得多、并发高得多。
Agent-Reach中的权限模型借鉴了云厂商IAM的思路:动作本身没有权限,渠道没有权限,只有"策略绑定到动作"这个组合才有权限。一个动作必须被显式授权才能执行,否则默认拒绝。这个"默认拒绝"特别重要,它跟网络安全里的白名单思想一致,是最保守也是最安全的配置。
4.2 三种常见的权限策略设计
我在Agent-Reach里落地了三种策略模式,按复杂度排序:
第一种叫静态白名单策略,在配置文件里写明允许的动作和渠道组合。适合动作数量少、权限变化不频繁的场景。我一般用YAML维护,每次需人工Review才能改。
第二种叫条件表达式策略,策略里可以写谓词,比如"仅当payload里user_id前缀是vip_并且当前请求处于工作时间内才允许执行"。这种适合有合规要求的场景,比如夜间不允许发营销短信、不允许导出超过指定条数的数据。
第三种叫基于规则的动态策略,把策略引擎抽出来,支持类似"用户组+角色+资源"的ABAC模型。这个是留给复杂组织使用的,比如一个Agent能访问订单库还是库存库,取决于它属于哪个Agent族。实现时我用了简单的AST解析,不建议一上来就引入重型规则引擎,YAML + 几个谓词函数通常够用了。
4.3 危险的默认值:我踩过的allow_all坑
这里分享一个真实教训。最早一版Agent-Reach我图省事,在PolicyEngine里加了一个fallback逻辑:如果某个动作没有注册任何策略,就默认放行,理由是"开发阶段方便测试"。结果这个默认放行跟着代码走进了灰度环境,一个写操作动作没注册策略,直接对生产数据跑了。
排查时,我在日志里足足翻了半个小时才意识到是fallback在作祟。因为按我的设计,每一次策略判断都会写一条policy.decision日志,但fallback路径漏掉了这个埋点,所以那几次调用连日志都没有。
那次之后我改了三个地方:默认策略改成拒绝;fallback路径必须打AutomaticDeny日志;每次启动时做一次配置扫描,发现"未注册策略的动作"直接告警。这三个改动看着简单,但真的能救命。如果你也要实现类似的权限层,一定要记住:安全性的最低标准是"宁可误杀,不可放行"。
5. 稳定性治理:重试、熔断与超时
5.1 三层超时模型
Agent场景下的超时不能"一刀切"。一个动作的总耗时,实际上由三层叠加而来:尝试连接上游的时间、上游业务处理的时间、Agent内部排队等待的时间。我建议对这三层分别设置时间限制:
第一层是连接超时,一般设2秒或者3秒。超过这个时间还连不上,大概率是网络或对端负载问题,再等下去也没意义。
第二层是读取超时,也叫响应超时,根据业务复杂度设定,一般5秒到15秒。注意这个时间必须覆盖上游合理的慢查询或外部API处理时间。
第三层是整体执行超时,也就是策略里声明这个动作的上限。比如"查订单必须在5秒内完成",如果超过了,即使上游最终返回成功了,Agent-Reach也会丢弃结果并向Agent返回TIME_OUT。
三个时间层级写进Action的metadata里,让上层Agent可以逐动作覆盖默认值。千万不要在代码里写死全局超时,因为发一条Slack消息和跑一次ETL任务的耗时差着几个数量级。
5.2 重试的幂等难题
重试是稳定性里最容易藏雷的部分。这里我的原则特别明确:没有幂等保护的渠道,不允许自动重试。比如扣款接口,重复提交就是重复扣款;但查询类接口,重复请求风险很低,可以放心重试。
具体落地时,我会在策略里给每个动作配上retryable和idempotent两个布尔值,Dispatcher执行重试前先判断这两个开关:
async def _with_retry(self, action, channel, policy): max_attempts = policy.retry.max_attempts for attempt in range(max_attempts): try: raw = await channel.invoke(action) result = channel.normalize(raw, action) if result.ok or not policy.retry.enabled: return result except (ChannelTimeout, ChannelConnectionError) as e: if not policy.retry.retryable_error(e): raise if not policy.idempotent: raise RetryBlockedError("non-idempotent action cannot retry") await asyncio.sleep(backoff(attempt)) raise MaxRetryExceededError(action)这段代码的核心逻辑是:先看错误类型是否值得重试,再看动作是否幂等。如果动作不幂等但错误本身是幂等的(比如网络握手失败),也可以考虑重试,但需要额外配合请求体里的幂等键去上游做去重。
幂等键我习惯用requestId + 动作名 + payload摘要拼出来,例如req_9f2c_send_sms_0452ab,这样同一个Agent会话内多次触发相同动作也能对上游去重。
5.3 熔断器放在哪里
超时和重试解决的是单次调用的稳定性,但Agent如果同时并发几十个动作,一个上游服务开始变慢,所有请求都会排队等待,最终能把整个Agent拖垮。这时候需要熔断。
熔断器我放在Dispatcher和Channel之间,也就是每次真正调用渠道前先检查熔断状态。基于滑动窗口统计最近N次调用里的失败率和平均耗时,超过阈值就打开熔断开关,接下来的请求直接快速失败,不再真正打到上游。
代码示意:
class CircuitBreaker: def __init__(self, threshold=0.5, window_seconds=60, cooldown_seconds=30): self.threshold = threshold self.window_seconds = window_seconds self.cooldown_seconds = cooldown_seconds self.failures = deque() self.successes = deque() self.state = "closed" def allow_request(self) -> bool: if self.state == "open": if time.monotonic() - self.opened_at > self.cooldown_seconds: self.state = "half_open" return True return False return True def record_success(self): self.successes.append(time.monotonic()) if len(self.successes) > 100: self.successes.popleft() def record_failure(self): self.failures.append(time.monotonic()) if len(self.failures) > 100: self.failures.popleft() if self.state == "closed": total = len(self.failures) + len(self.successes) if total > 20 and len(self.failures) / total > self.threshold: self.state = "open" self.opened_at = time.monotonic()熔断的阈值、窗口、冷却时间都应该是可配置的,而且最好按渠道单独配置。我之前统一给所有渠道用一个熔断参数,结果某条慢SQL渠道频繁触发熔断,影响了一个本来很快的HTTP渠道。分开配置之后,各个渠道之间互不干扰,排查起来也更直观。
6. 排障经验:如何让Agent-Reach的每次触达都可追踪
6.1 request_id贯穿全链路
触达层最怕的事情就是"线上出问题了但不知道是哪个动作引起的"。所以我做了一个强制约束:任何一个Action在进入Executor之前,必须带上request_id。这个request_id从Agent会话入口开始就一直向下传递,落到渠道日志、HTTP头、消息消息体里。
在Agent调用场景里,request_id我拼接了两段:会话ID + 动作序号。比如req_73a1_0003表示第73号会话的第3个动作。这样不用去查额外映射表,光看日志就能知道同一个会话的动作顺序。
HTTP外发时我会把这个ID放进自定义请求头,比如X-Agent-Reach-Id,这样上游系统也能在它们的日志里定位到对应请求,两边对齐只需要一个ID。
6.2 日志三件套:入参、出参、耗时
触达层的日志我不想记太多,但三条必须齐全:入参、出参、耗时。入参指Action的完整原始JSON,出参指ChannelResult的完整返回内容(包含原始raw片段),耗时指从Dispatcher进入渠道到渠道返回的整个时间。
我用的日志格式类似:
[agent-reach] action=send_sms channel=sms_provider request_id=req_73a1_0003 status=OK duration_ms=412 attempts=2 decision=allowed payload={"user_id":"u_1024","template_id":"promo_spring"} result={"ok":true,"provider_msg_id":"m_8871"}一条日志包含所有关键信息,排查问题时基本不用去翻别的系统。唯一要注意的是payload和result里可能带敏感字段,比如手机号、地址,我建议增加一个脱敏配置,把明显敏感字段替换成md5摘要。日志的完备性和安全性都要兼顾。
6.3 一个让我熬夜到凌晨的偶发超时问题
最后分享一个具体的排查案例。某个Agent动作是"生成报表并发送到邮件",功能上线后总出现偶发超时,但超时时间又不长,大约每20次出现一次,持续两到三秒。
一开始我怀疑是结果生成模块慢,给那个动作加了更长的超时,结果没好转。后来怀疑是数据库连接池满了,看监控也没发现。直到我打开了连接池等待时间的指标,才注意到问题出在连接获取上:生成报表的动作会并发拆成多个子查询,但连接池大小设置成了5,而同一时间Agent还会执行其他动作,导致子查询在等空闲连接。
解决方案不是调大连接池,而是给慢动作单独分配一个专用的数据库连接池,避免和其他快动作互相争抢。改动之后,偶发超时立刻消失。这件事给我的教训是:偶发问题先别急着调超时和重试,先看看资源争抢的指标,很多时候问题根本不在触达目标系统那一步,而在我们自己的基础设施层。
7. 从一套代码到平台:Agent-Reach的扩展性设计
7.1 插件加载器:一个渠道一个包
当渠道数量超过十个以后,就不能再往一个文件里堆了。我采用的是插件化加载:每个渠道是一个独立的Python包,包含channel.py、policy.py、README.md和一个schema.json,schema描述这个渠道支持的action列表和参数结构。
Agent-Reach启动时通过entry_point发现机制扫描已安装渠道,构建ChannelRegistry。这样做有两点好处:第一,新增渠道不需要改核心代码,只增加一个包;第二,每个渠道可以拥有自己独立的依赖,例如某个渠道用到了特殊的SDK,不会污染其他渠道的运行环境。
插件化的代价是需要定义清晰的接口契约,并且要经常检查接口是否泄露了内部细节。我的经验是接口方法越少越不容易乱,所以Channel协议一直保持只有invoke和normalize两个方法,其他能力都通过附加的Mixin来提供。
7.2 动态渠道注册表与灰度开关
生产环境里,渠道不能一上线就全量生效。我需要在Agent-Reach里支持按比例灰度某个渠道,比如先让5%的请求走新渠道,95%走老渠道,观察错误率和耗时表现后再逐步放量。
这里实际上用到了注册表的两级结构:一级是逻辑渠道名到渠道实例的映射,另一级是渠道实例到流量配比的映射。Dispatcher取渠道的时候,先通过权重算法选出一个实例,如果选中的实例权重是0就自然跳过。
class WeightedChannelRegistry: def __init__(self): self.logical_channels: dict[str, list[ChannelEntry]] = {} self.rng = random.Random() def add_channel(self, logical_name: str, channel: Channel, weight: float): self.logical_channels.setdefault(logical_name, []).append( ChannelEntry(channel=channel, weight=weight) ) def get(self, logical_name: str) -> Channel: entries = self.logical_channels.get(logical_name) if not entries: raise UnknownChannelError(logical_name) total = sum(e.weight for e in entries) pick = self.rng.uniform(0, total) cursor = 0.0 for entry in entries: cursor += entry.weight if pick <= cursor: return entry.channel return entries[-1].channel配合一个配置中心接口,就可以做到不重启服务动态调整流量比例。灰度开关和熔断器要分开理解,前者是主动控制流量分配,后者是被动保护,但它们都会影响实际触达结果,所以日志里一定要记录"当前请求选择了哪个渠道实例以及它被灰度的权重"。
7.3 渠道健康度指标:从"能用"到"好用"
渠道多了以后,光有请求级日志还不够,需要把关键指标聚合起来,方便做趋势分析和告警。我设计了三个基础指标,涵盖触达层最核心的方面:渠道成功率(2xx或业务层ok)、渠道平均耗时、渠道熔断率(熔断器拒绝请求的比例)。
这三个指标按逻辑渠道名打标签,例如channel=sms_provider,在监控看板上分组展示。设置告警时,我的推荐是分两级:触发持续失败率超过10%时告警,超过50%时立刻拉响优先级告警。因为Agent场景下,一个渠道挂了可能影响多个会话,快一点发现问题能少一堆客诉。
这些指标不一定都放在同一个监控系统里。我当时用了两种存储:近实时指标用Prometheus,明细日志用ES和对象存储。排查问题时先看看板确定时间点,再进日志系统拉完整链路,效率比全靠日志搜索高很多。
写在最后
Agent-Reach这套触达层我实际跑了两三个月,最大的感受是:Agent的智能程度固然重要,但没有一个可靠的"手脚",再聪明的脑子也落不了地。权限、重试、超时、追踪这些事情看起来不性感,做起来也很琐碎,但它们直接决定了Agent系统在真实业务里能不能站住脚。
如果让我给打算做Agent基础设施的朋友一句建议,那就是:先别急着接十个渠道,把一个渠道的权限、稳定性、可观测性完全打通之后,再推广到其他渠道。因为这套机制一旦成型,后面每接一个新渠道都是复制粘贴式的活,而前期的设计决策如果做错了,返工成本会随着渠道数量线性放大。