Day 31 / 共 60 天 · 阶段 5 Channels 通道

EphemeralValue & UntrackedValue:故意"不完整持久化"的通道

前面的通道都努力把状态完整存进 checkpoint。今天看两个反其道而行的——EphemeralValue(只活当前这一步、下一步自动清空)和 UntrackedValue(内存里明明有值,但 checkpoint() 永远返回 MISSING、根本不进存档)。它们把 Day 27 埋下的"内存态可以 ≠ 存档态"这条伏笔玩到极致。搞懂它们,你就理解了:为什么有些中间量不该被时间旅行"复活"、为什么有些巨大对象不该塞进数据库。

📍 你在 60 天里的位置(阶段 5:Channels 通道 D27-32)
D27 通道抽象 D28 LastValue D29 累加/Delta D30 Topic D31 不持久化 D32 同步屏障
💡 类比先兜住(延续"公司信箱"世界观) LastValue 是"贴在墙上的便利贴,一直在"。今天两位是"临时便签"
· EphemeralValue = 一张桌面便签:只在这一轮会议上有用,散会(超步结束)就撕掉。它被拍照存档,但下一轮开始时是空白的。
· UntrackedValue = 一句口头传话:这一步告诉下一步"喂,用这个连接对象",但拍照存档时它压根不入镜——因为它是个数据库连接、文件句柄这种"存下来也没意义、甚至没法存"的东西。
L01

两种"不完整持久化"分别解决什么

🤔 痛点:不是所有状态都该被永久保存。有些值"只在这一步有用"、有些值"根本不该进数据库",怎么办? 这就是今天两个通道的分工。先看它们的类头 docstring 一句话点题:
# 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 变体,只是在生命周期是否入档上做了手脚。
💡 本质:持久化不是"全有或全无",而是"每个通道自己说了算"Day 27 讲通道有独立的 checkpoint() 方法——这不是摆设。正因为每个通道能自主决定"存什么/存不存",框架才能同时容纳"完整存"(LastValue)、"存哨兵靠回放"(DeltaChannel,Day 29)、"存了但下步清"(Ephemeral)、"根本不存"(Untracked)四种策略。今天就是把后两种看透。
L02

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"。
L03

核心:空更新即清空

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 要管的事)。
控制流:EphemeralValue 只活一步 步 N: 节点写入 "go" value="go", 存档记下 步 N 末尾: 下游读到 "go" 值可用一次 步 N+1: 没人写 → update([]) value=MISSING(自动清空) 对比 LastValue:步 N+1 收到 update([]) 时 LastValue 保持 "go" 不变 差别仅在 update 里"空更新是否清空"这一个判断
Ephemeral 的"短命"完全由"空更新 → 清空"这一个分支实现
🅰 设计取舍①:为什么要"存档 + 下步清空",而不是干脆像 Untracked 那样不存? 因为 Ephemeral 的值在"它被写入的那个 checkpoint"里是有意义的、必须能被恢复的。设想崩溃恰好发生在"步 N 写了值、下游还没读"之间——恢复时必须能读回那个值,下游才能继续。如果像 Untracked 那样不存,崩溃恢复后值就丢了,下游拿不到、流程断裂。所以 Ephemeral 选择"存这一步的值以保证崩溃可恢复,靠下一步的空更新保证不会赖着不走"。这是"短命"与"可恢复"之间的精确平衡。
L04

guard:一个超步能收几个值?

🤔 并行的两个节点同一步都往这个通道写,算谁的? 看 guard 的处理(channels/ephemeral_value.py:62-67,UntrackedValue 一模一样在 untracked_value.py:59-64):
# 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 下并行写会直接抛异常,不是"默默取一个" 很多人以为"一步多写顶多结果不确定",但对 guard=True 的 Ephemeral/Untracked,多写是直接 InvalidUpdateError 崩掉整个超步。如果你的图里有两个并行节点可能同时写同一个这类通道,要么改用能容纳多写的通道(binop/Topic),要么显式设 guard=False 接受"随机留一个"。默认的严格是好事——它逼你在设计阶段就想清楚"并发写这里到底该怎么办",而不是上线后 debug 飘忽的结果。
L05

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——恢复时你重新构造一个新连接即可。这是"内存态 ≠ 存档态"最实用的一个落点。
L06

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 的统一契约——不复用旧实例,避免状态串味。
🅰 设计取舍②:Untracked 恢复后是空的,会不会让流程出错? 这是有意的、由使用约定兜底。UntrackedValue 的正确用法是"在一次运行内的相邻节点间传递、且每次运行都会被重新赋值"——比如入口节点建好连接、放进 Untracked 通道,后续节点使用。恢复运行时,入口节点会重新执行、重新建连接,Untracked 又被填满。所以"恢复后为空"不是 bug,而是"这类值本就该在每次运行时重建"的正确表达。反过来说:如果一个值恢复后为空会导致流程崩溃,那它就不该用 Untracked——这是使用者的责任边界。框架用"不存"的简单实现,把"重建"的责任清晰地交还给业务。
L07

三通道对比 + 小结

把 LastValue(Day 28)、EphemeralValue、UntrackedValue 三个"存最后一个值"的兄弟并排看,差异一目了然:

维度LastValueEphemeralValueUntrackedValue
存最后一个值
进 checkpoint?✅ 存✅ 存❌ 返回 MISSING
空更新 update([])保持原值清空(只活一步)保持原值
恢复后有值?✅ 有✅ 有(若存了)❌ 空,靠重建
guard 参数无(直接报 INVALID_CONCURRENT)✅ 有✅ 有
典型用途普通状态字段相邻步间的临时信号连接/客户端等不可序列化对象
数据结构:同一个值在三种通道里"进不进存档" LastValue内存: "go"存档: "go" ✓恢复后仍在 EphemeralValue内存: "go"存档: "go" ✓下一步空更新即清 UntrackedValue内存: 连接对象存档: MISSING ✗恢复后空,靠重建
三者都"存最后一个值",差别全在 checkpoint() 返不返、以及空更新清不清

👶 小白:我平时写图会直接用到这两个吗?

👨‍🏫 老师:直接实例化的机会不多——它们更多是框架内部或高级场景在用。但理解它们价值巨大:当你疑惑"为什么某个中间量时间旅行回去后消失了"(可能是 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
🔮 明日预告 · Day 32 AnyValue & NamedBarrierValue今天两个通道玩"是否/多久持久化"。明天两个玩"什么时候才算就绪":AnyValue(假设多个写入都相等,随便留一个,专治"多路都会写同一个值")和 NamedBarrierValue——一个真正的同步屏障:必须集齐一组指定的名字才算就绪、下游才被放行。我们会看 seen 集合怎么攒名字、consume() 如何过闸后重置,以及带 finish() 的进阶变体。
← Day 30 Topic Day 32 · 同步屏障 →