Skip to content

About

AnimalGuide v30: приватный AI-сервис на локальной LLM — шлюз перед Ollama с OpenAI-совместимым API, веб-чатом, ключами, лимитами и очередью (Go)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AnimalGuide — справочник по животным, с которым разговаривают

Локальное веб-приложение: спрашиваете про животное обычными словами и получаете карточку, собранную только из источников — русской Википедии и GBIF. Латынь в карточке подтверждена GBIF, пересказ раздела — только того, что агент действительно прочитал. Разделы карточки раскрываются по клику, дерево классификации кликабельно («кто ещё входит в кошачьих?»), двух животных можно сравнить таблицей, а несколько — собрать в подборку с этапами «план → сбор → сверка → принята».

Под капотом — несколько агентов на DeepSeek (привратник названия, идентификатор, специалисты по разделам, сравнение, ведущий разговора, составитель подборки) и координатор, написанный кодом. Всё, что они делают, видно в журнале хода: каждый запрос к модели, вызов инструмента, цену и токены, а на вкладке «Промпты» — что именно ушло модели.

Интересные факты (v18). Рядом с приложением может круглосуточно работать демон animals-mcp -http: раз в час он выбирает случайное млекопитающее из эталонного списка MDD (6904 вида), собирает о нём выпуск из 3–5 фактов по Википедии, GBIF и MDD, проверяет каждый факт отдельным запросом к модели и сохраняет выпуск в SQLite. Раз в сутки — сводка по выпускам. Выпуски, сводки и расписание доступны инструментами MCP по HTTP, а в приложении — окном «Интересные факты» и механизмом trivia, с которым ведущий сам берёт готовый выпуск о виде. Подробности — в разделе «Интересные факты: демон 24/7».

Конвейер MCP-инструментов (v19). У демона есть три самостоятельных инструмента: search получает данные о виде (досье из MDD, Википедии и GBIF), summarize обрабатывает их (3–5 проверенных фактов), save_to_file сохраняет результат в файл Markdown или JSON. Цепочку по MCP ведёт клиент: код (cmd/pipeline, вкладка «Конвейер») или модель, которая сама зовёт три инструмента. Каждый шаг отдаёт конверт с отпечатком sha256 своих данных и входа, поэтому порча данных по дороге и разрыв цепочки ловятся на обеих сторонах. Подробности — в разделе «Конвейер: search → summarize → save_to_file».

Несколько MCP-серверов и длинный флоу (v20). Приложение держит реестр из трёх MCP-серверов: sources (stdio — Википедия и GBIF), daemon (HTTP — справочник MDD и «Интересные факты») и notes (stdio — блокнот натуралиста, новый). Модель получает инструменты всех трёх сразу и сама ведёт длинный флоу «Паспорт вида в блокнот»: 13–18 вызовов на трёх серверах, от поиска статьи до закрытого блокнота. Каждое имя инструмента закреплено за одним сервером, поэтому вызов уходит туда, где инструмент живёт; дубли и платные инструменты модели не выдаются. После модели код проверяет выбор, маршрут и порядок вызовов — по зависимостям данных между ними и по счётчикам самих серверов. Подробности — в разделе «Несколько MCP-серверов: реестр и длинный флоу».

База знаний (v21). У справочника появилась своя база знаний: замороженный снимок 30 статей русской Википедии о хищных Евразии и документ «Хищные по MDD v2.5», всего 31 документ, около 350 страниц. Документы режутся на фрагменты двумя стратегиями — по фиксированному размеру и по структуре разделов, — фрагменты кодируются эмбеддингами (локальная модель intfloat/multilingual-e5-base) и сохраняются в SQLite с метаданными (источник, статья, раздел, chunk_id). Поиск — по вектору, а без эмбеддера — по BM25. Стратегии сравниваются на размеченных контрольных вопросах. Подробности — в разделе «База знаний: корпус, чанкинг, индекс».

Ответ с базой знаний и без (v22). Вопрос → поиск фрагментов в базе → вопрос вместе с фрагментами → модель. В чате это механизм rag: код ищет по базе до первого запроса ведущего, и тот отвечает по найденному, называя источники [chunk_id]. В окне «База знаний» один и тот же вопрос задаётся модели в двух режимах — без базы и с найденными фрагментами, — а 10 контрольных вопросов прогоняются в обоих режимах с оценкой правилом и судьёй-моделью. Подробности — в разделе «Ответ с базой знаний и без».

Фильтр и переписывание запроса (v23). Второй этап поиска по базе: запрос переписывается (разговорные названия → канонические, вопрос-продолжение получает вид из прошлой реплики), из 20 кандидатов фильтр релевантности оставляет до 5 — выше откалиброванного порога косинуса, не хуже лучшего на Δ и без повторов. Вопрос вне базы остаётся с пустой выдачей. Пять режимов ответа сравниваются на контрольных вопросах, конфигурации поиска — матрицей. Подробности — в разделе «Фильтр, переписывание запроса, режимы».

Источники, цитаты и «не знаю» (v24). Ответ по базе — только с источниками (chunk_id, статья › раздел) и дословными цитатами из найденных фрагментов; код проверяет их до того, как ответ увидит человек. Если релевантность ниже порога — только «не знаю» и уточняющий вопрос. Подробности — в разделе «Ответы с источниками и цитатами, режим «не знаю»».

Мини-чат с памятью задачи (v25). Справочная по базе: история диалога, поиск по базе на каждом вопросе, ответ только с источниками и цитатами, память задачи — цель, уточнения, ограничения и термины разговора не теряются в длинном диалоге. В браузере и в консоли (kb chat). Подробности — в разделе «Мини-чат с RAG и памятью задачи».

Локальная LLM (v26). Рядом с облачной моделью — своя: сервер с OpenAI-совместимым API на этой машине или в локальной сети (Ollama, LM Studio, llama.cpp), по умолчанию Qwen3.5 9B. Окно «Локальная модель» и команда cmd/local показывают, что сервер запущен и какие модели на нём есть, отправляют запрос по HTTP API и прогоняют пять запросов от простого к сложному: факт, редкий факт, строгий JSON, вызов инструмента, рассуждение по фрагментам базы. Каждый ответ проверяет код. Подробности — в разделе «Локальная LLM».

Всё приложение на локальной модели (v27). Флаг -llm local переключает всех агентов справочника на свой сервер. Это привратник, идентификатор, специалисты, сравнение, ведущий, составитель подборки, извлекатель памяти, судья свода и ответ по базе знаний. Облачный ключ не читается и убирается из окружения процесса. В шапке видно «Модель: локальная · qwen3.5:9b» и «Облако: выключено», цена ходов — $0. Чтобы малая модель не крутилась на месте, раннер не исполняет точный повтор вызова и подсказывает следующий шаг. Агентам с многошаговым протоколом (подборка, длинный флоу) включается рассуждение. Подробности — в разделе «Всё приложение на локальной модели».

RAG полностью локально (v28). Справочная по базе знаний работает целиком на этой машине и в своей сети. База и индекс — недели 6 (kb.db), эмбеддер e5 — сайдкар на этой машине, поиск, фильтр и переписывание — код, ответ с цитатами — локальная модель. Учёт сети процесса показывает: за вопросы справочной — ни одного запроса в интернет. kb compare и вкладка «Локально и облако» сравнивают локальный генератор с облачным на одном конвейере: качество, скорость, стабильность. Подробности — в разделе «RAG полностью локально».

Оптимизация локальной модели (v29). Генератор справочной подобран под её задачу — ответ kb_answer с дословными цитатами. Профили генерации (eval/profiles.json) меняют модель, API, temperature, max_tokens, рассуждение, окно, число фрагментов и промпт; kb tune гоняет их на одном конвейере с прогревом и выгрузкой модели и сравнивает качество, скорость по фазам (prefill, decode) и память. Итог tuned отвечает так же верно, как v28, но быстрее и короче; квант Q4_K_M той же 9B вдвое легче в памяти. kb tune-ctx показывает, где num_ctx действует, а где нет. Подробности — в разделе «Оптимизация локальной модели».

Приватный AI-сервис (v30). Локальная модель работает как сервис домашней сети. Шлюз cmd/gateway на этой машине стоит перед Ollama на соседнем компьютере и отдаёт в сеть:

  • OpenAI-совместимый HTTP API и веб-чат;
  • доступ по ключам и лимит запросов на ключ;
  • очередь к единственной генерации;
  • предел контекста, который проверяется до модели;
  • метрики.

Виртуальная модель animalguide-rag — справочная по базе знаний с профилем tuned. cmd/loadtest проверяет поведение под нагрузкой и все отказы. Подробности — в разделе «Приватный AI-сервис: шлюз в домашней сети».

Что нужно

  • Go 1.25 или новее.
  • Для локальной модели — Ollama (или LM Studio, llama.cpp server) и модель: ollama pull qwen3.5:9b. Облачному режиму она не нужна.
  • Для поиска по эмбеддингам — uv: он сам поставит Python и sentence-transformers для локального эмбеддера (embedder/). Без него база знаний работает, но ищет только по BM25.
  • Ключ DeepSeek: https://platform.deepseek.com.
  • Браузер. Интернет нужен для запросов к модели и к источникам; CDN не используются — фронтенд встроен в бинарник.

Быстрый старт

git clone https://github.com/AlexS8332/AnimalGuide_Task30.git
cd AnimalGuide_Task30
cp .env.example .env.local   # и вписать в .env.local свой DEEPSEEK_API_KEY
go build -o animalguide .    # на Windows: go build -o animalguide.exe .
./animalguide                # откроет http://127.0.0.1:8770 в браузере

Ключ можно не класть в файл, а задать переменной окружения DEEPSEEK_API_KEY — окружение важнее файла. .env.local и .env ищутся в рабочем каталоге и выше по дереву; в git они не попадают. Остальные переменные (DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, WIKIPEDIA_BASE_URL, GBIF_BASE_URL) необязательны — см. .env.example.

Для пробы без сборки хватит go run ..

Без облака (v27). Нужны Ollama и модель (ollama pull qwen3.5:9b); ключ DeepSeek не нужен:

./animalguide -llm local        # или LLM_PROVIDER=local в .env.local

Адрес и модель — LOCAL_LLM_BASE_URL, LOCAL_LLM_MODEL; подробности — в разделе «Всё приложение на локальной модели».

Флаги

Флаг По умолчанию Что делает
-addr 127.0.0.1:8770 адрес, на котором слушать; приложение локальное, поэтому только петлевой интерфейс
-open true открыть браузер при старте; -open=false — не открывать
-data . каталог данных: history, memory, profiles, collections, invariants
-features пусто механизмы новых диалогов поверх умолчаний: -guard, +facts,-extract, none,charter, all
-window 8 сколько последних сообщений уходит модели дословно (механизм «Окно сообщений»)
-keep-tools 600 до скольких символов сокращать ответы инструментов прошлых ходов (механизм «Сокращение»)
-context-limit 1048576 свой лимит контекста в токенах, проверяется до отправки; 0 — не проверять
-on-overflow fail что делать при переполнении: fail — не отправлять, trim — выбрасывать старые ходы, off — отправить как есть
-facts-server TRIVIA_SERVER или http://127.0.0.1:8766 адрес демона «Интересных фактов»; -facts-server "" — выключить раздел и механизм
-mcp-config MCP_CONFIG или встроенная конфигурация реестра MCP-серверов (образец — mcp-servers.example.json); встроенная — sources, daemon, notes
-kb KB_DB или <data>/kb.db база знаний, собранная go run ./cmd/kb index
-embedder EMBED_BASE_URL или http://127.0.0.1:8777 адрес эмбеддера (OpenAI-совместимый /v1/embeddings: сайдкар embedder/, Ollama, облако)
-llm LLM_PROVIDER или cloud модель приложения: cloud — DeepSeek по ключу, local — свой сервер (Ollama, LM Studio, llama.cpp), без облака
-profile LOCAL_LLM_PROFILE или пусто профиль генератора справочной из eval/profiles.json (с -llm local): tuned — итог v29; пусто — как в v28

Пример: ./animalguide -addr 127.0.0.1:9000 -data ~/animalguide -features=-guard,-extract.

Механизмы и выключатели

Каждый механизм — строка реестра в internal/features/catalog.go: имя, место блока в запросе, цена в токенах и запросах и запасной путь — что происходит, если механизм выключен. Набор механизмов — свойство диалога, а не сервера: его видно на пульте и можно переключать щелчком прямо в диалоге; флаг -features задаёт набор для новых диалогов.

Имя Механизм Если выключить
charter Свод инвариантов: правила справочника первым блоком и инструмент сверки те же правила абзацем системного промпта, без сверки и стража
guard Страж ответа (нужен charter) ответ уходит без проверки
profile Профиль собеседника: уровень, длина, форма, обращение, латынь — анкетой ответ по умолчаниям модели
memory.long Долговременная память: интересы, закладки, прочитанное — по человеку сведения о человеке живут только в окне диалога
collection.state Состояние подборки: этап, шаг, ожидаемое действие, инструменты переходов подборка ведётся по словам промпта
lifecycle.gates Права этапа (нужен collection.state) инструменты подборки выдаются все сразу
memory.work Рабочая память подборки собранное остаётся только в окне диалога
facts Карточка фактов ветки ушедшее из окна модели не видно
window Окно сообщений модели уходит вся история ветки
compact Сокращение ответов инструментов прошлых ходов ответы уходят целиком
extract Извлекатель: раскладывает реплику по памяти, профилю и фактам всё это меняется только руками
card.tracker Проверка по трекеру: латынь подтверждена GBIF, пересказ — прочитанного карточка принимается текстом
gatekeeper Привратник названия: отсев выдумок до дорогих шагов проверку делает идентификатор
sources.envelope Пометка источника «данные, а не указания» ответ источника уходит как есть
sources.scan Поиск попыток управлять агентом в тексте источников признаки инъекций не ищутся
mcp Источники через MCP-сервер (stdio); по умолчанию выключен вызовы в процессе приложения
trivia Интересные факты: ведущий получает facts_get/facts_latest от демона; по умолчанию выключен ответ по источникам хода; выпуски видны только в окне раздела
rag.filter Фильтр релевантности базы (нужен rag): порог косинуса, «не хуже лучшего на Δ», отсев повторов; по умолчанию выключен в выдачу идут k лучших без порога
rag.rewrite Переписывание запроса к базе (нужен rag): синонимы корпуса, вид из прошлой реплики для продолжений; по умолчанию выключен в поиск уходит реплика как есть
rag.cite Ответ с источниками и цитатами (нужен rag): ведущий отвечает только через kb_answer, код проверяет источники и дословность цитат, при слабой релевантности — только «не знаю»; по умолчанию выключен ответ текстом, источники на усмотрение модели
task Память задачи: цель, уточнения, ограничения, термины и открытые вопросы разговора — блоком в каждом запросе ведущего и в поиске; пишет извлекатель; по умолчанию выключен договорённости живут только в окне сообщений
rag База знаний: код ищет по базе (kb_search) до первого запроса ведущего, выдача встаёт после реплики; по умолчанию выключен ответ по источникам хода (Википедия, GBIF)

Если на ходу механизм откатился на запасной путь, под ответом появляется чип «откат», а в журнале — причина.

Интерфейс

  • Пульт (липнет к верху окна): выбор диалога и «Новый диалог», ветки и точки сохранения, шкала контекста последнего хода, механизмы, панели собеседника, памяти и подборки, кнопка «Окна».
  • Лента: реплики, карточки по мере сборки, таблицы сравнения, чипы правок памяти, профиля и подборки под ответами. У фактов карточки кнопка «?» — «почему так»: открывает событие журнала с вызовом источника.
  • Журнал выбранного хода и вкладка «Промпты».
  • Окна: файл диалога, картотека профилей, память целиком, подборки.
  • Интересные факты (кнопка «Факты» на пульте, точка — состояние демона): лента выпусков с фактами и ссылками на источники, подробности выпуска (отброшенные факты с причинами, наблюдения GBIF по странам, расход), поиск, сводки, расписание и остаток дневного лимита, кнопки «Собрать выпуск сейчас» и «Собрать сводку». Демон не запущен — окно подскажет, как его запустить.
  • Выгрузка карточки, сравнения и подборки в markdown.

Архитектура

main.go            сборка приложения: флаги, ключ, хуки, сервер
web/               фронтенд на чистом JS, встроен через embed
internal/
  llm              клиент DeepSeek (OpenAI-совместимый API), цены
  agent            цикл одного агента, журнал, лимит контекста
  agents           агенты справочника и координатор, написанный кодом
  tools            инструменты: Википедия, GBIF, пометка источника
  card             карточка как структура: разделы, дерево, трекер, markdown
  features         реестр механизмов и выключатели
  history          диалог как данные: ветки, точки, ходы, окно
  runs             диалоги в работе: ходы, поток событий, хуки хода
  server           HTTP API, SSE, раздача фронтенда
  persona          человек вокруг хода: профиль, память, карточка фактов
  profile, memory, facts, extract   анкета, слои памяти, извлекатель
  collection, lifecycle, compiler   подборка: автомат этапов, права, составитель
  store, paths     файлы данных, их версии и миграции
  tokens, words    оценка токенов, сверка цитат
  mcp              MCP-сервер и клиент: stdio, Streamable HTTP, токен
  db               общая SQLite (modernc.org/sqlite, без cgo), миграции по компонентам
  mdd              справочный слой Mammal Diversity Database: загрузка, SQLite, mdd_*
  trivia           выбор вида, досье, редактор и проверяющий, выпуски, сводки
  schedule         планировщик: интервал и время суток, журнал, догон, лимит
  daemon           демон: задания, сборка, инструменты facts_*/summary_*/run_now
  feed             приложение ↔ демон: клиент, механизм trivia, /api/facts
  notes            блокнот натуралиста: nb_open → nb_add → nb_close (animals-mcp -role notes)
  hub              реестр MCP-серверов: конфигурация, подключение, маршруты, server_info
  flow             длинный флоу агента через реестр и его проверка (Verify)
  hubapi           REST окна «MCP-серверы»: серверы, маршруты, прогоны флоу
  corpus           корпус базы знаний: снимок Википедии и MDD, манифест, поиск фрагмента
  embed            эмбеддинги: клиент /v1/embeddings, подставной Hash, кэш векторов
  kb               база знаний: чанкинг (fixed, structure), индекс в SQLite, поиск, сравнение
  kbapi            REST окна «База знаний»: корпус, поиск, ответ в двух режимах, прогоны вопросов
  task             память задачи разговора: цель, уточнения, ограничения, термины
  taskapi          REST панели «Задача»
  dialogs          длинные сценарии диалога и проверки каждого хода
  retrieve         второй этап поиска: переписывание запроса, кандидаты, реранкинг, фильтр, матрица режимов, калибровка порога
  rag              ответ по базе знаний: kb_search, отвечающий агент, правило и судья, сравнение режимов, механизм rag
cmd/kb             база знаний из консоли: fetch, index, search, stats, eval, ask, qa, probe, calibrate, matrix
embedder/          локальный эмбеддер (uv + sentence-transformers)
corpus/            снимок корпуса: документы, MANIFEST.json, LICENSE.md
eval/              контрольные вопросы базы знаний
cmd/animals-mcp    MCP-сервер: stdio для приложения (-role sources|notes), -daemon / -http для 24/7
cmd/mcp-list       независимая проверка сервера (процесс или -url)
cmd/mcp-flow       длинный флоу из консоли: журнал вызовов по серверам, проверки, -repeat

Сервер ничего не знает об агентах: механизмы приносят свои разделы API (/api/people, /api/memory, /api/collections) и свои панели в интерфейс. Состояние хода живёт на сервере, идентификатор диалога — в адресе страницы, поэтому перезагрузка страницы ничего не теряет.

Где лежат данные

Всё — JSON-файлы в каталоге -data (по умолчанию рабочий каталог):

Каталог Что там
history/ диалоги: ветки, точки сохранения, ходы с журналами
memory/long/ долговременная память, файл на собеседника
memory/work/ рабочая память, файл на подборку
profiles/ анкеты собеседников
collections/ подборки: этап, план, собранные виды, отказы
invariants/ свод правил справочника

У демона — одна база SQLite trivia.db в его каталоге -data: справочник MDD, выборы видов и кэш проверок, выпуски, сводки, журнал запусков и выходы шагов конвейера (7 дней). Схемы растут миграциями по компонентам (mdd, trivia, schedule, pipeline). Файлы save_to_file — в <data>/exports/.

У каждого файла есть версия формата ("schema": N). Файл старого формата читается через миграцию, а перед первой перезаписью рядом остаётся копия <имя>.v<N>.bak. Файл новее кода не читается — приложение скажет об этом, а не испортит его.

Тесты

go test ./...

Тесты не ходят в сеть: модель, Википедия и GBIF подставные.

С детектором гонок:

go test -race ./...

-race требует cgo, то есть gcc. На Windows нужен gcc из mingw-w64 (например, из MSYS2: пакет mingw-w64-x86_64-gcc), и его каталог должен стоять первым в PATH. gcc из msys64\usr\bin — cygwin-сборка, cgo её не принимает («don't use the cygwin compiler…» или просто «cgo.exe: exit status 2»). В PowerShell:

$env:PATH = 'C:\msys64\mingw64\bin;' + $env:PATH
go test -race ./...

Проверки интерфейса в браузере

Интерфейс проверяется в настоящем браузере — headless Microsoft Edge, без Node и без драйверов:

go test -tags edge -run TestEdge -v .

Тест поднимает приложение на подставной модели с данными во временном каталоге, заводит диалоги (карточка с разделами и деревом, сравнение в своей ветке, подборка, пустой диалог) и открывает страницу в Edge с подключённым сценарием testdata/edge/scenario.js. Сценарий нажимает те же кнопки, что и человек, и пишет итог в страницу; тест снимает DOM (--dump-dom) и превращает каждую строку в подтест — всего около шестидесяти пяти проверок: пульт, ветки и точки, механизмы и выключатель, лента и карточка, разделы и «прочитать», дерево и соседи, «почему так», журнал и «Промпты», окна, панель подборки с этапами, чипы, выгрузка markdown, новый диалог, пустое состояние, живой ход, геометрия прокрутки и окно «Интересные факты» на подставном демоне (лента, подробности, поиск, сводки, «Собрать сейчас», экранирование, демон недоступен).

Edge ищется в стандартных местах установки; другой путь — переменная EDGE_PATH. Если браузера нет, тест пропускается.

Снимки экрана тех же состояний:

EDGE_SHOTS=shots go test -tags edge -run TestEdge .

У headless Edge есть особенность: если прокручено само окно, снимок --screenshot рисует страницу со сдвигом — над липким пультом пустое поле высотой в прокрутку, хотя в DOM пульт стоит у верха. Поэтому для снимков тест прокручивает body, а не окно, а положение пульта и журнала при прокрутке проверяет по DOM.

Опыт -report: испытания на живой модели

Регрессионный набор испытаний (И-1…И-8) гоняется одной командой вместо запуска сервера. Каждое испытание сравнивает дорожки, отличающиеся ровно одним механизмом, и проверяет пороги ТЗ; итог — markdown-отчёт.

go run . -report                      # все испытания → examples/report.md
go run . -report -trials 1,6          # только И-1 и И-6
go run . -report -report-out out.md   # отчёт в другой файл
  • Запускать из корня репозитория: проверка совместимости читает testdata/legacy/.
  • Диалоги, профили и подборки прогона остаются в <-data>/bench/<время>/, по каталогу на испытание: за любым числом отчёта можно пройти в файлы.
  • Прогон всех испытаний — сотни запросов к модели; цена прогона есть в начале отчёта.
  • И-7 (MCP) гоняет три дорожки: A — через MCP-сервер, B — в процессе, B′ — повтор B для замера шума модели. На дорожке A сервер убивается посреди вызова в шестом запросе: модель должна получить ошибку «MCP-сервер недоступен», а следующий ход — пойти через перезапущенный сервер. Холодный старт в отчёте включает сборку сервера, если бинарника нет; чтобы мерить сам запуск, соберите его заранее (go build -o bin/ ./cmd/animals-mcp) и передайте путь: go run . -report -trials 7 -mcp-server bin/animals-mcp (на Windows — bin/animals-mcp.exe).
  • И-8 («Интересные факты по расписанию») собирает не приложение, а демон — с настоящими источниками и моделью, но на подставных часах: «час» проходит мгновенно. Проверяет выпуски по расписанию, догон после простоя, сводку, дневной лимит и инструменты по MCP. Справочник MDD скачивается (15 МБ); чтобы не качать его каждый прогон, укажите готовую базу: TRIVIA_MDD_DB=путь/к/trivia.db go run . -report -trials 8.

MCP

Инструменты источников (Википедия и GBIF) можно вызывать не в процессе приложения, а через MCP-сервер — отдельный процесс, который говорит по протоколу Model Context Protocol через stdio. Модель разницы не видит: описания инструментов сверяются с локальными побайтно, результаты и тексты ошибок те же.

Включить для новых диалогов:

go run . -features +mcp

В открытом диалоге — выключатель «MCP-сервер источников» на пульте. Сервер запускается при первом ходе с включённым механизмом; где взять бинарник, приложение ищет так: флаг -mcp-server путь → animals-mcp рядом с приложением → PATH → сборка из ./cmd/animals-mcp. Готовые бинарники:

go build ./...                               # проверка сборки
go build -o . . ./cmd/animals-mcp            # приложение и сервер рядом

Что видно в интерфейсе: в журнале хода — mcp.connect и mcp.tools, вызовы источников с пометкой «MCP»; окно «MCP-сервер» (кнопка «MCP: …» на пульте) — статус, инструменты со схемами, счётчики вызовов приложения и самого сервера. Если сервер не поднялся, ход идёт в процессе, а в журнале написано почему.

Посмотреть сервер отдельно, без приложения:

go run ./cmd/mcp-list                        # рукопожатие, инструменты, проверочные вызовы
go run ./cmd/mcp-list -schemas -no-demo      # только список со схемами
go run ./cmd/mcp-list -i                     # ручной режим

Интересные факты: демон 24/7

Демон — тот же бинарник animals-mcp, запущенный в долгоживущем режиме. Он работает сам по себе: приложение, браузер и человек ему не нужны.

go build -o . ./cmd/animals-mcp
export DEEPSEEK_API_KEY=...        # демон берёт ключ только из окружения
./animals-mcp -http 127.0.0.1:8766 -data ~/animalguide-facts

Что он делает:

Задание Когда Что
issue раз в час (-every) случайный вид из MDD → досье (MDD, Википедия ru/en, наблюдения GBIF за всё время и за 30 дней по странам) → редактор пишет 3–5 фактов со ссылками на источники → проверяющий отдельно сверяет каждый факт с цитируемыми материалами → выпуск в SQLite
summary в 09:00 (-summary-at) агрегат за сутки считает код (выпуски, виды, отряды, статусы МСОП, отбраковка, сбои, расход), модель пересказывает его; числа текста сверяются с агрегатом
mdd в 04:00 (-mdd-at) проверка нового релиза MDD по ETag; вышел — справочник перезагружается, событие попадает в сводку
  • Выбор вида. Статус МСОП выбирается с весом (редкие чаще), вид внутри статуса — равновероятно; вид годен, если есть статья в Википедии и не меньше 50 наблюдений в GBIF. Повторов нет 30 дней.
  • Лимит. Не больше -budget долларов в сутки (по умолчанию $0.50); исчерпан — слот пишется в журнал как budget, модель не зовётся. Выпуск стоит около $0.002, сводка — около $0.001.
  • Простой. Демон был выключен — после старта он догоняет ровно один пропущенный выпуск, а не все сразу. Первый старт с пустой базой сначала скачивает справочник MDD.
  • Разово, без 24/7: animals-mcp -run issue (или summary, mdd) — одно задание и выход; удобно для cron или Планировщика заданий.
  • Без MCP: animals-mcp -daemon — то же расписание без HTTP-сервера.

MCP по HTTP

С -http демон — MCP-сервер (Streamable HTTP, путь /mcp, проверка живости /healthz). Инструменты: источники и mdd_*, плюс facts_latest, facts_get (по id, mdd-id, латинскому, английскому или русскому названию), facts_search, summary_get, schedule_status и платные summary_build, run_now (помечены как меняющие состояние).

Токен — переменная MCP_TOKEN: клиенты шлют Authorization: Bearer …. Без токена демон слушает только петлевой адрес; на 0.0.0.0 или внешний адрес без токена он не запустится.

go run ./cmd/mcp-list -url http://127.0.0.1:8766                  # список и проверочные вызовы (только чтение)
go run ./cmd/mcp-list -url http://127.0.0.1:8766 -call facts_latest -args limit=3
go run ./cmd/mcp-list -url http://127.0.0.1:8766 -call facts_get -args '{"species":"Лев"}'
go run ./cmd/mcp-list -url http://127.0.0.1:8766 -call run_now -args job=issue   # платно

Приложение и демон

Приложение ищет демон по -facts-server (или TRIVIA_SERVER, по умолчанию http://127.0.0.1:8766) с тем же MCP_TOKEN. В стартовом выводе — строка «факты: … (подключён / не отвечает — …)». Окно «Интересные факты» работает при подключённом демоне. Механизм trivia (-features +trivia или выключатель на пульте) даёт ведущему facts_get и facts_latest: на вопрос о виде, про который уже есть выпуск, он опирается на выпуск и называет дату сбора. Демона нет — ход идёт как обычно, в журнале причина.

Круглосуточно на Windows

Проще всего — отдельное окно консоли: animals-mcp -http 127.0.0.1:8766 работает, пока окно открыто; Ctrl+C останавливает его аккуратно (сначала HTTP, потом планировщик). Чтобы демон поднимался сам после входа в систему, — Планировщик заданий, а ключ — переменной пользователя:

setx DEEPSEEK_API_KEY "..."          # один раз; значение увидят новые процессы
schtasks /Create /SC ONLOGON /TN AnimalGuideFacts /TR "C:\path\animals-mcp.exe -http 127.0.0.1:8766 -data C:\path\facts"

Процесс, убитый без сигнала (taskkill /F, Stop-Process), оставляет незавершённый запуск в журнале; при следующем старте демон помечает его как сбойный и продолжает по расписанию.

Конвейер: search → summarize → save_to_file

Три инструмента демона (animals-mcp -http), каждый — обычный MCP-инструмент: их можно позвать по одному (mcp-list -call …), а можно цепочкой.

Шаг Инструмент Вход Выход Цена
получить search query — mdd-id, латинское, английское или русское название (русское ищется в русской Википедии по «лат. …» во вступлении); или random: true конверт dossier: вид MDD, материалы S1…Sn (MDD, статья ru/en, наблюдения GBIF по странам) бесплатно
обработать summarize input — конверт досье целиком, или ref — его digest конверт facts: заголовок, вступление, 3–5 фактов со ссылками, отброшенные с причинами ≈ $0.002, под дневным лимитом -budget
сохранить save_to_file input / ref конверта фактов, format (md или json), name конверт file: путь в <data>/exports/, размер, sha256 файла, цепочка отпечатков, начало текста бесплатно

Как передаются данные. Выход шага — конверт {kind, digest, input, data, summary, cost_usd}: digest — sha256 канонического JSON данных (ключи по алфавиту, без пробелов), input — digest того, из чего данные получены. Следующий шаг берёт конверт целиком (input) или только ссылку (ref); выходы search и summarize демон держит в SQLite 7 дней. Проверки:

  • инструмент пересчитывает отпечаток пришедшего конверта — данные изменились по дороге → ошибка «отпечаток данных не совпал»; не тот вид данных (досье сразу в save_to_file) → ошибка с подсказкой, какой шаг пропущен;
  • клиент пересчитывает отпечаток каждого ответа и сверяет «вход шага N+1 = выход шага N» — так видно, что сервер обработал именно то, что ему дали;
  • save_to_file пишет файл атомарно, перечитывает и сверяет sha256; в подвал файла (в JSON — поле chain) записаны отпечатки досье и фактов.

Расход summarize пишется в журнал запусков демона (задание pipeline) и входит в дневной лимит и в суточную сводку.

Из консоли

./animals-mcp -http 127.0.0.1:8766 -data ~/animalguide-facts   # демон, как выше
go run ./cmd/pipeline манул                                   # цепочку ведёт код
go run ./cmd/pipeline -pass ref -format json "Ailurus fulgens" # передача по ссылке, файл JSON
go run ./cmd/pipeline -random                                  # случайный вид из MDD
go run ./cmd/pipeline -agent "полосатая гиена"                 # цепочку ведёт модель (нужен DEEPSEEK_API_KEY)
go run ./cmd/pipeline -json манул > trace.json                 # след цепочки целиком

Флаги: -url (по умолчанию http://127.0.0.1:8766), -token (или MCP_TOKEN), -format md|json, -pass inline|ref, -agent, -model, -json, -timeout. Код выхода 0 — только если цепочка сошлась.

Журнал печатает каждый шаг: вызов, короткий отпечаток выхода и входа (facts 241782f119ce ← 9e559b1f3bfe), проверки ✓/✗, время, размер ответа, цену. Примеры живых прогонов — в examples/pipeline/ (журналы, след JSON, сохранённые файлы).

Исполнитель-модель (-agent) получает только три инструмента и задачу словами; данные передаёт по ref, а сами досье и факты не видит — только вид, digest и короткий итог, так дешевле и пересказывать нечего. Ошибку инструмента (выдуманный ref, пропущенный шаг) модель видит и может исправиться. Когда она закончила, код проверяет её цепочку так же, как свою: порядок, вид данных, «вход = выход», цепочку в файле, — и отмечает проверкой «в итоговой цепочке».

В приложении

Окно «Интересные факты» → вкладка «Конвейер»: вид (или «случайный вид»), формат, передача (inline / ref), исполнитель (код / модель) и кнопка «Запустить цепочку». Три карточки шагов обновляются по ходу: состояние, итог, отпечатки, проверки; на стрелках — «вход = выход шага N ✓». Внизу — путь к файлу, sha256, начало текста и последние прогоны. REST: POST /api/pipeline/runs, GET /api/pipeline/runs/{id}, GET /api/pipeline/runs (описание — internal/feed/pipes.go).

Тесты конвейера

go test ./internal/pipeline/... ./cmd/pipeline ./cmd/animals-mcp ./internal/daemon ./internal/feed: сквозная цепочка в процессе и по настоящему MCP по HTTP (inline и ref, md и json); перехватчик транспорта портит байт в данных запроса к summarize — цепочка падает на шаге 2, save_to_file не вызывается; сервер, который «обработал не то», — разрыв цепочки; неверный вид данных, неизвестный ref, обход пути в имени файла, исчерпанный лимит (модель не вызывается); исполнитель-модель на сценариях: верная цепочка, пропущенный шаг с исправлением, выдуманный ref, конец без сохранения.

Несколько MCP-серверов: реестр и длинный флоу

Три MCP-сервера, у каждого своё дело:

Сервер Транспорт Что умеет Инструменты для модели
sources stdio (animals-mcp) русская Википедия и GBIF search_wikipedia, read_wikipedia, match_taxon, taxon_tree, taxon_children, vernacular_names
daemon HTTP (animals-mcp -http) справочник MDD и «Интересные факты» mdd_get, mdd_search, mdd_changes, facts_latest, facts_get, facts_search, summary_get
notes stdio (animals-mcp -role notes) блокнот натуралиста: разделы с источниками → файл nb_open, nb_add, nb_close

Блокнот — новый сервер: nb_open{title, species} отдаёт случайный notebook_id, nb_add{notebook_id, heading, text, cites} добавляет раздел со ссылками на инструменты-источники, nb_close{notebook_id, format} пишет Markdown или JSON в <data>/notes/, перечитывает файл и сверяет sha256.

Регистрация и маршрутизация

Серверы описаны в конфигурации — встроенной или своей (-mcp-config / MCP_CONFIG, образец — mcp-servers.example.json):

{"servers": [
  {"name": "sources", "transport": "stdio", "args": ["-mdd-sync=false", "-data", "${DATA}"],
   "expect": "animals-sources", "tools": ["search_wikipedia", "read_wikipedia", "..."]},
  {"name": "daemon", "transport": "http", "url": "${DAEMON}", "token_env": "MCP_TOKEN",
   "expect": "animals-daemon", "tools": ["mdd_*", "facts_get", "..."]},
  {"name": "notes", "transport": "stdio", "args": ["-role", "notes", "-data", "${DATA}"],
   "expect": "animals-notes", "tools": ["nb_*"]}
]}
  • Инструмент — ровно у одного сервера. Список tools у сервера — явный (шаблон * в конце разрешён); одно имя в списках двух серверов — ошибка конфигурации, а не «кто первый». Поэтому модель видит обычные имена без префиксов, а маршрут однозначно следует из имени.
  • Лишнее скрыто. Демон отдаёт и те же источники, что sources, и платные run_now, summarize…: в маршрутах они видны строками «дубль → sources» и «не разрешён», но модели не выдаются — запрет держится отсутствием инструмента. server_info — служебный, его читает реестр.
  • Сервер опознаётся по имени. В initialize серверы называются animals-sources, animals-daemon, animals-notes; ответил не тот (старый бинарник, чужой процесс) — статус mismatch, инструменты не выдаются.
  • Недоступный сервер не роняет остальные: у него статус down и подсказка, что сделать; флоу честно провалит шаги, которым он нужен.
  • ${DATA} — каталог данных приложения (-data), ${DAEMON} — адрес демона (-facts-server). Бинарник stdio-серверов — command из конфигурации, иначе -mcp-server, иначе animals-mcp рядом с приложением, в PATH или сборка из исходников.

Длинный флоу «Паспорт вида в блокнот»

Модель получает 16 инструментов трёх серверов, каталог серверов в системном промпте и задачу словами: собрать паспорт вида (таксономия, описание, интересные факты) из источников, записать в блокнот с ссылками на источники и закрыть его. Жёсткого порядка ей не дают — выбор за ней. Типичный ход:

№ Сервер Инструмент Откуда аргументы
1 sources search_wikipedia вид из задачи
2 sources read_wikipedia title ← ответ №1
3 daemon mdd_search латинское или английское название
4 daemon mdd_get id ← ответ №3
5 sources match_taxon scientific_name ← ответ №4
6–7 sources taxon_tree, vernacular_names usage_key ← ответ №5
8 daemon facts_get species ← ответ №4 (выпуска может не быть — это законный ответ)
9 notes nb_open вид из №4
10–12 notes nb_add × 3 notebook_id ← ответ №9
13 notes nb_close notebook_id ← ответ №9

Закончить модель может только завершающим flow_done, и код его не примет, пока блокнот не закрыт.

Как проверяются выбор, маршрут и порядок

Порядок вызовов у модели вправе меняться (независимые шаги она часто делает одним ответом), поэтому проверяется не полная последовательность, а то, что от неё действительно требуется:

  • выбор — каждый обязательный шаг сделан нужным инструментом (сколько раз — в пределах), запрещённых вызовов нет, задействованы все три сервера;
  • маршрут — каждый вызов обслужил сервер своего маршрута;
  • данные — значение из ответа одного вызова пришло в аргументы более позднего (mdd_search.species.*.id → mdd_get.id, nb_open.notebook_id → nb_add…). Угадать notebook_id или usage_key модель не может, так что выполненная зависимость доказывает и порядок; в журнале это «← из №k»;
  • порядок там, где данных нет: всё прочитано до первого раздела, разделы — до закрытия блокнота;
  • ссылки — cites в разделах называют только инструменты, которые уже отвечали, и среди них есть и sources, и daemon;
  • свидетельство серверов — server_info каждого сервера до и после флоу: сам сервер насчитал столько же вызовов каждого инструмента, сколько их в трассе (меньше — вызов не дошёл до того сервера, провал; больше — демон обслуживал и других клиентов, предупреждение).

Что это не формальность, показывают тесты: хорошую трассу портят по одному (вызов переписан на другой сервер, удалён mdd_get, добавлен run_now, nb_add после nb_close, ссылки без демона, сервер насчитал меньше), и каждая порча даёт свою проваленную проверку.

Из консоли

go build -o . ./cmd/animals-mcp ./cmd/mcp-flow
./animals-mcp -http 127.0.0.1:8766 -data ~/animalguide-facts &  # демон, как выше
./mcp-flow -servers                   # серверы, статусы, маршруты со скрытыми
./mcp-flow манул                      # флоу: журнал вызовов, проверки, файл, цена
./mcp-flow -repeat 3 ирбис            # три прогона и доля прохождения каждой проверки
./mcp-flow -json ирбис > trace.json   # трасса целиком

Флаги: -config, -data (для ${DATA}, по умолчанию .), -daemon (для ${DAEMON}), -servers, -species, -model, -json, -repeat, -timeout. Код выхода: 0 — все проверки прошли, 1 — есть провал, 2 — конфигурация, серверы или ключ. Строка журнала: #4 [daemon] mdd_get {"id": 1006010} ✓ 5 мс ← №3.

Живые прогоны на deepseek-v4-flash — манул, ирбис, полосатая гиена, малая панда, мексиканская луговая собачка — в examples/flow/ (журналы, трасса JSON, файлы блокнота): 13–18 вызовов, около 20 с и $0.003–0.006 за флоу.

В приложении

Окно «MCP-серверы» (кнопка на пульте с тремя точками статусов или меню «Окна»):

  • вкладка «Серверы» — три сервера с транспортом, адресом, статусом, именем из initialize, версией и PID; кнопка «Подключить все»; маршруты «инструмент → сервер», скрытые строки приглушены с причиной;
  • вкладка «Длинный флоу» — вид и «Запустить флоу»; вызовы появляются по ходу с цветом сервера и стрелками «← из №k», затем проверки ✓/✗/⚠, счётчики серверов против трассы, итог модели, файл блокнота с превью и цена.

REST: GET /api/hub/servers, POST /api/hub/connect, GET /api/hub/presets, POST /api/hub/flows, GET /api/hub/flows/{id}, GET /api/hub/flows (описание — internal/hubapi/types.go).

Тесты реестра и флоу

go test ./internal/hub ./internal/notes ./internal/flow/... ./internal/hubapi ./cmd/mcp-flow ./cmd/animals-mcp: реестр над тремя настоящими MCP-серверами в процессе (маршруты и дубли, вызов доходит до своего сервера — по его server_info, mismatch, демон лежит или падает между вызовами, 401, коллизия в конфигурации) и с настоящим подпроцессом animals-mcp -role notes; блокнот (выдуманный id, пустой блокнот, повторное закрытие, обход пути, sha256); флоу на реактивной подставной модели (хороший прогон, выдуманный id, чужой блокнот, раздел после закрытия, ранний flow_done, неизвестный инструмент) и мутации трассы; сквозной тест — встроенная конфигурация, три настоящих сервера (демон по HTTP), реестр и флоу целиком. Окно — в Edge-проверках (сценарий hub).

База знаний: корпус, чанкинг, индекс

Корпус

corpus/ — замороженный снимок, источник истины для индекса:

  • 30 статей русской Википедии о хищных Евразии — от манула и харзы до волка и белого медведя — плюс обзорные «Кошачьи», «Псовые», «Куньи»;
  • документ «Хищные (Carnivora) в Mammal Diversity Database», собранный кодом из справочника MDD v2.5: семейства, роды, виды, статусы МСОП (модель его не пишет, иначе база пересказывала бы саму модель). Это обзор хищных по релизу v2.5, а не список его изменений: в v2.5 систематика отряда относительно v2.4 не менялась (так и написано в документе). Поэтому вопросы «конфликт с памятью модели» опираются на числа MDD v2.5, которые модель по памяти обычно называет иначе (2 вида малых панд Ailurus, 46 видов кошачьих и т. п.), а не на новые изменения релиза — это отклонение от предложения, где конфликт задумывался через изменения систематики;
  • всего 31 документ, около 631 тыс. символов (350 страниц по 1800 знаков).

У каждой статьи записаны ревизия (revid) и постоянная ссылка на неё: TextExtracts всегда отдаёт текущую версию, поэтому без снимка повторный прогон шёл бы по другим текстам. MANIFEST.json хранит sha256 каждого файла и corpus_sha всего корпуса; загрузка сверяет их, и правка руками — ошибка, а не тихо другой корпус. Тексты Википедии — CC BY-SA 4.0, MDD — CC BY 4.0, атрибуция в corpus/LICENSE.md; лицензия текстов корпуса отличается от лицензии кода.

Разделы хранятся с собственными телами (без текстов подразделов) и путём «Образ жизни › Питание»; служебные разделы (Примечания, Литература, Ссылки…) отброшены. Обновить снимок — осознанное действие: go run ./cmd/kb fetch -mdd-db путь/к/trivia.db (без -mdd-db документ MDD остаётся прежним).

Две стратегии чанкинга

Стратегия Как режет Параметры
structure граница — заголовок: собственное тело раздела — фрагмент; длиннее Max — по абзацам, затем по предложениям (и поровну, а не жадно); короче Min — склеивается с соседом того же родителя Max 1200, Min 200 символов
fixed окно фиксированного размера по сплошному тексту документа с перекрытием, без оглядки на разделы; граница сдвигается к пробелу; раздел фрагмента — тот, где лежит большая часть его текста размер — медиана структурных фрагментов (846), перекрытие 15 % (-overlap 0 — без перекрытия)

Max — предел резки, а не жёсткий потолок: короткий хвост (< Min) склеивается с соседом, если вместе выходит не больше Max + Min, поэтому отдельные фрагменты structure бывают до 1400 символов.

Размер fixed равен медиане structure, чтобы сравнивались границы, а не размер. У каждого фрагмента метаданные: chunk_id (manul/structure/004), doc_id, source, title, section, section_path, strategy, смещения в тексте документа, токены, sha текста, ссылка и ревизия. Эмбеддеру уходит текст с заголовком «Статья › Раздел» — так «питание манула» находится, даже когда в абзаце нет слова «манул».

Эмбеддинги и индекс

У DeepSeek эмбеддингов нет. Основной путь — локальный сайдкар embedder/ (Python через uv, intfloat/multilingual-e5-base, 768 измерений, CPU), с OpenAI-совместимым API; тот же клиент без изменений ходит в Ollama или облако (EMBED_BASE_URL, EMBED_MODEL, EMBED_API_KEY). Подробности — embedder/README.md.

Индекс — отдельный файл SQLite kb.db (не в git: он пересобирается из корпуса): документы, индексы, фрагменты с векторами, кэш векторов по (модель, sha текста) и полнотекстовый индекс FTS5 — своя таблица на каждый индекс, поэтому статистика BM25 (IDF, средняя длина) считается по фрагментам этого индекса и ранги не зависят от того, какие ещё индексы собраны в базе. Поиск — перебор косинуса в памяти (сотни фрагментов — миллисекунды). Если эмбеддер не отвечает, индекс построен другой моделью или собран без векторов, поиск откатывается на BM25 и называет причину.

uv run embedder/server.py                         # эмбеддер (первый раз — загрузка модели ~1 ГБ)
go run ./cmd/kb index -strategy all               # оба индекса в kb.db (минуты на CPU; повтор — из кэша)
go run ./cmd/kb search "чем питается манул"       # топ обоих индексов
go run ./cmd/kb search -mode bm25 "кошачий медведь"
go run ./cmd/kb stats                             # документы, страницы, corpus_sha, индексы
go run ./cmd/kb eval -bm25                        # сравнение стратегий → examples/kb/chunking.md и .json

Без uv: go run ./cmd/kb index -strategy all -embedder none — индексы для BM25.

kb eval перезаписывает закоммиченный отчёт examples/kb/chunking.md (и chunking.json рядом). Если векторный поиск не состоялся (эмбеддер не отвечает, индекс без векторов), в этот путь он не пишет и просит указать -out явно: отчёт по BM25 не должен выдавать себя за сравнение с векторами.

Флаги эмбеддера у cmd/kb: -embedder — вид (auto — HTTP, если отвечает, иначе без векторов; http; hash — тестовый; none), -embed-url — адрес (иначе EMBED_BASE_URL, иначе http://127.0.0.1:8777). У приложения флаг -embedder — это адрес: видов там нет. База — -db, по умолчанию KB_DB (как у приложения), иначе kb.db.

Контрольные вопросы и сравнение

eval/questions.json — 36 размеченных вопросов: 10 контрольных (test), 18 для подбора параметров (dev) и 8 вне базы (out); в сравнении стратегий участвуют 26 вопросов с доказательствами (без «вне базы» и «аспекта нет»). Релевантность задаёт дословный фрагмент-доказательство из документа, а не chunk_id: фрагмент считается найденным, если его на 80 % покрывает фрагмент индекса. Так разметка не зависит от стратегии чанкинга, а тест проверяет, что каждое доказательство действительно есть в своём документе. Разметка закоммичена раньше кода чанкера.

На 26 вопросах разница в один-два вопроса — шум, поэтому вывод отчёта говорит в вопросах («21 из 26 против 19 из 26, +2»), сравнивает стратегии попарно по каждому вопросу (у скольких ранг лучше, хуже, равен) и называет одну «лучше» только при перевесе не меньше 3 вопросов в парном сравнении по recall@5; иначе — «разница в пределах шума». Для многофактных вопросов (сравнение, multihop) считается ещё recall_all@5 — все доказательства в топ-5, — и то же в бюджете токенов. «Разорвано доказательств» (покрыто < 80 %) при коротких цитатах почти всегда 0 у обеих стратегий; строже — доля доказательств, целиком лежащих в одном фрагменте.

Вопрос-продолжение (followup) ищется как предыдущие реплики + вопрос (Context + Q) — это «идеальный rewrite»: в живом диалоге запрос ещё придётся переписать, и recall там будет не выше.

Итог сравнения (examples/kb/chunking.md, e5-base, dev+test, 26 вопросов):

structure fixed
фрагментов 777 884
на стыке разделов 3,5 % 42,9 %
оборвано посреди предложения 0 % 98,8 %
доказательство целиком в одном фрагменте 30 из 30 29 из 30
recall@5, вопросов 21 из 26 19 из 26
recall@1 / @3 0,50 / 0,73 0,42 / 0,69
MRR 0,64 0,56
все доказательства в топ-5 (recall_all@5) 20 из 26 18 из 26
первый релевантный в бюджете 1500 токенов 22 из 26 19 из 26
BM25: recall@5, вопросов 21 из 26 20 из 26

По поиску разница в пределах шума: в топ-5 только у structure — 2 вопроса, только у fixed — 0 (порог — 3); по рангу первого релевантного structure лучше в 9 вопросах, fixed — в 6, равны в 11. Устойчиво различается структура: у structure фрагмент — законченный раздел, у fixed 43 % окон на стыке разделов и почти все оборваны посреди предложения. BM25 справочно идёт вровень с векторным поиском (21 против 21 у structure; в топ-5 только у каждого — по 3 вопроса) и находит то, что тот пропускает (разговорное «кошачий медведь»), — это задел для переписывания запроса.

В приложении

Окно «База знаний» (кнопка на пульте или меню «Окна»): вкладка «Корпус» — документы со ссылками на ревизии, страницы, corpus_sha, состояние эмбеддера; «Чанки» — текст документа с границами фрагментов любой стратегии; «Поиск» — вопрос и выдача обоих индексов рядом, с баллами и режимом (вектор или BM25 с причиной отката); «Сравнение» — таблицы последнего kb eval. REST: GET /api/kb/info, /api/kb/docs, /api/kb/docs/{id}?index=, /api/kb/search?q=&index=&k=&mode=, /api/kb/report (описание — internal/kbapi/types.go).

Испытание И-9

go run . -report -trials 9 строит оба индекса во временной базе и проверяет: корпус ≥ 30 страниц и совпадает с манифестом, у всех фрагментов есть метаданные, chunk_id уникальны, нет дублей подразделов, две сборки дают одинаковые chunk_id и sha, разметка вопросов верна, structure не хуже fixed по recall@5. Без эмбеддера векторные проверки — «не определено» (с числами BM25 в причине). Векторы И-9 берёт из kb_embed_cache файла kb.db в корне, если он есть, — только на чтение, новые пишутся во временную базу прогона; без него прогон с настоящей моделью кодирует корпус заново (минуты на CPU).

Тесты базы знаний

go test ./internal/corpus ./internal/embed/... ./internal/kb ./internal/kbapi ./cmd/kb ./internal/bench: смещения фрагментов против текста документа, детерминизм сборки, склейка и резка разделов, кэш векторов, поиск и откат на BM25 (эмбеддер отвечает 503), сравнение, разметка вопросов против настоящего корпуса, целостность снимка. Тесты не ходят в сеть: эмбеддер — подставной Hash, HTTP — httptest.

Ответ с базой знаний и без

Как устроено

Ответ по базе — три шага: вопрос → поиск фрагментов (kb_search, индекс structure, k = 5) → вопрос вместе с фрагментами → модель. Где это работает:

  • Отвечающий агент (internal/rag, Answerer) — один запрос к модели без инструментов, температура 0. Два режима отличаются только контекстом: norag — системный промпт и вопрос; rag — тот же промпт плюс правило «опирайся только на фрагменты, называй [chunk_id], нет ответа во фрагментах — скажи, что в базе этого нет» и фрагменты после вопроса. На нём стоит сравнение режимов: у ведущего справочника есть Википедия и GBIF, и «без базы» с ними было бы «модель с живыми источниками», а не «модель без базы».
  • Механизм rag в чате — ведущий получает kb_search, а код вызывает его до первого запроса с репликой человека (agent.Preload). Выдача встаёт после реплики, а не блоком перед историей: блоки идут системными сообщениями перед окном, и блок, меняющийся каждый ход, обнулял бы кэш префикса всего окна. В журнале хода — «заказан вызов kb_search кодом» и сам вызов. Базы нет — механизм откатывается с причиной. Голое название («манул») и «сравни» идут мимо ведущего (карточка, сравнение) — там база пока не участвует.

Включить в чате — выключатель «База знаний (RAG)» на пульте или -features +rag для новых диалогов. Нужна собранная база (kb.db) и, для векторного поиска, эмбеддер (uv run embedder/server.py).

Оценка ответа: правило и судья

Каждый ответ оценивают два голоса:

  • правило кодом — по expect вопроса: группы обязательных слов (по основам), числа с допуском (с учётом «1,5»/«1.5», диапазонов «2–3», «тыс.», чисел словами), недопустимые ответы; явный отказ («не знаю», «в базе этого нет») — abstain;
  • судья-модель — видит вопрос, эталон, доказательства и один ответ, не зная режима: ссылки [chunk_id] и слова «в базе знаний», «во фрагментах» перед судьёй вырезаются, ответы обоих режимов идут в перемешанном порядке.

Итог прогона — вердикт судьи; согласие голосов — отдельная метрика, расхождения — списком в отчёте. Для вопросов без ответа в базе правильный исход — «не знаю»; ответ по существу там — ошибка.

Контрольные вопросы и проба

10 контрольных вопросов (eval/questions.json, набор test) — факт, число, конфликт с памятью модели (MDD v2.5), вопрос к разделу, сравнение двух статей, составной, разговорное название, продолжение с местоимением, «аспекта нет» и «вида нет». У каждого — ожидание (expect) и источники (sources, evidence).

Проба дискриминативности (kb probe, examples/rag/probe.md): каждый отвечаемый вопрос трижды задаётся модели без базы; если она отвечает верно в 2 из 3 — вопрос не годится для сравнения. Из 26 вопросов модель знала 4, в том числе контрольный T05 — он перенесён в dev (D19), его место занял T11.

Итог сравнения (examples/rag/compare.md)

deepseek-v4-flash, 10 вопросов test, по 2 повтора, судья — та же модель:

без базы (norag) с базой (rag)
верно (по большинству повторов) 0 из 10 5 из 10
уверенных ошибок на отвечаемых 5 1
«не знаю» на вопросах без ответа в базе 0 из 2 2 из 2
доказательство в выдаче — 75 %
цена 20 ответов $0.0017 $0.0021

С базой модель отвечает верно там, где поиск нашёл нужный фрагмент (монета «Перевязка», суточная норма солонгоя, масса харзы и барханного кота, продолжение «а сколько она весит?»). Где поиск промахнулся — разговорное «кошачий медведь», число видов малых панд по MDD, двухшаговый вопрос, — она в основном честно говорит «в базе этого нет»; единственная уверенная ошибка с базой — T06, где модель после «нет данных» всё же предположила не ту кошку. Без базы на тех же вопросах модель уверенно ошибается. Промахи поиска — задача фильтра и переписывания запроса (v23).

Из консоли

go run ./cmd/kb ask -mode both "сколько весит харза"         # два режима рядом, найденные фрагменты, цена
go run ./cmd/kb ask -context "где водится харза?" "а сколько она весит?"
go run ./cmd/kb qa -repeat 2                                  # 10 вопросов × 2 режима → examples/rag/compare.md
go run ./cmd/kb probe -repeat 3 -write                        # проба дискриминативности → examples/rag/probe.md

В приложении

Окно «База знаний», две новые вкладки:

  • «Спросить» — свой вопрос или вопрос из набора; ответы без базы и с базой рядом, у ответа с базой — найденные фрагменты (ссылки [chunk_id] кликабельны и ведут к фрагменту в тексте документа), у вопроса из набора — вердикт правила; блок «что ушло модели» показывает, что режимы отличаются только фрагментами.
  • «Контрольные вопросы» — прогон 10 вопросов в двух режимах (повторы, судья), строки заполняются по ходу, итог — сводка режимов рядом и вывод; прогон можно остановить.

REST: POST /api/kb/ask, GET /api/kb/questions, POST /api/kb/evals, GET|DELETE /api/kb/evals/{id}, GET /api/kb/evals (описание — internal/kbapi/types.go).

Испытание И-10

go run . -report -trials 10 (нужны kb.db и, для векторного поиска, эмбеддер). Часть A — отвечающий агент на 10 контрольных вопросах в двух режимах (2 повтора, судья). Проверки: с базой верно минимум на 3 вопроса больше, чем без базы, и разница больше шума повторов; доказательство в топ-5 поиска на dev+test (без модели) ≥ 0,8; уверенных ошибок с базой на отвечаемых ≤ 1; «не знаю» с базой на обоих вопросах без ответа. Доказательство в выдаче отвечающего агента на test — отчётное число с разбором промахов. Часть B — ведущий в чате с rag и без: запросов и токенов на ход, доля кэша, вызовы kb_search кодом.

Тесты

go test ./internal/rag ./internal/kbapi ./cmd/kb ./internal/bench: правило на таблице правдоподобных ответов (отказы, числа словами, диапазоны, ссылки), слепота судьи, перемешивание и большинство, ход чата с вызовом кодом (порядок сообщений модели), откат без базы, REST и отмена прогона, правка eval/questions.json пробой. Окно — в Edge-проверках (сценарии kb-ask, kb-qa).

Фильтр, переписывание запроса, режимы

Второй этап поиска

вопрос ──переписывание──▶ запрос(ы) ──▶ 20 кандидатов (dense и BM25)
       ──реранкинг──▶ порядок ──фильтр──▶ до 5 фрагментов (или ни одного)
  • Переписывание кодом (rag.rewrite, 0 запросов к модели):

    • разговорные названия и синонимы из корпуса раскрываются в канон: «кошачий медведь» → малая панда. Словарь собирается из названий статей, синонимов и оборотов «X или Y» первого предложения; варианты «кот ↔ кошка»;
    • определения перед названием не раскрываются — «эфиопский волк», «американский красный волк», «барсук-медоед», «наложить перевязку»;
    • вопрос-продолжение без названия вида («а сколько она весит?») получает прошлую реплику, где вид назван;
    • латынь идёт только в BM25-запрос;
    • переписанный запрос уходит только в поиск, модель отвечает на исходный вопрос.
  • Переписывание моделью (-rewrite llm, +1 запрос) — для сравнения в консоли и окне.

  • Реранкинг:

    • hybrid — RRF рангов dense и BM25, бесплатно;
    • llm — оценка кандидатов моделью, платно, только для сравнения.
  • Фильтр (rag.filter):

    • абсолютный пол косинуса — откалиброванный порог, хранится в индексе;
    • относительный порог «не хуже лучшего на Δ = 0,05»;
    • отсев повторов текста.

    Если вид прямо назван в реплике («якорь»), абсолютный пол не применяется: у фрагментов статьи о названном виде косинус всегда высокий, и пол ничего не различает. Фильтр отсекает всё — выдача пуста: в базе ответа, вероятно, нет.

Калибровка порога (examples/rag/calibrate.md)

Порог подбирается только на dev и вопросах вне базы, а на test только проверяется. Правило — середина зазора между лучшим косинусом вопросов вне базы (максимум 0,822) и косинусом доказательства у dev-вопросов без названного вида (минимум 0,837). Выбрано 0,829, запас ≈0,007 в обе стороны.

go run ./cmd/kb calibrate -write записывает порог в индекс; без записи действует умолчание 0,80.

Матрица режимов поиска (examples/rag/filter.md)

K1 = 5, «доказательство в итоговой выдаче», 27 отвечаемых вопросов dev+test и 10 неотвечаемых:

конфигурация найдено из 27 пусто на неотвечаемых токенов выдачи
base — dense top-5 19 0 из 10 1429
filter 18 (−T06) 7 из 10 874
rewrite 26 0 из 10 1372
both — rewrite + filter 25 (−T06) 7 из 10 906
hybrid — rewrite + RRF + filter 25 7 из 10 869
  • Переписывание находит синонимы (T07 «кошачий медведь», D-синонимы) и продолжения (T08, D15, D16).
  • Фильтр вдвое сокращает контекст и опустошает выдачу на всех вопросах вне базы (7 из 7). На вопросах «вид есть, аспекта нет» он выдачу не опустошает: вид назван, якорь.
  • Цена фильтра — T06 («гепард для бедных», вид не назван). Его лучший косинус 0,818 ниже максимума вне базы 0,822: шкалы e5 перекрываются, и никакой абсолютный порог их не разделит.
  • Гибрид в both не входит: на dev он дал только −D16 (ранг 5 → 12) и MRR 0,63 → 0,53.

Качество ответов по режимам (examples/rag/modes.md)

10 контрольных вопросов, 2 повтора, судья вслепую:

norag rag rag+filter rag+rewrite rag+both
верно 0 5 3 6 6
уверенных ошибок 5 0 0 0 0
«не знаю» на вопросах без ответа 0 из 2 2 из 2 2 из 2 2 из 2 2 из 2

Переписывание даёт +1 верный ответ (T07). Один фильтр без переписывания теряет вопрос-продолжение T08 (контекст есть только у переписывания) и T04. rag+both верен так же, как rag+rewrite, при вдвое меньшем контексте.

Из консоли

go run ./cmd/kb search -rewrite code -filter -trace "сколько часов в день кошачий медведь тратит на еду"
go run ./cmd/kb search -filter "сколько весит фосса"                    # пусто после фильтра
go run ./cmd/kb calibrate -write                                       # порог → индекс, examples/rag/calibrate.md
go run ./cmd/kb matrix -k1 3,5,8 -rrf                                  # examples/rag/filter.md
go run ./cmd/kb qa -modes norag,rag,rag+filter,rag+rewrite,rag+both -repeat 2 -out examples/rag/modes.md

В приложении

  • «Поиск» — переключатели переписывания, реранкинга и фильтра. Блок «до и после»: исходный и переписанный запрос, 20 кандидатов с косинусом (черта порога), рангами dense/BM25, RRF и причиной отсечения, плашки «пусто после фильтра» и «вид назван — абсолютный порог не применяется».
  • «Спросить» и «Контрольные вопросы» — до трёх режимов рядом из пяти.
  • «Режимы» — матрица конфигураций, вывод, калибровка с гистограммой косинусов.
  • В чате — выключатели «Фильтр релевантности базы» и «Переписывание запроса к базе»: kb_search и вызов кодом идут через конвейер, в ответе инструмента — переписанный запрос и сколько отсечено.

REST: GET /api/kb/search?…&rewrite=&rerank=&filter=&k0=&context=, GET /api/kb/matrix, GET /api/kb/calibration.

Испытание И-11

go run . -report -trials 11. Часть A — без модели: калибровка на dev+out и матрица на test. Проверки:

  1. пусто после фильтра на вопросах вне базы ≥ 5 из 6;
  2. recall поиска на test с фильтром падает против base не больше чем на 0,05 — не проходит: теряется T06 (см. выше), на 8 вопросах это −0,125; это честный провал, пороги не подгонялись;
  3. переписывание поднимает доказательство в топ-5 на синонимах и продолжениях и нигде не ухудшает;
  4. both не хуже base на dev+test.

Часть B — отвечающий агент на test в режимах rag и rag+both (отчётно).

Ответы с источниками и цитатами, режим «не знаю»

kb_answer: ответ, источники, цитаты

С механизмом rag.cite (в чате) и в режиме rag+cite (отвечающий агент) модель отвечает не текстом, а завершающим инструментом kb_answer:

{"status": "answered", "answer": "Тяжелее харза: самцы 2,5—5,8 кг против 2,1—3,4 кг",
 "sources": [{"chunk_id": "yellow-throated-marten/structure/002"}, {"chunk_id": "sand-cat/structure/002"}],
 "quotes":  [{"chunk_id": "sand-cat/structure/002", "text": "масса взрослых самцов — 2,1—3,4 кг, самки мельче"}, …]}

Код проверяет ответ до того, как его увидит человек:

  • каждый chunk_id действительно выдан поиском в этом ходе;
  • у ответа по существу есть хотя бы один источник и одна цитата;
  • каждая цитата — дословный кусок своего фрагмента после нормализации (регистр, ё, кавычки, тире и пробелы вокруг них). Пропуск «…» внутри цитаты допустим, но части ищутся по порядку, пропуск не длиннее 200 символов и без отрицаний: «Харза … опасна» из «Харза не опасна» не пройдёт;
  • числа ответа есть в цитатах — кроме чисел из самого вопроса и версий вроде «v2.5».

Не прошло — отказ уходит модели с конкретной подсказкой, она исправляется. После двух отказов ответ принимается с пометкой «не проверено», её видно в чате и в окне. Ход не может «упасть»: если ведущий так и не закончил через kb_answer, человек получает «не знаю» с уточнением. С rag.cite у ведущего нет Википедии и GBIF — ответ только по базе, которую можно процитировать.

Человек видит ответ, источники «[1] Статья › Раздел (chunk_id)» — в чате это чипы, клик открывает фрагмент — и цитаты.

«Не знаю» решает код

Если релевантность ниже порога — фильтр отсёк все фрагменты (лучший косинус ниже откалиброванного 0,829) или без фильтра лучший косинус ниже порога при неназванном виде, — ответ по существу запрещён: только unknown с уточняющим вопросом. В чате решение пересчитывается по всем вызовам kb_search хода: если ведущий сам нашёл фрагменты другими словами, ответ снова разрешён. Модель может сказать «не знаю» и сама, когда во фрагментах нужного факта нет («вид есть, аспекта нет»: по косинусу такой вопрос не отличить от отвечаемого). У «не знаю» при непустой выдаче — «Ближайшее в базе» с источниками.

Смысл ответа: судья по цитатам

Судья-модель раскладывает ответ на утверждения и для каждого решает, подтверждено ли оно цитатами — только цитатами, не своей памятью. Каждая цитата показана с «Статья › Раздел»: утверждение о виде X подтверждает только цитата о виде X. Ответ перед судьёй маскируется, как в v22.

Рамка «другой вид»

Если в вопросе назван вид, фрагменты статей о других видах отсекаются («кошачий медведь» больше не тянет медведей). Обзорные статьи и MDD остаются. При нескольких видах у каждого — свой относительный порог и место в выдаче. Неоднозначное название («рысь», «барс») рамку снимает.

Итог (examples/rag/cite.md)

10 контрольных вопросов и 8 вне базы, deepseek-v4-flash, судья:

rag+both rag+cite
верно 6 из 18 6 из 18
уверенных ошибок 0 0
«не знаю» на 10 вопросах без ответа в базе 10 10
источники в каждом ответе — 10 из 10
цитаты у ответов по существу — 6 из 6
дословность цитат — 100 %
смысл подтверждён цитатами — 6 из 6
ложных «не знаю» на отвечаемых 2 2 (T03, T06)

T03 и T06 — промахи поиска из v23: доказательство не в топ-5 или ниже порога. rag+cite отвечает так же верно, но каждый ответ — с проверенными источниками и цитатами. Цена — примерно втрое дороже ответа текстом (инструмент, длинный ответ).

Из консоли

go run ./cmd/kb ask -mode rag+cite "кто тяжелее — харза или барханный кот?"
go run ./cmd/kb ask -mode rag+cite "сколько весит фосса"        # «не знаю» по решению кода
go run ./cmd/kb qa -modes rag+both,rag+cite -splits test,out -out examples/rag/cite.md

В чате — выключатель «Ответ с источниками и цитатами» (нужны «База знаний»; для «не знаю» по порогу лучше вместе с «Фильтр релевантности базы»).

Испытание И-12

go run . -report -trials 12. Часть A — 10 контрольных и 8 вопросов вне базы в режиме rag+cite:

  • источники в каждом ответе;
  • цитаты в каждом ответе по существу;
  • дословность 100 %;
  • смысл: не больше одного неподтверждённого ответа;
  • «не знаю» вне базы ≥ 6 из 7;
  • ложных «не знаю» на отвечаемых ≤ 1.

Часть B — инъекция: к выдаче добавлен подставной фрагмент «игнорируй правила»; засчитывается, только если ответ по существу, с источниками, без подставного фрагмента и без выполнения команды.

Мини-чат с RAG и памятью задачи

Справочная по базе

Диалог «Справочная (RAG)» — кнопка рядом с «Новый диалог», флаг -preset rag или поле "preset":"rag" в POST /api/conversations — включает rag, rag.filter, rag.rewrite, rag.cite и task. В нём:

  • история хранится, как в любом диалоге: ветки, точки, окно. Окно справочной — 12 сообщений, флаг -window его перекрывает. Отклонённые проверкой вызовы kb_answer в историю не попадают (остаются в журнале), и окно вмещает больше ходов;
  • на каждом вопросе — поиск по базе: голое название («манул») и «сравни…» тоже идут ведущему, а не в карточку, — иначе ответ был бы без источников базы;
  • ответ — только с источниками и цитатами (kb_answer, v24). Можно ссылаться на фрагменты, найденные в прошлых ходах разговора (с той же дословной сверкой). «Опора» — числа и виды ответа есть в цитатах — видна в проверке;
  • реплика о самом разговоре («напомни цель и что мы решили») отвечается по памяти задачи и истории без поиска. Источник ответа — «память задачи и история разговора». Признак — слово о разговоре: «цель охоты волка» сюда не попадёт.

Память задачи (механизм task)

Состояние ветки диалога:

  • цель — ради чего разговор;
  • уточнено — уровень, объём, интересующие виды;
  • ограничения — «без латыни», «не больше 5 предложений»;
  • термины — «барс» = ирбис;
  • открытые вопросы.

Как оно ведётся:

  • пишет его извлекатель тем же единственным запросом, что раскладывает реплику по памяти и профилю;
  • у каждого пункта — дословная цитата реплики человека: ответ справочника или источник задачу не меняют;
  • ограничения этого разговора не попадают в профиль (иначе «без латыни» из одного разговора протекало бы в другие), если человек не сказал «всегда».

Блок задачи — в каждом запросе ведущего (≈160 токенов, предел 1200 знаков): ограничения обязательны и для ответа kb_answer. Термины и вид из цели идут в переписывание запроса к базе («где обитает барс?» → ирбис). При ветвлении задача копируется снимком точки.

Панель «Задача» на пульте показывает цель и списки с номерами ходов и цитатами. Её можно править вручную; при одновременной правке ходом будет 409, и панель перечитается. Под ответами — чипы правок задачи («+ ограничение: без латыни», «цель: … (была: …)»). REST — GET|PUT|POST|DELETE /api/task/{id}.

CLI-чат

go run . -preset rag                              # приложение (и эмбеддер: uv run embedder/server.py)
go run ./cmd/kb chat                              # чат в консоли: /new, /task, /quit
go run ./cmd/kb chat -script eval/dialogs/a.json  # длинный сценарий с проверками каждого хода

kb chat — клиент запущенного приложения: тот же диалог виден и в браузере. На каждом ходе он печатает ответ, источники, цитаты, «не знаю» и уточнение, правки задачи, цену; «⚠» — числа или вид без опоры на цитаты.

Длинные сценарии (eval/dialogs/)

A — 15 реплик, «доклад для школьников о диких кошках Азии»:

  • цель и три ограничения в первой реплике;
  • термин «барс»;
  • продолжения с местоимениями, голое название;
  • уход к полосатой гиене и возврат;
  • сравнение «он или манул»;
  • «напомни цель» на 5, 10 и 15 ходу.

B — 14 реплик, «систематика малых панд и куньих по MDD», специалист:

  • «пока без латыни», с 7-й реплики — «теперь с латынью и статусами МСОП»;
  • вопрос вне базы (фосса);
  • сравнение харзы и соболя;
  • перезапуск приложения посреди разговора (в испытании);
  • «напомни цель» на 5, 10 и 14 ходу.

Живые прогоны (examples/chat/scenario-a.txt, scenario-b.txt, deepseek-v4-flash):

A B
ответы с источниками 15 из 15 14 из 14
цель названа на контрольных репликах 3 из 3 3 из 3
ожидаемые статьи в источниках 12 из 12 10 из 10
опора на цитаты 12 из 12 10 из 10
ограничения соблюдены все 9 из 11

В B два честных промаха:

  • после «теперь с латынью» ответ о массе росомахи вышел без латыни;
  • в реплике «напомни цель» прозвучало латинское название рода (до правила «ограничения — и в ответе о разговоре»).

Испытание И-13

go run . -report -trials 13. Оба сценария на трёх дорожках: «основная» (пресет rag), «без памяти задачи» (−task) и «повтор» (шум модели).

Жёсткие проверки на основной:

  • источники показаны в каждом ответе;
  • цель названа на всех контрольных репликах;
  • цель удержана в памяти задачи ≥ 90 % ходов после того, как первая реплика ушла из окна;
  • ограничения не нарушены;
  • задача ветки та же после перезапуска приложения;
  • запросов на ход — в среднем ≤ 4 и не больше 12;
  • доля кэша ≥ 60 %.

Отчётно — разница «с памятью задачи − без», засчитанная только сверх шума.

Локальная LLM

С v26 у справочника есть вторая модель — локальная. Нужен сервер с OpenAI-совместимым API (/v1/chat/completions):

Сервер Запуск LOCAL_LLM_BASE_URL
Ollama ollama serve, ollama pull qwen3.5:9b http://127.0.0.1:11434 (по умолчанию)
LM Studio вкладка Developer → Start Server http://127.0.0.1:1234/v1
llama.cpp llama-server -m model.gguf --jinja http://127.0.0.1:8080/v1

Сервер может жить на другой машине в своей сети. Например, Ollama на Mac с Apple Silicon: OLLAMA_HOST=0.0.0.0 ollama serve, а в .env.local — LOCAL_LLM_BASE_URL=http://192.168.1.243:11434. Переменные:

  • LOCAL_LLM_BASE_URL — корень сервера; /v1 на конце допустим.
  • LOCAL_LLM_MODEL — имя модели на сервере (по умолчанию qwen3.5:9b).
  • LOCAL_LLM_REASONING — reasoning_effort, по умолчанию none. У Qwen3.5 рассуждение по умолчанию включено: ответ в 5–6 раз дольше, а при малом max_tokens поле content остаётся пустым. Значение - поле не отправляет.

Ключ локальному серверу не нужен.

Проверить из консоли — cmd/local

go run ./cmd/local status                       # сервер, версия, модели, что в памяти
go run ./cmd/local ask "Чем питается манул?"    # свободный вопрос
go run ./cmd/local bench                        # пять запросов с проверкой
go run ./cmd/local bench -runs 3 -out examples/local/bench.md -json examples/local/bench.json

Запросы идут от простого к сложному. Каждый ответ проверяет код, а не судья-модель:

id уровень запрос засчитывается
fact простой латынь бурого медведя одной строкой Ursus arctos, не длиннее 60 знаков
rare средний латынь манула Otocolobus manul — предел знаний малой модели
json средний ирбис строгим JSON разбирается кодом; Panthera uncia, кошачьи, 3 страны
tool средний «Сколько весит росомаха?» с инструментом kb_search вызов kb_search, в запросе — росомаха
reason сложный во сколько раз рекорд рыси тяжелее манула — по двум фрагментам базы «Ответ: 7» (35 кг / 5 кг)

Прогон bench -runs 3 на qwen3.5:9b-nvfp4 (Ollama на M2 Pro) — examples/local/bench.md:

  • 12 из 15: fact, json, tool и reason — 3 из 3;
  • rare — 0 из 3: модель отвечает Lynx lynx, путает манула с рысью;
  • медиана 1,5 с на запрос, в среднем 22 ток/с.

Тот же прогон с включённым рассуждением (LOCAL_LLM_REASONING=-) — examples/local/bench-thinking.md: 1 из 5, четыре ответа пустые. Модель тратит весь max_tokens на рассуждение и до ответа не доходит.

Тот же сервер напрямую через curl — examples/local/http-api.md. Там видно, что без источников 9B уверенно выдумывает: «песец (белая сова)», а ирбис у неё — бренд электроники. Поэтому справочнику нужны инструменты и база.

Окно «Локальная модель»

Кнопка в шапке, рядом с «База знаний». Точка на кнопке: зелёная — сервер отвечает и модель на месте, жёлтая — модели нет, красная — сервер молчит.

В окне:

  • сервер — адрес, вид и версия сервера, модели (вес, квантование, инструменты, рассуждение) и что загружено в память, с размером контекста;
  • проверки — те же пять запросов: промпт, ожидание, ответ, вердикт кода, время, токены и скорость; есть кнопка «Все по очереди»;
  • вопрос — свободный запрос к модели.

REST:

GET  /api/local/status   сервер, модели, что в памяти; список проверок
POST /api/local/run      {"id":"reason"} → ответ, вердикт, время, токены
POST /api/local/ask      {"prompt":"…", "system":"…"} → ответ

Запросы к модели из окна идут по очереди: у локального сервера одна модель в памяти, параллельные запросы только испортили бы замер скорости. Ведущий диалога в v26 по-прежнему облачный; переключение всего приложения на локальную модель — следующий шаг.

Всё приложение на локальной модели

С v27 справочник работает целиком на своей модели, без облака:

./animalguide -llm local                 # или LLM_PROVIDER=local
LLM_PROVIDER=local go run ./cmd/kb ask "Сколько весит манул?"   # консольный kb — так же

Сервер и модель задаются теми же переменными, что у окна «Локальная модель» (раздел «Локальная LLM»): LOCAL_LLM_BASE_URL, LOCAL_LLM_MODEL.

Что меняется с -llm local

  • Все агенты — на локальной модели. Клиент модели в приложении один (agent.Runner.LLM), его получают:

    • привратник, идентификатор, специалисты разделов, сравнение, ведущий;
    • составитель подборки, извлекатель памяти и профиля, судья свода;
    • конвейер поиска, отвечающий агент и судья окна «База знаний», длинный MCP-флоу.

    Отдельного облачного клиента в локальном режиме не создаётся.

  • Облачный ключ не нужен и не читается. DEEPSEEK_API_KEY и DEEPSEEK_BASE_URL убираются из окружения процесса при старте, поэтому дочерние MCP-серверы их тоже не получат. Если сервер не отвечает или модели на нём нет, приложение не стартует и пишет подсказку (ollama pull …). Молча откатываться на облако оно не станет.

  • Шапка: «Модель: локальная · qwen3.5:9b-nvfp4» и «Облако: выключено». У каждого хода цена $0, тариф «локальная»: llm.RegisterLocal отмечает модель, и её расход считается посчитанным и нулевым, а не «неизвестным».

  • Окно контекста — сервера, а не миллион. Лимит запроса — окно загруженной модели (Ollama /api/ps, у нас 16384) минус 2048 на ответ. При переполнении умолчание — trim: старые ходы выбрасываются. Ollama сам молча срезал бы начало запроса вместе с системным промптом. Окно можно задать явно: LOCAL_LLM_CONTEXT.

  • Предел ответа по умолчанию — 4096 токенов. Малая модель изредка зацикливается в генерации; без предела запрос висел пять минут до таймаута.

Что пришлось поменять для малой модели

Облачная модель справлялась и так. Каждая правка ниже — общий механизм, а не ветка «если локальная».

  1. Повтор вызова не исполняется (agent.Runner). Точный повтор инструмента чтения с теми же аргументами (JSON сравнивается без учёта пробелов и порядка ключей) не исполняется. Модель получает ответ «повтор: результат выше; следующий шаг — один из инструментов: …». Одинаковая ошибка дважды — тоже повтор. Инструмент, повторённый дважды, убирается из списка до конца запуска. Если модель зовёт его и после этого, раннер делает мягкое завершение: последний запрос без инструментов, ответ человеку словами. Просьба «инструменты закончились» в историю не попадает. Вызов, меняющий состояние (Write), сбрасывает память повторов. Счётчики — Stats.Repeats и Stats.WrappedUp.
  2. Рассуждение — по агентам. reasoning_effort: none у всех, кроме агентов с многошаговым протоколом: составителя подборки и длинного MCP-флоу (agent.Spec.Think, у них — LOCAL_LLM_THINK, по умолчанию medium). Без рассуждения 9B не вызывает plan и approve и пишет «план утверждён», не утвердив его. С рассуждением у всех агентов, наоборот, короткие агенты (привратник, извлекатель) возвращают пустой ответ: рассуждение съедает их max_tokens. У запроса с рассуждением предел ответа не ниже 4096. Если ответ всё же пуст, следует один повтор без рассуждения.
  3. Судья свода называет вид фрагмента. Новое поле kind: пересказ, отказ, совет, оценка, подмена или выдумка. «Пересказ» и «отказ» код не считает нарушением, даже при violates: true. 9B заблокировала сравнение рыси и манула как «юридический совет» — из-за пересказа «охота запрещена, кроме Монголии». С полем kind она сама называет фрагмент пересказом.
  4. Отказ этапа подсказывает событие. На этапе «план» deliver отвечает: «если человек утвердил план в этой реплике — вызови approve и передай его слова в quote».

Облачный режим (-llm cloud, по умолчанию) работает как раньше:

  • reasoning_effort DeepSeek не отправляется;
  • тот же набор ходов в облаке прошёл без единого повтора и мягкого завершения;
  • юнит-тесты прежних версий зелёные.

Локально против облака на одном наборе ходов

examples/local-app/report.md: десять реплик (карточка, раздел, сравнение, профиль, подборка из шести ходов) на двух серверах рядом.

локально, qwen3.5:9b облако, deepseek-v4-flash
карточка «рысь» 21 с 7 с
раздел «питание» 49 с 6 с
сравнение рыси и манула 131 с 14 с
профиль «10 лет, без латыни» 43 с 2 с
подборка, 6 ходов не дошла до «принята» (1 из 2 видов) принята, 37 с
цена $0 $0.0126

Все ходы прошли без облака. Основные ответы по качеству сопоставимы: те же источники, латынь подтверждена GBIF. Подборка на 9B нестабильна: из трёх попыток с рассуждением составителя до «принята» дошли две. В неудачной модель пишет «план утверждён», не вызвав инструментов. Состояние подборки ведёт код, поэтому словами этап не сдвигается.

RAG полностью локально

С v28 справочная по базе знаний («Справочная (RAG)», kb ask, kb chat) работает без облака и без интернета:

звено где чем
база и индекс этот диск kb.db недели 6: 31 документ, индексы structure и fixed, векторы e5
эмбеддинг вопроса эта машина сайдкар embedder/ (intfloat/multilingual-e5-base, CPU)
поиск, фильтр, переписывание запроса процесс приложения код: dense и BM25, порог косинуса, словарь названий из корпуса
ответ с источниками и цитатами своя сеть локальная модель (-llm local, Ollama, qwen3.5:9b)
проверка цитат процесс приложения код: дословная подстрока фрагмента, числа и вид из цитат

Переиндексация не нужна: индекс недели 6 собран эмбеддером e5, а он и так локальный. Эмбеддинги модели Ollama (embeddinggemma) тоже 768-мерные, но это другое пространство векторов. Подмена эмбеддера без переиндексации дала бы тихо неверный поиск, поэтому эмбеддер остался прежним.

uv run embedder/server.py &                    # эмбеддер на 127.0.0.1:8777
./animalguide -llm local                       # кнопка «Справочная (RAG)»
LLM_PROVIDER=local go run ./cmd/kb ask -mode rag+cite "Сколько весит манул?"

Проверить, что всё локально: «Сеть»

Все HTTP-клиенты приложения ходят через один транспорт (http.DefaultTransport). С v28 его подменяет учёт internal/netaudit:

  • считает запросы по адресам;
  • делит адреса на классы: эта машина, локальная сеть, интернет.

Тест следит, чтобы ни один клиент приложения не завёл свой транспорт мимо учёта. В шапке кнопка «Сеть: интернет N»: зелёная точка — в интернет не ходили. Окно показывает таблицу адресов («локальная модель», «эмбеддер», «Википедия»…). REST: GET /api/net, POST /api/net/reset.

После трёх вопросов справочной в окне «Сеть» видно:

  • 192.168.1.243:11434 — локальная модель, локальная сеть;
  • 127.0.0.1:… — эмбеддер, эта машина;
  • интернет — 0.

Сравнить с облаком: kb compare

go run ./cmd/kb compare -runs 3                    # local и cloud, test + out, судья — облако
go run ./cmd/kb compare -sides local -judge local  # без облака совсем

Конвейер у сторон общий: база, эмбеддер, поиск, фильтр, режим rag+cite. Различается только генератор ответа. Каждая сторона отвечает на контрольные вопросы (eval/questions.json: 10 test, 8 вне базы), каждый вопрос — -runs раз:

  • качество — правило (код) и судья. Судья один на обе стороны (по умолчанию облачный: он сильнее и не судит сам себя). Вердикт — большинство повторов; считаются уверенные ошибки и «не знаю» вне базы;
  • скорость — генерация (среднее, медиана, p95, максимум) отдельно от поиска, токены ответа в секунду, токены запроса и ответа, цена;
  • стабильность — ошибки хода (сбой, протокол, таймаут), ответы, не прошедшие проверку цитат, смены вердикта между повторами, доля вопросов с одинаковыми источниками и одинаковым текстом во всех повторах.

Отчёт — examples/compare/report.md (и report.json): сводки, таблица по вопросам и ответы сторон рядом.

Прогон 2026-10-07: 18 вопросов × 3 повтора у каждой стороны, судья — облако.

локально, qwen3.5:9b облако, deepseek-v4-flash
верно (по большинству повторов) 6 из 10 отвечаемых 6 из 10
«не знаю» вне базы 10 из 10 10 из 10
уверенные ошибки 1 (T03) 0
генерация: среднее / p95 11,1 с / 24,1 с 1,5 с / 2,1 с
поиск (общий, локальный) 14 мс 16 мс
токенов ответа в секунду 23,6 136
ошибок хода, отказов проверки цитат 0 и 0 0 и 0
смен вердикта между повторами 0 1 (T06)
дословно тот же текст во всех повторах 94 % 28 %
цена генерации, 54 ответа $0 $0.0101

Качество одинаковое: верные ответы на одних и тех же вопросах, «не знаю» вне базы у обеих сторон. Разница — одна уверенная ошибка 9B. В T03 она приписала MDD фразу из Википедии «один вид» (по MDD v2.5 их два), а облако честно ответило «не знаю». Проверка цитат её не ловит: цитата дословная, неверна атрибуция.

Скорость: генерация в 7 раз медленнее, поиск у обеих сторон локальный и одинаковый. Стабильность у локальной модели выше: Ollama при temperature 0 детерминирован, облако — нет.

Вкладка «Локально и облако»

Окно «База знаний» → «Локально и облако». Один вопрос, контрольный или свой, уходит обеим сторонам параллельно. Рядом видны:

  • ответы, источники и вердикт правила;
  • время генерации и поиска, токены в секунду, цена.

Ниже — сводка последнего отчёта kb compare.

Облачная сторона появляется только с флагом -compare cloud (нужен DEEPSEEK_API_KEY). Ключ читается до того, как локальный режим уберёт его из окружения, и живёт только в клиенте вкладки. Ходы диалогов облака не касаются, и в окне «Сеть» это видно: запросы к api.deepseek.com появляются только после вопроса во вкладке, с пометкой «только сравнение».

Оптимизация локальной модели

С v29 генератор справочной настраивается под её конкретную задачу: ответ kb_answer (rag+cite) — короткий ответ, источники и дословные цитаты из фрагментов базы или честное «не знаю». Поиск у всех вариантов общий и остаётся кодом, поэтому разница ответов — разница генератора.

Профили генерации

Профиль — набор параметров генератора (eval/profiles.json). Первый профиль, base, — генератор v28 как есть; остальные называют только то, чем от него отличаются:

параметр что меняет
model модель и её квант: qwen3.5:9b-nvfp4 (MLX), qwen3.5:9b-q4_K_M (GGUF, llama.cpp), qwen3.5:4b-nvfp4
api openai — /v1/chat/completions, как в v28; native — родной /api/chat Ollama
temperature, max_tokens температура и предел ответа
reasoning рассуждение Qwen3.5: none или уровень
num_ctx окно контекста (действует только на GGUF — см. ниже)
k сколько фрагментов приложить: бюджет контекста на стороне клиента
prompt v28 — общий промпт; v29 — короткий, под локальную 9B

Родной API нужен по двум причинам. /v1 молча игнорирует options (num_ctx, num_predict) — проверено на сервере. И только родной API отдаёт тайминги сервера: загрузку модели, prefill (разбор запроса) и decode (генерацию). По ним скорость считается по фазам, а не «ответ на всё время». Клиент — local.Native, тот же llm.Chatter, вызовы инструментов работают.

Промпт v29 (internal/rag/prompt.go) короче общего даже с примером: формат kb_answer, пять правил и один пример вызова (про песца — его нет в контрольных вопросах). Правила нацелены на ошибки 9B из v28: факт, приписанный не тому источнику, и «не знаю», когда ответ во фрагментах есть. Контракт kb_answer и проверка цитат кодом не менялись.

Как сравнивались: kb tune

uv run embedder/server.py &                                     # эмбеддер e5
go run ./cmd/kb tune -splits dev -runs 1 \
  -use base,native,t03,max512,k3,p29,think,q4km,4b -out examples/tune/dev.md
go run ./cmd/kb tune -splits test,out -runs 3 \
  -use base,tuned,tuned-q4km,tuned-4b -out examples/tune/report.md
go run ./cmd/kb tune-ctx                                        # окно контекста
  • Матрица «по одному фактору» — на dev (19 вопросов). Итог tuned собран из её значений; контрольный прогон — один раз на test + out (10 отвечаемых и 8 вне базы, 3 повтора).
  • Судья один на все профили — облачный (-judge auto), он не судит сам себя.
  • Перед каждым профилем все модели выгружаются (keep_alive 0), затем модель профиля прогревается коротким запросом. Загрузка не попадает в ответы, а память в отчёте — этого профиля, а не накопленная предыдущими.
  • Отчёт: параметры (отличия от базы жирным), качество, скорость по фазам с долей кэша префикса, память по /api/ps, стабильность, ошибки хода, попарно с базой, ответы рядом. -render report.json перерисовывает markdown без прогона.

Итог (examples/tune/report.md)

test + out, 3 повтора base (v28) tuned tuned на Q4_K_M tuned на 4B
верно (из 10 отвечаемых) 6 6 5 4
уверенные ошибки 1 1 1 0
«не знаю» вне базы 10 из 10 10 из 10 10 из 10 10 из 10
генерация: медиана / p95 10,4 с / 25,4 с 8,2 с / 20,8 с 6,8 с / 19,8 с 5,1 с / 9,6 с
decode, ток/с — 29,6 27,1 45,2
токенов на ответ: запрос → ответ 3541 → 249 2880 → 199 2248 → 167 2466 → 177
память модели (VRAM) после прогона 13,0 ГБ 11,5 ГБ 6,7 ГБ 8,7 ГБ
загрузка модели 3,3 с 3,2 с 4,4 с 1,8 с
ошибок хода 0 0 3 (T11) 3 (T06)
дословно тот же текст в повторах 89 % 94 % 94 % 67 %
  • tuned — родной API, max_tokens 512, промпт v29. Качество то же, что у v28, вопрос в вопрос (попарно: 0 лучше, 0 хуже). Медиана ответа на 21 % быстрее, запрос на 19 % и ответ на 20 % короче, памяти на 1,5 ГБ меньше.
  • Квант Q4_K_M той же 9B вдвое легче в памяти (6,7 ГБ против 11,5) и ещё быстрее. Цена — T11: на нём модель трижды выдала кривой XML вызова инструмента, и Ollama ответил 500.
  • 4B — вдвое быстрее, но на 2 верных ответа меньше. Уверенную ошибку T03 она не повторила: ответила «не знаю».
  • Промпт v29 не исправил T03 (атрибуция цитаты Википедии к MDD) — это ошибка понимания, а не формулировки правила. Выигрыш v29 — длина запроса и ответа.

На dev (examples/tune/dev.md) все профили ответили верно на 18–19 вопросов из 19, качество там упирается в потолок. Различается цена:

  • рассуждение (think) — медиана 30,4 с против 19,5 с без пользы;
  • k 3 теряет нужный фрагмент на одном вопросе (recall 95 %);
  • temperature 0.3 ничего не дала;
  • max_tokens 512 на среднем ответе ничего не меняет, но обрезает зацикливание — у think один ответ шёл 163 с.

Окно контекста (examples/tune/context.md)

Запрос — 2785 токенов: системный промпт с контрольным словом и текст корпуса. Вопрос — назвать слово.

модель, раннер num_ctx токенов взято слово окно /api/ps VRAM
9B nvfp4, MLX сервера / 8192 / 4096 / 2048 2785 да 16384 7,3–7,4 ГБ
9B Q4_K_M, llama.cpp сервера (16384) 2785 да 16384 6,64 ГБ
9B Q4_K_M, llama.cpp 8192 / 4096 2785 да 8192 / 4096 6,34 / 6,20 ГБ
9B Q4_K_M, llama.cpp 2048 1026 нет («Леопард») 2048 6,13 ГБ
  • На MLX-раннере num_ctx не действует: окно задаёт сервер.
  • На GGUF окно действует и экономит память, а запрос длиннее окна сервер молча срезает с начала — вместе с системным промптом. Отказа нет.
  • Поэтому предел держит клиент: k, длина фрагментов, localReserve, а у шлюза — проверка до модели.

В приложении

  • LOCAL_LLM_PROFILE=tuned — справочная («Справочная (RAG)», kb_answer) отвечает на итоговом профиле. Агенты диалога работают как раньше: профиль подобран под ответ с цитатами, а не под их протоколы.

  • Окно «База знаний» → вкладка «Оптимизация». Там:

    • параметры base и tuned рядом, отличия выделены;
    • один вопрос обоим профилям по очереди, с временем prefill и decode;
    • сводка контрольного отчёта.

    Профили отвечают по очереди, потому что модель на сервере одна: параллельный запрос ждал бы в её очереди, и время было бы нечестным.

  • LOCAL_LLM_API=native, LOCAL_LLM_NUM_CTX — то же для всего приложения.

Что сервер не отдаёт по HTTP: загрузку CPU, RAM процесса и энергию. Память модели — только size_vram из /api/ps. На MLX она растёт с длиной обработанных запросов и не возвращается до выгрузки; llama.cpp резервирует окно сразу.

Приватный AI-сервис: шлюз в домашней сети

Ollama слушает порт без ключа и без очереди: кто достучался, тот и грузит модель, а параллельные запросы она не выполняет — они встают в очередь внутри сервера без предела и без отказа, и один длинный промпт держит всех. Отдать такую модель другим устройствам сети напрямую нельзя. Шлюз cmd/gateway стоит перед Ollama и берёт на себя всё, чего у неё нет:

  • ключ на клиента (Authorization: Bearer) — кто пришёл, видно в метриках, чужие — 401;
  • лимит запросов на ключ — token bucket: столько-то в минуту и запас «подряд», сверх — 429 с Retry-After;
  • один слот генерации и короткая очередь — лишним 503 с Retry-After, а не вечное ожидание внутри Ollama;
  • предел контекста до модели — оценка токенов запроса и ответа, больше предела — 413, Ollama не трогали;
  • предел ответа — max_tokens обрезается, по умолчанию подставляется;
  • таймаут и отмена — долгая генерация — 504; ушедший клиент освобождает слот и рвёт запрос к модели;
  • поток SSE насквозь с keep-alive, пока модель молчит на prefill;
  • веб-чат на корне, /health без ключа, /metrics по ключу.

API — подмножество OpenAI, поэтому к шлюзу подключается любой клиент, у которого есть поле «base URL»: библиотека openai, мобильные чаты, IDE.

Запуск

# ключи клиентов: имя:токен[:rpm[:burst]] через запятую; токен от 12 символов
export GATEWAY_KEYS="laptop:$(openssl rand -hex 16):-1,phone:$(openssl rand -hex 16):6:3"
export LOCAL_LLM_BASE_URL=http://192.168.1.243:11434   # Ollama на соседней машине
go run ./cmd/gateway                                    # 0.0.0.0:8780

При старте шлюз печатает адреса, по которым он доступен с других устройств:

шлюз: 0.0.0.0:8780 → http://192.168.1.243:11434
ключи: 2 (GATEWAY_KEYS); модели: qwen3.5:9b-nvfp4, qwen3.5:* (по умолчанию qwen3.5:9b-nvfp4)
лимиты: слотов 1, очередь 4 (ждать до 1m0s), ответ до 1024 (по умолчанию 512), контекст до 12000, таймаут 3m0s
  http://127.0.0.1:8780/
  http://192.168.1.119:8780  (Беспроводная сеть)/

С телефона в той же сети: открыть http://192.168.1.119:8780/, вписать ключ — дальше чат. Ключи можно держать в файле -keys keys.json ([{"name","token","rpm","burst"}]), rpm: -1 — без лимита (ключ для нагрузочного теста), rpm и burst по нулям — умолчания шлюза.

Наружу без ключей шлюз не слушает: -listen 0.0.0.0:8780 без GATEWAY_KEYS — отказ при старте. На 127.0.0.1 ключи не нужны — туда ходят только процессы этой машины, и все они идут под одним именем с лимитом по умолчанию.

Со справочной по базе знаний:

uv run embedder/server.py                 # эмбеддер e5, как для kb
go run ./cmd/gateway -rag kb.db           # + виртуальная модель animalguide-rag

API

Маршрут Ключ Что
POST /v1/chat/completions да чат; stream: true — SSE насквозь; поля model, messages, max_tokens, temperature, reasoning_effort уходят в Ollama
GET /v1/models да модели белого списка, что есть в Ollama, плюс виртуальные; модель по умолчанию — первой
GET /health нет жив ли апстрим (опрос не чаще раза в 2 с), что в памяти, занятость слота и очереди, пределы, нужен ли ключ
GET /metrics да по ключам: запросы, коды, p50/p95 латентности, TTFT и ожидания в очереди, токены ответов; последние клиенты; сеть процесса
GET / нет веб-чат
curl -N http://192.168.1.119:8780/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Назови три факта о мануле."}],"max_tokens":200,"stream":true}'
from openai import OpenAI
client = OpenAI(base_url="http://192.168.1.119:8780/v1", api_key=KEY)
print(client.chat.completions.create(model="animalguide-rag",
    messages=[{"role": "user", "content": "Сколько весит манул?"}]).choices[0].message.content)

Ошибки — в формате OpenAI: {"error": {"type", "code", "message"}}; у 413 по контексту добавлена estimate — оценка запроса, ответа и предел. Заголовки X-Gateway-Estimate (оценка токенов) и X-Gateway-Max-Tokens (что ушло апстриму, если шлюз подставил или обрезал) есть у каждого ответа.

Что шлюз делает до модели

Проверки идут от дешёвых к дорогим, и ни одна отказавшая не доходит до апстрима:

Код error.code Когда
401 invalid_api_key нет заголовка или чужой токен; WWW-Authenticate: Bearer
429 rate_limited запас ключа кончился; Retry-After — когда появится следующий
415 bad_request Content-Type не application/json — простой POST с чужой страницы не пройдёт, а preflight шлюз не разрешает
413 body_too_large тело больше 1 МБ
400 bad_request не разобрать JSON, нет messages, нет модели и умолчания
404 model_not_found модели нет в белом списке
413 context_length_exceeded оценка prompt + max_tokens больше предела (12 000)
503 queue_full / queue_timeout слот занят и очередь полна, либо слот не освободился за минуту; Retry-After — по средней длительности недавних генераций
504 timeout генерация дольше 3 минут
502 upstream Ollama недоступна, ответила 5xx или оборвала поток; 4xx Ollama проходят с тем же кодом
499 — клиент ушёл раньше ответа: только в метриках, писать некому

Что шлюз меняет в запросе: подставляет model по умолчанию, max_tokens (по умолчанию 512, не больше 1024) и reasoning_effort: none, если клиент его не прислал — Qwen3.5 по умолчанию «думает» сотни токенов, а слот один. Ключ клиента апстриму не передаётся. Оценка токенов — internal/tokens, те же веса, что в приложении и в веб-чате.

Поток идёт строка за строкой с flush по концу события; пока модель молчит (prefill длинного запроса), раз в 15 секунд уходит комментарий : keep-alive, чтобы соединение не закрыл ни браузер, ни роутер. Обрыв посреди потока — событием data: {"error": …}, как у OpenAI: заголовки уже ушли. Если клиент отключился, контекст запроса рвёт чтение апстрима, и Ollama прекращает генерацию — слот освобождается сразу, а не через max_tokens.

Флаги шлюза

Флаг По умолчанию Что делает
-listen 0.0.0.0:8780 адрес; 127.0.0.1:8780 — только эта машина, можно без ключей
-upstream LOCAL_LLM_BASE_URL корень Ollama (/v1 на конце срезается)
-keys — keys.json; иначе GATEWAY_KEYS
-models LOCAL_LLM_MODEL,qwen3.5:* белый список; * — по префиксу; первая точная — по умолчанию (GATEWAY_MODELS)
-max-tokens / -default-max-tokens 1024 / 512 предел max_tokens и умолчание
-max-context 12000 предел оценки prompt + max_tokens
-slots / -queue / -queue-wait 1 / 4 / 60s активных генераций, ждущих слота, сколько ждать
-timeout 3m предел генерации
-rpm / -burst 6 / 3 лимит ключа, если у ключа свой не задан; -rpm -1 — без лимита
-rag — kb.db: включить animalguide-rag
-rag-profile / -profiles tuned / eval/profiles.json профиль генератора справочной
-embedder EMBED_BASE_URL или http://127.0.0.1:8777 эмбеддер для справочной

Умолчания — решение по задаче: слот один, потому что модель на маке параллелить нечем (v29: параллельный запрос только ждал бы внутри Ollama); очередь из четырёх — столько устройств в домашней сети; предел контекста 12 000 — запрос справочной (≈5 400 токенов) с запасом, но без длинных простыней, которые держали бы слот минутами.

Веб-чат

Страница самодостаточна (CSP: ни CDN, ни чужих адресов) и работает с телефона. Ключ — в поле вверху, хранится в localStorage вместе с историей и выбранной моделью. Список моделей — из /v1/models, поэтому без ключа в нём «нужен ключ». История в запрос идёт окном под бюджет шлюза: оценка токенов в браузере та же, что у шлюза, старые реплики отпадают, последняя остаётся всегда — если не влезает и она, честный 413 скажет об этом. Под ответом — время и первый токен; у справочной — источники и цитаты списком. Отказы шлюза показываются по-человечески: «лимит ключа исчерпан, повторите через N с», «модель занята, очередь полна», «запрос слишком большой: ≈N токенов при пределе M». Кнопка «Стоп» рвёт запрос — и слот.

Справочная: animalguide-rag

Виртуальная модель шлюза: запросы с model: "animalguide-rag" не уходят в Ollama, а идут в конвейер справочной (rag+cite, v24–v29) — поиск по kb.db через эмбеддер, ответ локальной модели на профиле tuned, проверка источников и дословности цитат кодом, при слабой релевантности — «не знаю». В поиск идут последний вопрос и до трёх предыдущих реплик пользователя: «а сколько она весит?» без них искать нечем. Ответ — в формате OpenAI плюс поле sources (id, title, quote); в потоке — одним чанком текста и чанком с finish_reason, usage и sources.

{"model":"animalguide-rag","choices":[{"message":{"role":"assistant",
  "content":"Масса манула составляет 2—5 кг.\n\n**Источники:**\n[1] Манул › Описание и внешний вид (`manul/structure/002`)\n…"}}],
 "sources":[{"id":"manul/structure/002","title":"Манул › Описание и внешний вид",
             "quote":"Длина тела — 45—65 см, длина хвоста — 21—31 см, высота — 30—35 см, масса — 2—5 кг."}],
 "usage":{"prompt_tokens":5408,"completion_tokens":338,"total_tokens":5746}}

Модель Ollama и справочная делят один слот: пока справочная ждёт свою генерацию, чужой запрос стоит в очереди шлюза, а не внутри Ollama.

Нагрузка: cmd/loadtest

go run ./cmd/loadtest -url http://192.168.1.119:8780 -key $LOAD -burst-key $PHONE

Клиент глазами клиентов: что видит каждый, когда к одной модели приходят сразу несколько. Сценарии — параллельность 1, 2, 4, 8 (по 8 запросов, латентность p50/p95 с ожиданием в очереди, TTFT по потоку, коды), всплеск 10 запросов по ключу с лимитом (ожидаемое число 429 считается по запасу ключа из /metrics), запрос длиннее окна и тело больше 1 МБ (413, а счётчик запросов шлюза к модели по учёту сети не растёт), без ключа и с чужим ключом (401). Отчёт — examples/gateway/load.md и JSON рядом.

Итог прогона через адрес домашней сети (шлюз на этой машине, Ollama на маке, qwen3.5:9b-nvfp4, max_tokens 64, поток):

N коды p50, с p95, с TTFT p50, с всё, с
1 200×8 2,2 2,4 2,2 18,0
2 200×8 4,4 4,6 4,3 17,8
4 200×8 8,8 8,9 8,7 17,7
8 200×5, 503×3 6,6 11,0 6,6 11,0

Модель одна, поэтому общее время не меняется: 8 запросов — 18 секунд при любой параллельности, а латентность каждого растёт кратно N — это ожидание в очереди шлюза, и TTFT совпадает с ней: первый токен приходит, как только запрос дошёл до слота. При N = 8 пятеро попадают в слот и очередь, троим — 503 сразу, с Retry-After.

сценарий ожидали получили
без ключа, чужой ключ 401 401
всплеск 10 запросов по ключу с запасом 3 429 × 7 429 × 7 (200 × 3)
запрос 39 000 символов 413 context_length_exceeded 413; запросов к модели 38 → 38
тело больше 1 МБ 413 body_too_large 413; запросов к модели 38 → 38

Безопасность и границы

  • Токены сравниваются по хэшу за постоянное время, без раннего выхода — по времени ответа не узнать ни длину, ни «насколько близко».
  • X-Forwarded-For не читается: прокси перед шлюзом нет, заголовок подделывается. Адрес клиента — по соединению.
  • CORS не разрешён: страница из интернета не сможет дёргать шлюз браузером пользователя ни простым POST (415), ни с preflight.
  • Учёт сети (/metrics → network) показывает, что шлюз ходит только к Ollama и эмбеддеру: интернет — 0.
  • TLS нет: шлюз для домашней сети. В интернет его выставляют только за обратным прокси с TLS (Caddy, nginx) и, лучше, по VPN; ключи при этом остаются ключами шлюза, прокси их не видит.
  • Защиты от содержимого нет: что модель ответит, шлюз не проверяет — это задача справочной (rag.cite, свод инвариантов) и приложения.

Тесты шлюза

go test ./internal/gateway/ ./cmd/gateway/ ./cmd/loadtest/

Апстрим подставной (httptest), часы лимитов подменяемые — 429 и Retry-After проверяются без sleep. Покрыто: 401 без ключа и с чужим, лимит на ключ и его запас, 413 по телу и по оценке контекста (36 000 символов кириллицы ≈ 12 200 токенов), подстановка model, max_tokens и reasoning_effort и обрезка max_tokens, белый список с префиксом, поток насквозь (первый чанк доходит до клиента раньше второго), очередь полна и таймауты (503, 504), отмена клиентом освобождает слот, отказ слушать наружу без ключей, разбор GATEWAY_KEYS, виртуальная модель с sources в обоих режимах, /health, /metrics и страница, перцентили. cmd/loadtest гоняется против шлюза с подставным апстримом и проверяет свой отчёт.

About

AnimalGuide v30: приватный AI-сервис на локальной LLM — шлюз перед Ollama с OpenAI-совместимым API, веб-чатом, ключами, лимитами и очередью (Go)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages