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

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

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

模型不会自动知道:

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

它只知道 messages。messages 就是每次调用模型 API 时发过去的那个消息数组,数组里每条消息带一个角色:system 是系统规则,user 是用户说的话,assistant 是模型之前的回复,tool 是工具执行结果。模型每次只看这个数组,数组之外的东西一概不知道。

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

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

这里的 token 是模型计量文本长度的单位,粗略理解成“字数”就行:一个汉字或一个英文单词大概对应一到两个 token。模型一次请求能接收的 token 总量有硬上限(叫上下文窗口,context window),所以塞什么、塞多少必须精打细算。

上下文来源

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

这张图怎么看:每个方框是一类上下文来源,箭头表示的只是它们被拼进最终 messages 时的先后顺序,从上排一路串到下排,最后汇进右下角的 Messages(也就是发给模型的那个消息数组),不代表什么执行流程或调用关系。
上排偏“规则和记忆”(System 规则、Project 项目规则、Memory 长期记忆、Summary 历史摘要、Recent 最近消息),下排偏“本轮临时内容”(Docs 检索到的资料、Tool Result 工具结果、User Input 当前用户输入)。
左下角的 Priority gates 直译是“优先级闸门”:每类内容不是无条件进场,进不进、占多少空间要先过一道优先级检查,这道检查就是紧接着下面这张 P0 到 P4 的优先级表。

这些内容优先级不同。下表的 P0 到 P4 是优先级标号(P 就是 Priority,和公司里给 bug 定级的 P0/P1 是一个用法),P0 最高,数字越大越可以牺牲。第三列说的是:上下文塞不下时,这类内容允不允许被裁掉或压缩。

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

表里的 RAG 片段指用检索增强生成(RAG,Retrieval-Augmented Generation)拿到的资料:先拿用户问题去文档库里搜出相关段落,再把段落塞进上下文,让模型照着资料回答,而不是凭训练时的记忆瞎编。

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

必须预留输出空间,因为上下文窗口是输入和输出共用的。假设窗口一共 128k token,你把输入塞到 127k,模型最多只能回 1k,答案写到一半就被截断。预留 8k 给输出,输入最多就只能用 120k,这正是 availableInputTokens 算的东西。

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

拿数字走一遍:假设预算 100k token。先放 P0 的 system 规则(2k)和当前用户输入(1k),再放 P1 的项目规则(5k)和最新工具结果(10k),此时还剩 82k。轮到 P3 的历史会话,它有 120k,放不下,但它的 compressible 是 true,于是被压缩成一份摘要塞进剩余空间。

注意这里有两个顺序:选择时按优先级排,决定谁能留下来;最终返回前用 restoreContextOrder 恢复成正常的上下文顺序,决定模型按什么顺序读。优先级高不代表该放前面,比如当前用户输入是 P0 绝对不能丢,但它要放在 messages 的最后。

推荐上下文顺序

  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

当前用户输入放最后,是因为模型对靠近末尾的内容更敏感,越靠后的内容越像“刚说的话”。要是把用户问题放开头,后面跟着几万 token 的历史和资料,模型很容易顺着资料跑偏,答非所问。

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 是两个东西,别混。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
  };
}

rankMemory 的实现,入门版用关键词匹配就够了:用户输入里出现“Spring”,就把带 Spring 的记忆排前面。
进阶用 embedding:把每段文本转成一个高维向量,语义越接近的文本,向量距离越近。检索时算用户输入和每条记忆的向量相似度,这样“怎么部署到服务器”也能命中“项目用 Docker 部署”这种字面上完全不重叠的记忆。

Transcript 和 Summary

Transcript 是会话记录,也就是这轮会话里 user、assistant、tool 三方来回的完整消息流水。

策略:

  • 最近 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. 当前下一步。

判断 summary 合不合格的标准很直接:把它交给一个完全没参与前面对话的新会话,模型能不能靠它接着把活干下去。能,才算合格;只是把聊天内容缩短一遍,丢了上面七项里的任何一项,接手的模型就得重新踩坑。

Tool Result 管理

工具输出不能无限塞。比如 Agent 跑了一条命令读日志文件,返回 50 万个字符,全放进上下文就会把项目规则、历史摘要甚至用户问题本身全挤出去。

代码块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 类型多了两个字段:source 指这条记忆从哪来(用户明说的,还是模型自己推断的),confidence 指可信度,推断出来的记忆置信度低,和用户明说的冲突时应该让位。

最小测试集要覆盖三类失败:项目规则被挤掉、旧 memory 误导回答、长工具输出压掉当前用户问题。能复现并定位这三类问题,才说明上下文工程不是黑箱。

读源码抓手

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

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

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

延伸阅读