第 12 章 前缀缓存与会话停车
MoE 大模型低成本部署框架第 12 章 前缀缓存与会话停车
CHAPTER 12

第 12 章 前缀缓存与会话停车

10 分钟阅读 · 2,709 字

问题清单 #1 的另一半(排队之外,"重读"才是可省的那部分)。本章讲清:引擎怎么知道"这段历史
我已经读过了"
、检查点存的是什么、"停车"这个名词从哪来、以及为什么这套机制在长会话上
经常失效——驱逐风暴的数字与源码都在这里。

行号口径:截至 2026-10-19,SSEStraBed/src/program/generate.cpp 与 src/core/conversation_*.cpp。


12.1 同一个提示词的两种命运

可观测性实验报告(2026-10-14 前后,独跑口径)里最漂亮的一组数字:

情形 64k 提示词的 TTFT 倍数
全新读入 13.4 s —
前缀完全复用 0.79 s 17×

引擎日志的明细行直接给出机制(bench/parse_engine_log.py 解析的就是它):

strata serve: prompt 253214 tokens = 251787 reused + 1427 read in 2196 ms (650 tok/s), ...

reused 就是"这段前缀我读过、状态还在",read 才是真花钱的部分。
同一会话逐轮增量增长时,read 只有本轮新增的几百到几千 token——上面这行是 10-08 排查时
找到的反例证据,证明引擎本身没问题(memory: strata-ttft-prefill-queue)。

于是全书反复出现的一句话成立:重复历史不重读,是唯一能把 82 秒变成 0.79 秒的机制(09 章)。
问题在于:agent 形态的会话每轮都在改前缀,"前缀没变"这个前提随时被破坏。本章就是讲这套机制
如何工作、以及它在哪里断。

12.2 第一层:对话检查点(prompt cache)

参数表(generate.cpp 约 :437-450):

/// --serve: how many conversation checkpoints to keep between requests (0 = every request reads its whole
/// prompt again, the v0.1.2 behaviour).  One is the GDN recurrence of the 36 layers, the QSA indexer tails and
/// the PLE history (~118 MB of host RAM); the KV cache itself is positional and stays where it is.
int prompt_cache = 6;            // 请求之间保留几个对话检查点(每个约 118 MB 主机 RAM)。TTFT 的关键旋钮
int64_t prompt_cache_every = 16384;  // 每读 N 个新提示词 token 存一次检查点(0 = 只在最后一个 turn 边界存)
int64_t prompt_cache_root  = 2048;   // 从 0 读起的提示词,也在第一个 turn 边界(系统提示结尾)存检查点,当它 ≥N
int64_t turn_token = 248045;         // 开一个 chat turn 的 token(<|im_start|>);prompt 里最后一个就是可复用的检查点位置

三个旋钮各治一段:

旋钮 生产值 语义 调错的症状
--prompt-cache 6(默认) 请求之间保留几个对话检查点;0 = 每轮整段重读 太小 → 长会话中途丢状态;太大 → 主机 RAM(每个 ~118 MB)
--prompt-cache-every 16384(默认) 每读 16384 个新 token 存一次 太稀 → 一旦分叉只能回退到最后一个边界,重读区间大;太密 → RAM × 检查点数
--prompt-cache-root 2048(默认) 在系统提示结尾也存一个"根检查点",同一客户端所有对话共享 关掉 → 换了对话开头就完全没有公共前缀

检查点存的是什么(注释原话,值得逐字读):36 层的 GDN(DeltaNet)递归状态、QSA 索引器的尾部、
以及 PLE 历史——合计约 118 MB 主机 RAM。KV 缓存本身不在检查点里:它是位置性的(positional),
原地不动。这解释了为什么"复用"能省这么多:贵的东西(线性注意力的递归状态、索引器、PLE 历史)
被存下来了,而 KV 不需要搬。

检查点靠精确 token 前缀匹配(10-08 排查结论):resume 落在最后一个存活的边界上。
生产默认密度 16384 意味着:一旦前缀在某个 16k 边界之后发生变化,之后所有检查点全废,
只能回退到那个边界,其余全部重读。这不是 bug,是设计——但它决定了 12.4 的事故规模。

12.3 第二层:会话停车(conversation parking)

"停车"来自英文 park:把一条独立对话的状态从活跃批槽里挪出来、放进主机 RAM 的预算里,
等它下一轮回来再取。参数(generate.cpp :440-442,帮助文本 :551-552):

int64_t conversation_cache_mib = 0;   // opt-in host RAM for independent conversations  // 停车(park)独立对话的 RAM 预算(0 = 关)
int conversation_cache_slots = 4;     // 最多停几条
int64_t conversation_cache_min_free_mib = 2560;  // 预算之外还要给主机留的底线
--conversation-cache-mib N  --serve: RAM budget for parked conversations (default 0 = off)
--conversation-cache-slots N  --serve: at most N parked conversations (default 4)

生产值(deploy/engines/profiles/ssebed/strata-a.json / strata-b.json 的 args):
--conversation-cache-slots 20 --conversation-cache-mib 8192。
旧单实例配置(Strata/strata-iq3_xxs.json,已停)是 slots 40 / mib 16384。

前置条件(:1564-1566):--serve 下开了 conversation-cache-mib 但 prompt_cache == 0
或 conversation_cache_slots == 0 → 直接报错退出(停车依赖检查点存在)。
另一条排斥:同一张卡分段(--split-device 0)+ 停车被拒绝,提示 --conversation-cache-mib 0(:1604-1606)。

四个实现文件 + 自带测试(src/core/):

文件 角色
conversation_snapshot.cpp 快照:把一条对话的状态序列化出来
conversation_memory.cpp 主机 RAM 版存储
conversation_state.cpp 状态结构
conversation_checked.hpp 校验层
conversation_cache_test.cpp / conversation_memory_test.cpp / conversation_validation_test.cpp 自带测试,淘汰语义写在断言里

测试断言就是淘汰规则的权威说明(conversation_cache_test.cpp,逐条):

断言(测试里的原话) 语义
“retained storage is counted when checking a capture without eviction” 预留存储也计入预算
“pressure drops retained storage before evicting parked conversations” 压力先丢预留存储,再淘汰已停的会话
“evict oldest by slot limit” / “B evicted before A” / “eviction counter” 超槽位时按最旧淘汰,并有计数器
“in-flight reservation evicts otherwise fitting B” 在途请求的预留可以把本来装得下的 B 挤出去
“oversized snapshot does not evict useful entries” 装不下的超大快照不存,但不淘汰有效条目(失败请求不伤害现有缓存)
“8 superseded copies dropped, nothing evicted” 同一对话被取代的旧副本优先丢弃,不算淘汰

引擎侧的淘汰入口与日志(generate.cpp):conv_cache::eviction_victim(stamps...)(约 :5200,按时间戳选受害者)、
make_room 前先丢被取代的旧副本(约 :5028 的 #342 注释)、
容量估算"不能用封顶值,否则会 under-evict 并撑爆预算"(约 :5097)。
运行时每轮打一行明细(约 :5136):

strata serve: conversation cache: <put/best> N tokens in T ms; parked=<条数> bytes=<字节> evictions=<计数> snapshot_bytes=<快照> reused_kv_bytes=<复用>

这行日志是本章所有事故数字的来源,也是 12.4 的取证入口。

12.4 事故:驱逐风暴(2026-10-07 晚 → 10-08 定位)

现象:普道 strata-b 八并发时 TTFT 约 2 分钟(问题清单 #1 的 4.7 分钟口径同源)。

取证(memory: strata-ttft-prefill-queue,2026-10-08 排查,日志 ~/Strata/strata-iq3_xxs-b.log):

证据 数字
一条 254k 会话的停车快照 ≈4.7 GB(日志原文 parked=1 bytes=4716362992)
停车预算 --conversation-cache-mib 8192 / slots 20
全天驱逐次数 evictions 2508
194 条 ≥200k 提示词中 123 条只复用 16384 token(= prompt-cache-every 的默认步长)
其余部分 ~238k 重读
当晚负载 strata-b 全天 5318 条 / prefill 16,500s(≥200k 的 194 条占 10,980s);strata-a 451 条 / 1,150s

因果链(这是本章最重要的一段推理):

  1. 单条长会话快照 4.7 GB,预算 8 GiB → 装两三条就开始驱逐;
  2. 被驱逐的会话下一轮 conversations.best 未命中 → 只能靠 prompt cache 的检查点;
  3. 检查点密度 16384 → 最多救回 16384 token,其余 ~238k 整段重读;
  4. 重读走的是全局串行的 prefill(09 章)→ 八并发时排队叠加,TTFT 到分钟级。

放大因素:当晚 15:21–19:40 的 1M 上下文精度全量套件(~/bench/ctx1m_full.sh)
刻意全部打到 strata-b(快道留给真实用户),其自身记录显示窗口填满后每轮 ~105–110s。
压测流量与真实流量共享普道,是这次风暴的第二个燃料(17 章红线"压测只打普道"的另一面:
普道被打满时,真实用户就在同一队列里)。

反例(同一份排查):同一会话逐轮增量增长时
prompt 253214 = 251787 reused + 1427 read in 2196 ms —— 2.2 秒。
机制没问题,容量与密度配错了。

12.5 另一种失效:组装块让前缀分叉

RAG 时代(第 05 章)的组装块会改变前缀。10-08 排查的第二条根因就是这个:

RAG 组装块重建让前缀在 ~16k 处分叉:checkpoint 是精确 token 前缀匹配,
分叉点之后的 checkpoint 全废,resume 塌到最后一个存活边界(常见就是 16384)。

这也解释了 RAG 设计里那条"命门"约束(docs/plans/LONG-CONTEXT-RAG-PLAN.md §1.5):
组装结果在缓存期内必须保持稳定、只追加,只在三种情况重排(TTL 到期 / 话题漂移超阈值 / 窗口将满),
且重排的一次性 re-prefill 成本要被 N 轮摊薄。该文档 §2.8 把这条列为风险清单第一条:
“RAG 前缀缓存一旦设计错(重排频繁),TTFT 会从 0.8s 退化到分钟级”。
实测正例:280 轮 / 287K token 触发组装后,组装块复用让 250K 前缀 13s 完成(同文档 §3 决策记录,2026-10-06)。

当前生产 rag_enabled=false(纯透传,10-19),这条失效路径暂时不活跃;
但只要 RAG 复活,它就是第一个要盯的东西。

12.6 失效条件清单

条件 结果 出处
--prompt-cache 0 每轮整段重读(v0.1.2 行为) :437 注释
前缀在某个 16384 边界之后改变(组装重排、历史压缩、客户端改早期消息) 该边界之后检查点全废,回退重读 10-08 排查
快照超过剩余预算 不存(但不淘汰现有条目) conversation_cache_test.cpp “oversized snapshot…”
预算/槽位超限 按最旧淘汰(evictions++);压力先丢 retained 存储 同上 + :5200
在途请求预留 可以把本来装得下的会话挤出去 同上
同一对话的旧副本 优先丢弃(不算淘汰) 同上 + :5028 #342
--conversation-cache-mib 0 或 --conversation-cache-slots 0 停车关闭(且与已开的 mib 冲突时报错退出) :1564-1566
同卡分段 + 停车 被拒绝,要求关停车 :1604-1606
引擎进程重启 检查点与停车全丢(内存态) 16 章(模型重载 1–3 分钟)

12.7 观测与旋钮现状

盲区(总览 §6.2):停车占用与驱逐只在引擎日志里(evictions=),/health 没有这个字段。
想复现 12.4 的取证,只能解析 ~/Strata/strata-iq3_xxs-{a,b}.log 的
prompt N tokens = R reused + F read in T 与 conversation cache: ... evictions=K 两类行;
这些行没有时间戳,需要与 ~/deploy/logs/rag-proxy.out.log 的 ctx[...] 时间戳对齐
(bench/parse_engine_log.py 就是为此存在)。

已列未实施的四个旋钮(路线图 §7.5 / 09 章 §9.4,截至 2026-10-19 未做,需停服窗口):

旋钮 从 → 到 代价
--conversation-cache-mib 8192 → 24000+ 主机 RAM(当时 162/232 GiB 有余量,现 176/216 GiB 悬顶)
--prompt-cache-every 16384 → 4096 每检查点 ~118 MB × 更多个
普道准入 按提示词长度拒绝/限深 策略复杂化
组装预算封顶 100–120k RAG 已关,随复活再议

候选修复(已记入待办):双卡部署全景 §待办 6——“新鲜巨提示词的 park 会驱逐他人槽位”,
候选方案是按快照大小跳过 park(大快照干脆不停,避免把别人的会话挤出去)。

12.8 本章小结

  • 复用不是缓存"文本",而是缓存贵的状态:36 层 GDN 递归 + QSA 索引器尾部 + PLE 历史(~118 MB/检查点);
    KV 是位置性的,原地不动。
  • 三层旋钮:prompt-cache(几个)、prompt-cache-every(多密)、prompt-cache-root(公共前缀钉住);
    停车是第四层(整条对话搬进 RAM 预算)。
  • 检查点是精确前缀匹配——这是它威力最大也最脆弱的一点:任何早期改动会让后面全部作废。
  • 驱逐风暴的公式:快照 4.7 GB > 预算 8 GiB → 驱逐 2508 次 → 每轮只救回 16384 → 重读 238k → 排队分钟级。
    机制没错,容量与密度配错了。
  • 想验证收益,先补观测(/health 出停车占用/驱逐计数),否则扩容与加密都只是猜测。
全部章节