Stage 06:Agent 也得学会“下班前存档”——从 State 到 Memory、Checkpoint 与 HITL
Language: English | 简体中文
前五章结束以后,我们的 Agent 已经不像最开始那个“会聊天的函数”了。它能调用 Tool,能自己走 ReAct 循环,能按照 Workflow 或 Graph 编排任务,能去知识库里找证据,也能通过 MCP 接上外部系统。
能力越来越多,接下来出现的问题却非常朴素:
程序关掉以后,刚才做到哪了?
假设 Agent 正准备给用户退款。它已经查完订单、确认规则、算好金额,最后一步因为会真的动钱,所以系统暂停下来等人工审批。审批人午饭回来点了“同意”,结果原来的 Python 进程早就因为部署重启消失了。
如果你的系统只能回答:
“不好意思,那次审批属于上一条进程的人生经历。”
那它还不能算真正可持续运行的 Agent 系统。
Stage 06 就从这里开始。我们不急着把所有能存东西的数据库统称为“Memory”,而是先把几个非常容易混在一起的概念分清:State、Checkpoint、Short-term Memory、Long-term Memory,以及 Human-in-the-Loop。
这一章的核心不是“怎样把 JSON 塞进数据库”,而是:
什么必须为了继续执行而保存,什么值得跨会话记住,以及什么时候程序必须停下来把决定权交还给人。
1. State 已经有了,为什么还要 Checkpoint?
Stage 03 里我们把 State 摊在了桌面上。一个退款流程可能有这样的状态:
state = {
"order_id": "ORDER-42",
"amount": "18.50",
"phase": "waiting_approval",
}
只要 Python 进程还活着,这个状态待在内存里没有问题。
问题是,内存没有忠诚度。进程退出、容器重启、机器故障,它说没就没。
于是我们需要一个很自然的动作:
runtime state
↓ persist
checkpoint
Checkpoint 可以理解成某个执行时刻的可恢复快照。它关心的问题不是“用户喜欢什么”,而是:
“这次 run 已经执行到了哪里,恢复时必须知道什么?”
这一点很重要,因为 Checkpoint 和 Memory 经常都存到数据库,于是名字一模糊,架构也跟着糊。
把它们先粗略分开:
| 概念 | 它回答的问题 |
|---|---|
| State | 当前执行需要知道什么? |
| Checkpoint | 当前执行快照怎样跨进程保存? |
| Short-term Memory | 同一条会话 / thread 里过去哪些信息要继续保留? |
| Long-term Memory | 跨会话以后,哪些用户相关信息仍值得记住? |
| RAG Knowledge | 外部文档里有哪些证据可以被检索? |
数据库表可能长得很像,但语义不是由数据库产品决定的。
把 checkpoint 表改名叫 memory_super_pro_max,它也不会突然获得心理学学位。
2. run_id、thread_id、user_id 别混成一锅粥
随着系统开始持久化,你会遇到几个 ID。
最危险的写法不是忘记 ID,而是所有地方都用一个 "123",然后靠感觉解释它是谁。
考虑一个用户 Alice。她可能同时开两个对话:
user_id = alice
thread_id = trip-planning
thread_id = expense-reimbursement
而“报销”这条 thread 里,又可能启动一次具体的执行:
run_id = reimburse-2026-09-04-001
三个 ID 的作用域完全不同。
user_id 表示谁拥有长期数据;thread_id 表示哪段连续会话或任务上下文;run_id 表示某一次实际执行。
所以一个合理关系更像:
User
├── Thread A
│ ├── Run 1
│ └── Run 2
└── Thread B
└── Run 3
如果把 thread_id 当成 user_id,跨会话记忆很容易丢;如果把 user_id 当成 thread_id,不同任务的执行状态又可能莫名串在一起。
持久化系统的第一道题,往往不是“选 SQLite 还是 Postgres”,而是先把作用域说清楚。
3. Checkpoint 保存的是“继续干活所需的信息”
本章的教学代码用 SQLite 手写一个很小的 Checkpoint Store:
@dataclass(frozen=True, slots=True)
class WorkflowState:
run_id: str
phase: str
action: str
arguments: dict[str, Any]
result: dict[str, Any] | None = None
保存时,我们把状态序列化后写入数据库:
def save(self, state: WorkflowState) -> None:
payload = json.dumps(asdict(state))
conn.execute(
"""
INSERT INTO checkpoints(run_id, state_json)
VALUES (?, ?)
ON CONFLICT(run_id)
DO UPDATE SET state_json=excluded.state_json
""",
(state.run_id, payload),
)
这段代码最重要的不是 SQLite 语法,而是一个变化:
以前,流程能不能继续依赖原来的 Python 对象还在不在。
现在,只要新的 Runtime 能拿到同一个持久化存储和 run_id,就能重新读出:
phase = waiting_approval
action = issue_refund
arguments = ...
于是“恢复”第一次不再依赖原进程的寿命。
这就是 Durable Execution 最基础的一层含义。
4. Durable 不等于“永远不会重复执行”
这里很容易兴奋过头。
我们已经保存了 Checkpoint,于是有人会宣布:
“太好了,现在所有副作用都 exactly-once 了!”
先把庆功蛋糕放回冰箱。
Checkpoint 能告诉你“上一次做到哪”,但它不能自动控制数据库之外的世界。
想象这样一段流程:
1. 调用支付服务退款
2. 支付服务成功
3. 程序还没来得及保存 completed checkpoint
4. 机器断电
5. 系统恢复旧 checkpoint
6. 再调用一次退款
如果外部支付服务不知道这两个请求其实属于同一次业务动作,你可能真的退了两次。
所以 Durable Recovery 和 Exactly-once Side Effect 是两回事。
本章的教学实现用一个很小的 idempotency_key 演示这个思想:
idempotency_key = f"{run_id}:issue_refund"
然后在本地 effects 表里用唯一键保证同一动作不会被重复记录。
这只能证明教学数据库内部的幂等思路。
到了真实外部 API,你通常还需要对方支持 idempotency key、业务唯一约束,或者设计安全的补偿机制。
这是一个很典型的工程习惯:
不要因为解决了恢复,就顺手宣称解决了整个分布式一致性。
5. 现在轮到 Memory:什么东西值得跨会话记住?
Checkpoint 解决的是“这次任务做到哪”。
但用户可能还有另一类期待:
“以后都用中文回答我。”
“记住我喜欢简短解释。”
“下次别再问我的默认城市了。”
这些不是某一次 Workflow 的执行进度。它们属于跨 thread 的长期信息。
于是我们得到另一个方向:
execution continuity
-> checkpoint
cross-thread personalization / retained knowledge
-> long-term memory
关键问题随之变化。
Checkpoint 往往有很强的机械依据:没有它就无法恢复执行。
Memory 则不是“看到信息就存”。真正困难的是:
什么值得被保存?谁允许保存?保存多久?属于谁?
这就是为什么本章不会写一个函数:
def remember_everything(user_message):
database.insert(user_message)
这个函数确实很好写。
它也确实很容易让隐私团队在凌晨给你打电话。
6. 模型提取出的 Memory 只是候选,不是写入许可
我们定义一个 MemoryCandidate:
@dataclass(frozen=True, slots=True)
class MemoryCandidate:
owner_id: str
key: str
value: dict[str, Any]
kind: MemoryKind
explicit_user_request: bool
sensitive: bool = False
注意名字叫 Candidate。
模型可以从一句:
“以后请用简洁中文回答我。”
提取出:
MemoryCandidate(
owner_id="user-7",
key="answer-style",
value={"language": "Chinese", "style": "concise"},
kind="semantic",
explicit_user_request=True,
)
但 Candidate 不应该直接执行:
store.put(candidate)
中间还有 Policy:
decision = policy.evaluate(candidate)
if decision.store:
store.put(candidate)
本章的保守策略会拒绝三类东西:敏感信息、没有明确记忆意图的偶发信息,以及直接修改 Agent 自身程序规则的 procedural memory。
这并不是说所有产品都必须使用完全一样的规则。
重点是:
模型负责提出“这也许值得记住”;应用负责决定“允许不允许真的写进去”。
这个边界和我们从 Stage 00 一直坚持的原则其实完全一样:
model proposal != application authority
只是这一次,“Tool Call”换成了“Memory Candidate”。
7. Semantic、Episodic、Procedural:先理解,不要急着全实现
Memory 讨论里经常出现三个词。
Semantic Memory 更像稳定事实或偏好,例如“用户偏好中文”。
Episodic Memory 更像过去发生过的事件,例如“上次旅行规划最后选择了京都”。
Procedural Memory 则涉及“应该怎样做事”,例如某种工作流程、策略甚至行为规则。
三者的风险并不相同。
把“用户喜欢中文”写错了,通常还能修。
把“执行退款不需要审批”错误地写进 Procedural Memory,后果显然不在同一个量级。
所以学习 Memory 时不要只问:
“能不能向量搜索?”
还要问:
“它改变的到底是事实、经历,还是系统行为?”
这也是为什么本章默认对 Procedural Memory 更保守。
8. Memory Store 必须有 Owner Scope
一个最小 Long-term Memory Store 至少应该知道“这是谁的数据”。
本章使用:
PRIMARY KEY (owner_id, key)
读取时也必须带 owner_id:
store.get("alice", "answer-style")
而不是:
store.get("answer-style")
后者在单用户 Demo 里看起来毫无问题。
一旦进入多用户环境,它就像公司储物柜只写了“钥匙”两个字,没有柜号。
更完整的系统还会有 tenant、namespace、版本、过期时间、来源、删除状态等,但这些属于规模扩大后的治理问题。这里先把最重要的一件事刻进直觉:
Long-term Memory 从一开始就应该有所有权边界。
9. Human-in-the-Loop:有些地方 Agent 就该停下来
现在回到退款流程。
模型已经提出:
issue_refund(order_id="ORDER-42", amount="18.50")
参数也通过验证。
这并不意味着它应该立即执行。
因为我们又遇到了熟悉的问题:
模型建议做什么
≠
系统现在就有权做什么
退款会产生真实金融副作用,所以我们把流程停在:
waiting_approval
并产生结构化审批请求:
ApprovalRequest(
run_id="run-001",
action="issue_refund",
arguments={"order_id": "ORDER-42", "amount": "18.50"},
reason="Refund changes external financial state.",
)
这个设计比弹出一句:
“确认吗?yes/no”
要强得多,因为审批人明确知道自己正在审什么。
10. 审批不是只有“同意”和“拒绝”
实际业务里,人经常想说:
“可以退,但金额改成 12.50。”
所以我们提供三种结果:
approve
edit
reject
edit 特别容易写错。
人修改了参数,不代表新参数自动合法。
因此流程应该是:
model proposal
↓
human review
↓
approve / edit / reject
↓
if edit: validate edited arguments again
↓
authorization check
↓
execute
本章代码中的:
resolve_refund_arguments(...)
会对编辑后的 order_id 和 amount 重新验证。
比如人工把金额改成 -1,程序不会因为“这是人改的”就肃然起敬,然后给负数退款。
Human-in-the-Loop 是增加一道决策边界,不是关闭输入验证。
11. Approval 也不是 Authorization
这个区别值得单独讲。
假设 Bob 点了“批准退款”。
系统还必须问:
Bob 有退款审批权限吗?
如果 Bob 只是隔壁桌刚好路过的实习生,那么他的鼠标点击并不会获得魔法加持。
Approval 表示“某个人对某个动作给出了审查结果”。
Authorization 表示“这个身份是否被系统允许批准或执行这个动作”。
所以更完整的路径是:
proposal
↓
validation
↓
approval required?
↓
authorized reviewer approves
↓
authorization for execution
↓
side effect
本章重点是 Durable HITL,所以不会展开完整 RBAC / ABAC 系统。后面的可靠性与安全章节会继续处理权限边界。
12. 最关键的一步:原进程死了,审批回来以后还能继续
现在把前面的东西串起来。
Runtime A 启动退款:
runtime_a.start(
run_id="run-001",
order_id="ORDER-42",
amount="18.50",
)
它把状态保存成:
run-001
phase = waiting_approval
然后 Runtime A 消失。
过了一段时间,Runtime B 启动:
runtime_b = RefundWorkflow(
SQLiteCheckpointStore(db_path)
)
它不认识 Runtime A,也没有共享任何 Python 对象。
但它能:
state = store.load("run-001")
恢复后再处理:
ApprovalDecision(outcome="approve")
于是我们第一次得到真正有意义的 Durable HITL:
run
↓
persist
↓
pause
↓
process disappears
↓
new process loads checkpoint
↓
human decision arrives
↓
resume
这比“在一个 input() 前面停住 Python”多迈了一大步。
13. 为什么 Stage 06 不把所有历史直接塞回模型?
学到这里,一个很自然的问题出现了。
我们现在已经能保存:
- Checkpoint;
- 对话历史;
- Long-term Memory;
- 外部检索结果;
- Tool Observation;
- MCP 返回数据。
于是很容易写出一句豪迈的产品需求:
“既然都存了,每次调用模型时全给它不就行了?”
不行。
能保存什么和这一轮该给模型看什么是两个不同问题。
Stage 06 解决的是 retention 与 durability:哪些东西应该存在。
下一章 Stage 07 要解决的是 selection:面对这些已经存在的信息,这一次模型到底应该看到哪些。
这两个问题看起来挨得很近,但混在一起会让架构迅速失控。
数据库是仓库。
Context Window 是办公桌。
你可以在仓库里放一百箱资料,不代表每次开会都应该把一百箱一起倒在桌上。
14. 完整运行一次
本章完整代码在 code/ 中。
先运行:
python stages/06-memory-persistence-hitl/code/demo.py
你会看到流程先暂停等待审批,然后由一个重新创建的 Runtime 从同一个 SQLite 文件恢复并完成执行;之后再经过 Memory Policy 写入一条显式长期偏好。
边界检查:
python stages/06-memory-persistence-hitl/code/checks.py
它覆盖了几个本章真正重要的不变量:Checkpoint 能跨对象重建恢复;Reject 不产生副作用;Edit 后重新验证;教学存储中的 effect key 保持幂等;Memory 默认不保存偶发信息;敏感候选被拒绝;不同 owner 的长期记忆互不串线。
15. 这一章真正应该带走什么
到这里,不需要背一堆数据库产品名。
更重要的是形成几组明确边界。
State 是运行时执行快照,Checkpoint 是 State 的持久化版本。
Checkpoint 主要服务“继续执行”,Long-term Memory 服务“跨会话保留经过选择的信息”。
模型可以提出 Memory Candidate,但不能因为它“觉得重要”就自行获得永久写入权。
Human Approval 可以批准、编辑或拒绝动作,但人工编辑后的参数仍要验证,而且 Approval 不能替代 Authorization。
Durable Resume 可以让新进程继续旧任务,但它不会自动让外部 Side Effect 获得 exactly-once 语义。
如果这些区别已经能自然说清楚,下一章的问题就出现了:
现在我们什么都能存了,可每一次调用模型时,到底该从这些东西里拿什么出来?
这就是 Stage 07:Context Engineering。