Bernstein 实测:给 CLI 编码 Agent 加一层可离线验证的编排,四组破坏实验测出审计链的真正边界

10 月 29 日 Hacker News 首页挂了个项目:Bernstein,Apache-2.0,1,342 star,定位是「AI agent 的治理层」。同一天 PyPI 上的版本是 3.23.0。

这类项目我通常会先划走——编排器太多了,多到「再包一层」本身已经不算卖点。但这个项目的核心主张有点不同:它的调度器里没有 LLM。拆解目标的那一次模型调用之后,谁跑、谁被阻塞、什么能合并,全部由纯 Python 决定,所以同一份 plan 重放会得到同一张任务图。配上可选的 HMAC 链式审计日志和 Ed25519 签名的运行回执,它卖的不是「更聪明」,是「事后能被第三方核对」。

我花了两个多小时在 16GB M4 MacBook 上把它从装包测到出回执,中间撞了两道开箱墙、发现了一个计划重试导致的下游任务悬空问题、还做了四组破坏实验去试探那条审计链到底防什么。下面按我实际走的顺序讲,跑不通的地方我会写明。

它管的不是 agent,是 agent 上面那层

官方站点把这套流程拆成四段:decompose(把目标拆成任务、角色、完成信号)→ spawn(每个任务发到独立的 git worktree)→ verify(跑测试、类型检查、lint,可选跨模型复审)→ merge(只让通过门禁的 diff 落地)。

模型出现在两个可选位置:能力路由器和 best-of-n 的评审。唯一不放模型的地方就是「下一步该谁跑」这个 tick。这个设计取舍很诚实——把模型放进协调环,重放性立刻断掉,因为同一个调度决策第二次调用可能给出不同答案。

如果你读过我之前写的 task-view 那篇,会发现思路是同一条线上的:task-view 让单个 agent 写一份任务自述,再用只读 Git 逐条核对;Bernstein 则是把核对做成多 agent 之上的控制面,且证据从「自然语言自述」换成了「哈希链」。ThinkRail 那篇里的 SPEC 图谱是另一个方向——用规格约束 agent,而不是记录 agent。三种做法可以放在一起看:约束、自述、可验证记录。

装包很快,但开箱有两道墙

环境:macOS 26.5.2 / Apple M4 / 16GB / Python 3.13.12 / git 2.50.1。pip install bernstein 花了 53 秒,拉进来 86 个包(openai、fastapi、textual、signxml、reportlab 都在里面,装得不轻)。

Bash
# 环境:macOS 26.5.2 / M4 / 16GB,Python 3.13.12,git 2.50.1
python3 -m venv venv && ./venv/bin/pip install bernstein   # 实测 53 秒,86 个包
./venv/bin/bernstein --version                             # bernstein, version 3.23.0

mkdir demo && cd demo && git init && echo "# demo" > README.md
git add -A && git commit -m init
./venv/bin/bernstein init

init 会在仓库里铺开 .sdd/config.yaml(端口 8052、6 worker、默认模型 sonnet)、bernstein.yaml、以及一个 templates/ 目录:18 个角色(manager / backend / qa / security / reviewer…)、5 份 plan 模板、6 份 workflow 模板。.sdd 本身不大,跑完之后 3.2MB。

第一道墙是 bernstein doctor。这个预检做得相当细,30 多行检查,连「适配器 last-green 版本是否落后于当前安装版本」都查(我的 opencode 是 2.1.4,last-green 1.19.2,它给了 WARNING)。最后一行写着 Ready to run ✓ yes,我以为是绿灯,接着跑 workflow 就撞墙了:

Plain text
workspace /…/demo is not trusted; refusing to run manifest shell commands.
Run `bernstein trust` there (writes .sdd/runtime/workspace_trust.json) before
running this workflow.

doctor 说 ready,workflow 说 not trusted。这不算 bug——安全默认值愿意多问一次是对的——但两处判断标准不一致,第一次用的人会卡在这里。bernstein trust 补一步就过。

第二道墙在真正跑编排时出现:如果当前分支是仓库默认分支,它会直接拒绝启动,理由是「所有 agent 的工作都会被合并守卫拒绝并丢弃」。我建了个 bernstein-run 分支才跑起来。这一步设计得对,但提示方式很硬——它不是警告,是退出。

先不花一分钱:用纯命令节点验证「确定性」是不是真的

manifest 里的节点可以是 command(跑 shell)或 agent(派给某个角色),二者只能选一个。这意味着编排层可以完全不调模型就跑起来,对想验证调度逻辑的人来说很友好。

我写了三个纯命令节点,两个并行、一个收尾:

Yaml
name: smoke
description: "Three command nodes, no model in the loop."
version: "1.0.0"
nodes:
  - id: hello
    command: "echo hello-from-node > out_hello.txt && wc -c < out_hello.txt"
  - id: world
    command: "echo world-from-node > out_world.txt && wc -c < out_world.txt"
  - id: join
    depends_on: [hello, world]
    command: "cat out_hello.txt out_world.txt > out_join.txt && wc -l < out_join.txt"

bernstein workflow run 三次跑下来,spec_snapshot.json 里的 spec_digest 都是 17b3f97afe69bcfa…,两份快照 diff 下来一个字节都不差。这一点它没吹牛。

并行也是真的并行。我另写了一个 manifest,三个互不依赖的节点各 sleep 2,再挂一个 join:三个节点的完成时间戳相差不超过 31 微秒,整条工作流 5.1 秒跑完。串行的话应该是 8 秒。

但「确定性」这个词得掐着用。它确定的是规格摘要,不是运行记录:每次 run_id 都是随机的(我三次跑出 9d3ca025d4c7、cfd2b8f1de20、210c7023241a),节点记录里带着 wall_time_seconds。所以官方那句「重放出同一张任务图」是准确的,「byte-identical 的运行记录」不成立。

顺手记一个失败语义的坑。 我故意让一个节点 exit 3:进程退出码是 1(CI 能红,这点没问题),下游节点全部 skipped——包括那个 when 条件明明为真的补救节点:

Yaml
  - id: boom
    depends_on: [ok]
    command: "echo 'intentional failure' >&2 && exit 3"
  - id: gate
    depends_on: [boom]
    when: "test -f out_hello.txt"   # 条件为真,但依赖失败了 → 依然是 skipped
    command: "echo condition-true"

文档说 when 被跳过时「不阻塞依赖它的节点」,但没说清依赖本身失败时 when 根本没机会求值。这意味着「失败后自动补救」这类流程写不出来——补救节点必须挂在失败节点的父节点上,自己去读兄弟节点的结果。

真跑一遍编排:manager 拆了任务,测试任务却永远悬着

接下来是花钱的部分,我用本机 opencode 的免费模型跑(--cli opencode --model opencode/glm-5.3-flash-free --budget 0.5),成本 $0。goal 写的是:「建 greet.py,函数 greet(name) 返回 ‘Hello, !’;写 test_greet.py 覆盖空名和正常名;跑 pytest 确认。」

结果值得逐条看:

  • CLI 打印完计划就退出了,运行转后台,得自己 bernstein status 轮询。第一次用容易以为崩了。
  • manager 拆出两条任务:一条 backend(写 greet.py),一条 qa(写测试并跑 pytest),qa 依赖 backend。
  • backend 那条失败了两次。重试很积极,但每次重试都生成新的 task id:3390c896 → ba6cd04a → ce42a965。
  • 而 qa 任务的 depends_on 仍然指向最初那个 3390c896。于是它永远停在 open,一次都没跑。
  • 最后合并进分支的提交只有 greet.py 21 行——没有 test_greet.py。bernstein recap 里质量分一栏:lint 0、tests 0、type check 0、平均分 0。

这就是我在这个项目上看到的最重要的一件事:门禁的牙齿来自项目自己配好的 lint / 测试 / 类型检查工具。我的 demo 仓库里没装 ruff 和 pyright(doctor 早就警告过 CI tool: ruff ✗),所以「verify」这一步实际上无牙可咬,一份明显没满足 goal 的 diff 照样被合并了。

它不是骗人,是默认值的问题:一个不检查任何东西的守卫,和一个不存在的守卫,在审计日志里长得一模一样。要用它,先把 ruff / pytest / pyright 装进项目,否则那个「四阶段」里最关键的第三段是空的。

另一个可以观察到的细节:git log 里合并提交的作者是我本机全局 git 身份,不是我 repo 里临时用 -c 覆盖的那个——agent 的提交走全局配置,这点符合预期,但多人仓库里得提醒一下。

四组破坏实验:这条审计链到底防什么

这是我最想测的部分。开 BERNSTEIN_AUDIT=1 之后,审计日志写在 .sdd/audit/YYYY-MM-DD.jsonl,一行一个事件,带 hmac 和 prev_hmac。跑完那条真实编排,链上有 45 条事件:task.claim_receipt、task.release_receipt、agent.transition、adapter.admission_receipt、retry.checkpoint_decision。

实验一:改内容。 我把第 2 条的 details 改掉:

Bash
# 环境:同上;.sdd/audit/2026-10-30.jsonl 共 3 条事件
./venv/bin/bernstein audit verify-hmac
# HMAC Chain Verification FAILED
#   ! 2026-10-30.jsonl:2: HMAC mismatch (expected b409856ae22c808c…, got 5a042848d3dc8af9…)
# 退出码 1

定位精确到行,这条路是通的。

实验二:删尾巴。 把 3 条删成 2 条,再删成 1 条:

Bash
./venv/bin/bernstein audit verify-hmac   # 3 条 → 2 条 → 1 条,三次都是 Passed,退出码 0

verify-hmac 完全不知道历史被截断了。 HMAC 链只保证「相邻两条对得上」,删掉最后 N 条,剩下的链条依然自洽。这个坑很要紧:如果谁把 verify-hmac 接进 CI 当作「审计完整」,等于给自己发了一张假合格证。

实验三:先封存,再截断。 bernstein audit seal 会算 Merkle 根并把 checkpoint 钉在当前条数上。封存时 3 条,之后我把日志截到 2 条:

Bash
./venv/bin/bernstein audit seal        # Sealed: 3 entries,Checkpoint pinned at 3 entries
# 截断成 2 条后
./venv/bin/bernstein audit verify      # FAILED:! Merkle Tree  ! Checkpoints(退出码 1)
./venv/bin/bernstein audit seal        # 拒绝重新封存:
#   ! entry_count: chain holds 2 records; checkpoint pinned 3
#   ! 2026-10-30.jsonl: segment is 1396 bytes; checkpoint pinned the first 2094
#   The audit history shrank or was rewritten since the last accepted seal.

到这一步才算闭环:接 CI 的应该是 audit verify,不是 verify-hmac。前者查 HMAC 链 + Merkle 封存 + checkpoint,后者只查相邻哈希。官方文档把两者都列出来了,但没在显眼处说「前者不能防截断」,这个差别得自己撞一次才记得住。

实验四:干净工作区直接验。 这里有个反直觉的点——在一个刚 init 完、还没 seal 过的仓库里,bernstein audit verify 是红的(Merkle 柱失败,退出码 1)。也就是说,如果你照直觉把它写进流水线,第一次提交就会失败,而且原因跟你的代码毫无关系。得先跑一次 audit seal。

(还有个小的可用性问题:bernstein replay list 显示的 run id 是截断的 20261030-0114…,直接拿去 replay 会报 journal not found,得从 .sdd/runs/ 里捞完整 id。)

回执离线验:TOFU 和「钉住公钥」是两件事

配上 Ed25519 签名密钥后,一次运行会写出 run-receipt.json。我把它和公钥一起拷到 /tmp,在完全脱离仓库的目录下验:

Bash
# 生成密钥(cryptography 已在 bernstein 依赖里)
python - <<'PY'
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
k = ed25519.Ed25519PrivateKey.generate()
open('/tmp/k.pem','wb').write(k.private_bytes(
    serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption()))
PY

bernstein verify receipt /tmp/r.json --public-key /tmp/k.pub.pem
# OK (provenance: pinned key) -- every head recomputes from the embedded
# ranges and the signature verifies against the pinned public key.

不带 --public-key 时它也给 OK,但措辞变了:integrity-only: embedded key … trust-on-first-use,并明确写着「这只能证明文件内部自洽,不能证明是谁签的——控制整份文件的伪造者可以用自己的密钥重签」。这个区分很诚实,也把「离线验证」的真实含义说清楚了:离线能验的是完整性,来源得靠你自己钉住公钥。

再往下试,边界更清楚:我改 subject.name(把 run-receipt-2026… 改成 forged-run),验证依旧通过——那个字段不在签名覆盖范围里。改 run_id 或 journal.head_hash 才会炸:

Plain text
TAMPER DETECTED:
  - journal.head_hash does not match the head recomputed from the embedded rows
# 退出码 2

同样地,journal 自己的校验会说 CHAIN INTACT for 20261030-011410 (74 steps); identity=unverifiable,并列出未认证字段:elapsed_s / event_hash / index / payload_hash / prev_hash / ts。不配签名密钥的话,你能证明「记录没被改」,不能证明「记录是谁写的」。这句话建议刻在所有 agent 审计方案的说明书第一页。

我的结论:什么场景值得上,什么场景别碰

跑了这一圈,我的判断是它值不值得装,跟「你有几个 agent」关系不大,跟「有没有第三方要看你的记录」关系最大。

值得上的场景:多人共享的仓库,agent 的提交需要过门禁才能进主分支;交付物需要给客户或合规方留证据(它的 artifact 模式允许任务以「签名的交付凭据」收尾,而不是 commit);同一仓库要同时跑 Claude Code、Codex、opencode 这类不同 CLI,想让它们互不踩文件。它的 worktree 隔离是真做了——我这边确实看到 .sdd/worktrees/backend-d9bf2053 这样的独立工作树,主分支在合并前是干净的。

不建议用的场景:一个人写小项目,日常让一个 agent 改两行代码——你付出的是一套 .sdd 状态目录、一堆需要记住的子命令,以及「必须先配好 lint/test 才有意义」的前置条件。另外,它自己标着 beta、单人维护,README 明确建议锁版本,因为小版本会改接口;这种项目适合你用得起返工的场景。

还有个现实问题:这次跑完,git status 多出 25 条未跟踪路径。.gitignore 只忽略了 .sdd/runtime/,runs/、.sdd/adapters/、.sdd/attestations/ 这些全裸着。第一次提交前记得自己补 ignore。

上手清单

如果你决定试,按这个顺序能少走我走过的弯路:

Bash
# 环境:macOS / Linux,Python ≥3.12,git 已配置;至少一个 CLI 编码 Agent 已登录
uv tool install bernstein          # 或 pipx install bernstein(记得锁版本 bernstein==3.23.0)
cd your-repo
bernstein init
bernstein trust                    # 否则 workflow 的 command 节点会被拒绝执行
git checkout -b bernstein-run      # 否则真实编排拒绝在默认分支上启动
brew install ruff pyright          # 门禁的牙齿,缺了 verify 阶段等于空转
bernstein doctor                   # 确认 CI tool 三行都是 ✓

BERNSTEIN_AUDIT=1 bernstein workflow run .bernstein/workflows/你的流程.yaml
bernstein audit seal               # 必须封存一次,verify 才会是绿的
bernstein audit verify             # 接 CI 用这个,别用 verify-hmac

没实测的部分我列清楚:容器 / Docker 沙箱后端、Cloudflare Workers 运行时、Kubernetes 多机与 bernstein worker 集群、OTel/Datadog 遥测导出、best-of-n 与跨模型复审、审批门(interactive: true 在加载阶段就被明确拒绝,是个未实装的桩)、RFC 3161 时间戳锚定与 witness 共签、Windows 与 Linux 上的行为、以及除 opencode 之外那四十多个适配器。

顺便两个小出入:官网和 README 说 53 个 CLI 适配器,CLI 的 --help 里写的是「40+ adapters」,两处没对齐;bernstein agents sync 还会从 GitHub 拉一个 164 个专家的 catalog(~/.bernstein/catalogs/agency),这一步是联网的,离线环境要提前想好。

最后说一句我的真实感受:这类「治理层」项目的价值不在跑通,而在你故意弄坏它的时候它怎么反应。这四组实验里,verify-hmac 放过了截断、audit verify 抓到了、seal 拒绝重封并报出了字节数差——三个结果分别对应「看起来安全」「真的安全」「安全机制自己会拒绝妥协」。判断一个 agent 审计方案值不值得信,用这套动作去试它,比读它的首页快得多。

如果你也在给团队的 agent 流水线做证据链,之前那篇 cc-switch 管理多个 CLI agent 讲的是「怎么在几个 CLI 之间切」,这篇讲的是「切完之后怎么留下记录」,可以连着看;跑免费模型做验证的那套配置,写在 opencode 2 共享服务端那篇里,$0 跑完一整条编排就是靠它。项目本体在 GitHub,文档在 docs.bernstein.run,装包走 PyPI。

推荐阅读

  • 百度开源 Unlimited OCR 2 实测:一次读完 3600 页的 OCR,我在 16GB MacBook 上把它跑通了,多页模式有个数字坑

    百度 10 月 27 日开源的 Unlimited OCR 2 把文档解析上限推到「无限页」。本文用官方 Space 和 16GB MacBook 各实测一遍:单页 19 秒输出带坐标的结构化 Mar…

  • LFM3 实测:六款 2B 级模型共用一个骨干,16GB Mac 上我跑通了文本、视觉、音频和决策

    Liquid AI 的 LFM3 把文本、视觉、音频、决策塞进 1.7B 骨干,六款权重各 1.1GB。我在 M4/16GB 上编译 llama.cpp master 跑了四款:文本 65 tok/s…

  • Hob 实测:开源离线 AI 桌面端,16GB Mac 上它替我挑了 5GB 模型,预测 35 tok/s 实测只有 27

    Hob 是 10 月 26 日开源的本地 AI 桌面端,不选模型、不要账号,装完先扫你的机器。我把它的选型代码抽出来,在 16GB M4 上跑出它给的答案:推荐 Gemma 4 E4B、预测 35 t…

  • JetBrains 开源 ThinkRail 实测:spec 图谱管项目记忆,我用免费模型跑通了「改代码 + 更新文档」闭环

    JetBrains 开源的 Agent IDE ThinkRail 把项目约定固化成 SPEC.md 图谱,Agent 用专用工具读写设计意图,任务跑在独立 git worktree 里。我从安装、R…