Skip to main content

Capabilities and limits

Hallucination, staleness and non-determinism as engineering constraints — and the .NET patterns that contain them.

beginner17 min.NET 10.0

Everything so far has been mechanism. This lesson is the engineering: what a model is reliable at, the specific ways it fails, and what you build around it before it is allowed near a user. If you take one thing from this course into a code review, take this lesson.

What models do well

There is a single heuristic that predicts reliability better than any benchmark:

The closer the answer already is to the input, the more reliable the call.

Transformation of text you supplied is the strong case. Recall of facts you did not supply is the weak one — that is the lossy-compression property from lesson 1, and no amount of prompting repairs it.

TaskReliabilityWhy
Summarise, rewrite, translate supplied textHighThe answer is in the input
Extract fields into a schemaHighRecognition, and you can validate it
Classify into a fixed set of labelsHighBounded output space
Draft prose from supplied notesHighStyle, not fact
Explain a concept in general termsMediumCommon in training, but no provenance
Write code against a widely-used APIMediumPlausible signatures are not real ones
Recall a version number, a price, a citationLowSpecifics are exactly what lossy recall loses
Arithmetic, counting characters, sorting long listsLowTokenisation, lesson 2

The design move that follows is the same every time: shorten the distance between input and answer. Retrieve the document and let the model summarise it rather than asking what the document says. Pass the price from your database rather than asking for it. Do the arithmetic in C#. Almost every serious AI feature is a conventional system that gathers facts, with a model doing the last step of expressing them.

Failure modes

. The model produces a fluent, confident, specific, wrong answer — an API that does not exist, a section number that was never written, a citation to a plausible paper. There is no internal signal distinguishing this from a correct answer, because there is no distinction inside the mechanism: both are high-probability continuations. Fluency is not evidence, and self-reported confidence is just more generated text.

Staleness. Weights are frozen at training time. Anything newer — a release from last month, your codebase, today's price — is unknown unless you put it in the prompt.

. Every token in the context is treated alike, so text you retrieved or a user pasted can carry instructions. This is not a filtering problem you can solve with a blocklist; it is the direct consequence of instructions and data sharing one channel. The mitigations are architectural: give the model the least privilege that works, never let its output authorise an action on its own, and treat everything it emits as untrusted input — never concatenate it into SQL, a shell command, a file path or raw HTML.

Silent truncation. Hit MaxOutputTokens mid-sentence and you get a well-formed HTTP 200 containing a partial answer. Check response.FinishReason rather than assuming completion.

Non-determinism. Two identical calls can return different text, and 0 narrows that without closing it — Temperature, sampling and determinism is the whole of that story, and the reason the controls below assert on properties rather than on strings.

Containing them in production

Ask for a shape, then validate it. Free text is unverifiable; a schema is not. is the containment that does the most work per line of code. Microsoft.Extensions.AI has a generic overload that requests structured output and deserialises the reply.

ChangelogSummariser.cs
using System.ComponentModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
 
public sealed class ChangelogSummary
{
    [Description("One sentence, under 100 characters.")]
    public required string Headline { get; init; }
 
    [Description("Three to five user-visible changes.")]
    public required IReadOnlyList<string> Highlights { get; init; }
 
    public required bool ContainsBreakingChange { get; init; }
}
 
public sealed class ChangelogSummariser(IChatClient client, ILogger<ChangelogSummariser> logger)
{
    private static readonly ChatOptions Options = new() { Temperature = 0f, MaxOutputTokens = 500 };
 
    public async Task<ChangelogSummary?> SummariseAsync(string changelog, CancellationToken ct)
    {
        ChatResponse<ChangelogSummary> response =
            await client.GetResponseAsync<ChangelogSummary>(changelog, Options, cancellationToken: ct);
 
        // An unparseable response is a failed call, not a partial success.
        if (!response.TryGetResult(out ChangelogSummary? summary))
        {
            logger.LogWarning("Model output rejected. Raw: {Raw}", response.Text);
            return null;
        }
 
        // The schema constrains the shape. It does not constrain the content —
        // domain rules are still yours to enforce.
        return summary.Highlights.Count is >= 3 and <= 5 ? summary : null;
    }
}

The [Description] attributes are not comments: they are serialised into the schema the model is given, so they are prompt text with a compiler checking where they hang.

Bound the output space wherever you can. The routing example in Embeddings vs completions is the pattern — when the answer must be one of n known things, make the model's job a selection rather than a generation, so an invalid answer is unrepresentable.

answers in text you supplied, and require citations back into it, so a claim without a source is a rejectable output rather than a judgement call. That is the whole subject of the RAG course later in this track.

Put a ceiling on the call. The model has no idea what your budget is, so the guard belongs in the pipeline:

BudgetGuardChatClient.cs
using Microsoft.Extensions.AI;
using Microsoft.ML.Tokenizers;
 
/// <summary>Rejects a request that would exceed a per-call input ceiling.</summary>
public sealed class BudgetGuardChatClient(
    IChatClient innerClient,
    Tokenizer tokenizer,
    int maxInputTokens) : DelegatingChatClient(innerClient)
{
    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        int total = messages.Sum(message => tokenizer.CountTokens(message.Text ?? string.Empty));
        if (total > maxInputTokens)
        {
            throw new InvalidOperationException(
                $"Prompt is {total} tokens; the ceiling is {maxInputTokens}.");
        }
 
        return base.GetResponseAsync(messages, options, cancellationToken);
    }
}

Registered alongside the rest of the pipeline, with timeouts and retries handled where they belong — at the transport:

Program.cs
builder.Services
    .AddChatClient(provider)
    .UseDistributedCache()   // identical prompt + options → no second call
    .UseLogging()
    .UseOpenTelemetry()
    .Use(inner => new BudgetGuardChatClient(inner, tokenizer, maxInputTokens: 8_000));
 
// Model endpoints are slow and occasionally unavailable. Give them a real
// resilience policy rather than the default HttpClient timeout.
builder.Services.AddHttpClient("ai").AddStandardResilienceHandler();

Test properties, not strings. Since output varies, assert the things that must hold: the response deserialises; every cited id exists in the supplied context; Highlights has three to five entries; no answer exceeds the length budget; a question with no supporting context produces a refusal. Run those over a fixed set of recorded inputs and treat a drop in pass rate as a regression — that is what the Microsoft.Extensions.AI.Evaluation packages automate, and it is a course of its own.

Instrument in tokens and money. UseOpenTelemetry() gives you token counts and durations per call; put an alert on cost per request and on the rejection rate from your validators. A rising rejection rate is usually the first visible sign that a provider silently updated a model.

Keep a human where being wrong is expensive. Not as a disclaimer in the footer, but as a step in the workflow: a draft to approve, a suggestion to accept, an action that needs a click. Everything above reduces the rate of bad output. Nothing reduces it to zero, and designing as if it did is the single most common way an AI feature goes wrong in production.

That is the course. You now know what the thing does, what it costs, how it breaks, and roughly what has to sit around it. The rest of this track is depth on each of those — starting with prompts as code rather than folklore.

Check yourself

5 questions about judgement calls from this lesson, 4 of them to pass. Answers are kept in this browser only, and passing does not mark the lesson complete — that stays the button below.

  1. Question 1 of 5A grounded summariser returns valid JSON in which `citationId` names a document that was never in the context. Which control turns that into a detected failure?Choose one answer.
  2. Question 2 of 5A retrieval feature reads documents users can upload. Which mitigations against prompt injection are worth the code? Select all that apply.Choose every answer that applies.
  3. Question 3 of 5True or false: an HTTP 200 from a chat call means the model finished its answer.Choose one answer.
  4. Question 4 of 5Put these tasks in order, from the most reliable use of a model to the least.Use the arrow buttons to put these in order.
    1. Extract named fields from that document into a schema.
    2. Recall the version number in which a library changed a method signature.
    3. Explain a well-known concept in general terms.
    4. Summarise a document you supplied in the prompt.

  5. Question 5 of 5Your validators have been rejecting 2% of responses for months. This week it is 9%, with no deploy on your side. What is the first hypothesis?Choose one answer.

Your score appears here. Answers are stored in this browser, so they follow the browser and not you.

Practise it

You write this one locally, in your own editor. Nothing here runs or reads your code.

Make a fabrication detectable

Write a grounded summariser that is hard to lie to. It takes a handful of documents with ids, asks for structured output with a headline, three to five highlights and a citation id per highlight, and then rejects the response unless every cited id was actually supplied, the counts are in range and FinishReason says the model finished. Wrap the client in a delegating client that refuses a request over a token ceiling. Then test it as a property suite over recorded fixtures, including one where the answer is not in the documents at all and a refusal is the only passing outcome — and one where the fixture cites a document that does not exist, which your validator must catch.

In the repository
exercises/ai-fundamentals/06-grounded-summariser
Verify with
dotnet test exercises/ai-fundamentals/06-grounded-summariser

Self-reported and not verified. Nothing runs your code or reads your repository — this records what you say you did, in this browser.

Your notes

Plain text, saved in this browser as you type. Nothing is uploaded, and there is no account to sync it to — export from your profile page to carry it elsewhere.