OpenClaw 源码深度解析(一):规划篇 — 一条消息如何变成一次 Agent 运行

基于开源代码 openclaw/openclaw main 分支

引言

你发了一条微信给 AI 助手:“帮我查一下明天北京的天气”。几秒后,天气信息回来了。

看起来很简单,但在 OpenClaw 的世界里,这条消息经历了一段精密的旅程:访问控制 → 群聊过滤 → 信封格式化 → 命令检测 → 调度分发 → Agent 运行 → 上下文压缩

本文是源码深度解析系列的第一篇——规划篇。我们不深入 Agent 的推理循环(那是运行篇的事),而是聚焦于:在 Agent 开始思考之前,OpenClaw 做了哪些关键的"规划"工作?


1. 第一道关卡:你是谁?——访问控制

消息到达的第一件事,不是理解内容,而是验证身份。

src/channels/allow-from.ts 实现了一个简洁而严谨的访问控制机制:

// src/channels/allow-from.ts
export function isSenderIdAllowed(
  allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean },
  senderId: string | undefined,
  allowWhenEmpty: boolean,
): boolean {
  if (!allow.hasEntries) return allowWhenEmpty; // 默认 false — 没配置名单则拒绝
  if (allow.hasWildcard) return true;
  if (!senderId) return false;
  return allow.entries.includes(senderId);
}

这个函数的设计哲学值得品味:

  • 默认拒绝(deny by default)allowWhenEmpty 默认为 false。没有配置白名单?直接拒绝。这是安全领域的铁律。
  • 三层判断,短路返回:wildcard → 无 senderId → 精确匹配。逻辑清晰,没有多余的分支。
  • 预计算优化hasWildcardhasEntries 是在解析配置时预先计算好的布尔值,避免每次请求都遍历数组。

这意味着 OpenClaw 的安全模型是白名单制的——只有被显式允许的用户才能与 Agent 交互。


2. 第二道关卡:你在跟谁说话?——群聊 @提及检测

通过了身份验证,接下来要判断:这条消息是"发给我的"还是"群聊背景噪音"?

src/channels/mention-gating.ts 解决了这个问题。不同渠道有截然不同的提及机制:

  • Telegram:检测 @bot_username 模式
  • WhatsApp:使用 jid 引用检测
  • Discord:匹配 <@!bot_id> 格式

OpenClaw 没有试图用一套通用逻辑覆盖所有平台,而是为每个渠道实现了适配器。这符合适配器模式的思想——每个渠道负责自己的 mention 检测逻辑,上层只需要一个统一的布尔结果:这条消息是否在 @我?

这个设计决策很重要:群聊场景下,如果 Agent 响应每一条消息,既浪费 token 又打扰用户。mention gating 是"是否应该响应"的关键分叉点。


3. 你在哪种对话里?——会话类型归一化

确定了"该不该响应"之后,OpenClaw 需要知道"在什么语境下响应"。

src/channels/chat-type.ts 定义了四种会话类型:

// src/channels/chat-type.ts
export type ChatType = "direct" | "group" | "channel" | "thread";

export function normalizeChatType(raw?: string): ChatType | undefined {
  // 将各渠道原始类型统一归一化为上述四种之一
  // ...
}

四种类型覆盖了所有主流场景:

ChatType 含义 典型场景
direct 私聊 用户和 Agent 的一对一对话
group 群聊 微信群、Telegram group
channel 频道 Discord channel、Slack channel
thread 帖子/线程 Discord thread、Slack thread

normalizeChatType 的职责是抹平渠道差异——Telegram 的 “supergroup”、Discord 的 “GUILD_TEXT”、Slack 的 “channel”,统统归一化为这四个标准类型。后续所有逻辑只依赖这个枚举,再也不需要关心底层渠道。


4. 消息穿上信封:格式化入站消息

身份验证通过、mention gating 放行、会话类型确定——现在,原始消息要被"包装"成 Agent 能理解的结构。

这是 src/auto-reply/envelope.ts 的工作。核心函数 formatInboundEnvelope() 将原始消息转换为这样的格式:

[Channel Telegram from:张三 2026-03-10T15:00:00] 帮我查天气

4.1 头部净化

信封头部包含渠道、发送者、时间等元信息。但这些元信息可能包含恶意注入sanitizeEnvelopeHeaderPart() 负责净化:

// src/auto-reply/envelope.ts
// 净化逻辑(简化):
// 1. 折叠换行 → 单个空格
// 2. 替换方括号 [ ] 为圆括号 ( )
// 3. 合并连续空白

为什么要把 [ 换成 ( ?因为信封本身就是用方括号包裹的——[Channel ...]。如果发送者名称中包含方括号,会破坏信封的解析。这是防御性编程的典型体现。

4.2 Agent 信封

Agent 发送消息时也使用信封格式,由 formatAgentEnvelope() 处理:

// src/auto-reply/envelope.ts
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
  const channel = sanitizeEnvelopeHeaderPart(params.channel?.trim() || "Channel");
  const parts: string[] = [channel];
  // ... 拼接 elapsed time, from, host, ip, timestamp
  const header = `[${parts.join(" ")}]`;
  return `${header} ${params.body}`;
}

4.3 时区与时间间隔

信封的时间戳支持多种时区配置:utclocaluser、IANA 时区字符串(如 Asia/Shanghai)。

还有一个精巧的设计:includeElapsed 参数。开启后,信封中会显示距离上一条消息的时间间隔——比如 (5m ago)。这对 Agent 理解对话节奏非常有帮助:用户隔了 5 分钟才发下一条消息,和连续快速发送,语义是完全不同的。

4.4 群聊 vs 私聊

群聊消息和私聊消息的信封格式不同:群聊带 senderLabel(标识具体是谁说的),私聊只有 from。这同样是上下文信息——在群聊中,Agent 需要知道每句话是谁说的。


5. 是命令还是对话?——命令系统

格式化完成后,OpenClaw 要判断:这条消息是自然语言对话还是系统命令

src/auto-reply/commands-registry.types.ts 定义了命令的类型系统:

// src/auto-reply/commands-registry.types.ts
export type ChatCommandDefinition = {
  key: string;
  nativeName?: string;
  description: string;
  textAliases: string[];
  acceptsArgs?: boolean;
  args?: CommandArgDefinition[];
  argsParsing?: CommandArgsParsing;
  scope: CommandScope; // "text" | "native" | "both"
  category?: CommandCategory; // "session" | "options" | "status" | ...
};

命令分两类:

  • text 命令:通过纯文本匹配检测。用户输入 /new,OpenClaw 在消息文本中发现 /new,触发命令。简单直接。
  • native 命令:利用平台原生的斜杠命令机制(如 Telegram 的 BotCommand)。用户输入 / 时平台会弹出命令菜单,选择后触发。体验更好,但依赖平台支持。

scope: "both" 意味着同一个命令同时支持两种触发方式——/new 既可以手动输入,也可以从 Telegram 的命令菜单中选择。

这种双轨设计是一个务实的权衡:不是所有平台都支持 native 命令,所以 text 匹配是兜底方案;但 native 命令能提供更好的 UX,所以能用的地方就用。


6. 并发控制:谁在运行?

在 Agent 真正开始工作之前,还有一个容易被忽略但至关重要的子系统:并发状态机

src/channels/run-state-machine.ts 用极简的代码管理并发:

// src/channels/run-state-machine.ts
export function createRunStateMachine(params) {
  let activeRuns = 0;
  return {
    onRunStart() { activeRuns += 1; publish(); },
    onRunEnd() { activeRuns = Math.max(0, activeRuns - 1); publish(); },
  };
}

关键细节:activeRuns = Math.max(0, activeRuns - 1) ——用 Math.max(0) 而不是简单减一。这是防御性编程:如果因为某种异常导致 onRunEnd 被多调了一次,计数器不会变成负数。

状态机追踪当前有多少个 Agent 运行在进行中。这对于消息排队、限流、防止重复触发至关重要——你不会希望在同一个会话中同时启动 5 个 Agent 运行,互相覆盖回复。


7. 核心调度:dispatch

所有前置条件都满足了,终于进入核心调度环节。

src/auto-reply/dispatch.ts 是连接"消息处理"和"Agent 运行"的桥梁:

// src/auto-reply/dispatch.ts
export async function dispatchInboundMessage(params): Promise<DispatchInboundResult> {
  const finalized = finalizeInboundContext(params.ctx);
  return await withReplyDispatcher({
    dispatcher: params.dispatcher,
    run: () => dispatchReplyFromConfig({
      ctx: finalized,
      cfg: params.cfg,
      dispatcher: params.dispatcher,
    }),
  });
}

这个函数做了两件事:

  1. finalizeInboundContext():固化入站上下文。在规划阶段收集的所有信息(渠道、发送者、会话类型、命令解析结果等)在这一步被冻结,不再修改。
  2. withReplyDispatcher():用 try/finally 包裹实际的调度逻辑,保证 dispatcher 的资源(reservations)一定会被释放
// src/auto-reply/dispatch.ts
export async function withReplyDispatcher<T>(params) {
  try {
    return await params.run();
  } finally {
    params.dispatcher.markComplete();
    await params.dispatcher.waitForIdle();
    await params.onSettled?.();
  }
}

这个 finally 块是整个调度系统的安全网:无论 run() 成功、失败还是抛异常,markComplete() 都会被调用,确保 dispatcher 的状态不会泄漏。waitForIdle() 等待所有挂起的回复操作完成,onSettled?.() 执行清理回调。

这是资源管理的基本功——用 finally 保证资源释放,比在每个分支手动释放可靠得多。


8. 压缩:当上下文爆了

Agent 运行过程中,对话历史会不断增长。当上下文窗口快满了怎么办?

src/agents/pi-embedded-runner/compact.ts 实现了会话压缩。这是规划篇的最后一块拼图——虽然压缩发生在 Agent 运行过程中,但它的触发条件和参数配置是在规划阶段决定的。

压缩函数接受一个极其丰富的参数类型:

// src/agents/pi-embedded-runner/compact.ts
export type CompactEmbeddedPiSessionParams = {
  sessionId: string;
  sessionKey?: string;
  messageChannel?: string;
  messageProvider?: string;
  groupId?: string | null;
  senderIsOwner?: boolean;
  sessionFile: string;
  workspaceDir: string;
  config?: OpenClawConfig;
  provider?: string;
  model?: string;
  thinkLevel?: ThinkLevel;
  trigger?: "overflow" | "manual";
  // ... 30+ 参数
};

几个关键设计点:

  • 触发方式trigger 可以是 "overflow"(自动,上下文快满时触发)或 "manual"(用户手动触发)。
  • 独立模型配置:压缩可以用不同于主对话的模型:
// src/agents/pi-embedded-runner/compact.ts
const compactionModelOverride = params.config?.agents?.defaults?.compaction?.model?.trim();
// 如果配置了压缩专用模型,用便宜的模型做压缩

这是个非常务实的优化——压缩是一项"理解 + 总结"的任务,不需要最强的推理模型。用 GPT-4o-mini 压缩、GPT-4o 对话,既省钱又不损失质量。


全景回顾:消息的生命周期

把所有环节串起来,一条消息从到达 OpenClaw 到触发 Agent 运行的完整路径:

原始消息到达
    │
    ▼
┌─────────────────┐
│  isSenderIdAllowed()   │  ← 访问控制:你是谁?允许吗?
└────────┬────────┘
         │ ✅
         ▼
┌─────────────────┐
│  mention-gating         │  ← 群聊过滤:你在 @我 吗?
└────────┬────────┘
         │ ✅
         ▼
┌─────────────────┐
│  normalizeChatType()    │  ← 归一化:私聊/群聊/频道/线程?
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  formatInboundEnvelope()│  ← 信封:包装成 [Channel from:张三 时间] 内容
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  命令检测                │  ← 是 /new 命令还是自然语言?
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  createRunStateMachine()│ ← 并发控制:有其他运行在进行吗?
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  dispatchInboundMessage()│ ← 调度:固化上下文,启动 Agent
└────────┬────────┘
         │
         ▼
   Agent 开始运行
         │
         ▼
┌─────────────────┐
│  compact (overflow)     │  ← 上下文快满了?自动压缩
└─────────────────┘

设计哲学总结

通过阅读这些源码,我们可以提炼出 OpenClaw 规划层的几个核心设计哲学:

1. 防御性编程无处不在

sanitizeEnvelopeHeaderPart() 净化用户输入、Math.max(0, ...) 防止计数器异常、try/finally 保证资源释放——这些细节说明 OpenClaw 的开发者深知:在处理不可信输入时,每一个字节都值得怀疑。

2. 适配器模式抹平渠道差异

Telegram、WhatsApp、Discord、Slack……每个平台的数据格式都不同。OpenClaw 的策略不是发明一个万能格式,而是在入口处做归一化——normalizeChatType()mention-gating.ts 的渠道适配器——后续逻辑只依赖标准类型。这是经典的**防腐层(Anti-Corruption Layer)**模式。

3. 配置驱动,而非硬编码

压缩模型可以独立配置(config.agents.defaults.compaction.model)、命令系统用声明式类型定义、时区支持多种模式——OpenClaw 尽量让行为通过配置控制,而不是改代码。这对于一个需要适配多种部署场景的开源项目来说,是正确的选择。

4. 关注点分离

信封格式化、命令检测、并发控制、调度分发——每个模块只做一件事,模块之间通过明确的类型接口通信。ChatCommandDefinition 的类型定义、CompactEmbeddedPiSessionParams 的 30+ 参数,都是这种"用类型做契约"的体现。


下篇预告

规划篇覆盖了 Agent 开始运行前的所有准备工作。在下一篇运行篇中,我们将深入 Agent 的推理循环:OpenClaw 如何管理 tool call、如何处理流式输出、如何在多个工具调用之间保持状态一致性。

如果你对某个模块有疑问或想深入了解,欢迎留言讨论。


本文基于 openclaw/openclaw main 分支源码。代码可能有变动,建议对照最新代码阅读。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐