MemPalace 写入路由策略(Write Routing Policy):direct / prefer / require 三档策略与单写者安全机制解析
本文基于 MemPalace 仓库中的 docs/write-routing-policy.md 展开,完整梳理其定义的统一写入路由策略:三档策略(direct / prefer / require)的语义差异、direct / daemon / blocked 三种具体路由结果、环境变量与配置文件的完整配置形态及优先级链、向后兼容的遗留布尔映射,以及本地文件型后端的单写者租约保护。读完本文后,你可以准确理解 MemPalace 在 daemon 推广(Tier 3 rollout,追踪于 #1963)过程中如何统一 hook 与 CLI 的写入路径决策,并能对照 mempalace/write_routing.py 与 mempalace/config.py 的源码验证策略解析的每一步行为。
策略的定位:一个被测试覆盖的策略模型
原始文档开宗明义:write routing policy 是 Tier 3 daemon 分级推广(#1963)所使用的共享策略。这份"foundation PR"的核心约束是——它本身不改变现有 hook 或 CLI 的路由行为,只是提供一个经过测试的策略模型,供后续 hook 与 CLI 的推广 PR 直接消费,而无需各自发明不同的 fallback 规则。
这一设计意图在源码中体现得很清楚。mempalace/write_routing.py 的模块 docstring 说明该策略"刻意与传输方式无关(transport-agnostic)":hook 和 CLI 消费方各自负责回答两个事实——"daemon 是否已经可用"以及"我是否被允许启动一个 daemon";策略模块的职责是把这些事实加上一个策略,归约为唯一一条显式路由。
三个策略的语义定义如下(与原文档一致):
| 策略 | 语义 |
|---|---|
direct |
始终走既有的本地直连路径(direct local path)。 |
prefer |
优先使用可用 daemon;若调用方被允许启动 daemon 则可启动,否则回退到直连路径。 |
require |
优先使用可用 daemon;允许启动的调用方可以启动 daemon;若两者都不可行则阻断操作,绝不回退到直连 ChromaDB 写入者。 |
具体路由结果:direct / daemon / blocked
共享决策函数针对每次写入返回三种具体路由之一:
directdaemonblocked
除目标路由外,决策还会报告调用方是否应当自动启动 daemon(auto_start_daemon)以及选择该路由的原因(reason)。
调用方两类典型取值在文档中有明确约定,且被源码印证:
- hooks 一般传
daemon_can_start=False——hook 执行有严格的延迟预算,不能冷启动一个长驻进程; - 交互式 CLI 命令可以传
daemon_can_start=True。
核心决策逻辑实现在 choose_write_route:
def choose_write_route(
policy: WriteRoutingPolicy,
*,
daemon_available: bool,
daemon_can_start: bool,
) -> WriteRoutingDecision:
"""...
The key safety guarantee is that ``require`` never degrades to a direct
write when the daemon is unavailable.
"""
其判定顺序是:
- 策略为
direct→ 直接返回direct,原因policy-direct; - daemon 已可用 → 返回
daemon,原因daemon-available(不自动启动); - 允许启动 daemon → 返回
daemon且auto_start_daemon=True,原因daemon-auto-start; - 策略为
prefer且以上皆否 → 回退direct,原因daemon-unavailable-fallback; - 其余情况(即
require且 daemon 不可用、又无权启动)→ 返回blocked,原因daemon-required-unavailable。
决策以冻结 dataclass WriteRoutingDecision 承载,字段为 policy / target / auto_start_daemon / reason,并暴露 use_daemon 与 blocked 两个便捷属性。完整的行为矩阵由参数化测试 test_decision_matrix 固定:
| 策略 | daemon_available | daemon_can_start | 结果 | auto_start |
|---|---|---|---|---|
direct |
任意 | 任意 | direct |
False |
prefer |
True | 任意 | daemon |
False |
prefer |
False | True | daemon |
True |
prefer |
False | False | direct |
False |
require |
True | 任意 | daemon |
False |
require |
False | True | daemon |
True |
require |
False | False | blocked |
False |
其中"require 永不降级为直连写入"是这份策略最关键的安全保证,也是后文单写者安全机制成立的前提。
配置方式:环境变量与配置文件
环境变量
文档定义了三级环境变量,取值为 direct|prefer|require:
# 全局环境策略
MEMPALACE_WRITE_ROUTING=direct|prefer|require
# hook 专属环境策略
MEMPALACE_HOOK_WRITE_ROUTING=direct|prefer|require
# CLI 专属环境策略
MEMPALACE_CLI_WRITE_ROUTING=direct|prefer|require
配置文件形态
配置文件中的 write_routing 对象支持全局默认值与分作用域覆盖:
{
"write_routing": {
"default": "direct",
"hooks": "prefer",
"cli": "require"
}
}
优先级链(Precedence)
当多个来源同时配置了策略时,解析器按固定顺序取第一个已配置的值。
对 hooks 作用域,优先级从高到低:
MEMPALACE_HOOK_WRITE_ROUTINGMEMPALACE_WRITE_ROUTING- 遗留变量
MEMPALACE_HOOKS_DAEMON write_routing.hookswrite_routing.default- 遗留配置
hooks.daemon - 兜底
direct
对 CLI 写入:
MEMPALACE_CLI_WRITE_ROUTINGMEMPALACE_WRITE_ROUTINGwrite_routing.cliwrite_routing.default- 兜底
direct
实现位于 MempalaceConfig.resolve_write_routing。其做法是把各来源构造成有序的 RoutingPolicyCandidate 列表——每个候选携带 source 名称、value 与 legacy_boolean 标志——再交给 resolve_write_routing_policy 依次尝试:跳过 None,第一个可解析的值胜出,并连同来源名一起返回 ResolvedWriteRoutingPolicy;全部为空时返回默认值 direct(来源标记为 "default")。两个便捷属性 hook_write_routing 与 cli_write_routing 分别封装了两个作用域的解析结果。作用域只允许 hooks 与 cli,传入其他名称(例如 "mcp")会直接抛出 WriteRoutingError,这一点由 test_unknown_scope_is_rejected 覆盖。
优先级行为有对应测试逐条钉住,例如:
- test_scoped_env_beats_global_and_config:同时设置
MEMPALACE_HOOK_WRITE_ROUTING=require、MEMPALACE_WRITE_ROUTING=prefer与配置文件write_routing.hooks=prefer时,最终解析为require,来源为MEMPALACE_HOOK_WRITE_ROUTING; - test_legacy_hook_env_beats_config:遗留变量
MEMPALACE_HOOKS_DAEMON=true优先于配置文件,解析为prefer,来源标记为MEMPALACE_HOOKS_DAEMON (legacy); - test_scoped_config_beats_global_config:配置文件中
write_routing.cli覆盖write_routing.default。
向后兼容:遗留布尔值的映射
文档声明既有的 MEMPALACE_HOOKS_DAEMON 环境变量与 hooks.daemon 配置项继续受支持,映射规则为:
- 遗留 true 值(
1、true、yes、on、daemon)→ 映射为prefer; - 遗留 false 值(
0、false、no、off)→ 映射为direct。
源码中这两组词表定义在 write_routing.py 的 _LEGACY_TRUE / _LEGACY_FALSE,解析入口 parse_write_routing_policy 只有在 legacy_boolean=True 时才接受这些词;对新的策略设置,布尔值、整数和空串一律拒绝(见 test_new_policy_rejects_legacy_or_invalid_values)。
另一条兼容性承诺是:既有的 MempalaceConfig.hook_use_daemon 属性在本次基础 PR 中被刻意保持不变(见 hook_use_daemon,其逻辑仍只读遗留的 MEMPALACE_HOOKS_DAEMON 与 hooks.daemon),hook 与 CLI 的实际行为要等各自的"policy-aware"推广 PR 落地后才改变。测试 test_existing_hook_use_daemon_behavior_is_unchanged 专门验证了这一点。
非法策略值的处理:响亮失败(fail loudly)
新的策略设置只接受 direct、prefer、require 三个值。非法值不会静默回退,而是抛出带来源名的错误——resolve_write_routing_policy 在捕获解析异常时会重新包装为 "{source}: {exc}" 形式抛出。文档给出的理由是:如果一次拼写错误的 require 被静默降级成直连写入,就等于违背了策略本身的安全目的。
相关行为测试:
- test_resolver_names_invalid_source:错误信息必须包含来源名
MEMPALACE_WRITE_ROUTING; - test_invalid_scoped_policy_fails_loudly:配置文件中
write_routing.hooks="typo"触发含config write_routing.hooks的WriteRoutingError; - test_invalid_routing_object_fails_loudly:
write_routing本身不是对象(例如直接写成字符串)也会报错,而不是被当作默认值忽略。
本地后端的单写者安全
这是原文档篇幅最重的部分,也是理解整个路由策略"为什么要存在"的关键。
背景约束:chroma、sqlite_exact 以及 Milvus Lite 这类文件型后端,每个 palace 只支持一个可写进程。仅仅序列化单次调用是不够的,因为每个长驻进程都会在两次调用之间持有 SQLite/WAL、FTS 或向量索引的状态。
文档列出的规则与源码中的对应实现:
- 可写 daemon 在整个生命周期内持有 palace 写入租约:daemon 工作进程启动时通过 ExitStack 包住
mine_palace_lock获得租约,worker 退出后统一 释放。 - 可写 MCP HTTP 在绑定端口前获取该租约,整个服务期持有,活跃请求结束后释放;
- MCP stdio 在拿到租约前以只读方式打开
sqlite_exact:因此读操作可以与写者共存;而变更类工具(mutating tools)在另一个进程持有租约时会被拒绝(见 _mcp_peer_writer_refusal 与 MEMPALACE_MCP_ALLOW_PEER_WRITER 环境变量定义),租约持有者退出后可以重新以可写方式打开存储; - 只读 MCP HTTP 可以与写者共存;
- 只读
sqlite_exact客户端使用 immutable 连接(针对干净、已 checkpoint 的数据库),或在"活跃写者的完整 WAL sidecar 对必须可见"时使用mode=ro;两条路径都启用query_only,并跳过 schema、WAL、FTS、迁移与元数据初始化; - 直连 CLI 与 hook 写入不得与可写 daemon 或 MCP HTTP 持有者并存——当 daemon 拥有该 palace 时,应使用
require把它们路由进 daemon; - 直连
sqlite_exact的 collection 变更会争抢同一份 palace 租约,且全量 LLM closet 重生成会先持有租约再打开 collection、调用配置好的模型(见 repair.py 中的_rebuild_index_under_lease)。
palace 级锁本身的实现在 mine_palace_lock:锁文件按 sha256(规范化 palace 路径) 命名并存放于 ~/.mempalace/locks/,因此不同 palace 的挖掘可以并行,只有同一 palace 的写入被串行化;锁是非阻塞的(已有持有者时直接抛错而非排队),且对同一进程可重入,允许 miner 管道外层持锁、collection 写入层再获取而不自死锁。文档同时给出了锁的健康含义:并发多份 mempalace mine 会对同一 palace 并行驱动 HNSW 插入,可能损坏 HNSW 图并产生 link_lists 膨胀——这正是"多写者必须被路由掉"的底层原因。
MEMPALACE_MCP_ALLOW_PEER_WRITER 不能绕开上述保护:对本地文件型或未知插件后端它无效;该变量只为显式远程服务型后端(qdrant、pgvector 以及 Milvus server/Zilliz Cloud)保留,这些后端能自行协调并发客户端。Milvus Lite 作为本地文件型存储仍然受保护。
运维红线:不要通过删除或 unlink 活跃的 palace 锁来"夺回"所有权。正确做法是干净地停止持有进程,操作系统会自动释放其锁;怀疑数据损坏时,应备份 palace 并在没有任何可写服务运行的前提下离线执行完整性检查/修复。
hook 侧的实际消费:一次事件一个决策
文档的 "Follow-up PRs" 一节指出:hook 触发的写入现在已消费这份策略,详见 docs/hook-write-routing.md。仓库源码展示了消费方式的两个关键工程决策:
1. 每次 hook 事件只解析一次路由。 一个 Stop 或 SessionEnd 事件可能执行多笔写入(diary checkpoint、transcript ingest、project auto-ingest)。mempalace/hooks_cli.py 用 ContextVar(_HOOK_WRITE_ROUTING_CONTEXT)加上下文管理器 _hook_write_routing_context 把一次解析与一次 daemon 探活共享给整个写入突发,避免反复健康探测,也防止同一事件内不同写入选中不一致的路由。
2. daemon 探活是快速本地检查,而非冷启动。 _daemon_available 只做 localhost 健康探测(超时上限为 HOOK_PROBE_TIMEOUT),docstring 直接写明"hook 时间预算禁止冷启动一个长驻 daemon"。这与策略层 daemon_can_start=False 的约定一致(见 _compute_hook_write_routing 中对 choose_write_route 的调用)。
hook 侧还有两个值得注意的失败语义,均来自 hook-write-routing.md:
- 显式非法策略值失败关闭(fail closed):hook 写入被阻断,不尝试任何直连 ChromaDB 回退;而配置读取/运行时无关故障则保留历史直连保存行为,避免因一次配置读取失败丢掉最终 checkpoint。HookWriteRouting 与
_compute_hook_write_routing中WriteRoutingError与普通Exception的分流处理正对应这两种情形; - 提交歧义处理:一旦尝试了 daemon 提交,任何错误都不触发直连回退——daemon 可能已在客户端观察到故障之前接受了任务,直连重试可能造成内容重复。
require模式被阻断时,hook 会在日志中记录跳过、返回可见的systemMessage(由 _blocked_hook_output 生成),且 Stop save marker 不前进,允许后续重试。
使用 require 的受监督安装必须提前启动 daemon(登录时、插件初始化或会话建立时),例如 mempalace daemon start;SessionStart 在 require 模式下会做快速健康探测并在 daemon 不可用时尽早告警。
尚未纳入路由的操作
文档最后划定了边界,避免读者高估该策略的适用范围:
- CLI 常规写入:剩余推广 PR 才会把策略应用到常规 CLI 写入(本次 foundation 与 hook PR 均未改变 CLI 行为);
- 维护类操作(repair、migration、index rebuild):它们不是普通的"被路由写入",需要单独的 exclusive-maintenance 策略,不消费本策略模型。
小结与验证路径
这份策略的价值在于把"直连 / daemon / 阻断"的决策从各调用方头中收敛到一个纯函数里,并让"require 永不降级"这一安全不变量可以被单元测试穷举验证。建议按以下路径在仓库中自行核对:
- 策略模型本体:mempalace/write_routing.py(约 210 行,纯标准库实现,无 I/O 依赖);
- 配置解析与优先级:mempalace/config.py 的
resolve_write_routing; - hook 消费方式:mempalace/hooks_cli.py 与 docs/hook-write-routing.md;
- 行为基线测试:tests/test_write_routing.py(策略解析、遗留映射、优先级、决策矩阵、失败语义全覆盖);
- 单写者基础设施:mempalace/palace.py 的
mine_palace_lock、mempalace/daemon.py 的租约获取与 mempalace/mcp_server.py 的 peer-writer 保护。
需要说明的适用前提:本策略模型当前主要由 hook 写入路径消费,CLI 侧的常规写入推广仍在后续 PR 中;如果你依赖的是某个具体发行版,应以该版本仓库内 CHANGELOG.md 与文档标注为准,确认 write_routing.hooks / write_routing.cli 是否已被对应代码路径实际读取。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00