LocalAI ile Çoklu Model Yük Dengeleme

Üretim ortamında birden fazla LLM modeli aynı anda çalıştırmak, beklenmedik sorunların kapısını açar. Bir model isteklere boğulurken diğeri boş oturur, bazı istekler zaman aşımına uğrar, bazıları hiç yanıt alamaz. LocalAI’ın çoklu model yük dengeleme özelliği tam bu noktada devreye giriyor. Yıllar içinde farklı AI altyapıları kurdum ve en çok baş ağrıtan senaryo her zaman “hangi model, hangi isteği alacak?” sorusuydu. Bu yazıda LocalAI ile gerçek bir üretim ortamında çoklu model yük dengelemesini nasıl kurduğumu, hangi hatalarla karşılaştığımı ve nasıl çözdüğümü anlatacağım.

LocalAI’ın Çoklu Model Mimarisi Nasıl Çalışır?

LocalAI, özünde bir REST API sunucusudur ve OpenAI API’siyle uyumlu bir arayüz sunar. Tek bir process olarak çalışmasına rağmen, arka planda birden fazla model yükleyebilir ve bu modeller arasında istek dağıtımı yapabilir. Ancak burada önemli bir ayrım var: LocalAI’ın kendi içindeki model yönetimi ile harici yük dengeleme katmanı arasındaki fark.

LocalAI, her model için ayrı bir “backend” process başlatır. llama.cpp, whisper, stable-diffusion gibi farklı backend’ler aynı anda çalışabilir. Sistem belleği izin verdiği sürece teorik olarak sınırsız model yüklenebilir, ancak pratikte GPU belleği sizi kısıtlar.

Temel mimari şöyle özetlenebilir:

  • Model Havuzu: Birden fazla model aynı anda bellekte tutulur
  • İstek Yönlendirme: Gelen API istekleri model adına göre doğru backend’e yönlendirilir
  • Worker Thread Yönetimi: Her model kendi thread havuzuyla çalışır
  • Otomatik Model Yükleme/Boşaltma: Bellek baskısı altında kullanılmayan modeller bellekten atılabilir

Ortamı Hazırlamak

Ben bu kurulumu Ubuntu 22.04 üzerinde, 2x NVIDIA A10G GPU’ya sahip bir sunucuda yaptım. Önce LocalAI’ı Docker ile ayağa kaldırıyorum çünkü bağımlılık yönetimi açısından çok daha sağlıklı:

# GPU destekli LocalAI başlatma
docker run -d 
  --name localai-primary 
  --gpus all 
  -p 8080:8080 
  -v /opt/localai/models:/build/models:cached 
  -v /opt/localai/config:/build/config 
  -e MODELS_PATH=/build/models 
  -e THREADS=8 
  -e CONTEXT_SIZE=4096 
  -e GALLERIES='[{"name":"model-gallery","url":"github:go-skynet/model-gallery/index.yaml"}]' 
  --restart unless-stopped 
  quay.io/go-skynet/local-ai:latest-aio-gpu

Birden fazla LocalAI instance’ı çalıştıracaksanız, her birinin farklı portlarda olması gerekiyor. Ben genellikle 8080, 8081, 8082 şeklinde dağıtırım:

# İkinci instance - farklı GPU ve port
docker run -d 
  --name localai-secondary 
  --gpus '"device=1"' 
  -p 8081:8080 
  -v /opt/localai/models:/build/models:cached 
  -v /opt/localai/config:/build/config 
  -e MODELS_PATH=/build/models 
  -e THREADS=8 
  --restart unless-stopped 
  quay.io/go-skynet/local-ai:latest-aio-gpu

Model Konfigürasyonu

Her model için ayrı bir YAML dosyası oluşturmanız gerekiyor. Bu dosyalar /opt/localai/config/ dizininde bulunuyor. LocalAI’ın bu konfigürasyon sistemi başta garip gelebilir, ama öğrendikten sonra çok esnek bir yapı sunduğunu anlıyorsunuz.

# /opt/localai/config/mistral-7b.yaml
name: mistral-7b
parameters:
  model: mistral-7b-instruct-v0.2.Q4_K_M.gguf
  temperature: 0.7
  top_p: 0.9
  max_tokens: 2048
  threads: 4
  gpu_layers: 35
context_size: 4096
f16: true
mmap: true
mmlock: false
low_vram: false
roles:
  user: "[INST]"
  assistant: "[/INST]"
  system: "<<SYS>>"
template:
  chat: |
    {{.Input}}
# /opt/localai/config/llama3-8b.yaml
name: llama3-8b
parameters:
  model: Meta-Llama-3-8B-Instruct.Q5_K_M.gguf
  temperature: 0.8
  top_p: 0.95
  max_tokens: 4096
  threads: 4
  gpu_layers: 40
context_size: 8192
f16: true
mmap: true
roles:
  user: "<|start_header_id|>user<|end_header_id|>"
  assistant: "<|start_header_id|>assistant<|end_header_id|>"
  system: "<|start_header_id|>system<|end_header_id|>"

Nginx ile Temel Yük Dengeleme

En basit yük dengeleme senaryosunda Nginx’i upstream proxy olarak kullanabilirsiniz. Bu yaklaşım round-robin dağıtımı yapar, yani istekler sırayla her instance’a gönderilir:

# /etc/nginx/sites-available/localai-lb
upstream localai_backend {
    least_conn;  # En az bağlantıya sahip sunucuya yönlendir
    
    server 127.0.0.1:8080 weight=2 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:8081 weight=1 max_fails=3 fail_timeout=30s;
    
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name ai.sirketiniz.com;
    
    ssl_certificate /etc/ssl/certs/ai.crt;
    ssl_certificate_key /etc/ssl/private/ai.key;
    
    location /v1/ {
        proxy_pass http://localai_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 300s;
        proxy_connect_timeout 10s;
        proxy_send_timeout 300s;
        
        # Streaming yanıtlar için
        proxy_buffering off;
        proxy_cache off;
        chunked_transfer_encoding on;
    }
    
    location /health {
        proxy_pass http://localai_backend/readyz;
        access_log off;
    }
}

least_conn direktifi burada kritik. Round-robin yerine bunu kullanmanızı şiddetle öneririm çünkü LLM istekleri çok farklı sürelerde tamamlanabilir. Kısa bir soru 2 saniyede bitebilirken, uzun bir doküman özetleme isteği 45 saniye sürebilir. Round-robin’de bir instance onlarca uzun istekle boğulurken diğeri boş kalabilir.

HAProxy ile Gelişmiş Yük Dengeleme

Nginx’in ötesine geçmek isteyenler için HAProxy çok daha granüler kontrol sağlıyor. Sağlık kontrollerini özelleştirebilir, model bazlı yönlendirme yapabilir ve çok daha detaylı metrikler alabilirsiniz:

# /etc/haproxy/haproxy.cfg
global
    log /dev/log local0
    maxconn 4096
    user haproxy
    group haproxy
    daemon

defaults
    log global
    mode http
    option httplog
    option dontlognull
    timeout connect 10s
    timeout client 300s
    timeout server 300s
    timeout tunnel 3600s

frontend localai_front
    bind *:80
    bind *:443 ssl crt /etc/haproxy/certs/ai.pem
    
    # Model bazlı ACL tanımları
    acl is_llama3 path_reg -i /v1/chat/completions.*llama3
    acl is_mistral path_reg -i /v1/chat/completions.*mistral
    acl is_embedding path_beg /v1/embeddings
    
    # Yönlendirme kuralları
    use_backend embedding_servers if is_embedding
    use_backend llama3_servers if is_llama3
    use_backend mistral_servers if is_mistral
    default_backend general_ai_servers

backend general_ai_servers
    balance leastconn
    option httpchk GET /readyz
    http-check expect status 200
    
    server ai1 127.0.0.1:8080 check inter 10s rise 2 fall 3 weight 100
    server ai2 127.0.0.1:8081 check inter 10s rise 2 fall 3 weight 100
    server ai3 127.0.0.1:8082 check inter 10s rise 2 fall 3 weight 50 backup

backend embedding_servers
    balance roundrobin
    server ai1 127.0.0.1:8080 check inter 5s
    server ai2 127.0.0.1:8081 check inter 5s

backend llama3_servers
    balance leastconn
    server ai1 127.0.0.1:8080 check inter 10s
    
backend mistral_servers
    balance leastconn
    server ai2 127.0.0.1:8081 check inter 10s

listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 30s
    stats auth admin:guclu_sifre_koy

Bu konfigürasyondaki backup parametresi çok önemli. Üçüncü sunucuyu yalnızca diğer ikisi çöktüğünde devreye sokuyoruz, normal trafiği almıyor. Bu sayede daha zayıf bir sunucuyu acil durum yedek olarak tutabiliyorsunuz.

Python ile Akıllı Yük Dengeleme

Bazı senaryolar için daha akıllı bir yönlendirme mantığına ihtiyaç duyuyorsunuz. İstek boyutuna, modelin o anki yüküne veya kullanıcı önceliğine göre yönlendirme yapmak isteyebilirsiniz. Ben bu iş için FastAPI ile küçük bir proxy yazdım:

# smart_lb.py
import asyncio
import httpx
import json
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import time
from collections import defaultdict

app = FastAPI()

BACKENDS = [
    {"url": "http://localhost:8080", "weight": 2, "active_requests": 0, "healthy": True},
    {"url": "http://localhost:8081", "weight": 1, "active_requests": 0, "healthy": True},
]

request_counts = defaultdict(int)

async def check_health():
    """Arka planda sürekli sağlık kontrolü yapar"""
    while True:
        for backend in BACKENDS:
            try:
                async with httpx.AsyncClient(timeout=5.0) as client:
                    resp = await client.get(f"{backend['url']}/readyz")
                    backend["healthy"] = resp.status_code == 200
            except Exception:
                backend["healthy"] = False
        await asyncio.sleep(10)

def select_backend(token_count: int = 0):
    """Aktif istek sayısı ve token miktarına göre en uygun backend'i seçer"""
    healthy_backends = [b for b in BACKENDS if b["healthy"]]
    
    if not healthy_backends:
        raise HTTPException(status_code=503, detail="Tüm AI sunucuları çevrimdışı")
    
    # Weighted least connections algoritması
    best = min(
        healthy_backends,
        key=lambda b: b["active_requests"] / b["weight"]
    )
    return best

@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
    body = await request.json()
    
    # Token tahminleme (basit yaklaşım)
    messages = body.get("messages", [])
    estimated_tokens = sum(len(m.get("content", "")) // 4 for m in messages)
    
    backend = select_backend(estimated_tokens)
    backend["active_requests"] += 1
    
    try:
        is_streaming = body.get("stream", False)
        
        async with httpx.AsyncClient(timeout=300.0) as client:
            if is_streaming:
                async def stream_generator():
                    async with client.stream(
                        "POST",
                        f"{backend['url']}/v1/chat/completions",
                        json=body,
                        headers={"Content-Type": "application/json"}
                    ) as response:
                        async for chunk in response.aiter_bytes():
                            yield chunk
                
                return StreamingResponse(
                    stream_generator(),
                    media_type="text/event-stream"
                )
            else:
                response = await client.post(
                    f"{backend['url']}/v1/chat/completions",
                    json=body
                )
                return response.json()
    finally:
        backend["active_requests"] -= 1

@app.on_event("startup")
async def startup():
    asyncio.create_task(check_health())

Bu proxy’yi systemd service olarak çalıştırabilirsiniz. Gerçek üretimde bunu Gunicorn veya Uvicorn ile birden fazla worker’la çalıştırın.

İzleme ve Metrik Toplama

Yük dengeleme kurduğunuzda işin yarısı bitti demektir. Asıl mesele neler olduğunu görebilmek. Prometheus ve Grafana ikilisiyle LocalAI metriklerini toplamak için şu konfigürasyonu kullanıyorum:

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'localai-instances'
    static_configs:
      - targets: 
          - 'localhost:8080'
          - 'localhost:8081'
    metrics_path: '/metrics'
    scrape_timeout: 10s
    
  - job_name: 'haproxy'
    static_configs:
      - targets: ['localhost:8404']
    metrics_path: '/stats;csv'

LocalAI’ın /metrics endpoint’i Prometheus formatında veri sunuyor. Hangi modelin kaç istek aldığını, ortalama yanıt sürelerini ve hata oranlarını buradan takip edebilirsiniz.

Gerçek Dünya Senaryosu: Aynı Anda Farklı Modeller

Bir müşteri projesinde şu senaryoyla karşılaştım: Kodlama soruları için CodeLlama, genel sohbet için Mistral, Türkçe içerik için ince ayarlı bir model kullanmamız gerekiyordu. Nginx yeterli olmadı çünkü istek body’sini inceleme kapasitesi yok.

Çözüm olarak şu basit router scripti işe yaradı:

#!/bin/bash
# model_router_test.sh - Yük dengelemenin doğru çalışıp çalışmadığını test eder

ENDPOINTS=("http://localhost:8080" "http://localhost:8081")
TEST_PROMPT='{"model":"mistral-7b","messages":[{"role":"user","content":"Merhaba, nasılsın?"}],"max_tokens":50}'

echo "=== LocalAI Yük Dengeleme Testi ==="
echo ""

for endpoint in "${ENDPOINTS[@]}"; do
    echo "Endpoint test ediliyor: $endpoint"
    
    # Sağlık kontrolü
    health=$(curl -s -o /dev/null -w "%{http_code}" "$endpoint/readyz")
    echo "  Sağlık durumu: HTTP $health"
    
    # Yanıt süresi testi
    start_time=$(date +%s%N)
    response=$(curl -s -X POST 
        -H "Content-Type: application/json" 
        -d "$TEST_PROMPT" 
        "$endpoint/v1/chat/completions" 
        --max-time 60)
    end_time=$(date +%s%N)
    
    duration=$(( (end_time - start_time) / 1000000 ))
    
    if echo "$response" | grep -q '"content"'; then
        echo "  Yanıt durumu: BASARILI"
        echo "  Yanıt süresi: ${duration}ms"
    else
        echo "  Yanıt durumu: HATA"
        echo "  Ham yanıt: $response"
    fi
    echo ""
done

echo "Test tamamlandi."

Sık Karşılaşılan Sorunlar ve Çözümleri

GPU Bellek Sızıntısı: Uzun süre çalıştıktan sonra bazı modeller GPU belleğini serbest bırakmıyor. Bunu çözmek için periyodik container yeniden başlatması ekledim:

# /etc/cron.d/localai-restart
# Her gece 03:00'te kontrollü yeniden başlatma
0 3 * * * root docker restart localai-secondary && sleep 120 && docker restart localai-primary

Model Yükleme Süresi: İlk istek geldiğinde model henüz bellekte olmayabilir. LocalAI’ın preload özelliğini kullanmak bu sorunu çözüyor. Konfigürasyon dosyanıza şunu ekleyin:

# model config'e eklenir
name: mistral-7b
# diğer ayarlar...
preload: true  # Sunucu başlarken modeli önceden yükle

Timeout Ayarları: LLM yanıtları beklenmedik kadar uzun sürebilir. Nginx, HAProxy ve uygulamanızdaki timeout değerlerinin tutarlı olması gerekiyor. Nginx’te proxy_read_timeout 300s ayarını mutlaka yapın, varsayılan 60 saniye çoğu LLM isteği için yetersiz.

Streaming Yanıtlarla Proxy Sorunu: HAProxy’nin bazı eski sürümleri Server-Sent Events ile iyi çalışmıyor. tunnel timeout’u 3600s‘e çıkarmak ve option http-server-close direktifini kaldırmak genellikle sorunu çözüyor.

Kapasite Planlaması

Her GPU’nun ne kadar yük kaldırabileceğini bilmek yük dengeleme konfigürasyonunu doğrudan etkiliyor. Basit bir ölçüm scripti:

#!/bin/bash
# capacity_test.sh - Eş zamanlı istek kapasitesini ölçer

ENDPOINT="http://localhost:8080/v1/chat/completions"
CONCURRENT_REQUESTS=(1 2 4 8 16)

test_payload='{"model":"mistral-7b","messages":[{"role":"user","content":"Türkiye hakkında 3 cümle yaz."}],"max_tokens":100}'

for concurrent in "${CONCURRENT_REQUESTS[@]}"; do
    echo "Test: $concurrent eş zamanlı istek"
    
    start_time=$(date +%s)
    
    for i in $(seq 1 $concurrent); do
        curl -s -X POST 
            -H "Content-Type: application/json" 
            -d "$test_payload" 
            "$ENDPOINT" 
            --max-time 120 
            -o /dev/null &
    done
    
    wait
    end_time=$(date +%s)
    
    duration=$((end_time - start_time))
    throughput=$(echo "scale=2; $concurrent / $duration" | bc)
    
    echo "  Toplam süre: ${duration}s"
    echo "  İşleme hızı: ${throughput} istek/saniye"
    echo ""
done

Bu testin çıktısına bakarak hangi eş zamanlılık seviyesinde throughput düşmeye başladığını görüp HAProxy weight değerlerinizi ona göre ayarlayabilirsiniz.

Sonuç

LocalAI ile çoklu model yük dengeleme, tek bir doğru yöntemi olmayan, altyapınıza ve iş gereksinimlerinize göre şekillendirmeniz gereken bir kurulum. Nginx basit round-robin için yeterli, HAProxy model bazlı yönlendirme istiyorsanız daha iyi bir seçim, kendi Python proxy’nizi yazmak ise en esnek ama en çok bakım gerektiren yaklaşım.

Pratikte benim tercihim şu: Kademeli başlayın. Önce tek instance çalıştırın, sisteminizin gerçek yükünü ölçün, sonra ikinci instance ekleyin ve Nginx ile yük dağıtın. Gerçekten karmaşık bir yönlendirme mantığına ihtiyaç duyduğunuzda HAProxy’ye geçin. Eğer model seçimini istek içeriğine göre yapmak istiyorsanız o zaman özel bir proxy katmanına yatırım yapın.

En önemli nokta şu: GPU belleği değerli bir kaynak. Kaç model yükleyebileceğinizi değil, kaç modeli aynı anda verimli şekilde çalıştırabileceğinizi hesaplayın. 4 model yükleyip hepsinin yavaş çalışması yerine 2 model yükleyip hızlı ve kararlı çalışması çok daha değerli.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir