第 14 章 ★★ 睡眠时间:反思什么时候发生
本章目标:理解“后台反思(reflection / sleeptime)”为什么存在、由什么触发、如何记住“哪些对话已经反思过”。前置:第 9、12、13 章。对应代码:mini-letta
v0.5src/reflection/{post-turn,transcript}.ts。
14.1 为什么需要第二个写者
到目前为止,记忆只有一个写者:agent 自己,在对话中途、在完成用户任务的间隙。这有三个问题:
- 它在忙。用户让它修 bug,它不会停下来整理“用户似乎偏好函数式写法”这种模糊的模式。
- 它看不到全貌。一条偏好可能分散在好几个对话里,单个对话里的 agent 看不出“这是个模式”。
- 它可能很弱。第 6、9、10 章里的 3B 模型连“提交”都做不好。
letta-code 的回答是借用人类睡眠的比喻:对话之间,另一个 agent 在后台回顾刚发生的事,把值得记住的东西整理进记忆。系统提示是这么跟 agent 解释的(letta_root_memfs.md:167):“Just as human memory consolidates during sleep — strengthening important connections and discarding noise — your background agents refine your memory between active turns.” 对应的命令叫 /sleeptime(设置,src/cli/commands/registry.ts:148)、/dream 和 /reflect(手动触发,:86、:92)。
14.2 触发器
// src/reflection-settings.ts:1
export type ReflectionTrigger = "off" | "step-count" | "compaction-event";// src/cli/helpers/memory-reminder.ts:15, 51-56
const DEFAULT_STEP_COUNT = 25;
const DEFAULT_REFLECTION_SETTINGS: ResolvedReflectionSettings = {
trigger: "compaction-event",
stepCount: DEFAULT_STEP_COUNT,
merge: "auto",
mergeInstructions: "",
};- compaction-event(默认):这一回合里发生过上下文压缩,就反思。理由很漂亮:压缩意味着有一段经历刚刚离开了上下文窗口,正是需要“巩固”的时候。
- step-count:自上次成功反思以来,累计完成的 assistant 步数达到 N(默认 25)。
- off。
另外有一个宿主级开关:原生 Windows 上默认关闭自动反思(isAutoReflectionEnabled(),src/reflection-settings.ts:6,可用 LETTA_ENABLE_WINDOWS_AUTO_REFLECTION=1 打开),手动的 /dream、/reflect 不受影响。设置按 agent 保存在全局和项目本地两级(persistReflectionSettingsForAgent(),memory-reminder.ts:253)。
14.3 回合后的判断
// src/cli/helpers/post-turn-reflection.ts:22-79(节选)
export async function maybeLaunchPostTurnReflection(params: {...}): Promise<boolean> {
// Reminder restoration is client context maintenance, not reflection work.
// Always consume compaction state even when MemFS or reflection is disabled.
syncReminderStateFromContextTracker(params.reminderState, params.contextTracker);
if (!params.agentId || !params.memfsEnabled) {
params.reminderState.pendingReflectionTrigger = false;
return false;
}
...
switch (params.reflectionSettings.trigger) {
case "off":
params.reminderState.pendingReflectionTrigger = false;
return false;
case "compaction-event": {
if (!params.reminderState.pendingReflectionTrigger) return false;
params.reminderState.pendingReflectionTrigger = false;
return params.launch("compaction-event");
}
case "step-count": {
params.reminderState.pendingReflectionTrigger = false;
const transcriptState = await readTranscriptState(params.agentId, params.conversationId);
if (!shouldFireStepCountTrigger(transcriptState.steps_since_last_successful_reflection,
params.reflectionSettings)) return false;
return params.launch("step-count");
}
}
}注意 pendingReflectionTrigger 在每条路径上都被消费掉——不管最后有没有启动。压缩标记只在“压缩之后的那个回合结束时”有意义;如果当时设置是 off,以后改成 compaction-event 也不该补发一次。
调用它的地方在三条编排路径里各有一处(第 2 章)。在 headless 里,它紧跟在“把本回合追加到反思 transcript”之后,而且只在回合成功、以 end_turn 结束时才做(src/headless.ts:4650-4664)——被打断的回合不计数。
14.4 记住反思到了哪里:transcript 与水位线
反思需要知道“哪些对话内容还没被反思过”。letta-code 为此维护一份独立于后端存储的客户端 transcript:
~/.letta/transcripts/<agent-id>/<conversation-id>/
├── transcript.jsonl 每个成功回合追加一段(user/assistant/reasoning/tool_call 行)
└── state.json 水位线// src/cli/helpers/reflection-transcript.ts:28-38
export const REFLECTION_STATE_SCHEMA_VERSION = "v3_assistant_steps" as const;
export interface ReflectionTranscriptState {
schema_version: typeof REFLECTION_STATE_SCHEMA_VERSION;
reflected_through_message_id?: string; // 水位线:反思到了哪条消息
total_completed_steps: number;
reflected_completed_steps: number;
steps_since_last_successful_reflection: number;
last_reflection_started_at?: string;
last_reflection_succeeded_at?: string;
}几个设计点:
- 水位线是消息 id,不是行号。schema 名字里的 “v3_assistant_steps” 和旧版 “v2_message_id” 说明它演进过:计数从“回合”改成了“assistant 步”,并且有迁移代码(
migrateMessageIdState(),reflection-transcript.ts:792)。 - 计数单位是 assistant 行(
countAssistantRows(),:619):只数kind === "assistant"的条目,用户消息、工具调用都不算。 - 水位线只在成功时前进。启动反思时只记录
last_reflection_started_at;只有反思结果被“消费”(合并成功或确认无需修改,第 16 章)时,finalizeAutoReflectionPayload()(:1870)才把reflected_through_message_id推进到这次切片的末尾。失败了,下一次反思会拿到同一段内容重来。 - 切片有快照边界。
buildAutoReflectionPayload()(:1644)记下切片结束在第几行(endSnapshotLine);收尾时只看那一行之前的内容。反思进行期间新追加的回合不会被误标为“已反思”。
buildMultiReflectionPayload()(:1756)还支持一次反思多个对话:生成一个 multi_transcript_reflection_payload 清单,每个切片标注 unreflected 或 replay(已经反思过、为了去重和跨会话归纳再看一遍)。这对应第 14.1 节的第二个问题。
14.5 mini-letta 的实现
mini-letta 不单独维护 transcript:每个对话的 messages.jsonl 本来就是只追加的完整记录。水位线直接指向其中的消息 id:
// code/mini-letta @ v0.5 src/reflection/transcript.ts(节选)
export function unreflectedMessages(store: ConversationStore, state = readReflectionState(store)): Message[] {
const all = store.all();
if (!state.reflectedThroughMessageId) return all;
const index = all.findIndex((m) => m.id === state.reflectedThroughMessageId);
return index < 0 ? all : all.slice(index + 1);
}
export function stepsSinceLastReflection(store: ConversationStore): number {
return unreflectedMessages(store).filter((m) => m.role === "assistant").length;
}和 letta-code 的一个差别:mini-letta 的每条 assistant 消息都算一步,包括只有工具调用、没有文字的那种;letta-code 的 transcript 里工具调用是单独的 tool_call 行,不计数。
触发判断:
// code/mini-letta @ v0.5 src/reflection/post-turn.ts(节选)
const settings = input.agent.reflection ?? DEFAULT_REFLECTION_SETTINGS;
const store = new ConversationStore(input.agent.id, input.conversationId, input.home);
const compaction = store.readJson<{ pending?: boolean }>(COMPACTION_EVENT);
if (compaction?.pending) store.writeJson(COMPACTION_EVENT, { ...compaction, pending: false }); // 总是消费
if (settings.trigger === "off") return null;
if (settings.trigger === "compaction-event" && !compaction?.pending) return null;
if (settings.trigger === "step-count" && stepsSinceLastReflection(store) < settings.stepCount) return null;
return launchReflection(input);压缩标记是第 12 章 compactConversation() 写下的 compaction-event.json。设置存在 agent.json 的 reflection 字段,CLI:
bun src/cli.ts sleeptime --trigger step-count --steps 314.6 运行与验证
// test/v05-reflection.test.ts
test("step-count fires once enough assistant steps accumulate", async () => {
agent.reflection = { trigger: "step-count", stepCount: 3 };
for (const userText of ["我更喜欢用 tabs 缩进", "嗯", "好的"]) {
await runTurn({ ... userText });
outcomes.push(await maybeReflectAfterTurn({ ... }));
}
expect(outcomes.slice(0, 2)).toEqual([null, null]);
expect(outcomes[2]?.launched && outcomes[2].status).toBe("merged");
});
test("compaction-event (default) fires only after a compaction", async () => {
// 1 200 token 窗口,12 个回合
expect(compacted).toBeGreaterThan(0);
expect(fired).toBe(compacted); // 每次压缩恰好触发一次反思
});CLI 走一遍(step-count = 3):
$ bun src/cli.ts sleeptime --trigger step-count --steps 3
{"trigger":"step-count","stepCount":3}
$ bun src/cli.ts chat -p "帮我看下这个报错"
(mock)我听到了:帮我看下这个报错
$ bun src/cli.ts chat -p "我更喜欢用 tabs 缩进"
(mock)我听到了:我更喜欢用 tabs 缩进
$ bun src/cli.ts chat -p "今天我们决定以后一律用 pnpm"
(mock)我听到了:今天我们决定以后一律用 pnpm
☾ reflection 20261008160658-c0ef7940: merged — Merged 1 reflection commit(s).第三回合之后,反思启动、合并,human.md 多了两行(反思做了什么,见第 15 章)。
14.7 练习
- 巩固:设置
step-count、stepCount: 2,聊四句,查看reflection-state.json的变化;然后手动把reflectedThroughMessageId删掉,再聊一句,会发生什么? - 扩展:实现 letta-code 的 multi-transcript 反思:一次反思同一个 agent 最近的 3 个对话,payload 写成清单文件,每个切片带
mode: "unreflected" | "replay"。收尾时只推进unreflected切片的水位线(参照finalizeMultiReflectionPayload(),reflection-transcript.ts:1905)。 - 读源码:letta-code 为什么把反思 transcript 放在客户端(
~/.letta/transcripts)而不是直接读后端的消息?想想 Cloud 后端、多台机器、以及 transcript 行里那些source_message_id是“may be synthetic”的注释。