面试知识库

5. 记忆:事实库与会话级 P_t#

一句话结论:长期记忆是一张 memory_facts 事实表(key 即身份、同 key 覆盖),只经模型上下文生效——每轮注入 tier-one 一块、模型自己写进工具入参;会话级 P_t 仍走机制,由 planner 单写、当轮 item_picker 执行。

速背卡#

#hint展开一句
1长期=事实表,短期=P_tmemory_facts(跨会话)/ AgentState.middle_context["pt"](本次会话)
2key 即身份,同 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,不定杀伤力
7P_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 分钟版#

  1. 旧模型是偏好条目:PreferenceEntry 带 polarity / domain / slug / blocking,dedup_key 由四字段派生,还有 22 个品类域做隔离。
  2. M1 换成事实(e46d1ed):建 memory_facts(user_id + fact_key 唯一)+ users.memory_purge_gen;validate_fact 成为唯一写入口。
  3. M2 换注入(8b64026):build_preference_block → select_tier_one_facts + render_memory_block,不再按品类域过滤;新增 save_memory,recall_memories 改查事实表。
  4. M3 换抽取(4148e20):curator 从「判长期偏好」改成「抽事实」,每轮最多 3 条;加保留期 MEMORY_RETENTION_DAYS 和总开关 ENABLE_MEMORY。
  5. M4 删并行通路(c300282):memory/assemble.py 里「长期库 → 硬淘汰 / 减分 / 拼检索词」整条摘掉,记忆从此只经模型上下文生效;偏好页改成三字段表单。
  6. D2 清场(580e857):迁移 0016_drop_preferences 删旧表,PreferenceStore 改名 UserDataStore(只剩行为历史 + 收藏)。
  7. 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 不更新。

机制怎么跑#

  1. 开局:load_session_state(output/<thread_id>/session.json) → AgentState,middle_context["pt"] 读回 P_t;读坏按空开局(app/memory/session_state.py:pt_from_state)。
  2. planner 每轮跑:_render_prior_context 把上一轮 P_t 渲染给模型,只让它填本轮新说的词和 retract_terms(app/tools/planner.py:563)。
  3. P_t 合并:merge_pt_lite 三条确定性规则——换品类域清三个词表保预算、撤回逐词对原话核验才删、同词极性翻转以最新为准。
  4. 收货国第 3 层:resolve_dest_country_layered 读 key == SHIP_TO_KEY(default_ship_to)那条事实(app/tools/planner.py:200)。这是唯一一个被代码按名字读的 key。
  5. 注入: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 前缀,保前缀缓存。
  6. 模型用:模型把注入块里的 [constraint] 自己写进 item_search / item_picker 入参。
  7. 会话级通路:assemble(user_id) → MemoryBundle(exclude / penalty / must / affinity),只有 P_t 与收藏亲和两路,item_picker 只读这一份(app/memory/assemble.py)。
  8. 模型当场写:save_memory(key, value, category) → validate_fact → upsert_facts;返回 bool,写不进去回执明说「这次没存进长期记忆」(app/tools/save_memory.py)。
  9. 模型主动翻:recall_memories(topic) → match_facts 子串匹配 + 行为历史;只读工具,进 EXTERNAL_SOURCE_TOOLS 围栏白名单(app/agent/tool_registry.py:86)。
  10. 收尾之后:report_task_result 之后才跑 curate_turn(快档 LLM,只读 user / assistant 文本),最多落 MAX_NEW_FACTS = 3 条;抽取前后各读一次 purge_generation,代数变了整批丢弃(app/memory/curator.py)。

演进时间线#

日期提交改了什么为什么
2026-09-14fcac1ba / 668f6f1跨轮产物收成 session.json 一份;P_t 退回「词表就是状态」五份产物要互相对齐;id / 代际 / TTL 只服务「精确撤回一条」,实测极少用(旧版核对过)
2026-09-19e46d1edM1:建 memory_facts + memory_purge_gen,validate_fact 单门,旧数据迁移脚本对照 commerce-agents 的 commerce_common/memory.py 重建;列名用 fact_key 因为 key 是 MySQL 保留字
2026-09-198b64026M2:tier-one 注入 + save_memory 工具 + 收货国 / 缓存指纹改读 facts域隔离原本是挡机制侧硬淘汰腿的,记忆只经上下文后,域判错整轮丢规则的代价更大
2026-09-194148e20M3:抽取改写事实库,加保留期与 ENABLE_MEMORY让新学到的事实当轮进库并被下一轮注入,消掉 M2 的中间态
2026-09-19c300282M4:删机制侧长期记忆腿、偏好页改三字段表单、新增 memory-personalization skill两条并行通路不一致;三个字段用户自己填得出来,省掉 POST /parse 那次 LLM
2026-09-191fa79caD4:recall_memories 成独立只读工具注入只给 tier-one,用户问的恰恰可能是没进那批的那条
2026-09-1942311aa → 8b851f4D1 加过 skills/memory-forget,M4 删掉;S2 把 skill 预注入整体推翻遗忘改为同 key 覆盖后这份打法没了落点;skill 加载回归模型自觉
2026-09-21580e857D2:迁移 0016_drop_preferences 删旧表,PreferenceStore → UserDataStore旧表已无代码读写,留着让人以为还有第二条长期记忆路径

数字与证据#

数字指什么来源状态
8每轮注入的事实条数上限(constraint 不受限)app/memory/facts.py:TIER_ONE_CAP✓
64 / 200key / value 字符上限facts.py:KEY_MAX / VALUE_MAX✓
3回合后抽取一轮最多落几条app/memory/curator.py:MAX_NEW_FACTS✓
20recall_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 正文✓
0MEMORY_RETENTION_DAYS 默认(0 = 不设保留期)fact_store.py:get_fact_store、.env.example:240✓
trueENABLE_MEMORY 默认开,关掉时四处同时失效facts.py:memory_enabled、.env.example:238✓
22 条 → 2/2/18M1 本地迁移预演:constraint 2 / context 2 / preference 18,0 拒绝e46d1ed 提交正文✓(提交正文)
1347 / 1351 / 1313 / 1327M2 / M3 / M4 / D4 提交时的绿测数各提交正文✓(提交正文)
18 / 6 / 9 / 13test_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 正文最后一行。

坑与易混点#

  1. app/memory/session_state.py 模块 docstring 仍写「长期库(app.memory.store)…语义去重、带半衰期」——过期:store.py 现在只剩行为历史 + 收藏(UserDataStore),长期库是 memory_facts,没有语义去重也没有半衰期,只有 MEMORY_RETENTION_DAYS。
  2. CLAUDE.md §2.2 写「注入 <buyer-preferences>」——过期:实际标签是 <user_long_term_memory>(facts.render_memory_block)。
  3. app/memory/domains.py 还在、还被 planner 用,但它现在只服务 P_t 的换域判定,与长期记忆无关;看到 22 个域别以为长期偏好还在按域隔离。
  4. blocking / 硬淘汰授权分档、dedup_key、forget_preference 工具、memory/parser.py 全部已删,别在面试里讲成现状——那是 2026-09-19 之前的设计。
  5. MEMORY_RETENTION_DAYS 的过期是「读不到 + 下次写入时顺手删」,不是定时清理;没有时间戳的老行按过期处理。

本章和别章的接口#

  • 事实注入走哪个钩子点、和别的注入块怎么排序:第 6 章(Harness 控制面)。
  • 为什么记忆不进 system 前缀:第 7 章(上下文压缩与前缀缓存)。
  • recall_memories 的 <external_content> 围栏与只读工具边界:第 3 章(单环与写边界)。