yazılar
Agent Nedir? SQL'i Çalıştıran ve Hatasını Düzelten Asistan
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:
- Modele aracı tarif ederiz: adı, ne işe yaradığı, hangi parametreleri aldığı (JSON schema).
- Model normal metin yerine yapılandırılmış bir çağrı niyeti döndürür:
execute_sql(sql="SELECT ..."). - Biz o fonksiyonu çalıştırırız.
- Sonucu (veya hatayı)
role: "tool"mesajı olarak konuşmaya geri ekleriz. - 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 customersgibi bir zincir “SELECT ile başlıyor” testini geçerdi. - Hatayı
raiseetmiyoruz,returnediyoruz. 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:
- Şemaya güvenip
birth_datekullandı. - Hatayı okudu — ve DuckDB’nin
Candidate bindingsipucunu kullandı. - 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:
- Token ve Embedding
- İlk SQL ve Halüsinasyon
- RAG ve Schema Retrieval
- Agent: Tool Calling ve Self-Correction (bu yazı)
- Güvenlik, Multi-Agent ve Eval
- Deploy: API, Docker ve AWS