OpenBot 深度解析:自托管 Agent 平台的治理骨架
💬 评论CopilotKit 开源了 OpenBot:一个跑在自己机器上的 Agent 平台。它真正值得看的地方不是 UI,而是三件事——单一行动网关、CEL 策略引擎,以及用数据库仲裁替代定时器。

仓库:CopilotKit/OpenBot ↗ · 官网:copilotkit.ai/openbot ↗ MIT · Beta · 约 48MB / 6,000+ 文件
先说结论:OpenBot 是个模板,不是产品。README 开头就写得很直白——“A template, not a product”,没有托管版本,也没有发布成可以 npm install 的包,每个 workspace 都是私有的。你得把仓库 clone 下来,替换掉 examples/ 里的示例租户包,自己跑起来。
但正因如此,它的价值反而清楚:这是一个能把企业级 Agent 平台的骨架完整摊开给你看的项目。别人给你看的是一个跑起来的 demo,它给你看的是一整套决策。
我花时间把 server 那三十万行翻了个大概,最直接的感受是——作者把绝大部分力气花在了**“出事之后怎么办”**上,而不是”怎么让 Agent 更聪明”。这跟大部分 Agent 框架的取舍正好相反。
上图是整个部署的零件分布。左边是”谁来用”,右边是”凭什么可控”。右半边才是这个项目的重心。
先摸清体量#
先把数字摆出来,不然容易对”30 万行”这种说法失去感觉:
| 模块 | 文件数 | 代码行 | 干什么的 |
|---|---|---|---|
server | 761 | 295,828 | API、鉴权、策略、审计、插件、组件 |
app | 783 | 147,894 | React/Vite 前端 |
extension | 153 | 62,101 | 浏览器插件 |
shared | 49 | 8,192 | 前后端共享类型 |
desktop | 22 | 8,660 | 桌面壳 |
server 里最大的几个文件:plugins/plugin-store/connections.ts 3528 行、index.ts 2825 行、computer/gateway.ts 1756 行、audit.ts 1244 行。
另外有个细节值得说:仓库里有 13 个 agent-* 适配目录,LangGraph、CrewAI、Pydantic AI、Mastra、LlamaIndex、Google ADK、Claude SDK、Strands、Agno、AG2、Langroid、Microsoft Agent Framework 都能接。这不是”支持多框架”那种营销话术——它接的是 AG-UI ↗ 协议,只要你的 Agent 能说 AG-UI,就能变成 OpenBot 里的一个同事。
而治理这一半是跟着协议走,不跟着框架走。你换框架,策略、审计、权限那套不用重写。这一点我认为比支持 13 个框架本身更有意义。
一、单一行动网关:整个项目最值钱的一块#
先看 server/src/computer/gateway.ts,1756 行,是 server 里最核心的文件。它的职责说白了就一句话:所有 Agent 对外部世界做的动作,都必须从这一个口子过。
浏览器点击、键盘输入、文件读写、MCP 调用、命令行——没有例外。
流程是五步:

这五步的顺序不是随便定的,它对应三个很实际的问题:
第一,目标从哪来? 快照存在 PostgreSQL 里,不在进程内存里。原因写在代码注释里挺直白:OpenBot 是负载均衡后面好几个 server,同一个会话的连续两次调用很可能落在不同副本上。存内存的话,只有拍快照那个副本认识 ref,别的副本解析出来是空的,策略判的时候面前根本没有元素,审计行也写不出”到底点了什么”。
还有个细节我觉得设计得很细:每个动作都带一个 generation 号。页面刷新过之后,旧的 ref 不能解析到新页面上占着同一个位置的元素。这个 generation 就是防这个的。
第二,策略怎么判? 用 CEL(Common Expression Language),规则是一句话:
deny:
- tool.name == "computer_click" && element.name == "Submit"plaintext作者在 policy.ts 顶部解释了为什么用表达式语言而不是一张规则表:
A table of columns can express the shapes we thought of; an expression language can express the one they thought of.
表格只能表达我们想到的形状,表达式能表达他们真正想到的那个。翻译过来就是:企业想要的边界通常是一句自然语言(“永远不要点我们域名之外标着 Submit 的东西”),而不是一张枚举型的表格。
第三,失败了算什么? 这里区分了三种失败,语义完全不同:
- 拒绝——不是报错,是结论。策略判了不行,动作不发生,审计行写明是哪条规则拦的
- 执行失败——决策已经发生了,补写第二行失败记录
- 策略本身坏了——按拒绝处理
第三条是 fail-closed。空策略什么都不允许,deny 规则写坏了就拒绝,allow 规则写坏了不放行。代码里注释说得很清楚:“a broken deny rule denies, and a broken allow rule does not permit”。
我理解作者为什么这么较真:治理功能最怕的就是”配错了但没人发现,以为什么都没限制”。宁可一次失败挡掉一次操作,也不要出现一个”看起来在管,其实在放行”的假象。
策略能看见什么#
规则能引用的字段比我预想的多:

分三类:谁在动(工具名、Bot、发起者)、在哪动(页面 URL、元素)、动什么(intent、按键、命令、文件路径、MCP 调用)。
有两个细节我觉得是踩过坑才有的:
元素信息不信调用方自报。 element.ref / role / name 是 gateway 自己从快照里解析出来的,不是请求里带什么就信什么。注释里的话很重:
A policy that decides on an attacker-supplied label is decoration.
如果策略按调用方自报的标签判断,那”不许点 Submit”这条规则只要把按钮叫别的名字就绕过去了。等于没有。
区分 tool.name 和 intent。 工具名讲的是机制,策略该讲的是效果。同一个”激活按钮”的动作,可以来自 click、Enter、Space 三条路。如果规则只写 computer_click,那 Agent 被拒了一次之后会改按 Enter,订单就下去了。
所以策略里多了一个 intent 字段:activate / type / navigate / read / write_file / run_command 等等。作者给的理由是”operator thinks in effects”——运维的人想表达的是”不许激活任何叫 submit 的东西”,不是”不许调用 computer_click 这个工具”。
不过作者也很诚实,他在注释里承认 intent 也拦不干净:一个表单有三扇门,规则覆盖了两扇,因为 computer_type 带了个 submit 标志,按下 Enter 提交时也会报 activate,但浏览器可以在表单的任意一个输入框里按 Enter 提交,而按键规则看到的元素是那个输入框,不是整个表单。网关要判断”这次按键会不会提交表单”,需要知道页面结构,但决策的时候拿不到——ref 是 Playwright 挂在 DOM 外的,策略在动作到达浏览器之前就跑了。
所以最后那条规则还是得直接拒绝 Enter,预设里就是这么写的。这种”知道自己哪里不够,把话说明白”的态度,比假装覆盖完整要靠谱。
二、审计里最聪明的一个字段:initiator#
审计这块我原本以为就是”记个日志”。结果看到一个字段设计,当场就记住了。
每条审计行除了记”谁”(actor)和”干了什么”,还记了 “是什么引起的”(initiator):
| initiator_kind | 含义 |
|---|---|
person | 有人在场。默认值 |
deployment | 部署本身,启动时或拒绝一个认不出来的调用方 |
routine | 定时任务触发的,以它的主人身份,没人在场 |
responsibility | Bot 的常驻职责被触发,同样没人在场 |
memory | 记忆读取某个外部 App 导入事实,以那个来源的主人身份 |
handoff | 另一个 Bot 请求的,代表那个人 |
关键在最后一行注释:
An interactive run has somebody watching who will notice a wrong tool call; an unattended one does not, which is the case worth being able to find.
有人盯着的时候,工具调错了会被人发现。没人盯着的时候不会——而后者才是真正值得被找出来的那些。
所以审计页面有个专门的筛选项叫 “Nobody watching”,它把 routine、handoff、responsibility、memory 四种归到一起。翻译过来就是:“趁你不在的时候,用你的权限干了什么”。
这个字段还有一个我觉得很聪明的实现细节。initiator 不是由写审计行的那段代码决定,而是在签名过的 run assertion 里带过来。原因写在 audit.ts 里:
a hop is one run on one pod handing to another run on another, and the process that knows a routine began it is the one that claimed the routine, not the one writing the row.
A 找 B 交接,B 又找 C,这三段可能跑在三个不同的 pod 上。知道”是那个定时任务发起的”是最初认领这个定时任务的那个进程,不是最后写审计行的那段代码。所以答案得跟着签名一起传,谁持有断言谁就知道答案。
顺带解决了一个安全问题:Bot 不能给自己改标签,因为断言是部署签名的,而且部署不会写出来的 kind 会被当成 person。
交接失败必须说出来#
Bot 之间可以交接工作(message_bot),但作者对交接失败的处理特别强调:
When one runs out of attempts, the asking Bot is sent back into the conversation the person is watching to say plainly that nothing came back.
交接失败要明说。因为”问出去但没回音”和”回音还在路上”,在人眼里长得一模一样,结果就是对话突然停住了,谁也不知道发生了什么。
审计里记了交接的每一种结局:offered、refused(哪个上限挡的、缺哪个 grant)、delivered、failed、retried。作者特别指出 refused 那行最重要——发生过的交接在对话记录里看得见,被拒绝的交接在其他任何地方都是隐形的。
还有一点:交接的深度上限是拒绝而不是截断。BOT_HANDOFF_MAX_DEPTH 和 BOT_HANDOFF_MAX_PER_RUN 超了就直接拒。理由也写得明白:一次交接是另一头完整的一个 agent turn,一轮里问几个 Bot 就是几次完整运行,在每个 Bot 有自己 computer 的部署里,一次扇出会唤醒几台机器。
三、定时任务为什么不能写在 API 进程里#
这部分我原本以为是常规设计,看完注释之后发现自己想简单了。
OpenBot 有定时 routine、闲置 computer 回收、Bot 之间的交接,这三件事都需要”过一会儿做一次”。作者的做法是塞进一张 PostgreSQL 表 work_items:

为什么不用定时器?因为 OpenBot 是多副本部署,而定时器会在每个副本里都跑一遍,每个副本都会独立判断”这条 routine 该跑了”,于是同一条 routine 被跑 N 次。
而且副本一重启,“这条跑过没有”就没人记得了。
所以用数据库仲裁:
select ... from work_items
where due_at <= now() and (lease_until is null or lease_until <= now())
order by due_at
for update skip locked
limit 20sqlfor update skip locked 是关键:多个副本同时来抢,Postgres 保证同一行只会被一个事务锁住,别的直接跳过而不是排队等。代码注释里说这才是”并发”而不只是”正确”——没有 skip locked 的话,副本 2 会一直等锁,直到副本 1 干完。
租约机制配套:领到活儿的同时领一个租约(默认 60 秒),干活期间不断续租。一旦不再续租,说明那个副本大概挂了,别人可以接手。
有个细节我觉得值得单独拎出来。work/queue.ts 的注释里提到一个 bug:
finishandreleaseused to match on the key alone, so a replica whose lease had quietly [expired] …
以前 finish/release 只按 key 匹配,导致一个租约已经悄悄过期的副本,仍然能宣称”我干完了”或者”我放弃了”。修法是必须同时匹配 owner——光有 key 不够,还得证明这把锁现在是我的。
这就是分布式系统里最常见的一类 bug:身份对了不代表所有权还是你的。
顺带一提,租约的时间是拿数据库的 now() 算的,不是副本本地的 Date.now()。队列代码里明确说了这么做的原因:
computed as
Date.now() + leaseMson the replica and compared againstnow()in Postgres, which is two clocks pretending to be one
两个时钟假装成一个,迟早出事。节点慢 90 秒就会写出个有效期不对的租约。
四、工具太多,模型就会挑错#
这是另一个我觉得很实用的设计。
copilot.ts 里有一句注释,写得很直白:
A model picks the right tool reliably out of about ten, and unreliably out of thirty.
十个工具以内,模型基本挑得准;三十个,就开始不稳了。而一个部署了两个厂商的 Agent 平台,第一天下午就会越过这条线。
OpenBot 的解法不是不给工具,而是每次只摆出这次用得上的那几个:

流程是:
- 拿到这个 Bot 本来就授权的工具全集
- 每个 skill 声明自己要用的工具(
skill_tools) - 跑之前先问一次自己的模型:这条消息需要哪些 skill
- 把这些 skill 声明的工具,和”没有任何 skill 认领的已授权工具”取并集,摆给模型
有两条约束我觉得必须记下来:
是交集,不是并集。 skill 只能从”本来就授权的”里面挑,永远送不了新权限。写一个 skill 不可能给自己要到额外的工具。
收窄失败就全给。 没有 skill 声明、模型答不出、消息匹配不到任何 skill、工具总数本来就少于阈值——这些情况都退回”整个目录照旧摆出去”。
第二条尤其重要。作者的解释是:
a narrowing that failed closed would remove capability an administrator granted, silently
收窄是个优化,不是边界。如果它自己坏了就收紧,那管理员明明给的能力就悄悄没了,而且没人知道。反过来,边界失灵是危险的,收窄失灵只是慢一点。
每一步都会记一条 mcp.tools_discovered,写着这次摆出了多少个里的哪些、为什么这么选。
作者还强调了一句我觉得很清醒的话:
This narrows the offer. It is not a boundary, and it never substitutes for one.
收窄决定的是”模型看得见什么”,边界永远由 grant + policy + audit 三样东西守着。工具可见性是个体验问题,权限才是安全问题,两者不能混。
五、几个别的设计#
每个 Bot 一台自己的电脑#
CHANGELOG 里有个对现有部署影响很大的改动:0069_isolated_bots 加了 agent_profiles.isolated 字段,默认关。改完之后所有 Bot 共享一个人的电脑,而不是各有一台。
在这台电脑里,每个 Bot 有自己的文件夹和自己的浏览器 profile。私有的 Bot 的登录状态会被复制进它主人的电脑一次;公开 Bot(包括内置的那些)要重新登录一次——因为它们原来的登录状态是所有用过它的人共享的。
这个改动的逻辑我觉得很对:公开 Bot 共享一份登录状态,意味着 A 用过之后 B 直接继承了 A 的身份。
隔离级别有三种:每人一台、每个私有 Bot 一台、共享一台。COMPUTER_SUPERVISOR_URL 配了就走 supervisor,否则所有 Bot 共享 AGENT_COMPUTER_URL。
出网也管#
每台 computer 有自己的出网过滤代理,策略有四种:allow_all、defaults_plus_allowlist、allowlist_only、deny_all。最后一种是”它的主人关了云端网络访问”时 Bot 拿到的默认值。
server 在 computer 动作之前把策略推下去,每次唤醒也推一遍,在策略到达之前任何东西都不许出去。而且不管哪种模式,云元数据地址(169.254.169.254 之类)一律拒绝。
作者也诚实地划了边界:
A process that ignores the proxy variables and opens a raw socket is not stopped by this; that is a Kubernetes NetworkPolicy’s job.
绕过代理直接开裸 socket 的进程,这一层拦不住——那是 K8s NetworkPolicy 的活。这种”我知道我的防线到哪儿”的表述,比吹得滴水不漏更让人放心。
单用户模式的安全检查#
.env.example 里默认带 OPENBOT_SINGLE_USER=true,意思是所有请求都被当成同一个管理员,这样新 clone 下来不用先配 OAuth 就能跑起来。
但作者加了一道检查:如果 OPENBOT_PUBLIC_URL、OPENBOT_APP_URL 或者 TRUSTED_ORIGINS 里任何一项解析出来是公网可达的地址,部署直接拒绝启动。
理由很实在:公网上没有登录 = 每个访客都是管理员。回环地址静默放过,私有地址(家里 LAN、Tailnet、VPN、.local)放行但会警告并点名。
这种”为了方便留的开关,顺手给它加个自杀式检查”的做法,我很喜欢。
交接的落点是它自己的会话#
Bot A 交接给 Bot B 之后,答案落在 B 自己的会话里,不是 A 问的那个会话。
这不是随便选的,文档说是”a property of the platform rather than a choice”:一个 Intelligence 线程只属于一个 agent,所以群聊里每个 Bot 各有各的线程,大家读到的是一份共享的 group_messages,但每条回复都署着自己的说话人。
结果就是提问的会话里会有一条”活儿交给谁了”的提示,回答的会话会冒到花名册最上面带个未读标记。人拿到的是两半,不是一半。
而且交给 B 的那段历史里,只保留一行”谁问的、要干什么”,完整的信封不留给 B。作者解释:这是两段给两类读者看的文本——模型需要的是任务、约束和理想答案的形状;而刷聊天记录的人需要知道”这 Bot 突然说话是为了啥”。交接历史会作为上下文读给 B,但不重复写进记录。
沙箱用 K8s CRD,不用自己造#
server/src/computer/sandbox.ts 里的注释挺有意思。作者用的是 kubernetes-sigs/agent-sandbox 这个 CRD,理由是它描述的就是”隔离的、有状态的、单例工作负载,带稳定身份和持久存储”——这正好就是一台 Bot 的电脑。
作者说,本来最难搞的那块其实是一个字段:spec.operatingMode 可以是 Running 或 Suspended,Suspended 会终止 Pod 但保留 volume。于是”闲置时缩掉、回来时登录状态还在”就变成了一次 patch 加一个等 condition。
而且没引 client library,理由很实在:K8s API 就是 HTTP + JSON,这儿只需要五个动词,生成的 client 在一个已经装了浏览器的镜像里是笔大开销。
六、它不适合什么#
前面说的都是好话,但有几件事得说清楚,免得有人抄完发现对不上预期。
它是模板,不是产品。 没有托管版本,没有 npm 包,所有 workspace 都是私有的。你要自己替换 examples/ 里的示例租户包,自己接 CopilotKit Intelligence(要么用他们的托管服务,要么自己部署)。README 里还专门留了一段”要不要我们帮你搭”——这是他们的销售线索。
它需要 CopilotKit Intelligence。 线程、记忆、实时网关都在那边。虽然可以自托管,但那是一整套额外的部署。免费 Developer plan 够本地试用。
它现在还在 Beta。 README 自己写着 “Beta, and under active development. Expect rough edges and bugs, and expect things to move.” CHANGELOG 的 Unreleased 段落列了十六条会改变现有部署行为的事项——包括上面说的”Bot 改成共享电脑”这种迁移级别的改动,还有一堆迁移编号(0054 到 0069)。
它对 CopilotKit 有依赖。 AG-UI 是开放协议,这点很好,但 Intelligence 那部分是 CopilotKit 的东西。
它没解决”谁来当人”这个问题。 文档里说得很直白:
Who “a person” is, is a seam. This template answers the person in the conversation, which is the only answer a template can give honestly; a company has an on-call rota or a duty desk, and that is a route the deployment hands in rather than a channel post written into the tool.
“问人”这个动作,模板只能回答”问对话里的那个人”。真公司有值班表、有客服台,那是部署自己该接进去的路由,不是工具里写死的逻辑。这话说得很诚实——模板不该假装自己能回答所有部署形态的问题。
七、我从这项目里拿走的东西#
写到最后,我想留几条跟 OpenBot 本身无关、但可以直接搬走的:
1. 单一网关是 Agent 平台的最低门槛。 只要有一个动作能绕过网关,整套治理就都是装饰。哪怕先只做一个”所有文件写操作走这里”的窄网关,也比什么都没有强。
2. 策略要按效果写,不要按工具名写。 intent 这一层抽象很值得抄。同一个效果有多条触发路径,只堵一条等于没堵。
3. 元素/目标信息必须服务端解析,不能信调用方。 任何”调用方报什么就按什么判”的策略,都是可绕过的。
4. fail-closed 要贯彻到所有层。 策略坏了就拒绝、收窄坏了就全给、锁丢了就放弃所有权。三处的方向不一样,但底层逻辑一致:往”少做事”的方向退。
5. 多副本的”谁来做”必须由数据库仲裁。 定时器、内存 map、进程内锁,在多副本下都不成立。而且要记住 owner 也要匹配,不能只看 key。
6. 审计要记”起因”,不只是记”操作”。 initiator_kind 那一栏,让我能在事后问出”趁我不在的时候,我的权限被用来干了什么”。这个问题用普通的审计表回答不了。
7. 优化不是边界。 工具收窄、上下文压缩这类东西,失败了要退回”什么都没变”,而不是悄悄削能力。这条对任何 Agent 系统的性能优化都适用。
最后说一句总体判断。OpenBot 不是一个”更强”的 Agent 框架,它的模型能力、推理质量这些方面它并不出众。它厉害的地方是把”一个能碰你真实系统的 Agent”这件事所需的所有约束,一层层织进了协议和代码里。
如果你正在给 Agent 接真实的浏览器、真实的文件系统、真实的第三方 API,那这份”约束清单”本身可能比它的代码更值得看。
参考#
- 仓库:CopilotKit/OpenBot ↗
- 协议:ag-ui-protocol/ag-ui ↗
- 架构文档:
docs/architecture.md - 部署文档:
docs/deployment.md、docs/configuration.md