LLM ile Structured Output: JSON Mode, JSON Schema ve GetResponseAsync<T>
Serbest metni regex ile parse etmenin neden kırıldığını, JSON mode'un neden yetmediğini ve JSON schema'nın biçimi nasıl sabitlediğini görüyoruz. TypeScript'te Zod, C#'ta GetResponseAsync<T> kullanıyor, schema'ya uyan ama yanlış bir cevabı yakalıyor ve iki katmanlı doğrulama kuruyoruz.
Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel modellerde (gemma3:4b) denenmiştir; .NET 10 ve Node 22 kullanılmıştır.
Önce Tahmin Et
Modelden bir kitap kaydını JSON olarak istiyorsun ve bir JSON schema veriyorsun. Cevap geliyor, parse ediliyor, tipine de uyuyor. Bu kaydı doğrudan veritabanına yazabilir misin? Başka bir deyişle: schema'ya uyan bir cevap, doğru bir cevap mıdır?
Birazdan schema'ya kusursuz uyan ama kitapçıya ücretsiz ve stokta olmayan bir kitap ekleyecek bir cevap göreceğiz. Önce oraya nasıl geldiğimize bakalım.
Serbest Metni Parse Etmek
Mağazaya yeni gelen kitaplar için personel kısa notlar yazıyor ve biz bu notlardan kayıt oluşturmak istiyoruz. En doğal ilk deneme, modelden bilgileri çıkarmasını istemek ve cevabı bir regex ile okumak. Regex'i temperature: 0'da aldığımız cevaba bakarak yazdık; o cevapta stok satırı **Number of Copies in Stock:** 2 şeklindeydi:
import ollama from 'ollama';
const note = `New in store: 'Project Hail Mary' by Andy Weir. Paperback, 496 pages.
Now 18.99 USD (was 22.00). Only 2 copies left on the shelf.`;
const prompt = `Extract the title, author, price and number of copies in stock from this note:\n${note}`;
// temperature 0'da aldığımız cevaba bakılarak yazıldı
function parseCopies(text: string): number | null {
const match = text.match(/\*\*Number of Copies in Stock:\*\*\s*(\d+)/);
return match ? Number(match[1]) : null;
}
for (let i = 1; i <= 5; i++) {
// Pek çok gerçek uygulamadaki gibi modelin varsayılanları (temperature 1)
const res = await ollama.chat({ model: 'gemma3:4b', messages: [{ role: 'user', content: prompt }] });
console.log(`run ${i}: copies = ${parseCopies(res.message.content)}`);
}
run 1: copies = null
run 2: copies = null
run 3: copies = null
run 4: copies = null
run 5: copies = 2
Beş denemenin dördünde regex hiçbir şey bulamadı. Model yanlış bir şey söylemedi; yalnızca etiketi bazen "Copies in Stock", bazen "Number of Copies in Stock" diye yazdı, bazen de kesme işaretini düz yerine kıvrık kullandı. Bilgi aynı, biçim farklı. Sampling yazısında gördüğümüz gibi her token bir olasılık dağılımından seçiliyor ve bu, etiketin kelimeleri için de geçerli. Serbest metni parse eden kod, modelin hiç söz vermediği bir biçime güveniyor.
temperature: 0 bu sorunu yalnızca gizler. Aynı model ve aynı prompt'la biçim sabit kalır, ama model güncellendiğinde, prompt'a bir cümle eklediğinde ya da girdi biraz değiştiğinde biçim yine kayar ve bunu ancak üretimde, sessizce boş gelen alanlardan fark edersin.
JSON Mode: Geçerli Ama Tahmin Edilemez
İlk iyileştirme, modelden JSON istemek. Ollama'da bunun en basit yolu format: "json", yani JSON mode. Aynı prompt'u bu kez iki biçimde gönderelim:
import ollama from 'ollama';
const note = `New in store: 'Project Hail Mary' by Andy Weir. Paperback, 496 pages.
Now 18.99 USD (was 22.00). Only 2 copies left on the shelf.`;
const prompt = `Extract the title, author, price and number of copies in stock from this note:\n${note}`;
for (const format of [undefined, 'json']) {
const res = await ollama.chat({
model: 'gemma3:4b',
messages: [{ role: 'user', content: prompt }],
format,
options: { temperature: 0, seed: 42 },
});
console.log(`--- format: ${format}`);
console.log(res.message.content);
}
--- format: undefined
Here's the extracted information:
* **Title:** Project Hail Mary
* **Author:** Andy Weir
* **Price:** 18.99 USD
* **Number of Copies in Stock:** 2
--- format: json
{
"title": "Project Hail Mary",
"author": "Andy Weir",
"price": "18.99 USD",
"copies_in_stock": 2
}
JSON mode geçerli bir JSON garanti ediyor ve gerçekten de JSON.parse bu cevabı sorunsuz okur. Ama içeriğin şekline dair hiçbir söz yok. Alan adını model seçti: copies_in_stock; senin TypeScript tipin copiesInStock bekliyorsa alan undefined gelir. Fiyatı da sayı yerine "18.99 USD" string'i olarak yazdı; bir sonraki çalıştırmada 18.99 ya da "$18.99" da yazabilir. JSON mode biçim sorununu bir kat aşağı itiyor, çözmüyor.
JSON Schema ile Biçimi Sabitlemek
Asıl çözüm, modele beklediğin yapıyı bir JSON schema olarak vermek. Ollama 0.5.0'dan beri (Aralık 2024) format alanına "json" yerine bir schema object'i de kabul ediyor ve üretimi bu schema ile sınırlıyor. TypeScript'te schema'yı elle yazmak yerine Zod ile tanımlayıp z.toJSONSchema ile üretmek, aynı tanımı hem istek hem doğrulama için kullanmanı sağlıyor:
import ollama from 'ollama';
import { z } from 'zod';
const Book = z.object({
title: z.string(),
author: z.string(),
format: z.enum(['paperback', 'hardcover', 'ebook']),
price: z.number(),
copiesInStock: z.number().int(),
});
type Book = z.infer<typeof Book>;
const note = `New in store: 'Project Hail Mary' by Andy Weir. Paperback, 496 pages.
Now 18.99 USD (was 22.00). Only 2 copies left on the shelf.`;
const schema = z.toJSONSchema(Book);
console.log(JSON.stringify(schema));
const res = await ollama.chat({
model: 'gemma3:4b',
messages: [{
role: 'user',
// Doküman schema'yı prompt'ta da tarif etmeyi öneriyor
content: `Extract the book from this note as JSON matching this schema: ${JSON.stringify(schema)}\n\n${note}`,
}],
format: schema,
options: { temperature: 0, seed: 42 },
});
console.log(res.message.content);
const book: Book = Book.parse(JSON.parse(res.message.content));
console.log(book.price * book.copiesInStock);
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"title":{"type":"string"},"author":{"type":"string"},"format":{"type":"string","enum":["paperback","hardcover","ebook"]},"price":{"type":"number"},"copiesInStock":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["title","author","format","price","copiesInStock"],"additionalProperties":false}
{
"title": "Project Hail Mary",
"author": "Andy Weir",
"format": "paperback",
"price": 18.99,
"copiesInStock": 2
}
37.98
Bu kez alan adları schema'dakiyle birebir aynı, fiyat sayı, format da izin verdiğimiz üç değerden biri. Book.parse aynı Zod tanımıyla cevabı bir kez daha doğruluyor ve sonuç TypeScript'te tipli bir Book oluyor; stok değerini hesaplamak için hiçbir string işlemine gerek kalmadı. Ollama'nın dokümanı iki şey daha öneriyor ve ikisini de yaptık: schema'yı prompt'un içinde de tarif etmek ve temperature'ı 0'a çekmek. Schema üretimi sınırlıyor, ama modelin alanların ne anlama geldiğini anlaması için onları metinde de görmesi gerekiyor.
C#'ta GetResponseAsync<T>
Microsoft.Extensions.AI bu işi bir adım daha kısaltıyor. Schema'yı yazmıyorsun; C# tipini veriyorsun:
using System.ComponentModel;
using System.Globalization;
using Microsoft.Extensions.AI;
using OllamaSharp;
IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
ChatOptions options = new() { Temperature = 0, Seed = 42 };
string note = """
New in store: 'Project Hail Mary' by Andy Weir. Paperback, 496 pages.
Now 18.99 USD (was 22.00). Only 2 copies left on the shelf.
""";
ChatResponse<Book> response = await client.GetResponseAsync<Book>(
$"Extract the book from this note.\n\n{note}", options);
Console.WriteLine(response.Text);
Book book = response.Result;
decimal stockValue = book.Price * book.CopiesInStock;
Console.WriteLine($"{book.Title} | {book.Format} | stock value: {stockValue.ToString(CultureInfo.InvariantCulture)} USD");
enum BookFormat { Paperback, Hardcover, Ebook }
record Book(
string Title,
string Author,
BookFormat Format,
[property: Description("Current price in USD")] decimal Price,
int CopiesInStock);
{ "title": "Project Hail Mary", "author": "Andy Weir","format": "Paperback", "price": 18.99, "copiesInStock": 2}
Project Hail Mary | Paperback | stock value: 37.98 USD
GetResponseAsync<Book>, Book record'ından bir JSON schema üretiyor, isteğe ekliyor ve cevabı deserialize edip response.Result olarak veriyor. Giden isteği bir DelegatingHandler ile yakaladığımda format alanı şuydu:
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"title":{"type":"string"},"author":{"type":"string"},"format":{"type":"string","enum":["Paperback","Hardcover","Ebook"]},"price":{"description":"Current price in USD","type":"number"},"copiesInStock":{"type":"integer"}},"required":["title","author","format","price","copiesInStock"]}
Birkaç ayrıntı dikkat çekiyor. Property adları JSON'da camelCase'e çevrilmiş. BookFormat enum'u sayı olarak değil, isimleriyle bir enum listesi olarak gitmiş; ek bir converter yazmadım. [Description] attribute'u da schema'da alanın description'ı olmuş; modele bir alanın anlamını söylemenin en temiz yolu bu. Bir de eksik olan bir şey var: isteğin mesajlarında schema'dan hiç söz edilmiyordu, yani bu çağrıda schema prompt'a eklenmedi, yalnızca format alanında gitti. Alan adları yeterince açık olduğu için burada sorun çıkmadı; anlamı belirsiz alanlarda [Description] ya da prompt'ta kısa bir tarif ekle.
Schema Biçimi Garanti Eder, Doğruluğu Değil
Şimdi açılıştaki soruya gelelim. Yeni bir not: "Circe" geldi, fiyatı henüz belli değil, stok gelecek hafta gelecek. Aynı Book tipini kullanıyoruz, bir de bilinmeyen alanların null olabildiği bir taslak tipini:
using Microsoft.Extensions.AI;
using OllamaSharp;
IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
ChatOptions options = new() { Temperature = 0, Seed = 42 };
string circe = "Just in: 'Circe' by Madeline Miller, hardcover. Price to be announced. Stock arrives next week.";
// Bütün alanlar zorunlu ve null olamaz
ChatResponse<Book> strict = await client.GetResponseAsync<Book>($"Extract the book from this note.\n\n{circe}", options);
Console.WriteLine(strict.Text);
// Bilinmeyen değerler null olabilir ve prompt bunu söylüyor
ChatResponse<BookDraft> draft = await client.GetResponseAsync<BookDraft>(
$"Extract the book from this note. Use null for anything the note does not say.\n\n{circe}", options);
Console.WriteLine(draft.Text);
enum BookFormat { Paperback, Hardcover, Ebook }
record Book(string Title, string Author, BookFormat Format, decimal Price, int CopiesInStock);
record BookDraft(string Title, string Author, BookFormat? Format, decimal? Price, int? CopiesInStock);
{ "title": "Circe","author": "Madeline Miller","format": "Hardcover","price": 0, "copiesInStock": 0}
{
"title": "Circe",
"author": "Madeline Miller",
"format": "Hardcover",
"price": null,
"copiesInStock": null
}
İlk cevap schema'ya kusursuz uyuyor ve tamamen yanlış. Not, fiyatın belli olmadığını söylüyor; model fiyat alanını boş bırakamadığı için 0 yazdı. Stok için de 0 yazdı. Bu kaydı veritabanına yazarsan kitapçında ücretsiz bir kitap belirir ve stokta olmadığı için kimse alamaz. Schema modele "burada bir sayı olmak zorunda" dedi ve model bir sayı uydurdu.
İkinci cevapta alanlar nullable ve prompt "notta olmayan her şey için null kullan" diyor; model iki bilinmeyen için de null döndü. Ders şu: schema'yı tasarlarken modele "bilmiyorum" deme imkânı ver. Kaynakta olmayabilecek her alan nullable olmalı; zorunlu bir alan, modeli bir değer uydurmaya zorlar.
Doğrulama ve Hata Durumunda Ne Yapmalı
Gerçek bir uygulamada modelden gelen veriyi iki katmanda kontrol etmek gerekiyor: önce teknik olarak, sonra iş kuralları açısından. İkisini tek bir method'da toplayalım:
using System.Globalization;
using Microsoft.Extensions.AI;
using OllamaSharp;
IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
string hailMary = "New in store: 'Project Hail Mary' by Andy Weir. Paperback. Now 18.99 USD (was 22.00). Only 2 copies left.";
string circe = "Just in: 'Circe' by Madeline Miller, hardcover. Price to be announced. Stock arrives next week.";
Console.WriteLine(await ExtractAsync(client, hailMary, maxOutputTokens: 200));
Console.WriteLine(await ExtractAsync(client, circe, maxOutputTokens: 200));
// Fazla düşük bir sınır JSON'u yarıda keser
Console.WriteLine(await ExtractAsync(client, hailMary, maxOutputTokens: 15));
static async Task<string> ExtractAsync(IChatClient client, string note, int maxOutputTokens)
{
ChatResponse<BookDraft> response = await client.GetResponseAsync<BookDraft>(
$"Extract the book from this note. Use null for anything the note does not say.\n\n{note}",
new ChatOptions { Temperature = 0, Seed = 42, MaxOutputTokens = maxOutputTokens });
// 1) JSON tamam mı ve tipe uyuyor mu?
if (response.FinishReason == ChatFinishReason.Length || !response.TryGetResult(out BookDraft? draft))
{
return $"invalid ({response.FinishReason}): {response.Text}";
}
// 2) Değerler iş kuralları açısından anlamlı mı?
List<string> problems = [];
if (draft.Price is null or <= 0) problems.Add("no price");
if (draft.CopiesInStock is null) problems.Add("no stock count");
return problems.Count == 0
? $"ok: {draft.Title}, {draft.Format}, {draft.Price!.Value.ToString(CultureInfo.InvariantCulture)} USD, {draft.CopiesInStock} in stock"
: $"needs review: {draft.Title} ({string.Join(", ", problems)})";
}
enum BookFormat { Paperback, Hardcover, Ebook }
record BookDraft(string Title, string Author, BookFormat? Format, decimal? Price, int? CopiesInStock);
ok: Project Hail Mary, Paperback, 18.99 USD, 2 in stock
needs review: Circe (no price, no stock count)
invalid (length): {
"title": "Project Hail Mary",
"author
Birinci katman, cevabın tam ve tipe uygun olup olmadığına bakıyor. Üçüncü çağrıda MaxOutputTokens = 15 verdik; JSON "author'da kesildi, FinishReason length oldu ve TryGetResult false döndü. response.Result'ı doğrudan okusaydık bir JsonException alacaktık; bunu denediğimde mesaj "Expected end of string, but instead reached end of data" idi. TryGetResult, exception yerine bir boolean ile karar vermeni sağlıyor.
İkinci katman, değerlerin işine uygun olup olmadığına bakıyor. "Circe" kaydı teknik olarak geçerli, ama fiyatı ve stoğu yok; bunu bir hata olarak değil, "gözden geçirilmeli" olarak işaretledik. Bu katmanı model bilemez: 0 dolarlık bir kitabın kabul edilemez olduğunu senin iş kuralların söyler.
Hata durumunda ne yapacağın, hatanın türüne bağlı. Teknik hatalarda bir kez daha denemek mantıklı: kesilmiş bir JSON'da sınırı yükselterek, parse hatasında hatayı prompt'a ekleyerek. Ama sonsuz bir retry döngüsü kurma; bir ya da iki denemeden sonra isteği hata olarak kaydet. İş kuralı hatalarında ise tekrar denemek genelde işe yaramaz, çünkü model aynı nottan aynı sonucu çıkarır; bunları bir insana yönlendir. Hangisi olursa olsun yapmaman gereken tek şey, eksik alanları sessizce varsayılan değerlerle doldurup yola devam etmek.
Sık Düşülen Tuzaklar
İlk tuzak, schema'yı alanların anlamını anlatan bir metin sanmak. Schema yapıyı sınırlar, anlamı anlatmaz. price indirimli fiyat mı, eski fiyat mı? Notta iki fiyat vardı ve model doğru olanı seçti, ama bunu şansa bırakma: [Description] ya da prompt'ta bir cümleyle söyle.
İkincisi, çıktı sınırını küçük tutup JSON'un sonuna yer bırakmamak. Uzun açıklama alanları olan schema'larda MaxOutputTokens kolayca dolar ve geriye parse edilemeyen yarım bir object kalır. Sınırı koy, ama bitiş sebebini her zaman kontrol et.
Üçüncüsü, her alanı zorunlu yapmak. Yukarıda gördüğümüz gibi zorunlu alan, kaynakta olmayan bir bilgi için modeli bir değer uydurmaya iter. 0, boş string ya da "Unknown" gibi değerler hata gibi görünmez ve veritabanına sızar.
Dördüncüsü, aynı veriyi paylaşan iki tarafın enum biçimini karıştırmak. C# tarafında enum değerleri Paperback olarak, Zod tarafında paperback olarak gitti. İki service aynı JSON'u okuyacaksa enum değerlerini tek bir yerde, açıkça tanımla.
Beşincisi, her sağlayıcının aynı desteği verdiğini varsaymak. Ollama'nın dokümanına göre Ollama Cloud structured output'u desteklemiyor; bulut sağlayıcıları da JSON Schema'nın her özelliğini desteklemiyor. Kod IChatClient üzerinden sağlayıcıdan bağımsız görünse bile, kullandığın sağlayıcının hangi schema özelliklerini kabul ettiğini dokümanından kontrol et.
Küçük Bir Deneme
★☆☆ schema.ts'teki Zod tanımına genre: z.enum(['fantasy', 'science_fiction', 'mystery', 'other']) alanını ekle. Project Hail Mary için hangi değer geliyor? Prompt'tan schema tarifini çıkarınca değişiyor mu?
★★☆ s10_required.cs'teki BookDraft'a string? Isbn ve int? PublicationYear ekle. Notta olmayan bu iki bilgi için model null mı dönüyor, yoksa bir değer mi uyduruyor? Aynı alanları nullable olmadan dene.
★★★ ExtractAsync'e bir retry ekle: bitiş sebebi length ise aynı isteği MaxOutputTokens'ı iki katına çıkararak bir kez daha gönder, yine olmazsa hata döndür. Üçüncü çağrının çıktısı nasıl değişiyor?
Artık modelden programın okuyabileceği, tipli cevaplar alabiliyoruz. Bir sonraki adım, modelin bu yapıyı yalnızca cevap olarak değil, bir eylem isteği olarak üretmesi: tool calling. Bir sonraki yazıda modelin fonksiyonu kendisinin çağırmadığını, yalnızca bir çağrı isteği ürettiğini qwen3:4b ve /api/chat'in tools alanıyla ham hâliyle göreceğiz.