Как принимать архитектурные решения: KPI и трейдоффы, выбор стратегии (промпт vs LoRA vs RAG), построение датасета, оценка, пайплайны, fallback. От первого запроса до продакшена.
метрики и компромиссы
Прежде чем выбирать архитектуру — определи измеримые KPI. Без явных метрик любое решение оправдано, значит ни одно не обосновано. LLM-системы имеют четыре основных оси трейдоффов.
Большая модель = лучше. Но каждый дополнительный billion параметров добавляет latency. GPT-4 = ~2s, GPT-4o-mini = ~300ms на аналогичных промптах.
GPT-4o: $5/1M tokens input. GPT-4o-mini: $0.15/1M. При 10M запросов/месяц разница = $49,000 ежемесячно. Но если mini не решает задачу — экономия иллюзорна.
RAG с живой базой — актуальные данные, но нестабильность: новый документ может сломать ответ. Fine-tuned model стабильна, но устаревает.
Специализированная модель предсказуема на целевых задачах, но хуже на краевых случаях. Общая модель гибкая, но непредсказуемая в деталях.
| Задача | Бизнес-метрика | Техническая метрика | Порог «достаточно» |
|---|---|---|---|
| Саппорт-бот | % решённых без оператора | Intent accuracy, ROUGE vs шаблон | >80% resolve rate |
| NER в документах | Время обработки документа | F1 по сущностям, Precision@K | F1 > 0.90 |
| Суммаризация | Время прочтения уменьшилось | ROUGE-L, BERTScore, LLM-judge | LLM-judge > 4/5 |
| Поиск (RAG) | Click-through на результаты | Hit@5, MRR, nDCG@10 | Hit@5 > 0.85 |
| Генерация кода | % принятых PR от AI | Pass@K (тесты), BLEU | Pass@1 > 0.70 |
архитектурный выбор
LLM — не всегда правильный ответ. Понимание того, когда классический ML лучше, — фундаментальный навык system design.
// ВЫБОР АРХИТЕКТУРЫ · серая зона — когда оба подхода применимы, решает контекст задачи
| Сценарий | Рекомендация | Причина |
|---|---|---|
| Спам-фильтрация email | Классический ML (XGBoost) | Структурированные сигналы, миллионы примеров, нужна скорость <5ms |
| Классификация тональности (50 классов) | LLM + few-shot → SFT | Семантические нюансы, мало данных на класс |
| Рекомендательная система | Классический ML + embeddings | Нужна sub-10ms latency, миллионы item'ов |
| Извлечение информации из договоров | LLM (RAG или SFT) | Неструктурированный текст, complex reasoning |
| Предсказание churn | Классический ML | Табличные данные, объяснимость нужна бизнесу |
| Ответы в поддержку (FAQ) | RAG + LLM | Знания часто обновляются, нет данных для FT |
поэтапный подход
Золотое правило system design: начни с простого, усложняй только когда необходимо. Каждая ступень лестницы решает реальную проблему предыдущей — не прыгай через ступени.
// ЛЕСТНИЦА СЛОЖНОСТИ · каждая ступень добавляет затраты и требует обоснования
from openai import OpenAI import json client = OpenAI() # Хорошо структурированный few-shot промпт для NER SYSTEM = """Ты — система извлечения информации из деловых писем. Извлекай: PERSON, ORGANIZATION, DATE, AMOUNT, LOCATION. Отвечай ТОЛЬКО валидным JSON. Не добавляй пояснений.""" FEW_SHOT_EXAMPLES = [ { "role": "user", "content": "Иван Петров из ООО Ромашка предлагает встретиться 15 мая в Москве." }, { "role": "assistant", "content": """{"entities": [ {"text": "Иван Петров", "type": "PERSON"}, {"text": "ООО Ромашка", "type": "ORGANIZATION"}, {"text": "15 мая", "type": "DATE"}, {"text": "Москве", "type": "LOCATION"} ]}""" }, { "role": "user", "content": "Платёж на 500 000 рублей от Сбербанка поступит 20 декабря." }, { "role": "assistant", "content": """{"entities": [ {"text": "500 000 рублей", "type": "AMOUNT"}, {"text": "Сбербанка", "type": "ORGANIZATION"}, {"text": "20 декабря", "type": "DATE"} ]}""" }, ] def extract_entities(text: str) -> dict: messages = [ {"role": "system", "content": SYSTEM}, *FEW_SHOT_EXAMPLES, {"role": "user", "content": text}, ] resp = client.chat.completions.create( model="gpt-4o-mini", # дешевле чем gpt-4o, часто достаточно messages=messages, temperature=0, # детерминизм для структурированного вывода response_format={"type": "json_object"}, # JSON mode ) return json.loads(resp.choices[0].message.content) result = extract_entities("Директор Алексей Смирнов подписал договор с Газпромом на 2 млн.") # {"entities": [ # {"text": "Алексей Смирнов", "type": "PERSON"}, # {"text": "Газпромом", "type": "ORGANIZATION"}, # {"text": "2 млн", "type": "AMOUNT"}]}
from sentence_transformers import SentenceTransformer, CrossEncoder import faiss, numpy as np from openai import OpenAI # ── Компоненты RAG-пайплайна ───────────────────────────────── # 1. Bi-encoder: быстрый первичный retrieval (ANN search) bi_encoder = SentenceTransformer("BAAI/bge-m3") # поддерживает RU # 2. Cross-encoder: медленный но точный реранкинг топ-K cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") # 3. FAISS index (в проде: Qdrant, Weaviate, Pinecone) def build_index(documents: list[str]): embeddings = bi_encoder.encode(documents, normalize_embeddings=True) index = faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings) return index, embeddings def rag_pipeline( query: str, documents: list[str], index: faiss.Index, top_k_retrieve: int = 20, # первичный retrieval: много top_k_rerank: int = 4, # после реранкинга: мало ) -> str: # Шаг 1: Bi-encoder retrieval q_emb = bi_encoder.encode([query], normalize_embeddings=True) D, I = index.search(q_emb, top_k_retrieve) candidates = [documents[i] for i in I[0]] # Шаг 2: Cross-encoder reranking # Cross-encoder читает (query, doc) вместе → точнее bi-encoder pairs = [(query, doc) for doc in candidates] scores = cross_encoder.predict(pairs) ranked = sorted(zip(scores, candidates), reverse=True) top_docs = [doc for _, doc in ranked[:top_k_rerank]] # Шаг 3: Augmented generation context = "\n\n".join([f"[{i+1}] {d}" for i, d in enumerate(top_docs)]) prompt = f"""Ответь на вопрос, используя ТОЛЬКО предоставленный контекст. Если ответа нет в контексте — скажи "Не знаю". Контекст: {context} Вопрос: {query} Ответ:""" client = OpenAI() resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0, messages=[{"role": "user", "content": prompt}] ) return resp.choices[0].message.content # Почему reranking важен: # Bi-encoder: recall@20 ≈ 0.85, precision@4 ≈ 0.55 # + Cross-encoder: recall@4 ≈ 0.82, precision@4 ≈ 0.78 # Reranking добавляет +42% precision при том же recall!
данные для оценки
Golden dataset — небольшая (100–1000 примеров), высококачественная, репрезентативная выборка с проверенными ответами. Это твоя North Star: любое изменение системы оценивается по ней.
Когда нет бюджета на ручную разметку тысяч примеров — используй weak supervision: несколько слабых (шумных) источников сигнала объединяются через label model.
from snorkel.labeling import labeling_function, PandasLFApplier from snorkel.labeling.model import LabelModel import pandas as pd import re POSITIVE, NEGATIVE, ABSTAIN = 1, 0, -1 # Labeling functions (LF) — слабые, шумные сигналы @labeling_function() def lf_keywords_positive(x): """Позитивные ключевые слова → POSITIVE.""" pos_words = ["отлично", "супер", "рекомендую", "доволен"] return POSITIVE if any(w in x.text.lower() for w in pos_words) else ABSTAIN @labeling_function() def lf_keywords_negative(x): neg_words = ["ужасно", "плохо", "разочарован", "верну"] return NEGATIVE if any(w in x.text.lower() for w in neg_words) else ABSTAIN @labeling_function() def lf_rating_high(x): """Оценка ≥ 4 → POSITIVE.""" return POSITIVE if hasattr(x, "rating") and x.rating >= 4 else ABSTAIN @labeling_function() def lf_llm_judge(x): """LLM как labeling function — дорого но мощно.""" # Промпт к GPT-4o-mini для разметки response = call_llm(f"Тональность: '{x.text}'. Ответь: POSITIVE/NEGATIVE") if "POSITIVE" in response: return POSITIVE if "NEGATIVE" in response: return NEGATIVE return ABSTAIN # Применяем LF к датасету lfs = [lf_keywords_positive, lf_keywords_negative, lf_rating_high, lf_llm_judge] applier = PandasLFApplier(lfs=lfs) L_train = applier.apply(df=train_df) # матрица (N, len(lfs)) # Label Model: агрегирует шумные метки учитывая точность каждой LF label_model = LabelModel(cardinality=2) label_model.fit(L_train, n_epochs=300) probs = label_model.predict_proba(L_train) # вероятности классов # Используем только высоко-уверенные примеры (prob > 0.8) в датасете
Active learning — модель сама запрашивает разметку для примеров, в которых она наименее уверена. Это снижает стоимость разметки в 3–10× при том же качестве.
import numpy as np from scipy.stats import entropy def uncertainty_sampling( model, unlabeled_pool: list[str], n_to_label: int = 50, strategy: str = "entropy" ) -> list[int]: """Выбираем наиболее неопределённые примеры для разметки.""" # Получаем вероятности классов от модели probs = np.array([ model.predict_proba([text])[0] for text in unlabeled_pool ]) # shape: (N, num_classes) if strategy == "entropy": # Энтропия: максимум при равномерном распределении scores = entropy(probs, axis=1) elif strategy == "least_confident": # Наименее уверен = минимальный max_prob scores = 1 - probs.max(axis=1) elif strategy == "margin": # Margin sampling: разница между топ-2 вероятностями sorted_probs = np.sort(probs, axis=1)[:, ::-1] scores = 1 - (sorted_probs[:, 0] - sorted_probs[:, 1]) # Возвращаем индексы n_to_label самых неопределённых uncertain_idx = np.argsort(scores)[::-1][:n_to_label] return uncertain_idx.tolist()
система оценки
LLM-as-Judge (Zheng et al., 2023) — использование мощной LLM (GPT-4, Claude) для автоматической оценки качества ответов. Работает как offline, так и как метрика в CI/CD.
from openai import OpenAI import json client = OpenAI() JUDGE_TEMPLATE = """Оцени качество ответа ассистента по критериям. Отвечай ТОЛЬКО JSON с числами от 1 до 5. Вопрос пользователя: {question} Ответ ассистента: {answer} Эталонный ответ: {reference} Оцени: - relevance: насколько ответ отвечает на вопрос (1-5) - accuracy: фактическая точность (1-5) - completeness: полнота ответа (1-5) - clarity: ясность изложения (1-5) JSON: {{"relevance": N, "accuracy": N, "completeness": N, "clarity": N, "overall": N}}""" def llm_judge(question: str, answer: str, reference: str = "") -> dict: prompt = JUDGE_TEMPLATE.format( question=question, answer=answer, reference=reference ) resp = client.chat.completions.create( model="gpt-4o", # лучше использовать сильную модель для судьи messages=[{"role": "user", "content": prompt}], temperature=0, response_format={"type": "json_object"}, ) return json.loads(resp.choices[0].message.content) # Batch evaluation на golden set def evaluate_system(system_fn, golden_set: list[dict]) -> dict: all_scores = [] for example in golden_set: answer = system_fn(example["question"]) scores = llm_judge( question=example["question"], answer=answer, reference=example["reference_answer"], ) all_scores.append(scores) # Агрегация: mean + std для каждой метрики import numpy as np metrics = {} for key in ["relevance", "accuracy", "completeness", "clarity", "overall"]: vals = [s[key] for s in all_scores if key in s] metrics[key] = {"mean": np.mean(vals), "std": np.std(vals)} return metrics
Оффлайн-метрики не заменяют реальный пользовательский сигнал. A/B тест — единственный способ узнать, работает ли изменение в продакшене.
import hashlib from scipy import stats def get_variant(user_id: str, experiment: str, traffic_split: float = 0.5) -> str: """Детерминированное назначение в вариант по user_id.""" seed = f"{user_id}:{experiment}" h = hashlib.md5(seed.encode()).hexdigest() bucket = int(h[:4], 16) / 65535 # 0..1 return "treatment" if bucket < traffic_split else "control" def check_significance(control_rates: list[float], treatment_rates: list[float], alpha: float = 0.05) -> dict: """t-test для сравнения метрик control vs treatment.""" t_stat, p_value = stats.ttest_ind(control_rates, treatment_rates) ctrl_mean = sum(control_rates) / len(control_rates) trt_mean = sum(treatment_rates) / len(treatment_rates) relative_lift = (trt_mean - ctrl_mean) / ctrl_mean * 100 return { "significant": p_value < alpha, "p_value": round(p_value, 4), "relative_lift_pct": round(relative_lift, 2), "control_mean": ctrl_mean, "treatment_mean": trt_mean, } # Пример: тестируем RAG vs zero-shot # control: resolve_rate = [0.74, 0.72, 0.76, ...] (zero-shot) # treatment: resolve_rate = [0.81, 0.83, 0.79, ...] (RAG) # check_significance(...) → {"significant": True, # "p_value": 0.003, "relative_lift_pct": 9.5}
архитектурные паттерны
// NER PIPELINE · препроцессинг → LLM → постпроцессинг → confidence filter → fallback
| Задача | Рекомендованный стек | Ключевые метрики | Ловушки |
|---|---|---|---|
| NER | Few-shot → GLiNER/SFT, постпроцессинг (BIO → span), confidence threshold | F1 по типам, span-level precision | Перекрывающиеся сущности, вложенные |
| Суммаризация | Промпт + длина контроль → RAG для длинных doc, LLM-judge + ROUGE | ROUGE-L, BERTScore, faithfulness | Галлюцинации, потеря ключевых фактов |
| Классификация | Few-shot → SFT если >500 классов, calibration, threshold tuning | Macro-F1, calibration ECE | Label imbalance, drift с временем |
| QA / Поиск | BM25 + dense retrieval + cross-encoder reranking + LLM | Hit@5, MRR, Faithfulness | Retrieval bottleneck, context overflow |
| Извлечение данных | Structured output (JSON mode), валидация схемы Pydantic | Field-level F1, schema validity % | Опциональные поля, вложенные структуры |
from openai import OpenAI client = OpenAI() def summarize_with_faithfulness_check(document: str, max_sentences: int = 5) -> dict: """Суммаризация с автоматической проверкой фактической точности.""" # Шаг 1: Суммаризация summary_prompt = f"""Создай краткое резюме документа в {max_sentences} предложениях. Включай ТОЛЬКО факты из документа. Не добавляй внешних знаний. Документ: {document} Резюме:""" summary_resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0.3, messages=[{"role": "user", "content": summary_prompt}] ) summary = summary_resp.choices[0].message.content # Шаг 2: Проверка faithfulness (NLI-подход через LLM) faithfulness_prompt = f"""Проверь каждое утверждение резюме на соответствие документу. Отвечай JSON: {{"score": 0-1, "unsupported_claims": ["..."]}} Документ: {document} Резюме: {summary}""" faith_resp = client.chat.completions.create( model="gpt-4o", temperature=0, messages=[{"role": "user", "content": faithfulness_prompt}], response_format={"type": "json_object"}, ) import json faith_result = json.loads(faith_resp.choices[0].message.content) return { "summary": summary, "faithfulness_score": faith_result["score"], "unsupported_claims": faith_result.get("unsupported_claims", []), "is_reliable": faith_result["score"] > 0.9, } # faithfulness_score < 0.9 → тригерим fallback (человеческий ревью)
надёжность системы
LLM-системы могут отказывать по множеству причин: галлюцинации, низкая уверенность, timeout, unsafe content, out-of-scope запросы. Хорошая система имеет предсказуемое поведение при любом отказе.
// FALLBACK HIERARCHY · три уровня — каждый обеспечивает покрытие для отказов предыдущего
import time from openai import OpenAI from dataclasses import dataclass @dataclass class LLMResponse: text: str confidence: float # 0..1 latency_ms: int tier_used: str needs_review: bool def call_with_fallback( query: str, system_prompt: str, latency_limit_ms: int = 2000, confidence_threshold: float = 0.7, ) -> LLMResponse: client = OpenAI() # Tier 1: Primary (большая модель) try: t0 = time.time() resp = client.chat.completions.create( model="gpt-4o", temperature=0, messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": query}], timeout=latency_limit_ms / 1000, ) latency = int((time.time() - t0) * 1000) text = resp.choices[0].message.content # Простая confidence heuristic через logprobs confidence = compute_confidence(resp) if confidence >= confidence_threshold: return LLMResponse(text, confidence, latency, "tier1", False) except Exception as e: log_error("tier1_failed", error=str(e), query=query) # Tier 2: Fallback (меньшая модель, быстрее) try: t0 = time.time() resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0, messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": query}], timeout=1.0, ) latency = int((time.time() - t0) * 1000) confidence = compute_confidence(resp) text = resp.choices[0].message.content return LLMResponse(text, confidence, latency, "tier2", needs_review=confidence < 0.5) except Exception as e: log_error("tier2_failed", error=str(e)) # Tier 3: Детерминированный fallback (всегда работает) template = get_template_response(query) # шаблонные ответы по ключевым словам return LLMResponse(template, 0.3, 5, "tier3", needs_review=True)
финальный выбор
Когда промпт и RAG не решают задачу — нужно обучение. Выбор между CPT, TAPT, DAPT, SFT и alignment-методами зависит от типа проблемы, данных и ограничений.
| Термин | Расшифровка | Объём данных | Применение |
|---|---|---|---|
| CPT | Continued Pre-Training | 1B–100B токенов | Кардинальная смена домена. Медицинский LLM с нуля. |
| DAPT | Domain-Adaptive Pre-Training | 100M–10B токенов | Адаптация к домену. BERT → BioBERT через PubMed. |
| TAPT | Task-Adaptive Pre-Training | 1M–100M токенов | Адаптация к типу задачи. Финансовые отчёты → FT на NER. |
from transformers import ( AutoModelForCausalLM, AutoTokenizer, DataCollatorForLanguageModeling, TrainingArguments, Trainer ) from datasets import load_dataset # TAPT: продолжаем предобучение на доменном тексте (CLM) # Цель: научить модель «говорить» на языке домена # Пример: юридические договоры → NLP на договорах tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B") model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B") # Доменный корпус: юридические тексты (unlabeled) domain_corpus = load_dataset("text", data_files={"train": "legal_contracts.txt"}) def tokenize(examples): return tokenizer(examples["text"], truncation=True, max_length=2048, return_special_tokens_mask=True) tok_ds = domain_corpus.map(tokenize, batched=True, remove_columns=["text"]) # DataCollatorForLanguageModeling для CLM (не MLM) collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False) # TAPT: LR ниже чем в SFT, больше epochs args = TrainingArguments( output_dir="./qwen-legal-tapt", num_train_epochs=2, per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=1e-4, # выше чем SFT, ниже чем pretraining lr_scheduler_type="cosine", bf16=True, logging_steps=100, ) trainer = Trainer( model=model, args=args, train_dataset=tok_ds["train"], data_collator=collator, ) trainer.train() # Результат: domain-adapted base model # Следующий шаг: SFT на (instruction, output) парах для этого домена
from openai import OpenAI client = OpenAI() # Constitutional AI: модель сама критикует и исправляет ответы # по набору принципов (конституции) CONSTITUTION = [ "Ответ не должен содержать личные данные пользователей.", "Ответ должен признавать неопределённость если она есть.", "Ответ не должен содержать юридических советов без оговорки.", "Ответ должен быть конструктивным, не осуждающим.", ] def constitutional_generate(user_query: str, n_revisions: int = 2) -> str: """Генерируем ответ → критикуем по конституции → ревизируем.""" # Шаг 1: Первоначальный ответ resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0.7, messages=[{"role": "user", "content": user_query}] ) draft = resp.choices[0].message.content for _ in range(n_revisions): principles = "\n".join([f"- {p}" for p in CONSTITUTION]) # Шаг 2: Критика по принципам critique_prompt = f"""Оцени ответ по каждому принципу. Принципы: {principles} Ответ: {draft} Укажи нарушения (если есть).""" critique_resp = client.chat.completions.create( model="gpt-4o", temperature=0, messages=[{"role": "user", "content": critique_prompt}] ) critique = critique_resp.choices[0].message.content # Шаг 3: Ревизия с учётом критики revision_prompt = f"""Перепиши ответ, устранив нарушения. Нарушения: {critique} Исходный ответ: {draft} Исправленный ответ:""" rev_resp = client.chat.completions.create( model="gpt-4o-mini", temperature=0.3, messages=[{"role": "user", "content": revision_prompt}] ) draft = rev_resp.choices[0].message.content return draft # финальный ответ после n_revisions итераций