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 走到「控制平面」的分水岭。先用一句白话概括:intent 是「agent 想做什么」,audit 是「系统实际做了什么」,idempotency 是「同一件事绝不重复做第二遍」。下面我用一个简化过的节点定义,看它们怎么协作:

# 一个 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。所有会产生外部副作用的节点,在执行之前都要先构造一个结构化意图,里面带上 idempotency_key 和 payload。这些节点就是 tracker.transition / ai.agent / git.commit / pr.create / pr.merge / continuation.record 等。dry-run 的意思是只预演、不落地,这时候它只产出 preview(),连 Pi 都不启动,连 git 命令也不跑。所以你可以随时用 run-workflow(不带 --apply)把整套流程预演一遍,零风险。

第二层是 audit。IntentStore 会把每一个 intent 的生命周期全部 append 到 logs/workflow-runtime/<session>/intents.jsonl,这些状态是 planned → inflight → applied / skipped_duplicate / failed / unknown_outcome。这是一份不可变的审计日志。事后任何一个外部写操作,都能回溯到是哪个节点、哪个 attempt、什么 payload、什么时候发生的。

第三层是 idempotency,也就是保证同一件事不会被执行两次,这是最让我踏实的一层。apply_once 的逻辑是这样:在真正调用外部 API 之前,先往 applied_intents.json 里写一个 inflight 标记;调用成功就更新成 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-workflow、live-smoke、auto-dev、resolve-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 的 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 timeout、AI: Paginate PR-cap deadlock scans、AI: 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,欢迎来看。