cd /news/large-language-models/validate-your-llm-workflow-before-it… Β· home β€Ί topics β€Ί large-language-models β€Ί article
[ARTICLE Β· art-57877] src=dev.to β†— pub= topic=large-language-models verified=true sentiment=↑ positive

Validate Your LLM Workflow Before It Runs (Python + YAML Topology Checks)

A developer built a Python + YAML topology checker for LLM workflows that validates the graph structure before runtime, catching dead ends, missing steps, and illegal loops. The open-source package llm-workflow-router analyzes directed graphs of workflow steps and refuses to start if the topology is invalid, preventing runtime failures in production.

read8 min views1 publishedJul 13, 2026

Your LLM app boots fine. It answers the first few messages fine. Then, ten minutes into a real conversation, it quietly walks from the support

step into the billing

step that doesn't exist anymore, or loops faq β†’ support β†’ faq β†’ support

until something times out. Nothing crashed at startup. The mistake was in the workflow's shape the whole time β€” you just didn't find out until runtime, in production, with a user watching.

This is the same class of bug as a malformed config file: the error exists the moment you write it, but the tool waits until the worst possible time to tell you. The fix is the same too. Catch it at load time, before a single step executes.

In this tutorial we'll build a small, declarative LLM workflow as YAML, then validate its topology β€” the graph of which step can hand off to which β€” before we run anything. We'll catch dead ends, transitions to steps that don't exist, and loops that can never terminate. All the code here runs against a real open-source package, llm-workflow-router, so you can follow along end to end.

Most guardrails for LLM systems limit volume: a max number of tool calls, a timeout, a retry cap. Those are useful, but they treat the symptom. A runaway loop gets cut off after N iterations β€” it doesn't get prevented, and you learn nothing about why the workflow could loop in the first place.

Structural validation asks a different question: before this workflow ever handles a request, is its graph even valid? A step that transitions to a nonexistent target is invalid no matter what any model says. A step with no way out is invalid. A cycle through a step that isn't allowed to be re-entered is invalid. None of that depends on content, so none of it needs to wait for runtime to be caught.

The mental model: your workflow is a directed graph. Nodes are steps ("containers"). Edges are the transitions each step permits. Validating the workflow means analyzing that graph and refusing to start if it's broken.

Here's a minimal support flow. A user enters at support_entry

, which may hand off to faq

or refuse; faq

may only refuse. REFUSE

is a terminal state, not a step.

entrypoints:
  - support_entry

containers:
  support_entry:
    allow_transitions:
      - faq
      - REFUSE
    allow_reentry: false
    max_invocations: 3

  faq:
    allow_transitions:
      - REFUSE
    allow_reentry: true
    max_invocations: 5

Three things are being declared per step, and each one is a rule the validator can check:

allow_transitions

β€” the allow_reentry

β€” whether the step may be entered more than once. This is what turns "is there a cycle?" into "is there a cycle that's actually a problem?"max_invocations

β€” a hard ceiling on how many times the step may run. Must be greater than zero, or the step can never execute.entrypoints

names the authoritative roots of the graph. Declaring them explicitly means the validator doesn't have to guess where the workflow starts β€” and it can flag any step that no path can reach.

Install the package:

pip install llm-workflow-router

Then validate the config before you wire it into anything:

python -m router.cli validate good_config.yaml
{
  "ok": true
}

Clean exit, exit code 0

. Now let's break it on purpose β€” the kind of breakage that's easy to introduce and impossible to spot by eye once a config gets large:

containers:
  support_entry:
    allow_transitions:
      - faq
      - billing        # typo: this step does not exist
    allow_reentry: false
    max_invocations: 3

  faq:
    allow_transitions:
      - support_entry   # loops back into a no-reentry step
    allow_reentry: true
    max_invocations: 5

  orphaned_step:
    allow_transitions:
      - REFUSE
    allow_reentry: false
    max_invocations: 1

There are three separate problems hiding in there. Run validate

and it stops you at the first hard error:

python -m router.cli validate broken_config.yaml
{
  "ok": false,
  "error": "CONFIG_VALIDATION_ERROR",
  "message": "Container 'support_entry' has invalid transition target 'billing'. Must be a container name or one of {'REFUSE', 'PROCEED', ''}."
}

Exit code 2

. Notice what the error message actually does: it names the offending step (support_entry

), the bad value (billing

), and the set of things that would have been valid. That last part is the difference between an error that stops you and an error that helps you. More on that below.

validate

is pass/fail β€” good for CI, where you want a nonzero exit and the first blocking reason. But when you're actually fixing a workflow, you want the whole picture, not one error at a time. That's what analyze

is for: it inspects the topology and reports everything, graded by severity, without raising.

python -m router.cli analyze broken_config.yaml
{
  "container_count": 3,
  "entry_containers": [
    "orphaned_step"
  ],
  "entrypoints_declared": false,
  "cycle_count": 1,
  "cycles": [
    ["faq", "support_entry"]
  ],
  "issues": [
    {
      "severity": "ERROR",
      "code": "CYCLE_WITH_REENTRY_BLOCKED",
      "message": "Cycle ['faq', 'support_entry'] includes containers with allow_reentry=false: ['support_entry']"
    },
    {
      "severity": "ERROR",
      "code": "UNKNOWN_TARGET",
      "message": "Container 'support_entry' transitions to unknown 'billing'."
    },
    {
      "severity": "WARNING",
      "code": "UNREACHABLE_CONTAINERS",
      "message": "Unreachable containers: ['faq', 'support_entry']"
    }
  ]
}

This one report catches all three bugs I planted:

UNKNOWN_TARGET

billing

typo. A transition to a step that isn't in the graph.CYCLE_WITH_REENTRY_BLOCKED

faq

transitions back to support_entry

, forming a loop faq β†’ support_entry β†’ faq

, but support_entry

declared allow_reentry: false

. The workflow contradicts itself: the topology requires re-entering a step the config forbids re-entering.entrypoints

block, the analyzer had to orphaned_step

, so it wrongly became the "entry." Everything else is now unreachable from that inferred root, hence the UNREACHABLE_CONTAINERS

warning. This is a great argument for always declaring entrypoints

explicitly: it removes the guessing.The severity split matters. An ERROR

is a contradiction that makes the workflow structurally invalid β€” it blocks. A WARNING

is a smell worth surfacing but not necessarily fatal. Cycles are a nice example of the distinction: a loop through steps that all permit re-entry is a WARNING

(it can legitimately terminate via max_invocations

), while a loop through a step that forbids re-entry is an ERROR

(it can't).

The CLI is a thin wrapper. If you're building the workflow programmatically β€” or want validation inside your own app's startup path β€” the same checks are one function call.

from router.engine import WorkflowEngine
from router.models.config import ContainerConfig, WorkflowConfig
from router.models.enums import InteractionState
from router.models.metadata import InteractionMetadata
from router.validation.config_validator import validate_workflow_config

cfg = WorkflowConfig(
    containers={
        "support_entry": ContainerConfig(
            allow_transitions=["faq", "REFUSE"],
            allow_reentry=False,
            max_invocations=3,
        ),
        "faq": ContainerConfig(
            allow_transitions=["REFUSE"],
            allow_reentry=True,
            max_invocations=5,
        ),
    },
    entrypoints=["support_entry"],
)

validate_workflow_config(cfg)   # raises ConfigValidationError if the graph is invalid
print("Config valid βœ“")

validate_workflow_config

raises ConfigValidationError

on any hard error, so the natural place to call it is at load time β€” the workflow simply refuses to start if its shape is wrong. That's the whole idea: an invalid state is made unrepresentable at runtime because it was rejected before runtime began.

Once the config is validated, evaluating a step is deterministic β€” the same metadata always yields the same decision:

engine = WorkflowEngine(cfg)

metadata = InteractionMetadata(
    container="support_entry",
    previous_state=InteractionState.PROCEED,
    transition_history=[],
    invocation_depth={"support_entry": 1},
    requested_action="faq",
    trace_id="demo-001",
)

print(engine.evaluate(metadata))
EvaluationResult(state=<InteractionState.PROCEED: 'PROCEED'>, container='support_entry', reason=None, trace_id='demo-001')

The engine checks the request against the validated rules: is support_entry

a real step? Under its invocation ceiling? Is faq

in its allow_transitions

? All yes, so the result is PROCEED

. Ask for a transition that isn't allowed and you'd get a REFUSE

with a machine-readable reason

like INVALID_TRANSITION

β€” no exceptions thrown mid-flight, just an explicit, inspectable decision.

Go back and look at that first failure message:

Container 'support_entry' has invalid transition target 'billing'. Must be a container name or one of {'REFUSE', 'PROCEED', ''}.

It would have been easier to write ValueError: invalid transition

. It would also have been useless. A good validation error answers three questions without making the reader go digging: where is the problem (support_entry

), what is wrong (billing

isn't a valid target), and what would be right (a real step name, or a terminal state). The analyze

codes do the same job for tooling β€” UNKNOWN_TARGET

, CYCLE_WITH_REENTRY_BLOCKED

, ORPHAN_CONTAINER

are greppable, stable identifiers you can build dashboards or CI rules around.

If you take one habit away from this, let it be that one. Validation that only says no makes people resent your tool. Validation that says no, here, because of this, and here's what valid looks like makes people trust it. The effort is a few extra f-strings; the payoff is every future debugging session.

Structural bugs in an LLM workflow β€” dead ends, phantom transitions, non-terminating loops β€” are present the instant the config is written. There's no reason to let them surface at runtime. By modeling the workflow as an explicit graph and validating its topology at load time, you convert a whole category of production incidents into a fast, boring failure at startup with a message that tells you exactly what to fix.

Everything here runs against the open-source llm-workflow-router (MIT). Try

validate

and analyze

on your own configs β€” and if you're building anything where an AI system hands off between steps, put the validation call in your startup path before you need it.

── more in #large-language-models 4 stories Β· sorted by recency
── more on @llm-workflow-router 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/validate-your-llm-wo…] indexed:0 read:8min 2026-07-13 Β· β€”