From 4a80936c38422ea38dbfaa27a77217ea728186f9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 12:08:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E8=BF=9B=E7=A8=8B=E7=9A=84=E8=B7=91?= =?UTF-8?q?=E6=B3=95=E5=92=8C=E8=AF=B4=E6=B3=95=EF=BC=9Aprod.sh=20?= =?UTF-8?q?=E5=8A=A0=20--workers=EF=BC=88AGENTLAB=5FWORKERS=EF=BC=8CSQLite?= =?UTF-8?q?=20=E6=97=B6=E5=A4=A7=E4=BA=8E=201=20=E5=85=88=E6=8B=A6?= =?UTF-8?q?=E4=B8=8B=EF=BC=89=EF=BC=8CDocker=20=E5=85=A5=E5=8F=A3=E6=8C=89?= =?UTF-8?q?=20AGENTLAB=5FWORKERS=20=E8=B5=B7=20uvicorn=20--workers?= =?UTF-8?q?=EF=BC=8Cdev.sh=20=E5=86=99=E6=98=8E=E5=BC=80=E5=8F=91=E6=97=B6?= =?UTF-8?q?=E5=8D=95=E8=BF=9B=E7=A8=8B=EF=BC=9BREADME=20=E6=8A=8A=E5=90=84?= =?UTF-8?q?=E5=A4=84=E3=80=8C=E5=8D=95=E8=BF=9B=E7=A8=8B=E5=89=8D=E6=8F=90?= =?UTF-8?q?=E3=80=8D=E6=94=B9=E6=88=90=E5=AE=9E=E8=AF=9D=E2=80=94=E2=80=94?= =?UTF-8?q?SQLite=20=E5=8D=95=E8=BF=9B=E7=A8=8B=EF=BC=88=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E4=B8=AA=E8=BF=9B=E7=A8=8B=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=EF=BC=89=EF=BC=8CPostgreSQL=20=E5=8F=AF=E5=A4=9A=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=EF=BC=8C=E6=96=B0=E5=A2=9E=E3=80=8C=E5=A4=9A=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E3=80=8D=E4=B8=80=E8=8A=82=EF=BC=9A=E6=80=8E=E4=B9=88?= =?UTF-8?q?=E8=B5=B7=E3=80=81=E9=85=8D=E7=BD=AE=E9=A1=B9=E3=80=81=E8=AE=A4?= =?UTF-8?q?=E9=A2=86=E5=92=8C=E7=A7=9F=E7=BA=A6=E4=B8=8E=E5=90=84=E7=B1=BB?= =?UTF-8?q?=E6=8E=A5=E6=89=8B=E8=A7=84=E7=9F=A9=E3=80=81=E8=B7=A8=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=81=9C=E6=AD=A2=E3=80=81=E8=BF=90=E8=A1=8C=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E5=9B=9B=E7=B1=BB=E4=B8=8E=E7=BC=BA=E7=9C=81?= =?UTF-8?q?=E3=80=81=E8=B0=83=E5=BA=A6=E5=BD=93=E7=8F=AD=E3=80=81=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=B5=81=E3=80=81=E8=BF=9B=E7=A8=8B=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E8=B4=A6=E5=92=8C=E7=BC=93=E5=AD=98=EF=BC=88=E5=93=AA=E4=BA=9B?= =?UTF-8?q?=E8=BF=9B=E4=BA=86=E5=BA=93=E3=80=81=E5=93=AA=E4=BA=9B=E7=95=99?= =?UTF-8?q?=E5=9C=A8=E8=BF=9B=E7=A8=8B=E9=87=8C=E5=8F=8A=E5=85=B6=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=EF=BC=89=E3=80=81=E5=A4=9A=E5=8F=B0=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E6=9D=A1=E4=BB=B6=EF=BC=88=E5=85=B1=E4=BA=AB=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=90=8C=E4=B8=80=E8=B7=AF=E5=BE=84=E3=80=81=E5=AF=B9?= =?UTF-8?q?=E6=97=B6=E3=80=81NFS=20=E4=B8=8A=20SQLite=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E8=AF=9D=EF=BC=89=E3=80=81=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=A6=81=E5=81=9C=E6=8E=89=E5=85=A8=E9=83=A8=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E3=80=81=E5=A4=9A=E8=BF=9B=E7=A8=8B=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9Bdeploy/README=20=E8=A1=A5=E5=A4=9A?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E3=80=82=E6=8C=89=E9=9C=80=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E7=9A=84=E9=94=81=E6=94=B9=E6=88=90=E6=8C=89=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=8F=96=EF=BC=88=E6=AF=8F=E6=97=A5=E4=B8=8A=E9=99=90=E5=87=A0?= =?UTF-8?q?=E4=B8=AA=E9=94=AE=E4=B9=8B=E9=97=B4=E4=B9=9F=E6=95=B0=E5=BE=97?= =?UTF-8?q?=E5=87=86=EF=BC=89=EF=BC=8C=E5=87=BA=E5=85=B7=E7=9A=84=E9=94=81?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=85=A8=E7=AB=99=E4=B8=80=E6=8A=8A=EF=BC=88?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E4=B8=8A=E9=99=90=E6=98=AF=E5=85=A8=E7=AB=99?= =?UTF-8?q?=E7=9A=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +- README.md | 94 +++++++++++++++++++++++++++++++------ backend/app/apps/resolve.py | 5 +- backend/app/ledger/issue.py | 5 +- deploy/README.md | 5 +- scripts/dev.sh | 2 + scripts/prod.sh | 28 ++++++++++- 7 files changed, 121 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a2dfa22..9137ed6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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}\""] diff --git a/README.md b/README.md index 1e3c4c17..a6b8bd3c 100644 --- a/README.md +++ b/README.md @@ -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。 + --- ## 常见问题 diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 0ce0bfe9..d04ee995 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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" diff --git a/backend/app/ledger/issue.py b/backend/app/ledger/issue.py index ee94a0a5..77308a1c 100644 --- a/backend/app/ledger/issue.py +++ b/backend/app/ledger/issue.py @@ -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: diff --git a/deploy/README.md b/deploy/README.md index db66533e..22d0bfab 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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「多进程」。 ## 升级 diff --git a/scripts/dev.sh b/scripts/dev.sh index 773f523b..28fd4b99 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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=$! diff --git a/scripts/prod.sh b/scripts/prod.sh index a00ce384..fe688b39 100755 --- a/scripts/prod.sh +++ b/scripts/prod.sh @@ -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 起几个工作进程(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"