Tuluat: Java ile Kubernetes AI Operator İnşa Etmek

10 Ağustos 2026 · netologist · 18 dakika, 3829 kelime ·

Tuluat’ı sıfırdan nasıl yaptığımız - LLM provider’ları, çok-ajanlı iş akışları, RAG pipeline’ları, insan onay adımları ve çok turlu konuşma belleğini tamamen Kubernetes Custom Resource’ları olarak yöneten, üretime hazır bir AI orkestrasyon platformu. Bu yazı bir anlatı değil, adım adım bir geliştirme günlüğü: her adımda neyi neden yaptım, hangi tuzağa düştüm ve nasıl çözdüm.


Neden Kubernetes Operator?

LLM konfigürasyonunu kodun içine gömmek ya da agent’ları uygulama katmanında new ile birbirine bağlamak, ölçeklendiğinde şu dört sorunu kaçınılmaz kılıyor:

  1. Konfigürasyon dağıtımı - yeni bir model/agent için redeploy gerekir.
  2. İzolasyon - hangi takım hangi agent’ı hangi bütçeyle kullanıyor belli değil.
  3. Gözlemlenebilirlik - kubectl get ile durumu göremiyorsunuz.
  4. Yaşam döngüsü - kim ne zaman silindi, neden başarısız oldu, denetim kaydı yok.

Kubernetes CRD’leri bu dördünü de bedavaya verir: GitOps, RBAC, denetim kayıtları ve birleşik bir kontrol düzlemi. Temel içgörü şu:

CRD’ler AI altyapısı için doğru soyutlama katmanıdır. Agent’larınız Deployment gibi birinci sınıf Kubernetes vatandaşı olur; kubectl apply -f agent.yaml çalıştırırsınız, operator istenen durumu reconcile eder.


Neden Java?

Kubernetes ekosistemi Go’ya alışkın ve bunun çok iyi sebepleri var - ben de Go’yu rahat kullanıyorum. Burada sorduğum soru farklı:

Modern Java, AI altyapısı ve Kubernetes-native sistemler için ikna edici bir runtime olabilir mi?

On yıl önceki Java ile bugünkü Java aynı dil değil:

OpenJDK Babylon gibi projeler bu yönü daha da ilginç kılıyor. Bu bir hipotez, sonuç değil - asıl sınav execution/worker katmanı zorlaştığında (eşzamanlılık, kaynak kullanımı, başlatma, izolasyon, ölçeklenebilirlik) gelecek. Bu proje o hipotezi baskı altına almanın yolu.


Mimariye Genel Bakış

flowchart TD subgraph K8S["Kubernetes Control Plane"] CR["LlmProvider · AiAgent · McpServer
AiWorkflow · WorkflowSession"] end subgraph Operator["tuluat-operator"] REC["AiAgentReconciler · LlmProviderReconciler · McpServerReconciler
AiWorkflowReconciler · WorkflowSessionReconciler"] end subgraph Engine["tuluat-engine"] AES["AgentExecutionService"] GSM["GraphStateMachineEngine"] EMB["Embabel GOAP → TuluatGoalAgent"] RAG["RagService"] GRD["GuardrailPipeline"] end K8S -- "JOSDK reconciler" --> Operator Operator --> Engine AES -- "ModelGateway → Spring AI" --> SpringAI["Spring AI ChatModel"] GSM -- "durable execution" --> Temporal["Temporal"] RAG -- "pgvector + MinIO" --> Store["Vector + Object Store"]

Tuluat Mimari Diyagramı

Tuluat Dashboard

Proje altı Maven modülüne bölünmüştür - her modülün tek bir sorumluluğu vardır ve aralarındaki bağımlılık yönü tek yönlüdür (ArchUnit bunu derleme zamanında zorlar):

ModülSorumluluk
tuluat-crd-domainTüm CRD spec/status’ları için Java record’ları - şemanın tek kaynak gerçeği
tuluat-guardrailsPre/post execution filtre pipeline’ı (PII, injection, output validation)
tuluat-protocolsMCP istemci kaydı, A2A adaptörü
tuluat-engineAgent execution, iş akışı state machine, RAG, model gateway, Embabel, Temporal
tuluat-operatorJOSDK reconciler’lar - Kubernetes kontrol döngüsü
tuluat-appSpring Boot giriş noktası, REST controller’lar, WebSocket olayları

Bağımlılık kuralı nettir: operator engine’i import edebilir, tersi yasaktır; crd-domain hiçbir şeyi import etmez. Bu kuralı elle değil, ArchUnit ile derleme zamanında uygularız.


Adım 0 - Bağımlılıklar ve Sürümler

pom.xml‘in kalbi şu sürümlerdir:

BileşenSürümGörev
Java25Virtual threads, records, pattern matching
Spring Boot4.1.0Uygulama çatısı
Spring AI2.0.0ChatModel soyutlaması
JOSDK (java-operator-sdk)5.1.0Reconciler çatısı
Fabric8 Kubernetes Client7.8.0CR’ları okuma/yazma, Event üretme
Temporal1.27.0Durable workflow execution
Embabel2.0.0-SNAPSHOTGOAP tabanlı agent planlama
ArchUnit1.4.2Modül bağımlılık grafını zorlar

Püf Noktası - SNAPSHOT bağımlılıkları: Spring AI 2.0 + Spring Boot 4.1 + Embabel 2.0 henüz GA değil. Spring milestone/snapshot repository’lerini pom.xml‘e eklemeniz gerekir, yoksa Could not resolve artifact alırsınız. Bu bir risk - prod’a giderken GA sürümlere kilitleyin.


Adım 1 - Domain’i Tanımla: Record’lar Kaynak Gerçek

Her şey tuluat-crd-domain modülündeki Java record’larıyla başlar. Şemanın tek kaynak gerçeği burasıdır - CRD YAML’ları ve Fabric8 tarafından kullanılan tipler bu record’lardan türer.

public record AiAgentSpec(
    ProviderRef providerRef,
    String model,
    String systemPrompt,
    List<ToolDefinition> tools,
    List<SkillDefinition> skills,
    GuardrailsConfig guardrails,
    List<McpServerRef> mcpServers,
    Integer replicas,
    IngressSpec ingress,
    ...
) {
    public AiAgentSpec {
        if (tools == null) tools = List.of();
        if (skills == null) skills = List.of();
        if (systemPrompt == null) systemPrompt = "Yardımcı bir AI asistanısınız.";
    }
}

Püf Noktası - Compact Constructor: Record’lar immutability, equals/hashCode ve toString‘i bedavaya verir. Compact constructor içinde null listeleri List.of()‘a çevirerek asla NPE almayan güvenli varsayılanlar sağlarsınız. Bu, reconciler diffing’i için kritiktir: status.equals(newStatus) güvenilir olabilmek için null yerine boş liste döner.

Para alanları BigDecimal (double değil - kayan nokta yuvarlaması bütçe takibinde sessiz hata üretir), token sayaçları long tutulur.


Adım 2 - CRD YAML’larını Yaz

Record’ları tanımladıktan sonra manifests/crd/*.yaml altında CRD’leri yazarsınız. Kritik iki detay:

spec:
  group: ai.tuluat.com
  names:
    kind: AiWorkflow
    plural: aiworkflows
    shortNames: [workflow]     # kubectl get workflow
  scope: Namespaced
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources:
        status: {}              # ← status subresource AÇIK olmalı
      additionalPrinterColumns:
        - name: costSpent
          type: string
          jsonPath: .status.costSpentUsd

Püf Noktası - status subresource olmazsa olmaz: subresources.status: {} açılmazsa UpdateControl.patchStatus() çalışmaz. Ayrıca status’u ayrı subresource yapmak, spec güncellemeleriyle status güncellemelerinin çakışmasını engeller - operator sadece .status patch’ler, kullanıcı sadece .spec değiştirir.

Püf Noktası - Printer columns: additionalPrinterColumns sayesinde kubectl get aiworkflows tek satırda maliyet, token ve oturum sayısını gösterir. Bu, kubectl describe‘a gitmeden platformun sağlığını görmenizi sağlar. Her jsonPath .status.<alan>‘a işaret etmelidir.


Adım 3 - Operator İskeleti: Reconciler Kaydı

OperatorConfig, beş reconciler’ı JOSDK Operator‘una kaydeder:

@Configuration
public class OperatorConfig {

    @Bean
    public KubernetesClient kubernetesClient() {
        return new KubernetesClientBuilder().build();
    }

    @Bean(destroyMethod = "stop")
    @ConditionalOnExpression("environment.getProperty('AGENT_NAME') == null")
    public Operator operator(KubernetesClient client,
            LlmProviderReconciler providerReconciler,
            AiAgentReconciler agentReconciler,
            McpServerReconciler mcpServerReconciler,
            AiWorkflowReconciler workflowReconciler,
            WorkflowSessionReconciler sessionReconciler) {
        Operator operator = new Operator(o -> o.withKubernetesClient(client));
        operator.register(providerReconciler);
        operator.register(agentReconciler);
        operator.register(mcpServerReconciler);
        operator.register(workflowReconciler);
        operator.register(sessionReconciler);
        operator.start();
        return operator;
    }
}

Püf Noktası - Tek binary, iki rol: @ConditionalOnExpression(... AGENT_NAME == null) sayesinde aynı Docker image hem operator hem agent runtime olarak çalışır. AiAgent reconciler’ı her agent için AGENT_NAME env’li bir Deployment yaratır; bu pod’larda operator bean’i hiç oluşmaz. Tek imaj, tek dağıtım hattı - iki farklı davranış.


Adım 4 - Reconciler Derin Dalışı + Püf Noktaları

Reconciler bir JOSDK Reconciler<T> implementasyonudur; tek metodu reconcile(T resource, Context<T>) her olayda (create/update/delete) ve periyodik resync’te çağrılır. Tuluat’ta iki farklı reconciler deseni kullanılır.

4a. Çocuk kaynak üreten reconciler - AiAgentReconciler

@Override
public UpdateControl<AiAgent> reconcile(AiAgent agent, Context<AiAgent> context) {
    try {
        var spec = agent.getSpec();
        // 1. Referans verilen LlmProvider'ı çöz (cross-namespace destekli)
        LlmProvider provider = resolveProvider(spec, agent.getMetadata().getNamespace());
        if (provider == null) {
            agent.setStatus(AiAgentStatus.reconciling("Waiting for LlmProvider ...", ...));
            return UpdateControl.patchStatus(agent);
        }

        // 2. OwnerReference - garbage collection için
        OwnerReference ownerRef = new OwnerReferenceBuilder()
            .withApiVersion(agent.getApiVersion())
            .withKind(agent.getKind())
            .withName(agent.getMetadata().getName())
            .withUid(agent.getMetadata().getUid())
            .withController(true)
            .withBlockOwnerDeletion(true)
            .build();

        // 3. Deployment, Service, Ingress'i reconcile et
        reconcileDeployment(agent, ownerRef, ns);
        reconcileService(agent, ownerRef, ns);
        String ingressUrl = reconcileIngress(agent, ownerRef, ns);

        agent.setStatus(AiAgentStatus.ready(ingressUrl, activeSkills, activeTools, ...));
        return UpdateControl.patchStatus(agent);
    } catch (Exception e) {
        agent.setStatus(AiAgentStatus.failed("Reconciliation failure: " + e.getMessage(), ...));
        return UpdateControl.patchStatus(agent);
    }
}

Püf Noktası - OwnerReference + cascade delete: Çocuk kaynaklara withController(true) + withBlockOwnerDeletion(true) ile owner reference eklerseniz, kubectl delete aiagent X dediğinizde Kubernetes GC, Deployment/Service/Ingress’i otomatik siler. Elle delete çağrısı yazmak yerine platformun GC’sine güvenirsiniz.

Püf Noktası - Immutable alan kayması: Deployment selector.matchLabels immutable‘dır; güncelleyemezsiniz. Tuluat bunu yakalar: etiketler değiştiyse deletewaitUntilCondition(Objects::isNull)create. Bunu görmezden gelip update() çağırırsanız spec.selector: Invalid value ... is immutable hatası alırsınız.

Püf Noktası - Idempotency: reconcile her resync’te tekrar çalışır. Idempotent olmak zorunludur - createOrReplace() gibi Fabric8 hazır metotlarını kullanın; her seferinde sıfırdan create etmeyin. Aksi halde kaynak sürüm çakışması ve event fırtınası yaşarsınız.

Püf Noktası - Hata yakala, çökme: Reconciler exception fırlatırsa JOSDK yeniden dener ama log kirli kalır. Bunun yerine hatayı yakalayıp status = failed(...) yazıp patchStatus döndürürsünüz - döngü asla çökmez, durum her zaman gözlemlenebilir kalır.

4b. DB aggregation reconciler - AiWorkflowReconciler

Bu reconciler çocuk kaynak üretmez; bunun yerine WorkflowSessionRepository + NodeExecutionRepository‘den gerçek DB verilerini toplayıp status’u doldurur:

@ControllerConfiguration(
    maxReconciliationInterval = @MaxReconciliationInterval(interval = 30, timeUnit = TimeUnit.SECONDS))
public class AiWorkflowReconciler implements Reconciler<AiWorkflow> {

    @Override
    public UpdateControl<AiWorkflow> reconcile(AiWorkflow resource, Context<AiWorkflow> context) {
        // ... session'lardan cost/token/agentNames topla ...

        AiWorkflowStatus newStatus = new AiWorkflowStatus("Ready", nodeCount,
            costSpent.toPlainString(), budget.toPlainString(), sessionCount,
            totalTokens, inputTokens, outputTokens, agentNames);

        // Değişim yoksa status PATCH etme
        if (newStatus.equals(resource.getStatus())) {
            return UpdateControl.noUpdate();
        }
        resource.setStatus(newStatus);
        eventRecorder.record(resource, TYPE_NORMAL, "WorkflowStatusUpdated", ...);
        return UpdateControl.patchStatus(resource);
    }
}

Püf Noktası - Change detection (sonsuz döngü koruması): patchStatus bir update olayı tetikler → o da yeni bir reconcile tetikler → o da tekrar patch’ler… sonsuz döngü. Çözüm basittir: yeni status eski status’a eşitse UpdateControl.noUpdate() dön. Bu, record’ların equals()‘inin bedavaya gelmesinin en büyük kazancıdır.

Püf Noktası - maxReconciliationInterval: DB’de oturum tamamlandığında CR’da hiçbir olay tetiklenmez - çünkü oturum ayrı bir CR’dır. 30 saniyelik periyodik resync sayesinde AiWorkflow status’u gecikmeli de olsa kendini tazeler. Bu, event-driven’ı time-driven’la harmanlamanın temiz bir örneğidir.

Püf Noktası - toPlainString(): BigDecimal‘i doğrudan serialize ederseniz 6.6e-05 gibi bilimsel gösterim alırsınız. Okunabilir 0.000066 için toPlainString() kullanın - status’taki cost alanları bu yüzden String‘dir (bkz. Adım 13).


Adım 5 - Kubernetes Event’leri: Denetim İzi

kubectl describe ve kubectl get events çıktısında görünmek istiyorsanız core/v1 Event kaydetmeniz gerekir. Tuluat bunu KubernetesEventRecorder ile yapar:

@Component
public class KubernetesEventRecorder {
    public void record(HasMetadata resource, String type, String reason, String message) {
        if (resource == null || resource.getMetadata() == null) return;
        try {
            Event event = new EventBuilder()
                .withNewMetadata()
                    .withGenerateName(resource.getMetadata().getName() + "-")
                    .withNamespace(ns)
                .endMetadata()
                .withType(type)          // "Normal" | "Warning"
                .withReason(reason)
                .withMessage(message)
                .withNewInvolvedObject()
                    .withKind(resource.getKind())
                    .withApiVersion(resource.getApiVersion())
                    .withName(resource.getMetadata().getName())
                    .withUid(resource.getMetadata().getUid())
                .endInvolvedObject()
                .withFirstTimestamp(Instant.now().toString())
                .withLastTimestamp(Instant.now().toString())
                .withCount(1)
                .build();
            client.v1().events().inNamespace(ns).resource(event).create();
        } catch (Exception e) {
            log.warn("Failed to record event ...");   // yut, asla fırlatma
        }
    }
}

Püf Noktası - Event kaydı asla reconciliation’ı kırmamalı: Event üretimi best-effort’tur; try/catch ile sarılır ve hata log’lanıp yutulur. Event kaydetmek iş akışını durduran bir bağımlılık haline gelirse, RBAC hatası bile tüm platformu kilitler.

Püf Noktası - involvedObject: Event’i kaynağa bağlayan involvedObject alanıdır - kind, apiVersion, name, uid mutlaka doldurun. Yoksa kubectl describe aiworkflow X bu event’i göstermez. generateName ile isim çakışmasını önlersiniz.

Tuluat’ta şu event reason’ları üretilir:

ReasonTipAnlam
WorkflowSessionStartedNormalOturum çalışmaya başladı
WorkflowSessionCompletedNormalOturum başarıyla tamamlandı
WorkflowSessionFailedWarningOturum başarısız
WorkflowSessionWaitingApprovalNormalİnsan onayı bekleniyor
WorkflowSessionRejectedWarningOturum reddedildi
WorkflowSessionWorkflowNotFoundWarningReferans workflow yok
WorkflowStatusUpdatedNormalWorkflow metrikleri güncellendi

Adım 6 - Execution Pipeline: Agent Çağrısının Yolculuğu

AgentExecutionService, her agent çağrısını 8 aşamalı bir pipeline’dan geçirir:

1. Model & provider çözümle   ←  AiAgent CR + LlmProvider CR
2. Pre-execution guardrail'ler ←  PiiMaskingFilter + PromptInjectionFilter
3. Tool'ları çalıştır          ←  Virtual Thread (tool başına bir, eşzamanlı)
4. Session memory enjekte et   ←  kısa süreli konuşma belleği
5. System prompt inşa et       ←  base + tool + skill + MCP + RAG bağlamı
6. LLM çağrısı                 ←  ModelGateway → Spring AI ChatModel
7. Çıktıyı doğrula             ←  OutputValidationFilter
8. Cevabı belleğe kaydet       ←  SessionMemoryManager

GuardrailPipeline düzgün bir filtre zinciridir - her filtre PreExecutionFilter veya PostExecutionFilter uygulayan bir @Service‘tir. Yeni bir guardrail eklemek tek sınıf, sıfır framework değişikliği demektir.

Agent Logları


Adım 7 - İş Akışları: Sıralı Graf

AiWorkflow CR’ları iş akışlarını sıralı bir graf olarak tanımlar - tiplendirilmiş düğümlerden oluşan, koşullu dallanma ve maxLoops ile sınırlı döngü içeren lineer bir zincir. Bu henüz tam paralel bir DAG (fan-out/fan-in) değil; parallel execution yol haritasında:

spec:
  initialNode: araştır
  nodes:
    - { id: araştır,   type: AGENT,          agentRef: araştırmacı, outputKey: bulgular }
    - { id: karar,     type: CONDITION,      expression: "riskScore > 0.8" }
    - { id: onay,      type: HUMAN_APPROVAL }
  edges:
    - { from: araştır, to: karar }
    - { from: karar,   to: onay, condition: "true" }
    - { from: karar,   to: araştır, condition: "false" }   # döngü - maxLoops ile sınırlı

GraphStateMachineEngine grafı düğüm düğüm yürütür; her adım WorkflowSessionEntity‘yi PostgreSQL’e kalıcı olarak kaydeder:

public WorkflowSessionEntity executeNextStep(AiWorkflowSpec spec, WorkflowSessionEntity session, int maxLoops) {
    if (session.getLoopCount() >= maxLoops) {
        session.setStatus(SessionStatus.FAILED);   // sonsuz döngü koruması
        return session;
    }
    // ...
    if ("AGENT".equalsIgnoreCase(node.type())) {
        AgentResponse response = agentExecutionService.executeAgent(...);
        persistNodeExecution(...);                 // node başına metrik persist
        contextData.put(node.outputKey(), response.answer());
        // outputSchema varsa guardrail ile doğrula
    } else if ("CONDITION".equalsIgnoreCase(node.type())) {
        boolean result = evaluateCondition(node.expression(), contextData);  // SpEL
        // edge.condition'a göre yönlendir
    } else if ("HUMAN_APPROVAL".equalsIgnoreCase(node.type())) {
        if (!contextData.containsKey("approvalStatus")) {
            session.setStatus(SessionStatus.WAITING_APPROVAL);  // duraklat
            return session;
        }
        // onay/ret'e göre ilerle
    }
    session.setLoopCount(session.getLoopCount() + 1);
    return session;
}

Püf Noktası - Koşullar için SpEL: CONDITION düğümleri contextData üzerinde Spring Expression Language (SpelExpressionParser) değerlendirir. riskScore > 0.8 gibi ifadeler derleme olmadan YAML’dan yönetilir - iş mantığını redeploy etmeden değiştirebilirsiniz.

Püf Noktası - maxLoops döngü koruması: Graf döngü içerebilir (koşullu geri-dönüş). maxLoops sınırı olmazsa bir yanlış koşul sonsuz döngüye girer ve LLM maliyeti fırlar. Değer WorkflowSession.spec.parameters.maxLoops‘tan okunur, yoksa 10 varsayılır.

Püf Noktası - Node başına metrik persist: Token/cost’un 0 gelmesinin kök nedeni, metriklerin sadece Temporal activity’lerinde yazılmasıydı. Temporal bypass edilince (geliştirme ortamı) DB’ye hiçbir şey yazılmıyordu. GraphStateMachineEngine‘e NodeExecutionRepository + persistNodeExecution ekleyerek metriği her iki yolda da persist ettik.

İş Akışı Grafı


Adım 8 - İnsan Döngüsü: Onay Gelen Kutusu

Bir iş akışı HUMAN_APPROVAL düğümüne ulaştığında oturum WAITING_APPROVAL‘a geçer. Operator:

  1. Bağlı dashboard’lara WebSocket olayı yayınlar
  2. POST /api/v1/workflows/{sessionId}/approve veya /reject bekler
  3. Temporal kullanılıyorsa çalışan iş akışına ApprovalSignal gönderir

WorkflowExecutionService.processApprovalSignal onay kararını contextData‘ya yazar (approvalStatus, approvalFeedback) ve grafı kaldığı yerden devam ettirir. Onay gelen kutusu arayüzü bekleyen tüm onayları tam iş akışı bağlamı, agent akıl yürütmesi ve birikmiş contextData ile gösterir.

Püf Noktası - İç vs dış HITL: Bugün implemente olan HITL, process içi onay gelen kutusudur (WebSocket + REST /approve//reject + dashboard inbox). Harici HITL kanalları (email, webhook, dış aksiyonlar) yol haritasında - Temporal ApprovalSignal altyapısı bunlar için de temel olacak.

Onay Gelen Kutusu


Adım 9 - Temporal: Durable Execution

Uzun süreli iş akışları için execution WorkflowSessionTemporalWorkflowImpl aracılığıyla Temporal‘a devredilir. TemporalConfig bir WorkerFactory başlatır:

public static final String TASK_QUEUE = "AI_WORKFLOW_TASK_QUEUE";

@Bean
public WorkerFactory workerFactory(WorkflowClient client, GraphNodeActivitiesImpl activities) {
    WorkerFactory factory = WorkerFactory.newInstance(client);
    Worker worker = factory.newWorker(TASK_QUEUE);
    worker.registerWorkflowImplementationTypes(WorkflowSessionTemporalWorkflowImpl.class);
    worker.registerActivitiesImplementations(activities);
    factory.start();
    return factory;
}

Workflow implementasyonu, düğümleri bir ActivityStub üzerinden çalıştırır ve onay sinyalini signalApproval metoduyla alır:

public Map<String, Object> runSession(UUID sessionId, String workflowName, AiWorkflowSpec spec, ...) {
    Map<String, NodeDefinition> nodeIndex = spec.nodes().stream()
        .collect(Collectors.toMap(NodeDefinition::id, Function.identity()));  // O(1) lookup

    String currentNodeId = spec.initialNode();
    int loopCount = 0;
    while (currentNodeId != null && loopCount < maxLoops) {
        NodeType type = NodeType.from(nodeIndex.get(currentNodeId).type());
        var executor = executorFactory.getExecutor(type);
        currentNodeId = executor.map(e -> e.execute(...)).orElse(null);
        loopCount++;
    }
    return Map.copyOf(contextData);
}

Püf Noktası - Graceful degradation: WorkflowClient constructor’da Optional<WorkflowClient> olarak enjekte edilir. Temporal cluster yoksa engine aynı process içindeki GraphStateMachineEngine‘e düşer. Bu sayede geliştirme ortamı Temporal gerektirmez, üretim ise durable execution + distributed retry + visibility kazanır - kod iki yolu da tek arayüzden yürütür.

Püf Noktası - Temporal determinizmi: Workflow kodunuz deterministik olmalı - System.currentTimeMillis() ve doğrudan I/O workflow gövdesinde yasaktır; bunlar activity’lere taşınır. Tuluat’ta tüm LLM çağrısı ve DB erişimi GraphNodeActivities üzerinden yapılır, workflow sadece orchestrasyon tutar. Bu ayrım Temporal replay’in doğru çalışması için şarttır.


Adım 10 - Embabel: GOAP ile Ajan Planlama

Embabel, Goal-Oriented Action Planning (GOAP) motoruyla agent’ları hedef bazında planlar. Tuluat bunu iki parçayla entegre eder:

1. CRD’den dinamik model kaydı - CrdEmbabelConfiguration:

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(KubernetesClient.class)
@EnableScheduling
public class CrdEmbabelConfiguration {

    @Bean
    ProviderInitialization crdProviderInitialization() {
        return scanAndRegisterProviders("startup");
    }

    @Scheduled(fixedDelayString = "PT60S", initialDelayString = "PT30S")
    void reconcileCrdProviders() { ... }   // yeni/updated LlmProvider CR'larını tarar
}

Püf Noktası - Dinamik reconciling restart’sız: @Scheduled her 60 saniyede cluster’ı tarayıp yeni/updated LlmProvider CR’larından Embabel’e model kaydeder. Yeni bir provider eklemek için operator restart gerekmez. registeredProviderNames set’i gereksiz yeniden kaydı önler.

2. Hedef ajanı - TuluatGoalAgent:

@Agent(description = "Executes AI agent goals using the Tuluat agent execution pipeline")
public class TuluatGoalAgent {

    @AchievesGoal(description = "Completes the goal by executing the named AI agent")
    @Action
    public GoalResult executeGoal(GoalRequest request) {
        AgentResponse response = agentExecutionService.executeAgent(
            request.agentName(), request.goalDescription(), ...);
        return new GoalResult(request.agentName(), response.answer(), response.usage());
    }
}

Embabel’in GOAP motoru @Agent + @AchievesGoal + @Action anotasyonlarını keşfeder ve çok adımlı hedefleri (araştır → doğrula → raporla) tiplendirilmiş girdi/çıktı kontratlarına göre dinamik sıralar. Tek adımlı hedefler için planlama trivialdir; Tuluat bu ajanı tam execution pipeline’ına (guardrails → skills → RAG → gateway) delege eder.


Adım 11 - Model Gateway: Fallback ve Bütçe

ModelGateway, engine ile Spring AI arasında konumlanır:

spec:
  type: OPENAI
  defaultModel: gpt-4o
  fallbacks:
    - { providerName: anthropic-provider, model: claude-3-5-sonnet-20241022 }
    - { providerName: ollama-lokal,      model: llama3.2 }

Zincirdeki tüm rotalar başarısız olursa engine simüle edilmiş yanıta düşer - gerçek API anahtarı olmadan geliştirme ortamında iş akışlarını canlı tutar.

Püf Noktası - Placeholder: Bu ModelGateway hafif bir process-içi router’dır (fallback + bütçe) - PoC’yi canlı tutmak için. Uzun vadede merkezi bir model gateway (örn. LiteLLM) ile değiştirilmesi planlanıyor; provider yönlendirme + bütçe + fallback mantığı o katmana taşınacak.

MCP Provider’lar


Adım 12 - RAG: Kubernetes-Native Belge İndeksleme

RAG pipeline’ı temiz bir ingest → embed → retrieve modelini izler:

  1. Ingest - sourceRef önekiyle /api/v1/rag/ingest‘e POST
  2. Chunk - RecursiveCharacterChunker metni yapılandırılabilir örtüşmeyle böler
  3. Embed - SpringAiEmbeddingProvider (geliştirme için LocalHashEmbeddingProvider) vektör üretir
  4. Store - ham belge MinIO’da (S3-uyumlu), embedding’ler pgvector’da
  5. Retrieve - cosine similarity top-K, sistem promptuna kaynak atfıyla eklenir
Sistem promptuna enjekte edilen bağlam:
[kaynak runbooks/incident-42 #1 (benzerlik 0.91)]: ...
[kaynak runbooks/incident-42 #2 (benzerlik 0.87)]: ...

Retrieval sorgu bazında, otomatik ve agent için görünmezdir - agent sadece daha zengin bir sistem promptu alır.


Adım 13 - Kalıcılık: PostgreSQL, Flyway ve Transaction Tuzağı

Tuluat’ta Flyway’i Spring Boot yönetmez - migration’lar ayrı bir Kubernetes Job ile operator başlamadan önce çalışır, Spring sadece ddl-auto=validate yapar:

spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://postgres-service:5432/ai_operator_db}
spring.datasource.hikari.maximum-pool-size=3
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

Migration’lar tek projede, sıralı numaralarla (V1__workflow_operator.sqlV6__drop_short_memory_foreign_key.sql) tutulur; Flyway Job bunları flyway/flyway:11-alpine imajıyla ConfigMap’ten okur.

Püf Noktası - @Transactional kaldırmanın hikayesi: İlk tasarımda startSession tüm iş akışını (yavaş LLM çağrıları) tek transaction‘da tutuyordu. Bu, bir HikariCP connection’ını dakikalarca blokladı ve FATAL: sorry, too many clients hatası üretti. Çözüm: @Transactional‘ı kaldır - her repository.save zaten kendi transaction’ı. Connection’ı LLM çağrısı boyunca tutmamak bu hatanın kök çözümüdür; max_connections‘ı artırmak sadece yara bandıdır.

Püf Noktası - PVC şart: PostgreSQL verisi PVC olmadan kalıcı olmaz; rolling update/restart Flyway tablolarını siler → Schema validation: missing table [session_short_memory]. 1Gi PVC’yi /var/lib/postgresql/data‘ya mount edin.

Püf Noktası - Cost → String: BigDecimal status’ta 6.6e-05 olarak serialize olur. Hem Java record’unda hem CRD şemasında (type: string) hem printer column’da String kullanın. Şemayı unutup sadece Java’yı değiştirirseniz API server status PATCH’i reddeder: expected numeric, got string. Üçü (record + CRD şeması + column) birlikte değişmeli.


Adım 14 - Gözlemlenebilirlik: Micrometer

Her iş akışı geçişi Micrometer counter’ları yayınlar:

ai.workflow.session.created.total{workflow="fatura-onay"}
ai.workflow.session.completed.total{workflow="fatura-onay",status="COMPLETED"}
ai.workflow.node.executed.total{workflow="...",node_type="AGENT",node_id="analiz-et"}

Bunlar ek enstrümantasyon olmadan doğrudan Prometheus + Grafana dashboard’larına beslenir. WorkflowTelemetryService tüm geçiş noktalarında Optional olarak enjekte edilir - telemetry kapatıldığında da engine çalışır.


Adım 15 - Custom Resource Özeti

Projede beş Custom Resource Definition vardır:

CRDPluralTemel specTemel status
LlmProviderllmproviderstype, defaultModel, fallbacks[], apiKey.secretKeyRefprovider durumu
AiAgentaiagentsproviderRef, model, systemPrompt, guardrails, tools[], skills[], mcpServers[], replicas, ingressphase, ingressUrl, aktif skills/tools/mcp, model
McpServermcpserversMCP sunucu endpoint/konfigürasyonuMCP durumu
AiWorkflowaiworkflowsinitialNode, nodes[], edges[], memoryConfig, budgetLimitUsdstate, nodeCount, costSpentUsd, budgetLimitUsd, sessionCount, totalTokens, inputTokens, outputTokens, agentNames[]
WorkflowSessionworkflowsessionsworkflowRef, input, parameterssessionId, phase, currentNode, output, startTime, endTime, totalTokens, inputTokens, outputTokens, costUsd, durationSeconds, nodeExecutions[]

Akış şöyledir: kullanıcı AiWorkflow tanımlar → WorkflowSessionController.createSession bir WorkflowSession CR oluşturur → WorkflowSessionReconciler bunu görür, startSession çağırır, grafı yürütür → tamamlanınca AiWorkflowReconciler (30sn resync) tüm oturumları DB’den toplayıp AiWorkflow.status‘u günceller.

kubectl get aiworkflows -n tuluat-system
# NAME                       STATE  COSTSPENT  BUDGET  SESSIONS  TOTALTOKENS  INPUTTOKENS  OUTPUTTOKENS
# multi-agent-researcher     Ready  0.000132   0       2          140          80           60
# order-processing-workflow  Ready  0.000066   0       2          175          100          75

Görsel Tuval


Adım 16 - Java 25 + Spring Boot 4 Pratikte

Virtual Thread’ler

Tool’lar virtual thread’ler üzerinde eşzamanlı çalışır - thread havuzu boyutlandırması yok:

var futures = activeDefs.stream()
    .map(def -> virtualThreadExecutor.submit(() -> tool.execute(input, def.parameters())))
    .toList();

Executors.newVirtualThreadPerTaskExecutor() - minimum overhead ile binlerce eşzamanlı tool çağrısı. Spring’in async task’ları da virtual thread’e bağlanır:

@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
    return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}

Java Record’ları

Tüm CRD spec/status’ları compact constructor’lı record’lardır (Adım 1). Immutability + equals/hashCode + toString bedavaya gelir; reconciler diffing ve change detection bunun üzerine kuruludur.

Switch Expressions & Pattern Matching

switch (entity.getStatus()) {
    case COMPLETED       -> reason = "WorkflowSessionCompleted";
    case FAILED          -> reason = "WorkflowSessionFailed";
    case WAITING_APPROVAL -> reason = "WorkflowSessionWaitingApproval";
    case REJECTED        -> reason = "WorkflowSessionRejected";
    default              -> reason = "WorkflowSessionUpdated";
}

Optional Dependency Injection (ADR 005)

Her isteğe bağlı collaborator constructor’da Optional<T> kullanır:

public AgentExecutionService(
    ToolRegistry toolRegistry,
    Optional<SkillRegistry> skillRegistry,
    Optional<ModelGateway> modelGateway,
    Optional<RagService> ragService,
    ...
)

Bu, engine’i Spring context olmadan test edilebilir kılar, @ConditionalOn* ile aşamalı özellik etkinleştirmeyi destekler ve isteğe bağlılığı tip düzeyinde belgeler.


Adım 17 - Pipeline: Kind + Kustomize + Helm

Geliştirme döngüsü üç araç etrafında kuruludur: Kind (yerel cluster), Kustomize (örnek kaynaklar + secret’lar) ve Helm (release paketlemesi).

Yerel: Kind + Kustomize

./scripts/create-kind-cluster.sh her şeyi idempotent şekilde kurar:

  1. kind-config.yaml - Ingress için extraPortMappings (host 80/443 → container 80/443) ve DynamicResourceAllocation=true feature-gate’i
  2. Kind cluster oluştur (varsa atlar) + NGINX Ingress Controller kur
  3. docker build -t tuluat-operator:latest .
  4. kind load docker-image tuluat-operator:latest - imajı cluster’a yükle
  5. deploy-operator.sh - CRD’ler → RBAC → Temporal/MinIO/WireMock → örnek kaynaklar → Flyway Job
./scripts/create-kind-cluster.sh

deploy-operator.sh örnek kaynakları Kustomize ile uygular: kubectl apply -k config/. config/kustomization.yaml hem tüm örnek CR’ları (samples/*.yaml) hem de API anahtarlarını secretGenerator ile ortam değişkenlerinden üretir:

secretGenerator:
  - name: deepseek-secret
    literals:
      - api-key=${DEEPSEEK_API_KEY:-sk-placeholder-deepseek-key}
  - name: openai-secret
    literals:
      - api-key=${OPENAI_API_KEY:-sk-placeholder-openai-key}
generatorOptions:
  disableNameSuffixHash: true

Püf Noktası - kind load docker-image: Yerel geliştirmede imajı registry’ye push etmenize gerek yok - kind load docker-image imajı doğrudan node container’ına kopyalar; registry kurulumu olmadan sıfır-friction döngü.

Püf Noktası - Secret’ı commit etme: secretGenerator API anahtarlarını env var’dan okur, placeholder fallback ile. Gerçek anahtar repo’ya hiç girmiyor; disableNameSuffixHash: true secret adını kararlı tutar (deepseek-secret), böylece SecretKeyRef referansları bozulmaz.

Release: Helm

Release tarafında proje tek bir Helm chart olarak paketlenir (helm/tuluat-operator): operator, CRD’ler, altyapı (PostgreSQL+pgvector, Temporal, MinIO, WireMock, Prometheus, Grafana) ve örnek kaynaklar tek helm upgrade --install ile kurulur.

helm package helm/tuluat-operator -d dist
helm push dist/*.tgz "oci://ghcr.io/<org>/charts"      # GHCR OCI kayıt defteri
helm upgrade --install tuluat-operator dist/*.tgz \
  --namespace tuluat-system --set wiremock.enabled=true --set samples.install=true

Püf Noktası - Helm vs Kustomize: Kustomize yerel geliştirme ve örnek kaynakları (env-var secret’larıyla) yönetir; Helm release paketlemesi yapar - tek chart tüm stack’i GHCR OCI’ya taşır ve CI’daki e2e-kind bu chart’ı kurar. İkisi rakip değil, farklı katmanlar.

CI: GitHub Actions

GitHub Actions pipeline’ı mümkün olan yerlerde işleri paralel çalıştırır:

flowchart TD DC["değişiklikleri-algıla"] Build["build
Spotless → Checkstyle → ArchUnit → testler → Docker image"] PackageHelm["helm-paketle"] PublishHelm["helm-yayınla
GHCR OCI kayıt defteri"] E2E["e2e-kind
KinD cluster → Helm install → E2E kabul testleri"] Docs["build-docs
MkDocs → GitHub Pages"] DC --> Build DC --> PackageHelm --> PublishHelm --> E2E DC --> Docs

ArchUnit, modül bağımlılık grafını zorlar - engine operator’dan import edemez, domain engine’den import edemez. Mimari sapma derleme zamanında yakalanır (ArchitectureTest 13/13).

Püf Noktası - Wildcard import yasağı: Kod tabanında import com.foo.* yoktur; FQCN (fully-qualified class name) kullanımı da yasaktır. Bunlar Checkstyle + ArchUnit ile zorlanır. Bu kural diff’leri küçük ve review’u kolay tutar.


Teknik Borçlar: Dürüst Bir Envanter

Hızlı bir PoC’dan sonra ileride ele alınacak bilinen sınırlamaları belgeledik:

BorçEtki
Global RAG vektör deposuAgent izolasyonu yok - bir departmanın belgeleri diğerine sızabilir
Bellek içi bütçe takibiPod yeniden başlatmalarında sıfırlanır; çok replicada ölçeklenmez
Paylaşılan Tool kayıt defteriTool izolasyonu yok; JAR classloader sızıntısı riski
MCP tool yönlendirmesi yokmcpServers: spec alanı çalışma zamanında kısmen no-op
SNAPSHOT bağımlılıklarıSpring AI 2.0 + Spring Boot 4.1 + Embabel 2.0 henüz GA değil
Helm CRD’leri stalemanifests/crd/ deploy’da kullanılır; Helm CRD’leri senkron değil

Her biri docs/tech-debt/ altında önerilen düzeltme yollarıyla belgelenmiştir.


Deneyip Görmek İster Misiniz?

git clone https://github.com/netologist/tuluat
./scripts/create-kind-cluster.sh
./scripts/deploy-operator.sh          # CRD'ler + PostgreSQL/Flyway + Kustomize örnek kaynaklar
kubectl get aiworkflows -n tuluat-system
kubectl get workflowsessions -n tuluat-system

Operator CRD’lerinizi reconcile edecek ve dashboard’u http://localhost:8080 adresinde sunacaktır.


Java 25, Spring Boot 4.1, Spring AI 2.0, JOSDK 5.1, Temporal 1.27, Embabel 2.0, Fabric8 7.8 ve Kubernetes kontrol döngüsüne derin bir saygıyla inşa edilmiştir.