附录 D 练习参考答案
“巩固”题多数是动手观察,这里给出应当看到的现象;“扩展”题给出实现要点;“读源码”题给出答案与依据。
第 1 章
- 两个块(
persona、human);<memory_metadata>里有 AGENT_ID、CONVERSATION_ID、编译时间、recall 中的消息数(每个回合至少 +2,带工具调用的回合 +4)。 - 在
memory_replace里:new_text === ""时用split("\n").filter(l => !l.includes(old))删整行。新风险:agent 可以“遗忘”——误删、被提示注入诱导删除,而且 v0.1 没有历史可以恢复。这正是第 6 章引入 git 的动机之一。 - 技能通过
<available_skills>列表进入系统提示(本地后端:appendAvailableSkillsBlock(),名字来自请求体的client_skills),技能正文按需加载;其他按项目的上下文通过<system-reminder>注入。
第 2 章
- 见第 2 章 2.1 节的 mini-letta 目录树;补上:
agent.json(第 1 章,store 写,CLI/loop 读)、memory/(第 4 章)、context.json(第 12 章,压缩写、loop 读)、system-prompt.json(第 9 章)、pending-reminders.json(第 9 章,回合后写、回合前读)、compaction-event.json(第 12 章写、第 14 章读)、reflection-state.json(第 14 章)、memory-worktrees/、reflections/payloads/、reflection.lock(第 15–17 章)。 - 增加
format参数:v1 时normalized.startsWith("system/")。 - 子 agent 进程会继承父进程的
MEMORY_DIR/AGENT_ID环境变量;如果它被派去处理另一个 agent(或父 agent 的 worktree),环境变量就是错的。运行时上下文是调用方显式设定的作用域,更可信。
第 3 章
- 记忆变化走的是“系统提示不变 + 记录新 revision”的分支:
content保留旧值以保住提示缓存,memfsRevision、coreMemory、compiledAt更新为新值,免得下一步重复生成<memory_update>。 - 只能在后面追加:例如只发增量(变化了的文件),或在
<memory_update>里声明“取代之前所有的 memory_update”。任何修改已有消息的方案都会破坏缓存前缀。 - 压缩重写了上下文的开头(把一段消息换成摘要),提示缓存的前缀已经失效;此时顺便替换系统提示不会再额外损失缓存。
Lab 1
- 加一条规则:匹配
^忘掉[::](.+)$,调用memory_replace({label:"human", old_text:"- X", new_text:""});测试断言human块不再包含 X。 - 在循环里每次调用模型前
onEvent?.({ type: "step", n: step });CLI 打印· step n。 - 每次
execute()是一“步”:letta-code 的工具(客户端工具)在客户端执行,结果以新的请求发回后端,后端再执行一次。所以本地后端的resolveSystemPromptForTurn每一步都会被调用。
第 4 章
name分别是Persona、Human、Project Conventions(下划线和连字符都被当作分词符)。- 检查
relativePath不以/开头、没有./..段;用toLocaleLowerCase("en-US")比较已有路径。 - 注释说:如果不在创建时原子地打标签、插入根块,Cloud 上的 agent 会“born without GIT_MEMORY_ENABLED_TAG”,此后所有基于标签的检查都会把它当成非 MemFS agent——“on every machine, forever”;原子地做,即使进程中途崩溃,服务器也已经为它初始化了 v2 仓库,后续的惰性同步能把剩下的步骤补齐。
第 5 章
- 删除
projects/archive/MEMORY.md后再提交会被拒:projects/archive/old.md: missing required index projects/archive/MEMORY.md。 - 深度 3(
a/b/c/d.md有 3 个目录段),超过maxDepth: 2被拒:depth 3 exceeds maxDepth 2;另外projects/archive/2025/MEMORY.md也必须存在。 - 通过上级索引里的普通 Markdown 链接被发现(“references as synapses”)。这是有意的:系统提示只放第一层目录,更深的结构交给索引链,保持常驻部分的大小与目录深度无关。
第 6 章
prompt --fresh的输出不含改坏的内容;git stash后工作区恢复。- 在
commitMemoryPaths里try { git commit } catch { git restore --staged -- <paths>; throw };测试:写一个缺 frontmatter 的文件,调用后断言抛错且git diff --cached --name-only为空。 - 不带路径的
git commit会把暂存区里所有东西一起提交,包括 agent 暂存的文件;git commit -- <paths>只提交这些路径。letta-code 的 harness 写者都在claimMemoryOperation锁内运行,并依赖提示词要求 agent 自己“stage 并在同一条命令里 commit”,从而缩小两者交错的窗口;但主 agent 不走这把锁,所以窗口并非为零。
第 7 章
- 提交配置需要
MINI_LETTA_CONSTRAINTS_UPDATE=1;之后 mock 的“设定:”命令会失败,工具结果里是persona.md: read-only file cannot be created, changed or removed,mock 回复“操作失败:…”。 - 审计通过时记录
last-valid-revision;编译时如果 HEAD 审计失败,改为git show <last-valid>:<path>。这并不违背“只认已提交的”——它变成了“只认已提交且有效的”,代价是 HEAD 与生效版本可能不一致,需要在提醒里明说。 - 技能是一个目录:除了
SKILL.md还有scripts/、references/、templates/;目录也让技能能被整体移动、删除、弃用,并让skills/下不会混进普通记忆文件。
Lab 2
memory show <path>→git show HEAD:<path>。- validator 里遍历暂存树,用
git cat-file -s :<path>取大小,超过 1 MB 报错。 .git/hooks不会被 clone;CLI 升级后 hook 脚本(以及嵌入的校验函数源码)可能变了,所以在 clone、pull、init 时都重新安装,保证 hook 与当前 CLI 一致。
第 8 章
- 会:
projects/出现在<deferred-memory>;projects/sub/不会(只列第一层),要靠projects/MEMORY.md里的链接发现。 - 写一个
escapeXmlAttribute(& " ' < >五个字符),用于path、index属性。 - hook 不检查文件名是否是合法的 XML 名称,所以
<my notes>可以出现。模型并不需要严格合法的 XML——这些标签只是给模型看的分节符号——但它会让任何试图用 XML 解析器处理系统提示的工具出错,也让标签边界变得含糊。
第 9 章
- 三条
<memory_update>;recompile删除缓存,下一步完整编译,新系统提示含最新记忆,不再产生新的<memory_update>(但旧的三条仍在对话历史里,直到压缩)。 - 记录上一次
<memory_update>对应的 revision,用git diff --name-only <old> <new>找出变化的 core 文件,只渲染它们;模型需要拼接多个增量,提示文案里要说明“只包含变化的文件”。 - 最小改动是让
<memory_update>进入对话存储(例如在getOrCompileSystemPrompt返回后由后端追加一条持久化消息),这样uiMessages里就有它;改 pi-stream-adapter 只影响单次请求;改persistExecutorStream需要区分“模型产生的消息”和“harness 注入的消息”。无论哪种,都要遵守 CLAUDE.md 的“user 角色 + system-reminder”规则。
第 10 章
- 例:“inspect Git status and read the current files before editing”→ 无强制;“stage only the files you changed”→ 无强制(mini 的 mock 遵守);“commit in the same shell call”→ 无强制;“Verify the committed changes”→ 回合后
MEMORY COMMIT NEEDED提醒是事后反馈。 - 解析每个
MEMORY.md里的](相对路径),检查目标存在。放在提醒里更合适:断链不会让记忆“错误”,只会降低可发现性,而 hook 拒绝会挡住所有提交(第 5 章的整树副作用)。 - 例如本地提示词没有说明根
MEMORY.md不能有 frontmatter、其他文件“恰好name+description”、子目录需要索引——而 hook 按这些规则拒绝提交,本地 agent 只能从报错中学。
Lab 3
- 修改
agent.system后调用resolveSystemPrompt:返回的systemPrompt含新文本,memoryUpdate为undefined。 - 在
runTurn开始处把reminders通过onEvent({type:"reminders", items})发出,CLI 打印。 - 当本地后端启用、或该 agent 启用了 MemFS 时才显示记忆目录(
isLocalBackendEnvEnabled() || settingsManager.isMemfsEnabled(id))——非 MemFS 的 agent 没有记忆目录可言。
第 11 章
- 8 192 → 6 554(预留 1 638);128 000 → 111 616;1 000 000 → 983 616。
complete()返回usage,存进 assistant 消息;估算时取最近一条带 usage 的 assistant 消息的值 + 其后消息的估算;压缩时给摘要消息打上时间戳,早于它的 usage 一律忽略。- 用户故意设置小
max_tokens时,以length停止是正常的产品行为,不该触发压缩;如果按max_tokens算阈值,一个很大的max_tokens会让 harness 过早压缩,一个很小的又会在上下文真的满了时不压缩。
第 12 章
context.json里少了被摘要的那些 id,多了一条摘要消息的 id;messages.jsonl只多不少;摘要消息的kind是"summary"。- 把
estimateMessagesTokens(messages.slice(cutoff)) + floor与(1 - percentage) * window比较;压缩次数减少、每次保留的消息变少,锯齿变缓。 all模式下没有“保留最近消息原文”的需要,唯一不能被摘要掉的是还在等结果的工具调用——它的结果马上要追加在后面,调用必须原样在场。
第 13 章
- 两个对话都会命中;分数不同来自 BM25 的长度归一化(短消息分数更高)和词频。
- 解析引号为短语,候选必须
includes(phrase)(CJK 不加空格边界、拉丁文加),命中短语加分。 - 好处:子 agent 知道父 agent 在做什么,能判断哪些检索结果相关;代价:每次 recall 都要带上父对话的全部上下文 token。
memory子 agent 拿到的是“自包含的指令”(描述里要求 “Make the assignment self-contained”),它需要的是记忆文件而不是父对话,所以不分叉,更便宜。
Lab 4
- 900 的窗口阈值 720,地板(系统提示 + 工具 + agent-info)约 600,主对话几乎每回合都压缩,但仍能继续。我们实际跑了一下,真正出问题的是反思:CLI 让反思子 agent 共用这个 900 的窗口,于是每一次都是
failed(第 15 章踩坑记录 3 的同一个循环)。为此 mini-letta 加了MINI_LETTA_REFLECTION_CONTEXT_WINDOW;设为 8000 后同样的场景反思merged。 - 在
COMPACTION_EVENT里记录被摘要消息的首尾 id;recall_search读取context.json,不在上下文中的命中标注(evicted)。 - 那个判断是在问“滑动窗口这次压缩是否把上下文压到了窗口以内”,如果还超过整个窗口,下一次请求必然失败,就退回
all;而“超过阈值但没超过窗口”可以由下一次请求前的正常检查处理。
第 14 章
stepsSinceLastReflection每次回合 +1(或 +2,带工具调用时),到 2 时触发;删掉reflectedThroughMessageId后,下一次反思会从头再看整个对话——重复内容由反思者的“已经记过了吗”过滤掉。- 清单 JSON:
{ type: "multi_transcript_reflection_payload", transcripts: [{ conversation_id, mode, payload_path, end_message_id }] };收尾时只对mode === "unreflected"的切片调用markReflected。 - Cloud 后端的消息在服务器上,客户端每次去拉代价大;客户端 transcript 是本机上已经看到的东西,可以离线、快速、按行切片;
source_message_id可能是合成的,说明它是客户端视角的记录,后端未必有一一对应的 id。
第 15 章
- mock 规则:匹配“别再叫我 X 了,叫我 Y”,生成
sed -i 's/X/Y/' human.md(或 Edit 工具)而不是追加。 - 一个模块级
Map<agentId, { delayMs, retryAt }>;parent_dirty/merge_conflict时delayMs = min(prev*2 || 60_000, 30*60_000);launchReflection开头检查retryAt,manual触发跳过检查;成功时删除记录。 explicit模式让主 agent 在一个“集成对话”里审阅反思分支再自己合并——适用于用户希望 agent 对后台改动“知情并认可”的场景,或需要按mergeInstructions有选择地合并。requireAlreadyMerged检查反思分支是否确实已经是 HEAD 的祖先:防止集成对话声称合并了、其实没有。
第 16 章
- 返回
{ launched: false, reason: "parent_dirty" }。开始时检查是为了不浪费一次反思;收尾时再查,是因为反思可能跑了几分钟,期间主仓库可能变脏。 - memory worker 是主 agent 主动委派的、针对一个具体请求的改动,合并失败时保留分支让人或模型稍后处理比丢掉更好;反思是周期性的、可以从同一段 transcript 重来的,失败就清理重试最简单。主仓库脏不阻止 worker 合并,因为 git 只在改动重叠时才拒绝,那会表现为冲突。
branch -d只删除已合并的分支——在成功路径上,如果分支意外没有被合并,-d会失败而不是丢失提交;失败路径本来就决定丢弃,才用-D。
第 17 章
- 第二个得到
already_active;kill -9之后,锁文件里的 pid 已不存在,下一次tryAcquireLock回收并成功获取。 - 要点:worktree 标签用
memory-worker;合并用git merge,主仓库脏不预先拒绝;冲突时merge --abort但不删除分支,返回分支名供后续处理;合并后同步、使系统提示失效。 - 要点:在主检出上运行;提示词照搬
memory.md的 Git 一节(读两边、保留意图、不 abort/reset/stash、不清楚就保留现场并报告);用一个记录文件防止对同一冲突重复启动修复。
Lab 5
- 在
resolveSystemPrompt写缓存时已经写进了反思者的对话目录(agents/<id>~reflection/conversations/reflection-<id>/system-prompt.json),用jq -r .content查看,可以看到父 agent 的<human>、<persona>。 - mock 读取任务提示里的 “Additional user-provided reflection instruction”,按关键词过滤候选事实。
- 崩溃隔离:子进程崩溃不影响主进程,mini 的同进程实现会一起崩;沙箱:letta-code 可以把整个子进程放进沙箱,mini 只能沙箱化 shell 命令、文件工具只靠守卫;
-p一次性模式:mini 必须等反思结束才能退出(否则进程退出反思被杀),letta-code 的后台子进程有自己的生命周期管理。
第 18 章
- 冲突提醒里给出了
git pull --rebase origin main→ 编辑冲突文件 →git add→GIT_EDITOR=true git rebase --continue。 - post-commit hook:读
git config --local mini-letta.memoryRepository.url;[ "$branch" = main ] || exit 0;后台git push "$url" main:main >> .git/mirror.log 2>&1 &。 - rebase 把别人的提交和自己的提交重新组合成一棵新树,这棵树可能违反约束(比如两边各加了一些内容,合起来超过了核心记忆预算);推送前要校验的是“将要推上去的那棵树”。
第 19 章
- 只看到
agent-<自己>,其他 agent 的目录不存在(tmpfs 覆盖)。 - validator 里对每个暂存文件做正则匹配;误报可以用一个“需要人类批准”的环境变量放行,或者维护一个允许列表(比如
.memfs.config.json里的secretScanAllowglob,它本身受保护)。 - letta-code 把记忆子 agent 自己的 agent 目录(cwd 的直接父目录)挖回为可读,保证进程初始化能沿路径走到 cwd、环境变量得以保留。跨 agent 配置拒绝读取整个 agents 树,如果 cwd 在树里,就会触发这个空环境的坑,所以要求调用方保证 cwd 在树外(主 agent 的 cwd 通常是项目仓库)。
第 20 章
- 在
conversations()里读取compaction-event.json的at字段并在页面上显示。 doctor命令依次调用:git status --porcelain、auditCommittedMemory、memoryTree里超过maxFileCharacters * 0.8的文件、解析MEMORY.md链接、conversations()里stepsSinceReflection > 50的对话。- 以 SKILL.md 原文为准;一般包括:收集症状、查看当前记忆与系统提示、找出缺失或矛盾、判断是记忆问题还是 harness 问题、做最小修复并验证。Palace 能直接提供“当前记忆”“历史”“对话正在用的系统提示”“压缩与反思状态”这几类证据。
Lab 6
- 新服务挂另一个
data2卷和同一个remote卷,command: ["bun","src/cli.ts","chat","-a","agent-demo","-p","你知道我什么?"];它会从remote克隆agent-demo的记忆——前提是 seed 运行时设置了MINI_LETTA_REMOTE_ROOT(compose 里已设置)。 - 至少需要允许
unshare(用户命名空间)和挂载相关的系统调用;放宽容器的 seccomp/能力会削弱容器本身的隔离。多租户场景更常见的做法是每个 agent 一个容器,或在宿主机上用 bwrap,而不是“容器里再套 bwrap”。 - 静态文件:生成时刻的快照、没有监听端口、打开即用;服务器:数据实时、可部署共享,但多了一个需要保护的网络服务(mini 的 Palace 默认只监听 127.0.0.1、只读、校验 sha 参数)。