Tuluat: Java ile Kubernetes AI Operator İnşa Etmek
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:
- Konfigürasyon dağıtımı - yeni bir model/agent için redeploy gerekir.
- İzolasyon - hangi takım hangi agent’ı hangi bütçeyle kullanıyor belli değil.
- Gözlemlenebilirlik -
kubectl getile durumu göremiyorsunuz. - 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:
- Virtual threads - binlerce eşzamanlı LLM/tool çağrısını iş parçacığı havuzu derdi olmadan yönetir.
- GraalVM - native derleme ile düşük bellek ve hızlı başlatma seçenekleri sunar.
- CRaC - checkpoint/restore ile neredeyse sıfır soğuk başlatma yönünde bir başka kapı açar.
- JVM ekosistemi - Spring AI, Embabel, Temporal gibi AI/orkestrasyon kütüphaneleri hazır.
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ış
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"]


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ül | Sorumluluk |
|---|---|
tuluat-crd-domain | Tüm CRD spec/status’ları için Java record’ları - şemanın tek kaynak gerçeği |
tuluat-guardrails | Pre/post execution filtre pipeline’ı (PII, injection, output validation) |
tuluat-protocols | MCP istemci kaydı, A2A adaptörü |
tuluat-engine | Agent execution, iş akışı state machine, RAG, model gateway, Embabel, Temporal |
tuluat-operator | JOSDK reconciler’lar - Kubernetes kontrol döngüsü |
tuluat-app | Spring 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şen | Sürüm | Görev |
|---|---|---|
| Java | 25 | Virtual threads, records, pattern matching |
| Spring Boot | 4.1.0 | Uygulama çatısı |
| Spring AI | 2.0.0 | ChatModel soyutlaması |
| JOSDK (java-operator-sdk) | 5.1.0 | Reconciler çatısı |
| Fabric8 Kubernetes Client | 7.8.0 | CR’ları okuma/yazma, Event üretme |
| Temporal | 1.27.0 | Durable workflow execution |
| Embabel | 2.0.0-SNAPSHOT | GOAP tabanlı agent planlama |
| ArchUnit | 1.4.2 | Modü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, yoksaCould not resolve artifactalı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/hashCodevetoString‘i bedavaya verir. Compact constructor içinde null listeleriList.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çinnullyerine 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ı -
statussubresource olmazsa olmaz:subresources.status: {}açılmazsaUpdateControl.patchStatus()çalışmaz. Ayrıca status’u ayrı subresource yapmak, spec güncellemeleriyle status güncellemelerinin çakışmasını engeller - operator sadece.statuspatch’ler, kullanıcı sadece.specdeğiştirir.
Püf Noktası - Printer columns:
additionalPrinterColumnssayesindekubectl get aiworkflowstek satırda maliyet, token ve oturum sayısını gösterir. Bu,kubectl describe‘a gitmeden platformun sağlığını görmenizi sağlar. HerjsonPath.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çinAGENT_NAMEenv’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 Xdediğinizde Kubernetes GC, Deployment/Service/Ingress’i otomatik siler. Elledeleteçağrısı yazmak yerine platformun GC’sine güvenirsiniz.
Püf Noktası - Immutable alan kayması: Deployment
selector.matchLabelsimmutable‘dır; güncelleyemezsiniz. Tuluat bunu yakalar: etiketler değiştiysedelete→waitUntilCondition(Objects::isNull)→create. Bunu görmezden gelipupdate()çağırırsanızspec.selector: Invalid value ... is immutablehatası alırsınız.
Püf Noktası - Idempotency:
reconcileher 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ıppatchStatusdö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ı):
patchStatusbir 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şitseUpdateControl.noUpdate()dön. Bu, record’larınequals()‘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 sayesindeAiWorkflowstatus’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 ederseniz6.6e-05gibi bilimsel gösterim alırsınız. Okunabilir0.000066içintoPlainString()kullanın - status’taki cost alanları bu yüzdenString‘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/catchile 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ğlayaninvolvedObjectalanıdır -kind,apiVersion,name,uidmutlaka doldurun. Yoksakubectl describe aiworkflow Xbu event’i göstermez.generateNameile isim çakışmasını önlersiniz.
Tuluat’ta şu event reason’ları üretilir:
| Reason | Tip | Anlam |
|---|---|---|
WorkflowSessionStarted | Normal | Oturum çalışmaya başladı |
WorkflowSessionCompleted | Normal | Oturum başarıyla tamamlandı |
WorkflowSessionFailed | Warning | Oturum başarısız |
WorkflowSessionWaitingApproval | Normal | İnsan onayı bekleniyor |
WorkflowSessionRejected | Warning | Oturum reddedildi |
WorkflowSessionWorkflowNotFound | Warning | Referans workflow yok |
WorkflowStatusUpdated | Normal | Workflow 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.

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.8gibi ifadeler derleme olmadan YAML’dan yönetilir - iş mantığını redeploy etmeden değiştirebilirsiniz.
Püf Noktası -
maxLoopsdöngü koruması: Graf döngü içerebilir (koşullu geri-dönüş).maxLoopssınırı olmazsa bir yanlış koşul sonsuz döngüye girer ve LLM maliyeti fırlar. DeğerWorkflowSession.spec.parameters.maxLoops‘tan okunur, yoksa 10 varsayılır.
Püf Noktası - Node başına metrik persist: Token/cost’un
0gelmesinin 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‘eNodeExecutionRepository+persistNodeExecutionekleyerek metriği her iki yolda da persist ettik.

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:
- Bağlı dashboard’lara WebSocket olayı yayınlar
POST /api/v1/workflows/{sessionId}/approveveya/rejectbekler- Temporal kullanılıyorsa çalışan iş akışına
ApprovalSignalgö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 - TemporalApprovalSignalaltyapısı bunlar için de temel olacak.

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:
WorkflowClientconstructor’daOptional<WorkflowClient>olarak enjekte edilir. Temporal cluster yoksa engine aynı process içindekiGraphStateMachineEngine‘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şimiGraphNodeActivitiesü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:
@Scheduledher 60 saniyede cluster’ı tarayıp yeni/updatedLlmProviderCR’larından Embabel’e model kaydeder. Yeni bir provider eklemek için operator restart gerekmez.registeredProviderNamesset’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:
- Provider yönlendirme -
LlmProviderCRD tipinden doğruChatModelbean’ini çözer - Sıralı fallback zincirleri - birincil model başarısız olursa
spec.fallbacks[]sırayla denenir - Bütçe zorunluluğu - agent başına USD harcamasını takip eder; sınır aşılırsa LLM çağrısından
önce
BudgetExceededExceptionfırlatı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
ModelGatewayhafif 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.

Adım 12 - RAG: Kubernetes-Native Belge İndeksleme
RAG pipeline’ı temiz bir ingest → embed → retrieve modelini izler:
- Ingest -
sourceRefönekiyle/api/v1/rag/ingest‘e POST - Chunk -
RecursiveCharacterChunkermetni yapılandırılabilir örtüşmeyle böler - Embed -
SpringAiEmbeddingProvider(geliştirme içinLocalHashEmbeddingProvider) vektör üretir - Store - ham belge MinIO’da (S3-uyumlu), embedding’ler pgvector’da
- 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.sql …
V6__drop_short_memory_foreign_key.sql) tutulur; Flyway Job bunları flyway/flyway:11-alpine
imajıyla ConfigMap’ten okur.
Püf Noktası -
@Transactionalkaldırmanın hikayesi: İlk tasarımdastartSessiontüm iş akışını (yavaş LLM çağrıları) tek transaction‘da tutuyordu. Bu, bir HikariCP connection’ını dakikalarca blokladı veFATAL: sorry, too many clientshatası üretti. Çözüm:@Transactional‘ı kaldır - herrepository.savezaten 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].1GiPVC’yi/var/lib/postgresql/data‘ya mount edin.
Püf Noktası - Cost → String:
BigDecimalstatus’ta6.6e-05olarak serialize olur. Hem Java record’unda hem CRD şemasında (type: string) hem printer column’daStringkullanı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:
| CRD | Plural | Temel spec | Temel status |
|---|---|---|---|
| LlmProvider | llmproviders | type, defaultModel, fallbacks[], apiKey.secretKeyRef | provider durumu |
| AiAgent | aiagents | providerRef, model, systemPrompt, guardrails, tools[], skills[], mcpServers[], replicas, ingress | phase, ingressUrl, aktif skills/tools/mcp, model |
| McpServer | mcpservers | MCP sunucu endpoint/konfigürasyonu | MCP durumu |
| AiWorkflow | aiworkflows | initialNode, nodes[], edges[], memoryConfig, budgetLimitUsd | state, nodeCount, costSpentUsd, budgetLimitUsd, sessionCount, totalTokens, inputTokens, outputTokens, agentNames[] |
| WorkflowSession | workflowsessions | workflowRef, input, parameters | sessionId, 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

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:
kind-config.yaml- Ingress içinextraPortMappings(host 80/443 → container 80/443) veDynamicResourceAllocation=truefeature-gate’i- Kind cluster oluştur (varsa atlar) + NGINX Ingress Controller kur
docker build -t tuluat-operator:latest .kind load docker-image tuluat-operator:latest- imajı cluster’a yükledeploy-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-imageimajı doğrudan node container’ına kopyalar; registry kurulumu olmadan sıfır-friction döngü.
Püf Noktası - Secret’ı commit etme:
secretGeneratorAPI anahtarlarını env var’dan okur, placeholder fallback ile. Gerçek anahtar repo’ya hiç girmiyor;disableNameSuffixHash: truesecret adını kararlı tutar (deepseek-secret), böyleceSecretKeyRefreferansları 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:
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 deposu | Agent izolasyonu yok - bir departmanın belgeleri diğerine sızabilir |
| Bellek içi bütçe takibi | Pod yeniden başlatmalarında sıfırlanır; çok replicada ölçeklenmez |
| Paylaşılan Tool kayıt defteri | Tool izolasyonu yok; JAR classloader sızıntısı riski |
| MCP tool yönlendirmesi yok | mcpServers: 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 stale | manifests/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.