系统提示词的编排:一次 11 token 的改动,让 5201 token 重新计费
系统提示词要同时满足两个编排维度:哪段该放在模型注意力更强的位置, 哪段该落在能被缓存复用的分区里。而它的输出只有一根字符串。
两个维度压进一维,必然有一个失真。这篇讲失真发生在哪一侧、代价多大, 以及为什么最直接的那个修法在主力链路上会让请求直接返回 400。
结论先放这里
- priority 排序会被缓存分区静默覆盖。排序是全局的,分区是排序之后的二次分组, 所以
priority=12的段落可以排在priority=2的段落之前。实测偏移量: outputStyle 在 offset 7801,current-date 在 7933。 - 代价不在排序,在默认值方向。
cacheStability未标记时保守当作 dynamic, 而 10 个可生成附件里只有 1 个显式标了 stable。结果是动态区里 95.7% 是会话内 根本不会变的内容(本仓库受控构造实测),真易变的只有 4.3%。 - 放大倍数 473 倍:一次 todo 变更改动 11 token,触发整个 5201 token 的动态块重写。
- 最省的修法走不通。拆第三个分区能把这一轮的等效计费降 83.2%(按计费乘数推算, 非实测),但直连 Anthropic 时断点预算已经是 4/4,加第三个必然 400。
- 一个我自己判错的例子:探针显示 git 状态在缓存里冻结不刷新,看着像 bug, 回源码才发现易变块早在 2026-07 就被物理删掉了,冻结的是刻意保留的稳定部分。
- 只想知道该怎么标 → 直接跳 所以该怎么标。
两个维度,一根字符串
先把编排要解决的问题摆清楚。buildSystemPrompt(src/config/system-prompt.ts:330) 的输入是一堆来源各异的段落:身份、环境、工具指南、约束、项目规则、 技能清单、诊断、待办、拒绝规则、git 状态。输出是一根要塞进 system 参数的字符串。
这些段落身上挂着两个互不相关的属性。
第一个是 priority(src/config/attachments.ts:26),17 个档位,数字越小越靠前。 它管的是注意力:模型对长上下文里不同位置的敏感度不一样,重要的规则该靠前。
第二个是 cacheStability(src/config/attachments.ts:80),两个取值 stable / dynamic。 它管的是缓存:内容跨请求稳定的段落可以进静态分区享受长 TTL 复用, 每轮都在变的段落必须隔到 DYNAMIC_BOUNDARY 之后,否则它的变动会击穿整段前缀。
两个属性各自都合理,且彼此正交——一段内容可以「很重要且很稳定」(项目规则), 也可以「不重要但每轮都变」(git 状态)。问题是最终输出只有一维:字符顺序。
直觉做法会推出什么
最直觉的做法是一次排序搞定:把两个属性折进一个可比较的键,排完拼接。
这条路在第一个反例上就断了。缓存分区不是「排序偏好」,是硬边界—— DYNAMIC_BOUNDARY 之后的内容会被下游 buildSystemBlocks (src/api/cache-strategy.ts:132)切成独立的 cache block 分别打标。 一段内容要么在边界之前,要么在之后,没有「稍微靠前一点」这种中间态。
而 priority 是连续偏好。把一个硬边界折进连续排序键,等于要求 「所有 stable 段的 priority 必须全部小于所有 dynamic 段的 priority」—— 这个约束在语义上不成立:项目规则(stable、很重要)和当前日期(dynamic、要靠前) 就是一对反例。
所以实现只能是两步:先按 priority 全局排序,再按 cacheStability 分拣成两组。
// system-prompt.ts:547 —— 第一步,全局排序
attachments.sort((a, b) => a.priority - b.priority);
// system-prompt.ts:577 —— 第二步,按稳定性分拣
for (const att of attachments) {
const stability = (att as { cacheStability?: string }).cacheStability;
if (stability === "stable") stableParts.push(att.content);
else dynamicParts.push(att.content); // 未标记 → 保守当 dynamic
}两步都对,合起来的后果是:排序结果被分区覆盖。priority 只在同一个分区内部有效, 跨分区完全失效。
失真发生在哪一侧
跑一次就能看到。构造一份只含两个附件的提示词—— outputStyle(priority=12,标了 stable)和 current-date(priority=2,标了 dynamic):
outputStyle(priority=12) offset=7879
current-date(priority=2) offset=7933
边界位置=7904;style=静态区 date=动态区priority 更小的 date 排在了更后面。这不是 bug,是两步实现的必然结果, 静态区整体在边界之前。
值得注意的是这件事已经违反了一处写下来的意图。OUTPUT_STYLE: 12 那一档的注释(src/config/attachments.ts:64)声明它「放在 CLAUDE.md 之后、 诊断之前」,且「不越过项目规则」。实测:
| 段落 | priority | 实际偏移 | 落区 |
|---|---|---|---|
| outputStyle | 12 | 7801 | 静态区 |
| claudeMd | 10 | 8008 | 动态区 |
| diagnostics | 15 | 8541 | 动态区 |
outputStyle 排在 claudeMd 之前,也就是越过了项目规则——注释声明的两条约束 一条都没成立。写注释的人按 priority 数字推理,而真正决定位置的是分区归属。
不过排序失真本身影响有限:越过项目规则的是输出风格声明,两者语义不冲突。 真正贵的是同一个机制的另一侧。
默认值把系统推成了另一个形状
cacheStability 是可选字段,未标记时走保守分支当作 dynamic。这个默认方向在正确性上 是对的:漏标一段易变内容进静态区,会把日期这类值冻在跨会话缓存里, 是静默的正确性事故;而漏标一段稳定内容进动态区,只是白付钱。 用便宜的错误兜住贵的错误,是正确的取舍。
前提是标记率足够高。实测不是——stableAttachment 在整个仓库里只有一个调用点 (src/config/attachments.ts:353,就是 outputStyle)。把 10 个能实际生成的附件 逐个跑一遍:
p= 2 date dynamic
p= 8 skillListing (未标记→dynamic)
p=10 claudeMd (未标记→dynamic)
p=12 outputStyle stable
p=15 diagnostics (未标记→dynamic)
p=32 recalledMemory (未标记→dynamic)
p=33 sessionMemory (未标记→dynamic)
p=35 todo (未标记→dynamic)
p=38 denyRules (未标记→dynamic)
p=40 gitStatus (未标记→dynamic)一个显式 stable,一个显式 dynamic,八个靠默认值落进动态区。 而这八个里,claudeMd、denyRules、skillListing 都是会话内不会变的配置态内容。
按会话内是否会变这个口径,把本仓库的动态区拆开量一遍(构造方式: 以本仓库真实 CLAUDE.md 为项目规则,另给 todo / denyRules / gitStatus / outputStyle 各一份最小内容,模型按 200K 窗口):
| 分类 | 成员 | token | 占动态区 |
|---|---|---|---|
| 会话内稳定 | CLAUDE.md 4747 + 语言裁决 192 + denyRules 39 | 4978 | 95.7% |
| 真易变 | gitStatus 201 + date 12 + todo 11 | 224 | 4.3% |
| 合计 | —— | 5201 | 100% |
(两行相加是 5202,比合计多 1——分段估算各自向上取整造成的, estimateTokens 逐段调用与整段调用会差个别 token。)
动态区里 95.7% 是不会变的东西。它每轮跟着那 4.3% 一起重写。
放大倍数直接算得出来:一次 todo 变更改动 11 token, 让整个 5201 token 的块失效重建,约 473 倍。
这不是构造出来的极端样本。翻本机 36 个真实会话的 session.traj 首条 system 消息 (2026-08-09 实测,滚动窗口,你跑出来的数会不同):
| 指标 | p50(字符) | 占上一行 |
|---|---|---|
| system 总长 | 53,834 | —— |
| 动态区 | 17,331 | 32.2% |
| 动态区里的 CLAUDE.md | 14,571 | 84.1% |
(百分比由上面两个 p50 相除得出,读者能自己算。若改成「逐会话先算比值再取中位数」 是 32.1% / 83.2%——两个口径都对,量的不是同一件事,本文统一用前者。)
36 个样本里,CLAUDE.md 全部落在动态区,无一例外。它单独占整份系统提示词的 27.1%,而它在一次会话里通常一个字节都不变。
三个看着都对的修法,逐个淘汰
到这里结论似乎很明显:把 CLAUDE.md 标成 stable 就行了。三个候选,逐个走一遍。
候选一:把稳定内容并进静态区
一行 stableAttachment 就能改完。代价在一个容易被忽略的地方: 静态区在直连 Anthropic 时会被打上 scope: "global" (src/api/cache-strategy.ts:137),语义是跨用户共享同一份 KV Cache。
前缀缓存的顺序是 tools → system → messages,从断点往前的整段前缀被缓存。 所以静态区那个断点缓存的前缀是 [tools + 静态区],这一层跨用户共享。
把 CLAUDE.md 并进去,这一层前缀就变成了项目专属——它包含具体仓库的规则文本, 不可能与别的用户重合。跨用户共享退化到只剩 [tools] 那一层。
也就是说这个修法把「会话内复用」换来了,代价是丢掉「跨用户复用」。 两边哪个收益大取决于实际调用分布,本轮没有数据能回答。淘汰理由不是它错, 是它把一个已知收益换成另一个未量化的收益。
候选二:拆出第三个分区
更干净的形状是三区:真静态(跨会话+跨用户)、会话内稳定(跨请求)、真易变。 每区一个断点,各自享受该有的复用粒度。
按计费乘数推算收益(写入 1.25x、命中读 0.1x,api-reference/anthropic/anthropic-api.md:453):
| 形态 | 算式 | 等效基础 token / 轮 |
|---|---|---|
| 当前两区 | 3740×0.1 + 5201×1.25 | 6875 |
| 假想三区 | 3740×0.1 + 4978×0.1 + 224×1.25 | 1152 |
降幅 83.2%。这是推算不是实测——它假设三个区都稳定命中, 真实会话里前缀会被别的东西打断。
这条路的硬约束在别处:Anthropic 每请求最多 4 个缓存断点。数一遍现在用掉多少:
直连 Anthropic: 4/4 (system 2 + tools 1 + messages 1) → 余量 0
走网关: 3/4 (system 2 + tools 0 + messages 1) → 余量 1直连链路已经用满。加第三个 system 分区会变成 5 个断点,请求直接 400—— 仓库里有个运行时护栏专门拦这件事(assertCacheBreakpointBudget, src/api/cache-strategy.ts:346,dev 抛错 / prod 打日志)。
走网关有 1 个余量,因为工具区断点仅在直连时才打。但这意味着这个优化 只能在网关链路上生效,而两条链路要维护两种分区形态。淘汰理由是约束不可协商: 预算是服务端定的,不是能靠实现绕开的东西。
候选三:搬出 system,走消息通道
既然 system 分区不够用,那把稳定内容搬到消息序列里去?
这条路在本项目有一条明确的不变量:「放动态区」不等于「不占 user turn」。 OpenAI 族(deepseek 是主力)的 prependSystemMessage(src/llm/openai.ts:258) 会把动态区切出来、以 role: "user" 追加到 messages 末尾:
messages.unshift({ role, content: staticContent });
if (dynamicContent) {
messages.push({
role: "user",
content: `<system-reminder>\n${dynamicContent}\n</system-reminder>`,
});
}两族的实际落地形态因此完全不同:Anthropic 族动态区是独立 cache block, 真在 system 参数里;OpenAI 族它就是一条 user 消息。所以「搬到消息里省 system 空间」 在主力 provider 上一个 token 都省不下来,只是把同一批字节换了个位置。
这条不变量有守卫单测固化(tests/config/dynamic-boundary-multiprovider.test.ts), 因为历史上有两个方案基于「搬进动态区就不占 user turn」这个错误前提设计, 都在落地前被推翻。
所以该怎么标
三个候选各有各的不可协商约束,所以现在能做的不是重构分区, 而是把默认值的代价显式化。判据两条:
一、写附件时问「它在一次会话里会不会变」,而不是「它重要不重要」。 priority 和 cacheStability 是两个维度,用重要性推缓存归属必然错。 项目规则很重要且很稳定,git 状态不重要但每轮变——两者在两个维度上的位置正好交叉。
二、stableAttachment 不是可选优化,是漏了就要付钱的声明。 当前 10 个附件只标了 1 个,八个靠默认值落进动态区, 其中三个是配置态内容。每一个漏标都在按 473 倍的放大系数付费。
对使用者只有一条有行动价值:别把易变内容写进 CLAUDE.md。 它现在整份落在每轮重写的块里,所以在 CLAUDE.md 里放会随时间变的东西 (当前进度、今天的日期、待办列表)代价是双倍的——既击穿缓存, 又给模型一份会过期的事实。
这次为哪个方向让路
这篇整体归到「更省」:动态区那 95.7% 每轮重写,是可度量的浪费。
但它明确为正确性让了路,而且这个让路是对的。cacheStability 未标记按 dynamic 处理,方向选得没错——两类漏标的代价不对称: 漏标易变内容进静态区是静默的正确性事故(日期冻在跨会话缓存里,模型拿着错的事实干活), 漏标稳定内容进动态区只是白付钱。用能被账单发现的错误,兜住不会被发现的错误。
所以这里可省的空间不是靠翻转默认值拿到的,那样等于把便宜的错误换成贵的错误。 能拿的只有一条:把显式标记率补上去,让保守默认回到「兜底」的位置, 而不是像现在这样承担八成流量。
顺带说清另一组张力:这篇的方向与 JIT 上下文 正面对立。JIT 的正业是按需注入——内容随任务变化才有价值, 而任何随任务变化的内容都在伤缓存前缀。两边不可能同时拉满, 这也是为什么本文淘汰的三个修法里,没有一个是「少注入点东西」。
当前的能力边界
- 三区分拆在直连链路不可行,且网关链路的收益没实测过。 断点 4/4 是硬约束。 绕法:暂时没有;若要做,只能先让出 tools 区那个断点 (
SID_DISABLE_TOOL_CACHE=1)腾出预算,但那等于用工具区的复用换 system 区的复用, 哪边划算未测。 - 83.2% 的降幅是按计费乘数推算的,不是实测。 它假设三区都稳定命中。 真实会话的命中率见 Prompt Cache 那篇的账本下界 66.9%—— 两个数不同层,不要相除。
OUTPUT_STYLE注释声明的顺序意图与实际不符,未修。 声明「不越过项目规则」,实际越过了。影响有限(两段语义不冲突), 但它说明 priority 注释里的序关系描述都不可信——按数字推理会得到错的结论。 绕法:判断实际位置只能跑一次splitSystemByDynamicBoundary看偏移。- 截断降级路径完全不读
cacheStability。truncateToLimit(src/config/token-utils.ts:47)里这个字段出现 0 次, 它把 boundary 插在 coreParts 之后、所有附件之前,让保留下来的附件全落动态区。 这是刻意的保守策略(注释有说明:截断时提示词已超大、缓存本就难命中), 代价是 stable 标记在这条路径上静默失效。 - 36 会话样本来自滚动窗口。
~/.sid-code/下的旧轨迹会被清理, 你跑同一段统计得到的数会不同。会话数会变,「CLAUDE.md 全部落在动态区」这个结论不会。 - 动态区成分的 95.7% / 4.3% 是受控构造的单次测量,不是跨会话聚合。 它依赖本仓库 CLAUDE.md 的体积(约 4747 token),换一个仓库比例会变, 「稳定内容远多于易变内容」这个方向不会变。
一次判错的记录
排查中途我跑了个探针:连续两次构建提示词,中间新建一个未跟踪文件, 看 git 状态段会不会更新。结果是缓存键相同、整份提示词逐字节相同、新文件看不到。 清缓存重建,还是看不到。
看着像个典型缓存 bug:git 状态被冻在 5 分钟 TTL 里,模型拿着过期的工作区状态干活。
回源码才发现方向完全错了。generateGitStatusAttachment (src/config/attachments.ts:226)里那段最易变的 Status: 文件列表 早在 2026-07 就被物理移除了,注释写得很清楚:留着它就是「留着矛盾、 叫弱模型仲裁」,历史上修过两次仍复发,所以直接删掉,让上下文里只剩实时 git status 一个状态来源。现在快照里只有分支名、主分支、git 用户、最近提交—— 这些在会话内只增不减。
所以我的探针看到的「不刷新」是预期行为:它冻结的正是刻意保留的稳定部分。 如果我按探针结果写「git 状态在缓存里冻结,是个待修缺陷」, 就是把一个已经修完的问题当缺口重新上报了一遍。
这件事的可迁移部分是:探针能证明「行为是什么」,证明不了「这个行为对不对」。 后者只能回源码。
可迁移的两条
第一条:两个正交的分类维度压进一维输出,必然有一个维度失真—— 要提前决定牺牲哪个,而不是等它自己浮现。 这里失真的是 priority (跨分区完全失效),而代码里还留着一整套按 priority 推理的注释, 它们描述的是一个不存在的排序。如果当初显式写下「分区优先于 priority」, 那些注释就不会被写成现在这样。
第二条:保守默认值 + 低显式标记率 = 保守默认成了唯一形态。cacheStability 未标记按 dynamic 处理,方向是对的(用便宜的错误兜住贵的错误)。 但 10 个附件类型里只标了 1 个,按 token 量算,未显式标记、纯靠默认值落进 动态区的内容占动态区总量的 99.8%(5189/5201 tok,本文构造样本)—— 这个「兜底分支」不是偶尔兜底,它就是主路径。
判断自己有没有踩这条,一个问法就够:这个可选字段的显式标记率是多少? 低于一半,就该假设默认值分支才是主路径,然后按主路径的标准去审它的代价。
相关
- Prompt Cache:一个读错的字段名,把 95% 的命中率记成 2.2% —— 两族协议为什么必须写成两种形状、账本下界 66.9% 的口径、 一次让命中率降 11 个点的"优化"
- JIT 上下文:让规则在正确的时刻进入上下文 —— 规则按需注入与 cache 稳定性正面对立,那篇讲这个取舍的另一半
- 上下文与记忆 —— 自己这一份系统提示词各段占多少, 用
/context怎么看