OpenClaw 设计解析(五):Agent 运行时——LLM 编排与工具执行

OpenClaw Agent 运行时:Pi 框架集成、工具组装、策略管线、事件流、Subagent 和 Agent 持续运行。

本篇介绍 OpenClaw 的 Agent 运行时:Pi 框架集成、Agent 启动流程、工具组装管线、7 层工具策略管线、事件流订阅、Subagent 系统、Skills 系统,以及 Agent 如何实现 7×24 自主持续运行


1. Pi 框架集成

OpenClaw 的 Agent 运行时建立在 Pi 框架之上,使用三件套:

graph TD subgraph Pi["Pi 框架三件套"] A["pi-agent-core\n会话管理 / 消息序列化 / 上下文压缩"] B["pi-coding-agent\n编码工具集: read / write / edit / exec"] C["pi-ai\n多 Provider 适配\nAnthropic / OpenAI / Google / Copilot\n流式输出 / token 计量"] A --> C B --> C end
  • pi-agent-core — Agent 核心:会话管理、消息序列化、上下文压缩
  • pi-coding-agent — 编码工具集:read/write/edit/exec 等基础文件系统和进程管理工具
  • pi-ai — LLM 调用层:多 Provider 适配(Anthropic、OpenAI、Google、Copilot)、流式输出、token 计量

Pi 提供的三个核心抽象

Pi 的设计给上层应用暴露了三个注入点,OpenClaw 通过它们深度定制 Agent 行为,而不需要修改 Pi 框架本身。

① Session(会话)

createAgentSession() 是 Pi 最核心的 API。它接收模型配置、工具列表和会话管理器,返回一个会话对象:

方法 / 属性 作用
session.prompt(text) 发送用户消息并触发 LLM 执行循环 — 唯一的”启动”入口
session.subscribe(handler) 订阅事件流 — 注册回调,接收所有运行时事件
session.steer(text) 在执行过程中插入引导消息
session.abort() 中止当前执行
session.messages 当前消息历史
session.agent.streamFn 可替换的 LLM 调用函数(见下文)
session.agent.replaceMessages() 替换消息历史(用于清理/截断)

调用 session.prompt("用户消息") 后,Pi 内部运行一个自动循环:发消息给 LLM → 收到回复 → 如果 LLM 要调用工具则执行 → 把工具结果发回 LLM → 继续循环,直到 LLM 不再调用工具为止。整个循环由 Pi 驱动,OpenClaw 只需要调用一次 prompt() 就行。

② StreamFn(可替换的 LLM 调用函数)

streamSimple 是 Pi 默认的 LLM 调用函数——通过 HTTP 调用 LLM API,返回流式响应。Pi 的巧妙之处在于 streamFn 是可替换的,OpenClaw 利用这个接口做了大量适配:

1
2
3
4
5
6
7
8
9
10
11
// Pi 默认:HTTP 调用所有 LLM Provider
session.agent.streamFn = streamSimple

// OpenClaw 可以替换为其他传输方式
session.agent.streamFn = createOllamaStreamFn(baseUrl) // Ollama 原生 API
session.agent.streamFn = createOpenAIWebSocketStreamFn(...) // OpenAI WebSocket

// 还可以层层包装(装饰器模式),不修改原函数
session.agent.streamFn = wrapNumCtx(session.agent.streamFn, numCtx) // 注入参数
session.agent.streamFn = cacheTrace.wrapStreamFn(session.agent.streamFn) // 缓存追踪
session.agent.streamFn = logger.wrapStreamFn(session.agent.streamFn) // 请求日志

这就像一条中间件链——每一层包装都可以在请求发给 LLM 之前做修改(清理 thinking blocks、注入 Ollama 参数、记录请求日志),而不需要改 Pi 框架的代码。

③ 事件流(Event Stream)

Pi 不用传统的独立 callback(如 onMessageonToolCall),而是所有运行时事件走一条统一的事件流,由订阅者按类型处理:

1
2
3
4
5
6
7
8
9
10
11
12
session.subscribe((event) => {
switch (event.type) {
case "message_start": // LLM 开始生成回复
case "message_update": // 流式文本片段(逐 token)
case "message_end": // 一条完整回复结束
case "tool_execution_start": // LLM 决定调用工具
case "tool_execution_end": // 工具执行完毕,结果已返回给 LLM
case "auto_compaction_start": // 上下文太长,开始自动压缩
case "auto_compaction_end":
case "agent_start" / "agent_end":
}
})

OpenClaw 订阅这个事件流后,将每种事件分发到独立的处理模块——消息处理(流式分块、typing indicator)、工具处理(媒体 URL 过滤、变更追踪)、压缩处理(重试协调)、生命周期处理。

OpenClaw 与 Pi 的分工

graph LR subgraph Pi["Pi 负责(引擎)"] direction TB P1["LLM 对话循环\nprompt → 回复 → 工具调用 → 循环"] P2["工具分发\nLLM 说调 read → 找到工具并执行"] P3["消息持久化\nSessionManager 读写磁盘"] P4["上下文压缩\n消息历史太长时自动摘要"] P5["流式输出\n逐 token 推送"] end subgraph OC["OpenClaw 负责(驾驶员)"] direction TB O1["启动前准备\n模型解析 / Auth 轮换 / 上下文校验"] O2["工具注入\n7 个来源共 40+ 工具"] O3["streamFn 替换\n适配 Ollama / OpenAI WS / 中间件链"] O4["事件消费\n分块回复 / typing / 媒体过滤"] O5["错误分类与重试\n判断重试、轮换还是放弃"] O6["消息历史清理\n格式校验 / 截断 / 修复"] end

简单说:Pi 是 Agent 的执行引擎,负责”LLM 要调工具 → 执行工具 → 结果发回 LLM”这个核心循环;OpenClaw 是驾驶员,负责告诉引擎用什么模型、带哪些工具、怎么处理错误、怎么把结果传给用户。


2. Agent 启动流程

Agent 的完整启动流程可以分为以下几个阶段:

2.1 队列入队与工作区解析

首先解析 Session Lane(会话级队列)和 Global Lane(全局队列),将任务入队——确保同一会话的请求串行执行,避免并发冲突。

随后解析工作区目录。如果调用方未指定工作区路径,会按 sessionKey → agentId → 默认路径的顺序逐级回退。

2.2 Hook 预检与模型解析

在真正调用 LLM 之前,运行两个插件钩子:

  1. before_model_resolve — 插件可以覆盖 provider/model 选择
  2. before_agent_start — 遗留兼容钩子,新钩子优先级更高

模型解析支持 fallback 链:当主模型不可用时,自动切换到配置中的备选模型。

2.3 凭证保活:Auth Profile 轮换 + Token 自动刷新

Agent 可能长时间运行,期间 LLM 凭证可能失效。OpenClaw 用两个互补机制保证 Agent 不会因凭证问题中断:

Auth Profile 轮换——OpenClaw 支持同时配置多个 LLM Provider 的凭证(例如多个 API Key、多个账号)。当前凭证调用失败时(额度用完、被限流、Key 过期),自动切换到下一个:

1
2
3
4
用户发消息 → 用 Profile A (Anthropic) 调用 LLM
→ 失败(额度用完)
→ 标记 A 进入冷却期,切换到 Profile B (OpenAI)
→ 成功 → 回复用户

每个 Profile 有独立的冷却时间,防止短时间内反复尝试已失败的凭证。重试总上限 160 次。

Copilot Token 自动刷新——普通 API Key 长期有效不会过期,但 GitHub Copilot 使用 OAuth Token,有效期只有几小时。如果 Agent 跑了 2 小时中途 Token 过期,LLM 调用就会失败。OpenClaw 的做法是在 Token 过期前 5 分钟 主动刷新,Agent 全程无感知。

两者可以叠加:Token 刷新失败时,Profile 轮换兜底,把 Copilot 标记为冷却并切换到其他 Provider。

Auth Profile 轮换 Copilot Token 刷新
解决什么 当前凭证不可用 凭证即将过期
触发时机 调用失败后(被动) 过期前 5 分钟(主动)
策略 切换到另一个 Provider/Key 续期同一个 Token
适用范围 所有 Provider 仅 OAuth 类(如 Copilot)

2.4 上下文窗口守护

在运行前检查模型的上下文窗口大小:

  • 低于警告阈值 → 记录警告但继续运行
  • 低于硬性下限 → 直接阻断运行,返回错误

这可以防止用户配置了上下文极小的模型导致 Agent 反复报错。

2.5 执行循环

核心执行通过”尝试-分类-重试”的循环完成。每次尝试后根据错误类型分类处理:

flowchart TD A["执行一次 Agent 调用"] --> B{"成功?"} B -->|是| C["构建结构化回复\n处理 MEDIA / AUDIO 等指令"] B -->|否| D{"错误类型?"} D -->|计费错误| E["停止运行\n(不可恢复)"] D -->|上下文溢出| F["触发自动压缩\n分配诊断 ID → 重试"] D -->|认证错误| G["轮换到下一个\nAuth Profile → 重试"] D -->|速率限制| H["指数退避等待 → 重试"] D -->|瞬时超时| I["直接重试"]

3. 工具组装管线

Agent 的能力由它可用的工具决定。工具来自 7 个来源,按顺序组装:

flowchart TD subgraph 来源["7 个工具来源"] T1["① Core Tools\nread / write / edit\nPi 框架基础工具"] T2["② Sandbox Tools\n容器化变体\nhost ↔ sandbox 路径桥接"] T3["③ apply_patch\n特定 Provider 启用(如 OpenAI)"] T4["④ exec + 进程管理\n命令执行 / PTY / 白名单"] T5["⑤ Channel Tools\n通道专属工具\n如 Slack 转发、Discord 频道操作"] T6["⑥ OpenClaw Tools\nmessage / sessions / gateway\ncameras / subagents / media"] T7["⑦ Plugin Tools\n插件动态注册的工具"] end 来源 --> A["Provider 适配\nGemini 剥离约束字段\nAnthropic 保留完整 schema"] A --> B["7 层策略管线过滤\n(详见下节)"] B --> C["最终可用工具集"]

来源 1:Core Tools(pi-coding-agent)

基础编码工具:readwriteedit——来自 Pi 框架的 pi-coding-agent 包。这些是文件系统操作的核心,支持工作区安全限制。

来源 2:Sandbox Tools(容器化变体)

当 Agent 运行在沙箱环境中时,文件系统和进程工具会切换到容器化变体,通过独立的 host/sandbox 路径桥接实现文件访问。

来源 3:apply_patch(可选)

apply_patch 工具仅在特定 Provider 下启用(如 OpenAI),用于批量应用代码补丁。Anthropic OAuth 场景还会对工具名称做重映射。

来源 4:exec + 进程管理

execprocess 工具提供命令执行和进程管理能力。安全配置控制了命令白名单、超时和 PTY 分配。

来源 5:Channel Agent Tools

通道专属工具——例如 Slack 的消息转发、Discord 的频道操作。这些工具根据当前消息通道动态启用,并通过不兼容规则控制组合(例如语音通道禁用 TTS)。

来源 6:OpenClaw Tools

OpenClaw 自有工具:message(发送消息)、sessions(会话管理)、gateway(网关控制)、cameras(摄像头)、subagents(子代理生成)、media(媒体处理)等。

来源 7:Plugin Tools(动态加载)

通过插件系统动态注册的工具。每个插件可以声明自己的工具集,运行时通过插件加载器发现并合并。

Provider 适配

组装完成后,还需要针对不同 Provider 做 schema 规范化——Gemini 会剥离约束字段,Anthropic 保留完整 schema。工具还会被包装上 abort signal 和 before-call 钩子(循环检测、认证追踪)。

最后,所有工具统一通过 策略管线 过滤,这是下一节的重点。


4. 7 层工具策略管线

同一个 Gateway 可能同时服务多个场景——你自己在终端写代码需要全部工具,接入 Discord 群不能让群友执行 rm -rf /,给朋友配的只读 Agent 只能搜索和阅读。策略管线就是控制不同场景下 Agent 能用哪些工具的。

核心规则:只能收窄,不能扩大

7 层是级联过滤的,每一层只能从上层传下来的工具集里继续删,不能往里加:

1
2
3
4
5
全部 40+ 工具
→ 第 1-2 层(profile)过滤后剩 30 个
→ 第 3-4 层(全局策略)过滤后剩 25 个
→ 第 5-6 层(Agent 策略)过滤后剩 10 个
→ 第 7 层(群组策略)过滤后剩 5 个 ← Agent 最终只能用这 5 个

管理员在上层设的安全底线,下层的任何配置都无法突破。

举例

全局禁用危险工具(第 3 层)——配置 tools.allow: ["*", "!exec", "!process"],所有 Agent、所有通道都不能执行命令。

只读 Agent(第 5 层)——配置 agents.research.tools.allow: ["read", "web_search", "web_fetch"],这个 Agent 只能读文件和搜索,连 write 都没有。

Discord 群再收窄(第 7 层)——群组配置 tools.allow: ["read", "web_search"],即使 Agent 自身允许 web_fetch,在这个群里也不能用。

管线结构

flowchart TD A["工具全集"] --> L1 L1["层1: 用户工具配置文件\ncoding / analysis / messaging / full"] --> L2 L2["层2: Provider 专属 profile\n如 anthropic-restricted"] --> L3 L3["层3: 全局允许/拒绝列表"] --> L4 L4["层4: 全局 Provider 允许列表"] --> L5 L5["层5: Agent 专属工具策略"] --> L6 L6["层6: Agent + Provider 组合策略"] --> L7 L7["层7: 群组/通道级别策略"] --> B["过滤后的工具集"] style A fill:#e8f5e9 style B fill:#fff3e0
层级 配置路径 说明
1 tools.profile 用户工具配置文件(coding / analysis / messaging / full)
2 tools.byProvider.profile Provider 专属 profile(如 anthropic-restricted)
3 tools.allow 全局允许/拒绝列表
4 tools.byProvider.allow 全局 Provider 专属允许列表
5 agents.{id}.tools.allow Agent 专属工具策略
6 agents.{id}.tools.byProvider.allow Agent + Provider 组合策略
7 group tools.allow 群组/通道级别策略

管线执行

执行过程分为三步:

  1. 区分 core vs. plugin — 判断每个工具是核心工具还是插件工具
  2. 构建插件工具组 — 将同一插件的所有工具分组,支持通配符匹配(例如 plugin:mcp-* 匹配所有 MCP 插件工具)
  3. 逐层过滤 — 遍历 7 层,依次收窄工具集

如果某一层的允许列表包含未知条目(既不是核心工具也不匹配任何已加载插件),会输出诊断信息。


5. 事件流订阅

Pi 使用 事件流模型 而非传统 callback。OpenClaw 订阅事件流并按类型分发处理:

flowchart LR subgraph 事件流["Pi 事件流"] E1["message_start"] E2["message_end"] E3["tool_execution_start"] E4["tool_execution_end"] E5["agent_start / end"] E6["auto_compaction"] end E3 --> H1["刷新回复缓冲区\n发送 typing indicator\n记录工具调用元数据"] E4 --> H2["检测错误状态\n提交/丢弃消息文本\n运行 after_tool_call 钩子\n媒体 URL 安全过滤"]

关键事件处理

**tool_execution_start**:

这是一个 异步、尽力而为 的处理器——不阻塞主执行流。它完成三件事:

  1. 刷新回复缓冲区,确保消息边界正确
  2. 触发 typing indicator — 发送 fire-and-forget 的输入指示信号(告知用户”AI 正在操作”)
  3. 记录工具调用元数据

**tool_execution_end**:

处理更加复杂:

  1. 检测工具执行是否出错
  2. 提交或丢弃消息工具的文本(仅非错误时保留)
  3. 更新变更操作追踪(写操作的错误必须上报)
  4. 运行 after_tool_call 插件钩子
  5. 媒体 URL 安全过滤 — 区分受信任工具(如 readeditexecbrowser 等,直接透传 URL)和非受信任工具(仅允许 HTTP/HTTPS URL),防止恶意路径注入

错误抑制策略

并非所有错误都需要暴露给用户,系统实现了智能错误抑制:

错误类型 处理方式
可恢复错误(字段缺失/格式无效) 如果用户已有回复则静默
变更操作错误(写/删除失败) 始终上报(不能静默确认失败的写操作)
exec/bash 错误 除非 verbose 模式,否则抑制
跨会话发送超时 静默处理(瞬时问题)

6. Subagent 系统

OpenClaw 支持 子代理——父 Agent 通过工具调用生成独立的子 Agent。

sequenceDiagram participant P as 父 Agent participant S as 子 Agent P->>S: sessions_spawn(task, sandbox) Note over P: 父 Agent 继续其他工作 Note over S: 独立执行(独立 Lane,不占用父队列) S->>P: announce(宣告结果) Note over P: 收到结果,继续后续处理 rect rgb(255, 245, 230) Note over P,S: 失败处理 Note over S: 宣告失败 → 指数退避重试 1s→2s→4s→8s,最多 3 次 Note over S: 5 分钟强制过期 / 30 分钟硬过期(兜底) end rect rgb(255, 235, 235) Note over P,S: 孤儿检测 Note over S: 父会话已销毁?→ 标记 error + 清理资源 end

子代理运行记录

每个子代理运行都被完整追踪,记录关键状态:

1
2
3
4
5
6
7
8
9
10
SubagentRunRecord:
runId — 运行唯一 ID
childSessionKey — 子代理会话 key
requesterSessionKey — 父会话 key
task — 任务描述
cleanup — 完成后 "delete" 还是 "keep" 会话
spawnMode — 生成模式
createdAt / startedAt / endedAt — 时间戳
outcome — 结果 { status, error?, text? }
announceRetryCount — 宣告重试次数

生命周期常量

常量 说明
宣告窗口 2 分钟 子代理完成后等待宣告的时间窗口
强制过期 5 分钟 超时未宣告成功则过期
最大重试 3 次 宣告最大重试次数
重试延迟上限 8 秒 指数退避的上限
硬过期 30 分钟 防止任何原因导致的子代理永远挂起

孤儿检测

系统会检查子代理是否已成为孤儿——例如父会话已销毁、session ID 不再存在。发现孤儿后,将其标记为 status: "error" 并执行清理。30 分钟硬过期兜底,确保没有子代理永远挂起。


7. Skills 系统

Skills 是 Agent 运行时的 “知识扩展包”——不是工具(执行能力),而是提示词注入(知识能力)。

技能来源优先级

技能按来源分层,优先级从低到高(高优先级覆盖低优先级的同名技能):

flowchart LR A["① openclaw-extra\n显式配置的额外目录"] --> B["② openclaw-bundled\n内置技能"] B --> C["③ openclaw-managed\n包管理器安装的技能"] C --> D["④ ~/.agents/skills\n个人全局技能"] D --> E["⑤ .agents/skills\n项目级技能"] E --> F["⑥ 工作区 skills/\n最高优先级"] style A fill:#f5f5f5 style F fill:#e8f5e9
优先级 来源 路径
1(最低) openclaw-extra 显式配置的额外目录
2 openclaw-bundled 内置 OpenClaw 技能
3 openclaw-managed 包管理器安装的技能
4 agents-skills-personal ~/.agents/skills
5 agents-skills-project .agents/skills(工作区内)
6(最高) openclaw-workspace 工作区 skills/ 目录

预算控制

技能注入到系统提示词中,但有严格的预算控制:

限制 说明
最大技能数 150 个 超出则截断
最大字符数 30,000 字符 所有技能的总字符数
单文件上限 256 KB 单个技能文件大小

当技能总量超过字符预算时,使用 二分查找截断 算法找到能容纳的最大技能前缀。路径中的 $HOME 会被替换为 ~,节省约 400-600 个 token。

工作区同步

技能文件会被同步到沙箱工作区。同步操作按工作区序列化执行,避免并发写入冲突,并对路径做安全清理。


8. 执行流程全景

把上面的所有环节串起来,一个用户消息从接收到回复的完整路径如下:

flowchart LR A["用户消息"] --> B["会话定位"] --> C["模型解析\nfallback 链"] --> D["工具组装\n7 个来源"] --> E["策略管线\n7 层过滤"] --> F["技能提示词\n注入"]
flowchart TD subgraph G["Agent 执行循环"] G1["Lane 队列入队\nSession + Global Lane"] --> G2["Auth Profile 解析\nCopilot Token 刷新"] G2 --> G3["上下文窗口校验"] G3 --> G4["调用 Pi 框架执行"] end G4 --> H1 subgraph H["Pi 事件流处理"] H1["message_start/update/end\n→ 文本流式输出"] H2["tool_execution_start\n→ typing indicator"] H3["tool_execution_end\n→ 结果捕获 + 媒体过滤"] H4["auto_compaction\n→ 上下文压缩"] end H --> I{"错误?"} I -->|是| J["错误分类 + 重试\n(failover 状态机)"] J --> G4 I -->|否| K["构建结构化回复"] K --> L["回复用户\n(通过通道层投递)"]

9. Agent 持续运行:7×24 自主执行

前面的章节介绍的都是 被动模式——用户发消息,Agent 回复。但 OpenClaw 的 Agent 也能 主动运行:定时巡检工作区、响应异步事件、按计划执行任务,实现 24 小时不间断的自主服务。

核心设计:回合制,而非常驻进程

Agent 的持续运行 不是 一个永不退出的长进程。它采用 回合制(turn-based) 模型:

flowchart TD subgraph 被动["被动回合"] direction LR A1["用户消息"] --> A2["Agent 执行\n(调用 LLM + 工具)"] --> A3["回复用户"] end subgraph 主动["主动回合"] direction LR B1["定时器 / 事件 / Cron"] --> B2["Agent 执行\n(同样的 LLM + 工具)"] --> B3["投递结果\n或静默完成"] end subgraph 持久["跨回合持久化"] C1["会话历史(磁盘 JSON)"] C2["工作区文件(HEARTBEAT.md 等)"] end A3 --> C1 B3 --> C1 C1 -.->|"下一回合加载"| A2 C1 -.->|"下一回合加载"| B2

每个回合都是一次完整的 Agent 执行(getReplyFromConfig() → Pi 框架的 prompt → LLM → 工具调用循环 → 回复),和用户消息触发的回合走完全相同的代码路径。回合之间通过 会话持久化(磁盘上的 JSON 文件)和 工作区文件 保持上下文连续性。

这个设计的好处是:Agent 不需要长期占用内存和 LLM 连接,每个回合结束后资源就释放了,但通过持久化的会话历史,下一回合可以无缝接续上次的上下文。

9.1 Heartbeat:定时自主巡检

Heartbeat 是 Agent 持续运行的核心机制。Gateway 启动时,startHeartbeatRunner()src/infra/heartbeat-runner.ts)注册一个定时调度器,按配置的间隔周期性地触发 Agent 回合。

工作流程:

sequenceDiagram participant T as 定时器 participant R as HeartbeatRunner participant A as Agent(Pi 框架) participant D as 投递层 loop 每 30 分钟(可配置) T->>R: requestHeartbeatNow(reason: "interval") R->>R: 检查: 队列空闲? 在活跃时段内? R->>A: getReplyFromConfig(heartbeatPrompt) Note over A: 读取 HEARTBEAT.md,检查工作区状态,调用工具 A-->>R: 回复内容 alt 回复 = "HEARTBEAT_OK" R->>R: 静默处理,裁剪对话记录 else 回复有实质内容(告警/报告) R->>D: deliverOutboundPayloads() D-->>R: 发送到 Telegram / Discord / ... end end

Heartbeat Prompt 是 Agent 在每次心跳回合中收到的指令,默认值很简洁:

1
2
3
4
Read HEARTBEAT.md if it exists (workspace context).
Follow it strictly.
Do not infer or repeat old tasks from prior chats.
If nothing needs attention, reply HEARTBEAT_OK.

用户通过在工作区放置 HEARTBEAT.md 文件来告诉 Agent 每次巡检时该做什么——比如检查服务器状态、检查代码仓库是否有新 issue、执行构建验证等。

HEARTBEAT_OK 裁剪机制:如果 Agent 判断无事可做,回复 HEARTBEAT_OK,系统会 回退对话记录到心跳前的状态,避免大量”一切正常”的空洞对话膨胀上下文窗口。只有有实质内容的回合才会保留在会话历史中。

裁剪的实现非常巧妙——利用文件截断(fs.truncate)做字节级回退

sequenceDiagram participant R as HeartbeatRunner participant F as 对话记录文件(JSONL) participant A as Agent(Pi 框架) R->>F: captureTranscriptState() Note over F: 记录当前文件大小 = 48,230 字节 R->>A: getReplyFromConfig(heartbeatPrompt) Note over A: Pi 框架执行心跳回合,自动将<br/>user + assistant 消息追加写入文件 Note over F: 文件增长到 49,850 字节(+1,620) A-->>R: 回复 "HEARTBEAT_OK" alt HEARTBEAT_OK(无实质内容) R->>F: fs.truncate(path, 48230) Note over F: 截断回心跳前大小,对话记录被擦除 R->>F: restoreHeartbeatUpdatedAt() else 有实质内容(告警/报告) Note over F: 保留完整文件,心跳对话成为历史一部分 end

三步操作确保”无事发生”的心跳回合不留痕迹:

  1. 心跳前快照captureTranscriptState() 通过 fs.stat() 记录对话记录文件(JSONL 格式)的当前字节大小
  2. 文件截断pruneHeartbeatTranscript() 调用 fs.truncate(transcriptPath, preHeartbeatSize),把文件截断到心跳前的大小——Pi 框架在执行过程中追加写入的 user(心跳提示词)和 assistant(HEARTBEAT_OK 回复)消息被直接从文件末尾抹除
  3. 时间戳恢复restoreHeartbeatUpdatedAt() 将会话的 updatedAt 回退到心跳前的值,避免空心跳触发不必要的会话排序变动

这个设计之所以可行,是因为对话记录使用 JSONL 格式(每行一条消息,追加写入)——新消息总是追加在文件末尾,截断末尾就能精确移除最后写入的消息,不会破坏之前的记录。

重复告警抑制:如果 Agent 连续两次心跳回复完全相同的文本(24 小时内),第二次会被静默跳过,防止 Agent 对同一问题反复”唠叨”。

9.2 Heartbeat 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
agents:
defaults:
heartbeat:
every: "30m" # 间隔(支持 s/m/h 单位,默认 30 分钟)
prompt: "..." # 自定义心跳提示词(覆盖默认)
target: "last" # 结果投递目标(none / last / 具体通道 ID)
model: "anthropic/claude" # 心跳专用模型(可选,默认继承 Agent 模型)
session: "main" # 心跳使用的会话(默认主会话)
activeHours: # 活跃时段窗口(窗口外不触发心跳)
start: "09:00"
end: "22:00"
timezone: "user"
ackMaxChars: 300 # HEARTBEAT_OK 附带内容的字符上限
配置项 默认值 说明
every "30m" 心跳间隔;"0m" 禁用心跳
target "none" "none" = 静默执行;"last" = 投递到最近交互的通道
activeHours 限制心跳只在特定时段运行(如工作时间),窗口外的心跳被跳过
model 继承 可为心跳指定更便宜/更快的模型,节约成本

多 Agent 心跳:如果配置了多个 Agent(agents.list),每个 Agent 可以有独立的心跳配置。只有显式配置了 heartbeat 字段的 Agent 才会参与心跳调度;未配置的 Agent 不会被心跳触发。

9.3 系统事件:事件驱动的 Agent 唤醒

除了定时心跳,Agent 还可以被 异步事件 唤醒。enqueueSystemEvent()src/infra/system-events.ts)是事件入口:

理解系统事件机制,需要先分清三个 独立 的系统:

系统 职责 数据结构
Heartbeat 调度器 按固定间隔(如 30 分钟)触发回合 定时器(setTimeout
Cron 调度器 按 cron 表达式在精确时间点触发 独立定时器 + 任务存储
系统事件队列 消息缓冲区,暂存待注入的事件文本 内存 Map<string, Queue>

系统事件队列不是任务调度器——它只是一个缓冲区。事件不会”提前入队等待执行”,而是在实际发生时才被放入队列。

事件源分为两类,区别在于 入队的同时是否唤醒心跳

事件来源 入队(enqueueSystemEvent 唤醒(requestHeartbeatNow 行为
exec 命令完成 入队 + 立即唤醒
Webhook / Hook 触发 是(wakeMode=now 时) 入队 + 立即唤醒
设备通知 入队 + 立即唤醒
Slack 反应/编辑/删除/Pin 仅入队,搭便车
Telegram 反应 仅入队,搭便车
Gateway 重启通知 仅入队,搭便车

注意表里没有 Cron——Cron 走的是独立路径(详见 9.4 节),不是”先入队再等心跳消费”。

flowchart TD subgraph 入队且唤醒["入队 + 立即唤醒"] E1["exec 命令完成"] E3["Webhook / Hook"] E6["设备通知"] end subgraph 仅入队["仅入队(搭便车)"] E2["Slack 反应/编辑/Pin"] E7["Telegram 反应"] E5["Gateway 重启通知"] end E1 & E3 & E6 --> Q["系统事件队列\n(内存缓冲区,每会话最多 20 条)"] E2 & E7 & E5 --> Q E1 & E3 & E6 -->|requestHeartbeatNow| W["立即唤醒心跳"] W --> A["Agent 回合启动"] A --> D["drainSystemEventEntries()\n取出队列中所有事件"] Q --> D D --> P["注入到系统提示词"]

“仅入队”事件的搭便车模式:Slack 反应这类事件自己不会触发回合——它们只是静静地待在队列里。下一次回合(无论是定时心跳、exec 唤醒还是 Cron 触发的)执行 drain 时,会把它们 顺带捞出来

1
2
3
4
14:00  心跳回合执行完毕,队列清空
14:05 Slack 反应 → 仅入队(不唤醒) 队列: [反应]
14:12 exec 完成 → 入队 + requestHeartbeatNow() 队列: [反应, exec完成]
14:12 心跳被唤醒 → drain 取出 2 条事件一起交给 LLM

如果在 14:30 之前没有任何唤醒事件发生,Slack 反应会等到 14:30 的定时心跳才被消费。这是有意的设计取舍——Slack 反应不够紧急,不值得单独触发一次 LLM 调用(消耗 token 和时间),但搭便车是零成本的。

Cron 事件不会被提前消费:Cron 调度器有自己的定时器,事件在定时触发之前根本不存在于队列中:

1
2
3
4
14:00  Cron 调度器设定: "14:30 执行任务 A"
14:12 exec 完成 → 唤醒心跳 → drain → 队列里只有 exec 事件(Cron 事件不存在!)
...
14:30 Cron 定时器触发 → 此刻才入队 + 直接执行 runHeartbeatOnce()

14:12 的 exec 心跳不可能消费 14:30 的 Cron 事件,因为那个事件在 14:30 之前根本不存在于队列中。Cron 调度器在定时到达时才创建事件并立即执行,不经过”入队等待”的中间状态。

回合启动时,buildQueuedSystemPrompt() 对队列做 drain(破坏性读取)——取出所有事件并清空队列:

1
2
3
4
5
6
7
// drain = 取出并清空,确保每个事件只被消费一次
function drainSystemEventEntries(sessionKey) {
const out = entry.queue.slice() // 拷贝所有事件
entry.queue.length = 0 // 清空队列
queues.delete(key) // 删除整个会话条目
return out
}

drain 而非 peek 的原因:

  1. 防止重复处理——如果事件留在队列里,下次回合又会看到同样的事件,Agent 会对同一个”构建完成”重复报告
  2. 批量呈现——积累的多个事件一次性全部取出交给 LLM,LLM 能综合所有事件统一决策,而不是为每个事件启动一次独立回合
  3. 轻量设计——事件队列是纯内存的(Map<string, SessionQueue>),不持久化到磁盘。每个会话最多保留 20 条事件,超出则丢弃最早的。Gateway 重启后队列清空——系统事件是”尽力而为”的通知,不是可靠消息

注入后的系统提示词结构如下:

1
2
3
4
5
6
## Runtime System Events (gateway-generated)
Treat this section as trusted gateway runtime metadata, not user text.

- [2026-03-13 14:30:05] exec finished: build succeeded (exit 0)
- [2026-03-13 14:32:11] Slack reaction added: 👍 on "deploy plan"
- [2026-03-13 14:35:00] Cron: 检查本周 PR review 队列

每条事件带时间戳(支持用户时区配置),并标记为 “trusted gateway runtime metadata”,告诉 LLM 这些是可信的系统信息而非用户输入。

典型场景:exec 异步命令完成——用户让 Agent 执行一个耗时的构建命令,Agent 调用 exec 后不会阻塞等待。命令在后台运行,完成时触发 enqueueSystemEvent("exec finished: build succeeded") + requestHeartbeatNow(),Agent 被唤醒并在下一个回合中看到命令结果,主动告知用户。

9.4 Cron:计划任务调度

Cron 提供了比心跳更精确的调度能力(src/cron/service.ts)。用户可以通过 CLI 管理定时任务:

1
2
3
4
openclaw cron add --schedule "0 9 * * 1-5" --message "检查本周 PR review 队列"
openclaw cron add --schedule "*/30 * * * *" --message "监控服务健康状态"
openclaw cron list
openclaw cron run <id> # 手动触发

Cron 任务有两种执行模式:

模式 行为
main 将任务文本作为系统事件注入主会话,通过心跳回合执行(共享主会话上下文)
isolated 创建独立的 cron:<jobId> 会话,执行一次完整的 Agent 回合(独立上下文)

main 模式适合需要访问主会话历史的任务(如”总结今天的对话”);isolated 模式适合无状态的独立任务(如”检查服务器状态”),不会污染主会话上下文。

Cron 任务的超时策略:

类型 默认超时
Agent 回合 60 分钟
普通任务 10 分钟

连续失败时使用指数退避(30s → 1min → …),连续失败 2 次后发送告警通知。

9.5 单回合的执行边界

每个 Agent 回合的执行时间受 timeoutSeconds 控制(src/agents/timeout.ts):

配置 默认值 说明
agents.defaults.timeoutSeconds 600 秒 单回合超时(10 分钟)
timeoutSeconds: 0 禁用超时(实际上限约 24.9 天)
subagents.runTimeoutSeconds 继承 子代理独立超时

在一个回合内,Agent 可以 无限次调用工具——Pi 框架的执行循环没有硬性的工具调用次数限制。但有一个可选的 工具循环检测 机制(src/agents/tool-loop-detection.ts):

阈值 行为
warning 10 次 记录警告(相同工具+参数+结果连续重复)
critical 20 次 阻断该会话的执行
全局熔断 30 次 全局熔断,防止失控

9.6 会话持续性:跨回合的记忆

Agent 的持续运行之所以有意义,是因为 会话跨回合持久化

  • 会话存储~/.openclaw/sessions/ 下的 JSON 文件,每个 Agent 有独立的存储路径
  • 会话生命期:无限——没有 maxTurnsmaxDuration 限制,一个会话可以跨越数天甚至数周
  • 回合间隔:不限——两次回合之间可以间隔几秒(心跳),也可以间隔几天(等用户消息)

每次回合结束后,Pi 的 SessionManager 自动将对话历史写入磁盘。下一回合(无论是用户消息、心跳还是 Cron 触发的)加载同一个会话文件,Agent 就能”记住”之前所有交互。

结合上下文压缩(详见上下文记忆篇),即使对话历史很长,Agent 也能在有限的上下文窗口内保持连贯的记忆。

9.7 唤醒来源全景

Agent 回合可以被多种来源触发,形成一个 事件驱动 + 定时调度 的混合执行模型:

flowchart TD subgraph 被动触发["被动触发(用户驱动)"] T1["用户消息\nTelegram / Discord / WhatsApp / CLI"] end subgraph 主动触发["主动触发(系统驱动)"] T2["Heartbeat 定时器\n(默认每 30 分钟)"] T3["Cron 定时任务\n(cron 表达式调度)"] T4["exec 命令完成"] T5["Webhook / Hook"] T6["通道事件\n(Slack 编辑/反应/Pin 等)"] T7["Gateway 重启通知"] end T1 & T2 & T3 --> R["Agent 回合执行\n(getReplyFromConfig)"] T4 & T5 & T6 & T7 --> R R --> S["会话持久化\n写入磁盘"] S -.->|"下一回合加载"| R style T1 fill:#e3f2fd style T2 fill:#e8f5e9 style T3 fill:#e8f5e9 style T4 fill:#fff3e0 style T5 fill:#fff3e0 style T6 fill:#fff3e0 style T7 fill:#fff3e0

所有触发源最终都汇聚到同一个 getReplyFromConfig() 入口——Agent 不区分是用户主动找它还是系统定时唤醒它,执行逻辑完全一致。差异只在于提示词内容(心跳用 HEARTBEAT_PROMPT,Cron 用任务文本,用户消息用原文)和结果投递目标。


以上覆盖了 Pi 框架集成、工具组装与 7 层策略管线、事件流处理、Subagent 生命周期管理、Auth Profile 轮换、Skills 预算注入,以及 Agent 通过心跳、系统事件和 Cron 实现 7×24 自主持续运行的回合制架构。