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

状态与上下文的分界:把权威状态搬出窗口

把昨天那个退化修掉:权威进度写进磁盘,窗口重置后从磁盘重建一个等效上下文。重点不是怎么写文件,而是这条分界线该画在哪里,以及为什么重建的目标是等效而不是还原。

今日目标 0/3

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

今日目标

  1. 能说出哪三类信息必须进磁盘状态、哪两类只该留在窗口里,并各说明判断依据
  2. 能解释等效上下文与原上下文的区别,并举出一个还原原上下文反而更糟的具体情形
  3. 能通过关掉状态层让昨天的重复现象重现,用这个变异检验证明状态层真的在起作用

昨天那个退化,修法其实已经摆在眼前:进度一直在内存里躺着,只是没人把它写进下一个窗口的开场。今天要做的不是「写个文件」,而是画三条分界线。读完回到页面顶部把三条目标勾掉。

小白版讲解

白板:交代一遍会忘,写下来不会

昨天那个夜班交接的比方还没讲完。老张把手上的活口头交代给小李,小李中途换成小王,交代就没了。

修法车间里早就有了:墙上挂一块白板。 修好了哪几台、哪一台拆到一半、扭矩扳手在第三个抽屉——全写白板上。谁接班都先看白板。

白板和脑子的区别有三层,而且每一层都对应今天要做的一个决定:

  • 白板比当班的人活得久。 老张下班了白板还在,这是「跨窗口存活」。
  • 白板上写的是结论,不是过程。 没人会把「我先试了扳手 A 发现拧不动又换了扳手 B」写上去,只写「第三台修好了」。这是等效不是还原
  • 白板只有一块。 脑子里记的和白板不一致时,以白板为准。这是权威的意思。

今天要写的 work/state/run.json 就是这块白板。

分界线画在哪:权威的归磁盘,易失的归窗口

先把话说准。昨天我们发现「进度其实一直在内存里活着」——features[].passes 跨窗口没有被擦掉,昨天真正丢的是证据,不是数据。

那为什么还非得写盘?因为内存和进程同生共死。kill -9 一下、机器重启一下、容器被驱逐一下,账本就没了。人在旁边时这不要紧,你会重新起一次;跑一整夜时,没有这个人。

所以分界线是这么画的:

住在窗口里住在磁盘上
寿命到下一次压缩、截断或重置为止跨窗口、跨进程、跨机器重启
可信度参考。它可能是几步之前的旧结论权威。冲突时以它为准
内容当前这一小段工作的上下文做完了什么、正在做什么、试了几次
丢了会怎样重建一份就行整晚白跑,而且没人知道

还有一条容易忽略但今天必须定下来的边界:状态文件放在靶子仓库之外。

实验里靶子在 work/repo,账本在 work/state,是两个目录。写进 work/repo 会有两个后果:它会进 agent 自己的 commit(D4 的绿点里混进 harness 的簿记),而且回滚到上一个绿点时账本会被一起回滚掉——回滚的目标是代码,不该把「我已经试过三次」这种教训也一起忘掉。

三类必须写进磁盘

不是所有东西都值得持久化。判据只有一条:下一个窗口要做出正确的下一步,非知道不可的是什么? 按这条筛,只剩三类。

第一类,做完了什么。 这是最直接的一类,昨天的重复劳动就是因为它缺席。对应 done: string[],顺序即完成顺序。注意它存的是 feature 编号而不是描述文本——编号是稳定的,描述会随需求改。

第二类,正在做什么。 对应 current: string | null。它比第一类微妙:进程如果崩在半路,磁盘上会留下一条「正在做 F07」而 F07 既不在已完成里、也没有失败记录。这个悬空状态是有用的信息,不是脏数据——它告诉下一个窗口「F07 到底成没成不知道,先确认,别当成做完了」。丢掉它,下一个窗口要么重做一遍,要么直接跳过一条根本没做完的。

第三类,试了几次。 对应 attempts: Record<string, number>。单看一个窗口,「F23 试了一次没成」是噪声;跨五个窗口看,「F23 试了十二次都没成」是一个必须响的信号。这类信息的价值完全来自跨窗口累计,不写盘就永远攒不起来。D5 的打转检测、D6 的预算熔断,全部建立在这个计数上。

顺带还要记一样:spent,烧掉了多少窗口和多少步。它今天只是打印出来看看,D6 会拿它做熔断。

两类坚决不写

反过来,有两类信息不该进磁盘,而且理由不是「省空间」。

第一类,中间推理。 「我先看了 server.mjs 的结构,发现路由是用一个数组注册的,所以我打算……」——这段话在当时有用,结论落地之后它的使命就结束了。留着它有两个坏处:下一个窗口要花注意力去读一段已经作废的思路,而且它会把状态文件撑成一个越跑越大的日志。结论落地,过程作废。

第二类,一次性的工具回执。 某次 git status 的输出、某次读文件拿到的内容——这些东西的特点是可以随时重新获取,而且重新获取的那份才是对的。把三小时前的 git status 输出存下来喂给新窗口,比不给还糟:它会拿着一份过期快照做决定。

重建的目标是等效,不是还原

现在到今天最反直觉的一条。窗口换了,新开场该拼什么?

最自然的想法是还原:把上一个窗口的对话原样接上去。这个想法看起来无损,实际上是今天最贵的错误。

先看代价。实验里跑一个窗口(三步)产生的原始对话是 962 字符;重建出来的摘要是 175 字符。三步就差五倍多,跑一整夜是几十万 token 对几百 token 的差距。而且还原是不可持续的——上下文本来就是因为装不下才换窗口的,把装不下的东西原样塞回去,第二个窗口会立刻再次撑爆。

但真正的问题不是钱。是还原出来的上下文比摘要更难用。

把一晚上的流水原样喂回去,模型得自己从几百步里重新推断出「所以我到底做完了哪几条」。这是一次有误差、有成本、而且完全没必要的重新推断——因为这个结论 harness 手上明明已经有了,它就记在 done 里。

所以重建做的是一次有损压缩,但对决策无损:中间推理与工具回执被丢掉了(有损),而下一步该做什么这个决定所需的全部输入都在(无损)。

摘要是写给下一个窗口的自己看的

既然要摘要,就得想清楚读者是谁

D7 那份夜跑报告的读者是人,所以它要好读、有故事线、有前因后果。今天这段文本的唯一读者是下一个窗口里的模型,所以它按机器的口味写:

  • 结论前置,一行一件事,没有过渡句
  • 用 feature 编号,不用「那个笔记列表的接口」这种人话指代
  • 明确写出「更早的对话已经不存在了,不要引用」,免得它去引用自己看不到的东西

实验里重建出来的开场长这样:

TextText
—— 下面这段不是之前对话的回放,是从磁盘状态重建出来的进度摘要 ——
(每一步之后它都被写进磁盘,所以它比上一个窗口活得久。更早的那些对话已经不存在了,不要引用。)
 
进度:已完成 3 条(共 40 条)—— F01 F02 F03
已经烧掉:1 个窗口、3 步。
 
接着做的规则:从上面那份清单里挑第一条不在进度里的,做它。不要重做进度里已有的。

一百七十五个字符,换来的结果是完成条数从 3 变成 9、白干步数从 6 变成 0。

为什么状态文件是结构化数据而不是自由文本

有人会问:既然最后都要拼成一段文本喂给模型,那磁盘上直接存那段文本不就完了?

不行,而且理由很实在:harness 自己要读它。

模型只需要那段人话,但 harness 需要回答「F23 试了几次」「已完成的条数超过预算没有」「上次崩在哪条上」。这些问题对着一段自由文本只能靠正则去抠,而正则会在文案改一个字的时候悄悄失效。存成 JSON,这些都是字段访问。

所以分工很清楚:磁盘上存结构化数据,喂给模型前渲染成文本。 渲染是一个纯函数,改文案不影响 harness 的任何判断。今天 lab 里那个 summarizeState 就是这个渲染函数,它是全课唯一一处「磁盘状态 转成 模型上下文」的翻译。

源码导读

今天新增两个模块,读的时候重点放在三个位置。

第一个位置:原子写。 直接 writeFileSync 是「截断后再写」,进程死在这两件事之间,磁盘上就留下一个半截的 JSON。那比没有状态更糟——没有状态时下一次运行知道自己从头开始,有个半截文件时它会在解析上炸掉,或者更坏,解析出一份缺了一半的进度然后把做完的事再做一遍。

store.js
// 第 1 步:写进同目录的临时文件,并且 fsync 真按到盘上
function writeStateTemp(stateDir, state) {
  const tmp = join(stateDir, `.run.json.tmp-${process.pid}-${nextSeq()}`)
  const fd = openSync(tmp, 'wx')
  try {
    writeSync(fd, JSON.stringify(state, null, 2) + '\n')
    // 少了这一行,rename 仍然是原子的,但内容可能还在页缓存里
    fsyncSync(fd)
  } finally {
    closeSync(fd)
  }
  return tmp
}
 
// 第 2 步:原子改名。同一文件系统上,任何时刻读到的要么是旧的完整状态、
// 要么是新的完整状态,不存在中间态
function commitStateTemp(stateDir, tmpPath) {
  renameSync(tmpPath, join(stateDir, 'run.json'))
}

两个细节值得停一下。临时文件必须与 run.json 同目录——rename 只有在同一文件系统上才是原子的,写进临时目录再搬过来就不是了,而这个错在本机测不出来(同一块盘),只有到了容器里挂载不同卷时才炸。临时文件名里除了进程号还要带一个自增序号——只靠时间戳会在同一毫秒内撞名,而 wx 模式撞名直接抛错,那会变成一个只在快机器上偶发的崩。

顺带说清一条能力边界:严格地说 rename 之后还要 fsync 父目录,元数据才算真落盘。本 lab 做到文件级 fsync 加原子 rename 为止,再往下是文件系统语义的话题、不是 harness 的话题,而且各平台差异很大。这是明写出来的边界,不是忘了。

第二个位置:读回来的时候不许吞错。 这一处只有几行,但它是今天最容易写错的地方。

load-state.js
// 对的写法:文件不存在返回 null(那是「全新的运行」,不是错误);
// 存在但解析不出一份完整状态,直接抛
function loadState(stateDir) {
  const file = join(stateDir, 'run.json')
  if (!existsSync(file)) return null
  const parsed = JSON.parse(readFileSync(file, 'utf8')) // 坏了就让它抛
  if (!isRunState(parsed)) throw new Error(`状态文件坏了:${file}`)
  return parsed
}
 
// 错的写法:把一次数据损坏悄悄变成一次从头重跑。
// 无人值守时没人看得见,早上只会看到进度比预期少了一半
function loadStateSwallow(stateDir) {
  try {
    return JSON.parse(readFileSync(join(stateDir, 'run.json'), 'utf8'))
  } catch {
    return null
  }
}

那个 catch 返回 null 的写法,在交互式场景里是个无伤大雅的兜底;在无人值守场景里它是一类静默故障:不抛异常、不打警告,只是安静地把一整夜的进度清零重来。坏状态必须响。

第三个位置:重建的落点。 窗口边界处只有两行,但有一个决定藏在里面。

rebuild-hook.js
for (let w = 1; w <= config.windows; w += 1) {
  transcript.length = 0 // 与 D1 逐字相同:上下文照样被清空
 
  // 读的是磁盘,不是内存里那个 state 对象。
  // 内存里的进度其实一直是活的(D1 已经证明了),拿它拼一样能跑——
  // 但那样窗口边界就永远只能是一次函数调用,不可能是一次进程重启
  const disk = config.withState ? loadState(stateDir) : null
  const opening = config.withState ? rebuildContext(base, disk ?? state, features) : base
  // ...
}

注意今天改的不是「别丢上下文」。清空那一行与昨天逐字相同,窗口照样是易失的。改的是「丢了之后拿什么接上」。

还有一件事请留意:model/mock.ts 今天一个字都没改,自检拿内容哈希钉着它。开关 withState 属于 harness 侧,绝不传进 askModel。所以昨天的退化今天自己消失,不是被特判掉的——模型甚至不知道状态层存在,它只是在它一贯认的那个位置上第一次读到了进度。

动手实验

🧪 D2 实验:把权威状态搬出窗口

代码位置:labs/agent-harness-7days/day-02-state-layer

今天的 lab 是同一份代码跑两遍:一遍关状态层、一遍开状态层,把差摆出来。练习有五处,src/state/store.ts 三处(原子写两步加读回来)、src/state/rebuild.ts 两处(摘要与拼接)。昨天的四个冻结文件逐字复制,一个字都不用改。

  1. 先读 src/core/loop.ts 里状态层那个落点,看清今天新增的两行接在哪、为什么读磁盘不读内存。
  2. 补上 src/state/store.ts 的原子写两步与 loadState,注意解析失败必须抛、不许 catch 掉。
  3. 补上 src/state/rebuild.ts 的 summarizeState 与 rebuildContext。
  4. 跑 MOCK=1 pnpm selftest 到二十七项全绿。
  5. 跑 MOCK=1 pnpm start,确认完成条数从 3 变成 9、白干步数从 6 变成 0。

跑完记得对比两份账本。这是今天最该亲眼看一遍的一条:关状态层那趟的 work/no-state/state/run.json同样有一份完整账本,写着 done = [F01 F02 F03]丢的从来不是数据,是证据——账本一直在写,只是没有人把它写进下一个窗口的开场。

今天的变异检验不是「关掉状态层」(pnpm start 本来就跑了两趟)。README 里那条更值得做:把 summarizeState 里进度行的「已完成」换成「做好了」,其它一个字不动,再跑一次。

TextText
完成条数   3 → 3
白干步数   6 → 6

状态层整层静默失效了。磁盘上有账本,摘要也确实拼进了上下文,模型就是看不见——因为「已完成」是模型侧写死的那条约定,它只认这三个字加同一行里的编号。

面试题

今天四道题围绕状态边界的划分依据、上下文重建的目标设定,以及一个更基础的功夫:怎么证明你加的东西真的起了作用。

第三道题值得特别练。「我加了个状态层,指标变好了」在面试里是个陷阱题——面试官想听的不是你加了什么,是你怎么排除「碰巧」。答不上变异检验,前面讲得再花哨都会被折价。

检查清单与明日预告

  • 能说出哪三类信息必须进磁盘状态、哪两类只该留在窗口里,并各说明判断依据
  • 能解释等效上下文与原上下文的区别,并举出一个还原原上下文反而更糟的具体情形
  • 能通过关掉状态层让昨天的重复现象重现,用这个变异检验证明状态层真的在起作用
  • 能说清状态文件为什么要放在靶子仓库之外,以及混在一起会在哪一刻出错
  • 能讲明白原子写的两步各自防住了什么,以及临时文件为什么必须同目录
  • 让 MOCK=1 pnpm selftest 二十七项全绿,并对比过两份账本
  • 做过一次「换掉约定词」的变异检验,能说出这类静默故障的形态

明天是 D3《initializer agent:init.sh、进度文件与第一个 commit》。今天解决了「进度记在哪」,但还有一个更早的问题没解决:一个全新的窗口打开时,它怎么知道这个项目该怎么跑起来? 状态告诉它做到哪了,却没告诉它 pnpm install 之后要跑哪个命令、服务监听在哪个端口、测试怎么跑。这些东西每个窗口重新摸索一遍,是四类失败模式里最浪费的那一类。明天把初始化独立成一个阶段,产出三件套:init.sh、进度笔记、第一个 commit,并且给一条可检验的标准——任何新窗口三分钟之内能上手。

面试题库

  • 设计一个长时程 Agent 的状态层,你会把哪些东西写进磁盘,哪些坚决不写?Designing the state layer for a long-horizon agent, what goes to disk and what stays out of it?
    国内高频海外高频进阶#state-management#long-horizon#persistence

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

    1. 这题考的是有没有判据,不是能不能列清单。照着「进度、日志、上下文、工具结果」背一串东西的人很多,区分度在于你能不能给出一条**别人可以拿去套自己项目**的判断规则。
    2. 先把判据摆出来:**下一个窗口要做出正确的下一步,非知道不可的是什么?** 再补一条更好用的反向判据:**这条信息能不能重新算出来?** 能重算的不存,用的时候现取;不能重算的必须存,因为那是历史,历史不可重放。
    3. 按这两条筛,必须落盘的只有三类。一是**做完了什么**,存 feature 编号而不是描述文本——编号稳定,描述会随需求改。二是**正在做什么**,它的价值容易被低估:进程崩在半路时磁盘上会留下一条悬空的「正在做 F07」,那不是脏数据,它恰恰告诉下一个窗口「F07 成没成不知道,先确认,别当成做完了」。丢掉它,下一个窗口要么重做一遍,要么直接跳过一条根本没做完的。三是**试了几次**,这类信息的价值完全来自跨窗口累计——单看一个窗口「试了一次没成」是噪声,跨五个窗口「试了十二次都没成」是一个必须响的信号,打转检测和预算熔断全建在这个计数上。
    4. 坚决不写的有两类。**中间推理**:结论落地之后它的使命就结束了,留着既占地方又会让下一个窗口花注意力读一段已作废的思路。**一次性的工具回执**:某次 git status 的输出、某次读文件的内容——它们的特点是随时能重新获取,而且重新获取的那份才是对的。把三小时前的 git status 存下来喂给新窗口,比不给还糟,它会拿着一份过期快照做决定。
    5. 还有一个位置问题值得主动提,很多人答不到:**状态文件放在被操作的仓库之外**。写进去会有两个后果——它会进 agent 自己的 commit,而且回滚到上一个可用点时账本会被一起回滚掉。回滚的目标是代码,不该把「我已经试过三次」这种教训也一起忘掉。一句话判据:交付给用户的属于仓库,harness 自己的簿记属于状态目录。
    6. 可预期的追问是「为什么状态存结构化数据,反正最后都要拼成文本喂模型」。因为 harness 自己要读它:F23 试了几次、已完成条数超预算没有、上次崩在哪条上——对着自由文本只能用正则去抠,而正则会在文案改一个字时悄悄失效。存 JSON,这些都是字段访问。分工是磁盘上存结构化数据,喂模型前渲染成文本,渲染是一个纯函数。

    How to reason about it · think before answering

    1. This tests whether you have a criterion, not whether you can recite a list. Plenty of people can rattle off 'progress, logs, context, tool results'. The discriminator is giving a rule someone else could apply to their own project.
    2. Lead with the criterion: what does the next window absolutely need in order to choose the right next action? Then add the sharper inverse test: can this information be recomputed? If yes, do not store it - fetch it fresh when needed. If no, it must be stored, because it is history, and history cannot be replayed.
    3. Filtered through those, only three classes survive. First, what is done - store feature ids, not descriptions, because ids are stable and descriptions drift with requirements. Second, what is in flight, whose value is easy to underrate: if the process dies mid-task, disk holds a dangling 'working on F07'. That is not dirty data; it is precisely what tells the next window that F07 may or may not have landed, so verify before assuming. Drop it and the next window either redoes the work or skips something that was never finished. Third, how many attempts, whose value comes entirely from accumulating across windows - one failed attempt in one window is noise, twelve failures across five windows is a signal that must fire. Loop detection and budget cutoffs are both built on that counter.
    4. Two classes stay out. Intermediate reasoning: once the conclusion has landed, the reasoning has done its job, and keeping it both bloats the file and makes the next window spend attention reading a stale plan. One-shot tool receipts: the output of some git status, the contents of some file read. Their defining trait is that you can fetch them again and the fresh copy is the correct one. Feeding a three-hour-old git status into a new window is worse than feeding nothing - it will act on a stale snapshot.
    5. Raise the placement question too, since many candidates miss it: the state file belongs outside the repository being worked on. Putting it inside means it lands in the agent's own commits, and rolling back to the last good point rolls the ledger back with it. Rollback targets code; it should not also erase the lesson 'I have already tried this three times'. One-line test: deliverables belong to the repo, harness bookkeeping belongs to the state directory.
    6. Expected follow-up: why store structured data when it all gets rendered to text for the model anyway? Because the harness itself reads it - how many attempts on F23, is the completed count over budget, which item did the last crash land on. Against free text those become regexes, and a regex breaks silently when someone rewords one line. As JSON they are field accesses. The split is: structured on disk, rendered to text before the model, with rendering as a pure function.

    答题要点

    • 判据一:下一个窗口非知道不可的是什么。判据二:这条信息能不能重新算出来。
    • 必存三类:做完了什么(存编号不存描述)、正在做什么、试了几次。
    • 「正在做什么」的悬空记录是有用信息:它让下一个窗口去确认而不是假设。
    • 「试了几次」的价值来自跨窗口累计,打转检测与预算熔断都建在它上面。
    • 不存两类:中间推理(结论落地即作废)、一次性工具回执(能重取,且重取的才对)。
    • 状态文件放在被操作的仓库之外,否则会进 agent 的 commit、并被回滚一起抹掉。
    • 磁盘存结构化数据、喂模型前渲染成文本,因为 harness 自己要按字段查询它。

    Key points

    • Test one: what must the next window know? Test two: can this be recomputed?
    • Three classes persist: what is done (ids, not descriptions), what is in flight, how many attempts.
    • The dangling 'in flight' record is useful: it makes the next window verify instead of assume.
    • Attempt counts matter only when accumulated across windows; loop detection and budget cutoffs rest on them.
    • Two classes stay out: intermediate reasoning, and one-shot tool receipts that can be refetched.
    • Keep the state file outside the worked repo, or it lands in the agent's commits and dies on rollback.
    • Structured on disk, rendered to text for the model, because the harness queries it by field.
  • 窗口重置后,把之前的完整对话历史重新喂给模型是个好主意吗?After a window reset, is replaying the full prior transcript into the model a good idea?
    国内高频海外高频进阶#context-engineering#long-horizon#cost

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

    1. 这题看起来是道成本题,但只答「太贵了、装不下」拿不到高分。面试官想听的是另一层:**还原出来的上下文不只是更贵,它还更难用。** 能说出这一层的人很少。
    2. 先把成本说清楚,它是基础分。实测过一个数字可以直接用:跑三步产生的原始对话约 960 字符,重建出来的摘要约 175 字符,五倍多的差。跑一整夜是几十万 token 对几百 token。而且还原是不可持续的——上下文本来就是因为装不下才换窗口的,把装不下的东西原样塞回去,新窗口会立刻再次撑爆。
    3. 然后是真正的区分点。把一晚上的流水原样喂回去,模型得**自己从几百步里重新推断**出「我到底做完了哪几条」。这是一次有误差、有成本、而且完全没必要的重新推断——这个结论 harness 手上明明已经有了,它就记在状态里。让模型去重算一件调用方已知的事,是设计上的浪费。
    4. 举一个还原反而更糟的具体情形,这是拉开差距的地方:窗口 1 里模型试着实现 F07,写了一段有问题的代码,验证没过,于是改用另一种写法做完了 F08。把这段对话原样还原,新窗口的上下文里就**同时存在**那段失败的 F07 代码和后来的结论。模型很可能把那段失败代码当成现有实现去接着改——在上下文里它和成功的代码长得一模一样。摘要则只留一行「F08 已完成,F07 试过 1 次未成」,歧义消失了。**信息少了,决策反而更准。**
    5. 所以正解是重建一个**等效上下文**而不是还原原上下文:做一次有损压缩,但对决策无损——丢掉中间推理与工具回执(有损),保留下一步决策所需的全部输入(无损)。
    6. 可预期的追问是「那怎么判断压掉的东西是不是决策必需的」。给一条可操作的验收方式:拿重建出来的开场单独跑一遍,看模型选的下一步是否与预期一致。本课 lab 里有一条断言就是这么写的——只拿磁盘状态重建上下文,断言模型接着做的是 F10 而不是回头做 F01。
    7. 还有一个追问值得准备:「那不就是上下文压缩吗」。不完全是。压缩是把对话本身变短,重建是**根本不用对话**,直接从结构化状态渲染。前者的输入是历史文本,后者的输入是字段。两者可以叠加,但不是一回事。

    How to reason about it · think before answering

    1. This looks like a cost question, and answering only 'too expensive, will not fit' caps your score. The interviewer wants the second layer: a restored transcript is not merely more expensive, it is harder to act on. Few candidates get there.
    2. Cover cost first for baseline credit. A measured figure you can quote: three steps produce roughly 960 characters of raw transcript, while the rebuilt summary is about 175 - more than fivefold. Over a night that is hundreds of thousands of tokens against hundreds. Restoration is also self-defeating: you switched windows because the context did not fit, so stuffing it back in overflows immediately.
    3. Now the real discriminator. Replay the night's stream and the model has to re-derive, from hundreds of steps, which items are actually done. That inference is lossy, costly and entirely unnecessary, because the harness already holds that conclusion in state. Making the model recompute something the caller knows is a design waste.
    4. Give a concrete case where restoring is worse. In window one the model attempts F07, writes broken code, fails verification, then switches approach and completes F08. Replay that verbatim and the new context contains both the failed F07 code and the later conclusion. The model may well treat the failed code as the current implementation and keep editing it - in context it looks identical to working code. The summary keeps one line: F08 done, F07 attempted once, failed. The ambiguity is gone. Less information, better decisions.
    5. So the answer is to rebuild an equivalent context rather than restore the original: lossy with respect to transcript, lossless with respect to the decision - drop intermediate reasoning and tool receipts, keep everything the next choice depends on.
    6. Expected follow-up: how do you know what you dropped was not decision-critical? Offer an operational check - run the rebuilt opening on its own and see whether the model picks the expected next action. The course lab asserts exactly this: rebuild from disk state alone and assert the model proceeds to F10 rather than looping back to F01.
    7. Another follow-up worth preparing: is this just context compaction? Not quite. Compaction shortens the transcript itself; rebuilding skips the transcript entirely and renders from structured state. One takes history text as input, the other takes fields. They compose, but they are not the same thing.

    答题要点

    • 不是好主意。成本上:实测三步的原始对话约 960 字符,重建摘要约 175 字符。
    • 还原不可持续:上下文本来就是装不下才换窗口的,塞回去会立刻再次撑爆。
    • 更关键的是还原更难用:模型要自己从几百步里重新推断出 harness 已知的结论。
    • 具体情形:失败的旧代码与后来的结论并存,模型可能把失败代码当现有实现接着改。
    • 正解是重建等效上下文——对对话有损,对下一步决策无损。
    • 验收方式:拿重建出的开场单独跑,断言模型选的下一步符合预期。
    • 重建不等于压缩:压缩的输入是历史文本,重建的输入是结构化字段。

    Key points

    • No. On cost: three steps of raw transcript measure about 960 characters versus about 175 rebuilt.
    • Restoration is self-defeating - you switched windows because it did not fit, so it overflows again.
    • The bigger issue is usability: the model re-derives a conclusion the harness already holds.
    • Concrete case: failed code and the later conclusion coexist, and the model may keep editing the failed code.
    • Rebuild an equivalent context - lossy on transcript, lossless on the next decision.
    • Acceptance check: run the rebuilt opening alone and assert the model picks the expected next action.
    • Rebuilding is not compaction: one takes history text as input, the other takes structured fields.
  • 你加了一个状态层,跑下来指标变好了。怎么证明是它起的作用,而不是碰巧?You added a state layer and the metrics improved. How do you show the state layer caused it, rather than coincidence?
    国内高频海外高频深入#evaluation#mutation-testing#state-management

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

    1. 这题是本岗位每天的功课,也是最容易被轻视的一道。答「跑了 A/B 对比,开的那组更好」只能拿基础分——那只证明了两组有差,没证明差来自你改的那个东西。
    2. 第一层要说的是**对照的前提**:除了被测的那一处,两次运行的其他一切必须相同。同一份代码、同一个模型、同一份剧本、同一个靶子,只有开关不同。这里有个必须主动提的坑:**开关绝不能传进模型侧**。一旦模型能感知当前开没开,它就能配合演出——开了好好干、关了装傻,曲线很漂亮,但你测的是配合度不是效果。本课的模型接口只收一个字符串,就是为了守住这条。
    3. 第二层是**变异检验**,这才是真正的区分点:不只看「开了之后变好」,还要**关掉它确认旧现象原样回来**。只有前半句时,一个根本没生效的改动也可能因为别的原因让指标变好。两边都做到,因果链才闭合。
    4. 第三层最能拉开差距:**变异检验要挑那个最小的、最关键的点去改,而不是整层关掉。** 整层关掉容易,但它证明的东西比较弱。本课 lab 里那个例子很典型:状态层其它部分一个字不动,只把摘要里「已完成」这个约定词换成「做好了」——完成条数立刻从 9 掉回 3,白干步数从 0 回到 6。这说明效果确实来自「模型在上下文里读到了它认得的进度证据」这条因果链,而不是来自「多写了一个文件」。
    5. 第四层是**断言必须双向写**,这是个容易踩的恒真陷阱。只断言「重复现象消失了」是不够的——一个什么活都不干的实现也能让「无重复」成立。所以要同时断言反向那一半:第一个窗口内部没有重复,**并且确实做满了三条**。本课两条断言的第一版都是恒真的,靠变异检验才发现。
    6. 最后提一句这类改动特有的风险:状态层的失效是**静默**的。不抛异常、不打警告、状态文件看着完全正常、代码评审也看不出问题,只是早上进度比预期少了一半。所以验收不能只看「指标好不好」,要有一条断言直接钉住那个约定的格式本身。
    7. 可预期的追问是「真实项目里没法做这么干净的对照怎么办」。分两步答:能控制的部分(同一批任务、同一个模型版本、固定随机种子)尽量控死;控不住的部分(真实模型的随机性)用重复多次取分布,并且**明确说出置信度的来源**,而不是拿单次运行下结论。

    How to reason about it · think before answering

    1. This is the daily work of the role and the most commonly underrated question. Answering 'I ran an A/B and the enabled arm did better' earns baseline credit only - it shows the two arms differ, not that the difference came from your change.
    2. Layer one is the precondition for a control: apart from the thing under test, everything must match - same code, same model, same script, same target, only the switch differs. Raise the trap proactively: the switch must never reach the model side. Once the model can sense whether the feature is on, it can play along - work properly when enabled, act forgetful when not. The curve looks great but measures cooperation, not effect. The course's model interface takes a single string precisely to hold this line.
    3. Layer two is the mutation check, and this is the real discriminator: do not only observe that enabling it improved things - turn it off and confirm the old behavior returns unchanged. With only the first half, a change that never took effect could still coincide with better numbers. Both halves close the causal loop.
    4. Layer three separates strong answers: mutate the smallest, most load-bearing point rather than disabling the whole layer. Disabling everything is easy but proves less. The lab example is instructive - leave the state layer entirely intact and change only the agreed marker word in the summary from 'done' to a synonym. Completed items drop from nine back to three and wasted steps go from zero back to six. That pins the causal chain to 'the model read progress evidence it recognizes in context', not to 'we wrote an extra file'.
    5. Layer four is writing assertions in both directions, a tautology trap that is easy to fall into. Asserting only 'the duplication disappeared' is insufficient - an implementation that does nothing at all also satisfies 'no duplicates'. Assert the other half too: the first window contains no repeats and did complete its three items. Both of this course's first-draft assertions were tautologies, caught only by mutation testing.
    6. Close with the risk specific to this kind of change: state-layer failure is silent. No exception, no warning, the state file looks normal, code review sees nothing - you just find half the expected progress in the morning. So acceptance cannot rest on 'the metric looks good'; you need an assertion that pins the agreed format itself.
    7. Expected follow-up: what if a real project cannot give you this clean a control? Answer in two parts. Pin down what you can - same task set, same model version, fixed seeds. For what you cannot - real model nondeterminism - repeat and report a distribution, and state where your confidence comes from instead of concluding from a single run.

    答题要点

    • A/B 只证明两组有差,不证明差来自你改的那处,这只是基础分。
    • 对照的前提是除被测点外一切相同,而且开关绝不能传进模型侧。
    • 变异检验:不只看开了变好,还要关掉确认旧现象原样回来,因果链才闭合。
    • 更强的做法是改最小的关键点:只换掉约定词,完成数从 9 掉回 3。
    • 断言双向写,否则「现象消失」是恒真的——什么都不做也能成立。
    • 状态层失效是静默的,所以要有断言直接钉住约定格式本身。
    • 真实项目里控不住随机性时,重复取分布并说清置信度来源,不拿单次下结论。

    Key points

    • An A/B shows the arms differ, not that your change caused it. Baseline credit only.
    • A control requires everything matched but the tested point, and the switch must never reach the model.
    • Mutation check: also turn it off and confirm the old behavior returns, closing the causal loop.
    • Stronger: mutate the smallest load-bearing point - change only the agreed marker and completions fall from nine to three.
    • Write assertions both ways, or 'the symptom disappeared' is a tautology satisfied by doing nothing.
    • State-layer failure is silent, so assert the agreed format itself, not just the metric.
    • When real runs cannot be controlled, repeat for a distribution and state where confidence comes from.
  • 状态文件在写入过程中进程被杀了怎么办?What happens if the process is killed while the state file is being written?
    国内高频海外高频深入#crash-safety#persistence#state-management

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

    1. 这题考的是**故障形态的想象力**。答「加个 try catch」或者「写完校验一下」都没打到点上——问题不在写的时候会不会报错,在于死在中间会在磁盘上留下什么。
    2. 先说清楚坏在哪。直接覆盖写是「截断后再写」:先把文件清空,再往里写内容。进程死在这两件事之间,磁盘上留下的是一个**半截的 JSON**。这比没有状态更糟——没有状态时下一次运行知道自己要从头开始,有个半截文件时它要么在解析上炸掉,要么更坏:解析出一份缺了一半的进度,然后把已经做完的事再做一遍。
    3. 正解是**先写临时文件再原子改名**,两步:第一步把完整内容写进**同目录**的临时文件并 fsync,第二步 rename 成正式文件。同一文件系统上的 rename 是原子的,所以任何时刻去读,看到的要么是旧的完整状态、要么是新的完整状态,不存在中间态。死在第一步之后第二步之前,磁盘上是「旧的完整状态加一个没人认的临时文件」——那个临时文件是垃圾,不是陷阱。
    4. 三个细节能体现实战经验,值得主动说。**临时文件必须同目录**:rename 只有在同一文件系统上才原子,写到临时目录再搬过来就退化成拷贝了,而这个错在本机测不出来(同一块盘),只有到了容器里挂载不同卷时才炸。**fsync 不能省**:少了它 rename 仍然是原子的,但内容可能还在页缓存里,断电后会出现「文件名指向一个内容为空的块」。**临时文件名要带自增序号**:只用时间戳会在同一毫秒内撞名,而排他创建模式撞名直接抛错,那会变成一个只在快机器上偶发的崩。
    5. 另一半同样重要:**读的时候不许吞错。** 把 JSON 解析包在 try 里、失败返回空,是交互式场景的无害兜底,在无人值守场景里它是一类静默故障——一次数据损坏被悄悄变成一次从头重跑,没人看得见,早上只看到进度少了一半,而且完全查不出为什么。坏状态必须响。
    6. 最后主动交代能力边界,这一步很加分:严格地说 rename 之后还要 fsync 父目录,元数据才算真落盘;各平台语义还不一样。做到文件级 fsync 加原子 rename 是一个明确的取舍,不是忘了——**能说出自己方案的边界在哪,比多做一层更能体现判断力。**
    7. 可预期的追问是「每一步都写盘不会太慢吗」。先量再说:状态文件是几百字节量级,而一步里有模型调用和进程启动,写盘那点开销在噪声里。真要优化也是先合并连续的小写,不是放弃崩溃安全——无人值守场景里这一条的代价是一整夜。

    How to reason about it · think before answering

    1. This tests imagination about failure shapes. 'Wrap it in try/catch' or 'validate after writing' both miss - the question is not whether writing errors, it is what a mid-write death leaves on disk.
    2. Name the defect first. Overwriting in place means truncate-then-write: the file is emptied, then filled. Die between those and disk holds a half-written JSON. That is worse than no state at all - with no state the next run knows to start over, while a half file either explodes at parse time or, worse, parses into a partial progress record and redoes finished work.
    3. The fix is write-temp-then-atomic-rename, in two steps: write the complete content to a temp file in the same directory and fsync it, then rename it onto the real name. Rename within one filesystem is atomic, so a reader at any instant sees either the old complete state or the new complete state, never something in between. Dying between the steps leaves the old complete state plus an unclaimed temp file - garbage, not a trap.
    4. Three details show field experience and are worth volunteering. The temp file must be in the same directory: rename is atomic only within a filesystem, and writing to a temp dir then moving degrades to a copy - an error that cannot reproduce on one local disk and only bites in a container with separate mounts. The fsync is not optional: without it rename stays atomic but the bytes may sit in page cache, so a power loss can leave the name pointing at an empty block. And the temp name needs a counter: timestamps alone collide within a millisecond, and exclusive-create mode throws on collision, producing a crash that only appears on fast machines.
    5. The other half matters equally: do not swallow errors on read. Wrapping the parse in try and returning empty is a harmless fallback interactively; unattended it is a silent failure that converts one corruption into a full restart nobody witnesses. You just see half the progress in the morning with no way to trace why. Bad state must be loud.
    6. Finish by naming your limits, which scores well: strictly, the parent directory needs fsync after rename before the metadata is durable, and the semantics differ across platforms. Stopping at file-level fsync plus atomic rename is a stated trade-off, not an oversight - being able to say where your solution ends shows more judgment than adding another layer.
    7. Expected follow-up: is writing on every step too slow? Measure before arguing: the state file is a few hundred bytes, while each step contains a model call and process startup, so the write disappears into the noise. If it ever mattered, batch consecutive small writes rather than abandoning crash safety - unattended, that trade costs a whole night.

    答题要点

    • 直接覆盖写是截断后再写,死在中间会留下半截 JSON,比没有状态更糟。
    • 正解是两步:写同目录临时文件加 fsync,再 rename 成正式文件。
    • 同一文件系统上 rename 原子,读到的要么是旧的完整状态要么是新的,没有中间态。
    • 临时文件同目录(跨卷会退化成拷贝,本机测不出来)、fsync 不能省、文件名带自增序号防撞名。
    • 读的时候不许吞错:catch 掉返回空会把一次损坏静默变成一次从头重跑。
    • 能力边界要明说:父目录 fsync 没做,是取舍不是遗漏。
    • 性能追问先量:状态文件几百字节,开销淹没在模型调用里。

    Key points

    • In-place overwrite is truncate-then-write; dying mid-way leaves a half JSON, worse than no state.
    • The fix is two steps: write a temp file in the same directory with fsync, then rename it into place.
    • Rename within one filesystem is atomic, so readers see old-complete or new-complete, never partial.
    • Same directory (cross-volume degrades to copy and cannot reproduce locally), fsync is required, and add a counter to the temp name.
    • Do not swallow read errors: catching and returning empty turns corruption into a silent full restart.
    • State your limits: parent-directory fsync is omitted deliberately, not forgotten.
    • On performance, measure: a few hundred bytes disappears next to a model call.

评论