
字节笔记本
2026年10月24日 · 约 11 分钟读完
数据库怎么设计:10 张表与 JSON 扩展列

本文是《从 0 写 Agent》系列第 35 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「数据库设计:10 张表与 JSON 扩展列」。
agents-server 用 SQLite 存所有配置和数据,Agent 定义、会话、MCP server、沙箱配置、guardrail、trace。10 张表,怎么设计才灵活可扩展?
这一章拆 agents-server 的数据库设计,核心是"JSON 列承载扩展性 + 敏感字段隔离 + 泛型 CrudStore"。
10 张表
agents-server 的 schema(store/schema.go)有 10 张表:
| 表 | 存什么 |
|---|---|
sessions | 会话(id/name/pinned/agent_config_id) |
messages | 会话消息(含原始 SDK item JSON) |
agent_configs | Agent 定义(最肥的表) |
mcp_servers | MCP server 配置 |
memories | 持久记忆 |
settings | KV 配置 |
provider_routes | 模型路由(前缀→api_key/base_url) |
sandbox_configs | 沙箱配置 |
trace_events | trace/span |
guardrails | 护栏定义 |

核心设计一:JSON 列承载扩展性
这是最重要的设计。很多表的"配置"字段是 JSON 列(json.RawMessage),不是展开的关系字段:
// store/models.go(概念)
type McpServerConfig struct {
ID string `bun:"id"`
TransportType string `bun:"transport_type"` // stdio / streamable_http
Config json.RawMessage `bun:"config"` // ← JSON 列!
}
type SandboxConfig struct {
Type string `bun:"type"` // local / docker / ssh
Config json.RawMessage `bun:"config"` // ← JSON 列!
}为什么用 JSON 列存配置?
想象 MCP server 有两种 transport(stdio / streamable_http),每种配置字段不同:
- stdio:
{command, args, env} - streamable_http:
{url, oauth_config}
如果用关系字段,要么加一堆可空列(混乱),要么拆两张表(复杂)。JSON 列让"不同 transport 的不同配置"都存在一个字段里,新增 transport 不用改表结构。
// bridge 层根据 TransportType 解析 Config
switch cfg.TransportType {
case "stdio":
var c StdioConfig
json.Unmarshal(cfg.Config, &c)
case "streamable_http":
var c HTTPConfig
json.Unmarshal(cfg.Config, &c)
}新增一种 transport?加一个 case + 新 struct,表结构不动。 这是"schema 稳定 + 格式灵活"的经典权衡。
核心设计二:敏感字段隔离
有些字段是敏感的,ChatGPT token、MCP OAuth token。agents-server 把它们独立成列,CRUD 时特殊保护:
// store/models.go
type AgentConfig struct {
// ...
ChatGPTToken string `bun:"chatgpt_token" json:"-"` // JSON 输出忽略
}json:"-",这个字段不参与 JSON 序列化,不会通过 API 返回给前端。
更重要的,Update 时不覆盖 token(agent_config_store.go:22):
// 重写 Update,排除 chatgpt_token 列
func (s *AgentConfigStore) Update(ctx, cfg) error {
_, err := s.db.NewUpdate().Model(cfg).
ExcludeColumn("chatgpt_token"). // 不更新 token 列
WherePK().Exec(ctx)
}为什么? 用户编辑 Agent 配置(改模型名、改指令)时,前端传回的表单不含 token(因为 json:"-")。如果 Update 不排除 token 列,会用空值覆盖掉已存的 token。ExcludeColumn 保证编辑配置不会擦掉 token。
核心设计三:泛型 CrudStore
13 个 handler 大部分是 CRUD(增删改查)。为避免每个资源写一遍 CRUD,agents-server 用泛型 CrudStore(store/crud_store.go:45):
// 概念示意
type CrudStore[T any] struct {
db *bun.DB
}
func (s *CrudStore[T]) Create(ctx, entity *T) error { /* 通用 insert */ }
func (s *CrudStore[T]) List(ctx) ([]T, error) { /* 通用 select all */ }
func (s *CrudStore[T]) Get(ctx, id string) (*T, error) { /* 通用 select by id */ }
func (s *CrudStore[T]) Update(ctx, entity *T) error { /* 通用 update */ }
func (s *CrudStore[T]) Delete(ctx, id string) error { /* 通用 delete */ }所有"string 主键 + created_at/updated_at"的实体共享这五个方法。 各资源 store 只需 embed 它,再加自己的特化查询:
type SessionStore struct {
*CrudStore[Session] // 通用 CRUD
}
// 特化查询(session 独有的)
func (s *SessionStore) ListPinned(ctx) ([]Session, error) {
// 只查 pinned 的
}Go 泛型让 CRUD 不重复。 这是泛型在业务代码里的实用场景,不是炫技,是真正减少重复。
索引设计
两条索引(schema.go:31,39):
// messages 的复合索引
Index("idx_messages_session_id").Column("session_id", "id")
// trace_events 的复合索引
Index("idx_trace_events_session_run").Column("session_id", "run_id")和第 10 章 SQLSession 的索引设计同理,给查询模式建索引。messages 的查询是"按 session 取消息按 id 排序",trace_events 是"按 session+run 取 trace"。
WAL 模式
// DSN: file:data.db?cache=shared&_journal_mode=WALWAL(Write-Ahead Logging)模式提升并发读,写操作不阻塞读。对 Web 应用(多用户同时读 + 偶尔写)很重要。
注意 SetMaxOpenConns(1)(db.go:19),因为 SQLite 写是串行的(单写者),限制连接数避免锁竞争。
小结
读完这一章,你应该理解 agents-server 数据库设计的三个核心:
- JSON 列承载扩展性,不同 transport/backend 的配置存一个 JSON 字段,新增类型不改表
- 敏感字段隔离,token 独立列 +
json:"-"+ Update ExcludeColumn,防泄露防覆盖 - 泛型 CrudStore,CRUD 不重复,各 store embed + 特化查询
这是"schema 稳定 + 格式灵活 + 代码不重复"的数据库设计范式。 适合配置驱动的应用。
下一篇
第 36 章,WebSocket 流式:三层架构。agents-server 怎么把 SDK 的流式事件推给前端?连接层(鉴权+写锁)→ 分发层(HITL pending 状态机)→ 运行层(SDK 事件→protocol 事件)。
本文是《从 0 写 Agent》系列第 35 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

