本节摘要:有界策展解决"记少而精",大规模记忆交给可插拔 provider:
plugins/memory/下并列 8 种后端——honcho(用户建模)/mem0(LLM 事实抽取)/hindsight(知识图谱)/holographic(本地 SQLite+FTS5+HRR 向量)/retaindb(云混合检索)/byterover(brv CLI 知识树)/supermemory(语义长记忆)/openviking(火山引擎上下文库)——同一套MemoryProviderABC(is_available/initialize/prefetch/sync_turn/get_tool_schemas...),同时只激活一个。跨会话的另一条记忆通路是会话历史搜索:hermes_state.py用 SQLite FTS5 虚拟表对全部历史消息建全文索引(session_search工具),支持 BM25/短语/布尔/前缀语法;针对 CJK,原生扩展native/fts5_cjk/fts5_cjk.c(252 行 C)包装 unicode61 分词器,把 CJK 连续段重切为重叠二元组(Lucene CJKAnalyzer 语义),使两字中文/韩文词也能走索引而非 3-6 秒全表 LIKE 扫描。
内容来源:原项目源码
agent/memory_provider.py(ABC)、plugins/memory/*/README.md与各 provider 实现、hermes_state.py(FTS_CJK_TABLE_SQL/load_fts5_cjk_extension)、hermes_state_search.py(查询清洗/CJK 路由)、native/fts5_cjk/fts5_cjk.c、native/fts5_cjk/build.sh。
⚠️ 注意:8 种 provider 是互斥选择而非叠加:MemoryManager 只接受一个外部 provider,
hermes config set memory.provider <name>切换。FTS5 的 CJK 大表messages_fts_cjk仅当 libfts5_cjk.so 可加载时才存在;加载失败的进程会自愈式删掉触发器继续写消息(索引变陈旧,等下次hermes sessions optimize-storage在有能力的机器上重建)。
阅读完本节,你应当能够:
| provider | 机制 | 依赖 | 适用 |
|---|---|---|---|
| honcho | AI 原生跨会话用户建模,多轮辩证推理+会话摘要+双向 peer 工具 | honcho-ai 包+云账号/自托管 | 深度理解"用户是谁" |
| mem0 | 服务端 LLM 事实抽取+语义搜索+多信号混合检索(Platform v3 API,亦可 OSS 本地) | mem0ai 包+API key | 自动化事实记忆 |
| hindsight | 知识图谱+实体消解+多策略检索;云/本地内嵌/本地外置三模式 | 云 key 或本地 LLM | 关系型知识 |
| holographic | 本地 SQLite 事实库:FTS5+信任分+实体消解+HRR 组合式向量检索 | 无(SQLite 自带,NumPy 可选) | 零依赖本地方案 |
| retaindb | 云记忆 API:向量+BM25+重排混合检索,7 种记忆类型 | 付费账号 | 云端全托管 |
| byterover | brv CLI 层级知识树:模糊文本→LLM 驱动的分层检索 |
byterover CLI | CLI 生态用户 |
| supermemory | 语义长记忆+画像召回+显式记忆工具+整会话摄取 | supermemory 包(托管/自托管) | 语义召回 |
| openviking | 火山引擎(字节)上下文库:文件系统式知识层级+分级检索+自动抽取 | openviking-server | 中文/字节生态 |
八者覆盖了记忆后端的光谱:云端 SaaS(honcho/mem0/retaindb/supermemory)↔ 纯本地(holographic);抽取式(mem0/openviking)↔ 图谱式(hindsight)↔ 层级式(byterover);其中 holographic 连 FTS5 都复用 SQLite 自带,与下一节的会话搜索同源。安装统一走 hermes memory setup 交互向导(自动装依赖、写 ~/.hermes/.env 密钥),或手工 hermes config set memory.provider <name>;mem0 还支持 mode: oss 切自管进程内模式,hindsight 有云/本地内嵌/本地外置三形态,honcho 提供 OAuth/设备码/API key 三种接入(SSH 无头机用设备码)。检索语义也各有侧重:holographic 的信任分(default_trust: 0.5)给事实加权;retaindb 的 7 种记忆类型区分事实/偏好/事件等;plugins/memory/query_rewrite.py 则在查询侧做统一的改写预处理。
agent/memory_provider.py:104 定义抽象基类,核心钩子分四组:
104 class MemoryProvider(ABC): 107 @property 109 def name(self) -> str: ... # 'honcho'/'hindsight'/... 115 def is_available(self) -> bool: ... # 只查配置与依赖,不发网络请求 123 def initialize(self, session_id, **kwargs) -> None: ... # kwargs 恒有 hermes_home/platform;可含 agent_context(primary/subagent/cron/flush # — 非 primary 上下文应跳过写入)、agent_identity、user_id 等 157 def system_prompt_block(self) -> str: ... # 静态信息进系统提示 166 def prefetch(self, query, *, session_id="") -> str: ... # 轮前召回:要求快,后台线程做真活,这里返回缓存结果 180 def queue_prefetch(self, query, *, session_id="") -> None: ... # 轮后排队,为下一轮 prefetch 备料 201 def sync_turn(self, user_content, assistant_content, ...) -> ... # 轮后同步:把本轮对话喂给后端 220 def get_tool_schemas() -> ... / handle_tool_call(...) # 暴露自己的工具
设计上有两个易漏的细节:initialize 的 agent_context 参数要求 provider 在 cron/subagent/flush 上下文跳过写入——"cron system prompts would corrupt user representations"(定时任务的系统提示会把用户画像带偏);prefetch 的契约是"快",重活必须在 queue_prefetch 的后台线程里预做。可选钩子还有一排:on_session_end(真会话边界触发,可全量摄取)、on_pre_compress(压缩前交出 provider 记忆防丢)、on_delegation(子 agent 任务沉淀)、recall_status(给 UI 一行"recalled N memories"状态)、get_config_schema/save_config(向导可配置项)、backup_paths(备份纳入)。第 5-01 节的 MemoryManager 负责把这些钩子接到 run_agent.py 的四个时机(构造/组 prompt/轮前/轮后),并通过 normalize_tool_schema 兜住两种 schema 形状——包错一层就会让 DeepSeek 以 HTTP 400 拒掉整个请求(#47707);memory_provider_tools_exposed 还确保 provider 的系统提示块与其工具同进同退,不让提示广告不存在的工具(#81014)。
会话历史的每一行消息都进 state.db 的 FTS5 虚拟表,session_search 工具走 hermes_state_search.py:1722 的 _search_messages_impl:
1724 """Full-text search across session messages using FTS5. 1726 Supports FTS5 query syntax: 1727 - Simple keywords: "docker deployment" 1728 - Phrases: '"exact phrase"' 1729 - Boolean: "docker OR kubernetes", "python NOT java" 1730 - Prefix: "deploy*"
用户输入先过 _sanitize_fts5_query(1180 行):剥 FTS5 特殊字符(+{}():"^@/#&|~[]<>,;!?$=)、给带连字符/点号的词加引号(it's、gateway/run.py、a,b 这类词裸放会炸语法)、截到 MAX_FTS5_QUERY_CHARS。命中结果按 BM25 rank 排序,或按时间序(newest/oldest 以时间戳为主键、rank 决胜),每条带 snippet() 摘要与前后文。长查询还有一层防哑火(1327 行注释):FTS5 词间隐式 AND,一个罕见短词就能让整条查询零命中——查询改写层会把多词查询组织成 OR 组合的宽松形态再试。一个体贴的语义细节:被用户"回退"(active=0, compacted=0)的行默认排除——"the user took those back";而**压缩归档行**(compacted=1)默认仍可搜——被摘要掉的原始转写仍是会话记录的一部分(#38763)。索引不健康时有 LIKE 全表兜底路径(_search_messages_like_fallback),维护则靠后台的 bounded merge pass 与 hermes sessions optimize-storage(84 行起:"Run one bounded FTS5 merge pass without failing the completed write")。
问题陈述写在 fts5_cjk.c 头注释里:unicode61 把一整段 CJK 当一个 token("웅기가말했다" 索引成单个 6 字符词,2 字查询永远匹配不进去);自带的 trigram 分词器能做子串但要每个查询词 ≥3 字符——两字词(일본、구글、以及大量中文双字词)退化为全表 LIKE,在 6.8GB 消息表上实测 3-6 秒,是 session_search 延迟的头号来源。解法(12-18 行):"wrap unicode61. Every token it emits is re-examined; maximal CJK runs inside the token are re-emitted as overlapping character BIGRAMS (Lucene CJKAnalyzer semantics) ... Because FTS5 turns consecutive tokens emitted from one query term into a phrase, a query word like 캘린더 → [캘린][린더] gets exact substring semantics with index-speed lookups, down to 2-char terms."。核心实现 cjk_emit(96 行起):
102 /* Fast path: no CJK anywhere → pass through untouched. */ ... 131 /* CJK run: collect char byte-boundaries, emit bigrams. */ ... 142 while (i < nToken) { ... 147 rc = p->xOuterToken(p->pOuterCtx, tflags, 148 pToken + bounds[0], bounds[2] - bounds[0], ...); 151 bounds[0] = bounds[1]; /* 滑窗前移一格 */ 152 bounds[1] = bounds[2]; ... 155 if (rc == SQLITE_OK && nChars == 1) { 156 /* lone CJK char: emit as unigram */
CJK 判定(cjk_is_cjk,34 行)用码点区间覆盖谚文音节/Jamo、中日统一表意文字(含扩展 A-F)、平假名、片假名;UTF-8 解码器(52 行)手写逐码点推进,非法字节按原样吐出保证分段可终止;非 CJK 段原样直通,英文行为零变化;每个子 token 的偏移量都 clamp 回原区间(highlight 高亮不越界,匹配不受影响)。注册入口 sqlite3_ftscjk_init(231 行)通过 SELECT fts5(?) 绑定 fts5_api_ptr 拿 fts5_api 指针,注册名为 cjk_unicode61 的分词器——额外参数透传给 unicode61(如 remove_diacritics 2);Windows 下入口函数 __declspec(dllexport) 导出,另留 sqlite3_fts5_cjk_init 下划线别名。编译与加载:
# native/fts5_cjk/build.sh(核心一步) gcc -shared -fPIC -O2 fts5_cjk.c -o libfts5_cjk.so cp libfts5_cjk.so ~/.hermes/lib/
# hermes_state.py:3487(节选) 3487 def load_fts5_cjk_extension(conn: sqlite3.Connection) -> bool: 3497 path = fts5_cjk_so_path() # ~/.hermes/lib/libfts5_cjk.so 3501 conn.enable_load_extension(True) 3503 conn.load_extension(str(path)) 3505 conn.enable_load_extension(False)
效果对照(以"漓江"两字词查 6.8GB 消息表):
| 分词器 | 建索引行为 | 两字 CJK 查询 |
|---|---|---|
| unicode61(默认) | 整段 CJK 作单 token | 永不命中,退 LIKE 3-6s |
| trigram | 三字符滑窗 | <3 字词零命中,退 LIKE |
| cjk_unicode61 | CJK 段切重叠 bigram | 索引速度短语精确匹配 |
这段扩展由社区贡献(PR #65544,Soju06)。
建表 DDL(hermes_state.py 的 FTS_CJK_TABLE_SQL)用 external-content 模式:虚表挂在排除 tool 行的视图上(WHERE role <> 'tool'——工具输出约占消息字节九成且是机器噪声),零内联文本副本。触发器用专属水位标记对(fts_cjk_rebuild_high_water/fts_cjk_rebuild_progress)门控:trigram→cjk 的升级回填不会卡住主索引的触发器;索引陈旧时触发器必须保持 DROP——"an external-content 'delete' op for a rowid the index never held is the canonical FTS5 index-corruption hazard"(对索引里不存在的 rowid 发 delete 是 FTS5 索引损坏的经典路径)。查询侧(hermes_state_search.py:1878 起):只要大表可用且不含孤立单字 CJK,一切 CJK 查询形状统一走 bigram 路由——每个词加引号变成短语(FTS5 会把一个词切出的连续 bigram 当短语匹配,即精确子串语义),沿用主路径的 BM25 排序与 snippet;例外只剩 role='tool' 过滤(大表没索引 tool 行,退 LIKE)与孤立单字(LIKE 子串语义更宽)。
💡 循环要点:本章两节合成记忆器官的完整解剖:小而硬的本地有界记忆(MEMORY.md/USER.md)+大而软的外部语义 provider(8 选 1)+全量可溯的会话全文检索(FTS5+CJK bigram)。三层各司其职——注入靠小,召回靠检索,理解靠 provider。而 fts5_cjk 用 252 行 C 代码换来中文两字词从"3-6 秒全表扫描"到"索引速度"的跨越,是整个项目里中文生态诚意最硬核的注脚。
记忆与技能都会注入 prompt——下一章研究注入的纪律:prompt caching 神圣不可侵犯的铁律、三层组装流水线,以及上下文压缩与 micro-compaction 如何在省钱与保缓存之间走钢丝。