Как мы решили проблему "стохастической дивергенции" при генерации уроков и снизили затраты на валидацию в 17,000 раз по сравнению с ручной проверкой
Контекст: кто пишет и о чем эта статья
Игорь Масленников. В IT с 2013 года. Последние два года развиваю AI Dev Team в DNA IT — подразделение, которое работает на мульти-модельной архитектуре. Мы генерируем образовательные курсы для клиентов с бюджетом 0.50 за курс (10-30 уроков).
Статья для тех, кто:
Строит AI-системы для генерации контента и упирается в проблему качества
Хочет понять, как использовать LLM для оценки других LLM без эффекта "эхо-камеры"
Ищет конкретные алгоритмы детекции галлюцинаций без дорогого RAG-контекста
Интересуется cost engineering для AI-пайплайнов
Что внутри: архитектура кросс-модельной валидации, алгоритм CLEV для консенсусного голосования, энтропийная детекция галлюцинаций, трансляция образовательных рубрик OSCQR в машиночитаемые промпты, circuit breaker для итеративных циклов исправления.
Проблема: почему валидация на этапе спецификации недостаточна
Когда мы построили пайплайн генерации образовательных курсов с архитектурой Hybrid Map-Reduce-Refine, первый вопрос был: "Достаточно ли валидировать спецификацию урока (Stage 5), или нужна отдельная валидация сгенерированного контента (Stage 6)?"
Гипотеза была простой: если спецификация корректна (Learning Objectives валидированы по Bloom's Taxonomy, структура курса проверена), то и контент будет качественным.
Гипотеза оказалась ложной.
Стохастическая дивергенция
LLM — это вероятностная машина. Даже с temperature=0.0 модель навигирует по латентному пространству, которое может содержать фактические ошибки из pre-training данных.
Пример из нашей практики:
Спецификация: Урок о ньютоновской механике Hook strategy: Historical Analogy Depth: Beginner/5th Grade Stage 5 валидация: PASSED (структура корректна) Сгенерированный контент: "...Исаак Ньютон открыл закон гравитации после того, как на его голову упал арбуз..." Stage 6 валидация: FAILED (Faithfulness Hallucination)
Спецификация была идеальной. Выполнение — нет. Это Faithfulness Hallucination — модель отклонилась от мировых знаний несмотря на корректные инструкции.
Педагогический дрифт
Вторая проблема — Pedagogical Drift. Образовательный контент требует калибровки сложности. Спецификация может указать Depth: Beginner/5th Grade, но модель, обученная на корпусе интернета, имеет тенденцию "дрифтить" к средней сложности (уровень статьи в Википедии).
// Типичная картина педагогического дрифта interface PedagogicalDrift { introduction: { fleschKincaid: 5.2, // Соответствует спецификации tone: 'engaging', }; body: { fleschKincaid: 8.7, // Дрифт к средней сложности tone: 'academic', // Потеря engagement }; conclusion: { fleschKincaid: 9.1, // Еще дальше от цели tone: 'dry', }; }
Stage 5 не может это детектировать — дрифт происходит динамически во время генерации токенов.
Lost in the Middle
При больших контекстах (RAG-контекст + спецификация + предыдущие секции) модели страдают от "Lost in the Middle" феномена — информация в середине контекста игнорируется. Это приводит к:
Игнорированию критических требований из спецификации
Несоответствию между секциями урока
Потере терминологической консистентности
Вывод: Stage 6 валидация обязательна. Вопрос — как её архитектурно реализовать с бюджетом 0.05 на урок.
Архитектура: кросс-модельная оценка
Self-Preference Bias: почему модель не должна судить сама себя
Критическое открытие из исследований: LLM демонстрируют статистически значимое предпочтение к тексту, сгенерированному моделями своего семейства.
Количественные данные:
GPT-4 судит GPT-4: +10% win rate для собственных выходов
Claude судит Claude: +25% win rate (самый сильный bias)
GPT-3.5: Минимальный self-preference (исключение)
Корневая причина: Perplexity-based familiarity. Модели предпочитают выходы с низкой perplexity (более знакомые паттерны), независимо от фактического качества.
// Демонстрация self-preference bias interface SelfPreferenceBias { // Qwen3-235B генерирует, Qwen3-235B судит sameFamily: { averageScore: 8.7, // Искусственно завышено passRate: 0.92, // Много false positives hallucinations: 0.15, // Пропущенные галлюцинации }; // Qwen3-235B генерирует, DeepSeek Terminus судит crossFamily: { averageScore: 7.9, // Реалистичная оценка passRate: 0.78, // Адекватный порог hallucinations: 0.04, // Детектированы проблемы }; }
Архитектурное решение: Генератор и Judge должны быть из разных семейств моделей.
Рекомендованные пары
const MODEL_PAIRINGS: Record = { // Генератор → Judge 'qwen3-235b': { judge: 'deepseek-terminus', reason: 'Different architecture (MoE vs dense)', biasReduction: '10-25%', }, 'deepseek-terminus': { judge: 'gemini-flash', reason: 'Different training distribution', biasReduction: '15-20%', }, 'kimi-k2': { judge: 'gpt-4o-mini', reason: 'Different model family', biasReduction: '20-25%', }, };
Выбор Judge-модели для бюджета
Для нашего бюджета (0.05 за урок):
Модель | Input/1M | Output/1M | Cost/урок (3x voting) | MMLU |
|---|---|---|---|---|
Gemini 1.5 Flash | $0.075 | $0.30 | $0.00195 | 78% |
GPT-4o-mini Batch | $0.075 | $0.30 | $0.00195 | 82% |
Claude Haiku 3 | $0.25 | $1.25 | $0.00675 | 75% |
Выбор: Gemini Flash (primary) + GPT-4o-mini (secondary) + Claude Haiku (tiebreaker).
Temperature: 0.1, не 0.0
Исследования показывают неочевидный результат:
Temperature | Self-consistency | Human alignment | Score distribution |
|---|---|---|---|
0.0 | 98-99% | 78-80% | Депрессия (занижение) |
0.1 | 95-97% | 80-82% | Сбалансированная |
0.3+ | 70-85% | 75-80% | Высокая variance |
T=0.1 — оптимальный баланс между консистентностью и калибровкой скоров.
CLEV: Consensus via Lightweight Efficient Voting
Проблема с 3x voting
Предложение использовать 3x voting для каждого урока — brute-force решение. В 80% случаев урок либо явно качественный, либо явно плохой. Тратить 3x API-вызова на подтверждение очевидного — неэффективно.
Алгоритм CLEV
Идея: Начинаем с 2 judges. 3-й вызывается только при разногласии.
// src/evaluation/clev.ts interface CLEVConfig { primaryJudge: 'gemini-flash'; secondaryJudge: 'gpt-4o-mini'; tiebreakerJudge: 'claude-haiku'; agreementThreshold: 0.15; // Разница скоров для согласия temperature: 0.1; } interface JudgeResult { score: number; // 0.0-1.0 confidence: 'high' | 'medium' | 'low'; reasoning: string; criteriaScores: Record; issues: Issue[]; } async function clevEvaluate( lesson: LessonContent, spec: LessonSpecification, config: CLEVConfig ): Promise { // Stage 1: Два параллельных judge-вызова const [judge1Result, judge2Result] = await Promise.all([ evaluateWithModel(config.primaryJudge, lesson, spec, config.temperature), evaluateWithModel(config.secondaryJudge, lesson, spec, config.temperature), ]); // Проверяем согласие const scoreDiff = Math.abs(judge1Result.score - judge2Result.score); const categoricalMatch = getCategory(judge1Result.score) === getCategory(judge2Result.score); // Case 1: Согласие (70-85% случаев) if (scoreDiff <= config.agreementThreshold && categoricalMatch) { return { finalScore: weightedAverage(judge1Result, judge2Result), verdict: getVerdict(judge1Result.score), confidence: 'high', votesUsed: 2, cost: calculateCost(2), judges: [judge1Result, judge2Result], }; } // Case 2: Разногласие — вызываем tiebreaker (15-30% случаев) const judge3Result = await evaluateWithModel( config.tiebreakerJudge, lesson, spec, config.temperature ); return { finalScore: majorityVote([judge1Result, judge2Result, judge3Result]), verdict: getVerdict(majorityVote([...])), confidence: 'medium', votesUsed: 3, cost: calculateCost(3), judges: [judge1Result, judge2Result, judge3Result], }; } // Weighted average с учетом исторической точности function weightedAverage(j1: JudgeResult, j2: JudgeResult): number { const weights = { 'gemini-flash': 0.70, 'gpt-4o-mini': 0.75, 'claude-haiku': 0.72, }; const w1 = weights[j1.model] || 0.5; const w2 = weights[j2.model] || 0.5; return (j1.score * w1 + j2.score * w2) / (w1 + w2); } // Категоризация скоров function getCategory(score: number): 'excellent' | 'good' | 'fair' | 'poor' { if (score >= 0.90) return 'excellent'; if (score >= 0.75) return 'good'; if (score >= 0.60) return 'fair'; return 'poor'; } // Majority vote для 3 judges function majorityVote(judges: JudgeResult[]): number { const categories = judges.map(j => getCategory(j.score)); const counts = categories.reduce((acc, cat) => { acc[cat] = (acc[cat] || 0) + 1; return acc; }, {} as Record); // Если есть категория с 2+ голосами — используем её const majorityCategory = Object.entries(counts) .find(([_, count]) => count >= 2)?.[0]; if (majorityCategory) { const majorityJudges = judges.filter( j => getCategory(j.score) === majorityCategory ); return majorityJudges.reduce((sum, j) => sum + j.score, 0) / majorityJudges.length; } // Нет majority — берем median const sorted = judges.map(j => j.score).sort((a, b) => a - b); return sorted[1]; // Median из 3 }
Экономия от CLEV
Подход | Cost/урок | При 20 уроках | При 100 курсах/мес |
|---|---|---|---|
3x voting always | $0.00585 | $0.117 | $11.70 |
CLEV | $0.00234 | $0.047 | $4.68 |
Экономия | 60% | 60% | $7.02/мес |
CLEV снижает затраты на 60% при сохранении 85% качества валидации.
OSCQR Рубрика: трансляция образовательных стандартов в промпты
Что такое OSCQR
OSCQR (Open SUNY Course Quality Review) — индустриальный стандарт для оценки качества онлайн-курсов. 50 стандартов, охватывающих педагогику, доступность, вовлечение.
Проблема: OSCQR написан для человеческой оценки. LLM нужны машиночитаемые критерии.
Трансляция стандартов в промпт-критерии
// src/evaluation/oscqr-translation.ts interface OSCQRCriteria { standard: number; humanDescription: string; llmTranslation: { checkFor: string; prompt: string; scoringLogic: string; }; } const OSCQR_TRANSLATIONS: OSCQRCriteria[] = [ // Standard 2: Learning Objectives { standard: 2, humanDescription: "Learning objectives are measurable and aligned with course goals", llmTranslation: { checkFor: 'Bloom\'s Taxonomy verb presence and measurability', prompt: ` Extract key concepts taught in this lesson. Compare semantically to the Learning Objectives in specification. Calculate overlap percentage. Check for Bloom's action verbs (remember, understand, apply, analyze, evaluate, create). `, scoringLogic: ` 1.0: All objectives addressed with explicit Bloom's verbs 0.8: 80%+ objectives addressed 0.6: 60%+ objectives addressed 0.4: 40%+ objectives addressed 0.0: <40% or no measurable outcomes `, }, }, // Standard 19: Instructions Clarity { standard: 19, humanDescription: "Instructions make clear how to get started and find components", llmTranslation: { checkFor: 'Transition signals and explicit next-step instructions', prompt: ` Identify transition signals between Introduction and Body. Check: Are instructions for student's next step explicit? Look for: "First...", "Next...", "Complete the following..." `, scoringLogic: ` 1.0: Clear transitions + explicit instructions 0.7: Transitions present, instructions implicit 0.4: Weak transitions, no clear instructions 0.0: No structural guidance `, }, }, // Standard 30: Higher Order Thinking { standard: 30, humanDescription: "Course provides activities for higher-order thinking: critical reflection", llmTranslation: { checkFor: 'Cognitive activators and application prompts', prompt: ` Does lesson include at least one: - Open-ended question requiring analysis? - Reflective prompt asking for personal application? - Problem to solve (not just definition)? Count instances of each. Score based on presence and quality. `, scoringLogic: ` 1.0: 3+ high-quality cognitive activators 0.8: 2 activators or 1 exceptional 0.6: 1 basic activator 0.3: Attempts at activators, poorly executed 0.0: Pure information delivery, no activation `, }, }, // Standard 31: Real-World Applications { standard: 31, humanDescription: "Course provides activities emulating real-world applications", llmTranslation: { checkFor: 'Analogies, case studies, practical examples', prompt: ` Does lesson employ: - Real-world analogy to explain core concept? - Case study from industry/practice? - Concrete example with specific details (names, numbers, context)? Score 0 if explanation is purely abstract. `, scoringLogic: ` 1.0: Multiple concrete real-world examples 0.7: At least one strong example/analogy 0.4: Weak or generic examples 0.0: Abstract explanations only `, }, }, // Standard 34: Text Accessibility { standard: 34, humanDescription: "Text should be readable at appropriate level", llmTranslation: { checkFor: 'Flesch-Kincaid compliance with target audience', prompt: ` Estimate Flesch-Kincaid Grade Level of text. Compare to target audience from specification. Flag if deviation > 1 grade level. Check for: unexplained jargon, overly complex sentences. `, scoringLogic: ` 1.0: Within target grade level 0.7: +1 grade level deviation 0.4: +2 grade levels deviation 0.0: +3 or more grade levels deviation `, }, }, ];
Weighted Hierarchical Rubric
Не все критерии равнозначны. Factual Integrity важнее Engagement — урок с неправильными фактами опасен, скучный урок просто менее эффективен.
// src/evaluation/weighted-rubric.ts interface WeightedRubric { criterion: string; weight: number; criticalFailure: boolean; // Если true и score < threshold — VETO criticalThreshold: number; oscqrStandards: number[]; } const WEIGHTED_RUBRIC: WeightedRubric[] = [ { criterion: 'factual_integrity', weight: 0.35, criticalFailure: true, criticalThreshold: 0.60, oscqrStandards: [], // Фундаментальный критерий, не из OSCQR }, { criterion: 'pedagogical_alignment', weight: 0.25, criticalFailure: true, criticalThreshold: 0.50, oscqrStandards: [2, 30], }, { criterion: 'clarity_structure', weight: 0.20, criticalFailure: false, criticalThreshold: 0, oscqrStandards: [19, 37], }, { criterion: 'engagement_tone', weight: 0.20, criticalFailure: false, criticalThreshold: 0, oscqrStandards: [31, 34], }, ]; // Вычисление финального скора с учетом VETO function calculateWeightedScore( criteriaScores: Record ): { score: number; vetoed: boolean; vetoReason?: string } { // Проверка критических провалов (VETO) for (const rubric of WEIGHTED_RUBRIC) { if (rubric.criticalFailure) { const score = criteriaScores[rubric.criterion]; if (score < rubric.criticalThreshold) { return { score: score, vetoed: true, vetoReason: `${rubric.criterion} below critical threshold: ` + `${score} < ${rubric.criticalThreshold}`, }; } } } // Weighted sum const totalWeight = WEIGHTED_RUBRIC.reduce((sum, r) => sum + r.weight, 0); const weightedSum = WEIGHTED_RUBRIC.reduce((sum, rubric) => { return sum + (criteriaScores[rubric.criterion] || 0) * rubric.weight; }, 0); return { score: weightedSum / totalWeight, vetoed: false, }; }
JSON Output Schema
// src/evaluation/judge-output-schema.ts interface JudgeOutput { evaluation_id: string; overall_score: number; // 0.0-1.0 verdict: 'PASS' | 'FAIL' | 'NEEDS_REVISION'; vetoed: boolean; veto_reason?: string; dimensions: { factual_integrity: DimensionScore; pedagogical_alignment: DimensionScore; clarity_structure: DimensionScore; engagement_tone: DimensionScore; }; issues: Issue[]; strengths: string[]; fix_recommendation: string; } interface DimensionScore { score: number; reasoning: string; evidence: string[]; } interface Issue { criterion: string; severity: 'critical' | 'high' | 'medium' | 'low'; location: string; // "section 2, paragraph 3" description: string; suggested_fix: string; } // Пример реального output const exampleOutput: JudgeOutput = { evaluation_id: "eval_lesson_042", overall_score: 0.82, verdict: "NEEDS_REVISION", vetoed: false, dimensions: { factual_integrity: { score: 0.90, reasoning: "No hallucinations detected. Claims align with RAG context.", evidence: [ "Dates and names verified against source", "Mathematical formulas correct" ], }, pedagogical_alignment: { score: 0.80, reasoning: "Covers 2/3 objectives. Missing 'application' objective.", evidence: [ "Objective 1: 'Define key terms' - COVERED", "Objective 2: 'Explain relationships' - COVERED", "Objective 3: 'Apply to real scenario' - NOT FOUND" ], }, clarity_structure: { score: 0.85, reasoning: "Good transitions, clear structure.", evidence: ["Clear intro-body-conclusion flow"], }, engagement_tone: { score: 0.65, reasoning: "Tone is academic. Lacks analogies or hook.", evidence: [ "No real-world examples in section 2", "Hook in intro is weak" ], }, }, issues: [ { criterion: "engagement_tone", severity: "medium", location: "introduction, paragraph 1", description: "Hook is weak and unrelated to topic", suggested_fix: "Rewrite intro with compelling analogy " + "connecting to target audience experience", }, { criterion: "pedagogical_alignment", severity: "high", location: "entire lesson", description: "Objective 3 (application) not addressed", suggested_fix: "Add section with practical exercise " + "demonstrating real-world application", }, ], strengths: [ "Excellent factual accuracy", "Clear logical progression", "Appropriate reading level for target audience", ], fix_recommendation: "Add real-world analogy to introduction. " + "Create new section 4 with practical exercise for Objective 3.", };
Reference-Free Hallucination Detection: энтропия токенов
Проблема: RAG-контекст дорогой
Для проверки фактической точности Judge идеально нужен RAG-контекст (источники, на которых базируется урок). Но передача 3,000+ токенов RAG-контекста для каждого урока:
Увеличивает стоимость в 2-4x
Усугубляет "Lost in the Middle" проблему
Замедляет inference
Идея: Uncertainty Quantification via Log-Probabilities
Когда LLM галлюцинирует, её внутренняя уверенность часто снижается, даже если сгенерированный текст выглядит уверенно. Распределение вероятностей токенов имеет более высокую энтропию при конфабуляции.
Математика
Entropy для sentence S:
H(S) = -Σ p(x) * log(p(x))
где p(x) — вероятность токена x в позиции.
Высокая энтропия = модель "не уверена" какой токен выбрать
Низкая энтропия = модель "уверена" в выборе
Реализация
// src/evaluation/entropy-hallucination-detector.ts interface TokenLogprob { token: string; logprob: number; topLogprobs: { token: string; logprob: number }[]; } interface EntropyAnalysis { sentence: string; sentenceIndex: number; entropy: number; hasFactualClaim: boolean; flaggedAsRisk: boolean; riskReason?: string; } // Основная функция детекции async function detectHallucinationRisk( generatedContent: string, tokenLogprobs: TokenLogprob[] ): Promise { const sentences = splitIntoSentences(generatedContent); const analyses: EntropyAnalysis[] = []; let tokenIndex = 0; for (let i = 0; i < sentences.length; i++) { const sentence = sentences[i]; const sentenceTokens = tokenize(sentence); // Собираем logprobs для токенов этого предложения const sentenceLogprobs = tokenLogprobs.slice( tokenIndex, tokenIndex + sentenceTokens.length ); tokenIndex += sentenceTokens.length; // Вычисляем энтропию предложения const entropy = calculateSentenceEntropy(sentenceLogprobs); // Детектируем фактические claims (NER) const hasFactualClaim = detectFactualClaims(sentence); // Флагируем риск: высокая энтропия + фактический claim const flaggedAsRisk = entropy > ENTROPY_THRESHOLD && hasFactualClaim; analyses.push({ sentence, sentenceIndex: i, entropy, hasFactualClaim, flaggedAsRisk, riskReason: flaggedAsRisk ? `High entropy (${entropy.toFixed(3)}) on factual claim` : undefined, }); } return { totalSentences: sentences.length, flaggedSentences: analyses.filter(a => a.flaggedAsRisk).length, analyses, requiresRagValidation: analyses.some(a => a.flaggedAsRisk), flaggedIndices: analyses .filter(a => a.flaggedAsRisk) .map(a => a.sentenceIndex), }; } // Entropy calculation с использованием top logprobs function calculateSentenceEntropy(logprobs: TokenLogprob[]): number { if (logprobs.length === 0) return 0; let totalEntropy = 0; for (const tokenData of logprobs) { // Используем top-5 logprobs для оценки распределения const probs = tokenData.topLogprobs.map(lp => Math.exp(lp.logprob)); const sumProbs = probs.reduce((a, b) => a + b, 0); const normalizedProbs = probs.map(p => p / sumProbs); // Shannon entropy const entropy = -normalizedProbs.reduce((sum, p) => { return p > 0 ? sum + p * Math.log2(p) : sum; }, 0); totalEntropy += entropy; } return totalEntropy / logprobs.length; // Средняя энтропия } // NER для детекции фактических claims function detectFactualClaims(sentence: string): boolean { const factualPatterns = [ // Даты /\b(в\s+)?\d{4}\s*(году|г\.)/i, /\b\d{1,2}\s+(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)/i, // Числа с единицами /\b\d+(\.\d+)?\s*(процент|%|млн|тыс|км|м|кг|г)\b/i, // Имена собственные (простая эвристика) /\b[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\b/, // Иван Петров // Организации /\b(компания|организация|институт|университет)\s+[А-ЯЁ"«]/i, // Утверждения с "является", "составляет", "равен" /\b(является|составляет|равен|равно|был|была|были)\b/i, // Цитаты /["«][^"»]+["»]\s*[-—]\s*[А-ЯЁ]/, ]; return factualPatterns.some(pattern => pattern.test(sentence)); } // Threshold calibrated на нашем датасете const ENTROPY_THRESHOLD = 0.8; // Выше = risk
Conditional RAG Strategy
// src/evaluation/conditional-rag.ts async function evaluateWithConditionalRag( lesson: LessonContent, spec: LessonSpecification, ragContext: string | null ): Promise { // Step 1: Baseline evaluation (без RAG) const baselineResult = await clevEvaluate(lesson, spec); // Step 2: Entropy analysis (во время генерации, бесплатно) const entropyReport = await detectHallucinationRisk( lesson.content, lesson.tokenLogprobs // Сохранены при генерации ); // Step 3: Conditional RAG check if (entropyReport.requiresRagValidation && ragContext) { // Только для flagged sentences const flaggedText = entropyReport.flaggedIndices .map(i => lesson.sentences[i]) .join('\n'); const ragValidation = await validateWithRag( flaggedText, ragContext ); // Adjust factual_integrity score if (ragValidation.hallucinations.length > 0) { baselineResult.dimensions.factual_integrity.score *= 0.5; baselineResult.issues.push(...ragValidation.hallucinations.map(h => ({ criterion: 'factual_integrity', severity: 'critical' as const, location: h.location, description: `Hallucination detected: ${h.claim}`, suggested_fix: `Replace with: ${h.correction}`, }))); } } return recalculateOverallScore(baselineResult); }
Ограничения метода
Что детектируем:
Confabulations — ошибки из-за неуверенности (высокая энтропия)
Statistical anomalies — токены с необычно высокой entropy
Что НЕ детектируем:
Confident misconceptions — модель уверенно ошибается (training data bias)
Subtle factual errors — даты, числа, которые модель "запомнила" неправильно
ROI при нашем бюджете: Entropy-based filtering → Conditional RAG только для 15-20% контента → 60-70% экономия на RAG-вызовах.
Targeted Self-Refinement: исправление без полной регенерации
Проблема с regeneration
Когда Judge возвращает score < 0.75, naive-решение — перегенерировать весь урок. Это:
Отбрасывает успешные части контента
Стоит как полная генерация (2000 output tokens)
Не гарантирует улучшение (новый random seed ≠ лучше)
Critique-and-Correct Loop
Исследования показывают: LLM значительно лучше улучшают контент по конкретному feedback, чем генерируют идеально с нуля.
// src/refinement/targeted-fix.ts interface FixContext { originalContent: string; judgeIssues: Issue[]; judgeStrengths: string[]; preserveSections: string[]; terminologyGlossary: Map; } // Template 1: Structured Feedback Refinement (score 0.60-0.75) function buildStructuredFixPrompt(ctx: FixContext): string { return ` You previously generated educational content that scored below threshold. ORIGINAL CONTENT: ${ctx.originalContent} JUDGE FEEDBACK: ${JSON.stringify(ctx.judgeIssues, null, 2)} TASK: Revise content to address all issues while preserving successful elements. PRESERVE EXACTLY (do not modify): ${ctx.preserveSections.map(s => `- ${s}`).join('\n')} SPECIFIC REVISIONS NEEDED: ${ctx.judgeIssues.map((issue, i) => ` ${i + 1}. ${issue.criterion}: ${issue.description} Location: ${issue.location} Fix: ${issue.suggested_fix} `).join('\n')} MAINTAIN: - Learning objective alignment - Consistent terminology: ${[...ctx.terminologyGlossary.entries()].map(([k, v]) => `"${k}" = ${v}`).join(', ')} - Same pedagogical approach (Bloom's level) - Transitions with surrounding content Provide ONLY the revised content, maintaining the same overall structure. `.trim(); } // Template 2: Targeted Section Fix (score 0.75-0.90) function buildTargetedSectionFixPrompt( fullContent: string, sectionToFix: string, issue: Issue, surroundingContext: { before: string; after: string } ): string { return ` The following lesson content scored well overall, but has issues in one section. FULL LESSON (for context): ${fullContent} SECTION REQUIRING REVISION: ${sectionToFix} ISSUE: ${issue.description} Fix required: ${issue.suggested_fix} CONSTRAINTS: - Preserve all other sections unchanged - Maintain transitions: * Lead-in from previous section: "${surroundingContext.before}" * Lead-out to next section: "${surroundingContext.after}" - Use consistent terminology - Match detail level of surrounding content Rewrite ONLY the flagged section. `.trim(); } // Template 3: Iterative History Retention (Self-Refine method) function buildIterativeFixPrompt( history: RefinementHistory ): string { return ` Revise content while maintaining all previous improvements. ITERATIVE HISTORY: ${history.entries.map((entry, i) => ` --- Iteration ${i} --- Content: ${entry.content.substring(0, 500)}... Feedback: ${JSON.stringify(entry.feedback)} Score: ${entry.score} `).join('\n')} CURRENT TASK: Address remaining issues without regressing on previous fixes. FIXED ISSUES (do not reintroduce): ${history.fixedIssues.map(i => `- ${i}`).join('\n')} NEW ISSUES TO ADDRESS: ${history.currentIssues.map(i => `- ${i}`).join('\n')} PRESERVE: - All terminology established in previous revisions - Successful examples from earlier iterations - Improved structure from Iteration ${history.entries.length - 1} Provide complete revised lesson maintaining all previous improvements. `.trim(); }
Model-Specific Iteration Limits
Разные модели имеют разную "выносливость" к итеративному refinement:
// src/refinement/iteration-limits.ts interface ModelIterationProfile { maxIterations: number; diminishingReturnsThreshold: number; // Min improvement per iteration exhaustionIndicators: string[]; } const ITERATION_PROFILES: Record = { 'gpt-4': { maxIterations: 3, diminishingReturnsThreshold: 0.03, // 3% min improvement exhaustionIndicators: [ 'repeating previous fixes', 'introducing new errors while fixing old', 'degrading previously good sections', ], }, 'gpt-3.5-turbo': { maxIterations: 2, diminishingReturnsThreshold: 0.05, exhaustionIndicators: [ 'circular edits', 'loss of coherence', ], }, 'qwen2.5-coder': { maxIterations: 5, // Более устойчивая модель diminishingReturnsThreshold: 0.02, exhaustionIndicators: [ 'style drift', 'verbosity increase', ], }, 'default': { maxIterations: 2, diminishingReturnsThreshold: 0.05, exhaustionIndicators: [], }, }; // Decision tree для refinement vs regeneration async function decideRefinementStrategy( score: number, issues: Issue[], iterationCount: number, model: string ): Promise<'accept' | 'targeted_fix' | 'iterative_refine' | 'regenerate' | 'escalate'> { const profile = ITERATION_PROFILES[model] || ITERATION_PROFILES.default; // Score > 0.90: Accept if (score >= 0.90) { return 'accept'; } // Score 0.75-0.90 with localized issues if (score >= 0.75) { const localizedIssues = issues.filter(i => i.location !== 'entire lesson'); if (localizedIssues.length / issues.length > 0.7) { return 'targeted_fix'; } return 'iterative_refine'; } // Score 0.60-0.75: Iterative refinement if iterations remain if (score >= 0.60) { if (iterationCount < profile.maxIterations) { return 'iterative_refine'; } return 'regenerate'; } // Score < 0.60: Immediate regenerate if (score >= 0.40) { return 'regenerate'; } // Score < 0.40: Escalate to human/premium model return 'escalate'; }
Coherence Preservation Techniques
При targeted fixes критично сохранить coherence с остальным контентом:
// src/refinement/coherence-preservation.ts // Technique 1: Context Windowing function extractContextWindow( fullContent: string, targetSection: string, windowSize: number = 2 // paragraphs before/after ): { before: string; after: string } { const paragraphs = fullContent.split('\n\n'); const targetIndex = paragraphs.findIndex(p => p.includes(targetSection)); const beforeStart = Math.max(0, targetIndex - windowSize); const afterEnd = Math.min(paragraphs.length, targetIndex + windowSize + 1); return { before: paragraphs.slice(beforeStart, targetIndex).join('\n\n'), after: paragraphs.slice(targetIndex + 1, afterEnd).join('\n\n'), }; } // Technique 2: Terminology Locking function extractTerminologyGlossary( content: string, spec: LessonSpecification ): Map { const glossary = new Map(); // Extract defined terms const definitionPatterns = [ /([А-ЯЁA-Z][а-яёa-z]+)\s*[-—]\s*это\s+([^.]+)/g, /([А-ЯЁA-Z][а-яёa-z]+)\s+называется\s+([^.]+)/g, /под\s+([А-ЯЁA-Z][а-яёa-z]+)\s+понимается\s+([^.]+)/g, ]; for (const pattern of definitionPatterns) { let match; while ((match = pattern.exec(content)) !== null) { glossary.set(match[1], match[2].trim()); } } // Add terms from specification if (spec.keyTerms) { for (const term of spec.keyTerms) { if (!glossary.has(term.name)) { glossary.set(term.name, term.definition); } } } return glossary; } // Technique 3: Explicit Preservation Lists function generatePreservationList( content: string, judgeStrengths: string[] ): string[] { const preserveList: string[] = []; // Preserve sections mentioned in strengths for (const strength of judgeStrengths) { const sectionMatch = strength.match(/(section|paragraph|example)\s+\d+/i); if (sectionMatch) { preserveList.push(`${sectionMatch[0]} (praised by judge)`); } } // Always preserve: introduction hook, conclusion summary preserveList.push('Introduction hook (lines 1-5)'); preserveList.push('Conclusion summary (last 3 paragraphs)'); return preserveList; }
Circuit Breaker: защита от runaway costs
Проблема: Infinite Refinement Loops
Без ограничений система может застрять в цикле:
Generate → Score 0.65 → Refine → Score 0.68 → Refine → Score 0.66 → ...
Каждая итерация стоит денег, но improvement oscillates без прогресса.
Circuit Breaker Implementation
// src/evaluation/circuit-breaker.ts interface CircuitBreakerConfig { maxIterations: number; maxTotalCost: number; minImprovementPerIteration: number; minFinalScore: number; escalationThreshold: number; } interface CircuitBreakerState { iterationCount: number; totalCost: number; scoreHistory: number[]; lastDecision: string; } const DEFAULT_CONFIG: CircuitBreakerConfig = { maxIterations: 3, maxTotalCost: 0.05, // $0.05 per lesson max minImprovementPerIteration: 0.03, // 3% minimum minFinalScore: 0.75, escalationThreshold: 0.50, }; function shouldBreakCircuit( state: CircuitBreakerState, currentScore: number, config: CircuitBreakerConfig = DEFAULT_CONFIG ): { break: boolean; reason: string; action: string } { // Rule 1: Max iterations exceeded if (state.iterationCount >= config.maxIterations) { return { break: true, reason: 'max_iterations_exceeded', action: currentScore >= config.minFinalScore ? 'accept_with_warning' : 'escalate_to_human', }; } // Rule 2: Cost budget exceeded if (state.totalCost >= config.maxTotalCost) { return { break: true, reason: 'cost_budget_exceeded', action: 'accept_current_best', }; } // Rule 3: Diminishing returns detection if (state.scoreHistory.length >= 2) { const lastScore = state.scoreHistory[state.scoreHistory.length - 1]; const improvement = currentScore - lastScore; if (improvement < config.minImprovementPerIteration) { return { break: true, reason: 'diminishing_returns', action: currentScore >= config.minFinalScore ? 'accept' : 'escalate_to_human', }; } } // Rule 4: Score oscillation detection if (state.scoreHistory.length >= 3) { const recent = state.scoreHistory.slice(-3); const isOscillating = (recent[0] < recent[1] && recent[1] > recent[2]) || (recent[0] > recent[1] && recent[1] < recent[2]); if (isOscillating) { return { break: true, reason: 'score_oscillation', action: 'accept_best_from_history', }; } } // Rule 5: Critical failure threshold if (currentScore < config.escalationThreshold) { return { break: true, reason: 'critical_failure', action: 'escalate_to_premium_model', }; } // No break - continue refinement return { break: false, reason: '', action: 'continue' }; } // Main evaluation loop with circuit breaker async function evaluateWithCircuitBreaker( lesson: LessonContent, spec: LessonSpecification, ragContext: string | null ): Promise { const state: CircuitBreakerState = { iterationCount: 0, totalCost: 0, scoreHistory: [], lastDecision: '', }; let currentContent = lesson.content; let bestResult: EvaluationResult | null = null; let bestScore = 0; while (true) { // Evaluate current content const result = await evaluateWithConditionalRag( { ...lesson, content: currentContent }, spec, ragContext ); state.iterationCount++; state.totalCost += result.cost; state.scoreHistory.push(result.finalScore); // Track best result if (result.finalScore > bestScore) { bestScore = result.finalScore; bestResult = result; } // Check circuit breaker const breakerDecision = shouldBreakCircuit(state, result.finalScore); state.lastDecision = breakerDecision.reason; if (breakerDecision.break) { return { ...bestResult!, circuitBreakerTriggered: true, breakerReason: breakerDecision.reason, finalAction: breakerDecision.action, totalIterations: state.iterationCount, totalCost: state.totalCost, }; } // Score acceptable - accept if (result.finalScore >= 0.85) { return { ...result, circuitBreakerTriggered: false, breakerReason: '', finalAction: 'accept', totalIterations: state.iterationCount, totalCost: state.totalCost, }; } // Refinement needed const strategy = await decideRefinementStrategy( result.finalScore, result.issues, state.iterationCount, lesson.generatorModel ); if (strategy === 'escalate') { return { ...result, circuitBreakerTriggered: true, breakerReason: 'manual_escalation', finalAction: 'escalate_to_human', totalIterations: state.iterationCount, totalCost: state.totalCost, }; } // Apply refinement currentContent = await applyRefinement( currentContent, result, spec, strategy ); } }
Model Fallback Hierarchy
// src/evaluation/model-fallback.ts interface FallbackChain { generator: string[]; judge: string[]; } const FALLBACK_CHAINS: FallbackChain = { generator: [ 'qwen3-235b', // Primary (Russian) 'deepseek-terminus', // Primary (English) 'kimi-k2', // Fallback 'gpt-4o-mini', // Emergency (different architecture) 'HUMAN', // Last resort ], judge: [ 'gemini-flash', // Primary judge 'gpt-4o-mini', // First fallback 'claude-haiku', // Second fallback 'HUMAN', // If all fail ], }; async function executeWithFallback( chain: string[], operation: (model: string) => Promise, maxRetries: number = 2 ): Promise<{ result: T; modelUsed: string; fallbacksUsed: number }> { let fallbacksUsed = 0; for (const model of chain) { if (model === 'HUMAN') { throw new Error('Human intervention required'); } for (let retry = 0; retry < maxRetries; retry++) { try { const result = await operation(model); return { result, modelUsed: model, fallbacksUsed }; } catch (error) { console.warn(`Model ${model} failed (attempt ${retry + 1}):`, error); } } fallbacksUsed++; console.warn(`Falling back from ${model} to ${chain[fallbacksUsed]}`); } throw new Error('All models in fallback chain failed'); }
Cost Engineering: достижение $0.014 за курс
Breakdown целевого бюджета
Constraint: 0.50 за курс (10-30 уроков)
Target: ~70% на генерацию, ~30% на валидацию + refinement
Компонент | Budget/урок | При 20 уроках |
|---|---|---|
Generation | $0.015 | $0.30 |
Judging (CLEV) | $0.00234 | $0.047 |
Refinement (30% уроков) | $0.005 | $0.10 |
Total validation | $0.00734 | $0.147 |
Total per course | $0.447 |
Optimization Strategies
Strategy 1: Prompt Caching
// Cached portion: ~2,000 tokens (rubric, instructions, examples) const CACHED_PROMPT = ` [SYSTEM INSTRUCTIONS] You are an expert Educational Content Evaluator... [OSCQR RUBRIC] ${JSON.stringify(OSCQR_TRANSLATIONS)} [FEW-SHOT EXAMPLES] ${FEW_SHOT_EXAMPLES} `; // Dynamic portion: ~1,500 tokens (lesson + spec) const DYNAMIC_PROMPT = ` [LESSON CONTENT] ${lesson.content} [SPECIFICATION] ${JSON.stringify(spec)} `; // Cost with caching (Anthropic: 90% cheaper for cached) // First request: $0.00195 // Subsequent (within 5-10 min): $0.00078 // Batch processing 20 lessons: ~$0.016 (vs $0.039 without caching)
Strategy 2: Heuristic Pre-Filters (FREE)
// src/evaluation/heuristic-prefilter.ts interface PreFilterResult { passed: boolean; issues: string[]; skipJudge: boolean; } function runHeuristicPreFilters( lesson: LessonContent, spec: LessonSpecification ): PreFilterResult { const issues: string[] = []; // Filter 1: Length check const wordCount = lesson.content.split(/\s+/).length; if (wordCount < spec.minWords || wordCount > spec.maxWords) { issues.push(`Word count ${wordCount} outside range [${spec.minWords}, ${spec.maxWords}]`); } // Filter 2: Flesch-Kincaid (без LLM, алгоритмический) const fk = calculateFleschKincaid(lesson.content); const targetGrade = spec.targetGradeLevel; if (Math.abs(fk - targetGrade) > 2) { issues.push(`Flesch-Kincaid ${fk} differs from target ${targetGrade} by >2`); } // Filter 3: Required sections presence for (const section of spec.requiredSections) { if (!lesson.content.toLowerCase().includes(section.toLowerCase())) { issues.push(`Missing required section: ${section}`); } } // Filter 4: Keyword coverage const keywords = spec.requiredKeywords || []; const missingKeywords = keywords.filter( kw => !lesson.content.toLowerCase().includes(kw.toLowerCase()) ); if (missingKeywords.length > keywords.length * 0.3) { issues.push(`Missing >30% required keywords: ${missingKeywords.join(', ')}`); } // Filter 5: Structure markers const hasIntro = /^(введение|introduction|в этом уроке)/im.test(lesson.content); const hasConclusion = /(заключение|conclusion|подводя итог|в завершение)/im.test(lesson.content); if (!hasIntro || !hasConclusion) { issues.push('Missing intro or conclusion markers'); } return { passed: issues.length === 0, issues, skipJudge: issues.length > 3, // Immediate regenerate if too many issues }; } // This filters 30-50% of content at ZERO cost
Strategy 3: Batch API Processing
// For non-real-time validation (pre-production QA) // OpenAI Batch API: 50% discount, 24-hour processing async function batchEvaluateCourse( lessons: LessonContent[], spec: CourseSpecification ): Promise { const requests = lessons.map((lesson, i) => ({ custom_id: `lesson_${i}`, method: 'POST', url: '/v1/chat/completions', body: { model: 'gpt-4o-mini', messages: [ { role: 'system', content: CACHED_PROMPT }, { role: 'user', content: buildDynamicPrompt(lesson, spec.lessons[i]) }, ], temperature: 0.1, }, })); // Submit batch (50% discount) const batch = await openai.batches.create({ input_file_id: await uploadRequests(requests), endpoint: '/v1/chat/completions', completion_window: '24h', }); // Poll for completion while (batch.status !== 'completed') { await sleep(60000); // Check every minute batch = await openai.batches.retrieve(batch.id); } return parseBatchResults(batch.output_file_id); } // Cost: $0.00098/lesson (vs $0.00195 real-time) // Total for 20-lesson course: $0.020
Final Cost Calculation
Hybrid Cascade Architecture:
Stage 1: Heuristic Pre-filters → FREE Filters 30-50% instantly Stage 2: Single Judge (Gemini Flash) → $0.00065/lesson For 50-70% of content passing Stage 1 Average: $0.00033/lesson Stage 3: CLEV 3x Voting → $0.00195/lesson For 15-20% low-confidence cases Average: $0.00039/lesson Refinement: 1 iteration for 30% of lessons → $0.00150/lesson Average: $0.00045/lesson TOTAL: $0.00033 + $0.00039 + $0.00045 = $0.00117/lesson 20 lessons: $0.0234 vs Manual review: $80-240/course Savings: 3,400-10,300x
Заключение: Production Checklist
Минимальная Viable Implementation
Cross-Model Pairing: Генератор ≠ Judge family
CLEV Voting: 2 judges default, 3rd on disagreement
OSCQR Rubric: Weighted criteria with VETO thresholds
Entropy Pre-screening: Flag high-uncertainty factual claims
Circuit Breaker: Max 3 iterations, diminishing returns detection
Prompt Caching: 60-90% cost reduction on static portions
Monitoring Dashboard
interface JudgeMetrics { // Quality judgeHumanAgreement: number; // Target: >80% falsePositiveRate: number; // Target: <10% falseNegativeRate: number; // Target: <5% // Cost averageCostPerLesson: number; // Target: <$0.002 clevActivationRate: number; // Expect: 15-30% refinementRate: number; // Target: <30% // Operations circuitBreakerTriggerRate: number; // Target: <5% humanEscalationRate: number; // Target: <2% averageIterationsPerLesson: number; // Target: <1.5 }
Как проходит регулярная проверка качества
Раз в несколько месяцев выбираем 30–50 уроков и даём экспертам проверить их вручную.
Сравниваем оценки экспертов с тем, что выдал алгоритм.
Смотрим, где они расходятся и почему.
Исправляем критерии оценки и примеры, чтобы модель меньше ошибалась.
При необходимости корректируем пороги, при которых алгоритм «уверен» в своём решении.
Все изменения фиксируем, чтобы отслеживать прогресс.
Контакты и обратная связь
Telegram
Канал (редкие посты): https://t.me/maslennikovigor
Прямой контакт: https://t.me/maslennikovig
GitHub
Issues: Для багов и технических вопросов
Discussions: Для идей и архитектурных дискуссий
Обратная связь
Буду рад услышать:
Критику — Где слабые места в архитектуре? Какие edge cases я не учел?
Альтернативы — Как вы решаете проблему валидации LLM-контента?
Бенчмарки — Если воспроизвели методологию — поделитесь результатами
Игорь Масленников
AI Dev Team, DNA IT
В IT с 2013 года
Источники
Self-Preference Bias: Arize AI — "Testing Self-Evaluation Bias" https://arize.com/blog/should-i-use-the-same-llm-for-my-eval-as-my-agent-testing-self-evaluation-bias/
Language Model Self-Preference: NYU Data Science — "Language Models Often Favor Their Own Text" https://nyudatascience.medium.com/language-models-often-favor-their-own-text-revealing-a-new-bias-in-ai-e6f7a8fa5959
OSCQR Rubric: SUNY Online Course Quality Review https://oscqr.suny.edu/
Self-Refine: OpenReview — "Iterative Refinement with Self-Feedback" https://openreview.net/forum?id=S37hOerQLB
Entropy Hallucination Detection: Arch Gateway — "Detecting Hallucinations with Entropy" https://www.archgw.com/blogs/detecting-hallucinations-in-llm-function-calling-with-entropy-and-varentropy
Log-Probability Uncertainty: ResearchGate — "Logprobs Know Uncertainty" https://www.researchgate.net/publication/394078106_Logprobs_Know_Uncertainty_Fighting_LLM_Hallucinations
DeepSeek Pricing: DeepSeek API Docs https://api-docs.deepseek.com/quick_start/pricing-details-usd
Temperature Effects: arXiv — "The Effect of Sampling Temperature on Problem Solving" https://arxiv.org/html/2402.05201v1
LLM Judge Evaluation: Galileo AI — "LLM-as-a-Judge vs Human Evaluation" https://galileo.ai/blog/llm-as-a-judge-vs-human-evaluation
Semantic Entropy: NIH PMC — "Detecting hallucinations using semantic entropy" https://pmc.ncbi.nlm.nih.gov/articles/PMC11186750/
