Нитобэ связывает знание с поступком: мало повторить выученное, интересно увидеть, что с ним можно сделать. Для локального ИИ-агента тоже выберем действие. Модель получит просьбу, найдёт цену через одну функцию и посчитает сумму через другую. Ход работы останется виден в окне программы.

Допустим, вы делаете картонный домик. Стены уже вырезаны, но для крыши нужен ещё лист, а клей заканчивается. Возьмём вымышленный прайс: материал A стоит 120, материал B стоит 80. Нужно купить один A и два B. Сумма маленькая, поэтому её удобно отдать помощнику и тут же проверить по собственным числам.

Цель первой попытки небольшая: увидеть сумму 280, след вызовов и понятную реакцию на неизвестный код.

Как устроен локальный ИИ-агент

У локального ИИ-агента в примере разделены две обязанности. Модель читает просьбу и выбирает подходящий инструмент. До закупки ей выдаём небольшой набор: узнать цену и сложить стоимость. Программа разрешает только эти функции, проверяет аргументы и возвращает результат. После этого та может попросить ещё одно действие или сформулировать ответ.

lookup_price знает цены A и B. calculate_total получает коды и количества, берёт цены из того же прайса и складывает стоимость. У этих двух функций нет отправки запросов в интернет, командной оболочки и записи файлов. Их работа ограничена чтением двух чисел и арифметикой.

Цикл: модель запрашивает действие, программа проверяет аргументы, функция возвращает результат; предел — пять циклов. Модель предлагает действие, а выполняет его разрешённая функция. Результат отправляется обратно в историю диалога.

Важная часть агента — как раз этот возврат. Если модель попросила узнать цену, а программа только вывела её на экран, следующему ответу недостаёт результата инструмента. Для человека число уже перед глазами, но в новом запросе модели его ещё нет. У каждого участника здесь свой кусок разговора. Поэтому код ниже сохраняет сообщение модели целиком, добавляет ответ функции с её именем и продолжает разговор. Такой цикл описан в руководстве Ollama о вызове инструментов.

Что понадобится для первого запуска

Чтобы локальный ИИ-агент заработал, нужны совместимый компьютер, Ollama и Node.js 22 или новее. Ollama загружает и запускает модель; Node.js выполняет нашу небольшую программу. Знакомство с ними можно начать по официальному quickstart Ollama и установщику Node.js для своей системы.

Для исходной загрузки программы и весов нужен интернет. На странице qwen3:4b указан объём весов около 2,5 ГБ. Память для выполнения — отдельный вопрос: кроме весов, её занимает контекст и работа среды. Число 2,5 не означает, что достаточно ровно такой RAM. Возможности и скорость своего компьютера выясняют при запуске.

Основной путь ниже — для Windows. Для macOS и Linux настройку сервера найдёте в FAQ Ollama. Отдельных библиотек код не требует.

Если вы ещё не работали с такими программами, опору можно получить в обычном чате DeepSeek, без передачи собственных материалов:

Хочу запустить учебный локальный расчёт через Ollama и готовый файл agent.mjs. Уточни мою систему и что уже установлено. Объясняй по одному действию: какое окно открыть, куда вставить команду, какой результат ждать. Для настройки Ollama опирайся на текущую официальную справку. Если её не можешь открыть, попроси у меня нужный фрагмент. Жди моего ответа, а затем помогай со следующим шагом. Ошибку я пришлю без личных данных.

Сами материалы останутся в локальной программе; для объяснения достаточно шагов и текста ошибки.

Отключить облако и выбрать местную модель

В Windows завершите Ollama через значок в области уведомлений. Откройте настройку переменных среды своей учётной записи и создайте переменную OLLAMA_NO_CLOUD со значением 1. Для локального адреса задайте OLLAMA_HOST со значением 127.0.0.1:11434. Примените изменения и заново откройте Ollama из меню «Пуск».

С этими настройками локальный ИИ-агент обращается к серверу своего компьютера, а облачные возможности Ollama отключены. После перезапуска проверьте журнал: в нём должна появиться строка Ollama cloud disabled: true. На Windows откройте в Проводнике папку %LOCALAPPDATA%\Ollama и найдите server.log. Эти настройки и подтверждение описаны в FAQ, расположение журнала — в инструкции по диагностике.

Если в журнале подтверждения нет, сначала выясните, получила ли перезапущенная программа нужную переменную. Присваивание в чужом окне терминала само по себе не меняет настройки уже работающего приложения. Не переходите к своим материалам, пока не понятен выбранный режим.

Теперь откройте PowerShell и введите:

ollama pull qwen3:4b

Дождитесь завершения загрузки, затем запустите короткую проверку:

ollama run qwen3:4b

В открывшемся разговоре напишите «Поздоровайся одной фразой». Когда получите ответ, введите /bye, чтобы выйти из этого разговора. Здесь нужен именно тег qwen3:4b, без облачного варианта. Этот шаг проверяет, что модель отвечает на вашем компьютере; функции расчёта подключим дальше.

Карта границ: первоначальная загрузка приходит извне, вопрос, модель и две функции находятся внутри компьютера. Различаем загрузку весов и последующую обработку. Для расчёта готовый исполнитель обращается только к 127.0.0.1.

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

Готовый исполнитель: сохранить один файл

Создайте отдельную папку local-agent. В обычном редакторе сохраните в неё следующий код как agent.mjs. Он использует встроенный fetch в Node.js и обращается к http://127.0.0.1:11434/api/chat. Секретного ключа для этого локального обращения не нужно.

Готовый локальный ИИ-агент уже содержит прайс, описания функций, проверки и предел пяти обращений к модели. Код можно взять целиком.

import { pathToFileURL } from 'node:url';

const endpoint = 'http://127.0.0.1:11434/api/chat';
const model = 'qwen3:4b';
const prices = Object.freeze({ A: 120, B: 80 });

function objectWithKeys(value, keys) {
  if (!value || typeof value !== 'object' || Array.isArray(value) ||
      Object.keys(value).some(key => !keys.includes(key))) {
    throw new Error('Отказ: неверные поля аргументов');
  }
}

function price(code) {
  if (typeof code !== 'string' || !Object.hasOwn(prices, code)) {
    throw new Error('Отказ: неизвестный код материала');
  }
  return prices[code];
}

export function runTool(name, args) {
  if (name === 'lookup_price') {
    objectWithKeys(args, ['code']);
    return { code: args.code, price: price(args.code) };
  }
  if (name === 'calculate_total') {
    objectWithKeys(args, ['items']);
    if (!Array.isArray(args.items) || args.items.length < 1 || args.items.length > 20) {
      throw new Error('Отказ: нужно от 1 до 20 строк');
    }
    const total = args.items.reduce((sum, item) => {
      objectWithKeys(item, ['code', 'quantity']);
      if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 1000) {
        throw new Error('Отказ: количество — целое число от 1 до 1000');
      }
      return sum + price(item.code) * item.quantity;
    }, 0);
    return { total };
  }
  throw new Error('Отказ: инструмент не разрешён');
}

const tools = [
  { type: 'function', function: {
    name: 'lookup_price', description: 'Узнать цену материала по коду A или B',
    parameters: { type: 'object', required: ['code'], additionalProperties: false,
      properties: { code: { type: 'string', enum: ['A', 'B'] } } }
  } },
  { type: 'function', function: {
    name: 'calculate_total', description: 'Посчитать сумму по кодам и количествам; цены берёт из своего прайса',
    parameters: { type: 'object', required: ['items'], additionalProperties: false,
      properties: { items: { type: 'array', minItems: 1, maxItems: 20,
        items: { type: 'object', required: ['code', 'quantity'], additionalProperties: false,
          properties: { code: { type: 'string', enum: ['A', 'B'] },
            quantity: { type: 'integer', minimum: 1, maximum: 1000 } } } } } }
  } }
];

async function send(body) {
  const response = await fetch(endpoint, {
    method: 'POST', redirect: 'error', signal: AbortSignal.timeout(60000),
    headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
  });
  if (!response.ok) throw new Error(`Ollama: HTTP ${response.status}`);
  return response.json();
}

export async function agentLoop(question, transport = send) {
  const messages = [
    { role: 'system', content: 'Ты помощник для расчёта материалов. Доступны цены A и B. Для цен вызывай lookup_price, для суммы calculate_total. Не придумывай цены. При неизвестном коде сообщи, что его нет. Ты можешь вызвать инструменты несколько раз. После расчёта дай короткий ответ по-русски.' },
    { role: 'user', content: question }
  ];
  const trace = [];
  for (let round = 1; round <= 5; round++) {
    const response = await transport({ model, messages, tools, stream: false, think: false });
    const message = response.message;
    if (!message || message.role !== 'assistant') throw new Error('Нет сообщения assistant');
    messages.push(message);
    const calls = message.tool_calls ?? [];
    if (!Array.isArray(calls) || calls.length > 4) throw new Error('Неверное число вызовов');
    if (calls.length === 0) return { answer: message.content ?? '', trace, rounds: round };
    for (const call of calls) {
      const name = call.function?.name;
      const result = runTool(name, call.function?.arguments);
      trace.push({ name, arguments: call.function.arguments, result });
      messages.push({ role: 'tool', tool_name: name, content: JSON.stringify(result) });
    }
  }
  throw new Error('Остановка: достигнут предел 5 циклов');
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const question = process.argv.slice(2).join(' ') || 'Узнай цены A и B, затем рассчитай стоимость одного A и двух B.';
  try {
    const result = await agentLoop(question);
    for (const step of result.trace) console.log(JSON.stringify(step));
    console.log('Ответ модели:', result.answer);
    if (!result.trace.some(step => step.name === 'calculate_total')) {
      console.log('Сумма инструментом не проверена. Для расчёта повторите запрос с явной просьбой вызвать calculate_total.');
    }
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

Откройте PowerShell в папке local-agent: в Проводнике можно перейти в эту папку, в адресной строке ввести powershell и нажать Enter. Выполните:

node --version

В ответ должна появиться версия, начинающаяся с v22 или более нового основного номера. Если команда не найдена, проверьте установку Node.js и откройте новое окно PowerShell. Затем запустите файл:

node agent.mjs

Программа отправит просьбу узнать цены и посчитать один A и два B. На экране появятся строки вызовов с именами, аргументами и результатами, а затем текст модели. Ищите calculate_total и total:280 в его результате. Ответ сверяем именно с этим расчётом; одних слов «итого 280» без вызова инструмента для проверки пути мало.

Если модель закончила разговор без расчёта, программа сообщит, что сумма инструментом не проверена. Повторите запуск с явной просьбой:

node agent.mjs "Узнай цены A и B, затем обязательно вызови calculate_total для одного A и двух B."

Так вы уточняете нужное действие, сохраняя готовый проверяемый код.

Сверить сумму и неизвестный код

В прайсе A=120, B=80. Значит, один A и два B дают 120+2×80=280. Чтобы проверить, какие материалы выбрал локальный ИИ-агент, посмотрите на аргументы items: у A должно быть количество 1, у B — 2. Это отдельная проверка: функция безошибочно посчитает и два листа вместо одного, если такие количества ей передали. Но для нашей крыши получилась бы другая покупка. Поэтому сначала сверяем предметы и количества, затем итог.

Что проверяем Ожидаемый результат На что смотреть
Один A и два B 280 Коды, количества и результат calculate_total
Материал unknown Сообщение, что такого кода нет Отсутствие придуманной цены; попытка вызвать функцию с неизвестным кодом получает отказ

Для второго вопроса в том же PowerShell выполните:

node agent.mjs "Сколько стоит материал unknown?"

Модель может сразу объяснить, что такого кода нет. Если попробует передать его функции, исполнитель остановит обработку с отказом. В смете не возникнет третий материал с ценой, пришедшей в голову посреди разговора. Ни один из этих путей не должен превращать неизвестный материал в число. Код также отклоняет постороннее имя функции, строку вместо целого количества и выход за заданный диапазон.

Ожидаемые проверки: A+2B даёт 280, а для unknown нет цены в прайсе. На схеме показаны ожидаемые результаты. Фактический ход модели смотрите в выводе своего запуска.

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

Если вместо результата ошибка

Не удалось соединиться. Проверьте, запущена ли Ollama и совпадает ли её адрес с 127.0.0.1:11434. Затем посмотрите журнал. В этом маршруте локальный ИИ-агент не ищет запасной облачный сервер; сбой соединения останется видимым.

Модель не найдена. Сверьте, завершилась ли загрузка qwen3:4b. Команда ollama list в PowerShell покажет доступные модели; нужный тег должен быть в списке. Не заменяйте его случайным облачным названием ради исчезновения ошибки — для этого примера нам нужен выбранный местный вариант.

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

После окончания работы можно освободить память модели командой ollama stop qwen3:4b в том же PowerShell. Она выгружает модель; закрыть само приложение Ollama можно отдельно, когда оно больше не нужно.

Вопросы и ответы о локальном агенте

Почему нельзя просто попросить модель посчитать?

Модель выбирает действие, функция возвращает проверяемое число. Здесь изучаем этот путь; след показывает работу каждого участника.

Какая память понадобится?

Размер загружаемых весов и память для работы — разные величины. На расход влияют модель, контекст и способ выполнения. Для вашего устройства полезнее проверить совместимость Ollama и фактический запуск, чем вывести RAM из размера файла.

Есть ли у функций доступ ко всем файлам?

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

Чем этот путь отличается от Qwen-Agent?

Здесь мы используем локальный API Ollama и небольшой исполнитель с двумя функциями. Если захотите другой способ собирать агента через готовый фреймворк, можно перейти к разбору Qwen-Agent. Это отдельный следующий маршрут.

У помощника появилась проверяемая работа

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

А картонный домик пока дождётся своего клея. Вместо долгого разговора о красоте крыши в нашем следе есть дело покороче: A, два B и сумма 280. У помощника появился собственный маленький участок работы. Стены пока держим сами.