LangChain: From One Model Call to an Agent
Start with one HTTP call to a model. Add the message list, a tool, and a loop, one idea at a time, then see which of those pieces LangChain names for you.
One question, one answer
Your code sends some text to a model over HTTPS and gets some text back.
Your code sends a request. Inside it is a list with one item: the question.
Python
response = client.chat.completions.create( model=MODEL, messages=[ {"role": "user", "content": "What is a Kubernetes Pod?"}, ],)What goes over the wire
POST /api/v1/chat/completions{ "model": "…", "messages": [ {"role": "user", "content": "What is a Kubernetes Pod?"} ]}The model sends back one item of the same shape, written by it. Then it forgets the exchange.
Python
message = response.choices[0].messageprint(message.content) # A Pod is the smallest unit Kubernetes # schedules: one or more containers that # share a network address and storage.What came back
{ "choices": [{ "message": { "role": "assistant", "content": "A Pod is the smallest unit…" }, "finish_reason": "stop" }], "usage": {"prompt_tokens": 14, "completion_tokens": 31}}That arrow was three things. A library in your process built the request, a routing service received it, and a provider ran the model.
Where each one is named
from openai import OpenAI # the SDK client = OpenAI( base_url="https://openrouter.ai/api/v1", # the router api_key=os.environ["OPENROUTER_API_KEY"],)MODEL = "anthropic/…" # the model, at its providerA model is an HTTP endpoint
Nothing in this exchange is specific to AI plumbing. Your code makes a POST request and reads JSON. The reply has a role and some content, and the provider keeps no record of the conversation for the next call.
- You send
- A list of messages
- You get
- One message, plus token counts
- Kept between calls
- Nothing
response.choices[0].message.contentThe SDK, the router and the model fail separately: a bad key is rejected by the router, a rate limit comes from the provider, a timeout can be any of them. The wire format shown here is Chat Completions, which OpenRouter accepts. OpenAI's newer Responses API names the fields differently.
THE POINT TO KEEPUnder every framework there is one HTTP call: a list of messages goes out, one message comes back.
The request is a list, and each item says who wrote it
Three kinds of item are enough for a conversation.
You wrote the first item, the user wrote the second, and the model wrote the third. The label on each one is called its role.
The same list in Python
messages = [ {"role": "system", "content": "You are a Kubernetes…"}, {"role": "user", "content": "What is a Pod?"}, {"role": "assistant", "content": "A Pod is the smallest…"},]Three roles, three authors
The system message is where you say how the model should behave. The user message carries what the person asked. An assistant message is something the model said earlier, which your code chose to put back into the list.
- system
- Your standing instructions
- user
- The person's words
- assistant
- The model's earlier replies
{"role": …, "content": …}A system message is instructions the model usually follows. It is not memory, not a database, and not a permission check: text arriving later in the list can talk the model out of it.
THE POINT TO KEEPA conversation is a list of role-tagged messages that your code assembles before every call.
The model remembers nothing, so you send everything again
Ask three questions in a row and watch what each request has to carry.
The first question goes out on its own.
Tokens sent
- Request 130
Example token counts. Real counts depend on the model's tokenizer.
"Can it contain two containers?" only makes sense after the first exchange, so your code sends that exchange again with the new question on the end.
Tokens sent
- Request 130
- Request 295
Example token counts. Real counts depend on the model's tokenizer.
The third request carries both earlier exchanges. Each call is billed for the whole list, not for the new line.
Tokens sent
- Request 130
- Request 295
- Request 3170
Example token counts. Real counts depend on the model's tokenizer.
The whole of "memory"
messages.append(reply) # what the model just saidmessages.append(question) # what the user asks nextreply = call_model(messages) # everything, every timeYour list is the memory
The model answers a follow-up correctly because the earlier turns are in front of it again. If your code drops a turn, the model never saw it. If the process exits, the list is gone unless you stored it.
- Who keeps history
- Your code
- What grows
- Input tokens, on every call
- What limits it
- The model's context window
response.usage.prompt_tokensLong conversations are trimmed or summarised for this reason. Whatever stays in the list also stays in front of the model, including anything a tool returned earlier.
THE POINT TO KEEPHistory is context you resend, and you pay for all of it on every call.
The model asks for a function. Your code runs it.
Give the model one function it could use, ask a question that needs it, and follow what happens.
There are two things here: a function that runs, and a description of it. Only the description will be sent.
The function: runs in your process
def get_cluster_name(): return "kind-dev"Its description: shown to the model
{"type": "function", "function": { "name": "get_cluster_name", "description": "Get the current Kubernetes cluster", "parameters": {"type": "object", "properties": {}} }}The request now carries the question and the description.
Python
response = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOLS, # the descriptions)The reply has no answer in it. It names the function and stops.
NOTHING HAS RUNThe model wrote a request. It has no connection to your function, and until your code acts, nothing happens.
What came back
{"role": "assistant", "content": null, "tool_calls": [{ "id": "call_1", "type": "function", "function": {"name": "get_cluster_name", "arguments": "{}"} }]}Your code reads the name, finds the function, and calls it.
Python
call = message.tool_calls[0]function = TOOLS_BY_NAME[call.function.name]result = function() # "kind-dev"The result goes back as a new item, tagged with the id of the request it answers. The model is called a second time.
Python
messages.append(message) # the model's requestmessages.append({ "role": "tool", "tool_call_id": call.id, # which request this answers "content": result,})With the result in the list, the model can answer in words.
Tool calling is a reply format
A model with tool descriptions has two ways to reply: text, or a structured request naming a function and its arguments. This is called tool calling, or function calling. The request is data. Your process decides whether to act on it.
- The model produces
- A name and arguments
- Your code does
- The lookup, the call, the result message
- A fourth role
- tool: a result written by your code
response.choices[0].message.tool_callsOrder matters. The assistant message holding the request must be in the list before the tool message that answers it, and the ids must match, or the provider rejects the next call. One reply can also ask for several tools at once; each gets its own result message.
OpenAI: function calling guide ↗LangChain: models and bind_tools ↗
THE POINT TO KEEPThe model never executes a tool. It writes a request, and your application decides what happens next.
Arguments arrive as text, and text can be wrong
A tool that takes arguments gets them as a string the model wrote. Three things can come back.
The string parses, the names and types match the description, and the function runs.
RUNSget_pod_status(namespace="payments", pod_name="payments-api-7d9f")
What the model sent
"arguments": "{\"namespace\": \"payments\", \"pod_name\": \"payments-api-7d9f\"}"Python
arguments = json.loads(call.function.arguments)check(arguments, SCHEMA) # names, types, requiredresult = get_pod_status(**arguments)The string parses, but pod_name is missing. Your check catches it, and the error goes back to the model as the tool's result.
DOES NOT RUNThe model reads the error on its next call and can try again with both arguments.
What the model sent
"arguments": "{\"namespace\": \"payments\"}"What your code sends back
{"role": "tool", "tool_call_id": "call_2", "content": "error: pod_name is required"}The reply hit its length limit halfway through the arguments, so the string is not JSON at all.
DOES NOT RUNjson.loads raises. Catch it and return an error, or the whole run ends on one malformed reply.
What the model sent
"arguments": "{\"namespace\": \"payments\", \"pod_na""finish_reason": "length"Python
try: arguments = json.loads(call.function.arguments)except json.JSONDecodeError: result = "error: arguments were not valid JSON"The description guides. Your check decides.
The description uses JSON Schema to say which arguments exist, their types, and which are required. It steers what the model writes. It does not make the output correct, so the code that receives the string has to parse it and validate it before anything runs.
- A schema is
- Guidance to the model, and a contract you can check against
- Valid arguments are
- Well-formed, not necessarily permitted
- Errors go
- Back to the model, as the tool result
"required": ["namespace", "pod_name"]Some providers offer a strict mode that constrains the reply to the schema. Chat Completions requests are not strict by default. Either way, a well-formed call can still name a namespace this user should never read. That check comes later on this page.
THE POINT TO KEEPA schema shapes what the model writes. Validation in your code decides what runs.
Behind the function is the Kubernetes API
Swap the toy function for one that lists pods. The model's side does not change at all.
The function makes the same request kubectl get pods -n payments would make, with whatever credential your process holds.
Python
from kubernetes import client, config config.load_kube_config() # or the pod's ServiceAccountcore = client.CoreV1Api() def list_pods(namespace: str): pods = core.list_namespaced_pod(namespace).itemsThe API server replies with complete Pod objects: spec, status, managed fields, every condition.
If you returned it as is
- Full Pod objects5,200
Example size for a handful of pods. All of it would be model input, on this call and every later one.
The function keeps what a diagnosis needs and returns that.
What the model is sent
- Full Pod objects5,200
- Four fields per pod45
Example sizes. The result becomes a message, so it is resent on every later call.
Python
return [ {"name": pod.metadata.name, "phase": pod.status.phase, "ready": all(c.ready for c in statuses(pod)), "restarts": sum(c.restart_count for c in statuses(pod))} for pod in pods ]Tool result
[{"name": "payments-api-7d9f", "phase": "Running", "ready": false, "restarts": 8}]A tool is ordinary code with ordinary credentials
The function reaches the cluster the way any client does: an HTTPS request to the API server, authenticated with a kubeconfig or a ServiceAccount token. Kubernetes authorises that credential. It has no idea a model was involved.
- Same API as
- kubectl get pods -n payments
- Authorised by
- RBAC, for the credential your process uses
- The model sees
- Only what the function returns
GET /api/v1/namespaces/payments/podsA pod reported as Running can still be failing: Running is the phase, and it only says a container has started at least once. The ready flag and restart count are what show the trouble here.
Kubernetes: API concepts ↗Kubernetes: Pod lifecycle ↗LangChain: tools ↗
THE POINT TO KEEPReturn the few facts the model needs. Every byte a tool returns is input on every later call.
A real question takes several round trips
Ask why payments-api is failing. The model has two tools and decides what to look at.
The question goes out with the system message and both tool descriptions.
What model call 1 receives
- System prompt40
- Tool schemas160
- Conversation10
Example token counts. Real counts depend on the model's tokenizer.
The model wants to see what is in the namespace first.
What model call 1 receives
- System prompt40
- Tool schemas160
- Conversation10
Example token counts. Real counts depend on the model's tokenizer.
Your code runs list_pods and appends the result. One pod is not ready and has restarted eight times.
What model call 2 receives
- System prompt40
- Tool schemas160
- Conversation70
Example token counts. Real counts depend on the model's tokenizer.
The whole list goes out again. This time the model asks for details of the pod that looked wrong.
What model call 2 receives
- System prompt40
- Tool schemas160
- Conversation70
Example token counts. Real counts depend on the model's tokenizer.
The container is waiting in CrashLoopBackOff, and its previous run ended with exit code 137, reason OOMKilled.
What model call 3 receives
- System prompt40
- Tool schemas160
- Conversation165
Example token counts. Real counts depend on the model's tokenizer.
Third call, six items. The model has what it needs and answers in text.
What model call 3 receives
- System prompt40
- Tool schemas160
- Conversation165
Example token counts. Real counts depend on the model's tokenizer.
One question, three model calls
Nobody told the model to list pods before asking for details. It chose that order from the tool descriptions and from what the first result showed. Your code did the same thing each time round: run what was asked, append the result, call again.
- Model calls
- 3
- Tool runs
- 2, both by your code
- Items in the list at the end
- 7
last_exit_code=137 last_reason=OOMKilledCrashLoopBackOff is what the container is doing now: waiting before its next restart. Why it crashed is recorded on the previous run, as the last termination reason. OOMKilled there means the container went over its memory limit.
THE POINT TO KEEPThe model picks the next tool from what it has seen so far. Your code runs it and reports back.
Fold those round trips up and you have a loop
The three calls you just followed are one pattern repeated: call the model, run what it asks for, call it again.
Round the left side while the model keeps asking for tools, out through the right side when it answers in text.
The loop
messages = [system, question]for _ in range(MAX_STEPS): # your limit reply = call_model(messages, TOOLS) messages.append(reply) # keep the request if not reply.tool_calls: return reply.content # plain text: done for call in reply.tool_calls: # may be several result = run_tool(call) # your code runs it messages.append(tool_message(call.id, result))return "Stopped: step limit reached."Kubernetes refuses the request. The function returns the error as text, and the model reads it like any other result and explains it.
THE LOOP KEEPS GOINGAn exception here would end the run. An error string gives the model something to report: "I can't read pods in that namespace."
The loop
messages = [system, question]for _ in range(MAX_STEPS): # your limit reply = call_model(messages, TOOLS) messages.append(reply) # keep the request if not reply.tool_calls: return reply.content # plain text: done for call in reply.tool_calls: # may be several result = run_tool(call) # your code runs it messages.append(tool_message(call.id, result))return "Stopped: step limit reached."A confused model can keep requesting tools. The loop has no natural end, so yours needs a limit.
STOPPED BY YOUR CODEWithout MAX_STEPS this run would continue, and bill, until something outside it gave up.
The loop
messages = [system, question]for _ in range(MAX_STEPS): # your limit reply = call_model(messages, TOOLS) messages.append(reply) # keep the request if not reply.tool_calls: return reply.content # plain text: done for call in reply.tool_calls: # may be several result = run_tool(call) # your code runs it messages.append(tool_message(call.id, result))return "Stopped: step limit reached."This loop is what people call an agent
An agent is a model, some tools, the message list, and a loop that keeps calling the model until it stops asking for tools. The model steers. Everything that actually happens is done by the code around it.
- The model decides
- Which tool, which arguments, when it has enough
- Your loop decides
- Whether to run it, how often, when to give up
- State
- The message list
if not reply.tool_calls: return reply.contentThe limit, the tool lookup, argument checks, timeouts and permissions all live in this loop. A prebuilt agent still has them; they are just configured instead of written.
THE POINT TO KEEPAn agent is a loop around a model and tools, and the loop belongs to your code.
LangChain gives the same pieces one spelling
Nothing new happens in this view. The request, the reply and the tool result get standard classes.
Everything so far, written against one provider's JSON.
By hand
messages = [{"role": "user", "content": question}]response = client.chat.completions.create( model=MODEL, messages=messages, tools=SCHEMAS)message = response.choices[0].messagecall = message.tool_calls[0]arguments = json.loads(call.function.arguments)result = TOOLS_BY_NAME[call.function.name](**arguments)messages.append(message)messages.append({"role": "tool", "tool_call_id": call.id, "content": result})The same exchange. The picture has not changed; the objects travelling along it have names.
| By hand | LangChain | What you stop writing |
|---|---|---|
| {"role": …} dicts | SystemMessage, HumanMessage, AIMessage, ToolMessage | Per-provider message shapes |
| A JSON schema per function | @tool | The schema |
| Provider client and response parsing | A chat model class with .invoke() | Digging through choices[0] |
| json.loads on the arguments | AIMessage.tool_calls, already parsed | The parse step |
| A {"role": "tool"} dict | ToolMessage from tool.invoke(call) | Matching the id by hand |
With LangChain
messages = [HumanMessage(question)]reply = model_with_tools.invoke(messages) # an AIMessagecall = reply.tool_calls[0] # {"name", "args", "id"}result = TOOLS_BY_NAME[call["name"]].invoke(call)messages.append(reply)messages.append(result) # already a ToolMessageA standard layer over many provider APIs
Each provider names these things differently on the wire. LangChain gives messages, models and tools one interface, with a provider package underneath that does the translation. Change the provider and the loop you wrote stays the same.
- langchain-core
- Messages, tools, the Runnable interface
- Provider packages
- langchain-openai, langchain-anthropic, langchain-openrouter, …
- Still yours
- The loop
init_chat_model("…", model_provider="openrouter")LangChain's documentation recommends the dedicated langchain-openrouter package for OpenRouter. Pointing ChatOpenAI at OpenRouter's base URL works for plain chat, but fields a router adds may not survive.
LangChain: messages ↗LangChain: models and bind_tools ↗LangChain: tools ↗
THE POINT TO KEEPLangChain standardises the pieces around the model call. It does not change what the call is.
@tool writes the description from the function
The JSON you wrote by hand earlier is built from three things the function already has.
The function's name becomes the tool's name.
You write
@tooldef get_pod_details(namespace: str, pod_name: str) -> str: """Show one pod's container state, last exit code and reason.""" …The model is sent
{"type": "function", "function": { "name": "get_pod_details", "description": "Show one pod's container state, last exit code and reason.", "parameters": { "type": "object", "properties": {"namespace": {"type": "string"}, "pod_name": {"type": "string"}}, "required": ["namespace", "pod_name"]}}}The docstring becomes the description. The model reads it to decide when to use the tool, so it is prompt text.
You write
@tooldef get_pod_details(namespace: str, pod_name: str) -> str: """Show one pod's container state, last exit code and reason.""" …The model is sent
{"type": "function", "function": { "name": "get_pod_details", "description": "Show one pod's container state, last exit code and reason.", "parameters": { "type": "object", "properties": {"namespace": {"type": "string"}, "pod_name": {"type": "string"}}, "required": ["namespace", "pod_name"]}}}The type hints become the argument schema. An argument with no default is required.
You write
@tooldef get_pod_details(namespace: str, pod_name: str) -> str: """Show one pod's container state, last exit code and reason.""" …The model is sent
{"type": "function", "function": { "name": "get_pod_details", "description": "Show one pod's container state, last exit code and reason.", "parameters": { "type": "object", "properties": {"namespace": {"type": "string"}, "pod_name": {"type": "string"}}, "required": ["namespace", "pod_name"]}}}Three parts of a function become three parts of a schema
The decorator turns the function into a tool object. Calling tool.invoke(call) with a tool-call dict validates the arguments against this schema, runs the function, and wraps the return value in a ToolMessage with the matching id.
- Function name
- Tool name
- Docstring
- Description the model reads
- Type hints
- Argument schema, checked before the function runs
get_pod_details.name, .description, .argsWrite the docstring for the model: say when to call the tool and what it returns. A missing argument raises a validation error before your function body runs.
THE POINT TO KEEPName, docstring and type hints are the tool's contract. Write them for the model that will read them.
bind_tools attaches descriptions. It runs nothing.
This is the same step you saw with one toy function, under its LangChain name.
bind_tools returns a copy of the model that will attach the tool descriptions to every request.
Python
model_with_tools = model.bind_tools( [list_pods, get_pod_details])The request carries messages and descriptions. The reply is an AIMessage with empty content and one tool call.
NOTHING HAS RUNlist_pods has not been called. The reply only says the model would like it to be.
Python
reply = model_with_tools.invoke(messages)reply
AIMessage( content="", tool_calls=[{ "name": "list_pods", "args": {"namespace": "payments"}, "id": "call_1", }],)Outside an agent, running the tool and appending its ToolMessage is still your code's job.
Python
messages.append(reply)for call in reply.tool_calls: tool = TOOLS_BY_NAME[call["name"]] messages.append(tool.invoke(call)) # a ToolMessageWhat bind_tools does and does not do
It makes the tool descriptions part of each request, so the model can reply with a structured tool call. It does not connect the model to your functions. The arguments in reply.tool_calls are already parsed into a dict, which is one step fewer than the raw API.
- Does
- Send the descriptions with every request
- Does not
- Execute a tool, ever
- tool_choice
- Can force a tool call, or one specific tool
reply.tool_calls[0]["args"]Some providers offer tools they run on their own servers, such as web search. Those come back already executed inside the reply. Tools you define are always run by you.
THE POINT TO KEEPBinding a tool gives the model a way to ask. Execution stays in your process.
The pieces snap together in a line, and only in a line
Prompts, models and parsers share one interface, so they can be joined with a pipe.
Each box takes an input and returns an output through the same three methods. LangChain calls anything with this interface a Runnable.
| Method | Does |
|---|---|
| invoke(input) | One input, one output |
| stream(input) | Yields the output in chunks as it is produced |
| batch([inputs]) | Runs several inputs concurrently |
The pipe feeds each output into the next input. The result is itself a Runnable, so the whole chain can be invoked, streamed or batched.
Python
chain = prompt | model | parser chain.invoke({"question": "What is a Pod?"}) # "A Pod is one or more containers…"The agent needs to go from the model to the tools and back to the model, as many times as it takes. A pipe only runs forward.
A PIPE CANNOT LOOPprompt | model | tools runs each step once and ends. "Again, until the model stops asking" has nowhere to go.
Good for pipelines, silent about loops
Composing Runnables with | is called LCEL, the LangChain Expression Language. It describes data flowing one way: fill a prompt, call a model, parse the result. That covers a lot of useful programs. It has no way to repeat a step until a condition holds.
- Fits
- Fixed steps known in advance
- Does not fit
- Repeating, branching on results, pausing
- Also Runnables
- Tools, retrievers, and compiled LangGraph graphs
parser.invoke(model.invoke(prompt.invoke(x)))This is why the loop earlier was a plain Python for loop even though every piece inside it was a LangChain object.
LangChain reference: Runnable ↗LangChain: models and bind_tools ↗
THE POINT TO KEEPLCEL composes straight pipelines. An agent needs a cycle, which is a different kind of structure.
create_agent is the loop, already written
Hand it a model and tools, and it builds the loop you wrote by hand.
The loop from earlier: call the model, run the tools it asks for, repeat until it answers.
By hand
messages = [system, question]for _ in range(MAX_STEPS): # your limit reply = call_model(messages, TOOLS) messages.append(reply) # keep the request if not reply.tool_calls: return reply.content # plain text: done for call in reply.tool_calls: # may be several result = run_tool(call) # your code runs it messages.append(tool_message(call.id, result))return "Stopped: step limit reached."The same loop, built for you. The picture is identical.
With create_agent
from langchain.agents import create_agent agent = create_agent( model, tools=[list_pods, get_pod_details], system_prompt="You are a read-only Kubernetes…",)result = agent.invoke({"messages": [ {"role": "user", "content": "Why is payments-api failing?"}]})result["messages"][-1].textWhat comes back is a graph with a model node and a tools node. The loop was not removed. It was drawn for you.
Python
type(agent).__name__ # 'CompiledStateGraph'list(agent.get_graph().nodes) # ['__start__', 'model', 'tools', '__end__']Your limits become middleware
from langchain.agents.middleware import ( ModelCallLimitMiddleware, HumanInTheLoopMiddleware) agent = create_agent(model, tools=TOOLS, middleware=[ ModelCallLimitMiddleware(run_limit=6), # MAX_STEPS HumanInTheLoopMiddleware( interrupt_on={"set_memory_limit": True}),], checkpointer=checkpointer)The standard loop as a compiled graph
create_agent returns a compiled LangGraph graph. Its model node calls the model; if the reply contains tool calls, the tools node runs them and appends the results; then the model node runs again. The run ends when a reply has no tool calls.
- Input and output
- A dict with a messages list
- Customised with
- Middleware: hooks around the model call and each tool call
- Still yours
- Which tools exist and what they are allowed to do
['__start__', 'model', 'tools', '__end__']Older tutorials use AgentExecutor, or create_react_agent from langgraph.prebuilt. create_agent in the langchain package replaces both. Pausing for approval needs a checkpointer, which is where LangGraph comes in.
LangChain: agents ↗LangChain reference: create_agent ↗LangChain: human-in-the-loop middleware ↗
THE POINT TO KEEPcreate_agent packages the model-and-tools loop as a graph. The graph is still there to inspect and extend.
The same question at five levels
"Why is payments-api failing?" runs the same way at every level. What changes is how much of it you write.
You build the JSON, send it, parse the reply, and write the loop.
| Layer | It gives you | You still write |
|---|---|---|
| Model API | Messages in, one message out | Everything else |
| SDK | Builds the HTTP call, parses the JSON | Messages, tools, the loop |
| Tool calling | Lets the reply be a structured request | Running the tool, sending the result |
| A loop you wrote | Repeats until the model answers in text | Limits, checks, errors |
| LangChain parts | One spelling for messages, models and tools | The loop |
| create_agent | The standard loop, with hooks | Tools, limits, approvals |
| LangGraph | The loop as a graph you can change, save and pause | Designing the graph |
Raw HTTP
POST https://openrouter.ai/api/v1/chat/completionsAuthorization: Bearer $OPENROUTER_API_KEY {"model": "…", "messages": [{"role": "user", "content": "Why is payments-api failing?"}], "tools": [{"type": "function", "function": {…}}]}The SDK builds the request and gives you objects back. Messages, tool schemas and the loop are still yours.
| Layer | It gives you | You still write |
|---|---|---|
| Model API | Messages in, one message out | Everything else |
| SDK | Builds the HTTP call, parses the JSON | Messages, tools, the loop |
| Tool calling | Lets the reply be a structured request | Running the tool, sending the result |
| A loop you wrote | Repeats until the model answers in text | Limits, checks, errors |
| LangChain parts | One spelling for messages, models and tools | The loop |
| create_agent | The standard loop, with hooks | Tools, limits, approvals |
| LangGraph | The loop as a graph you can change, save and pause | Designing the graph |
OpenAI SDK
response = client.chat.completions.create( model=MODEL, messages=messages, tools=SCHEMAS)message = response.choices[0].messageif message.tool_calls: … # parse arguments, run, append, call againMessages, the model and the tools are standard objects. The loop is still a few lines of your Python.
| Layer | It gives you | You still write |
|---|---|---|
| Model API | Messages in, one message out | Everything else |
| SDK | Builds the HTTP call, parses the JSON | Messages, tools, the loop |
| Tool calling | Lets the reply be a structured request | Running the tool, sending the result |
| A loop you wrote | Repeats until the model answers in text | Limits, checks, errors |
| LangChain parts | One spelling for messages, models and tools | The loop |
| create_agent | The standard loop, with hooks | Tools, limits, approvals |
| LangGraph | The loop as a graph you can change, save and pause | Designing the graph |
LangChain parts
model_with_tools = model.bind_tools(TOOLS)for _ in range(MAX_STEPS): reply = model_with_tools.invoke(messages) messages.append(reply) if not reply.tool_calls: break for call in reply.tool_calls: messages.append(TOOLS_BY_NAME[call["name"]].invoke(call))The loop disappears from your code. Limits and approvals become configuration.
| Layer | It gives you | You still write |
|---|---|---|
| Model API | Messages in, one message out | Everything else |
| SDK | Builds the HTTP call, parses the JSON | Messages, tools, the loop |
| Tool calling | Lets the reply be a structured request | Running the tool, sending the result |
| A loop you wrote | Repeats until the model answers in text | Limits, checks, errors |
| LangChain parts | One spelling for messages, models and tools | The loop |
| create_agent | The standard loop, with hooks | Tools, limits, approvals |
| LangGraph | The loop as a graph you can change, save and pause | Designing the graph |
create_agent
agent = create_agent(model, tools=TOOLS, system_prompt=SYSTEM)result = agent.invoke({"messages": [question]})The loop comes back into view as a graph, so you can change its shape, save its state and pause it.
| Layer | It gives you | You still write |
|---|---|---|
| Model API | Messages in, one message out | Everything else |
| SDK | Builds the HTTP call, parses the JSON | Messages, tools, the loop |
| Tool calling | Lets the reply be a structured request | Running the tool, sending the result |
| A loop you wrote | Repeats until the model answers in text | Limits, checks, errors |
| LangChain parts | One spelling for messages, models and tools | The loop |
| create_agent | The standard loop, with hooks | Tools, limits, approvals |
| LangGraph | The loop as a graph you can change, save and pause | Designing the graph |
LangGraph
graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()Pick the lowest level that does the job
A single call needs no framework. A fixed pipeline fits a chain. A tool loop you want to ship fits create_agent. Reach for a hand-built graph when you need your own control flow, saved state, or a pause for a person.
- Before shipping
- Bound the loop, cap tool output, return errors as text
- Watch
- Model calls per question, input tokens per call, tool errors
- Never rely on
- The system prompt as a permission check
reply.usage_metadata["input_tokens"]Moving a tool behind an MCP server changes where the function runs, from your process to another one over the network. The loop on this page is unchanged, and so are the four checks.
LangChain: agents ↗LangChain: models and bind_tools ↗LangChain: Model Context Protocol ↗
THE POINT TO KEEPEach layer formalises plumbing you could write yourself. The model call underneath is the same at every level.