Para a maioria dos casos em .NET, comece pelo Microsoft.Extensions.AI: o seu serviço depende só de IChatClient, a chamada de ferramentas sai do pipeline, e trocar de provedor ou testar sem rede vira configuração. Use o SDK do provedor quando precisar de um recurso do modelo que a abstração ainda não cobre, isolado atrás de um adaptador. Use o Semantic Kernel quando houver orquestração de verdade: vários passos, plugins, agentes. As três opções não são concorrentes; são camadas, e a pergunta certa é quanto de abstração você precisa.
O que eu comparei e o que é simulado
Fiz a mesma tarefa três vezes em .NET 10, com os pacotes conferidos no nuget.org em 23/10/2026: o SDK da OpenAI 2.14.0, o Microsoft.Extensions.AI 10.11.0 (com o Microsoft.Extensions.AI.OpenAI 10.11.0) e o Microsoft.SemanticKernel 1.81.0. Essas APIs mudam rápido, então confira as versões do seu projeto.
Um aviso antes de tudo: o modelo é simulado. Nenhuma chave foi usada e nenhum serviço pago foi chamado. Para as camadas que aceitam IChatClient, o modelo é um cliente falso e determinístico, escrito por mim. Para o SDK real da OpenAI rodar sem rede, subi um servidor HTTP local que responde no formato de chat da OpenAI. Toda saída começa com a linha SIMULATED model. No network, no API key. O que é real são os pacotes, os tipos, o pipeline e o dotnet test. Por isso, nada aqui mede qualidade de resposta, custo ou latência de um modelo real.
Três camadas, não três concorrentes
O SDK do provedor fala com a API de um fornecedor. É a camada mais baixa, com os tipos daquele fornecedor.
O Microsoft.Extensions.AI define interfaces comuns, principalmente o IChatClient, e um pipeline de middlewares no mesmo estilo do ASP.NET Core.
O Semantic Kernel é um framework maior, que orquestra plugins e agentes, e pode rodar em cima de um IChatClient. No exemplo, ele roda exatamente sobre o mesmo cliente das outras opções.
A tarefa: resumir um pedido com function calling
A tarefa é resumir um pedido. O texto enviado ao modelo é Order 1042: 3 items, total 349.90 USD. Summarize it and include the shipping status. O status de envio não está no texto: ele vem de uma função que o modelo precisa pedir (function calling, ou chamada de ferramenta). A função é código local, sem rede:
public static class OrderTools
{
[Description("Returns the shipping status of an order.")]
public static string GetShippingStatus(
[Description("The order id")] int orderId) =>
orderId == 1042
? "In transit, ETA 2 days"
: "Unknown order";
}SDK do provedor direto: controle total, tipos do fornecedor
Na primeira opção, o serviço usa o SDK real da OpenAI. A ferramenta é descrita à mão, com um esquema JSON, e o laço de chamada de ferramenta fica comigo: chamo o modelo, e se ele termina pedindo uma ferramenta, executo a função e devolvo o resultado numa mensagem de ferramenta.
public sealed class SdkOrderSummary(ChatClient chat)
{
static readonly ChatTool ShippingTool =
ChatTool.CreateFunctionTool(
"GetShippingStatus",
"Returns the shipping status of an order.",
BinaryData.FromString("""
{"type":"object",
"properties":{"orderId":{"type":"integer"}},
"required":["orderId"]}
"""));
public async Task<string> SummarizeAsync(Order order)
{
List<ChatMessage> messages =
[new UserChatMessage(order.Prompt)];
var options = new ChatCompletionOptions
{ Tools = { ShippingTool } };
while (true)
{
ChatCompletion done = (await chat.CompleteChatAsync(
messages, options)).Value;
if (done.FinishReason != ChatFinishReason.ToolCalls)
return done.Content[0].Text;
messages.Add(new AssistantChatMessage(done));
foreach (var call in done.ToolCalls)
{
using var args =
JsonDocument.Parse(call.FunctionArguments);
var id = args.RootElement
.GetProperty("orderId").GetInt32();
messages.Add(new ToolChatMessage(
call.Id, OrderTools.GetShippingStatus(id)));
}
}
}
}Repare nos tipos: ChatCompletion, ToolChatMessage, ChatTool. Todos do fornecedor, dentro do meu serviço. Contra o servidor local simulado, o SDK pediu a ferramenta, eu a executei, e a resposta final foi Simulated summary: order 1042 has 3 items, total 349.90 USD. Shipping: In transit, ETA 2 days.
O lado bom: os recursos do modelo ficam disponíveis no dia em que o SDK os publica. O custo: trocar de provedor, ou testar sem rede, vira refatoração.
Microsoft.Extensions.AI: IChatClient e um pipeline de middlewares
Na segunda opção, o serviço depende só de IChatClient. A ferramenta é a mesma função comum, registrada com AIFunctionFactory, sem esquema escrito à mão e sem laço:
public sealed class OrderSummaryService(IChatClient chat)
{
public async Task<string> SummarizeAsync(Order order)
{
var options = new ChatOptions
{
Tools = [AIFunctionFactory.Create(
OrderTools.GetShippingStatus)],
};
var response =
await chat.GetResponseAsync(order.Prompt, options);
return response.Text;
}
}Não há laço porque quem executa a ferramenta é o pipeline. Ele é montado com ChatClientBuilder: primeiro a telemetria, depois a invocação de funções e por último um middleware meu, de auditoria, que herda de DelegatingChatClient e registra cada ida ao modelo.
public static IChatClient Build(
IChatClient inner, Action<string> log) =>
new ChatClientBuilder(inner)
.UseOpenTelemetry(sourceName: "demo.ai")
.UseFunctionInvocation()
.Use(next => new AuditChatClient(next, log))
.Build();
public sealed class AuditChatClient(
IChatClient inner, Action<string> log)
: DelegatingChatClient(inner)
{
int round;
public override Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
round++;
log($"audit: model call {round}, tools offered: " +
$"{options?.Tools?.Count ?? 0}");
return base.GetResponseAsync(
messages, options, cancellationToken);
}
}O modelo falso é uma classe que implementa IChatClient: se há uma ferramenta oferecida e ainda não chegou o resultado dela, ele pede a ferramenta; senão, devolve o resumo. É o mesmo cliente que uso em teste e offline. Rodando, a auditoria viu duas chamadas ao modelo, a que pediu a ferramenta e a que resumiu (saída mais abaixo).
Trocar de provedor sem mexer no serviço
Para trocar de provedor, mantive o mesmo serviço e o mesmo pipeline e mudei só o cliente de dentro: primeiro o modelo falso, depois o SDK real da OpenAI, apontado para o servidor local e adaptado com AsIChatClient().
IChatClient[] providers =
[
new FakeChatClient(),
Providers.OpenAiSdk().AsIChatClient(),
];
foreach (var p in providers)
{
var chat = Pipeline.Build(p, _ => { });
var service = new OrderSummaryService(chat);
Console.WriteLine($"{p.GetType().Name,-18}: " +
await service.SummarizeAsync(order));
}
static class Providers
{
public static ChatClient OpenAiSdk() => new(
"simulated-model",
new ApiKeyCredential("simulated"),
new OpenAIClientOptions
{
Endpoint = new Uri(FakeOpenAiServer.Url + "/v1"),
});
}As saídas reais do pipeline, da troca de provedor e da telemetria (quebrei as linhas longas):
# pipeline com o modelo falso
audit: model call 1, tools offered: 1
audit: model call 2, tools offered: 1
meai : Simulated summary: order 1042 has 3 items,
total 349.90 USD. Shipping: In transit, ETA 2 days.
model calls: 2
# troca de provedor
FakeChatClient : Simulated summary: order 1042 has
3 items, total 349.90 USD.
Shipping: In transit, ETA 2 days.
OpenAIChatClient : Simulated summary: order 1042 has
3 items, total 349.90 USD.
Shipping: "In transit, ETA 2 days".
# telemetria
span : chat
gen_ai.response.finish_reasons = ["stop"]
gen_ai.response.model = simulated-model
gen_ai.usage.input_tokens = 28
gen_ai.usage.output_tokens = 24O serviço não mudou uma linha. Mas repare na diferença real: pelo adaptador da OpenAI, o resultado da ferramenta chega ao modelo serializado em JSON, com aspas. Abstração não significa igualdade perfeita entre provedores, e o teste de um não substitui o teste do outro.
Telemetria que sai do próprio pipeline
O UseOpenTelemetry gera um span com o modelo da resposta, o motivo de parada e o uso de tokens, nos atributos gen_ai.*. Como ele é o primeiro middleware, envolve a operação inteira, incluindo as duas idas ao modelo. Os tokens, aqui, são uma contagem simulada que o cliente falso calcula a partir das palavras do texto; servem para ver o encanamento, não para estimar custo.
O limite do Microsoft.Extensions.AI é o mesmo de toda abstração: ela mira o que é comum entre provedores. Um recurso muito específico de um modelo pode exigir descer até o SDK.
Semantic Kernel sobre o mesmo IChatClient
Na terceira opção aparece vocabulário novo. A função vira um plugin, com o atributo KernelFunction; o plugin é registrado num Kernel; e o comportamento de ferramenta é escolhido nas configurações de execução, com FunctionChoiceBehavior.Auto(). O Semantic Kernel roda sobre o meu IChatClient, e precisei do UseKernelFunctionInvocation para ele invocar as funções do kernel:
public sealed class ShippingPlugin
{
[KernelFunction,
Description("Returns the shipping status of an order.")]
public string GetShippingStatus(int orderId) =>
OrderTools.GetShippingStatus(orderId);
}
public sealed class KernelOrderSummary(
IChatClient chat, PromptExecutionSettings? settings = null)
{
public async Task<string> SummarizeAsync(Order order)
{
var builder = Kernel.CreateBuilder();
var withTools = chat.AsBuilder()
.UseKernelFunctionInvocation().Build();
builder.Services.AddSingleton(
withTools.AsChatCompletionService());
builder.Plugins.AddFromType<ShippingPlugin>("Shipping");
var kernel = builder.Build();
settings ??= new PromptExecutionSettings
{
FunctionChoiceBehavior =
FunctionChoiceBehavior.Auto(),
};
var history = new ChatHistory();
history.AddUserMessage(order.Prompt);
var answer = await kernel
.GetRequiredService<IChatCompletionService>()
.GetChatMessageContentAsync(
history, settings, kernel);
return answer.Content ?? "";
}
}O projeto suprime os avisos de API experimental SKEXP0001 e SKEXP0010 no .csproj, então parte desse caminho ainda é marcada como experimental. O resultado foi o mesmo resumo, com duas chamadas ao modelo. O Semantic Kernel tem bem mais do que isso (templates de prompt, agentes, integração com vetores), e eu não usei nada disso aqui. Para uma chamada com uma ferramenta, é demais; vale quando você orquestra vários passos.
Três armadilhas de configuração, antes e depois
Rodei cada armadilha duas vezes, no modelo simulado: a primeira versão e a corrigida.
Primeira: ferramenta oferecida, mas sem UseFunctionInvocation. O pipeline era só new ChatClientBuilder(new FakeChatClient()).Build(). Nenhuma exceção: o texto veio vazio, o motivo de parada foi pedido de ferramenta e sobrou uma chamada pendente. Com .UseFunctionInvocation(), o resumo volta e não sobra nada pendente.
Segunda: a ordem do pipeline. Coloquei .Use(next => new AuditChatClient(...)) antes de .UseFunctionInvocation(). A auditoria viu uma chamada, mas o modelo foi chamado duas vezes. O primeiro middleware da lista é o mais externo e enxerga só a operação inteira. Para contar cada ida ao modelo, a auditoria vai depois da invocação, mais perto do modelo. Para medir a operação inteira, a posição de fora é a certa: depende do que você quer medir.
Terceira: Semantic Kernel sem FunctionChoiceBehavior. Registrar o plugin não basta. Com um new PromptExecutionSettings() vazio, a ferramenta não foi oferecida ao modelo: o resumo saiu, mas sem o status de envio, numa chamada só. Com FunctionChoiceBehavior.Auto(), a função é chamada.
# armadilha 1, antes
text : ''
finish : tool_calls
pending: GetShippingStatus
# armadilha 1, depois
text : 'Simulated summary: order 1042 has 3 items,
total 349.90 USD. Shipping: In transit, ETA 2 days.'
finish : stop
pending: none
# armadilha 2, antes
audit: model call 1, tools offered: 1
real model calls: 2
# armadilha 2, depois
audit: model call 1, tools offered: 1
audit: model call 2, tools offered: 1
real model calls: 2
# armadilha 3, antes
Simulated summary: order 1042 has 3 items,
total 349.90 USD. Shipping: not available.
model calls: 1
# armadilha 3, depois
Simulated summary: order 1042 has 3 items,
total 349.90 USD. Shipping: In transit, ETA 2 days.
model calls: 2Um teste xUnit que protege o pipeline
Para não depender da memória, cinco testes xUnit protegem o pipeline com o modelo falso: o resultado da ferramenta chega ao resumo, um pedido desconhecido diz Unknown order, a ferramenta custa duas idas ao modelo, a auditoria vê todas elas, e o caminho do Semantic Kernel dá a mesma resposta.
static (OrderSummaryService, FakeChatClient, List<string>)
Setup()
{
var fake = new FakeChatClient();
var log = new List<string>();
var chat = Pipeline.Build(fake, log.Add);
return (new OrderSummaryService(chat), fake, log);
}
[Fact]
public async Task Audit_sees_every_model_round_trip()
{
var (service, fake, log) = Setup();
await service.SummarizeAsync(Orders.Sample);
Assert.Equal(fake.Calls, log.Count);
}Com o pipeline correto, os cinco passam. Depois troquei a ordem das duas linhas do pipeline, UseFunctionInvocation e a auditoria, e rodei de novo:
# pipeline correto
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5
# ordem trocada
PipelineTests.Audit_sees_every_model_round_trip [FAIL]
Assert.Equal() Failure: Values differ
Expected: 2
Actual: 1
Failed! - Failed: 1, Passed: 4, Skipped: 0, Total: 5A auditoria esperava duas chamadas e viu uma: a segunda armadilha foi pega pelo teste, antes de chegar à produção. Os outros quatro continuaram passando, e desfiz a troca.
O que este exemplo não mostra
Nenhum modelo real foi usado, então nada aqui fala de qualidade das respostas, custo ou latência. Também não mostrei streaming (o cliente falso nem implementa), nem agentes, memória e templates do Semantic Kernel, nem o Microsoft Agent Framework. E o SDK testado foi só o da OpenAI; o de outro provedor tem tipos próprios, que não testei.
Como eu escolho a camada
- Uma ou duas chamadas, um chat simples ou uma ferramenta, e teste fácil: Microsoft.Extensions.AI.
- Um recurso novo do modelo que a abstração ainda não cobre: SDK direto, isolado atrás de um adaptador.
- Vários passos, vários agentes, orquestração: Semantic Kernel.
Na dúvida, comece pelo IChatClient, que é o mais fácil de trocar depois. E uma regra, na minha opinião: escolha uma camada de entrada e deixe as outras escondidas atrás dela. Misturar as três é ter três jeitos de configurar, três de registrar log e três modelos falsos nos testes.
Resumo
- SDK do provedor, Microsoft.Extensions.AI e Semantic Kernel são camadas, não rivais.
- Comece pelo
IChatCliente pelo pipeline doChatClientBuilder; o serviço fica igual quando o provedor muda. - Trocar de provedor não é trocar de comportamento: no exemplo, o resultado da ferramenta chegou com aspas pelo adaptador da OpenAI.
- Cuidado com a invocação de funções, com a ordem do pipeline e com o
FunctionChoiceBehaviorno Semantic Kernel. - Deixe um teste com modelo falso protegendo tudo isso.
Se você quer ir além de uma ferramenta, leia também MCP em .NET, sobre expor ferramentas e dados a modelos com o Model Context Protocol, e RAG em .NET, que usa IChatClient para responder com base nos seus próprios dados. Se precisa de ajuda para escolher a camada e levar IA para produção, veja o serviço de backend .NET.
Referências
- Microsoft Learn: Microsoft.Extensions.AI
- Microsoft Learn: Introdução ao Semantic Kernel
- NuGet: Microsoft.Extensions.AI
- NuGet: Microsoft.Extensions.AI.OpenAI
- NuGet: Microsoft.SemanticKernel
- NuGet: OpenAI
