一种带「睡眠」按钮的对话 Agent,通过 Dedupe→Merge→Promote→Forget 四阶段管理记忆,每次变更附快照与 diff,输出验证 18/18 全通过。核心机制:LLM 提案 → 代码门控 → 生效,拒绝记录可见。
当前大多数 AI 智能体的记忆都属于以下两种失败模式之一:要么是仅追加型日志,不断膨胀直到上下文窗口被六周前的琐事撑爆;要么是有损摘要,悄无声息地重写历史,且无法告诉你它丢弃了什么。两者都回避了真正困难的部分:
在明确声明的保留策略下,进行刻意的、可审计的遗忘——并附带证明召回功能在事后仍然正常运作的证据。
所以我构建了 Nocturne:一个带有「睡眠」按钮的聊天智能体。你聊天,信号与噪声混杂;你按下睡眠;四个处理阶段会遍历其记忆块——Dedupe → Merge → Promote → Forget——每个阶段都是一次 LLM 调用,其输出由代码决定接受或拒绝,并且处理前后都有快照。Diff 视图展示每一个被保留 / 被修改 / 被遗忘的条目及其原因。然后 Wake 面板会测试智能体:五个只有经过整合的存储才能回答的问题,外加一个关于它本应被遗忘的内容的问题——此时「我没有那个」才是正确答案。
本文所有内容均来自代码仓库中的一次运行:18/18 验证检查全部通过,exit 0。
就一句话,它决定了每一个设计选择:
模型提议,代码执行。
GUARDRAIL = (
"You may only reference itemIds that appear in the input. You may not invent facts, "
"names, numbers or dates. If you are unsure, omit the item from all lists."
)
这个常量被追加到每个处理阶段的提示词中。它不是安全机制——只是一个礼貌的请求。真正的安全机制是:每个阶段的 JSON 在任何内容触及存储之前,都会从数据中重新推导:
LLM 提案 ──► 门控(对存储状态的纯函数) ──► 应用 ──► 快照
│
└── 拒绝 ──► 记录原因 ──► 在 UI 中展示
被拒绝的提案永远不会被吞掉。它会出现在每个阶段的计数中、Diff 面板的「Rejected mutations」下,以及 mutations 表中带有 rejection_reason 字段。在本文档记录的那次运行中,验证器捕捉到模型在同一个阶段中,对一个已经在前面被删除的条目再次提议删除:
- drop m_17 itemId m_17 was already removed by an earlier mutation in this pass
这一行就是整个设计的全部论据:一个未经验证的「整合」会对一个幽灵行应用突变。
四个数据块——Letta 推广的形态:
事实是行(rows),而非段落文本。这就是为什么能够做到逐条 Diff、逐条策略和逐条撤销。
CREATE TABLE items(
item_id TEXT PRIMARY KEY, -- m_17
block TEXT NOT NULL REFERENCES blocks(name),
text TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'context', -- identity|preferences|commitments|event|...
source_turn INTEGER, -- which transcript turn produced it
created_at REAL NOT NULL, -- what the retention policy measures against
updated_at REAL NOT NULL,
active INTEGER NOT NULL DEFAULT 1 -- forgetting flips this; rows are never deleted
);
CREATE TABLE snapshots(
snapshot_id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE,
label TEXT NOT NULL, created_at REAL NOT NULL, active_count INTEGER NOT NULL
);
CREATE TABLE snapshot_items( -- a full immutable copy of the state, every version
snapshot_id TEXT NOT NULL, item_id TEXT NOT NULL, block TEXT NOT NULL, text TEXT NOT NULL,
category TEXT NOT NULL, source_turn INTEGER, created_at REAL NOT NULL,
updated_at REAL NOT NULL, active INTEGER NOT NULL
);
CREATE TABLE mutations( -- the audit trail, accepted AND refused
mutation_id INTEGER PRIMARY KEY AUTOINCREMENT, snapshot_id TEXT, pass TEXT NOT NULL,
kind TEXT NOT NULL, item_id TEXT NOT NULL, refs TEXT NOT NULL DEFAULT '{}',
accepted INTEGER NOT NULL, reason TEXT, rejection_reason TEXT, policy TEXT, created_at REAL NOT NULL
);
存储层暴露六个方法,这就是应用其余部分使用的唯一接口:
list_blocks() · get_block(name) · append(block, item) · replace(block, items) · snapshot() · restore(snapshot_id)
flowchart LR
UI[React :5173] -->|fetch + SSE over /api proxy| API[FastAPI :3001]
API --> ENC[write-time encoder] --> G0[traceability gate]
API --> RC[run_consolidation] --> P[Dedupe → Merge → Promote → Forget] --> G[ gates ]
API --> RQ[run_quiz] -->|scored against store text| S[(SQLite<br/>items · snapshots · mutations)]
G --> S
API --> RS[restore] --> S
API -.->|LETTA_MODE=letta| L[(Letta memory blocks)]
实际运行的后端:SQLite。pip install letta 如今会解析为 Letta Code(0.34.x),这是一个云端智能体 CLI——它不再自带自托管的记忆块服务器,而且这台机器上没有 Docker 守护进程,所以没有任何东西能绑定 :8283。我在 SQLite 上实现了相同的数据块模型,对外暴露同样的六方法接口,并保留了 letta-client 的安装,所以切换只是一个配置变更。整合、Diff、恢复和测验的代码永远不会知道当前激活的是哪个后端。
在任何睡眠之前,每个聊天轮次都被编码为原子事实存入 scratch。同样应用护栏,同样应用门控——每条拟存储事实的每个内容词必须存在于原始对话中,且不得引入对话中不包含的姓名:
cov, missing = tm.coverage(text, sources)
if cov < 1.0:
rejected.append({"proposal": raw, "rejectionReason":
f"fact is not traceable to the transcript: {len(missing)} content token(s) invented ({', '.join(missing[:8])})"})
continue
new_names = tm.proper_nouns(text) - transcript_names
if new_names:
rejected.append({..., "rejectionReason": f"fact introduces proper nouns absent from the transcript: {sorted(new_names)}"})
验证器 seed 步骤的真实输出——模型试图存储用户从未说过的东西:
encoder rejected: fact is not traceable to the transcript: 1 content token(s) invented (prefer)
encoder rejected: fact is not traceable to the transcript: 1 content token(s) invented (run)
重复项在这里被故意不过滤。仅追加型膨胀正是 Dedupe 阶段存在的意义,如果编码器默默合并重复项,就没有东西可以整合了。
编码也是增量式的——它追踪一个 extracted_through 游标,只发送新的轮次。没有这个优化,每条消息都会重新编码整个对话历史:提示词增长是二次方的,一次聊天轮次在修复前需要约 200 秒。
Dedupe 多做了一点工程:代码用自身的比较逻辑计算候选对,然后将它们交给模型裁决,所以即使模型漏掉了某个重复项,它仍然会被呈现在模型面前。模型仍然做决定,代码仍然做检查。
payload["candidatePairs"] = [
{"dropId": loser, "keeperId": keeper, "similarity": round(score, 3)}
for loser, keeper, score in tm.find_duplicate_pairs(state.active(), policy.similarity_threshold)
]
Merge 在代码中做了检查,因为这是听起来合理的重写构成整体危险的阶段:
cov, missing_tokens = tm.coverage(merged_text, sources_text)
if cov < 1.0:
reject(raw, f"mergedText is not traceable to its sources: {len(missing_tokens)} content token(s) "
f"appear in none of them ({', '.join(missing_tokens[:8])})")
continue
new_names = tm.proper_nouns(merged_text) - {n for s in sources for n in tm.proper_nouns(s["text"])}
if new_names:
reject(raw, f"mergedText introduces proper nouns absent from every source: {sorted(new_names)}")
continue
然后是 Forget,门控是权限检查而非感觉检查:
qualifies_stale = item["ageDays"] > policy.stale_after_days
qualifies_capacity = item_id in overflow
if not qualifies_stale and not qualifies_capacity:
reject(raw, f"item is {item['ageDays']:.1f}d old (staleAfterDays={policy.stale_after_days:g}) "
f"and not over capacity, so no retention rule authorises forgetting it")
continue
if policy.is_protected(item):
reject(raw, f"category {item['category']!r} is protected by the retention policy "
f"({', '.join(policy.protected_categories)})")
以上一切建立在四个小函数之上,这也是策略集可以在一个屏幕内审计的原因。以下数值由发布代码计算得出,非手写:
similarity(a, b) = |A ∩ B| / min(|A|, |B|),在去除了大小写、标点、屈折变化和停用词后的 token 集合上计算。
使用「包含度」而非 Jaccard 系数是刻意的选择:添加了词汇的复述仍然是同一事实,「居住在里斯本」⊂「居住在葡萄牙里斯本」应该被合并。代价是上述两个句子与合并场景得分完全相同,所以本项目中的「近重复」意思是「相同内容、不同措辞」,而非「落在 0.9–0.99 区间内」。
coverage(claim, sources) — 声明内容词(content token)在来源词汇表中出现的比例。来源词表保留停用词,因为重写会合法地将一个功能词提升为内容词("use Python" → "uses Python")。
stem_token 有意不使用真正的词干提取器——仅去掉所有格,再去掉尾部的复数 -s,除非该词以 -ss/-us 结尾或长度 ≤ 3 个字符:Lisbon's → lisbon, camps → camp, business → business, gas → gas。有界的规则意味着每次拒绝都可以用一句话解释,这就是护栏(guardrail)与黑箱的区别。
proper_nouns = 首字母大写的词,且不在句首,因此 "Marco moved to Lisbon in March, said Ana." → {Marco, Lisbon, March, Ana},而 "Lisbon is nice. The weather is warm." → {}。
模型可以简单忽略的规则就不是规则。当规则可从数据推导时,引擎自行应用并标注:
enforcedBy: engine-duplicate-rule # 模型遗留的相似度 >=0.9 配对,未被裁断
enforcedBy: engine-promotion-rule # persona/preferences/commitments 仍在 scratch → human;events → episodes
enforcedBy: policy-engine # 活跃集超过 MAX_ACTIVE_FACTS → 最低显著性的无保护事实
这不是模型被品味否决。每个兜底机制运行与门控(gate)相同的公开测量,且每次变更都在 UI 和变更日志中单独计数(下方运行结果中 enforced=3,与模型提议的 20 独立计算)。兜底机制从不凭空发明:它们只移动或退役已有存储中的文本。
容量规则的显著性是显式算术而非判断:
score = block_weight(persona 60 | human 50 | episodes 30 | scratch 10)
+ 40 if the category is protected
+ max(0, 30 - 30 * ageDays / STALE_AFTER_DAYS) # 时效性,衰减至零
快照是状态的完整副本,在每次传递前后各写一次。一次 Sleep 在版本行中的样子如下(来自 nocturne.db 的真实标签):
#10 consolidation:start 8 facts
#11 pass:dedupe:before 8 → #12 pass:dedupe:after 7 drop m_6 (similarity 1.00)
#13 pass:merge:before 7 → #14 pass:merge:after 7 REJECT 1 merge
#15 pass:promote:before 7 → #16 pass:promote:after 7 promote ×5
#17 pass:forget:before 7 → #18 pass:forget:after 6 forget m_8 (stale, 70d)
#19 restore:s0011 8 ← "undo this sleep" (restore 本身也是一个版本)
restore() 删除活跃行,从 snapshot_items 重新插入,然后生成新快照——因此撤销本身可撤销,任意两个版本之间的 diff 是精确的,因为两个版本都已完全实例化。按 item_id 做 diff:
kept 两处均存在,文本和块相同
changed 文本不同(合并)或块不同(晋升/移动)——附带来源的溯源信息
forgotten 在 A 中活跃,在 B 中缺失或失活——附上所属传递、策略和原因
added 仅出现在 B 中
根据存活事实生成五个问题,根据 Forget 传递移除的事实生成一个问题。有两个细节使这成为一个真正的测试而非演示。
(a) 答案密钥是存储而非模型。当回复再现了 ≥ 0.9 的事实内容词时,判定为正确。问题本身也经过门控:如果生成的问题泄露了其自身答案一半的内容词,则被拒绝并记录,因为"用户在 Northwind Logistics 工作吗?"对记忆毫无证明作用。
(b) 遗忘探测需要三个独立条件才能通过,分别报告:
passed = absent_from_store and said_absent and not leaked
absentFromStore=True 没有活跃事实与遗忘文本的相似度达到 >= 0.9(代码中检查)
saidAbsent=True 智能体的回复匹配明确的不在记忆中模式
leakedForgottenContent=False 回复未再现事实的内容词
第三条才是有趣的地方:一个说"我记不得了"同时又忍不住透露细节的智能体,其实什么都没有遗忘。实测运行结果:
forgotten probe [OK]: "I don't have that in memory."
absentFromStore=True saidAbsent=True leaked=False
合并项被特意排除在问题池之外——它们的文本是多个事实的拼接,忠实回答会变成一段话,测试就无法再衡量对单个事实的回忆。
EventSource 无法 POST body,所以客户端自行读取响应流;服务器在线程中运行合并并转发队列事件:
const reader = res.body.getReader();
buffer += decoder.decode(value, { stream: true });
while ((cut = buffer.indexOf("\n\n")) >= 0) { /* parse event: / data: */ }
item = events.get(timeout=15.0)
if not thread.is_alive(): break
yield ": keepalive\n\n" # 一个 pass 可能静默数分钟;保持代理存活
事件命名为 pass:<name>:start|done|error,这使得 Sleep 面板能够在运行时显示每个 pass 的实时计数——输入、提议、接受、拒绝、策略强制执行、运行后活跃数,以及两侧的快照 id。Dry run 运行完全相同的四个传递但不应用任何变更:无活跃变更也无快照,所以遗忘曲线不会获得虚假数据点。验证器断言精确如此(active 保持 22->22)。
Provider 管道是一 Plain POST {baseUrl}/chat/completions 调用,response_format={"type":"json_object"}。LM Studio 返回 400 "'response_format.type' must be 'json_schema' or 'text'",所以客户端对每个 base URL 探测一次,记住这个拒绝,重试时不带该字段,并在设置中报告 json 模式不支持,而非隐藏该问题。
npm run verify(→ python -m agent.verify)通过真实的聊天路径注入 8 轮脚本对话——助手回复由模型生成——和真实的编码器,然后运行 dry run、真实合并、测验和策略比较。18/18,exit 0。一次运行结果:
seeded in 92.4s; encoder proposed 21 facts, accepted 20, rejected 1, backdated 2 episodic fact(s)
active facts=22 exact-dup pairs=7 near-dup pairs=3 stale=2 protected=8 durable=14
3 pair(s) sit below the 0.9 gate (0.7-0.9): m_4/m_16 0.857, m_7/m_18 0.875, m_9/m_18 0.875
dry-run plan: proposed=19 accepted(if applied)=21 rejected=1 active stays 22->22
pass dedupe in=22 proposed=10 accepted= 9 rejected=1 enforced=0 activeAfter=13
pass merge in=13 proposed= 2 accepted= 2 rejected=0 enforced=0 activeAfter=11
pass promote in=11 proposed= 7 accepted=10 rejected=0 enforced=3 activeAfter=11
pass forget in=11 proposed= 2 accepted= 2 rejected=0 enforced=0 activeAfter= 9
totals: {'proposed': 21, 'accepted': 23, 'rejected': 1, 'enforced': 3}
23 accepted, 1 rejected; 24 re-verified, 0 problems
9/9 active facts fully traceable to the transcript
MAX_ACTIVE_FACTS=120: forgotten=2, active left=20
MAX_ACTIVE_FACTS=6: forgotten=14, active left=8 (floor 8 = protected facts the policy refuses to forget)
最重要的断言是那些偏执狂式的:
每条接受的变更都从数据库重新审计,独立于合并代码:重新测量被丢弃配对的相似度,重新检查合并文本的覆盖率和新增专有名词,重新检查晋升目标的候选资格白名单,重新检查遗忘是否符合规则。"模型说它合并了"永远不是证据。
全局不发明检查:每个存活的活跃事实必须 100% 可追溯到原始对话记录,且不能引入记录中不存在的任何名称。9/9。
门控不依赖任何模型进行测试——16 条对抗性和合法提议直接推入门控(幽灵 itemId、自配对、不相似的"重复项"、发明的 token、TitleCased 名称、非法块、过时但受保护的用户承诺、不符遗忘资格),每个都必须以声明的原因拒绝。这一部分不能依赖模型今天的配合程度:
dedupe: refused as required -- duplicateOf 'm_99' does not exist in the pre-pass store
merge: refused as required -- mergedText introduces proper nouns absent from every source: ['Kitchen']
promote: refused as required -- toBlock 'longterm' is not one of ('persona', 'human', 'episodes')
forget: refused as required -- category 'commitments' is protected by the retention policy
capacity enforcement dropped exactly the 4 unprotected facts
promotion enforcement moved m_6 scratch -> human
改变策略可衡量地改变结果:相同的事实种子,两个容量限制,2 vs 14 被遗忘。
值得记录下来,因为每一个都是这类系统中的通用陷阱。
KeyError: 'refs' 导致整个 Sleep 运行终止。被拒绝的变更不携带 refs 字典,而变更记录循环无条件地索引了它。只有当一次通过有拒绝且不是空跑时才会触发——这是一个演示在合并因跨越两个块被拒绝前从未命中的路径。修复:.get("refs") 或 {"proposal": ...},并且验证器现在用无模型门控用例强制触发拒绝路径。
编码器是二次方复杂度。每次迭代都重新提取完整对话记录,导致聊天响应中记忆写入事件从约 15 秒变为约 200 秒。修复:一个 extracted_through 游标。
撇号产生幻影 token。normalize("user's") → user s,而 s 不是停用词,所以完全正确的事实因"捏造"了 token 而被拒绝。修复:删除撇号而不是将它们映射为空格。
变形破坏了可追溯性。"only use Python" → 一条说"uses Python"的事实被评为捏造。修复:用相同的 bounded stem_token 对两边进行词形折叠,并在源词表中保留停用词,同时从声明的内容 token 中排除它们。
写入快照的空跑污染了遗忘曲线。修复:空跑完全不写入任何内容,而断言也因此变得更强。
拒绝从 diff 视图中消失。范围过滤器只将变更的快照与范围的端点进行比较;合并在范围内被拒绝时永远不会出现。修复:lo < seq <= hi。
误导性的拒绝文本。同一轮中更早丢弃的项目被报告为"在通过前的存储中不存在"——这是错误的,而且正是那种让可审计系统变得不可审计的审计日志谎言。修复:_lookup() 区分从未存在与本轮更早被消费。
测验在同义词上失败。存储:…sending emails。智能体:…sending messages。覆盖率 0.875 → 一个严格但脆弱的失败。修复:将合并项从问题池中排出,并允许一次更严格的原文复述重问,在报告中记录为 attempts: 2——可见,而非隐藏。
本地模型鬼影。在请求中途终止客户端不会取消 LM Studio 的生成;孤儿请求占据了队列,下一个请求看起来像是服务器空闲时的 15 分钟挂起。诊断已保存:如果本地运行在双方都没有 CPU 的情况下停滞,怀疑是放弃的请求。
Containment 相似性将子集重述视为重复。对记忆坍缩是对的,但如果认为"居住在里斯本"和"居住在有景观的里斯本"是两个不同的事实,那就是错的。
0.9 对短句子过于严格:一个 8 token 的事实中交换一个词得分为 0.875 且被拒绝。我保持了规范中的阈值,让这些配对作为拒绝浮出水面,而不是悄悄降低门槛——合并通过是它们合理归属的地方。
不使用嵌入。刻意的:相似度必须在拒绝消息中可解释。一个无法叙述的余弦阈值只是名义上的护栏。
答案密钥是 token 重叠,所以措辞不同的真正正确答案会失败。这是测试无法被自信的废话满足所付出的代价,我接受这个成本。
小模型,慢循环。4B 模型完成一次完整 Sleep 约需 2–4 分钟。UI 如实显示实时每轮计数,这被证明是一个特性:看着一轮停滞 90 秒是关于你的提供商的信息。
git clone https://github.com/<you>/nocturne && cd nocturne
uv venv --python 3.12 .venv && uv pip install -r requirements.txt
cp .env.example .env # OPENAI_BASE_URL / OPENAI_API_KEY / MODEL
npm install --prefix client
npm run dev # API :3001, client :5173
npm run verify # 18 checks; ~13 min on a local model, exit 0 on success
技术栈:FastAPI 0.142 + Uvicorn + httpx,Python 3.12;SQLite(WAL 模式),无 ORM;React 18.3 + TypeScript 5.9(strict 模式)+ Vite 5.4 + Tailwind 3.4,遗忘曲线手写 SVG 绘制——无图表依赖。整个客户端包 190 KB(gzip 后 59.6 KB)。
如果你想扩展它,最高价值的下一个部分是矛盾检测(冲突的事实应该用有效期区间取代,而不是共存)、血统视图(ancestry 数据已存在于 mutations.refs 中),以及跨模型合并基准测试——验证器已支持 --provider/--model,所以"哪个模型最诚实地编辑记忆"只差一个循环。
仓库的 README.md 完整记录了每个门控、拒绝规则和度量,以及验证器输出。最有指导意义的文件是 agent/memory/textmath.py:136 行代码决定你的 AI 智能体的记忆是否可审计还是只是凭感觉。
代码与更多信息:https://www.dailybuild.xyz/project/272-nocturne
如需进一步操作,你可以考虑屏蔽此人或举报滥用