JIT 上下文:让规则在正确的时刻进入上下文
你在 src/ui/CLAUDE.md 里写了"这个目录下的组件禁止用彩色 emoji",agent 改完代码, emoji 还在。第一反应大概是"模型不听话"。
多数情况下不是。是那条规则当时根本不在上下文里。
结论先放这里
- 全量加载在这个仓库要付 11,921 token / 轮(2026-08-06 实测),而绝大多数任务 只用得上其中一份规则。更麻烦的不是钱,是无关规则会主动干扰模型。
- 同一份带
paths:的规则,启动加载与 JIT 发现的命中判定方式必须不同—— 两条路径手上的信息量不一样,用同一套判据必然有一侧失效。 - 累积字节 P95 = 11.2 KB(2026-08-08 实测),所以淘汰机制现在不该做。 这个结论是数据给的。
- 三个缺陷都是静默失效:去重缓存毒化整个目录、
startsWith把兄弟目录当项目内、 中文标点吞掉@import。三个都已修,最后一条的第一版修法方向是错的。 - 原本五条的边界表现在只剩两条。三条是"做了但没接到底",一条只能收窄不能消除, 还有一条根本不是缺口——回源码核验上游才发现那一项我们本来就领先。
- 代价:JIT 与 prompt cache 方向直接对立,且这个取舍不是普适最优。
- 只想知道怎么把规则写对 → 直接跳 怎么把规则写对。
Anthropic 在 Effective context engineering for AI agents 里把"运行时按需拉数据进上下文"叫 just-in-time,并点明 Claude Code 走混合策略: CLAUDE.md 启动时直接塞进上下文,glob / grep 负责运行时按需取文件内容。
sid-code 走同一条路,但把"按需"推得更远:不只文件内容按需取,规则本身也按需注入。 下面从约束推起——这套机制的形状不是选出来的,是被逼出来的。
约束:全量加载要付多少
最省事的做法是启动时把项目里所有 CLAUDE.md 一次读完、全塞进系统提示词。 在这个仓库里(2026-08-06 实测,estimateTextTokens 口径):
| 规则文件 | 体积 | 估算 token |
|---|---|---|
CLAUDE.md(项目根) | 19.2 KB | ≈ 4,154 |
src/ui/CLAUDE.md(TUI 规范) | 31.5 KB | ≈ 6,811 |
docs/summary/CLAUDE.md | 4.4 KB | ≈ 956 |
| 合计 | 55.0 KB | ≈ 11,921 |
一万二千个 token,每一轮请求都要重新发一遍。而绝大多数任务只会碰到其中一份。 写文档时那 6,811 token 的 TUI 规范一条都用不上,但它每轮都在收费。
这张表值得停一下。上一版这篇同一张表写的是 26.7 KB / 10,561 token——四天后重量, 已经长到 55.0 KB。 规则文件是活的,把体积写死在文章里必然过期, 所以下面每一处数字都带测量日期。
钱不是最麻烦的部分
无关规则会主动干扰模型。踩过一次真实的:在 website/ 做文档任务, 上下文里被塞进了 src/ui/ 的 TUI 组件规范,模型连续 6 次在回复里自述 "注入的规范与当前任务无关"——它不但没帮上忙,还占着模型的注意力反复自我解释。
规则之间还会互相矛盾。src/ui/ 要求"状态靠排版不靠颜色", website/ 要求"配色走 theme 变量"——单独看都对,同时出现模型就得猜。
所以目标不是"尽可能多给规则",而是在正确的时刻给正确的规则。
唯一解的形状:两条路径,而不是一条
先看最直觉的做法:既然要按需,那就全部按需,启动时什么都不加载。这条路立刻撞墙—— 整场会话不触碰任何文件的任务(纯对话、纯规划)永远拿不到项目根的 CLAUDE.md, 而那恰恰是最该无条件生效的一份。
反过来全部启动加载,就退回上一节那 11,921 token。所以只能是两条路径,分工明确:
| 启动加载 | JIT 发现 | |
|---|---|---|
| 时机 | 会话启动时一次 | 每轮工具调用后按需 |
| 判定依据 | 你在哪工作 + 你在改什么 | 工具实际访问的文件路径 |
| 覆盖范围 | 企业 → 全局 → 用户 → 项目根 → 子目录 → .claude/rules/ → CLAUDE.local.md | 被访问路径向上到项目根的整条目录链 |
启动加载负责"一定要有的":项目根 CLAUDE.md、全局个人偏好、企业下发的强制约束。 这些不依赖任何运行时信号。
JIT 负责"用到才给"。agent 读了 src/ui/Footer.tsx,src/ui/CLAUDE.md 这一刻才被 发现并注入;没碰过这个目录,这份规范整场会话不出现。 后面全部是这一句的细化。
触发面:从硬编码名单到工具自报
工具执行完一轮后,harness 检查这批工具访问了哪些路径,向上遍历到项目根, 每一级检查 CLAUDE.md / .claude.md / claude.md / .claude/CLAUDE.md / .claude/instructions.md / CLAUDE.local.md,以及 .claude/rules/**/*.md。
读了 src/ui/components/Footer.tsx,扫描链是 src/ui/components/ → src/ui/ → src/ → 项目根,链上任何一级有规则文件都会被拾起。
"哪些工具该触发"有过一次返工。原实现在 app.ts 里硬编码了一份名单: ["read", "write", "edit", "grep", "glob"]。而现在实现了路径自报的文件类工具是 10 个 (2026-08-06 实测,扫 src/tool/*.ts)——read_many / notebook_edit / ls / lsp / bash 五个全部漏在旧名单外面。硬编码名单和真实注册的工具之间没有对账机制, 新增工具必然漏。
现在改成工具自报:每个工具实现一个 jitAffectedPaths(input) 纯函数, 声明"我这次调用碰了哪些路径",再由 tests/tool/jit-affected-paths-audit.test.ts 双向对账,漏报让 CI 变红。
这里刻意不做"按 file_path / path 字段名猜"的兜底。猜测式兜底会把非文件语义的 同名字段(web_fetch 的 url、MCP 工具的 path 形参)误当本地路径去 stat; 而漏报的代价是 CI 可见的——所以这里选 fail-closed。
bash:只认高确定性形态
一条 shell 命令碰了哪些路径,静态看不出来。做法是只认几个高确定性形态, 其余一律不报(2026-08-08 直接调 bashWriteTargets 实测, bun scripts/probe/jit-boundary-b4.ts 可重跑):
cat > src/ui/Badge.tsx <<EOF → ["src/ui/Badge.tsx"]
sed -i '' 's/a/b/' src/ui/F.tsx → ["src/ui/F.tsx"]
echo hi | tee -a src/api/log.txt → ["src/api/log.txt"]
echo x > "src/my dir/a.ts" → ["src/my dir/a.ts"]
cp src/a.ts src/ui/b.ts → ["src/ui/b.ts"] 取目标
cp a.ts b.ts src/ui/ → ["src/ui/"] 多源只取目标
touch src/ui/New.tsx → ["src/ui/New.tsx"]
mkdir -p src/ui/sub → ["src/ui/sub"]
echo x > $OUT → [] 变量,运行时才知道
echo x > /dev/null → [] 不是业务文件
rm src/ui/Old.tsx → [] 刻意不支持,见下
python src/ui/gen.py → [] 程序自己写文件,静态不可判定为什么宁漏不误:误报的代价是烧 token + 可能让模型遵循错误规范, 漏报只是回到改造前的状态。两者不对称,所以往保守一侧倒。
rm 是刻意永久不支持的:删除之后那个目录的规则不再适用于任何后续操作,注入是纯浪费。 这类"设计取舍"在测试里有显式断言——不写的话,未来有人顺手给 rm 加上支持 不会有任何东西变红,那条裁决就静默失效了。取舍也需要测试来固定, 不然它和缺口区分不开。
cp / mv / install / touch / mkdir 这五个是 2026-08-08 才加的, 加的过程推翻了一条写了很久的代码注释。
glob:从 pattern 提取静态前缀
glob("src/ui/**/*.tsx") 不带 path 参数时,原实现拿不到目录信息, 退化成扫项目根——目标目录的规范拿不到。现在从 pattern 里提取静态前缀:
glob{pattern:"src/ui/**/*.tsx"} → ["src/ui"]
glob{pattern:"src/api/*.ts"} → ["src/api"]
glob{pattern:"**/*.ts"} → []
grep{path:"src",pattern:"ui/**"} → ["src"]最后一条值得单独交代,因为这篇文章的上一版把它写错了:旧版称 grep 会同时报 ["src","src/ui"]。回源码一查不是这样——src/tool/grep.ts:88 明确只取 path、 刻意不解析 pattern,因为 grep 的 pattern 是正则不是 glob, 把 src/\w+\.ts 送进 glob 前缀提取会得到伪目录。
paths: 作用域:同一份规则,两条路径判定方式不同
规则文件可以在 frontmatter 里声明作用域。这个仓库的 src/ui/CLAUDE.md 就是:
---
paths: ["src/ui/**", "src/ink/**"]
---意思是:这份规范只在处理 src/ui/ 或 src/ink/ 下的文件时才该出现。 两条路径判定"命中"的方式必须不同,因为它们手上的信息不一样。
JIT 侧:拿真实的活动文件判
JIT 手里有确切信息——被访问路径就是 agent 这一刻真正在读写的文件,拿它跟 paths 直接比:读 src/ui/Footer.tsx 命中并注入,读 website/index.md 不命中并跳过。 作用域 = 我此刻正在动的文件,这是 paths 语义最干净的一次判定。
启动侧:没有"活动文件",得先构造信号
启动时 agent 还没碰任何文件。如果这时候对所有带 paths: 的规则一律拒绝, 就留下一个坑:整场会话不触碰该目录的任务永远拿不到作用域规则。
所以启动侧自己采集两个信号:cwd 目录标记(你在哪工作)与 git 变更文件(你在改什么)。 在一个受控 fixture 里实测三种启动位置,规则是 paths: ["src/ui/**"]:
cwd=项目根,工作区干净
活动信号 = []
注入 src/ui 规则 = false ← 只能靠 JIT 运行时发现
cwd=项目根,src/ui/Badge.tsx 未提交
活动信号 = ["src/ui/Badge.tsx"]
注入 src/ui 规则 = true ← git 信号命中
cwd=src/ui,工作区干净
活动信号 = ["src/ui/", "src/ui/Badge.tsx"]
注入 src/ui 规则 = true ← 目录标记命中
cwd=docs,工作区干净
活动信号 = ["docs/"]
注入 src/ui 规则 = false ← 正确落空两个信号各补对方的盲区。目录标记只能满足 dir/** 形状的规则, paths: ["**/*.py"] 这种按扩展名收窄的作用域光有目录标记一律不匹配; 反过来,还没开始改文件时只有目录标记能说明你在哪。
三个只能靠实测钉住的实现细节
把 glob 语义实测一遍就清楚第一个(2026-08-06,Bun.Glob):
Glob("src/ui/**").match("src/ui") → false
Glob("src/ui/**").match("src/ui/") → true
Glob("**/*.py").match("src/") → false
Glob("src/ui/**").match("src/ui/READ.md") → true
Glob("src/ui/**/*.tsx").match("READ.md") → false目录标记必须带末尾 /。少这一个字符,整个信号静默失效——不报错、不告警, 只是作用域规则再也匹配不上。
第二个:git 变更必须按 cwd 收窄,只取当前工作目录子树内的变更。这正是前面那起 "website 里被注入 TUI 规范"事故的修法——这类仓库里 src/ui 常有未提交改动, 取全仓变更会让那份 TUI 规范在任何目录下都被拉进上下文,等于换个入口重演事故。
第三个在采集命令里(src/config/rules.ts:347):
git --no-optional-locks status --porcelain -z -uall-uall 是必需的。默认的 -unormal 会把未追踪目录折叠成 website/ 一条, 新文件的扩展名根本不出现在输出里——于是 paths: ["**/*.py"] 对新建的 Python 文件 一律失配,恰好是最该命中的场景。
顺带一条设计取向:git 不可用时采集结果退回到"仅含 cwd 标记"而不是"匹配一切"。 收窄注入面是这套机制的目的,采集失败就该倒向更保守的一侧。
实测基线
这套机制自带埋点,每次发现都记一条 jit_context 事件,无论命中与否—— 只在命中时打点会让分母永远缺失,算不出覆盖率。
bun scripts/jit-context-stats.ts --all --by-file2026-08-08 实测,扫 33 个会话(5 个早于埋点上线、14 个有 JIT 数据):
| 指标 | 值 | 怎么读 |
|---|---|---|
| 命中率 | 0.6%(2 / 312 次触发) | 分母是每一次文件访问 |
| 均次注入 | 74 B | 单次很轻 |
| 浪费率 | 0.0%(跳过 0 / 扫到 2 份) | 作用域判定的"空转"占比 |
| 累积字节 P50 | 11.2 KB | 每轮全量携带的成本 |
| 累积字节 P95 / MAX | 11.2 KB | 上限 |
| 发现耗时 P50 / MAX | 3 ms / 6 ms | 不在关键路径上 |
归因分布 nested_traversal × 2;通道分布 main × 289、subagent × 23。 排行榜上最吃上下文的是 CLAUDE.md(11.2 KB)。
P95 = 11.2 KB 意味着淘汰机制现在不该做。这个常驻量还构不成成本压力; 先写一套 LRU 淘汰再去找数据证明它合理,是把顺序做反了。
命中率 0.6% 不是缺陷。分母是"每一次文件访问",而绝大多数文件所在的目录本来就没有 规则文件。这个数字的用途是当异常哨兵——掉到 0 说明边界判定或触发面出了问题。
subagent 这个数字有个读法上的坑。上一版这里是 subagent × 0,当时的交代是 "回源码核验 sub-agent.ts 确实在发同一个事件,所以 0 的含义是这个窗口没有子代理 触发过 JIT,不是埋点缺失"。这个交代是对的,但 2026-08-08 之后它还不够—— 子代理的 JIT 开关那时才被接上(见边界表那节), 所以 0 从此还有第三种可能:"用了子代理,但 jitContext: false 正确地关掉了"。 三种情况的埋点长得一模一样,只能靠阳性对照区分。
这篇文章自己的数据漂移了 52 倍
上一版这篇写的是"19 个会话、累积字节 P50 = 186 B、P95 = 17.4 KB"。四天后重跑 同一条命令,只剩 12 个会话,P50 从 186 B 变成 9.5 KB——52 倍。
现在有第三个和第四个数了。同一条命令四次跑出四个结果:
| 日期 | 会话数 | 命中率 | 累积字节 P50 |
|---|---|---|---|
| 上一版 | 19 | — | 186 B |
| 2026-08-06 | 12 | 0.7% | 9.5 KB |
| 2026-08-07 | 15 | 0.4% | 11.2 KB |
| 2026-08-08 | 14 | 0.6% | 11.2 KB |
四次四个数,全部"正确"。 数字没错,是窗口移动了:~/.sid-code/ 下的轨迹是 滚动窗口,旧会话被清理。 而漂移的幅度恰好证明了当时那条定性结论:JIT 的成本不是均匀摊开的,是二值的—— 上个窗口多数会话根本不碰 src/ui/,成本接近零;这个窗口碰上了, 那份 31.5 KB 的 TUI 规范就常驻上下文直到会话结束。
所以结论要写成能跨窗口成立的形态。"命中率个位数百分比、多数触发落在无规则目录" 换个窗口依然成立;"命中 19 次"第二天就过期。你跑出来的数一定和上表不同。
怎么把规则写对
机制的部分到这里够用了。这四条不看实现也能直接照做。
规则放在它约束的代码旁边。 src/ui/ 的组件规范就放 src/ui/CLAUDE.md。 这样它的作用域天然正确,连 paths: 都不用声明——agent 碰这个目录才注入。 目录结构本身就是最好的作用域声明。
只在跨目录约束时用 paths:。"所有 Python 文件必须带类型标注"没有自然的目录归属, 就放项目根并声明 paths: ["**/*.py"]。
规则要写得能被检验。"代码要优雅"没有任何约束力;"禁止彩色 emoji,状态靠排版表达" 可以——模型能判断自己有没有违反,你也能。
规则不生效时,先怀疑它不在上下文里,再怀疑模型。排查顺序:
- 这轮 agent 碰过那个目录下的文件吗?没碰 → JIT 不会发现它,符合预期
- 规则文件有
paths:吗?拿实际访问的文件路径手动比一下 glob—— 注意src/ui/**匹配src/ui/README.md,但src/ui/**/*.tsx不匹配 - 规则是刚改的吗?子目录规则要再次触达该目录才重读——这是设计取舍而非缺陷 (代价是不即时,收益是不用递归监听全树,理由在这里)
- 规则里有
@import吗?路径后面紧跟中文标点的形态已在 2026-08-08 修好; 但@后跟非路径 token(@Component、@media)仍会被当成导入,用行内代码包裹它
四步走完还是不生效,那才轮到"模型没遵守"这个判断。
三个只能靠实测发现的缺陷
这几类缺陷有共同特征:不报错、不告警,只是静默地不生效,读代码看不出来。
一次正确的跳过,永久毒化整个目录
JIT 每轮工具调用都跑,必须去重。天然的做法是缓存两个集合: 已加载的文件、已扫描的目录。
但"已扫描"这个状态和作用域判定放在一起会出问题。用 paths: ["src/ui/**/*.tsx"] 构造一个序列(README 不匹配 .tsx,Footer 匹配):
1. 读 src/ui/README.md → 扫 src/ui/ → 作用域未命中 → 跳过
2. 读 src/ui/Footer.tsx → src/ui/ 已在"已扫描"集合 → 直接返回
→ 规则永远拿不到 ✘第一步的跳过是对的——README.md 确实不该触发 .tsx 组件规范。但如果因此把 src/ui/ 记成"已扫描",第二步那个本该命中的文件就再也没机会拿到规则了。
修法是把两种"没注入"分开:链上候选全处理完才登记为已扫描; 有规则因作用域未命中被跳过则不登记,留待下次触达重新判定。
在 fixture 里复现同一序列(2026-08-06,直接调 discoverDetailed):
读 src/ui/README.md hit=true scopeSkipped=1
loaded = proj/CLAUDE.md[nested_traversal]
读 src/ui/Footer.tsx hit=true scopeSkipped=0
loaded = ui/CLAUDE.md[path_glob_match] ✔这类缺陷的共同特征:去重缓存和条件判定耦合在一起时,"这次不适用"会被误存成 "以后都不用看"。
字符串前缀不是路径段
JIT 向上扫描时必须在项目根停住。停止条件最初是这样写的:
while (currentDir.startsWith(projectRoot)) {startsWith 是字符串前缀比较,不是路径段比较。/tmp/proj-evil 确实以 /tmp/proj 开头——于是项目根是 /tmp/proj 时,兄弟目录 /tmp/proj-evil 被判定为"在项目内", 它的 CLAUDE.md 被当作本项目规则注入。触发形态在日常开发里很常见: sid-code / sid-code-old、proj / proj-worktree、monorepo 里的 app / app-legacy。
放大危害的是用户看不见注入内容。JIT 是 harness 静默注入的内部上下文, 终端里不显示;泄露了也无从发现,你只会偶尔觉得 agent 行为有点怪。
修法是把判据换成路径段,并叠加 realpath 解引用(src/config/jit-context.ts:268):
const rootWithSep = realRoot.endsWith(sep)
? realRoot : realRoot + sep;
const isInsideProject = (dir) =>
dir === realRoot || dir.startsWith(rootWithSep);dir === realRoot 单独列出是必要的:项目根自己不以 projectRoot + sep 开头, 只写后半条会把项目根本身排除掉。
两个判据缺一不可:只做路径段比对会被 symlink 绕过(proj/vendor → /other/pkg), 只做 realpath 会被 proj-evil 这类字符串前缀兄弟目录绕过。而且向上遍历时 每一步都要重新 realpath——只在入口解引用一次,挡不住"入口在项目内、 祖先链爬出项目外"这种形态。
fixture 复现(proj 与 proj-evil 并列,后者规则里埋了标记串,2026-08-06):
projectRoot = /tmp/jitlab/proj
访问 = /tmp/jitlab/proj-evil/src/a.ts
hit=false loaded=0 elapsed=0.25ms
注入内容含 EVIL_RULE_LEAKED = false ✔顺带一个发现:把 Claude Code 的同段循环逻辑抽出来实测,同样的输入也会把 /tmp/proj-evil 纳入扫描目录——这是一条上游同样存在的缺陷。
中文标点会吞掉 @import
这条是写这篇时顺手撞出来的——跑一个无关的探针脚本,日志里蹦出一行告警:
⚠ [IMPORT] 导入文件不存在: .../src/ui/jrichman)。**ccjrichman)。**cc 显然不是路径。源头在 src/ui/CLAUDE.md 的一句 prose 里:
ink fork 整套 vendor 进了仓库(……`node_modules` 已无 ink /
@jrichman)。**cc 的渲染能力本项目基本都有**……规则文件支持 @path/to/file 语法导入其他文件。提取时会剥掉尾随标点 (src/config/import-processor.ts:116)。但这里的 @jrichman 后面跟的是 )。**cc——中间夹了非标点字符,剥不掉。
构造最小用例,用一个真实存在的 NOTE.md 测各种标点形态 (2026-08-06 直接调 processImports 实测):
see @NOTE.md, then go ✔ 英文逗号 + 后续文字
详见 @NOTE.md, ✔ 中文逗号在句末
见 @NOTE.md,然后继续 ✘ 中文逗号 + 后续文字
见 @NOTE.md。然后继续 ✘ 中文句号 + 后续文字
(已脱离 @NOTE.md)。**后续 ✘ 括号 + 句号 + 强调
见 @NOTE.md、以及别的 ✘ 中文顿号
见「@NOTE.md」后续 ✘ 全角引号包裹
见 @NOTE.md!后续 ✘ 感叹号英文标点后接空格,路径 token 到空格就断了;中文标点不需要空格, 于是标点连着后面的字一起被吞进路径。规则文件本身就是中文写的, 这个形态在这个仓库里不是边角情况。
好在失效方向是安全的:导入不成功,原文照样保留在上下文里,只是少了展开的那部分, 并留下一行告警。但如果不是恰好跑了那个探针,这行告警会一直被日志噪音盖着。
修法:第一版方向是错的
2026-08-08 修掉了。值得写的是第一版修法方向错了:既然是"尾巴剥不干净", 那就把剥离的字符类写全一点——但 (已脱离 @NOTE.md)。**后续 这个形态里, 标点后面还有非标点字符,$ 锚定的正则永远匹配不上。
真正的根因不在剥离,在提取:token 字符类是 [^\s\\]+,只把空白当终止符。 所以修法是把 CJK 标点加进终止符集合,让它在提取时就断开:
const CJK_PUNCT = ",。、;:!?()【】「」『』〈〉《》〔〕“”‘’…—~·";
// 注意开头的字符类也要放开,否则 `见「@NOTE.md」后续` 这种「@ 前紧贴全角引号」
// 的形态根本匹配不到开头——只改结尾会漏掉一半
const re = new RegExp(`(?:^|[\\s${CJK}])@((?:[^\\s\\\\${CJK}]|\\\\ )+)`, "g");通用教训:清洗输出治不了输入端的过度接纳。
顺带补了一条一直落后 CC 的:#fragment 截断(@NOTE.md#标题 → NOTE.md, CC 在 claudemd.ts:466 早就有)。但没有照抄 CC 另一条 isValidPath 首字符 白名单(claudemd.ts:477,要求 ^[a-zA-Z0-9._-])——它的副作用是纯中文路径直接不认, 而这个仓库的规则文件全中文,@文档/说明.md 是合法形态。 对标不是照抄,要看清对方的取舍前提是不是也是你的前提。 CC 用的是同一个正则思路,所以中文标点这条它同样存在,而且它连标点剥离都没有—— 这是共同上限,不是"我们落后"。它是英文语境产品,这个形态在它那里几乎不发生。
实测 13 种形态全通过(2026-08-08,bun scripts/probe/jit-boundary-b1.ts,可重跑), 两条哨兵一并钉在测试里:见 @文档/说明.md,后续 防止未来有人照抄 CC 的白名单, 见 @a.b.md,后续 防止有人把事后剥离写得过于贪婪(把 . 也当终止符会截成 a)。
最后一句诚实交代:开头那行告警并没有消失。 它只是从垃圾路径 jrichman)。**cc 变成了合法路径 jrichman——那个包不在仓库里,existsSync 照样失败。 想彻底消掉得改文档写法(用行内代码包裹),那是文档侧的事。
追加式注入需要一个单一收口
JIT 注入的实现方式是把规则块追加到系统提示词末尾。这带来一个必然的副作用: 任何"覆盖式重建系统提示词"的操作都会把它整体抹掉。
覆盖式重建的入口不少:/model 切模型、/language 切语言、/memory reload、 CLAUDE.md 变更触发的重建、压缩后重建——每一个都是 setSystemPrompt(newPrompt)。
更麻烦的是抹掉之后不会自愈。JIT 的去重集合里已经把那份文件记为"已加载", 后续再访问同一目录也不会重新注入,规则就此永久丢失直到进程重启。用户视角看到的是: 前半场会话规则好好生效,切了一次模型之后突然不遵守了,且没有任何提示。
第一版修法是在 App 里守一个 applySystemPrompt,要求所有重建都走它。这个方案漏了—— /memory reload 拿到的是 ctxMgr,直接调裸 setSystemPrompt 就绕过了收口。 靠纪律维持的收口必然漏网:它把"别漏"的责任推给了新增入口的人。
第二版把回灌下沉进 ContextManager.setSystemPrompt 本身(src/context/manager.ts:559):
setSystemPrompt(prompt: string): void {
let finalPrompt = prompt;
try {
const blocks = this.jitBlocksProvider?.() ?? [];
const missing = blocks.filter(
(b) => b && !prompt.includes(b),
);
if (missing.length > 0)
finalPrompt = prompt + "\n\n" + missing.join("\n\n");
} catch {
// 回灌失败不能阻断提示词写入
}
this.systemPrompt = finalPrompt;
}现在没有可绕过的路径:所有写入者共享同一个不变量,新增入口不必知道 JIT 的存在。 逐块判定保证幂等——重复注入会让模型看到两份可能已经不一致的内容。实测:
边界位置=5 JIT位置=78
JIT 落在动态区(缓存边界之后) = true
重建 3 次后 JIT 块出现次数 = 1 ← 幂等
裸调 setSystemPrompt 后自动回灌 = true ← 无旁路差别在责任的位置:守一个 applySystemPrompt 是把不变量交给调用方维护, 下沉到 setSystemPrompt 是让不变量由持有状态的那个类自己保证。 前者的漏网数量随入口增长,后者恒为零。
张力:JIT 与 prompt cache 互相冲突
JIT 是"按需追加系统提示词",prompt cache 是"系统提示词前缀不变才能命中"。 这两件事在方向上直接对立,而两者服务的是同一个北极星方向——省。
sid-code 的系统提示词按一个边界标记切成两区:静态区打缓存断点,动态区(日期、 git 状态这类每次都变的内容)放在边界之后。上面那组实测确认 JIT 注入落在动态区, 所以它不会击穿整个前缀缓存。但新注入发生的那一轮,动态区内容变了,那部分要重算。
这是个真实的成本,不是零。 它换来的是无关规则不进上下文(省下的是每一轮的钱), 以及规则不互相干扰(省下的是模型的注意力)——JIT 为"精准"牺牲了一点"缓存命中"。
而"落在动态区所以不击穿前缀"只对 Anthropic 族完整成立。OpenAI 族没有分段能力, 动态区被搬到了消息序列末尾——那正是 JIT 追加内容的位置,所以每次新注入都会让 该位置之后的前缀本轮断裂。完整对比在 Prompt Cache:两族协议的分叉。
这个取舍也不是普适最优。规则文件少且都是项目级无条件规则的仓库里, 启动时一次全量加载、之后前缀完全稳定,反而更省。JIT 的收益随"目录级规范的数量和 分散程度"增长——这解释了上面那个二值分布:碰不到 src/ui/ 的会话, JIT 的净收益就是省下 6,811 token 而只付百来字节。
所以它是个可关的开关(全部可配字段见 settings.json):
{ "jitContext": false }关掉之后,带 paths: 的规则只剩启动加载那条路径,运行时发现不再发生。
当前的能力边界
这张表在 2026-08-06 有五行。2026-08-08 逐条闭环之后剩下两行—— 被消掉的那三条怎么消的,写在下一节。
| 边界 | 具体表现 | 绕法 |
|---|---|---|
bash 三类形态不认 | 变量路径(> $OUT)、命令替换(> $(mktemp))、程序自己写文件(python gen.py)不提取 | 需要规则可靠生效时走 edit / write。cp / mv / install / touch / mkdir 已在 2026-08-08 支持 |
@ 后紧跟非路径 token 仍会被当导入 | @Component(装饰器)、@media(CSS)被提取为无扩展名路径,existsSync 失败后跳过并留一行告警 | 用行内代码包裹(`@Component`)。失效方向安全:原文保留,只是多一行日志噪音 |
第一条只能收窄不能消除,而且后两类不是"难"而是"不可能":值在运行时才知道、 任意程序可以写任意路径,静态提取必错。要支持等于要静态分析任意程序。 第二条是这次修中文标点时选择不动的——它是另一个议题(装饰器误抓), 改它会扩大 blast radius,而它的失效方向是安全的。
列在这里不是免责声明。不写出来,用户只会遇到"规则有时候生效有时候不生效" 这种最难排查的现象。
一条从这张表里移出去的:子目录规则不被监听
旧版把它列为边界,写的是"watchCLAUDEmd 只收项目根 / 全局 / 企业三个文件 + .claude/rules/ 目录,会话中途改子目录 CLAUDE.md 不会即时重建"。 技术描述准确,但归类错了——它是设计取舍,不是缺陷。
两个实测理由。一是 mtime 兜底真的生效(2026-08-08,fixture 构造"会话中途改子目录规则", 直调 discoverDetailed 两次):
① 首次读 Footer.tsx hit=true loaded=2 含V1=true 含V2=false
(此处改盘:src/ui/CLAUDE.md 内容 V1 → V2)
② 改规则后再读同一文件 hit=true loaded=1 含V1=false 含V2=true
← 旧内容被替换,不是叠加用户可感知的症状("改了规则不生效")并不存在。二是回源码扫了一遍 Claude Code: 它全仓 12 个 fs.watch/chokidar 消费方没有一个是 memory 文件, 靠 getMemoryFiles 的 memoize + 9 处显式 clearMemoryFileCaches()。 也就是说 CC 根本没有"会话中途改文件自动生效"这个能力,sid-code 有 watcher, 覆盖范围严格大于它。把一个已经领先的实现写成缺口,比不写更伤可信度。
那要不要把 watcher 扩到全部子目录?不要,三条理由:递归 watch 大仓有真实成本 (inotify 句柄、FSEvents 回调风暴),而收益只是一个感知不到的窗口期; watcher 触发的是覆盖式重建系统提示词,正是上一节 讲的高危路径,多一个触发源就多一次回灌考验;mtime 兜底是无状态的 (每次读盘现场判定),watcher 是有状态的(漏一次事件就永久失配)—— 在两者能力重叠的部分,无状态那条更可靠。
代价说清楚:改完规则、还没碰那个目录的窗口期内,规则是旧的。 但这个窗口期里模型本来就不会用到那份规则(它没在碰那个目录)。
这张边界表是怎么被消掉的
上一节那张表原本有五行。把它逐条做成闭环(2026-08-08)比任何机制解析都更能说明 这类缺口的性质,所以值得单独写一节。方案与全部实测记录在 docs/bugfixes/todo/20260807-JIT上下文五条能力边界闭环-设计与二次验证方案.md。
五条里三条是"做了但没接到底"。 参数存在却没穿线(子代理开关)、 默认值靠调用约定而非常量(jitContext)、监听范围小于加载范围(子目录规则)。 这类缺口的共同特征是代码里看得见对应的机制,所以比"没实现"更难发现—— 读代码的人看到 jitDisabled 参数就以为它在工作。 前两条修掉了,各配一道会变红的测试;第三条见上一节,它根本不是缺口。
一条是把领先写成了短板。 子目录监听那条,回源码核验 CC 之后才发现它连 watcher 都没有。自曝其短是对的,但过度自曝也是不准确——它和"沿用过期描述"是同一类错误的 两个方向,锚都是回源码跑一遍。
一条只能收窄不能消除,而收窄的依据推翻了一条代码注释。 bash 那条, jit-affected-paths.ts 里写了很久的不支持理由是"mv/cp 目标可能是目录, 语义判定复杂"。实测下来这个理由不成立:JIT 下游 discoverDetailed 有 targetIsDir 分支,传目录、传尾斜杠、传不存在的路径三种形态全部安全。那条注释是按静态提取的难点 写理由,而没有回头看下游能不能消化——下游早就能了,上游还在因为一个不存在的约束 拒绝提取。注释里的理由也会过期。留着一个被推翻的理由,下一个人会照着它继续拒绝 正确的改动。
于是 cp / mv / install / touch / mkdir 现在都认(取"最后一个非选项 token"作目标, touch/mkdir 则每个参数都是目标),复用原有的过滤链,所以变量、通配、/tmp、 - 前缀这些形态与旧有的重定向形态走同一套判据。而 rm 刻意永久不支持—— 删除之后那个目录的规则不再适用于任何后续操作,注入是纯浪费。
最后一条经验,关于怎么验收。这类"接线"修复有个特有的陷阱:埋点显示 subagent × 0 时,它既可能是"开关正确地关掉了",也可能是"这条路径本来就不生效"。 两者的埋点长得一模一样。所以验收必须做阳性对照——默认开启时派子代理读有规则的目录, 埋点里必须出现 subagent ≥ 1:
默认开启 + 派子代理读 src/ui/Badge.tsx → subagent×3,其中 hit=true 载入 src/ui/CLAUDE.md
jitContext: false + 同一个 prompt → subagent×0,而 sub_agent 确实执行、确实读了同一个文件没有上面那一行,下面那一行什么都证明不了。
一句话
规则能不能约束模型,取决于它在那一刻是否在上下文里——这是工程问题,不是模型问题。 JIT 把"什么规则、什么时刻"做成机制,代价是一点缓存开销和几处需要小心维护的耦合点。
两条比机制本身更通用的教训:去重缓存碰上条件判定,"这次不适用"容易被误存成 "以后都不用看";追加式内容碰上覆盖式重建,不变量要由持有状态的类自己保证, 而不是靠每个调用方记得。
还有一条方法论上的,这次重写自己撞了两回:旧文那张体积表和那条 grep 会报 两个信号的说法,回源码一查全变了。 带着证据口气的过期描述比不写更容易骗人。
这条在把边界表消掉的过程中又应验了两次,而且换了两个载体:一次是代码注释 ("cp/mv 目标可能是目录"这个不支持理由,实测下来根本不成立), 一次是这篇文章自己(把一个比上游领先的实现写成了缺口)。 注释、文档、博客三者都会漂移,唯一的锚是跑一遍。
相关
- Prompt Cache:两族协议的分叉 —— JIT 追加内容落在哪个区、 两族协议为什么算出不同的账,附跨数百会话的命中率实测账本
- 记忆与 CLAUDE.md —— 七层规则的优先级与作用范围,写规则文件先读这篇
- 上下文与压缩 —— 上下文占用怎么看、
/compact什么时候该手动跑 - settings.json 字段 ——
jitContext等全部可配字段与默认值
本文所有实测的原始记录、探针脚本与逐条裁决在仓库里: docs/bugfixes/todo/20260807-JIT上下文五条能力边界闭环-设计与二次验证方案.md。 包含五条边界的逐条证据、四个可重跑的 fixture 探针、以及落地时与方案预期不符的四处偏差。