Microsoft.Extensions.AI: IChatClient e o Pipeline de Middleware
Objective
Microsoft.Extensions.AI (MEAI) é a base de toda esta trilha.
Microsoft.Extensions.AI.Abstractions define IChatClient, uma interface com
duas chamadas que os provedores implementam; Microsoft.Extensions.AI adiciona
o pipeline do ChatClientBuilder (logging, cache, ferramentas, telemetria) em
volta de qualquer IChatClient. A lição chama um modelo local do Ollama, lê a
resposta, prova a ordem do pipeline, mede um acerto de cache, registra clientes
no DI, troca o provedor por um fake e registra as falhas exatas.
Versões: Microsoft.Extensions.AI e .Abstractions 10.11.0, OllamaSharp
5.5.0, Microsoft.Extensions.Caching.Memory 10.0.14, Ollama 0.40.12 com
qwen3:4b-instruct, .NET 10.
Use Cases
- Programar contra
IChatClient, para que o provedor (Ollama local, um modelo na nuvem em produção) seja um detalhe de registro. - Logging, cache ou telemetria em toda chamada ao modelo, num lugar só.
- Testes unitários com um
IChatClientfake: sem rede, respostas fixas. - Fazer streaming da resposta para a UI e ainda obter um
ChatResponsecom uso.
Deep Dive
Uma chamada, e o que volta
OllamaApiClient (OllamaSharp) implementa IChatClient. Uma string vira uma
ChatMessage de usuário; ChatOptions traz os ajustes comuns tipados:
csharpIChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "qwen3:4b-instruct");
ChatResponse r = await client.GetResponseAsync(
"Name the capital of France in one word.",
new ChatOptions { Temperature = 0, Seed = 42 });textMessages.Count = 1 (assistant, TextContent) Text = Paris Usage = in 17, out 2, total 19 FinishReason = stop ModelId = qwen3:4b-instruct ConversationId = null ResponseId = 2026-10-29T21:10:23.870667Z
Mesma resposta e contagens em 3 de 3 execuções; Usage bate com a resposta
crua de /api/chat (prompt_eval_count 17, eval_count 2). A documentação
diz que uma resposta pode ter várias mensagens (um loop de ferramentas adiciona
algumas), a última sendo a final. ResponseId vem como timestamp.
Streaming, e juntar as atualizações de volta
GetStreamingResponseAsync recebe as mesmas entradas e devolve
IAsyncEnumerable<ChatResponseUpdate>:
csharpList<ChatResponseUpdate> updates = [];
await foreach (var u in client.GetStreamingResponseAsync("Count from 1 to 10, separated by spaces.", options))
updates.Add(u); // uma UI imprimiria u.Text aqui
ChatResponse r = updates.ToChatResponse(); // ou .ToChatResponseAsync() no streamtextupdates = 21, first update: Text='1' Role=assistant updates carrying UsageContent = 1, last FinishReason = stop ToChatResponse: Messages=1 Text='1 2 3 4 5 6 7 8 9 10' Usage in 21 out 21 Finish=stop
Idêntico em 3 de 3 execuções. O uso chega uma vez, como um item
UsageContent, e ToChatResponse o move para ChatResponse.Usage; código que
imprime u.Text e descarta as atualizações perde as contagens de tokens.
O cliente não lembra
IChatClient não guarda conversa. Um DelegatingChatClient que imprime o que
repassa mostra o que chega ao Ollama:
csharpList<ChatMessage> history = [new(ChatRole.System, "Answer in one short sentence."), new(ChatRole.User, "My name is Ana.")];
ChatResponse r1 = await client.GetResponseAsync(history, options);
await client.GetResponseAsync("What is my name?", options); // uma lista nova
history.AddMessages(r1); // anexa a resposta
history.Add(new(ChatRole.User, "What is my name?"));
await client.GetResponseAsync(history, options);textsent 2: system: Answer in one short sentence. | user: My name is Ana. sent 1: user: What is my name? without history: I don't know your name. ... sent 4: system: Answer in one short sentence. | user: My name is Ana. | assistant: Hello Ana! How can I assist you today? | user: What is my name? with history: Your name is Ana.
O nome só foi lembrado na chamada com 4 mensagens, em 3 de 3 execuções. Para
serviços com estado, a documentação descreve outro modo: definir
ChatOptions.ConversationId (ou copiar ChatResponse.ConversationId) e enviar
só as mensagens novas. O Ollama não guarda estado: ConversationId voltou
null sempre, e "conv-123" com só a pergunta recebeu o mesmo "I don't know
your name". O histórico é seu, e cada turno paga tokens de entrada por ele.
O pipeline e sua ordem
ChatClientBuilder envolve um cliente em camadas (DelegatingChatClients ou
delegates passados a Use). Três que registram entrada e saída, sobre um fake:
csharpIChatClient client = new ChatClientBuilder(fake)
.Use(inner => new Tracer(inner, "A", log)) // DelegatingChatClient que
.Use(inner => new Tracer(inner, "B", log)) // sobrescreve só GetResponseAsync
.Use(async (messages, options, next, ct) => // um delegate para as duas chamadas
{
log.Add("C enter");
await next(messages, options, ct);
log.Add("C exit");
})
.Build();textA enter -> B enter -> C enter -> C exit -> B exit -> A exit Tracer > Tracer > AnonymousDelegatingChatClient > ScriptedChatClient stream: C enter -> C exit
O primeiro Use é a camada mais externa: vê a requisição primeiro e a resposta
por último, e é o que Build() devolve. A última linha é a armadilha:
DelegatingChatClient repassa todo método que não sobrescreve, então A e B
foram pulados na chamada de streaming. Sobrescreva os dois métodos, ou use a
sobrecarga de Use com delegate compartilhado, que rodou nos dois.
UseLogging escreveu GetResponseAsync invoked. e ... completed. em Debug,
os mesmos eventos com mensagens e resposta em JSON em Trace, e nada em
Information. UseFunctionInvocation adiciona o loop de ferramentas (próxima
lição). UseOpenTelemetry, segundo a documentação, emite spans e métricas nas
convenções GenAI do OpenTelemetry (medidas na lição de telemetria).
Cache de respostas com UseDistributedCache
UseDistributedCache guarda respostas em qualquer IDistributedCache. Com um
cache em memória e um fake que conta chamadas:
csharpIDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
IChatClient client = new ChatClientBuilder(fake).UseDistributedCache(cache).Build();textfake calls after 2 identical = 1; same instance=False after same text, different Temperature = 2; after different casing = 3 after 2 identical streams = 4; non-streaming after streaming same text = 5
A chave cobre mensagens e opções: outra temperatura ou uma maiúscula é miss,
streaming e sem streaming têm entradas separadas, e um acerto é uma cópia
desserializada. Na frente do Ollama, duas chamadas idênticas chegaram ao modelo
uma vez, e a cópia ainda informou out 3 tokens: Usage vai junto para o
cache, então um painel que o soma conta tokens que ninguém pagou.
A ordem importa aqui também: com .UseDistributedCache().UseLogging() duas
chamadas idênticas geraram log uma vez; com .UseLogging() primeiro, duas.
Deixe fora do cache o que precisa ver toda requisição, e dentro o que custa.
DI, metadados e troca de provedor
AddChatClient registra o pipeline e devolve o builder; AddKeyedChatClient
faz o mesmo sob uma chave:
csharpservices.AddDistributedMemoryCache();
services.AddChatClient(new OllamaApiClient(ollamaUri, "qwen3:4b-instruct"))
.UseDistributedCache() // IDistributedCache vindo do DI
.UseLogging(); // ILoggerFactory vindo do DI
services.AddKeyedChatClient("fake", new ScriptedChatClient("from the fake"));textIChatClient key=(none) lifetime=Singleton IChatClient key=fake lifetime=Singleton main = DistributedCachingChatClient, same instance on 2nd resolve = True metadata: Provider=ollama Uri=http://localhost:11434/ DefaultModel=qwen3:4b-instruct GetService<OllamaApiClient> through pipeline = OllamaApiClient fake metadata = null ollama: 'Hello!' (ModelId=qwen3:4b-instruct, out tokens=3) fake: 'from the fake' (ModelId=null, out tokens=null)
Os dois são singletons, então uma camada não pode capturar um serviço scoped.
GetService<ChatClientMetadata>() no cliente externo alcançou os metadados do
provedor; o fake não tem. O motivo da interface: um método que recebe
IChatClient (system "Reply with one word.", user "Say hello.", temperatura 0)
rodou contra os dois registros (duas últimas linhas), e a linha do Ollama se
repetiu em 3 de 3 execuções. Um provedor pode não informar model id nem uso:
tolere null.
Modos de falha
textclosed port 11999: System.Net.Http.HttpRequestException: Connection refused (localhost:11999) inner: System.Net.Sockets.SocketException: Connection refused unknown model: System.Net.Http.HttpRequestException: Response status code does not indicate success: 404 (Not Found). cancelled before: System.Threading.Tasks.TaskCanceledException: A task was canceled. cancelled 300 ms in: System.Threading.Tasks.TaskCanceledException: The operation was canceled. cancelled mid-stream: no exception: loop ended after 5 updates, aggregated Text='1 2 3' FinishReason=null Usage=null
Dois surpreendem. Para modelo desconhecido o Ollama responde
{"error":"model 'no-such-model:1b' not found"}, mas o OllamaSharp 5.5.0 lança
um 404 sem esse texto. E cancelar no meio do stream não lançou exceção em 3 de 3
execuções: o await foreach só terminou, e a resposta parcial parece completa
se o código não conferir FinishReason (null aqui, stop num fim real).
Trade-offs
- Abstração versus recursos do provedor.
ChatOptionstipa o que os provedores compartilham;AdditionalProperties,RawRepresentationFactoryeRawRepresentationalcançam o resto e prendem o código a um provedor. - Histórico sem estado custa tokens. Reenviar funciona em qualquer provedor,
mas a entrada cresce a cada turno. A documentação lista redutores
(
MessageCountingChatReducer,SummarizingChatReducer) como experimentais. - Acerto de cache parece chamada real. A chave exata torna acertos raros em
texto livre, e um acerto devolve o
Usageoriginal. Faça cache de prompts determinísticos (classificação, extração com temperatura 0), não de chat. - Cancelamento depende do provedor. O que um stream cancelado faz depende da
implementação; trate
FinishReasonnullcomo resposta parcial. - Antes versus hoje.
Microsoft.Extensions.AI.Ollama(última 9.7.0-preview) está deprecated no NuGet em favor do OllamaSharp. A documentação diz queIChatClienteIEmbeddingGeneratorsaíram do Semantic Kernel; use o MEAI.