《AI Agent 工程实战》5.1 Schema 设计:会话/消息/运行/记忆表 + 迁移

ResearchCopilot 的数据层从四张表开始:sessions、messages、runs、memories。本节给出可直接执行的 DDL,说明为什么消息要存原始 JSON 而不是拆成列,用 psycopg 真连本机 PostgreSQL 17.11 跑通建表、写入与查询,再配一套带版本表与咨询锁的迁移脚本。

5.1 Schema 设计:会话/消息/运行/记忆表 + 迁移

前四章把 ResearchCopilot 的 REST 接口、JWT 鉴权、租户隔离都立起来了,但直到现在,用户的一次提问、模型的每轮回复、每次工具调用、每个后台任务,全都活在进程内存里——进程一重启,什么都不剩。一个真正能用的研究助理,必须把「发生过什么」落进数据库。这一节就搭数据层的第一块砖:Schema。

本节把 ResearchCopilot 推进到数据层:用四张表(sessions / messages / runs / memories)承载会话、消息、运行与记忆,配一套带版本表的迁移机制,并用 psycopg 3.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, ...) 复合索引,不必先 join sessions 再过滤。代价是写入时多一个字段、要保证它和 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 状态机原子更新、行锁防重复执行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距