OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四)
约 7 分钟2098 字0 次阅读

OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制
引言
在构建一个真正实用的 AI 编程助手时,仅仅让 AI 理解代码和执行工具是不够的。会话管理和长期记忆同样是核心能力。当用户与 OpenCode 进行多轮对话时,系统需要:
- 维护完整的对话历史
- 追踪每个会话的 token 使用量和成本
- 在上下文窗口接近满时自动压缩历史
- 支持会话的创建、切换、删除等操作
本文将深入剖析 OpenCode 的数据库层设计、会话管理服务、以及智能的 Auto-Compact 摘要压缩机制。
一、数据库架构设计
1.1 SQLite 选型考量
OpenCode 选择 SQLite 作为数据存储,有几个重要原因:
| 考量 | SQLite 的优势 |
|---|---|
| 部署简单 | 单文件数据库,无需独立服务器进程 |
| 零配置 | 不需要设置用户名、密码、连接池 |
| Go 生态 | 通过 modernc.org/sqlite 实现纯 Go 嵌入 |
| 性能足够 | 对于单用户 CLI 工具,读写性能绰绰有余 |
| 可移植性 | 数据库就是一个文件,方便备份和迁移 |
使用 ncruces/go-sqlite3 库实现嵌入式 SQLite:
// internal/db/connect.go
import (
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
1.2 数据库连接配置
// internal/db/connect.go
func Connect() (*sql.DB, error) {
dataDir := config.Get().Data.Directory
dbPath := filepath.Join(dataDir, "opencode.db")
db, err := sql.Open("sqlite3", dbPath)
// 验证连接
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// 性能优化 PRAGMA
pragmas := []string{
"PRAGMA foreign_keys = ON;", // 启用外键约束
"PRAGMA journal_mode = WAL;", // WAL 模式提升并发
"PRAGMA page_size = 4096;", // 4KB 页大小
"PRAGMA cache_size = -8000;", // 8MB 缓存
"PRAGMA synchronous = NORMAL;", // 普通同步模式
}
}
WAL 模式(Write-Ahead Logging) 是一种比默认回滚日志更高效的存储方式,它允许读写操作并发进行,显著提升多线程场景下的性能。
1.3 核心表结构
会话表 (sessions)
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
parent_session_id TEXT, -- 父会话 ID(用于 Task Agent)
title TEXT NOT NULL, -- 会话标题
message_count INTEGER NOT NULL DEFAULT 0, -- 消息计数
prompt_tokens INTEGER NOT NULL DEFAULT 0, -- 输入 token 总数
completion_tokens INTEGER NOT NULL DEFAULT 0, -- 输出 token 总数
cost REAL NOT NULL DEFAULT 0.0, -- 总成本
updated_at INTEGER NOT NULL, -- 更新时间戳
created_at INTEGER NOT NULL, -- 创建时间戳
summary_message_id TEXT -- 摘要消息 ID(Auto-Compact 后)
);
消息表 (messages)
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL, -- 所属会话
role TEXT NOT NULL, -- 角色:user/assistant/system/tool
parts TEXT NOT NULL default '[]', -- JSON 序列化的消息内容
model TEXT, -- 使用的模型
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER, -- 完成时间戳
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
);
文件变更表 (files)
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL, -- 所属会话
path TEXT NOT NULL, -- 文件路径
content TEXT NOT NULL, -- 文件内容
version TEXT NOT NULL, -- 版本标识
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(path, session_id, version) -- 唯一约束
);
1.4 自动触发器 (Triggers)
数据库使用触发器自动维护派生数据,这是 SQLite 的强大特性:
自动更新时间戳
CREATE TRIGGER update_sessions_updated_at
AFTER UPDATE ON sessions
BEGIN
UPDATE sessions SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
自动维护消息计数
-- 插入消息时 +1
CREATE TRIGGER update_session_message_count_on_insert
AFTER INSERT ON messages
BEGIN
UPDATE sessions SET message_count = message_count + 1
WHERE id = new.session_id;
END;
-- 删除消息时 -1
CREATE TRIGGER update_session_message_count_on_delete
AFTER DELETE ON messages
BEGIN
UPDATE sessions SET message_count = message_count - 1
WHERE id = new.session_id;
END;
这种设计的优雅之处在于:数据一致性由数据库自身维护,应用层无需手动更新 message_count。
二、sqlc 代码生成
2.1 什么是 sqlc
sqlc 是一个 Go 语言的 SQL 工具,它根据 SQL 查询自动生成 类型安全的 Go 代码。开发者编写 SQL 查询,sqlc 生成对应的 Go 方法。
OpenCode 使用 sqlc v1.29.0:
# sqlc.yaml
version: "2"
database:
engine: "sqlite"
schema: "internal/db/migrations"
queries: "internal/db/sql"
2.2 SQL 查询文件
-- internal/db/sql/sessions.sql
-- name: CreateSession :one
INSERT INTO sessions (
id, parent_session_id, title, message_count,
prompt_tokens, completion_tokens, cost, summary_message_id,
updated_at, created_at
) VALUES (
?, ?, ?, ?,
?, ?, ?, null,
strftime('%s', 'now'),
strftime('%s', 'now')
) RETURNING *;
-- name: GetSessionByID :one
SELECT * FROM sessions WHERE id = ? LIMIT 1;
-- name: ListSessions :many
SELECT * FROM sessions ORDER BY updated_at DESC;
2.3 生成的 Go 代码
// internal/db/sessions.sql.go (sqlc 生成)
type CreateSessionParams struct {
ID string
ParentSessionID sql.NullString
Title string
MessageCount int64
PromptTokens int64
CompletionTokens int64
Cost float64
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.queryRow(ctx, q.createSessionStmt, createSession, ...)
var i Session
err := row.Scan(&i.ID, &i.ParentSessionID, ...)
return i, err
}
优势:
- 类型安全:编译时检查 SQL 参数类型
- IDE 支持:可以跳转、引用、重构
- SQL 验证:sqlc 验证 SQL 语法正确性
- 可读性强:生成的代码清晰易懂
三、会话服务实现
3.1 Session 数据结构
// internal/session/session.go
type Session struct {
ID string
ParentSessionID string
Title string
MessageCount int64
PromptTokens int64
CompletionTokens int64
SummaryMessageID string
Cost float64
CreatedAt int64
UpdatedAt int64
}
3.2 会话服务接口
type Service interface {
pubsub.Suscriber[Session] // 继承事件订阅
Create(ctx context.Context, title string) (Session, error)
CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
Get(ctx context.Context, id string) (Session, error)
List(ctx context.Context) ([]Session, error)
Save(ctx context.Context, session Session) (Session, error)
Delete(ctx context.Context, id string) error
}
3.3 三种会话类型
OpenCode 根据用途定义了三种不同的会话:
普通会话 (Create)
func (s *service) Create(ctx context.Context, title string) (Session, error) {
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: uuid.New().String(),
Title: title,
})
session := s.fromDBItem(dbSession)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
标题生成会话 (CreateTitleSession)
用于异步生成会话标题:
func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: "title-" + parentSessionID,
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: "Generate a title",
})
// ...
}
注意会话 ID 的格式是 title-{parentSessionID},这是后续识别标题会话的关键。
任务子会话 (CreateTaskSession)
当 Agent 调用子 Agent (AgentTool) 时,会创建一个任务会话:
func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: toolCallID, // 使用工具调用 ID 作为会话 ID
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: title,
})
// ...
}
重要:任务会话的 ID 直接使用 toolCallID,这使得父会话可以将子会话的成本累加回来:
// internal/llm/agent/agent-tool.go
updatedSession, _ := b.sessions.Get(ctx, session.ID)
parentSession, _ := b.sessions.Get(ctx, sessionID)
parentSession.Cost += updatedSession.Cost // 成本累加
b.sessions.Save(ctx, parentSession)
3.4 事件发布订阅
会话服务实现了 pubsub.Suscriber[Session] 接口,当会话发生变化时发布事件:
// 创建会话时发布事件
s.Publish(pubsub.CreatedEvent, session)
// 删除会话时发布事件
s.Publish(pubsub.DeletedEvent, session)
// 更新会话时(通过 pubsub.UpdatedEvent)
TUI 层订阅这些事件以保持界面同步:
// internal/tui/tui.go
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
a.selectedSession = msg.Payload // 更新选中会话的状态
}
四、消息服务与内容模型
4.1 消息角色
// internal/message/content.go
type MessageRole string
const (
Assistant MessageRole = "assistant"
User MessageRole = "user"
System MessageRole = "system"
Tool MessageRole = "tool"
)
4.2 内容部分 (Content Parts)
OpenCode 使用组合模式表示消息内容:
type ContentPart interface {
isPart()
}
type TextContent struct {
Text string `json:"text"`
}
type ReasoningContent struct {
Thinking string `json:"thinking"` // Claude 的思考过程
}
type ImageURLContent struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type BinaryContent struct {
Path string
MIMEType string
Data []byte
}
4.3 完成原因
type FinishReason string
const (
FinishReasonEndTurn FinishReason = "end_turn" // 正常结束
FinishReasonMaxTokens FinishReason = "max_tokens" // 达到最大 token
FinishReasonToolUse FinishReason = "tool_use" // 需要工具调用
FinishReasonCanceled FinishReason = "canceled" // 用户取消
FinishReasonError FinishReason = "error" // 错误
FinishReasonPermissionDenied FinishReason = "permission_denied" // 权限拒绝
)
4.4 消息创建流程
// internal/message/message.go
func (s *service) Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
// 非 assistant 消息自动添加结束标记
if params.Role != Assistant {
params.Parts = append(params.Parts, Finish{
Reason: "stop",
})
}
partsJSON, err := marshallParts(params.Parts)
dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
ID: uuid.New().String(),
SessionID: sessionID,
Role: string(params.Role),
Parts: string(partsJSON),
Model: sql.NullString{String: string(params.Model), Valid: true},
})
message, err := s.fromDBItem(dbMessage)
s.Publish(pubsub.CreatedEvent, message)
return message, nil
}
五、Auto-Compact 智能摘要机制
5.1 为什么需要 Auto-Compact
LLM 的上下文窗口是有限的。以 Claude 4 Sonnet 为例,其上下文窗口为 200K tokens。当对话越来越长时:
- 成本增加:每次请求都发送完整历史
- 性能下降:长上下文推理更慢
- 可能溢出:超过上下文限制导致无法继续
5.2 触发条件
Auto-Compact 在 TUI 层检测:
// internal/tui/tui.go
case pubsub.Event[agent.AgentEvent]:
payload := msg.Payload
if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
model := a.app.CoderAgent.Model()
contextWindow := model.ContextWindow
tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
// 达到上下文窗口的 95% 时触发
if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
return a, util.CmdHandler(startCompactSessionMsg{})
}
}
关键阈值:95% 而不是 100%,留有余量避免边界问题。
5.3 摘要执行流程
步骤 1:启动摘要
case startCompactSessionMsg:
a.isCompacting = true
a.compactingMessage = "Starting summarization..."
return a, func() tea.Msg {
ctx := context.Background()
a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
return nil
}
步骤 2:获取消息历史
// internal/llm/agent/agent.go - Summarize 方法
func (a *agent) Summarize(ctx context.Context, sessionID string) error {
summarizeCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
defer a.activeRequests.Delete(sessionID + "-summarize")
defer cancel()
// 发布开始事件
event := AgentEvent{Type: AgentEventTypeSummarize, Progress: "Starting summarization..."}
a.Publish(pubsub.CreatedEvent, event)
// 获取所有消息
msgs, err := a.messages.List(summarizeCtx, sessionID)
if len(msgs) == 0 {
event = AgentEvent{Type: AgentEventTypeError, Error: fmt.Errorf("no messages to summarize")}
a.Publish(pubsub.CreatedEvent, event)
return
}
event = AgentEvent{Type: AgentEventTypeSummarize, Progress: "Analyzing conversation..."}
a.Publish(pubsub.CreatedEvent, event)
步骤 3:生成摘要提示
// 添加工具化的摘要提示
summarizePrompt := `Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including:
- What we did
- What we're currently doing
- Which files we're working on
- What we're going to do next`
promptMsg := message.Message{
Role: message.User,
Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
}
msgsWithPrompt := append(msgs, promptMsg)
步骤 4:调用摘要模型
event = AgentEvent{Type: AgentEventTypeSummarize, Progress: "Generating summary..."}
a.Publish(pubsub.CreatedEvent, event)
response, err := a.summarizeProvider.SendMessages(
summarizeCtx,
msgsWithPrompt,
make([]tools.BaseTool, 0), // 摘要不使用工具
)
步骤 5:保存摘要
// 创建摘要消息
msg, err := a.messages.Create(summarizeCtx, oldSession.ID, message.CreateMessageParams{
Role: message.Assistant,
Parts: []message.ContentPart{
message.TextContent{Text: summary},
message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()},
},
Model: a.summarizeProvider.Model().ID,
})
// 更新会话的摘要消息 ID
oldSession.SummaryMessageID = msg.ID
oldSession.CompletionTokens = response.Usage.OutputTokens
oldSession.PromptTokens = 0
步骤 6:计算成本并完成
// 计算摘要成本
model := a.summarizeProvider.Model()
usage := response.Usage
cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
model.CostPer1MIn/1e6*float64(usage.InputTokens) +
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
oldSession.Cost += cost
a.sessions.Save(summarizeCtx, oldSession)
event = AgentEvent{
Type: AgentEventTypeSummarize,
SessionID: oldSession.ID,
Progress: "Summary complete",
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
5.4 摘要后的对话恢复
当用户继续对话时,系统会:
- 加载摘要消息:从
SummaryMessageID找到摘要内容 - 重置 token 计数:将
PromptTokens和CompletionTokens归零 - 继续累积:新的对话内容继续累加到计数器
这样既保持了上下文的连续性,又释放了上下文窗口的空间。
六、pubsub 事件总线
6.1 通用事件总线设计
OpenCode 实现了一个轻量级的事件总线:
// internal/pubsub/broker.go
type Broker[T any] struct {
subscribers map[string]chan T
mu sync.RWMutex
}
func (b *Broker[T]) Subscribe(key string) chan T {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan T, 100) // 带缓冲的 channel
b.subscribers[key] = ch
return ch
}
func (b *Broker[T]) Publish(eventType string, payload T) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, ch := range b.subscribers {
select {
case ch <- payload: // 非阻塞发送
default:
}
}
}
6.2 事件类型
const (
CreatedEvent pubsub.EventType = "created"
UpdatedEvent pubsub.EventType = "updated"
DeletedEvent pubsub.EventType = "deleted"
)
type Event[T any] struct {
Type EventType
Payload T
}
6.3 在 TUI 中的应用
TUI 订阅会话和消息事件来保持界面同步:
// 订阅会话更新
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
a.selectedSession = msg.Payload
}
// 订阅 Agent 事件
case pubsub.Event[agent.AgentEvent]:
if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
a.isCompacting = false
return a, util.ReportInfo("Session summarization complete")
}
七、成本追踪
7.1 Token 使用记录
每次 LLM 调用后,系统会记录使用量:
// internal/llm/agent/agent.go - TrackUsage 方法
func (a *agent) TrackUsage(ctx context.Context, sessionID string, m models.Model, usage provider.TokenUsage) {
session, _ := a.sessions.Get(ctx, sessionID)
session.PromptTokens += usage.InputTokens + usage.CacheCreationTokens
session.CompletionTokens += usage.OutputTokens
// 计算成本
cost := m.CostPer1MIn/1e6*float64(usage.InputTokens) +
m.CostPer1MOut/1e6*float64(usage.OutputTokens)
session.Cost += cost
a.sessions.Save(ctx, session)
}
7.2 成本计算公式
cost := m.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) + // 缓存创建
m.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) + // 缓存读取
m.CostPer1MIn/1e6*float64(usage.InputTokens) + // 输入
m.CostPer1MOut/1e6*float64(usage.OutputTokens) // 输出
OpenCode 的模型定义包含完整的成本信息:
type Model struct {
ID ModelID
CostPer1MIn float64 // 每百万输入 token 成本
CostPer1MOut float64 // 每百万输出 token 成本
CostPer1MInCached float64 // 缓存输入成本
CostPer1MOutCached float64 // 缓存输出成本
}
八、数据库迁移
8.1 goose 迁移框架
OpenCode 使用 goose 管理数据库迁移:
// internal/db/connect.go
goose.SetBaseFS(FS) // 嵌入的迁移文件
if err := goose.SetDialect("sqlite3"); err != nil {
return nil, err
}
if err := goose.Up(db, "migrations"); err != nil {
return nil, fmt.Errorf("failed to apply migrations: %w", err)
}
8.2 迁移文件示例
-- internal/db/migrations/20250515105448_add_summary_message_id.sql
-- +goose Up
-- +goose StatementBegin
ALTER TABLE sessions ADD COLUMN summary_message_id TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- SQLite 不支持 DROP COLUMN,需要重建表
-- +goose StatementEnd
8.3 迁移命名规范
迁移文件遵循 YYYYMMDDHHMMSS_description.sql 格式:
20250424200609_initial.sql- 初始 schema20250515105448_add_summary_message_id.sql- 添加摘要字段
结语
OpenCode 的数据库与会话管理系统体现了几个重要的设计原则:
- 数据一致性由数据库保证:通过触发器自动维护派生数据
- 类型安全的 SQL 访问:sqlc 生成的代码既安全又易读
- 智能的资源管理:Auto-Compact 机制让长对话成为可能
- 清晰的事件驱动架构:pubsub 让组件间解耦
这套系统支撑着 OpenCode 的核心使用场景——多轮对话、长任务执行、上下文累积——是整个应用不可或缺的基础设施。
本系列下一篇文章将深入探讨 OpenCode 的 TUI 界面实现:Bubble Tea 框架的应用、页面系统设计、对话框机制、以及主题系统。