Agent CLI源码剖析 - 上下文工程与记忆系统
Agent CLI 源码剖析 04:上下文工程与记忆系统
上下文工程决定模型“看见什么”
模型不会自动知道:
- 当前项目结构。
- 用户偏好。
- 之前聊过什么。
- 哪些文件刚被修改。
- 哪些资料是真实来源。
它只知道 messages。messages 就是每次调用模型 API 时发过去的那个消息数组,数组里每条消息带一个角色:system 是系统规则,user 是用户说的话,assistant 是模型之前的回复,tool 是工具执行结果。模型每次只看这个数组,数组之外的东西一概不知道。
所以 Agent CLI 的上下文工程就是:
在有限 token 预算内,把最该给模型的信息,以最合适的顺序放进 messages。
这里的 token 是模型计量文本长度的单位,粗略理解成“字数”就行:一个汉字或一个英文单词大概对应一到两个 token。模型一次请求能接收的 token 总量有硬上限(叫上下文窗口,context window),所以塞什么、塞多少必须精打细算。
上下文来源
这张图怎么看:每个方框是一类上下文来源,箭头表示的只是它们被拼进最终 messages 时的先后顺序,从上排一路串到下排,最后汇进右下角的 Messages(也就是发给模型的那个消息数组),不代表什么执行流程或调用关系。
上排偏“规则和记忆”(System 规则、Project 项目规则、Memory 长期记忆、Summary 历史摘要、Recent 最近消息),下排偏“本轮临时内容”(Docs 检索到的资料、Tool Result 工具结果、User Input 当前用户输入)。
左下角的 Priority gates 直译是“优先级闸门”:每类内容不是无条件进场,进不进、占多少空间要先过一道优先级检查,这道检查就是紧接着下面这张 P0 到 P4 的优先级表。
这些内容优先级不同。下表的 P0 到 P4 是优先级标号(P 就是 Priority,和公司里给 bug 定级的 P0/P1 是一个用法),P0 最高,数字越大越可以牺牲。第三列说的是:上下文塞不下时,这类内容允不允许被裁掉或压缩。
| 优先级 | 内容 | 能不能丢 |
|---|---|---|
| P0 | system 安全规则 | 不能 |
| P0 | 当前用户输入 | 不能 |
| P1 | 项目规则 | 尽量不能 |
| P1 | 最新工具结果 | 通常不能 |
| P2 | 相关 memory | 可筛选 |
| P2 | RAG 片段 | 可筛选 |
| P3 | 最近会话 | 可裁剪 |
| P4 | 早期会话 | 应压缩 |
表里的 RAG 片段指用检索增强生成(RAG,Retrieval-Augmented Generation)拿到的资料:先拿用户问题去文档库里搜出相关段落,再把段落塞进上下文,让模型照着资料回答,而不是凭训练时的记忆瞎编。
ContextPart
构建上下文时先把它拆成一个个 part(块)来管理,不要上来就拼一个大字符串。
代码块收起展开
// ContextPart = 一个可独立管理的上下文块。
// 先拆块,后面才能按优先级裁剪、压缩和 debug。
export type ContextPart = {
name: string; // 块名,例如 memory、project_instructions。
role: "system" | "user" | "assistant" | "tool"; // 放进模型 messages 时的角色。
priority: number; // 数字越小优先级越高,越不该丢。
content: string; // 上下文正文。
estimatedTokens: number; // 估算 token,用于预算判断。
compressible: boolean; // 超预算时是否允许摘要压缩。
};为什么要这样做?
- 可以估算 token。
- 可以按优先级裁剪。
- 可以单独压缩某部分。
- 可以 debug 最终上下文由哪些部分组成。
ContextBudget
代码块收起展开
// ContextBudget = 本次模型请求的上下文预算。
export type ContextBudget = {
maxInputTokens: number; // 最大输入 token。
reservedOutputTokens: number; // 给模型输出预留的 token。
};
// availableInputTokens = 实际可用于 messages 的输入 token。
export function availableInputTokens(budget: ContextBudget) {
return budget.maxInputTokens - budget.reservedOutputTokens;
}必须预留输出空间,因为上下文窗口是输入和输出共用的。假设窗口一共 128k token,你把输入塞到 127k,模型最多只能回 1k,答案写到一半就被截断。预留 8k 给输出,输入最多就只能用 120k,这正是 availableInputTokens 算的东西。
buildContext
代码块收起展开
// buildContext = 上下文构建总入口。
// 它不直接拼一个大 prompt,而是先构造 ContextPart,再按预算选择。
export async function buildContext(input: BuildContextInput): Promise<Message[]> {
// 根据模型上下文窗口创建预算。
const budget = createBudget(input.model);
// 每个来源都是一个 ContextPart。
const parts: ContextPart[] = [
await systemPart(),
await projectInstructionsPart(input.cwd),
await memoryPart(input.userInput),
await transcriptPart(input.sessionId),
await retrievalPart(input.userInput),
currentUserPart(input.userInput)
];
// 在预算内选择/压缩上下文块。
const selected = await fitPartsIntoBudget(parts, budget);
// 保存 debug 快照,方便排查模型到底看到了什么。
await saveContextDebug(input.sessionId, selected);
// 最终转成模型 messages。
return selected.map(part => ({
role: part.role,
content: part.content
}));
}这个结构比直接写一个 const prompt = ... 大字符串强很多:每个块的名字、大小、优先级、有没有被压缩都能单独看到,模型答错时能定位到具体哪一块出了问题。
fitPartsIntoBudget
代码块收起展开
// fitPartsIntoBudget = 上下文预算裁剪函数。
async function fitPartsIntoBudget(
parts: ContextPart[],
budget: ContextBudget
): Promise<ContextPart[]> {
// 可用输入 token。
const max = availableInputTokens(budget);
// 按优先级选择,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 = await compressPart(part, max - used);
if (compressed) {
selected.push(compressed);
used += compressed.estimatedTokens;
}
}
}
// 输出时恢复正确上下文顺序,不按 priority 排列。
return restoreContextOrder(selected);
}拿数字走一遍:假设预算 100k token。先放 P0 的 system 规则(2k)和当前用户输入(1k),再放 P1 的项目规则(5k)和最新工具结果(10k),此时还剩 82k。轮到 P3 的历史会话,它有 120k,放不下,但它的 compressible 是 true,于是被压缩成一份摘要塞进剩余空间。
注意这里有两个顺序:选择时按优先级排,决定谁能留下来;最终返回前用 restoreContextOrder 恢复成正常的上下文顺序,决定模型按什么顺序读。优先级高不代表该放前面,比如当前用户输入是 P0 绝对不能丢,但它要放在 messages 的最后。
推荐上下文顺序
- system base rules
- safety and permission rules
- project instructions
- relevant memory
- transcript summary
- recent transcript
- retrieved sources
- latest tool results
- current user input
当前用户输入放最后,是因为模型对靠近末尾的内容更敏感,越靠后的内容越像“刚说的话”。要是把用户问题放开头,后面跟着几万 token 的历史和资料,模型很容易顺着资料跑偏,答非所问。
Project Instructions
常见来源:
AGENTS.mdCLAUDE.mdREADME.md.cursor/rules- 项目配置文件
代码块收起展开
// projectInstructionsPart = 读取项目规则文件。
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", // 项目规则属于 system 级约束。
priority: 1, // 高优先级。
content, // 规则正文。
estimatedTokens: estimateTokens(content), // token 估算。
compressible: true // 可摘要,但不应完全丢。
};
}项目规则最好可压缩,但不能随便丢。
Memory
Memory 和 transcript 是两个东西,别混。transcript 是完整聊天记录,什么都往里进;Memory 只存换个会话之后仍然有用的稳定事实。
代码块收起展开
// Memory = 长期记忆,不是聊天记录。
export type Memory = {
id: string; // 记忆 ID。
scope: "user" | "project"; // 用户级或项目级。
type: "preference" | "fact" | "decision" | "todo"; // 记忆类型。
content: string; // 可复用事实/偏好/决策。
updatedAt: string; // 更新时间。
};什么该进 memory:
| 类型 | 例子 |
|---|---|
| preference | 用户喜欢中文解释 |
| fact | 项目用 Spring Boot |
| decision | 工具必须先只读后写 |
| todo | 后续要迁移 AI Agent 笔记 |
什么不该进:
- 一次性命令输出。
- 中间猜测。
- 很快过时的状态。
- 重复流水账。
Memory 检索
代码块收起展开
// memoryPart = 选取相关长期记忆并放入上下文。
async function memoryPart(userInput: string): Promise<ContextPart> {
// 加载记忆库。
const memories = await loadAllMemory();
// 按当前任务相关性排序,只取前 12 条。
const ranked = rankMemory(memories, userInput).slice(0, 12);
// 记忆要带 scope/type,让模型知道这是偏好、事实还是任务项。
const content = ranked.map(memory =>
`- [${memory.scope}/${memory.type}] ${memory.content}`
).join("\n");
return {
name: "memory",
role: "system",
priority: 2,
content: `Relevant memory:\n${content}`,
estimatedTokens: estimateTokens(content),
compressible: true
};
}rankMemory 的实现,入门版用关键词匹配就够了:用户输入里出现“Spring”,就把带 Spring 的记忆排前面。
进阶用 embedding:把每段文本转成一个高维向量,语义越接近的文本,向量距离越近。检索时算用户输入和每条记忆的向量相似度,这样“怎么部署到服务器”也能命中“项目用 Docker 部署”这种字面上完全不重叠的记忆。
Transcript 和 Summary
Transcript 是会话记录,也就是这轮会话里 user、assistant、tool 三方来回的完整消息流水。
策略:
- 最近 8-12 条原样保留
- 更早历史压缩成 summary
代码块收起展开
// transcriptPart = 会话历史上下文。
// 最近消息保留原文,更早消息压成 summary。
async function transcriptPart(sessionId: string): Promise<ContextPart> {
// 读取完整 transcript。
const transcript = await loadTranscript(sessionId);
// 最近 12 条原样保留。
const recent = transcript.slice(-12);
// 更早部分准备压缩。
const older = transcript.slice(0, -12);
// 有旧历史才生成/读取 summary。
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
};
}好 summary 要保留什么
压缩是让模型自己把旧历史写成摘要,给它下的那条压缩指令(压缩 prompt)应明确要求保留:
- 用户目标。
- 已读文件。
- 已改文件。
- 已运行命令和结果。
- 关键决策。
- 失败尝试。
- 当前下一步。
判断 summary 合不合格的标准很直接:把它交给一个完全没参与前面对话的新会话,模型能不能靠它接着把活干下去。能,才算合格;只是把聊天内容缩短一遍,丢了上面七项里的任何一项,接手的模型就得重新踩坑。
Tool Result 管理
工具输出不能无限塞。比如 Agent 跑了一条命令读日志文件,返回 50 万个字符,全放进上下文就会把项目规则、历史摘要甚至用户问题本身全挤出去。
代码块收起展开
// formatToolResult = 工具结果上下文格式化。
// 目标:不要让超长工具输出挤爆上下文。
function formatToolResult(result: ToolResult) {
const max = 16_000;
// 不超限就完整放入。
if (result.content.length <= max) {
return result.content;
}
// 超限就截断,并明确提示模型“这不是完整输出”。
return [
result.content.slice(0, max),
`\n[Tool output truncated. Summary: ${result.summary}]`
].join("");
}截断必须告知模型,不能让它以为看到了完整输出。否则模型可能因为截断后的片段里恰好没有报错,就得出“日志正常”的错误结论继续往下干。
Context Debug
一定要保存最终发给模型的上下文。作用类似后端排查线上问题时的请求日志:不落盘,出了问题只能凭想象还原现场。
代码块收起展开
// saveContextDebug = 保存最终上下文快照。
// 调试时最重要的问题是:模型到底看到了什么?
async function saveContextDebug(sessionId: string, parts: ContextPart[]) {
await writeFile(
`.agent/debug/${sessionId}-context.json`,
JSON.stringify(parts.map(part => ({
name: part.name, // 哪个上下文块。
priority: part.priority, // 优先级。
estimatedTokens: part.estimatedTokens, // 估算 token。
contentPreview: part.content.slice(0, 500) // 只保存预览,避免 debug 文件太大。
})), null, 2),
"utf8"
);
}这样才能排查:
- 模型有没有看到资料。
- 哪部分被压缩了。
- 哪部分挤掉了其他内容。
- memory 有没有误导模型。
失败模式
这张表把常见翻车方式串起来:第一列是缺了什么机制,第二列是缺了之后的症状,第三列是本文前面讲过的对应解法。
| 失败 | 表现 | 修复 |
|---|---|---|
| 无预算 | prompt 爆掉 | ContextBudget |
| 无优先级 | 重要规则被挤掉 | ContextPart priority |
| memory 乱塞 | 模型被旧事实误导 | 相关性检索 |
| transcript 全塞 | 长任务越来越乱 | summary |
| 工具输出过长 | 模型忽略重点 | 截断 + summary |
| 无 context debug | 只能猜 | 保存上下文快照 |
源码验收门槛
能拼出 prompt 只是及格线。读源码或自己实现上下文系统时,至少要用下面这些门槛判断它是否可靠。表怎么读:中间列是合格实现里应该能找到的代码证据,右列是缺了这条时的典型症状:
| 验收项 | 必须看到的证据 | 不合格表现 |
|---|---|---|
| 预算可解释 | 每个 ContextPart 有 token 估算、优先级和是否可压缩 | 超预算时随机丢内容 |
| 顺序稳定 | system、project rules、memory、tool result、user input 的顺序固定 | 同一任务多次构造上下文顺序漂移 |
| 记忆有边界 | memory 带 scope/type/source/confidence | 旧事实和当前任务混在一起 |
| 压缩可审计 | summary 记录输入来源、保留决策和丢弃原因 | 压缩后不知道丢了什么 |
| debug 可复盘 | 能导出最终 messages 或 context snapshot | 模型答错时只能猜它看到了什么 |
表里“记忆有边界”那行比前面的 Memory 类型多了两个字段:source 指这条记忆从哪来(用户明说的,还是模型自己推断的),confidence 指可信度,推断出来的记忆置信度低,和用户明说的冲突时应该让位。
最小测试集要覆盖三类失败:项目规则被挤掉、旧 memory 误导回答、长工具输出压掉当前用户问题。能复现并定位这三类问题,才说明上下文工程不是黑箱。
读源码抓手
看上下文系统时,重点找:
- system prompt 在哪里生成。
- 项目规则从哪里读。
- memory 如何筛选。
- transcript 如何保留/压缩。
- token 是否估算。
- 超预算怎么处理。
- tool result 是否截断。
- 最终上下文能否 debug。
上下文工程决定 Agent 是“真的懂项目”,还是“表面会聊天”。