记一次线上事故:Agent 执行到一半被中断,恢复后人傻了 😱
事故现场
周二的下午,线上突然告警:某用户的报表查询请求超时了。
运维的兄弟二话不说,先 kill -9 掉了卡住的进程,然后重启服务。
结果用户炸了------
"我的查询进度怎么全丢了?!我跑了 2 小时的数据分析,重启之后全没了,从头开始?!"
我赶紧去看日志,发现问题比想象中严重:
ini
[14:32:01] User request: 查询 2024Q1 报表
[14:32:02] Node: fetch_data -> checkpoint saved
[14:32:03] Node: process_data -> checkpoint saved
[14:35:47] ⚠️ Process killed (OOM)
[14:35:52] Process restarted
[14:35:53] User request: resume from last checkpoint
→ ❌ No checkpoint found for thread_id=xxx
[14:35:53] Starting from scratch...
用户以为他在等一个查询,实际上他在等 两个小时的计算。而运维的"快速止血"直接把这些计算全清了。
小李看到这一幕,脸都绿了:
"哥,LangGraph 不是有 checkpoint 吗?为什么没恢复?"
这个问题好,我也想知道答案。
一、什么是 Checkpoint?为什么需要它?
短期记忆 vs 长期记忆
在 LangGraph 里,Agent 的记忆分两层:
| 类型 | 存储 | 用途 | 生命周期 |
|---|---|---|---|
| Checkpoint(短期记忆) | Redis / Postgres | 保存 Node 执行过程中的中间状态 | 一个 Thread 的执行期间 |
| Store(长期记忆) | Redis / Postgres / S3 | 跨 session 的持久化知识 | 永久,直到手动删除 |
Checkpoint 是执行过程中的快照。类比人的话,就是工作记忆------当前正在做的事的各种中间状态。
为什么需要 Checkpoint?
三个核心场景:
1. 中断恢复
css
Agent 执行中...
→ Node A done,状态写入 Checkpoint
→ Node B done,状态写入 Checkpoint
→ Node C 执行到一半,进程崩了
→ 重启后,从 Node C 继续(不用从头跑 A、B)
2. 并发控制
ini
User A: thread_id=user_a,checkpoint=001
User B: thread_id=user_a,checkpoint=002 ← 乐观锁,版本冲突则重试
3. 多轮对话
sql
Round 1: User 说"帮我查Q1报表" → checkpoint001
Round 2: User 说"再加个Q2" → 从 checkpoint001 恢复,继续执行 → checkpoint002
Round 3: User 说"导出Excel" → 从 checkpoint002 恢复 → 完成
二、deepagents 的 Checkpoint 体系
create_deep_agent 的 checkpointer 参数
deepagents/graph.py 里的 create_deep_agent 签名(line 231):
python
def create_deep_agent(
model: str | BaseChatModel | None = None,
tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None,
*,
# ... 其他参数 ...
checkpointer: Checkpointer | None = None, # ← 关键参数
store: BaseStore | None = None,
# ...
) -> CompiledStateGraph:
这个 checkpointer 最终透传给 create_agent(line 772):
python
return create_agent(
model,
system_prompt=final_system_prompt,
tools=_tools,
middleware=deepagent_middleware,
# ...
checkpointer=checkpointer, # ← 透传到 LangChain Agent
store=store,
# ...
)
三种 Checkpointer
LangGraph 支持多种存储后端:
python
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.redis import RedisSaver
from langgraph.checkpoint.memory import MemorySaver # 仅用于测试
# 生产环境
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
# 或者 Redis(性能更高)
checkpointer = RedisSaver.from_url("redis://...")
| Checkpointer | 适用场景 | 性能 | 持久性 |
|---|---|---|---|
| MemorySaver | 测试 | 最快 | 进程重启丢失 ❌ |
| RedisSaver | 生产(单机) | 快 | Redis 持久化 ✅ |
| PostgresSaver | 生产(分布式) | 中 | Postgres 持久化 ✅ |
⚠️ 生产环境绝对不能用 MemorySaver。进程重启后数据全丢。
三、deepagents 怎么做到中断继续?
核心链路
arduino
graph.invoke(input, config={"configurable": {"thread_id": "xxx"}})
↓
deepagents 内部调用 LangChain create_agent
↓
LangChain Agent 编译成 Pregel 图
↓
Pregel.run() 执行循环,每个 Node 完成后写 checkpoint
↓
如果配置了 interrupt_on,触发 HumanInTheLoopMiddleware
HumanInTheLoopMiddleware:中断机制的核心
langchain/agents/middleware/human_in_the_loop.py 实现了 deepagents 的中断功能:
python
class HumanInTheLoopMiddleware(AgentMiddleware):
def __init__(
self,
interrupt_on: dict[str, bool | InterruptOnConfig],
*,
description_prefix: str = "Tool execution requires approval",
) -> None:
# interrupt_on 格式:
# {"edit_file": True, "delete_file": {"allowed_decisions": ["approve", "reject"]}}
self.interrupt_on = resolved_configs
def after_model(self, state, runtime) -> dict[str, Any] | None:
# 在 LLM 返回 tool_calls 后,检查哪些需要人工审批
last_ai_msg = messages[-1] # 拿到 LLM 的响应
if not last_ai_msg.tool_calls:
return None
for tool_call in last_ai_msg.tool_calls:
if tool_call["name"] in self.interrupt_on:
# 调用 langgraph.types.interrupt 暂停执行
decisions = interrupt(hitl_request)["decisions"]
interrupt() 函数:暂停执行的机制
langgraph/types.py line 801:
python
def interrupt(value: Any) -> Any:
"""Interrupt the graph with a resumable exception from within a node.
The first invocation of this function raises a `GraphInterrupt` exception,
halting execution. The provided `value` is included with the exception
and sent to the client.
A client resuming the graph must use `Command` primitive to specify a
value for the interrupt and continue execution.
"""
关键 :当 interrupt() 被调用时:
scss
interrupt(value)
→ raise GraphInterrupt(value)
→ 执行暂停,状态保存到 Checkpointer
→ 客户端收到中断请求,审查 tool_calls
→ 客户端调用 graph.invoke(Command(resume=value))
→ 从断点恢复,继续执行
中断时的状态保存
python
# 伪代码,实际在 Pregel.run() 里
for node in graph.nodes:
result = node.execute(state)
# 每个 Node 执行完毕后,写入 checkpoint
checkpointer.put(
thread_id=thread_id,
checkpoint_id=new_checkpoint_id,
state=current_state # 包含所有节点的输出
)
if should_interrupt(node):
# 保存中断点
save_interrupt_checkpoint(thread_id, node.name, result)
raise GraphInterrupt(resume_data)
四、中断恢复的完整流程
创建 Agent 时配置 interrupt_on
python
from deepagents.graph import create_deep_agent
from langgraph.checkpoint.redis import RedisSaver
checkpointer = RedisSaver.from_url("redis://localhost:6379")
agent = create_deep_agent(
model=model,
tools=ALL_TOOLS,
checkpointer=checkpointer,
interrupt_on={
"edit_file": True, # 所有 edit_file 调用都中断
"execute": True, # 所有 execute 调用都中断
"delete_file": { # 自定义审批策略
"allowed_decisions": ["approve", "reject", "edit"],
"description": "确认删除文件"
}
}
)
第一次调用:正常执行直到中断点
python
result = agent.invoke(
{"messages": [{"role": "user", "content": "帮我修改 config.py,把端口改成 8080"}]},
config={"configurable": {"thread_id": "user_123_session_456"}}
)
# 执行到 edit_file,触发中断
# 返回 GraphInterrupt,携带待审批的 tool_call 信息
恢复执行:批准/修改/拒绝
python
from langgraph.types import Command
# 场景1:批准原始调用
result = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config={"configurable": {"thread_id": "user_123_session_456"}}
)
# 场景2:修改参数后执行
result = agent.invoke(
Command(resume={
"decisions": [{
"type": "edit",
"edited_action": {
"name": "edit_file",
"args": {"file_path": "config.py", "old_string": "port=3000", "new_string": "port=8080"}
}
}]
}),
config={"configurable": {"thread_id": "user_123_session_456"}}
)
# 场景3:拒绝
result = agent.invoke(
Command(resume={
"decisions": [{
"type": "reject",
"message": "这个文件不能修改,请换其他方案"
}]
}),
config={"configurable": {"thread_id": "user_123_session_456"}}
)
多轮对话:同一个 thread_id
python
# Round 1
result = agent.invoke(
{"messages": [HumanMessage(content="查Q1报表")]},
config={"configurable": {"thread_id": "user_456"}}
)
# checkpoint_001 saved
# Round 2(从 checkpoint_001 恢复,继续执行)
result = agent.invoke(
{"messages": [HumanMessage(content="再加个Q2")]},
config={"configurable": {"thread_id": "user_456"}}
)
# checkpoint_002 saved
五、应用层怎么做中断恢复?
实战代码:完整的中断恢复流程
python
from deepagents.graph import create_deep_agent
from langgraph.checkpoint.redis import RedisSaver
from langgraph.types import Command
from langchain.agents.middleware.human_in_the_loop import HITLRequest
checkpointer = RedisSaver.from_url("redis://localhost:6379")
agent = create_deep_agent(
model="claude-sonnet-4-6",
tools=[...],
checkpointer=checkpointer,
interrupt_on={
"edit_file": True,
"execute": True,
}
)
def run_with_hitl(user_input: str, thread_id: str):
"""带有人工审批的 Agent 执行"""
while True:
try:
result = agent.invoke(
{"messages": [HumanMessage(content=user_input)]},
config={"configurable": {"thread_id": thread_id}}
)
return result
except GraphInterrupt as e:
# e.value 是 HITLRequest,包含待审批的 tool_calls
hitl_request: HITLRequest = e.value
print(f"⚠️ 需要人工审批 {len(hitl_request['action_requests'])} 个操作")
# 这里应该调用审批接口,让人类确认
decisions = prompt_user_approval(hitl_request)
# 用审批结果恢复执行
user_input = None # 恢复时不需要新输入
result = agent.invoke(
Command(resume={"decisions": decisions}),
config={"configurable": {"thread_id": thread_id}}
)
return result
自动恢复:幂等调用
python
def safe_invoke(agent, user_input: str, thread_id: str):
"""如果已经被中断过,直接从 checkpoint 恢复"""
try:
# 先检查是否有未完成的 checkpoint
existing = checkpointer.get(thread_id, "latest")
if existing and existing.get("pending_tool_calls"):
# 存在未完成的流程,直接恢复
return agent.invoke(
Command(resume={"decisions": []}), # 用空决策恢复
config={"configurable": {"thread_id": thread_id}}
)
# 没有 checkpoint,正常执行
return agent.invoke(
{"messages": [HumanMessage(content=user_input)]},
config={"configurable": {"thread_id": thread_id}}
)
except Exception as e:
logger.error(f"Invoke failed: {e}")
raise
异常处理与恢复
python
from langgraph.errors import CheckpointNotFoundError
def run_with_recovery(thread_id: str, input_data: dict, max_retries: int = 3):
for attempt in range(max_retries):
try:
result = agent.invoke(
input_data,
config={"configurable": {"thread_id": thread_id}}
)
return result
except CheckpointNotFoundError:
# 没有 checkpoint,从头开始
input_data = None # 恢复时会从 checkpoint 读取状态,不需要新 input
continue
except GraphInterrupt as e:
# 人工审批中断,按正常流程处理
raise
except Exception as e:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt) # 指数退避
六、事故复盘:为什么那次没恢复?
回到开头的事故。分析日志发现:
ini
[14:32:03] Node: process_data -> checkpoint saved ← ✅ 写入成功
[14:35:47] ⚠️ Process killed (OOM) ← ✅ OOM 杀进程
[14:35:52] Process restarted ← ❌ 重启后用了新的 thread_id
[14:35:53] resume from checkpoint ← ❌ thread_id 不匹配,查不到
根因 :运维重启服务后,代码里每次请求都生成了新的 thread_id,导致 Checkpoint 查不到。
python
# ❌ 错误:每次都用新的 thread_id(我司事故的根因)
thread_id = str(uuid4()) # 每次请求都是新的 UUID
result = graph.invoke(input, config={"configurable": {"thread_id": thread_id}})
# ✅ 正确:同一个用户/会话用同一个 thread_id
thread_id = f"user_{user_id}_session_{session_id}" # 固定的
result = graph.invoke(input, config={"configurable": {"thread_id": thread_id}})
修复方案:Session 管理
python
class SessionManager:
def __init__(self, user_id: str, session_id: str):
self.thread_id = f"user_{user_id}_session_{session_id}"
def get_or_create(self, user_id: str, session_id: str) -> str:
"""获取固定的 thread_id"""
return f"user_{user_id}_session_{session_id}"
def invoke(self, agent, user_input: str):
return agent.invoke(
{"messages": [HumanMessage(content=user_input)]},
config={"configurable": {"thread_id": self.thread_id}}
)
def resume(self, agent, decisions: list):
return agent.invoke(
Command(resume={"decisions": decisions}),
config={"configurable": {"thread_id": self.thread_id}}
)
七、Checkpoint 的数据结构
Checkpoint 完整结构
langgraph/checkpoint/base/__init__.py 定义了 Checkpoint 的结构(line 92-123):
python
class Checkpoint(TypedDict):
"""State snapshot at a given point in time."""
v: int # 版本号,当前是 `2`
id: str # 唯一 ID,递增,用于排序
ts: str # ISO 8601 时间戳
channel_values: dict[str, Any] # 各 channel 的当前值
channel_versions: dict[str, str | int | float] # 各 channel 的版本号
versions_seen: dict[str, ChannelVersions] # 每个节点见过的 channel 版本
updated_channels: list[str] | None # 本次更新的 channel 列表
channel_values 实际存储示例
对于 deepagents 的 Agent,典型的 channel_values 长这样:
python
{
"messages": [ # ← 使用 DeltaChannel,不是直接存完整列表
AIMessage(content="用户的问题是...", id="msg_001"),
ToolMessage(content="工具返回了...", tool_call_id="call_001", id="msg_002"),
AIMessage(content="分析结果...", id="msg_003"),
],
"fetch_data": {"status": "done", "rows": 1024},
"process_data": {"status": "done", "result": [...]},
"llm_output": {"reasoning": "...", "final": "..."},
}
注意 :messages 字段不是普通的 list,而是 DeltaChannel 类型,用特殊的 reducer 来增量更新。
CheckpointMetadata
python
class CheckpointMetadata(TypedDict, total=False):
source: Literal["input", "loop", "update", "fork"]
"""checkpoint 来源:input=输入、loop=循环、update=手动更新、fork=分支"""
step: int
"""步骤号。-1 是第一个 input,0 是第一个 loop"""
parents: dict[str, str]
"""父 checkpoint ID(用于回溯)"""
run_id: str
"""创建这个 checkpoint 的 run ID"""
counters_since_delta_snapshot: dict[str, tuple[int, int]]
"""每个 channel 的更新次数和 superstep 数(用于 DeltaChannel 快照策略)"""
CheckpointTuple:完整的检查点元组
python
class CheckpointTuple(NamedTuple):
config: RunnableConfig # 包含 thread_id, checkpoint_id 等
checkpoint: Checkpoint # 实际的 checkpoint 数据
metadata: CheckpointMetadata # 元数据
parent_config: RunnableConfig | None = None # 父 checkpoint 的配置
pending_writes: list[PendingWrite] | None = None # 已提交但未 flush 的写
PendingWrite:中间态写入
python
PendingWrite = tuple[str, str, Any] # (task_id, channel_name, value)
当一个 Node 执行完毕但 checkpoint 还没写入时,写入会先存在 pending_writes 里。
八、_messages_delta_reducer:消息怎么增量存储
问题:为什么需要特殊的 reducer?
对于 messages 字段,如果每次都存完整列表:
python
# ❌ 朴素存储:每次追加都是 O(N)
messages: [msg1, msg2, ..., msg1000] # N=1000 时,序列化/反序列化很慢
随着对话进行,消息列表越来越长,每次序列化和反序列化的成本是 O(N²)------每次写入都要复制整个列表。
解决方案:DeltaChannel + _messages_delta_reducer
deepagents 的 _DeepAgentState(deepagents/graph.py line 63-66):
python
class _DeepAgentState(AgentState):
"""AgentState with DeltaChannel on messages to reduce checkpoint growth from O(N²) to O(N)."""
messages: Required[
Annotated[
list[AnyMessage],
DeltaChannel(_messages_delta_reducer, snapshot_frequency=50) # ← 关键
]
]
这行代码的意思是:
messages是一个list[AnyMessage]- 用
DeltaChannel包装,而不是普通 channel snapshot_frequency=50表示每 50 次更新才写一次完整快照
_messages_delta_reducer 做了什么?
deepagents/_messages_reducer.py 的核心逻辑(line 31-87):
python
def _messages_delta_reducer(
state: list[AnyMessage], # 当前已存在的消息列表
writes: list[list[AnyMessage]] # 新写入的消息批次
) -> list[AnyMessage]:
"""增量 reducer:只追加新消息,不复制旧消息"""
# 1. 扁平化所有 writes
flat = []
for w in writes:
flat.extend(w) if isinstance(w, list) else flat.append(w)
# 2. 按 ID 去重(ID 已经在 LangGraph 层面分配好了)
# 3. 墓碑机制:RemoveMessage(id) 把对应 ID 的消息标记为删除
# 4. REMOVE_ALL_MESSAGES:清空所有消息
result = []
index = {} # id -> position
for m in state:
if m.id is not None:
index[m.id] = len(result)
result.append(m)
for msg in flat:
mid = msg.id
if mid is None:
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None # 墓碑
del index[mid]
elif mid in index:
result[index[mid]] = msg # 更新已有消息
else:
index[mid] = len(result)
result.append(msg)
return [m for m in result if m is not None] # 过滤墓碑
增量存储的实际效果
| 对话轮数 | 朴素存储 | DeltaChannel 存储 |
|---|---|---|
| Round 1 | msg1 | msg1 |
| Round 10 | msg1,...,msg10 | msg1 + delta |
| Round 100 | msg1,...,msg100 | msg1 + 2个snapshot |
| Round 1000 | msg1,...,msg1000 | msg1 + 10个snapshot |
snapshot_frequency=50 意味着:每 50 条消息写一次完整快照,中间只存增量 delta。这样反序列化时最多重放 50 条消息,成本从 O(N²) 降到 O(N)。
墓碑机制:怎么删除消息?
python
# 墓碑消息,标记某个 ID 的消息被删除
RemoveMessage(id="msg_003")
# 在 reducer 里:
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None # 标记为 None
del index[mid]
# 最终过滤掉 None
return [m for m in result if m is not None]
九、DeltaChannel 的快照策略
什么时候写完整快照?
DeltaChannel 有两个触发快照的条件(langgraph/channels/delta.py line 50-55):
- 更新次数达到
snapshot_frequency(默认 1000) - superstep 数达到
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT(系统级上限,默认 5000)
python
# DeltaChannel.checkpoint() 永远返回 MISSING
# 快照写入由 create_checkpoint 决定
def checkpoint(self) -> Any:
return MISSING # 不存完整值,只存 sentinel
真正写入快照的地方在 create_checkpoint,它会检查:
python
# 如果这个 channel 的更新次数 >= snapshot_frequency
# 或者系统 superstep 数 >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
# 就写入 _DeltaSnapshot(value)
_DeltaSnapshot 结构
python
# langgraph/checkpoint/serde/types.py
class _DeltaSnapshot:
value: Any # 当时 channel 的完整值
恢复时:
python
# DeltaChannel.from_checkpoint()
if isinstance(checkpoint, _DeltaSnapshot):
new.value = checkpoint.value # 直接恢复快照
else:
# 否则需要重放增量writes
new.replay_writes(writes)
十、面试题
Q1:LangGraph 的 Checkpoint 和 Store 有什么区别?
| 维度 | Checkpoint | Store |
|---|---|---|
| 用途 | 执行过程中的中间状态 | 跨 session 的持久化知识 |
| 生命周期 | 一个 Thread 执行期间 | 永久 |
| 触发时机 | 每个 Node 执行完毕 | 手动写入 |
| 类比 | 工作记忆(RAM) | 长期记忆(硬盘) |
Q2:Checkpoint 的存储结构是什么样的?thread_id 在哪?
python
# Checkpoint 是 KV 存储,key 是 (thread_id, checkpoint_id)
# thread_id 在 config 里,不是在 checkpoint 内部
{
"key": ("user_123", "checkpoint_003"),
"value": {
"v": 2,
"id": "checkpoint_003",
"ts": "2024-03-01T14:32:03.000Z",
"channel_values": {
"messages": [...],
"fetch_data": {"status": "done"}
}
}
}
thread_id 是查询条件,不是存储内容。所以换个 thread_id 就查不到,这是开头事故的根因。
Q3:为什么 messages 用 DeltaChannel 而不是普通列表?
时间复杂度:朴素存储是 O(N²),DeltaChannel 增量是 O(N)。
实际例子:
python
# 1000 轮对话后
朴素存储: 1000 条消息 × 序列化 = O(1000²) = 百万级操作
DeltaChannel: 1个snapshot + 20个delta × 重放50条 = O(1000) 线性
Q4:DeltaChannel 的快照策略是什么?什么时候不写快照?
两个触发条件:
- 该 channel 的更新次数达到
snapshot_frequency(默认 1000) - 系统 superstep 数达到
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT(默认 5000)
中间状态只存增量 writes,不存完整值。
Q5:什么是墓碑机制?为什么需要它?
墓碑 是 RemoveMessage(id="xxx"),标记某个 ID 的消息被删除。
为什么需要:消息在 reducer 层去重和更新时,需要一个标记来表示"这条消息曾经存在,但现在被删了"。直接删除会导致 ID 错位,墓碑保留位置但过滤掉最终输出。
Q6:deepagents 的中断是怎么实现的?
核心依赖 langgraph.types.interrupt():
python
# 当 HumanInTheLoopMiddleware 检测到需要审批的 tool_call
decisions = interrupt(hitl_request)["decisions"]
# → raise GraphInterrupt(hitl_request)
客户端收到 GraphInterrupt 后,必须用 Command(resume={...}) 恢复执行。
Q7:Checkpoint 怎么保证并发安全?
乐观锁。写入时会检查版本号:
python
def put(thread_id, checkpoint_id, state, version):
existing = db.get(thread_id, checkpoint_id)
if existing.version != version:
raise ConcurrentUpdateError() # 版本冲突
db.put(thread_id, checkpoint_id, state)
冲突时框架自动重试,业务层感知不到。
Q8:进程崩溃后,Checkpoint 还能恢复吗?
取决于 Checkpointer 类型:
MemorySaver:进程重启后丢失,不能恢复RedisSaver:能恢复(Redis 持久化)PostgresSaver:能恢复(Postgres 持久化)
十一、线上问题案例
案例一:Redis 内存打满,Checkpoint 全部丢失
现象:Redis 实例内存被打满,所有 checkpoint 数据被 Redis 主动 eviction。用户 session 全部失效。
根因:Redis 没有设置 maxmemory + 淘汰策略,所有数据存在同一 DB。
解决:
- Redis 分开部署:checkpoint 用专用实例,设置 maxmemory + allkeys-lru
- 降级策略:Redis 不可用时,用 Postgres 做 fallback
- 监控:内存使用 > 70% 预警
案例二:thread_id 不固定导致恢复失败
现象:用户反馈"我的任务跑到一半,刷新页面后要从头跑"。
根因:前端每次请求生成新的 session_id,导致 thread_id 变化。
解决:thread_id 存储在前端 localStorage,整个会话用同一个 thread_id。
案例三:DeltaChannel 导致的历史消息消失
现象:用户反映"很早之前的消息在恢复后找不到了"。
根因:DeltaChannel 的快照策略问题------如果某个 channel 很久没更新,中间的增量 writes 可能因为 prune 操作丢失,导致恢复时无法重建完整历史。
解决:
- 不要对使用 DeltaChannel 的 thread 做 aggressive prune
- 或者把 snapshot_frequency 调低(更频繁写快照)
十二、问题速查表
| 分类 | 问题 | 解法 |
|---|---|---|
| 中断恢复 | OOM 后状态丢失 | Redis/PG checkpointer,固定 thread_id |
| 中断审批 | 需要人工确认工具调用 | interrupt_on + HumanInTheLoopMiddleware |
| 并发安全 | 多请求同一 thread_id | 乐观锁,框架自动重试 |
| 存储膨胀 | checkpoint 太大 | DeltaChannel + 大对象存 Store |
| Redis 故障 | 全站不可用 | Redis + Postgres 双写 fallback |
| thread_id | 每次请求都是新的 | 用 user_id + session_id 固定 |
| 历史丢失 | prune 导致 DeltaChannel 重建失败 | 不要 aggressive prune |
十三、总结
deepagents 的中断机制依赖两层:1) Checkpointer 负责状态持久化 2) HumanInTheLoopMiddleware 触发 interrupt()。两者缺一不可。
Checkpoint 存储结构:channel_values 存各节点输出,DeltaChannel 负责 messages 的增量更新,墓碑机制处理消息删除。
核心铁律:thread_id 必须固定,否则中断恢复不到正确状态。