跳到主内容
创建时间:最后更新:作者:白川

上下文管理:装配、裁剪与保留

拆解模型实际收到的上下文来源,用 transformContext 与 prepareRequest 钩子裁剪和注入内容,并规划压缩策略。

上下文管理:装配、裁剪与保留

上下文是智能体应用里最贵的一块内存。你以为自己在传一段任务说明,模型收到的却是系统提示、项目规则、几十轮工具输出、几个 Skill 的描述、以及可能上百万 token 的历史。所有这些加起来才是"一次请求"。

这篇的目标很具体:你能说清楚模型每次请求到底收到了什么,并且能在两个层次上动手改它——一层是请求前的裁剪与注入,另一层是长任务里的压缩与保留。

用一个具体任务来说明:Pi 修 TypeScript 类型错误时反复读文件、跑 tsc,历史会迅速膨胀。这篇讲怎么让它不炸。

读它需要会用 TypeScript,并且对 Agent 循环有一个基本印象——模型发起调用,程序校验参数后执行,再把结果回填。读完你能打印出一次请求的上下文构成,并用两个钩子改写它。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

最终要跑通什么

  1. 打印出一次请求的上下文构成,确认每一块都来自你预期的来源。
  2. 用 transformContext 裁掉体积最大的历史段,同时保留任务状态。
  3. 触发一次压缩,确认摘要条目写进了会话树,而原始条目还在。

第一步:模型到底收到了什么

先把这件事说清楚,后面所有决策才有依据。官方文档的表述是:模型收到的是活动分支,不是整个会话文件;Pi 把这段历史与系统提示、上下文文件、可用工具、已加载的 Skill 描述组合在一起。展开成一份可核对的清单:

组成块来源是否每轮全量进入上下文
系统提示(preamble / 工具说明 / 规则 / 文档路径)
Pi 内置;SYSTEM.md 可整体替换,systemPromptOverride 可再改写
是
追加指令(addendum)
APPEND_SYSTEM.md
是,项目文件优先于 agent 目录同名文件
项目上下文(project_context)
AGENTS.md、CLAUDE.md 等上下文文件
是,在信任决策之后加载
工具定义
当前启用的工具集合
是,工具变化以系统消息形式声明
Skill 描述(name / description / location)
SKILL.md 的 frontmatter
是,只有描述
Skill 全文
SKILL.md 正文
否,按需加载
对话历史
活动分支的消息条目
是,模型收到活动分支而非全部分支
压缩摘要
CompactionEntry
是,替换被摘要的旧消息

最容易被误解的是最后两行。 Skill 的设计是"描述常驻、全文按需":启动时 Pi 只把每个 Skill 的名称、描述和路径写进系统提示,正文要等模型判断任务匹配时自己去读。这让 Skill 数量可以很多而不炸上下文。

同理,压缩不是删除。压缩会加入一段摘要并保留近期消息,但不删除原始会话条目。原始条目仍在会话树里,只是不再进入模型上下文。

第二步:三个钩子的分工

Agent Core 提供了三个可以改上下文的钩子,作用时机完全不同:

钩子位置拿到的数据适合做什么不适合做什么
transformContext
convertToLlm 之前
全部 AgentMessage[]
裁剪、注入、过滤
重建权威状态
convertToLlm
发请求前最后一步
AgentMessage[]
过滤 UI-only 消息、转换自定义消息
改变语义
prepareRequest
每次 provider 请求之前
{ context, model, thinkingLevel }
重建权威上下文、换模型
逐条筛选消息

消息流转的完整路径:

text
# 两步转换各管一段:先裁剪上下文,再转成模型格式
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM

两者的区别值得记牢:transformContext 拿到的是全部消息,包括系统消息;convertToLlm 负责把自定义角色过滤或转换掉,因为 LLM 只认 user、assistant、toolResult 三种。

prepareRequest 的位置更靠后,也更权威:

typescript
// 最权威的钩子:用权威来源整体替换上下文
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。

第三步:裁剪与注入

先做一个最小可用的裁剪器:保留系统消息和最近的历史,把中段压成一条占位消息。

typescript
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();
}

这个版本只适合演示。真实项目里"哪些内容必须保留"是业务决策。做成显式清单,比埋在代码里的魔法数字可靠:

内容保留策略理由
系统消息与工具声明
永远保留
承载提示与工具契约,丢了模型会乱调工具
当前用户任务
永远保留
这是模型的当前目标
已被修改的文件路径
永久保留
后续修改需要知道动过哪些文件
已确认的技术决策
永久保留
避免模型推翻已定结论
中段工具输出
裁剪或摘要
体积最大,信息密度最低
近期消息(10–20 条)
保留原文
上下文连贯性需要
早期完整输出
留在会话树里
需要时用 /tree 或重读文件回溯

"裁剪"和"压缩"是两回事。 裁剪是你在 transformContext 里手动做的,只影响这一次请求,不写进会话;压缩是 Pi 内置机制,会往会话树里追加一个 CompactionEntry,影响之后所有请求。两者可以叠加。

第四步:压缩机制

自动压缩的触发条件是一个简单不等式:

text
# 自动压缩的触发条件:剩余可用窗口不足以再塞一条消息时就该压缩
contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16384。也就是说,模型上下文窗口还剩不到 16K token 时,Pi 开始压缩。步骤是:向前回溯累积 token 估算直到 keepRecentTokens(默认 20000)→ 收集待摘要的消息 → 调 LLM 生成摘要 → 追加 CompactionEntry → 之后重建上下文时用摘要替换更早的消息。

阈值可以在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json 里配:

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 精确覆盖,键是大小写敏感的完整值:

json
{
	"compaction": {
		"reserveTokens": 16384,
		"keepRecentTokens": 20000,
		"modelOverrides": {
			"some-provider/big-model": {
				"reserveTokens": 400000
			}
		}
	}
}

覆盖项的查找顺序是:模型覆盖 → 普通设置 → 内置默认值,两者各自独立回退。enabled 是全局的,不参与模型覆盖。

第五步:扩展钩子:自己接管压缩

压缩有两个扩展事件可以挂。session_before_compact 在自动压缩或 /compact 之前触发,可以取消压缩或提供自己的摘要:

typescript
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 每次都会重新读。

typescript
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 做裁剪。

谁负责什么

职责由谁承担说明
组装系统提示、项目上下文、工具定义、Skill 描述
Pi
宿主可通过 systemPromptOverride / APPEND_SYSTEM.md / 上下文文件影响
把活动分支转成模型消息
Pi
SessionManager 是权威来源
自动压缩与阈值判断
Pi
compaction.* 配置
请求前的裁剪与注入
宿主
transformContext / prepareRequest
保留策略(什么必须留下)
宿主
业务决策,Pi 只提供机制
上下文体积的最终约束
宿主
模型上下文上限是硬边界,超了会溢出报错

三种场景的可观察结果

上下文管理最容易变成"看起来在跑,其实在烧钱"。给几个场景定可观察结果:

场景触发方式可观察结果
成功(裁剪生效)
提交一个会读很多文件的任务
transformContext 被调用;请求里的历史消息条数不随轮次线性增长;任务仍然完成
成功(压缩生效)
让上下文逼近窗口上限
出现 compaction_start / compaction_end;会话树里多了一个 compaction 条目;原始消息条目仍在文件里
失败(注入出错)
让 prepareRequest 抛异常
请求失败,agent_settled 到达且 aborted=false;错误可观测,不会静默吞掉
取消(压缩中被取消)
压缩中调用 session.abort()
compaction_end 的 aborted=true;没有写入 compaction 条目;session_compact_failed 触发

第一行值得展开:验证裁剪不能只看"任务完成了"。要在 transformContext 里打印裁剪前后的消息条数,才能确认裁剪真的生效了。否则模型可能只是碰巧答对。

你现在能做什么

  • 在 transformContext 里打印消息条数和角色分布,跑一个多轮任务,确认条数不随轮次线性增长——这是唯一能证明裁剪生效的证据。
  • 写一个 transformContext 钩子,保留系统消息 + 最近 10 条 + 一条占位消息,然后跑一个 20 轮的任务,确认模型仍能完成(说明占位消息的信息量够用)。
  • 让上下文逼近窗口上限(用 compaction.modelOverrides 把 reserveTokens 调小可以更快触发),观察 compaction_start / compaction_end 事件和会话文件里新增的 compaction 条目。
  • 用 session_before_compact 钩子取消一次自动压缩(reason === "threshold" 且 token 数不高时返回 { cancel: true }),确认压缩没有发生、会话文件里也没有新条目。

排障速查

现象检查点处理
请求体越来越大
transformContext 是否真的被调用
SDK 层要挂在 session.agent.transformContext 上;确认裁剪逻辑生效
裁剪后模型忘了任务
保留策略是否漏了当前任务与已改文件
把"任务目标 / 已改文件 / 已定决策"列入永久保留清单
压缩没触发
compaction.enabled 与阈值
默认阈值是 contextWindow - 16384;大窗口模型要用 modelOverrides 调 reserveTokens
手动 /compact 也不行
provider 是否可用
压缩需要模型生成摘要;provider 不可用时先修 provider 再重试
压缩后原始消息不见了
是否误以为压缩会删条目
压缩只写摘要条目,原始条目仍在会话树里,用 SessionManager.getEntries() 可查
Skill 全文一直不进上下文
是否期待模型自动加载
启动时只进描述;模型判断匹配后自己读 SKILL.md,也可以用 /skill:name 强制
prepareRequest 里改了消息但没生效
返回值形态
必须返回 { context };返回 undefined 表示保持不变
同一文件被反复读取
上下文里没有已读文件清单
把已读/已改文件路径注入到上下文里,减少重复读

参考资料