Dark Dwarf Blog background

Sqlite 在 Agent 系统中的使用

SQLite 在 LLM Agent 工程中的使用

在开发 nonoka-agent、nonoka-cli,以及实习中的一些项目时,多次使用了 sqlite 这个轻量数据库中间件,这里整理下 sqlite 在 Agent 组件中的应用以及一些相关设计

1. 前置知识与统一模型

a.a. 为什么是 SQLite

对于一个单机、单实例、低并发写的 LLM agent 系统,轻量的 SQLite 非常合适:

  • 零运维、零依赖:Python 自带 sqlite3,Node 有 better-sqlite3,不需要配置其他基础设施。
  • 单文件、可移植:一个 .db 文件就是整个数据库,备份、迁移、调试都极其简单。
  • 事务与崩溃安全:原子提交、WAL 模式下的崩溃恢复,正好是状态持久化的底线要求。
  • 跨进程并发(有限度):WAL 模式下读不阻塞写、写不阻塞读,足以支撑”一个 TUI 进程 + 一个 worker 进程 + 几个后台线程”的典型拓扑。

SQLite 只是实现这些设计的一个轻量工具,之后也可以根据业务的需要迁移到PostGreSQL、专用消息队列或任务队列上。

b.b. 幂等键与交付语义

幂等的定义是:同一个操作执行一次和执行 N 次,产生的效果相同。重试时无法区分具体是”请求没到”还是”响应丢了”,所以必须有幂等机制防止副作用重复发生。

在工程上,对幂等键的设计和操作有下面的规则:

  1. 记键和执行业务必须原子(同事务或用唯一约束兜底)。
  2. 同键同结果:重试返回与首次相同的结果。
  3. 键要有保留期,同键不同参数要报错。

然后是交付语义的概念,分布式中的交付语义有三种:

语义含义可能发生适用场景/实现方法
at-most-once至多投递一次,不重试可能丢丢得起的数据
at-least-once失败就重试,直到确认可能重复默认;消费端幂等即可
exactly-once恰好一次严格做不到用 at-least-once + 幂等消费逼近

c.c. 租约

租约(lease)是带过期时间的锁,它是为了解决下面问题出现的:我们有如下的锁操作:

SET locked = 1
...
SET locked = 0

如果 worker 在持有锁的时候挂掉了,这个锁就一直会被持有导致死锁。而租约给锁加上了过期时间,这样我们就不需要做别的事情、只需要自然等待即可。

租约的设计有如下规则:

  1. TTL 必须大于任务最长执行时间。
  2. 持有者要心跳续租,续租失败立即停止工作,因为租约失败后别人可能已经在执行了。

c.c. 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.statusruns(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_jsonrun_checkpoints + 可选 payload 表context_json + artifacts

注意 nonoka 的”实例层”和”恢复点层”是合并的:一整行 checkpoints.state_json 既存会话状态,也是恢复快照。这是它作为会话型 agent 的自然选择。

2. 具体实现

前面提到了两类 Agent 设计的不同实现模式:会话循环型和状态机/工作流型。下面分别讲讲不同类型的实现方式。

a.a. 会话循环型

以 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 不能被非终态覆盖,成功重试要清掉旧失败记录,失败要清掉旧成功记录。

b.b. 状态机/工作流型

状态机型把一次执行建模成确定性的阶段/步骤推进。这类系统会在基础状态机上叠加不同的扩展层:有的需要完整审计事件,有的需要独立产物与投递层。不过基本的设计是差不多的。

i.i. 完整的 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 任务跑完。

ii.ii. 带产物与投递的状态机

另一种常见变体处理的是会产生独立业务产物的流程,例如生成 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 表直接当队列。

p.s.p.s.这种实现方法不太正统,仅供参考。

a.a. 原子领取

原子领取的基本框架如下:

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。

b.b. 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) 去重),就拼出了完整的可靠投递链。

c.c. 使用 SQLite 作为队列的场景

场景SQLite 队列Redis/BullMQKafka 等流平台
单机、任务量千级/天✅ 零依赖首选过度过度
延迟任务、需要到点触发⚠️ 需自建 run_at 轮询✅✅
高吞吐(万级/秒)❌ 单写者瓶颈✅✅
多实例 worker 水平扩展⚠️ WAL 要求同机✅✅
消息回溯/审计✅ 事件表天然可查⚠️✅