Skip to content

第 12 章 可审计性:哈希链、来源追溯与证据链 ​

2026 年 3 月,一家在线问诊平台做事故复盘。系统在孕期用药咨询里给出一句"布洛芬可正常使用",用户截图投诉。工程团队查到 03-11 14:22 有一次召回,返回三条记忆,其中一条写着"患者对布洛芬不过敏,可正常使用",置信度 0.90。

到此为止。三个问题答不出:这条记忆是哪一次对话写进去的;写它的是患者本人、坐诊医生,还是被爬取的药品页面;置信度 0.90 是谁给的、后来有没有被下调。

三个问题属于同一类:审计。日志回答"系统做了什么",审计回答"这个说法凭什么成立"。差别不在详细程度,而在举证方向——日志是系统对自己的陈述,审计是交付给第三方、对方可独立复核的材料。可复核需要三条性质:写入者事后无法否认,称不可否认;能指到具体某一条而非一个批次,称可定位;第三方拿同样输入得出同样结论,称可复算。缺一条,材料在审计桌上就退化成自述。

多数记忆系统的可观测性停在操作流水:谁在何时调了哪个接口、耗时多少、有无报错。这对排障够用,对举证不够用。日志默认轮转删除,保留 7 到 30 天;日志由运维账号写入,有该机器权限的人都能改;批量写入只留一个批次号,第 7 条和第 8 条的区别在日志里不存在。

本章沿"从流水到举证"展开:先界定日志与审计的分界(12.1),再看哈希链如何把"被改过"变成"能指出改在哪一条"(12.2),然后核对审计覆盖面(12.3)、指针链回溯的边界(12.4)、三件套如何组合回答事故复盘(12.5)。最后一节写成本,和一件它明确做不到的事。

12.1 为什么"有日志"不等于"可审计" ​

12.1.1 日志的三个结构性缺陷 ​

第一,可删。 日志轮转是运维常规动作,保留 7 天是很多团队的默认值。一次合规问询从提出到取证通常超过 30 天,等到需要时,关键那几天的日志已被清理,而清理动作本身不留痕。

第二,可改。 日志以追加方式写入普通文件或普通表,读写权限靠操作系统账号维持。一旦攻击者拿到该账号,或内部人员有动机掩盖误操作,修改一条日志的成本接近于零,且没有残留。

第三,无定位。 批量接口的日志粒度是批次。retain_batch 一次写入 200 条,日志里只有一行"200 条写入成功"。事后要判断第 137 条是谁写的、基于哪段输入,日志给不出答案。

具体场景:某三甲医院随访系统用记忆保存患者用药史。2026 年 5 月做药监口径自查,需证明"某条过敏记录在 4 月 2 日之后未被修改"。团队能提供那之后的日志,但日志只能证明"没有修改接口被调用",不能证明"记录没被绕过接口直接改过"——数据库有写权限的账号不止一个。

12.1.2 可审计的三条判据 ​

判据比机制重要,因为它决定了一个系统在什么条件下算过关。三条判据对应三种攻击:

判据要抵挡的攻击检验方式
不可否认写入者事后否认自己写过记录与主体绑定,主体无法单方面重写
可定位篡改后只承认"可能有问题"校验失败时给出具体位置
可复算不同实现得出不同结论同一数据、同一规则,结果一致

表 12-1 可审计的三条判据与对应的检验方式

"可定位"最容易被跳过。很多系统提供一个"完整性校验"按钮,点了返回"通过"或"失败"。失败时它不知道错在哪,运维只能全量恢复备份——这在数据量上不可行,在举证上也不成立:全量恢复意味着放弃区分"哪一条被改"与"哪些条没被改"。

代价必须说清。满足这三条判据的写入路径比只写日志的路径慢:每条多算一次 SHA-256、多存一个前驱哈希、多一次串行化排队。判据是:如果这套记忆的输出会被用于对人的决策(医疗、信贷、雇佣),账本成本必须付;如果只是给个人助手记偏好,日志加定期备份的性价比更高。

12.2 哈希链的工程实现 ​

12.2.1 一条记录如何绑定前一条 ​

哈希链审计账本(hash-chained audit ledger)的核心只有一行公式:每条记录的哈希,由前一条的哈希、本条内容、本条时间戳拼接后取 SHA-256。

h_i = SHA-256(h_{i-1} ‖ content_i ‖ ts_i)

任何一条被改动,它自己的哈希就对不上;而下一条的哈希里包含它的哈希,于是下一条也对不上,依次向后传播。校验的结果因此不是一个布尔值,而是一个位置。

verify_integrity() 返回 {valid, total, first_broken_at}。first_broken_at 是第一个不匹配的下标,也就是篡改点。这是它比日志强一个量级的地方:日志被改,你只知道"可能不对";链被改,你知道改的是第几条。

下面这段最小复现验证"第一个不匹配的位置即篡改点",不依赖任何第三方库:

python
import hashlib

def chain_hash(prev, content, ts):
    return hashlib.sha256(f"{prev}|{content}|{ts}".encode()).hexdigest()

def verify_integrity(entries):
    prev = "0" * 64                      # 创世前驱
    for i, e in enumerate(entries):
        if chain_hash(prev, e["content"], e["ts"]) != e["hash"]:
            return {"valid": False, "total": len(entries),
                    "first_broken_at": i}
        prev = e["hash"]
    return {"valid": True, "total": len(entries), "first_broken_at": None}

改第 3 条内容而不重算哈希,返回 first_broken_at 为 3。改第 3 条并重算它自己的哈希,返回的仍是 3——因为第 4 条记录的 prev 指向旧哈希。要把整条链改干净,必须从篡改点起把后面全部重算,代价随链长线性增长。这正是防护所在。

12.2.2 删除为什么必须留墓碑 ​

链式结构对删除提出硬约束:不能真的把中间某一行抹掉。抹掉之后,后一条记录的前驱哈希指向不存在的值,整条链从该点起永久失效,first_broken_at 会指向一个"什么都没做错"的位置。因此带哈希链的系统,删除语义必须落到"写一条新记录说明旧记录已失效",而不是 DELETE FROM。

这条约束会污染存储:账本只增不减,磁盘占用单调上升,没有回收手段。第 13 章的四层删除语义里,前三层都遵守这条约束,只有第四层会真的破坏链——那时可验证范围就到物理删除发生的位置为止。

12.2.3 哈希链防不住什么 ​

哈希链的前提是攻击者只能改数据、不能重算整条链。一旦攻击者同时具备读全表和写全表权限,并知道算法与字段拼接格式,他可以从头重算一条自洽的链,校验照样通过。真正的防护要在链外锚定:把链头哈希定期写到攻击者够不着的地方——另一套凭据体系下的对象存储、时间戳服务、或纸面归档。

截至 2026 年 10 月的 8.1.0 版本,账本提供链内校验,不提供外部锚定。它能抵挡"改几条数据"这类内部误操作与局部篡改,挡不住"有全库写权限且知道格式"的内部人。做等保或 SOC 2 类审计时这一点必须主动说明,否则就是把链内校验的能力说过头了。

12.3 审计覆盖面 ​

账本记录哪些动作,决定了它能回答哪些问题。8.1.0 覆盖的动作与事后能回答的问题对应如下:

动作记录内容事后能回答
写入(retain / retain_batch)内容哈希、主体、时间戳这条是谁在何时写的
更新(memory_mask / unmask)前后哈希、动作类型正文被谁换成占位符
删除(软删 / hard=true)目标 id、类型、操作者谁删的、标记还是物理
导出 / 导入 / claim范围、条目数、目标作用域数据何时搬走、搬到哪
置信度变更旧值、新值、变更理由0.90 是谁给的、何时下调

表 12-2 审计覆盖面与各动作能回答的问题

最容易被忽略的是最后一行。confidence_history 追踪单条记忆的置信度轨迹,它不是账本的一部分,而是与账本并列的另一条时间线。分开的原因很实际:置信度会随召回反馈、冲突消解、巩固路径反复变化,若每次都进链,链会以检索频次的量级膨胀。代价是置信度的变化本身不受哈希保护。

覆盖面上有一个明确缺口:读取不入链。账本记录写入、更新、删除、迁移,不记录谁在何时召回了哪条记忆。于是"这条敏感记忆被哪些调用方看过",账本答不出来。

这是设计取舍而非疏漏。读操作数量通常是写的两个数量级以上,全部入链会让写入路径的串行化代价转移到读取路径上。代价同样明确:面向 GDPR 第 15 条访问权、或需证明"数据未被越权查看"的场景,账本单独不够用,必须在网关层另做访问日志,并接受那份日志不受哈希保护。

12.4 来源追溯 ​

12.4.1 指针链回溯做了什么 ​

账本回答"谁写的、何时写的",不回答"基于哪句话写的"。后者需要另一套机制:指针链回溯(pointer-chain backtracking)。

记忆在形成时会分层。原始对话是 L0;从对话抽出的单条事实是 L1;若干条 L1 归并成的会话摘要是 L2;同一项目下多条 L2 合并成的主题摘要是 L3。memory_trace 沿指针从 L3 走到 L2 再走到 L1,返回每层内容、该层覆盖的轮次区间,以及对应的 message_ids。

一次典型调用是这样:L3 主题摘要"该客户三季度风险偏好由稳健转为进取",覆盖轮次 12–48;展开到 L2 会话摘要"客户明确接受 15% 回撤",覆盖轮次 31–48;再展开到 L1 事实"客户在 08-14 第 37 轮表示可接受 15% 回撤",message_ids 指向具体消息。三层走完,"这条结论来自哪句话"有了答案。接口说明见 Mnemosyne OS 官方源码仓库。

12.4.2 有意留下的边界 ​

指针链在 L1 停住。引擎不存 L0 的逐字记录,只存指向它的 message_ids,调用方拿着这串 id 回自己的对话历史里取。

这个边界常被误读为缺陷,实际是有意的隐私设计。如果记忆引擎自己留存全部逐字对话,它就变成一份比业务系统更完整、更少被治理的对话副本——业务系统按 30 天清理会话,记忆引擎却永久保存,这恰好是数据最小化原则最反对的形态。把逐字文本留在调用方手里,等于把"保留多久"的决定权交给真正对数据负责的一方。

代价同样明确,而且比想象中严重:指针链的可用性取决于调用方的留存策略。如果调用方按 30 天清理会话历史,那么 31 天前写入的记忆,其 L1 层的 message_ids 全部指向已被清理的消息——指针链在最后一跳断掉,只剩摘要,回不到逐字依据。

举证需求通常发生在数月之后,因此这条链在默认配置下大概率是断的。判据:需要长期举证的场景(医疗、金融投顾、劳动纠纷记录),必须由调用方自己留存 L0 并把留存周期写进制度;把举证责任寄托在记忆引擎上,会在最需要证据的那天发现证据已不在。

12.5 证据链三件套 ​

12.5.1 三个粒度各管一段 ​

三样东西合起来才构成证据链,单独任何一样都不完整:

层级工具回答的问题失效条件
操作级audit谁在何时对哪条记录做了什么有全库写权限者篡改
可信度级confidence_history可信度如何变化、为什么变更未附理由时无法归因
语义级memory_trace结论从哪几句话归纳而来调用方未留存 L0

表 12-3 证据链三件套的分工与各自的失效条件

audit 证明"这条记录存在且未被改",confidence_history 证明"系统对它的信任如何演变",memory_trace 证明"它来自哪些输入"。缺第一样,无法排除事后伪造;缺第二样,无法解释为什么系统当时信了它;缺第三样,无法判断结论是不是从一句被断章取义的话里抽出来的。

12.5.2 一次事故复盘怎么走 ​

把三件套串起来,一次复盘是五步:

  1. 用 recall 复现问题输出,拿到涉事记忆的 memory_id。
  2. 用 audit 查写入记录:时间、主体、有无后续更新或掩码。
  3. 用 confidence_history 查轨迹:初始值、是否被下调、理由。
  4. 用 memory_trace 回溯到 L1,拿 message_ids 取逐字依据。
  5. 把 2–4 的产物按时间排序,附上 verify_integrity() 的输出。

第 5 步是整个流程成立的关键,也最容易被省略。前四步的产物都是可以被再次修改的数据;只有把它们的哈希写进账本、交付时附上校验结果,这份复盘材料才从"我们的说法"变成"可被对方复核的材料"。判据:如果复盘报告的结论只能由出具方自己验证,它就还不是证据。

12.6 成本与边界 ​

链越长,校验越慢。 verify_integrity() 是 O(n) 的全链遍历,n 是账本条目数。十万条量级的全量校验在普通硬件上是秒级操作,百万条以上就要改为增量校验加定期全量。增量校验的代价是:它只能发现"上次校验点之后"的篡改,更早的篡改需要一次全量才能暴露。

并发写入必须串行化。 每条新记录的哈希依赖前一条,写入路径天然是一个队列。批量写入能摊薄单条成本(retain_batch 的批量路径比逐条快约 15 倍),但摊薄的是调用开销,不是串行化本身。高并发下账本是吞吐上限所在,加机器解决不了——除非分片成多条独立的链,代价是跨链顺序关系无法证明。

哈希链能证明"未被篡改",不能证明"已不存在"。 这是本章最重要的一句边界。账本能证明第 137 条记录自写入以来内容未变;它不能证明第 137 条记录所描述的事实,已经从所有副本、缓存、嵌入索引、备份、下游导出文件里消失。恰恰相反:账本为了保持链的完整,必须永久保留这条记录的哈希与前驱指针,于是"曾经存在过什么"本身变成了永久记录。

这条边界不是实现缺陷,是结构性的。哈希链的证明方向是"过去没被改",删除的证明方向是"现在不存在",两者在逻辑上指向相反的时间。任何声称用审计链解决了删除可验证性的说法,都需要被追问一句:链里那条墓碑记录算什么。

12.7 本章小结 ​

本章确立三条判断。

第一,日志与审计的分界不在详细程度,而在举证方向。日志是系统对自己的陈述,审计是交付给第三方独立复核的材料,判据是不可否认、可定位、可复算。

第二,哈希链的价值集中在一个返回值上。first_broken_at 把"数据可能有问题"变成"第 137 条被改过"。这个定位能力是它相对普通日志唯一实质性的增益,其余都是工程代价。

第三,证据链是三样东西的组合。audit 管存在性、confidence_history 管信任演变、memory_trace 管输入来源。缺任何一样,复盘都退化成自述。

三条判断各有成立条件。第一条要求写入路径愿意付出串行化成本,在纯吞吐导向的场景里不成立。第二条要求攻击者不具备全库写权限;内部人同时能读写全表时哈希链失效,需要外部锚定,而 8.1.0 不提供外部锚定。第三条要求调用方自己留存 L0,否则指针链在最后一跳断掉——这是最容易失守的一条,因为默认留存策略通常短于举证周期。

两个问题留在这里没有解决。读取不入链,意味着"谁看过这条记忆"无法回答;账本只增不减,意味着磁盘占用单调上升且没有回收手段。

还有一个更硬的问题被推迟了。账本证明了记录未被篡改,却没有、也不能证明记录所描述的事实已经不存在。当用户说"把关于我的都删掉",当监管方要求出具"已彻底清除"的证明,哈希链给不出答案。它给出的恰恰是反方向的答案——一条永久的墓碑。第 13 章从这个矛盾开始。


Copyright © 2026 胡景堃(Hu Jingkun). 采用 CC BY-NC-ND 4.0 许可。未经授权禁止传播、复制或用于商业化。