Немного душно про ИИ
20 subscribers
2 links
Архитектура, код, кейсы внедрений. Иногда душно, но по делу.
Download Telegram
Привет, я рад что ты здесь! 👋

Меня зовут Александр Козлов. Я AI-архитектор.

С 19 лет в IT — начинал с Java в Альфа-Банке, потом строил нейро-помощников, голосовых ботов, транскрибаторы, были проекты на классическом ML и CV, а последние годы полностью ушёл в AI: архитектура, LLM, RAG, агентные системы.

Сейчас занимаюсь тем, что нравится больше всего — проектирую и внедряю AI в бизнес.
💼 Что делаю:
• Проектирую RAG-архитектуры и мультиагентные системы
• Внедряю self-hosted LLM платформы (OpenWebUI / кастомные решения)
• Строю AI-ассистентов сотрудников на данных компании
• Дообучаю модели под конкретные домены
• Инферю / оптимизирую
• Разрабатываю собственную RAG-платформу для enterprise

📝 В этом канале пишу про:
• Как реально устроены AI-системы в компаниях
• Агентные системы: когда работают, когда нет, и почему
• Кейсы внедрений с реальными цифрами

Формат — от коротких заметок до серий на глубокие темы.

По любым вопросам @alskozlov
Почему автономные агенты сносят базы в проде и как строить правильно

Replit Agent удалил production БД с 1206 записями. Несмотря на прямой запрет. Потом сфабриковал результаты тестов.

AI-агент Alibaba начал майнить крипту. Без инструкции в промпте.

AI.Ventures получили счёт $60 000/мес — агент масштабировал инфраструктуру с 12 до 500 нод за 3 минуты.

Все эти системы были "автономными агентами". И проблема у них одна: у агента нет фиксированного маршрута. Один промпт при разных температурах — разное поведение. А если есть доступ к деструктивным операциям — непредсказуемый маршрут = непредсказуемые последствия.


"Workflow vs agent" — ложная дихотомия. На практике работает agent-as-node: граф фиксирует маршрут, а внутри каждого узла может жить полноценный агент.

Каждая нода — это либо:
🔹 простая функция — один LLM-вызов, валидация
🤖 полноценный агент — ReAct-цикл с инструментами

START → route
├→ 🔹 validate_input
├→ 🤖 rag (субграф: retrieve → grade → rewrite)
├→ 🤖 data_analysis (ReAct: code → execute → plot)
└→ 🔹 validate → END

Маршрут фиксированный, но агенты-узлы сами решают какие тулзы вызвать и сколько раз для решения своей задачи.


Что важно: каждый агент-нода — это любая архитектура: ReAct, PAR, DVF и т.д. RAG-субграф с retrieve → grade → rewrite. Plan-and-Execute с декомпозицией задач. Даже мультиагентная система с supervisor внутри одной ноды.

Но у каждого — только свои тулзы. Blast radius ограничен рамками узла.

Anthropic, OpenAI, Google говорят одно: минимально необходимый уровень агентности + guardrails + human approval перед опасными действиями.

Хороший агент — как хороший сотрудник: принимает решения сам, но в рамках своей должностной инструкции, а не бегает по всему офису с ключами от серверной.
Немного про OpenWebUI

Пилотировали OpenWebUI для enterprise — закрытый контур, 4× H100, своя доменная модель.


Стек пилота:
▪️ Inference: OpenWebUI + 2× vLLM (MetalGPT + Qwen3.5-27B-FP8)
▪️ OCR / ASR: Chandra, Faster-Whisper, Docling
▪️ Инфра: MinIO, RabbitMQ
▪️ Observability: Grafana + Prometheus + Loki + OTEL

Всё on-premise.

Пилот взлетел быстро. Бизнес увидел value за первую неделю. 200 сотрудников ежедневно. Но базовый OpenWebUI под нагрузкой показал системные ограничения, которые сверху не прикрутишь.


Монолит под нагрузкой. OpenWebUI — один процесс: фронт, бэкенд, RAG, vector store. Архитектура не рассчитана на корпоративные сценарии — под параллельными ingest'ами API захлёбывается. Это не баг, это дизайн-решение. Для enterprise нужна нормальная раскладка — очереди, воркеры, async-ingestion, connection pooling. Сверху не прикрутишь, только перекладывать в отдельные сервисы.

Code interpreter формально есть — но архитектурно сыро.

Runtime — два варианта, оба компромисс:
▪️ pyodide — Python через WASM прямо в браузере. Сервер не нужен, но runtime обрезанный: нет numba, часть C-extension'ов pandas/scipy не работает, большие DataFrame тормозят на стороне клиента.
▪️ jupyter — внешний Jupyter Server по HTTP. admin сам его поднимает, прописывает URL + auth, мониторит и изолирует по пользователям.

Дефолта нет. Единого runtime внутри платформы — нет.

Цикл исполнения слабый:
▪️ retry до 5 попыток с прокидом ошибки в LLM — но без plan-ноды и verify, модель крутится «в слепую» пока не угадает
▪️ каждая попытка — отдельный inference-round с полной историей сообщений; на локальной Qwen 27B это 20-30 сек на retry, полторы-две минуты на «не та колонка → перепиши»
▪️ состояния между шагами нет — файлы прокидываются в runtime на каждый шаг через files в event-payload'е (pyodide — по WebSocket в браузер, jupyter — URL скачивания), инкрементальных апдейтов DataFrame между cell'ами нет, каждый retry перезагружает весь файл
▪️ streaming промежуточных результатов нет — execute_code_jupyter это blocking RPC, пользователь смотрит в пустой спиннер пока pandas считает 10M строк

Итого: для «покажи две метрики из csv» — работает. Для реальной аналитики с merge'ами и итеративной доработкой графика — нет. Нормальный агент (plan → codeact → verify → replan) делает это из коробки. Встроенный code interpreter OWUI остаётся демкой.

Нет агентности. Вообще. Нет MCP (протокол подключения внешних тулов), нет stateful-графа. Pipeline filters — это inlet/outlet-хуки. Перехватывают запрос и ответ. Это middleware, а не оркестрация. Ни ветвления, ни циклов, ни состояния между шагами. Любой агент придётся строить сбоку и подключать к OpenWebUI через OpenAI-compat endpoint — сам OpenWebUI только UI-шелл.

Граница доработок размытая. Самая болезненная проблема. Пишешь pipeline filter, работает. Через месяц OpenWebUI обновляется — filter ломается. API плагинов нестабильный, changelog не покрывает breaking changes.

RAG - ну слишком простой для 2026 года.


OpenWebUI — отличный пилот. Если нужно за 3 дня поднять AI-чат, показать бизнесу value, собрать feedback — хороший вариант. Но строить на нём production — как надстраивать этажи на фундаменте, который не рассчитан на здание. Каждый новый слой делает конструкцию хрупче.

Рабочий путь в production — вся non-UI логика в orchestrator-сервис с MCP-hub'ом. OpenWebUI остаётся тонким frontend'ом: форк в ~50 строк middleware — прокидывает сообщение, файлы и chat-контекст в один OpenAI-compat endpoint оркестратора. Дальше orchestrator сам роутит на LLM / RAG / data-analyst / SQL-агент через MCP и внутренний LangGraph. Новый агент = регистрация MCP-server в конфиге, без правки UI.
Замечаю, что SGR многие слышали, но путают то со Structured Output, то с reasoning-моделями, то с chain-of-thought.

Поэтому — серия постов: что это, зачем, когда применять и где грабли
Пост 1/6 — «Schema Guided Reasoning: что это и как применять»

Structured Output (SO) гарантирует формат ответа. SGR — порядок и содержание рассуждений. Это не библиотека и не API — это паттерн поверх SO.

Термин формализован Ринатом Абдуллиным в 2025 году на базе production-опыта в банках, MedTech и логистике. До этого подход жил в виде разрозненных практик ("Pydantic + chain-of-thought field"), но без общего имени.


Определение:
SGR — это применение structured output, при котором JSON-схема описывает не только формат ответа, но и последовательность когнитивных шагов, через которые модель обязана пройти, прежде чем выдать финальный результат.


Три уровня абстракции:
Plain prompt → свободный текст → гарантирует ничего
Structured Output → JSON по схеме → гарантирует формат, типы, enum
SGR → JSON, поля = шаги мышления → гарантирует формат + порядок reasoning
Reasoning model → скрытый CoT → внутренний CoT без внешней схемы


Ключевая идея SGR:


class Answer(BaseModel):
# СНАЧАЛА думаем
facts: list[str] # извлекаем факты
analysis: str # анализируем
confidence: float # оцениваем уверенность

# ПОТОМ отвечаем
final_answer: str


Использование — одна строка:


llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

structured_llm = llm.with_structured_output(Answer)

result: Answer = await structured_llm.ainvoke("your prompt")


with_structured_output() под капотом добавляет JSON Schema из Pydantic-модели в API-вызов и парсит ответ обратно в инстанс класса. Аналоги: Instructor, Pydantic AI, Outlines, vLLM guided decoding — идея одна.


Почему это работает: LLM генерирует токены авторегрессивно (слева направо). К моменту генерации final_answer модель уже "видит" собственный reasoning — facts, analysis, counter_arguments лежат выше в том же JSON.

Если поменять местами final_answer и facts, модель сначала выдаст ответ, а потом будет под него подгонять "обоснование". Порядок полей в схеме = порядок генерации токенов = порядок мышления.
Пост 2/6 — «Под капотом: от токенов до constrained decoding»

Чтобы понимать ограничения SGR, нужно понимать механику. Здесь — полный разбор: от токенов и softmax до маскирования логитов.


Токены — не слова

LLM работает не со словами, а с токенами — фрагментами текста из фиксированного словаря (vocabulary). Словарь формируется при обучении через BPE и содержит от ~32k до ~150k записей.


"hello" → [15339] # 1 токен
"unhappiness" → [359, 71, 14172] # 3 токена
"JSON" → [40, 942] # 2 токена


Кириллица дробится сильнее — "категория" может занять 3-5 токенов. Это влияет на constrained decoding.


Цикл генерации

Каждый шаг — классификация по всему словарю:


logits = model(input_ids) # вектор размера |V|
probs = softmax(logits) # нормализация в вероятности
next = sample(probs) # выбор следующего токена
input_ids = input_ids + [next] # добавляем к контексту


logits — сырые числа. softmax превращает их в распределение:


P(token_i) = e^(logit_i) / Σ_j e^(logit_j)


Пример. Словарь из 4 токенов:


"the"=2.1 "cat"=0.3 "{"=-1.7 "hello"=4.5

P("the") = 8.2%
P("cat") = 1.4%
P("{") = 0.2%
P("hello") = 90.3%


Модель "хочет" "hello" с 90%. Но нам нужен JSON, и единственный допустимый токен — {.


Constrained decoding: маскирование логитов

Идея: запрещаем недопустимые токены, устанавливая их логиты в -∞:


allowed_mask = compute_allowed_tokens(parser_state)
logits[~allowed_mask] = float('-inf')
probs = softmax(logits)


Почему -∞, а не 0? Потому что softmax(0) = e^0 = 1 — ненулевая вероятность. А softmax(-∞) = e^(-∞) ~ 0 — примерно 0.

Результат для примера выше:


masked = [-inf, -inf, -1.7, -inf]
P("{") = 0.18 / 0.18 = 100%


Модель не знает, что её ограничили. Относительные предпочтения между допустимыми токенами сохраняются. Семантический выбор модели не разрушается — он ограничивается.
Пост 3/6 — «Parser state machine: кто вычисляет маску»

Маску допустимых токенов вычисляет не модель, а внешний grammar engine — конечный автомат (FSM), который отслеживает текущую позицию в JSON-структуре.


Как строится FSM из JSON Schema

Входная схема:


{
"properties": {
"risk_level": {"enum": ["low", "medium", "high"]},
"score": {"type": "integer"}
}
}


Компилируется в автомат:

S0: начало → ожидаем {
S1: после { → ожидаем " (начало ключа)
S2: внутри ключа → допустимые: "risk_level", "score"
S3: после : → зависит от типа поля
S4: внутри enum → trie по ["low","medium","high"]
S5: внутри integer → цифры [0-9], знак -
S6: после , → следующий ключ или }
S7: конец → ожидаем EOS

Цикл на каждом шаге:

FSM: state → allowed_tokens → mask
LLM: logits → mask → softmax → sample → token
FSM: transition(token) → new_state → повторить


Пример генерации по шагам:

Схема: {"risk_level": Literal["low","high"], "score": int}

| Шаг | Сгенерировано | Допустимые | Выбрано |
|---|---|---|---|
| 0 | (пусто) | { | { |
| 1 | { | " | " |
| 2 | {" | risk_level, score | risk |
| 3 | {"risk | _level" | _level" |
| 4 | {"risk_level" | : | : |
| 5 | {"risk_level": | " | " |
| 6 | {"risk_level":" | low, high | low |
| 7 | {"risk_level":"low | " | " |

На шаге 6 — модель делает семантический выбор между "low" и "high". Grammar engine разрешает оба, но logit("low") > logit("high") → модель выбирает "low".

Это и есть точка, где SGR работает: schema контролирует какие варианты допустимы, модель контролирует какой из допустимых выбрать.

Trie для enum

Enum-значения сначала прогоняются через токенизатор модели. Один и тот же текст может стать одним токеном или несколькими — зависит от словаря:


"low" → ["low"] → 1 токен id=5023
"medium" → ["med", "ium"] → 2 токена id=1821, 772
"high" → ["high"] → 1 токен id=993


Из этих токенов строится префиксное дерево. Узлы — это token_id, в скобках — что этот токен значит текстом:


(root) — стейт сразу после открывающей "

├── 5023 ("low") → END ← одношаговая ветка

├── 1821 ("med") ─── 772 ("ium") → END ← двухшаговая ветка

└── 993 ("high") → END ← одношаговая ветка


Что отсюда видно про токены:

- Маска на шаге после " разрешает ровно три token_id: {5023, 1821, 993}. Все остальные ~50k токенов словаря получают −∞ в логитах.
- Если модель выбрала 1821 ("med"), FSM перешёл в новый стейт, где маска разрешает только 772 ("ium"). Никакого выбора у модели уже нет — даже если logit("ium") низкий, это единственный допустимый токен.
- Длина enum-значения в символах ≠ числу шагов генерации. "medium" длиннее "high" в символах, но различие в шагах определяется токенизатором, а не строкой.

Свободные поля: str внутри JSON

Для поля reasoning: str grammar знает только структурные ограничения — допустимы все текстовые токены, не допустим raw " без escape.

Grammar не знает, когда закрыть кавычку. Решение о длине строки принимает модель. Поэтому reasoning: str не ограничивает содержание мышления — только не даёт сломать JSON.

Это же объясняет, почему SGR не делает модель умнее: свободные текстовые поля — "окна" в грамматике, где модель генерирует без ограничений.
Пост 4/6 — «Паттерны SGR: Single-Stage, Routing, Cascade, Cycle»

Все примеры — на Pydantic + LangChain. Принципы те же для Instructor, Pydantic AI, Outlines, vLLM guided decoding.

4.1 Single-Stage: reasoning встроен в схему

Один LLM-вызов, схема явно требует промежуточных шагов:


class TicketTriage(BaseModel):
# 1. Извлечение фактов
user_intent: str
mentioned_entities: list[str]
# 2. Анализ
is_urgent_signals: list[str]
sentiment: Literal["neutral", "frustrated", "angry"]
# 3. Решение
category: Literal["billing", "technical", "account", "other"]
priority: Literal["low", "medium", "high", "critical"]
confidence: float = Field(ge=0.0, le=1.0)
# 4. Action (после reasoning)
suggested_action: Literal["auto_reply", "route_to_l1", "route_to_l2", "escalate"]
reasoning: str


Порядок полей = порядок токенов. suggested_action идёт после priority и category, а не до.

Антипаттерн: reasoning_steps: list[str] последним полем "для отчётности". Модель его сгенерирует, но на финальное решение он уже не повлияет.

4.2 Routing: schema как router

Вместо отдельного intent-классификатора:


class RouteDecision(BaseModel):
user_request_summary: str
requires_realtime_data: bool
requires_internal_docs: bool
requires_calculation: bool
route: Literal["rag", "websearch", "code_interpreter", "direct_answer", "clarify"]
clarification_question: str | None = None


route — Literal, constrained decoding гарантирует одно из допустимых значений. Поля requires_* перед route — reasoning: модель сначала "обдумывает" характеристики, потом решает.

Плюсы: один LLM-вызов вместо двух, контекстное решение (видит весь чат), self-explaining (поля requires_* = аудит-trail).

Минус: latency выше, чем у эмбеддинг-классификатора. Окупается при нетривиальном route-решении.

4.3 Cascade: декомпозиция на этапы

Сложные задачи разбиваются на цепочку SGR-вызовов, каждый со своей схемой:


class ExtractedEntities(BaseModel):
diagnoses: list[str]
medications: list[str]
procedures: list[str]

class NormalizedEntities(BaseModel):
diagnoses_icd10: list[str]
medications_atc: list[str]
timeline: list[dict]

class ClinicalAssessment(BaseModel):
risk_factors: list[str]
contraindications: list[str]
risk_level: Literal["low", "medium", "high"]
requires_specialist_review: bool
reasoning: str

async def process_document(text: str):
extracted = await extract_llm.ainvoke(text)
normalized = await normalize_llm.ainvoke(extracted.model_dump_json())
return await assess_llm.ainvoke(normalized.model_dump_json())


Зачем разбивать: каждый этап тестируется юнит-тестом, разные модели на разных этапах, 4+ уровней вложенности или 50+ полей в одной схеме деградируют качество, возможность кэширования промежуточных результатов.


4.4 Cycle: self-correction через retry

Самый мощный паттерн в production — "draft → validate → fix":


class SQLDraft(BaseModel):
intent: str
tables_needed: list[str]
sql: str
expected_row_count_range: tuple[int, int]

async def generate_validated_sql(question, schema, max_retries=3):
error_context = ""
for attempt in range(max_retries):
prompt = f"Question: {question}\nSchema: {schema}"
if error_context:
prompt += f"\nPrevious attempt failed: {error_context}"
draft = await sql_llm.ainvoke(prompt)
try:
await db.execute(f"EXPLAIN {draft.sql}")
return draft.sql
except DBError as e:
error_context = f"Attempt {attempt+1}: {e}\nSQL: {draft.sql}"
raise RuntimeError(f"Failed after {max_retries}")


Важно: ошибка валидации должна возвращаться модели в структурированном виде, а не как сырой stack trace.


4.5 Hybrid

Реальный production-pipeline комбинирует паттерны:

[Routing] classify intent (SGR с Literal)

[Cascade] retrieve → grade → rewrite (SGR-вызовы)

[Cycle] draft answer → validate → repair (loop, max 3)

final answer
Пост 5/6 — «LangGraph + SGR: end-to-end пример»

Как собирается RAG-агент на SGR, пример: классификация запроса → retrieval → оценка чанков → генерация ответа с цитатами → валидация → self-repair при ошибке.


Архитектура графа:


START → classify (Routing-SGR)
├─ clarify → END
├─ direct_answer → END
└─ retrieve → grade → generate (Single-Stage SGR) → validate
↑ ↓
└── repair ───┤ (Cycle, max 3)

finalize → END


Главная схема — FinalAnswer. Порядок полей тут критичен:


class CitationClaim(BaseModel):
claim: str
chunk_id: int

class FinalAnswer(BaseModel):
question_understanding: str # 1. понимаем вопрос
relevant_chunks_used: list[int] # 2. отбираем источники
reasoning_chain: list[str] # 3. строим цепочку вывода
answer: str # 4. формулируем ответ
citations: list[CitationClaim] # 5. привязываем к источникам
self_check_passed: bool # 6. сами проверяем


К моменту, когда модель генерирует answer, в JSON уже лежат question_understanding → relevant_chunks_used → reasoning_chain. Три шага reasoning пройдены. Self_check_passed в конце — модель сама сообщает, уверена ли.


Как SGR-поля превращаются в ветвления графа:


def route_after_classify(state) -> str:
decision = state["route_decision"]
if decision.route == "clarify":
return "clarify"
if decision.route == "direct_answer" or decision.is_chitchat:
return "direct_answer"
return "retrieve"

def route_after_validate(state) -> str:
if not state.get("validation_errors"):
return "finalize"
if state.get("repair_attempts", 0) >= 3:
return "finalize"
return "repair"


Это и есть связка SGR граф: поле Literal в схеме → conditional edge в DAG. Никаких отдельных классификаторов, промптов для роутинга, regex на выходе LLM.


Что здесь на уровне паттернов SGR:

▪️ classify — Routing. Reasoning-поля (requires_internal_docs, is_chitchat) перед route
▪️ grade — Cascade-step. Один SGR-вызов оценивает все чанки
▪️ generate — Single-Stage: question_understanding → reasoning_chain → answer
▪️ validate + repair — Cycle. Ошибка уходит обратно в generate через state
▪️ self_check_passed — модель сама сигналит низкую уверенность
Пост 6/6 — «SGR + Reasoning Models, чеклист и главный takeaway»

SGR + Reasoning Models: комплементарность

С появлением o1/o3/R1/Claude Extended Thinking вопрос: нужен ли SGR, если модель сама умеет CoT?

Короткий ответ: да, но иначе.

Reasoning model alone
▪️ Reasoning — скрытый thinking блок
▪️ Аудитируемость — токены не всегда видны
▪️ Контроль порядка — модель решает сама
▪️ Когда — research, math, open-ended

SGR
▪️ Reasoning — явные поля схемы
▪️ Аудитируемость — полностью видно
▪️ Контроль порядка — задано схемой
▪️ Когда — production, integrations

Рекомендация:
▪️ Reasoning model + SGR — для критичных бизнес-решений (медицина, финансы, legal)
▪️ Reasoning model alone — для исследовательских задач
▪️ SGR без reasoning model — на open-weight (qwen, llama), когда нужно вытянуть максимум


Чеклист для production:

Дизайн схемы:
▪️ Reasoning-поля перед action/answer
▪️ Глубина вложенности ≤ 3 уровней
▪️ ≤ 30 полей на схему (иначе Cascade)
▪️ float вместо int для измерений
▪️ Optional вместо дефолтов, маскирующих ошибки
▪️ Literal для дискретных значений вместо str

Валидация:
▪️ Pydantic-валидация на выходе обязательна
▪️ Бизнес-валидация поверх Pydantic
▪️ Retry loop с error-context обратно в LLM
▪️ Бюджет ретраев (max_retries=3)

Мониторинг:
▪️ Логирование raw LLM output до парсинга
▪️ Метрики: schema compilation time, validation error rate, retry rate
▪️ Семплинг в human review — Pydantic-валидный ≠ бизнес-валидный



Главный takeaway

SGR — это дисциплина проектирования, а не библиотека. Его суть:

Схема — это не формат, а спецификация когнитивного процесса. Constrained decoding — механизм её принуждения.

▪️ Каждое поле схемы — обязательный шаг мышления модели
▪️ Порядок полей = порядок токенов = порядок reasoning
▪️ Pydantic-схема = unit-тестируемая часть промпт-инжиниринга
▪️ Tool calling — для действий. SGR — для решений
▪️ Reasoning model + SGR > reasoning model alone > SGR alone > plain prompt

Structured output не делает модель умнее. Он делает её более управляемой, аудитируемой и интегрируемой. Это фундаментально другая ось улучшения — не через model scaling, а через design.


Ссылки:
▪️ Schema-Guided Reasoning — Rinat Abdullin: https://abdullin.com/schema-guided-reasoning/
▪️ SGR Patterns (Cascade/Routing/Cycle): https://abdullin.com/schema-guided-reasoning/patterns
▪️ The Format Tax — arxiv: https://arxiv.org/abs/2604.03616
▪️ Structured Outputs Create False Confidence — BAML: https://boundaryml.com/blog/structured-outputs-create-false-confidence
▪️ Structured Decoding in vLLM: https://blog.vllm.ai/2025/01/14/struct-decode-intro.html
▪️ Self-Correction with Instructor: https://python.useinstructor.com/examples/self_critique/
▪️ Local LLM Tool Calling — Docker: https://www.docker.com/blog/local-llm-tool-calling-a-practical-evaluation/
Открыл repo: 145 AI-skills для русских юристов

Anthropic пару месяцев назад тихо выкатил claude-for-legal — пак из 110+
воркфлоу для американских юристов под открытой лицензией. Не приложение,
не SaaS, просто папка с Markdown-файлами.

Залез внутрь, изучил и портировал под русское право.
Получился ru-legal — Apache 2.0, на GitHub.

▪️ Проблема

Юристам в РФ нужно ровно то же, что американским: проверить договор,
оценить риски увольнения, ответить на ВНП, отработать DSAR под 152-ФЗ.

Сейчас всё делается руками — Word + 8 вкладок: pravo.gov.ru, ЕГРЮЛ, КАД,
ЕФРСБ, Роспатент, Росреестр, Закупки, КонсультантПлюс.

RAG поверх документов не спасает. Юриспруденция — не «найди и расскажи»,
а процедура с обязательной верификацией против актуальных источников.
LLM без procedural workflow процитирует устаревшую редакцию ГК и забудет
проверить КАД → юрист подпишет → иск проигран.

Нужны две вещи:
• операционализированные процедуры (skills)
• доступ к живым госреестрам (MCP)

▪️ Skill — это исполняемый чек-лист

Markdown-файл с frontmatter и workflow прозой. LLM читает один раз и
выполняет процедуру, не пересказывает.

Самое неочевидное — внутри workflow прямо в тексте прописаны вызовы
инструментов. Из contract-review:

Если ИНН контрагента известен — вызови egrul.lookup_company(inn).
Извлеки статус, руководителя, бенефициаров.

Через pravo.search_npa() найди релевантные статьи ГК с актуальной
редакцией.

Это не псевдокод. LLM реально дёргает MCP-инструменты с этими аргументами.

Плюс обязательная секция degraded mode: «если MCP лёг — продолжай,
но в финальном отчёте честно укажи, что контрагент не проверен и цитаты
ГК без сверки помечены [ред. требует проверки]». Без этого либо падаем,
либо галлюцинируем с уверенным лицом.

145 skills, 14 паков: договорное, трудовое, налоговое, корпоративное, IP,
152-ФЗ, ДДУ-214, госзакупки, AML, литигация, AI governance.

Половина — глубокая адаптация Anthropic'ского пака (at-will employment и
ст.81 ТК — два разных мира). Вторая половина — с нуля под РФ.

▪️ MCP-стек над госреестрами

Семь специализированных серверов плюс агрегатор:

pravo-mcppravo.gov.ru — НПА с указанием редакции
egrul-mcpapi-fns.ru — контрагент, руководство, бенефициары
kad-mcpkad.arbitr.ru — арбитражные дела по ИНН
efrsb-mcpbankrot.fedresurs.ru — банкротства
rospatent-mcpfips.ru — патенты, ТЗ, ПО
zakupki-mcpzakupki.gov.ru — тендеры + реестр недобросовестных
rosreestr-mcp → Росреестр — кадастр и права
ru-legal-mcp — агрегатор, регистрирует все 145 skills как MCP prompts

Каждый — PyPI-пакет на fastmcp + httpx, с rate limiter, LRU-кэшем,
timeout'ом, Pydantic-валидацией входов.

Как это работает в связке: юрист пишет «проверь договор с ИНН 7707083893»
→ contract-review делает три параллельных вызова в egrul / kad / efrsb
→ склеивает в risk-summary с конкретными ссылками.

То, что руками 30-40 минут — 4 секунды и воспроизводимо.

▪️ Честно про стабильность

Не все MCP одинаково надёжны, и проблема не в коде обёрток, а в самих
источниках:

• egrul, pravo, efrsb — стабильно
kad.arbitr.ru — снаружи РФ режет Qrator, нужен прокси
• rospatent — endpoint плавает, схема меняется без warning'ов
• zakupki, rosreestr — частично, лимиты на пагинацию + смешанная авторизация

Сейчас активно ищу альтернативы — партнёрки с Casebook/Caselook вместо
прямого КАД, прокси-инфра в РФ для нестабильных источников,
embedding-based router для skills (на 145 ещё keyword работает,
на 500 уже нет).

▪️ Что дальше

Skills и MCP — это слой контента и доступа. Они работают в Claude Code /
Cursor / любом MCP-клиенте, но это интерактивный режим «юрист + AI».

Следующий шаг — отдельный harness для тех кто не может клод использовать.

Это будет отдельный репо ru-legal-agent, чтобы не смешивать слой
контента со слоем оркестрации.


Open-source, Apache 2.0:
🔗 github.com/AlsKozlov/ru-legal
🔥1