EU AI Act reference architecture for enterprise LLM deployment at scale — Reference Architecture

Reference architecture · published 2026-10-16 · regulated enterprise · EU AI Act · layout spec_first · implementation executed in CI before publish

Technical parameters

Every value below is a parameter you can hold a vendor or an internal team to. Adjectives are not acceptance criteria.

Implementation parameters for this deployment shape
Deployment vectorDedicated inference tier with per-tenant admission control
Security standardEU AI Act
Applicable regimesSOC 2 Type II, ISO/IEC 27001:2022, GDPR
Latency SLAp95 ≤ 450ms — interactive surfaces need sub-second; batch jobs do not
Integration protocolREST over HTTPS — the widest compatibility and the easiest audit story
Idempotency controlan Idempotency-Key header, honoured for 24h
Throughput envelope~2k rps per gateway node before connection churn dominates
Middleware requirementpolicy decision point in front of the model gateway
Data classes in scopePII, commercial-in-confidence data
Retention obligationper the data class retention schedule, enforced automatically
Data residencycontracted processing region, asserted on every response
Contractual instrumentDPA plus a disclosed subprocessor list
Systems of recordSAP, Salesforce, Workday
Primary failure modea retried POST creating a duplicate record

The control that resolves this is a Tenant-Fair Inference Tier, evaluated before the request reaches a model provider rather than audited afterwards. In regulated enterprise the binding constraint is EU AI Act, and the question it actually asks is what risk class is this system, and where is the technical documentation. The shape below holds p95 ≤ 450ms over REST over HTTPS, and ships with a standard-library implementation that runs as written.

Where the rollout actually stalls

A shared inference tier holds under average load and breaches its p95 under a neighbour's batch job. The SLA is written on a percentile, the dashboards show a mean, and admission control is a single global rate limit that protects the provider rather than any individual tenant.

What EU AI Act specifically requires

The EU AI Act regulates the SYSTEM, not the data. The first question is its risk classification, and a high-risk classification pulls in Article 11 technical documentation, Article 12 record-keeping and Article 14 human oversight — obligations that have to be designed in, not attached later.

The artifact that satisfies it is a model card plus Article 12 automatic logs. The way teams most often fail it: a system classified as limited-risk that is actually high-risk because it informs a decision about a person.

Why this blocks a rollout rather than slowing it: Noisy-neighbour latency cannot be fixed downstream of admission. By the time a request is queued, the only remaining options are to shed it or to breach someone's contract. In regulated enterprise the cost of getting it wrong is concrete — a failed Type II observation window restarts the clock.

Working implementation — Per-tenant admission control and percentile tracking

Standard-library Python, no external dependencies, runs as written. Executed by the publish gate: if it raises, this page does not ship. Wire it behind REST over HTTPS; the failure mode it guards against is a retried POST creating a duplicate record. The output you hand the assessor is the Annex III classification record and model card.

"""Per-tenant admission control for a shared inference tier.

Why this shape: a global rate limit protects the provider and nobody else. The
failure enterprises actually hit is the noisy-neighbour one — tenant A's batch
job saturates the queue and tenant B breaches the p95 latency SLA that is
written into their contract. You cannot fix that downstream of admission.

Two controls, both required:
  * a per-tenant token bucket, so one tenant cannot consume another's budget
  * a global concurrency ceiling with per-tenant reservations, so the shared
    tier degrades predictably instead of collapsing
"""
import heapq
import time
from dataclasses import dataclass, field


@dataclass
class TokenBucket:
    rate: float            # sustained requests per second
    burst: float           # instantaneous allowance
    tokens: float = None
    updated: float = field(default_factory=time.monotonic)

    def __post_init__(self):
        if self.rate <= 0 or self.burst <= 0:
            raise ValueError("rate and burst must be positive")
        if self.tokens is None:
            self.tokens = self.burst

    def take(self, n: float = 1.0, now: float = None) -> bool:
        now = time.monotonic() if now is None else now
        elapsed = max(0.0, now - self.updated)
        self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
        self.updated = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

    def retry_after(self, n: float = 1.0) -> float:
        """Seconds until n tokens exist. Return this in a 429 Retry-After so
        clients back off correctly instead of hot-looping."""
        deficit = max(0.0, n - self.tokens)
        return deficit / self.rate


class Admission:
    """Per-tenant buckets plus a global concurrency ceiling with reservations.

    reserved: tenant -> guaranteed in-flight slots. The sum of reservations
    must stay below `ceiling`, or the guarantee is fiction.
    """

    def __init__(self, ceiling: int, reserved: dict = None):
        self.ceiling = ceiling
        self.reserved = dict(reserved or {})
        if sum(self.reserved.values()) > ceiling:
            raise ValueError(
                f"reservations {sum(self.reserved.values())} exceed ceiling "
                f"{ceiling}: at least one tenant's SLA cannot be honoured")
        self.buckets: dict = {}
        self.inflight: dict = {}

    def configure(self, tenant: str, rate: float, burst: float):
        self.buckets[tenant] = TokenBucket(rate=rate, burst=burst)

    def admit(self, tenant: str, cost: float = 1.0) -> tuple:
        """-> (admitted, reason, retry_after_seconds)"""
        bucket = self.buckets.get(tenant)
        if bucket is None:
            return False, "tenant_not_provisioned", None

        used = sum(self.inflight.values())
        mine = self.inflight.get(tenant, 0)
        my_reservation = self.reserved.get(tenant, 0)

        # A tenant inside its reservation is admitted even when the shared pool
        # is full — that is what makes the reservation worth signing.
        if used >= self.ceiling and mine >= my_reservation:
            return False, "global_ceiling", 0.05

        if not bucket.take(cost):
            return False, "tenant_rate_limit", round(bucket.retry_after(cost), 3)

        self.inflight[tenant] = mine + 1
        return True, "ok", None

    def release(self, tenant: str):
        self.inflight[tenant] = max(0, self.inflight.get(tenant, 0) - 1)


class LatencyWindow:
    """Streaming p95/p99 over a bounded window.

    An average hides exactly the breach you are contractually exposed to: an
    SLA is written on a percentile, so the number you alert on must be one.
    """

    def __init__(self, size: int = 2048):
        self.size = size
        self.samples: list = []

    def observe(self, ms: float):
        self.samples.append(ms)
        if len(self.samples) > self.size:
            self.samples = self.samples[-self.size:]

    def quantile(self, q: float) -> float:
        if not self.samples:
            return 0.0
        ordered = sorted(self.samples)
        # Nearest-rank: the smallest sample at or above the q-th position.
        idx = min(len(ordered) - 1, max(0, int(round(q * len(ordered) + 0.5)) - 1))
        return ordered[idx]

    def breach(self, slo_ms: float, q: float = 0.95) -> bool:
        return self.quantile(q) > slo_ms


if __name__ == "__main__":
    adm = Admission(ceiling=10, reserved={"bank-eu": 4})
    adm.configure("bank-eu", rate=5, burst=5)
    adm.configure("retail-us", rate=50, burst=50)
    for _ in range(9):
        adm.admit("retail-us")
    print("noisy neighbour holds", adm.inflight)
    print("reserved tenant still admitted:", adm.admit("bank-eu"))
    print("burst exhausted ->", [adm.admit("bank-eu") for _ in range(5)][-1])

    win = LatencyWindow()
    for ms in [120, 140, 135, 128, 900, 131, 139, 142, 150, 2200]:
        win.observe(ms)
    print(f"p95={win.quantile(0.95):.0f}ms breach(400ms)={win.breach(400)}")

The EU AI Act control, as code

Annex IV documentation completeness and Article 12 logging. This is the artifact an assessor asks for under EU AI Act specifically — it is not the same control as the one above, and a system that implements one does not thereby implement the other. It executes under the same publish gate.

"""Annex IV technical documentation completeness + Article 12 logging.

A high-risk system under the EU AI Act needs technical documentation covering
specified elements, and automatic recording of events over the system lifetime.
The failure is rarely refusal to document — it is documentation that is stale,
partial, or cannot be tied to the exact model version that produced an output.

This checks the documentation set against the Annex IV elements and refuses to
mark a release conformant while any element is missing or stale.
"""
from datetime import date, timedelta

ANNEX_IV_ELEMENTS = {
    "general_description": "intended purpose, provider, version",
    "design_specification": "architecture, algorithmic logic, design choices",
    "training_data": "provenance, scope, characteristics, labelling",
    "human_oversight": "measures enabling natural-person oversight",
    "accuracy_metrics": "accuracy, robustness and cybersecurity metrics",
    "risk_management": "risk management system per Article 9",
    "change_log": "changes made through the lifecycle",
    "post_market_plan": "post-market monitoring plan",
}

STALENESS_DAYS = 180


class TechnicalFile:
    def __init__(self, system: str, model_version: str, today: date):
        self.system, self.model_version, self.today = system, model_version, today
        self.elements: dict[str, date] = {}

    def attach(self, element: str, updated: date) -> None:
        if element not in ANNEX_IV_ELEMENTS:
            raise KeyError(f"{element} is not an Annex IV element")
        self.elements[element] = updated

    def missing(self) -> list[str]:
        return sorted(set(ANNEX_IV_ELEMENTS) - set(self.elements))

    def stale(self) -> list[str]:
        cutoff = self.today - timedelta(days=STALENESS_DAYS)
        return sorted(k for k, v in self.elements.items() if v < cutoff)

    def conformant(self) -> dict:
        missing, stale = self.missing(), self.stale()
        return {"system": self.system, "model_version": self.model_version,
                "missing": missing, "stale": stale,
                "conformant": not missing and not stale}


class EventLog:
    """Article 12: records must tie an output to the version that produced it."""

    def __init__(self):
        self.events: list[dict] = []

    def record(self, model_version: str, event: str, ref: str) -> None:
        self.events.append({"model_version": model_version,
                            "event": event, "ref": ref})

    def trace(self, ref: str) -> dict | None:
        for e in self.events:
            if e["ref"] == ref:
                return e
        return None

    def untraceable(self, known_versions: set[str]) -> list[str]:
        return [e["ref"] for e in self.events
                if e["model_version"] not in known_versions]


if __name__ == "__main__":
    today = date(2026, 10, 16)
    tf = TechnicalFile("claims-triage", "v4.2.0", today)
    for element in ANNEX_IV_ELEMENTS:
        tf.attach(element, today - timedelta(days=30))
    assert tf.conformant()["conformant"], tf.conformant()

    partial = TechnicalFile("claims-triage", "v4.3.0", today)
    for element in list(ANNEX_IV_ELEMENTS)[:5]:
        partial.attach(element, today - timedelta(days=10))
    partial.attach("change_log", today - timedelta(days=400))
    report = partial.conformant()
    assert not report["conformant"], report
    assert "post_market_plan" in report["missing"], report
    assert "change_log" in report["stale"], report

    log = EventLog()
    log.record("v4.2.0", "inference", "out-9001")
    log.record("v4.1.7", "inference", "out-8800")
    assert log.trace("out-9001")["model_version"] == "v4.2.0"
    assert log.untraceable({"v4.2.0"}) == ["out-8800"]

    print("EU AI Act Annex IV + Article 12: OK")
    print(f"  complete file conformant={tf.conformant()['conformant']}, "
          f"partial missing {len(report['missing'])}, stale {len(report['stale'])}")

The blueprint

Tenant-Fair Inference Tier — the request path, with the decision points that must exist before the first model call:

flowchart TB
    subgraph Tenants
      TA[Tenant A - batch]
      TB[Tenant B - interactive p95 400ms]
    end
    TA --> AD{Admission}
    TB --> AD
    AD -->|bucket empty| S429[429 + Retry-After]
    AD -->|ceiling full,<br/>outside reservation| S429
    AD -->|admit| Q[Deadline queue]
    Q -->|deadline passed| SH[Shed - do not serve late]
    Q --> W[Worker pool]
    W --> P[Model / vLLM]
    P --> OBS[Per-tenant p95 / p99 window]
    OBS -->|breach| ALERT[Page + autoscale signal]
Request path. Rendered as a diagram where scripting is available; the Mermaid source above is the authoritative definition either way.

Implementation components

  1. Per-tenant token bucket — Sustained rate plus burst, so one tenant cannot spend another's budget.
  2. Reservation ledger — Guaranteed in-flight slots per tenant; reservations must sum below the ceiling.
  3. Global concurrency ceiling — Bounds the shared tier so it degrades predictably instead of collapsing.
  4. Percentile window — Streaming p95/p99 per tenant — the number the contract is written on.
  5. Queue with deadline — Requests carry a deadline and are shed rather than served late.
  6. Shed path — 429 with an accurate Retry-After so clients back off instead of hot-looping.

Risk register with acceptance criteria

Each row carries an acceptance parameter rather than an adjective, because "low latency" cannot be signed off. Retention here is bound by per the data class retention schedule, enforced automatically.

CategoryRiskControlAcceptance parameter
EU AI ActRisk classification is wrong, so the wrong obligations were builtClassify per Annex III against the actual decision the output informs; re-run the classification when the use case changesClassification recorded with its Annex III reference and reviewed on every material change of use
Latency SLAOne tenant's batch traffic breaches another's contractual p95Per-tenant token buckets plus reserved in-flight slots inside a global ceilingReserved tenants hold p95 under their SLO while a neighbour saturates the tier
Latency SLAMean latency hides the percentile the contract is written onTrack streaming p95/p99 per tenant; alert on the percentile, never the averageAlert fires on p95 breach within one window; mean is dashboard-only
Latency SLALate responses served instead of shed, so queues grow unboundedlyAttach a deadline to every request and shed on expiry0 responses served after deadline; shed rate published per tenant
Data privacyCross-tenant leakage through a shared cache or KV reuseNamespace every cache key by tenant id; never key on prompt text aloneCache key audit shows 100% tenant-namespaced; cross-tenant hit rate 0
Cost controlOver-provisioning GPU to absorb one tenant's burstsBound burst at the bucket, scale on admitted load rather than offered loadGPU utilisation 60-80%; scaling driven by admitted load
Cost controlRetries amplify a degradation into an outageAccurate Retry-After, client backoff, and a circuit breaker on the shed pathRetry amplification under 1.3x during an induced degradation
OperationalReservations sum above the ceiling, so guarantees are fictionValidate at configuration time; refuse to start with over-committed reservationsStartup check rejects over-commitment; covered by a unit test

Review questions and answers

Related architectures

Enterprise infrastructure review

If you are carrying this blocker, the useful next step is not a demo. It is a review of your actual request path against the control points above:

  • Which of the 6 components exist in your estate today, and which are assumed
  • Your current numbers against the 7 acceptance parameters, starting with p95 ≤ 450ms
  • Which EU AI Act control questions you cannot yet answer, and what closes each gap
Request an architecture review