Agent CLI深水区 - 上下文预算压缩记忆 · AI Agent
Agent CLI 深水区 04:上下文预算、压缩与记忆
上下文工程是 Agent 的隐形核心
Agent 能不能“懂项目”,很大程度取决于上下文工程。
模型看到的不是你的整个电脑,而是 messages:
代码块收起展开
system prompt
project instructions
memory
recent transcript
retrieved docs
tool results
current user input所以问题不是“塞不塞上下文”,而是:
在有限 token 预算里,放哪些信息,按什么顺序放,超限时删什么,压缩什么。
| 图中节点 | 实际包含 |
|---|---|
| 候选上下文 | rules、memory、history、RAG、tool result、current user input |
| 保护 P0/P1 | system、当前用户输入、关键工具结果、项目规则 |
| 压缩低优先级 | transcript、memory、RAG 片段、长工具输出 |
这张图对应源码里的三个核心函数:estimateTokens()、fitPartsIntoBudget()、compressPartToFit()。
ContextBudget
先把预算建模。
代码块收起展开
// ContextBudget = 一次模型请求的上下文预算。
// 重点:输入不能把上下文窗口塞满,要给模型输出预留空间。
type ContextBudget = {
maxInputTokens: number; // 模型上下文窗口最大输入 token。
reservedOutputTokens: number; // 给模型回答预留的 token。
systemTokens: number; // system prompt 预计占用。
memoryTokens: number; // 记忆预计占用。
historyTokens: number; // 历史对话预计占用。
retrievalTokens: number; // 检索资料预计占用。
toolResultTokens: number; // 工具结果预计占用。
};
// getAvailableInputTokens = 当前真正可用于输入 messages 的 token。
function getAvailableInputTokens(budget: ContextBudget) {
return budget.maxInputTokens - budget.reservedOutputTokens;
}为什么要保留 output tokens?
因为模型上下文窗口通常包含输入和输出。如果输入塞满,模型就没空间回答。
上下文优先级
不是所有信息平等。
| 优先级 | 内容 | 原因 |
|---|---|---|
| P0 | system 安全规则 | 不能丢 |
| P0 | 当前用户输入 | 任务本体 |
| P1 | 项目规则 | 决定行为边界 |
| P1 | 当前工具结果 | Agent 下一步依据 |
| P2 | 相关 memory | 用户偏好和稳定事实 |
| P2 | RAG 召回片段 | 私有知识 |
| P3 | 最近会话 | 连续性 |
| P4 | 更早会话 | 可压缩 |
| P5 | 长工具输出 | 优先摘要/截断 |
ContextBuilder 完整结构
代码块收起展开
// buildContext = 上下文构建总入口。
// 它把 system、项目规则、记忆、检索、历史、当前用户输入拼成 messages。
export async function buildContext(input: BuildContextInput): Promise<Message[]> {
// 根据模型上下文窗口创建预算。
const budget = createBudget(input.model);
// 每一块上下文都先抽象成 ContextPart,带优先级和 token 估算。
const parts: ContextPart[] = [
await systemPart(),
await projectInstructionsPart(input.cwd),
await memoryPart(input.userInput),
await retrievalPart(input.userInput),
await recentTranscriptPart(input.sessionId),
currentUserPart(input.userInput)
];
// 根据预算选择哪些块完整保留、哪些压缩、哪些丢弃。
const selected = fitPartsIntoBudget(parts, budget);
// 最终转成模型 messages。
return selected.map(part => ({
role: part.role,
content: part.content
}));
}这里引入 ContextPart:
代码块收起展开
// ContextPart = 一个可独立选择/压缩的上下文块。
// 例如 system rules、memory、retrieved docs、recent transcript 都是 part。
type ContextPart = {
name: string; // 块名称,例如 "memory"、"retrieval"。
role: "system" | "user" | "assistant" | "tool"; // 放进 messages 时的 role。
priority: number; // 数字越小优先级越高,越不能丢。
content: string; // 这块上下文正文。
estimatedTokens: number; // 估算 token,用于预算选择。
compressible: boolean; // 超预算时是否允许摘要压缩。
};有了 priority 和 compressible,才能做预算裁剪。
ContextPart 字段不是形式主义,每个字段都对应一个工程问题:
| 字段 | 解决的问题 |
|---|---|
name | debug 时知道是哪块上下文占了预算 |
role | 转成模型 messages 时保持协议正确 |
priority | 超预算时先保谁、后删谁 |
content | 真正给模型看的内容 |
estimatedTokens | 预算裁剪依据 |
compressible | 决定是摘要、截断,还是直接丢弃 |
fitPartsIntoBudget
代码块收起展开
// fitPartsIntoBudget = 在预算内选择上下文块。
// 策略:先按优先级选,高优先级保留;低优先级必要时压缩或丢弃。
function fitPartsIntoBudget(parts: ContextPart[], budget: ContextBudget) {
// 计算输入可用 token。
const max = getAvailableInputTokens(budget);
// 按 priority 排序,P0/P1 先处理。
const sorted = [...parts].sort((a, b) => a.priority - b.priority);
const selected: ContextPart[] = [];
let used = 0;
for (const part of sorted) {
// 如果完整放得下,直接保留。
if (used + part.estimatedTokens <= max) {
selected.push(part);
used += part.estimatedTokens;
continue;
}
// 如果放不下但允许压缩,就尝试压缩到剩余预算内。
if (part.compressible) {
const compressed = compressPartToFit(part, max - used);
if (compressed) {
selected.push(compressed);
used += compressed.estimatedTokens;
}
}
}
// 选择时按优先级,但输出 messages 时要恢复合理顺序。
// 例如 system 仍然要在 user 前面。
return selected.sort((a, b) => originalOrder(a.name) - originalOrder(b.name));
}注意:选择时按优先级,最终输出时按合理顺序。不要让低优先级历史插到 system 前面。
Token 估算
入门可以粗略估:
代码块收起展开
// estimateTokens = 粗略 token 估算。
// 中文/英文/tokenizer 差异很大,这只是入门兜底。
function estimateTokens(text: string) {
return Math.ceil(text.length / 2);
}更稳的做法是用模型 tokenizer。但即使粗略估,也比完全不估强。
Project Instructions
项目规则通常来自:
AGENTS.mdCLAUDE.mdREADME.md.cursor/rules- 自定义配置
读取时要限制:
代码块收起展开
// projectInstructionsPart = 读取项目规则文件。
// 这些规则会影响 Agent 如何读写代码,所以优先级高。
async function projectInstructionsPart(cwd: string): Promise<ContextPart> {
// 常见项目规则来源。
const candidates = ["AGENTS.md", "CLAUDE.md", "README.md"];
const chunks: string[] = [];
for (const file of candidates) {
// 文件不存在就跳过。
const content = await readFileIfExists(resolve(cwd, file));
if (content) {
// 每个规则文件都限长,防止 README 巨大。
chunks.push(`# ${file}\n${content.slice(0, 12_000)}`);
}
}
const content = chunks.join("\n\n");
return {
name: "project_instructions",
role: "system",
priority: 1,
content,
estimatedTokens: estimateTokens(content),
compressible: true
};
}项目规则可以压缩,但不能完全丢。
Memory 检索
长期记忆不能全塞。要按相关性找。
代码块收起展开
// Memory = 长期记忆的一条记录。
// 记忆不是聊天历史,而是跨任务可复用的稳定事实/偏好/决策。
type Memory = {
id: string; // 记忆 ID。
scope: "user" | "project"; // 用户级还是项目级。
type: "preference" | "fact" | "decision" | "todo"; // 记忆类型。
content: string; // 记忆正文。
updatedAt: string; // 更新时间,用于排序和淘汰旧信息。
};
// memoryPart = 选出与当前任务相关的记忆,拼成上下文块。
async function memoryPart(userInput: string): Promise<ContextPart> {
// 加载所有记忆,但不会全量塞给模型。
const memories = await loadAllMemory();
// 按相关性排序,只取前 12 条。
const ranked = rankMemory(memories, userInput).slice(0, 12);
// 带上 type,模型能区分偏好、事实、决策、任务项。
const content = ranked
.map(memory => `- [${memory.type}] ${memory.content}`)
.join("\n");
return {
name: "memory",
role: "system",
priority: 2,
content: `Relevant long-term memory:\n${content}`,
estimatedTokens: estimateTokens(content),
compressible: true
};
}简单相关性:
代码块收起展开
// rankMemory = 入门版记忆相关性排序。
// 用查询词和记忆内容的词重叠数打分。
function rankMemory(memories: Memory[], query: string) {
// 把用户输入切词成集合。
const words = new Set(query.toLowerCase().split(/\s+/));
return memories
.map(memory => ({
memory,
// 统计 memory.content 中有多少词出现在 query 里。
score: memory.content
.toLowerCase()
.split(/\s+/)
.filter(word => words.has(word)).length
}))
// 分数高的排前面。
.sort((a, b) => b.score - a.score)
.map(item => item.memory);
}后续可以换成 embedding 检索。
Transcript 压缩
会话历史最容易膨胀。
策略:
代码块收起展开
保留最近 8-12 条
更早历史压成 summary
summary 只保留目标、决策、修改、失败、下一步代码块收起展开
// recentTranscriptPart = 构建会话历史上下文块。
// 策略:最近消息原文保留,更早历史压成 summary。
async function recentTranscriptPart(sessionId: string): Promise<ContextPart> {
// 读取完整会话记录。
const transcript = await loadTranscript(sessionId);
// 最近 12 条保留原文,保证连续性。
const recent = transcript.slice(-12);
// 更早的消息进入摘要。
const older = transcript.slice(0, -12);
// 如果有旧消息,加载或创建摘要。
const summary = older.length
? await loadOrCreateSummary(sessionId, older)
: "";
const content = [
summary ? `Previous summary:\n${summary}` : "",
`Recent messages:\n${formatMessages(recent)}`
].filter(Boolean).join("\n\n");
return {
name: "transcript",
role: "system",
priority: 3,
content,
estimatedTokens: estimateTokens(content),
compressible: true
};
}压缩摘要的 prompt
压缩不是普通总结。要面向后续执行。
代码块收起展开
Summarize the previous agent session for continuation.
Keep:
1. User's goal.
2. Files inspected.
3. Files modified.
4. Commands run and meaningful results.
5. Decisions made.
6. Failed attempts and why they failed.
7. Current next step.
Drop:
1. Chit-chat.
2. Repeated logs.
3. Full command output unless essential.
4. Outdated speculation.中文理解:
摘要不是写读后感,而是给下一轮 Agent 接着干活用的交接单。
压缩摘要的取舍表:
| 信息 | 保留方式 | 原因 |
|---|---|---|
| 用户最终目标 | 原文或短句 | 任务方向不能漂 |
| 已修改文件 | 路径 + 修改意图 | 后续要避免重复改 |
| 关键错误 | 错误码 + 根因 | 防止下一轮重踩 |
| 决策结论 | 结论 + 为什么 | 保持架构连续 |
| 命令输出 | 只留关键结果 | 原始输出太占上下文 |
| 闲聊/重复催促 | 丢弃 | 对执行没有帮助 |
| 大段源码 | 用路径和行号替代 | 需要时重新读真源 |
Tool Result 预算
工具结果尤其危险。比如 grep 可能返回上万行。
处理策略:
| 工具 | 策略 |
|---|---|
read_file | 限制文件大小,必要时分段读 |
grep | 返回前 N 条 + count |
bash | stdout/stderr 截断 |
web_fetch | 提取正文 + 限长 |
mcp | 根据 tool schema 设置输出限制 |
代码块收起展开
// formatToolResult = 控制工具结果放进上下文的长度。
// 工具输出很容易超长,例如 grep、test、日志。
function formatToolResult(result: ToolResult) {
const max = 16_000;
// 不超限就原样返回。
if (result.content.length <= max) {
return result.content;
}
// 超限时截断,并附上 summary 告诉模型这不是完整输出。
return [
result.content.slice(0, max),
`\n[Tool output truncated. Summary: ${result.summary}]`
].join("");
}RAG 片段拼装
RAG 结果必须带来源:
代码块收起展开
// RetrievedChunk = RAG 检索出来的一段资料。
// 必须保留来源,否则模型回答无法追溯。
type RetrievedChunk = {
sourcePath: string; // 来源文件路径。
titlePath: string; // 文档标题路径或层级标题。
content: string; // 片段正文。
score: number; // 检索相关性分数。
};
// formatRetrievedChunks = 把 RAG 片段格式化给模型。
function formatRetrievedChunks(chunks: RetrievedChunk[]) {
return chunks.map((chunk, index) => `
[Source ${index + 1}]
path: ${chunk.sourcePath}
title: ${chunk.titlePath}
score: ${chunk.score}
content:
${chunk.content}
`).join("\n");
}Prompt 里要要求:
代码块收起展开
Answer using the provided sources.
If the sources do not contain the answer, say you are not sure.
Cite source paths when making factual claims.上下文顺序的工程经验
推荐最终顺序:
代码块收起展开
1. system base rules
2. safety and permission rules
3. project instructions
4. relevant memory
5. previous summary
6. recent transcript
7. retrieved sources
8. latest tool result
9. current user input原因:
- 规则先出现,确立行为边界。
- 当前用户输入最后出现,防止被历史淹没。
- 检索资料靠近用户问题,方便模型引用。
- 工具结果靠近下一次模型调用,方便继续推理。
Context Debug
一定要能导出最终 prompt。
代码块收起展开
// saveContextDebug = 保存最终发给模型的 messages。
// 它是排查“模型到底看到了什么”的关键工具。
async function saveContextDebug(sessionId: string, messages: Message[]) {
await writeFile(
`.agent/debug/${sessionId}-context.json`,
JSON.stringify(messages, null, 2),
"utf8"
);
}没有 context debug,就很难判断:
- 模型是不是没看到资料?
- 是不是历史太长挤掉了规则?
- RAG 片段是不是无关?
- 工具结果是不是被截断过头?
上下文问题诊断矩阵:
| 现象 | 优先怀疑 | 看什么证据 | 修复方向 |
|---|---|---|---|
| 模型忘记用户偏好 | memory 没进上下文 | debug context 是否包含 memory | 提高相关 memory 优先级 |
| 模型违背项目规则 | instructions 被压缩/丢失 | system messages 顺序和长度 | P1 规则不可完全丢 |
| 引用资料不相关 | RAG 召回错 | chunk score、sourcePath、titlePath | 改检索 query / rerank |
| 工具结果被忽略 | tool result 太长或太早 | tool result 是否截断、位置 | 摘要后靠近当前 user input |
| 长任务突然跑偏 | transcript summary 丢决策 | summary 是否保留目标/文件/下一步 | 改压缩 prompt |
| 模型没空间回答 | 未预留 output tokens | input token 接近窗口上限 | 增大 reservedOutputTokens |
Memory 进入上下文的治理策略
长期记忆不是越多越好。进入上下文前,memory 必须经过筛选、排序、冲突处理和过期判断,否则它会变成比普通历史更危险的噪声。
| 阶段 | 判断问题 | 必须保留的字段 | 不合格表现 |
|---|---|---|---|
| 写入 | 这条信息是否长期稳定 | type、scope、sourceTraceId、confidence | 把一句临时聊天写成永久偏好 |
| 检索 | 它和当前任务是否相关 | queryTerms、matchedReason、score | 所有 memory 固定塞进 prompt |
| 排序 | 它比 RAG、规则、当前问题更重要吗 | priority、recency、sourceType | 旧偏好压过当前明确要求 |
| 冲突 | 新旧记忆是否互相矛盾 | supersedes、conflictWith | 两条相反偏好同时出现 |
| 过期 | 是否已经不适用于当前项目 | expiresAt、projectId、validUntil | 旧项目决策污染新项目 |
| 复盘 | 为什么这条记忆被放进上下文 | contextReason、rank | 错答后不知道哪条记忆影响了模型 |
推荐把 memory 分成几类,不同类别用不同策略:
| 类型 | 例子 | 进入上下文策略 |
|---|---|---|
preference | 用户不喜欢废话、偏好高密度内容 | 高优先级,但要允许当前指令覆盖 |
project_fact | 当前项目路径、启动命令、模块边界 | 只在相关项目任务进入 |
decision | 为什么采用 MCP thin adapter | 与架构讨论相关时进入 |
lesson | 某类 bug 的根因和修法 | 调试相似问题时进入 |
temporary_state | 本轮任务进行到哪里 | 只在当前会话/短期任务进入 |
最小 memory 选择算法:
代码块收起展开
// selectMemories = 从长期记忆里选出本轮真正该给模型看的部分。
function selectMemories(memories: MemoryRecord[], task: TaskContext) {
return memories
.filter(m => m.scope === "global" || m.scope === task.projectId) // 不跨项目乱带
.filter(m => !m.expiresAt || m.expiresAt > task.now) // 过期记忆不进上下文
.map(m => ({
memory: m,
score: relevance(m, task) + recencyBoost(m) + confidenceBoost(m)
}))
.filter(item => item.score >= task.memoryThreshold)
.sort((a, b) => b.score - a.score)
.slice(0, task.maxMemories)
.map(item => item.memory);
}验收方式:同一个任务跑两次,一次带 memory,一次不带 memory。带 memory 的版本必须更贴合用户偏好或项目事实;如果只是更长、更啰嗦,说明 memory 没有治理好。
上下文工程的常见失败
| 失败 | 表现 | 修复 |
|---|---|---|
| 无预算 | prompt 越来越长 | ContextBudget |
| 无优先级 | 重要规则被挤掉 | ContextPart priority |
| 无压缩 | 长任务中断 | transcript summary |
| memory 乱塞 | 模型被旧事实误导 | 相关性检索 |
| RAG 无引用 | 答案不可查 | source path 强制保留 |
| 工具输出太长 | 模型忽略重点 | 截断 + summary |
源码验收门槛
上下文预算模块要能证明“为什么保留这个、丢掉那个”。只要解释不了,后续所有 RAG、memory、tool result 都会变成不可控噪声。
| 验收项 | 必须看到的证据 | 不合格表现 |
|---|---|---|
| 预算预留 | 输入预算扣除了 reservedOutputTokens | 输入塞满导致模型无空间回答 |
| 优先级保护 | P0/P1 永不被完全丢弃 | system 或项目规则被压缩没了 |
| 压缩保真 | summary 保留目标、文件、决策、未完成事项 | 长任务压缩后方向丢失 |
| 检索引用 | RAG part 保留 sourcePath/title/score | 模型引用资料但无法追溯 |
| 记忆隔离 | memory 按 type/scope/time 进入上下文 | 过期偏好和当前事实混用 |
| 快照复盘 | 每次模型请求可导出 context debug | 无法解释一次错误回答 |
最小回归用例:同一个长任务连续 20 轮后,检查项目规则仍在、当前目标仍在、最近工具结果仍在、旧低相关记录被降级或压缩。这个用例比单轮 prompt 好看更能证明上下文工程质量。
读源码时看上下文系统
重点找:
- system prompt 从哪里来。
- 项目文件读取规则。
- memory 如何筛选。
- history 如何裁剪。
- token 如何估算。
- auto compact 什么时候触发。
- 压缩摘要保留什么。
- tool result 如何截断。
- debug context 能不能导出。
这些比“prompt 写得好不好看”重要得多。