第 8 章:上下文工程 —— 让有限窗口装下无限对话
LLM 的上下文窗口是固定的,但 coding-agent 的对话会无限增长——跑一次 npm install 的 stderr 可能十几 KB,read 一个 5000 行源文件可能 80KB,几十轮对话轻松破 100K token。窗口是硬上限,超了直接报错,对话中断。
上下文工程(Context Engineering) 就是应对这个问题的工程纪律:在内容送入 LLM 之前,多层裁剪、过滤、压缩、组织,让有限窗口装下"对当前任务最有价值的信息"。Pi 在两个环节布置了 4 种互补的技巧,本章挨个看。
地图:两层防护
┌── 输入侧(送进 LLM 之前)─────────────────────┐
│ ① 工具输出截断 — bash/read/grep 结果按行/字节裁剪 │ ← 每次工具调用
│ ② 系统提示词组装 — 多层 CLAUDE.md 递归 + Skills 懒加载 │ ← 每轮 prompt
└──────────────────────────────────────────────┘
┌── 历史侧(长对话管理)────────────────────────┐
│ ③ Compaction — 阈值触发,旧消息变结构化摘要 │ ← 阈值触发
│ ④ 分支摘要 — 切换会话树分支时给被放弃分支做摘要 │ ← 用户切分支时
└──────────────────────────────────────────────┘
输入侧 ①:工具输出截断
双重限制:行数 + 字节,先触者胜
最朴素的"按字符数截断"会立刻撞三个问题:截断位置不对(bash 报错在末尾、文件读取开头更重要)、切断多字节字符(一个 emoji 切成无效码元)、单行就超限(grep 命中一行 100KB 压缩 JS)。Pi 的解法在 truncate.ts:
- 行数上限:
DEFAULT_MAX_LINES = 2000 - 字节上限:
DEFAULT_MAX_BYTES = 50 * 1024(50KB) - grep 单行上限:
GREP_MAX_LINE_LENGTH = 500(按字符计,不是字节)
任何输出按"最多 2000 行"或"最多 50KB"裁剪,哪个先触发用哪个。为什么要双限制?只限行数,单行可能极长,3 行就撑爆字节;只限字节,可能把第 100 行切一半破坏结构。行数管"可读性",字节管"硬体积",互相兜底。
两种策略:truncateHead vs truncateTail
同样的双限制,从哪头裁是另一个问题,两个函数核心差异只是遍历方向:
| 函数 | 保留哪段 | 用在哪 | 为什么 |
|---|---|---|---|
truncateHead |
开头 | read 文件 | 文件头部是 import / 类定义 / 接口签名,信息密度最高 |
truncateTail |
末尾 | bash 输出 | 错误堆栈、最终结果都在末尾,末尾最有信号 |
// 伪代码:truncateTail 的核心思路(从末尾往回选保留行)
for (let i = lines.length - 1; i >= 0; i--) {
const lineBytes = byteLength(lines[i]) + 1; // +1 是换行符
if (kept.length >= maxLines) break; // 行数到了,停
if (bytes + lineBytes > maxBytes) break; // 字节到了,停
kept.unshift(lines[i]); // 插到头部保持原顺序
bytes += lineBytes;
}
三个边界细节
UTF-8 边界安全:字节级截断最阴险的 bug 是切坏多字节字符。truncateStringToBytesFromEnd 逐字符累加字节数,把 4 字节 emoji(代理对)当不可分割整体——要么完整保留要么完全不要。
损坏输入的兜底:先说明一点——replaceUnpairedSurrogates 这个函数只存在于 agent 包的 truncate.ts:82,coding-agent 包的同名文件简化了实现(直接用 Buffer.byteLength + slice),并没有这个函数。它要处理的机制是这样:输入本身可能就损坏(含未配对的代理码元),函数用 `` 替换掉这些残缺码元,避免后续编码直接炸掉。字节边界上的细活,不显眼但必要。
单行就超限:如果最长那行单独就超过 maxBytes,不能返回空——truncateTail 取该行的末尾 maxBytes 字节并设 lastLinePartial: true,bash 工具渲染专门提示:[Showing last 49.5KB of line 1 (line is 92.3KB). Full output: /tmp/pi-bash-xxx.log]。
截断后的提示:让 LLM 知道发生了什么
截断是有损的,但 Pi 不偷偷干。TruncationResult 记录完整元信息(被什么限制触发、原始/输出行数字节数),bash 工具据此在输出末尾追加一行——这行字也进 LLM 上下文:
[Showing lines 6501-8500 of 8500. Full output: /tmp/pi-bash-xxx.log]
这是截断机制的"逃生通道":默认截断省 token,需要时 LLM 自己用 read 拉完整内容。
输入侧 ②:系统提示词动态组装
截断是"减法",上下文工程还有"加法"问题:怎么让 LLM 自动知道项目约定(这个子项目用 pnpm 不用 npm、测试用 vitest),而不让用户每次手动说?
多级 CLAUDE.md:从当前目录向上递归
Pi 从 cwd 向上递归到根目录,把沿途所有目录的 AGENTS.md / CLAUDE.md 全部合并(resource-loader.ts:85-123)。完整顺序:
1. agentDir/CLAUDE.md ← 全局(用户级 ~/.pi/)
2. 祖先目录/CLAUDE.md ← 从 / 到 cwd 上一层(最通用在前)
3. cwd/CLAUDE.md ← 当前项目(最具体在后)
为什么向上递归?monorepo 里组织规范、团队规范、项目规范层层嵌套——按"从外到内"合并,LLM 像读分层手册,先读总则再读细则。找到的文件用 XML 包装(buildSystemPrompt):
<project_context>
<project_instructions path="/myorg/CLAUDE.md">全组织规范...</project_instructions>
<project_instructions path="/myorg/teams/teamA/app1/CLAUDE.md">本项目用 pnpm...</project_instructions>
</project_context>
为什么用 XML 而不是 Markdown?边界明确(</project_instructions> 是清晰结束标记)、带 path 属性(LLM 能区分组织级和项目级规范的优先级)。
Skills 懒加载:列表进 prompt,内容按需读
每个 skill 的 SKILL.md 可能几千字,全文塞进系统提示词开销巨大且大部分用不上。Pi 的方案(skills.ts:335-361)是只放轻量清单,全文按需 read:
<available_skills>
<skill>
<name>test-setup</name>
<description>How to run tests for this project</description>
<location>/path/to/skills/test-setup/SKILL.md</location>
</skill>
</available_skills>
清单顶上有一句指令:"Use the read tool to load a skill's file when the task matches its description"——这就是懒加载契约。对比:全文塞 10 个 skill 约 50K token,懒加载清单只要约 500 token,用时才付 token,不用不付。
系统提示词完整骨架:角色定位 → 工具列表 → 通用 guidelines → Pi 文档路径 → <project_context> → <available_skills> → 末尾才是 Current date 和 cwd(处理相对时间和相对路径的基本元数据)。
历史侧 ③④:Compaction 与分支摘要
Compaction 是核心压缩算法——阈值触发,把旧消息变成结构化摘要腾空间。它足够重要也足够复杂,第 9 章独立详讲(触发条件、findCutPoint 切割算法、6 section 摘要模板、增量更新、文件跟踪、极端情况)。这里只记住一个关键事实:Compaction 生成的 CompactionSummaryMessage 会出现在后续对话的 context.messages 里作为新上下文。
分支摘要(Branch Summarization) 解决另一个问题:Pi 的会话是树状结构(第 10 章详讲),用户可以从历史节点分叉。切换分支后 LLM 只看到新路径——旧分支上"试过方案 A 但行不通"的探索成果就丢了。Pi 的解法(branch-summarization.ts):
- LCA 算法找分叉点——在新路径上从后往前找第一个也在旧路径里的节点,从旧叶子向上爬到 LCA,沿途收集的就是"被放弃的分支";
- 复用 Compaction 底层管道(convertToLlm、serializeConversation、共享系统提示词),但 prompt 不同:只有 5 section(无 Critical Context),前言是"The user explored a different conversation branch before returning here"(让 LLM 知道这是参考而非主线),maxTokens 固定 2048(分支摘要只是辅助,不能喧宾夺主);
- 摘要存为
BranchSummaryMessage,出现在新分支上下文开头——LLM 立刻知道"触发器方案因性能放弃了",不会再次走进同一条死胡同。
| 维度 | Compaction | Branch Summarization |
|---|---|---|
| 触发 | 阈值(tokens > window - reserve) | 用户切换会话树分支 |
| 目的 | 防窗口溢出 | 保留被放弃分支的探索成果 |
| 切割 | findCutPoint(向后累积) | LCA 算法(找分叉点) |
| maxTokens | min(0.8×reserve, model.maxTokens) | 2048(固定) |
| 前言语义 | "history compacted" | "explored a different branch" |
两者互补——一个处理"线性对话的长度问题",一个处理"树状对话的分支遗忘问题"。
全景链路
一次完整调用经过的关卡:系统提示词组装(加法)→ 消息进 context → Agent Loop → 执行工具 → 工具输出截断(减法)→ toolResult 进历史 → agent_end → shouldCompact 判定(是则 Compaction);用户切分支则触发 Branch Summarization。四层各管一段:单条结果体积、提示词内容、长对话总长、多分支信息保留,组合成一道漏斗,最后送进 LLM 的才是"对当前任务最有价值的信息"。
设计精华
- 多层防护,没有银弹:每个机制只解决自己擅长的问题,互不替代——Compaction 调得再激进,单条 80KB 的 read 结果没有截断连一轮都撑不过。承认每个机制的能力边界,组合使用。
- 加法 + 减法的双向操作:上下文工程不是单纯"压缩"而是"塑形"——同等 token,结构化信息密度更高(Compaction 6 section 模板、Branch Summary 5 section、系统提示词 XML 标签,都是用一点 prompt 工程撬动理解的巨大提升)。
- 工具调用 = 按需上下文加载:Skills 懒加载揭示了"拉模式"——系统只给清单,LLM 主动用工具拉取需要的内容。当 LLM 有工具能力时,工具本身就是上下文工程的载体,不必把所有可能用到的信息预付进 prompt。
下一站
本章看了上下文工程全景,其中 Compaction 是整条防线里最核心、最复杂的一环:什么时候触发?从哪里切开旧消息?摘要怎么保证不丢关键信息?压缩多次之后怎么增量更新?下一章——上下文压缩——逐段拆开这个算法。
本章关键源码索引:
coding-agent/src/core/tools/truncate.ts(截断算法)、system-prompt.ts(buildSystemPrompt)、resource-loader.ts:85-123(CLAUDE.md 递归)、skills.ts:335-361(Skills 懒加载)、agent/src/harness/compaction/branch-summarization.ts(分支摘要)。本文改编自 CC-BY-SA-4.0 许可的开源教程。