Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
Written By: BrainSoft In AI & Research
You ask the model for JSON, it hands back a friendly sentence wrapped around a code fence, and your json.loads throws at 2am. This is one of the most common integration problems we hit when wiring LLMs into existing systems, and it is almost never the model being dumb. It is the contract between your code and the model being vague.
The fix is to treat the model like any other untrusted service: define a strict schema, ask for it explicitly, validate every response against that schema, retry once with the validation error fed back, and keep a tolerant parser as a last resort. Below is the sequence we use on real projects.
response_format with a JSON schema, or Anthropic tool use with an input schema, constrains decoding. It is not a guarantee, but it removes most of the noise.Here is the request shape we use with OpenAI's structured outputs. The schema is short on purpose; every extra optional field is another place the model can drift.
from openai import OpenAI
import json
client = OpenAI()
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "number"}
},
"required": ["title", "tags", "confidence"],
"additionalProperties": False
}
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Return only JSON matching the schema. No prose."},
{"role": "user", "content": article_text},
],
response_format={"type": "json_schema", "json_schema": {"name": "summary", "schema": schema, "strict": True}},
)
data = json.loads(resp.choices[0].message.content)
That gets you most of the way. The remaining work is the validation layer, which is where the real reliability lives. We use Pydantic because it gives you typed objects and readable error messages in one step.
from pydantic import BaseModel, ValidationError
class Summary(BaseModel):
title: str
tags: list[str]
confidence: float
try:
parsed = Summary.model_validate_json(raw)
except ValidationError as e:
# feed str(e) back to the model as a correction turn
...
Three details matter more than they look. First, additionalProperties: false and a full required list: optional fields invite the model to invent or omit. Second, keep field names boring and unambiguous. A field called date will sometimes come back as "next Tuesday"; call it iso_date and say so in the description. Third, if you need enums, declare them as enums in the schema rather than listing allowed values in prose.
The tolerant extractor is a small function worth writing once. It should handle a leading code fence, a trailing sentence, and a stray comma. If it still cannot parse, that is a bug report, not a silent fallback.
import json, re
def extract_json(text: str):
text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.MULTILINE)
start = text.find("{")
if start == -1:
raise ValueError("no JSON object found")
depth = 0
for i, ch in enumerate(text[start:], start):
depth += ch == "{"
depth -= ch == "}"
if depth == 0:
return json.loads(text[start:i + 1])
raise ValueError("unbalanced braces")
Log the raw response alongside the parsed result on every call, at least in staging. When something drifts, the raw text tells you whether the model ignored the schema or your parser did. We have seen both, and guessing wastes hours. If you are wiring this into a larger system, treat the LLM call as one adapter behind an interface, so replacing the provider later does not ripple through your codebase. That is the kind of thing we do regularly as part of our services.
No. It constrains the decoder heavily and makes failures rare, but truncation, context limits, and provider bugs still produce invalid output. Always validate on arrival.
Use JSON Schema. It can be enforced by the provider and reused for validation in your own code. Prose descriptions drift and cannot be checked automatically.
One. Feed the validation error back, and if it fails again, log the raw response and surface an error. More retries usually mean the schema or prompt is wrong.