Разбираю архитектуру веб-приложения для вопросов о здоровье: ловушки данных, дизайн инструментов и проверка гипотез. Читайте, чтобы не наступить на грабли.
Вы когда-нибудь строили систему, которая должна отвечать на вопросы по вашим данным, и обнаруживали, что она даёт неверные ответы в 10% случаев? Я построил веб-приложение для вопросов о собственном здоровье, и в процессе выявил шесть ловушек, которые сломали бы любого наивного ИИ-ассистента. В этой статье я покажу, как структура инструментов может предотвратить ошибки лучше, чем любые промпты, и почему проверка гипотез на реальных данных — единственный способ избежать самообмана.
Моё приложение отвечает на два типа вопросов, и для каждого — свой подход. Первый — режим тренера — полностью детерминированный. Это чистые функции в lib/signals/, покрытые юнит-тестами: соблюдение белка, дефицит калорий, динамика веса, перетренированность, застой в упражнениях, свежесть данных. Никакого ИИ — только правила. Важно, что unknown — это отдельный результат, отличный от ok, потому что эти данные постоянно дают неизвестность.
Второй — режим вопросов — использует LLM (Claude) на серверной стороне с тринадцатью read-only инструментами. Он обрабатывает вопросы, для которых нет правил: «Я застрял в приседаниях?», «Какой у меня реальный дефицит?», «Что происходит с моим VO2max?». Вся интересная архитектурная работа — именно здесь, и она в основном о том, от чего я отказался строить.
Самый простой вариант — дать модели SQL: схему, read-only подключение, и пусть пишет запросы. Так делают все демо «поговори с базой». На это уходит полдня, и для маленькой базы стоимость запросов неважна. Но я отказался, потому что в этих данных есть шесть ловушек, и каждая уже давала неверный ответ хотя бы раз.
Если дать модели SQL, она наступит на все шесть граблей. Это говорит не о модели, а о схеме: ловушки невидимы из неё. Ничто в колонке calories не скажет, что сегодняшнее значение — неполное.
Я мог бы перечислить все шесть ловушек в системном промпте, и модель бы их учитывала в большинстве случаев. Это и определило дизайн, потому что «в большинстве случаев» — худший исход. Инструмент, который ошибается всегда, будет выброшен в первый же день. Инструмент, который прав в 90+% случаев, вызывает доверие, и редкая ошибка приходит в том же уверенном форматировании, что и правильные ответы. Я не могу её заметить, потому что спрашиваю именно потому, что не знаю ответа.
Поэтому ловушки исключены формой инструментов, а не инструкциями:
AND observed_on < today_local(). Частичный день не исключается моделью — он просто недостижим.energy_balance нельзя получить без проверки реальностью в том же ответе. Невозможно получить вводящее в заблуждение число отдельно.Промпт всё ещё описывает все шесть ловушек, потому что модель, понимающая почему окно заканчивается, даёт лучшие ответы, чем та, что получает усечённые данные. Но не промпт их удерживает. Если бы промпт удалили завтра, ответы стали бы хуже, но не в этих шести конкретных направлениях.
Кроме того, нет инструмента записи — буквально, а не как отключённого. Программа тренировок меняется в Liftosaur, цели по макронутриентам — в MacroFactor, поэтому у чата нет причин что-либо мутировать.
Затем я чуть не выпустил инструмент, который был неправильным способом, не защищённым структурой. Чату нужно знать, какие метрики существуют. Есть таблица metric_catalog с каноническими единицами и оценкой внимания, поэтому list_metrics читает из неё. Всё компилировалось, типы сходились, возвращались правдоподобные строки: 38 из 81 метрики каталогизированы, остальные 43 — не мусор: это дистанция ходьбы и бега (3865 дней данных, обновлено сегодня), этажи, средний пульс при ходьбе, скорость, длина шага, а также вся панель микронутриентов — всё актуально, потому что MacroFactor логирует микронутриенты.
Инструмент работал бы как написано и заставлял чат отвечать «у меня нет этого» про десять лет данных о ходьбе, которые лежат в базе. Ни один тест бы это не поймал. Я заметил, когда вывел .length из скрипта и увидел 38 вместо ожидаемых 81.
Исправление — инвертировать join: идти от observations_daily, LEFT JOIN каталог, и некаталогизированная метрика появится с catalogued: false и null-единицей, а не пропадёт. Это стоит один агрегат по непрерывному агрегату, что по EXPLAIN ANALYZE укладывается в 100 мс — приемлемо для вызова раз за разговор. Общий принцип: справочная таблица не является индексом реальности, если что-то не гарантирует этого. Ничего не гарантировало. Сейчас тоже, но отсутствие видно в выводе.
Сырые строки из представлений выглядят так: 1370.9033333354562 и 187.9353333412348. Суммирование float даёт такое. Две проблемы, когда это доходит до языковой модели. Во-первых, ложная точность: весы, измеряющие с точностью до пятой фунта, не измеряли тринадцать знаков. Во-вторых, это чистые токены: восемнадцать символов вместо шести, на каждой точке каждого ряда.
Я округлил до двух знаков на границе инструмента, а не в слое запросов, по принципу: слой данных возвращает то, что хранит база, а слой инструмента уже отвечает за форматирование для модели. Сделано внутри единого хелпера json(), через который проходят все возвраты инструментов, так что новый инструмент не сможет забыть. Это сократило полезную нагрузку get_nutrition примерно на треть для семидневного окна и пропорционально больше для 90-дневного ряда.
Я не хочу переоценивать структурный аргумент: модель несколько раз превзошла ожидания. Я прогнал девять намеренно каверзных вопросов через скрипт проверки, чтобы результаты можно было воспроизвести. Три ответа содержали рассуждения, которых нет в промпте.
На вопрос о VO2max модель сообщила о восходящем тренде, но предупредила, что Apple вычисляет эту цифру на основе данных пульса при ходьбе и беге на улице, и что «оценка зависит от снижения веса, а не только от физической формы», сделав вывод «предположительно, но не окончательно». В промпте нет ни слова о VO2max.
На вопрос о застое в приседаниях модель разделила подходы T1 и T2, изучив веса и количество повторений — подсказка об уровнях, существующая в кодовой базе, не доступна ни одному инструменту.
На вопрос о сне модель прочитала показатели покрытия и отказалась оценивать 21 ночь. В этом ответе была ошибка, полностью моя, которую я заметил через час. Это последний раздел поста.
Последний случай — мой любимый, потому что он случайный. Я добавил поле days365, чтобы сделать индекс метрик честным о покрытии. Модель использовала его как общий фильтр «достаточно ли данных для ответа». Поле, добавленное по одной причине, пригодилось для лучшей — это стоит помнить, прежде чем урезать полезную нагрузку инструмента ради токенов. Структура ограничивает режимы отказа, но не ограничивает потенциал.
Очевидная следующая фича — кросс-доменная: влияет ли плохой сон на пропущенные тренировки, проявляется ли глубокий дефицит в маркерах восстановления. Каждый сигнал в приложении читает ровно один домен, и некоторые содержат советы, ссылающиеся на связи, которые код не проверяет.
Прежде чем писать что-либо, я проверил три самые очевидные гипотезы на реальной истории. Они не прошли проверку, и вот почему.
Единственный подъём, который правило застоя выделяет — тот, что оно должно игнорировать. Прогон stalling() без фильтра по 2545 подходам и 190 сессиям даёт ровно одно срабатывание: разгибание трицепса, застрявшее на 47.5 фунтах на две сессии. Это вспомогательное упражнение T3, и остановка на одном весе — это программа работает как задумано, а не застой. Именно тот ложный позитив, для подавления которого существует фильтр уровней, и с фильтром ничего не остаётся.
Правило перетренированности срабатывало один раз за девять лет. Я прогнал реальную функцию overreaching() по всей истории, а не аппроксимировал её математику в SQL, потому что получить правило слегка неправильным вручную — это то самое, на чём проект меня постоянно ловит. Из 2409 дней: ok — 2344, unknown — 48, watch — 16, и act — один раз, 25 апреля 2022. Ничего не выходило за ok с июня 2024.
Последний абзац — исправление. Мои заметки говорили, что act никогда не срабатывал, и я верил в это месяцы, потому что скрипт, создавший это утверждение, был разовым, я его не коммитил и не перезапускал. Оформление как npm run verify-signals заняло пятнадцать минут и сразу противоречило заметке. Сделайте проверку запускаемой — и она рано или поздно скажет вам то, что вы не хотели слышать.
Последовательное логирование питания началось 13 июля, так что четыре полные недели — вот с чем я бы коррелировал. Это анекдот в одежде выборки.
Поэтому в приложении нет кросс-доменных сигналов: событий, которые они бы коррелировали, в моих данных ещё нет. Записано как отложенное продолжение, а не закрыто, потому что гипотезы не отвергнуты — они просто неразрешимы на текущих данных. Я нахожу это более удовлетворительным, чем выпуск фичи: версия меня, пропустившая проверку, построила бы четыре сигнала корреляции, они бы вечно висели на unknown, и я бы не узнал ничего, кроме того, что страница шумная.
Две честные дыры, потому что пост о проверке, заканчивающийся чисто, — это пост, который недостаточно старался. Интерфейс вопросов никогда не рендерился в браузере. Расширение Chrome не могло внедриться в localhost, поэтому формат потоковой передачи проверен через curl и юнит-тесты декодера, и всё. Второе: я не проверял, как система поведёт себя с данными другого человека — все пороги калиброваны под меня.
Если вы строите ИИ-ассистента поверх своих данных, сделайте три вещи:
Это сэкономит вам месяцы самообмана.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →