主题
JIT 上下文:让规则在正确的时刻进入上下文
你在 src/ui/CLAUDE.md 里写了"这个目录下的组件禁止用彩色 emoji",agent 改完代码, emoji 还在。第一反应大概是"模型不听话"。
多数情况下不是。是那条规则当时根本不在上下文里。
结论先放这里
- 规则有两条进入上下文的路径:启动加载(一定要有的)与 JIT 运行时发现(用到才给)。 同一份带
paths:的规则,两条路径的命中判定方式必须不同——它们手上的信息不一样。 - 实测基线(2026-08-02,19 个有 JIT 数据的会话):触发命中率 5.6%、均次注入 310 B、 累积字节 P50 186 B / P95 17.4 KB。P50 与 P95 差近 100 倍,说明成本是二值的,不是均摊的。
- P95 = 17.4 KB 意味着淘汰机制现在不该做 —— 这个结论是数据给的,不是感觉给的。
- 三个缺陷都是静默失效:去重缓存毒化整个目录、
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 一次读完、全塞进系统提示词。 在这个仓库里,那意味着:
| 规则文件 | 体积 | 估算 token |
|---|---|---|
CLAUDE.md(项目根) | 7.7 KB | ≈ 3,097 |
src/ui/CLAUDE.md(TUI 规范) | 17.8 KB | ≈ 6,811 |
docs/summary/CLAUDE.md | 1.2 KB | ≈ 653 |
| 合计 | 26.7 KB | ≈ 10,561 |
一万个 token,每一轮请求都要重新发一遍。而绝大多数任务只会碰到其中一份。 写文档的时候那 6,811 token 的 TUI 规范一条都用不上,但它每轮都在收费。
这还只是钱的问题。更麻烦的是无关规则会主动干扰模型。我们踩过一次真实的: 在 website/ 目录做文档任务,上下文里被塞进了 src/ui/ 的 TUI 组件规范, 模型连续 6 次在回复里自述"注入的规范与当前任务无关"。它不但没帮上忙, 还占着模型的注意力,让它反复解释自己为什么不用理这些规则。
规则之间还会互相矛盾。src/ui/ 要求"状态靠排版不靠颜色", website/ 要求"配色走 theme 变量"——单独看都对,同时出现模型就得猜哪条适用。
所以目标不是"尽可能多给规则",而是在正确的时刻给正确的规则。 这就要求机制能回答一个问题:现在这一刻,什么算"相关"?
两条注入路径
sid-code 里规则有两条进入上下文的路径,分工明确。
| 启动加载 | 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"]。而仓库里接受路径参数的 文件类工具实际有 11 个——read_many / notebook_edit / ls / lsp / bash 全部漏在名单外面。硬编码名单和真实注册的工具之间没有对账机制,新增工具必然漏。
现在改成工具自报:每个工具实现一个 jitAffectedPaths(input) 纯函数, 声明"我这次调用碰了哪些路径"。再由一个测试双向对账,漏报会让 CI 变红:
tests/tool/jit-affected-paths-audit.test.ts这里刻意不做"按 file_path / path 字段名猜"的兜底。猜测式兜底会把非文件语义 的同名字段(web_fetch 的 url、MCP 工具的 path 形参)误当本地路径去 stat, 产生无意义 IO 与误注入。而漏报的代价是 CI 可见的——所以这里选 fail-closed。
bash 是这轮改造里最麻烦的一个。一条 shell 命令碰了哪些路径,静态看不出来。 做法是只认几个高确定性形态,其余一律不报。实测:
text
cat > src/ui/Badge.tsx <<EOF → ["src/ui/Badge.tsx"]
sed -i '' 's/a/b/' src/ui/Footer.tsx → ["src/ui/Footer.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"]
echo x > $OUT → [] (变量,运行时才知道)
echo x > /dev/null → [] (不是业务文件)
cp src/a.ts src/ui/b.ts → [] (语义复杂,刻意不支持)为什么宁漏不误:误报的代价是烧 token + 可能让模型遵循错误规范; 漏报只是回到改造前的状态。两者不对称,所以往保守一侧倒。
glob 也有一处类似的退化。glob("src/ui/**/*.tsx") 不带 path 参数时, 原实现拿不到目录信息,退化成扫项目根——目标目录的规范拿不到。 现在从 pattern 里提取静态前缀:
text
glob("src/ui/**/*.tsx") → ["src/ui"]
glob("src/api/*.ts") → ["src/api"]
glob("**/*.ts") → [] (通配符前无目录可用)
grep{path:"src", pattern:"ui/**/*.tsx"} → ["src","src/ui"]最后那条是两个信号都要报的原因:单看 path 只到 src,单看 pattern 前缀只有 相对段 ui,组合出的 src/ui 才是真正被搜索的目录。
paths: 作用域:同一份规则,两条路径判定方式不同
规则文件可以在 frontmatter 里声明作用域。这个仓库的 src/ui/CLAUDE.md 就是:
markdown
---
paths: ["src/ui/**", "src/ink/**"]
---
# src/ui — TUI 设计规范:视觉语言 + 交互体验意思是:这份规范只在处理 src/ui/ 或 src/ink/ 下的文件时才该出现。
两条路径判定"命中"的方式必须不同,因为它们手上的信息不一样。
JIT 侧:拿真实的活动文件判
JIT 手里有确切信息——accessedPath 就是 agent 这一刻真正在读写的文件。 拿它跟 paths 直接比:
- 读
src/ui/Footer.tsx→ 命中 → 注入 - 读
website/index.md→ 不命中 → 跳过
这是 paths 语义最干净的一次判定:作用域 = 我此刻正在动的文件。
启动侧:没有"活动文件",得先构造信号
启动时 agent 还没碰任何文件,accessedPath 不存在。如果这时候对所有带 paths: 的规则一律拒绝,会留下一个真实的坑:整场会话不触碰该目录的任务(纯对话、 纯规划、只读代码不改)永远拿不到作用域规则。
所以启动侧自己采集两个信号:
- cwd 目录标记——你在哪个目录启动的,代表"你当前在哪工作"
- git 变更文件——你已经改了什么,代表"你正在改什么"
(下面两块是这套判定的实测细节。只想知道怎么写规则的话, 可以直接跳到 怎么把规则写对。)
在一个受控 fixture 里实测三种启动位置(规则是 paths: ["src/ui/**"]):
text
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 语义实测一遍就清楚了(Glob 即 Bun.Glob):
text
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/README.md") → true
Glob("src/ui/**/*.tsx").match("src/ui/README.md") → false目录标记必须带末尾 /。少这一个字符,整个信号静默失效——不报错、不告警, 只是作用域规则再也匹配不上。
git 变更还必须按 cwd 收窄,只取当前工作目录子树内的变更,不是全仓变更。 这正是前面那起"website 里被注入 TUI 规范"事故的修法:这类长期开发的仓库里 src/ui 常有未提交改动,若取全仓变更,那份 TUI 规范会在任何目录下都被拉进上下文, 等于换个入口把事故重演一遍。收窄后语义才自洽:作用域 = 我此刻工作的范围。
采集 git 变更的命令也有个坑:
bash
git --no-optional-locks status --porcelain -z -uall-uall 是必需的。默认的 -unormal 会把未追踪目录折叠成 website/ 一条, 新文件的扩展名根本不出现在输出里——于是 paths: ["**/*.py"] 对新建的 Python 文件 一律失配。恰好是最该命中的场景。
顺带一条设计取向:git 不可用或不在仓库里时,采集结果退回到"仅含 cwd 标记", 而不是"匹配一切"。作用域机制的目的是收窄注入面,采集失败就应该倒向更保守的那一侧。
实测基线:19 个会话的 JIT 数据
讲机制容易,讲清楚它值不值得就得有数。这套机制自带埋点,每次发现都记一条 jit_context 事件——无论命中与否。只在命中时打点会让分母永远缺失, 算不出覆盖率。
bash
bun scripts/jit-context-stats.ts --all --by-file2026-08-02 实测,跨 31 个会话(其中 5 个早于埋点上线、19 个有 JIT 数据):
| 指标 | 值 | 怎么读 |
|---|---|---|
| 命中率 | 5.6%(19 / 339 次触发) | 多数触发落在无规则目录 |
| 均次注入 | 310 B | 单次很轻 |
| 浪费率 | 13.6%(作用域跳过 3 / 扫到 22 份) | 作用域判定的"空转"占比 |
| 累积字节 P50 | 186 B | 半数会话几乎零成本 |
| 累积字节 P95 / MAX | 17.4 KB | 每轮全量携带的上限 |
| 发现耗时 P50 / MAX | 6 ms / 16 ms | 不在关键路径上 |
| 加载失败 / 超限 | 0 / 0 |
归因分布 path_glob_match × 17、nested_traversal × 2, 通道分布 main × 334、subagent × 5。
你跑出来的数会和这里不同
~/.sid-code/ 下的轨迹是滚动窗口:旧会话会被清理,新会话不断追加。 所以上面这张表是 2026-08-02 那天的切片,不是一个可复现的固定值—— 两天后重跑同一条命令,"有 JIT 数据的会话"已经从 19 个变成 10 个,各项指标随之全变。
这不影响结论,因为下面这几条读法都是对窗口不敏感的形态: "命中率个位数百分比、多数触发落在无规则目录"换个窗口依然成立, "命中 19 次"第二天就过期了。拿这类数据下结论时, 结论要写成能跨窗口成立的样子,否则读者一复现就对不上。
几个值得说的读法。
命中率 5.6% 不是缺陷,它就是这套机制该有的样子。分母是"每一次文件访问", 而绝大多数文件所在的目录本来就没有规则文件。这个数字的用途是当异常哨兵—— 如果它掉到 0,说明边界判定或触发面出了问题。
累积字节的 P50 与 P95 差了将近 100 倍(186 B vs 17.4 KB),这个分布形状比 平均值有信息量:绝大多数会话根本不碰 src/ui/,成本接近零;一旦碰上, 那份 17.8 KB 的 TUI 规范就常驻上下文直到会话结束。JIT 的成本不是均匀摊开的, 是二值的。
P95 = 17.4 KB 意味着淘汰机制现在不该做。这个结论是数据给的,不是感觉给的—— 17 KB 的常驻量还构不成成本压力,先写一套 LRU 淘汰再去找数据证明它合理, 是把顺序做反了。
一个口径细节:--by-file 排行取的是各会话的最大字节而非累加。 同一份规则在一次会话里因文件变更重载多次,累加会把"重载次数"当成"体积", 排行就变成了"哪份规则改得最勤",答的是另一个问题。
怎么把规则写对
机制的部分到这里够用了。下面这四条是不看实现也能直接照做的, 剩下的章节讲"为什么这么设计"和"哪里踩过坑"。
规则放在它约束的代码旁边。 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吗?后面紧跟中文标点的话,那条导入现在是坏的
四步走完还是不生效,那才轮到"模型没遵守"这个判断。
三个只能靠实测发现的缺陷
这套机制里有几类缺陷有共同特征:不报错、不告警,只是静默地不生效。 读代码看不出来,只能构造场景实测。
一次正确的跳过,永久毒化整个目录
JIT 每轮工具调用都跑,必须去重,否则同一份规则每轮重复注入。 天然的做法是缓存两个集合:已加载的文件、已扫描的目录。
但"已扫描"这个状态和作用域判定放在一起会出问题。用 paths: ["src/ui/**/*.tsx"] 构造一个序列(README 不匹配 .tsx,Footer 匹配):
text
1. 读 src/ui/README.md → 扫描 src/ui/ → 作用域未命中 → 跳过
2. 读 src/ui/Footer.tsx → src/ui/ 已在"已扫描"集合里 → 直接返回
→ 规则永远拿不到 ❌第一步的"跳过"是对的——README.md 确实不该触发 .tsx 组件规范。但如果因此把 src/ui/ 记成"已扫描",第二步那个本该命中的文件就再也没机会拿到规则了。
修法是把两种"没注入"分开:
- 链上所有候选都处理完了 → 登记为已扫描,后续跳过(真的没有新东西)
- 有规则因作用域未命中被跳过 → 不登记,留待下次触达重新判定
修完后同一序列的实测:
text
① 读 src/ui/README.md → hit=true scopeSkipped=1
→ 只注入了项目根 CLAUDE.md
② 读 src/ui/Footer.tsx → hit=true scopeSkipped=0
→ src/ui/CLAUDE.md[path_glob_match] ✔同理,作用域未命中的文件也不进"已加载"集合——它没被加载,只是这次不适用。
这类缺陷的共同特征:去重缓存和条件判定耦合在一起时,"这次不适用"会被误存成 "以后都不用看"。
字符串前缀不是路径段
JIT 向上扫描时必须在项目根停住。停止条件最初是这样写的:
js
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(同级 git worktree,我们自己天天在用)、 monorepo 里的 app / app-legacy。
放大危害的是用户看不见注入内容。JIT 是 harness 静默注入的内部上下文, 终端里不显示。泄露了也无从发现——你只会偶尔觉得 agent 行为有点怪。
修法是把判据换成路径段,并叠加 realpath 解引用:
js
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 并列,后者的规则里埋了标记串):
text
projectRoot = /tmp/jitlab/proj
访问 = /tmp/jitlab/proj-evil/src/a.ts
hit=false loaded=0 elapsed=0.07ms
注入内容含 EVIL_RULE_LEAKED = false ✔顺带一个发现:把 Claude Code 的同段循环逻辑抽出来实测,同样的输入也会把 /tmp/proj-evil 纳入扫描目录。这是一条上游同样存在的缺陷。
中文标点会吞掉 @import
这条是写这篇文章时顺手撞出来的——跑一个无关的探针脚本,日志里蹦出一行告警:
⚠ [IMPORT] 导入文件不存在: .../src/ui/jrichman)。**ccjrichman)。**cc 显然不是路径。定位到 src/ui/CLAUDE.md 第 209 行:
markdown
> **渲染底座 = vendor 进 `src/ink` 的 claude-code 同款 ink fork**(已脱离
> node_modules 的 `@jrichman/ink`)。cc 的渲染能力**本项目都有**……规则文件支持 @path/to/file 语法导入其他文件。提取时会剥掉尾随标点, 避免把句末标点当路径的一部分。但这里的 @jrichman/ink 后面跟的是 )。**cc——中间夹了非标点字符,剥不掉。
构造最小用例,用一个真实存在的 NOTE.md 测各种标点形态:
text
see @NOTE.md, then go 导入=✔ ← 英文逗号 + 后续文字
详见 @NOTE.md, 导入=✔ ← 中文逗号在句末
见 @NOTE.md,然后继续 导入=✘ ← 中文逗号 + 后续文字
见 @NOTE.md。然后继续 导入=✘ ← 中文句号 + 后续文字
(已脱离 @NOTE.md)。**后续 导入=✘ ← 括号 + 句号 + 强调标记英文标点后接空格,路径 token 到空格就断了;中文标点不需要空格, 于是标点连着后面的字一起被吞进路径。规则文件本身就是中文写的, 这个形态在这个仓库里不是边角情况。
好在失效方向是安全的:导入不成功,原文照样保留在上下文里,只是少了展开的那部分内容, 并留下一行告警。但它满足"静默失效"的全部特征——如果不是恰好跑了那个探针, 这行告警会一直被日志噪音盖着。已登记待修。
追加式注入需要一个单一收口
JIT 注入的实现方式是把规则块追加到系统提示词末尾。这带来一个必然的副作用: 任何"覆盖式重建系统提示词"的操作都会把它整体抹掉。
覆盖式重建的入口不少:/model 切模型、/language 切语言、/memory reload、 CLAUDE.md 变更触发的重建、压缩后重建……每一个都是 setSystemPrompt(newPrompt)。
更麻烦的是抹掉之后不会自愈。JIT 的去重集合里已经把那份文件记为"已加载", 后续再访问同一目录也不会重新注入。规则就此永久丢失,直到进程重启。 用户视角看到的现象是:前半场会话规则好好生效,切了一次模型之后突然不遵守了, 且没有任何提示。
第一版修法是在 App 里守一个 applySystemPrompt,要求所有重建都走它。 这个方案漏了——/memory reload 拿到的是 ctxMgr,直接调裸 setSystemPrompt 就绕过了收口。靠纪律维持的收口必然漏网:它把"别漏"的责任推给"新增入口的人 要知道有这个收口"。
第二版把回灌下沉进 ContextManager.setSystemPrompt 本身:
ts
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 的存在。 逐块判定保证幂等——重复注入同一份规则不但浪费 token,还会让模型看到两份可能 已经不一致的内容。实测:
text
边界位置=5 JIT位置=78
JIT 落在动态区(缓存边界之后) = true
重建 3 次后 JIT 块出现次数 = 1 ← 幂等
裸调 setSystemPrompt 后自动回灌 = true ← 无旁路差别在责任的位置:守一个 applySystemPrompt 是把不变量交给调用方维护, 下沉到 setSystemPrompt 是让不变量由持有状态的那个类自己保证。 前者的漏网数量随入口增长,后者恒为零。
一个必须正视的张力:JIT 与 prompt cache 互相冲突
JIT 是"按需追加系统提示词"。prompt cache 是"系统提示词前缀不变才能命中"。 这两件事在方向上直接对立。
sid-code 的系统提示词按一个边界标记切成两区:静态区打缓存断点,动态区(日期、 git 状态这类每次都变的内容)放在边界之后。上面那组实测确认了 JIT 注入落在动态区 ——所以它不会击穿整个前缀缓存。但新注入发生的那一轮,动态区内容变了,那部分要重算。
这是个真实的成本,不是零。它换来的是:无关规则不进上下文(省下的是每一轮的钱), 以及规则不互相干扰(省下的是模型的注意力)。
不过上面这段"落在动态区所以不击穿前缀"只对 Anthropic 族完整成立。 OpenAI 族没有分段能力,动态区被搬到了消息序列末尾——而那正是 JIT 追加内容的位置, 所以每次新注入都会让该位置之后的前缀本轮断裂。两族的账不一样, 完整的实测对比在 Prompt Cache:两族协议的分叉。
值得说清楚的是这个取舍不是普适最优。规则文件少且都是项目级无条件规则的仓库里, 启动时一次全量加载、之后前缀完全稳定,反而更省。JIT 的收益随"目录级规范的数量和 分散程度"增长——这也解释了上面那个二值分布:碰不到 src/ui/ 的会话, JIT 的净收益就是省下 6,811 token 而只付 186 B。
所以它是个可关的开关:
json
{ "jitContext": false }关掉之后,带 paths: 的规则只剩启动加载那条路径(靠 cwd 与 git 变更信号判定), 运行时按需发现不再发生。
当前的能力边界
写这篇时把旧版博客列的边界逐条重测了一遍,多数已经在后续几批改造里修掉了 (bash 现在触发、glob 现在提取静态前缀、子代理现在走 JIT)。 剩下这些是实测确认仍然存在的:
| 边界 | 具体表现 | 绕法 |
|---|---|---|
| 子目录规则不被监听 | 文件变更监听只覆盖项目根 / 全局 / 企业 / .claude/rules/。会话中途改子目录 CLAUDE.md 不会即时重建 | 靠 mtime 兜底——再次触达该目录时会重读;想立刻生效则重开会话 |
| 子代理 JIT 关不掉 | 子代理有 jitDisabled 参数但唯一调用点从不传它,jitContext: false 对子代理无效 | 暂无。影响是"关不掉"而非"不生效",方向安全 |
jitContext 默认值无单一事实源 | "默认 true"靠三处 === false 的调用约定维持,新增消费点写成 if (config.jitContext) 会静默反转默认值 | 改代码时注意 |
中文标点吞 @import | 见上文,中文标点 + 后续文字会把标点连字一起吞进路径 | 让 @path 后跟空格或换行 |
bash 部分形态不认 | cp / mv / 变量路径 / 命令替换刻意不提取 | 需要规则可靠生效时走 edit / write |
其中前三条有个共同点:它们都不是"功能没做",而是做了但没接到底—— 参数存在却没穿线、默认值靠约定而非常量、监听范围小于加载范围。这类缺口比 "没实现"更难发现,因为代码里看得见对应的机制。
这些列在这里不是免责声明。知道边界在哪,你才能绕开它。不写出来, 用户只会遇到"规则有时候生效有时候不生效"这种最难排查的现象。
测试怎么写比写了多少条更值得说。两条做法:作用域采集那批的每条用例都锚定一个 具体的静默退化场景——比如"目录标记必须带末尾斜杠",摘掉那个斜杠测试就变红; 边界缺陷的回归测试成对写,既测"不泄露"也测"项目内不误伤", 因为只测前者很容易写出一个把所有目录都拒绝的"修复"。
一句话
规则能不能约束模型,取决于它在那一刻是否在上下文里——这是工程问题,不是模型问题。 JIT 把"什么规则、什么时刻"做成机制,代价是一点缓存开销和几处需要小心维护的耦合点。
两条比机制本身更通用的教训:去重缓存碰上条件判定,"这次不适用"容易被误存成 "以后都不用看";追加式内容碰上覆盖式重建,不变量要由持有状态的类自己保证, 而不是靠每个调用方记得。
还有一条方法论上的:这篇里三个缺陷,一个靠构造 fixture 测出来,一个靠对照 字符串前缀语义想出来,一个靠跑无关脚本时瞥见一行日志告警。静默失效的东西 读代码读不出来——得让它跑起来,还得有人真的看日志。
相关
- 记忆与 CLAUDE.md —— 七层规则的优先级与作用范围,写规则文件先读这篇
- 上下文与压缩 —— 上下文占用怎么看、
/compact什么时候该手动跑 - settings.json 字段 ——
jitContext等全部可配字段