{ "cells": [ { "cell_type": "markdown", "id": "dc082433", "metadata": {}, "source": [ "# LangGraph 101\n", "\n", "[LLMs](https://python.langchain.com/docs/concepts/chat_models/) make it possible to embed intelligence into a new class of applications. [LangGraph](https://langchain-ai.github.io/langgraph/) is a framework to help build applications with LLMs. Here, we will overview the basics of LangGraph, explain its benefits, show how to use it to build workflows / agents, and show how it works with [LangChain](https://www.langchain.com/) / [LangSmith](https://docs.smith.langchain.com/).\n", "\n", "![ecosystem](./img/ecosystem.png)\n", "\n", "## Chat models\n", "\n", "[Chat models](https://python.langchain.com/docs/concepts/chat_models/) are the foundation of LLM applications. They are typically accessed through a chat interface that takes a list of [messages](https://python.langchain.com/docs/concepts/messages/) as input and returns a [message](https://python.langchain.com/docs/concepts/messages/) as output. LangChain provides [a standardized interface for chat models](https://python.langchain.com/api_reference/langchain/chat_models/langchain.chat_models.base.init_chat_model.html), making it easy to [access many different providers](https://python.langchain.com/docs/integrations/chat/)." ] }, { "cell_type": "code", "execution_count": null, "id": "cecc2b24", "metadata": {}, "outputs": [], "source": [ "from dotenv import load_dotenv\n", "load_dotenv(\"../.env\", override=True)" ] }, { "cell_type": "code", "execution_count": 2, "id": "e0ee8f6c", "metadata": {}, "outputs": [], "source": [ "from langchain.chat_models import init_chat_model\n", "llm = init_chat_model(\"openai:gpt-4.1\", temperature=0)" ] }, { "cell_type": "markdown", "id": "50777b0b", "metadata": {}, "source": [ "## Running the model\n", "\n", "The `init_chat_model` interface provides [standardized](https://python.langchain.com/docs/concepts/runnables/) methods for using chat models, which include:\n", "- `invoke()`: A single input is transformed into an output.\n", "- `stream()`: Outputs are [streamed](https://python.langchain.com/docs/concepts/streaming/#stream-and-astream) as they are produced. " ] }, { "cell_type": "code", "execution_count": 3, "id": "a28159d5", "metadata": {}, "outputs": [], "source": [ "result = llm.invoke(\"What is an agent?\")" ] }, { "cell_type": "code", "execution_count": null, "id": "41137023", "metadata": {}, "outputs": [], "source": [ "type(result)" ] }, { "cell_type": "code", "execution_count": null, "id": "dc1d00eb", "metadata": {}, "outputs": [], "source": [ "from rich.markdown import Markdown\n", "Markdown(result.content)" ] }, { "cell_type": "markdown", "id": "9a24d8ef", "metadata": {}, "source": [ "## Tools\n", "\n", "[Tools](https://python.langchain.com/docs/concepts/tools/) are utilities that can be called by a chat model. In LangChain, creating tools can be done using the `@tool` decorator, which transforms Python functions into callable tools. It will automatically infer the tool's name, description, and expected arguments from the function definition. You can also use [Model Context Protocol (MCP) servers](https://github.com/langchain-ai/langchain-mcp-adapters) as LangChain-compatible tools. " ] }, { "cell_type": "code", "execution_count": 6, "id": "afdff275", "metadata": {}, "outputs": [], "source": [ "from langchain.tools import tool\n", "\n", "@tool\n", "def write_email(to: str, subject: str, content: str) -> str:\n", " \"\"\"Write and send an email.\"\"\"\n", " # Placeholder response - in real app would send email\n", " return f\"Email sent to {to} with subject '{subject}' and content: {content}\"" ] }, { "cell_type": "code", "execution_count": null, "id": "c52ec55b-0b60-4b0c-95d4-ff528a64694e", "metadata": {}, "outputs": [], "source": [ "type(write_email)" ] }, { "cell_type": "code", "execution_count": null, "id": "23a40647-3d48-4760-aabe-144d627de110", "metadata": {}, "outputs": [], "source": [ "write_email.args" ] }, { "cell_type": "code", "execution_count": null, "id": "abd85ae4-9d4c-4efa-9577-aca96e9f22cd", "metadata": {}, "outputs": [], "source": [ "Markdown(write_email.description)" ] }, { "cell_type": "markdown", "id": "c8a6b427", "metadata": {}, "source": [ "## Tool Calling\n", "\n", "Tools can be [called](https://python.langchain.com/docs/concepts/tool_calling/) by LLMs. When a tool is bound to the model, the model can choose to call the tool by returning a structured output with tool arguments. We use the `bind_tools` method to augment an LLM with tools.\n", "\n", "![tool-img](img/tool_call_detail.png)\n", "\n", "Providers often have [parameters such as `tool_choice`](https://python.langchain.com/docs/how_to/tool_choice/) to enforce calling specific tools. `any` will select at least one of the tools.\n", "\n", "In addition, we can [set `parallel_tool_calls=False`](https://python.langchain.com/docs/how_to/tool_calling_parallel/) to ensure the model will only call one tool at a time." ] }, { "cell_type": "code", "execution_count": 10, "id": "bfa57bc4", "metadata": {}, "outputs": [], "source": [ "# Connect tools to a chat model\n", "model_with_tools = llm.bind_tools([write_email], tool_choice=\"any\", parallel_tool_calls=False)\n", "\n", "# The model will now be able to call tools\n", "output = model_with_tools.invoke(\"Draft a response to my boss (boss@company.ai) about tomorrow's meeting\")" ] }, { "cell_type": "code", "execution_count": null, "id": "7985eab6-9e6b-4fa5-8027-52d32886b97e", "metadata": {}, "outputs": [], "source": [ "type(output)" ] }, { "cell_type": "code", "execution_count": null, "id": "ea0ce030-e760-4679-838f-d88d1480664e", "metadata": {}, "outputs": [], "source": [ "output" ] }, { "cell_type": "code", "execution_count": null, "id": "717779cb", "metadata": {}, "outputs": [], "source": [ "# Extract tool calls and execute them\n", "args = output.tool_calls[0]['args']\n", "args" ] }, { "cell_type": "code", "execution_count": null, "id": "09f85694", "metadata": {}, "outputs": [], "source": [ "# Call the tool\n", "result = write_email.invoke(args)\n", "Markdown(result)" ] }, { "cell_type": "markdown", "id": "b6f9c52a", "metadata": {}, "source": [ "![basic_prompt](img/tool_call.png)\n", "\n", "## Workflows\n", " \n", "There are many patterns for building applications with LLMs. \n", "\n", "[We can embed LLM calls into pre-defined workflows](https://langchain-ai.github.io/langgraph/tutorials/workflows/), giving the system more agency to make decisions. \n", "\n", "As an example, we could add a router step to determine whether to write an email or not.\n", "\n", "![workflow_example](img/workflow_example.png)\n", "\n", "## Agents\n", "\n", "We can further increase agency, allowing the LLM to dynamically direct its own tool usage. \n", "\n", "[Agents](https://langchain-ai.github.io/langgraph/tutorials/workflows/#agent) are typically implemented as tool calling in a loop, where the output of each tool call is used to inform the next action.\n", "\n", "![agent_example](img/agent_example.png)\n", "\n", "Agents are well suited to open-ended problems where it's difficult to predict the *exact* steps needed in advance.\n", " \n", "Workflows are often appropriate when the control flow can easily be defined in advance. \n", "\n", "![workflow_v_agent](img/workflow_v_agent.png)\n", "\n", "## What is LangGraph? \n", "\n", "[LangGraph](https://langchain-ai.github.io/langgraph/concepts/high_level/) provides low-level supporting infrastructure that sits underneath *any* workflow or agent. \n", "\n", "It does not abstract prompts or architecture, and provides a few benefits:\n", "\n", "- **Control**: Make it easy to define and / or combine agents and workflows.\n", "- **Persistence**: Provide a way to persist the state of a graph, which enables both memory and human-in-the-loop.\n", "- **Testing, Debugging, and Deployment**: Provide an easy onramp for testing, debugging, and deploying applications.\n", "\n", "### Control\n", "\n", "LangGraph lets you define your application as a graph with:\n", "\n", "1. *State*: What information do we need to track over the course of the application?\n", "2. *Nodes*: How do we want to update this information over the course of the application?\n", "3. *Edges*: How do we want to connect these nodes together?\n", "\n", "We can use the [`StateGraph` class](https://langchain-ai.github.io/langgraph/concepts/low_level/#graphs) to initialize a LangGraph graph with a [`State` object](https://langchain-ai.github.io/langgraph/concepts/low_level/#state).\n", "\n", "`State` defines the schema for information we want to track over the course of the application. \n", "\n", "This can be any object with `getattr()` in python, such as a dictionary, dataclass, or Pydantic object: \n", "\n", "- TypeDict is fastest but doesn’t support defaults\n", "- Dataclass is basically as fast, supports dot syntax `state.foo`, and has defaults. \n", "- Pydantic is slower (especially with custom validators) but gives type validation." ] }, { "cell_type": "code", "execution_count": 15, "id": "3319290a", "metadata": {}, "outputs": [], "source": [ "from typing import TypedDict\n", "from langgraph.graph import StateGraph, START, END\n", "\n", "class StateSchema(TypedDict):\n", " request: str\n", " email: str\n", "\n", "workflow = StateGraph(StateSchema)" ] }, { "cell_type": "markdown", "id": "b84bedb9", "metadata": {}, "source": [ "Each node is simply a python function or typescript code. This gives us full control over the logic inside each node.\n", "\n", "They receive the current state, and return a dictionary to update the state.\n", "\n", "By default, [state keys are overwritten](https://langchain-ai.github.io/langgraph/how-tos/state-reducers/). \n", "\n", "However, you can [define custom update logic](https://langchain-ai.github.io/langgraph/concepts/low_level/#reducers). \n", "\n", "![nodes_edges](img/nodes_edges.png)" ] }, { "cell_type": "code", "execution_count": 16, "id": "d5e79c1f", "metadata": {}, "outputs": [], "source": [ "def write_email_node(state: StateSchema) -> StateSchema:\n", " # Imperative code that processes the request\n", " output = model_with_tools.invoke(state[\"request\"])\n", " args = output.tool_calls[0]['args']\n", " email = write_email.invoke(args)\n", " return {\"email\": email}" ] }, { "cell_type": "markdown", "id": "737c8040", "metadata": {}, "source": [ "Edges connect nodes together. \n", "\n", "We specify the control flow by adding edges and nodes to our state graph. " ] }, { "cell_type": "code", "execution_count": 17, "id": "554e0d8b", "metadata": {}, "outputs": [], "source": [ "workflow = StateGraph(StateSchema)\n", "workflow.add_node(\"write_email_node\", write_email_node)\n", "workflow.add_edge(START, \"write_email_node\")\n", "workflow.add_edge(\"write_email_node\", END)\n", "\n", "app = workflow.compile()" ] }, { "cell_type": "code", "execution_count": null, "id": "7cc79b40", "metadata": {}, "outputs": [], "source": [ "app.invoke({\"request\": \"Draft a response to my boss (boss@company.ai) about tomorrow's meeting\"})" ] }, { "cell_type": "markdown", "id": "2446dea9", "metadata": {}, "source": [ "Routing between nodes can be done [conditionally](https://langchain-ai.github.io/langgraph/concepts/low_level/#conditional-edges) using a simple function. \n", "\n", "The return value of this function is used as the name of the node (or list of nodes) to send the state to next. \n", "\n", "You can optionally provide a dictionary that maps the `should_continue` output to the name of the next node." ] }, { "cell_type": "code", "execution_count": 19, "id": "f29b05bf", "metadata": {}, "outputs": [], "source": [ "from typing import Literal\n", "from langgraph.graph import MessagesState\n", "from email_assistant.utils import show_graph\n", "\n", "def call_llm(state: MessagesState) -> MessagesState:\n", " \"\"\"Run LLM\"\"\"\n", "\n", " output = model_with_tools.invoke(state[\"messages\"])\n", " return {\"messages\": [output]}\n", "\n", "def run_tool(state: MessagesState):\n", " \"\"\"Performs the tool call\"\"\"\n", "\n", " result = []\n", " for tool_call in state[\"messages\"][-1].tool_calls:\n", " observation = write_email.invoke(tool_call[\"args\"])\n", " result.append({\"role\": \"tool\", \"content\": observation, \"tool_call_id\": tool_call[\"id\"]})\n", " return {\"messages\": result}\n", "\n", "def should_continue(state: MessagesState) -> Literal[\"run_tool\", \"__end__\"]:\n", " \"\"\"Route to tool handler, or end if Done tool called\"\"\"\n", " \n", " # Get the last message\n", " messages = state[\"messages\"]\n", " last_message = messages[-1]\n", " \n", " # If the last message is a tool call, check if it's a Done tool call\n", " if last_message.tool_calls:\n", " return \"run_tool\"\n", " # Otherwise, we stop (reply to the user)\n", " return END\n", "\n", "workflow = StateGraph(MessagesState)\n", "workflow.add_node(\"call_llm\", call_llm)\n", "workflow.add_node(\"run_tool\", run_tool)\n", "workflow.add_edge(START, \"call_llm\")\n", "workflow.add_conditional_edges(\"call_llm\", should_continue, {\"run_tool\": \"run_tool\", END: END})\n", "workflow.add_edge(\"run_tool\", END)\n", "\n", "# Run the workflow\n", "app = workflow.compile()" ] }, { "cell_type": "code", "execution_count": null, "id": "fd9f07af-c633-4527-b2d9-52c6451dc9c5", "metadata": {}, "outputs": [], "source": [ "show_graph(app)" ] }, { "cell_type": "code", "execution_count": null, "id": "dadbafde", "metadata": {}, "outputs": [], "source": [ "result = app.invoke({\"messages\": [{\"role\": \"user\", \"content\": \"Draft a response to my boss (boss@company.ai) confirming that I want to attend Interrupt!\"}]})\n", "for m in result[\"messages\"]:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "a78b232d", "metadata": {}, "source": [ "With these low level components, you can build many many different workflows and agents. See [this tutorial](https://langchain-ai.github.io/langgraph/tutorials/workflows/)!\n", "\n", "Because agents are such a common pattern, [LangGraph](https://langchain-ai.github.io/langgraph/tutorials/workflows/#pre-built) has [a pre-built agent](https://langchain-ai.github.io/langgraph/agents/overview/?ref=blog.langchain.dev#what-is-an-agent) abstraction.\n", "\n", "With LangGraph's [pre-built method](https://langchain-ai.github.io/langgraph/tutorials/workflows/#pre-built), we just pass in the LLM, tools, and prompt. " ] }, { "cell_type": "code", "execution_count": null, "id": "5a317ad8", "metadata": {}, "outputs": [], "source": [ "from langgraph.prebuilt import create_react_agent\n", "\n", "agent = create_react_agent(\n", " model=llm,\n", " tools=[write_email],\n", " prompt=\"Respond to the user's request using the tools provided.\" \n", ")\n", "\n", "# Run the agent\n", "result = agent.invoke(\n", " {\"messages\": [{\"role\": \"user\", \"content\": \"Draft a response to my boss (boss@company.ai) confirming that I want to attend Interrupt!\"}]}\n", ")\n", "\n", "for m in result[\"messages\"]:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "3c6e506f", "metadata": {}, "source": [ "### Persistence\n", "\n", "#### Threads\n", "\n", "It can be very useful to allow agents to pause during long running tasks.\n", "\n", "LangGraph has a built-in persistence layer, implemented through checkpointers, to enable this. \n", "\n", "When you compile graph with a checkpointer, the checkpointer saves a [checkpoint](https://langchain-ai.github.io/langgraph/concepts/persistence/#checkpoints) of the graph state at every step. \n", "\n", "Checkpoints are saved to a thread, which can be accessed after graph execution completes.\n", "\n", "![checkpointer](img/checkpoints.png)\n", "\n", "We compile the graph with a [checkpointer](https://langchain-ai.github.io/langgraph/concepts/persistence/#checkpointer-libraries).\n" ] }, { "cell_type": "code", "execution_count": 23, "id": "9a72377e", "metadata": {}, "outputs": [], "source": [ "from langgraph.checkpoint.memory import InMemorySaver\n", "\n", "agent = create_react_agent(\n", " model=llm,\n", " tools=[write_email],\n", " prompt=\"Respond to the user's request using the tools provided.\",\n", " checkpointer=InMemorySaver()\n", ")\n", "\n", "config = {\"configurable\": {\"thread_id\": \"1\"}}\n", "result = agent.invoke({\"messages\": [{\"role\": \"user\", \"content\": \"What are some good practices for writing emails?\"}]}, config)\n", " " ] }, { "cell_type": "code", "execution_count": null, "id": "10984007", "metadata": {}, "outputs": [], "source": [ "# Get the latest state snapshot\n", "config = {\"configurable\": {\"thread_id\": \"1\"}}\n", "state = agent.get_state(config)\n", "for message in state.values['messages']:\n", " message.pretty_print()" ] }, { "cell_type": "code", "execution_count": null, "id": "7f23ac58", "metadata": {}, "outputs": [], "source": [ "# Continue the conversation\n", "result = agent.invoke({\"messages\": [{\"role\": \"user\", \"content\": \"Good, let's use lesson 3 to craft a response to my boss confirming that I want to attend Interrupt\"}]}, config)\n", "for m in result['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "code", "execution_count": null, "id": "5f09fe50", "metadata": {}, "outputs": [], "source": [ "# Continue the conversation\n", "result = agent.invoke({\"messages\": [{\"role\": \"user\", \"content\": \"I like this, let's write the email to boss@company.ai\"}]}, config)\n", "for m in result['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "4ae8a5d2-cf8a-4465-8d1b-96bb6c6276cf", "metadata": {}, "source": [ "#### Interrupts\n", "\n", "In LangGraph, we can also use [interrupts](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/wait-user-input/) to stop graph execution at specific points.\n", "\n", "Often this is used to collect input from a user and continue execution with collected input." ] }, { "cell_type": "code", "execution_count": 27, "id": "52c17b60-9474-49a5-b4a1-583b0dc8bba7", "metadata": {}, "outputs": [], "source": [ "from typing_extensions import TypedDict\n", "from langgraph.graph import StateGraph, START, END\n", "\n", "from langgraph.types import Command, interrupt\n", "from langgraph.checkpoint.memory import InMemorySaver\n", "\n", "class State(TypedDict):\n", " input: str\n", " user_feedback: str\n", "\n", "def step_1(state):\n", " print(\"---Step 1---\")\n", " pass\n", "\n", "def human_feedback(state):\n", " print(\"---human_feedback---\")\n", " feedback = interrupt(\"Please provide feedback:\")\n", " return {\"user_feedback\": feedback}\n", "\n", "def step_3(state):\n", " print(\"---Step 3---\")\n", " pass\n", "\n", "builder = StateGraph(State)\n", "builder.add_node(\"step_1\", step_1)\n", "builder.add_node(\"human_feedback\", human_feedback)\n", "builder.add_node(\"step_3\", step_3)\n", "builder.add_edge(START, \"step_1\")\n", "builder.add_edge(\"step_1\", \"human_feedback\")\n", "builder.add_edge(\"human_feedback\", \"step_3\")\n", "builder.add_edge(\"step_3\", END)\n", "\n", "# Set up memory\n", "memory = InMemorySaver()\n", "\n", "# Add\n", "graph = builder.compile(checkpointer=memory)" ] }, { "cell_type": "code", "execution_count": null, "id": "57c94cb7-067c-4bc5-be33-b615d8e5775e", "metadata": { "scrolled": true }, "outputs": [], "source": [ "show_graph(graph)" ] }, { "cell_type": "code", "execution_count": null, "id": "028372b5-88ae-4f13-813d-22430a697f07", "metadata": {}, "outputs": [], "source": [ "# Input\n", "initial_input = {\"input\": \"hello world\"}\n", "\n", "# Thread\n", "thread = {\"configurable\": {\"thread_id\": \"1\"}}\n", "\n", "# Run the graph until the first interruption\n", "for event in graph.stream(initial_input, thread, stream_mode=\"updates\"):\n", " print(event)\n", " print(\"\\n\")" ] }, { "cell_type": "markdown", "id": "f142d467-7b4c-4a04-bad5-bf3fbe953b84", "metadata": {}, "source": [ "To resume from an interrupt, we can use [the `Command` object](https://langchain-ai.github.io/langgraph/how-tos/command/). \n", "\n", "We'll use it to resume the graph from the interrupted state, passing the value to return from the interrupt call to `resume`. " ] }, { "cell_type": "code", "execution_count": null, "id": "d90374d3-65a1-4658-82ee-a16cc2835cf4", "metadata": {}, "outputs": [], "source": [ "# Continue the graph execution\n", "for event in graph.stream(\n", " Command(resume=\"go to step 3!\"),\n", " thread,\n", " stream_mode=\"updates\",\n", "):\n", " print(event)\n", " print(\"\\n\")" ] }, { "cell_type": "markdown", "id": "8639a518", "metadata": {}, "source": [ "### Tracing\n", "\n", "When we are using LangChain or LangGraph, LangSmith logging [will work out of the box](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_langgraph) with the following environment variables set:\n", "\n", "```\n", "export LANGSMITH_TRACING=true\n", "export LANGSMITH_API_KEY=\"\"\n", "```" ] }, { "cell_type": "markdown", "id": "9cb2a3e5", "metadata": {}, "source": [ "Here is the LangSmith trace from above agent execution:\n", "\n", "https://smith.langchain.com/public/6f77014f-d054-44ed-aa2c-8b06ceab689f/r\n", "\n", "We can see that the agent is able to continue the conversation from the previous state because we used a checkpointer." ] }, { "cell_type": "markdown", "id": "f0269214", "metadata": {}, "source": [ "### Deployment\n", "\n", "We can also deploy our graph using [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/). \n", "\n", "This creates a server [with an API](https://langchain-ai.github.io/langgraph/cloud/reference/api/api_ref.html) that we can use to interact with our graph and an interactive IDE, LangGraph [Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).\n", "\n", "We simply need to ensure our project has [a structure](https://langchain-ai.github.io/langgraph/concepts/application_structure/) like this:\n", "\n", "```\n", "my-app/\n", "├── src/email_assistant # all project code lies within here\n", "│ └── langgraph101.py # code for constructing your graph\n", "├── .env # environment variables\n", "├── langgraph.json # configuration file for LangGraph\n", "└── pyproject.toml # dependencies for your project\n", "```\n", "\n", "The `langgraph.json` file specifies the dependencies, graphs, environment variables, and other settings required to start a LangGraph server.\n", "\n", "To test this, let's deploy `langgraph_101.py`. We have it in our `langgraph.json` file in this repo:\n", "\n", "```\n", " \"langgraph101\": \"./src/email_assistant/langgraph_101.py:app\",\n", "```\n", "\n", "For LangGraph Platform, there are a range of [deployment options](https://langchain-ai.github.io/langgraph/tutorials/deployment/): \n", " \n", "* Local deployments can be started with `langgraph dev` from the root directory of the repo. Checkpoints are saved to the local filesystem.\n", "* There are also various [self-hosted options](https://langchain-ai.github.io/langgraph/tutorials/deployment/#other-deployment-options). \n", "* For hosted deployments, checkpoints are saved to Postgres using a postgres [checkpointer](https://langchain-ai.github.io/langgraph/concepts/persistence/#checkpointer-libraries). \n", "\n", "Test: \n", "```\n", "Draft a response to my boss (boss@company.ai) confirming that I want to attent Interrupt!\n", "```" ] }, { "cell_type": "markdown", "id": "f3644093", "metadata": {}, "source": [ "Here we can see a visualization of the graph as well as the graph state in Studio.\n", "\n", "![langgraph_studio](img/langgraph_studio.png)\n", "\n", "Also, you can see API docs for the local deployment here:\n", "\n", "http://127.0.0.1:2024/docs" ] } ], "metadata": { "jupytext": { "cell_metadata_filter": "-all", "main_language": "python", "notebook_metadata_filter": "-all" }, "kernelspec": { "display_name": ".venv", "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.6" } }, "nbformat": 4, "nbformat_minor": 5 }