Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Guided course · AI and Career Skills for .NET Developers

    AI/LLM Integration in .NET: back to the course

    Module 1 · Foundations · Lesson 1 of 14

    The .NET AI Stack in 2026: Microsoft.Extensions.AI, Agent Framework and IChatClient

    Learning outcome

    You can place each piece of the current .NET AI stack (Microsoft.Extensions.AI, Microsoft.Extensions.VectorData, Microsoft Agent Framework, Semantic Kernel, provider SDKs) and say when to use which. You can call a chat model through IChatClient, read everything a ChatResponse returns, and explain why conversation memory is your job.

    Intuition

    IChatClient is to LLMs what ILogger is to logging: one small interface your code depends on, with providers plugged in at startup. Your endpoints, tests and middleware don't change when you switch from Azure OpenAI to OpenAI, Ollama or a fake. Higher layers (agents, RAG, evaluation) are built on the same interface.

    flowchart TD
        APP["your ASP.NET Core / worker code"] --> AF["Microsoft Agent Framework<br/>agents, sessions, workflows"]
        APP --> MEAI["Microsoft.Extensions.AI<br/>IChatClient, IEmbeddingGenerator, middleware"]
        AF --> MEAI
        APP --> VD["Microsoft.Extensions.VectorData<br/>vector store abstractions"]
        MEAI --> P1["Azure OpenAI / OpenAI"]
        MEAI --> P2["Ollama, other providers"]
        MEAI --> P3["FakeChatClient (this course)"]

    Deep dive

    • Microsoft.Extensions.AI (M.E.AI):
      • Microsoft.Extensions.AI.Abstractions defines IChatClient, IEmbeddingGenerator<TInput,TEmbedding>, ChatMessage, ChatOptions and the content types (text, function call/result, usage, data).
      • Microsoft.Extensions.AI adds the middleware: function invocation, caching, logging and OpenTelemetry.
      • Providers ship adapters, for example AsIChatClient() on an OpenAI or Azure OpenAI chat client.
      • This course uses version 10.10.
    • Microsoft Agent Framework (Microsoft.Agents.AI) reached 1.0 in April 2026. It is the successor to Semantic Kernel and AutoGen, built by the same teams, and it adds agents, sessions, multi-agent orchestration and MCP/A2A interop on top of IChatClient. Lesson 9 covers it; this course uses 1.24.
    • Semantic Kernel v1.x is still supported (critical and security fixes, for at least one year after Agent Framework GA). Keep existing SK apps running; start new agent work on Agent Framework. SK's vector store connectors now implement Microsoft.Extensions.VectorData and are still the connectors you use for RAG (Lesson 7).
    • Choosing a layer:
      • A single prompt, classification, extraction or summarization: IChatClient.
      • RAG over your documents: IChatClient, plus IEmbeddingGenerator, plus a vector store.
      • Multi-turn assistants with tools, persisted state or several cooperating agents: Agent Framework.
    • Conversation state: with a stateless client such as this fake, your code resends the selected history. Some IChatClient providers keep server-side state and return a ConversationId to pass in ChatOptions instead. History still contributes to model context and provider-dependent input charges (Lesson 11).
    • What comes back: ChatResponse holds:
      • Messages, with assistant and tool messages;
      • Text;
      • FinishReason (stop, length, tool_calls, content_filter);
      • Usage (input and output tokens);
      • ModelId;
      • provider metadata.

    Log the usage and the finish reason on every call. A length finish means a truncated answer.

    The fake model used in this course

    No API keys are used anywhere in this course. Every example runs against FakeChatClient, a real IChatClient implementation whose answers come from a small C# script, and HashingEmbeddingGenerator, a real IEmbeddingGenerator that hashes words. They are labelled FAKE in every program and output. Everything around them is the real library code you would ship: DI, middleware, function invocation, vector stores, agents and evaluators. Token counts from the fake are estimates (about 4 characters per token); a real provider may supply token counts in Usage; handle missing usage and provider-specific accounting.

    C#
    // FAKE, DETERMINISTIC AI SERVICES FOR TEACHING. No model, no network, no API key.
    // FakeChatClient implements the real Microsoft.Extensions.AI IChatClient interface, but its "intelligence" is a
    // script you pass in. HashingEmbeddingGenerator implements the real IEmbeddingGenerator interface with a
    // bag-of-words hashing trick, so similar wording gives similar vectors. Swap either for a real provider
    // (Azure OpenAI, OpenAI, Ollama, ...) by changing one registration; the calling code stays the same.
    using System.Runtime.CompilerServices;
    using System.Text.RegularExpressions;
    using Microsoft.Extensions.AI;
    
    namespace FakeAI;
    
    public delegate ChatMessage Script(IReadOnlyList<ChatMessage> messages, ChatOptions? options);
    
    public sealed class FakeChatClient(Script script, string modelId = "fake-model-1") : IChatClient
    {
        public int Calls { get; private set; }
    
        public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken ct = default)
        {
            ct.ThrowIfCancellationRequested();
            Calls++;
            var list = messages.ToList();
            var reply = script(list, options);
            bool toolCall = reply.Contents.OfType<FunctionCallContent>().Any();
            return Task.FromResult(new ChatResponse(reply)
            {
                ModelId = modelId,
                FinishReason = toolCall ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop,
                Usage = new UsageDetails { InputTokenCount = Tokens.Estimate(list), OutputTokenCount = Tokens.Estimate([reply]), TotalTokenCount = Tokens.Estimate(list) + Tokens.Estimate([reply]) },
            });
        }
    
        public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken ct = default)
        {
            var response = await GetResponseAsync(messages, options, ct);
            var msg = response.Messages[^1];
            foreach (var c in msg.Contents.Where(c => c is not TextContent)) yield return new ChatResponseUpdate(msg.Role, [c]) { ModelId = modelId };
            foreach (Match w in Regex.Matches(msg.Text, @"\S+\s*"))      // one update per word, like token-by-token streaming
            {
                ct.ThrowIfCancellationRequested();
                await Task.Yield();
                yield return new ChatResponseUpdate(msg.Role, w.Value) { ModelId = modelId };
            }
            yield return new ChatResponseUpdate { FinishReason = response.FinishReason, Contents = [new UsageContent(response.Usage!)], ModelId = modelId };
        }
    
        public object? GetService(Type serviceType, object? serviceKey = null) =>
            serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("fake", null, modelId) :
            serviceType.IsInstanceOfType(this) ? this : null;
        public void Dispose() { }
    }
    
    public static class Tokens
    {
        /// <summary>FAKE token estimate: about 4 characters per token, the usual English rule of thumb. Real counts come from the provider's Usage.</summary>
        public static int Estimate(IEnumerable<ChatMessage> messages) => messages.Sum(m => (m.Text.Length + 3) / 4 + 4);
        public static int Estimate(string text) => (text.Length + 3) / 4;
    }
    
    public sealed class HashingEmbeddingGenerator(int dimensions = 256) : IEmbeddingGenerator<string, Embedding<float>>
    {
        public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken ct = default)
        {
            var result = new GeneratedEmbeddings<Embedding<float>>(values.Select(v => new Embedding<float>(Embed(v)) { ModelId = "fake-hashing-embeddings" }));
            return Task.FromResult(result);
        }
        float[] Embed(string text)
        {
            var v = new float[dimensions];
            foreach (var word in Words(text)) v[(int)(Fnv(word) % (uint)dimensions)] += 1f;   // stable hash, not string.GetHashCode()
            var norm = MathF.Sqrt(v.Sum(x => x * x));
            if (norm > 0) for (int i = 0; i < v.Length; i++) v[i] /= norm;                    // unit length: cosine = dot product
            return v;
        }
        public static IEnumerable<string> Words(string text) =>
            Regex.Matches(text.ToLowerInvariant(), "[a-z0-9]+").Select(m => m.Value).Where(w => w.Length > 2 && !Stop.Contains(w)).Select(Stem);
        static string Stem(string w) => w.Length > 4 && w.EndsWith('s') ? w[..^1] : w;
        static readonly HashSet<string> Stop = ["the", "and", "for", "are", "with", "that", "this", "you", "can", "how", "what", "does", "from", "your", "not", "but", "has", "have", "into", "when", "which", "its", "our", "use", "was", "will"];
        static uint Fnv(string s) { uint h = 2166136261; foreach (char c in s) { h ^= c; h *= 16777619; } return h; }
        public object? GetService(Type serviceType, object? serviceKey = null) =>
            serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata("fake", null, "fake-hashing-embeddings", dimensions) :
            serviceType.IsInstanceOfType(this) ? this : null;
        public void Dispose() { }
    }

    Code example: IChatClient through DI, history, options

    C#
    // IChatClient basics with a FAKE, scripted client (no model, no key). The calling code is what you would
    // write against Azure OpenAI, OpenAI or Ollama; only the registration line changes.
    using FakeAI;
    using Microsoft.Extensions.AI;
    using Microsoft.Extensions.DependencyInjection;
    
    // The "model": a script that answers from a tiny FAQ and remembers the user's name from history.
    Script faq = (messages, options) =>
    {
        var last = messages[^1].Text.ToLowerInvariant();
        var name = messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)
                           .Select(t => t.StartsWith("I am ") ? t[5..].TrimEnd('.') : null).LastOrDefault(n => n is not null);
        string text = last.Contains("refund") ? "Refunds are issued to the original payment method within 5 business days."
                    : last.Contains("my name") ? (name is null ? "You have not told me your name." : $"Your name is {name}.")
                    : last.StartsWith("i am ") ? "Nice to meet you."
                    : "I can answer questions about refunds.";
        if (options?.Temperature is float t) text += $" (temperature {t})";
        return new ChatMessage(ChatRole.Assistant, text);
    };
    
    var services = new ServiceCollection();
    services.AddChatClient(new FakeChatClient(faq));          // real app: new AzureOpenAIClient(...).GetChatClient("deployment").AsIChatClient()
    using var sp = services.BuildServiceProvider();
    var chat = sp.GetRequiredService<IChatClient>();
    
    // 1. One call, and everything that comes back besides the text.
    ChatResponse r = await chat.GetResponseAsync("How long do refunds take?");
    Console.WriteLine($"text:          {r.Text}");
    Console.WriteLine($"model:         {r.ModelId}");
    Console.WriteLine($"finish reason: {r.FinishReason}");
    Console.WriteLine($"usage:         in={r.Usage?.InputTokenCount} out={r.Usage?.OutputTokenCount} total={r.Usage?.TotalTokenCount} (fake estimate)");
    Console.WriteLine($"metadata:      provider={chat.GetService<ChatClientMetadata>()?.ProviderName} defaultModel={chat.GetService<ChatClientMetadata>()?.DefaultModelId}");
    
    // 2. The client is stateless: conversation memory is the list of messages YOU send each time.
    List<ChatMessage> history = [new(ChatRole.System, "You are a support assistant for Contoso. Answer briefly.")];
    foreach (var user in new[] { "I am Priya.", "What is my name?" })
    {
        history.Add(new(ChatRole.User, user));
        var reply = await chat.GetResponseAsync(history);
        history.AddMessages(reply);                               // append the assistant message(s) to the history
        Console.WriteLine($"user: {user,-18} assistant: {reply.Text}   [messages sent: {history.Count - 1}, input tokens ~{reply.Usage?.InputTokenCount}]");
    }
    var fresh = await chat.GetResponseAsync("What is my name?");
    Console.WriteLine($"new conversation, same question: {fresh.Text}");
    
    // 3. ChatOptions travel with each request.
    var opt = await chat.GetResponseAsync("Tell me about refunds", new ChatOptions { Temperature = 0.2f, MaxOutputTokens = 200 });
    Console.WriteLine($"with options: {opt.Text}");
    Console.WriteLine($"history roles: {string.Join(", ", history.Select(m => m.Role.Value))}");

    Output (.NET 10, Microsoft.Extensions.AI 10.10.0, FAKE model)

    Text
    text:          Refunds are issued to the original payment method within 5 business days.
    model:         fake-model-1
    finish reason: stop
    usage:         in=11 out=23 total=34 (fake estimate)
    metadata:      provider=fake defaultModel=fake-model-1
    user: I am Priya.        assistant: Nice to meet you.   [messages sent: 2, input tokens ~25]
    user: What is my name?   assistant: Your name is Priya.   [messages sent: 4, input tokens ~42]
    new conversation, same question: You have not told me your name.
    with options: Refunds are issued to the original payment method within 5 business days. (temperature 0.2)
    history roles: system, user, assistant, user, assistant

    Code walkthrough

    • AddChatClient registered the fake as IChatClient. In production that line becomes a provider client with .AsIChatClient(), and nothing else in the file changes.
    • One call returned text, model: fake-model-1, finish reason: stop and usage in=11 out=23 total=34. These are fake estimates; with a real provider, use the reported usage when available and its pricing rules to estimate charges.
    • GetService<ChatClientMetadata>() exposed the provider name and default model. It is useful for logging which model answered.
    • In the history loop the second question was answered ("Your name is Priya.") only because 4 messages were sent, including the first exchange. The input estimate grew from about 25 to about 42 tokens.
    • The same question in a new conversation got "You have not told me your name.": the client keeps no state.
    • ChatOptions (temperature 0.2, max output tokens 200) travelled with that one request; the script echoed the temperature to show it arrived.
    • AddMessages(reply) appended the assistant reply, so the history roles ended as system, user, assistant, user, assistant.

    Failure modes

    • Writing directly against one provider SDK everywhere, so switching models or adding caching or telemetry means touching every call site.
    • Assuming the model remembers: a bot that forgets the user's name, or one that resends an unbounded history and gets slower and more expensive every turn.
    • Ignoring FinishReason.Length: answers silently cut off at MaxOutputTokens.
    • Starting new agent projects on Semantic Kernel's agent APIs without checking the current status. The successor (Agent Framework 1.x) is GA.
    • Committing API keys to appsettings.json. Use managed identity (Entra ID) for Azure OpenAI, or user-secrets and Key Vault.

    Interview angle

    "How would you add an LLM feature to our .NET API?" Answer: "Depend on IChatClient from Microsoft.Extensions.AI, register the provider in DI (Azure OpenAI with managed identity), and compose middleware for telemetry, caching and function invocation. Agent Framework only if we need agents with tools and persisted sessions. Log usage and finish reason per call. Write tests against a fake IChatClient, so CI never calls a paid model."

    Practice questions

    1. What does IChatClient give you that a provider SDK does not?

    Answer: A provider-neutral abstraction with a shared middleware ecosystem (function calling, caching, telemetry). You can also swap providers, or use a fake in tests, without changing call sites.

    1. Where does conversation memory live?

    Answer: For a stateless client, in the messages your code resends (possibly managed by an agent session). Stateful providers can retain it server-side and use ChatResponse.ConversationId / ChatOptions.ConversationId.

    1. Semantic Kernel or Agent Framework for a new agent project in 2026?

    Answer: Agent Framework, the GA successor. SK v1.x stays supported for existing apps for at least a year after Agent Framework GA.

    1. A response has FinishReason = length. What happened?

    Answer: The output hit the token limit and was truncated. Raise MaxOutputTokens, ask for a shorter answer, or continue the generation.

    Revision checklist

    • M.E.AI: IChatClient, IEmbeddingGenerator, middleware.
    • Agent Framework 1.x (GA April 2026) is the successor to SK and AutoGen; SK v1.x is in support mode.
    • Stateless clients resend history; stateful providers can use a conversation id. Context usage and pricing still matter.
    • Log Usage, FinishReason and ModelId.
    • Tests and CI use a fake IChatClient.

    Analogy

    Everyday picture

    A universal power adapter

    IChatClient is the adapter in your bag. The laptop (your code) has one plug; at the hotel you change only the adapter head (the provider). A dummy plug that just lights an LED (the fake) is how you test the laptop without a socket.

    Quick reference

    Which layer

    • One prompt or extraction: IChatClient.
    • RAG: IChatClient + IEmbeddingGenerator + VectorData connector.
    • Agents, sessions, multi-agent: Agent Framework.
    • Existing SK apps: keep them; migrate when it pays off.
    • Always: log usage, finish reason and model.
    Sign in to mark lessons done and keep your place in the course.Sign in