第 12 章 前缀缓存与会话停车
问题清单 #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 |
因果链(这是本章最重要的一段推理):
- 单条长会话快照 4.7 GB,预算 8 GiB → 装两三条就开始驱逐;
- 被驱逐的会话下一轮
conversations.best未命中 → 只能靠 prompt cache 的检查点; - 检查点密度 16384 → 最多救回 16384 token,其余 ~238k 整段重读;
- 重读走的是全局串行的 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出停车占用/驱逐计数),否则扩容与加密都只是猜测。