做了几年 LLM 应用落地,我最大的体会之一是:存储方案选得好不好,直接决定了项目的迭代速度。见过不少团队一上来就上向量数据库、Redis、消息队列,结果数据量还没跑起来,运维成本先压垮了整个项目。我自己这两年反而越来越倾向单文件方案——在绝大多数轻量级场景里,SQLite 这种轻量级数据存储方案,是 LLM 应用落地时最值得优先考虑的选项。
这篇文章我踩过的坑都写在里面了。如果你是正在做知识库问答、聊天机器人、Agent 工具集成,或者想在本地/移动端跑模型应用的同学,这篇内容应该对你有参考价值。我不打算讲数据库基础概念,只聊真实项目里的选型逻辑、建表方案和排错经验。
1. 别急着上向量数据库:LLM应用存储的第一性原则
1.1 LLM应用的数据画像:量不大、写多读少、要快
我们在规划存储之前,先冷静看看 LLM 应用产生的数据到底是什么形态。我接触过的项目里,核心数据逃不出这几类:多轮对话记录、Prompt 模板与变量、知识库文档切片及对应的向量、Agent 运行时的工具调用输入输出。这些数据的共同特点是:单条数据量不大,总量在几十万条以内,写入以追加为主,查询以"最近记录"和"关键字召回"为主。
这个画像跟传统业务系统的"高并发、强事务、多表关联"完全不同。它其实更适合一个"单机、单文件、管好事务"的数据库。很多人一想到 AI 应用就觉得必须上分布式存储,这个思维惯性往往才是项目变慢的根源。就拿最常见的知识库问答来说,一个几百篇文档的本地知识库,切片后通常也就几万条,SQLite 处理这个量级毫不费力。
1.2 SQLite的三个"反常识"优势
第一个优势是部署成本趋近于零。SQLite 不需要单独的数据库服务进程,不需要配置连接串,不需要管理用户权限,一个 .db 文件就是全部。对于跑在用户本地的工具、嵌入到移动端的模型应用、或者只需要在测试环境快速验证的 MVP 来说,这几乎是无缝贴合。
第二个优势是"单写多读"的特性在 LLM 场景下刚好够用。SQLite 默认同一时刻只允许一个写入者,但读操作可以并行。仔细想想 LLM 应用的操作模式:写入主要发生在"产生一条新对话""记录一次工具调用"时,读取则高频发生在"拼 Prompt""查知识库"时。也就是说写频率其实很低,读才是主旋律。这在 SQLite 的能力边界内完全够用。
第三个优势是生态和心智负担极低。排查问题可以直接用 DB Browser for SQLite 打开文件看数据,也可以在命令行 sqlite3 里跑 SQL。比起"容器里连不上数据库""连接池泄漏""权限配置错"这些中间件常驻问题,SQLite 给人的踏实感是很多重型组件给不了的。
1.3 什么时候真的需要重型存储
SQLite 不是银弹。我给自己定的判断标准很简单:
- 应用需要多实例部署共享同一个数据库文件(比如 K8s 多个 Pod 同时读写),这时候 SQLite 的文件锁会成为瓶颈;
- 向量检索量达到百万级,且需要近似最近邻的召回效果;
- 数据模型复杂到需要细粒度的行级权限控制。
出现这些信号再往 PostgreSQL + pgvector 或者专门的向量数据库迁移也不迟。而且因为 SQLite 的 SQL 语法和 PostgreSQL 高度接近,提前把表结构设计好,迁移成本远低于想象。我见过不止一个团队初期用 SQLite 验证商业逻辑,后来平滑迁到 PostgreSQL 的案例——关键在于表结构设计规范,而不是一开始就上重武器。
2. 三类高价值数据怎么落到SQLite:会话、知识库、Agent轨迹
2.1 会话记忆:最近N轮 + 分层摘要,别把原始对话一股脑塞进Prompt
对话记录是 LLM 应用最基础的数据。但"存储"和"可用"之间还隔着两层设计:一是怎样快速取出构建上下文需要的那部分,二是怎样处理"长对话超出上下文窗口"的问题。
我的标准做法是两张表。sessions 表存会话元信息,其中有个 summary 字段专门保存"到目前为止这段对话的摘要";messages 表按时间存每一条消息。当要构建上下文时,用一条 SQL 取最近 N 条消息;如果会话很长,就用 summary 加上最近的一小段原文,而不是全量历史。摘要的生成由 LLM 完成——每攒够比如 20 轮,就调用一次模型把旧对话压缩成一段摘要存回 sessions.summary。这个模式我跑了很久,效果比简单粗暴截断窗口好得多,Token 消耗也控制得住。
具体到查询,一条简单的 SQL 就能搞定最近 20 条消息的读取:
SELECT role, content, created_at FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT 20;只要(session_id, created_at DESC)索引存在,这个查询是微秒级的。真正让很多项目变慢的,不是这条查询本身,而是代码里反复"全量拉取历史再手动截断"的写法。
2.2 RAG知识库:BLOB向量、sqlite-vec扩展、FTS5关键词召回的组合拳
RAG 是 LLM 落地最热的场景,但"知识库切片加向量"的存储未必非得求助于向量数据库。在几万条切片的规模上,SQLite 完全可以扛住。具体有两条路线:
一条是把 embedding 序列化成 BLOB 存进表里,查询时用 numpy 或 torch 在内存里算余弦相似度。几万条向量加载到内存也就几十 MB,计算一次全部相似度耗时在几十毫秒量级,完全能接受。这条路线最稳,无需额外扩展。
另一条是用 sqlite-vec 这类向量扩展,在 SQL 里直接写向量距离查询,类似ORDER BY vec_distance_cos(embedding, ?) LIMIT 5。它对几万条级别的知识库检索足够快,业务代码也更简洁。
工程上我更喜欢组合拳:先用 FTS5 全文索引做关键词召回,取回候选集(比如 50 条),再对候选集做向量精排。这么做的好处是:十万条文档的关键词召回在毫秒级,精排只在 50 条上计算,几乎不增加时延,而且关键词召回还能兜底那些 embedding 表达不够准确的边缘情况。
需要特别提醒:FTS5 默认的 unicode61/porter 分词器对中文支持不太友好。实际项目里我倾向于在写入前用 jieba 这类工具做分词,把分词结果单独存一个字段再建 FTS 索引。这样召回质量会明显上一个台阶,尤其是处理产品名、人名这种专有名词时。
2.3 Agent工具调用与运行轨迹:JSON1扩展撑起审计与评估
智能体应用的存储比聊天机器人多一个硬需求:记录 Agent 每一步决策和工具调用的输入输出。这些数据不只是给用户看历史,更重要的是做追溯和评估——比如用 LLM-as-Judge 判断某次 Agent 的表现时,必须知道它当时调用了什么工具、传了什么参数、拿到了什么结果。
我用一张 agent_events 表解决:id、session_id、event_type、tool_name、input_json、output_json、timestamp。SQLite 内置的 JSON1 扩展支持 json_extract() 函数,我可以在 SQL 里直接按字段筛事件,比如"找出所有调用 search 工具且返回错误的记录"。这个能力虽然不如 PostgreSQL 的 JSONB 丰富,但做事件审计和效果分析完全够用。
SELECT tool_name, input_json, output_json FROM agent_events WHERE session_id = ? AND json_extract(output_json, '$.error') IS NOT NULL;3. 一张能扛住实际业务的表设计:从需求反推Schema
3.1 核心表结构:sessions、messages、chunks、llm_cache
直接给一套我验证过的建表 SQL(SQLite 方言):
CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, summary TEXT DEFAULT '', model TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')), content TEXT NOT NULL, tokens INTEGER DEFAULT 0, created_at INTEGER NOT NULL ); CREATE INDEX idx_messages_session_time ON messages(session_id, created_at DESC); CREATE TABLE chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id TEXT NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding BLOB, source TEXT, page_no INTEGER, created_at INTEGER NOT NULL ); CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id, content, tokenize='unicode61'); CREATE TABLE llm_cache ( prompt_hash TEXT PRIMARY KEY, prompt TEXT NOT NULL, response TEXT NOT NULL, model TEXT NOT NULL, created_at INTEGER NOT NULL );几个设计细节值得展开说一下:
- sessions.id 用 TEXT 存 UUID,方便未来分布式迁移;messages.id 用自增整数保证插入顺序。这个组合让"会话维度的有序读取"和"全局维度的唯一标识"互不干扰。
- created_at 统一存 INTEGER 毫秒时间戳。比 TEXT 日期排序快,也没有时区烦恼。LLM 应用的审计、成本统计、会话过期清理都依赖这个字段,选型值得认真对待。
- messages.role 加 CHECK 约束,数据库层面挡住脏数据。很多人喜欢在代码里校验,但数据库约束这道防线其实更可靠,尤其是团队协作时。
- tokens 字段第一版就加上。后面做成本统计、上下文截断策略、甚至给用户展示 Token 消耗时,你会发现这个字段极其重要。后期再补字段,SQLite 的 ALTER TABLE 会让你头疼。
3.2 索引策略与SQL写法:别再被LIKE全表扫描坑了
在 SQLite 里,查询能不能走索引,性能差距是数量级的。十万条数据,走索引的等值查询和范围查询是微秒到毫秒级,而LIKE '%关键词%'这种写法则是全表扫描,耗时可能上百毫秒。索引设计上,我建议:
- 高频查询"某会话最近的 N 条消息",建
(session_id, created_at DESC)复合索引; - 按时间过滤的场景,建
created_at单列索引; - RAG 关键词检索走 FTS5 虚拟表,不走普通 LIKE;
- 缓存表直接用 prompt_hash 主键做点查,天然有索引。
另外,ORM 层有时候会生成"看似走索引实则没走"的 SQL。建议拿到一条慢查询时,先跑一下EXPLAIN QUERY PLAN,确认是否命中索引。这一步排查成本不高,但能省掉很多线上慢查询的眼泪。
3.3 响应缓存表:用规范化哈希把Token费用打下来
LLM 调用是有成本瓶颈的,尤其客服、FAQ 这类场景,大量问题是重复的。我见过不少项目在这个点上选择了 Redis 做缓存,但问题是缓存的数据结构其实极简单:一个哈希对应一个响应。SQLite 一张 llm_cache 表就完全够了。
关键点在于 prompt_hash 怎么算:不能直接对原始 Prompt 做 md5,因为语义相同的问法("机票怎么退"和"怎么退机票")写进 Prompt 后字符串往往不一样。我一般的做法是把系统提示词、用户问题、关键上下文先做规范化处理——统一大小写、按语义单元排序、去掉多余空白,然后再哈希。这一步做好,缓存命中率可以显著提升。
我见过一个电商 FAQ 机器人,第一版缓存命中率就做到了 40% 左右,当月的模型费用直接砍掉一小半。这套机制在 SQLite 里实现成本极低,但收益实实在在。如果你正在为模型账单发愁,先别急着换供应商,把响应缓存做好往往更划算。
4. 性能实测与并发优化:十万条数据的真实底线
4.1 十万条场景的基准数据:查询、写入、检索分别多快
很多同学担心 SQLite 扛不住十万条数据。我直接说一下实测数字,环境是一台普通 i5 笔记本,16G 内存,NVMe 固态,SQLite 默认配置:
| 场景 | 数据量 | 执行方式 | 耗时 |
|---|---|---|---|
| 按会话取最近20条消息 | 10万消息 | 走复合索引 | <1ms |
| 全表行数统计 | 10万条 | 全表扫描 | 约20ms |
| FTS5关键词召回TOP50 | 5万文档块 | 全文索引 | 5~15ms |
LIKE '%关键词%'查询 | 10万条 | 全表扫描 | 100~200ms |
| 事务内批量写入1000条 | — | 单事务提交 | 约100ms |
这里最有价值的结论是:十万条对 SQLite 来说只是热身。真正的分水岭往往不是数据量,而是你有没有建索引、有没有用 FTS5、有没有合理利用事务。很多人踩的坑是把大量 INSERT 逐条提交,而不是包在一个事务里——逐条提交的耗时能比批量事务多一个数量级。
4.2 WAL模式与busy_timeout:终结"database is locked"
默认模式下 SQLite 的读写互斥很严重,LLM 应用里"一边记录日志、一边被请求查询"的场景很容易触发database is locked。解决办法就是三行 PRAGMA:
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;WAL(Write-Ahead Logging)模式下,读操作不会被写阻塞,写之间依然是串行,但对我们这种"低频写、高频读"的负载完全够用。busy_timeout 5000 表示竞争写锁时最多等待 5 秒,超时才抛错。这套配置我放到所有项目的初始化脚本里,实测能消掉 95% 以上的锁报错。
有一点必须提醒:WAL 模式会产生-wal和-shm两个伴生文件。如果你用 Docker 挂载卷或者手动备份,一定要把这三个文件一起处理。最好的做法是先执行PRAGMA wal_checkpoint;把 WAL 内容合并回主库文件,再复制 .db 文件。这个坑我见不少人踩过,直接导致数据库文件损坏、数据全丢。
4.3 迁移信号:什么时候必须换PostgreSQL
我给自己定的迁移信号很具体:
- 多个进程/实例同时写同一个库文件,锁冲突开始频繁出现;
- 数据量从十万级涨到千万级,查询出现明显瓶颈;
- 需要细粒度权限管理、行级安全或者数据库审计;
- 团队在做多租户产品,数据库层需要隔离。
出现这些信号,就往 PostgreSQL + pgvector 迁。好消息是 SQLite 到 PostgreSQL 的迁移路径比我预想顺滑得多——表结构设计照搬,SQL 大多等价,主要改的是类型映射和少量方言差异。所以从第一天起把表结构设计得规范,就是给自己留的退路。
5. 我在落地中踩过的坑:改字段、迁数据、移动端与调试工具
5.1 SQLite的ALTER TABLE限制:改字段类型必须重建表
SQLite 对 ALTER TABLE 的支持是所有关系型数据库里最克制的:只能改表名和加列。想改字段类型、删列、加约束,官方不给你捷径,只能重建表。网上搜"sqlite 修改字段类型",搜到的方案本质都是五步流程:
PRAGMA table_info(旧表)导出当前结构;ALTER TABLE 旧表 RENAME TO 旧表_tmp;- 用期望的新结构
CREATE TABLE 旧表(...); INSERT INTO 旧表 SELECT ... FROM 旧表_tmp拷贝数据;- 校验数据完整后
DROP TABLE 旧表_tmp。
我踩过最疼的一个坑:给某张业务表的 content 字段加 NOT NULL 约束时,历史数据里面有 NULL,拷贝完一查询就报错。所以执行任何重建前,一定要先跑SELECT count(*) FROM 表 WHERE 可疑列 IS NULL,提前把脏数据清掉或给默认值,再动手迁移。
5.2 MySQL转SQLite的迁移流程与数据校验
确实有团队从 MySQL 降级回 SQLite 的情况——不是为了性能,而是运维成本实在不划算。迁移时候注意几个点:
- 小表直接靠工具:DB Browser for SQLite 的导入向导,或者 Python 的
pandas.to_sql()都很快。 - 大表或带外键约束的,用
mysqldump导出 SQL,再写脚本做类型映射。MySQL 的AUTO_INCREMENT对应 SQLite 的INTEGER PRIMARY KEY AUTOINCREMENT,TINYINT对应INTEGER,DATETIME建议转成 INTEGER 毫秒时间戳。 - 迁移完必须做校验:逐表对比行数、抽查字段值、验证自增 ID 连续性。
我自己习惯写一个 Python 脚本,用字典做类型映射,逐表读取、转换、写入,最后自动跑一遍行数对比。这个脚本第一次跑完就留着了,后面所有迁移都复用,省了不少事。
5.3 移动端/嵌入式端的SQLite与本地模型实践
在移动端跑本地 LLM(比如集成 GGUF 量化模型)是最近明显变多的需求。这类应用的典型场景是:模型文件多个、对话历史越攒越多、推理结果需要缓存。如果全用 SharedPreferences 或 JSON 文件管理,很快会陷入数据混乱;用 SQLite 一个 .db 文件就能把"模型元数据 + 会话 + 消息 + 缓存"全部管起来,还天然支持事务和索引。
Android 上我推荐 Room 库,它把 SQLite 的 API 封装得非常顺手,配合 Kotlin 协程用很舒服。桌面端如果做本地 LLM 客户端,Python 标准库的 sqlite3 就够用,Node 环境用 better-sqlite3。至于 C# 加 VSCode 这个组合,官方 NuGet 包Microsoft.Data.Sqlite值得信赖,用法跟 ADO.NET 基本一致。Rocky Linux 这类服务器环境下装 SQLite 也很简单,Debian 系用apt install sqlite3,RedHat 系用dnf install sqlite,一条命令的事。
5.4 排查问题的工具箱:DB Browser与SQLite CLI
我的调试三板斧:
第一,装一个 DB Browser for SQLite,免费开源,Windows/macOS/Linux 都有。它能查看表结构和全部数据、直接执行 SQL、看查询计划,还能导出 CSV。遇到诡异的线上问题,先把 .db 文件拷下来打开,手工跑一遍 SQL 定位,比在代码里加日志快得多。
第二,Linux 服务器上排查,命令行 sqlite3 是标配。很多人不知道的是,只要先执行.headers on和.mode column,输出格式就会变得清晰得多。
第三,别忘了EXPLAIN QUERY PLAN SELECT ...,这是判断 SQL 有没有走索引的最快方式。LLM 应用的查询逻辑往往简单,但简单的 SQL 也可能因为字段顺序不一致而全表扫描,这个命令会让你少踩很多坑。
说实话,在第一次把项目里越来越多的存储需求从"计划中的向量数据库"降到"一张 SQLite 表"时,我自己也犹豫过。但跑了一段时间后,我发现对大量轻量级 LLM 应用来说,SQLite 不是"退而求其次",而是"更聪明的选择"。它让我能把精力花在提示词链路、模型策略和产品迭代上,而不是半夜爬起来处理数据库告警。
最后再分享一个每天都在用的小技巧:SQLite 的热备份其实非常简单——先执行PRAGMA wal_checkpoint;把 WAL 内容合并回主库,然后直接复制 .db 文件就行。我甚至把这个命令写成了 crontab 任务,每天凌晨自动备份一次。对于十万条数据量级的 LLM 应用,这套备份方案简单到几乎可以忽略成本,却足够让人安心。