- nocmem Python service (mem/): FastAPI wrapper around NuoNuo's Hopfield-Hebbian memory, with /recall, /ingest, /store, /stats endpoints - NOC integration: auto recall after user message (injected as system msg), async ingest after LLM response (fire-and-forget) - Recall: cosine pre-filter (threshold 0.35) + Hopfield attention (β=32), top_k=3, KV-cache friendly (appended after user msg, not in system prompt) - Ingest: LLM extraction + paraphrase augmentation, heuristic fallback - Wired into main.rs, life.rs (agent done), http.rs (api chat) - Config: optional `nocmem.endpoint` in config.yaml - Includes benchmarks: LongMemEval (R@5=94.0%), efficiency, noise vs scale - Design doc: doc/nocmem.md
9.3 KiB
nocmem — NOC 自动记忆系统
动机
NOC 现有记忆:100 个文本槽位(200 字符/槽)+ 滑动窗口摘要。全部塞在 system prompt 里,每次对话都带着。
问题:
- 没有语义检索,无关记忆浪费 token
- 槽位容量有限,不可扩展
- 没有联想能力(A 提到 → 想起 B → 引出 C)
nocmem 用 NuoNuo 的 Hopfield-Hebbian 混合记忆网络替代朴素文本槽位,实现自动召回和自动存储。
核心技术
NuoNuo Hippocampal Memory
生物启发的双层记忆架构(详见 ../nuonuo/doc/architecture.md):
Layer 1 — Hopfield(单跳,噪声容忍)
存储 (cue, target) embedding 对。召回时两阶段:
- NN 预过滤:cosine similarity 找 top-K 候选(K=20)
- Hopfield settle:β-scaled softmax attention 迭代收敛(3 步)
关键特性:paraphrase 容忍 — 用户换一种说法问同样的事,照样能召回。通过存储 cue variants(同一条记忆的多种表述)实现,attention 按 memory_id 聚合。
Layer 2 — Hebbian(多跳,联想链)
WTA pattern separation(384D → 16384D 稀疏码,k=50,稀疏度 0.3%)+ 外积权重矩阵 W。
Hopfield 找到起点后,Hebbian 通过 W @ code 沿关联链前进:A → B → C。
这是传统 RAG 做不到的——向量搜索只能找"相似",Hebbian 能找"相关但不相似"的东西。
性能指标
| 指标 | 数值 |
|---|---|
| Paraphrase recall(+augmentation, 2K bg) | 95-100% |
| Multi-hop(3 hops, 500 bg) | 100% |
| Scale(20K memories, no augmentation) | 80% |
| Recall 延迟 @ 20K | 4ms |
| VRAM | ~1 GB |
Embedding
使用 all-MiniLM-L6-v2(384 维),CPU/GPU 均可。选择理由:
- NuoNuo 实验(P1)验证:gap metric(相关与不相关的分数差)比绝对相似度更重要
- MiniLM 在 gap metric 上优于 BGE-large 等更大模型
- 推理快:GPU ~1ms,CPU ~10ms per query
记忆提取
对话结束后,用 LLM 从 (user_msg, assistant_msg) 中提取 (cue, target, importance) 三元组:
- cue:什么情况下应该回忆起这条记忆(触发短语)
- target:记忆内容本身
- importance:0-1 重要度评分
LLM 不可用时回退到 heuristic(问答模式检测 + 技术关键词匹配)。
提取后,LLM 为每个 cue 生成 3 个 paraphrase,作为 cue_variants 存入,提升召回鲁棒性。
架构
┌─────────────┐
│ Telegram │
│ User │
└──────┬───────┘
│ message
▼
┌─────────────┐
│ NOC │
│ (Rust) │
│ │
│ 1. 收到 user │
│ message │
│ │
│ 2. HTTP POST ├──────────────────┐
│ /recall │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ nocmem │
│ │ │ (Python) │
│ │ │ │
│ │ │ embed(query) │
│ │◄────────┤ hippocampus │
│ recalled │ │ .recall() │
│ memories │ │ format results │
│ │ └─────────────────┘
│ 3. 构建 messages:
│ [...history,
│ user_msg,
│ {role:system,
│ recalled memories}]
│ │
│ 4. 调 LLM │
│ (stream) │
│ │
│ 5. 得到 │
│ response │
│ │
│ 6. 异步 POST ├──────────────────┐
│ /ingest │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ nocmem │
│ │ │ │
│ │ │ LLM extract │
│ │ │ embed + store │
│ │ │ save checkpoint │
│ │ └─────────────────┘
│ 7. 回复用户 │
└──────────────┘
消息注入策略
关键设计:recalled memories 注入在 user message 之后,作为独立的 system message。
[
{"role": "system", "content": "persona + memory_slots + ..."}, // 不变
{"role": "user", "content": "历史消息1"}, // 历史
{"role": "assistant", "content": "历史回复1"},
...
{"role": "user", "content": "当前用户消息"}, // 当前轮
{"role": "system", "content": "[相关记忆]\n- 记忆1\n- 记忆2"} // ← nocmem 注入
]
为什么不放 system prompt 里?
KV cache 友好。System prompt 是所有对话共享的前缀,如果每条消息都改 system prompt 的内容(注入不同的 recalled memories),整个 KV cache 前缀失效,前面几千 token 全部重算。
放在 user message 之后,前缀(system prompt + 历史消息 + 当前 user message)保持稳定,只有尾部的 recalled memories 是变化的,KV cache 命中率最大化。
临时性。Recalled memories 不持久化到对话历史数据库。每轮对话独立召回,下一轮消息进来时重新召回当时相关的记忆。这避免了历史消息中堆积大量冗余的记忆注入。
HTTP API
POST /recall
请求:
{"text": "数据库最近是不是很慢"}
响应:
{
"memories": "[相关记忆]\n- 上次数据库慢是因为缺少索引 (hop=1)\n- PostgreSQL 跑在 5432 端口 (hop=2)",
"count": 2
}
- 如果没有相关记忆,返回
{"memories": "", "count": 0} - NOC 检查 count > 0 才注入,避免空消息
POST /ingest
请求:
{
"user_msg": "帮我看看数据库为什么慢",
"assistant_msg": "检查了一下,是 users 表缺少 email 字段的索引..."
}
响应:
{"stored": 2}
- fire-and-forget,NOC 不等响应
- 内部流程:LLM 提取 → embed → generate paraphrases → store → save checkpoint
GET /stats
{
"num_memories": 1234,
"num_cue_entries": 4500,
"augmentation_ratio": 3.6,
"vram_mb": 1024,
"embedding_model": "all-MiniLM-L6-v2"
}
NOC 侧改动
config.yaml
nocmem:
endpoint: "http://127.0.0.1:9820"
Rust 改动(最小化)
config.rs:加一个可选字段
#[serde(default)]
pub nocmem: Option<NocmemConfig>,
#[derive(Deserialize, Clone)]
pub struct NocmemConfig {
pub endpoint: String,
}
main.rs(主消息处理路径):
在 api_messages.push(user_msg) 之后、run_openai_with_tools 之前:
// auto recall from nocmem
if let Some(ref nocmem) = config.nocmem {
if let Ok(recalled) = nocmem_recall(&nocmem.endpoint, &prompt).await {
if !recalled.is_empty() {
api_messages.push(serde_json::json!({
"role": "system",
"content": recalled
}));
}
}
}
在 LLM 回复之后(push_message 之后):
// async ingest to nocmem (fire-and-forget)
if let Some(ref nocmem) = config.nocmem {
let endpoint = nocmem.endpoint.clone();
let u = prompt.clone();
let a = response.clone();
tokio::spawn(async move {
let _ = nocmem_ingest(&endpoint, &u, &a).await;
});
}
nocmem_recall 和 nocmem_ingest 是两个简单的 HTTP 调用函数。recall 设 500ms 超时(失败就跳过,不影响正常对话)。
同步覆盖的调用点
| 位置 | 场景 | recall | ingest |
|---|---|---|---|
main.rs handle_message |
用户聊天 | ✅ | ✅ |
life.rs AgentDone |
子代理完成通知 | ✅ | ❌ |
life.rs run_timer |
定时器触发 | ❌ | ❌ |
http.rs api_chat |
HTTP API 聊天 | ✅ | ✅ |
gitea.rs |
Gitea webhook | ❌ | ❌ |
部署
nocmem 作为独立 Python 服务运行:
cd /data/src/noc/mem
uv run uvicorn server:app --host 127.0.0.1 --port 9820
可配 systemd 管理。checkpoint 持久化到 ./data/hippocampus.pt(相对于 mem 目录)。
未来方向
- 重要度衰减:长期不被召回的记忆自动降权
- 矛盾检测:新记忆与旧记忆冲突时自动替换
- 记忆整合(sleep consolidation):定期合并碎片记忆为更紧凑的表示
- 和 memory slot 融合:逐步迁移 slot 内容到 nocmem,最终淘汰 slot 系统