Building AI Agents That Don't Hallucinate: Structured Workflows, Guardrails, and Per-Step Evaluation How we replaced fragile prompt chains with typed schemas, validation gates, and evaluation at every step — 94% task success vs 60% baseline The Prompt Chain Trap January 2024. We built a "research agent" — 12 prompts chained together: Decompose question → 2. Search planning → 3. Execute searches → 4. Extract facts → 5. Synthesize → 6. Fact-check → 7. Format → ... It worked 60% of the time. The other 40%: Step 3 returned malformed JSON → Step 4 crashed Step 5 hallucinated citations → Step 6 missed it Step 7 output wrong format → Downstream consumer failed No visibility into which step failed Debugging meant reading 12 LLM calls' worth of logs. Adding a step broke three others. The Shift: Agents as Typed Workflows We moved from prompt chains to structured workflows with: Pydantic schemas for every step input/output Guardrails that validate and auto-retry Explicit state machine (not implicit chaining) Evaluation harness per step (not just end-to-end) ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 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] Enter fullscreen mode Exit fullscreen mode Core Abstractions # 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) Enter fullscreen mode Exit fullscreen mode The Agent Loop # 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) Enter fullscreen mode Exit fullscreen mode Guardrails That Actually Block # 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 Enter fullscreen mode Exit fullscreen mode Per-Step Evaluation (Not Just End-to-End) # 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), ] Enter fullscreen mode Exit fullscreen mode Structured Output Extraction with Auto-Retry # 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 Enter fullscreen mode Exit fullscreen mode Results: Structured vs. Prompt Chain 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 The Mental Shift 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. Getting Started pip install agent-eval-framework Enter fullscreen mode Exit fullscreen mode 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?")) Enter fullscreen mode Exit fullscreen mode Open Source All MIT licensed: agent-eval-framework — Core agent + evaluation llm-eval-harness — Judge ensembles + CI integration structured-output — Universal extractor with auto-retry Code: github.com/yourname/agent-eval-framework | Discussion: Hacker News | Follow: @yourname
Building AI Agents That Don't Hallucinate: Structured Workflows, Guardrails, and Per-Step Evaluation
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.