diff --git a/docs/cassettes/shared-state_5f36c16f-d68d-456c-b0b7-5eb94fb89ca6.msgpack.zlib b/docs/cassettes/shared-state_5f36c16f-d68d-456c-b0b7-5eb94fb89ca6.msgpack.zlib new file mode 100644 index 000000000..6ea5bfc76 --- /dev/null +++ b/docs/cassettes/shared-state_5f36c16f-d68d-456c-b0b7-5eb94fb89ca6.msgpack.zlib @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/docs/docs/concepts/memory.md b/docs/docs/concepts/memory.md new file mode 100644 index 000000000..3009734fc --- /dev/null +++ b/docs/docs/concepts/memory.md @@ -0,0 +1,175 @@ +# Memory + +## What is Memory? + +Memory in the context of LLMs and AI applications refers to the ability to process, retain, and utilize information from past interactions or data sources. Examples include: + +- Managing what messages (e.g., from a long message history) are sent to a chat model to limit token usage +- Summarizing past conversations to give a chat model context from prior interactions +- Selecting few shot examples (e.g., from a dataset) to guide model responses +- Maintaining persistent data (e.g., user preferences) across multiple chat sessions +- Allowing an LLM to update its own prompt using past information (e.g., meta-prompting) +- Retrieving information relevant to a conversation or question from a long-term storage system + +Below, we'll discuss each of these examples in some detail. + +## Managing Messages + +### Editing message lists + +Chat models accept instructions through [messages](https://python.langchain.com/docs/concepts/#messages), which can serve as general instructions (e.g., a system message) or user-provided instructions (e.g., human messages). In chat applications, messages often alternate between human inputs and model responses, accumulating in a list over time. Because context windows are limited and token-rich message lists can be costly, many applications can benefit from approaches to actively manage messages. + +The most directed approach is to remove specific messages from a list. This can be done using [RemoveMessage](https://langchain-ai.github.io/langgraph/how-tos/memory/delete-messages/#manually-deleting-messages) based upon the message `id`, a unique identifier for each message. In the below example, we keep only the last two messages in the list using `RemoveMessage` to remove older messages based upon their `id`. + +```python +from langchain_core.messages import RemoveMessage + +# Message list +messages = [AIMessage("Hi.", name="Bot", id="1")] +messages.append(HumanMessage("Hi.", name="Lance", id="2")) +messages.append(AIMessage("So you said you were researching ocean mammals?", name="Bot", id="3")) +messages.append(HumanMessage("Yes, I know about whales. But what others should I learn about?", name="Lance", id="4")) + +# Isolate messages to delete +delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]] +print(delete_messages) +[RemoveMessage(content='', id='1'), RemoveMessage(content='', id='2')] +``` + +Because the context window for chat model is denominated in tokens, it can be useful to trim message lists based upon some number of tokens that we want to retain. To do this, we can use [`trim_messages`](https://python.langchain.com/docs/how_to/trim_messages/#trimming-based-on-token-count) and specify number of token to keep from the list, as well as the `strategy` (e.g., keep the last `max_tokens`). + +```python +from langchain_core.messages import trim_messages +trim_messages( + messages, + # Keep the last <= n_count tokens of the messages. + strategy="last", + # Remember to adjust based on your model + # or else pass a custom token_encoder + token_counter=ChatOpenAI(model="gpt-4o"), + # Most chat models expect that chat history starts with either: + # (1) a HumanMessage or + # (2) a SystemMessage followed by a HumanMessage + # Remember to adjust based on the desired conversation + # length + max_tokens=45, + # Most chat models expect that chat history starts with either: + # (1) a HumanMessage or + # (2) a SystemMessage followed by a HumanMessage + start_on="human", + # Most chat models expect that chat history ends with either: + # (1) a HumanMessage or + # (2) a ToolMessage + end_on=("human", "tool"), + # Usually, we want to keep the SystemMessage + # if it's present in the original history. + # The SystemMessage has special instructions for the model. + include_system=True, +) +``` +### Usage with LangGraph + +When building agents in LangGraph, we commonly want to manage a list of messages in the graph state. Because this is such a common use case, [MessagesState](https://langchain-ai.github.io/langgraph/concepts/low_level/#working-with-messages-in-graph-state) is a built-in LangGraph state schema that includes a `messages` key, which is a list of messages. `MessagesState` also includes an `add_messages` reducer for updating the messages list with new messages as the application runs. The `add_messages` reducer allows us to [append](https://langchain-ai.github.io/langgraph/concepts/low_level/#serialization) new messages to the `messages` state key as shown below. When we perform a state update with `{"messages": new_message}` returned from `my_node`, the `add_messages` reducer appends `new_message` to the existing list of messages. + +```python +def my_node(state: State): + # Add a new message to the state + new_message = HumanMessage(content="message") + return {"messages": new_message} +``` + +The `add_messages` reducer built into `MessagesState` [also works with the `RemoveMessage` utility that we discussed above](https://langchain-ai.github.io/langgraph/how-tos/memory/delete-messages/). In this case, we can perform a state update with a list of `delete_messages` to remove specific messages from the `messages` list. + +```python +def my_node(state: State): + # Delete messages from state + delete_messages = [RemoveMessage(id=m.id) for m in state['messages'][:-2]] + return {"messages": delete_messages} +``` + +See this how-to [guide](https://langchain-ai.github.io/langgraph/how-tos/memory/manage-conversation-history/) and module 2 from our [LangChain Academy](https://github.com/langchain-ai/langchain-academy/tree/main/module-2) course for example usage. + +## Summarizing Past Conversations + +The problem with trimming or removing messages, as shown above, is that we may loose information from culling of the message queue. Because of this, some applications benefit from a more sophisticated approach of summarizing the message history using a chat model. + +Simple prompting and orchestration logic can be used to achieve this. As an example, in LangGraph we can extend the [MessagesState](https://langchain-ai.github.io/langgraph/concepts/low_level/#working-with-messages-in-graph-state) to include a `summary` key. + +```python +from langgraph.graph import MessagesState +class State(MessagesState): + summary: str +``` + +Then, we can generate a summary of the chat history, using any existing summary as context for the next summary. This `summarize_conversation` node can be called after some number of messages have accumulated in the `messages` state key. + +```python +def summarize_conversation(state: State): + + # First, we get any existing summary + summary = state.get("summary", "") + + # Create our summarization prompt + if summary: + + # A summary already exists + 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 all but the 2 most recent messages + delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]] + return {"summary": response.content, "messages": delete_messages} +``` + +See this how-to [here](https://langchain-ai.github.io/langgraph/how-tos/memory/add-summary-conversation-history/) and module 2 from our [LangChain Academy](https://github.com/langchain-ai/langchain-academy/tree/main/module-2) course for example usage. + +## Few Shot Examples + +Few-shot learning is a powerful technique where LLMs can be ["programmed"](https://x.com/karpathy/status/1627366413840322562) inside the prompt with input-output examples to perform diverse tasks. While various [best-practices](https://python.langchain.com/docs/concepts/#1-generating-examples) can be used to generate few-shot examples, often the challenge lies in selecting the most relevant examples based on user input. + +LangChain [`ExampleSelectors`](https://python.langchain.com/docs/how_to/#example-selectors) can be used to customize few-shot example selection from a collection of examples using criteria such as length, semantic similarity, semantic ngram overlap, or maximal marginal relevance. + +If few-shot examples are stored in a [LangSmith Dataset](https://docs.smith.langchain.com/how_to_guides/datasets), then dynamic few-shot example selectors can be used out-of-the box to achieve this same goal. LangSmith will index the dataset for you and enable retrieval of few shot examples that are most relevant to the user input based upon keyword similarity ([using a BM25-like algorithm](https://docs.smith.langchain.com/how_to_guides/datasets/index_datasets_for_dynamic_few_shot_example_selection) for keyword based similarity). + +See this how-to [video](https://www.youtube.com/watch?v=37VaU7e7t5o) for example usage of dynamic few-shot example selection in LangSmith. Also, see this [blog post](https://blog.langchain.dev/few-shot-prompting-to-improve-tool-calling-performance/) showcasing few-shot prompting to improve tool calling performance and this [blog post](https://blog.langchain.dev/aligning-llm-as-a-judge-with-human-preferences/) using few-shot example to align an LLMs to human preferences. + +## Maintaining Data Across Chat Sessions + +LangGraph's [persistence layer](https://langchain-ai.github.io/langgraph/concepts/persistence/#persistence) has checkpointers that utilize various storage systems, including an in-memory key-value store or different databases. These checkpoints capture the graph state at each execution step and accumulate in a thread, which can be accessed at a later time using a thread ID to resume a previous graph execution. We add persistence to our graph by passing a checkpointer to the `compile` method, as shown here. + +```python +# Compile the graph with a checkpointer +checkpointer = MemorySaver() +graph = workflow.compile(checkpointer=checkpointer) + +# Invoke the graph with a thread ID +config = {"configurable": {"thread_id": "1"}} +graph.invoke(input_state, config) + +# get the latest state snapshot at a later time +config = {"configurable": {"thread_id": "1"}} +graph.get_state(config) +``` + +Persistence is critical sustaining a long-running chat sessions. For example, a chat between a user and an AI assistant may have interruptions. Persistence ensures that a user can continue that particular chat session at any later point in time. However, what happens if a user initiates a new chat session with an assistant? This spawns a new thread, and the information from the previous session (thread) is not retained. This motivates the need for a memory service that can maintain data across chat sessions (threads). + +## Meta-prompting + +Meta-prompting uses an LLM to generate or refine its own prompts or instructions. This approach allows the system to dynamically update and improve its own behavior, potentially leading to better performance on various tasks. This is particularly useful for tasks where the instructions are challenging to specify a priori. + +Meta-prompting can use past information to update the prompt. As an example, this [Tweet generator](https://www.youtube.com/watch?v=Vn8A3BxfplE) uses meta-prompting to iteratively improve the summarization prompt used to generate high quality paper summaries for Twitter. In this case, we used a LangSmith dataset to house several papers that we wanted to summarize, generated summaries using a naive summarization prompt, manually reviewed the summaries, captured feedback from human review using the LangSmith Annotation Queue, and passed this feedback to a chat model to re-generate the summarization prompt. The process was repeated in a loop until the summaries met our criteria in human review. + +## Retrieving relevant information from long-term storage + +A central challenge that spans many different memory use-case can be summarized simply: how can we retrieve *relevant information* from a long-term storage system and pass it to a chat model? As an example, assume we have a system that stores a large number of specific details about a user, but the user asks a specific question related to restaurant recommendations. It would be costly to trivially extract *all* personal user information and pass it to a chat model. Instead, we want to extract only the information that is most relevant to the user's current chat interaction (e,g,. food preferences, location, etc.) and pass it to the chat model. + +There is a large body of work on retrieval that aims to address this challenge. See our tutorials focused on [RAG, or Retrieval Augmented Generation](https://python.langchain.com/v0.1/docs/how_to/rag/), our conceptual docs on [retrieval](https://python.langchain.com/docs/concepts/#retrieval), and our [open source repository](https://github.com/langchain-ai/rag-from-scratch) along with [videos](https://www.youtube.com/playlist?list=PLfaIDFEXuae2LXbO1_PKyVJiQ23ZztA0x) on this topic. diff --git a/docs/docs/how-tos/index.md b/docs/docs/how-tos/index.md index e84bf6733..57266115a 100644 --- a/docs/docs/how-tos/index.md +++ b/docs/docs/how-tos/index.md @@ -24,6 +24,7 @@ LangGraph makes it easy to persist state across graph runs. The guide below show - [How to manage conversation history](memory/manage-conversation-history.ipynb) - [How to delete messages](memory/delete-messages.ipynb) - [How to add summary conversation memory](memory/add-summary-conversation-history.ipynb) +- [How to share state between threads](memory/shared-state.ipynb) - [How to use Postgres checkpointer for persistence](persistence_postgres.ipynb) - [How to create a custom checkpointer using MongoDB](persistence_mongodb.ipynb) - [How to create a custom checkpointer using Redis](persistence_redis.ipynb) diff --git a/docs/docs/how-tos/memory/shared-state.ipynb b/docs/docs/how-tos/memory/shared-state.ipynb new file mode 100644 index 000000000..254c7bbaf --- /dev/null +++ b/docs/docs/how-tos/memory/shared-state.ipynb @@ -0,0 +1,460 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "d2eecb96-cf0e-47ed-8116-88a7eaa4236d", + "metadata": {}, + "source": [ + "# How to share state between threads\n", + "\n", + "By default, LangGraph state is scoped to a single thread. LangGraph also allows you to store information that can be **shared** across threads.\n", + "\n", + "For instance, you can persist each user’s preferences to a shared memory and reuse them in new conversational threads.\n", + "\n", + "In this guide, we will show how to construct and use a graph that has a shared memory implemented using the `Store` interface.\n", + "\n", + "
\n", + "

Note

\n", + "

\n", + " Support for the Store API that is used in this notebook was added in LangGraph v0.2.32.\n", + "

\n", + "
\n", + "\n", + "## Setup\n", + "\n", + "First, let's install the required packages and set our API keys" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3457aadf", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install -U langchain_openai langgraph" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "aa2c64a7", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "OPENAI_API_KEY: ········\n" + ] + } + ], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "51b6817d", + "metadata": {}, + "source": [ + "
\n", + "

Set up LangSmith for LangGraph development

\n", + "

\n", + " Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. \n", + "

\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "c4c550b5-1954-496b-8b9d-800361af17dc", + "metadata": {}, + "source": [ + "## Create graph\n", + "\n", + "In this example we will create a graph that will let us store information about a user's preferences. We will do so by defining an `InMemoryStore` - an object that can store data in memory and query that data. We can then pass the store object when compiling the graph. This allows each node in the graph to access the store: when you define node functions, you can define `store` keyword argument, and LangGraph will automatically pass the store object you compiled the graph with.\n", + "\n", + "When storing objects using the `Store` interface you define two things:\n", + "\n", + "* the namespace for the object, a tuple (similar to directories)\n", + "* the object key (similar to filenames)\n", + "\n", + "In our example, we'll be using `(\"memories\", )` as namespace and random UUID as key for each new memory.\n", + "\n", + "Importantly, to determine the user, we will be passing `user_id` via the config keyword argument of each node function." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a7f303d6-612e-4e34-bf36-29d4ed25d802", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Literal, TypedDict, Annotated\n", + "import uuid\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "from langchain_core.runnables import RunnableConfig\n", + "from langgraph.graph import StateGraph, MessagesState, START, END\n", + "from langgraph.store.base import BaseStore\n", + "from langgraph.store.memory import InMemoryStore\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "\n", + "\n", + "# We will give this as a tool to the agent\n", + "# This will let the agent call this tool to save a fact\n", + "class Info(TypedDict):\n", + " \"\"\"This tool should be called when you want to save a new fact about the user.\n", + "\n", + " Attributes:\n", + " fact (str): A fact about the user.\n", + " topic (str): The topic related the fact is about, i.e. Food, Location, Movies, etc.\n", + " \"\"\"\n", + "\n", + " fact: str\n", + " topic: str\n", + "\n", + "\n", + "# This is the prompt we give the agent\n", + "# We will pass known info into the prompt\n", + "# We will tell it to use the Info tool to save more\n", + "prompt = \"\"\"You are a helpful assistant that learns about users to provide better assistance.\n", + "\n", + "Current user information:\n", + "\n", + "{info}\n", + "\n", + "\n", + "Instructions:\n", + "1. Use the `Info` tool to save new information the user shares.\n", + "2. Save facts, opinions, preferences, and experiences.\n", + "3. Your goal: Improve assistance by building a user profile over time.\n", + "\n", + "Remember: Every piece of information helps you serve the user better in future interactions.\n", + "\"\"\"\n", + "\n", + "\n", + "# We give the model access to the Info tool\n", + "model = ChatOpenAI(model=\"gpt-4o-mini\").bind_tools([Info])\n", + "\n", + "\n", + "def call_model(state: MessagesState, config: RunnableConfig, *, store: BaseStore):\n", + " \"\"\"Call the model.\"\"\"\n", + " user_id = config[\"configurable\"][\"user_id\"]\n", + " memories = store.search((\"memories\", user_id))\n", + " info = \"\\n\".join([d.value[\"fact\"] for d in memories])\n", + " # Format system prompt\n", + " system_msg = prompt.format(info=info)\n", + " # Call model\n", + " response = model.invoke(\n", + " [{\"role\": \"system\", \"content\": system_msg}] + state[\"messages\"]\n", + " )\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "# Routing function to decide what to do next\n", + "# If no tool calls, then we end\n", + "# If tool calls, then we update memory\n", + "def route(state):\n", + " if len(state[\"messages\"][-1].tool_calls) == 0:\n", + " return END\n", + " else:\n", + " return \"update_memory\"\n", + "\n", + "\n", + "def update_memory(state: MessagesState, config: RunnableConfig, *, store: BaseStore):\n", + " \"\"\"Update the memory.\"\"\"\n", + " user_id = config[\"configurable\"][\"user_id\"]\n", + " memory_id = str(uuid.uuid4())\n", + " tool_calls = []\n", + " memories = {}\n", + " # Each tool call is a new memory to save\n", + " for tc in state[\"messages\"][-1].tool_calls:\n", + " # We append ToolMessages (to pass back to the LLM)\n", + " # This is needed because OpenAI requires each tool call be followed by a ToolMessage\n", + " tool_calls.append(\n", + " {\"role\": \"tool\", \"content\": \"Saved!\", \"tool_call_id\": tc[\"id\"]}\n", + " )\n", + " # We create a new memory from this tool call\n", + " store.put((\"memories\", user_id), memory_id, {\n", + " \"fact\": tc[\"args\"][\"fact\"],\n", + " \"topic\": tc[\"args\"][\"topic\"],\n", + " })\n", + " # Return the messages and memories to update the state with\n", + " return {\"messages\": tool_calls}\n", + "\n", + "\n", + "# This is the in memory checkpointer we will use\n", + "# We need this because we want to enable threads (conversations)\n", + "checkpointer = MemorySaver()\n", + "\n", + "# This is the in memory store needed to save the memories (i.e. user preferences)\n", + "in_memory_store = InMemoryStore()\n", + "\n", + "# Construct this relatively simple graph\n", + "graph = StateGraph(MessagesState)\n", + "graph.add_node(call_model)\n", + "graph.add_node(update_memory)\n", + "graph.add_edge(\"update_memory\", END)\n", + "graph.add_edge(START, \"call_model\")\n", + "graph.add_conditional_edges(\"call_model\", route, path_map=[END, \"update_memory\"])\n", + "graph = graph.compile(checkpointer=checkpointer, store=in_memory_store)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5f36c16f-d68d-456c-b0b7-5eb94fb89ca6", + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "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iAiLQz1cmR1c1DRShlvp5ZKW5SOjnjkfuLoynkaWAEF4Jla53KY3M1z7dGH1qKysudwfR0DpaJlHUQmqqpqb0JmFpe6KEuI2f4MOfotAe4A84PJnWq1UljttNb6GBtNR07BHFE3ua0faepP2nqe8r6UNDTWuip6Ojp4qSjp42ww08DAyOJjRprWtHQAAAADoAF90BERAREQEREGDc7NR3jxU1cDZZKScVNNL3PglALedju9p5XOadd7XuadtcQcayVtWzktty7Se408DHS1sdK6OnqN7Bew7IaSQSYy7bd+sacdusC72WlvcMDKmPmdTzsqYHte5jo5WHbXBzSCPWCAdOa5zTtriCGei0+PXp9e2ahrpaIX2iazx6mo5XPbGXgljhzNa7lcASOmthzdktJW4QEREBERAREQEREBERAREQaO/zT108VmpWyaqmOFZVU9W2GajhLXBr2jRcXOcOVugNaeeYFoB3FPA2mgjhYXlkbQwGR5e4gDXVziST9pJJWhxCLxqS63aVlpkqaurkhFXa3F/aQQvcyJsjz3vb6XM0dGuLgPWTRICIiAiIgIiICIiAiIgn8sqJbNHBe2T1DKagJfW01LRiokqYC0jWgOccjiJPQJOmuHK7Y1vwQRsdQv1T+El8Fl8nPbdXOtkrqAVN506epbHoNm5x/CBzSDznqevN6W0FAiIgIi0l4zfHsfqvFrne7fQVOubsaipYx+vp5Sd6+1Z00VVzamLycW7RS3nTw72ntXvbPvTzp4d7T2r3tn3rbs2NyT0lbTkqUUt508O9p7V72z7086eHe09q97Z96bNjck9JLTkqUUt508O9p7V72z7086eHe09q97Z96bNjck9JLTkqVqMmy6xYXQMrshvVusVE+QQtqbnVR08bnkEhoc8gEkNcdd/Q/Qtb508O9p7V72z71zrwgqbAuOPCW/4lVZNZxNVQl9HM+qj/calnpRP3vp6XQ/YSmzY3JPSS05KvhLxBxbI7cLTasjxC43WN1TUyUGK18U0bIjUOIk5GuLgTzsL3a1zvP0hdDXg3/w6uHli4QY7fcpyu6UFsya6yGhhpamoa2SCljd1JBP+0eN93cxp9a9k+dPDvae1e9s+9NmxuSeklpyVKKW86eHe09q97Z96edPDvae1e9s+9NmxuSeklpyVKKW86eHe09q97Z96edPDvae1e9s+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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Image, display\n", + "display(Image(graph.get_graph().draw_mermaid_png()))" + ] + }, + { + "cell_type": "markdown", + "id": "552d4e33-556d-4fa5-8094-2a076bc21529", + "metadata": {}, + "source": [ + "## Run graph on one thread\n", + "\n", + "We can now run the graph on one thread and give it some information" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "18bd8679-3a73-4033-bfb4-5093ac1f5d7f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'call_model': {'messages': [AIMessage(content='Hello! How can I assist you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 178, 'total_tokens': 188, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_74ba47b4ac', 'finish_reason': 'stop', 'logprobs': None}, id='run-8e7b57b7-8231-4811-945d-a2cf2e1adba3-0', usage_metadata={'input_tokens': 178, 'output_tokens': 10, 'total_tokens': 188})]}}\n", + "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_mpPvip6DbMYkZueShwDDws3y', 'function': {'arguments': '{\"fact\":\"User likes pepperoni pizza\",\"topic\":\"Food\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 200, 'total_tokens': 221, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_74ba47b4ac', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-5cd7b512-071b-49ac-9932-de0130774a1e-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'User likes pepperoni pizza', 'topic': 'Food'}, 'id': 'call_mpPvip6DbMYkZueShwDDws3y', 'type': 'tool_call'}], usage_metadata={'input_tokens': 200, 'output_tokens': 21, 'total_tokens': 221})]}}\n", + "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_mpPvip6DbMYkZueShwDDws3y'}]}}\n", + "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Jqn1cbXFQcVl4hGrbB660kw1', 'function': {'arguments': '{\"fact\":\"User just moved to San Francisco\",\"topic\":\"Location\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 22, 'prompt_tokens': 246, 'total_tokens': 268, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_74ba47b4ac', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-9490584d-1678-47db-bb0a-d0797a7af7c3-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'User just moved to San Francisco', 'topic': 'Location'}, 'id': 'call_Jqn1cbXFQcVl4hGrbB660kw1', 'type': 'tool_call'}], usage_metadata={'input_tokens': 246, 'output_tokens': 22, 'total_tokens': 268})]}}\n", + "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_Jqn1cbXFQcVl4hGrbB660kw1'}]}}\n" + ] + } + ], + "source": [ + "config = {\"configurable\": {\"thread_id\": \"1\", \"user_id\": \"1\"}}\n", + "\n", + "# First let's just say hi to the AI\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}, config, stream_mode=\"updates\"\n", + "):\n", + " print(update)\n", + "\n", + "# Let's continue the conversation (by passing the same config) and tell the AI we like pepperoni pizza\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"i like pepperoni pizza\"}]},\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", + " print(update)\n", + "\n", + "# Let's continue the conversation even further (by passing the same config) and tell the AI we live in SF\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"i also just moved to SF\"}]},\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", + " print(update)" + ] + }, + { + "cell_type": "markdown", + "id": "16f19db5-ff4a-4666-9be5-ef791c39be0a", + "metadata": {}, + "source": [ + "We can now inspect our in-memory store and verify that we have in fact saved the memories for the user:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "57335ef9-c6fc-40d8-8dd7-dce8a600d05b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'fact': 'User likes pepperoni pizza', 'topic': 'Food'}\n", + "{'fact': 'User just moved to San Francisco', 'topic': 'Location'}\n" + ] + } + ], + "source": [ + "for memory in in_memory_store.search((\"memories\", \"1\")):\n", + " print(memory.value)" + ] + }, + { + "cell_type": "markdown", + "id": "b8c416fa-086a-491d-a7d3-57091f6413e3", + "metadata": {}, + "source": [ + "## Run graph on a different thread\n", + "\n", + "We can now run the graph on a different thread and see that it remembers facts about the user (specifically that the user likes pepperoni pizza and lives in SF):" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e240f025-ff8b-4d17-beb7-2420c0575dd9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'call_model': {'messages': [AIMessage(content=\"Since you like pepperoni pizza, I can recommend a few places in San Francisco where you can enjoy some delicious pizza. Here are some options:\\n\\n1. **Tony's Pizza Napoletana** - Located in North Beach, this restaurant is famous for its award-winning pizzas. You can find a variety of styles, including classic pepperoni.\\n\\n2. **Pizza Orgasmica** - Known for its fun atmosphere and a wide range of pizza options, including pepperoni, this spot is a local favorite.\\n\\n3. **Little Star Pizza** - This place offers deep-dish and thin-crust options. Their pepperoni pizza is highly recommended.\\n\\n4. **Pizzeria Delfina** - A popular spot known for its artisanal pizzas, including a delicious pepperoni option.\\n\\n5. **The Pizza Place on Noriega** - A casual eatery offering classic pizza options in the Sunset District.\\n\\nWould you like more options, or are you interested in a specific neighborhood for dinner?\", additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 198, 'prompt_tokens': 205, 'total_tokens': 403, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_74ba47b4ac', 'finish_reason': 'stop', 'logprobs': None}, id='run-e5d7ff2e-1665-49c4-83dd-ec9b3e693b3c-0', usage_metadata={'input_tokens': 205, 'output_tokens': 198, 'total_tokens': 403})]}}\n" + ] + } + ], + "source": [ + "config = {\"configurable\": {\"thread_id\": \"2\", \"user_id\": \"1\"}}\n", + "\n", + "for update in graph.stream(\n", + " {\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\",\n", + " }\n", + " ]\n", + " },\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", + " print(update)" + ] + }, + { + "cell_type": "markdown", + "id": "091995d3", + "metadata": {}, + "source": [ + "Perfect! The AI recommended restaurants in SF, and included a pizza restaurant at the top of it's list.\n", + "\n", + "Notice that the `messages` in this new thread do NOT contain the messages from the previous thread since we didn't store them as shared values across the `user_id`. However, the `info` we saved in the previous thread was saved since we passed in the same `user_id` in this new thread.\n", + "\n", + "Let's now run the graph for another user to verify that the preferences of the first user are self contained:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f9bf2c15", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'call_model': {'messages': [AIMessage(content=\"To help you better, could you please share your location or the area you're interested in? Additionally, do you have any preferences for cuisine or dietary restrictions?\", additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 32, 'prompt_tokens': 192, 'total_tokens': 224, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_74ba47b4ac', 'finish_reason': 'stop', 'logprobs': None}, id='run-fba82c81-703a-4f5e-bd1a-1edf5876425e-0', usage_metadata={'input_tokens': 192, 'output_tokens': 32, 'total_tokens': 224})]}}\n" + ] + } + ], + "source": [ + "config = {\"configurable\": {\"thread_id\": \"3\", \"user_id\": \"2\"}}\n", + "\n", + "for update in graph.stream(\n", + " {\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\",\n", + " }\n", + " ]\n", + " },\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", + " print(update)" + ] + }, + { + "cell_type": "markdown", + "id": "b7086cea", + "metadata": {}, + "source": [ + "Perfect! The graph has forgotten all of the previous preferences and has to ask the user for it's location and dietary preferences.\n", + "\n", + "We can also verify that there are no memories stored for user \"2\":" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "dea8be21-c4a0-434c-87ab-706c138beb23", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "in_memory_store.search((\"memories\", \"2\"))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 07ac4410e..93b373a67 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -142,6 +142,7 @@ nav: - Manage conversation history: how-tos/memory/manage-conversation-history.ipynb - Delete messages: how-tos/memory/delete-messages.ipynb - Add summary of the conversation history: how-tos/memory/add-summary-conversation-history.ipynb + - Share state between threads: how-tos/memory/shared-state.ipynb - Use Postgres checkpointer for persistence: how-tos/persistence_postgres.ipynb - Create custom checkpointer using MongoDB: how-tos/persistence_mongodb.ipynb - Create custom checkpointer using Redis: how-tos/persistence_redis.ipynb @@ -197,6 +198,7 @@ nav: - LangGraph Glossary: concepts/low_level.md - Common Agentic Patterns: concepts/agentic_concepts.md - Human-in-the-Loop: concepts/human_in_the_loop.md + - Memory: concepts/memory.md - Multi-Agent Systems: concepts/multi_agent.md - Persistence: concepts/persistence.md - Streaming: concepts/streaming.md diff --git a/libs/checkpoint-postgres/Makefile b/libs/checkpoint-postgres/Makefile index aadb22cd7..b41e55257 100644 --- a/libs/checkpoint-postgres/Makefile +++ b/libs/checkpoint-postgres/Makefile @@ -16,10 +16,10 @@ test: EXIT_CODE=$$?; \ make stop-postgres; \ exit $$EXIT_CODE - +TEST ?= . test_watch: make start-postgres; \ - poetry run ptw .; \ + poetry run ptw $(TEST); \ EXIT_CODE=$$?; \ make stop-postgres; \ exit $$EXIT_CODE diff --git a/libs/checkpoint-postgres/langgraph/store/postgres/base.py b/libs/checkpoint-postgres/langgraph/store/postgres/base.py index f5ad3408d..d979c5fbc 100644 --- a/libs/checkpoint-postgres/langgraph/store/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/store/postgres/base.py @@ -40,12 +40,9 @@ logger = logging.getLogger(__name__) MIGRATIONS = [ """ -CREATE EXTENSION IF NOT EXISTS ltree; -""", - """ CREATE TABLE IF NOT EXISTS store ( -- 'prefix' represents the doc's 'namespace' - prefix ltree NOT NULL, + prefix text NOT NULL, key text NOT NULL, value jsonb NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -54,8 +51,8 @@ CREATE TABLE IF NOT EXISTS store ( ); """, """ --- For faster listing of namespaces & lookups by namespace with prefix/suffix matching -CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING gist (prefix); +-- For faster lookups by prefix +CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops); """, ] @@ -93,7 +90,7 @@ class BasePostgresStore(BaseStore, Generic[C]): FROM store WHERE prefix = %s AND key IN ({keys_to_query}) """ - params = (_namespace_to_ltree(namespace), *keys) + params = (_namespace_to_text(namespace), *keys) results.append((query, params, namespace, items)) return results @@ -120,7 +117,7 @@ class BasePostgresStore(BaseStore, Generic[C]): query = ( f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})" ) - params = (_namespace_to_ltree(namespace), *keys) + params = (_namespace_to_text(namespace), *keys) queries.append((query, params)) if inserts: values = [] @@ -129,7 +126,7 @@ class BasePostgresStore(BaseStore, Generic[C]): values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)") insertion_params.extend( [ - _namespace_to_ltree(op.namespace), + _namespace_to_text(op.namespace), op.key, Jsonb(op.value), ] @@ -152,11 +149,11 @@ class BasePostgresStore(BaseStore, Generic[C]): queries: list[tuple[str, Sequence]] = [] for _, op in search_ops: query = """ - SELECT prefix, key, value, created_at, updated_at, prefix + SELECT prefix, key, value, created_at, updated_at FROM store - WHERE prefix <@ %s + WHERE prefix LIKE %s """ - params: list = [_namespace_to_ltree(op.namespace_prefix)] + params: list = [f"{_namespace_to_text(op.namespace_prefix)}%"] if op.filter: filter_conditions = [] @@ -181,22 +178,39 @@ class BasePostgresStore(BaseStore, Generic[C]): ) -> list[tuple[str, Sequence]]: queries: list[tuple[str, Sequence]] = [] for _, op in list_ops: - query = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store" - # https://www.postgresql.org/docs/current/ltree.html - # The length of a label path cannot exceed 65535 labels. - params: list[Any] = [op.max_depth if op.max_depth is not None else 65536] + query = """ + SELECT DISTINCT ON (truncated_prefix) truncated_prefix, prefix + FROM ( + SELECT + prefix, + CASE + WHEN %s::integer IS NOT NULL THEN + (SELECT STRING_AGG(part, '.' ORDER BY idx) + FROM ( + SELECT part, ROW_NUMBER() OVER () AS idx + FROM UNNEST(REGEXP_SPLIT_TO_ARRAY(prefix, '\.')) AS part + LIMIT %s::integer + ) subquery + ) + ELSE prefix + END AS truncated_prefix + FROM store + """ + params: list[Any] = [op.max_depth, op.max_depth] conditions = [] if op.match_conditions: for condition in op.match_conditions: if condition.match_type == "prefix": - conditions.append("prefix ~ %s::lquery") - lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*" - params.append(lquery_pattern) + conditions.append("prefix LIKE %s") + params.append( + f"{_namespace_to_text(condition.path, handle_wildcards=True)}%" + ) elif condition.match_type == "suffix": - conditions.append("prefix ~ %s::lquery") - lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}" - params.append(lquery_pattern) + conditions.append("prefix LIKE %s") + params.append( + f"%{_namespace_to_text(condition.path, handle_wildcards=True)}" + ) else: logger.warning( f"Unknown match_type in list_namespaces: {condition.match_type}" @@ -204,11 +218,12 @@ class BasePostgresStore(BaseStore, Generic[C]): if conditions: query += " WHERE " + " AND ".join(conditions) + query += ") AS subquery " query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s" params.extend([op.limit, op.offset]) - queries.append((query, params)) + return queries @@ -386,13 +401,17 @@ class PostgresStore(BasePostgresStore[Connection]): class Row(TypedDict): key: str value: Any - prefix: bytes + prefix: str created_at: datetime updated_at: datetime -def _namespace_to_ltree(namespace: tuple[str, ...]) -> str: - """Convert namespace tuple to ltree-compatible string.""" +def _namespace_to_text( + namespace: tuple[str, ...], handle_wildcards: bool = False +) -> str: + """Convert namespace tuple to text string.""" + if handle_wildcards: + namespace = tuple("%" if val == "*" else val for val in namespace) return ".".join(namespace) @@ -435,7 +454,9 @@ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any: return orjson.loads(cast(bytes, content)) -def _decode_ns_bytes(namespace: Union[str, bytes]) -> tuple[str, ...]: +def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]: + if isinstance(namespace, list): + return tuple(namespace) if isinstance(namespace, bytes): namespace = namespace.decode()[1:] return tuple(namespace.split(".")) diff --git a/libs/checkpoint-postgres/tests/test_async_store.py b/libs/checkpoint-postgres/tests/test_async_store.py index ad6504312..e7a7b31a1 100644 --- a/libs/checkpoint-postgres/tests/test_async_store.py +++ b/libs/checkpoint-postgres/tests/test_async_store.py @@ -80,9 +80,9 @@ async def test_abatch_order(store: AsyncPostgresStore) -> None: async def execute_side_effect(query: str, *params: Any) -> None: # My super sophisticated database. - if "WHERE prefix <@" in query: + if "SELECT prefix, key," in query: cursor.fetchall = mock_search_cursor.fetchall - elif "SELECT DISTINCT subltree" in query: + elif "SELECT DISTINCT ON (truncated_prefix)" in query: cursor.fetchall = mock_list_namespaces_cursor.fetchall elif "WHERE prefix = %s AND key" in query: cursor.fetchall = mock_get_cursor.fetchall @@ -390,7 +390,6 @@ class TestAsyncPostgresStore: max_depth_result = await store.alist_namespaces(max_depth=3) assert all([len(ns) <= 3 for ns in max_depth_result]) - max_depth_result = await store.alist_namespaces( max_depth=4, prefix=[test_pref, "*", "documents"] ) diff --git a/libs/checkpoint-postgres/tests/test_store.py b/libs/checkpoint-postgres/tests/test_store.py index bc952a862..add9fb1c5 100644 --- a/libs/checkpoint-postgres/tests/test_store.py +++ b/libs/checkpoint-postgres/tests/test_store.py @@ -78,9 +78,9 @@ def test_batch_order(store: PostgresStore) -> None: def execute_side_effect(query: str, *params: Any) -> None: # My super sophisticated database. - if "WHERE prefix <@" in query: + if "SELECT prefix, key, value" in query: cursor.fetchall = mock_search_cursor.fetchall - elif "SELECT DISTINCT subltree" in query: + elif "SELECT DISTINCT ON (truncated_prefix)" in query: cursor.fetchall = mock_list_namespaces_cursor.fetchall elif "WHERE prefix = %s AND key" in query: cursor.fetchall = mock_get_cursor.fetchall