5.1 Schema 设计:会话/消息/运行/记忆表 + 迁移
前四章把 ResearchCopilot 的 REST 接口、JWT 鉴权、租户隔离都立起来了,但直到现在,用户的一次提问、模型的每轮回复、每次工具调用、每个后台任务,全都活在进程内存里——进程一重启,什么都不剩。一个真正能用的研究助理,必须把「发生过什么」落进数据库。这一节就搭数据层的第一块砖:Schema。
本节把 ResearchCopilot 推进到数据层:用四张表(
sessions/messages/runs/memories)承载会话、消息、运行与记忆,配一套带版本表的迁移机制,并用psycopg3.3.6 真连本机 PostgreSQL 17.11 跑通建表、写入与查询。
5.1.1 四张表,四种生命周期
先别急着写 DDL,想清楚这四类数据各自的「寿命」与「访问模式」,表结构基本就定了:
| 表 | 承载什么 | 增长速率 | 典型访问 |
|---|---|---|---|
sessions | 一次对话的容器(标题、归属) | 慢 | 按 workspace 列会话、按 id 取单个 |
messages | 对话里的每一条消息(含工具调用) | 最快 | 按 workspace + session 取最近 N 条 |
runs | 一次 Agent 执行的状态机 | 中 | 按状态轮询、按 id 原子跃迁 |
memories | 跨会话的长期记忆 | 中 | 按 workspace + user 检索 |
这张表里最关键的判断是:messages 是唯一的高增长表,也是唯一需要在设计阶段就想清楚索引的表。它随用户使用线性膨胀,一次多跳检索动辄产生几十条消息;而 sessions 和 memories 的量级要小两三个数量级。所以后面 5.3 的查询优化几乎全部围绕 messages 展开。
runs 为什么不能并进 sessions?因为一个会话可以发起多次运行,而且运行的生命周期与并发语义完全不同:会话是「长期存在、只追加」,运行是「短期存在、有状态、会被多个 worker 争抢」。把它单独成表,才能在 5.2 里用行锁和条件更新精确控制「谁在跑这一次」。attempts 与 error 两列也是为它预留的——重试次数与失败原因属于运行,不属于会话。
5.1.2 四张表的 DDL
migrations/001_init.sql 一次建全四张表。注意每张表都带 workspace_id——这是第 4 章行级租户隔离在数据层的落点,任何查询都必须先按它过滤:
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id text NOT NULL,
title text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE messages (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id text NOT NULL,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('system','user','assistant','tool')),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id text NOT NULL,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
attempts int NOT NULL DEFAULT 0,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz
);
CREATE TABLE memories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id text NOT NULL,
user_id text NOT NULL,
kind text NOT NULL DEFAULT 'fact',
content text NOT NULL,
embedding real[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz
);
CREATE INDEX idx_sessions_ws_updated ON sessions (workspace_id, updated_at DESC);
CREATE INDEX idx_runs_ws_status ON runs (workspace_id, status, created_at);
几个刻意的选择,逐条说明:
- 主键用
uuid(sessions/runs)而不是自增整数:会话和运行的 id 会出现在 URL 和 SSE 事件里,自增整数让攻击者能枚举别人的资源;gen_random_uuid()是 PG 13+ 内置函数,无需装pgcrypto。 messages用bigint IDENTITY:它是高增长表,自增整数比 uuid 小得多(16 字节 vs 8 字节),索引也更紧凑;消息 id 从不出现在对外接口里,没有枚举风险。workspace_id在messages上是冗余的(它可由session_id推出),但故意冗余:这样「按 workspace + session 取消息」这条最热的查询可以直接走(workspace_id, session_id, ...)复合索引,不必先 joinsessions再过滤。代价是写入时多一个字段、要保证它和sessions.workspace_id一致——这个一致性由服务层在写入时保证。embedding用real[]:本机容器是纯postgres:17-alpine,没装 pgvector,用原生数组足以演示「记忆带向量」的形状;生产里换成vector(1024)+ HNSW 索引即可,属于/posts/postgresql/专题的深水区。
5.1.3 为什么消息存原始 JSON 而不是拆成列
看 messages 的 payload 列:它是 jsonb,存的正是卷一 minagent.types.Message 序列化后的完整对象。为什么不拆成 content text、tool_call_name text、tool_call_args jsonb 这样的列?
因为消息的形状天然是递归、可变的。一条 assistant 消息可以带零个或多个 tool_calls,每个 ToolCall 有自己的 id / name / arguments;tool 消息用 tool_call_id 回指;模型供应商还会不断往消息里加新字段(思考块、缓存标记、多模态内容块)。如果拆成列,tool_calls 就得再建一张子表,arguments 这种任意结构又得再序列化——拆了一层,底下还是一层 JSON,复杂度翻倍,收益为零。
更实际的理由是读路径:Agent 需要的是「把这一会话的消息按顺序完整还原成 list[Message] 喂回模型」,这正是一次 SELECT payload ORDER BY id 就能做到的事。存 JSON 意味着还原时零转换成本,而拆成列则要在 Python 里重新拼装、还要处理列与嵌套的不一致。存进来什么样,读出来就什么样,这是消息表最重要的性质。
代价是:不能对消息内容建普通的 B-tree 索引,要按内容检索得用 jsonb 的 GIN 索引——而这恰恰是「哪些索引不该加」的典型(5.3 会讲)。下面这条查询直接从 payload 里取出工具调用的名字与参数,完全不用改表结构:
SELECT payload->'tool_calls'->0->>'name' AS tool,
payload->'tool_calls'->0->'arguments'->>'q' AS q
FROM messages
WHERE session_id = $1 AND payload ? 'tool_calls';
这就是 JSON 存法的红利:字段是「模型协议的一部分」,不是「表结构的一部分」;协议演进时,Schema 一行都不用动。
顺带说一句时间戳:四张表统一用 timestamptz 而不是 timestamp。前者带时区、内部按 UTC 存储,后者是「裸时间」,跨时区或跨夏令时的机器读出来会漂。Agent 服务往往分布在不同区域的机器上,timestamptz 是唯一不会让你在半夜调时差的选项。默认值统一写 now(),由数据库生成,而不是由 Python 传——这样即便某个写入路径忘了带时间,落库的也永远是服务端时间。
5.1.4 真跑:建表、写入、查询
本机已用 Docker 起好书稿专用容器(Postgres 17.11,映射到非默认端口 15435)。读者要复现,先起同样的容器:
docker run -d --name ab-pg \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=research \
-p 15435:5432 postgres:17-alpine
连接串是 postgresql://postgres:postgres@localhost:15435/research。注意端口是 15435 不是 5432——本机 5432 被别的容器占了,写错就连不上。用 psycopg 3.3.6 真连真跑:插入一个会话、四条消息、一个 run、一条记忆,再查回来:
import json, psycopg
DSN = "postgresql://postgres:postgres@localhost:15435/research"
with psycopg.connect(DSN) as conn:
sid = conn.execute(
"INSERT INTO sessions (workspace_id, title) VALUES (%s, %s) RETURNING id",
("ws-acme", "向量检索调研")).fetchone()[0]
msgs = [
("user", {"role": "user", "content": "帮我查一下 pgvector 的召回率"}),
("assistant", {"role": "assistant", "content": "",
"tool_calls": [{"id": "c1", "name": "search",
"arguments": {"q": "pgvector recall"}}]}),
("tool", {"role": "tool", "tool_call_id": "c1", "content": "命中 3 篇"}),
("assistant", {"role": "assistant", "content": "召回率见报告第 2 节。"}),
]
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO messages (workspace_id, session_id, role, payload) VALUES (%s,%s,%s,%s)",
[("ws-acme", sid, r, json.dumps(p, ensure_ascii=False)) for r, p in msgs])
conn.execute("INSERT INTO runs (workspace_id, session_id, status) VALUES (%s,%s,'queued')",
("ws-acme", sid))
conn.execute("INSERT INTO memories (workspace_id, user_id, kind, content) VALUES (%s,%s,%s,%s)",
("ws-acme", "u-42", "preference", "偏好带引用的简短结论"))
conn.commit()
真跑输出(Python 3.14.6,服务端 PostgreSQL 17.11):
session_id = 1ac365cd-430f-481d-8ccf-293e301a679e
#1 user '帮我查一下 pgvector 的召回率'
#2 assistant '' tool_calls=[{"id": "c1", "name": "search", "arguments": {"q": "pgvector recall"}}]
#3 tool '命中 3 篇'
#4 assistant '召回率见报告第 2 节。'
-- 只取工具调用,不用改表结构 --
tool=search q='pgvector recall'
四条消息按 id 顺序读回来,第 2 条的 tool_calls 原样保真——这正是喂回模型时需要的形状。最后一行是上面那条 JSON 查询的结果:不 join、不改表,直接从 payload 里把 search 的参数抠了出来。
5.1.5 迁移:版本表 + 有序脚本
表建好了,但下一个问题更现实:Schema 会变。今天加一个 memories.kind,明天给 runs 加一个 trace_id。如果靠手工 ALTER TABLE,线上、测试、同事的本地库迟早长得不一样,「我这能跑啊」的经典事故就是这么来的。解法是迁移:每个结构变更写成编号脚本,按顺序、只应用一次,并记录「已应用到第几号」。
# migrate.py
import pathlib, psycopg
DSN = "postgresql://postgres:postgres@localhost:15435/research"
MIGRATIONS = pathlib.Path("migrations")
def migrate(dsn: str = DSN) -> list[str]:
applied: list[str] = []
with psycopg.connect(dsn, autocommit=True) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations ("
"version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())")
conn.execute("SELECT pg_advisory_lock(42001)") # 同一时刻只允许一个进程改结构
try:
done = {r[0] for r in conn.execute("SELECT version FROM schema_migrations")}
for path in sorted(MIGRATIONS.glob("*.sql")):
if path.name in done:
continue
with conn.transaction(): # 每个迁移是一个事务
conn.execute(path.read_text())
conn.execute("INSERT INTO schema_migrations(version) VALUES (%s)", (path.name,))
applied.append(path.name)
finally:
conn.execute("SELECT pg_advisory_unlock(42001)")
return applied
if __name__ == "__main__":
print("applied:", migrate())
三个设计点值得单独说:
schema_migrations版本表:记录已应用的文件名与时间。判断「要不要跑」只依赖它,不依赖「表存不存在」——后者在「建了表但没记版本」的半截状态下会误判。pg_advisory_lock咨询锁:多个进程(API、worker、CI)同时启动时会争抢迁移。咨询锁是数据库级的互斥量,抢到的那个跑迁移,其它进程阻塞等待,跑完再发现「无事可做」。这比「文件锁」可靠,因为它跨机器也成立。- 每个迁移包在一个事务里:DDL 与「写版本号」要么都成功、要么都回滚。PG 支持事务性 DDL,这点比 MySQL 强——不会出现「表建了但版本没记」的裂缝。
真跑两次,第一次应用,第二次幂等跳过:
$ python migrate.py
applied: ['001_init.sql']
$ python migrate.py
applied: []
为什么不能手改表,值得单独强调。假设你直接在 psql 里敲一句 ALTER TABLE runs ADD COLUMN trace_id text,本机立刻生效、看着很爽——但这条改动没有任何记录:同事拉下代码后不知道要执行它,CI 里的空库跑测试直接报「column trace_id does not exist」,预发环境的表结构和生产又对不上。迁移脚本把「结构变更」变成了受版本控制的代码:它随 PR 评审、随代码发布、在任何环境可重放。手改表省下的那 10 秒,迟早要用一次线上事故来还。同理,迁移只允许「向前加」,禁止写 DROP COLUMN 直接删——真要下线一列,先发布一版不再读写它的代码,隔一个版本再删,给回滚留窗口。
5.1.6 真实环境与验收清单
本节的耗时与输出全部来自本机真连的 PostgreSQL 17.11(容器 ab-pg)。真实生产环境里还有三件事需要补:
- 连接池:示例每次
psycopg.connect都新建连接,生产里要用psycopg_pool复用,否则每请求一次 TCP 握手 + 鉴权会拖垮吞吐。 real[]→ pgvector:记忆检索真正上线时,embedding列应换成vector(1024)并建 HNSW 索引,把「按向量找相似记忆」下推给数据库。- 分区的临界点:
messages涨到亿级时,按created_at做范围分区能显著改善维护与归档;在此之前,普通表 + 正确索引足够。
本节验收清单:
- 四张表能一键建出,
messages.session_id外键级联删除生效 - 消息以
jsonb存原始对象,能原样读回tool_calls - 迁移脚本可重复执行,第二次输出
applied: [] -
schema_migrations记录了已应用的版本
数据库有了,但 runs 这张表还只是「躺着的状态字段」。下一节 5.2 事务与并发:run 状态机原子更新、行锁防重复执行
会让它动起来,并解决多个 worker 抢同一个任务时的重复执行问题。想先补 SQL 与事务的基础,可看站内 /posts/postgresql/ 专题。
一句话总结这一节:Schema 是 Agent 服务的记忆体,迁移是它的版本控制系统。四张表定好边界,剩下的三节都在回答「怎么把它用对」。
阅读导航:上一节:4.3 工具权限与审批:高危工具二次确认 · 下一节:5.2 事务与并发:run 状态机原子更新、行锁防重复执行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。