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

от автора

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

Вокруг столько движухи вокруг ИИ: бесплатный 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 friend», этот текст разбивается на три токена «hello» «my» «friend», причем каждый привязан к определенному числу. В итоге в нейронку передается три числа, как пример: 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://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 modelEmbeddID = "MY" //для RAG var url = "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(url), modelEmbeddID);  //настройка генерации var opt = new EmbeddingGenerationOptions() {     ModelId = modelID,     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, можно использовать отдельную модель для эмбеддингов. В таком случае необходимо переопределить переменную var modelEmbeddID = "MYEmbedd", предварительно добавив эту модель в Ollama.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/914392/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *