Agent触达链路工程化:静默失败、幂等去重与可观测性实践
凌晨两点半被电话叫起来说群里那个 Agent 不回消息了。我第一反应是去看模型服务结果一切正常推理延迟稳定在 400ms 上下错误率为零。真正的问题藏在我们从来没认真看过的一层——触达链路消息是进来了但路由规则把这条消息分给了一个早就被回收的实例任务静静躺在队列里没人报错没人告警用户只看到已读不回。那次之后我就把 Agent-Reach 这套东西单独拎出来当成一个系统来做它不再是我在业务代码里随手写的一个回调而是一层有明确边界、有指标、有降级策略的基础设施。Agent-Reach 说白了解决的就是两个方向的问题外面的世界怎么可靠地找到并叫醒 Agent以及 Agent 怎么安全地把动作送出去。前者叫入站触达后者叫出站触达。绝大多数团队在搭 Agent 的时候把 90% 的精力放在提示词、工具调用、检索质量上触达层往往是最后三天赶出来的胶水代码结果线上出问题的时候八成事故都出在这层胶水上。这篇内容我想把这层东西从头到尾拆一遍适合已经在跑 Agent 但还没把它工程化的同学也适合刚开始设计多通道接入的团队。不强求你有分布式经验但至少要写过一点后端不然有些取舍你体会不到。1. 触达链路不是一根线而是四段职责不同的链路1.1 静默失败才是触达层最要命的地方模型侧出问题通常会给你一个明确的报错超时、限流、上下文超长、工具调用参数不合法。这些错误有堆栈、有错误码、有告警排查起来至少知道往哪个方向看。触达层不一样它的故障形态大多是静默的。消息收进来了回调返回 200网关认为投递成功但消息根本没到 Agent 手里或者 Agent 处理完了结果回执投递失败用户永远收不到回复。整条链路每一段都成功了端到端却是失败的。我后来养成了一个习惯任何一段触达链路的返回值都不能只看 HTTP 状态码。入口层返回 200 只代表我收到了不代表我处理了处理层返回 200 只代表我入队了不代表我执行了出口层返回 200 只代表对方网关收了不代表用户看到了。这四个不代表之间的空隙就是 Agent 事故的高发地带。所以我给 Agent-Reach 定了一条基本设计原则每一段都要有独立的、可查询的状态且状态必须能沿着同一个 trace_id 串起来。没有这条原则后面所有的排查都是靠猜。还有一个反直觉的点触达层的可用性目标应该比模型层更高。模型层挂了你可以降级到规则回复、可以提示稍后再试触达层挂了用户连稍后再试都收不到。所以我在做容量规划的时候触达层的冗余度通常是模型层的两倍听起来很浪费但算一笔账就明白了——模型层是算力成本触达层是连接成本后者的单价低得多用低成本换高可用这笔买卖划算。1.2 入口、路由、激活、回流四段各自的坑在哪把链路拆开看Agent-Reach 一共四段。我列个表把每段的核心职责和最容易翻车的地方对照着看这张表我贴在过道白板上贴了半年。链路分段核心职责最典型的翻车方式关键监控指标入口层多通道接入、鉴权、格式归一、去重通道重推导致重复触达去重命中率、信封解析失败率路由层会话归属判定、实例选择、优先级会话被分到已回收实例路由命中率、会话漂移次数激活层进程唤起、上下文装载、并发控制冷启动超时、上下文串台唤醒耗时 P95、装载失败率回流层动作执行、结果投递、失败重试回执丢失、重复执行副作用投递成功率、死信积压量这四段里入口层和回流层是系统间的问题路由层和激活层是系统内的问题。系统间的坑大多来自对端行为的不确定性——对端会重推、会乱序、会在半夜做批量操作系统内的坑大多来自你自己的状态管理——实例会重启、上下文会被覆盖、并发会打架。两类问题的排查手法完全不同混在一起查会浪费大量时间。我一般的排查顺序是先确认入口层有没有收到消息查原始信封表再确认路由层把它分给了谁查路由日志再确认那个实例有没有被唤醒查实例生命周期事件最后确认回流层有没有把结果送出去查投递记录。这个顺序不能乱因为越靠前的环节越接近事实越靠后的环节越接近推断。从事实往下查比从现象往上猜效率高一个数量级。1.3 什么时候该自建触达层什么时候该用现成的不是所有团队都需要把 Agent-Reach 做成一套自研系统。我的判断标准是三条满足两条以上才建议自建通道数量超过三个且每个通道的消息模型差异很大会话状态需要跨实例共享且有强一致要求延迟预算压到了端到端 2 秒以内。如果只是接一个通道、并发量不大、允许几百毫秒的抖动用现成的消息队列加一点点胶水代码就够了硬做一套抽象反而增加维护成本。我见过一个三人团队花两个月做了一套通用触达框架最后只接了一个通道路由规则只有两条 if-else代码里全是为未来扩展预留的接口结果那个未来一直没来。抽象是要付出理解成本的过早抽象等于给自己挖坑。反过来如果你的场景是多渠道 多租户 强隔离自建几乎是唯一选择因为现成方案在多租户隔离上的默认配置往往过于宽松。这里有个细节值得注意多租户场景下会话标识一定要带租户维度否则两个租户里昵称相同的用户会被合并成同一个会话这种 bug 很难在测试环境复现因为测试环境通常只有一个租户。2. 入口层把五花八门的消息收敛成一个信封2.1 统一信封的字段设计多一个字段就多一份麻烦入口层最重要的一件事是定义一个统一的消息信封所有通道的消息在进入系统时立刻被归一化成这个结构后面的所有环节都只认信封不认原始格式。这个设计的好处是显而易见的——路由层和激活层完全不需要知道消息来自哪个通道通道差异被挡在入口层之内。我给的信封结构大概是这样的字段不多但每个都有存在的理由{ trace_id: tr_8f3a91c2, channel: im_group, channel_msg_id: msg_778812, tenant_key: org_1024, user_key: u_5512, thread_id: th_9090, ts: 1735689600123, content: {type: text, text: 帮我查下昨天的报表}, attachments: [], reply_to: null }trace_id是整条链路的骨架必须在入口层生成并且一路透传下去任何一段都没有权力重新生成。channel_msg_id是对端给的唯一 ID这是幂等的基础如果某个通道不提供这个字段就必须自己用内容哈希加时间窗来构造虽然不够严谨但聊胜于无。user_key千万不要用昵称或者手机号昵称会改、手机号属于敏感信息最稳的是用租户内自增的稳定 ID。thread_id在群聊场景必须区分否则同一个群里多个话题会被揉成一团。一开始我曾经加过十几个字段包括用户语言偏好、渠道版本号、设备信息等等想着以后可能有用。实际情况是三个月后我删掉了九个因为没有任何一段代码读它们反而每次改信封结构都要同步改十几个地方。信封这个东西字段应该按有代码读的标准来加不是按理论上可能有来加。2.2 幂等键怎么选TTL 设多久才算合适入口层的去重是整个 Agent-Reach 里投入产出比最高的一段代码。对端重推是常态而不是异常网络抖动会重推超时会重推对端的定时补偿任务也会重推。如果你不做去重用户发一句话Agent 执行三遍同样的动作那种后果在写操作上可能是灾难性的。去重的实现非常朴素一个带过期时间的原子写就够了import hashlib import redis r redis.Redis(decode_responsesTrue) def is_duplicate(env: dict, ttl_seconds: int 86400) - bool: raw_key f{env[tenant_key]}:{env[channel]}:{env[channel_msg_id]} if not env.get(channel_msg_id): # 对端没给唯一 ID退化成内容指纹 秒级时间窗 fingerprint f{env[user_key]}|{env[content].get(text, )}|{env[ts] // 1000} raw_key hashlib.sha1(fingerprint.encode()).hexdigest() key dedup: raw_key # SET NX 返回 True 表示这是首次出现 return not r.set(key, 1, nxTrue, exttl_seconds)TTL 的取值是这段代码里唯一需要动脑子的地方。设短了对端隔了一小时重推你还是会重复执行设长了Redis 内存压力大而且某些通道的消息 ID 会在较长时间后复用。我的经验值是24 小时起步如果业务里有明显的日切概念就设成 25 小时跨过日切点。如果对端明确告诉你消息 ID 是全局单调递增且永不复用的那 TTL 可以压到 6 小时省内存。注意去重键一定要带租户维度。我曾经因为漏了租户前缀导致两个租户在同一个秒级时间窗里发了完全相同的内容第二条被当成重复丢弃了排查了整整一个下午才发现是键设计的问题。2.3 会话标识别让 Agent 把两个人当成一个人会话标识的设计直接决定了 Agent 会不会串台。最简单的做法是拿tenant_key user_key作为会话主键群聊场景再拼上thread_id。听起来很简单但实际落地时有三类边界情况必须提前想清楚。第一类是多设备场景。同一个人在手机和电脑上分别说话这两条消息应该进同一个会话还是两个会话我的判断是看 Agent 的用途如果是帮我处理事情这类任务型 Agent应该合并因为用户期望的是连续的任务上下文如果是分别管理不同设备上的会话记录这类场景才需要区分。合并的做法是只认user_key设备信息只作为信封里的附加字段存在不参与会话主键构造。第二类是身份绑定场景。很多业务里同一个人在不同通道上有不同的 IDIM 里是im_7788邮件里是mail_abcexample.com。如果不做绑定用户会觉得这个 Agent 怎么这么健忘。绑定关系需要单独维护一张映射表并且要处理一个人绑定了多个渠道后消息应该回复到哪个渠道的问题。我的做法是记录最近一次活跃渠道默认回复到那里除非用户在消息里明确指定。第三类是群聊里的多话题并发。一个群里十个人同时问不同的问题如果没有thread_id所有消息会进同一个会话Agent 的回答顺序会完全乱掉。这里我踩过一次坑群里话题一多上下文长度爆炸模型开始胡言乱语。后来加了thread_id并且给每个话题单独做上下文窗口问题就消失了。3. 激活层冷启动、热驻留和上下文装载的取舍3.1 常驻内存还是按需唤起这笔账要算清楚激活层最核心的决策就一个Agent 实例是一直活着还是收到消息再唤醒。这个问题没有标准答案只能算账。方案唤醒延迟内存占用成本特征适用场景全常驻0ms高每实例数百 MB 起固定成本随实例数线性增长高并发、延迟敏感池化热驻留50-200ms中按池大小定半固定成本有空转浪费中高频触达按需唤醒800ms-3s极低按调用付费冷启动有额外开销低频、可容忍延迟判断该怎么选我用的指标是触达间隔的中位数。如果一个用户的两次触达间隔中位数小于 3 分钟常驻或池化是划算的因为冷启动的开销会被频繁摊薄如果中位数超过 30 分钟按需唤醒通常更省钱因为大部分时间实例在空转。中间区间要看并发峰值峰值高就池化峰值平缓就按需。池化热驻留是我最常用的折中方案具体做法是维护一个大小为 N 的实例池实例处理完消息后不销毁而是回到池里等待下一次分配同时设置空闲超时比如 5 分钟自动回收。这个方案的关键参数是池大小和空闲超时池太小会排队池太大会浪费内存。我一般的调法是先按峰值 QPS 的 1.2 倍估算池大小跑一周后看实例的平均利用率利用率低于 30% 就往下调。3.2 上下文重建Agent 醒来之后怎么想起来按需唤醒的实例是失忆的它的进程内存里什么都没有必须从外部把上下文重新装回来。这个装载过程是激活层里最耗时也最容易出错的一段。上下文通常分三层。第一层是短期记忆也就是最近几轮对话放在 Redis 里按thread_id组织用 List 结构存每次装载取最后 N 条。第二层是会话摘要当短期记忆超过一定长度时用一个小模型把早期对话压缩成摘要避免每次都把全部历史塞进上下文。第三层是长期记忆靠检索拿到通常走向量库返回与当前提问最相关的若干片段。这三层装载完之后要拼接进上下文窗口拼接时的预算分配我摸索了很久才找到比较稳的比例系统提示和工具定义占 10% 到 15%短期记忆占 35% 到 40%检索片段占 25% 到 30%剩余 20% 留作模型输出的空间。这个比例不是死的但核心原则是必须给输出留空间。我见过太多人把上下文塞到 95% 满结果模型一句话都说不完整就被截断了。还有一个细节容易被忽略装载过程要能部分降级。如果向量库挂了检索层拿不到结果这个时候不应该让整条链路失败而应该降级成只用短期记忆同时在回执里标注本次回答未使用历史资料。降级策略一定要提前设计好线上临时加降级方案基本来不及。3.3 排队和限流别让一波流量把 Agent 打爆触达层面对的是不可控的外部流量用户什么时候发消息你管不了所以队列和限流是必需品而不是奢侈品。我的做法是三层限流。第一层是单用户串行。同一个user_key的消息必须串行处理否则用户连发三条消息Agent 会并发执行三个任务最后回复顺序错乱甚至互相干扰对方的上下文。串行的实现方式是在路由层按user_key取一把分布式锁拿不到锁的消息进等待队列。第二层是全局并发上限。整个系统同一时刻处理的实例数不能超过某个阈值这个阈值取决于模型服务的配额和内存上限一般取模型侧并发配额乘以 0.8作为安全边界。超过上限的消息进入延迟队列按指数退避重试入队。第三层是优先级分级。不是所有消息都同等重要用户的直接提问应该优先于系统内部的定时任务付费租户的请求应该优先于免费租户。我给信封加了一个内部字段priority取值 0 到 9路由层按优先级维护多个队列高优先级队列先消费。提示队列积压量是必须告警的指标但告警阈值不能设成固定值。业务高峰期积压 1000 条是正常的凌晨积压 1000 条就是出事了。我一般用过去七天同一时段均值的 3 倍作为动态阈值。4. 回流层Agent 主动触达外部世界的刹车与油门4.1 出口动作的权限分级写操作必须踩一脚刹车回流入站消息是被动的Agent 只需要响应回流出口动作是主动的Agent 会去调外部接口、发消息、改数据。这一段的危险程度比入口层高一个量级因为入口层出错最多是不回复出口层出错可能造成真实的业务损失。我把出口动作分成四级。只读级查询接口、搜索、读文件可以全自动执行不需要额外审批。写入级创建记录、更新状态、发送通知需要执行前做参数校验和影响面评估如果涉及的数据条数超过阈值就拦下来。对外发送级给外部用户发消息、发邮件、提交表单需要额外的模板校验和频率限制防止 Agent 被诱导发送不当内容。高影响级涉及金额、批量删除、不可逆操作一律需要人工确认Agent 只负责生成待办和预演结果。这套分级的意义在于把可控性做成一个显式的工程结构而不是寄希望于提示词里写一句请谨慎操作。提示词的约束力是不稳定的代码结构的约束力是确定的。我踩过的最典型的一个坑是Agent 在没有分级约束的情况下把一次帮我清理测试数据的请求理解成了真删因为测试库和生产库的连接配置放在了同一个配置文件里只在启动参数上做区分。后来我强制要求所有写操作都必须经过权限分级网关网关里硬编码了环境白名单非白名单环境的写操作直接拒绝。4.2 重试策略和死信队列哪些错误该重试哪些不该出口动作失败是常态关键是要区分值得重试的失败和重试也没用的失败。我的分类标准是这样的错误类型是否重试典型处理方式网络超时、连接重置是指数退避重试 3 次对端限流429是读取 Retry-After按指示等待参数校验失败400否直接进死信触发人工排查鉴权失败401/403否直接进死信同时报警对端服务不可用503是退避重试超过 3 次进死信业务语义错误否进死信并记录完整上下文重试的退避曲线我一般用 1 秒、4 秒、16 秒再加 0 到 500 毫秒的随机抖动。抖动很重要不加抖动的话一批同时失败的消息会在同一时刻同时重试形成二次冲击把刚刚恢复的对端又打挂。死信队列的价值不在于存起来回头再处理而在于让人看见。我的做法是死信进来之后立刻推送一条结构化告警包含 trace_id、失败动作、错误码和信封快照值班同学可以直接拿 trace_id 去查完整链路。死信积压超过一定数量还要升级告警等级因为死信往往不是孤立的一条死信背后可能是一整批同类失败。4.3 事件轨迹让每一次触达都留下可回放的记录可观测性这件事我建议从第一天就做别等到出事再补因为出事的时候你补的埋点往往抓不到关键信息。Agent-Reach 里的核心可观测性载体是一张事件轨迹表每次触达的关键节点都往里写一条记录字段大致是trace_id、stage、event、ts、detail。真正有价值的不是记录本身而是能按trace_id把整条链路快速拉出来。我给内部做了一个简单的查询接口输入 trace_id返回一条时间轴把入口、路由、激活、执行、回流五个阶段的所有事件按时间排序展示每两条事件之间标注耗时。这个视图救了我很多次因为很多问题的根因不在于某个环节失败而在于某个环节耗时异常比如上下文装载花了 4 秒把整个端到端延迟拖垮了单看每个环节的返回值都是成功的。这里有个小经验事件里不要只写成功/失败要写关键的数量信息。比如检索层的事件里应该带上召回了 12 条、过滤后剩 5 条、最终注入 3 条因为很多问题的表现是成功了但结果是错的只看状态码永远看不出来。5. 触达质量得用数字说话几个我长期在盯的指标5.1 端到端延迟要拆开看不然你不知道该优化哪一段端到端延迟是最常被提到的指标也是最容易被误读的指标。只看一个 P95 数字你没法判断该优化什么。我的做法是把端到端延迟拆成四段每段单独统计总延迟 入口处理耗时 路由耗时 唤醒与装载耗时 执行与投递耗时拆开之后你会发现很多时候大头不在模型推理上。我经手过的一个真实案例里模型推理只占 600ms但上下文装载占了 2.3 秒原因是向量检索没有加缓存每次都要全量扫。优化了缓存之后装载降到 300ms端到端延迟直接砍掉一半。如果不拆开看你可能一直在优化提示词长度效果微乎其微。除了延迟还有三个我必看的指标。触达成功率要按通道分别统计因为不同通道的稳定性差异很大混在一起看会掩盖个别通道的问题。去重命中率突然升高通常意味着对端在重推是个预警信号。上下文装载失败率如果超过 0.5%说明存储层或者检索层有问题需要立刻排查。5.2 合成探针每分钟发一条假消息走完整链路监控指标只能告诉你已知的环节出问题了但触达层最怕的是未知的断裂。所以我一直坚持跑合成探针每分钟往系统里注入一条特殊标记的消息走完整的入口、路由、激活、执行、回流链路最后在出口处校验回执是否正确。关键点是必须走完整链路不能走 mock。我见过一些团队为了省钱探针只测到入口层就返回成功结果入口层完全健康激活层的冷启动早就挂了两个月没人发现。探针的成本其实很低一分钟一条一天也就 1440 条相比一次线上事故的损失可以忽略。探针消息要打上明确的标记字段走一条影子路径——在业务逻辑上和真实消息完全一样但在数据写入时被路由到影子表不污染真实数据。这个隔离必须在设计探针的时候就做好否则探针会往用户的会话里插入垃圾消息。5.3 一个可以抄的压测脚本骨架压测是验证触达层承载能力的唯一手段但压测有个大坑不要打真实通道。真实通道的对端网关会因为压测流量把你限流甚至封禁而且压测数据会污染真实用户的会话。压测的目标应该是一个沙箱通道它在入口层接受消息但在回流层把动作写到内存队列而不是真实外部系统。脚本骨架大概是这样import asyncio import time import uuid async def send_one(session, user_pool, results): user_key user_pool[uuid.uuid4().int % len(user_pool)] payload { trace_id: fbench_{uuid.uuid4().hex[:12]}, channel: sandbox, channel_msg_id: uuid.uuid4().hex, tenant_key: bench_tenant, user_key: user_key, thread_id: fth_{user_key}, ts: int(time.time() * 1000), content: {type: text, text: 压测消息} } start time.perf_counter() async with session.post(ENTRY_URL, jsonpayload) as resp: await resp.read() status resp.status results.append((status, time.perf_counter() - start)) async def main(total5000, concurrency50): results [] sem asyncio.Semaphore(concurrency) async with aiohttp.ClientSession() as session: async def guarded(): async with sem: await send_one(session, [fu_{i} for i in range(500)], results) await asyncio.gather(*[guarded() for _ in range(total)]) ok [d for s, d in results if s 200] ok.sort() print(f成功 {len(ok)}/{total}, P50{ok[len(ok)//2]*1000:.1f}ms, fP95{ok[int(len(ok)*0.95)]*1000:.1f}ms)用户池要准备至少几百个不同的user_key因为同一个用户是串行的如果压测只用几个用户你测出来的其实是串行队列的吞吐而不是系统的并发能力。还有一点压测时观察队列积压量的曲线找到它开始持续上升的那个拐点那个并发数就是当前架构的实际上限比任何理论计算都准。6. 三次真实的排查过程坑长什么样6.1 Agent 反复执行同一个动作根因居然在幂等 TTL 上现象是用户反馈我只让它删了一条记录它删了四条。第一反应是 Agent 逻辑有问题但看执行日志四条删除的时间戳分别是 0 秒、2 秒、18 秒、62 秒间隔完全不符合 Agent 内部重试的退避曲线更像是外部重推。顺着这个思路查入口层的原始信封表发现确实有四条一模一样的消息channel_msg_id完全相同说明对端重推了。但幂等逻辑明明写了为什么没拦住去看 Redis 里的去重键发现键存在但值被覆盖了四次。问题出在去重键的构造上——我用了tenant_key channel channel_msg_id但那个通道在同一个租户下会用不同的channel值因为接入时把群聊和私聊当成了两个不同的 channel而对端重推时 channel 值发生了变化。键不一样幂等自然失效。修复方案是把幂等键的范围收窄只用对端给的稳定标识不掺入接入层自己定义的分类字段。这个坑教给我一个原则幂等键的构成要素必须全部来自对端的稳定标识不能掺入自己系统的任何加工字段因为你自己的字段语义可能会变。6.2 心跳还在跳消息却发不进去现象是监控面板上实例状态全是健康的心跳每 5 秒一次从没断过但用户反馈消息发出去几分钟没反应。查队列积压发现积压量在缓慢上升说明消息进来了但没被消费。这个问题的本质是长连接的半开状态。TCP 连接可能因为中间网络设备的会话表过期而被单向丢弃客户端心跳包能发出去因为发送路径还通但服务端的响应回不来或者服务端以为连接还在实际上对端的接收缓冲区早就没了。心跳机制如果只在应用层做我还在不做我能收到你的消息的验证就会漏掉这种半开状态。解决办法有两个我两个都做了。第一个是在心跳里带上递增的序号服务端必须原样回显客户端校验回显是否正确这样能同时验证上下行。第二个是服务端设置空闲超时如果一个连接超过 90 秒没有收到任何业务数据主动断开并让客户端重连。这两个加在一起之后半开连接的最长存活时间被压缩到了 90 秒以内。6.3 上下文串台罪魁祸首是一个没带 user_key 的全局缓存现象很诡异用户 A 提问Agent 的回答里提到了用户 B 的项目信息。第一反应是数据隔离出问题但查存储层所有查询都带了user_key条件权限也是对的。排查到第三步才找到根因激活层有一个最近使用的工具定义缓存为了省事做成了全局字典键是工具名值是工具定义。这个缓存本身不含用户数据看起来无害。但问题在于工具定义里有一个字段是当前会话的默认参数这个字段是在装载阶段根据用户上下文动态填进去的。全局缓存把第一个用户的默认参数固化了下来后面的用户复用缓存时就把 A 的参数带进了 B 的会话。这个坑的关键教训是缓存键的设计要跟着数据的实际作用域走。一个缓存如果包含了任何与用户相关的字段它的键就必须带用户维度。判断标准很简单——问自己这个缓存对象在两个不同用户之间复用会不会产生错误结果只要答案是可能会就必须带用户维度哪怕 99% 的字段是公共的。排查完了之后我给所有缓存加了一层强制校验缓存对象的构造函数必须显式声明作用域全局/租户/用户/会话运行时如果发现作用域声明和键的构成不匹配直接抛异常。这个校验在测试环境拦下过两次类似的隐患成本很低但收益很高。7. 从单实例到集群触达层怎么平滑扩展7.1 会话粘性和状态外置两条路各有代价单实例的时候所有会话状态都在进程内存里简单直接。一旦要扩到多实例立刻面临一个选择要么把同一会话的消息总是路由到同一个实例会话粘性要么把所有状态都外置到共享存储无状态化。会话粘性的实现成本低路由层按user_key做一致性哈希就行实例内部可以继续用内存缓存性能好。代价是实例扩缩容或者重启的时候会有一批会话发生漂移需要重新装载上下文表现为那部分用户的响应突然变慢。另外如果某个用户特别活跃他会把承载他的那个实例压得很重而其他实例很闲负载不均。状态外置的代价是每次读写都要走网络延迟增加而且共享存储本身会成为瓶颈和单点。好处是实例完全对等扩缩容无感负载天然均衡。维度会话粘性状态外置实现复杂度低中到高单次响应延迟低内存命中中网络往返扩缩容影响有会话漂移无感负载均衡依赖哈希质量天然均衡故障恢复实例挂掉丢状态状态在外部恢复快我的选择是折中热状态走粘性冷状态走外置。最近几轮对话放在实例内存里靠粘性保证命中完整历史、摘要、长期记忆放在外部存储需要时才读。这样既保住了热路径的性能又让实例重启时能够从外部把状态恢复回来不会彻底失忆。7.2 迁移路径要分三步走别一次到位从单实例迁到集群我的建议是分三步每步都能独立上线并观察一段时间不要试图一次重构到位。第一步是把状态读写全部收口到一个抽象接口后面即使实现还是内存字典但调用方已经改成走接口了。这一步不改变任何行为只是把状态在哪里这件事从散落各处的代码里抽出来是后面两步的前提。这一步大概占整个迁移工作量的四成看起来做了很多无用功但没有它后面会非常痛苦。第二步是把冷状态搬到外部存储热状态仍然留在内存同时开启会话粘性。这一步上线的观察重点是上下文装载的失败率和耗时如果装载耗时明显上升说明存储的读写模式需要优化比如加批量读、加本地缓存。第三步才是逐步把热状态也外置同时把粘性路由换成随机路由。这一步必须在小流量上先跑因为热状态外置会直接影响每次响应的延迟一旦存储抖动用户立刻能感知到。整个过程我最大的体会是触达层的迁移要以周为单位观察不要以天为单位。因为很多问题不是立刻暴露的而是随着缓存逐渐过期、实例逐渐重启、会话逐渐漂移才慢慢浮现。急着推进度的结果往往是两周后集中爆发一批问题那时候已经很难定位是哪个步骤引入的了。最后分享一个小技巧。我在 Agent-Reach 的入口层加了一个影子记录机制每一条进来的消息除了正常处理之外还会往一张只写不读的表里落一份完整快照保留七天。这张表平时没有任何查询只在出事的时候才用但它的价值极大——因为当你需要排查一个三天前的问题时业务库里的数据可能已经被后续操作覆盖了日志也可能已经滚动删除了只有这张影子表还留着原始的样子。存储成本很低一个月的量在我们这里也就几十 GB相比它节省的排查时间完全不值一提。另外如果你现在还没给触达链路加 trace_id我建议这件事排在所有优化之前做它不提升任何性能但它决定了你未来所有问题的排查效率。