Chapter 21: Dream 系统:会「睡觉」的 Agent
Chapter 17 已经拆解了 Dream 的触发门控、四阶段流程和失败回滚机制。本章不重复那些内容,而是从不同视角审视同一个系统——把 Dream 视为一种认知架构模式,探讨它的工程骨架、资源管理、可观测性,以及这种模式能推广到哪里。
如果 Chapter 17 回答的是「Dream 做了什么」,本章回答的是「Dream 为什么这样做,以及这种做法能用在哪里」。
关键模块:自动 Dream 服务、整合提示词模块、整合锁模块、Dream 任务管理器、fork 子 Agent 工具。
Session Ends
│
┌────▼────────────────────────┐
│ Gate Check (low cost) │
│ time > 24h? sessions > 5? │
│ │ │
│ Lock Acquire │
│ │ │
│ ★ Dream Agent (fork) ★ │ ◄── 本章聚焦
│ ┌────────────────────────┐ │
│ │ Orient -> Gather -> │ │
│ │ Consolidate -> Prune │ │
│ │ (restricted tools) │ │
│ └────────────┬───────────┘ │
│ success/fail │
│ │ │
│ Update / Rollback lock │
└────────────────────────────┘
21.1 一种被忽视的架构模式
问题
多数 Agent 框架把精力花在「对话中」的智能上——更好的提示词、更聪明的工具选择、更精确的推理。但一个长期运行的 Agent 面临的真正瓶颈往往不在对话中,而在对话之间:知识碎片化、上下文膨胀、冗余累积。
这些问题有一个共同特征:它们不需要用户在场就能解决,且解决它们的最佳时机恰恰是用户不在场的时候——因为后台处理不会打断工作流,不会争夺上下文窗口,不会给用户增加等待焦虑。
思路
神经科学给了一个现成的隐喻。人类的记忆巩固不发生在清醒的学习时刻,而发生在睡眠的 REM 阶段。海马体在白天快速编码经历,夜间将其「回放」给新皮层,完成从短期到长期的转化。这个过程有几个关键特征:
- 异步的——不占用清醒时的认知资源
- 选择性的——不是录像回放,而是提炼和重组
- 自动触发的——不需要意识参与,累积到阈值就启动
- 容错的——一晚没睡好不会丢失记忆,下次补回来
该系统的 Dream 机制复刻了这四个特征。但它真正有趣的地方不在于隐喻的精妙,而在于它作为一种通用的后台认知模式的工程实现。这种模式的核心是:fork 一个受限的子 Agent,在后台执行反思性任务,通过 Task 系统向前台报告进度,失败时干净回滚。
理解了这个骨架,你就能把它套用到记忆整合之外的很多场景。
21.2 后台 Fork:代价与收益的精确平衡
问题
为什么不在主对话循环里做记忆整合?用户发完消息,Agent 回复之前先花 30 秒整理一下记忆,不行吗?
不行。原因有三:第一,30 秒的沉默会让用户以为系统卡死了。第二,整合过程本身需要多轮 LLM 调用(浏览目录、读文件、写文件),这些调用的 token 会污染主对话的上下文。第三,整合失败不应该阻塞用户的正常工作。
所以 Dream 必须在一个隔离的执行环境中运行。但隔离不是免费的——它带来了资源管理、状态同步和进度可见性三个工程问题。
思路
fork 子 Agent 运行函数是 Dream 的执行引擎。理解它的设计需要关注三个维度:
维度一:隔离什么? 子 Agent 上下文创建函数的答案是「默认隔离一切,显式共享极少数」。文件状态缓存被克隆,UI 回调被置空,状态变更回调默认是空函数。子 Agent 看不到父 Agent 的 UI,改不了父 Agent 的状态,摸不到父 Agent 的文件缓存。
这像什么?像 Unix 的 fork()——子进程继承父进程的内存快照,但之后各自独立。只不过这里 fork 的不是进程,而是 Agent 的认知上下文。
维度二:共享什么? 只有一样东西被刻意共享:prompt cache。这是 Dream 设计中最精妙的成本优化。子 Agent 继承父 Agent 的缓存安全参数,包含系统提示、用户上下文、工具定义——这些在父子之间完全相同。因此子 Agent 的第一次 API 调用就能命中父 Agent 已经建立的缓存,省下大量 input token 的费用。
缓存键的构成包括:system prompt + tools + model + messages prefix + thinking config。缓存安全参数精确地携带了这些组件。甚至连 fork 的消息前缀都被统一为相同的占位符文本,确保所有子 Agent 产生字节级一致的请求前缀。
这个设计的经济账很清楚:一次 Dream 可能产生 5-10 轮 LLM 调用,每轮的系统提示和工具定义大约 10K-20K tokens。没有缓存,这就是 50K-200K tokens 的额外 input 成本。有了缓存,这些 token 以 cache read 价格计算——通常是原价的 10%。
维度三:谁管资源回收? fork 运行函数在 finally 块中做了两件事:清空克隆的文件状态缓存,清空初始消息数组。这不是普通的清理——文件状态缓存可能持有大量文件内容的副本,不及时释放会造成内存泄漏。
实现
Dream 对子 Agent 施加了额外的权限约束,比通用的 fork 更严格。自动 Dream 函数注入了一段工具限制声明:Bash 只允许只读命令(ls, find, grep, cat 等),任何写操作都会被拒绝。
注意这段限制放在额外参数中而非共享的 prompt 体中——手动触发的 /dream 命令在主循环中运行,拥有正常权限,如果把只读约束写进共享提示词,手动模式下会产生误导。这是一个「同一功能、不同入口、差异化约束」的设计细节。
Dream 还设置了跳过会话日志记录标志,阻止 Dream 的内部对话被记录到会话日志。这不仅节省存储,更避免了一个微妙的自引用问题:如果 Dream 的对话被记录,下次 Dream 运行时可能会读到自己上次的对话记录,试图「整合」自己的整合过程——一种认知层面的无限递归。
还有一个容易忽略的资源管理细节。fork 运行函数在子 Agent 完成后(无论成功还是异常),会在 finally 块中清空两样东西:克隆的文件状态缓存和初始消息数组。文件状态缓存是一个 Map,key 是文件路径,value 是文件内容——在一个大型项目中可能持有几十 MB 的数据。如果不及时清理,每次 Dream 都会留下一个缓存残影,内存占用持续增长。
这种「创建时克隆、完成时清空」的生命周期管理,类似于 C++ 的 RAII(Resource Acquisition Is Initialization)模式——资源的获取和释放绑定在同一个作用域内,即使中间发生异常也能保证释放。
21.3 Task 系统:让后台进程可见
问题
后台运行的 Dream 对用户来说是一个黑箱。它在做什么?进展如何?出了问题怎么办?如果用户无法感知后台活动,就无法建立信任——更无法在必要时干预。
思路
该系统的解决方案是 Task 注册系统。每个 Dream 实例在启动时注册为一个 DreamTask,获得一个在 UI 中可见的状态条目。用户可以在底部状态栏看到 Dream 的存在,通过 Shift+Down 打开详情对话框查看进度,随时终止。
DreamTask 状态的字段设计体现了「对用户有意义的最小信息集」:
phase:只有两个值——starting(正在分析)和updating(正在写入)。虽然 Dream 内部有四个阶段(orient/gather/consolidate/prune),但注释明确说「我们不解析阶段」,只在检测到第一次文件写入时翻转状态。四阶段的细节对用户没有价值,两态足矣。sessionsReviewing:正在回顾多少个会话——这给用户一个规模感。filesTouched:修改了哪些文件——这是用户最关心的。turns:最近的 30 个对话轮次摘要——供好奇的用户深入查看。
实现
进度监控通过 Dream 进度观察器实现。这个函数接收子 Agent 的每条消息,做三件事:提取文本内容作为摘要、统计工具调用次数、收集被修改文件的路径。
关键的状态翻转逻辑:当修改路径列表非空时(意味着有文件被写入),phase 从 starting 变为 updating。这是一个单向翻转——一旦进入 updating 就不会回退。
一个防抖优化值得注意:如果某一轮既没有文本输出、也没有工具调用、也没有新文件被修改,状态更新函数直接返回原状态,避免触发无意义的 UI 重渲染。这种「只在有变化时才更新」的模式在高频回调中至关重要。
终止流程同样精心设计。DreamTask 的 kill 方法做两件事:通过 AbortController 取消子 Agent,然后回滚锁文件的 mtime。回滚确保用户取消 Dream 后,下次会话的时间门仍然能通过——Dream 不会因为用户一次取消就永远跳过。
kill 方法中有一个精妙的防重复保护:状态更新回调先检查任务是否仍在运行——如果状态已经不是运行中(比如已经自然完成或已经失败),整个更新操作变为 no-op。此时先前的 mtime 保持 undefined,后续的回滚也被跳过。这确保了不会对一个已经终止的任务做多余的回滚。
Task 完成后的清理也值得注意。完成函数立即将 notified 设为 true——因为 Dream 没有模型面向的通知路径(它是纯 UI 层的),内联的系统消息就是用户通知。将 abortController 设为 undefined 释放了对 AbortController 的引用,允许垃圾回收。
21.4 遥测:量化 Dream 的价值
问题
Dream 消耗 API token,占用后台资源。团队需要回答一个尖锐的问题:Dream 值得吗? 没有数据就没有答案。
思路
该系统在 Dream 的生命周期中埋设了三个遥测事件,覆盖「触发 - 完成 - 失败」的完整路径。
触发事件记录两个维度:距上次整合多少小时和累积了多少会话。这些数据帮助团队调优触发阈值——如果 90% 的触发都发生在 48 小时以上,说明 24 小时的默认值对大多数用户偏低。
完成事件记录缓存命中指标(cache_read, cache_created, output)和回顾的会话数。缓存命中率直接反映了 prompt cache 共享策略的效果——如果 cache_read 远大于 cache_created,说明子 Agent 成功复用了父 Agent 的缓存。
Fork 度量事件记录更细粒度的指标:总时长、消息数、各类 token 用量、计算得出的缓存命中率。这个事件不仅用于 Dream,所有 fork 子 Agent 都共享,形成一个统一的后台任务观测面板。
实现
缓存命中率的计算揭示了一个有意思的指标定义:
hitRate = cacheReadTokens / (inputTokens + cacheCreationTokens + cacheReadTokens)
分母是「总输入 token」——包括新计算的、新缓存的和从缓存读取的。这个比率越接近 1,说明子 Agent 越充分地复用了已有缓存。根据前面对缓存安全参数的分析,一个正常运行的 Dream 应该有非常高的缓存命中率,因为系统提示和工具定义在父子之间完全一致。
遥测数据还有一个隐含用途:异常检测。如果某个时间段内失败事件突增,可能意味着某次部署引入了 bug(比如整合提示词格式变了导致子 Agent 解析失败)。三个事件的比率(fired:completed:failed)是 Dream 系统健康度的晴雨表。
Dream 完成后,如果修改了文件,主线程会收到一条内联通知。系统检查修改文件列表是否非空,如果有文件被修改,就通过系统消息注入一条记忆改进通知。这条消息的动词被设为 'Improved' 而非默认的 'Saved'——一个措辞上的小区别,但它准确地传达了 Dream 的本质:不是创建新记忆,而是改进已有记忆。
这种跨线程的通知机制也值得关注。子 Agent 在后台完成工作后,通过父线程传入的回调函数在主线程中注入消息。这不是直接修改主线程的状态,而是通过回调函数间接通信——保持了隔离性,同时实现了跨线程的信息传递。
21.5 入口与生命周期:从初始化到每轮检查
问题
Dream 的检查函数在每轮对话结束时都会被调用。但一个对话可能只持续 2 秒(用户问了一个简单问题),Dream 的检查也要在这 2 秒内完成——任何明显的延迟都会被用户感知为「Agent 变慢了」。
思路
执行入口函数的注释直接说明了性能预算:每轮启用时的成本只有一次特性开关缓存读取加一次文件系统 stat。也就是说,绝大多数情况下(时间门未通过),Dream 检查只需要读一次配置缓存加一次 stat。这两个操作加起来耗时不到 1 毫秒。
只有当时间门通过后,才会进入更昂贵的会话扫描。而会话扫描本身又受 10 分钟冷却期保护——即使时间门持续通过(因为锁文件 mtime 没更新),扫描也不会频繁于每 10 分钟一次。
实现
初始化函数使用闭包封装了上次扫描时间状态。运行器变量存储闭包内的运行函数,初始化前为 null。执行入口通过可选链调用,如果初始化从未被调用,整个函数就是一个 no-op。
这种「延迟初始化 + null 安全调用」模式确保了:即使在测试环境中忘记调用初始化函数,系统也不会崩溃——只是默默跳过。防御性设计不仅体现在对外部输入的校验上,也体现在对内部调用顺序的容错上。
另一个细节:Dream 运行函数在获锁成功后使用 try/catch/finally 包裹整个执行过程,但锁的释放不在 finally 中。为什么?因为成功时锁不需要释放——锁文件的 mtime 被更新为当前时间,成为下次时间门检查的基准。只有失败时才需要回滚 mtime。这和常规的「获锁-执行-释放」模式不同,本质上锁文件身兼两职:它既是互斥锁,又是时间戳记录。成功路径下「不释放」反而是正确行为。
21.6 配置的分层防御
问题
Dream 是一个自动触发的后台任务,消耗真金白银。用户需要能够控制它——开关、频率、阈值。但配置来源可能不可靠(远程特性开关的缓存可能过期或返回错误类型),所以配置读取本身需要防御。
思路
启用检测函数展示了一个简洁的两级优先链:用户本地设置 > 远程特性开关。本地设置存在 settings.json 中,是用户的明确意愿,永远优先。只有用户没有明确设置时,才回退到远程开关。
这个模式值得推广:用户意图优先于系统策略。系统可以有默认行为(通过远程开关控制灰度),但用户的显式选择永远胜出。
实现
配置获取函数对远程配置做了逐字段的防御性验证。每个数值字段都检查三个条件:是数字、有限值、大于零。这不是过度防御——远程配置读取函数的名字本身就在警告:缓存可能过期,值可能是旧版本的格式(比如字符串而非数字)。
门控聚合函数汇集了所有硬性前置条件:助手模式不触发(它有自己的 dream 机制)、远程模式不触发(后台任务不应在远程会话中运行)、自动记忆未启用不触发。四个布尔条件的短路求值意味着最常见的退出原因(特性未启用)几乎零成本。
21.7 超越记忆:Dream 模式的推广
问题
Dream 系统的工程骨架——门控触发、fork 隔离、Task 可见性、遥测度量、失败回滚——并不依赖「记忆整合」这个具体任务。把任务换成别的,骨架还能用吗?
思路
先回顾这个骨架的五个组成部分,注意它们之间的松耦合:
- 门控层:决定何时触发,与任务内容无关
- 隔离层:子 Agent 上下文创建函数创建沙箱,与任务内容无关
- 执行层:fork 运行函数运行子 Agent,任务内容由提示词消息决定
- 观测层:Task 注册 + 消息回调,与任务内容无关
- 恢复层:回滚机制,与任务内容无关
五层中只有执行层的提示词和观测层的状态字段与具体任务相关。更换任务只需要:写一个新的提示词、定义一个新的 TaskState 类型、实现一个新的 progress watcher。基础设施不变。
该系统内部已经有了实证。同样基于 fork 子 Agent 的后台任务至少还有:
- 记忆提取:每轮对话后自动提取值得保存的信息
- 会话记忆压缩:压缩过长的会话历史
- 推测执行:在用户思考时预判下一步
它们共享同一套基础设施:fork 运行函数做执行、子 Agent 上下文创建函数做隔离、缓存安全参数做缓存优化、遥测函数做度量。区别只在于触发条件和任务内容。
这揭示了一个通用模式,可以叫它**「Dreamer Pattern」**:
门控检查(低成本优先)
-> 获锁(防并发)
-> 注册 Task(可见性)
-> fork 受限子 Agent(隔离执行)
-> 监控进度(状态回调)
-> 成功:更新状态 + 遥测
-> 失败:回滚 + 遥测
-> 取消:回滚 + 标记
这个模式适用于任何满足以下条件的 Agent 任务:
- 不需要用户实时参与
- 可以容忍延迟(不是立即需要结果)
- 失败不影响主流程(降级为跳过)
- 需要与主循环隔离(避免上下文污染)
推广到更广的场景,你可以用 Dreamer Pattern 做:
- 代码库健康检查:定期扫描技术债、过期依赖
- 文档同步:检测代码变更,更新对应文档
- 测试覆盖率分析:识别高风险但低覆盖的模块
- 上下文预热:预读用户可能用到的文件
每个场景只需要定义自己的门控条件和任务提示词,基础设施完全复用。
以「代码库健康检查」为例,门控条件可以是:距上次检查 7 天 + 期间有 20 次以上的 git commit + 没有其他检查在运行。任务提示词让子 Agent 扫描 package.json 的依赖版本、查找 TODO/FIXME 注释、检查 lint 规则的更新。Task 注册让用户看到「正在做代码体检」,遥测记录每次体检发现了多少问题。失败回滚让下次体检不受影响。
这个模式之所以强大,在于它把何时做(门控)、怎么做(fork + 受限子 Agent)、做得怎样(Task + 遥测)和没做成(回滚)这四个正交关注点用统一的框架解决了。你不需要为每个后台任务重新发明这四个轮子。
一个更有想象力的方向是跨 Agent 的 Dream 协作。当多个 Agent 在同一个项目上工作时(比如 Agent Swarm 架构),每个 Agent 都有自己的会话记忆。一个「全局 Dream」可以在所有 Agent 空闲时启动,交叉参考不同 Agent 的记忆,发现矛盾、消除冗余、建立统一的知识基线。这是从「个体记忆巩固」到「集体知识管理」的跃迁——而基础设施仍然是同一套 Dreamer Pattern。
21.8 设计取舍:被拒绝的方案
问题
Dream 的当前设计看起来自然而然,但每个「选择了 A」的背后都有一个「没选择 B」。理解被拒绝的方案,才能真正理解设计空间。
思路
为什么不用数据库锁? 锁文件的 write-then-read 方案看起来很原始。数据库的原子事务不是更可靠吗?但 Dream 的设计约束是零外部依赖——不依赖数据库、不依赖消息队列、不依赖 Redis。锁文件基于 POSIX 文件系统语义,任何环境都能运行。一小时的过期保护处理进程崩溃,PID 检查处理活锁,write-then-read 处理竞争。三层防护覆盖了文件锁的已知弱点。
为什么不实时整合? 每次写入记忆时就立即去重和整合,不是更及时吗?问题在于成本。整合需要读取所有已有记忆并做语义比较——这是一个 O(N) 的操作(N 是记忆条目数)。如果每次写入都触发,随着记忆增长,写入延迟会越来越大。批量处理(累积 5 个会话再整合)将 N 次 O(N) 操作摊薄为 1 次 O(N),总成本从 O(N^2) 降到 O(N)。这和数据库的 WAL(Write-Ahead Log)+ 定期 compaction 是同一思路——先快速写入,后台慢慢整理。
为什么不用一个专门的整合模型? 比如训练一个小型模型专门做记忆整合,而不是用通用的大语言模型。答案藏在整合提示词的复杂度里——Phase 2 需要理解代码语义(判断记忆是否过时),Phase 3 需要写高质量的 Markdown(合并和更新记忆文件),Phase 4 需要做编辑决策(哪些索引条目该删除)。这些任务需要通用的语言理解和生成能力,专用小模型很难胜任。用通用模型 + 精心设计的提示词,比训练专用模型更灵活、更容易迭代。
为什么是 24 小时 / 5 会话? 这两个阈值来自经验调优。太频繁会浪费 API 成本(每次 Dream 可能消耗几千 token),太稀疏会让记忆退化。24 小时对应「一天的工作周期」,5 会话对应「有足够新信息值得整合」。通过远程配置的覆盖能力,团队可以 A/B 测试不同阈值组合的效果。
为什么初始化函数用闭包而非模块级变量? 注释给出了直接答案:状态是闭包作用域的,而非模块级的——测试在 beforeEach 中调用初始化函数即可获得新的闭包。如果上次扫描时间这样的状态是模块级变量,测试之间会互相污染——一个测试修改了扫描时间戳,下一个测试就会得到意外的结果。闭包作用域让每次调用都创建一套独立的状态,这是一个「可测试性驱动设计」的典型案例。
21.9 回到隐喻
Dream 系统最终教给我们的不只是「如何做后台记忆整合」,而是一种更广泛的 Agent 架构思想:
Agent 不应该只在「清醒」时工作。 用户在场时处理请求,用户不在场时反思和整理——这种双模态运行方式让 Agent 从「工具」进化为「持续运作的助手」。就像一个好的人类助手,不仅在你问问题时给出答案,还会在你离开后整理笔记、归档文件、准备明天的材料。
从工程角度看,Dream 模式的价值在于它把一组本可以很复杂的问题(并发安全、资源管理、进度可见、失败恢复、成本控制)用一套统一的骨架解决了。这个骨架是可复制的——任何需要后台反思的 Agent 功能都可以套用。
名字「Dream」的精妙之处在于,它不仅是技术描述(后台记忆整合),更是设计哲学的宣言:一个真正智能的 Agent,应该在「睡觉」的时候也在变得更聪明。
最后一个值得反思的问题:Dream 系统的存在本身暗示了一个更深层的架构选择——系统设计者选择了「积累 + 批量整理」而非「每次写入就保持整洁」。这不是懒惰,而是对 LLM 能力边界的务实判断。要求 Agent 在高速对话中同时做好「解决用户问题」和「完美组织记忆」两件事,就像要求一个外科医生在手术过程中同时整理器械台——做不到,也不应该做。
把两种认知模式分离到不同时间段,让每种模式都能专注,这才是 Dream 模式最本质的洞察。在认知科学中这叫做「模式切换」(mode switching)——分析模式和整理模式使用不同的认知策略,混合执行两者都会退化。Dream 给了 Agent 一个专门的「整理时间」,就像人类的睡眠给了大脑一个专门的「整合时间」。有趣的是,人类如果被长期剥夺 REM 睡眠,认知能力会显著退化。类推到 Agent:如果长期不运行 Dream(比如关闭了自动记忆功能),记忆文件会持续膨胀、冗余累积、索引超限——Agent 的「认知能力」(检索到相关记忆的概率)也会退化。
从工程师的角度看,Dream 最重要的遗产可能不是记忆整合本身,而是它证明了一件事:AI Agent 可以拥有超越单次对话的生命周期。它会积累、会遗忘、会反思、会自我修正。这不再是一个工具,而是一个持续运作的认知系统。Dream 是通向这个未来的第一步。
思考题
Dream 目前使用文件锁实现互斥。如果该系统演化为多节点部署(多台机器共享同一个记忆目录),文件锁会遇到什么问题?你会用什么替代方案,同时保持「零外部依赖」的约束?提示:考虑 NFS 文件锁的语义差异。
Dream 的跳过日志记录设置避免了自引用递归。但如果我们 想要 Agent 反思自己的整合过程(「上次整合是否遗漏了什么?」),应该如何安全地实现?提示:考虑用一个独立的、有限深度的反思步骤。
尝试用 Dreamer Pattern 设计一个「代码审查整合器」:Agent 在后台回顾最近的代码变更,生成待办的 code review 要点。你会如何设计门控条件(什么时候触发)、任务提示词(让子 Agent 做什么)和失败策略(出错了怎么办)?
Dream 的整合提示词是静态模板。如果不同用户的记忆结构差异很大(有人有 3 个文件,有人有 300 个),同一个提示词能否同时服务好两种情况?你会如何设计自适应的整合策略?