你让 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 里还有一个 TokenBudget 字段,给后台运行设置了预算上限。超支了自动进入 BudgetLimited 状态,Agent 停下来等你处理,而不是继续烧钱。
在 LLM API 按 Token 计费的时代,这不是锦上添花,而是运营成本控制的刚需。
(别问我怎么知道月底账单有多刺激的。)
杀手特性一:后台持续执行
好了,基础架构搭完了。真正让这套系统飞起来的是三个杀手级特性。
第一个特性回答了一个问题:Agent 一个任务要执行 50 步,但单次请求只能处理 20 步,怎么办?传统的做法要么硬撑到底(超时风险),要么直接放弃(用户体验灾难)。
PR #174 的方案堪称优雅(核心实现在 src/OpenClaw.Agent/AgentRuntime.cs)。当一次 turn 达到 MaxIterationsPerBatch(默认 20)时,RunTurnAsync 不会硬撑下去,而是干净利落地返回一个 AgentTurnResult,其中 BatchLimitReached 标志位设为 true,ShouldContinue 设为 true。这就好比一个勤劳的工人到了下班点,跟你说:"活还没干完,但我按时交班,明天继续。"
注意这个 API 设计的变化------RunTurnAsync 取代 RunAsync,返回 AgentTurnResult 而非 string。这个签名变化本身就是一个信号:Agent 不再只是"回一句话",而是"完成一段可追踪、可恢复、可审计的工作"。AgentTurnStopReason 枚举告诉调用方为什么停下来:是完成了?卡死了?还是批次到了需要续跑?
Gateway 首次检测到需要续跑时,会创建一个 BackgroundRunMetadata 对象。
这里面信息量不小。先看标识:RunId 标识这次后台运行,Objective 记录原始目标------有了这两个,你永远不会搞不清楚"这个后台任务到底是为了什么而跑的"。再看时间线:StartedAtUtc 和 LastContinuedAtUtc 精确追踪何时启动、何时续跑,ContinuationCount 和 ContinuationSequence 维护执行顺序。这些字段合在一起,构成了一条完整的审计链。
最让我拍案的是 ConsecutiveNoProgressCount。连续多轮没有实质进展时它会自动递增,达到阈值就判定卡死,停止空转。说白了,这是一个"防傻跑"保险。Agent 不会因为一个无解的目标而永远循环下去------它会自己判断"这事好像搞不定了",然后停下来等你。
Gateway 检测到需要续跑后,会写入一条 background_auto_continue 系统消息,通过 MessagePipeline.InboundWriter 重新入队。这里的设计很干净:没有旁路,没有特例线程池。续跑产生的 InboundMessage 跟用户发的消息长得一模一样,走完全一样的路由逻辑。
InboundMessage 新增的两个字段 BackgroundRunId 和 BackgroundContinuationSequence 让续跑有了完整的追踪能力。出了问题,你可以精确定位是哪一次续跑、第几个批次出的错。
MaxConcurrentBackgroundTurns 默认设为 3,与用户请求并发隔离,由 BackgroundExecutionLimiter 独立控制。前台聊天不卡,后台任务不停。 你可以一边跟 Agent 闲聊,一边让它在后台编译那个大型项目------两边互不干扰。
最妙的是:WebSocket 断开了也不会取消后台任务。你关闭浏览器去吃饭,Agent 在服务器上继续"修仙"。回来重连,进度还在,检查点还在,一切接续如初。如果有活跃的 Goal,续跑提示还会自动包含 Goal 特定指令------Agent 知道自己为什么要继续,不会跑偏。
杀手特性二:启动自愈
后台执行很好,但如果网关本身宕机了呢?服务器重启、容器重新调度、OOM 被杀------生产环境里这些都是家常便饭。
这就是第二个杀手特性要解决的问题。
BackgroundSessionRecoveryWorker(位于 src/OpenClaw.Gateway/Background/BackgroundSessionRecoveryWorker.cs)在网关启动时自动执行。它的任务很明确:找出所有"失联"的后台任务,然后逐个恢复。
具体怎么找?它向 IMemoryStore 发出一条精准查询:WHERE RunState IN (Running, Continuing) AND Goal IS ACTIVE。翻译成人话:找出所有正在运行或正在续跑、且关联的 Goal 仍然活跃的会话。那些 Goal 已经被用户取消的、状态已经是 Completed 或 Failed 的,不会被恢复------这是正确的,没必要 resurrect 一个已经死透的任务。
每一个符合条件的会话,都被逐个入队一条 background_auto_resume 系统消息。
这里有一个关键设计:不是粗暴地全部同时启动。想象一下,网关刚恢复,状态还没完全 warm up,几十个后台任务一股脑涌上来------刚活过来的网关可能直接被压垮。所以系统设了两个保护参数。AutoResumeStaggerSeconds 默认 5 秒错峰启动,给网关喘息的时间;AutoResumeMaxConcurrent 默认 3 个并发上限,控制恢复节奏。AutoResumeOnStartup 默认开启,你也可以关掉它------如果你不信任自动恢复,或者想手动排查问题。
这意味着什么?网关宕机了,重启后 Agent 自动恢复------不是"从头再来",而是从最近一个检查点继续。 那个编译了一半的项目,会在网关恢复后继续 build;那份写到第三章的报告,会从断点处接着写。用户甚至不会感知到中间发生过重启。
说真的,这个设计让我想起了 Linux 的 systemd------服务挂了自动重启,状态保持连续。只不过这里保持连续的不是进程,而是一个 AI Agent 的"数字生命"。
杀手特性三:统一消息管道
第三个特性可能不如前两个 flashy,但从架构角度看,它是最重要的一环------因为前两个特性之所以能做到简洁优雅,全靠它的支撑。
在 PR #174 中,background_auto_continue、background_auto_resume 与用户消息、sessions_spawn 等等------这些看起来完全不同的消息来源------全部产生相同的 InboundMessage 形状。
没有旁路,没有特例线程池。所有消息------无论来源------全部走 MessagePipeline.InboundWriter。路由、并发控制、持久化,全是同一套。
这个统一的威力在于工程上的简洁。想象如果后台任务走了单独的逻辑分支------你需要维护两套并发控制、两套持久化、两套错误处理。代码量翻倍不说,出 bug 的概率也翻倍。而且每新增一种消息来源,都要再复制一份逻辑。OpenClaw.NET 选择了一条更干净的路:无论消息从哪来,进门先统一成同一种形状,剩下的逻辑全部复用。
这就是好架构的标志------不是做了多少功能,而是少做了多少重复代码。统一的消息管道让后台任务拥有了与用户请求完全一致的可靠性保障:同样的持久化、同样的错误处理、同样的可观测性。你不需要为后台任务单独写监控,因为它们走的完全是同一条路。
这改变了什么?
对开发者来说
Agent 从"一次性问答"变成了"长时间运行的协作伙伴"。你可以让它持续监控某个数据源,逐步完成一份复杂报告,甚至帮你编译一个大型项目------过程中你可以随时离开,回来继续。
具体来说:以前你要让 Agent 分析一个大型代码库,只能守在屏幕前等着。超时了?重来。网关断了?重来。每一步都要人工确认,生怕它走偏了。现在呢?你把任务扔给 Agent,关掉浏览器去吃饭。两小时后回来,Agent 已经执行到第 47 步,检查点自动保存了 6 个,Token 消耗精确记录在案。中间网关重启过?没关系,自动恢复了。Token 超预算了?Agent 自己停下来等你处理。以前你不敢交给 Agent 的长任务,现在可以放心扔给它,明天来看结果。
这不只是"更方便"------这是完全不同的使用模式。Agent 从"我盯着它干活"变成了"我布置任务,它自己执行"。
对架构师来说
"会话是状态"的设计哲学------无状态网关 + 有状态会话存储------这是云原生 Agent 系统的正确打开方式。
网关可以水平扩展,会话跟着存储走。想扩容?加 Gateway 实例就行,会话数据在 SQLite(或者你替换的 PostgreSQL/MySQL 实现)里纹丝不动。网关挂了?换一个实例启动,BackgroundSessionRecoveryWorker 自动扫描存储、恢复所有后台任务。会话不绑定到任何特定网关进程,存储在哪,会话就在哪。
这套架构天然适配 Kubernetes 的 Pod 漂移和弹性伸缩。K8s 杀掉一个 Pod 再调度一个新 Pod,对 Agent 来说只是网关换了个实例------会话状态在持久化存储里安然无恙,新 Pod 启动后自动恢复所有后台任务。Horizontal Pod Autoscaler 根据负载自动增减 Gateway 实例数,会话不会丢,后台任务不会断。你甚至可以在 CI/CD 流水线里优雅地滚动更新 Gateway------老 Pod 退出,新 Pod 接管,Agent 的数字生命在存储层持续存在,完全感知不到网关已经换了一代人。
StatefulSet?不需要。粘性会话?不需要。复杂的分布式协调?也不需要。存储层就是你唯一的"状态锚点"。
对未来而言
长持久会话是 Agent "数字生命"的第一步。今天的断点续传,明天可能就是 Agent 的数字记忆与身份认同------一个持续存在、有历史、有上下文、能自愈的数字实体。
Agent 学会了"记住",就有了连续性。学会了"自愈",就有了韧性。你可以关闭对话框,但它不会"死亡";迁移网关,它的记忆完整跟随。
这离"通用人工智能"还有十万八千里。但一个能记住你在做什么、失败了能自己恢复、能让你放心离开的系统------不就是我们想要的"可靠工具"吗?PR #174 没解决 AI 的所有问题,它解决的是更务实的那一个:让 Agent 从"玩具"变成"工具"。
听起来像科幻?代码已经合并到 main 分支了。
这不是科幻------这是 PR #174,4350 行代码,24 个 commits,38 个文件,已合并,已可用。
PR #174: https://github.com/clawdotnet/openclaw.net/pull/174
长持久会话文档: https://github.com/clawdotnet/openclaw.net/blob/main/docs/zh-CN/LONG_LIVED_PERSISTENT_SESSIONS.md