SQLite 在 LLM Agent 工程中的使用
在开发 nonoka-agent、nonoka-cli,以及实习中的一些项目时,多次使用了 sqlite 这个轻量数据库中间件,这里整理下 sqlite 在 Agent 组件中的应用以及一些相关设计
1. 前置知识与统一模型
为什么是 SQLite
对于一个单机、单实例、低并发写的 LLM agent 系统,轻量的 SQLite 非常合适:
- 零运维、零依赖:Python 自带
sqlite3,Node 有better-sqlite3,不需要配置其他基础设施。 - 单文件、可移植:一个
.db文件就是整个数据库,备份、迁移、调试都极其简单。 - 事务与崩溃安全:原子提交、WAL 模式下的崩溃恢复,正好是状态持久化的底线要求。
- 跨进程并发(有限度):WAL 模式下读不阻塞写、写不阻塞读,足以支撑”一个 TUI 进程 + 一个 worker 进程 + 几个后台线程”的典型拓扑。
SQLite 只是实现这些设计的一个轻量工具,之后也可以根据业务的需要迁移到PostGreSQL、专用消息队列或任务队列上。
幂等键与交付语义
幂等的定义是:同一个操作执行一次和执行 N 次,产生的效果相同。重试时无法区分具体是”请求没到”还是”响应丢了”,所以必须有幂等机制防止副作用重复发生。
在工程上,对幂等键的设计和操作有下面的规则:
- 记键和执行业务必须原子(同事务或用唯一约束兜底)。
- 同键同结果:重试返回与首次相同的结果。
- 键要有保留期,同键不同参数要报错。
然后是交付语义的概念,分布式中的交付语义有三种:
| 语义 | 含义 | 可能发生 | 适用场景/实现方法 |
|---|---|---|---|
| at-most-once | 至多投递一次,不重试 | 可能丢 | 丢得起的数据 |
| at-least-once | 失败就重试,直到确认 | 可能重复 | 默认;消费端幂等即可 |
| exactly-once | 恰好一次 | 严格做不到 | 用 at-least-once + 幂等消费逼近 |
租约
租约(lease)是带过期时间的锁,它是为了解决下面问题出现的:我们有如下的锁操作:
SET locked = 1
...
SET locked = 0
如果 worker 在持有锁的时候挂掉了,这个锁就一直会被持有导致死锁。而租约给锁加上了过期时间,这样我们就不需要做别的事情、只需要自然等待即可。
租约的设计有如下规则:
- TTL 必须大于任务最长执行时间。
- 持有者要心跳续租,续租失败立即停止工作,因为租约失败后别人可能已经在执行了。
Agent 统一四层数据模型
把几个项目的表结构放在一起看,会发现它们其实是同一个数据模型的不同实现,这个数据由四个层次组成:
| 层 | 职责 | 解决的问题 |
|---|---|---|
| 实例层 Instance | 一次执行的”本体”:状态机、租约、幂等键 | 这个任务现在是什么状态?谁在执行? |
| 事件层 Event Log | 可选的 append-only 历史,与状态转换同事务 | 系统是怎么走到当前状态的? |
| 步骤层 Steps | 幂等执行单元:状态、attempt、修复计数、输入哈希 | 每个步骤执行过没有?要不要重试? |
| 恢复点层 Checkpoints | 稳定恢复点:快照或恢复上下文 | 崩溃后从哪继续? |
以图的视角来看:
- state 是节点:实例层的状态字段就是”当前指针”指向的节点。
- event 是边:事件层的一行 = 一条从
from_state到to_state的有向边,sequence是边的顺序。 - steps 是关卡:步骤层记录”要执行的事情”:每个关卡的状态、尝试次数、幂等键。
- checkpoints 是存档点:恢复点层是沿着关卡设置的”现场快照”,恢复时从最近的存档点继续。
注意,在这种数据模型中,事件层并不是必须的。事件层在两种完全不同的架构里扮演完全不同的角色:
| Temporal / 事件溯源 | SQLite agent | |
|---|---|---|
| 真相源 | 事件历史 | 状态快照 |
| 恢复方式 | 代码从头重跑,遇到事件取结果 | 直接读快照 + 合并增量 |
| 丢事件后果 | 状态无法重建 | 状态还在,只是丢了历史 |
| 事件的角色 | 运行时必需 | 追溯/审计可选 |
Temporal 的 durable execution 是”重放式”:workflow 编排代码确定性地重跑,LLM/工具这些非确定 Activity 的结果以事件形式被记录,重放时直接取结果。而在本文讨论的 SQLite agent 实现里,恢复根本不重放事件,而是读快照和 checkpoint。事件只是账本,丢了不影响恢复。
当需要回答”系统是怎么一步步走到当前状态的”(审计、调试、给用户展示时间线)时,才需要完整事件层;如果只需要崩溃恢复快照 + 增量就够了。
以我开发的项目为例,我将它们分为下面两类 Agent(之后会详细讲它们的实现模式),它们分别按照下面的方式实现了上面的数据模型:
| 层 | 会话循环型 | 标准状态机 / 工作流型 | 带产物 / 投递的状态机(变体) |
|---|---|---|---|
| 实例层 | checkpoints.session_id + SessionState.status | runs(state + lease + retry) | task_runs(status + input_hash) |
| 事件层 | 可选 / 仅观测 | run_events(from/to + sequence) | delivery_events / workflow_events(局部审计) |
| 步骤层 | step_updates(status/result/error 增量) | run_steps(step_key + input_hash + attempt) | step_runs(repair_count + attempt_count) |
| 恢复点层 | checkpoints.state_json | run_checkpoints + 可选 payload 表 | context_json + artifacts |
注意 nonoka 的”实例层”和”恢复点层”是合并的:一整行
checkpoints.state_json既存会话状态,也是恢复快照。这是它作为会话型 agent 的自然选择。
2. 具体实现
前面提到了两类 Agent 设计的不同实现模式:会话循环型和状态机/工作流型。下面分别讲讲不同类型的实现方式。
会话循环型
以 nonoka 为例, nonoka 的链路形态是”用户 ↔ agent”的持续交互。ReAct 每轮自己决定下一步,PlanExecutor 按 DAG 推进,外部工具可能暂停等待用户批准——所有这些都发生在SessionState里。恢复时要一次性重建整个上下文(消息、计划、memory、预算、检测器状态),所以它的持久化非常简洁、只需要保存整份 SessionState 的快照即可:
CREATE TABLE checkpoints (
session_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL, -- 整份 SessionState 快照
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE step_updates (
session_id TEXT NOT NULL,
step_id TEXT NOT NULL,
update_type TEXT NOT NULL, -- 'status' | 'result' | 'error'
payload_json TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (session_id, step_id, update_type)
);
写入 checkpoints 时全部用 ON CONFLICT ... DO UPDATE 的幂等 upsert;加载时先取快照,再按 updated_at ASC 合并增量。
这个数据库操作在实际的 Agent 运行中需要注意下面的细节:
- 由于同秒内
status/result/error三行写入顺序不可靠,只靠时间戳可能会出现错误的状态覆盖。加载时要在应用层保护终态——COMPLETED/FAILED不能被非终态覆盖,成功重试要清掉旧失败记录,失败要清掉旧成功记录。
状态机/工作流型
状态机型把一次执行建模成确定性的阶段/步骤推进。这类系统会在基础状态机上叠加不同的扩展层:有的需要完整审计事件,有的需要独立产物与投递层。不过基本的设计是差不多的。
完整的 run ledger 结构
状态机/工作流型系统的典型结构是 run ledger(执行台帐:用一组表记录一次 run 的完整执行过程。一次请求按 RECEIVED → QUEUED → PROCESSING → ... → DELIVERING 的类似状态机推进。
CREATE TABLE runs ( -- 实例层
id TEXT PRIMARY KEY,
request_key TEXT NOT NULL UNIQUE, -- 业务幂等键 / trace 键
state TEXT NOT NULL, -- 当前状态机节点
terminal_status TEXT,
failed_step TEXT,
recovery_attempts INTEGER NOT NULL DEFAULT 0,
next_retry_at INTEGER,
lease_owner TEXT, -- 租约:谁在执行这个 run
lease_expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE run_events ( -- 事件层:完整审计
run_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_state TEXT,
to_state TEXT,
step_type TEXT,
attempt INTEGER NOT NULL DEFAULT 1,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
PRIMARY KEY (run_id, sequence)
);
CREATE TABLE run_steps ( -- 步骤层
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step_type TEXT NOT NULL,
step_key TEXT,
input_hash TEXT,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
idempotency_key TEXT,
lease_expires_at INTEGER,
retryable INTEGER NOT NULL DEFAULT 0,
next_retry_at INTEGER,
started_at INTEGER NOT NULL,
completed_at INTEGER,
FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE,
UNIQUE(run_id, step_type, attempt, idempotency_key)
);
CREATE UNIQUE INDEX idx_run_steps_stable_attempt
ON run_steps(run_id, step_key, attempt) WHERE step_key IS NOT NULL;
CREATE TABLE run_checkpoints ( -- 恢复点层(元数据)
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step_id TEXT,
checkpoint_type TEXT NOT NULL, -- INPUT / TOOL_RESULT / SYNTHESIS / DELIVERY / ...
schema_version TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
payload_ref TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE',
payload_hash TEXT,
cipher_version TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE checkpoint_payloads ( -- 大 payload 独立加密存储
id TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL,
iv BLOB NOT NULL,
auth_tag BLOB NOT NULL,
key_version TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/json',
payload_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER
);
每个 durable step 真正执行前,先把输入 canonicalize 再取 sha256 得到 input_hash,先查库:
SELECT * FROM run_steps
WHERE run_id = ? AND step_key = ? AND input_hash = ?
AND status = 'COMPLETED' ORDER BY attempt DESC LIMIT 1;
命中就直接复用 checkpoint 里的结果,并记录 STEP_RESULT_REUSED 事件;没命中才真正执行。这样可以避免恢复时重复调用 LLM 或重复查数据库。
恢复器定期扫描 lease_expires_at < now 且无活跃 RUNNING step 的 stale run,然后通过条件 UPDATE 抢占 lease(WHERE id=? AND lease_expires_at < now),changes === 1 则租约抢占成功。抢占后转入 RECOVERING,recovery_attempts 超过上限则带着 RECOVERY_EXHAUSTED 同时事件转入 FAILED。
从前面的设计可以看到,执行层(run ledger)和会话层(sessions / messages / context_checkpoints)是可以分开的。INPUT checkpoint 里只存 session_id 引用,恢复重跑时从会话表加载历史,而 run 恢复不影响上下文,重跑不会失去记忆。这种分层也是会话循环型与状态机型组装时的自然切分:会话层负责维护长期上下文,执行层只负责把当前这次 durable 任务跑完。
带产物与投递的状态机
另一种常见变体处理的是会产生独立业务产物的流程,例如生成 JSON 或者其他、并且之后还需要异步投递给外部系统。我们可以在实例层和步骤层之外,显式拆出”产物”和”投递”两层:
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
scope_id TEXT NOT NULL, -- 业务域 / workflow 标识
request_id TEXT NOT NULL, -- 请求标识
input_hash TEXT NOT NULL, -- 规范化输入的 SHA-256,幂等键
status TEXT NOT NULL,
context_json TEXT NOT NULL DEFAULT '{}', -- workflow 定义 + 原始输入
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX task_runs_active_idempotency
ON task_runs(scope_id, request_id, input_hash)
WHERE status NOT IN ('succeeded', 'failed', 'cancelled', 'expired');
CREATE TABLE step_runs (
task_id TEXT NOT NULL,
step_id TEXT NOT NULL,
action TEXT NOT NULL, -- 步骤动作 / 技能标识
status TEXT NOT NULL,
repair_count INTEGER NOT NULL DEFAULT 0, -- 失败修复次数
attempt_count INTEGER NOT NULL DEFAULT 0, -- 实际执行次数
updated_at TEXT NOT NULL,
error_json TEXT,
PRIMARY KEY(task_id, step_id)
);
CREATE TABLE artifacts ( -- 产物层
artifact_id TEXT PRIMARY KEY,
task_id TEXT,
step_id TEXT,
kind TEXT NOT NULL,
checksum TEXT NOT NULL,
size INTEGER NOT NULL,
payload_json TEXT,
file_path TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE delivery_intents ( -- 投递意图层
intent_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
artifact_id TEXT NOT NULL,
status TEXT NOT NULL, -- prepared → sending → confirmed / failed_retryable / failed_terminal
checksum TEXT NOT NULL,
target_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(task_id, artifact_id, checksum)
);
CREATE TABLE delivery_events ( -- 投递审计
event_id TEXT PRIMARY KEY,
intent_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
标准的 Run Ledger 与带产物/投递的状态机都是状态机型,但叠的扩展层不同:
| 你关心什么 | 标准执行台账 | 带产物 / 投递的状态机 |
|---|---|---|
| 需要回答”这次执行是怎么走到当前状态的” | 加完整事件层(run_events) | 只在关键域记事件(delivery_events) |
| 流程会生成独立产物并需要外部投递/确认 | 产物作为 checkpoint 的一部分 | 加 artifacts + delivery_intents 独立层 |
| 恢复时要避免重复调用 LLM/查库 | input_hash + durable step 复用 | 产物一旦生成就可复用 |
| 多进程竞争同一个执行 | run 级 + step 级 lease | 单实例 + 超时置死 |
这些根据具体业务变化的的具体设计没有银弹。关键不是”哪种实现更好”,而是状态机在执行什么、需要记录什么和会生成什么东西。
3. 轻量任务队列
很多 agent 系统需要后台任务:记忆抽取、通知发送、产物投递。也可以用 SQLite 表直接当队列。
这种实现方法不太正统,仅供参考。
原子领取
原子领取的基本框架如下:
BEGIN IMMEDIATE;
-- 1) 选候选
SELECT * FROM jobs
WHERE status = 'pending' ORDER BY created_at LIMIT 1;
-- 2) CAS 条件 UPDATE
UPDATE jobs
SET status = 'running', started_at = ?
WHERE job_id = ? AND status = 'pending';
COMMIT;
SQLite 是单写者,同一时刻只有一个 worker 能成功把 pending 改成 running,没有选到任务的(WHERE status = 'pending' 条件不成立)继续选择即可。
简单的带租约和重试的租约表如下:
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL UNIQUE, -- 去重键
payload_ciphertext BLOB NOT NULL, -- 加密 payload
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
lease_until INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
领取时 WHERE status='pending' OR (status='processing' AND lease_until <= now),失败后记 next_attempt_at = now + backoff(attempts),超过 max_attempts 标记 dead。
Outbox 模式
agent 系统里大量存在的”本地状态变更 + 远程副作用”的组合:记忆要异步抽取、通知要发出、产物要投递可以使用 Outbox 模式,把这类操作变成”先写 SQLite 事务、后台异步消费”。
核心是业务变更和写消息必须在同一个事务里提交:事务提交时消息一定已经落表,消息写不进去则业务也不会提交。这样远程副作用只会”晚到”,不会”丢失”:
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL UNIQUE, -- 幂等键:同一条消息只投递一次
topic TEXT NOT NULL, -- 'memory_extract' / 'notification' / 'deliver'
payload_json TEXT NOT NULL, -- 要投递的负载
status TEXT NOT NULL DEFAULT 'pending', -- pending → sent / dead
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL, -- 失败退避后的重试时间
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
BEGIN IMMEDIATE;
-- 1) 本地状态变更(业务操作)
UPDATE task_runs SET status = 'completed' WHERE task_id = ?;
-- 2) 同一事务里写 outbox 消息
INSERT INTO outbox (id, source_id, topic, payload_json)
VALUES (?, ?, 'notification', ?);
COMMIT;
后台消费者轮询 outbox,把消息投递到远程(HTTP、消息队列等):
-- 1) 领取一条(多消费者时 CAS 领取同 §3.a)
SELECT * FROM outbox
WHERE status = 'pending' AND next_attempt_at <= now
ORDER BY created_at LIMIT 1;
-- 2) 调用远程 API 发送...
-- 3) 成功后标记 sent
UPDATE outbox SET status = 'sent', updated_at = ? WHERE id = ?;
-- 4) 失败则退避重试,超过上限标记 dead
UPDATE outbox
SET attempts = attempts + 1,
status = CASE WHEN attempts >= ? THEN 'dead' ELSE 'pending' END,
next_attempt_at = now + backoff(attempts),
last_error = ?
WHERE id = ?;
注意”发送远程副作用”和”标记 sent”之间永远隔着一个崩溃窗口:发送成功但标记前进程挂了,重启后消息仍是 pending,会被再次投递。所以 outbox 是 at-least-once 语义,重复投递靠下游幂等兜底——比如下游建 UNIQUE(source_id) 表,重复消息直接冲突丢弃。
这是 outbox 的 SQLite 实现:本地状态变更与远程副作用之间,永远隔着一层”持久化的待办”。再加上下游幂等(如消费端用 UNIQUE(source_id) 去重),就拼出了完整的可靠投递链。
使用 SQLite 作为队列的场景
| 场景 | SQLite 队列 | Redis/BullMQ | Kafka 等流平台 |
|---|---|---|---|
| 单机、任务量千级/天 | ✅ 零依赖首选 | 过度 | 过度 |
| 延迟任务、需要到点触发 | ⚠️ 需自建 run_at 轮询 | ✅ | ✅ |
| 高吞吐(万级/秒) | ❌ 单写者瓶颈 | ✅ | ✅ |
| 多实例 worker 水平扩展 | ⚠️ WAL 要求同机 | ✅ | ✅ |
| 消息回溯/审计 | ✅ 事件表天然可查 | ⚠️ | ✅ |