Flowness 真实问题 · 第 1 期 · 阅读系列导读 →
熟悉的朋友可以直接跳过这一段。
Flowness 是我们搭的多 agent 工程系统,由一个人和一群 AI agent 一起运行:人定方向、做取舍,agent 写代码、派活、审查。它从 2026 年 3 月下旬跑到现在,6 个多月;只算还留着会话记录的部分(大多是 7 月以后的),它就消耗了超过 1400 亿 Claude token,其中九成以上是缓存读取。这个系列把运行中真实遇到的问题一个个摊开写。真实环境里 agent 碰到的问题,大多非常琐碎:一次 grep 没匹配到,一道检查没有对象也记“通过”,一个用量字段被读错了含义。结果能不能信,往往就卡在这些地方。我们把它们写出来,是想和在做同样事情的人交流。
假设你给写入流程加了一道检查。上线一个月,它跑了几千次,记录里全是“通过”,一次也没拦过。你大概会把这读成两件事:检查在工作,输入一直是干净的。周报里写一句上线以来零拦截,没人会追问。这个读法有一个前提:检查每次都真的判断了。记录不会告诉你这个前提成不成立。两件事里的第一件,我们读错过一次,后面会讲。
2026 年 7 月 29 日(本文时刻均为 UTC),我们给 Flowness 提交流程里的一道检查做验证。在此之前,这道检查在账本里留下的 485 条记录全部是“不适用”。先送入一个应当通过的输入,记录显示“通过”;再送入一个应当被拦的输入,才第一次看到它判出“本来会拒”(判为不合规)。
随后的普查发现,当时出现过的 38 种检查里,24 种在账本里只有“通过”记录,只有 2 种同时出现过“通过”和“本来会拒”。一道检查在没有对象可判时也记“通过”,这样的通过记录和真正判断后的通过记录在结果字段上完全相同,单看通过记录,无法知道它有没有在判断。今天,在一类比对改动范围的检查里,98% 的“通过”记录同时带着“已跳过”的附注,也就是说检查其实没有比对。
背景:一次提交怎样留下检查记录
Flowness(我们搭的多 agent 工程系统)里,agent 每次要改变共享状态(新建一个概念、登记一个问题、关闭一项待办),都要把改动打包成一次“提交”,交给提交关口(commit gate)。提交关口是一个程序,它按顺序运行一串检查函数,再由一段登记代码把每道检查的结果写进记录。全部通过,这批改动连同一条“接受”记录写入事件账本(append-only log:只追加,写入后不能修改或删除);有一项拒绝,这批改动不写入,账本里只留下拒绝的记录。
本文说的“一道检查”,就是这串检查函数中的一个,它有一个固定名称。每次提交被接受时,“接受”记录里会列出这次跑过的每道检查和它的结果,结果只有三种取值:
- 通过:检查认为这次提交合规。
- 不适用:检查认为这次提交里没有它要判断的对象。
- 本来会拒:检查处在观察模式(类似 shadow mode:只记录、不阻断),判为不合规,但不拦下提交。
三种取值里有“不适用”,记录格式本来就给“没有判断”留了位置。后文三条路径的共同点,是都没有用它。
真正拦下提交的拒绝,记在另一条“拒绝”记录里,格式和命名都与“接受”记录不同:它写的是失败原因的名称(比如“概念编号已存在”),不是检查的名称。后文统计拒绝的时候会回到这一点。文中带引号的结果名和失败原因名,是我们对内部取值的中文译名。
经过
那天,我们在验证一组阶段衔接检查。它们看的是一项工作从设计阶段进入工程阶段、再进入团队共识时,交接的对象是否合格。其中一道检查针对工程设计对象(例如一个组件、一项工程决策):如果对象被标记为“共识候选”,也就是提议写进团队共同遵守的约定,它的状态必须已经是“已接受”(而不是“草稿”“已审阅”等更早的状态),并且附有书面理由。
账本里这道检查此前共有 485 条记录,每一条的结果都是“不适用”,它从来没有真正判断过一次。其中 125 次提交确实创建了工程设计对象,但检查当时读不到这些对象:它读取的字段位置与系统实际写入的位置不一致。这个问题前一天已经登记为待办,后来修复。其余是没有工程设计对象的提交。另外,我们用来创建这类对象的标准命令,当时没有任何选项能设置“共识候选”这个标记。
02:29,我们登记了一项待办,把这件事的验收条件写清楚。条件的第 3 条要求同时做反向对照:把状态改成“已审阅”,同一条流程必须产出违反条款的记录。条件里还写明,只测正向不算过,因为只测正向时,检查在没有对象可判时放行,和检查判断后通过,看起来一样。这一条写在任何探针之前。
02:35,我们建了一个正向探针:一个状态为“已接受”、标记为共识候选、附有理由的工程对象。检查记录为“通过”,这是它第一次给出“不适用”以外的结果。
02:38,当时的工作记录里写的是“只测正向不算过(这是我自己写的判据)——做反向对照”。
02:39,我们建了反向探针:除了状态改成“已审阅”,其余完全相同。检查记录为“本来会拒”,并在附注里写出了违反的条款和实际值。由于这道检查处在观察模式,这次提交本身仍然被接受了。
正向和反向两次结果合在一起,才说明这道检查确实在读对象、在判断。只有正向那一次,同样的“通过”也可能来自一个读不到任何对象、直接放行的检查。
02:58 到 03:02,我们写了一个普查脚本,统计账本里每种检查出现过哪些结果。结果是:38 种检查里,12 种永远是“不适用”,24 种只出现过“通过”(和“不适用”),2 种同时出现过“通过”和“本来会拒”。这 2 种里,一种就是刚才做了反向探针的那道检查,它唯一的一条“本来会拒”正是来自那次探针。把普查截到反向探针那次提交之前重算,这个 2 会变成 1。不算我们亲手造的那一条,普查里还有一种检查有过“本来会拒”记录,只有一条,我们没有核对它的来源。
为什么“通过”不能当证据
一条“通过”记录最自然的读法是:检查运行了,读到了对象,判断了,合规。这个读法在记录格式上说得通,因为三种取值里有“不适用”,没有对象可判时检查应当写它。追查下来,有三条具体的代码路径让检查在没有判断时也写出“通过”,三条在我们的生产账本里都有记录。每条先写它在账本里看起来是什么,再写代码实际做了什么。
第一条:对象不触发检查,也记“通过”。 看起来是这样的:这道检查在读得到对象以后,留下了一长串“通过”,像是逐个放行了合规的对象。实际发生的:代码里,如果对象没有被标记为共识候选,就返回一个空的问题清单;登记代码把空清单一律记成“通过”。所以在这道检查读得到对象的时期,即使检查本身完全正确,每次提交里创建普通对象也会留下一条“通过”。账本里的数对得上这个读法:
| 账本里的记录(截至 10 月 6 日) | 数量 |
|---|---|
| 创建工程设计对象的记录 | 551 次 |
| 其中带共识候选标记的 | 20 次,对应 15 个不同的对象,其余是同一对象的重复创建;2 次是 7 月 29 日的探针,其余 18 次从 8 月 19 日起陆续出现 |
| 这 20 次里,状态不是“已接受”的 | 16 次 |
| 这 20 次里,状态是“已接受”的 | 4 次 |
| 这道检查到今天一共记了 | 332 条“通过”,16 条“本来会拒”(“不适用”另计,不在此行) |
| 按提交复算:只创建了普通对象的提交 | 475 次,其中 328 次这道检查记“通过”,147 次记“不适用”(125 次在探针之前、检查还读不到对象时,22 次在 9 月 18 日检查停用之后) |
16 条“本来会拒”正好对上 16 次状态不是“已接受”的候选创建。所以我们推断,332 条“通过”里最多 4 条来自真正判断过的候选对象,其余 328 条都是“对象不是候选,没有判断”。按提交复算得到的 328 次,和这个数一致。
第二条:缺少基线时返回“通过,且已跳过”,跳过的标记被丢掉。 看起来是这样的:比对改动范围的检查几乎每次提交都有一条“通过”,像是每次都比对过。实际发生的:另一组检查负责两件事,一是这次提交有没有越出上下文包划定的范围(上下文包是系统发给 agent 的一份工作材料,里面划定了它可以改动的范围),二是有没有和它读取时的账本基线冲突。当它找不到上下文包或基线时,函数返回“通过,且已跳过”。登记代码只把“跳过”写进一条附注文字,结果字段里记的仍是“通过”。这组检查共四道(范围、版本一致、写冲突、数据新鲜度)。其中的范围检查,到普查那天,全部通过记录中有 95.1% 同时带着“已跳过”附注;今天这个比例是 98.1%(59,287 条里有 58,139 条)。正向探针和反向探针那两次提交的记录里,都同时有这四道检查的“通过”和“已跳过”附注。也就是说,我们用来验证一道检查的那两次提交,本身各带着四条没有判断的“通过”。
第三条:读错字段名,被判对象集合恒为空。 看起来是这样的:另外两道检查的条件形如“每一个对象都满足某个条件”,它们记了 145 条“通过”,像是 145 次确认了全部对象合规。实际发生的:读取对象编号时用的字段名是 id,而生产流程写入的字段名是 concept_id。于是它们每次拿到的对象集合都是空的,而空集合上“每一个都满足”恒为真,记录为“通过”。这样的记录(结果是通过,实际没有判断)一共有 145 条。改对字段名之后,我们把这 145 条旧记录按修正后的读法重放,结果全部变成了“本来会拒”,但这些拒绝同样不对:这两道检查只看本次提交里的对象和关系,而系统的标准流程是先建对象、再建关系,分成两次提交,所以每一批里关系数都是 0。另外,其中一道检查要求“每一个都满足”的那一端也与设计文档相反,这是另一个独立的问题。先修好字段名,之后才第一次看到检查条件本身的错误。
开头说我们读错过一次,就是第三条。 7 月 28 日 07:18,这两道检查中的一道留下了它的第一条“通过”。我们推断是这个信号,曾被我们汇报成这批阶段衔接检查“第一次真实生产求值”。7 月 29 日 03:00,我们用分区间重放推翻了这个说法,并在当天登记的待办里书面订正。07:18 的这条是这道检查的第一条“不适用”以外的记录,它看起来和检查终于开始工作时会留下的那条一模一样。到 7 月 29 日 02:39,这道检查共记了 127 条“通过”,按我们的理解,都是读错字段名的产物。
这三条路径里,两条有现成的名字。 第二条是 fail-open:检查拿不到它判断所需的输入时,选择放行而不是报错。第三条是空集合上的全称判断恒为真(vacuous truth):“每一个对象都满足”在零个对象上自动成立。第一条我们没有找到贴切的通用名字,它是登记代码把“没有问题可报”和“判断后合规”写成了同一个值。两个名字都不新。agent 系统里新的部分有两处。第一,读这些记录、据此写汇报的也是 agent:按我们的推断,7 月 28 日那条“通过”就是被一个 agent 会话读成了“第一次真实生产求值”。第二,提交量大,没有人逐次查看检查是否判断了;我们判断一道检查有没有在工作,主要依据是账本里的记录,而范围检查今天有 59,287 条“通过”。每一条“通过”都如实回答了“检查有没有报出问题”,错在它被拿去回答了另一个问题:“检查有没有判断”。
三条路径的共同点是:检查在没有可判对象时,没有返回“不适用”,而是被登记成了“通过”。其中第二条路径里,检查自己知道“跳过”了,但登记时这个标记被丢掉。
三条路径能同时存在,有一个共同的结构性原因。 检查记录里没有一个字段表示“这次实际检查了几个对象”。有了这个字段,检查了 0 个的通过和检查了 1 个的通过在账本里就是两条不同的记录。没有它,上面两组分布都是我们事后拿创建事件和附注文字对出来的,记录本身不说。这一项登记为待办,尚未处理。
约 15 小时后,同一个问题的另一种形态。 这次出在验收条件上,不在检查函数里。系统会定期生成一份健康度报告,汇总各项工作的运行状况。报告里有一个指标,设计上有四种取值:活跃、休眠、从未活跃、数据不足。当时有一条验收条件写着:这个指标不再是“数据不足”。一次独立的只读检查发现,计算这个指标的函数只会给出“活跃”“休眠”“从未活跃”三种状态,整个函数里没有一处会写出“数据不足”。即使底层数据完全读不到,函数也会静默返回空列表,最终得到“从未活跃”。这条验收条件在代码层面不可能为假,验证它只会得到“成立”。它和三条路径的共同点是:本该表示“算不出来”的结果没有出现在记录里,被另一个结果代替了。
我们试过的
按时间写。这个问题有三个缺口:缺口 1,检查在没有可判对象时记“通过”;缺口 2,每道检查缺少来自真实流程的负向记录;缺口 3,统计拒绝时读不到真实拒绝记录。另有一项是验收条件在代码里不可能为假。下面每一件事,只作用在其中一两个缺口上。
7 月 29 日 02:29,把反向对照写进验收条件。 这一步发生在第一次探针之前 6 分钟。这一次,反向探针是因为条件里已经写明才做的。它是一项待办的验收条件,管的是这一件事,不是所有检查的规则。
7 月 29 日,正反两个探针。 它们证明了那一道检查在候选对象上会判断,没有证明其他检查。探针产生的事件写进了只追加的账本,不能删除;后来统计这道检查的候选对象时,20 次创建里有 2 次是探针。同一天 03:05 到 03:12,我们给另一道阶段衔接检查也做了一对正反探针。到这里,我们亲手造过反向输入的检查是两道。
7 月 29 日 02:58,普查脚本。 它把问题变成了可以计数的数字。但它是一个手动运行的脚本,我们没有找到任何定时任务或钩子调用它。它还有一个设计上的缺陷:只读“接受”记录里的检查结果。真正拦下提交的拒绝记在“拒绝”记录里,而且“拒绝”记录用的是失败原因的名称(比如“概念编号已存在”),不是检查的名称。到普查那天,账本里已经有 376 条真实的拒绝记录,普查一条也看不到,其中就有对应“只见过通过”的检查的:
| 到普查那天,账本里的真实拒绝记录 | 条数 |
|---|---|
| 全部 | 376 条 |
| 其中对应“只见过通过”的检查,例如:概念关联性检查 | 28 条 |
| 同上:写冲突检查 | 5 条 |
| 同上:范围检查 | 4 条 |
这是幸存者偏差(survivorship bias)的一种形态:统计只读了通过关口的提交,而能证明检查拦过东西的记录,全部在没通过的那一边。所以“24 种只见过通过”这个数字,在普查自己的口径下是准确的,“这 24 种从来没有拦过东西”这个推论不成立。今天我们对 37 种非观察模式、只见过“通过”的检查再做一遍对应,至少 13 种有真实拒绝记录。这是我们第一次统计“拦下过什么”时漏掉真实拒绝。
7 月 31 日,给另一道阶段衔接检查加一条账本测试,要求它同时有“通过”和“本来会拒”两种记录。 就是 7 月 29 日 03:05 到 03:12 做过第二对探针的那道检查。这条测试今天仍然通过。它统计的是账本里的累计次数,次数只会增加,所以一旦通过就永远不会失败。这道检查在 9 月 18 日已经停用,测试照样通过。它能证明过去发生过,不能证明现在还在判断。
8 月 4 日,修正那条不可能为假的验收条件。 健康度计算改为先检查每一路输入数据是否可用:没有看到任何活动、而又有一路输入不可用时,给出“数据不足”,不再默认判为“从未活跃”。“数据不足”成为一个可以真正出现的状态,相关测试 56 个今天全部通过。按我们的理解,这是这九件事里唯一一件让“算不出来”成为真正会被写出的值的。
8 月 9 日,专门做一个“这道检查拦住过什么”的证据工具。 工具的作者在模块开头写了“证据可能骗人”,初版却只读了按检查名称汇总的表,漏掉了 99% 的真实拒绝,当天被复查发现并修正。模块开头的说明后来补了一句,大意是:我在这里引用了那个失败模式,然后自己犯了它。修正后它仍然只能把“失败原因名称恰好等于检查名称”的拒绝对应上。今天对“概念编号唯一性”这道检查运行它,输出是“拦下 0 次”,而账本里对应的失败原因“概念编号已存在”有 207 条。这是我们第二次统计“拦下过什么”时漏掉真实拒绝:初版漏在只读了按检查名称汇总的表,修正后仍漏在失败原因和检查名称两套命名对不上。
9 月 18 日,处理早期的阶段衔接检查,并给新检查定上线规则。 新的判定条件在上线前,必须登记一个会被判错的输入和一个正常输入,缺一侧就不能上线;观察模式的检查要升级为强制拦截,也必须先登记两侧对照。9 月 18 日我们处理了 12 道早期的阶段衔接检查:8 道停用,4 道改写算法,0 道升级为强制拦截。规则有了,但我们登记过的观察模式检查,到今天没有一道升级为强制拦截。
9 月 29 日起,把“判别力测试”写成硬性要求。 我们给合并前的测试守卫(代码合并前必须通过的一组测试,每一组针对一类曾经出过的问题)加了一条规则:每一类守卫都必须附带一条测试,人为构造的同类错误必须让它报错,正确写法必须让它保持通过。这条规则管的是测试守卫这一层,不是提交检查。
10 月 3 日,给“已跳过”附注加上原因。 只在附注里区分了跳过的两种原因,放行规则没有变。
现在的状态
截至 2026 年 10 月 6 日:
- 第二条路径仍然存在。检查结果仍然没有“已跳过”这个取值,范围检查 98.1% 的通过记录附带“已跳过”附注。10 月 3 日的改动只在附注里区分了跳过的两种原因(找不到上下文包、上下文包里没有范围字段),放行规则没有变。
- 检查记录里没有一个字段表示“这次实际检查了几个对象”,这是“没判断”和“判断后通过”无法区分的结构性原因。这一项登记为待办,尚未处理。
- 每道检查至少要有一次来自真实生产流程的负向记录,这项待办仍未关闭。
- 我们没有找到任何定时调用普查脚本的地方(查了定时任务、系统定时器、脚本目录、部署目录和钩子)。
- 按普查那天的同一口径重算,今天的普查结果如下表。同时有“通过”和“本来会拒”的 19 种里有 17 种是观察模式的检查,它们的负向记录全部是“本来会拒”,从未真正拦下提交。这些负向记录几乎全是观察模式下的记录,不能读成“已经有人在拦”。
| 按普查那天的同一口径 | 7 月 29 日 | 10 月 6 日 | 增加的来自 |
|---|---|---|---|
| 出现过的检查 | 38 种 | 73 种 | |
| 永远“不适用” | 12 种 | 10 种 | |
| 只见过“通过” | 24 种 | 44 种 | 主要是 20 种新加的检查;另有 2 种从“永远不适用”变成了“只见过通过” |
| 同时有“通过”和“本来会拒” | 2 种 | 19 种 | 9 种是后来新增的检查;其余 8 种是 7 月 29 日就存在、此后才第一次有判断记录或被修复的检查 |
如果是你的系统
下面是我们会在饭桌上跟同行说的几句话。每一句都能在你的机器上动手试;我们自己还没做到的,会标出来。
先看你的检查在没东西可判的时候写什么。 一道检查在没有可判对象、基线缺失、输入读不到时会返回什么?如果返回的是“通过”,或者返回了“跳过”但被登记成“通过”,那么它的通过记录不能用来证明它在工作。可以查:同一条记录里,是否同时出现“通过”和任何“跳过”“缺失”类的附注。下面这段脚本做两件事,一是普查每种检查出现过哪些结果,二是数“通过”里附注带跳过或缺失字样的有多少。它要求输入是每行一条检查结果的 JSONL,开头几个常量(字段名、“通过”和“不适用”的取值、跳过字样)按你的记录改;某个取值在输入里一次也没出现时它会提醒。我们只在两份样例上跑过(6 行和 5 行),没有在自己的账本上跑过:我们的检查结果嵌在接受记录里,“已跳过”附注记在提交一级,要先摊平,再把附注配回对应的检查。我们自己这一项还没做到:结果字段至今没有“已跳过”这个取值,也没有“检查了几个对象”的字段。
# 用法:python3 check_census.py <每行一条检查结果的 JSONL>
# 开头几个常量按你的记录改:三个字段名;"通过"和"不适用"的取值;附注里表示跳过或缺失的字样。
import json, sys, collections
CHECK, RESULT, NOTE = "check", "result", "note"
PASS, NA = "passed", "not_applicable"
SKIP_WORDS = ("skip", "missing", "跳过", "缺失")
seen = collections.defaultdict(set); passes = collections.Counter(); pass_skip = collections.Counter(); values = set()
for line in open(sys.argv[1]):
if not line.strip(): continue
r = json.loads(line); name, res, note = r[CHECK], r[RESULT], (r.get(NOTE) or "")
seen[name].add(res); values.add(res)
if res == PASS:
passes[name] += 1
if any(w in note.lower() for w in SKIP_WORDS): pass_skip[name] += 1
for v in (PASS, NA):
if v not in values: print(f"提醒:取值 {v!r} 在输入里一次也没出现,先核对常量 PASS / NA 是不是你记录里的写法")
only_pass = [n for n, s in seen.items() if PASS in s and s <= {PASS, NA}]
print(f"{len(seen)} 种检查,其中 {len(only_pass)} 种只出现过 {PASS}(和 {NA})")
for n in sorted(seen):
print(f" {n}: 结果集合={sorted(seen[n])} 通过={passes[n]} 通过且附注含跳过/缺失={pass_skip[n]}")
给每道检查找一条它拒绝已知错误输入的记录。 对“通过类”检查,这是它在判断的直接证据。构造一个应当被拒的输入,送进真实流程,看记录。最好在写验收条件时就把反向对照写进去。探针会留在账本里,之后进统计。
统计拒绝之前,先确认拒绝记在哪里、用什么名字。 我们两次统计“这道检查拦下过什么”都漏掉了真实拒绝记录:第一次只读接受记录,第二次只读按检查名称汇总的表。可以查:你的拒绝记录用的是检查名称,还是失败原因?两者之间有没有一张能机械对应的表?
基于累计次数的测试,只能证明过去。 “这道检查至少拒绝过一次”这样的测试,一旦通过就永远通过,检查停用之后也一样。要证明检查现在还在判断,按时间窗口看最近一段的记录。
观察模式的“本来会拒”不是拦截。 它说明检查判出了问题,提交本身仍被接受。在汇报里把它写成“被拒绝”,会让人以为系统有拦截。
检查你的验收条件在代码里能不能为假。 一条写成“某个指标不再是 X”的条件,先确认代码里存在能产生 X 的路径。如果 X 从不会被写出,这条条件永远成立,验证它不会带来任何信息。
还没想明白的
有两件事我们到今天没有答案,想听听你的做法。
第一件关于找不到基线时该怎么办。今天范围检查 98.1% 的通过记录,是在找不到上下文包或范围字段时写下的。让它在这种情形下拒绝,今天带“已跳过”附注的那 58,139 条通过记录对应的提交就都会被拦下;放行,等于这道检查对这些提交不存在;记成“已跳过”,跳过的记录要有人去读,不然它和“通过”一样没有人看。如果你的检查依赖一份经常缺的上下文或基线,你让它走哪条路,代价怎么算?
第二件关于真实的负向记录从哪里来。观察模式的检查从不拦下提交,要升级为强制拦截,要先登记两侧对照(一个会被判错的输入、一个正常输入);而要证明检查在真实流程里判断,又需要真实的负向记录,我们的待办要求每道检查至少有一次;我们 12 道早期检查的处置结果是 8 道停用、4 道改写、0 道升级。手造探针能拿到负向记录,但探针写进只追加的账本就删不掉,会进以后的统计。你是等负向样本自然出现,定期注入探针,还是在回放里构造?探针怎样不混进生产统计?
还有一个小一点的问题:如果你的拒绝记录按失败原因命名、检查按检查名命名,你把两者的对应表维护在哪里?我们的证据工具至今只能对上名称恰好相同的那几种。
依据说明:本文依据 Flowness 事件账本的全量重放(含冷归档,按事件编号去重)、当时的工作记录、git 提交历史和当前代码,日期按 UTC,时刻同。“最多 4 条是真正判断过的候选对象”和“至少 13 种有真实拒绝记录”是推断,前者来自候选创建数与检查结果数的精确吻合,后者来自失败原因与检查函数的对应关系;今天那 37 种检查里,除已核实的 13 种之外的其余检查有没有真实拒绝记录,我们没有核实。那 18 次真实候选创建用的是什么命令,我们没有核对。7 月 28 日那条“通过”与“第一次真实生产求值”汇报的对应,是推断;汇报发出的时刻没有查。“如果是你的系统”里的脚本只在两份样例上运行过,没有在我们的账本上运行过。数字截至 2026 年 10 月 6 日,账本仍在增长。
每篇末尾的问题我们自己还没有答案。你有做法,或者在自己的系统里见过同样的事,欢迎写信到 hi@natureblueee.com。