8 мин чтения

Локальные LLM в .NET: Ollama, приватность и цена вопроса

Когда локальная модель выгоднее облачной: приватность, предсказуемая стоимость и работа офлайн. Разбираем Ollama, Gemma 4 12B и подключение к .NET.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

Когда облако не подходит

Облачные модели удобны, и именно поэтому их выбирают по умолчанию: нулевая настройка, свежие веса, оплата по факту. Но у этого выбора есть три ограничения, из-за которых в части проектов локальный запуск выигрывает не по удобству, а по существу.

Первое — данные. Внешний API означает, что содержимое запроса покидает контур компании. Для черновика в репозитории это безразлично; для договора, персональных данных или результата проверки регулятора — нет. Законодательные ограничения на передачу данных за периметр в ряде отраслей жёсткие, и их нельзя закрыть формулировкой в договоре с провайдером.

Второе — предсказуемость стоимости. При вызовах API вы платите за токены, и сумма зависит от числа вызовов, длины контекста и выбранной модели. В пилоте это незаметно. В потоке, где один запрос разворачивает в десятки вызовов агентов, счёт растёт пропорционально трафику, а не по плану.

Третье — доступность. Внешний сервис недоступен, когда нет сети, упирается в rate limit, отдаёт 529 при переполнении очереди, меняет поведение при обновлении модели. Локальный запуск даёт предсказуемую задержку и работу офлайн.

При этом локальная модель — не бесплатная замена. Покупается железо, и качество ответов ниже, чем у большой облачной модели. Поэтому вопрос не «локально или облако», а где проходит граница.

Граница проходит по данным и по нагрузке

Локальный запуск оправдан там, где выполняются два условия одновременно.

Данные не должны покидать контур. Это не про размер компании, а про содержимое: исходники с доступом, персональные данные, договорные документы, содержимое тикетов.

Нагрузка предсказуема и невелика. Локальная модель хорошо обслуживает задачи с фиксированным потоком: классификация, извлечение полей, черновик ответа по шаблону, локальный код-ассистент для разработчика. Она плохо обслуживает пиковые всплески и длинные рассуждения, где нужен большой контекст и сложный план.

Промежуточный вариант — гибрид: чувствительные данные не уходят из контура, потому что предварительный этап (конвертация документа, извлечение фактов, классификация) идёт локально, а финальный запрос к облачной модели уходит уже с обезличенной и усечённой выборкой. Такой расклад стоит проектировать заранее: если в облако уходит весь исходник, локальный этап не даёт ничего.

Gemma 4 12B как рабочая локальная модель

Для локального запуска на своём железе интересна не самая большая доступная модель, а та, что влезает в разумный бюджет памяти. Gemma 4 12B — 11.95 млрд параметров, 48 слоёв, словарь 262K токенов, лицензия Apache 2.0, что снимает вопрос о коммерческом использовании. В каталоге Ollama семейство опубликовано как gemma4 — теги 12b, 26b и 31b.

Заявленное контекстное окно — 256K токенов. Архитектура у модели необычная: в варианте 12B «Unified» энкодеры для изображений и аудио убраны, патчи изображения проецируются напрямую в декодер, поэтому весь вход обрабатывается одним проходом.

Объём памяти под загрузку определяется квантизацией. По расчётной таблице Google, для Gemma 4 12B получается:

Точность Память GPU/TPU под загрузку
BF16 (16 бит) 26.7 ГБ
SFP8 (8 бит) 13.4 ГБ
Q4_0 (4 бит) 6.7 ГБ

Сам Google оговаривает, что эти числа зависят от инструмента и окружения. И это ещё без KV-кэша: он растёт линейно с длиной контекста и оплачивается сверху. Поэтому «модель с окном 256K» и «модель, работающая на ноутбуке с 16 ГБ RAM» — это разные утверждения. Второе верно для Q4_0 и умеренного контекста, первое верно про возможности архитектуры.

Ollama: запуск и главная ловушка

Ollama — локальный рантайм моделей: скачивает веса, держит их в памяти и отдаёт HTTP-эндпоинт. Документация по API и моделям — docs.ollama.com. На macOS и Linux ставится скриптом, на Windows — установщиком OllamaSetup.exe:

curl -fsSL https://ollama.com/install.sh | sh
ollama run gemma4:12b

Команда скачивает веса на диск — для gemma4:12b это 7.7–8.0 ГБ. Дальше модель доступна по двум протоколам: родному /api/chat и совместимому с OpenAI /v1/chat/completions. Совместимость удобна: к ней подключается любой клиент, который умеет говорить с OpenAI. Сервер по умолчанию слушает 127.0.0.1:11434.

Ловушка в другом. По умолчанию Ollama использует контекстное окно 4096 токенов, а не все 256K, заявленные моделью. Значение меняется двумя способами: переменной окружения OLLAMA_CONTEXT_LENGTH при старте сервера или параметром options.num_ctx в теле запроса к /api/chat.

И вот здесь развилка: у OpenAI-совместимого эндпоинта /v1/chat/completions параметра num_ctx нет, и задать размер контекста через этот эндпоинт нельзя. Чтобы поднять его для OpenAI-клиента, нужен отдельный тег модели с зафиксированным параметром — его задаёт файл Modelfile:

FROM gemma4:12b
PARAMETER num_ctx 32768
ollama create gemma4-32k -f Modelfile

Практический смысл: если приложение ходит в локальную модель через OpenAI-совместимый клиент, размер контекста задаётся один раз в Modelfile, а не в каждом запросе. Тот, кто этого не учёл, получает модель, у которой контекст молча обрезан до 4K токенов, и диагностирует «модель не помнит» там, где проблема в конфигурации.

Подключение к .NET

Для .NET у Ollama есть клиенты, и выбор — между ними. Официальный пакет Microsoft.Extensions.AI.Ollama существует, но публикуется в preview-версиях (последняя доступная — 9.7.0-preview.1), тогда как стабильная ветка Microsoft.Extensions.AI уже дошла до 10.11.0. Для OpenAI-совместимых эндпоинтов есть отдельный пакет Microsoft.Extensions.AI.OpenAI — стабильный, с той же версией, — и он умеет говорить с любым сервером, реализующим /v1/chat/completions. Отдельно стоит комьюнити-пакет Ollama (OllamaSharp) — у него стабильные версии и поддержка IChatClient. Официальная линия интеграций с моделями в .NET собрана в .NET AI extensions.

Есть и готовая связка с оркестратором: пакет CommunityToolkit.Aspire.OllamaSharp регистрирует клиент Ollama в контейнере зависимостей одной строкой, а заодно — IChatClient и IEmbeddingGenerator из Microsoft.Extensions.AI:

builder.AddOllamaApiClient("ollama")
    .AddChatClient(otel => otel.EnableSensitiveData = true);

Строка требует ещё и адреса сервера: ConnectionStrings:ollama в формате строки подключения (Endpoint=http://localhost:11434) либо секция Aspire:OllamaSharp с ключом Endpoint. Без этого хост поднимается, а первое обращение к клиенту падает с ошибкой о ненастроенном OllamaApiClient. Отдельный AddOllamaSharpChatClient из того же пакета помечен как устаревший — в актуальной версии правильная форма именно та, что выше.

Но ни один из них не обязателен: Ollama — это HTTP-сервис, а значит, обычный HttpClient. Совместимость с OpenAI-схемой позволяет переиспользовать тот же код-клиент, что и для облачного провайдера, меняя базовый адрес.

using System.Net.Http.Json;
using System.Text.Json.Serialization;

public sealed record ChatMessage(
    [property: JsonPropertyName("role")] string Role,
    [property: JsonPropertyName("content")] string Content);

public sealed record ChatRequest(
    [property: JsonPropertyName("model")] string Model,
    [property: JsonPropertyName("messages")] IReadOnlyList<ChatMessage> Messages,
    [property: JsonPropertyName("temperature")] double Temperature = 0.2,
    [property: JsonPropertyName("top_p")] double TopP = 0.9,
    [property: JsonPropertyName("stream")] bool Stream = false);

public sealed record ChatResponse(
    [property: JsonPropertyName("choices")] IReadOnlyList<ChatChoice>? Choices);

public sealed record ChatChoice(
    [property: JsonPropertyName("message")] ChatMessage Message);

public sealed class OllamaChatClient(HttpClient http, string model)
{
    public async Task<string> CompleteAsync(
        string prompt,
        CancellationToken cancellationToken = default)
    {
        var request = new ChatRequest(
            Model: model,
            Messages: [new ChatMessage("user", prompt)]);

        var response = await http.PostAsJsonAsync(
            "/v1/chat/completions", request, cancellationToken);

        response.EnsureSuccessStatusCode();

        var payload = await response.Content.ReadFromJsonAsync<ChatResponse>(
            cancellationToken: cancellationToken);

        return payload?.Choices?.FirstOrDefault()?.Message.Content ?? string.Empty;
    }
}

Регистрация клиента в Program.cs — единственное место, где различаются локальный и облачный варианты:

builder.Services.AddHttpClient<OllamaChatClient>((sp, client) =>
{
    client.BaseAddress = new Uri(
        sp.GetRequiredService<IConfiguration>()["Ollama:BaseUrl"] ?? "http://localhost:11434");
})
.AddTypedClient<OllamaChatClient>((http, sp) => new OllamaChatClient(
    http,
    sp.GetRequiredService<IConfiguration>()["Ollama:Model"] ?? "gemma4:12b"));

Деталь, на которой спотыкаются чаще всего: у клиента два параметра конструктора — HttpClient и string model. Контейнер внедрения умеет собрать HttpClient сам, а вот string он не знает откуда взять: без явной фабрики первое же разрешение падает с «Unable to resolve service for type ‘System.String’». Поэтому модель передаётся через конфигурацию и явный AddTypedClient, а не остаётся на волшебстве контейнера.

Два параметра стоит задавать явно, а не оставлять на волю рантайма. Первый — temperature. Модель-карта Gemma 4 рекомендует для большинства задач temperature=1.0, top_p=0.95, top_k=64, но для кодовых задач в статье Сахила Малика из журнала CODE предложены temperature: 0.2 и top_p: 0.9: код требует точности, а не свободы, и «креативная» догадка в коде обычно означает синтаксическую ошибку. Второй — размер контекста, о котором выше.

Когда локальный рантайм не нужен

Локальная модель не универсальна, и это ограничение стоит назвать прямо.

Если задача требует длинного рассуждения, широкого контекста или сложного многошагового планирования, локальная модель 12B проиграет большой облачной — и выигрыш в приватности придётся оплатить качеством. Если задача — разовая и не требует повторяемости, настройка рантайма, модели и Modelfile обойдётся дороже облачного вызова.

Есть и промежуточная форма: локальный рантайм на собственной машине разработчика для черновой работы и локальных экспериментов, облачный провайдер в продакшене. Один и тот же OpenAI-совместимый код клиента работает в обоих режимах — меняется адрес и имя модели. Когда в схеме становится больше одной модели, вопрос «какой ответ считать правильным» решается отдельно от выбора рантайма — в .NET это закрывает Delibera, где решения нескольких моделей сводятся к общему вердикту.

Порядок внедрения, который даёт результат без лишних затрат: сначала инвентаризация того, что вообще уходит из контура, потом выбор модели по памяти и лицензии, потом один работающий сценарий с замером качества и задержки, и только потом перенос остальных сценариев. Прыгать сразу к «перевести всё на локальную модель» — значит получить недовольных пользователей и неверный вывод о том, что локальные модели не работают.

О чём стоит помнить

Локальный запуск решает три задачи, которые облако решает плохо: данные не покидают контур, стоимость предсказуема, работа не зависит от внешнего сервиса. Платит за это железом, качеством модели и ответственностью за рантайм.

Практические выводы:

  • Q4_0 — разумная отправная точка по памяти: около 6.7 ГБ под загрузку 12B-модели по расчётной таблице Google.
  • Окно 256K у модели и 4096 токенов у рантайма по умолчанию — разные вещи; размер задаётся явно.
  • У OpenAI-совместимого эндпоинта нет параметра контекста — только Modelfile.
  • Локальный сценарий оправдан, когда данные не должны уходить и нагрузка предсказуема.
  • Клиенты для .NET есть: официальный Microsoft.Extensions.AI.OpenAI (стабильный) для OpenAI-совместимых эндпоинтов, Microsoft.Extensions.AI.Ollama (preview) и комьюнити-пакет Ollama (OllamaSharp) со стабильными версиями.
  • Всё, что уходит в облако, должно уходить в обезличенном и усечённом виде — иначе локальный этап не даёт эффекта.

Похожие статьи

CTA

Если часть ваших AI-сценариев упирается в требование «данные не покидают контур» или в непредсказуемый счёт за вызовы, начните с инвентаризации: что именно уходит наружу, с каким объёмом и в каком виде. Дальше подберём локальную конфигурацию под ваш контур или покажем, где облако всё равно остаётся правильным ответом.

Разобрать сценарии под ваш контур — напишите нам.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.