整条链路分成两条互不干扰的路径。读路径在每轮对话里跑,
毫秒级、一次 LLM 调用都不能加;写路径在连接断开后跑,
慢一点没关系,所有需要判断力的活都堆在这里。
这条分界线决定了每个功能该放在哪一环。
更新至 2026-09-02:安全约束不再每轮无条件注入;
身份层已落地,Claude Code 和设备读写同一份记忆;多了一个记忆控制台。
从设备说话到模型拿到记忆,中间只有两次 qdrant 往返,全程约 5 毫秒。 这里没有任何 LLM 调用——加一次就等于把对话延迟押在模型上。
decay=time 的条目有到期日,事实和偏好不参与。
check_safety 工具。同实体补齐紧随其后:从问题里取关系词捞一轮,再从结果里提出专名(「女儿叫小橙子」→ 小橙子)捞第二轮,接上抽取时改用名字造成的断链。
0.343「推荐个餐厅」低于无关最高 0.384「把灯关了」
0.477「晚上下碗面条」低于0.497「现在几点」
check_safety 工具。而工具描述里列的是话题清单,不是"你觉得需要的时候"——清单本身不含隐私,只说哪些话题要查。
这里有三次 LLM 调用,都不在对话的关键路径上。 设备已经断开,慢几十秒没人等——所有需要判断力的活都该堆在这一侧。
mem0.add() 是两阶段的,而最终入库的文本来自阶段二——
即使 event 是 ADD,那条文本也已经被归并阶段重写过一遍。
{"i": 0, ...},结果整体偏了一位:「幽闭恐惧症」拿到了下一条的到期日。标签错配比标不上更糟——前者是静默的错误数据。改成要求原样回显原文、用文本匹配对齐后,准确率 19/23 → 23/23。
拿「我老婆对海鲜过敏,尤其是虾」走一遍完整流程。
| 时刻 | 发生了什么 | 路径 |
|---|---|---|
| 说出口 | 设备把这句话送到 server,正常走对话流程。此刻它还不是记忆。 | 读 |
| 断开后 | 阶段一抽出 老婆对海鲜过敏,尤其是虾,一吃就起疹子;阶段二判定为 ADD、逐字保留;写进 qdrant。 |
写 |
| 紧接着 | 打标调用判定 cls=safety、decay=volume(永不过期)、subject=老婆,PATCH 进 payload。 |
写 |
| 几天后 | 用户问「周末带家里人出去吃饭要注意什么」。向量分 0.43 过了 0.38 的阈值,被检索到——因为它是 safety,提升进安全组而不是混在相关记忆里。分数够不着的场景(实测「推荐个餐厅」只有 0.343)则由模型调 check_safety 工具补上。 |
兜底 |
| 同一轮 | 渲染进 [安全约束] 组,带上"与请求冲突就必须先说"的强制规则。 |
读 |
| 回答 | 「先避开海鲜,尤其虾,家里那位要是碰到就容易起疹子」——用户从没在这轮提过"海鲜"两个字。 | 读 |
同一道题答错,可能是记忆压根没被捞出来,也可能是捞出来了模型没用。 这两件事要改的地方完全不同,所以分两层测。 安全约束改成按需取之后又多了第三层——那一改把"会不会漏"从设计保证变成了经验问题,只能测。
ws://…:8000,走真实的 auth 三元组 + hello 握手 + listen/detect 文本上报,和真机走的是同一条 startToChat。每道题单独建一次连接——同一个连接里问不算数,上下文里本来就有;断开重连才是"过了几天再问",那时唯一的信息来源只剩记忆库。判分交给另一个模型,判据写死在提示词里。
业界现成的三个(LoCoMo / LongMemEval / BEAM)都是英文长对话,而且假设"把整段历史喂给系统"。 这条链路是中文口语短句、每轮真要过一次 TTS(端到端 8~15 秒一轮),500 题跑两小时还得烧一遍语音合成的钱。 所以借它的五类能力,另加两类设备场景天天遇到、它没有的。
| 能力 | 题数 | 考什么 |
|---|---|---|
| 信息抽取 | 4 | 说过一次的事实,隔了几天还记得 |
| 时序推理 | 2 | 「下周三」「这周四晚上」这类相对时间 |
| 知识更新 | 2 | 搬家、改课时间之后,答的是新值不是旧值 |
| 多会话推理 | 2 | 把不同天说的事串起来用 |
| 隐式检索 | 3 | 自加。用户不会说"我老婆海鲜过敏,所以…",他会直接说"订个海鲜礼包送我老婆"——记忆得在没有关键词的时候自己冒出来 |
| 拒答 | 3 | 没说过的事要承认不知道,不能编 |
| 噪声抗性 | 3 | 自加。问天气、点歌时不该把私人信息捞进上下文——召回无关记忆比没有记忆更糟,模型会硬去找关联 |
女儿特别喜欢恐龙相似度 0.486 排第一、已注入,模型只说了名字和年龄;
I3「周四能不能约饭」的每周四晚上有吉他课 0.522、已注入,模型答的是主食和海鲜。
A2 该拒答却说"记得不够完整",既没否认也没反问。
原来安全类每轮无条件注入,"会不会漏"不用测——它永远在。改成 「命中才提升 + 模型按需调工具」之后,这就成了经验问题。 15 道题分三类,判分标准各不相同。
| 类别 | 判分标准 | 结果 |
|---|---|---|
SURFACE有一条约束明确适用 |
必须出现在回答里。不看有没有调工具——向量先捞到也算对,那样还更快。 | 6/6 |
LOOK话题相关但没哪条约束适用 |
不能要求它提约束,提了反而违反"不冲突就不要主动提起"。只认它查过。 | 3/3 |
QUIET放歌/写代码/天气 |
约束一个字都不该出现,也不该调工具。 | 6/6 |
LOOK 那类当成 SURFACE:「晚上给我自己下碗面条」——女朋友的过敏、妈妈的用药都不关这碗面的事,模型正确地没提,却被我判成失败。判据本身有 bug,会把对的改成错的。放首歌/写代码 这类的注入从原来每轮固定几十个 token 变成 0 字符。
下面每一组都是固定同一份记忆快照、只切换一个变量跑出来的,所以因果成立。 不带这个前提的分数对比一律不可信——原因见本节末尾。
entity_limit 0 → 4)
[相关记忆] 组而不是 [安全约束] 组——因为它被向量命中了,就从安全组里被剔除。而强制规则只挂在安全组上。改一行方向,立刻通过。日志只说"召回 N 条",不说召回的是什么、进了哪一组;排查召回问题必须打印注入原文。
check_safety 工具承担。规模再涨的话,这一条是要重测的。
放首歌/写代码 的注入从每轮固定几十 token 变成 0 字符。
check_safety,但「带家人出去吃饭要注意什么」不会——只泛泛说了句"有忌口过敏的先说清楚",完全不知道真有花生过敏。光把触发条件写在工具描述里不够。<tool_and_knowledge> 里原有的那句「Only call a tool when the user's request clearly matches one of them」,它和"这类话题必须先查"直接冲突。把触发清单同时写进 <memory_usage> 和必调列表之后,15/15。
已经落地了(2026-09-01)。难点不在存储,在身份: 设备用 MAC 认人,而 Claude Code 和 openclaw 都没有 MAC。
source 字段了,只需要把隔离键从 MAC 换成 person,
设备 MAC 退化成众多来源中的一个。
多设备/多用户靠 person_map(设备 id → person)分流——这同时是评测隔离的入口:
测试设备映射到 person bench,跟真人的库互不可见(实测另一 person 的设备
query_memory 返回空、日志里没有召回记录)。改完这张表记得清 Redis——
智控台缓存配置,不清就还是旧值,而且不报错。
type: user 的是零条,为这个密度做全会话自动抽取不划算。
所以那一侧是「你明说要记,才写」。取舍见下。
设备侧读写都由 provider 无条件触发;Claude Code 侧只有工具,也就是 由模型/由你决定。这不是没做完,是密度不同: 设备侧的对话是生活化的、个人事实密度高;Claude Code 侧几乎全是代码和命令。
memory_search。这正是设备侧 M1、I1 那几道题考的东西。实测过一次代价:安全约束改成按需取之后,「带家人出去吃饭要注意什么」不会触发 check_safety,模型只泛泛说了句"有忌口过敏的先说清楚"。只把触发条件写在工具描述里不够,必须同时写进系统提示词的 <memory_usage> 和 <tool_and_knowledge>——后者原来那句"只在明确匹配时才调工具"正好是反作用。补完之后 15/15。
| 设备侧 | Claude Code 侧 | 谁决定要不要执行 |
|---|---|---|
每轮 query_memory |
UserPromptSubmit 钩子 |
系统 · 无条件注入 |
断连时 save_memory |
Stop / SessionEnd 钩子未做 |
系统 · 无条件抽取 |
check_safety 工具 |
memory_search 工具 |
模型 · 判断话题相关时 |
| — | memory_add 工具 |
你 · 说「记住这个」时 |
第一行是设备侧的主力。注入比工具多一个好处:它能把安全约束放进
系统提示词的固定位置,而不是作为一次工具返回值混在对话里——
也就是 [安全约束] 那一组带着"必须先说"的强制规则。工具返回值拿不到这个待遇,
这也是为什么被命中的安全类要提升进那一组、而不是留在相关记忆里。
data/.mcp_server_settings.json,那是框架级配置、不分 agent,而工具调用不携带设备身份——所以这个进程的 PERSON_ID 就是所有调用方的读写目标。bench 的测试设备把 19 条虚构测试语料经由 memory_add 写进了真人的库,而且读不出异常——从库的角度那就是一次正常写入,是对着 source 统计才看出来的。:9100 给 Claude Code(全套),:9101 给设备(只有 check_safety)。设备本来就有断连自动写记忆的通道,根本不需要 memory_add。读取侧的洞还在——check_safety 仍返回该实例 person 的约束,不看调用方是谁;单人多设备没问题,多用户仍是洞。
库里是过敏、用药、住址、家人关系,泄露一次收不回来。而内网里有 ESP32、 访客手机、各种自动化脚本——所以不按来源 IP 放行,内外一视同仁。 现在三道入口,每道都要凭据:
| 入口 | 凭据 | 实测 |
|---|---|---|
qdrant :6333 |
QDRANT__SERVICE__API_KEY |
裸 curl → 401 |
MCP :9100 / :9101 |
Authorization: Bearer,两份 token 分权 |
无 token → 401 |
控制台 :9200 |
账号密码 → HMAC 签名会话 | 跨 person 看不到/删不掉 |
[SSL] record layer failure。QdrantClient 一看到 api_key 就默认把连接切成 HTTPS(它假定"要鉴权=走公网=有 TLS"),而容器网络里跑的是明文。报的是 SSL 错、看着像证书问题,实际是协议选错了——而且表现为记忆功能整个停用,不是某次查询失败。QdrantConfig 不收 https 字段,只能用 url 显式钉死协议;而 url/host/path 三者互斥,给了 url 就得把 host、port 摘掉。
| 客户端 | 连接方式 | 暴露面 |
|---|---|---|
| Claude Code | HTTP + Bearer,或本地 stdio | 局域网就够 |
| openclaw | 同上 | 局域网就够 |
| ChatGPT | 只接受公网 HTTPS 的远程 MCP,不支持本地 stdio | 记忆库须公网可达 |
source 不只是溯源字段,冲突时它决定谁说了算。