Building AI Agents

Agent Architecture

Agent Architecture

Before any framework, it is worth knowing what an agent actually is. Strip away the libraries and it is a loop you could write yourself in about forty lines — and this page shows you those lines.

Once you have seen the loop, LangChain and LangGraph stop looking like magic and start looking like convenience.

What a model can and cannot do

A language model takes text and returns text. That is all.

It cannot look anything up, run anything, remember the previous message, or change anything in the world. It is a brain with no hands and no memory.

Everything else an "AI agent" appears to do is code you wrote around it.

The three pieces

An agent is a model, some tools, and a loop.

PieceWhat it isWho provides it
Modeldecides what to do nextthe provider
ToolsPython functions that touch the worldyou
Looprun the model, run the tool it chose, feed the result backyou, or a framework
Statethe messages so farcarried through the loop

Notice what is missing: there is no separate "brain" component and no planner. The model does the deciding, one step at a time, and the loop keeps handing it the results.

The loop

1. Put the question into the message history
2. Send the history and the tool list to the model
3. Did it ask for a tool?
   No  -> that is the answer, stop
   Yes -> run the tool, append the result, go to 2

That is it. The cycle at step 3 is what makes it an agent rather than a single call. A model that answers once is a chatbot; a model that can look at a result and decide to do something else is an agent.

Written out in full

No framework, nothing hidden.

import json

TOOLS = {
    "get_ticket": get_ticket,
    "search_runbook": search_runbook,
}

TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "get_ticket",
            "description": "Fetch an incident ticket by its id.",
            "parameters": {
                "type": "object",
                "properties": {"ticket_id": {"type": "integer"}},
                "required": ["ticket_id"],
            },
        },
    },
]

MAX_ITERATIONS = 8

def run(question):
    messages = [
        {"role": "system", "content": "You are an incident triage assistant."},
        {"role": "user", "content": question},
    ]

    for step in range(MAX_ITERATIONS):
        reply = call_model(messages, tools=TOOL_SCHEMAS)
        messages.append(reply)

        calls = reply.get("tool_calls")
        if not calls:
            return reply["content"]

        for call in calls:
            name = call["function"]["name"]
            args = json.loads(call["function"]["arguments"])
            try:
                result = TOOLS[name](**args)
            except Exception as e:
                result = f"Error: {e}"
            messages.append({
                "role": "tool",
                "tool_call_id": call["id"],
                "content": str(result),
            })

    return "Stopped: too many steps."

Read it once slowly. Every idea in this tutorial is in there:

  • TOOLS is a dictionary of functions, looked up by name
  • TOOLS[name](**args) is spreading a dictionary into a call
  • messages is a list of dictionaries, growing each turn
  • the tool schemas are JSON Schema, which Pydantic and type hints generate for you
  • the try/except returns the error as text so the model can correct itself
  • MAX_ITERATIONS stops it running forever

The model never runs anything

This is the point people most often get wrong.

The model does not execute your function. It returns a message saying "I would like to call get_ticket with ticket_id=4471" — as text, in a structured field. Your code decides whether to run it.

That gap is where everything important lives: validation, permissions, logging, cost control, and refusing to run something dangerous. An agent that runs whatever the model asks for, unchecked, is a security problem rather than a design.

Why the ceiling matters

MAX_ITERATIONS is not defensive decoration. A model that keeps deciding one more lookup would be helpful will keep going, and each turn is a paid API call carrying the entire message history.

Two things grow every turn: the number of calls, and the size of each one. The cost is worse than linear. Cap it, and make the cap a setting you can change without a deploy.

Where memory comes from

There is none, inside the model. The messages list is the memory, and it is passed in full on every single call.

This has consequences you will meet quickly:

  • a long conversation eventually exceeds the context window
  • every turn re-sends everything, so cost grows with conversation length
  • "memory" in a framework means summarising, trimming or storing that list, not something the model retains

What a framework adds

Nothing conceptual. It gives you:

  • schema generation — @tool builds the JSON from your type hints and docstring
  • the loop, already written and tested
  • message handling across different providers
  • streaming, retries, tracing as options rather than code
  • branching and cycles, which is LangGraph's contribution

The loop above is roughly what create_agent runs for you. Knowing that is what lets you debug it when it misbehaves.

The shapes you will meet

ShapeWhat it isUse it when
Single callone prompt, one answerno outside information needed
Chainfixed steps, in a fixed orderyou know the order in advance
Agentthe model chooses the orderthe right order depends on the answers
Multi-agentseveral agents, each specialisedone prompt has grown unmanageable

Move down that table only when you need to. A chain is cheaper, faster and far easier to debug than an agent, and a great deal of production work is a chain that somebody labelled an agent.

Design rules worth keeping

  • One job per tool. get_ticket and update_ticket, not manage_ticket.
  • Describe tools for the model, not for yourself. The docstring is what it reads to choose.
  • Return errors as text. A tool that raises kills the run; a tool that returns "Error: no such ticket" lets the agent recover.
  • Log every tool call and its arguments. When it goes wrong, that log is all you have.
  • Cap the loop, and cap the cost.
  • Never let the model decide something irreversible without a check in your code.

Practise this

  • Read the loop above and name the exit conditions. There are two.
  • Write the TOOLS dictionary for two functions and call one by looking it up by name.
  • Explain out loud why the message list has to be sent again on every turn.
  • Decide, for something you would like to automate at work, whether it needs an agent or only a chain.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1from typing import Callable, Any
2import json
3
4class SimpleAgent:
5 """A minimal AI agent implementation."""
6
7 def __init__(self, name: str):
8 self.name = name
9 self.tools: dict[str, Callable] = {}
10 self.memory: list[dict] = []
11
12 def register_tool(self, name: str, func: Callable, description: str):
13 """Register a tool the agent can use."""
14 self.tools[name] = {
15 "function": func,
16 "description": description
17 }
18 print(f"āœ“ Registered tool: {name}")
19
20 def think(self, user_input: str) -> dict:
21 """Decide what action to take (simplified)."""
22 # In real agents, this would call an LLM
23 if "calculate" in user_input.lower():
24 return {"action": "calculator", "input": user_input}
25 elif "search" in user_input.lower():
26 return {"action": "search", "input": user_input}
27 return {"action": "respond", "input": user_input}
28
29 def act(self, action: dict) -> str:
30 """Execute the decided action."""
31 tool_name = action["action"]
32
33 if tool_name in self.tools:
34 tool = self.tools[tool_name]
35 result = tool["function"](action["input"])
36 return f"Tool result: {result}"
37
38 return f"I'll help with: {action['input']}"
39
40 def run(self, user_input: str) -> str:
41 """Main agent loop."""
42 print(f"\nšŸ‘¤ User: {user_input}")
43
44 # Think
45 action = self.think(user_input)
46 print(f"šŸ¤” Thinking: {action}")
47
48 # Act
49 result = self.act(action)
50 print(f"šŸ¤– {self.name}: {result}")
51
52 # Store in memory
53 self.memory.append({"input": user_input, "output": result})
54
55 return result
56
57# Create agent and register tools
58agent = SimpleAgent("Helper")
59
60agent.register_tool(
61 "calculator",
62 lambda x: "42 (simulated calculation)",
63 "Performs calculations"
64)
65
66agent.register_tool(
67 "search",
68 lambda x: "Found 3 results (simulated)",
69 "Searches the web"
70)
71
72# Run the agent
73agent.run("Can you search for Python tutorials?")
74agent.run("Calculate 6 times 7")
75agent.run("Tell me a joke")