“Будь-яка достатньо розвинена система доступу нерозрізненна від системи довіри.” — вільно за Артуром Кларком
У першій частині я описав сім архітектурних шарів, які перетворюють чат-бота на робочу систему: пам’ять, граф знань, retrieval, делегування, model routing, кеш промптів, верифікація. Тоді це була архітектура, значна частина якої була на рівні ідей на папері — послідовний виклад принципів із прикладами коду.
За наступні два тижні ці шари стали працюючим кодом. Але не все пішло за планом.
Що було зроблено
- Інтеграція NotebookLM — автоматичний ingest нотатників у єдиний індекс знань через extraction → chunking → embedding pipeline
- FAISS + гібридний retrieval — повний embedding pipeline на E5, лексичний пошук BM25, об’єднання через Reciprocal Rank Fusion
- Scoped agent access — два субагенти для Telegram-груп (наука та викладання) на спільній базі знань з ізольованими scope’ами та taint policy; запущені як пілот з колегами
- Делегування v1.1 — hardened специфікація з truthful completion semantics та explicit success contracts
- repo-task-proof-loop — skill для автоматичної верифікації роботи субагентів у sandbox (Docker, без мережі)
- Retrieval metadata layer —
retrieval_hints,source_kind,sensitivityна кожному вузлі графа знань - Cross-cluster bridges — автоматичне виявлення семантичних зв’язків між ізольованими кластерами
- Agent runbook suite — шість операційних runbook’ів для класифікації задач та маршрутизації моделей
Що зламалось і здивувало
Чотири речі здивували найбільше:
“Найрозумніша” модель — не завжди найкраща для задачі. GPT-5.4 і Gemini 3.1 Pro на деяких завданнях заводили в довгі манівці: моделі генерували розлогі пояснення, переформульовували умову, пропонували часткові рішення — і так по колу, не виходячи на результат. Особливо яскраво це проявлялося, коли задача передбачала самостійну зміну JSON-конфігів OpenClaw: модель часто вносила неповні зміни, не перевіряючи валідність результату. Переключення на Anthropic Claude Opus або навіть Sonnet 4.6 в поєднанні з цим skill’ом майже одразу давало конкретне рішення. Проблему частково вдалося мінімізувати, створивши кастомний skill на основі документації OpenClaw — openclaw-docs-expert. Це не вирок першим моделям — але практичне підтвердження, що model routing за типом задачі в поєднанні з правильним контекстом є реальним архітектурним рішенням, а не теорією.
Семантичний пошук сам по собі повертав “правдоподібно, але неправильно.” Запит про алгоритм Pan-Tompkins для виявлення R-піків на ЕКГ повертав документи про “обробку сигналів” загалом — семантично близько, фактично безкорисно.
Мульти-агентний доступ без scope контролю витікав контекст. Субагент для викладання отримував фрагменти з наукових досліджень, не пов’язаних із курсом. Не тому що хтось налаштував неправильно — тому що ніхто не налаштував ізоляцію взагалі.
Делегування, яке рапортує успіх на порожньому виводі, — гірше за відкритий провал. Субагент отримав помилку від провайдера моделі, повернув (no output), а батьківський оркестратор відрапортував: “задача виконана успішно.”
Усі чотири спостереження — різні прояви однієї глибшої теми: довіра — це не властивість компонента. Це властивість пайплайну. Пошук може бути точним, але якщо він показує не ті дані не тому агенту — системі не довіряєш. Делегування може бути формально коректним, але якщо порожній вивід означає “успіх” — результатам не віриш.
Ця частина про те, як ми будували довіру — шар за шаром, від retrieval до верифікації.
Гібридний retrieval: чому семантичний пошук — це лише половина відповіді
Перша версія пошуку в Ayona працювала просто: текст → E5 embedding → FAISS індекс → cosine similarity → top-K результатів. Класична схема з будь-якого RAG-туторіалу.
Проблема стала помітною на реальних запитах. Доменна термінологія — медичні, юридичні, технічні терміни — мала специфіку, з якою чистий vector search справлявся погано. “Pan-Tompkins algorithm” — це конкретний алгоритм для виявлення R-піків у ЕКГ-сигналі. Але семантичний пошук бачив у цьому запиті лише “обробка сигналів” і повертав загальні документи про signal processing. Семантично близько, практично марно.
Ще один клас проблем — білінгвальні запити. Корпус містить документи українською та англійською, і запит українською іноді промахувався повз релевантний англомовний документ, навіть якщо ключовий термін був ідентичний в обох мовах.
Рішення: BM25 + E5 + Reciprocal Rank Fusion
Замість того щоб покращувати одну модель пошуку, ми додали другу — і об’єднали їхні результати.
BM25 — класичний лексичний пошук на основі TF-IDF. Він не “розуміє” семантику, але чудово знаходить точні збіги термінів. Якщо в запиті є “Pan-Tompkins” і в документі є “Pan-Tompkins” — BM25 його знайде, навіть якщо vector search промахнувся.
E5 — семантичний embedding, який добре знаходить парафрази, синоніми та тематичну схожість, але може промахнутись на точних термінах.
Reciprocal Rank Fusion (RRF) — алгоритм, який об’єднує два ранжовані списки в один, не вимагаючи калібрування скорів. Формула проста: для кожного документа в обох списках рахується 1 / (K + rank), де K — константа (ми використовуємо 60). Чим вище документ в обох списках — тим вищий сумарний RRF-скор.
Ядро реалізації — дванадцять рядків:
def rrf_fuse(bm25_results, vector_results, k=60):
"""Fuse two ranked lists using Reciprocal Rank Fusion."""
fused = defaultdict(float)
for rank, (score, idx) in enumerate(bm25_results):
fused[idx] += 1.0 / (k + rank + 1)
for rank, (score, idx) in enumerate(vector_results):
fused[idx] += 1.0 / (k + rank + 1)
ranked = sorted(fused.items(), key=lambda x: -x[1])
return ranked
BM25 знаходить Pan-Tompkins на першому місці завдяки точному збігу терміна. E5 ховає його на четвертому місці серед семантично схожих документів. RRF fusion виносить його на перше місце з комбінованим скором.
Локальний embeddings-е5 контейнер
Замість того щоб платити за кожен embedding-запит до зовнішнього API, ми розгорнули власний Docker-контейнер embeddings-e5 на VPS — FastAPI-сервіс на базі sentence-transformers, сумісний з OpenAI /v1/embeddings API. Це знизило вартість індексування фактично до нуля.
Але одразу виникла проблема: контейнер завантажував CPU на 100% навіть на невеликих батчах. Рішення — вибір intfloat/multilingual-e5-small: компактна версія моделі (384-вимірні embeddings замість 768 у base-варіанті), суттєво менший inference footprint при CPU-only inference — і достатня якість для retrieval на нашому корпусі. Після переключення CPU-навантаження на VPS впало до прийнятних значень.
# 02_distill/deploy/embeddings-e5/docker-compose.yml
services:
embeddings:
image: python:3.11-slim
container_name: embeddings-e5
environment:
MODEL_NAME: intfloat/multilingual-e5-small # small, не base
ports:
- "127.0.0.1:8000:8000" # localhost only
networks:
- openclaw-net
Пайплайн на етапі індексації:
NotebookLM extraction → chunking (3000 chars, overlap 500)
→ E5 batch embeddings (8-item batches)
→ FAISS IndexFlatIP + BM25 index
На етапі запиту:
query → BM25.search(top_k=20) + vector_search(top_k=20) → RRF fusion → top-K результатів
Кожен результат зберігає обидва скори для прозорості:
#1 [RRF:0.0323] Pan_Tompkins_ECG_detection.txt
BM25:8.42 | Vec:0.7891
"The Pan-Tompkins algorithm detects QRS complexes in ECG signals..."
Чому IndexFlatIP, а не IndexIVFFlat?
Корпус — менше 10 000 фрагментів. Flat index (exact search) на такому обсязі працює за мілісекунди. IVF додав би складність конфігурації (nlist, nprobe), необхідність тренувального етапу — і жодного практичного виграшу. Рішення перейти на IVF прийдеться ухвалити, коли корпус перевалить за 100k фрагментів. Не раніше.
Failure mode: параметр K
K у RRF — це “демпфер позиції”. Низький K (наприклад, 1) надто сильно підсилює різницю між першим і другим місцем у ранжованому списку. Високий K (наприклад, 1000) вирівнює всі позиції і робить fusion безглуздим. K=60 — емпіричне значення з оригінальної статті Cormack et al., яке добре працює для більшості корпусів. Ми протестували на 10 тестових запитах (українською та англійською) і не знайшли причин відхилятися.
Практичний ефект: на тестовому наборі з 10 запитів (5 українських, 5 англійських) гібридний пошук стабільно знаходив релевантні результати там, де чистий vector search промахувався на точних термінах. Реалізація — ~190 рядків коду, нуль додаткових зовнішніх залежностей (BM25 написаний з нуля).
Scoped access: агент бачить тільки те, що йому дозволено
Коли в системі один агент — контроль доступу не потрібний. Але на цьому етапі ми запустили двох спеціалізованих субагентів — поки що виключно як асистентів у Telegram-групах для тестування з колегами:
- research-assistant — бот у дослідницькій групі, орієнтований на кластер
research(наукові статті, дані, методологія) - teaching-bot — бот у навчальній групі для кластера
teaching(матеріали курсів, лекції, завдання студентів) - main — основна Айона (повний доступ, поза групами)
Обидва субагенти — це ранній пілот: реальні колеги, реальні запити в чатах, ніякого staging. Це дало швидкий зворотній зв’язок, але одразу підняло практичне питання ізоляції: обидва субагенти працювали на одній базі знань і без scoping teaching-bot буквально бачив сирі дані досліджень, не призначені для студентів — і навпаки.
Рішення: agent_scopes.json + ScopedRAG + taint policy
Ізоляція побудована на трьох рівнях.
Рівень 1 — Scope mapping. Один JSON-файл визначає, який агент бачить які кластери:
{
"research-assistant": {
"scopes": ["research", "ayona_ops"],
"access_level": "internal",
"deny_patterns": ["memory/*"]
},
"teaching-bot": {
"scopes": ["teaching"],
"access_level": "group",
"deny_patterns": ["memory/*", "02_distill/research/*"]
},
"main": {
"scopes": [],
"access_level": "internal",
"deny_patterns": []
}
}
Порожній scopes означає повний доступ. Deny patterns — додаткові glob-маски для фільтрації шляхів, які агент не повинен бачити навіть у межах дозволеного scope.
Рівень 2 — ScopedRAG middleware. Клас, який стоїть між агентом і пошуковим індексом:
rag = ScopedRAG.from_config("teaching-bot")
results = rag.search("методи оцінювання знань студентів", top_k=5)
from_config() завантажує scope, потім search() послідовно застосовує: scope filter → deny pattern matching → taint policy → audit log.
Ключове рішення: deny-by-default для невідомих агентів. Якщо agent_id відсутній у конфігу, агент отримує порожній scope ["__NONE__"] і рівень доступу "denied". Це єдиний безпечний default у мультиагентній системі.
if agent_id not in configs:
return cls(scopes=["__NONE__"], access_level="denied")
Рівень 3 — Taint policy. Навіть у межах дозволеного scope не кожне джерело однаково надійне. Taint policy визначає, що можна робити з результатом залежно від його рівня довіри:
TRUST_LEVELS = {
"internal": 3, # workspace files — повна довіра
"external": 2, # Drive, NotebookLM — може інформувати, не ініціювати
"untrusted": 1, # web, forwarded — тільки показати
}
ACTION_RISKS = {
"read": 1, # показати інформацію
"analyze": 1, # аналіз, резюме
"suggest": 2, # пропозиція дій
"execute": 3, # виконання команд
"external_send": 3, # email, telegram, webhook
}
Правило просте: trust_level має бути не менший за action_risk. Untrusted джерело не може ініціювати execute або external_send. External джерело вимагає explicit approval для високоризикових дій.
Аудит. Кожен scoped запит логується в rag_audit.jsonl:
{"timestamp": "2026-03-25T14:22:01", "query": "методи оцінювання знань студентів",
"scope": "teaching", "action": "read", "allowed": 4, "blocked": 1}
Це не лише для debugging. Це для відповіді на питання: “Чому бот не знайшов ось цей документ?” — яке виникає в продакшені раніше, ніж здається.
Практичний ефект: агенти діляться одним робочим середовищем і одним індексом, але бачать повністю різні зрізи знань. Додати нового агента — один JSON entry. Видалити доступ — видалити entry. Не потрібна жодна зміна в коді пошуку.
research-assistant та teaching-bot проходять через ScopedRAG middleware, який застосовує scope filter → deny patterns → taint policy. Невідомий агент отримує порожній scope [“NONE”] і рівень доступу “denied” автоматично.
Делегування v1.1: порожній вивід — це не успіх
У першій частині я описав bounded delegation envelope — контракт, який формалізує делегування замість “просто відправ промпт субагенту.” Між теорією і продакшеном лежала одна помилка, яка змінила всю специфікацію.
Що сталось
Дочірній агент отримав задачу: написати аналітичний документ і зберегти його як артефакт. Під час виконання провайдер моделі повернув помилку. Дочірній агент завершився з (no output). Батьківський оркестратор отримав completion event і відрапортував: “задача виконана успішно.”
Ніхто не перевірив, чи з’явився артефакт. Ніхто не подивився в child history. Completion event прийшов — значить, успіх.
Це класичний deceptive completion — провал, який маскується під успіх. І він руйнує довіру до всієї системи делегування.
Три правила v1.1
Ми не переписували архітектуру. Ми додали три правила — hardening pass, не redesign.
Правило 1: Truthful completion semantics. Якщо дочірній сеанс завершується з ознаками помилки (stopReason == error, непорожній errorMessage, або abort з failure payload), батьківський completion мусить бути failed. Точка. (no output) не маскує провал.
Правило 2: Порожній вивід — це suspect state. Порожній completion — це не валідний success. Це підозра, яка вимагає обов’язкової верифікації:
- Перевірити child session history
- Перевірити terminal assistant message
- Перевірити expected artifact path
- Тільки після цього оголосити результат
Правило 3: Explicit success contract. Launch packet тепер зобов’язаний визначити критерії успіху до запуску:
delegation_flow: v1.1
task_class: writing
primary_model: anthropic/claude-sonnet-4-6
expected_output_type: artifact
expected_artifact_path: 03_insights/analysis.md
success_condition: "artifact exists and is non-empty"
failure_condition: "terminal error OR artifact missing"
verification_steps: [child_history, artifact_exists, terminal_state]
До v1.1: порожній вивід → completion event → “Task completed successfully” → довіра зруйнована. Після v1.1: три перевірки (error semantics, empty output, artifact existence) → Status: FAILED → retry або ескалація.
Artifact gate
Якщо expected_output_type дорівнює artifact або both, completion не може бути success, поки артефакт не існує. Це механічне правило, яке не залежить від “думки” моделі про якість результату. Файл або є — або його немає.
Практичний ефект: нуль false-success completions після прийняття v1.1. Верифікація додає крок до кожного делегування — але це ціна, яку варто платити.
Proof loop: код, який доводить, що він працює
Делегування v1.1 гарантує, що ми не пропустимо провал. Але як перейти від “ми помітили провал” до “система сама повторює спробу і доводить успіх”?
Для цього ми створили repo-task-proof-loop — skill, який замикає коло: від формулювання задачі до автоматично верифікованого результату.
Пайплайн
Опис задачі
→ генерація acceptance criteria (heuristic або LLM)
→ запуск субагента в sandbox (Docker, без мережі)
→ автоматична верифікація
→ якщо must-критерії не пройшли → retry
→ якщо пройшли → apply patches → create branch → push → PR URL
Orchestrator: while-loop з верифікацією
Серце skill’у — клас Orchestrator, який координує runner, verifier та acceptance criteria:
def run(self):
report = {'run_id': self.runner.run_id, 'iterations': []}
iteration = 0
success = False
while iteration < self.max_iterations and not success:
iteration += 1
# запуск субагентної задачі
rc, out, err = self.runner.run_subtask('...')
# верифікація
results = self.verifier.run_criteria(self.ac.get('criteria', []))
# рішення: чи є must-failures?
must_fail = [r for r, c in zip(results, self.ac['criteria'])
if c.get('severity') == 'must' and r['rc'] != 0]
success = len(must_fail) == 0
if success:
# apply patches, push branch, create PR
branch = f'feature/run-{self.runner.run_id}'
self.runner.apply_patches(branch_name=branch, push=True)
Verifier: навмисна простота
Верифікатор — це свідомо мінімальний компонент. Весь код — два десятки рядків:
class Verifier:
def __init__(self, workdir='.'):
self.workdir = workdir
def run_test(self, command: str):
proc = subprocess.Popen(command, shell=True, cwd=self.workdir,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate()
return {
'command': command,
'rc': proc.returncode,
'stdout': out.decode('utf8', errors='replace'),
'stderr': err.decode('utf8', errors='replace')
}
def run_criteria(self, criteria):
return [self.run_test(c.get('test_command')) for c in criteria]
Це не баг, а рішення. Верифікатор має бути аудабельним за секунди. Якщо верифікатор сам потребує верифікації — це рекурсія, яка нікуди не веде.
Must vs Should
Acceptance criteria мають два рівні severity:
- must — блокують completion. Якщо pytest не пройшов — retry або fail.
- should — інформативні. Якщо linter знайшов стилістичну проблему — це не причина блокувати робочий результат.
Це розділення критичне. Без нього flaky linter або педантичний formatter може нескінченно блокувати субагента, який вже створив працюючий код.
Практичний ефект: робота субагента, яка раніше вимагала ручного review кожного результату, тепер самоверифікується. Людина ревʼюює PR, а не процес. Proof loop — це delegation envelope v1.1, зроблений executable.
Граф знань як контекстна маршрутизація
У першій частині граф знань був описаний як “топологія рішень” — структура, яка зменшує ентропію доступу до знань. За два тижні він став ще й маршрутизатором контексту для retrieval.
Retrieval metadata layer
Раніше граф-маршрутизація залежала від першого абзацу markdown-картки — людського тексту, написаного для людей. Це працювало, поки карток було мало. На 100+ вузлах якість маршрутизації стала залежати від того, наскільки вдало автор написав перше речення.
Рішення: додати machine-oriented metadata layer до кожного routing-critical вузла:
retrieval_hints:
- "model routing policy for subagent delegation"
- "ops-lite vs design-tier task classification"
source_kind: policy
sensitivity: internal
canonical_artifacts:
- 99_process/agent_runbooks/task_classification_and_model_routing.md
Тепер graph-context skill читає retrieval_hints напряму, замість того щоб парсити markdown і сподіватись, що перший абзац достатньо інформативний.
Bucket A/B/C класифікація
Не всі вузли однаково важливі для маршрутизації. Ми класифікували їх у три категорії:
- Bucket A (graph-facing hubs) — policy cards, protocol references, routing entry points. Ці вузли отримали повний retrieval metadata в першу чергу.
- Bucket B (supporting) — корисні, але не routing hubs.
- Bucket C (passive) — рідко запитувані, low-touch.
Це дозволило зосередити зусилля з metadata enrichment на ~20% вузлів, які забезпечують ~80% routing-трафіку.
Cross-cluster bridges
Scoped access ізолює кластери — і це правильно для безпеки. Але іноді знання з різних кластерів семантично пов’язані, і ці зв’язки варто знати.
cross_cluster_bridges.py порівнює embeddings між scoped індексами і знаходить пари фрагментів з cosine similarity вище порогу (за замовчуванням 0.78):
# Compute cross-similarity matrix
sim = emb1 @ emb2.T
# Find pairs above threshold
indices = np.argwhere(sim > threshold)
Результат — список “мостів” між кластерами:
[0.8234] research ↔ teaching
02_distill/research/nlp_evaluation_metrics.md
02_distill/teaching/text_generation_lab.md
Це не порушує ізоляцію — мости видні лише адміністратору. Але вони підказують: “можливо, ці матеріали варто зв’язати в графі” або “цей науковий результат перетинається з темою лекції.”
Практичний ефект: граф-маршрутизація стала детермінованою замість імовірнісної. Контекстна селекція перестала залежати від того, наскільки вдало людина написала перший абзац картки.
Уроки та failure modes
Два тижні імплементації залишили уроки, які варто записати.
Один метод пошуку — це один point of failure. Чистий vector search виглядає елегантно, але на доменній термінології та білінгвальних запитах він систематично промахується. Гібрид (BM25 + semantic + RRF) дешевий в реалізації і суттєво надійніший.
Deny-by-default — єдиний безпечний default. Якщо новий агент автоматично бачить все — це не feature, це інцидент, який чекає свого часу. Unknown agent = zero access.
Порожній вивід — це не нейтральний результат. Це suspect state. Системи, які трактують empty output як “нічого не сталось”, рано чи пізно пропускають реальний провал. Краще зупинитися і перевірити, ніж рухатися далі з оптимістичним “мабуть, все добре.”
Верифікатор має бути простішим за код, який він верифікує. Якщо верифікатор — це складна система зі своїми залежностями та конфігурацією, хто верифікує верифікатор? Двадцять три рядки — це не обмеження. Це рішення.
Metadata для машин — не те саме, що документація для людей. Перший абзац markdown-картки написаний для читача. retrieval_hints написані для retrieval pipeline. Коли ці дві цілі змішуються в одному тексті, страждають обидві.
Від теорії до пайплайну
Кожен шар відповідає на окреме питання: що знайти → хто може бачити → який контекст → як виконати → чи справді спрацювало. Разом вони формують єдиний пайплайн довіри.
Архітектура без імплементації — це whitepaper. Імплементація без верифікації довіри — це демо.
За два тижні ми пройшли від “ось як це має працювати” до “ось де це зламалось і як ми це полагодили.” Гібридний retrieval, scoped access, delegation hardening, proof loop, metadata-enriched граф — кожен із цих компонентів вирішує свою конкретну проблему. Але разом вони формують єдиний пайплайн довіри: від того, що система знаходить, через те, кому вона це показує, до того, як вона доводить, що робота справді зроблена.
Довіра — це не фіча. Це пайплайн.
Продовження буде.
Сергій Заболотній — DSc, NLP/LLM Researcher, Professor, AI Systems Architect. Будую Ayona — AI-native систему для дослідження та операцій.