Runtime
Enterprise Architecture for a Fully Custom C Customer Engineering LLM Runtime
Report summary
This report assumes .NET 7+ , Linux containers for production deployment, and a customer-engineering runtime that must serve multiple enterprise tenants, enforce policy, expose a stable internal API, and integrate directly with provider-hosted LLMs while leaving room for an optional local/native inf
Key topics
- Runtime
- AI
- .NET
- C#
- GGUF
- Semantic Systems
- Research Archive
- Strategy
Research provenance
For citation, use the report title and canonical URL. Archival presence does not establish authorship or promote report statements into portfolio evidence.
Source availability: 45 citation markers in the source export have no recoverable source links. Those markers are omitted from this reader; any supplied bibliography and ordinary links remain. Check the original sources before relying on the cited claims.
This page renders the archived Markdown as safe, formatted HTML. It is background research and does not become a portfolio claim without evidence review.
Full report
On this page
Executive summary
This report assumes .NET 7+, Linux containers for production deployment, and a customer-engineering runtime that must serve multiple enterprise tenants, enforce policy, expose a stable internal API, and integrate directly with provider-hosted LLMs while leaving room for an optional local/native inference path. That assumption is consistent with the attached references, which emphasize a direct-to-the-metal C# approach for local inference, and with the current provider and .NET documentation for direct HTTP, streaming, authentication, configuration, dependency injection, and observability.
The highest-confidence architecture is a Clean Architecture, SOLID, provider-neutral runtime core with a canonical inference contract at the center and pluggable model adapters around it. In practice, the runtime should treat provider-hosted models as the primary execution path for most enterprise workloads, because OpenAI, Azure OpenAI, Anthropic, and Gemini all expose materially different authentication methods, streaming semantics, rate-limit surfaces, and error behaviors; those differences are exactly what the adapter boundary should absorb. OpenAI supports Responses API creation, SSE streaming, structured outputs via JSON schema, and exposes error codes in the response object; Anthropic documents SSE streaming, retry-after, and rate-limit headers; Gemini documents REST generateContent, SSE streamGenerateContent, and project-level rate limits.
A secondary local-backend path is still strategically valuable for regulated, offline, cost-controlled, or ultra-low-latency workloads, but it should be isolated behind a separate backend boundary, and preferably even a separate worker process, until it is mature. The attached references consistently converge on the same local-inference fundamentals: GGUF ingestion, unmanaged/aligned memory, memory mapping, SIMD or hardware intrinsics, paged KV cache management, batching, and direct native interop. Those themes are consistent with official .NET guidance on NativeMemory, SIMD/intrinsics, P/Invoke/LibraryImport, Native AOT, and with primary sources around ONNX Runtime C#, ONNX opset/version semantics, and llama.cpp’s quantized inference model and OpenAI-compatible serving.
The enterprise recommendation is therefore:
- Build the runtime core around canonical domain interfaces, not around any provider SDK.
- Use built-in .NET DI, configuration, logging, and typed HTTP clients as foundational infrastructure.
- Standardize on async, bounded, back-pressured request processing using channels and cooperative cancellation.
- Implement policy, telemetry, rate limiting, retries, auth, and secrets as cross-cutting layers, not scattered in adapters.
- Support provider-native caching and streaming, but keep them behind platform-neutral abstractions.
- Treat local/native inference as a specialized backend, not as the default responsibility of the CE API tier.
- Enforce automated testing, contract validation, and progressive delivery before model or runtime upgrades are allowed into production.
Architectural blueprint
The right shape is a four-ring Clean Architecture: Presentation, Application, Domain, and Infrastructure. The Domain layer owns canonical request/response types, policies, invariants, and interfaces. The Application layer owns orchestration use cases such as prompt rendering, token budgeting, tool routing, streaming, and retry decisions. Infrastructure owns provider adapters, tokenizers, native/local backends, caches, credentials, telemetry exporters, and persistence. Presentation exposes REST, gRPC, streaming, and health endpoints. This structure aligns well with .NET’s built-in DI container, options pattern, configuration providers, and structured logging.
flowchart LR
subgraph ClientSurface[Client Surface]
Web[Web App]
CE[CE Tools]
S2S[Service to Service]
end
subgraph Presentation[Presentation Layer]
Api[REST API]
Grpc[gRPC API]
Stream[SSE/WebSocket Stream Endpoint]
Health[Health/Readiness Endpoints]
end
subgraph Application[Application Layer]
Orchestrator[Inference Orchestrator]
PromptManager[Prompt Manager]
Scheduler[Batching Scheduler]
Policy[Policy & Guardrails]
RateLimiter[Rate Limiter]
Retry[Retry/Backoff Policy]
end
subgraph Domain[Domain Layer]
Contracts[Canonical Contracts]
Interfaces[Interfaces]
Models[Model Manifest & Routing Policy]
end
subgraph Infrastructure[Infrastructure Layer]
OpenAI[OpenAI Adapter]
Azure[Azure OpenAI Adapter]
Anthropic[Anthropic Adapter]
Gemini[Gemini Adapter]
Local[Local Backend Adapter]
Tokenizer[Tokenizer Services]
Cache[Cache Layers]
Telemetry[Telemetry Pipeline]
Secrets[Secrets & Credentials]
Config[Typed Configuration]
end
subgraph External[External Systems]
OAI[OpenAI API]
AOAI[Azure OpenAI]
Claude[Anthropic Claude API]
GAI[Gemini API]
Worker[Local Inference Worker]
KV[Redis / Distributed Cache]
Vault[Key Vault / Secret Manager]
OTel[OTel Collector]
end
Web --> Api
CE --> Api
S2S --> Grpc
Api --> Orchestrator
Grpc --> Orchestrator
Stream --> Orchestrator
Health --> Scheduler
Orchestrator --> PromptManager
Orchestrator --> Scheduler
Orchestrator --> Policy
Orchestrator --> RateLimiter
Orchestrator --> Retry
Orchestrator --> Contracts
Orchestrator --> Interfaces
Interfaces --> OpenAI
Interfaces --> Azure
Interfaces --> Anthropic
Interfaces --> Gemini
Interfaces --> Local
PromptManager --> Tokenizer
Scheduler --> Cache
Retry --> OpenAI
Retry --> Azure
Retry --> Anthropic
Retry --> Gemini
Retry --> Local
OpenAI --> OAI
Azure --> AOAI
Anthropic --> Claude
Gemini --> GAI
Local --> Worker
Cache --> KV
Secrets --> Vault
Telemetry --> OTel
The most important design decision is the canonical contract boundary. Your application should speak in terms of InferenceRequest, InferenceResponse, StreamingDelta, ToolInvocation, ModelDescriptor, UsageRecord, and ProviderError, not OpenAI-specific or Anthropic-specific DTOs. That is the only way to survive provider drift, model replacement, and dual-backend operation. OpenAI’s Responses API, Anthropic’s Messages API, and Gemini’s generation APIs are similar enough to normalize, but different enough that normalization must be deliberate.
A practical component partition is shown below.
| Component | Owns | Must not own |
|---|---|---|
| Presentation | Transport, auth handoff, serialization, streaming framing | Provider-specific business logic |
| Inference orchestrator | Use-case flow, adapter selection, cancellation, streaming fan-out | HTTP transport details |
| Model adapter | Provider/local protocol translation, usage extraction, retry hints | Prompt templating policy |
| Tokenizer | Counting, chunking, token budgeting, truncation advice | Provider selection |
| Prompt manager | Templates, conversation compaction, system/developer/user layering | Network calls |
| Scheduler | Admission control, batching, prioritization, queueing | Auth and secrets |
| Cache subsystem | Response cache, prompt-prefix cache metadata, tokenization cache | Policy decisions |
| Security subsystem | AuthZ, tenant isolation, secrets retrieval, key usage, audit | Core inference logic |
| Telemetry subsystem | Logs, traces, metrics, event schema | Functional behavior |
The class structure below keeps dependencies flowing inward.
classDiagram
direction LR
class IInferenceOrchestrator {
+ExecuteAsync(request, cancellationToken) Task~InferenceResponseDto~
+StreamAsync(request, cancellationToken) IAsyncEnumerable~InferenceEventDto~
}
class IModelAdapter {
+ProviderName string
+SupportsStreaming bool
+GenerateAsync(request, cancellationToken) Task~AdapterResponse~
+StreamAsync(request, cancellationToken) IAsyncEnumerable~AdapterEvent~
}
class ITokenizer {
+CountTokensAsync(request, cancellationToken) ValueTask~TokenCountResult~
+TruncateAsync(request, budget, cancellationToken) ValueTask~InferenceRequestDto~
}
class IPromptManager {
+RenderAsync(request, cancellationToken) ValueTask~PromptEnvelope~
+CompactAsync(request, budget, cancellationToken) ValueTask~PromptEnvelope~
}
class IRequestScheduler {
+EnqueueAsync(request, cancellationToken) Task~ScheduledRequest~
}
class IResponseCache {
+TryGetAsync(key, cancellationToken) ValueTask~CacheHit~
+SetAsync(key, response, ttl, cancellationToken) Task
}
class IRateLimitCoordinator {
+AcquireAsync(key, weight, cancellationToken) ValueTask~Lease~
}
class InferenceOrchestrator
class PromptManager
class TokenizerFacade
class ProviderAdapterRegistry
class ResponseCache
class RateLimitCoordinator
IInferenceOrchestrator <|.. InferenceOrchestrator
IPromptManager <|.. PromptManager
ITokenizer <|.. TokenizerFacade
IResponseCache <|.. ResponseCache
IRateLimitCoordinator <|.. RateLimitCoordinator
InferenceOrchestrator --> IPromptManager
InferenceOrchestrator --> ITokenizer
InferenceOrchestrator --> IRequestScheduler
InferenceOrchestrator --> IResponseCache
InferenceOrchestrator --> IRateLimitCoordinator
InferenceOrchestrator --> IModelAdapter
ProviderAdapterRegistry --> IModelAdapter
For dependency injection, use the .NET Generic Host, strongly typed options, and named or typed HttpClient registrations. Microsoft explicitly documents DI as a built-in framework capability, the options pattern as the preferred way to bind related hierarchical configuration, and IHttpClientFactory as the right foundation for DI-ready clients with centralized configuration, outgoing middleware, and logging.
A production adapter strategy looks like this:
| Strategy | Where it fits best | Tradeoff |
|---|---|---|
Raw typed HttpClient adapters | Best default for a provider-neutral runtime | More code, highest control |
| Official SDK behind adapter | Good when provider has strong .NET support | SDK abstractions can leak into your domain |
| Generated REST clients | Good for internal consistency and contract regeneration | OpenAPI coverage is inconsistent across providers |
| Local backend adapter | Best for regulated, offline, or cost-sensitive workloads | Highest operational and performance complexity |
OpenAI has an official .NET library, Azure OpenAI documents .NET package usage with Azure Identity, and Gemini’s official docs clearly show a REST-first path with provider-specific headers and SSE streaming endpoints. That combination strongly favors a runtime that owns its own adapter abstractions instead of treating any one SDK as the architectural center.
Runtime modules and canonical API contracts
A CE runtime should expose a small, durable surface area and hide provider churn behind it. The canonical request pipeline is: authenticate → authorize → resolve tenant/model policy → render prompt → count/truncate → acquire capacity → execute or stream via adapter → normalize usage/errors → emit telemetry → cache where allowed. That flow is justified by the provider/auth/rate limit/streaming differences in the official docs and by the enterprise need for consistent behavior across adapters.
The module boundaries should be explicit.
| Module | Responsibilities | Key design notes |
|---|---|---|
| Model adapter | Translate canonical requests to provider/local protocol; normalize outputs, usage, finish reasons, errors | Must be stateless except for connection reuse |
| Tokenizer | Count tokens, chunk, truncate, estimate prompt size, normalize token budgets | Adapter-owned strategy avoids tokenizer drift |
| Prompt manager | Compose system/developer/user/tool context; compaction; template rendering; redaction hooks | Central place for prompt policy |
| Inference engine | Coordinate request execution and stream fan-out | Pure application-layer orchestrator |
| Batching scheduler | Queueing, coalescing, prioritization, backpressure, cancellation propagation | Internal batching should be opt-in and measurable |
| Cache subsystem | Provider prompt-cache hints, semantic cache, idempotent response cache, tokenization cache | Must be tenant- and policy-scoped |
| Streaming subsystem | SSE/WebSocket framing, delta normalization, terminal events, stream recovery | Treat streaming as first-class, not a different code path |
| Retry/backoff | Retry only safe failures; honor provider hints and headers; add jitter | Never retry non-idempotent tool side effects blindly |
| Rate limiting | Tenant quotas, provider quotas, concurrency budgets, queue budgets | Separate “admission” from “provider quota” |
| Telemetry | Logs, traces, metrics, audit events, cost/usage reporting | Mandatory cross-cutting concern |
| Auth and secrets | JWT/OIDC validation, service identity, key retrieval, key rotation | Must never live in adapters |
| Config | Strongly typed provider/model/tenant configuration | Enforce validation at startup |
OpenAI’s Responses API supports hierarchical message roles, tool definitions with JSON schema, and structured outputs using json_schema with optional strict adherence. That makes it an excellent reference point for your canonical DTOs, even if you remain provider-neutral.
The API surface below is intentionally small and durable.
| Endpoint | Purpose | Request | Response | Streaming | Notes |
|---|---|---|---|---|---|
GET /v1/models | List runtime-allowed models for caller/tenant | none | ModelListDto | No | Runtime policy filtered |
POST /v1/responses | General-purpose text/tool generation | InferenceRequestDto | InferenceResponseDto | Optional SSE | Canonical primary endpoint |
GET /v1/responses/{id} | Retrieve stored response metadata/output | none | InferenceResponseDto | No | Optional if persistence enabled |
DELETE /v1/responses/{id} | Delete retained response | none | DeleteResultDto | No | Policy-controlled |
POST /v1/embeddings | Generate embeddings via adapter | EmbeddingRequestDto | EmbeddingResponseDto | No | Optional separate workload |
GET /health/live | Liveness | none | HealthResultDto | No | Process alive only |
GET /health/ready | Readiness | none | HealthResultDto | No | Dependency-aware |
GET /metrics | Prometheus/OTel scrape or export gateway | none | metrics stream | No | Internal or protected |
The error contract should be canonical even when upstreams differ.
| HTTP | Internal code | Meaning | Retriable | Typical source |
|---|---|---|---|---|
| 400 | invalid_request | Schema, prompt, or parameter issue | No | Caller / adapter mapping |
| 401 | auth_failed | Caller identity invalid | No | Gateway |
| 403 | policy_denied | Tenant/model/tool/data policy denied request | No | Runtime policy |
| 404 | model_not_found | Unknown or disallowed model | No | Runtime routing |
| 409 | concurrency_exhausted | Queue or per-tenant budget exhausted | Maybe | Scheduler |
| 413 | context_limit_exceeded | Prompt too large after compaction | No | Tokenizer/prompt manager |
| 429 | rate_limited | Provider or tenant rate limit exceeded | Yes, with backoff | Provider / runtime |
| 500 | runtime_error | Internal unexpected failure | Maybe | Runtime |
| 502 | provider_error | Upstream provider returned invalid or failed response | Maybe | Adapter |
| 503 | provider_unavailable | Provider overloaded/unavailable | Yes | Adapter |
| 504 | upstream_timeout | Adapter timeout | Yes, cautiously | Adapter |
The sequence below shows the preferred streaming path.
sequenceDiagram
autonumber
participant C as Client
participant API as API Layer
participant AUTH as Auth/Policy
participant PM as Prompt Manager
participant TOK as Tokenizer
participant SCH as Scheduler
participant RL as Rate Limiter
participant ADP as Model Adapter
participant UP as Provider or Local Backend
participant TEL as Telemetry
C->>API: POST /v1/responses?stream=true
API->>AUTH: Validate identity and tenant policy
AUTH-->>API: Authorized context
API->>PM: Render prompt envelope
PM->>TOK: Count / truncate / compact
TOK-->>PM: Budgeted request
API->>SCH: Enqueue scheduled request
SCH->>RL: Acquire tenant and provider capacity
RL-->>SCH: Lease granted
SCH->>ADP: StreamAsync(canonical request)
ADP->>UP: Provider-native streaming request
UP-->>ADP: Delta / event stream
ADP-->>API: Canonical streaming events
API-->>C: SSE/WebSocket deltas
ADP->>TEL: Usage, latency, trace, outcome
API-->>C: Terminal event with usage and status
A representative C# contract set is below.
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace CeRuntime.Contracts;
/// <summary>
/// Represents a canonical inference request for any provider or local backend.
/// </summary>
public sealed class InferenceRequestDto
{
[Display(Name = "Tenant")]
public string Tenant { get; init; } = string.Empty;
[Display(Name = "Model")]
public string Model { get; init; } = string.Empty;
[Display(Name = "Conversation")]
public string? ConversationId { get; init; }
[Display(Name = "Stream")]
public bool Stream { get; init; }
[Display(Name = "Max Output Tokens")]
public int? MaxOutputTokens { get; init; }
[Display(Name = "Temperature")]
public double? Temperature { get; init; }
[Display(Name = "Top P")]
public double? TopP { get; init; }
[Display(Name = "Messages")]
public IReadOnlyList<MessageDto> Messages { get; init; } = [];
[Display(Name = "Tools")]
public IReadOnlyList<ToolDefinitionDto> Tools { get; init; } = [];
[Display(Name = "Metadata")]
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Represents a canonical message in an inference request.
/// </summary>
public sealed class MessageDto
{
[Display(Name = "Role")]
public string Role { get; init; } = string.Empty;
[Display(Name = "Content")]
public string Content { get; init; } = string.Empty;
}
/// <summary>
/// Represents a tool definition exposed to a model.
/// </summary>
public sealed class ToolDefinitionDto
{
[Display(Name = "Name")]
public string Name { get; init; } = string.Empty;
[Display(Name = "Description")]
public string Description { get; init; } = string.Empty;
[Display(Name = "Json Schema")]
public string JsonSchema { get; init; } = string.Empty;
}
/// <summary>
/// Represents a normalized inference response.
/// </summary>
public sealed class InferenceResponseDto
{
[Display(Name = "Id")]
public string Id { get; init; } = string.Empty;
[Display(Name = "Model")]
public string Model { get; init; } = string.Empty;
[Display(Name = "Output Text")]
public string OutputText { get; init; } = string.Empty;
[Display(Name = "Finish Reason")]
public string FinishReason { get; init; } = string.Empty;
[Display(Name = "Usage")]
public UsageDto Usage { get; init; } = new();
[Display(Name = "Provider")]
public string Provider { get; init; } = string.Empty;
}
/// <summary>
/// Represents normalized token and cost usage.
/// </summary>
public sealed class UsageDto
{
[Display(Name = "Input Tokens")]
public int InputTokens { get; init; }
[Display(Name = "Output Tokens")]
public int OutputTokens { get; init; }
[Display(Name = "Cached Input Tokens")]
public int CachedInputTokens { get; init; }
[Display(Name = "Estimated Cost Micros")]
public long EstimatedCostMicros { get; init; }
}
/// <summary>
/// Defines the adapter boundary for provider-hosted or local models.
/// </summary>
public interface IModelAdapter
{
/// <summary>
/// Gets the provider name exposed by the adapter.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Executes a single non-streaming inference request.
/// </summary>
/// <param name="request">The canonical inference request.</param>
/// <param name="cancellationToken">The cooperative cancellation token.</param>
/// <returns>The normalized inference response.</returns>
Task<InferenceResponseDto> GenerateAsync(
InferenceRequestDto request,
CancellationToken cancellationToken);
/// <summary>
/// Executes a streaming inference request.
/// </summary>
/// <param name="request">The canonical inference request.</param>
/// <param name="cancellationToken">The cooperative cancellation token.</param>
/// <returns>A stream of normalized inference events.</returns>
IAsyncEnumerable<InferenceEventDto> StreamAsync(
InferenceRequestDto request,
CancellationToken cancellationToken);
}
/// <summary>
/// Represents a normalized streaming event.
/// </summary>
public sealed class InferenceEventDto
{
[Display(Name = "Type")]
public string Type { get; init; } = string.Empty;
[Display(Name = "Delta")]
public string? Delta { get; init; }
[Display(Name = "Response Id")]
public string? ResponseId { get; init; }
[Display(Name = "Usage")]
public UsageDto? Usage { get; init; }
}
A clean registration pattern uses typed options plus named/typed clients.
using CeRuntime.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace CeRuntime.Bootstrap;
/// <summary>
/// Provides runtime service registration helpers.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers CE runtime core services and provider adapters.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddCeRuntime(this IServiceCollection services)
{
services.AddOptions<RuntimeOptions>()
.BindConfiguration("Runtime")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<OpenAiAdapterOptions>()
.BindConfiguration("Providers:OpenAI")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<AzureOpenAiAdapterOptions>()
.BindConfiguration("Providers:AzureOpenAI")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddHttpClient("openai");
services.AddHttpClient("azure-openai");
services.AddHttpClient("anthropic");
services.AddHttpClient("gemini");
services.TryAddSingleton<ITokenizer, CompositeTokenizer>();
services.TryAddSingleton<IPromptManager, PromptManager>();
services.TryAddSingleton<IInferenceOrchestrator, InferenceOrchestrator>();
services.AddKeyedSingleton<IModelAdapter, OpenAiModelAdapter>("openai");
services.AddKeyedSingleton<IModelAdapter, AzureOpenAiModelAdapter>("azure-openai");
services.AddKeyedSingleton<IModelAdapter, AnthropicModelAdapter>("anthropic");
services.AddKeyedSingleton<IModelAdapter, GeminiModelAdapter>("gemini");
return services;
}
}
/// <summary>
/// Represents runtime-wide configuration.
/// </summary>
public sealed class RuntimeOptions
{
[Display(Name = "Default Model")]
[Required]
public string DefaultModel { get; init; } = string.Empty;
[Display(Name = "Max Concurrent Requests")]
[Range(1, int.MaxValue)]
public int MaxConcurrentRequests { get; init; } = 128;
[Display(Name = "Enable Semantic Cache")]
public bool EnableSemanticCache { get; init; } = true;
}
Runtime engineering for concurrency, memory, performance, and scale
The runtime should be async all the way to the transport edge. Provider API calls are naturally I/O-bound and stream-oriented, and both OpenAI and Anthropic explicitly document SSE streaming, while Gemini documents streamGenerateContent over SSE as well. A sync-over-async API surface will artificially serialize network latency, consume thread-pool threads unnecessarily, and make cancellation far less reliable.
Inside the service, use bounded Channel<T> pipelines for admission control and batching. Microsoft’s channels library is explicitly designed for asynchronous producer/consumer pipelines, supports bounded and unbounded modes, exposes backpressure behavior, and uses ValueTask to reduce allocations. Bounded channels are the right primitive for request queues, streaming fan-out buffers, and microbatch schedulers.
For outbound provider calls, use IHttpClientFactory with named or typed clients or long-lived HttpClient instances with PooledConnectionLifetime, but do not create/dispose HttpClient per request. Microsoft documents that each HttpClient has its own connection pool, that repeated disposal causes connection recreation and can contribute to port exhaustion, and that recommended lifetime strategies are either factory-created clients or long-lived clients with connection lifetime management. IHttpClientFactory additionally centralizes logging and outgoing middleware.
The biggest concurrency decision is not “threaded or async”; it is which workloads stay on async network paths and which workloads move onto dedicated compute workers.
| Workload | Preferred execution model | Why |
|---|---|---|
| Provider-hosted inference | Async I/O + SSE streaming | Network-bound, cancellation sensitive |
| Tokenization and prompt compaction | CPU-bound but short-lived | Usually stay inline; parallelize only after profiling |
| Embeddings bulk ingestion | Queue + bounded parallelism | Predictable, throughput-oriented |
| Local ONNX or GGUF inference | Dedicated worker threads or isolated worker process | Compute-heavy, memory-sensitive, different failure profile |
| Telemetry export | Fire-and-forget buffered pipeline | Must not block inference path |
For batching, the tradeoff is not purely throughput versus latency. It is throughput versus fairness versus cancellation complexity.
| Strategy | Best for | Benefits | Costs |
|---|---|---|---|
| No batching | Premium interactive traffic | Lowest tail latency, simplest semantics | Lowest throughput |
| Fixed-window microbatching | Moderate traffic | Easy to reason about | Window adds latency |
| Continuous batching | High concurrency/local inference | Best device utilization | Hardest scheduler; hardest cancellation |
| Prefix-bucketed batching | Similar prompt prefixes | Better cache behavior | More queue complexity |
| Provider-native batch jobs | Offline or back-office tasks | Cost-efficient bulk execution | Poor fit for interactive UX |
The caching strategy should also be layered, because “cache” means different things in different parts of the runtime.
| Cache layer | Scope | Best use | Constraints |
|---|---|---|---|
| Provider prompt cache | Provider-side prefix reuse | Static system prompts, long repeated context | Provider-specific semantics and TTL |
| Tokenizer cache | Local | Repeated token counts for identical prefixes | Must be keyed by tokenizer version |
| Semantic response cache | Runtime-side | Idempotent tasks with narrow policy scope | Risk of stale or policy-inappropriate reuse |
| Tool result cache | Runtime-side | Expensive deterministic tool calls | Must be auth- and tenant-aware |
| Local KV/prefix cache | Local backend only | Repeated prompt prefixes and speculative decoding | Hardest invalidation model |
Provider-native caching is worth surfacing in the adapter. OpenAI documents request-level prompt caching with prompt_cache_retention, including 24h support and token thresholds; Anthropic documents prompt caching via cache_control, a default 5-minute cache lifetime, optional 1-hour TTL, cache usage details, and cache isolation properties. Those are not interchangeable features, which is exactly why they belong behind a provider-capability interface.
On the memory side, the primary hosted-runtime strategy is minimize transient allocations. Use ArrayPool<T> or ObjectPool<T> for serialization buffers, stream frame builders, and token arrays; keep request DTOs immutable; and avoid concatenating large prompt strings repeatedly. For local/native backends, move heavyweight tensor buffers off the managed heap entirely. Microsoft documents NativeMemory for native memory allocation and aligned allocation, and ONNX Runtime’s C# guidance explicitly recommends OrtValue reuse for fixed-size numeric tensors because it reuses underlying buffers, pins managed buffers, and reduces data transfer overhead.
For compute-heavy local inference, the attached references are directionally correct: use aligned unmanaged allocations, memory-mapped model weights, layout-aware prepacking, paged KV caching, and SIMD/hardware intrinsics for hot kernels. Microsoft’s SIMD guidance recommends System.Numerics.Vector<T> where possible and runtime checks such as Vector.IsHardwareAccelerated; the intrinsics API exposes Avx2.IsSupported-style capability checks; and llama.cpp’s official materials emphasize quantization across multiple bit widths, concurrency, and OpenAI-compatible serving for GGUF models.
If you support ONNX locally, treat opset/version compatibility as a first-class policy. ONNX is strongly typed, does not support implicit casts, and every graph carries opset versions that determine operator semantics. That means your local-backend compatibility gate should reject unsupported opsets or domains before execution begins.
For managed runtime tuning, prefer server GC in the API tier and use low-latency modes sparingly and only after measurement. Microsoft documents that server GC is intended for server applications that need throughput and scalability, uses dedicated threads per logical CPU, and is faster on the same heap size; SustainedLowLatency suppresses some generation 2 collections but can increase heap size and fragmentation, so it is appropriate only for measured, contained latency windows rather than as a blanket setting.
Native interop should be explicit and narrow. Source-generated P/Invoke via [LibraryImport] is the right interop baseline for CUDA, DirectML, vendor tokenizers, or specialized local kernels. Native AOT is attractive for isolated sidecars and constrained environments because it reduces startup time and memory and runs without JIT, but Microsoft also documents serious limitations, including no dynamic loading and no runtime code generation. That makes Native AOT a fit for stable worker binaries, not for reflection-heavy plugin hosts.
Security, governance, and compliance posture
The security design should assume a multi-tenant enterprise service exposed to untrusted prompts and trusted internal systems. That means you need separate controls for caller auth, service identity, provider credentials, data handling, tool invocation, and LLM-specific abuse patterns such as prompt injection and insecure output handling. OWASP’s LLM Top 10 remains a practical threat-modeling taxonomy for this layer, covering prompt injection, insecure output handling, model denial of service, sensitive information disclosure, excessive agency, and model theft.
Authentication should be layered. For external callers, use OIDC/JWT validation at the edge, with tenant and model authorization policies in the runtime. For outbound provider access, use the strongest identity offered by the provider. OpenAI uses bearer authorization headers; Azure OpenAI explicitly recommends token-based authentication over API keys and documents Microsoft Entra ID and managed identities; Gemini’s public API examples use the x-goog-api-key header. Where provider workload identity or managed identity is available, prefer it over long-lived static secrets.
Secrets should never live in application code, container images, or long-lived config maps. Microsoft documents Azure Key Vault as a centralized service for secrets, keys, and certificates, with authentication and authorization gates; OWASP’s secrets management guidance recommends centralization, standardization, automated rotation, dynamic secrets where possible, and reducing the time secrets remain in memory.
The threat model below is the minimum viable enterprise security map.
| Threat | Primary control | Secondary control |
|---|---|---|
| Prompt injection | Separate system/developer/user channels; tool allowlists; output validation | Retrieval and tool-scoping policy |
| Insecure output handling | Strict parsers, JSON schema validation, escaping, sandboxed downstream actions | Content scanning and approval gates |
| Sensitive data leakage | Data classification, redaction, tenant-scoped logs/caches, no raw prompt logging by default | Encrypt retained artifacts |
| Model DoS | Per-tenant quotas, token budgets, bounded queues, provider-aware rate limiting | Upstream overload fallback |
| Supply chain risk | Signed images, SBOM, dependency review, minimal interop surface | Isolated local workers |
| Excessive agency | Tool permission model, human approval for side effects | Step-level auditability |
| Model theft or credential theft | Secret manager, workload identity, cache isolation, egress control | Rate anomaly detection |
At the transport and data layer, require TLS in transit, encryption at rest for retained conversation state, tenant-scoped cache keys, and explicit retention policies for prompts, responses, and telemetry. OpenAI and Anthropic both document provider-specific caching and retention semantics, so your runtime must treat retained provider-side state as a configurable policy choice rather than an implicit default.
For governance, adopt NIST AI RMF as the umbrella for AI trustworthiness and NIST SSDF as the secure software delivery baseline. NIST describes the AI RMF as a voluntary framework to incorporate trustworthiness considerations into the design, development, use, and evaluation of AI systems, while the SSDF provides a core set of high-level secure software development practices intended to reduce vulnerabilities and mitigate their impact. That pairing fits an enterprise CE platform very well: AI RMF for model/runtime governance, SSDF for implementation and delivery discipline.
Testing, quality engineering, and CI/CD
This runtime should be validated as both a software platform and an LLM integration surface. That means the test strategy cannot stop at ordinary unit tests. It needs unit, integration, contract, property-based, fuzz, chaos, performance, and end-to-end coverage, with promotion gates tied to the risk of the change. NIST SSDF explicitly recommends integrating secure development practices into the SDLC, which supports treating the CI/CD system as an enforcement mechanism rather than a convenience tool.
A practical test matrix looks like this:
| Test type | Primary target | Gate level |
|---|---|---|
| Unit | Prompt rendering, token budgeting, routing, error normalization | Every PR |
| Integration | Redis/cache, secret retrieval, HTTP transport, stream framing | Every PR |
| Contract | Provider API surface, schema mapping, error/header handling | Every PR against recorded fixtures; nightly against live sandboxes |
| Property-based | Prompt compaction, truncation invariants, stream assembler correctness | Every PR |
| Fuzz | JSON parsing, SSE parsing, tool-output validation | Every PR or nightly |
| Chaos | Retry/backoff, overload behavior, dependency loss, queue saturation | Nightly / pre-prod |
| Performance | p50/p95/p99 latency, TTFT, throughput, memory, cache hit ratio | Pre-release and nightly |
| End-to-end | Full auth → inference → telemetry → cache → audit flow | Pre-release |
The most valuable harness pattern is a deterministic fake-adapter stack. Build FakeModelAdapter, FakeTokenizer, and FakeClock implementations for domain-level tests; a record/replay HTTP harness for provider contract tests; and a stream event replayer for SSE parser validation. For local backends, build a golden-corpus differential harness against reference outputs and a benchmark harness that captures throughput, latency, and memory regression. The attached references are especially valuable for this local path because they frame the correctness risks around quantization layouts, memory ownership, KV cache semantics, and native interop.
Provider contract tests should specifically verify:
- header handling for rate limits and retries,
- structured output schema enforcement,
- streaming delta ordering and terminal events,
- usage extraction and cached-token handling,
- error normalization into internal codes.
That focus is grounded in the provider docs: OpenAI documents structured outputs, response error codes, rate-limit headers, and random exponential backoff; Anthropic documents retry-after, rate-limit headers, and SSE error events; Gemini documents project-level rate limits and SSE generation endpoints.
A representative unit-test pattern is below.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using CeRuntime.Contracts;
using Xunit;
namespace CeRuntime.Tests;
/// <summary>
/// Verifies orchestration behavior using deterministic fakes.
/// </summary>
public sealed class InferenceOrchestratorTests
{
[Fact]
public async Task ExecuteAsync_returns_normalized_response_from_adapter()
{
// Arrange
var adapter = new FakeModelAdapter();
var tokenizer = new PassThroughTokenizer();
var promptManager = new PassThroughPromptManager();
var orchestrator = new InferenceOrchestrator(adapter, tokenizer, promptManager);
var request = new InferenceRequestDto
{
Tenant = "tenant-a",
Model = "gpt-like-model",
Messages =
[
new MessageDto
{
Role = "user",
Content = "Say hello."
}
]
};
// Act
InferenceResponseDto response = await orchestrator.ExecuteAsync(request, CancellationToken.None);
// Assert
Assert.Equal("adapter-response-1", response.Id);
Assert.Equal("hello", response.OutputText);
Assert.Equal("fake-provider", response.Provider);
Assert.Equal(3, response.Usage.InputTokens);
Assert.Equal(1, response.Usage.OutputTokens);
}
private sealed class FakeModelAdapter : IModelAdapter
{
public string ProviderName => "fake-provider";
public Task<InferenceResponseDto> GenerateAsync(
InferenceRequestDto request,
CancellationToken cancellationToken)
{
var response = new InferenceResponseDto
{
Id = "adapter-response-1",
Model = request.Model,
OutputText = "hello",
FinishReason = "stop",
Provider = ProviderName,
Usage = new UsageDto
{
InputTokens = 3,
OutputTokens = 1,
CachedInputTokens = 0,
EstimatedCostMicros = 25
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<InferenceEventDto> StreamAsync(
InferenceRequestDto request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return new InferenceEventDto
{
Type = "delta",
Delta = "hel"
};
yield return new InferenceEventDto
{
Type = "delta",
Delta = "lo"
};
await Task.CompletedTask;
}
}
private sealed class PassThroughTokenizer : ITokenizer
{
public ValueTask<TokenCountResult> CountTokensAsync(
InferenceRequestDto request,
CancellationToken cancellationToken) =>
ValueTask.FromResult(new TokenCountResult(3));
public ValueTask<InferenceRequestDto> TruncateAsync(
InferenceRequestDto request,
int budget,
CancellationToken cancellationToken) =>
ValueTask.FromResult(request);
}
private sealed class PassThroughPromptManager : IPromptManager
{
public ValueTask<PromptEnvelope> RenderAsync(
InferenceRequestDto request,
CancellationToken cancellationToken) =>
ValueTask.FromResult(new PromptEnvelope(request));
public ValueTask<PromptEnvelope> CompactAsync(
InferenceRequestDto request,
int budget,
CancellationToken cancellationToken) =>
ValueTask.FromResult(new PromptEnvelope(request));
}
}
The CI/CD pipeline should enforce a strict progression: restore/build → static analysis → unit/integration/property/fuzz tests → provider contract tests → performance regression tests → image build/signing/SBOM → canary deployment → post-deploy verification. Where provider credentials are required, use short-lived service identities or workload identity patterns instead of embedded long-lived secrets; Azure’s managed identity guidance is directly aligned with that approach.
Deployment, observability, versioning, and risk management
The production deployment model should separate the CE API tier from any local compute tier. The API tier is a horizontally scalable stateless service; the local inference tier, if present, is a specialized worker pool with different memory and scheduling characteristics. This separation lowers blast radius, simplifies autoscaling, and prevents native/backend concerns from contaminating the API process. That recommendation is strongly supported by the attached references, which show how far local inference pulls the design toward unmanaged memory, driver interop, and custom scheduling.
For Kubernetes deployment, use Deployments with rolling updates, readiness probes, liveness probes, and Horizontal Pod Autoscaling. Kubernetes documents that readiness probes prevent traffic from reaching not-yet-ready containers, liveness probes restart broken ones, Deployments support rolling updates via maxUnavailable and maxSurge, and HPA can scale on both standard and custom metrics.
Observability should be based on OpenTelemetry from day one. OpenTelemetry .NET documents support for traces, metrics, and logs, and marks the three major components as stable. That is enough to justify a single telemetry contract spanning request traces, provider calls, stream lifecycle events, queue depth, token usage, cache hits, cost estimates, and local-worker performance counters.
The minimum useful SLI/SLO set is:
| SLI | Recommended target | Why it matters |
|---|---|---|
| Availability | 99.9% monthly for inference API | Baseline CE platform reliability |
| Time to first token | p95 under tenant-specific target | Best user-perceived latency metric for streaming |
| Full response latency | p95 and p99 by route/model | Required for capacity planning |
| Error rate | under 1% excluding caller faults | Signals runtime/provider instability |
| Queue wait time | low p95 with hard fail threshold | Protects interactive workloads |
| Stream completion rate | high 99th percentile | Detects partial-stream failures |
| Cache hit ratio | monitored by provider and tenant | Direct cost/latency lever |
| Cost per 1K requests | tracked per tenant/model | Essential CE governance signal |
Versioning should be two-dimensional: runtime version and model version must evolve independently. For provider-hosted models, store a ModelManifest that records provider, endpoint family, model identifier, capability flags, tokenizer version, prompt cache capability, streaming support, tool support, and deprecation status. For local ONNX backends, include IR/opset compatibility; for GGUF backends, include quantization family, tokenizer artifact version, and backend requirements. This is not optional: ONNX graphs carry explicit opset rules, provider models change over time, and Azure’s current documentation makes clear that model availability and API capability are versioned surfaces.
A safe migration strategy is:
- shadow traffic for adapter or model swaps,
- golden-corpus regression on representative prompts,
- canary rollout by tenant or route,
- dual manifest support during transition,
- automatic rollback on SLO breach,
- explicit deprecation windows for contract changes.
The risk register below reflects the most material enterprise concerns.
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Provider API drift | High | Medium | Canonical contract, contract tests, adapter isolation |
| Prompt injection causing unsafe tool use | High | High | Tool permission model, output validation, approval gates |
| Hidden rate-limit saturation | High | High | Header-aware rate limiter, queue budgets, adaptive backoff |
| Cache data leakage across tenants | High | Medium | Tenant-scoped cache keys, encryption, strict auth context |
| Local backend memory faults | High | Medium | Process isolation, SafeHandle discipline, soak tests |
| Performance regressions after model switch | Medium | High | Golden corpus + perf gate before promotion |
| Secret leakage in CI/CD or runtime | High | Medium | Managed identity / secret manager / rotation |
| Telemetry blind spots | Medium | Medium | OTel-first design, required trace/span attributes |
| Native AOT incompatibility for plugins | Medium | Medium | Limit AOT use to stable workers and sidecars |
Open questions and limitations
This report is intentionally high-confidence and architecture-first. A few decisions still depend on constraints that were not specified in the prompt.
If local inference is a hard requirement from day one, you should decide early whether the first local backend is ONNX Runtime C# or a GGUF-native worker. ONNX gives you a more standardized model contract and strong C# bindings; GGUF aligns more directly with the attached llama.cpp-oriented references and quantized local LLM serving.
If hard compliance requirements exist, such as region-locked processing, PHI, export control, or customer-managed key mandates, the final deployment topology may shift materially toward Azure OpenAI with Entra ID and managed identities, or toward a local-only controlled backend. The current evidence supports those paths, but the exact governance posture still depends on the customer environment and data classification.
If extreme throughput is the primary driver, the batching, cache, and local-worker design should be benchmarked against your actual prompt distributions before adopting continuous batching or specialized low-latency GC modes. Microsoft’s GC guidance is explicit that low-latency modes can increase fragmentation and memory pressure, and SIMD/intrinsics only help when the surrounding memory behavior is disciplined.