逐日AI
第 2 周 · D11约 4 小时

跨会话记忆:显式记忆、自动记忆与检索注入的三个判据

让 Agent 记住这个仓库的坑:实现一个文件式的记忆目录,区分用户显式写下的记忆与模型自动沉淀的记忆,用关键词与路径做检索并只注入相关的几条,同时想清楚什么东西不该被记住。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能设计一套文件式的记忆存储,并说清它与会话日志、指令文件的分工
  2. 能实现记忆的检索与按需注入,控制注入条数与预算
  3. 能说出三类绝不该写进记忆的内容,并在实现里挡住它们

昨天那块清单是易失的,今天要留下来的正相反。读完回到页面顶部把三条目标勾掉。

小白版讲解

交接笔记:这个仓库有哪些坑,写下来给下一个人

那个新人已经干了十天:能读代码、能改文件、知道团队规矩、手里还有一张工作清单。今天他下班了,明天来的是另一个人。

于是同一件事第二次发生:新来的那位又跑了一遍 npm test,又发现它是个空壳,又花二十分钟才搞明白这个仓库的测试得用另一条命令。上周那位也踩过这个坑,只不过他把它留在了自己脑子里。

交接良好的团队都有解法:一本交接笔记。 不是流水账也不是规章制度,就是一沓「这里有个坑」的短条子,每张一件事,写清是谁写的、什么时候写的。下一个人不用通读它,只在撞上相关的活儿时翻出那一两张。

它和第七天那个会话日志的区别值得先说清楚:第七天解决的是「同一条会话怎么接着说」,今天解决的是「不同的会话之间怎么传经验」。 恢复会话只对同一件事有用;而「测试得用另一条命令」跟你上次聊什么完全无关。

所以核心命题只有一句:Agent 没有记忆,但你可以给它一个能被检索的外部笔记本。 难的不是把字写下来,是之后怎么只把恰当的那几张翻出来,以及什么东西根本不该写上去

三样东西各管一段:事实、过程、规定

到今天为止 mca 手里已经有三样看起来都像「存着东西」的机制,混在一起是这个功能最常见的死法。

记忆(今天)会话事件日志(第七天)项目指令文件(第九天)
存什么事实过程规定
谁写的用户显式写下 + 模型自动沉淀循环自动逐条追加人手写进仓库
生命周期跨会话长期保留,会过期一条会话一份,只追加不改写跟着仓库进版本库
怎么用按相关性检索,只注入几条恢复时整段重放每一轮全量进系统提示
进不进版本库不进:这是本机学到的经验不进
出错的样子记了条错事实,模型自信地做错事恢复到错的位置模型莫名不听话

三条判据帮你分东西该往哪儿放:

  • 「下次还成立吗」 不成立的是过程,归日志。「刚才那次测试是红的」不该进记忆。
  • 「这是学到的还是规定的」 规定的进指令文件,学到的进记忆。
  • 「读一次代码能知道吗」 能知道的不必记。记忆存的是踩过的坑、口头约定、试过但不行的做法、用户偏好。

第二条顺便回答了「记忆目录为什么不进版本库」:一条没经过评审就能影响所有人的「事实」很危险。值得全组共享的经验应该写进指令文件,让人看一眼再合进去。

一条记忆一个文件

存储的形状看着最没技术含量,后面每个决定都被它约束。本实验用一条记忆一个文件,格式是「几行元信息 + 一个空行 + 正文」:

TextText
id: m1
source: model
at: 2026-09-07T00:00:00.000Z
keys: 测试, 验证, node, --test
paths: package.json, test
anchor-path: package.json
anchor-contains: node --test
 
这个仓库的测试只能用 node --test 跑,脚本里那条是给 CI 用的,本地直接跑会少载一个用例

为什么不是一个大文件——四条工程理由:

  • 元信息挂得住。 大文件只能给整份一个时间戳,于是三个月前那句话和昨天那句话在检索时一样新。
  • 检索的单位天然就是文件。 命中原因能指到一个文件名,用户能直接打开、直接删。大文件要先切分,而切分规则就是下一个 bug 的来源。
  • 并发写不打架。 追加同一个大文件会交错,而交错出来的那一行是永久损坏的。
  • 删一条就是删一个文件。 改大文件是一次全量重写,写坏了整份记忆一起没。

代价是几百条之后目录会变大,所以「删除」与「过期核对」不是加分项,是这个功能能不能活过两周的前提。

sourceat 最便宜也最值钱:出问题时你要能回答「这条错的事实是谁写进来的、什么时候写的」。

两条写入路径:显式命令与模型沉淀

记忆有两个入口,缺哪个都不行。

第一条是显式的:/remember 这个仓库的测试只能用 node --test 跑 它更该先有——用户自己知道哪些坑值得记,而且他写下的东西他自己记得、能自己删。

第二条是模型自己调的 remember 工具。 它省事,代价是它写的东西没人认领。这条路径上有个细节:关键词与路径要它自己给,不要我们从正文里抽——它刚读过那个文件,知道这条经验以后会在什么话题下用得上。把「以后怎么找回来」交给写的时候想,比交给检索的时候猜便宜得多。

两条路径必须汇到同一个写入函数:拦截、去重、写盘都只写一遍。理由和第十天「校验放数据模型不放工具」一样——不变量属于数据,少写一处那一处就是漏洞,而这里漏掉的是密钥。

还有一条容易忽略的判定:remember 不是只读工具,和第十天的清单工具正好相反。清单是这一次任务的易失工作面,记忆会留在磁盘上并影响以后每一次会话——一个会改变将来所有会话行为的写操作,正是第五天那道审批门该管的。 判据从来不是「它改的是不是用户的源码」,而是「它会不会造成不可逆的外部影响」。

检索与注入:三个判据、两个上限、一个可替换的槽位

先立一条纪律:默认是「不注入」,不是「全注入」。 一个记了三百条的目录全量注入等于把记忆变成第二份系统提示,而其中两百九十条和这句话毫无关系——它们不只浪费额度,还会把模型带偏。宁可漏也不要糊。

三个判据,分数就是它们的可靠性顺序:

src/memory/retrieve.ts
// 判据一:路径命中。最硬的信号——路径是个精确的东西,不会像词那样撞车
const pathHits = paths.filter((p) => haystack.includes(p.toLowerCase()))
if (pathHits.length > 0) {
  score += PATH_SCORE * pathHits.length
  reasons.push(`路径 ${pathHits.join('、')}`)
}
 
// 判据二:关键词命中。撞一个词可能是巧合,撞三个基本就是同一个话题;
// **再多也不加分** —— 否则一条关键词写得多的记忆会永远排在最前面
const keyHits = entry.memory.keys.filter((key) => haystack.includes(key.toLowerCase()))
if (keyHits.length > 0) {
  const counted = Math.min(keyHits.length, KEY_HIT_CAP)
  score += KEY_SCORE * counted
  reasons.push(`关键词 ${keyHits.slice(0, counted).join('、')}`)
}
 
// 一条都没命中就不进上下文。**这一行就是「只注入相关的」的全部实现**
if (score === 0) continue
 
// 判据三:来源加权。同分时用户亲手写下的压过模型自己沉淀的
if (entry.memory.source === 'command') score += SOURCE_BONUS

第三条判据的理由不是「人一定对」,是责任:用户写的那条他自己记得、能自己删;模型沉淀的那条没人认领。

两个上限都要有:条数管「别糊」,字符管「别把额度吃光」。 本实验一次最多三条——这个数字小得像写错了,它就是要这么小。

然后是今天最重要的结构决定,它和第八天的引用注入正好相反:引用是一次性的(这一句点名要的资料,插一次算一次),记忆是常驻的(它是背景知识,不属于任何一句话)。所以记忆不跟着用户消息走,而是占住历史最前面(紧跟系统消息)的一个槽位,每一轮按当前这句话重算并原地替换

没有 用户这一句 读盘: 全部记忆 核对: 锚点还成立吗 检索: 三个判据打分 有命中吗 原地替换记忆槽位 整条拿掉槽位 报账打在终端
Mermaid 源码
mermaidmermaid
flowchart LR
  A[用户这一句] --> B[读盘: 全部记忆]
  B --> C[核对: 锚点还成立吗]
  C --> D[检索: 三个判据打分]
  D --> E{有命中吗}
  E -->|| F[原地替换记忆槽位]
  E -->|没有| G[整条拿掉槽位]
  F --> H[报账打在终端]
  G --> H

两个后果都值得要。一是占用量恒定:追加式注入十轮之后上下文里有十份记忆,前九份还是过时的检索结果。二是前缀稳定:常驻的东西靠前,网关那边才有机会命中缓存。

最后一件必须做的事:把命中原因打给用户看,而且不发给模型。

TextText
记忆注入 1/1 条(共 146 字符、约 124 token,占记忆预算 12%;一次最多 3 条)
  ✔ [m1] 这个仓库的测试只能用 node --test 跑,脚本里那条是给 C
      命中 3 分:关键词 测试、用户写下

记忆是唯一一种「用户没提、却影响了这一轮回答」的上下文。 它错了的表现是「模型莫名其妙地坚持一件不成立的事」,而用户手里没有任何东西可查。有了这几行,「它为什么说要用内置的测试运行器」从猜变成看一眼。模型则不需要知道分数——那是给人做决策用的。

一条都没命中时也要打一行「这句话一条都没命中」。这一行比注入本身重要:它证明「没注入」是判断的结果,不是功能坏了。

这一路该占总预算多少、总预算又怎么分——那是明天的题目,今天只报自己这一路的账。估算器直接复用第八天那个:同一段文本在两处报出两个数,用户就没法对账了。

什么不该记:三类内容,挡在写入那一层

三类内容各有一条性质不同的理由,处理方式也不一样。

第一类是密钥,安全问题,拦得最死。 记忆目录是明文的、常驻的、每一轮都往网关发一遍——一条记住的密钥等于抄进了每一次请求。判据是按形状拦,不按变量名拦:「我的 key 是 sk 加一长串」里那个长串才是要拦的,而「凭据要放进环境变量不要写死」这句话里一个密钥都没有,它是条好记忆。

src/memory/redact.ts
const SECRET_RULES: Array<{ rule: string; pattern: RegExp }> = [
  { rule: '带家族前缀的 token', pattern: /\b(?:sk|pk|ghp|gho|xox[baprs])[-_][A-Za-z0-9]{16,}/ },
  { rule: '云厂商 access key', pattern: /\b(?:AKIA|ASIA|AIza)[A-Za-z0-9]{12,}/ },
  { rule: 'PEM 私钥块', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
  { rule: '带密码的连接串', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]{6,}@/ },
]
 
for (const { rule, pattern } of SECRET_RULES) {
  if (!pattern.test(text)) continue
  return {
    allowed: false,
    // **这句话里一个字的原文都没有。** 回显被拦下的密钥
    // 等于把它写进了另一份日志(终端历史、会话日志、CI 输出)
    note:
      `这条内容里有密钥的形状(${rule}),已经拒绝写入记忆,原文不会被记录到任何地方。` +
      '正确的做法是把它放进环境变量,记忆里只写这个项目需要哪个环境变量。' +
      '另外:它已经出现在这次输入里了,请把那个凭据轮换掉。',
  }
}

那句拒绝话术的最后一句是重点:只说「不能记」是没尽到责任的。 那个凭据已经出现在这次输入里、已经在终端历史与会话日志里躺着,要提醒用户去轮换。

也交出这个实现的边界:本实验的脱敏只覆盖记忆这一路。 模型请求的那张工具卡片与审批提示还是会原样回显它要记的东西,所以密钥仍会在屏幕上闪一次。真要连回显都挡住,得在渲染层再做一次脱敏。知道这条边界在哪,比以为拦住写入就万事大吉重要。

第二类是临时结论,分类问题。 「现在那个用例是红的」是过程不是事实,归第七天的会话日志:三天后它会带着一条早就不成立的「现在」进上下文,而它自己说不清那是什么时候的现在。判据是「这句话里有一个只在说话那一刻成立的时间锚」。这一条一定会误杀——「这个仓库现在还在用 CommonJS」其实是条不错的记忆,所以拒绝话术必须给出改法。一条不给出路的拦截会让用户直接放弃这个功能。

第三类是代码里已经写着的事,预算问题。 它不是错的,是不值得:读一次文件就知道的事占着常驻额度每一轮重发。实现上有个反直觉的旋钮:判断「是不是在复述代码」的片段长度下限必须给得比直觉大——给四个字符时,「测试只能用 node --test」会被 test 命中 package.json 而误杀。而且只在这句话点了某个文件名时才去读那个文件:全仓库搜索的命中率高得离谱,那条闸会直接变成「什么都不许记」。

三类都拦在写入那一层,拒绝的理由原样回灌给模型。本实验里能看到很干净的一幕:模型想把一个 key 记进记忆,被拒、读到理由,下一轮自己改成记住那个环境变量名——失败的工具结果就是它的下一步说明书,这和第三天的坏参数走的是同一条通路。

记忆会过期:用之前先核对

最后一节决定这个功能三个月后是资产还是负债。一条记忆写下的那一刻是对的,之后世界会变。所以元信息里可以带一个锚点:一个「靠什么判断它还成不成立」的可核对依据。「测试要用内置运行器」这条就该能指出「去看 package.json,里面写着那条命令」。

src/memory/retrieve.ts
const anchor = memory.anchor
if (!anchor) {
  // 没有锚点只能靠年龄。它不是「过期了」,是「没人能替你判断它过没过期」
  const old = ageDays >= MEMORY_AGING_DAYS
  checked.push({ memory, ageDays, status: old ? 'aging' : 'fresh', note: /* … */ })
  continue
}
const source = await fs.readFile(path.join(cwd, anchor.path), 'utf8').catch(() => null)
const broken =
  source === null
    ? `锚点文件 ${anchor.path} 不在了`
    : source.includes(anchor.contains)
      ? null
      : `${anchor.path} 里已经找不到「${anchor.contains}」`
// 失效的一律 stale:**它不注入,只被列出来等人处理**
checked.push({ memory, ageDays, status: broken ? 'stale' : 'fresh', note: broken ?? '核对通过' })

三条状态对应三种处理:

  • 核对通过:正常注入。
  • 锚点失效不注入,但要在终端列出来。 悄悄不注入是错的——用户会以为它还在生效,下次模型答错时他去查记忆目录,那条记忆明明还在文件里。静默失效比失效更难查。
  • 没有锚点又很旧:照样注入,但带一句「可能已经过期」。没有锚点不代表它过期了,只代表没人能替你判断它过没过期

还有一条边界:锚点失效不等于这条记忆错了,也可能只是文件改了名。所以处理方式是「拿下来等人看」而不是自动删——自动删记忆是不可逆的,而判据只是一次字符串包含检查。

源码导读

动手实验

🧪 D11 实验:一个文件式的记忆目录,两条写入路径、三个检索判据、三类拦截

代码位置:labs/my-coding-agent-21days/day-11-memory

今天挖了五个练习点,其中三个是「看着无害、实际致命」的陷阱:不做零分过滤(于是全量注入)、关键词命中不设上限(于是堆砌能压过路径精确命中)、槽位直接追加(于是十轮之后上下文里有十份记忆)。起点代码原样跑是十七项里过九项。

跨会话那一幕在管道里不好演(一条管道就是一个进程一条会话),所以由自检负责:先在会话一里让模型记下一条,再开一条历史只有一条系统消息的新会话问一句相关的话,断言那条事实进了这一轮的请求。离线模式下「模型说了什么」是剧本写的,所以能验的是那条事实有没有回到请求里——而这正是召回的定义。

  1. 补齐写入层的去重,并想清楚为什么拦截也必须在这一层。
  2. 把三类拦截做完:密钥按形状拦且不回显原文,另两类各给出改法。
  3. 实现锚点核对:失效的拿下来等人处理,很旧又没锚点的带一句「先核对」,都不要自动删。
  4. 补上检索的零分过滤与关键词上限,并把命中原因打在终端上。
  5. 把槽位做成原地替换:紧跟系统消息、每轮重算、没命中就整条拿掉。自检应是 17/17 通过

验收看四条勾:自检 17/17 通过;相关的一句话命中并打印原因、无关的明确说「没命中」;连说几轮消息数组里始终只有一条记忆消息;密钥被拒且记忆目录里搜不到它。条数、分数、字符数离线可复现,写下时间与会话 id 不可复现。

面试题

今天三道题考的是「一个外部笔记本怎么才不变成垃圾场」:

  1. Agent 的记忆和会话历史、项目指令文件分别解决什么问题?
  2. 记忆怎么检索才能只注入相关的?相关性判据你会怎么定?
  3. 哪些内容绝不该写进长期记忆?记忆过期了怎么发现?

完整的中英题干、分析过程与答题要点见本课面试题库的第十一天。第二题最容易答成「上向量库」——那只是把问题换了个地方放,能说出「零分不注入」和「命中原因要给用户看」的人不多。

检查清单与明日预告

  • 能用三条判据把一件事分到记忆、会话日志、指令文件三者之一
  • 知道记忆目录为什么不进版本库,要共享的经验该往哪儿放
  • 能说出「一条记忆一个文件」的四条理由、它的代价,以及两条写入路径为什么必须汇到一个函数
  • 能说清三个检索判据的顺序,以及「零分不注入」「关键词有上限」各挡住了什么
  • 知道记忆为什么是原地替换的槽位,以及三类不该记的内容各自的理由
  • 能解释锚点核对的三种处理,以及为什么失效了也不自动删

明天是 D12《上下文压缩:token 怎么数、压什么留什么、压完怎么验证没丢》。今天只报了自己这一路的账,明天要回答的是总预算怎么在七路之间分、满了之后先压哪一路。顺序有讲究:先把各路的账算清,再谈怎么分——只有一个总数时你只知道「满了」,有了各路额度才知道该动谁。

面试题库

  • Agent 的记忆和会话历史、项目指令文件分别解决什么问题?What problem does an agent's memory solve, versus conversation history and project instruction files?
    国内高频海外高频基础#agent-memory#context-design

    分析过程 · 先想清楚再作答

    1. 这题在考「你有没有真做过一个记忆系统」。答「记忆让它记住东西」是空话——三样东西都让它记住东西。区分度在于你能不能给出一条可操作的分类判据,而不是三段各自的定义。
    2. 怎么拆:先给一句话的分工——**一个记事实,一个记过程,一个是规定。** 记忆存的是跨会话还成立的事实(这个仓库的测试得用哪条命令);会话事件日志存的是这一次干活的过程,用来恢复与分叉;项目指令文件存的是规矩,进版本库、对所有人生效。它们的生命周期完全不同:记忆长期保留但会过期,日志一条会话一份且只追加不改写,指令文件跟着代码走。
    3. 更值得说的是**用法不同**:指令文件每一轮全量进系统提示(所以它有硬预算,一超就得截断);会话日志只在恢复的时候整段重放;记忆是三者里唯一**按相关性检索、只注入几条**的——因为它会一直长,而它绝大多数条目和眼前这句话无关。**能不能按需检索,才是记忆和另两者最本质的差别。**
    4. 然后给三条判据证明你分得清:①「这句话下次还成立吗」不成立的是过程,归日志(「刚才那次测试是红的」不该进记忆);②「这是学到的还是规定的」规定的进指令文件,学到的进记忆;③「读一次代码能知道吗」能知道的不必记——记忆该存的是踩过的坑、口头约定、试过但不行的做法、用户的偏好。
    5. 混在一起的代价要具体:把过程写进记忆,三天后模型带着一条早就不成立的「现在」进上下文,而它自己说不清那是什么时候的现在;把学到的东西写进指令文件,等于让一条没人评审的猜测对全组生效;反过来把规矩只记在本机记忆里,换台机器就没了。
    6. 可预期的追问:记忆目录该不该进版本库?我的答案是不该——它可能含有不该共享的东西,而更根本的是**一条没经过评审就能影响所有人的「事实」很危险**。值得全组共享的经验应该被人手动搬进指令文件、走一次代码评审。(这一条各家实现不一样,重点是能说出取舍,而不是背某个产品的行为。)

    How to reason about it · think before answering

    1. This tests whether you have actually built a memory system. Saying memory lets it remember things is empty — all three let it remember things. The signal is an operational classification rule, not three separate definitions.
    2. How to break it down: give the one-line split first — one stores facts, one stores process, one states rules. Memory holds facts that still hold across sessions (which command this repo's tests need); the session event log holds the process of this particular run, used for resume and fork; the project instruction file holds the rules, is committed, and applies to everyone. Their lifecycles differ completely: memory is long-lived but expires, a log is one per session and append-only, instruction files travel with the code.
    3. The more interesting difference is how each is consumed. Instruction files go into the system prompt in full every round, so they need a hard budget and truncation. The session log is replayed only on resume. Memory is the only one of the three that is retrieved by relevance and injected a few entries at a time — because it grows forever and most of it is irrelevant to the sentence in front of you. That retrievability is the essential difference.
    4. Then offer three tests to show you can actually sort things: will this still be true next time (if not it is process, and belongs in the log); is this learned or mandated (mandated goes in the instruction file, learned goes in memory); could you learn it by reading the code once (if so it is not worth remembering — memory should hold traps you hit, verbal conventions, approaches you tried that failed, and user preferences).
    5. Be concrete about the cost of mixing them. Put process in memory and three days later the model carries a long-dead now into the context without being able to say when that now was. Put learned guesses into the instruction file and an unreviewed assumption starts governing the whole team. Conversely, keep a real rule only in local memory and it vanishes on the next machine.
    6. Likely follow-up: should the memory directory be committed? My answer is no — it may contain things that should not be shared, and more fundamentally a fact that can influence everyone without review is dangerous. Experience worth sharing should be moved into the instruction file by a human and go through code review. Implementations differ here, so the point is articulating the tradeoff rather than reciting one product's behavior.

    答题要点

    • 一句话分工:记忆记事实、会话日志记过程、指令文件是规定
    • 最本质的差别是用法:只有记忆按相关性检索、只注入几条;另两者一个全量进提示、一个整段重放
    • 三条分类判据:下次还成立吗 / 学到的还是规定的 / 读一次代码能知道吗
    • 混起来的具体代价:过程进记忆会带着过期的「现在」;猜测进指令文件会没评审就影响全组
    • 记忆不进版本库:没经过评审就能影响所有人的事实很危险,要共享就搬进指令文件

    Key points

    • One line: memory holds facts, the session log holds process, instruction files state rules
    • The essential difference is consumption: only memory is retrieved by relevance a few entries at a time
    • Three sorting tests: still true next time / learned or mandated / knowable by reading the code once
    • Concrete cost of mixing: process in memory carries a stale now; guesses in instruction files skip review
    • Memory is not committed: an unreviewed fact that governs everyone is dangerous; promote it to instructions
  • 记忆怎么检索才能只注入相关的?相关性判据你会怎么定?How do you retrieve memories so that only the relevant ones get injected, and how would you define relevance?
    国内高频海外高频进阶#memory-retrieval#context-injection

    分析过程 · 先想清楚再作答

    1. 这题最容易答成「上向量库做语义检索」。那不是错,但它把问题换了个地方放:向量库解决的是「怎么排序」,而这题真正的难点是**排完之后注入多少、以及注入之后用户怎么知道注了什么**。区分度就在这两处。
    2. 怎么拆:先立默认值——**默认是不注入,不是全注入。** 一个记了三百条的目录全量注入等于把记忆变成第二份系统提示,而其中两百九十条和这句话无关;它们不只浪费额度,还会把模型带偏。所以打分为零的一条都不进,**宁可漏也不要糊**。这一句是这题的第一个分水岭。
    3. 然后给判据,并按可靠性排序。我用三个:**路径命中**(这句话里提到的文件正好是某条记忆挂着的文件,权重最高——路径是精确的,不像词那样撞车)、**关键词命中**(命中几个算几分,但**必须有上限**)、**来源加权**(同分时用户亲手写下的压过模型自动沉淀的)。第三条的理由不是「人一定对」,是责任:用户写的那条他自己记得、能自己删,模型沉淀的那条没人认领。
    4. 关键词上限那条是第二个分水岭,因为它是只有踩过才知道的:**少了它,一条关键词写得多的记忆只要撞上七八个词就能压过一条路径精确命中的记忆。** 那不叫相关,那叫关键词堆砌——而堆砌它的既可能是兜底的抽词器(中文没有空格,不引分词表就只能切相邻两字组合,一句话能切出十几个),也可能是模型自己:让它给关键词,它会很热心地给十二个。
    5. 两个上限都要有:**条数管「别糊」,字符管「别把额度吃光」**。条数要小得反直觉(一次两三条),因为注入的价值随条数递减而干扰随条数递增。至于这一路总共该占多少额度,那属于上下文总预算的分配,是另一个题目;这一层只需要报出「我占了多少字符、约多少 token」。
    6. 最后是这题真正的深水区:**命中原因必须打给用户看,而且不发给模型。** 记忆是唯一一种「用户没提、却影响了这一轮回答」的上下文——它错了的表现是「模型莫名其妙地坚持一件不成立的事」,而用户手里没有任何东西可查。打出「这条命中几分、因为路径 A 与关键词 B」,问题就从猜变成看一眼。至于模型,它不需要知道分数,那是给人做决策用的。**而且一条都没命中的时候也要打一行「没命中」**:它证明「没注入」是判断的结果,不是功能坏了。
    7. 可预期的追问:什么时候该上向量检索?条目上千、且用户的说法和记忆的措辞经常对不上(同义词、跨语言)的时候。但换之前先想清楚两件事:嵌入模型是新的外部依赖与新的一笔成本,而且**语义相似度是个连续值,你仍然要自己定阈值与条数上限**——这题里的两个上限一个都省不掉,只有排序的那一层换了实现。

    How to reason about it · think before answering

    1. The easy answer is put it in a vector store and do semantic search. That is not wrong, but it moves the problem: a vector store solves ranking, while the hard parts here are how much you inject after ranking and how the user finds out what was injected. Those two are the signal.
    2. How to break it down: set the default first — the default is not to inject, not to inject everything. Fully injecting a directory of three hundred entries turns memory into a second system prompt, and two hundred ninety of them are unrelated to this sentence; they not only waste budget, they pull the model off course. So anything scoring zero gets in at all: prefer missing one over blurring everything. That sentence is the first dividing line.
    3. Then give the criteria, ordered by reliability. I use three: path hits (the file named in this sentence is exactly the file a memory is attached to — highest weight, because paths are exact and do not collide the way words do), keyword hits (score per hit, but with a mandatory cap), and source weighting (on a tie, what the user wrote by hand beats what the model sedimented). The third is not because humans are always right; it is accountability — the user remembers and can delete his own entry, while nobody owns the model's.
    4. The keyword cap is the second dividing line, because you only learn it by getting burned: without it, a memory with many keywords beats a memory with an exact path hit just by colliding on seven or eight words. That is not relevance, it is keyword stuffing — and the stuffing comes either from your fallback extractor (Chinese has no spaces, so without a tokenizer you slice adjacent character pairs and one sentence yields a dozen) or from the model itself, which will cheerfully hand you twelve keywords.
    5. You need both limits: a count limit against blur and a character limit against budget. The count should be counterintuitively small — two or three — because injected value falls with each extra entry while interference rises. How much of the total budget this lane deserves is a separate question about overall context allocation; this layer only needs to report how many characters and roughly how many tokens it took.
    6. The real deep end: hit reasons must be shown to the user and must not be sent to the model. Memory is the only context the user never mentioned that still shapes this answer — when it is wrong, the symptom is the model inexplicably insisting on something untrue, and the user has nothing to inspect. Printing this entry scored N because of path A and keyword B turns guessing into looking. The model does not need the scores; those are for a human decision. And when nothing matches, print a line saying so — it proves that not injecting was a decision, not a broken feature.
    7. Likely follow-up: when should you move to vector retrieval? Once you have thousands of entries and the user's phrasing often misses the memory's wording — synonyms, cross-language. But settle two things first: an embedding model is a new external dependency and a new cost, and semantic similarity is a continuous value, so you still have to pick your own threshold and count limit. Neither limit here goes away; only the ranking layer changes implementation.

    答题要点

    • 默认不注入:0 分的一条都不进上下文,宁可漏也不要糊
    • 三个判据按可靠性排:路径命中最硬、关键词命中按个数计分、来源加权(用户写下的压过模型沉淀的)
    • 关键词必须有命中上限,否则关键词堆砌能压过路径精确命中
    • 两个上限都要:条数管别糊、字符管别把额度吃光;条数要小得反直觉
    • 命中原因打给用户不发给模型;一条都没命中也要明确说「没命中」
    • 上向量检索只换掉排序那一层,两个上限与命中原因该打给谁一个都省不掉

    Key points

    • Default to not injecting: anything scoring zero stays out; prefer missing one over blurring everything
    • Three criteria by reliability: exact path hits, keyword hits scored per hit, and source weighting
    • Keyword hits need a cap, or keyword stuffing outranks an exact path match
    • Both limits matter: count against blur, characters against budget; the count should be small
    • Show hit reasons to the user, not the model; print an explicit no-match line when nothing hits
    • Vector retrieval only replaces the ranking layer — the limits and the hit reporting still apply
  • 哪些内容绝不该写进长期记忆?记忆过期了怎么发现?What must never go into long-term memory, and how do you detect that a memory has gone stale?
    国内高频海外高频深入#memory-hygiene#secret-redaction

    分析过程 · 先想清楚再作答

    1. 这题两问,前一问容易答(谁都知道别记密钥),后一问才是区分度所在——**「记忆会过期」这件事绝大多数实现根本没做,做了的也常常做成自动删。** 前一问要靠具体的实现细节证明你真做过闸,后一问要靠一条可核对的机制。
    2. 第一问怎么拆:三类,而且三类的**性质完全不同**,所以处理方式也不一样。① **密钥类,安全问题**:记忆目录是明文的、常驻的、每一轮都往模型发一遍,一条记住的密钥等于抄进了每一次请求,拦得最死。② **临时结论,分类问题**:「现在那个用例是红的」是过程不是事实,属于会话日志;写进记忆的后果是三天后它带着一条早就不成立的「现在」进上下文,而它自己说不清那是什么时候的现在。③ **代码里已经写着的事,预算问题**:这一类不是错的,是不值得——读一次文件就知道的事占着常驻额度每一轮重发。
    3. 密钥那一类有三个只有做过才说得出的细节:**按形状拦不按变量名拦**(「我的 key 是 sk 加一长串」里那个长串才是要拦的,而「凭据要放进环境变量不要写死」是条好记忆);**拒绝话术里一个字的原文都不许回显**(把它抄进错误信息,等于把它写进了另一份日志——终端历史、会话日志、CI 输出);**只说不能记是没尽到责任的**,那个凭据已经出现在这次输入里了,要提醒用户去轮换。再加一句诚实的边界:拦住写入不等于万事大吉,模型请求的工具卡片与审批提示可能还在回显它,真要挡住得在渲染层再脱敏一次。
    4. 后两类的共同点是**一定会误杀**,所以拒绝话术必须给出改法。「这个仓库现在还在用 CommonJS」其实是条不错的记忆,只是带了个时间词;告诉用户「去掉时间词重写一遍就能记」,他就会改,只说「不许记」他会直接放弃这个功能。而「代码里已经写着」这条闸的旋钮更刁:判断片段的长度下限给小了会大面积误杀(给四个字符,「测试只能用 node --test」会被 test 命中 package.json),而且**必须只在这句话点了某个文件名时才去读那个文件**——全仓库搜索的命中率高得离谱,那条闸会直接变成「什么都不许记」。
    5. 三类都要拦在**写入那一层**,不是在两个入口各写一遍。显式命令与模型工具都汇到同一个写入函数,理由是不变量属于数据:少写一处,那一处就是漏洞——而这里漏掉的是密钥。拒绝的理由则原样回灌给模型,它读了理由自己就会改(把密钥改成记住那个环境变量名)。
    6. 第二问:**给每条记忆一个可核对的锚点。** 一条记忆说「测试要用内置运行器」,那它就该能指出「去看 package.json,里面写着那条命令」;注入之前去核对一眼。于是有三种状态:核对通过就注入;锚点失效(文件没了、那段字不在了)**不注入,但要在终端列出来**;没有锚点的只能靠年龄提醒——没有锚点不代表它过期了,只代表没人能替你判断它过没过期。
    7. 最后是这题真正的深水区,两条都反直觉。一、**失效了不要自动删**:锚点失效不等于这条记忆错了,也可能只是文件改了名,而**自动删是不可逆的,判据却只是一次字符串包含检查**。二、**不注入必须说出来**:悄悄不注入的话,用户会以为它还在生效,下次模型答错时他去查记忆目录,那条记忆明明还在文件里——**静默失效比失效更难查。** 同一条道理适用于删除:删了个不存在的编号也要报出来。
    8. 可预期的追问:那没有锚点又没过期机制的记忆怎么办?靠三件事兜底——写下时间和来源永远保留(出问题时你要知道是谁写的)、注入时带上「写于多久之前」、以及一条能让用户一眼看完全部记忆与核对状态的命令。**记忆系统的可维护性不在写入侧,在「能不能看见并且删掉」这一侧。**

    How to reason about it · think before answering

    1. Two parts. The first is easy — everyone knows not to store secrets. The second is where the signal is: most implementations never handle memory going stale at all, and those that do often implement it as automatic deletion. Part one needs concrete implementation details to prove you built the gate; part two needs a verifiable mechanism.
    2. How to break part one down: three classes, and their natures differ completely, so the handling differs too. Secrets are a security problem: the memory directory is plaintext, resident, and sent to the model every round, so one remembered secret is copied into every request — gate it hardest. Transient conclusions are a classification problem: that test is red right now is process, not fact, and belongs in the session log; store it and three days later it carries a long-dead now into the context without being able to say when that now was. Things already written in the code are a budget problem: not wrong, just not worth it — something you learn by reading a file once occupies resident budget and is resent every round.
    3. The secret class has three details only a builder would mention. Gate by shape, not by variable name: in my key is sk plus a long string, the long string is the target, while credentials belong in environment variables, never hardcoded is a good memory. Never echo a single character of the blocked value in the rejection message — copying it into an error writes it into another log: terminal scrollback, the session log, CI output. And saying you cannot store this is not enough: that credential already appeared in this input, so tell the user to rotate it. Add an honest boundary too: blocking the write is not the whole job, since the tool-call card and approval prompt may still echo it, and stopping that needs a second redaction in the render layer.
    4. The other two classes share a property: they will produce false positives, so the rejection must offer a fix. This repo is still on CommonJS right now is actually a decent memory that merely carries a time word; tell the user to drop the time word and rewrite it and he will, whereas a flat refusal makes him abandon the feature. The already-in-the-code gate has a trickier knob: too small a minimum fragment length causes wide false positives (at four characters, tests must use node --test gets matched by test inside package.json), and you must only read a file when the sentence actually names it — full-repo search hits so often that the gate degrades into nothing may be remembered.
    5. All three gates belong in the write layer, not duplicated at each entry point. The explicit command and the model tool both funnel into one write function, because invariants belong to the data: miss one place and that place is the hole — and here the hole leaks secrets. The rejection reason is fed back to the model verbatim, and it corrects itself, replacing the secret with the name of the environment variable.
    6. Part two: give every memory a verifiable anchor. A memory saying tests need the built-in runner should be able to point at go look at package.json, that command is written there — and you check it before injecting. That yields three states: verified, so inject; anchor broken (file gone, or the string no longer present), so do not inject but list it in the terminal; no anchor, so fall back to age — lacking an anchor does not mean it expired, only that nobody can judge whether it did.
    7. The deep end has two counterintuitive rules. First, do not auto-delete on failure: a broken anchor does not mean the memory is wrong, perhaps the file was just renamed, and deletion is irreversible while the test is a single substring check. Second, not injecting must be announced: stay silent and the user assumes it is still in effect, so the next time the model is wrong he checks the directory and the memory is plainly still there — silent failure is harder to debug than failure. The same applies to deletion: report it when the id you were told to delete does not exist.
    8. Likely follow-up: what about memories with neither an anchor nor an expiry rule? Three fallbacks: always keep the write time and the source (when something goes wrong you need to know who wrote it), stamp written N days ago onto the injection, and provide one command that shows every memory with its verification status. Maintainability of a memory system lives on the can I see it and delete it side, not on the write side.

    答题要点

    • 三类各有不同性质:密钥是安全问题、临时结论是分类问题、代码里已有的是预算问题
    • 密钥按形状拦不按变量名拦;拒绝话术不回显原文,还要提醒轮换;并承认渲染层可能仍在回显
    • 后两类必然误杀,所以拒绝必须给出改法;「代码里已有」的长度下限与「只读被点名的文件」两条都不能省
    • 三类都拦在唯一的写入函数里,理由原样回灌给模型让它自己改
    • 过期靠锚点核对:通过则注入、失效则不注入但要列出来、没锚点的靠年龄提醒
    • 失效不自动删(不可逆,而判据只是一次包含检查);不注入必须说出来,静默失效比失效更难查

    Key points

    • Three classes, three natures: secrets are security, transient conclusions are classification, already-in-code is budget
    • Gate secrets by shape, never echo the value, and tell the user to rotate it; admit the render layer may still echo
    • The latter two will misfire, so rejections must offer a fix; keep both the length floor and the named-file-only rule
    • All three gates live in the single write function; the reason is fed back so the model corrects itself
    • Staleness needs a verifiable anchor: verified injects, broken does not inject but is listed, no anchor falls back to age
    • Never auto-delete on a broken anchor, and always announce a non-injection — silent failure is the harder bug

评论