主题
Day 3 — 会话管理 (Session)
读完这章你能获得什么:理解多用户多轮对话的管理机制——如何为每个用户保持独立的对话上下文,如何存储和清理会话数据。
一、前情提要
经过 Day 1-2,我们有了:
- Gateway:统一消息格式 + 路由分发
- Channel:CLI / WebChat / Webhook 多种接入方式
但现在有个尴尬的问题:
用户说了"我叫小明",然后接着问"我叫什么名字?"——系统根本不记得上一句话说了什么!
每条消息都是独立的,没有上下文。 这就像一个失忆的客服,每次你打电话去,他都问你"请问您是哪位?"。我们需要一个"记事本"来记录对话历史。
二、生活类比:银行客户档案
想象你去银行办业务:
- 开户:第一次来,银行给你建一份档案(Session),里面记录你的基本信息
- 每次来都查档案:你说"我要查余额",柜员先用你的身份证(user_id)找到你的档案
- 同一个人在不同网点有不同档案:你在 A 网点办的理财和 B 网点办的存款是分开记录的——同一个用户在 CLI 和 WebChat 有不同的会话
- 超时销户:如果你 3 年没来,银行会把你的不活跃账户清理掉——TTL 过期清理
- 两种存储方式:
- 小银行把档案存在柜台后面的文件柜里 →
FileStorage(JSON 文件) - 大堂经理脑子里记几个常客的信息 →
InMemoryStorage(内存,重启就忘了)
- 小银行把档案存在柜台后面的文件柜里 →
三、核心概念详解
3.1 Session 和 ChatMessage —— 档案和交易记录
Session(会话)= 一份完整的客户档案:
| 字段 | 类比 | 说明 |
|---|---|---|
session_id | 档案编号 | UUID 唯一标识 |
user_id | 身份证号 | 用户标识 |
channel | 办理网点 | 来自哪个渠道(cli/webchat 等) |
messages | 交易记录列表 | 对话中的所有消息 |
created_at | 开户时间 | 会话创建时间 |
updated_at | 最后一次来的时间 | 每次有新消息就更新 |
ChatMessage(聊天消息)= 档案里的每一条交易记录:
| 字段 | 说明 | 与 OpenAI API 的关系 |
|---|---|---|
role | 谁说的(user/assistant/system/tool) | 和 OpenAI 的 role 完全对齐 |
content | 说了什么 | 消息正文 |
tool_call_id | 关联的工具调用 ID | 工具返回结果时用 |
tool_calls | 工具调用请求列表 | LLM 要求调用工具时填写 |
为什么 role 要和 OpenAI 对齐? 因为 Session 里的消息最终要发给 LLM——格式一致就可以直接用,不需要再转一次。
3.2 SessionManager —— 银行柜台
SessionManager 是管理会话的"总柜台",对外只暴露几个简单操作:
get_or_create(user_id, channel) → 找到已有档案,或者新建一份
save(session) → 保存变更(添加新消息后要记得保存)
delete(session_id) → 销户
cleanup_expired() → 清理所有过期档案查找流程(get_or_create 的工作方式):
为什么要"内存索引"? 查找加速。直接遍历存储里所有会话太慢了,内存里维护一个 (user_id, channel) → session_id 的映射表,O(1) 就能查到。类比:银行柜台电脑上有个检索系统,输入身份证号秒查到档案在哪。
3.3 SessionStorage —— 档案柜
存储接口是抽象的,提供两种实现:
| 存储 | 类比 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| InMemoryStorage | 柜员脑子里记 | 快! | 重启就忘了 | 开发、测试 |
| FileStorage | 文件柜 | 重启还在 | 慢一点 | 轻量生产环境 |
为什么要做成抽象接口? 因为将来你可能想换成 Redis 或数据库——只需要写一个新的 Storage 类实现接口,不用改 SessionManager 的代码。这就是"面向接口编程"的好处。
3.4 TTL 过期清理 —— 定期销户
TTL(Time To Live)= 生存时间。如果一个会话超过指定时间没有新消息,就认为它"过期"了。
为什么需要清理? 如果不清理,内存和磁盘会被废弃会话占满。类比:银行不可能永远保留所有客户的账户,不活跃的要清理。
清理是"主动"而非"自动后台":需要调用 cleanup_expired() 才会执行清理,不会在后台偷偷启动定时器。这样可以完全控制清理的时机,避免"正在处理消息时突然触发清理"的意外。
四、动手实验指南
4.1 运行示例
bash
python day3-session/example/main.py示例会演示:
- 用内存存储创建会话、追加消息
- 用文件存储把会话持久化到磁盘
- 用短 TTL 触发过期清理
4.2 改一改,看看会怎样
实验 1:创建两个不同用户的会话,然后用 list_active_sessions() 查看所有活跃会话。
实验 2:把 TTL 设成 5 秒,创建会话后等 6 秒再调用 get_or_create 同一用户——观察是否创建了新会话。
实验 3:用 FileStorage 保存一个会话,找到生成的 JSON 文件打开看看里面长什么样。
4.3 运行测试
bash
pytest day3-session/session/ -v五、常见问题 FAQ
Q1:同一个用户在 CLI 和 WebChat 的会话为什么是分开的?
A:因为场景不同。用户在 CLI 可能在调试代码,在 WebChat 可能在日常聊天——两边的对话上下文混在一起会很混乱。用 (user_id, channel) 作为联合键来隔离,就像同一个人在不同银行网点办不同业务。
Q2:messages 列表会不会无限增长?
A:在当前实现中会的。Day 7 我们会引入 ShortTermMemory 来做"窗口裁剪"——超过一定条数就把旧消息清理掉,只保留最近的 N 条。
Q3:为什么 ChatMessage 的 role 用 StrEnum 而不是普通字符串?
A:类型安全。如果你手打 "asistant"(少了个 s),普通字符串不会报错但 LLM 会不认识。用 StrEnum,IDE 会在你打错的时候立刻标红提醒。
下一章
现在消息能进来了,对话上下文也能保存了。但系统还缺一个关键角色——谁来"思考"并回复用户?
下一章 Day 4: Agent 运行时 将引入整个系统的"大脑"。