← All posts

LLM Agentic Architecture: How Models Take Actions in the World

An agent is a model that does more than answer questions - it plans, uses tools, observes results, and adjusts. Building agents well requires understanding the agentic loop, memory types, planning strategies, and the failure modes that compound across multi-step tasks. Here's the architecture.

The gap between a language model and an agent is a loop. A language model takes input and produces output, once. An agent takes input, produces an action, observes the result of that action, produces another action, and continues until it achieves a goal - or gets stuck.

This loop is what makes agents qualitatively different from question-answering systems. It also introduces failure modes that don’t exist in single-shot generation: errors compound, context grows, and the model must track state across many turns.


The agentic loop

The minimal agent loop:

from typing import Any

def agent_loop(
    model_fn,
    tools: dict,
    system_prompt: str,
    initial_message: str,
    max_iterations: int = 20,
) -> str:
    """
    Basic agentic loop: model decides action → execute → observe → repeat.
    
    tools: dict mapping tool_name → callable
    Returns the model's final answer.
    """
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": initial_message},
    ]
    
    for iteration in range(max_iterations):
        response = model_fn(messages)
        
        # Check if the model wants to use a tool
        tool_call = extract_tool_call(response)
        
        if tool_call is None:
            # No tool call → model is done, return final answer
            return response["content"]
        
        tool_name = tool_call["name"]
        tool_args = tool_call["arguments"]
        
        # Execute the tool
        if tool_name not in tools:
            tool_result = f"Error: unknown tool '{tool_name}'"
        else:
            try:
                tool_result = tools[tool_name](**tool_args)
            except Exception as e:
                tool_result = f"Error: {str(e)}"
        
        # Add assistant action and tool result to conversation
        messages.append({"role": "assistant", "content": None, "tool_calls": [tool_call]})
        messages.append({"role": "tool", "tool_call_id": tool_call["id"],
                         "content": str(tool_result)})
    
    return "Max iterations reached without completing the task."

def extract_tool_call(response: dict) -> dict | None:
    """Extract tool call from model response if present."""
    if response.get("tool_calls"):
        return response["tool_calls"][0]
    return None

The loop is simple. What makes agents complex is what happens inside it: the model must plan what to do, decide when to use tools, track what it’s already tried, and know when it’s done.


Tool schema and model-callable tools

For a model to use tools reliably, tools must be described in a structured way. OpenAI’s function-calling format has become the de facto standard:

import json

TOOL_DEFINITIONS = [
    {
        "type": "function",
        "function": {
            "name": "search_web",
            "description": "Search the web for current information. Use when you need facts you don't have in your training data.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "The search query"
                    },
                    "n_results": {
                        "type": "integer",
                        "description": "Number of results to return (1-5)",
                        "default": 3
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "execute_python",
            "description": "Execute Python code and return the output. Use for calculations, data processing, or anything that needs computation.",
            "parameters": {
                "type": "object",
                "properties": {
                    "code": {
                        "type": "string",
                        "description": "Python code to execute"
                    }
                },
                "required": ["code"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read the contents of a file.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Absolute path to the file"
                    }
                },
                "required": ["path"]
            }
        }
    },
]

def validate_tool_call(tool_call: dict, tool_definitions: list) -> tuple[bool, str]:
    """Basic validation: does the tool call match the schema?"""
    tool_name = tool_call.get("name")
    tool_defs  = {t["function"]["name"]: t["function"] for t in tool_definitions}
    
    if tool_name not in tool_defs:
        return False, f"Unknown tool: {tool_name}"
    
    schema = tool_defs[tool_name]["parameters"]
    required = schema.get("required", [])
    provided = set(tool_call.get("arguments", {}).keys())
    
    missing = set(required) - provided
    if missing:
        return False, f"Missing required args: {missing}"
    
    return True, "OK"

The tool description quality matters enormously. Vague descriptions cause the model to call the wrong tool or pass wrong arguments. Specific descriptions with examples in the docstring improve reliability significantly.


Memory types

Agents need to track state across turns. There are four distinct memory mechanisms:

class AgentMemory:
    """
    The four types of memory in an agentic system.
    """
    
    def __init__(self, max_context_tokens: int = 8000):
        # 1. In-context (working memory): the conversation history
        # Limited by context window. Everything else is a workaround for this limit.
        self.context: list[dict] = []
        self.max_context_tokens = max_context_tokens
        
        # 2. External (episodic): persisted across sessions
        # Files, databases - survives restarts
        self.persistent_notes: dict = {}
        
        # 3. Semantic (knowledge): retrieved when relevant
        # Vector store for similarity search
        self.knowledge_base: list[dict] = []  # list of {text, embedding}
        
        # 4. Procedural (skills): encoded in the system prompt
        # Instructions, constraints, learned behaviors
        self.system_prompt: str = ""
    
    def add_to_context(self, message: dict, estimated_tokens: int):
        self.context.append(message)
        # Trim old messages if context is getting long
        while self.estimate_context_tokens() > self.max_context_tokens:
            # Remove oldest non-system messages
            for i, msg in enumerate(self.context):
                if msg["role"] != "system":
                    del self.context[i]
                    break
    
    def estimate_context_tokens(self) -> int:
        return sum(len(m.get("content", "") or "") // 4 for m in self.context)
    
    def retrieve_relevant(self, query: str, top_k: int = 3) -> list[str]:
        """Semantic retrieval: find knowledge relevant to current query."""
        # In practice: embed query, cosine similarity with knowledge_base
        # Returns top_k most relevant stored knowledge pieces
        return []
    
    def persist(self, key: str, value: str):
        """Store information that should survive session boundaries."""
        self.persistent_notes[key] = value

The practical challenge: context grows with every tool call. After 20 tool calls, the context can be quite large. Strategies for managing this:

  • Summarize completed subtasks: when a subtask is done, replace the detailed trace with a summary
  • Selective context: only include the most relevant history, not all history
  • External memory offload: write intermediate findings to a file; retrieve as needed

Planning strategies

ReAct (Reasoning + Acting): The model alternates between reasoning (“I need to find out X, which means I should call search_web”) and acting (calling search_web). The reasoning step appears in the context, which helps subsequent steps.

REACT_SYSTEM_PROMPT = """
You are a helpful agent. For each task:

1. THINK: reason about what you need to do and why
2. ACT: call a tool if needed
3. OBSERVE: read the tool result
4. Repeat until the task is complete, then give your final answer.

Format your thinking as:
Thought: [your reasoning]
Action: [tool name and arguments]
Observation: [tool result - filled in by the system]

When done, just provide the final answer without any Action."""

# ReAct produces more reliable multi-step behavior than raw function calling
# because the "Thought" step forces intermediate planning into context

Plan-and-execute: Generate a full plan upfront, then execute steps sequentially. More efficient for tasks where the plan is predictable, but less robust to unexpected tool outputs.

def plan_and_execute_agent(model_fn, tools: dict, task: str) -> str:
    # Phase 1: Generate a plan
    plan_response = model_fn([
        {"role": "system", "content": "Generate a step-by-step plan to accomplish the task. Output only the numbered steps."},
        {"role": "user", "content": task}
    ])
    
    plan_steps = parse_numbered_list(plan_response["content"])
    results = []
    
    # Phase 2: Execute each step
    for step in plan_steps:
        step_result = agent_loop(
            model_fn=model_fn,
            tools=tools,
            system_prompt=f"Execute this specific step: {step}\nContext: {results}",
            initial_message=f"Complete this step: {step}",
            max_iterations=5,
        )
        results.append({"step": step, "result": step_result})
    
    # Phase 3: Synthesize
    synthesis = model_fn([
        {"role": "user", "content": f"Given these step results, provide the final answer for: {task}\n\nResults: {results}"}
    ])
    return synthesis["content"]

def parse_numbered_list(text: str) -> list[str]:
    import re
    lines = text.strip().split('\n')
    return [re.sub(r'^\d+\.?\s*', '', line).strip() for line in lines if line.strip()]

Multi-agent architectures

Some tasks benefit from multiple specialized agents collaborating:

class AgentOrchestrator:
    """
    Orchestrates multiple specialized agents.
    The orchestrator decides which sub-agent handles each part of the task.
    """
    def __init__(self):
        self.agents = {
            "researcher": {
                "description": "Searches for and synthesizes information",
                "tools": ["search_web", "fetch_url"],
            },
            "coder": {
                "description": "Writes and executes code",
                "tools": ["execute_python", "read_file", "write_file"],
            },
            "analyst": {
                "description": "Analyzes data and generates insights",
                "tools": ["execute_python", "read_file"],
            },
        }
    
    def route_subtask(self, subtask: str, model_fn) -> str:
        """Determine which agent should handle this subtask."""
        routing_response = model_fn([{
            "role": "user",
            "content": f"Which agent should handle this subtask? Options: {list(self.agents.keys())}\n\nSubtask: {subtask}\n\nReply with just the agent name."
        }])
        return routing_response["content"].strip().lower()
    
    def execute_task(self, task: str, model_fn) -> str:
        """Break down task and route to appropriate agents."""
        # This is simplified - real implementations need proper handoffs
        subtasks = self.decompose_task(task, model_fn)
        results = {}
        
        for subtask in subtasks:
            agent_name = self.route_subtask(subtask, model_fn)
            if agent_name in self.agents:
                tools = {k: lambda **kwargs: k for k in self.agents[agent_name]["tools"]}
                result = agent_loop(model_fn, tools, "", subtask)
                results[subtask] = result
        
        return self.synthesize_results(task, results, model_fn)
    
    def decompose_task(self, task: str, model_fn) -> list[str]:
        response = model_fn([{
            "role": "user",
            "content": f"Break this task into 2-4 independent subtasks:\n{task}"
        }])
        return parse_numbered_list(response["content"])
    
    def synthesize_results(self, task: str, results: dict, model_fn) -> str:
        response = model_fn([{
            "role": "user",
            "content": f"Synthesize these results for the task '{task}':\n{results}"
        }])
        return response["content"]

Failure mode analysis

Agentic systems fail in characteristic ways:

Failure modeCauseMitigation
Infinite loopsAgent calls same tool repeatedlyTrack recent actions, break on repetition
Context overflowLong tasks exhaust contextPeriodic summarization, context compression
Tool misuseWrong tool or wrong argumentsStrong schema validation, error messages with guidance
Goal driftModel loses track of original taskRepeat the goal in system prompt; re-anchor periodically
Hallucinated tool callsModel invents tool namesStrict validation against known tools
Error propagationEarly error causes all subsequent steps to failError handling with recovery strategies
def safe_agent_loop(model_fn, tools, system_prompt, initial_message, max_iterations=20):
    """Agent loop with failure detection and recovery."""
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": initial_message},
    ]
    
    recent_actions = []   # for loop detection
    error_count = 0
    max_errors = 3
    
    for i in range(max_iterations):
        response = model_fn(messages)
        tool_call = extract_tool_call(response)
        
        if tool_call is None:
            return response["content"]
        
        # Loop detection
        action_signature = f"{tool_call['name']}:{json.dumps(tool_call.get('arguments', {}), sort_keys=True)}"
        if action_signature in recent_actions[-5:]:
            messages.append({"role": "user", "content": 
                "It looks like you're repeating the same action. Try a different approach or conclude."})
            continue
        
        recent_actions.append(action_signature)
        
        # Execute with error handling
        try:
            result = tools[tool_call["name"]](**tool_call.get("arguments", {}))
            messages.append({"role": "assistant", "content": None, "tool_calls": [tool_call]})
            messages.append({"role": "tool", "tool_call_id": tool_call.get("id", "0"), 
                             "content": str(result)})
            error_count = 0  # reset on success
        except Exception as e:
            error_count += 1
            if error_count >= max_errors:
                return f"Failed after {max_errors} consecutive errors. Last error: {e}"
            messages.append({"role": "tool", "tool_call_id": tool_call.get("id", "0"),
                             "content": f"Error: {e}. Please try a different approach."})
    
    return "Task incomplete: maximum iterations reached."

Summary

An LLM agent is a model running in a loop with access to tools. The core components:

  1. Agentic loop: model decides → tool executes → observation → repeat
  2. Tool schema: structured descriptions that let the model select and call tools correctly
  3. Memory management: balancing what stays in context vs what gets offloaded
  4. Planning strategy: ReAct for adaptive tasks, plan-and-execute for predictable ones
  5. Failure handling: loop detection, error recovery, goal re-anchoring

The fundamental challenge is that errors compound. A single incorrect tool call can put the agent in a state it can’t recover from cleanly. Building reliable agents means investing heavily in error handling and recovery, not just the happy path.


Next: Tool Use - The Four Categories - a taxonomy of what kinds of tools agents use and how they should be implemented.

Previous: Token Economy - the cost implications of running agents at scale.

Stay in the loop

New tools, workflows, and AI dev insights

Monthly digest of what was built, what was learned, and what's next.

No spam · Unsubscribe anytime