ByteNoteByteNote
会话进数据库:SQLite 与 Postgres 后端
字

字节笔记本

2026年10月24日 · 约 20 分钟读完

会话进数据库:SQLite 与 Postgres 后端

API中转
¥120

CLAUDE.md 与自动记忆:Agent 的项目记忆

本文是《从 0 写 Agent》系列第 10 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「SQL Session:SQLite/Postgres 后端」。

上一章的 FileSession 用 JSONL 文件,零依赖、单机够用。但真实的服务端场景,多用户、需要查询、要事务,文件搞不定,得上数据库。

这一章拆 SQLSession,基于 bun ORM 的 SQLite/Postgres 后端。它会让你深刻理解两件事:为什么数据库后端是独立 module(保持核心零依赖),以及接口驱动多后端的价值,同一个 Session 接口,文件和数据库两种实现,Agent 循环代码一行不改。


引子:什么时候需要数据库

FileSession 在这些场景会力不从心:

场景FileSession 的问题数据库怎么解
多用户并发跨进程无锁,会冲突数据库事务
查询某个 session全文件扫描WHERE + 索引
分页取历史读整个文件再切片LIMIT/OFFSET
历史审计不能 SQL 查询直接 SQL 分析
备份恢复文件级别数据库工具链

如果你的 Agent 是个 Web 服务(像 agents-server),多用户同时用,FileSession 不够,必须上数据库。


Hooks:运行时事件钩子参考

源码对照:独立 module 的设计

打开 sessions/sessions.go,第一段 package doc 就点明了核心设计(sessions.go:1):

go
// Package sessions provides SQL-backed agents.Session implementations (SQLite
// and PostgreSQL) built on uptrace/bun. It is a separate Go module so the
// database driver dependencies never reach the core SDK's dependency graph —
// callers who use only InMemorySession or memory.FileSession pay nothing for it.

翻译,sessions 包是独立的 Go module,数据库驱动依赖绝不进核心 SDK 的依赖图。只用 InMemorySession 或 FileSession 的用户,不用为数据库依赖付代价。

这是什么意思?Go 的 module 系统里,go get github.com/zzir/agents-go 会拉核心包的依赖。如果核心包 import 了 uptrace/bun(数据库 ORM)和 SQLite 驱动,那所有用 agents-go 的人,都要下载这些依赖,哪怕他只用内存 Session。

独立 module 解决了这个问题:

text
核心 module: github.com/zzir/agents-go
  - 依赖:jsonschema-go, mcp go-sdk, openai-go(很少)
  - 内置:InMemorySession, memory.FileSession

独立 module: github.com/zzir/agents-go/sessions
  - 依赖:uptrace/bun, SQLite 驱动, Postgres 驱动
  - 提供:SQLSession(要数据库的人才 go get 这个)

用 SQLSession 的人,显式 go get .../sessions,拉数据库依赖。不用的人,核心包干干净净。 这是 Go 多 module 仓库管理依赖的经典手法。

agents-go 里同样的设计还有,Docker 沙箱(sandbox/docker)、SSH 沙箱(sandbox/ssh)都是独立 module。重依赖永远隔离,核心保持极简。


源码对照:数据模型

session 的存储模型很简单,一行一个对话 item(sessions.go:27):

go
// sessions/sessions.go:27
type message struct {
    bun.BaseModel `bun:"table:agent_messages,alias:m"`

    ID        int64  `bun:"id,pk,autoincrement"`  // 自增主键
    SessionID string `bun:"session_id,notnull"`   // 哪个 session
    Item      string `bun:"item,notnull"`         // JSON 序列化的对话 item
}

三个字段,自增 ID(保证顺序)、SessionID(区分不同对话)、Item(JSON 存的对话内容)。

关键设计,Item 存的是 JSON 字符串,不是展开成多个字段。为什么?

因为对话 item 的结构是异构的,有的是 user message(有 content),有的是 function_call(有 name + arguments),有的是 function_call_output(有 call_id + output)。展开成关系表会非常复杂,而且 OpenAI 的 Responses API 格式还会变。 存 JSON 字符串,反序列化就能还原,简单且向前兼容。

和 FileSession 用一样的序列化格式(agents.MarshalInputItem),保证两种后端的数据语义一致。


源码对照:复合索引

CreateSchema(sessions.go:71)建表时还建了个索引:

go
// sessions/sessions.go:75
db.NewCreateIndex().
    Model((*message)(nil)).
    Index("idx_agent_messages_session").
    Column("session_id", "id").  // 复合索引:(session_id, id)
    IfNotExists().
    Exec(ctx)

复合索引 (session_id, id)。为什么是这两个字段?

因为所有查询都是"某个 session 内,按 id 顺序":

  • GetItems → WHERE session_id = ? ORDER BY id
  • PopItem → WHERE session_id = ? ORDER BY id DESC LIMIT 1

复合索引让这些查询都能走索引,先按 session_id 定位(索引前缀),再按 id 排序(索引有序)。没有这个索引,数据库要全表扫描 + 排序,数据量大时慢。

索引设计的原则,给你的查询模式建索引,不是给字段建索引。 这里的查询模式是"按 session 分组 + 按 id 排序",所以索引是 (session_id, id)。


源码对照:无 CGO 的 SQLite

NewSQLite(sessions.go:53)用的是 sqliteshim:

go
// sessions/sessions.go:53
func NewSQLite(dsn, sessionID string) (*Session, *bun.DB, error) {
    sqldb, err := sql.Open(sqliteshim.ShimName, dsn)
    db := bun.NewDB(sqldb, sqlitedialect.New())
    return New(db, sessionID), db, nil
}

sqliteshim 是 bun 提供的"SQLite 驱动选择器",它会根据环境选择驱动。agents-go 实际跑的是 modernc 纯 Go 驱动(不是 CGO 的 mattn/go-sqlite3)。

为什么不用 CGO 驱动? 因为 CGO 驱动要求编译时链接 C 库,

  • 交叉编译困难(在 Mac 上编 Linux 二进制要配 C 工具链)
  • 静态编译困难(要带 C 运行时)
  • Docker 镜像要装 C 库

纯 Go 驱动(modernc)完全没这些问题,go build 出来就是纯 Go 二进制,交叉编译、静态编译、Scratch 镜像都支持。

代价是性能,纯 Go 的 SQLite 比 CGO 慢一些。但对 Session 这种"低频写、偶尔读"的场景,性能差距可以接受。换来的部署便利性,值。

这就是为什么 agents-server 能做到"单二进制 + SQLite + 零外部依赖",选了纯 Go 驱动。


源码对照:事务防并发 pop

PopItem(sessions.go:132)是最容易出并发 bug 的操作。看它怎么用事务保护:

go
// sessions/sessions.go:132(简化)
func (s *Session) PopItem(ctx context.Context) (*agents.TResponseInputItem, error) {
    var row message
    err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
        // 在事务里:先查最后一条
        tx.NewSelect().Model(&row).
            Where("session_id = ?", s.sessionID).
            Order("id DESC").Limit(1).Scan(ctx)
        // 再删它
        tx.NewDelete().Model(&row).WherePK().Exec(ctx)
        return nil
    })
    // ...
}

关键,"查最后一条"和"删它"在同一个事务里。为什么?

想象没有事务的情况,

  1. goroutine A 查到最后一条是 id=100
  2. goroutine B 也查到最后一条是 id=100(A 还没删)
  3. A 删了 id=100
  4. B 也删 id=100(但已经没了)或 B 返回 id=100 给调用方(但实际已删)

两个 goroutine pop 到同一条,这是 bug。事务解决了这个问题,"查+删"是原子的,两个并发 pop 不会拿到同一条。

注释明说(sessions.go:131):"The select-and-delete runs in one transaction so concurrent pops do not return the same row twice",select 和 delete 在一个事务里,防止并发 pop 返回同一行两次。

对比 FileSession,FileSession 用 per-path mutex 防 concurrent pop(单进程)。SQLSession 用数据库事务(跨进程也安全)。两种后端,两种并发保护策略,但都保证了 PopItem 的语义正确。


源码对照:GetItems 的 limit 处理

GetItems(sessions.go:87)对 limit 的处理有个细节:

go
// sessions/sessions.go:87(简化)
func (s *Session) GetItems(ctx context.Context, limit int) ([]agents.TResponseInputItem, error) {
    var rows []message
    q := s.db.NewSelect().Model(&rows).Where("session_id = ?", s.sessionID)
    if limit > 0 {
        q = q.Order("id DESC").Limit(limit)  // 倒序取 limit 条
    } else {
        q = q.Order("id ASC")  // 全取,正序
    }
    q.Scan(ctx)
    if limit > 0 {
        slices.Reverse(rows)  // 倒序变正序
    }
    // 反序列化...
}

当 limit > 0 时,先 ORDER BY id DESC LIMIT n(取最近 N 条,但顺序是新的在前),再 slices.Reverse 翻成 oldest-first。

为什么要翻?因为 Session 接口的语义是"返回 oldest-first"(按时间顺序)。但"取最近 N 条"用 ORDER BY id DESC 最自然(新的在前)。所以先倒序取,再翻转,两全其美。

这是个微小的细节,但体现了接口契约的严谨,不管后端怎么存,返回给调用方的顺序必须一致。这让 Agent 循环代码不用关心"我是从文件还是数据库读的",顺序都对。


FileSession vs SQLSession 对比

把两种实现放一起对比,能看出接口抽象的价值:

维度FileSessionSQLSession
存储JSONL 文件SQLite/Postgres 表
依赖零bun ORM + 数据库驱动
并发保护per-path mutex(单进程)数据库事务(跨进程)
崩溃安全临时文件+fsync+rename数据库的 WAL/事务
查询能力全文件扫描WHERE + 索引
适用场景个人 CLI、单机Web 服务、多用户
PopItem 防并发mutex事务

两者的代码量相近(FileSession 247 行,SQLSession 162 行),但存储范式完全不同。 这正是接口的价值,定义"做什么",不定义"怎么做"。Agent 循环只跟 Session 接口的四个方法打交道,换后端是配置问题,不是代码问题。


设计权衡:为什么 item 存 JSON 而不是展开成关系表

有人会想,把 message 的 role/content、function_call 的 name/arguments 展开成不同的表,不是更"规范"吗?

不。 三个理由:

1. Responses API 格式会变。 OpenAI 的 item 格式不是稳定的,今天有 message/function_call/function_call_output,明天可能加新的 type。展开成关系表,每次格式变都要改 schema、写迁移。存 JSON,格式变了反序列化代码改改就行。

2. 查询模式不需要展开。 Session 的查询就是"按 session 取历史",不需要"查所有 role=user 的消息"这种关系查询。没有关系查询需求,就不该用关系模型。

3. JSON 列让 schema 稳定。 agent_messages 表永远只有三个字段(id, session_id, item),不管对话格式怎么变,表结构不变。新增 item 类型不需要 schema 迁移。

这是"JSON 列承载扩展性"的典型应用,结构稳定的部分(id, session_id)用关系列,结构会变的部分(item 内容)用 JSON 列。agents-server 的数据库设计(Part VI 第 35 章)大量用了这个模式。


小结

读完这一章,你应该理解:

  1. 为什么 SQLSession 是独立 module?,数据库驱动依赖不污染核心 SDK,只用文件/内存 Session 的人不用付代价
  2. 数据模型为什么简单?,一行一个 item,item 存 JSON 字符串,三个字段(id/session_id/item)
  3. 复合索引为什么是 (session_id, id)?,匹配查询模式"按 session 分组 + 按 id 排序"
  4. 为什么用纯 Go SQLite 驱动?,无 CGO,交叉编译友好,单二进制部署
  5. 事务防并发 pop 怎么做?,查+删在同一个事务,防止并发 pop 拿到同一行
  6. 接口抽象的价值?,FileSession 和 SQLSession 范式完全不同,但 Agent 循环代码一行不改

Part II 的"记忆"主题,前 3 章(8-10)讲的是"怎么存"。 但存下来还不够,历史会无限增长,怎么压缩?怎么分叉?这些是第 11 章的内容。


下一篇

第 11 章,会话分叉与压缩。当对话历史长到上下文装不下,怎么办?ForkSession 怎么按消息分叉出新对话?长历史压缩(compaction)怎么用模型总结代替原始历史?服务端状态(previous_response_id)怎么省 token?这些是让 Session 在长对话场景可持续的关键能力。


本文是《从 0 写 Agent》系列第 10 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: