Agent 服务要扩多副本:会话与任务状态该放在哪

发布时间:2026/10/14 11:20:44
Agent 服务要扩多副本:会话与任务状态该放在哪
说明本文讨论的是 Agent 服务从单实例扩到多副本时会话状态、任务状态与控制状态该留在进程内还是搬到进程外属于 Agent 工程落地话题不涉及具体模型版本与价格。AI 领域版本迭代极快凡涉及版本号、价格、可用性请以你阅读时的官方页面为准。文中代码为结构示意请按自己的技术栈调整后再上生产。一、单实例跑得好多实例就乱三类状态生命周期差一个量级Agent 服务与普通 HTTP 接口的差别不在它调用了模型而在它记着事。把这些记着的事拆开至少有三类会话状态session state——多轮对话的上下文、当前任务指针生命周期在分钟到小时量级。任务状态task state——一个长任务跑到第几步、中间产物放在哪生命周期在小时到天量级。控制状态control state——幂等键idempotency key、去重标记、并发占位生命周期在秒到分钟量级。三类状态都需要被别的实例看见差别在生命周期的量级。把三者塞进同一个存储、配同一个过期时间必然有一类被拖累或提前过期。请求越长撞上实例退出的概率越高单实例时进程内的状态看起来是免费的读写就是内存操作没有网络往返也没有序列化。代价藏在实例什么时候消失这件事里。普通接口的请求时长在几十毫秒量级一次滚动更新rolling update、一次节点回收、一次进程重启撞上在途请求的概率很低。Agent 请求不一样一次带工具调用的任务跑几十秒到几分钟是常态请求时长被放大三个数量级。粗算一笔假设单个实例平均 6 小时被替换一次一个 90 秒的请求撞上实例退出的概率约为 90 ÷ 21600 ≈ 0.4%。单实例时无所谓开到 20 个副本、每天几十万次请求就是每天上千次会话状态被清掉。更麻烦的是它不可复现用户说它上一句还记得我选的是北京下一句就问我城市了日志里只能看到那条会话从一个不认识的实例开始前面没有任何报错。加个副本不能直接解决问题最直觉的做法是让每个副本各存一份本地状态会话跟着副本走。这条路通向粘性路由第四章讲它的取舍。先记住代价实例从此是有状态的把它从可服务池里摘走drain之前必须等本地那份状态落完否则那一批会话直接消失。另一条路是每次请求都从外部存储读写实例随时可以杀代价是每轮多一到两次外部读写而且外部存储成了进程级依赖——它挂了整片服务一起不可用。两条路都要先回答同一个问题哪些东西值得搬出去。二、哪些必须搬出去哪些留下分档判据别的实例要不要看见它判据只有两条同时成立才值得外置别的实例需要看见它会话的下一轮可能落到任意副本本实例消失后还得接着用重启、滚动更新、节点回收之后不能丢。两条只满足一条的都不必外置只满足前者的可以本地缓存、外部留底只满足后者的外置纯属浪费一次往返。状态生命周期别的实例要看得见实例挂了要接着用归属多轮对话上下文、当前任务指针分钟—小时是是外置长任务进度与步骤标记小时—天是是外置任务中间产物大文件、长文本小时—天是是外置对象存储 object storage状态记录只放指针幂等键、去重标记秒—分钟是视业务外置短过期单请求内的中间变量、工具调用栈与请求同生命周期否否留在进程内分词表、静态提示模板、编译后的执行图与实例同生命周期否可重建留在进程内推理侧的键值缓存、连接池、本地文件缓存与实例同生命周期否可重建留在进程内什么时候不该外置第一类是生命周期与本请求绑定的请求内的中间变量、工具调用栈、当前轮的 token 计数请求结束就没有意义外置只换来每个请求多一次序列化与网络往返。第二类是可重建且重建便宜的分词表、静态提示模板、编译后的执行图启动时加载一次、挂了重来外置不提升可用性只让每次请求多一次反序列化。第三类是有本地亲和的资源推理侧的键值缓存、模型连接池、本地文件缓存它们物理上没法跨实例共享硬做共享只会把热点集中到一处。最常见的判断错误是拿这个状态重不重要当标准。重要的状态也可能不该外置——能被便宜重建的、只在一个请求内有效的外置只增加失败面。混在一个存储里的三个后果过期时间无法同时满足。TTL 取长的控制状态的键永远清不掉、键空间无上限膨胀取短的任务做到一半就消失。访问模式互相干扰。任务状态写大块产物、低频会话状态写小对象、高频放在同一个实例上一次大写入会挤掉一批热键的命中。故障影响面合并。三类共用一套存储它抖动一次三个不相干的功能同时报警排查时先要做一次归因用量也没法按类别拆开看。落地上不需要三种存储只需要三种命名空间与过期策略同一套存储按前缀或按库分开各自设置 TTL 与淘汰策略。三、外置载体怎么选四个候选先看它在不在关键路径上外部存储的读写在每个请求的关键路径上所以延迟是第一顺位。载体典型读延迟丢失窗口查询能力适合放什么不宜放什么内存缓存亚毫秒大异步落盘时可达秒级到分钟级仅按键会话状态热数据、控制状态唯一真相源、需要按条件查的数据键值库单毫秒可做到零同步落盘时按键部分支持二级索引会话状态、控制状态复杂条件查询、事务性更新关系库单毫秒到十毫秒零完整 SQL、唯一约束、事务任务状态、幂等键高频小对象连接与事务开销占比高对象存储几十到几百毫秒零仅按键任务中间产物、长文本、文件关键路径上的小对象读写比的是三件事延迟、丢失窗口、一致性读写延迟。一次 Agent 请求可能读三次状态、写两次。单次状态读写从 0.5 毫秒换到 10 毫秒端到端就多出 50 毫秒——对分钟级的请求不算什么对首字延迟敏感的场景不能忽略。丢失窗口loss window。它回答写失败或进程崩溃时能容忍丢多久这个数只能由业务给控制状态丢了会导致重复执行通常要求零丢失会话状态的最后一轮丢几百毫秒用户重发一句就补上。一致性要求。同一会话的并发请求是否必须看到同一份状态。冲突怎么处置不属于本篇但它影响选型要求强的就不能拿最终一致的缓存当真相源。大产物与状态指针要分开存状态记录里只放指针对象键、行 ID、摘要、结果大小。产物本体进对象存储。理由不是省空间是省读写放大状态记录每个请求读一次、每轮写一次如果里面塞着整段对话历史或完整工具输出几轮之后单条记录会涨到几百 KB每次追加都要整体重写、整体传输。指针式记录能稳定在几 KB 量级一直留在热缓存里。⚠️ 代码待验证{session_id:s_7f3a9c,state_version:3,task_pointer:{task_id:t_2c91,step:4,status:running},context_ref:{kind:object,key:ctx/s_7f3a9c/turn_12.json,bytes:48211},artifacts_ref:[{kind:object,key:out/t_2c91/report.md,bytes:120442}],updated_at_ms:1792000000000}注意context_ref与artifacts_ref里没有内容本体只有键与大小。时间字段写成updated_at_ms而不是updated_at把单位写进字段名——这个习惯能省掉一次故障。按类别分开读写路径的封装大致是这个形状。⚠️ 代码待验证# 三类状态各自独立的读写封装命名空间、TTL、失败语义都不共用STATE_CLASS{session:{ns:sess,ttl_s:3600,write:sync},task:{ns:task,ttl_s:604800,write:async},control:{ns:ctrl,ttl_s:180,write:sync},}defread_state(kind:str,key:str,store)-dict:cfgSTATE_CLASS[kind]try:rawstore.get(f{cfg[ns]}:{key},timeout_ms200)exceptStoreError:# 读失败与读不到必须分开上报否则报警没有指向性return{status:error,kind:kind}ifrawisNone:return{status:missing,kind:kind}return{status:ok,kind:kind,value:raw}四、路由粘性还是完全无状态两条路的取舍粘性路由sticky session同一个会话固定落到同一个实例实例本地缓存状态、异步持久化到外部。改动最小负载均衡上加一个哈希键通常就能跑起来。完全无状态stateless每次请求都从外部存储读写状态实例不保留跨请求的东西。维度粘性路由完全无状态改造成本低路由层加哈希键中状态读写全部抽到存储层单轮额外延迟通常为零本地命中每轮多一到两次外部读写扩缩容自由度实例数变化会影响哈希映射随意扩缩摘流量的代价要等本地状态落完可能几十秒摘完即可退出故障影响面单实例故障影响其上绑定的会话单实例故障无影响长尾风险热点会话能把单实例吃满热点集中在存储层粘性路由真正的坑哈希键与摘流量哈希键选错会把粘性变成热点。按用户 ID 哈希一个重度用户开出的十个会话会全部落到同一个实例按会话 ID 哈希更合理但如果会话 ID 是单调递增的时间戳加自增序号新会话会集中到一个哈希区间滚动扩缩容时新实例接不到人、老实例先撑爆。做法是给哈希键加随机盐或者用一致性哈希consistent hashing配合虚拟节点打散。第二个坑是摘流量。粘性路由下实例本地那份状态就是最新副本把它从可服务池里移除时必须先停止接收新会话、等在途请求结束、把本地状态落完才能关进程。完整动作顺序属于部署侧话题。混合做法本地当加速层不当真相源折中是两者叠加请求优先读本地缓存本地没有就回落外部存储并回填写的时候先写外部、再更新本地。前提是本地缓存必须允许丢——允许丢它就是加速层不允许丢它就变成了真相源。⚠️ 代码待验证# 路由与状态读取的配置示意粘性只做加速真相源在外部routing:strategy:consistent_hashhash_key:session_id# 不用 user_id避免重度用户堆在单实例salt:2026-10# 定期换盐打散单调递增的会话 IDfallback:external_store# 本地未命中时回落state:source_of_truth:kv_store# 本地缓存不是真相源local_cache:ttl_seconds:300max_bytes:67108864# 超限按 LRU 淘汰允许丢弃write:mode:write_through# 先写外部再更新本地on_external_failure:reject最后一行是关键外部写失败时如果还去更新本地本地就缓存了一份其实没落库的状态同一会话在不同实例上会读到不一样的内容。五、写入时序与丢失窗口先落存储再回复还是先回复再异步落两种顺序的差别只有一个崩在中间那个窗口里会丢多少。写入时序关键路径延迟崩溃时丢什么适合的状态先落存储再回复write-through多一次写延迟不丢控制状态、会话状态的当前轮先回复再异步落write-behind无附加延迟最后一次未刷盘的写会话状态的历史轮、任务进度标记结论不是二选一而是按状态类别分别选状态允许异步落可容忍的丢失窗口依据幂等键、去重标记否零丢了会重复执行会话状态的当前轮否零丢了下一轮直接接错会话状态的历史轮是秒级少一句历史用户能自己补任务进度标记是秒级到十秒级顶多多跑一小段任务中间产物指针是但需先落产物再写指针零指针与产物要一致指向不存在的产物比丢进度更难查最后一行是容易踩的坑产物和指针的写入顺序必须是先产物、后指针。反过来会出现状态说产物在去取却不存在失败重试会卡在这个不一致上。这个选择必须在设计阶段定下来。事后改要动整条调用链同步改异步所有依赖写完即可读的代码要重新审异步改同步等于给每个请求加上一次外部写延迟超时预算要重算。崩溃窗口有多宽丢失窗口不是拍一个几秒出来它是三段时间之和异步刷盘的批量间隔T_batch写超时与重试的累计时间T_retry (retry 1) × timeout进程退出时的残留处理时间T_shutdown。一笔具体的账批量间隔 200 毫秒、单次写超时 1 秒、重试 2 次、优雅退出预留 500 毫秒最坏窗口约为 200 3000 500 ≈ 3.7 秒。实例被强杀时最近三秒多的状态可能没落库。定这个窗口要从业务倒推会话的当前轮丢一秒用户重发一句就补上幂等键丢一秒客户端重试会穿透过去执行第二次。所以幂等键不能进异步通道。幂等键要覆盖到哪一步幂等键的生命周期必须覆盖请求最长时长 客户端重试窗口。如果它只有 60 秒而 Agent 请求能跑 3 分钟客户端的第一次重试落在键过期之后服务端会当成新请求同一件事执行两遍。落地要点有三个写入用仅当不存在时创建的原子语义关系库的唯一约束、键值库的 SET NX 都行过期时间按最大请求时长 最大重试退避 余量设键的取值由客户端给出服务端生成的话重试时拿不到同一个键。同一会话两个请求同时到达该怎么排序、要不要串行化是另一个问题本篇不展开——这里只解决键放在哪、活多久。完整版资料清单本文用到的状态分档表与存储载体对照都整理在里面了扫码即可获取六、状态也要有版本滚动更新期两版代码读同一份状态滚动更新期间会同时存在两版代码而它们读同一份序列化状态。这件事在无状态服务里不存在在多副本 Agent 服务里是默认状态。四类改动会让新代码读不了旧状态字段增删新代码把某个字段当必填枚举改名旧状态里写着pending新代码只认queued时间戳单位变化毫秒改秒数值对但含义变了嵌套层级调整原来平铺的字段挪进了一个对象。其中枚举改名排查起来最花时间反序列化成功、字段也在只是取值不在新代码的枚举集合里代码走到没有默认分支的地方行为取决于语言运行时的默认值。三种兼容策略与它们的代价策略做法适用状态代价向前兼容新代码能读旧状态新增字段给默认值会话状态、任务状态需要长期维护默认值语义双写过渡过渡期同时写新旧两版任务状态两份额外写入需要明确的收尾时间点显式拒绝并重建版本不认识就丢弃走重建只有可重建的状态会话历史用户会丢上下文幂等键不能这么做默认选项是向前兼容新增字段一律可选删除字段先标废弃但保留读取分支枚举改名走新增一个值、旧值同时接受等旧状态全部过期再删旧分支。旧状态的过期时间就是这段兼容代码的最短存活期。读不了的时候拒绝、迁移还是重建先要能判断读不了是哪种三种情况处理方式完全不同版本比当前新说明请求在更新期间落到了旧实例上这是部署顺序问题不是数据问题应该拒绝并让路由重试到新实例版本比当前旧走兼容分支解析失败字段缺失或格式损坏走重建。判断依据落在状态记录里的state_version字段上分支要显式写出来不要靠字段不存在就跳过这种隐式兜底——隐式兜底在半年后没人记得它当初跳过的是什么。⚠️ 代码待验证读取状态后的分派结构示意不是可运行代码 read_state(session_id): raw store.get(session_id) if raw is None: - REBUILD # 从未写过或已过期 obj try_parse(raw) if obj is None: - REBUILD # 格式损坏 v obj.state_version if v CURRENT_VERSION: - REJECT # 路由重试到新实例 if v MIN_SUPPORTED: - REBUILD # 超出兼容窗口 if v CURRENT_VERSION: - MIGRATE_CONTINUE # 走向前兼容分支 - CONTINUEREJECT与REBUILD必须分开前者是换个实例再试一次就好后者是这份数据没用了。合成一条路径实例数抖动时的表现会变得无法解释。七、观测与落地顺序四组要看的数外置之后状态从内存里的一个字典变成了一个独立依赖必须单独观测。外部存储的读写分位P50 / P95 / P99。它整体叠加在请求延迟上要看 P99 而不是均值——均值 1 毫秒、P99 80 毫秒的存储会以超时的形式出现在每一千个请求里的那一个上。状态读写失败率。必须区分读不到从没写过或已过期和读失败存储故障或超时混在一起报警就没有指向性。重建次数。多少会话因为状态读不到而走了重建路径这个数就是丢失窗口的真实发生率。跨实例命中率。粘性路由下请求落到非绑定实例的比例。持续偏高说明哈希不稳定或实例数在频繁变化粘性名存实亡。落地顺序先会话再任务最后精调控制状态第一步会话状态外置。它体量最大、跨实例需求最直接。这一步同时把路由改成粘性加外部兜底实例从必须有状态回到最好有缓存。第二步任务状态外置同时把中间产物切到对象存储、状态记录里只留指针。这一步改动面最大因为任务状态的读取方不只有服务本身还有查询进度、查看产物、失败重试这几个入口。第三步控制状态的过期时间与原子语义收紧。它数据量最小但一次失误就是同一件事做了两遍。迁移方式用双写过渡一批流量先同时写进程内与外部存储读仍然走进程内比对两侧内容是否一致对齐之后切读最后关掉进程内那份。⚠️ 代码待验证# 迁移期要盯的指标按状态类别分开看不要混算# 具体查询命令按你的监控栈调整这里只看查什么# 1. 外部存储读写延迟分位# metric: state_store_read_seconds / state_store_write_seconds# label: class session | task | control# panel: p50 / p95 / p99# 2. 读写失败率区分 not_found 与 error# metric: state_store_ops_total{resultnot_found|error}# 3. 状态重建次数丢失窗口的真实发生率# metric: state_rebuild_total{reasonmissing|corrupt|version}# 4. 粘性路由是否还成立# metric: routing_hit_total{scopebound_instance|other_instance}这四组指标都要按class标签拆开否则一个任务状态的大写入会把会话状态的延迟曲线一起带起来你会以为是存储整体变慢。上线前的一页自查三类状态是否分开设置了过期时间与淘汰策略状态记录里是否只有指针产物本体在对象存储幂等键的过期时间是否不小于最大请求时长 最大重试退避本地缓存是否允许丢允许它才是缓存不允许它就得先落外部再回复。写外部失败的路径是否明确拒绝请求还是降级继续状态记录里是否有版本字段读到不认识的版本走哪条分支是否显式摘流量时本地状态能不能落完把上面七条逐条对应到代码位置比在评审会上讨论要不要外置有用得多。完整版资料清单本文用到的落地顺序清单与指标定义都整理在里面了扫码即可获取附表 A关键取舍一览本文涉及的所有工程判断集中在这里方便按需回看。工程取舍本文结论原因依据章节哪些状态要外置别的实例要看得见且实例挂了要接着用两条同时成立才值得付网络往返第二章重要的状态要不要外置不一定可便宜重建的仍留进程内外置只增加失败面第二章三类状态是否合用一个存储分开命名空间与过期策略生命周期差一个量级TTL 无法兼顾第二章存储载体怎么选先看读写延迟再看丢失窗口与一致性状态读写在每个请求的关键路径上第三章大产物放哪本体进对象存储状态记录只放指针避免单条记录涨到几百 KB第三章路由选粘性还是无状态粘性只做加速真相源放外部本地缓存允许丢才能随便扩缩第四章粘性路由的哈希键用会话 ID 加盐不用用户 ID用户 ID 会把重度用户堆到单实例第四章先落库还是先回复按状态类别分别选不统一控制状态丢一秒就是重复执行第五章幂等键能不能异步落不能过期后客户端重试会穿透执行第二次第五章产物与指针的写入顺序先产物后指针反过来会指向不存在的产物第五章状态版本兼容策略默认向前兼容新增字段可选兼容代码的存活期由旧状态过期时间决定第六章观测看哪些数读写分位、失败率、重建次数、跨实例命中率四组数分别指向不同故障第七章外置的落地顺序会话状态、任务状态、控制状态按体量与改动面从小到大推进第七章附表 B术语速查表术语含义会话状态多轮对话上下文与当前任务指针生命周期分钟到小时任务状态长任务跑到第几步、产物在哪生命周期小时到天控制状态幂等键、去重标记、并发占位生命周期秒到分钟状态外置把状态从进程内搬到外部存储使实例可任意替换粘性路由同一会话固定落到同一实例实例本地缓存状态摘流量把实例从可服务池移除不再接收新请求丢失窗口写失败或崩溃时可能丢失状态的时间宽度write-through先落外部存储再返回崩溃不丢但多一次关键路径延迟write-behind先返回再异步落盘延迟低但会丢一个窗口幂等键用于识别重复请求的键其存活期决定重复能否被吸收状态版本状态记录里的版本字段用于判断新旧代码能否互读向前兼容新代码能读旧状态新增字段带默认值写在最后这篇用到的资料写这篇文章时我把几个模型侧与存储侧的文档、参数表和实测记录都对了一遍顺手整理成几份配套的东西大模型学习路线图从 LLM 基础到 Agent 开发各阶段该学什么、用什么资料大模型全套教程按主题分好的视频与文档清单大模型实战好书24 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。