会话历史与持久化
会话历史与持久化 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
把聊天记录变成可靠的数据:会话、消息、调用三张表怎么分工;历史长度怎么控制;写入怎么做到不重不漏。
问题场景
刷新页面,聊天记录没了;重试一次,同一个问题出现两条;换台设备,什么都没有。「先跑起来再说」的存储方案,每个坑都会回来找你。
核心解释
本站用三张表承载一次对话(better-sqlite3,见 lib/db 与 app/api/ai/chat/route.ts):
-- 会话表:一次对话一行,context 存页面上下文(JSON)
CREATE TABLE ai_conversation (
id TEXT PRIMARY KEY, title TEXT, context TEXT,
created_at TEXT, updated_at TEXT
);
-- 消息表:每条消息一行,order_index 维护顺序
CREATE TABLE ai_message (
id TEXT PRIMARY KEY, conversation_id TEXT, order_index INTEGER,
role TEXT, content TEXT, refs TEXT, -- refs:命中的课程引用(JSON)
status TEXT, -- streaming | done | error | cancelled
model TEXT, created_at TEXT
);
-- 调用表:每次模型请求一行,message_id 关联到消息
CREATE TABLE ai_call (
id TEXT PRIMARY KEY, conversation_id TEXT, message_id TEXT,
model TEXT, kind TEXT,
prompt_tokens INTEGER, completion_tokens INTEGER, latency_ms INTEGER,
status TEXT, error TEXT, created_at TEXT
);
三个设计要点:
- 消息与调用分开存。一条 assistant 消息可能对应多次模型调用(工具循环,A17 课展开);调用记录(model、token、耗时)是排障与算账的真相来源——A03 课「记录模型标识」的承诺在这里兑现。
- 占位消息有完整生命周期:用户消息在生成前先写入;assistant 先写占位(
status='streaming'、内容为空),流结束后 UPDATE 补全内容与状态;一字未出且失败就 DELETE 占位。A11 课遗留的「刷新后永久转圈」在这里收尾:加载历史时把遗留的 streaming 消息标记为 error。 - 模型配置也是记录的一部分:消息和调用上都存 model 字段,回看时能知道每条回答出自哪个模型。
历史长度的两套口径再强调一次(A02 的结论落到存储层):发给模型裁剪,存进库里全量。
示例拆解
真实 route 里的关键查询(简化):
// 发给模型最多 20 条:DESC 取最近,再反转回正序
const history = db.prepare(
"SELECT role, content FROM ai_message WHERE conversation_id = ? " +
"AND role IN ('user','assistant') ORDER BY order_index DESC LIMIT 20"
).all(convId);
history.reverse();
// 重试幂等:retry 请求不带 message,走 A12 的分支,不 INSERT 用户消息
// 顺序号:插入前取当前最大值 + 1
const { n } = db.prepare(
"SELECT COALESCE(MAX(order_index), 0) AS n FROM ai_message WHERE conversation_id = ?"
).get(convId);
关键点:
- 存库和裁剪是两个独立动作,别在写库时就丢内容。
- 防重复写入的最后防线:给
(conversation_id, order_index)建唯一索引,并发或重放时插入直接失败而不是悄悄重复。 refs、context用 JSON 字符串存 TEXT 列,读取时JSON.parse——存取两侧约定好形状,解析同样要过 A09 的校验纪律。- 会话列表按
updated_at倒序;标题取首条用户消息前 30 字(本站做法),不额外调模型起名。
动手任务
- 打开本站数据库文件,用 SQL 查最近一个会话的全部消息与调用记录,核对 token 数、耗时与 model 字段。
- 制造一次失败并重试,然后查消息表:确认用户消息只有一条、上次未完成的 assistant 残留被清理。
- 写出「防重复写入」的约束方案(唯一索引或插入前检查),说明它在什么并发场景下仍可能漏。
常见错误
- 每次重试都 INSERT 用户消息:问题重复、历史膨胀,发给模型的上下文也跟着重复。
- 占位消息无人收尾:状态永远 streaming,界面上永远转圈,数据里永远脏着。
- 调用记录缺失:排障时只剩「它说它错了」,没有 model、token、latency 可查。
- 存库时就裁剪历史:回看和换设备时内容丢失,也没法审计当时模型看到了什么。
检查与复盘
自查清单:
- 三张表各自的职责与关联字段说得清
- streaming 占位的完整生命周期(建立、更新、删除)说得出
- 重试场景下三张表各自怎么变化,能口述
参考资料
- SQLite 官方文档:ON CONFLICT —— 唯一约束与冲突处理,防重复写入的底层机制
- MDN:JSON 对象 —— refs/context 的序列化与解析
- Next.js 文档:Route Handlers —— 写入发生在服务端路由中
- 下一课 A14 把全部零件装成一个能交付的聊天工作台。
读完这一课了?
标记完成后,工作台会安排接下来的学习。