Simas Razinskas

Agentų valdomas užduočių variklis

Saugus bendras užduočių laukas autonominiams DI agentams: paimti darbą vieną kartą, atkurti apleistas nuomas ir perduoti pasikartojančius gedimus žmonėms be foninio darbininko.

Paskelbta 2026-06-30Peržiūrėta 2026-07-02

Trumpai

Problema
DI agentai turėjo dirbti iš tos pačios eilės kaip žmonės, nepaimdami užduočių dukart ir nepaslėpdami gedimų.
Apribojimas
Sistema turėjo likti paprasta: be nuolat veikiančio darbininko, trapios apklausos kilpos ir be prarastos būsenų istorijos.
Sukurta sistema
Pagal kontraktą kuriamas užduočių API su Postgres eilučių užraktais, atnaujinamomis nuomomis, tingiu pasibaigimu, SSE ir įvykių žurnalu.
Rezultatas
Agentai gali saugiai paimti, pratęsti, užbaigti, atmesti ir eskaluoti darbą, o žmonės realiu laiku mato tą pačią lentą.
Kur tai pritaikoma
Tinka komandoms, kurios nori DI agentų gamyboje, bet prieš plečiant autonomiją turi aiškiai apibrėžti atsakomybę, peržiūrą ir gedimus.
Technologijos
Drizzle · Postgres LISTEN/NOTIFY · SSE · contract-first API

Kodėl tai svarbu

Jei jūsų komanda planuoja sistemą su tais pačiais gedimo scenarijais — būtent nuo tokio mąstymo prasideda pirmas pokalbis.

Užduočių paėmimo gyvavimo ciklo schema: agentas paima užduotį naudodamas FOR UPDATE SKIP LOCKED, laiko atnaujinamą nuomą su periodiniais širdies dūžiais, užduotį užbaigia arba atmeta, o po trijų nesėkmių skaitiklis eskaluoja ją žmogui; pasibaigusi, neatnaujinta nuoma tingiai atkuriama kito paėmėjo, o ne foninio valytuvo.

Apžvalga

Pirmiau aprašytas apribojimas — visa užduotis: bendras užduočių laukas, iš kurio darbą gali saugiai imtis tiek žmonės, tiek autonominiai agentai, be nuolat veikiančio darbininko, kurį reikėtų diegti ir prižiūrėti, be apklausos kilpos, mainančios greitą reakciją į apkrovą, ir su pilna kiekvieno paėmimo, pratęsimo, užbaigimo, gedimo ir eskalavimo istorija, kad lentos praeities niekada nereikėtų atkurti iš nuojautos. Du agentai gali stebėti tą pačią lentą tą pačią akimirką, abu nuspręsti paimti tą pačią užduotį ir pateikti paėmimus per kelias mikrosekundes vienas nuo kito — sugedęs agentas gali dingti darbo viduryje ir palikti savo paėmimą kabantį ore, o netinkamai veikiantis agentas gali nesėkmingai bandyti tą pačią užduotį vėl ir vėl, jei niekas neverčia žmogaus į tai pažiūrėti. Viskas toliau sukurta iš primityvų, kuriuos Postgres jau turi — eilučių užraktų, nuomos su TTL ir tik papildomo žurnalo — o ne iš antros sistemos, pritvirtintos kompensuoti tai, ką duomenų bazė jau gali padaryti nativiai.

Paėmimas be konkurencijos

Naivus paėmimas — tai SELECT, randantis laukiančią užduotį, po kurio seka atskiras UPDATE, pažymintis ją paimta, ir būtent tarpas tarp šių dviejų užklausų yra ta vieta, kur du agentai gali abu pasirinkti tą pačią eilutę ir abu manyti, kad ją paėmė. Sprendimas — ne atskiras paskirstytas užraktų servisas šalia Postgres, o sąlyga, kurią Postgres jau turi:

SELECT id FROM tasks
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1;

Paėmimo užklausa — lygiagretūs agentai atsiduria ant skirtingų eilučių

FOR UPDATE užrakina kandidatinę užduoties eilutę paėmimo tranzakcijos viduje; SKIP LOCKED liepia Postgres, kad jei eilutė jau užrakinta kito, dar vykdomo paėmimo, nelaukti jos ir nemesti klaidos, o tiesiog pereiti prie kitos. Du agentai, vykdantys šią užklausą tą pačią akimirką, vienas kito neblokuoja ir nesusiduria — kiekvienas praeina pro tai, ką kitas jau užrakino, ir užrakina kitą laisvą užduotį. Paėmimui nereikia nei pakartojimo ciklo, nei kompensuojančio „atšaukti paėmimą“ žingsnio, nes abipusę išskirtį užtikrina pati duomenų bazės eilučių lygio užraktų sistema, o ne apytiksliai imituoja programos kodas.

Kodėl ne apklausos kilpa ar užraktų servisas

Apklausos kilpa — agentai periodiškai klausia „ar yra kas laukia?“ ir lenktyniauja atnaujindami tai, ką grąžina užklausa — vis tiek turi lygiai tą pačią lenktynių problemą, jei pats atnaujinimas nėra atominis, todėl apklausa pati savaime yra planavimo strategija, o ne konkurencijos valdymas. Paskirstytas užraktų servisas išsprendžia lenktynes, bet prideda antrą sistemą, kurią reikia diegti, stebėti ir suprasti jos gedimo scenarijus — įskaitant, kas nutinka paėmimui, jei pats užraktų servisas tampa nepasiekiamas — dėl garantijos, kurią Postgres jau suteikia toje pačioje lentelėje, kurioje gyvena pati užduotis. SKIP LOCKED laiko konkurencijos valdymą toje pačioje tranzakcijos ribose, kaip ir duomenis, kuriuos jis saugo — tai vienu judančiu elementu mažiau, kurį reikia prižiūrėti.

Atnaujinamos nuomos ir tingus pasibaigimas

Paėmimas — tai nuoma, o ne amžinas perdavimas: paimant užduotį, jai priskiriama leased_until laiko žyma, fiksuotu TTL laiku į priekį, ir tikimasi, kad paėmęs agentas pratęs nuomą iki šio termino, stumdamas leased_until dar toliau. „Paimta“ ir „vis dar dirbama“ — ne tas pats faktas: agentas gali sugesti, prarasti ryšį ar būti nutrauktas darbo viduryje, o užduotis, kurią jis paėmė, neturėtų likti paimta amžinai vien todėl, kad joks aiškus atlaisvinimo žingsnis niekada neįvyko.

UPDATE tasks
SET leased_until = now() + interval '2 minutes'
WHERE id = $1 AND leased_by = $2 AND leased_until > now()
RETURNING id;

Pratęsimas (heartbeat) — atnaujinti nuomą, kurią agentas dar turi

Sąlyga leased_until > now() svarbi lygiai taip pat, kaip ir pats intervalas: jei nuoma jau pasibaigusi, pratęsimas neatitinka jokios eilutės, ir agentas, manęs, kad vis dar valdo užduotį, tai sužino iš karto, užuot tęsęs darbą su užduotimi, kurią jau galėjo pasiimti kažkas kitas.

Kodėl ne foninis valytuvas (reaper)

Akivaizdus nuomos pasibaigimo sprendimas — suplanuotas valytuvas: darbas, kuris kas kelias sekundes nuskaito lentelę ir grąžina eilutes, kurių leased_until jau praėjo, atgal į laukiančių būseną. Tai procesas su sava gyvavimo trukme: jį reikia diegti, reikia stebėti, ar jis vis dar veikia, o jei jis tyliai sustoja, pasibaigusios nuomos tyliai nustoja būti atgaunamos, kol kas nors pastebi susikaupusią užstrigusių užduočių krūvą. Tai lygiai tas pats nuolat veikiantis darbininkas, kurį apribojimas draudžia.

Alternatyva — tingus pasibaigimas: niekas iš anksto neatgauna pasibaigusios nuomos. Vietoj to pati paėmimo užklausa laiko pasibaigusią nuomą tiek pat paimtina, kiek ir niekada nepaimtą užduotį:

SELECT id FROM tasks
WHERE status = 'pending'
   OR (status = 'in_progress' AND leased_until < now())
ORDER BY priority DESC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1;

Paėmimo užklausa, praplėsta atgauti pasibaigusias nuomas tiesiog vietoje

Pasibaigusi nuoma atgaunama tą akimirką, kai kitas agentas pradeda ieškoti darbo, o tai judrioje lentoje įvyksta po kelių sekundžių, o ne pagal fiksuotą tikrinimo intervalą. Jei lenta nutyla, apleista užduotis tiesiog laukia — o tai teisingas elgesys, kai nėra kam ją perduoti bet kuriuo atveju. Šiam mechanizmui palaikyti nereikia jokio atskiro proceso — atgavimo logika visiškai gyvena toje pačioje užklausoje, kuri paima ir naują darbą.

Schema: užduoties paėmimo gyvavimo ciklas — agentas paima užduotį naudodamas FOR UPDATE SKIP LOCKED, laiko atnaujinamą nuomą su periodiniais pratęsimais, užduotį arba užbaigia, arba atmeta, o pasikartojus gedimui trijų bandymų skaitiklis perduoda ją žmogui; pasibaigusi ir nepratęsta nuoma tingiai atgaunama kito pretendento, o ne foninio valytuvo.

Eskalavimas ir audituojama istorija

Ne kiekvienas gedimas yra laikinas. Agentas gali nepavykti dėl blogo užklausimo, iš esmės neišsprendžiamos užduoties ar netvarkingų įvesties duomenų, o kartojant bandymą su tuo pačiu agentu tomis pačiomis sąlygomis dažnai vėl nepavyks, potencialiai amžinai, jei niekas neįsikiša. Kiekviena užduotis turi gedimų skaitiklį, kuris padidėja su kiekvienu fail perėjimu; trečias gedimas eskaluoja užduotį žmogaus eilei, užuot leidęs ketvirtą paėmimą. Ši riba brėžia liniją, nuo kurios priklauso visas sprendimas: vienas nesėkmingas bandymas — triukšmas, trys — modelis, į kurį žmogus turėtų pažvelgti.

Kiekvienas perėjimas — paimta, pratęsta, užbaigta, nepavyko, eskaluota — įrašomas kaip nauja eilutė tik papildomame įvykių žurnale, o ne perrašomas ant vienos kintamos būsenos skilties.

INSERT INTO task_events (task_id, type, actor, payload, created_at)
VALUES ($1, 'failed', $2, $3, now());

Perėjimo įrašymas — įvykių lentelė tik papildoma

Kintama būsenos skiltis gali atsakyti tik į „kokia šios užduoties būsena dabar“; tik papildomas žurnalas gali atsakyti į „kas nutiko šiai užduočiai ir kokia tvarka“. Vien būsenos sekimas pasakytų, kad užduotis nepavyko tris kartus, bet ne kaip atrodė kiekvienas bandymas, kuris agentas jį vykdė, ar kaip pratęsimas, galbūt taip ir nepasiekęs serverio, susijęs su galutiniu gedimu — ta istorija arba gyvena žurnaluose, kurie jau galėjo būti perrašyti, arba jos tiesiog nebėra. Įvykių žurnalas paverčia lentos istoriją užklausiamu faktu, o ne kažkuo, ką reikia atkurti po incidento, o tai svarbu dar labiau, ne mažiau, kai užduotis paimantis ir joje suklystantis klientas yra autonominis ir negali būti vėliau paklaustas, ką jis, jo manymu, tuo metu darė.

Realaus laiko sklaida

Žmonėms ir agentams reikia stebėti tą pačią lentą, kad nė vienas jų nedaužytų jos užklausomis. Apklausa kliento pusėje — kiekvienas atviras skirtukas kas kelias sekundes vykdantis naują užklausą — blogai mastelėja, nes kiekvienas papildomas žiūrovas tiesiogiai daugina apkrovą, ir yra iš prigimties pavėluota, nes vidutinis atsilikimas geriausiu atveju siekia pusę apklausos intervalo. Alternatyva — leisti pačiam Postgres paskelbti pokyčius: kiekvienas būsenos perėjimas siunčia NOTIFY toje pačioje tranzakcijoje, kuri padarė pakeitimą, o vienas ilgai gyvuojantis procesas laiko LISTEN prenumeratą ir paskleidžia kiekvieną pranešimą prisijungusioms naršyklėms kaip serverio siunčiamą įvykį.

NOTIFY task_events, '{"taskId": "...", "type": "claimed"}';

Pokyčio paskelbimas toje pačioje tranzakcijoje, kuri jį padarė

SSE, o ne WebSockets, čia yra tinkamas sudėtingumo lygis: lentai reikia tik vienpusio srauto nuo serverio prie naršyklės, o SSE tai suteikia per paprastą HTTP, o pakartotinį prisijungimą tvarko pati naršyklės EventSource sąsaja, o ne rankomis rašomas kodas. Viena LISTEN jungtis paskleidžia informaciją visiems atviriems skirtukams; duomenų bazės pranešimų sistema yra vienintelis „kažkas pasikeitė“ tiesos šaltinis, o sąsajai niekada nereikia apklausti, kad tai sužinotų.

Kompromisai ir apribojimai

  • Tingus pasibaigimas reiškia, kad užduotis, kurios nuoma pasibaigė, iš tikrųjų neatgaunama, kol kitas pretendentas pradeda ieškoti darbo — tylioje lentoje apleista užduotis gali gulėti neatgauta gerokai ilgiau, nei rodytų jos TTL, nes niekas neverčia to spręsti.
  • FOR UPDATE SKIP LOCKED vis tiek reikalauja stebėti pačią lentelę esant dideliam apyvartumui — didelis atnaujinimų ir trynimų kiekis reikalauja įprasto Postgres autovacuum dėmesio, kitaip užraktų konkurencija ir negyvų eilučių kaupimasis pasireiškia esant apkrovai, nepriklausomai nuo paėmimo šablono.
  • LISTEN/NOTIFY neišgyvena nutrūkusio ryšio ar perkrovimo, o pranešimų dydis ribotas — po atsijungimo iš naujo prisijungiantis sklaidos procesas gali praleisti tai, kas įvyko, kol jis buvo nepasiekiamas, todėl tai geriausių pastangų realaus laiko sluoksnis, o ne patvarus; tiesos šaltinis lieka lentelė ir įvykių žurnalas, o ne pranešimų srautas.
  • Nuomos TTL riboja, kiek ilgai tinkamai besielgiantis agentas gali laikyti užduotį, bet niekas nesutrukdo netinkamai veikiančiam agentui pratęsinėti nuomą pagal grafiką, realiai nieko nenuveikiant — nuoma saugo nuo sugedusio ar atsijungusio agento, o ne nuo gyvo, be prasmės besisukančio savo paties nuomos lange.
  • Kontraktu grįstos API disciplinos laikomasi peržiūros, o ne įrankių priverstinai — niekas techniškai nesutrukdo ateityje pridėti galinio taško, kuris sulaužytų paėmimo, pratęsimo, užbaigimo ar gedimo kontraktą, nuo kurio jau priklauso autonominis klientas.
  • Trijų bandymų riba yra fiksuota, o ne derinama pagal užduoties tipą, todėl užduotis, kuri tiesiog sudėtingesnė nei vidutinė, eskaluojama pagal tą patį grafiką, kaip ir ta, kuri iš tikrųjų sugedusi.

Šaltiniai ir tolesnis skaitymas

Čia naudojami primityvai — gerai dokumentuoti, bendros paskirties statybiniai blokai, o ne kažkas išskirtinai sukurta šiai sistemai:

Susijusios atvejų analizės

Sistemos, kurias su šia sieja bendras apribojimas — pakartojimai, izoliacija, kaina, atkūrimas.

Technologijos: Drizzle · Postgres LISTEN/NOTIFY · SSE · contract-first API← Visos atvejų analizės

Turite sistemą, kuri privalo veikti?

Rezervuokite pokalbį — susidėliosime, ką ji turi daryti, kur ji lūš ir kokią mažiausią versiją verta kurti pirmiausia.