Files
AgentLab/docs/zh-CN/postgresql-and-scaling.md
T
ThearchyHelios 2714890d0d 文档重新梳理:README 精简成介绍和索引,细节按主题拆进 docs/zh-CN 与 docs/en(17 篇一一对应),英文文档不含中文
README.zh-CN.md / README.md 只留:这是什么、为什么用它、主要功能(按领域一行一条,链到文档)、快速开始、部署、文档索引、
开发与测试、许可(原文不动)。原来的每一节按读者和任务分到 17 篇主题文档:ask-data、workflows、evidence、
data-sources(新增:接入数据库、只读保证、上传表格、跨库合并、遮罩列)、data-catalog、tools(新增:工具、审批档位、
能力登记、端点、网络规则——它们管所有工作流,不只应用)、apps、evaluation-and-ledger、schedules(含通知)、deployment、
configuration、postgresql-and-scaling、identity-and-security、architecture、development、upgrade-notes(按时间倒序,
每波先写要做的事)、faq。删掉的只有模块名(api/copilot_gen.py、chat/drive.py、data/schema_view.py)、重复的话和
「以前…现在…」的叙述;操作步骤、命令、配置名、限制、权限边界都有归宿。中文是原文,英文按新的中文写;英文里界面文字
只写英文名,开头注明界面目前只有简体中文,中文占位符换成英文占位符,中文数字的例子改成不依赖中文的说法。
数据剖析可以同时剖析 2–3 张表(baf23998)收进 data-catalog。
新增 scripts/check-docs.py:英文文档(README.md、docs/en/**)不许有中日韩字符和全角符号(代码块也算),
README、docs/**、deploy/README.md 的相对链接和锚点都要通,docs/zh-CN 与 docs/en 文件一一对应、标题层级相同。
中文提示里指向 README 章节的地方改指新文档:cluster/guard.py、cluster/version.py、db/base.py 两处报错,
data/schema_view.py 文档串,.env.example 三处,scripts/prod.sh、scripts/test-postgres.sh,terms.ts 两处、
DataSourcesTab.tsx、check-manage.mjs 注释,deploy/README.md 三处链接。deploy/agentlab.env 未动。
验收:check-docs 0 问题(英文文档 18 个 0 处中文,相对链接 369 条 0 断链,18 对标题层级一致);相关后端测试
117 通过、7 跳过;tsc -b、check-copy 通过。
2026-10-21 10:23:26 +08:00

252 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[English](../en/postgresql-and-scaling.md) | 简体中文
# PostgreSQL 与多进程
这篇讲把应用库换成 PostgreSQL、从 SQLite 导入数据、备份,以及起多个工作进程、多台机器时的规矩。给运维看。
## 什么时候换成 PostgreSQL
缺省的应用库是数据目录里的 SQLite:单机、单进程,什么都不用装。要起多个工作进程,就得换成 PostgreSQL。
换过去的是应用库(工作流、运行、事件、审批、待办、数据目录、评测……)和执行断点。工件、上传表格的版本库、原件、附件、记录表、沙箱工作区和 `.secret_key` 仍是数据目录里的文件,所以 `AGENTLAB_DATA_DIR` 照样要设。
## 用 PostgreSQL
设一个环境变量:
```bash
AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agentlab # postgresql:// 也认
```
- 要装 `[postgres]` 额外依赖(`environment.yml` 和 Docker 镜像都已带上)。没装就设了这一项,启动时直接报要装什么:`pip install -e 'backend[postgres]' -c backend/requirements.lock`。
- 库要先建好(`CREATE DATABASE agentlab`)。表由服务启动时建;老版本建的库,启动时补列补索引,和 SQLite 一样。
- 启动日志第一句写明用的是哪种库、哪台主机、哪个库(不写密码)。
### SSL 与断点库
- SSL:地址后加 `?sslmode=require`(或 `verify-ca`、`verify-full`;asyncpg 的写法 `?ssl=require` 也认),启动时换成 asyncpg 认的参数。
- 证书文件不写在地址里(asyncpg 不收 `sslrootcert` 这类参数,写了启动就报错),改设环境变量 `PGSSLROOTCERT`、`PGSSLCERT`、`PGSSLKEY`。应用库的 asyncpg 和断点库的 libpq 都认它们。
- 地址里别的 libpq 参数(`application_name`、`connect_timeout` 之类)asyncpg 也不收,同样在启动时报出来。
- 断点是 LangGraph 的四张表:checkpoints、checkpoint_blobs、checkpoint_writes、checkpoint_migrations,不和应用的表重名。缺省放在同一个库;要放别处,设 `AGENTLAB_CHECKPOINT_URL=postgresql://…`(这一项是 libpq 的写法,`sslrootcert` 等可以直接写)。
### 连接池与连接数
- 每个进程常驻 `AGENTLAB_DB_POOL_SIZE`(缺省 10)条连接,高峰再多开 `AGENTLAB_DB_MAX_OVERFLOW`(缺省 20)条。取连接前探活,每条连接用 30 分钟换新。断点库另有一个小池子。
- 一个进程最多 `AGENTLAB_DB_POOL_SIZE + AGENTLAB_DB_MAX_OVERFLOW` 条应用库连接(缺省 10 + 20 = 30),断点库的池子最多 `min(AGENTLAB_MAX_CONCURRENT_RUNS, 20)` 条(至少 4,缺省 20),合起来一个进程最多 50 条。导入命令另占几条。
- PostgreSQL 缺省 `max_connections=100`,还要留几条给 `psql`、备份和超级用户(`superuser_reserved_connections` 缺省 3)。一个进程够用;起几个进程就是几倍。办法三选一:
- 把 `max_connections` 调大,约「进程数 × 50 + 20」;
- 调小两边的池子,比如 `POOL_SIZE=5`、`MAX_OVERFLOW=10`、`MAX_CONCURRENT_RUNS=10`,一个进程最多 25 条;
- 前面放 PgBouncer(见[直连与 PgBouncer](#直连与-pgbouncer))。
- 多进程时,每个进程另有一条专门收发进程间通知(LISTEN / NOTIFY)的连接,不占连接池;跨进程的锁(会话级咨询锁)持锁期间占一条。
### 和 SQLite 一致的行为
换成 PostgreSQL 后,行为和 SQLite 上一致:
- 字符串列不限长(SQLite 本来就不查长度)。
- 文本按字节比大小和排序:列上带 `COLLATE "C"`,和 SQLite 一样,不看建库时选的区域设置。
- 整数列是 BIGINT。
- JSON 列存原文(PostgreSQL 的 json,不是 JSONB:JSONB 会重排对象里的键)。
- 时间存不带时区的 UTC。
- 排序时,可空列的 NULL 放在 SQLite 的位置:升序在前、降序在后。只管直接按列排的;按聚合、coalesce 之类表达式排的不管。列表都另带了 id 之类的决胜键,同名、同时间并列时两种库排出来一样。
- 按 JSON 里的键筛选时,按 JSON 的类型比(true 不等于 1)。
- 两种 SQLite 照存、PostgreSQL 不收的值会被换掉:JSON 里的 NaN / Infinity 写成 null;文本里(JSON 里的、普通文本列里的)的 NUL 字符去掉。
不分大小写的搜索(运行列表按名字、对象索引按注释):文本列是 `COLLATE "C"`,列上的 ILIKE 只认 ASCII 的大小写,所以按建库时的区域设置折大小写。建库用 `en_US.UTF-8`、`C.UTF-8` 这类区域时,É/é、全角A/a 都认;用纯 `C` 区域建的库只认 ASCII。
### 启动时等库
和库的容器一起起、库还在初始化时,启动会等库:
- 按退避重试(1、2、4、5、5… 秒),每次打一条「应用库还没就绪」,最多等 `AGENTLAB_DB_STARTUP_TIMEOUT`(缺省 90)秒,再报错退出。
- 密码不对、库不存在这类等也没用的错误(SQLSTATE 28、3D、42 类)立刻报错。
- 建表、补列补索引在库里的咨询锁里做,默认数据在跨进程的锁里补。几个工作进程同时起,这些都只做一次。
## 和库一起起
- **部署**:离线包的 `./deploy.sh install --postgres` 带一个内置 PostgreSQL;`migrate-to-postgres` 把 SQLite 的部署切过去(见 [deploy/README.md](../../deploy/README.md) 的「用 PostgreSQL」)。
- **开发**:`./scripts/dev.sh --postgres` 起(已经有就复用)一个本地的 `postgres:16` 容器 `agentlab-dev-postgres`:
- 只开在 `127.0.0.1:55432`,数据在 docker 数据卷 `agentlab-dev-pgdata`;
- 密码第一次自动生成在 `data/.postgres-dev/`,后端按 `PGPASSFILE` 读;后端的 `AGENTLAB_DATABASE_URL` 指向这个容器;
- Ctrl-C 只停前后端,库的容器留着下次复用;
- 容器名、端口、数据卷、镜像用 `AGENTLAB_DEV_PG_CONTAINER`、`AGENTLAB_DEV_PG_PORT`、`AGENTLAB_DEV_PG_VOLUME`、`AGENTLAB_DEV_PG_IMAGE` 改(环境变量,或写在 `.env` 里,环境变量优先)。
- 不加 `--postgres` 就和原来一样:SQLite,不用 docker。
## 从 SQLite 搬过来
`python -m app.db copy-to-postgres` 把数据目录里的 `agentlab.db` 和 `checkpoints.db` 导进 PostgreSQL。停在审批上的、中断了的运行,导过去之后照样能接着跑。
```bash
# 1. 停服务(导入会检查:来源旁边还留着 agentlab.db-wal / checkpoints.db-wal,或者目标库上已经有服务连着,就拒绝)
# 2. 备份数据目录(停着服务拷一份)
# 3. 只核对:数来源有多少、目标上已有多少,什么都不写
cd backend && python -m app.db copy-to-postgres ../data postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agentlab --dry-run
# 4. 导入:建表、按外键先后逐表分批插入,结束时逐表按主键核对、断点按条数核对,有没导进去的退出码非零
cd backend && python -m app.db copy-to-postgres ../data postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agentlab
# 5. 设 AGENTLAB_DATABASE_URL(AGENTLAB_DATA_DIR 还指着原来的数据目录),起服务
```
- 目标地址不给就用 `AGENTLAB_DATABASE_URL`;`--checkpoint-url` 把断点导到另一个库;`--work-dir` 放快照(要留出和两个库一样大的空间,缺省是系统临时目录)。
- 读的是 SQLite 备份接口拷出的一份快照,在快照上先补到当前的表结构;来源的内容不改。
- 重跑是幂等的:目标上已有同一主键(或撞了别的唯一约束)的行一律不动,只补上还没有的,不重复、也不覆盖。所以导入之后又在 SQLite 上改过的行,重跑不会带过去;要整份重来,就清空目标库再导。断点同理,已有的不重写。
- 能查到的只有第 1 步那两样。服务连着别的数据目录、另一台机器上的服务连着目标库,查不出来,要自己保证。服务正常停下时会关掉所有连接,-wal 随之消失;还在的是上次强杀留下的,确认服务已停后加 `--assume-stopped`。
- 老库里 SQLite 存得下、PostgreSQL 不收的数据,会先在快照上处理,并在报告里照实列出:
- 老版本补列时没带缺省值、留着 NULL 的 NOT NULL 列,填成模型的缺省值(启动时的迁移在 SQLite 上也这么填,两边存的一样);
- **孤儿行**(外键指向已经不在的行,是 SQLite 当时没强制外键留下的):外键是 SET NULL 的,把外键置空;别的(CASCADE、不可空)不导入,按表列出条数,不算核对失败(父行被删时它们本来就该跟着删)。
- 自增主键的序列接在 SQLite 用过的最大号后面,删过的号不复用。
- 导完后 `agentlab.db` 留在数据目录里也没关系,可以当回退用;它照样算 AgentLab 自己的文件,不能登记成数据源。
## 备份
- PostgreSQL 用 `pg_dump`(应用表和断点在同一个库里,就一次带上);数据目录照旧整份拷。
- 两样最好在停服务时一起做。不停服务的话,先 dump 库,再拷数据目录:工件、原件按内容寻址、只增不改,库里引用到的文件拷数据目录时都在。
- 例外是填报的记录表 `records/*.db`:它们是原地追加写的 SQLite 文件,开着服务直接拷,可能拷到写了一半的。要么停服务拷,要么一个个用 SQLite 的备份接口拷:
```bash
sqlite3 data/records/x.v1.db ".backup /备份目录/x.v1.db"
```
## 多进程
应用库是 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 | 调度循环一拍的间隔 |
### 直连与 PgBouncer
有几样连接依赖会话状态:收发进程间通知的那一条(LISTEN)、跨进程的锁(会话级咨询锁,取锁和放锁要在同一条会话上)、断点库(预备语句,建表时取会话锁)。
- 普通查询走 `AGENTLAB_DATABASE_URL`。
- 设了 `AGENTLAB_DATABASE_DIRECT_URL`(写法同上)时,上面这几样走直连地址;断点库缺省也走它,`AGENTLAB_CHECKPOINT_URL` 另给的除外。
- 不设,就全用同一个地址。
三种部署:
| 部署 | 怎么设 |
| --- | --- |
| 直连 PostgreSQL | 只设 `AGENTLAB_DATABASE_URL`。最简单,多进程建议这样 |
| PgBouncer 会话模式(`pool_mode=session`) | `AGENTLAB_DATABASE_URL` 指 PgBouncer 就行:会话模式下一个客户端连接独占一条服务端连接,会话状态都在 |
| PgBouncer 事务模式(`pool_mode=transaction`) | `AGENTLAB_DATABASE_URL` 指 PgBouncer,另设 `AGENTLAB_DB_PGBOUNCER=1` 和 `AGENTLAB_DATABASE_DIRECT_URL`(直连 PostgreSQL,或指 PgBouncer 的会话模式那个库)。没给直连地址时拒绝启动 |
`AGENTLAB_DB_PGBOUNCER=1` 做的事(只对普通地址):
- 不用客户端的连接池,交给 PgBouncer。
- 关掉 SQLAlchemy 和 asyncpg 的预备语句缓存:事务模式下,下一条语句可能落在另一条服务端连接上,缓存着的预备语句在那边不存在。
- asyncpg 每条语句仍先预备再执行(在同一个事务里、同一条服务端连接上),名字用随机的,不和别的客户端留下的撞名。PgBouncer 那边要配 `server_reset_query = DISCARD ALL` 加 `server_reset_query_always = 1`,收掉留下的预备语句;或者用 1.21 以上的版本,开 `max_prepared_statements`。
- 事务级的咨询锁(发名额、同源队、建表时用)在事务模式下照常成立:取锁、放锁在同一个事务里。
这几条照 SQLAlchemy、asyncpg、psycopg 的文档和代码核过,但没有在真的 PgBouncer 上跑过。
### 认领、租约与接手
- 每个进程在库里登记一行(主机、pid、启动时的随机串),每一拍心跳续一次租约。
- 运行、问数据的一轮、后台任务(探查、起草、剖析、评测、影子起草)、处理中的文档、执行中的发布单,都记着认领它的进程。租约按进程算:进程没了,它认领的行一起到期。
- 平稳关停的进程会交班、删掉自己的登记,别的进程马上接手;强杀的,要等租约过期。
- 启动时的整理只碰属主不在了的行,绝不碰别的活进程的。
属主不在了之后:
| 什么 | 怎么处理 |
| --- | --- |
| 在跑的运行 | 标中断(可以从断点继续),补一条「服务上次未正常关闭」。不自动重跑有副作用的步骤,由人点「继续运行」 |
| 排队的运行 | 标失败(从没开始执行,没有断点,「恢复」只会伪造一份结果),补一条失败原因 |
| 停在审批上的运行 | 不属于任何进程:谁收到决定谁接着跑,同一个决定只会被一个进程执行(审批和运行都是带条件的更新) |
| 问数据的一轮 | 还在建图的标中断(可以「重试本轮」);已经发起运行的,接手的进程接着等这次运行,照常复核收尾 |
| 起草、剖析、探查、评测、影子起草 | 标中断(没做完的表、题记成停止),在界面上点「重试」接着做 |
| 处理中的文档、执行中的发布单 | 文档标失败(请重新上传);发布单改成停下,停在没做完的那一步,可以接着做 |
| 待审批、结果未知的写入 | 缺了待办的补建;结果未知、还没安排核实的补上核实(所属运行还在别的活进程里执行的不碰) |
### 停止
运行、问数据的一轮、后台任务在别的进程上跑时,「停止」写一条给属主的请求(PostgreSQL 上用 NOTIFY 叫醒它,最迟一拍心跳)。属主照本进程的规矩停下、收尾,`run.cancelled` 带上是谁停的。界面上和单进程时一样。属主不在了的,按上表接手。
### 运行并发
每段执行(发起、审批之后接着跑、继续运行)开始前要拿一个名额。名额按来源分四类,几个进程合起来算:
| 类别 | 包括 | 缺省优先级 |
|---|---|---|
| 交互 | 问数据、画布试运行、手动发起的正式运行、蓝图试运行、个人应用刷新 | 40 |
| 应用 | 应用入口、按需视图、钩子、接口 | 30 |
| 定时 | 定时任务、事件触发 | 20 |
| 系统 | 评测、口径召回、对账、补算、按口径正式出具 | 10 |
- 全站上限是 `AGENTLAB_MAX_CONCURRENT_RUNS`(缺省 20)。
- 其中一部分**留给交互**:缺省是全站上限的 1/4,至少 1,至多全站上限减 1。应用、定时、系统三类合起来永远用不到留底,所以有人问数据、试运行时总有名额。
- 留底以外的名额,其他三类可以**借用**:三类各自的上限缺省就是「全站上限 − 留底」,没人用问数据时评测也能用满这些名额;可以在设置里调小。交互自己的上限缺省等于全站上限。
- 等名额时按优先级排,同级先来先得;已经在执行的不打断。
- 在「设置 → 偏好设置 → 运行并发」里改(管理员)。改了之后,最迟几秒各进程按新的值发名额。
- 单进程时也照这套规则发名额。满载时评测、召回会慢一些,换来问数据总有名额。
### 调度当班
只有一个进程当班(库里的一份租约)。下面这些事只在当班的进程里做:定时认领、事件派发、Webhook 投递、流程的时限和核实、每天的汇总、各类周期清理、口径比对的补比和自动召回的续发,以及接手不在了的进程留下的行。
- 其余进程待命;当班的没了,待命的接班。平稳关停的进程马上交班。
- 切换的那一会儿不会重复:定时认领是带条件的更新,触发和投递记录有唯一约束。
### 实时事件
- 运行事件、问数据一轮的事件都先落库。页面的 WebSocket / SSE 连到的进程不在执行它时,从库里按序号追。
- 执行的进程每提交一批事件,发一个 NOTIFY(只带运行或轮次的 id 和序号),别的进程收到就回库里读。NOTIFY 丢了也有兜底:运行的流每 2 秒、问数据的流每一拍心跳回库里看一次。
- 不落库的两样——正文 token 和思考增量、问数据助手的保活心跳——只转给有人在看的进程(攒一小段放进 NOTIFY)。
- 回放、重连、心跳的行为不变。序号由执行它的进程发;别的进程要往执行中的运行里记事件,交给执行的进程记;停着的运行追加事件时,按库里的最后一条接号(带条件的更新,防撞号)。
### 进程间一致的状态
- **全站一致、记在库里的**:每日花费账(裁判、核查、评测),先占住那一行再读写;访问密钥每分钟的限流;登录失败的退避;同源队(同一个数据源上的起草、剖析、探查一个接一个)。
- **用跨进程锁(PostgreSQL 的咨询锁)的**:按需探查、加入探查范围、上传表格的版本存储、记录表的写入、按需视图的单飞和每日上限、个人应用刷新、出具、召回、补算、定时发起。
- **在各进程内存里、改了会通知的**:登录设置、对外访问地址、向量模型、能力登记的治理快照、数据源的连接池、应用页面的缓存。改的那个进程马上刷新,别的进程收到通知(或最迟一拍心跳)跟着重读。
- **纯性能的缓存**留在各进程里,过期无害:问数据的向量、取值猜测、表结构快照、取数快照的遮罩集合、OIDC 的元数据、DoH 解析、xlsx 的列扫描、口径比对的 SQL 形状。
- 访问密钥的请求记录先攒在各进程自己的内存里,每一拍心跳写库。
- 各进程自己的并发闸门(口径比对、自动对比、起草的模型调用、上传解析)按进程算,进程多了,总量跟着多。
### 多台机器
几台机器要用同一份数据目录:
- 数据目录放在共享存储(NFS 之类)上,每台挂在**同一个绝对路径**(库里记着上传表格各版本库文件的绝对路径)。
- 各台机器的时钟要对时(NTP)。租约按库的时钟算,机器之间差多少都不影响谁算活着;但「停了两个租约时长」这类判断,看的是各机器按本机时钟写下的时间。
- 库服务器自己的时钟往前跳超过一个租约时(少见),所有进程续租时都会发现自己的租约已经过期,一起隔离:执行到一半的运行标成中断,在运行详情里点「继续运行」从断点接着执行;后台任务按「属主不在了」的规矩处理;各进程自动换身份重新登记,之后发起的照常。往回跳时不隔离,只是真没了的进程要多等跳回去的那么久才被接手。
- 本机墙钟跳了(NTP 一步校时、手动改时间)不会隔离:判续租晚没晚,看的是计入休眠的单调钟;对不上时以库里的租约为准。
- 文件都是先写临时文件再改名,读的一方看不到写了一半的;写上传版本库、记录表时,在库里的锁下一个接一个。
要说实话:数据目录里还有 SQLite 文件——上传表格的各版本库(发布后只读)、填报的记录表(`records/*.db`,原地追加写)、沙箱里代码自己建的库。SQLite 文件放在网络文件系统上,读写一致性靠文件锁,而 NFS 上的文件锁不可靠:一台机器在追加时另一台读,可能读到不一致的页。所以多台机器时,记录表写得多的部署,建议用一台机器多进程,或者把数据目录放在支持可靠文件锁的存储上。
沙箱会话(microVM)在各进程自己手上,不跨进程共享。
### 升级
不支持新旧版本的进程同时连着一个库,代码里拦着:
- 每个进程登记时带上代码版本(包版本、构建号或 git 提交、源码指纹)和库结构版本。源码指纹包括依赖锁文件,所以只升了依赖也算版本不同。
- 库里还有活着的、版本不同的进程时,新版本的进程在补列之前就拒绝启动;报错里列出全部这样的进程、各是什么版本、心跳还在不在走。同版本的照常加入。
升级步骤:
1. 停掉全部旧版本的进程。平稳关停的马上删掉自己的登记;强杀的,要等它的租约过期,新进程启动时会等。
2. 备份:`pg_dump` 加数据目录(见[备份](#备份))。
3. 起新版本:第一个进程补列补索引,之后的照常加入。
只停了一部分就起新版本,新版本起不来,旧版本照常跑,不会两边各按各的规矩写同一批行。Docker 部署的 `./deploy.sh upgrade` 就是按这个顺序做的。
## 测试
- `scripts/test-postgres.sh` 用本机 docker 已有的 `postgres:16` 镜像起一个临时容器(只绑 127.0.0.1 的随机端口,跑完删掉),在上面跑后端全量;参数原样交给 pytest。只对 SQLite 成立的测试标了 `sqlite_only`,在 PostgreSQL 上跳过。
- `scripts/test-multiprocess.sh` 用本机 docker 的 `postgres:16` 起临时库,在上面起两个工作进程,用演示模型跑端到端(`backend/tests/test_multiprocess_e2e.py`,缺省跳过,`AGENTLAB_MULTIPROCESS_E2E=1` 时跑)。它验证:运行由任一进程认领;另一个进程上的流收得到实时事件;同一个决定只执行一次;跨进程停止;定时只触发一次;评测和问数据并发时优先级生效;每日花费上限不越过;强杀一个进程之后被接手或照实标中断。
- 前端检查脚本的 `--stacks`(隔离的副本服务)只支持 SQLite。