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 。