Локальная LLM в мониторинге сети: Ollama + MCP + Spring Boot за три дня
Как я подключил локальную LLM к системе мониторинга OLT через MCP. Архитектура, in-process диспетчер вместо HTTP, 16 tools, SSE в браузер.
В GetOLT, сервисе мониторинга PON-сетей, который я разрабатываю (как он появился) — 350+ OLT и 100K+ ONU. Когда техотделу нужно ответить на «сколько ONU в Алматы с плохим сигналом на CDATA?», никто не пишет SQL. До этой недели — 5 кликов в фильтрах, после — один вопрос в чат.
Бот отвечает на естественном языке и ходит в реальную базу, не в кэш. От первого коммита до прогона на трёх живых OLT — три дня.
Почему локальная LLM
Первый вопрос — почему не GPT/Claude через API. Ответ продуктовый, не технический:
- Данные клиентов — биллинговые id, IP оборудования, договорные логины — не должны уезжать в облако третьих лиц
- 152-ФЗ: согласие пользователя на обработку ПДн — есть, на трансграничную передачу — нет, и получать его ради удобства бота незачем
- gpt-oss:20b на Apple Silicon выдаёт 30-40 tokens/sec — приемлемо для интерактивного чата
- 0 рублей за запрос, лимит — мощность железа, а не бюджет
Если бы я делал ассистента для open-source инструмента, выбор был бы другим. Для коммерческого продукта в B2B-телекоме — локальная LLM единственный честный путь.
Архитектура: три коробки
[Browser] ←SSE— [LlmChatController] → [LlmChatService]
│ tool-loop
▼
┌──────────────┬──────────────────┐
[Ollama] [InProcessMcpDispatcher]
gpt-oss:20b │
▼
[16 MCP tools]
│
▼
[OltService, OnuService, ...]
│
▼
[MySQL]
Главное решение — in-process MCP dispatcher. Стандартный MCP подразумевает отдельный процесс с HTTP или stdio-транспортом. У меня LLM-loop и MCP-сервер живут в одном Spring Boot — поэтому я выкинул сериализацию. Tool-вызов это обычный Java-метод по имени и JSON-аргументам, без HTTP-моста.
Минус: нельзя переиспользовать MCP-сервер из других клиентов (Claude Desktop, например). Плюс: меньше кода, меньше задержки, единый Security-контекст и аудит. Для embedded-ассистента в продукте это правильный tradeoff.
LlmChatService.run — обычный tool-loop:
while (turn < MAX_TURNS) {
var response = ollama.chat(messages, toolSchemas);
if (response.toolCalls().isEmpty()) {
emit(response.content());
break;
}
for (var call : response.toolCalls()) {
var result = mcpDispatcher.invoke(call.name(), call.args());
messages.add(toolResultMessage(call.id(), result));
emit(new ToolEvent(call, result));
}
turn++;
}
SSE-streaming передаёт пользователю не только финальный текст, но и каждый tool-вызов с аргументами и результатом. Видно, как бот строит ответ — какой фильтр применил, что вернула БД. Это и UX-фича, и средство отладки.
Что получилось end-to-end
- 16 MCP-tools поверх существующих сервисов:
olt.list,onu.stats_by_olt,olt.distinct_values,data.export,feedback.sendи другие. Каждый — узкий, с явной JSON-схемой. - Полный append-only аудит:
chat_session+chat_message+mcp_chat_log. Любой диалог восстанавливается побайтово. - Эфемерные file-attachments: модель может выгрузить агрегат в CSV/JSON, файл живёт 5 минут, ссылка одноразовая.
- Read-only SQL escape hatch: если задача не покрыта tools, модель может попросить SQL — но через отдельный tool с SELECT-только-валидацией. Это снижает галлюцинации и даёт зону gradual coverage.
Прогон на проде, три живых OLT, реальные данные:
- «покажи ONU с сигналом хуже -27 dBm на CDATA в Алматы» — отвечает за 4-6 секунд
- «сколько портов заполнено выше 75% по операторам?» — считает агрегат, рисует таблицу
- «выгрузи список проблемных ONU за неделю» — генерирует CSV, отдаёт ссылку
Что было сложно
Три категории, каждая тянет на отдельный пост:
-
SSE + Spring Security + async-dispatch. Три отдельные ловушки, из-за которых SSE падал с AccessDenied на финализации.
@PreAuthorizeломал async-dispatch,SecurityContextне пробрасывался в worker thread,SecurityFilterChainбил поDispatcherType.ASYNC. Разбор — в следующем посте. -
Prompt engineering как инженерная дисциплина. Модель не звала SQL для агрегатов, выдумывала несуществующие города и IP-адреса, выводила внутренние db-id наружу. Решение — не апгрейд модели, а дизайн tools и system prompt'а как кода. Подробный разбор — в посте про prompt-as-code.
-
Browser prefetch съедал file-attachments. Классическая ловушка: браузер делал HEAD/GET по ссылке для preview, и одноразовый токен сгорал до клика пользователя. Решилось
Cache-Control: no-store+Sec-Purpose: prefetchфильтром на сервере. Стоит отдельной заметки.
Что я понял
- In-process MCP > HTTP MCP, если LLM-loop и сервер живут в одном процессе. Сериализация ради сериализации — лишний код.
- Tool design > model size. Точность ответов выросла больше от добавления
distinct_valuestool, чем от любых попыток подобрать модель помощнее. - Prompt — это код. Каждая «странность» поведения это commit в system prompt, а не «AI глупый».
- Локальная LLM — это вопрос политики, не возможностей. gpt-oss:20b с tool-calling закрывает 90% задач embedded-ассистента, не выходя за пределы сервера.
AI-ассистент в продукте — это не модель. Это сотня строк tool-loop'а, набор узких MCP-tools поверх существующих сервисов и три страницы system prompt'а. Модель, которую ты выберешь — наименее интересная часть стека.
Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.