Agent CLI源码剖析 - 上下文工程与记忆系统 · AI Agent

Agent CLI 源码剖析 04:上下文工程与记忆系统

上下文工程决定模型“看见什么”

模型不会自动知道:

  • 当前项目结构。
  • 用户偏好。
  • 之前聊过什么。
  • 哪些文件刚被修改。
  • 哪些资料是真实来源。

它只知道 messages。

所以 Agent CLI 的上下文工程就是:

在有限 token 预算内,把最该给模型的信息,以最合适的顺序放进 messages。

上下文来源

上下文来源 System Project Memory Summary Recent Docs Tool Result User Input Messages Priority gates

这些内容优先级不同。

优先级内容能不能丢
P0system 安全规则不能
P0当前用户输入不能
P1项目规则尽量不能
P1最新工具结果通常不能
P2相关 memory可筛选
P2RAG 片段可筛选
P3最近会话可裁剪
P4早期会话应压缩

ContextPart

把上下文拆成 part,而不是直接拼字符串。

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

代码块TS · 10 行收起展开
// ContextBudget = 本次模型请求的上下文预算。
export type ContextBudget = {
  maxInputTokens: number;       // 最大输入 token。
  reservedOutputTokens: number; // 给模型输出预留的 token。
};

// availableInputTokens = 实际可用于 messages 的输入 token。
export function availableInputTokens(budget: ContextBudget) {
  return budget.maxInputTokens - budget.reservedOutputTokens;
}

必须预留输出空间。否则输入塞满,模型没地方回答。

buildContext

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

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

注意:选择时按优先级,输出时按上下文顺序。

推荐上下文顺序

代码块TEXT · 9 行收起展开
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.md
  • CLAUDE.md
  • README.md
  • .cursor/rules
  • 项目配置文件
代码块TS · 26 行收起展开
// 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 只存稳定事实。

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

代码块TS · 22 行收起展开
// 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 是会话记录。

策略:

代码块TEXT · 2 行收起展开
最近 8-12 条原样保留
更早历史压缩成 summary
代码块TS · 31 行收起展开
// 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 应要求保留:

  1. 用户目标。
  2. 已读文件。
  3. 已改文件。
  4. 已运行命令和结果。
  5. 关键决策。
  6. 失败尝试。
  7. 当前下一步。

这不是“总结对话”,而是“交接任务”。

Tool Result 管理

工具输出不能无限塞。

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

一定要保存最终上下文。

代码块TS · 14 行收起展开
// 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 误导回答、长工具输出压掉当前用户问题。能复现并定位这三类问题,才说明上下文工程不是黑箱。

读源码抓手

看上下文系统时,重点找:

  1. system prompt 在哪里生成。
  2. 项目规则从哪里读。
  3. memory 如何筛选。
  4. transcript 如何保留/压缩。
  5. token 是否估算。
  6. 超预算怎么处理。
  7. tool result 是否截断。
  8. 最终上下文能否 debug。

上下文工程决定 Agent 是“真的懂项目”,还是“表面会聊天”。