
字节笔记本
2026年10月24日 · 约 20 分钟读完
会话进数据库:SQLite 与 Postgres 后端

本文是《从 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 不够,必须上数据库。

源码对照:独立 module 的设计
打开 sessions/sessions.go,第一段 package doc 就点明了核心设计(sessions.go:1):
// 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 解决了这个问题:
核心 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):
// 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)建表时还建了个索引:
// 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 idPopItem→WHERE session_id = ? ORDER BY id DESC LIMIT 1
复合索引让这些查询都能走索引,先按 session_id 定位(索引前缀),再按 id 排序(索引有序)。没有这个索引,数据库要全表扫描 + 排序,数据量大时慢。
索引设计的原则,给你的查询模式建索引,不是给字段建索引。 这里的查询模式是"按 session 分组 + 按 id 排序",所以索引是 (session_id, id)。
源码对照:无 CGO 的 SQLite
NewSQLite(sessions.go:53)用的是 sqliteshim:
// 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 的操作。看它怎么用事务保护:
// 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
})
// ...
}关键,"查最后一条"和"删它"在同一个事务里。为什么?
想象没有事务的情况,
- goroutine A 查到最后一条是 id=100
- goroutine B 也查到最后一条是 id=100(A 还没删)
- A 删了 id=100
- 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 的处理有个细节:
// 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 对比
把两种实现放一起对比,能看出接口抽象的价值:
| 维度 | FileSession | SQLSession |
|---|---|---|
| 存储 | 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 章)大量用了这个模式。
小结
读完这一章,你应该理解:
- 为什么 SQLSession 是独立 module?,数据库驱动依赖不污染核心 SDK,只用文件/内存 Session 的人不用付代价
- 数据模型为什么简单?,一行一个 item,item 存 JSON 字符串,三个字段(id/session_id/item)
- 复合索引为什么是 (session_id, id)?,匹配查询模式"按 session 分组 + 按 id 排序"
- 为什么用纯 Go SQLite 驱动?,无 CGO,交叉编译友好,单二进制部署
- 事务防并发 pop 怎么做?,查+删在同一个事务,防止并发 pop 拿到同一行
- 接口抽象的价值?,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 移植版)。全系列目录见总览。



