
OpenClaw 设计解析(五):Agent 运行时——LLM 编排与工具执行
OpenClaw 设计解析(五):Agent 运行时——LLM 编排与工具执行
OpenClaw Agent 运行时:Pi 框架集成、工具组装、策略管线、事件流、Subagent 和 Agent 持续运行。
本篇介绍 OpenClaw 的 Agent 运行时:Pi 框架集成、Agent 启动流程、工具组装管线、7 层工具策略管线、事件流订阅、Subagent 系统、Skills 系统,以及 Agent 如何实现 7×24 自主持续运行。
1. Pi 框架集成
OpenClaw 的 Agent 运行时建立在 Pi 框架之上,使用三件套:
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 | // Pi 默认:HTTP 调用所有 LLM Provider |
这就像一条中间件链——每一层包装都可以在请求发给 LLM 之前做修改(清理 thinking blocks、注入 Ollama 参数、记录请求日志),而不需要改 Pi 框架的代码。
③ 事件流(Event Stream)
Pi 不用传统的独立 callback(如 onMessage、onToolCall),而是所有运行时事件走一条统一的事件流,由订阅者按类型处理:
1 | session.subscribe((event) => { |
OpenClaw 订阅这个事件流后,将每种事件分发到独立的处理模块——消息处理(流式分块、typing indicator)、工具处理(媒体 URL 过滤、变更追踪)、压缩处理(重试协调)、生命周期处理。
OpenClaw 与 Pi 的分工
简单说:Pi 是 Agent 的执行引擎,负责”LLM 要调工具 → 执行工具 → 结果发回 LLM”这个核心循环;OpenClaw 是驾驶员,负责告诉引擎用什么模型、带哪些工具、怎么处理错误、怎么把结果传给用户。
2. Agent 启动流程
Agent 的完整启动流程可以分为以下几个阶段:
2.1 队列入队与工作区解析
首先解析 Session Lane(会话级队列)和 Global Lane(全局队列),将任务入队——确保同一会话的请求串行执行,避免并发冲突。
随后解析工作区目录。如果调用方未指定工作区路径,会按 sessionKey → agentId → 默认路径的顺序逐级回退。
2.2 Hook 预检与模型解析
在真正调用 LLM 之前,运行两个插件钩子:
before_model_resolve— 插件可以覆盖 provider/model 选择before_agent_start— 遗留兼容钩子,新钩子优先级更高
模型解析支持 fallback 链:当主模型不可用时,自动切换到配置中的备选模型。
2.3 凭证保活:Auth Profile 轮换 + Token 自动刷新
Agent 可能长时间运行,期间 LLM 凭证可能失效。OpenClaw 用两个互补机制保证 Agent 不会因凭证问题中断:
Auth Profile 轮换——OpenClaw 支持同时配置多个 LLM Provider 的凭证(例如多个 API Key、多个账号)。当前凭证调用失败时(额度用完、被限流、Key 过期),自动切换到下一个:
1 | 用户发消息 → 用 Profile A (Anthropic) 调用 LLM |
每个 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 执行循环
核心执行通过”尝试-分类-重试”的循环完成。每次尝试后根据错误类型分类处理:
3. 工具组装管线
Agent 的能力由它可用的工具决定。工具来自 7 个来源,按顺序组装:
来源 1:Core Tools(pi-coding-agent)
基础编码工具:read、write、edit——来自 Pi 框架的 pi-coding-agent 包。这些是文件系统操作的核心,支持工作区安全限制。
来源 2:Sandbox Tools(容器化变体)
当 Agent 运行在沙箱环境中时,文件系统和进程工具会切换到容器化变体,通过独立的 host/sandbox 路径桥接实现文件访问。
来源 3:apply_patch(可选)
apply_patch 工具仅在特定 Provider 下启用(如 OpenAI),用于批量应用代码补丁。Anthropic OAuth 场景还会对工具名称做重映射。
来源 4:exec + 进程管理
exec 和 process 工具提供命令执行和进程管理能力。安全配置控制了命令白名单、超时和 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 | 全部 40+ 工具 |
管理员在上层设的安全底线,下层的任何配置都无法突破。
举例
全局禁用危险工具(第 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,在这个群里也不能用。
管线结构
| 层级 | 配置路径 | 说明 |
|---|---|---|
| 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 |
群组/通道级别策略 |
管线执行
执行过程分为三步:
- 区分 core vs. plugin — 判断每个工具是核心工具还是插件工具
- 构建插件工具组 — 将同一插件的所有工具分组,支持通配符匹配(例如
plugin:mcp-*匹配所有 MCP 插件工具) - 逐层过滤 — 遍历 7 层,依次收窄工具集
如果某一层的允许列表包含未知条目(既不是核心工具也不匹配任何已加载插件),会输出诊断信息。
5. 事件流订阅
Pi 使用 事件流模型 而非传统 callback。OpenClaw 订阅事件流并按类型分发处理:
关键事件处理
**tool_execution_start**:
这是一个 异步、尽力而为 的处理器——不阻塞主执行流。它完成三件事:
- 刷新回复缓冲区,确保消息边界正确
- 触发 typing indicator — 发送 fire-and-forget 的输入指示信号(告知用户”AI 正在操作”)
- 记录工具调用元数据
**tool_execution_end**:
处理更加复杂:
- 检测工具执行是否出错
- 提交或丢弃消息工具的文本(仅非错误时保留)
- 更新变更操作追踪(写操作的错误必须上报)
- 运行
after_tool_call插件钩子 - 媒体 URL 安全过滤 — 区分受信任工具(如
read、edit、exec、browser等,直接透传 URL)和非受信任工具(仅允许 HTTP/HTTPS URL),防止恶意路径注入
错误抑制策略
并非所有错误都需要暴露给用户,系统实现了智能错误抑制:
| 错误类型 | 处理方式 |
|---|---|
| 可恢复错误(字段缺失/格式无效) | 如果用户已有回复则静默 |
| 变更操作错误(写/删除失败) | 始终上报(不能静默确认失败的写操作) |
| exec/bash 错误 | 除非 verbose 模式,否则抑制 |
| 跨会话发送超时 | 静默处理(瞬时问题) |
6. Subagent 系统
OpenClaw 支持 子代理——父 Agent 通过工具调用生成独立的子 Agent。
子代理运行记录
每个子代理运行都被完整追踪,记录关键状态:
1 | SubagentRunRecord: |
生命周期常量
| 常量 | 值 | 说明 |
|---|---|---|
| 宣告窗口 | 2 分钟 | 子代理完成后等待宣告的时间窗口 |
| 强制过期 | 5 分钟 | 超时未宣告成功则过期 |
| 最大重试 | 3 次 | 宣告最大重试次数 |
| 重试延迟上限 | 8 秒 | 指数退避的上限 |
| 硬过期 | 30 分钟 | 防止任何原因导致的子代理永远挂起 |
孤儿检测
系统会检查子代理是否已成为孤儿——例如父会话已销毁、session ID 不再存在。发现孤儿后,将其标记为 status: "error" 并执行清理。30 分钟硬过期兜底,确保没有子代理永远挂起。
7. Skills 系统
Skills 是 Agent 运行时的 “知识扩展包”——不是工具(执行能力),而是提示词注入(知识能力)。
技能来源优先级
技能按来源分层,优先级从低到高(高优先级覆盖低优先级的同名技能):
| 优先级 | 来源 | 路径 |
|---|---|---|
| 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. 执行流程全景
把上面的所有环节串起来,一个用户消息从接收到回复的完整路径如下:
9. Agent 持续运行:7×24 自主执行
前面的章节介绍的都是 被动模式——用户发消息,Agent 回复。但 OpenClaw 的 Agent 也能 主动运行:定时巡检工作区、响应异步事件、按计划执行任务,实现 24 小时不间断的自主服务。
核心设计:回合制,而非常驻进程
Agent 的持续运行 不是 一个永不退出的长进程。它采用 回合制(turn-based) 模型:
每个回合都是一次完整的 Agent 执行(getReplyFromConfig() → Pi 框架的 prompt → LLM → 工具调用循环 → 回复),和用户消息触发的回合走完全相同的代码路径。回合之间通过 会话持久化(磁盘上的 JSON 文件)和 工作区文件 保持上下文连续性。
这个设计的好处是:Agent 不需要长期占用内存和 LLM 连接,每个回合结束后资源就释放了,但通过持久化的会话历史,下一回合可以无缝接续上次的上下文。
9.1 Heartbeat:定时自主巡检
Heartbeat 是 Agent 持续运行的核心机制。Gateway 启动时,startHeartbeatRunner()(src/infra/heartbeat-runner.ts)注册一个定时调度器,按配置的间隔周期性地触发 Agent 回合。
工作流程:
Heartbeat Prompt 是 Agent 在每次心跳回合中收到的指令,默认值很简洁:
1 | Read HEARTBEAT.md if it exists (workspace context). |
用户通过在工作区放置 HEARTBEAT.md 文件来告诉 Agent 每次巡检时该做什么——比如检查服务器状态、检查代码仓库是否有新 issue、执行构建验证等。
HEARTBEAT_OK 裁剪机制:如果 Agent 判断无事可做,回复 HEARTBEAT_OK,系统会 回退对话记录到心跳前的状态,避免大量”一切正常”的空洞对话膨胀上下文窗口。只有有实质内容的回合才会保留在会话历史中。
裁剪的实现非常巧妙——利用文件截断(fs.truncate)做字节级回退:
三步操作确保”无事发生”的心跳回合不留痕迹:
- 心跳前快照:
captureTranscriptState()通过fs.stat()记录对话记录文件(JSONL 格式)的当前字节大小 - 文件截断:
pruneHeartbeatTranscript()调用fs.truncate(transcriptPath, preHeartbeatSize),把文件截断到心跳前的大小——Pi 框架在执行过程中追加写入的 user(心跳提示词)和 assistant(HEARTBEAT_OK 回复)消息被直接从文件末尾抹除 - 时间戳恢复:
restoreHeartbeatUpdatedAt()将会话的updatedAt回退到心跳前的值,避免空心跳触发不必要的会话排序变动
这个设计之所以可行,是因为对话记录使用 JSONL 格式(每行一条消息,追加写入)——新消息总是追加在文件末尾,截断末尾就能精确移除最后写入的消息,不会破坏之前的记录。
重复告警抑制:如果 Agent 连续两次心跳回复完全相同的文本(24 小时内),第二次会被静默跳过,防止 Agent 对同一问题反复”唠叨”。
9.2 Heartbeat 配置
1 | agents: |
| 配置项 | 默认值 | 说明 |
|---|---|---|
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 节),不是”先入队再等心跳消费”。
“仅入队”事件的搭便车模式:Slack 反应这类事件自己不会触发回合——它们只是静静地待在队列里。下一次回合(无论是定时心跳、exec 唤醒还是 Cron 触发的)执行 drain 时,会把它们 顺带捞出来:
1 | 14:00 心跳回合执行完毕,队列清空 |
如果在 14:30 之前没有任何唤醒事件发生,Slack 反应会等到 14:30 的定时心跳才被消费。这是有意的设计取舍——Slack 反应不够紧急,不值得单独触发一次 LLM 调用(消耗 token 和时间),但搭便车是零成本的。
Cron 事件不会被提前消费:Cron 调度器有自己的定时器,事件在定时触发之前根本不存在于队列中:
1 | 14:00 Cron 调度器设定: "14:30 执行任务 A" |
14:12 的 exec 心跳不可能消费 14:30 的 Cron 事件,因为那个事件在 14:30 之前根本不存在于队列中。Cron 调度器在定时到达时才创建事件并立即执行,不经过”入队等待”的中间状态。
回合启动时,buildQueuedSystemPrompt() 对队列做 drain(破坏性读取)——取出所有事件并清空队列:
1 | // drain = 取出并清空,确保每个事件只被消费一次 |
drain 而非 peek 的原因:
- 防止重复处理——如果事件留在队列里,下次回合又会看到同样的事件,Agent 会对同一个”构建完成”重复报告
- 批量呈现——积累的多个事件一次性全部取出交给 LLM,LLM 能综合所有事件统一决策,而不是为每个事件启动一次独立回合
- 轻量设计——事件队列是纯内存的(
Map<string, SessionQueue>),不持久化到磁盘。每个会话最多保留 20 条事件,超出则丢弃最早的。Gateway 重启后队列清空——系统事件是”尽力而为”的通知,不是可靠消息
注入后的系统提示词结构如下:
1 | ## Runtime System Events (gateway-generated) |
每条事件带时间戳(支持用户时区配置),并标记为 “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 | openclaw cron add --schedule "0 9 * * 1-5" --message "检查本周 PR review 队列" |
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 有独立的存储路径 - 会话生命期:无限——没有
maxTurns或maxDuration限制,一个会话可以跨越数天甚至数周 - 回合间隔:不限——两次回合之间可以间隔几秒(心跳),也可以间隔几天(等用户消息)
每次回合结束后,Pi 的 SessionManager 自动将对话历史写入磁盘。下一回合(无论是用户消息、心跳还是 Cron 触发的)加载同一个会话文件,Agent 就能”记住”之前所有交互。
结合上下文压缩(详见上下文记忆篇),即使对话历史很长,Agent 也能在有限的上下文窗口内保持连贯的记忆。
9.7 唤醒来源全景
Agent 回合可以被多种来源触发,形成一个 事件驱动 + 定时调度 的混合执行模型:
所有触发源最终都汇聚到同一个 getReplyFromConfig() 入口——Agent 不区分是用户主动找它还是系统定时唤醒它,执行逻辑完全一致。差异只在于提示词内容(心跳用 HEARTBEAT_PROMPT,Cron 用任务文本,用户消息用原文)和结果投递目标。
以上覆盖了 Pi 框架集成、工具组装与 7 层策略管线、事件流处理、Subagent 生命周期管理、Auth Profile 轮换、Skills 预算注入,以及 Agent 通过心跳、系统事件和 Cron 实现 7×24 自主持续运行的回合制架构。
- 感谢你的欣赏!



