π Pi Agent 源码学习课程 阶段四 · 9/14
阶段四 · 编码代理

第 9 讲系统提示词、上下文组装与压缩(Compaction)

Agent 的「灵魂装配线」:系统提示词如何分层装配、项目上下文文件如何被发现与合并、技能描述如何按需加载,以及上下文逼近上限时如何把旧历史压缩成结构化摘要。

3~4 小时预计学习时长
5 个文件核心源码精读
§9.1–§9.8本讲分节

学习目标

  1. 说清 buildSystemPromptSections() 的装配顺序,以及每段为什么用 XML 标签包裹。
  2. 解释 prompt cache 与「稳定前缀」的关系,掌握 diffSystemPromptSections() 的差量补丁机制。
  3. 复述 context files 的目录向上发现规则、同名候选优先级与 worktree 遮蔽处理。
  4. 算得清技能注入的上下文经济账:为什么只放「名字 + 描述 + 路径」。
  5. 用 shouldCompact() 与 findCutPoint() 解释压缩何时触发、在哪里下刀、保多少。
  6. 区分「请求消息数组被压缩」与「会话树原始条目保留」,并说出分支摘要的生成流程。

§9.1系统提示词装配线:preamble + XML sections

先把整件事类比成一条装配线:Pi 不把系统提示词当成一整块字符串拼出来,而是当成若干个有名有姓的工位(section),每个工位负责一段内容,最后按固定顺序装进 SystemMessage.sections。也就是说,它更像「带标签的配置分区」,而不是一次性的字符串模板拼接。

核心入口在 packages/coding-agent/src/core/system-prompt.ts。preamble 是唯一不带标签的段落;其余每一段都被同名 XML 标签包裹——这是刻意设计:标签让模型在后续收到「同名段落的新版本」时,能精确匹配到旧段落并理解「这是替换,不是追加」。

packages/coding-agent/src/core/system-prompt.ts节选
// buildSystemPromptSections() 主体(节选,省略段名校验与选项归一化)
const promptSections: Record<string, string> = {};
if (customPrompt) {
  promptSections.preamble = customPrompt;          // .pi/SYSTEM.md:整体替换默认前缀
} else {
  promptSections.preamble =
    "You are a coding assistant running inside pi, a coding agent harness. ...";
  promptSections.tools   = `${tools}\n\nAdditional tools may be available.`;
  promptSections.rules   = buildRules(declaredTools, toolGuidelines, promptGuidelines);
  promptSections.docs    = `Pi documentation (read only when the user asks about ...)`;
}
if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;
if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);
if (skillFileReadTool && skills.length > 0) {
  promptSections.skills = formatSkillsForPrompt(skills, skillFileReadTool).trim();
}
promptSections.cwd = cwd.replace(/\\/g, "/");
for (const [name, content] of Object.entries(customSections)) {
  if (content) promptSections[name] = content;    // 扩展自定义段落
}
// 输出:preamble 裸文本,其余段落用同名标签包裹(节选结束)
const sections: SystemPromptSections = { preamble: promptSections.preamble };
for (const [name, content] of Object.entries(promptSections)) {
  if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;
}

装配顺序固定为:preamble →(无自定义前缀时)tools → rules → docs → addendum → project_context → skills → cwd → 扩展自定义 sections。几个容易忽略的细节:

  • 自定义前缀是「三段一起换」:一旦 .pi/SYSTEM.md(或 before_agent_start 给出的 customPrompt)存在,tools/rules/docs 三段整体不再生成,由这一段前缀承担全部基础指令。
  • 工具列表必须与请求声明一致:declaredTools = selectedTools 减去 hiddenTools。被 prepareLoadout 隐藏声明的工具(只能经其他工具间接到达)不会出现在提示词工具清单里,提示词与请求的 tool declarations 永不「说一套带一套」。
  • 段落名受正则约束:/^[a-z][a-z0-9_-]*$/,且不允许叫 preamble——因为段落名会直接变成 XML 标签名。
  • 强制提示词是黑盒:forceSystemPrompt(由 before_agent_start 返回)走 buildSystemPromptState() 的另一条分支,整体放进 content、不带 sections;结构化 sections 仍照常落盘,只是在请求出站时被投影替换(§9.7)。
INPUTS tool snippets / rules contextFiles (AGENTS…) skills name+desc+path cwd / append / sections buildSystemPromptSections() — 固定顺序装配 preamble(裸文本) <tools> <rules> <docs> <addendum> <project_context> <skills> <cwd> custom sections(扩展注入) customPrompt 存在时,tools/rules/docs 三段整体不生成 hiddenTools:提示词工具清单与请求 tool declarations 保持一致 段名正则 ^[a-z][a-z0-9_-]*$,段名即 XML 标签名 运行内稳定(cache 命中区) 可随 /reload、loadout 变化 SystemMessage.sections(transcript 落盘) buildSystemPrompt() = getSystemMessageText(...) 会话回放时逐段重放,前缀字节级一致 diffSystemPromptSections(previous, current) 仅变化段落生成 patch system message 未变段落不重发,不打断 prompt cache prompt cache 以「请求前缀完全一致」为命中条件;段顺序固定 + XML 标签 + 只补丁变化段,共同保证历史前缀稳定。 forceSystemPrompt:buildSystemPromptState() 返回 { content }(黑盒),请求侧由 transformContext 投影替换(见 §9.7)。
系统提示词装配线。四路输入汇入固定顺序的分段装配;「稳定前缀」与「可变段落」之间用虚线分界,输出分为「完整 sections 落盘」与「运行中差量补丁」两条轨道。

Java 视角 · 分段配置与差量补丁

这等价于 Java 里「分层 Properties + 只覆盖变化的 key」与「每次全量重写配置文件」的区别。如果每次请求都重新拼接整段系统提示词,任何一个字符变动都会让 provider 的 prompt cache 前缀失配——就像把整个 JSON 配置重新序列化后与旧值做 equals,键序一变就全部判不等。

Pi 的做法是:段落顺序固定、内容用同名 XML 标签包裹、变化时只把变化的段落包成一条新的 system 消息追加(patch 中 null 表示删除该段)。这与 Git 对文件做逐 hunk diff 而非整文件替换是同一个工程直觉。

§9.2cache 稳定前缀:为什么提示词要「分段 + 打补丁」

LLM 请求是无状态 RPC:每次都携带完整历史。第 1 讲提过 prompt caching——provider 按请求前缀的字节级一致性命中缓存,命中即可省钱、提速。因此系统提示词工程里有一条铁律:能不变的部分,一个字节都不要变。

Pi 把这条铁律落成三件事:

  • 段落顺序固定:对象属性按插入序序列化,preamble 永远第一。
  • 同段同名标签:更新 project_context 时模型看到的是「又一个 <project_context>」,语义上是替换。
  • 差量补丁:会话进行中提示词变化(例如 /reload 后技能清单变了),只追加一条携带变化段落的 system 消息,而不是重写头部的完整提示词。
packages/coding-agent/src/core/system-prompt.ts节选
/** 模型当前已有的 sections(从 transcript 重放,永不为 null)与期望 sections 求差,
 *  返回 SystemMessage.sections 补丁;无变化返回 undefined。 */
export function diffSystemPromptSections(
  previous: Record<string, string | null>,
  current: SystemPromptSections,
): Record<string, string | null> | undefined {
  const patch: Record<string, string | null> = {};
  for (const [name, text] of Object.entries(current)) {
    if (previous[name] !== text) patch[name] = text;      // 内容不同,记录新版本
  }
  for (const name of Object.keys(previous)) {
    if (current[name] === undefined) patch[name] = null;   // 段落消失,null 表示删除
  }
  return Object.keys(patch).length > 0 ? patch : undefined;
}

调用方是 AgentSession._preparePromptAndToolLoadout():先 buildSystemPromptSections(options) 得到「期望状态」,再与「模型当前已有状态」(用 getCurrentSystemMessage() 从消息数组重放)求差。无差则不发任何 system 消息;有差则返回一条 { role: "system", content: "", sections: patch }。工具增减由 agent loop 通过 toolsAdded/toolsRemoved 声明,不混入文本段落。

§9.3Context files:AGENTS.md 层级发现与合并

项目上下文文件(context files)解决「同一个 Pi,在不同项目里应该遵守不同规矩」。类比 Spring Boot 的多 profile 配置:全局一份、每层目录一份——但 Pi 选择的是合并注入而非按键覆盖。

发现逻辑在 packages/coding-agent/src/core/resource-loader.ts,分两级规则:

  • 每个目录内,按固定优先级取第一个命中的文件:AGENTS.override.md → AGENTS.md → AGENTS.MD → CLAUDE.md → CLAUDE.MD。同一目录最多贡献一份。
  • 目录之间,全局(agentDir,即 ~/.pi/agent)最先;然后从 cwd 向上走到文件系统根,逐层收集,最终按「根在前、cwd 在后」的顺序合并;seenPaths 去重,BOM 用 stripBom() 剥掉。
packages/coding-agent/src/core/resource-loader.ts节选
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
  const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
  for (const filename of candidates) {
    const filePath = join(dir, filename);
    if (existsSync(filePath)) {
      try {
        if (!statSync(filePath).isFile()) continue;
        return { path: filePath, content: stripBom(readFileSync(filePath, "utf-8")) };
      } catch (error) {
        console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
      }
    }
  }
  return null;
}
packages/coding-agent/src/core/resource-loader.ts节选
// loadProjectContextFiles() 主体(节选)
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
const globalContext = loadContextFileFromDir(resolvedAgentDir);   // 全局 ~/.pi/agent
if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path); }

const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
while (true) {                                                    // cwd 向上到根
  const contextFile = loadContextFileFromDir(currentDir);
  if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
    ancestorContextFiles.unshift(contextFile);                    // unshift:根在前,cwd 在后
    seenPaths.add(contextFile.path);
  }
  const parentDir = dirname(currentDir);
  if (parentDir === currentDir) break;                            // 到达文件系统根
  currentDir = parentDir;
}
contextFiles.push(...ancestorContextFiles);

合并后的文件被 renderProjectContext() 逐个包进 <project_instructions path="…"> 标签,整体作为 project_context 段落进入系统提示词。路径随内容一起注入,模型因此知道自己正在遵守的是哪一层规矩。还有几个专门的文件与机制:

  • .pi/SYSTEM.md(项目受信任时优先,否则回退全局 ~/.pi/agent/SYSTEM.md):作为 customPrompt 整体替换默认前缀。
  • .pi/APPEND_SYSTEM.md / ~/.pi/agent/APPEND_SYSTEM.md:内容合并进 addendum 段落,位置在 docs 之后、project_context 之前。
  • worktree 遮蔽:findShadowedContextFile() 处理「嵌套 linked worktree 的 AGENTS.md 符号链接回主仓库」导致同一逻辑仓库作用域注入两份的边界情况。
  • 启动选项可设 noContextFiles 完全关闭发现(测试与沙箱场景)。
~/.pi/agent/AGENTS.md① 全局 /home/dev/AGENTS.md② 根层 /home/dev/work/AGENTS.md③ 上级 /home/dev/work/app/AGENTS.override.md④ cwd 每目录候选优先级:AGENTS.override.md > AGENTS.md > AGENTS.MD > CLAUDE.md > CLAUDE.MD <project_context> <project_instructions path="…"> … </project_instructions> × N(按 ①→④ 顺序,全部合并注入,不覆盖) BOM 剥离;seenPaths 去重;worktree 遮蔽剔除
上下文文件层级发现:全局目录一份 + 从文件系统根到 cwd 的每目录一份,按序合并为 project_context 段落。就近文件排在最后,但语义上是叠加而非覆盖。

Java 视角 · 分层配置合并

这就是 Spring Boot 的 application.yml + profile 覆盖链,或者 Maven 的 parent POM 继承:全局定义「公司级规范」,每层目录补充「项目级规范」。区别在于 Spring 靠 key 覆盖,Pi 靠顺序叠加——更接近日志追加(append),同一主题的指令由模型自行调和,且每份内容都带着自己的来源路径,方便模型判断优先级。

§9.4Skills:描述注入与按需加载的上下文经济学

技能(Skills)是「带说明书的工作包」:一个目录,内含 SKILL.md(frontmatter + 指令正文),可捆绑 scripts/、references/、assets/。加载逻辑在 packages/coding-agent/src/core/skills.ts。

关键设计是一笔上下文经济学账:启动时只把每个技能的三行元数据——名字 + 描述 + 路径——注入 skills 段落;SKILL.md 全文只有当任务匹配、模型用 read 工具去读时才进入上下文。假设装了 20 个技能、平均每个 SKILL.md 2000 token:全量注入要预付 40,000 token;按需注入只付「20 条描述」(描述上限 1024 字符,通常几十 token),命中率哪怕只有 10%,也节省了九成以上的固定开销。描述同时是路由信息:它决定模型何时去读全文,所以要写清「做什么」和「何时用」。

packages/coding-agent/src/core/skills.ts节选
export function formatSkillsForPrompt(skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"): string {
  const visibleSkills = skills.filter((s) => !s.disableModelInvocation);   // 命令专属技能不进提示词
  if (visibleSkills.length === 0) return "";
  const lines = [
    "\n\nThe following skills provide specialized instructions for specific tasks.",
    fileReadTool === "read"
      ? "Use the read tool to load a skill's file when the task matches its description."
      : fileReadTool === "bash"
        ? "Use bash to load a skill's file when the task matches its description."
        : "Load a skill's file when the task matches its description.",
    "Resolve relative paths against the skill's directory (parent of SKILL.md). Use absolute paths in tool calls.",
    "", "<available_skills>",
  ];
  for (const skill of visibleSkills) {
    lines.push("  <skill>");
    lines.push(`    <name>${escapeXml(skill.name)}</name>`);
    lines.push(`    <description>${escapeXml(skill.description)}</description>`);
    lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);
    lines.push("  </skill>");
  }
  lines.push("</available_skills>");
  return lines.join("\n");
}

与源码一致的细节:

  • 校验规则:名字 ≤ 64 字符(小写字母/数字/连字符,无首尾与连续连字符),缺失时回退父目录名;描述必填且 ≤ 1024 字符,缺描述的技能直接不加载;frontmatter 解析失败的 SKILL.md 产生告警。
  • 读取工具的自适应提示:若 read 在声明工具中,提示「用 read」;否则若 bash 在,提示「用 bash」;若两者都只能间接到达,提示词写 "indirect" 分支,不点名任何工具,与请求声明保持一致。
  • 强制加载兜底:模型可能漏判,用户可用 /skill:name 强制加载(参数追加为用户请求);frontmatter 里 disable-model-invocation: true 的技能只保留该命令入口,不进 <available_skills>。
  • 发现位置:~/.pi/agent/skills(user)与 <project>/.pi/skills(project),另支持 Agent Skills 规范的 ~/.agents/skills/ 与 .agents/skills/(项目级沿祖先目录发现,存在仓库根时停在根)。含 SKILL.md 的目录视为技能根不再递归;同名冲突先到先得并告警;编辑后 /reload 生效。
  • 路径解析提示:提示词明确「相对路径按 SKILL.md 父目录解析,工具调用用绝对路径」——这是让捆绑脚本与引用文件可移植的关键一句。

§9.5Compaction:触发阈值、切点、摘要与保留边界

会话越滚越长,上下文逼近窗口上限时必须压缩。Pi 的 compaction 是一次由 LLM 自己完成的历史摘要:把旧消息替换成一条结构化摘要消息,近期消息原样保留。一句话类比:日志压缩(log compaction)——旧明细被快照取代,但原始日志仍在磁盘上。

触发:一条减法不等式

所有判断收敛到一个纯函数(core/compaction/compaction.ts):

packages/coding-agent/src/core/compaction/compaction.ts节选
export interface CompactionSettings {
  enabled: boolean;
  reserveTokens: number;      // 剩余空间警戒线
  keepRecentTokens: number;   // 保留边界:近期消息预算
}

export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
  enabled: true,
  reserveTokens: 16384,
  keepRecentTokens: 20000,
};

export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
  if (!settings.enabled) return false;
  return contextTokens > contextWindow - settings.reserveTokens;   // 128k 窗口约 111.6k 触发
}

检查时机(据 docs/compaction.md):多轮运行中,工具结束、结果追加后、下一次 assistant 响应开始前的 prepareNextTurn 阶段;新用户提示进来之前;以及底层运行结束后的溢出恢复(overflow recovery)。若一批收尾工具终结了运行且没有排队消息需要继续,轮间检查会跳过。

token 估算用保守的 chars/4 启发式(estimateTokens()),系统消息按 content 与全部 sections 字符数合计;若最后一条有效 assistant usage 之后又追加了消息,则 usage 总量 + 尾部估算(estimateContextTokens())。发生 context_edit 或压缩后,旧 usage 不再可信,会退回全量估算(estimateProjectedContextTokens())。

下刀:从最新往回走,永不切断工具调用对

findCutPoint() 从最新条目向旧走,累加估算 token,累计达到 keepRecentTokens(默认 20k)即停,取该位置之后最近的合法切点。合法切点:user、assistant、bashExecution、custom(含 branch_summary)消息;toolResult 永远不是合法切点——工具结果必须紧跟产生它的工具调用,切断即产生孤儿消息。若预算超在 toolResult 上,会回退到携带 toolCall 的 assistant 消息,连同其结果一起保留。

packages/coding-agent/src/core/compaction/compaction.ts节选
// findCutPoint() 核心回溯(节选)
let accumulatedTokens = 0;
let cutIndex = cutPoints[0];                       // 兜底:从第一条可切消息保起
for (let i = endIndex - 1; i >= startIndex; i--) {
  const messageTokens = sessionEntryToContextMessages(entries[i])
    .reduce((sum, m) => sum + estimateTokens(m), 0);
  if (messageTokens === 0) continue;
  accumulatedTokens += messageTokens;
  if (accumulatedTokens >= keepRecentTokens) {
    cutIndex = cutPoints.find((c) => c >= i) ?? cutPoints[cutPoints.length - 1];
    break;
  }
}
// 切点不是回合起点时 isSplitTurn = true,turnStartIndex 指向该回合的 user 消息

摘要:结构化格式 + 迭代更新 + 文件清单

被摘要的消息先经 convertToLlm() 与 serializeConversation() 序列化为纯文本,防止模型把它当成「要继续的对话」。序列化格式为 [User] / [Assistant] / [Assistant tool calls] / [Tool result] 四类行;工具结果截断到 2000 字符并标注截断量;thinking 块整体省略(重放的 thinking 常不在模型实况上下文中,塞进去反而可能让摘要请求比被摘要的上下文还大)。

摘要请求是一条独立 LLM 调用:专用 SUMMARIZATION_SYSTEM_PROMPT("Do NOT continue the conversation… ONLY output the structured summary"),并设置 cacheRetention: "none"——一次性请求不写缓存,未传 sessionId 时生成新路由 ID,避免污染会话的 cache 预热;瞬时流错误经 retryAssistantCall() 按配置重试。

摘要正文是固定模板:Goal / Constraints & Preferences / Progress(Done · In Progress · Blocked)/ Key Decisions / Next Steps / Critical Context;再次压缩时改用 UPDATE 提示词,携带 <previous-summary> 做迭代合并(保留旧信息、更新进度、可移除失效项)。最后附加从工具调用中累计提取的 <read-files> 与 <modified-files> 清单。

拆分回合:两份摘要合并

单个 user 回合跨度超过 keepRecentTokens 时,切点落在回合中间的 assistant 消息上(isSplitTurn = true)。此时 Pi 生成两份摘要再拼接:历史摘要(含旧 summary 的迭代更新;无历史时写 "No prior history.")+ 回合前缀摘要(TURN_PREFIX 模板,只覆盖该回合开头到切点),以 --- 与 Turn Context (split turn) 标题合并为一条,两份 usage 相加。回合前缀模板强调「只总结上面出现的信息,不要推断后面的消息」。

落盘:原始条目保留,请求投影重建

压缩产物是一条追加到会话树的 CompactionEntry(JSONL 事件溯源,第 10 讲展开):

packages/coding-agent/src/core/session-manager.ts节选
interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string | null;
  timestamp: string;
  summary: string;
  firstKeptEntryId: string;   // 保留消息从哪条开始
  tokensBefore: number;
  usage?: Usage;              // 生成摘要的 LLM 用量,计入会话总账
  fromHook?: boolean;         // 扩展提供时为 true(兼容旧字段名)
  details?: T;                // 默认 { readFiles, modifiedFiles },扩展可存任意 JSON
  systemMessage?: SystemMessage;  // 压缩边界处的完整提示词与工具状态快照
}

原始条目一个都不删。buildContextEntries() 构建后续请求时:取路径上最新一条 compaction,投影为 [systemMessage?, compactionSummary 消息],其后只拼接从 firstKeptEntryId 开始的保留条目;更旧的、已被摘要的条目对 LLM 不可见,但仍在 JSONL 与 /export、历史搜索、计费统计中可查。重复压缩时,摘要起点从上一次压缩的保留边界(而非压缩条目本身)算起,上一次幸存的消息这次也会进入摘要范围;tokensBefore 每次按重建后的投影重新计算,保证记录的是真实被替换的上下文规模。

压缩前 · 发给 LLM 的消息数组 contextTokens 越过 contextWindow − reserveTokens,触发压缩 sys u1 a1 t1 u2 a2 t2 t3 a3 t4 历史越滚越长 messagesToSummarize(u1 至 t1) 保留边界 firstKeptEntryId = u2(回溯累计达到 keepRecentTokens 20k) 会话树(JSONL)—— 压缩只追加,不删除 hdr u1 a1 t1 u2 … t4 cmp CompactionEntry 追加为叶子 summary / firstKeptEntryId / tokensBefore usage / fromHook? / details / systemMessage? 原始 u1/a1/t1 仍在树中:/export、历史搜索、审计、回放、分支切换都还能看到它们。 压缩后 · 下一次请求的消息数组 sys compactionSummary Goal/Progress/…/files u2 a2 t2 t3 a3 t4 系统提示词保持原前缀 (cache 命中不被压缩打断) 估计规模由 tokensBefore 降到 estimatedTokensAfter;CompactionResult 随 compaction_end 事件携带两者与 usage。 切点永不落在 t(toolResult)上:孤儿结果无法解释;预算超在结果上时回退保留其 assistant 调用。 u=user · a=assistant · t=toolResult · sys=system · cmp=CompactionEntry
压缩前后请求消息数组对比。上:越线时的完整历史;中:会话树只追加 cmp 条目、原始条目全部保留;下:重建后的请求把旧历史替换为一条 compactionSummary,仅携带保留边界之后的近期消息。

Java 视角 · 日志压缩与事件溯源快照

Kafka 的 log compaction 保留每个 key 的最新值、丢弃旧值;Event Sourcing 系统做 snapshot 后,事件日志依旧完整保留用于审计与重建。CompactionEntry 就是这个模型:写模型(JSONL 树)只追加;读模型(发往 LLM 的消息数组投影)由快照 + 近期事件重建。「压缩」没有修改任何历史事实,它只改变「下次请求读哪些」的投影规则。

顺带注意 systemMessage? 字段:压缩边界处连「当时的提示词与工具状态」都拍了快照,回放旧分支时能精确还原那一刻的装配现场。

§9.6/compact 手动压缩、失败降级与溢出恢复

/compact [instructions] 手动触发同一套流程:同样的 prepareCompaction() 切点计算、同样的摘要生成;可选指令作为 customInstructions 以 "Additional focus: …" 附加到提示词,引导摘要侧重(例如「重点保留数据库 schema 变更」)。手动与自动在扩展事件上走同一个介入点 session_before_compact,事件里的 reason 取值为 "manual" | "threshold" | "overflow"。

失败降级(AgentSession._runAutoCompaction() 的 catch 分支与文档「Overflow and Length Recovery Ordering」节):

  • 摘要调用失败(网络错误、stopReason: "error")或被 stopReason: "length" 截断——长度截断意味着摘要不完整,getSummarizationFailure() 直接判定不可持久化,抛错而非写入半截 checkpoint。
  • 捕获后:不追加任何 CompactionEntry,发出带 errorMessage 的 compaction_end 与 session_compact_failed 事件,函数返回 false(不调度内部重试)。
  • 溢出恢复失败或被取消时:保留已做的 omission edits(上下文编辑),不追加压缩;已排队的后续消息仍按普通 steering / follow-up 规则处理。
  • 瞬态流错误(如 socket 断开)在 completeSummarization() 内先按 settings.retry 重试;确定性错误与主动 abort 立即返回,不空耗重试预算。
  • 摘要响应若包含 toolCall 也判定失败——摘要助手不允许产生工具调用。

降级的哲学:宁可带着长上下文继续,也不写坏会话树。 原始条目未动,用户可以稍后重试 /compact 或换模型;会话文件永远处于合法状态。

packages/coding-agent/src/core/compaction/compaction.ts节选
/** 摘要响应无法安全持久化时返回错误信息;length 截断 = 半截 checkpoint,绝不能落盘 */
export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined {
  if (response.stopReason === "error") {
    return `${label} failed: ${response.errorMessage || "Unknown error"}`;
  }
  if (response.stopReason === "length") {
    return `${label} failed: generation hit the token cap and the summary is incomplete`;
  }
  return undefined;
}

// completeSummarization():一次性摘要不写缓存;未传 sessionId 时生成新路由 ID
const requestOptions: SimpleStreamOptions = {
  ...options,
  cacheRetention: "none",
  sessionId: options.sessionId ?? uuidv7(),
};

配置位于 ~/.pi/agent/settings.json 或 <project>/.pi/settings.json(两文件递归合并后查询,全局的同名 model 级配置优先于项目级通用值):

packages/coding-agent/docs/compaction.md配置示例
{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000,
    "modelOverrides": {
      "some-provider/big-model": { "reserveTokens": 400000 }
    }
  }
}

modelOverrides 的键是精确区分大小写的 provider/modelId;reserveTokens 与 keepRecentTokens 各自独立回退(模型级 → 普通设置 → 内置默认);enabled 只全局生效,不按模型覆盖。

§9.7transformContext:请求出站前的最后一道链

agent.transformContext 是 agent loop 在发送请求前对消息数组的最后一道变换钩子(签名近似 (messages, signal) => Promise<AgentMessage[]>)。coding-agent 在 AgentSession 里链式安装了两个投影,每个都先调用 previousTransformContext 再叠加自己的逻辑——典型的责任链模式:

  • 隐藏工具声明投影(_installHiddenDeclarationsProjection):从历史 system 消息的 toolsAdded/toolsRemoved 中滤掉被 prepareLoadout 隐藏的工具,保证整条 transcript 的声明投影一致。
  • 强制提示词投影(_installAgentForcedPromptProjection):当 before_agent_start 返回 systemPrompt 时,把请求里所有 system 消息折叠成一条头部消息,content 为强制文本,保留当前 toolsAdded。落盘的仍是结构化 sections,替换只发生在出站投影。
packages/coding-agent/src/core/agent-session.ts节选
private _installAgentForcedPromptProjection(): void {
  const previousTransformContext = this.agent.transformContext;      // 链式:先跑上一层
  this.agent.transformContext = async (messages, signal) => {
    const transformed = previousTransformContext
      ? await previousTransformContext(messages, signal) : messages;
    const forced = this._runSystemPromptOptions?.forceSystemPrompt;
    if (forced === undefined) return transformed;
    const current = getCurrentSystemMessage(transformed);
    const head: SystemMessage = {
      role: "system",
      content: forced,
      ...(current?.toolsAdded ? { toolsAdded: current.toolsAdded } : {}),
      timestamp: current?.timestamp ?? Date.now(),
    };
    return [head, ...transformed.filter((message) => message.role !== "system")];
  };
}

在 core/sdk.ts 中还能看到第三个也是最通用的用法——把扩展的 context 事件处理器接到这条链上:

packages/coding-agent/src/core/sdk.ts节选
transformContext: async (messages) => {
  const runner = extensionRunnerRef.current;
  if (!runner) return messages;
  return runner.emitContext(messages);   // 每个扩展可裁剪 / 注入 / 替换出站消息
},

这就是「裁剪 + 注入」的落点:裁剪(drop、替换、折叠某些消息)与注入(插入额外上下文)都发生在请求真正离开进程之前,且不改写会话树本体——transcript 与出站投影分离,正是压缩能安全降级的同一套结构性原因。

Java 视角 · Servlet Filter 责任链

transformContext 就是出站方向的 javax.servlet.Filter 链 / Spring HandlerInterceptor:每个环节拿到上一个环节的输出,追加自己的变换,最后才交给「网络层」。coding-agent 里 previousTransformContext 的写法,等价于 FilterChain.doFilter(request, response) 的手动展开。扩展的 context 处理器则是「注册新的 Filter」——SDK 用户与扩展作者都能在不改框架源码的前提下介入请求装配。

§9.8分支摘要与扩展介入点

分支摘要(Branch Summarization)

会话是树(第 10 讲详述)。用 /tree 从 A 分支切到 B 分支时,Pi 提示「要不要给被离开的分支生成摘要」,生成的 BranchSummaryEntry 追加在目标位置,把旧分支的探索结论带回新分支。类比:你在 git 的实验分支上做了些尝试后切回主线,顺手留了一张便签贴在主线上。

流程(core/compaction/branch-summarization.ts):

  • collectEntriesForBranchSummary() 找两路径的最深公共祖先,从旧叶子向祖先回溯收集条目——不因压缩边界而停,压缩摘要本身就是上下文。
  • prepareBranchEntries() 按预算(contextWindow − reserveTokens,默认 16384)从最新向旧取消息;遇到已有的 compaction / branch_summary 摘要消息时尽力塞入(重要上下文,已用预算不足 90% 时容忍超支)。
  • generateBranchSummary() 用与压缩同源的 SUMMARIZATION_SYSTEM_PROMPT 与序列化器,产出 Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps(比压缩少一段 Critical Context),加前导语 "The user explored a different conversation branch before returning here.",同样附文件清单。
  • BranchSummaryEntry 含 fromId(从哪条导航而来)与可选 usage / fromHook / details;文件清单跨压缩与嵌套分支摘要累计(扩展生成的摘要不自动继承,扩展自管 details)。

扩展介入点一览

第 11 讲会系统讲扩展系统;这里先记住与「提示词 + 压缩」相关的几个钩子:

介入点时机能做什么
before_agent_start每次运行开始前修改 systemPromptOptions(含 selectedTools 与自定义 sections);返回 systemPrompt 即强制提示词
context 处理器每次请求出站前经 runner.emitContext() 接入 transformContext 链,裁剪或注入消息
session_before_compact手动 / 阈值 / 溢出压缩前读取 preparation;返回 { cancel: true } 取消,或返回自定义 compaction(summary / usage / details)
session_compact压缩条目落盘后拿到已保存的 CompactionEntry 做索引、遥测等后处理
session_compact_failed压缩失败或取消后配对遥测:reason / errorMessage / aborted / willRetry / fromExtension
session_before_tree/tree 导航前取消导航,或在用户要求摘要时提供自定义分支摘要
packages/coding-agent/docs/compaction.md扩展示例 · 节选
pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
  // preparation: messagesToSummarize / turnPrefixMessages / previousSummary
  //              fileOps / tokensBefore / firstKeptEntryId / settings
  // reason: "manual" | "threshold" | "overflow"

  return { cancel: true };                 // 取消本次压缩
  // 或提供自定义摘要:
  // return { compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId,
  //                        tokensBefore: preparation.tokensBefore, usage, details } };
});

pi.on("session_compact_failed", async (event, ctx) => {
  const { reason, errorMessage, aborted, willRetry, fromExtension } = event;
});

官方示例 examples/extensions/custom-compaction.ts 演示了用另一个模型做摘要:把 prepareCompaction() 给出的 messagesToSummarize 先 convertToLlm() 再 serializeConversation(),喂给自有模型,然后把结果作为 compaction 返回——框架负责切点计算与落盘,你只负责「怎么总结」。

动手实践

  1. 观察装配结果。在一个测试项目中分别放置 AGENTS.md(上层目录)与 AGENTS.override.md(当前目录),再建 .pi/APPEND_SYSTEM.md;用 /export 导出会话,检查系统提示词中的 <project_instructions path="…"> 顺序、<addendum> 与 <cwd> 段落是否与本讲规则一致。
  2. 人为触发自动压缩。在 .pi/settings.json 中把 compaction.reserveTokens 调到接近上下文窗口的值,使一次长对话即越线;触发后用 /export 对比压缩前后的请求消息数组,找出 compactionSummary 消息与保留边界,并核对会话 JSONL 中原始条目仍然存在。
  3. 写一个自定义压缩扩展。订阅 session_before_compact,把默认摘要替换为「结构化要点」格式(例如:变更文件 / 关键命令 / 未决问题三段);再用 /skill:名字 与普通对话各触发一次技能加载,对比技能正文进入上下文的时机。

常见误区

误区一:「压缩就是把旧消息从会话里删掉」

恰恰相反。压缩只在会话树追加一条 CompactionEntry,原始消息全部保留;被改变的只是「构建下一次请求时的投影」。若真删除,历史搜索、导出、审计、计费统计与回放都会失真,分支切换也无法还原压缩边界处的提示词状态(systemMessage 快照)。

误区二:「系统提示词每次请求重新拼一遍也一样」

对模型可见的内容也许一样,但对 prompt cache 完全不一样。cache 命中要求前缀字节级一致;全量重拼只要有一处顺序或空行差异,整段缓存失配。Pi 用固定段落顺序 + 同名标签 + diffSystemPromptSections() 只补丁变化段,就是为了守住这条前缀。

误区三:「技能描述是锦上添花,随便写写就行」

描述是路由的唯一依据——模型只看「名字 + 描述 + 路径」决定要不要读全文。写成 "Helps with PDFs" 这类没有触发条件的描述,模型就无从判断何时加载;而描述又直接占系统提示词的固定 token,写得含糊等于既花了钱又没买到路由能力。

误区四:「压缩摘要失败就丢一段历史继续跑」

Pi 的策略是「不写坏会话」:失败(含 stopReason: "length" 的半截摘要)时不追加任何条目、发 session_compact_failed、不做内部重试,原始上下文原样保留。宁可下一次再试,也不把不完整 checkpoint 持久化成会话事实。

自测问答

Q1为什么技能只注入「名字 + 描述 + 路径」,而不把 SKILL.md 全文放进系统提示词?

这是上下文经济学问题:描述是路由元数据,全文是载荷。全量注入意味着为所有技能预付 token,且绝大多数任务只用到其中一两个;按需注入只付 N 条描述的固定小成本,命中任务时模型才用 read(或 bash)加载全文。描述本身有 1024 字符上限约束,保证这段固定开销可控;disable-model-invocation 的技能不进清单,/skill:name 保留强制加载兜底。

Q2压缩后原始条目为什么仍保留在会话树中?

会话树是事件溯源的写模型,压缩是只追加的 CompactionEntry(含 summary、firstKeptEntryId、tokensBefore、可选 systemMessage 快照)。保留原始条目使 /export、历史搜索、审计、计费统计、分支回放都完整可用;请求消息数组只是「最新压缩 + 保留边界之后条目」的投影,投影失败也不损伤数据。类比 Kafka log compaction / Event Sourcing snapshot:快照加速读取,日志保证真相。

Q3系统提示词的哪些部分必须保持稳定?为什么?

一段运行内不变的段落——preamble 与(无自定义前缀时的)tools/rules/docs——以及所有段落的排列顺序与 XML 标签名。原因是 prompt cache 按请求前缀字节级命中:任何重排、重拼都会使缓存失配。可变内容(project_context、skills、cwd、扩展 sections)也应尽量低频变化,变化时通过 diffSystemPromptSections() 只补丁变化段落,而不是重写头部。

Q4压缩切点为什么永远不能落在 toolResult 消息上?

toolResult 必须紧跟产生它的 assistant toolCall 才能被解释。若切点在 toolResult 上,保留区会以一条孤儿结果开头,模型无从得知是哪次调用产生了它。findCutPoint() 的合法切点只含 user / assistant / bashExecution / custom(含 branchSummary);当回溯累计超预算的位置只有 toolResult 时,会回退到最近的合法切点(通常是携带 toolCall 的 assistant),把调用与结果一起保留。

Q5什么情况下会产生「拆分回合」(split turn)?Pi 如何处理?

当单个 user 回合的消息跨度本身超过 keepRecentTokens 时,切点落在该回合中间的 assistant 消息上(isSplitTurn = true,turnStartIndex 指向回合起点)。Pi 生成两份摘要合并为一条:其一为历史摘要(对更早消息,含旧 summary 的迭代更新,无则用 "No prior history.");其二为回合前缀摘要(TURN_PREFIX 模板,只覆盖回合开头到切点)。两者以 --- 和 Turn Context (split turn) 标题拼接,usage 相加,文件清单统一附加。

Q6手动 /compact 与阈值自动压缩,扩展如何统一介入?失败时系统如何降级?

两者都触发 session_before_compact 事件,扩展可读取 preparation(含 settings 与 fileOps)与 reason("manual" / "threshold" / "overflow"),返回 cancel 取消或返回自定义 compaction 内容。失败降级:摘要 stopReason 为 error 或 length、或响应含 toolCall 时判定失败;不追加条目、发 compaction_end(error)与 session_compact_failed,不调度内部重试;溢出恢复失败时保留 omission edits,已排队消息按普通规则继续。瞬态错误先经 retryAssistantCall() 按配置重试。