多进程的跑法和说法:prod.sh 加 --workers(AGENTLAB_WORKERS,SQLite 时大于 1 先拦下),Docker 入口按 AGENTLAB_WORKERS 起 uvicorn --workers,dev.sh 写明开发时单进程;README 把各处「单进程前提」改成实话——SQLite 单进程(第二个进程拒绝启动),PostgreSQL 可多进程,新增「多进程」一节:怎么起、配置项、认领和租约与各类接手规矩、跨进程停止、运行并发的四类与缺省、调度当班、实时流、进程里的账和缓存(哪些进了库、哪些留在进程里及其影响)、多台机器的条件(共享存储同一路径、对时、NFS 上 SQLite 文件的实话)、升级要停掉全部进程、多进程端到端测试;deploy/README 补多进程。按需视图的锁改成按应用取(每日上限几个键之间也数得准),出具的锁改成全站一把(每日上限是全站的)

This commit is contained in:
ThearchyHelios
2026-10-19 12:08:17 +08:00
parent ae254b052e
commit 4a80936c38
7 changed files with 121 additions and 23 deletions
+3 -2
View File
@@ -98,5 +98,6 @@ LABEL org.opencontainers.image.title="AgentLab" \
org.opencontainers.image.version="${VERSION}" \
agentlab.microvm="${WITH_MICROVM}"
# 单进程:运行管理器在进程内、数据库是 SQLite,不能开多个 worker
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# 工作进程数照 AGENTLAB_WORKERS(缺省 1)。大于 1 要应用库是 PostgreSQL(AGENTLAB_DATABASE_URL):SQLite 只支持单进程,
# 服务启动时拒绝第二个进程(app/cluster/guard.py)。exec:uvicorn 直接收 SIGTERM,走完整的优雅退出
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers \"${AGENTLAB_WORKERS:-1}\""]
+80 -14
View File
@@ -64,11 +64,9 @@ Mock 不会写引用标记:探索运行里报告撰写节点照样跑完,但
http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建,`--build` / `--no-build` 强制构建或跳过。
`--port`(或 `AGENTLAB_PORT`)改端口;`--host` 监听所有网卡,默认同样**不开登录**,多人使用请开登录
([身份与权限](#身份与权限))。
数据目录默认为仓库下的 `data/`,`AGENTLAB_DATA_DIR` 可改。应用库默认是数据目录里的 SQLite,也可以换成 PostgreSQL
([用 PostgreSQL](#用-postgresql)),但目前仍只能单进程运行:运行管理器在进程内;后台任务
(探查、起草、剖析、评测、影子起草、口径比对与召回)的排队、限流和按需探查的锁也都在进程内,多进程部署要先换成库里的锁。
上传表格的版本存储同样只支持单进程运行(不要给 uvicorn 加 `--workers`):启动时会锁住数据目录,同一个数据目录
起了第二个进程时,后起的那个不做任何清理,上传、启用版本等写操作一律返回 503。
数据目录默认为仓库下的 `data/`,`AGENTLAB_DATA_DIR` 可改。应用库默认是数据目录里的 SQLite,**只支持单进程**:同一个数据目录
已经有一个服务在用时,第二个进程拒绝启动、照实报错(不要给 uvicorn 加 `--workers`,`--workers` 大于 1 时 `prod.sh` 先拦下)。
换成 PostgreSQL([用 PostgreSQL](#用-postgresql))之后可以多进程:`./scripts/prod.sh --workers 4`,规矩见[多进程](#多进程)。
按期累积的数据源每个历史版本是一份完整副本,磁盘占用随期数和被引用的版本数增长。除当前版本和被运行引用的
版本外,每个数据源再保留最近启用过的 3 个版本,其余的会被回收、无法再启用。
@@ -462,8 +460,8 @@ flowchart LR
一次运行只看得到开始时已有的记录;使用者逐行只看得到自己提交的(「记录列表」组件),正式运行的汇总是全量的。
表单只加可选字段时同一版本加列,改类型、删字段、加必填字段出新版本,旧记录照原样保留:看板查新一版时,
旧版本只读并进同一张 `records`(旧版本没有的字段、类型改得对不上的字段为空),各版本都按运行
开始时的记录固定;新一版发布之前已经发起、还在半路的实例照常写完旧版本,之后发起的只写新一版。记录表按单进程锁写入,
面向人工填报的频率,不适合每秒多次的高频写入。表单里的附件先上传(`data/files/`,按内容哈希存、只读)、提交时绑到
开始时的记录固定;新一版发布之前已经发起、还在半路的实例照常写完旧版本,之后发起的只写新一版。记录表的写入全站一个接一个
(多进程时是库里的锁),面向人工填报的频率,不适合每秒多次的高频写入。表单里的附件先上传(`data/files/`,按内容哈希存、只读)、提交时绑到
这次运行,读得到那次运行或那一行记录的人才能下载;上传后 24 小时没提交的自动清理。
**验收用的假外部系统**:`python scripts/fake-erp.py --port 8765`,可以切换「正常 / 写成功后超时 / 500 / 4xx」,接口见
@@ -1035,7 +1033,7 @@ pip install -e 'backend[microvm,dev,db,docs,postgres]' -c backend/requirements.l
### 用 PostgreSQL
缺省的应用库是数据目录里的 SQLite:单机单进程,什么都不用装。要把应用库(工作流、运行、事件、审批、待办、数据目录、
缺省的应用库是数据目录里的 SQLite:单机单进程,什么都不用装。换成 PostgreSQL 之后才能多进程([多进程](#多进程))。要把应用库(工作流、运行、事件、审批、待办、数据目录、
评测……)和执行断点放进 PostgreSQL,设一项:
```bash
@@ -1080,12 +1078,7 @@ cd backend && python -m app.db copy-to-postgres ../data postgresql+asyncpg://age
**备份。** PostgreSQL 用 `pg_dump`(应用表和断点在同一个库里就一次带上),数据目录照旧整份拷。两样最好在停服务时一起做;
不停服务的话先 dump 库、再拷数据目录:工件、原件按内容寻址、只增不改,库里引用到的文件拷数据目录时都在。
**多台机器。** 应用库换成 PostgreSQL 之后,几台机器要用同一份数据目录:放在共享存储(NFS 之类)上,每台挂在**同一个绝对路径**
(库里记着上传表格各版本库文件的绝对路径)。文件都是先写临时文件再改名,读的一方看不到写了一半的。但要说实话:数据目录里还有
SQLite 文件——上传表格的各版本库(`uploads/tables/`,发布后只读,读没问题)、填报的记录表(`records/*.db`,原地追加写)、
沙箱里代码自己建的库——**SQLite 文件放在网络文件系统上并发写是不安全的**(文件锁在 NFS 上不可靠),上传表格的版本存储启动时
取的 flock 在 NFS 上也不一定管用。所以眼下即使换了 PostgreSQL,仍按单进程部署([生产部署](#生产部署)那一段的限制都还在);
多进程、多台机器要等这些写入收归一个进程或换成库里的锁之后。
**多台机器**见下一节[多进程](#多进程)。
**测试。** `scripts/test-postgres.sh` 用本机 docker 已有的 `postgres:16` 镜像起一个临时容器(只绑 127.0.0.1 的随机端口,跑完删掉),
在上面跑后端全量;参数原样交给 pytest。只对 SQLite 成立的测试标了 `sqlite_only`,在 PostgreSQL 上跳过。
@@ -1094,6 +1087,79 @@ SQLite 文件——上传表格的各版本库(`uploads/tables/`,发布后
进行中的轮次服务:已结束(成功、失败、取消、中断)超过 14 天的轮次由调度循环每小时清一次、整轮删除;重开已结束的一轮读的是
对话里那一行(答案、图、复核、用量、几次尝试),不受影响。助手等模型时的心跳只在实时流上发、不落库。
### 多进程
应用库是 PostgreSQL 时可以起几个工作进程,指着同一个库、同一个数据目录:
```bash
AGENTLAB_DATABASE_URL=postgresql+asyncpg://… ./scripts/prod.sh --workers 4 # 一台机器、uvicorn 起 4 个进程共用一个端口
AGENTLAB_WORKERS=4 … # Docker:agentlab.env 里加这一行(deploy/README.md)
```
几个容器、几台机器各起一份也行(前面挂一个负载均衡,WebSocket、SSE 不需要粘性会话)。**SQLite 只支持单进程**:数据目录上有一把
文件锁,第二个进程拒绝启动、照实报错;`AGENTLAB_WORKERS` 大于 1 而应用库是 SQLite 时同样拒绝。
| 配置 | 缺省 | 说的是什么 |
| --- | --- | --- |
| `AGENTLAB_WORKERS` | 1 | 起几个工作进程(`prod.sh`、Docker 的入口按它给 uvicorn `--workers`) |
| `AGENTLAB_WORKER_HEARTBEAT_S` | 5 | 每个进程隔多久续一次租约、取一次发给自己的请求(停止之类) |
| `AGENTLAB_WORKER_LEASE_S` | 30 | 租约多长(至少是心跳的 3 倍):进程没了(强杀、断电)多久之后别的进程接手 |
| `AGENTLAB_SCHEDULER_TICK_S` | 15 | 调度循环一拍的间隔 |
**认领和租约。** 每个进程在库里登记一行(主机、pid、启动时的随机串),隔一拍心跳续租约。运行、问数据的一轮、后台任务(探查、起草、
剖析、评测、影子起草)、处理中的文档、执行中的发布单都记着认领它的进程;租约按进程算,进程没了它认领的行一起到期。平稳关停的进程
交班、删掉自己的登记,别的进程马上接手;强杀的等租约过期。启动时的整理只碰属主不在了的行,绝不碰别的活进程的。接手的规矩:
| 什么 | 属主不在了之后 |
| --- | --- |
| 在跑的运行 | 标中断(可从断点继续运行),补一条「服务上次未正常关闭」——不自动重跑有副作用的步骤,人点「继续运行」 |
| 排队的运行 | 标失败(从没开始执行,没有断点,「恢复」只会伪造一份结果),补一条失败原因 |
| 停在审批上的运行 | 不属于任何进程:谁收到决定谁接着跑,同一个决定只会被一个进程执行(审批和运行都是带条件的更新) |
| 问数据的一轮 | 还在建图的标中断(可「重试本轮」);已经发起运行的,接手的进程接着等这次运行、照常复核收尾 |
| 起草、剖析、探查、评测、影子起草 | 标中断(没做完的表、题记停止),界面上点「重试」接着做 |
| 处理中的文档、执行中的发布单 | 文档标失败(请重新上传);发布单改成停下、停在没做完的那一步,可以接着做 |
| 待审批、结果未知的写入 | 缺了待办的补建;结果未知、还没安排核实的补上核实(所属运行还在别的活进程里执行的不碰) |
**停止。** 运行、问数据的一轮、后台任务在别的进程上跑时,「停止」写一条给属主的请求(PostgreSQL 上 NOTIFY 叫醒它,最迟一拍心跳),
属主照本进程的规矩停、收尾(`run.cancelled` 带上是谁停的),界面上和单进程时一样;属主不在了的按上表接手。
**运行并发。** 每段执行(发起、审批之后接着跑、继续运行)开始前要一个名额,按来源分四类,各有全站上限和优先级,几个进程合起来算:
交互(问数据、画布试运行、手动发起的正式运行、蓝图试运行、个人应用刷新)、应用(应用入口、按需视图、钩子、接口)、定时(定时任务、
事件触发)、系统(评测、口径召回、对账、补算、按口径正式出具)。名额满了时先给优先级高的一类,已经在跑的不打断。缺省:全站上限取
`AGENTLAB_MAX_CONCURRENT_RUNS`(20),交互 = 全站上限、应用 2/5、定时 1/5、系统 1/5,优先级 40 / 30 / 20 / 10——评测再多也只占得到
系统那几个名额,不挤占问数据。在「设置 → 偏好设置 → 运行并发」里改(管理员),改了最迟几秒各进程按新的发。单进程时也照这套发名额。
**调度当班。** 只有一个进程当班(库里的一份租约):定时认领、事件派发、Webhook 投递、流程的时限和核实、每天的汇总、各类周期清理、
口径比对的补比和自动召回的续发、接手不在了的进程留下的行,都只在当班的进程里做;其余进程待命,当班的没了待命的接班(平稳关停的马上
交班)。切换的那一会儿重复不了:定时认领是条件更新、触发和投递记录有唯一约束。访问密钥的请求记录攒在各进程自己的内存里,各自隔一拍
心跳写库。
**实时流。** 运行事件、问数据一轮的事件都先落库。页面的 WebSocket / SSE 连到的进程不在执行它时,从库里按 seq 追:执行的进程每提交
一批事件发一个 NOTIFY(只带运行 / 轮次的 id 和 seq),别的进程收到就回库里读;NOTIFY 丢了也有兜底(运行隔 2 秒、问数据隔一拍心跳回库里
看一次)。不落库的那两样——正文 token、思考增量,问数据助手的保活心跳——只转给有人在看的进程(攒一小段放进 NOTIFY 里)。回放、重连、
心跳的说法不变;序号由执行它的进程发,别的进程往执行中的运行里记事件交给它记,停着的运行追加时按库里的最后一条接号(条件更新防撞号)。
**进程里的账和缓存。** 要全站一致的都进了库:每日花费账(裁判、核查、评测)先占住那一行再读写;访问密钥每分钟限流、登录失败的退避
记在库里;按需探查、加入范围、上传表格的版本存储、记录表的写入、按需视图的单飞和每日上限、个人应用刷新、出具、召回、补算、定时发起
用跨进程的锁(PostgreSQL 的咨询锁);同源队(起草、剖析、探查一个接一个)记在库里。登录设置、对外访问地址、向量模型、能力登记的治理
快照、数据源的连接池、应用页面的缓存仍在各进程内存里,改的那个进程马上刷新,别的进程收到通知(或最迟一拍心跳)跟着重读。纯性能的缓存
留在进程里、过期无害:问数据的向量、取值猜测、表结构快照、取数快照的遮罩集合、OIDC 的元数据、DoH 解析、xlsx 的列扫描、口径比对的
SQL 形状。各进程各自的并发闸门(口径比对、自动对比、起草的模型调用、上传解析)按进程算,进程多了总量跟着多。
**多台机器。** 几台机器要用同一份数据目录:放在共享存储(NFS 之类)上,每台挂在**同一个绝对路径**(库里记着上传表格各版本库文件的
绝对路径),各台机器的时钟要对时(NTP;租约按墙钟算)。文件都是先写临时文件再改名,读的一方看不到写了一半的;写上传版本库、记录表
在库里的锁下一个接一个。但要说实话:数据目录里还有 SQLite 文件——上传表格的各版本库(发布后只读)、填报的记录表(`records/*.db`,
原地追加写)、沙箱里代码自己建的库——SQLite 文件放在网络文件系统上,读写一致性靠文件锁,而 NFS 上的文件锁不可靠:一台机器在追加时
另一台读,可能读到不一致的页。所以多台机器时,记录表写得多的部署建议用一台机器多进程,或者把数据目录放在支持可靠文件锁的存储上。
沙箱会话(microVM)在各进程自己手上,不跨进程共享。
**升级。** 升级要先停掉全部进程再起新版本:不支持新旧版本的进程同时连着一个库。
**测试。** `scripts/test-multiprocess.sh` 用本机 docker 的 `postgres:16` 起临时库,在上面起两个工作进程,用演示模型跑端到端
(`backend/tests/test_multiprocess_e2e.py`,缺省跳过,`AGENTLAB_MULTIPROCESS_E2E=1` 时跑):运行由任一进程认领、另一个进程上的
流收得到实时事件、同一个决定只执行一次、跨进程停止、定时只触发一次、评测和问数据并发时优先级生效、每日花费上限不越过、强杀一个进程
之后被接手或照实标中断。检查脚本的 `--stacks`(隔离的副本服务)只支持 SQLite。
---
## 常见问题
+3 -2
View File
@@ -388,7 +388,7 @@ _LOCKS: dict[str, asyncio.Lock] = {}
def _lock(key: str) -> Any:
"""按需视图的单飞锁(app/cluster/mutex,多进程之间也是一把):同一个键正在运行的那次接上,不另发。"""
"""按需视图的单飞锁(app/cluster/mutex,多进程之间也是一把):按应用取,同一个键正在运行的那次接上、不另发,每日上限也数得准。"""
from app.cluster import mutex
return mutex.lock(f"app-view:{key}")
@@ -471,7 +471,8 @@ async def run_view(session: AsyncSession, *, app: Any, version: AppVersion, view
raise FormRejected(errors)
inputs = normalize_params(form, params)
key = await cache_key(session, state, inputs)
async with _lock(key):
# 按应用一把锁(不按缓存键):同一个键正在运行的那次接上不另发,每日上限的「数一数再发」几个进程、几个键之间也不越过
async with _lock(app.id):
run = await _cached(session, key)
if run is not None:
return run, run.status == "succeeded"
+3 -2
View File
@@ -329,10 +329,11 @@ _locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[tuple[str, st
def _lock(key: tuple[str, str, int]) -> Any:
"""跨进程的锁(app/cluster/mutex):多进程时「有没有出具过、今天出了几次」的核对和发起照样一个接一个。"""
"""跨进程的锁(app/cluster/mutex):多进程时「有没有出具过、今天出了几次」的核对和发起照样一个接一个。全站一把(不按键):
每日上限是全站的,按键分锁时几个不同的答案同时出具会一起越过上限;出具不频繁,发起本身只是建一条运行。"""
from app.cluster import mutex
return mutex.lock(f"ledger-issue:{key[0]}:{key[1]}:{key[2]}")
return mutex.lock("ledger-issue")
async def _hider(run_id: str) -> Any:
+4 -1
View File
@@ -101,7 +101,10 @@ docker run --rm --user 10001:10001 -v "$(pwd)/data:/data" --env-file agentlab.en
停在审批上、中断了的运行导过去之后照样能接着跑。导完 `data/agentlab.db` 可以留着当回退:把那一行删掉、重新部署就回到 SQLite
(导入之后在 PostgreSQL 上新产生的东西不会回来)。备份时除了数据目录,再用 `pg_dump` 备份这个库。
现在仍按一台机器、一个容器部署:多台机器共用一份数据目录的限制见仓库 README「用 PostgreSQL」。
**多进程。** 换成 PostgreSQL 之后可以在容器里起几个工作进程:`agentlab.env` 里加 `AGENTLAB_WORKERS=4`(缺省 1)再
`./deploy.sh upgrade`。几个进程共用一个端口,各自在库里登记、续租约;一个进程没了,别的进程在租约到期后接手它留下的运行、
问数据和后台任务(规矩见仓库 README「多进程」)。SQLite 时设大于 1 会启动失败(照实报错:SQLite 只支持单进程)。
几台机器、几个容器指着同一个库也行,但数据目录要放在共享存储上、每台挂在同一个路径,限制见仓库 README「多进程」。
## 升级
+2
View File
@@ -71,6 +71,8 @@ cleanup() {
trap cleanup EXIT INT TERM
echo "==> 后端 http://127.0.0.1:$API_PORT"
# 开发时一个进程、改了代码自动重启(--reload 先停旧的再起新的,SQLite 的单进程守卫不会挡住它)。要试多进程用
# scripts/prod.sh --workers N(应用库要是 PostgreSQL),或者 scripts/test-multiprocess.sh 跑多进程端到端
(cd "$ROOT/backend" && "$PY_BIN" -m uvicorn app.main:app --host 127.0.0.1 --port "$API_PORT" --reload) &
API_PID=$!
+26 -2
View File
@@ -4,25 +4,28 @@ set -euo pipefail
usage() {
cat <<'USAGE'
用法:./scripts/prod.sh [--host] [--port <端口>] [--build | --no-build]
用法:./scripts/prod.sh [--host] [--port <端口>] [--workers <进程数>] [--build | --no-build]
--host 监听 0.0.0.0(同一网络里的设备都能打开)。默认只听 127.0.0.1,只有本机能打开。
注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它——跑工作流、花你配的
模型额度、读数据源、执行沙箱代码。只在信得过的网络里开,并配好防火墙。
--port <端口> 监听端口。也可以用环境变量 AGENTLAB_PORT,默认 8000。
--workers <N> 起几个工作进程(uvicorn --workers),也可以用环境变量 AGENTLAB_WORKERS,默认 1。
大于 1 要先把应用库换成 PostgreSQL(AGENTLAB_DATABASE_URL):SQLite 只支持单进程。
--build 不管前端产物新旧,强制重新构建。
--no-build 跳过构建,直接用现有的 frontend/dist。
-h, --help 显示这段说明。
默认只在 frontend/dist 不存在、或者比前端源码旧的时候才构建。
数据目录照后端配置:默认是仓库下的 data/,用环境变量 AGENTLAB_DATA_DIR 改。
只能单进程运行:运行管理器在进程内、数据库是 SQLite,多个 worker 会互相踩。
应用库是 SQLite(缺省)时只能单进程;换成 PostgreSQL 之后可以多进程(README「多进程」)。
USAGE
}
# 参数先认完再干活,认不出的直接报错,免得写错的参数被悄悄忽略
HOST="127.0.0.1"
PORT="${AGENTLAB_PORT:-8000}"
WORKERS="${AGENTLAB_WORKERS:-1}"
BUILD="auto"
while [ $# -gt 0 ]; do
case "$1" in
@@ -31,6 +34,10 @@ while [ $# -gt 0 ]; do
[ $# -ge 2 ] || { echo "--port 后面要跟端口号"; echo; usage; exit 2; }
PORT="$2"; shift ;;
--port=*) PORT="${1#--port=}" ;;
--workers)
[ $# -ge 2 ] || { echo "--workers 后面要跟进程数"; echo; usage; exit 2; }
WORKERS="$2"; shift ;;
--workers=*) WORKERS="${1#--workers=}" ;;
--build) BUILD="always" ;;
--no-build) BUILD="never" ;;
-h|--help) usage; exit 0 ;;
@@ -44,6 +51,18 @@ esac
if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "端口超出范围:$PORT"; exit 2
fi
case "$WORKERS" in
''|*[!0-9]*) echo "进程数必须是正整数:$WORKERS"; exit 2 ;;
esac
if [ "$WORKERS" -lt 1 ]; then
echo "进程数至少是 1:$WORKERS"; exit 2
fi
# SQLite 只支持单进程(服务自己也会拒绝启动第二个进程,app/cluster/guard.py):在这里先说清楚,免得 uvicorn 一遍遍重起被拒的进程
if [ "$WORKERS" -gt 1 ] && [ -z "${AGENTLAB_DATABASE_URL:-}" ]; then
echo "应用库是 SQLite 时只支持单进程:要 --workers $WORKERS,先设 AGENTLAB_DATABASE_URL 换成 PostgreSQL(README「用 PostgreSQL」「多进程」)"
exit 2
fi
export AGENTLAB_WORKERS="$WORKERS"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST="$ROOT/frontend/dist"
@@ -152,4 +171,9 @@ echo "==> 启动 $URLS"
cd "$ROOT/backend"
# exec:让 uvicorn 直接收 Ctrl-C / SIGTERM,一个信号就走完整的优雅退出(收尾运行、关沙箱)。
# 要是包一层 shell 再转发,终端的 Ctrl-C 会让 uvicorn 收到两次信号,第二次直接强退、跳过收尾
# 多进程(--workers 大于 1,PostgreSQL):uvicorn 起几个工作进程共用这个端口,各自在库里登记、续租约(app/cluster)
if [ "$WORKERS" -gt 1 ]; then
echo "==> $WORKERS 个工作进程(应用库 PostgreSQL)"
exec "$PY_BIN" -m uvicorn app.main:app --host "$HOST" --port "$PORT" --workers "$WORKERS"
fi
exec "$PY_BIN" -m uvicorn app.main:app --host "$HOST" --port "$PORT"