这是「从零搭建 Agent」系列的第三篇。到这里,Agent 已经不只是会调用  calculator  的教学玩具了,它可以读文件、写文件、跑命令、搜网页、抓正文,也可以在不同模型接口之间切换。
但能力变强之后,新的问题出现了:工具输出、命令日志、网页正文、错误信息、历史对话都会不断进入 conversation history。模型每一轮到底应该看到什么?哪些内容应该保留?哪些内容应该截断?哪些内容应该总结?这就是本章要实现的 Context Engine。

同步项目地址 https://github.com/Tritium0041/Singularity,当前进度位于 https://github.com/Tritium0041/Singularity/commit/0a33a7a99bccab142129235be1ac6cdf36a748fa

# 为什么现在需要 Context Engine?

第 2 章中实现的 Agent Loop 其实已经形成了一个闭环,它每一轮做的事情很清楚:

User input
  -> append user message
  -> call LLM
  -> assistant may call tools
  -> execute tools
  -> append tool results
  -> next turn

在代码里,当前上下文实现就只是简单地把完整消息历史交给模型:

const rawRequest: LlmRequest = {
  model: this.model,
  systemPrompt: this.systemPrompt,
  messages: [...this.messages],
  tools: this.tools.toLlmToolSpecs(),
  reasoning: options.reasoning ?? this.reasoning,
  signal: options.signal
};

在只有  calculator  和  get_weather  这样的 mock 工具的时候,这么做没什么问题。历史消息很短,工具结果也很短,模型每一轮看到完整 history 反而最简单。

但在第 2.5 章之后,我们新增了文件、Shell、Web/Search 这些工具,Agent 开始接触真实环境。真实环境里的 observation 往往不是一句话,而是一大块东西:

  • read_file  可能读到几千行源代码。
  • execute_command  可能吐出一整屏构建日志。
  • fetch_url  可能抓回一篇很长的网页正文。
  • 工具失败时,stderr、exit code、timeout 信息也要进入上下文。

这时候如果继续把完整 history 原样塞给模型,就会遇到三个问题:

第一个问题是上下文窗口爆炸。即使我们在工具层已经做了 head/tail truncation,长任务里这些 “截断后的工具结果” 仍然会继续累积,迟早把模型窗口撑满。

第二个问题是注意力漂移。模型看到的信息越多,不一定越聪明。有时它会被旧日志、旧错误、旧网页内容带偏,忘记当前真正要做什么。

第三个问题是成本和缓存。第 1 章里说过,Harness 很重要的一部分是缓存友好的上下文分层。稳定内容越靠前,动态内容越靠后,越容易命中 prompt cache。把所有东西混成一条不断变长的消息数组,会让后续优化很难做。

所以,我们需要一个统一的 Context Engine 对历史消息进行整理,在 Agent Loop 将消息发送给 LLM 之前,多加一道生成 “模型可见视图” 的处理层:

完整 Agent history
  -> Context Engine
     -> system prompt fragments
     -> token estimate and budget
     -> request-view tool truncation
     -> history compaction
     -> dynamic compression
  -> LlmRequest
  -> Provider adapter

Agent 自己的  this.messages  是事实记录,不能随便破坏;模型看到的  request.messages  是工作视图,可以被截断、压缩、替换和重排。这两个实体的分离,是本章最重要的设计。

# 从 Codex 和 Pi 学到什么?

Codex 和 Pi 的基本思路实际都是差不多的,例如把历史消息和模型上下文分离、Token 估算、动态构造初始 context,以及基于 Fork Agent 的 LLM summary。

我们也把这些思路当作本章的基本路径:

  1. 不追求精确 tokenizer,用稳定、低成本的估算代替。
  2. 保留最近上下文,因为最近的工具结果通常最关键,且能为下一步动作提供指导。
  3. 老上下文不能直接被切除,要变成 summary,保留用户目标。
  4. 压缩只作用于 request-view context,默认不破坏完整 history。

于是我们在  src/context/  下新增了一组模块,作为我们的 Context Engine 实现:

src/context/
  token-estimator.ts
  budget-manager.ts
  prompt-builder.ts
  history-compressor.ts
  dynamic-compressor.ts
  context-engine.ts
  types.ts

# 第一层:Prompt Builder,把稳定提示词构建起来

第 2 章时, systemPrompt  还是一个简单字符串。用户传什么,我们就发什么。

第三章之后,system prompt 开始承担更多职责。它不只是 “你是一个助手” 这种角色设定,还要包含:

  • 默认 Agent 行为规则。
  • 当前工作目录、日期、时区、shell。
  • 当前可用工具列表。
  • 用户传入的 user goal prompt。

这些内容变化频率不同。默认行为规则、工具列表通常比较稳定;日期、cwd、用户目标相对动态;当前任务状态更动态。为了后续 prompt cache 的稳定性,我们需要 PromptBuilder  做的事情很简单:把这些片段按稳定顺序拼起来。

export class PromptBuilder {
  buildConversationSystemPrompt(options: SystemPromptBuilderOptions): string | undefined {
    const fragments = [...(options.fragments ?? [])];
    const backgroundFragment =
      options.background === false
        ? undefined
        : this.buildBackgroundFragment(options.background ?? {});
    if (backgroundFragment) {
      fragments.unshift(backgroundFragment);
    }
    const defaultInstructions =
      options.defaultInstructions === false
        ? undefined
        : options.defaultInstructions ?? DEFAULT_AGENT_INSTRUCTIONS;
    return this.buildSystemPrompt(
      defaultInstructions,
      [toBasePromptFragment(options.basePrompt), ...fragments].filter(Boolean) as PromptFragment[]
    );
  }
}

上下文背景会被渲染成结构化 XML 风格:

<environment_context>
  <cwd>/path/to/project</cwd>
  <current_date>2026-06-11</current_date>
  <timezone>Asia/Shanghai</timezone>
  <shell>zsh</shell>
</environment_context>
<available_tools>
  <tool name="read_file">Read a UTF-8 file.</tool>
  <tool name="execute_command">Execute a shell command.</tool>
</available_tools>

这一设计是为了降低模型理解现状的成本,Context Engine 首先要实现的就应该是让模型每一轮都能稳定地看到 “自己是谁、在哪里、有什么工具”。

# 第二层:Token Usage,让上下文有共同度量

要做预算,首先要有计算当前上下文用量的能力。由于 Agent 可能对接不同模型,我们没有引入精确 tokenizer,而是用了经验公式近似取值:

export function estimateTextTokens(text: string | undefined): number {
  if (!text) {
    return 0;
  }
  return Math.ceil(text.length / 4);
}

估算范围不能只看 message content,请求里所有类型的消息都会占上下文,所以完整的上下文估算方法  estimateRequestTokens()  会拆成三部分:

const systemPromptTokens = estimateTextTokens(request.systemPrompt);
const messageTokens = estimateMessagesTokens(request.messages);
const toolTokens = estimateToolSpecsTokens(request.tools);
const heuristicTotalTokens = systemPromptTokens + messageTokens + toolTokens;

如果 Provider 已经返回 usage,我们的 Engine 也会根据回传的 provider usage 校准。上一轮结束后 provider 已经告诉我们 inputTokens 了,那这一轮就不用从零估算整段消息历史,只需要把上一轮之后新增的 Token 用量估算进去。

const providerEstimate = estimateFromProviderUsage(request.messages);
if (!providerEstimate) {
  return heuristicEstimate;
}
return {
  ...heuristicEstimate,
  totalTokens: providerEstimate.totalTokens,
  source: "provider_usage",
  providerInputTokens: providerEstimate.providerInputTokens,
  providerOutputTokens: providerEstimate.providerOutputTokens,
  appendedMessageTokens: providerEstimate.appendedMessageTokens
};

# 第三层:Budget Manager,决定什么时候压缩

有能力计算上下文用量之后,就需要一个预算器。

export const DEFAULT_CONTEXT_ENGINE_OPTIONS = {
  enabled: true,
  contextWindowTokens: 256000,
  compactionThresholdRatio: 0.9,
  reservedOutputTokens: 16000,
  keepRecentTokens: 20000,
  maxToolResultTokens: 20000,
  summarizeHistory: true,
  dynamicCompression: false
};

contextWindowTokens  是这一代模型上下文窗口的常用大小, reservedOutputTokens  是留给模型回复的空间, compactionThresholdRatio  是触发压缩的比例。

真正的触发线是两者取更保守的那个(本质是 codex 和 pi 用了不同的实现,一开始选的是抄 pi 的,后来改成了用 codex 的):

get compactionTriggerTokens(): number {
  const thresholdTokens = Math.floor(this.options.contextWindowTokens * this.options.compactionThresholdRatio);
  return Math.max(0, Math.min(thresholdTokens, this.availableInputTokens));
}

# 第四层:启发式上下文压缩

Context Engine 的第一层压缩先处理过长的 tool result 和模型输出。这一层压缩基本是用来 fallback 的,实际生产环境不太会用到。

如果整个 request 超过预算,且没有其他压缩模式可用,就会触发启发式压缩。压缩的基本策略是:

  1. 按 user message 切分 turns。
  2. 从最新 turn 倒序保留,直到达到  keepRecentTokens
  3. 更早的 turns 不直接原样保留。
  4. 保留所有用户指令,丢弃旧 assistant/tool 细节。

代码里对应的是:

const turns = splitIntoTurns(messages);
const keptTurns: MessageTurn[] = [];
for (let index = turns.length - 1; index >= 0; index -= 1) {
  const turn = turns[index];
  const turnTokens = estimateMessagesTokens(turn.messages);
  if (keptTurns.length > 0 && keptTokens + turnTokens > this.options.keepRecentTokens) {
    break;
  }
  keptTurns.unshift(turn);
  keptTokens += turnTokens;
}

最近上下文通常包含当前正在处理的文件、刚失败的命令、刚返回的 tool result。它最应该被完整保留。更早的内容里,最重要的是用户目标和约束,而不是每一轮旧工具输出的细枝末节。

所以默认启发式压缩会保留早期 user message:

function selectUserInstructionMessages(messages: readonly AgentMessage[]): AgentMessage[] {
  return messages
    .filter((message) => message.role === "user" && !isHandoffSummaryMessage(message.content))
    .map((message) => ({ role: "user" as const, content: message.content }));
}

# 第五层:Handoff Summary,让旧历史变成交接记录

纯启发式压缩有点过于粗糙了。它会保留用户指令,却不一定能保留旧工具执行中真正重要的事实。比如早期 turn 里读过某个文件,发现一个关键函数;或者跑过一次测试,得到一个具体错误。只保留用户消息就导致上下文失真了。

参照 Codex,Context Engine 支持 model handoff compaction。压缩时,它会构造一个 summary request,让 fork 出的 worker 生成一段交接摘要:

private buildSummaryRequest(request: LlmRequest): LlmRequest {
  return {
    ...request,
    model: this.options.compressionModel ?? request.model,
    reasoning: this.options.compressionReasoning ?? request.reasoning,
    messages: [
      ...request.messages,
      {
        role: "user",
        content: CONTEXT_HANDOFF_SUMMARY_PROMPT
      }
    ],
    tools: []
  };
}

summary request 中,发送给模型的请求不带 tools 选项,压缩模型的任务只做把旧上下文整理成下一轮能接住的摘要,不做任何额外操作。这一步可能会导致缓存匹配失效,可以在后期实验优化。

压缩模型也可以和主模型不同:

const agent = new Agent({
  llm: mainLlm,
  model: "main-model",
  compressionLlm: cheapLlm,
  compressionModel: "small-summary-model"
});

这给后续成本优化留下了空间。主模型负责推理和工具调用,便宜模型负责上下文交接。

生成的摘要会以普通 user message 注入 request view:

Context checkpoint summary for the next model:
...

它不是伪装成 assistant 的旧回答,也不是偷偷塞进 system prompt,而是明确告诉模型:这是给下一轮恢复任务用的 checkpoint。

如果是手动调用:

await agent.compactHistory();

则可以把压缩后的 messages 写回 Agent history。自动压缩默认只影响本轮 request view,不破坏完整历史。

完成后的 summary 会和系统提示词、历史所有用户消息、最近几轮调用一起拼回上下文中:

[
  ...oldUserMessagesFromCompactedPrefix,
  {
    role: "user",
    content: "Context checkpoint summary for the next model:\n" + summaryText
  },
  ...keptRecentTurns
]

# 第六层:Dynamic Compression,让模型自己触发上下文折叠

上面的 handoff compaction 是 “一次性压缩”。它适合上下文快爆了的时候做一次急救。

另一种上下文压缩的方式是动态维护一组可复用 summary block:哪些旧目标的消息已经闭环,可以折叠;哪些还在当前工作路径上,必须保留。这个判断靠固定规则并不好做,因为只有模型知道当前任务真正依赖哪些上下文。

所以本章还实现了一个实验性的 dynamic compression。

开启后,Context Engine 会给 request 注入一套压缩协议,并可选暴露一个工具:

compact_context

这个工具不会直接让主模型手写 summary。它会启动一个 side worker,让压缩模型从当前可见上下文中选择已经闭合的范围,生成 summary block,然后把这个 block 安装到动态压缩状态里。

后续 request 会把对应旧消息替换成:

Dynamic context summary:
...

内部状态大概长这样:

export type DynamicCompressionBlock = {
  id: number;
  startIndex: number;
  endIndex: number;
  messageCount: number;
  coveredMessageIds?: string[];
  coveredToolCallIds?: string[];
  summary: string;
  summaryTokens: number;
  topic?: string;
  source?: "auto" | "tool" | "worker";
};

这里最重要的是  coveredToolCallIds  和 message fingerprints。动态压缩不是简单把一段文本替换掉,它还要知道这个 summary 覆盖了哪些原始消息、哪些工具调用,以及后续是否还能复用。

当当前上下文达到了最大限度的 50% 时,模型接收到的系统提示词会多一句(发现问题了吗)

<context_compression_nudge>
Estimated context is at or above ... tokens.
Before continuing, call compact_context ...
</context_compression_nudge>

模型会自己选择合适的时机调用这一工具,freeze 自身并触发 worker 进行 block summary,对当前上下文中选定的部分进行替换。

# Context Engine 的完整数据流

把上面几层合起来, ContextEngine.prepare()  的流程大概是:

raw LlmRequest
  -> buildRequestView
     -> build stable system prompt
     -> truncate oversized tool results
  -> estimate request tokens
     -> use provider usage if available
     -> fallback to heuristic estimate
  -> dynamic compression prepare
     -> inject protocol
     -> project active summary blocks
     -> maybe generate new block
  -> check budget
     -> if under threshold: return request view with metadata
     -> if over threshold: compact
        -> model handoff summary if available
        -> otherwise heuristic compaction
  -> final LlmRequest

代码结构也基本按这个顺序:

private prepareInternal(request: LlmRequest, summarize?: SummaryModel) {
  if (!this.options.enabled) {
    return { request };
  }
  const requestWithTruncatedTools = this.buildRequestView(request);
  const baseEstimate = estimateRequestTokens(requestWithTruncatedTools);
  const dynamicResult = this.dynamicCompressor.prepare(
    requestWithTruncatedTools,
    baseEstimate,
    summarize
  );
  return this.finishPreparedRequest(
    dynamicResult.request,
    dynamicResult.applied,
    summarize,
    "automatic"
  );
}

最后返回的  LlmRequest  会带上 context metadata:

metadata: {
  context: {
    compacted,
    estimatedInputTokens,
    tokenEstimateSource,
    compactionDecisionEstimatedInputTokens,
    compactionSummarySource,
    estimate,
    compaction,
    dynamicCompression
  }
}

这部分 metadata 是事件流的一部分。UI、CLI、日志系统可以知道这一轮有没有压缩、估算来源是什么、压缩前后 token 大概是多少。

# 接入 Agent Loop

将 Context Engine 接入 Agent Loop 的改动很小。原来 Agent 在每一轮直接调用 LLM:

const assistant = await this.completeAssistant(turn, rawRequest, streamEventSink);

现在中间加一层:

const prepared = await this.prepareRequest(rawRequest, options.context);
const request = prepared.request;
const assistant = this.withRequestContext(
  await this.completeAssistant(turn, request, streamEventSink),
  request
);

如果发生手动 history replacement,就更新  this.messages

if (prepared.historyReplacement) {
  this.messages.splice(0, this.messages.length, ...prepared.historyReplacement);
}

否则,自动上下文压缩只改变本轮 request,不改变完整 history。

Agent 的消息记录承担了历史事实记录和当前模型输入两部分职责。前者应该尽量完整,后者应该尽量有效。把这两者分开,后续 Memory、Planner、Reflector 才有继续演化的空间。

# 这一章完成后,Agent 发生了什么变化?

第二章结束时,Agent 解答的核心问题是:

  • 模型能不能调用工具?
  • 工具结果能不能回填?
  • 循环能不能终止?

第 2.5 章结束时,问题变成:

  • Agent 能不能接触真实文件、命令和网页?
  • 工具输出变长后,模型能力会不会失控?

第三章完成后,问题又往前走了一步:

  • 当信息开始变多时,Agent 能不能决定模型应该看到什么?

当前进度下,我们的项目已经具备了 Harness 的第一块核心能力:在完整历史和模型输入之间,建立一个可控、可测、可扩展的上下文管理层,也给我们的 Agent 提供了几个核心能力。

第一,长任务不再只能靠 “把所有东西塞进去”。工具结果可以在 request view 中二次截断,旧历史可以变成 handoff summary,动态压缩可以把闭合上下文折叠成 block。

第二,模型更容易保持任务主线。早期用户目标会被保留,最近 turn 会优先保留,旧的噪声细节不会无限占据注意力。

第三,Provider usage 开始成为预算的一部分。我们不再只靠本地粗估,而是能把上一轮真实 input/output token 反馈进下一轮决策。

第四,系统 prompt 开始有了稳定分层。默认指令、环境上下文、工具信息、用户 prompt 不再混成一个随手拼接的字符串。

第五,可观测性有了入口。每轮请求可以带 context metadata,后续 UI 可以展示压缩前后差异、触发原因和 summary 调用成本。

# 下一章做什么?

Context Engine 解决的是 “当前任务里,哪些信息应该进入模型上下文”。但它还不足以支撑 Agent 完成一项真实情景中的长任务。

为了提供完成长任务的能力,我们在下一节中会开始构建并完善 Memory System,为超长上下文任务提供一套高召回率的证据链:

  • 短期记忆(模型的 request-view 进行管理):当前 turn 和最近工具结果。
  • 中期记忆(compact、summary 系统承担了一部分职责):当前任务计划、已读文件、已修改文件、重要错误。
  • 长期记忆:用户偏好、项目约定、可复用知识。

到那时,Context Engine 会变成 Memory 的调度入口:它不只是压缩当前 history,还要决定从在已经进入冷存储的上下文中召回什么内容,放在 prompt 的什么位置,用多少 token。