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

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

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

Agent 能不能“懂项目”,很大程度取决于上下文工程(context engineering)。这个词听着玄,说白了就是:每次调用模型前,决定给它看什么内容的那套逻辑。

模型没有能力直接看你的整个电脑,它每次被调用时能看到的只有你拼给它的 messages(一个按角色排列的消息列表):

  • system prompt
  • project instructions
  • memory
  • recent transcript
  • retrieved docs
  • tool results
  • current user input

上下文肯定要塞,真正的问题是:

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

这里的 token 是模型处理文本的基本单位,一个 token 大概对应半个到一个汉字、或大半个英文单词,模型按 token 计长度也按 token 计费。每个模型一次能处理的 token 总量有硬上限,叫上下文窗口(context window),所以塞什么进去必须精打细算。

上下文预算决策流 候选上下文 估算 token 预算判断 最终输出 保护 P0/P1 压缩低优先级 丢弃低相关 继续降级 未超预算 超预算

下面这张表解释图里每个节点具体装的是什么。先说两个黑话:P0/P1 是排优先级的说法,P 是 priority,数字越小越重要,P0 就是无论如何都不能丢的那一档;RAG(Retrieval-Augmented Generation,检索增强生成)指先从你自己的文档库里检索出相关片段,再连同问题一起喂给模型。
表里的 transcript 就是当前会话的聊天记录。

图中节点实际包含
候选上下文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?

因为模型的上下文窗口通常是输入和输出共用的。举个数:窗口 200k token,你把输入塞到 199k,模型最多只能回 1k token,答案写一半就被截断。所以要先扣掉 reservedOutputTokens,剩下的才是输入能用的预算。

上下文优先级

上下文里各类信息的重要程度差别很大,得排个序。下表从 P0 到 P5,越靠上越不能丢,超预算时从 P5 开始往上砍。表里的“召回”是检索圈的说法,意思就是“检索命中并取回来”。

优先级内容原因
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

这个函数干的事和 Redis 设了 maxmemory 之后的内存淘汰很像:空间不够时按规则优先保住重要的 key、淘汰次要的。区别在于这里除了“保留”和“丢弃”,还有第三条路:压缩之后再塞进去。

代码块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(把文本切成 token 的分词器,每家模型切法不同,算出来的数量也不同)。但即使粗略估,也比完全不估强。

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

这种词重叠打分很糙:query 里写“数据库”,记忆里写的是“MySQL”,一个词都对不上,直接得 0 分。后续可以换成 embedding 检索,也就是把文本转成一串数字向量,用向量之间的距离衡量语义相近程度,同义不同词也能匹配上。

Transcript 压缩

会话历史最容易膨胀。

策略:

  • 保留最近 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

这里的压缩和平时写总结目标不同:它写给下一轮 Agent 看,唯一目的是让它能接着干活。

代码块PLAINTEXT · 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 可能返回上万行。

处理策略按工具分别定。表里的 mcp 指通过 MCP(Model Context Protocol,一个让 Agent 挂载外部工具的标准协议)接进来的第三方工具,tool schema 是工具声明的参数和返回值定义:

工具策略
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 结果必须带来源,不然模型说“文档里写了 X”的时候,你根本没法核对它是真引用还是瞎编:

代码块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 里要要求:

代码块PLAINTEXT · 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.

上下文顺序的工程经验

推荐最终顺序:

  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 片段是不是无关?
  • 工具结果是不是被截断过头?

排查上下文问题时照下表走:左边是你观察到的现象,往右依次是优先怀疑什么、去 debug 快照里找什么证据、往哪个方向修。表里的 rerank 指检索之后再用一个模型对候选片段重新排序,把最相关的顶到前面:

现象优先怀疑看什么证据修复方向
模型忘记用户偏好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 必须经过筛选、排序、冲突处理和过期判断,否则它会变成比普通历史更危险的噪声,因为模型会把过期记忆当成现在还成立的事实。

下表把 memory 的生命周期切成六个阶段,每个阶段回答一个问题。第三列是 memory 记录上要带的字段,有这些字段才做得了对应的判断:sourceTraceId 记这条记忆出自哪次对话,confidence 是写入时对可靠程度的打分,supersedes 表示这条新记忆取代了哪条旧的,conflictWith 标记它和哪条互相矛盾。

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

剩下几行的字段也过一遍:检索行的 queryTermsmatchedReason 记的是这条记忆当时被哪些查询词、因为什么原因命中,score 是那次检索的相关性得分,留着它们才能回答“这条记忆为什么被捞出来”。
排序行的 recency 是记忆的新旧程度(越新越可信),sourceType 记它是用户亲口说的还是模型自己推断的,前者理应压过后者。
过期行的 expiresAtvalidUntil 是有效期,projectId 把记忆绑定在具体项目上,防止 A 项目的决策带进 B 项目。
复盘行的 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 没有治理好。

GSSC:从候选信息到有效上下文

Hello Agents(Datawhale 社区出的一本开源 Agent 入门教程,免费电子书,后文还会引用它)第 9 章把 ContextBuilder 总结为 GSSC,四个字母就是四个步骤:Gather 把候选信息从各处收集起来,Select 按预算和优先级挑选,Structure 按固定顺序摆放,Compress 把放不下的压缩掉。
它正好是本篇 ContextPart -> fitPartsIntoBudget 那套代码的上层工作流。

Gather → Select → Structure → Compress

阶段输入关键动作必留证据
Gather规则、任务、历史、RAG、Memory、Tool Result多源汇集,单源失败不拖垮整体source、fetch error、tokenCount
SelectContextPart[]按优先级、相关性、新鲜度和预算选择score、rank、dropReason
Structure选中信息固定分区和稳定顺序section、position、sourceId
Compress超预算内容裁剪、折叠、摘要或引用替代strategy、before/after tokens

表里“必留证据”一列的意思是:每个阶段跑完都要留下这些字段,方便事后复盘。比如 Select 阶段留了 dropReason,出问题时你才查得到某段资料当初为什么被丢。
GSSC 真正的价值就在这里:它把“模型最终看到了什么”变成一条每步可查的流水线,每一段候选信息都能回答从哪里来、为什么选中、放在哪、超预算时怎么处理。

ContextPacket 最小字段

ContextPacket 是 GSSC 流水线里流转的信息单元,和前面的 ContextPart 一个思路,多出来的字段全是为了溯源:

代码块TS · 12 行收起展开
type ContextPacket = {
  id: string;
  content: string;
  sourceType: "system" | "task" | "history" | "rag" | "memory" | "tool";
  sourceId: string;
  tokenCount: number;
  priority: number;
  relevance: number;
  timestamp?: string;
  selectedReason?: string;
  dropReason?: string;
};

系统规则不能只靠 relevance 排序;它们属于硬约束,需要预留预算和固定优先级。RAG、Memory、历史和 Tool Result 才进入竞争选择。

Just-in-time 上下文与渐进式披露

标题里的 Just-in-time(JIT)意思是按需即时获取:哪块信息用到了再去取,别提前全塞进上下文。渐进式披露(progressive disclosure)是同一个思路的另一面:先只给模型一份轻量目录,它判断需要什么再展开正文。

为什么要这样做?因为上下文越长,模型掌握得未必越多。Hello Agents 将长上下文中的检索能力下降称为 context rot(上下文腐蚀):信息仍在窗口里,但高信号内容被大量低相关 token 稀释,模型反而抓不住重点,就像让你在几万行日志里找一行关键报错。

更稳的模式是维护轻量引用,再按需获取正文。下图里有三个词先解释:Project Map 就是项目目录结构的地图;Skill 是给 Agent 预先写好的某类任务操作手册(比如“怎么发布这个项目”的步骤文档),平时上下文里只放它的名字和一句描述,Agent 判断用得上时再调 load_skill 这个工具把正文加载进来;Resource URI 是远程资料的地址,比如 MCP 服务器暴露出来的一份文档,同样是用到才去取:

轻量引用按需加载流程

这套模式你其实见过:MySQL 查询不会把整张表读进内存,它先走索引定位,再取需要的行。这里的 Project Map、文件路径列表就是索引,文件正文就是数据行。
下表对比几种加载策略,“全量预加载”是开局就把可能用到的内容全塞进 prompt,“预计算 RAG”是提前把文档切块建好索引、运行时只做检索,“JIT 探索”是运行时靠工具现查现读:

策略优点风险适用
全量预加载第一次调用快上下文膨胀、信息腐蚀、成本高很小且稳定的规则集
预计算 RAG召回快、可批量索引索引可能过期、查询意图可能偏大量相对稳定文档
JIT 探索新鲜、可根据中间结果改查询工具调用更多、路径可能走偏代码库、日志、动态系统
混合策略基础地图稳定,细节按需需要明确预加载边界生产 Coding Agent

典型混合方案:预加载 AGENTS.md、任务目标和目录地图;文件正文、测试输出、Skill 正文和远程资料按需读取。

最小可行工具集

工具描述本身也占上下文。工具过多、职责重叠会让“选哪个工具”变成新的推理负担。推荐先保留最小可行工具集:

  • 一个文件读取入口。
  • 一个精确编辑入口。
  • 一个受控命令执行入口。
  • 一个搜索入口。
  • 必要时再加入 MCP 或业务工具。

如果两种工具连人类都说不清边界,就应该合并、改名或加强 schema,别指望模型自己猜对。

上下文工程的常见失败

把常见翻车方式汇总一遍:左列是缺了哪块机制,中间是症状,右列指回本篇对应的解法。

失败表现修复
无预算prompt 越来越长ContextBudget
无优先级重要规则被挤掉ContextPart priority
无压缩长任务中断transcript summary
memory 乱塞模型被旧事实误导相关性检索
RAG 无引用答案不可查source path 强制保留
工具输出太长模型忽略重点截断 + summary
context rot信息都在但模型抓不住重点减少低价值 token,改用 JIT 获取
工具描述拥挤选错工具或参数最小工具集 + 低重叠 schema

源码验收门槛

上下文预算模块要能证明“为什么保留这个、丢掉那个”。只要解释不了,后续所有 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(上下文快满时自动把旧历史压成摘要的机制,Claude Code 里就叫这个名字)什么时候触发。
  7. 压缩摘要保留什么。
  8. tool result 如何截断。
  9. debug context 能不能导出。

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

延伸阅读