Как мы решили проблему "стохастической дивергенции" при генерации уроков и снизили затраты на валидацию в 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 не может это детектировать — дрифт происходит динамически во время генерации токенов.
При больших контекстах (RAG-контекст + спецификация + предыдущие секции) модели страдают от "Lost in the Middle" феномена — информация в середине контекста игнорируется. Это приводит к:
Игнорированию критических требований из спецификации
Несоответствию между секциями урока
Потере терминологической консистентности
Вывод: Stage 6 валидация обязательна. Вопрос — как её архитектурно реализовать с бюджетом 0.05 на урок.
Критическое открытие из исследований: 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%',
},
};
Для нашего бюджета (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 |
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 — оптимальный баланс между консистентностью и калибровкой скоров.
Предложение использовать 3x voting для каждого урока — brute-force решение. В 80% случаев урок либо явно качественный, либо явно плохой. Тратить 3x API-вызова на подтверждение очевидного — неэффективно.
Идея: Начинаем с 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
}
|
Подход |
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 (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
`,
},
},
];
Не все критерии равнозначны. 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,
};
}
// 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.",
};
Для проверки фактической точности Judge идеально нужен RAG-контекст (источники, на которых базируется урок). Но передача 3,000+ токенов RAG-контекста для каждого урока:
Увеличивает стоимость в 2-4x
Усугубляет "Lost in the Middle" проблему
Замедляет inference
Когда 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
// 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-вызовах.
Когда Judge возвращает score < 0.75, naive-решение — перегенерировать весь урок. Это:
Отбрасывает успешные части контента
Стоит как полная генерация (2000 output tokens)
Не гарантирует улучшение (новый random seed ≠ лучше)
Исследования показывают: 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();
}
Разные модели имеют разную "выносливость" к итеративному 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';
}
При 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;
}
Без ограничений система может застрять в цикле:
Generate → Score 0.65 → Refine → Score 0.68 → Refine → Score 0.66 → ...
Каждая итерация стоит денег, но improvement oscillates без прогресса.
// 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
);
}
}
// 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');
}
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 |
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
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
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
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 уроков и даём экспертам проверить их вручную.
Сравниваем оценки экспертов с тем, что выдал алгоритм.
Смотрим, где они расходятся и почему.
Исправляем критерии оценки и примеры, чтобы модель меньше ошибалась.
При необходимости корректируем пороги, при которых алгоритм «уверен» в своём решении.
Все изменения фиксируем, чтобы отслеживать прогресс.
Канал (редкие посты): https://t.me/chatgptdom_telegram_bot>
Прямой контакт: https://t.me/chatgptdom_telegram_bot>
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/