Cross-Session Memory: Explicit Memory, Automatic Memory, and Three Criteria for Retrieval Injection
Make the agent remember this repo's pitfalls: implement a file-based memory directory, distinguish memory a user writes explicitly from memory the model distills automatically, retrieve by keyword and path and inject only the relevant few entries, and work out exactly what should never be remembered.
Today's Goals
- Design a file-based memory store, and explain how it divides labor with the session log and instruction files
- Implement memory retrieval and on-demand injection, controlling entry count and budget
- Name three categories of content that must never be written to memory, and block them in the implementation
Yesterday's checklist was ephemeral; today's is the opposite. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Handover notes: the traps in this repo, written down for the next person
The new hire has worked ten days: reads code, edits files, knows the team rules, and keeps a checklist. Today they clock off and someone else arrives tomorrow.
So the same thing happens a second time: the newcomer runs npm test, finds it is an empty shell, and spends twenty minutes discovering this repository's tests need a different command. Last week's hire hit the same trap and left it in their own head.
Teams that hand over well have a solution: a book of handover notes. Not a diary and not a rulebook, just a stack of short "there is a trap here" cards, one thing each, with who wrote it and when. The next person does not read it through; they pull out the one or two cards when they hit related work.
The difference from day seven's session log is worth stating up front: day seven solved how one conversation continues; today solves how experience passes between different conversations. Restoring a session helps only with the same job, while "tests need a different command" has nothing to do with what you discussed last time.
So the core proposition is one sentence: an Agent has no memory, but you can give it a searchable external notebook. The hard part is not writing things down, it is pulling out only the right few afterwards — and what must never be written down at all.
Three things govern three things: facts, process, rules
By today mca has three mechanisms that all look like "storing something," and conflating them is this feature's most common death.
| Memory (today) | Session event log (day seven) | Project instruction files (day nine) | |
|---|---|---|---|
| Stores | facts | process | rules |
| Written by | the user explicitly, plus the model distilling | the loop, appended automatically | humans, into the repository |
| Lifetime | long-lived across sessions, can expire | one per session, append-only | versioned with the repository |
| Used by | retrieval by relevance, only a few injected | full replay on recovery | fully included in the system prompt every turn |
| Versioned | no: this is experience learned on this machine | no | yes |
| Failure looks like | a wrong fact remembered, the model confidently wrong | recovery to the wrong point | the model inexplicably ignoring instructions |
Three criteria help sort where something goes:
- "Will it still hold next time?" If not, it is process and belongs in the log. "That test run was red" does not go into memory.
- "Was it learned or decreed?" Decreed goes to instruction files, learned goes to memory.
- "Can one read of the code tell you?" If yes, it need not be remembered. Memory holds traps hit, verbal conventions, approaches tried and rejected, and user preferences.
The second criterion also answers why the memory directory is not versioned: a "fact" that can affect everyone without review is dangerous. Experience worth sharing with the whole team belongs in an instruction file, where a human reads it before merging.
One memory, one file
The storage shape looks least technical and constrains every later decision. The lab uses one memory per file, formatted as a few metadata lines, a blank line and the body:
id: m1
source: model
at: 2026-09-07T00:00:00.000Z
keys: test, verify, node, --test
paths: package.json, test
anchor-path: package.json
anchor-contains: node --test
This repository's tests only run under node --test; the script entry is for CI, and running
it locally loads one case fewerWhy not one large file — four engineering reasons:
- Metadata has somewhere to hang. A large file can carry only one timestamp for the whole thing, so a sentence from three months ago looks as fresh as yesterday's during retrieval.
- The unit of retrieval is naturally a file. A hit can point at a filename the user can open and delete directly. A large file must be chunked first, and the chunking rule is the next bug's source.
- Concurrent writes do not collide. Appending to one large file interleaves, and an interleaved line is permanently corrupt.
- Deleting one entry is deleting one file. Editing a large file is a full rewrite, and a bad write loses the whole memory.
The cost is that the directory grows large after a few hundred entries, so deletion and expiry checks are not extras but the precondition for the feature surviving two weeks.
source and at are the cheapest and most valuable: when something goes wrong you must be able to answer who wrote that false fact and when.
Two write paths: an explicit command and model distillation
Memory has two entrances, and neither is optional.
The first is explicit: /remember this repository's tests only run under node --test. It should exist first — users know which traps are worth recording, and what they wrote they remember and can delete themselves.
The second is a remember tool the model calls. It is convenient, at the cost of nobody owning what it wrote. One detail on this path: let it supply the keywords and paths rather than extracting them from the body — it just read that file and knows what topics this experience will be useful under later. Deciding how to find something again at write time is far cheaper than guessing at retrieval time.
Both paths must converge on one write function: interception, deduplication and persistence written once. Same reason as day ten's "validation in the data model, not the tool" — invariants belong to the data, and a missing one is a hole, and here the hole leaks secrets.
One easily missed judgment: remember is not a read-only tool, exactly opposite to day ten's checklist tool. The checklist is this task's ephemeral working surface; memory stays on disk and affects every future session — a write that changes the behavior of all future sessions is exactly what day five's approval gate is for. The criterion was never "does it change the user's source code" but "can it cause irreversible external effects."
Retrieval and injection: three criteria, two caps, and one replaceable slot
First a discipline: the default is "do not inject," not "inject everything." Injecting a three-hundred-entry directory in full turns memory into a second system prompt, and two hundred and ninety of those entries are unrelated to this sentence — they waste budget and pull the model off course. Better to miss than to smear.
Three criteria, whose scores are their reliability order:
// Criterion one: a path hit. The hardest signal - paths are exact and do not collide like words
const pathHits = paths.filter((p) => haystack.includes(p.toLowerCase()))
if (pathHits.length > 0) {
score += PATH_SCORE * pathHits.length
reasons.push(`paths ${pathHits.join(', ')}`)
}
// Criterion two: keyword hits. One word may be coincidence, three is the same topic;
// **more adds nothing** - otherwise an entry with many keywords always ranks first
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(`keywords ${keyHits.slice(0, counted).join(', ')}`)
}
// No hit at all means it does not enter the context.
// **This one line is the entire implementation of "inject only the relevant"**
if (score === 0) continue
// Criterion three: source weighting. On a tie, what the user wrote beats what the model distilled
if (entry.memory.source === 'command') score += SOURCE_BONUSdef score_one(entry: Checked, haystack: str) -> Hit | None:
"""One memory's relevance to this sentence. None means "does not enter the context"."""
score, reasons = 0, []
# Criterion one: a path hit. The hardest signal - paths are exact, words collide
if hits := [p for p in entry.paths if p.lower() in haystack]:
score += PATH_SCORE * len(hits)
reasons.append("paths " + ", ".join(hits))
# Criterion two: keyword hits, counted up to KEY_HIT_CAP. More adds nothing,
# or the entry with the most keywords always ranks first
if keys := [k for k in entry.keys if k.lower() in haystack]:
counted = min(len(keys), KEY_HIT_CAP)
score += KEY_SCORE * counted
reasons.append("keywords " + ", ".join(keys[:counted]))
# No hit means no context - this line is all of "inject only the relevant"
if score == 0:
return None
# Criterion three: source weighting. On a tie, the user's own entry wins
if entry.source == "command":
score += SOURCE_BONUS
return Hit(entry, score, reasons)The third criterion's reason is not "humans are always right" but ownership: the entry the user wrote, they remember and can delete; the one the model distilled has no owner.
Both caps are needed: the count prevents smearing, the character budget prevents eating the allowance. The lab injects at most three at a time — a number small enough to look like a typo, and it is meant to be that small.
Then today's most important structural decision, exactly opposite to day eight's reference injection: references are one-off (material named by this sentence, inserted once and counted once), memory is permanent (background knowledge belonging to no particular sentence). So memory does not travel with the user message; it occupies one slot at the front of the history (right after the system message), recomputed each turn against the current sentence and replaced in place:
Mermaid source
flowchart LR
A[this user sentence] --> B[read from disk: all memories]
B --> C[verify: does the anchor still hold]
C --> D[retrieve: score by three criteria]
D --> E{any hits}
E -->|yes| F[replace the memory slot in place]
E -->|no| G[remove the slot entirely]
F --> H[accounting printed in the terminal]
G --> HBoth consequences are wanted. One: constant occupancy — append-style injection leaves ten memory blocks after ten turns, nine of them stale retrieval results. Two: a stable prefix — permanent things go first so the gateway has a chance to hit its cache.
One last mandatory step: print the hit reasons for the user, and do not send them to the model.
Memory injected 1/1 entries (146 characters, about 124 tokens, 12% of the memory budget; at most 3 at a time)
v [m1] This repository's tests only run under node --test; the script entry is for C
3 points: keywords test, written by the userMemory is the only context that the user never mentioned yet shapes this turn's answer. When it is wrong, the symptom is "the model inexplicably insists on something untrue," and the user has nothing to inspect. With these lines, "why did it say to use the built-in test runner" changes from a guess to a glance. The model does not need the scores — those are for a human's decision.
When nothing hits, print a line saying so. That line matters more than the injection: it proves "no injection" was a judgment rather than a broken feature.
How much of the total budget this path should take, and how the total is divided — that is tomorrow's subject; today reports only this path. The estimator is reused verbatim from day eight: the same text reported as two different numbers in two places makes reconciliation impossible.
What must not be remembered: three categories, blocked at the write layer
The three categories have reasons of different natures and different handlings.
One: secrets, a security matter, blocked hardest. The memory directory is plaintext, permanent, and resent to the gateway every turn — one remembered secret is copied into every request. The criterion is block by shape, not by variable name: in "my key is sk followed by a long string," the long string is what to block, while "credentials belong in environment variables, never hard-coded" contains no secret at all and is a good memory.
const SECRET_RULES: Array<{ rule: string; pattern: RegExp }> = [
{ rule: 'family-prefixed token', pattern: /\b(?:sk|pk|ghp|gho|xox[baprs])[-_][A-Za-z0-9]{16,}/ },
{ rule: 'cloud access key', pattern: /\b(?:AKIA|ASIA|AIza)[A-Za-z0-9]{12,}/ },
{ rule: 'PEM private key block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
{ rule: 'connection string with a password', 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,
// **Not one character of the original appears here.** Echoing a blocked secret
// writes it into another log (terminal history, session log, CI output)
note:
`this content has the shape of a secret (${rule}); it was refused and the original is recorded nowhere. ` +
'Put it in an environment variable and record only which variable this project needs. ' +
'Also: it already appeared in this input, so rotate that credential.',
}
}SECRET_RULES: list[tuple[str, re.Pattern[str]]] = [
("family-prefixed token", re.compile(r"\b(?:sk|pk|ghp|gho|xox[baprs])[-_][A-Za-z0-9]{16,}")),
("cloud access key", re.compile(r"\b(?:AKIA|ASIA|AIza)[A-Za-z0-9]{12,}")),
("PEM private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("connection string with a password", re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s/@]{6,}@")),
]
def screen_secret(text: str) -> Verdict | None:
for rule, pattern in SECRET_RULES:
if not pattern.search(text):
continue
# Not one character of the original appears here: echoing a blocked secret
# writes it into another log (terminal history, session log, CI output)
return Verdict(
allowed=False,
reason="secret",
note=(
f"this content has the shape of a secret ({rule}); it was refused and the "
"original is recorded nowhere. Put it in an environment variable and record "
"only which variable this project needs. Also: it already appeared in this "
"input, so rotate that credential."
),
)
return NoneThe refusal's last sentence is the point: saying only "this cannot be remembered" is not enough. That credential already appeared in this input and already sits in terminal history and the session log, so remind the user to rotate it.
Also state the implementation's boundary: the lab's redaction covers only the memory path. The model's tool card and the approval prompt still echo what it wanted to remember, so a secret still flashes on screen once. Blocking the echo too requires redacting in the render layer. Knowing where that boundary lies matters more than assuming a write block is the end of it.
Two: temporary conclusions, a classification matter. "That case is red right now" is process, not fact, and belongs in day seven's session log: three days later it enters the context with a "right now" that stopped holding long ago, and it cannot say when that "now" was. The criterion is "this sentence contains a time anchor that held only at the moment of speaking." This one will produce false positives — "this repository still uses CommonJS" is a decent memory — so the refusal must offer a rewrite. An interception with no way forward makes users abandon the feature.
Three: things already written in the code, a budget matter. Not wrong, just not worth it: something one file read would tell you occupies permanent budget and is resent every turn. There is a counterintuitive knob in the implementation: the minimum fragment length for judging "is this restating code" must be set larger than intuition suggests — at four characters, "tests only run under node --test" is falsely killed because test appears in package.json. And only read a file when this sentence named it: a whole-repository search hits absurdly often and turns that gate into "nothing may be remembered."
All three are blocked at the write layer, and the reason is fed back verbatim to the model. The lab shows a clean scene: the model tries to remember a key, is refused, reads the reason, and next turn records the environment variable name instead — a failed tool result is its instruction sheet for the next step, the same channel as day three's bad arguments.
Memory expires: verify before using
The last section decides whether the feature is an asset or a liability in three months. A memory is true the moment it is written, and then the world changes. So the metadata may carry an anchor: a checkable basis for "how do I tell whether this still holds." The "tests use the built-in runner" entry should point at "look in package.json, that command is written there."
const anchor = memory.anchor
if (!anchor) {
// With no anchor, only age is available. It is not "expired", it is
// "nobody can judge for you whether it expired"
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
? `the anchor file ${anchor.path} is gone`
: source.includes(anchor.contains)
? null
: `${anchor.path} no longer contains "${anchor.contains}"`
// Anything broken becomes stale: **it is not injected, only listed for a human**
checked.push({ memory, ageDays, status: broken ? 'stale' : 'fresh', note: broken ?? 'anchor verified' })def verify(memory: Memory, cwd: Path, now: float) -> Checked:
age_days = max(0, int((now - memory.at.timestamp()) // 86_400))
if memory.anchor is None:
# With no anchor, only age is available. Not "expired", but
# "nobody can judge for you whether it expired"
old = age_days >= MEMORY_AGING_DAYS
return Checked(memory, "aging" if old else "fresh", age_days, ...)
path, contains = memory.anchor
try:
source = (cwd / path).read_text(encoding="utf-8")
except OSError:
return Checked(memory, "stale", age_days, f"the anchor file {path} is gone")
if contains not in source:
return Checked(memory, "stale", age_days, f'{path} no longer contains "{contains}"')
return Checked(memory, "fresh", age_days, f"anchor verified in {path}")Three statuses, three handlings:
- Verified: injected normally.
- Anchor broken: not injected, but listed in the terminal. Silently skipping is wrong — the user assumes it is still in effect, and when the model answers wrongly next time they check the memory directory and the entry is plainly still there. Silent invalidation is harder to diagnose than invalidation.
- No anchor and old: still injected, with a note that it may be out of date. Having no anchor does not mean it expired, only that nobody can judge for you whether it did.
One more boundary: a broken anchor does not mean the memory is wrong; the file may simply have been renamed. So the handling is "take it down for a human to look at," not automatic deletion — deleting a memory automatically is irreversible, and the criterion is only a substring check.
Source Reading
Hands-On Lab
Today leaves five exercises, three of which are "looks harmless, is fatal" traps: no zero-score filter (so everything is injected), no cap on keyword hits (so stuffing beats an exact path hit), and appending to the slot (so ten turns leave ten memory blocks). The starter passes nine of seventeen unmodified.
The cross-session scene cannot be staged in a pipe (one pipe is one process and one session), so the self-test handles it: have the model record one entry in session one, then open a new session whose history is only a system message and ask something related, asserting that the fact entered this turn's request. Offline, "what the model said" is scripted, so what is verifiable is whether the fact came back into the request — which is the definition of recall.
- Complete deduplication in the write layer, and work out why interception must live there too.
- Finish the three interceptions: secrets blocked by shape with no echo of the original, and a rewrite offered for the other two.
- Implement anchor verification: broken ones taken down for a human, old ones without an anchor carrying a "verify first" note, and nothing deleted automatically.
- Add the retrieval zero-score filter and the keyword cap, and print hit reasons in the terminal.
- Make the slot replace in place: right after the system message, recomputed each turn, removed entirely when nothing hits. The self-test should read 17/17 passed.
Acceptance is four ticks: the self-test prints 17/17 passed; a related sentence hits and prints reasons while an unrelated one explicitly says nothing hit; several turns in a row leave exactly one memory message in the array; and a secret is refused and cannot be found anywhere in the memory directory. Counts, scores and character counts are reproducible offline; write times and session ids are not.
Interview Questions
Today's three questions test how an external notebook avoids becoming a landfill:
- What do an Agent's memory, session history and project instruction files each solve?
- How must memory be retrieved so only the relevant is injected? How would you define relevance?
- What must never be written into long-term memory? How do you discover that a memory has expired?
Full bilingual prompts, analyses and key points are in this course's day-eleven question bank. Question two is most often answered as "use a vector store" — that just relocates the problem, and few can state "zero score means no injection" and "hit reasons must be shown to the user."
Checklist and Tomorrow
- I can use the three criteria to sort something into memory, the session log or instruction files
- I know why the memory directory is not versioned, and where shareable experience belongs
- I can name the four reasons for one memory per file, its cost, and why both write paths converge on one function
- I can state the three retrieval criteria in order, and what "zero score means no injection" and the keyword cap each block
- I know why memory is a slot replaced in place, and the reason behind each of the three forbidden categories
- I can explain the three handlings of anchor verification, and why a broken anchor is not deleted automatically
Tomorrow is D12, "Context Compaction: Counting Tokens, What to Compress and What to Keep, and Verifying Nothing Was Lost." Today reported only this path's ledger; tomorrow answers how the total budget is divided among seven paths and which path is compressed first when it fills. The order matters: get each path's ledger straight before dividing — with only one total you know only that it is full; with per-path allowances you know whose turn it is.
Interview questions
What problem does an agent's memory solve, versus conversation history and project instruction files?Agent 的记忆和会话历史、项目指令文件分别解决什么问题?
Common in ChinaCommon overseasBasic#agent-memory#context-designHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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).
- 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.
- 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?记忆怎么检索才能只注入相关的?相关性判据你会怎么定?
Common in ChinaCommon overseasIntermediate#memory-retrieval#context-injectionHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题最容易答成「上向量库做语义检索」。那不是错,但它把问题换了个地方放:向量库解决的是「怎么排序」,而这题真正的难点是**排完之后注入多少、以及注入之后用户怎么知道注了什么**。区分度就在这两处。
- 怎么拆:先立默认值——**默认是不注入,不是全注入。** 一个记了三百条的目录全量注入等于把记忆变成第二份系统提示,而其中两百九十条和这句话无关;它们不只浪费额度,还会把模型带偏。所以打分为零的一条都不进,**宁可漏也不要糊**。这一句是这题的第一个分水岭。
- 然后给判据,并按可靠性排序。我用三个:**路径命中**(这句话里提到的文件正好是某条记忆挂着的文件,权重最高——路径是精确的,不像词那样撞车)、**关键词命中**(命中几个算几分,但**必须有上限**)、**来源加权**(同分时用户亲手写下的压过模型自动沉淀的)。第三条的理由不是「人一定对」,是责任:用户写的那条他自己记得、能自己删,模型沉淀的那条没人认领。
- 关键词上限那条是第二个分水岭,因为它是只有踩过才知道的:**少了它,一条关键词写得多的记忆只要撞上七八个词就能压过一条路径精确命中的记忆。** 那不叫相关,那叫关键词堆砌——而堆砌它的既可能是兜底的抽词器(中文没有空格,不引分词表就只能切相邻两字组合,一句话能切出十几个),也可能是模型自己:让它给关键词,它会很热心地给十二个。
- 两个上限都要有:**条数管「别糊」,字符管「别把额度吃光」**。条数要小得反直觉(一次两三条),因为注入的价值随条数递减而干扰随条数递增。至于这一路总共该占多少额度,那属于上下文总预算的分配,是另一个题目;这一层只需要报出「我占了多少字符、约多少 token」。
- 最后是这题真正的深水区:**命中原因必须打给用户看,而且不发给模型。** 记忆是唯一一种「用户没提、却影响了这一轮回答」的上下文——它错了的表现是「模型莫名其妙地坚持一件不成立的事」,而用户手里没有任何东西可查。打出「这条命中几分、因为路径 A 与关键词 B」,问题就从猜变成看一眼。至于模型,它不需要知道分数,那是给人做决策用的。**而且一条都没命中的时候也要打一行「没命中」**:它证明「没注入」是判断的结果,不是功能坏了。
- 可预期的追问:什么时候该上向量检索?条目上千、且用户的说法和记忆的措辞经常对不上(同义词、跨语言)的时候。但换之前先想清楚两件事:嵌入模型是新的外部依赖与新的一笔成本,而且**语义相似度是个连续值,你仍然要自己定阈值与条数上限**——这题里的两个上限一个都省不掉,只有排序的那一层换了实现。
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
答题要点
- 默认不注入:0 分的一条都不进上下文,宁可漏也不要糊
- 三个判据按可靠性排:路径命中最硬、关键词命中按个数计分、来源加权(用户写下的压过模型沉淀的)
- 关键词必须有命中上限,否则关键词堆砌能压过路径精确命中
- 两个上限都要:条数管别糊、字符管别把额度吃光;条数要小得反直觉
- 命中原因打给用户不发给模型;一条都没命中也要明确说「没命中」
- 上向量检索只换掉排序那一层,两个上限与命中原因该打给谁一个都省不掉
What must never go into long-term memory, and how do you detect that a memory has gone stale?哪些内容绝不该写进长期记忆?记忆过期了怎么发现?
Common in ChinaCommon overseasDeep dive#memory-hygiene#secret-redactionHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 是 sk 加一长串」里那个长串才是要拦的,而「凭据要放进环境变量不要写死」是条好记忆);**拒绝话术里一个字的原文都不许回显**(把它抄进错误信息,等于把它写进了另一份日志——终端历史、会话日志、CI 输出);**只说不能记是没尽到责任的**,那个凭据已经出现在这次输入里了,要提醒用户去轮换。再加一句诚实的边界:拦住写入不等于万事大吉,模型请求的工具卡片与审批提示可能还在回显它,真要挡住得在渲染层再脱敏一次。
- 后两类的共同点是**一定会误杀**,所以拒绝话术必须给出改法。「这个仓库现在还在用 CommonJS」其实是条不错的记忆,只是带了个时间词;告诉用户「去掉时间词重写一遍就能记」,他就会改,只说「不许记」他会直接放弃这个功能。而「代码里已经写着」这条闸的旋钮更刁:判断片段的长度下限给小了会大面积误杀(给四个字符,「测试只能用 node --test」会被 test 命中 package.json),而且**必须只在这句话点了某个文件名时才去读那个文件**——全仓库搜索的命中率高得离谱,那条闸会直接变成「什么都不许记」。
- 三类都要拦在**写入那一层**,不是在两个入口各写一遍。显式命令与模型工具都汇到同一个写入函数,理由是不变量属于数据:少写一处,那一处就是漏洞——而这里漏掉的是密钥。拒绝的理由则原样回灌给模型,它读了理由自己就会改(把密钥改成记住那个环境变量名)。
- 第二问:**给每条记忆一个可核对的锚点。** 一条记忆说「测试要用内置运行器」,那它就该能指出「去看 package.json,里面写着那条命令」;注入之前去核对一眼。于是有三种状态:核对通过就注入;锚点失效(文件没了、那段字不在了)**不注入,但要在终端列出来**;没有锚点的只能靠年龄提醒——没有锚点不代表它过期了,只代表没人能替你判断它过没过期。
- 最后是这题真正的深水区,两条都反直觉。一、**失效了不要自动删**:锚点失效不等于这条记忆错了,也可能只是文件改了名,而**自动删是不可逆的,判据却只是一次字符串包含检查**。二、**不注入必须说出来**:悄悄不注入的话,用户会以为它还在生效,下次模型答错时他去查记忆目录,那条记忆明明还在文件里——**静默失效比失效更难查。** 同一条道理适用于删除:删了个不存在的编号也要报出来。
- 可预期的追问:那没有锚点又没过期机制的记忆怎么办?靠三件事兜底——写下时间和来源永远保留(出问题时你要知道是谁写的)、注入时带上「写于多久之前」、以及一条能让用户一眼看完全部记忆与核对状态的命令。**记忆系统的可维护性不在写入侧,在「能不能看见并且删掉」这一侧。**
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
答题要点
- 三类各有不同性质:密钥是安全问题、临时结论是分类问题、代码里已有的是预算问题
- 密钥按形状拦不按变量名拦;拒绝话术不回显原文,还要提醒轮换;并承认渲染层可能仍在回显
- 后两类必然误杀,所以拒绝必须给出改法;「代码里已有」的长度下限与「只读被点名的文件」两条都不能省
- 三类都拦在唯一的写入函数里,理由原样回灌给模型让它自己改
- 过期靠锚点核对:通过则注入、失效则不注入但要列出来、没锚点的靠年龄提醒
- 失效不自动删(不可逆,而判据只是一次包含检查);不注入必须说出来,静默失效比失效更难查