# Building AI Agents That Don't Hallucinate: Structured Workflows, Guardrails, and Per-Step Evaluation

> Source: <https://dev.to/imus_d7584cbc8ee9b0336256/building-ai-agents-that-dont-hallucinate-structured-workflows-guardrails-and-per-step-evaluation-5gg1>
> Published: 2026-07-21 09:43:52+00:00

*How we replaced fragile prompt chains with typed schemas, validation gates, and evaluation at every step — 94% task success vs 60% baseline*

January 2024. We built a "research agent" — 12 prompts chained together:

It worked 60% of the time. The other 40%:

Debugging meant reading 12 LLM calls' worth of logs. Adding a step broke three others.

We moved from **prompt chains** to **structured workflows** with:

```
┌─────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐
│  Decompose  │──▶│   Search    │──▶│  Extract    │──▶│ Synthesize  │
│  Question   │   │   Planning  │   │   Facts     │   │  Answer     │
│             │   │             │   │             │   │             │
│ In: Query   │   │ In: Plan    │   │ In: Results │   │ In: Facts   │
│ Out: SubQ[] │   │ Out: Steps  │   │ Out: Fact[] │   │ Out: Answer │
└──────┬──────┘   └──────┬──────┘   └──────┬──────┘   └──────┬──────┘
       │                 │                 │                 │
       ▼                 ▼                 ▼                 ▼
  [Schema]          [Schema]           [Schema]           [Schema]
  [Guardrail]       [Guardrail]        [Guardrail]        [Guardrail]
  [Eval: 0.9]       [Eval: 0.85]       [Eval: 0.9]        [Eval: 0.95]
python
# agent_eval/schemas.py
from pydantic import BaseModel, Field
from typing import Literal, Any

class DecomposeInput(BaseModel):
    user_query: str
    context: dict = Field(default_factory=dict)

class DecomposeOutput(BaseModel):
    sub_questions: list[str] = Field(min_length=1, max_length=5)
    requires_tools: bool
    reasoning: str

class PlanInput(BaseModel):
    sub_questions: list[str]
    available_tools: list[str]

class Step(BaseModel):
    query: str
    source: Literal["web", "internal", "api"]
    priority: int = Field(ge=1, le=3)

class PlanOutput(BaseModel):
    steps: list[Step] = Field(min_length=1)
    estimated_confidence: float = Field(ge=0, le=1)

class Fact(BaseModel):
    claim: str
    evidence: str
    source_url: str
    confidence: float = Field(ge=0, le=1)

class ExtractInput(BaseModel):
    tool_results: list[Any]
    original_query: str

class ExtractOutput(BaseModel):
    facts: list[Fact] = Field(min_length=1)
    gaps: list[str] = Field(default_factory=list)
    confidence: float

class Citation(BaseModel):
    text: str
    source_url: str

class SynthesizeInput(BaseModel):
    facts: list[Fact]
    user_query: str
    tone: Literal["professional", "casual", "technical"] = "professional"

class SynthesizeOutput(BaseModel):
    answer: str
    citations: list[Citation]
    confidence: float
    warnings: list[str] = Field(default_factory=list)
python
# agent_eval/agent.py
class StructuredAgent:
    def __init__(self, steps: list[AgentStep], guardrails: list[Guardrail], evaluator: Evaluator):
        self.steps = steps
        self.guardrails = guardrails
        self.evaluator = evaluator

    async def run(self, input: BaseModel) -> AgentResult:
        context = input.model_dump()
        step_results = []

        for step in self.steps:
            # 1. Execute step with structured output extraction
            output = await self._execute_step(step, context)

            # 2. Validate schema
            validated = step.output_model.model_validate(output)

            # 3. Run guardrails (blocking)
            for guardrail in self.guardrails:
                if not await guardrail.check(validated, context):
                    return AgentResult(blocked=True, reason=guardrail.violation)

            # 4. Evaluate step quality (non-blocking, for observability)
            eval_result = await self.evaluator.evaluate_step(step.name, context, validated)

            step_results.append(StepResult(step=step.name, output=validated, eval=eval_result))
            context.update(validated.model_dump())

        return AgentResult(steps=step_results, final_output=context)
python
# agent_eval/guardrails.py
from abc import ABC, abstractmethod

class Guardrail(ABC):
    @abstractmethod
    async def check(self, output: BaseModel, context: dict) -> bool: ...

class CitationValidator(Guardrail):
    """Every claim in answer must have a citation from retrieved docs."""
    async def check(self, output: SynthesizeOutput, context: dict) -> bool:
        retrieved_docs = context.get("retrieved_docs", [])
        doc_text = " ".join(d.text for d in retrieved_docs)

        for citation in output.citations:
            if citation.text not in doc_text:
                return False  # Hallucinated citation
        return True

class ConfidenceGate(Guardrail):
    """Block low-confidence outputs."""
    def __init__(self, threshold: float = 0.7):
        self.threshold = threshold

    async def check(self, output: BaseModel, context: dict) -> bool:
        return getattr(output, "confidence", 1.0) >= self.threshold

class FormatEnforcer(Guardrail):
    """Ensure structured output matches schema exactly."""
    async def check(self, output: BaseModel, context: dict) -> bool:
        try:
            type(output).model_validate(output.model_dump())
            return True
        except ValidationError:
            return False

class SafetyGuardrail(Guardrail):
    """PII, harmful content, policy violations."""
    def __init__(self):
        self.detector = instructor.from_openai(AsyncOpenAI())

    async def check(self, output: BaseModel, context: dict) -> bool:
        class SafetyCheck(BaseModel):
            safe: bool
            violations: list[str]

        result = await self.detector.chat.completions.create(
            model="gpt-4o-mini",
            response_model=SafetyCheck,
            messages=[{
                "role": "user",
                "content": f"Check for PII, harmful content, policy violations:\n{output.model_dump_json()}"
            }],
            temperature=0.0,
        )
        return result.safe
python
# agent_eval/evaluation.py
class StepEvaluator:
    def __init__(self, judges: list[Judge]):
        self.judges = judges

    async def evaluate_step(self, step_name: str, input: dict, output: BaseModel) -> StepEvalResult:
        results = {}
        for judge in self.judges:
            if judge.applies_to(step_name):
                result = await judge.evaluate(input, output)
                results[judge.name] = result

        return StepEvalResult(step=step_name, judge_results=results)

# Judges per step type
DECOMPOSE_JUDGES = [
    LLMJudge("completeness", "All aspects of query covered?", threshold=0.8),
    LLMJudge("no_hallucination", "Sub-questions answerable from available tools?", threshold=0.9),
]

PLAN_JUDGES = [
    LLMJudge("feasibility", "Plan executable with available tools?", threshold=0.85),
    LLMJudge("efficiency", "Minimal steps to answer?", threshold=0.7),
]

EXTRACT_JUDGES = [
    LLMJudge("faithfulness", "Facts supported by tool results?", threshold=0.9),
    LLMJudge("completeness", "All relevant info extracted?", threshold=0.8),
]

SYNTHESIZE_JUDGES = [
    LLMJudge("accuracy", "Answer matches extracted facts?", threshold=0.9),
    LLMJudge("citation_quality", "Citations precise and relevant?", threshold=0.85),
    LLMJudge("tone_adherence", "Matches requested tone?", threshold=0.8),
]
python
# agent_eval/structured_output.py
import instructor
from openai import AsyncOpenAI
from pydantic import BaseModel, ValidationError

class StructuredExtractor:
    def __init__(self, model="gpt-4o-mini", max_retries=3):
        self.client = instructor.from_openai(AsyncOpenAI())
        self.model = model
        self.max_retries = max_retries

    async def extract(self, response_model: type[BaseModel], prompt: str, 
                      system: str = None, context: dict = None) -> BaseModel:
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        if context:
            messages.append({"role": "system", "content": f"Context:\n{json.dumps(context)}"})
        messages.append({"role": "user", "content": prompt})

        last_error = None
        for attempt in range(self.max_retries):
            try:
                return await self.client.chat.completions.create(
                    model=self.model,
                    response_model=response_model,
                    messages=messages,
                    temperature=0.0,
                )
            except ValidationError as e:
                last_error = e
                messages.append({"role": "assistant", "content": f"Validation failed: {e}"})
                messages.append({"role": "user", "content": "Fix the validation errors. Output ONLY valid JSON."})

        raise last_error
```

| Metric | Prompt Chain | Structured Agent | Improvement |
|---|---|---|---|
| Task success rate | 60% | 94% | +34 pp |
| Format validity | 72% | 99.8% | +27.8 pp |
| Hallucination rate | 23% | 3% | -20 pp |
| Avg steps to complete | 4.2 | 2.8 | -33% |
| Debug time (per failure) | 45 min | 8 min | -82% |
| CI catch rate (regressions) | 12% | 87% | +75 pp |

| Prompt Chain | Structured Agent |
|---|---|
| "Write a prompt that works" | "Define the I/O contract for each step" |
| Test on 5 examples | Golden set with 200+ stratified cases |
| "Add safety to prompt" | Guardrail as typed, testable code |
| Debug by reading logs | Debug by failed step + judge scores |
| Hope it generalizes | Regression test on every change |

The upfront cost (schemas, guardrails, eval) pays off at step 3. By step 5 it's mandatory.

```
pip install agent-eval-framework
python
from agent_eval import StructuredAgent, DecomposeStep, PlanStep, ExtractStep, SynthesizeStep
from agent_eval.guardrails import CitationValidator, ConfidenceGate, SafetyGuardrail
from agent_eval.judges import create_judge_ensemble

agent = StructuredAgent(
    steps=[
        DecomposeStep(),
        PlanStep(),
        ExtractStep(),
        SynthesizeStep(),
    ],
    guardrails=[
        CitationValidator(),
        ConfidenceGate(0.7),
        SafetyGuardrail(),
    ],
    evaluator=StepEvaluator(judges=create_judge_ensemble()),
)

result = await agent.run(DecomposeInput(user_query="How do I reset my 2FA?"))
```

All MIT licensed:

*Code: github.com/yourname/agent-eval-framework |
Discussion: Hacker News |
Follow: @yourname*
