Две недели назад я поставил себе инженерную задачу без редакции и без бюджета: сайт должен выпускать по пять материалов в день о том, что произошло в мире LLM, причём каждый материал проверен по первоисточникам, снабжён картинкой, разметкой для поиска и не повторяет предыдущие. Человек в этом конвейере остаётся ровно в одной точке — воротах согласования, где у него десять минут на решение.Сейчас в журнале конвейера 61 карточка, из них 56 стали страницами на сайте, семь дней подряд собирается вечерняя сводка, а сам прогон от поиска до готовой страницы занимает три минуты, из которых языковая модель работает меньше минуты. Ниже — как это устроено, что пришлось переделывать и на чём PowerShell с LLM мстят друг другу. Читать далее
Уровень сложностиСредний
Время на прочтение10 мин
Охват и читатели1.3K
Кейс
Две недели назад я поставил себе инженерную задачу без редакции и без бюджета: сайт должен выпускать по пять материалов в день о том, что произошло в мире LLM, причём каждый материал проверен по первоисточникам, снабжён картинкой, разметкой для поиска и не повторяет предыдущие. Человек в этом конвейере остаётся ровно в одной точке — воротах согласования, где у него десять минут на решение.
Сейчас в журнале конвейера 61 карточка, из них 56 стали страницами на сайте, семь дней подряд собирается вечерняя сводка, а сам прогон от поиска до готовой страницы занимает три минуты, из которых языковая модель работает меньше минуты. Ниже — как это устроено, что пришлось переделывать и на чём PowerShell с LLM мстят друг другу.
Что было в требованияхПять материалов в день с перерывом не меньше двух часов между ними — чтобы лента не выглядела пакетом, выпущенным одной командой. Жёсткая дедупликация: тема, пришедшая второй раз под другим заголовком, должна отсекаться до того, как на неё потратятся токены. Проверка фактов по первоисточникам, а не по пересказам. Обязательная картинка: без неё страница не собирается. Согласование человеком с дедлайном: если за десять минут никто не нажал «да», материал остаётся в холде и не уходит в публикацию. И финал — выгрузка, карта сайта, оповещение поисковиков.
Всё это живёт на обычном хостинге с FTP, без серверной части и без базы данных: сайт статический, состоящий из HTML-файлов. Управляющая часть — PowerShell 5.1 на моей машине, 47 скриптов, около 9,7 тысяч строк.
Журнал как единственный источник истиныОтказаться от базы было легко, потому что все данные конвейера линейны: карточка темы, её состояние и обогащения. Поэтому журнал — это файл JSONL, по строке на материал. Каждый шаг читает строку, дописывает в неё свои поля и сохраняет обратно; никакой шаг не хранит состояние у себя.
Вот как выглядит карточка в середине обработки (поля сокращены, «…» — вырезанные источники):
{
"заголовок": "Agent4Sci: смогут ли ИИ-агенты улучшать научные модели?",
"url": "https://arxiv.org/abs/2610.28763",
"рубрика": "релизы",
"дата": "2026-10-22",
"язык": "en",
"лид": "Новый тестбенч Agent4Sci проверяет, способны ли агенты улучшать модели…",
"статус": "готово",
"тема": 169,
"источники": [{ "url": "https://arxiv.org/abs/2610.28763", "название": "arXiv cs.AI" }, "…"],
"факты": [["Что случилось", "…"], ["Цифры", "…"]],
"вывод": "…",
"полезно": [["цена/доступность", "…"], ["что нужно из железа", "…"]],
"фактЧек": "ок",
"faq": [{ "в": "…", "о": "…" }]
}
Постоянный адрес страницы считается из данных, а не назначается вручную: дата плюс первые восемь шестнадцатеричных знаков SHA-1 от URL первоисточника. Получается 2026-10-22-2ea2fcfc.html — коротко, стабильно, без транслита и без коллизий в пределах дня.
Ключевой приём всей схемы — гейт публикации проверяет файл, а не флаг. Материал считается опубликованным тогда и только тогда, когда на диске существует сайт\news\<slug>.html. Из этого автоматически следуют приятные вещи: сводки и ленты строятся только по существующим страницам, битых ссылок не бывает, а любая авария в середине цепочки просто оставляет карточку без страницы, что видно по счётчику, но не ломает сайт.
Каждый шаг — отдельный процесс powershell.exe -File. Это дороже по запуску, зато падение шага не убивает прогон: конвейер пишет его в список упавших и идёт дальше, а материал добирается следующим запуском. Реальные секунды сняты с лога прогона 22 октября:
Шаг | Что делает | Секунды |
|---|---|---|
| опрос лент, отбор кандидатов | 29 (17 кандидатов) |
| поиск дополнительных источников по сырому заголовку | 3 |
| заголовок 45–65 знаков и лид 200–450 знаков | 6 |
| номер темы и повторная сверка на дубли по всему журналу | 3 |
| факт в 8–14 слов на каждый источник (анкор вместо голой ссылки) | 17 |
| таблица фактов, «что это значит», «что из этого полезно вам» | 17 |
| проверка утверждений по тексту первоисточника | 14 |
| автопочинка неудачных проверок до публикации | 15 |
| картинка к материалу в локальной модели | 68 |
| четыре вопроса-ответа для FAQ-разметки | 6 |
| сборка страницы, лента, карта сайта, RSS | 2 |
Итого: старт в 17:00:02, материал зафиксирован в 17:03:04. Дольше всего идёт не языковая модель, а картинка — 68 секунд на локальной видеокарте. Сами LLM-шаги вместе дают 46 секунд.
Дедупликация стоит до всех дорогих шагов, и это осознанное решение: сначала сверка заголовка с журналом целиком, и только потом адаптация, факты и картинка. Платить токенами за материал, который через две минуты отсеется как дубль, — самая обидная статья расходов в таком конвейере.
Четыре обращения к модели и их контрактыМодель в конвейере не «пишет статью». Она вызывается четыре раза, и каждый вызов — узкая задача с жёстким форматом ответа. Так дешевле, стабильнее и, главное, каждую часть можно перепроверить отдельно.
Вызов | Задача | Лимит токенов | Что вернулось на замере |
|---|---|---|---|
| заголовок и лид из англоязычного материала | 900 | ~950 токенов, 3–4 с |
| по факту на каждый источник | 300 | 177 токенов, 1,9 с |
| факты, вывод, «полезно» (300–500 слов) | 2 600 | 1 054 токена, 5,7 с |
| четыре пары «вопрос-ответ» | 1 200 | 286 токенов, 2,7 с |
На материал уходит 2,5–4,5 тысячи токенов — это доли цента при текущих тарифах, так что узкое место конвейера не деньги, а качество контракта. Поэтому факты принимаются только в виде [["Что случилось","…"],["Цифры","…"]], а если модель вернула набор без обязательных полей, шаг не пропускает его дальше, а переспрашивает — три попытки с паузой.
Вызов модели — обычный HTTP из PowerShell, без SDK и без внешних библиотек:
$тело = @{
model = $Модель
messages = @(@{ role = "user"; content = $промт })
temperature = 0.4
max_tokens = 2600
}
if ($Рез.JsonMode) { $тело.response_format = @{ type = 'json_object' } }
if ($Рез.ReasoningNone) { $тело.reasoning_effort = 'none' }
$body = $тело | ConvertTo-Json -Depth 6 -Compress
$req = [System.Net.HttpWebRequest]::Create($Рез.Url)
$req.Method = "POST"; $req.ContentType = "application/json"; $req.Timeout = 120000
$req.Headers.Add("Authorization", "Bearer $Ключ")
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
$req.ContentLength = $bytes.Length
$rs = $req.GetRequestStream(); $rs.Write($bytes, 0, $bytes.Length); $rs.Close()
$sr = New-Object IO.StreamReader($req.GetResponse().GetResponseStream(), [Text.Encoding]::UTF8)
$ответ = $sr.ReadToEnd() | ConvertFrom-Json
Две строки с флагами — не украшение. Про них ниже, в разделе про грабли: без них модель возвращает пустой ответ, и понять это по коду ответа невозможно.
Модель — не константа в пяти скриптахПервая версия конвейера была честной и глупой: имя модели, адрес API и имя переменной с ключом были прописаны в каждом скрипте, который вызывает модель. Пять файлов, пять копий одной и той же тройки значений. Любая смена модели превращалась в правку пяти файлов, а забытый шестой (обычно это скрипт, который запускается раз в сутки) выяснялся по странному поведению в логах.
Сейчас есть один резолвер. Он читает настройки сессии, находит там провайдера и модель, проверяет, что модель вообще есть в списке /v1/models этого провайдера, берёт имя переменной с ключом из настроек и возвращает готовый набор: адрес, модель, ключ, а заодно два флага режима.
. (Join-Path $PSScriptRoot 'модель-сессии.ps1')
$Рез = Get-МодельСессии
$Модель = $Рез.Модель # если не задана вручную параметром -Модель
# $Рез.Url, $Рез.КлючИмя, $Рез.JsonMode, $Рез.ReasoningNone
Внутри — кэш на десять минут, инвалидация по времени изменения файла настроек и фолбэк на провайдера по умолчанию, если в настройках пусто или модель не найдена в каталоге. Я поменял модель в настройках один раз и получил смену автора всех материалов без правки скриптов; параметр -Модель остался как ручное переопределение на один запуск.
Рассказывать про работающий код скучно, поэтому вот места, где я реально потерял время. Все они из этой системы, и все уже вылечены.
1. Reasoning-модель съедает лимит и возвращает пустотуМодель deepseek-v4.1-flash на шлюзе B.AI — рассуждающая: кроме content она возвращает reasoning_content, и этот блок считается внутри max_tokens. При лимите 70 токенов ответ пришёл с finish_reason: length, content пустой, а в reasoning — 287 знаков размышлений про то, как надо ответить. При 400 токенах картина та же: 1090 знаков размышлений и снова пустой ответ. Со стороны это выглядит как «модель сломалась», хотя она честно отработала лимит.
Лечится флагом reasoning_effort: 'none' — режим отключает блок размышлений, ответ приходит за 2–6 секунд, и лимит снова принадлежит содержанию. Значение low не помогает: размышления всё равно едят бюджет. Вывод для любых конвейеров: если провайдер отдаёт reasoning-блок, сначала посмотрите, входит ли он в лимит, и только потом увеличивайте max_tokens — иначе будете платить за размышления, которые не попадают в результат.
Классика, на которой я попался дважды. Первый раз — так:
$S = "E:\...\скрипты" # папка скриптов
foreach ($s in @($карточка.источники)) { $путь = Join-Path $S $s.url }
Потому что $S и $s для PowerShell — одна и та же переменная. Внутри цикла папка скриптов превратилась в объект-ссылку, и Join-Path упал с сообщением про диск с именем @{url=https.
Второй раз — тише и потому дороже. Скрипт вечерней сводки держал путь к своему кэшу в переменной $ФайлКэша. Резолвер модели, который подключается в том же файле через точку (dot-sourcing), объявляет свою переменную с тем же именем для кэша выбора модели. Подключение переписало путь, и текст сводки бодро записался в файл кэша модели, затерев его содержимое. Ошибки не было ни одной: запись прошла успешно, просто не туда. Нашлось это только после того, как я поставил в код временную печать пути — она показала имя файла модели вместо ожидаемого.
Мораль: в PowerShell с его динамической областью видимости dot-sourcing — это не «импорт функций», а «выполнить чужой код в моей области видимости». Префиксуйте имена в подключаемых модулях или хотя бы не называйте переменные общеупотребительными словами вроде «путь к кэшу».
3. Файл .ps1 без BOM читается как CP1251Запуск powershell.exe -File script.ps1 для файла без BOM в UTF-8 означает чтение в системной кодировке. В русской локали это CP1251, и весь текст с кириллицей внутри скрипта превращается в мусор — вплоть до ParserError на строке с сообщением. Причём интерактивный запуск того же файла работает: там кодировка определяется иначе. Я лечу это записью BOM после любой правки скрипта, и в половине случаев именно забытый BOM — причина «мистического» падения по расписанию, когда руками всё отлично.
Сообщение Variable reference is not valid. ':' was not followed by a valid variable name — про то, что двоеточие после имени переменной в двойных кавычках трактуется как квалификатор диска. Строка вида "Итоги за $дата:" не компилируется; надо ${дата}:. Мелочь, но она прилетает именно в шаблонах сообщений, то есть в самом конце работы, когда всё уже почти готово.
Правило «без картинки страницы нет» я считал защитой, пока не выяснилось, что оно работает в обе стороны. Скрипт сборки страницы не публикует материал без файла картинки — и если генератор картинки упал (а он падает от банального таймаута локальной модели), материал просто выпадает из публикации. Снаружи никакой ошибки: квота дня израсходована, статья «принята», а на сайте её нет. Лечится порядком шагов (генерация картинки строго перед сборкой страницы) и блоком восстановления: если на старте прогона обнаруживается карточка с темой и без страницы, конвейер досоздаёт картинку и страницу, вместо того чтобы идти за новым материалом.
Согласование за десять минут, или Где всё-таки нужен человекАвтоматическая публикация «в интернет без спроса» — то, чего я не хотел даже в собственном проекте. Поэтому у конвейера есть ворота: готовая карточка получает номер темы, попадает в hold-список, из которого страница на сервер не выгружается, и одновременно я получаю письмо и событие в журнал Windows. Дальше работает сторож: он ждёт решение десять минут, и если решение «да» — снимает тему с hold, выгружает сайт, обновляет карту и отправляет оповещение поисковикам. Если решения нет — тема остаётся в холде до следующего раза, а прогон живёт дальше.
Две вещи в этой схеме оказались важнее, чем я ожидал. Первая — дедлайн. Без него «согласую потом» превращается в очередь из двадцати материалов и в итоге в публикацию пачкой, что убивает всю идею ежедневного ритма. Вторая — то, что согласование висит на hold-списке, а не на флаге в карточке: достаточно посмотреть файл и каталог _deploy-hold, чтобы понять, что реально ждёт решения, даже если я не открывал почту.
Деплой — FTP со сверкой MD5 по каждому файлу: сравнивать размеры бесполезно, правка вида ?v= в ссылке на стили не меняет длину файла, зато меняет содержимое. Перед выгрузкой сайт пересобирается целиком: лента, страницы, карта сайта, RSS, отдельная карта для новостей.
С поисковиками три канала, и все три идут после успешной выгрузки: карта сайта отправляется через API Search Console, отдельные адреса — через Indexing API (с паузой два дня на адрес, иначе квота сгорает на повторах), плюс IndexNow для Яндекса и Bing. Сбой любого из каналов не ломает деплой: уведомления складываются в очередь и отправляются следующим прогоном. Это не теоретическая аккуратность: API Вебмастера Яндекса лежит у меня тринадцатый день, очередь терпеливо копится, а сайт от этого не останавливается.
Отдельный урок про FTP-хостинг: листинг не показывает дотфайлы. .htaccess с правилами редиректов и заголовками безопасности в листинге не виден вообще — ни в коротком, ни в длинном формате, при том что файл существует и работает. Вывод: наличие скрытых файлов на таком хостинге проверяется запросом файла, а не листингом. Иначе однажды выгрузите сайт «как есть» и потеряете редиректы, не заметив этого.
Показатель | Значение |
|---|---|
Опубликовано материалов | 56 (из 61 карточки журнала) |
Вечерних сводок | 7, каждая со своим разбором дня |
Скриптов в конвейере / строк | 47 / ~9 700 |
Время прогона одного материала | ~3 минуты |
Расход токенов на материал | 2,5–4,5 тыс. |
Адресов в карте сайта / в индексе Google (23.10) | 76 / 54 (71 %) |
Оповещений IndexNow отправлено | 45 |
Честная часть: трафик у такого проекта на старте почти нулевой, и это ожидаемо — сайту две недели, а новостные запросы в поиске конкурентны. Цель этого этапа была не трафик, а проверка инженерной гипотезы: может ли один человек с PowerShell и недорогой моделью держать ежедневный ритм публикаций без редакции. Пока может: за две недели конвейер не сорвал ни дня, а все аварии были техническими и лечились в тот же вечер.
Что бы я сделал иначеВынести модель в резолвер сразу, а не после того, как пять скриптов разошлись в поведении. Сделать все шаги идемпотентными с кэшем результата с первого дня: я пришёл к этому только на сводках, когда понял, что повторный прогон за прошедшую дату переписывает уже опубликованный текст, и любая отладка дёргает страницу, которую поисковик только что проиндексировал. И третье: не отдавать код 200 на */index.html. Внутренние ссылки шаблона вели на файл, а канонический адрес был без файла — через месяц поисковик насчитал тринадцать «страниц-вариантов» и прислал письмо о проблемах индексации. Лечится одной строкой в .htaccess, но обнаруживается поздно.
Конвейер из десяти маленьких скриптов, файла-журнала и четырёх узких запросов к модели выпускает материал за три минуты и оставляет человеку ровно одно решение. Всё остальное — дедупликация, факты, картинка, разметка, карта сайта, оповещение поисковиков — считается само. Если вы делаете похожую штуку на PowerShell, три вещи сэкономят вам вечер: проверяйте, входит ли reasoning-блок в max_tokens; не называйте переменные в подключаемых скриптах общими словами; и держите кэш для всего, что дороже секунды, — включая текст, который пишет модель.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Конвейер из 10 агентов без строчки кода: три промпта, 40 минут и прогон при зале | 0 | 9.7 | 10-07-2026 |
| 2 | ИИ-Автопилот: поток принятых задач вырос в тринадцать раз | 1 | 8.82 | 31-07-2026 |
| 3 | Одна статья, 90 языковых версий и Google: как пет‑проект превратился в платформу | 0 | 9.92 | 05-08-2026 |
| 4 | Алгоритм был правильным. Ошибка была в контракте графа | -1 | 7.57 | 13-08-2026 |
| 5 | Как заставить LLM выбирать осмысленные фрагменты из часовой расшифровки: почему «найди интересные моменты» не работает | 0 | 5 | 28-06-2026 |
| 6 | Как я искал себе читалку и придумал новый формат электронных книг для изучающих язык | 0 | 5 | 08-07-2026 |
| 7 | Как я искал себе читалку и придумал новый формат электронных книг для изучающих язык | 0 | 5.5 | 08-07-2026 |
| 8 | Как желание быстрее читать чужой код превратилось в войну с недетерминизмом LLM | 0 | 5 | 28-06-2026 |
| 9 | Как оптимизировать инференс LLM: кеширование, время ответа и GPU-ресурсы | 0 | 11.5 | 08-07-2026 |
| 10 | Создаём DSL на C#: генерация кода, BlackSilence и Hot Reload | 0 | 4.9 | 07-10-2026 |