多机运行
这一页讲 noeta 怎么同时跑在很多台机器上:一个 Postgres 存储,任意多个 noeta worker 进程,前面再放 noeta serve;以及为什么一个任务每走完一步,都可以换到其中任何一台接着跑。也讲清楚执行环境从每台工人看过去必须是什么样,哪些东西只留在某一个进程里,工人死了会怎样。具体的搭建命令见作为服务端运行。
内核里没有任何专门为多机写的东西。一个任务就是一条日志,而且只有一个写者:谁持有任务的认领,谁就折叠它的日志、往里追加,每次追加都用认领的 epoch 把关。单进程跑在 sqlite 上,和五十台工人跑在 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/ |
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,绝不盲目重做(如果是一次认领没确认到提交结果,会去问存储"它认为这台工人在跑哪些任务",把它找回来)。
实时输出怎么跨机器
从工人出去的消息有两种:
- 记下的事件:在写入它们的同一个事务里,用
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 上的执行环境里读写文件、执行命令。任务每走完一步都可能换到别的机器,所以这个目录从每台工人看过去都必须一样。两种部署可以做到:
- 每台工人在同一路径挂同一个共享文件系统(NFS、EFS、CephFS 之类)。默认的本机执行环境直接就能用。
- 远端执行环境:命令在一台所有工人都能连到的主机上的容器里执行(
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,或者干脆单独的机器。