AI-агенти в багатьох сервісах: читають через SQL, пишуть після схвалення

Коли в команди багато маленьких сервісів, кожен зі своєю базою й своїм API, AI-агент може дуже допомогти: відповідати на питання про дані, виправляти записи, щось додавати. Але одразу виникають дві проблеми. Агент не знає, що означають дані. І ніхто не хоче, щоб він змінював продакшен навмання.

Підхід нижче розв'язує обидві, і в нього одне правило: агент може читати що завгодно, але сам ніколи не змінює дані. Це не теорія: він працює в продакшені на десятку маленьких сервісів.

У центрі — один сервіс, який стоїть між агентами й усіма іншими сервісами. Назвімо його шлюзом. Агенти говорять лише зі шлюзом, через MCP (протокол, яким AI-агенти викликають інструменти).

Одне варто знати одразу: «читати що завгодно» — це буквально. Агент може прочитати все, що бачить роль шлюзу для читання, зокрема персональні дані, а все прочитане потрапляє до моделі, яка його запускає. Тож перше рішення — що саме цій ролі можна бачити (докладніше нижче).

Приклад від початку до кінця

Людина просить агента: «перестань стежити за стрічкою новин MiniMax». Є сервіс News, який стежить за стрічками новин. Його код лежить у репозиторії acme/news, а дані — в базі news.

  1. Агент знаходить дані. Він читає документи, які репозиторії тримають про свої бази, і виконує SQL через шлюз. SQL показує, що стрічка — це запис 29 у таблиці resources.
  2. Агент знаходить API, яке це змінює. Для кожного API є опис API: Markdown-файл поруч із його кодом, який точно каже, як його викликати. Шлюз знаходить опис API, яке змінює таблицю resources.
  3. Агент просить шлюз підготувати виклик: «видалити запис 29». Запит шлюз будує сам, з опису API. Нічого ще не надіслано.
  4. Шлюз надсилає чернетку. Чернетка — це той самий запит з draft=1. Сервіс News робить усе видалення в транзакції бази, відповідає так, як відповів би насправді, а потім скасовує транзакцію, тож нічого не змінюється. Відповідь ще й містить хеш (checksum) запису 29 у його теперішньому стані.
  5. Агент пояснює відповідь чернетки простими словами: «стрічка новин MiniMax більше не відстежуватиметься. Її запис буде видалено, і повернути його можна, лише додавши стрічку знову».
  6. Людина переглядає виклик. Він лежить у групі: наборі з назвою, куди потрапляють усі виклики, які агент підготував для цієї задачі. Агент дає людині посилання на групу, а сторінка показує кожен виклик, його чернетку й пояснення.
  7. Людина схвалює його ключем схвалення — секретом, який мають лише ті, кому дозволено схвалювати зміни. Шлюз ще раз запускає чернетку, перевіряє, що хеш той самий (тобто запис 29 тим часом не змінився), і тільки тоді надсилає справжнє видалення.

Далі — про кожну частину, і чому її зроблено саме так.

Що знає шлюз

Реєстр. Один файл перелічує всі сервіси: які бази кожному належать, де живе його API (базова адреса) і які заголовки має виклик до нього.

Документи. Кожен репозиторій пояснює себе Markdown-файлами в папці docs/, які змінюються в тих самих комітах, що й код. Вони бувають трьох видів:

  • docs/db_schema.md: короткий огляд бази репозиторію з посиланнями на документи наступного виду;
  • сторінки з подробицями про кожну частину бази: таблиці, що означає кожне значення, і готові запити;
  • описи API, docs/api/<name>.yaml.md.

Шлюз тримає копію всіх цих файлів у власній базі. Вебхук GitHub повідомляє йому про кожен push, і за кілька секунд він оновлює файли, що змінилися. Кожна копія пам'ятає, з якого коміту в git вона взялася, тож згодом кожен запит і кожен підготовлений виклик записує, на яку саме версію документа спирався.

Читання даних

  1. Агент одним зверненням просить у шлюзу db_schema.md з усіх репозиторіїв. З цих коротких оглядів він бачить, який сервіс тримає потрібні дані і яка сторінка з подробицями про них розповідає.
  2. Другим зверненням він читає цю сторінку.
  3. Потім він пише SQL, а шлюз його виконує.

Шлюз виконує SQL від імені ролі Postgres, яка вміє лише читати. Ця роль має доступ до баз із реєстру — і більше ні до чого. Її транзакції тільки для читання, тож жоден запит не змінить даних, а в кожного запиту є таймаут, тож жоден не триватиме вічно. Кожен запит потрапляє в лог разом із питанням, на яке він мав відповісти, і версіями документів, з яких його склали.

Ендпоінтів API лише для читання немає. Якщо дані є в базі, їх читають через SQL. Так дані читаються лише одним способом, і нікому не треба будувати й підтримувати окремі ендпоінти для агентів: API робить лише те, чого не може база.

Зміна даних докладно

Як знайти API. Опис API починається з блоку налаштувань YAML, який називають front matter. Його список writes перелічує кожне поле бази, яке можуть змінити виклики цього API, у вигляді база.таблиця.колонка — тими самими назвами, що й у SQL:

writes:
  - news.resources.url
  - news.resources.name

Агент питає шлюз: «яке API змінює news.resources?» Шлюз читає свою копію всіх описів API й повертає ті, у чиєму списку writes є ця таблиця: тут це опис resources у acme/news. Агент може шукати й простими словами («перестати стежити за стрічкою»). Окремого пошукового індексу, який треба оновлювати, немає: шлюз шукає у своїй копії описів, яка оновлюється з кожним push.

Дві частини опису API. Front matter обмежує агента. Він оголошує параметри з їхніми типами й обмеженнями, а також дії. Кожна дія має HTTP-метод, шлях, місце, куди йде кожен параметр, і позначку, чи потрібне схвалення людини. Шлюз будує запит саме з цієї частини, а не з того, як агент уявляє собі API. Тож агент не може вигадати поле, надіслати неправильний тип чи викликати ендпоінт, якого немає в описі.

actions:
  delete:
    method: DELETE
    path: /api/resources/{$id}
    query:
      draft:
        $param: draft

Тут {$id} підставляє параметр id у шлях, а $param: draft додає параметр draft у рядок запиту. Значення id дає агент, а значення draft — ніколи: опис лише каже, куди draft іде, а значення шлюз підставляє сам: 1 для чернетки, 0 для справжнього виклику.

Текст у Markdown після нього підказує агентові, як діяти. Він пояснює, яка дія підходить до якого запиту, як перетворити слова людини на параметри і як прочитати кожну можливу відповідь. З нього агент вибирає delete з id: 29.

Підготовка виклику. Агент передає шлюзу репозиторій, опис, дію, параметри, одне речення про те, навіщо це, і назву групи. Шлюз перевіряє параметри за front matter, бере базову адресу з реєстру, будує й зберігає запит: метод, адресу, заголовки й тіло. Облікові дані разом із ним не зберігаються: їх додають із реєстру тільки в момент надсилання.

Чернетка і навіщо вона. Одразу шлюз надсилає запит з draft=1. Це підтримує кожен ендпоінт, що змінює дані. Він робить усю справжню роботу в одній транзакції бази (перевірки, записи), скасовує транзакцію й відповідає так само, як відповів би справжній виклик. Роботу поза базою, як-от надсилання листа чи виклик іншого сервісу, у чернетці не виконують. Чернетка робить дві справи:

  • Перевіряє запит, який склав агент, на справжньому сервісі. Якщо агент щось сплутав (запису немає, сервіс не приймає значення), чернетка не проходить, і виклик так і не потрапляє до людини.
  • Дає хеш. Ендпоінт обчислює його з даних, від яких залежить зміна, і повертає в заголовку відповіді. Для цього видалення це запис 29 у його теперішньому стані. Для створення — що такого запису ще немає. Що саме входить у хеш, вирішує код самого ендпоінта, тож туди має потрапити все, від чого залежить зміна. Шлюз лише зберігає хеш і надсилає його назад разом зі справжнім викликом.

Пояснення. Агент читає відповідь чернетки й простими словами пише, що станеться, якщо виклик схвалять. Пояснення спирається на те, що відповіло API, а не на те, що агент хотів зробити, і написане для людини, яка вирішує.

Сторінка перевірки

Коли агент підготував усі виклики для задачі, він дає людині посилання на групу. Справжнє прохання часто потребує кількох викликів: «заміни ці три стрічки на нові адреси» — це три видалення і три створення, а деякі задачі зачіпають кілька сервісів. Група тримає їх разом, тож людина бачить усю зміну в одному місці й схвалює її одразу. Нічого не йде частинами, перевіреними наполовину.

Для кожного виклику сторінка показує:

  • Навіщо цей виклик: намір агента його власними словами.
  • Опис API, з якого його збудовано, з посиланням, і дію.
  • Дані, які надсилаються, у вигляді таблиці.
  • Відповідь чернетки. Зелена, якщо чернетка пройшла, і червона, якщо ні (червоний виклик схвалити не можна). Жовтіє, коли минув термін дії чернетки: за замовчуванням через 30 хвилин її відповідь може вже застаріти. Якщо її розгорнути, видно, що саме відповіло API.
  • Що означає відповідь чернетки: пояснення агента просто під цією відповіддю, щоб людина могла звірити слова з доказом.
  • Подробиці, згорнуті: точний збережений запит, хеш і час.
  • Позначки про зміни: «змінено», якщо новіша чернетка дала інший хеш, і «застаріло», якщо минув термін дії чернетки. Кожен виклик можна перевірити чернеткою ще раз або відкинути.

Унизу людина позначає виклики, які треба надіслати (усі або лише деякі), і вводить ключ схвалення. Власник шлюзу задає цей ключ у його налаштуваннях і дає лише тим, кому дозволено схвалювати зміни. Агенти говорять зі шлюзом іншим ключем, тож жоден агент не може схвалити власні виклики.

Під час схвалення шлюз знову запускає всі вибрані чернетки й порівнює нові хеші з тими, що на сторінці. Тож застаріла чернетка ніколи не заважає схваленню: її просто оновлюють. Якщо якийсь хеш змінився, нічого не надсилається: сторінка позначає ці виклики, і людина дивиться ще раз. Інакше виклики йдуть по одному, по порядку, кожен зі своїм хешем. Ендпоінт обчислює хеш ще раз у тій самій транзакції, перш ніж щось застосувати. Якщо дані змінилися в останню мить, він відповідає HTTP 412 («Precondition Failed») і нічого не застосовує.

Потім людина може попросити агента перевірити результат. Агент читає групу й пояснює, що кожна справжня відповідь додає до пояснення чернетки, або просто пише «зроблено, як і планували». Тоді сторінка показує і план, і результат кожної зміни.

Що все ще може піти не так

Група може зупинитися на півдорозі. Якщо виклик не вдався чи отримав 412, надсилання там і зупиняється. Уже надіслані виклики залишаються виконаними, а решта повертаються в очікування, кожен із поясненням чому. Нічого не скасовується автоматично. Сторінка показує, які виклики пішли, а людина вирішує, що далі. Деякі зміни взагалі не скасувати (видалений запис може зникнути назавжди), тож на видалення варто дивитися найуважніше.

Виклик ніколи не надсилається двічі. Якщо шлюз зупиниться, поки надсилає виклик, він не може знати, чи зміна відбулася. Він позначає такий виклик «може, виконано, а може, й ні» і не надсилає решту групи. Сам він ніколи не повторює виклик.

Виклики в одній групі не можуть залежати один від одного. Кожна чернетка запускається на даних у їхньому теперішньому стані. Якщо виклику 3 потрібен id, який створить виклик 2, чернетку виклику 3 не запустити, доки виклик 2 не виконано. Така задача потребує двох груп: схвалюють першу, потім готують другу.

Чернетка безпечна рівно настільки, наскільки її ендпоінт. Скасування транзакції відкочує записи в базу. А не надсилати листів і не викликати інших сервісів у чернетці має сам кожен ендпоінт, і ендпоінт, який зробить це неправильно, надішле справжній лист із чернетки. Чернетка ще й трохи коштує: поки вона працює, вона ненадовго блокує записи, яких торкається, а чернетка, що створює запис, витрачає номер із послідовності id у базі, тож id можуть іти з пропусками. Тому кожен сервіс дотримується одних записаних правил для чернеток і хешів, з тестами на них. Додати це в сервіс — чимала робота.

Хеш захищає лише те, що в нього входить. Якщо ендпоінт пропустить щось, від чого залежить його зміна, схвалення на старих даних усе одно може пройти.

Роль для читання бачить усе, до чого має доступ, зокрема персональні дані, а все, що агент прочитав, потрапляє до моделі, яка його запускає. Кожен запит потрапляє в лог разом із метою, але лог нічого не приховує. Якщо якісь таблиці чи колонки мають лишатися недоступними для агентів, ролі не можна давати до них доступ, і хтось має вирішити, до яких саме.

Агент читає текст, який написали інші. Заголовок новини чи документ можуть містити «ігноруй свої інструкції». Це може змінити не лише те, як агент пояснює виклик, а й те, які виклики він узагалі готує. Справжній захист — людина: нічого не надсилається без її схвалення, а сторінка показує поруч із кожним поясненням справжній запит і відповідь. Пояснення допомагає читати, а доказ — це запит і відповідь.

Люди можуть схвалювати, не читаючи. Двадцять викликів з охайними поясненнями підштовхують швидко натиснути «схвалити все». Маленькі групи, одна на задачу, легше прочитати як слід.

Один спільний ключ схвалення не каже, хто схвалив. Шлюз записує, коли схвалено кожен виклик, але не ким. Команді з кількома людьми, які схвалюють, потрібні особисті ключі.

Списки writes пишуть люди. Якщо в описі забули поле, пошук за цим полем не знайде API, і ніхто про це не дізнається. Списки змінюються в тому ж коміті, що й код та його міграції, а перевірка в CI могла б ловити пропуски. Поки що це правило, якого треба триматися, а не гарантія.

Чому не щось простіше?

Найпростіше — дати агентові роль бази тільки для читання, а всі зміни робитиме людина вручну. Це безпечно, але тоді всю роботу робить людина: шукає потрібний ендпоінт, складає запит, перевіряє його. Тут цю частину робить агент, сервіс її перевіряє, а людина лише вирішує.

Протилежне — дати агентові право запису — заощаджує людині час, але ніхто не бачить зміни, поки вона не сталася.

Чому це працює

  • Контекст іде з документів, які змінюються разом із кодом і знаходяться за два короткі кроки.
  • Читати дешево й безпечно, і кожне читання залишає слід.
  • Як викликати API, вирішує опис API, а не агент.
  • Чернетка перевіряє кожну зміну на справжньому сервісі ще до того, як людина витратить на неї час.
  • Людина схвалює наслідки, а не код. Сторінка простими словами показує, що станеться, і поруч — докази.
  • Хеш перевіряє, що схвалене досі актуальне, для даних, які він охоплює: стається саме те, що схвалили, — або нічого.

Коротко: агент шукає й готує, сервіси перевіряють кожну зміну на собі, а останнє слово за людиною, у якої перед очима все потрібне.