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 都在里面,装得不轻)。
# 环境: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 initinit 会在仓库里铺开 .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 就撞墙了:
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(派给某个角色),二者只能选一个。这意味着编排层可以完全不调模型就跑起来,对想验证调度逻辑的人来说很友好。
我写了三个纯命令节点,两个并行、一个收尾:
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 条件明明为真的补救节点:
- 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,
结果值得逐条看:
- CLI 打印完计划就退出了,运行转后台,得自己
bernstein status轮询。第一次用容易以为崩了。 - manager 拆出两条任务:一条 backend(写 greet.py),一条 qa(写测试并跑 pytest),qa 依赖 backend。
- backend 那条失败了两次。重试很积极,但每次重试都生成新的 task id:
3390c896→ba6cd04a→ce42a965。 - 而 qa 任务的
depends_on仍然指向最初那个3390c896。于是它永远停在open,一次都没跑。 - 最后合并进分支的提交只有
greet.py21 行——没有 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 改掉:
# 环境:同上;.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 条:
./venv/bin/bernstein audit verify-hmac # 3 条 → 2 条 → 1 条,三次都是 Passed,退出码 0verify-hmac 完全不知道历史被截断了。 HMAC 链只保证「相邻两条对得上」,删掉最后 N 条,剩下的链条依然自洽。这个坑很要紧:如果谁把 verify-hmac 接进 CI 当作「审计完整」,等于给自己发了一张假合格证。
实验三:先封存,再截断。 bernstein audit seal 会算 Merkle 根并把 checkpoint 钉在当前条数上。封存时 3 条,之后我把日志截到 2 条:
./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,在完全脱离仓库的目录下验:
# 生成密钥(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 才会炸:
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。
上手清单
如果你决定试,按这个顺序能少走我走过的弯路:
# 环境: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。