Статья обзорная, для динозавров, которые только сейчас очнулись из беспросветного сна неведения. Таким динозавром собственно являюсь я сам. Все термины, описание, мыслеформы и прочее, никак не претендуют на точность и истину в последней инстанции. На вопросы "а почему не использовали инструмент Х" отвечу: так получилось. Статья была написана в свободное от работы время, практически урывками.
Приятного чтения.
Вокруг столько движухи вокруг ИИ: бесплатный DeepSeek R1 обвалил акции ИТ гигинтов США! Tulu 3 превзошла DeepSeek V3! Qwen 2.5-VL от Alibaba обошел DeepSeek! Ну и т.д. и т.п.
А что это за ИИ такой? Программисты с помощью его пишут код, копирайтеры пишут текст, дизайнеры рисую дизайны (тот же Ионов от студии Артемия Лебедева), контент-мейкеры генерируют рисунки и видео.
А что остаётся нам, обычным людям? Алиса, Маруся, Салют, Сири, Кортана, Алекса, Bixby, и прочее.
Это конечно хорошо, но все эти замечательные ИИ ассистенты нам полностью не принадлежат. Мы не можем их полностью контролировать. Вся наша жизнь благодаря этим онлайн ассистентам — как открытая книга для корпораций, которые не прочь на нас заработать.
А что если ...мы попробуем сделать своего собственного ИИ ассистента?
Что мы хотим?
Алиса и прочие ИИ ассистенты — слушают команды с микрофона и выполняют определенные действия. Было бы неплохо иметь свой собственный аналог заточенный на свои собственные потребности, который не будет самостоятельно лезть в интернет, и сливать личную информацию. Давайте назовем своего ИИ ассистента не банально «Джарвис». И команды он будет выполнять только если первое слово в предложении - «Джарвис».
К примеру, как может выглядеть управление своим загородным домом
время, дата (выдает текущее время и дату)
какие сегодня новости? (выдает заголовки топ 5 новостей)
какая погода в москве и питере? (выдает погоду)
проверь почту (выдает заголовки непрочитанных писем эл.почты)
закажи суши (вызов API магазина суши если таковой имеется)
отправь СМС брату: «сегодня не приеду, весь день занят» (вызов API отправки СМС)
курс доллара (выводит стоимость 1 доллара в рублях по курсу ЦБ)
включи музыку бетховен симфония номер пять (запуск плеера)
будильник на завтра в 17:15 (добавление будильника)
отмени все дела сегодня (отмена всех будильников)
включи робот-пылесос (команда «умному дому»)
включи телевизор в кухне (команда «умному дому»)
закрой шторы в гостиной (команда «умному дому»)
выключи свет в коридоре, в прихожей и в ванной (команда «умному дому»)
поставь дом на охрану (команда «умному дому»)
Таким образом, ассистент должен знать какую-то контекстную личную информацию чтобы мог выполнить определенные команды: какие есть комнаты, что есть контакт с определенным номером телефона, адрес дома в который необходимо заказать суши, и т.д.
Ассистент в нашем случае — по сути это компьютер с микрофоном и колонками, включенный в локальную сеть, имеющий доступ к интернету, локально запускающий ИИ, который понимает и выполняет наши устные команды. Желательно чтобы всё это работало на недорогом ПК, без крутой видеокарты.
С чего начать?
Начнем с распознавания голоса. Т.к. я уже знаком с замечательным инструментом для распознавания голоса https://alphacephei.com/vosk/index.ru, проект: https://github.com/alphacep/vosk-api – то его и будем использовать.
Для начала установим пакет Vosk в NuGet. Затем скачаем и разархивируем модель vosk-model-small-ru-0.22 (всего 45Мб) из https://alphacephei.com/vosk/models.
Для работы с микрофоном установим пакет NAudio. А для озвучивания ответов Джарвиса будем использовать TTS (Text-to-Speech). По умолчанию в системе может не быть русского мужского голоса, поэтому можно установить подходящий с сайта https://rhvoice.su/voices. В настройках приложения снимаем галку «Предпочтительная 32-разрядная версия».
Cобственно код
using NAudio.Wave;
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Speech.Synthesis;
using Vosk;
namespace ConsoleJarvis
{
internal class Program
{
private class RecognizeWord
{
public double conf { get; set; }
public double end { get; set; }
public double start { get; set; }
public string word { get; set; }
}
private class RecognizeResult
{
public RecognizeWord[] result { get; set; }
public string text { get; set; }
}
static void Main(string[] args)
{
//модель
Model model = new Model("vosk-model-small-ru-0.22");
//настраиваем "распознаватель"
var recognizer = new VoskRecognizer(model, 16000.0f);
recognizer.SetMaxAlternatives(0);
recognizer.SetWords(true);
//настраиваем и «включаем» микрофон
var waveIn = new WaveInEvent();
waveIn.DeviceNumber = 0; //первый микрофон по умолчанию
waveIn.WaveFormat = new WaveFormat(16000, 1); //для лучшего распознавания
waveIn.DataAvailable += WaveIn_DataAvailable;
waveIn.StartRecording();
//получаем данные от микрофона
void WaveIn_DataAvailable(object sender, WaveInEventArgs e)
{
//распознаем
if (recognizer.AcceptWaveform(e.Buffer, e.BytesRecorded))
{
//получаем распознанный текст в json
string txt = recognizer.FinalResult();
//преобразуем
RecognizeResult values = JsonConvert.DeserializeObject<RecognizeResult>(txt);
//парсим команды
parseCommands(values);
}
}
Console.WriteLine();
Console.WriteLine("Скажите одну из команд:");
Console.WriteLine("Джарвис, список команд!");
Console.WriteLine("Джарвис, дата");
Console.WriteLine("Джарвис, время");
Console.WriteLine("Джарвис, запусти блокнот");
Console.WriteLine("Джарвис, выход");
Console.WriteLine("Напишите 'exit' и нажмите Enter что-бы выйти");
Console.WriteLine();
var input = Console.ReadLine();
while (input != "exit")
{
}
}
//генерируем ответ
static void PlayTTS(string text)
{
var synthesizer = new SpeechSynthesizer();
synthesizer.SetOutputToDefaultAudioDevice(); //аудио-выход по умолчанию
//synthesizer.SelectVoice(voiceName); //выбор голоса
var builder = new PromptBuilder();
builder.StartVoice(synthesizer.Voice);
builder.AppendText(text);
builder.EndVoice();
//генерируем звук
synthesizer.Speak(text);
}
static void parseCommands(RecognizeResult words)
{
if (words.text.Length == 0) return;
Console.WriteLine("Распознано: " + words.text + Environment.NewLine);
//если в предложении первое слово джарвис - слушаем команду
if (words.result.First().word.Contains("джарвис"))
{
var text = words.result.Select(obj => obj.word).ToList();
var print = string.Join(" ", text);
var command = string.Join(" ", text.Skip(1)); //Skip(1) - пропускаем первое слово "джарвис"
//логируем команду
Console.WriteLine(print);
var executerComment = "";
if (command.Trim().Length == 0)
{
Console.WriteLine("Джарвис: что?");
PlayTTS("что?");
}
else if (!Executer.Parse(command, ref executerComment)) //выполняем команды
{
Console.WriteLine("Джарвис: Команда не распознана");
PlayTTS("Команда не распознана");
}
else
{
Console.WriteLine("Джарвис: "+executerComment);
PlayTTS(executerComment);
}
Console.WriteLine("");
}
}
}
}
Доступные команды, файл Executer.cs:
using System;
using System.Collections.Generic;
using System.Globalization;
namespace ConsoleJarvis
{
public static class Executer
{
public delegate void Func(string text, ref string comment);
private class Command
{
public string word { get; set; }
public Func action { get; set; }
public Command(string word, Func action)
{
this.word = word;
this.action = action;
}
}
private static readonly List<Command> commands = new List<Command>();
static Executer()
{
//Добавляем все доступные команды
commands.Add(new Command("список команд", (string text, ref string comment) =>
{
foreach (var c in commands) {
comment = comment + Environment.NewLine + c.word + '.';
}
}));
commands.Add(new Command("дата", (string text, ref string comment) =>
{
comment = DateTime.Now.ToString("dddd dd MMMM yyyy", CultureInfo.CurrentCulture);
}));
commands.Add(new Command("время", (string text, ref string comment) =>
{
comment = DateTime.Now.ToString("H mm", CultureInfo.CurrentCulture);
}));
commands.Add(new Command("запусти", (string text, ref string comment) =>
{
foreach (var c in text.Split(' '))
{
switch (c)
{
case "калькулятор":
System.Diagnostics.Process.Start(@"calc.exe");
return;
case "блокнот":
System.Diagnostics.Process.Start(@"notepad.exe");
return;
}
}
comment = "я не умею запускать ничего кроме калькулятора и блокнота";
}));
commands.Add(new Command("выход", (string text, ref string comment) =>
{
Environment.Exit(0);
}));
}
public static bool Parse(string text, ref string comment)
{
foreach (var command in commands)
{
if (text.Contains(command.word))
{
command.action(text, ref comment);
return true;
}
}
return false;
}
}
}
Пример распознанного текста:
{
"result" : [{
"conf" : 1.000000,
"end" : 1.110000,
"start" : 0.630000,
"word" : "джарвис"
}, {
"conf" : 1.000000,
"end" : 1.410000,
"start" : 1.110000,
"word" : "курс"
}, {
"conf" : 1.000000,
"end" : 1.860000,
"start" : 1.410000,
"word" : "доллара"
}],
"text" : "джарвис курс доллара"
}
Вот так с помощью нехитрых приспособлений буханка белого хлеба превратилась в троллейбус мы получили «Голосовой ассистент Ирина» https://habr.com/ru/articles/595855/.
Пробуем LLM
Первым делом для запуска LLM моделей локально, гугл советует установить LM Studio. После установки выяснилось что для запуска модели необходима поддержка процессором инструкции AVX2. К сожалению такой инструкции на Intel(R) Core(TM) i7-3770K нет.
Следующее что попробовал установить: GPT4ALL. Программа позволяет загрузить модели из своего списка «оптимизированные для GPT4ALL», а так же с некоего HuggingFace.
Что такое HuggingFace? Оказывается это целая платформа с большой библиотекой нейросетевых моделей. Выбрал в поиске самую малую модель Jarvis-0.5B.f16.gguf размером примерно 948Мб.
Пишу «привет». Зашумел процессорный кулер, и через секунду оно ответило.

Вот оно что! Вот зачем нужен апгрейд ПК! Не банальная причина поиграть в игры! А ради вот этого самого!
Отличный вариант попробовать модели — koboldcpp.
Но погодите, прежде чем рваться в код, надо немного теории.
LLM, БЯМ, Нейронка, Чат-гпт. Краткий понятийный курс
Что-бы прикоснуться к прекрасному миру LLM, окунемся немного в теорию. Что такое нейронка, с чем ее едят. Пояснения для тех кто особо никогда этим не интересовался, но хочет понять. Дальнейшие рассуждения — мои личные наблюдения, так сказать простым понятным языком. Просьба не судить строго за стиль письма.
Фактически нейронка — это массив чисел загруженный в оперативную память, которому подают на вход какую-либо информацию (текст, фото, звук), и эта информация, проходя через массив, выдает определенный результат.
Сам процесс обработки информации нейронкой называется «инференс» (inference).
Как нейронка понимает какую информацию мы ей передаем, и какими единицами она «мыслит»? Понятно что раз нейронка — это массив чисел, то и подавать на вход ей надо числа. Текст который вы пишете нейронке, разбивается на «токены» - кусочки текста, которые в нейронке соотносятся к определенному числу. Поэтому когда вы пишете: «hello my frend», этот текст разбивается на три токена «hello» «my» «frend», причем каждый привязан к определенному числу. В итоге в нейронку передается три числа, как пример: 12234, 42112, 234345.
Т.е. у нейронки есть определенный словарь токенов: связь токена и числа (вектор).
Размер нейронки — не бесконечный, мы не можем передать ей бесконечное количество информации за один раз. Мы можем передать ей на вход ограниченное количество токенов. Такое ограничение называется «Контекстным окном». Чем больше и круче нейронка — тем больше контекстное окно, т.е. больше токенов можно передать в нейронку.
Так сложилось, что первая LLM была сделана в США и «понимала» только английские слова. Когда нейронки стали обучать другим языкам, то пришлось увеличивать словарь токенов. В русском языке одно слово может сильно видоизменяться по падежам, и раздувать словарь токенов одним словом в разных падежах — это очень нерационально. Придумали лайфхак: разбивать слова на куски, из которых можно собрать любое слово. Поэтому текст «привет мой друг» преобразуется не на три токена, а на большее количество: «при» «вет» « » «мой» « » «др» «уг». В итоге в нейронку передается уже 7 токенов а не три.
Поэтому в определенное «контекстное окно» входит больше слов на английском языке, и меньше — на русском.
Инференсом можно управлять: дать волю нейронке или сдержать ее фантазию. Для этого существует параметр «Температура»: чем выше — тем больше нейронка фантазирует. Однако чем больше температура, тем больше вероятность что нейронка будет «галлюцинировать»: выводить совершенно бессвязный ответ.
Другой параметр «TopK» позволяет при генерации ответа сузить диапазон выдаваемых слов, отбросив самые нерелевантные варианты. Например генерируя фразу: «Солнце встает на…» нейронка может вставить последнее слово «картина». Но если мы укажем параметр TopK=3, то нейронка выберет один из топ 3 самых релевантных слов: «восток», «рассвет», «небо».
Еще один параметр «Frequency penalty» - штраф за частоту: модель получает «штраф» за каждое повторение токена в ответе, что увеличивает разнообразие ответа.
«Presence penalty» - штраф за присутствие: модель получает «штраф» за повторяющийся токен, и генерирует новый неповторяющийся токен в ответе.
Когда мы что-то пишем нейронке — наш текст называется «промпт» (prompt): запрос пользователя.
Мы так же можем управлять нейронкой подобрав правильный промт: попросить анализировать текст, сгенерировать новый, ответить в определенном стиле, выделить важное, суммировать информацию и т.д. В ход идут не только слова, но и знаки препинания (!), написание важных слов В ВЕРХНЕМ РЕГИСТРЕ, выделение звездочками, кавычками и т.д. Искусство промптинга называется «Промпт-инжиниринг».
Что-бы придать нейронке определенный стиль, придать направление общения, применяется «Системный промпт», например: «Ты — полезный помощник Олег. Отвечай кратко и по делу. Не задавай лишних вопросов». Системный промпт задается в начале диалога с пользователем.
Нейронка в своих ответах опирается на «контекст», который формируется как системным промптом, так и в процессе общения с пользователем. Кроме того нейронка так же ориентируется на историю общения. Поэтому очистив историю и контекст, общение с нейронкой начнется как с чистого листа.
Нейронка может работать не только с текстом, но и с изображением, видео, звуком. Такие нейронки называют «мультимодальными».
Размер нейронки можно характеризовать количеством «параметров»: т.е. количеством чисел (весов) из которых состоит массив нейронки. Аналог параметра в живой природе — сила связи между нейронами. Есть модели на 1, 10, 70, 180 миллиардов параметров.
Для того что-бы пользоваться нейронкой, необходимо иметь достаточно оперативной памяти что-бы нейронка могла туда загрузиться полностью. Кроме того, что-бы нейронка работала шустро — нужен достаточно мощный процессор, который должен поддерживать определенные процессорные инструкции (AVX, AVX2). Но если у вас есть мощная видеокарта с большим количеством памяти — вам повезло: инференс будет намного быстрее чем на центральном процессоре ПК.
Но не у всех есть видеокарта с 24-80 Гб. для запуска полноценных нейронок. Можно уменьшить размер нейронки почти не теряя в качестве. Такой процесс называется «квантизацией»: весь массив чисел (весов) из которых состоит нейронка преобразуют в массив чисел с меньшей точностью. Например в массиве были числа примерно такие: 0.123456789 а стали: 0.123. Почитать об этом можно здесь: https://habr.com/ru/articles/797443/
Обучение модели намного более трудоёмкий процесс чем инференс. Качество готовой модели сильно зависит от «датасета»: исходных данных для обучения с правильными вариантами ответа. Первая часть обучения называется «претрейн» (pretrain, предобучение) — самый трудозатратный процесс для железа на котором идет обучение. Модель обучают общими знаниями о мире. Такая «претрейн» модель практически бесполезна для общения.
Далее идет «файнтюн» (finetune, тонкая настройка): модель учится отвечать на датасетах с диалогами. Третья необязательная часть обучения «алаймент» (alignment, выравнивание): настройка модели на корректный и безопасный вывод (например, исключение неэтичных тем).
Готовая модель содержит знания только на тот момент когда ее обучали, и будет отвечать только в контексте прошедшего времени. Если ее спросить какой сейчас год — она ответит например 2022, если обучалась на данных того момента времени. Постоянно дообучать модель — накладно. Кроме того, модель знает только обобщенную информацию.
Поэтому разработчики чат-ботов используют следующие инструменты:
Function Calling – модели говорят что есть определенные функции например «вывод погоды» и т.д. И если пользователь спросит «какая погода в Москве», модель может выполнить эту функцию (заранее реализованную программистом), вернув пользователю ответ.
RAG (Retrieval Augmented Generation) — модель может обратиться к массиву данных (через Function Calling) любезно предоставленным программистом для вывода информации пользователю. Либо сам разработчик подкидывает найденную информацию по запросу в промпт или в историю чата. Для этого заранее сканируются документы, где текстовая информация индексируется весами модели (формируется массив векторов для поиска).
Поиск информации в интернете, включение лампочки и прочее — всё реализуется через Function Calling. Сама модель не умеет самостоятельно лезть в гугл или открывать вам шторы в комнате по команде.
А еще по причине того, что нейронка не может дообучаться и расти как живой организм, ни о каком захвате скайнета пока речи не идет.
Ок. Пока на этом всё. Идем дальше.
NPU
В современных процессорах есть блок NPU (Neural Processing Unit). По сути это небольшой "сопроцессор" заточенный на выполнение операций с матрицами. Задействовать его можно через библиотеку Intel® NPU Acceleration Library на пайтоне: https://github.com/intel/intel-npu-acceleration-library. Так же можно задействовать через OpenVINO, DirectML.
В перспективе, для запуска больших ИИ моделей можно обойтись этим блоком, напихав побольше ОЗУ в ПК, без траты на дорогие видеокарты с памятью от 24Гб.
Но у меня старый процессор. Поэтому оставим это на будущее.
Пробуем в код. LlamaSharp, он же llama.cpp
Итак, наша задача: попробовать запустить модель напрямую, и желательно без python. Сам пайтон очень тяжелый, а нам нужно максимально облегчить работу с LLM. Простите питонисты.
На гитхабе есть проект https://github.com/ggerganov/llama.cpp на C/C++. А на c# есть замечательный проект https://github.com/SciSharp/LlamaSharp использующий llama.cpp, вот его и будем использовать.
Сразу скажу, много перепробовал моделей, но в итоге самой быстрой оказалась: Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf, см.: https://huggingface.co/Vikhrmodels/QVikhr-2.5-1.5B-Instruct-r_GGUF/tree/main
Добавляем в проект пакеты из NuGet: LlamaSharp и LlamaSharp.Backend.Cpu, без которого не удастся запустить и инферить модель.
Простой пример
using LLama.Common;
using LLama;
using LLama.Sampling;
using LLama.Transformers;
//путь к модели
string modelPath = @"c:\models\QVikhr-2.5-1.5B-Instruct-r-Q8_0.gguf";
var parameters = new ModelParams(modelPath)
{
ContextSize = 1024, //контекст
};
//загружаем модель
using var model = LLamaWeights.LoadFromFile(parameters);
//создаем контекст
using var context = model.CreateContext(parameters);
//для инструкций
var executorInstruct = new InstructExecutor(context);
//для интерактива
var executorInteractive = new InteractiveExecutor(context);
//каждый раз сбрасывает контекст
var executorStateless = new StatelessExecutor(model, parameters)
{
ApplyTemplate = true,
SystemMessage = "Ты - полезный помощник. Отвечай кратко"
};
//параметры инференса
InferenceParams inferenceParams = new InferenceParams()
{
MaxTokens = 256, //максимальное количество токенов
AntiPrompts = new List<string> { ">" },
SamplingPipeline = new DefaultSamplingPipeline()
{
Temperature = 0.4f
,TopK = 50
,TopP = 0.95f
,RepeatPenalty = 1.1f
}
};
//загружаем из модели правильный шаблон для промпта
LLamaTemplate llamaTemplate = new LLamaTemplate(model.NativeHandle)
{
AddAssistant = true
};
//генерируем правильный промпт
string createPrompt(string role, string input)
{
var ltemplate = llamaTemplate.Add(role, input);
return PromptTemplateTransformer.ToModelPrompt(ltemplate);
}
Console.Write("\n>");
string userInput = Console.ReadLine() ?? "";
while (userInput != "exit")
{
//посмотрим какой нам генерируется ответ
//prompt: <| im_start |> user
//привет <| im_end |>
//<| im_start |> assistant
//для executorInstruct и executorInteractive каждый раз будет дублировать всю историю переписки, т.к. она хранится в контексте
//для executorStateless - контекст будет создаваться заново без истории переписки
var prompt = createPrompt("user", userInput);
Console.WriteLine("prompt: "+ prompt);
//инфер
await foreach (var text in executorInteractive.InferAsync(prompt, inferenceParams))
{
Console.ForegroundColor = ConsoleColor.White;
Console.Write(text);
}
userInput = Console.ReadLine() ?? "";
}
Сразу хотелось бы отметить: у каждой модели свой шаблон общения (ChatML, CommandR, Gemma 2, и т.д.). В данном случае нам не нужно формировать правильный промпт вручную. За нас это делает код, и данные хранящиеся в модели.
Однако эксперименты подразумевают частую правку кода и запуск проекта, а значит и периодический тяжелый этап загрузки модели.
Ollama
Существует такой проект https://ollama.com, позволяющий работать сразу с несколькими моделями одновременно. Работает просто: локально запускается сервер, который по первому требованию загружает модель, и вы спокойно с ней работаете. Остановили свой проект на c#, поправили код, запустили — а модель уже загружена в ollama, не нужно ждать новой загрузки.
Скачиваем Ollama (релиз https://github.com/ollama/ollama/releases/tag/v0.5.11), устанавливаем. Ищем интересующую вас модель https://ollama.com/search и скачиваем командой: ollama pull modelname
. Можем с ней поработать прямо из консоли: ollama run modelname
. Выход: /bye
. Вывести список скачанных локально моделей: ollama list
. Запустить сервер: ollama serve
.
Однако я уже скачал ранее модель Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf на 1,53 Гб, и на сайте ollama такой модели нет. Что делать?
Можно добавить любую уже скачанную gguf модель на локальный сервер Ollama:
Скрытый текст
1) Создаем файл Modelfile (без расширения) с таким содержимым:
from c:\models\QVikhr-2.5-1.5B-Instruct-r-Q8_0.gguf
# set the temperature to 1 [higher is more creative, lower is more coherent]
PARAMETER temperature 0.0
#PARAMETER top_p 0.8
#PARAMETER repeat_penalty 1.05
#PARAMETER top_k 20
TEMPLATE """
{{- if .Messages }}
{{- if or .System .Tools }}<|im_start|>system
{{- if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}
# Tools
You may call one or more functions to assist with the user query. Do not distort user description to call functions!
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
{{- end }}<|im_end|>
{{ end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 -}}
{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}<|im_end|>
{{ else if eq .Role "assistant" }}<|im_start|>assistant
{{ if .Content }}{{ .Content }}
{{- else if .ToolCalls }}<tool_call>
{{ range .ToolCalls }}{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{ end }}</tool_call>
{{- end }}{{ if not $last }}<|im_end|>
{{ end }}
{{- else if eq .Role "tool" }}<|im_start|>user
<tool_response>
{{ .Content }}
</tool_response><|im_end|>
{{ end }}
{{- if and (ne .Role "assistant") $last }}<|im_start|>assistant
{{ end }}
{{- end }}
{{- else }}
{{- if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
{{ end }}{{ .Response }}{{ if .Response }}<|im_end|>{{ end }}
"""
# set the system message
SYSTEM """You are JARVIS. You are a helpful assistant. Answer user requests briefly."""
2) Затем запускаем в командной строке импорт модели в Ollama:
ollama create MY -f c:\models\Modelfile
Вуаля. Теперь к этой модели можно обращаться по имени «MY».
Очень важно: в TEMPLATE указаны инструкции без которых Ollama не будет работать с Function Calling. Иначе Ollama выдаст ошибку "registry.ollama.ai MY does not support tools"
.
Если после запуска командой ollama serve
у вас выходит ошибка "Error: listen tcp 127.0.0.1:11434: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted."
, посмотрите в трей - там может быть запущен экземпляр ollama. Его можно выгрузить, выбрав "Quit Ollama".
Почему именно этот старый релиз 0.5.11? Потому что у модели Qvikhr-2.5-1.5B-Instruct-r-Q8_0.gguf в новых релизах ollama перестают работать функции.
Microsoft Semantic Kernel
У мелкософта есть свой проект по работе с LLM, в том числе и с Ollama. Репозиторий: https://github.com/microsoft/semantic-kernel, еще есть кукбук https://github.com/microsoft/SemanticKernelCookBook.
Простой чат
Устанавливаем пакеты Microsoft.SemanticKernel, Microsoft.SemanticKernel.Connectors.Ollama в NuGet.
Простой чатик
#pragma warning disable SKEXP0070
using Microsoft.SemanticKernel;
var modelId = "MY";
var url = "http://localhost:11434";
var builder = Kernel.CreateBuilder();
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();
Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
var answer = await kernel.InvokePromptAsync(input);
Console.WriteLine("AI: " + string.Join("\n", answer));
Console.Write("User: ");
}
Пользовательские функции
Теперь попробуем вызвать пользовательские функции
Function Calling
#pragma warning disable SKEXP0070
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using System.ComponentModel;
var modelId = "MY";
var url = "http://localhost:11434";
var builder = Kernel.CreateBuilder();
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();
//Добавляем свои функции
kernel.Plugins.AddFromObject(new MyWeatherPlugin());
kernel.Plugins.AddFromType<MyTimePlugin>();
kernel.Plugins.AddFromObject(new MyNewsPlugin());
//настраиваем ollama на запуск функций
var settings = new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
Temperature = 0,
TopP = 0,
};
Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
var answer = await kernel.InvokePromptAsync(input, new(settings));
Console.WriteLine("AI: " + string.Join("\n", answer));
Console.Write("User: ");
}
//Плагины
public class MyWeatherPlugin
{
[KernelFunction, Description("Gets the current weather for the specified city")]
public string GetWeather(string _city)
{
return "very good in " + _city + "!";
}
}
public class MyTimePlugin
{
[KernelFunction, Description("Get the current day of week")]
public string DayOfWeek() => System.DateTime.Now.ToString("dddd");
[KernelFunction, Description("Get the current time")]
public string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'");
[KernelFunction, Description("Get the current date")]
public string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}
public class MyNewsPlugin
{
[KernelFunction, Description("Gets the current news for the specified count")]
public string GetNews(int count)
{
return "the ruble strengthened. An alien ship was found in the USA. It's a full moon today.";
}
}
Результат:

RAG, Поисковая расширенная генерация
Попробуем заставить LLM искать информацию в наших данных (сделаем из него поисковик) т.е. реализуем RAG.
Как это выглядит:
Индексируем данные
Используем функцию для поиска данных (объект 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, и выдал Семёна.
Второй раз мы принудительно указали параметры поиска
у кого есть три кота? (( recall input='три кота' collection='generic' relevance=0.8 limit=1 ))
, с лимитом 1, что бы удостовериться, что этот запрос похож на первый.Третий раз мы принудительно указали лимит 2, что бы TextMemoryPlugin выдал нам список из 2 позиций, ...и тут что-то не так. ИИ должна была вывести Ивана и Семёна.
Оказалось что при выводе нескольких значений (а для этого используется json), в LLM возвращалась кривая строка с unicode последовательностями. Что-то типа такого: ["\u0423 \u0421\u0435\u043C\u0451\u043D\u0430 \u0435\u0441\u0442\u044C \u0442\u0440\u0438 \u043A\u043E\u0442\u0430.","\u0423 \u0418\u0432\u0430\u043D\u0430 \u0435\u0441\u0442\u044C \u0442\u0440\u0438 \u043A\u043E\u0442\u0430."]
Давайте сделаем свой аналог TextMemoryPlugin, который будет искать по всем коллекциям семантической памяти и выдавать корректный вывод. Тем более что исходники есть на гитхабе. Заодно раскрасим вывод.
MyTextMemoryPlugin
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Memory;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
[Experimental("SKEXP0001")]
public sealed class MyTextMemoryPlugin
{
private ISemanticTextMemory memory;
public MyTextMemoryPlugin(ISemanticTextMemory memory)
{
this.memory = memory;
}
[KernelFunction, Description("Key-based lookup for a specific memory")]
public async Task<string> RetrieveAsync(
[Description("The key associated with the memory to retrieve")] string key,
//[Description("Memories collection associated with the memory to retrieve")] string? collection = DefaultCollection,
CancellationToken cancellationToken = default)
{
//ищем во всех коллекциях
var collections = await this.memory.GetCollectionsAsync();
foreach (var collection in collections)
{
var info = await this.memory.GetAsync(collection, key, cancellationToken: cancellationToken).ConfigureAwait(false);
var result = info?.Metadata.Text;
if (result != null)
return result;
}
return string.Empty;
}
[KernelFunction, Description("Semantic search and return up to N memories related to the input text")]
public async Task<string> RecallAsync(
[Description("The input text to find related memories for")] string input,
//[Description("Memories collection to search")] string collection = DefaultCollection,
//[Description("The relevance score, from 0.0 to 1.0, where 1.0 means perfect match")] double? relevance = DefaultRelevance,
[Description("The maximum number of relevant memories to recall")] int? limit = 3,
CancellationToken cancellationToken = default)
{
var collections = await this.memory.GetCollectionsAsync();
foreach (var collection in collections)
{
// Search memory
List<MemoryQueryResult> memories = await this.memory
.SearchAsync(collection, input, limit.Value, 0.9, cancellationToken: cancellationToken)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memories.Count == 1)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("RecallAsync: " + string.Join("\n", memories[0].Metadata.Text));
return memories[0].Metadata.Text;
}
if (memories.Count > 1)
{
var opt = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
var t = JsonSerializer.Serialize(memories.Select(x => x.Metadata.Text), opt);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("RecallAsync: " + string.Join("\n", t));
return t;
}
}
return string.Empty;
}
}
Результат:

Можно использовать встроенный плагин TextMemoryPlugin, только правильно его настроить:
var opt = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
kernel.Plugins.AddFromObject(new TextMemoryPlugin(memory, jsonSerializerOptions: opt));
Однако и тут есть проблемы. Если поставить версию компонентов (Microsoft.SemanticKernel, Microsoft.SemanticKernel.Connectors.Ollama, Microsoft.SemanticKernel.Plugins.Memory) выше 1.40.1 - логирование не работает. Мелкософт опять что-то сломали.
Другой очень заметный минус Microsoft Semantic Kernel: чем больше подключено функций-плагинов, тем тяжелее выполняется любой запрос. А ведь мы не хотим ограничиваться парой тройкой функций...
Да, я пробовал систему с "агентами": сперва агент определяет необходимость вызова функции и если вызов необходим - ИИ может запустить функцию (настраивается через settings). Но это не поможет ускорить ответы. В любом случае, каждый запуск функции будет очень длительным.
Пример с агентом определяющим необходимость вызова функции
#pragma warning disable SKEXP0001
#pragma warning disable SKEXP0050
#pragma warning disable SKEXP0070
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using System.ComponentModel;
var modelId = "MY";
var url = "http://localhost:11434";
var builder = Kernel.CreateBuilder();
builder.AddLocalTextEmbeddingGeneration(); //для embeddingGenerator
builder.Services.AddOllamaChatCompletion(modelId, new HttpClient() { BaseAddress = new Uri(url) });
var kernel = builder.Build();
//"облегченное ядро"
var kernelLight = kernel.Clone();
//Добавляем свои функции в "основное ядро"
kernel.Plugins.AddFromObject(new MyWeatherPlugin());
kernel.Plugins.AddFromType<MyTimePlugin>();
kernel.Plugins.AddFromObject(new MyNewsPlugin());
//логирование вызовов функций
kernel.FunctionInvocationFilters.Add(new FunctionCallLoggingFilter());
kernelLight.FunctionInvocationFilters.Add(new FunctionCallLoggingFilter());
//определяем список всех доступных функций
var functions = "";
foreach (var plugin in kernel.Plugins)
{
var items = plugin.Select(x => {
//var param = x.Metadata.Parameters.Select(y => y.Name).ToArray();
var param = x.Metadata.Parameters.Select(y =>
{
//если есть описание параметра - берем его, иначе - наименование параметра
//return string.IsNullOrEmpty(y.Description) ? y.Name : y.Name + " - " + y.Description;
return y.Name;
}).ToArray();
var result = param.Length == 0 ? x.Name : x.Name + "(" + string.Join(",", param) + ")";
return result;
}).ToArray();
functions += string.Join(",", items) + ",";
}
functions = "[" + functions + "]";
//агент
var functionNeedAgent = kernelLight.CreateFunctionFromPrompt(@$"ОПРЕДЕЛИ ПОДХОДИТ ЛИ ЗАПРОС ПОД ФУНКЦИИ: {functions}.
ЕСЛИ ДА - ВЕРНИ ТОЛЬКО: 'function:название;parameters:параметры в запросе' ИЛИ 'null'.
БОЛЬШЕ НИЧЕГО НЕ ВЫВОДИ.
ПРИМЕР: 'function:GetWeather;parameters:-'.
ЗАПРОС: '{{{{$user_input}}}}'");
//настраиваем ollama на запуск функций
var functionSettings = new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke:true), //включаем плагины
Temperature = 0
};
//отключаем запуск функций
var defaultSettings = new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.None(), //отключаем плагины
Temperature = 0.3f //добавим креативности
};
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("User: ");
string? input = null;
while ((input = Console.ReadLine()) is not null)
{
//проверка на необходимость вызова функции
var answer = await functionNeedAgent.InvokeAsync(kernelLight, new() { ["user_input"] = input });
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("functionNeedAgent: " + string.Join("\n", answer));
//если необходимо вызвать функцию - вызываем
if (answer.ToString().Contains("function"))
{
answer = await kernel.InvokePromptAsync(input, new(functionSettings));
} else
answer = await kernel.InvokePromptAsync(input, new(defaultSettings));
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine("AI: " + string.Join("\n", answer));
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("User: ");
}
//Плагины
public class MyWeatherPlugin
{
[KernelFunction, Description("Gets the current weather for the specified city")]
public string GetWeather(string _city)
{
return "very good in " + _city + "!";
}
}
public class MyTimePlugin
{
[KernelFunction, Description("Get the current day of week")]
public string DayOfWeek() => System.DateTime.Now.ToString("dddd");
[KernelFunction, Description("Get the current time")]
public string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'");
[KernelFunction, Description("Get the current date")]
public string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}
public class MyNewsPlugin
{
[KernelFunction, Description("Gets the current news for the specified count")]
public string GetNews(int count)
{
return "the ruble strengthened. An alien ship was found in the USA. It's a full moon today.";
}
}
public class FunctionCallLoggingFilter : IFunctionInvocationFilter
{
public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
{
try
{
var values = "";
foreach (var arg in context.Arguments.Names)
{
var val = context.Arguments[arg] == null ? "null" : context.Arguments[arg];
values += $"{arg}: {val}; ";
}
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"call {context.Function.Name}: {values}");
}
catch
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"call {context.Function.Name}");
}
await next(context);
}
}
Результат:

Microsoft.Extensions.AI
После долгих поисков в ускорении Microsoft Semantic Kernel, наткнулся на примеры библиотеки, которую собственно использует этот самый Kernel. Установил дополнительный пакет Microsoft.Extensions.AI.Ollama и начал эксперименты... Простые ответы модели (не использующие функции) не стали зависеть от количества плагинов. Однако, на него я потратил много времени пытаясь выяснить: почему результат функции на русском языке передается модели с unicode последовательностями. Никакие ухищрения, как на примере выше с MyTextMemoryPlugin не помогают. Как оказалось этот пакет - устаревший, и больше не поддерживается. Visual Studio рекомендует использовать другой пакет OllamaSharp. Однако этот пакет необходимо для RAG (OllamaEmbeddingGenerator).
OllamaSharp
В сети есть проект https://github.com/awaescher/OllamaSharp который позволяет очень просто работать с Ollama. Устанавливаем пакет OllamaSharp в NuGet. Смотрим как использовать Function Calling https://awaescher.github.io/OllamaSharp/docs/tool-support.html.
Пример с вызовом функций
using OllamaSharp;
var ollama = new OllamaApiClient("http://localhost:11434", "MY:latest");
//доступные функции
List<object> Tools = [new GetWeatherTool(), new DateTool()];
var chat = new Chat(ollama);
while (true)
{
Console.Write("User: ");
var message = Console.ReadLine();
Console.Write("AI: ");
//передаем сообщение и наши функции
await foreach (var answerToken in chat.SendAsync(message, Tools))
Console.Write(answerToken);
Console.WriteLine();
}
Функции в отдельном файле SampleTools.cs (namespace обязателен):
namespace OllamaSharp;
public static class SampleTools
{
//обязательные комментарии
//из них генератор будет брать описание функций для модели
/// <summary>
/// Get the current weather for a city
/// </summary>
/// <param name="city">Name of the city</param>
[OllamaTool]
public static string GetWeather(string city) => $"It's cold at only 6° in {city}.";
/// <summary>
/// Get the current date
/// </summary>
[OllamaTool]
public static string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy");
}
При вводе текста без знаков препинания, без вопросительных и восклицательных знаков — всё ок. Но стОит только ввести в конце вопросительный знак, либо использовать буквы в верхнем регистре — модель обращается к функциям, и ведет себя неадекватно. Как победить эту проблему - пока не ясно.
Пример неадекватности:
User: как тебя зовут
AI: Я – JARVIS.
Второй пример:
User: Как тебя зовут?
AI: Теплое сообщение: <tool_response>
Но нам не важны знаки препинания, мы будем передавать модели распознанный текст с микрофона, в котором не будет знаков препинания и верхнего регистра.
Microsoft.Extensions.AI + OllamaSharp
Реализуем сразу и Function Calling, и RAG (аналог мелкософтовского TextMemoryPlugin).
Скрытый текст
using Microsoft.Extensions.AI;
using OllamaAITest;
using OllamaSharp;
using System.ComponentModel;
using System.Numerics.Tensors;
var ollamaClient = new OllamaApiClient(new Uri("http://localhost:11434"), "MY");
IChatClient chatClient = new ChatClientBuilder(ollamaClient)
//.UseFunctionInvocation() //автозапуск функций отключим, мы будем вручную запускать функции
//.UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) //кэш результатов отключим, нам нужны свежие результаты
.Use(async (chatMessages, options, nextAsync, cancellationToken) =>
{
await nextAsync(chatMessages, options, cancellationToken);
})
.Build();
//генератор векторов
OllamaEmbeddingGenerator ollamaEmbeddingGenerator = new(new Uri("http://localhost:11434"), "MY");
//настройка генерации
var opt = new EmbeddingGenerationOptions()
{
ModelId = "MY",
AdditionalProperties = new()
{
["Temperature"] = "0"
},
};
//факты
string[] facts = [
"Иван живет в Москве",
"У Ивана есть три кота",
"Семён живет в Питере",
"У Семёна есть три кота",
"Марина живет в Воркуте",
"У Марины есть две собаки",
];
//генерируем вектора из фактов
var factsEmbeddings = await ollamaEmbeddingGenerator.GenerateAndZipAsync(facts, opt);
//настройка чата
ChatOptions options = new ChatOptions();
options.ToolMode = AutoChatToolMode.Auto;
options.Tools = [
AIFunctionFactory.Create(GetWeather),
AIFunctionFactory.Create(DayOfWeek),
AIFunctionFactory.Create(Time),
AIFunctionFactory.Create(Date),
AIFunctionFactory.Create(Recall)
];
//история
List<ChatMessage> chatHistory = new();
string answer = "";
bool lastWasTool = false;
do
{
if (!lastWasTool)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("User: ");
var userMessage = Console.ReadLine();
chatHistory.Add(new ChatMessage(ChatRole.User, userMessage));
}
again:
var response = await chatClient.GetResponseAsync(chatHistory, options);
if (response == null)
{
Console.WriteLine("No response from the assistant");
continue;
}
foreach (var message in response.Messages)
{
chatHistory.Add(message);
FunctionCallContent[] array = response.Messages.FirstOrDefault().Contents.OfType<FunctionCallContent>().ToArray();
if (array.Length > 0)
{
await ProcessToolRequest(message, chatHistory);
lastWasTool = true;
}
}
if (lastWasTool)
{
lastWasTool = false;
goto again;
} else
{
answer = string.Join(string.Empty, response.Messages.Select(m => m.Text));
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine($"AI: {answer}");
chatHistory.Clear();//очищаем
}
} while (true);
async Task ProcessToolRequest(
ChatMessage completion,
IList<ChatMessage> prompts)
{
foreach (var toolCall in completion.Contents.OfType<FunctionCallContent>())
{
//AIFunction aIFunction = options.Tools.OfType<AIFunction>().FirstOrDefault((AIFunction t) => t.Name == toolCall.Name);
AIFunction aIFunction = options.Tools.OfType<AIFunction>().FirstOrDefault((AIFunction t) => t.Name.Contains(toolCall.Name.Replace("__Main___g__", ""))); //__Main___g__
var functionName = toolCall.Name;
var arguments = new AIFunctionArguments(toolCall.Arguments);
var callLog = string.Join("; ", arguments.Select(x => x.Key.ToString() +": " + x.Value?.ToString() + " ").ToArray());
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Call: " + functionName + " " + callLog);
if (aIFunction == null) continue;
var result = await aIFunction.InvokeAsync(arguments);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Call result: " + string.Join("\n", result));
ChatMessage responseMessage = new(ChatRole.Tool,
[
new FunctionResultContent(toolCall.CallId, result)
]);
prompts.Add(responseMessage);
}
}
//функции
[Description("Gets the current weather for the specified city")]
[return: Description("The current weather")]
string GetWeather([Description("The city")] string _city)
{
return "The weather in " + _city + " is 30 degrees and sunny.";
}
[Description("Get the day of week")]
string DayOfWeek() => System.DateTime.Now.ToString("dddd", new System.Globalization.CultureInfo("en-EN"));
[Description("Get the time")]
string Time() => System.DateTime.Now.ToString("HH 'hour' mm 'minutes'", new System.Globalization.CultureInfo("en-EN"));
[Description("Get the date")]
string Date() => System.DateTime.Now.ToString("dddd dd MMMM yyyy", new System.Globalization.CultureInfo("en-EN"));
[Description("Search the memory for a given query.")]
[return: Description("Collection of text search result")]
async Task<List<string>> Recall([Description("The query to search for.")] string query)
{
//настройка генерации
var o = new EmbeddingGenerationOptions()
{
ModelId = "MY",
AdditionalProperties = new()
{
["Temperature"] = "0"
},
};
//генерируем вектор из запроса
var userEmbedding = await ollamaEmbeddingGenerator.GenerateAsync(query, o);
//производим поиск из запроса среди фактов
var topMatches = factsEmbeddings
//формируем список
.Select(candidate => new
{
Text = candidate.Value,
Similarity = TensorPrimitives.CosineSimilarity(candidate.Embedding.Vector.Span, userEmbedding.Vector.Span)
})
//relevance - отсекаем слабые совпадения
/*
.Where(x => {
return x.Similarity >= 0.92f;
})
*/
//сортируем - сначала самые релевантные
.OrderByDescending(match => match.Similarity)
//limit - ограничиваем количество
.Take(3);
var result = topMatches.Select(x => x.Text).ToList<string>();
return result;
}
Результат:

По итогу: первым у нас выполняется генерация векторов фактов, в пределах 3 секунд. Далее, при вводе любого запроса к модели, ей передается информация по всем доступным функциям (json описание). Это самое длительное действие - около 20 секунд. Далее любой запрос, в том числе когда модель выполняет функцию - от 3 до 7 секунд. Неплохо для неразогнанного Intel Core i7-3770K из далёкого 2012 года.
Для лучшего понимания того, как происходит общение с моделью, можно поставить точку останова на chatHistory.Clear()
:
[user] Text = "у кого есть три кота" добавляем запрос пользователя в историю чата. Запускаем инфер истории чата.
[assistant] FunctionCall = 3a5d650f, Main_g__Recall_7([query, У кого есть три кота?]) модель анализирует запрос, формирует и передает нам json с заполненными полями. Формируется уникальный номер запуска функции. Мы эту функцию запускаем. А так же добавляем результат в историю чата.
[tool] FunctionResult = 3a5d650f, [ "У Семёна есть три кота", "Семён живет в Питере", "У Ивана есть три кота"] добавляем результат функции с ее уникальным номером в историю чата. Запускаем инфер истории чата.
[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. А на сегодня - всё.

Алиса, отбой...
nbkgroup
Попробуйте gemma3.