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,在后续会话再加载新状态。
九、今天留下的问题
今天真正学会的不是某一个 API,而是:一次可靠的 Agent 状态修改,需要把业务原子性、并发控制、持久化安全和 Context 生命周期分开设计。



