Статья обзорная, для динозавров, которые только сейчас очнулись из беспросветного сна неведения. Таким динозавром собственно являюсь я сам. Все термины, описание, мыслеформы и прочее, никак не претендуют на точность и истину в последней инстанции. На вопросы "а почему не использовали инструмент Х" отвечу: так получилось. Статья была написана в свободное от работы время, практически урывками.
Приятного чтения.

Вокруг столько движухи вокруг ИИ: бесплатный DeepSeek R1 обвалил акции ИТ гигинтов США! Tulu 3 превзошла DeepSeek V3! Qwen 2.5-VL от Alibaba обошел DeepSeek! Ну и т.д. и т.п.

А что это за ИИ такой? Программисты с помощью его пишут код, копирайтеры пишут текст, дизайнеры рисую дизайны (тот же Ионов от студии Артемия Лебедева), контент-мейкеры генерируют рисунки и видео.

А что остаётся нам, обычным людям? Алиса, Маруся, Салют, Сири, Кортана, Алекса, Bixby, и прочее.

Это конечно хорошо, но все эти замечательные ИИ ассистенты нам полностью не принадлежат. Мы не можем их полностью контролировать. Вся наша жизнь благодаря этим онлайн ассистентам — как открытая книга для корпораций, которые не прочь на нас заработать.

А что если ...мы попробуем сделать своего собственного ИИ ассистента?

Что мы хотим?

Алиса и прочие ИИ ассистенты — слушают команды с микрофона и выполняют определенные действия. Было бы неплохо иметь свой собственный аналог заточенный на свои собственные потребности, который не будет самостоятельно лезть в интернет, и сливать личную информацию. Давайте назовем своего ИИ ассистента не банально «Джарвис». И команды он будет выполнять только если первое слово в предложении - «Джарвис».

К примеру, как может выглядеть управление своим загородным домом
  • время, дата (выдает текущее время и дату)

  • какие сегодня новости? (выдает заголовки топ 5 новостей)

  • какая погода в москве и питере? (выдает погоду)

  • проверь почту (выдает заголовки непрочитанных писем эл.почты)

  • закажи суши (вызов API магазина суши если таковой имеется)

  • отправь СМС брату: «сегодня не приеду, весь день занят» (вызов API отправки СМС)

  • курс доллара (выводит стоимость 1 доллара в рублях по курсу ЦБ)

  • включи музыку бетховен симфония номер пять (запуск плеера)

  • будильник на завтра в 17:15 (добавление будильника)

  • отмени все дела сегодня (отмена всех будильников)

  • включи робот-пылесос (команда «умному дому»)

  • включи телевизор в кухне (команда «умному дому»)

  • закрой шторы в гостиной (команда «умному дому»)

  • выключи свет в коридоре, в прихожей и в ванной (команда «умному дому»)

  • поставь дом на охрану (команда «умному дому»)

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

Ассистент в нашем случае — по сути это компьютер с микрофоном и колонками, включенный в локальную сеть, имеющий доступ к интернету, локально запускающий ИИ, который понимает и выполняет наши устные команды. Желательно чтобы всё это работало на недорогом ПК, без крутой видеокарты.

С чего начать?

Начнем с распознавания голоса. Т.к. я уже знаком с замечательным инструментом для распознавания голоса https://alphacephei.com/vosk/index.ru, проект: https://github.com/alphacep/vosk-api – то его и будем использовать.

Для начала установим пакет Vosk в NuGet. Затем скачаем и разархивируем модель vosk-model-small-ru-0.22 (всего 45Мб) из https://alphacephei.com/vosk/models.

Для работы с микрофоном установим пакет NAudio. А для озвучивания ответов Джарвиса будем использовать TTS (Text-to-Speech). По умолчанию в системе может не быть русского мужского голоса, поэтому можно установить подходящий с сайта https://rhvoice.su/voices. В настройках приложения снимаем галку «Предпочтительная 32-разрядная версия».

Cобственно код
using NAudio.Wave;
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Speech.Synthesis;
using Vosk;

namespace ConsoleJarvis
{
    internal class Program
    {
        private class RecognizeWord
        {
            public double conf { get; set; }
            public double end { get; set; }
            public double start { get; set; }
            public string word { get; set; }
        }
        private class RecognizeResult
        {
            public RecognizeWord[] result { get; set; }
            public string text { get; set; }
        }

        static void Main(string[] args)
        {
            //модель
            Model model = new Model("vosk-model-small-ru-0.22");

            //настраиваем "распознаватель"
            var recognizer = new VoskRecognizer(model, 16000.0f);
            recognizer.SetMaxAlternatives(0);
            recognizer.SetWords(true);

            //настраиваем и «включаем» микрофон
            var waveIn = new WaveInEvent();
            waveIn.DeviceNumber = 0; //первый микрофон по умолчанию
            waveIn.WaveFormat = new WaveFormat(16000, 1); //для лучшего распознавания
            waveIn.DataAvailable += WaveIn_DataAvailable;
            waveIn.StartRecording();

            //получаем данные от микрофона
            void WaveIn_DataAvailable(object sender, WaveInEventArgs e)
            {
                //распознаем
                if (recognizer.AcceptWaveform(e.Buffer, e.BytesRecorded))
                {
                    //получаем распознанный текст в json
                    string txt = recognizer.FinalResult();

                    //преобразуем
                    RecognizeResult values = JsonConvert.DeserializeObject<RecognizeResult>(txt);

                    //парсим команды
                    parseCommands(values);
                }
            }

            Console.WriteLine();
            Console.WriteLine("Скажите одну из команд:");
            Console.WriteLine("Джарвис, список команд!");
            Console.WriteLine("Джарвис, дата");
            Console.WriteLine("Джарвис, время");
            Console.WriteLine("Джарвис, запусти блокнот");
            Console.WriteLine("Джарвис, выход");
            Console.WriteLine("Напишите 'exit' и нажмите Enter что-бы выйти");
            Console.WriteLine();
            var input = Console.ReadLine();
            while (input != "exit")
            {
            }
        }

        //генерируем ответ
        static void PlayTTS(string text)
        {
            var synthesizer = new SpeechSynthesizer();
            synthesizer.SetOutputToDefaultAudioDevice(); //аудио-выход по умолчанию
            //synthesizer.SelectVoice(voiceName); //выбор голоса

            var builder = new PromptBuilder();
            builder.StartVoice(synthesizer.Voice);
            builder.AppendText(text);
            builder.EndVoice();

            //генерируем звук
            synthesizer.Speak(text);
        }

        static void parseCommands(RecognizeResult words)
        {
            if (words.text.Length == 0) return;

            Console.WriteLine("Распознано: " + words.text + Environment.NewLine);

            //если в предложении первое слово джарвис - слушаем команду
            if (words.result.First().word.Contains("джарвис"))
            {
                var text = words.result.Select(obj => obj.word).ToList();

                var print = string.Join(" ", text);
                var command = string.Join(" ", text.Skip(1)); //Skip(1) - пропускаем первое слово "джарвис"

                //логируем команду
                Console.WriteLine(print);

                var executerComment = "";

                if (command.Trim().Length == 0)
                {
                    Console.WriteLine("Джарвис: что?");
                    PlayTTS("что?");
                }
                else if (!Executer.Parse(command, ref executerComment)) //выполняем команды
                {
                    Console.WriteLine("Джарвис: Команда не распознана");
                    PlayTTS("Команда не распознана");
                }
                else
                {
                    Console.WriteLine("Джарвис: "+executerComment);
                    PlayTTS(executerComment);
                }
                Console.WriteLine("");
            }
        }
    }
}

Доступные команды, файл Executer.cs:

using System;
using System.Collections.Generic;
using System.Globalization;

namespace ConsoleJarvis
{
    public static class Executer
    {
        public delegate void Func(string text, ref string comment);

        private class Command
        {
            public string word { get; set; }
            public Func action { get; set; }
            public Command(string word, Func action)
            {
                this.word = word;
                this.action = action;
            }

        }

        private static readonly List<Command> commands = new List<Command>();

        static Executer()
        {
            //Добавляем все доступные команды

            commands.Add(new Command("список команд", (string text, ref string comment) =>
            {
                foreach (var c in commands) {
                    comment = comment + Environment.NewLine + c.word + '.';
                }
            }));

            commands.Add(new Command("дата", (string text, ref string comment) =>
            {
                comment = DateTime.Now.ToString("dddd dd MMMM yyyy", CultureInfo.CurrentCulture);
            }));

            commands.Add(new Command("время", (string text, ref string comment) =>
            {
                comment = DateTime.Now.ToString("H mm", CultureInfo.CurrentCulture);
            }));

            commands.Add(new Command("запусти", (string text, ref string comment) =>
            {
                foreach (var c in text.Split(' '))
                {
                    switch (c)
                    {
                        case "калькулятор":
                            System.Diagnostics.Process.Start(@"calc.exe");
                            return;
                        case "блокнот":
                            System.Diagnostics.Process.Start(@"notepad.exe");
                            return;
                    } 
                }
                comment = "я не умею запускать ничего кроме калькулятора и блокнота";
            }));

            commands.Add(new Command("выход", (string text, ref string comment) =>
            {
                Environment.Exit(0);
            }));

        }

        public static bool Parse(string text, ref string comment)
        {
            foreach (var command in commands)
            {
                if (text.Contains(command.word))
                {
                    command.action(text, ref comment);
                    return true;
                }
            }
            return false;
        }
    }
}

Пример распознанного текста:

{
  "result" : [{
      "conf" : 1.000000,
      "end" : 1.110000,
      "start" : 0.630000,
      "word" : "джарвис"
    }, {
      "conf" : 1.000000,
      "end" : 1.410000,
      "start" : 1.110000,
      "word" : "курс"
    }, {
      "conf" : 1.000000,
      "end" : 1.860000,
      "start" : 1.410000,
      "word" : "доллара"
    }],
  "text" : "джарвис курс доллара"
}

Вот так с помощью нехитрых приспособлений буханка белого хлеба превратилась в троллейбус мы получили «Голосовой ассистент Ирина» https://habr.com/ru/articles/595855/.

Пробуем LLM

Первым делом для запуска LLM моделей локально, гугл советует установить LM Studio. После установки выяснилось что для запуска модели необходима поддержка процессором инструкции AVX2. К сожалению такой инструкции на Intel(R) Core(TM) i7-3770K нет.

Следующее что попробовал установить: GPT4ALL. Программа позволяет загрузить модели из своего списка «оптимизированные для GPT4ALL», а так же с некоего HuggingFace.

Что такое HuggingFace? Оказывается это целая платформа с большой библиотекой нейросетевых моделей. Выбрал в поиске самую малую модель Jarvis-0.5B.f16.gguf размером примерно 948Мб.

Пишу «привет». Зашумел процессорный кулер, и через секунду оно ответило.

Вот оно что! Вот зачем нужен апгрейд ПК! Не банальная причина поиграть в игры! А ради вот этого самого!

Отличный вариант попробовать модели — koboldcpp.

Но погодите, прежде чем рваться в код, надо немного теории.

LLM, БЯМ, Нейронка, Чат-гпт. Краткий понятийный курс

Что-бы прикоснуться к прекрасному миру LLM, окунемся немного в теорию. Что такое нейронка, с чем ее едят. Пояснения для тех кто особо никогда этим не интересовался, но хочет понять. Дальнейшие рассуждения — мои личные наблюдения, так сказать простым понятным языком. Просьба не судить строго за стиль письма.

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

Сам процесс обработки информации нейронкой называется «инференс» (inference).

Как нейронка понимает какую информацию мы ей передаем, и какими единицами она «мыслит»? Понятно что раз нейронка — это массив чисел, то и подавать на вход ей надо числа. Текст который вы пишете нейронке, разбивается на «токены» - кусочки текста, которые в нейронке соотносятся к определенному числу. Поэтому когда вы пишете: «hello my frend», этот текст разбивается на три токена «hello» «my» «frend», причем каждый привязан к определенному числу. В итоге в нейронку передается три числа, как пример: 12234, 42112, 234345.

Т.е. у нейронки есть определенный словарь токенов: связь токена и числа (вектор).

Размер нейронки — не бесконечный, мы не можем передать ей бесконечное количество информации за один раз. Мы можем передать ей на вход ограниченное количество токенов. Такое ограничение называется «Контекстным окном». Чем больше и круче нейронка — тем больше контекстное окно, т.е. больше токенов можно передать в нейронку.

Так сложилось, что первая LLM была сделана в США и «понимала» только английские слова. Когда нейронки стали обучать другим языкам, то пришлось увеличивать словарь токенов. В русском языке одно слово может сильно видоизменяться по падежам, и раздувать словарь токенов одним словом в разных падежах — это очень нерационально. Придумали лайфхак: разбивать слова на куски, из которых можно собрать любое слово. Поэтому текст «привет мой друг» преобразуется не на три токена, а на большее количество: «при» «вет» « » «мой» « » «др» «уг». В итоге в нейронку передается уже 7 токенов а не три.

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

Инференсом можно управлять: дать волю нейронке или сдержать ее фантазию. Для этого существует параметр «Температура»: чем выше — тем больше нейронка фантазирует. Однако чем больше температура, тем больше вероятность что нейронка будет «галлюцинировать»: выводить совершенно бессвязный ответ.

Другой параметр «TopK» позволяет при генерации ответа сузить диапазон выдаваемых слов, отбросив самые нерелевантные варианты. Например генерируя фразу: «Солнце встает на…» нейронка может вставить последнее слово «картина». Но если мы укажем параметр TopK=3, то нейронка выберет один из топ 3 самых релевантных слов: «восток», «рассвет», «небо».

Еще один параметр «Frequency penalty» - штраф за частоту: модель получает «штраф» за каждое повторение токена в ответе, что увеличивает разнообразие ответа.

«Presence penalty» - штраф за присутствие: модель получает «штраф» за повторяющийся токен, и генерирует новый неповторяющийся токен в ответе.

Когда мы что-то пишем нейронке — наш текст называется «промпт» (prompt): запрос пользователя.

Мы так же можем управлять нейронкой подобрав правильный промт: попросить анализировать текст, сгенерировать новый, ответить в определенном стиле, выделить важное, суммировать информацию и т.д. В ход идут не только слова, но и знаки препинания (!), написание важных слов В ВЕРХНЕМ РЕГИСТРЕ, выделение звездочками, кавычками и т.д. Искусство промптинга называется «Промпт-инжиниринг».

Что-бы придать нейронке определенный стиль, придать направление общения, применяется «Системный промпт», например: «Ты — полезный помощник Олег. Отвечай кратко и по делу. Не задавай лишних вопросов». Системный промпт задается в начале диалога с пользователем.

Нейронка в своих ответах опирается на «контекст», который формируется как системным промптом, так и в процессе общения с пользователем. Кроме того нейронка так же ориентируется на историю общения. Поэтому очистив историю и контекст, общение с нейронкой начнется как с чистого листа.

Нейронка может работать не только с текстом, но и с изображением, видео, звуком. Такие нейронки называют «мультимодальными».

Размер нейронки можно характеризовать количеством «параметров»: т.е. количеством чисел (весов) из которых состоит массив нейронки. Аналог параметра в живой природе — сила связи между нейронами. Есть модели на 1, 10, 70, 180 миллиардов параметров.

Для того что-бы пользоваться нейронкой, необходимо иметь достаточно оперативной памяти что-бы нейронка могла туда загрузиться полностью. Кроме того, что-бы нейронка работала шустро — нужен достаточно мощный процессор, который должен поддерживать определенные процессорные инструкции (AVX, AVX2). Но если у вас есть мощная видеокарта с большим количеством памяти — вам повезло: инференс будет намного быстрее чем на центральном процессоре ПК.

Но не у всех есть видеокарта с 24-80 Гб. для запуска полноценных нейронок. Можно уменьшить размер нейронки почти не теряя в качестве. Такой процесс называется «квантизацией»: весь массив чисел (весов) из которых состоит нейронка преобразуют в массив чисел с меньшей точностью. Например в массиве были числа примерно такие: 0.123456789 а стали: 0.123. Почитать об этом можно здесь: https://habr.com/ru/articles/797443/

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

Далее идет «файнтюн» (finetune, тонкая настройка): модель учится отвечать на датасетах с диалогами. Третья необязательная часть обучения «алаймент» (alignment, выравнивание): настройка модели на корректный и безопасный вывод (например, исключение неэтичных тем).

Готовая модель содержит знания только на тот момент когда ее обучали, и будет отвечать только в контексте прошедшего времени. Если ее спросить какой сейчас год — она ответит например 2022, если обучалась на данных того момента времени. Постоянно дообучать модель — накладно. Кроме того, модель знает только обобщенную информацию.

Поэтому разработчики чат-ботов используют следующие инструменты:

  • Function Calling – модели говорят что есть определенные функции например «вывод погоды» и т.д. И если пользователь спросит «какая погода в Москве», модель может выполнить эту функцию (заранее реализованную программистом), вернув пользователю ответ.

  • RAG (Retrieval Augmented Generation) — модель может обратиться к массиву данных (через Function Calling) любезно предоставленным программистом для вывода информации пользователю. Либо сам разработчик подкидывает найденную информацию по запросу в промпт или в историю чата. Для этого заранее сканируются документы, где текстовая информация индексируется весами модели (формируется массив векторов для поиска).

Поиск информации в интернете, включение лампочки и прочее — всё реализуется через Function Calling. Сама модель не умеет самостоятельно лезть в гугл или открывать вам шторы в комнате по команде.

А еще по причине того, что нейронка не может дообучаться и расти как живой организм, ни о каком захвате скайнета пока речи не идет.

Ок. Пока на этом всё. Идем дальше.

NPU

В современных процессорах есть блок NPU (Neural Processing Unit). По сути это небольшой "сопроцессор" заточенный на выполнение операций с матрицами. Задействовать его можно через библиотеку Intel® NPU Acceleration Library на пайтоне: https://github.com/intel/intel-npu-acceleration-library. Так же можно задействовать через OpenVINO, DirectML.

В перспективе, для запуска больших ИИ моделей можно обойтись этим блоком, напихав побольше ОЗУ в ПК, без траты на дорогие видеокарты с памятью от 24Гб.

Но у меня старый процессор. Поэтому оставим это на будущее.

Пробуем в код. LlamaSharp, он же llama.cpp

Итак, наша задача: попробовать запустить модель напрямую, и желательно без python. Сам пайтон очень тяжелый, а нам нужно максимально облегчить работу с LLM. Простите питонисты.

На гитхабе есть проект https://github.com/ggerganov/llama.cpp на C/C++. А на c# есть замечательный проект https://github.com/SciSharp/LlamaSharp использующий llama.cpp, вот его и будем использовать.

Сразу скажу, много перепробовал моделей, но в итоге самой быстрой оказалась: Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf, см.: https://huggingface.co/Vikhrmodels/QVikhr-2.5-1.5B-Instruct-r_GGUF/tree/main

Добавляем в проект пакеты из NuGet: LlamaSharp и LlamaSharp.Backend.Cpu, без которого не удастся запустить и инферить модель.

Простой пример
using LLama.Common;
using LLama;
using LLama.Sampling;
using LLama.Transformers;

//путь к модели
string modelPath = @"c:\models\QVikhr-2.5-1.5B-Instruct-r-Q8_0.gguf";

var parameters = new ModelParams(modelPath)
{
    ContextSize = 1024, //контекст
};

//загружаем модель
using var model = LLamaWeights.LoadFromFile(parameters);

//создаем контекст
using var context = model.CreateContext(parameters);

//для инструкций
var executorInstruct = new InstructExecutor(context);
//для интерактива 
var executorInteractive = new InteractiveExecutor(context);
//каждый раз сбрасывает контекст
var executorStateless = new StatelessExecutor(model, parameters)
{
    ApplyTemplate = true,
    SystemMessage = "Ты - полезный помощник. Отвечай кратко"
};

//параметры инференса
InferenceParams inferenceParams = new InferenceParams()
{
    MaxTokens = 256, //максимальное количество токенов
    AntiPrompts = new List<string> { ">" },
    SamplingPipeline = new DefaultSamplingPipeline()
    {
         Temperature = 0.4f
        ,TopK = 50
        ,TopP = 0.95f
        ,RepeatPenalty = 1.1f
    }
};

//загружаем из модели правильный шаблон для промпта
LLamaTemplate llamaTemplate = new LLamaTemplate(model.NativeHandle)
{
    AddAssistant = true
};

//генерируем правильный промпт
string createPrompt(string role, string input)
{
    var ltemplate = llamaTemplate.Add(role, input);
    return PromptTemplateTransformer.ToModelPrompt(ltemplate);
}

Console.Write("\n>");
string userInput = Console.ReadLine() ?? "";
while (userInput != "exit")
{
    //посмотрим какой нам генерируется ответ
    //prompt: <| im_start |> user
    //привет <| im_end |>
    //<| im_start |> assistant

    //для executorInstruct и executorInteractive каждый раз будет дублировать всю историю переписки, т.к. она хранится в контексте
    //для executorStateless - контекст будет создаваться заново без истории переписки
    var prompt = createPrompt("user", userInput);
    Console.WriteLine("prompt: "+ prompt);

    //инфер
    await foreach (var text in executorInteractive.InferAsync(prompt, inferenceParams))
    {
        Console.ForegroundColor = ConsoleColor.White;
        Console.Write(text);
    }
    userInput = Console.ReadLine() ?? "";
}

Сразу хотелось бы отметить: у каждой модели свой шаблон общения (ChatML, CommandR, Gemma 2, и т.д.). В данном случае нам не нужно формировать правильный промпт вручную. За нас это делает код, и данные хранящиеся в модели.

Однако эксперименты подразумевают частую правку кода и запуск проекта, а значит и периодический тяжелый этап загрузки модели.

Ollama

Существует такой проект https://ollama.com, позволяющий работать сразу с несколькими моделями одновременно. Работает просто: локально запускается сервер, который по первому требованию загружает модель, и вы спокойно с ней работаете. Остановили свой проект на c#, поправили код, запустили — а модель уже загружена в ollama, не нужно ждать новой загрузки.

Скачиваем Ollama (релиз https://github.com/ollama/ollama/releases/tag/v0.5.11), устанавливаем. Ищем интересующую вас модель https://ollama.com/search и скачиваем командой: ollama pull modelname. Можем с ней поработать прямо из консоли: ollama run modelname. Выход: /bye. Вывести список скачанных локально моделей: ollama list. Запустить сервер: ollama serve.

Однако я уже скачал ранее модель Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf на 1,53 Гб, и на сайте ollama такой модели нет. Что делать?

Можно добавить любую уже скачанную gguf модель на локальный сервер Ollama:

Скрытый текст

1) Создаем файл Modelfile (без расширения) с таким содержимым:

from c:\models\QVikhr-2.5-1.5B-Instruct-r-Q8_0.gguf
# set the temperature to 1 [higher is more creative, lower is more coherent]
PARAMETER temperature 0.0
#PARAMETER top_p 0.8
#PARAMETER repeat_penalty 1.05
#PARAMETER top_k 20

TEMPLATE """
{{- if .Messages }}
{{- if or .System .Tools }}<|im_start|>system
{{- if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}

# Tools

You may call one or more functions to assist with the user query. Do not distort user description to call functions! 

You are provided with function signatures within <tools></tools> XML tags:
<tools>
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>

For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
{{- end }}<|im_end|>
{{ end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 -}}
{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}<|im_end|>
{{ else if eq .Role "assistant" }}<|im_start|>assistant
{{ if .Content }}{{ .Content }}
{{- else if .ToolCalls }}<tool_call>
{{ range .ToolCalls }}{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{ end }}</tool_call>
{{- end }}{{ if not $last }}<|im_end|>
{{ end }}
{{- else if eq .Role "tool" }}<|im_start|>user
<tool_response>
{{ .Content }}
</tool_response><|im_end|>
{{ end }}
{{- if and (ne .Role "assistant") $last }}<|im_start|>assistant
{{ end }}
{{- end }}
{{- else }}
{{- if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
{{ end }}{{ .Response }}{{ if .Response }}<|im_end|>{{ end }}
"""

# set the system message
SYSTEM """You are JARVIS. You are a helpful assistant. Answer user requests briefly."""

2) Затем запускаем в командной строке импорт модели в Ollama:

ollama create MY -f c:\models\Modelfile

Вуаля. Теперь к этой модели можно обращаться по имени «MY».

Очень важно: в TEMPLATE указаны инструкции без которых Ollama не будет работать с Function Calling. Иначе Ollama выдаст ошибку "registry.ollama.ai MY does not support tools".

Если после запуска командой ollama serve у вас выходит ошибка "Error: listen tcp 127.0.0.1:11434: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.", посмотрите в трей - там может быть запущен экземпляр ollama. Его можно выгрузить, выбрав "Quit Ollama".

Почему именно этот старый релиз 0.5.11? Потому что у модели Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf в новых релизах ollama перестают работать функции.

Microsoft Semantic Kernel

У мелкософта есть свой проект по работе с LLM, в том числе и с Ollama. Репозиторий: https://github.com/microsoft/semantic-kernel, еще есть кукбук https://github.com/microsoft/SemanticKernelCookBook.

Простой чат

Устанавливаем пакеты Microsoft.SemanticKernel, Microsoft.SemanticKernel.Connectors.Ollama в NuGet.

Простой чатик
#pragma warning disable SKEXP0070

using Microsoft.SemanticKernel;

var modelId = "MY";
var url = "http://localhost:11434";

var builder = Kernel.CreateBuilder();
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();

Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
    var answer = await kernel.InvokePromptAsync(input);
    Console.WriteLine("AI: " + string.Join("\n", answer));

    Console.Write("User: ");
}

Пользовательские функции

Теперь попробуем вызвать пользовательские функции

Function Calling
#pragma warning disable SKEXP0070

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using System.ComponentModel;

var modelId = "MY";
var url = "http://localhost:11434";

var builder = Kernel.CreateBuilder();
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();

//Добавляем свои функции
kernel.Plugins.AddFromObject(new MyWeatherPlugin());
kernel.Plugins.AddFromType<MyTimePlugin>();
kernel.Plugins.AddFromObject(new MyNewsPlugin());

//настраиваем ollama на запуск функций
var settings = new OllamaPromptExecutionSettings
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
    Temperature = 0,
    TopP = 0,
};

Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
    var answer = await kernel.InvokePromptAsync(input, new(settings));
    Console.WriteLine("AI: " + string.Join("\n", answer));

    Console.Write("User: ");
}

//Плагины
public class MyWeatherPlugin
{
    [KernelFunction, Description("Gets the current weather for the specified city")]
    public string GetWeather(string _city)
    {
        return "very good in " + _city + "!";
    }
}

public class MyTimePlugin
{
    [KernelFunction, Description("Get the current day of week")]
    public string DayOfWeek() => System.DateTime.Now.ToString("dddd");

    [KernelFunction, Description("Get the current time")]
    public string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'");

    [KernelFunction, Description("Get the current date")]
    public string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}

public class MyNewsPlugin
{
    [KernelFunction, Description("Gets the current news for the specified count")]
    public string GetNews(int count)
    {
        return "the ruble strengthened. An alien ship was found in the USA. It's a full moon today.";
    }
}

Результат:

RAG, Поисковая расширенная генерация

Попробуем заставить LLM искать информацию в наших данных (сделаем из него поисковик) т.е. реализуем RAG.

Как это выглядит:

  1. Индексируем данные

  2. Используем функцию для поиска данных (объект TextMemoryPlugin из Microsoft.SemanticKernel.Plugins.Memory)

Добавим в NuGet компоненты: SmartComponents.LocalEmbeddings.SemanticKernel, Microsoft.SemanticKernel.Plugins.Memory.

В коде мы добавили факты: "Иван живет в Москве"; "У Ивана есть три кота" и т.д. А так же добавили логирование вызова пользовательских функций.
Плагин для поиска в семантической памяти нам любезно предоставил мелкософт: TextMemoryPlugin.

RAG
#pragma warning disable SKEXP0001
#pragma warning disable SKEXP0050
#pragma warning disable SKEXP0070

using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using Microsoft.SemanticKernel.Embeddings;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel.Plugins.Memory;
using System.ComponentModel;

var modelId = "MY";
var url = "http://localhost:11434";

var builder = Kernel.CreateBuilder();
builder.AddLocalTextEmbeddingGeneration(); //для embeddingGenerator
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();

//Добавляем свои функции
kernel.Plugins.AddFromObject(new MyWeatherPlugin());
kernel.Plugins.AddFromType<MyTimePlugin>();
kernel.Plugins.AddFromObject(new MyNewsPlugin());

//===RAG
//семантическая память
var embeddingGenerator = kernel.Services.GetRequiredService<ITextEmbeddingGenerationService>();
var store = new VolatileMemoryStore();
var memory = new MemoryBuilder()
           .WithTextEmbeddingGeneration(embeddingGenerator)
           .WithMemoryStore(store)
           .Build();

//добавляем факты
const string CollectionName = "generic";
await memory.SaveInformationAsync(CollectionName, "Иван живет в Москве.", Guid.NewGuid().ToString(), "generic", kernel: kernel);
await memory.SaveInformationAsync(CollectionName, "У Ивана есть три кота.", Guid.NewGuid().ToString(), "generic", kernel: kernel);
await memory.SaveInformationAsync(CollectionName, "Семён живет в Питере.", Guid.NewGuid().ToString(), "generic", kernel: kernel);
await memory.SaveInformationAsync(CollectionName, "У Семёна есть три кота.", Guid.NewGuid().ToString(), "generic", kernel: kernel);
await memory.SaveInformationAsync(CollectionName, "Марина живет в Воркуте.", Guid.NewGuid().ToString(), "generic", kernel: kernel);
await memory.SaveInformationAsync(CollectionName, "У Марины есть две собаки.", Guid.NewGuid().ToString(), "generic", kernel: kernel);

//плагин поиска
kernel.Plugins.AddFromObject(new TextMemoryPlugin(memory));
//===RAG

//логирование вызовов функций
kernel.FunctionInvocationFilters.Add(new FunctionCallLoggingFilter()); 

//настраиваем ollama на запуск функций
var settings = new OllamaPromptExecutionSettings
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), //включаем плагины
    Temperature = 0
};

Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
    var answer = await kernel.InvokePromptAsync(input, new(settings));

    Console.WriteLine("AI: " + string.Join("\n", answer));

    Console.Write("User: ");
}

//Плагины
public class MyWeatherPlugin
{
    [KernelFunction, Description("Gets the current weather for the specified city")]
    public string GetWeather(string _city)
    {
        return "very good in " + _city + "!";
    }
}

public class MyTimePlugin
{
    [KernelFunction, Description("Get the current day of week")]
    public string DayOfWeek() => System.DateTime.Now.ToString("dddd");

    [KernelFunction, Description("Get the current time")]
    public string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'");

    [KernelFunction, Description("Get the current date")]
    public string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}

public class MyNewsPlugin
{
    [KernelFunction, Description("Gets the current news for the specified count")]
    public string GetNews(int count)
    {
        return "the ruble strengthened. An alien ship was found in the USA. It's a full moon today.";
    }
}

public class FunctionCallLoggingFilter : IFunctionInvocationFilter
{
    public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
    {
        try
        {
            var values = "";
            foreach (var arg in context.Arguments.Names)
            {
                var val = context.Arguments[arg] == null ? "null" : context.Arguments[arg];
                values += $"{arg}: {val}; ";
            }

            Console.WriteLine($"call {context.Function.Name}: {values}");
        }
        catch
        {
            Console.WriteLine($"call {context.Function.Name}");
        }

        await next(context);
    }
}

Результат:

  1. Первый раз мы спросили "у кого есть три кота", ИИ начал поиск в семантической памяти с лимитом 1, и выдал Семёна.

  2. Второй раз мы принудительно указали параметры поиска у кого есть три кота? (( recall input='три кота' collection='generic' relevance=0.8 limit=1 )), с лимитом 1, что бы удостовериться, что этот запрос похож на первый.

  3. Третий раз мы принудительно указали лимит 2, что бы TextMemoryPlugin выдал нам список из 2 позиций, ...и тут что-то не так. ИИ должна была вывести Ивана и Семёна.

Оказалось что при выводе нескольких значений (а для этого используется json), в LLM возвращалась кривая строка с unicode последовательностями. Что-то типа такого: ["\u0423 \u0421\u0435\u043C\u0451\u043D\u0430 \u0435\u0441\u0442\u044C \u0442\u0440\u0438 \u043A\u043E\u0442\u0430.","\u0423 \u0418\u0432\u0430\u043D\u0430 \u0435\u0441\u0442\u044C \u0442\u0440\u0438 \u043A\u043E\u0442\u0430."]

Давайте сделаем свой аналог TextMemoryPlugin, который будет искать по всем коллекциям семантической памяти и выдавать корректный вывод. Тем более что исходники есть на гитхабе. Заодно раскрасим вывод.

MyTextMemoryPlugin
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Memory;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;

[Experimental("SKEXP0001")]
public sealed class MyTextMemoryPlugin
{
    private ISemanticTextMemory memory;

    public MyTextMemoryPlugin(ISemanticTextMemory memory)
    {
        this.memory = memory;
    }

    [KernelFunction, Description("Key-based lookup for a specific memory")]
    public async Task<string> RetrieveAsync(
        [Description("The key associated with the memory to retrieve")] string key,
        //[Description("Memories collection associated with the memory to retrieve")] string? collection = DefaultCollection,
        CancellationToken cancellationToken = default)
    {
        //ищем во всех коллекциях
        var collections = await this.memory.GetCollectionsAsync();
        foreach (var collection in collections)
        {
            var info = await this.memory.GetAsync(collection, key, cancellationToken: cancellationToken).ConfigureAwait(false);
            var result = info?.Metadata.Text;

            if (result != null)
                return result;
        }

        return string.Empty;
    }

    [KernelFunction, Description("Semantic search and return up to N memories related to the input text")]
    public async Task<string> RecallAsync(
        [Description("The input text to find related memories for")] string input,
        //[Description("Memories collection to search")] string collection = DefaultCollection,
        //[Description("The relevance score, from 0.0 to 1.0, where 1.0 means perfect match")] double? relevance = DefaultRelevance,
        [Description("The maximum number of relevant memories to recall")] int? limit = 3,
        CancellationToken cancellationToken = default)
    {
        var collections = await this.memory.GetCollectionsAsync();

        foreach (var collection in collections)
        {
            // Search memory
            List<MemoryQueryResult> memories = await this.memory
            .SearchAsync(collection, input, limit.Value, 0.9, cancellationToken: cancellationToken)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

            if (memories.Count == 1)
            {
                Console.ForegroundColor = ConsoleColor.Yellow;
                Console.WriteLine("RecallAsync: " + string.Join("\n", memories[0].Metadata.Text));
                return memories[0].Metadata.Text;
            }

            if (memories.Count > 1)
            {
                var opt = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
                var t = JsonSerializer.Serialize(memories.Select(x => x.Metadata.Text), opt);
                Console.ForegroundColor = ConsoleColor.Yellow;
                Console.WriteLine("RecallAsync: " + string.Join("\n", t));
                return t;
            }
        }
        return string.Empty;
    }
}

Результат:

Можно использовать встроенный плагин TextMemoryPlugin, только правильно его настроить:

var opt = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
kernel.Plugins.AddFromObject(new TextMemoryPlugin(memory, jsonSerializerOptions: opt));

Однако и тут есть проблемы. Если поставить версию компонентов (Microsoft.SemanticKernel, Microsoft.SemanticKernel.Connectors.Ollama, Microsoft.SemanticKernel.Plugins.Memory) выше 1.40.1 - логирование не работает. Мелкософт опять что-то сломали.

Другой очень заметный минус Microsoft Semantic Kernel: чем больше подключено функций-плагинов, тем тяжелее выполняется любой запрос. А ведь мы не хотим ограничиваться парой тройкой функций...

Да, я пробовал систему с "агентами": сперва агент определяет необходимость вызова функции и если вызов необходим - ИИ может запустить функцию (настраивается через settings). Но это не поможет ускорить ответы. В любом случае, каждый запуск функции будет очень длительным.

Пример с агентом определяющим необходимость вызова функции
#pragma warning disable SKEXP0001
#pragma warning disable SKEXP0050
#pragma warning disable SKEXP0070

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using System.ComponentModel;

var modelId = "MY";
var url = "http://localhost:11434";

var builder = Kernel.CreateBuilder();
builder.AddLocalTextEmbeddingGeneration(); //для embeddingGenerator
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();

//"облегченное ядро"
var kernelLight = kernel.Clone();

//Добавляем свои функции в "основное ядро"
kernel.Plugins.AddFromObject(new MyWeatherPlugin());
kernel.Plugins.AddFromType<MyTimePlugin>();
kernel.Plugins.AddFromObject(new MyNewsPlugin());

//логирование вызовов функций
kernel.FunctionInvocationFilters.Add(new FunctionCallLoggingFilter());
kernelLight.FunctionInvocationFilters.Add(new FunctionCallLoggingFilter());

//определяем список всех доступных функций
var functions = "";
foreach (var plugin in kernel.Plugins)
{
    var items = plugin.Select(x => {
        //var param = x.Metadata.Parameters.Select(y => y.Name).ToArray();
        var param = x.Metadata.Parameters.Select(y =>
        {
            //если есть описание параметра - берем его, иначе - наименование параметра
            //return string.IsNullOrEmpty(y.Description) ? y.Name : y.Name + " - " + y.Description;
            return y.Name;
        }).ToArray();
        var result = param.Length == 0 ? x.Name : x.Name + "(" + string.Join(",", param) + ")";
        return result;
    }).ToArray();
    functions += string.Join(",", items) + ",";
}
functions = "[" + functions + "]";

//агент 
var functionNeedAgent = kernelLight.CreateFunctionFromPrompt(@$"ОПРЕДЕЛИ ПОДХОДИТ ЛИ ЗАПРОС ПОД ФУНКЦИИ: {functions}. 
    ЕСЛИ ДА - ВЕРНИ ТОЛЬКО: 'function:название;parameters:параметры в запросе' ИЛИ 'null'. 
    БОЛЬШЕ НИЧЕГО НЕ ВЫВОДИ.
    ПРИМЕР: 'function:GetWeather;parameters:-'.
    ЗАПРОС: '{{{{$user_input}}}}'");

//настраиваем ollama на запуск функций
var functionSettings = new OllamaPromptExecutionSettings
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke:true), //включаем плагины
    Temperature = 0
};

//отключаем запуск функций
var defaultSettings = new OllamaPromptExecutionSettings
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.None(), //отключаем плагины
    Temperature = 0.3f //добавим креативности
};

Console.ForegroundColor = ConsoleColor.Green;
Console.Write("User: ");

string? input = null;
while ((input = Console.ReadLine()) is not null)
{
    //проверка на необходимость вызова функции
    var answer = await functionNeedAgent.InvokeAsync(kernelLight, new() { ["user_input"] = input });

    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine("functionNeedAgent: " + string.Join("\n", answer));

    //если необходимо вызвать функцию - вызываем
    if (answer.ToString().Contains("function"))
    {
        answer = await kernel.InvokePromptAsync(input, new(functionSettings));
    } else
        answer = await kernel.InvokePromptAsync(input, new(defaultSettings));

    Console.ForegroundColor = ConsoleColor.White;
    Console.WriteLine("AI: " + string.Join("\n", answer));

    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write("User: ");
}

//Плагины
public class MyWeatherPlugin
{
    [KernelFunction, Description("Gets the current weather for the specified city")]
    public string GetWeather(string _city)
    {
        return "very good in " + _city + "!";
    }
}

public class MyTimePlugin
{
    [KernelFunction, Description("Get the current day of week")]
    public string DayOfWeek() => System.DateTime.Now.ToString("dddd");

    [KernelFunction, Description("Get the current time")]
    public string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'");

    [KernelFunction, Description("Get the current date")]
    public string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}

public class MyNewsPlugin
{
    [KernelFunction, Description("Gets the current news for the specified count")]
    public string GetNews(int count)
    {
        return "the ruble strengthened. An alien ship was found in the USA. It's a full moon today.";
    }
}

public class FunctionCallLoggingFilter : IFunctionInvocationFilter
{
    public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
    {
        try
        {
            var values = "";
            foreach (var arg in context.Arguments.Names)
            {
                var val = context.Arguments[arg] == null ? "null" : context.Arguments[arg];
                values += $"{arg}: {val}; ";
            }

            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"call {context.Function.Name}: {values}");
        }
        catch
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"call {context.Function.Name}");
        }

        await next(context);
    }
}

Результат:

Microsoft.Extensions.AI

После долгих поисков в ускорении Microsoft Semantic Kernel, наткнулся на примеры библиотеки, которую собственно использует этот самый Kernel. Установил дополнительный пакет Microsoft.Extensions.AI.Ollama и начал эксперименты... Простые ответы модели (не использующие функции) не стали зависеть от количества плагинов. Однако, на него я потратил много времени пытаясь выяснить: почему результат функции на русском языке передается модели с unicode последовательностями. Никакие ухищрения, как на примере выше с MyTextMemoryPlugin не помогают. Как оказалось этот пакет - устаревший, и больше не поддерживается. Visual Studio рекомендует использовать другой пакет OllamaSharp. Однако этот пакет необходимо для RAG (OllamaEmbeddingGenerator).

OllamaSharp

В сети есть проект https://github.com/awaescher/OllamaSharp который позволяет очень просто работать с Ollama. Устанавливаем пакет OllamaSharp в NuGet. Смотрим как использовать Function Calling https://awaescher.github.io/OllamaSharp/docs/tool-support.html.

Пример с вызовом функций
using OllamaSharp;

var ollama = new OllamaApiClient("http://localhost:11434", "MY:latest");

//доступные функции
List<object> Tools = [new GetWeatherTool(), new DateTool()];

var chat = new Chat(ollama);
while (true)
{
    Console.Write("User: ");
    var message = Console.ReadLine();

    Console.Write("AI: ");
    //передаем сообщение и наши функции
    await foreach (var answerToken in chat.SendAsync(message, Tools))
        Console.Write(answerToken);

    Console.WriteLine();
}

Функции в отдельном файле SampleTools.cs (namespace обязателен):

namespace OllamaSharp;

public static class SampleTools
{
    //обязательные комментарии
    //из них генератор будет брать описание функций для модели

    /// <summary>
    /// Get the current weather for a city
    /// </summary>
    /// <param name="city">Name of the city</param>
    [OllamaTool]
    public static string GetWeather(string city) => $"It's cold at only 6° in {city}.";

    /// <summary>
    /// Get the current date
    /// </summary>
    [OllamaTool] 
    public static string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}

При вводе текста без знаков препинания, без вопросительных и восклицательных знаков — всё ок. Но стОит только ввести в конце вопросительный знак, либо использовать буквы в верхнем регистре — модель обращается к функциям, и ведет себя неадекватно. Как победить эту проблему - пока не ясно.

Пример неадекватности:

User: как тебя зовут
AI: Я – JARVIS.

Второй пример:
User: Как тебя зовут?
AI: Теплое сообщение: <tool_response>

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

Microsoft.Extensions.AI + OllamaSharp

Реализуем сразу и Function Calling, и RAG (аналог мелкософтовского TextMemoryPlugin).

Скрытый текст
using Microsoft.Extensions.AI;
using OllamaAITest;
using OllamaSharp;
using System.ComponentModel;
using System.Numerics.Tensors;

var ollamaClient = new OllamaApiClient(new Uri("http://localhost:11434"), "MY");

IChatClient chatClient = new ChatClientBuilder(ollamaClient)
    //.UseFunctionInvocation() //автозапуск функций отключим, мы будем вручную запускать функции
    //.UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) //кэш результатов отключим, нам нужны свежие результаты
    .Use(async (chatMessages, options, nextAsync, cancellationToken) =>
     {
         await nextAsync(chatMessages, options, cancellationToken);
     })
    .Build();


//генератор векторов
OllamaEmbeddingGenerator ollamaEmbeddingGenerator = new(new Uri("http://localhost:11434"), "MY");

//настройка генерации
var opt = new EmbeddingGenerationOptions()
{
    ModelId = "MY",
    AdditionalProperties = new()
    {
        ["Temperature"] = "0"
    },
};

//факты
string[] facts = [
    "Иван живет в Москве",
    "У Ивана есть три кота",
    "Семён живет в Питере",
    "У Семёна есть три кота",
    "Марина живет в Воркуте",
    "У Марины есть две собаки",
    ];

//генерируем вектора из фактов
var factsEmbeddings = await ollamaEmbeddingGenerator.GenerateAndZipAsync(facts, opt);

//настройка чата
ChatOptions options = new ChatOptions();
options.ToolMode = AutoChatToolMode.Auto;
options.Tools = [
    AIFunctionFactory.Create(GetWeather),
    AIFunctionFactory.Create(DayOfWeek),
    AIFunctionFactory.Create(Time),
    AIFunctionFactory.Create(Date),
    AIFunctionFactory.Create(Recall)
    ];

//история
List<ChatMessage> chatHistory = new();

string answer = "";
bool lastWasTool = false;
do
{
    if (!lastWasTool)
    {
        Console.ForegroundColor = ConsoleColor.Green;
        Console.Write("User: ");
        var userMessage = Console.ReadLine();
        chatHistory.Add(new ChatMessage(ChatRole.User, userMessage));
    }

again:
    var response = await chatClient.GetResponseAsync(chatHistory, options);
    if (response == null)
    {
        Console.WriteLine("No response from the assistant");
        continue;
    }

    foreach (var message in response.Messages)
    {
        chatHistory.Add(message);

        FunctionCallContent[] array = response.Messages.FirstOrDefault().Contents.OfType<FunctionCallContent>().ToArray();
        if (array.Length > 0)
        {
            await ProcessToolRequest(message, chatHistory);
            lastWasTool = true;
        }
    }

    if (lastWasTool)
    {
        lastWasTool = false;
        goto again;
    } else
    {
        answer = string.Join(string.Empty, response.Messages.Select(m => m.Text));
        Console.ForegroundColor = ConsoleColor.White;
        Console.WriteLine($"AI: {answer}");

        chatHistory.Clear();//очищаем
    }

} while (true);

async Task ProcessToolRequest(
    ChatMessage completion,
    IList<ChatMessage> prompts)
{
    foreach (var toolCall in completion.Contents.OfType<FunctionCallContent>())
    {
        //AIFunction aIFunction = options.Tools.OfType<AIFunction>().FirstOrDefault((AIFunction t) => t.Name == toolCall.Name);

        AIFunction aIFunction = options.Tools.OfType<AIFunction>().FirstOrDefault((AIFunction t) => t.Name.Contains(toolCall.Name.Replace("__Main___g__", ""))); //__Main___g__

        var functionName = toolCall.Name;
        var arguments = new AIFunctionArguments(toolCall.Arguments);

        var callLog = string.Join("; ", arguments.Select(x => x.Key.ToString() +": " + x.Value?.ToString() + " ").ToArray());
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine("Call: " + functionName + " " + callLog);

        if (aIFunction == null) continue;
        var result = await aIFunction.InvokeAsync(arguments);

        Console.ForegroundColor = ConsoleColor.Yellow;
        Console.WriteLine("Call result: " + string.Join("\n", result));

        ChatMessage responseMessage = new(ChatRole.Tool,
            [
                new FunctionResultContent(toolCall.CallId, result)
            ]);

        prompts.Add(responseMessage);
    }
}

//функции

[Description("Gets the current weather for the specified city")]
[return: Description("The current weather")]
string GetWeather([Description("The city")]  string _city)
{
    return "The weather in " + _city + " is 30 degrees and sunny.";
}

[Description("Get the day of week")]
string DayOfWeek() => System.DateTime.Now.ToString("dddd", new System.Globalization.CultureInfo("en-EN"));

[Description("Get the time")]
string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'", new System.Globalization.CultureInfo("en-EN"));

[Description("Get the date")]
string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy", new System.Globalization.CultureInfo("en-EN"));

[Description("Search the memory for a given query.")]
[return: Description("Collection of text search result")]
async Task<List<string>> Recall([Description("The query to search for.")] string query)
{
    //настройка генерации
    var o = new EmbeddingGenerationOptions()
    {
        ModelId = "MY",
        AdditionalProperties = new()
        {
            ["Temperature"] = "0"
        },
    };

    //генерируем вектор из запроса
    var userEmbedding = await ollamaEmbeddingGenerator.GenerateAsync(query, o);

    //производим поиск из запроса среди фактов
    var topMatches = factsEmbeddings
        //формируем список
        .Select(candidate => new
        {
            Text = candidate.Value,
            Similarity = TensorPrimitives.CosineSimilarity(candidate.Embedding.Vector.Span, userEmbedding.Vector.Span)
        })
        //relevance - отсекаем слабые совпадения
        /*
        .Where(x => {
            return x.Similarity >= 0.92f;
        })
        */
        //сортируем - сначала самые релевантные
        .OrderByDescending(match => match.Similarity)
        //limit - ограничиваем количество
        .Take(3);

    var result = topMatches.Select(x => x.Text).ToList<string>();

    return result;
}

Результат:

По итогу: первым у нас выполняется генерация векторов фактов, в пределах 3 секунд. Далее, при вводе любого запроса к модели, ей передается информация по всем доступным функциям (json описание). Это самое длительное действие - около 20 секунд. Далее любой запрос, в том числе когда модель выполняет функцию - от 3 до 7 секунд. Неплохо для неразогнанного Intel Core i7-3770K из далёкого 2012 года.

Для лучшего понимания того, как происходит общение с моделью, можно поставить точку останова на chatHistory.Clear():

  1. [user] Text = "у кого есть три кота" добавляем запрос пользователя в историю чата. Запускаем инфер истории чата.

  2. [assistant] FunctionCall = 3a5d650f, Main_g__Recall_7([query, У кого есть три кота?]) модель анализирует запрос, формирует и передает нам json с заполненными полями. Формируется уникальный номер запуска функции. Мы эту функцию запускаем. А так же добавляем результат в историю чата.

  3. [tool] FunctionResult = 3a5d650f, [ "У Семёна есть три кота", "Семён живет в Питере", "У Ивана есть три кота"] добавляем результат функции с ее уникальным номером в историю чата. Запускаем инфер истории чата.

  4. [assistant] Text = "Итак, у Семёна и у Ивана по три кота." модель выдает результат после анализа п.1 и п.3.

Итого: обычный запрос - 1 инфер. Запрос с выполнением функции - 2 инфера моделью.

Попробуем добавить что-то посложнее, например плагин добавляющий напоминания. Для вывода списка со своей структурой (MyEvent), необходимо не забыть дописать методы доступа к внутренним полям структуры{ get; set; }, иначе модель получит пустой список.

MyEventPlugin
using System.ComponentModel;

namespace OllamaAITest
{
    public class MyEventPlugin
    {
        public class MyEvent
        {
            public string description { get; set; }
            public DateTime time { get; set; }
        }

        List<MyEvent> events = new List<MyEvent>();

        [Description("Sets an alarm at the specified time with format 'hour:minut' and specified exact description")]
        public string SetEvent(string time, string? description)
        {
            foreach (var item in events)
            {
                //переписываем описание
                if (item.time.Equals(time))
                {
                    item.description = description;
                    return $"Event updated by description";
                }

                if (item.description.Equals(description))
                {
                    item.time = DateTime.Parse(time);
                    return $"Event updated by time";
                }
            }
            //ничего не нашлось - добавляем
            events.Add(new MyEvent() { time = DateTime.Parse(time), description = description });
            return $"Event set for time: {time}";
        }

        [Description("Remove one Event at the provided specified time with format 'hour:minut' or specified description")]
        public string RemoveEvent(string time, string? description)
        {
            var deleteCount = 0;
            var index = 0;
            while (index < events.Count)
            {
                var item = events[index];
                if (item.time.Equals(time) || item.description.Equals(description))
                {
                    events.RemoveAt(index);
                    deleteCount++;
                }
                else
                {
                    index++;
                }
            }

            if (deleteCount > 0) return $"Droped {deleteCount} Events";

            return $"Nothing deleted";
        }

        [Description("Remove all Events")]
        public string RemoveAllEvents()
        {
            events.Clear();
            return $"All Event is dropped";
        }

        [Description("List all Events")]
        [return: Description("Events with description and datetime")]
        public List<MyEvent> ListEvents()
        {
            return events;
        }
    }
}

А теперь добавим в Program.cs:

var events = new MyEventPlugin();

//настройка чата
ChatOptions options = new ChatOptions();
options.ToolMode = AutoChatToolMode.Auto;
options.Tools = [
    ...
    //ага вот эти ребята
    AIFunctionFactory.Create(events.SetEvent),
    AIFunctionFactory.Create(events.ListEvents),
    AIFunctionFactory.Create(events.RemoveEvent),
    AIFunctionFactory.Create(events.RemoveAllEvents),
    ];

Результат:

Итого

Дорогой дневник, мне не передать ту боль и страдания, которые я перенёс...

На протяжении 7 месяцев, перерыв почти весь github, перепробовав мыслимое и немыслимое количество вариантов, таки удалось выполнить минимум: заставить модель работать более-менее сносно на русском языке на слабом железе.

Работа с урезанной версией LLM полна страданий. Модель понимает только очень простые вещи. Если плохо понимает на русском языке - необходимо переходить на английский. Модель внезапно может быть очень болтливой. Любой лишний символ в описании функции может поломать диалог с моделью. Добавление новой функции может повлиять на работу ранее добавленных функций. Чем больше функций - тем медленнее первый ответ. Модель не поддерживает тип DateTime в функциях (вернее конвертор json внутри компонента работы с моделью). Да и любые другие типы кроме string и int прибавят вам головной боли.

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

Да, нам не удалось сделать полноценного AI-ассистента. В данном случае модель выполняет в основном функцию оператора if then else, часто с нестабильным результатом.

Если нужен рабочий вариант: это n8n + полноценные LLM. А на сегодня - всё.

n8n
n8n

Алиса, отбой...

Комментарии (1)


  1. nbkgroup
    01.07.2025 12:59

    Работа с урезанной версией LLM полна страданий. Модель понимает только очень простые вещи. Если плохо понимает на русском языке - необходимо переходить на английский.

    Попробуйте gemma3.