Kaan Gümele

yazılar

Agent Nedir? SQL'i Çalıştıran ve Hatasını Düzelten Asistan

Agent · Tool Calling · ReAct · Text-to-SQL · LLM
Sunum slaytları →

Seri: Doğal Dille Veritabanına Soru Sormak — Sıfırdan Text-to-SQL Asistanı. Önceki yazı: RAG ve Schema Retrieval

Eğitim slaytlarıDers 4 — Agent

Buraya kadar asistanımız iyi bir yazar oldu: şemayı görüyor, doğru tabloları RAG’le seçiyor, düzgün SQL üretiyor. Ama tek bir şeyi yapamıyor — o SQL’i çalıştırmayı.

Bu ayrımı küçümsemeyin, serinin en kritik eşiği burası:

LLM tek başına: SQL metni üretir. Çalıştıramaz, sonucu göremez, yanlış yazdığını bilemez.

Sorgu orders tablosunda olmayan bir kolona referans veriyorsa model bunu asla öğrenemez, çünkü geri bildirim almıyor. Biz sonucu kopyalayıp DBeaver’a yapıştırıp hatayı okuyup modele geri anlatana kadar sistemin ortasında bir insan duruyor.

O insanı döngüden çıkardığımız an elimizdekinin adı değişiyor.

Agent = LLM + Araç + Döngü

Formül gerçekten bu kadar sade:

  • LLM — ne yapılacağına karar verir.
  • Araç (tool) — modelin dış dünyaya uzanan eli. Bizde: execute_sql.
  • Döngü — sonucu görüp yeniden karar verebilmesi.

Literatürde bu döngünün adı ReAct: Reason → Act → Observe → (gerekirse) tekrar. Düşün, bir eylem yap, sonucu gözlemle, gözlemine göre yeniden düşün.

soru → [düşün] → SQL yaz → [çalıştır] → sonuç/HATA gör
                    ▲                        │
                    └──── düzelt ◄───────────┘
                                                → cevapla

Fark ettiyseniz “agent” kelimesinde mistik hiçbir şey yok. Bir while döngüsü. Aşağıda tam olarak onu yazacağız.

Tool Calling Mekaniği

Model bizim fonksiyonumuzu çağıramaz — Python process’imize erişimi yok. Olan biten şu:

  1. Modele aracı tarif ederiz: adı, ne işe yaradığı, hangi parametreleri aldığı (JSON schema).
  2. Model normal metin yerine yapılandırılmış bir çağrı niyeti döndürür: execute_sql(sql="SELECT ...").
  3. Biz o fonksiyonu çalıştırırız.
  4. Sonucu (veya hatayı) role: "tool" mesajı olarak konuşmaya geri ekleriz.
  5. Model devam eder.

Yani model asla bizim makinemizde bir şey çalıştırmıyor; sadece “şunu çalıştırır mısın” diyor. Kontrol tamamen bizde kalıyor — bu, bir sonraki dersin güvenlik tartışmasının da temeli.

Araç tanımı (OpenAI-uyumlu format; biz DeepSeek kullanıyoruz):

TOOLS = [{
    "type": "function",
    "function": {
        "name": "execute_sql",
        "description": "DuckDB'de bir SELECT sorgusu çalıştırır ve sonucu döndürür. "
                       "SQL'i hazırlayınca bu aracı çağır.",
        "parameters": {
            "type": "object",
            "properties": {
                "sql": {"type": "string", "description": "Çalıştırılacak SELECT sorgusu"}
            },
            "required": ["sql"],
        },
    },
}]

Buradaki description alanları dekoratif değil — modelin aracı ne zaman çağıracağına karar vermek için okuduğu tek şey onlar. Kötü yazılmış bir araç açıklaması, kötü yazılmış bir prompt’tur.

Aracın Gerçek İşi

Fonksiyonun kendisi sıkıcı olmalı; sıkıcı olması iyi bir işaret:

_db = duckdb.connect(DB_PATH, read_only=True)

def execute_sql(sql):
    q = sql.strip().rstrip(";").strip()
    if ";" in q:            # "SELECT 1; DROP TABLE ..." kontrolü geçmesin
        return "HATA: Güvenlik gereği tek bir okuma sorgusu çalıştırılır."
    if not (q.lower().startswith("select") or q.lower().startswith("with")):
        return "HATA: Güvenlik gereği yalnızca SELECT/WITH (okuma) sorgusu çalıştırılır."
    try:
        df = _db.sql(q).df()
        return df.to_string(index=False) if len(df) else "(sonuç boş)"
    except Exception as e:
        return f"SQL HATASI: {e}"      # <-- hatayı MODELE geri veriyoruz

Üç detay önemli:

  • read_only=True. String kontrolleri aşılabilir; asıl zorlayıcı sınır bağlantının kendisi. Kod seviyesindeki filtre birinci kalkan, read-only bağlantı ikinci kalkan.
  • Noktalı virgül engeli. Tek ifade kuralı olmadan SELECT 1; DROP TABLE customers gibi bir zincir “SELECT ile başlıyor” testini geçerdi.
  • Hatayı raise etmiyoruz, return ediyoruz. Bu, dersin bütün numarası. Exception fırlatırsak program çöker; hatayı string olarak döndürürsek model onu okur.

ReAct Döngüsü — Tam Kod

def run_agent(question, max_steps=6, schema=SCHEMA):
    messages = [
        {"role": "system", "content":
            "Sen veriyle konuşan bir analitik asistansın. Kullanıcının sorusunu SQL'e çevir, "
            "execute_sql aracıyla çalıştır. SQL HATASI alırsan sorguyu düzeltip tekrar dene. "
            "Sonucu Türkçe, kısa cümlelerle özetle.\n\nSCHEMA:\n" + schema},
        {"role": "user", "content": question},
    ]
    for _ in range(max_steps):
        msg = chat(messages, tools=TOOLS).choices[0].message

        if not msg.tool_calls:
            return msg.content                      # araç istemedi → nihai cevap

        messages.append({                           # tool_calls'lu asistan mesajı
            "role": "assistant",
            "content": msg.content or "",
            "tool_calls": [{"id": tc.id, "type": "function",
                            "function": {"name": tc.function.name,
                                         "arguments": tc.function.arguments}}
                           for tc in msg.tool_calls],
        })

        for tc in msg.tool_calls:                   # BİZ çalıştırırız
            args = json.loads(tc.function.arguments or "{}")
            result = execute_sql(args.get("sql", ""))
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})

    return "Adım limiti aşıldı."

Döngünün çıkış koşuluna dikkat: model araç istemeyi bıraktığında biter. Yani ne zaman duracağına biz değil, model karar veriyor — max_steps sadece emniyet freni.

Bir de şu var: asistan mesajını tool_calls’ıyla birlikte geçmişe geri eklemek zorundayız. Atlanırsa model kendi çağrısını hatırlamaz ve tool mesajı havada kalır; API çoğu zaman doğrudan hata döner. Tool calling’de en sık yapılan hata bu.

Self-Correction: İşin Özü

Sıradan bir soruyla (“En çok satan 3 ürün”) agent tek adımda doğru SQL’i yazıp bitirir. Etkileyici ama öğretici değil. Asıl göstermek istediğimiz şey hata aldığında ne yaptığı.

Bunu güvenilir biçimde tetiklemek için gerçek bir production sorununu taklit ediyoruz: schema drift. Dokümantasyondaki şema, veritabanındaki şemayla uyuşmuyor.

# Bilerek ESKİMİŞ şema: dokümanda 'birth_date' var, ama DB'de yok.
STALE_SCHEMA = """Tables:
- customers(id, name, city, registered_at, birth_date)
- products(id, name, price, category)
- orders(id, customer_id, order_date)
- order_items(order_id, product_id, quantity)
"""

run_agent("Müşterileri doğum tarihine göre en yaşlıdan sırala.", schema=STALE_SCHEMA)

Bu senaryoyu seçmemin sebebi, uydurma bir hata olmaması: şema dokümanının veritabanının gerisinde kalması her veri ekibinin başına gelir. Agent şemaya güveniyor, şema yalan söylüyor.

Akış şöyle ilerliyor:

[araç çağrısı] SELECT * FROM customers ORDER BY birth_date ASC
[sonuç]        SQL HATASI: Binder Error: Referenced column "birth_date" not found in FROM clause!
               Candidate bindings: "registered_at"

[araç çağrısı] SELECT id, name, city, registered_at FROM customers ORDER BY registered_at ASC
[sonuç]        (satırlar)

CEVAP: Veritabanında doğum tarihi kolonu bulunmuyor. En eski kayıt tarihine göre
       sıraladım: ...

Üç adımda olan biten:

  1. Şemaya güvenip birth_date kullandı.
  2. Hatayı okudu — ve DuckDB’nin Candidate bindings ipucunu kullandı.
  3. Sorguyu düzeltti, çalıştırdı, hem cevabı verdi hem de varsayımının yanlış olduğunu söyledi.

Bu geri bildirim döngüsü, “LLM’e SQL yazdırmak” ile “text-to-SQL sistemi kurmak” arasındaki farkın tamamı. Modelin ilk denemede haklı olması gerekmiyor; yanıldığını öğrenebilmesi yetiyor.

Ölçek Köprüsü: RAG’i Araca Dönüştürmek

Demomuzda şema 4 tablo, o yüzden doğrudan system prompt’a koyduk. Gerçek bir veritabanında bu mümkün değil — ve çözüm zaten elimizde.

Ders 3’teki relevant_schema fonksiyonunu ikinci bir araç olarak tanımlarsanız (get_schema(konu)), agent artık “bu soru için hangi tabloya bakmalıyım?” diye kendisi sorabilir. RAG, sabit bir ön-işleme adımı olmaktan çıkıp modelin kullandığı bir yeteneğe dönüşür. Buna agentic RAG deniyor: retrieval’ı ne zaman ve kaç kez çağıracağına model karar verir.

Bu Döngüyü Neden Elle Yazdık?

LangGraph, CrewAI ya da sağlayıcıların “tool runner” yardımcıları bu for döngüsünü sizin için döndürür. Elle yazmamın tek sebebi var: kutunun içinde sihir olmadığını görmek. İçini bir kez yazınca framework’ün ne yaptığını (ve bir şey ters gittiğinde nereye bakacağınızı) biliyorsunuz.

Bir de production uyarısı: döngü limiti şart. max_steps olmadan, sürekli düzeltmeye çalışan bir agent sonsuza kadar deneyip token yakabilir. Her adım = yeni bir LLM çağrısı = para. Self-correction gerçek bir production kalıbı, ama yanında retry sayısı ve maliyet takibiyle gelir.

Nereye Geldik?

Ders Asistan ne kazandı
1 Metin token’a bölünür, anlam embedding’e gömülür
2 Şemadan SQL üretir — ama olmayan tabloyu uydurabilir
3 Doğru tabloları vektör DB ile seçer
4 SQL’i çalıştırır, hatayı okur, kendini düzeltir

Asistan artık pasif bir yazar değil, veritabanına dokunan bir aktör. Bu güçlü — ve tam olarak bu yüzden tehlikeli. Veritabanınıza SQL çalıştırma yetkisi verdiğiniz an yeni bir saldırı yüzeyi açtınız.

Sıradaki yazı bunun üzerine: yıkıcı sorgular, prompt injection, veri sızıntısı; doğrulayıcı agent’lar; ve “ürettiği SQL gerçekten doğru mu?” sorusunun ölçülebilir cevabı.


Seri:

  1. Token ve Embedding
  2. İlk SQL ve Halüsinasyon
  3. RAG ve Schema Retrieval
  4. Agent: Tool Calling ve Self-Correction (bu yazı)
  5. Güvenlik, Multi-Agent ve Eval
  6. Deploy: API, Docker ve AWS