RUBY ON RAILS · 19 MIN READ ·

Rails LLM Structured Output: Betrouwbare JSON van Claude en OpenAI met Schema Validatie

Rails LLM structured output gids: betrouwbare JSON van Claude en OpenAI met schema mode, response validatie en retries. Geen code fences meer parseren.

Rails LLM Structured Output: Betrouwbare JSON van Claude en OpenAI met Schema Validatie

Een logistieke klant belde me op een dinsdag omdat hun AI-gedreven zending-classifier stilzwijgend 3% van de orders in een “ongeclassificeerd” wachtrij dumpte die niemand in de gaten hield. Zes weken lang had het foutloos gedraaid. De verandering die het brak was geen codewijziging — Anthropic had een nieuw Claude-model uitgebracht, de classifier was overgestapt, en het model verpakte zijn JSON-response nu ongeveer één keer op de dertig in ` json ... ` markdown fences. Hun parser deed JSON.parse(response.text) en raiseerde, de exception werd rescued, de zending kreeg de tag unclassified, en hun ops-team herclassificeerde ze elke ochtend rustig met de hand zonder engineering iets te vertellen. Rails LLM structured output — goed gedaan, met schemas en validatie, geen string parsing — had dit bij de eerste request gevangen.

Na negentien jaar Rails en drie jaar LLM-features in productie brengen, heb ik elke regex verwijderd die ik ooit heb geschreven om JSON uit een model-response te trekken. De industrie is eind 2024 eindelijk geconvergeerd naar schema-constrained outputs, zowel Claude als OpenAI bieden het aan via hun API’s, en er is geen reden meer om triple backticks te gsub-en of openingsbraces te tellen. Dit artikel is hoe ik Rails LLM structured output in een Rails-app aansluit voor beide providers, hoe ik valideer wat terugkomt, en hoe ik de faalscenario’s aanpak die zelfs met schema-beperkingen nog voorkomen.

Waarom Rails LLM Structured Output Beter is dan Prompt Engineering

De pre-schema aanpak was om een prompt te schrijven als “return only JSON, no commentary, no markdown, no code fences” en er dan een regex bovenop te bouwen om de eerste {...} block uit de response te trekken. Dit werkt meestal. Meestal is niet goed genoeg voor productie. Modellen driften, prompts worden aangepast, een nieuwe fine-tune lekt een training-artefact, en ineens komt 3% van je requests terug als Here is the JSON you requested: json {...}`` en je parser explodeert.

Rails LLM structured output met provider-native schema-beperkingen is anders. Het model wordt niet gevraagd om JSON te produceren — het wordt grammaticaal beperkt op het token-sampling niveau zodat het alleen tokens kan uitzenden die de output geldig houden tegen jouw schema. Claude doet dit via tool use (je definieert een tool met een input schema en dwingt het model om het aan te roepen). OpenAI doet dit via response_format: { type: "json_schema", ... }. In beide gevallen is de output gegarandeerd parseerbaar als JSON en conformeert gegarandeerd aan jouw schema-keys, types en verplichte velden. Refusals en safety-responses bestaan nog steeds, maar je weet bij het parseren of je een geldige payload of een refusal hebt gekregen — geen dubbelzinnigheid.

De andere reden om dit belangrijk te vinden: Rails-apps die LLMs aanroepen, voeren de response meestal in een downstream getypeerd systeem — een ActiveRecord model, een Sidekiq job-argument, een Turbo Stream partial. Wanneer de vorm gegarandeerd is, kun je de defensieve response.dig(:items, 0, :name) rescue nil scaffolding weglaten die zich rondom elke LLM-call site opbouwt.

Het Schema is het API Contract

Voordat je een van beide provider-SDK’s aanraakt, definieer je jouw schema op één plek. Ik gebruik plain Ruby hashes omdat beide provider-API’s JSON Schema willen en ik geen vertaallaag wil tussen mijn schema-definitie en het wire-formaat.

# app/llm/schemas/shipment_classification.rb
module LLM
  module Schemas
    SHIPMENT_CLASSIFICATION = {
      type: "object",
      properties: {
        category: {
          type: "string",
          enum: %w[electronics apparel food hazardous other],
          description: "Primary shipment category based on the item description."
        },
        confidence: {
          type: "number",
          minimum: 0,
          maximum: 1,
          description: "Model confidence between 0.0 and 1.0."
        },
        requires_review: {
          type: "boolean",
          description: "True if the item is ambiguous or borderline hazardous."
        },
        reasoning: {
          type: "string",
          description: "One-sentence justification, plain English, no markdown."
        }
      },
      required: %w[category confidence requires_review reasoning],
      additionalProperties: false
    }.freeze
  end
end

Twee dingen zijn hier belangrijk. required moet elke key vermelden die je wilt lezen, anders kan het model ze weglaten en zal jouw .fetch(:category) raisen. additionalProperties: false voorkomt dat het model extra velden verzint die dan gelogd worden en debugging in de war schoppen. Beide providers respecteren deze beperkingen strikt.

De description-velden zijn niet decoratief — ze zijn de eigenlijke prompt die het model gebruikt om te beslissen wat er voor elk veld moet worden uitgezonden. Behandel ze als docstrings voor toekomstige-jij.

Claude Tool Use voor Structured Output

Claude heeft geen json_mode flag. Het idiomatische patroon is om één tool te definiëren waarvan de input_schema jouw schema is, en dan tool_choice te zetten om het model te dwingen die aan te roepen. De response komt terug als een tool_use block waarvan de input een gevalideerde instantie van jouw schema is.

# app/llm/claude_client.rb
require "anthropic"

class LLM::ClaudeClient
  MODEL = "claude-sonnet-5"

  def initialize
    @client = Anthropic::Client.new(api_key: ENV.fetch("ANTHROPIC_API_KEY"))
  end

  def structured(system:, user:, schema:, tool_name:)
    response = @client.messages.create(
      model: MODEL,
      max_tokens: 1024,
      system: system,
      messages: [{ role: "user", content: user }],
      tools: [{
        name: tool_name,
        description: "Return the classification result.",
        input_schema: schema
      }],
      tool_choice: { type: "tool", name: tool_name }
    )

    tool_use = response.content.find { |b| b.type == "tool_use" }
    raise LLM::EmptyResponse, "no tool_use block returned" unless tool_use

    tool_use.input.deep_symbolize_keys
  end
end

tool_choice: { type: "tool", name: tool_name } is het stukje dat mensen missen. Zonder dit kan Claude in gewone tekst antwoorden als het denkt dat de tool niet het beste antwoord is, wat het parseerprobleem opnieuw introduceert dat je probeerde te vermijden. Het forceren van de tool garandeert dat de response een gestructureerd tool_use block is. De input op dat block is een Ruby hash waarvan de vorm overeenkomt met jouw schema.

Eén eigenaardigheid: Claude geeft af en toe nil of leeg terug voor optioneel-uitziende numerieke velden, zelfs als ze required zijn. Ik heb dit niet betrouwbaar kunnen reproduceren. Mijn verdediging is downstream validatie, waar ik zo op terugkom.

OpenAI json_schema Response Format

OpenAI voegde response_format: { type: "json_schema" } eind 2024 toe en dit is nu de juiste manier om Rails LLM structured output met GPT-modellen te doen. De syntaxis is verbose maar expliciet — je geeft het schema, een naam en strict: true door.

# app/llm/openai_client.rb
require "openai"

class LLM::OpenAIClient
  MODEL = "gpt-4o-2024-11-20"

  def initialize
    @client = OpenAI::Client.new(access_token: ENV.fetch("OPENAI_API_KEY"))
  end

  def structured(system:, user:, schema:, schema_name:)
    response = @client.chat(
      parameters: {
        model: MODEL,
        messages: [
          { role: "system", content: system },
          { role: "user",   content: user }
        ],
        response_format: {
          type: "json_schema",
          json_schema: {
            name: schema_name,
            strict: true,
            schema: schema
          }
        }
      }
    )

    message = response.dig("choices", 0, "message")
    raise LLM::Refusal, message["refusal"] if message["refusal"]

    JSON.parse(message["content"], symbolize_names: true)
  end
end

Twee OpenAI-specifieke dingen. Ten eerste is strict: true vereist om de schema-beperking af te dwingen — zonder dit behandelt het model het schema als een suggestie. Ten tweede, wanneer het model weigert (safety filter, policy schending), komt de response terug met content: null en een refusal-veld dat de weigeringsreden als gewone tekst bevat. Check hier expliciet op. Als je die check overslaat en JSON.parse(nil) doet, krijg je een TypeError en verlies je de weigeringsreden, wat precies de informatie was die je nodig had om te loggen.

Een Provider-Onafhankelijke Adapter

Echte apps gebruiken uiteindelijk beide providers — één voor het primaire pad, één voor fallback, soms één per feature afhankelijk van kosten en latency. Verpak ze achter één interface zodat callers niet hoeven te weten welke de request bediende.

# app/llm/structured_output.rb
class LLM::StructuredOutput
  Result = Struct.new(:data, :provider, :model, :usage, keyword_init: true)

  def self.call(prompt:, schema:, schema_name:, provider: :claude)
    client = case provider
             when :claude then LLM::ClaudeClient.new
             when :openai then LLM::OpenAIClient.new
             else raise ArgumentError, "unknown provider #{provider}"
             end

    raw = client.structured(
      system: prompt[:system],
      user: prompt[:user],
      schema: schema,
      tool_name: schema_name,
      schema_name: schema_name
    )

    Result.new(data: raw, provider: provider, model: client.class::MODEL)
  end
end

client.structured neemt tool_name en schema_name omdat Claude de eerste wil en OpenAI de tweede; elke client negeert het argument dat het niet nodig heeft. Dit houdt de caller onwetend van provider-eigenaardigheden.

Valideren Wat Er Terugkomt

Schema-constrained outputs falen nog steeds op manieren die schemas niet kunnen vangen. Een confidence van 0.5 kan aan minimum: 0, maximum: 1 voldoen maar onzinnig zijn voor jouw business logic als je confidence boven 0.7 vereist om auto-goedkeuring te doen. Een category van hazardous kan door de enum-check komen maar in conflict zijn met de item-beschrijving op een manier die je alleen kunt detecteren door kruisverwijzing met je eigen database. Behandel het schema als syntactische validatie en voeg er semantische validatie bovenop toe.

Ik gebruik dry-validation voor de semantische pass omdat de contracten goed samengaan met ActiveRecord en gestructureerde error-objecten produceren die ik kan loggen.

# app/llm/contracts/shipment_classification.rb
require "dry/validation"

module LLM
  module Contracts
    class ShipmentClassification < Dry::Validation::Contract
      schema do
        required(:category).filled(:string, included_in?: %w[electronics apparel food hazardous other])
        required(:confidence).filled(:float, gteq?: 0.0, lteq?: 1.0)
        required(:requires_review).filled(:bool)
        required(:reasoning).filled(:string, min_size?: 5, max_size?: 500)
      end

      rule(:reasoning) do
        key.failure("looks like markdown") if value.include?("```") || value.include?("**")
      end
    end
  end
end

Nu wordt het call-site patroon: roep de LLM aan, valideer met het contract, beslis wat te doen bij failure.

class ShipmentClassifier
  def classify(item)
    result = LLM::StructuredOutput.call(
      prompt: build_prompt(item),
      schema: LLM::Schemas::SHIPMENT_CLASSIFICATION,
      schema_name: "shipment_classification",
      provider: :claude
    )

    validation = LLM::Contracts::ShipmentClassification.new.call(result.data)

    if validation.success?
      Classification.create!(item: item, **result.data, model: result.model)
    else
      Rails.logger.warn(
        event: "llm_semantic_validation_failed",
        errors: validation.errors.to_h,
        raw: result.data,
        model: result.model
      )
      raise LLM::ValidationError, validation.errors.to_h
    end
  end
end

De Rails.logger.warn-regel is niet decoratief. Semantische failures tegen anderszins schema-geldige output zijn de interessante bugs — een gelekt training-artefact, prompt drift, een nieuw model dat zich anders gedraagt. Je wilt ze in je logs waar je ze later kunt bevragen, niet weggeslikt.

Retrying, Maar Alleen bij de Juiste Fouten

Structured output kan nog steeds op drie manieren falen: transport-fout (netwerk, 5xx), refusal en semantische validatie. Retry de eerste, retry de tweede niet, retry de derde één keer met een hint in de prompt.

class LLM::WithRetry
  MAX_ATTEMPTS = 3

  def self.call(&block)
    attempts = 0
    begin
      attempts += 1
      block.call(attempt: attempts)
    rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Anthropic::APIError => e
      retry if attempts < MAX_ATTEMPTS
      raise
    rescue LLM::Refusal
      raise
    rescue LLM::ValidationError => e
      raise if attempts >= 2
      retry
    end
  end
end

Refusals zijn niet retryable — het model heeft al besloten dat jouw request buiten policy viel, dus retrying met dezelfde input zal weer weigeren en tokens verspillen. Semantische validatie-failures zijn één retry waard omdat het model een andere (geldige) response kan samplen, maar niet meer dan één of je betaalt gewoon voor degradeerde kwaliteit.

Voor de retry op validatie geef je de vorige response mee in de volgende call zodat het model kan zien wat het fout deed:

def build_prompt(item, previous_error: nil)
  user = "Classify this shipment item: #{item.description}"

  if previous_error
    user += "\n\nYour previous response had this validation error: " \
            "#{previous_error}. Please fix and re-emit."
  end

  { system: SYSTEM_PROMPT, user: user }
end

Wat Dit Vervangt

Voordat Rails LLM structured output landde als een first-class feature, had elke serieuze Rails-app een file die er zo uitzag:

def extract_json(text)
  cleaned = text.gsub(/^```(?:json)?\s*/, "").gsub(/```$/, "")
  match = cleaned.match(/\{.*\}/m)
  return nil unless match
  JSON.parse(match[0])
rescue JSON::ParserError
  nil
end

Elke van die files was een bron van stille bugs. De regex mist geneste braces, de rescue slikt de echte error op, de return value is nil bij failure dus de caller kan geen onderscheid maken tussen “geen response” en “ongeldige response”. Verwijder deze files. Gebruik response_format op OpenAI en geforceerde tool use op Claude. Dit is geen micro-optimalisatie — het is het sluiten van een hele klasse van productie-incidenten.

Er is een gerelateerd patroon voor het extraheren van structured output uit open-source modellen die geen native schemas ondersteunen (Llama, Mistral). Het bibliotheek-ecosysteem is geconvergeerd naar grammar-constrained sampling (llama.cpp GBNF grammars, outlines in Python, guidance). Als je je eigen inference draait en Rails LLM structured output nodig hebt, is de Ruby-kant hetzelfde — geef een JSON Schema over HTTP door — maar de server moet het ondersteunen. De meeste Rails-teams waarmee ik werk draaien in 2026 geen eigen inference; ze roepen Claude, OpenAI of Gemini rechtstreeks aan, en alle drie ondersteunen schemas.

Kosten en Latency

Schema-constrained output kost hetzelfde als niet-constrained output bij beide providers — je betaalt voor input- en output-tokens, en het schema neemt wat input-tokens in beslag. In de praktijk vermindert de schema-beschrijving ook output-tokens omdat het model niet hoeft uit te leggen wat het teruggeeft; het emit gewoon de velden. Bij de classifier van mijn logistieke klant sneed het overschakelen van prompt-engineered JSON naar schema-constrained tool use output-tokens met ongeveer 40% omdat het model stopte met het toevoegen van “Here is the classification you requested:” preambles.

Latency is essentieel identiek. Grammar-constrained sampling is een kleine overhead aan de modelkant, maar network round-trip domineert dus je zult het niet meten.

FAQ

Wat is Rails LLM structured output en waarom is het belangrijk?

Rails LLM structured output is het patroon van het gebruiken van provider-native schema-beperkingen (Claude tool use, OpenAI json_schema response format) om LLM-responses in een gevalideerde JSON-vorm te dwingen in plaats van vrije tekst met regexes te parsen. Het elimineert de hele klasse van “het model heeft zijn output in markdown verpakt”-bugs en laat je LLM-responses behandelen als getypeerde API-responses.

Ondersteunt Claude JSON mode zoals OpenAI?

Niet als een top-level flag. Claude bereikt hetzelfde resultaat door geforceerd tool use — definieer een tool waarvan de input_schema jouw schema is, zet tool_choice: { type: "tool", name: "..." }, en de response komt terug als een tool_use block waarvan de input een gevalideerde instantie van jouw schema is. Functioneel equivalent aan OpenAI’s response_format: { type: "json_schema" }.

Hoe ga ik om met refusals bij structured output?

OpenAI geeft een refusal-veld terug op het message-object wanneer het model weigert te antwoorden; check hier op voordat je JSON.parse op content aanroept. Claude heeft geen expliciet refusal-veld voor tool use — als het model weigert, geeft het een tekstblok terug in plaats van een tool_use-blok, dus check op de aanwezigheid van het verwachte tool_use voordat je iets extraheert. In beide gevallen: log de refusal en retry niet met dezelfde input.

Kan ik dry-validation gebruiken met LLM structured output?

Ja, en ik raad het aan. Schema-beperkingen geven je syntactische validatie (juiste types, juiste keys), maar semantische validatie (waarden die logisch zijn voor jouw business) vereist nog steeds een aparte pass. Dry-validation-contracten gaan goed samen met ActiveRecord, produceren gestructureerde error-objecten, en laten je regels afdwingen die het JSON-schema niet kan uitdrukken — zoals “het reasoning-veld mag geen markdown bevatten”.

Hulp nodig bij het inbouwen van betrouwbare AI-features in een Rails-app zonder de JSON-parsing horror show? TTB Software is gespecialiseerd in Rails, LLM-integratie en het uitleveren van productie-klare features die om 3 uur ‘s nachts niet omvallen. We doen dit al negentien jaar.

#rails-llm-structured-output #rails-openai-json-mode #rails-claude-tool-use-json #rails-json-schema-validation #rails-ai-response-parsing #rails-llm-retries

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software