← blog 架构与系统 · 2026-08-31

mini-agent 数据库现状梳理:四个 SQLite 文件与十张表的关系图景

从数据表关系梳理 mini-agent:4 个 SQLite 库、10 张业务表,覆盖待办同步、多租户认证配额、幂等任务与记忆压缩,并给出主键设计取舍与迁移优先级。

8 min read

要理解一个事件驱动的 agent 系统,事件流只是表象,数据最终落到哪里、以什么结构落,才决定了同步、配额、幂等这些行为能不能成立。mini-agent 的全部持久化都建立在 SQLite 上:4 个数据库文件、10 张业务表,外加一个纯内存的检查点库。这篇从表关系出发,把整个数据层梳理一遍。

总览:四个库各管一件事

数据库文件表数量职责
data/todos.db3待办、定时消息、推送令牌
data/users.db5用户认证、会话、邀请码、配额
data/execution_tasks.db1Web 消息的异步任务执行
data/memories.db1对话记忆的压缩摘要
:memory:(内存)3LangGraph 检查点 + 消息时间戳

一个值得先记住的整体特征:跨库之间没有任何物理外键。execution_tasks.owner_user_id 逻辑上指向 users.id,memories.thread_id 逻辑上挂在用户线程命名空间下,但它们分属不同文件,SQLite 的外键约束跨不了库,所以这些关联全靠应用层保证。

todos.db:双节点同步的三张表

这个库承载了本地与云端两台机器之间的双向复制,是三张表里设计最有讲究的。

todos 与 scheduled_messages:复合主键解决双写冲突

CREATE TABLE todos (
    id INTEGER NOT NULL,
    origin INTEGER NOT NULL,       -- 0=本地, 1=云端
    content TEXT NOT NULL,
    due_at TEXT,
    done INTEGER DEFAULT 0,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    updated_by TEXT NOT NULL DEFAULT 'legacy',
    PRIMARY KEY (origin, id)
);

scheduled_messages 结构类似,只是把 due_at/done 换成了 action/trigger_at/cron_expr/enabled。

主键为什么是 (origin, id) 而不是自增 id?因为两台机器各自生成自增 ID,本地插一条 id=42,云端也可能插一条 id=42,如果主键只有 id,同步时必然撞键。origin 前缀把两台机器的 ID 空间隔开,(0, 42) 和 (1, 42) 是两个合法的独立行。

origin 不是冲突仲裁者,只是所有权标记;真正的冲突裁决靠 updated_at + updated_by 组成的修订元组:时间戳不同时取新,时间戳相等时 updated_by(稳定的节点 ID)打破平局。这套修订元组是 upsert_sync_row 幂等重放的基础——游标边界重放同一条记录多少次,结果都收敛到同一行。

push_tokens:推送令牌的简单存储

CREATE TABLE push_tokens (
    token TEXT PRIMARY KEY,
    platform TEXT NOT NULL,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

APK 端注册的 FCM 令牌。云端实例只做存储,本地调度器在触发提醒前通过受鉴权保护的对等同步接口拉取令牌,再走 FCM 下发,Telegram 作为降级通道。

users.db:多租户的认证与配额中心

UserManager 独占这个库,五张表一次性建好:

表主键解决的问题
usersid 自增邮箱唯一,密码 argon2id 哈希,role 区分管理员,disabled_at 软封禁
sessionstoken可过期(24h)、可吊销的登录态,外键指向 users(id)
invite_codescode注册不是开放入口,邀请码限次数、限有效期
password_reset_codescode管理员发起的密码重置,一次性使用
quota_usage(user_id, bucket)按 UTC 日桶累计 task_calls/query_calls/chat_calls/pi_seconds

关系图:

users ──1:N──→ sessions                 一个用户多个会话
users ──1:N──→ invite_codes             创建者 / 使用者两个方向都指向 users
users ──1:N──→ password_reset_codes     目标用户 / 发起管理员
users ──1:N──→ quota_usage              每用户每天一行

配额表的设计值得注意:bucket 是 UTC 日期,主键 (user_id, bucket) 让每日用量统计变成一次原子 UPDATE ... SET x = x + 1,不需要额外的计数服务。管理员豁免配额门槛,但用量照常记录。

password_reset_codes 用随机 code 做主键,这一点后文会单独讨论。

execution_tasks.db:幂等的异步任务表

/message 接口把每条消息变成一个可查询的后台任务,全库只有这一张表:

CREATE TABLE execution_tasks (
    task_id TEXT PRIMARY KEY,          -- 随机 UUID
    owner_user_id INTEGER,
    request_id TEXT NOT NULL,
    request_hash TEXT NOT NULL,
    state TEXT NOT NULL,
    created_at TEXT NOT NULL,
    started_at TEXT, finished_at TEXT,
    log_file TEXT, log_start INTEGER, log_end INTEGER,
    error TEXT, trace_id TEXT,
    UNIQUE(owner_user_id, request_id)
);
CREATE INDEX idx_execution_tasks_owner_state
    ON execution_tasks(owner_user_id, state);

两个关键设计:

幂等靠 UNIQUE(owner_user_id, request_id) 兜底。 客户端重发同一个 request_id,create_or_get 先查 UNIQUE 索引,命中就直接返回已有任务,不会重复执行。内容哈希 request_hash 额外校验:同一 request_id 配不同内容会抛 RequestIdConflict,防止客户端用同一个 ID 塞进不同的请求。

结果不存表里,只存定位信息。 完成的任务把结果写进按日滚动的 result-YYYY-MM-DD.log,表里只记 log_file + log_start + log_end 三个字段,读取时按偏移量切片。任务表本身不随结果体积膨胀。

状态机有守卫条件,不允许非法跳变:

accepted → running → completed / failed / cancelled

start()    只接受 state='accepted'
complete() 排除已 completed / cancelled 的行
cancel()   只允许从 accepted / running 取消

memories.db:跨重启的对话记忆

LangGraph 的检查点库是 :memory:,进程一重启对话状态就清零。真正让上下文活过重启的是这张表:

CREATE TABLE memories (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    thread_id TEXT NOT NULL,
    summary TEXT NOT NULL,
    compressed_at TEXT DEFAULT CURRENT_TIMESTAMP,
    message_count INTEGER NOT NULL,
    content_hash TEXT
);

对话线程超过 token 阈值时,_compress_if_needed() 把较早的 70% 消息交给 LLM 摘要,存到这里;content_hash 是内容 MD5,内容没变的线程跳过压缩,避免重复花 LLM 调用。压缩是惰性的——chat() 和 record_turn() 每次调用末尾都检查一次,所以即使云端实例关了调度器也不影响正确性。

跨库关系全景

把上面四个库放在一起,逻辑关联的方向是这样的:

flowchart TB
    subgraph users_db["users.db"]
        users
        users -->|"1:N 外键"| sessions
        users -->|"1:N 外键"| invite_codes
        users -->|"1:N 外键"| password_reset_codes
        users -->|"1:N 外键"| quota_usage
    end

    subgraph tasks_db["execution_tasks.db"]
        execution_tasks
    end

    subgraph mem_db["memories.db"]
        memories
    end

    subgraph todos_db["todos.db"]
        todos <-->|"origin 双节点同步(同构)"| scheduled_messages
        push_tokens
    end

    users -.->|"owner_user_id:逻辑关联,无物理外键"| execution_tasks
    users -.->|"thread_id:用户线程命名空间"| memories

四个设计特征

  1. 跨库无外键——物理隔离换来的部署简单,关联完整性由应用层承担。
  2. 复合主键做同步——(origin, id) 隔开双节点 ID 空间,updated_at + updated_by 修订元组保证重放收敛。
  3. 检查点在内存,摘要在磁盘——完整对话状态可丢弃,只有摘要跨重启保留,这是刻意的取舍。
  4. 幂等靠约束而非逻辑——UNIQUE(owner_user_id, request_id) 让重复提交天然去重,比在代码里维护”已处理集合”更可靠。

延伸:主键设计的两个争议点

梳理过程中有两个表的主键选择值得展开,它们恰好是同一枚硬币的两面:

  • password_reset_codes 用随机 code 做主键——查询路径最短(一次 B 树查找,无回表),写入是随机插入。但它写入频率趋近于零,页分裂代价不存在,这个选择是对的。
  • execution_tasks 用随机 UUID task_id 做主键——如果未来 /message 写入量上来,这是唯一值得迁移成”自增主键 + UNIQUE(task_id)”的表。其余表都没有迁移必要。

B 树页分裂的机制、随机主键为什么引发它、以及 UNIQUE 约束冲突时 SQLite 到底怎么处理,放在另一篇《从 B 树页分裂到 UNIQUE 索引:主键设计的两个侧面》里展开。

现状评估

当前数据层与系统规模是匹配的:单机、低并发、个人用途,SQLite 的锁模型完全够用。真正需要提前想清楚的只有两件事:execution_tasks 的结果日志没有过期清理策略,长期运行会积累文件;以及如果哪天要跨进程共享数据,SQLite 的单写者模型会成为第一个瓶颈。在那之前,这套四库十表的结构不需要动。

Sources

No external sources for this entry.

Related