第 八 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

拆解复刻 Claude Code 核心设计:四级压缩法干掉上下文膨胀

💡 四层:大结果落盘 → snip → micro → 模型摘要。先便宜,后昂贵。
管理信息生命周期:大结果放哪里、旧内容怎样降级、哪些消息绝不能拆开、压缩后如何追溯。canonical history 与 request history 分离是核心边界;压缩发生在两次模型调用之间,批量进行以保住 KV Cache 前缀。
本章进度
0%
1 本章要掌握的目标
  • 大工具结果按 UTF-8 bytes 落盘,历史只留路径和有界预览(严格大于阈值才落盘)。
  • 说清压缩发生的时间点:整轮结束先落盘,下一轮请求前再做 snip/micro/摘要,保住 KV Cache 前缀。
  • snip 按完整消息组裁掉中段;micro 把更早工具组结果换占位符,调用结构保留。
  • 前三层之后仍超 50,000 bytes 才调模型摘要:先写 transcript,再接受字段精确相等的 5 字段 JSON。
  • 相同历史直接复用缓存,纯追加历史复用已压缩前缀;canonical history 永不改写。
2 核心知识点
三种历史和一种产物

canonical history(会话事实,不被改写);request history(每次请求前的复制快照,可压缩);transcript(摘要前保存的 canonical 快照);tool-result artifact(大结果的完整内容)。

canonical history  会话事实,不会被请求压缩改写
request history   每次请求前复制,可被 snip/micro/摘要
        ↓
大结果在写入 canonical 之前先落盘为 artifact
canonical 里存 <persisted-tool-result> 路径与预览
消息原子组

一条 assistant 可含多个 tool_calls,它与全部 tool results 组成不可拆分原子组。snip 的保留边界、micro 的替换单位、摘要保留的尾部都用组。

assistant(tool_calls=[call_a, call_b])
tool(tool_call_id=call_a)
tool(tool_call_id=call_b)
→ 一个原子组,整组保留或整组省略
第一层:按 bytes 落盘

单项严格大于 30,000 bytes 才落盘(恰好等于仍保留在消息里);未落盘合计仍大于 200,000 时,按字节从大到小挑最大的落,够用就停。消息保留相对路径、original_bytes、头尾各最多 2,000 bytes 预览;预览按 byte 截断,装不下的多字节字符整字丢弃。

const contentBytes = Buffer.from(result.content, "utf8");
const sizeBytes = contentBytes.byteLength;
第二三层:snip 与 micro

snip:>50 组时保留前 3 组 + [Compacted] 标记 + 后 46 组。micro:保留最近 3 个工具交换组,更早结果替换为 [Earlier tool result compacted. Re-run if needed.]。注意 micro 对短结果反而可能让历史变长——它是为几 KB 以上的结果设计的,规则简单且结果确定。

snip:
  50 组阈值 → 前 3 组 + 省略标记 + 后 46 组
micro:
  保留最近 3 个工具交换组;更早结果替换为占位符
(不删 assistant 调用,不改 tool_call_id)
第四层:超过 50,000 bytes 才摘要

先保存 canonical transcript,再让 summarizer 读取 request history;摘要请求不带工具;只接受 stop、无 toolCalls、五字段精确 JSON。五字段:current_goal / key_findings / files_read_or_changed / remaining_work / user_constraints。

1. 保存 canonical 快照为 transcript
2. 让 summarizer 读 request history
3. 摘要请求不带任何工具
4. 只接受 finishReason==stop 且无 toolCalls
5. 输出精确 5 字段非空 JSON(多一个字段也拒)
   transcript 写失败 → 不调摘要模型
压缩时机与 KV Cache

压缩不在一次请求内部改上下文,而在两次模型调用之间:整轮工具跑完后整批结果落盘写入 canonical;下一轮请求前才复制快照做 snip/micro/摘要。KV Cache 按前缀命中,从改动点之后缓存失效、之前仍有效——所以批量压缩优于频繁压缩,摘要推迟到超过字节阈值之后。

第 N 轮结束: 整批 ToolResult → compactToolResults() 落盘
第 N+1 轮开始: 复制 canonical → prepare(): snip → micro → 超阈值才摘要
KV Cache: [System][工具定义][消息1..N] 前缀命中,改动点之后失效
相同历史不重复压缩

CompactionManager 在实例内缓存最近一次压缩前快照和压缩后请求历史:完全相同 → 直接返回上次结果(连对象引用都相同);纯追加 → 复用已压缩前缀只接新后缀;否则完全重新准备。缓存不跨进程、不持久化,canonical history 仍是唯一事实。省钱省在摘要输入上:重压缩时 summarizer 看「缓存前缀 + 新后缀」,transcript 仍写完整 canonical。

完全相同 → return cachedHistory
纯追加   → [...cachedHistory, ...新消息]
重压缩   → 摘要输入 = 缓存前缀 + 新后缀
           transcript = 完整 canonical
3 机制流程
1
大结果落盘

整轮 ToolResult 先按单项/批次预算落盘,再以引用写入 canonical(发生在写入 canonical 之前)。

2
复制 request history

下一轮请求前从 canonical 复制快照——压缩只发生在两次调用之间。

3
snip(完整消息组)

超过 50 组裁中段。

4
micro(完整工具组)

替换更早工具结果。

5
检查 UTF-8 bytes

超过 50_000 才写 transcript + 调摘要模型。

6
请求模型

用处理后的 request history。

4 术语表
canonical historyAgentRunner.#history 保存的会话事实;不被请求级压缩改写。
request history每次请求前从 canonical 复制、可被压缩的快照。
transcript摘要前保存的 canonical JSONL 快照(键排序稳定 JSON),可追溯但不含超大正文——那在 artifact 里。
tool-result artifact大工具结果完整内容落盘,临时文件+硬链接独占发布,ID 冲突绝不覆盖。
消息原子组assistant 工具调用 + 其全部 tool results 组成的不可拆分组。
Context Rot窗口没满但关键信息被无关内容稀释,模型「看得见却找不到」;加大窗口只解决「装不下」。
prompt-too-long供应商因输入过长拒绝请求;与输出截断(finishReason=length)是两件事,P08 Loop 尚未自动接这个信号。
5 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. 压缩发生在哪份历史上?
Q2. 大工具结果落盘的阈值与预算大约是?
Q3. 为什么按『消息原子组』裁剪而不是单条消息?
Q4. 摘要模型请求的完成条件是什么?
Q5. P08 有没有把 compact 暴露成工具让模型调用?
Q6. finishReason === length 是否等于输入 prompt-too-long?
6 验证与实验
  • npm run test:ch08:21 个测试文件 / 174 个用例全部通过(本章新增 31 个)。
  • 验证 bytes 落盘、原子组裁剪、micro 占位、五字段精确摘要、canonical 不变、缓存复用。
  • npm run ch08 -- --prompt "读取 large-demo.txt 并告诉我它的内容特征",再到 .agent_tutorial\artifacts\ 确认完整正文逐字节一致。
  • 五个离线小实验(scratch 探针)可复现落盘、预览丢字、批次挑选、snip/micro 切组、缓存省调用;artifacts 目录无自动清理,需手动删。