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 通过。
22 KiB
English | 简体中文
PostgreSQL 与多进程
这篇讲把应用库换成 PostgreSQL、从 SQLite 导入数据、备份,以及起多个工作进程、多台机器时的规矩。给运维看。
什么时候换成 PostgreSQL
缺省的应用库是数据目录里的 SQLite:单机、单进程,什么都不用装。要起多个工作进程,就得换成 PostgreSQL。
换过去的是应用库(工作流、运行、事件、审批、待办、数据目录、评测……)和执行断点。工件、上传表格的版本库、原件、附件、记录表、沙箱工作区和 .secret_key 仍是数据目录里的文件,所以 AGENTLAB_DATA_DIR 照样要设。
用 PostgreSQL
设一个环境变量:
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)。
- 把
- 多进程时,每个进程另有一条专门收发进程间通知(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 的「用 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。停在审批上的、中断了的运行,导过去之后照样能接着跑。
# 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 的备份接口拷:
sqlite3 data/records/x.v1.db ".backup /备份目录/x.v1.db"
多进程
应用库是 PostgreSQL 时,可以起几个工作进程,指着同一个库、同一个数据目录:
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 提交、源码指纹)和库结构版本。源码指纹包括依赖锁文件,所以只升了依赖也算版本不同。
- 库里还有活着的、版本不同的进程时,新版本的进程在补列之前就拒绝启动;报错里列出全部这样的进程、各是什么版本、心跳还在不在走。同版本的照常加入。
升级步骤:
- 停掉全部旧版本的进程。平稳关停的马上删掉自己的登记;强杀的,要等它的租约过期,新进程启动时会等。
- 备份:
pg_dump加数据目录(见备份)。 - 起新版本:第一个进程补列补索引,之后的照常加入。
只停了一部分就起新版本,新版本起不来,旧版本照常跑,不会两边各按各的规矩写同一批行。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。