上下文与 Token 管理
对话越长,塞给模型的东西越多,迟早撑爆"上下文窗口"。这一页贴 query.ts 里"每轮开头分级瘦身"的真实代码,看 Claude Code 怎么从轻到重地省空间。这是第 2 周(核心引擎)的收官——前 4 天讲"怎么干活",今天讲"干久了怎么不被自己的记录撑死",第 3 周转向扩展能力。
上下文窗口的约束
大模型每次请求能接受的 token 有上限,叫上下文窗口。Agent 干活时每一轮都要把"之前所有对话 + 工具结果 + 系统提示 + CLAUDE.md"全塞进去。干得越久这堆越大,早晚超窗口——超了 API 直接报错。
prompt is too long,任务当场断掉。压缩就是在撞上这堵墙之前,悄悄把桌上没用的纸清走。Claude Code 的策略是分级瘦身:先用最轻的手段(删过期内容),不够再用重的(大摘要)。全在 queryLoop 每轮开头做(query.ts:523 起,Day 06 讲的 while 循环体开头)。
分级瘦身阶梯(真实代码骨架)
循环层每轮调模型之前,按"从轻到重"顺序处理消息。看真实代码的骨架(query.ts:523 起,我把各步串起来):
// 每轮开头,从完整消息里派生"这次真正要发给模型的消息"
let messagesForQuery = getMessagesAfterCompactBoundary(messages) // ① 裁掉压缩边界前的历史
messagesForQuery = messagesForQuery.map(msg => { ... delete copy.toolUseResult ... }) // ② 释放旧的大工具结果对象
messagesForQuery = await applyToolResultBudget(messagesForQuery, ...) // ③ 每条工具结果超限就替换内容
if (feature('HISTORY_SNIP')) { ...snipCompactIfNeeded(messagesForQuery)... } // ④ 删僵尸/过期消息
const microcompactResult = await deps.microcompact(messagesForQuery, ...) // ⑤ 微压缩(L04)
messagesForQuery = microcompactResult.messages
// ⑥ autocompact 大摘要(L05)——最后手段
① 裁掉压缩边界前的历史(getMessagesAfterCompactBoundary)· ② 释放旧的大工具结果对象
③ tool-result 预算(applyToolResultBudget)· ④ snip 删僵尸消息
⑤ microcompact 微压缩——清空旧工具结果内容,不动结构
⑥ autocompact 大摘要——调 LLM 把历史压成摘要
一个隐藏细节:浅拷贝而非改原对象(真实注释)
第②步"释放旧工具结果"那段,源码有一段很长的注释解释"为什么要浅拷贝、而不是直接删原对象上的字段"(query.ts:531,真实注释节选):
// IMPORTANT: shallow-copy rather than mutate. messagesForQuery elements
// are references shared with mutableMessages (UI state); deleting
// toolUseResult in place strips it from the live message while React may
// still be rendering it. ... a mutation race makes tool-result rows render blank.
messagesForQuery = messagesForQuery.map(msg => {
if (msg.type !== 'user' || !('toolUseResult' in msg) || ...) return msg
const copy = { ...msg } // ← 浅拷贝一份
delete copy.toolUseResult // ← 在副本上删,不动原对象
return copy
})
messagesForQuery 里的消息对象,和 UI 正在渲染的消息(mutableMessages,Day 06)是同一批引用——指向内存里同一个对象。如果为了省内存直接 delete msg.toolUseResult,就会连 UI 正在渲染的那条也删了,导致工具结果那行渲染成空白(源码说的 "render blank" 竞态)。解决办法:{ ...msg } 复制一份、在副本上删——发给模型的用瘦身版,UI 用的原版不动。"给 API 的和给 UI 的分开",避免二者互相踩。这种细节正是真实工程和玩具项目的差距。{ ...obj } 复制出一个新对象(第一层字段复制一份)。因为多处代码"共享"同一个对象引用,直接改会牵连所有引用它的地方;复制一份再改就只影响副本。React 渲染时如果对象被别人偷偷改了,会出乱子——所以这里刻意复制。microcompact:清空旧工具结果内容
microcompactMessages(src/services/compact/microCompact.ts)是"较重但不调 LLM"的一档。它就地清空旧工具结果的内容,但保留消息结构——把内容换成占位:"[Old tool result content cleared]"。
tool_use 必须有对应的 tool_result(Day 03 讲过),不能直接删掉工具结果消息(会破坏配对、API 报错)。但那些很久以前的工具结果内容(比如 20 轮前读的一个文件)模型早就不需要了。于是把内容换成"[已清空]"、保留结构(哪条消息、tool_use_id 对应关系不变)——既省 token 又不破坏协议。这是"在协议约束下尽量瘦身"的巧妙折中。getMessagesAfterCompactBoundary(第①步)和这里的 microcompact 都靠 tool_use_id 来对应工具调用和结果——所以两者能"叠加不打架"(源码注释在 query.ts:558 明说 "the two compose cleanly")。autocompact:大摘要(最后手段)
轻手段都不够时,上 autoCompactIfNeeded(src/services/compact/autoCompact.ts)——调一次 LLM 把前面的历史压缩成一段摘要:
- fork 一次 LLM 调用生成摘要(
compact.ts的compactConversation)。 - 压缩产物 = 一个边界标记 + 摘要消息 + 保留的尾部消息 + attachments。
- 之后
messagesForQuery换成压缩后的,并 yield 摘要消息给 UI。
会话层(QueryEngine.ts,Day 06)收到 compact_boundary 后,会把边界之前的消息从 mutableMessages 里 splice 掉以便垃圾回收。这就是你有时看到的 "Compacting conversation…" + 一条摘要。你也能手动 /compact 触发(Day 11)。
getMessagesAfterCompactBoundary 就靠它——每轮只带边界之后的消息 + 那段摘要。相当于给对话打了个"存档点":之前的浓缩成一段摘要,之后的照常。fork 一次 LLM 指开一个独立的小请求专门做摘要,不影响主对话。阈值与预测式压缩
什么时候触发 autocompact?autoCompact.ts 算出几档水位线:
- 有效窗口 = 模型窗口 − 预留给摘要输出的空间。
- autocompact 阈值 = 有效窗口 − buffer(buffer 随窗口大小变:800k 窗口留 50k,普通留 13k)。
calculateTokenWarningState算出 warning / error / autocompact / blocking 各档。
预测式压缩(query.ts:852 附近)更聪明:即使当前没超,如果"当前 tokens + 本轮预计增长"会超窗口,就提前压缩——避免这一轮跑到一半才发现超了。
Stop hook 续命 & token 预算:何时才算真结束
Day 03 讲过——模型说"我完事了"(不再要工具),一轮本该结束(query.ts:1349 的 if (!needsFollowUp) 分支)。但在真结束前,还要过几道"协商":
① Stop hook 能"逼"模型继续(query.ts:1557 附近)
停止前跑 Stop hooks。如果 hook 说"还没达标"(返回 blockingErrors),循环就把错误插回消息、continue——相当于对模型说"不行,接着弄"。你可以配一个 Stop hook 检查"测试过了吗""TODO 做完了吗"(Day 14),不满意就不让它停。
② token 预算:鼓励用满(query.ts:1598 附近)
反方向的机制(feature('TOKEN_BUDGET')):如果还没用到预算的 90%(COMPLETION_THRESHOLD=0.9)且非"收益递减",就插一条 nudge 让模型继续;但若连续几轮几乎没产出(continuationCount >= 3 且增量 < 500 token),判为"收益递减"→ 停。
return {reason:'completed'}(query.ts:1647)。Stop hook 防"偷懒早停",token budget 的"收益递减检测"防"为凑预算空转"——一防太早停、一防空耗,两头都管住。这就是为什么 Claude Code 有时会"多做一步验证"或"觉得够了就收"——背后是这套精细的停止协商。👶 小白:模型说"我干完了",直接停不就好了?为啥还搞一堆"续命"和"预算"的判断?
👨🏫 老师:因为模型有两种"坏毛病"要治。一是偷懒早停——活没干利索就说完事了(测试还没跑、TODO 还剩着);Stop hook 就是"验收员",不达标把它推回去接着弄。二是反过来为凑预算空转——明明没啥可做了还硬挤没营养的步骤;"收益递减检测"发现连续几轮几乎没产出,就果断喊停。一个防太早停、一个防瞎空耗,两头都管住,"结束"才既不潦草也不啰嗦。
第 2 周收官 + 动手
🧠 第 2 周(核心引擎)自测
- 上下文窗口是什么约束?为什么长对话要压缩?
- 分级瘦身从轻到重有哪些级?为什么先轻后重?
- 第②步为什么"浅拷贝而非原地删"?(防 UI 渲染竞态)
- microcompact 为什么"清内容留结构"?靠什么对应工具调用/结果?(tool_use_id)
- autocompact 的边界标记起什么作用?为什么要预测式提前压?
- Stop hook 怎么"逼"继续?token budget 怎么防偷懒/空耗?
回顾第 2 周:Day 06 QueryEngine → Day 07 Tool 接口 → Day 08 内置工具 → Day 09 权限 → Day 10 上下文。这一周是全项目最"源码密集"的核心引擎,你已经对着真实代码走了一遍。
✋ 动手:对着真实代码读一遍
# 1. 分级瘦身阶梯(L02,注意那段浅拷贝注释)
sed -n '523,608p' src/query.ts
# 2. microcompact 清内容留结构
sed -n '30,60p' src/services/compact/microCompact.ts
# 3. autocompact 阈值
sed -n '33,120p' src/services/compact/autoCompact.ts
# 4. 停止协商:Stop hook + token budget
sed -n '1349,1360p' src/query.ts
sed -n '1557,1631p' src/query.ts
# 5. 实操:长对话里手动 /compact,观察摘要
bun run dev # 聊几轮后输入 /compact