Pi Agent · Book
M08

第8章:上下文工程 —— 让有限窗口承载长会话

6291字 · 含 200 行代码 · 约 32 分钟

第 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 行的源文件,可能 80KB
  • grep 全仓库的关键词,命中几百行
  • 多轮工具调用累积下来,几十轮对话轻松破 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 的输出。如果不加控制,几轮下来上下文窗口就被工具结果塞满了。

最朴素的解法是”按字符数截断”。但这会立刻撞到三个新问题:

  1. 截断的位置不对——bash 报错通常在末尾,截尾才是有用的;文件读取通常开头更重要,截头才对
  2. 切断多字节字符——直接按字节切,会把一个 emoji 切成两个无效码元
  3. 单行就超限——比如 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 保持顺序。但两者并非在所有边界上都完全对称:如果第一行本身就超过 maxBytestruncateHead 返回空内容并设置 firstLineExceedsLimit: true;如果最后一行本身超过上限,truncateTail 会返回这一行的 UTF-8 安全末尾片段。

边界安全:UTF-8 多字节字符

字节级截断最阴险的 bug 是切坏多字节字符。一个 emoji 😀 在 UTF-8 里是 4 个字节,如果你在第 2 个字节切一刀,剩下两个字节会变成无效字符

coding-agent 的 truncateStringToBytesFromEndpackages/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 定位该行,再配合 cuthead -ctail -c 等命令分片;read 自己仍会因为这一行超过 50KB 而拒绝返回它。

单行限长:grep 的 500 字符规则

grep 工具还有一个独立的截断:truncateLinepackages/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.tsresource-loader.ts 里——核心是两件事:多级项目上下文候选 + Skills 轻索引、正文按需读取

多级上下文文件:从当前目录向上递归

Pi 在每个目录下按 AGENTS.mdAGENTS.MDCLAUDE.mdCLAUDE.MD 的顺序寻找,读取到第一份可读候选后停止检查该目录的其他候选;如果较早候选存在但读取失败,代码会记录警告并继续尝试后面的文件。然后它从 cwd 向上递归到根目录,把沿途每个命中目录的一份规范文件合并

多层 CLAUDE.md 递归 + Skills 懒加载
多层 CLAUDE.md 递归 + Skills 懒加载

配图说明:左侧 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-123loadProjectContextFiles 函数。

XML 包装:让 LLM 理解”这是一份项目指令”

找到了上下文文件,buildSystemPromptsystem-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?

  1. XML 边界明确——</project_instructions> 给每份文件一个清晰的结束标记,减少不同来源内容混在一起的歧义
  2. 带 path 属性——LLM 看到内容来自哪个文件,能区分”组织级规范”和”项目级规范”的优先级

这里的 XML 是提示结构,不是安全边界;文件内容仍然会作为指令文本进入模型上下文。

Skills 懒加载:列表进 prompt,内容按需读

Skills(项目特定操作指南)有另一个微妙的设计。每个 skill 是一个 SKILL.md 文件,可能几千字。如果把所有 skill 全文塞进系统提示词,token 开销巨大且大部分用不上。

Pi 的方案是 formatSkillsForPromptskills.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 datecwd——这两个看似简单的信息其实是上下文工程的”基本元数据”。LLM 需要知道”今天是哪天”(处理”昨天”、“上周”这类相对时间)、“我在哪个目录”(处理相对路径)。

小结:系统提示词组装是”加法”上下文工程——通过多层文件递归 + XML 结构化 + Skills 懒加载,让 LLM 自动接收项目规范,无需用户重复说明。


五、历史侧 ③:Compaction(联动第9章)

长对话终究会超过窗口上限。Compaction 是 Pi 的核心压缩算法——把旧消息变成结构化摘要,用摘要替代原始消息,腾出空间但保留关键信息。

这个问题足够重要也足够复杂,已经独立成章:

👉 第9章:上下文压缩 —— 当对话太长怎么办

那章详细讲了:

  • 触发条件shouldCompactcontextWindow - 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)——也就是两个分支开始分叉的那个节点。

collectEntriesForBranchSummarybranch-summarization.ts:102-140)的逻辑用通俗的话讲就三步:

旧路径:root → ... → leaf_1
新路径:root → ... → leaf_2

1. 把两条路径都拿出来
2. 在新路径上从后往前找,第一个也在旧路径里的节点 = LCA(分叉点)
3. 从 leaf_1 向上爬到 LCA(不含 LCA),沿途收集的内容
   就是"被放弃的分支"

摘要生成:复用 Compaction 的工具

收集到 entries 后,generateBranchSummarybranch-summarization.ts:287-370)用 LLM 生成摘要。它复用了 Compaction 的几件底层工具:

  • convertToLlm——把消息翻译成 LLM 格式
  • serializeConversation——把消息序列化成对话文本
  • SUMMARIZATION_SYSTEM_PROMPT——共享的系统提示词

也就是说,两套摘要机制复用了消息转换、序列化、系统提示和文件跟踪工具;它们的 entry 选择、token 预算、用户控制与 prompt 仍然不同。

提示词:和 Compaction 的关键差异

BRANCH_SUMMARY_PROMPTbranch-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 写死成 2048branch-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 对照

维度CompactionBranch Summarization
触发阈值 / 溢出,或手动 /compact树导航时显式选择 summarize(扩展也可参与)
目的防止窗口溢出保留被放弃分支的探索成果
切割findCutPoint 算法(向后累积)LCA 算法(找分叉点)
保留区最近 N tokens 的消息新分支路径(完全保留)
压缩区切点前的旧消息离开路径中位于公共祖先之后的 entries
maxTokensmin(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}/*.tspackages/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 获取正文。它的优势是边界容易从源码检查;代价是是否加载正确技能仍依赖模型选择或用户显式调用。

实现:formatSkillsForPromptskills.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章)