博客
首页归档关于搜索

关联站点

CodeRunCommon AuthNav2文件中转站搜索引擎ZBookSBTI 人格测试OSS对象存储在线翻译云笔记

鄂ICP备19019526号

© 2026 博客

  1. 首页
  2. OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四)

OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四)

2026年5月11日·约 7 分钟·2098 字·0 次阅读
AI大模型
OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四)

目录

  • 引言
  • 一、数据库架构设计
  • 1.1 SQLite 选型考量
  • 1.2 数据库连接配置
  • 1.3 核心表结构
  • 1.4 自动触发器 (Triggers)
  • 二、sqlc 代码生成
  • 2.1 什么是 sqlc
  • 2.2 SQL 查询文件
  • 2.3 生成的 Go 代码
  • 三、会话服务实现
  • 3.1 Session 数据结构
  • 3.2 会话服务接口
  • 3.3 三种会话类型
  • 3.4 事件发布订阅
  • 四、消息服务与内容模型
  • 4.1 消息角色
  • 4.2 内容部分 (Content Parts)
  • 4.3 完成原因
  • 4.4 消息创建流程
  • 五、Auto-Compact 智能摘要机制
  • 5.1 为什么需要 Auto-Compact
  • 5.2 触发条件
  • 5.3 摘要执行流程
  • 5.4 摘要后的对话恢复
  • 六、pubsub 事件总线
  • 6.1 通用事件总线设计
  • 6.2 事件类型
  • 6.3 在 TUI 中的应用
  • 七、成本追踪
  • 7.1 Token 使用记录
  • 7.2 成本计算公式
  • 八、数据库迁移
  • 8.1 goose 迁移框架
  • 8.2 迁移文件示例
  • 8.3 迁移命名规范
  • 结语

OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制

引言

在构建一个真正实用的 AI 编程助手时,仅仅让 AI 理解代码和执行工具是不够的。会话管理和长期记忆同样是核心能力。当用户与 OpenCode 进行多轮对话时,系统需要:

  1. 维护完整的对话历史
  2. 追踪每个会话的 token 使用量和成本
  3. 在上下文窗口接近满时自动压缩历史
  4. 支持会话的创建、切换、删除等操作

本文将深入剖析 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
}

优势:

  1. 类型安全:编译时检查 SQL 参数类型
  2. IDE 支持:可以跳转、引用、重构
  3. SQL 验证:sqlc 验证 SQL 语法正确性
  4. 可读性强:生成的代码清晰易懂

三、会话服务实现

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。当对话越来越长时:

  1. 成本增加:每次请求都发送完整历史
  2. 性能下降:长上下文推理更慢
  3. 可能溢出:超过上下文限制导致无法继续

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 摘要后的对话恢复

当用户继续对话时,系统会:

  1. 加载摘要消息:从 SummaryMessageID 找到摘要内容
  2. 重置 token 计数:将 PromptTokens 和 CompletionTokens 归零
  3. 继续累积:新的对话内容继续累加到计数器

这样既保持了上下文的连续性,又释放了上下文窗口的空间。

六、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 - 初始 schema
  • 20250515105448_add_summary_message_id.sql - 添加摘要字段

结语

OpenCode 的数据库与会话管理系统体现了几个重要的设计原则:

  1. 数据一致性由数据库保证:通过触发器自动维护派生数据
  2. 类型安全的 SQL 访问:sqlc 生成的代码既安全又易读
  3. 智能的资源管理:Auto-Compact 机制让长对话成为可能
  4. 清晰的事件驱动架构:pubsub 让组件间解耦

这套系统支撑着 OpenCode 的核心使用场景——多轮对话、长任务执行、上下文累积——是整个应用不可或缺的基础设施。


本系列下一篇文章将深入探讨 OpenCode 的 TUI 界面实现:Bubble Tea 框架的应用、页面系统设计、对话框机制、以及主题系统。

相关文章

  • 灯塔5月11日
  • OpenCode 深度解析系列总结:开源 AI 编程助手的现在与未来 | 深度解析(十)5月11日
  • OpenCode 安全机制:从代码执行到权限管理的深度剖析 | 深度解析(九)5月11日

评论

加载评论中…

发表评论

返回首页