Chapter 20: Capstone - Building a Private, Offline Coding Assistant
Theoretical Foundations
The architecture of a private, offline coding assistant represents a paradigm shift from the ubiquitous cloud-centric AI models we've explored previously. To understand this shift, we must first dissect the fundamental tension in modern AI application development: the trade-off between capability, privacy, and latency. In previous chapters, specifically Book 8, Chapter 18: 'Consuming Cloud Intelligence,' we focused on orchestrating remote Large Language Models (LLMs) via HTTP clients, handling authentication, and parsing JSON responses. That architecture relies on a high-trust, high-bandwidth connection to external endpoints like OpenAI or Azure AI. The Capstone project in Book 9 inverts this entirely. We are moving the "brain" of the application from a distributed data center to the user's local machine.
This theoretical foundation rests on three pillars: Local Inference via ONNX, Retrieval-Augmented Generation (RAG), and Asynchronous Token Streaming.
The Local Inference Paradigm: ONNX Runtime
The core mechanism enabling this privacy-centric approach is the Open Neural Network Exchange (ONNX) Runtime. To understand why this is critical, we must look at the friction points of traditional Deep Learning frameworks.
Historically, deploying a model meant deploying the framework. If a model was trained in PyTorch, the inference environment needed PyTorch. If it was trained in TensorFlow, it needed TensorFlow. This created a "dependency hell" and a massive memory footprint, often measured in gigabytes. Furthermore, these frameworks were designed for training—optimizing for gradient calculations and weight updates—which is computationally expensive and unnecessary for inference (the act of simply asking the model a question).
ONNX solves this by acting as a universal translator. It is an open-source standard that represents machine learning models in a high-level, intermediate representation (IR). When a model like Phi-3 or Llama 3.2 is converted to ONNX, it is "frozen" into a graph of mathematical operations. This graph is hardware-agnostic.
The Execution Provider (EP) Abstraction: This is where the "Edge" in Edge AI comes alive. The ONNX Runtime is not just a runner; it is an orchestrator that utilizes Execution Providers. Think of the ONNX Runtime as a general contractor building a house. The model (the blueprint) dictates what needs to be built. The Execution Providers are the specialized subcontractors.
- CPU EP: The generalist. It can run the model on any standard processor, but it translates neural network operations into generic scalar math. It is slow but universally compatible.
- CUDA/ROCm (GPU) EP: The specialist. It offloads the massive matrix multiplications inherent in LLMs to the thousands of parallel cores on a discrete graphics card. This is the difference between a 500ms response and a 50ms response.
- DirectML (Windows) / CoreML (macOS): These leverage the specialized silicon found in modern laptops (NPUs - Neural Processing Units) or the unified memory architecture of Apple Silicon.
In our C# application, we do not interact with these hardware nuances directly. We configure the SessionOptions object, which acts as the negotiation layer. We tell the Runtime, "I want to use the GPU if available, otherwise fall back to CPU." This ensures the application is portable across different user hardware without recompilation.
The Quantization Factor: To run these models locally, we must address the "Size vs. Intelligence" trade-off. A raw LLM might require 20GB+ of VRAM. To make this feasible on a consumer laptop, we use Quantization. This is the process of reducing the precision of the model's weights (e.g., from 16-bit floating-point to 4-bit integers). The analogy is a high-resolution photograph versus a JPEG. The JPEG is significantly smaller and loads faster, but to the naked eye (or in this case, the inference engine), the essential information is preserved. In this chapter, we will utilize models quantized to INT8 or INT4, allowing a capable model to fit into 4GB of RAM.
Retrieval-Augmented Generation (RAG) with ML.NET
While the ONNX model provides the "reasoning" capability, it suffers from a fundamental limitation: Static Knowledge. A model trained on data up to a specific date cannot answer questions about your local codebase, your company's internal libraries, or recent changes to a project.
This is where Retrieval-Augmented Generation (RAG) enters the architecture. RAG is not a model; it is a system design pattern. It bridges the gap between the vast, general knowledge of the LLM and the specific, private context of the user.
The Library Analogy:
Imagine the LLM is a brilliant, well-read librarian who has memorized every book published before 2023 but has never been inside your specific library building. If you ask, "How do I use the CalculateTax method in our internal Finance.dll?", the librarian will hallucinate an answer based on general tax laws.
RAG changes the workflow:
- Ingestion (The Indexing): Before the user asks a question, we scan their local source code. We don't feed the raw code to the LLM (it's too long). Instead, we use an embedding model (a small, fast neural network) to convert code snippets into Vectors—lists of numbers that represent the semantic meaning of the text.
- Storage: These vectors are stored in a Vector Database. In our C# ecosystem, we often use ML.NET or lightweight libraries like
Microsoft.ML.TokenizersandKnnSharpto handle this locally without needing a heavy database server like Pinecone or Weaviate. - Retrieval (The Search): When the user asks a question, we convert that question into a vector using the same embedding model. We then perform a mathematical search (Cosine Similarity) to find the code snippets in our local database that are "closest" to the question's intent.
- Augmentation (The Prompt Engineering): We take the retrieved code snippets and inject them into the system prompt sent to the LLM. The prompt effectively changes from:
- Original: "Explain how to calculate tax."
- Augmented: "Context: Here is the code for
CalculateTaxinFinance.dll. [Code Block]. Question: Explain how to calculate tax."
This technique grounds the LLM, reducing hallucinations and allowing it to answer questions about private data without that data ever leaving the machine.
Asynchronous Streaming and UI Responsiveness
The final theoretical pillar concerns the User Experience (UX). LLMs are autoregressive; they generate text one token (roughly a word or part of a word) at a time. If we wait for the model to generate a full response of 500 tokens before displaying anything, the application will appear frozen for several seconds. This breaks the illusion of a "live" assistant.
We rely heavily on C#'s asynchronous programming model (async/await) and specifically IAsyncEnumerable<T>. This allows the application to establish a continuous stream of data from the ONNX Runtime to the UI layer.
The Waterfall Analogy: Imagine filling a swimming pool.
- Synchronous: You turn on the tap, block the exit, and wait for the entire pool to fill. Then, you open the exit and let the water flow out to the user. The user gets a massive rush of water, but they had to wait a long time with nothing.
- Asynchronous Streaming: You turn on the tap. The water flows through a pipe directly to the user. The user gets a steady trickle immediately. As more water is generated, the user receives it instantly.
In C#, this is implemented via Channels (System.Threading.Channels). We create a ChannelWriter that the inference engine writes tokens to as they are generated. Simultaneously, a ChannelReader listens in the UI thread. Because channels are thread-safe and designed for high-performance producer/consumer scenarios, we can update the UI with new text blocks without blocking the main execution thread.
Architectural Flow Visualization
The following diagram illustrates the data flow through the three pillars described above. Notice how the "Private Data" loop never intersects with the "Internet" boundary.
Modern C# Features in AI Architecture
To build this robustly, we leverage specific C# features that are essential for managing the complexity of AI integrations.
1. Interfaces for Abstraction (The Strategy Pattern):
We never hard-code a specific model. We define an ILanguageModel interface. This allows us to swap between a local ONNX model and a cloud model (like GPT-4) for comparison testing without changing the core application logic.
public interface ILanguageModel
{
IAsyncEnumerable<string> CompleteAsync(string prompt, CancellationToken ct);
}
2. Records for Data Transfer:
AI interactions are defined by complex configurations. We use record types for immutable configuration objects, such as InferenceSettings or RagParameters. This prevents accidental mutation of settings during the inference lifecycle.
3. Channels for High-Performance Streaming:
As mentioned, System.Threading.Channels is superior to BlockingCollection or Observable patterns for this specific use case because it is allocation-free and supports async/await natively.
var channel = Channel.CreateUnbounded<string>();
// Writer in inference loop
await channel.Writer.WriteAsync(token);
// Reader in UI loop
await foreach (var token in channel.Reader.ReadAllAsync())
{
// Update UI
}
4. Spanstring.Substring creates memory copies, which is inefficient. Modern C# allows us to use Span<char> to parse and tokenize code snippets without allocating new memory on the heap. This is critical when scanning thousands of files for RAG indexing.
Summary of the "Why"
We are building this specific architecture because it solves the "Data Exfiltration" problem. In a corporate environment, sending proprietary source code to a cloud API is often a compliance violation (GDPR, HIPAA, IP protection). By using ONNX to run models locally and ML.NET to perform RAG on local files, we create a closed loop. The data enters the application, is processed by the model, and the result is displayed, all within the memory space of the user's machine. This is the only viable path for AI-assisted coding in high-security environments.
Basic Code Example
Here is a basic "Hello World" example for running a local ONNX LLM (Phi-3 Mini) using C# and the Microsoft.ML.OnnxRuntime library. This example demonstrates the fundamental pattern of loading a model, preparing inputs, running inference, and decoding the output tokens.
The Real-World Context
Imagine you are building a tool for a secure environment where data cannot leave the premises (e.g., a bank, a hospital, or a government facility). You need an AI assistant to help with simple coding tasks, but you cannot use cloud APIs like OpenAI due to privacy regulations. This example solves that by loading a small, efficient language model (Phi-3) directly from your hard drive and running it entirely on your local CPU or GPU.
The Code
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
// This example demonstrates running a local ONNX model (Phi-3 Mini) for text generation.
// Prerequisites:
// 1. Install NuGet package: Microsoft.ML.OnnxRuntime
// 2. Download a Phi-3 Mini ONNX model (e.g., from Hugging Face) and place it in a folder named "models".
// Ensure you have the 'tokenizer.json' in the same folder for proper token decoding.
public class LocalLlmInference
{
public static void Main()
{
Console.WriteLine("Initializing Local AI Assistant...");
// Path to the ONNX model file.
// Note: In a real app, use Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "models", "phi-3-mini.onnx")
string modelPath = "models/phi-3-mini.onnx";
if (!File.Exists(modelPath))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Model file not found at: {modelPath}");
Console.WriteLine("Please download a Phi-3 Mini ONNX model to proceed.");
Console.ResetColor();
return;
}
// 1. Initialize the Inference Session
// We use 'using' to ensure resources are disposed of correctly.
using var session = new InferenceSession(modelPath);
// 2. Prepare the Input
// For this example, we will manually tokenize a simple prompt.
// In a production app, you would use the Microsoft.ML.OnnxRuntime.Extensions NuGet package
// to load 'tokenizer.json' and handle tokenization automatically.
// "What is 2 + 2?" (Prompt token IDs for Phi-3 Mini - simplified for example)
// Note: Real tokenization requires a tokenizer library. Here we simulate the input tensor.
// We need to construct the 'input_ids' tensor.
// Shape: [batch_size, sequence_length]
// For Phi-3, the input shape is usually [1, sequence_length].
// Let's create a dummy input for demonstration.
// In a real scenario, you'd tokenize "What is 2 + 2?" into integers.
// Example token IDs for "What is 2 + 2?" (approximate for Phi-3):
// <s> (1), What (1867), is (318), 2 (17), + (337), 2 (17), ? (30)
long[] inputIds = [1, 1867, 318, 17, 337, 17, 30];
// Attention mask (usually all 1s for valid tokens)
long[] attentionMask = [1, 1, 1, 1, 1, 1, 1];
// Position IDs (usually 0 to sequence_length-1)
long[] positionIds = [0, 1, 2, 3, 4, 5, 6];
// Convert arrays to Tensors
var inputIdsTensor = new DenseTensor<long>(inputIds, [1, inputIds.Length]);
var attentionMaskTensor = new DenseTensor<long>(attentionMask, [1, attentionMask.Length]);
var positionIdsTensor = new DenseTensor<long>(positionIds, [1, positionIds.Length]);
// 3. Create NamedOnnxValue inputs
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor),
NamedOnnxValue.CreateFromTensor("attention_mask", attentionMaskTensor),
NamedOnnxValue.CreateFromTensor("position_ids", positionIdsTensor)
};
// 4. Run Inference
Console.WriteLine("Running inference...");
// We use 'Run' to execute the model.
// The output name "logits" is specific to the model architecture.
using var results = session.Run(inputs);
// 5. Process the Output
// The model outputs 'logits' (raw scores for the next token).
// We need to find the token with the highest score (Greedy Search).
var logitsTensor = results.First().AsTensor<float>();
// Shape is [batch_size, sequence_length, vocab_size]
// We look at the last token position (the prediction for the next token).
int vocabSize = logitsTensor.Dimensions[2];
int lastTokenIndex = inputIds.Length - 1; // Index of the last input token
// Extract logits for the last token
float[] lastTokenLogits = new float[vocabSize];
for (int i = 0; i < vocabSize; i++)
{
// Accessing tensor data: [batch=0, sequence_position=lastTokenIndex, vocab_index=i]
lastTokenLogits[i] = logitsTensor[0, lastTokenIndex, i];
}
// Find the index of the maximum value (ArgMax)
int predictedTokenId = Array.IndexOf(lastTokenLogits, lastTokenLogits.Max());
// 6. Decode the Output (Simulated)
// In a real app, you would feed this 'predictedTokenId' back into the model
// repeatedly (autoregressive generation) until you hit a stop token.
// Here, we just print the single predicted token ID.
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\nInput Prompt (Token IDs): {string.Join(", ", inputIds)}");
Console.WriteLine($"Predicted Next Token ID: {predictedTokenId}");
// Note: Without a tokenizer, we can't easily convert ID back to text here.
// But typically, ID 1867 might be "What", 17 might be "2", etc.
Console.WriteLine("Inference complete.");
Console.ResetColor();
}
}
Line-by-Line Explanation
-
usingStatements:- We import
Microsoft.ML.OnnxRuntime(for running models) andMicrosoft.ML.OnnxRuntime.Tensors(for handling data structures). StandardSystemnamespaces are used for console output and file handling.
- We import
-
MainMethod:- The entry point of the application. It sets up the environment and orchestrates the inference flow.
-
Model Path Definition:
string modelPath = "models/phi-3-mini.onnx";- This defines the location of the ONNX model file. ONNX (Open Neural Network Exchange) is a standard format that allows models trained in PyTorch or TensorFlow to run in C#.
- Critical Check: The code checks if the file exists. If you run this without downloading the model, it will fail gracefully.
-
InferenceSessionInitialization:using var session = new InferenceSession(modelPath);- This loads the ONNX model from the disk into memory. The
InferenceSessionis the core engine that handles the hardware acceleration (CPU/GPU) and graph execution. usingensures that when the code block ends, the memory allocated for the model is released.
-
Input Data Preparation (The Tensor):
- LLMs do not understand text directly; they understand numbers (tokens).
- We define
inputIds,attentionMask, andpositionIdsas arrays oflong. DenseTensor<long>: We wrap these arrays into a Tensor object. The shape[1, inputIds.Length]represents:- Dimension 0: Batch size (1, meaning we are processing one request at a time).
- Dimension 1: Sequence length (the number of tokens in our prompt).
- Note: In a real application, you would use a Tokenizer (like
Microsoft.ML.OnnxRuntime.Extensions) to convert the string "What is 2 + 2?" into these IDs automatically.
-
NamedOnnxValue:- ONNX Runtime requires inputs to be named because the model expects specific input nodes (e.g., "input_ids").
- We create a list of
NamedOnnxValueobjects mapping the tensor data to the expected input names.
-
Running Inference (
session.Run):using var results = session.Run(inputs);- This is where the magic happens. The execution provider (CPU, CUDA, etc.) takes the input tensors, passes them through the neural network layers (Matrix Multiplications, Activation Functions), and produces the output tensors.
- The result is an
IDisposableReadOnlyCollection<OrtValue>.
-
Processing the Output (Logits):
- LLMs output "logits"—raw, unnormalized scores for every word in the vocabulary.
results.First().AsTensor<float>(): We extract the first output tensor (usually named "logits") as a float tensor.- The shape is typically
[1, sequence_length, vocab_size]. We are interested in the last position of the sequence because that represents the prediction for the next token.
-
Greedy Decoding (ArgMax):
- We iterate through the logits of the last token position to find the index with the highest value.
Array.IndexOf(lastTokenLogits, lastTokenLogits.Max())performs this "ArgMax" operation.- This index corresponds to the ID of the most likely next token in the vocabulary.
-
Output:
- The program prints the predicted Token ID. While we can't easily print the text here without a tokenizer library, seeing the ID confirms the model executed successfully.
Visualizing the Data Flow
Common Pitfalls
-
Missing Tokenizer:
- The Mistake: Beginners often try to feed raw strings directly into the
InferenceSessionor manually guess token IDs. - The Consequence: The model will output garbage or throw errors because the input shape or token mapping is incorrect.
- The Fix: Always use the specific tokenizer associated with the model (e.g., the
tokenizer.jsonfile included with Phi-3 models). In C#, use theMicrosoft.ML.OnnxRuntime.Extensionspackage to bind the tokenizer to the ONNX graph or process text separately.
- The Mistake: Beginners often try to feed raw strings directly into the
-
Incorrect Input Shapes:
- The Mistake: Passing a 1D array when the model expects a 2D tensor (Batch x Sequence).
- The Consequence:
OnnxRuntimeExceptionstating shape mismatch. - The Fix: Remember that even for a single sentence, the input tensor must be
[1, N](Batch size 1, N tokens).
-
Forgetting to Dispose:
- The Mistake: Not using
usingstatements forInferenceSessionorOrtValue(Tensor). - The Consequence: Memory leaks. ONNX Runtime allocates native memory outside the .NET Garbage Collector's control. If you don't dispose of it, your application will consume more and more RAM until it crashes.
- The Fix: Always wrap sessions and tensors in
usingblocks or manually call.Dispose().
- The Mistake: Not using
-
Execution Provider Selection:
- The Mistake: Assuming the code runs fast on the CPU by default.
- The Consequence: Inference is extremely slow (seconds per token instead of milliseconds).
- The Fix: If you have an NVIDIA GPU, you must install the
Microsoft.ML.OnnxRuntime.Gpupackage and configure the session options to use CUDA:SessionOptions.MakeSessionOptionWithCudaProvider().
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.