要理解一个事件驱动的 agent 系统,事件流只是表象,数据最终落到哪里、以什么结构落,才决定了同步、配额、幂等这些行为能不能成立。mini-agent 的全部持久化都建立在 SQLite 上:4 个数据库文件、10 张业务表,外加一个纯内存的检查点库。这篇从表关系出发,把整个数据层梳理一遍。
总览:四个库各管一件事
| 数据库文件 | 表数量 | 职责 |
|---|---|---|
data/todos.db | 3 | 待办、定时消息、推送令牌 |
data/users.db | 5 | 用户认证、会话、邀请码、配额 |
data/execution_tasks.db | 1 | Web 消息的异步任务执行 |
data/memories.db | 1 | 对话记忆的压缩摘要 |
:memory:(内存) | 3 | LangGraph 检查点 + 消息时间戳 |
一个值得先记住的整体特征:跨库之间没有任何物理外键。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 独占这个库,五张表一次性建好:
| 表 | 主键 | 解决的问题 |
|---|---|---|
users | id 自增 | 邮箱唯一,密码 argon2id 哈希,role 区分管理员,disabled_at 软封禁 |
sessions | token | 可过期(24h)、可吊销的登录态,外键指向 users(id) |
invite_codes | code | 注册不是开放入口,邀请码限次数、限有效期 |
password_reset_codes | code | 管理员发起的密码重置,一次性使用 |
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
四个设计特征
- 跨库无外键——物理隔离换来的部署简单,关联完整性由应用层承担。
- 复合主键做同步——
(origin, id)隔开双节点 ID 空间,updated_at + updated_by修订元组保证重放收敛。 - 检查点在内存,摘要在磁盘——完整对话状态可丢弃,只有摘要跨重启保留,这是刻意的取舍。
- 幂等靠约束而非逻辑——
UNIQUE(owner_user_id, request_id)让重复提交天然去重,比在代码里维护”已处理集合”更可靠。
延伸:主键设计的两个争议点
梳理过程中有两个表的主键选择值得展开,它们恰好是同一枚硬币的两面:
password_reset_codes用随机code做主键——查询路径最短(一次 B 树查找,无回表),写入是随机插入。但它写入频率趋近于零,页分裂代价不存在,这个选择是对的。execution_tasks用随机 UUIDtask_id做主键——如果未来/message写入量上来,这是唯一值得迁移成”自增主键 +UNIQUE(task_id)”的表。其余表都没有迁移必要。
B 树页分裂的机制、随机主键为什么引发它、以及 UNIQUE 约束冲突时 SQLite 到底怎么处理,放在另一篇《从 B 树页分裂到 UNIQUE 索引:主键设计的两个侧面》里展开。
现状评估
当前数据层与系统规模是匹配的:单机、低并发、个人用途,SQLite 的锁模型完全够用。真正需要提前想清楚的只有两件事:execution_tasks 的结果日志没有过期清理策略,长期运行会积累文件;以及如果哪天要跨进程共享数据,SQLite 的单写者模型会成为第一个瓶颈。在那之前,这套四库十表的结构不需要动。