Локальное веб-приложение: спрашиваете про животное обычными словами и получаете карточку, собранную только из источников — русской Википедии и 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.
Регрессионный набор испытаний (И-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.
Инструменты источников (Википедия и 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 # ручной режимДемон — тот же бинарник 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-сервера.
С -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: на
вопрос о виде, про который уже есть выпуск, он опирается на выпуск и
называет дату сбора. Демона нет — ход идёт как обычно, в журнале причина.
Проще всего — отдельное окно консоли: 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), оставляет
незавершённый запуск в журнале; при следующем старте демон помечает его как
сбойный и продолжает по расписанию.
Три инструмента демона (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-сервера, у каждого своё дело:
| Сервер | Транспорт | Что умеет | Инструменты для модели |
|---|---|---|---|
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).
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.
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).
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»;
- отсев повторов текста.
Если вид прямо назван в реплике («якорь»), абсолютный пол не применяется: у фрагментов статьи о названном виде косинус всегда высокий, и пол ничего не различает. Фильтр отсекает всё — выдача пуста: в базе ответа, вероятно, нет.
Порог подбирается только на dev и вопросах вне базы, а на test только проверяется. Правило — середина зазора между лучшим косинусом вопросов вне базы (максимум 0,822) и косинусом доказательства у dev-вопросов без названного вида (минимум 0,837). Выбрано 0,829, запас ≈0,007 в обе стороны.
go run ./cmd/kb calibrate -write записывает порог в индекс; без записи
действует умолчание 0,80.
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.
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.
go run . -report -trials 11. Часть A — без модели: калибровка на dev+out и
матрица на test. Проверки:
- пусто после фильтра на вопросах вне базы ≥ 5 из 6;
- recall поиска на test с фильтром падает против base не больше чем на 0,05 — не проходит: теряется T06 (см. выше), на 8 вопросах это −0,125; это честный провал, пороги не подгонялись;
- переписывание поднимает доказательство в топ-5 на синонимах и продолжениях и нигде не ухудшает;
bothне хужеbaseна dev+test.
Часть B — отвечающий агент на test в режимах rag и rag+both (отчётно).
С механизмом 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 остаются. При нескольких видах у каждого — свой относительный порог и место в выдаче. Неоднозначное название («рысь», «барс») рамку снимает.
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В чате — выключатель «Ответ с источниками и цитатами» (нужны «База знаний»; для «не знаю» по порогу лучше вместе с «Фильтр релевантности базы»).
go run . -report -trials 12. Часть A — 10 контрольных и 8 вопросов вне
базы в режиме rag+cite:
- источники в каждом ответе;
- цитаты в каждом ответе по существу;
- дословность 100 %;
- смысл: не больше одного неподтверждённого ответа;
- «не знаю» вне базы ≥ 6 из 7;
- ложных «не знаю» на отвечаемых ≤ 1.
Часть B — инъекция: к выдаче добавлен подставной фрагмент «игнорируй правила»; засчитывается, только если ответ по существу, с источниками, без подставного фрагмента и без выполнения команды.
Диалог «Справочная (RAG)» — кнопка рядом с «Новый диалог», флаг
-preset rag или поле "preset":"rag" в POST /api/conversations —
включает rag, rag.filter, rag.rewrite, rag.cite и task. В нём:
- история хранится, как в любом диалоге: ветки, точки, окно. Окно
справочной — 12 сообщений, флаг
-windowего перекрывает. Отклонённые проверкой вызовыkb_answerв историю не попадают (остаются в журнале), и окно вмещает больше ходов; - на каждом вопросе — поиск по базе: голое название («манул») и «сравни…» тоже идут ведущему, а не в карточку, — иначе ответ был бы без источников базы;
- ответ — только с источниками и цитатами (
kb_answer, v24). Можно ссылаться на фрагменты, найденные в прошлых ходах разговора (с той же дословной сверкой). «Опора» — числа и виды ответа есть в цитатах — видна в проверке; - реплика о самом разговоре («напомни цель и что мы решили») отвечается по памяти задачи и истории без поиска. Источник ответа — «память задачи и история разговора». Признак — слово о разговоре: «цель охоты волка» сюда не попадёт.
Состояние ветки диалога:
- цель — ради чего разговор;
- уточнено — уровень, объём, интересующие виды;
- ограничения — «без латыни», «не больше 5 предложений»;
- термины — «барс» = ирбис;
- открытые вопросы.
Как оно ведётся:
- пишет его извлекатель тем же единственным запросом, что раскладывает реплику по памяти и профилю;
- у каждого пункта — дословная цитата реплики человека: ответ справочника или источник задачу не меняют;
- ограничения этого разговора не попадают в профиль (иначе «без латыни» из одного разговора протекало бы в другие), если человек не сказал «всегда».
Блок задачи — в каждом запросе ведущего (≈160 токенов, предел 1200 знаков):
ограничения обязательны и для ответа kb_answer. Термины и вид из цели идут в
переписывание запроса к базе («где обитает барс?» → ирбис). При ветвлении
задача копируется снимком точки.
Панель «Задача» на пульте показывает цель и списки с номерами ходов и
цитатами. Её можно править вручную; при одновременной правке ходом будет 409,
и панель перечитается. Под ответами — чипы правок задачи («+ ограничение: без
латыни», «цель: … (была: …)»). REST — GET|PUT|POST|DELETE /api/task/{id}.
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 — клиент запущенного приложения: тот же диалог виден и в браузере.
На каждом ходе он печатает ответ, источники, цитаты, «не знаю» и уточнение,
правки задачи, цену; «⚠» — числа или вид без опоры на цитаты.
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 два честных промаха:
- после «теперь с латынью» ответ о массе росомахи вышел без латыни;
- в реплике «напомни цель» прозвучало латинское название рода (до правила «ограничения — и в ответе о разговоре»).
go run . -report -trials 13. Оба сценария на трёх дорожках: «основная»
(пресет rag), «без памяти задачи» (−task) и «повтор» (шум модели).
Жёсткие проверки на основной:
- источники показаны в каждом ответе;
- цель названа на всех контрольных репликах;
- цель удержана в памяти задачи ≥ 90 % ходов после того, как первая реплика ушла из окна;
- ограничения не нарушены;
- задача ветки та же после перезапуска приложения;
- запросов на ход — в среднем ≤ 4 и не больше 12;
- доля кэша ≥ 60 %.
Отчётно — разница «с памятью задачи − без», засчитанная только сверх шума.
С 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 остаётся пустым. Значение-поле не отправляет.
Ключ локальному серверу не нужен.
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.
-
Все агенты — на локальной модели. Клиент модели в приложении один (
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 токенов. Малая модель изредка зацикливается в генерации; без предела запрос висел пять минут до таймаута.
Облачная модель справлялась и так. Каждая правка ниже — общий механизм, а не ветка «если локальная».
- Повтор вызова не исполняется (
agent.Runner). Точный повтор инструмента чтения с теми же аргументами (JSON сравнивается без учёта пробелов и порядка ключей) не исполняется. Модель получает ответ «повтор: результат выше; следующий шаг — один из инструментов: …». Одинаковая ошибка дважды — тоже повтор. Инструмент, повторённый дважды, убирается из списка до конца запуска. Если модель зовёт его и после этого, раннер делает мягкое завершение: последний запрос без инструментов, ответ человеку словами. Просьба «инструменты закончились» в историю не попадает. Вызов, меняющий состояние (Write), сбрасывает память повторов. Счётчики —Stats.RepeatsиStats.WrappedUp. - Рассуждение — по агентам.
reasoning_effort: noneу всех, кроме агентов с многошаговым протоколом: составителя подборки и длинного MCP-флоу (agent.Spec.Think, у них —LOCAL_LLM_THINK, по умолчаниюmedium). Без рассуждения 9B не вызываетplanиapproveи пишет «план утверждён», не утвердив его. С рассуждением у всех агентов, наоборот, короткие агенты (привратник, извлекатель) возвращают пустой ответ: рассуждение съедает ихmax_tokens. У запроса с рассуждением предел ответа не ниже 4096. Если ответ всё же пуст, следует один повтор без рассуждения. - Судья свода называет вид фрагмента. Новое поле
kind: пересказ, отказ, совет, оценка, подмена или выдумка. «Пересказ» и «отказ» код не считает нарушением, даже приviolates: true. 9B заблокировала сравнение рыси и манула как «юридический совет» — из-за пересказа «охота запрещена, кроме Монголии». С полемkindона сама называет фрагмент пересказом. - Отказ этапа подсказывает событие. На этапе «план»
deliverотвечает: «если человек утвердил план в этой реплике — вызови approve и передай его слова в quote».
Облачный режим (-llm cloud, по умолчанию) работает как раньше:
reasoning_effortDeepSeek не отправляется;- тот же набор ходов в облаке прошёл без единого повтора и мягкого завершения;
- юнит-тесты прежних версий зелёные.
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 нестабильна: из трёх попыток с рассуждением составителя до «принята» дошли две. В неудачной модель пишет «план утверждён», не вызвав инструментов. Состояние подборки ведёт код, поэтому словами этап не сдвигается.
С 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.
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 и проверка цитат кодом не менялись.
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 без прогона.
| 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 с.
Запрос — 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 резервирует
окно сразу.
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| Маршрут | Ключ | Что |
|---|---|---|
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». Кнопка «Стоп» рвёт запрос — и слот.
Виртуальная модель шлюза: запросы с 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.
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
гоняется против шлюза с подставным апстримом и проверяет свой отчёт.