1. 模块概述
Session 管理(nanobot/session/manager.py)负责会话的持久化、加载和历史回放。核心数据结构是 Session,由 SessionManager 管理其生命周期。
📍 源码:
nanobot/nanobot/session/manager.py#L1-L876
1.1 会话文件格式
会话存储为 JSONL 文件(sessions/<safe_key>.jsonl),第一行是元数据记录:
1 | {"_type":"metadata","key":"telegram:12345","created_at":"...","updated_at":"...","metadata":{...},"last_consolidated":0} |
1.2 在系统中的位置
graph TB
subgraph "SessionManager"
GC[get_or_create]
LOAD[_load]
SAVE[save]
REPAIR[_repair]
FORK[fork_session_before_user_index]
end
subgraph "Session"
GH[get_history]
AM[add_message]
CL[clear]
RRS[retain_recent_legal_suffix]
EFC[enforce_file_cap]
end
subgraph "消费者"
AL[AgentLoop]
AC[AutoCompact]
WUI[WebUI API]
end
AL --> GC
AL --> SAVE
AL --> GH
AC --> GC
WUI --> FORK
WUI --> LOAD
2. 命名体系与易混淆函数对比
2.1 命名规律拆解
| 前缀 | 操作对象 | 操作 | 含义 |
|---|---|---|---|
get_ |
or_ |
create |
获取或创建会话 |
_get_ |
session_ |
path |
获取会话文件路径 |
_get_ |
legacy_session_ |
path |
获取旧版会话路径 |
retain_ |
recent_legal_ |
suffix |
保留合法的最近消息后缀 |
enforce_ |
file_ |
cap |
执行文件消息数上限 |
fork_ |
session_before_ |
user_index |
在指定用户消息前分叉会话 |
_sanitize_ |
assistant_replay_ |
text |
清理回放文本中的内部标记 |
_message_ |
preview_ |
text |
生成消息预览文本 |
_metadata_ |
— | title |
从元数据提取会话标题 |
2.2 易混淆函数对比表
| 对比维度 | get_history |
retain_recent_legal_suffix |
|---|---|---|
| 目的 | 获取用于 LLM 输入的历史消息 | 获取用于持久化的最近消息 |
| 是否修改 session | ❌ 只读 | ✅ 修改 messages 和 last_consolidated |
| 返回值 | 消息列表 | (dropped_messages, already_consolidated) |
| 使用场景 | AgentLoop._state_build() | AutoCompact + enforce_file_cap |
| 一句话区分 | get_history 为 LLM 准备输入 |
retain_recent_legal_suffix 为持久化截断会话 |
| 对比维度 | save |
flush_all |
|---|---|---|
| fsync | 默认 False | 强制 True |
| 作用范围 | 单个 session | 所有缓存的 session |
| 使用场景 | 每次 turn 后 | 优雅关闭时 |
| 一句话区分 | save 是常规持久化 |
flush_all 是关闭时的强制刷盘 |
3. API Signatures
1 |
|
4. 数据结构深度解析
4.1 Session
4.a 结构体存在的理由
Session 是对话上下文的持久化单元。它将消息列表、元数据和巩固游标封装在一起,支持增量保存和基于 Token 的历史截断。如果没有 Session,每次 LLM 调用都需要从零构建上下文,无法记住之前的对话。
4.b 字段三层分析表
| 字段 | 设计动机 | 反事实 | 替代方案 |
|---|---|---|---|
last_consolidated |
标记已归档到 history.jsonl 的消息数量 | 每次都需要扫描全部消息判断哪些已归档 | 可以用消息上的 archived 标志,但游标模式在消息被删除后仍正确 |
metadata |
存储 session 级别的运行时状态(checkpoint、goal_state、title) | 需要额外的键值存储来跟踪这些状态 | 可以在消息中嵌入元数据,但污染 LLM 上下文 |
key |
channel:chat_id 格式的唯一标识 |
无法区分不同渠道的相同 chat_id | 可以用 UUID,但 channel:chat_id 具有业务含义 |
4.c 生命周期状态图
stateDiagram-v2
[*] --> Created: get_or_create()
Created --> Active: add_message()
Active --> Active: add_message() / save()
Active --> Consolidated: consolidate_by_tokens()
Consolidated --> Active: 新消息到达
Active --> Truncated: retain_recent_legal_suffix()
Truncated --> Active: 新消息到达
Active --> Cleared: clear() / delete_session()
Cleared --> [*]
5. 函数逐行精讲
5.1 Session.get_history() — 历史回放
1 | def get_history(self, max_messages=120, *, max_tokens=0, ...): |
5.2 SessionManager.save() — 原子持久化
1 | def save(self, session, *, fsync=False): |
6. 设计决策分析
6.1 为什么使用 JSONL 而不是 SQLite?
决策:会话存储使用 JSONL 文本文件。
权衡:
- ✅ 人类可读,便于调试和手动修复
- ✅ 追加写入性能好
- ✅ 无外部依赖
- ❌ 不支持索引查询(如”所有包含 tool_calls 的消息”)
- ❌ 大文件需要全量扫描
6.2 为什么 last_consolidated 使用消息数而不是消息 ID?
决策:使用整数索引跟踪已巩固的消息。
权衡:
- ✅ 简单直接,O(1) 切片
- ✅ 不依赖消息中的 ID 字段
- ❌ 如果消息被外部修改导致顺序变化,游标失效
6.3 为什么 session 分叉只保留到指定 user_index 之前?
决策:fork_session_before_user_index 在用户编辑某条消息后创建新会话分支。
权衡:
- ✅ 用户可以在任意历史点”重新开始”,类似 Git branch
- ✅ 原始会话不受影响,保留完整历史
- ❌ 分叉后的会话不共享后续历史(这是设计意图)
7. 学习检查点
📝 本章小结
- Session 是对话上下文的持久化单元,包含消息列表、元数据和巩固游标
- JSONL 格式:第一行元数据,后续行消息,人类可读
- 双重历史限制:先按消息数(max_messages),再按 Token 数(max_tokens)
- 原子写入:先写临时文件,再
os.replace替换 - 会话分叉支持 WebUI 的”编辑并重试”功能
🤔 思考题
为什么
get_history中要先按消息数切片再按 Token 数裁剪,而不是反过来?参考答案
消息数切片是粗粒度的快速过滤,Token 数裁剪是细粒度的精确控制。如果反过来(先按 Token 裁剪再按消息数切片),可能保留了不足 max_messages 条消息——因为 Token 裁剪可能已经切掉了很多短消息。实际上,消息数限制防止历史过长导致 LLM 注意力分散,Token 限制防止超出上下文窗口。两者服务于不同的约束。
enforce_file_cap的默认上限 2000 条消息有什么考量?参考答案
2000 条消息(
nanobot/nanobot/session/manager.py#L28)约对应 10-20 万 Token(取决于消息长度),远超任何模型的上下文窗口。这个上限不是为了 Token 预算,而是防止 session 文件无限制增长导致磁盘 I/O 和加载性能下降。在达到此上限前,Consolidator的 Token 预算巩固早已触发,因此 enforce_file_cap 主要作为最后的安全网。