Das Modell hinter Ihrer Anwendung hat ein Ablaufdatum. Gesetzt haben Sie es nicht.
Wenn es soweit ist, entscheidet nicht Ihre Planung, wie lange die Umstellung dauert. Es entscheidet, an wie vielen Stellen in Ihrem Code der Name eines Anbieters steht.
Seit dem 13. August 2026 gibt es dafür Bausteine in der Standardbibliothek. Ich habe sie gebaut, gemessen und dabei zwei eigene Annahmen widerlegt. Beides steht in diesem Beitrag.
Was neu ist
Microsoft hat Routing und Failover in Microsoft.Extensions.AI aufgenommen, veröffentlicht am 11. August 2026 mit Version 10.9.0, angekündigt zwei Tage später im .NET Blog.
Sechs Typen kommen dazu: RoutingChatClient und SemanticRoutingChatClient für die Auswahl, FailoverChatClient und OrderedFailoverChatClient für die Kette, dazu RoutingContext und FailoverChatClientAttempt. Alle tragen [Experimental] mit der Diagnose-ID MEAI001. Ohne ein #pragma warning disable MEAI001 kompiliert nichts davon.
Der entscheidende Satz aus der Ankündigung ist dieser:
„Every router is an IChatClient, so a cost- or capability-aware router can sit inside a failover chain."
Alles ist ein IChatClient. Router lassen sich in Ketten stecken, Ketten in Router, und die Fachlogik dahinter sieht in jedem Fall dieselbe Schnittstelle.
Drei Dinge, die in der Ankündigung nicht stehen und die ich beim Bauen gefunden habe:
Es gibt keine ChatClientBuilder-Erweiterungen für Failover. Von sieben Erweiterungsmethoden im Paket hat keine damit zu tun. Die Typen werden direkt instanziiert. Wer den vertrauten Builder-Stil erwartet, sucht vergeblich.
Zwei der sechs Typen liegen in Microsoft.Extensions.AI.Abstractions, nicht im Hauptpaket. Für den Code ist das folgenlos, das Metapaket zieht beides.
Und: Sie brauchen kein .NET 10. Das Paket liefert Assemblies bis hinunter zu netstandard2.0. Das Demo, das diesem Beitrag zugrunde liegt, baut auf net9.0. Für Bestandscode ist das die eigentlich gute Nachricht.
Das Problem vor der Abstraktion
So sieht Kopplung aus:
public class AngebotsService(OpenAIClient client)
{
public async Task<string> ZusammenfassenAsync(string text)
{
var chat = client.GetChatClient("gpt-...");
var antwort = await chat.CompleteChatAsync(text);
return antwort.Value.Content[0].Text;
}
}
Die Fachlogik kennt den Anbieter. Sie kennt seinen Client-Typ, seine Modellnamen, sein Antwortformat. Ein Anbieterwechsel ist damit keine Konfigurationsänderung, sondern eine Änderung an jeder Klasse, die so aussieht. In einer gewachsenen Anwendung sind das selten drei Stellen.
Der Ausweg ist alt und unspektakulär: eine Schnittstelle dazwischen. Neu ist, dass die Bausteine dafür jetzt mitgeliefert werden.
Baustein 1: Die Fallback-Kette
Drei Stufen, der Reihe nach: Hauptanbieter, Ersatzanbieter, lokales Modell im eigenen Haus.
#pragma warning disable MEAI001 // Experimental: Routing/Failover
IChatClient primary = new OpenAIClient(new ApiKeyCredential(apiKey))
.GetChatClient(model)
.AsIChatClient();
IChatClient backup = /* zweiter Cloud-Anbieter, gleiche Bauart */;
// OllamaApiClient implementiert IChatClient direkt.
// Microsoft.Extensions.AI.Ollama ist abgekündigt und wird nicht verwendet.
IChatClient local = new OllamaApiClient(new Uri(ollamaEndpoint), "qwen3-coder:30b");
IChatClient chain = new OrderedFailoverChatClient([primary, backup, local]);
Das Collection-Literal trägt unverändert. Der Konstruktor nimmt eine IReadOnlyList<IChatClient> und optional ein leaveOpen.
Ab hier bekommt die Fachlogik chain als IChatClient injiziert und weiß von den drei Stufen nichts.
Ein Detail, das Sorgen macht und keine machen muss. Die Dokumentation spricht davon, dass eine „uncanceled failure" zur nächsten Stufe weiterschaltet. Die naheliegende Sorge lautet: Mein HttpClient-Timeout wirft eine TaskCanceledException, gilt als Abbruch und beendet die Kette, statt weiterzuschalten.
Das ist nicht so. Ich habe beide Fälle gemessen. Entscheidend ist der Zustand des Anfrage-Tokens, nicht der Typ der Ausnahme. Ein HttpClient-Timeout schaltet weiter. Nur wenn der Aufrufer selbst abgebrochen hat, bricht die Kette ab. Das ist genau das Verhalten, das man will, und es ist am Namen der Ausnahme nicht abzulesen.
Baustein 2: Der lokale Fallback
Für die dritte Stufe empfiehlt Microsoft inzwischen OllamaSharp, aktuell 5.4.30 vom 24. Juli 2026. Das eigene Paket Microsoft.Extensions.AI.Ollama ist abgekündigt, alle Versionen tragen ein Deprecation-Feld.
Hier lag mein erster Irrtum. Ich war davon ausgegangen, dass OllamaSharp die OpenAI-kompatible Schicht /v1 anspricht, so wie es andere Bibliotheken tun. Daraus hatte ich gefolgert, dass sich das Nachdenken eines Modells aus .NET heraus nicht abschalten lässt, denn über /v1 geht das nachweislich nicht.
Beides habe ich nachgemessen, statt es zu glauben. Ein lokaler Horcher auf einem eigenen Port, die Anfrage mitgeschrieben:
POST /api/chat HTTP/1.1
OllamaSharp ist ein nativer Ollama-Client. Er spricht die native Schnittstelle und reicht deren Felder durch. Die /v1-Annahme trifft auf einen anderen Weg zu, nicht auf diesen.
Damit lässt sich das Nachdenken abschalten, und zwar auf zwei Wegen:
// portabel, ohne Kenntnis des Anbieters
var options = new ChatOptions
{
Reasoning = new ReasoningOptions { Effort = ReasoningEffort.None }
};
// anbieternah, reicht das native Feld durch
var options = new ChatOptions
{
RawRepresentationFactory = _ => new ChatRequest { Think = false }
};
Der erste Weg ist die Abstraktion, der zweite der Notausgang. Genau dieses Paar ist der Grund, warum ein Anbieterwechsel zur Konfiguration wird: Für das Gemeinsame gibt es eine portable Option, und für alles andere eine Tür zum Anbieter, ohne dass die Fachlogik sie sieht.
Was das an Zeit ausmacht, sechs Wege, jeder zehnmal gemessen, zwei vollständige Durchläufe, verschachtelte Reihenfolge:
| Gruppe | Median | Einzelwerte | erzeugte Token |
|---|---|---|---|
| ohne Nachdenken | 0,64 bis 0,68 s | 0,62 bis 0,71 s | 47 |
| mit Nachdenken | 4,56 bis 4,59 s | 4,40 bis 4,71 s | 403 |
Gut Faktor sieben, bei sonst identischem Aufbau. Die Gruppen überschneiden sich nicht: 0,71 Sekunden ist der langsamste Wert ohne, 4,40 der schnellste mit.
Und jetzt der Teil, den ich fast falsch veröffentlicht hätte. Diese Reihe war der vierte Anlauf. Die ersten drei widersprachen sich: derselbe Weg einmal bei 1,42 Sekunden, einmal bei 0,66, einmal bei 0,68. Jede einzelne Reihe sah in sich stabil aus. Die Streuung zwischen den Reihen war größer als die innerhalb.
Die Ursache war nicht die Schnittstelle und nicht die Maschine. Es war die Antwortlänge. Ohne feste Temperatur erzeugte dasselbe Modell auf dieselbe Frage zwischen 343 und 504 Token, eine Spanne von Faktor 1,5. Mit Temperature = 0 waren es über zehn Läufe konstant 403. Die Korrelation zwischen Token-Zahl und Dauer liegt bei 0,976, der Durchsatz blieb konstant bei rund 89 Token pro Sekunde.
Die gemessene Dauer eines denkenden Wegs ist also keine Eigenschaft dieses Wegs. Sie ist eine Eigenschaft davon, wie viele Token das Modell diesmal erzeugt hat. Wer solche Zeiten misst, ohne die Antwortlänge zu fixieren, misst die Laune des Modells.
Die Zahlen oben gelten unter drei Bedingungen: Temperatur auf null, Modell resident, Zugriff auf den Host serialisiert. Ohne die erste kommt beim Nachmessen etwas anderes heraus.
Die Modellwahl für die letzte Stufe ist damit keine Tempofrage mehr. Drei Varianten liefen in der vollständigen Kette, je fünfzehn Läufe:
| Variante | Median | Spanne | Denkanteil |
|---|---|---|---|
qwen3-coder:30b, kann nicht denken |
0,39 s | 0,34 bis 0,58 s | keiner |
nemotron-3.5-lightning, Nachdenken an |
4,91 s | 3,78 bis 11,26 s | 1 630 Zeichen |
nemotron-3.5-lightning, Nachdenken aus |
0,61 s | 0,49 bis 0,87 s | keiner |
Zwischen der ersten und der dritten Zeile liegen 0,22 Sekunden. In einem Fall, in dem bereits zehn oder zwanzig Sekunden verbrannt sind, ist das ohne Belang. Beide sind vertretbar.
Die zweite Zeile ist es nicht, und zwar nicht wegen des Medians. Sie schwankt zwischen 3,78 und 11,26 Sekunden, Faktor drei innerhalb derselben Reihe, während die anderen beiden unter Faktor 1,8 bleiben. Für eine Notfallstufe zählt nicht, dass der Wert im Mittel gut aussieht. Es zählt, dass er hält.
Ich habe für die letzte Stufe deshalb nicht das beste Modell genommen, sondern das mit dem berechenbarsten Antwortverhalten: das, dessen schlechtester Lauf nah an seinem besten liegt. Wenn zwei Anbieter bereits ausgefallen sind, ist eine schlichte Antwort in einer Sekunde mehr wert als eine gute in zehn.
Die Antworten der dritten Stufe sind sprachlich schwächer. In früheren Läufen kamen zusammengeschriebene Wörter und ein verirrter Satzpunkt vor, in den letzten Läufen nicht. Das ist unstet und taugt nicht als Messgröße. Planen Sie damit, statt es wegzudiskutieren.
Baustein 3: Was die Ankündigung verschweigt
Der Blogpost zeigt, wie man jeden Versuch sichtbar macht: eine Ableitung von OrderedFailoverChatClient, die OnRoutingUpdateAsync überschreibt.
Diese Ableitung kompiliert nicht. OrderedFailoverChatClient ist sealed.
Wer das Beispiel abschreibt, bekommt einen Compilerfehler. Die Basisklasse ist FailoverChatClient, und mit ihr fällt die Reihenfolge-Logik an einen selbst zurück, die OrderedFailoverChatClient sonst mitbringt.
public class LoggingFailoverChatClient : FailoverChatClient
{
readonly IReadOnlyList<IChatClient> _clients;
readonly ILogger _log;
// Pro Anfrage legt die Basisklasse genau einen RoutingContext an. Der Zeiger auf
// die aktuelle Stufe haengt deshalb am Kontext, nicht am Client, und der Client
// bleibt threadsicher.
readonly ConditionalWeakTable<RoutingContext, RequestState> _state = new();
public LoggingFailoverChatClient(IReadOnlyList<IChatClient> clients, ILogger log)
{
_clients = clients;
_log = log;
// Ohne dieses Limit waehlt die Basisklasse nach dem letzten Fehlschlag erneut aus.
MaximumAttemptsPerRequest = clients.Count;
}
protected override ValueTask<IChatClient> SelectClientAsync(
RoutingContext context, CancellationToken cancellationToken)
{
var state = _state.GetValue(context, static _ => new RequestState());
if (state.Index >= _clients.Count)
{
_state.Remove(context);
state.LastFailure?.Throw();
throw new InvalidOperationException("Kette erschoepft.");
}
return new(_clients[state.Index++]);
}
protected override ValueTask OnRoutingUpdateAsync(
RoutingContext context, FailoverChatClientAttempt attempt,
bool isTerminal, CancellationToken cancellationToken)
{
var state = _state.GetValue(context, static _ => new RequestState());
if (attempt.Exception is not null)
state.LastFailure = ExceptionDispatchInfo.Capture(attempt.Exception);
_log.LogInformation(
"Versuch {Stufe}/{Stufen} {Client}: Dauer {Dauer:F0} ms, Fehler {Fehler}",
state.Index, _clients.Count, Describe(attempt.Client),
attempt.Duration.TotalMilliseconds,
attempt.Exception?.GetType().Name ?? "-");
if (isTerminal) _state.Remove(context);
return default;
}
sealed class RequestState
{
public int Index;
public ExceptionDispatchInfo? LastFailure;
}
}
Drei Punkte, die man beim Bauen lernt und danach nie wieder vergisst:
MaximumAttemptsPerRequest ist nicht optional. Ohne die Grenze fragt die Basisklasse nach dem letzten Fehlschlag erneut nach einem Client.
Der Zustand gehört an den RoutingContext, nicht an den Client. Die Basisklasse legt pro Anfrage genau einen Kontext an. Eine ConditionalWeakTable daran hält den Client threadsicher, ohne dass man selbst aufräumen muss.
Und beim Protokollieren gehört der Endpunkt nicht ins Log. Die Metadaten eines Clients liefern Anbieternamen und Modell-ID getrennt von der Adresse. Nehmen Sie die ersten beiden, lassen Sie die dritte weg. Sonst steht Ihre interne Adresse in jeder Logzeile und irgendwann in einem Ticket.
FailoverChatClientAttempt trägt übrigens mehr als die vier dokumentierten Felder: neben Client, Dauer, TimeToFirstUpdate und Ausnahme auch ResponseCompleted und OutputCommitted. TimeToFirstUpdate bleibt ohne Streaming leer. Im Streaming-Lauf lag es bei 22 Millisekunden, bei 371 Millisekunden Gesamtdauer. Wenn Sie streamen, ist das die Zahl, die Ihr Nutzer erlebt, nicht die Gesamtdauer.
So sieht die Kette dann im Log aus, zwei gescheiterte Cloud-Stufen und eine lokale Antwort:
Versuch 1/3 primary: Dauer 2 ms, Fehler HttpRequestException
Versuch 2/3 backup: Dauer 0 ms, Fehler HttpRequestException
Versuch 3/3 ollama/qwen3-coder:30b: Dauer 582 ms, Fehler -
Was das kostet
Die Kette selbst kostet fast nichts. Drei Szenarien, Stufe eins und zwei als Attrappen mit gesetztem Fehlerbild, Stufe drei ein echtes lokales Modell, je fünf Läufe:
| Szenario | Stufe 1 | Stufe 2 | Gesamtdauer |
|---|---|---|---|
| Beide scheitern sofort | HTTP 500 | HTTP 429 | 0,39 s |
| Ein Timeout im Pfad | Timeout 10 s | HTTP 429 | 10,39 s |
| Worst Case | Timeout 10 s | Timeout 10 s | 20,41 s |
Im ersten Szenario besteht die Gesamtdauer zu hundert Prozent aus der Antwortzeit des lokalen Modells. Der Failover-Mechanismus liegt im Messrauschen.
Die Timeout-Werte sind gesetzt, nicht gemessen. Die Gesamtdauer ist damit eine Funktion Ihrer eigenen Konfiguration, kein Naturgesetz.
Nur ist Ihr Timeout nicht der einzige Hebel. In einem Lauf gegen zwei echte Cloud-Clients, deren Endpunkt auf einen geschlossenen Port zeigte, brauchte jede Stufe gut acht Sekunden, obwohl die Verbindung sofort abgelehnt wurde. Das ist die Wiederholungslogik des Anbieter-SDK, die innerhalb einer Stufe abläuft und sich zur Kettendauer addiert. Die vollständige Anfrage dauerte 17,6 Sekunden und lieferte am Ende eine korrekte Antwort aus dem eigenen Haus.
Wer eine schnelle Kette will, konfiguriert also zwei Dinge: den eigenen Timeout und die Wiederholungspolitik jedes Anbieter-SDK. Das zweite wird regelmäßig vergessen.
Was Sie zusätzlich einkaufen: lokale Hardware, die stehen und laufen muss, und eine dritte Stufe, deren Antworten schwächer sind als die der ersten. Eine Euro-Zahl nenne ich hier nicht. Sie hinge an Preisdaten beider Cloud-Stufen und einer Annahme über Ihr Anfragevolumen, und beides habe ich nicht gemessen.
Die Grenzen
Was die Bausteine ausdrücklich nicht können: Model Cascading, Ensemble-Routing, Hedging. Wer parallel mehrere Modelle fragt und die beste Antwort nimmt, baut das weiterhin selbst.
Die Routing-Entscheidung fällt einmal pro Anfrage. Ein RoutingContext hat genau zwei Member, Messages und ChatOptions, beide nur lesbar. Eine Routing-Regel sieht die Nachrichten und die Optionen. Keinen Versuchszähler, keine Historie, keinen Zustand. Für kostenbewusstes Routing reicht das. Für Regeln, die aus dem bisherigen Verlauf lernen sollen, nicht.
Der semantische Router braucht mehr Unterbau, als der Name vermuten lässt: einen IEmbeddingGenerator, ein Profil je Client, einen Standard-Client und eine Entscheidung über die Score-Aggregation. Das ist ein eigenes Thema, kein Zweizeiler.
Und, zum Schluss: Alles hier ist [Experimental]. Die Signaturen können sich ändern.
Ausblick: dieselbe Kette für Ihre Automatisierung
Wenn die Kette einmal steht, ist sie ein IChatClient, und ein IChatClient lässt sich in drei Zeilen hinter HTTP legen:
app.MapPost("/ask", async (AskRequest req, IChatClient chain, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.Prompt))
return Results.BadRequest(new { error = "prompt fehlt" });
var response = await chain.GetResponseAsync(req.Prompt, cancellationToken: ct);
return Results.Ok(new { answer = response.Text });
});
public sealed record AskRequest(string Prompt);
Damit erbt jedes Werkzeug, das diesen Endpunkt aufruft, die gesamte Fallback-Logik: n8n, ein Skript, ein Formular. Der Aufrufer weiß von drei Stufen nichts. Wie das im Zusammenspiel mit n8n aussieht, ist der nächste Beitrag.
Für wen das nichts ist
Wenn Sie einen einzigen Anbieter nutzen, keine Ausfallanforderung haben und keine Daten, die das Haus nicht verlassen dürfen, ist eine Fallback-Kette Aufwand ohne Gegenwert. Ein sauber gekapselter Client reicht dann völlig.
Interessant wird es, wenn eines von dreien zutrifft: Sie haben eine Verfügbarkeitszusage gegeben. Sie haben Unterlagen, die im eigenen Netz bleiben müssen. Oder Sie haben schon einmal erlebt, wie lange es dauert, den Namen eines Anbieters aus einer gewachsenen Anwendung herauszuoperieren.
Denn das Ablaufdatum kommt. Microsoft benachrichtigt mindestens 60 Tage vorher bei allgemein verfügbaren Modellen und 30 Tage bei Preview-Modellen, bei Sicherheitsfällen schneller. Fest gebuchte Kapazität wird nicht automatisch mitgenommen. Und auf die Frage, ob sich ein Retirement-Datum verlängern lässt, steht in der Dokumentation ein Wort:
„No. Retirement dates aren't extendable."
Die eigentliche Frage steht also vor der technischen: Wissen Sie, an wie vielen Stellen in Ihrem Code der Name eines einzigen Anbieters steht?
Quellen
- .NET Blog, „Routing and Failover for Microsoft.Extensions.AI", 13.08.2026
- NuGet,
Microsoft.Extensions.AI10.9.0, veröffentlicht 11.08.2026 - NuGet,
OllamaSharp5.4.30, veröffentlicht 24.07.2026 - Microsoft Learn, Model lifecycle and retirements
- Alle Messwerte stammen aus eigenen Läufen vom 04. bis 08.09.2026 auf identischer Hardware. Die Grenzen jeder Reihe stehen im Text.
