A13第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

会话历史与持久化

会话历史与持久化 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

把聊天记录变成可靠的数据:会话、消息、调用三张表怎么分工;历史长度怎么控制;写入怎么做到不重不漏。

问题场景

刷新页面,聊天记录没了;重试一次,同一个问题出现两条;换台设备,什么都没有。「先跑起来再说」的存储方案,每个坑都会回来找你。

核心解释

本站用三张表承载一次对话(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
);

三个设计要点:

  1. 消息与调用分开存。一条 assistant 消息可能对应多次模型调用(工具循环,A17 课展开);调用记录(model、token、耗时)是排障与算账的真相来源——A03 课「记录模型标识」的承诺在这里兑现。
  2. 占位消息有完整生命周期:用户消息在生成前先写入;assistant 先写占位(status='streaming'、内容为空),流结束后 UPDATE 补全内容与状态;一字未出且失败就 DELETE 占位。A11 课遗留的「刷新后永久转圈」在这里收尾:加载历史时把遗留的 streaming 消息标记为 error。
  3. 模型配置也是记录的一部分:消息和调用上都存 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 字(本站做法),不额外调模型起名。

动手任务

  1. 打开本站数据库文件,用 SQL 查最近一个会话的全部消息与调用记录,核对 token 数、耗时与 model 字段。
  2. 制造一次失败并重试,然后查消息表:确认用户消息只有一条、上次未完成的 assistant 残留被清理。
  3. 写出「防重复写入」的约束方案(唯一索引或插入前检查),说明它在什么并发场景下仍可能漏。

常见错误

  • 每次重试都 INSERT 用户消息:问题重复、历史膨胀,发给模型的上下文也跟着重复。
  • 占位消息无人收尾:状态永远 streaming,界面上永远转圈,数据里永远脏着。
  • 调用记录缺失:排障时只剩「它说它错了」,没有 model、token、latency 可查。
  • 存库时就裁剪历史:回看和换设备时内容丢失,也没法审计当时模型看到了什么。

检查与复盘

自查清单:

  • 三张表各自的职责与关联字段说得清
  • streaming 占位的完整生命周期(建立、更新、删除)说得出
  • 重试场景下三张表各自怎么变化,能口述

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读会话持久化的表结构与历史查询代码。为什么「消息」和「调用」要分成两张表?查询为什么先 DESC 再 reverse?

CREATE TABLE ai_message (
  id TEXT PRIMARY KEY, conversation_id TEXT, order_index INTEGER,
  role TEXT, content TEXT, refs TEXT, status TEXT, model TEXT, created_at TEXT
);
CREATE TABLE ai_call (
  id TEXT PRIMARY KEY, conversation_id TEXT, message_id TEXT,
  model TEXT, prompt_tokens INTEGER, completion_tokens INTEGER,
  latency_ms INTEGER, status TEXT, error TEXT, created_at TEXT
);

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();
Q2故障定位中等

bug 现场:一次生成中途服务进程被杀。之后每次打开这个会话,那条消息永远显示「生成中」的转圈。查库发现该消息 status 一直是 'streaming'。根因与修法?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...