Agent CLI深水区 - 上下文预算压缩记忆 · AI Agent

Agent CLI 深水区 04:上下文预算、压缩与记忆

上下文工程是 Agent 的隐形核心

Agent 能不能“懂项目”,很大程度取决于上下文工程。

模型看到的不是你的整个电脑,而是 messages:

代码块TEXT · 7 行收起展开
system prompt
project instructions
memory
recent transcript
retrieved docs
tool results
current user input

所以问题不是“塞不塞上下文”,而是:

在有限 token 预算里,放哪些信息,按什么顺序放,超限时删什么,压缩什么。

上下文预算决策流 候选上下文 估算 token 预算判断 最终输出 保护 P0/P1 压缩低优先级 丢弃低相关 继续降级 未超预算 超预算
图中节点实际包含
候选上下文rules、memory、history、RAG、tool result、current user input
保护 P0/P1system、当前用户输入、关键工具结果、项目规则
压缩低优先级transcript、memory、RAG 片段、长工具输出

这张图对应源码里的三个核心函数:estimateTokens()fitPartsIntoBudget()compressPartToFit()

ContextBudget

先把预算建模。

代码块TS · 16 行收起展开
// 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?

因为模型上下文窗口通常包含输入和输出。如果输入塞满,模型就没空间回答。

上下文优先级

不是所有信息平等。

优先级内容原因
P0system 安全规则不能丢
P0当前用户输入任务本体
P1项目规则决定行为边界
P1当前工具结果Agent 下一步依据
P2相关 memory用户偏好和稳定事实
P2RAG 召回片段私有知识
P3最近会话连续性
P4更早会话可压缩
P5长工具输出优先摘要/截断

ContextBuilder 完整结构

代码块TS · 25 行收起展开
// 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:

代码块TS · 10 行收起展开
// 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; // 超预算时是否允许摘要压缩。
};

有了 prioritycompressible,才能做预算裁剪。

ContextPart 字段不是形式主义,每个字段都对应一个工程问题:

字段解决的问题
namedebug 时知道是哪块上下文占了预算
role转成模型 messages 时保持协议正确
priority超预算时先保谁、后删谁
content真正给模型看的内容
estimatedTokens预算裁剪依据
compressible决定是摘要、截断,还是直接丢弃

fitPartsIntoBudget

代码块TS · 34 行收起展开
// 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 估算

入门可以粗略估:

代码块TS · 5 行收起展开
// estimateTokens = 粗略 token 估算。
// 中文/英文/tokenizer 差异很大,这只是入门兜底。
function estimateTokens(text: string) {
  return Math.ceil(text.length / 2);
}

更稳的做法是用模型 tokenizer。但即使粗略估,也比完全不估强。

Project Instructions

项目规则通常来自:

  • AGENTS.md
  • CLAUDE.md
  • README.md
  • .cursor/rules
  • 自定义配置

读取时要限制:

代码块TS · 27 行收起展开
// 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 检索

长期记忆不能全塞。要按相关性找。

代码块TS · 32 行收起展开
// 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
  };
}

简单相关性:

代码块TS · 19 行收起展开
// 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 压缩

会话历史最容易膨胀。

策略:

代码块TEXT · 3 行收起展开
保留最近 8-12 条
更早历史压成 summary
summary 只保留目标、决策、修改、失败、下一步
代码块TS · 31 行收起展开
// 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

压缩不是普通总结。要面向后续执行。

代码块TEXT · 16 行收起展开
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
bashstdout/stderr 截断
web_fetch提取正文 + 限长
mcp根据 tool schema 设置输出限制
代码块TS · 16 行收起展开
// 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 结果必须带来源:

代码块TS · 20 行收起展开
// 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 里要要求:

代码块TEXT · 3 行收起展开
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.

上下文顺序的工程经验

推荐最终顺序:

代码块TEXT · 9 行收起展开
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。

代码块TS · 9 行收起展开
// 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 tokensinput token 接近窗口上限增大 reservedOutputTokens

Memory 进入上下文的治理策略

长期记忆不是越多越好。进入上下文前,memory 必须经过筛选、排序、冲突处理和过期判断,否则它会变成比普通历史更危险的噪声。

阶段判断问题必须保留的字段不合格表现
写入这条信息是否长期稳定typescopesourceTraceIdconfidence把一句临时聊天写成永久偏好
检索它和当前任务是否相关queryTermsmatchedReasonscore所有 memory 固定塞进 prompt
排序它比 RAG、规则、当前问题更重要吗priorityrecencysourceType旧偏好压过当前明确要求
冲突新旧记忆是否互相矛盾supersedesconflictWith两条相反偏好同时出现
过期是否已经不适用于当前项目expiresAtprojectIdvalidUntil旧项目决策污染新项目
复盘为什么这条记忆被放进上下文contextReasonrank错答后不知道哪条记忆影响了模型

推荐把 memory 分成几类,不同类别用不同策略:

类型例子进入上下文策略
preference用户不喜欢废话、偏好高密度内容高优先级,但要允许当前指令覆盖
project_fact当前项目路径、启动命令、模块边界只在相关项目任务进入
decision为什么采用 MCP thin adapter与架构讨论相关时进入
lesson某类 bug 的根因和修法调试相似问题时进入
temporary_state本轮任务进行到哪里只在当前会话/短期任务进入

最小 memory 选择算法:

代码块TS · 14 行收起展开
// 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 好看更能证明上下文工程质量。

读源码时看上下文系统

重点找:

  1. system prompt 从哪里来。
  2. 项目文件读取规则。
  3. memory 如何筛选。
  4. history 如何裁剪。
  5. token 如何估算。
  6. auto compact 什么时候触发。
  7. 压缩摘要保留什么。
  8. tool result 如何截断。
  9. debug context 能不能导出。

这些比“prompt 写得好不好看”重要得多。