@sedatdemirAra

Structured outputs: LLM'den güvenilir JSON alma

Bir LLM'e "bana JSON döndür" dediğinizde çoğu zaman JSON alırsınız. Ama bazen sonuna fazladan bir virgül koyar. Bazen alanı description yerine desc yapar. Bazen de JSON'un etrafına nazikçe bir açıklama paragrafı sarar. Üretim ortamında JSON.parse patladığında kullanıcıya gösterecek anlamlı bir hata mesajınız yoktur, çünkü modelin ne yapacağını tahmin edemezsiniz.

Bu yazıda LLM'lerden tutarlı, parse edilebilir, şemaya uygun JSON almak için güncel yöntemleri, birbirleriyle nasıl birleştirilebileceklerini ve her birinin sınırlarını anlatıyorum.

Sorunun kökü: serbest metin üretimi

Dil modelleri token token üretim yapar. Her token, bir önceki bağlama bakılarak olasılık dağılımından seçilir. Model "bu bir JSON çıktısıdır" kavramını anlar ama bunu garanti eden mekanik bir kısıtlama yoktur, en azından varsayılan modda. Prompt'a Return valid JSON yazmak modelin niyetini yönlendirir, çıktı formatını zorlamaz.

Bu yüzden prompt mühendisliği tek başına yetmez. İki farklı katmanda çözüm gerekir: üretim sırasında kısıtlama (constrained decoding) ve üretim sonrasında doğrulama.

Provider-native structured output desteği

Güncel büyük API sağlayıcıları artık JSON Schema'yı doğrudan kabul edip çıktıyı o şemaya zorluyor.

OpenAI: response_format ile JSON Schema

OpenAI, gpt-4o ve sonraki modellerde response_format parametresine json_schema tipi geçilmesini destekliyor. Model, constrained decoding ile şemaya uyan tokenları seçmeye zorlanıyor.

import OpenAI from "openai";

const client = new OpenAI();

const response = await client.chat.completions.create({
  model: "gpt-4o-2024-08-06",
  messages: [
    {
      role: "system",
      content: "Verilen ürün açıklamasından yapılandırılmış bilgi çıkar.",
    },
    {
      role: "user",
      content: "Apple MacBook Air M3, 16GB RAM, 512GB SSD, uzay grisi",
    },
  ],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "product_info",
      strict: true,
      schema: {
        type: "object",
        properties: {
          brand: { type: "string" },
          model: { type: "string" },
          specs: {
            type: "object",
            properties: {
              ram_gb: { type: "number" },
              storage_gb: { type: "number" },
            },
            required: ["ram_gb", "storage_gb"],
            additionalProperties: false,
          },
          color: { type: "string" },
        },
        required: ["brand", "model", "specs", "color"],
        additionalProperties: false,
      },
    },
  },
});

const product = JSON.parse(response.choices[0].message.content!);
console.log(product.specs.ram_gb); // 16

strict: true ayarı şemanın tam olarak uygulanmasını sağlıyor. Bu modda additionalProperties: false zorunlu; aksi halde API hata döner. Bir kısıtlama var: strict mod oneOf, anyOf gibi bazı JSON Schema özelliklerini desteklemiyor (OpenAI Structured Outputs dokümanı, Ağustos 2024 güncellemesi).

Anthropic: tool_choice ile zorunlu tool use

Anthropic'in Claude modelleri doğrudan response_format sunmuyor. Bunun yerine tool use (function calling) mekanizması üzerinden structured output elde ediliyor. Bir tool tanımlayıp tool_choice ile o tool'u zorunlu kılınca model çıktısını tool'un input_schema'sına uygun JSON olarak veriyor.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const response = await client.messages.create({
  model: "claude-sonnet-4-20250514",
  max_tokens: 1024,
  tools: [
    {
      name: "extract_product",
      description: "Ürün bilgilerini yapılandırılmış olarak çıkarır",
      input_schema: {
        type: "object" as const,
        properties: {
          brand: { type: "string" },
          model: { type: "string" },
          ram_gb: { type: "number" },
          storage_gb: { type: "number" },
          color: { type: "string" },
        },
        required: ["brand", "model", "ram_gb", "storage_gb", "color"],
      },
    },
  ],
  tool_choice: { type: "tool", name: "extract_product" },
  messages: [
    {
      role: "user",
      content: "Apple MacBook Air M3, 16GB RAM, 512GB SSD, uzay grisi",
    },
  ],
});

const toolBlock = response.content.find((b) => b.type === "tool_use");
if (toolBlock && toolBlock.type === "tool_use") {
  console.log(toolBlock.input); // { brand: "Apple", model: "MacBook Air M3", ... }
}

Bu yaklaşım aslında bir "sahte tool call". Tool'u gerçekten çağırmıyorsunuz, sadece modelin çıktı formatını kısıtlamak için kullanıyorsunuz. İşe yarıyor ama semantik olarak biraz garip.

Google Gemini: responseSchema

Gemini API'de generationConfig içinde responseMimeType: "application/json" ve responseSchema ile doğrudan şema zorunlu kılınabiliyor. Gemini 1.5 Pro ve 2.0 modellerinde bu özellik aktif.

Vercel AI SDK ile provider-agnostic structured output

Her provider için ayrı API şekli yazmak yorucu. Vercel AI SDK, generateObject fonksiyonu ile provider farkını soyutluyor ve Zod şemasını otomatik olarak JSON Schema'ya çeviriyor.

import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const { object } = await generateObject({
  model: openai("gpt-4o"),
  schema: z.object({
    brand: z.string(),
    model: z.string(),
    specs: z.object({
      ram_gb: z.number(),
      storage_gb: z.number(),
    }),
    color: z.string(),
  }),
  prompt: "Apple MacBook Air M3, 16GB RAM, 512GB SSD, uzay grisi",
});

// object tipi otomatik olarak çıkarılır
console.log(object.specs.ram_gb); // 16

Burada object değişkeni TypeScript tarafında tam tiplidir. Zod şemasını değiştirdiğinizde derleme hatası alırsınız. Bu, end-to-end tip güvenliği açısından büyük bir kazanç.

generateObject arka planda provider'a göre doğru stratejiyi seçer: OpenAI için response_format, Anthropic için tool use, desteklemeyen modeller için ise prompt'a şema talimatı enjekte edip çıktıyı parse eder.

Yerel modellerde constrained decoding

Yerel modeller çalıştırırken de structured output mümkün. Ollama 0.5.0 ve sonrasında format parametresine JSON Schema geçilebiliyor.

curl http://localhost:11434/api/generate -d '{
  "model": "llama3.1",
  "prompt": "Extract product info: iPhone 15 Pro, 256GB, Natural Titanium",
  "format": {
    "type": "object",
    "properties": {
      "brand": { "type": "string" },
      "model": { "type": "string" },
      "storage_gb": { "type": "number" },
      "color": { "type": "string" }
    },
    "required": ["brand", "model", "storage_gb", "color"]
  },
  "stream": false
}'

Ollama bu işi llama.cpp'nin GBNF grammar desteği üzerinden yapıyor. Her token üretilirken grammar kurallarına uymayan tokenlar maskeleniyor. Sonuç her zaman geçerli JSON.

Ancak bir uyarı: constrained decoding şeklin doğru olmasını garanti eder, içeriğin doğru olmasını değil. Model ram_gb alanına 32 yazabilir gerçekte 16 olmasına rağmen. Format doğruluğu ile semantik doğruluk farklı problemler.

Doğrulama katmanı: parse ettikten sonra ne yapmalı

Structured output provider tarafında zorlanmış olsa bile bir doğrulama katmanı eklemek iyi pratiktir. Birkaç neden var: model refusal dönebilir (OpenAI güvenlik filtresi), provider API'si değişebilir, ya da birden fazla provider arasında geçiş yapıyorsanız tutarlılık istersiniz.

Zod burada doğal bir tercih. Zod v4 ile parse ve validasyon tek adımda yapılıyor.

import { z } from "zod";

const ProductSchema = z.object({
  brand: z.string().min(1),
  model: z.string().min(1),
  specs: z.object({
    ram_gb: z.number().positive(),
    storage_gb: z.number().positive(),
  }),
  color: z.string().min(1),
});

type Product = z.infer<typeof ProductSchema>;

function parseLLMResponse(raw: string): Product {
  const parsed = JSON.parse(raw);
  const result = ProductSchema.safeParse(parsed);

  if (!result.success) {
    // Hata detayları result.error.issues içinde
    throw new Error(
      `LLM çıktısı şemaya uymuyor: ${result.error.issues.map((i) => i.message).join(", ")}`
    );
  }

  return result.data;
}

.min(1) ve .positive() gibi refinement'lar JSON Schema'da ifade edilemeyen kısıtlamalardır. Provider'ın constrained decoding'i boş string veya negatif sayı üretmesini engellemez. Bu yüzden Zod katmanı gerçek bir güvenlik ağı.

Retry stratejisi

Doğrulama başarısız olduğunda ne yapmalı? En basit yaklaşım: hatayı modele geri verip tekrar denemek.

import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

async function extractWithRetry(prompt: string, maxAttempts = 3) {
  const schema = z.object({
    brand: z.string().min(1),
    model: z.string().min(1),
    ram_gb: z.number().int().positive(),
    storage_gb: z.number().int().positive(),
    color: z.string().min(1),
  });

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const { object } = await generateObject({
        model: openai("gpt-4o"),
        schema,
        prompt,
      });
      return object;
    } catch (err) {
      if (attempt === maxAttempts) throw err;
      // generateObject zaten Zod validation yapıyor,
      // başarısız olursa hata fırlatır
    }
  }
}

generateObject Zod şemasını hem JSON Schema'ya çevirip provider'a gönderiyor hem de dönen cevabı Zod ile doğruluyor. İki katmanlı koruma tek fonksiyon çağrısında.

Function calling ve structured output farkı

Bu iki kavram sık karıştırılıyor. Function calling, modele "şu fonksiyonları çağırabilirsin" demek. Model hangi fonksiyonu çağıracağına, hangi argümanları geçeceğine karar verir. Structured output ise "cevabını şu formatta ver" demek. Anthropic'te structured output'u tool use üzerinden yapmanız bu karışıklığı artırıyor.

Pratik fark şu: function calling'de model bazen hiçbir fonksiyon çağırmamayı tercih edebilir (serbest metin döner). tool_choice: "auto" bunu mümkün kılar. Structured output istiyorsanız tool_choice'u belirli bir tool'a zorlayın veya OpenAI'da response_format kullanın.

AI ajanları geliştirirken function calling gerçek fonksiyon çağrısı içindir. Sırf JSON formatı almak için function calling kullanıyorsanız, aslında structured output istiyorsunuz demektir.

Enum ve union tipleri

JSON Schema'da enum desteklenir ve modelin belirli değerler arasından seçim yapmasını zorlar.

const SentimentSchema = z.object({
  text: z.string(),
  sentiment: z.enum(["positive", "negative", "neutral"]),
  confidence: z.number().min(0).max(1),
});

Bu şema OpenAI strict mode ile kullanıldığında model sentiment alanına yalnızca üç değerden birini yazabilir. Token seviyesinde diğer tüm olasılıklar maskelenir.

Discriminated union'lar (Zod'da z.discriminatedUnion) ise daha karmaşık. OpenAI strict mode anyOf desteklemediği için bu tip şemaları doğrudan kullanamazsınız. Çözüm: her varyant için ayrı bir tool tanımlayıp modelin birini seçmesini sağlamak ya da şemayı düzleştirip opsiyonel alanlarla ifade etmek.

Streaming ile structured output

Vercel AI SDK'nın streamObject fonksiyonu, JSON'u kısmi olarak stream ediyor ve her adımda Zod ile partial validation yapıyor.

import { streamObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const { partialObjectStream } = streamObject({
  model: openai("gpt-4o"),
  schema: z.object({
    title: z.string(),
    summary: z.string(),
    tags: z.array(z.string()),
  }),
  prompt: "Bu makaleyi analiz et: ...",
});

for await (const partial of partialObjectStream) {
  // partial.title ilk gelen kısım olabilir
  // partial.tags henüz undefined olabilir
  console.log(partial);
}

Bu, generative UI pattern ile birleştiğinde güçlü bir kullanıcı deneyimi verir: kullanıcı JSON alanlarının birer birer dolduğunu görebilir.

Ne zaman hangisini kullanmalı

Provider'ın native structured output desteği varsa (OpenAI response_format, Gemini responseSchema) doğrudan onu kullanın. Constrained decoding, prompt talimatından çok daha güvenilirdir.

Birden fazla provider arasında geçiş yapmanız gerekiyorsa ya da Zod şemasından otomatik tip çıkarımı istiyorsanız Vercel AI SDK'nın generateObject/streamObject fonksiyonları iyi bir soyutlama katmanı.

Yerel model çalıştırıyorsanız Ollama'nın format parametresi ya da doğrudan llama.cpp'nin grammar desteği işinizi görür.

Hangi yolu seçerseniz seçin, bir Zod doğrulama katmanı ekleyin. Constrained decoding format doğruluğunu garanti eder ama semantik doğruluğu, alan uzunluklarını, sayı aralıklarını garanti etmez. Son savunma hattı her zaman uygulama tarafındaki validasyondur.