教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
上下文压缩
在不丢失目标、约束和证据的前提下,为长对话释放上下文空间。
预计 60 分钟本页目录
完整 Agent Loop 已经接入 Trace 与评测,能够在每轮重新组装 ContextSource 并记录模型、工具、计划与 Skill 事件。
src/compaction.tssrc/context.tssrc/types.tssrc/agent-loop.tstests/compaction.test.ts
长任务的问题不是“历史消息太多”这么简单。真正的风险是:为了节省 token 删除旧内容时,把用户约束、尚未完成的计划、工具证据或一次失败尝试一起删掉,Agent 随后重复工作、越权操作,甚至错误地宣布完成。
本章采用两层策略:Harness 保存一份可检查、可验证的结构化摘要,模型上下文只携带这份摘要和最近的原始事件;如果 Model Adapter 支持原生压缩,还可以把它作为模型侧连续性的优化。官方 Responses API 提供 responses.compact,返回可在后续请求中继续使用的压缩项;这些项应被视为不透明数据,而不是应用状态的替代品。参见 OpenAI:Compact conversation 与 Conversation state。
Step 1先定义哪些内容绝不能丢
压缩不是截断字符串,而是把一段已发生的运行历史转换成下一阶段所需的最小充分状态。先扩展 AgentState:
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 才处理,也不要在模型已返回工具调用、但工具结果还没全部写回时压缩。
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 );}pendingToolCallCount 与 pendingOutputCount 由 Loop 当前批次显式传入;它们不是前面从未加入 AgentState 的隐含字段。恢复章会用 durable tool intent 记录跨进程的 in-flight 状态。
0.8 是 Harness 的保守策略,不是模型的固定限制。生产环境应使用模型暴露的上下文窗口和真实 token counter;没有精确 tokenizer 时,估算器必须倾向高估,并记录估算值与实际 usage 的偏差。
适合压缩的时机包括:一个计划 Step 刚完成、工具批次已经完整落盘、进入新的调查阶段,或下一轮组装预计超过阈值。禁止在以下时机压缩:并行工具批次执行中、等待用户批准时尚未保存审批请求、正在流式接收模型输出,或事件日志尚未提交。
Step 3从状态与事件生成结构化快照
不要让模型面对无边界的“总结一下”。Compactor 接收确定的事件区间和当前状态投影,只返回可校验的 JSON:
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 原生压缩,可以另外保存其返回项:
const compacted = await openai.responses.compact({ model, input: responseItems, instructions: systemPrompt,});state.compaction.providerItems = compacted.output;原生 providerItems 只用于同一 Provider 的续轮输入。应用自己的 CompactionSnapshot 仍负责审计、跨进程恢复、UI 展示和 Provider 切换。
Step 4验证摘要覆盖了关键事实
先做确定性覆盖检查,再接受摘要。计划和验收条件来自状态,因此可以逐项比对:
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,中间历史由快照代替,最近事件保持原文。
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”之间。它自身也是可观察状态转换:
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 中的顺序如下:
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。先测试临界点,再测试内容覆盖:
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 原生压缩失败时仍能使用应用快照继续。