Содержание
- Что такое LLMOps и какую задачу он закрывает
- Чем LLMOps отличается от MLOps
- Архитектура LLMOps-контура
- Промпт как артефакт релиза
- Трассировка и токеномика
- Почему оценка заменяет привычные метрики
- Подводные камни эксплуатации LLM-приложений
- Практика: минимальный LLMOps-контур на локальной модели
- Когда LLMOps нужен, а когда избыточен
- Заключение
- Референсные ссылки
LLMOps (Large Language Model Operations) это набор практик и инструментов для разработки, тестирования, развёртывания, мониторинга и сопровождения приложений на основе больших языковых моделей. Подход вырос из MLOps и добавил к нему то, чего в классической эксплуатации ML-моделей не было: управление промптами, поиском по документам, контекстом, ИИ-агентами, стоимостью запросов и качеством ответов. Разберём, из чего состоит LLMOps-контур, чем он принципиально отличается от MLOps и как выглядит его минимальная рабочая версия на локальной модели.
Что такое LLMOps и какую задачу он закрывает
LLMOps это операционная надстройка над приложением, которое обращается к большой языковой модели (large language model, LLM). Класс решений тот же, что у DevOps и MLOps: набор практик, соглашений и инструментов, которые превращают работающий на ноутбуке прототип в сервис с предсказуемым поведением, понятной стоимостью и возможностью откатиться назад.
Задача, которую закрывает LLMOps, формулируется просто. Демо на LLM собирается за вечер. А вот ответ на вопрос «стало ли лучше после вчерашней правки» без специального контура получить нельзя. У LLM-приложения нет привычной метрики вроде accuracy на отложенной выборке, нет единственного артефакта модели, который можно положить в реестр, и нет гарантии, что на одном и том же входе завтра придёт тот же ответ. LLMOps добавляет именно эти недостающие детали: реестр версий промптов, трассировку каждого вызова, наборы тестовых сценариев с оценкой, контроль расхода токенов и ограничители на опасный вывод.
Важно, что LLMOps не отменяет MLOps и не заменяет его. Это надстройка: инфраструктура, CI/CD, версионирование данных и мониторинг сервиса остаются теми же. Меняется предмет управления.
Чем LLMOps отличается от MLOps
Главное отличие в том, что уезжает в продакшен. В MLOps релиз это новый файл модели, обученной на ваших данных. В LLM-приложении модель чужая. Её берут готовой и трогают редко. Релизом становится текст промпта, настройки поиска по базе знаний, список разрешённых инструментов и политика фильтрации. Всё это обычные строки и конфиги, которые легко поправить в обход код-ревью, а последствия правки такие же, как у подмены модели.
| Аспект | MLOps | LLMOps |
|---|---|---|
| Артефакт релиза | Обученная модель, веса, препроцессор | Промпт, настройки поиска, права инструментов, guardrails |
| Источник качества | Обучение на своих данных | Формулировка задачи и подобранный контекст |
| Как измеряется качество | Метрики на размеченной выборке | Наборы сценариев, оценка человеком или моделью-судьёй |
| Воспроизводимость | Детерминированный вывод при фиксированных весах | Вывод недетерминирован, повтор запроса даёт другой текст |
| Основная статья затрат | Обучение, разовый пик нагрузки | Инференс, оплачивается каждый запрос по токенам |
| Что ломает продакшен | Дрейф данных, деградация признаков | Смена версии модели у провайдера, правка промпта, разросшийся контекст |
Отсюда следует практический вывод: инструменты MLOps остаются на месте, но метрик из них не хватает. Подробный разбор того, как одно вырастает из другого, есть в статье блога Что такое LLMOps или MLOps для больших языковых моделей.
Разработка и внедрение ML-решений
Код курса
MLOPS
Ближайшая дата курса
24 августа, 2026
Продолжительность
24 ак.часов
Стоимость обучения
66 000
Архитектура LLMOps-контура
Контур LLMOps замкнут в петлю: версия промпта уезжает в приложение, приложение пишет трейсы, по трейсам и тестовым наборам считается оценка, оценка решает судьбу следующей версии. Разорванная петля превращает разработку в угадывание.
Типичный набор компонентов выглядит так.
- Реестр промптов. Хранит версии текста, кто и когда менял, какая версия сейчас в продакшене. Промпт получает хеш и номер ровно так же, как образ контейнера получает тег.
- Шлюз к моделям. Единая точка вызова провайдеров, где живут лимиты, ретраи, таймауты и подмена модели без правки кода приложения.
- Трассировка. Запись каждого вызова со всеми промежуточными шагами: какой промпт, какой контекст подмешался, сколько токенов ушло, сколько заняла генерация.
- Оценка. Наборы сценариев с эталонами, автоматические проверки и модель-судья для того, что формально не проверишь.
- Ограничители. Фильтры на вход и выход, маскирование персональных данных, лимит расхода на пользователя.
Собирать всё это руками не обязательно. MLflow, Langfuse и подобные платформы закрывают трассировку, реестр промптов и оценку из коробки.
Промпт как артефакт релиза
Правка одной строки в системном промпте меняет поведение сервиса сильнее, чем недельный рефакторинг кода. Поэтому промпт хранят отдельно от кода, версионируют и катят как релиз: с автором изменения, датой, возможностью откатить и с обязательным прогоном тестового набора до выкатки. Хеш текста промпта проставляется в каждый трейс. Без него через месяц не понять, какая формулировка породила плохой ответ.
Трассировка и токеномика
Трейс LLM-вызова это не просто строка лога. В него пишут идентификатор запроса, имя и версию промпта, модель, входной и выходной текст, число входных и выходных токенов, латентность и расчётную стоимость. Индустрия сходится на общем словаре. Спецификация OpenTelemetry для генеративного ИИ фиксирует атрибуты вида gen_ai.request.model, gen_ai.usage.input_tokens и gen_ai.usage.output_tokens. На момент публикации она остаётся в статусе Development, то есть ещё не стабилизирована. MLflow Tracing уже поддерживает эти соглашения на экспорт и приём, что снимает привязку к одному вендору.
Стоимость в LLM-приложении это такая же эксплуатационная метрика, как латентность. Она считается по токенам и растёт линейно с трафиком. Удвоить её может безобидная правка промпта, добавившая пару абзацев инструкций в каждый запрос.
Почему оценка заменяет привычные метрики
В классическом машинном обучении качество меряют на размеченной выборке одним числом. С LLM так не выходит по трём причинам, и именно они делают LLMOps отдельной дисциплиной.
Во-первых, у большинства задач нет единственного правильного ответа: пересказ, ответ по документам и письмо клиенту допускают десятки корректных формулировок. Во-вторых, вывод недетерминирован, поэтому одиночный удачный прогон ничего не доказывает. В-третьих, качество зависит от версии модели у провайдера, которую вы не контролируете.
Работающий ответ на это состоит из трёх слоёв проверок. Формальные автотесты закрывают то, что проверяется машинально: формат ответа, наличие обязательных полей, отсутствие запрещённых слов. Модель-судья (LLM-as-a-judge) оценивает то, что формализовать сложно, например связность и полноту ответа. Разметка человеком остаётся эталоном, к которому калибруют первые два слоя. MLflow, например, поставляет готовые метрики модели-судьи и хранилище эталонных наборов, чтобы этот прогон не приходилось собирать с нуля.
Подводные камни эксплуатации LLM-приложений
Ниже собраны проблемы, которые всплывают в проде и почти не встречаются в обычных ML-сервисах.
- Тихая смена модели. Провайдер выкатил новую версию под тем же именем, ваш код не изменился, а метрики поехали. Лечится пином версии и регулярным прогоном тестового набора.
- Улучшение на трёх примерах. Правка промпта помогла на кейсах, которые разработчик держал в голове, и сломала пять других. Лечится только зафиксированным набором сценариев.
- Расползание контекста. В промпт добавляют инструкции и примеры, он растёт, растут счета и латентность, а качество после определённого объёма перестаёт улучшаться.
- Мусор в ответе. Модель внезапно вставляет фрагмент на другом языке или служебную разметку. Без разбора формата ответа такое доезжает до пользователя.
- Отсутствие следа. Жалоба пользователя без трассировки неразрешима: вы не знаете ни какой промпт применялся, ни что модель ответила на самом деле.
Общее у всех пунктов одно: они обнаруживаются не в момент правки, а через несколько дней, и без трейсов с оценкой их не поймать вовсе.
ИИ-агенты для оптимизации бизнес-процессов
Код курса
AGENT
Ближайшая дата курса
26 октября, 2026
Продолжительность
24 ак.часов
Стоимость обучения
66 000
Практика: минимальный LLMOps-контур на локальной модели
По традиции весь код используемый в статье выкладываем на наш GitHub репозиторий
Чтобы увидеть смысл контура, платформа не нужна. Достаточно двух файлов и локальной модели через Ollama. Файл llm_trace.py держит реестр промптов с версиями и хешем, вызывает модель и пишет трейс каждого вызова в JSONL с латентностью, токенами и расчётной стоимостью.
# LLMOps demo, прогнано на Python 3.12.13, ollama-python 0.6.2, Ollama 0.32.9, модель qwen2.5:7b
"""Минимальный LLMOps-контур: версионированный промпт плюс трассировка каждого вызова."""
import hashlib
import json
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
import ollama
BASE_DIR = Path(__file__).resolve().parent
TRACE_FILE = BASE_DIR / "traces.jsonl"
# Модель это такой же параметр конфигурации, как адрес базы: её меняют без правки кода.
MODEL = os.getenv("LLMOPS_MODEL", "qwen2.5:7b")
# Реестр промптов. Каждая версия это отдельный артефакт релиза: правка текста
# меняет поведение системы так же, как в MLOps его меняет новый файл модели.
PROMPTS = {
"support_classifier": {
"v1": "Определи категорию обращения пользователя.",
"v2": (
"Ты классификатор обращений в техподдержку. "
"Ответь ровно одним словом из списка: billing, access, performance, bug, other. "
"Никаких пояснений, знаков препинания и заглавных букв."
),
}
}
# Условный тариф провайдера в рублях за 1000 токенов. Локальная модель денег не стоит,
# но поле оставлено намеренно: в проде стоимость запроса это такая же метрика, как латентность.
PRICE_IN_PER_1K = 0.15
PRICE_OUT_PER_1K = 0.60
def resolve_prompt(name: str, version: str) -> tuple[str, str]:
"""Возвращает текст промпта и его короткий хеш, который уезжает в трейс."""
text = PROMPTS[name][version]
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
return text, digest
def ask(name: str, version: str, user_text: str, temperature: float = 0.0, seed: int | None = 42) -> dict:
"""Один вызов модели с замером латентности, токенов и стоимости, запись трейса в JSONL."""
system_prompt, digest = resolve_prompt(name, version)
# Без seed Ollama сама выбирает случайное зерно, и повторный вызов даёт другой ответ.
options = {"temperature": temperature}
if seed is not None:
options["seed"] = seed
started = time.perf_counter()
response = ollama.chat(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
options=options,
)
latency_ms = round((time.perf_counter() - started) * 1000)
tokens_in = response.prompt_eval_count or 0
tokens_out = response.eval_count or 0
cost_rub = round(
tokens_in / 1000 * PRICE_IN_PER_1K + tokens_out / 1000 * PRICE_OUT_PER_1K, 5
)
record = {
"trace_id": str(uuid.uuid4()),
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"model": MODEL,
"prompt_name": name,
"prompt_version": version,
"prompt_hash": digest,
"temperature": temperature,
"input": user_text,
"output": response.message.content.strip(),
"latency_ms": latency_ms,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"cost_rub": cost_rub,
}
# Трейс дописывается построчно: JSONL переживает падение процесса и читается любым инструментом.
with TRACE_FILE.open("a", encoding="utf-8") as trace:
trace.write(json.dumps(record, ensure_ascii=False) + "\n")
return record
if __name__ == "__main__":
demo = ask("support_classifier", "v2", "Списали деньги дважды за один и тот же тариф")
print(json.dumps(demo, ensure_ascii=False, indent=2))
Второй файл, eval_run.py, это тот самый регрессионный прогон. Он берёт восемь обращений с известной категорией и гоняет их на двух версиях промпта. Считает долю попаданий, латентность и стоимость. В конце показывает недетерминированность на одном и том же входе.
# LLMOps demo, прогнано на Python 3.12.13, ollama-python 0.6.2, Ollama 0.32.9, модель qwen2.5:7b
"""Offline-eval двух версий промпта плюс проба на недетерминированность."""
import statistics
from llm_trace import ask
# Набор кейсов с эталоном. Это регрессионный тест LLM-приложения: он гоняется
# на каждую правку промпта и на каждую смену версии модели.
CASES = [
("Списали деньги дважды за один и тот же тариф", "billing"),
("Не приходит письмо для сброса пароля", "access"),
("Отчёт строится восемь минут вместо десяти секунд", "performance"),
("Кнопка экспорта роняет вкладку браузера", "bug"),
("Хочу узнать, когда у вас корпоративы", "other"),
("Не могу войти под своей учётной записью", "access"),
("Счёт выставлен на юрлицо, а нужен на другое", "billing"),
("Приложение виснет при загрузке файла больше 100 мегабайт", "performance"),
]
def normalize(answer: str) -> str:
"""Модель любит добавлять точки, кавычки и заглавные буквы, эталон от этого не меняется."""
return answer.strip().strip('.,!"\'`«»').lower()
def run_version(version: str) -> dict:
"""Прогоняет весь набор на одной версии промпта и считает агрегаты."""
passed, latencies, tokens, cost = 0, [], 0, 0.0
for text, expected in CASES:
record = ask("support_classifier", version, text)
ok = normalize(record["output"]) == expected
passed += ok
latencies.append(record["latency_ms"])
tokens += record["tokens_in"] + record["tokens_out"]
cost += record["cost_rub"]
mark = "OK " if ok else "FAIL"
print(f" {mark} ожидали={expected:<12} получили={record['output'][:60]!r}") return { "version": version, "pass_rate": round(passed / len(CASES) * 100), "latency_median_ms": round(statistics.median(latencies)), "latency_max_ms": max(latencies), "tokens": tokens, "cost_rub": round(cost, 4), } def drift_probe(runs: int = 3) -> None:
"""Один и тот же вход без фиксированного seed даёт разные ответы, и это штатное поведение."""
question = "Списали деньги дважды за один и тот же тариф"
answers = [
ask("support_classifier", "v1", question, temperature=0.8, seed=None)["output"]
for _ in range(runs)
]
unique = len(set(answers))
print(f" уникальных ответов на один вход: {unique} из {runs}")
for number, answer in enumerate(answers, start=1):
print(f" [{number}] {answer[:100]}")
if __name__ == "__main__":
results = []
for version in ("v1", "v2"):
print(f"\n=== Прогон версии промпта {version} ===")
results.append(run_version(version))
print("\n=== Сводка ===")
header = f"{'версия':<8}{'pass rate':<12}{'медиана мс':<14}{'макс мс':<10}{'токены':<9}{'руб':<8}"
print(header)
for row in results:
print(
f"{row['version']:<8}{str(row['pass_rate']) + '%':<12}"
f"{row['latency_median_ms']:<14}{row['latency_max_ms']:<10}"
f"{row['tokens']:<9}{row['cost_rub']:<8}"
)
print("\n=== Проба на недетерминированность ===")
drift_probe()
Прогон на модели qwen2.5:7b даёт вот такую сводку.
Цифры говорят сразу о нескольких вещах. Расплывчатая версия промпта не прошла ни одного кейса: модель отвечала свободным текстом вместо категории. Она же оказалась вчетверо медленнее и дороже, потому что лишние токены на выходе стоят денег. Строгая версия дала 75%. Это не идеал, и два провалившихся кейса и есть предмет следующей итерации. Проба на недетерминированность вернула три разных ответа на один вход, причём в третьем модель приплела иероглифы. Такой ответ спокойно уедет пользователю, если после генерации нет проверки формата.
Ещё нагляднее эффект от смены модели. Тот же самый промпт v2 без единой правки кода на llama3.1:8b прошёл все восемь кейсов, то есть 100% против 75%.
Это ровно тот сценарий, ради которого в LLMOps держат регрессионный набор: качество меняется от того, что вы не писали и не контролируете.
Когда LLMOps нужен, а когда избыточен
Полный контур оправдан там, где ответ модели видит пользователь или на нём завязано решение: поддержка, поиск по документам с генерацией (retrieval-augmented generation), ассистенты внутри продукта, ИИ-агенты с доступом к инструментам. Чем больше шагов между запросом и ответом, тем нужнее трассировка: без неё непонятно, на каком шаге агент свернул не туда.
Разворачивать всё сразу не нужно. Разовая аналитическая задача или прототип для демонстрации отлично живут без реестра промптов и дашбордов. Разумный порядок такой: сначала трассировка вызовов, потом фиксация версий промптов, потом набор сценариев с оценкой, и только затем дашборды, ограничители и модель-судья. Обратный порядок обычно заканчивается красивой панелью, по которой нечего смотреть.
Отдельно стоит сказать про людей и процессы. LLMOps требует, чтобы кто-то регулярно смотрел на трейсы и пополнял набор сценариев реальными провалами из прода. Инструмент это делать за команду не будет. Как выстраивается такая эксплуатация в целом, разбирается на курсе MLOps: Разработка и внедрение ML-решений, а специфика промптов и контекста подробно рассмотрена в материалах про prompt engineering.
Заключение
LLMOps это про то, чтобы приложение на большой языковой модели вело себя предсказуемо, когда его код не менялся. Три вещи делают основную работу: версионированный промпт, трейс каждого вызова с токенами и латентностью, регрессионный набор сценариев с оценкой. Всё остальное, от шлюзов до модели-судьи, наращивается поверх по мере роста нагрузки. Минимальная версия контура умещается в две сотни строк и уже отвечает на главный вопрос эксплуатации: стало ли лучше после последней правки.
Референсные ссылки
- MLflow Tracing for LLM and Agent Observability, официальная документация по трассировке и совместимости с OpenTelemetry
- MLflow, LLM and Agent Evaluation, официальная документация по оценке, метрикам модели-судьи и эталонным наборам
- Langfuse, LLM Observability and Application Tracing, официальная документация по трассировке и управлению промптами
- Inside the LLM Call, GenAI Observability with OpenTelemetry, разбор атрибутов gen_ai и текущего статуса спецификации
- Что такое LLMOps или MLOps для больших языковых моделей, обзорная статья блога BigDataSchool




