Back to all posts
·5 min read

Wiring LLM output into code: schema, validation and a repair loop

When you connect a language model to your application, the first real question is: how does the model's answer enter your code? Showing free text on a screen is easy. But if the answer has to be a classification result, an order draft or a command for another service, you have to parse it, and that is where things get fragile quickly.

In this post I describe the approach I follow when asking a model for structured output: design the schema as a contract, always validate the output on the server, try a limited number of repairs on failure, and fall back to a safe path if that still does not work. The examples are in C#, but the idea is the same in any language.

Model output is untrusted input

The most important mental shift is this: the JSON a model returns is as untrusted as form data from a user. Many providers support schema-constrained output, and that noticeably lowers the error rate. Still, leaning on that feature and skipping validation would be a mistake. Schema support does not guarantee semantic correctness; a model can produce valid JSON with a wrong value inside. A dropped connection, a truncated response or a provider change can also break the format.

So I think in two layers: steer the model towards the schema on its side, and on the application side validate everything as if it came from outside.

Designing the schema as a contract

A good schema makes the model's job easier. A few practical rules:

  • Keep the number of fields small. Every extra field is one more place to make a mistake.
  • Use closed sets (enums) instead of free text, for example low, medium, high for priority.
  • Write meaningful field names and descriptions; the model is guided by field names too.
  • Leave an explicit way out for "I don't know": an empty value or an option like unknown. Otherwise the model is pushed to invent something.

As an example, a record that classifies a support ticket:

public enum Priority { Low, Medium, High }

public sealed record TicketTriage(
    string Category,
    Priority Priority,
    bool NeedsHuman,
    string? Reason);

The NeedsHuman field is a small but valuable safety valve: if the model is unsure it can say so explicitly, and you stop the automated flow.

Validating on the server

Parsing and validating are separate steps. First make sure the JSON is well-formed, then make sure the values obey your business rules:

public static bool TryParse(string raw, out TicketTriage? result, out string error)
{
    result = null;
    error = "";

    try
    {
        result = JsonSerializer.Deserialize<TicketTriage>(raw, Options);
    }
    catch (JsonException ex)
    {
        error = $"Invalid JSON: {ex.Message}";
        return false;
    }

    if (result is null) { error = "Empty response"; return false; }

    if (!Allowed.Contains(result.Category))
    {
        error = $"Unknown category: {result.Category}";
        return false;
    }

    return true;
}

Two details matter here. First, you check fields such as the category against your own allowed list, even if the schema already says so. Second, the error message is readable for humans and also useful to the model: in the next step we will hand it back.

Remember to use JsonStringEnumConverter and to fail on unknown values; silently falling back to a numeric default is one of the hardest bugs to notice.

A bounded repair loop

When validation fails, the first reflex is to repeat the same request. One or two retries are reasonable, but reporting the error to the model is more effective than blind repetition: include the previous answer and the validation error, and ask for corrected output.

for (var attempt = 0; attempt < MaxAttempts; attempt++)
{
    var raw = await model.CompleteAsync(messages, ct);

    if (TryParse(raw, out var ok, out var error))
        return ok;

    messages.Add(Assistant(raw));
    messages.Add(User($"The output failed validation: {error}. Return only JSON that matches the schema."));
}

return null; // move on to the fallback path

An upper bound is mandatory. An unbounded loop lets both cost and latency get out of control. I usually settle for two or three attempts in total; an input that does not get fixed by the third attempt is most often ambiguous or outside the model's scope. Log every attempt and its reason; which fields break most often is the best hint for improving the schema.

Fallback: failure is also a result

If the repair attempts run out as well, what the system does must have been designed up front. The options depend on context:

  • Hand the work to a human queue.
  • Fall back to a simpler, rule-based method.
  • Tell the user clearly that "this could not be processed automatically right now".

Whichever you choose, the rule is this: never let unvalidated output continue. A wrong but well-formed command reaching another service is far more expensive than sending no command at all. Also add a separate authorization and limit check before any action with side effects; the model saying "do it" does not mean it may be done.

Testing and monitoring

Test the structured output pipeline like any other code. Write ordinary unit tests for the parser and validator: a missing field, an extra field, a wrong type, an unknown enum value, truncated JSON. These tests need no real model call, they are fast, and they catch regressions.

For model behaviour, keep a small evaluation set: real example inputs and expected results. When you change the prompt or the model, run this set and check that the validation success rate and accuracy have not dropped. In production, also measure the share of responses that pass on the first attempt, the number of repairs and how often the fallback triggers. These three numbers summarize the health of the pipeline.

Closing

Getting structured output from a model is not a matter of "ask for JSON and hope"; it is building a small data-processing pipeline. Keep the schema simple and closed, validate every response on the server, repair a limited number of times by feeding the error back, and fall back to a safe path when that fails. These four steps keep your application predictable no matter how much the model changes.