Day 10 / 共 20 天 · 第 2 周收官 · 加深版

上下文与 Token 管理

对话越长,塞给模型的东西越多,迟早撑爆"上下文窗口"。这一页贴 query.ts 里"每轮开头分级瘦身"的真实代码,看 Claude Code 怎么从轻到重地省空间。这是第 2 周(核心引擎)的收官——前 4 天讲"怎么干活",今天讲"干久了怎么不被自己的记录撑死",第 3 周转向扩展能力。

📍 你在整门课的位置 · 第 2 周 核心引擎(收官)
D06 QueryEngine D07 工具接口 D08 内置工具 D09 权限 D10 上下文/Token· W3 扩展能力 →
💡 用一个类比先兜住今天(本日世界观:一张放不下的"办公桌面") 上下文窗口就是模型的办公桌面——面积固定(比如 20 万 token),每轮干活都要把"所有历史 + 工具结果 + 系统提示"全摊在桌上给它看。桌面迟早堆满。Claude Code 的清理策略是从轻到重,跟你收拾桌面一模一样:① 先扔废纸(删过期/僵尸消息,0 成本);② 再抽走旧文件夹里的纸、只留个空标签(microcompact:清内容留结构);③ 实在满了,才请秘书把一摞资料浓缩成一页纪要(autocompact:调 LLM 做摘要,花钱花时间)。记住"先扔废纸、再抽旧纸、最后才请人写纪要",今天全通。
L01

上下文窗口的约束

大模型每次请求能接受的 token 有上限,叫上下文窗口。Agent 干活时每一轮都要把"之前所有对话 + 工具结果 + 系统提示 + CLAUDE.md"全塞进去。干得越久这堆越大,早晚超窗口——超了 API 直接报错。

token 是什么? 模型处理文本的最小单位,大致 1 token ≈ 0.75 个英文单词 / 1-2 个汉字。"上下文窗口 20 万 token"意思是这次请求(历史 + 新内容)加起来最多 20 万 token。所以长对话必须主动"瘦身"——这就是 compaction(压缩)。
📝 举个例子:桌面怎么一步步堆满 你让 Agent 重构一个模块:读了 8 个文件(每个约 4000 token)+ 跑了几次测试(日志各上万 token)+ 十几轮来回对话……轻松逼近十几万 token。到某一轮,"历史 + 这轮要发的" > 窗口上限 → API 直接报 prompt is too long,任务当场断掉。压缩就是在撞上这堵墙之前,悄悄把桌上没用的纸清走。

Claude Code 的策略是分级瘦身:先用最轻的手段(删过期内容),不够再用重的(大摘要)。全在 queryLoop 每轮开头做(query.ts:523 起,Day 06 讲的 while 循环体开头)。

L02

分级瘦身阶梯(真实代码骨架)

循环层每轮调模型之前,按"从轻到重"顺序处理消息。看真实代码的骨架(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 把历史压成摘要

桌面快满了 → 从便宜到贵,逐级清理 桌面(窗口) 堆满📄 ① 扔废纸删过期·0成本 ② 控预算超限截断 ③ 抽旧纸留标签microcompact·不调LLM ④ 请人写纪要autocompact·调LLM·贵 能用左边便宜手段解决,就绝不动右边那个要花钱的"写纪要"
图注:清理手段从左(便宜、0 成本)到右(贵、调 LLM)逐级升级;只有前面都不够,才动用最右边的大摘要。
为什么分这么多级、从轻到重? 大摘要(autocompact)要额外调一次 LLM——花钱、花时间、还可能丢信息。所以能用便宜手段(删过期、清空旧工具结果)解决就绝不上大摘要。这跟 gov-agents 教程"能用代码就别用 LLM"是同一成本哲学:先用确定性的廉价手段,实在不行才动用昂贵的 LLM。
L03

一个隐藏细节:浅拷贝而非改原对象(真实注释)

第②步"释放旧工具结果"那段,源码有一段很长的注释解释"为什么要浅拷贝、而不是直接删原对象上的字段"(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 的分开",避免二者互相踩。这种细节正是真实工程和玩具项目的差距。
浅拷贝、mutate 是什么? mutate(原地改) = 直接改一个对象的字段。浅拷贝 = { ...obj } 复制出一个新对象(第一层字段复制一份)。因为多处代码"共享"同一个对象引用,直接改会牵连所有引用它的地方;复制一份再改就只影响副本。React 渲染时如果对象被别人偷偷改了,会出乱子——所以这里刻意复制。
L04

microcompact:清空旧工具结果内容

microcompactMessagessrc/services/compact/microCompact.ts)是"较重但不调 LLM"的一档。它就地清空旧工具结果的内容,但保留消息结构——把内容换成占位:"[Old tool result content cleared]"

为什么"清内容留结构"? 因为 Anthropic 协议要求每个 tool_use 必须有对应的 tool_result(Day 03 讲过),不能直接删掉工具结果消息(会破坏配对、API 报错)。但那些很久以前的工具结果内容(比如 20 轮前读的一个文件)模型早就不需要了。于是把内容换成"[已清空]"、保留结构(哪条消息、tool_use_id 对应关系不变)——既省 token 又不破坏协议。这是"在协议约束下尽量瘦身"的巧妙折中。
Day 02 讲的 getMessagesAfterCompactBoundary(第①步)和这里的 microcompact 都靠 tool_use_id 来对应工具调用和结果——所以两者能"叠加不打架"(源码注释在 query.ts:558 明说 "the two compose cleanly")。
L05

autocompact:大摘要(最后手段)

轻手段都不够时,上 autoCompactIfNeededsrc/services/compact/autoCompact.ts)——调一次 LLM 把前面的历史压缩成一段摘要

  • fork 一次 LLM 调用生成摘要(compact.tscompactConversation)。
  • 压缩产物 = 一个边界标记 + 摘要消息 + 保留的尾部消息 + attachments。
  • 之后 messagesForQuery 换成压缩后的,并 yield 摘要消息给 UI。

会话层(QueryEngine.ts,Day 06)收到 compact_boundary 后,会把边界之前的消息从 mutableMessages 里 splice 掉以便垃圾回收。这就是你有时看到的 "Compacting conversation…" + 一条摘要。你也能手动 /compact 触发(Day 11)。

"边界标记"是什么? 一个特殊消息,标记"从这里往前的历史已被摘要替代"。第①步的 getMessagesAfterCompactBoundary 就靠它——每轮只带边界之后的消息 + 那段摘要。相当于给对话打了个"存档点":之前的浓缩成一段摘要,之后的照常。fork 一次 LLM 指开一个独立的小请求专门做摘要,不影响主对话。
L06

阈值与预测式压缩

什么时候触发 autocompact?autoCompact.ts 算出几档水位线:

autocompacterror
0有效窗口 = 模型窗口 − 预留摘要输出
  • 有效窗口 = 模型窗口 − 预留给摘要输出的空间。
  • autocompact 阈值 = 有效窗口 − buffer(buffer 随窗口大小变:800k 窗口留 50k,普通留 13k)。
  • calculateTokenWarningState 算出 warning / error / autocompact / blocking 各档。

预测式压缩query.ts:852 附近)更聪明:即使当前没超,如果"当前 tokens + 本轮预计增长"会超窗口,就提前压缩——避免这一轮跑到一半才发现超了。

为什么要预留摘要空间、要 buffer、还要预测? 因为压缩本身也要生成 token(摘要文本),下一轮模型回复也要占空间。如果卡到窗口边缘才压缩,可能"连生成摘要的空间都没有了"。留 buffer + 预测式提前压,是给系统留腾挪余地。不要等撞墙才刹车,要预判着减速——资源管理的通用智慧。
L07

Stop hook 续命 & token 预算:何时才算真结束

Day 03 讲过——模型说"我完事了"(不再要工具),一轮本该结束(query.ts:1349if (!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),判为"收益递减"→ 停。

"结束"不是一个简单判断,而是一串协商。 一轮结束前要依次过:prompt-too-long 恢复 → max-tokens 恢复 → Stop hooks(可能逼继续)→ token budget(可能鼓励继续 / 判递减而停),全通过才真 return {reason:'completed'}query.ts:1647)。Stop hook 防"偷懒早停",token budget 的"收益递减检测"防"为凑预算空转"——一防太早停、一防空耗,两头都管住。这就是为什么 Claude Code 有时会"多做一步验证"或"觉得够了就收"——背后是这套精细的停止协商。

👶 小白:模型说"我干完了",直接停不就好了?为啥还搞一堆"续命"和"预算"的判断?

👨‍🏫 老师:因为模型有两种"坏毛病"要治。一是偷懒早停——活没干利索就说完事了(测试还没跑、TODO 还剩着);Stop hook 就是"验收员",不达标把它推回去接着弄。二是反过来为凑预算空转——明明没啥可做了还硬挤没营养的步骤;"收益递减检测"发现连续几轮几乎没产出,就果断喊停。一个防太早停、一个防瞎空耗,两头都管住,"结束"才既不潦草也不啰嗦。

L08

第 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
第 3 周预告 · Day 11:核心引擎讲完了!第 3 周转向"扩展能力"(Slash 命令 / MCP / Skills / Hooks / 子代理)。这些页目前是较概念的版本;如果你也想让它们像本周一样"贴真实代码加深",随时告诉我。

← Day 09 权限系统 Day 11 · Slash 命令系统 →