一起来学 Langgraph [第三节]

介绍

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

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

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

一、引言

本节聊下消息列表管理和记忆存储相关的话题,我们首先看下下面的这段代码

python 复制代码
llm = ChatTongyi(model="qwen-max", api_key=SecretStr(os.getenv("DASHSCOPE_API_KEY")))


# Node
def chat_model_node(state: MessagesState):
    return {"messages": llm.invoke(state["messages"])}


# Build graph
builder = StateGraph(MessagesState)
builder.add_node("chat_model", chat_model_node)
builder.add_edge(START, "chat_model")
builder.add_edge("chat_model", END)
graph = builder.compile()

这段代码非常简单,只有一个大模型调用节点,我们可以直接在langgraph-studio试验下效果:

可以发现可以正常运行和调用模型,并且从调用结果来看是具备记忆功能的

接下来我们直接在代码中运行这个Graph,看看是否存在不同,我们添加下面的调用代码

python 复制代码
def test_run_graph_by_stream(input_str: str):
    for event in graph.stream({'messages': input_str}, stream_mode="values"):
        event['messages'][-1].pretty_print()


def test_run_graph(input_str: str):
    output = graph.invoke({'messages': input_str})
    for m in output['messages']:
        m.pretty_print()


test_run_graph_by_stream("我的名字叫做小张")
test_run_graph_by_stream("请问我的名字叫什么")

最终的输出结果如下:

从上面的图片可以看出,大模型丢失了会话记忆,怎么解决直接调用时的记忆问题呢,同时我们也知道历史对话很多的时候去调用大模型时会消耗大量token,有没有对记忆进行修剪的办法呢?这就是本节需要讨论的内容。

二、线程记忆

我们对上面的代码做下简单修改就可以实现记忆功能,修改完成之后的代码逻辑如下:

python 复制代码
from langgraph.checkpoint.memory import InMemorySaver
memory = InMemorySaver()
graph = builder.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "1"}}


def test_run_graph_by_stream(input_str: str, config: RunnableConfig):
    for event in graph.stream({'messages': input_str}, config=config, stream_mode="values"):
        event['messages'][-1].pretty_print()


def test_run_graph(input_str: str, config: RunnableConfig):
    output = graph.invoke({'messages': input_str}, config=config)
    for m in output['messages']:
        m.pretty_print()


test_run_graph_by_stream("我的名字叫做小张", config=config)
test_run_graph_by_stream("请问我的名字叫什么", config=config)

执行效果如下:

可以发现具备了记忆功能,其核心改动在于我们编译图的时候添加了checkpointer,此外还添加了config ,两次对话都使用同一个线程ID这样就可以保证记忆不会出现错乱,假如我上面的代码中第二次调用传递另一个不同的线程ID,依然不会有记忆功能 之所以时候用langgraph-studio来运行是有记忆的是因为langgraph-studio会自动保存checkpointer 并且每次会话也会传递线程ID,如下图所示:

三、长期记忆

在第二节中我们实现了对话记忆功能 ,但是还存在下面几个问题:

  • 程序关闭重启之后即使我们还是传递相同的线程ID,记忆也会丢失
  • 有的时候我们希望不同线程id的对话也能共享记忆

如何去解决这两个问题就是本节的重点:

对于第一个问题由于我们之前的代码定义的是InMemorySaver,这是本质是个内存存储,所以每次程序关闭之后记忆都会丢失,所以我们需要换用一个能够长期存储数据的媒介,比如sqlite,下面我们简单的看下修改之后的代码:

python 复制代码
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

db_path = "state_db/example.db"
conn = sqlite3.connect(db_path, check_same_thread=False)

memory = SqliteSaver(conn)
graph = builder.compile(checkpointer=memory)

我们这里指定了一个db_path,这个dbpath下面有个sqlite文件,存在两张表:

这个数据库文件可以从 github.com/langchain-a... 下载,按照上面的这样进行配置的时候线程记忆就有了持久化的能力。下次继续启动的时只要线程ID不变,记忆就会保留。

上面的办法解决了第一个问题,对于第二个问题,我们需要在compile方法中注入第二个参数,代码如下:

python 复制代码
# Store for long-term (across-thread) memory
across_thread_memory = InMemoryStore()

# Checkpointer for short-term (within-thread) memory
within_thread_memory = MemorySaver()

# Compile the graph with the checkpointer fir and store
# 配置了 checkpointer 后,图具备了线程级的对话记忆能力;配置 store 则实现了跨线程的长期记忆,两者功能互补,共同构建完整的记忆体系。
graph = builder.compile(checkpointer=within_thread_memory, store=across_thread_memory)

这里注意一下checkpointer 对应的是 xxxxxSaver store对应的是 xxxxxStore checkpointer 解决的是统一线程ID的记忆问题,而Store 解决的是跨线程的长期记忆问题以及RAG场景

上面的代码compile方法我们指定了第二个参数store,那么我们在节点中就可以对这个store里面的数据进行修改更新 甚至是搜索,可以看下下面的代码

python 复制代码
def update_memory(state: MessagesState,  config: RunnableConfig,  * ,  store: BaseStore):
    # Get the user id from the config
    user_id = config["configurable"]["user_id"]
    # Namespace the memory
    namespace = (user_id, "memories")
    # ... Analyze conversation and create a new memory
    # Create a new memory ID
    memory_id = str(uuid.uuid4())
    # We create a new memory
    store.put(namespace, memory_id, {"memory": memory})

store 还可以充当一个数据库的角色,帮助我们实现向量存储,类似下面的代码

python 复制代码
store = InMemoryStore(
    index={
        "embed": init_embeddings("openai:text-embedding-3-small"),  # Embedding provider
        "dims": 1536,                              # Embedding dimensions
        "fields": ["food_preference", "$"]              # Fields to embed
    }
)

# Store with specific fields to embed
store.put(
    namespace_for_memory,
    str(uuid.uuid4()),
    {
        "food_preference": "I love Italian cuisine",
        "context": "Discussing dinner plans"
    },
    index=["food_preference"]  # Only embed "food_preferences" field
)

# Store without embedding (still retrievable, but not searchable)
store.put(
    namespace_for_memory,
    str(uuid.uuid4()),
    {"system_info": "Last updated: 2024-01-01"},
    index=False
)

memories = store.search(
    namespace_for_memory,
    query="What does the user like to eat?",
    limit=3  # Return top 3 matches
)

三、记忆过滤

langgraph支持在调用大模型之前对对话记忆进行裁剪和过滤,下面是一个典型的例子:

python 复制代码
from langchain_core.messages import trim_messages

# Node
def chat_model_node(state: MessagesState):
    messages = trim_messages(
            state["messages"],
            max_tokens=100,
            strategy="last",
            token_counter=ChatOpenAI(model="gpt-4o"),
            allow_partial=False,
        )
    return {"messages": [llm.invoke(messages)]}

上面的代码中我们使用trim_messages对对话进行裁剪,规定了携带的上下文token不能超过100。不借助trim_messages其实我们也可以通过对 state["messages"] 进行切片做到这一点,比如我们只传递最后10条对话,就可以这样写

python 复制代码
def chat_model_node(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"][-10:])]}

此外还有一种思路,按照上面的裁剪思路可能导致关键信息被裁剪掉,比如我在最开始的时候和大模型沟通时告诉了大模型我的个人信息,这个信息我希望能一直保留,而不会被裁剪,按照本节的裁剪办法就做不到。 怎么实现呢?有下面两种思路

  • 在大模型节点时把这些信息给存到store里面,而不是仅仅依靠checkpoint做存储,这在第二节已经有所涉及
  • 在调用大模型之前,先让把之前对话的关键信息给总结一下,然后携带这个总结信息作为上下文再次调用大模型。比如我们可以规定对话次数大于10之后需要总结一次,后续每次有新的对话就更新总结内容,然后携带这个总结信息作为上下文再次调用大模型,这种方案的实现思路可以参考下面的代码
python 复制代码
# LLM
model = ChatOpenAI(model="gpt-4o", temperature=0)


# State 
class State(MessagesState):
    # 记录历史对话的总结内容
    summary: str


# Define the logic to call the model
def call_model(state: State, config: RunnableConfig):

    summary = state.get("summary", "")

    # If there is summary, then we add it
    if summary:
        # Add summary to system message
        system_message = f"Summary of conversation earlier: {summary}"
        # Append summary to any newer messages
        messages = [SystemMessage(content=system_message)] + state["messages"]

    else:
        messages = state["messages"]

    response = model.invoke(messages, config)
    return {"messages": response}


def summarize_conversation(state: State):
    
    # First, we get any existing summary
    summary = state.get("summary", "")

    # Create our summarization prompt :总结存在与否 我们给到大模型的提示词是不同的
    if summary:
        summary_message = (
            f"This is summary of the conversation to date: {summary}\n\n"
            "Extend the summary by taking into account the new messages above:"
        )
    else:
        summary_message = "Create a summary of the conversation above:"

    # Add prompt to our history
    messages = state["messages"] + [HumanMessage(content=summary_message)]
    response = model.invoke(messages)
    
    # 总结之后部分历史消息可以删除
    delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
    return {"summary": response.content, "messages": delete_messages}

# Determine whether to end or summarize the conversation
def should_continue(state: State):
    messages = state["messages"]
    
    #当前对话总数超过6则需要总结
    if len(messages) > 6:
        return "summarize_conversation"
    
    # Otherwise we can just end
    return END

接着我们构建这个图:

python 复制代码
# Define a new graph
workflow = StateGraph(State)
workflow.add_node("conversation", call_model)
workflow.add_node(summarize_conversation)

# Set the entrypoint as conversation
workflow.add_edge(START, "conversation")
workflow.add_conditional_edges("conversation", should_continue)
workflow.add_edge("summarize_conversation", END)

# Compile
memory = MemorySaver()
graph = workflow.compile(checkpointer=memory)

构建出来的图结构如下:

这段代码的关键就是通过大模型总结之前的对话记录,最后一点就是通过 delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]] 对对话进行删除, RemoveMessage 会按照消息ID 删除对应的消息

四、总结

下面我们看下下面这张图总结下目前介绍过的内容(此图来自官方文档)

  • 边:控制节点的走向和执行顺序
  • 超步:每个顺序执行的节点都属于一个独立的超步,并行节点共享一个超步
  • 提交点(checkpoint): 存放每一个超步的元数据和State值
  • Thread:一堆提交点的集合
  • get_state(): 获取当前的State信息
  • get_state_history(): 获取历史所有状态信息
  • stream() & invoke(): 执行图
相关推荐
面朝大海,春不暖,花不开13 分钟前
自定义Spring Boot Starter的全面指南
java·spring boot·后端
钡铼技术ARM工业边缘计算机1 小时前
【成本降40%·性能翻倍】RK3588边缘控制器在安防联动系统的升级路径
后端
CryptoPP1 小时前
使用WebSocket实时获取印度股票数据源(无调用次数限制)实战
后端·python·websocket·网络协议·区块链
白宇横流学长1 小时前
基于SpringBoot实现的大创管理系统设计与实现【源码+文档】
java·spring boot·后端
草捏子2 小时前
状态机设计:比if-else优雅100倍的设计
后端
考虑考虑4 小时前
Springboot3.5.x结构化日志新属性
spring boot·后端·spring
sky_ph4 小时前
JAVA-GC浅析(二)G1(Garbage First)回收器
java·后端
涡能增压发动积4 小时前
一起来学 Langgraph [第二节]
后端
hello早上好4 小时前
Spring不同类型的ApplicationContext的创建方式
java·后端·架构