Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
© 2026
From Prompt to Production: Designing a Reliable AI Workflow

From Prompt to Production: Designing a Reliable AI Workflow

A workflow that double-charges a customer on retry, or loses 40 minutes of progress on a crash, isn't reliable. Here's the toolkit that actually fixes both.

13 min read
Share

A workflow that calls a model, waits, and returns the answer works fine right up until the call times out. Retry it, and one of two things happens: either the retry is safe, or the email it already sent, the record it already wrote, the charge it already made, happens a second time. That's not a hypothetical edge case, it's the default behavior of a retry on anything that isn't explicitly built to survive one. Reliability in an AI workflow isn't about the model being right more often. It's about the system around it correctly handling the model being slow, wrong, or interrupted, three things that are guaranteed to happen eventually at real volume, not exceptions to plan for later.

The one sentence to remember

A workflow is reliable when a retry, a timeout, a crash, or a bad model output is something the system already has a specific, tested answer for, not something that happens to work out by accident because nothing's gone wrong yet in testing.

From Prototype to Production names reliability as one of the problems that stays invisible until the scale stage, when a rare failure stops being rare. This is the hands-on toolkit for actually building it in: retries that know what's safe to repeat, idempotency that makes retries safe in the first place, structured output validation, model fallbacks, and checkpointing that survives a crash without starting over.


The Architecture

Request arrives, gets an idempotency key
Model called with a bounded timeout
Output validated against a schema, retried on malformed output, not just on error
Retryable failure: exponential backoff, then a fallback model if retries are exhausted
Side-effecting step: idempotency key checked before anything runs, not after
Progress checkpointed after each step, not just at the end
Result returned, or the workflow resumes from the last checkpoint if it was interrupted

Each of these solves a different failure, not the same one twice

Retries handle a call that failed. Idempotency handles a call that succeeded but whose result never made it back. Checkpointing handles the process itself dying mid-workflow. A system with only retries still double-sends on the second failure mode, and still loses everything on the third, no matter how well the retry logic itself is written.


Retries: Not Every Failure Deserves the Same Response

The Anthropic SDK's own exception hierarchy already encodes the distinction that matters: which failures are worth retrying, and which ones will just fail identically a second time.

python
import time
import random
import anthropic
 
client = anthropic.Anthropic()
 
def call_with_retry(prompt: str, max_attempts: int = 4) -> str:
    for attempt in range(1, max_attempts + 1):
        try:
            response = client.messages.create(
                model="claude-sonnet-5",
                max_tokens=1024,
                messages=[{"role": "user", "content": prompt}],
            )
            return response.content[0].text
        except anthropic.RateLimitError:
            pass  # retryable, back off and try again
        except anthropic.APIConnectionError:
            pass  # retryable, likely transient
        except anthropic.BadRequestError:
            raise  # the request itself is malformed, retrying won't fix it
        except anthropic.APIStatusError as e:
            if e.status_code < 500:
                raise  # a 4xx that isn't rate limiting is not a retry candidate
        if attempt == max_attempts:
            raise RuntimeError("Exhausted retries")
        backoff = (2 ** attempt) + random.uniform(0, 1)
        time.sleep(backoff)

The jitter isn't decoration

Exponential backoff alone, with no randomness added, means every client that failed at the same moment retries at the same moment again, a self-inflicted thundering herd against whatever was already struggling. The random.uniform(0, 1) spreads those retries out instead of synchronizing them.

This is the same retryable-versus-not distinction covered under Failure Handling in Multi-Agent AI Systems, applied here at the level of a single call instead of a whole worker. If the workflow also needs volume limits and a breaker that stops calling a struggling dependency altogether, the rate limiter and circuit breaker built in How to Give AI Agents Access to APIs apply directly on top of this.


Idempotency: What Actually Makes a Retry Safe

Retrying a read is free. Retrying a write, send this email, charge this card, create this ticket, is only safe if the system can tell "this already happened" from "this hasn't happened yet."

python
processed_keys: set[str] = set()  # a real system uses a database, not memory
 
def send_confirmation_email(idempotency_key: str, to: str, body: str) -> dict:
    if idempotency_key in processed_keys:
        return {"status": "already_sent", "idempotency_key": idempotency_key}
 
    result = email_client.send(to=to, body=body)
    processed_keys.add(idempotency_key)
    return {"status": "sent", "idempotency_key": idempotency_key, "result": result}

The idempotency key has to be generated before the first attempt, not on each retry

Generating a new key every time the step is called defeats the entire point, the second attempt just looks like a brand new, never-seen-before request. The key has to be created once, when the workflow first decides this email needs to be sent, and carried through every retry of that same logical step unchanged.

Not every step needs this. A step that only reads data, or one that's naturally idempotent already, setting a status field to "complete" twice has the same effect as setting it once, doesn't need a key at all. The steps that need it are exactly the ones with a real-world side effect that isn't naturally safe to repeat.


Structured Output Validation: A Malformed Answer Isn't an Exception

A model that returns text where JSON was expected doesn't throw an error, it returns a 200 with a response that quietly fails the next step downstream. That failure needs to be caught explicitly, with a retry that tells the model what was wrong, not just what was asked for again.

python
from pydantic import BaseModel, ValidationError
import json
 
class OrderSummary(BaseModel):
    order_id: str
    total: float
    line_items: int
 
def get_structured_output(prompt: str, max_attempts: int = 3) -> OrderSummary:
    feedback = ""
    for attempt in range(1, max_attempts + 1):
        full_prompt = prompt + (f"\n\nYour previous response was invalid: {feedback}" if feedback else "")
        response = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=512,
            messages=[{"role": "user", "content": full_prompt}],
        )
        raw = response.content[0].text
        try:
            return OrderSummary.model_validate(json.loads(raw))
        except (json.JSONDecodeError, ValidationError) as e:
            feedback = str(e)
            if attempt == max_attempts:
                raise ValueError(f"Model never produced valid output: {feedback}")

Feed the actual validation error back, not a generic 'try again'

"Your response was invalid, please try again" gives the model nothing to correct. The actual ValidationError, missing field, wrong type, gives it something concrete to fix on the next attempt, the same principle as feeding a Reviewer's specific rejection back to a Worker, covered in Build a Multi-Agent System.


Fallback Models: What Runs When the First Choice Won't

Retries handle a transient failure on the same model. A fallback model handles the case where the primary model is degraded long enough that retrying it isn't going to help within the time the workflow has.

python
def call_with_fallback(prompt: str) -> tuple[str, str]:
    for model in ["claude-sonnet-5", "claude-haiku-4-5"]:
        try:
            response = client.messages.create(
                model=model,
                max_tokens=1024,
                messages=[{"role": "user", "content": prompt}],
            )
            return response.content[0].text, model
        except (anthropic.RateLimitError, anthropic.APIConnectionError, anthropic.APIStatusError):
            continue
    raise RuntimeError("All models in the fallback chain failed")

A silent fallback is a debugging trap waiting to happen

The function above returns which model actually answered, on purpose. A workflow that falls back without recording that fact will, weeks later, have someone confused about an inconsistency between two answers that were never actually produced by the same model. Log the fallback every time it happens, don't just quietly absorb it.


Checkpointing: Surviving a Crash Without Starting Over

A five-step workflow that crashes on step four, with no record of steps one through three having already run, doesn't resume, it restarts, redoing work, and re-triggering every side effect those first three steps already caused. Checkpointing after each step is what turns a crash into a pause instead of a loss.

Persist progress after every step, not just at the end

python
import json
from pathlib import Path
 
def save_checkpoint(workflow_id: str, step_index: int, state: dict) -> None:
    path = Path(f"checkpoints/{workflow_id}.json")
    path.write_text(json.dumps({"step_index": step_index, "state": state}))
 
def load_checkpoint(workflow_id: str) -> dict | None:
    path = Path(f"checkpoints/{workflow_id}.json")
    return json.loads(path.read_text()) if path.exists() else None

A real system uses a database, not a JSON file, but the shape is the same: after a step completes, its result is durable before the workflow moves on.

Resume from the last checkpoint, don't rebuild the workflow from step one

python
def run_workflow(workflow_id: str, steps: list) -> dict:
    checkpoint = load_checkpoint(workflow_id)
    start_index = checkpoint["step_index"] + 1 if checkpoint else 0
    state = checkpoint["state"] if checkpoint else {}
 
    for index in range(start_index, len(steps)):
        state = steps[index](state)
        save_checkpoint(workflow_id, index, state)
 
    return state

A restart after a crash calls this same function with the same workflow_id. It picks up at start_index, not zero, every step before that already ran and already checkpointed.

Idempotency and checkpointing solve different halves of the same crash

Checkpointing means the workflow doesn't redo a step that already completed. Idempotency means that if it does end up re-running a step, because the crash happened between the step finishing and the checkpoint being saved, that step is still safe to run again. Neither one substitutes for the other.


Timeouts Everywhere, and Graceful Degradation

A step with no timeout is a workflow that can hang forever

Every external call, the model, a tool, a database, needs a timeout that's shorter than whatever is waiting on the workflow to finish. A hung call with no timeout doesn't fail, it just never returns, which is worse than a failure because nothing downstream ever gets told to stop waiting.

When a non-critical step in an otherwise-working workflow genuinely can't complete in time, the honest options are to fail the whole thing or return a partial result with that fact clearly flagged, not to silently drop the missing piece and hand back something that looks complete. A report missing one section because a slow data source timed out, clearly marked as missing, is more useful than either a total failure or a report that looks whole and isn't.


Gate Deploys on Evaluation, Not on Vibes

A prompt change, a model swap, or a new fallback tier is itself a reliability risk if nothing checks that it didn't quietly make things worse. The measurement discipline for this is covered in full in LLM Evaluation; the reliability-specific point is that the eval suite belongs in the deploy path, not run manually and occasionally:

python
def deploy_if_eval_passes(new_prompt: str, eval_suite, min_pass_rate: float = 0.95) -> bool:
    results = eval_suite.run(new_prompt)
    pass_rate = results.passed / results.total
    if pass_rate < min_pass_rate:
        raise RuntimeError(f"Eval pass rate {pass_rate:.2%} below {min_pass_rate:.2%}, blocking deploy")
    return True

A prompt change that passes a human's quick read-through but drops the eval pass rate from 97% to 91% is exactly the kind of regression that looks fine in a demo and shows up as a real incident once it's serving real traffic.


A Worked Trace

A report-generation workflow: gather data, summarize it with the model, validate the structured output, send the finished report by email.

StepWhat happens
1Workflow starts with a fresh idempotency key, checkpoint saved after data gathering completes
2Model call to summarize times out. Retried with backoff, succeeds on the second attempt, checkpoint saved
3Model returns malformed JSON for the structured summary. Retried once with the validation error fed back, succeeds
4The process crashes here, before the email step runs or checkpoints
5On restart, run_workflow loads the checkpoint from step 3, resumes at the email step, does not re-run data gathering or summarization
6Email step checks its idempotency key first. It was never marked sent, so it sends once, correctly, exactly one time despite the crash and restart

Notice which failure each mechanism actually caught

The timeout and retry caught step 2's transient failure. Structured validation caught step 3's malformed output. Checkpointing is what made the crash in step 4 a pause instead of a full restart. Idempotency is what kept the email from being sent twice, once before the crash was even possible, and once after resuming. No single one of these would have caught all three problems.


When Simpler Is Fine

Match the investment to the blast radius, not to what sounds thorough

A low-volume internal tool with no side effects worth double-executing doesn't need idempotency keys. A workflow that finishes in under a second doesn't need checkpointing, there's nothing meaningful to resume. Building the full toolkit into something that doesn't need it is its own kind of waste, the same judgment call covered for multi-agent architecture in general in Multi-Agent AI Systems: match the mechanism to a real, specific failure the system would otherwise suffer, not to a checklist applied uniformly.

If the workflow is...The right investment is probably...
Read-only, low volume, short-livedBasic retries with backoff, nothing else
Has side effects (emails, charges, writes) but runs to completion in secondsAdd idempotency keys, skip checkpointing
Multi-step and long-running, minutes to hoursAdd checkpointing, idempotency, and a fallback model
Feeds a decision or ships to users automaticallyAdd the evaluation gate before anything else on this list

The Bottom Line

None of these mechanisms exist to make the model more accurate. They exist because a model call is one part of a system that also has to survive timeouts, crashes, malformed output, and retries, and a workflow that only works when none of those things happen isn't reliable, it's untested. Retries that respect what's actually safe to repeat, idempotency that makes repeating safe in the first place, validated output, a fallback tier, checkpointed progress, and an evaluation gate on every change, together, are what close the actual gap between a workflow that worked in a demo and one that survives real production traffic.

The audit worth running before this handles anything real

Pick one workflow currently running in production. For each side-effecting step, ask: if this step's response never made it back and the caller retried, what happens? If the honest answer is "it runs twice," that's not a future hardening task, that's the reliability gap this article is about, and it's usually still there the first time someone actually checks.

A demo never has to survive a retry, a crash, or a malformed answer, because nobody's testing it hard enough for any of those to happen. Production always does, eventually, and the workflow's job is to already have an answer ready when it does.

CChetan Yamger

Written by

Chetan Yamger

Cloud Engineer · AI Automation Architect · Modern Workplace Consultant

Cloud Engineer, AI Automation Architect, and Modern Workplace Consultant based in Amsterdam, Netherlands. Specializing in scalable, secure enterprise solutions with Microsoft Azure, Intune, PowerShell, and AI-driven automation using ChatGPT, Gemini, and modern LLM technologies.

Cloud & Modern WorkplaceMicrosoft Intune & MDMAzure & Microsoft 365AI AutomationPrompt EngineeringPowerShell & Graph APIWindows AutopilotConditional Access & Zero TrustSCCM / MECM & MSIXVDI / WVDPower BINode.js & Next.js
Newsletter

Stay in the loop.
New articles, straight to you.

Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.

New article notifications
No spam, ever
Free forever

Discussion

Share your thoughts — your email stays private

Leave a comment

0/2000

Your email is used to prevent spam and will never be displayed.