Kafka for evented AI
Async ingestion, embedding pipelines, and agent side-effects — using Kafka (or similar logs) so AI work is durable and replayable.
What Kafka is (in an LLM product)
Apache Kafka is a distributed commit log: producers append events to topics; consumer groups read and process them independently. For AI systems it is the backbone for async RAG ingest, embedding backfills, eval sampling, and agent side-effects that must not die with a request timeout.
Cousins (Pulsar, Kinesis, Redpanda, NATS JetStream) share the mental model: durable ordered streams + fan-out. Learn Kafka once; the product patterns transfer.
flowchart LR
Upload[Doc uploaded] --> T1[topic: docs.raw]
T1 --> Parse[Parse workers]
Parse --> T2[topic: docs.chunked]
T2 --> Emb[Embed workers]
Emb --> T3[topic: docs.embedded]
T3 --> Idx[Indexer → vector DB]
T3 --> Eval[Eval / sampling sink]
Interview cue: Draw ingest as a pipeline of topics with idempotent upserts. “Kafka” is the bus; the hard part is keys, versions, and replay.
The engineering problem
Synchronous “upload PDF → embed → answer” couples UX to GPU/CPU work and fails half-way with no replay. Event logs let you:
| Need | Stream-shaped fix |
|---|---|
| Decouple ingest from indexing | Topics + consumer groups |
| Replay failed embedding batches | Retain + reset offsets |
| Fan out to analytics / evals / indexes | Multiple consumers on one topic |
| Durable agent side-effects | Outbox → topic → worker |
| Absorb embed spikes | Lag + backpressure instead of 504s |
Kafka is ops weight. Earn it with volume, multi-consumer fan-out, or replay requirements — not day-one prototypes.
Architecture mental model
| Concept | Meaning for AI pipelines |
|---|---|
| Topic | Named stream (docs.raw, agent.side_effects) |
| Partition | Parallelism + ordering key (e.g. doc_id) |
| Consumer group | Competing workers sharing the load |
| Offset | Per-partition progress cursor |
| Retention | How long you can replay |
| DLQ / dead topic | Poison messages for humans |
| Schema / version | Payload evolution without silent breaks |
flowchart TB
Prod[API / uploader] --> Broker[Kafka brokers]
Broker --> G1[group: parsers]
Broker --> G2[group: embedders]
Broker --> G3[group: indexers]
G2 --> GPU[Embed GPUs]
G3 --> VDB[(Vector DB)]
Ship rule: partition by the entity you need ordered (tenant_id or doc_id). Random keys destroy per-doc ordering and make upsert races painful.
Ordering and hot partitions
| Key choice | Good | Bad |
|---|---|---|
doc_id |
Per-doc order for chunk upserts | Huge docs can skew if one key dominates |
tenant_id |
Tenant isolation | Hot tenant starves others |
| Round-robin / null | Max parallelism | No per-entity order |
Often: partition by tenant_id, include doc_id in the payload, and make indexer upserts idempotent so rare reorders are safe.
Topic design for RAG ingest
| Topic | Payload | Consumers |
|---|---|---|
docs.raw |
blob ref + ACL + checksum | parsers |
docs.chunked |
chunks + metadata | embedders |
docs.embedded |
vectors + ids | indexers |
docs.dead |
poison + error reason | humans / repair jobs |
Keep payloads small: store blobs in S3/GCS; put refs + hashes on the wire. Large message bodies blow heap and kill rebalance latency. Pair with chunking and metadata and vector databases.
sequenceDiagram
participant API as Upload API
participant S3 as Object store
participant K as Kafka
participant P as Parser
participant E as Embedder
participant V as Vector DB
API->>S3: Put blob
API->>K: docs.raw ref
K->>P: consume
P->>K: docs.chunked
K->>E: consume
E->>K: docs.embedded
K->>V: upsert vectors
Exactly-once is a lie — design for idempotency
Workers will reprocess (rebalance, crash, at-least-once delivery). Production patterns:
- Upsert by stable chunk id into the vector store
- Store processing version (embed model + chunker hash) with each vector
- Commit offsets after successful side effect (or use transactional outbox)
- Route poison messages to
docs.dead— never infinite retry on bad PDFs
# Shape — idempotent index write
def handle_embedded(event: dict) -> None:
chunk_id = event["chunk_id"]
version = event["pipeline_version"] # embedder@v3+chunker@v2
upsert_vector(
id=f"{chunk_id}:{version}",
vector=event["vector"],
metadata={**event["meta"], "pipeline_version": version},
)
# commit offset only after upsert succeedsOptional: tombstone or delete old chunk_id:* versions after a successful migrate so the index does not grow forever.
How it fits agent products
sequenceDiagram
participant U as User
participant A as Agent API
participant DB as App DB
participant K as Kafka
participant W as Side-effect worker
U->>A: "Create Zendesk ticket"
A->>DB: Write agent state + outbox row (same txn)
A-->>U: Accepted
A->>K: Publish outbox event
K->>W: ticket.create
W->>W: Call Zendesk (idempotent key)
When an agent “creates a ticket,” write an outbox event in the same DB transaction as agent state, then publish. Consumers perform the side effect. This avoids “LLM said success but API failed” half-states — pair with agents and ReAct and multi-agent orchestration.
Long-running multi-step jobs also fit: emit agent.task.requested → workers → agent.task.completed with budgets and OpenTelemetry trace_id on every message.
Backpressure and SLOs
Embedding spikes overwhelm GPUs and vector DBs. Operate on:
| Signal | Action |
|---|---|
| Consumer lag | Scale embed workers / pause partitions |
| Vector DB write p95 | Slow consumers; shed batch traffic first |
| DLQ growth | Stop the pipeline; fix parser/schema |
| Interactive vs batch | Separate consumer groups + quotas |
| Rebalance rate | Fix session timeouts / processing time |
flowchart LR
Lag[Consumer lag high] --> Scale[Scale embedders]
Lag --> Shed[Pause batch topics]
DLQ[DLQ growth] --> Halt[Halt + page humans]
VDB[VDB write p95] --> Slow[Reduce batch size]
Interactive chat should not share the same GPU pool as nightly backfills without isolation (Ray or separate queues).
Schema evolution
| Practice | Why |
|---|---|
schema_version field |
Reject unknown versions loudly |
| Additive fields only in minor bumps | Old consumers keep working |
| Contract tests in CI | Producer/consumer agree |
| Never rename silently | Breaks replay of retained history |
Prefer JSON + explicit version for early products; Avro/Protobuf + registry when many teams share topics.
Failure modes
- Schema drift — version payloads; reject unknown fields loudly
- Keying mistakes — hot partitions on one tenant starve others
- Offset commit too early — lost embeds after crash
- Giant messages — blob-in-topic anti-pattern
- No DLQ — poison PDF loops forever
- ACL / PII on the wire — encrypt refs; never put raw regulated text in clear topics without policy
- Lag blindness — alert on lag and DLQ, not only CPU
- Missing trace propagation — cannot debug a doc across stages
Production checklist
- Topic map + partition key + retention documented.
- Idempotent upserts with
pipeline_version. - DLQ + runbook for poison documents.
- Separate consumer groups for interactive vs batch.
- Lag + DLQ alerts before GPU util alerts.
- Propagate
trace_id/tenant_idon every event. - Replay drill: reset offsets for one partition in staging quarterly.
Alternatives
| Need | Prefer |
|---|---|
| Solo prototype / low volume | Postgres SKIP LOCKED / Redis streams |
| Cloud-managed fan-out | SQS + SNS, Pub/Sub, Kinesis |
| Complex durable workflows + HITL | Temporal / Inngest (+ Kafka optional) |
| Pure batch on a cluster | Ray Data / Spark — no Kafka required |
| Only “fire and forget” emails | Simple queue; skip Kafka ops |
Micro-project
- Design topics for doc-ingest → embed → index (table above).
- Specify partition key + idempotency key for the indexer.
- Add an outbox sketch for one irreversible agent tool.
- Compare to a Ray Data backfill for the same corpus — three bullets each.
Related
Guided Agentic workflows & multi-agent and RAG ingest. Data: vector databases, chunking and metadata. Advanced Key Tech: Ray, OpenTelemetry for LLMs.