[拆解LangChain执行引擎]回到过去,开启平行世界[上篇]

Pregel还提供了如下所示的update_state/aupdate_statebulk_update_state/abulk_update_state方法,我们可以调用它们以增量的方式修改部分状态。有一点需要明确的是,这些方法并非直接修改某个持久化的Checkpoint,而是在此基础上创建一个子Checkpoint,所以历史是不会被篡改的,只会在某个时间点开启了一个 平行世界 。创建的Checkpoint的标识会存储返回的RunnableConfig配置中。

这些方法总是以一个Node的名义模拟一个具体的任务来更新对应的状态值。虽然这个模拟的任务不会真正被执行,但是它必须关联一个有效的Node,因为需要借助于对应Node的writers 列表完成写入操作。针对单一任务的更新请求通过一个StateUpdate对象来表示,它的as_nodetask_id字段表示状态更新的模拟任务的Node名称和任务标识。

python 复制代码
class Pregel(
    PregelProtocol[StateT, ContextT, InputT, OutputT],
    Generic[StateT, ContextT, InputT, OutputT]): 
    
    def bulk_update_state(
        self,
        config: RunnableConfig,
        supersteps: Sequence[Sequence[StateUpdate]],
    ) -> RunnableConfig
    async def abulk_update_state(
        self,
        config: RunnableConfig,
        supersteps: Sequence[Sequence[StateUpdate]],
     ) -> RunnableConfig 

    def update_state(
        self,
        config: RunnableConfig,
        values: dict[str, Any] | Any | None,
        as_node: str | None = None,
        task_id: str | None = None,
    ) -> RunnableConfig
    async def aupdate_state(
        self,
        config: RunnableConfig,
        values: dict[str, Any] | Any,
        as_node: str | None = None,
        task_id: str | None = None,
    ) -> RunnableConfig

class StateUpdate(NamedTuple):
    values: dict[str, Any] | None
    as_node: str | None = None
    task_id: str | None = None

1. 单一Node更新

update_state/aupdate_state方法用于单一Node的状态更新。如果没有利用as_node参数显式指定Node,这两个方法会默认使用最后一次实施更新的Node。那么如何确定最后一次状态更新来源于哪个Node呢?

还记得Checkpoint的channel_versionsversions_seen字段吗?前者返回所有Channel的最新版本,后者返回每个Node可见的Channel和版本。能够看到具有最高版本的那个Channel的所有Node都会被认为是实施了最新的状态更新。如果通过这种策略解析出来的Node不止一个,就会抛出异常并提醒我们提供确定的as_node,如下的演示程序体现了这一点。

python 复制代码
from langgraph.channels import LastValue
from langgraph.pregel import Pregel, NodeBuilder
from langgraph.checkpoint.memory import InMemorySaver
from functools import partial

def handle(node:str, args:dict)->str:
    return node

foo = (NodeBuilder()
        .subscribe_to("start",read=False)
        .do(partial(handle, "foo"))
        .write_to("bar"))
bar1 = (NodeBuilder()
        .subscribe_to("bar",read=False)
        .do(partial(handle, "bar1"))
        .write_to("bar1"))
bar2 = (NodeBuilder()
        .subscribe_to("bar",read=False)
        .do(partial(handle, "bar2"))
        .write_to("bar2"))
app = Pregel(
    nodes={"foo": foo, "bar1": bar1, "bar2": bar2},
    channels={
        "start": LastValue(None),
        "bar": LastValue(str),
        "bar1": LastValue(str),
        "bar2": LastValue(str),
    },
    input_channels=["start"],
    output_channels=["bar1","bar2"],
    checkpointer=InMemorySaver(),
)
config={"configurable": {"thread_id": "tx123"}}
result = app.invoke(input={"start": None}, config=config)
assert result== {"bar1": "bar1", "bar2": "bar2"}

new_config = app.update_state(
    config=config, 
    values= {"bar1": "bar1[new]"},
)

state = app.get_state(config=new_config)
print(state.values)

上面这个Pregel体现的执行流程很简单,启动的时候通过写入通道start驱动节点foo的执行,后者结束之后写入通道bar驱动节点bar1和bar2的执行,这两个Node最终会将自身的Node名称写入对应的通道bar1和bar2。在完成常规调用之后,我们调用update_state方法试图将最终通道bar1的值改写成bar1[new],但是最终会抛出InvalidUpdateError异常,并提示Ambiguous update, specify as_node

由于没有在作为输入参数的RunnableConfig配置中指定Checkpoint ID,所以针对状态的修改是基于最后创建的Checkpoint进行的。按照我们上面说的逻辑,引擎会根据每个Node可见Channel的最高版本得到最近一次更新状态的Node。很明显,节点bar1和bar2订阅的通道bar具有更高的版本。由于不满足Node的唯一性,所以InvalidUpdateError异常被抛出。由于通道bar1是由节点bar1写入的,所以我们将as_node参数设置为 "bar1" (实际上你设置任意的Node名称都可以,只要对应Node存在即可)。

python 复制代码
new_config = app.update_state(
    config=config, 
    values= {"bar1": "bar1[new]"},
    as_node="bar1"
)
state = app.get_state(config=new_config)
print(state.values)

你以为这就结束了吗?虽然这次状态更新成功了,但是最新的状态却非我所愿。从如下的输出结果可以看出,"bar1"的值是"{'bar1': 'bar1[new]'}",也就是说它是将作为参数values的字典整个作为了写入的值。

json 复制代码
{'start': None, 'bar': 'foo', 'bar1': {'bar1': 'bar1[new]'}, 'bar2': 'bar2'}

要解释这个问题,就必须真正了解状态究竟是如何被更新的。我们之所以需要确定以哪个Node的名义更新状态,并不仅仅是为了审核的目的补充必要的信息,实际上整个更新操作依赖于对应PregelNode的writers列表。我们再回顾一下PregelNode如下所示的writers字段,它返回的Channel写入器体现为一组Runnable对象。

python 复制代码
class PregelNode:
    writers: list[Runnable]

    @cached_property
    def flat_writers(self) -> list[Runnable]

class ChannelWrite(RunnableCallable):
    writes: list[ChannelWriteEntry | ChannelWriteTupleEntry | Send]
    def __init__(
        self,
        writes: Sequence[ChannelWriteEntry | ChannelWriteTupleEntry | Send],
        *,
        tags: Sequence[str] | None = None,
    )

class ChannelWriteEntry(NamedTuple):
    channel: str
    value: Any = PASSTHROUGH
    skip_none: bool = False
    mapper: Callable | None = None

class ChannelWriteTupleEntry(NamedTuple):
    mapper: Callable[[Any], Sequence[tuple[str, Any]] | None]
    value: Any = PASSTHROUGH
static: Sequence[tuple[str, Any, str | None]] | None = None

PASSTHROUGH = object()

每个Channel写入器对应一个ChannelWrite对象,后者针对针对Channel的写入意图会被添加到writes字段对用的列表中,这是由一组ChannelWriteEntryChannelWriteTupleEntry或者Send对象的列表。当update_state/aupdate_state方法将Node确定下来后,这个列表被提取出来,对于ChannelWriteEntryChannelWriteTupleEntry,其values字段被替换成传入update_state/aupdate_state方法的values参数,仅此而已。这就是我们提供的字典作为整体被写入通道bar1的原因,所以调用update_state/aupdate_state方法的时候按照如下的方式提供具体的值就好。

python 复制代码
new_config = app.update_state(
    config=config, 
    values= "bar1[new]",
    as_node="bar1"
)

我们在前面说过,update_state/aupdate_state方法是通过在最新或者指定Checkpoint基础上创建一个新的Checkpoint,进而达到更新状态的目的,我们可以输出完整的历史来证明这一点。

python 复制代码
for state in app.get_state_history(config):
    metadata = state.metadata
    step=metadata['step']
    source=metadata["source"]
    print(f"step:{step}\nsource:{source}\nvalues: {state.values}\n")

在完成状态更新后,我们使用上面的代码提取组成历史的每个快照,并将对应的Superstep、Source和Values输出来。在如下所示的输出中,Superstep 2对应的快照就是调用update_state方法产生的,这个快照还具有不同的Source(update)揭示它的与众不同。

sql 复制代码
step:2
source:update
values: {'start': None, 'bar': 'foo', 'bar1': 'bar1[new]', 'bar2': 'bar2'}

step:1
source:loop
values: {'start': None, 'bar': 'foo', 'bar1': 'bar1', 'bar2': 'bar2'}

step:0
source:loop
values: {'start': None, 'bar': 'foo'}

step:-1
source:input
values: {'start': None}

2. 更新失效

如果我们对上述的更新原理不了解,在遇到一些状态更新失效的场景,可能永远找不到问题的症结。比如对于如下这个简单的例子,Pregel唯一的Node会将值foo写入Channel,调用之后针对结果的断言也证实了写入时成功的。

python 复制代码
from langgraph.channels import LastValue
from langgraph.pregel import Pregel, NodeBuilder
from langgraph.checkpoint.memory import InMemorySaver

node = (NodeBuilder()
        .subscribe_only("foo")
        .do(lambda args: args)
        .write_to(output=lambda _: "foo"))

app = Pregel(
    nodes={"node": node},
    channels={
        "foo": LastValue(str),
        "output": LastValue(str),
    },
    input_channels=["foo"],
    output_channels=["output"],
    checkpointer=InMemorySaver(),
)

config={"configurable": {"thread_id": "tx123"}}
result = app.invoke(input={"foo": "foo"}, config=config)
assert state.values["output"] == "foo" # state remains unchanged

我们本希望调用update_state方法将输出改写为bar。但是从调用get_state的结果来看状态并没有更新成功。那么是因为新的Checkpoint没有创建吗?为此我们按照如下的方式输出整个历史。

python 复制代码
for state in app.get_state_history(config):
    metadata = state.metadata
    step=metadata['step']
    source=metadata["source"]
    print(f"step {step}\nsource: {source}\nvalues: {state.values}")
    print()

从如下的输出结果可以看出,update_state方法调用对应的Checkpoint已经成功创建,但是它的状态(values)就是没有改变。

sql 复制代码
step 1
source: update
values: {'start': None, 'output': 'foo'}

step 0
source: loop
values: {'start': None, 'output': 'foo'}

step -1
source: input
values: {'start': None}

这个的问题出在Node的构建上面,由于我们调用调用NodeBuilder的write_to方法时采用了关键字参数来确定目标Channel,并以Lambda表达式的方式提供写入的值,并且Lambda表达式并没有使用原始输入,而是直接硬编码成foo 。这行代码将生成一个ChannelWriteEntry对象,并为它指定正确得Channel名称( "output" ),它的value不会被设置为处理函数的返回值,但是Lambda表达式转换成的Callable对象将作为mapper字段。由于update_state方法仅仅通过对Node的写入器稍加改造来完成Channel写入。对于这个背后创建的ChannelWriteEntry对象来说,改变的只有其value字段,mapper字段将保持不变。由于mapper无脑返回"foo",所以状态永远也不可能被改变。

3. 对Pending Write的影响

前面介绍的更新都是在一个不存在Pending Write的状态上完成的,如果Superstep尚未完结,并且同时具有完成和中断的任务,update_state方法调用后整个状态又是什么样子呢?经过我的测试,不论我们以"完成任务对应的Node的名义",还是以"中断任务对应的Node的名义",针对update_state方法的调用都会以创建新的Checkpoint的方式强行闭合当前Superstep。

以如下这段程序为例,Pregel的两个并行执行的初始节点foo和bar,前者成功执行,后者会遇到中断。我们调用update_state方法以节点"bar" 的名义试图更新bar的状态。在调用前后,我们输出整个历史。

python 复制代码
from langgraph.channels import LastValue
from langgraph.pregel import Pregel, NodeBuilder
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt

def handle( args:dict)->str:
    resume = interrupt("Resuming execution")
    return resume

foo = (NodeBuilder()
        .subscribe_to("start",read=False)       
        .do(lambda args:args)
        .write_to("foo"))

bar = (NodeBuilder()
        .subscribe_to("start",read=False)
        .do(handle)
        .write_to("bar"))

app = Pregel(
    nodes={"foo": foo, "bar": bar},
    channels={
        "start": LastValue(None),
        "foo": LastValue(str),
        "bar": LastValue(str),
    },
    input_channels=["start"],
    output_channels=["foo","bar"],
    checkpointer=InMemorySaver(),
)

config={"configurable": {"thread_id": "tx123"}}
def show_history(config):
     for _,checkpoint, metadata,_,pending_writes in app.checkpointer.list(config):
        step=metadata['step']
        source=metadata["source"]
        print(
f"step:{step}\nsource:{source}\nvalues: {checkpoint['channel_values']}\npending_writes: {pending_writes}\n")

app.invoke(input={"start": None}, config=config)
print("After invoke:")
show_history(config)

new_config = app.update_state(
    config=config, 
    values= "updated value",
    as_node="bar"
)

print("\nAfter update_state:")
show_history(config)

从如下的输出结果可以看出,正常调用之后确实遇到了中断。update_state方法调用之后,通道bar的状态确实被更新。但是新的Checkpoint被创建后,之前的Pending Write将不复存在。

python 复制代码
After invoke:
step:-1
source:input
values: {'start': None}
pending_writes: [('a2357188-2d04-182f-8672-0328182f68a0', '__interrupt__', [Interrupt(value='Resuming execution', id='fcb47fce081f41d1e1141d004a9b66f9')]), ('10b0e7ea-3453-e5ad-18a7-eb47f3caf108', 'foo', {})]

After update_state:
step:0
source:update
values: {'start': None, 'bar': 'updated value'}
pending_writes: []

step:-1
source:input
values: {'start': None}
pending_writes: [('a2357188-2d04-182f-8672-0328182f68a0', '__interrupt__', [Interrupt(value='Resuming execution', id='fcb47fce081f41d1e1141d004a9b66f9')]), ('10b0e7ea-3453-e5ad-18a7-eb47f3caf108', 'foo', {}), ('a2357188-2d04-182f-8672-0328182f68a0', 'bar', 'updated value')]

本例中我们是以中断节点的名义对状态实施修改的,所以Pending Write被新的状态抹除还说得过去的。但是如果我们按照如下的方式以成功执行的节点foo的名义修改通道foo的状态呢?

python 复制代码
new_config = app.update_state(
    config=config, 
    values= "updated value",
    as_node="foo"
)

从如下所示的输出结果可以看出,虽然我们对中断节点bar没有实施任何操作,update_state方法依然会将其Pending Write抹除。我们从这个例子大体可以看出update_state放背后的逻辑:针对最终状态的更新总是在最新的Checkpoint描述的状态下进行,并且创建一个新的Checkpoint作为最终的状态。

python 复制代码
After invoke:
step:-1
source:input
values: {'start': None}
pending_writes: [('87a80b8a-6c7b-a026-68b5-dd39d405a6a4', 'foo', {}), ('8a53715a-dc21-56ce-d0a6-d13ff6604d15', '__interrupt__', [Interrupt(value='Resuming execution', id='8672a7e5d7233e1f8c11fc9ff4b00c56')])]

After update_state:
step:0
source:update
values: {'start': None, 'foo': 'updated value'}
pending_writes: []

step:-1
source:input
values: {'start': None}
pending_writes: [('87a80b8a-6c7b-a026-68b5-dd39d405a6a4', 'foo', {}), ('8a53715a-dc21-56ce-d0a6-d13ff6604d15', '__interrupt__', [Interrupt(value='Resuming execution', id='8672a7e5d7233e1f8c11fc9ff4b00c56')])]
相关推荐
@atweiwei1 小时前
Rust 实现 LangChain
开发语言·算法·rust·langchain·llm·agent·rag
ZWZhangYu1 小时前
【LangChain专栏】核心组件Model I/O 模块
microsoft·langchain
ZaneAI1 小时前
🚀 Claude Agent SDK 使用指南:深度掌握 Hooks 机制
langchain·agent·claude
ZaneAI1 小时前
🚀 Claude Agent SDK 使用指南:如何优雅地处理用户审批与提问 (User Input)
langchain·agent·claude
datalover1 小时前
spring security自定义表结构处理
数据库·python·spring
励ℳ1 小时前
【生信绘图】基因组大小与CDS数量关系的可视化
python·信息可视化
喵手2 小时前
Python爬虫实战:电商问答/FAQ 语料构建 - 去重、分句、清洗,做检索语料等!
爬虫·python·爬虫实战·faq·零基础python爬虫教学·电商问答·语料构建
Dxy12393102162 小时前
DataFrame条件筛选:从入门到实战的数据清洗利器
python·dataframe