OpenClaw 核心流程第三部分:工具、记忆与 Hook 管道
第一部分 追踪了 gateway。第二部分 涵盖了路由与 agent 循环。现在我们完成最后一块拼图:工具如何在 Docker 沙箱中执行、记忆检索的工作原理,以及消息路径中所有 13 个插件 hook 的精确执行顺序。
- 第一部分:Gateway — WebSocket 到 Agent 调度
- 第二部分:路由、提示词与 Agent 状态机
- 第三部分(本文): 工具、记忆与 Hook 管道
工具策略:谁能使用什么
文件: src/agents/tool-policy.ts
工具通过带有分组展开的分层策略进行管控:
const TOOL_GROUPS = {
"group:memory": ["memory_search", "memory_get"],
"group:web": ["web_search", "web_fetch"],
"group:fs": ["read", "write", "edit", "apply_patch"],
"group:runtime": ["exec", "process"],
};四种预设配置控制访问权限:
| 配置 | 访问范围 |
|---|---|
minimal |
仅 session_status |
coding |
文件系统、运行时、会话、记忆、图像 |
messaging |
消息 + 会话工具 |
full |
无限制 |
策略编译为 glob 匹配器,采用拒绝优先语义。解析层级:agent 专属 → 全局 → agent 级别 → 全局工具策略。子 agent 受到额外限制 — gateway、cron、memory_search 和 sessions_send 始终被拒绝。
沙箱:Docker 工具执行
文件: src/agents/sandbox/
容器创建
buildSandboxCreateArgs() 以加固的默认配置构建 Docker 容器:
--security-opt=no-new-privileges- 用于系统调用过滤的 Seccomp 配置文件
- AppArmor 强制访问控制
- 所有 Linux capabilities 已丢弃
- 只读根文件系统
- 内存 + CPU + PID 限制
两阶段符号链接检测
文件: src/agents/sandbox/validate-sandbox-security.ts
安全验证同时捕获明显的和隐蔽的逃逸尝试:
第一阶段 — 字符串验证:
parseBindSourcePath()从source:target[:mode]中提取宿主机路径normalizeHostPath()解析.、..,折叠//getBlockedBindReason()检查根挂载、被封锁路径、相对路径
第二阶段 — 符号链接解析:
tryRealpathAbsolute()解析到真实文件系统位置- 对解析后的路径重新进行被封锁路径校验
- 捕获:
/innocent/path→ 符号链接 →/etc/shadow
被封锁的宿主机路径(永不暴露):
/etc /proc /sys /dev /root /boot /run /var/run Docker socketShell 执行:三种宿主
exec 工具将命令路由到三种执行宿主之一:
| 宿主 | 使用时机 | 安全性 |
|---|---|---|
| Sandbox(默认) | 容器化执行 | 受限权限 |
| Gateway | 本地机器 | 白名单 + 两阶段审批 |
| Node | 远程设备 | Gateway 代理调用 |
两阶段审批机制:首先对照 shell 白名单模式分析命令,然后以"仅允许一次"或"始终允许"选项请求用户审批。审批 ID 有有效期。
SSRF 防护:Web Fetch
文件: src/infra/net/ssrf.ts
fetchWithSsrfGuard() 实现多层防御:
- 协议白名单 — 仅允许 HTTP/HTTPS
- IPv4 封锁 —
10.0.0.0/8、127.x.x.x、169.254.x.x、172.16-31.x.x、192.168.x.x、100.64-127.x.x - IPv6 封锁 —
::、::1、fe80::/10、fc00::/7 - 内嵌 IPv4 检测 — 捕获 IPv4 映射 IPv6、NAT64、6to4、Teredo、ISATAP 隧道
- 主机名黑名单 —
localhost、metadata.google.internal、.local、.internal - DNS 固定 —
createPinnedLookup()一次性解析并固定,防止 DNS 重绑定 - 重定向验证 — 最多 3 次重定向,循环检测,每个目标重新验证
限制:默认响应 50KB,最大 2MB,绝对上限 10MB。超时 30 秒。
记忆:混合向量 + 全文检索
Embedding 提供商链
文件: src/memory/embeddings.ts
自动模式解析:
1. 本地(GemmaEmbed 300M,通过 node-llama-cpp)→ 免费、私有
2. OpenAI → 远程 API
3. Gemini → 远程 API
4. Voyage → 远程 API
5. 仅 FTS 降级 → 无向量,仍可用所有向量均经过 L2 归一化:
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map(v => Number.isFinite(v) ? v : 0);
const magnitude = Math.sqrt(sanitized.reduce((sum, v) => sum + v * v, 0));
if (magnitude < 1e-10) return sanitized;
return sanitized.map(v => v / magnitude);
}混合检索合并
文件: src/memory/hybrid.ts
async function mergeHybridResults(params: {
vector: HybridVectorResult[];
keyword: HybridKeywordResult[];
vectorWeight: number;
textWeight: number;
mmr?: Partial<MMRConfig>;
temporalDecay?: Partial<TemporalDecayConfig>;
})算法流程:
- 联合合并 — 按 ID 合并向量和关键词结果
- 加权评分 —
vectorWeight × vectorScore + textWeight × textScore - 时间衰减 — 指数衰减,可配置半衰期(默认 30 天)
- MMR 重排序 — 最大边际相关性(Maximal Marginal Relevance)提升结果多样性
BM25 归一化
关键词检索使用 BM25 排名,归一化至 0-1:
function bm25RankToScore(rank: number): number {
return 1 / (1 + Math.max(0, rank));
}MMR:最大边际相关性
文件: src/memory/mmr.ts
Carbonell & Goldstein(1998)实现:
MMR = λ × relevance − (1 − λ) × max_similarity_to_selected相似度使用小写 token 集合上的 Jaccard 计算。默认 λ = 0.7(偏重相关性)。算法从剩余候选项中迭代选择 MMR 最高的条目,避免返回冗余片段。
时间衰减
文件: src/memory/temporal-decay.ts
function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
const lambda = Math.LN2 / halfLifeDays;
return Math.exp(-lambda * Math.max(0, ageInDays));
}带日期的记忆文件(memory/2026-02-15.md)根据年龄进行衰减。“常青"文件(无日期)保持完整评分。
三表 SQLite 设计
chunks— 单个数据块,包含路径、来源、哈希、内容chunks_vec— 向量 embedding 的虚拟表(sqlite-vec)chunks_fts— 全文检索索引
差量索引跳过未更改的文件(哈希比较)。通过临时数据库 + 文件重命名的原子交换实现安全重建索引。
完整 Hook 管道
文件: src/plugins/hooks.ts
三种执行模式:
| 模式 | 使用时机 | 行为 |
|---|---|---|
| Void | 观察 | 并行,触发即忘 |
| Modifying | 转换 | 按优先级顺序执行,结果合并 |
| Synchronous | 热路径 | 不允许异步 |
13 步 Hook 执行顺序
每条消息按以下精确顺序通过这些 hook:
| # | Hook | 模式 | 时机 |
|---|---|---|---|
| 1 | message_received |
void(并行) | 入站归一化后 |
| 2 | before_model_resolve |
modifying(顺序) | 模型选择期间 |
| 3 | before_prompt_build |
modifying(顺序) | 系统提示词组装前 |
| 4 | before_agent_start |
modifying(顺序) | LLM 调用前 |
| 5 | llm_input |
modifying(顺序) | 每次 LLM API 调用前 |
| 6 | before_tool_call |
modifying(顺序) | 每次工具执行前 |
| 7 | tool_result_persist |
sync | 工具结果返回后、持久化前 |
| 8 | after_tool_call |
modifying(顺序) | 每次工具执行后 |
| 9 | llm_output |
modifying(顺序) | 每次 LLM 响应后 |
| 10 | agent_end |
void(并行) | Agent 轮次完成后 |
| 11 | message_sending |
modifying(顺序) | 出站投递前 |
| 12 | before_message_write |
sync | 转录持久化前 |
| 13 | message_sent |
void(并行) | 成功投递后 |
Hook 6-9 在一个轮次内针对每次工具调用重复执行。before_tool_call hook 可以修改工具参数或完全阻止调用。message_sending hook 可以在消息到达 channel 之前修改出站消息。
Hook Runner 实现
function createHookRunner(hooks, options) {
function getHooksForName(name) {
return hooks
.filter(h => h.name === name)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); // 高优先级优先
}
// Void:全部并行执行
async function runVoidHooks(name, data) {
await Promise.all(matched.map(h => h.handler(data)));
}
// Modifying:顺序执行,合并结果
async function runModifyingHooks(name, initial, merge) {
let result = initial;
for (const hook of matched) {
const hookResult = await hook.handler(result);
if (hookResult !== undefined) result = merge(result, hookResult);
}
return result;
}
// Sync:不允许异步,若 handler 返回 Promise 则警告
function runSyncHooks(name, data) {
for (const hook of matched) {
const result = hook.handler(data);
if (result instanceof Promise) logger?.warn?.("Async in sync hook");
}
}
}端到端:WhatsApp 消息 → 响应
以一条 WhatsApp 私信为例,串联所有流程:
WhatsApp webhook 触发
→ Channel adapter 构建 MsgContext { Channel:"whatsapp", From: JID, Body: text }
→ finalizeInboundContext() 归一化字段
→ [Hook 1: message_received]
resolveAgentRoute({ channel:"whatsapp", peer:{ kind:"direct", id:JID }})
→ Tier 6 匹配(binding.account)→ 默认 agent
→ sessionKey: "agent:main:direct:5511999999999"
recordInboundSession() → 持久化元数据
dispatchReplyFromConfig()
→ [Hook 2: before_model_resolve] → 选定模型
→ [Hook 3: before_prompt_build] → 组装系统提示词
→ runReplyAgent()
→ [Hook 4: before_agent_start]
→ createAgentSession() → Agent + AgentSession
→ session.prompt(text)
→ Agent._runLoop()
→ agentLoop() → 双重循环:
→ [Hook 5: llm_input]
→ streamAssistantResponse() → LLM API 调用
→ [Hook 9: llm_output]
→ 若有工具调用:
→ [Hook 6: before_tool_call]
→ tool.execute()(在 Docker 沙箱中)
→ [Hook 7: tool_result_persist](sync)
→ [Hook 8: after_tool_call]
→ 检查 steering → 循环或退出
→ agent_end 事件
→ [Hook 10: agent_end]
回复投递:
→ [Hook 11: message_sending] → 可能修改出站消息
→ [Hook 12: before_message_write](sync)
→ WhatsApp 出站 adapter → 文本切分至 4000 字符
→ [Hook 13: message_sent]
Gateway WebSocket:
→ agent 事件 → 150ms 节流 → 广播(dropIfSlow)
→ 最终消息 → 广播(保证投递)关键架构洞察
-
双重循环设计优雅。 内层循环 = 工具执行 + steering。外层循环 = 后续跟进。中断语义与延续语义分离清晰。
-
工具调用间的 steering 是杀手级特性。 用户可在执行过程中重定向 agent。剩余工具调用被跳过,新指令注入。
-
13 个 hook 点赋予插件完全控制能力。插件可修改模型选择、工具参数、出站消息,甚至完全阻止工具调用 — 无需触碰核心代码。
-
记忆优雅降级。 向量 → FTS → 无。系统在 embedding 失败时不会崩溃。
-
SSRF 防护相当彻底。 DNS 固定防止重绑定。内嵌 IPv4 检测捕获大多数实现都会遗漏的隧道攻击手段。
-
两阶段符号链接检测在业界实属罕见。大多数沙箱只做字符串检查。
realpath阶段捕获真正有威胁的攻击。
本文完成了我们对 OpenClaw 核心流程的深度分析。架构概览请参见原始系列的 第一部分、第二部分、第三部分。
分析基于 OpenClaw v2026.2.18 和 pi-mono v0.53.0。来源:openclaw/openclaw、badlogic/pi-mono。研究来自 cryptocj.org。