# Как собрать локального research-агента для научных статей: поиск литературы, чтение и запуск экспериментов на GPU

> AI Release · @ai_release1 · https://ai-release.org/guides/kak-sobrat-lokalnogo-research-agenta-dlya-nauchnyh-stat.html

## TL;DR
- Время выполнения: 60–90 минут (без учёта времени на скачивание моделей и прогон обучения)
- Уровень сложности: Intermediate
- Понадобится: Linux или Windows 11 с WSL2, Python (минимальную версию сверьте с документацией PyMuPDF и Ollama; в примерах — `python3.11`), `pip`, `curl`, `git`, Docker с NVIDIA Container Toolkit, локальный сервер LLM (Ollama, порт по умолчанию `11434` — сверьтесь с документацией), GPU с установленным драйвером NVIDIA, ~20 ГБ свободного места под PDF, веса моделей и логи

## Требования и подготовка
![Иллюстрация к разделу «Требования и подготовка»](https://ai-release.org/guides/img/kak-sobrat-lokalnogo-research-agenta-dlya-nauchnyh-stat-ru-1.jpg)


- ОС: Linux (Ubuntu) или Windows 11 + WSL2. На Windows без WSL2 часть Python-пакетов для парсинга PDF ставится с проблемами.
- Драйвер NVIDIA + CUDA-совместимый GPU. Проверка: `nvidia-smi` должна показать таблицу с именем карты и объёмом памяти.
- Docker Engine и NVIDIA Container Toolkit — нужны, чтобы эксперимент запускался в изолированном контейнере с доступом к GPU, а не «в вашей домашней папке».
- Локальный сервер LLM (Ollama) — чтобы статья и план эксперимента не уходили во внешний API. Какой именно сервер и модель брать под ваше железо — см. разбор [Что такое NPU и как выбрать мини-ПК для локального запуска ИИ](https://ai-release.org/guides/chto-takoe-npu-i-kak-vybrat-mini-pk-dlya-lokalnogo-zapu.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide).
- Свободное место: 20 ГБ минимум (PDF + веса + артефакты прогонов).
- Права: пользователь в группе `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-агента](https://ai-release.org/guides/kak-prevratit-obychnyy-chat-s-llm-v-polnotsennogo-ai-ag.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide).

## Шаг 1. Каркас проекта и поиск литературы

**Что сделать:** создать структуру папок, виртуальное окружение и скрипт, который забирает статьи из arXiv API (открытый API научного архива препринтов arXiv) и складывает их в JSON.

**Зачем:** без воспроизводимого списка источников агент каждый раз будет искать заново и выдавать разные ответы. JSON на диске — это кэш и одновременно журнал того, что агент реально видел.

**Как проверить:** в конце шага `data/papers.json` содержит столько же записей, сколько статей вернул API.

```bash
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):

```bash
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`:

```python
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")
```

```bash
python scripts/search.py
```

**Пример:** `5 papers saved`. Дальше отбор: отсортируйте `papers.json` по пересечению ключевых слов с вашей темой и оставьте 3–5 штук — LLM плохо работает с двадцатью статьями сразу.

Скачивание PDF (пауза между запросами обязательна — arXiv просит не частить, точные лимиты сверьте с их правилами использования):

```bash
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
```

## Шаг 2. Чтение статей локальной моделью и генерация плана эксперимента
![Иллюстрация к разделу «Шаг 2. Чтение статей локальной моделью и генерация плана эксперимента»](https://ai-release.org/guides/img/kak-sobrat-lokalnogo-research-agenta-dlya-nauchnyh-stat-ru-2.jpg)


**Что сделать:** поднять локальный LLM-сервер, прогнать каждую статью через «читатель» (PDF → текст → заметка) и попросить модель собрать YAML-спеку эксперимента.

**Зачем:** читать 30 страниц PDF вручную долго, а спека в YAML нужна, чтобы запуск на GPU был воспроизводимым: те же данные, те же гиперпараметры, тот же образ.

**Как проверить:** сервер отвечает на `/api/tags`, в `notes/` появились файлы, `spec.yaml` парсится как YAML.

```bash
ollama serve &
curl -s http://localhost:11434/api/tags
```

**Пример:** ответ — JSON вида `{"models":[{"name":"<тег-модели>", ...}]}`. Имя модели в поле `name` подставьте в скрипт ниже вместо плейсхолдера `<тег-модели>`.

Читатель `scripts/read.py`:

```python
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:

```bash
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` — путём внутри контейнера.

## Шаг 3. Запуск эксперимента на GPU и проверка результата
![Иллюстрация к разделу «Шаг 3. Запуск эксперимента на GPU и проверка результата»](https://ai-release.org/guides/img/kak-sobrat-lokalnogo-research-agenta-dlya-nauchnyh-stat-ru-3.jpg)


**Что сделать:** запустить обучение в контейнере с проброшенным GPU, перенаправив логи в файл, и убедиться, что метрика записалась.

**Зачем:** контейнер фиксирует окружение, а `--gpus` не даёт процессу занять все карты и уронить чужие задачи.

**Как проверить:** код возврата `0` и наличие строки с метрикой в логе.

```bash
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`. Проверка метрики:

```bash
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` — непустой, у каждой записи есть `pdf`
- [ ] `notes/*.md` — есть все пять разделов
- [ ] `experiments/run01/spec.yaml` — парсится, `seed` и `gpu_id` заданы
- [ ] `logs/train.log` — код возврата 0
- [ ] метрика из `spec.yaml` присутствует в логе
- [ ] артефакты прогона лежат в `experiments/run01/`, а не в корне проекта

Если статья лежит за JS-страницей и PDF не скачивается напрямую, забор файла можно автоматизировать браузером — см. [Как подключить ИИ-агента к Chrome: настройка автоматизации браузера](https://ai-release.org/guides/kak-podklyuchit-ii-agenta-k-chrome-nastroyka-avtomatiza.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide).

## Возможные проблемы и решения

**Проблема. Обучение падает сразу после старта, GPU занят на 100%**

- Ошибка: `torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate ... GiB`
- Причина: `batch_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 refused`
- Причина: сервер LLM не запущен, либо он слушает другой порт, либо вы внутри контейнера, где `localhost` — это сам контейнер.
- Решение: `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` или ответ `429`
- Причина: слишком частые запросы или отсутствие `User-Agent` с контактом; сервис ограничивает анонимный трафик.
- Решение: добавьте заголовок `-A "research-agent/0.1 (mailto:<ваш-email>)"`, увеличьте паузу между запросами до 5 секунд и повторите. Актуальные лимиты сверьте с правилами использования API.

## Резюме и следующие шаги

Агент собран из четырёх звеньев, каждое из которых пишет артефакт на диск: `papers.json`, `notes/*.md`, `spec.yaml`, `logs/train.log`. Дальше имеет смысл добавить дедупликацию источников по DOI и автоматический отчёт, который собирает цитаты из заметок вместе с метрикой прогона.

## FAQ

**Чем заменить Ollama, если не хватает скорости?**
Любым локальным сервером с OpenAI-совместимым API — тогда в скриптах меняется только URL и имя модели. Конкретный сервер и его флаги сверьте с документацией.

**Сколько статей давать модели за один раз?**
Начните с 3–5: больше не влезает в контекст без потери качества, а обрезка текста уже отбрасывает раздел с результатами.

**Обязателен ли Docker для запуска на GPU?**
Нет, но без него окружение эксперимента не фиксируется, и результат сложно воспроизвести. Минимум — зафиксируйте версии пакетов в `requirements.txt` с точными пинами.

**Что делать, если модель возвращает план не в YAML?**
Снизьте температуру генерации, уберите из промпта лишние пояснения и добавьте фразу «только YAML, без markdown-обёртки». Как крайний вариант — режьте ответ от первой строки `dataset:`.
