OpenClaw 上下文工程深度剖析:五层防线如何让 Agent 永续运行

从源码层面剖析 OpenClaw 的五层上下文管理防线:Prompt Cache Boundary、Tool Result Guard、LLM-based Compaction、Session Truncation、Context Engine Plugin,以及它们之间的 trade-off 设计。

一、大多数人对 Context Engineering 的误解

如果你问一个有经验的 LLM 应用开发者"上下文管理是什么",大概率得到的回答是"token 快满了就截断"。这个理解不能说错,但它就像把操作系统的内存管理理解为"内存满了就 kill 进程"——技术上成立,但遗漏了所有有趣的部分。

OpenClaw 是一个可以跨 Signal、Telegram、Discord、Web 等多渠道运行的 AI agent 平台。一个 OpenClaw agent session 可能持续数天甚至数周,期间会经历数百次 tool 调用、读取大量文件、执行 shell 命令、生成图片——每一个操作都在向上下文窗口注入内容。如果只靠"快满了就截断",agent 要么频繁丢失关键任务状态,要么在 API 调用成本上烧掉不必要的钱。

OpenClaw 的上下文管理不是一个算法,而是一个五层防线体系。每一层解决不同时间尺度上的上下文压力:从单次 API 调用的字节级缓存稳定性,到跨天对话的 JSONL 文件膨胀控制。这个分层设计的核心 trade-off 是:用可预测的、分级的信息损失,换取系统的永续运行能力

让我们从源码开始,逐层拆解。

二、全景:一条消息从进入到送达模型的旅程

在深入每一层之前,先看整体数据流。以下是一条用户消息从 session 文件到达 LLM API 的完整管线:

Session File (JSONL on disk)
    │
    ▼
SessionManager.open()                    ← 加载历史
    │
    ▼
stripToolResultDetails()                 ← 安全:移除 toolResult.details
    │
    ▼
validateReplayTurns()                    ← 结构校验:确保消息配对完整
    │
    ▼
limitHistoryTurns(messages, limit)       ← 按 DM/channel 限制保留最近 N 轮
    │
    ▼
repairToolUseResultPairing()             ← 修复截断后的 tool 孤儿
    │
    ▼
assembleAttemptContextEngine()           ← Context Engine 插件介入
    │
    ▼
installToolResultContextGuard()          ← 实时 tool result 预算守卫
    │
    ▼
buildAgentSystemPrompt()                 ← 动态组装 system prompt
    │  ├─ 工具列表 + 摘要
    │  ├─ Skills、Memory、Docs 等 section
    │  ├─ Bootstrap 文件 (AGENTS.md, SOUL.md...)
    │  ├─ SYSTEM_PROMPT_CACHE_BOUNDARY ← 缓存切割线
    │  └─ 动态内容 (Group Chat Context, Runtime)
    │
    ▼
Model API Call (system + messages + tools)

这个管线里每一步都不是随意的——它们的顺序、组合方式、以及在出错时的 fallback 行为,构成了 OpenClaw 上下文工程的核心设计。接下来逐层展开。

三、第一层:Prompt Cache Boundary —— 一条注释值多少钱

Anthropic 的 API 有一个重要的性能特性:如果连续两次请求的 system prompt 前缀字节完全一致,第二次请求可以复用缓存的 KV 向量,显著降低延迟和成本。这就是 prompt prefix caching。

OpenClaw 对这个特性的利用精确到了"在 system prompt 的第几个字节开始放动态内容"这个粒度。

系统在 system-prompt-cache-boundary.ts 中定义了一个缓存切割线:

export const SYSTEM_PROMPT_CACHE_BOUNDARY =
  "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";

buildAgentSystemPrompt() 在组装 system prompt 时(system-prompt.ts:681),把所有稳定内容——工具列表、Safety 规则、Skills 描述、Bootstrap 文件——放在这条线之上,把所有动态内容——Group Chat Context、Heartbeat prompt、Runtime 元数据——放在这条线之下

为什么这么做?因为对于同一个 session 的连续 turn,工具集合、安全规则、项目上下文文件这些东西几乎不变。如果每次都重新计算这些 token 的 KV cache,是纯粹的浪费。但 Group Chat Context 可能每轮都在变(比如有新成员加入聊天),所以放在缓存边界之后,只影响后缀部分。

更精细的是,OpenClaw 还对 system prompt 内部的内容做了确定性排序prompt-cache-stability.ts)。capability 列表会经过 normalizePromptCapabilityIds() 排序后再拼接,工具也按固定的 toolOrder 数组排列(system-prompt.ts:281-308)。如果你不做这一步,JavaScript 的 Object.keys() 在某些引擎上的遍历顺序可能不稳定,导致两次构建的 system prompt 字节不同,白白浪费缓存。

Cache Break 检测:当缓存失效时,你需要知道为什么

光做缓存还不够,你还需要知道缓存什么时候坏了、为什么坏了。OpenClaw 在 prompt-cache-observability.ts 中实现了一个观测系统。每次 API 调用前后,系统会:

  1. 构建快照:对 system prompt 取 SHA-256 digest,对 tool 名称集合做排序后取 digest
  2. 比较快照:跟上一轮的快照 diff,识别是 model 变了、provider 变了、system prompt 变了、还是 tool set 变了
  3. 检测 cache break:如果本轮的 cacheRead token 数低于上轮的 95%,且下降超过 1000 token,判定为一次 cache break
const hasMeaningfulDrop =
  cacheRead < previousCacheRead * MAX_STABLE_CACHE_READ_RATIO &&
  tokenDrop >= MIN_CACHE_BREAK_TOKEN_DROP;

这个 MAX_STABLE_CACHE_READ_RATIO = 0.95MIN_CACHE_BREAK_TOKEN_DROP = 1000 的组合是一个经过工程考量的阈值——太敏感会被正常的历史消息增长触发假阳性,太迟钝则会错过真正的系统配置变更导致的缓存失效。

这层防线解决的问题时间尺度最短:每一次 API 调用。它不减少任何信息,只是确保"不变的东西不被重复计算"。

四、第二层:Tool Result Context Guard —— 在 Tool Loop 中实时防御上下文爆炸

一个 agent 在执行任务时,经常需要连续调用多个 tool——读文件、跑命令、搜索代码——每个 tool 的返回值都会注入上下文。一次 grep 可能返回几千行匹配结果,一次 exec 可能输出一个完整的测试报告。如果不加控制,三四个 tool 调用就能把 128K 上下文窗口填满。

OpenClaw 的 Tool Result Context Guard(tool-result-context-guard.ts)是一个安装在 agent 的 transformContext 钩子上的实时守卫,在每次 LLM 调用前对所有消息做预算检查。

三道预算线

const CONTEXT_INPUT_HEADROOM_RATIO = 0.75;    // 总上下文预算 = 窗口的 75%
const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5; // 单个 tool result ≤ 窗口的 50%
const PREEMPTIVE_OVERFLOW_RATIO = 0.9;        // 超过 90% 触发全量 compaction

对于一个 128K token 的窗口,这意味着:

  • 总上下文字符预算 ≈ 128K × 4(chars/token) × 0.75 = 384K 字符
  • 单个 tool result 上限 ≈ 128K × 2(chars/token for tool) × 0.5 = 128K 字符
  • 超过 90% 后触发全量压缩

注意这里对 tool result 用了不同的 chars-per-token 估算系数。CHARS_PER_TOKEN_ESTIMATE = 4(通用消息),而 TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE = 2(tool 输出)。为什么 tool 输出用更小的系数?因为 tool 返回的内容往往是代码、JSON、日志——这些文本的 token 密度高于自然语言,同样长度的字符串会消耗更多 token。用 2 chars/token 而不是 4 是一个保守的安全假设,宁愿高估也不要低估。

压缩方向:newest-first 的反直觉选择

当总上下文超预算时,guard 需要压缩已有的 tool result。这里有一个不显眼但关键的设计决策:从最新的 tool result 开始压缩tool-result-context-guard.ts:113):

// Compact newest-first so the cached prefix stays intact:
// rewriting messages[k] for small k invalidates the
// provider prompt cache from that point onward.
for (let i = messages.length - 1; i >= 0; i--) {

直觉上,你可能觉得应该压缩最旧的——反正老的 tool result 已经不那么相关了。但 OpenClaw 选择了 newest-first。原因写在注释里:修改消息数组中靠前位置的元素,会使 prompt prefix cache 从该位置起全部失效。如果你压缩了 messages[2],从 messages[2] 到消息末尾的所有缓存都作废了。而如果你压缩 messages[n-1](最新的),只有最后一条消息的缓存受影响。

这是一个缓存稳定性 vs. 信息相关性的显式 trade-off:牺牲最新(可能更相关)的 tool result 内容,保护整个对话前缀的缓存命中率。在成本敏感的场景下,这个 trade-off 是值得的——被压缩的 tool result 还保留着 [compacted: tool output removed to free context] 占位符,agent 知道那里曾经有内容,如果需要可以重新调用 tool 获取。

Smart Truncation:保留尾部的错误信息

当单个 tool result 超过上限时,truncateToolResultText()tool-result-truncation.ts:74-116)采用了一个比简单截断更聪明的策略。它会检查文本的最后 2000 个字符是否包含 error、exception、failed 等关键词:

function hasImportantTail(text: string): boolean {
  const tail = text.slice(-2000).toLowerCase();
  return (
    /\b(error|exception|failed|fatal|traceback|panic|...)\b/.test(tail) ||
    /\}\s*$/.test(tail.trim()) ||   // JSON 闭合结构
    /\b(total|summary|result|complete|finished|done)\b/.test(tail)
  );
}

如果尾部有重要信息,就采用 head(70%) + tail(30%) 的策略,中间插入 ⚠️ [... middle content omitted — showing head and tail ...]。这个设计解决了一个常见的 agent 痛点:一个跑了 5 分钟的测试命令,输出 10000 行日志,最后 3 行是 "3 tests failed, 47 passed"。如果只保留头部,agent 看不到测试结果;如果只保留尾部,agent 看不到测试的开头配置。head+tail 是这个约束下最合理的折中。

溢出级联:从 tool 压缩到全量 compaction

如果 tool result 压缩之后,总上下文仍然超过 90%(PREEMPTIVE_OVERFLOW_RATIO),说明问题不在 tool result 上——可能是对话本身太长了。这时 guard 会抛出一个特殊的错误:

if (postEnforcementChars > preemptiveOverflowChars) {
  throw new Error(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
}

这个错误会被 attempt.ts 中的 overflow recovery 逻辑捕获,触发下一层防线:全量 compaction。这就是"防线"的含义——每层都有清晰的责任边界和明确的升级路径。

五、第三层:Compaction —— 用 LLM 自身做"记忆萃取"

当 tool result 压缩不够用时,系统需要对整个对话历史做有损压缩。这就是 compaction——用 LLM 自身来总结旧的对话,生成摘要后替换原始消息。

Compaction 的核心代码在 compaction.ts,但它的设计决策比代码更有意思。

为什么不用简单的 sliding window?

最简单的上下文管理是 sliding window:保留最近 N 条消息,丢弃之前的。但对一个执行复杂任务的 agent 来说,这会导致灾难性的信息丢失——agent 可能在第 5 轮决定了用某种策略处理一个 bug,到第 50 轮 sliding window 把那个决策丢了,agent 开始用另一种策略重做同样的事。

OpenClaw 选择了 LLM-based summarization:让模型读完旧消息,生成一个保留关键信息的摘要。这更贵(每次 compaction 需要额外的 LLM 调用),但信息保留质量远高于盲目截断。

分阶段总结:大象也能吃

长对话可能有几十万 token,不可能一次性塞给 LLM 做总结。summarizeInStages()compaction.ts:396-460)实现了一个分治策略:

  1. 按 token 比例切分splitMessagesByTokenShare() 把消息分成 N 块(默认 2 块),每块占总 token 的约 40%(BASE_CHUNK_RATIO = 0.4
  2. 逐块总结:每块独立调用 LLM 生成摘要
  3. 合并摘要:如果有多个部分摘要,再做一次 merge 总结

自适应 chunk ratio 是一个值得注意的细节。computeAdaptiveChunkRatio()compaction.ts:208-227)会检测平均消息大小:如果平均消息占上下文的 10% 以上(说明有巨型 tool result),就把 chunk 比例从 40% 降到最低 15%,避免单个 chunk 超过模型的处理能力。

export function computeAdaptiveChunkRatio(
  messages: AgentMessage[], contextWindow: number
): number {
  const avgRatio = (safeAvgTokens) / contextWindow;
  if (avgRatio > 0.1) {
    const reduction = Math.min(avgRatio * 2,
      BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
    return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
  }
  return BASE_CHUNK_RATIO;
}

三层 Fallback:总结失败时的优雅降级

Compaction 的鲁棒性设计是源码中最值得品味的部分。summarizeWithFallback()compaction.ts:326-394)实现了三层降级:

Level 1:全量总结失败 → 尝试部分总结。把"oversized"的消息(单条占上下文 > 50%)排除,只总结小消息,并附注 [Large assistant (~47K tokens) omitted from summary]

Level 2:部分总结也失败 → 尝试更小的消息子集。

Level 3:一切总结都失败 → 返回一个纯文本记录:Context contained 42 messages (3 oversized). Summary unavailable due to size limits.

这个 fallback 链的设计哲学是:永远不要让 compaction 失败导致 session 不可用。哪怕最后只留下一行"这里有 42 条消息我总结不了",也比直接 crash 好——至少 agent 知道它丢了一些上下文,可以决定是否需要重新获取。

Identifier 保留:总结可以丢细节,但不能丢 UUID

Compaction 指令中有一条严格的规则(compaction.ts:34-36):

const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
  "Preserve all opaque identifiers exactly as written " +
  "(no shortening or reconstruction), including UUIDs, " +
  "hashes, IDs, tokens, API keys, hostnames, IPs, ports, " +
  "URLs, and file names.";

为什么单独强调这个?因为 LLM 在做摘要时有一个自然倾向:把 a1b2c3d4-e5f6-7890-abcd-ef1234567890 "简化"为"一个 UUID"。但如果 agent 后续需要用这个 UUID 调用 API,简化就变成了破坏。同样,文件路径被"总结"为"某个配置文件"后,agent 就无法执行 read 操作了。

这个 policy 是可配置的(identifierPolicy: "strict" | "off" | "custom"),但默认值是 strict,说明 OpenClaw 团队在实际使用中被这个问题伤过。

安全边界:toolResult.details 永远不进入总结

每次进入总结流程之前,stripToolResultDetails() 都会被调用(compaction.ts:100-102)。注释说得很直白:

// SECURITY: toolResult.details can contain untrusted/verbose payloads;
// never include in LLM-facing compaction.
const safe = stripToolResultDetails(messages);

toolResult.details 是 tool 的补充信息字段,可能包含原始的 HTTP 响应、命令输出等未经过滤的内容。如果直接喂给总结用的 LLM,存在 prompt injection 风险——tool 的返回内容可能包含类似 "Ignore previous instructions..." 的恶意文本。通过在进入 LLM 之前剥离这些字段,OpenClaw 在 compaction 流程中建立了一道安全边界。

六、第四层:Session Truncation —— JSONL 不能无限膨胀

Compaction 解决了上下文窗口的问题,但它带来了一个新问题:磁盘空间。

OpenClaw 的 session 以 JSONL 格式持久化——每条消息一行 JSON。Compaction 会在文件末尾追加一个 compaction entry(包含摘要),并标记 firstKeptEntryId(摘要覆盖的最后一条保留消息)。但原始消息仍然在文件里——compaction 是逻辑上的替换,不是物理上的删除。

经过多轮 compaction 后,session 文件会膨胀到远超实际需要的大小。session-truncation.ts 解决了这个问题。

DAG Re-parenting:比你想的复杂

OpenClaw 的 session 不是一个简单的消息数组——它是一个 DAG(有向无环图)。每个 entry 有一个 parentId,指向它在对话树中的父节点。分支(branch)、标签(label)、分支摘要(branch_summary)都是这个 DAG 的一部分。

当我们删除一个消息时,它的子节点不能变成孤儿。truncateSessionAfterCompaction()session-truncation.ts:33-218)通过向上遍历 parent chain 找到最近的存活祖先,把孤儿节点 re-parent 上去:

let newParentId = entry.parentId;
while (newParentId !== null && removedIds.has(newParentId)) {
  const parent = entryById.get(newParentId);
  newParentId = parent?.parentId ?? null;
}

只有 compaction 前、且被摘要覆盖的 message 类型 entry 会被删除。非消息类型的 session 状态(model_change、thinking_level_change、session_info 等)即使在已总结区间内也会保留——它们记录了 session 级别的配置变更,丢了会导致 session 行为不一致。

truncation 操作是原子的:先写临时文件,再 rename 替换原文件。如果中途失败,原文件不受影响。还支持可选的 archive 参数,在截断前备份原始文件。

数字感觉

假设一个 session 经历了 10 轮 compaction,每轮产生约 200 条消息。截断前文件可能有 2000+ 条 entry,截断后只保留最近一轮 compaction 的尾部约 200 条 + 所有非消息 session state。日志输出的 reduction 百分比通常在 60-90%。

七、第五层:Context Engine Plugin —— 为什么要把上下文管理做成可插拔的

前面四层都是 OpenClaw 内置的上下文管理。但 context-engine/types.ts 定义了一个完整的插件接口,允许第三方完全替换上下文管理策略。

ContextEngine 接口(types.ts:104-231)定义了七个生命周期方法:

bootstrap  → 初始化/导入历史上下文
maintain   → 每轮后的 transcript 维护
ingest     → 持久化单条消息
ingestBatch→ 批量持久化
afterTurn  → 一轮结束后的生命周期钩子
assemble   → 每次 LLM 调用前组装上下文(核心方法)
compact    → 上下文压缩

核心方法是 assemble():在每次 LLM 调用前,接收当前所有消息和 token 预算,返回最终要发给模型的有序消息列表、估算 token 数、以及可选的 system prompt 补充内容。

Legacy Engine:最小可行实现

内置的 LegacyContextEnginelegacy.ts)是一个透传实现:

async assemble(params) {
  // Pass-through: the existing sanitize → validate → limit →
  // repair pipeline in attempt.ts handles context assembly.
  return {
    messages: params.messages,
    estimatedTokens: 0,
  };
}

ingest 是 no-op(SessionManager 已经在持久化了),assemble 是 pass-through(前面的管线已经处理了),compact 委托给内置的 compaction 逻辑。这意味着所有前面描述的四层防线,在 legacy engine 下都正常工作。

为什么需要插件化?

想象以下场景:

  • RAG-augmented context:一个 plugin 在 assemble() 时,根据当前用户消息做向量检索,把相关的历史片段注入上下文,而不是简单地保留最近的消息
  • 跨 session 记忆:一个 plugin 在 afterTurn() 时,把重要的对话信息写入外部知识库,在后续 session 的 bootstrap() 时加载回来
  • 自定义压缩策略:一个 plugin 用 embedding 相似度而非时间顺序决定保留哪些消息

通过让 ContextEngine 成为可插拔的合约,OpenClaw 允许在不修改核心管线的前提下实验完全不同的上下文策略。注册方式也很干净:

registerContextEngineForOwner("legacy",
  () => new LegacyContextEngine(), "core",
  { allowSameOwnerRefresh: true }
);

一个外部 plugin 只需要注册自己的 engine,就可以接管整个上下文生命周期。

八、设计张力:两个不可能同时满足的目标

贯穿 OpenClaw 上下文工程的核心矛盾是 prompt cache stability vs. context freshness

Prompt cache 要求前缀字节不变——system prompt 要稳定,消息历史最好也别动。这对成本和延迟很重要:一个 1M token 的上下文窗口,如果每次都从零计算,比缓存命中贵几倍。

但上下文新鲜度要求系统积极地修改历史——压缩旧 tool result、总结旧消息、注入新的检索结果。每一次修改都可能打破缓存。

OpenClaw 的解决方案不是在两者间"平衡",而是在不同层面做不同的选择:

层级 选择 代价
System Prompt 缓存优先:CACHE_BOUNDARY 隔离动态内容 动态内容不受缓存保护
Tool Result Guard 缓存优先:newest-first 压缩 最新的 tool result 先被牺牲
Compaction 新鲜度优先:重写历史 整个缓存前缀失效
Session Truncation 无关:磁盘操作不影响 API 调用
Context Engine 取决于 plugin 实现 plugin 自己的 trade-off

这个分层策略的精妙之处在于:便宜的操作优先保护缓存,昂贵的操作才允许打破缓存。Tool result 压缩在每次 LLM 调用前都会发生,所以要保护缓存;Compaction 只在溢出时才触发,打破一次缓存的成本可以被后续多次调用分摊。

九、还有一个隐藏层:Transcript Repair

严格来说,transcript repair 不是"上下文管理",但它是使上下文管理成为可能的基础设施。

Anthropic 的 API 有一个严格要求:每个 assistant 消息中的 toolCall 必须紧跟一个匹配的 toolResult。但 OpenClaw 的历史消息经过 limitHistoryTurns() 截断后,可能出现 tool call 和 tool result 分离的情况——tool call 在保留的消息里,但对应的 tool result 在被截掉的消息里(或反过来)。

repairToolUseResultPairing()session-transcript-repair.ts:355-530)做了以下修复:

  1. 移动错位的 toolResult:如果 toolResult 出现在非预期位置(比如在下一个 user turn 之后),把它移到对应的 assistant turn 之后
  2. 合成缺失的 toolResult:对于找不到 result 的 tool call,插入一个 isError: true 的占位 result
  3. 去重:对同一个 tool call ID 的多个 result,只保留第一个
  4. 丢弃孤儿 toolResult:找不到对应 tool call 的 toolResult 被丢弃

这个修复在每次上下文组装时都会运行,是后续所有 LLM 调用能正常进行的前提。如果没有这层修复,任何涉及历史截断的操作都可能导致 API 返回 400 错误。

十、面试深度拷打

Q1:OpenClaw 的上下文管理和简单的 sliding window 有什么本质区别?

表面答案:OpenClaw 用 LLM 总结替代了简单截断,保留更多语义信息。

源码级答案:区别不在于一个算法,而在于五层防线的分工。Sliding window 只有一个决策点(保留多少),OpenClaw 有五个——prompt cache boundary 控制字节级缓存、tool result guard 控制单次调用的实时预算、compaction 控制长期历史、session truncation 控制磁盘、context engine 控制策略。每层的触发条件、压缩粒度、和信息损失模式都不同。更关键的是级联升级机制:tool result 压缩不够就触发 compaction,compaction 有三层 fallback。这种分层使得系统在各种对话模式下都能找到最小损失的应对方案。

追问链:

面试官:那为什么 tool result guard 选择 newest-first 压缩而不是 oldest-first?

→ 因为修改消息数组中靠前位置的元素会使 Anthropic 的 prompt prefix cache 从该位置起全部失效。newest-first 只影响尾部缓存。

面试官:那这不是牺牲了最新信息的完整性吗?

→ 是的,这是一个显式的 trade-off。但被压缩的 tool result 保留了 [compacted: tool output removed to free context] 占位符,agent 可以选择重新调用 tool 获取。而缓存失效的成本是隐性的、不可恢复的。

面试官:如果用户就是需要最新的 tool result 完整保留呢?

→ 这正是 Context Engine plugin 存在的意义——你可以实现一个 assemble() 方法,按自己的策略决定保留什么。Legacy engine 的 newest-first 是一个 sensible default,不是唯一选项。

Q2:Compaction 的 identifier 保留策略解决了什么问题?

表面答案:防止 LLM 在总结时把 UUID 等标识符简化掉。

源码级答案:这个策略(compaction.ts:34-36,默认 strict)解决的是 compaction 后的 agent 行为连续性 问题。一个 agent 在 compaction 前可能正在操作一个 PR(#45123)、编辑一个文件(src/gateway/protocol/schema.ts)、或等待一个 cron job(ID a1b2c3d4)。如果这些标识符在总结中被泛化为"一个 PR""某个文件""一个定时任务",compaction 后的 agent 就无法继续执行之前的操作——它需要重新查找这些标识符,或者更糟,用错误的标识符执行操作。

MERGE_SUMMARIES_INSTRUCTIONS 的设计也反映了这一点(compaction.ts:20-33):它要求保留 "Active tasks and their current status"、"Batch operation progress (e.g., '5/17 items completed')"——这些都是 agent 恢复执行所需的精确状态,不是可以被"概括"的信息。

Q3:为什么 Context Engine 的 Legacy 实现里 assemble() 是一个 pass-through?

表面答案:因为现有的管线已经处理了消息组装。

源码级答案:这是一个关于渐进式架构演进的设计决策。OpenClaw 在引入 Context Engine 插件系统之前,已经有了完整的上下文管理管线(sanitize → validate → limit → repair)。Context Engine 的 assemble() 是在这个管线之后调用的(attempt.ts:1179),意味着 plugin 拿到的已经是经过修复和限制的消息。

Legacy engine 的 pass-through 实现确保了零行为变更——引入插件系统不改变任何现有行为。新的 plugin 可以在 assemble() 中做额外的过滤、重排、注入,但如果没有 plugin,一切照旧。这是一个经典的"扩展点在前,实现在后"的架构模式——先定义合约,让默认实现是 no-op,再逐步用真实逻辑替换。

追问链:

面试官:但这意味着 plugin 无法修改 sanitize/validate/limit 的行为?

→ 对。那些是安全和正确性保障(stripToolResultDetails 是安全要求,repairToolUseResultPairing 是 API 兼容性要求),不应该被 plugin 绕过。plugin 只控制"在保证安全和正确性的前提下,如何组装上下文"。这是一个有意的权限划分。

面试官:如果 plugin 需要在 limit 之前介入呢?

→ Context Engine 的 maintain() 方法可以在每轮之后修改 transcript(通过 runtimeContext.rewriteTranscriptEntries()),这实际上允许 plugin 在源头修改消息——下次 limit 跑的时候看到的已经是 plugin 修改过的版本了。这是一个更安全的间接介入方式。

Q4:estimateTokens 用 chars/4 是不是太粗糙了?

表面答案:是一个粗略估算,但够用了。

源码级答案CHARS_PER_TOKEN_ESTIMATE = 4 是英文文本的典型比率,但对于代码、JSON、非拉丁字符集来说可能偏差很大。OpenClaw 对此的应对不是追求精确——而是在每个使用 token 估算的地方都加了安全边距。SAFETY_MARGIN = 1.2 给 compaction 加了 20% 缓冲(compaction.ts:17),CONTEXT_INPUT_HEADROOM_RATIO = 0.75 给整体预算打了 25% 的折。Tool result 用了更保守的 TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE = 2——对代码/JSON 这种 token 密度高的内容更接近真实值。

粗糙估算 + 多处安全边距,比精确 token 计数更适合这个场景:精确计数需要加载 tokenizer(每个 provider 的 tokenizer 还不一样),延迟和复杂度都不划算。当估算偏差导致 compaction 过早触发时,代价只是一次额外的 LLM 总结调用;但如果估算偏差导致 API 返回 context overflow 错误,代价是整个 turn 失败。所以宁可保守。

十一、工程启示:可迁移的 Context Engineering 模式

从 OpenClaw 的设计中,可以提炼出几个适用于任何 LLM agent 系统的模式:

1. 分层防线优于单点决策

不要试图在一个地方解决所有上下文问题。把防线分层:字节级缓存 → 单消息截断 → 全局压缩 → 磁盘清理。每层解决不同时间尺度的问题,每层有独立的 fallback。

适用场景:任何需要长时间运行的 agent 系统。 不适用场景:一次性的 chatbot(没有状态积累,不需要多层防线)。

2. Cache-aware 压缩方向

如果你使用支持 prefix caching 的 API(Anthropic、部分 OpenAI 模型),压缩方向应该从消息数组的尾部开始。修改前缀的代价远高于修改后缀。

适用场景:高频 API 调用且成本敏感的系统。 不适用场景:不支持 prefix caching 的 provider,或调用频率低到缓存几乎没命中。

3. 安全估算 + 多处 margin 优于精确计算

Token 估算不需要精确——但需要在每个使用点都有安全边距。chars/4 不准确不要紧,只要你在预算上打了 25% 折、在 compaction 上加了 20% 缓冲、在 tool result 上用了更保守的系数。多处 margin 的叠加效果比一处精确计算更鲁棒。

4. 有损压缩需要保留恢复能力

被压缩的 tool result 应该留占位符([compacted: tool output removed]),让 agent 知道那里有信息被压缩了。被总结的对话要保留标识符。这样 agent 可以在需要时主动恢复信息,而不是在不知情的情况下基于不完整信息做决策。

5. 上下文管理的最终形态是可插拔的

不同的使用场景需要不同的上下文策略。一个编程 agent 可能需要保留最近的代码 diff;一个客服 agent 可能需要保留整个 issue 历史。把 assemble() 做成可插拔的,允许下游根据自己的场景选择策略,是一个比"设计最优默认策略"更实用的工程决策。


OpenClaw 的上下文工程不是某个天才算法的产物。它是一系列被真实运行场景逼出来的工程决策:长会话的 token 膨胀、tool 返回值的不可预测大小、API 的严格格式要求、缓存的成本收益、磁盘文件的无限增长。每个设计选择都是对某个具体约束的回应,每个 trade-off 都有清晰的"什么时候会 break"分析。

这才是 context engineering 的真正含义——不是管理 token 数,而是管理信息在时间维度上的生命周期。