LangGraph Module 4: Building an LLM Agent with Tools from Scratch
Introduction
The standard agent loop is simple: the model decides whether to call a tool, a tool runs, and the result goes back to the model until it can answer. Building it by hand in LangGraph shows exactly what create_agent automates and gives you full control to customize it.
Prerequisites
- Modules 1 to 3 completed
- pip install langchain "langchain[anthropic]" and an API key
Step-by-Step Guide
Step 1: Set up the model and tools
Tools are defined with the @tool decorator, and bind_tools tells the model about them. The tools_by_name dictionary lets the tool node find a function from the name the model chose.
from langchain.chat_models import init_chat_model
from langchain.tools import tool
model = init_chat_model("anthropic:claude-opus-5-5", max_tokens=16000)
@tool
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers."""
return a * b
tools = [add, multiply]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)Step 2: Write the LLM node
The node calls the model with a system message plus the conversation so far and returns the reply as a state update. MessagesState appends it to the history.
from langchain.messages import SystemMessage, ToolMessage
from langgraph.graph import MessagesState
SYSTEM = SystemMessage(
content="You do arithmetic. Always use the tools for calculations."
)
def llm_call(state: MessagesState) -> dict:
reply = model_with_tools.invoke([SYSTEM] + state["messages"])
return {"messages": [reply]}Step 3: Write the tool node
If the last message asked for tools, run each requested tool and return a ToolMessage for each, using the matching tool_call_id so the model can connect results to requests.
def tool_node(state: MessagesState) -> dict:
results = []
for call in state["messages"][-1].tool_calls:
observation = tools_by_name[call["name"]].invoke(call["args"])
results.append(
ToolMessage(content=str(observation), tool_call_id=call["id"])
)
return {"messages": results}Step 4: Wire the loop
After the LLM node, a conditional edge sends the graph to the tool node if the model requested tools, or to the end if it produced a final answer. The tool node loops back to the LLM node.
from langgraph.graph import StateGraph, START, END
def should_continue(state: MessagesState) -> str:
if state["messages"][-1].tool_calls:
return "tool_node"
return END
builder = StateGraph(MessagesState)
builder.add_node("llm_call", llm_call)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_call")
builder.add_conditional_edges("llm_call", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "llm_call")
agent = builder.compile()Step 5: Run it and read the trace
Invoke with a message. Printing the history shows each turn: the question, the model's tool requests, the tool results, and the final answer. Compare this loop with create_agent from the LangChain course; they are the same pattern.
result = agent.invoke(
{"messages": [{"role": "user", "content": "What is (3 + 4) * 5?"}]}
)
for message in result["messages"]:
message.pretty_print()FAQs
- Why build this by hand if create_agent exists?
- To understand it and to customize it: adding an approval step, a planning node, extra state, or different routing is straightforward once you own the loop.
- What stops this loop from running forever?
- The model eventually answers without requesting a tool. As a safety net, add a step counter to state and route to END past a limit, as shown in Module 3.
Summary
An agent is an LLM node, a tool node, and a conditional edge that loops until no tool is requested. Owning this graph lets you add custom steps wherever you need them.
Comments
Log in or create a free account to join the discussion.