Context Mode 实战:用 SQLite FTS5 与 MCP 构建上下文窗口管理骨架
1. 当 CLI 工具把上下文窗口当成垃圾桶用 Claude Code 或者类似的 CLI 编程助手写代码半小时后模型开始重复、跑偏、甚至输出乱码这个场景我猜你大概率遇到过。问题往往不在模型本身而在上下文窗口被工具调用的原始输出塞满了。一次 Playwright 快照 56 KB20 个 GitHub issue 59 KB500 行访问日志 45 KB跑 30 分钟四成窗口就被这些「噪音」占掉模型自然开始健忘。Context Mode 这个思路的核心就是不让原始数据直接进上下文。它在工具调用时启动一个隔离子进程只捕获 stdout把完整原始数据落到本地 SQLite FTS5 数据库里然后只把一小段摘要或者检索句柄返回给模型。模型需要细节时再通过检索把相关片段召回。这样上下文窗口里放的是「索引卡片」而不是「整本会议纪要」。这篇要落地的是 CLI 场景下的上下文窗口管理骨架以 SQLite FTS5 做检索底座MCP 做工具接入层给出可复制的 config.toml 与 settings.json并演示一次检索验证动作。目标很明确——让长上下文按需召回而不是全量塞入。适合正在用 CLI 编程助手、被上下文烧光困扰、想自己搭一套骨架的开发者。2. 前置准备TaoToken 接入与本地环境在动手写配置之前先把模型接入层准备好。我这边用的是 TaoToken 的 API 作为模型调用入口它兼容常见的 OpenAI 风格接口CLI 工具和 MCP 服务都能直接对接。你需要先去控制台拿一个 API Key然后确认本地已经有 Python 3.10 和 sqlite3 命令行工具。拿 Key 的路径很简单打开 https://taotoken.net/api-keys 创建一个新 Key复制保存。注意这个 Key 只在创建时完整显示一次丢了就得重建。如果你还没注册可以从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进去注册后在控制台里能看到用量和余额。环境侧确认三件事python3 --version输出 3.10 以上sqlite3 --version能正常输出pip install mcp能装上 MCP 的 Python SDK。SQLite 的 FTS5 模块在大多数发行版里是默认编译进去的验证方式是进 sqlite3 交互界面执行PRAGMA compile_options;输出里能看到ENABLE_FTS5就说明可用。如果没看到需要换一个带 FTS5 的 SQLite 构建或者用 Python 的sqlite3模块连接时检查sqlite3.sqlite_version。模型侧建议先用对话接口验证 Key 是否可用打开 https://taotoken.net/models 可以看当前支持的模型列表。CLI 场景下我一般选响应快、上下文大的模型做主力具体型号按你实际任务挑。这一步不用纠结太久Key 能通、模型能回话就可以进入配置环节。3. 可复制配置config.toml 与 settings.json 骨架整个骨架分两层config.toml 管 CLI 工具和检索库的参数settings.json 管 MCP 服务的注册和工具暴露。先建目录结构我习惯放在~/.context-mode/下mkdir -p ~/.context-mode/{db,scripts,logs} cd ~/.context-mode然后是 config.toml这是检索底座和沙盒行为的核心配置# ~/.context-mode/config.toml [storage] db_path ~/.context-mode/db/context.db fts_table context_fts max_snippet_bytes 512 retention_days 7 [sandbox] enabled true capture stdout max_raw_bytes 5242880 timeout_seconds 30 [retrieval] default_limit 5 bm25_weights { title 3.0, body 1.0 } snippet_tokens 64 [model] api_base https://taotoken.net/api api_key_env TAOTOKEN_API_KEY default_model claude-sonnet几个参数说明一下。max_snippet_bytes控制返回给模型的摘要上限512 字节大约 100 多个汉字够模型判断相关性。max_raw_bytes是单次工具输出落库的上限超过就截断并标记防止一个巨大日志把库撑爆。bm25_weights里 title 权重给到 3.0是因为工具名和命令名往往比正文更能定位内容。接着是 settings.json这是 MCP 服务的注册文件Claude Code 和多数 CLI 助手都认这个格式{ mcpServers: { context-mode: { command: python3, args: [~/.context-mode/scripts/server.py], env: { CONTEXT_MODE_CONFIG: ~/.context-mode/config.toml, TAOTOKEN_API_KEY: sk-your-key-here } } } }注意TAOTOKEN_API_KEY这里直接写明文只适合本地开发生产环境建议用环境变量注入或者系统钥匙串。command和args指向我们接下来要写的 MCP server 脚本。这个 server 的职责就三件事接收工具调用、把原始输出写进 FTS5、返回摘要和检索句柄。初始化数据库的 SQL 也一并给你跑一次就行-- ~/.context-mode/scripts/init.sql CREATE VIRTUAL TABLE IF NOT EXISTS context_fts USING fts5( tool_name, command, body, created_at UNINDEXED, raw_path UNINDEXED, tokenize porter unicode61 );tokenize porter unicode61是关键porter 词干化让 running、runs、ran 归到同一词根unicode61 处理中文和符号。created_at和raw_path不参与索引只做元数据。执行sqlite3 ~/.context-mode/db/context.db ~/.context-mode/scripts/init.sql就建好了。4. MCP Server 实现与一次检索验证MCP server 用 Python SDK 写核心逻辑是拦截工具输出、落库、返回摘要。下面是一个最小可跑的骨架# ~/.context-mode/scripts/server.py import os, sqlite3, hashlib, subprocess, tomllib from pathlib import Path from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent cfg tomllib.loads(Path(os.path.expanduser( os.environ[CONTEXT_MODE_CONFIG])).read_text()) DB os.path.expanduser(cfg[storage][db_path]) def store(tool, cmd, body): raw_dir Path(DB).parent / raw raw_dir.mkdir(exist_okTrue) h hashlib.sha1(body.encode()).hexdigest()[:16] raw_path raw_dir / f{h}.txt raw_path.write_text(body) conn sqlite3.connect(DB) conn.execute( INSERT INTO context_fts(tool_name,command,body,created_at,raw_path) VALUES(?,?,?,datetime(now),?), (tool, cmd, body[:cfg[sandbox][max_raw_bytes]], str(raw_path))) conn.commit(); conn.close() return h def search(query, limit5): conn sqlite3.connect(DB) rows conn.execute( SELECT tool_name, command, snippet(context_fts,2,[,], ...,64), bm25(context_fts,3.0,1.0) FROM context_fts WHERE context_fts MATCH ? ORDER BY bm25(context_fts,3.0,1.0) LIMIT ?, (query, limit)).fetchall() conn.close() return rows app Server(context-mode) app.list_tools() async def tools(): return [ Tool(namerun_and_index, description执行命令并索引输出, inputSchema{type:object,properties:{ command:{type:string}},required:[command]}), Tool(namerecall, description按关键词召回历史输出, inputSchema{type:object,properties:{ query:{type:string}},required:[query]}), ] app.call_tool() async def call(name, args): if name run_and_index: out subprocess.run(args[command], shellTrue, capture_outputTrue, textTrue, timeoutcfg[sandbox][timeout_seconds]).stdout h store(cli, args[command], out) return [TextContent(typetext, textf已索引 {len(out)} 字节句柄 {h}用 recall 检索细节)] if name recall: rows search(args[query]) text \n.join(f[{r[0]}] {r[1]} :: {r[2]} for r in rows) return [TextContent(typetext, texttext or 无匹配)] raise ValueError(name) async def main(): async with stdio_server() as (r, w): await app.run(r, w, app.create_initialization_options()) if __name__ __main__: import asyncio; asyncio.run(main())这段代码里run_and_index执行命令、把 stdout 落库、只返回字节数和句柄recall用 FTS5 的MATCH加bm25排序召回snippet()函数直接生成带高亮的片段。模型看到的是「已索引 56KB句柄 abc123」需要细节时再调 recall。验证动作分两步。先手动灌一条数据确认 FTS5 检索通sqlite3 ~/.context-mode/db/context.db \ INSERT INTO context_fts(tool_name,command,body,created_at,raw_path) VALUES(cli,gh issue list,running tests failed on auth module,datetime(now),/tmp/x); sqlite3 ~/.context-mode/db/context.db \ SELECT tool_name, snippet(context_fts,2,[,],...,32) FROM context_fts WHERE context_fts MATCH run;第二条命令应该返回cli|running tests failed on [auth] module这样的结果注意run匹配到了running说明 porter 词干化生效。然后启动 MCP server在 CLI 助手里调用recall工具query 传auth应该能召回同一条。这一步通了整条链路就活了。5. 本篇常见错排查FTS5 报no such module: fts5。这是 SQLite 构建没带 FTS5。先PRAGMA compile_options;确认没有就换构建。Python 用户可以用pysqlite3-binary替代标准库它自带 FTS5。macOS 自带的 sqlite3 通常没问题Linux 上某些精简发行版需要装libsqlite3-dev后重编。MCP server 启动后 CLI 里看不到工具。九成是 settings.json 路径没展开。~在 JSON 里不会自动展开要么写绝对路径要么在 server.py 里用os.path.expanduser处理。另外确认command指向的 python3 就是装了 mcp SDK 的那个虚拟环境里要写全路径。recall 返回空但数据明明在库里。检查 MATCH 的查询语法。FTS5 默认把空格当 ANDauth module会要求两个词都出现。想模糊匹配用auth OR module想前缀匹配用auth*。中文检索要注意 unicode61 分词对连续中文的处理必要时在写入前做分词预处理。落库的 body 被截断导致检索不到尾部内容。这是max_raw_bytes在起作用原始文件其实完整存在raw_path里。召回时如果 snippet 不够可以加一个fetch_raw工具按句柄读原文但要注意别把原文又整个塞回上下文只读需要的行区间。API Key 报 401。确认TAOTOKEN_API_KEY环境变量在 MCP server 进程里可见。settings.json 的 env 块只对该 server 生效如果你在 shell 里 export 了但 server 没读到检查是不是用了不同的 shell 会话。Key 本身可以在 https://taotoken.net/api-keys 重新生成一个对比测试。6. 把骨架跑起来之后这套骨架跑通后你会发现上下文窗口的占用曲线明显平缓了。原来跑 30 分钟就告急的会话现在能撑到两三个小时因为进窗口的只有摘要和检索结果原始数据都沉在 SQLite 里。需要回溯细节时模型自己调 recall 就行不用你手动贴日志。如果你主要做长期编码或者 Agent 任务建议把 Coding Plan 也配上地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 它针对长会话场景做了额度优化。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有 MCP 对接的完整参数说明。想先验证模型对话是否正常直接开 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 试一轮就行。最后留一个我踩过的坑FTS5 的snippet()函数参数顺序容易记混它是snippet(表名, 列索引, 前缀, 后缀, 省略符, token 数)列索引从 0 开始。我一开始把列索引写成 1结果高亮打在了 command 列上排查了半天。你写的时候直接照抄上面的 SQL 就不会错。