Agent CLI源码剖析 - 上下文工程与记忆系统 · AI Agent
Agent CLI 源码剖析 04:上下文工程与记忆系统
上下文工程决定模型“看见什么”
模型不会自动知道:
- 当前项目结构。
- 用户偏好。
- 之前聊过什么。
- 哪些文件刚被修改。
- 哪些资料是真实来源。
它只知道 messages。
所以 Agent CLI 的上下文工程就是:
在有限 token 预算内,把最该给模型的信息,以最合适的顺序放进 messages。
上下文来源
这些内容优先级不同。
| 优先级 | 内容 | 能不能丢 |
|---|---|---|
| P0 | system 安全规则 | 不能 |
| P0 | 当前用户输入 | 不能 |
| P1 | 项目规则 | 尽量不能 |
| P1 | 最新工具结果 | 通常不能 |
| P2 | 相关 memory | 可筛选 |
| P2 | RAG 片段 | 可筛选 |
| P3 | 最近会话 | 可裁剪 |
| P4 | 早期会话 | 应压缩 |
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;
}必须预留输出空间。否则输入塞满,模型没地方回答。
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);
}注意:选择时按优先级,输出时按上下文顺序。
推荐上下文顺序
代码块收起展开
1. system base rules
2. safety and permission rules
3. project instructions
4. relevant memory
5. transcript summary
6. recent transcript
7. retrieved sources
8. latest tool results
9. current user input当前用户输入放最后,是为了避免被历史淹没。
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。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
};
}入门可以关键词匹配,进阶用 embedding。
Transcript 和 Summary
Transcript 是会话记录。
策略:
代码块收起展开
最近 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 应要求保留:
- 用户目标。
- 已读文件。
- 已改文件。
- 已运行命令和结果。
- 关键决策。
- 失败尝试。
- 当前下一步。
这不是“总结对话”,而是“交接任务”。
Tool Result 管理
工具输出不能无限塞。
代码块收起展开
// 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 误导回答、长工具输出压掉当前用户问题。能复现并定位这三类问题,才说明上下文工程不是黑箱。
读源码抓手
看上下文系统时,重点找:
- system prompt 在哪里生成。
- 项目规则从哪里读。
- memory 如何筛选。
- transcript 如何保留/压缩。
- token 是否估算。
- 超预算怎么处理。
- tool result 是否截断。
- 最终上下文能否 debug。
上下文工程决定 Agent 是“真的懂项目”,还是“表面会聊天”。