逐日AI
第 1 周 · D7约 4 小时

会话持久化与恢复:只追加的事件日志、resume 与分叉,第一周复盘

让对话不再随进程消失:用只追加的 JSONL 事件日志记下每一次消息、工具调用与用量,重启后重放日志恢复现场,还能从任意一条事件分叉出一条新会话,最后回头复盘第一周这七层是怎么叠起来的。

今日目标 0/3

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

今日目标

  1. 能设计一份只追加的会话事件日志,并说清为什么不能存快照式的会话对象
  2. 能通过重放事件恢复会话,并处理日志损坏与版本不兼容
  3. 能实现从任意一条事件分叉出新会话,并说清分叉与回滚的区别

这是第一周的最后一天,读完回到页面顶部把三条目标勾掉。

小白版讲解

交接班:他下班了,明天另一个人接着干

昨天那个新人已经能自己处理失败了。但他六点下班,第二天来接班的是另一个人。

交接得好不好,取决于他留下了什么。留一句「那个 bug 我看过了」,接班的人只能从头再查一遍;留一份记录——几点做了什么、跑了哪条命令、输出是什么、结论是什么——接班的人五分钟就能续上。

Agent 这边的情况一模一样,只是「下班」来得更频繁:用户按了 Ctrl+C、终端被关掉、机器重启、你改了一行代码重启进程。每一次,内存里那个消息数组就没了,包括它刚才花了三轮工具调用才查明的那些事实

这里要澄清一个容易误解的地方:今天做的事不是给模型加记忆。第一天就说过模型没有记忆,历史是我们每轮重新搬进去的。今天只是把搬历史的活儿从内存挪到磁盘——恢复不等于它记住了,而是历史被重新搬回来了。 这个区分不只是措辞:正因为历史是我们自己搬的,我们才能选择搬多少(第十二天的压缩)、搬到哪一步为止(今天的分叉)。

所以问题变成一个很朴素的工程问题:这份记录该怎么写? 答案的第一层是「写成什么形状」,第二层是「怎么读回来」,第三层是「读回来之后还能怎么用」。今天三层都做。

为什么是事件日志而不是会话对象:可追加、可审计、可分叉

第一个诱惑是把整个会话序列化成一个 JSON 文件:一个对象,里面有消息数组、用量统计、当前状态,每轮结束改一次。写起来五行代码。

但它有三个问题,而且都是那种「平时没事,出事就很难看」的:

  1. 每次都要整体读、整体写。 会话越长越慢,而且写的时候要先在内存里持有全量。
  2. 失败模式无穷多。 整体写到一半崩掉,你会拿到一个截断的 JSON——那不是「少了最后一条消息」,而是整个文件解析不了。更阴险的是覆盖写成功了一半的情况:新内容比旧内容短,尾部还留着旧文件的残渣。
  3. 没有历史,只有现状。 你无法回答「那次编辑是第几轮做的」「上一轮花了多少 token」。这些问题在事后复盘和算成本的时候一定会被问到。

事件日志(event log)反过来:只追加,永不改写。 每发生一件事就往文件末尾加一行。

对比会话对象事件日志
写入整体覆盖只在末尾追加
失败模式很多种,且难以判断只有一种:最后一行可能不完整
历史没有,只有现状天然完整,可审计
分叉要深拷贝整个对象复制前 N 行即可

第二行是今天最值得记住的一句:追加写的失败模式只有一种,所以它是唯一你能事先想清楚的那种。 后面第五节整节都在处理这一种。

一行一个事件:序号、时间、类型,三个字段就够

格式用 JSONL:一行一个 JSON 对象,行与行之间没有任何语法关系。它的代价是「读的时候要自己拼」,收益是「写的时候只碰文件末尾」。

每行三个必备字段——seq(序号)、ts(时间)、type(类型),后面跟这个类型自己的字段:

JSONJSON
{"seq":1,"ts":"2026-09-07T10:00:00.000Z","type":"message","message":{"role":"user","content":"跑测试看看哪个用例失败了"}}
{"seq":4,"ts":"2026-09-07T10:00:01.200Z","type":"message","message":{"role":"assistant","content":"我先跑一遍测试。","toolCalls":[{"id":"call_test_1","name":"run_command","args":"{\"command\":\"node --test\"}"}]}}
{"seq":5,"ts":"2026-09-07T10:00:01.900Z","type":"tool_end","id":"call_test_1","result":{"ok":false,"content":"命令退出码 1…"}}

三个字段各有各的不可替代:seq 用来定位与分叉(「从第 8 条分出去」),ts 用来审计(「这一步花了多久」),type 决定后面还有什么字段。本实验一共五种类型:messagetool_endusageerrorfork

src/session/log.ts
export type SessionEvent =
  | { seq: number; ts: string; type: 'message'; message: Message }
  | { seq: number; ts: string; type: 'tool_end'; id: string; result: ToolResult }
  | { seq: number; ts: string; type: 'error'; message: string }
  | { seq: number; ts: string; type: 'fork'; from: string; atSeq: number }
 
async append(event: NewEvent): Promise<SessionEvent> {
  this.seq += 1
  const full = { seq: this.seq, ts: new Date().toISOString(), ...event } as SessionEvent
  // 一次 appendFile 就是一次 O_APPEND 写。这是整个持久化层唯一的写入手段:
  // 只要它是唯一的,失败模式就只有「最后一行不完整」这一种
  await fs.appendFile(sessionPath(this.id), `${JSON.stringify(full)}\n`, 'utf8')
  return full
}

还有两个小决定值得说:

  • 会话 id 形如 s-20260907-100000-ab12(日期时间加四位随机)。带时间是为了让「按文件名排序」直接等于「按时间排序」——--resume 不带参数时取最近一个,就是靠这个,不用另外维护一个「当前会话」指针。带随机是因为同一秒里可能开两个会话。
  • 落盘的时机是「事件发生时」,不是「一轮结束时」。 攒到一轮结束再写,中途被杀掉那一轮就一条不剩;而且日志里的顺序会变成「先所有事件、后所有消息」,读的人得在脑子里重排。本实验的顺序是天然对的:助手消息、它的 tool_end、然后工具结果消息。

重放恢复:哪些事件参与重建消息,哪些只用于展示

恢复就是重放:把事件从头读一遍,重建出消息数组。规则只有一条,但它反直觉——

只有 message 事件参与重建,其余四种一条都不参与。

tool_end 里明明有工具结果,为什么不用它重建那条 tool 消息?因为工具结果已经以一条 role 为 tool 的消息落进日志了,那才是要发给模型的东西。tool_end 里额外记的是耗时、字符数、有没有被截断、是被审批门还是打转检测挡下来的——这些是给人看的:审计、算成本、事后复盘。

src/session/replay.ts
export function replay(events: SessionEvent[]): Replayed {
  const messages: Message[] = []
  let replayed = 0
  let skipped = 0
 
  for (const event of events) {
    if (event.type === 'message') {
      messages.push(event.message)
      replayed += 1
    } else {
      // tool_end / usage / error / fork:只给人看,不参与重建
      skipped += 1
    }
  }
  return { messages, replayed, skipped }
}

两类信息分开记的收益很直接:重建逻辑只有一条规则,不会因为「哪个字段更权威」而分叉;想给终端加一列统计,只要多记一种非 message 事件,重放代码一行都不用改。反过来把两类混在一种事件里,第十二天做上下文压缩时会立刻付账——你要在同一个对象上区分「这部分要发给模型」和「这部分只给人看」。

本实验里跑一轮之后日志有 8 条事件,其中 5 条是 message;重放出 5 条消息,另外 3 条不参与重建。这几个数在 MOCK=1 下是可复现的(剧本固定)。同一行里那个累计 token 数不是——工具结果里带着命令耗时(「命令退出码 1(65ms)」),毫秒数一变,估算出来的 token 就跟着变。所以自检只断言它大于零,不断言具体值。

顺带一句版本兼容:日志是要长期存在的,而你的事件类型会加。所以读的时候遇到不认识的类型不要崩,本实验的做法是把它当成「不参与重建的那一类」。反过来,已经写出去的字段含义不许改——那等于篡改历史。

日志损坏怎么办:最后一行截断是常态,读到坏行就停在前一行

现在处理那唯一的失败模式。

进程随时会被 Ctrl+C、被 kill、被关掉终端。如果它正好停在 appendFile 写到一半,文件的最后一行就是半截 JSON。这是常态,不是异常——一个会话跑一年,这种情况会发生很多次。

处理办法只有一句:读到坏行就停在前一行,不跳过、不修补。

src/session/log.ts
export async function readEvents(id: string): Promise<ReadResult> {
  const lines = (await fs.readFile(sessionPath(id), 'utf8')).split('\n')
  if (lines.at(-1) === '') lines.pop() // 文件正常结束时,末尾换行后是个空串
 
  const events: SessionEvent[] = []
  for (let i = 0; i < lines.length; i += 1) {
    const parsed = parseLine(lines[i] as string, events.at(-1)?.seq ?? 0)
    // 停在这里,不 continue:跳过坏行等于假设文件中间也会坏,
    // 而追加写不会产生那种情况
    if (!parsed.ok) return { events, stoppedAtLine: i + 1, reason: parsed.reason }
    events.push(parsed.event)
  }
  return { events, stoppedAtLine: null, reason: null }
}

为什么不跳过坏行继续读?因为跳过它,等于假设文件中间会坏。追加写不会产生那种情况——一旦真的产生,说明这个文件被别的东西动过,那时候更不该猜。停下来,把行号和原因告诉用户,让他自己决定。

校验也别只校验 JSON 能不能解析。本实验还查两件事:seq 必须严格递增(不递增说明有两个进程在互相覆盖,或者文件被拼过),type 必须认识。Python 版还多利用了一个信号——没有换行符结尾的那一行就是没写完的那一行,这比等 JSON 解析失败更早、更准。

分叉:复制到新文件并截断,比原地改写安全得多

有了序号,就有了一件新能力:从第 N 条事件分出去,长成一条新会话。

这件事的实际用途比听起来多。它对话到第八条时给出了一个方向,你想试试另一个方向,又不想丢掉现在这条;或者你想拿同一段前情做两次不同的尝试,对比一下——这就是第二十天做基准评估的雏形。

实现只有两步:把 seq 小于等于分叉点的行复制到一个新文件,再追加一条 fork 事件记下血缘(从哪条会话的哪一步来的)。三条口径:

  • 复制到新文件,不要原地截断。 原地截断是一次改写,写到一半崩了会同时失去母会话和分叉。代价只是多占一份磁盘。
  • seq 不重新编号。 保留原编号,才能拿分叉点的序号直接对回母会话。
  • 分叉点不是任意一行,只有干净的边界才行。 只有用户消息、以及不带工具调用的助手消息之后才能分。在一条带工具调用的助手消息之后截断,会得到一个「请求了工具却没有结果」的消息数组,下一轮请求直接不合法——那是第三天定下的不变量。所以终端要把可选的位置列给用户,别让他自己数行号:
TextText
你 > /fork
  可以从这些位置分叉(只有干净的边界才能分):
    /fork 2  user: 跑测试看看哪个用例失败了
    /fork 8  assistant: 失败的是 divide by zero 那个用例:div…
    /fork 9  user: 那把它修掉
    /fork 19  assistant: 四个用例全绿,divide by zero 那个用例修好…

最后区分一件事,第十四天会再用到:分叉不是回滚。 分叉是「从那一步长出一条新的,旧的原封不动」;回滚是「让当前这条退回到那一步」。而且今天这套日志只管对话,不管文件——分叉出一条新会话,磁盘上那个已经被改过的 src/calc.js 不会跟着回去。文件的回滚要靠快照,那是第十四天的题目。日志恢复对话,快照恢复文件,两者互相独立,可以只回滚一边。

第一周复盘:七层叠起来之后,它已经能做完一次真实修复

第一周到这里结束了。回头看,七天各加了一层,而且顺序不是随便排的:

D1 REPL 与网关抽象把模型接进终端 D2 流式与事件分层StreamDelta 与 AgentEvent D3 只读工具循环、分片归并、结果截断 D4 写工具与 shell第一次真的改了代码 D5 审批门三态规则、暂停不是异常 D6 错误处理与自纠回灌、退避、取消、硬上限 D7 会话持久化只追加日志、恢复、分叉
Mermaid 源码
mermaidmermaid
flowchart TB
  D1["D1 REPL 与网关抽象<br/>把模型接进终端"] --> D2["D2 流式与事件分层<br/>StreamDelta 与 AgentEvent"]
  D2 --> D3["D3 只读工具<br/>循环、分片归并、结果截断"]
  D3 --> D4["D4 写工具与 shell<br/>第一次真的改了代码"]
  D4 --> D5["D5 审批门<br/>三态规则、暂停不是异常"]
  D5 --> D6["D6 错误处理与自纠<br/>回灌、退避、取消、硬上限"]
  D6 --> D7["D7 会话持久化<br/>只追加日志、恢复、分叉"]

每一层都只依赖它下面那些,这是有意的:

  • D2 那条分层(网关说了什么 / 循环发生了什么)让后面五天加能力时都不用碰渲染层。
  • D3 那个只读标记在 D5 变成了「只读工具默认放行」,一个字段省掉一张白名单。
  • **D5 把审批做成「返回一条结果」**而不是抛异常,于是 D6 处理拒绝时不需要任何新代码——被拒的调用和工具失败长得一样。
  • D6 先把一轮之内的失败处理干净,D7 才谈跨进程的恢复。反过来做,你会把「这一轮出错了」和「上一次没跑完」混在同一段代码里,而它们的处理完全不同。

现在的 mca 已经能做完一件真事:在一个真实仓库里跑测试、读代码、改文件、再跑一次证明修好了,全程该问的问、该停的停、摔倒了自己起来、进程退了还能接着干。这就是一个 Coding Agent 的核心循环——剩下十四天加的全是「让它干得更久、更聪明、更好分发」,核心不会再变。

第二周从明天开始换方向:前七天解决的是「它能不能干活」,后七天解决的是「它能不能少走弯路」。第一件事就是别让它自己翻遍整个仓库。

源码导读

动手实验

🧪 D7 实验:可恢复、可分叉的会话事件日志与一次跨进程接着干的完整演示

代码位置:labs/my-coding-agent-21days/day-07-session-resume

今天挖了五个练习点,其中两个是「看着更整齐、实际是改写」的陷阱:坏行跳过继续读、分叉时顺手把母会话也截断。起点代码原样跑是十一项里过四项。

恢复这一项必须跨两个进程验,所以自检里父进程会 spawn 两个子进程:第一个开新会话查明一个事实,第二个带 --resume 接着干。在同一个进程里重放一遍什么都证明不了——消息数组本来就还在内存里。

  1. 定义五种事件与序号规则,把消息、工具调用、用量都写成事件;跑一轮之后用 /log 看 seq 是不是从 1 连续,tool_end 是不是正好夹在助手消息与工具结果之间。
  2. 实现追加写与重放恢复;换一个进程 --resume 起来,看模型第一句是不是引用了上一轮查明的用例名。
  3. 手工把日志最后一行截断一半,确认恢复停在前一条完整事件,并在终端报出停在第几行。
  4. 实现从指定序号分叉,并用 /fork 不带参数列出可选位置;分叉之后确认母会话文件一个字节都没变。
  5. 跑自检:MOCK=1 SELFTEST=1 pnpm start 应该打印 11/11 通过。事件条数、seq、分叉点个数都是可复现的;会话 id 与每行的 ts 不可复现,别拿它们做断言。

验收看五条勾:自检 11/11 通过;第一轮之后日志逐行落盘且顺序正确;换进程 --resume 之后模型引用了上一轮的发现;日志最后一行被截断时仍能恢复并报出行号;/fork 列出的位置里没有带工具调用的助手消息,分叉后母会话纹丝不动。

面试题

今天三道题,考的是持久化的取舍判断,不是「事件溯源是什么」:

  1. 会话持久化你选事件日志还是存会话快照?各自的代价是什么?
  2. 只追加的日志怎么处理写到一半崩掉的最后一行?
  3. 从历史某一步分叉出一条新会话,和回滚到那一步有什么区别?

完整的中英题干、分析过程与答题要点见本课面试题库的第七天。第二题看着最小,实际最能区分——它问的是「你有没有想过失败模式」,而不是「你会不会写 try catch」。

检查清单与明日预告

  • 能说出会话对象的三个代价,以及事件日志为什么只有一种失败模式
  • 知道每行为什么必须有 seq、ts、type,以及会话 id 为什么带时间
  • 能解释为什么只有 message 事件参与重建,其余类型只用于展示与审计
  • 知道坏行为什么要停在前一行,以及为什么不许「顺手修好」日志
  • 能说清分叉为什么要复制到新文件、seq 为什么不重新编号
  • 能说出哪些位置不是干净的分叉点,以及在那里截断会出什么错
  • 能说清分叉与回滚的区别,以及日志与快照各管什么

明天是 D8《引用注入:解析 @ 文件、目录、URL 与图片,并把注入量说清楚》,第二周开始。这七天里它每次想看一个文件,都得自己先 glob 再 grep 再 read,来回三四轮——而很多时候你早就知道该看哪个文件。明天做 @ 引用:把文件、目录、网页地址直接解析成注入内容放进上下文,并且在终端显式打印这一次注入了多少字符、估算多少 token。顺序上它排在第二周第一位,是因为后面几天(项目指令、记忆、压缩)都是在往上下文里塞东西,而 @ 引用是最简单、最可控的那一种,先用它把「注入要报账」这条纪律立起来。

面试题库

  • 会话持久化你选事件日志还是存会话快照?各自的代价是什么?For session persistence, would you use an append-only event log or store a session snapshot? What does each cost?
    国内高频海外高频基础#persistence#event-log

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

    1. 这题在看你会不会按「失败模式」选方案。答「事件日志更专业」拿不到分,答「快照简单够用」也拿不到——区分度在于你能不能把两者的代价说成可比较的东西。
    2. 怎么拆:先问一句「这个文件写到一半崩掉会怎样」。快照是整体覆盖,失败模式无穷多:截断的 JSON 整个解析不了;更阴险的是新内容比旧内容短时,尾部还留着旧文件的残渣,于是你拿到一个语法合法、语义错乱的文件。只追加的失败模式只有一种——最后一行可能不完整。**唯一的那种,才是你能事先想清楚的那种。**
    3. 再看第二个维度:快照只有现状,没有历史。「那次编辑是第几轮做的」「上一轮花了多少 token」这类问题它答不了,而事后复盘与算成本一定会问。事件日志天然带历史,因为它记的是「发生了什么」而不是「现在是什么」。
    4. 第三个维度是分叉:日志分叉就是复制前 N 行,快照分叉要深拷贝整个对象并想清楚哪些字段该跟着走。
    5. 结论要给出日志的代价,否则听起来像在推销:读的时候要自己重放(多一层代码);文件只增不减,长会话要另配归档;而且要定清楚「哪些事件参与重建、哪些只用于展示」,否则重放逻辑会分叉。我的实现里只有 message 事件参与重建,工具调用的 meta、用量、错误、血缘都只给人看。
    6. 可预期的追问:事件类型以后会加,旧日志怎么办?两条纪律——读到不认识的类型不要崩(当成不参与重建的那一类),已经写出去的字段含义不许改。后者等于篡改历史,比不兼容更糟。

    How to reason about it · think before answering

    1. This checks whether you choose by failure mode. Saying event logs are more professional scores nothing, and neither does snapshots are simpler. The signal is making the two costs comparable.
    2. How to break it down: ask what happens if the write is interrupted halfway. A snapshot is a whole-file overwrite with unbounded failure modes: a truncated JSON does not parse at all, and worse, when the new content is shorter than the old, the tail of the previous file survives and you get a syntactically valid, semantically corrupt file. Append-only has exactly one failure mode: the last line may be incomplete. The single mode is the one you can actually design for.
    3. Second dimension: a snapshot holds the present, not the past. It cannot answer which round made that edit or how many tokens the last round cost, and postmortems and cost accounting always ask. A log carries history by construction, because it records what happened rather than what is.
    4. Third dimension: forking. Forking a log is copying the first N lines; forking a snapshot means deep-copying an object and deciding which fields should follow.
    5. State the log's costs too, or it sounds like a sales pitch: you must replay on read, the file only grows so long sessions need archiving, and you must decide which event types participate in reconstruction versus which are display only, or the replay logic forks. In my implementation only message events rebuild state; tool metadata, usage, errors, and lineage are for humans.
    6. Likely follow-up: event types will grow, so what about old logs? Two rules — never crash on an unknown type (treat it as display only), and never change the meaning of a field you already wrote. The second is rewriting history, which is worse than incompatibility.

    答题要点

    • 按失败模式选:追加写只有「最后一行不完整」一种,整体覆盖的失败模式无穷多
    • 覆盖写最阴险的情况是新内容比旧内容短,尾部残留旧数据,文件语法合法语义错乱
    • 快照只有现状没有历史,答不了「第几轮做的」「花了多少 token」这类复盘问题
    • 日志的代价是要重放、文件只增不减、必须定清楚哪些事件参与重建
    • 演进纪律:不认识的事件类型不要崩,已写出去的字段含义不许改

    Key points

    • Choose by failure mode: append-only has one, whole-file overwrite has unbounded ones
    • The nastiest overwrite case is shorter new content leaving old bytes in the tail, giving a valid but corrupt file
    • A snapshot holds only the present, so it cannot answer which round or how many tokens
    • The log's costs: replay on read, a file that only grows, and a clear rule on which events rebuild state
    • Evolution rules: never crash on unknown event types, never change the meaning of a field already written
  • 只追加的日志怎么处理写到一半崩掉的最后一行?How does an append-only log handle a last line that was cut off mid-write?
    国内高频海外高频进阶#durability#event-log

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

    1. 这题看着最小,实际最能筛人。它问的不是「你会不会写 try catch」,而是「你有没有把这件事当成常态」。答「加个校验和」「用事务」的人,都是在试图消灭它,而它消灭不掉。
    2. 怎么拆:先给出定性——**最后一行写坏是常态,不是异常。** 进程会被 Ctrl+C、被 kill、被关掉终端,一个会话跑一年,这种情况会发生很多次。既然是常态,处理它就该是主路径的一部分,不该藏在错误分支里。
    3. 结论只有一句:**读到坏行就停在前一行,不跳过、不修补。** 停在前一行是因为坏行之后不存在东西;不跳过是因为跳过它等于假设文件中间也会坏,而追加写不会产生那种情况——一旦真的产生,说明文件被别的东西动过,那时候更不该猜。
    4. 然后是「不修补」为什么重要,这是最容易答错的一半:常见的自作聪明是发现最后一行坏了就删掉它再写回去。**那是一次改写,而改写正是只追加想避免的事**——如果这次改写本身被中断,你会连前面那些好行一起弄坏。正确做法是读的时候停在前一行,写的时候从最后一条完整事件之后继续追加。那半行会一直留在文件里,它无害,而且它是「上次崩在这里」的证据。
    5. 校验别只校验 JSON 能不能解析。至少再查两件事:序号必须严格递增(不递增说明有两个进程在互相覆盖或者文件被拼过),类型必须认识。逐行读的时候还有一个更早的信号——**没有换行符结尾的那一行,就是没写完的那一行**,比等解析失败更准。
    6. 可预期的追问:那要不要告诉用户?要,而且要给行号与原因。恢复得不完整而不说,用户会以为模型在瞎猜;说清「停在第几行、前面 N 条是完整的」,他自己就能判断要不要接着用这条会话。

    How to reason about it · think before answering

    1. This looks like the smallest question and filters the most. It is not about writing a try/catch, it is about whether you treat a torn last line as normal. Answers like add a checksum or use transactions try to eliminate it, and it cannot be eliminated.
    2. How to break it down: state the framing first — a torn last line is the normal case, not an exception. Processes get Ctrl+C'd, killed, and have their terminals closed; over a year of sessions this happens many times. Since it is normal, handling it belongs on the main path, not hidden in an error branch.
    3. The conclusion is one sentence: stop at the line before the bad one, do not skip it and do not repair it. Stop, because nothing exists after the bad line. Do not skip, because skipping assumes corruption can appear mid-file, which append-only writes do not produce — if it really did, the file was touched by something else and guessing is even worse.
    4. Then why not repair, the half people get wrong: the tempting move is to delete the bad line and rewrite the file. That is an overwrite, exactly what append-only exists to avoid, and if that rewrite is interrupted you lose the good lines too. The right behavior is to stop on read and to keep appending after the last complete event on write. The half line stays in the file forever; it is harmless and it is evidence of where the last crash happened.
    5. Do not validate only that the JSON parses. Check at least two more things: sequence numbers must strictly increase, since non-increasing means two processes overwrote each other or the file was concatenated, and the type must be recognized. When reading line by line there is an even earlier signal: the line without a trailing newline is the unfinished one, which beats waiting for a parse error.
    6. Likely follow-up: do you tell the user? Yes, with the line number and the reason. Recovering partially and staying silent makes the user think the model is guessing; saying it stopped at line N with the first M events intact lets them decide whether to keep using that session.

    答题要点

    • 定性先说:最后一行写坏是常态,处理它属于主路径而不是错误分支
    • 读到坏行停在前一行,不跳过——跳过等于假设文件中间也会坏
    • 绝不「顺手修好」:删掉坏行再写回去是一次改写,中断时会连好行一起弄坏
    • 写入时从最后一条完整事件之后继续追加,那半行留着当崩溃证据
    • 校验要加上序号严格递增与类型可识别;逐行读时「没有换行符结尾」是更早的信号

    Key points

    • Frame it first: a torn last line is normal, so handling it belongs on the main path
    • Stop at the line before the bad one and never skip it, since skipping assumes mid-file corruption
    • Never repair it: deleting and rewriting is an overwrite that can destroy the good lines if interrupted
    • On write, keep appending after the last complete event and leave the half line as crash evidence
    • Validate strictly increasing sequence numbers and known types; a missing trailing newline is the earliest signal
  • 从历史某一步分叉出一条新会话,和回滚到那一步有什么区别?What is the difference between forking a new session at some point in history and rolling back to that point?
    国内高频海外高频深入#fork#session-state

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

    1. 这题在考语义精确度,也在考你有没有想过「状态不止一份」。答「差不多,都是回到某一步」的人,接下来一定会做出一个把用户文件搞坏的功能。
    2. 怎么拆:先分清两件事各自动了谁。分叉是「从那一步长出一条新的,旧的原封不动」,实现是复制前 N 条事件到新文件;回滚是「让当前这条退回到那一步」,实现要么是改写现有日志,要么是在语义上把后面的作废。前者是加法,后者是减法——**加法几乎不会出事,减法要考虑清楚被丢掉的东西还有没有人在引用。**
    3. 然后是这题真正的题眼:**会话状态不止一份。** 事件日志管的是对话,磁盘上被改过的文件不在它管辖范围内。所以分叉出一条新会话之后,那个已经被改过的源文件不会跟着回去——你得到的是「一段旧对话 + 一份新文件」,如果不说清楚,模型会基于错误的前提继续推理。文件的回滚要靠文件快照,那是另一套机制。
    4. 结论:两套机制互相独立,可以只回滚一边,而且要让用户知道自己回滚的是哪一边。我的实现里日志恢复对话、快照恢复文件,分叉只碰前者。
    5. 实现上还有两条值得主动讲:分叉要复制到新文件而不是原地截断(原地截断是改写,写到一半崩了会同时失去母会话和分叉);分叉的序号不重新编号(保留原编号才能拿分叉点直接对回母会话)。
    6. 可预期的追问:能从任意一条事件分叉吗?不能。只有用户消息与不带工具调用的助手消息之后才是干净边界。在一条带工具调用的助手消息之后截断,会得到「请求了工具却没有结果」的消息数组,下一轮请求直接不合法——每个调用必须有且只有一条结果消息。所以要把可选位置列给用户,别让他自己数序号。

    How to reason about it · think before answering

    1. This tests semantic precision and whether you have realized there is more than one kind of state. Anyone who says they are basically the same will ship a feature that corrupts the user's files.
    2. How to break it down: name what each one touches. A fork grows a new branch from that point and leaves the original untouched, implemented by copying the first N events into a new file. A rollback moves the current branch back, implemented either by rewriting the log or by invalidating everything after that point. One is addition, the other subtraction, and subtraction forces you to ask who still references what you dropped.
    3. Then the real point of the question: session state is not one thing. The event log governs the conversation, not the files already modified on disk. So after forking, the edited source file does not travel back with you — you get an old conversation paired with new files, and if you do not say so, the model keeps reasoning from a false premise. Rolling files back needs file snapshots, a separate mechanism.
    4. Conclusion: the two mechanisms are independent, you can roll back only one of them, and the user must be told which one they rolled back. In my implementation the log restores the conversation, snapshots restore files, and forking touches only the former.
    5. Two implementation points worth volunteering: fork by copying into a new file rather than truncating in place, since truncation is an overwrite that can lose both branches if interrupted; and do not renumber sequence numbers in the fork, because keeping them lets you map the fork point straight back to the parent.
    6. Likely follow-up: can you fork at any event? No. Only after a user message or an assistant message without tool calls. Truncating right after an assistant message that requested tools yields a message array with a call and no result, which makes the next request invalid, since every call needs exactly one result. So list the valid points for the user instead of making them count sequence numbers.

    答题要点

    • 分叉是加法:复制前 N 条事件长出新会话,母会话原封不动;回滚是减法,要处理被丢掉的东西
    • 题眼是状态不止一份:日志管对话,磁盘上改过的文件不在它管辖范围内
    • 所以分叉之后是「旧对话 + 新文件」,文件回滚要靠快照,两套机制互相独立
    • 分叉要复制到新文件而不是原地截断;序号不重新编号,才能对回母会话
    • 只有用户消息与不带工具调用的助手消息之后才是干净分叉点,要把可选位置列给用户

    Key points

    • Forking is additive: copy the first N events into a new session and leave the parent untouched; rollback is subtractive and must handle what it drops
    • The real point is that state is plural: the log governs the conversation, not the files already changed on disk
    • So after a fork you have an old conversation with new files; rolling files back needs snapshots, an independent mechanism
    • Fork by copying into a new file rather than truncating in place, and keep the original sequence numbers so the fork point maps back
    • Only user messages and assistant messages without tool calls are clean fork points, so list the valid ones for the user

评论