OpenClaw 设计与实现
第17章 设计模式与架构决策
第17章 设计模式与架构决策
运行时系统和框架的根本分歧在于:谁拥有 main 函数。框架说'你来调用我',运行时说'我来调用你'。这一个控制反转,决定了安全能否集中执行、会话能否跨请求持久、配置能否零停机热更新。
本章要点
- 提炼 OpenClaw 中反复出现的七大设计模式:适配器、中间件管线、分层覆盖等
- 理解"运行时 vs. 框架"这一最关键的架构决策及其深远影响
- 掌握 Token 预算感知设计与推送式编排模式
- 用六个可迁移的提问维度,快速给任何 Agent 系统定位
17.1 为什么这一章重要
如果前面十六章是"拆开钟表,审视每一个齿轮",那本章就是"退后三步,看整座钟表为什么能精确走时"。
好的架构师不是记住了更多的组件,而是识别出了更深层的模式。棋手不是记住了更多棋局,而是看到了棋子之间的力学。
关键概念:设计模式(Design Patterns) 设计模式是在特定上下文中反复出现的问题及其解决方案的提炼。本章逐节拆解 OpenClaw 代码库中七个反复出现的模式:适配器(驾驭异构性)、中间件管线(链式处理)、分层覆盖(配置灵活性)、策略式优雅降级(按失败类别恢复)、Symbol 键控单例(跨产物共享全局状态)、推送式编排(避免轮询)和 Token 预算感知(资源管理)。
我们不再聚焦于任何特定模块,而是提升到模式层面:哪些设计在 OpenClaw 代码库中反复出现?每个解决哪类问题?换一组约束条件,同样的问题会被推向什么不同的解法?
这是全专栏最可迁移的一章。即使你从不使用 OpenClaw,这里提炼的模式和权衡,适用于你构建的任何 AI Agent 系统。
🔥 深度洞察:模式是压缩后的经验——设计的"基因组"
为什么设计模式比任何具体实现都更持久?答案来自信息论与进化生物学的交叉点。基因组不编码具体的蛋白质形状,它编码的是"如何在特定环境中制造蛋白质"的规则。同样,设计模式不编码具体的代码,它编码的是"如何在特定约束下做出权衡"的智慧。一个库的 v1 和 v2 的 API 可以完全不同,但它们背后的适配器模式往往完全相同。OpenClaw 的代码可能在三年后面目全非,但"用函数式适配器而非类层次结构来驾驭异构性"这个洞察,在你遇到下一个异构系统时依然适用。好的架构师不是收集具体方案的人——而是积累可迁移的判断力的人。 这一章的价值,不在于你记住了 OpenClaw 的七个模式,而在于你下次面对新系统时,能自己识别出第八个。
📖 历史小故事:设计模式的建筑学起源
设计模式的概念并非来自计算机科学——它来自建筑学。1977 年,建筑师 Christopher Alexander 出版了《A Pattern Language》,记录了 253 种建筑和城市设计中反复出现的解决方案。1987 年,Kent Beck 和 Ward Cunningham 率先在 OOPSLA 上尝试把 Alexander 的"模式语言"用到面向对象程序设计上;1994 年,四位软件工程师(Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides,被称为 "Gang of Four")出版《Design Patterns》,把这一思路系统化:软件设计中也有类似的重复模式! 这本书从此改变了软件工程的面貌。"适配器模式"常被拿来和现实世界类比——你带着美式插头去欧洲旅行时用的电源转换器,就是适配器的直观实例。OpenClaw 的 Provider 层扮演的正是这个角色:LLM 世界的"万能电源转换器",让任何"电器"(Agent 逻辑)都能插入任何"插座"(模型 API)。有趣的是,Alexander 本人后来对软件设计模式的发展方向颇有微词——他认为软件工程师过于关注模式的形式结构,而忽略了模式背后的"生活品质"。这个批评在今天读来依然振聋发聩:好的设计模式不是因为"它是一个模式"而值得使用,而是因为它真正改善了系统的某个品质——可维护性、可理解性、安全性。
17.2 适配器模式:驾驭异构性
17.2.1 问题的本质
OpenClaw 运行在一个极度异构的生态系统中:数十个 LLM 提供商,每个有不同的 API 格式、认证方式和能力特征;二十余个通道,每个有不同的消息格式、媒体支持和速率限制;不同厂商、不同协议(如 MCP)的工具定义格式也各不相同。
如果每个组合都需要专门的代码,复杂度将是 O(提供商数 × 通道数 × 工具格式数)——一个不可维护的组合爆炸。
graph LR
subgraph "图 17-1:适配器模式消除组合爆炸"
P1["OpenAI API"] --> A1["适配器"]
P2["Anthropic API"] --> A2["适配器"]
P3["Google API"] --> A3["适配器"]
P4["本地模型 API"] --> A4["适配器"]
A1 --> U["统一 StreamFn 接口"]
A2 --> U
A3 --> U
A4 --> U
U --> Agent["Agent 引擎"]
end
style U fill:#1e8449,color:#fff
style Agent fill:#1f618d,color:#fff
17.2.2 OpenClaw 的适配器实现
提供商适配器是最典型的案例。这里先纠正一个容易望文生义的地方:OpenClaw 的 src/ 下没有 providers/ 目录——每个提供商都是 extensions/ 下的一个插件(extensions/anthropic/、extensions/openai/、extensions/google/、extensions/groq/……),靠 openclaw.plugin.json 里的 providers 字段声明自己支持哪些 provider id。
适配的核心不是继承一个基类,而是注册一个流式函数:
// src/agents/custom-api-registry.ts:15-35(简化:省略 import 与类型断言)
import type { StreamFn } from "@mariozechner/pi-agent-core";
export function ensureCustomApiRegistered(api: Api, streamFn: StreamFn): boolean {
if (getApiProvider(api)) {
return false; // 已注册则不覆盖
}
registerApiProvider(
{
api,
stream: (model, context, options) => streamFn(model, context, options),
streamSimple: (model, context, options) => streamFn(model, context, options),
},
getCustomApiRegistrySourceId(api),
);
return true;
}
StreamFn 的签名是 (model, context, options) => ...——把某个厂商的 HTTP API 包进这个形状,它就成了 OpenClaw 眼里的一个 provider。添加新提供商不需要理解类继承层次,也不需要处理虚函数覆盖的微妙语义。注册表还带一个 sourceId,这样"这个 API 是谁注册的"在诊断时是可追溯的。
为什么选择函数而非类? 三个原因:
- 单方法接口不需要类。一个只有一个方法的类本质上就是一个函数。类增加了样板代码(构造函数、属性)但没有增加表达力。
- 避免继承的"脆弱基类"问题。类层次结构中,基类的任何修改都可能破坏所有子类。函数签名的合约更简洁、更稳定。
- 更容易测试。测试一个函数只需要提供输入和验证输出。测试一个类需要考虑状态、生命周期和方法调用顺序。
⚠️ 注意:函数式适配器模式在单方法接口场景下优于类继承,但在需要共享状态(如连接池、缓存)的场景下,闭包可能导致状态管理不够直观。OpenClaw 在需要共享状态的适配器中使用工厂函数返回闭包,而非类实例——以接口的简洁换取状态管理上的一点不直观。
17.2.3 工具格式适配
工具适配器展示了适配器模式的另一个变体——运行时鸭子类型:
// src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts:156-192(简化)
// 把 Anthropic 形状的工具定义转成 OpenAI function 形状:不问来源,只看字段
function normalizeOpenAiFunctionAnthropicToolDefinition(tool: unknown) {
const toolObj = tool as Record<string, unknown>;
if (toolObj.function && typeof toolObj.function === "object") {
return toolObj; // 已经是 OpenAI function 形状
}
const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : "";
// ……省略空名时原样返回的分支
const functionSpec: Record<string, unknown> = {
name: rawName,
parameters:
toolObj.input_schema && typeof toolObj.input_schema === "object"
? toolObj.input_schema // Anthropic 形状
: toolObj.parameters && typeof toolObj.parameters === "object"
? toolObj.parameters // 已是 JSON Schema 参数
: { type: "object", properties: {} },
};
// ……省略 description / strict 的拷贝
return { type: "function", function: functionSpec };
}
MCP 工具的 inputSchema 则在进入工具表时就被改名为 parameters(src/agents/pi-bundle-mcp-materialize.ts:92)。这种按字段判断的做法比依赖"来源声明"的分支更健壮——声明可能有误,但实际的数据结构不会撒谎。这是"鸭子类型"的经典应用:不关心它声称是什么,只关心它实际是什么。
17.2.4 函数式适配 vs 类层次结构
用抽象基类 + 子类做提供商适配是更常见的做法:定义一个 BaseChatModel,每个提供商继承并实现若干抽象方法。这条路的优势是类型约束更强、IDE 补全更友好、共享逻辑可以放进基类。
代价则是:每加一个提供商就要新建一个类、继承基类、实现全部抽象方法;基类一旦要新增或修改方法,所有子类都得跟着动——这就是经典的"脆弱基类"问题。
OpenClaw 把这个成本压到最低——一个函数。代价同样实在:共享逻辑没有基类可放,只能靠高阶函数包装(src/agents/anthropic-payload-log.ts 的 wrapStreamFn: (streamFn: StreamFn) => StreamFn 就是典型),类型上也不如类层次结构那样"编译器逼着你实现完整"。
哪种更好取决于接口有几个方法。单方法接口用函数,多方法且有大量共享状态的接口用类——这不是品味问题,是接口形状决定的。
17.3 中间件管线模式
17.3.1 问题的反复出现
"多个独立的处理阶段需要按顺序应用到数据上"——这个问题在 OpenClaw 中反复出现,例如:
- 工具策略管线(第10章):
buildDefaultToolPolicyPipelineSteps()返回 7 个默认过滤阶段(tools.profile→tools.byProvider.profile→tools.allow→tools.byProvider.allow→ agent 级 → agent 的 byProvider → group 级),调用处还会追加 sandbox 与 subagent 两级,合计 9 级。 - 插件钩子系统(第9章):三种交互模式(通知、修改、认领)的处理器链。
- 安全审计:多个独立的安全检查依次执行、汇总发现(
src/security/audit.ts)。
17.3.2 管线的设计变体
不同的管线场景需要不同的管线语义:
线性管线(Linear Pipeline):每个阶段处理数据后传递给下一个。没有跳过、没有回退。工具策略管线使用这种语义——每个策略阶段在上一阶段的结果上继续过滤,工具集只会逐级收窄。
数据 → [阶段1] → [阶段2] → [阶段3] → 结果
短路管线(Short-circuit Pipeline):某个阶段可以提前终止管线并返回结果。认领型钩子(如 inbound_claim、before_dispatch,走 src/plugins/hooks.ts 的 runClaimingHook)使用这种语义——第一个返回 { handled: true } 的处理器接管,后面的不再执行。
数据 → [阶段1: 不匹配] → [阶段2: 匹配!返回] → [阶段3: 不执行]
分叉管线(Fork Pipeline):数据复制到多个并行阶段。通知型钩子(如 message_received,走 runVoidHook)使用这种语义——所有处理器并行执行,互不影响。
┌→ [处理器A]
数据 → ├→ [处理器B]
└→ [处理器C]
为什么不统一成一种? 因为不同的语义对性能、正确性和可调试性有不同的影响。线性管线最容易理解和调试(数据流线性、可预测),但不支持提前返回。短路管线支持优化(避免不必要的处理),但调试时需要理解"为什么后面的阶段没执行"。分叉管线最灵活但最难管理副作用。
17.3.3 管线 vs. 观察者模式
一个常见的替代方案是观察者模式(事件发布-订阅)。为什么 OpenClaw 选择管线而非观察者?
| 维度 | 管线 | 观察者 |
|---|---|---|
| 执行顺序 | 确定性的 | 不确定的 |
| 数据流向 | 线性、可追踪 | 扇出、难追踪 |
| 调试难度 | 低(从头到尾跟踪) | 高(谁处理了这个事件?) |
| 安全审计 | 容易(记录管线每步) | 困难(观察者可能在任何地方) |
对于安全敏感的操作(如工具策略),确定性执行顺序是必需的——"每一级只能在上一级的结果上继续收窄"这个语义要求严格的执行顺序,排障时也要靠它回溯"是哪一级筛掉了这个工具"。观察者模式的不确定顺序会使策略组合不可预测。
17.4 分层覆盖模式
17.4.1 一个反复出现的非 GoF 模式
这不是经典 GoF(Gang of Four)设计模式,但在 OpenClaw 中反复出现的频率足以将其命名:
分层覆盖(Layered Override):多个来源提供相同类型的配置,高优先级来源覆盖低优先级。
出现场景:
- 技能系统:6 级优先级(extra → bundled → managed → personal → project → workspace)
- 工具 profile:生效的 profile 取
agents.<id>.tools.profile ?? tools.profile,Agent 级覆盖全局(第10章)
同一思路并非处处适用:工具策略的各级 allow / deny 不是"高优先级覆盖",而是逐级收窄(17.3);模型配置则是合并而非替换(见下文)。
17.4.2 为什么总是完全替换?
技能系统的覆盖语义是完全替换,非合并。这在第16章中已经讨论过,但值得从模式层面再次审视。OpenClaw 并没有把它推广到所有地方:生成 models.json 时,models.mode 默认是 "merge",配置文件显式声明的提供商会与隐式发现的同名提供商逐模型合并(src/agents/models-config.merge.ts 的 mergeProviders());工具 profile 的 alsoAllow 也是追加而非替换。
合并的诱惑:合并看起来更"智能"——高优先级只需声明它想改变的部分,其余继承低优先级。但合并引入了三个问题:
- 删除语义不明确。高优先级想删除低优先级的某个字段怎么办?需要引入特殊标记(如 JSON Merge Patch 的
null表示删除),增加了复杂度。 - 数组合并的歧义。高优先级有
[A, B],低优先级有[C, D]。合并后是[A, B, C, D]?[A, B]?[C, D, A, B]?没有一个"显然正确"的答案。 - 调试时的因果链。当最终配置出现意外值时,需要追溯"这个值来自哪一层"。合并使因果链变得更长——可能是三层合并的结果。完全替换使因果链最短——值来自最高优先级声明了该键的层。
哲学对齐:与 CSS 级联的逻辑一致——特异性和接近用户的程度决定哪个值胜出。CSS 在样式冲突时不"合并"两种颜色——更特异的选择器完全覆盖。
17.4.3 分层覆盖的通用实现
虽然 OpenClaw 没有提取一个通用的 LayeredOverride<T> 工具类,但采用完全替换语义的各处实现遵循相同的模式:
// 伪代码:分层覆盖的通用逻辑(layers 按优先级从低到高排列)
function resolve<T>(layers: Array<{ source: string; value?: T }>): T | undefined {
// 从高优先级到低优先级遍历(先复制再反转,不修改调用方的数组)
for (const layer of [...layers].reverse()) {
if (layer.value !== undefined) {
return layer.value; // 第一个有值的层胜出
}
}
return undefined; // 所有层都没有定义
}
17.5 策略模式:优雅降级
17.5.1 为什么降级不是异常处理
在传统软件中,"第三方服务不可用"是异常——用 try-catch 处理,记录错误,返回降级响应。在 Agent 系统中,降级是常态。
LLM 提供商限流——正常。提供商超时——正常。上下文溢出——正常。认证令牌过期——正常。这些不是"异常情况",而是"Agent 日常运行的一部分"。OpenClaw 的回应是将降级设计为默认运行模式,而非异常处理程序。
17.5.2 类别特定的恢复策略
模型回退链不是简单的"失败就换下一个"——不同类别的失败需要不同的恢复策略:
| 失败类别 | 恢复策略 | 原因 |
|---|---|---|
| 用户中止 | 立即终止,不降级 | 用户意图明确 |
| 超时 | 内层先换同 Provider 的下一个 Auth Profile,再降级到下一个候选模型 | 可能是提供商的临时问题 |
| 上下文溢出 | 内层压缩后重试;抛到降级层也直接重新抛出,不换模型 | 下一个候选的窗口可能更小,只会失败得更糟 |
| 限流 | 轮换 Auth Profile;全部冷却中再降级到下一个模型 | 同一提供商可能配了多份凭据 |
| 认证错误 | 轮换 Auth Profile;全部失败再降级到下一个模型 | 当前凭据不可用 |
| 模型不存在 | 降级到下一个候选模型 | 换一个模型 ID 才有意义 |
(口径与第4章降级表一致:src/agents/model-fallback.ts 与 src/agents/pi-embedded-runner/run.ts。)
这种分类的关键洞察是:失败的类型决定了恢复的方向。限流和认证错误出在凭据上——先换一份凭据;超时可能是提供商的临时问题——换凭据不行再换模型;上下文溢出出在输入本身——换模型解决不了,要靠压缩把输入变小。
17.5.3 上下文压缩的反直觉设计
上下文压缩策略在接近上下文限制时压缩对话历史。实现中有一个反直觉的关键细节:
// src/agents/compaction.ts:34
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.";
同一个文件里还有一段 MUST PRESERVE 清单,点名要保住的是"活跃任务及其状态""批量操作进度(如 5/17 items completed)""用户最后提的要求和当时正在做什么""已做的决定及其理由""TODO、未决问题与约束""已承诺的后续动作",并要求 PRIORITIZE recent context over older history——紧跟其后的一句指令点明了理由:Agent 需要知道的是"我刚才在干什么",而不只是"聊过些什么"。
标识符保留策略还是可配置的(identifierPolicy,默认 strict,也可以设为 custom 给自定义指令,或设为 off 关闭)。为什么要特别强调保留标识符?因为 LLM 在做摘要时有"创意改写"的倾向——它可能将 src/agents/tool-policy-pipeline.ts 摘要为"工具策略管线代码",丢失了具体路径。如果 Agent 后续需要读取这个文件,它只知道"工具策略管线代码"这个抽象描述,而不知道确切的文件路径。
这个细节揭示了 Agent 系统中上下文压缩的独特挑战:传统文本摘要追求语义保真,Agent 上下文压缩还需要追求引用保真——那些看起来不重要的标识符,实际上是 Agent 后续操作的关键锚点。
17.5.4 降级链的终止条件
一个容易遭到忽视的设计问题:降级链什么时候应该停止重试,直接报错?
runWithModelFallback() 的循环有两类出口:
- 所有候选模型都已尝试。没有更多的选择了,汇总各次尝试的原因后报错。
- 遇到不可重试的错误。用户中止、上下文溢出直接重新抛出,不再尝试后面的候选。
值得注意的是降级链层面没有总时间预算(按源码判断):运行超时 agents.defaults.timeoutSeconds(默认 48 小时)是随每次候选调用传入、各自计时的(src/agents/pi-embedded-runner/run/attempt.ts 的 scheduleAbortTimer(params.timeoutMs, ...))。考虑这个场景:3 个候选模型,每个都要等到自己的超时才判定失败——用户就得把三次超时依次等完才看到错误。设计自己的降级链时,这正是值得补上的一道总预算。
17.6 全局状态:Symbol 键控单例
17.6.1 ESM 的单例挑战
无论 CommonJS 还是 ESM,模块缓存都以解析后的路径(ESM 里是 URL)为键——同一路径只加载一次,模块级变量自然形成单例。但只要同一份源码以不同路径被加载,或者像 OpenClaw 这样被打包工具拆进多个产物 chunk、各自带一份副本,模块级变量就会出现多份,不再是可靠的单例。OpenClaw 引入统一单例工具的那次提交(4ca84acf24,#43683)标题就是 "share singleton state across bundled chunks"。
17.6.2 Symbol.for() 的解决方案
Symbol.for() 创建全局唯一的 Symbol——同一字符串键始终返回同一 Symbol,无论从哪个模块调用:
// src/plugins/hook-runner-global.ts:19
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
const getState = () =>
resolveGlobalSingleton<HookRunnerGlobalState>(hookRunnerGlobalStateKey, () => ({
hookRunner: null,
registry: null,
}));
同样的手法在插件子系统里出现了多次——openclaw.pluginCommandsState、openclaw.pluginInteractiveState、openclaw.plugins.binding.global-state 等,都走 resolveGlobalSingleton(key, factory)(src/shared/global-singleton.ts)这个统一入口;该文件的注释另外提醒,必须跨拆分 chunk 存活的可变状态不要走这个辅助函数,而要直接用 globalThis[Symbol.for(...)] 查找。键名统一带 openclaw. 前缀,可以避免和同进程内其他库的全局 Symbol 撞车。
这个模式确保即使多次加载同一模块,各实例仍共享同一个全局状态——globalThis 和 Symbol.for() 都不受模块加载路径的影响。
17.6.3 刻意使用 var 的反直觉决策
src/agents/subagent-registry.ts 里有一行刻意使用 var(而非 let),源码注释写明了原因:
// Use var to avoid TDZ when init runs across circular imports during bootstrap.
var restoreAttempted = false;
这是为了规避循环导入解析中的暂时性死区(Temporal Dead Zone)。
ES6 中,let 和 const 在声明语句执行之前不可访问——即使经过了 hoist。循环导入场景下,模块 A 导入 B,B 又导入 A。B 可能在 A 的模块体执行完之前就调用了 A 导出的函数;这个函数一旦读到 A 里用 let 声明、声明语句尚未执行的变量,就会抛出 ReferenceError。
var 不受暂时性死区限制——它在声明前的值是 undefined 而非错误。虽然 undefined 不理想,但至少不会崩溃——后续的初始化会在正确的时机设置真实值。
这是务实的工程妥协——理想情况下应该消除循环导入,但在大型代码库中彻底根治可能需要大规模重构。var 给出了一个低成本的缓解方案。
17.7 推送式编排
17.7.1 轮询的经济学灾难
问题:父 Agent 如何得知子 Agent 已完成任务?
轮询方案:父 Agent 反复调用 sessions_list 检查状态。每次轮询消耗一次 API 调用和 token。假设子 Agent 平均运行 5 分钟,轮询间隔 30 秒——需要 10 次轮询。如果有 5 个并行子 Agent,需要 50 次轮询。
轮询成本 = 子Agent数 × 平均运行时间 / 轮询间隔 × 每次token成本
成本随子 Agent 数量线性增长。
推送方案:子 Agent 完成时触发通知,作为普通消息回合投递给父 Agent。无论有多少子 Agent,父 Agent 只在收到通知时消耗 token。
推送成本 = 子Agent数 × 每次通知的 token 量
关键不在于那个常数是多少,而在于它与子 Agent 的运行时长无关。轮询成本随运行时长线性增长,推送成本不随之增长——一个跑 30 秒的子 Agent 和一个跑 30 分钟的子 Agent,通知开销一模一样。
17.7.2 推送式通知的可靠性
推送比轮询更高效,但更难做到可靠——"你发了通知但我没收到"比"我轮询了但结果没变"更危险。
OpenClaw 通过子 Agent 的通告(announce)机制实现可靠推送:
| 特性 | 实现 | 常量 |
|---|---|---|
| 重试退避 | 1s → 2s …指数退避,封顶 8s(受 3 次上限限制,实际只用到前几档) | MIN_ANNOUNCE_RETRY_DELAY_MS = 1_000、MAX_ANNOUNCE_RETRY_DELAY_MS = 8_000 |
| 重试上限 | 最多 3 次,超出即放弃并记日志 | MAX_ANNOUNCE_RETRY_COUNT = 3 |
| 过期时间 | 普通通知 5 分钟,期待完成消息的通知硬上限 30 分钟 | ANNOUNCE_EXPIRY_MS = 5 * 60_000、ANNOUNCE_COMPLETION_HARD_EXPIRY_MS = 30 * 60_000 |
| 幂等投递 | 稳定的 announceId 去重,直接投递与排队投递共用同一标识 |
src/agents/announce-idempotency.ts |
| 错误宽限 | 生命周期 error 事件先缓 15 秒,等模型重试有结论再决定是否算失败 |
LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000 |
| 后代未完成 | 子 Agent 还有活跃后代时推迟清理,而非直接放弃 | kind: "defer-descendants" |
重试与过期常量在 src/agents/subagent-registry-helpers.ts,LIFECYCLE_ERROR_RETRY_GRACE_MS 在 src/agents/subagent-registry.ts,判定逻辑在 resolveDeferredCleanupDecision()(src/agents/subagent-registry-cleanup.ts)。放弃时会区分原因是 retry-limit 还是 expiry 并写进日志——这两种放弃对应完全不同的排障方向。
17.7.3 推送式编排的深层影响
推送式编排不仅影响成本——它改变了编排的编程模型。
轮询模型是命令式的:父 Agent 循环检查状态,根据状态做决策。整个编排逻辑在一个连续的执行流中。
推送模型是事件驱动的:父 Agent 发出子任务后"睡眠"。每个完成通知触发一次处理回合。编排逻辑分散在多个不连续的回合中——每个回合基于"到目前为止收到了哪些完成通知"来决定下一步。
这种模型更自然地适配了 LLM 的"回合制"交互模式——LLM 本来就是"收到消息、处理、回复"的模式,推送式通知天然地映射为"收到完成消息"的回合。
17.8 Token 预算感知设计
17.8.1 一个新的架构约束类别
这一元模式渗透 OpenClaw 的架构,代表与传统软件设计最显著的差异。在传统系统中,内存丰富、计算是瓶颈。在 Agent 系统中,上下文窗口是瓶颈——提示、历史、工具输出和技能指令竞争相同的有限预算。
这不仅仅是"性能优化"——它是一个全新的架构约束类别。在传统系统中,你不会因为"函数签名太长"而重新设计 API。在 Agent 系统中,你确实可能因为"工具描述占了太多 token"而重新设计工具接口。
17.8.2 Token 感知设计的具体体现
| 模块 | Token 感知设计 | 出处 |
|---|---|---|
| 技能系统 | 提示里只放目录,正文靠 read 工具按需读 |
src/agents/skills/workspace.ts |
| 技能目录 | 完整 → 紧凑 → 二分截断三层降级,默认 30,000 字符预算(skills.limits.maxSkillsPromptChars 可调) |
applySkillsPromptLimits |
| 技能路径 | home 前缀压成 ~(源码注释估算每技能省 5–6 token) |
compactSkillPaths |
| 浏览器 | 用无障碍树表示页面,而非截图或整页 DOM | extensions/browser/ |
按需选择页面,另有 pdfMaxPages / pdfMaxBytesMb 兜底 |
agents.defaults.pdf* |
|
| 上下文压缩 | 保留标识符的有损摘要 | src/agents/compaction.ts |
| 上下文守卫 | 模型窗口与 contextTokens 双重封顶 |
src/agents/context-window-guard.ts |
| 工具输出 | 单个工具结果不超过上下文窗口的 30%(另有 400,000 字符硬上限),超出即截断 | src/agents/pi-embedded-runner/tool-result-truncation.ts |
表里刻意没有"节省 N 倍"这一列。省多少完全取决于你的技能数量、页面复杂度、对话长度,脱离具体负载给倍数只会误导——要测就用
openclaw gateway usage-cost在自己的负载上测。
17.8.3 对传统设计模式的颠覆
Token 预算感知颠覆了一些传统的设计直觉:
传统直觉:"提供更多信息总是更好。" → Token 感知:"每一 token 的信息都有成本——多余的信息不是免费的,它直接压缩了可用于推理的空间。"
传统直觉:"详细的错误消息帮助调试。" → Token 感知:"错误消息也占用上下文。一个 500 token 的堆栈追踪可能不如一个 50 token 的精炼错误描述有用。"
传统直觉:"缓存经常访问的数据。" → Token 感知:"在上下文中缓存数据意味着每一轮都携带它。如果数据只用一次,缓存反而浪费。"
这个模式在传统 GoF 中没有对应物。它是 LLM 系统独有的新架构关注类别——可以称之为"Token 经济学"。传统软件的稀缺资源是 CPU 时间和内存带宽;Agent 系统的稀缺资源是上下文窗口中的每一个 token——它既是工作记忆,也是通信带宽,还是计费单位。三位一体的约束,前所未有。
17.9 运行时 vs. 框架:最关键的架构决策
17.9.1 定义差异
"运行时"和"框架"都可以被称作"AI Agent 解决方案",但它们的架构定位截然不同:
| 维度 | 运行时(OpenClaw) | 框架(你写代码调用库) |
|---|---|---|
| 使用方式 | 写配置 + Markdown 技能 | 写代码 |
| 生命周期 | 常驻守护进程,自管理 | 随宿主应用启停 |
| 多通道 | 内置 9 个通道 ID,含扩展共 22 个 | 自己写适配器 |
| 后台任务 | 内建 Cron + 心跳 | 自己接外部调度器 |
| 安全执行 | 集中的工具策略管线 + exec 审批 | 开发者自行实现 |
| 灵活性 | 受配置面约束 | 最大 |
| 学习曲线 | 低(配置即用) | 高(需要编程) |
graph TB
subgraph "图 17-2:运行时 vs. 框架"
direction TB
subgraph Runtime["运行时(OpenClaw)"]
R1["配置文件"] --> R2["持久守护进程"]
R2 --> R3["内建通道/安全/调度"]
end
subgraph Framework["框架"]
F1["应用代码"] --> F2["随宿主启停"]
F2 --> F3["开发者自建基础设施"]
end
end
style R2 fill:#1e8449,color:#fff
style F2 fill:#c0392b,color:#fff
17.9.2 这个选择的深层原因
OpenClaw 做成运行时而非框架,背后的逻辑是:AI Agent 系统的困难问题——可靠投递、安全执行、会话管理、模型回退——是基础设施问题,最好在共享运行时中一次解决而非在每个应用中重复解决。正如每栋写字楼不需要自建发电厂,每个 Agent 应用也不需要自建安全审计和通道管理。
Web 开发里有个现成的类比:Nginx 是运行时,Express 是框架。两者都能服务 HTTP 请求,但 Nginx 把 TLS 终止、反向代理、静态文件服务这些基础设施问题一次解决,让应用层专注于业务逻辑。OpenClaw 站的是 Nginx 那个位置。
17.9.3 这个选择的代价
选择运行时意味着放弃了框架的某些优势:
- 可编程性:运营者日常只能在配置面提供的选项中选择;要用代码定制工具调用逻辑,得写插件(17.9.4),而插件与网关同进程运行、没有沙箱隔离(第9章),门槛和风险都更高。
- 嵌入性:OpenClaw 的主要形态是独立的常驻进程,而不是供现有应用嵌入的库——包根入口虽导出了
getReplyFromConfig、loadConfig等少量函数(src/library.ts),但通道、调度、安全这些能力都依附于网关进程。 - 架构锁定:运营者必须接受 OpenClaw 的架构假设(单运营者、通道模型、工具策略管线)。
这些代价是真实的;对个人或小团队的 Agent 运营者而言,它们通常可以接受——这类用户更需要"开箱即用"而非"完全可编程"。
17.9.4 混合方案:运行时 + 扩展点
OpenClaw 不是在"运行时"和"框架"之间做二选一——它在运行时基础上开放了有限的扩展点:
- 插件系统(第9章)允许以代码方式扩展通道、提供商和工具。
- 技能系统(第16章)允许以 Markdown 方式扩展 Agent 的知识。
- ACP(第6章)允许编排外部 Agent 进程。
这种"运行时 + 扩展点"的混合模型,比纯运行时更灵活,比纯框架更省心——常见需求通过配置满足,剩下那部分才需要动到扩展点。
17.10 六个维度:读懂任何一个 Agent 系统
本节原本是一张"OpenClaw vs 五个同类项目"的打勾表。本专栏最终删掉了它,理由很直接:这些项目迭代极快,任何一格结论都可能在几周内失效;而本专栏能逐行核对源码的只有 OpenClaw 一家,其余各家只能凭印象填——靠印象填出来的表格,越整齐越危险。
更耐用的做法是把维度留下、把结论交还给读者。拿到任何一个 Agent 系统,问这六个问题,你就能快速给它定位:
- 核心抽象是什么? 系统的"名词"是什么——链?Agent?团队?工作流节点?还是像 OpenClaw 这样的"网关 + 会话"?核心抽象决定了你思考问题时的词汇表。
- 谁拥有
main函数? 你调用它(框架),还是它调用你(运行时)?这个控制反转决定了安全能否集中执行、会话能否跨请求持久、配置能否零停机热更新。 - 多模型是一等公民吗? 是"能配多个模型",还是"失败时自动按类别选择恢复策略"?前者是配置项,后者是架构能力。
- 通道从哪来? 内置、插件化,还是要你自己写适配器?以及——通道之间的安全策略能否差异化?
- 安全在哪一层? 集中在运行时的策略管线里,还是散落在每个应用的调用点上?有没有默认安全的起点?
- 有没有 Token 预算意识? 系统是否在设计层面就把上下文窗口当稀缺资源对待——还是把"塞得下就行"当作默认假设?
OpenClaw 对这六问的答案分别是:网关 + 会话;它拥有 main;原生按失败类别降级;插件化的 22 个通道;集中的 7 级(调用处 9 级)工具策略管线;全栈的 Token 预算感知。
这六个答案没有一个是"正确答案"——它们只是一组自洽的取舍,服务于"个人或小团队的 Agent 运营者"这个特定用户。换一个用户画像,六个答案都可能不同。理解取舍背后的约束,比评判"谁更好"有价值得多。
17.11 Agent 系统的新兴模式
从 OpenClaw 的设计中,我们可以提炼出七种在 Agent 系统中格外突出的设计模式。它们大多不在经典 GoF 目录里:
17.11.1 Token 预算感知加载
问题:上下文窗口有限但消费者众多。 方案:按需加载、需时加载、预算超出时优雅降级。 实例:技能系统、浏览器快照、工具结果截断。
17.11.2 分层覆盖配置
问题:多个来源的配置需要合理组合。
方案:明确的优先级层次,完全替换语义,可预测且可调试。
实例:技能 6 级来源优先级、Agent 级 tools.profile 覆盖全局。
17.11.3 推送式编排
问题:编排者需要知道下游任务的完成状态。 方案:完成时推送通知而非轮询状态,带重试和去重。 实例:子 Agent 完成通知、通告队列。
17.11.4 纵深防御安全
问题:单一安全层不够。 方案:多个独立层,每个捕获不同风险类别,假设其他层可能失败。 实例:分层的安全模型、多级工具策略管线。
17.11.5 优雅降级链
问题:主方案失败时需要替代。 方案:按失败类别选择恢复策略,而非简单重试。 实例:模型回退链、Auth Profile 轮转。
17.11.6 能力自适应发现
问题:系统的能力随运行时环境动态变化。 方案:声明式能力注册 + 动态资格检查 + 自动调整。 实例:Node 能力路由、技能动态资格。
17.11.7 不可变快照协调
问题:生产者和消费者之间的竞态条件。 方案:版本化快照消除读写竞争,版本号驱动更新。 实例:技能快照、配置快照。
17.12 实战推演:一个架构决策的完整推导过程
让我们用一个具体问题,展示如何运用本章的设计模式做出架构决策。
17.12.1 问题:Agent 需要同时支持 Web 搜索和学术论文搜索
你正在构建一个研究助手 Agent。它需要两种搜索能力:
- 通用 Web 搜索(Brave、Google)
- 学术论文搜索(Semantic Scholar、arXiv)
这两种搜索有不同的 API 格式、认证方式、结果结构。如何设计?
17.12.2 运用模式推导
适配器模式(17.2)→ 两种搜索引擎有不同的 API,但 Agent 不应该关心差异。设计统一的 SearchResult 接口,每个搜索引擎提供适配器。
策略模式(17.5)→ 如果 Brave API 限流了,自动降级到 Google。如果 Semantic Scholar 超时,降级到 arXiv。不同的失败类型触发不同的降级策略。
Token 预算感知(17.8)→ 论文摘要比网页 snippet 长得多,同样返回 10 条,占用的上下文可能差一个量级。所以格式化时就该分层:列表阶段只返回标题、作者、年份和 DOI,完整摘要留给"用户确实点进去了"的那一两篇按需加载——这正是技能系统"目录 + 正文"思路的复用。
分层覆盖(17.4)→ 搜索配置需要多级:全局默认(搜索引擎偏好)→ Agent 级(研究助手偏好学术搜索)→ 会话级(用户在特定对话中指定只搜论文)。
好的架构决策不是灵光一闪——而是在正确的问题上应用正确的模式。模式不是答案,而是提问的框架。当你面对一个设计问题时,试着问:"这里有适配器模式吗?有降级需求吗?有 token 成本考量吗?"——这些问题本身就会指引你走向合理的设计。
17.13 设计清单:构建 Agent 系统时的架构检查表
以下是从 OpenClaw 的设计经验中提炼的实用检查表。无论你是使用 OpenClaw、构建自己的 Agent 框架,还是评估竞品方案,这份清单都能帮你发现关键的设计盲点。
17.13.1 Token 与上下文管理
- 上下文预算分配:是否明确了系统提示、工具定义、对话历史、工具输出各自的 token 预算?
- 工具输出压缩:工具返回的数据(网页、搜索结果、文件内容)是否有压缩策略?占多少 token?
- 按需加载:知识/技能/指令是否按需加载,还是全部塞进系统提示?
- 降级策略:上下文不够时怎么办?截断?摘要?格式降级?
- 标识符保留:上下文压缩时是否保留了文件路径、URL、UUID 等 Agent 后续需要的锚点?
17.13.2 安全与权限
- 层次防御:安全模型是否有多个独立层,而非依赖单一防线?
- 默认安全:新功能和新用户的默认配置是最安全的吗?
- 凭证隔离:API Key 是否避免在配置文件和日志中明文出现?
- 命令执行控制:Agent 能执行 Shell 命令吗?有分级控制吗?
- 提示注入防御:外部内容(用户输入、网页内容、工具输出)是否有消毒/边界隔离?
- 审计追踪:所有安全相关操作(命令执行、凭证访问、配置变更)是否有审计日志?
17.13.3 多模型与降级
- 模型回退链:主模型不可用时是否有自动回退?回退策略是否区分失败类型?
- 提供商隔离:提供商 API 变更是否影响核心逻辑?是否有适配器层隔离?
- 逐任务模型选择:不同类型的任务(摘要 vs 编码 vs 监控)是否可以使用不同的模型?
- 成本感知:是否有机制防止高频任务使用昂贵模型?
17.13.4 多通道与路由
- 通道抽象:核心逻辑是否依赖特定通道的 API?添加新通道需要修改核心代码吗?
- 消息格式化:同一条回复在不同通道上是否有适配的格式(Markdown → 纯文本→富文本)?
- 安全差异化:不同通道(DM vs 公共频道 vs API)是否有不同的信任级别和权限?
17.13.5 编排与可靠性
- 推送优于轮询:子任务完成时是推送通知还是父 Agent 轮询状态?
- 优雅降级:任何单个组件失败时,系统是否能继续提供部分服务?
- 重启恢复:进程重启后是否能恢复之前的状态?错过的定时任务是否有追赶机制?
- 超时保护:长时间运行的操作是否有超时?防止失控进程无限运行?
17.13.6 可观测性与运维
- 结构化日志:日志是否为 JSON 等可查询格式?是否自动脱敏?
- 健康检查:是否有端点让监控系统判断"系统是否正常"?
- 诊断命令:是否有一键诊断工具(类似
doctor)检查常见问题? - 配置变更追踪:配置修改是否有记录?出问题时能否追溯到"哪个变更导致的"?
17.13.7 开发者体验
- 零配置启动:新用户能否在 5 分钟内让系统跑起来?
- 错误即文档:每个错误消息是否包含"怎么修"的建议?
- 渐进式复杂度:简单场景是否简单?复杂功能是否可选?
- 非交互模式:所有交互操作是否支持脚本自动化(CI/CD 友好)?
使用建议:不必一次性全部满足。根据你的 Agent 系统所处的阶段(原型 → MVP → 生产),优先关注不同的检查项。原型阶段关注 Token 管理和基本安全即可;生产阶段则需要完整覆盖。
17.14 本章小结
OpenClaw 的架构是设计模式应用于 AI Agent 系统的活案例。经典模式(适配器、管线、策略、单例)适配了 Agent 特定约束。新模式(Token 预算感知加载、推送式编排、分层覆盖)从 LLM 系统独有的挑战中涌现。17.13 节的设计清单将这些模式转化为可操作的检查项——无论你是在构建、评估还是维护 Agent 系统,它都是一份值得反复参考的实用工具。
核心洞察:OpenClaw 中最重要的架构决策不是任何单个模式——而是选择成为运行时系统而非编程框架(即 17.10 节六问中的第二问:谁拥有 main)。这一决策级联贯穿整个架构:配置替代代码成为主要接口,安全策略集中执行而非依赖开发者实现,生产关注点(日志、重启、监控)内建而非附加。理解这一选择背后的约束比记忆任何特定模式更有价值——正是这些约束塑造了 Agent 系统的架构。
而在模式层面,Token 预算感知设计是 Agent 系统贡献给软件工程的全新思维工具。
"这占多少 token?值得吗?"——这是传统软件从不需要问的问题,却是 Agent 系统每一个设计决策的隐形标尺。
当系统的"工作记忆"变成稀缺资源时,每一个数据结构、每一个接口设计、每一个输出格式都需要回答这个问题。
至此,我们完成了对 OpenClaw 的逐层解剖与模式提炼。最后一章将视角从分析转向实践——当你掌握了所有齿轮的运作原理后,如何将它们组装成你自己的 Agent 帝国?
思考题
- 概念理解:本章提炼了多个设计模式(如"不可变快照协调"、"优雅降级链"、"中间件管线")。选择其中两个,分析它们解决的是哪一类系统性问题,以及在非 Agent 系统中是否同样适用。
- 实践应用:如果你要设计一个全新的 Agent 框架,你会从 OpenClaw 的设计模式中借鉴哪三个?会放弃哪些?为什么?
- 开放讨论:架构决策中最困难的不是"选什么",而是"放弃什么"。在 OpenClaw 的设计决策中,你认为哪个权衡最有争议?你会做出不同的选择吗?
📚 推荐阅读
- Design Patterns: Elements of Reusable Object-Oriented Software (GoF) — 设计模式的经典之作,本章多个模式源于此书
- Software Architecture: The Hard Parts (Ford et al.) — 架构决策中的权衡艺术
- Architectural Styles and the Design of Network-based Software Architectures (Roy Fielding) — REST 之父的博士论文,理解架构风格的理论基础