Flowness / Flowness 真实问题

子 agent 拒绝写入的那条记录,协调 agent 自己写了

评审子 agent 判断这条记录不该写,把理由写进了最终报告。报告写出前 6.7 秒,派出它的协调 agent 已经用它磁盘上的结果文件发出了写入命令。

熟悉的朋友可以直接跳过这一段。

Flowness 是我们搭的多 agent 工程系统,由一个人和一群 AI agent 一起运行:人定方向、做取舍,agent 写代码、派活、审查。它从 2026 年 3 月下旬跑到现在,6 个多月;只算还留着会话记录的部分(大多是 7 月以后的),它就消耗了超过 1400 亿 Claude token,其中九成以上是缓存读取。这个系列把运行中真实遇到的问题一个个摊开写。真实环境里 agent 碰到的问题,大多非常琐碎:一次 grep 没匹配到,一道检查没有对象也记“通过”,一个用量字段被读错了含义。结果能不能信,往往就卡在这些地方。我们把它们写出来,是想和在做同样事情的人交流。

你用一个协调 agent 并行派出一组子 agent,让它们把结果写成文件,自己只管收。等的时候你去磁盘上看了一眼:几份文件已经写好了,内容完整,其中一份正是你验收清单上要的那一项。整个 workflow 还要等几分钟才结束,这份文件现在就能用。你把它落账了。按协调 agent 当时手里的证据,这一步看起来没有问题,后面有一节会一条条讲。2026 年 7 月 29 日,我们的协调 agent 就是这么做的。

15:38:57(本文时刻均为 UTC),Flowness 的协调 agent 用一个评审子 agent 写在磁盘上的结果文件,向事件账本发出了一条评审裁决记录的写入命令。6.7 秒后,那个子 agent 写出了自己的最终报告。报告里说明,它没有交回写入裁决记录的命令,理由是它扮演的评审角色按定义不产出裁决记录,手工构造这样的命令,就是在制造一条不符合角色定义的事件。这份报告又过了 7 分多钟才正式交到协调 agent 手里。

子 agent 的判断是对的,协调 agent 事后也承认了。这次写入没有被拦住。我们推断,子 agent 的拒绝在这套派发方式里本来就拦不住:它的产物和它的判断分开到达协调 agent,产物先到,判断后到,而写入只需要产物。账本只允许追加,这条记录至今还在。之后五周里,同一个角色名下又出现了 4 条同类的裁决记录。

背景:子 agent 的话怎样传回来

Flowness(我们搭的多 agent 工程系统)里,这次工作涉及下面几样东西。

协调 agent(orchestrator-workers 模式里的 orchestrator)是一个长期运行的 Claude Code 会话,负责拆分工作、派出子 agent、收回结果,并把结论写入账本。派发信是协调 agent 写给子 agent 的任务说明。在本文这次工作里,派发信规定只由协调 agent 写账本。

子 agent(subagent,即这个模式里的 worker)是协调 agent 通过 Claude Code 的 workflow 功能并行派出的一次性会话,一个 workflow 同时运行若干子 agent。子 agent 的工作通过三条路到达协调 agent,三条路的到达时间不一样:

  • 子 agent 运行过程中写到磁盘上的结果文件,协调 agent 随时可以读;
  • 每个子 agent 完成时,它的最终报告会写进 workflow 的运行日志;
  • 全部子 agent 结束后,所有报告一次性正式交回协调 agent。

后文的论证依赖这个先后:协调 agent 发出写入命令,在子 agent 写出最终报告之前,所以它只能用到结果文件。

评审角色是一组写在角色说明文件里的行为规范。本次涉及的是需求追溯评审:它对设计里的每个元素追问“这个元素服务哪条需求”,三步之内追溯不到需求的,就记一条“问题记录”,指出这里可能是多余设计或缺失需求。它的说明文件写明:这个角色唯一的产出是问题记录,不产出裁决记录。

事件账本(append-only event log)是一个只追加的 JSONL 日志。每条事件有序号和时间戳,写入后不能修改或删除,订正只能再追加一条新事件。账本里有两种事件类型和这篇有关。问题记录(finding)登记一个问题。裁决记录(verdict)的含义是“某个评审角色对某个对象给出了通过或不通过的裁决”;记录里有一个“裁决类型”字段,说明这是哪一种裁决(例如判断两个候选方案是否真的不同),由写入方填写。裁决记录只有一个写入命令。这个命令会检查评审是否由一个独立启动的进程完成,检查裁决内容是否为空,但不检查这个角色是否应该产出裁决记录。

图 1:子 agent 的结果文件最早到,它对这份结果该不该落账的判断写在最终报告里,晚于结果文件。

经过

当天早些时候,协调 agent 为一项工作写下了验收条件,其中一条要求账本里有裁决记录,三个评审角色各至少一条。三个角色里包括需求追溯评审。

14:49,协调 agent 在一个 workflow 里派出一个子 agent,任务是让需求追溯评审对真实设计文档跑一次,交回结果文件。派发信的第一条规定,大意是:子 agent 不写账本,需要落账的内容,把可以直接执行的命令或数据文件交回来,由协调 agent 执行。同一封派发信的背景说明里,协调 agent 自己写着这个角色产出的是问题记录。

子 agent 14:49 开工后就读了角色说明文件,第 75 行写着这个角色唯一的产出事件是问题记录,不产出裁决记录。它按要求启动了独立评审进程,对 8 份设计文档逐个追问,约 15:35 得到一份 30KB 的结果文件,写到磁盘上。这时它还在准备要交回的写入裁决记录的命令。15:36 之后,它转向查问题记录的写入命令;我们根据它的工具调用推断,它在这时识别出了冲突,决定不交回写入裁决记录的命令,并在最终报告里解释原因。

15:36,协调 agent 查看 workflow 的运行日志:7 个子 agent 中有 4 个已完成,这个子 agent 还在运行,日志里还没有它的报告。协调 agent 在磁盘上看到了三个评审角色的结果文件,记下三份结果都已产出。

15:38:57,它用需求追溯评审的结果文件内容,自己发出了裁决记录的写入命令,裁决类型是它自己选的一个名称。按我们的推断,此时子 agent 已经转向,但还没有写出报告。6.7 秒后,子 agent 写出最终报告。报告里说(大意):它没有手工构造写入裁决记录的命令交回来,因为那样做本身就是在制造一条不符合这个角色定义的假事件。

接下来两分钟,协调 agent 补写了另一个角色的裁决,又做了两次反向测试,分别伪造两种证据:评审不是由独立进程启动的;评审结果来自日志而不是真实运行。写入命令两次都拒绝了。它据此判定这条验收条件成立,正反两个方向都验证过。

15:46:26,workflow 全部结束,子 agent 的报告正式交到协调 agent 手里。15:47,协调 agent 查了角色说明文件,确认子 agent 说得对,自己错了。

图 2:写入命令发出 6.7 秒后子 agent 才写出最终报告,报告又等了 7 分 23 秒才正式送达协调 agent。

这前后有六个时刻,它们是六件不同的事:

时刻 发生了什么
15:38:57 协调 agent 发出写入命令
15:39:04.083 这条事件的时间戳
15:39:04.190 子 agent 写出最终报告;距离协调 agent 发出写入命令 6.7 秒,比事件的时间戳晚 0.107 秒
15:39:29 提交关口(commit gate,所有写账本操作的必经之路)接受了这条事件,它正式进入账本
15:39:31 写入命令的输出返回协调 agent
15:46:26 报告正式送达;距离协调 agent 发出写入命令 7 分 29 秒

我们把它们分开写,因为账本写入是不是早于报告,答案取决于口径:按发出命令和事件时间戳算,早;按提交关口接受算,报告写出在前。对这次错误要紧的是第一个时刻。协调 agent 的决定在 15:38:57 已经做出;从命令发出到输出返回之间,它在等这条命令,没有读到运行日志里的报告。

为什么当时看起来没问题

这一节是这篇文章的核心。协调 agent 发出写入命令时,手里有四条支持写入的证据。我们先按它当时的读法写每一条,写到你也会同意为止,再写实际的含义。四条写完之后,还有一条我们推断的结构性原因。

证据一:验收条件要求这条记录。 条件是协调 agent 自己写的,按字面,没有需求追溯评审的裁决记录,条件就不成立。一个 agent 对照自己写的验收清单逐项补齐,是它该做的事。

实际发生的:这个角色按定义根本不产出裁决记录,所以这条条件在结构上就不可能被正确地满足。写验收条件的会话和后来写入这条记录的会话是同一个。

证据二:上游文档也这么写。 两份工程文档把这个角色写成产出裁决记录。按这两份文档,这条记录就该有。

实际发生的:角色说明文件和一份设计文档写着不产出;角色说明文件里专门有一节解释那两份工程文档是转录错误。从可见的记录看,协调 agent 在写入之前没有读这个文件;这个会话此前被压缩过,压缩之前是否读过,记录无法证明。它派出这个子 agent 时自己写的派发信里已经有正确信息:背景说明里写着这个角色产出的是问题记录。

证据三:写入命令的所有检查都通过了,协调 agent 还做了反向测试。 两次伪造的独立性证据都被拒绝,正向写入被接受。按它的读法,这项检查正反两个方向都验证过了。

实际发生的:反向测试证明独立性检查有效,可是这组检查只回答“评审是否独立完成”,不回答“这个角色该不该产出这种记录”。所以反向测试增加了协调 agent 的信心,对这次错误却没有任何鉴别作用。

证据四:磁盘上的结果文件内容完整。 它是对 8 份设计文档逐个追问的结果,30KB,看起来就是可以直接落账的交付物。

实际发生的:结果文件里是评审内容;这份内容该不该通过裁决记录落账的判断,写在最终报告里。协调 agent 写入时,这份报告还没有写出,运行日志里也没有。它读到了产物,没有读到判断。

图 3:证据一和证据二都把这个角色写成产出裁决记录,两处都错了;证据三和证据四回答的是别的问题。

结构性原因:我们推断,派发方式让子 agent 的拒绝没有约束力。 派发信规定子 agent 交回命令、由协调 agent 执行,所以协调 agent 是唯一的写入方。子 agent 识别出了冲突,它能做的是两件事:不交命令,以及在最终报告里写明理由。它没有办法阻止协调 agent 自己构造一条。这两件事走的都是三条路里慢的那两条:报告在它完成时才写出,到 workflow 全部结束才正式送达。它的产物走的是最快的那条:结果文件一写到磁盘,协调 agent 就能读。

上面推断的这个机制没有贴切的通用名字。它和先检查后执行的竞态(check-then-act race)不同:那种竞态里,被检查的状态在检查和执行之间变了;这里协调 agent 读到的结果文件本身是真的,缺的是还没到达的判断。精确地说,这套派发方式把子 agent 的产物和子 agent 的判断放在两条到达时间不同的路上,产物先到,判断后到,而写入只需要产物。

子 agent 按角色定义行事了,协调 agent 没有。我们不把这读成两个 agent 的高下。两个会话在那一刻的上下文不一样:子 agent 在开工时(14:49)读过角色说明文件;协调 agent 手里至少有 7 个子 agent 的进度和一张验收清单,从可见的记录看,它没有读过角色说明文件。我们的看法是,流程把正确的判断放在了一个对写入没有约束力的位置上,这比哪个 agent 判断得对更值得写下来。

这条记录的评审内容是真实的:它来自一个独立启动的评审进程,对真实设计文档的真实追问。错的是事件类型。这个角色的产出应当记成问题记录,却被记成了裁决记录。

图 4:子 agent 的拒绝经由最终报告走到协调 agent,协调 agent 的写入经由一条检查独立性和内容非空、不检查角色资格的命令直接走到账本。

我们试过的

这次事故有三个缺口。缺口 1:协调 agent 在对应子 agent 交出最终报告之前就写入。缺口 2:写入命令不检查角色是否应该产出这种记录。缺口 3:验收条件要求一个角色产出它按定义不产出的记录。另外还有一件事:事后的订正能不能被后来的复查看到。下面六件事按时间写,每一件最多作用在这四项中的一项,有的哪一项也没有作用到。

7 月 29 日 15:48,补一条订正记录。 协调 agent 写入一条严重级别为 major 的问题记录,说明那条裁决记录不符合角色定义,并写明处理要求:不允许用再写一条正确记录来覆盖它的方式处理,要互相链接、订正验收条件、修正上游文档。这条订正记录当时进了账本,但它的正文放在会话的临时目录里,账本事件只保存了一个文件路径。9 月 30 日我们普查未关闭的问题时,这个路径已经不存在,订正记录仍是未关闭状态,正文无法复核。

7 月 29 日,修改验收条件。 协调 agent 把条件改为两个角色各一条裁决记录,加上需求追溯评审的问题记录,并写明这是在订正一个结构上不可能满足的条件。但同一天早些时候登记的一项待办(系统里登记的未完成事项,每项附有验收条件),它的验收条件里仍是三个角色各一条,这一处没有同步修改。

7 月 29 日,改走正确的渠道。 需求追溯评审的 8 条发现,此后都用问题记录的写入命令逐条落账,每条注明为什么不走裁决记录。这一步符合角色定义,但不能撤销已经写入的那条。

7 月 29 日,写成操作规则。 当天,协调 agent 把这次经历写进了协调 agent 接班时使用的错误记录文件,规则的大意是:收回 agent 或 workflow 的结果,是动手之前的第一个动作。另附一个自查问题:我正要写的记录、正要改的文件,有没有一个已完成但我还没读的 agent 交付,范围覆盖这件事?这条规则没有对应的自动检查。我们的判断是,按这次的实际时序,它也没有覆盖住关键的那一步:写入时子 agent 还没有完成,并不存在“已完成但没读”的交付,缺的是“等对应的子 agent 交出最终报告再写”这一步。

修正上游文档。 两份把角色写错的工程文档加了勘误段落,消除了误导来源。

7 月 31 日,在读取端加校验。 系统会给设计中的每个概念记录一个可信等级,裁决记录可以作为升级的依据。我们在这条升级路径里加了一条规则:如果裁决记录来自一个按定义不产出裁决的角色,就不能用来升级。这条规则附带测试,测试把“可以产出裁决的角色”这个集合,与各角色说明文件里声明的产出逐一核对。这道校验到今天仍在主干上,测试通过。

它只保护了一种用法。8 月 1 日,一次复查(另起一个会话,核对一项待办是否真的完成)明确指出这条记录与角色定义冲突,结论是无法确认完成。9 月 2 日,另一次复查需要判断上面那项待办是否完成。复查方按角色统计了裁决记录,需求追溯评审名下有 5 条(7 月 29 日那条,加上下一节提到的之后 4 条),并把 7 月 29 日那条当作真实的评审记录引用为证据,待办随后被关闭。两次复查对 7 月 29 日这条记录做了相反的处理,后一次没有引用前一次,我们没有查到原因。

图 5:六件事里没有一件改了写入端,也没有一件给“先等子 agent 交出最终报告再落账”加上自动检查。

现在的状态

截至 2026 年 10 月 6 日:

  • 读取端的校验和它的测试都在主干上,今天运行通过。
  • 写入端没有改动。裁决记录的写入命令仍不检查这个角色是否应该产出裁决记录。8 月 5 日之后,它的帮助文字还把需求追溯评审的角色名列为参数示例。
  • 7 月 29 日之后,账本里又出现了 4 条同一角色名下的裁决记录,分别在 8 月 19 日、8 月 29 日和 8 月 30 日(两条)。4 条的裁决类型有 3 种(8 月 30 日的两条相同),没有一种属于该角色定义的产出。账本没有记录是谁调用的写入命令,所以我们不能断定它们的成因与 7 月 29 日相同。可以确定的是,写入端没有阻止它们。
  • 订正记录的正文已经丢失,只留下一个失效路径。
  • 关闭时引用了这条记录的那项待办,我们按待办编号查询,没有查到重新打开的记录。
  • “先等子 agent 交出最终报告再落账”目前没有通用的自动检查。我们在另一个窄场景里做过机械化处理:复查结论里如果写着不得据此关闭待办之类的措辞,关闭流程会自动停下。它是按措辞匹配的自动检查,只覆盖“关闭待办”这一种动作;这个机制来自 9 月 19 日处理的另一件事,不是针对这次。

如果是你的系统

下面是我们会在饭桌上跟同行说的几句话。每一句都能在你自己的系统上动手查;我们自己还没做到的,会标出来。

把子 agent 的产物文件和它的最终报告当成两样东西。 子 agent 运行中写到磁盘的文件,只是它工作的一部分。它对“这份产物该怎么用、能不能用”的判断,通常写在最后的报告里。如果你的编排方式是“全部子 agent 结束后一次性回传”,那么在回传之前读到的任何文件,都缺少这一层判断。可以查:协调方的写入操作,有没有发生在对应子 agent 写出最终报告之前(它的判断能被读到的最早时刻,就是报告写出的时刻)。办法是把写入命令的发出时刻和每个子 agent 报告的写出时刻放在一条时间线上。如果你用的是 Claude Code,这段脚本就是做这件事的;它读协调会话的记录和它派出的子 agent 的记录,改三个占位符就能跑,前提是你的会话记录目录布局和我们机器上的一样,不一样的话按同样的思路找:

# 协调会话的记录,以及它这次 workflow 里各子 agent 的记录
S=$HOME/.claude/projects/<项目目录>/<会话 id>.jsonl
W=$HOME/.claude/projects/<项目目录>/<会话 id>/subagents/workflows/<workflow id>
PAT='<写入命令里独有的字符串>'
{
  jq -r --arg pat "$PAT" 'select(.type=="assistant") | .timestamp as $t
     | .message.content[]? | select(.type=="tool_use" and .name=="Bash")
     | select(.input.command|test($pat)) | "\($t)\t写入命令发出"' "$S"
  for f in "$W"/agent-*.jsonl; do
    a=$(basename "$f" .jsonl)
    t=$(jq -r 'select(.type=="assistant")|.timestamp' "$f" | tail -n 1)
    printf '%s\t子 agent 最终报告写出\t%s\n' "$t" "$a"
  done
} | sort

第一段列出协调会话里命令文本含有那个字符串的每一次调用的时刻;第二段取每个子 agent 记录里最后一条 assistant 消息的时刻,当作它写出最终报告的时刻(这一点我们在那次的 7 个子 agent 上核对过,你的版本不一定一样);合在一起按时间排。写入命令排在对应子 agent 的报告之前,就是这篇文章说的那种写入。字符串匹配的是命令文本,所以准备写入的命令和反向测试也会被列出来,看时间线时要认一下哪几行是真的写入。我们在 7 月 29 日那个会话的记录上跑过这段:15:38:57 的写入命令和 15:39:04.190 的最终报告按这个顺序排了出来。

让拒绝发生在写入端。 如果子 agent 只能“交回命令由协调方执行”,它的拒绝就只是报告里的一段文字。我们的设计建议(还没有实施)是:让写入命令自己知道每个角色允许产出哪些记录类型,并拒绝不匹配的写入。可以查:你的每个写入入口,是否同时校验了“证据是否齐全”和“这个角色是否有资格产出这条记录”。前者通过,不说明后者成立。

看一眼你的反向测试伪造的是什么。 反向测试证明一项检查会拒绝某种错误输入。它只对那一个维度有效。可以查:你最近一次“正反两个方向都验证过”的结论,反向测试伪造的错误和你担心的错误是不是同一种。我们那次伪造的是独立性证据,担心的应该是角色资格。

订正记录的正文要和事件存在一起,并且查这条记录时要一起返回。 在只追加的日志里,订正是唯一的修正手段。正文放在会被清理的位置,只在日志里存路径,几周后订正本身就无法复核。我们的两次复查对同一条错误记录做了相反的处理,后一次没有引用前一次。可以查两件事:你的订正或撤回记录,正文是否保存在与原记录同样持久的地方;查询一条被订正过的记录时,订正是否会跟着一起返回。就 7 月 29 日这条记录而言,第一件没有做到(正文已丢失);第二件,按事件编号查询账本,没有返回订正记录。

写验收条件时,对照角色定义检查一遍。 我们的验收条件要求一个不产出裁决记录的角色产出裁决记录,写条件的会话后来又亲自去满足它。可以查:验收条件里要求某个角色产出的每一种记录,是否都在这个角色的定义之内。

还没想明白的

有两件事我们到今天没有答案,想听听你的做法。

第一件关于写入端怎么知道这个角色该产出什么。我们建议的改法是让写入命令持有一张角色与允许产出的记录类型的对照表。可是角色的定义写在给模型读的角色说明文件里,这次事故里两份工程文档和角色说明文件就已经不一致,再在写入命令里放一份表,就是又一份副本,它会不会在下一次改角色时漂掉?我们的读取端校验用测试把代码里的集合和角色说明文件逐一核对,这能发现漂移,但只在跑测试时发现。如果你的系统里角色定义和写入校验分属两处,你是让写入时现读角色文件,还是让测试钉住两边一致,还是要求每条写入带上子 agent 最终报告的标识、让写入端拒绝没有报告的写入?各自的代价你怎么算?

第二件关于协调方什么时候可以读子 agent 的中间文件。运行中读磁盘上的结果文件,是协调方看进度最直接的办法,我们不想禁掉它。可是这次错误正是从这条路进来的。你是把运行中的文件标成草稿、不许据此写入,还是让子 agent 把“这份产物该怎么用”写进产物文件本身的第一行、而不是只写进报告?后一种做法我们没有试过,它要求子 agent 在产出之前就做完判断,而这次的子 agent 在开工时就读过角色说明文件,转向问题记录通道却是在结果文件产出之后(按它的工具调用推断)。

还有一个小一点的问题:如果你维护的也是只追加的事件日志,你怎么让一条订正在别人查询被订正的那条记录时一定被看到?我们 8 月 1 日和 9 月 2 日的两次复查,后一次按角色数了记录数,没有引用前一次的发现。


依据说明:本文依据 Flowness 的事件账本、协调 agent 与子 agent 的会话记录、workflow 运行日志、git 提交历史和当前代码,时间均为 UTC,状态截至 2026 年 10 月 6 日。引文标“大意”的为转述。协调 agent 写入前是否读过角色说明文件,会话记录无法完全证明,文中已注明;从可见的记录看,它写入前涉及这个角色的工具调用只有 3 次,没有一次是读取它的说明文件。8 月之后 4 条同类记录的成因未经追查。文中到秒的时刻为舍去秒以下部分,时长按未舍去的原始时刻计算,取整到秒或 0.1 秒;子 agent 识别出冲突的时刻是根据它的工具调用推断的,它的推理文字没有保留。“如果是你的系统”里的脚本在我们的机器上对 7 月 29 日的协调会话记录实际运行过;脚本把子 agent 记录里最后一条 assistant 消息当作最终报告,这一点我们在那次 workflow 的 7 个子 agent 上逐个核对过,每个的最后一条消息都与运行日志里的报告逐字相同。


每篇末尾的问题我们自己还没有答案。你有做法,或者在自己的系统里见过同样的事,欢迎写信到 hi@natureblueee.com。