75

Как я за вечер запустил «виртуальную подругу» на своём NAS (Ollama + Telegram Stars)

Короткая предыстория. Увидел новость: «российский айтишник запилил ИИ-девушку и заработал ~800 000₽ за 3 месяца». Под капотом — готовая нейронка, красиво упакованная в чат-бот. Задача понятна: сделать свой MVP быстро, дёшево и локально, без платных API и внешних серверов.

Ниже — мой путь: стек, код, грабли и как их обошёл. Всё повторяемо.

Цель и рамки MVP

Что нужно пользователю: короткие тёплые ответы «виртуальной подруги» без NSFW, с поддерживающим вопросом в конце.
Что нужно мне: локальная модель (без облака), оплата сутками через Telegram Stars (XTR), админ-панель на командах.

Технически: один NAS c Docker, один бот в Telegram.

Выбор стека

  • LLM: Ollama + qwen2.5:3b-instruct-q4_K_M (есть и 1.5B для экономии). Всё локально.

  • Язык: Python 3.11.

  • Фреймворк бота: aiogram v3.

  • Хранилище: SQLite (users/access/bans/prefs).

  • Платежи: Telegram Stars (валюта XTR).

  • Деплой: Docker Compose на Synology DSM (или просто через docker compose в SSH).

Структура проекта

ai-gf/

docker-compose.yml

bot/

Dockerfile

requirements.txt

.env

main.py

llm.py

db.py

Конфигурация Docker Compose

version: "3.8"

services:

ollama:

image: ollama/ollama:latest

container_name: ollama

ports:

- "11434:11434"

volumes:

- ollama:/root/.ollama # кэш моделей, чтобы не качать заново

restart: unless-stopped

bot:

build: ./bot

container_name: ai-girlfriend-bot

env_file:

- ./bot/.env

depends_on:

- ollama

restart: unless-stopped

volumes:

ollama:

Переменные окружения бота (bot/.env)

BOT_TOKEN=xxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

ADMIN_IDS=123456789

# Локальный LLM

LLM_BACKEND=ollama

OLLAMA_URL=http://<IP_ВАШЕГО_NAS>:11434

OLLAMA_MODEL=qwen2.5:3b-instruct-q4_K_M

OLLAMA_AUTOPULL=1

# Генерация

OLLAMA_TEMPERATURE=0.4

OLLAMA_NUM_CTX=2048

OLLAMA_NUM_PREDICT=60

# OLLAMA_NUM_THREAD=2 # можно зафиксировать потоки CPU

# Цена суток (Stars / XTR)

DAY_PRICE_STARS=100

Важно: используйте IP устройства. Так исключаем сетевые «приколы» с DNS/контекстом.

Зависимости

aiogram==3.13.1

httpx==0.27.2

aiosqlite==0.20.0

python-dotenv==1.0.1

Dockerfile бота

FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1

ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt /app/

RUN pip install --no-cache-dir -r requirements.txt

COPY . /app/

CMD ["python", "main.py"]

Мозг: LLM-клиент

  • Жёсткий системный промпт (тёплая, короткая, на русском).

  • stream: False, чтобы получать единый JSON, а не поток чанков (меньше багов).

  • Анти-шаблонная пост-обработка (до 2 коротких предложений, обязательно вопрос).

  • Лёгкий «дауншифт» при таймауте: уменьшаем num_predict и повторяем запрос.

import os, re

from typing import Any, Dict, Optional

import httpx

SYSTEM_PROMPT = (

"Ты — заботливая виртуальная подруга. Тебя зовут Кристина. "

"ВСЕГДА отвечай по-русски, без англицизмов и вставок на другом языке. "

"Поддерживай, будь тёплой, игривой, без NSFW. "

"Строго: одно короткое предложение, без кавычек и без предисловий.\n"

"Формат: 1–2 коротких предложения с тёплой поддержкой и уточняющим вопросом. "

"Избегай канцелярита, эмодзи по минимуму. Не давай медицинских/юридических рекомендаций. "

"Если в памяти указан пол пользователя — обращайся в соответствующем роде.\n\n"

"Примеры:\n"

"— Понимаю, это неприятно, но ты справишься — что сейчас больше всего беспокоит?\n"

"— Похоже, день выдался тяжёлый; чем могу поддержать прямо сейчас?\n"

"— Звучит обидно, но это пройдёт — хочешь обсудить, что делать дальше?\n"

)

LLM_BACKEND = os.getenv("LLM_BACKEND", "ollama").lower()

OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/")

OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b-instruct-q4_K_M")

AUTOPULL = os.getenv("OLLAMA_AUTOPULL", "1") == "1"

def _f(name, default):

v=os.getenv(name,"");

try: return type(default)(v) if v!="" else default

except: return default

GEN_TEMPERATURE = _f("OLLAMA_TEMPERATURE", 0.4)

GEN_NUM_CTX = _f("OLLAMA_NUM_CTX", 2048)

GEN_NUM_THREAD = _f("OLLAMA_NUM_THREAD", None)

GEN_TOP_P = _f("OLLAMA_TOP_P", 0.9)

GEN_TOP_K = _f("OLLAMA_TOP_K", None)

GEN_NUM_PREDICT = _f("OLLAMA_NUM_PREDICT", 60)

def _build_options() -> Dict[str, Any]:

opts = {"stop": ["\n"], "temperature": GEN_TEMPERATURE, "num_ctx": GEN_NUM_CTX, "top_p": GEN_TOP_P}

if GEN_NUM_THREAD is not None: opts["num_thread"] = GEN_NUM_THREAD

if GEN_TOP_K is not None: opts["top_k"] = GEN_TOP_K

if GEN_NUM_PREDICT is not None:opts["num_predict"] = GEN_NUM_PREDICT

opts["repeat_penalty"] = float(os.getenv("OLLAMA_REPEAT_PENALTY", "1.1"))

opts["repeat_last_n"] = int(os.getenv("OLLAMA_REPEAT_LAST_N", "64"))

return opts

async def _ollama_pull_model(client: httpx.AsyncClient) -> None:

r = await client.post(f"{OLLAMA_URL}/api/pull", json={"name": OLLAMA_MODEL}, timeout=None)

r.raise_for_status()

def _extract_text(data: Dict[str, Any]) -> str:

if isinstance(data.get("message"), dict):

c = data["message"].get("content")

if isinstance(c, str) and c.strip(): return c.strip()

ch = data.get("choices")

if isinstance(ch, list) and ch:

c = ch[0].get("message", {}).get("content")

if isinstance(c, str) and c.strip(): return c.strip()

return "..."

def _format_reply(t: str) -> str:

t = " ".join(t.strip().split())

t = re.sub(r"(?i)\bкак\s+дела( сегодня)?\??$", "", t).strip()

sents = re.split(r"(?<=[.!?])\s+", t)

t = " ".join(sents[:2]).strip()

if not t: t = "Я рядом; что сейчас особенно волнует?"

if "?" not in t: t = t.rstrip(".!…") + " — что сейчас важнее всего для тебя?"

return t[:220]

async def ask_llm(user_text: str, memory: str = "") -> str:

if LLM_BACKEND != "ollama":

return "Бэкенд LLM не настроен."

sys = SYSTEM_PROMPT + (f"\nПамять: {memory}" if memory else "")

payload = {

"model": OLLAMA_MODEL,

"messages": [{"role":"system","content":sys},{"role":"user","content":user_text}],

"options": _build_options(),

"keep_alive": "30m",

"stream": False,

}

TIMEOUT = httpx.Timeout(connect=10, read=240, write=180, pool=180)

async with httpx.AsyncClient(timeout=TIMEOUT) as client:

try:

r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)

if r.status_code == 404 and AUTOPULL:

await _ollama_pull_model(client)

r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)

r.raise_for_status()

return _format_reply(_extract_text(r.json()))

except (httpx.ReadTimeout, httpx.HTTPError):

cur = int(payload.get("options", {}).get("num_predict", GEN_NUM_PREDICT or 60))

payload.setdefault("options", {})["num_predict"] = max(30, int(cur * 0.5))

try:

r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)

r.raise_for_status()

return _format_reply(_extract_text(r.json()))

except Exception:

return "Сервис генерации ответа временно недоступен. Попробуйте позже."

Память и доступы:

  • users, access (до какого времени оплачен доступ), bans, prefs (пол пользователя).

  • Простой API: grant_access(user_id, days), has_access, set_ban/unset_ban, set_gender/get_gender.

# bot/db.py (сокращённо)

from typing import Optional

import aiosqlite

from datetime import datetime, timedelta, timezone

DB_PATH = "data.sqlite3"

...........

async def init_db(): ...

async def upsert_user(u): ...

async def has_access(user_id:int)->bool: ...

async def grant_access(user_id:int, days:int=1): ...

async def set_ban(user_id:int, reason:str=None): ...

async def unset_ban(user_id:int): ...

async def is_banned(user_id:int)->bool: ...

async def get_stats(): ...

async def set_gender(user_id:int, gender:str):

if gender in ("male","female"):

# upsert в prefs

async def get_gender(user_id:int) -> Optional[str]: ...

(Полный код легко вставить — у меня он на ~150 строк, но суть выше.)

Логика бота:

  • /start, /buy (Stars), /status,

  • админ: /whoami, /gift <days> [user_id], /ban, /unban, /stats,

  • обычный текст → проверка бана/доступа → вызов LLM,

  • быстрые фразы «я мужчина/женщина» → запоминаем пол → тон меняется.

# bot/main.py (ключевые места)

from llm import ask_llm

from db import init_db, upsert_user, has_access, grant_access, set_ban, unset_ban, is_banned, get_stats, set_gender, get_gender

from aiogram import Bot, Dispatcher, F

from aiogram.filters import CommandStart, Command

from aiogram.types import Message, LabeledPrice, PreCheckoutQuery

@dp.message(F.text & ~F.text.startswith("/"))

async def chat(m: Message):

await upsert_user(m.from_user)

if await is_banned(m.from_user.id):

return await m.answer("Доступ запрещён. Обратитесь в поддержку.")

if not is_admin(m.from_user.id) and not await has_access(m.from_user.id):

return await m.answer("Доступ не активен. Купите сутки общения: /buy")

txt = (m.text or "").strip().lower()

if re.search(r"\bя\s+(мужчина|парень)\b", txt):

await set_gender(m.from_user.id, "male")

return await m.answer("Поняла, буду обращаться по-мужски; о чём хочешь поговорить?")

if re.search(r"\bя\s+(женщина|девушка)\b", txt):

await set_gender(m.from_user.id, "female")

return await m.answer("Поняла, буду обращаться по-женски; что сейчас важнее всего для тебя?")

gender = await get_gender(m.from_user.id)

mem = f"Пол пользователя: {'мужской' if gender=='male' else 'женский'}." if gender else ""

reply = await ask_llm(m.text, memory=mem)

await m.answer(reply)

Запуск (DSM или SSH)

sudo -i

PROJECT=/volume1/docker/ai-gf

mkdir -p "$PROJECT"

cd "$PROJECT"

# положите сюда файлы проекта (как выше)

docker compose up -d --build

# один раз подтянуть модель (если не подтянулась сама)

docker exec -it ollama ollama pull qwen2.5:3b-instruct-q4_K_M

# логи бота

docker logs -f --tail=100 ai-girlfriend-bot

Через DSM → Менеджер контейнеров → Проекты:
Создать из docker-compose.yml, запустить, затем в контейнере ollama выполнить ollama pull ....

Журнал граблей и решений

  1. curl: command not found в контейнере.
    Либо ставить утилиты, либо сразу использовать httpx из Python. Я ушёл в HTTP-клиент бота и API /api/chat.

  2. ollama run -p не работает.
    У ollama run нет -p. Либо:
    ollama run qwen2.5:3b-instruct-q4_K_M "Текст"
    либо REST: POST /api/chat.

  3. Потоковые чанки JSON → таймауты и каша.
    Ставим "stream": false и читаем единый ответ.

  4. Модель игнорирует .env и берёт 7B.
    Проверил переменные внутри контейнера:

docker exec -it ai-girlfriend-bot env | egrep 'OLLAMA_MODEL|OLLAMA_URL'

Исправил bot/.env, убедился, что env_file подключён в compose, пересобрал:

docker compose up -d --no-deps --force-recreate --build bot

  • httpx.ReadTimeout при длинном ответе.
    Добавил ретрай с уменьшением num_predict и увеличил read-таймаут до 240 с.

  • Name or service not known на ollama:11434.
    Вынес на IP NAS в .env: OLLAMA_URL=http://192.168.1.xx:11434.

  • Шаблонные ответы «Как дела сегодня?» и путаница с полом.
    — Жёсткий системный промпт + примеры, температура 0.4
    — Память пола + пост-обработка текста (вопрос обязателен, максимум две короткие фразы).

  • Импорт Optional упал.
    Добавил from typing import Optional в db.py.

Оптимизация ресурсов

  • Перейти на qwen2.5:1.5b-instruct-q4_K_M (минимум ватт), сократить num_ctx до 1024, num_predict до 45, зафиксировать OLLAMA_NUM_THREAD=2, снизить keep_alive до 5m.

  • Контролировать через:

docker stats ai-girlfriend-bot ollama

Итог

За вечер поднял локальную «виртуальную подругу» без облачных API. Базовый стек — Docker + Ollama + aiogram — прост, дешёв и управляем. Дальше можно докручивать стиль, подписки и витрину.

Вопрос к читающим: какие короткие «фишки» вы бы добавили в такой бот, чтобы заходило лучше всего?

Сразу почему это не та модель, которую можно «просто взять и обучить»

Ollama тянет квантованные GGUF-веса (у меня qwen2.5:3b-instruct-q4_K_M). Это облегчённые файлы, сделанные для быстрого инференса (генерации), а не для обучения.
Что это значит на практике:

  • Дообучать GGUF нельзя. Квантование «сжимает» веса — обратной дороги для градиентного спуска нет.

  • Если хочется «свою» Кристину, путь такой:

    1. Берём оригинальные веса из HuggingFace (например, Qwen2.5-1.5B/3B-Instruct, FP16/BF16).

    2. Обучаем QLoRA/LoRA (PEFT/TRL/Axolotl/Unsloth) на GPU (8–16 ГБ+). NAS на CPU — будет дни/недели.

    3. Сливаем адаптер (merge), конвертим в GGUF, квантуем (например, q4_K_M).

    4. Делаем Modelfile и ollama create kristina-ft.
      После этого можно ставить в .env: OLLAMA_MODEL=kristina-ft.

Для 99% MVP-проектов хватает того, что мы уже сделали: системный промпт + короткие примеры + «память» (пол/предпочтения) — это бесплатно и стабильно работает на NAS.

Про «жрёт энергию» и «портит погоду»

Да, даже локальная 1.5–3B-модель на NAS — это чистая математика на каждом токене (матричные умножения). Чем больше модель/контекст/длина ответа — тем больше ватт·секунд.

Что влияет сильнее всего:

  • Размер модели (7B ≫ 3B ≫ 1.5B).

  • Контекст (num_ctx): чем длиннее история/системный промпт, тем дороже каждый новый токен.

  • Длина ответа (num_predict).

  • Параллелизм (num_thread): больше потоков → быстрее, но пик мощности выше.

  • keep_alive: сколько держим модель в памяти без дела (прогретая — удобно, но потребляет).

Экологический перевод на человеческий: каждый запрос = потребление электроэнергии. Если электросеть у вас не «зелёная», это добавляет к углеродному следу. Конечно, один короткий ответ — это не самолёт до Бали, но при большом трафике эффект становится заметным. Так что да — в шутливом смысле «каждый ваш запрос к ИИ тоже немного портит погоду»: вычисления → киловатт-часы → выбросы (зависят от источника энергии региона).

«Зелёные» настройки для бота

Минимальные правки, которые реально снижают ватт/тепло и почти не бьют по качеству в нашем кейсе:

# перейти на более лёгкую модель

OLLAMA_MODEL=qwen2.5:1.5b-instruct-q4_K_M

# короткие ответы и меньший контекст

OLLAMA_NUM_PREDICT=45

OLLAMA_NUM_CTX=1024

# ограничить потоки CPU

OLLAMA_NUM_THREAD=2

# не держать модель прогретой часами

# (в llm.py поменять "keep_alive": "30m" -> "5m")

Плюс — держите системный промпт компактным (я сократил), не плодите лишние «истории» и отключайте параллельные бенчи.

Как проверить эффект:
docker stats ai-girlfriend-bot ollama — смотрим загрузку, а лучше — бытовой ваттметр в розетке NAS. После смены на 1.5B и урезанных токенов/контекста пики и средняя мощность ощутимо падают.

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества