欢迎光临
我们一直在努力

娜样学AI(十八|18):Atomic Batch Operations 与 Agent Memory——从原子性、文件锁到安全持久化

2026年10月4日

一、今天学了什么

今天真正搞明白的主线,不只是 Atomic Batch Operations(原子批量操作) 这个名词,而是一个 Agent Memory 系统在修改持久化数据时,为什么不能简单地“读文件 → 改字符串 → 写回去”。

从批量操作的 all-or-nothing(要么全部成功,要么全部不生效),到 replace() 如何唯一定位一条 Memory,再到文件锁、临时文件、fsync()、os.replace(),这些东西解决的其实是不同层次的问题:

业务操作是否完整
↓
Atomic Batch Operations

多个线程/进程会不会互相覆盖
↓
File Lock

程序崩溃时文件会不会只写了一半
↓
Temp File + fsync + os.replace

Agent 当前会话的 Prompt 是否稳定
↓
Frozen Snapshot + Prefix Cache

我之前会把这些机制都模糊地理解成“为了防止写文件出错”,但实际上它们分别在保护 业务一致性、并发一致性、持久化完整性和推理性能。


二、Atomic Batch Operations:重点不是 Batch,而是 Atomic

第一次看到 Atomic Batch Operations 时,我对 Atomic、Batch、Transaction 这些词其实都比较陌生。拆开以后就简单很多:

  • Batch(批量):一次处理多个操作。
  • Atomic(原子):这一组操作不可再被观察成“只完成了一半”。
  • Atomic Batch Operations:多个操作组成一个整体,要么全部成功,要么整体失败。

例如 Memory 中需要同时执行:

remove old_memory_A
replace old_memory_B
add new_memory_C

假设前两个成功,第三个失败。

如果系统直接留下:

A 已删除
B 已修改
C 添加失败

Memory 已经处于一个原本并不希望出现的中间状态,原子操作期望的是:

A 删除成功
B 修改成功
C 添加成功
↓
整体提交

或者:

中间任意一步失败
↓
整个批次不生效

这就是 all-or-nothing semantics(全有或全无语义)。

这里还有一个需要修正的理解:

Atomic Batch Operations 并不等于完整数据库事务。

数据库事务通常会讨论 ACID:

Atomicity 原子性
Consistency 一致性
Isolation 隔离性
Durability 持久性

我们今天主要讨论的是其中的 Atomicity。一个 Memory 工具支持原子批量修改,并不能自动说明它具备完整的 ACID Transaction。


三、没有 memory_id,replace 到底怎么知道修改哪一条?

我原来很自然地认为:

修改一条 Memory,应该先拿到 UUID 或 memory_id,然后根据 ID 更新。

但今天看到的实现并不是这样。它保存的是若干文本 Entry,修改时通过 old_text 做 substring matching(子串匹配):

matches = [ i for i, entry in enumerate(entries) if old_text in entry]

这里 enumerate(entries) 会同时返回:

索引 i + 当前 entry

例如:

entries = [ "用户喜欢 Python", "用户正在学习 Agent", "用户正在学习 RAG"]

如果:

old_text = "学习 Agent"

那么只会匹配第二条。真正关键的不是“能找到”,而是:

len(matches) == 1

只有唯一匹配时才允许继续修改。如果:

len(matches) == 0

说明目标不存在。如果:

len(matches) > 1

说明描述有歧义。这时最安全的设计不是“随便改第一条”,而是直接失败。这是一种典型的 fail closed(失败时默认拒绝) 思路。对于 Agent Tool 来说尤其重要,因为 LLM 给出的参数不是绝对可靠的。如果 "Python" 同时出现在五条 Memory 中:

replace(old_text="Python", …)

系统绝不能自己猜“模型大概想改第二条”。


四、文件锁和原子写解决的根本不是同一个问题

这是今天我认为最重要的一个认知修正。File Lock:解决并发覆盖。

假设两个进程几乎同时更新 Memory。

进程 A:

读取版本 V1
修改 A
准备写回

进程 B:

读取版本 V1
修改 B
准备写回

如果没有锁,可能发生:

A 写入 V2
B 随后根据旧 V1 写入 V3

最终 A 的修改被 B 覆盖。这叫 Lost Update(丢失更新)。因此代码通常会:

获取文件锁
↓
重新读取最新状态
↓
修改
↓
写回
↓
释放锁

注意一定是拿到锁以后重新读取,否则即使加锁,也可能仍然拿着加锁之前读到的旧数据进行覆盖。


Atomic Write:解决半文件问题

另一个问题发生在写文件本身。最简单的方式是:

with open(path, "w") as f: f.write(content)

"w" 会先截断原文件。如果程序刚写了一半突然:

进程崩溃
机器断电
抛出异常

可能留下一个残缺文件。因此更可靠的策略是:

原文件 memory.md
↓
创建同目录临时文件
↓
完整写入临时文件
↓
flush()
↓
fsync()
↓
os.replace()
↓
新文件整体替换旧文件

简化代码可以理解成:

import osimport tempfilefd, tmp_path = tempfile.mkstemp(dir=".")try: with os.fdopen(fd, "w") as f: f.write("new memory") f.flush() os.fsync(f.fileno()) os.replace(tmp_path, "memory.md")except Exception: if os.path.exists(tmp_path): os.unlink(tmp_path) raise

这样设计的核心不是“临时文件比较高级”,而是尽可能让观察者看到:

完整旧版本

或者:

完整新版本

而不是半个新版本。所以一定要区分:

Lock 防止多个 writer 互相覆盖;Atomic Replace 防止一次 writer 留下半成品。

两者不能互相替代。


五、一次安全的 replace,其实是一条完整执行链

今天看到 replace() 的实现以后,我发现生产级 Tool 和普通 Python 函数最大的区别,就是它不会直接执行核心动作。

一个更完整的执行流程类似:

old_text / new_content
↓
参数 strip
↓
检查是否为空
↓
安全扫描
Injection / Exfiltration
↓
获取文件锁
↓
重新加载最新状态
↓
寻找 old_text
↓
必须唯一匹配
↓
检查 Memory 容量限制
↓
更新内存状态
↓
原子持久化到磁盘

也就是说,真正的:

entries[index] = new_content

可能只是整个 Tool Runtime 中最简单的一步。更重要的是它前后的 Validation(校验) 和 Persistence Safety(持久化安全)。这和我最近学习 Agent Runtime 的理解其实完全一致:

LLM
↓
生成 Tool Call
↓
Runtime Validation
↓
真正执行
↓
State Update
↓
Observation 返回模型

LLM 可以负责“决定要做什么”,但不能直接决定所有副作用如何落地。确定性的执行器必须兜住边界。


六、为什么 Memory 已经修改了,System Prompt 却故意不更新?

今天还有一个挺反直觉的设计:

def format_for_system_prompt(self, target): block = self._system_prompt_snapshot.get(target, "") return block if block else None

这里返回的不是实时 Memory,而是:

_system_prompt_snapshot

也就是会话开始时加载出来的 Frozen Snapshot(冻结快照)。我最开始会认为:

Memory 修改成功以后,下一轮 Prompt 应该立即使用最新 Memory。

但这个实现故意没有这样做。原因和 Prefix Cache / KV Cache 前缀复用 有关。假设系统提示词前半部分一直稳定:

System Prompt
+ Tool Definitions
+ Long-term Memory Snapshot

那么多轮 Agent 调用时,大段前缀可以保持不变。如果每次 Memory 有一点修改,就重新生成 System Prompt:

第1轮 Prefix:AAAAA
第2轮 Prefix:AAAAB
第3轮 Prefix:AAABC

稳定前缀不断变化,会削弱 Prefix Cache 的复用效果。因此这个架构实际上把两件事拆开了:

Memory Persistent State
≠
Current Session Prompt Snapshot

Memory 可以已经成功写入磁盘,但当前会话仍继续使用启动时的 Snapshot;新的 Memory 可以等到后续重新加载时进入 Prompt。这让我进一步理解了:

Memory 系统不仅是“存数据”,还必须考虑数据什么时候进入 Context。


七、和真实 Agent 项目的关系

今天这些内容最终可以映射到一个很典型的 Agent Harness / Runtime 设计:

LLM Planning
↓
Tool Call
↓
Argument Validation
↓
Security Check
↓
Acquire Lock
↓
Reload Latest State
↓
Deterministic Update
↓
Atomic Persistence
↓
Update Runtime State
↓
Observation
↓
LLM 下一轮决策

真正的 Agent 工程并不是:

model -> function()

这么简单。只要 Tool 会修改文件、Memory、数据库、订单或者其他外部状态,就必须考虑:

并发、原子性、唯一定位、错误处理、状态同步和副作用安全。这也是为什么我现在越来越能理解一句话:

LLM 是概率性的决策器,Runtime 应尽可能是确定性的执行器。


八、今天踩的坑 / 容易混淆的地方

我原来以为:Atomic 就是“这条代码执行得很快,不能被打断”。

实际上:这里更重要的是从外部可观察结果来看,一个操作或者一组操作不能留下“执行了一半”的业务状态。


我原来以为:有了 os.replace(),文件更新就是线程安全的。

实际上:os.replace() 主要解决文件替换的完整性问题;多个 writer 的读-改-写竞争仍然需要 Lock 或其他并发控制。


我原来以为:Memory 更新以后应该立即修改当前 System Prompt。

实际上:持久化状态和当前会话 Context 可以采用不同生命周期。为了保持 Prompt Prefix 稳定,系统可以使用 Frozen Snapshot,在后续会话再加载新状态。


九、今天留下的问题

  • 如果两个 Agent 实例分别运行在不同机器上,本地 .lock 文件已经无法协调并发,这时 Memory 应该怎样实现分布式锁或版本控制?
  • File Lock + Atomic Replace 能解决单文件安全更新,但如果一次操作需要同时修改多个文件,怎样实现真正的跨文件原子性?
  • Memory 更新不立即进入当前 Prompt 能提高 Prefix Cache 复用率,但如果刚写入的是用户要求“立即记住并使用”的关键信息,应该如何在一致性和缓存效率之间做取舍?
  • 今天真正学会的不是某一个 API,而是:一次可靠的 Agent 状态修改,需要把业务原子性、并发控制、持久化安全和 Context 生命周期分开设计。

    赞(0)
    未经允许不得转载:171主机测评 » 娜样学AI(十八|18):Atomic Batch Operations 与 Agent Memory——从原子性、文件锁到安全持久化
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址