Lesson 4 - Agents with LangGraph
Welcome to Agents with LangGraph
In Module 8 you built an agent from scratch: you defined a tool schema by hand, called the model, checked the stop reason, dispatched the tool, fed the result back, and looped until the model stopped asking for tools. That loop — think, act, observe — was the whole point, and you wrote every line of it. In this lesson you’ll build the same agent with LangGraph, where that loop is already there. You define your tools, hand them to one function, and call invoke(). Everything you wrote by hand is provided for you, and because you wrote it once, you’ll recognize every piece of what the framework does.
By the end of this lesson, you will be able to:
- Define tools cleanly with the
@tooldecorator - Create an agent in one call with
create_agent(model, tools=[...]) - Read the message trajectory and spot the same think-act-observe loop
- Give an agent memory across turns with a checkpointer and a
thread_id
We’re on LangChain 1.x and LangGraph 1.x. Let’s build one.
Defining Tools with @tool
In Module 8 a tool was a hand-written JSON schema — a dictionary with name, description, and a nested input_schema listing every property and its type. It worked, but you maintained that schema separately from the Python function that actually ran. LangChain’s @tool decorator collapses the two: you write one normal function, and the decorator builds the schema from its name, type hints, and docstring.
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current temperature in Celsius for a city."""
return {"Tokyo": "14", "Paris": "9"}.get(city, "unknown")
@tool
def calculator(expression: str) -> str:
"""Evaluate a basic arithmetic expression."""
return str(eval(expression, {"__builtins__": {}}, {}))
print("name:", get_weather.name)
print("description:", get_weather.description)
print("args:", get_weather.args)name: get_weather
description: Get the current temperature in Celsius for a city.
args: {'city': {'title': 'City', 'type': 'string'}}Look at where each piece of the schema came from. The name is the function name. The description is the docstring — which is exactly the text the model reads to decide when to call the tool, so write it for the model, not just for yourself. The args schema is built from the city: str type hint. This is the same schema you wrote by hand in Module 8, only now it’s generated from the function and can never drift out of sync with it. The decorated object is still callable, and the agent will run it for you.
Creating an Agent and Reading the Trajectory
With your tools defined, the agent itself is a single call. create_agent takes a model and a list of tools and returns a runnable agent. Under the hood it is a LangGraph graph: a model node and a tools node, wired into the looping, stateful structure you saw in Lesson 1. You don’t write the loop — you describe the pieces, and LangGraph runs the loop for you.
from langchain_anthropic import ChatAnthropic
from langchain.agents import create_agent
model = ChatAnthropic(model="claude-haiku-4-5", max_tokens=400)
agent = create_agent(model, tools=[get_weather, calculator])
result = agent.invoke(
{"messages": [("human", "What is the temp in Tokyo in Fahrenheit? Use tools.")]}
)
for m in result["messages"]:
tc = getattr(m, "tool_calls", None)
if tc:
print(type(m).__name__, "tool_calls=", [(c["name"], c["args"]) for c in tc])
else:
print(type(m).__name__, repr(m.content))HumanMessage 'What is the temp in Tokyo in Fahrenheit? Use tools.'
AIMessage tool_calls= [('get_weather', {'city': 'Tokyo'})]
ToolMessage '14'
AIMessage tool_calls= [('calculator', {'expression': '14 * 9/5 + 32'})]
ToolMessage '57.2'
AIMessage 'The current temperature in Tokyo is **14°C**, which converts to **57.2°F** (Fahrenheit).'The result is a dictionary, and result["messages"] is the full trajectory — every step the agent took, in order. Read it top to bottom and you’ll see the exact loop you hand-wrote:
HumanMessage— your question goes in.AIMessagewithtool_calls— the model thinks and decides to act: callget_weatherwith{'city': 'Tokyo'}. This is your Module 8stop_reason == "tool_use"step.ToolMessage— the observation: the tool ran and returned'14'. This is yourtool_resultbeing fed back.- Another
AIMessagewithtool_calls— the model observes 14°C, then thinks again and callscalculatorto convert it. - Another
ToolMessage—'57.2'comes back. - A final
AIMessagewith text and notool_calls— the model is done, so the loop ends and this is your answer.
That’s two full turns through think-act-observe before the agent decided it had enough to answer. You didn’t write the while loop, the stop-reason check, or the tool dispatch — LangGraph did. But you can read the whole thing because it’s the same loop, just printed as a list of messages instead of as your own print statements.
This is the Module 8 loop, now provided
The trajectory you’re reading is exactly the agent loop you built by hand: call the model, see if it asked for a tool, run the tool, feed the result back, repeat until it stops asking. An AIMessage with tool_calls is the “act” step; a ToolMessage is the “observe” step; an AIMessage with only text is the loop exit. LangGraph gives you the loop, the state, and the tool dispatch — your job is just to define the tools and read the messages.
Adding Memory with a Checkpointer
The agent above forgets everything the moment invoke() returns. Each call starts with an empty trajectory. To carry state across calls — so a follow-up question can rely on what was said earlier — you add a checkpointer. A checkpointer saves the agent’s state (the message history) after each invocation and reloads it on the next one, keyed by a conversation id you choose. The simplest one, InMemorySaver, keeps that state in memory for the life of the process.
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model, tools=[calculator], checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "abc"}}
agent.invoke({"messages": [("human", "My favorite number is 7. Remember it.")]}, cfg)
r2 = agent.invoke({"messages": [("human", "Multiply my favorite number by 6.")]}, cfg)
print(r2["messages"][-1].content)Your favorite number (7) multiplied by 6 equals **42**!Two separate invoke() calls, and the second one knew the answer was based on 7 — even though “7” appears nowhere in the second message. The key is the thread_id. Both calls pass the same config, {"configurable": {"thread_id": "abc"}}, so the checkpointer loads the saved history from the first call before running the second. The agent sees the full conversation, finds the favorite number, calls calculator with 7 * 6, and answers 42. Change the thread_id to a different value and you’d start a fresh conversation with no memory of 7 — which is exactly how you keep separate users’ conversations apart.
This is the memory you assembled by hand in Module 8 by appending to a messages list and passing it back in. Here you pass a thread_id instead of carrying the list yourself, and the checkpointer holds the history between calls.
What the thread_id does
The checkpointer persists state between invocations; the thread_id decides which conversation a call belongs to. Same thread_id means “keep talking in this conversation” — the prior messages are reloaded. A different thread_id means “start a new conversation” — a clean, empty history. In a real app you’d use one thread_id per user or per chat session.
Practice Exercises
Exercise 1: Add a tool
Write a third tool with @tool called word_count that takes a text: str and returns the number of words as a string. What three things does the decorator pull from your function to build the schema?
Hint
The body is return str(len(text.split())). The decorator takes the name from the function name (word_count), the description from the docstring (write one that tells the model when to use it), and the argument schema from the text: str type hint. Add it to the tools=[...] list in create_agent and the agent can call it.
Exercise 2: Trace the loop
In the weather trajectory, the agent produced two AIMessages with tool_calls and two ToolMessages before the final text answer. Map each of those four messages onto the “think / act / observe” steps you wrote in Module 8, and explain what tells the loop to stop.
Hint
Each AIMessage with tool_calls is a think-then-act step (the model decided to call a tool); each ToolMessage is the observe step (the tool’s result fed back in). The loop stops when an AIMessage comes back with text and no tool_calls — the same condition as your Module 8 stop_reason != "tool_use" check.
Exercise 3: Two conversations
Using the memory agent, call it once with thread_id "alice" saying her favorite number is 3, then once with thread_id "bob" asking “What is my favorite number times 10?” What does Bob’s agent say, and why?
Hint
Bob’s agent has no idea — "bob" is a different thread_id, so the checkpointer loads an empty history for it. Alice’s number lives only under "alice". The agent will say it doesn’t know Bob’s favorite number or ask for it. That isolation is exactly why you key memory by thread_id.
Summary
You built the same agent as Module 8, but with the loop provided. You defined tools with the @tool decorator, which generates the schema from the function’s name, type hints, and docstring instead of you writing JSON by hand. You created the agent in one call with create_agent(model, tools=[...]) — a LangGraph graph under the hood — and read result["messages"] to see the trajectory: HumanMessage, then AIMessages with tool_calls (act), ToolMessages (observe), and a final AIMessage with only text (done). That’s the think-act-observe loop you wrote by hand, now running for you. Finally you added memory with an InMemorySaver checkpointer and a thread_id, and a follow-up that depended on an earlier turn answered correctly across two separate calls.
Key Concepts
@tool— turns a normal function into a tool, building the schema from its name, type hints, and docstring.create_agent— builds an agent (a LangGraph graph) from a model and a list of tools in one call.- Trajectory —
result["messages"]: the ordered list ofHumanMessage/AIMessage/ToolMessagesteps the agent took. tool_calls— the field on anAIMessagethat holds the tool requests; anAIMessagewith none is the loop’s exit.- Checkpointer — saves and reloads agent state between invocations;
InMemorySaverkeeps it in memory. thread_id— the conversation key that decides which saved history a call uses.
Why This Matters
Hand-writing an agent loop teaches you how agents work; using create_agent is how you ship them. The framework handles the loop, the state machine, the tool dispatch, and the memory plumbing — all the parts that are easy to get subtly wrong — so you can focus on which tools to give the agent and how to describe them. And because you built the loop yourself first, you’re not trusting a black box: you can read any trajectory, see exactly which step misbehaved, and drop back to the raw mechanics when you need to. That combination of framework speed and ground-truth understanding is what production agent work actually looks like.
Next Steps
Continue to Lesson 5 - Guided Project: LangGraph Agent
Put it all together: build a complete, memory-equipped LangGraph agent with multiple tools in a guided, hands-on project.
Back to Module Overview
Return to the LangChain & LangGraph module overview
Continue Building Your Skills
You can now define tools with @tool, spin up an agent with create_agent, read its trajectory, and give it memory across turns — the whole Module 8 loop, handed to you. Next you’ll bring these pieces together in a guided project, building a complete LangGraph agent from the ground up.