EvolCode
треки/ИИ-агенты·01 / 07
12 мин чтения

Агент по расписанию — самообновляющийся дайджест

Большая ценность агентов — не в том что они отвечают на твои вопросы, а в том что они делают работу пока ты спишь. Самый частый паттерн: триггер по расписанию → агент собирает данные → пушит результат в твой канал. Звучит просто, но в проде куча нюансов: что если LLM упал на полпути? Что если cron сработал дважды? Что если результат пришёл в 3 утра? Разбираем по пунктам.

Где запустить cron — 4 варианта

(1) Linux crontab на VPS — старая школа, бесплатно, надёжно если не падает машина. (2) GitHub Actions on schedule — бесплатно для open-source репо, минимум до 5 минут гранулярности, нет статуса между запусками. (3) Cron-as-a-Service (Cron-job.org, EasyCron, Upstash QStash) — внешний сервис делает HTTP-запрос к твоему /api/cron/run по расписанию. Самый простой для serverless. (4) Vercel Cron Jobs или Cloudflare Workers Cron — встроены в платформу, удобно если уже там хостишься. Для агентов рекомендую #4 (если есть) или #3.

Идемпотентность — самый важный принцип

Cron имеет привычку срабатывать дважды (retry, сетевой глюк, переезд сервера). Если твой агент при двойном запуске пришлёт два дайджеста или два раза купит акцию — будет больно. Идемпотентность = повторный запуск с теми же входами не приводит к новому действию. Реализуется через ключ дедупликации: для дневного дайджеста ключ это дата YYYY-MM-DD. Перед действием проверяешь — был ли уже запуск с этим ключом, если да → выходишь без вреда. Хранить в Redis, БД, или просто в digest_log таблице.

Очереди — а что если задача длинная

Cron-триггер хорош для коротких задач (минута-две). Если агент собирает данные с 50 источников и работает 30 минут — сервер cron-провайдера убьёт его по таймауту. Решение: cron только триггерит — а реальная работа идёт в очереди. Положил job в Redis/SQS/QStash, отдельный воркер забирает и прогоняет агента. У воркера свой timeout (часы), retry, история. Минимально без инфры — Upstash QStash: один POST к их API кладёт задачу, через X секунд тебе на webhook прилетает событие, ты делаешь работу.

Retry и failure mode

Что должно произойти если агент упал на половине? Варианты: (1) Полный retry с нуля — просто, но дорого если агент уже сделал половину работы (и ещё пришлёт частичный результат). (2) Чекпоинты — после каждого крупного шага сохраняешь промежуточный state, при retry поднимаешь с последнего чекпоинта. Сложнее, но эффективнее. (3) DLQ (dead letter queue) — после N неудач кладёшь job в отдельную очередь и шлёшь себе alert. Не теряешь данные, но и не зацикливаешься. Минимум для прода: 3 retry с экспоненциальной задержкой → DLQ.

Логирование и наблюдаемость

Без логов автономный агент — чёрный ящик. Что обязательно записывать: (1) Каждый запуск с timestamp, длительностью, статусом. (2) Каждый вызов LLM — модель, токены вход/выход, стоимость. (3) Каждый вызов tool — какой, с какими параметрами, что вернул. (4) Финальный результат и куда отправлен. Минимум — пиши в БД таблицу agent_runs. Идеал — отдельный observability-инструмент (Helicone, LangSmith) который покажет как агент думал на каждом шаге. Без этого через 2 недели ты не поймёшь почему он стал слать херню.

примерtypescript
// vercel.json
// {
//   "crons": [{ "path": "/api/cron/digest", "schedule": "0 7 * * *" }]
// }
// schedule = "0 7 * * *" = каждый день в 07:00 UTC

// app/api/cron/digest/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { runDigestAgent } from '@/lib/agents/digest';
import { sendTelegram } from '@/lib/telegram';

export async function GET(req: Request) {
  // 1. Авторизация (Vercel шлёт CRON_SECRET в Authorization)
  const auth = req.headers.get('authorization');
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  // 2. Ключ идемпотентности — дата
  const today = new Date().toISOString().slice(0, 10);

  // 3. Проверяем не запускались ли сегодня
  const existing = await prisma.cronRun.findUnique({
    where: { key: `digest:${today}` },
  });
  if (existing?.status === 'success') {
    return NextResponse.json({ ok: true, skipped: 'already done today' });
  }

  // 4. Создаём/обновляем запись и запускаем
  await prisma.cronRun.upsert({
    where: { key: `digest:${today}` },
    create: { key: `digest:${today}`, status: 'running' },
    update: { status: 'running', startedAt: new Date() },
  });

  try {
    const result = await runDigestAgent();
    await sendTelegram(result.summary);
    await prisma.cronRun.update({
      where: { key: `digest:${today}` },
      data: { status: 'success', finishedAt: new Date() },
    });
    return NextResponse.json({ ok: true });
  } catch (e) {
    await prisma.cronRun.update({
      where: { key: `digest:${today}` },
      data: { status: 'failed', error: String(e) },
    });
    return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
  }
}

Vercel Cron Job + идемпотентность через БД

главное
  • 01Для serverless проще всего — Vercel Cron или Cloudflare Cron. Для VPS — обычный crontab.
  • 02Идемпотентность обязательна. Ключ — дата или другой стабильный идентификатор. Без неё словишь дубликаты.
  • 03Длинные задачи отдавай в очередь, cron только триггерит. Иначе таймаут платформы убьёт всё.
  • 04Retry + DLQ — стандартный паттерн отказоустойчивости. 3 попытки → отдельная очередь и alert.
  • 05Без логов автономный агент = чёрный ящик. Минимум — таблица agent_runs со всеми вызовами.
проверь себя
01Что такое идемпотентность в контексте cron-агента?
02Когда стоит использовать очередь (а не запускать агента прямо в cron-handler)?
03Что должно быть в логах автономного агента в проде?
следующий урок: Когда дробить задачу на подагентов — а когда не надо