Flowness / Flowness 真实问题

两个进程在交替写账本,账本里看不出是两个写入者

一把互斥锁让两个进程的写入按提交顺序一条接一条,账本里每条记录的写入者标识相同、间隔相同,至今看不出是两个写入者。

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

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

你让 agent 在后台跑一个长脚本,往数据库、队列或者只追加的日志里逐条写东西。隔几分钟看一眼它还在不在,用的是 ps | grep 脚本名。某一次,grep 什么也没返回。日志文件是空的。写入计数停在一个不完整的数字上。前两条证据指向同一个读法:进程死了,重启。第三条看起来只是个小差异。

2026 年 7 月 29 日,Flowness(我们搭的多 agent 工程系统)的协调 agent 就是这么读的。它在后台运行一个脚本,往事件账本里逐条写入 68 条关系边。写到第 57 条时,ps | grep 脚本名 没有匹配到,它判断脚本已经结束,又启动了一份。第一份其实还在运行。此后约 9 分钟里,两个进程轮流写入,最后 10 条边各被写了两次。这些事件写入了只追加的账本,至今还在。

事后我们把这 78 条事件排出来看。它们在账本里记录的写入者标识完全相同。两个进程并发写入的那一段,事件每隔约 27 秒一条,间隔相同。账本里没有任何字段能看出这里有两个写入者。

这篇文章讲两件事怎样叠在一起:一次把活着的进程判成已结束的误判,和一道幂等检查挡不住的并发;以及我们之后做的事,为什么到今天还没有解决其中任何一件。

背景:账本、提交锁和那个脚本

理解这件事需要四个概念,和那天的那个脚本。

事件账本(append-only event log)是系统共享状态的记录:只追加的 JSONL 日志,每条事件有全局递增的序号和时间戳,写入后不能修改或删除。每条事件还记录写入者标识,包括写入时所属的会话编号和写入身份,不包括进程号。最后半句在后文很要紧。

概念图记录设计对象、工程组件以及它们之间的关系边。它是账本的派生视图,不是权威来源:从账本事件重放出来的当前状态视图(内部也叫“投影”,projection),可以从账本重建。读图命令每次读取前会先追上账本的最新位置。

提交关口(commit gate,本系统的叫法)是所有写账本操作的必经之路。一次提交在账本里留下 3 条记录:提交申请、改动本身(这里是一条边)、关口的接受记录。关口内部有一把全局互斥锁(mutex),同一时刻只允许一个进程进入提交临界区(critical section)。从账本看,当时一条边从事件时间戳到提交完成大约要 26.5 秒;我们推断这段时间进程持有提交锁(依据见后文)。慢在哪里,8 月 20 日的一次修复说明里有解释,放在后文“我们试过的”里;修复后一次提交降到 2.6 秒。

协调 agent 是一个长期运行的 Claude Code 会话,负责派活、收结果,也亲自执行写账本这类动作。

建边脚本由一个子 agent 编写,任务是把 68 组“工程组件实现了哪个设计对象”的对应关系,作为 68 条 implements 边写进概念图。脚本每次调用一次命令行,写一条边;每条边的编号在调用时随机生成,与边的两端无关。写脚本的子 agent 想得不少,文件开头的注释里写了三条设计约束:

  • 默认只读:不加 --execute 参数时只做对账,不写入。
  • 幂等(idempotent):启动时读一次概念图,算出哪些边已经存在,只写剩下的。注释说明,这是为了防止脚本被杀后重跑、产生重复事件。
  • 串行:注释写明,提交锁全局唯一,脚本一次只处理一条边,不起并发。

读到这三条,你大概会觉得这个脚本是安全的。我们当时也这么觉得。它防住了它设想的那种事故:一个进程被杀,再跑一遍。它没有设想的是两个进程同时在跑。

懂并发的读者到这里已经认出来了:先读概念图、再写缺少的边,读和写之间没有互斥,这是先检查后执行(check-then-act)的竞态,TOCTOU(time-of-check to time-of-use)的一种。这个竞态本身不新。新的是它怎样被触发:第二次启动它的是一个 agent,它手里的三条证据没有一条是假的,只是每条证据回答的问题,都和它想问的那个不一样。“为什么当时看起来没问题”一节把这三条证据逐一摊开。

图 1:一条边进入概念图之前,有约 26.5 秒对别的读者不可见。

经过

本文时刻均为 UTC。

09:19,协调 agent 先不带参数运行一次:图上已有 0 条,待建 68 条。随后它在后台启动脚本(下称进程 A),输出重定向到一个日志文件。第一条边在 09:19:42 写入,之后大约每 35.5 秒一条。

接下来的 34 分钟里,agent 三次用 ps -eo pid,etime,args | grep 脚本名 | grep -v grep 检查,三次都看到了进程 A。09:53:30 那次,账本里已有 56 条。

09:54:01,agent 发出下一条检查命令。这次它把两件事写进了一行:先统计账本里的 implements 边数,再执行 ps -eo pid,args | grep "<脚本名>" | grep -v grep >/dev/null && echo " 建边仍在跑" || echo " 建边进程已结束"。09:54:07 返回两行:implements 边: 57/68 和 建边进程已结束。ps 的原始输出被丢进了 /dev/null,没有留下。

就在这一刻,进程 A 正在提交第 58 条边。这条边的事件时间戳是 09:54:01.4,到 09:54:27.97 才提交完成。

09:54:17,agent 查看日志文件,wc -l 显示 0 行。09:54:28,它写下判断:日志 0 行,进程被杀了,SIGKILL 不会把缓冲区写出去。

09:54:30,agent 再做一次只读对账,09:54:38 得到结果:已存在 58、待建 10。它注意到这个数比刚才多一条,写道:“比我数的 57 多一条,投影视角更准。”

09:54:37,进程 A 开始提交第 59 条边。

09:54:45,agent 启动第二份脚本(下称进程 B)。进程 B 启动时读到 58 条已存在,于是把剩下 10 条列为待写。第 59 条正是进程 A 正在提交、还没进入概念图的那一条。

09:55:04.116,进程 A 的第 59 条提交完成;27 毫秒后,进程 B 同一条边的事件时间戳出现。此后,两个进程轮流进入临界区,把同样的 10 条边各写了一遍。全局看是每 26.8 秒一条,每个进程自己是每 53 秒一条。

进程 A 在 10:03:05 写完,进程 B 的最后一条在 10:03:32 提交完成。脚本收尾的自检报告 68 条目标边全部确认存在。

图 2:grep 判断“已结束”时,进程 A 正在提交第 58 条;进程 B 启动读图时,A 的第 59 条还在提交中。

12:01,agent 做了一次不依赖脚本自报的独立回查:implements 事件有 78 条,去重后的边只有 68 组,也就是 10 对重复。

这 10 对有同样的特征:每对相隔 3 个序号(正好是一次提交留下的记录数)、约 27 秒,写入者标识相同,边编号不同。脚本代码里没有重试逻辑。agent 的结论是:“是我起了第二个进程而第一个没真死。”

为什么当时看起来没问题

这一节是这篇文章的核心。我们把 agent 当时手里的每条证据先按它当时的读法写一遍,再说它实际的含义。读的时候可以在每条揭开之前停一下,想想你会不会也信。

证据一:grep 没有匹配到。 前三次类似的检查都匹配到了进程 A,这一次没有。按名字匹配进程是常见做法。前三次检查都对,没有理由怀疑第四次。

实际发生的:进程 A 当时正在提交第 58 条。这次检查的 ps 管道没有输出任何匹配行,这一点由命令输出本身确定;为什么没有,我们不知道。这次检查把一个仍在运行的进程判成了未运行,属于假阴性(false negative)。它返回 21 秒后,进程 A 完成了第 58 条,又过了 9 秒开始提交第 59 条。为什么没有匹配到,我们查不到原因,因为输出被丢弃了,进程当时的状态也没有保存。这是整件事里最让我们不安的一个空白:我们知道它错了,不知道它为什么错。

证据二:日志是空的。 wc -l 显示 0 行。一个跑了 34 分钟、写了 57 条的进程,日志里一个字都没有,最顺手的解释是进程被 SIGKILL 杀掉了,缓冲区没来得及写出去。这个解释在技术上成立:SIGKILL 确实不给进程刷新缓冲区的机会。

实际发生的:解释成立,只是不是这里发生的事。我们用一个最小脚本复现了这个情形:在 Python 3.12 里,把标准输出重定向到文件时,输出是按块缓冲的(block buffering),进程在运行时日志文件是 0 行,进程退出后内容才出现。事故中的脚本没有在打印时强制刷新,启动时也没有加 -u 参数,而且每 5 条边才打印一行进度。所以一个正在运行 34 分钟的进程,日志为 0 行是正常现象。下面两行命令可以复现同样的情形,放在“如果是你的系统”里。

证据三:对账读到 58,比刚才多一条。 agent 注意到了这个差别,并给了一个解释:投影视角更准。两种统计办法差一条,并不罕见。

实际发生的:三个读数放在一起看,09:53:30 是 56,09:54:07 是 57,09:54:38 是 58。只要把它们放在一起比较,就能看出写入还在继续。agent 对这三个数的反应各不相同。56 那次没有引起任何动作。它把 57 读成只有 57/68、缺 11 条。读到 58 时它和 57 比了,把多出的一条读成两种统计口径的差别,并把这次对账的结果(已存在 58、待建 10)当作脚本的幂等识别正确的证明,据此补完剩下 10 条。它没有把三个数放在一起看它们在增长。增长本身是最直接的存活证据。

图 3:前两条证据被读成进程已结束,第三条是增长的证据,被读成口径差别。

还有一道保险:脚本是幂等的。 agent 在重启前写道,幸好脚本是幂等的。它随后做了一次只读对账,确认 58 已存在、10 待建,然后启动了第二份。脚本的幂等做法是先读概念图,再写不存在的边,这对同一个进程被杀后重跑确实有效。

实际发生的:这就是背景节里说的那个先检查后执行的竞态。两个进程同时运行时,读和写之间没有互斥,而一条边在提交过程中的 26.5 秒里,对其他读者不可见。进程 B 启动时读到 58 条,正是因为 A 的第 59 条还在这 26.5 秒里。先读后写的幂等,前提是读和写之间没有别人在写;它防的是先后两次运行,防不住同时的两份。

事后才看到的:提交锁让两个写入者在账本里无法区分。 两个进程轮流进入提交临界区,写入按提交顺序一条接一条,间隔相同。每条事件的写入者标识也相同:脚本用的写入命令把事件记在一个已登记的共享会话编号下,两个进程都是如此;账本的来源字段里没有进程号。我们推断事件的时间戳是在进程拿到锁之后才生成的,依据是账本里的时间关系:前一条提交完成后 27 到 28 毫秒,后一条的时间戳就出现。于是这把锁一面保证同一时刻只有一个写入者,一面把两个写入者的记录排成了一条看不出来源的序列。脚本最后的自检也没有发现问题:它把目标边和图上的边作为集合比较,重复的边在集合里折叠成一个,自检报告 68 条目标边全部确认存在。

图 4:同一段 20 次提交,账本记录里写入者标识相同、间隔相同,A 与 B 交替只能从时间序列推断出来。

事后我们还发现,提交关口里其实有一套乐观并发保护(optimistic concurrency control):写入方在提交里声明读取状态时账本走到了第几条(读取基线,作用类似版本号),以及要读写哪些对象;关口在写入前检查,自那个位置以来,这些对象有没有被别人改过。这条写边的路径没有声明读取基线,填的是 0,关口遇到 0 就跳过这项检查。这一点我们核对了账本里的提交申请记录和代码。下面两句是读代码得出的推断,没有做实验验证:即使填了基线,关口比较的是声明的对象编号,两个进程写的是两条编号不同的新边,编号不会重叠,检查也会通过。真正重复的是边的两端和类型,这些内容不在关口的比较范围里。系统里有一道为并发写入准备的门,这条路径从它旁边走了过去;按这个推断,就算走进去,它比较的也不是会重复的那个东西。

我们试过的

这次事故留下两个缺口。缺口 1:把还在运行的后台进程判成已结束。缺口 2:两个写入者在提交临界区之外各自判断同一条边不存在。下面按每件事开始的日期排列。每一件在当时都是合理的下一步,每一件也只作用在一个环节上:判活,或临界区之外的去重,或事后纠正。

7 月 29 日,写成规则。 当天,agent 把这次事故写进了协调 agent 接班时使用的错误记录文件,提出两条做法:判断一个具体进程的死活,用进程号(ps -p <pid>),不要按名字匹配;看它的产出是否还在增长,至少采样两次。这是一条文档规则,没有机制强制执行。

8 月 15 日,又一次并发写入同一条边。 这次不是误判进程死活:一个后台批量写入正在运行时,协调 agent 手动执行了一条真实写入命令,用来测一次提交要多久,结果同一条边被写了两次,相隔 60.7 秒。这次我们追加了一条“撤销边”事件,让概念图停用后写的那一条。账本只允许追加,但可以用一条新事件让概念图不再使用某条重复记录(事件溯源里叫补偿事件,compensating event)。7 月 29 日那 10 对重复边没有做这个处理,今天在概念图里仍然是各两条有效边。

8 月 16 日和 9 月 2 日,批量写入口。 新的批量命令把一批边合并成一次提交,并在写入前按“两端加类型”去重。从机制上看,提交次数变少,并发叠加的机会也随之变少,我们没有测量。去重仍然发生在提交临界区之外。

8 月 19 日,又一次(我们数到的第三次)。 另一个会话用 nohup python3 <脚本> & 启动一个写账本的脚本,同一行命令末尾的 tail 读不到文件,整条命令返回失败。据这个会话自己的交接文档,它因此认为脚本没有启动成功,又启动了一次。账本显示,两个写入方各自创建了同一个概念,又各自给它加了两条相同的边。这个会话事后在交接文档里写的正确判据是 pgrep -f,与 7 月 29 日那条规则(用进程号,不要按名字匹配)相反。两份认真的事后总结,给出了两条相反的规则。我们的仓库里,对“怎样判断后台进程是否还在”,至今至少有三种互相不一致的写法:按进程号查;用 pgrep -f 按命令行匹配;看脚本自己写的输出文件末行是否出现完成标记。

8 月 19 日到 21 日,在提交关口里加唯一性检查。 针对“同一个概念被创建两次”,我们在提交临界区内部加了一道检查:已存在的概念编号不能再次创建。检查直接比较提交内容里的概念编号和已提交的记录,不依赖写入方的声明,并附带两个真实操作系统进程争抢同一把锁的测试。到目前为止,这道检查至少拒绝过 207 次(统计口径见依据说明)。这 207 次里有多少是真实并发、多少是脚本顺序重跑,我们没有逐条区分。8 月 21 日之后,按概念编号分组,我们没有查到新的重复创建(有意的替换式创建不算在内)。这道检查只覆盖“创建概念”,不覆盖“添加边”。它是这些做法里唯一一个在提交临界区内部、按业务键做判断的。

8 月 20 日,缩短临界区。 修复说明里写了慢在哪里:临界区里有一步要把约 61 万条事件全部加载一遍,修复时实测这一步耗时 22.4 秒。我们没有在 7 月 29 日当天单独测量这一步。修复后,一次提交在临界区的时间降到 2.6 秒。并发写入不可见的时间窗口缩小了,并没有消失。

9 月 30 日,进程登记工具。 我们写了一个启动后台脚本的辅助工具。它最初是为了避免误杀进程(只停止自己登记过的进程)而写的,同时它有一个对本文有用的行为:启动时给脚本一个标签,如果同标签的进程已登记且还活着,就拒绝再启动;检查状态时同时核对进程号和进程启动时间,防止进程号被复用后误判。它有 15 个测试,今天通过。它是可选的,脚本不通过它启动,就没有单实例保护。

图 5:没有一件事在提交临界区内按边的业务键去重,缺口 2 至今没有机制强制的保护。

现在的状态

截至 2026 年 10 月 6 日:

  • 7 月 29 日的 10 对重复事件还在账本里,概念图里这 10 组关系各有两条有效边,没有做停用处理。
  • 提交关口对“添加边”没有“同样两端和类型已有有效边就拒绝”的检查。根据代码,如果今天再有两个进程并发写同一条边,关口不会拒绝第二条。我们没有实际演示这一点,也没有按关键字检索到覆盖这种情形的测试(怎么找的,见依据说明)。
  • 8 月 22 日之后,账本里新增的 3380 条边事件中没有出现重复。这只说明我们按“两端加类型”这个维度没有查到,不能证明没有其他形态的重复。
  • 判断后台进程是否存活的规则在仓库里不一致,进程登记工具不是强制要求。
  • 09:54 那次 grep 为什么没有匹配到,原因仍然未知。

如果是你的系统

下面是我们会在饭桌上跟同行说的几句话。每一句都能在你自己的机器上动手试;其中有几条我们自己也还没做到,在对应条目里写明。命令都在 Linux(procps-ng 4.0.4、Python 3.12.3、jq 1.7)上实际跑过。

判断一个具体进程,用进程号加启动时间,并且留住原始输出。 按名字匹配的结果取决于命令行文本和 ps 的输出,任何一处变化都可能漏掉一个活着的进程;我们那次没有留下原始输出,所以不知道具体是哪一种。启动时记下进程号和启动时间,检查时两者一起核对:

python3 -u build_edges.py > build.log 2>&1 &
pid=$!
start=$(ps -o lstart= -p "$pid")
# 之后每次检查:进程号和启动时间都对得上,才算还在
if [ "$(ps -o lstart= -p "$pid" 2>/dev/null)" = "$start" ]; then
  echo "进程 $pid 还在"
else
  echo "进程 $pid 不在(或进程号已被复用)"
fi

这个检查有一个前提:在同一个 shell 里启动并检查时,bash 会回收已退出的子进程;如果启动它的程序不回收子进程,已退出的子进程会以僵尸状态(ps 的 STAT 列为 Z)保留同一个启动时间,被当成还在。要排除这种情况,把 -o lstart= 换成 -o lstart=,stat=,看到 Z 就算不在。不要把检查命令的原始输出丢进 /dev/null,出现意外判断时,它是唯一的证据。进程号加启动时间的核对,我们的进程登记工具做了,但它是可选的;留住原始输出这一点,我们那次没有做到。

判断它是不是还在工作,看产出有没有增长,而且真的比较前后两个读数。 一个读数判断不了“在动还是停了”。我们手里有三个递增的读数,却没有把它们放在一起看趋势。一个可行的做法是把上一次的读数存下来,让下一次检查自动比较:prev=$(cat last_count); now=$(<统计命令>); echo "$now" > last_count; [ "$now" -gt "$prev" ] && echo "仍在增长"。把 <统计命令> 换成你的计数方式,例如 wc -l < edges.jsonl。这是我们的建议,还没有在自己的系统里落地。

日志为空只说明没有信息。 标准输出重定向到文件时通常是块缓冲,活着的进程可能长时间不留下任何内容。两行命令就能看到差别:

python3    -c 'import time; print("started"); time.sleep(60)' > a.log &   # 运行中 wc -l 得 0
python3 -u -c 'import time; print("started"); time.sleep(60)' > b.log &   # 运行中 wc -l 得 1

看完用 kill %1 %2 收掉这两个进程。需要用日志判断进度时,启动时加 -u,或在打印时强制刷新。我们没有核对自己的脚本是否都这样做了。

“先读状态、再写缺少的”只防顺序重跑。 两个进程同时运行时它会失效,因为读和写之间没有互斥。需要防并发,要么保证同一时刻只有一个实例,要么在写入的临界区内部按业务键去重。第二种做法我们在“创建概念”上已经采用,并有两个真实进程并发的测试;对“添加边”,我们还没有验证。

看看你的锁会不会掩盖并发,以及记录里有没有实例标识。 全局锁让一次只有一个写入者进入提交,多个写入者的记录也就按提交顺序一条接一条排在一起。8 月 19 日那次,两个进程各自生成了会话编号,账本里可以分辨;7 月 29 日用的写入命令把事件记在同一个已登记的会话编号下,两个进程的记录字段完全相同,分辨不出来。建议在每条记录的来源字段里加上进程号或实例标识;我们自己的账本今天还没有这个字段。

自检同时数条数和去重后的键数。 集合比较会把重复折叠掉。同时数“事件条数”和“去重后的键数”,两者不相等就说明有重复。如果你的边记录是 JSONL,键是“两端加类型”,一行就能列出重复的键:

jq -r '[.src, .dst, .type] | @tsv' edges.jsonl | sort | uniq -d

发现了重复,如果你的系统也是从只追加的日志重放出当前状态,事件删不掉,但可以追加一条撤销事件,让重放出的状态停用重复的那一条,并在事件里写明原因。这一条我们在 8 月 15 日那次做了,7 月 29 日的 10 对没有做。

还没想明白的

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

第一件关于判活。我们现在有一个带标签的进程登记工具,但它是可选的;agent 在 shell 里只要写一句 nohup python3 <脚本> &,就可以不经过它。仓库里已经有两条互相相反的文档规则(7 月 29 日、8 月 19 日),没有机制强制其中任何一条。如果你的系统里 agent 可以自由启动后台进程,你是怎么让“同一个脚本不会被启动两份”成为强制的:拦在 shell 层、拦在脚本入口,还是拦在写入端?

第二件关于去重键放在哪一层。“创建概念”的唯一性检查放进了提交临界区;测试里它能拦住两个真实进程的并发创建。按我们的理解,每多一类业务键,就要在临界区里多做一次比对,这些比对各花多少时间,我们没有测量。对“添加边”,这道检查今天还没有。如果你维护的也是只追加的事件日志,你把业务键唯一性放在写入时还是重放时,代价怎么算?

还有一个小一点的问题:如果你见过 ps | grep 漏掉一个确实在运行的进程,并且查清了原因,我们想知道那是什么。我们那次的原因至今空着。


依据说明:本文依据 Flowness 事件账本的全量扫描、做判断的那个协调 agent 的会话记录(逐行读取了事发前后约 300 行)、编写脚本的子 agent 的会话记录、git 提交历史和当前代码;8 月 19 日那次的经过来自当事会话自己的交接文档和账本,没有读它的会话记录。进程 A 和进程 B 的区分来自账本时间序列,因为事件里没有进程号。“今天并发写同一条边仍会重复”是读代码得出的推断,没有实际执行。“没有找到覆盖这种情形的测试”的查法是:在测试目录里按“多进程相关用法”加“添加边”两组关键字检索,也没有找到覆盖这种情形的文件。“至少 207 次”的口径:按“拒绝原因为概念编号已存在”计,账本里有 207 条拒绝记录;另一个内部统计口径显示 415,两者的差异我们没有核对。事件日期和时刻为 UTC;状态核查日为 2026 年 10 月 6 日(服务器本地日期)。


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