给 AI 面试官装上记忆:SQLite 历史落库实战(七)


📮 本系列首发于公众号「GO语言入门之路」,系列聚合目录。前文见 第四篇:飞书机器人 ~ 第六篇:部署与成本监控。

上周你用我们的 AI 面试官面了 Go 后端工程师,拿了 65 分;这周又面了一场,78 分。进步了,但进步在哪?反复栽跟头的还是不是同一个维度?——报告躺在聊天记录里越滚越远,这些问题根本答不上来。

答案是:让面试官有记忆。本篇给机器人加上 SQLite 落库:每场面试的问答明细、评估、评分报告全部入库,飞书里发「历史」就能回看任何一场。

落库时机与依赖方向:一次可选的能力注入

先定两个设计原则,都是产品级 Agent 的经验之谈:

  1. 落库是副作用,绝不能影响主流程。报告生成成功但落库失败时,用户手里的报告一文不少,只在末尾附一行警告。
  2. 接口定义在消费侧,而不是实现侧。Store 接口由 bot 包定义,internal/store 去实现它——这样 bot 不 import 任何具体存储,传 nil 就是整体关闭历史功能。这条依赖方向让「换 PostgreSQL」变成只新增一个包的事。

落库点选在 Finish 之后两处:正常面完和「结束」指令提前结束,走的是同一个 saveHistory。报告由 report.Markdown() 生成后,连同状态机里的整场记录一起交给 Store:

// saveHistory 报告落库。失败不阻塞回复(报告正文已经发出),只附一行警告。
func (b *Bot) saveHistory(ctx context.Context, key string, s *session, reportMD string) string {
    if b.store == nil {
        return ""
    }
    rec, ok := s.it.(Recorder) // 状态机可选实现:交出整场记录
    if !ok {
        return ""
    }
    pos, overall, turns := rec.Record()
    if _, err := b.store.Save(ctx, key, pos, overall, reportMD, turns); err != nil {
        return "\n\n(⚠️ 本场历史落库失败:" + err.Error() + ")"
    }
    return "\n\n本场记录已存入历史,回复「历史」随时回看。"
}

注意 Recorder 是个可选接口:bot 对状态机的依赖面(Interview 接口)一个方法没加,想落库的状态机实现 Record() 即可,测试里的 fake 不实现也照常跑。

表结构设计:两张表,一个取舍

表结构刻意保持简单——两张表起步,报告 Markdown 整段存 interviews.report_md,不拆维度表:

表 字段 设计理由
interviews id / chat_key / position / overall / report_md / created_at 一场一行;chat_key 隔离不同会话;报告整段存,给人读的文档不做聚合分析
answers interview_id / seq / dimension / question / answer / confidence / coverage / followups 一题一行,按 (interview_id, seq) 有序;维度、置信度、覆盖度留作以后的对比分析素材
索引 idx_interviews(chat_key, created_at DESC)、idx_answers(interview_id) 「历史」列表按会话+时间倒序取,高频查询必须走索引

「报告不拆维度表」值得展开一句:报告本质是给人阅读的文档,不是要做 BI 聚合的数据。把它拆成维度分数表,唯一的收益是「按维度查历史均分」——这个需求真出现时再加一张统计表即可,别为想象中的需求预付设计复杂度。

SQLite 驱动选 modernc.org/sqlite(2026-11-06 核对自 Go Module Proxy,最新版 v1.61.1):纯 Go 实现,不需要 CGO。意味着交叉编译不用装 gcc、Docker 镜像可以直接用 alpine/distroless,部署体验和无 CGO 的简历解析包(ledongthuc/pdf)一脉相承。

SQLite 并发:单写者的坑,一行配置填平

SQLite 的经典限制:同一时刻只允许一个写入者。我们的 bot 每个会话虽然有 mutex 串行化,但 Store 是进程级共享资源,多个会话同时落库时写入方可能互撞 SQLITE_BUSY。

与其在代码里写重试,不如在连接池这一层直接串行化:

db, err := sql.Open("sqlite", path)
// 所有读写排队走单连接,从根上消灭并发写互撞
db.SetMaxOpenConns(1)
for _, p := range []string{
    "PRAGMA journal_mode=WAL",  // WAL:读写不互堵,崩溃可恢复
    "PRAGMA busy_timeout=5000", // 撞锁等 5s 而不是立刻报错
    "PRAGMA foreign_keys=ON",
} {
    if _, err := db.Exec(p); err != nil { /* 处理错误 */ }
}

MaxOpenConns(1) 把所有读写串成单连接队列,配合 busy_timeout 兜底,调用方完全不用感知并发。代价是吞吐——一个面试机器人的写入量,离 SQLite 的天花板还差着几个数量级。这个取舍我们有对应测试背书:20 个 goroutine 并发 Save,全部成功、一条不丢。

「历史」指令:IM 里的列表交互怎么做

IM 和网页不同:没法渲染可点击的表格,交互全靠纯文本对话。经典做法是列表 + 序号缓存——先发带序号的列表并缓存在机器人内存里,用户回复序号即定位:

📋 最近 5 场面试:
1. SRE · 综合 91 分 · 10-31 09:20
2. Go 后端工程师 · 综合 80 分 · 10-30 18:22

回复「历史 序号」查看对应完整报告。
// 「历史」→ 查库、缓存列表、发序号列表
items, _ := b.store.Recent(ctx, key, 5)
b.history.Store(key, items)
// 「历史 2」→ 从缓存按序号取,再用 ID 查完整报告
item := v.([]HistoryItem)[n-1]
rep, err := b.store.Report(ctx, key, item.ID)

另一个容易忽略的细节是越权防护:Report 查询带 AND chat_key = ? 条件,A 会话没法通过猜 ID 看到 B 会话的报告。机器人处理的是多人消息,这类防护在一开始就要做进去,而不是等出事再补。

还有个数据一致性的坑:用户「结束」提前打断时,最后一题可能已作答、已评估,但还挂在状态机的 Pending 字段里没归档。Record() 实现时要把 Pending(如有作答)一并入库,否则报告和问答明细对不上账。

测试:fake 验证行为,真库验证 SQL

新增两类测试,各司其职:

  • bot 包用内存 fakeStore:验证「落库时机、失败警告、无 Recorder 不落库、历史列表与选号」这些行为契约,不碰数据库;
  • store 包用临时文件真库(t.TempDir() 下的真实 SQLite):验证 SQL 本身——会话隔离、倒序、limit、重开进程数据还在、以及前面说的并发写串行化。

两类加起来 12 个用例,go test ./... 依旧零外部依赖、秒级完成。

小结与预告

至此,这个系列的产品闭环基本完整:出题、追问、打分 → 简历解析 → Web/飞书双端 → 语音 → 7×24 部署与成本监控 → 历史落库。一个可以从零跟到部署上线的 Go + LLM 项目。

下一篇是系列收官:架构演进全回顾 + 「fork 改造成你自己的 Agent」路线图——面试教练改成英语口语陪练、公务员结构化面试,各要动哪几层。

相关阅读:系列聚合目录 · 第四篇:飞书机器人 · 第五篇:语音消息 · 第六篇:部署与成本监控

版本说明

  • modernc.org/sqlite:v1.61.1(2026-11-06 核对自 proxy.golang.org 的 modernc.org/sqlite@latest;纯 Go 实现、无 CGO,源码仓库 gitlab.com/cznic/sqlite)
  • 安装命令:go get modernc.org/sqlite@v1.61.1

本文系 AI 辅助生成,内容已经人工核对,如有错误欢迎评论区留言指正。

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

📮 关注公众号「GO语言入门之路」
GO语言入门之路 公众号二维码

每周更新 Go + AI 应用开发实战:AI 面试官完整项目、MCP、Agent 工程化,完整源码随文章首发。

微信扫码,或搜索「GO语言入门之路」关注,第一时间获取系列更新与源码,不再错过任何一篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策