← 返回教程库

跨会话回忆:FTS5 全文检索 + LLM 总结 + 辩证式用户建模

最后更新 2026-06-25
你将学到
  • 理解跨会话记忆面临的三个核心挑战,知道为什么不能直接塞全部历史
  • 掌握 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 把历史对话压缩成摘要。

核心流程:

  1. 每隔 N 条消息(或每次会话结束时),取出这段对话。
  2. 调一次 LLM,让它写一段 200-300 字的摘要,提炼"这段对话解决了什么问题、有哪些结论"。
  3. 把摘要存起来(可以单独建一张 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 数字员工落地指南,或了解 数字员工搭建实战课。需要为企业落地方案,欢迎找我们聊 企业服务

📄 来源 / 自校链接

本文为学习整理,关键步骤与代码请结合下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为学习与落地整理,AI 工具与平台更新较快,关键步骤请结合官方最新资料验证。见免责声明