Flowness 真实问题 · 第 1 期 · 阅读系列导读 →
先说我们是谁。Flowness 是我们搭建的多 agent 工程系统,属于 Harness 这一类(Harness 指围绕 AI agent 运行的工程系统,负责分派任务、准备上下文、检查结果):它规定 AI agent 怎样接任务、交结果、接受复查,并把共享状态的变更记在一本只追加的事件账本里。它目前由一个人和一群 AI agent 一起运行:人定方向、做取舍,agent 写代码、派活、审查。文中的“我们”指这个组合,“负责人”指那个人。这个系统从 2026 年 3 月下旬跑到现在,6 个多月;只算还留着会话记录的部分(大多是 7 月以后的),它就消耗了超过 1400 亿 Claude token,其中九成以上是缓存读取。截至 10 月 5 日,账本里有约 87 万条事件(按事件编号去重)。我们把运行中遇到的问题整理出了 191 个,这个系列从中挑选,每期公开 5 个。
为什么写,有三个原因。
第一个原因是,把一个问题说清楚,本身就值得做。这些问题多数有公认的名字:第 1 篇是先检查后执行的竞态,第 2 篇是毒消息(每次处理都失败、每次又被放回队列的任务)和管它的重派上限,第 3 篇是 fail-open 和空集合上的全称判断恒为真,第 5 篇是重复计数;每篇在讲清机制的地方都点了名字,第 4 篇的机制我们没有找到贴切的通用名字,文中照实写了。名字不新,我们也不打算把它们说成新的。新的是它们在 agent 系统里出现的形态:做判断的是 agent,它手里的每条证据都是真的,只是被拿去回答了另一个问题。五篇摊开之后,能看到同一个形状,后面“五篇的共同点”一节会讲。一个被说清楚的问题(在什么条件下出现、做判断的 agent 当时看到了什么、为什么那样读在当时是合理的、后来怎么发现的),别人能在自己的系统里认出来。
第二个原因是,这些问题里有不少我们自己还没有答案。每篇末尾都留了一两个我们真的卡住的地方:去重键该放在写入时还是重放时;“这次和上次有没有不同”由程序判还是由人判;一道检查依赖的基线经常缺,让它拒绝、放行还是记成跳过。我们把它们写出来,是想听做法。有些读者在自己的系统里大概已经做过选择,我们想知道你选了哪条,代价是怎么算的。
第三个原因最简单:这就是我们每天在做的事。读者看到的是五篇文章;在我们这边,它们是五段几天到几周的工作,中间有查错方向的时候,有修了一半的时候,有两次事故后各写下一条文档规则、两条规则互相相反的时候。不少修复到今天停在“写成规则、没有机制强制”,五篇里会反复读到这一句。我们按原样写,包括那些让我们不太好意思的部分。去掉它们,问题就不像真的了。
本期五个问题
-
两个进程在交替写账本,账本里看不出是两个写入者。协调 agent 用
ps | grep判断后台脚本已经结束,又启动了第二份,第一份其实还在运行。两份写入经全局提交锁按顺序一条接一条,间隔相同,10 条边各写了两次,账本里没有任何字段能看出有两个写入者。这篇讲一次存活检测的假阴性和一个先检查后执行的竞态怎样叠在一起,以及一把保护数据的互斥锁怎样把两个写入者的记录排成一条看不出来源的序列。那次 grep 为什么没有匹配到,我们至今不知道。 -
一个任务派出 9 个执行会话,只有最后一个成功结束。同一个任务在一天里先后交给 9 个执行会话,前 8 个没有成功结束,其中至少 3 次用的是上一次失败时的同一份任务包。派发前的每道检查都回答“可以派”,它们只问“现在能不能派”,不问“和上次有没有不同”。系统里 6 月就写好了同一任务最多自动重派 3 次的上限,这个任务的重试计数记到了 8,上限一次也没有生效:据代码阅读,派发决定不拿重试计数和上限比较。做过消息队列的人会认出毒消息和死信队列的形状;这篇还写了三类验收条件为什么换多少个执行者都过不去。
-
只见过“通过”的检查,不能证明它在工作。一道检查在没有可判对象时也写下“通过”,这样的记录和真正判断后的通过在结果字段上一模一样。到 10 月 6 日,这道检查留下了 332 条“通过”,其中真正判断过的,我们推断最多 4 条。我们靠一次反向探针才第一次看到它判断;随后的普查发现 38 种检查里 24 种只出现过通过,而做普查的脚本只读了“接受”记录,真正的拒绝一条也看不到。这篇点了 fail-open 和空集合上恒为真两个名字,也写了我们两次统计“拦下过什么”都漏掉了真实的拒绝记录。
-
子 agent 拒绝写入的那条记录,协调 agent 自己写了。一个评审子 agent 读了自己的角色说明,发现这个角色按定义不产出裁决记录,于是没有交出写入命令,把理由写进了最终报告。协调 agent 读了它磁盘上的结果文件就发出了写入命令,6.7 秒后子 agent 才写出报告,报告又过了 7 分多钟才送达。账本只允许追加,这条记录至今还在;之后五周,同一角色名下又出现 4 条同类记录。这篇讲的是子 agent 的产物和它的判断走的是两条到达时间不同的路;我们推断,这套派发方式让子 agent 的拒绝本来就拦不住写入。
-
上下文只用了一半就被压缩。Claude Code 会话里含 advisor 调用的请求,记录下的用量约为真实上下文的 2 倍;自动压缩记录里的“压缩前大小”与它基本相等,我们推断压缩是按它触发的,没有看过客户端代码。调低压缩窗口后的一天里,452 次自动压缩有 194 次发生在真实上下文只有记录值一半的时候。追查中还发现,我们自己的统计按日志行累加,得到的 advisor 调用次数约为真实值的 5 倍,这是重复计数。文中写明了案例会话的 Claude Code 版本和模型,附一段可以直接在自己的会话记录上运行的脚本。
五篇的共同点
五篇里没有一条证据是假的。grep 确实没有匹配到;四道检查确实都放行了;那些“通过”确实是检查函数返回的结果;结果文件确实在磁盘上、内容完整;顶层用量确实是主模型读入的 token 总量。每条证据都正确回答了某个问题。错在做判断的 agent 拿它去回答了另一个问题:
- grep 没有匹配到,回答的是“此刻 ps 的输出里有没有这个名字”,被读成“进程还在不在”;
- “可以派”回答的是“此刻有没有东西挡着派发”,被读成“值得再派一次”;
- “通过”回答的是“检查函数有没有报出问题”,被读成“检查看过对象并且认可了”;
- 结果文件回答的是“子 agent 产出了什么”,被读成“这份产物该落账”;
- 顶层用量回答的是“读入了多少”,被读成“上下文有多大”。
这两个问题的差别,五篇里的记录或检查都没有把它分开:账本的来源字段里没有进程号;派发前没有一道检查比较和上次有没有不同;检查记录里没有“这次实际检查了几个对象”;子 agent 的判断只在最终报告里,写入命令不看它;上下文大小要到用量字段下面的分段数组里才能看到,当时没有人去看。所以五篇的“为什么当时看起来没问题”一节,揭开的都是一条证据和它被拿去回答的问题之间的错位;事后把两种情形分开,靠的是把时间序列排出来、逐条计数,或者读代码。读的时候可以带着一个问题:你系统里那条你最信的证据,它回答的是哪个问题;你需要区分的两种情形,记录里有没有一个字段把它们分开。
怎么读
每篇按同一条线写:我们以为是什么,实际是什么,为什么当时看不出来,我们试了什么、哪些没有用,现在明白了什么,还没想明白的是什么。“为什么当时看起来没问题”一节是每篇的核心,我们把做判断的 agent 手里的每条证据先按它当时的读法写一遍,写到你也会同意为止,再写实际的含义。“如果是你的系统”一节的每一条都能在你的机器上动手试,能给命令的给了命令,我们自己还没做到的,当场标出。内部概念在每篇背景节只保留理解这一篇必需的几个,其余的全系列有一个术语页。
文中的数字来自事件账本、会话记录和 git 历史,时刻一律 UTC,每篇末尾说明了依据和哪些结论是推断。我们推断的地方写“我们推断”,没有核实的地方写“没有核实”。这些词读起来会显得弱,但它们是这些文章可以被信的原因。
每篇末尾的问题都是真问题,我们想听你的做法。