Главная / Статьи

Полезный локальный LLM за 30 минут: программирование, вопросы и ответы к документам и голосовой ввод

Полезный локальный LLM за 30 минут с помощью Ollama, Qwen3 и Whisper.cpp: ассистент для программирования, система RAG для работы с вашими заметками и офлайн-режим работы с голосом, размер которого соответствует объему вашей памяти.

Издатель: REACTAPP.TOP14 мин чтения3183 слов

Источники: Run a Useful Local LLM in 30 Minutes (Coding, RAG, Voice)

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

$ ollama run qwen3:8b
>>> rewrite this function to use async/await

Удивительно высокое качество. Хотя он уступает лучшим облачным моделям в задачах сложного многоэтапного рассуждения, он достаточно надежно справляется с обычными инженерными задачами, благодаря чему разница между утилитой для досуга и рабочим инструментом существенно сократилась.

За последние полтора года, благодаря трём волнам разработок, это стало практически реальным. Размеры файлов моделей сократились до уровня, позволяющего использовать их в обычной оперативной памяти: версия Qwen 3 содержит 8 миллиардов параметров и занимает около 5 ГБ. Qwen3-Coder, выпущенный в феврале 2026 года, показывает результат 70,6 процента на тесте SWE-bench; он разработан с использованием подхода mixture-of-experts с 30 миллиардами параметров, из которых активируется лишь 3 миллиарда на каждый токен. Благодаря Ollama установка теперь происходит не через запутанную сеть сред Python, а с помощью одного бинарного файла.

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

Общая основа

Каждому решению требуется движок инференса и набор весов модели.

Ollama выполняет роль этого движка. Он учитывает различия в аппаратном обеспечении, определяет, имеет ли устройство GPU от NVIDIA, чип Apple Silicon или только CPU, и готовит соответствующую структуру выполнения. Он работает в фоновом режиме и предоставляет локальный HTTP-API, который ведет себя как концовки крупных облачных провайдеров.

Установка зависит от платформы. На macOS эту задачу выполняет Homebrew:

brew install ollama

У Linux есть скрипт установки:

curl -fsSL https://ollama.com/install.sh | sh

Пользователи Windows могут скачать установщик MSI с веб-сайта Ollama.

После установки движка следует выбор модели. В качестве базовой используется qwen3:8b, которая требует примерно 5 ГБ дискового пространства и комфортно работает при 16 ГБ оперативной памяти. При отсутствии отдельной видеокарты Ollama переходит на обработку на процессоре со скоростью примерно 8–12 токенов в секунду, что всё ещё позволяет получать полностью читаемый результат.

Скачать:

ollama pull qwen3:8b

Затем подтвердите, что всё работает, проведя быстрый запуск:

ollama run qwen3:8b "Write a python script to reverse a string."

Ответ поступает в терминал. На фоне этого Ollama загружает файл GGUF — формат, который прямо из диска маппирует веса в оперативную память, поэтому при просмотре монитора системы видно, как объем памяти увеличивается примерно на размер файла.

Устройство с 8 ГБ оперативной памяти или меньше начнет использовать диск для обмена данными, в результате чего скорость обработки сильно снизится. Для таких случаев лучше подойдут модели qwen3:4b или gemma3:4b. При наличии GPU объемом 24 ГБ возможно использование модели qwen3:14b как следующего логичного уровня.

Путь А: ассистент для программирования в редакторе

Те, кто постоянно работает в редакторе кода, получат максимальную пользу от этого подхода. Цель — подключить локальную модель к VS Code для рефакторинга, создания документации и шаблонов кода.

Общая модель Qwen 3 демонстрирует неплохие результаты, но для работы с кодом требуется специализированная подготовка. По состоянию на май 2026 года Qwen3-Coder является самым сильным универсальным локальным инструментом для работы с кодом: он набрал 70,6 процента на тесте SWE-bench Verified и 44,3 процента на тесте SWE-Bench Pro. Его общее количество параметров составляет 30 миллиардов, однако при обработке каждого токена активными остаются лишь 3 миллиарда параметров, поэтому он помещается в 24 ГБ VRAM или работает стабильно на Mac серии M с 32 ГБ объединенной памяти.

Ноутбук с 16 ГБ памяти без отдельной графической карты, скорее всего, не справится с моделью объемом 30 миллиардов параметров. Альтернативой является модель qwen2.5-coder:7b, которая требует 4,7 ГБ памяти и при этом достигает примерно 92 процентов на тесте HumanEval.

# For 24GB+ VRAM or 32GB+ Mac Unified Memory
ollama pull qwen3-coder:30b

# For 16GB RAM laptops
ollama pull qwen2.5-coder:7b

Редактору нужен интерфейс для взаимодействия с моделью. Cline — это расширение для VS Code с более чем 5 миллионами установок, которое позволяет читать файлы, предлагать различия между версиями и выполнять команды терминала. Его можно найти, введя его название в магазине расширений.

Существует одна проблема, на которую стоит обратить внимание. Cline включает длинное системное указание, объясняющее, как форматировать различия между версиями, просматривать структуру файлов и использовать инструменты; это указание часто содержит более 25 000 токенов до того, как будет добавлен какой-либо код. По умолчанию окно контекста для моделей Qwen 3 в Ollama составляет 40 000 токенов. При открытии нескольких файлов общее количество токенов легко превышает этот лимит, после чего Ollama без предупреждения обрезает указание. В результате модель теряет инструкции по форматированию, игнорирует правила и генерирует обычный текст вместо корректных различий между версиями.

Решением является использование специального Modelfile, который позволяет увеличить размер окна контекста.

Modelfile, настроенный для Cline

Сохраните следующий текст в рабочей директории в файл с именем Modelfile.

# Modelfile — cline-tuned qwen3-coder
# Save as: ./Modelfile

FROM qwen3-coder:30b

# Cline's system prompt is ~25-30K tokens before your code is added.
# Ollama's default num_ctx for Qwen 3 is 40K, but Cline ships an
# expanded system prompt in v3+ that comfortably exceeds it on any
# real task. 65536 is the safe floor for serious use; 131072 if
# you have RAM/VRAM to spare.
PARAMETER num_ctx 65536

# Code edits want low-variance output. The default 0.7 is too loose.
PARAMETER temperature 0.2

# Stop after the model's natural turn. Cline parses this.
PARAMETER stop "<|im_end|>"

Создайте модель на её основе с помощью Ollama CLI, затем проверьте, что настройка контекста вступила в силу:

# Build the Cline-tuned model from the Modelfile in the current dir
ollama create qwen3-coder-cline -f ./Modelfile

# Verify the context window actually took
ollama show qwen3-coder-cline --modelfile | grep num_ctx
# Expected output:  PARAMETER num_ctx 65536

# Sanity-check it answers
ollama run qwen3-coder-cline "write a python one-liner to read /etc/hostname"

В VS Code откройте палитру команд с Cmd+Shift+P и выберите пункт настроек Cline. Укажите на локальный движок:

API Provider:      Ollama
Base URL:          http://localhost:11434
Model:             qwen3-coder-cline
Context Window:    65536

Значение окна контекста в графическом интерфейсе должно полностью совпадать с значением num_ctx в файле модели. Если оно меньше, Cline предполагает, что окно меньше, и тихо ограничивает количество отправляемых файлов.

Чтобы попробовать, откройте файл на Python с синхронной функцией, откройте панель чата Cline и попросите переписать код в формат async/await. Cline считывает файл, отправляет его на локальную модель Qwen3-Coder, получает ответ и отображает чистую разницу в редакторе. Принять изменения можно одним кликом, причем весь процесс происходит офлайн.

Такая конфигурация позволяет выполнять переписывание кода в одном файле, тестировать изолированные функции и генерировать шаблоны кода. По результатам тестирования сообществом её качество на узко определённых задачах соответствует примерно 70–85 процентам от качества передовых облачных решений.

Этот подход терпит неудачу при крупных изменениях архитектуры, включающих множество файлов. Перенос двадцатифайлового проекта на Flask в FastAPI приводит к сбоям: потоки выполнения теряются, появляются ложные импорты, и код становится нерабочим. Такие масштабные задачи лучше решать с использованием современных облачных решений. Однако для десятков небольших, рутинных правок, выполняемых ежедневно, достаточно локального решения.

Вариант B: поиск в собственных документах

Этот вариант предполагает использование папки с записями в формате Markdown, стенограммами встреч или документацией проекта, которые ищут вручную. Вся система помещается примерно в 60 строк обычного Python, используя только клиент Ollama и библиотеку numpy.

Модель эмбеддингов преобразует текст в векторы. Nomic Embed Text v1.5 имеет 137 миллионов параметров, генерирует векторы размером 768 измерений, занимает менее 300 МБ, обрабатывает контекст из 8192 токенов и показывает хорошие результаты в тестах поиска по MTEB.

Загрузите модель эмбеддингов и установите пакеты Python:

llama pull nomic-embed-text
pip install ollama numpy

Затем сохраните скрипт как rag.py.

"""Local RAG over a folder of notes — Ollama embeddings + numpy cosine.

No vector DB. For a few hundred docs, an in-memory list + numpy is
faster to set up and fast enough to run. Swap in sqlite-vec when
the corpus outgrows it (typically past ~5000 chunks).

Setup:
    ollama pull qwen3:8b
    ollama pull nomic-embed-text
    pip install ollama numpy

Run:
    python rag.py ./notes "what did the team decide about pricing?"
"""
from __future__ import annotations
import glob
import os
import sys

import numpy as np
import ollama

EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "qwen3:8b"
CHUNK_WORDS = 400          # ~500 tokens; fits 4 chunks in an 8K context
TOP_K = 3


def load_chunks(folder: str) -> list[str]:
    """Read every .md / .txt under folder, split into ~CHUNK_WORDS chunks."""
    chunks: list[str] = []
    for path in glob.glob(os.path.join(folder, "**/*"), recursive=True):
        if not path.endswith((".md", ".txt")):
            continue
        with open(path, encoding="utf-8") as f:
            words = f.read().split()
        for i in range(0, len(words), CHUNK_WORDS):
            chunk = " ".join(words[i : i + CHUNK_WORDS])
            if chunk.strip():
                chunks.append(chunk)
    return chunks


def embed(texts: list[str]) -> np.ndarray:
    """Embed a batch of texts via Ollama. Returns an (N, D) matrix."""
    resp = ollama.embed(model=EMBED_MODEL, input=texts)
    return np.array(resp["embeddings"], dtype=np.float32)


def top_k_indices(
    query_vec: np.ndarray, doc_mat: np.ndarray, k: int
) -> list[int]:
    """Return indices of the k most cosine-similar rows in doc_mat.

    The trick: if both vectors are unit-length (norm == 1), their
    dot product equals their cosine similarity. So we normalize
    once, then one matrix multiply gives a similarity score for
    every chunk against the query — no Python loop needed.
    """
    # Normalize query and every chunk to unit length.
    # The 1e-8 prevents division by zero on a zero vector.
    q = query_vec / (np.linalg.norm(query_vec) + 1e-8)
    d = doc_mat / (np.linalg.norm(doc_mat, axis=1, keepdims=True) + 1e-8)

    # One matmul across all chunks: scores[i] = cos(query, chunk_i).
    scores = d @ q

    # Sort descending, take the first k indices.
    return np.argsort(scores)[::-1][:k].tolist()


def answer(query: str, chunks: list[str], doc_mat: np.ndarray) -> str:
    """Retrieve top-K chunks, stuff them into a prompt, generate."""
    q_vec = embed([query])[0]
    idx = top_k_indices(q_vec, doc_mat, k=TOP_K)
    context = "\n\n---\n\n".join(chunks[i] for i in idx)

    prompt = (
        "Answer the question using ONLY the context below. "
        "If the context does not contain the answer, say so plainly.\n\n"
        f"CONTEXT:\n{context}\n\nQUESTION: {query}"
    )
    resp = ollama.chat(
        model=CHAT_MODEL,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp["message"]["content"]


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit('usage: python rag.py <folder> "<question>"')

    folder, query = sys.argv[1], sys.argv[2]

    chunks = load_chunks(folder)
    if not chunks:
        sys.exit(f"no .md or .txt files found under {folder}")

    print(f"indexing {len(chunks)} chunks...")
    doc_mat = embed(chunks)

    print(answer(query, chunks, doc_mat))

Направьте его на папку с заметками и задайте вопрос:

python rag.py ~/Documents/meeting_notes "what did the team decide about pricing?"

Скрипт просматривает папку, читает файлы и разделяет их на фрагменты по 400 слов. Полный список фрагментов отправляется на точку входа Ollama для генерации эмбеддингов одной партией. Для каждого вопроса вектор запроса сравнивается с векторами всех документов с использованием косинусного сходства.

Три лучших фрагмента вставляются в промпт для генерации, после чего модель qwen3:8b пишет ответ. В этом промпте есть строгое указание: отвечать исключительно на основе предоставленного контекста, и четко указывать, если в контексте отсутствует ответ. Локальные модели часто создают кажущиеся полезными вымыслы, когда поиск ничего не возвращает, а это правило заставляет их признавать свою некомпетентность.

Это не производственный способ поиска информации. Техника разбиения текста на фрагменты — это грубый способ подсчёта символов, который иногда разделяет предложение пополам. Эмбеддинги хранятся в памяти и исчезают при завершении работы, поэтому корпус данных необходимо снова преобразовывать в эмбеддинги при каждом запуске. Когда количество документов превышает несколько тысяч и процесс запуска становится раздражающим, замена матрицы numpy на sqlite-vec позволяет хранить векторы в локальном файле SQLite. Для личных заметок достаточно простого скрипта.

Путь C: цикл голосового общения

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

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

Для транскрипции используется Whisper.cpp — сильно оптимизированная версия библиотеки Whisper от OpenAI, работающая на CPU, Apple Metal и NVIDIA CUDA. Для преобразования аудио требуется библиотека ffmpeg. В macOS:

brew install whisper-cpp ffmpeg

В Linux установите ffmpeg с помощью менеджера пакетов, склонируйте репозиторий whisper.cpp и запустите команду make.

Whisper.cpp требует веса в формате GGML. Файл ggml-base.en.bin объемом около 150 МБ доступен в официальном репозитории Hugging Face и обеспечивает быструю и точную синтезацию речи на английском языке. Для других языков или более высокой точности используется файл ggml-large-v3.bin. Разместите файл в известной папке, например ~/models/.

Синтез речи осуществляется с помощью Kokoro ONNX — легкого движка TTS, веса которого составляют примерно 300 МБ; он способен к синтезу практически в реальном времени на обычном процессоре. В настоящее время он требует Python версий от 3.10 до 3.13 и не устанавливается на версии 3.14.

pip install -U sounddevice ollama kokoro-onnx

Скрипт обработки

Сохраните следующий код как voice.py и измените путь WHISPER_MODEL так, чтобы он указывал на только что загруженный файл GGML.

"""Local voice loop — mic → whisper.cpp → Ollama → Kokoro → speaker.

Everything runs offline. Nothing leaves the machine.

Setup:
    brew install whisper-cpp ffmpeg                  # macOS
    # (Linux: apt install ffmpeg; build whisper.cpp from source)

    pip install -U sounddevice ollama kokoro-onnx

    # Whisper GGML model — pick one:
    #   ggml-base.en.bin   (~150MB, fast, English-only)
    #   ggml-large-v3.bin  (~3GB, accurate, multilingual)
    # Download from https://huggingface.co/ggerganov/whisper.cpp

    # Kokoro model files auto-download to ~/.cache/kokoro-onnx/
    # on first run, or grab them manually from
    # https://github.com/thewh1teagle/kokoro-onnx/releases

Run:
    python voice.py
"""
from __future__ import annotations
import subprocess
import tempfile
import wave
from pathlib import Path

import sounddevice as sd
import ollama
from kokoro_onnx import Kokoro

WHISPER_MODEL = Path.home() / "models" / "ggml-base.en.bin"
CHAT_MODEL = "qwen3:8b"
KOKORO_VOICE = "af_sarah"      # see kokoro voices.json for all options
SAMPLE_RATE = 16000            # whisper.cpp requires 16kHz mono
RECORD_SECONDS = 5

# Kokoro auto-downloads the model files on first instantiation.
kokoro = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")


def record() -> Path:
    """Record from the default mic, write a 16kHz mono WAV, return its path."""
    print(f"listening for {RECORD_SECONDS}s...")
    audio = sd.rec(
        int(RECORD_SECONDS * SAMPLE_RATE),
        samplerate=SAMPLE_RATE,
        channels=1,                    # mono
        dtype="int16",                 # 16-bit PCM is what wave.open expects
    )
    sd.wait()                          # block until recording finishes

    # Fixed path in the system tempdir — overwritten each invocation.
    wav_path = Path(tempfile.gettempdir()) / "voice_in.wav"
    with wave.open(str(wav_path), "wb") as w:
        w.setnchannels(1)              # mono
        w.setsampwidth(2)              # 2 bytes per sample == int16
        w.setframerate(SAMPLE_RATE)    # 16 kHz — whisper.cpp's required rate
        w.writeframes(audio.tobytes())
    return wav_path


def transcribe(wav_path: Path) -> str:
    """Run whisper.cpp on a WAV file, return the transcript text."""
    result = subprocess.run(
        [
            "whisper-cli",
            "-m", str(WHISPER_MODEL),
            "-f", str(wav_path),
            "-otxt",                   # write the transcript to <input>.txt
            "-np",                     # suppress progress prints on stdout
        ],
        capture_output=True,
        text=True,
        timeout=60,
    )
    if result.returncode != 0:
        raise RuntimeError(f"whisper-cli failed: {result.stderr}")

    # -otxt writes the transcript next to the input file, with .txt
    # tacked onto the existing name — so /tmp/voice_in.wav becomes
    # /tmp/voice_in.wav.txt (same directory, same stem, extra suffix).
    txt_path = wav_path.with_suffix(wav_path.suffix + ".txt")
    return txt_path.read_text(encoding="utf-8").strip()


def respond(text: str) -> str:
    """Send the transcript to the local LLM, return the reply."""
    resp = ollama.chat(
        model=CHAT_MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a voice assistant. Keep replies to one or "
                    "two short sentences. No markdown, no lists."
                ),
            },
            {"role": "user", "content": text},
        ],
    )
    return resp["message"]["content"]


def speak(text: str) -> None:
    """Synthesize the reply with Kokoro and play it back."""
    samples, sample_rate = kokoro.create(
        text, voice=KOKORO_VOICE, speed=1.0, lang="en-us"
    )
    sd.play(samples, sample_rate)
    sd.wait()


if __name__ == "__main__":
    wav = record()
    heard = transcribe(wav)

    if not heard:
        print("nothing heard. exiting.")
        raise SystemExit(0)

    print(f"you said: {heard}")
    reply = respond(heard)
    print(f"model:    {reply}")
    speak(reply)

При запуске скрипт на протяжении пяти секунд прослушивает аудио, транскрибирует его, генерирует ответ и озвучивает его. При первом запуске происходит пауза, пока Kokoro загружает свои файлы формата ONNX в папку ~/.cache/kokoro-onnx/.

Три аспекта дизайна имеют большее значение, чем кажется на первый взгляд. Запись аудио в формате моночастоты 16 кГц является обязательной: модель Whisper обучалась на аудио с частотой 16 кГц, а использование микрофона с частотой 44,1 кГц приводит к генерации вымышленного текста или пустого результата. Запуск утилиты whisper-cli как отдельного подпроцесса вместо использования Python-интерфейса позволяет избежать конфликтов за доступ и проблем с расширениями на языке C, а флаг для вывода текста сохраняет транскрипцию в файл, так что не требуется извлекать информацию из стандартного вывода. В системном сообщении также прямо запрещается использование формата Markdown, поскольку Kokoro озвучивает звездочки, и форматированный ответ портит весь опыт использования.

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

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

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

Ноутбук с 16 ГБ памяти и отсутствием отдельной графической карты имеет пропускную способность системной памяти от 50 до 70 ГБ/с. Он может запускать модели qwen3:8b или qwen2.5-coder:7b на процессоре со скоростью обработки 8–12 токенов в секунду, что достаточно для поиска информации и обработки голоса, но недостаточно для кодирования. Технология Apple Silicon меняет ситуацию благодаря объединенной памяти с пропускной способностью от 200 до 400 ГБ/с, поэтому устройства серии M Pro или Max с 24–36 ГБ памяти с легкостью справляются с моделью qwen3-coder:30b, поскольку конструкция MoE активирует всего 3 миллиарда параметров на токен.

Карты NVIDIA идут на компромисс между объемом памяти и пропускной способностью. При наличии 12–16 ГБ видеопамяти модель qwen3:8b запускается мгновенно, а квантизированная версия qwen3-coder:30b также подходит. При объеме памяти 24 ГБ и более, например у карт RTX 3090 или 4090, модель объемом 30 миллиардов параметров работает с полной скоростью, что близко к возможностям платной подписки для работы с одним файлом. При менее чем 8 ГБ оперативной памяти кодирование практически невозможно, поэтому реалистичным вариантом являются модели qwen3:4b или phi-4-mini с поддержкой голоса и для небольших задач по поиску информации.

Связанные статьи

  • Повторная классификация в RAG: кросс-энкодеры против оценщиков на основе LLM и их задержки — Как вторичный ранжировщик улучшает порядок кандидатов в RAG, сравнение кросс-энкодеров с оценщиками на основе LLM, затраты на задержку, способы сбоев и методы их оценки.
  • Поиск видео и аудио по смыслу: создание мультимодальной системы RAG с использованием Gemini Embedding 2 — Как единое пространство эмбеддингов для текста, аудио, видео и PDF заменило традиционные методы транскрипции, включая разбиение на фрагменты, использование ChromaDB, расширение запросов, повторную классификацию и установку порогов.
  • Graphify, OKF и RAG: какой слой контекста необходим вашему кодовому базису — Результаты тестирования показывают, что Graphify сопоставим с векторным RAG по точности. Здесь объясняется, как Graphify, OKF от Google и векторный RAG взаимодействуют между собой, указаны причины сбоев и представлена матрица принятия решений.
  • Частный мультимодальный RAG на Mac с процессором M1: селективная обработка графиков с помощью Ollama — Поиск конфиденциальных PDF-файлов офлайн: PyMuPDF для текста и таблиц, небольшая визуальная модель Qwen только для графиков, проверка чисел и поиск связей между элементами в ChromaDB.