我做了一个叫 ai-factory 的项目,跑了大概两个月。它本质是一个本地调度器:拿 Gitea 的 issue/PR/label 当任务状态机,拿固定 workspace 当执行隔离,拿 pi CLI 当 worker——scout 发现 backlog、worker 实现分支、reviewer 审 PR、planner 从 roadmap 拆任务,七个 slot 滚动填空,可以 24 小时自己转。这篇不讲它怎么调 AI 写代码,只复盘一件事——我为什么要花大力气把这套东西从「一个能跑的调度器」重构成「一个可审计的控制平面」

目标读者是已经在用 agent 做自动开发、并且开始被「AI 改坏了不知道怎么回滚」「同一个 PR 被提交了两次」「调度器崩了留下一堆孤儿进程」这些问题折磨的人。如果你还在纠结用哪个框架、提示词怎么写,这篇可能太后面了。

一个判断:agent 的输出不能直接等于系统的副作用

这个判断是整个项目最核心的设计立场。绝大多数自动开发系统——包括我自己最早那版 MVP——都是这个回路:agent 输出一段文本,调度器解析文本里的 APPROVED: / CHANGES_REQUESTED: 标记,然后直接去 Gitea 改 label、合 PR、建 follow-up issue。它简单,能跑,但有一个致命问题:agent 的输出和系统的副作用之间没有任何隔离带

一旦 agent 输出了点意料之外的东西——多输出了一个 label、verdict 解析错了、prompt injection 诱导它写了条危险评论——副作用就直接发生了,而且因为没有任何留痕,你事后根本说不清楚「这个 PR 是怎么被合并的」。升级设计文档里我给自己写得很直接:把 agent 输出和系统副作用耦合在一起,是当前系统最危险的耦合。

所以整个重构围绕一个目标:agent 只能产出结构化的 intent,调度器校验之后才执行,执行的全过程留痕、可重放、可审计

intent / audit / idempotency:三层隔离

这三层是 ai_factory/intents.py 的全部职责,也是项目从 MVP 演进到「控制平面」的分水岭。我用一个简化的节点定义来展示它们怎么协作:

# 一个 pr.merge 节点在 dry-run 下产出 intent preview,不碰 Gitea
intent = _intent(node_id, "pr.merge", attempt, str(number),
                 payload={"number": number, "style": merge_style})
if not apply:
    return {"status": "planned", "intent": intent.preview()}
 
# 只有显式 --apply,才把 intent 交给 IntentStore 执行
applied = store.apply_once(intent, lambda: _merge_pr(adapters, number, style))

第一层是 intent:每一个会产生外部副作用的节点(tracker.transition / ai.agent / git.commit / pr.create / pr.merge / continuation.record 等)在被执行前,都先构造一个带 idempotency_keypayload 的结构化意图。dry-run 时它只产出 preview()连 Pi 都不启动,连 git 命令都不跑。这意味着你可以随时 run-workflow(不带 --apply)预览整套流程会做什么,零风险。

第二层是 auditIntentStore 把每一次 intent 的生命周期——plannedinflightapplied / skipped_duplicate / failed / unknown_outcome——全部 append 到 logs/workflow-runtime/<session>/intents.jsonl。这是不可变的审计日志,事后任何一个外部写操作都能回溯到「哪个节点、哪个 attempt、什么 payload、什么时候」。

第三层是 idempotency:这是最让我踏实的一层。apply_once 的逻辑是,在真正调用外部 API 之前,先写一个 inflight 标记到 applied_intents.json;如果调用成功,更新成 applied;如果调用抛异常且无法确定副作用是否发生,写成 unknown_outcome——绝不自动重试

这三层隔离的真正含义

它把「agent 想干的」和「系统真正干的」拆成了两个独立的可审计层。agent 再怎么被 prompt injection 带偏,它能产出的只是 intent;intent 还要过 schema 校验、权限校验、当前状态校验、幂等记录校验,任何一关不过就 fail-closed。隔离优于信任——这是整个项目反复出现的一条偏好。

举一个真实场景:调度器崩溃重启后,旧版本会重新跑一遍 worker,结果同一个分支被 push 了两次、同一个 PR 被评论了两次。现在的版本里,session id 是从 workflow 身份(lock 的 source_hash)+ slot + attempt 确定性派生的,所以同一次 run-workflow --apply 重跑会落到同一个 idempotency store,replay 自动跳过已 apply 的副作用。编辑 workflow 会改变身份、产生新 store,这是有意的。

dry-run 默认门:一种我反复用到的人格化设计

这个项目的几乎每一个有副作用的命令都默认 dry-run:run-workflowlive-smokeauto-devresolve-unknown。要真正写外部系统,必须显式带 --apply。这不是什么高深设计,但它是一种贯穿全局的保守姿态,我用一个三列表格说明为什么这么执着于它:

做法代价为什么我不选
默认 apply,需要时加 --dry-run少打几个字人在疲惫时会把「跑一下看看」当成无害操作,一次误操作的成本远高于一辈子多打 --apply
完全只读,靠另一个命令写双命令割裂日常流程会被打断,operator 容易绕过
dry-run 默认,显式 --apply多 8 个字符这是我选的——把破坏性操作的门槛刻意抬高一点点

live-smoke 是这套姿态的极端体现。它是一个端到端探针,专门针对测试用的 Gitea repo 和 Linear team,dry-run 时连网络都不打。apply 模式会创建 ai-factory-live-e2e-* 命名的资源,跑完整 workflow,读回结果,再写一个清理 manifest。文档里我特意标注「只用于专门的测试 team/repo,别指生产」——这种警告本身就说明,我没有指望默认门挡住所有误用,它只是把「需要明确意图」这件事变成系统的第一反应。

policy 注册表:用纯函数取代 eval/exec/Jinja

v2 节点 runtime 有一个 policy.evaluate 节点,它的实现方式是我整个项目里最克制的一处。我没有搞一套表达式语言让 workflow 去动态求值,而是维护了一个命名的纯 Python 函数注册表

from .node_runtime import POLICY_REGISTRY  # always-allow / always-block / low-risk-auto-merge
 
def _evaluate_policy(node, context):
    policy = node["with"]["policy"]          # 必须是一个已注册的名字
    handler = POLICY_REGISTRY.get(policy)
    if handler is None:
        raise WorkflowRuntimeError("unknown policy ...")
    return handler(node["with"], context["needs"])

关键约束是:plan、dry-run、apply 三个阶段用的是同一个 POLICY_REGISTRY,同一个纯函数。这意味着你在 dry-run 看到的策略判断,和 apply 时真正生效的,是同一段代码、同一份输入。不存在「dry-run 时按一套规则算,apply 时换了另一套」这种漂移。而且整个 runtime 禁用 eval / exec / Jinja——模板渲染只支持 {{ dotted.path }} 这种纯替换,没有循环、没有过滤器、没有函数调用。

表达式方案代价为什么不选
内嵌 Jinja2 / 自研 DSL灵活、表达力强引入 eval 类风险,plan 和 apply 之间多了一层解释器,一致性难保证
让 workflow 直接写 Python最灵活等于把任意代码执行暴露给配置文件,彻底破坏隔离
命名纯函数注册表每加一个策略要写 Python我选的——策略是代码,就要接受代码的审查和测试标准

这套设计的直接收益是:low-risk-auto-merge 这种策略,在 dry-run 里能看到它「会批准合并」,在 apply 里它就用完全相同的逻辑决定是否真合。确定性优于概率性——agent 的判断是概率性的,但策略执行必须是确定性的,这两者必须分开。

review-loop:多轮对抗式 AI 审查

如果说 intent/audit/idempotency 是「执行层」的隔离,那 review-loop 是「开发层」的方法论,也是这个项目我私下最得意的部分。它的基本单元是一个 phase:worker 先实现一版,然后 correctness reviewer 和 security reviewer 并行、只读地审,fix worker 综合反馈改,下一轮再审,直到 closure。review-loop/ 目录里躺着两百多份这样的轮次记录,每个 phase 普遍跑 3 轮收尾。

我拿 phase-0 举例。worker 实现完,输出一份 phase-0-worker.md,里面有改动文件、实现细节、跑过的命令、一个结构化的 acceptance-report。然后 correctness reviewer 接手,它做的事很具体——不是泛泛说「看起来不错」,而是定位到行号的 blocker。在 phase-0 里它发现了一个真问题:Config.validate_phase0() 只挡了 scout.mode / roadmap.enabled / active_plan.enabled 三条 legacy 生产路径,但 reviewer 发现 reviewer 的 follow-up issue 创建路径(create_review_followup_issue)也能造 Gitea issue,这条路径没被挡。这意味着一个 config 能通过 validate,但 run 时仍会偷偷创建 Gitea issue——fail-secure 的承诺没兑现。

security reviewer 平行地审另一组角度:validate 是不是真的只读(grep 确认没有 mkdir / write_text / token 读取)、机器协议 stdout 是否干净、有没有 Electron 回归、managed path 是否安全。它的输出是 phase-0-round-1-validation-security.md,给一个明确的 verdict(merge / blocker)加上一堆可选改进。

fix worker 拿到 correctness 的 blocker 后,不改其他的,只补那一个 fail-safe:把「linear runtime without migration」直接整体 reject run,而不是只挡 producer keys。这一轮的 phase-0-round-1-fix-worker.md 老老实实写了 residual risks——「Phase 0 仍然只是给 slot task 加了 tracker origin 注解,没有真正的 claim 模型」。

worker 实现 → correctness review (并行只读) ─┐
             → security review  (并行只读) ─┤→ fix worker 综合 → 下一轮 review
                                             └→ 直到 round-N closure

这套流程的价值不在「AI 审 AI」的新奇,而在它逼着每一轮的输出都是可验证的。correctness reviewer 不是凭印象,而是跑 python3 -m unittest、构造临时 invalid config 验证 exit code、grep 确认没有副作用泄漏。fix worker 不是重写,而是做最小改动然后重新跑全套测试。对抗不是为了吵赢,是为了把判断落到可复现的证据上

坦白讲,这套 review-loop 的产出质量是参差的。有的 phase 三轮就干净收尾(phase-0 的 round-3 final review 只剩一个 scope 边界的 nit),有的 phase 到 round-3 还在挖新问题(original-phase4 跑到 round-3 还在补 idempotency 细节)。但即便参差,它也比「写完就合」要稳得多——我在 git log 里能看到一连串 AI: Kill Pi process groups on timeoutAI: Paginate PR-cap deadlock scansAI: Validate state task history retention config 这种 bot 提交,每一个都对应一个被 review-loop 逼出来的修复。

项目用自己开发自己

这一段是整个项目最 meta、也最能说明它可用性的部分。ai-factory 的 git log 里,最近的提交几乎清一色是 AI: ... 开头——也就是说,这套自动开发闭环正在开发它自己。issue 是 scout/planner 从自己的代码里发现并提的,PR 是 worker 实现并推的,review 是 reviewer 做的,合并是 scheduler 在 merge_enabled=true 时自动合的。

这件事听起来像自举(bootstrapping),但它的意义不在于「AI 全自动写了一个项目」——我上一篇文章已经说过,我不信全自主开发是高效方向。它的真正意义在于:它是一个吃自己狗粮(eat its own dog food)的压力测试。每一条 intent 层的 bug、每一个 idempotency 漏洞、每一次 dry-run 与 apply 不一致,都会在这个项目自己开发自己的过程中被触发。phase-4 round-1 的 idempotency review、phase-5 的 dedupe-key review,都不是我凭空设计出来的,而是 review-loop 在「用 ai-factory 开发 ai-factory」时被真实问题逼出来的。

我也承认局限。这个项目重度依赖我介入:架构决策是我定的,roadmap 是我维护的,每个 phase 的 scope 是我批的,复杂的 review-loop 流程也是我用 pi CLI 手动触发的。它不是「按下启动键就走人」的系统,它是一个把人的判断放大、把人的重复劳动自动化的系统。如果你期待的是后者,它会让你失望。

对标 Symphony,但选择不 fork

升级设计文档里有一节「外部项目调研」,我克隆并读了 OpenAI Symphony、Microsoft Conductor、gh-aw、OpenHands、Temporal、Restate 这一批仓库。Symphony 是最接近的对标对象——它是一个 reconciliation loop + task claim + workspace lifecycle 的 workflow engine,思路和 ai-factory 的 v2 runtime 高度重合。

但我最终没有 fork Symphony,而是选择自己实现。原因很现实:

方案代价为什么不选
fork Symphony 改造上游同步成本、license 确认它基于 GitHub/Linear 的状态机,和我的 Gitea label 语义、pi runner 模型对不上,改造成本不比自己写低
嵌入 OpenHands 当 runner引入完整服务端栈违背「标准库优先、本地可控」的硬约束
接 Temporal/Restate 当 durable engine部署重、Restate 有 BSL 风险短期不需要多机、强 replay,借用它的 retry/idempotency 思想就够了,不必引入它的部署复杂度
自己实现 capability registry要自己写状态机、safe-output、policy lock我选的——这些必须适配我现有的 Gitea label、Pi runner、prompt 模型,复制不来

这个选择的本质是:隔离优于复用。Symphony 的 reconciliation loop 思想我借鉴了(每 tick 先 reconcile remote/state/process/workspace,再 dispatch),gh-aw 的 safe-output + compile-time 安全检查我借鉴了(policy.yaml → policy.lock.json → runtime 只读 lock),但具体到我的 Gitea label 状态机、pi runner 沙箱、Electron allowlist,这些都必须是原创的,因为它们绑死在我这个项目的具体形态上。

关于「可审计控制平面」的一个普遍判断

收尾我想提炼一条可迁移的判断,因为它不局限于这个项目。

当一个自动开发系统还小的时候,「agent 输出 ≈ 系统副作用」是高效的——少写代码、少抽象、跑起来就行。但当它跑到第二个项目、第二次崩溃恢复、第二次「这个 PR 怎么被合的」追问时,隔离带的缺失会变成复利成本。每一次无法解释的副作用,都会累积成对系统的不信任;而一个不被信任的自动开发系统,最终会被 Operator 退化成手动模式。

intent/audit/idempotency 三层的投入,本质上是在提前支付信任成本。它让每一次副作用都可解释、可重放、可拒绝。这套东西不优雅——intents.py 里光是处理 unknown_outcome 的边界就有一大段防御性代码, ManagedLock 为了防止「锁文件被删后重建导致旧 inode 复用」要持有 fd 做身份比对,session id 的确定性派生逻辑读起来要花点脑子。它也还有没做完的部分:policy 注册表目前只有三个命名策略,hooks/scripts 节点还没进 runtime,scheduler 的 run 命令和 v2 runtime 的集成还没打通,整个系统仍然是单进程、单机。

但我的判断没变:在一个 agent 会自主产生副作用的系统里,可审计性不是 nice-to-have,是它能长期跑下去的前提。ai-factory 还在演进,它不完美,它用着自己开发自己这件事来持续暴露自己的 bug。如果你也在做类似的事,代码在 https://git.ruochongliang.top/lrc/ai-factory,欢迎来看。