一起来学 Langgraph [第二节]

一、引言

这是一个系列博客,通过学习Langgraph官方文档自我总结而来、本篇是系列第二篇。

第一篇地址: 一起来学 Langgraph [第一节]

二、Langgraph的核心基础组件

本节以一个实际案例介绍下state、edge、node三个langGraph中的概念

首先我们构建下面的一个图

python 复制代码
#  这里是自己编写的一个工具函数 非常简单 分别是乘法、除法、加法
tools = [math_tools.multiply, math_tools.add, math_tools.divide]

llm = ChatTongyi(model="qwen-max", api_key=SecretStr(os.getenv("DASHSCOPE_API_KEY")))
# 为大模型绑定工具
llm_with_tools = llm.bind_tools(tools)

# System message
sys_msg = SystemMessage(content="您是一个乐于助人的助手,负责对一组输入执行算术运算.")


# Node
def assistant(state: MessagesState):
    return {"messages": [llm_with_tools.invoke([sys_msg] + state["messages"])]}


# Build graph
builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "assistant")

builder.add_conditional_edges(
    "assistant",
    tools_condition,
)
builder.add_edge("tools", "assistant")

# Compile graph
graph = builder.compile()

在上面的代码中我们使用了qwen-max作为我们的基准大模型,并给这个大模型绑定了一个简单的运算工具tools,这样大模型在回答运算相关的问题的时候就会使用工具。 同时我们在langGraph-studio中展示了这个图的基本结构,通过add_conditional_edges方法添加了一个条件边,tools_condition 是langGraph内置的一个条件,表示如果最新的对话消息中存在工具调用,就走向tools节点,否则直接走到_end_结束节点 使用上面的代码,就可以实现一个简单的工具调用。

下面从这个代码开始我们逐步串讲出来Langgraph所有的相关基础知识点

state

我们首先看下上面的定义图的关键代码

python 复制代码
builder = StateGraph(MessagesState)

定义了图的State类型为MessagesState,相当于定义了图中节点上下文的数据结构,这样后续的每个节点的入参和出参都会是MessagesState类型的数据结构,事实上我们也可以自定义State,继承自TypedDict即可 ( MessagesState本身也是继承的TypedDict) 如下面的代码所示

python 复制代码
class State(TypedDict):
    messages: list[AnyMessage]
    extra_field: int


graph_builder = StateGraph(State)

但是有的时候我们希望输入节点和输出节点的入参结构简单点,一个典型的场景是我可能只需要用户给定部分参数,输出的就是一段文本,但是在图的内部节点时我需要用一个复杂的State数据结构来记录过程数据,这时候定义图的时候就可以像下面这样进行定义:

python 复制代码
# 定义输入Schema
class InputState(TypedDict):
    question: str


# 定义图的输出Schema
class OutputState(TypedDict):
    answer: str


# 图的State
class OverallState(InputState, OutputState):
    pass


def answer_node(state: InputState):
    return {"answer": "bye", "question": state["question"]}

# 关键代码定义了图的输入和输出数据结构
builder = StateGraph(OverallState, input=InputState, output=OutputState)
builder.add_node(answer_node)  
builder.add_edge(START, "answer_node") 
builder.add_edge("answer_node", END) 
graph = builder.compile()  # Compile the graph

在上面的代码中我们定义的输入的数据结构为InputState,输出的数据结构为OutputState,这样我们的节点接收的数据结构就是InputState类型, 图的总的数据结构为OverallState是继承了InputState和OutputState的,也就是会同时包含question和answer两个属性,一个图必须有一个总的State,也就是说这里的OverallState是必须的。

可能有一种场景就是两个节点之间的数据结构不一致,那么应该怎么处理呢? 可以参考下面的代码

python 复制代码
class OverallState(TypedDict):
    foo: int


class PrivateState(TypedDict):
    baz: int


def node_1(state: OverallState) -> PrivateState:
    print("---Node 1---")
    return {"baz": state['foo'] + 1}


def node_2(state: PrivateState) -> OverallState:
    print("---Node 2---")
    return {"foo": state['baz'] + 1}


# Build graph
builder = StateGraph(OverallState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)

# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

# Add
graph = builder.compile()

上面的这个图中Node1 到 Node2之间是私有的,node1中设置了baz属性值 node2接收的参数类型是PrivateState 也就是说Node1 和 Node2 之间也可以使用不同的数据结构进行通信而不必强制按照OverallState定义的数据结构进行参数传递

Node

节点其实就是一个 python 函数。第一个位置参数是 state,如上所述。因为每个节点都有 state 作为入参,所以我们就可以通过state 访问到我们定义的State数据结构中的属性,节点中也可以对state进行更新

python 复制代码
from typing_extensions import TypedDict


class State(TypedDict):
    graph_state: str


def node_1(state):
    print("---Node 1---")
    return {"graph_state": state['graph_state'] + " I am"}


def node_2(state):
    print("---Node 2---")
    return {"graph_state": state['graph_state'] + " happy!"}


def node_3(state):
    print("---Node 3---")
    return {"graph_state": state['graph_state'] + " sad!"}

默认情况下,每个节点返回的新值将覆盖之前的 state 值。但是有的时候比如历史对话信息我们往往希望是追加而不是覆盖,具体如何覆盖在第三节会有介绍

Edge

边主要用来连接节点,例如,如果要始终从 node_1 转到 node_2,则使用 Normal Edges。如果要选择性地在节点之间路由,则使用 Conditional Edge。条件边被实现为函数,这些函数根据某些逻辑返回下一个要访问的节点。比如我们本节开始的例子中有下面的代码

python 复制代码
builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "assistant")
# 条件边
builder.add_conditional_edges(
    "assistant",
    tools_condition,
)
builder.add_edge("tools", "assistant")

在上面的代码中assistant节点可以走到tools节点,也可以直接结束,具体怎么走取决于 tools_condition的条件值,当然我们也可以自定义条件,如下图所示:

python 复制代码
import random
from typing import Literal


def decide_mood(state) -> Literal["node_2", "node_3"]:
    # Often, we will use state to decide on the next node to visit
    user_input = state['graph_state']

    # Here, let's just do a 50 / 50 split between nodes 2, 3
    if random.random() < 0.5:
        # 50% of the time, we return Node 2
        return "node_2"

    # 50% of the time, we return Node 3
    return "node_3"


from langgraph.graph import StateGraph, START, END

# Build graph
builder = StateGraph(State)
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_conditional_edges("node_1", decide_mood,["node_2","node_3"])
builder.add_edge("node_2", END)
builder.add_edge("node_3", END)

# Add
graph = builder.compile()

在上面的代码中我们定义了条件边 decide_mood,当node_1节点执行完成之后,会调用decide_mood函数,根据decide_mood函数返回的值,决定下一个节点是node_2还是node_3, add_conditional_edges 还可以制定第三个参数,限定下一跳的节点名称集合。比较推荐添加这第三个参数,这样渲染图结构的时候连线会比较清晰,

python 复制代码
builder.add_conditional_edges("decision", router, ["action", END])

三、 图的编译和执行

图构建完毕之后需要调用compile()方法编译图,编译之后就可以执行图了,那么怎么执行图呢?有两个方法可以执行图一个为invoke一个为stream

invoke

python 复制代码
graph.invoke({"value": "hi!"})
# graph.invoke({}) 如果没有什么数据需要传递给开始节点也可以直接写个空字典

invoke 方法传递的数据结构需要和我们定义的InputState类型一致,例如我们的State的数据结构为MessagesState,那么invoke方法需要传递的数据结构为MessagesState,如下所示:

python 复制代码
res = graph.invoke({"messages": [HumanMessage("Hi")]})
print(res)

stream

python 复制代码
for event in graph.stream(initial_input, thread, stream_mode="values"):
    event['messages'][-1].pretty_print()

initial_input 是输入数据,通常是用户的提问消息,和我们之前讲的invoke方法的入参是一个含义。 thread 是线程ID,用于维护会话记忆和状态隔离。 第三个参数 stream_mode="values" 是用来控制图执行时的输出流模式(streaming mode)。 具体来说,stream_mode 参数决定了 graph.stream() 方法在执行图时,如何逐步返回执行结果的内容和格式。LangGraph 支持多种流模式,常见的包括:

  • "values":每一步返回当前整个状态的完整快照(即所有State字段的当前值)。
  • "updates":每一步只返回本次执行节点产生的状态更新部分。
  • "messages":专门用于流式返回LLM生成的消息(如聊天模型的token流)。
  • "debug":返回详细的调试信息,包括任务执行细节。

也可以传入多个模式的列表同时启用多种流模式。

最后stream实际还有一个非阻塞式的 astream_events 方法 :

python 复制代码
node_to_stream = 'conversation'
config = {"configurable": {"thread_id": "3"}}
input_message = HumanMessage(content="Tell me about the 49ers NFL team")
# 注意这里使用了astream 不是stream astream是异步的 stream方法是同步的
async for event in graph.astream_events({"messages": [input_message]}, config, version="v2"):
    print(f"Node: {event['metadata'].get('langgraph_node','')}. Type: {event['event']}. Name: {event['name']}")
    # Get chat model tokens from a particular node 
    if event["event"] == "on_chat_model_stream" and event['metadata'].get('langgraph_node','') == node_to_stream:
        print(event["data"])

在上面的方法中我们可以直接获取某个节点生成片段消息,得到类似下面的输出:

{'chunk': AIMessageChunk(content='', id='run-b76ec3b8-9c45-42fe-b321-4ec3a69c185c')}

{'chunk': AIMessageChunk(content='The', id='run-b76ec3b8-9c45-42fe-b321-4ec3a69c185c')}

{'chunk': AIMessageChunk(content=' San', id='run-b76ec3b8-9c45-42fe-b321-4ec3a69c185c')}

{'chunk': AIMessageChunk(content=' Francisco', id='run-b76ec3b8-9c45-42fe-b321-4ec3a69c185c')}

...

invoke和stream都能执行图 那么两者之间的区别是什么呢?

  • invoke 是"跑完再给结果",适合批量或非交互场景。
  • stream 是"边跑边给结果",适合需要实时反馈的交互式场景。
  • stream 支持多种流模式,能细粒度控制输出内容和格式。
  • invoke 返回最终状态,stream 返回执行过程中的多个中间结果。

还有个问题就是无论是invoke还是stream,默认情况下都是阻塞式的一下子给出所有结果,如果需要实现打字机的效果怎么来实现呢?可以参考下面的代码进行编写

python 复制代码
app = workflow.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "1"}}
input_str = [HumanMessage(content="请问辣椒炒肉怎么做")]

# 注意这里stream_mode 一定要设置为messages 否则这样写是不行的
for chunk, meta in app.stream({'messages': input_str}, config=config, stream_mode="messages"):
    if isinstance(chunk, AIMessage):
        print(chunk.content,end="", flush=True)

或者直接利用之前说过的 astream_events 方法, 代码如下所示:

python 复制代码
app = workflow.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "1"}}
input = [HumanMessage(content="请问辣椒炒肉怎么做")]



async def run_async():
    node_to_stream = 'conversation'
    async for event in app.astream_events({"messages": input}, config=config, version="v2"):
        if event["event"] == "on_chat_model_stream" and event['metadata'].get('langgraph_node', '') == node_to_stream:
            data = event["data"]
            print(data["chunk"].content, end="", flush=True)

import asyncio
asyncio.run(run_async())

四、state的reduce功能

默认情况下每个节点返回的新值将覆盖之前的 state 值。那么怎么做到不覆盖呢? 比如我们做大模型对话应用的场景中我们的每次对话信息是存储再state中的messages字段中的,如果每次都覆盖肯定是不对的,我们希望的是把新的对话信息给追加到messages字段中,怎么做呢?可以使用reduce函数来实现 接下来我们通过一个例子来学习reduce函数

python 复制代码
class State(TypedDict):
    value_1: list[str]


def step_1(state: State):
    state["value_1"] += [" step_1 "]
    print(state["value_1"])
    return {"value_1": state["value_1"]}


def step_2(state: State):
    state["value_1"] += [" step_2 "]
    print(state["value_1"])
    return {"value_1": state["value_1"]}


def step_3(state: State):
    state["value_1"] += [" step_3 "]
    print(state["value_1"])
    return {"value_1": state["value_1"]}


graph_builder = StateGraph(State)
# 使用这个方法可以简化代码 节点和边一并加上了
graph_builder.add_sequence([step_1, step_2, step_3])
graph_builder.add_edge(START, "step_1")
graph = graph_builder.compile()

result = graph.invoke({"value_1": []})
print(result)

上面的代码是一个标准的链式调用,图结构如下

最终输出结果为

' step_1 '

' step_1 ', ' step_2 '

' step_1 ', ' step_2 ', ' step_3 '

{'value_1': [' step_1 ', ' step_2 ', ' step_3 ']}

看起来还不错,但是每个节点都需要显式的去往state中更新数据,那么有没有什么方式可以简化代码呢?请看简化之后的实现逻辑

python 复制代码
from typing import Annotated

from langgraph.graph import START
from langgraph.graph import StateGraph
from typing_extensions import TypedDict


def combine(left: list[str], right: list[str]) -> list[str]:
    return left + right


# 第一步定义State  也就是数据结构,默认情况下节点的输入和输出都是这种数据结构
class State(TypedDict):
    value_1: Annotated[list[str], combine]


def step_1(state: State):
    state["value_1"] = [" step_1 "]
    return {"value_1": state["value_1"]}


def step_2(state: State):
    state["value_1"] = [" step_2 "]
    return {"value_1": state["value_1"]}


def step_3(state: State):
    state["value_1"] = [" step_3 "]
    return {"value_1": state["value_1"]}


graph_builder = StateGraph(State)
# 使用这个方法可以简化代码 节点和边一并加上了
graph_builder.add_sequence([step_1, step_2, step_3])
graph_builder.add_edge(START, "step_1")
graph = graph_builder.compile()

result = graph.invoke({"value_1": []})
print(result)

在上面的代码中我们通过 Annotated[list[str], combine] 为最终的输出指定了合并函数,每个节点都会对 state["value_1"] 进行修改,但是等到执行图的时候最终结果会合并,上面的代码执行结果如下

{'value_1': [' step_1 ', ' step_2 ', ' step_3 ']}

五、超步(super-steps)

超级步骤可以被认为是对图节点的一次单次迭代。并行运行的节点属于同一个超级步骤,而顺序运行的节点属于不同的超级步骤。 在图执行开始时,所有节点都处于 inactive 状态。当一个节点在其任何传入边(或"通道")上接收到新的消息(状态)时,它变为 active 。 然后,active节点运行其函数并响应更新。在每个超级步骤结束时,没有传入消息的节点通过将自己标记为 inactive 。当所有节点都是 inactive 且没有传输中的消息时,图执行终止。

对于上面的这段概念我们以一个例子来说明问题:

python 复制代码
class State(TypedDict):
    aggregate: Annotated[list, operator.add]
    which: str


def a(state: State):
    print(f'Adding "A" to {state["aggregate"]}')
    return {"aggregate": ["A"]}


def b(state: State):
    print(f'Adding "B" to {state["aggregate"]}')
    return {"aggregate": ["B"]}


def c(state: State):
    print(f'Adding "C" to {state["aggregate"]}')
    return {"aggregate": ["C"]}


def d(state: State):
    print(f'Adding "D" to {state["aggregate"]}')
    return {"aggregate": ["D"]}


def e(state: State):
    print(f'Adding "E" to {state["aggregate"]}')
    return {"aggregate": ["E"]}


builder = StateGraph(State)
builder.add_node(a)
builder.add_node(b)
builder.add_node(c)
builder.add_node(d)
builder.add_node(e)
builder.add_edge(START, "a")


def route_bc_or_cd(state: State) -> Sequence[str]:
    if state["which"] == "cd":
        return ["c", "d"]
    return ["b", "c"]


intermediates = ["b", "c", "d"]
builder.add_conditional_edges(
    "a",
    route_bc_or_cd,
    intermediates,
)
for node in intermediates:
    builder.add_edge(node, "e")

builder.add_edge("e", END)
graph = builder.compile()

#  指定 which 为 bc 相当于指定了执行条件  会并行执行 b 和 c
res = graph.invoke({"aggregate": [], "which": "bc"})
print(res)

上面的这个图使用了条件边,通过route_bc_or_cd函数来判断是并行执行 b 和 c 还是并行执行 c 和 d,图的结构如下图所示:

上图由于invoke的时候传递的which的值为"bc",所以最终b和c会并行执行,b和c并行执行时就是隶属于同一个超级步骤 ,也就是说此时b和c打印的state["aggregate"] 是一样的。 但是当b和c执行完毕之后到了e节点,此时e节点的打印语句会输出 ['A', 'B', 'C'] b和c节点对 state["aggregate"] 进行修改会因为配置了reduce函数(operator.add)而合并

在这个案例里面 b和c并行执行 如果这里在state定义那里时候没有用add函数, b和c对state["aggregate"] 同时进行修改是会报错的 ,因为同一个步骤中对State值进行并行修改,这是不允许的

现在我们修改代码考虑一个更复杂一点的例子,代码如下:

python 复制代码
class State(TypedDict):
    aggregate: Annotated[list, operator.add]


def a(state: State):
    print(f'Adding "A" to {state["aggregate"]}')
    return {"aggregate": ["A"]}


def b(state: State):
    print(f'Adding "B" to {state["aggregate"]}')
    return {"aggregate": ["B"]}


def c(state: State):
    print(f'Adding "C" to {state["aggregate"]}')
    return {"aggregate": ["C"]}


def d(state: State):
    print(f'Adding "D" to {state["aggregate"]}')
    return {"aggregate": ["D"]}


def b_2(state: State):
    print(f'Adding "B_2" to {state["aggregate"]}')
    return {"aggregate": ["B_2"]}


builder = StateGraph(State)
builder.add_node(a)
builder.add_node(b)
builder.add_node(b_2)
builder.add_node(c)
builder.add_node(d)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("a", "c")
builder.add_edge("b", "b_2")

# 一次性添加多个前驱的边(add_edge(["b_2", "c"], "d"))
# 这是LangGraph提供的语法糖,表示 d 节点需要等待所有前驱节点(b_2 和 c)完成后,才执行一次。
# 也就是说,d 节点的激活是"合并"的,只有当所有指定的前驱都完成时才触发。
builder.add_edge(["b_2", "c"], "d")

# 下面这样写最终结果会有两个D 因为b_2 和 d节点 会并行执行一次(这两个节点的层级一致) 然后 b_2->d 的时候又执行了一次d节点
# builder.add_edge("b_2", "d")
# builder.add_edge("c", "d")

上述代码的图结构如下图所示:

使用下面的代码执行图

python 复制代码
builder.add_edge("d", END)
graph = builder.compile()

res = graph.invoke({"aggregate": []})
print(res)

最终执行结果如下:

Adding "A" to []

Adding "B" to ['A']

Adding "C" to ['A']

Adding "B_2" to ['A', 'B', 'C']

Adding "D" to ['A', 'B', 'C', 'B_2']

{'aggregate': ['A', 'B', 'C', 'B_2', 'D']}

此时b和c属于同一个超级步骤,所以执行开始前获取到的state["aggregate"] 值只有一个元素A , b_2则属于另一个超级步骤,所以执行开始前获取到的state["aggregate"] 值有三个元素 A、B、C

需要注意的是我们通过builder.add_edge(["b_2", "c"], "d") 添加了多个前驱的边,这样写也规定了d节点需要等待所有前驱节点(b_2 和 c)完成后,才执行一次

这里还有一个问题 因为b节点和c节点是并行的,这时候b先执行还是c先执行 是不确定的,这就导致最终的State的aggregate值是不确定的,怎么解决这个问题呢,我们可以考虑给aggregate增加一个排序逻辑:

python 复制代码
def sorting_reducer(left, right):
    """ Combines and sorts the values in a list"""
    if not isinstance(left, list):
        left = [left]

    if not isinstance(right, list):
        right = [right]
    
    return sorted(left + right, reverse=False)

class State(TypedDict):
    # sorting_reducer will sort the values in state
    aggregate: Annotated[list, sorting_reducer]

此外还有一个办法就是 并行的节点各自往不同的字段写入数据 然后由sink节点合并并行节点的输出,最后写到state的某个变量里面。

六、本章小结

本节详细介绍了 State 、node、edge的相关概念,同时给出了reduce函数的使用以及什么叫做超步,理解超步的概念非常关键,特别是在并行节点的设计中,并行节点无法同时在不定义reduce函数情况下对同一个State的变量进行修改这个限制需要尤其注意。

相关推荐
钡铼技术ARM工业边缘计算机4 分钟前
【成本降40%·性能翻倍】RK3588边缘控制器在安防联动系统的升级路径
后端
CryptoPP37 分钟前
使用WebSocket实时获取印度股票数据源(无调用次数限制)实战
后端·python·websocket·网络协议·区块链
白宇横流学长43 分钟前
基于SpringBoot实现的大创管理系统设计与实现【源码+文档】
java·spring boot·后端
草捏子1 小时前
状态机设计:比if-else优雅100倍的设计
后端
考虑考虑3 小时前
Springboot3.5.x结构化日志新属性
spring boot·后端·spring
涡能增压发动积3 小时前
一起来学 Langgraph [第三节]
后端
sky_ph3 小时前
JAVA-GC浅析(二)G1(Garbage First)回收器
java·后端
hello早上好3 小时前
Spring不同类型的ApplicationContext的创建方式
java·后端·架构
roman_日积跬步-终至千里3 小时前
【Go语言基础【20】】Go的包与工程
开发语言·后端·golang