第 6 章讲消息系统时我们说过:coding-agent v0.80.2 声明 7 种 AgentMessage,调用 LLM 之前会经过 convertToLlm 边界,归一化为 3 种 Message。一次 Agent 运行完成后,AgentSession 还会在后处理阶段检查是否需要压缩;这不是 agent_end 监听器本身完成的。
这两件事背后其实藏着同一个核心问题——单个模型的上下文窗口有明确上限,但 coding-agent 的会话历史可以继续增长并超过它。
这一章我们就打开 Pi 的”上下文工程”全貌。你会看到:上下文压缩(你或许已经在 第9章 看过)只是冰山一角。Pi 实际上在 输入、历史 两个环节都布置了防线,每一层都对应一种具体的工程问题。
校对口径:本章对应 Pi v0.80.2。工具截断实现见
packages/coding-agent/src/core/tools,产品实际使用的压缩与分支摘要见packages/coding-agent/src/core/compaction。2000 行 / 50KB 是 read 与 bash 的默认限制,不是所有工具共享的无条件总闸门。
一、问题:窗口是固定的,对话是增长的
把一个 coding-agent 一次会话的所有”信息源”列出来,你会意识到问题的严重性:
一次会话送进 LLM 的内容
├── 系统提示词(工具说明、guidelines、pi 文档路径)
├── 项目上下文文件(CLAUDE.md / AGENTS.md,可能多层嵌套)
├── Skills 列表(每个 skill 一段描述)
├── 工具定义(每个工具的 JSON schema)
├── 对话历史(每一轮 user / assistant / toolResult)
│ ├── 用户输入
│ ├── LLM 回复(含 thinking、toolCall)
│ └── 工具结果(read 文件、bash 输出、grep 命中……)
└── 当前轮的新输入
随便挑一项都可能爆炸:
- 跑一次
npm install的 stderr 可能十几 KB read一个 5000 行的源文件,可能 80KBgrep全仓库的关键词,命中几百行- 多轮工具调用累积下来,几十轮对话轻松破 100K token
模型上下文窗口是请求上限;超过后如何表现取决于 Provider,可能直接报错,也可能以 length、空输出或用量异常等形式暴露。Pi 因而既做阈值预防,也保留溢出恢复路径。
上下文工程(Context Engineering) 就是应对这个问题的一组工程实践:在内容送入 LLM 之前,多层裁剪、过滤、压缩、组织,让有限的窗口装下”对当前任务最有价值的信息”。
Pi 在这个环节实现了 4 种互补的技巧。这一章我们就挨个看。
二、地图:两层防护
在钻进每个技巧之前,先建立一张总图。Pi 的上下文工程分布在两个环节:
输入侧(送进 LLM 之前)
├─ ① 工具输出截断
│ read / bash / grep / find / ls 各按契约限制结果
└─ ② 系统提示词组装
多层 AGENTS.md / CLAUDE.md 候选 + Skills 索引
│
▼
历史侧(长对话管理)
├─ ③ Compaction
│ 阈值触发,把旧消息变成结构化摘要
└─ ④ 分支摘要
树导航时可选,把离开路径的摘要带到新位置
| 层 | 解决的问题 | 触发频率 |
|---|---|---|
| ① 工具输出截断 | 单次文本结果太大 | 相关大输出工具执行时 |
| ② 系统提示词组装 | 项目规范要进上下文,又不能让用户每次说 | 会话创建或资源/配置重建时 |
| ③ Compaction | 长对话累积超窗口 | 阈值触发 |
| ④ 分支摘要 | 树导航后还想保留离开路径的信息 | 用户选择“Summarize”时 |
接下来按这个顺序展开。
三、输入侧 ①:工具输出截断(truncateHead / truncateTail)
问题:一条 bash 命令就能撑爆窗口
想象你让 Agent 跑 npm test,输出 8000 行日志;或者让它 read 一个 3000 行的源文件。单次工具调用就可能产生几十 KB 的输出。如果不加控制,几轮下来上下文窗口就被工具结果塞满了。
最朴素的解法是”按字符数截断”。但这会立刻撞到三个新问题:
- 截断的位置不对——bash 报错通常在末尾,截尾才是有用的;文件读取通常开头更重要,截头才对
- 切断多字节字符——直接按字节切,会把一个 emoji 切成两个无效码元
- 单行就超限——比如
grep命中一行 100KB 的压缩 JS,怎么切?
Pi 用一套双重限制 + 边界安全的公共算法解决这些问题,实现见 truncate.ts;具体工具再选择方向和额外限制。
双重限制:行数 + 字节,先触者胜
公共截断模块定义了两个默认上限(源码 truncate.ts):
- 行数上限:
DEFAULT_MAX_LINES = 2000 - 字节上限:
DEFAULT_MAX_BYTES = 50 * 1024(50KB) - grep 单行长度上限:
GREP_MAX_LINE_LENGTH = 500
read 与 bash 使用“最多 2000 行或 50KB,先触者为准”。grep / find / ls 使用 50KB 再叠加各自的 match/result/entry 数量上限;write / edit 返回短确认信息,不经过这套大文本截断。
为什么要双限制?因为单一限制各有失败模式:
- 只限行数:单行可能极长(压缩 JS、minified CSS),3 行就撑爆字节
- 只限字节:一个 50KB 的源文件可能只有 200 行,但你想看完整结构,按字节切可能把第 100 行切一半
双限制互相兜底——行数管”展示可读性”,字节管”硬性体积”。
两种策略:truncateHead vs truncateTail
同样的双重限制,从哪头裁是另一个问题。Pi 提供两个函数:在“逐行收集完整行”的常规路径上,核心差异是遍历方向;首行或末行本身超过字节上限时,两者还有不同的兜底行为:
配图说明:上半双重限制(2000 行 + 50KB 谁先触发谁赢)。下半左右对照——truncateHead 从前往后保留开头(绿色实线 = 留下,灰色虚线 = 裁掉),用在 read 文件(import / 接口最密);truncateTail 从后往前保留末尾,用在 bash 输出(错误堆栈最有信号)。恢复方式并不共用:bash 给出完整输出的临时文件,read 提示下一次读取的 offset。
| 函数 | 保留哪一段 | 用在哪 | 为什么 |
|---|---|---|---|
truncateHead | 开头 | read,以及 grep/find/ls 的结果列表 | 保留自然顺序的前段,并提示还有内容未展示 |
truncateTail | 末尾 | bash 输出 | 命令终态和错误信息通常出现在末尾 |
bash 工具的描述在源码里写得很清楚(bash.ts:284):
Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file.
“last” 这个词很关键——bash 工具的契约就是”保留末尾”。truncateTail 的核心逻辑是从末尾往回选保留行(packages/coding-agent/src/core/tools/truncate.ts:168-242),简化后是这样:
// 伪代码:truncateTail 的核心思路
function truncateTail(content, maxLines, maxBytes) {
const lines = content.split("\n");
const kept = []; // 从末尾往回收集的行
let bytes = 0;
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;
}
return kept.join("\n");
}
truncateHead 的完整行收集循环与之镜像:从前往后遍历,并用 push 保持顺序。但两者并非在所有边界上都完全对称:如果第一行本身就超过 maxBytes,truncateHead 返回空内容并设置 firstLineExceedsLimit: true;如果最后一行本身超过上限,truncateTail 会返回这一行的 UTF-8 安全末尾片段。
边界安全:UTF-8 多字节字符
字节级截断最阴险的 bug 是切坏多字节字符。一个 emoji 😀 在 UTF-8 里是 4 个字节,如果你在第 2 个字节切一刀,剩下两个字节会变成无效字符 �。
coding-agent 的 truncateStringToBytesFromEnd(packages/coding-agent/src/core/tools/truncate.ts:247-265)先把字符串编码成 UTF-8 Buffer,算出候选起点后跳过形如 10xxxxxx 的续字节,直到字符边界再切片。这样不会从一个多字节字符的中间开始返回结果。
单行就超限:部分行兜底
truncateTail 还有一个边角逻辑(packages/coding-agent/src/core/tools/truncate.ts:201-215):如果从末尾遇到的最后一行本身就超过 maxBytes,它会取这一行的末尾 maxBytes 字节,并设置 lastLinePartial: true 标志位。truncateHead 没有对应的部分行返回:首行超限时会返回空内容,并通过 firstLineExceedsLimit: true 让 read 工具提示改用 bash 对该行做裁剪。
下游的 bash 工具会渲染专门的提示(bash.ts:366-368):
[Showing last 49.5KB of line 1 (line is 92.3KB). Full output: /tmp/pi-bash.log]
这样 LLM 至少看到这行的末尾,并且知道完整输出存在哪个文件里。后续若要检查这类超长单行,应按 read 提示改用 bash,例如用 sed 定位该行,再配合 cut、head -c、tail -c 等命令分片;read 自己仍会因为这一行超过 50KB 而拒绝返回它。
单行限长:grep 的 500 字符规则
grep 工具还有一个独立的截断:truncateLine(packages/coding-agent/src/core/tools/truncate.ts:268),默认 GREP_MAX_LINE_LENGTH = 500。grep 经常命中压缩文件、minified 代码——一行几万字符。这个函数把超长行截到 500 个 JavaScript 字符单元并加 ... [truncated] 后缀,避免一行就吃掉过多上下文。
截断后的提示:让 LLM 知道发生了什么
截断本身是有损的,但 Pi 不会偷偷干。TruncationResult 结构(truncate.ts:15-38)记录了完整的元信息——是否截断(truncated)、被什么限制触发(truncatedBy: "lines" | "bytes" | null)、原始行数/字节数(totalLines / totalBytes)、输出行数/字节数(outputLines / outputBytes)、最后一行是否部分截断(lastLinePartial)等等。
bash 工具据此在输出末尾追加一行提示(bash.ts:362-374):
[Showing lines 6501-8500 of 8500. Full output: /tmp/pi-bash-xxx.log]
这行字也会进 LLM 上下文。bash 超限时会给出完整输出的临时文件;read 则提示继续使用 offset/limit;grep/find/ls 报告命中或体积上限。不同工具的“逃生通道”并不相同。
流式输出补充:bash 命令是流式输出的。
OutputAccumulator负责有界累积,并在需要保存完整输出时写入临时文件;展示快照最终调用truncateTail。
小结:公共截断原语提供方向、行/字节边界和 UTF-8 处理;每个大输出工具再定义自己的数量上限与恢复方式。不要把它误读成 Agent Loop 对所有 ToolResult 的统一裁刀。
四、输入侧 ②:系统提示词动态组装
问题:项目规范要进上下文,但不能让用户每次说
工具输出截断是“减法”——把太大的东西变小。但上下文工程还有“加法”问题:怎么把项目约定装入当前模型请求?
比如用户在 monorepo 里开发,希望 LLM 知道:“这个子项目用 pnpm 不用 npm”、“测试用 vitest”。如果每次对话都要手动说一遍,体验极差。
Pi 的方案在 system-prompt.ts 和 resource-loader.ts 里——核心是两件事:多级项目上下文候选 + Skills 轻索引、正文按需读取。
多级上下文文件:从当前目录向上递归
Pi 在每个目录下按 AGENTS.md、AGENTS.MD、CLAUDE.md、CLAUDE.MD 的顺序寻找,读取到第一份可读候选后停止检查该目录的其他候选;如果较早候选存在但读取失败,代码会记录警告并继续尝试后面的文件。然后它从 cwd 向上递归到根目录,把沿途每个命中目录的一份规范文件合并。
配图说明:左侧 monorepo 目录树,红色虚线箭头从 src/(cwd)向上爬到根,沿途每个目录最多收集一份上下文文件。右侧合并顺序——① 全局(默认 ~/.pi/agent/)→ ② 祖先到 cwd(按从外到内的目录顺序,只列实际命中的文件)。最后拼接的命中文件离 cwd 最近,但代码没有字段级“覆盖”算法。每份内容最终用 XML <project_instructions path="..."> 包装送进 system prompt。底部对照推模式与拉模式:全文固定进提示词,或只放 name / description / location 后按需 read。
为什么向上递归?因为现代项目常常是 monorepo 嵌套结构:
/myorg
├── CLAUDE.md ← 全组织规范(通用)
└── teams
└── teamA
└── projects
└── app1
├── CLAUDE.md ← 项目规范(最具体)
└── src/ ← cwd 在这里
在 src/ 目录启动 Agent,会向上找到 2 个 CLAUDE.md,按”从外到内”的顺序合并——祖先目录的规范在前面(最通用),离 cwd 更近的规范在后面(最具体)。代码只规定拼接顺序,并没有实现一套字段级“覆盖”算法。
除了向上递归,还有全局上下文——从 agentDir(用户主目录下的 .pi 配置目录)读取一份。完整的查找顺序:
系统提示词组装顺序
├─ 1. agentDir/<首个候选>
│ 全局(用户级)
├─ 2. 祖先目录/<首个候选>
│ 从文件系统根目录到 cwd 的上一层
└─ 3. cwd/<首个候选>
当前项目
源码在 resource-loader.ts:85-123 的 loadProjectContextFiles 函数。
XML 包装:让 LLM 理解”这是一份项目指令”
找到了上下文文件,buildSystemPrompt(system-prompt.ts:154-161)用 XML 标签包装:
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="/myorg/CLAUDE.md">
全组织规范:所有项目使用 TypeScript strict 模式...
</project_instructions>
<project_instructions path="/myorg/teams/teamA/projects/app1/CLAUDE.md">
本项目使用 pnpm,测试用 vitest...
</project_instructions>
</project_context>
为什么用 XML 而不是 Markdown?
- XML 边界明确——
</project_instructions>给每份文件一个清晰的结束标记,减少不同来源内容混在一起的歧义 - 带 path 属性——LLM 看到内容来自哪个文件,能区分”组织级规范”和”项目级规范”的优先级
这里的 XML 是提示结构,不是安全边界;文件内容仍然会作为指令文本进入模型上下文。
Skills 懒加载:列表进 prompt,内容按需读
Skills(项目特定操作指南)有另一个微妙的设计。每个 skill 是一个 SKILL.md 文件,可能几千字。如果把所有 skill 全文塞进系统提示词,token 开销巨大且大部分用不上。
Pi 的方案是 formatSkillsForPrompt(skills.ts:335-361)——只放轻量清单,全文按需 read:
传统方式(推模式) Pi 的方式(拉模式)
───────────────────── ─────────────────────
系统提示词 ←─ 全文塞进 系统提示词 ←─ 只放清单
│
▼
LLM 看清单,判断需要哪个
│
▼
LLM 主动调 read 工具
│
▼
SKILL.md 全文进入后续上下文
最终在系统提示词里长这样:
<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”——这就是按需读取契约。模型不一定总会主动遵循;用户可以用 /skill:name 强制加载。并且只有 read 工具可用时,默认系统提示词才附加技能清单。
对比”全文塞进系统提示词”:
| 方案 | Token 开销 | 信息密度 |
|---|---|---|
| 全文塞 | 与全部 skill 正文总长度一起增长 | 未使用的正文也固定占空间 |
| 懒加载 | 先支付 name / description / location 清单 | 匹配任务时才读取正文 |
这是用”工具调用”做按需上下文加载的范式——把 LLM 的主动性纳入上下文工程。后面 §八还会展开讨论这个设计模式。
默认系统提示词的完整骨架
未传 customPrompt 时,把上面所有元素串起来,buildSystemPrompt 生成的默认提示词结构是:
1. 角色定位
"You are an expert coding assistant operating inside pi..."
2. 工具列表
"- read: Read a file\n- bash: Execute...\n- edit: ..."
3. 通用 guidelines
"- Be concise in your responses\n- Show file paths clearly..."
4. Pi 文档路径(让 LLM 能 read 自身文档)
5. [可选] appendSystemPrompt(追加内容)
6. <project_context>... CLAUDE.md 内容 ...</project_context>
7. <available_skills>... Skills 清单 ...</available_skills>
8. Current date: YYYY-MM-DD
9. Current working directory: /path/to/cwd
如果传入 customPrompt,它会替换上面第 1–4 项的默认角色、工具、guidelines 与 Pi 文档段落;appendSystemPrompt、项目上下文和可用的 Skills 清单仍分别按条件追加,日期和 cwd 则始终追加在末尾。
末尾才是 Current date 和 cwd——这两个看似简单的信息其实是上下文工程的”基本元数据”。LLM 需要知道”今天是哪天”(处理”昨天”、“上周”这类相对时间)、“我在哪个目录”(处理相对路径)。
小结:系统提示词组装是”加法”上下文工程——通过多层文件递归 + XML 结构化 + Skills 懒加载,让 LLM 自动接收项目规范,无需用户重复说明。
五、历史侧 ③:Compaction(联动第9章)
长对话终究会超过窗口上限。Compaction 是 Pi 的核心压缩算法——把旧消息变成结构化摘要,用摘要替代原始消息,腾出空间但保留关键信息。
这个问题足够重要也足够复杂,已经独立成章:
那章详细讲了:
- 触发条件:
shouldCompact用contextWindow - reserveTokens作为阈值 - 切割点算法:
findCutPoint从后往前累积所有 message 的估算 token,但不允许 toolResult 作为保留区起点 - 结构化摘要:6 个部分(Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context)
- 增量更新:多次压缩时用
UPDATE_SUMMARIZATION_PROMPT在旧摘要基础上更新 - 文件跟踪:摘要末尾附加
<read-files>和<modified-files>列表 - 极端情况:Turn 分割与 turnPrefix 摘要
本章 §七的全景链路会把 Compaction 整合进来,这里不重复展开。记住一个关键事实就够:Compaction 生成的 CompactionSummaryMessage 会出现在后续对话的 context.messages 里,作为新的上下文。
六、历史侧 ④:分支摘要(Branch Summarization)
Compaction 解决的是”当前路径过长”。Pi 还把会话组织成树(第 10 章会详讲):用户可以导航到历史节点,再从那里追加新内容。
这种结构带来一个可选需求:用户导航离开当前路径时,可能希望把这条路径的探索成果带到新位置。
问题:分支跳转后,旧分支的内容怎么处理?
举个具体场景:
对话树:
root
│
[探索方案 A]
│
[A 的实现]
│
leaf_1 ← 用户当前在这里
用户:从 root 重新分叉探索方案 B
root
│
[探索方案 A] ← 这部分还在,但被"放弃"了
│
[A 的实现]
│
leaf_1(旧叶子)
用户切换到:
root
│
[探索方案 B] ← 新分支
│
leaf_2 ← 用户现在在这里
不生成摘要时,LLM 看到的上下文只来自当前 root → leaf 路径,不会自动包含离开路径的内容。如果旧路径上有重要发现,用户可以在树导航 UI 中选择生成摘要。
直接把整个旧分支接进上下文?太占空间,违背了 §一的精神。
Pi 的可选解法是 coding-agent 的 branch-summarization.ts:对离开路径相对目标路径多出来的 entries 生成摘要。
LCA 算法:找”分叉点”
第一步是确定”被放弃的分支包含哪些内容”。这需要找两个叶子节点的最近公共祖先(Lowest Common Ancestor, LCA)——也就是两个分支开始分叉的那个节点。
collectEntriesForBranchSummary(branch-summarization.ts:102-140)的逻辑用通俗的话讲就三步:
旧路径:root → ... → leaf_1
新路径:root → ... → leaf_2
1. 把两条路径都拿出来
2. 在新路径上从后往前找,第一个也在旧路径里的节点 = LCA(分叉点)
3. 从 leaf_1 向上爬到 LCA(不含 LCA),沿途收集的内容
就是"被放弃的分支"
摘要生成:复用 Compaction 的工具
收集到 entries 后,generateBranchSummary(branch-summarization.ts:287-370)用 LLM 生成摘要。它复用了 Compaction 的几件底层工具:
convertToLlm——把消息翻译成 LLM 格式serializeConversation——把消息序列化成对话文本SUMMARIZATION_SYSTEM_PROMPT——共享的系统提示词
也就是说,两套摘要机制复用了消息转换、序列化、系统提示和文件跟踪工具;它们的 entry 选择、token 预算、用户控制与 prompt 仍然不同。
提示词:和 Compaction 的关键差异
BRANCH_SUMMARY_PROMPT(branch-summarization.ts:252-279)和 Compaction 的 SUMMARIZATION_PROMPT 高度相似——但只有 5 个部分(Goal / Constraints / Progress / Key Decisions / Next Steps),没有 Critical Context(Compaction 才有这一部分,所以 Compaction 是 6 个部分)。差异主要在两点:
差异 1:上下文前言不同
// 这段前言精准描述了语义——"用户探索了一个不同的分支,然后回到这里"
// LLM 看到这句,知道这不是"主线历史",而是"另一条线的探索记录"
// 对待方式会更轻量(当作参考,而不是主线)
const BRANCH_SUMMARY_PREAMBLE =
`The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n`;
差异 2:maxTokens 更小
Compaction 的 maxTokens 是 min(0.8 × reserveTokens, model.maxTokens)——可能上万 token。但 Branch Summary 的 maxTokens 写死成 2048(branch-summarization.ts:341)。
源码明确规定了 2048 的较小上限,但没有把产品理由写成注释。可观察到的效果是:分支摘要比默认 compaction 摘要预算更紧,为目标路径保留更多窗口。
摘要的注入:成为 BranchSummaryMessage
生成的摘要包含 BRANCH_SUMMARY_PREAMBLE 前言和 <read-files> / <modified-files> 标签,并作为 BranchSummaryEntry 挂在导航目标位置;buildSessionContext() 再把它变成 BranchSummaryMessage。它位于目标节点之后、后续新消息之前;只有导航到根时才近似处在上下文开头。
The following is a summary of a branch that this conversation came back from:
<summary>
The user explored a different conversation branch before returning here.
Summary of that exploration:
## Goal
Try approach A (PostgreSQL triggers)
## Progress
### Done
- [x] Read schema.ts, identified trigger points
### Blocked
- [x] Performance test showed 3x slowdown — abandoned this approach
## Key Decisions
- **Abandon triggers**: Too slow for high-throughput tables
<read-files>
schema.ts
benchmark/trigger-bench.ts
</read-files>
</summary>
LLM 看到这个,立刻知道”之前试过触发器方案,因为性能问题放弃了”——避免了它再次走进同一条死胡同。
Compaction vs Branch Summarization 对照
| 维度 | Compaction | Branch Summarization |
|---|---|---|
| 触发 | 阈值 / 溢出,或手动 /compact | 树导航时显式选择 summarize(扩展也可参与) |
| 目的 | 防止窗口溢出 | 保留被放弃分支的探索成果 |
| 切割 | findCutPoint 算法(向后累积) | LCA 算法(找分叉点) |
| 保留区 | 最近 N tokens 的消息 | 新分支路径(完全保留) |
| 压缩区 | 切点前的旧消息 | 离开路径中位于公共祖先之后的 entries |
| maxTokens | min(0.8×reserve, model.maxTokens) | 2048(固定,更精简) |
| 前言语义 | ”history compacted" | "explored a different branch” |
| 共存 | 当前路径可能经历多次 compaction | 一次启用摘要且有内容的导航可产生一条分支摘要 |
两者互补——Compaction 处理”线性对话的长度问题”,Branch Summarization 处理”树状对话的分支遗忘问题”。Pi 的会话树同时支持两种机制。
七、全景链路:一次工具调用经过的所有上下文处理
把 §三~§六串起来,看一次完整的 LLM 调用经过的所有上下文工程关卡。
配图说明:用户提问 → [1] 系统提示词已组装 → [2] 消息进 context → [3] Agent Loop → [4] LLM 返回 toolCall → [5] 执行 read 工具 → [6] ★ 工具输出截断 → [7] toolResult 进历史 → [8] Agent run 完成 → [9] Session 后处理检查压缩。树导航与分支摘要是另一条可选路径,不是每次工具调用都会经过的固定步骤。
用户输入 "修复 auth.ts 的 bug"
│
▼
[1] 系统提示词组装(§四)
buildSystemPrompt()
├─ 找 CLAUDE.md(向上递归 + agentDir)
├─ 加载 Skills 清单(懒加载)
├─ 拼接工具列表 + guidelines
└─ 末尾加 Current date / cwd
│
▼
[2] 用户消息进入 context.messages(第6章)
│
▼
[3] Agent Loop 开始(第3章的循环与事件流)
│
▼
[4] LLM 返回 toolCall: read("auth.ts")
│
▼
[5] 执行工具 —— read auth.ts
│
▼
[6] 工具输出截断(§三)
├─ truncateHead(read 用 head)
│ └─ 默认 2000 行 / 50KB 双限制
├─ UTF-8 边界安全
├─ 普通多行超限 → 保留完整行并提示下一次 offset
└─ 仅当前首行自身 > 50KB → firstLineExceedsLimit,提示改用 bash
│
▼
[7] 工具结果进入 context.messages(第5章)
│
▼
...循环...
│
▼
[8] Agent run 完成(已发 agent_end)
│
▼
[9] _handlePostAgentRun → _checkCompaction(§五 / 第9章)
│
├── 否 → 等下一轮
│
└── 是 → 执行 Compaction
├─ findCutPoint
├─ generateSummary(LLM 调用)
├─ 写入 CompactionEntry
└─ 立即 buildSessionContext 并更新 Agent state
用户树导航并选择 summarize?
│
▼
[10] Branch Summarization(§六)
├─ collectEntriesForBranchSummary(LCA)
├─ generateBranchSummary(LLM 调用)
└─ 写入 BranchSummaryEntry,再重建 Agent context
这是本章关注的主要链路。它不是每次请求都逐项重新执行的固定流水线:例如系统提示词通常在会话装配或资源变化时重建,分支摘要也只在树导航场景触发。每一层都在不同时间点塑形 LLM 最终看到的内容:
- §三 控制单条工具结果的体积
- §四 控制系统提示词的内容
- §五 控制长对话的总长度
- §六 控制多分支的信息保留
任何一层都不是孤立的——它们组合成一道”漏斗”,每一层都过滤/塑形一部分内容,最后送进 LLM 的才是”对当前任务最有价值的信息”。
八、设计精华
回顾整章,Pi 的上下文工程有三个值得带走的设计思路。
1. 多层防护:没有银弹,只有层层兜底
§二开头那张”两层防护”图是这一章最重要的一张图。它的核心思想是——没有任何单一机制能搞定所有上下文问题。
- 工具输出截断解决”单条太大”
- 系统提示词组装解决”项目规范注入”
- Compaction 解决”线性对话过长”
- Branch Summarization 解决”分支跳转遗忘”
每层都只解决自己擅长的问题,互不替代。你可以把 Compaction 调得非常激进(reserveTokens 设很大),但单条工具输出仍然需要 truncate——因为单条 80KB 的 read 结果,在没有截断的情况下,连一轮都撑不过。反过来也一样。
可复用的工程结论是:先明确每个机制的输入边界,再组合使用。Pi 没有让一种全局算法处理所有体积问题,而是把限制分别放在工具、提示词装配和会话历史层。
实现:散落在
packages/coding-agent/src/core/{tools,system-prompt,compaction}/*.ts与packages/agent/src/harness/
2. 加法 + 减法:上下文工程的双向操作
§三 是”减法”——把太大的东西变小。§四 是”加法”——主动加入项目规范和 Skills。§五~§六 在裁剪的同时也做”加法”——加入结构化摘要、加入被放弃分支的探索成果。
上下文工程不是单纯的”压缩”,而是”塑形”——在体积约束下,让信息更准确、更有结构、更易理解。
举个对比:即使 50 轮原始对话没有超窗口,它仍要求模型重新从长历史中提取重点;“结构化摘要 + 近期消息”用丢失细节换取显式的目标、进度与决策字段。哪种效果更好取决于任务,但两者的信息形态和风险不同。
Pi 在三处体现了这个思想:
- Compaction 用 6 个部分的模板(含 Critical Context)
- Branch Summary 用 5 个部分的模板(无 Critical Context)
- 系统提示词组装用 XML 标签(明确语义边界)
结构化在这里的作用是强制摘要覆盖预定栏目、标明内容来源;它降低遗漏风险,但不能保证摘要准确或完整。
3. 工具调用支持按需加载上下文
§四的 Skills 懒加载揭示了一个更深的设计模式——用工具调用做按需上下文加载。
传统上下文工程是”推”模式:系统决定给 LLM 看什么,一股脑塞进 system prompt。
Pi 的 Skills 是”拉”模式:系统只给一个清单(轻量),LLM 根据当前任务主动调用 read 工具拉取需要的 skill 全文。
| 维度 | 推模式 | 拉模式 |
|---|---|---|
| Token 开销 | 全部正文预付 | 先付索引,读取时再付正文 |
| 信息相关性 | 未匹配内容也在场 | 取决于模型或用户是否选对 skill |
| LLM 主动性 | 被动接收 | 主动选择 |
| 适用场景 | 必须知道的信息 | 可能用到的信息 |
核心洞察:当 LLM 有工具调用能力时,“工具”本身就是上下文工程的载体——不需要把所有可能用到的信息都塞进 prompt,让 LLM 用工具按需取。
Pi 在这里给出了一种具体实现:XML 清单暴露 name / description / location,再让模型通过 read 获取正文。它的优势是边界容易从源码检查;代价是是否加载正确技能仍依赖模型选择或用户显式调用。
实现:
formatSkillsForPrompt(skills.ts:335)+ 提示词中的"Use the read tool to load a skill's file when the task matches its description"
九、下一站
这一章我们看了 Pi 的上下文工程全景。其中 §五的 Compaction 和 §六的 Branch Summarization 都涉及一个我们反复提到但没展开的概念——Session Tree。
Compaction 的结果存为 CompactionEntry,“追加到 Session Tree”。Branch Summarization 触发于”用户切换会话树分支”。但 Session Tree 到底是什么结构?为什么对话历史不是线性数组而是一棵树?分支跳转的 LCA 算法依赖什么数据结构?
下一章先深入 Compaction 的触发、切点与摘要格式;第 10 章再完整解释 Session Tree 的持久化与分叉。
本章关键源码索引:
packages/coding-agent/src/core/tools/truncate.ts— 截断算法(truncateHead / truncateTail / truncateLine)packages/coding-agent/src/core/tools/output-accumulator.ts— 流式累积器(实现细节,本章不展开)packages/coding-agent/src/core/tools/bash.ts— bash 工具的整合(截断 + 累积 + 落盘)packages/coding-agent/src/core/system-prompt.ts— 系统提示词组装(buildSystemPrompt)packages/coding-agent/src/core/resource-loader.ts:85-123— CLAUDE.md / AGENTS.md 向上递归查找packages/coding-agent/src/core/skills.ts:335-361— Skills 懒加载(formatSkillsForPrompt)packages/coding-agent/src/core/compaction/branch-summarization.ts— 产品使用的分支摘要(LCA + 5 个部分的模板)packages/coding-agent/src/core/compaction/compaction.ts— 产品使用的 Compaction 主算法(详见第9章)