4 ноября 2026 г.

Как создать ИИ-агента на своём VPS: примерно 90 строк на Python

Рабочий ИИ-агент примерно в 90 строках Python: инструменты, белый список, лимит шагов и таймер systemd. Проверено на VPS 2 vCPU без GPU.

ИИ-агент — это цикл. Вы даёте языковой модели задачу и короткое меню инструментов, модель отвечает «вызови вот это с такими аргументами», ваш код выполняет вызов и возвращает результат, и так по кругу, пока модель не ответит обычным текстом. Вся идея на этом и кончается, а создать ИИ-агента по ней можно одной стандартной библиотекой Python. Ниже рабочий пример: он проверяет диск и службы сервера. К нему добавлено то, что в туториалах обычно опускают: лимиты, страховка от ошибок модели и таймер systemd для запуска без вашего участия. Всё проверено на VPS HIP-HOSTING с 2 vCPU, 3,8 ГБ ОЗУ и без GPU. Мы покажем и то, что сломалось, включая падение первой версии.

Коротко. Небольшая модель за локальным API в формате OpenAI, память ограничена через systemd. Агенту — инструменты только для чтения, которые принимают имена из белого списка, а не команды и пути. Вокруг модели — ограниченный цикл. Запуск от обычного пользователя, из защищённой службы systemd, по таймеру.

Что такое ИИ-агент на самом деле

Обычная модель чата умеет только выдавать текст. Агент добавляет к ней две вещи: инструменты (функции, которые может запустить ваш код) и цикл. Многие серверы моделей, в том числе Ollama для моделей, которые это умеют, поддерживают вызов инструментов (tool calling): вы описываете свои функции в JSON, и вместо прозы модель может ответить структурированным запросом, например «запусти service_status с name равным nginx». Сама модель ничего не запускает. Запускать ли и что вернуть в ответ, решает ваш код.

Схема всегда одна: отправили сообщения, прочитали ответ, если он просит инструменты — выполнили и добавили результаты, отправили снова. Ответ без запроса инструментов и есть итог. Многие фреймворки заворачивают это в дополнительные слои. Для агента с одной узкой задачей они не нужны, а написанный вручную цикл — самый быстрый способ увидеть, где живёт риск.

Что понадобится: железо без иллюзий

Нужен VPS с Python 3 и запасом памяти под небольшую модель. Наш стенд — хиплет HIP-HOSTING (так у нас называется виртуальный сервер): Ubuntu 24.04.5, 2 vCPU, 3,8 ГБ ОЗУ, без GPU и без swap. VPS HIP-HOSTING с 2 vCPU и 4 ГБ подходит для модели на 1,5 млрд параметров. Модели крупнее мы не пробовали и цифр по ним дать не можем. Что мы намерили с qwen2.5:1.5b:

Что

Замер

Что это значит

Загрузка модели

986 МБ

Место на диске, один раз

Память Ollama при лёгкой работе

пик 1 230 311 424 Б (около 1,15 ГиБ)

Под модель на 1,5 млрд параметров закладывайте больше гигабайта

Память Ollama во втором прогоне

1 887 436 800 Б (1,8 ГиБ), заданный нами потолок

В цифру входит кэш страниц от файла модели, отображённого в память, поэтому счётчик растёт к любому потолку, который вы поставите

Скорость генерации

8,83 токена/с (66 токенов)

Ответ в 66 токенов — около 7,5 с

Разбор запроса

36,07 токена/с (40 токенов)

Длинные запросы и результаты инструментов стоят реального времени

Один шаг агента

примерно от 3 до 12 с при загруженной модели (23 и 16 с при первом холодном запуске)

Запуск из двух шагов: 10-20 с на прогретой модели и около 40 с на холодной

Токен — фрагмент текста, грубо говоря короткое слово или его часть. С такой скоростью можно жить, если задача просыпается раз в несколько часов, и нельзя, если ответа ждёт человек. Для живого чата нужна более быстрая машина или модель по платному API.

Шаг 1. Адрес модели и потолок памяти

Агент говорит с моделью по HTTP. Ollama отдаёт совместимую с OpenAI точку входа (/v1/chat/completions — формат запроса, который копируют многие серверы моделей), поэтому тот же скрипт потом можно направить на другой сервер. Установку Ollama мы разбирали в статье Ollama на VPS и повторять не станем. Одно отличие: на стенде Ollama работала из tar-архива в /opt/aglab на порту 11435, чтобы ни с чем не пересечься. При обычной установке порт 11434, и в настройке агента AGENT_API (ниже) надо указать именно его.

Главное тут — потолок памяти. Модель — большой файл, который читается в ОЗУ, и ничто не мешает ей занять всё, до чего дотянется. Мы запускали Ollama через systemd-run: он стартует команду как временную службу systemd с ограничениями ресурсов, которые передаются флагами --property. Вот команда в том виде, в каком мы её запускали:

$ sudo systemd-run --unit=aglab-ollama --property=MemoryMax=1800M --property=MemorySwapMax=0 --property=CPUQuota=150% --setenv=OLLAMA_MODELS=/opt/aglab/models --setenv=OLLAMA_HOST=127.0.0.1:11435 --setenv=OLLAMA_KEEP_ALIVE=10m --setenv=HOME=/opt/aglab /opt/aglab/bin/ollama serve
  • MemoryMax=1800M — жёсткий потолок для всей службы через cgroup, механизм ядра, на котором systemd строит лимиты. Дальше ядро сначала высвобождает что можно (включая кэш), а если не хватает, убивает процессы внутри службы.
  • MemorySwapMax=0 — swap для этой службы запрещён. На нашем боксе его и не было, строка просто фиксирует это явно.
  • CPUQuota=150% — не больше полутора ядер из двух: модель не займёт оба.
  • OLLAMA_HOST=127.0.0.1:11435 — слушать только локальный интерфейс, на нашем порту стенда.
  • OLLAMA_MODELS и HOME — файлы моделей и собственные файлы Ollama лежат внутри каталога стенда.
  • OLLAMA_KEEP_ALIVE=10m — сколько модель остаётся в памяти после последнего запроса. По документации значение по умолчанию 5 минут; мы этого на стенде не проверяли. После выгрузки следующий запрос снова медленный.

Сервер поднят, теперь скачиваем модель. Клиенту надо указать наш нестандартный порт; на обычной установке с портом по умолчанию хватит простого ollama pull qwen2.5:1.5b.

$ OLLAMA_HOST=127.0.0.1:11435 /opt/aglab/bin/ollama pull qwen2.5:1.5b

Если вы ставили Ollama обычным способом, она уже работает как служба systemd с именем ollama, и те же три лимита нужно задать её переопределением (systemctl edit ollama). Этот вариант мы не проверяли. Теперь проверьте, что лимиты применились и порт привязан только к 127.0.0.1. Вот что вышло у нас:

Первая строка с 127.0.0.1:11435 означает, что до модели дотянутся только программы на этой машине. MemoryPeak — максимум за всё время, тут он упёрся в потолок.

Шаг 2. Инструменты и список того, чего им можно касаться

Всё из этого шага и следующего сохраните в один файл /opt/aglab/agent.py в том порядке, как показано. Сначала настройки и три инструмента. Инструмента-оболочки нет: нет функции «выполни команду», через которую запутавшаяся или подставленная модель сделала бы всё, что может ваш пользователь. Каждый инструмент делает одно фиксированное дело и принимает только значения из заранее написанного списка (белый список, а не чёрный из тех плохих вещей, что вы успели вспомнить).

#!/usr/bin/env python3
"""Минимальный агент: модель решает, какой инструмент вызвать, мы выполняем и возвращаем результат, цикл с лимитом шагов.
Только стандартная библиотека. Модель: любой сервер с OpenAI-совместимым API (здесь Ollama на 127.0.0.1)."""
import inspect, json, os, shutil, subprocess, sys, time, urllib.request
API = os.environ.get("AGENT_API", "http://127.0.0.1:11435/v1/chat/completions")
MODEL = os.environ.get("AGENT_MODEL", "qwen2.5:1.5b")
MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "6"))
ALLOWED_SERVICES = {"nginx", "ssh", "cron"}
ALLOWED_LOGS = {"syslog": "/var/log/syslog", "auth": "/var/log/auth.log"}
def disk_usage(path="/"):
    u = shutil.disk_usage(path if path in ("/", "/var", "/tmp") else "/")
    return {"path": path, "used_percent": round(u.used / u.total * 100, 1), "free_gb": round(u.free / 2**30, 1)}
def service_status(name):
    if name not in ALLOWED_SERVICES:
        return {"error": "service not allowed", "allowed": sorted(ALLOWED_SERVICES)}
    r = subprocess.run(["systemctl", "is-active", name], capture_output=True, text=True, timeout=20)
    return {"service": name, "status": r.stdout.strip()}
def tail_log(name, lines=5):
    if name not in ALLOWED_LOGS:
        return {"error": "log not allowed", "allowed": sorted(ALLOWED_LOGS)}
    lines = max(1, min(int(lines), 20))
    r = subprocess.run(["tail", "-n", str(lines), ALLOWED_LOGS[name]], capture_output=True, text=True, timeout=5)
    return {"log": name, "lines": r.stdout.splitlines()}
  • AGENT_API, AGENT_MODEL, AGENT_MAX_STEPS — переменные окружения со значениями по умолчанию. На обычной установке Ollama задайте AGENT_API как http://127.0.0.1:11434/v1/chat/completions.
  • service_status отказывает любому имени вне ALLOWED_SERVICES и передаёт имя в systemctl отдельным элементом списка, а не вклеивает в строку для оболочки. Оболочки нет, значит, и подставлять во что-то нечего.
  • tail_log путь вообще не принимает. Модель присылает короткое имя, а путь выдаёт словарь. Запрос /etc/shadow ключом не является, поэтому ничего не откроется. Число строк ограничено от 1 до 20.
  • disk_usage принимает только /, /var и /tmp, а на всё остальное молча подставляет /. Небольшой изъян: в ответе всё равно возвращается путь, который запросила модель. Здесь безвредно, но тут можно быть строже.
  • У каждого subprocess.run есть timeout, поэтому зависшая команда не повесит агента навсегда.

Теперь меню, которое видит модель. Ваш Python она не видит, только эти описания: по именам и коротким пояснениям она и выбирает, что вызвать.

TOOLS = {"disk_usage": disk_usage, "service_status": service_status, "tail_log": tail_log}
SCHEMA = [
    {"type": "function", "function": {"name": "disk_usage", "description": "Disk usage of a path",
        "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": []}}},
    {"type": "function", "function": {"name": "service_status", "description": "Is a systemd service active",
        "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}},
    {"type": "function", "function": {"name": "tail_log", "description": "Last lines of a log: syslog or auth",
        "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "lines": {"type": "integer"}}, "required": ["name"]}}},

]

Схема — просьба, а не правило. Модель вправе её проигнорировать, в шаге 5 это видно. Правило — белые списки в коде.

Шаг 3. Цикл и лимит шагов

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

def call_tool(fn, args):
    """Модель может выдумать имя аргумента или сломать вызов: ошибка уходит модели как результат, агент не падает."""
    if fn is None:
        return {"error": "unknown tool"}
    allowed = set(inspect.signature(fn).parameters)
    extra = sorted(set(args) - allowed)
    if extra:
        return {"error": "unexpected arguments: %s" % ", ".join(extra), "allowed": sorted(allowed)}

try:

        return fn(**args)
    except Exception as e:
        return {"error": "%s: %s" % (type(e).__name__, e)}
def chat(messages):
    body = json.dumps({"model": MODEL, "messages": messages, "tools": SCHEMA, "temperature": 0}).encode()
    req = urllib.request.Request(API, body, {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.load(r)
def run(task):
    messages = [{"role": "system", "content": "You are a server health agent. Use the tools to check facts, never guess. Answer briefly."},
                {"role": "user", "content": task}]
    for step in range(1, MAX_STEPS + 1):
        t0 = time.time()
        resp = chat(messages)
        msg = resp["choices"][0]["message"]
        calls = msg.get("tool_calls") or []
        print("step %d: %.1fs, tool_calls=%d, tokens=%s" % (step, time.time() - t0, len(calls), resp.get("usage", {}).get("completion_tokens")))
        if not calls:
            print("ANSWER:", msg.get("content", "").strip())

return

messages.append(msg)

        for c in calls:
            name = c["function"]["name"]
            args = c["function"]["arguments"]
            args = json.loads(args) if isinstance(args, str) else args
            fn = TOOLS.get(name)
            result = call_tool(fn, args)
            print("  tool %s(%s) -> %s" % (name, json.dumps(args), json.dumps(result)[:160]))
            messages.append({"role": "tool", "tool_call_id": c.get("id", ""), "content": json.dumps(result)})
    print("STOP: step limit reached (%d)" % MAX_STEPS)
if __name__ == "__main__":
    run(" ".join(sys.argv[1:]) or "Check the disk and whether nginx is running.")
  • "temperature": 0 просит модель каждый раз брать самые вероятные слова, чтобы повторные запуски были как можно ближе друг к другу. Одинаковый вывод это не гарантирует.
  • timeout=300 позволяет одному запросу ждать до пяти минут: модель на процессоре бывает медленной. Служба systemd из шага 6 остановит весь запуск раньше.
  • messages — вся переписка. После каждого ответа модели добавляется он сам, а за ним результат каждого инструмента: между запросами модель ничего не помнит, кроме того, что вы отправите снова.
  • for step in range(1, MAX_STEPS + 1) — это и есть лимит шагов. Модель, которая бесконечно просит инструменты, иначе крутила бы цикл вечно и жгла процессор. Строка ничего не стоит, а без неё получится фоновый процесс, который не кончается.
  • if not calls: инструменты не запрошены — модель закончила, её текст и есть ответ.
  • Строки print — ваш журнал аудита: каждый вызов с аргументами и первыми 160 символами результата.

Шаг 4. Первый запуск

Запустите с задачей или без неё, тогда возьмётся задача по умолчанию (проверить диск и nginx):

Читайте как протокол. На шаге 1 модель запросила сразу два инструмента, агент выполнил оба и вернул результаты. На шаге 2 она ответила текстом, и цикл закончился. Числа в ответе совпадают с результатами инструментов — именно это стоит сверять при каждом запуске. Этот вывод мы сняли из журнала службы systemd (шаг 6), скорее всего при уже загруженной модели, поэтому на первом запуске ждите дольше: наш первый, холодный запуск занял около 23 и 16 секунд на два шага против 5 и 6 на тёплой модели.

Шаг 5. Что идёт не так и что сделала с этим каждая строка

Туториал, который показывает только удачный путь, прячет самую полезную половину. Мы подсовывали агенту неудобные просьбы. Выводы ниже сняты на нашем боксе.

Просьба о секретном файле

Мы попросили хвост /etc/shadow, файла с хешами паролей. Модель попыталась:

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

Падение: аргумент, которого не было

Для «перезапусти nginx» подходящего инструмента нет. Человек сказал бы «не могу». Модель стала импровизировать и дописала аргумент restart, которого не существует. Первая версия нашего агента вызывала функцию напрямую, через fn(**args), и Python возразил:

Для скрипта на таймере падение в три часа ночи из-за каприза модели — это баг скрипта. Поэтому и появился call_tool из шага 3: он сверяет аргументы с настоящей сигнатурой функции (inspect.signature), а всё лишнее превращает в сообщение об ошибке, которое модель получает как обычный результат инструмента. Версия 2 на той же просьбе:

В этом блоке две мысли. Охранник сработал: ничего не перезапущено и nginx остался active, потому что инструмента перезапуска у агента просто нет. Но прочтите ответ. Результат инструмента сообщил, что допустим только ["name"], а модель написала, что допустимы «name» и «restart». Модель на 1,5 млрд параметров способна неверно пересказать то, что только что прочла, и пообещала повторить, чего не сделала: цикл заканчивается, когда в ответе нет запроса инструментов. Относитесь к тексту агента как к черновику, а к строкам инструментов над ним — как к доказательству.

Служба вне списка

Postfix на том боксе на самом деле работал, а агент отказался смотреть. Белый список делает свою работу, и в этом его цена: чтобы проверять ещё одну службу, вы вносите её в ALLOWED_SERVICES осознанно.

Лимит шагов

Инструменты отработали, но модель не успела прочитать результаты, поэтому ответа нет, только честный STOP.

Таймаут под нагрузкой

В версии 1 у systemctl is-active был таймаут 5 секунд. Один раз, пока модель занимала процессор, вызов в него не уложился:

subprocess.TimeoutExpired: Command '['systemctl', 'is-active', 'nginx']' timed out after 5 seconds

Мы подняли таймаут до 20 секунд. В версии 2 call_tool ловит любое исключение и возвращает его модели текстом, так что то же событие теперь стало бы результатом-ошибкой, а не падением (это прочитано по коду, не воспроизводилось). На маленькой машине модель и ваши инструменты делят те же два ядра, и коротким командам нужен больший запас времени, чем на свободном сервере.

Шаг 6. Запуск без вас: служба и таймер systemd

Агент оправдывает себя, когда работает сам. Берём службу systemd (что запускать) и таймер (когда); разницу с cron разбирает статья systemd-таймеры против cron. Сначала обычный пользователь, чтобы за ошибкой агента никогда не стоял root. Первая команда создаёт системную учётную запись без оболочки для входа и с домашним каталогом внутри каталога стенда (служба спрячет /home), вторая создаёт этот каталог:

Создайте /etc/systemd/system/aglab-agent.service с текстом ниже и выполните sudo systemctl daemon-reload, чтобы systemd увидел новый файл. «aglab» — просто имя нашего стенда, назовите по-своему.

[Unit]

Description=Server health agent (one run)

[Service]

Type=oneshot

User=aglab

SupplementaryGroups=adm

WorkingDirectory=/opt/aglab

Environment=AGENT_MAX_STEPS=4

ExecStart=/usr/bin/python3 /opt/aglab/agent.py "Check the disk and whether nginx is running."

TimeoutStartSec=180

NoNewPrivileges=yes

ProtectSystem=strict

ProtectHome=yes

PrivateTmp=yes

PrivateDevices=yes

ProtectKernelTunables=yes

ProtectControlGroups=yes

RestrictAddressFamilies=AF_UNIX AF_INET

CapabilityBoundingSet=

MemoryMax=256M

Построчно:

  • Type=oneshot — программа запускается один раз и завершается; для systemd завершение процесса значит «готово».
  • User=aglab и SupplementaryGroups=adm — обычная учётная запись плюс членство в группе adm, которой в Ubuntu обычно принадлежат файлы в /var/log, так что tail_log должен их читать. Этот путь мы на стенде успешно не запускали, и нужны файлы /var/log/syslog и /var/log/auth.log (их создаёт rsyslog).
  • WorkingDirectory и Environment=AGENT_MAX_STEPS=4 — где стартует программа и лимит в четыре шага для запусков по расписанию.
  • ExecStart — команда, задача передана аргументом.
  • TimeoutStartSec=180 — systemd завершит запуск дольше трёх минут, чем бы код ни занимался. Четыре шага даже на холодной модели укладываются с запасом.
  • NoNewPrivileges=yes — процесс и его потомки не могут получить прав больше тех, с которыми стартовали.
  • ProtectSystem=strict — вся файловая система для службы только для чтения (кроме служебных /dev, /proc, /sys). Агент ничего не пишет, так что ничего не теряется.
  • ProtectHome=yes — /home и /root недоступны.
  • PrivateTmp=yes — свой пустой /tmp.
  • PrivateDevices=yes — нет доступа к настоящим устройствам, только к безобидным вроде /dev/null.
  • ProtectKernelTunables=yes и ProtectControlGroups=yes — настройки ядра и cgroup доступны только для чтения.
  • RestrictAddressFamilies=AF_UNIX AF_INET — только локальные сокеты (через такой systemctl разговаривает с systemd) и IPv4 (запрос к модели).
  • CapabilityBoundingSet= с пустым значением — отбирает все особые привилегии Linux.
  • MemoryMax=256M — сам агент мал. Мы не измеряли его память: 256 МБ — потолок, который у нас сработал.

Запустите один раз вручную и прочтите журнал. Мы работали от root; если вы нет, добавьте sudo:

Строка Deactivated successfully говорит, что запуск завершился чисто. Ответ агента живёт только в журнале; отправить его в чат или на почту — следующий шаг, мы его не делали. Теперь спросим у systemd, насколько служба открыта:

Шкала идёт от 0 (ничего не открыто) до 10 (всё настежь), оценка 4,7 помечена как OK. Это подсказка, не сертификат: утилита считает, какие ограничения вы включили, а не здоров ли ваш код. Теперь таймер. Создайте /etc/systemd/system/aglab-agent.timer:

[Unit]

Description=Run the health agent every 6 hours

[Timer]

OnCalendar=*-*-* 00/6:00:00

Persistent=true

[Install]

WantedBy=timers.target

  • OnCalendar=*-*-* 00/6:00:00 — каждый день, каждые шесть часов начиная с полуночи: 00:00, 06:00, 12:00, 18:00.
  • Persistent=true — по документации systemd, если машина в момент срабатывания была выключена, пропущенный запуск выполнится после её включения. Мы этого не проверяли.
  • WantedBy=timers.target — после включения таймер стартует вместе с системой.

Снова выполните sudo systemctl daemon-reload и включите таймер:

Ещё про время. Раз в шесть часов запуск почти наверняка холодный: модель успевает выгрузиться после периода хранения (по документации 5 минут по умолчанию, у нас стояло 10) и загружается заново, так что закладывайте около 40 секунд, как в нашем первом запуске. Этот вывод сделан по документации, а не по замеру на стенде. Напоследок проверим, что учётная запись агента не читает секреты. Это проверка прав пользователя, а не песочницы службы:

Правила безопасности для любого агента

  • Никакой оболочки и никаких произвольных путей. Инструменты принимают имя из списка, а не команду и не имя файла. Одно это правило убирает самый опасный класс ошибок.
  • Сначала только чтение. Инструмент, который что-то меняет (перезапуск службы), добавляйте после того, как читающие поработали какое-то время; делайте его отдельной функцией со своим белым списком и, если ставки высоки, с подтверждением от человека.
  • Текст от инструментов считайте недоверенным. tail_log передаёт строки лога прямо модели, а в части строк есть текст, на который влияет посторонний (неудачный вход по SSH, например, записывает в лог введённое имя пользователя). Инструкция, спрятанная в такой строке, способна повлиять на модель. Это настоящий риск такой схемы (prompt injection, внедрение инструкций через данные); воспроизвести его мы не пытались. Ущерб ограничивает первое правило: инструменты умеют читать лишь несколько фиксированных вещей.
  • Остальное вы уже видели: обычный пользователь, модель на 127.0.0.1, лимит шагов и таймауты, запись в журнал о каждом вызове инструмента.

Во сколько это обходится по памяти

Дороже всего обходится сама модель: около 1,15 ГиБ при лёгкой работе и до потолка 1,8 ГиБ в более тяжёлом прогоне. В нашем тесте параллельные запросы к модели исчерпали память, и сервер на какое-то время перестал отвечать. Поэтому ограничивайте модель через MemoryMax и оставляйте запас системе, отправляйте ей один запрос за раз и не делайте её соседом боевых служб.

Локальная модель или платный API?

Скрипт не привязан к Ollama: подойдёт любой сервер с тем же форматом запросов, если поменять адрес, имя модели и, для платных сервисов, добавить заголовок с ключом (наш chat() его не отправляет). Ни один платный API мы не проверяли. Наше правило: начинайте с локальной модели для закрытых повторяющихся проверок, где скорость не важна. Платная модель не занимает память вашего сервера и отвечает быстрее, но текст задачи и результаты инструментов уходят с машины, а платить приходится за токены. Крупные модели в целом лучше держат формат инструментов; мы не сравнивали. В любом случае белые списки и лимиты остаются в вашем коде. Если удобнее собирать сценарии визуально, смотрите n8n на VPS, а за готовым интерфейсом поверх локальных моделей — AnythingLLM на VPS.

FAQ

Как создать ИИ-агента без фреймворка?

Нужны три вещи: функции, которые агенту разрешено вызывать, их описание в JSON для модели и цикл, который отправляет переписку, выполняет запрошенные моделью инструменты и возвращает результаты, пока в ответе не исчезнет запрос инструментов. На стандартной библиотеке Python у нас это заняло около 90 строк вместе с лимитами и обработкой ошибок.

Может ли ИИ-агент работать на VPS без GPU?

Да, с небольшой моделью. Мы запускали модель на 1,5 млрд параметров на 2 vCPU без GPU, скорость 8,83 токена в секунду: для проверки по расписанию годится, для живого чата нет, а памяти она занимала от 1,15 до 1,8 ГиБ. Модели крупнее мы не проверяли.

Безопасно ли давать ИИ-агенту выполнять команды на сервере?

Если дать ему оболочку, нет. Дайте фиксированные инструменты, принимающие только имена из белого списка, запускайте от обычного пользователя в защищённой службе systemd и ограничьте шаги и время. Но и тогда текст, который вернул инструмент (строки логов), остаётся недоверенным входом и может попытаться повлиять на модель.

Когда лучше взять платный API модели?

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

Итоги

  • Агент — это цикл: модель, инструмент, результат, снова модель. Сама модель ничего не исполняет, это делает ваш код.
  • Инструменты берут имена из белых списков. Проверяйте каждый вызов и превращайте ошибки в результаты для модели: наша первая версия упала на одном выдуманном аргументе.
  • Сверяйте слова агента со строками инструментов над ними: наша модель неверно пересказала собственную ошибку.
  • Запускайте из защищённой службы systemd по таймеру (наша получила 4,7 OK в systemd-analyze security), ограничивайте модели память и отправляйте ей по одному запросу.

Здесь не проверялось: модели крупнее, платные API, системы кроме Ubuntu 24.04, инструменты, которые что-то меняют, и успешный запуск tail_log на настоящем логе. Если нужна машина под такую задачу, мы запускали всё на VPS HIP-HOSTING с 2 vCPU и 4 ГБ ОЗУ: сервер можно заказать на hip.hosting. Держите его отдельно от боевых служб и ставьте потолок памяти модели до первого запроса.

PUBLISHED
4 ноября 2026 г.
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU