Агент по расписанию — самообновляющийся дайджест
Большая ценность агентов — не в том что они отвечают на твои вопросы, а в том что они делают работу пока ты спишь. Самый частый паттерн: триггер по расписанию → агент собирает данные → пушит результат в твой канал. Звучит просто, но в проде куча нюансов: что если 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 недели ты не поймёшь почему он стал слать херню.
// 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 + идемпотентность через БД