Claude Code 频繁 Compacting?发布次日我根治了 2 个痛点

0 阅读11分钟

Claude Code 频繁 Compacting?发布次日我根治了 2 个痛点

v0.5.0 发布次日就出了 v0.5.1——不是翻车救火,是我最喜欢的一类迭代:疼了就根治,一天闭环,一步门禁没省——两个痛点,各根治一次。

先给没读过前篇的读者一句话背景:CCM(Claude Code Model Manager)是我用 Rust 写的单二进制「本地模型路由控制平面」——把 Claude Code 指向任意第三方模型网关(z.ai、DeepSeek 等),中间加装路由、熔断、观测与运行时切换。立项原因第 75 篇讲过,不展开。

时间线先摆出来,全部出自仓库 docs/V0.5_PLAN.md 的记录:

日期事件
10-10v0.5.0 tag + Release
10-10greenlight「ccm 侧根治」,落地 commit 2d39dae——起因:直连 glm 长会话在 1M 窗口 ~15% 处被提前压缩
10-11问了一句 --resume 支不支持 → greenlight「实现吧,然后注意修改文档」(commit 84e4d2d)
10-11v0.5.1 发布:tag 前 docs 审计 7 缺陷全是文档(修进 tag commit 05ab15c),CI 双 runner 绿,产物 + LF checksums 重下验证

痛点一:1M 的窗口,15% 就开始 Compacting

症状与机理

我用 ccm run glm 挂直连长会话(glm-5.3 走 z.ai,真实上下文窗口 1M,z.ai 文档口径),Claude Code 的界面开始频繁蹦 Compacting conversation。压缩点在哪?V0.5_PLAN §11 的落地记录写得很直白:会话在真实 1M 窗口的 ~15% 处就被自动压缩(单例实测,约)。

150K 上下就动手压缩,反推 Claude Code 把 glm-5.3 当成了 200K 档的陌生模型在管。「按假定小窗口提前压缩」的行为,是文档记录在案的。

200K 这个数也不是凭空推断——社区口径约 200K,官方文档对 pinned 模型场景还明文记载了 200K 默认(仓库 USAGE 5.3 记录);只是官方没覆盖 unpinned 的第三方 id,那一档属经验值【从业者判断】。

机理一句话:Claude Code 不认识第三方模型 id,解析不了就按一个保守的假定小窗口做 proactive auto-compaction(README / V0.5_PLAN §11 口径)。它不是坏了,它在保护你——只是保护的参数配错了对象。

钥匙其实早就存在:CLAUDE_CODE_MAX_CONTEXT_TOKENS。§11 记录的适用条件——对无法解析的 id 直接生效(claude code v2.1.193+);对它认识的 claude-* id 无效,除非叠加 DISABLE_COMPACT。以官方文档为准。

192 个测试全绿的 v0.5.0 对此一无所知,这不怪测试:机理藏在别人的客户端行为里,仓库里没有任何测试能表达它——v0.5 做 count_tokens 时就确认过这条边界(the repo cannot test client behavior,V0.5_PLAN §3.3)。发布前靠 spike 照亮过一次(17 次重试风暴,见第 81 篇),这次轮到真实使用。

为什么不打补丁

最省事的补丁人人都会:README 加一节,教用户每次启动前手动 export 一个 CLAUDE_CODE_MAX_CONTEXT_TOKENS=1000000。

为什么不做?因为这个方案把「模型的属性」错放成了「shell 的属性」。你换个终端、换台机器、换个模型,这个 env 就错位了——而窗口明明长在模型身上。

最讽刺的是,ccm 自己都不允许犯这个错:代理模式至今拒绝注入任何窗口,理由记录在 §11——运行时 ccm switch 会让启动时刻注入的窗口过期,launch-time 的窗口配不上 runtime 的模型。我凭什么把一个自己都不肯犯的错,包装成用户手册教出去?

所以根治的形态很自然:窗口成为模型声明的一等字段,直连启动时由 ccm 注入。当时的决定,计划记录的 greenlight 原话只有一句:「ccm 侧根治」。

落地:一个字段,连同它的全部边界

[models.glm]
provider = "zai"
model_id = "glm-5.3"
context_window = 1000000
# 等价的 CLI 写法
ccm add model glm --provider zai --model-id glm-5.3 --context-window 1000000

值的纪律:>= 1,0 在 parse/load/add 三层被拒;CLI 上限钉在 i64::MAX(TOML 整数天花板)——能 parse 却存不下的值在 flag 处就报错,不留到 config save 才炸。

主干一行,边界六条——缺了边界的修复会在别的模式里复发。以下出处均为 V0.5_PLAN §11 与 README:

  • 直连启动注入 CLAUDE_CODE_MAX_CONTEXT_TOKENS;代理永不注入(理由如上)
  • starter 配置 glm = 1M(z.ai 文档口径的能力事实);claude-* 不声明,Claude Code 原生认识它们的窗口
  • ccm discover 注册不带窗口——网关的 /v1/models 里没有这份数据,和 pricing 一样走「手填事实」政策,绝不猜
  • ccm doctor 三态诊断:已声明 → ✓;未声明的第三方 current → ! 提示;openai-compatible(仅代理可用,注入承诺不适用)→ 整行不出现
  • doctor 还会检出 Claude Code 自己 settings 里冲突的同名 env——但只在「已声明且可直连」时查,不越界报警
  • /_ccm/models 声明了才带字段,没声明 key 直接缺席(wire shape 有测试钉住)

对抗验证走了三轮。第一轮被中途杀掉——验证 agent 无视 review-only 指令改了工作树,两处未授权编辑回滚后对着全量 diff 审过才放行(完整教训在第 82 篇《让 Agent 审 Agent》),此后固化为全只读 Explore agent + git-diff 篡改封条。

抢救出的 findings 硬化五条边;第二轮(封条完整,专审这个特性)12 confirmed / 3 plausible / 5 refuted,再硬化六条——最硬的一条:doctor 的窗口诊断连缺 provider 的模型都会报警,可那种启动在注入发生前就中止,诊断注定扑空;现在 provider 查找排在窗口检查之前,这道闸收敛成有测试钉住的纯谓词。

第三轮是发布前的 docs 审计(7 条,见时间线)。

痛点二:--resume 传不进去

从一个问题到一行决定

10 月 11 日,我盯着 ccm run 问了个朴素问题:claude --resume <session-id> 做得到吗?做不到——ccm 把 claude 子进程包起来启动,参数通道压根没留。

V0.5_PLAN §11 记录的 greenlight 原话:「实现吧,然后注意修改文档」。

包一层进程的隐藏成本:宿主每多一个参数,封装层就欠一次门。

设计:把 -- 之后的世界原样交出去

ccm run [flags] [target] -- <claude args...>
# 例如续一个会话(<session-id> 换成你的会话 id):
ccm run glm -- --resume <session-id>

实现核心是 clap 的 #[arg(last = true)]:-- 之后的一切被原样捕获,追加到 claude 之后,直连与代理两种模式都生效。

三个细节。其一,-- 之后的内容永远不填 target 位置参数——clap 见到 -- 就跳到最后一个 positional,所以 ccm run -- --resume <id> 启动的是持久化默认目标,不会把 --resume 误当模型名。

其二,我最在意的一条回归钉:ccm run glm --proxy 必须仍是代理模式。尾部捕获一旦写贪,这条命令会被静默翻转成直连启动——测试专门钉住这个行为,防未来的重构悄悄吃掉它。

其三,clippy 强制把代理会话参数收拢成一个 Option<(&str, &str)>——两个值要么都在、要么都不在。这原本是两个 Option 之间的君子协定,现在成了编译期事实。

Windows 护栏:大声拒绝,好过猜一种转义

Windows 上 ccm 经 cmd /c 启动 claude,& | < > ^ " 和换行符就是现成的注入面(其他平台直接 exec 无 shell,没这个问题)。

策略不是转义,是拒绝:含元字符的透传参数在任何副作用之前点名报错、exit 1。计划记录的政策原话:"loud rejection beats guessing an escaping strategy"——拒绝得大声,好过猜一种转义策略。

两个细节,都是对抗验证逼出来的:

  • 「任何副作用之前」动真格:校验被提到 proxy pre-switch 之前——pre-switch 会持久化写 clients.toml,先切再报错就留下脏状态
  • 元字符集合最初漏了换行符:裸换行在 cmd 里就是命令分隔符,而标准库不会替它加引号

%PAIR% 变量展开是记录在案的 caveat,不拦——拦它就得猜 cmd 的展开语义,又绕回猜转义的老路。

验证抓到了什么

痛点二另起一轮对抗验证(全只读 agent + 封条完整)15 findings,去重后 7 confirmed,另 6+2 条被有据驳回。最值钱的一条:launcher 的 argv 测试在 Linux CI 上会 panic——get_args() 不含程序名,Windows 上碰巧无害。

修法不是放宽断言,是在 WSL2 上实跑,把两个平台各自的完整 argv 钉进测试。

又是「真实环境 × 交界处」:门禁全绿拦不住的,都藏在两块钢板的焊缝里。

E2E 冒烟(Windows,V0.5_PLAN §11 记录的三种结局):

-- --version 透传        → 打印 claude 版本,exit 0
含 cmd 元字符的参数       → 点名拒绝,exit 1
claude 参数忘在 -- 之前   → clap 分隔符提示,exit 2

评论区预答:三个一定会来的反驳

反驳一:出尔反尔? v0.5 计划白纸黑字写着不把 context window 持久化进 Model schema——这条 non-goal 确实在,V0.5_PLAN §4 的判据原文:"point-in-time facts go stale in config"——时点事实写进配置会过期。但它针对的是 probe 自动探测的数据;这次落地的是手填声明,用户对自己选的模型负责,与 pricing 表同一政策。

计划 §4 同时记下了推翻本身:"reversed post-release as a hand-entered declaration"。可以推翻,推翻必须留痕——这是「文档即契约」的另一半。

反驳二:一天一个版本,是不是 v0.5.0 没测好就发了?

恰恰相反,v0.5.1 一步门禁没省——时间线最后一行就是清单:docs 审计、双平台门禁、CI 双 runner 绿(run 38105354005)、checksums、重下验证(V0.5_PLAN §2)。两轮发布审计(v0.5.0 8 条 confirmed、去重 5 个缺陷;v0.5.1 7 条)缺陷全是文档——代码有测试兜底,文档只能靠审计。

至于「没测好」——这类交界处问题只有真实使用照得亮。发布不是测试的终点判决,是探测的起点。攒一个月发个大版本?那期间每一次 Compacting 都在提醒我痛点还活着。

反驳三:一行环境变量就能解决,何必这么重?

能解决一次启动,解决不了错位——窗口是模型的属性,不是 shell 的。完整论证见上文「为什么不打补丁」一节。

写进 Claude Code 自己的 settings.json?那份 env 是静态的,ccm switch 运行时换模型它就悄悄过期——这正是代理模式拒绝 launch-time 注入的同一条理由。注入必须跟着「当前直连的那个模型」走,所以它的家在模型声明里;doctor 会检出 settings 里的同名冲突,抓的就是这份静态 env 和声明打架的时刻。

方法论:疼了就根治的三要素

两次迭代共用一个骨架,拆开是三条:

  1. 定位机理,不停在症状。 频繁 Compacting 是症状;「客户端按假定小窗口管理陌生 id」才是机理。对着症状修(教用户调低期望、忍一忍)是安慰剂。
  2. 修在正确的层。 窗口是模型的属性 → 进模型声明;不进用户手册,不进 shell env,不进 FAQ。--resume 同理:正确的家是原样透传通道,不是一张越攒越长的参数对照表。判断口诀:这个知识应该住在哪个数据结构里?
  3. 边界与主干同落。 代理不注入、doctor 三态、discover 不猜数、claude-* 免声明——只修 happy path 的根治是半个根治,另外半个会在别的模式里复发。

打补丁和根治的差别,一张表说清:

打补丁根治
知识住在哪用户手册 / FAQ[models.<name>] 一等字段
出错谁发现用户搜 issueccm doctor 启动即诊
换模型 / 换终端env 错位声明跟着模型走
下一个用户重新踩一遍坑无感

ccm 的价值全在「我的配置 × 别人的客户端」这条缝上——缝上的行为要么根治、要么把边界写成声明,唯独不能糊。

现在就能做的事

在用 Claude Code 接第三方网关的读者,30 秒自检:

# 装了 ccm 的:先跑体检,看窗口诊断行
ccm doctor
# ✓ 已声明 / ! 第三方模型未声明 / 整行不出现(openai 兼容)

# 再验透传通道
ccm run glm -- --version
# 打印 claude 版本且 exit 0,通道就是通的

没用 ccm 的,记一件事就够:频繁 Compacting conversation 而网关明明是大窗口,先查 Claude Code 认不认识你的模型 id——CLAUDE_CODE_MAX_CONTEXT_TOKENS 对陌生 id 直接生效。

两个特性的完整落地记录在仓库 docs/V0.5_PLAN.md §11;ccm 本体在 github.com/Thneoly/ccm(MIT,206 tests,Windows + Linux 双平台验证)。《CCM 造记》第 83 篇,下一篇见。