记忆子系统
把散落在各处的记忆收成一个门面:情节、偏好、画像、生命周期。 它的核心约束只有一条——自动写入不越过人工确认, 但「事实记录」与「可复用知识」被区别对待。
为什么需要一个门面
记忆不是一种东西。至少有三类,它们的写入时机、可信度、失效方式完全不同:
| 层 | 记什么 | 可信度 | 谁能改 |
|---|---|---|---|
| 情节 | 问过什么 → 生成了什么 SQL → 判定结果 → 改过几轮 | 事实记录,不声称正确 | 系统自动 |
| 偏好 | 用户表达过的长期口径与习惯 | 模型抽取,可能抽错 | 高置信直接生效,低置信待确认 |
| 知识 | 可复用的示例与教训 | 必须人工确认 | 草稿自动、生效靠人 |
在此之前这些记忆各写各的,读的地方也各取各的。门面 service.py:41 MemoryService 把它们收成一个读写口,统一失败不阻塞查询:任何一次记忆读写出问题, 都只落日志,不影响这次问答。
service.py:15 New automatic memory
源码的自述比常见转述更精确:自动写入的东西要么是 pending 草稿(等管理员确认),要么是自包含记录(只对产生它的那个用户可见、不会被当成知识复用)。
情节记忆属于后者——它是「当时发生了什么」的事实记录,按 (数据源, 用户) 隔离,不参与跨用户的知识复用。把它和「可复用的示例/教训」混为一谈,会得出「记忆子系统会自动污染知识库」的错误结论。
开关:渐进启用
记忆默认开在最保守的一档上,每一层都能单独关。
| 开关 | 默认 | 作用 |
|---|---|---|
| enabled | true | 总开关 |
| episodes | true | 情节记忆 |
| auto_examples | true | 成功问答 → pending 示例草稿 |
| auto_preferences | true | 会话压缩时抽取偏好 |
| promotion | false | 自动晋升(见下)——默认关 |
| promotion_threshold | 0.8 | 晋升所需置信度 |
| profile_boost | false | 把用户失败画像作为最低优先级提示注入 |
| schema_drift_check | true | 周期性对比实时 schema 与 KB |
| retention_days | 空 | 空 = 不清理 |
models.py:58 MemoryConfig models.py:21 MemoryScope models.py:33 MemoryEntry
注意 retention_days 的默认值语义:空字典意味着永不清理,
不是「用默认天数清理」。这是一个刻意的默认——数据保留策略应该由部署方明确声明,
而不是由一个库替你决定。
情节记忆:跨会话的「问过什么」
存在 ~/.trove/memory/episodes.sqlite,按
(datasource, user_id, question, sql) 去重,所以同一问题的反复提问不会堆成垃圾。
召回:词面与语义的 OR 门
检索走两路并联:词面相关度与向量余弦各设一道门槛, 任一门通过就召回。这个 OR 而不是 AND 的选择是有意的—— 两种失败模式不对称:漏掉一条有用经验,比多带一条不相关经验代价大得多。
| 常量 | 值 | 含义 |
|---|---|---|
| _LEXICAL_GATE | 0.5 | 词面门槛(纯词面部署时等同旧行为) |
| _COSINE_GATE | 0.55 | 语义门槛 |
| _COSINE_WEIGHT | 0.6 | 融合时语义通道权重 |
| _LEXICAL_WEIGHT | 0.4 | 融合时词面通道权重 |
| _SEARCH_WINDOW | 500 | 候选窗口 |
episode.py:68 _COSINE_WEIGHT episode.py:70 _LEXICAL_GATE episode.py:71 _COSINE_GATE
总分是 0.6·余弦 + 0.4·词面,再加一个温和的最近度衰减——
30 天半衰,权重只有 0.2。衰减刻意做得很轻:问数场景里,半年前的一条正确口径
通常仍然是对的,不该因为「旧」被埋掉。
episode.py:344 score episode.py:313 _decay
没有配置 embedding 的数据源退化为纯词面路径,不是报错也不是空结果—— 旧行为原样保留。这是这个仓库反复出现的模式:向量是增强,不是前提。
进上下文时是什么
召回的情节作为 episodes 块进生成上下文,但成功的和失败的用法相反:
成功的 SQL 作为少样本锚点,失败记录连同修正历史作为反例。只放成功的会让模型看不到边界,
只放失败的会让它学不到正确写法。
偏好:会话压缩时抽取
偏好的抽取时机是会话压缩,不是每一轮。理由很实际: 一次性请求和闲聊里根本没有持久偏好,只有一段对话的全局特征才能暴露「这个人习惯怎么看数」。
草稿存在 ~/.trove/memory/preferences.sqlite,由 LLM 抽取
(提示词模板 memory/preference_extract.{en,zh}.j2,跟随提问语言)。
preferences.py:243 extract_and_store preferences.py:44 HIGH_CONFIDENCE
两条出路,按置信度分
- 置信度 ≥ 0.9 —— 直接写进该用户的
user_facts,下一轮就生效。 - 低于阈值 —— 落成 pending 草稿,等管理员在
/admin/memory/preferences确认或拒绝。
为什么高置信可以直通?因为 user_facts 是按用户隔离的:
一条「我习惯看日均值」抽错了,影响的是这个人自己的下一次提问,不会污染别人,
也不会进知识库。这是「自包含」的一个实例——风险范围决定了要不要人工确认。
抽取前还有一道噪音过滤:闲聊、一次性请求、不含持久口径的句子被直接丢掉, 而不是低置信存起来。存下来只会让待确认列表变成噪音。
preferences.py:61 is_usable_preference preferences.py:275 HIGH_CONFIDENCE
自动晋升:默认关的那一档
这是整个子系统里唯一「自动把草稿变成生效知识」的通道,因此默认关闭。 它的机制是一个证据累加器:某条 pending 教训被反复印证,置信度就会涨, 越过阈值后自动确认。
| 证据类型 | 每次增量 | 当前接了吗 |
|---|---|---|
| repeated_correction | 0.3 | 已接 |
| upvote | 0.4 | 已接(POST /v1/kb/ratings 点赞) |
表里只有两条,是按「真的有产出方」筛过的:曾经还有一条
lesson_reuse_pass(教训被复用且当轮成功),但没有任何代码产生这种证据,
且与 repeated_correction 语义重叠(同一件事可能加两次分),已删。
登记一个没有产出方的证据类型,等于给这条通道留一个永远为 0 的加数。
教训在 lessons.yml 里有两种写法:失败蒸馏与人工追加按
pattern,用户投票按 question。证据的匹配键因此
两者都认,检索文本也把 question 计入——否则投票教案的相似度
恒为 0,票数加权会乘在一个零上。
promotion.py:27 _EVIDENCE_DELTA promotion.py:40 apply_evidence promotion.py:48 maybe_promote
晋升条件除阈值外还有一条:净好评 ≥ 3。两条满足其一即可。
晋升动作本身是调用 KbService.update_lesson_confidence——
写的是 lessons.yml 里的置信度字段,所以仍然可审计、可回退。
自动化的边界到此为止:它调整的是「这条已验证过的教训有多可信」,而不是无中生有一条知识。
kb/service.py:2246 update_lesson_confidence
一次问答结束后,写回什么
问答结束时的写回分四类,全部走 observe:
flowchart LR
RUN["一轮问答结束"]:::entry --> OB["observe 统一写回"]:::exec
OB --> EP["情节记录
自包含,不进确认门"]:::entry
OB --> DR["三类草稿(pending)
成功示例 · 纠正教训 · 蒸馏教训"]:::entry
DR --> G{"管理端确认"}:::gate
G -- "确认" --> KB[("KB 检索")]:::exec
G -- "拒绝" --> X["丢弃"]:::entry
EP --> C["进 episodes 上下文块
成功当锚点,失败当反例"]:::gen
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
| 触发条件 | 写什么 | 状态 |
|---|---|---|
| 每一轮(情节开启时) | 情节记录 | 自包含 |
| 判定为 OK / EMPTY 且有 SQL | 示例草稿 | pending |
| 出现过纠正 | 教训草稿 | pending |
| 硬失败 | LLM 蒸馏出的教训草稿 | pending |
第三、四类的区别在成本上:纠正历史是免费的——用户已经把正确答案说出来了, 直接记下即可;硬失败没有正确答案可参考,得花一次 LLM 调用把问题蒸馏成一条可复用的教训。 蒸馏结果还要过一道噪音过滤,避免把「这个问题本身就问得不清楚」记成教训。
lesson_distill.py:84 is_noise_lesson
门禁守在哪
「pending 不进检索」不是靠约定,是靠检索侧的过滤:
示例检索显式跳过 pending 标记的条目;教训列表默认只返回 confirmed
(list_lessons(confirmed_only=True))。写侧可以放心地多写草稿,
读侧永远只读已确认的。
kb/service.py:617 pending kb/service.py:1725 list_lessons
生命周期与漂移
一个周期任务负责清理与体检,间隔由配置决定,默认 24 小时一次。
- 保留清理——情节、偏好草稿、用户事实、检索日志四类各自按
retention_days过期。没配就不清,四个目标都遵守这一条。 - 结构漂移——对比实时 catalog 与 KB 的
schema_notes.yml,找出新增的表、消失的表、变更的列。纯代码,零 LLM。详见 数据漂移治理。
schema_drift.py:16 detect_drift
画像:用户 × 数据源
按 (用户, 数据源) 汇总正确率、失败模式与已提交事实,供管理员查看。
它也可以反过来影响生成——把「这个人在这张表上反复失败的模式」作为提示注入。 但这条默认关闭,且优先级排在最末(priority 10),只有在预算还有余量时才进得去。 顺序本身就是态度:画像是一种弱信号,不该挤掉规则和术语。
接在哪
| 位置 | 做什么 |
|---|---|
| 装配 | 构造门面并注入工作流服务集 |
| 生成节点 | 读情节块进上下文 |
| 会话层 | 一轮结束后调 observe;无门面时回落到旧的单路径捕获 |
| API 启动 | 周期任务跑 run_lifecycle |
| 管理端 | 画像与偏好草稿的查看/确认/拒绝 |
session.py:1986 _observe_memory session.py:1570 _capture_lessons app.py:212 _periodic_sweep
「让记忆自动生效、不用人工确认」看起来是个明显的改进,实际上会把知识库变成模型输出的回音室——一旦某条错误教训被自动确认,它会持续影响后续所有生成,而且没有人知道它是什么时候进来的。
情节记忆是例外不是漏洞:它记录事实、按用户隔离、不参与知识复用。这条边界值得保持。