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),所以塞什么进去必须精打细算。
下面这张表解释图里每个节点具体装的是什么。先说两个黑话:P0/P1 是排优先级的说法,P 是 priority,数字越小越重要,P0 就是无论如何都不能丢的那一档;RAG(Retrieval-Augmented Generation,检索增强生成)指先从你自己的文档库里检索出相关片段,再连同问题一起喂给模型。
表里的 transcript 就是当前会话的聊天记录。
| 图中节点 | 实际包含 |
|---|---|
| 候选上下文 | 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?
因为模型的上下文窗口通常是输入和输出共用的。举个数:窗口 200k token,你把输入塞到 199k,模型最多只能回 1k token,答案写一半就被截断。所以要先扣掉 reservedOutputTokens,剩下的才是输入能用的预算。
上下文优先级
上下文里各类信息的重要程度差别很大,得排个序。下表从 P0 到 P5,越靠上越不能丢,超预算时从 P5 开始往上砍。表里的“召回”是检索圈的说法,意思就是“检索命中并取回来”。
| 优先级 | 内容 | 原因 |
|---|---|---|
| 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
这个函数干的事和 Redis 设了 maxmemory 之后的内存淘汰很像:空间不够时按规则优先保住重要的 key、淘汰次要的。区别在于这里除了“保留”和“丢弃”,还有第三条路:压缩之后再塞进去。
代码块收起展开
// 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(把文本切成 token 的分词器,每家模型切法不同,算出来的数量也不同)。但即使粗略估,也比完全不估强。
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);
}这种词重叠打分很糙:query 里写“数据库”,记忆里写的是“MySQL”,一个词都对不上,直接得 0 分。后续可以换成 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
这里的压缩和平时写总结目标不同:它写给下一轮 Agent 看,唯一目的是让它能接着干活。
代码块收起展开
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 |
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 结果必须带来源,不然模型说“文档里写了 X”的时候,你根本没法核对它是真引用还是瞎编:
代码块收起展开
// 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.上下文顺序的工程经验
推荐最终顺序:
- system base rules
- safety and permission rules
- project instructions
- relevant memory
- previous summary
- recent transcript
- retrieved sources
- latest tool result
- 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 片段是不是无关?
- 工具结果是不是被截断过头?
排查上下文问题时照下表走:左边是你观察到的现象,往右依次是优先怀疑什么、去 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 tokens | input token 接近窗口上限 | 增大 reservedOutputTokens |
Memory 进入上下文的治理策略
长期记忆塞多了反而坏事。进入上下文前,memory 必须经过筛选、排序、冲突处理和过期判断,否则它会变成比普通历史更危险的噪声,因为模型会把过期记忆当成现在还成立的事实。
下表把 memory 的生命周期切成六个阶段,每个阶段回答一个问题。第三列是 memory 记录上要带的字段,有这些字段才做得了对应的判断:sourceTraceId 记这条记忆出自哪次对话,confidence 是写入时对可靠程度的打分,supersedes 表示这条新记忆取代了哪条旧的,conflictWith 标记它和哪条互相矛盾。
| 阶段 | 判断问题 | 必须保留的字段 | 不合格表现 |
|---|---|---|---|
| 写入 | 这条信息是否长期稳定 | type、scope、sourceTraceId、confidence | 把一句临时聊天写成永久偏好 |
| 检索 | 它和当前任务是否相关 | queryTerms、matchedReason、score | 所有 memory 固定塞进 prompt |
| 排序 | 它比 RAG、规则、当前问题更重要吗 | priority、recency、sourceType | 旧偏好压过当前明确要求 |
| 冲突 | 新旧记忆是否互相矛盾 | supersedes、conflictWith | 两条相反偏好同时出现 |
| 过期 | 是否已经不适用于当前项目 | expiresAt、projectId、validUntil | 旧项目决策污染新项目 |
| 复盘 | 为什么这条记忆被放进上下文 | contextReason、rank | 错答后不知道哪条记忆影响了模型 |
剩下几行的字段也过一遍:检索行的 queryTerms 和 matchedReason 记的是这条记忆当时被哪些查询词、因为什么原因命中,score 是那次检索的相关性得分,留着它们才能回答“这条记忆为什么被捞出来”。
排序行的 recency 是记忆的新旧程度(越新越可信),sourceType 记它是用户亲口说的还是模型自己推断的,前者理应压过后者。
过期行的 expiresAt 和 validUntil 是有效期,projectId 把记忆绑定在具体项目上,防止 A 项目的决策带进 B 项目。
复盘行的 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 没有治理好。
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 |
| Select | ContextPart[] | 按优先级、相关性、新鲜度和预算选择 | score、rank、dropReason |
| Structure | 选中信息 | 固定分区和稳定顺序 | section、position、sourceId |
| Compress | 超预算内容 | 裁剪、折叠、摘要或引用替代 | strategy、before/after tokens |
表里“必留证据”一列的意思是:每个阶段跑完都要留下这些字段,方便事后复盘。比如 Select 阶段留了 dropReason,出问题时你才查得到某段资料当初为什么被丢。
GSSC 真正的价值就在这里:它把“模型最终看到了什么”变成一条每步可查的流水线,每一段候选信息都能回答从哪里来、为什么选中、放在哪、超预算时怎么处理。
ContextPacket 最小字段
ContextPacket 是 GSSC 流水线里流转的信息单元,和前面的 ContextPart 一个思路,多出来的字段全是为了溯源:
代码块收起展开
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 好看更能证明上下文工程质量。
读源码时看上下文系统
重点找:
- system prompt 从哪里来。
- 项目文件读取规则。
- memory 如何筛选。
- history 如何裁剪。
- token 如何估算。
- auto compact(上下文快满时自动把旧历史压成摘要的机制,Claude Code 里就叫这个名字)什么时候触发。
- 压缩摘要保留什么。
- tool result 如何截断。
- debug context 能不能导出。
这些比“prompt 写得好不好看”重要得多。