跨会话回忆:FTS5 全文检索 + LLM 总结 + 辩证式用户建模
- 理解跨会话记忆面临的三个核心挑战,知道为什么不能直接塞全部历史
- 掌握 FTS5 全文检索的落地方式,能复制代码跑通"查出历史片段"这一步
- 看懂 LLM 定期总结和辩证式用户建模的实现思路,知道各自解决什么问题
- 能组合三种手段搭出比纯向量记忆更轻的跨会话记忆方案
你开了一个新会话,对 Agent 说:"上次我们聊到的那个 Nginx 反向代理配置,你还记得吗?"——然后它一脸空白。
不是它不聪明,是它根本没有跨会话记忆这个机制。每次会话就是一张白纸,除非你主动喂给它背景,否则它什么都不知道。
想让 Agent 记住老用户、记住上次聊了什么,不一定非要上 Faiss 或 Chroma 这种重型向量库。SQLite 内置的 FTS5 全文检索加上 LLM 定期总结,就够轻够用。 这节讲三种手段的配合思路,以及怎么用一套可跑代码把它落地。
这篇适合谁:已经能写基础 Agent(参见 L2 的第一个 Agent),理解了 短期、向量、结构化三层记忆架构(4.2 节),现在想真正解决"跨会话失忆"这件事。
跨会话记忆的三个挑战
挑战一:历史太长,context 装不下
一个活跃用户和 Agent 聊三个月,历史对话可能有几万条。直接塞进 system prompt 或 context?不可能。即便模型支持超长上下文,token 费用也扛不住。
挑战二:信息老化,旧记忆变无用甚至误导
半年前用户说"我不懂 Python",现在可能已经是熟手。用户半年前的偏好直接沿用到今天,会让 Agent 回答失准。记忆需要更新,而不是无限堆积。
挑战三:冷启动检索,不知道找什么
新会话开始时,Agent 不知道哪段历史"这次有用"。向量检索靠语义相似度,但用户今天的问题可能和三周前的某段对话关键词完全不同——全文检索有时反而更直接。
这三个挑战,正好对应三种手段:FTS5 全文检索解决"找到相关历史"、LLM 总结解决"装不下怎么压缩"、辩证式用户建模解决"记忆老化和更新"。
手段一:FTS5 全文检索——轻量不用向量库
SQLite 从 3.9 版本起内置了 FTS5(Full-Text Search 5)全文检索扩展,不需要安装任何额外依赖,Python 标准库的 sqlite3 就能用。
思路:把每次会话的所有对话存进 SQLite,建一张 FTS5 虚拟表,新会话开始时用关键词检索,把相关的历史片段捞回来注入当前上下文。
这和 hermes-agent 的 session_search 工具 是同一套思路(hermes 的具体实现以官方仓库为准)——核心逻辑是:不用嵌入模型、不用向量数据库,关键词匹配就够了。
可跑代码示例
下面这段代码包含:建表、存消息、FTS5 检索,可以复制粘贴直接跑。
import sqlite3
from datetime import datetime
from pathlib import Path
DB_PATH = Path.home() / ".myagent" / "history.db"
DB_PATH.parent.mkdir(exist_ok=True)
def init_db(conn: sqlite3.Connection):
"""建表:一张普通表存原始消息,一张 FTS5 虚拟表做全文检索"""
conn.executescript("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session TEXT NOT NULL,
role TEXT NOT NULL, -- 'user' 或 'assistant'
content TEXT NOT NULL,
created TEXT NOT NULL
);
-- FTS5 虚拟表:content='' 表示它只存索引,真实数据在 messages 表
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
USING fts5(content, content='messages', content_rowid='id');
-- 触发器:往 messages 插入时自动同步到 FTS5
CREATE TRIGGER IF NOT EXISTS messages_ai
AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
""")
conn.commit()
def save_message(conn: sqlite3.Connection, session: str, role: str, content: str):
"""存一条消息,触发器自动把它加进 FTS5 索引"""
conn.execute(
"INSERT INTO messages (session, role, content, created) VALUES (?, ?, ?, ?)",
(session, role, content, datetime.now().isoformat()),
)
conn.commit()
def search_history(conn: sqlite3.Connection, query: str, limit: int = 5) -> list[dict]:
"""
用 FTS5 全文检索历史消息,返回最相关的几条。
FTS5 的 rank 字段越小越相关(负数,绝对值越大越相关)。
"""
rows = conn.execute(
"""
SELECT m.session, m.role, m.content, m.created
FROM messages_fts
JOIN messages m ON messages_fts.rowid = m.id
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(query, limit),
).fetchall()
return [
{"session": r[0], "role": r[1], "content": r[2], "created": r[3]}
for r in rows
]
# ─────────────────────────────────────────────
# 测试:存几条消息,再检索
# ─────────────────────────────────────────────
if __name__ == "__main__":
conn = sqlite3.connect(DB_PATH)
init_db(conn)
# 模拟两次历史会话
save_message(conn, "session-001", "user", "Nginx 反向代理怎么配?")
save_message(conn, "session-001", "assistant", "在 /etc/nginx/sites-available 里加 server 块,proxy_pass 指向后端端口。")
save_message(conn, "session-002", "user", "Python 虚拟环境怎么创建?")
save_message(conn, "session-002", "assistant", "用 python -m venv .venv,再 source .venv/bin/activate。")
save_message(conn, "session-003", "user", "上次那个 Nginx 的配置,proxy_pass 后面加不加斜杠有区别吗?")
# 新会话开始,用关键词检索
results = search_history(conn, "Nginx proxy_pass")
print("检索到的相关历史:")
for r in results:
print(f" [{r['session']}] {r['role']}: {r['content'][:60]}")
conn.close()
你应该看到什么
运行后终端打印类似:
检索到的相关历史:
[session-003] user: 上次那个 Nginx 的配置,proxy_pass 后面加不加斜杠有区别吗?
[session-001] user: Nginx 反向代理怎么配?
[session-001] assistant: 在 /etc/nginx/sites-available 里加 server 块,proxy_pass 指向后端端口。
三条和"Nginx proxy_pass"相关的消息全被找到了,和 Python 虚拟环境那条无关的完全没出现。这就是全文检索的效果:精准、快、零嵌入费用。
把检索出来的片段格式化成一段"相关历史背景",在新会话开始时注入到 system prompt 里,Agent 就"想起"了之前聊过的事情。
手段二:LLM 总结——把长历史压缩成摘要
FTS5 检索到的是原始对话片段,但如果一段历史对话有几十轮,直接塞进来 token 就炸了。这时候第二种手段登场:定期让 LLM 把历史对话压缩成摘要。
核心流程:
- 每隔 N 条消息(或每次会话结束时),取出这段对话。
- 调一次 LLM,让它写一段 200-300 字的摘要,提炼"这段对话解决了什么问题、有哪些结论"。
- 把摘要存起来(可以单独建一张
summaries表),同时给它加上 FTS5 索引。
def summarize_session(messages: list[dict], llm_client) -> str:
"""
用 LLM 把一段会话压缩成摘要。
messages 格式:[{"role": "user"/"assistant", "content": "..."}]
"""
transcript = "\n".join(
f"{m['role'].upper()}: {m['content']}" for m in messages
)
prompt = (
"请用 200 字以内总结下面这段对话,重点提炼:解决了什么问题、最终结论是什么、"
"有哪些用户偏好或特定要求值得记住。只输出总结,不要解释你在做什么。\n\n"
f"{transcript}"
)
response = llm_client.messages.create(
model="claude-opus-4-5", # 以官方文档为准(截稿 2026-06)
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
触发总结的时机有三种常见选择:
- 会话结束时(关闭对话窗口/调用
/end命令) - 消息条数超过阈值(比如超过 20 条)
- 定时任务(每天凌晨跑一次,把前一天的会话全部压缩)
摘要比原始对话小 10-20 倍,再配上 FTS5 检索,海量历史也能轻松应对。
手段三:辩证式用户建模——记忆要更新,不只是堆积
前两种手段解决了"找回历史"的问题,但有一个更难的问题:用户是会变的。
半年前 Agent 记录"用户不熟悉 Docker",但现在用户已经在用 Docker 部署生产环境了。如果记忆只是不断追加新条目,旧的错误认知还在,新会话时 Agent 会搞不清楚到底该用哪条——这叫记忆冲突。
辩证式用户建模(Dialectical User Modeling)的思路:新信息进来时,不是直接追加,而是和已有认知对照,判断是更新、补充还是替代。
实现上不需要复杂算法,用一个结构化的 USER 档案 + 每次更新前先让 LLM 做冲突检测就够了:
USER_PROFILE_PATH = Path.home() / ".myagent" / "USER.md"
def update_user_profile(new_observation: str, llm_client) -> str:
"""
往用户档案追加新观察,但先让 LLM 检测是否和已有认知冲突。
如果冲突,让 LLM 决定是替换还是并存。
"""
existing = (
USER_PROFILE_PATH.read_text(encoding="utf-8")
if USER_PROFILE_PATH.exists()
else "(档案为空)"
)
check_prompt = (
f"现有用户档案:\n{existing}\n\n"
f"新观察到的信息:{new_observation}\n\n"
"请判断:\n"
"1. 新信息是否和现有档案有冲突或矛盾?\n"
"2. 如有冲突,应该替换旧条目还是两者并存(附时间标注)?\n"
"3. 给出更新后的完整档案(保持简洁,不超过 400 字)。\n"
"只输出更新后的档案内容,不要输出分析过程。"
)
response = llm_client.messages.create(
model="claude-opus-4-5", # 以官方文档为准(截稿 2026-06)
max_tokens=600,
messages=[{"role": "user", "content": check_prompt}],
)
updated = response.content[0].text
USER_PROFILE_PATH.write_text(updated, encoding="utf-8")
return updated
这样每次更新用户档案,都会经过一轮"辩证"——新旧信息对照,LLM 决定怎么合并。档案不会无限膨胀,也不会留下过时的错误认知。
三种手段的组合时序
完整的跨会话记忆流程长这样:
新会话开始
↓
① 从 USER.md 读用户档案,注入 system prompt(常驻记忆)
↓
② 用用户的开场问题做 FTS5 检索,捞出相关历史片段,拼进上下文
↓
对话进行中
↓
会话结束 / 消息超阈值
↓
③ LLM 总结本轮对话,存入 summaries 表(带 FTS5 索引)
↓
④ 如有新的用户特征观察,用辩证式更新 USER.md
三层配合:常驻的用户档案保证"始终在场的认知",FTS5 检索拉回"按需的历史背景",LLM 总结控制"单次注入的 token 量"。
和纯向量记忆的对比:什么时候够用
| 维度 | FTS5 + LLM 总结 | 纯向量记忆(Faiss/Chroma) |
|---|---|---|
| 依赖 | SQLite(标准库)+ LLM | 嵌入模型 + 向量数据库 |
| 检索方式 | 关键词全文匹配 | 语义相似度 |
| 擅长场景 | 精确词汇(工具名、版本号、专有名词) | 语义模糊查询("和这个问题相关的历史") |
| 冷启动成本 | 极低,建表即用 | 需要跑嵌入、建索引 |
| 运营成本 | 几乎零(SQLite 本地) | 向量库托管 / 本地显存 |
| 适合规模 | 中等(几万条历史消息) | 大规模(百万级语料) |
什么时候 FTS5 够用:用户的提问里通常包含明确关键词(框架名、命令名、报错信息、项目名),历史量在几万条以内,不想引入额外基础设施。
什么时候需要向量库:查询本身语义模糊(比如"帮我找找之前和情绪管理相关的对话"),历史量超过十万条,或者你已经有向量基础设施了。
三层记忆架构(4.2 节)里讲的向量层适合存知识库,FTS5 这套适合存对话历史——两者不矛盾,分工不同。如果你的项目既有知识库又有对话历史,可以两套并用:知识库上向量,对话历史上 FTS5。
故障排查表
| 症状 | 可能原因 | 解法 |
|---|---|---|
sqlite3.OperationalError: no such table: messages_fts |
FTS5 触发器建在 init_db 里但 init_db 没被调用,或数据库文件是旧版没有该表 | 确认每次建连接后都调 init_db(conn);或删掉旧 db 文件重建 |
sqlite3.OperationalError: fts5: syntax error near "..." |
FTS5 MATCH 的查询字符串包含特殊字符(*, -, ", 括号)未转义 |
用 fts5_query = " ".join(query.split()) 做简单清洗;或用 "..." 把整个查询包成短语匹配 |
| 检索到的历史和当前问题毫无关系 | 用户输入的关键词太宽泛(比如单个"怎么"、"问题"),命中了大量无关记录 | 在注入上下文前先过滤:只取 rank < -0.5 的结果;或让 LLM 先提取关键词再送给 FTS5 |
| 总结摘要丢失了重要信息 | LLM 总结 prompt 太模糊,或 max_tokens 设得太小 | 在 prompt 里明确要求"保留工具名/版本号/命令等具体细节";适当提高 max_tokens |
| USER.md 越来越长,新信息追加但旧信息没清理 | 辩证更新没有被触发,或 LLM 判断"两者并存"太保守 | 定期人工审查 USER.md;或在 update_user_profile 里加一条"如有信息超过 6 个月未更新则标注[待验证]"的指令 |
| 跨会话检索到了内容,但 Agent 没有用上 | 检索结果没有被正确注入到 system prompt,或被后续消息覆盖 | 确认检索结果是在 system 角色的消息里(不是 user),且在对话消息之前;打印 system prompt 确认它到位了 |
常见问题
Q:FTS5 对中文支持怎么样?
SQLite FTS5 默认分词器是 ascii,对中文是按字节切分,不做中文分词。结果是搜"Nginx"完全正常,搜"反向代理"可能漏掉只写"代理"的条目。两个解法:① 搜索时把用户输入按字和词拆开多个词分别 OR 搜(MATCH "反向 代理 nginx");② 引入外部中文分词插件(jieba 等),在存入时对 content 分词再存一列专门给 FTS5 用。大多数场景下①就够了。
Q:LLM 总结会不会把重要信息总结没了?
会有这个风险,尤其是代码片段、具体命令、报错信息,LLM 倾向于"提炼主旨"而丢掉细节。应对方式:① 总结 prompt 里明确要求"保留所有具体命令、版本号、报错信息原文";② 原始消息仍然保留在数据库,总结只是检索时的入口,真正注入上下文时可以拿原始片段。
Q:这套方案和 hermes-agent 是什么关系?
hermes-agent(4.3 节已详细拆解)是这套思路的一个完整开源实现,它的 session_search 工具用的就是 FTS5,用户档案对应 USER.md。本节的代码是从零实现同一套思路,方便你集成到自己的 Agent 里。hermes 的具体实现细节以官方仓库为准。想在自己的 Agent 里积累技能知识体系,可以看 给自己的 Agent 加记忆技能积累(4.7 节)。
Q:用户建模里"辩证"这个词是什么意思,有学术出处吗?
这里"辩证"是工程意义上的描述:新旧信息对照、决定替换还是共存——不是严格的学术术语。如果你要读相关论文,可以搜 "user modeling with belief revision"或"incremental user profile updating",方向是对的。实现上不需要算法,用 LLM 做仲裁就够用。
小结
跨会话记忆不是"上不上向量库"的二选一。三种手段各有分工:
- FTS5 全文检索:轻量、零依赖、擅长精确关键词,历史对话直接存 SQLite,新会话按词捞回来。
- LLM 总结:解决"历史太长装不下"的问题,定期把长对话压缩成摘要,token 省 10-20 倍。
- 辩证式用户建模:解决"记忆老化"问题,新信息和旧认知对照更新,档案不会越积越错。
三种手段组合,比"塞全部历史"省 token,比"纯向量 RAG"轻,适合个人 Agent 和中小规模应用。
更深的记忆架构全貌在 三层记忆架构(短期/向量/结构化) 里;hermes 的文件即记忆三件套在 文件即记忆:hermes-agent 三件套拆解 里;把记忆能力沉淀成可复用技能,看 给自己的 Agent 加记忆技能积累。
对照 AI 数字员工 整条路线图,4.4 之后是 4.5 RAG 知识库接入,两者配合才是完整的"记什么、怎么查"。
👉 看看 AI 数字员工落地指南,或了解 数字员工搭建实战课。需要为企业落地方案,欢迎找我们聊 企业服务。