如何设计 AI Chat 的数据库模型
很多人做 AI Chat 的第一张表,会从 messages 开始。
字段也很直觉:id、conversation_id、role、content、created_at。用户发一条消息,插入一行;模型回一条消息,再插入一行。页面读取消息列表,渲染出来。
这个模型能支撑 Demo,但支撑不了真实 AI Chat。
真实 AI Chat 里,一条助手消息背后可能包含一次 run、多次 streaming delta、一次或多次工具调用、检索结果、token 用量、失败重试、用户中断、模型 fallback 和最终写入的消息内容。
所以 AI Chat 的数据库模型不能只围绕“聊天记录”设计,还要围绕“生成过程”设计。
AI Chat 和普通 IM 的区别
普通 IM 的消息通常是确定的。
用户输入内容,点击发送,服务端保存消息,其他客户端同步消息。消息一旦发送成功,主要状态就是已发送、已读、撤回、删除。
AI Chat 不一样。助手消息不是用户输入的现成内容,而是一次生成任务的结果。
这带来几个差异:
- 助手消息会经历生成中、部分完成、完成、失败、取消等状态。
- 一次回复可能边生成边展示,最终内容逐步累积。
- 生成过程中可能调用工具或检索文档。
- 同一条用户消息可能触发多次重试,产生多个候选助手回复。
- 用户可能编辑历史消息,从某个节点分叉出新对话。
- 数据库要同时服务页面展示、上下文组装、审计排查和成本统计。
如果只用一张 messages 表,很快会把所有状态都塞进 metadata,最后变成难以查询和维护的半结构化泥团。
核心实体怎么拆
一个可维护的 AI Chat 模型,至少可以拆成五类实体:
chat_threads:一段对话或会话。chat_messages:用户可见的消息。ai_runs:一次 AI 生成任务。ai_run_steps:生成过程中的步骤,如模型调用、检索、工具调用。ai_usage:token、延迟、成本等用量数据。
如果支持工具调用,还需要:
tool_calls:模型请求执行的工具。tool_results:工具执行结果。
如果支持文件和引用,还需要:
message_attachments:消息关联的文件、图片、文档。message_sources:助手回复引用的来源、检索片段或 citation。
这不是唯一设计,但它体现了一个重要原则:消息是用户看到的结果,run 是系统执行的过程。两者要关联,但不要混成一个概念。
会话表:chat_threads
chat_threads 表示一个对话容器。
它通常包含:
会话表不要承担太多生成细节。它主要回答:这是谁的对话、属于哪个业务场景、当前是否可见、列表页如何展示。
如果应用支持多租户,tenant_id 非常重要。后续权限、配额、成本统计、数据隔离都离不开它。
消息表:chat_messages
chat_messages 保存用户可见的消息。
常见字段包括:
这里有几个设计点。
第一,role 不一定只有 user 和 assistant。
前端页面上主要展示 user 和 assistant,但系统内部可能需要 system event、tool result、error event。是否放在同一张消息表,取决于产品是否要展示它们。如果只是运行日志,可以放到 run steps;如果用户需要看到“正在查询订单”“已读取 3 篇文档”,可以用 event message 或 step event 支持 UI。
第二,助手消息应该允许先创建、后填充。
Streaming 场景下,后端可以先插入一条 status=streaming 的 assistant message,再逐步更新内容。生成完成后改为 completed。失败或取消时保留已生成的部分,状态改为 failed 或 cancelled。
第三,parent_message_id 可以支持分叉。
用户编辑历史消息后重新生成,原来的后续消息不一定要删除。更好的方式是形成一棵消息树或使用 branch 标识。简单产品可以先不做完整分叉,但至少要意识到“会话不是永远线性的”。
Run 表:ai_runs
ai_runs 记录一次 AI 生成任务。
一条 assistant message 可以由一个 run 生成,也可能经过失败重试后关联多个 run。一个 run 也可能还没生成最终 assistant message,比如在工具调用失败前就终止了。
常见字段包括:
run 的价值是让一次生成可追踪。当前端反馈“这次回答卡住了”,你应该能通过 run id 找到:
- 用户发了哪条消息。
- 系统选择了哪个模型。
- 上下文用了哪些消息和检索片段。
- 是否调用了工具。
- 是否发生 retry 或 fallback。
- 首 token 花了多久。
- 最终为什么失败或成功。
没有 run 表,AI Chat 的排查通常只能靠翻日志。
Step、工具调用和检索记录
现代 AI Chat 经常不是一次模型调用结束。
它可能先检索文档,再调用模型;模型要求调用工具,工具返回结果后再调用模型;中途还可能等待用户确认。
这时需要 ai_run_steps。
工具调用可以单独建表,因为它通常需要更强审计。
tool_calls 可以记录工具名、参数、调用状态、是否需要用户确认、执行者、幂等 key。tool_results 可以记录结果摘要、错误、外部系统返回 id。
注意,不建议把大型工具结果完整塞进下一轮 prompt,也不一定要完整塞进消息内容。数据库可以保存完整结果,但上下文组装时只注入必要摘要和关键字段。
用量与成本:ai_usage
AI 应用一定要从早期就记录用量。
ai_usage 可以按 run 记录:
这些数据不只是给财务看,也会影响产品和工程决策。
例如,某个入口成本突然上升,可能是 prompt 版本变长、检索片段过多、历史裁剪失效或模型输出失控。没有 usage 表,就很难定位。
Context Snapshot 是否需要存
很多 AI 问题需要复现,但复现 AI 输出并不容易。
模型可能升级,随机采样可能不同,外部工具结果可能变化,检索库内容也可能更新。因此,生产系统常常需要保存本次请求的上下文快照。
可以设计 ai_context_snapshots:
是否保存完整上下文,要看安全和合规要求。包含用户隐私、商业数据或密钥风险的内容必须脱敏或只保存引用。不要为了调试方便,把所有敏感上下文原样写进日志和数据库。
前端状态如何映射数据库
前端看到的是消息列表,但底层状态来自 message 和 run 的组合。
一个常见流程是:
前端可以把 assistant_message_id 作为 UI 上正在生成的消息,把 run_id 作为操作对象。点击停止、重试、查看日志、反馈质量时,都应该带上 run id。
对于前端工程师来说,最重要的是不要只把 AI 回复当成一个最终字符串。它是一个持续变化的对象,有生命周期、有错误、有来源、有成本。
常见坑
坑一:只有 messages 表,没有 runs 表。
这样很难追踪一次生成为什么失败、用了哪个模型、消耗多少 token、是否调用工具。消息表负责展示,run 表负责过程。
坑二:把所有信息都塞进 metadata。
metadata 很适合放低频扩展字段,但不适合承载核心查询。状态、模型、token、错误码、耗时、租户、用户这些字段应该结构化。
坑三:不区分展示内容和模型上下文。
数据库里完整保存的消息,不等于下一次要完整发给模型。上下文组装应该有自己的策略和快照。
坑四:失败消息直接删除。
失败、取消、中断是重要状态。删除它们会让用户困惑,也会让排查失去线索。更好的做法是保留状态和可重试入口。
坑五:没有幂等设计。
网络重试、浏览器刷新、用户连点发送,都可能创建重复用户消息或重复 run。发送消息接口应该支持客户端生成 id 或 idempotency key。
坑六:忽略删除和合规。
AI Chat 往往包含敏感内容。删除会话时,消息、附件、上下文快照、日志、工具结果和向量索引引用都需要有一致策略。
坑七:过早追求复杂消息树。
分叉、编辑、版本历史很有用,但不是所有产品第一天都需要完整实现。可以先保留 parent_message_id 或 branch_id 的扩展空间,避免后续完全重构。
小结
AI Chat 的数据库模型,不只是保存聊天记录。它要同时支持用户可见消息、AI 生成过程、工具调用、上下文追踪、用量统计、错误排查和未来扩展。
一个实用的起点是把 chat_threads、chat_messages、ai_runs、ai_run_steps 和 ai_usage 分开。消息负责展示,run 负责生命周期,step 负责过程细节,usage 负责成本和性能。
对前端工程师来说,理解这套模型会直接改善 UI 设计:你会知道为什么助手消息需要 streaming 状态,为什么停止和重试要基于 run,为什么错误不能只显示一句失败,为什么一次 AI 回复背后其实是一条可追踪的系统链路。