☰
Agent 服务要扩多副本:会话与任务状态该放在哪
2026/10/14 11:20:42 网站建设 项目流程

说明:本文讨论的是 Agent 服务从单实例扩到多副本时,会话状态、任务状态与控制状态该留在进程内还是搬到进程外,属于 Agent 工程落地话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、单实例跑得好,多实例就乱

三类状态,生命周期差一个量级

Agent 服务与普通 HTTP 接口的差别,不在它调用了模型,而在它记着事。把这些"记着的事"拆开,至少有三类:

会话状态(session state)——多轮对话的上下文、当前任务指针,生命周期在分钟到小时量级。

任务状态(task state)——一个长任务跑到第几步、中间产物放在哪,生命周期在小时到天量级。

控制状态(control state)——幂等键(idempotency key)、去重标记、并发占位,生命周期在秒到分钟量级。

三类状态都需要被别的实例看见,差别在生命周期的量级。把三者塞进同一个存储、配同一个过期时间,必然有一类被拖累或提前过期。

请求越长,撞上实例退出的概率越高

单实例时,进程内的状态看起来是免费的:读写就是内存操作,没有网络往返,也没有序列化。代价藏在"实例什么时候消失"这件事里。普通接口的请求时长在几十毫秒量级,一次滚动更新(rolling update)、一次节点回收、一次进程重启,撞上在途请求的概率很低。Agent 请求不一样:一次带工具调用的任务跑几十秒到几分钟是常态,请求时长被放大三个数量级。

粗算一笔:假设单个实例平均 6 小时被替换一次,一个 90 秒的请求撞上实例退出的概率约为 90 ÷ 21600 ≈ 0.4%。单实例时无所谓;开到 20 个副本、每天几十万次请求,就是每天上千次会话状态被清掉。

更麻烦的是它不可复现:用户说"它上一句还记得我选的是北京,下一句就问我城市了",日志里只能看到那条会话从一个不认识的实例开始,前面没有任何报错。

"加个副本"不能直接解决问题

最直觉的做法是让每个副本各存一份本地状态,会话跟着副本走。这条路通向粘性路由,第四章讲它的取舍。先记住代价:实例从此是有状态的,把它从可服务池里摘走(drain)之前必须等本地那份状态落完,否则那一批会话直接消失。另一条路是每次请求都从外部存储读写,实例随时可以杀,代价是每轮多一到两次外部读写,而且外部存储成了进程级依赖——它挂了,整片服务一起不可用。

两条路都要先回答同一个问题:哪些东西值得搬出去。

二、哪些必须搬出去,哪些留下

分档判据:别的实例要不要看见它

判据只有两条,同时成立才值得外置:

  1. 别的实例需要看见它(会话的下一轮可能落到任意副本);
  2. 本实例消失后还得接着用(重启、滚动更新、节点回收之后不能丢)。

两条只满足一条的都不必外置:只满足前者的,可以本地缓存、外部留底;只满足后者的,外置纯属浪费一次往返。

状态生命周期别的实例要看得见实例挂了要接着用归属
多轮对话上下文、当前任务指针分钟—小时是是外置
长任务进度与步骤标记小时—天是是外置
任务中间产物(大文件、长文本)小时—天是是外置(对象存储 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:cfg=STATE_CLASS[kind]try:raw=store.get(f"{cfg['ns']}:{key}",timeout_ms=200)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 # 走向前兼容分支 -> CONTINUE

REJECT与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{result="not_found"|"error"}# 3. 状态重建次数:丢失窗口的真实发生率# metric: state_rebuild_total{reason="missing"|"corrupt"|"version"}# 4. 粘性路由是否还成立# metric: routing_hit_total{scope="bound_instance"|"other_instance"}

这四组指标都要按class标签拆开,否则一个任务状态的大写入会把会话状态的延迟曲线一起带起来,你会以为是存储整体变慢。

上线前的一页自查

  • 三类状态是否分开设置了过期时间与淘汰策略?
  • 状态记录里是否只有指针,产物本体在对象存储?
  • 幂等键的过期时间是否不小于"最大请求时长 + 最大重试退避"?
  • 本地缓存是否允许丢?允许,它才是缓存;不允许,它就得先落外部再回复。
  • 写外部失败的路径是否明确:拒绝请求,还是降级继续?
  • 状态记录里是否有版本字段,读到不认识的版本走哪条分支是否显式?
  • 摘流量时本地状态能不能落完?

把上面七条逐条对应到代码位置,比在评审会上讨论"要不要外置"有用得多。

完整版资料清单:本文用到的落地顺序清单与指标定义都整理在里面了,扫码即可获取:

附表 A:关键取舍一览

本文涉及的所有工程判断集中在这里,方便按需回看。

工程取舍本文结论原因依据章节
哪些状态要外置别的实例要看得见,且实例挂了要接着用两条同时成立才值得付网络往返第二章
重要的状态要不要外置不一定,可便宜重建的仍留进程内外置只增加失败面第二章
三类状态是否合用一个存储分开命名空间与过期策略生命周期差一个量级,TTL 无法兼顾第二章
存储载体怎么选先看读写延迟,再看丢失窗口与一致性状态读写在每个请求的关键路径上第三章
大产物放哪本体进对象存储,状态记录只放指针避免单条记录涨到几百 KB第三章
路由选粘性还是无状态粘性只做加速,真相源放外部本地缓存允许丢才能随便扩缩第四章
粘性路由的哈希键用会话 ID 加盐,不用用户 ID用户 ID 会把重度用户堆到单实例第四章
先落库还是先回复按状态类别分别选,不统一控制状态丢一秒就是重复执行第五章
幂等键能不能异步落不能过期后客户端重试会穿透执行第二次第五章
产物与指针的写入顺序先产物,后指针反过来会指向不存在的产物第五章
状态版本兼容策略默认向前兼容,新增字段可选兼容代码的存活期由旧状态过期时间决定第六章
观测看哪些数读写分位、失败率、重建次数、跨实例命中率四组数分别指向不同故障第七章
外置的落地顺序会话状态、任务状态、控制状态按体量与改动面从小到大推进第七章

附表 B:术语速查表

术语含义
会话状态多轮对话上下文与当前任务指针,生命周期分钟到小时
任务状态长任务跑到第几步、产物在哪,生命周期小时到天
控制状态幂等键、去重标记、并发占位,生命周期秒到分钟
状态外置把状态从进程内搬到外部存储,使实例可任意替换
粘性路由同一会话固定落到同一实例,实例本地缓存状态
摘流量把实例从可服务池移除,不再接收新请求
丢失窗口写失败或崩溃时可能丢失状态的时间宽度
write-through先落外部存储再返回,崩溃不丢但多一次关键路径延迟
write-behind先返回再异步落盘,延迟低但会丢一个窗口
幂等键用于识别重复请求的键,其存活期决定重复能否被吸收
状态版本状态记录里的版本字段,用于判断新旧代码能否互读
向前兼容新代码能读旧状态,新增字段带默认值

写在最后:这篇用到的资料

写这篇文章时,我把几个模型侧与存储侧的文档、参数表和实测记录都对了一遍,顺手整理成几份配套的东西:

  • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
  • 大模型全套教程:按主题分好的视频与文档清单
  • 大模型实战好书:24 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

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

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

立即咨询