Spring AI + Kafka: The Consumer
This is the heart of the system — the consumer that actually processes AI tasks.
@Component
@Slf4j
public class AITaskConsumer {
private final ChatClient chatClient;
private final KafkaTemplate<String, AIResult> resultTemplate;
private final AITaskRepository taskRepository;
private final MeterRegistry meterRegistry;
private final AdaptiveRateLimiter rateLimiter;
@KafkaListener(
topics = { AITopicConfig.TASKS_SUMMARIZE, AITopicConfig.TASKS_CLASSIFY },
groupId = "ai-worker-high-priority",
concurrency = "8", // 8 consumer threads per pod
containerFactory = "aiListenerContainerFactory"
)
public void consume(
@Payload AITask task,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
Acknowledgment ack) {
log.info("task={} type={} attempt={}", task.taskId(), task.taskType(), task.attemptNumber());
if (taskRepository.isCompleted(task.taskId())) {
log.debug("task={} already completed — skipping (idempotent ack)", task.taskId());
ack.acknowledge();
return;
}
Timer.Sample timer = Timer.start(meterRegistry);
try {
rateLimiter.acquire();
AIResult result = switch (task.taskType()) {
case "summarize" -> handleSummarize(task);
case "classify" -> handleClassify(task);
default -> throw new IllegalArgumentException("Unknown type: " + task.taskType());
};
taskRepository.markComplete(task.taskId(), result);
resultTemplate.send(RESULTS_COMPLETED, task.taskId(), result);
ack.acknowledge();
rateLimiter.onSuccess();
timer.stop(meterRegistry.timer("ai.task.processing",
"type", task.taskType(), "status", "success"));
} catch (RateLimitException e) {
rateLimiter.onRateLimit();
log.warn("task={} rate-limited (attempt={})", task.taskId(), task.attemptNumber());
} catch (Exception e) {
log.error("task={} failed attempt={}: {}", task.taskId(), task.attemptNumber(), e.getMessage());
if (task.attemptNumber() >= 3) {
sendToDLQ(task, e.getMessage());
ack.acknowledge();
}
timer.stop(meterRegistry.timer("ai.task.processing",
"type", task.taskType(), "status", "error"));
}
}
private AIResult handleSummarize(AITask task) {
String content = (String) task.payload().get("content");
int maxWords = (int) task.payload().getOrDefault("maxWords", 150);
String prompt = """
Summarize the following in under %d words.
Be specific — preserve key facts, numbers, and named entities.
Content:
%s
""".formatted(maxWords, content);
Instant start = Instant.now();
ChatResponse resp = chatClient.prompt().user(prompt).call().chatResponse();
Usage usage = resp.getMetadata().getUsage();
return new AIResult(
task.taskId(), "summarize", true, Instant.now(),
Duration.between(start, Instant.now()).toMillis(),
"llama-3.1-8b-instant",
(int) usage.getPromptTokens(), (int) usage.getGenerationTokens(),
Map.of("summary", resp.getResult().getOutput().getContent()),
null
);
}
}
The ack.acknowledge() placement is load-bearing. We only call it after the result is successfully published to the results topic. If the result publish fails — network blip, Kafka broker restart — Kafka will redeliver the task to another consumer thread. Combined with the idempotency check at the top, the task will be processed exactly once even across failures.
Rate limiting happens inside the consumer, not at the API gateway. Your LLM provider doesn't know about your queue depth — it just sees requests arriving. Rate limiting at the consumer level means you never exceed quota regardless of how deep the queue gets.
Handling Backpressure: Adaptive Rate Limiting
The most common failure mode isn't Kafka — it's the LLM provider. When you hit rate limits, the naive response (throw exception, retry immediately) makes things significantly worse. Here's what we do instead:
@Component
public class AdaptiveRateLimiter {
private final double maxRate = 0.45;
private final double minRate = 0.05;
private final AtomicDouble currentRate = new AtomicDouble(0.3);
private volatile RateLimiter limiter = RateLimiter.create(0.3);
public void acquire() { limiter.acquire(); }
public void onSuccess() {
double next = Math.min(currentRate.get() * 1.05, maxRate);
updateLimiter(next);
}
public void onRateLimit() {
double next = Math.max(currentRate.get() * 0.5, minRate);
updateLimiter(next);
log.warn("Rate-limited — throttling to {:.2f} req/sec", next);
}
private void updateLimiter(double newRate) {
if (Math.abs(newRate - currentRate.get()) > 0.01) {
currentRate.set(newRate);
limiter = RateLimiter.create(newRate);
}
}
}
This is AIMD — Additive Increase, Multiplicative Decrease — the same algorithm TCP congestion control uses. On success, increase by 5%. On rate limit, halve. Over time it converges on the sustainable throughput for your provider tier without manual tuning.
flowchart TD
A[Message dequeued] --> B{Rate limiter\nacquire}
B -->|Permit granted| C[Call LLM]
C -->|200 OK| D[Publish result to Kafka]
D --> E[ack.acknowledge]
E -->|onSuccess: rate × 1.05| B
C -->|429 Rate Limited| F[onRateLimit: rate ÷ 2]
F --> G[No ack — Kafka redelivers]
G --> A
C -->|500 or timeout| H{attemptNumber}
H -->|< 3| G
H -->|≥ 3| I[sendToDLQ]
I --> J[ack.acknowledge\nfrom main topic]
style E fill:#065f46,color:#6ee7b7
style I fill:#7f1d1d,color:#fca5a5
style G fill:#1e3a5f,color:#7dd3fc
Dead Letter Queues: Don't Silently Drop AI Work
A DLQ is your safety net for permanent failures. Every task that fails three times ends up there — unprocessed, with the error reason attached as metadata.
private void sendToDLQ(AITask task, String errorMessage) {
Map<String, String> dlqMeta = new HashMap<>(task.metadata());
dlqMeta.put("dlq.reason", errorMessage);
dlqMeta.put("dlq.sentAt", Instant.now().toString());
dlqMeta.put("dlq.finalAttempt", String.valueOf(task.attemptNumber()));
AITask dlqTask = new AITask(
task.taskId(), task.taskType(), task.requesterId(), task.priority(),
task.submittedAt(), task.attemptNumber(), task.payload(), dlqMeta
);
String dlqTopic = "ai.tasks." + task.taskType() + ".dlq";
dlqTemplate.send(dlqTopic, task.taskId(), dlqTask);
log.error("task={} → DLQ after {} attempts: {}", task.taskId(), task.attemptNumber(), errorMessage);
meterRegistry.counter("ai.task.dlq", "type", task.taskType()).increment();
}
DLQ messages are retained for 30 days. Twice a week we run a DLQ reprocessing job: identify the root cause, fix the consumer, then replay DLQ messages back into the main topic. Nothing is permanently lost.
[!WARNING]
Do not set DLQ retention too short. A production incident that takes 3 days to diagnose needs the evidence to still be in the DLQ when you're ready to replay. 30 days is a safe default for AI task DLQs.
Exactly-Once Semantics: When You Actually Need Them
Kafka supports exactly-once semantics (EOS) via transactions. For most AI tasks, you don't need them — idempotent consumers plus at-least-once delivery is simpler and sufficient. But for billing events, audit logs, and compliance-critical document processing, true exactly-once matters.
@Bean
public KafkaTransactionManager<String, AIResult> kafkaTxManager(
ProducerFactory<String, AIResult> pf) {
return new KafkaTransactionManager<>(pf);
}
@Transactional("kafkaTxManager")
@KafkaListener(topics = "ai.tasks.billing-critical", groupId = "ai-billing")
public void consumeExactlyOnce(@Payload AITask task, Acknowledgment ack) {
AIResult result = processTask(task);
resultTemplate.send(RESULTS_COMPLETED, task.taskId(), result);
}
The tradeoff: EOS adds roughly 15–20% latency overhead due to transaction coordination. For billing tasks, worth it. For bulk document summarization, not worth it — idempotent at-least-once is the right choice there.
Observability: Tracing AI Events Across Services
An AI task might be submitted by Service A, consumed by AI Worker B, and the result consumed by Service C. When something goes wrong, you need to trace the full journey without manual correlation.
We propagate OpenTelemetry trace context through Kafka headers:
public void submitTask(AITask task) {
ProducerRecord<String, AITask> record = new ProducerRecord<>(
TASKS_SUMMARIZE, task.taskId(), task);
GlobalOpenTelemetry.getPropagators()
.getTextMapPropagator()
.inject(Context.current(), record.headers(),
(headers, key, val) -> headers.add(key, val.getBytes(UTF_8)));
kafkaTemplate.send(record);
}
@KafkaListener(...)
public void consume(@Payload AITask task, @Headers MessageHeaders headers) {
Context parentCtx = GlobalOpenTelemetry.getPropagators()
.getTextMapPropagator()
.extract(Context.current(), headers, new MessageHeadersGetter());
Span span = tracer.spanBuilder("ai.task.process")
.setParent(parentCtx)
.setAttribute("task.id", task.taskId())
.setAttribute("task.type", task.taskType())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
AIResult result = processTask(task);
span.setAttribute("tokens.input", result.inputTokens());
span.setAttribute("tokens.output", result.outputTokens());
span.setAttribute("latency.ms", result.processingMs());
} finally {
span.end();
}
}
In Grafana Tempo, the complete trace now shows: HTTP POST /enrich → Kafka publish → ai.task.process (the LLM call spans inside) → Kafka produce result → result consumer → database write. The entire 8-second LLM call is one contiguous trace chain.
Key metrics per consumer group:
Configuration That Actually Matters
spring:
kafka:
bootstrap-servers: ${KAFKA_BROKERS}
consumer:
group-id: ai-worker-high-priority
auto-offset-reset: earliest
enable-auto-commit: false
max-poll-records: 5
max-poll-interval-ms: 120000
fetch-min-bytes: 1
producer:
acks: all
retries: 3
enable-idempotence: true
listener:
ack-mode: MANUAL_IMMEDIATE
concurrency: 8
ai:
rate-limiter:
max-rate-per-second: 0.45
min-rate-per-second: 0.05
retry:
max-attempts: 3
dlq-retention-days: 30
max-poll-interval-ms is the one that bites everyone. If a consumer takes longer than this value between poll() calls, Kafka considers it dead and triggers a rebalance — even if the consumer is actively processing a long LLM call. We set it to 120 seconds (double our P99 LLM latency) to avoid unnecessary rebalancing during legitimate slow calls.
max-poll-records: 5 limits how many messages a single consumer thread fetches in one batch. With 8 threads each holding 5 messages, you have 40 LLM calls potentially in-flight per pod. Size this to your rate limit headroom.
The Cost Breakdown
After 90 days running this architecture against ~2M requests/day:
The event-driven architecture contributed two cost wins that weren't possible with synchronous calls:
Intelligent model routing. The consumer reads task.payload().get("complexity") — a simple score computed at task submission — and routes simple classifications to llama-3.1-8b-instant instead of a 70B model. This cut our average cost-per-task by 31%.
Micro-batching. For task types where the model supports batch input, we accumulate messages in 100ms windows and send them as a single request. 40% reduction in per-call API overhead (connection setup, auth handshake, response parsing). Impossible to do cleanly in a synchronous request handler without blocking the caller.
[!NOTE]
$687/day sounds large. At 2M requests that's $0.00034 per request — roughly $20K/month. Running equivalent workloads on self-hosted GPU infrastructure (A100s, realistic utilization rates, ops overhead) would cost us approximately $2,100/day. The managed API is cheaper at our scale.
Before and After: Production Numbers
We ran both architectures in parallel for 30 days — old sync on 20% of traffic, new event-driven on 80% — before cutting over completely.
The P99 number matters most. Users hitting a 28-second timeout perceived the feature as broken, even if only 3% of requests reached that threshold. With async dispatch and WebSocket delivery, P50 user-visible latency is under 1 second (the 202 response arrives instantly) and the result appears as a push notification when the LLM finishes.
The 93% reduction in on-call pages was unexpected. Most of the 14 weekly pages were timeout-related cascades. When you remove synchronous coupling between the API layer and the LLM layer, most cascade failure modes disappear entirely.
When NOT to Go Event-Driven
This architecture adds real operational complexity. It's not always the right answer.
Stay synchronous when:
The result must block the response. Real-time payment validation, auth decisions, interactive AI chat where the user is watching a streaming response. If you can't send a 202, you need sync.
Volume is under ~10K requests/day. The operational overhead of Kafka, consumer groups, DLQ management, and result delivery infrastructure doesn't pay off at low volume. A simple async HTTP call with a 30-second timeout and a Postgres-backed retry queue is the right choice.
You don't have result delivery infrastructure. WebSockets, SSE, or polling endpoints are meaningful engineering work. If you're not willing to build that, your users will stare at a spinner indefinitely. "We'll add the result delivery later" becomes "we added Kafka but the feature still feels broken."
Your team is new to Kafka. Consumer group rebalancing, partition assignment, exactly-once transactions, DLQ management — these are non-trivial operational concerns. Kafka is operationally complex when something goes wrong. Make sure your team has the skills before you take the dependency.
The decision framework:
flowchart TD
A{Is LLM call in the\ncritical response path?} -->|Yes| B{Can user wait\n>2 seconds?}
A -->|No, background work| E[Go event-driven]
B -->|No| C[Stay synchronous\nuse streaming SSE]
B -->|Yes| D{Volume > 50K/day?}
D -->|No| F[Synchronous with\nretry queue in Postgres]
D -->|Yes| G{Team has\nKafka experience?}
G -->|No| H[Start sync, plan\nKafka migration]
G -->|Yes| E
style E fill:#065f46,color:#6ee7b7
style C fill:#1e3a5f,color:#7dd3fc
style F fill:#374151,color:#d1d5db
The Minimum Viable Starting Point
If you're starting fresh and want to validate the pattern before building the full architecture, here's the minimum working setup:
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: ai-worker
auto-offset-reset: earliest
enable-auto-commit: false
max-poll-interval-ms: 90000
listener:
ack-mode: MANUAL_IMMEDIATE
@KafkaListener(topics = "ai.tasks", groupId = "ai-worker")
public void consume(@Payload AITask task, Acknowledgment ack) {
try {
AIResult result = callLLM(task);
resultTemplate.send("ai.results", task.taskId(), result);
ack.acknowledge();
} catch (Exception e) {
log.error("Task {} failed: {}", task.taskId(), e.getMessage());
}
}
Start here. Add the adaptive rate limiter when you first see 429s in production. Add DLQs when you see messages redelivered indefinitely. Add idempotency when you see duplicate results. Add distributed tracing when debugging becomes painful. The architecture I described above is 18 months of production failures distilled — you don't need all of it on day one.
Key Takeaways
Synchronous LLM calls are fine until they're not. The failure mode is sudden and cascading — build the escape hatch before you need it. The warning signs are rising P99 latency and rate limit errors.
Kafka absorbs traffic spikes that would kill a synchronous system. The queue is the buffer between your traffic patterns and your provider's rate limits. You can't add that buffer to a sync architecture without fundamentally restructuring it.
Idempotency is non-negotiable. At-least-once delivery is guaranteed by Kafka; idempotent consumers are your responsibility. An idempotency check at the top of every consumer is not optional overhead — it's correctness.
Rate limiting belongs in the consumer, not the gateway. Only the consumer knows what's actually in flight against the LLM provider. Gateway-level rate limiting gives you no visibility into queue depth.
Token counts in every result event. You cannot optimize what you cannot see. Per-model, per-task-type token counts are the only way to know which workloads are driving your LLM bill.
DLQs are not optional. Every permanently failed task needs a destination. "Log it and move on" means you've silently dropped user work. DLQ + replay gives you a recovery path that sync architectures simply don't have.
The biggest win isn't latency — it's reliability. P99 improvements are impressive. Getting paged 93% less often at 3AM is what actually changes how your team feels about owning the service.
The investment is real — probably 6–8 weeks of solid engineering to get this fully operational. In our case, it was worth it the first Saturday night we didn't get paged.
The next post in this series covers stateful aggregation with Kafka Streams — specifically windowed token-count aggregations for real-time cost alerts and per-tenant quota enforcement. If you're hitting a different failure mode than I described here, or building something similar, I'd love to hear about it.