5. 记忆:事实库与会话级 P_t#
一句话结论:长期记忆是一张
memory_facts事实表(key 即身份、同 key 覆盖),只经模型上下文生效——每轮注入 tier-one 一块、模型自己写进工具入参;会话级 P_t 仍走机制,由 planner 单写、当轮item_picker执行。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 长期=事实表,短期=P_t | memory_facts(跨会话)/ AgentState.middle_context["pt"](本次会话) |
| 2 | key 即身份,同 key 覆盖 | 不再有 polarity/domain/slug 拼 dedup_key,改主意就用原 key 写新值 |
| 3 | 记忆只经上下文生效 | 注入给模型 → 模型写进 item_search 入参;机制侧硬淘汰腿 M4 删了 |
| 4 | 注入=tier-one,工具=翻旧账 | constraint 全进 + 最近补到 8 条;没进的靠 recall_memories(topic) |
| 5 | 写入单门 validate_fact | 工具 / 抽取 / 偏好页三条写路径都过它,PII 三条拒 |
| 6 | 三分类只定优先级 | constraint / preference / context,不定杀伤力 |
| 7 | P_t 单写者是 planner | 换域清表保预算、撤回按原话核验 |
30 秒版#
记忆分两层。长期层是 memory_facts 表,一条事实就是 key / value / category,key 就是身份、同 key 覆盖写;每轮把 tier-one 那批(全部 constraint + 最近补到 8 条)注入成 <user_long_term_memory>,剩下的靠 recall_memories 按主题翻。写有三条路径——save_memory 工具、回合后抽取、偏好页 API——都过 validate_fact 一道门。短期层是会话级 P_t,唯一写者是 planner,当轮 item_picker 就执行。
3 分钟版#
- 旧模型是偏好条目:
PreferenceEntry带 polarity / domain / slug / blocking,dedup_key 由四字段派生,还有 22 个品类域做隔离。 - M1 换成事实(
e46d1ed):建memory_facts(user_id + fact_key唯一)+users.memory_purge_gen;validate_fact成为唯一写入口。 - M2 换注入(
8b64026):build_preference_block→select_tier_one_facts+render_memory_block,不再按品类域过滤;新增save_memory,recall_memories改查事实表。 - M3 换抽取(
4148e20):curator 从「判长期偏好」改成「抽事实」,每轮最多 3 条;加保留期MEMORY_RETENTION_DAYS和总开关ENABLE_MEMORY。 - M4 删并行通路(
c300282):memory/assemble.py里「长期库 → 硬淘汰 / 减分 / 拼检索词」整条摘掉,记忆从此只经模型上下文生效;偏好页改成三字段表单。 - D2 清场(
580e857):迁移0016_drop_preferences删旧表,PreferenceStore改名UserDataStore(只剩行为历史 + 收藏)。 - P_t 没动:
merge_pt_lite的换域清表、撤回按原话核验、极性翻转三条规则照旧;assemble现在只装配 P_t + 收藏亲和两路。
它解决什么问题#
决策 1:长期记忆从「偏好条目」换成「事实」#
- 问题:一条偏好要落库,模型得同时给对 polarity / category / domain / slug 四个字段,冲突消解还要它引用旧条目的 dedup_key。
- 坏法:dedup_key 拼错一个字符,旧条目删不掉、新条目又落一条,库里两条互相打架。坏了不报错,只是推荐变糊。
- 修法:
key就是身份(MemoryFactRow,user_id + fact_key唯一),同 key 覆盖;分类退成三个值,只决定注入优先级(app/memory/facts.py:MemoryCategory)。 - 代价:key 由模型自拟,同一主题可能被写成两个 key;靠
same_fact的字符 bigram Jaccard(阈值 0.6)在抽取侧挡一道,工具侧不挡。
决策 2:记忆只经模型上下文生效,删掉机制侧那条腿#
- 问题:一条长期偏好原来有两条并行通路——注入给模型看的文本,和
assemble按域过滤后的词表(硬淘汰 / 减分 / 拼检索词)。 - 坏法:两份来源判据不同,「模型看到的」和「机制执行的」长期不一致;域闸 fail-closed,planner 判不出品类那轮长期硬规则静默整轮消失,用户只看到结果变差,归因不到记忆头上。
- 修法:M4 把机制侧那条腿整条删(
app/memory/assemble.py模块 docstring)。注入块里带[constraint]标记,模型自己把它写进item_search入参——写没写进去,在工具入参里看得见。 - 代价:普通轮
item_picker由 autopick 自动跑、没有模型入参,长期 constraint 不再在精挑层生效,只在检索层和收尾把关(提交正文里写明「已知并接受」)。
决策 3:注入只给 tier-one,其余留给工具#
- 问题:老用户攒几十条事实,全量注入把上下文灌爆,还每轮都灌。
- 坏法:按语义 top-k 裁剪要在热路径上多一次 embedding 往返,还让记忆层依赖远程服务。
- 修法:
select_tier_one_facts——全部constraint不受 cap 限制,其余按updated_at倒序补到TIER_ONE_CAP = 8;没进这批的由模型调recall_memories(topic)捞(RECALL_MAX_ENTRIES = 20)。 - 代价:constraint 很多的用户注入块没有上限;「该不该翻旧账」交给模型判,判错就是多一次往返或漏一条事实。
决策 4:写入收成一道门 validate_fact#
- 问题:三条写路径(
save_memory工具 / 回合后抽取 / 偏好页 API)各自校验,规则会飘。 - 坏法:key 大小写、空格不统一,「Ship To」和「ship_to」被当成两条,覆盖写失灵;PII 从某一条路径漏进库,此后每轮注入都在重放。
- 修法:三路都调
app/memory/facts.py:validate_fact——key 小写 + 空格转下划线、长度封顶(KEY_MAX=64/VALUE_MAX=200)、过写入过滤器(≥9 位数字 / IBAN / 邮箱三条),拒绝文案不回显 value。 - 代价:
apply_filter=False留了个后门给旧数据迁移用;分类给不出合法值时退到preference,宁可降一档优先级也不丢整条。
决策 5:记忆写入不给模型删除口#
- 问题:用户说「别记了 / 忘掉 X」,模型需要一个撤回动作。
- 坏法:给模型一个
delete,它删错 key 用户不知道,而且「已经忘掉了」这句回执一旦说错就无法验证。 - 修法:遗忘走
save_memory同 key 覆盖成新值(「不要塑料」→「塑料也可以」);真删只由用户在偏好页点(DELETE /api/preferences/{user_id}/{key},另有一条清空)。save_memory的回执文案不说「已删除」。 - 代价:库里会留下语义相反的历史值;用户想彻底清掉得自己去页面点。
决策 6:P_t 只有 planner 一个写者(沿用,已对代码核实)#
- 问题:curator 曾经也写 P_t,两个写者的先后要靠收尾前重新快照协调。
- 坏法:curator 用快档模型、收尾后才跑、看的是助手回复的转述,历史事故全是它覆盖掉 planner 的正确结果。
- 修法:产生和撤销都归 planner(
app/tools/planner.py:_sync_session_pt→merge_pt_lite),curator 只写事实表、连 P_t 都不再作为输入喂给它(M3 去掉prev_pt/session_domains两个参数)。 - 代价:会话约束的质量全看 planner 这一次结构化输出;planner 没跑的轮次 P_t 不更新。
机制怎么跑#
- 开局:
load_session_state(output/<thread_id>/session.json)→AgentState,middle_context["pt"]读回 P_t;读坏按空开局(app/memory/session_state.py:pt_from_state)。 - planner 每轮跑:
_render_prior_context把上一轮 P_t 渲染给模型,只让它填本轮新说的词和retract_terms(app/tools/planner.py:563)。 - P_t 合并:
merge_pt_lite三条确定性规则——换品类域清三个词表保预算、撤回逐词对原话核验才删、同词极性翻转以最新为准。 - 收货国第 3 层:
resolve_dest_country_layered读key == SHIP_TO_KEY(default_ship_to)那条事实(app/tools/planner.py:200)。这是唯一一个被代码按名字读的 key。 - 注入:
post_tool_call优先级 50 的钩子preference_inject(app/harness/hooks/context_shaping.py:103)在 planner 之后跑,select_tier_one_facts+render_memory_block→ 一条<user_long_term_memory>system 消息。不进 system 前缀,保前缀缓存。 - 模型用:模型把注入块里的
[constraint]自己写进item_search/item_picker入参。 - 会话级通路:
assemble(user_id)→MemoryBundle(exclude / penalty / must / affinity),只有 P_t 与收藏亲和两路,item_picker只读这一份(app/memory/assemble.py)。 - 模型当场写:
save_memory(key, value, category)→validate_fact→upsert_facts;返回bool,写不进去回执明说「这次没存进长期记忆」(app/tools/save_memory.py)。 - 模型主动翻:
recall_memories(topic)→match_facts子串匹配 + 行为历史;只读工具,进EXTERNAL_SOURCE_TOOLS围栏白名单(app/agent/tool_registry.py:86)。 - 收尾之后:
report_task_result之后才跑curate_turn(快档 LLM,只读 user / assistant 文本),最多落MAX_NEW_FACTS = 3条;抽取前后各读一次purge_generation,代数变了整批丢弃(app/memory/curator.py)。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 2026-09-14 | fcac1ba / 668f6f1 | 跨轮产物收成 session.json 一份;P_t 退回「词表就是状态」 | 五份产物要互相对齐;id / 代际 / TTL 只服务「精确撤回一条」,实测极少用(旧版核对过) |
| 2026-09-19 | e46d1ed | M1:建 memory_facts + memory_purge_gen,validate_fact 单门,旧数据迁移脚本 | 对照 commerce-agents 的 commerce_common/memory.py 重建;列名用 fact_key 因为 key 是 MySQL 保留字 |
| 2026-09-19 | 8b64026 | M2:tier-one 注入 + save_memory 工具 + 收货国 / 缓存指纹改读 facts | 域隔离原本是挡机制侧硬淘汰腿的,记忆只经上下文后,域判错整轮丢规则的代价更大 |
| 2026-09-19 | 4148e20 | M3:抽取改写事实库,加保留期与 ENABLE_MEMORY | 让新学到的事实当轮进库并被下一轮注入,消掉 M2 的中间态 |
| 2026-09-19 | c300282 | M4:删机制侧长期记忆腿、偏好页改三字段表单、新增 memory-personalization skill | 两条并行通路不一致;三个字段用户自己填得出来,省掉 POST /parse 那次 LLM |
| 2026-09-19 | 1fa79ca | D4:recall_memories 成独立只读工具 | 注入只给 tier-one,用户问的恰恰可能是没进那批的那条 |
| 2026-09-19 | 42311aa → 8b851f4 | D1 加过 skills/memory-forget,M4 删掉;S2 把 skill 预注入整体推翻 | 遗忘改为同 key 覆盖后这份打法没了落点;skill 加载回归模型自觉 |
| 2026-09-21 | 580e857 | D2:迁移 0016_drop_preferences 删旧表,PreferenceStore → UserDataStore | 旧表已无代码读写,留着让人以为还有第二条长期记忆路径 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 8 | 每轮注入的事实条数上限(constraint 不受限) | app/memory/facts.py:TIER_ONE_CAP | ✓ |
| 64 / 200 | key / value 字符上限 | facts.py:KEY_MAX / VALUE_MAX | ✓ |
| 3 | 回合后抽取一轮最多落几条 | app/memory/curator.py:MAX_NEW_FACTS | ✓ |
| 20 | recall_memories 单次回多少条 | app/tools/recall_memories.py:RECALL_MAX_ENTRIES | ✓ |
| 0.6 | 判「说的是同一件事」的 bigram Jaccard 阈值 | facts.py:same_fact 默认参数 | ✓ |
| 3 条 | 写入过滤器的 PII 规则(≥9 位数字 / IBAN / 邮箱) | facts.py:DEFAULT_BLOCKED_PATTERNS + e46d1ed 正文 | ✓ |
| 0 | MEMORY_RETENTION_DAYS 默认(0 = 不设保留期) | fact_store.py:get_fact_store、.env.example:240 | ✓ |
| true | ENABLE_MEMORY 默认开,关掉时四处同时失效 | facts.py:memory_enabled、.env.example:238 | ✓ |
| 22 条 → 2/2/18 | M1 本地迁移预演:constraint 2 / context 2 / preference 18,0 拒绝 | e46d1ed 提交正文 | ✓(提交正文) |
| 1347 / 1351 / 1313 / 1327 | M2 / M3 / M4 / D4 提交时的绿测数 | 各提交正文 | ✓(提交正文) |
| 18 / 6 / 9 / 13 | test_memory_facts / test_curator / test_memory / test_session_state 的测试函数数 | grep -c "def test_" | ✓ |
| 22 | 旧品类域枚举个数 | 旧版手册核对过;已不用于长期记忆(M2 起不按域过滤) | 历史 |
追问 10 题#
Q1. 长期记忆和会话级 P_t 的分界是什么?
一句话答:跨会话成立的进 memory_facts(经模型上下文生效),本次选购里说的进 P_t(经机制生效,当轮 item_picker 执行)。证据:app/memory/facts.py 与 app/memory/session_state.py 模块 docstring。
Q2. ⚠ 为什么把「长期库直接过滤检索结果」那条腿删了?
一句话答:一条偏好有两条并行通路会不一致,且域闸 fail-closed 让硬规则在判不出品类的轮次里静默消失;改成「模型写进工具入参」后,生效与否在入参里看得见。证据:app/memory/assemble.py 模块 docstring、c300282 正文。
Q3. 删掉那条腿的代价你认不认?
一句话答:认——普通轮 item_picker 由 autopick 自动跑、没有模型入参,长期 constraint 不在精挑层生效,只剩检索层和收尾把关。证据:c300282 正文「已知并接受」。
Q4. 每轮注入哪几条,凭什么挑?
一句话答:select_tier_one_facts——全部 constraint 不受 cap 限制,其余按 updated_at 倒序补到 8 条;漏一条硬规则是推错东西,漏一条取向只是不够贴合。证据:facts.py:select_tier_one_facts。
Q5. 那没进注入的事实怎么用得上?
一句话答:模型调 recall_memories(topic),子串匹配、最多 20 条,且返回过 <external_content> 围栏——记忆正文源头是用户说过的话,一条注入句会在此后每次召回时重放。证据:app/tools/recall_memories.py、tool_registry.py:86。
Q6. ⚠ 用户说「忘掉我不吃坚果」,代码怎么做?
一句话答:模型用同一个 key 写新值覆盖,没有删除口;真删只有用户在偏好页点(DELETE /api/preferences/{user_id}/{key} 或清空)。证据:app/tools/save_memory.py 模块 docstring。
Q7. 清空记忆和回合后抽取撞车怎么办?
一句话答:抽取前后各读一次 users.memory_purge_gen,代数变了整批丢弃——否则刚点完清空,几条旧事实又自己长回来。证据:app/memory/curator.py 模块 docstring、fact_store.py:164。
Q8. save_memory 写库失败时回什么?
一句话答:upsert_facts 返回 False 时回执明说「这次没存进长期记忆,本轮我仍会按它来,下次请再说一遍」——说「已记住」的话用户不会再说第二遍,这条就永远丢了。证据:app/tools/save_memory.py。
Q9. ⚠ 为什么注入放在 planner 之后、而不是进 system prompt?
一句话答:历史原因是注入要等 planner 判出品类域(M2 已不按域过滤),保留这个位置的理由是记忆每轮都可能变,放进 system 前缀会连带后面的历史一起缓存失效。证据:app/harness/hooks/context_shaping.py:103(钩子点 post_tool_call 50)。
Q10. 压力题:你怎么证明这套换载体之后没把效果做反?
一句话答:拿得出的是单测(test_memory_facts 18 个、test_curator 6 个、test_session_state 13 个)与各提交的全量绿测数;记忆类端到端 Rubric(q19 / q21 / q23)在 M4 时明确标注「未跑」,之后也没有产物——这一格是未验证。证据:c300282 正文最后一行。
坑与易混点#
app/memory/session_state.py模块 docstring 仍写「长期库(app.memory.store)…语义去重、带半衰期」——过期:store.py现在只剩行为历史 + 收藏(UserDataStore),长期库是memory_facts,没有语义去重也没有半衰期,只有MEMORY_RETENTION_DAYS。CLAUDE.md§2.2 写「注入<buyer-preferences>」——过期:实际标签是<user_long_term_memory>(facts.render_memory_block)。app/memory/domains.py还在、还被 planner 用,但它现在只服务 P_t 的换域判定,与长期记忆无关;看到 22 个域别以为长期偏好还在按域隔离。blocking/ 硬淘汰授权分档、dedup_key、forget_preference工具、memory/parser.py全部已删,别在面试里讲成现状——那是 2026-09-19 之前的设计。MEMORY_RETENTION_DAYS的过期是「读不到 + 下次写入时顺手删」,不是定时清理;没有时间戳的老行按过期处理。
本章和别章的接口#
- 事实注入走哪个钩子点、和别的注入块怎么排序:第 6 章(Harness 控制面)。
- 为什么记忆不进 system 前缀:第 7 章(上下文压缩与前缀缓存)。
recall_memories的<external_content>围栏与只读工具边界:第 3 章(单环与写边界)。