AI Agent 说「已完成」,凭什么信——ChipCode 的证据链与产物门禁设计
摘要:大模型写代码的能力早已不是瓶颈,瓶颈是「它说完成了,你凭什么信」。这篇讲我们在做 ChipCode 时反复撞上的三类「已完成」,模型自述不可信的机制性原因,以及最后落到产品里的三个设计:append-only 的证据账本、由磁盘文件算出的阶段状态、把验收压到进程层的门禁。文末附可直接跑的校验脚本,以及这套设计在哪些场景下不该用。
一、三种「已完成」
做 AI 辅助开发这两年,我们见过三种「已完成」。
第一种,Agent 在对话里回:「已修复,测试通过。」
你去跑,编译报错。
第二种,它确实执行了命令,但执行的是 echo "ok",或者一个早就通过的旧测试。工具调用记录里躺着一条成功的返回,结论是真的,但它没证明任何事。
第三种,它确实编译了、确实测了、功能也确实对。但两周后你想确认「这个模块到底验过什么」,发现证据只存在于那段已经翻不到底的会话里。
第一种是幻觉,第二种是自欺,第三种是工程债。三种指向同一个根因:我们把「状态」的判定权,交给了一个只会输出文字的东西。
这三种情况不是假设。ChipCode 早期版本里,AI 改完代码会在对话里说「已验证」,我们真的照着它说的去跑,失败的次数多到不得不记下来。后来统计了一下,问题不集中在「代码写得不对」——大部分时候代码是对的,出问题的是我们对「已经验证」这件事的理解。
二、模型的自述为什么不能当证据
不是「模型爱撒谎」这么简单。机制上至少有三个来源。
一是乐观偏差。 训练语料里,「我完成了」后面跟着的往往是成功案例。模型学到的是「完成」这个语域的形态和句式,而不是「完成」的实际条件。它很擅长生成一段看起来像验收报告的文字,因为那段文字在语料里大量存在。
二是抽样偏差。 它跑了 10% 的用例,写出覆盖 100% 的结论。你问「测了吗」,它答「测了」——两个「测」不是同一个意思。这不完全是编造,是它把「我执行了验证动作」和「我验证了全部条件」当成了同一件事。
三是状态遗忘。 上下文压缩、会话切换、跨轮工具输出裁剪,都会让「刚才验证过」这个事实从窗口里消失。但结论留在对话里,看起来仍然成立。于是同一个 Agent 在第三轮会自信地引用一个它已经记不清的验证结果。
这三条不是靠 prompt 能解决的。「请务必真实验证」「不要谎报通过」这类指令,能改变措辞,改变不了判定依据。要动的是判定依据本身。
三、可信度的三个层次
把「证据」按来源分三层,差别立刻清楚:
| 层次 | 证据形态 | 谁能让它成立 | 可伪造性 |
|---|---|---|---|
| 语言层 | 「我已经测试通过了」 | 模型自己 | 完全可伪造 |
| 工具层 | 工具返回 status: success |
工具的实现方 | 可伪造,因为返回码是自己定的 |
| 进程层 | 编译器 exit code、标准输出、串口日志、Flash 占用 | 一个不关心你结论的外部程序 | 除非它真的通过了,否则伪造不了 |
关键差别在最后一列。语言层的结论由被验证者给出;进程层的结论由不关心结论的程序给出。
所以设计原则只有一句:能落到进程层的,绝不停留在语言层。
这句话听起来像废话,但大部分 AI 编程工具停在了前两层。它们把工具调用成功当成验证完成——而 bash 返回 0 只说明命令跑完了,不说明命令跑对了。
ChipCode 的验证设计都是围绕「把结论压到第三层」展开的。下面三个部分分别对应:证据怎么记、状态怎么算、人在哪里介入。
四、证据账本:从「我做了」到「记录了什么」
光有进程层输出还不够。输出会滚掉,会话会丢。得让它落盘,并且能被机器读。
ChipCode 用的是 append-only 的 JSONL,项目根目录下的 evidence.jsonl,一行一条,永不修改:
{
"id":"ev-0007","at":"2026-11-02T13:20:00Z","claim":"水泵启停 50 次无卡死","result":"pass","covers":["c1","c3"],"by":"ai"}
四个字段是设计核心。
result 三态,不是布尔。pass / fail / inconclusive。绝大多数系统只记成功失败,结果就是「没测」和「测失败」混成同一个值。硬件联调里这两者差别极大:示波器没接上,和测出纹波超标,是完全不同的两件事,后续动作也完全不同。三态让「未验证」有个地方待着,而不是被迫挤进失败。
covers 显式映射到验收条件。
不是「我测了」,而是「我测的这件事,覆盖了 c1、c3,没覆盖 c2」。条件来自需求阶段的 spec.json。没有这层映射,证据是孤立的,你只知道「有东西被验证过」;有了这层映射,你才能回答「还差什么」。
does_not_cover 写清楚没证明什么。
这条最反直觉,也最有用。一份只写「证明了什么」的报告,读起来永远像是完成了;把缺口写出来,它立刻显形。ChipCode 把「未证明」单独列成看板上的一栏,而不是藏在报告的脚注里。
撤销靠追加,不靠删除。
{
"id":"ev-0008","invalidates":"ev-0007","reason":"换了传感器"}
改主意是正常的。但删除会让「曾经认为通过」这段历史消失,下一次有人问「为什么换传感器」,账本里没有答案。追加一条失效记录,既撤销了结论,也保留了因果。
这条规则有个容易忽略的连锁反应:撤销一条证据,它当初覆盖的所有条件会全部回退成未证明。 换传感器之后,原来那条「启停 50 次」的证据失效,c1 和 c3 一起退回。这不是在惩罚改主意,而是让「改主意」本身也留下代价——否则会出现「结论还在、依据已经没了」的账本。
为什么是 JSONL 而不是数据库
git diff能逐行看出哪条证据是新加的,评审时不需要额外工具- 并发追加不冲突,多个 Agent 各写各的,不会互相覆盖
- 一行一个
json.loads()就能消费,CI 脚本不用装依赖 - 写入失败只影响最后一行,不会像 JSON 数组那样整体损坏
代价也要说清楚:没有索引、没有事务、没有并发控制。几千条以内够用,上万条就该换方案了。
一个能直接跑的校验脚本
账本写出来是给人看的,但真正拦住问题的是机器读它。下面这个脚本检查「验收条件是否被 pass 证据完整覆盖」,可以直接挂进 CI 或 pre-commit:
#!/usr/bin/env python3
"""校验证据账本是否覆盖了全部验收条件。
用法:python check_evidence.py evidence.jsonl c1 c2 c3
退出码:0 = 全覆盖,1 = 存在未证明的条件,2 = 参数错误
"""
import json
import sys
from pathlib import Path
if len(sys.argv) < 3:
print(__doc__)
sys.exit(2)
path, required = Path(sys.argv[1]), set(sys.argv[2:])
records, invalidated = [], set()
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
try:
ev = json.loads(line)
except json.JSONDecodeError as e:
print(f"[账本损坏] 第 {lineno} 行不是合法 JSON:{e}")
sys.exit(1)
if "invalidates" in ev:
invalidated.add(ev["invalidates"])
else:
records.append(ev)
covered = set()
for ev in records:
if ev["id"] in invalidated:
continue
if ev.get("result") == "pass": # fail / inconclusive 都不算覆盖
covered.update(ev.get("covers", []))
missing = sorted(required - covered)
print(f"验收条件 {len(required)} 条,已覆盖 {len(required) - len(missing)} 条,"
f"未证明 {len(missing)} 条")
if missing:
print("未证明:" + ", ".join(missing))
sys.exit(1)
注意脚本里 invalidates 的处理顺序:先收集失效集合,再遍历有效记录。这个顺序不能反,否则被撤销的证据仍然会贡献覆盖。这类细节是账本方案唯一容易写错的地方。
五、产物门禁:状态由文件算出,不由 Agent 宣布
有账本,还得有个东西去读它,并且拒绝让状态被口头推进。
ChipCode 里的「产品工厂」把做一款硬件拆成五步:创意、造型、硬件、软件、联调。每一步的产出写进项目里固定的文件:
| 阶段 | 落盘产物 |
|---|---|
| 创意 | spec.json(条件与确认状态)、spec.md |
| 造型 | design/shape.json(dims / model / mesh / views[]) |
| 硬件 | bom.json、constraints.json |
| 软件 | firmware/ |
| 联调 | evidence.jsonl |
关键在于:阶段状态不是存储的字段,而是根据这些文件算出来的纯函数。 同样的文件树,算出来永远是同一个状态。
实际生效的判定规则大概是这样:
| 磁盘上的情况 | 看板显示 |
|---|---|
| 验收条件未确认 | 停在「进行中」 |
shape.json 里写到的模型 / 网格 / 视图文件不存在 |
「阻塞」 |
| 有 2D 视图但没有 3D 网格(或反过来) | 停在「进行中」 |
bom.json 里有一颗器件没有来源链接 |
「阻塞」 |
有效证据里存在 fail |
「阻塞」 |
这套写法有三个直接后果:
Agent 无法通过「说」改变状态。 它只能写文件,而文件是能被检查的。想推进阶段,就得真的把产物写出来,把证据录进去。
状态可复现。 换台机器、重开项目、交给别人,算出来的结论一样。不依赖会话、不依赖缓存。
评审有了共同标的。 讨论「这个模块做完了吗」时,双方看的是同一棵树,而不是各自记忆里的对话。
「阻塞」不等于「出错」
这是我们在实现里最坚持的一点:门禁要把「阻塞」和「出错」区分开。
- 出错 = 有东西坏了,要去修
- 阻塞 = 有东西没落地,要去补
两者的心智完全不同,后续动作也不同。把它们混成同一个红色感叹号,人会很快对这个颜色脱敏,然后开始习惯性忽略——门禁就失效了。
所以 ChipCode 的「阻塞」必须写清楚缺什么:缺 3D 网格、缺器件来源链接、有一条证据 fail 且没被撤销。而不是一个笼统的警告图标。
其中一个细节值得单说:造型阶段要求三件套齐全——3D 源码(.scad / .step)、3D 网格(.stl / .obj / .3mf)、2D 视图。只写 .scad 不导网格,看板只能画线框,造型会一直停在「进行中」。
这个规则被人问过「是不是太严」。我们的判断是:结构图应该是能转着看的,不该靠用户在对话里提醒 AI「你还没导出网格」。门禁的价值就在于它不接受解释。
六、让人只出现在该出现的地方
门禁跑起来之后有个副作用:人被迫参与的次数变多了。每个阻塞点都在等人。所以必须收敛决策点。
ChipCode 把人的介入压到三类,其余全部自动:
- 确认验收条件——做什么算完成
- 确认关键选型与结构——用什么做
- 给物理结果——示波器、实机现象、手感
其余环节 Agent 自己推进。这样人不是「每一步都审批」,而是「在最贵的地方拍板」。第 1 条和第 3 条尤其不该外包:前者决定了后面所有的验证有没有意义,后者是唯一无法由进程给出的证据。
具体到交互上,AI 只提案,不替人确认。验收条件、结构、选型写进文件后都是未确认状态,确认按钮在人手上。这条约束看起来很笨——多一步操作,少一点流畅——但它把「谁批准了这件事」变成了可追溯的记录,而不是一句「我问过他了」。
配套的一条原则:失败必须可见。
工具调用失败,界面上要标红并写明原因——编译失败、插件报错、操作被拒绝,这些是不同的失败,不能折叠成一句「遇到一些问题」。Agent 太容易把失败叙述成「部分完成」,而「部分完成」是最危险的中间态:它听起来像成功。
ChipCode 里 AI 改完代码会跑一次只读的独立验证,结论是 PASS / FAIL / PARTIAL 三档;子代理与独立验证各用独立卡片,能看出是谁下的结论。命令行版则把每步事件写成一行 JSON,下游只认 failed 字段,不解析自然语言:
{
"seq":3,"type":"tool.step","name":"compile","status":"completed","failed":true,"stderr":"main.c:12: error: ..."}
还有一条容易被忽略的:AI 说「通过」和流水线认为「通过」,必须指向同一个事实。 所以退出码要语义化,把「真失败」和「没人确认」分开——失败是 1,用法错误是 2,非交互环境下需要人工确认是 4。混成一个非零码,CI 就没法自动判断该重试还是该叫人。
七、这套设计的代价
写到这里该说不好的部分。这套设计有成本,而且不低:
多写一批文件。 每个阶段都有产物,项目里会多出 spec、bom、evidence 这些「不是代码的代码」。对一个人的小玩具项目,这是纯负担——为了管住 200 行代码,先写 4 个 JSON,不划算。
门禁会拦人。 有东西没落地就走不下去,这在探索阶段特别烦。探索本来就不该有验收条件,硬套只会让人学会绕过门禁。
JSONL 不是数据库。 前面说过,没有索引和事务。要查询、要并发、要权限,就该换正经存储,而不是给 JSONL 打补丁。
它只适合有明确交付物的任务。 「做个自动浇花器」能这么管,「帮我看看这段代码为什么慢」不能——后者没有可验收的条件,硬凑出来的「验收条件」只是形式主义。
覆盖面有边界。 产品工厂只管到固件与结构,不做 PCB 设计,也不管打样和采购。越界的部分我们没做,也不打算假装做了。
所以我们的判断是:任务越接近交付,这套越值;越接近探索,越不该用。 中间地带靠人判断,没有通用答案。这也是为什么 ChipCode 里保留了普通代码项目类型——不是所有活儿都值得走五步。
八、三条能搬走的原则
不管用不用 ChipCode,这三条可以搬到任何 AI 编程流程里:
一、状态外置。
状态写在文件里,界面是文件的函数。Agent 改不了状态,只能改文件——而文件可被审查、可被 diff、可被回滚。
二、验证下沉。
能拿到 exit code 就别看自然语言。能跑就别「说」,能编译就别「看起来没问题」。可信度是有层级的,往下沉一层,可信度就多一分。
三、失败留痕。
三态比布尔多一个「不知道」;append-only 比可编辑多一段历史;写「没证明什么」比写「证明了什么」更能防止自欺。
AI 写代码这件事,早就不是「写得对不对」的问题了。真正难的是:当它说「好了」的时候,你的工程里有没有一个东西,能替你回答「好在哪」。
如果没有,那它说的每一个「已完成」,你都只能选择相信。
作者为 ChipCode 开发团队成员。文中代码示例为通用示意,可直接移植到任意语言栈。