nuc · xiaozhi-esp32-server · mem0_local

记忆链路时序

整条链路分成两条互不干扰的路径。读路径在每轮对话里跑, 毫秒级、一次 LLM 调用都不能加;写路径在连接断开后跑, 慢一点没关系,所有需要判断力的活都堆在这里。 这条分界线决定了每个功能该放在哪一环。
更新至 2026-09-02:安全约束不再每轮无条件注入; 身份层已落地,Claude Code 和设备读写同一份记忆;多了一个记忆控制台。

读路径 · 每轮对话

一句话进来,记忆怎么被查出来

从设备说话到模型拿到记忆,中间只有两次 qdrant 往返,全程约 5 毫秒。 这里没有任何 LLM 调用——加一次就等于把对话延迟押在模型上。

StackChan server mem0_local qdrant gpt-5.4-mini listen/detect 文本 query_memory(query) ① 向量检索 · top-5 · 阈值 0.38 bge-base-zh · 768 维 · Cosine 命中若干条 ② 丢掉过期的 plan 类 ③ scroll 该 person 全量记忆 ④ 命中的安全类 → 提升进安全组 + 同实体补齐:关系词 → 专名 → 再捞 ⑤ 安全组同主题合并(纯字面,无 LLM) [安全约束] + [相关记忆] system prompt 里的 <memory> 标签 回复 TTS 音频 ① ~ ⑤ 合计约 5ms(检索 3.2ms + 全量扫描 3.2ms,779 条记忆实测)
读路径。两次 qdrant 往返:一次向量检索,一次全量扫描(供同实体补齐)。 安全类不再无条件全取——它和其它记忆一样参与向量检索,只有被命中才提升进安全组。 这一改是 2026-09-02 做的,理由见下。
向量检索 阈值 0.38 是实测出来的最优点。选 bge-base 而不是 large,因为收益不在"答案分更高",而在无关记忆的分数被压得更低——噪声上限从 0.44 降到 0.37,阈值才有了余量。
丢掉过期的计划 「下周三要去杭州出差」作为"我说过这句话"永远为真,作为安排早就该退场。只有 decay=time 的条目有到期日,事实和偏好不参与。
安全类:从「无条件注入」改成「命中才提升」 过敏、忌口、用药、旧伤——这些和当前问题的语义距离可能很远(问「带家人吃饭注意什么」,库里存的是「老婆海鲜过敏」),但漏掉的代价是给出让人受伤的建议。所以最初的做法是全量扫描、绕开向量和阈值,每轮无条件注入。后来废掉了:安全约束恰恰是库里最私密的一档,却成了唯一每轮广播的东西——放首歌、写段代码,这些也跟着进上下文。现在改成「被检索到才提升进安全组」,兜不住的尾巴交给 check_safety 工具。同实体补齐紧随其后:从问题里取关系词捞一轮,再从结果里提出专名(「女儿叫小橙子」→ 小橙子)捞第二轮,接上抽取时改用名字造成的断链。
④′
为什么闸门只能交给语言模型 既然不再全量注入,就得有个"这轮要不要看安全约束"的判断。先试了纯向量闸门,量完发现走不通——6 条安全记忆 × 17 句查询,两种比法都是负间隙
· query ↔ 安全记忆:斜相关最低 0.343「推荐个餐厅」低于无关最高 0.384「把灯关了」
· query ↔ 话题原型:0.477「晚上下碗面条」低于0.497「现在几点」
bge 在短句上的底噪本身就有 0.25~0.40,没有任何阈值能同时放过前者、挡住后者。所以闸门必须由模型判断,落地成 check_safety 工具。而工具描述里列的是话题清单,不是"你觉得需要的时候"——清单本身不含隐私,只说哪些话题要查。
同主题合并,而且不用 LLM 「这几天在减肥」被「减肥期间晚上不吃主食」字面完全覆盖,是纯冗余,不需要语义理解。安全组一旦命中就带强制规则,越长越挤占模型对相关记忆的注意力。判据是字符覆盖率 ≥ 80%,「花生过敏 vs 海鲜过敏」只有 50% 重叠,不会误合。
分两组渲染,去重方向不能反 提示词里只有 [安全约束] 组带"与请求冲突就必须先说"的强制规则。所以安全类始终留在安全组,由渲染方把它从"相关记忆"里剔掉——反过来写,安全记忆一旦被向量命中就会掉出安全组、失去强制规则。这个方向写反过一次,代价是一道题连败七轮。
写路径 · 连接断开后

说过的话,怎么变成一条记忆

这里有三次 LLM 调用,都不在对话的关键路径上。 设备已经断开,慢几十秒没人等——所有需要判断力的活都该堆在这一侧。

server mem0_local mem0.add() qdrant gpt-5.4-mini ⚡ 设备断开连接 save_memory(整段对话) add(messages) 阶段一 · 抽取 定制中文 prompt:只取陈述句,疑问句与祈使句一律不记 facts[] 查该用户已有记忆 阶段二 · 归并 定制 UPDATE prompt:ADD 逐字照抄,不许当文字编辑 [{id, text, event}] ← 最终入库文本在这一步定 写入向量 + payload 本轮新增/更新的条目(0~3 条) 打标:cls / expires / subject(一次批量) 按【原文】对齐回下标,不信模型给的 i ↳ 调用失败 → 降级规则表;规则判 safety 时取并集 PATCH payload:mem_class · decay · expires_at · source 三次 LLM 调用全在这一侧 · 设备已断开,没人在等
写路径。mem0.add() 是两阶段的,而最终入库的文本来自阶段二—— 即使 event 是 ADD,那条文本也已经被归并阶段重写过一遍。
两阶段:文本的决定权在后面那一步 这是排查了三轮才找到的地方。阶段一稳定抽出「从事智能硬件相关工作」,库里 ADD 进去的却是「平时做智能硬件」——丢了"工作"二字,于是「我是做什么工作的」这一问召回 0 条。只定制抽取提示词是不够的,归并那一步也必须接管,第一条规则就是"你不是编辑,ADD 逐字照抄"。
打标为什么在写入侧 判断"这条算不算安全事实"需要真正的语义理解——规则关键词表只认得 15 条里的 6 条,漏掉的全是「在吃降压药」「膝盖伤了不能跑步」这种用后果描述的表达。但这个判断放在读路径会拖慢每一轮对话,所以放在写入侧:一次批量调用覆盖本轮全部新条目,通常 0~3 条。
用原文对齐,不信模型返回的下标 第一版让模型输出 {"i": 0, ...},结果整体偏了一位:「幽闭恐惧症」拿到了下一条的到期日。标签错配比标不上更糟——前者是静默的错误数据。改成要求原样回显原文、用文本匹配对齐后,准确率 19/23 → 23/23。

一条记忆的一生

拿「我老婆对海鲜过敏,尤其是虾」走一遍完整流程。

时刻发生了什么路径
说出口 设备把这句话送到 server,正常走对话流程。此刻它还不是记忆。
断开后 阶段一抽出 老婆对海鲜过敏,尤其是虾,一吃就起疹子;阶段二判定为 ADD、逐字保留;写进 qdrant。
紧接着 打标调用判定 cls=safetydecay=volume(永不过期)、subject=老婆,PATCH 进 payload。
几天后 用户问「周末带家里人出去吃饭要注意什么」。向量分 0.43 过了 0.38 的阈值,被检索到——因为它是 safety,提升进安全组而不是混在相关记忆里。分数够不着的场景(实测「推荐个餐厅」只有 0.343)则由模型调 check_safety 工具补上。 兜底
同一轮 渲染进 [安全约束] 组,带上"与请求冲突就必须先说"的强制规则。
回答 「先避开海鲜,尤其虾,家里那位要是碰到就容易起疹子」——用户从没在这轮提过"海鲜"两个字。
怎么验证

两层评测,因为答错有两种错法

同一道题答错,可能是记忆压根没被捞出来,也可能是捞出来了模型没用。 这两件事要改的地方完全不同,所以分两层测。 安全约束改成按需取之后又多了第三层——那一改把"会不会漏"从设计保证变成了经验问题,只能测。

L1
检索层 · 只问「捞出来了吗」 在容器里直接调 mem0 的 add / search,不过 LLM 生成。搜索时不设阈值、取 top-10 连分数一起存下来,阈值在离线阶段扫描——一次注入就能画出整条阈值曲线,不用为每个阈值重跑。秒级出结果,而且是纯几何计算,同一模型跑两次完全一样。
L2
端到端 · 问「最终这句话答得对不对」 模拟设备连 ws://…:8000,走真实的 auth 三元组 + hello 握手 + listen/detect 文本上报,和真机走的是同一条 startToChat每道题单独建一次连接——同一个连接里问不算数,上下文里本来就有;断开重连才是"过了几天再问",那时唯一的信息来源只剩记忆库。判分交给另一个模型,判据写死在提示词里。

题集:照搬 LongMemEval 的能力分类,重写中文口语语料

业界现成的三个(LoCoMo / LongMemEval / BEAM)都是英文长对话,而且假设"把整段历史喂给系统"。 这条链路是中文口语短句、每轮真要过一次 TTS(端到端 8~15 秒一轮),500 题跑两小时还得烧一遍语音合成的钱。 所以借它的五类能力,另加两类设备场景天天遇到、它没有的。

能力题数考什么
信息抽取4说过一次的事实,隔了几天还记得
时序推理2「下周三」「这周四晚上」这类相对时间
知识更新2搬家、改课时间之后,答的是新值不是旧值
多会话推理2把不同天说的事串起来用
隐式检索3自加。用户不会说"我老婆海鲜过敏,所以…",他会直接说"订个海鲜礼包送我老婆"——记忆得在没有关键词的时候自己冒出来
拒答3没说过的事要承认不知道,不能编
噪声抗性3自加。问天气、点歌时不该把私人信息捞进上下文——召回无关记忆比没有记忆更糟,模型会硬去找关联
当前端到端 · 含注入的完整一轮
信息抽取
4/4
时序推理
2/2
知识更新
2/2
多会话推理
1/2
拒答
2/3
噪声抗性
3/3
隐式检索
2/3
16/19 2026-09-02 实测。三个失败全在生成侧,不是召回侧——用控制台的召回预览逐个核对过: M2「给女儿买生日礼物」的女儿特别喜欢恐龙相似度 0.486 排第一、已注入,模型只说了名字和年龄; I3「周四能不能约饭」的每周四晚上有吉他课 0.522、已注入,模型答的是主食和海鲜。 A2 该拒答却说"记得不够完整",既没否认也没反问。
这个分数和上面几轮不严格可比,有两个原因: ① 判分器从 DeepSeek 换成了 gpt-5.4-mini(前者账户余额不足)——判据一个字没改,但打分的人换了; ② 这是含注入跑的完整一轮,不是下面 A/B 用的固定快照, 所以抽取措辞的漂移也算在里面(本节末尾那条方法学说明讲的就是这个)。 要跟历史数字对比,得用固定快照 + 只重跑提问阶段。

第三层:安全闸门 · 该说的还说不说,不该说的会不会漏出去

原来安全类每轮无条件注入,"会不会漏"不用测——它永远在。改成 「命中才提升 + 模型按需调工具」之后,这就成了经验问题。 15 道题分三类,判分标准各不相同

类别判分标准结果
SURFACE
有一条约束明确适用
必须出现在回答里。不看有没有调工具——向量先捞到也算对,那样还更快。 6/6
LOOK
话题相关但没哪条约束适用
不能要求它提约束,提了反而违反"不冲突就不要主动提起"。只认它查过 3/3
QUIET
放歌/写代码/天气
约束一个字都不该出现,也不该调工具。 6/6
最容易写错的是评测本身,而不是被测的东西 第一版把 LOOK 那类当成 SURFACE:「晚上给我自己下碗面条」——女朋友的过敏、妈妈的用药都不关这碗面的事,模型正确地没提,却被我判成失败。判据本身有 bug,会把对的改成错的。
顺带一个数字:改成按需取之后,放首歌写代码 这类的注入从原来每轮固定几十个 token 变成 0 字符
控制台的召回预览,是分清两种错法的工具 L2 那三个失败之所以能判定为"生成侧",靠的是控制台的召回预览:它同时给出最终注入 system prompt 的完整文本同一 query 的裸向量明细(分数、阈值刻度、是否过期、类别与来源)。少了后半截,一条记忆没被用上就只能靠猜——这正是"去重方向写反"那次连败七轮的根因:日志只说"召回 N 条",看不见它落在哪一组。

哪些改动是真的有用

下面每一组都是固定同一份记忆快照、只切换一个变量跑出来的,所以因果成立。 不带这个前提的分数对比一律不可信——原因见本节末尾。

换 embedding:small → base,阈值 0.45 → 0.38 同一份记忆文本,15/19 → 18/19,三题转正、零回归。真正的收益不在"答案分更高",而在无关记忆的分数被压低了:噪声题的最高无关分从 0.32~0.44 降到 0.28~0.37,阈值这才有了余量。纯向量层实测 small 16/19、base 18/19、large 17/19——base 比 large 好,选 embedding 要看信噪比不是参数量。
实体聚合开关(entity_limit 0 → 4)
关闭 「五岁小朋友的话,恐龙主题很稳…」
开启 「给小橙子选的话,恐龙类准没错…」
问「给我女儿买生日礼物」。抽取把「女儿叫小橙子 / 今年五岁 / 喜欢恐龙」拆成三条原子事实之后,名字那条几乎不携带语义,向量捞不到——把 top-k 从 5 提到 8 也无效,证明不是取几条的问题。
接管归并阶段的提示词(各跑 3 次) 3/3 稳定产出的条目从 8 条增加到 10 条;「每周四晚上有吉他课」从 1/3 变 3/3。没有这份提示词时,阶段一好不容易抽出的「从事智能硬件相关工作」会被归并阶段改写成「平时做智能硬件」,丢掉"工作"二字,于是那一问召回 0 条。
安全分类:关键词表 → 模型判断 同一批 23 条测试用例,规则表只认得 6/15 条安全事实,模型 23/23。规则漏掉的全是用后果描述的表达——「在吃降压药」「膝盖伤了不能跑步」「吃海鲜会吐」「不能熬夜会心悸」。根子是记忆内容是开放集合,而关键词表假设你能穷举
去重方向写反 —— 一道题连败七轮 「晚上给我下碗面条吧」始终不提减肥。我一直归因为"模型不用记忆",直到打印出实际注入的完整文本才看清:那条记忆排在召回结果第一位,但落在 [相关记忆] 组而不是 [安全约束] 组——因为它被向量命中了,就从安全组里被剔除。而强制规则只挂在安全组上。改一行方向,立刻通过。日志只说"召回 N 条",不说召回的是什么、进了哪一组;排查召回问题必须打印注入原文。
规模压测:灌到 779 条噪声 没有兜底的普通事实仍然 6/6 全中,噪声零误召,向量检索 2.9ms → 3.2ms、全量扫描 1.9ms → 3.2ms。规模比预想的宽得多。只有分数本身就低的条目会在 ~300 条时被挤出 top-5——当时它恰好是安全类,走全量扫描不受影响,那是运气好,不是设计好。而安全类改成"命中才提升"之后,这份运气就用完了:它现在也要跟别人抢 top-5 的名额,兜底改由 check_safety 工具承担。规模再涨的话,这一条是要重测的。
安全约束:无条件注入 → 命中才提升(隐私换来的,不是分数) 这一改不是为了提分,是为了不再把最私密的一档每轮广播出去。所以要问的是"代价多大"。量完发现代价很小:拿 6 条典型安全记忆 × 17 句查询实测,斜着相关的 8 句里普通向量检索就能捞到 7 句(带家人吃饭/买零食/想去跑步/点外卖…),无关的 9 句挡掉 8 句。真正兜不住的只有「推荐个餐厅」这类(0.343),交给工具。
换来的:放首歌写代码 的注入从每轮固定几十 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。
⚠ 一个方法论错误,早期几轮的分数因此不可比 这套题最初每轮都重新注入语料,而抽取的措辞每次都不同——同一句话这轮抽成「从事智能硬件相关工作」、下轮抽成「平时做智能硬件」,后者的向量分就掉到阈值以下。 于是分数在 15~19 之间起伏,我一度把措辞漂移当成了改动的效果。 改用固定快照 + 只重跑提问阶段之后,两次独立运行 19 道题的判定完全一致,评测才真正可复现。 上面每一组 A/B 都是在这个前提下跑的。
设计中 · 尚未实现

让 Claude、GPT 也用上同一份记忆

已经落地了(2026-09-01)。难点不在存储,在身份: 设备用 MAC 认人,而 Claude Code 和 openclaw 都没有 MAC。

改造前 StackChan provider(进程内) qdrant 隔离键 = 44:1b:f6:e4:79:70 ← 一台设备就是一个人 现在(已上线) StackChan Claude Code openclaw ChatGPT memory-service MCP over HTTP 复用同一份检索逻辑 qdrant 隔离键 = person_id = "tank" 设备 MAC 降级成来源之一: source = device:44:1b… / claude-code / …
改动比看上去小:payload 里已经有 source 字段了,只需要把隔离键从 MAC 换成 person, 设备 MAC 退化成众多来源中的一个。 多设备/多用户靠 person_map(设备 id → person)分流——这同时是评测隔离的入口: 测试设备映射到 person bench,跟真人的库互不可见(实测另一 person 的设备 query_memory 返回空、日志里没有召回记录)。改完这张表记得清 Redis—— 智控台缓存配置,不清就还是旧值,而且不报错。
Claude Code memory-service qdrant StackChan 电脑上写下的 → 设备端问得出来 Stop 钩子 会话结束,自动抽取这次说过的事 写入 person_id=tank · source=claude-code 几天后 · 换台设备 provider · 每轮 语音提问,检索无条件发生 跨 source 检索 命中电脑上写下的那条 → 设备答得出来 设备记下的 → 回到电脑前也在 provider · 断连时 抽取这次对话里的事实 写入 person_id=tank · source=device:44:1b… 回到电脑前 UserPromptSubmit 钩子 每次提问前,无条件注入 设备记下的事,在电脑上照样看得到 四个箭头的触发者都是钩子或 provider —— 没有一个依赖模型自己想起来要用记忆
两个方向都成立。但实际落地的只有工具那一半—— 设备侧本来就是每轮查、断连抽(provider 触发,不依赖模型); Claude Code 侧目前只挂了 MCP 工具,图里那两个钩子没有做: Claude Code 会话里几乎全是代码和命令,实测既有的 115 条记忆里 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 工具 你 · 说「记住这个」时

第一行是设备侧的主力。注入比工具多一个好处:它能把安全约束放进 系统提示词的固定位置,而不是作为一次工具返回值混在对话里—— 也就是 [安全约束] 那一组带着"必须先说"的强制规则。工具返回值拿不到这个待遇, 这也是为什么被命中的安全类要提升进那一组、而不是留在相关记忆里。

MCP 是框架级全局的,跨 person 写入吃过一次 服务端 MCP 走 data/.mcp_server_settings.json,那是框架级配置、不分 agent,而工具调用不携带设备身份——所以这个进程的 PERSON_ID 就是所有调用方的读写目标。
2026-09-02 跑 L2 时,隔离身份 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 看不到/删不掉
开 qdrant 鉴权会连带炸掉整个记忆功能 给 qdrant 配上 API key 之后,provider 起不来:[SSL] record layer failure
原因是 QdrantClient 一看到 api_key 就默认把连接切成 HTTPS(它假定"要鉴权=走公网=有 TLS"),而容器网络里跑的是明文。报的是 SSL 错、看着像证书问题,实际是协议选错了——而且表现为记忆功能整个停用,不是某次查询失败。
mem0 的 QdrantConfig 不收 https 字段,只能用 url 显式钉死协议;而 urlhostpath 三者互斥,给了 url 就得把 hostport 摘掉。

三个客户端不在一个安全档位上

客户端连接方式暴露面
Claude Code HTTP + Bearer,或本地 stdio 局域网就够
openclaw 同上 局域网就够
ChatGPT 只接受公网 HTTPS 的远程 MCP,不支持本地 stdio 记忆库须公网可达
ChatGPT 这一步值得单独决策 前两个客户端在局域网内就能跑通,风险几乎为零。而为了接 ChatGPT,得把这个库放到公网可达的位置——里面是过敏史、用药、住址、家人信息。三个选项:Cloudflare Tunnel + Access(不开端口,隧道出去,带鉴权);只读且脱敏(公网那份只给检索、且过滤掉 safety 类,最敏感的不出门);或者先不接,需要时手动贴。
写入要按来源分级 你在 Claude Code 里明确打出来的一句话,和设备 ASR 从嘈杂环境里转出来的一句话,置信度不是一回事。混着写,早晚会出现"打字记下的东西被听错的内容覆盖"。所以 source 不只是溯源字段,冲突时它决定谁说了算。