Откуда это взялось

В IT я больше двадцати пяти лет, в Android - почти пятнадцать. Оказалось, что этого мало.

Опыт никуда не делся. Я по-прежнему знаю, чем интерфейс отличается от абстрактного класса и в чём разница между val и var. Но работать по-старому стало тяжело. В требованиях всё чаще стояло не знание Kotlin и Compose, а умение вести разработку с ИИ. Не "пробовал Copilot", а именно вести: ставить задачу, разбирать результат, замечать, где инструмент уверенно врёт.

Вместо привычных TDD, BDD, DDD и FDD вокруг всё чаще звучали AIDD и SDD. Дошло до того, что на работе в карточках JIRA появилось обязательное поле "уровень использования ИИ" со значениями "полностью выполнено агентом" и "частично выполнено агентом". Варианта “выполнено самостоятельно” там нет. Намёк понятен.

Назад пути не было, пришло время меняться.

Учиться по статьям смысла нет - там всё получается. Нужен был настоящий проект, достаточно большой, чтобы в нём было где ошибиться.

Тему выбрал не случайно. Хотел разобраться не только в том, как пользоваться ИИ-агентами, но и в том, как их делать. Так и вышло: агента я писал с помощью агента.

Получилось приложение. Через полгода оно лежит в Google Play, я пользуюсь им каждый день, и это уже давно не учебная работа. Дальше - как оно из неё выросло: через три смены курса (учебный проект стал продуктом, Gemini CLI сменился на Claude Code, маршрутизатор на два выхода стал графом), одну стену, в которую я въехал на полном ходу, и запуск, который не сработал.

Что это и зачем

Я живу в деревне, в другой стране. Интернет тут есть не везде. Телефон при этом всегда со мной - когда я не за столом, он и есть весь мой компьютер. Возможно, это профессиональная деформация, но для меня давно стало принципом: если что-то можно сделать на телефоне, я буду делать это на телефоне.

И вот нужна от модели самая обычная вещь. Пересказать длинный текст. Найти, где я про это писал. Вытащить из документа даты и суммы. Ответить на вопрос по собственным заметкам. То, что любая модель делает не задумываясь.

Только её нет. Не "медленно отвечает" - нет, пока не появится сеть.

Это первая половина. Вторая - то, что я и при работающем интернете никуда не отдам: переписка, заметки о людях, выписки из клиники, договор под NDA. Не потому что там страшная тайна, а потому что незачем.

Итог в обоих случаях одинаковый: задача не делается. Не медленнее - вообще никак.

Приложение, про которое дальше пойдёт речь, решает это одним способом: модель работает на самом телефоне. Движок - LiteRT-LM, надстройка для языковых моделей над LiteRT, наследником TensorFlow Lite. Модель - Gemma 4 E4B, для телефонов послабее есть E2B. Файл весит около 3.4 ГБ и качается один раз, дальше нужно 2+ ГБ свободной памяти. У меня на Galaxy S25 Ultra с GPU так:

В этом режиме разговор устройство не покидает - ему просто некуда деваться. Ниже будут облачные модели, MCP и HTTP-запросы, так что оговорюсь заранее: всё это появляется только после того, как вы сами впишете ключ, адрес сервера или список доменов. Пока не вписали, для приложения этого не существует.

И ещё одно, потому что словосочетание "ИИ-агент" рисует что-то с руками в вашем телефоне. Сценарий, который я описал, не требует ни одного инструмента: модель читает и отвечает, больше ничего. Что агент умеет и кто решает, что ему можно, будет ниже, там же, где про граф.

Шесть недель

Первый коммит - 2 марта.

Дальше по порядку: каркас и архитектура, движок на LiteRT-LM и управление моделями, память и контекст, инструменты и интеграция с ОС, экран чата, фоновая работа, оркестрация, стабилизация, мультисессионность, "умные" пайплайны, разбор техдолга, переработка визуального редактора пайплайнов.

Двенадцать фаз. Двести семьдесят шесть коммитов. Шесть недель.

Я выписал это списком нарочно, потому что список выглядит странно. Для учебного проекта тут перебор. Но я и не садился строить приложение - я разбирался, как ведётся разработка с агентом, а приложение было полигоном. Полигон разросся сам.

Что я вынес за эти шесть недель, если коротко: задачу надо ставить точно, а каждый шаг проверять. Звучит банально ровно до первого раза. Gemini CLI был любителем заглушек и тестов, которые ничего не проверяют. Не раз бывало так: задача закрыта, отчёт бодрый, а реализации нет. Он создавал структуру класса, а внутри методов стояло "а потом когда-нибудь добавим вот это вот". Тест к этому прилагался. Зелёный.

Темп при этом был примерно такой, какого я и ждал. Я был наслышан, чего нынче хотят работодатели, так что двенадцать фаз за шесть недель меня не удивили.

16 апреля

К середине апреля я разобрался в том, за чем пришёл. И оказался перед выбором, которого не планировал: на руках было приложение, которое уже жалко выбрасывать.

Учебный проект в этот момент положено закрыть - своё он отработал. Я решил иначе: растить дальше, во что-то полезнее учебной работы. И в тот же день сменил инструмент, с Gemini CLI на Claude Code.

Главной причиной были те самые заглушки. Разбирать за агентом, что сделано на самом деле, а что только обещано, выходило дороже, чем написать самому. Claude Code оказался в этом надёжнее - тоже иногда забывает важное, но заметно реже. Плюс там было куда расти и что изучать дальше.

Дату можно не вспоминать, она видна в истории репозитория. 16 апреля, ровно на границе двенадцатой и тринадцатой фазы. До неё 276 коммитов, после - 1336.

Как маршрутизатор стал поведением

Начиналось всё просто. Есть локальная модель, есть облачная, надо выбирать между ними по ситуации. Маршрутизатор на два выхода, делов на вечер.

Дальше каждый следующий кусок появлялся не потому, что я хотел добавить фич, а потому что ломался предыдущий. Расскажу по порядку, это самая полезная часть.

Модель на 2-4 миллиарда параметров разваливается на многошаговых задачах. Не медленно отвечает - отвечает неправильно.

Первый же случай был совсем простой: сравнить два факта. Или просто найти два разных факта в рамках одной задачи. Модель шла в поиск одним запросом, сразу про всё, и получала мусор. Ответ она потом строила на этом мусоре, и звучал он уверенно.

Та же задача работает, если разложить её на два поиска, выполнить их по очереди и сравнить результаты. Разница не в качестве модели, а в том, дали ей посильный шаг или нет.

Отсюда первый ход: разбить задачу на шаги и дать каждому шагу отдельную ноду. Модели больше не нужно держать всю задачу целиком - её держит граф. Отсюда же взялась и отдельная нода декомпозиции: разбивать умеет не только человек за редактором.

Нода, которая должна выдать структуру, иногда её не выдаёт. Роутер обязан вернуть класс, парсер обязан вернуть JSON. Модель на 2B об этом иногда забывает, и прогон падал целиком.

Второй ход - гейт валидации. Если ответ не распарсился, нода получает вторую попытку, и в промпт ей дословно кладётся текст ошибки: не "сделай правильно", а что именно оказалось не так. По умолчанию таких попыток две, настраивается от нуля до четырёх.

К пятой ноде промпт тащит за собой всё, что случилось раньше. Маленькая модель в этом тонет, и качество падает не там, где ошибка, а через два шага после неё.

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

Промпту нужны свежие значения. Дата, время, список активных инструментов, язык устройства. Захардкодить их - значит соврать на следующем же запуске.

Четвёртое - переменные в промптах, девять штук, подставляются на каждом прогоне заново.

Модель не умеет переспросить. Там, где человек уточнил бы, она угадывает, и двусмысленность превращается в уверенно неправильный ответ.

Пятое - нода уточняющего вопроса. Прогон останавливается и спрашивает.

Графы стали большими и повторяющимися. Одни и те же куски копировались из пайплайна в пайплайн.

Шестое - композиция. Пайплайн можно вставить в другой пайплайн как ноду, а набор инструментов с их ограничениями оформить скиллом.

Типов нод в итоге четырнадцать. Вот на этом месте оно и перестало быть просто маршрутизатором: граф описывает уже не "куда пойти", а что вообще происходит.

Кто решает, что агенту можно

Обещал наверху вернуться к этому - возвращаюсь, потому что вопрос задают первым.

Из четырнадцати типов нод вызвать хоть что-нибудь умеют две: нода инструмента и нода скилла. Вывод обычной модельной ноды на предмет вызовов даже не разбирается - там нечего перехватывать. Пайплайн из модели и выхода не может сделать ничего, кроме как ответить текстом.

Из пятнадцати пайплайнов, которые идут в комплекте, девять не содержат ни одной такой ноды.

Теперь про всё, что смотрит наружу. Облачная модель работает только на вашем ключе: нет ключа - нет облачной ноды, маршрутизатору не из чего выбирать. MCP-сервер - только тот, что вы добавили сами. Инструмент исходящих HTTP-запросов вообще не показывается модели, пока вы не заполните список разрешённых доменов, до этого он для неё не существует. Отчёты о крашах в сборке из Play выключены по умолчанию, а если включить, в них нет ни промптов, ни сообщений. Есть и отдельная сборка без проприетарных зависимостей: в ней Firebase нет вовсе, и тумблера тоже - репортить нечем.

А когда вы всё-таки собрали граф, который что-то делает, всё разрушительное и чувствительное останавливается и ждёт вашего подтверждения. В том числе когда пайплайн сработал в фоне по триггеру - тогда подтверждение приходит уведомлением.

Доступ к действиям - это свойство графа, который собрали вы, а не свойство приложения, которое вы поставили.

Стена

Дальше про место, где я въехал на полном ходу.

Инструменты агенту нужны - уметь не только говорить, но и делать. В Android для этого есть AppFunctions: приложение объявляет функции, другое приложение их находит и вызывает. Ровно то, что нужно: дотянуться до того, что уже стоит на телефоне.

В локальном Android-ИИ я тогда был не в теме и, не потратив времени на изучение этого нового компонента Jetpack, сразу сел реализовывать. Написал сторону вызывающего, сторону вызываемого, разбор аргументов, диспетчеризацию. Ничего не работало.

Дальше было долго. Я перечитывал документацию, перепроверял манифест, менял версии, писал минимальные примеры. Всё было сделано правильно - и не работало. Это худший вид отладки: когда ошибки нет, а результата тоже нет.

Потом выяснилось, почему. Объявить свои функции может любое приложение - с этой стороны всё открыто, и гайдов "как показать свои функции Gemini" уже хватает. Закрыта другая сторона. Чтобы найти и вызвать чужую функцию, нужно разрешение EXECUTE_APP_FUNCTIONS, а оно объявлено так, что достаётся предустановленным приложениям и системным модулям. Приложению из Play его не выдадут - ни на одном стоковом образе. Мой код был верным. Неверной была предпосылка - что вызывающей стороной может быть кто угодно, а не только то, что стоит в системе с завода.

Обходом стал MCP: инструменты, которые агент берёт с сервера, а не из соседнего приложения. Работает, но это не то, ради чего затевалось: смысл AppFunctions был в том, чтобы дотянуться до приложений, которые уже стоят у вас на телефоне.

Отложено до времён, когда и если разрешение откроют.

Но настоящая цена была не в выброшенной ветке.

Из той же истории я вынес, что для AppFunctions нужен Android 16. И минимальная поддерживаемая версия приложения так и стояла - Android 16 - почти всю жизнь проекта. Это отрезает всех, у кого телефон старше.

Проверил я это предположение только в августе. Оказалось, что шестнадцатую не требует ничего: ни зависимости, ни движок вывода, ни одна строчка моего кода. Порог опустился до Android 14, на две версии вниз, и ничего не сломалось.

Урок здесь не про Android, и он мне не нравится. Прыжок в реализацию до изучения платформы стоит не отладки - он стоит выброшенной ветки. А потом предположение, из-за которого всё началось, переживает своё опровержение и продолжает брать с вас плату месяцами, пока вы его наконец не измерите.

Гонка и проверки

Отдельная статья расходов, о которой мало пишут: всё, на чём это построено, двигается под ногами. Kotlin, Android Gradle Plugin, Hilt, Koog, сам движок LiteRT-LM. Обновляться надо, иначе через полгода не соберёшься вообще, но каждое обновление - это отдельная задача, а не строчка в конфиге.

Дальше четыре вещи, которые мне это объяснили.

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

Второе: в отладке всё хорошо, у пользователей всё плохо. Долговременная память не работала в релизных сборках. Во всех релизных сборках. Причина - оптимизатор R8: класс, который я доставал рефлексией, он посчитал ненужным и сделал абстрактным. В отладочной сборке R8 не работает, поэтому у меня всё было прекрасно. Ровно так же вылезал и падающий релиз на flogger.

Это, пожалуй, главное отличие от того, к чему привыкаешь на учебном проекте. Учебный проект вы запускаете сами и у себя. Разница между "работает" и "работает у людей" - это целый класс ошибок, которого вы не увидите, пока не начнёте выпускать релизы всерьёз.

Третье: удалить кнопку не значит выключить функцию. Я убрал из настроек переключатель аналитики - и обнаружил, что сбор от этого не прекратился: сохранённый флаг оказался старше манифеста и продолжал действовать. Для приложения, у которого приватность стоит в описании, это не мелочь. Отсюда и выросли проверки.

Тот самый урок второй недели - ставить задачу точно и проверять каждый шаг - со временем перестал быть моей личной дисциплиной и переехал в сборку. Сейчас одна команда прогоняет всё разом: статический анализ с разрешением типов, форматирование, линтер Android, порог покрытия тестами, архитектурные тесты. Плюс девятнадцать отдельных проверок, которые я дописывал под конкретные грабли: сверка сгенерированных файлов с исходниками, битые ссылки в документации, расхождение версий между четырьмя местами. И инструментальные тесты на эмуляторах в CI.

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

И тут же честная оговорка, потому что иначе это звучит слишком гладко. Проверки тоже надо проверять. У меня три теста сборочной логики просидели красными через два слияния - просто потому, что они лежали в отдельной сборке, до которой основная команда не дотягивалась. Всё было зелёное. Тесты падали.

И четвёртое, про чужие умолчания. Облачные провайдеры подключены через библиотеку, у которой есть настройки таймаутов. Я их не трогал - по умолчанию же разумно. Оказалось, что запрос к зависшему провайдеру живёт при этих умолчаниях пятнадцать минут: замерил, получилось 900 033 миллисекунды. Пятнадцать минут приложение выглядит думающим, хотя на том конце давно никого нет.

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

Что получилось

Сейчас это сорок три фазы и полгода по календарю. Приложение лежит в Google Play, исходники открыты под Apache 2.0. Я пользуюсь им каждый день - и это, честно говоря, единственная гарантия сопровождения, которую соло-разработчик может дать, не соврав.

Называется оно Knotwork. Имя я выбрал за образ переплетённого узора - граф, узлы, связи. Мне до сих пор непонятно, читается это кем-нибудь кроме меня или нет.

Теперь про запуск.

В начале сентября я написал про это в r/LocalLLaMA, крупнейшем месте, где сидят люди, гоняющие модели локально. Пост собрал 13 тысяч просмотров и рейтинг ноль. Прокомментировали трое. Разговор по существу вышел ровно с одним человеком. Он сказал: без нормальной виртуализации он не станет держать агента на телефоне, которым пользуется каждый день. Только на запасном, в порядке эксперимента.

Сначала я прочитал это как отказ. Потом перечитал: это и есть ответ на вопрос, с которым я туда пришёл. Я делал помощника для человека, который не отдаёт своё наружу - то есть для того, у кого настройка по умолчанию "не доверять". И получил от него ровно то, что этой настройкой предусмотрено: он не пустит агента внутрь, пока не сможет проверить, что тот заперт. Недоверие не останавливается на границе сети.

Что у меня под этот запрос есть, а чего нет.

Есть. Каждый прогон пишет трейс: какая нода что получила, что вернула, какой инструмент дёрнула, - это видно в консоли прогона. Всё разрушительное ждёт подтверждения. HTTP-инструмент не существует без вашего списка доменов, облако - без вашего ключа, MCP - без вашего сервера. Есть сборка без проприетарных зависимостей, в ней Firebase нет вовсе.

Нет. Сборки, у которой нет сети вообще. Модель на 3.4 ГБ надо откуда-то скачать, разрешение INTERNET в манифесте стоит, и поэтому "ничего не уходит" вы сегодня проверяете по исходникам и на слово, а не снаружи по факту. Инструменты выполняются в процессе приложения, без собственной песочницы: агент заперт ровно настолько, насколько заперто само приложение.

Вариант с ручной загрузкой файла модели и без INTERNET в манифесте - похоже, самый дешёвый из того, чем на слова того человека можно ответить делом. Я его пока не сделал.

Вторая половина того же - ещё смешнее. Один из FOSS-сабреддитов, куда я собирался идти следом, недавно завёл правило: приложения, сделанные с существенным участием ИИ, там запрещены. Примерно половина моих коммитов несёт след модели, я этого не скрываю и написал модераторам сам, до публикации. Ответа так и не получил.

Складывается симметрично. Я пошёл учиться агентской разработке, потому что этого стал требовать рынок. Написал агента с помощью агента. И встретил два сообщества, одно из которых не приняло то, что я построил, а второе - то, чем я строил. Отвергнуты обе половины одного и того же занятия.

Поэтому вопрос, с которым я сюда пришёл, не "как их переубедить". Переубеждать - плохая цель, да и незачем: их позиция последовательна, и я её понимаю. Вопрос проектный, и после Reddit он стал конкретнее.

Что должен показать локальный агент, чтобы человек с настройкой "не доверять" поставил его на основной телефон, а не на запасной? Хватит ли сборки без сети и полного журнала действий - или нужна изоляция, которую приложение само себе дать не может, и тогда это вопрос к платформе, а не ко мне?

Я спрашивал похожее там же, в треде, и ответа не получил. Подавать чужое молчание как значимое было бы нечестно, так что просто спрошу ещё раз, здесь.


Исходники, сборки и документация - ссылка ниже. Приложение бесплатное, без рекламы и без аккаунта; я ничего не продаю и пришёл не за установками.

https://github.com/alexeyw/knotwork