Перезапускаемый LLM-бэкфилл модерации на Node.js

Когда в продукт добавляют LLM-модерацию, новые публикации проверяются на лету, а старые остаются непроверенными. Их прогоняют разовым бэкфиллом — пакетной классификацией архива. На практике такой скрипт падает на середине из-за таймаута, лимита запросов или перезапуска контейнера. После повторного запуска он снова отправляет уже обработанные тексты и снова за них платит. Если в одной базе лежат данные нескольких арендаторов, к этому добавляется вопрос, чьи токены были потрачены и не попал ли результат одного клиента в выдачу другого.
В статье соберём небольшой, но честный стенд: Node.js-скрипт читает JSONL с публикациями, вызывает классификатор, пишет результаты в журнал только на добавление и переживает падение в любой момент. Затем проверим четыре свойства: повторный запуск не делает лишних вызовов, оборванная запись не ломает журнал, токены считаются по арендаторам, а подмена арендатора в результатах обнаруживается автоматически.
Важно: классификатор в примере — детерминированная заглушка. Её «токены» — грубая оценка по длине текста, а не показания реального токенизатора. Это пример для проверки механики конвейера, а не замер стоимости или качества модерации.
Архитектура в трёх файлах
key.mjs— устойчивый ключ записи. Ключ одинаков при любом запуске, если не изменились арендатор, публикация, текст, версия промпта и модель.backfill.mjs— конвейер. Источник истины — журналout/results.jsonl; файлout/checkpoint.jsonпоказывает прогресс.check.mjs— независимая проверка: дубликаты, неизвестные ключи, изоляция арендаторов, сводка токенов.
Главное решение — не доверять номеру строки как контрольной точке. Если входной файл перевыгрузили, отсортировали иначе или дописали, номер строки начнёт указывать на другую публикацию. Поэтому пропуск решается по ключу из журнала, а checkpoint.json нужен человеку и мониторингу, а не логике возобновления.
Шаг 1. Подготовка стенда
Нужен Node.js 18 или новее; внешние пакеты не используются. Все команды работают только внутри новой папки.
mkdir llm-backfill-lab && cd llm-backfill-lab
mkdir -p data
node --version
Тестовые данные вымышлены: два условных арендатора, совпадающие post_id у разных арендаторов и одна дублирующаяся строка.
cat > data/posts.jsonl <<'EOF'
{"tenant_id":"tenant-a","post_id":"p1","text":"Отличная статья, спасибо"}
{"tenant_id":"tenant-a","post_id":"p2","text":"Заходите в наше казино, бонус каждый день"}
{"tenant_id":"tenant-a","post_id":"p3","text":"Ты идиот и ничего не понимаешь"}
{"tenant_id":"tenant-b","post_id":"p1","text":"Где купить запчасти для велосипеда?"}
{"tenant_id":"tenant-b","post_id":"p2","text":"Спам-рассылка: заработок без вложений"}
{"tenant_id":"tenant-b","post_id":"p3","text":"Хорошая подборка, добавлю в закладки"}
{"tenant_id":"tenant-a","post_id":"p1","text":"Отличная статья, спасибо"}
{"tenant_id":"tenant-b","post_id":"p4","text":"Ненавижу понедельники"}
EOF
Во входе восемь строк, но уникальных ключей семь: седьмая строка повторяет первую.
Шаг 2. Устойчивый ключ
Ключ — идемпотентный идентификатор работы: «эту публикацию этого арендатора этим промптом этой моделью уже классифицировали». В ключ входит хэш текста, поэтому отредактированная публикация будет проверена заново. Версия промпта и модель тоже входят в ключ: после смены правил модерации бэкфилл честно пройдёт архив ещё раз, а не будет молча использовать старые ярлыки.
cat > key.mjs <<'EOF'
import { createHash } from 'node:crypto';
export const PROMPT_VERSION = 'moderation-v1';
export const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
// Разделитель \u0000 не встречается в идентификаторах, поэтому
// пары вида ("a", "bc") и ("ab", "c") не дадут одинаковый ключ.
export function stableKey(post, model) {
return sha256(
[post.tenant_id, post.post_id, sha256(post.text), PROMPT_VERSION, model].join('\u0000')
);
}
EOF
tenant_id стоит в ключе первым не для красоты. У обоих арендаторов есть p1, и без арендатора в ключе результат одного из них затёр бы результат другого.
Шаг 3. Конвейер с журналом и контрольной точкой
cat > backfill.mjs <<'EOF'
import {
createReadStream, existsSync, readFileSync, writeFileSync, renameSync,
openSync, writeSync, fsyncSync, closeSync, truncateSync, mkdirSync,
} from 'node:fs';
import { createInterface } from 'node:readline';
import { stableKey, PROMPT_VERSION } from './key.mjs';
const INPUT = process.env.INPUT ?? 'data/posts.jsonl';
const OUT_DIR = process.env.OUT_DIR ?? 'out';
const RESULTS = `${OUT_DIR}/results.jsonl`;
const CHECKPOINT = `${OUT_DIR}/checkpoint.json`;
const MODEL = process.env.MODEL ?? 'mock';
const CRASH_AFTER = Number(process.env.CRASH_AFTER ?? 0);
const MAX_TOKENS_PER_TENANT = Number(process.env.MAX_TOKENS_PER_TENANT ?? Infinity);
mkdirSync(OUT_DIR, { recursive: true });
function addUsage(usage, tenant, u) {
const t = usage.get(tenant) ?? { input_tokens: 0, output_tokens: 0 };
t.input_tokens += u.input_tokens;
t.output_tokens += u.output_tokens;
usage.set(tenant, t);
}
const spent = (usage, tenant) => {
const t = usage.get(tenant);
return t ? t.input_tokens + t.output_tokens : 0;
};
// Журнал — источник истины. Запись без завершающего \n считается
// оборванной при падении и отрезается до последней целой строки.
function loadLedger() {
const done = new Set();
const usage = new Map();
if (!existsSync(RESULTS)) return { done, usage };
const buf = readFileSync(RESULTS);
const complete = buf.lastIndexOf(0x0a) + 1;
if (complete < buf.length) {
console.warn(`ledger: отрезаю неполный хвост ${buf.length - complete} байт`);
truncateSync(RESULTS, complete);
}
for (const line of buf.subarray(0, complete).toString('utf8').split('\n')) {
if (!line) continue;
const r = JSON.parse(line);
done.add(r.key);
addUsage(usage, r.tenant_id, r.usage);
}
return { done, usage };
}
function appendDurable(path, obj) {
const fd = openSync(path, 'a');
try {
writeSync(fd, JSON.stringify(obj) + '\n');
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
// Атомарная замена: читатель видит либо старый, либо новый файл целиком.
function writeCheckpoint(state) {
const tmp = `${CHECKPOINT}.tmp`;
writeFileSync(tmp, JSON.stringify({ ...state, at: new Date().toISOString() }));
renameSync(tmp, CHECKPOINT);
}
// Заглушка. Для реального провайдера верните сюда ярлык и счётчики
// токенов из ответа API — не оценку по длине текста.
async function classify(post) {
if (MODEL !== 'mock') throw new Error('Подключите реальный клиент в classify()');
const text = post.text.toLowerCase();
const label = /спам|казино/.test(text) ? 'spam'
: /идиот|ненавиж/.test(text) ? 'abuse'
: 'ok';
return {
label,
usage: { input_tokens: Math.ceil(post.text.length / 4) + 50, output_tokens: 5 },
};
}
const { done, usage } = loadLedger();
let calls = 0, skipped = 0, deferred = 0, lineNo = 0;
const rl = createInterface({ input: createReadStream(INPUT), crlfDelay: Infinity });
for await (const line of rl) {
lineNo++;
if (!line.trim()) continue;
const post = JSON.parse(line);
if (!post.tenant_id || !post.post_id || typeof post.text !== 'string') {
throw new Error(`строка ${lineNo}: нужны tenant_id, post_id и text`);
}
const key = stableKey(post, MODEL);
if (done.has(key)) { skipped++; continue; }
if (spent(usage, post.tenant_id) >= MAX_TOKENS_PER_TENANT) { deferred++; continue; }
const { label, usage: u } = await classify(post);
appendDurable(RESULTS, {
key, tenant_id: post.tenant_id, post_id: post.post_id, label,
prompt_version: PROMPT_VERSION, model: MODEL, usage: u, at: new Date().toISOString(),
});
done.add(key);
addUsage(usage, post.tenant_id, u);
calls++;
writeCheckpoint({ input_line: lineNo, calls, skipped, deferred });
if (CRASH_AFTER && calls >= CRASH_AFTER) {
console.error(`имитация падения после ${calls} вызовов`);
process.exit(137);
}
}
writeCheckpoint({ input_line: lineNo, calls, skipped, deferred, finished: true });
console.log(JSON.stringify({ calls, skipped, deferred, usage: Object.fromEntries(usage) }));
EOF
Порядок операций выбран так, чтобы падение в любой точке было безопасным:
- Падение до записи в журнал. Вызов модели потерян, ключа в журнале нет, при перезапуске публикация будет классифицирована снова. Это единственный сценарий повторной оплаты, и он ограничен одной записью.
- Падение посреди записи. В журнале остаётся строка без
\n.loadLedger()отрезает её, и публикация обрабатывается заново. - Падение между журналом и контрольной точкой.
checkpoint.jsonотстаёт на одну запись, но пропуск решается по ключу, поэтому лишнего вызова не будет.
Бюджет на арендатора — мягкий. Проверка стоит перед вызовом, поэтому последний вызов может превысить лимит на размер одного ответа. Отложенные публикации не пишутся в журнал и подхватываются следующим запуском с бо́льшим бюджетом.
Шаг 4. Независимая проверка
Проверка не использует состояние конвейера: она заново читает вход, заново вычисляет ключи и сверяет с ними каждую запись журнала.
cat > check.mjs <<'EOF'
import { readFileSync } from 'node:fs';
import { stableKey } from './key.mjs';
const INPUT = process.env.INPUT ?? 'data/posts.jsonl';
const RESULTS = process.env.RESULTS ?? 'out/results.jsonl';
const MODEL = process.env.MODEL ?? 'mock';
const readJsonl = (p) =>
readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const byKey = new Map(readJsonl(INPUT).map((p) => [stableKey(p, MODEL), p]));
const errors = [];
const seen = new Set();
const tokens = {};
for (const r of readJsonl(RESULTS)) {
const short = r.key.slice(0, 12);
if (seen.has(r.key)) errors.push(`дубликат ключа ${short}`);
seen.add(r.key);
const src = byKey.get(r.key);
if (!src) { errors.push(`ключ ${short} не найден во входе`); continue; }
if (src.tenant_id !== r.tenant_id || src.post_id !== r.post_id) {
errors.push(`изоляция: ${short} записан как ${r.tenant_id}/${r.post_id}, во входе ${src.tenant_id}/${src.post_id}`);
}
const t = (tokens[r.tenant_id] ??= { calls: 0, input_tokens: 0, output_tokens: 0 });
t.calls++;
t.input_tokens += r.usage.input_tokens;
t.output_tokens += r.usage.output_tokens;
}
const missing = [...byKey.keys()].filter((k) => !seen.has(k)).length;
console.log(JSON.stringify({ results: seen.size, expected: byKey.size, missing, tokens }, null, 2));
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
EOF
Шаг 5. Проверка результата
5.1. Падение и возобновление
Каталог out создаёт сам скрипт. При повторных прогонах удаляйте только его.
CRASH_AFTER=3 node backfill.mjs; echo "exit=$?"
cat out/checkpoint.json; echo
node backfill.mjs
Ожидаемое поведение на этих данных: первый запуск делает 3 вызова и завершается с кодом 137. Второй запуск пропускает 4 строки (три уже обработанные и дубль) и делает ещё 4 вызова: "calls":4,"skipped":4.
5.2. Идемпотентность
node backfill.mjs
node check.mjs; echo "exit=$?"
Третий запуск обязан показать "calls":0,"skipped":8. Проверка должна вернуть "results": 7, "expected": 7, "missing": 0, разбивку токенов по tenant-a и tenant-b и exit=0.
5.3. Оборванная запись
printf '{"key":"broken' >> out/results.jsonl
node backfill.mjs
node check.mjs; echo "exit=$?"
Скрипт предупредит, что отрезает неполный хвост, сделает 0 вызовов, и проверка снова пройдёт.
5.4. Негативный тест изоляции
Создаём отдельную копию журнала, где первая запись приписана чужому арендатору. Исходный журнал при этом не меняется.
node -e "const fs=require('fs');const l=fs.readFileSync('out/results.jsonl','utf8').trim().split('\n');const r=JSON.parse(l[0]);r.tenant_id='tenant-b';l[0]=JSON.stringify(r);fs.writeFileSync('out/tampered.jsonl',l.join('\n')+'\n')"
RESULTS=out/tampered.jsonl node check.mjs; echo "exit=$?"
Ожидается строка изоляция: … записан как tenant-b/p1, во входе tenant-a/p1 и exit=1. Если проверка прошла, значит, она не защищает от главного риска и ей нельзя доверять.
5.5. Бюджет по арендатору
OUT_DIR=out-budget MAX_TOKENS_PER_TENANT=100 node backfill.mjs
RESULTS=out-budget/results.jsonl node check.mjs
При оценке токенов из заглушки каждый арендатор получает по два вызова, после чего лимит исчерпан. Ожидаемый итог: "calls":4,"deferred":3,"skipped":1, а в проверке "missing": 3. Это не ошибка: отложенные публикации доберёт запуск с бо́льшим лимитом в тот же OUT_DIR.
Подключение реальной модели
Замените тело classify() вызовом своего провайдера и запускайте с MODEL=<идентификатор модели>. Модель входит в ключ, поэтому результаты заглушки не смешаются с настоящими. Соблюдайте три правила:
- Берите
usageиз ответа API. Оценка по длине годится только для стенда. - Отправляйте в один запрос публикации только одного арендатора. В этом конвейере каждая публикация идёт отдельным вызовом. Если перейдёте на пакеты, группируйте их по
tenant_id, иначе текст одного клиента окажется в контексте модели при классификации другого. - Повторы при сетевых ошибках и 429 делайте внутри
classify()с экспоненциальной паузой. Если ошибка постоянная, лучше дать процессу упасть: перезапуск продолжит с места остановки.
Ключи API передавайте через переменные окружения или менеджер секретов. Не записывайте их в журнал и в checkpoint.json.
Типовые ошибки
- Ключ без
tenant_id. Одинаковыеpost_idу разных арендаторов перетирают друг друга. Негативный тест 5.4 ловит подмену, но не сломанную схему ключа, поэтому держите в данных совпадающие идентификаторы. - Возобновление по номеру строки. После перевыгрузки входа скрипт пропускает не те публикации. Номер строки годится для отчёта о прогрессе, но не для решения «делать или нет».
- Ключ без версии промпта. После изменения правил модерации архив остаётся размечен по старым правилам, и это никак не видно.
- Запись в журнал без
fsync. При сбое питания запись может пропасть уже после оплаты вызова. Это не ломает корректность, но увеличивает повторную оплату. - Два процесса на один журнал. Оба не видят ключей друг друга и делают двойные вызовы. Запускайте один процесс на
OUT_DIRили шардируйте вход по арендаторам с отдельными каталогами. - Учёт токенов по логам приложения. Логи теряются и дублируются. Считайте затраты по журналу результатов: каждая строка содержит ровно один оплаченный вызов.
Ограничения
- Конвейер последовательный. Для больших архивов нужен пул с ограничением параллелизма. Тогда
doneнадо помечать до вызова, а журнал писать из одного места. - Журнал целиком загружается в память при старте. Для миллионов записей лучше хранить ключи в SQLite или в таблице с уникальным индексом.
- Вызов, завершившийся до записи в журнал, оплачивается повторно. Устранить это полностью можно, только если провайдер поддерживает идемпотентные запросы.
- Бюджет мягкий и действует в рамках одного
OUT_DIR. Это не замена биллингу и квотам на стороне провайдера. - Заглушка ничего не говорит о качестве модерации. Её ярлыки (например, «Ненавижу понедельники» →
abuse) показывают только работу механики.
Что дальше
Другие практические разборы собраны в гайдах. Термины из статьи — бэкфилл, идемпотентность, мультиарендность — объяснены в глоссарии.