Назад к блогу

Параллельные запросы к Gemini из Google Таблиц: как обойти лимиты Apps Script

Параллельные запросы к Gemini из Google Таблиц: как обойти лимиты Apps Script

Ускорение обработки данных в Google Таблицах через параллельные запросы к Gemini — это способ обойти ограничения Apps Script по времени выполнения. Автор разбирает архитектуру системы, где сочетание пакетной обработки и параллелизма позволяет сократить общее время ожидания и не упереться в лимиты API при работе с сотней RSS-источников.

Два разных приёма: пачка внутри запроса и параллельные запросы

Два приёма часто ставят рядом, хотя задачи у них разные. Пачка внутри запроса отвечает на вопрос «сколько раз мы дёргаем API». Параллельные запросы — на вопрос «сколько мы ждём».

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

Параллельная отправка сокращает именно эту сумму. Когда несколько независимых пачек уходят одновременно, общее ожидание приближается к длительности самого медленного запроса, а не к их сумме. Число обращений к модели при этом не меняется: пять пачек — это по-прежнему пять запросов, просто они стартуют вместе.

В скрипте это две отдельные константы:

const BATCH_SIZE = 10;
const LITE_PARALLEL_BATCHES = 5;

BATCH_SIZE задаёт, сколько анонсов склеивается в один запрос. LITE_PARALLEL_BATCHES — сколько таких запросов допускается отправить одной группой. Одна группа вмещает до 50 анонсов: пять независимых запросов по десять. При этом каждый запрос получает свой контекст — модель, обрабатывающая первую пачку, не видит остальные четыре.

Практический смысл различия в том, что настраивать их нужно по разным причинам. Размер пачки ограничен окном контекста и вашей способностью проверить ответ: чем больше анонсов в одном запросе, тем больше шансов получить решение, которое придётся отбрасывать целиком. Число параллельных пачек ограничено не контекстом, а квотой на запросы в минуту. Если лимит позволяет всего три обращения в текущем окне, группа собирается из трёх запросов, а не из пяти, — остальные анонсы ждут следующей возможности. То есть один приём экономит обращения, второй — время, и подставлять один вместо другого не выйдет.

Схема этапов: от RSS до очереди и логов

Вся система держится на обычной Google Таблице. Четыре листа делят работу между собой: RSS хранит список источников (у автора их около сотни), NewsInbox принимает анонсы до отбора, NewsQueue содержит отобранные статьи вместе с готовыми черновиками, а Log фиксирует уже обработанные ссылки. Такое разделение позволяет не перепутать стадию: анонс лежит в одном листе, готовый пост — в другом.

Серверного процесса здесь нет. Скрипт просыпается по триггеру, обрабатывает очередную порцию данных и засыпает. Если работа не уложилась в текущий запуск, скрипт ставит себе добавочные «будильники» и продолжает позже. Слово «сервер» в описании отражает роль системы — она автономно принимает входные данные и выдаёт результат на чужом железе, — а не тип хостинга.

Модели тоже разделены по задачам. Gemini 3.5 Flash-Lite оценивает анонс: подходит он под тему или нет. У неё лимиты RPM 15 и RPD 500 — запросов в минуту и в сутки соответственно. Gemini 3.8 Flash берёт уже отобранные статьи и превращает их в посты; её лимиты заметно строже — RPM 5 и RPD 20. Генерация во Flash объединяет несколько статей в один запрос, но эти запросы идут последовательно. Параллельность касается только этапа Lite.

Очереди решают две разные проблемы: обрыв запуска по времени и сбой API. Анонс сохраняется в таблицу до отбора, а готовый пост — до отправки в Telegram. Если сообщение не ушло, не нужно заново просить модель написать тот же текст: черновик уже лежит в NewsQueue.

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

Почему последовательный Lite не успевает за 6 минут

Вот фрагмент реального отчёта, снятого прямо из работающего скрипта: Оценено Lite: 10, Ожидают Lite (NewsInbox): 22, Время: подготовка 3 с; RSS 126 с; Lite 92 с, Перенос: Закончено время для Lite. Двадцать два анонса стоят в очереди, оценку прошли только десять — и всё из-за того, что этап Lite занял больше полутора минут.

Это не чистый бенчмарк модели. В отчёте измеряется время этапов самого скрипта, но для вас результат всё равно конкретный: часть анонсов снова остаётся ждать следующего запуска. Сбор RSS забрал 126 секунд, оценка — 92 секунды, и эти значения складываются, а не пересекаются.

Здесь и вступает ограничение Apps Script на время выполнения — 6 минут на один запуск (квоты и лимиты). Простая арифметика: подготовка, сбор и оценка уже съели больше двух минут, а триггеру ещё нужно прочитать таблицы, сохранить решения, поставить «будильники» на продолжение. Задержка внешнего API бьёт не только по тому, когда вы увидите результат, но и по объёму работы, который вообще помещается в один запуск. Экономия 80 секунд здесь — не косметика, а разница между «успел» и «перенёс».

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

// batches - заранее подготовленные пачки анонсов.
for (const batch of batches) {
  const request = makeLiteRequest(batch);
  const { url, ...options } = request;

  const response = UrlFetchApp.fetch(url, options);
  // Пока ответ не получен, следующая пачка не отправляется.
  const decisions = readLiteAnswer(response, batch);

  // Здесь сохраняем решения по текущей пачке.
}

Между пачками нет зависимости. Чтобы оценить новости № 11–20, модели не нужен результат по № 1–10: критерии отбора одинаковые, тексты разные. Значит, эти ожидания можно перекрыть — о том, как это устроено, дальше.

Группировка анонсов: BATCH_SIZE, splitIntoBatches и границы maxArticles

Десять анонсов в одном запросе — это не десять обращений к модели, а одно. Проверка на стороне скрипта устроена так: константы BATCH_SIZE = 10 и LITE_PARALLEL_BATCHES = 5 задают размер пачки и число пачек, которые уходят одним групповым вызовом. Перемножьте — получите потолок в 50 анонсов на группу.

Функция splitIntoBatches(articles) работает в два шага. Сначала считает maxArticles = BATCH_SIZE * LITE_PARALLEL_BATCHES и сравнивает с длиной входного массива. Если анонсов больше, выбрасывается ошибка с текстом вида «За одну группу принимаем до 50 анонсов» — в сообщение подставляется именно вычисленный maxArticles, а не жёстко прописанное число. Дальше массив нарезается циклом с шагом BATCH_SIZE через articles.slice(i, i + BATCH_SIZE).

Как это выглядит на конкретных числах. На входе 50 новостей — на выходе пять массивов ровно по 10. На входе 23 — три массива: 10, 10 и 3. Последняя пачка короче, и это нормально: модель получает по промпту на каждую пачку, а внутри промпта статьи нумеруются локальным id от 0. Поэтому в ответе по третьей пачке будут только ID 0, 1 и 2, а не 20, 21, 22.

Сетевых вызовов внутри splitIntoBatches() нет — это чистая подготовка массивов. Запросы строятся отдельно, а отправляются уже группой. Ограничение maxArticles существует не ради красоты кода: оно не даёт случайно скормить одной группе больше пачек, чем предполагает схема с пятью параллельными запросами.

Отсюда практическое следствие для вашего пайплайна: если источник отдаёт, скажем, 60 свежих анонсов за раз, вы не можете просто увеличить входной массив. Нужно либо разбить работу на несколько групп, либо поднимать LITE_PARALLEL_BATCHES с оглядкой на квоты. Вторая опция упирается в лимиты запросов в минуту и в сутки у выбранной модели Lite, поэтому рост числа пачек почти всегда означает и пересчёт бюджета обращений.

fetchAll(): перекрытие ожиданий и пределы ускорения

Пять вызовов UrlFetchApp.fetch() подряд выстраиваются в очередь: пока не придёт ответ на первую пачку, вторая не отправляется. Между пачками нет зависимости — для оценки новостей № 11–20 не нужен результат оценки новостей № 1–10. Критерии отбора одни и те же, тексты разные. Именно это ожидание и перекрывает fetchAll().

Как это выглядит в коде. Пачки уже нарезаны, для каждой строится описание запроса через makeLiteRequest(batch). Дальше вместо цикла — один вызов:

const requests = batches.map(makeLiteRequest);
responses = UrlFetchApp.fetchAll(requests);

fetchAll() принимает массив описаний запросов и допускает перекрытие ожиданий на сетевом уровне. Ответы возвращаются тем же порядком, что и запросы, поэтому сопоставление идёт по индексу: batches[index] соответствует responses[index]. Внутри пачки статья определяется по полю id, которое вернула модель. Так, id: 0 из второго ответа — это первая статья второй пачки, а не первая статья всего списка.

Важно не перепутать масштаб. Это не пять параллельных экземпляров скрипта. JavaScript продолжит работу только после возврата из fetchAll(): нарезка пачек, сборка промптов и разбор ответов выполняются последовательно внутри одного запуска.

Ограничения честнее проговорить сразу.

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

Ускорение не гарантировано. Метод не обещает ровно пять одновременных серверных обработчиков и не даёт ускорения в пять раз. Вы задаёте лишь размер отправляемой группы; фактическое исполнение контролируют Google и провайдер модели. При ограничениях на стороне сервиса запросы могут встать в очередь, и выигрыш окажется меньше ожидаемого.

Квота не уменьшается. fetchAll() с пятью элементами — это всё те же пять запросов к Gemini, у каждого свои входные токены и свой ответ. Метод не экономит лимиты, он экономит время.

Сбои. Если исключение выбросил сам групповой вызов и массива ответов нет, все анонсы остаются в очереди — но это не доказывает, что провайдер не получил запросы, поэтому попытки нельзя просто вычесть из счётчика квоты. Если же вызов вернулся, а отдельная пачка — например, с HTTP 503, — выбрасывать результаты соседних не нужно: проверяйте каждую пачку независимо. При включённом muteHttpExceptions: true сетевой метод не бросает исключение на клиентские и серверные ошибки — обработка статус-кодов (включая 429 и 503) целиком переносится в разбор ответа.

Разбор ответов: readLiteAnswer и строгие проверки

fetchAll() вернул массив ответов, ошибок транспорта нет — и на этом месте хочется расслабиться. Рано. Код ответа 200 говорит только о том, что HTTP-обмен состоялся. Что внутри — модель ответила осмысленно, вернула решения по всем анонсам, не перепутала ID — из статуса не видно.

Пример: пять пачек ушли одной группой, четыре вернули 200, пятая — 503. Ещё в одной тело пришло валидным JSON, но решений оказалось меньше, чем входных анонсов. Ещё в одной модель прислала один и тот же id дважды. Если проверять только код, эти пачки молча попадут в очередь как обработанные.

Поэтому ответ разбирается до записи в таблицу, и разбор устроен как набор проверок. Сначала — статус вне диапазона 2xx:

const status = response.getResponseCode();
if (status < 200 || status >= 300) {
  throw new Error(`Gemini: HTTP ${status}`);
}

Дальше — тело. Бывает, что 200 приходит вместе с объектом error внутри JSON: тогда статус формально успешен, а по смыслу это отказ.

const body = JSON.parse(response.getContentText());
if (body.error) throw new Error("Gemini вернула ошибку API");

Затем — признак завершённости. У кандидата есть finishReason, и успешным считается только STOP. Значения вроде MAX_TOKENS означают, что модель остановилась на середине: JSON может оказаться обрезанным, и часть решений просто отсутствует.

const candidate = body.candidates?.[0];
if (candidate?.finishReason !== "STOP") {
  throw new Error("Нет завершённого ответа модели");
}

Теперь текст. Его собирают из частей ответа, отбрасывая служебные блоки (part.thought) и оставляя только строки:

const text = (candidate.content?.parts || [])
  .filter(part => !part.thought && typeof part.text === "string")
  .map(part => part.text)
  .join("");

Схема ответа — массив объектов с полями id и shouldProcess. Схема гарантирует форму, но не содержание: модель может прислать меньше решений или повторить ID. Поэтому длину сверяют с числом анонсов в пачке, а сами решения прогоняют через проверку каждого элемента:

const decisions = JSON.parse(text);
if (!Array.isArray(decisions) || decisions.length !== batch.length) {
  throw new Error("Число решений не совпало с числом анонсов");
}

const seen = new Set();
return decisions.map(item => {
  if (!item || !Number.isInteger(item.id) ||
      item.id < 0 || item.id >= batch.length ||
      seen.has(item.id) || typeof item.shouldProcess !== "boolean") {
    throw new Error("Некорректное или повторное решение");
  }
  seen.add(item.id);
  return { url: batch[item.id].url, shouldProcess: item.shouldProcess };
});

Каждое условие здесь закрывает свой сценарий. Number.isInteger(item.id) отсекает строки и null. Границы 0 <= id < batch.length ловят ID, которых во входной пачке нет, — модель иногда «вспоминает» лишний. seen не даёт одному и тому же анонсу получить два разных вердикта. typeof item.shouldProcess !== "boolean" не пускает строки вроде "true".

Проверки идут по пачкам независимо друг от друга — это принципиально. Если четыре запроса из пяти прошли, а пятый упал, результаты четырёх не выбрасывают. Успешные решения уходят в таблицу, необработанные анонсы остаются в NewsInbox и попадают в следующую группу. При muteHttpExceptions: true сетевой метод не бросает исключение на 4xx и 5xx, так что вся логика статусов сосредоточена в одном месте — в разборе ответа, а не размазана между транспортом и парсингом.

Строгая проверка «одно решение на каждый анонс» — не единственный вариант. Её можно ослабить и сохранять корректную часть пачки, оставив в очереди только пропущенные статьи. Но тогда появляется новая забота: держать в консистентном состоянии очередь с частично обработанной пачкой. Начинать проще со строгого варианта — он заметно сокращает число мест, где что-то может тихо разойтись. Актуальные схемы ответа Gemini описаны в документации по структурированному выводу.

Квоты, LockService и повторные попытки при 429 и 503

Сколько запросов реально можно отправить в одной группе? Не пять «по умолчанию» — а столько, сколько разрешает минутное окно. У Lite в моём аккаунте было 15 запросов в минуту, 500 в сутки и 250 000 входных токенов в минуту; актуальные цифры для вашего проекта смотрите в AI Studio. Если в окне осталось место только на три пачки, группа урезается до трёх — остальные анонсы ждут следующей возможности, а не ломают лимит.

Счётчики живут в Script Properties, иначе они не переживут границу запуска: триггер завершился, состояние сохранилось, следующий запуск видит остаток. Проверку и резервирование оборачиваем в короткую блокировку — LockService.getScriptLock(). Захватили, вычли будущие запросы из доступного окна, отпустили. Сетевое ожидание идёт уже без блокировки: держать её на время ответа Gemini нельзя, иначе параллельные ветки начнут ждать друг друга и весь смысл перекрытия пропадёт.

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

Два статуса требуют разного поведения. При HTTP 503 повтор откладывается отдельным запуском — не крутим цикл ожидания внутри текущего триггера. При HTTP 429 сначала смотрим причину ограничения и, если сервер передал время ожидания, планируем повтор с его учётом. Оба случая доходят до логики через readLiteAnswer, поскольку стоит muteHttpExceptions: true и транспорт не бросает исключение на статус.

Отдельная тонкость: если исключение выбросил сам fetchAll() и массива ответов нет, анонсы остаются в NewsInbox. Это не доказывает, что провайдер не получил запросы, поэтому вычитать их из внутреннего счётчика квоты нельзя — иначе на следующем шаге счётчик соврёт в вашу пользу, и лимит прилетит снова.

Где приём уместен и что запомнить

Сначала отделим одно от другого. Параллельные запросы не экономят ни токены, ни строчки дневной квоты: пять вызовов fetchAll() — это всё те же пять обращений к модели, каждое со своим вводом и своим ответом. Экономится время: вместо суммы длительностей запросов вы получаете длительность самого медленного плюс накладные расходы. Отсюда и границы применимости — параллелить имеет смысл только независимые задачи с одинаковыми правилами: классификацию обращений, извлечение полей из документов, отбор анонсов, проверку отдельных карточек. Если следующий шаг зависит от предыдущего, порядок придётся сохранить.

Второй вывод — про цену короткого запуска. В Apps Script на один запуск отпущено 6 минут, поэтому выигранные на ожидании секунды уходят не в комфорт, а в объём работы, который вообще успевает пройти за триггер. Транспортная часть при этом оказалась маленькой: пять промптов, массив запросов, fetchAll(), разбор ответов. Вся аккуратность понадобилась вокруг — проверить статус каждого ответа, сопоставить решения с исходными анонсами, сохранить успешные пачки, даже если соседняя вернула 503.

Практический совет: сохраняйте исходные данные до запроса к модели, а не после. Анонс ложится в NewsInbox ещё до отбора, готовый пост — до отправки. Тогда неудачная отправка не заставит снова просить модель написать тот же текст, а сбой на этапе разбора не унесёт вместе с собой новость, которая ещё может пригодиться. И держите связь «решение → исходная статья» явной, например по id внутри пачки: без неё ответы модели легко привязать не к той строке.

Похожее