📰 来源: 博客园
你让 AI Agent 分析一份 10 万字的文档,等了半小时,网关重启了,进度全丢。
这种事我经历过不止一次。看着那个光秃秃的空白对话框,心里只有一个念头:刚才那半个小时,算谁的?
这是今天大多数 Agent 系统的现实。 它们像是金鱼——每次对话都是全新的人生,上一秒的记忆下一秒就归零。我们管这叫"无状态架构",翻译成人话就是:你的 Agent 根本不会"记得"自己做过什么。
多数开发者听到"会话持久化",第一反应是:"不就是存聊天记录吗?"扔数据库里不就完了。这种理解也不能说错——毕竟如果连聊天记录都存不住,确实谈不上持久化。但 OpenClaw.NET 刚刚合并的 PR #174 告诉我们:远不止于此。
4350 行新增代码,24 个 commits,横跨 38 个文件,2026 年 7 月合并。作者 geffzhang 没有写一个"聊天记录保存"功能——他构建了一整套 AI Agent 生命周期管理基础设施。从热路径缓存到冷路径回水合,从后台持续执行到启动自愈,从检查点系统到 Token 审计账本。
这不是一个功能点,而是 Agent"能做什么"的边界被改写了。 今天,我们拆开这套系统,看看它如何让 Agent 从"一次性问答玩具"变成一个真正长时间运行的协作伙伴。
打开 src/OpenClaw.Core/Models/Session.cs,你会看到一行被反复引用的设计哲学:"Sessions are state, not threads." 翻译过来:会话是状态,不是线程。
这个区别很关键。很多系统把会话当成一个长期挂着的线程——开着占用内存,断了丢失一切。想象一下,每个用户的会话都对应一个持续运行的线程,一千个用户就是一千个线程,内存和 CPU 的消耗可想而知。而且一旦进程重启,线程全部消失,所有状态付之一炬。
OpenClaw.NET 走了另一条路(源码在 src/OpenClaw.Core/Sessions/SessionManager.cs):SessionManager 不拥有任何执行上下文,一个会话本质上只是一行带对话列表和配置覆盖的键值数据。Agent 没在执行的时候,这个会话在内存里只占几百字节,甚至可以完全从内存淘汰出去,安安稳稳躺在 SQLite 里等下一次唤醒。
执行上下文与会话数据的解耦,是整个架构的基石。 没有这一点,后面的双层缓存、后台执行、启动自愈全都无从谈起。
这个设计哲学撑起了整个双层持久化架构。
双层架构:热咖啡与冷藏库
想象一家咖啡店。早上高峰时段,最常用的原料摆在操作台上,伸手就能拿到——这是热路径。不常用的放进冷藏库,需要时再取——这是慢路径。没有一个理性的咖啡师会在操作台上摆满所有库存,也没有一个理性的系统会把所有会话数据常驻内存。
OpenClaw.NET 的 SessionManager 正是这样工作的。
热路径是一层 ConcurrentDictionary<string, Session>,名字叫 _active。新消息来了,先查内存字典,命中直接返回。ConcurrentDictionary 是 .NET 提供的线程安全无锁结构,读操作的时间复杂度是 O(1)。在高并发场景下,绝大多数请求都不需要碰磁盘,这是性能的生命线。
如果没命中——说明这个会话暂时不在内存里——就进入慢路径:从 IMemoryStore(默认 SQLite 实现)加载完整会话数据。这里用了一个经典的双检锁模式(double-checked locking),防止多个并发请求同时加载同一个会话。具体来说是:先检查 _active 字典,未命中后加锁,再次检查字典(防止前面被别的线程填充了),最终才真正从存储加载。加载完成后回写到 _active 字典,后续请求再走热路径。
_capacity 上限到了怎么办?按 LastActiveAt 淘汰最旧的。但这里有一个关键细节:从内存淘汰的会话,完整保留在持久化存储中。 它只是在操作台上被撤下去了,并没有被倒进垃圾桶。下次消息到达时,它会自动从 SQLite "回水合"到内存——这就是"长时间持久"的核心保障。你的 Agent 可能昨天启动的,中间服务器重启过好几次,但只要存储还在,对话就能无缝接续。
这种"用时间换空间、用分层保性能"的思路,说出来不复杂,但能在 Agent 框架里做到这个粒度,PR #174 是第一个。
不过,这套双层架构也不是没有代价。SQLite 在单节点场景下工作得很好,但当你把网关水平扩展到三个、五个实例时,共享的 SQLite 文件会立刻变成瓶颈——并发写入时的文件锁竞争会让请求排队。生产环境大概率需要把 IMemoryStore 换成 PostgreSQL 或 MySQL,或者上读写分离。好消息是接口已经抽象好了(IMemoryStore),替换成本不高。坏消息是,替换之前你需要先意识到这个瓶颈的存在。一个小团队如果只跑单个 Gateway 实例,可能永远不会遇到这个问题;但一旦业务增长、开始扩容,存储层就是第一个要动的地方。
检查点:游戏存档的艺术
长会话面临一个经典难题:如果 Agent 执行到一半挂了,恢复时从哪开始?从头再来太浪费,但随意恢复可能重复执行已经完成的操作——想象一下,一个已经扣款的支付工具被重复调用,后果不敢想。
PR #174 给出的答案是 ExecutionCheckpoint——不是完整运行时快照(那太占空间了),而是工具调用批次完成后的"游戏存档"。
具体来说,当 Agent 完成一批工具调用、拿到结果、准备进入下一步推理时,系统会在这个"接缝处"写入一个检查点。为什么选择这里?因为这是第一个可以安全恢复而不重复执行工具调用的持久点。 工具已经调完了,结果已经回来了,在这个节点存档,恢复时可以直接从推理继续,不需要重新调用外部 API。
检查点 ID 保存在 BackgroundRunMetadata.LastCheckpointId 中,与 SessionRunState 枚举一起,构成了完整的 Agent 生命周期状态机。这个枚举有八个状态:Idle(空闲)、Running(运行中)、Continuing(续跑中)、Paused(已暂停)、Blocked(被阻塞)、BudgetLimited(预算超限)、Completed(已完成)、Failed(失败)。八个状态覆盖了 Agent 生命周期的每一个可能阶段,状态转换由网关统一协调。
说实话,这个设计让我想起了任天堂的存档机制——精确、可靠、永远不会让你在 Boss 战前白打。
但这里有一个值得想的边界:检查点存的是 Agent 的内部状态,不是外部世界的。如果一个检查点记录的是"已调用支付 API",恢复时支付服务端的订单状态可能已经变了——超时关闭了、被退款了、或者因为网络重试实际扣了两次款。PR #174 把检查点做到了工具调用批次粒度,这是正确的取舍,但它不能替代业务层的幂等设计。说白了,检查点保证的是 Agent 自己不会重复干活,但不保证外部世界在你恢复期间没有变化。这个界限用得好很强大,用得模糊就会踩坑。
Token 审计:Agent 的"电费账单"
运营一个 Agent 系统,最怕什么?不是崩溃,崩溃至少你能看见。最怕的是失控——某个会话疯狂循环烧 Token,月底一看账单傻眼了。
PR #174 给每个会话配了一套原子计数器:TotalInputTokens、TotalOutputTokens、TotalCacheReadTokens、TotalCacheWriteTokens。每次推理完成后自动累加,持久化到 JSONL 格式的 Ledger 文件中。
这意味着你可以精确追踪每个 Agent 的"电费账单"——哪个会话烧钱最多,哪个用户最费 Token,一目了然。 更进一步, 🔗 原文链接: 点击阅读原文BackgroundRunMetadata
文章评论