Первый автотест для LLM за вечер: pytest + Ollama
Пошагово: ставим Ollama и маленькую модель, вызываем её через OpenAI-совместимый API и пишем на pytest семь проверок: формат JSON, обязательные факты, длину, стабильность на 10 прогонах и простую проверку через LLM-судью. Всё локально и бесплатно.
15ea6aa
Содержание
Коротко
- За вечер вы поднимете локальную модель через 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.
Два эксперимента, которые стоит сделать руками:
- Поставьте
temperature=0.0и сравните число уникальных ответов. - Добавьте в запрос параметр
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.


