第 9 讲系统提示词、上下文组装与压缩(Compaction)
Agent 的「灵魂装配线」:系统提示词如何分层装配、项目上下文文件如何被发现与合并、技能描述如何按需加载,以及上下文逼近上限时如何把旧历史压缩成结构化摘要。
学习目标
- 说清
buildSystemPromptSections()的装配顺序,以及每段为什么用 XML 标签包裹。 - 解释 prompt cache 与「稳定前缀」的关系,掌握
diffSystemPromptSections()的差量补丁机制。 - 复述 context files 的目录向上发现规则、同名候选优先级与 worktree 遮蔽处理。
- 算得清技能注入的上下文经济账:为什么只放「名字 + 描述 + 路径」。
- 用
shouldCompact()与findCutPoint()解释压缩何时触发、在哪里下刀、保多少。 - 区分「请求消息数组被压缩」与「会话树原始条目保留」,并说出分支摘要的生成流程。
§9.1系统提示词装配线:preamble + XML sections
先把整件事类比成一条装配线:Pi 不把系统提示词当成一整块字符串拼出来,而是当成若干个有名有姓的工位(section),每个工位负责一段内容,最后按固定顺序装进 SystemMessage.sections。也就是说,它更像「带标签的配置分区」,而不是一次性的字符串模板拼接。
核心入口在 packages/coding-agent/src/core/system-prompt.ts。preamble 是唯一不带标签的段落;其余每一段都被同名 XML 标签包裹——这是刻意设计:标签让模型在后续收到「同名段落的新版本」时,能精确匹配到旧段落并理解「这是替换,不是追加」。
// 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)。
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 消息,而不是重写头部的完整提示词。
/** 模型当前已有的 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()剥掉。
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;
}
// 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完全关闭发现(测试与沙箱场景)。
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%,也节省了九成以上的固定开销。描述同时是路由信息:它决定模型何时去读全文,所以要写清「做什么」和「何时用」。
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):
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 消息,连同其结果一起保留。
// 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 讲展开):
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 每次按重建后的投影重新计算,保证记录的是真实被替换的上下文规模。
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 或换模型;会话文件永远处于合法状态。
/** 摘要响应无法安全持久化时返回错误信息;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 级配置优先于项目级通用值):
{
"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,替换只发生在出站投影。
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 事件处理器接到这条链上:
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 导航前 | 取消导航,或在用户要求摘要时提供自定义分支摘要 |
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 返回——框架负责切点计算与落盘,你只负责「怎么总结」。
动手实践
- 观察装配结果。在一个测试项目中分别放置
AGENTS.md(上层目录)与AGENTS.override.md(当前目录),再建.pi/APPEND_SYSTEM.md;用/export导出会话,检查系统提示词中的<project_instructions path="…">顺序、<addendum>与<cwd>段落是否与本讲规则一致。 - 人为触发自动压缩。在
.pi/settings.json中把compaction.reserveTokens调到接近上下文窗口的值,使一次长对话即越线;触发后用/export对比压缩前后的请求消息数组,找出compactionSummary消息与保留边界,并核对会话 JSONL 中原始条目仍然存在。 - 写一个自定义压缩扩展。订阅
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() 按配置重试。