2.5 · Упражнение: JSON из 20 текстов
Неделя 2 · Основы LLM · ⏱ ~4 ч · Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Разница между хобби-проектом и продуктом на LLM — в одной фразе: «правильно в 17 из 20, ошибки в основном в поле даты». Это первая настоящая оценка качества (eval) в курсе. Умение собрать эталон, посчитать метрики по полям и найти систематические ошибки — центральный навык AI-инженера; на собеседованиях на вопрос «как вы поняли, что стало лучше?» ждут именно такого ответа.
Что такое eval в самом простом виде
Заголовок раздела «Что такое eval в самом простом виде»датасет (входы) + эталон (правильные ответы, 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 примерах
Заголовок раздела «Переобучение на 20 примерах»У цикла «ошибка → гипотеза → изменение» есть обратная сторона. Если улучшать промпт, глядя на те же 20 текстов, рано или поздно начинается «подгонка» промпта под конкретные примеры. Минимальная защита даже на таком маленьком наборе: откладываем 5 текстов (holdout) и не смотрим на ошибки на них до финального прогона. Если на 15 «рабочих» точность выросла, а на 5 отложенных — нет, промпт подогнан под данные.
Вторая ловушка при сравнении версий — случайность самих ответов. Каждый прогон повторяем 2–3 раза: даже при одинаковом промпте ответы немного различаются. Если «до» = 82% ± 3%, а «после» = 84% ± 3%, ничего не доказано. Разница, которую стоит обсуждать на 20 примерах, — несколько текстов, а не один.
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Сам eval от провайдера не зависит: датасет, эталон, сравнение по полям, holdout — всё то же. Меняются две вещи: какие тексты можно отправлять в какую модель и как сделать прогон воспроизводимым.
Маршрутизация по приватности — в коде, а не в голове
Заголовок раздела «Маршрутизация по приватности — в коде, а не в голове»В датасете будут сообщения клиентов. Даже обезличенные, это переписка людей с магазином — в DeepSeek (серверы в КНР) и другие облачные модели их не отправляем. Письма поставщиков и публичные анонсы — можно. Делаем это правилом, которое проверяет код:
# every dataset item has "private": true/falseCLOUD_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 — по желанию, для сравнения.
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Перечитываем свою функцию
extract()и модельProductUpdateиз 2.2 (≈15 мин): решаем, что меняется в схеме после принятия решений по разметке (например, значениеotherвupdate_type, полеnew_price, список товаров). - Документация Anthropic → раздел про создание тестов и критериев успеха в Prompt engineering / Test & evaluate (≈30 мин). Отправная точка — Prompt engineering overview, где есть ссылки на страницы «define success criteria» и «develop tests». Берём оттуда: критерии успеха должны быть конкретными и измеримыми; тестовые наборы должны включать граничные случаи.
- Anthropic Courses → курс Prompt evaluations: читаем первые 1–2 урока (≈30 мин) — о том, зачем нужны evals и как устроен простой code-graded eval. Остальное — на этапе evals.
- Статья Hamel Husain «Your AI Product Needs Evals» (hamel.dev/blog/posts/evals) (≈30 мин) — практичный взгляд на evals и анализ ошибок от человека, который много консультирует продуктовые команды. Берём: «смотри на данные», уровни проверок, итеративный цикл.
- Перечитываем в главе 2.2 раздел «DeepSeek и Ollama» и страницу Ollama Structured outputs (≈10 мин): какой способ строгой схемы используем для каждого провайдера. В Chat API Ollama находим
options.seedиoptions.temperature. - Практика (≈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, sysfrom collections import defaultdictfrom 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=15field acc invented missed wrongupdate_type 93.3% 0 0 1product 86.7% 0 1 1supplier 80.0% 1 0 2effective_date 66.7% 2 1 2new_price 86.7% 1 0 1exact match: 53.3%- 5. Прогон «до» на
dev, 2–3 раза: прогон A (DeepSeek, публичные тексты — скрипт сам отбираетprivate: false) и прогон B (Ollamaqwen3: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 текстах. Какой вывод корректен?
c. Разница в 2 п. п. на 15 текстах — меньше одного примера, в пределах шума.
Вопрос 8
Почему руководство по разметке почти дословно попадает в промпт?
Правила разметки — это и есть спецификация задачи; если модели их не сообщить, она будет решать пограничные случаи по-своему и «ошибаться» относительно эталона.
Вопрос 9
В датасет нужно добавить сообщение клиента из поддержки: Здравствуйте, это Анна Смирнова, заказ 10482, тел. +7 912 345-67-89. Кружка «Север» пришла с трещиной, а на сайте её уже нет — замену пришлёте? Что нужно сделать перед этим?
Работать с копией; обезличить или переписать сообщение: убрать имя, телефон, номер заказа и прочие данные клиента, оставив структуру и товар; пометить "private": true. Если есть сомнение — придумать похожее сообщение.
Вопрос 10
Что из этого — пример систематической ошибки, а не случайной?
b. Повторяющаяся ошибка с общей причиной; её лечат одним изменением (например, давать дату письма).
Вопрос 11
Почему важно сохранять в results/ не только метрики, но и предсказания по каждому примеру и конфигурацию прогона?
Чтобы делать анализ ошибок, сравнивать версии по конкретным примерам и воспроизводить прогон (модель, промпт, схема, дата) — это основа будущих evals.
Вопрос 12
Сообщение клиента обезличено: ни имени, ни телефона, ни номера заказа. Можно ли теперь отправить его в DeepSeek?
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 как основа для полноценного набора тестов.