上下文管理:装配、裁剪与保留
拆解模型实际收到的上下文来源,用 transformContext 与 prepareRequest 钩子裁剪和注入内容,并规划压缩策略。
上下文管理:装配、裁剪与保留
上下文是智能体应用里最贵的一块内存。你以为自己在传一段任务说明,模型收到的却是系统提示、项目规则、几十轮工具输出、几个 Skill 的描述、以及可能上百万 token 的历史。所有这些加起来才是"一次请求"。
这篇的目标很具体:你能说清楚模型每次请求到底收到了什么,并且能在两个层次上动手改它——一层是请求前的裁剪与注入,另一层是长任务里的压缩与保留。
用一个具体任务来说明:Pi 修 TypeScript 类型错误时反复读文件、跑 tsc,历史会迅速膨胀。这篇讲怎么让它不炸。
读它需要会用 TypeScript,并且对 Agent 循环有一个基本印象——模型发起调用,程序校验参数后执行,再把结果回填。读完你能打印出一次请求的上下文构成,并用两个钩子改写它。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 打印出一次请求的上下文构成,确认每一块都来自你预期的来源。
- 用
transformContext裁掉体积最大的历史段,同时保留任务状态。 - 触发一次压缩,确认摘要条目写进了会话树,而原始条目还在。
第一步:模型到底收到了什么
先把这件事说清楚,后面所有决策才有依据。官方文档的表述是:模型收到的是活动分支,不是整个会话文件;Pi 把这段历史与系统提示、上下文文件、可用工具、已加载的 Skill 描述组合在一起。展开成一份可核对的清单:
最容易被误解的是最后两行。 Skill 的设计是"描述常驻、全文按需":启动时 Pi 只把每个 Skill 的名称、描述和路径写进系统提示,正文要等模型判断任务匹配时自己去读。这让 Skill 数量可以很多而不炸上下文。
同理,压缩不是删除。压缩会加入一段摘要并保留近期消息,但不删除原始会话条目。原始条目仍在会话树里,只是不再进入模型上下文。
第二步:三个钩子的分工
Agent Core 提供了三个可以改上下文的钩子,作用时机完全不同:
消息流转的完整路径:
# 两步转换各管一段:先裁剪上下文,再转成模型格式
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM两者的区别值得记牢:transformContext 拿到的是全部消息,包括系统消息;convertToLlm 负责把自定义角色过滤或转换掉,因为 LLM 只认 user、assistant、toolResult 三种。
prepareRequest 的位置更靠后,也更权威:
// 最权威的钩子:用权威来源整体替换上下文
agent.prepareRequest = async ({ context }) => ({
// 必须返回 { context };返回 undefined 表示保持不变
context: { ...context, messages: await session.loadModelContext() },
});注意文档里那句提醒:prepareRequest 不轮询队列。运行期间排进来的 steering 消息要等下一次正常的 steering 轮询才会被处理。
这三个钩子在 SDK 层都是可写属性:session.agent.transformContext、session.agent.convertToLlm、session.agent.prepareRequest。
第三步:裁剪与注入
先做一个最小可用的裁剪器:保留系统消息和最近的历史,把中段压成一条占位消息。
import { createAgentSession } from "@earendil-works/pi-coding-agent";
// 保留最近多少条原文;中段其余部分被压成一条占位消息
const KEEP_RECENT = 12;
const { session } = await createAgentSession();
session.agent.transformContext = async (messages) => {
// 系统消息承载提示与工具声明,永远保留
const system = messages.filter((m) => m.role === "system");
const rest = messages.filter((m) => m.role !== "system");
// 还没超阈值就原样返回:裁剪本身会让模型丢掉信息
if (rest.length <= KEEP_RECENT) return messages;
const head = rest.slice(0, rest.length - KEEP_RECENT);
const tail = rest.slice(rest.length - KEEP_RECENT);
// 中段只留一条提示,让模型知道这里被裁过
const placeholder = {
role: "user" as const,
content: `[已裁剪 ${head.length} 条早期消息:包含初始任务与历史工具输出]`,
timestamp: Date.now(),
};
// 注意这里只影响这一次请求,不写进会话
return [...system, placeholder, ...tail];
};
try {
await session.prompt("修复 src/types.ts 里 UserProfile 的类型错误");
console.log("完成");
} finally {
session.dispose();
}这个版本只适合演示。真实项目里"哪些内容必须保留"是业务决策。做成显式清单,比埋在代码里的魔法数字可靠:
"裁剪"和"压缩"是两回事。 裁剪是你在 transformContext 里手动做的,只影响这一次请求,不写进会话;压缩是 Pi 内置机制,会往会话树里追加一个 CompactionEntry,影响之后所有请求。两者可以叠加。
第四步:压缩机制
自动压缩的触发条件是一个简单不等式:
# 自动压缩的触发条件:剩余可用窗口不足以再塞一条消息时就该压缩
contextTokens > contextWindow - reserveTokensreserveTokens 默认 16384。也就是说,模型上下文窗口还剩不到 16K token 时,Pi 开始压缩。步骤是:向前回溯累积 token 估算直到 keepRecentTokens(默认 20000)→ 收集待摘要的消息 → 调 LLM 生成摘要 → 追加 CompactionEntry → 之后重建上下文时用摘要替换更早的消息。
阈值可以在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json 里配:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}关掉 "enabled": false 只停用自动压缩,/compact 手动命令照常可用。
三个容易记错的细节:
压缩点不能落在工具结果上。 合法切点是用户消息、助手消息、bashExecution 消息、自定义消息、分支摘要消息和压缩摘要消息;工具结果必须和它的工具调用待在一起,所以不能作为切点。
反复压缩的起点会前移。 第二次压缩的待摘要区间从上一次压缩保留的边界开始算,而不是从会话开头。
一次用户消息跨太长时会拆分。 切点落到区间内部的助手消息上时,Pi 会生成两段摘要——"历史摘要"和"这一段前缀的摘要",再拼成一段,中间用 **Turn Context (split turn):** 分隔。
压缩摘要的格式是固定的:Goal、Constraints & Preferences、Progress、Key Decisions、Next Steps,压缩摘要还多一段 Critical Context,文件列表追加在末尾(<read-files> / <modified-files> 标签)。
不同模型的上下文窗口差别很大。用 compaction.modelOverrides 按 provider/modelId 精确覆盖,键是大小写敏感的完整值:
{
"compaction": {
"reserveTokens": 16384,
"keepRecentTokens": 20000,
"modelOverrides": {
"some-provider/big-model": {
"reserveTokens": 400000
}
}
}
}覆盖项的查找顺序是:模型覆盖 → 普通设置 → 内置默认值,两者各自独立回退。enabled 是全局的,不参与模型覆盖。
第五步:扩展钩子:自己接管压缩
压缩有两个扩展事件可以挂。session_before_compact 在自动压缩或 /compact 之前触发,可以取消压缩或提供自己的摘要:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_before_compact", async (event) => {
const { preparation, reason, willRetry } = event;
// preparation.messagesToSummarize —— 待摘要的消息
// preparation.firstKeptEntryId —— 保留区从哪里开始
// preparation.tokensBefore —— 压缩前的上下文 token 数
// reason 是 "manual" | "threshold" | "overflow"
// 拒绝压缩:这次压缩不该发生
if (reason === "threshold" && preparation.tokensBefore < 50_000) {
return { cancel: true };
}
return {
compaction: {
summary: `## Goal\n继续修复类型错误\n\n## Next Steps\n1. 重跑 tsc 确认`,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
},
};
});
pi.on("session_compact_failed", async (event) => {
// reason / errorMessage / aborted / willRetry / fromExtension
// 把失败和取消都记进你自己的遥测
});
}取消压缩时用 { cancel: true }。想自己写摘要就返回 { compaction: { ... } },firstKeptEntryId 必须提供——它标出保留区从哪里开始。如果一条都不保留,官方做法是把这个字段填成压缩条目自己的 ID。
这个钩子的价值在于:你可以把"哪些信息值得留下"变成业务规则,而不是完全交给模型自由总结。比如任务里最重要的是"改了哪些文件",可以在摘要里显式保留这一段。
第六步:用 prepareRequest 重建权威上下文
transformContext 做的是"每次请求都重新算一遍",prepareRequest 做的是"每次请求都从权威来源重建"。差别在有副作用的场景下很关键:从数据库读项目状态时,缓存在 transformContext 里容易过期,而 prepareRequest 每次都会重新读。
import { createAgentSession, type AgentMessage } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
// 保留原有的钩子,先跑它再叠加我们的逻辑
const previousPrepareRequest = session.agent.prepareRequest;
session.agent.prepareRequest = async (request, signal) => {
const result = await previousPrepareRequest?.(request, signal);
const context = result?.context ?? request.context;
// 从权威来源重建:会话投影 + 当前项目状态
const projection = session.sessionManager.buildSessionProjection();
// 每次请求都重新读:prepareRequest 不轮询队列,但每次请求都会触发
const projectState = await loadProjectState(signal);
const messages: AgentMessage[] = [
...projection.messages,
{
// 用 XML 标签包起来,让模型能分辨哪段是注入的外部状态
role: "user",
content: `<project-state>\n${projectState}\n</project-state>`,
timestamp: Date.now(),
},
];
// 必须返回 { context }:返回 undefined 表示保持原上下文不变
return { ...result, context: { ...context, messages } };
};
async function loadProjectState(signal?: AbortSignal): Promise<string> {
// 实际项目里这里读数据库、读配置文件或调用内部 API
return "分支: feature/fix-types\n已修改文件: src/types.ts";
}
try {
await session.prompt("继续修复类型错误");
} finally {
session.dispose();
}注意 prepareRequest 的返回值形态:返回 { context } 会替换本次请求的上下文,返回 undefined 则保持不变。文档明确说它"在每次对话型 provider 请求之前运行,包括第一次"。
这个钩子不该用来做过滤。 它拿到的是整个请求上下文,影响面比 transformContext 大得多。用它做注入和状态重建,用 transformContext 做裁剪。
谁负责什么
三种场景的可观察结果
上下文管理最容易变成"看起来在跑,其实在烧钱"。给几个场景定可观察结果:
第一行值得展开:验证裁剪不能只看"任务完成了"。要在 transformContext 里打印裁剪前后的消息条数,才能确认裁剪真的生效了。否则模型可能只是碰巧答对。
你现在能做什么
- 在
transformContext里打印消息条数和角色分布,跑一个多轮任务,确认条数不随轮次线性增长——这是唯一能证明裁剪生效的证据。 - 写一个
transformContext钩子,保留系统消息 + 最近 10 条 + 一条占位消息,然后跑一个 20 轮的任务,确认模型仍能完成(说明占位消息的信息量够用)。 - 让上下文逼近窗口上限(用
compaction.modelOverrides把reserveTokens调小可以更快触发),观察compaction_start/compaction_end事件和会话文件里新增的compaction条目。 - 用
session_before_compact钩子取消一次自动压缩(reason === "threshold"且 token 数不高时返回{ cancel: true }),确认压缩没有发生、会话文件里也没有新条目。