我是张大鹏,做了十多年人工智能,带过不少项目。说实话,最难的不是让AI生成一句漂亮的回答,是让它在聊了50轮之后还能记得你第一句说了什么。最近在给如意Agent设计对话持久化方案,顺便解决长对话上下文爆炸的问题,本文记录完整的设计思路和实现规划。
一、一个让人崩溃的现状
如意Agent跑了一段时间后,我发现对话数据散落在四个地方:
| 存储位置 | 内容 | 问题 |
|---|---|---|
data/memory/chat_history.json |
渲染后的markdown,UI直接读 | 全文件覆盖写,4KB数据就7KB了 |
BaseSession.history(内存list) |
Claude content blocks,LLM真正吃的上下文 | 进程崩溃瞬间清零 |
temp/model_responses/<PID>.txt |
文本日志,含<history>标签 |
PID命名在PyInstaller下脆弱 |
data/logs/logs.db(logstack) |
结构化日志+traces | 与对话无关联,trace_id找不回会话 |
核心病灶:4份事实源、3种数据格式、0个统一查询入口。
更难受的是长对话的上下文管理。每次新建会话都是一次"记忆断点",AI好像得了金鱼记忆。而如果把历史全部塞进prompt,100轮之后token费用能吓死人,响应速度也直线下降。
所以我决定做两件事:统一持久化 + 滚动记忆引擎。
二、方案选型:为什么不用Mem0/Letta/LangChain
市面上做记忆的开源方案不少,我逐一评估过:
| 方案 | 优点 | 放弃理由 |
|---|---|---|
| Mem0 | 声明式API,自动embedding | 需外部向量服务,PyInstaller打包困难 |
| Letta | 持久化agent状态 | 架构太重,引入大量无关依赖 |
| Zep | 专为LLM对话设计 | 需独立服务部署,与单机桌面定位不符 |
| LangChain Memory | 生态成熟 | 已被官方标记为legacy,不建议新项目使用 |
| 自研(SQLAlchemy 2.0) | 零额外依赖,完全可控 | 需自己实现,工作量较大 |
我的决策逻辑很简单------如意Agent是单机桌面应用,追求的是零配置开箱即用。引入任何一个外部服务都会破坏这个前提。而且记忆系统的核心逻辑并不复杂,一个设计清醒的自有实现比依赖随时可能变向的第三方库更可靠。
最终选择:SQLAlchemy 2.0 + Alembic + SQLite FTS5,自研滚动记忆引擎。
三、架构设计:独立chat.db + Repository模式
3.1 为什么独立chat.db,不合并进logs.db
| 维度 | 独立chat.db | 合并logs.db |
|---|---|---|
| 保留期 | 永久 | logs 30天滚动 |
| 备份策略 | 用户可独立导出会话 | 备份必须连日志一起 |
| 故障域 | 日志库损坏不影响对话 | 一损俱损 |
| schema演进 | 自由 | 受logstack现有规则约束 |
保留期与故障域的考量优先于JOIN便利性。
3.2 模块布局
src/storage/chat/
├── __init__.py # 导出ChatRepository、SummaryEngine、ContextBuilder
├── models.py # Conversation、Message、ConversationSummary
├── engine.py # create_engine、scoped_session、PRAGMA hooks
├── repository.py # ChatRepository(CRUD API)
├── schema.py # type-hint DTO(不依赖SQLAlchemy)
├── summary.py # SummaryEngine(异步总结worker)
├── context.py # ContextBuilder(上下文拼装)
├── search.py # FTS5 wrapper(chat_search工具底层)
└── tokens.py # TokenCounter抽象 + 启发式实现
alembic/ # Alembic迁移管理
├── versions/
│ ├── 0001_initial_chat_schema.py
│ └── 0002_rolling_memory.py # summaries表 + FTS5 + 触发器
3.3 数据模型
sql
-- conversations: 一次对话会话
CREATE TABLE conversations (
id VARCHAR(32) PRIMARY KEY, -- 20260504_094030_129231
title VARCHAR(200) NOT NULL,
model VARCHAR(80), -- 末次使用的LLM名称
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
metadata TEXT -- JSON扩展字段
);
-- messages: 对话中的每一轮发言
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id VARCHAR(32) NOT NULL,
turn INTEGER NOT NULL, -- 在会话内的序号
role VARCHAR(16) NOT NULL, -- user | assistant | tool | system
content TEXT NOT NULL, -- 渲染后markdown(UI视图)
raw_blocks TEXT, -- JSON:Claude content blocks(预留)
trace_id VARCHAR(64), -- 对应logstack.traces.trace_id
token_count INTEGER, -- 估算token数(P2.5启用)
created_at DATETIME NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX idx_messages_conv_turn ON messages(conversation_id, turn);
CREATE INDEX idx_messages_trace ON messages(trace_id);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
ID方案保留了现有的YYYYMMDD_HHMMSS_microseconds字符串主键------人类可读、天然按时序排序,不引入UUID破坏现有兼容性。
四、滚动记忆引擎:三层叠加设计
这是整个方案最核心也最有意思的部分。
4.1 为什么必须三层
单层方案的缺陷非常明显:
| 单层方案 | 问题 |
|---|---|
| 仅滑动窗口 | 早期对话丢失,AI失忆 |
| 仅滚动总结 | 关键细节被压缩失真 |
| 仅检索 | AI知道"历史上说过",但不知道"刚才说过什么" |
| 三层叠加 | 短期忠实 / 中期压缩 / 长期可查 |
4.2 三层架构详解
| 层 | 时间尺度 | 数据来源 | 注入方式 | 触发 |
|---|---|---|---|---|
| L_window 滑动窗口 | 分钟级 | 最近N条messages(按token) | 直接放入user/assistant槽位 | 每次对话 |
| L_summary 滚动总结 | 小时~天 | conversation_summaries累积 | 拼入system prompt | 未压缩区 > 阈值时后台触发 |
| L_recall FTS5检索 | 永久 | messages_fts全文索引 | AI主动调用chat_search工具 | AI决策 |
4.3 滚动总结的工作原理
python
class SummaryEngine:
"""后台滚动总结引擎。"""
def notify_appended(self, conv_id: str) -> None:
"""对话新增消息后调用;引擎自行决定是否触发总结。"""
def get_active_summary(self, conv_id: str) -> ConversationSummary | None:
"""读取当前活动总结(superseded_by IS NULL中to_turn最大者)。"""
触发逻辑:
notify_appended(conv_id)入队- worker计算
unsummarized_tokens = sum(m.token_count for m in messages where turn > active_summary.to_turn) - 若 > 阈值(默认8192)→ 调用cheap_llm生成新总结
- 新总结的
from_turn = active.to_turn + 1或0;to_turn = 当前最大turn - 旧总结的
superseded_by指向新总结,保留历史链可追溯 - 失败:3次重试,仍失败则记ERROR日志,不阻塞用户对话
总结Prompt模板 (保存于assets/summary_prompt.txt):
你是一位专业的对话归纳助手。请把下面对话浓缩成不超过800字的摘要,要求:
1. 保留用户的核心意图和决策
2. 保留AI给出的事实结论与推荐
3. 标注关键文件路径、命令、配置
4. 略去寒暄、重复确认、错误尝试中已被纠正的部分
5. 用中性叙述,不模仿原说话风格
{prior_summary_block}
{messages_block}
4.4 ContextBuilder:预算控制下的上下文拼装
python
@dataclass
class ContextBudgets:
total: int = 12288 # 总预算
system: int = 4096 # base sys_prompt + L1-L4记忆
summary: int = 2048 # 滚动总结
window: int = 6144 # 滑动窗口(最近消息)
class ContextBuilder:
def build(self, conv_id: str, base_system: str, memory_block: str) -> ContextPacket:
# 拼装顺序:
# system_prompt = base_system + "\n\n[长期记忆]\n" + memory_block + "\n\n[历史摘要]\n" + active_summary
# messages = repo.list_messages(conv_id).filter(turn > active_summary.to_turn)
# .iter_until_token_budget(window_budget, from='tail')
为什么按token触发而不是按消息数? 因为工具调用的输出体积差异巨大------一条web_scan可能5K token,一条问候只10 token。按消息数会两端失衡。
五、五个阶段的渐进迁移
这个方案不是一刀切,而是分5个阶段渐进实施,每阶段都可独立合并、独立回滚。
| 阶段 | 目标 | 工作量 | 风险 |
|---|---|---|---|
| P0 基础设施 | 仓库具备SQLAlchemy + Alembic能力,但无任何调用方 | 0.5天 | 低 |
| P1 UI切流 | UI读写走chat.db,chat_history.json由DB派生导出 | 1天 | 中 |
| P2 Session持久化 | BaseSession.history也入库,/resume可完整恢复 | 1.5天 | 高(触及LLM调用链) |
| P2.5 滚动记忆引擎 | 滑动窗口+滚动总结+FTS5检索+默认续接会话 | 2天 | 中 |
| P3 清理下线 | 移除JSON派生导出、temp/model_responses迁移 | 0.5天 | 低 |
5.1 P0:零行为变化的基础设施建设
添加依赖、模块骨架、Alembic配置、初始迁移。验收标准:alembic upgrade head可在临时目录创建空表;新代码mypy --strict + ruff零告警。
5.2 P1:UI切流
改造src/desktop/utils.py的_load_history()和_save_history(),让UI走chat.db。同时加一个兼容hook:每次保存后异步写chat_history.json,老脚本仍可读。
5.3 P2.5:永续会话体验
这是用户体验变化最大的阶段:
- UI启动时自动打开最近会话,定位到末尾,不需要"新建对话"
- "新建会话"按钮移到右上角菜单(不再放sidebar醒目位置)
- 用户体感:永远在同一个聊天框,但底层有边界(便于归档/清理/导出)
六、关键设计决策
6.1 为什么用FTS5而不是向量检索
| 维度 | FTS5(当前) | 向量检索(sqlite-vec) |
|---|---|---|
| 依赖 | SQLite自带 | 需扩展 + embedding模型 |
| 启动成本 | 0 | 需选embedding(OpenAI/本地BGE等) |
| 中文支持 | unicode61默认能切(够用) | 需中文embedding模型 |
| 准确度(关键词) | 高 | 中(需chunk + rerank) |
| PyInstaller兼容 | 零问题 | 需测扩展加载 |
| 实施工作量 | 0.5天 | 2天+ |
结论:P2.5用FTS5满足80%场景。P3之后若用户反馈"找不到只记得意思的内容"再加向量层。
6.2 总结模型策略:固定便宜模型
在mykey.py/config.yaml增加可选项summary_llm,默认指向GLM-Flash / Kimi-Auto / Haiku。未配置则降级到主对话LLM并打印warning。总结调用走异步worker,永远不阻塞用户对话。
6.3 跨线程安全
PySide6 UI线程、Agent线程、Summary worker三个线程会同时访问DB。配置方案:
python
# connect_args + engine配置
create_engine(
"sqlite:///data/memory/chat.db",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# PRAGMA配置
PRAGMA foreign_keys = ON
PRAGMA journal_mode = WAL
PRAGMA synchronous = NORMAL
scoped_session + check_same_thread=False + StaticPool,SummaryEngine自己持session_factory。
七、测试策略
| 测试层级 | 范围 | 速度目标 |
|---|---|---|
| 单元(models) | 字段约束、JSON roundtrip、ON DELETE CASCADE | < 50ms |
| 单元(repository) | CRUD、事务边界、并发(threading) | < 200ms |
| 单元(summary) | 触发阈值、superseded_by链接、失败重试 | < 300ms |
| 单元(context) | token预算控制、丢弃策略、summary优先 | < 100ms |
| 单元(search) | FTS5触发器同步、查询命中、跨会话隔离 | < 100ms |
| 集成(migration) | JSON→DB完整性、幂等性、损坏JSON容错 | < 1s |
| 集成(rolling_chat) | 100条消息全流程:append→summary→context | < 5s |
关键fixture:
in_memory_engine(sqlite:///:memory:+ 全部表创建),所有单元测试用此fake_cheap_llm(mock一个总是返回固定文本的session)seeded_repo_100msgs(预填100条消息的repo,给集成测试用)
总结
| 维度 | 内容 |
|---|---|
| 核心思路 | SQLAlchemy 2.0统一持久化 + 滑动窗口/滚动总结/FTS5三层记忆 |
| 数据库 | 独立data/memory/chat.db,不合并logs.db |
| 迁移策略 | P0→P1→P2→P2.5→P3五阶段渐进,每阶段可独立回滚 |
| 记忆预算 | 默认12K总预算(system 4K + summary 2K + window 6K) |
| 总结触发 | 未压缩区token > 8192,后台异步触发 |
| 检索方案 | P2.5用FTS5,P3后视反馈决定是否上向量层 |
| 会话体验 | 默认续接最近会话,"新建会话"收纳到菜单 |
| 预计工期 | 5.5工作日 |
参考资料:
作者 :张大鹏
团队 :大鹏 AI 教育
日期:2026-05-05我是张大鹏,10年全栈开发经验,目前专注于 AI + 全栈教育培训。关注我,每周分享AI和全栈开发领域的深度实战经验。