← Back to Blog
For: AI engineers, ML engineers, platform engineers, AI security engineers, enterprise architects

Design Patterns for SLM-First Systems

Implementation patterns for routers, validators, and loop controllers with LangGraph 1.x, including a corrected human-approval pattern built on interrupt() and Command(resume=...)

Updated
#small language models#SLM#LLM#langgraph#agentic AI#AI architecture#design patterns#routers#validators#loop controllers

The previous article in this series established why SLMs belong in production AI systems as infrastructure - not as smaller GPTs, but as composable intelligence units that handle routing, classification, transformation, and loop control. If you haven't read it, start there: Small Language Models Are Not Smaller GPTs - They're Infrastructure.

This article is about the how. Specifically: what do these patterns look like in code? How do you wire a router SLM into a LangGraph workflow? What does a validator node actually check? How do you build a loop controller that escalates correctly without infinite-looping into bankruptcy?

Every pattern here has been distilled from production systems. The failure modes are real. The code is runnable. By the end you'll have a composable pipeline where each SLM handles exactly one decision type and every path is observable, testable, and fallback-protected.

Six patterns are covered: Router - Schema Enforcer - Output Validator - Loop Controller - Consequence Modeler - Context Pruner. Skip ahead if you're here for a specific one.

19 October 2026: code updated for LangGraph 1.x and LangChain 1.x (langchain-ollama replaces the deprecated langchain_community import), and the human-in-the-loop section rebuilt on interrupt() and Command(resume=...). The earlier version resumed with a plain state dict, which never reached the approval branch. Each pattern now has its own diagram.

Routing, schema enforcement and cascades get their full treatment in my book Right-Sized AI: Small Language Models in Production: Chapter 8 on structured output and constrained decoding, Chapter 10 on routing, cascades and speculative decoding.


Setup and Shared Infrastructure

Before patterns, the scaffolding. All examples use LangGraph - a framework for building stateful, multi-actor agentic workflows as directed graphs. Each node in the graph is a Python function that reads from and writes to shared state. Edges define the execution flow. Conditional edges let you branch based on state values - this is how the SLM's routing decision becomes a graph transition. All examples share the same base setup.

python
from langchain_openai import ChatOpenAIfrom langchain_ollama import OllamaLLMfrom langchain_core.messages import AIMessage, HumanMessage, SystemMessagefrom langgraph.graph import StateGraph, ENDfrom langgraph.checkpoint.memory import MemorySaverfrom typing import TypedDict, Literal, Annotatedimport operatorimport jsonimport os# Frontier LLM - used only for reasoning steps# Set LLM_MODEL to your provider's current frontier model stringllm = ChatOpenAI(model=os.environ["LLM_MODEL"], temperature=0)# SLM - used for routing, validation, classification# Run locally via Ollama, or swap for any small model API.# OllamaLLM returns a plain string, so every node below parses it with json.loadsslm = OllamaLLM(model="phi4-mini", temperature=0)# Shared state schema - every graph in this article uses this baseclass AgentState(TypedDict):    messages: Annotated[list, operator.add]    route: str    confidence: float    intent: dict    loop_count: int    last_action_result: str    escalate: bool

Two things worth calling out. First, temperature=0 on both models. Routing and validation decisions must be deterministic. Any non-zero temperature introduces variance into your control layer, which means the same input might route differently on different calls. That's a silent failure mode that's extremely hard to debug.

Second, loop_count in state. Every agentic system needs a circuit breaker. You will add this if you don't build it in upfront - the only question is whether you add it before or after your first runaway loop incident.

What the setup encodes is two models with different jobs writing to one shared state:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
slm: "SLM: phi4-mini via Ollama\nrouting, validation, loop control\ntemperature=0" {class: slm}
llm: "Frontier LLM: LLM_MODEL\nreasoning steps only\ntemperature=0" {class: main}
state: "AgentState\nmessages, route, confidence, intent,\nloop_count, last_action_result, escalate" {class: sup}
slm -> state: "every control decision"
llm -> state: "only escalated work"

Most requests touch only the left-hand model. The frontier model sees the traffic the SLM layer explicitly hands it, and both leave their decisions in the same state object, which is what makes every path traceable later.


Pattern 1 - The SLM Router

The router is the entry point of every SLM-first system. It classifies incoming requests and assigns execution paths. The LLM never sees requests that the router can handle or redirect to simpler paths.

The Wrong Way

python
# Don't do thisdef route_with_llm(state: AgentState) -> AgentState:    response = llm.invoke(        f"Classify this request: {state['messages'][-1].content}\n"        "Return one of: simple_lookup, tool_call, complex_reasoning"    )    state["route"] = response.content.strip()    return state

This sends every request to a frontier model to decide which requests need a frontier model. The classification costs an order of magnitude or more than it needs to, and the extra round trip adds latency before any real work starts - how much depends on your provider and your hardware, so measure it on your own traffic. And because frontier models are verbose, you'll spend tokens on the classification explanation, not just the classification.

Put the two designs side by side and the difference is where the frontier model sits:

d2
grid-rows: 1
grid-gap: 40
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
wrong: "Wrong:\nLLM routes" {
  label.near: top-center
  style: {fill: "#FDECEA"; stroke: "#E74C3C"}
  req: "Every request" {class: sup}
  llm: "Frontier LLM\nclassifies" {class: bad}
  out: "Route label\n+ explanation" {class: sup}
  req -> llm: "100% of traffic"
  llm -> out
}
right: "Right:\nSLM routes" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#6BCF7F"}
  req: "Every request" {class: sup}
  router: "SLM router\nJSON route + confidence" {class: slm}
  look: "direct_response" {class: ok}
  tool: "tool_executor" {class: ok}
  llm: "llm_handler" {class: main}
  clar: "clarification" {class: ok}
  req -> router
  router -> look
  router -> tool
  router -> llm: "reasoning or\nconfidence < 0.7"
  router -> clar
}

On the left the expensive model is the front door. On the right it is one of four exits, reached only by requests that need reasoning or that the router is unsure about.

The Right Way

python
ROUTER_PROMPT = """You are a request router. Classify the request into exactly one category.Categories:- simple_lookup: factual questions, status checks, definitions- tool_call: actions requiring an API or database call- complex_reasoning: multi-step analysis, synthesis, judgment calls- clarification_needed: ambiguous or incomplete requestsRequest: {request}Respond with JSON only:{{"route": "<category>", "confidence": <0.0-1.0>, "reason": "<one sentence>"}}"""def slm_router(state: AgentState) -> AgentState:    request = state["messages"][-1].content    prompt = ROUTER_PROMPT.format(request=request)    raw = slm.invoke(prompt)    try:        parsed = json.loads(raw.strip())        state["route"] = parsed["route"]        state["confidence"] = parsed["confidence"]    except (json.JSONDecodeError, KeyError):        # Parsing failure -> safe default is escalation, not silent wrong routing        state["route"] = "complex_reasoning"        state["confidence"] = 0.0    return statedef route_decision(state: AgentState) -> str:    # Low confidence escalates to LLM regardless of route label    if state["confidence"] < 0.7:        return "llm_handler"    route_map = {        "simple_lookup": "direct_response",        "tool_call": "tool_executor",        "complex_reasoning": "llm_handler",        "clarification_needed": "clarification_handler",    }    return route_map.get(state["route"], "llm_handler")

Three things in this pattern that matter:

Structured output. The SLM returns JSON, not a sentence. Natural language classification responses are brittle - "I think this is a tool call" versus "tool_call" require different parsing logic and the former will break. Force JSON. Validate it. If parsing fails, don't silently default to any route - escalate. Current runtimes can enforce the shape at decode time: Ollama accepts a JSON schema in its format parameter, and LangChain chat models expose with_structured_output, which removes most parse failures before they happen. Keep the try/except anyway - constrained decoding guarantees valid JSON, not a correct route.

Confidence threshold. The SLM labels every decision with a confidence score. Anything below 0.7 escalates to the LLM regardless of the route label. This single mechanism catches most distribution shift failures automatically - the SLM doesn't know what it doesn't know, but it does know when it's uncertain. Measure which signal you escalate on, though: in a Haiku-to-Sonnet cascade, routing on the small model's priority label beat routing on its own needs-human flag.

Graceful degradation. The except block defaults to complex_reasoning + confidence 0.0, which routes to the LLM handler. This means a router failure produces a slower, more expensive response - not a crash, not a silent wrong routing.

Read together, the three mechanisms form an ordered set of checks, and every "no" lands on the frontier model:

d2
grid-rows: 3
grid-columns: 2
grid-gap: 26
classes: {
  q: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  esc: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
q1: "1. Does the SLM output parse as JSON\nwith route and confidence?" {class: q}
a1: "No: complex_reasoning,\nconfidence 0.0 -> llm_handler" {class: esc}
q2: "2. Is confidence at least 0.7?" {class: q}
a2: "No: llm_handler,\nwhatever the label says" {class: esc}
q3: "3. Is the label in route_map?" {class: q}
a3: "Yes: its node.\nNo: llm_handler" {class: ok}
q1 -> q2
q2 -> q3

Only a request that clears all three reaches a cheap path. The router can be wrong in one way only - confidently wrong with a valid label - which is why the test suite later in this article exists.

Wiring into LangGraph

python
def build_routing_graph():    graph = StateGraph(AgentState)    graph.add_node("router", slm_router)    graph.add_node("direct_response", direct_response_node)    graph.add_node("tool_executor", tool_executor_node)    graph.add_node("llm_handler", llm_handler_node)    graph.add_node("clarification_handler", clarification_handler_node)    graph.set_entry_point("router")    graph.add_conditional_edges(        "router",        route_decision,        {            "direct_response": "direct_response",            "tool_executor": "tool_executor",            "llm_handler": "llm_handler",            "clarification_handler": "clarification_handler",        }    )    graph.add_edge("direct_response", END)    graph.add_edge("tool_executor", END)    graph.add_edge("llm_handler", END)    graph.add_edge("clarification_handler", END)    return graph.compile(checkpointer=MemorySaver())

Every node that produces a terminal response routes to END. The checkpointer=MemorySaver() persists state across turns - without this, multi-turn conversations lose routing history, which means you can't detect when a user's request type changes across turns.


Pattern 2 - The Schema Enforcer

The schema enforcer transforms natural language into typed, structured payloads. The pattern is: SLM extracts intent and parameters, validates the output, and hands structured data to your downstream service. The downstream service never touches natural language.

python
SCHEMA_ENFORCER_PROMPT = """Extract structured data from the user request.Target schema:{schema}User request: {request}Rules:- Return valid JSON matching the schema exactly- Use null for missing optional fields- If a required field cannot be determined, set "extraction_failed": true- Do not invent values - only extract what is explicitly statedRespond with JSON only."""def make_schema_enforcer(schema: dict, schema_name: str):    """Factory that returns a schema enforcer node for a specific schema."""    def schema_enforcer_node(state: AgentState) -> AgentState:        request = state["messages"][-1].content        prompt = SCHEMA_ENFORCER_PROMPT.format(            schema=json.dumps(schema, indent=2),            request=request        )        raw = slm.invoke(prompt)        try:            extracted = json.loads(raw.strip())        except json.JSONDecodeError:            state["intent"] = {"extraction_failed": True, "raw": raw}            state["escalate"] = True            return state        if extracted.get("extraction_failed"):            state["intent"] = extracted            state["escalate"] = True        else:            state["intent"] = extracted            state["escalate"] = False        return state    schema_enforcer_node.__name__ = f"schema_enforcer_{schema_name}"    return schema_enforcer_node# Example: calendar event schemaCALENDAR_SCHEMA = {    "intent": "string (create_event | update_event | delete_event)",    "title": "string (required)",    "start_time": "ISO 8601 datetime (required)",    "end_time": "ISO 8601 datetime (optional)",    "attendees": "array of email strings (optional)",    "location": "string (optional)"}calendar_enforcer = make_schema_enforcer(CALENDAR_SCHEMA, "calendar")

The factory pattern matters here. You'll have multiple schemas in a real system - calendar, tickets, orders, search queries. Rather than writing a separate node for each, the factory generates typed enforcer nodes on demand. Each enforcer carries its schema definition in its closure, so the node is self-documenting.

The extraction_failed flag is the pattern's safety valve. If the SLM cannot confidently extract a required field - the user said "book a meeting with Arjun" without specifying a time - the flag triggers escalation. The escalation path can ask for clarification, hand off to the LLM for inference, or return an error to the user. What it should never do is silently pass incomplete data to the downstream service.

Follow the "book a meeting with Arjun" request through the enforcer and both exits are visible:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  esc: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
}
req: "\"Book a meeting with Arjun\"" {class: sup}
enf: "schema_enforcer_calendar\nSLM + CALENDAR_SCHEMA" {class: slm}
fail: "escalate = True\nstart_time missing:\nextraction_failed" {class: esc}
intent: "state[\"intent\"]\ntyped JSON payload" {class: ok}
next: "validate_calendar_intent\n(next section)" {class: sup}
req -> enf: "1. natural language"
enf -> fail: "2a. bad JSON or\nrequired field unknown"
enf -> intent: "2b. every required field found"
intent -> next: "3. validate before any API call"

Beyond the bottom of this picture sits the downstream service. Nothing reaches it as natural language, and nothing reaches it with a field the SLM had to guess.

Validation Layer

The schema enforcer extracts data. A separate validator verifies it. These are different jobs.

python
def validate_calendar_intent(state: AgentState) -> AgentState:    intent = state.get("intent", {})    errors = []    # Required field check    if not intent.get("title"):        errors.append("title is required")    if not intent.get("start_time"):        errors.append("start_time is required")    # Type/format validation    if intent.get("start_time"):        try:            from datetime import datetime            datetime.fromisoformat(intent["start_time"])        except ValueError:            errors.append(f"start_time is not valid ISO 8601: {intent['start_time']}")    # Business rule validation    if intent.get("start_time") and intent.get("end_time"):        from datetime import datetime        start = datetime.fromisoformat(intent["start_time"])        end = datetime.fromisoformat(intent["end_time"])        if end <= start:            errors.append("end_time must be after start_time")    if errors:        state["last_action_result"] = f"Validation failed: {'; '.join(errors)}"        state["escalate"] = True    else:        state["escalate"] = False    return state

Validation catches two different failure categories. SLM extraction errors - wrong format, hallucinated values, incomplete fields. And business rule violations - end time before start time, past dates, invalid combinations. Both need to fail before the downstream API call, not after.

Three kinds of check run in plain code, with no model call, and any failure flips the same flag:

d2
direction: down
classes: {
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
intent: "Extracted intent from the SLM" {class: sup}
checks: "validate_calendar_intent: plain Python" {
  label.near: top-center
  grid-rows: 1
  grid-gap: 16
  req: "Required fields\ntitle, start_time" {class: main}
  fmt: "Format\nISO 8601 datetimes" {class: main}
  biz: "Business rule\nend_time after start_time" {class: main}
}
pass: "escalate = False\nsafe to call the API" {class: ok}
fail: "escalate = True\nerrors in last_action_result" {class: bad}
intent -> checks
checks -> pass: "no errors"
checks -> fail: "any error"

Extraction errors trip the first two boxes; the third catches a request that is well-formed but impossible. One flag covers both, so the graph needs only one escalation edge.


Pattern 3 - The Output Validator

The output validator runs after the LLM generates a response, before it reaches the user. It's the last line of defense against PII leakage, schema violations, safety issues, and length anomalies.

python
OUTPUT_VALIDATOR_PROMPT = """You are an output safety validator. Review the AI response below.Check for:1. PII exposure (names + contact info combined, SSNs, credit card numbers, passwords)2. Schema compliance: does the response match the expected format? Expected: {expected_format}3. Hallucination signals: confident specific claims without basis (exact numbers, dates, names not in context)4. Toxicity or policy violationsResponse to validate:{response}Return JSON:{{"pass": true/false, "issues": ["issue1", "issue2"], "severity": "low|medium|high|critical"}}If pass is false and severity is critical, the response must be blocked entirely."""def output_validator(state: AgentState, expected_format: str = "natural language") -> AgentState:    last_message = state["messages"][-1].content    prompt = OUTPUT_VALIDATOR_PROMPT.format(        response=last_message,        expected_format=expected_format    )    raw = slm.invoke(prompt)    try:        result = json.loads(raw.strip())    except json.JSONDecodeError:        # Validator itself failed - safe default is to flag for review        state["last_action_result"] = "validator_error"        state["escalate"] = True        return state    if not result.get("pass", True):        severity = result.get("severity", "medium")        if severity == "critical":            # Block the response entirely, return safe error message            state["messages"].append(AIMessage(                content="I encountered an issue generating a safe response. Please try rephrasing your request."            ))            state["escalate"] = True        elif severity in ("high", "medium"):            # Flag for human review, still return response            state["last_action_result"] = f"flagged:{severity}:{','.join(result.get('issues', []))}"    return state

The severity tiering is important. Not all validation failures warrant blocking the response. A slightly long response is low. A response that mentions someone's name alongside their phone number is critical. Blocking everything that fails any check creates a system that's too conservative to be useful. Tiering lets you tune the response: critical issues get blocked, medium issues get flagged for review, low issues get logged.

Sitting between the LLM's draft and the user, the validator fans its verdict out by severity:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  warn: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
draft: "LLM draft response" {class: main}
val: "SLM output validator\nPII, schema, hallucination, policy" {class: slm}
crit: "critical\nblock, send safe message,\nescalate" {class: bad}
med: "high / medium\nreturn response,\nflag for review" {class: warn}
low: "low or pass\nreturn response,\nlog it" {class: ok}
err: "validator JSON fails\nvalidator_error,\nescalate" {class: warn}
draft -> val
val -> crit
val -> med
val -> low
val -> err

Only one exit replaces the response, and that is a critical finding. A validator that cannot produce a verdict at all escalates the turn without blocking it, so a broken validator degrades to "reviewed later" rather than to an outage.


Pattern 4 - The Loop Controller

The loop controller is the most consequential SLM pattern in agentic systems. It makes the continue/terminate/escalate decision at every iteration of the agentic loop. Getting this wrong means either premature termination (tasks not completed) or runaway loops (tasks running forever, burning money, potentially executing destructive actions repeatedly).

python
LOOP_CONTROLLER_PROMPT = """You are a loop controller for an agentic system. Evaluate the current state.Task: {task}Loop iteration: {loop_count} of {max_loops}Last action result: {last_result}Conversation so far: {history}Decide the next action:- CONTINUE: task not complete, last action succeeded, safe to proceed- COMPLETE: task is finished, return result to user- REPLAN: task not complete but current approach is failing, need new strategy- ESCALATE: situation requires human judgment or LLM reasoning- ABORT: task cannot be completed, too many failures, or approaching loop limitRules:- If loop_count >= {max_loops} * 0.8, prefer COMPLETE or ABORT over CONTINUE- If last action failed twice in a row, prefer REPLAN or ESCALATE- Never CONTINUE if last_result contains an unhandled errorReturn JSON:{{"decision": "<CONTINUE|COMPLETE|REPLAN|ESCALATE|ABORT>",  "reason": "<one sentence>",  "confidence": <0.0-1.0>}}"""MAX_LOOPS = 10ESCALATE_THRESHOLD = 0.65def loop_controller(state: AgentState) -> AgentState:    loop_count = state.get("loop_count", 0) + 1    state["loop_count"] = loop_count    # Hard circuit breaker - never trust the SLM to self-limit    if loop_count >= MAX_LOOPS:        state["route"] = "ABORT"        return state    task = state["messages"][0].content    last_result = state.get("last_action_result", "no action yet")    history = "\n".join([        f"{m.type}: {m.content[:200]}"        for m in state["messages"][-6:]  # Last 3 turns    ])    prompt = LOOP_CONTROLLER_PROMPT.format(        task=task,        loop_count=loop_count,        max_loops=MAX_LOOPS,        last_result=last_result,        history=history    )    raw = slm.invoke(prompt)    try:        result = json.loads(raw.strip())        decision = result["decision"]        confidence = result.get("confidence", 0.5)    except (json.JSONDecodeError, KeyError):        # Controller parse failure - safe default is ESCALATE        state["route"] = "ESCALATE"        return state    # Low confidence decisions escalate regardless of label    if confidence < ESCALATE_THRESHOLD and decision not in ("COMPLETE", "ABORT"):        decision = "ESCALATE"    state["route"] = decision    return statedef loop_decision(state: AgentState) -> str:    decision_map = {        "CONTINUE": "action_executor",        "COMPLETE": "response_formatter",        "REPLAN": "llm_planner",        "ESCALATE": "llm_handler",        "ABORT": "abort_handler",    }    return decision_map.get(state["route"], "llm_handler")

The hard circuit breaker at line 4 of loop_controller is non-negotiable. The SLM's prompt tells it to prefer termination near the loop limit, but that instruction competes with its training to complete tasks. A task-completion bias in the SLM can override soft guidance. The hard check doesn't ask the SLM - it just stops the loop. Put this check first, before the SLM call.

The 80% threshold in the prompt passes MAX_LOOPS as a format variable, so the SLM receives a concrete number ("8 of 10") rather than an expression. This produces more graceful terminations - the SLM starts preferring COMPLETE or ABORT at iteration 8, rather than hitting the hard stop at 10 and returning whatever partial state exists.

Inside loop_controller, the order of checks is the point of the pattern - code first, model second, confidence last:

d2
grid-rows: 4
grid-columns: 2
grid-gap: 24
classes: {
  q: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  esc: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
q1: "1. loop_count >= MAX_LOOPS (10)?" {class: q}
a1: "Yes: ABORT.\nThe SLM is never asked" {class: bad}
q2: "2. Does the SLM's JSON parse?" {class: q}
a2: "No: ESCALATE" {class: esc}
q3: "3. confidence < 0.65 and the\ndecision is not COMPLETE or ABORT?" {class: q}
a3: "Yes: ESCALATE" {class: esc}
q4: "4. Otherwise" {class: q}
a4: "Take the SLM's decision:\nCONTINUE, COMPLETE, REPLAN,\nESCALATE or ABORT" {class: ok}
q1 -> q2
q2 -> q3
q3 -> q4

From step 2 onward the model gets a vote, and a low-confidence vote to keep going is overruled. Stopping is the one decision the controller is always allowed to make on its own.

Full Agentic Loop Graph

python
def build_agentic_loop_graph():    graph = StateGraph(AgentState)    # Nodes    graph.add_node("loop_controller", loop_controller)    graph.add_node("action_executor", action_executor_node)    graph.add_node("response_formatter", response_formatter_node)    graph.add_node("llm_planner", llm_planner_node)    graph.add_node("llm_handler", llm_handler_node)    graph.add_node("abort_handler", abort_handler_node)    # Entry    graph.set_entry_point("loop_controller")    # Loop controller routes    graph.add_conditional_edges(        "loop_controller",        loop_decision,        {            "action_executor": "action_executor",            "response_formatter": "response_formatter",            "llm_planner": "llm_planner",            "llm_handler": "llm_handler",            "abort_handler": "abort_handler",        }    )    # Action executor feeds back into the loop controller    graph.add_edge("action_executor", "loop_controller")    # LLM planner feeds back into the loop controller with updated plan    graph.add_edge("llm_planner", "loop_controller")    # Terminal nodes    graph.add_edge("response_formatter", END)    graph.add_edge("llm_handler", END)    graph.add_edge("abort_handler", END)    return graph.compile(checkpointer=MemorySaver())

The critical edges are action_executor -> loop_controller and llm_planner -> loop_controller. These are the feedback loops. Every completed action returns to the controller for evaluation before the next action executes. This is what makes the loop controlled rather than runaway - every iteration requires the controller to explicitly authorize continuation.

Drawn as a graph, the two feedback edges are the only cycles, and both go through the controller:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
lc: "loop_controller (SLM)" {class: slm}
exe: "action_executor\none action per call" {class: sup}
plan: "llm_planner" {class: main}
fmt: "response_formatter" {class: ok}
llmh: "llm_handler" {class: main}
ab: "abort_handler" {class: bad}
end: "END" {class: sup}
lc -> exe: "CONTINUE"
exe -> lc: "result"
lc -> plan: "REPLAN"
plan -> lc: "new plan"
lc -> fmt: "COMPLETE"
lc -> llmh: "ESCALATE"
lc -> ab: "ABORT"
fmt -> end
llmh -> end
ab -> end

No path from action_executor to another action skips the controller, so every iteration, including the tenth, passes the hard circuit breaker first.

action_executor_node is deliberately domain-specific - it's where your actual tool dispatch lives. Here's the stub:

python
def action_executor_node(state: AgentState) -> AgentState:    """    Executes the next action in the agentic loop.    Replace this stub with your tool dispatch logic.    Contract:    - Read the current plan from state["messages"]    - Execute one action (one tool call, one API call, one write operation)    - Write the result to state["last_action_result"]    - Append result as AIMessage to state["messages"]    - Never execute more than one action per call - the loop controller decides whether to continue    """    # Example: extract action from last LLM plan message    plan = state["messages"][-1].content    # Replace with your actual tool dispatch    # e.g. result = your_tool_registry.execute(plan)    result = f"Executed action from plan: {plan[:100]}"    state["last_action_result"] = result    state["messages"] = [AIMessage(content=result)]    return state

The docstring is the contract. One action per call is the invariant. The loop controller decides continuation - the executor should never decide to keep going on its own.

Human-in-the-Loop with interrupt()

When the consequence modeler flags an action as requiring human approval, you need the graph to pause and wait - not abort, not escalate to the LLM, but actually suspend and hand control back to a human. LangGraph's interrupt() function does this from inside a node:

python
from langgraph.checkpoint.memory import MemorySaverfrom langgraph.types import interruptdef human_approval_node(state: AgentState) -> AgentState:    """    Pauses the graph and surfaces the pending action for human review.    The graph resumes when the caller invokes    graph.invoke(Command(resume=...)) with the same thread_id.    On resume this node runs again from the top, and interrupt()    returns the resume value instead of pausing.    """    pending_action = state.get("intent", {})    blocked_reason = state.get("last_action_result", "requires approval")    # interrupt() pauses execution here and returns control to the caller.    # The payload below is what the caller sees in result["__interrupt__"].    # Nothing above this line may have side effects: it re-runs on resume.    human_decision = interrupt({        "pending_action": pending_action,        "reason": blocked_reason,        "message": "This action requires your approval before execution."    })    # human_decision is whatever the caller passes in Command(resume=...)    if human_decision.get("approved"):        state["escalate"] = False        state["last_action_result"] = "approved_by_human"    else:        state["escalate"] = True        state["last_action_result"] = f"rejected_by_human:{human_decision.get('reason', '')}"    return statedef build_agentic_loop_with_approval():    graph = StateGraph(AgentState)    graph.add_node("loop_controller", loop_controller)    graph.add_node("action_executor", action_executor_node)    graph.add_node("consequence_check", consequence_check_node)    graph.add_node("human_approval", human_approval_node)    graph.add_node("response_formatter", response_formatter_node)    graph.add_node("abort_handler", abort_handler_node)    graph.add_node("llm_handler", llm_handler_node)    graph.set_entry_point("loop_controller")    graph.add_conditional_edges(        "loop_controller",        loop_decision,        {            "action_executor": "consequence_check",  # Check before every execution            "response_formatter": "response_formatter",            "llm_planner": "llm_handler",            "llm_handler": "llm_handler",            "abort_handler": "abort_handler",        }    )    # Consequence check gates every action    graph.add_conditional_edges(        "consequence_check",        lambda s: "human_approval" if s.get("escalate") else "action_executor",        {            "human_approval": "human_approval",            "action_executor": "action_executor",        }    )    # After human decision, either execute or return to loop    graph.add_conditional_edges(        "human_approval",        lambda s: "action_executor" if not s.get("escalate") else "loop_controller",        {            "action_executor": "action_executor",            "loop_controller": "loop_controller",        }    )    graph.add_edge("action_executor", "loop_controller")    graph.add_edge("response_formatter", END)    graph.add_edge("llm_handler", END)    graph.add_edge("abort_handler", END)    # The interrupt() call inside human_approval_node does the pausing.    # A checkpointer is required: it is where the paused state lives.    return graph.compile(checkpointer=MemorySaver())

The caller workflow:

python
from langgraph.types import Commandconfig = {"configurable": {"thread_id": "task-001"}}graph = build_agentic_loop_with_approval()# Start the task - runs until interrupt() fires inside human_approvalresult = graph.invoke(initial_state, config=config)# Graph is now paused. The interrupt payload comes back in the result:pending = result["__interrupt__"][0].valueprint(f"Pending action requiring approval: {pending['pending_action']}")# While paused, graph.get_state(config).next == ("human_approval",)# Human approves - the dict becomes interrupt()'s return valueresult = graph.invoke(Command(resume={"approved": True}), config=config)# Or, human rejects - resume with a rejection reason instead:# result = graph.invoke(#     Command(resume={"approved": False, "reason": "Budget not authorized"}),#     config=config,# )

Pause and resume form a short conversation between the caller and the graph:

d2
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
hitl: "Pause and resume with interrupt()" {
  shape: sequence_diagram
  caller: "Caller (your API)" {class: main}
  graph: "Graph + checkpointer" {class: sup}
  node: "human_approval_node" {class: slm}
  caller -> graph: "1. invoke(initial_state)"
  graph -> node: "2. consequence_check set escalate"
  node -> graph: "3. interrupt(payload): state saved"
  graph -> caller: "4. result[\"__interrupt__\"]"
  caller -> graph: "5. invoke(Command(resume={approved: True}))"
  graph -> node: "6. node runs again from the top"
  node -> graph: "7. interrupt() returns the resume dict"
  graph -> caller: "8. action_executor runs, loop continues"
}

Steps 3 and 6 are the ones that bite. The state is persisted at step 3, so you can close the process, restart it, and resume from exactly where it stopped, as long as you use the same thread_id and a persistent checkpointer (swap MemorySaver for SqliteSaver or PostgresSaver in production). And at step 6 the node starts over, which is why nothing before interrupt() may write, charge or send.

Resume only with Command(resume=...). Passing a plain state dict such as {"human_decision": {...}} to invoke does not answer the pending interrupt(), so the approval branch never runs. LangGraph also offers static interrupt_before breakpoints at compile time, but its documentation reserves those for debugging and recommends interrupt() for human-in-the-loop.


Pattern 5 - The Consequence Modeler

The consequence modeler runs before irreversible or write actions. It classifies whether an action is safe to execute, safe to retry, requires human approval, or should be blocked. If you have agentic loops executing write operations, you need this pattern.

python
CONSEQUENCE_MODELER_PROMPT = """You are a consequence modeler for an agentic system.Classify the proposed action before it executes.Proposed action: {action}Action parameters: {parameters}System context: {context}Classification dimensions:1. Reversibility: can this action be undone? (reversible | partially_reversible | irreversible)2. Idempotency: is this action safe to retry? (idempotent | non_idempotent)3. Blast radius: what breaks if this fails or produces wrong output? (isolated | service_level | system_wide)4. Data sensitivity: does this action touch sensitive data? (none | low | high | critical)Return JSON:{{  "reversibility": "<reversible|partially_reversible|irreversible>",  "idempotency": "<idempotent|non_idempotent>",  "blast_radius": "<isolated|service_level|system_wide>",  "data_sensitivity": "<none|low|high|critical>",  "requires_approval": <true|false>,  "safe_to_execute": <true|false>,  "reason": "<one sentence>"}}"""def consequence_modeler(state: AgentState, action: str, parameters: dict) -> dict:    context = {        "loop_count": state.get("loop_count", 0),        "previous_actions": state.get("messages", [])[-4:],    }    prompt = CONSEQUENCE_MODELER_PROMPT.format(        action=action,        parameters=json.dumps(parameters),        context=json.dumps(context, default=str)    )    raw = slm.invoke(prompt)    try:        result = json.loads(raw.strip())    except json.JSONDecodeError:        # Consequence modeler failure - block the action, don't guess        return {            "safe_to_execute": False,            "requires_approval": True,            "reason": "Consequence modeler failed to parse - blocking for safety"        }    # Override: irreversible + non_idempotent always requires approval    if (result.get("reversibility") == "irreversible" and            result.get("idempotency") == "non_idempotent"):        result["requires_approval"] = True        result["safe_to_execute"] = False    # Override: system_wide blast radius always requires approval    if result.get("blast_radius") == "system_wide":        result["requires_approval"] = True    return result

The two override blocks at the bottom are deliberate. They enforce invariants that the SLM's probabilistic reasoning should never override: an irreversible, non-idempotent action always requires human approval, and a system-wide blast radius always requires human approval. These are not recommendations - they're hard rules. The SLM classifies; the overrides enforce the policy.

Seen as two layers, one probabilistic and one not, the division of labour is plain:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  warn: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
action: "Proposed action + parameters" {class: sup}
cls: "SLM classifies (probabilistic)" {
  label.near: top-center
  grid-rows: 1
  grid-gap: 14
  rev: "reversibility" {class: slm}
  idem: "idempotency" {class: slm}
  blast: "blast radius" {class: slm}
  sens: "data sensitivity" {class: slm}
}
rules: "Code overrides (hard rules)\nirreversible + non_idempotent -> approval, not safe\nsystem_wide blast radius -> approval" {class: warn}
run: "safe_to_execute\nrun it" {class: ok}
human: "requires_approval\nhuman_approval_node" {class: warn}
block: "parse failure\nblock, require approval" {class: bad}
action -> cls
cls -> rules
rules -> run
rules -> human
cls -> block: "no valid JSON"

The SLM can talk itself into "safe", but it cannot talk its way past the yellow box. Whatever it concludes about an irreversible, non-idempotent action, the override sends that action to a human.

Idempotency Key Integration

The consequence modeler's idempotency classification feeds directly into how actions execute:

python
import hashlibimport timedef execute_with_idempotency(    action_fn,    parameters: dict,    consequence: dict,    state: AgentState) -> dict:    """Execute an action with idempotency key if classified as non-idempotent."""    if consequence.get("idempotency") == "non_idempotent":        # Generate deterministic idempotency key from action + params + turn        key_source = json.dumps({            "params": parameters,            "loop_count": state.get("loop_count"),            "message_id": len(state.get("messages", []))        }, sort_keys=True)        idempotency_key = hashlib.sha256(key_source.encode()).hexdigest()[:32]        return action_fn(**parameters, idempotency_key=idempotency_key)    else:        return action_fn(**parameters)

The idempotency key is deterministic - it's derived from the action parameters and position in the conversation. This means if the loop controller retries the same action at the same position (same loop count, same message count), it generates the same key. The downstream service sees the retry as a duplicate and returns the original result without re-executing. The double-charge problem is solved at the execution layer, not by hoping the loop controller doesn't retry.

Two attempts at the same position collapse into one execution:

d2
direction: down
classes: {
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
a1: "Attempt\nloop_count=3, messages=7" {class: main}
a2: "Retry after a timeout\nloop_count=3, messages=7" {class: main}
key: "sha256(params, loop_count, message_id)[:32]\nsame key both times" {class: sup}
svc: "Downstream service\nexecutes the first, returns the stored result\nfor the duplicate" {class: ok}
a1 -> key
a2 -> key
key -> svc: "idempotency_key"

No timestamp and no random component go into the key, which is what makes the retry recognisable. Add either and every retry looks like a new request.


Pattern 6 - The Context Pruner

The context pruner sits between your retrieval layer and your LLM. It reduces retrieved chunks to the highest-signal subset. The most common implementation uses a cross-encoder reranker - a small model purpose-built to score (query, passage) relevance pairs.

Note on terminology: In RAG pipelines you'll often see this called a "reranker" rather than a "context pruner." They're the same role. A cross-encoder like cross-encoder/ms-marco-MiniLM-L-6-v2 scores and ranks chunks. A general SLM pruner can also compress, deduplicate, and restructure. The cross-encoder is the most common and most efficient implementation for pure reranking tasks.

python
from sentence_transformers import CrossEncoder# Dedicated cross-encoder reranker - faster and more accurate than# a general SLM for pure relevance scoringreranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")def context_pruner_node(state: AgentState, retrieved_chunks: list[str]) -> AgentState:    query = state["messages"][-1].content    if len(retrieved_chunks) <= 3:        # Small enough to pass through directly        state["intent"]["context"] = retrieved_chunks        return state    # Score all (query, chunk) pairs    pairs = [(query, chunk) for chunk in retrieved_chunks]    scores = reranker.predict(pairs)    # Rank and keep top-k    ranked = sorted(        zip(scores, retrieved_chunks),        key=lambda x: x[0],        reverse=True    )    TOP_K = 4    MIN_RELEVANCE_SCORE = 0.1  # Chunks below this are noise regardless of rank    filtered = [        chunk for score, chunk in ranked[:TOP_K]        if score > MIN_RELEVANCE_SCORE    ]    if not filtered:        # All chunks below threshold - better to have the LLM say        # "I don't have enough context" than to hallucinate from noise        state["intent"]["context"] = []        state["intent"]["context_insufficient"] = True    else:        state["intent"]["context"] = filtered        state["intent"]["context_insufficient"] = False    return state

The MIN_RELEVANCE_SCORE threshold is the pattern's most important parameter. Without it, the pruner always returns TOP_K chunks even when all of them are irrelevant to the query. A chunk that scores below the threshold has too little relevance to help - passing it to the LLM adds noise, not signal, and increases hallucination risk. An empty context that triggers a "I don't have enough information" response is more honest and more useful than four irrelevant chunks that push the LLM toward a confident wrong answer. The 0.1 value is specific to this model's output scale: the model card's own predict example returns raw logits (8.6 for a relevant passage, -4.3 for an irrelevant one), not 0-1 probabilities, so 0.1 is a mildly positive score rather than a floor near zero. Recalibrate the threshold on your own queries whenever you change the reranker.

Ten retrieved chunks go in; at most four come out, or an explicit "not enough context":

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  warn: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
chunks: "10 retrieved chunks\nsimilarity_search(query, k=10)" {class: sup}
ce: "Cross-encoder\nms-marco-MiniLM-L-6-v2\nscores every (query, chunk) pair" {class: slm}
keep: "Sort, keep TOP_K = 4" {class: main}
thr: "Drop scores at or below\nMIN_RELEVANCE_SCORE = 0.1" {class: warn}
ctx: "1-4 chunks -> LLM context" {class: ok}
none: "context_insufficient = True\nLLM says it lacks context" {class: bad}
chunks -> ce: "1. pairs"
ce -> keep: "2. scores"
keep -> thr: "3. top 4"
thr -> ctx: "4a. some survive"
thr -> none: "4b. none survive"

Rank and threshold do different jobs. TOP_K caps how much the LLM reads; the threshold decides whether any of it is worth reading.

When to Use a General SLM Instead

Use a cross-encoder for pure reranking. Use a general SLM pruner when you need to do more than rank. The two differ in what they can do and in how they fail:

d2
grid-rows: 1
grid-gap: 40
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  warn: {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
}
ce: "Cross-encoder reranker" {
  label.near: top-center
  grid-columns: 1
  a: "Scores each (query, chunk) pair" {class: main}
  b: "Ranks and thresholds only" {class: main}
  c: "Fast, deterministic scores" {class: main}
  d: "Cannot spot duplicates" {class: warn}
}
gen: "General SLM pruner" {
  label.near: top-center
  grid-columns: 1
  a: "Sees all chunks at once" {class: slm}
  b: "Drops irrelevant and duplicate chunks" {class: slm}
  c: "Returns indices: [0, 3, 5]" {class: slm}
  d: "Parse failure: first 4 chunks" {class: warn}
}

Neither pruner rewrites chunk text. The general SLM returns indices only, so a confused model can drop the wrong chunk but cannot invent a new one.

python
SLM_PRUNER_PROMPT = """Given the query and retrieved chunks, produce a pruned context.Query: {query}Retrieved chunks:{chunks}Tasks:1. Remove chunks with no relevance to the query2. Remove duplicate information across chunks3. If two chunks say the same thing, keep the more specific one4. Preserve all unique facts relevant to the queryReturn a JSON array of the chunks to keep, in order of relevance.Do not modify chunk text. Return chunk indices only: [0, 3, 5]"""def slm_context_pruner_node(state: AgentState, retrieved_chunks: list[str]) -> AgentState:    query = state["messages"][-1].content    chunks_formatted = "\n\n".join([        f"[{i}] {chunk}" for i, chunk in enumerate(retrieved_chunks)    ])    prompt = SLM_PRUNER_PROMPT.format(        query=query,        chunks=chunks_formatted    )    raw = slm.invoke(prompt)    try:        indices = json.loads(raw.strip())        pruned = [retrieved_chunks[i] for i in indices if i < len(retrieved_chunks)]    except (json.JSONDecodeError, IndexError):        # Pruner failure - fall back to first 4 chunks rather than all chunks        pruned = retrieved_chunks[:4]    state["intent"]["context"] = pruned    return state

Composing Patterns - A Complete Pipeline

Individual patterns are useful. The real value comes from composition. Here's a complete customer support pipeline that chains router, schema enforcer, consequence modeler, and loop controller.

Before the graph, the node stubs that complete the pipeline. These are the terminal and utility nodes referenced throughout:

python
def direct_lookup_node(state: AgentState) -> AgentState:    """Handles simple factual lookups without LLM - stub for your lookup logic."""    query = state["messages"][-1].content    # Replace with your actual lookup logic (DB query, cache hit, etc.)    result = f"Direct lookup result for: {query}"    state["messages"] = [AIMessage(content=result)]    state["last_action_result"] = "success"    return statedef tool_executor_node(state: AgentState) -> AgentState:    """Executes a validated, consequence-checked tool call."""    intent = state.get("intent", {})    # Replace with your actual tool dispatch logic    state["last_action_result"] = f"Tool executed: {intent.get('intent', 'unknown')}"    state["messages"] = [AIMessage(content=f"Action completed: {state['last_action_result']}")]    return statedef llm_handler_node(state: AgentState) -> AgentState:    """Sends request to the frontier LLM for reasoning tasks."""    context = state.get("intent", {}).get("context", [])    context_str = "\n\n".join(context) if context else ""    messages = state["messages"]    if context_str:        messages = [SystemMessage(content=f"Context:\n{context_str}")] + messages    response = llm.invoke(messages)    state["messages"] = [response]    state["last_action_result"] = "llm_response"    return statedef llm_planner_node(state: AgentState) -> AgentState:    """Re-plans when the loop controller signals REPLAN."""    task = state["messages"][0].content    last_result = state.get("last_action_result", "")    plan_prompt = (        f"Task: {task}\n"        f"Previous approach failed: {last_result}\n"        "Devise a different approach. Be specific about the next action to take."    )    response = llm.invoke(plan_prompt)    state["messages"] = [response]    state["last_action_result"] = "replanned"    return statedef response_formatter_node(state: AgentState) -> AgentState:    """Formats the final response when the loop controller signals COMPLETE."""    # Last message already contains the result - clean it up if needed    state["last_action_result"] = "complete"    return statedef abort_handler_node(state: AgentState) -> AgentState:    """Returns a safe error when the loop hits the hard circuit breaker."""    loop_count = state.get("loop_count", 0)    state["messages"] = [AIMessage(        content=(            f"The task could not be completed after {loop_count} attempts. "            "Please try rephrasing your request or contact support."        )    )]    state["last_action_result"] = "aborted"    return statedef clarification_handler_node(state: AgentState) -> AgentState:    """Asks the user for clarification when intent is ambiguous."""    state["messages"] = [AIMessage(        content="I need a bit more information to help you. Could you clarify what you're trying to accomplish?"    )]    return statedef consequence_check_node(state: AgentState) -> AgentState:    """Wrapper that runs consequence_modeler against the current intent."""    intent = state.get("intent", {})    action = intent.get("intent", "unknown_action")    parameters = {k: v for k, v in intent.items() if k != "intent"}    consequence = consequence_modeler(state, action, parameters)    if not consequence.get("safe_to_execute", False) or consequence.get("requires_approval", False):        state["escalate"] = True        state["last_action_result"] = f"blocked:{consequence.get('reason', 'requires approval')}"    else:        state["escalate"] = False    return statedef context_pruner_wrapper(state: AgentState) -> AgentState:    """Retrieves and prunes context before passing to LLM handler."""    from langchain_chroma import Chroma    from langchain_openai import OpenAIEmbeddings    query = state["messages"][-1].content    # Concrete retrieval example using Chroma - swap for your vector DB    # (FAISS: use FAISS.load_local(...).similarity_search(query, k=10))    # (Pinecone: use index.query(vector=embed(query), top_k=10))    vectorstore = Chroma(        collection_name="knowledge_base",        embedding_function=OpenAIEmbeddings(),        persist_directory="./chroma_db"    )    docs = vectorstore.similarity_search(query, k=10)    retrieved_chunks = [doc.page_content for doc in docs]    return context_pruner_node(state, retrieved_chunks)

Here's the full pipeline architecture before the code:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
router: "User input -> SLM router" {class: slm}
lookup: "Direct lookup" {class: sup}
enforcer: "Schema enforcer (SLM)" {class: slm}
ivalid: "Intent validator (code)" {class: sup}
cons: "Consequence check (SLM)" {class: slm}
tool: "Tool executor" {class: sup}
pruner: "Context pruner" {class: slm}
llmh: "LLM handler" {class: main}
outval: "Output validator (SLM)\n-> response to user" {class: slm}
router -> lookup: "simple_lookup"
router -> enforcer: "tool_call"
router -> pruner: "complex_reasoning"
router -> outval: "clarification_needed"
enforcer -> ivalid
ivalid -> cons: "valid"
ivalid -> llmh: "invalid"
cons -> tool: "safe"
cons -> llmh: "blocked"
pruner -> llmh
tool -> outval
llmh -> outval
lookup -> outval

Count the purple boxes: five of the nine nodes are small-model decisions, and the frontier model is a single blue box that three different paths fall back into. Every path, cheap or expensive, ends at the same output validator.

python
def build_support_pipeline():    graph = StateGraph(AgentState)    # Stage 1: Route the request    graph.add_node("router", slm_router)    # Stage 2a: Simple lookup path (no LLM)    graph.add_node("direct_lookup", direct_lookup_node)    # Stage 2b: Tool execution path    graph.add_node("schema_enforcer", calendar_enforcer)    graph.add_node("intent_validator", validate_calendar_intent)    graph.add_node("consequence_check", consequence_check_node)    graph.add_node("tool_executor", tool_executor_node)    # Stage 2c: Complex reasoning path    graph.add_node("context_pruner", context_pruner_wrapper)    graph.add_node("llm_handler", llm_handler_node)    # Stage 3: Output validation (all paths)    graph.add_node("output_validator", output_validator)    # Entry    graph.set_entry_point("router")    # Router branches    graph.add_conditional_edges(        "router",        route_decision,        {            "direct_response": "direct_lookup",            "tool_executor": "schema_enforcer",            "llm_handler": "context_pruner",            "clarification_handler": "output_validator",  # Return clarification request        }    )    # Tool path: enforce -> validate -> consequence check -> execute    graph.add_edge("schema_enforcer", "intent_validator")    graph.add_conditional_edges(        "intent_validator",        lambda s: "consequence_check" if not s.get("escalate") else "llm_handler",        {            "consequence_check": "consequence_check",            "llm_handler": "llm_handler",        }    )    graph.add_conditional_edges(        "consequence_check",        lambda s: "tool_executor" if not s.get("escalate") else "llm_handler",        {            "tool_executor": "tool_executor",            "llm_handler": "llm_handler",        }    )    # All paths converge at output validator    graph.add_edge("direct_lookup", "output_validator")    graph.add_edge("tool_executor", "output_validator")    graph.add_edge("llm_handler", "output_validator")    graph.add_edge("context_pruner", "llm_handler")    graph.add_edge("output_validator", END)    return graph.compile(checkpointer=MemorySaver())

The convergence at output_validator is intentional. Every path - simple lookup, tool execution, LLM reasoning - runs through the same output validation before returning to the user. This is the single point where PII checks, length validation, and safety filtering happen. One node, universal coverage.


Observability - Logging Every Decision

Every SLM decision in every pattern above must be logged. Not for debugging convenience - for operational necessity. You cannot improve a router you cannot observe. You cannot detect distribution shift in a validator you cannot measure. You cannot tune a loop controller you cannot trace.

python
import structlogfrom opentelemetry import tracelogger = structlog.get_logger()tracer = trace.get_tracer("slm-pipeline")def instrument_slm_node(node_fn, node_name: str):    """Decorator that adds structured logging and OpenTelemetry spans to any SLM node."""    def instrumented(state: AgentState) -> AgentState:        with tracer.start_as_current_span(f"slm.{node_name}") as span:            input_summary = state["messages"][-1].content[:100] if state.get("messages") else ""            span.set_attribute("slm.node", node_name)            span.set_attribute("slm.input_preview", input_summary)            span.set_attribute("slm.loop_count", state.get("loop_count", 0))            result_state = node_fn(state)            span.set_attribute("slm.route", result_state.get("route", ""))            span.set_attribute("slm.confidence", result_state.get("confidence", 0.0))            span.set_attribute("slm.escalate", result_state.get("escalate", False))            logger.info(                "slm_decision",                node=node_name,                route=result_state.get("route"),                confidence=result_state.get("confidence"),                escalate=result_state.get("escalate"),                loop_count=result_state.get("loop_count"),                input_preview=input_summary,            )            return result_state    return instrumented# Apply to routerinstrumented_router = instrument_slm_node(slm_router, "router")

The instrument_slm_node decorator wraps any SLM node with an OpenTelemetry span and a structured log entry. Your existing SRE tooling - Jaeger, Grafana, Datadog - picks up the spans automatically. The log entries feed into whatever aggregation pipeline you already run. No custom dashboards required.

Each instrumented decision leaves two records, and they land in tooling you already run:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  main: {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
}
wrap: "instrument_slm_node" {
  label.near: top-center
  node: "SLM node\nrouter, validator, loop controller" {class: slm}
}
span: "OpenTelemetry span slm.<node>\nroute, confidence, escalate, loop_count" {class: main}
log: "structlog event slm_decision\nsame fields + input preview" {class: main}
sre: "Jaeger, Grafana, Datadog" {class: sup}
agg: "Your log aggregation" {class: sup}
wrap -> span
wrap -> log
span -> sre
log -> agg

Both records carry the same fields on purpose: a confidence drift you spot in a dashboard can be traced to the individual decisions behind it.

The attributes captured per decision - node name, route label, confidence score, escalation flag, loop count - are the minimum viable observability set. Confidence score drift over time tells you when your input distribution has shifted. Escalation rate per node tells you which SLMs are failing most often. Loop count distribution tells you whether your loop controller is terminating too early or too late.

For LLM-specific observability - prompt/response pairs, token counts, and latency per node - Langfuse and LangSmith both integrate directly with LangGraph via callback handlers. The instrument_slm_node decorator above handles the SLM control layer; Langfuse handles the LLM reasoning layer. You want both - they cover different parts of the pipeline and have different failure signatures.


Testing SLM Patterns

You cannot ship a router to production and trust it based on manual testing. Each pattern needs a test suite that runs before deployment and after every fine-tuning cycle.

Think of this as decision contract testing - the same principle as contract testing in microservices, applied to SLM decision nodes. Each SLM node has an implicit contract: given this input, produce this decision with at least this confidence. The test suite makes that contract explicit and verifiable. When the contract breaks - a new model version, a distribution shift, a prompt change - the tests catch it before production does.

Each case states a three-part contract, and it fails if either assertion does:

d2
direction: down
classes: {
  slm: {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  sup: {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  ok: {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  bad: {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
case: "Test case\n(input, expected_route, min_confidence)" {class: sup}
node: "slm_router(state)" {class: slm}
check: "assert route == expected_route\nassert confidence >= min_confidence" {class: sup}
pass: "Contract holds\nsafe to deploy" {class: ok}
fail: "Contract broken\nmodel, prompt or traffic changed" {class: bad}
case -> node
node -> check
check -> pass: "both hold"
check -> fail: "either fails"

Teams tend to skip the confidence assertion, and it is the one that catches a model that still picks the right label but has stopped being sure of it.

python
import pytestfrom langchain_core.messages import HumanMessage# Router test suiteROUTER_TEST_CASES = [    # (input, expected_route, min_confidence)    ("What is my order status for #12345?",       "tool_call",          0.8),    ("Explain the difference between TCP and UDP", "complex_reasoning",  0.75),    ("What does refund mean?",                     "simple_lookup",      0.8),    ("do the thing",                               "clarification_needed", 0.6),    ("Book a meeting with Priya tomorrow at 3pm",  "tool_call",          0.8),    ("Why is my payment failing?",                 "complex_reasoning",  0.7),]@pytest.mark.parametrize("request,expected_route,min_confidence", ROUTER_TEST_CASES)def test_router_classification(request, expected_route, min_confidence):    state = AgentState(        messages=[HumanMessage(content=request)],        route="", confidence=0.0, intent={},        loop_count=0, last_action_result="", escalate=False    )    result = slm_router(state)    assert result["route"] == expected_route, (        f"Expected {expected_route}, got {result['route']} "        f"(confidence: {result['confidence']:.2f}) for: {request}"    )    assert result["confidence"] >= min_confidence, (        f"Confidence {result['confidence']:.2f} below threshold {min_confidence} for: {request}"    )# Validator test suiteVALIDATOR_TEST_CASES = [    # (intent_dict, should_pass)    ({"intent": "create_event", "title": "Standup", "start_time": "2026-04-01T09:00:00"}, True),    ({"intent": "create_event", "title": "", "start_time": "2026-04-01T09:00:00"},          False),  # Empty title    ({"intent": "create_event", "title": "Standup", "start_time": "not-a-date"},            False),  # Bad date    ({"intent": "create_event", "title": "Standup",      "start_time": "2026-04-01T10:00:00", "end_time": "2026-04-01T09:00:00"},              False),  # End before start]@pytest.mark.parametrize("intent,should_pass", VALIDATOR_TEST_CASES)def test_intent_validator(intent, should_pass):    state = AgentState(        messages=[], route="", confidence=0.9,        intent=intent, loop_count=0, last_action_result="", escalate=False    )    result = validate_calendar_intent(state)    if should_pass:        assert not result["escalate"], f"Validation failed unexpectedly for: {intent}"    else:        assert result["escalate"], f"Validation passed unexpectedly for: {intent}"

Two test categories that matter most. First, route coverage - every intent category the router is expected to handle should have at least 5 test cases, including edge cases and adversarial inputs. A router with 100% accuracy on your test set but 85% in production means your test set doesn't reflect your traffic distribution.

Second, validator invariants - every business rule the validator enforces should have a failing test case. If you can't write a failing case for a validation rule, the rule isn't testable and shouldn't be in production.

Run these before every deployment. Run them nightly against production traffic samples. The moment pass rate drops below your threshold, you have a distribution shift event.


Common Failure Modes

These patterns fail in predictable ways. Knowing the failure modes is as important as knowing the patterns.

The silent wrong route. The router assigns a route with high confidence, but the assignment is wrong. This happens most often when the user's phrasing is unusual or domain-specific in ways the router hasn't seen. You discover it through user complaints, not monitoring. Fix: add a feedback mechanism that lets users flag incorrect handling, and feed flagged cases back into your router evaluation set.

The validation loop. The schema enforcer extracts incomplete data. The validator flags it. The escalation handler asks the LLM to fill in the gaps. The LLM's response goes back through the schema enforcer. The schema enforcer extracts slightly different incomplete data. Repeat. Fix: track the number of extraction attempts per request. After two failures, return a structured error to the user rather than looping.

The consequence modeler bypass. A developer adds a new tool action and forgets to register it with the consequence modeler. The modeler has no classification for the action and either silently passes it through or crashes. Fix: make consequence modeling mandatory at the framework level - every tool call must produce a consequence classification before execution. Unknown actions get blocked by default, not passed through.

The confidence floor. The SLM learns to output high confidence scores regardless of actual certainty, because high-confidence decisions that route correctly are reinforced during fine-tuning while the cost of low-confidence escalations is invisible during training. The confidence score becomes meaningless. Fix: evaluate confidence calibration as a separate metric. The fraction of high-confidence decisions that were correct should be consistently above your threshold. If it's not, retrain with explicit calibration objectives.

The loop controller persuasion attack. An adversarial input tells the loop controller it has already completed the task and should return a specific result. The controller, being a language model, may comply. Fix: never trust the loop controller's assessment of task completion without validating against the actual state. The task is complete when the output validator passes and the result answers the original query - not when the controller says it's complete.


What's Coming Next

This article covered the implementation patterns. The next article goes deeper into the fine-tuning layer:

  • Fine-Tuning SLMs for Production Roles - building training datasets for router and validator SLMs, running LoRA fine-tuning, and evaluating domain-specific SLMs against your actual workload distribution

The patterns above work with off-the-shelf SLMs. Fine-tuning gives you the next tier of reliability - a router that knows your specific intent taxonomy, a validator that understands your business rules natively, a loop controller trained on your domain's task completion patterns.

If you're building the orchestration layer that connects these patterns, the LangGraph series on this blog covers stateful multi-agent workflows, checkpoint persistence, and interrupt-based patterns for human-in-the-loop systems.

The patterns in this article are not AI patterns. They are software engineering patterns that happen to use AI at specific nodes. Build them that way.

What is inside Right-Sized AI

The patterns here assume you have already chosen the SLM. Right-Sized AI starts one step earlier: it picks the smallest model that can plausibly do the job, then closes the remaining gap with engineering. For the nodes in this article, Chapter 8 covers structured output, tool calling and constrained decoding, which is what makes a schema enforcer reliable. Chapter 9 covers evaluation harnesses, slices and gates, so a router's accuracy is something you measure rather than assume. Chapter 10 covers routing, cascades and speculative decoding. The fine-tuning layer this article points to next is Chapter 4, "Fine-Tuning and Adaptation."

Explore the book: right-sized-ai.ranjankumar.in

Right-Sized AI: Small Language Models in Production, by Ranjan Kumar

A note on code: All examples target LangGraph 1.x and LangChain 1.x on Python 3.10+ (checked against langgraph 1.2.16, langchain-core 1.6.12 and langchain-ollama 1.1.0 in October 2026). The OllamaLLM integration comes from the langchain-ollama package and requires Ollama running locally with the phi4-mini model pulled; any current 3-9B instruct model works, provided it passes the router test suite above. For cloud-hosted SLMs, substitute ChatOpenAI with the appropriate model string. Code is illustrative - production systems require additional error handling, retry logic, and environment-specific configuration.

References


Small Language Model

More Articles

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Right-Sized AI cover

Right-Sized AI

Harness Engineering for Production AI Systems cover

Harness Engineering

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments