2026-09-21

陌生人的一次代码审查,找出了两个我们漏掉的真实缺口

把 Spomory 公开发出去之后,收到的不只是反应,还有真实的技术审查。两条评论各自指着一行具体的代码,问了一个我们答不上来的干净问题。两个问题都变成了真实的修复,发布在 v0.2.0 里——这是当时到底哪里不对、以及改了什么。

「这是在依赖 host 的批准弹窗,还是客户端完全可以无视这个提示?」

大意是这个问题。forget_all_memory 标注了 destructive_hint=true,在 Claude Desktop 里,正是这个标注让调用前弹出逐条批准的对话框。问题的根子在于:如果换一个不弹这个对话框的 host,或者一个被配置成自动批准危险操作的 agent,会发生什么?

去查了实际的工具实现,没有凭感觉回答。它在第一次调用时就直接执行了 delete_all()——没有第二步,没有 dry run。destructive_hint 在 MCP 协议里被明确写成是一个建议性的提示,不强制任何 host 拦截。所以老实的答案是:是的,这完全依赖 host 愿不愿意去执行一个协议本身并不要求它执行的东西。

@mcp.tool(annotations=_TOOL_ANNOTATIONS["forget_all_memory"])
def forget_all_memory(confirm: bool = False) -> str:
    """永久删除整个记忆图谱——所有实体和关系。

    这个工具标了 destructive_hint=True,但这只是个提示——MCP 协议
    不要求 host 一定拦截。真正起作用的是 confirm:第一次调用
    (confirm 留在默认值 False)不会删除任何东西,只会报告真正
    执行会删掉多少。
    """
    ...

不带参数的调用现在只会报告真正执行会删掉多少个实体/关系,不会删除任何东西。真要清空记忆图谱,得显式带上 confirm=true 再调一次——不管 host 的界面弹不弹东西出来。改动不大,但作用不小:这道防线不再依赖这个代码库控制不到的东西。

「抽取只是简单的一半。记忆图谱里没有任何东西能证明更晚写入的就是对的。」

这条评论原话更尖锐:两个客户端写入互相矛盾的事实,才是最该优先设计应对的失败场景,因为没有任何依据能判断哪个是真的。回去查了真实的合并逻辑,没有直接反驳说这不是问题。

情况比「最新覆盖」还糟。同样的主体+关系,不同的客体,代码直接取 existing[0](连按时间排序都不保证)无条件覆盖。没有时间戳比较。`Relation` 上有一个 `confidence` 字段,形状正好是为了解决这个问题——但它在整条抽取路径里从来没被设成过默认值 1.0 之外的任何东西,合并逻辑也从来没读过它。一个看起来像机制、实际上什么都不是的字段。

candidate_client = _client_name(candidate)
outdated_client = _client_name(outdated)
if candidate_client and outdated_client and candidate_client != outdated_client:
    # 两个不同的 MCP 客户端在打架,不是同一个会话在自我纠正——
    # 两条都保留,不去猜哪个对。
    return MemoryAction(ActionType.ADD, relation=candidate, conflict_with=outdated.id)

真正的修复没有去猜谁是对的——这在一般情况下是个真正困难、甚至可能无解的问题。它做的是自动记录下是哪个 MCP 客户端写入了每一条事实,直接从客户端自己在 initialize 握手阶段声明的身份里读出来,不是调用方需要记得传的参数。当一条已有关系和一条新来的关系分别来自两个不同的客户端时,这就不是同一个会话在自我纠正,而是两个客户端产生了分歧。两条都会保留下来,`add_memory` 的返回消息会明确说明这一点,而不是让一条悄悄覆盖另一条。同客户端的修正、或者任意一方身份未知(现在的大多数情况仍是这样)时,还是走原来的原地更新逻辑,没有变。

现在线上是什么状态

两个修复都在 v0.2.0 里——特意用了 minor 版本号,因为 forget_all_memory 的 confirm 要求对老的调用方式来说是一次真实的破坏性变更。已发布到 PyPI、官方 MCP Registry,也部署到了生产环境;新增/改动了 9 个测试,覆盖跨客户端保留双方、同客户端正常更新、客户端身份未知时的兜底、以及 confirm 参数的两条路径。

目前为止的规律

这份清单上的每一个修复——Glama 部署那条链路、embedding 启动阻塞、还有这两个——都是从有人真的在用或者真的在读 Spomory、真的撞上了什么开始的,不是凭空规划出来的功能。这也是把源码摆出来、邀请别人来挑刺的意义所在:真正重要的缺口,是内部从来没想过要问的那些。