Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /AI

Loops vs Graphs in Agent Architecture and Why Compilers Bridge the Gap

Unify agent architecture loops graphs. Compiler design bridges iterative runs and deterministic flows to build fast, reliable AI systems.

Dian Rijal Asyrof/August 27, 2026/6 min read
Illustration for Loops vs Graphs in Agent Architecture and Why Compilers Bridge the Gap

Current agent design broken. Industry split: loops vs graphs.

Loops give flexibility. Graphs give control.

Both fail in production. Loops cause infinite runs, state drift, high cost. Graphs break on unexpected input.

Solution: Use compiler design.

The Loop: Ergonomics and Chaos

ReAct pattern is classic loop. LLM reads prompt, runs tool, observes, repeats.

Easy to prototype. Fast setup.

Basic loop agent:

def run_agent_loop(user_query: str, tools: dict):
    context = f"User: {user_query}\n"
    while True:
        response = call_llm(context + "Choose next action or output final answer.")
        if "FINAL_ANSWER" in response:
            return parse_answer(response)
        
        tool_name, tool_input = parse_action(response)
        tool_output = tools[tool_name](tool_input)
        context += f"\nAction: {tool_name}({tool_input}) -> {tool_output}\n"

Runtime behavior is black box. Bad tool output breaks LLM. Agent loops infinitely.

Context grows every step. Agent loses plan, forgets intent, hallucinates. Testing hard. Uncontrolled loops often lead to developer fatigue, prompting some to fire their AI assistant due to context drift.

The Graph: Rigid Determinism

Graphs and state machines fix loop chaos. Nodes run code or LLM. Edges define paths.

Guarantees validation, enforces auth.

Conceptual graph setup:

class AgentState:
    def __init__(self, query: str):
        self.query = query
        self.results = []
        self.validated = False
 
def validate_node(state: AgentState):
    state.validated = check_query_safety(state.query)
    return state
 
def search_node(state: AgentState):
    if not state.validated:
        raise ValueError("Unauthorized query")
    state.results = db_search(state.query)
    return state
 
# Constructing the graph
workflow = Workflow()
workflow.add_node("validate", validate_node)
workflow.add_node("search", search_node)
workflow.set_entry_point("validate")
workflow.add_edge("validate", "search")

Graphs give control but require high boilerplate.

Loses LLM adaptability. Unexpected queries fail. Graph becomes hard-coded decision tree. Manual approval gates do not solve this; research shows humans miss security threats in agent commands when validating actions.

Core Conflict: Flexibility vs Static Analysis

Runtime paths give fast prototyping. Static paths give safety, optimization, predictability.

Need both. Developer writes flexible code, engine runs optimized plan.

Use compilation.

Why Compilers Bridge Gap

Agent compiler takes flexible definition, outputs optimized execution graph.

Like traditional compiler converting AST to machine code. Agent compiler traces paths, optimizes prompts, generates state machine.

Example: DSPy. Developer defines signatures. Compiler optimizes prompts against dataset, builds pipeline.

Separates intent from execution.

Toy Agent Compiler

Python example. Compiler takes step list, runs safety checks, optimizes path, outputs state machine.

Target execution graph:

import json
from typing import Dict, List, Any, Callable
 
class Node:
    def __init__(self, name: str, action: Callable[[Dict], Dict]):
        self.name = name
        self.action = action
 
class CompiledGraph:
    def __init__(self):
        self.nodes: Dict[str, Node] = {}
        self.edges: Dict[str, str] = {}
        self.conditional_edges: Dict[str, Callable[[Dict], str]] = {}
 
    def add_node(self, name: str, action: Callable[[Dict], Dict]):
        self.nodes[name] = Node(name, action)
 
    def add_edge(self, from_node: str, to_node: str):
        self.edges[from_node] = to_node
 
    def add_conditional_edge(self, from_node: str, router: Callable[[Dict], str]):
        self.conditional_edges[from_node] = router
 
    def execute(self, initial_state: Dict) -> Dict:
        state = initial_state
        current = "start"
        visited = set()
        
        while current in self.nodes:
            # Prevent infinite execution loops at runtime
            state_key = f"{current}:{hash(json.dumps(state, sort_keys=True))}"
            if state_key in visited:
                print(f"Loop detected at node '{current}'. Aborting execution.")
                break
            visited.add(state_key)
            
            print(f"Executing node: {current}")
            state = self.nodes[current].action(state)
            
            if current in self.conditional_edges:
                current = self.conditional_edges[current](state)
            else:
                current = self.edges.get(current, "end")
        return state

Compiler code:

class AgentCompiler:
    def __init__(self, steps: List[Dict[str, Any]]):
        self.steps = steps
 
    def compile(self) -> CompiledGraph:
        graph = CompiledGraph()
        
        # Automatically inject a safety validation node at the start
        graph.add_node("start", self._create_validation_node())
        
        previous_node = "start"
        
        for i, step in enumerate(self.steps):
            node_name = step["name"]
            action_type = step["type"]
            
            # Optimize: Skip redundant search steps if they happen consecutively
            if i > 0 and action_type == "search" and self.steps[i-1]["type"] == "search":
                print(f"Compiler Optimization: Merging redundant search step '{node_name}'")
                continue
            
            if action_type == "llm":
                node_action = self._build_llm_action(step["prompt"])
            elif action_type == "search":
                node_action = self._build_search_action()
            else:
                node_action = lambda state: state
            
            graph.add_node(node_name, node_action)
            graph.add_edge(previous_node, node_name)
            previous_node = node_name
            
        # Add transition to end
        graph.add_edge(previous_node, "end")
        return graph
 
    def _create_validation_node(self) -> Callable[[Dict], Dict]:
        def validate(state: Dict) -> Dict:
            query = state.get("query", "")
            if "drop table" in query.lower() or "delete" in query.lower():
                raise ValueError("SQL injection risk detected in input.")
            state["validated"] = True
            return state
        return validate
 
    def _build_llm_action(self, prompt: str) -> Callable[[Dict], Dict]:
        def llm_action(state: Dict) -> Dict:
            # Simulated LLM call
            state["response"] = f"Processed with prompt '{prompt}': {state.get('query')}"
            return state
        return llm_action
 
    def _build_search_action(self) -> Callable[[Dict], Dict]:
        def search_action(state: Dict) -> Dict:
            state["search_results"] = ["result_1", "result_2"]
            return state
        return search_action

Execution:

# Define high-level steps (our source code)
steps_definition = [
    {"name": "fetch_data", "type": "search"},
    {"name": "refetch_data", "type": "search"}, # Redundant step
    {"name": "generate_report", "type": "llm", "prompt": "Summarize data"}
]
 
compiler = AgentCompiler(steps_definition)
compiled_agent = compiler.compile()
 
# Run the compiled graph
initial_state = {"query": "Find latest sales metrics"}
final_state = compiled_agent.execute(initial_state)
print("Final State:", final_state)

Compiler injected validation node, removed redundant search step. Output is clean, predictable path.

Prompt Optimization via Compilation

Hard-coded prompts break when LLM changes. To prevent regressions, teams must run a structured model upgrade evaluation before switching production models.

Compiler treats prompt as code. Optimizes prompt instructions and few-shot examples against dataset for target model. This shift toward minimal, optimized instructions mirrors how Anthropic slashed Claude Code's system prompt to improve execution efficiency.

Turns prompt engineering into structured process.

Static Analysis

Raw loops cannot guarantee termination. Compiled graphs allow static checks.

Checks:

  • Unreachable Nodes: Nodes never executed.
  • Sink Nodes: Nodes with no exit path.
  • Type Mismatches: Output schema violates next node input schema.

Catches bugs before production.

Static analysis implementation:

def analyze_graph(graph: CompiledGraph) -> Dict[str, List[str]]:
    errors = {"unreachable": [], "sinks": []}
    all_nodes = set(graph.nodes.keys())
    visited = set()
    
    def traverse(node_name: str):
        if node_name in visited or node_name == "end":
            return
        visited.add(node_name)
        
        # Check standard edge
        next_node = graph.edges.get(node_name)
        if next_node:
            traverse(next_node)
            
        # Check conditional edges
        if node_name in graph.conditional_edges:
            # Static analysis assumes all paths possible
            pass 
 
    traverse("start")
    
    unreachable = all_nodes - visited
    errors["unreachable"] = list(unreachable)
    
    for node in graph.nodes:
        if node != "end" and node not in graph.edges and node not in graph.conditional_edges:
            errors["sinks"].append(node)
            
    return errors

Run analysis before execution. Block deployment if errors found.

Architecture Comparison

MetricLoop Agents (ReAct)Graph Agents (DAGs)Compiled Agents
Developer ErgonomicsHigh (Write simple code)Low (Write boilerplate)High (Write code, compiler builds graph)
PredictabilityLow (State drift, loops)High (Defined paths)High (Validated paths)
AdaptabilityHigh (LLM decides paths)Low (Hard-coded paths)Medium-High (Optimized paths)
Cost ControlHard (Uncapped iterations)Easy (Set max steps)Easy (Statically bounded)

Intermediate Representation (IR)

Scale requires IR. Acts as platform-independent layer between source and target.

Agent IR defines state schemas, prompts, tools, transitions.

Benefits:

  • Port agents to different LLMs without rewriting prompts.
  • Run static checks for dead ends or infinite loops.
  • Apply optimization passes (merge calls, parallelize tools).

Developer targets IR. Runtime executes IR, handles state, recovers from errors.

Example IR definition:

{
  "metadata": {
    "agent_id": "report_generator",
    "version": "1.0.0"
  },
  "state_schema": {
    "query": "string",
    "validated": "boolean",
    "search_results": "array",
    "response": "string"
  },
  "nodes": [
    { "name": "start", "type": "validation", "config": {} },
    { "name": "fetch_data", "type": "search", "config": {} },
    { "name": "generate_report", "type": "llm", "config": { "prompt": "Summarize data" } }
  ],
  "edges": [
    { "from": "start", "to": "fetch_data" },
    { "from": "fetch_data", "to": "generate_report" },
    { "from": "generate_report", "to": "end" }
  ]
}

IR decouples agent definition from runtime engine. Allows target-specific optimizations.

Practical Engineering Patterns

State Hydration and Resiliency

Persist state to database at every node transition. If LLM fails, resume from last checkpoint. Prevents data loss, saves tokens.

State hydration implementation:

import sqlite3
import pickle
 
class DbStateStore:
    def __init__(self, db_path: str = ":memory:"):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS states (session_id TEXT PRIMARY KEY, state BLOB)"
        )
        self.conn.commit()
 
    def save(self, session_id: str, state: dict):
        data = pickle.dumps(state)
        self.conn.execute(
            "INSERT OR REPLACE INTO states (session_id, state) VALUES (?, ?)",
            (session_id, data)
        )
        self.conn.commit()
 
    def load(self, session_id: str) -> dict:
        cursor = self.conn.execute(
            "SELECT state FROM states WHERE session_id = ?", 
            (session_id,)
        )
        row = cursor.fetchone()
        return pickle.loads(row[0]) if row else {}

Integrate store into runtime loop. Save state after node execution. Load state on failure recovery.

Static Validation of LLM Outputs

Compile schemas into prompts using Pydantic. Compiler guarantees output matches input schema of next node. Route validation failures to correction nodes.

Dynamic Routing with Static Fallbacks

Restrict LLM routing choices to statically compiled transitions. Prevents invalid state transitions.

Just-In-Time (JIT) Agent Compilation

Future: JIT compilers for agents.

JIT compiler analyzes query at runtime, builds task-specific graph, runs it, discards it.

Combines loop flexibility with graph safety.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleKeenable Search API Architecture for Autonomous AgentsNext articleCloudflare Issues AI Agent Wallets with Enforced Spending Caps
AI AgentsLoopsGraphsArchitectureCompilers
On this page↓
  1. The Loop: Ergonomics and Chaos
  2. The Graph: Rigid Determinism
  3. Core Conflict: Flexibility vs Static Analysis
  4. Why Compilers Bridge Gap
  5. Toy Agent Compiler
  6. Prompt Optimization via Compilation
  7. Static Analysis
  8. Architecture Comparison
  9. Intermediate Representation (IR)
  10. Practical Engineering Patterns
  11. State Hydration and Resiliency
  12. Static Validation of LLM Outputs
  13. Dynamic Routing with Static Fallbacks
  14. Just-In-Time (JIT) Agent Compilation

On this page

  1. The Loop: Ergonomics and Chaos
  2. The Graph: Rigid Determinism
  3. Core Conflict: Flexibility vs Static Analysis
  4. Why Compilers Bridge Gap
  5. Toy Agent Compiler
  6. Prompt Optimization via Compilation
  7. Static Analysis
  8. Architecture Comparison
  9. Intermediate Representation (IR)
  10. Practical Engineering Patterns
  11. State Hydration and Resiliency
  12. Static Validation of LLM Outputs
  13. Dynamic Routing with Static Fallbacks
  14. Just-In-Time (JIT) Agent Compilation

See also

Illustration for Keenable Search API Architecture for Autonomous Agents
Software Engineering/Aug 27, 2026

Keenable Search API Architecture for Autonomous Agents

Scale agent search api architecture for low-latency structured extraction. Compare retrieval trade-offs on a 100B-page index built for AI workflows.

8 min read
KeenableAI Agents
Illustration for Engineering for the Agentic Era: Infrastructure, Identity, and Operational Control
Software Engineering/Jul 20, 2026

Engineering for the Agentic Era: Infrastructure, Identity, and Operational Control

As AI agents gain execution autonomy in production, software engineering focus is shifting from code generation to security boundaries, identity stacks, and observability.

3 min read
ArchitectureSecurity
Illustration for Improving LLM Code Generation Quality using agent.md
Programming/Aug 28, 2026

Improving LLM Code Generation Quality using agent.md

Define agent md llm context to standardize repo rules. Stop AI code hallucinations, boost output accuracy, guide coding assistants.

5 min read
AI CodingLLMs