Agent触达最后一公里:统一信封、路由去重与全链路可观测 📅 发布时间:2026/9/18 13:28:32 👁 浏览次数: 做 Agent 的人大概都经历过同一个场面演示环节行云流水用户问一句、模型答一句台下鼓掌真上线跑一周才发现问题压根不在模型这一层——用户找不到入口业务系统叫不动它它想主动找个人更是无从下手。Agent-Reach 要补的就是这段总被忽略的最后一公里让 Agent 从能回答变成够得着、叫得到、找得上人。我把它定义成一套独立的触达接入层。朝上它给 Agent 一个统一的身份和会话入口朝下它把网页、站内信、邮件、工单、内部沟通工具、开放接口这些形态各异的通道抽象成同一种信封中间它负责路由、去重、重试、限流和全链路埋点。核心关键词其实就三个可达性、统一信封、可观测。这三个词看着平淡但真做起来每一个背后都有一堆参数要算、一堆坑要踩。这篇内容适合三类人正在把 Agent 往业务里塞的后端和平台同学做客服、运营、消息系统的同学以及被消息重复会话串号半夜告警折磨过的运维同学。代码给的是能直接改的骨架思路讲的是我为什么这么选。新手也能看因为我会把每个专业词都用生活里的场景解释一遍老手可以直接跳到第 3、5 章看参数和排查表。1. Agent-Reach 到底在解决什么问题1.1 三个断点入口断、路由断、回流断把 Agent 上线最常见的失败不是答得不好而是根本没人问到它。我把这类失败拆成三个断点分别对应链路的头、中、尾。入口断。用户在你的页面上提问消息落到了一个临时的前端状态里刷新就没了或者客服在工单系统里回复Agent 完全不知道这条工单存在。Agent 没有一个被外部系统叫到的固定地址谁能找到它、用什么格式找它全靠各个业务自己约定最后变成七八套写法。路由断。消息进来了但不知道该给哪个 Agent 处理。是售前咨询还是售后工单是英文还是中文是普通提问还是需要人工兜底的投诉没有一层统一的路由业务代码里就会长出一堆 if-else每加一个场景就改一次主流程改到最后没人敢动。回流断。Agent 处理完了结果回不去。用户那边显示处理中其实早就答完了或者答完了却回写到错误的会话里A 用户的答案飘到了 B 用户的屏幕上。这类问题最要命因为它不报错只是安静地错。我试过一个偷懒的做法把这三件事全塞进业务服务里谁要用谁自己写。结果是三个业务各写了一遍重试逻辑重试参数还不一样其中一个用了固定间隔 1 秒重试 10 次直接把下游打挂。从那之后我就认定了触达这件事必须单独抽一层出来因为它是跨业务的公共契约不该由任何一个业务私自定义。1.2 四类典型场景看看你踩在哪一类不同团队对触达的诉求差别很大我把它归成四类你对号入座一下后面的设计取舍会更好理解。场景类型典型诉求关键约束最容易出的事故对外客服型用户在网页或工单里提问Agent 秒回首响时间要短要能转人工转人工时上下文丢失内部助手型同事在沟通工具里 一下就能用身份要能对上人权限要隔离越权查到别的部门数据主动触达型系统发现异常让 Agent 主动通知人要有频控不能半夜轰炸重复推送、轰炸式提醒系统集成型别的服务通过接口调 Agent 干活幂等、超时、返回结构要统一上游重试导致重复执行我做内部助手的时候踩过一个坑一开始没做身份映射同事在沟通工具里的昵称和工号系统里的姓名对不上导致 Agent 拿不到正确的权限一个同事查到了本不该看到的排班表。后来加了一层身份映射表把通道侧的 ID、内部工号、角色三者绑在一起这类问题才算根治。这一步不难但一定要在设计初期就留位置后补会很痛。主动触达型的坑更隐蔽。我们做过一个异常提醒的 Agent规则写得太宽同一个问题在十分钟内推了四十多条同事直接把机器人禁言了。后来加了同一实体 同一问题类型的窗口聚合十分钟内只推一条并在消息里带上共 N 次的计数体验才回来。1.3 为什么我坚持把它做成独立一层有人会问多一层不就多一次跳转、多一份延迟吗这话对但收益更大。独立一层的第一个好处是契约收敛所有外部系统只需要认识一种信封格式加新通道时改的是适配器不是业务主流程。第二个好处是故障隔离通道侧超时、限流、抖动被这一层挡住不会顺着调用链把业务服务拖死。第三个好处是能力复用去重、限流、埋点、审计这些事做一次全业务都能用。代价我也说清楚链路多了一跳大概多 5 到 20 毫秒多了一份配置要维护出问题时定位的环节变多了。所以我会配套做三件事来抵消——把这一层做成无状态可水平扩展的服务、把全链路 trace id 打通、把配置做成热加载避免重启。这三件事做完多出来的这一层基本就是净赚。2. 整体架构设计把触达拆成四个可替换的部件2.1 身份与会话给 Agent 一个门牌号先说身份。Agent 需要一个稳定的标识我用的是三层结构tenant租户或业务线、agent具体哪个 Agent、channel_user通道侧的用户标识。三者拼起来才是一次会话的唯一键。为什么不只用channel_user因为同一个人在不同业务线里的权限完全不同同一套身份直接复用会串权限。会话的设计更讲究。我见过两种做法一种是一条消息一次会话简单但没法多轮另一种是永久会话上下文越滚越长最后把模型的上下文窗口撑爆。我的做法是滑动过期 分段摘要会话在最后一次交互后保留 30 分钟中间每 10 轮做一次摘要压缩把历史压成一段结构化文本塞进系统提示里。30 分钟这个数不是拍脑袋来的——我看过线上数据客服场景里超过 30 分钟还接着上一句问的用户不到 4%为这 4% 一直保留全量上下文成本划不来。注意会话键里千万不要放时间戳之类的变量否则每次请求都会生成新会话多轮对话直接失效。这个错误我在两个项目里都见过排查起来非常费时间因为日志里每条记录看起来都正常。2.2 路由与编排消息进来之后发生了什么一条消息进到 Agent-Reach 之后会依次经过五个动作校验、归一、匹配、执行、回写。我把它叫五段式流水线每一段的职责单一方便单独替换和单独压测。校验签名、时间戳、来源白名单、体量上限。体量上限我一般设 256 KB超过的直接拒绝防止有人拿大文件来打内存。归一把不同通道的原始报文转成统一信封补上缺失的字段统一时间格式为毫秒时间戳。匹配按路由表找目标 Agent。路由表支持四种匹配维度优先级从高到低是精确会话、精确用户、规则表达式、默认兜底。执行调用 Agent带超时、带重试、带熔断。回写把结果按原通道格式化后送回同时写一份审计记录。这里有个设计上的取舍我想强调编排逻辑不要下沉到通道适配器里。我见过有人在适配器里写了如果消息里带投诉两个字就转人工这种判断结果换了一个通道之后规则就失效了。规则应该集中在路由层适配器只负责格式转换这个边界守住了后面加通道基本不用改逻辑。2.3 通道适配与统一信封为什么死磕一种格式统一信封是整套设计里最值钱的部分。它的价值不在于好看而在于让上游和下游可以独立演进。通道那边改了接口字段只要适配器把映射改一下Agent 侧一行代码都不用动。我的信封里保留这些字段消息 ID、会话键、租户、来源通道、发送者、接收者、内容类型、内容体、附件列表、语言、时间戳、幂等键、扩展字段。字段不多但每一个都有明确的产生方和消费方。其中幂等键由入口生成规则是通道 通道侧消息 ID的哈希这样同一条消息无论重投多少次键都一样去重就能生效。提示扩展字段一定要保留而且要用可序列化的结构。真实业务里总有一些通道特有的东西比如消息是否被引用、是否有话题分组硬塞进固定字段会让信封越来越脏。2.4 存储与状态上下文到底放哪这个问题我纠结了很久。放内存最快但服务一重启就全丢放关系库最稳但每轮对话读写两次QPS 一上来就是瓶颈放缓存最合适但要接受它可能丢。我最终的组合是热数据放缓存冷数据落库中间用异步写入衔接。具体来说会话上下文、路由缓存、去重键放缓存设置合理的过期时间审计记录、消息全文、执行结果异步落到关系库或对象存储允许有秒级延迟。这样热路径只有一次缓存读和一次缓存写延迟能压到几毫秒冷路径异步做不影响主流程。代价是极端情况下可能丢掉最后几条审计记录对业务来说可以接受因为审计本身不是实时的。数据保留策略也要在第一天就定。我一般的做法是消息全文保留 7 天审计摘要保留 180 天会话上下文保留 30 分钟。这三个数字直接影响存储成本别等到磁盘告警了才想起来清理。3. 核心实现细节搭一个最小可用版本3.1 目录结构与依赖选型我把服务拆成四层目录职责一目了然。agent-reach/ app/ api/ # 入口只做参数绑定和调用编排 core/ envelope.py # 统一信封定义与校验 router.py # 路由表匹配 executor.py # 执行、重试、熔断 idempotent.py # 去重 channels/ # 各通道适配器一个通道一个文件 web.py ticket.py mail.py infra/ cache.py db.py metrics.py选型上我偏保守Web 框架用一个轻量的异步框架就够了关键是异步因为触达层的大量时间花在等待下游上同步模型会把线程池吃干。缓存用一个成熟的内存型存储关系库用常见的开源库指标上报直接对接通用监控体系。我不太建议在这层引入复杂的工作流引擎除非你的编排真的到了几十个分支的规模——大多数团队根本用不上反而增加运维负担。3.2 统一信封的设计与代码信封我用数据类来写字段固定校验集中在一个方法里。这样任何通道进来只要转换成功就能往下走。from dataclasses import dataclass, field from typing import Any dataclass class Envelope: msg_id: str # 全局唯一入口生成 session_key: str # 会话键tenant:agent:channel_user tenant: str channel: str sender: str receiver: str content_type: str # text / markdown / file content: str attachments: list field(default_factorylist) lang: str zh ts: int 0 # 毫秒时间戳 idem_key: str extra: dict field(default_factorydict) MAX_BODY 256 * 1024 def validate(self): if not self.msg_id or not self.session_key: raise ValueError(missing_identity) if len(self.content.encode(utf-8)) self.MAX_BODY: raise ValueError(body_too_large) if not self.idem_key: self.idem_key f{self.channel}:{self.msg_id} if self.ts 0: raise ValueError(missing_timestamp) return self这段代码看着简单但每一行都有来历。MAX_BODY用字节数而不是字符数是因为中文字符占多个字节用字符数判断会漏掉大报文idem_key在缺失时自动补是为了兼容那些还没升级的旧通道时间戳必须由入口填不允许下游自己取当前时间否则重试之后时间就漂了跨系统的顺序判断会乱。3.3 路由表的匹配规则与优先级路由匹配我设计成四档优先级命中即返回不再往下走。这个设计的关键是可预测任何一条消息你能一眼看出它会被哪条规则接住。routes: - id: vip-session priority: 100 match: { session_key: t1:sales:vip_8821 } target: sales_agent_v2 - id: user-level priority: 80 match: { tenant: t1, sender: u_10086 } target: sales_agent_v1 - id: rule-based priority: 50 match: { expr: lang zh and 退款 in content } target: aftersale_agent - id: fallback priority: 0 match: {} target: default_agent优先级之间的间隔我留得比较大是为了以后插入新档位时不用改老配置。规则表达式那一档要格外小心我吃过一次亏规则里写了退款 in content结果有个用户问退款流程是什么被直接路由到了售后 Agent而售后 Agent 只处理已购订单答了一堆无关内容。后来改成意图分类 关键词双条件误路由率从 7% 降到了 1% 以内。3.4 幂等、重试与超时参数是算出来的不是猜的这三个参数决定系统的脾气我把计算过程写出来你可以照着套。超时预算。假设对外承诺的首响时间是 3 秒那预算得这么切入口校验 50 毫秒归一 10 毫秒路由 10 毫秒限流与去重检查 30 毫秒Agent 执行 2200 毫秒回写 200 毫秒剩余 500 毫秒留给网络抖动和排队。所以 Agent 侧的硬超时设 2200 毫秒不是随便写的 5 秒——如果设 5 秒端到端必然突破承诺用户早就走了。重试策略。只对幂等且可重试的失败重试比如网络超时、下游返回 5xx。公式是delay_n min(base * 2^n, cap) random(0, jitter_ms) base 200ms, cap 2000ms, jitter_ms 200也就是 200、400、800、1600、2000……最多重试 3 次。为什么加抖动因为不加抖动的话一批同时失败的消息会在同一时刻再次涌向下游形成二次冲击。为什么最多 3 次因为第 4 次重试时用户早就超时离开了重试只是在浪费资源。去重窗口。幂等键的过期时间必须大于最大可能重试窗口。上面算下来最大窗口约 20040080016002000 ≈ 5 秒加上排队和调度我取1 小时。这个数字看起来大得离谱但你要考虑到上游可能自己也在重试而且有些通道的消息会延迟投递。1 小时的成本很低一条键也就几十字节但漏掉的重复消息会让用户体验直接崩掉。参数取值依据Agent 硬超时2200 ms端到端 3s 预算扣除其他环节重试基数200 ms下游首次抖动通常很短重试上限3 次第 4 次已无用户价值退避上限2000 ms避免长尾等待抖动0 到 200 ms打散重试洪峰去重窗口1 小时覆盖最大重试窗口与延迟投递3.5 可观测性埋点出问题时你能看见什么这一层不做埋点等于闭着眼睛开车。我埋三类点日志、指标、链路。日志用结构化格式每条必须带 trace id、msg id、session key、tenant、channel、阶段名、耗时。五个字段一个都不能省因为排查时你往往只知道其中一个。指标我关注四个入口 QPS、路由命中率、执行耗时分布P50/P95/P99、失败率按错误码分类。链路追踪把一次请求经过的所有环节串起来尤其是跨服务调用这是定位到底卡在哪一跳的唯一手段。提示P99 比平均值重要得多。我们有一次线上问题是平均耗时 180 毫秒看着很健康但 P99 到了 8 秒原因是千分之五的请求命中了没建索引的查询。只看平均值的话这个问题永远发现不了。4. 实操过程把一条消息送到 Agent 手里4.1 本地起服务与最小验证本地验证我一般从命令行直接打信封不接任何真实通道先把执行链路跑通。curl -X POST http://localhost:8080/v1/reach \ -H Content-Type: application/json \ -H X-Signature: sig \ -d { msg_id: m_20240101_0001, session_key: t1:sales:u_10086, tenant: t1, channel: web, sender: u_10086, receiver: sales_agent_v2, content_type: text, content: 我这个订单什么时候能到, ts: 1704067200000 }第一次跑通之后我会立刻做三件验证重复投递同一条 msg_id确认只执行一次把 Agent 侧的超时降到 100 毫秒确认重试按预期退避故意发一条 300 KB 的消息确认被拒绝并返回明确的错误码。这三条过了说明去重、重试、体量控制都活着比写十个单测都管用。4.2 接入第一个真实通道接通道的顺序我建议从最简单的那个开始通常是站内信或者测试用的网页入口。原因是通道越简单你能越快暴露信封转换的问题而不是被通道自己的鉴权、回执、格式问题干扰。接通道的步骤基本固定第一步在通道侧配置回调地址指向我们的入口第二步写适配器实现原始报文到信封和结果到通道报文两个方向的转换第三步处理通道特有的回执机制有些通道要求你在几秒内返回一个成功码这时候千万不能等 Agent 执行完再返回要先回执、后异步处理第四步跑一遍回归重点看附件和特殊字符。注意先回执后处理这个模式一定要配上去重和状态机否则用户看到已收到但实际执行失败了会反复追问反而制造更多消息。我的做法是回执里带上一个查询地址用户能在那里看到真实状态。4.3 压力测试与容量估算容量我不靠感觉靠算。单实例的吞吐大概这么估假设异步框架下单实例能撑 200 个并发在途请求平均处理耗时 300 毫秒那么理论 QPS ≈ 200 / 0.3 ≈ 667。但实际要留水位按 60% 算就是单实例 400 QPS。然后看峰值需求。假设日活用户 5 万集中在晚 8 点到 10 点峰值系数按 5 倍算日总请求 15 万次两个小时里占比 60%也就是 9 万次平均 12.5 QPS峰值再乘 3 就是 37.5 QPS。这么一算两台实例绰绰有余。但我仍然会部署四台原因是一台要留着做滚动发布一台要扛实例故障多出来的成本远低于半夜被告警叫醒的成本。压测我关注三个指标在目标 QPS 下 P99 是否还在预算内、错误率是否保持在千分之一以下、下游被调用的速率是否触发了限流。前两个是给自己看的第三个是给下游看的——很多人压测只盯着自己结果把下游压挂了这种事故比自挂更难看。4.4 灰度上线与回滚预案上线我按影子、白名单、小流量、全量四步走。影子阶段只复制流量不真正回写用来验证转换和路由的正确性白名单阶段挑一两个内部用户真跑小流量按 5% 放开观察 24 小时没问题再全量。回滚预案必须在上线前写好包括一个开关能瞬间把流量切回旧链路以及一份哪些数据需要人工补的清单。我见过最狼狈的情况是上线出问题想回滚结果发现新链路已经写了一批会话状态回滚后旧链路不认识这些状态导致一部分用户对话接不上。所以状态格式要么向后兼容要么在灰度期双写。5. 常见问题与排查技巧实录5.1 消息重复、丢失、乱序怎么快速定位重复。先看幂等键有没有落到上游手里。我遇到过的原因有三种上游没传 msg_id 导致每次都生成新的幂等键在缓存里被提前淘汰并发场景下检查再写入不是原子的。第三种最隐蔽解法是把检查和写入合并成一个原子操作而不是分两步做。丢失。丢失通常发生在先回执后处理的模式里回执发了异步任务因为队列满被拒了。排查方法是给异步任务加落盘缓冲并且监控队列长度和拒绝数。这两个指标一涨说明处理能力跟不上了不是丢消息本身的问题是容量问题。乱序。同一个会话的两条消息后发的先到了。原因通常是重试导致的——第一条失败了在退避第二条直接成功。解法是在会话维度加一个单调递增的序号Agent 侧遇到乱序时按序号缓冲或者丢弃过期请求。我给的建议是按序号缓冲窗口设 3 秒超过就直接放行避免为了顺序把延迟拖长。5.2 超时与级联几种典型表现超时最怕的不是超时本身而是级联。表现是这样的下游变慢我们的请求占住并发槽新请求排队排队时间计入超时于是更多请求超时并发槽被占得更满。这个过程几十秒内就能把整个服务拖死。破解手段有三个缺一不可。并发隔离给每个下游分配独立的并发额度一个下游慢不影响其他下游。熔断连续失败到阈值我用 20 次里失败 10 次就快速失败隔 30 秒后放一个请求试探。超时必须逐层递减上游给我们的预算是 2 秒我们给下游的就只能是 1.5 秒绝不能出现下游超时比上游还长的情况那样上游先超时了我们的重试全是无用功。5.3 会话串号的几种隐蔽原因会话串号是最难查的一类问题因为日志里每条记录都看起来对。我遇到过三种原因。第一种会话键的拼接顺序不一致。有的地方写tenant:agent:user有的地方写tenant:user:agent结果同一个人被拆成了两个会话。解法是把拼接逻辑封装成一个函数全项目只允许调这个函数禁止手写字符串。第二种缓存键和会话键混用。有人图省事直接用会话键当缓存键但缓存里有别的业务也在用同一套键空间前缀一撞就串了。解法是所有缓存键加业务前缀。第三种多租户环境下的默认值。某个租户没传 tenant代码里用了默认值t1于是这个租户的数据全落到了 t1 名下。解法是 tenant 缺失直接拒绝不给默认值。这个原则我在所有涉及隔离的字段上都用——隔离字段不允许有默认值。5.4 常见问题速查表现象最可能原因快速验证方法处理方式同一问题被执行多次幂等键不稳定或淘汰过早查同一 msg_id 的执行记录数原子化检查写入延长键过期用户说没收到回复先回执后处理异步任务被拒查队列拒绝数与落盘缓冲扩容消费者加重试缓冲答非所问路由规则过宽打印命中的规则 ID收紧规则加意图条件消息顺序颠倒重试导致后发先至比对同一会话的消息序号按序号缓冲 3 秒响应突然变慢下游变慢导致并发被占满看 P99 与并发槽使用率并发隔离加熔断会话接不上上文会话键拼接不一致全量搜会话键生成点统一封装生成函数某租户数据串了隔离字段有默认值查默认值配置改为缺失即拒绝6. 上线后我总结的几条经验6.1 设计期的最该守住的三个边界第一条适配器不许有业务逻辑。适配器的唯一职责是格式转换和回执。一旦你在里面写了判断后面每加一个通道就要复制一遍判断维护成本是指数增长的。第二条超时与重试必须集中配置不许散落在业务代码里。我见过同一个服务里三个模块用了三套重试参数出问题时谁也说不清到底重试了几次。集中配置还有一个好处是能按下游分别调整某个下游特别慢单独给它放宽不影响其他。第三条隔离字段必须显式传递。tenant、agent、channel 这三个字段任何一层都不允许用默认值兜底。少一个就报错这是代价最小、收益最大的防御手段。6.2 运维阶段的两个习惯给每个告警配一条排查手册。告警响了值班同学能照着手册在五分钟内定位到大致方向而不是从日志第一条开始翻。手册不用长三五行就够看哪个指标、查哪张表、大概率是什么原因。每周做一次失败样本复盘。我会把一周内的失败请求按错误码抽样出来人工看一百条。这个习惯帮我抓到了好几个自动化监控抓不到的问题比如某类特殊字符在某个通道里会被截断错误率只有万分之几但影响的是真实用户。指标看趋势样本看真相两件事都得做。最后分享一个我自己一直在用的小技巧在信封的扩展字段里永远塞一个trace_hint内容是入口侧的最简上下文比如来自工单 8821 的第三次追问。它不参与任何逻辑判断纯粹是给排查用的。出了问题的时候这一句话经常比翻十分钟日志还有用。