乐于分享
好东西不私藏

从 OpenClaw 源码解析:如何构建一个 Agent

从 OpenClaw 源码解析:如何构建一个 Agent

本文假设你熟悉 TypeScript 和 LLM API 的基本概念。如果你还不了解 Function Calling,建议先阅读 OpenAI Tool Use 文档。

https://platform.openai.com/docs/guides/function-calling

OpenClaw 是一个跑在生产环境里的 AI Agent 框架,代码量不小,但核心就四个模块——执行循环、工具系统、记忆系统、插件系统。这篇文章把每个模块拆开看看里面怎么写的,最后整理一份自己做 Agent 时可以参考的清单。


一、先看全景

开始翻代码之前,先搞清楚 OpenClaw 大概长什么样。一句话说:

Gateway 接收消息 → Agent 循环调用 LLM + 工具 → 记忆系统提供上下文 → 插件扩展一切。

四个模块各管各的,耦合度不高。后面逐个看,先扫一眼目录结构:

目录
一句话说明
src/agents/
Agent 执行循环、工具注册、模型管理
src/memory/
记忆索引、嵌入向量、混合检索
src/gateway/
WebSocket 网关、认证、RPC
src/plugin-sdk/
插件 SDK、Hook 系统
src/channels/
通道抽象层(状态机、路由、线程绑定)
extensions/
73 个插件(通道 / LLM Provider / 工具扩展)

二、Agent 核心循环

很多教程里的 Agent 就是一个 while 循环加一次 LLM 调用。但真跑在线上的 Agent,得处理网络抖动、API 限流、上下文爆掉、工具死循环这些破事。OpenClaw 的核心入口在 src/agents/pi-embedded-runner/run.ts

2.1 主干逻辑

把容错代码全去掉,核心循环其实就这点东西:

LLM 决定要不要调工具,调了就把结果喂回去,来回循环,直到 LLM 觉得可以直接回复了。这个”工具循环”是所有 Agent 框架的共同骨架。

2.2 加上容错

实际跑起来,上面的循环随时可能断。OpenClaw 加了三层防护:

分别是:

  • 上下文压缩compact.ts):对话太长超出模型窗口时,用 Context Engine 对历史做摘要,保留关键信息,不是直接砍掉前面的消息。
  • 模型 Failoverrun.ts):Auth 过期、API 限流、余额不足,自动冷却当前 Auth Profile,换一个继续。整个重试循环上限 160 次,Auth 还能提前 5 分钟自动续期。
  • 工具熔断器tool-loop-detection.ts):用 30 次调用的滑动窗口检测三种异常——同一工具反复调、两个工具乒乓互调、轮询类工具没进展。10 次注入警告提示词,20 次强制提示停下,30 次直接终止。

2.3 流式处理

Agent 不会等 LLM 把整段话说完才动,而是通过事件订阅(pi-embedded-subscribe.ts)边收边处理。核心状态大概长这样:

// src/agents/pi-embedded-subscribe.ts — 简化后的核心状态{  assistantTexts: string[]         // 逐步累积的回复文本  toolMetas: ToolMetaEntry[]       // 每次工具调用的元数据  blockBuffer: string// 流式分块缓冲区  blockState: {    thinking: boolean// 当前是否在 <think> 标签内    final: boolean// 当前是否在 <final> 标签内  }  messagingToolSentTexts: string[] // 已通过消息工具发送的文本(去重用,上限 200 条)}

事件处理器监听 message_start / message_update / message_end / tool_execution_start / tool_execution_end 这些事件,在语义边界处切分文本块推给用户。所以用户不用等 Agent 跑完所有工具调用才看到第一个字。


三、工具系统

LLM 只会生成文本,工具系统让它能读写文件、跑命令、搜网页、发消息——光会说不行,还得能干活。

3.1 工具长什么样

一个工具就是一个 JSON Schema 加一个执行函数,没什么花活:

// src/agents/tools/ 下的典型工具定义{  name: "web_search",              // LLM 看到的工具名  description: "Search the web",   // LLM 据此判断何时调用  parameters: Type.Object({        // JSON Schema,LLM 按此生成参数    query: Type.String()  }),async execute(toolCallId, args, signal) {const results = await search(args.query)return { content: [{ type"text", text: results }] }  }}

src/agents/openclaw-tools.ts 注册了所有内置工具,按能力域分类:

能力域
代表工具
做什么
文件操作
read

writeapply-patch
读写代码和配置文件
命令执行
exec

process
在沙箱中运行 Shell 命令
Web 交互
web_search

browser
搜索引擎查询、浏览器自动化
消息通信
message

sessions_send
向 Discord / Slack 等通道发消息
多媒体
image_generate

tts
图像生成、语音合成
子 Agent
sessions_spawn
派生子 Agent 执行子任务

3.2 安全管线

工具不是 LLM 说调就能调的,每次调用都要过一条管线:

  • 权限检查tool-policy.ts):cron 这种工具只有 Owner 能调,其他人直接拒绝。
  • 策略过滤:根据消息来源和模型 Provider 的兼容性,动态裁剪可用工具列表。
  • 循环检测tool-loop-detection.ts):就是前面说的 30 次滑动窗口熔断器。
  • 沙箱执行exec 类工具跑在隔离沙箱里,改文件、跑系统命令这种操作需要用户点确认。

3.3 动态注册

除了内置工具,插件可以根据运行时状态动态注册:

api.registerTool((ctx: OpenClawPluginToolContext) => {if (!ctx.config.featureEnabled) returnnull// 条件不满足时不注册return {    name: "conditional_tool",    parameters: Type.Object({ query: Type.String() }),async execute(toolCallId, args) { /* ... */ }  }})

工具集可以根据用户配置、通道类型、模型能力等条件灵活调整,不用一股脑全暴露给 LLM。


四、记忆系统

没有记忆的 Agent 每次对话都是从头来。记忆系统让 Agent 能跨会话记住用户偏好、项目背景和之前做过的决定。OpenClaw 的记忆系统是整个项目里最有意思的部分。

4.1 就两件事:存和取

看着简单,但每个环节都有不少细节。

4.2 存:从 Markdown 到向量

数据从哪来

两个来源:

  • memory 源:工作区中的 MEMORY.md 和 memory/*.md 文件,通常由用户或 Agent 主动维护
  • sessions 源:Agent 的历史会话记录(JSONL 格式),自动采集

索引管线

文件发现后,经过变更检测、分块、嵌入、写入四个阶段:

几个值得注意的点:

  • 增量索引:用 SHA256 哈希检测文件有没有变,只处理改过的文件,不用每次全量重建。
  • 嵌入缓存embedding_cache 表按内容哈希去重,同样的文本不会重复调嵌入 API。缓存满了自动清理旧的。
  • Batch 处理:支持 OpenAI / Gemini / Voyage 的 Batch API 批量生成向量。Batch 连续挂 2 次就自动退回逐条调用,不影响整体可用性。

存在哪:SQLite Schema

每个 Agent 有自己的 SQLite 数据库:~/.openclaw/memory/{agentId}.sqlite。为什么用 SQLite 不用 Pinecone / Milvus?很简单——不需要额外装数据库服务,一个文件就是全部记忆,拷到另一台机器直接能用。sqlite-vec 扩展的向量搜索性能对这个场景完全够。

核心表结构(省略了部分字段):

-- 分块存储:文本 + 嵌入向量CREATETABLE chunks (idTEXT PRIMARY KEY,        -- SHA256(source:path:line:hash:model)pathTEXTNOTNULL,         -- 源文件路径sourceTEXTNOTNULL,       -- 'memory' | 'sessions'  start_line INTEGERNOTNULL,  end_line INTEGERNOTNULL,textTEXTNOTNULL,         -- 分块原文  embedding TEXTNOTNULL,    -- 向量,JSON float 数组  updated_at INTEGERNOTNULL);-- 向量索引:sqlite-vec 虚拟表CREATEVIRTUALTABLE chunks_vec USING vec0(idTEXT PRIMARY KEY,  embedding FLOAT[N]          -- N = 嵌入维度(如 OpenAI 为 1536));-- 全文索引:FTS5CREATEVIRTUALTABLE chunks_fts USING fts5(textid UNINDEXED, ...);-- 嵌入缓存:按内容哈希去重CREATETABLE embedding_cache (  provider TEXTmodelTEXT, provider_key TEXThashTEXT,  embedding TEXT, dims INTEGER, updated_at INTEGER,  PRIMARY KEY (provider, model, provider_key, hash));

三张表分工明确:chunks 存原始数据,chunks_vec 做向量近邻搜索,chunks_fts 做关键词全文检索。

4.3 取:混合检索

纯向量搜索擅长语义匹配(”怎么部署”能匹配到”上线流程”),但会漏掉精确关键词;纯全文搜索反过来。OpenClaw 把两者拼在一起,再加上时间衰减和去重排序,形成四步检索管线:

每一步在干什么:

Step 1 — 向量搜索:通过 sqlite-vec 计算查询向量与每个 chunk 向量的余弦相似度,返回 [0, 1] 范围的得分。

Step 2 — 全文搜索:FTS5 返回 BM25 排名(负数),归一化为 [0, 1]:

Step 3 — 加权合并:语义匹配权重给高一点,因为多数查询是模糊意图不是精确关键词:

Step 4 — 后处理

  • 时间衰减temporal-decay.ts):λ,半衰期 30 天。带日期的文件(比如 memory/2026-03-15.md)会随时间降权,MEMORY.md 这种常驻文件不受影响。
  • MMR 重排mmr.ts):λλ。用 Jaccard 相似度衡量候选结果之间的重复度,避免返回一堆内容差不多的片段。

最终返回的结果结构:

type MemorySearchResult = {  path: string;       // 来源文件  startLine: number;  // 分块起始行  endLine: number;    // 分块结束行  score: number;      // 综合得分 [0, 1]  snippet: string;    // 截断到 700 字符的摘要  source: "memory" | "sessions";};

4.4 嵌入提供商

支持 6 种嵌入提供商。auto 模式按优先级依次试,Ollama 得手动指定:

优先级
Provider
类型
Batch API
默认模型
1
Local
本地
embeddinggemma-300m
2
OpenAI
远程
text-embedding-3-small
3
Gemini
远程
text-embedding-004
4
Voyage
远程
voyage-3
5
Mistral
远程
mistral-embed
手动
Ollama
本地
nomic-embed-text

4.5 降级策略

记忆系统最实用的一点:不要求所有组件都正常,能用多少用多少。

没有嵌入 API Key,关键词搜索照样能用;sqlite-vec 加载失败,FTS5 兜底。比起”缺一个组件就整个报错”,这种做法靠谱得多。

4.6 实时同步

记忆不是建好就完事了,文件在改、新对话在产生,索引得跟上:

  • 文件监听:Watch MEMORY.md 和 memory/**/*.md,变更后防抖 1.5s 触发重新索引
  • 会话监听:订阅 Session Transcript 更新事件,防抖 5s 后增量索引新内容
  • 同步时机:搜索前自动同步(确保结果最新)、会话开始时同步、可配置定时同步
  • 并发控制:4 路并行索引处理,Session 写锁防止并发冲突

五、插件系统

OpenClaw 的 73 个扩展——Discord 通道、Anthropic Provider 这些——全走统一的插件 SDK,没有硬编码的特殊通道。

5.1 插件生命周期

  • 发现:从 bundled(内置)、global(全局安装)、workspace(工作区)三个位置扫描插件
  • 加载:通过 jiti 动态导入插件模块,支持 TypeScript 直接加载
  • 注册:插件调用 SDK API 注册工具、Hook、CLI 命令和后台服务
  • 激活:所有插件注册完毕后,统一激活插件注册表(缓存上限 128 条)

5.2 两种插件形态

通用插件——注册工具和 Hook:

definePluginEntry({  id: "my-plugin",  name: "My Plugin",  register(api) {    api.registerTool({ name: "my_tool", ... })    api.on("before_agent_start"async () => {return { prependContext: "注入额外上下文" }    })  }})

通道插件——接入新的消息通道:

defineChannelPluginEntry({  id: "discord",  plugin: discordPlugin,         // 实现 ChannelPlugin 接口  setRuntime: setDiscordRuntime, // 接收运行时能力  registerFull: registerHooks    // 注册通道特有的 Hook 和工具})

5.3 25 个 Hook

插件可以在 Agent 生命周期的 25 个节点插入自己的逻辑。不用 fork 核心代码就能改 Agent 的行为:

阶段
代表 Hook
典型用途
模型选择
before_model_resolve

before_prompt_build
覆盖默认模型、注入系统上下文
LLM 交互
llm_input

llm_output
审计日志、内容过滤、token 统计
工具调用
before_tool_call

after_tool_calltool_result_persist
调用拦截、结果持久化
消息处理
message_received

message_sendingmessage_sent
消息预处理、格式转换、发送确认
会话管理
session_start

session_endbefore_compaction
初始化、清理、压缩前干预
子 Agent
subagent_spawning

subagent_ended
子任务分发策略、结果汇总
网关
gateway_start

gateway_stop
服务启停时的资源管理

六、Gateway

Gateway 是 Agent 和外面世界之间的中间层。不管消息从 Discord、Slack、Telegram 还是 Web UI 来,都统一走 Gateway 路由到 Agent。

6.1 结构

6.2 一条消息的旅程

比如用户在 Discord 里发了一句话,到 Agent 回复,中间经过这些环节:

其中会话状态机src/channels/run-state-machine.ts)比较关键。每个会话有自己的状态:idle → running → drafting → completed,保证同一个会话不会被并发请求搞乱。通道健康监控(channel-health-monitor.ts)一直在检测各通道连接状态,断了自动重连。


七、做 Agent 的路线图

前面六章看完了 OpenClaw 怎么做的,这章整理一下:如果自己从零开始做一个 Agent,按什么顺序推进比较合理。

7.1 先搭核心循环

这是 Agent 的骨架,最小可用版本。需要三个东西:

System Prompt 构建器:别把 System Prompt 写死。OpenClaw 在 system-prompt.ts 里动态拼装——运行时信息(OS、时区、模型名)、可用工具列表、通道能力描述、用户自定义指令,按需注入。同一个 Agent 在不同环境下表现才能一致。

LLM 调用层:一定要用流式(streaming)不要用阻塞调用。OpenClaw 通过事件订阅(pi-embedded-subscribe.ts)实时处理 text_delta 和 tool_call 事件,用户能马上看到输出,不用干等。

工具循环:LLM 返回 tool_use 就执行对应工具,把结果作为 tool_result 喂回去,直到 LLM 不再请求工具。这个循环所有 Agent 框架都一样。

7.2 加容错

裸循环跑 demo 没问题,上线必须加容错。按重要程度排:

容错机制
解决什么问题
OpenClaw 的实现
上下文压缩
对话太长,超出模型窗口
compact.ts

 调用 Context Engine 做摘要压缩
Auth Failover
API Key 过期、限流、计费失败
run.ts

 自动切换备用 Auth Profile / 备用模型
工具熔断器
Agent 陷入工具调用死循环
tool-loop-detection.ts

 滑动窗口 + 三级熔断
流式去重
消息工具和直接回复内容重复
pi-embedded-subscribe.ts

 跟踪已发送文本

上下文压缩是最容易被忽略但最要命的。没有它,长对话必崩。OpenClaw 的做法是溢出时用 Context Engine 对历史做摘要,保留关键信息后重试,不是简单砍掉前面的消息。

7.3 做记忆

这是让 Agent 从”一次性对话”变成”持续助手”的关键一步。OpenClaw 的方案可以直接抄:

最小实现

  1. 选个嵌入模型(text-embedding-3-small 便宜够用)
  2. SQLite + sqlite-vec 存向量(不用搭 Milvus,省事)
  3. 加 FTS5 全文索引兜底
  4. 混合检索:0.7 × 向量得分 + 0.3 × BM25 得分

进阶

  • 增量索引(SHA256 变更检测),不用每次全量重建
  • 嵌入缓存(按内容哈希去重),少调几次 API
  • 时间衰减让旧记忆自然淡出
  • MMR 重排保证结果不重复
  • 降级策略:嵌入挂了用 FTS,FTS 也挂了就不检索,但 Agent 本身不能挂

为什么不用向量数据库服务? 单用户或小团队的 Agent,SQLite 够了。一个 .sqlite 文件就是全部记忆,拷到另一台机器就能跑。等真需要多租户、分布式检索的时候再迁移不迟。

7.4 设计工具安全策略

Agent 能调工具就意味着能产生副作用——写文件、发消息、跑命令。安全策略不是可选的。

OpenClaw 的分层做法可以参考:

一句话:只读的放行,有副作用的隔离并确认。再加上循环检测熔断器兜底就行。

7.5 开放扩展

Agent 要支持新通道、新工具、新 Provider 的时候,不该每次都改核心代码。OpenClaw 的插件系统可以参考:

  • 定义 Hook 点:在核心流程的关键位置(模型选择、Prompt 构建、工具调用前后、消息收发)暴露 Hook,让插件能插入逻辑。
  • 统一注册 APIregisterTool()registerHook()registerService() 三个方法覆盖大部分扩展场景。
  • 运行时能力注入:通过 PluginRuntime 把核心能力(配置读写、媒体处理、子 Agent 管理)暴露给插件,不让插件直接碰内部模块。

不用一开始就做 25 个 Hook。先从几个高频的开始:before_prompt_build(注入上下文)、before_tool_call / after_tool_call(工具拦截)、message_received(消息预处理)。后面按需加。

7.6 总结成表

上面的路线图压缩一下:

阶段
要做的事
OpenClaw 对应实现
优先级
骨架
工具循环 + 流式输出
attempt.ts

 + pi-embedded-subscribe.ts
P0
骨架
动态 System Prompt
system-prompt.ts
P0
容错
上下文压缩
compact.ts
P0
容错
Auth / 模型 Failover
run.ts

 重试循环
P1
容错
工具循环熔断
tool-loop-detection.ts
P1
记忆
SQLite + 向量 + FTS
memory/manager.ts

 + sqlite-vec.ts
P1
记忆
混合检索 + 降级
hybrid.ts

 + embeddings.ts
P1
安全
工具权限 + 沙箱
tool-policy.ts

 + bash-tools.exec.ts
P1
扩展
插件 SDK + Hook
plugin-sdk/core.ts
P2
扩展
多通道接入
gateway/server.ts

 + channels/
P2

P0 是最小可用版本要有的,P1 是上线前得补的,P2 是用户量上来之后再考虑的。


附录:核心文件速查

你想了解…
去看这个文件
Agent 主循环
src/agents/pi-embedded-runner/run.ts
单次 LLM 调用 + 工具循环
src/agents/pi-embedded-runner/run/attempt.ts
流式事件处理
src/agents/pi-embedded-subscribe.ts
System Prompt 构建
src/agents/pi-embedded-runner/system-prompt.ts
上下文压缩
src/agents/pi-embedded-runner/compact.ts
工具注册
src/agents/openclaw-tools.ts
工具安全策略
src/agents/tool-policy.ts
工具循环检测
src/agents/tool-loop-detection.ts
记忆管理器
src/memory/manager.ts
混合检索算法
src/memory/hybrid.ts
向量存储
src/memory/sqlite-vec.ts
嵌入提供商工厂
src/memory/embeddings.ts
时间衰减
src/memory/temporal-decay.ts
MMR 重排
src/memory/mmr.ts
插件 SDK
src/plugin-sdk/core.ts
Gateway 服务器
src/gateway/server.ts
消息路由
src/routing/resolve-route.ts
会话状态机
src/channels/run-state-machine.ts
本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 从 OpenClaw 源码解析:如何构建一个 Agent

猜你喜欢

  • 暂无文章