从零开始学LangGraph:详解State的定义、更新与管理

开源AI·十一月创作之星挑战赛 10w+人浏览 781人参与

从零开始学LangGraph:详解State的定义、更新与管理

关于State的定义

LangGraph中的State,可以用TypedDictPydantic model,或者dataclass来定义,其中前两种比较常用。

TypedDict:轻量级的选择

TypedDict主打一个轻量级简单直接。它本质上就是Python标准库typing模块中的一个类型提示工具,不需要安装额外的依赖,使用起来非常方便。

正如我在前期文章中给大家展示的,用TypedDict定义State的典型写法如下:

from typing import TypedDictclass AppState(TypedDict):    Key_1: str    Key_2: int

需要注意的是,由于typing本质上是个类型提示工具,它只在开发阶段(通过IDE或类型检查工具)发挥提示作用,而并不会在应用实际运行时对其中的参数、键值进行强制的类型验证。举个例子,虽然我们已经设定Key_1的数据类型为str,但如果我们向它传入一个int,程序不会报错(但可能会导致后续逻辑错误)。因此。如果你需要更严格的运行时验证,那就需要考虑使用Pydantic model了。

Pydantic Model:更强大的选择

Pydantic是一个强大的数据验证库,它可以在项目运行时对数据进行验证,确保数据的类型和格式符合预期。这对于构建健壮的AI Agent来说是非常重要的。

下面我们来看一个使用Pydantic model定义State的示例:

from pydantic import BaseModel, Fieldclass AppState(BaseModel):    Key_1: str    Key_2: int = Field(ge=0, le=100)  # 限制Key_2的值必须在0到100之间    Key_3: str = "默认值"  # 如果没传这个字段,就自动使用"默认值"

可以看到,与TypedDict相比,Pydantic model提供了几个非常实用的功能:

    1. 限制取值范围:通过Field可以给数值类型的字段设置范围限制。比如ge=0表示"大于等于0"(ge是greater or equal的缩写),le=100表示"小于等于100"(le是less or equal的缩写)。这样就能确保数据在合理的范围内,避免出现负数等级、超过上限的血量这种不合理的情况。
    1. 自动验证数据:当你传入的数据不符合要求时,Pydantic会在程序运行时立即报错。比如上面例子中,如果你传入的Key_2是负数或者大于100,程序就会抛出异常,告诉你数据有问题。
    1. 设置默认值:如果某个字段是可选的,你可以给它设置一个默认值。比如上面的Key_3,如果调用时没有传这个字段,它就会自动使用"默认值"。这样就不需要每次都手动检查字段是否存在了。

当然Pydantic还提供了其他一些更复杂的功能,比如:

  • 自定义验证器:可以写自己的验证函数,对字段值进行更复杂的检查,比如验证邮箱格式、密码强度等
  • 字段间的依赖关系:可以让一个字段的值依赖于另一个字段,比如总价 = 单价 × 数量
  • 数据转换:可以在验证时自动转换数据类型,比如把字符串"123"自动转换成整数123
  • 嵌套模型:可以在一个模型中包含另一个模型,实现复杂的数据结构

这些功能在构建复杂应用时非常有用。如果你想深入了解,可以查看Pydantic的官方文档:https://docs.pydantic.dev/

那么在实际开发中,我们应该如何选择呢?通常的建议是:

  • TypedDict:如果你的项目比较简单,不需要复杂的验证逻辑,或者你希望保持代码的轻量级,那么TypedDict是个不错的选择
  • Pydantic model:如果你需要严格的数据验证,或者你的State结构比较复杂,需要字段间的依赖关系,那么Pydantic model会更合适

关于State的更新

在LangGraph中,State的更新其实是一个两阶段的过程:

    1. 第一阶段:节点函数处理 - Node函数接收当前的state,根据业务逻辑进行处理,计算出需要更新的新值。
    1. 第二阶段:Reducer决定更新方式 - Reducer函数决定如何将节点返回的新值更新到state中,是覆盖(overwrite)还是拼接(append)等

理解这两个阶段的工作机制,对于构建功能完善的Agent至关重要。

第一阶段:节点函数处理

节点函数是State更新的第一步。它接收当前的state作为输入,根据业务逻辑进行处理,然后返回一个结果,包含需要更新的state字段和新值。

回顾一下我们在从零开始学LangGraph中的例子:

def dodge_check_node(state: AppState) -> AppState:    """检查闪避结果并处理HP变化"""    dice_roll = random.randint(1, 6)        if dice_roll > 3:        print("你成功闪避了伤害")        return {}  # 没有变化,返回空字典    else:        new_HP = state['char_HP'] - dice_roll        print(f"闪避失败!受到{dice_roll}点伤害")        return {"char_HP": new_HP}  # 返回需要更新的字段和新值

这里,节点函数计算出了新的HP值,并返回{"char_HP": new_HP}。但这时候,我们还无法判断这个新值将如何对state产生影响,因为这还得由定义state时设置的Reducer来决定。

第二阶段:Reducer决定更新方式

Reducer函数决定了节点返回的新值如何更新到state中,每一个键都可以有其独立的Reducer。这是State更新的关键环节,不同的Reducer会产生不同的更新效果。

1.Reducer的基本用法

在从零开始学LangGraph一文中,我向大家介绍了一种常用的Reducer,add_messages

from typing import Annotated, Listfrom langchain_core.messages import AnyMessagefrom langgraph.graph.message import add_messagesclass ChatState(TypedDict):    messages: Annotated[List[AnyMessage], add_messages]

这里的关键是Annotated的使用。Annotated是Python的类型注解工具,它允许我们为类型添加额外的元数据。在LangGraph中,我们用它来指定Reducer函数。

add_messages的作用是:

  • • 将新消息追加到现有消息列表的末尾
  • • 如果新消息的ID与旧消息相同,则更新旧消息而不是追加

除了add_messages,LangGraph中还有其他一些内置的Reducer,比如:

    1. operator.add:用于数值累加,将新值加到旧值上。比如统计总分、计数等场景:
from typing import Annotated, TypedDictfrom operator import addclass ScoreState(TypedDict):    total_score: Annotated[int, add]  # 每次返回的分数会累加到总分上
    1. operator.extend:用于列表扩展,将新列表的元素追加到当前列表中。比如收集多个节点的结果:
from typing import Annotated, List, TypedDictfrom operator import extendclass ResultState(TypedDict):    results: Annotated[List[str], extend]  # 新列表的元素会追加到现有列表中

除了这些内置Reducer,LangGraph也允许我们自定义Reducer来实现想要的效果。

2.自定义Reducer

当内置的Reducer无法满足需求时,我们可以自定义Reducer函数实现对state更新过程的控制。自定义Reducer的过程非常简单,只需要定义一个函数即可。

自定义Reducer的基本要求:

    1. 参数:Reducer必须接收两个参数,依次从左到右分别为:
  • old_value:state中该字段的当前值
  • new_value:节点函数返回的新值
    1. 返回值:返回合并后的结果,类型应该与字段类型一致

下面我给大家一个简单的例子。我们定义一个作用是取新旧值中的较大者的Reducer,并将它设定在 state中。

from typing import Annotated, TypedDict# 定义一个自定义Reducer函数def take_max(old_value: int, new_value: int) -> int:    """自定义Reducer:取新旧值中的较大者"""    return max(old_value, new_value)# 在State定义中使用自定义Reducerclass AppState(TypedDict):    max_score: Annotated[int, take_max]  # 使用自定义的take_max作为Reducer

然后,我们假设一个更新分数的节点:

def update_score_node(state: AppState) -> AppState:    """更新分数"""    return {"max_score": 85}  # 返回新的分数值

上述代码意味着,根据这个节点函数的计算操作(被简化),输出max_score的值为85,但由于我们设定了Reducer,所以这个85不会直接对state进行更新,而是需要与经过这个节点之前的state中保存的值(这才是当前的state!)进行比较:

如果当前max_score是80,节点返回85,Reducer会取max(80, 85) = 85
如果当前max_score是90,节点返回85,Reducer会取max(90, 85) = 90

是不是很酷炫?

3.Overwrite:强制覆盖机制

有时候,即使我们已经为某个字段设定了Reducer(比如add_messages),但在某些特殊场景下,我们可能希望忽略这个Reducer,直接用新值覆盖state中的旧值。这时候,我们就可以使用Overwrite机制。

Overwrite允许我们在节点返回时,强制忽略已设定的Reducer,直接覆盖state中的值。

LangGraph提供了两种使用Overwrite的方式:

    1. 使用Overwrite类型包装值
from langgraph.graph import Overwritedef reset_node(state: ChatState) -> ChatState:    """重置消息列表,忽略add_messages的Reducer"""    return {"messages": Overwrite([])}  # 直接覆盖为空列表,忽略add_messages的拼接逻辑
    1. 使用__overwrite__
def reset_node(state: ChatState) -> ChatState:    """重置消息列表,忽略add_messages的Reducer"""    return {"messages": {"__overwrite__": []}}  # 效果与上面相同

Overwrite在以下场景特别有用:

  • 重置状态:需要清空累积的数据(比如重置消息历史)
  • 强制替换:需要完全替换某个字段的值,而不考虑之前的累积结果
  • 特殊处理:某些节点需要绕过Reducer的常规逻辑

需要注意的是,如果不指定Reducer,LangGraph默认就会直接覆盖(相当于自动使用overwrite),所以Overwrite主要用于在已设定Reducer的情况下强制覆盖的场景。

关于State的管理

在实际应用中,我们有时候需要更精细地管理State的可见性和访问权限。LangGraph提供了几种机制来实现这个目标,包括input schema、output schemaprivate schema

input schema和output schema

在某些场景下,我们可能希望Graph对外暴露的接口与内部使用的State结构有所不同。比如,我们可能希望用户只需要传入简单的参数,而不需要了解Graph内部复杂的State结构。

这时候,我们就可以使用input schemaoutput schema来定义Graph的输入和输出格式。

1.基本用法

假设我们有一个内部State,结构比较复杂:

from typing import TypedDict, Listclass InternalState(TypedDict):    user_name: str    user_age: int    messages: List[str]    result: str    status: str    internal_counter: int    debug_info: dict

但我们希望用户调用Graph时,只需要传入用户名和年龄,而不需要关心内部的messagesinternal_counterdebug_info等字段。同时,我们也希望Graph返回给用户的只是处理结果,而不是整个内部State。

这时候,我们可以分别定义input和output schema:

from typing import TypedDictclass InputSchema(TypedDict):    """用户输入格式"""    user_name: str    user_age: intclass OutputSchema(TypedDict):    """用户输出格式"""    result: str    status: str

然后在创建Graph时指定这些schema:

graph = StateGraph(InternalState,input_schema=InputSchema,    output_schema=OutputSchema)
2.效果说明

使用input/output schema后,Graph的行为会发生以下变化:

(1)输入转换:

用户调用时,只需要传入InputSchema格式的数据

result = app.invoke({"user_name": "张三", "user_age": 25})

LangGraph会自动将InputSchema转换为InternalState,未输入的地方会自动初始化为空值。 转换后的内部state如下:

{"user_name": "张三","user_age": 25,"messages": [],  # 自动初始化为空列表"internal_counter": 0,  # 自动初始化为0"debug_info": {}  # 自动初始化为空字典}

(2)输出转换:

Graph执行完成后,内部state可能是:

{    "user_name": "张三",    "user_age": 25,    "messages": ["欢迎张三!"],    "result": "处理完成",    "status": "success"    "internal_counter": 1,    "debug_info": {"processed": True}}

这时,OutputSchema就像一个筛选器一样,只会向用户返回在其中定义的键值。在我们的示例中,实际返回给用户的sate可能是:

{    "result": "处理完成",    "status": "success"}

关键点:

  • • input schema定义了用户调用invoke()时需要传入的字段格式
  • • output schema定义了Graph返回给用户的字段格式
  • • 内部State的完整结构仍然存在,只是在输入输出时进行了转换
  • • 如果output schema中的字段在内部State中不存在,需要在节点中返回这些字段,或者使用转换函数

private schema

有时候,我们希望在Graph的执行过程中使用一些中间状态,这些状态只在节点之间传递,但不应该出现在Graph的最终输出中。比如:

    1. 中间计算结果:某些节点需要计算中间值,这些值会被后续节点使用,但不需要对外暴露;
    1. 临时数据存储:在Graph执行过程中需要临时存储一些数据,用于节点间的信息传递;
    1. 内部状态管理:需要跟踪Graph内部的执行状态,但这些状态对用户来说是不必要的。

LangGraph提供了private state机制来实现这个需求。

1.基本用法

private state的使用方法非常简单,就是是定义一个独立的PrivateState类型,然后在节点函数中使用它。关键点在于:

  • • 定义独立的PrivateState类型,包含需要在节点间传递但不对外暴露的字段
  • • 节点函数可以接收PrivateState作为输入,也可以返回PrivateState作为输出
  • PrivateState中的字段会在节点间正常传递,但不会出现在Graph的最终输出中

下面我们通过一个示例来理解private state的使用:

from typing import TypedDictfrom langgraph.graph import StateGraph, START, END# 定义Graph的整体状态:包含公有字段和私有字段classOverallState(TypedDict):    user_input: str      # 公有字段,会出现在最终输出中    graph_output: str    # 公有字段,会出现在最终输出中    foo: str            # 公有字段,会出现在最终输出中# 定义私有状态:只在节点间传递,不会出现在最终输出中classPrivateState(TypedDict):    bar: str            # 私有字段,只在节点间传递# 节点1:处理用户输入,写入OverallStatedefnode_1(state: OverallState) -> OverallState:    # 处理用户输入,生成中间结果    return {"foo": state["user_input"] + " name"}# 节点2:读取OverallState,写入PrivateStatedefnode_2(state: OverallState) -> PrivateState:    # 从OverallState读取foo,计算中间值并存入PrivateState    return {"bar": state["foo"] + " is"}# 节点3:读取PrivateState,写入OverallStatedefnode_3(state: PrivateState) -> OverallState:    # 从PrivateState读取bar,生成最终输出    return {"graph_output": state["bar"] + " Lance"}# 创建Graph,只使用OverallState作为主状态builder = StateGraph(OverallState)builder.add_node("node_1", node_1)builder.add_node("node_2", node_2)builder.add_node("node_3", node_3)builder.add_edge(START, "node_1")builder.add_edge("node_1", "node_2")builder.add_edge("node_2", "node_3")builder.add_edge("node_3", END)graph = builder.compile()# 调用Graphresult = graph.invoke({"user_input": "My"})print(result)

这段代码的运行结果将是:{'user_input': 'My', 'graph_output': 'My name is Lance', 'foo': 'My name'}

2.效果说明

(1)私有状态在节点间正常传递:

在上面的示例中,PrivateState中的bar字段在node_2中被设置,然后在node_3中被读取使用。这说明private state在Graph内部的节点之间是完全可用的,可以正常传递数据。

(2)私有状态不会出现在最终输出中:

虽然PrivateState中的bar字段在Graph执行过程中被使用(在node_2中写入,在node_3中读取),但最终的输出结果中只包含OverallState中定义的字段(user_inputgraph_outputfoo),bar字段被自动过滤掉了。

结语

本期我们从三个维度深入了解了LangGraph中的State机制:

State的定义:可以使用TypedDictPydantic model来定义State。TypedDict轻量级但只有类型提示,Pydantic model提供运行时验证、默认值设置等更强大的功能。

State的更新:State更新分为两个阶段——节点函数处理业务逻辑并返回新值,Reducer决定如何将新值合并到State中。LangGraph提供了add_messagesoperator.add等内置Reducer,也支持自定义Reducer。当需要强制覆盖时,可以使用Overwrite机制。

State的管理:通过input_schemaoutput_schema可以控制Graph的输入输出格式,简化对外接口。通过private state可以定义只在节点间传递但不对外暴露的中间状态,保持内部实现细节的封装性。

理解这些机制有助于我们更好地设计和管理Agent的状态流转,构建更清晰、更易维护的Graph结构。

好了,以上就是本期的主要内容,希望对大家有帮助,喜欢的朋友别忘了点赞、收藏、转发,祝大家玩的开心~

最近这几年,经济形式下行,IT行业面临经济周期波动与AI产业结构调整的双重压力,很多人都迫于无奈,要么被裁,要么被降薪苦不堪言。但我想说的是一个行业下行那必然会有上行行业,目前AI大模型的趋势就很不错,大家应该也经常听说大模型,也知道这是趋势,但苦于没有入门的契机,现在他来了,我在本平台找到了一个非常适合新手学习大模型的资源。大家想学习和了解大模型的,可以**点击这里前往查看**

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值