Defending Against LLM Output: Parsing Dirty JSON in C#
Post 3 in my series on an automation hub for a ticket-resale business. If you've ever put an LLM into a production pipeline, you know the feeling: the prompt clearly says "return ONLY valid JSON," and the model responds with... a friendly greeting, a markdown code fence, and then the JSON — occasionally missing a bracket. This post collects the techniques I use to parse LLM output safely in C#.
The problem: an LLM is an "API" with no schema guarantee
In my email-classification pipeline (see post 2), the model must return a list of objects like:
{
"uid": "452455",
"subject": "Presale starts Friday!",
"performer": "...",
"venue": "...",
"event_date": "Oct 10, 2026",
"label": "PRESALES",
"confidence": 0.92
}In practice the model returns every possible variation: uid is sometimes a string and sometimes a number, label is occasionally null, confidence arrives as 0.92 one day and "0.92" the next, and the whole thing may be wrapped in ```json ... ``` with narrative text around it. Deserialize that directly with System.Text.Json and the pipeline dies on its second run.
The principle I landed on: treat LLM output like user input, not like an API response. Everything goes through a defensive layer.
Layer 1: Separating JSON from prose — a balanced scanner
Regex isn't reliable enough to cut JSON out of surrounding text (nested objects, strings containing braces...). I wrote ExtractFirstJsonObject: a scanner that tracks {}/[] depth, with state for being inside/outside a string literal and for escape characters. It finds the first opening character, runs until depth returns to zero, and returns exactly the first complete JSON block — surviving markdown fences, the model's greetings, and any "additional explanation" tacked on afterwards.
Layer 2: Custom JsonConverters for every temperamental type
String or number? Both.
// The LLM returns "452455" one day and 452455 the next
public class FlexibleStringConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, ...)
{
return reader.TokenType switch
{
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number => reader.GetInt64().ToString(),
_ => throw new JsonException()
};
}
}Enums by wire name, not by C# name
The label from the model is "PROMOTION_DISCOUNT", but the C# enum member is NatureType.PromotionsDiscounts. I wrote a JsonConverterFactory that reflects over [EnumMember] attributes to map wire names ↔ enum values in both directions, falling back to a case-insensitive Enum.TryParse when nothing matches.
Null must never crash the pipeline
The converter above is wrapped in a NullToEnumConverter<TEnum>: on null, it degrades to NatureType.Unknown instead of throwing. One unclassified email is far better than losing the whole batch.
Confidence: float, int, or string — all accepted
A ParseConfidence helper accepts all three shapes. Similarly, NormalizeText treats the literal string "null" (yes, the model actually prints the word null) as a real null.
Layer 3: Enforce business rules in code — never trust the prompt
The prompt asks for plenty of things, but the C# layer always has a backstop:
- Prompt: "uids must be unique" → code:
EnsureUniqueUidsappends-1,-2suffixes on collision. - Prompt: "refund amounts must be absolute values" → code:
Math.Absbefore writing to the sheet. - Prompt: "only return future events" → code: filter out past-dated events before sending the digest.
The general rule: prompts raise the hit rate; code guarantees correctness. The prompt is an optimization; the code is the invariant.
Bonus: the same techniques for human-written APIs
LLMs aren't the only source of temperamental data. One third-party API in this project (a purchasing-account management system) has fields that are sometimes a string, sometimes null, and sometimes an entire object. The solution: declare the property as a raw JsonElement plus a J() helper that stringifies based on the token type, along with [JsonExtensionData] to catch any unknown fields the upstream adds later — the API changes its schema, and the code doesn't fall over.
A checklist for putting LLMs into a production pipeline
- Always extract JSON with a balanced scanner; never trust "ONLY output JSON."
- Every field from the model goes through a lenient converter: string/number, null-safe, enums by wire name.
- Business invariants are enforced in code; the prompt is just high-quality advice.
- A failure on one item must never kill the whole batch — degrade item by item.
- Log the raw response before parsing: when the model gets "creative," you'll want the evidence.
Next post: when there's no API at all — syncing Lysted orders into SkyBox by scraping HTML emails out of Slack.
Comments
Post a Comment