第 2 章 硬约束如何塑造了设计
本章素材:
docs/项目背景与问题总览.md§3.1、§8(2026-10-15 核实口径)、
docs/deployment/POD-ENV-NOTES.md(踩坑时间线)、docs/plans/方案猜想.md(已否决方案)
本章是全书的因果中枢。第 3~12 章的每一个设计,几乎都能回溯到这里的一条约束。
2.1 约束清单
先摆出全部十一条。它们都是改不了的——改不了的意思是:试图违反它的代价,比绕开它的代价更高。
| # | 约束 | 具体数字 | 绕开它的手段 |
|---|---|---|---|
| 1 | 原生窗口硬固定 | 262144,引擎从不截断、超窗即 400 | 网关检索组装 |
| 2 | CPU 配额与可见核数不一致 | cgroup 32 核,nproc 看到 76 |
显式 --pool-workers |
| 3 | 钉核是命门 | 不 taskset 会掉到 9 tok/s |
每 lane 一段独立核区间 |
| 4 | mlock 不可用 |
硬限 8 MB | PLE 走页缓存 + 顺序访问 |
| 5 | 只有一个公网端口 | 8080 | 网关模式,引擎只听回环 |
| 6 | 前缀缓存决定 TTFT | 数量级差异 | 组装块 TTL 内稳定复用 |
| 7 | GPU 是唯一硬瓶颈 | 解码速度与上下文长度几乎无关 | 把代价集中到 prefill 上治理 |
| 8 | 内存悬顶 | 双实例已用 ~170 / 216 GiB | 停车准入检查 + 物理内存地板 |
| 9 | 服务对外在线 | 改引擎配置 = 重启 = 停服 | 分层改动成本,能改上层就不改下层 |
| 10 | 网关全内存无持久化 | 重启即清空会话与嵌入缓存 | 已知缺陷,待设计 |
| 11 | serve 模式强制 MTP | 缺 --spec/--mtp/--prefill 直接 exit 2 |
没有「关掉 MTP 跑基线」这条路 |
下面逐条讲它逼出了什么。
2.2 约束 1:窗口硬固定 262144,超窗即 400
引擎的窗口是一个硬数字。引擎对自己的窗口采取「要么完整装得下,要么明确拒绝」的策略:
它不会悄悄丢掉一段历史,也不会截断后继续生成。
这条约束的后果很直接:用户粘贴 300K token,引擎会返回 400。而服务对外承诺「永不 400 拒」。
于是 1M 能力只能放到引擎外面。这就是第 5 章的全部内容:网关把超窗历史压缩成
「最近窗口逐字 + 检索块」,让引擎永远收到 ≤262144。
为什么引擎侧扩窗口没有被采纳? docs/plans/方案猜想.md 与
docs/plans/LONG-CONTEXT-RAG-PLAN.md 都算过这笔账:把原生窗口扩到 1M,代价落在 KV 缓存与
PLE 表上,而这两块已经吃满了 24G 显存与 216 GiB 内存的余量。相比之下,检索组装的代价是
「召回质量可能不够」——一个可测量的软代价,比一个会直接把服务打爆的硬代价更容易接受。
这个取舍在第 19 章被重新列为待决问题。
2.3 约束 2:cgroup 32 核,容器看到 76 核
这是本项目最隐蔽的一条约束,也是最容易让人写出「看起来正常、跑起来崩」的代码的地方。
容器里 nproc 报 76。这是宿主机的物理核数。真实的配额在 cgroup 里:
cpu.max = 3200000/100000 # 每 100ms 周期允许 3200ms CPU 时间 = 32 核
于是开 76 个线程去抢 32 核的配额,后果会被内核的 CFS 调度器周期性掐死:所有线程一起跑满
配额,然后一起被冻结到下一个周期,宏观表现是吞吐剧烈抖动。
应对手段是一个引擎参数:
--pool-workers 13
为什么是 13?因为一台机器上跑两个引擎,每个引擎的 CPU 专家池都要吃核,
两个 13 加上 Python 服务层、网关、嵌入/重排服务、tokenizer、系统,
剩下给别的。这个数字是试出来的。
一个必须记住的推论:任何「按硬件能力自动决定线程数」的代码,在这个 Pod 里都是错的。
引擎必须被显式告知配额。
2.4 约束 3:钉核是命门
两个引擎各自有一份 CPU 专家池。如果它们自由调度在全部 76 个可见核上,会互相抢。
实测后果(见 §2.9 事故复盘):solo 解码掉到 9.3 tok/s。
所以每个 lane 被 taskset 钉到一段独立的核区间:
strata-a → taskset -c 0-15
strata-b → taskset -c 38-53
(出处:~/Strata/run-iq3_xxs-a.sh / -b.sh,2026-10-17 版本)
注意两段区间中间留了 16–37 这段空隙。这段空隙留给 Python 服务层、网关、嵌入服务、
以及引擎自己的主线程与 CUDA 驱动线程。
钉核在这个项目里的地位是正确性前提:不钉核时两个引擎会互相踩,性能曲线出现无法解释的
断崖。排障时第一个该问的问题是「两个引擎的核区间重叠了吗」。
2.5 约束 4:mlock 不可用
mlock 是把内存页钉在物理内存里、不让它被换出的系统调用。这个 Pod 里它的硬限是 8 MB——
等于不可用。
这对 PLE 表(28.8 GiB)是致命的:如果这 28.8 GiB 被换出,每次 PLE 查表都要走磁盘。
应对手段分两半:
一半是参数:--ple-io ram 把 PLE 表放在 RAM 而不是每次读磁盘。
另一半是访问模式:既然页可能被换出,就让访问模式尽量是页缓存擅长的样子。
PLE 是逐层查表,访问序列由 token 序列决定。引擎侧的优化方向是让同一层的查表在时间上聚簇,
减少跨页跳跃。
这条约束的产物是第 7 章会讲到的一个现象:PLE 随机读延迟 6.9 ms 是性能命门。
它看起来是内存问题,实际会通过 PLE 随机读间接打到解码速度上。这也是 §2.10 内存悬顶
必须当成硬约束来对待的原因。
2.6 约束 5:只有一个公网端口 8080
平台只给一个公网端口(frps 中继,TLS 在中继侧终结)。
于是所有能力必须挤进一个入口:OpenAI 协议、Anthropic 协议、健康检查、并发监控、
多模态、鉴权——全部走 8080。
这条约束直接生成了网关模式:
公网 :8080 → rag-proxy(唯一监听公网的进程)
├→ 127.0.0.1:8091 strata-a
└→ 127.0.0.1:8092 strata-b
引擎只听回环。这带来一个附带的好处:引擎不需要自己处理鉴权、Host 校验、跨站防护——
这些全在网关做一次。附带的好处之二:排障时可以直接 curl 127.0.0.1:8091 绕过网关,
把问题切成「网关的」和「引擎的」两半。
2.7 约束 6:前缀缓存决定 TTFT(数量级差异)
这是全书最应该被记住的一个实测事实:
同一个 64K token 的提示词,前缀可复用时 TTFT 0.79 s,不可复用时 13.4 s。
(出处:docs/deployment/Strata-双卡部署全景.md)
17 倍。 这是数量级级别的差异,属于优化手段里收益最大的一类。于是它反过来约束了网关的设计:
- 组装块必须在 TTL 内稳定复用。如果每次请求都重新检索、重新拼一遍历史,前缀就变了,
引擎就要重读整段——TTFT 从 0.79 s 回到 13.4 s。 - 所以「组装块」是一个有生命周期的对象,而每次请求的临时产物。第 5 章会给出它的 TTL、
复用条件、以及重建判据。
这是第 5 章「漂移重建」判据存在的真正理由:它的目标是在「旧组装块对当前问题已经没用了」
和「旧组装块还能复用、复用能省 17 倍 TTFT」之间做判断。判据偏向后者,因为省下的那一头是数量级。
2.8 约束 7:GPU 是唯一硬瓶颈,代价全在 prefill
一个反直觉的实测结论:
解码速度几乎与上下文长度无关。
从 8K 到 254K,解码 tok/s 变化很小。真正随长度爆炸的是 prefill:
| 提示词长度 | prefill 耗时 |
|---|---|
| 64K | 13.4 s(不可复用) |
| 254K | 81–91 s |
(出处:docs/项目背景与问题总览.md §5 问题 #1、docs/deployment/Strata-双卡部署全景.md)
这条约束决定了性能优化的主攻方向:prefill 的次数。第 9 章(prefill 串行)与
第 12 章(前缀复用与停车)都在处理这同一件事。
它也解释了为什么「换更小量化冲并发」这条路被否决:量化影响的是每个 token 的计算量,
而瓶颈在「读提示词要读多少遍」。
2.9 约束 8:内存悬顶
216 GiB 内存,双实例已用约 170 GiB。剩下约 46 GiB。
会话停车缓存的预算是 --conversation-cache-mib 8192(8 GiB)、--conversation-cache-slots 20。
一份停车快照约 4.7 GiB。
于是「停车」这个动作必须带准入检查:快照本身是否超预算、物理内存是否还剩够地板、
捕获之后是否仍然够。引擎侧对应 conversation_memory_admit 与
conversation_available_memory(src/core/conversation_*.cpp),
检查失败就在日志里说明为什么放弃。这套检查的必要性很直白:不查就会 OOM。
这条约束直接生成了第 12 章的驱逐机制与「快照 4.7G > 8GiB 预算 → 全天驱逐 2508 次」这个事故。
2.10 约束 9:服务对外在线,改引擎 = 停服
这条约束决定了团队的分工方式。改动成本是分层的:
改 Python 服务层 → 不用重编,不用停服(前端连重启都不用)
改网关 → 只需重启 rag-proxy,引擎不动
改引擎 → 必须停服窗口
于是团队的工作方式被排成一个明确的优先级:能在上层解决的,绝不往下走一层。
这条分层也解释了为什么可观测性被列为前置工作:很多问题(并发下的 timings、批槽状态、
prefill 排队)需要在引擎侧加输出,而加输出就要重启,而重启要停服窗口。
于是「先在上层凑合观测」成了常态,而凑合观测又让定位变慢。这个循环是第 19 章的主线之一。
2.11 约束 10:网关全内存无持久化
网关的会话状态、嵌入缓存、组装块全在内存里。进程重启,全部清空。
这是已知缺陷。它的后果在约束 6 的语境下尤其贵:网关重启 → 组装块消失 →
下一个请求的前缀变了 → 引擎重读整段 → TTFT 回到 13.4 s 那一档。
也就是说,网关重启的代价会通过前缀缓存放大到引擎侧。这条约束目前没有被解决,
列在 docs/项目背景与问题总览.md §6.2 的盲区清单里。
2.12 约束 11:serve 模式强制 MTP
引擎的 serve 模式要求 --spec T、--mtp DIR、--prefill CHUNK 三个参数齐备,
缺任何一个直接 exit 2(src/program/generate.cpp:4182)。
于是「关掉 MTP 跑一个干净基线」这个实验做不了。想做 A/B,只能在「MTP 开」的前提下比较
MTP 的不同配置(--spec 4 vs --spec 6、--batch-mtp 开与关)。
这条约束对方法论的影响比它看起来大:它限制了你能问什么问题。
2.13 已否决的路:为什么「更简单的样子」走不通
回答「为什么系统长成这个样子」,最有力的证据是那些试过并被放弃的替代方案。
| 曾经考虑的方案 | 结论 | 真正的原因 |
|---|---|---|
| 同型号单卡实例扩吞吐 | 先否决,后翻案为现生产 | 当时 solo 掉到 9.3 tok/s,真因是两个引擎的 CPU 专家池抢核,taskset 钉核即解决 |
| 双卡各跑 dense + 对称共享专家池 | 不推进 | 引擎不支持对称共享专家池:--peer-device 是主从形态,且不能与 --layer-split 并用 |
| Q2_0 量化 | 排除 | 质量不可接受;做「真·快通道」时可重新评估 |
| Coder 变体模型 | 不建议 | 中文明显变差,本服务中文为主 |
| 抢占式高速通道 | 不选 | 代价是在途请求被取消;最终选了插队式(忙时降级为批速度) |
| 换更小量化冲并发目标 | 不可达 | 本机聚合天花板 ~145–170 tok/s,要达标只能加卡 |
(出处:docs/项目背景与问题总览.md §9、docs/plans/方案猜想.md)
第一条值得单独讲,因为它是这个项目最重要的一次认知修正。
早期结论是「同型号双实例没用,因为两张卡各自跑一个 125B 引擎会互相抢资源」。
这个结论被实测支持:solo 掉到 9.3 tok/s。
后来发现慢的原因在 CPU 核争抢上。两个引擎各自有一份 CPU 专家池,各自按 nproc 开线程,
两者在同一个 cgroup 配额里互相抢。钉核之后,同型号双实例立刻成立,成为今天的生产布局。
这条修正的价值在于方法论:一个被「实测」支持的结论,可能测的是另一个变量的效应。
这也是第 17 章「测量纪律」为什么要求 A/B 交替顺序、要求逐 token 一致性校验。
2.14 本章小结
十一条约束,各自逼出一个设计:
| 约束 | 逼出的设计 | 详见 |
|---|---|---|
| 窗口硬固定 262144 | 网关检索组装 | 第 5 章 |
| cgroup 32 核 / 看到 76 核 | 显式 --pool-workers 13 |
第 15 章 |
| 钉核是命门 | 每 lane 独立核区间 | 第 15 章、第 18 章 |
mlock 不可用 |
--ple-io ram + 页缓存友好的访问模式 |
第 7 章 |
| 只有一个公网端口 | 网关模式,引擎只听回环 | 第 4 章 |
| 前缀缓存决定 TTFT | 组装块 TTL 稳定复用、漂移重建判据 | 第 5 章 |
| 代价全在 prefill | 主攻「prefill 次数」而非速度 | 第 9、12 章 |
| 内存悬顶 | 停车准入检查 + 物理内存地板 | 第 12 章 |
| 改引擎 = 停服 | 分层改动成本、可观测性前置 | 第 16、19 章 |
| 网关无持久化 | 已知缺陷,待设计 | 第 19 章 |
| serve 强制 MTP | A/B 只能在 MTP 开的前提下做 | 第 13 章 |
读完本章应该能回答:为什么系统长成这个样子。答案在约束与设计的对应关系里。