# Как поднять фейковый LLM-сервер для тестов: mock OpenAI API своими руками

> AI Release · @ai_release1 · https://ai-release.org/guides/kak-podnyat-feykovyy-llm-server-dlya-testov-mock-openai.html

Локальный фейковый эндпоинт спасает, когда код уже написан, а тратить деньги и квоту реального провайдера на прогон автотестов не хочется: мок-сервер отвечает в формате OpenAI API (это де-факто стандартная схема запроса `POST /v1/chat/completions` и ответа с полем `choices`), поэтому ваш клиент, SDK или пайплайн видит привычный интерфейс, но за ним стоит ваш собственный код. Гайд рассчитан на Python 3.9+ и любой компьютер — Linux, macOS или Windows, — где есть интернет только для установки пакетов; после установки сервер работает полностью офлайн.

## Требования и подготовка
![Иллюстрация к разделу «Требования и подготовка»](https://ai-release.org/guides/img/kak-podnyat-feykovyy-llm-server-dlya-testov-mock-openai-ru-1.jpg)

- Python 3.9 или новее и менеджер пакетов `pip`.
- Свободный TCP-порт, по умолчанию возьмём `8000`. Если он занят, подойдёт любой другой, например `8010`.
- Установленные пакеты: `fastapi` (веб-фреймворк, который принимает HTTP-запросы) и `uvicorn` (ASGI-сервер — то, что реально слушает порт и запускает код FastAPI).
- Для проверки из терминала: `curl`. Для проверки из кода: любая библиотека-клиент OpenAI-совместимого API.
- Права: достаточно обычного пользователя, root/администратор не нужны, потому что порт выше 1024.
- Ключ API не нужен: сервер можно настроить так, чтобы принимать любой ключ или не требовать его вообще.

Установка выглядит так:

```bash
python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install fastapi uvicorn
```

Почему это работает: виртуальное окружение изолирует пакеты проекта, чтобы мок-сервер не конфликтовал с другими вашими библиотеками.

## Пошаговая инструкция
![Иллюстрация к разделу «Пошаговая инструкция»](https://ai-release.org/guides/img/kak-podnyat-feykovyy-llm-server-dlya-testov-mock-openai-ru-2.jpg)

1. Создайте файл `mock_llm.py` в пустой папке. Это единственный файл, который нужен: в нём будет и роутер, и логика ответа.

2. Опишите две «ручки»: `GET /v1/models` — список доступных моделей, и `POST /v1/chat/completions` — генерация ответа. Клиенты при старте часто проверяют список моделей, поэтому без первой ручки вторая может даже не вызваться.

**Пример:**

```python
# mock_llm.py
import time
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ChatRequest(BaseModel):
    model: str = "mock-model"
    messages: list = []
    temperature: float = 0.0
    stream: bool = False

@app.get("/v1/models")
def models():
    return {"object": "list",
            "data": [{"id": "mock-model", "object": "model", "owned_by": "local"}]}

@app.post("/v1/chat/completions")
def chat(req: ChatRequest):
    user_text = req.messages[-1]["content"] if req.messages else ""
    time.sleep(0.3)                      # имитируем задержку сети
    return {
        "id": "chatcmpl-mock-1",
        "object": "chat.completion",
        "created": int(time.time()),
        "model": req.model,
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": f"ЭХО: {user_text}"},
            "finish_reason": "stop"
        }],
        "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
    }
```

Почему это работает: `pydantic`-модель `ChatRequest` автоматически проверяет тело запроса, а `time.sleep(0.3)` даёт вашим тестам реалистичную задержку, чтобы таймауты и спиннеры в UI вели себя как с настоящим провайдером.

3. Запустите сервер командой ниже из той же папки.

**Пример:**

```bash
uvicorn mock_llm:app --host 127.0.0.1 --port 8000 --reload
```

Ожидаемый результат — в терминале строки вида `Uvicorn running on http://127.0.0.1:8000` и `Application startup complete`. Флаг `--reload` перезапускает сервер при сохранении файла: удобно, пока правите ответы.

4. Проверьте, что сервер отвечает. **Пример:**

```bash
curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"mock-model","messages":[{"role":"user","content":"привет"}]}'
```

Ожидаемый результат — JSON с `"content": "ЭХО: привет"`. Если увидели именно эхо, значит связка «запрос → валидация → ответ» работает. Дальше можно менять текст ответа на что угодно: например, возвращать фиксированную JSON-строку, чтобы тестировать парсинг.

5. Подключите свой клиент. Направьте SDK на локальный адрес: у OpenAI-совместимых клиентов для этого есть параметр `base_url`, а ключ можно передать любой непустой, чтобы клиент не падал на проверке.

**Пример:**

```python
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="test")
r = client.chat.completions.create(model="mock-model",
                                   messages=[{"role": "user", "content": "привет"}])
print(r.choices[0].message.content)   # ЭХО: привет
```

Почему это работает: клиент просто формирует HTTP-запрос по адресу `base_url + /chat/completions`. Обратите внимание на `/v1` в конце `base_url` — он не должен дублироваться дальше в коде.

6. Добавьте режимы для негативных тестов. Мок ценен тем, что умеет ломаться по команде: возвращать код `429` при определённом слове в промпте, отдавать `500` каждый третий запрос или «зависать» на 10 секунд для проверки таймаута. Реализуется это условиями внутри `chat()` — например, если в тексте есть подстрока `SLOW`, увеличивайте `time.sleep` до 10.

7. Проверьте сценарии со стримингом отдельно. Если клиент ставит `"stream": true`, а сервер отвечает обычным JSON, вы получите ошибку разбора. Начните с `stream=False` и только потом добавляйте `StreamingResponse`, отдающий кусочки в формате `data: {...}` с финальным `data: [DONE]`.

8. Прогоните автотесты против мока. Здесь появляется главный выигрыш: тесты становятся детерминированными и бесплатными. Если вы тестируете поиск по документам, посмотрите гайд [Локальный RAG: как поднять Qdrant или Chroma на своём мини-ПК](https://ai-release.org/guides/lokalnyy-rag-na-mini-pk.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide) — мок-LLM удобно поставить рядом с локальной векторной базой, чтобы проверять весь путь «запрос → поиск → ответ» без внешних сервисов. А если ваш агент вызывает инструменты, пригодится материал [MCP: что это и как подключить первый MCP-сервер](https://ai-release.org/guides/mcp-chto-eto-i-kak-podklyuchit-server.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide): MCP — это протокол, по которому модель получает доступ к внешним инструментам, и мок умеет возвращать заранее заданный вызов инструмента, чтобы проверить обработку на своей стороне.

Чек-лист перед тем, как считать мок готовым:

- [ ] `GET /v1/models` отвечает списком
- [ ] `POST /v1/chat/completions` отвечает в формате с `choices[0].message.content`
- [ ] ваш клиент запускается с `base_url`, указывающим на `127.0.0.1:8000/v1`
- [ ] есть хотя бы один негативный сценарий: `429`, `500` или таймаут
- [ ] автотесты проходят без доступа в интернет

## Возможные проблемы и решения
![Иллюстрация к разделу «Возможные проблемы и решения»](https://ai-release.org/guides/img/kak-podnyat-feykovyy-llm-server-dlya-testov-mock-openai-ru-3.jpg)

**Симптом: `curl: (7) Failed to connect to 127.0.0.1 port 8000` или `Connection refused`.** Это значит, что никто не слушает порт. Проверьте, что окно с `uvicorn` ещё открыто и в нём нет трейсбека после старта. Затем убедитесь, что порт не занят: команда `lsof -i :8000` (Linux/macOS) или `netstat -ano | findstr :8000` (Windows) покажет процесс. Решение — либо остановить чужой процесс, либо поднять мок на другом порту: `uvicorn mock_llm:app --port 8010` и поменять `base_url` на `http://127.0.0.1:8010/v1`.

**Симптом: `404 Not Found` в ответ на запрос к чату.** Почти всегда это неверный путь: либо в `base_url` нет `/v1`, либо он есть и клиент добавляет свой второй `/v1`, получая `/v1/v1/chat/completions`. Решение — распечатать итоговый URL (`base_url` плюс путь) и сверить со списком ручек в коде мока. Быстрая проверка: `curl http://127.0.0.1:8000/v1/models` должен вернуть JSON, а не 404.

**Симптом: `422 Unprocessable Entity` с описанием поля.** Мок отклонил тело запроса, потому что структура не совпала со схемой. Частая причина — клиент шлёт `messages` как объект, а не список, или добавляет новые поля. Решение: посмотрите текст ошибки, там указано поле и причина; затем либо приведите запрос к схеме, либо ослабьте требования в `ChatRequest`, добавив значения по умолчанию или `dict` вместо строгого типа.

**Симптом: ответ приходит целиком, хотя клиент ждал поток.** Клиент поставил `"stream": true`, а мок вернул обычный JSON. Решение: либо временно отключите стриминг у клиента, либо реализуйте отдачу чанками и завершайте поток маркером `[DONE]`.

## FAQ

**Нужен ли настоящий API-ключ, чтобы мок заработал?**
Нет. Достаточно передать любую непустую строку, например `test`, потому что проверка ключа — забота сервера, а ваш мок её не делает. Если клиент требует ключ из переменной окружения, задайте её локально, например `OPENAI_API_KEY=test`.

**Можно ли пользоваться мок-сервером без интернета?**
Да, после установки `fastapi` и `uvicorn` сервер работает офлайн, потому что он ничего не запрашивает наружу. Это удобно в закрытых контурах и на машинах без доступа к внешним API.

**Чем мок отличается от локальной модели вроде Ollama?**
Мок не думает: он возвращает то, что вы в него заложили, и всегда одинаково. Локальная модель генерирует настоящий текст, но её ответы менее предсказуемы и требуют ресурсов. Для автотестов берут мок, для демо качества ответов — локальную модель.

**Подходит ли такой сервер для проверки агентов и вызовов инструментов?**
Да. Верните в ответе заранее заданный вызов инструмента, и ваш код обработает его как настоящий. Если инструменты у вас описаны через MCP, начните с гайда [MCP: что это и как подключить первый MCP-сервер](https://ai-release.org/guides/mcp-chto-eto-i-kak-podklyuchit-server.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide), а затем подставьте мок как источник ответов.
