Skip to content

多机运行 ​

这一页讲 noeta 怎么同时跑在很多台机器上:一个 Postgres 存储,任意多个 noeta worker 进程,前面再放 noeta serve;以及为什么一个任务每走完一步,都可以换到其中任何一台接着跑。也讲清楚执行环境从每台工人看过去必须是什么样,哪些东西只留在某一个进程里,工人死了会怎样。具体的搭建命令见作为服务端运行。

内核里没有任何专门为多机写的东西。一个任务就是一条日志,而且只有一个写者:谁持有任务的认领,谁就折叠它的日志、往里追加,每次追加都用认领的 epoch 把关。单进程跑在 sqlite 上,和五十台工人跑在 Postgres 上,用的是同一套代码;变的只是存储。

终端界面 noeta --server URL 编辑器 noeta acp --server URL 网页 noeta serve --web DIR 你的代码 RemoteClient · HTTP 建、发、看 noeta serve HTTP / WebSocket 要带令牌访问 可以开好几个 Postgres 存储 任务、日志、收件箱、 快照;认领用 SKIP LOCKED,租约 按数据库的时钟 noeta worker × N worker A claim · renew worker B append · release worker C --queue NAME 认领·追加 执行环境 共享文件系统(同一路径) 或远端容器 模型 provider 每台机器配置相同
前端连 noeta serve;任意多台工人共用一个 Postgres 存储

有哪些部分 ​

部分做什么代码
Postgres 存储任务行、事件日志、收件箱和快照,放在一个 schema 里(默认 noeta);唯一的共享状态noeta/backends/stores/postgres.py(以及 postgres_conn.py、postgres_feed.py、postgres_snapshots.py)
noeta worker认领就绪的任务并跑起来,同时最多 --concurrency 个(默认 8);也负责跑渠道(channel)noeta/core/runner/runtime.py
noeta serve通过 HTTP 和 WebSocket 提供客户端接口,要带令牌访问;除非加 --no-worker,它自己也跑一个工人noeta/host/http.py
前端终端界面(noeta --server URL)、编辑器(noeta acp --server URL)、网页、你自己的代码(RemoteClient)noeta/host/remote.py
执行环境工具读写文件、执行命令的地方:共享文件系统,或者远端容器noeta/backends/execenvs/
sh
export NOETA_STORE=postgres://noeta:…@db.internal/noeta
noeta worker --concurrency 16            # 每台工人机器上都跑
noeta serve --no-worker --host 0.0.0.0   # 接口,一台或几台

用 --server 连上来的前端不需要自己的模型和 key:它的任务用服务端的模型、在服务端的目录里跑,除非用 --model 或 --cwd PATH(服务端上的一个目录)另行指定。

为什么任何一台工人都能跑任何任务 ​

  • 是认领,不是分配。 工人用 FOR UPDATE SKIP LOCKED 拿一个可认领的任务,两台工人不会拿到同一个,也不需要谁来派活。认领用的索引只包含能被认领的行,所以闲着等待的任务不增加扫描的负担。
  • 写入有把关。 每次认领都把任务的 epoch 加一。每次追加先锁住任务行,再核对状态、epoch 和预期的最后一个 seq,所以一台卡住、等租约过期后才醒过来的工人,再也写不进去。租约只决定"多久没动静就可以换人"。租约按数据库的时钟算,各台机器的时钟有偏差,对"谁持有租约"的判断也一致。
  • 日志里什么都有。 对话、每个插件的状态、提示词覆盖、任务用的插件、模型走哪个 provider,全在日志里。认领任务的工人把它们折叠出来(快照加上之后的事件),发出的请求和上一台工人本来要发的逐字节相同(见上下文)。
  • 等待不占资源。 等用户、等子任务、等计时器的任务会交出认领;答复到了,哪台工人空着就由哪台接手(见等待与唤醒)。

每次写入都是一个事务。遇到死锁或串行化失败,整个事务作废,连同检查一起重新执行;连接在发出 COMMIT 之前断了,换个新连接重试一次;连接恰好在 COMMIT 途中断了,就抛 CommitUnknown,绝不盲目重做(如果是一次认领没确认到提交结果,会去问存储"它认为这台工人在跑哪些任务",把它找回来)。

实时输出怎么跨机器 ​

实时文字 尽力送达,不存 记下的事件 持久,所有人可见 工人 A 模型流式输出 on_delta → publish_deltas pg_notify <schema>_deltas 每条 ≤ 7,000 字节 工人 A append:事件行 + pg_notify,同一个事务 pg_notify <schema>_changes 提交了才有通知 noeta serve 任何一台机器上 LISTEN 两个频道 on_delta · on_change 前端 /watch 的 事件流(SSE) 边写边看到文字 监听连接断开重连期间的通知会丢:运行器每隔三分之一个租约看一眼自己任务的收件箱,补上这个空档。 sqlite 上实时文字只留在跑任务的进程里。
实时文字尽力送达;记下的事件随提交一起通知

从工人出去的消息有两种:

  • 记下的事件:在写入它们的同一个事务里,用 pg_notify 在 <schema>_changes 上通知,所以只要变化提交了,就一定有通知,没提交就一定没有。每个进程都保持一个 LISTEN 连接(断了会重连),喂给 on_change;正在看某个任务的前端随后从存储里读新事件。
  • 模型的实时文字(回复流式输出时的片段)走第二个频道 <schema>_deltas,不在任何任务的事务里(publish_deltas,noeta/host/local.py)。它尽力送达、从不存储:每条通知都低于 Postgres 8,000 字节的上限(每条最多 7,000 字节,长片段拆成几条)。任何一台机器上的 noeta serve 都能收到,并通过 /watch(服务端推送的事件流,SSE)推出去,所以连在一台服务器上的前端,也能边写边看到另一台机器上跑的任务的文字。

LISTEN 连接重连期间发出的通知会丢。为了补上这个空档,运行器每隔三分之一个租约就看一眼正在跑(或占着认领等待)的任务的收件箱,所以空档里投来的消息只会晚一点被取到,不会漏。在 sqlite 上(单机),实时文字只留在跑任务的那个进程里;记下的事件照样所有人都能看到。

每台工人看到的工作区必须一样 ​

工具在任务 cwd 上的执行环境里读写文件、执行命令。任务每走完一步都可能换到别的机器,所以这个目录从每台工人看过去都必须一样。两种部署可以做到:

  1. 每台工人在同一路径挂同一个共享文件系统(NFS、EFS、CephFS 之类)。默认的本机执行环境直接就能用。
  2. 远端执行环境:命令在一台所有工人都能连到的主机上的容器里执行(noeta.backends.execenvs.container,或者你自己在 noeta.execenvs 组里注册的执行环境),由宿主程序的 Runtime(env_for=…) 按任务选。

任务自己的文件放在它的工具所在的地方,也就是执行环境里,所以会跟着任务走:为模型存下的超长输出(存放目录,~/.noeta/tool-results/<task id>/)、计划文件(~/.noeta/plans/)、自动记忆(~/.noeta/projects/<project>/memory/、~/.noeta/agent-memory/)。模型用自己的文件工具读写它们,和 Claude Code 一样,所以它们是文件而不是存储里的行;第一次用到时路径记进日志。用远端执行环境时,这些文件就在那个环境里,各台工人上 noeta 的主目录路径必须一致。用本机执行环境加共享文件系统时,这几个目录也必须在共享挂载上、各处路径相同(把 NOETA_HOME 放到挂载上,或者把 tool-results、plans、projects、agent-memory 链接过去);否则任务换了机器,会发现存下的输出、计划和记忆都不见了。

哪些东西只留在一个进程里 ​

有些东西离不开拥有它的那个进程。每一样都做了处理,让任务换机器、工人死掉都是安全的:

只在一个进程里任务换机器或工人死掉时
后台 Bash 任务和它的输出后台任务在跑的时候,任务一直占着那台工人上的认领(ToolContext.keep,ADR-029):任务行显示在等待,工人续租约并自己取任务的收件箱。工人死了,租约过期,下一个认领者告诉模型这个后台任务没了。
挂起的 MCP 调用、MCP 连接连接按进程共享,到了别处需要时重新连;必须在某个连接上等待的调用,同样占着认领。
模型的实时文字尽力送达,见上文;作数的是记下的回复。
用户粘贴的图片放在 ~/.noeta/image-cache(本机执行环境)或每个用户自己的临时目录;接手任务的工人会根据对话把它们重新写出来,并告诉模型新位置。
正在后台写的摘要随工人一起丢失;之后的某一步会再起一个(见上下文)。
模型列表、设备编号每台机器各自一份(~/.noeta/cache/models、~/.noeta/device_id)。模型走哪个 provider、列表给的上限按任务记下,所以列表可以不同(见模型路由)。
~/.noeta 里的设置、代理、插件每个进程读自己的。给每台机器装同一份;缺少某个任务启动时用过的插件的工人,绝不在缺插件的情况下跑它(见插件版本记录)。
渠道(channel)在每个启用了它的 noeta worker / noeta serve 里跑;收进来的消息是幂等的,跑好几份也安全,回复从任务内部发出。

工人死了会怎样 ​

工人死了,不会留下任何别人接不了的东西:

  • 它的任务保持 running,直到租约过期(noeta worker --lease,默认 30 秒),然后任何一台工人认领,从日志接着跑。
  • 被崩溃打断的模型调用,从日志重新拼出同一个请求再发;已经记下"开始了"的工具调用答复"结果未知",除非这个工具能查到结果,或者可以安全地重跑(见崩溃恢复)。
  • 只是卡住、后来又活过来的工人,会发现自己的 epoch 已经过时:它的下一次追加被拒,于是放下这个任务。
  • 有意停掉的工人(SIGTERM)会立刻交还任务,不用等租约过期;为后台任务占着认领的任务保持等待,下次来消息时会被告知后台任务没了。

make cluster 就是在 Postgres 上跑多个工人进程并随机杀掉,专门检查这些(tests/cluster)。

升级与多方共用 ​

  • 升级。 升级前先停掉所有工人和 noeta serve;不同版本的工人不能共用一个存储。新版本的第一个进程会就地把存储的表结构升上去;比存储表结构旧的进程拒绝打开它(StoreUnavailable)。只换插件的话,逐台重启就够了:一次停一台、装好、再启动,在几个租约的时间内做完。
  • 队列。 noeta worker --queue NAME 只跑这个队列;子任务可以派到另一个队列(view.spawn(queue=...)),比如派到带 GPU 的机器上。
  • 一个存储只服务一方。 一个存储和一个服务端只服务一方:拿到令牌的人能操作所有任务,工具以工人进程的系统用户身份执行(ADR-028)。要服务彼此不能互相看到的几方,给每一方单独的服务端和存储 schema,或者干脆单独的机器。