atelier 源码拆解 · 二

代码注释里留下的那些判断失误

上一篇 讲 atelier 长什么样。
这篇讲它是怎么长成这样的——包括三次判断被自己推翻。

本文的依据全部来自代码注释与提交记录。凡是文中提到的教训,代码里都留着对应痕迹。

一、先看结论

atelier 现在有 2058 个测试用例、15 个包。但真正塑造它的是几个错误判断:

判断 结果
「加一层抽象会更清晰」 设计的投影层零消费者,删了
「用更强的枚举统一语义」 两个正交维度,不能合并
「压缩结果没变短就不写回」 制造了一个隐蔽的行为差异,修掉了
「先拆文件再理逻辑」 撞墙两次,顺序反了
「测试全绿就是好的」 1 条用例三次跑一红两绿,还挖出 2 条假用例

下面一条条讲。

二、第一次自我纠正:抽象层没有消费者

我最初的设计

主循环跑完,需要一个「结果摘要」给宿主看。我设计了 RunProjection:

// 已经删掉的结构体,这里只留个样子
struct RunProjection {
    let status: String
    let turns: Int64
    let toolCalls: Int64
    let text: String
}

还加了一层 outcomeOf() 想统一两个翻译器,让它们都从 projection 取值。

实测结果

零消费者。

  • A2A 和 AG-UI 的消费端是逐事件调用的,它们不想要「跑完之后的总结」
  • 而 outcomeOf() 需要完整事件序列才能算——形态根本不匹配

删掉之后

更关键的是判据变了。原来的验收标准是「代码变简单了吗」,改成了:

「哪一行代码会被删掉?」有具体行会消失,才是真消费者。

用这个判据重新看,RunProjection 和 outcomeOf 一行都删不掉任何东西——它们是纯粹的负担。

于是留下一条通用判据:「加一层抽象」和「统一判据」往往都是错的直觉。

三、第二次自我纠正:两个正交维度不能合并

我想做什么

Agent 的回合结束有多种原因(正常结束、工具预算耗尽、被钩子拦、被取消、失败……)。我设计了 TurnEndReason 枚举,想用它替代已有的 StopReason——「一个字段说清所有事」。

为什么不行

因为它们是两个正交的问题:

问什么 谁回答
这个 run 结没结束? TurnEndReason
这个回合内要不要继续? StopReason(ToolUse / Length / …)

举例:模型调了工具然后又调了工具,此时 StopReason = ToolUse,但如果 reason 认为「任务已完成」就会提前下发终态——工具还在跑,协议里终态却不可再更新。

最终的设计

两个字段并存,各答各的。而且 TurnEndEvent.reason 刻意不设默认值:

public class TurnEndEvent <: AgentEvent {
    /**
     * 不设默认值:默认值会把
     * 「旧的错误收尾」静默当成
     * Completed,而那正是这个字段
     * 要消除的那类事实缺口。
     */
    public let reason: TurnEndReason
}

不设默认值的代价是:所有构造点必须逐个改,漏一个就编译报错。这正是想要的效果——用编译器强制你把每个收尾原因说清楚。

四、第三次自我纠正:一个「顺手删」造成的隐蔽差异

上下文

把压缩策略提成可替换接口 Compactor 时,我重写了默认实现。其中有一段:

// 我认为:压缩后如果没变短,
// 就没必要返回新列表
if (next.size >= messages.size) {
    return messages      // ← 丢弃结果
}

看起来是合理的优化——没变短,等于没压缩。

实际后果

3 个压缩用例变红。

问题出在一个边界情况:

单条消息超长(18000 字符)
  → 单条就超过保留预算 2000
  → retainTokens 切点算出来是 0
  → 压缩产出「检查点 + 原消息」= 1 条
  → 原消息也是 1 条
  → size 相等,被判定为「没变短」
  → 丢弃 → 什么都没发生

但原实现是会返回新列表的——因为「检查点 + 原消息」虽然条数相同,内容不同:它插入了一条摘要。

修法与留下的契约

// 注意:这里必须**原样返回 next**
// (哪怕它没变短)
// —— 压缩「没变短」由调用方按
// result.size < messages.size
// 判定为 noop 并留痕,
// 不是靠这里丢弃结果。
// 丢弃结果会带来一个隐蔽的行为差异:
// 摘要成功但保留区覆盖全文时
// (单条超长消息就属于这种),
// 历史本应被替换成「检查点 + 原消息」,
// 丢弃则什么都不发生。

这段注释现在还在 compactor.cj 里。教训是:

「没有效果」和「没做」是两件事。 前者要留痕(noop 记录),后者的静默丢弃会让一次成功的操作看起来像没发生。

五、548 行拆成 270 行:顺序反了两次

背景

主循环 chatWithEnv 一度有 548 行,主循环体 500 行。显然该拆。

我试错的两次

尝试 做法 结果
第一版 按「阶段类」拆,5 个类共享一个 RunState(8 字段) 撞墙
第二版 用 extend 按主题拆成多个文件 又撞墙

两次都撞在同一面墙上,但当时没意识到为什么。

真正的原因

问题不在「怎么拆」,在「先拆什么」。

正确的顺序是:先理清控制流,再看代码能不能按这个边界切开。我一开始跳过了第一步,直接想文件怎么分,所以怎么试都不对。

最终的拆法

按控制流的七个阶段切(不是按文件、也不是按类):

阶段 职责
openTurn 回合开场、插话处理、归档
compactBeforeRequest 请求前压缩
invokeProvider 调模型 + 超窗恢复
executeToolBatch 批量执行工具
admitToolCall 准入判定(三分支)
admitAfterHooks 钩子裁决
admitByAsking 发起提问

跨回合状态收进一个 RunScope(原来散落 81 处引用,现在阶段方法只收一个引用):

// 阶段方法通过 scope 访问运行时状态,
// 装配期组件仍在 AgentRuntime 上。
internal class RunScope {
    var messages: ArrayList<Message>
    var turnCount: Int64 = 0
    var toolCallCount: Int64 = 0
    var lengthContinuations: Int64 = 0
    var verifyPasses: Int64 = 0
    var resumingSameTurn: Bool = false
    // ...
}

一个「刻意不合并」的细节

lengthContinuations 和 verifyPasses 看起来都是「次数」,能不能合并成一个?

不能。因为它们回答的问题不同:

截断续跑:同一条回复没写完
  → 不推进回合计数
自验:模型已宣布完成,进入核对
  → 按新回会计

由此产生一个有意的行为:一次自验之后,本回合的续跑预算会重置——因为核对阶段被截断时还得有完整额度自救。整轮上界由 maxTurns 兜住。

这类「看起来能合并、实际不能」的字段,注释里必须写清为什么不合并。否则下一个人(包括三个月后的自己)会顺手合并掉。

六、最反直觉的一条:flaky 比失败更危险

现象

修完一批缺陷后,全量测试「通过」。但有一条用例:

第 1 次:红
第 2 次:绿
第 3 次:绿

为什么这比红更危险

  • 明确失败:你知道有问题,会去修
  • flaky(时红时绿):你倾向于认为「这次是偶发」,然后重跑一次,绿了就走

而 flaky 背后往往是产品侧的竞态。这次留在测试里,下次就留在生产里。

背后是什么

子 Agent 的等待回复有个时序窗口:

父:提问已投递 → 子:还没登记等待
  → 窗口内的回复被判为「回合中消息」
  → 子一直等到超时

修法是让投递带上「我会等」的标记,先登记再投递——用顺序消除窗口,而不是靠加长超时。

顺带挖出两条假用例

自查 flaky 时发现另外两条恒真的用例:

// 断言「run 结束后状态是 Idle」
// 但 run 开始前状态也是 Idle
// —— 这条断言永远成立
@Assert(status == Idle)

它从来没测过任何东西。

这两条比 flaky 更糟:flaky 至少还会红一次,假用例永远绿。

七、从这些事里抽出的四条判据

回头看,上面所有纠正都可以归到四条可执行的判据:

判据一:抽象要看「删掉什么」

加一层抽象之前先问:它会删掉哪些具体的行?
说不出来就别加。

判据二:字段要看「会不会一起变」

两个字段能不能合并,不看名字像不像,看它们会不会同时变化。
一个变另一个不变(或变化条件不同),就是正交,不能合。

判据三:无效果不等于没发生

「操作执行了但没有效果」必须可观测(noop 留痕),
不能靠静默丢弃来「省事」。

判据四:全绿要看连跑

报告「全绿」之前至少连跑三次。
而且每个断言都要问:它在操作之前也成立吗? 成立就是恒真,等于没测。

八、这些纠正留下了什么

不是抽象的感悟,而是落在代码里的东西:

遗留物 位置 作用
TurnEndReason 无默认值 event.cj 编译器强制每个收尾点说清原因
compact() 必须原样返回 compactor.cj 注释写明了「丢弃会怎样」
RunScope 字段的差异注释 run_scope.cj 防止后人把两个正交字段合并
先登记再投递 子 Agent 通信 用顺序消除竞态窗口

这大概是「错误判断」最大的价值:它不只是一次修正,还会变成一个别人绕不过去的约束。

九、小结

  • atelier 有 2058 个测试,但塑造它的不是这些测试,而是几次把自己说服的过程推翻
  • 三次自我纠正:抽象零消费者、正交维度误合并、静默丢弃造成的行为差异
  • 548 行拆分的顺序比切法更重要:先理控制流,再想文件
  • flaky 比明确失败更危险;恒真的假用例比 flaky 更糟
  • 四条判据:删掉什么 / 会不会一起变 / 无效果要留痕 / 全绿要连跑

上一篇讲它长什么样(把模型变成 Agent 的那层脚手架 )。
它的存储底座见 badger-cj 。