python3.11), pip, curl, git, Docker с NVIDIA Container Toolkit, локальный сервер LLM (Ollama, порт по умолчанию 11434 — сверьтесь с документацией), GPU с установленным драйвером NVIDIA, ~20 ГБ свободного места под PDF, веса моделей и логи
nvidia-smi должна показать таблицу с именем карты и объёмом памяти.docker (sudo usermod -aG docker $USER, затем перелогин).Архитектура агента — четыре звена. Каждое звено пишет файл на диск, поэтому сбой одного не убивает весь прогон:
| Звено | Вход | Выход | Чем проверяем |
|---|---|---|---|
| Поиск | запрос строкой | `data/papers.json` | число записей в JSON |
| Чтение | PDF из `data/pdf/` | `notes/<id>.md` | файл с секциями «метод/данные/результаты» |
| План | заметки + цель | `experiments/<run>/spec.yaml` | YAML валиден, поля заполнены |
| Запуск | spec.yaml | `experiments/<run>/logs/*.log` | код возврата 0, метрика в логе |
Общая идея «поиск → чтение → действие» — это и есть переход от чата к агенту; подробнее про цикл «подумал → вызвал инструмент → проверил результат» в гайде Как превратить обычный чат с LLM в полноценного AI-агента.
Что сделать: создать структуру папок, виртуальное окружение и скрипт, который забирает статьи из arXiv API (открытый API научного архива препринтов arXiv) и складывает их в JSON.
Зачем: без воспроизводимого списка источников агент каждый раз будет искать заново и выдавать разные ответы. JSON на диске — это кэш и одновременно журнал того, что агент реально видел.
Как проверить: в конце шага data/papers.json содержит столько же записей, сколько статей вернул API.
mkdir -p ~/research-agent/{data/pdf,notes,experiments,scripts}
cd ~/research-agent
python3.11 -m venv .venv && source .venv/bin/activate
pip install requests pymupdf pyyaml
Почему это работает: venv изолирует зависимости агента от системного Python, а pymupdf умеет вытаскивать текст из PDF без внешних бинарников.
Запрос к arXiv (официальный эндпоинт export.arxiv.org; формат параметров сверьте с документацией API):
curl -s -A "research-agent/0.1 (mailto:<ваш-email>)" \
"http://export.arxiv.org/api/query?search_query=all:%22retrieval%20augmented%20generation%22&start=0&max_results=5&sortBy=submittedDate&sortOrder=descending" \
-o data/arxiv_raw.xml
grep -c "<entry>" data/arxiv_raw.xml
Пример: ожидаемый вывод — 5. Если вышло 0, запрос синтаксически неверен или arXiv вернул ошибку вместо Atom-фида — откройте data/arxiv_raw.xml и посмотрите первые строки.
Парсер в scripts/search.py:
import json, xml.etree.ElementTree as ET
NS = {"a": "http://www.w3.org/2005/Atom"}
tree = ET.parse("data/arxiv_raw.xml")
papers = []
for e in tree.getroot().findall("a:entry", NS):
papers.append({
"id": e.find("a:id", NS).text.rsplit("/", 1)[-1],
"title": " ".join(e.find("a:title", NS).text.split()),
"summary": " ".join(e.find("a:summary", NS).text.split()),
"pdf": next(l.get("href") for l in e.findall("a:link", NS) if l.get("title") == "pdf"),
})
json.dump(papers, open("data/papers.json", "w"), ensure_ascii=False, indent=2)
print(len(papers), "papers saved")
python scripts/search.py
Пример: 5 papers saved. Дальше отбор: отсортируйте papers.json по пересечению ключевых слов с вашей темой и оставьте 3–5 штук — LLM плохо работает с двадцатью статьями сразу.
Скачивание PDF (пауза между запросами обязательна — arXiv просит не частить, точные лимиты сверьте с их правилами использования):
python - <<'PY'
import json, time, requests
for p in json.load(open("data/papers.json")):
r = requests.get(p["pdf"], timeout=60)
open(f"data/pdf/{p['id']}.pdf", "wb").write(r.content)
time.sleep(3)
print(p["id"], r.status_code, len(r.content))
PY

Что сделать: поднять локальный LLM-сервер, прогнать каждую статью через «читатель» (PDF → текст → заметка) и попросить модель собрать YAML-спеку эксперимента.
Зачем: читать 30 страниц PDF вручную долго, а спека в YAML нужна, чтобы запуск на GPU был воспроизводимым: те же данные, те же гиперпараметры, тот же образ.
Как проверить: сервер отвечает на /api/tags, в notes/ появились файлы, spec.yaml парсится как YAML.
ollama serve &
curl -s http://localhost:11434/api/tags
Пример: ответ — JSON вида {"models":[{"name":"<тег-модели>", ...}]}. Имя модели в поле name подставьте в скрипт ниже вместо плейсхолдера <тег-модели>.
Читатель scripts/read.py:
import fitz, json, requests, pathlib
MODEL = "<тег-модели>" # имя из /api/tags
PROMPT = ("Ты читаешь научную статью. Верни markdown с разделами: "
"## Задача, ## Метод, ## Данные, ## Метрики, ## Ограничения. "
"Только то, что есть в тексте, без домыслов.\n\nТекст:\n")
for p in json.load(open("data/papers.json")):
doc = fitz.open(f"data/pdf/{p['id']}.pdf")
text = "\n".join(page.get_text() for page in doc)[:20000]
r = requests.post("http://localhost:11434/api/generate",
json={"model": MODEL, "prompt": PROMPT + text, "stream": False},
timeout=600)
pathlib.Path(f"notes/{p['id']}.md").write_text(r.json()["response"])
print(p["id"], "ok")
Почему это работает: fitz отдаёт чистый текст, а обрезка до 20000 символов не даёт превысить контекст модели — остальное отбрасывается осознанно, а не «как получится».
Пример: вывод 2401.00001 ok для каждой статьи. Откройте notes/<id>.md — если раздел «## Метрики» пуст, модель не нашла метрик в первых 20000 символах, и это повод сдвинуть окно на результаты.
Теперь план. Отдайте модели заметки и цель, потребуйте строгий YAML:
cat notes/*.md > notes/_all.md
python - <<'PY'
import requests
MODEL = "<тег-модели>"
prompt = ("На основе заметок составь YAML-план воспроизведения эксперимента. "
"Ключи строго: dataset, base_model, batch_size, learning_rate, epochs, "
"seed, gpu_id, script, metric. Без пояснений, только YAML.\n\n"
+ open("notes/_all.md").read()[:20000])
r = requests.post("http://localhost:11434/api/generate",
json={"model": MODEL, "prompt": prompt, "stream": False}, timeout=600)
open("experiments/run01/spec.yaml", "w").write(r.json()["response"])
PY
mkdir -p experiments/run01/logs
python -c "import yaml;print(yaml.safe_load(open('experiments/run01/spec.yaml')))"
Пример: на экране — словарь с ключами dataset, batch_size, learning_rate. Если вместо YAML пришёл текст с «Конечно, вот план», значит модель проигнорировала инструкцию — обрежьте ответ по первой строке dataset: и повторите.
Спека — единственный источник правды для запуска. Проверьте глазами: batch_size, learning_rate, gpu_id должны быть числами, а script — путём внутри контейнера.

Что сделать: запустить обучение в контейнере с проброшенным GPU, перенаправив логи в файл, и убедиться, что метрика записалась.
Зачем: контейнер фиксирует окружение, а --gpus не даёт процессу занять все карты и уронить чужие задачи.
Как проверить: код возврата 0 и наличие строки с метрикой в логе.
docker run --rm --gpus '"device=0"' \
-v "$PWD/experiments/run01:/work" -w /work \
<ваш-образ> \
bash -lc "python $(python -c "import yaml;print(yaml.safe_load(open('experiments/run01/spec.yaml'))['script'])") --config spec.yaml" \
2>&1 | tee experiments/run01/logs/train.log
echo "exit=${PIPESTATUS[0]}"
Пример: в логе идут строки вида epoch 1/3 loss=..., в конце exit=0. Проверка метрики:
grep -Ei "accuracy|f1|loss" experiments/run01/logs/train.log | tail -n 5
nvidia-smi --query-gpu=memory.used,utilization.gpu --format=csv
Успех выглядит так: exit=0, в последних строках лога есть число метрики, nvidia-smi показывает ненулевое memory.used во время прогона и падение до нуля после.
Чек-лист приёмки прогона:
data/papers.json — непустой, у каждой записи есть pdfnotes/*.md — есть все пять разделовexperiments/run01/spec.yaml — парсится, seed и gpu_id заданыlogs/train.log — код возврата 0spec.yaml присутствует в логеexperiments/run01/, а не в корне проектаЕсли статья лежит за JS-страницей и PDF не скачивается напрямую, забор файла можно автоматизировать браузером — см. Как подключить ИИ-агента к Chrome: настройка автоматизации браузера.
Проблема. Обучение падает сразу после старта, GPU занят на 100%
torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate ... GiBbatch_size из spec.yaml больше, чем помещается в память карты вместе с активациями.batch_size вдвое в spec.yaml, перезапустите контейнер и добавьте PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True в docker run -e. Проверка: nvidia-smi во время прогона показывает загрузку памяти ниже предела.Проблема. Скрипт чтения не может достучаться до модели
requests.exceptions.ConnectionError: HTTPConnectionPool(host='localhost', port=11434): Max retries exceeded ... Connection refusedlocalhost — это сам контейнер.ollama serve &, затем curl -s http://localhost:11434/api/tags. Из контейнера обращайтесь по http://host.docker.internal:11434 (Linux: добавьте --add-host=host.docker.internal:host-gateway).Проблема. Поиск возвращает пустой список статей
grep -c "<entry>" data/arxiv_raw.xml печатает 0, а в файле http.client.RemoteDisconnected или ответ 429User-Agent с контактом; сервис ограничивает анонимный трафик.-A "research-agent/0.1 (mailto:<ваш-email>)", увеличьте паузу между запросами до 5 секунд и повторите. Актуальные лимиты сверьте с правилами использования API.Агент собран из четырёх звеньев, каждое из которых пишет артефакт на диск: papers.json, notes/*.md, spec.yaml, logs/train.log. Дальше имеет смысл добавить дедупликацию источников по DOI и автоматический отчёт, который собирает цитаты из заметок вместе с метрикой прогона.
Чем заменить Ollama, если не хватает скорости?
Любым локальным сервером с OpenAI-совместимым API — тогда в скриптах меняется только URL и имя модели. Конкретный сервер и его флаги сверьте с документацией.
Сколько статей давать модели за один раз?
Начните с 3–5: больше не влезает в контекст без потери качества, а обрезка текста уже отбрасывает раздел с результатами.
Обязателен ли Docker для запуска на GPU?
Нет, но без него окружение эксперимента не фиксируется, и результат сложно воспроизвести. Минимум — зафиксируйте версии пакетов в requirements.txt с точными пинами.
Что делать, если модель возвращает план не в YAML?
Снизьте температуру генерации, уберите из промпта лишние пояснения и добавьте фразу «только YAML, без markdown-обёртки». Как крайний вариант — режьте ответ от первой строки dataset:.