Р. Аллахвердиев
Оценка качестваLLM и моделиСерия «AI QA с нуля» · часть 3

Первый автотест для LLM за вечер: pytest + Ollama

Пошагово: ставим Ollama и маленькую модель, вызываем её через OpenAI-совместимый API и пишем на pytest семь проверок: формат JSON, обязательные факты, длину, стабильность на 10 прогонах и простую проверку через LLM-судью. Всё локально и бесплатно.

опубликовано · 11 мин15ea6aa
Обложка: Первый автотест для LLM за вечер: pytest + Ollama
Содержание

Коротко

  • За вечер вы поднимете локальную модель через Ollama и напишете семь pytest-тестов на её ответы. Ни карты, ни ключей API не нужно.
  • Ollama отдаёт OpenAI-совместимый API по адресу http://localhost:11434/v1, поэтому тот же код потом заработает с облачной моделью: поменяется адрес и ключ.
  • Начинайте с детерминированных проверок: JSON парсится, нужные факты есть, ответ не длиннее лимита. Они дешёвые и не врут.
  • Один и тот же вопрос, заданный 10 раз, покажет, почему для LLM считают долю верных ответов. Проверка через LLM-судью в конце работает, но верить ей вслепую нельзя.

Что понадобится

  • Python 3.10 или новее. Проверьте: python --version.
  • Несколько гигабайт на диске и свободная оперативная память: модель из этой статьи весит 1,9 ГБ и при работе целиком загружается в память. Видеокарта не обязательна, на процессоре маленькая модель тоже отвечает, только медленнее.
  • Один вечер. Дольше всего, скорее всего, будет скачиваться модель.

Если вы ещё не писали на Python, сначала пролистайте Python-минимум для AI QA: ниже используются функции, словари, JSON и pytest с параметризацией.

Шаг 1. Ставим Ollama и модель

Ollama запускает открытые модели на вашем компьютере и даёт к ним HTTP API. Установка по официальной инструкции:

# macOS и Linux
curl -fsSL https://ollama.com/install.sh | sh
# Windows (PowerShell)
irm https://ollama.com/install.ps1 | iex

Можно и обычным установщиком с той же страницы. После установки скачайте модель и задайте ей вопрос прямо в терминале:

ollama pull qwen2.5:3b
ollama run qwen2.5:3b "Сколько дней в неделе? Ответь одним числом."

Я взял qwen2.5:3b: на странице модели в библиотеке Ollama она весит 1,9 ГБ и в списке языков есть русский (данные на 14 октября 2026). Если компьютер слабый, возьмите qwen2.5:1.5b, она примерно вдвое меньше. Отвечать она будет хуже, и для учебных тестов это даже полезно: багов будет больше.

Проверьте, что сервер Ollama работает:

curl http://localhost:11434/v1/models

В ответе должен быть JSON со списком моделей, среди них qwen2.5:3b. На Linux, если ответа нет, запустите сервер командой ollama serve.

Шаг 2. Проект и клиент

Создайте папку и виртуальное окружение:

mkdir llm-first-test && cd llm-first-test
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS и Linux: source .venv/bin/activate
pip install openai pytest

Модель вызываем официальной библиотекой openai. Ollama поддерживает часть OpenAI API, в том числе /v1/chat/completions, параметры temperature, seed, max_tokens и режим JSON (документация Ollama). Ключ клиенту нужен формально, Ollama его не проверяет.

Файл llm_client.py:

import os

from openai import OpenAI

MODEL = os.environ.get("LLM_MODEL", "qwen2.5:3b")

client = OpenAI(
    base_url=os.environ.get("LLM_BASE_URL", "http://localhost:11434/v1"),
    api_key=os.environ.get("LLM_API_KEY", "ollama"),  # Ollama ключ не проверяет
    timeout=120,
)


def ask(prompt: str, *, system: str | None = None,
        temperature: float = 0.0, json_mode: bool = False) -> str:
    """Один вызов модели. Возвращает текст ответа."""
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})

    extra = {"response_format": {"type": "json_object"}} if json_mode else {}
    response = client.chat.completions.create(
        model=MODEL,
        messages=messages,
        temperature=temperature,
        **extra,
    )
    return response.choices[0].message.content or ""

Три решения в этом файле стоит понимать.

Адрес, ключ и модель читаются из переменных окружения. Чтобы прогнать те же тесты на облачной модели, достаточно задать LLM_BASE_URL, LLM_API_KEY и LLM_MODEL, код не меняется.

temperature передаётся всегда, по умолчанию 0. Если её не передать, OpenAI-совместимый слой Ollama подставит 1.0: так написано в исходном коде этого слоя. При 1.0 ответы заметно разнообразнее, и тесты на факты начинают «мигать».

timeout=120 защищает от зависшего запроса. Первый вызов после запуска медленный: модель загружается в память.

Шаг 3. Детерминированные проверки

Начнём с проверок, которые не требуют понимать смысл ответа. Их можно посчитать кодом, и они дают один и тот же вердикт на одном и том же тексте. На них и держится нижний этаж любого набора тестов LLM.

Сценарий: ассистент магазина знает одно правило о возврате. Файл test_llm.py:

import json
import re
from collections import Counter

import pytest

from llm_client import ask

POLICY = "Правила магазина: возврат товара возможен в течение 30 дней при наличии чека."
SYSTEM = f"Ты ассистент магазина. Отвечай по-русски, коротко и только по правилам.\n{POLICY}"


def test_answer_is_not_empty_and_in_russian():
    answer = ask("Привет! Чем ты можешь помочь?", system=SYSTEM)
    assert answer.strip(), "пустой ответ"
    assert re.search(r"[а-яё]", answer.lower()), f"ответ не на русском: {answer!r}"


def test_json_has_required_fields():
    raw = ask(
        'Назови столицу Франции. Верни JSON вида {"city": "...", "country": "..."} '
        "и больше ничего.",
        json_mode=True,
    )
    data = json.loads(raw)  # упадёт, если модель вернула не JSON
    assert set(data) >= {"city", "country"}, f"не хватает полей: {data}"
    assert data["city"].lower() in {"париж", "paris"}


@pytest.mark.parametrize("question, must_have", [
    ("Сколько дней есть на возврат товара?", "30"),
    ("Что нужно, чтобы вернуть товар?", "чек"),
], ids=["srok", "chek"])
def test_answer_contains_fact(question, must_have):
    answer = ask(question, system=SYSTEM)
    assert must_have in answer.lower(), f"нет «{must_have}» в ответе: {answer!r}"


def test_answer_is_short():
    answer = ask("Сколько дней есть на возврат? Ответь одним предложением.", system=SYSTEM)
    assert len(answer) <= 300, f"слишком длинно ({len(answer)} символов): {answer!r}"

Что здесь проверяется:

Тест Свойство ответа Какой дефект ловит
not_empty_and_in_russian ответ есть и на русском пустой ответ, переход на английский
json_has_required_fields JSON парсится, поля на месте нарушение формата, сломанный фронтенд
contains_fact в ответе есть «30» и «чек» неполный ответ, неверный срок
is_short не длиннее 300 символов «вода», рост стоимости ответа

Обратите внимание на два приёма. Мы проверяем свойство ответа («содержит 30»), а не точную строку: формулировка может быть любой. И в каждом assert выводим сам ответ: когда тест упадёт, вы сразу увидите, что сказала модель, без перезапуска.

Режим JSON (response_format={"type": "json_object"}) заставляет Ollama вернуть синтаксически правильный JSON. Какие в нём будут поля, режим не гарантирует, поэтому проверка полей всё равно нужна.

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

[pytest]
markers =
    slow: много вызовов модели, запускать отдельно
    judge: проверка через LLM-судью

Запуск быстрых тестов:

pytest -v -m "not slow and not judge"

Ожидаемый результат:

test_llm.py::test_answer_is_not_empty_and_in_russian PASSED    [ 20%]
test_llm.py::test_json_has_required_fields PASSED              [ 40%]
test_llm.py::test_answer_contains_fact[srok] PASSED            [ 60%]
test_llm.py::test_answer_contains_fact[chek] PASSED            [ 80%]
test_llm.py::test_answer_is_short PASSED                       [100%]

=============== 5 passed, 2 deselected in 14.2s ===============

Время прогона здесь иллюстративное, у вас оно будет другим: всё зависит от железа. Может упасть и какой-нибудь тест, особенно на модели 1.5b. Это нормальный результат. Прочитайте ответ в сообщении об ошибке и решите, что перед вами: дефект модели (срок не тот) или слишком строгая проверка (модель написала «тридцать»).

На Windows кириллица в выводе иногда превращается в «кракозябры». Запускайте так: python -X utf8 -m pytest -v.

Шаг 4. Десять прогонов одного вопроса

Теперь зададим модели один и тот же вопрос 10 раз с temperature=1.0 и посчитаем, сколько ответов разные и в скольких есть верный срок. Допишите в test_llm.py:

@pytest.mark.slow
def test_fact_is_stable_across_runs():
    runs = 10
    answers = [ask("Сколько дней есть на возврат товара?", system=SYSTEM, temperature=1.0)
               for _ in range(runs)]

    print(f"\nуникальных ответов: {len(Counter(answers))} из {runs}")
    for text, n in Counter(answers).most_common():
        print(f"  {n}x {text[:90]}")

    correct = sum("30" in a for a in answers)
    assert correct / runs >= 0.9, f"факт «30 дней» только в {correct} из {runs} ответов"

Запуск с выводом print:

pytest -v -s -m slow

Вот как выглядит вывод, когда тест падает. Ответы ниже иллюстративные, у вашей модели формулировки и цифры будут свои:

уникальных ответов: 4 из 10
  3x Возврат возможен в течение 30 дней при наличии чека.
  3x Срок возврата — 30 дней с чеком.
  2x Вернуть товар можно в течение 14 дней.
  2x У вас есть 30 дней на возврат, если сохранился чек.
FAILED test_llm.py::test_fact_is_stable_across_runs
AssertionError: факт «30 дней» только в 8 из 10 ответов

Формулировки разные, и это нормально: проверка на точную строку упала бы на 9 ответах из 10. А два ответа из десяти содержат неверный срок. Один прогон легко показал бы «всё хорошо».

Вердикт тут строится на доле: assert correct / runs >= 0.9. Порог 0.9 я взял для примера. В реальном проекте его выбирают по базовой версии системы, об этом подробнее в следующей части. Попробуйте прогнать тест несколько раз: он может то проходить, то падать. Такой тест называют плавающим, и это не ошибка в тесте. Он честно показывает, что система ведёт себя нестабильно. Как с этим жить в CI, разобрано в статье про quality gate.

Два эксперимента, которые стоит сделать руками:

  1. Поставьте temperature=0.0 и сравните число уникальных ответов.
  2. Добавьте в запрос параметр seed (Ollama его поддерживает) и проверьте, повторяются ли ответы при одном и том же seed. Для seed в функцию ask придётся добавить ещё один аргумент.

Шаг 5. Проверка смысла через LLM-судью

Детерминированные проверки не скажут, верен ли ответ по смыслу. На вопрос «можно ли вернуть товар через три недели?» правильный ответ «да», и слова «да» в нём может не быть. Для таких случаев используют вторую модель, судью: даём ей вопрос, эталон и ответ и просим вердикт.

JUDGE_PROMPT = """Ты строгий судья. Сравни ответ ассистента с эталоном.
Вопрос: {question}
Эталон: {reference}
Ответ ассистента: {answer}

Совпадает ли ответ с эталоном по смыслу? Лишние вежливые фразы не важны,
другие факты и цифры важны. Верни JSON: {{"verdict": "yes" или "no", "reason": "одна фраза"}}"""


@pytest.mark.judge
def test_answer_matches_reference_by_meaning():
    question = "Можно ли вернуть товар через три недели после покупки?"
    reference = "Да, можно: возврат возможен в течение 30 дней при наличии чека."
    answer = ask(question, system=SYSTEM)

    raw = ask(JUDGE_PROMPT.format(question=question, reference=reference, answer=answer),
              json_mode=True)
    verdict = json.loads(raw)
    assert verdict["verdict"] == "yes", f"судья: {verdict.get('reason')} | ответ: {answer!r}"

Двойные фигурные скобки {{ и }} нужны, чтобы format не принял пример JSON за место для подстановки.

Запуск: pytest -v -m judge.

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

  • возьмите судью сильнее проверяемой модели и, по возможности, из другого семейства;
  • разметьте вручную 50–100 ответов и посчитайте, как часто судья согласен с людьми;
  • зафиксируйте модель судьи и его промпт: другой судья даст другие цифры.

Как это сделать по шагам, я разбирал в статье можно ли доверять LLM-судье.

Где этот подход ломается

  • Проверка "30" in answer пропустит ответ «тридцать дней» и одобрит «не 30, а 14 дней». Простые правила хороши для формата и обязательных маркеров, но для смысла их мало.
  • Десять прогонов показывают недетерминизм, но для решения о релизе этого мало. Нужен датасет из десятков вопросов разных типов, а не один вопрос.
  • По умолчанию Ollama даёт модели небольшое окно контекста: при видеопамяти меньше 24 ГБ это 4 тысячи токенов (документация). Через OpenAI-совместимый API размер окна не задать. Если будете подавать модели длинные документы, увеличьте контекст в настройках Ollama, иначе часть текста молча не попадёт к модели.
  • Маленькая локальная модель отвечает хуже облачных. Для учёбы это плюс, для выводов о конкретном продукте нужна та модель, которая работает в продукте.

Что дальше

  • Следующая часть: как оценивать ответы LLM: от assert до LLM-судьи. Там вся лестница проверок, пороги и метрики RAG.
  • Добавьте в тесты ещё три вопроса, на которые в правилах нет ответа («есть ли доставка в Казахстан?»). Ожидаемое поведение: ассистент говорит, что не знает. Как бы вы это проверили?
  • Если захочется теории о том, почему ответы разные, вернитесь ко второй части про LLM.