首页/Agent 能力/记忆子系统

记忆子系统

把散落在各处的记忆收成一个门面:情节、偏好、画像、生命周期。 它的核心约束只有一条——自动写入不越过人工确认, 但「事实记录」与「可复用知识」被区别对待。

为什么需要一个门面

记忆不是一种东西。至少有三类,它们的写入时机、可信度、失效方式完全不同:

层记什么可信度谁能改
情节 问过什么 → 生成了什么 SQL → 判定结果 → 改过几轮 事实记录,不声称正确 系统自动
偏好 用户表达过的长期口径与习惯 模型抽取,可能抽错 高置信直接生效,低置信待确认
知识 可复用的示例与教训 必须人工确认 草稿自动、生效靠人

在此之前这些记忆各写各的,读的地方也各取各的。门面 service.py:41 MemoryService 把它们收成一个读写口,统一失败不阻塞查询:任何一次记忆读写出问题, 都只落日志,不影响这次问答。

service.py:15 New automatic memory

「pending 或自包含」不是「一律 pending」

源码的自述比常见转述更精确:自动写入的东西要么是 pending 草稿(等管理员确认),要么是自包含记录(只对产生它的那个用户可见、不会被当成知识复用)。

情节记忆属于后者——它是「当时发生了什么」的事实记录,按 (数据源, 用户) 隔离,不参与跨用户的知识复用。把它和「可复用的示例/教训」混为一谈,会得出「记忆子系统会自动污染知识库」的错误结论。

开关:渐进启用

记忆默认开在最保守的一档上,每一层都能单独关。

开关默认作用
enabledtrue总开关
episodestrue情节记忆
auto_examplestrue成功问答 → pending 示例草稿
auto_preferencestrue会话压缩时抽取偏好
promotionfalse自动晋升(见下)——默认关
promotion_threshold0.8晋升所需置信度
profile_boostfalse把用户失败画像作为最低优先级提示注入
schema_drift_checktrue周期性对比实时 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) 去重,所以同一问题的反复提问不会堆成垃圾。

episode.py:114 EpisodeStore

召回:词面与语义的 OR 门

检索走两路并联:词面相关度与向量余弦各设一道门槛, 任一门通过就召回。这个 OR 而不是 AND 的选择是有意的—— 两种失败模式不对称:漏掉一条有用经验,比多带一条不相关经验代价大得多。

常量值含义
_LEXICAL_GATE0.5词面门槛(纯词面部署时等同旧行为)
_COSINE_GATE0.55语义门槛
_COSINE_WEIGHT0.6融合时语义通道权重
_LEXICAL_WEIGHT0.4融合时词面通道权重
_SEARCH_WINDOW500候选窗口

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

两条出路,按置信度分

为什么高置信可以直通?因为 user_facts 是按用户隔离的: 一条「我习惯看日均值」抽错了,影响的是这个人自己的下一次提问,不会污染别人, 也不会进知识库。这是「自包含」的一个实例——风险范围决定了要不要人工确认。

抽取前还有一道噪音过滤:闲聊、一次性请求、不含持久口径的句子被直接丢掉, 而不是低置信存起来。存下来只会让待确认列表变成噪音。

preferences.py:61 is_usable_preference preferences.py:275 HIGH_CONFIDENCE

自动晋升:默认关的那一档

这是整个子系统里唯一「自动把草稿变成生效知识」的通道,因此默认关闭。 它的机制是一个证据累加器:某条 pending 教训被反复印证,置信度就会涨, 越过阈值后自动确认。

证据类型每次增量当前接了吗
repeated_correction0.3已接
upvote0.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;
图 1 · 写回分两条路:情节是自包含的事实记录(直接可召回),其余三类是 pending 草稿(确认后才进检索)。「pending 或自包含」说的就是这条分叉。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

service.py:185 observe

触发条件写什么状态
每一轮(情节开启时)情节记录自包含
判定为 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 小时一次。

service.py:405 run_lifecycle

schema_drift.py:16 detect_drift

画像:用户 × 数据源

按 (用户, 数据源) 汇总正确率、失败模式与已提交事实,供管理员查看。

profile.py:17 build_profile

它也可以反过来影响生成——把「这个人在这张表上反复失败的模式」作为提示注入。 但这条默认关闭,且优先级排在最末(priority 10),只有在预算还有余量时才进得去。 顺序本身就是态度:画像是一种弱信号,不该挤掉规则和术语。

接在哪

位置做什么
装配构造门面并注入工作流服务集
生成节点读情节块进上下文
会话层一轮结束后调 observe;无门面时回落到旧的单路径捕获
API 启动周期任务跑 run_lifecycle
管理端画像与偏好草稿的查看/确认/拒绝

session.py:1986 _observe_memory session.py:1570 _capture_lessons app.py:212 _periodic_sweep

别把它改成自动注入

「让记忆自动生效、不用人工确认」看起来是个明显的改进,实际上会把知识库变成模型输出的回音室——一旦某条错误教训被自动确认,它会持续影响后续所有生成,而且没有人知道它是什么时候进来的。

情节记忆是例外不是漏洞:它记录事实、按用户隔离、不参与知识复用。这条边界值得保持。

本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页