За последнее время моя работа заметно изменилась (конечно же из-за LLM). Раньше я писала код руками, а теперь все задачи решаю через агентов. Поэтому мне стало интересно немного копнуть вглубь и разобраться, как такие системы устроены внутри. Я не ML-инженер, так что сейчас смотрю на это лишь с тем базовым пониманием, которое у меня есть.
Начать я решила с кэширования промптов. Почему запрос переиспользуется только с начала, как кэш устроен в памяти и с помощью чего мы можем на это влиять (если вообще можем).
Кэширование промптов: почему порядок в контексте решает
🔗 Link for desktop
⚡️ Link for mobile (Instant View)
Дайте знать, насколько вам было бы интересно и дальше поразбираться вместе со мной в устройстве LLM и движков, на которых они работают.
В постах про using мы разбирали, как освобождение ресурса привязывается к границам блока. Но у этого подхода есть предел: объявлений должно быть столько же, сколько ресурсов, и все они должны быть известны на момент написания кода. Для случаев, когда ресурсов заранее неизвестное количество, в стандарте есть DisposableStack и его асинхронный вариант AsyncDisposableStack.
Допустим, надо открыть файлы по списку путей и поработать со всеми сразу. Написать объявление в цикле не выйдет, потому что тело цикла — это отдельная область, и файл закроется на своей же итерации, а не в конце.
Стек решает это тем, что накапливает ресурсы по ходу дела, но сам при этом подчиняется тому же протоколу. То есть объявляется через using, и когда блок заканчивается, освобождает всё накопленное в обратном порядке.
await using stack = new AsyncDisposableStack();
const files = [];
for (const p of paths) {
const file = stack.use(await open(p));
files.push(file);
}
// работаем со всеми файлами
Метод use() кладёт объект в стек и возвращает его же, поэтому вызов удобно писать прямо вокруг выражения, которое ресурс создаёт, то есть так stack.use(await open(p)). В документации советуют не разносить получение ресурса и регистрацию на две строки, чтобы между ними не появился код, который может упасть. Ведь в этот момент стек ещё не знает про ресурс, а значит, не может его закрыть.
Также у стека есть метод adopt(). Он принимает значение и функцию для очистки, которая получит это значение аргументом:
Таким образом, стек оказывается универсальнее самого using. Для using объект обязан иметь [Symbol.dispose], а стеку достаточно передать функцию для закрытия. Поэтому стек может иметь смысл даже для единственного ресурса, если у того нет нужного протокола:
using stack = new DisposableStack();
stack.adopt(handler, h => emitter.off('data', h));