EphemeralValue & UntrackedValue:故意"不完整持久化"的通道
前面的通道都努力把状态完整存进 checkpoint。今天看两个反其道而行的——EphemeralValue(只活当前这一步、下一步自动清空)和 UntrackedValue(内存里明明有值,但 checkpoint() 永远返回 MISSING、根本不进存档)。它们把 Day 27 埋下的"内存态可以 ≠ 存档态"这条伏笔玩到极致。搞懂它们,你就理解了:为什么有些中间量不该被时间旅行"复活"、为什么有些巨大对象不该塞进数据库。
· EphemeralValue = 一张桌面便签:只在这一轮会议上有用,散会(超步结束)就撕掉。它会被拍照存档,但下一轮开始时是空白的。
· UntrackedValue = 一句口头传话:这一步告诉下一步"喂,用这个连接对象",但拍照存档时它压根不入镜——因为它是个数据库连接、文件句柄这种"存下来也没意义、甚至没法存"的东西。
两种"不完整持久化"分别解决什么
# ephemeral_value.py:15
class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the value received in the step immediately preceding, clears after."""
# 存"紧邻的上一步"收到的值,之后清空
# untracked_value.py:15
class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the last value received, never checkpointed."""
# 存最后收到的值,但永不进 checkpoint
Ephemeral:clears after关键词"clears after"——用完即清。它主要服务于"这一步产生、下一步消费、消费完就该消失"的中间信号(比如某个只在相邻两步间传递的临时标记)。Untracked:never checkpointed关键词"never checkpointed"——永不入档。内存里正常有值、正常读写,但存 checkpoint 时假装没有。给"不可/不宜序列化的对象"(DB 连接、大模型客户端、临时缓存)准备的。三泛型全相同 [Value,Value,Value]和 LastValue 一样,写、读、存同类型。它俩本质都是"存最后一个值"的 LastValue 变体,只是在生命周期和是否入档上做了手脚。checkpoint() 方法——这不是摆设。正因为每个通道能自主决定"存什么/存不存",框架才能同时容纳"完整存"(LastValue)、"存哨兵靠回放"(DeltaChannel,Day 29)、"存了但下步清"(Ephemeral)、"根本不存"(Untracked)四种策略。今天就是把后两种看透。EphemeralValue:会存档,但只活一步
先建立直觉:Ephemeral 会被写进 checkpoint(它的 checkpoint() 老实返回值),但它靠"空更新即清空"实现"只活一步"。看构造和存档(channels/ephemeral_value.py:23-26,78-79):
# ephemeral_value.py:23
def __init__(self, typ: Any, guard: bool = True) -> None:
super().__init__(typ)
self.guard = guard # 是否禁止"一步多写",默认 True
self.value = MISSING # 起点:没有值
# ephemeral_value.py:78
def checkpoint(self) -> Value:
return self.value # 和 LastValue 一样,有啥存啥(可能是 MISSING)
self.value = MISSING初值是 MISSING 哨兵——表示"当前没有值",读它会抛 EmptyChannelError。这点和 LastValue 完全一致。guard: bool = True多一个参数 guard:控制"一个超步内能不能收到多个值"。默认 True = 只能一个(L04 详解)。checkpoint 返回 self.value划重点:Ephemeral 确实进存档!它把当前值交出去存。它的"短命"不是靠"不存"实现的,而是靠下一步开头的空更新把自己清掉(L03)。update([])。EphemeralValue 收到空更新时,会主动把自己清成 MISSING(下一讲的代码)。所以它的值最多存活到"写入后的下一个超步开始"——存档里能看到它,但流程往前走一步它就没了。这正是"clears after"。核心:空更新即清空
Ephemeral 的灵魂在 update 的前几行,它对"空更新"的处理和别人不一样(channels/ephemeral_value.py:55-68):
# ephemeral_value.py:55
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0: # ← 没人给我写(空更新)
if self.value is not MISSING:
self.value = MISSING # 把上一步存的值清掉!
return True # "从有到无"是一次变化
else:
return False # 本来就没有 → 无变化
if len(values) != 1 and self.guard: # guard 检查(下一讲)
raise InvalidUpdateError(...)
self.value = values[-1] # 有值:取最后一个
return True
len(values) == 0 → 清空核心差异!LastValue 收到空更新是"啥也不做、保持原值",Ephemeral 却把原值清成 MISSING。这一行就是"只活一步"的全部秘密——下一步没人续写,它就自我了断。return True(从有到无)清空后返回 True,告诉引擎"这个通道变了"(从有值变成无值),好让订阅它的下游正确感知。self.value = values[-1]有值时:取最后一个。多个值时哪个是"最后"取决于遍历顺序(这也是 guard 要管的事)。guard:一个超步能收几个值?
# ephemeral_value.py:62
if len(values) != 1 and self.guard:
raise InvalidUpdateError(
f"At key '{self.key}': EphemeralValue(guard=True) can receive "
f"only one value per step. Use guard=False if you want to store "
f"any one of multiple values."
)
self.value = values[-1] # guard=False 时:多写不报错,取最后一个
guard=True(默认)一步收到不是恰好 1 个值就报错 InvalidUpdateError。这是保护:并行多写同一个 Ephemeral,语义歧义(该信谁?),默认宁可报错让你显式处理。和 Day 28 LastValue 的 INVALID_CONCURRENT 是同一个哲学。guard=False关掉保护:多写不报错,取 values[-1](遍历到的最后一个)。报错信息里明说"想存多个中的任意一个就用 guard=False"——即"我不在乎是哪个,随便留一个"。__eq__ 也比较 guard两个 Ephemeral 相等要求 guard 也相同(ephemeral_value.py:28-29)——guard 是通道身份的一部分,不是无关紧要的开关。guard=True 的 Ephemeral/Untracked,多写是直接 InvalidUpdateError 崩掉整个超步。如果你的图里有两个并行节点可能同时写同一个这类通道,要么改用能容纳多写的通道(binop/Topic),要么显式设 guard=False 接受"随机留一个"。默认的严格是好事——它逼你在设计阶段就想清楚"并发写这里到底该怎么办",而不是上线后 debug 飘忽的结果。UntrackedValue:checkpoint 返回 MISSING
Untracked 和 Ephemeral 的 update/get 几乎一样,唯一的灵魂差异在 checkpoint()(channels/untracked_value.py:48-49):
# untracked_value.py:48
def checkpoint(self) -> Value | Any:
return MISSING # ← 无论内存里有啥,存档时永远交白卷!
# 对比 EphemeralValue(会交出真实值):
# ephemeral_value.py:78
def checkpoint(self) -> Value:
return self.value
# UntrackedValue 的 update 照常存值(untracked_value.py:56)
def update(self, values):
if len(values) == 0:
return False # 注意:空更新啥也不做,不清空(和 Ephemeral 不同)
if len(values) != 1 and self.guard:
raise InvalidUpdateError(...)
self.value = values[-1] # 内存里正常存
return True
checkpoint 恒返回 MISSING灵魂一句:存档时假装自己没有值。内存里 self.value 明明有东西(比如一个数据库连接对象),但 checkpoint 交白卷。于是这个通道在 checkpoint blob 里完全不占地方。update 空更新 return False细节对比:Untracked 收到空更新是 return False(保持原值),不像 Ephemeral 会清空。因为 Untracked 语义是"存最后一个值"(LastValue 式),不是"只活一步"。它的"临时"体现在不入档,而非"下步清空"。update 正常存值可用性正常:本次运行内,写进去就能读出来。它只是跨 checkpoint 边界会消失——重启/恢复后就没了。psycopg 数据库连接、一个初始化好的大模型客户端放进 State。这些东西要么根本没法 pickle/msgpack(Day 39),要么序列化了也毫无意义(连接重启后失效)。UntrackedValue 让它们"在一次运行内的节点间传递"成为可能,同时绝不污染 checkpoint——恢复时你重新构造一个新连接即可。这是"内存态 ≠ 存档态"最实用的一个落点。from_checkpoint:Untracked 恢复时空手而归
既然存档里没有 Untracked 的值,从存档恢复时它自然拿不回任何东西(channels/untracked_value.py:51-54):
# untracked_value.py:51
def from_checkpoint(self, checkpoint: Value) -> Self:
empty = self.__class__(self.typ, self.guard)
empty.key = self.key
return empty # 直接返回空通道,连 checkpoint 参数都不看!
# 对比 EphemeralValue(会灌回存档的值):
# ephemeral_value.py:48
def from_checkpoint(self, checkpoint: Value) -> Self:
empty = self.__class__(self.typ, self.guard)
empty.key = self.key
if checkpoint is not MISSING:
empty.value = checkpoint # 存了就恢复
return empty
Untracked 忽略 checkpoint 参数它连传进来的 checkpoint 都不读,直接造个空通道返回。因为它存的时候就交白卷,恢复时当然无从恢复——逻辑自洽。Ephemeral 会 if checkpoint is not MISSING 恢复对照:Ephemeral 存了值,所以恢复时会把值灌回去(前提是存档里确实有,不是 MISSING)。这保证了 L03 说的"崩溃可恢复"。两者都新建实例共同点:都是"造新的空壳 + 按需灌值"。这是 from_checkpoint 的统一契约——不复用旧实例,避免状态串味。三通道对比 + 小结
把 LastValue(Day 28)、EphemeralValue、UntrackedValue 三个"存最后一个值"的兄弟并排看,差异一目了然:
| 维度 | LastValue | EphemeralValue | UntrackedValue |
|---|---|---|---|
| 存最后一个值 | ✅ | ✅ | ✅ |
| 进 checkpoint? | ✅ 存 | ✅ 存 | ❌ 返回 MISSING |
| 空更新 update([]) | 保持原值 | 清空(只活一步) | 保持原值 |
| 恢复后有值? | ✅ 有 | ✅ 有(若存了) | ❌ 空,靠重建 |
| guard 参数 | 无(直接报 INVALID_CONCURRENT) | ✅ 有 | ✅ 有 |
| 典型用途 | 普通状态字段 | 相邻步间的临时信号 | 连接/客户端等不可序列化对象 |
👶 小白:我平时写图会直接用到这两个吗?
👨🏫 老师:直接实例化的机会不多——它们更多是框架内部或高级场景在用。但理解它们价值巨大:当你疑惑"为什么某个中间量时间旅行回去后消失了"(可能是 Ephemeral),或"为什么把连接对象放 State 会序列化报错、该怎么绕"(答案就是 Untracked),你就能秒懂。它们是"持久化不是非黑即白"这句话的活教材。
🧠 今日小结自测
- EphemeralValue 靠什么实现"只活一步"?(下一步的空更新 update([]) 把 value 清成 MISSING)
- Ephemeral 会进 checkpoint 吗?为什么?(会,为保证"写入后立即崩溃"也能恢复那一步的值)
- UntrackedValue 的 checkpoint() 返回什么?(永远 MISSING,内存有值但不入档)
- Untracked 恢复后是空的,为何不算 bug?(约定它每次运行都由节点重建,如连接对象)
- guard 参数管什么?(一个超步能否收多个值;默认 True 多写报 InvalidUpdateError,False 则取最后一个)
- Ephemeral 和 Untracked 面对空更新的区别?(Ephemeral 清空,Untracked 保持原值)
✋ 10 分钟动手
# 1. 两个文件都很短,通读
sed -n '1,80p' libs/langgraph/langgraph/channels/ephemeral_value.py
sed -n '1,74p' libs/langgraph/langgraph/channels/untracked_value.py
# 2. 对照两处 checkpoint() —— 一个返回值、一个返回 MISSING
grep -n "def checkpoint" libs/langgraph/langgraph/channels/*.py
# 3. 亲手验证 Ephemeral 空更新清空
python - <<'PY'
from langgraph.channels.ephemeral_value import EphemeralValue
e = EphemeralValue(str)
e.update(["go"]); print("写入后:", e.get()) # go
print("空更新变化?", e.update([])) # True(清空了)
try: e.get()
except Exception as ex: print("再读:", type(ex).__name__) # EmptyChannelError
PY
AnyValue(假设多个写入都相等,随便留一个,专治"多路都会写同一个值")和 NamedBarrierValue——一个真正的同步屏障:必须集齐一组指定的名字才算就绪、下游才被放行。我们会看 seen 集合怎么攒名字、consume() 如何过闸后重置,以及带 finish() 的进阶变体。