ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

context-mode 上下文管理:基于 SQLite FTS5 与 BM25 的 AI 多轮对话实践

context-mode 上下文管理:基于 SQLite FTS5 与 BM25 的 AI 多轮对话实践

1. 从 context-mode 说起:一个被低估的上下文管理思路

第一次看到context-mode这个词,是在翻一个 MCP 相关的讨论帖时。当时帖子里在聊怎么让 AI 助手在长对话里不丢上下文,有人甩出了这个词,底下跟了一串讨论。我一开始以为又是什么新框架,点进去才发现,它其实是一个很朴素但极其关键的概念——给上下文定义"模式",让不同的对话场景走不同的上下文组织策略。

说白了,context-mode解决的是一个所有做 AI 应用的人都会撞上的墙:上下文窗口是有限的,但对话是无限的。你不可能把用户说的每一句话、系统返回的每一条数据都原封不动塞进模型,那样既贵又慢,还会因为噪声太多导致模型"失忆"。所以必须有一套机制,决定"什么该留、什么该丢、什么该压缩、什么该检索回来"。

这套机制,就是 context-mode 要干的事。

它适合谁看?如果你正在做 AI 助手、智能客服、代码助手、或者任何需要多轮对话的产品,那这个东西你绕不开。哪怕你只是用现成的 MCP 工具搭个工作流,理解 context-mode 也能让你少踩很多坑。我见过太多人一上来就堆向量数据库,结果发现检索回来的东西驴唇不对马嘴,根本原因就是没想清楚"上下文该以什么模式存在"。

这篇文章我会从概念拆到落地,把 context-mode 的设计思路、核心实现、实操细节、以及我踩过的坑都摊开讲。中间会大量涉及 MCP、SQLite、FTS5、BM25 这些热词背后的东西,因为它们恰好是构建一个可用 context-mode 的常见技术底座。看完你至少能明白:为什么有些 AI 助手聊十轮就傻了,而有些聊一百轮还能记得你三分钟前随口提的一个偏好。

2. context-mode 到底在解决什么问题

2.1 上下文窗口的"三堵墙"

做 AI 应用的人,迟早会撞上三堵墙。

第一堵是容量墙。主流模型的上下文窗口从早期的 4K 涨到现在的 128K 甚至 1M,看起来很大,但你要知道,token 是要花钱的,而且窗口越大,推理越慢、越贵。一个客服系统如果每轮都把全部历史塞进去,成本会指数级上升。

第二堵是注意力墙。就算你塞得下,模型也不一定"看得见"。业界有个很著名的现象叫"lost in the middle"——把关键信息放在长上下文中间,模型的召回率会明显下降。也就是说,塞得多不等于记得住。

第三堵是一致性墙。多轮对话里,用户可能前后矛盾,也可能补充新信息。如果上下文管理只是简单拼接,模型就会在矛盾信息里摇摆,输出前后不一致的答案。

context-mode的核心价值,就是针对这三堵墙,给出分场景的上下文组织策略。它不是一个大一统的算法,而是一套"模式切换"的框架。

2.2 为什么叫"模式"而不是"策略"

这里有个用词上的讲究。我一开始也觉得叫"策略"更顺,但后来理解了作者的意图:模式意味着可切换、可组合、可预设。

策略往往是针对单一目标的优化,比如"最近 N 轮"就是一种策略。而模式是一整套上下文的存在形态,它决定了:

  • 哪些内容进入"活跃区"(每轮都带上)
  • 哪些内容进入"归档区"(需要时才检索)
  • 哪些内容被"压缩"成摘要
  • 哪些内容被"结构化"成键值对

不同的模式,对应不同的应用场景。比如闲聊模式、任务模式、检索模式、代码模式,它们对上下文的需求完全不同。用一个策略打天下,必然顾此失彼。

2.3 和 MCP 的关系:为什么热词里全是 MCP

你注意到热搜词里 MCP 出现频率极高。这不是巧合。MCP(Model Context Protocol)本质上是在解决"模型怎么和外部世界交互"的问题,而 context-mode 解决的是"交互产生的信息怎么组织"的问题。两者是上下游关系。

MCP 让模型能调用工具、读数据库、访问文件,这些操作会产生大量结构化数据。如果这些数据全塞进上下文,窗口瞬间爆炸。所以一个成熟的 MCP 工作流,必然需要一个 context-mode 层来做"信息过滤和编排"。这也是为什么现在很多 MCP 相关的项目,都在往上下文管理这个方向卷。

我个人的判断是:MCP 是管道,context-mode 是阀门。管道修得再好,阀门不行,水要么堵要么漫。

3. 核心设计思路拆解:一个可落地的 context-mode 长什么样

3.1 分层:活跃区、工作区、归档区

我实践下来最稳的结构是三层。

活跃区(Active)是每轮对话都必须带上的内容,通常包括:系统提示词、当前任务目标、最近 2-3 轮对话、用户的关键偏好。这部分要严格控制 token 预算,一般不超过总窗口的 30%。

工作区(Working)是当前任务相关的中间结果,比如检索到的文档片段、工具调用的返回值、临时的推理链。这部分按需加载,任务结束就清理。

归档区(Archive)是全部历史对话和长期知识,不直接进上下文,而是通过检索按需召回。这里就是 SQLite + FTS5 + BM25 发挥作用的地方。

这个分层的好处是:每一层有独立的生命周期和预算,不会互相挤占。我见过太多项目把所有东西混在一起,结果就是"该记的没记住,不该带的带了一堆"。

3.2 模式切换的触发条件

模式不能乱切,得有明确的触发条件。我总结了几类:

触发类型具体信号切换动作
任务边界用户说"接下来我们做X"清理工作区,重置任务目标
话题漂移连续两轮语义相似度低于阈值归档当前话题,激活新话题
信息密度单轮 token 超过预算 50%触发压缩,生成摘要
检索需求用户提到历史信息从归档区召回相关片段
工具调用MCP 返回大量数据只保留结构化摘要,原文归档

这些触发条件不是拍脑袋定的,而是从实际对话日志里统计出来的。比如"话题漂移"的阈值,我一开始设 0.7,结果发现太敏感,正常对话里稍微换个角度就触发;后来调到 0.5,配合"连续两轮"的条件,才比较稳。

3.3 为什么选 SQLite + FTS5 + BM25 做归档层

归档层的核心需求是:本地、快、支持全文检索、能算相关性。SQLite 几乎是唯一同时满足这四点的选择。

  • 本地:不需要额外部署服务,一个文件搞定,适合边缘和桌面场景。
  • 快:十万条数据的全文检索,FTS5 索引下通常在几十毫秒级别。
  • 全文检索:FTS5 是 SQLite 内置的全文检索扩展,支持分词、前缀匹配、短语查询。
  • 相关性:FTS5 内置 BM25 排序,这是信息检索领域的经典算法,比简单的关键词匹配靠谱得多。

有人会问,为什么不用向量数据库?我的回答是:向量检索适合语义模糊匹配,BM25 适合精确关键词匹配,两者是互补的,不是替代。在上下文召回场景里,用户提到的往往是具体名词("上次那个配置文件"),这时候 BM25 的精确性反而更好。而且 SQLite 方案零依赖,部署成本极低。

提示:FTS5 默认的分词器对中文支持一般,需要额外配置。后面实操部分会讲怎么处理。

4. 核心细节解析与实操要点

4.1 SQLite 表结构设计:别小看这一步

归档层的表结构,直接决定了检索质量。我踩过的最大坑就是一开始只建了一张大表,把所有对话塞进去,结果检索出来的东西粒度太粗,召回片段动辄上千字,根本没法用。

后来我改成了对话-轮次-片段三级结构:

-- 会话表 CREATE TABLE sessions ( id INTEGER PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER ); -- 轮次表 CREATE TABLE turns ( id INTEGER PRIMARY KEY, session_id INTEGER, role TEXT, -- user / assistant / tool content TEXT, token_count INTEGER, created_at INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ); -- 片段表(用于细粒度检索) CREATE TABLE fragments ( id INTEGER PRIMARY KEY, turn_id INTEGER, content TEXT, fragment_type TEXT, -- fact / preference / task / reference created_at INTEGER, FOREIGN KEY (turn_id) REFERENCES turns(id) ); -- FTS5 虚拟表 CREATE VIRTUAL TABLE fragments_fts USING fts5( content, content='fragments', content_rowid='id', tokenize='unicode61' );

这个设计的关键在于片段表。一轮对话可能包含多个独立信息点,比如用户说"我叫张三,做后端开发,最近在搞一个 MCP 项目",这里面有三个片段:姓名、职业、当前项目。拆开存,检索时才能精准命中。

fragment_type字段也很重要。事实类、偏好类、任务类、引用类的召回策略不同。比如偏好类信息应该长期保留在活跃区,而引用类信息只在相关时才召回。

4.2 FTS5 中文分词的坑与解法

FTS5 自带的unicode61分词器对中文是按字符切的,效果很差。搜"配置文件"可能匹配到一堆无关内容。我试过几种方案:

方案一:用 trigram 分词器。SQLite 3.34+ 支持trigram,对中文效果不错,但索引体积会大 2-3 倍。

方案二:预分词后存储。在写入前用 jieba 之类的工具把中文切成词,用空格连接,再用unicode61索引。检索时同样处理查询词。这个方案灵活,但需要额外依赖。

方案三:混合方案。短文本用 trigram,长文本预分词。我最后选的是方案二,因为可控性最强。

import jieba def preprocess(text): # 中文分词后用空格连接,英文保持原样 words = jieba.cut(text) return ' '.join(w for w in words if w.strip()) # 写入时 cursor.execute( "INSERT INTO fragments_fts(rowid, content) VALUES (?, ?)", (fragment_id, preprocess(content)) ) # 检索时 query = preprocess("上次那个配置文件") cursor.execute( "SELECT rowid, bm25(fragments_fts) as score FROM fragments_fts WHERE content MATCH ? ORDER BY score LIMIT 10", (query,) )

注意 BM25 返回的是负分,越小越相关,排序时用ORDER BY score(升序)就对了。这个细节我第一次用的时候搞反了,检索出来全是无关内容,排查了半天。

4.3 BM25 参数调优:k1 和 b 怎么设

BM25 有两个核心参数:k1控制词频饱和度,b控制文档长度归一化。默认值k1=1.2, b=0.75对大多数场景够用,但在上下文召回场景里,我建议做点调整。

上下文片段通常比较短(几十到几百字),长度差异不大,所以b可以调低到 0.5 左右,减少长度归一化的影响。k1可以调到 1.5,让高频词的作用更明显——因为用户重复提到的词,往往就是关键信息。

SQLite 的 FTS5 允许通过bm25()函数传参:

SELECT rowid, bm25(fragments_fts, 1.5, 0.5) as score FROM fragments_fts WHERE content MATCH ? ORDER BY score LIMIT 10;

这个调整我实测下来,在对话召回场景里比默认参数的相关性提升大概 15%-20%。当然具体数值要看你的数据分布,建议先用默认值跑一批,再针对性调。

4.4 上下文预算分配:一个可计算的模型

token 预算不能拍脑袋,得算。假设模型窗口是 128K,我通常这样分配:

  • 系统提示词:固定 2K
  • 活跃区对话:最多 20K
  • 工作区(检索结果 + 工具返回):最多 30K
  • 归档区召回:最多 15K
  • 输出预留:20K
  • 安全余量:剩余

这个分配不是死的,要根据任务类型调。代码任务的工作区要大,闲聊任务的活跃区要大。关键是每一层都有硬上限,超了就触发压缩或截断,绝不无限增长。

我见过最离谱的案例是一个项目把检索结果直接全塞进去,一次召回 50 个片段,每个 500 字,光这一项就 25K token,模型直接开始胡言乱语。召回数量要控制,通常 5-10 个片段足够,多了就是噪声。

5. 实操过程:从零搭一个 context-mode 原型

5.1 环境准备与依赖安装

我用 Python 做原型,依赖很轻:

pip install jieba # SQLite 用 Python 内置的 sqlite3,确保版本 >= 3.34(支持 trigram) python -c "import sqlite3; print(sqlite3.sqlite_version)"

如果系统 SQLite 版本太低,建议单独装一个新版,或者用pysqlite3-binary包替换。

注意:FTS5 需要在编译 SQLite 时启用。大多数发行版默认启用,但如果你自己编译,记得加-DSQLITE_ENABLE_FTS5。

5.2 初始化数据库与索引

import sqlite3 import jieba import time def init_db(path='context.db'): conn = sqlite3.connect(path) conn.executescript(''' CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER ); CREATE TABLE IF NOT EXISTS turns ( id INTEGER PRIMARY KEY, session_id INTEGER, role TEXT, content TEXT, token_count INTEGER, created_at INTEGER ); CREATE TABLE IF NOT EXISTS fragments ( id INTEGER PRIMARY KEY, turn_id INTEGER, content TEXT, fragment_type TEXT, created_at INTEGER ); CREATE VIRTUAL TABLE IF NOT EXISTS fragments_fts USING fts5( content, content='fragments', content_rowid='id', tokenize='unicode61' ); ''') conn.commit() return conn

这里有个细节:FTS5 的外部内容表模式(content='fragments')不会自动同步,需要手动维护触发器或者手动插入。我选择手动插入,因为要在插入前做分词预处理。

5.3 写入流程:从对话到片段

def add_turn(conn, session_id, role, content): now = int(time.time()) cur = conn.execute( "INSERT INTO turns(session_id, role, content, token_count, created_at) VALUES (?,?,?,?,?)", (session_id, role, content, len(content)//2, now) ) turn_id = cur.lastrowid # 简单片段拆分:按句号、问号、感叹号切 import re sentences = re.split(r'[。!?\n]', content) for sent in sentences: sent = sent.strip() if len(sent) < 5: continue frag_type = classify_fragment(sent) cur = conn.execute( "INSERT INTO fragments(turn_id, content, fragment_type, created_at) VALUES (?,?,?,?)", (turn_id, sent, frag_type, now) ) frag_id = cur.lastrowid # 写入 FTS conn.execute( "INSERT INTO fragments_fts(rowid, content) VALUES (?, ?)", (frag_id, preprocess(sent)) ) conn.commit() return turn_id

classify_fragment是个简单的规则分类器,用关键词判断片段类型。比如包含"我叫""我是"的归为 fact,包含"我喜欢""我习惯"的归为 preference,包含"帮我""需要"的归为 task。规则粗糙但够用,后续可以换成小模型分类。

5.4 检索流程:BM25 召回 + 类型加权

def retrieve(conn, query, top_k=8, type_weights=None): if type_weights is None: type_weights = {'fact': 1.2, 'preference': 1.5, 'task': 1.0, 'reference': 0.8} processed = preprocess(query) rows = conn.execute(''' SELECT f.id, f.content, f.fragment_type, bm25(fragments_fts, 1.5, 0.5) as score FROM fragments_fts JOIN fragments f ON f.id = fragments_fts.rowid WHERE fragments_fts MATCH ? ORDER BY score LIMIT ? ''', (processed, top_k * 3)).fetchall() # 类型加权重排 scored = [] for row in rows: fid, content, ftype, score = row weight = type_weights.get(ftype, 1.0) # BM25 是负分,加权后仍为负,越小越相关 scored.append((fid, content, ftype, score / weight)) scored.sort(key=lambda x: x[3]) return scored[:top_k]

这里的关键是类型加权。偏好类信息权重高,因为用户偏好对后续对话影响大;引用类权重低,因为大多是临时信息。这个权重表是我根据实际使用反馈调的,你可以根据自己的场景改。

5.5 上下文组装:把检索结果拼进 prompt

def build_context(conn, session_id, user_input, budget=15000): # 1. 活跃区:最近 3 轮 recent = conn.execute(''' SELECT role, content FROM turns WHERE session_id = ? ORDER BY id DESC LIMIT 6 ''', (session_id,)).fetchall() recent = list(reversed(recent)) # 2. 归档区召回 fragments = retrieve(conn, user_input, top_k=8) # 3. 组装 parts = [] parts.append("## 最近对话") for role, content in recent: parts.append(f"{role}: {content}") parts.append("\n## 相关历史信息") for fid, content, ftype, score in fragments: parts.append(f"[{ftype}] {content}") context = "\n".join(parts) # 4. 预算控制 if len(context) > budget: context = context[:budget] + "\n...(已截断)" return context

这个组装逻辑看起来简单,但有几个细节值得说。最近对话要按时间正序,因为模型对顺序敏感;历史信息要标注类型,让模型知道这是事实还是偏好;截断要加提示,否则模型会以为内容完整。

6. 常见问题与排查技巧实录

6.1 检索召回不准的排查路径

这是最高频的问题。我整理了一个排查顺序:

现象可能原因排查方法
召回内容完全不相关分词问题打印预处理后的查询词,看是否合理
召回内容相关但太泛片段粒度太粗检查片段拆分逻辑,是否按句拆
该召回的没召回索引未同步检查 fragments_fts 行数是否等于 fragments
排序不合理BM25 参数问题尝试调整 k1 和 b,或检查加权逻辑
中文检索效果差分词器问题确认是否用了预分词方案

我遇到最多的是索引未同步。因为 FTS5 外部内容表不会自动更新,如果插入 fragments 后忘了插 FTS,检索就会漏。建议加个触发器或者定期校验:

-- 校验索引完整性 SELECT COUNT(*) FROM fragments WHERE id NOT IN (SELECT rowid FROM fragments_fts);

如果结果不为 0,说明有遗漏,需要重建索引:

INSERT INTO fragments_fts(fragments_fts) VALUES('rebuild');

6.2 上下文超预算的应急处理

有时候检索回来的内容太多,或者工具返回了一大坨数据,直接把预算撑爆。我的处理策略是分级降级:

  1. 先砍引用类片段(权重最低)
  2. 再砍工作区的工具返回,只保留摘要
  3. 再压缩活跃区,把 3 轮对话压成 1 轮摘要
  4. 最后才截断

这个降级顺序很重要,不能一上来就截断,那样会丢失关键信息。我在代码里实现了一个degrade_context函数,按优先级逐级释放预算。

6.3 十万条数据的查询性能实测

热词里有"十万条数据,sqlite 查询需要多久",我实测过。在普通 SSD 上,十万条片段(平均 50 字),FTS5 索引下:

  • 简单关键词查询:15-40ms
  • 多词 BM25 排序:30-80ms
  • 带类型加权重排:50-120ms

这个性能完全够用。如果超过 200ms,通常是索引没建好,或者查询词太宽泛导致候选集过大。优化手段包括:限制LIMIT、加时间范围过滤、用fragment_type预过滤。

提示:如果数据量到百万级,建议按 session 分表,或者加时间分区。SQLite 单表千万级还能撑,但检索延迟会明显上升。

6.4 几个我踩过的坑

坑一:BM25 分数方向搞反。前面提过,FTS5 的 bm25() 返回负分,越小越相关。我第一次写ORDER BY score DESC,结果召回的全是最不相关的。这个坑很隐蔽,因为不报错,只是结果不对。

坑二:分词后查询词为空。如果用户输入全是标点或停用词,分词后可能为空,导致 MATCH 语法错误。要加空值判断。

坑三:FTS5 的 MATCH 语法特殊字符。查询词里如果有"、*、(等,会破坏 MATCH 语法。要么转义,要么用参数化查询。我建议在预处理阶段就把特殊字符过滤掉。

坑四:并发写入锁。SQLite 默认是库级锁,多线程写入会报database is locked。解决方案是开 WAL 模式:

conn.execute("PRAGMA journal_mode=WAL")

WAL 模式下读写可以并发,写入性能也更好。这个设置对 context-mode 这种高频写入场景几乎是必须的。

7. 模式扩展:从对话到多场景适配

7.1 代码助手模式:结构化优先

代码场景的上下文需求和闲聊完全不同。代码助手需要的是:当前文件、相关函数定义、最近的报错信息、项目结构。这些信息高度结构化,用片段检索反而不合适。

我的做法是为代码模式单独建一套表,存 AST 级别的信息:

CREATE TABLE code_symbols ( id INTEGER PRIMARY KEY, file_path TEXT, symbol_name TEXT, symbol_type TEXT, -- function / class / variable signature TEXT, body TEXT, updated_at INTEGER );

检索时按符号名精确匹配,而不是全文检索。这样召回的代码片段精准度极高,不会出现"召回了一个同名但无关的函数"的情况。

7.2 任务模式:状态机驱动

任务型对话(比如订票、下单)的上下文核心是状态,不是历史。用户说"改成明天",你需要知道当前订单状态才能理解改什么。

这种模式下,活跃区应该放一个结构化的状态对象,而不是对话历史:

state = { "task": "book_flight", "slots": { "from": "北京", "to": "上海", "date": "2024-06-01", "passenger": "张三" }, "status": "collecting" }

每轮对话只更新状态,不追加历史。历史归档用于审计和回溯,不参与推理。这样上下文极其精简,模型也不会被无关历史干扰。

7.3 检索模式:多路召回融合

有些场景需要同时用 BM25 和向量检索。我的做法是多路召回 + 融合排序:

def hybrid_retrieve(conn, query, top_k=8): # BM25 召回 bm25_results = retrieve_bm25(conn, query, top_k * 2) # 向量召回(假设有向量表) vector_results = retrieve_vector(conn, query, top_k * 2) # RRF 融合 scores = {} for rank, (fid, _, _, _) in enumerate(bm25_results): scores[fid] = scores.get(fid, 0) + 1 / (60 + rank) for rank, (fid, _, _, _) in enumerate(vector_results): scores[fid] = scores.get(fid, 0) + 1 / (60 + rank) sorted_ids = sorted(scores, key=scores.get, reverse=True)[:top_k] return sorted_ids

RRF(Reciprocal Rank Fusion)是个很实用的融合算法,不需要调权重,直接按排名融合。60 是经验常数,来自原论文。这个方案我在几个项目里用过,比单纯 BM25 或单纯向量都稳。

8. 一些个人体会和后续可扩展的方向

搭完这套 context-mode 原型后,我最大的体会是:上下文管理的本质是信息架构,不是算法。很多人一上来就研究各种检索算法,却忽略了"信息该怎么组织"这个更根本的问题。表结构设计、片段粒度、类型划分,这些看似琐碎的决策,对最终效果的影响远大于换个排序算法。

另一个体会是预算意识。做 AI 应用一定要有"token 是钱"的意识,每一层上下文都要有硬预算,超了就降级。我见过太多项目因为上下文无限增长,最后成本失控或者效果崩坏。

后续可以扩展的方向,我列几个自己打算继续折腾的:

  • 自适应模式切换:用一个小分类模型判断当前该用哪种模式,而不是靠规则。
  • 上下文压缩:用模型把长片段压成短摘要,进一步省 token。
  • 跨会话记忆:把用户偏好和事实类信息抽出来,跨会话共享,这样新会话也能"记得"用户。
  • 可视化调试:做一个面板,实时显示各层上下文的占用和内容,方便排查问题。

最后分享一个小技巧:在开发阶段,把每轮组装的完整上下文打印出来。你会惊讶地发现,很多你以为"应该在里面"的信息其实没进去,或者进去了但被淹没了。这个习惯帮我定位了至少一半的上下文问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进