Search
Write a publication
Pull to refresh

Алиса, подвинься

Level of difficultyEasy
Reading time42 min
Views16K

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

Вокруг столько движухи вокруг ИИ: бесплатный 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).

Фактически инференс - это операции сложения/вычитания/умножения/деления тензоров. Помните курс линейной алгебры в 11 классе по операциям с матрицами? Так вот матрицы (тензоры) в данном случае состоят из чисел с плавающей запятой. На обычном центральном процессоре (CPU) это довольно ресурсоемкие операции. Но на видеокарте (GPU) имеющей множество микроядер заточенных под рисование линий, вращение 3D объектов (то же самое перемножение матриц) - дело обстоит чуть лучше. Поэтому инференс на видеокарте будет существенно быстрее чем на центральном процессоре.

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

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

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

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

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

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

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

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

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

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

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

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

У каждого типа модели есть свой шаблон (TEMPLATE, тэмплэйт) общения: некая конструкция из управляющих тэгов, которую понимает только эта модель. Например "ChatML" (Chat Markup Language):

<| im_start |> user
привет <| im_end |>
<| im_start |> assistant

См.: https://huggingface.co/learn/llm-course/chapter11/2

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

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

Некоторые модели могут "рассуждать": логически анализировать и делать выводы. Такой процесс называется ризонинг. В некоторых моделях прямо в запросе (в промпте) можно управлять процессом рассуждения, добавляя команду /think либо /nothink.

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

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

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

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

Существует так же архитектура Mixture-of-Experts (MoE, "смесь экспертов"), когда в инференсе участвует не вся модель разом, а отдельные ее участки - "эксперты", что позволяет ускорить инференс кратно.

Обучение модели намного более трудоёмкий процесс чем инференс. Качество готовой модели сильно зависит от «датасета»: исходных данных для обучения с правильными вариантами ответа. Первая часть обучения называется «претрейн» (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://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 f:\GPT4ALL\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 <toolcall></toolcall> XML tags:
<toolcall>
{"name": <function-name>, "arguments": <args-json-object>}
</toolcall>
{{- 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 }}<toolcall>
{{ range .ToolCalls }}{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{ end }}</toolcall>
{{- 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".

Что интересно, после релиза Ollama v0.5.11 (а может и выше), эта модель основанная на Qwen2.5 (и возможно остальные модели из этого "семейства") перестала выполнять функции. Пришлось править файл Modelfile (менять с "tool_call" на "toolcall"). Поэтому если вы скачаете производную модель от Qwen2.5 с сайта 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 modelID = "MY"
var url = "http://localhost:11434";

//для RAG
var modelEmbeddID = "MY" 
var urlEmbedd = "http://localhost:11434";

var ollamaClient = new OllamaApiClient(new Uri(url), modelID);

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(urlEmbedd), modelEmbeddID);

//настройка генерации
var opt = new EmbeddingGenerationOptions()
{
    ModelId = modelEmbeddID,
    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 = modelEmbeddID,
        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 года.

Для реализации RAG, можно использовать отдельную модель для эмбеддингов. В таком случае необходимо переопределить переменную modelEmbeddID , предварительно добавив эту модель в Ollama. Если эмбеддинг модель будет находится на другом сетевом порту, либо на другом ПК - можно так же переопределить urlEmbedd.

Для лучшего понимания того, как происходит общение с моделью, можно поставить точку останова на 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),
    ];

Результат:

Microsoft.Extensions.AI + llama.cpp

Избавимся от Ollama, и воспользуемся напрямую llama.cpp.

Заходим на страницу скачивания последнего релиза: https://github.com/ggml-org/llama.cpp/releases. В моем случае качаем llama-b5849-bin-win-cpu-x64.zip для windows. Распаковываем архив в удобную для нас папку. И создаем файл llamacpp.cmd для запуска сервера:

llama-server --temp 0.0 --no-webui -fa --embedding --pooling mean --jinja --chat-template chatml --chat-template-file Qwen2.5-1.5B-Instruct.jinja.txt -m Vikhr-Qwen-2.5-1.5b-Instruct-Q8_0.gguf --host 127.0.0.1 --port 11434

Что же тут происходит?

Согласно документации https://github.com/ggml-org/llama.cpp/tree/master/tools/server, у нас следующие настройки:

  • --temp 0.0 - температура 0

  • --no-webui - отключаем web интерфейс

  • -fa - включение технологии "Flash Attention"

  • --embedding - включает генерацию эмбеддингов

  • --pooling mean - а это очень важная вещь. Любое значение кроме none - позволяет работать с эмбеддингами в OpenAI совместимом режиме. Проще говоря это возможность работать с эмбеддингами через компонент OpenAIClient.

  • --jinja - включает работу с функциями (Function Calling)

  • --chat-template chatml - шаблон (template) по умолчанию для работы с большинством моделей. Эту опцию можно убрать, если использовать --chat-template-file (см. ниже)

  • --chat-template-file Qwen2.5-1.5B-Instruct.jinja.txt - здесь мы можем сами указать свой шаблон для работы с нашей моделью

  • -m Vikhr-Qwen-2.5-1.5b-Instruct-Q8_0.gguf - путь к файлу модели

  • --host 127.0.0.1 - хост

  • --port 11434 - порт

  • -v - эта опция лога покажет в консоли содержимое переданное в модель и обратно в клиентское приложение. Полезно если необходимо подсмотреть в каком формате происходит вызов процедур или генерация эмбеддингов.

Шаблон (template) в формате jinja для работы с моделью, файл Qwen2.5-1.5B-Instruct.jinja.txt (исходный вариант там же https://github.com/ggml-org/llama.cpp/tree/master/models/templates):

Qwen2.5-1.5B-Instruct.jinja.txt
{%- if tools %}
    {{- '<|im_start|>system\n' }}
    {%- if messages[0]['role'] == 'system' %}
        {{- messages[0]['content'] }}
    {%- else %}
        {{- 'You are JARVIS. You are a helpful assistant. Answer user requests briefly.' }}
    {%- endif %}
    {{- "\n\n# Tools\n\nYou may call one or more functions to assist with the user query. Do not distort user description to call functions!\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
    {%- for tool in tools %}
        {{- "\n" }}
        {{- tool | tojson }}
    {%- endfor %}
    {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
{%- else %}
    {%- if messages[0]['role'] == 'system' %}
        {{- '<|im_start|>system\n' + messages[0]['content'] + '<|im_end|>\n' }}
    {%- else %}
        {{- '<|im_start|>system\nYou are JARVIS. You are a helpful assistant. Answer user requests briefly.<|im_end|>\n' }}
    {%- endif %}
{%- endif %}
{%- for message in messages %}
    {%- if (message.role == "user") or (message.role == "system" and not loop.first) or (message.role == "assistant" and not message.tool_calls) %}
        {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
    {%- elif message.role == "assistant" %}
        {{- '<|im_start|>' + message.role }}
        {%- if message.content %}
            {{- '\n' + message.content }}
        {%- endif %}
        {%- for tool_call in message.tool_calls %}
            {%- if tool_call.function is defined %}
                {%- set tool_call = tool_call.function %}
            {%- endif %}
            {{- '\n<tool_call>\n{"name": "' }}
            {{- tool_call.name }}
            {{- '", "arguments": ' }}
            {{- tool_call.arguments | tojson }}
            {{- '}\n</tool_call>' }}
        {%- endfor %}
        {{- '<|im_end|>\n' }}
    {%- elif message.role == "tool" %}
        {%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != "tool") %}
            {{- '<|im_start|>user' }}
        {%- endif %}
        {{- '\n<tool_response>\n' }}
        {{- message.content }}
        {{- '\n</tool_response>' }}
        {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
            {{- '<|im_end|>\n' }}
        {%- endif %}
    {%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
    {{- '<|im_start|>assistant\n' }}
{%- endif %}

Файл должен быть сохранен в кодировке UTF-8.

Немного переделаем код под работу с llama.cpp через OpenAIClient компонент. В нашем проекте будет всего три пакета из NuGet: Microsoft.Extensions.AI, Microsoft.Extensions.AI.OpenAI, System.Numerics.Tensors.

Результат:

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

var modelID = "MY";//для llama-server не имеет значения, но должно быть не пусто
var url = "http://localhost:11434/v1";

//для RAG
var modelEmbeddID = "MY";
var urlEmbedd = "http://localhost:11434/v1";

var aiopt = new OpenAIClientOptions() { Endpoint = new Uri(url) };
var aicred = new ApiKeyCredential("aaa"); //не имеет значение. можно задать как опцию --api-key при запуске llama-server

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


//генератор векторов
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator = new OpenAIClient(aicred, aiopt).GetEmbeddingClient(modelID).AsIEmbeddingGenerator();

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

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

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

var events = new MyEventPlugin();

//настройка чата
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),
    AIFunctionFactory.Create(events.SetEvent),
    AIFunctionFactory.Create(events.ListEvents),
    AIFunctionFactory.Create(events.RemoveEvent),
    AIFunctionFactory.Create(events.RemoveAllEvents),
    ];

//история
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;

        object result;
        bool iserror = false;
        try
        {
            result = await aIFunction.InvokeAsync(arguments);
        } catch (Exception e) {
            result = e.Message;
            iserror = true;
        }

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

        if (iserror) result = "function call error";

        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 = modelID,
        AdditionalProperties = new()
        {
            ["Temperature"] = "0"
        },
    };

    //генерируем вектор из запроса
    //var userEmbedding = await ollamaEmbeddingGenerator.GenerateAsync(query, o);
    var userEmbedding = await embeddingGenerator.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;
}

Вариант не такой стабильный как с Ollama. Но Ollama - фактически оболочка для запуска llama.cpp, поэтому достичь результата как с Ollama возможно. Необходимо только немного подкорректировать шаблон в Qwen2.5-1.5B-Instruct.jinja.txt, а так же поиграться с параметрами запуска llama-server.

Мультимодальность

Продолжим эксперименты с Microsoft.Extensions.AI + llama.cpp. Попробуем передать модели изображение, и спросить у неё что собственно на нем нарисовано. Для этого надо найти модель которая это умеет.

Скачиваем gemma-3-4b-it-Q4_K_M.gguf и mmproj-model-f16.gguf с https://huggingface.co/ggml-org/gemma-3-4b-it-GGUF/tree/main.

Создаем файл llamacpp.cmd для запуска сервера:

llama-server -m gemma-3-4b-it-Q4_K_M.gguf --mmproj mmproj-model-f16.gguf --host 127.0.0.1 --port 11434

Новый параметр --mmproj позволяет подключить "обработчик изображений", который будет помогать "главной" модели с изображениями.

Создадим изображение размером 30x30 пикселей в формате jpg.

Передадим изображение и команду модели, предварительно увеличив таймаут ожидания от модели (на слабом CPU это будет долго):

Скрытый текст
var aiopt = new OpenAIClientOptions() { Endpoint = new Uri(url), NetworkTimeout = TimeSpan.FromMinutes(10) }; //добавим таймаут 10 минут
var aicred = new ApiKeyCredential("aaa"); //не имеет значение. можно задать как опцию --api-key при запуске llama-server

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

...

//загружаем изображение
var img = Convert.ToBase64String(File.ReadAllBytes("C:\\Temp\\testllm.jpg"));
//передаем в формате base64
var data = new DataContent("data:image/jpeg;base64," + img, "image/jpeg");
//команда модели
var text = new TextContent("Please describe the following image. Answer briefly.");

chatHistory.Add(new ChatMessage(ChatRole.User, [data, text]));

//инференс
var response = await chatClient.GetResponseAsync(chatHistory, options);

Можно так же перейти на web интерфейс по адресу http://127.0.0.1:11434 и сделать то же самое с помощью мышки и клавиатуры:

Распознавание изображения через web интерфейс

Итого

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

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

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

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

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

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

n8n
n8n

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

Tags:
Hubs:
+44
Comments14

Articles