Перейти к содержимому

8.4 · Ручной разбор ошибок

Неделя 8 · Evals · ⏱ ~4 ч · Практика

После экспериментов 8.2 и 8.3 у выбранной конфигурации есть цифра, например «correctness 0,78» на dev из 56 вопросов. Метрика говорит, сколько ответов плохих, но не говорит, что чинить. Ручной разбор ошибок (error analysis) отвечает на вопрос «почему» и превращает 12 проваленных вопросов в список причин с частотами, а список — в приоритет работы. Hamel Husain называет этот шаг самым недооценённым и самым ценным в работе над AI-продуктами. На собеседовании история «прочитали 30 провалов, 40% оказались одной причиной, исправили её и получили +N п.п.» показывает инженера, а не оператора фреймворка.

Почему смотреть глазами, а не придумывать метрики

Заголовок раздела «Почему смотреть глазами, а не придумывать метрики»

Соблазн: придумать ещё одну автоматическую метрику. Но неизвестно, что мерить, пока не видно, как система ошибается. В 6.1 был цикл «смотреть на данные → найти типы ошибок → написать eval → изменить систему». Сейчас делаем первый и второй шаги всерьёз.

Аналогия из жизни магазина: разбор жалоб покупателей. Общая цифра «8% обращений закончились недовольством» говорит, что плохо, но чинить нечего, пока не открыть каждую жалобу и не увидеть: здесь оператор назвал старый срок возврата, здесь перепутал гарантию двух ламп, здесь посчитал доставку не по той строке тарифа. Распределение причин подсказывает, что править первым.

Чтобы найти, где цепочка сломалась, мало видеть вопрос и неверный ответ: нужен каждый шаг пайплайна. Для каждого провала читаем целиком:

  1. вопрос, его тип и теги ловушек (tags);
  2. эталон, must_include и цитаты evidence;
  3. найденные куски (топ-k) с позициями, url, флагом archived, и где среди них релевантные;
  4. ответ модели, флаг отказа и передачу оператору;
  5. вердикты и критики судей.

Всё это уже есть в results.jsonl из 6.4 или в трейсе Langfuse (6.5). Стоит сделать удобный просмотрщик: читать JSON в терминале 30 раз утомительно, и детали начнут ускользать.

Трейсы собраны; теперь нужен способ превратить 30 прочитанных историй в таблицу причин и при этом не придумать причины заранее. Hamel заимствует из качественных исследований два шага:

Открытое кодирование (open coding). Читаем трейс и пишем свободную заметку о первой ошибке в цепочке: «вопрос про „Свет мини“, в топ-3 только lamp-light.md», «кусок с тарифом на 2-м месте, но модель взяла цену из соседней строки таблицы», «эталон устарел, в kb/delivery.md теперь другой тариф». На этом шаге ничего не подгоняем под готовые категории.

Почему первая ошибка: ошибки каскадируют. Если поиск не нашёл кусок, а модель потом выдумала ответ, корень — поиск; галлюцинация — следствие.

Осевое кодирование (axial coding). Когда заметок 20–30, группируем их в категории. Стартовый список:

Категория Признак в трейсе Где чинить
R: не нашёл кусок Релевантного куска нет в топ-k Поиск: чанкинг, гибрид, переформулировка, k, реранкер, фильтры
G: нашёл, но не использовал Релевантный кусок в топ-k, ответ неверен или ложный отказ Промпт ответа, порядок/формат контекста, модель
H: галлюцинация Ответ содержит факт, которого нет в кусках (faithfulness FAIL) Промпт («только по кускам»), модель, отказ
D: плохой вопрос в наборе Неоднозначный вопрос, неверный/устаревший эталон, неверная цитата Набор (6.3)

Почти наверняка появятся подкатегории и новые категории. Типичные для ассистента поддержки (почти все совпадают с ловушками из tags):

  • R-wrong-product: найден кусок похожего товара — «Свет» вместо «Свет мини», MUG-SOUTH вместо MUG-NORTH;
  • R-partial: вопросу нужны две страницы, найдена одна — например, порог бесплатной доставки только из delivery.md без promo.md;
  • R-archived: в контекст попал returns-2025.md, и ответ цитирует старые правила возврата (archived_in_context > 0);
  • G-refusal: кусок найден, но модель ответила «В базе знаний нет ответа на этот вопрос.» (ложный отказ);
  • G-table: нужная таблица в топ-k, но модель взяла не ту ячейку тарифов (зону, вес);
  • G-conflict: обе страницы с порогом найдены, а модель назвала одну цифру вместо двух;
  • G-no-handoff: вопрос без ответа в базе («где мой заказ 1042?»), а модель ответила общими словами про статусы вместо отказа и передачи оператору;
  • H-promise: обещание действия, которого ассистент не делает: «оформим возврат», «вернём деньги», «вот промокод».

Отдельно от подкатегорий появляется пятая категория верхнего уровня — J: ошибка судьи: ответ на самом деле верный (или неверный), а судья ошибся. Она важна: часть «провалов» — провалы судьи. Если их много, сначала чинится судья, иначе все дальнейшие цифры ложные.

Чтобы один и тот же трейс не попадал то в R, то в H в зависимости от настроения, категории проставляем по фиксированному порядку вопросов:

провал
│
├─ вопрос/эталон корректен? ── нет ──► D
│
├─ ответ на самом деле верен? ── да ──► J (судья ошибся)
│
├─ релевантный кусок в топ-k? ── нет ──► R (R-wrong-product / R-partial / R-archived / ...)
│
├─ ответ содержит факты не из кусков? ── да ──► H (в т.ч. H-promise)
│
└─ иначе ──────────────────────────────────► G (refusal / table / conflict / no-handoff / ...)

Порядок важен: сначала проверяем набор и судью, потом поиск, потом генерацию. Для unanswerable шаг «релевантный кусок» пропускаем: находить нечего.

Цель — 30 провалов. На dev из 56 вопросов при correctness 0,78 провалов около 12, поэтому обычно их меньше 30: тогда разбираем все, добавляем провалы из прогонов других конфигураций 8.3 и «мигающие» вопросы из 8.1 (в одном прогоне PASS, в другом FAIL). Если больше, сэмплируем со стратификацией по типу, чтобы не разобрать 30 comparative и ни одного factual.

Признак, что прочитано достаточно, — насыщение: последние 5–10 трейсов не добавляют новых категорий.

Отдельно читаем 5–10 успешных ответов. Это проверка судьи с другой стороны: нет ли среди PASS неверных ответов. Если есть, судья пропускает ошибки, и correctness завышена. Типичный пример — PASS за «12 месяцев гарантии» в вопросе про «Свет мини», у которой 6.

Когда у каждого трейса есть категория, считаем, сколько раз встретилась каждая. После разметки — простая таблица:

Причина Кол-во Доля
R-wrong-product 9 30%
G-refusal 6 20%
R-partial (порог доставки) 5 17%
D (эталон/вопрос) 4 13%
H-promise 3 10%
J 3 10%
Итого 30

(Иллюстрация.) Такая таблица и есть результат разбора: она говорит, что сначала стоит чинить. Важно помнить: на 30 случаях доли грубые, ±десяток п.п. Отличать 30% от 20% не стоит, отличать 30% от 5% — да.

Чиним самую частую причину, если исправление разумно по усилиям. Для примера выше:

  • R-wrong-product (30%): товар почти всегда назван в вопросе. Фильтр по category из 5.1 оставляет все карточки товаров, но не отличает одну лампу от другой. Стоит проверить, распознаётся ли товар на этих вопросах; возможно, не распознаются «маленькая лампа», «мини-светильник», SKU в нижнем регистре lamp-light-mini. Исправление: распознавание товара по названию, SKU и разговорным вариантам + фильтр или буст по doc его карточки (kb/products/lamp-light-mini.md).
  • D (13%): исправляется сразу и дёшево, но это не улучшение системы, а улучшение набора. Исправляем, поднимаем версию набора, перепрогоняем baseline.
  • J: исправляем промпт судьи, перепроверяем согласие на разметке eval/judge_labels.jsonl.

Порядок на практике: сначала D и J (иначе меряем кривой линейкой), затем самая частая категория системы.

Исправление без замера — снова «попробовали, вроде лучше». Исправление — это эксперимент по форме 8.2: гипотеза («распознавание товара с фильтром по карточке уберёт большую часть R-wrong-product, hit@5 +X п.п.»), прогон, парное сравнение, проверка регрессий. Два важных вопроса:

  1. Ушли ли ошибки той категории, которую чинили? Пересчитываем: из 9 R-wrong-product сколько теперь проходят.
  2. Не сломалось ли что-то ещё? Вопросы, которые проходили и стали проваливаться. Фильтр по товару может испортить comparative-вопросы: «чем отличается гарантия „Свет“ и „Свет мини“?» теперь ищет только по одной карточке. А наивный поиск подстроки находит «Свет» внутри «Свет мини» и путает товары в обратную сторону.

Ручной разбор дорог, и повторять его после каждого изменения не получится. Поэтому то, что нашли глазами, закрепляем кодом: каждая найденная категория — кандидат в автоматическую проверку:

  • R-wrong-product → метрика «доля кусков в топ-k с карточки нужного товара» (дешёвая, по doc и source_urls из набора);
  • R-archived → счётчик archived_in_context (уже есть в 6.4, должен быть 0);
  • G-refusal → счётчик ложных отказов false_refusals (тоже уже есть);
  • G-conflict → assertion для вопросов с тегом conflict: в ответе ссылки и на kb/delivery.md, и на kb/promo.md;
  • H-promise → assertion «нет обещаний действий» («оформим возврат», «вернём деньги») для тега action_request;
  • формат ответа → assertion «есть хотя бы одна ссылка kb/<file>.md#<anchor>».

Так набор метрик растёт из реальных ошибок, а не из списка в библиотеке.

Метод разбора от провайдера не зависит. Зависят от провайдера типичные провалы, которые легко принять за ошибки системы, и то, кому можно показывать трейсы. Сверено в ноябре 2026 по api-docs.deepseek.com и docs.ollama.com.

Помимо R / G / H / D / J на нашем стеке появляются провалы (часть из них уже встречалась в 6.4 и 8.3), где виновата не логика RAG, а то, как вызвана модель. Их стоит выделить в отдельную категорию I, потому что чинятся они конфигом, а не промптом или поиском:

Признак в трейсе Провайдер Что на самом деле Как проверить и чинить
Пустой content, вердикт ERROR, посчитанный как FAIL DeepSeek JSON mode документированное поведение: при невалидном JSON ответ может быть пустым ретрай; считать ERROR отдельно
Вердикты на одном ответе разные между прогонами DeepSeek судья в режиме thinking, temperature не действует thinking: disabled в конфиге судьи
Ответ игнорирует кусок, который точно был в топ-k Ollama контекст сервера меньше промпта, хвост обрезан молча (по умолчанию 4k, если видеопамяти меньше 24 ГБ) prompt_eval_count в ответе против ожидаемого числа токенов, ollama ps
Таймауты, 429, внезапно долгие ответы DeepSeek / Ollama лимит одновременных запросов DeepSeek; у Ollama — очередь, загрузка модели после простоя, своп при 8 ГБ памяти лог ретраев и времени, прогрев, последовательный прогон
Ответ на английском на русский вопрос, сломанный формат ссылок чаще локальная 4B слабая модель хуже держит инструкцию это G (модель), не I; отмечаем срез по языку (ru/en)
Результаты «поплыли» между неделями при том же конфиге DeepSeek под именем модели теперь другая версия сравнить resp.model в прогонах

Порядок в дереве решений расширяется: сначала I (инфраструктура), потом D (набор), J (судья), R, H, G. Провал из-за обрезанного контекста, размеченный как G-refusal, отправит переписывать промпт, который ни при чём.

Открытое кодирование делаем сами: в этом весь смысл. Но на шаге осевого кодирования модель может помочь сгруппировать 30 заметок в черновые категории. Для проекта 3 это можно делать в deepseek-flash: трейсы содержат только выдуманные вопросы и публичную базу знаний kb/. Правило на будущее: когда разбор пойдёт по агенту-разборщику заказов (проект 4), трейсы со снимком базы магазина, где есть личные данные клиентов, в DeepSeek и другие облака не отправляются. Локальная модель 3–4B (машина с 8 ГБ) для группировки слабовата, так что там группируем сами или работаем на демо-базе без личных данных.

Есть подозрение на ошибку судьи deepseek-v4-pro? Прогоняем спорные трейсы (5–10 штук) через судью другого семейства — Claude или OpenAI (данные публичные) — и сравниваем с собственным решением. Если другой судья согласен с нами, а DeepSeek нет, это сильный довод чинить промпт судьи. Локальная модель 3–4B для этой роли не годится: она слабее оцениваемой системы.

Что нужно разбору Claude OpenAI DeepSeek Ollama (4B, 8 ГБ)
Типичные провалы-«не ошибки системы» ограничения параметров у части моделей отказ reasoning-моделей от sampling-параметров пустой JSON, thinking и temperature, смена версии под именем обрезка контекста, прогрев, очередь, своп
Помощь в группировке заметок можно (публичные данные) можно (публичные данные) можно (публичные данные) приватно, но слабо
Второе мнение по J да да это и есть главный судья нет, слишком слабая
Трейсы с личными данными клиентов нельзя нельзя нельзя, серверы в Китае можно, но качество низкое
  1. Hamel: A Field Guide to Rapidly Improving AI Products → разделы про error analysis и про просмотрщик данных (≈30 мин). Обращаем внимание на идею, что лучший инструмент — простой, сделанный под свои данные.
  2. Hamel: AI Evals FAQ → вопросы про error analysis, open coding / axial coding и сколько трейсов читать (поиск по странице «error analysis», «axial») (≈30 мин).
  3. В Hamel: Your AI Product Needs Evals перечитываем раздел про просмотр данных и инструменты для этого (≈10 мин).
  4. (По желанию) Who Validates the Validators? (Shankar и др.) → введение и выводы (≈20 мин): про дрейф критериев, который мы увидим на себе во время разметки.
  5. Открываем свои результаты: последний прогон выбранной конфигурации из 8.3 (eval/runs/<...>/results.jsonl) или трейсы в Langfuse.
  6. В главе 6.4 · Скрипт оценки: retrieval + faithfulness перечитываем раздел «DeepSeek и Ollama» (≈10 мин): пустой JSON, thinking и temperature, обрезка контекста Ollama — это кандидаты в категорию I.
  7. Ollama API: /api/chat (≈10 мин): находим поля prompt_eval_count и eval_count в ответе — ими проверяется, дошёл ли весь контекст до локальной модели.
  • Подгонка под готовые категории. Начинаем с открытых заметок; категории формируются после.
  • Разметка следствия, а не корня. Галлюцинация после промаха поиска — это R, не H.
  • Игнорирование ошибок судьи. Если 20% «провалов» — ошибки судьи, все метрики врут на эту величину.
  • Исправление эталона под ответ системы. Эталон правим, только когда он объективно неверен (сверка с kb/), и пишем причину в notes.
  • Частный фикс под один вопрос («если спрашивают про гарантию мини-лампы, бери раздел X») — подгонка под набор. Исправление должно быть общим механизмом.
  • Доли на 30 случаях грубые. Не делаем выводов из разницы 20% и 17%.
  • Чтение без записи. Через час не вспомнить, что было в 7-м трейсе. Размечаем сразу в файл.
  • Остановка на распределении. Цель главы — одно исправление с измеренным эффектом.
  • Инфраструктурный провал размечен как ошибка модели. Обрезанный контекст Ollama, пустой JSON DeepSeek, судья в режиме thinking — сначала категория I.
  • Ложный отказ и пропущенная передача — разные ошибки. G-refusal портит ответы на вопросы, где ответ в базе есть; G-no-handoff оставляет покупателя без оператора. Правка промпта против одной легко усиливает другую: смотрим оба счётчика.
  • Личные данные в облачный «помощник по разметке». В проекте 3 трейсы публичные; в проекте 4 — нет.
  • Пишем eval/review_failures.py: берёт results.jsonl прогона, выбирает провалы (correctness FAIL, неверный отказ, пропущенная передача оператору), сэмплирует до 30 со стратификацией по type, показывает по одному трейсу в читаемом виде: вопрос и tags, эталон, цитаты, топ-k с позициями, url и пометкой релевантных и архивных, ответ, критики судей. Сразу добавляем вывод resp.model, режима thinking, usage (для Ollama — prompt_eval_count) и числа ретраев: без них категорию I не увидеть.
  • Для каждого трейса записываем в eval/error_labels.jsonl: id, note (свободная заметка о первой ошибке), позже category, subcategory.
  • Читаем все 30, только заметки (открытое кодирование).
  • Группируем заметки в категории (осевое кодирование): стартуем с R / G / H / D, добавляем J, I и подкатегории по необходимости. I проверяем первой в дереве решений. Проставляем категории в файле.
  • (Опционально) Просим deepseek-flash сгруппировать наши 30 заметок в черновые категории и сравниваем с собственной группировкой. Только для публичных трейсов проекта 3.
  • Читаем 5–10 успешных ответов; найденные неверные PASS отмечаем как J.
  • Для 5–10 спорных случаев категории J получаем второе мнение судьи другого семейства (Claude или OpenAI) и записываем, с каким решением согласны мы.
  • Скрипт печатает распределение причин (кол-во и доля), также по type и по tags. Таблицу сохраняем в experiments/EXP-03-error-analysis.md.
  • Исправляем D и J (набор → новая версия; промпт судьи → повторная проверка согласия). Перепрогоняем baseline выбранной конфигурации на новой версии набора.
  • Выбираем самую частую причину системы, записываем гипотезу исправления и ожидаемый эффект.
  • Реализуем исправление (общим механизмом, не под конкретные вопросы), прогоняем evals, сравниваем через compare.py: сколько вопросов исправленной категории теперь проходят, какие регрессии появились, парный тест.
  • Добавляем хотя бы одну новую автоматическую метрику или assertion по найденной категории в eval/run.py.
  • Добавляем строку в таблицу экспериментов.
  • Размечено 30 провалов, есть распределение причин (таблица с количеством и долей).
  • Исправлены ошибки набора и судьи (если были), версия набора поднята.
  • Одно исправление самой частой причины с измеренным эффектом: сколько ошибок категории ушло, Δ метрик, регрессии, парный тест.

Вопрос 1

Чем ручной разбор ошибок отличается от подсчёта метрик и почему нужен и тот, и другой?

Метрики говорят «сколько» ошибок, разбор — «почему»; без причин непонятно, что чинить, без метрик не измерить эффект исправления.

Вопрос 2

Поиск не нашёл кусок, модель выдумала ответ. Какая категория и почему?

  • a) H, потому что в ответе выдуманный факт
  • b) R, потому что это первая ошибка в цепочке
  • c) G
  • d) D.

b. Ошибки каскадируют; корень в поиске, галлюцинация — следствие отсутствия нужного куска.

Вопрос 3

Что такое открытое и осевое кодирование?

Открытое — свободные заметки о первой ошибке в каждом трейсе без готовых категорий; осевое — группировка заметок в категории и подсчёт их частот.

Вопрос 4

Почему сначала исправляют ошибки набора и судьи, а потом системы?

Иначе меряем кривой линейкой: неверные эталоны и ошибки судьи искажают и распределение причин, и эффект любого исправления.

Вопрос 5

Как понять, что прочитано достаточно трейсов?

По насыщению: последние 5–10 трейсов не добавляют новых категорий.

Вопрос 6

Зачем читать несколько успешных ответов при разборе ошибок?

Чтобы найти ложные PASS судьи (например, «12 месяцев» в ответе про «Свет мини»): если среди успешных есть неверные ответы, correctness завышена и судью надо чинить.

Вопрос 7

Что не так с таким исправлением?

if "мини" in question.lower() and "гарант" in question.lower():
chunks = [c for c in chunks if c["section"] == "Товары > Лампа «Свет мини» > Гарантия"]

Частный хардкод под конкретный вопрос из набора: подгонка, которая не обобщается, хрупкая (опечатки, «маленькая лампа», SKU LAMP-LIGHT-MINI, другие формулировки) и может сломать соседние вопросы. Нужен общий механизм: распознавание товара в вопросе и фильтр по карточке этого товара.

Вопрос 8

После исправления категории R-wrong-product hit@5 вырос, но comparative-вопросы про две лампы стали проваливаться. Что произошло вероятнее всего?

Фильтр оставляет одну карточку, а comparative-вопросу нужны две: фильтр должен учитывать все упомянутые товары (и не находить «Свет» внутри «Свет мини» как отдельный товар).

Вопрос 9

По результатам разбора: R 30%, G 20%, D 13%. Можно ли утверждать, что R встречается в полтора раза чаще G?

Нет: на 30 случаях это 9 против 6, разница в пределах шума. Можно сказать только, что обе категории частые.

Вопрос 10

Как результаты разбора превращаются в новые evals? Пример.

Каждая частая категория получает автоматическую проверку, например R-wrong-product → доля кусков в топ-k с карточки нужного товара по метаданным doc; G-conflict → assertion на ссылки и на kb/delivery.md, и на kb/promo.md; H-promise → assertion на отсутствие обещаний «оформим возврат».

Вопрос 11

В разборе 6 из 30 провалов — локальная модель «проигнорировала» кусок, который был в топ-k. К какой категории их отнести в первую очередь и как проверить?

Сначала проверить категорию I: не обрезан ли контекст Ollama (по умолчанию 4k, если видеопамяти меньше 24 ГБ). Сравнить prompt_eval_count с ожидаемым числом токенов промпта и посмотреть ollama ps. Только если контекст дошёл целиком, это G.

Вопрос 12

Почему трейсы проекта 3 можно отдать deepseek-flash для черновой группировки заметок, а трейсы агента проекта 4 — нет?

В проекте 3 трейсы содержат только выдуманные вопросы и публичную базу знаний магазина. Агент проекта 4 читает снимок базы магазина с личными данными клиентов, а данные DeepSeek обрабатываются на серверах в Китае; личные данные клиентов в облако не отправляются, такие трейсы разбираются локально.

Все эксперименты сделаны. В 8.5 · Проект 3: таблица экспериментов в README собираем их в историю улучшений в цифрах для портфолио.