LinOnward / Agent TutorialAgent Tutorial
章节16 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

15

上下文压缩

在不丢失目标、约束和证据的前提下,为长对话释放上下文空间。

本页目录
本章任务压缩包含 200 条 observation 的运行,并证明目标、计划和证据没有丢失。
章节目标实现一条可验证的上下文压缩链路:在安全边界生成结构化快照,保留最近原始事件,并让 Agent Loop 在上下文耗尽前继续工作。
开始之前

完整 Agent Loop 已经接入 Trace 与评测,能够在每轮重新组装 ContextSource 并记录模型、工具、计划与 Skill 事件。

本章涉及文件
  • src/compaction.ts
  • src/context.ts
  • src/types.ts
  • src/agent-loop.ts
  • tests/compaction.test.ts

长任务的问题不是“历史消息太多”这么简单。真正的风险是:为了节省 token 删除旧内容时,把用户约束、尚未完成的计划、工具证据或一次失败尝试一起删掉,Agent 随后重复工作、越权操作,甚至错误地宣布完成。

本章采用两层策略:Harness 保存一份可检查、可验证的结构化摘要,模型上下文只携带这份摘要和最近的原始事件;如果 Model Adapter 支持原生压缩,还可以把它作为模型侧连续性的优化。官方 Responses API 提供 responses.compact,返回可在后续请求中继续使用的压缩项;这些项应被视为不透明数据,而不是应用状态的替代品。参见 OpenAI:Compact conversationConversation state

Step 1先定义哪些内容绝不能丢

压缩不是截断字符串,而是把一段已发生的运行历史转换成下一阶段所需的最小充分状态。先扩展 AgentState

typescript
export interface CompactionSnapshot {  id: string;  createdAt: string;  sourceEventRange: { from: number; to: number };  goal: string;  constraints: string[];  decisions: Array<{ statement: string; evidenceEventIds: string[] }>;  completedWork: Array<{ stepId: string; evidence: string[] }>;  pendingWork: Array<{ stepId: string; reason: string }>;  acceptanceCriteria: AcceptanceCriterion[];  activeSkills: string[];  changedFiles: string[];  validationResults: ValidationRecord[];  unresolvedQuestions: string[];  failedAttempts: string[];  checksum: string;}export interface CompactionState {  snapshots: CompactionSnapshot[];  compactedThroughEvent: number;  providerItems?: unknown[];}export interface AgentState {  // 保留 runId、task、plan、messages、contextSources、skills、steps 等字段  compaction: CompactionState;}export interface ContextKindMap {  compaction_snapshot: true;}

compaction 加到 src/types.ts 的状态,在 createInitialState 中初始化为 { snapshots: [], compactedThroughEvent: 0 };把来源种类加到 src/context.ts。不要让完整 Loop 在第一次读取 state.compaction 时才发现字段不存在。

以下内容必须从权威状态重新注入,不能交给摘要模型自由改写:系统安全规则、用户原始目标、当前 TaskPlan、验收条件状态、权限决定、未完成工具调用以及 Harness 的预算与停止规则。

Step 2在耗尽前、且只在安全边界压缩

为上下文预留输出、工具结果和意外增长空间。不要等 API 返回 context-length error 才处理,也不要在模型已返回工具调用、但工具结果还没全部写回时压缩。

typescript
export interface ContextUsage {  estimatedInputTokens: number;  contextWindowTokens: number;  reservedOutputTokens: number;}export function shouldCompact(usage: ContextUsage): boolean {  const usableInput = usage.contextWindowTokens - usage.reservedOutputTokens;  const trigger = Math.floor(usableInput * 0.8);  return usage.estimatedInputTokens >= trigger;}export function isSafeCompactionBoundary(input: {  status: AgentState["status"];  pendingToolCallCount: number;  pendingOutputCount: number;}): boolean {  return (    input.status === "running" &&    input.pendingToolCallCount === 0 &&    input.pendingOutputCount === 0  );}

pendingToolCallCountpendingOutputCount 由 Loop 当前批次显式传入;它们不是前面从未加入 AgentState 的隐含字段。恢复章会用 durable tool intent 记录跨进程的 in-flight 状态。

0.8 是 Harness 的保守策略,不是模型的固定限制。生产环境应使用模型暴露的上下文窗口和真实 token counter;没有精确 tokenizer 时,估算器必须倾向高估,并记录估算值与实际 usage 的偏差。

适合压缩的时机包括:一个计划 Step 刚完成、工具批次已经完整落盘、进入新的调查阶段,或下一轮组装预计超过阈值。禁止在以下时机压缩:并行工具批次执行中、等待用户批准时尚未保存审批请求、正在流式接收模型输出,或事件日志尚未提交。

Step 3从状态与事件生成结构化快照

不要让模型面对无边界的“总结一下”。Compactor 接收确定的事件区间和当前状态投影,只返回可校验的 JSON:

typescript
export interface CompactorInput {  task: string;  plan: TaskPlan;  events: AgentEvent[];  activeSkillNames: string[];  changedFiles: string[];}export interface Compactor {  compact(input: CompactorInput): Promise<Omit<CompactionSnapshot, "id" | "createdAt" | "checksum">>;}export async function buildCompactionSnapshot(options: {  state: AgentState;  events: AgentEvent[];  compactor: Compactor;}): Promise<CompactionSnapshot> {  if (!options.state.plan) throw new Error("cannot compact before plan initialization");  const from = options.state.compaction.compactedThroughEvent + 1;  const to = options.events.at(-1)?.sequence ?? from - 1;  const draft = await options.compactor.compact({    task: options.state.task,    plan: options.state.plan,    events: options.events.filter((event) => event.sequence >= from && event.sequence <= to),    activeSkillNames: Object.keys(options.state.skills.activeSkills),    changedFiles: options.state.changedFiles,  });  const snapshotWithoutChecksum = {    ...draft,    id: crypto.randomUUID(),    createdAt: new Date().toISOString(),    sourceEventRange: { from, to },  };  return {    ...snapshotWithoutChecksum,    checksum: sha256(canonicalJson(snapshotWithoutChecksum)),  };}

压缩 Prompt 要求每个决定引用原始 eventId,明确列出失败尝试与未解决问题,并写明“没有证据就保留 unknown,不得推断完成”。结构化输出仍是不可信输入:Harness 需要 schema 校验、长度限制和事实对照。

如果 Model Adapter 使用 OpenAI 原生压缩,可以另外保存其返回项:

typescript
const compacted = await openai.responses.compact({  model,  input: responseItems,  instructions: systemPrompt,});state.compaction.providerItems = compacted.output;

原生 providerItems 只用于同一 Provider 的续轮输入。应用自己的 CompactionSnapshot 仍负责审计、跨进程恢复、UI 展示和 Provider 切换。

Step 4验证摘要覆盖了关键事实

先做确定性覆盖检查,再接受摘要。计划和验收条件来自状态,因此可以逐项比对:

typescript
export function validateCompaction(  snapshot: CompactionSnapshot,  state: AgentState,  events: AgentEvent[],): void {  if (snapshot.goal !== state.task) throw new Error("snapshot goal changed");  if (!state.plan) throw new Error("cannot validate compaction without a plan");  assertStringSetEqual(snapshot.constraints, state.constraints, "constraints");  assertCriteriaEqual(snapshot.acceptanceCriteria, state.plan.acceptanceCriteria);  assertCompletedWorkEqual(snapshot.completedWork, state.plan.steps);  const pendingIds = new Set(snapshot.pendingWork.map((step) => step.stepId));  for (const step of state.plan.steps.filter((step) => step.status !== "completed")) {    if (!pendingIds.has(step.id)) throw new Error(`missing pending step: ${step.id}`);  }  for (const file of state.changedFiles) {    if (!snapshot.changedFiles.includes(file)) throw new Error(`missing changed file: ${file}`);  }  assertStringSetEqual(snapshot.activeSkills, Object.keys(state.skills.activeSkills), "skills");  assertStringSetEqual(snapshot.failedAttempts, state.failedAttempts, "failed attempts");  if (canonicalJson(snapshot.validationResults) !== canonicalJson(state.validations)) {    throw new Error("validation evidence changed during compaction");  }  const unresolvedQuestions = state.pendingUserInput ? [state.pendingUserInput.question] : [];  assertStringSetEqual(snapshot.unresolvedQuestions, unresolvedQuestions, "unresolved questions");  for (const decision of snapshot.decisions) {    for (const eventId of decision.evidenceEventIds) {      if (        !events.some(          (event) =>            event.eventId === eventId &&            event.sequence >= snapshot.sourceEventRange.from &&            event.sequence <= snapshot.sourceEventRange.to,        )      ) {        throw new Error(`unknown evidence event: ${eventId}`);      }    }  }  if (snapshot.sourceEventRange.to < snapshot.sourceEventRange.from) {    throw new Error("empty compaction range");  }}function assertCriteriaEqual(  actual: AcceptanceCriterion[],  expected: AcceptanceCriterion[],): void {  if (canonicalJson(actual) !== canonicalJson(expected)) {    throw new Error("acceptance criteria changed during compaction");  }}function assertCompletedWorkEqual(actual: CompactionSnapshot["completedWork"], steps: PlanStep[]): void {  const expected = steps    .filter((step) => step.status === "completed")    .map((step) => ({ stepId: step.id, evidence: step.evidence }));  if (canonicalJson(actual) !== canonicalJson(expected)) {    throw new Error("completed work changed during compaction");  }}

assertCriteriaEqual 逐项比较 ID、description、status 与 evidence;assertCompletedWorkEqual 要求每个 completed Step 的 ID 和证据完全一致,并拒绝摘要凭空增加完成项。validationResults、未解决问题与决定证据也应按同样方式对照权威事件投影。只核对“ID 存在”不等于 verified。

校验失败时保留原上下文并发出 compaction_rejected,不能使用一个“差不多”的摘要继续。连续失败时停止并报告原因,让操作者扩大预算、拆分任务或修复 Compactor。

Step 5保留摘要、原始尾部与证据索引

压缩成功后也不删除审计日志。只改变下一次模型请求的上下文投影:旧事件留在 durable store,中间历史由快照代替,最近事件保持原文。

typescript
export interface CompactedContext {  snapshot: CompactionSnapshot;  rawTail: AgentEvent[];}export function projectCompactedContext(options: {  snapshot: CompactionSnapshot;  events: AgentEvent[];  rawTailSize: number;}): CompactedContext {  const rawTailBoundary = selectRawTailBoundary(    options.events,    options.snapshot,    options.rawTailSize,  );  return {    snapshot: options.snapshot,    rawTail: options.events.filter((event) => event.sequence >= rawTailBoundary),  };}function selectRawTailBoundary(  events: AgentEvent[],  snapshot: CompactionSnapshot,  rawTailSize: number,): number {  const lastN = events.at(-rawTailSize)?.sequence ?? snapshot.sourceEventRange.from;  const currentStep = events.findLast((event) => event.type === "step_started")?.sequence;  const lastUserInput = events.findLast((event) => event.type === "user_input")?.sequence;  const lastToolBatch = events.findLast((event) => event.type === "tool_batch_started")?.sequence;  return Math.min(    lastN,    ...[currentStep, lastUserInput, lastToolBatch].filter(      (sequence): sequence is number => sequence !== undefined,    ),  );}export function compactionAsContextSources(context: CompactedContext): ContextSource[] {  return [    {      id: `compaction:${context.snapshot.id}`,      kind: "compaction_snapshot",      label: "Verified task memory",      content: JSON.stringify(context.snapshot),      priority: 95,    },    ...context.rawTail.map(eventAsContextSource),  ];}

selectRawTailBoundary 从“最近 N 条”“当前 Step 开始”“最后一次用户输入”“最近完整工具批次开始”四个边界中选择最早者。因此即使快照覆盖到最新事件,rawTail 仍会与快照末尾有一小段有意重叠,而不是错误地变成空数组。体积仍过大时,优先把大型工具输出存成 artifact,只在上下文里保留路径、hash、截取范围和结论。

Step 6把压缩门禁接入 Agent Loop

压缩发生在“状态更新完成”与“下一轮 buildModelRequest”之间。它自身也是可观察状态转换:

typescript
export async function maybeCompactContext(options: {  state: AgentState;  events: AgentEvent[];  usage: ContextUsage;  pendingToolCallCount: number;  pendingOutputCount: number;  compactor: Compactor;  emit: (event: AgentEventInput) => Promise<void>;}): Promise<void> {  if (!shouldCompact(options.usage)) return;  if (    !isSafeCompactionBoundary({      status: options.state.status,      pendingToolCallCount: options.pendingToolCallCount,      pendingOutputCount: options.pendingOutputCount,    })  ) {    return;  }  await options.emit({ type: "compaction_started", through: options.events.at(-1)?.sequence ?? 0 });  const snapshot = await buildCompactionSnapshot(options);  validateCompaction(snapshot, options.state, options.events);  options.state.compaction.snapshots.push(snapshot);  options.state.compaction.compactedThroughEvent = snapshot.sourceEventRange.to;  await options.emit({    type: "compaction_completed",    snapshotId: snapshot.id,    sourceEventRange: snapshot.sourceEventRange,  });}

在 Loop 中的顺序如下:

text
OBSERVE tool resultsUPDATE plan and acceptance evidencePERSIST events and stateMAYBE COMPACT at a safe boundaryASSEMBLE authoritative state + latest snapshot + rawTailCALL model

不要每轮压缩。每次压缩都会增加延迟与费用,也会产生信息损耗。只有超过阈值或阶段边界明确要求时才执行,并记录压缩前后 token 数、覆盖事件区间和拒绝原因。

Step 7用不变量测试信息保真

创建 tests/compaction.test.ts。先测试临界点,再测试内容覆盖:

typescript
it("keeps authoritative task state and only replaces old history", async () => {  const state = createLongRunningFixture();  const events = createEventsThroughCompletedStep();  await maybeCompactContext({    state,    events,    usage: {      estimatedInputTokens: 82_000,      contextWindowTokens: 100_000,      reservedOutputTokens: 10_000,    },    compactor: createFakeCompactor(),    emit: appendFixtureEvent,  });  const snapshot = state.compaction.snapshots.at(-1);  expect(snapshot?.goal).toBe(state.task);  expect(snapshot?.pendingWork.map((step) => step.stepId)).toContain("run-tests");  expect(snapshot?.changedFiles).toEqual(state.changedFiles);  expect(state.plan.steps).toEqual(originalPlan);});

继续覆盖:阈值以下不压缩、有 pending tool call 时不压缩、摘要漏掉验收条件时拒绝、引用不存在事件时拒绝、重复压缩区间不重叠、rawTail 保留完整工具批次、超大 observation 转 artifact,以及 Provider 原生压缩失败时仍能使用应用快照继续。