说明:本文讨论的是 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: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 # 走向前兼容分支 -> 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{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 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。