В моем приложении для управления финансами была кнопка “Показать Кубышу” Она раскладывала накопления по целям: сколько положить в подушку, сколько в отпуск, сколько в машину. Работала через LLM. У части людей из за белых списков вместо ответа она показывала «работает только с VPN»
Я переписал ее на обычный алгоритм. Он умещается в один файл, работает детерменировано, без сети и отвечает мгновенно. Ниже почему модель там была лишней, как устроен алгоритм и где модель в приложении осталась.
Что за кнопка
Для контекста, коротко про Кубыш. Приложение строит план от получки до получки, собирает все накопления на одном счете, а в приложении они разложены по целям, как по конвертам. У каждой цели указывается сумма, приоритет и желаемая дата, а далее приложение пересчитывает прогнозную дату по целей с учетом их параметров.
Когда целей несколько, возникает вопрос: как оптимально разложить уже накопленное, чтобы все успели к своим датам или как сколько оставить, чтобы высвободить немного денег для хотелок. Для этого и была кнопка. Модель получала список целей, суммы и даты и предлагала раскладку.
Что пошло не так
1. Доступность. Модель работает на внешнем сервисе. На мобильном интернете в России внешние сервисы доступны не всегда, и человек без VPN получал ошибку (в последствии проблему белых списков победил)
2. Один вопрос, разные ответы. Нажал кнопку дважды, получил две разные раскладки. В тексте это нормально, в деньгах подрывает доверие: какой из двух ответов правильный?
3. Ответ нельзя проверить тестом. Нельзя написать тест «модель разложила правильно» Можно только смотреть глазами или готовить Датасеты, но всех кейсов покрыть почти невозможно.
4. Даты не совпадали с остальным приложением. Дату каждой цели в Кубыше считает календарный движок: по доходу, премиям и плану пополнений. Модель называла свои даты, и они расходились с карточками целей на соседнем экране.
Это не задача для модели
Если убрать слова, задача оказывается оптимизацией с двумя режимами:
-
«Достичь целей быстрее»: отдать деньги целям по очереди, чтобы первые в очереди собрались как можно раньше
-
«Высвободить деньги на хотелки»: положить в каждую цель минимум, при котором она успевает к желаемой дате. Остальное остается свободным, его можно потратить на себя.
Очередь та же, что в календарном движке: сначала приоритет (критичная, важная, хотелка), потом ближайшая желаемая дата внутри приоритета. Если очередь будет своя, раскладка и даты снова разойдутся.
Минимальная сумма. Чем больше денег уже лежит в цели, тем раньше она собирается. Дата монотонно зависит от суммы, значит, минимум можно найти бинарным поиском. Точность до рубля не нужна, шаг 1 000 ₽:
/// Наименьшая сумма (шаг 1 000 ₽), при которой цель успевает к желаемой дате.static func minimalAmount(upTo cap: Double, index: Int, goals: [Goal], target: Date, forecast: ([Goal]) -> [UUID: Date]) -> Double { func onTime(_ amount: Double) -> Bool { var probe = goals probe[index].currentAmount = amount guard let date = forecast(probe)[probe[index].id] else { return amount >= probe[index].targetAmount } return date <= target } guard !onTime(0) else { return 0 } // успевает и без денег guard onTime(cap) else { return cap } // не успевает даже со всеми var low = 0.0, high = cap while high - low > 1_000 { let mid = ((low + high) / 2 / 1_000).rounded() * 1_000 if mid <= low || mid >= high { break } if onTime(mid) { high = mid } else { low = mid } } return high}
static func minimalAmount(upTo cap: Double, index: Int, goals: [Goal], target: Date,
forecast: ([Goal]) -> [UUID: Date]) -> Double {
func onTime(_ amount: Double) -> Bool {
var probe = goals
probe[index].currentAmount = amount
guard let date = forecast(probe)[probe[index].id] else { return amount >= probe[index].targetAmount }
return date <= target
}
guard !onTime(0) else { return 0 } // успевает и без денег
guard onTime(cap) else { return cap } // не успевает даже со всеми
var low = 0.0, high = cap
while high - low > 1_000 {
let mid = ((low + high) / 2 / 1_000).rounded() * 1_000
if mid <= low || mid >= high { break }
if onTime(mid) { high = mid } else { low = mid }
}
return high
}
Один источник дат. forecast передается снаружи, и это та же функция, которая считает даты на карточках целей. Поэтому дата после раскладки совпадает с датой, которую человек увидит на соседнем экране. Чтобы никто не посчитал даты в обход, есть тест-сторож: он падает, если где-то появляется второй расчет прогноза.
Что получилось
-
работает предсказуемо, без сети, в том числе на мобильном интернете без VPN
-
отвечает мгновенно
-
один и тот же вход дает один и тот же ответ
-
покрыт тестами: цель без даты, посильная дата, непосильная, очередь, премия.
Где модель осталась
Там, где нужен текст, а не число: инсайт дня, итоги недели, совет, что сделать дальше. Правила такие:
|
Задача |
Кто делает |
|---|---|
|
Сумма на день, даты целей, раскладка денег |
код |
|
Хватит ли до получки, на сколько дней |
код |
|
Объяснить цифру простыми словами |
модель |
|
Совет на завтра, тон, разрешение порадовать себя |
модель |
|
Нет сети |
запасной текст-шаблон |
Пять правил, к которым я пришел
-
Цифры считает код. Модель получает их уже посчитанными и не придумывает новые.
-
Один источник для каждой цифры. Если модель называет дату, это дата из движка, а не ее собственная.
-
У каждого текста модели есть запасной шаблон на случай, если сети нет.
-
Если результат можно проверить тестом, это задача для кода.
-
Если ошибка в цифре стоит человеку спокойствия, модель туда не пускаем.
Расскажите где в ваших продуктах/проектах ИИ оказался лишним? А где после его внедрения реально получилось улучшить результаты?
Автор: popov_kirill_a


