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

2.5 · Упражнение: JSON из 20 текстов

Неделя 2 · Основы LLM · ⏱ ~4 ч · Практика

Разница между хобби-проектом и продуктом на LLM — в одной фразе: «правильно в 17 из 20, ошибки в основном в поле даты». Это первая настоящая оценка качества (eval) в курсе. Умение собрать эталон, посчитать метрики по полям и найти систематические ошибки — центральный навык AI-инженера; на собеседованиях на вопрос «как вы поняли, что стало лучше?» ждут именно такого ответа.

датасет (входы) + эталон (правильные ответы, gold)
│
▼
наша система (промпт + модель + схема) → предсказания
│
▼
сравнение предсказаний с эталоном → метрики + список ошибок

Это ровно то, что мы делали в главе 2.1 для меток отзывов, только теперь ответ — структура из нескольких полей, и сравнивать надо по каждому полю. Аналогия из обычной разработки: эталон — это ожидаемые значения в unit-тестах, а eval — тестовый прогон, только вместо «зелёный/красный» получаем процент, потому что LLM-система вероятностная и 100% не бывает.

Задача упражнения — та же, что в главе 2.2: из письма поставщика магазина «Полка» достать событие по товару (поступление, снятие с производства, изменение цены), товар, поставщика и дату; добавим к ним новую цену. Систему уже написали — это функция extract() и модель ProductUpdate; теперь нужны тексты, на которых её проверить. 20 текстов — мало для статистики, но достаточно, чтобы увидеть типовые ошибки. Важнее разнообразие, чем количество:

Тип Сколько Зачем
Обычные письма о поступлении товара и изменении цены 6–8 «Счастливый путь»
Снятие с производства 3–4 Модель может спутать с поступлением
Несколько товаров в одном письме 2 Проверка схемы (список или одна запись?)
Без даты / дата без года / относительная дата («со следующего понедельника») 2–3 Проверка выдумок и нормализации
Описания товаров, рекламные тексты (не объявления) 2 Должны давать «нет события»
Сообщения клиентов из поддержки (обезличенные, см. ниже) 2–3 Шумные, не похожие на письма поставщиков входы: в реальном потоке они тоже попадаются, и система не должна находить в них «поступление» или «новую цену»

Где взять тексты. Готового источника нет: рассылки поставщиков приходят на почту магазина и в открытом доступе не лежат. Два пути. Первый — написать тексты руками по образцу настоящих рассылок: письма «новое поступление», уведомления о новом прайсе, сообщения о снятии позиции с производства (такие разделы «Новости» есть на сайтах многих оптовиков). Второй — сгенерировать черновики моделью и отредактировать каждый руками. Черновики от модели обычно слишком «чистые», поэтому трудности возвращаем специально: даты без года («с 1 декабря»), несколько товаров в одном письме, разные написания поставщика (ООО «Чайный двор», Чайный Двор, teayard.ru), цена за килограмм вместо цены за 100 г. В поле source пишем, как появился текст (handwritten, generated+edited). Тот, у кого есть свои реальные письма поставщиков и право их использовать, может взять их.

Безопасность данных. Сообщения клиентов — это персональные данные: имя, телефон, адрес, номер заказа. У нас они выдуманы, но обращаемся с ними как с настоящими — эта привычка понадобится на работе. Правила:

  • работаем только с копией (если что-то берём из базы магазина — только через VACUUM INTO в отдельный файл, read-only);
  • сообщения клиентов отправляем только в локальную модель (Ollama) — в DeepSeek и другие облачные API их нельзя даже обезличенными (см. раздел «DeepSeek и Ollama» ниже); и всё равно обезличиваем, ведь датасет лежит в репозитории: убираем имена, телефоны, email, адреса, номера заказов и трек-номера; лучше переписать сообщение руками, сохранив структуру;
  • никаких выгрузок из почты или личных кабинетов поставщиков по токенам: тексты — публичные, написанные для курса или скопированные вручную;
  • если есть сомнение, можно ли отдавать текст наружу, — не отдаём, придумываем похожий.

Эталон: разметка — это решения, а не механика

Заголовок раздела «Эталон: разметка — это решения, а не механика»

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

  • Как писать поставщика: ООО «Чайный двор», Чайный Двор, teayard.ru? → канонический вид (например, нижний регистр, без организационно-правовой формы и кавычек: чайный двор).
  • Какая дата, если в тексте «новые цены действуют с 1 декабря 2026, заказы по старым принимаем до 28 ноября»? → правило: дата вступления в силу.
  • Письмо о выходных склада — это update_type = null или отдельное значение? → схема: возможно, лучше явное other: update_type: Literal["new_arrival", "discontinued", "price_change", "other"].
  • «2400 ₽/кг» для чая — это new_price = 240 (как в карточке товара, за 100 г). Поля new_price в модели из главы 2.2 нет — добавляем его.

Записываем эти решения в annotation_guide.md — это одновременно спецификация для промпта. Хорошая практика: правила разметки почти дословно переходят в описания полей и в <rules> промпта.

Формат эталона — JSONL, по строке на пример:

{"id": "t07", "text": "...", "gold": {"update_type": "price_change", "product": "улун молочный", "supplier": "чайный двор", "effective_date": "2026-12-01", "new_price": 240.0}}

Когда есть эталон, остаётся сравнить с ним ответы модели. Наивное pred == gold для целого объекта даёт одну цифру и ничего не объясняет. Сравниваем каждое поле отдельно и с нормализацией:

def norm_supplier(v: str | None) -> str | None:
if v is None:
return None
v = v.strip().lower().removeprefix("ооо ").strip("«»\" ")
aliases = {"teayard.ru": "чайный двор", "tea yard": "чайный двор"} # from the annotation guide
return aliases.get(v, v)
NORMALIZERS = {
"product": lambda v: v.strip().lower().strip("«»\"") if v else None,
"supplier": norm_supplier,
"effective_date": lambda v: str(v) if v else None, # ISO string or None
"new_price": lambda v: round(float(v), 2) if v is not None else None,
"update_type": lambda v: v,
}

Нормализаторы приводят эквивалентные написания к одному виду до сравнения. После этого у каждого поля четыре возможных исхода — верно, ошибка, пропуск, выдумка (различать их важно для полей, которые могут быть null):

gold pred Исход
значение то же значение ✅ верно
значение другое значение ❌ ошибка
значение null ❌ пропуск (модель «не нашла»)
null значение ❌ выдумка (галлюцинация) — самая опасная
null null ✅ верно

Пропуск и выдумка — разные болезни с разным лечением: пропуск лечат подсказками, где искать; выдумку — явным правилом «если нет в тексте — null». Считаем их отдельно.

Исходы по полям складываются в несколько цифр:

  • Точность по полю = верных / всего примеров (для поля). Главная цифра этого упражнения.
  • Exact match = доля примеров, где верны все поля. Строгая метрика «запись целиком правильная».
  • Число выдумок по полю — отдельно.
  • Ошибки формата — сколько раз ответ вообще не распарсился (при structured outputs должно быть 0).

Для одной-двух вещей, вроде «нашли ли все товары в письме с несколькими товарами», понадобятся precision/recall — это тема этапа evals, сейчас достаточно отметить такие примеры вручную.

Цифры говорят, насколько плохо, но не почему. После прогона выводим таблицу ошибок: id, поле, gold, pred, кусок текста. Потом группируем глазами: «5 из 7 ошибок — даты вида „с 1 декабря“ без года», «поставщик берётся из подписи транспортной компании внизу письма, а не из шапки». Каждой группе — одна гипотеза и одно изменение (промпт, описание поля, схема, нормализация). Это тот же цикл, что в гл. 2.1.

Иногда виновата разметка: ошиблись мы сами или правило было неоднозначным. Это нормально — исправляем эталон и записываем, почему.

У цикла «ошибка → гипотеза → изменение» есть обратная сторона. Если улучшать промпт, глядя на те же 20 текстов, рано или поздно начинается «подгонка» промпта под конкретные примеры. Минимальная защита даже на таком маленьком наборе: откладываем 5 текстов (holdout) и не смотрим на ошибки на них до финального прогона. Если на 15 «рабочих» точность выросла, а на 5 отложенных — нет, промпт подогнан под данные.

Вторая ловушка при сравнении версий — случайность самих ответов. Каждый прогон повторяем 2–3 раза: даже при одинаковом промпте ответы немного различаются. Если «до» = 82% ± 3%, а «после» = 84% ± 3%, ничего не доказано. Разница, которую стоит обсуждать на 20 примерах, — несколько текстов, а не один.

Сам eval от провайдера не зависит: датасет, эталон, сравнение по полям, holdout — всё то же. Меняются две вещи: какие тексты можно отправлять в какую модель и как сделать прогон воспроизводимым.

Маршрутизация по приватности — в коде, а не в голове

Заголовок раздела «Маршрутизация по приватности — в коде, а не в голове»

В датасете будут сообщения клиентов. Даже обезличенные, это переписка людей с магазином — в DeepSeek (серверы в КНР) и другие облачные модели их не отправляем. Письма поставщиков и публичные анонсы — можно. Делаем это правилом, которое проверяет код:

# every dataset item has "private": true/false
CLOUD_PROVIDERS = {"deepseek", "openrouter", "anthropic", "openai"}
def guard(item: dict, provider: str) -> None:
if item.get("private") and provider in CLOUD_PROVIDERS:
raise PermissionError(f"{item['id']}: private text must stay local (ollama), got {provider}")

Отсюда два прогона:

Прогон Провайдер Какие тексты
A DeepSeek deepseek-flash все публичные (private: false) — 17–18 штук
B Ollama qwen3:4b (модель курса по умолчанию; хватает 8 ГБ RAM) все 20, включая сообщения клиентов

Есть и третий вариант — вместо обезличенных сообщений клиентов придумать похожие (фейковые) и пометить их private: false. Это нормально и даже правильно для публичного репозитория; но тогда качество меряется на фейках — это стоит записать в summary.md.

  • DeepSeek: выключаем размышление (thinking: disabled) и ставим низкую temperature — в режиме размышления она молча игнорируется (глава 1.7). Записываем в results/ поле model из ответа: за именем deepseek-flash может смениться версия, и тогда «улучшение» v2 → v3 окажется сменой модели.
  • Ollama: options={"temperature": 0, "seed": 42} и ID модели из ollama list в конфиге прогона. Используем строгую схему через format (глава 2.2) — ошибок формата должно быть 0.
  • Для всех: конфиг прогона = провайдер, модель, версия промпта, схема, параметры, дата, время (для DeepSeek — пик/вне пика влияет на стоимость).

Чем отличаются провайдеры в этом упражнении

Заголовок раздела «Чем отличаются провайдеры в этом упражнении»
Claude / OpenAI DeepSeek Ollama (qwen3:4b, локально)
Строгая схема для extract() parse только strict tool (бета) или JSON mode + повтор format со схемой
Можно отправлять сообщения клиентов нет (облако; правило курса) нет да
Воспроизводимость ручек сэмплинга почти нет (у новых Claude — никаких) temperature без размышления, версия за алиасом может смениться temperature=0 + seed, версия под нашим контролем
Стоимость 20 текстов × 3 прогона центы доли цента $0, но минуты времени
Чего ждать по качеству эталон для сравнения высоко ниже, особенно на датах и нескольких товарах

Основной прогон — DeepSeek на публичных текстах (быстро и дёшево, хорошая «планка»), второй — Ollama на всех 20 текстах. Публичную часть можно прогнать и через модели OpenRouter из главы 2.4 (guard уже считает его облачным) — так таблица моделей из главы 2.3 получит первые собственные цифры качества. Разница точности на одних и тех же публичных текстах покажет, сколько качества теряется, когда данные остаются локально; это пригодится, когда будем решать, как строить бота над базой магазина с персональными данными клиентов (глава 3.1 · Проект 1: архитектура бота и копия базы). Claude/OpenAI — по желанию, для сравнения.

  1. Перечитываем свою функцию extract() и модель ProductUpdate из 2.2 (≈15 мин): решаем, что меняется в схеме после принятия решений по разметке (например, значение other в update_type, поле new_price, список товаров).
  2. Документация Anthropic → раздел про создание тестов и критериев успеха в Prompt engineering / Test & evaluate (≈30 мин). Отправная точка — Prompt engineering overview, где есть ссылки на страницы «define success criteria» и «develop tests». Берём оттуда: критерии успеха должны быть конкретными и измеримыми; тестовые наборы должны включать граничные случаи.
  3. Anthropic Courses → курс Prompt evaluations: читаем первые 1–2 урока (≈30 мин) — о том, зачем нужны evals и как устроен простой code-graded eval. Остальное — на этапе evals.
  4. Статья Hamel Husain «Your AI Product Needs Evals» (hamel.dev/blog/posts/evals) (≈30 мин) — практичный взгляд на evals и анализ ошибок от человека, который много консультирует продуктовые команды. Берём: «смотри на данные», уровни проверок, итеративный цикл.
  5. Перечитываем в главе 2.2 раздел «DeepSeek и Ollama» и страницу Ollama Structured outputs (≈10 мин): какой способ строгой схемы используем для каждого провайдера. В Chat API Ollama находим options.seed и options.temperature.
  6. Практика (≈2.5 ч).
  • Без руководства по разметке эталон «плывёт»: пример 3 размечен так, пример 15 — иначе. Модель не может быть точнее неоднозначного эталона.
  • Нормализация может скрыть ошибки или наоборот создать их. Нормализуем только то, что для задачи эквивалентно (регистр, кавычки, «ООО»), и логируем исходные значения.
  • Даты. Относительные даты («с понедельника») и даты без года без даты письма в контексте не решаются. Если их нужно извлекать — передаём дату письма в промпт.
  • Подгонка под тест. Few-shot примеры не должны быть из этих 20 текстов, и holdout не трогаем до конца.
  • Одна цифра врёт. «90% exact match» может означать «всё отлично», а может «всё отлично, кроме выдумок в 2 рекламных текстах без всякого события», что для продукта критично: магазин снимет с продажи или переоценит товар, о котором поставщик ничего не писал. Смотрим метрики по полям и выдумки отдельно.
  • Утечка персональных данных через сообщения клиентов — реальная ошибка новичков. Только обезличенные копии.
  • Код стоит сохранить аккуратно — eval_extract.py станет основой наших evals в неделях 6–8. Не пишем его одноразово в ноутбуке.

Структура:

exercises/week-02/extract_eval/
annotation_guide.md
dataset.jsonl # 20 texts with gold labels
eval_extract.py
results/ # one JSON per run: config + per-item predictions + metrics
  • 1. Собрать 20 текстов по таблице из теории: письма поставщиков (поступления, снятия с производства, изменения цен), описания товаров, 2–3 обезличенных сообщения клиентов из поддержки. Тексты пишем руками или генерируем черновики моделью и редактируем (см. «Где взять тексты»). Источник каждого текста записываем в поле source, а для сообщений клиентов ставим "private": true (остальным — false). Скрипт перед каждым вызовом вызывает guard(item, provider) из раздела «DeepSeek и Ollama».
  • 2. Руководство по разметке. annotation_guide.md: канонические названия поставщиков и товаров, какое событие к какому типу относится, какая дата берётся, в каких единицах цена, что делать с несколькими товарами, когда null.
  • 3. Разметить эталон в dataset.jsonl. Помечаем 5 текстов "split": "holdout", остальные "split": "dev".
  • 4. Скрипт. eval_extract.py собирает всё из теории: NORMALIZERS из раздела «Сравнение по полям», guard() из раздела «DeepSeek и Ollama», сравнение по полям и подсчёт исходов. Аргументы командной строки: --split, --version, --provider. Каркас:
import json, sys
from collections import defaultdict
from pathlib import Path
FIELDS = ["update_type", "product", "supplier", "effective_date", "new_price"]
def compare(gold: dict, pred: dict | None) -> dict[str, str]:
"""Return outcome per field: ok / wrong / missed / invented / format_error."""
if pred is None:
return {f: "format_error" for f in FIELDS}
out = {}
for f in FIELDS:
g, p = NORMALIZERS[f](gold.get(f)), NORMALIZERS[f](pred.get(f))
if g == p:
out[f] = "ok"
elif g is None:
out[f] = "invented"
elif p is None:
out[f] = "missed"
else:
out[f] = "wrong"
return out
def run(split: str, version: str) -> None:
items = [json.loads(l) for l in Path("dataset.jsonl").read_text().splitlines() if l.strip()]
items = [i for i in items if i["split"] == split]
per_field = defaultdict(lambda: defaultdict(int))
rows = []
for item in items:
pred = safe_extract(item["text"]) # TODO: call guard(), then extract() from AIL-8; model_dump(mode="json") or None on failure
outcome = compare(item["gold"], pred)
for f, o in outcome.items():
per_field[f][o] += 1
rows.append({"id": item["id"], "pred": pred, "outcome": outcome})
# TODO: print accuracy per field, invented count, exact match;
# print a table of errors; save everything to results/{version}_{split}.json

Ожидаемый вывод примерно такой:

version=v1 split=dev n=15
field acc invented missed wrong
update_type 93.3% 0 0 1
product 86.7% 0 1 1
supplier 80.0% 1 0 2
effective_date 66.7% 2 1 2
new_price 86.7% 1 0 1
exact match: 53.3%
  • 5. Прогон «до» на dev, 2–3 раза: прогон A (DeepSeek, публичные тексты — скрипт сам отбирает private: false) и прогон B (Ollama qwen3:4b, все тексты). Флаг --provider deepseek|ollama; провайдер и модель — часть имени файла результата. Результаты сохраняем.
  • 5б. Проверка охраны. Запускаем --provider deepseek без фильтра публичных текстов — скрипт обязан упасть на PermissionError до первого сетевого запроса. Оставляем это как тест.
  • 6. Анализ ошибок. Выписываем группы ошибок с гипотезами в results/analysis_v1.md (например: «даты вида „с 1 декабря“ → модель ставит неправильный год → дать дату письма в контекст»). Найденные ошибки разметки исправляем в эталоне с комментарием.
  • 7. Улучшение. По одному изменению за итерацию (описание поля, правило в промпте, значение other в update_type, дата письма в контексте, нормализация) — прогон после каждого. 2–4 итерации.
  • 8. Финал на holdout. Прогоняем первую и последнюю версии на 5 отложенных текстах. Сравниваем прирост на dev и на holdout.
  • 9. Отчёт. В results/summary.md: таблица «провайдер → версия → точность по полям → exact match → выдумки → стоимость/время прогона» (через estimate_cost из главы 1.7) и 3–5 предложений выводов, включая: насколько локальная 4B-модель отстаёт от DeepSeek на одних и тех же публичных текстах и что тексты написаны или сгенерированы для курса.
  • Скрипт печатает точность по полям в процентах для версии «до» и «после», а также exact match и число выдумок.
  • Есть анализ ошибок с группами и гипотезами, и итоговое сравнение на holdout.
  • Код и датасет сохранены в репо и запускаются одной командой (python eval_extract.py --split dev --version v3).
  • В датасет не попало ни одного сообщения с настоящими персональными данными (стоит ещё раз проверить глазами), а тексты с private: true технически не могут уйти в облачного провайдера (guard + тест).

Вопрос 1

Что такое эталон (gold) и почему его размечают вручную?

Правильные ответы для каждого входа, с которыми сравнивают предсказания. Вручную — потому что это и есть определение «правильно»; при разметке принимаются решения о пограничных случаях.

Вопрос 2

Чем «выдумка» (gold = null, pred = значение) хуже «пропуска» (gold = значение, pred = null) для системы, которая автоматически обновляет карточки товаров по письмам поставщиков?

Выдумка запускает действие на несуществующем событии (товар снят с продажи или переоценён, хотя поставщик ничего такого не писал), пропуск — лишь упущенное обновление. Для автоматических реакций ложные срабатывания обычно дороже.

Вопрос 3

Зачем нормализовать значения перед сравнением? В каком случае нормализация вредна?

Чтобы эквивалентные для задачи значения (ООО «Чайный двор»/чайный двор, «Север»/Север) не считались ошибкой. Вредна, если склеивает разное: например, нормализация Чайный двор Опт → чайный двор в задаче, где это разные поставщики с разными ценами.

Вопрос 4

Exact match = 50%, а точность по каждому полю ≥ 85%. Как такое возможно?

Ошибки в разных полях приходятся на разные примеры: в каждом примере ошибается какое-то одно поле, поэтому записей, где всё верно, мало.

Вопрос 5

Что такое holdout и зачем он даже на 20 примерах?

Отложенная часть данных, на которую не смотрят во время улучшений. Проверяет, что улучшение обобщается, а не подогнано под конкретные примеры.

Вопрос 6

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

correct = sum(1 for g, p in zip(golds, preds) if g == p)
print("accuracy", correct / len(golds))

Сравниваются объекты целиком без нормализации: одна-единственная цифра, не видно, какие поля ломаются; регистр и формат дат дадут ложные ошибки; не различаются выдумки, пропуски и ошибки формата.

Вопрос 7

Версия v2 дала 84%, v1 — 82%, по одному прогону каждая на 15 текстах. Какой вывод корректен?

  • a) v2 лучше
  • b) v1 лучше
  • c) разница в пределах шума, нужны повторы и больше данных
  • d) обе плохие

c. Разница в 2 п. п. на 15 текстах — меньше одного примера, в пределах шума.

Вопрос 8

Почему руководство по разметке почти дословно попадает в промпт?

Правила разметки — это и есть спецификация задачи; если модели их не сообщить, она будет решать пограничные случаи по-своему и «ошибаться» относительно эталона.

Вопрос 9

В датасет нужно добавить сообщение клиента из поддержки: Здравствуйте, это Анна Смирнова, заказ 10482, тел. +7 912 345-67-89. Кружка «Север» пришла с трещиной, а на сайте её уже нет — замену пришлёте? Что нужно сделать перед этим?

Работать с копией; обезличить или переписать сообщение: убрать имя, телефон, номер заказа и прочие данные клиента, оставив структуру и товар; пометить "private": true. Если есть сомнение — придумать похожее сообщение.

Вопрос 10

Что из этого — пример систематической ошибки, а не случайной?

  • a) модель один раз перепутала поставщиков
  • b) модель всегда ставит текущий год для дат без года
  • c) один ответ не распарсился из-за обрыва сети
  • d) разметчик опечатался в одном названии товара

b. Повторяющаяся ошибка с общей причиной; её лечат одним изменением (например, давать дату письма).

Вопрос 11

Почему важно сохранять в results/ не только метрики, но и предсказания по каждому примеру и конфигурацию прогона?

Чтобы делать анализ ошибок, сравнивать версии по конкретным примерам и воспроизводить прогон (модель, промпт, схема, дата) — это основа будущих evals.

Вопрос 12

Сообщение клиента обезличено: ни имени, ни телефона, ни номера заказа. Можно ли теперь отправить его в DeepSeek?

  • a) да, обезличенное можно
  • b) нет: по правилу курса настоящие сообщения клиентов — только в локальную модель, в облако — только выдуманные похожие
  • c) да, если выключить размышление
  • d) только через OpenRouter

b. Обезличивание не меняет того, что это переписка клиента с магазином, и её не показываем посторонним, в том числе облачному провайдеру (у DeepSeek серверы в КНР); настоящие сообщения — только Ollama, в облако — фейковые аналоги.

Вопрос 13

На DeepSeek получилось v2 = 88%, через неделю v3 = 92%. Какие две вещи, специфичные для провайдера, нужно проверить, прежде чем радоваться?

Что за алиасом deepseek-flash не сменилась версия модели (поле model в логах прогона) и что режим размышления/temperature одинаковые в обоих прогонах (в режиме размышления temperature игнорируется). Плюс обычное: шум и повторы.

Вопрос 14

Почему точность Ollama и DeepSeek надо сравнивать на одних и тех же публичных текстах, а не «Ollama на всех 20 vs DeepSeek на 17»?

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

В [2.6 · Tool calling: модель вызывает 3 функции (гл. 2.6)](/week-02/2-6/) модель начнёт не только возвращать данные, но и просить наш код выполнить действия. А eval_extract.py вернётся на этапе evals как основа для полноценного набора тестов.