--- doc_id: hfOmUoGoBOfA description: "用一张 DynamoDB 表, 只靠 partition key 和 sort key 存企业内部 AI 聊天应用的多用户会话和消息记录, 不建 GSI 或 LSI, 靠每个会话两条记录加事务同时做到按 id 查会话和按最近时间排序. 是一篇带 key 编码, boto3 代码和成本估算的表设计记录." tags: [dynamodb, aws, chatbot, data-modeling] created_at: "2026-11-06" updated_at: "2026-11-06" --- # 企业内部 AI 聊天应用的聊天记录怎么存: 一张 DynamoDB 表, 不建索引 ChatGPT 出来以后, 很多企业都想做一个自己内部的聊天工具. 说白了就是数据要隐私, 员工问的问题, 贴进去的内容, 不想离开公司自己的环境. 聊天这一块其实不难做, 调一下模型 API, 把回答流式吐给前端就完事了. 真正麻烦的是后面那件事: 每个用户登录以后的聊天数据存在哪, 怎么保证张三看不到李四的记录, 侧边栏那一串历史会话怎么按最近时间排好. 这个需求是实打实的, 看起来容易, 要做好其实没那么容易. 我的方案很轻: 一张 DynamoDB 表, 只用原生的 partition key (下文简称 pk) 和 sort key (sk), 不建任何 index. 每个会话存两条记录, 一条负责 "按 id 找到它", 一条负责 "按最近时间排序", 用户每发一条消息, 用一个事务把这几条记录一起改掉. 这篇讲清楚 key 怎么编码, 为什么这么编, 读写各要几次请求, 以及一个月 100 万轮对话数据库花多少钱. ## 要存的东西只有三样: 用户, 会话, 消息 先把范围划死. 这篇只讲 chat: 不讲 agent, 不讲文件上传和产出文件, 也不讲 ChatGPT 和 Claude.ai 里的 Project 那种把会话归组的功能. 剩下的实体只有三个. 用户 (user). 多租户是硬要求, 但不需要单独建一张用户表: 用户登录以后, 认证系统自然会给出一个 `user_id`, 数据按它隔离就够了. 会话 (session), 就是侧边栏里的一条聊天 thread. 字段: | 字段 | 说明 | | :--- | :--- | | `session_id` | 会话 id | | `user_id` | 会话属于谁 | | `title` | 会话标题 | | `create_time` | 创建时间 | | `last_message_time` | 最近一条消息的时间, 侧边栏按它排序 | | `message_count` | 消息条数 | 消息 (message), 是会话里一来一回的一轮: 用户问一句, AI 答一段, 存成同一条记录. 字段: | 字段 | 说明 | | :--- | :--- | | `message_id` | 消息 id, 同时决定消息的先后顺序 | | `session_id` | 属于哪个会话 | | `create_time` | 用户发出问题的时间 | | `response_start_time` | AI 开始返回的时间 | | `response_end_time` | AI 返回结束的时间 | | `input_body` | 用户的输入, 纯文本 | | `output_body` | AI 的回答, 纯文本 | | `input_token` | 输入的 token 数 | | `output_token` | 输出的 token 数 | | `system_prompt_id` | 这一轮用的 system prompt 模板 | | `status` | AI 有没有返回: `pending`, `done`, `failed` | `status` 是这次新加的. 问题和回答放在同一条记录里, 但它们不是同时产生的: 用户一按发送, 问题就该落盘, 而回答要等几秒甚至几十秒. 有了 `status`, 就能先把问题存下来, AI 回完再补全, 中途失败了也看得出来. 还有一条规矩: 不给用户删除聊天记录的按钮. 删除是后台管理员的事, 不在这套读写流程里. 这一条省掉的麻烦比看上去多, 后面会说到. ## 两个查询决定了表怎么设计 DynamoDB 设计表的套路是先列查询, 再定 key. 一个聊天应用的界面, 真正高频的读只有两个: 1. 打开应用, 侧边栏列出 "我的会话", 按最近一条消息的时间倒序, 一页 20 条, 往下滚再加载. 2. 点开一个会话, 显示标题和最近的几十轮对话, 往上滚再加载更早的. 这两个查询有个共同点: 都是 "在某个范围里, 按某个顺序取一页". 这正好就是 DynamoDB 的 Query 干的事: 指定一个 pk, 按 sk 的字典序正着或倒着取, 带分页. 没有 join, 没有多条件筛选, 也没有跨用户的统计. 查询方式和 DynamoDB 的模型对得上, 这是我选它的根本原因. 按请求付费, 不用管服务器, 延迟稳定, 这些是附带的好处. ## 难点在侧边栏: 会话既要按 id 找, 又要按最近时间排 消息那个查询好办: 一个会话的消息放在同一个 pk 下, sk 用一个按时间递增的 id, 倒着 Query 就是最近的消息. 难的是会话. 侧边栏要按 `last_message_time` 排序, 那 `last_message_time` 就得在 sk 里. 可是一旦它在 sk 里, 你只拿着一个 `session_id` (比如用户从链接 `/chat/` 直接打开) 就找不到这条记录了, 因为 GetItem 要完整的 sk, 而你不知道这个会话最后一次说话是什么时候. 反过来, sk 里先放 `session_id` 再放时间, 也就是 `SES##