学完 claw0 后写的笔记。claw0 是 OpenClaw 的教学版,用十讲代码带你从零搭建一个完整的 AI Agent 网关。
这篇笔记不是逐章总结,而是用第一性原理的方式拆解每一讲:为什么需要这个机制?它解决了什么问题?又带来了什么新问题?
如果你同时在学 Claude Code 的内部架构,可以对照这篇笔记一起看——两个系统很多设计是相通的:Claude Code 架构学习笔记。
十讲的层叠关系
整个课程的结构很清晰:每一讲都在前一讲的基础上加一层,核心循环从始至终没有改变。
问题 解法
─────────────────────────────────────────────
怎么让 LLM 持续对话? 01讲:while True + stop_reason
怎么让 LLM 执行外部操作? 02讲:TOOLS + TOOL_HANDLERS 分发表
对话历史怎么持久化/不爆炸? 03讲:JSONL 持久化 + ContextGuard
怎么接入多个平台? 04讲:Channel 抽象 + InboundMessage
一个 bot 怎么服务多个场景? 05讲:BindingTable 路由
怎么给 Agent 注入性格和记忆? 06讲:8层系统提示词 + 记忆搜索
怎么让 Agent 主动发起行为? 07讲:Heartbeat + Cron
消息发送失败怎么办? 08讲:DeliveryQueue + 指数退避
API 挂了怎么办? 09讲:三层重试洋葱 + Key 轮换
多个任务并发怎么不乱? 10讲:命名 Lane + Generation 计数器01讲:Agent 循环
核心认知: LLM 不执行任何操作,它只返回"意图",你的代码负责执行。
while True:
用户输入 → messages.append
→ LLM API
→ stop_reason == "end_turn" → 打印,继续等待
→ stop_reason == "tool_use" → 执行工具,结果还给 LLMmessages[] 是唯一的状态,每次 API 调用都带上完整数组,LLM 才能"记住"上下文。
这带来的问题: messages[] 会无限增长,最终超出上下文窗口。这是第03讲 ContextGuard 存在的根本原因。
02讲:工具使用
核心认知: 工具 = schema(告诉 LLM)+ 分发表(告诉代码)
TOOLS = [{"name": "bash", "description": "...", "input_schema": {...}}]
TOOL_HANDLERS = {"bash": tool_bash, "read_file": tool_read_file}LLM 只返回一个 JSON 说"我要调用 bash,参数是 ls",实际执行是你的代码查表完成的。
新增工具只需两处各加一行,循环本身不动——这是 Dispatch Map 模式的优雅之处。
值得注意: 工具执行错误不要抛异常,而是返回错误字符串。这样 LLM 能看到错误并自行修正。
03讲:会话与上下文保护
核心问题: 对话历史会越来越长,进程重启会丢失,上下文会爆炸。
JSONL 持久化
每个会话是一个 .jsonl 文件,每行一条记录,追加写入:
{"type": "user", "content": "..."}
{"type": "assistant", "content": [...]}
{"type": "tool_use", "name": "bash", "input": {...}}
{"type": "tool_result", "content": "..."}恢复时重放这些记录,重建为 API 格式。
三阶段保护(ContextGuard)
Attempt 0: 正常调用
↓ 溢出
Attempt 1: 裁剪过大的 tool_result
↓ 溢出
Attempt 2: 用 LLM 生成摘要替换旧消息
↓ 溢出
raise 异常代价: 压缩会不可逆地丢失细节。这就是为什么重要信息要主动存到 MEMORY.md(06讲),而不是依赖对话历史。
04讲:通道抽象
核心思路: 不同平台格式各异,但 Agent 逻辑只看统一的 InboundMessage。
两种接入方式:
- 长轮询(Telegram):你的代码主动每 30s 问一次"有没有新消息"
- Webhook(飞书):平台主动推送到你的服务器
无论哪种,最终都归一化成同一个数据结构:
@dataclass
class InboundMessage:
text: str
sender_id: str
channel: str # "telegram" / "feishu" / "cli"
peer_id: str # 会话范围
media: list消息合并:两种场景,同一思路
等一个短暂的时间窗口,把属于"同一次表达"的碎片合并成一条。
| 判断依据 | 等待时间 | |
|---|---|---|
| 媒体组合并 | 相同的 media_group_id | 500ms |
| 文本合并 | 同一用户无新消息 | 1s 静默 |
Telegram 发一组图片时会拆成多条消息,必须等 500ms 攒齐再合并,否则 Agent 会对同一组图片响应多次。
05讲:网关与路由
核心问题: 一个 bot 收到消息,怎么决定交给哪个 Agent 处理?
五层路由,从最具体到最宽泛:
Tier 1: peer_id → 特定用户(优先级最高)
Tier 2: guild_id → 特定群组
Tier 3: account_id → 特定 bot 账号
Tier 4: channel → 整个平台
Tier 5: default → 兜底(优先级最低)本地单人使用时,只有 Tier 5 兜底规则,路由层形同透明。真正要用到多 Agent,需要公网 IP + 在绑定表里配置路由规则。
06讲:智能层
核心问题: 怎么给 Agent 注入稳定的性格,同时让它了解"这个具体的用户"?
两个时机,两件事
启动时(只跑一次,结果缓存): 加载 SOUL.md、IDENTITY.md、所有 SKILL.md → 这就是为什么安装了新 skill 需要重启
每轮(动态构建): _auto_recall() 搜索最相关记忆 → build_system_prompt() 拼 8 层
8 层系统提示词
Layer 1: IDENTITY.md 身份
Layer 2: SOUL.md 性格(靠前 = 影响强)
Layer 3: TOOLS.md 工具指南
Layer 4: 技能块
Layer 5: 记忆(常驻 + 本轮召回)
Layer 6: 引导上下文
Layer 7: 运行时上下文
Layer 8: 通道提示个性化 = 性格 + 记忆
记忆注入不是加载所有记忆,而是每轮只注入当前最相关的片段——用 TF-IDF 搜索,加时间衰减(越近的记忆权重越高),加 MMR 去重。纯 Python 实现,不需要外部向量数据库。
与 03 讲的关系: 系统提示词越大,上下文压力越大,ContextGuard 越重要。
07讲:心跳与 Cron
核心问题: Agent 目前只能被动响应,怎么让它主动发起行为?
心跳 vs Cron
| 心跳 | Cron | |
|---|---|---|
| 触发频率 | 高频持续 | 低频精确 |
| 目的 | Agent 自检"有没有事做" | 执行预定任务 |
| 决策者 | LLM 自己判断 | 时间到了就执行 |
| 类比 | 每隔一会儿看手机 | 设了一个闹钟 |
心跳的核心价值:把条件判断的决策权交给 LLM,不需要硬编码 if 超过30分钟 then 提醒,只需在 HEARTBEAT.md 里用自然语言描述,LLM 自己决定。
Lane 互斥(用户永远优先)
# 用户线程:阻塞获取,永远能进
lane_lock.acquire()
# 心跳线程:非阻塞获取,抢不到就跳过
acquired = lane_lock.acquire(blocking=False)
if not acquired:
return08讲:消息投递
核心问题: 消息发送可能失败,进程可能崩溃,怎么保证消息最终能送达?
消息发送有两种失败,需要分别应对:
网络抖动(发了但没成功)
→ 指数退避重试:[5s, 25s, 2min, 10min] ± 20% 抖动
→ ±20% 随机抖动是为了打散重试时间,防止多条消息同时重试把 API 再次打挂(雷群效应)
进程崩溃(还没发就挂了) → 预写磁盘:消息入队时先写文件,发成功了才删文件
写入 .tmp.{pid}.{id}.json → 崩溃 = 孤立临时文件,无害
os.fsync() → 数据已落盘
os.replace() → 原子交换,绝不产生半写文件这在工程上叫 at-least-once delivery(至少投递一次)。
类比:就像餐厅把订单写在纸上再通知厨房。服务员摔倒了,纸上的订单还在,换个人捡起来继续执行。
09讲:弹性
核心问题: API Key 限流、认证失败、上下文溢出,各种错误怎么自动恢复?
与 08 讲的区别:
- 08讲:消息投递的可靠性(回复怎么送达用户)
- 09讲:API 调用的可靠性(LLM 调用失败怎么恢复)
三层重试洋葱
Layer 1: 遍历所有 API Key,跳过冷却中的
Layer 2: 上下文溢出 → 压缩历史后重试
Layer 3: 标准工具调用循环出了问题先分类,再按类型路由到对应层处理。 这个思维方式比具体代码更重要:
| 错误类型 | 处理方式 | 冷却时长 |
|---|---|---|
| auth / billing | 换 Key | 300s |
| rate_limit | 换 Key | 120s |
| timeout | 换 Key | 60s |
| overflow | 压缩历史,原地重试 | 不冷却 |
10讲:并发
核心问题: 用户输入、心跳、Cron 同时触发,怎么不互相干扰?
命名 Lane
每种任务进入独立的 FIFO 队列(Lane),Lane 内串行,Lane 间并行:
main [用户消息处理中...]
cron [定时任务处理中...]
heartbeat [心跳检查中...]为什么 Lane 之间并行不会冲突? 因为每条 Lane 有自己独立的 messages[],根本不存在共享状态。冲突只会发生在共享状态上——07 讲需要锁是因为当时只有一个全局 messages[]。
这是对 07 讲锁机制的升级:
| 07讲(Lock) | 10讲(Lane) | |
|---|---|---|
| 模型 | 同一时刻只有一个任务 | Lane 之间并行 |
| 扩展性 | 任务类型多了难管理 | 直接新增 Lane |
Generation 计数器(防僵尸任务)
重启时所有 Lane 的 generation 递增。旧任务完成时 generation 不匹配,静默丢弃,不再泵送队列——防止过期状态污染新的运行。
总结
第01讲的 while True 循环在第10讲的核心依然清晰可辨。每一讲都是在这个循环外面加一层,而不是替换它。
AI Agent 的本质: 一个 while True 循环,加一张工具分发表,外面包裹着持久化、路由、智能、调度、可靠性和并发控制的层层机制。
对应 Claude Code 的架构可以对照这篇文章:Claude Code 架构学习笔记——你会发现两个系统在工具分发、上下文压缩、技能加载等设计上几乎是同构的。