Головна / Статті

Агенти, стійкі до повторного відтворення: уникнення викликів LLM у робочому процесі за допомогою темпоральних механізмів.

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

Видавець: REACTAPP.TOP16 хв читання3685 слів

Джерела: Fixing Non-Deterministic Replay Bugs in AI Agent Loops

Уявіть агента на 7-му з 10 кроків, коли його працівник гине: це може бути вбивство через нестачу пам’яті, подія технічного обслуговування чи крах ядра. Якщо виклик LLM знаходиться всередині робочого процесу, відновлення стає мовчазним переписуванням історії. Робочий процес знову запускає модель, отримує іншу відповідь, і агент опиняється не там, де загинув. Одні й ті самі токени оплачуються двічі, а відновлений запуск відрізняється від початкового.

Це не думковий експеримент. Однієї ночі о 02:14 агент підтримки закрив клієнтський рахунок, який мав отримати 30-денний період відстрочки. Виклик інструменту вдалося. Коли інженер переграв те саме повідомлення клієнта, агент повівся правильно. У аналізі причин виникла прогалина там, де мала б бути основна причина, яку заповнили словами „неможливо відтворити“. Невідтворювана несправність не зникла — це несправність, яку більше неможливо дослідити.

Правило, яке це запобігає, є простим: код, який керує агентом, має бути детермінованим, тоді як LLM не є таким, тому LLM має знаходитися за межею, яка насправді не є обов’язковою. Temporal — це двигун, який використовується у прикладах, але ця схема є незалежною від конкретної платформи. Той самий принцип застосовується до AWS Step Functions, Prefect та Dagster, до засобів для збору даних з бази даних, а також до саморобних таблиць стану, причому у наступних розділах ця ідея детально розглядається на прикладі цих інструментів.

Баг перед словниковим запасом

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

# ❌ What I almost wrote — LLM call INSIDE the workflow.
@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call — right here, in the workflow. THIS IS THE BUG.
        # If worker dies here, replay will re-call the LLM and mutate state/history.
        response = await llm_client.complete(
            messages=build_decide_prompt(scored),
            temperature=0.0,
        )
        selected = parse_llm_decision(response.content, scored)

        approval = await workflow.wait_condition(...)
        # ...create PO, initiate payment

Все працює належним чином. Старший інженер, який ніколи не бачив Temporal, назвав би це хорошим. Проблеми виникають, якщо працівник зазнає збою відразу після повернення результату виклику LLM, коли схвалення людини ще не отримане.

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

# ✅ The fix — LLM call moved into an activity.
@activity.defn
async def decide_activity(scored: list[dict], brief: SourcingBriefInput) -> dict:
    """The LLM call lives here — in the activity, not the workflow."""
    from app.agentmesh.llm import get_llm_client

    client = get_llm_client()
    response = await client.complete(
        messages=build_decide_prompt(scored),
        temperature=0.0,
    )
    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale}


@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call is NOWHERE in the workflow.
        # The activity result is recorded. On replay, it's injected.
        decision = await workflow.execute_activity(
            decide_activity,
            args=(scored, brief),
        )

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

Правило: повторне виконання має відтворювати історію

Під час виконання робочого процесу Temporal записує кожен крок у історію подій — журнал, який постійно зростає, з такими записами, як «робочий процес розпочато», «завдання заплановано», «завдання виконано», «сигнал отримано» та «таймер спрацював». Події є незмінними, а історія є джерелом істини.

Після збою Temporal повторює виконання робочого процесу. Новий працівник запускає функцію робочого процесу з її першого рядка. При кожному виклику, такому як workflow.execute_activity(...) або workflow.wait_condition(...), Temporal звертається до історії. Якщо існує відповідна подія, він вставляє зазначений результат замість виконання будь-яких дій. Якщо функція здійснює виклик, який відрізняється від того, що записано в історії, Temporal створює помилку недетермінізму.

Це і є підхід на основі джерела подій. Історія — це журнал записів, функція робочого процесу — це обробка цього журналу, і однакові події в тому самому порядку через ту саму функцію завжди дають однаковий стан. У документації Temporal чітко сказано: код робочого процесу має бути детермінованим для підтримки повторної обробки, а недетерміновані операції, такі як виклики API, використання LLM та запити до бази даних, мають виконуватися в межах окремих завдань. Кожне завдання виконується один раз, його результат потрапляє до історії записів, а пізніше для повторної обробки дані читаються саме звідти.

Кожен, хто працював з Postgres, бачив подібну структуру. Журнал записів типу write-ahead log є записом даних, файли таблиць — це матеріалізовані перегляди, а процес відновлення — це повторна обробка цього журналу. У посібнику з підходу на основі джерела подій сформульовано абсолютно такий самий принцип: однакові події, однаковий порядок, однаковий кінцевий стан, без використання годинників, випадкових ідентифікаторів чи даних, отриманих у певний час доби під час обробки.

Як виглядає порушення цього принципу

Коли ШІ-модель використовується в рамках робочого процесу, одночасно виникають дві проблеми.

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

Хід виконання відрізняється. Спочатку було обрано постачальника B, а під час повторної обробки — постачальника A. Історія виконання більше не збігається, система Temporal видає помилку про недетермінізм, і робочий процес припиняється.

Це не рідкісні явища. Приблизно 0,8% дзвінків до Anthropic та OpenAI зазнають невдачі протягом тридцяти днів, тож агент, який виконує 12 викликів моделі на завдання, стикається з рівнем невдач приблизно 9,2% на завдання. Якщо ШІ знаходиться у робочому процесі, кожна спроба повторного виконання знову запускає його. Якщо ж воно знаходиться у завданні, спроба повторного виконання отримує вже зареєстрований результат. Самі автори Temporal чітко розмежовують це: робочий процес є шаром оркестрації та має бути детермінованим, щоб агенти могли вижити після збоїв процесу, тоді як завдання — це місце, де відбувається справжня робота, включаючи виклики ШІ, використання інструментів та запити до API, і вони можуть бути настільки непередбачуваними, наскільки це необхідно.

Чому температура нуль — це не захист

Хтось завжди заперечує, стверджуючи, що температура встановлена на 0. Це не допомагає з кількох причин:

  • Нульова температура означає жадібне декодування, а не детермінізм. Згідно з одним аналізом, тисяча ідентичних запитів дали 80 різних результатів через відмінності у числах з плаваючою комою на GPU, залежні від групової обробки.
  • Однакова назва моделі не означає однакову модель. Постачальники тихо оновлюють моделі, тож модель, яка працювала у вівторок, може не збігатися з моделлю у середу.
  • Постачальник може бути недоступний під час повторної обробки. У такому разі робочий процес застрягає у очікуванні.
  • Навіть ідентична за розміром відповідь коштує грошей. За другу обробку все одно стягується плата.

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

Активність як мембрана

Зміна є структурною. Усередині активності дозволено все. Ззовні все має бути детермінованим.

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

Уявіть це як анатомію. Те, що виконує робочий процес, — це не сам агент, а координація дій навколо нього, тоді як модель є лише сервісом, до якого звертається агент. Мозок є непередбачуваним, хребет має бути стабільним, і мозок не потрапляє всередину хребта.

Як це виглядає у реальному кодбазі

У спрощених фрагментах вище прихований певний деталь. У справжній базі коду виклик LLM знаходиться всередині вузлів графа, а граф виконується всередині завдання, тож існує два межі, а не один:

Temporal Workflow (deterministic — replayed)
    └── Activity: run_graph_until_interrupt (non-deterministic — recorded once)
            └── LangGraph StateGraph
                    └── decide_node (async function)
                            └── get_llm_client().complete()  ← the LLM call

Сам процес роботи є суто оркестрацією — послідовністю викликів execute_activity та wait_condition, і нічим більше. Рядок execute_activity є межею. Те, що відбувається всередині діяльності, записується, а те, що відбувається ззовні, відтворюється знову:

@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        # ↓ This is the boundary. The activity runs once. Result is recorded.
        graph_result = await workflow.execute_activity(run_graph_until_interrupt, brief, ...)

        # Wait for human signal — a Temporal primitive, not an LLM call.
        # On replay, the signal is injected from history.
        await workflow.wait_condition(lambda: self._approval_received, timeout=timedelta(hours=24))

        # ↓ Another boundary. Resume activity runs once. Result is recorded.
        resume_result = await workflow.execute_activity(resume_graph, self._approval_data, ...)

        # ↓ Side effects — each is its own activity with its own retry policy.
        po_result = await workflow.execute_activity(create_po_activity, args=(...), ...)

У ньому немає нічого, що б викликало клієнт LLM, надсилало HTTP-запит, читало поточний час чи генерувало випадкове число. Ті самі вхідні дані, та сама історія, той самий шлях виконання — щоразу.

Ця діяльність створює граф та ініціює його виконання, і саме у цій одній рядку graph.ainvoke() полягає недетермінізм: виклики LLM, виклики інструментів та запити до пам’яті. Діяльність повертає словник, який потім фіксується, а повторне виконання пропускає весь цей процес:

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    graph = build_sourcing_graph(checkpointer=await get_checkpointer())
    config = {"configurable": {"thread_id": activity.info().workflow_id}}

    # ↓ Everything inside this call is non-deterministic. It runs ONCE.
    #   The result dict is recorded in the event history. On replay, it's injected.
    final_state = await graph.ainvoke({"brief": brief, "suppliers": [], "attempts": 0, ...}, config)

    return {"paused": True, "selected_supplier": selected, "suppliers": final_state.get("suppliers", [])}

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

async def decide_node(state: AgentState) -> dict:
    scored = state.get("scored_suppliers", [])
    messages = build_decide_prompt(brief.item, brief.quantity, brief.budget, scored, past_decisions)

    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale, "cost_incurred": response.cost_usd}    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

Це три рівні нижче за потік роботи: діяльність, граф, вузол, а потім ШІ. Під час повторного виконання жоден з них не запускається, оскільки використовується результат діяльності. Потік роботи є детермінованим. Діяльність, граф та модель — ні, проте недетермінізм обмежується межами діяльності. Потік роботи бачить лише зафіксований словник, той самий під час кожного повторного виконання.

Де межа стає нечіткою

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

Коли граф зупиняється на вузлі схвалення за допомогою interrupt() від LangGraph, операція завершується та повертає словник назад у робочий процес. Потім робочий процес викликає workflow.wait_condition(), щоб чекати сигналу від людини. У цей момент у двох системах існують дві копії стану:

Temporal event history             LangGraph checkpoint (Postgres)
  └── ActivityCompleted(result)      └── graph state at interrupt()
        paused: True                       node: "approve"
        selected: SupplierB                 selected: SupplierB
        suppliers: [A, B, C]                suppliers: [A, B, C]

Обидва знаходяться в Postgres, проте це окремі таблиці, які заповнюються за допомогою окремих шляхів коду у різний час. Чекпоїнт LangGraph створюється під час виконання операції при виникненні переривання; коли операція завершується, Temporal додає подію про її завершення. Це означає два записи, дві системи та один логічний стан.

Проблеми виникають, коли один запис зберігається, а інший — ні. Одним із способів їх усунення є оперативні заходи: thread_id має бути Temporal workflow_id, який залишається стабільним під час повторних виконань на відміну від run_id; таблиця контрольних точок має зберігати дані протягом усього часу існування історії Temporal; крім того, функція відновлення діяльності має викликати graph.ainvoke(None, config), оскільки передача значення None повідомляє LangGraph завантажити дані з контрольної точки, а не запускати все з нуля. Якщо щось з цього зробити неправильно, шари системи починають відхилятися. Робочий процес не завершується, але продовжує роботу з неправильного стану.

Краща відповідь: зробити граф безстановим

Інтуїтивний дизайн дозволяє механізму перевірки Postgres у LangGraph відстежувати стан на всіх вузлах агентів разом із історією Temporal. Відстеження модів збоїв під час багатоденних перерв розкриває приховану проблему — класичну проблему подвійного запису між двома базами даних. Якщо працівник зупиняється відразу після того, як LangGraph записує свій контрольний пункт, але до того, як Temporal зафіксує завершення, двигуни стану не погоджуються, і робочий процес мовчки продовжується з неузгодженим станом.

Замість того, щоб постійно додавати правила синхронізації щодо відповідності потоків, зберігання даних та їх перезавантаження, кращим рішенням є видалення другого двигуна станів. Без його механізму контролю LangGraph стає безстановою одиницею міркувань у пам’яті всередині активності. Temporal керує всіма довгостроковими станами, історією виконання та паузами, спричиненими участю людини, за допомогою workflow.wait_condition(). LangGraph просто виконує свій цикл міркувань, повертає словник з результатами назад до активності та завершує роботу. Результат: відсутність подвійних записів, жодних ризиків десинхронізації та простіша структура керування.

Жоден з інструментів не стає зайвим. LangGraph залишається ефективним у швидкому маршрутизуванні графів у оперативній пам’яті, виклику інструментів та виконанні логічних операцій на рівні вузлів, тоді як Temporal перевершує у стійкості протягом багатьох днів, відновленні після збоїв та дистрибуйованій оркестрації. Безстановий LangGraph усередині стійкої діяльності Temporal уникає конкуренції між ними за зберігання даних та зберігає сильні сторони обох.

Одне обмеження в багатьох системах

Фреймворки оркестрації — це лише одне з місць, де проявляється концепція контракту відтворення. Будь-яка система, яка відновлюється шляхом відтворення даних, має три основні компоненти: запис подій, чисту функцію, яка об’єднує їх у стан, та все непередбачуване, що зберігається по інший бік записаної кромки.

Агенти роблять ситуацію ще складнішою. У класичному реєстрі, заснованому на подіях, функція apply залишається чистою, оскільки додавання десяти доларів на рахунок завжди призводить до однакового результату. Бізнес-логіка агента полягає у тому, щоб запитати ШІ, що робити, а відповідь щоразу відрізняється. Якщо „мозок“ знаходиться у шляху відтворення, відтворення стає неможливим, тому „мозок“ мусить знаходитися поза ним.

Temporal був обраний для прикладної платформи тому, що він забезпечує дотримання умов контракту: його SDK генерує помилку недетермінізму у разі порушення замість того, щоб тихо призвести до різниці у результаті виконання. Step Functions забезпечує це безкоштовно, оскільки його декларативна мова станів унеможливлює випадкове включення виклику LLM у машину станів. За допомогою функції outbox або користувацького опитування контроль здійснюється вручну. Ніщо не заважає виклику LLL у циклі опитування, і ніщо про це не попереджає. Умова є ідентичною у всіх цих системах. Єдине питання — хто помітить порушення: фреймворк чи той, хто працює у ніч на 3 годину ранку.

Такий самий підхід характерний і для інших стеків агентів. Деякі з них його применюють, а саме Temporal та Microsoft Agent Framework. Інші надають примітиви та залишають завдання з їх реалізації розробнику, як це роблять LangGraph та AutoGen. Ще інші повністю ігнорують це питання, як-от Celery та CrewAI. Практичне питання полягає у тому, яку частину контракту повторної реалізації потрібно створювати вручну.

Дані з зовнішніх порівнянь вказують на те саме. У звіті про міграцію з Celery до Temporal було вказано високий ризик подвійної обробки завдань під час повторних спроб, особливо для завдань, які потребують значної кількості часу GPU. Проєкт Temporal з CrewAI досяг описаної вище архітектури, використовуючи двигун оркестрації як джерело істини для стану та фреймворк агентів як безстанову одиницю міркувань, яка запускається всередині завдання. У одному з тестів AutoGen проти CrewAI AutoGen виконував завдання з показником затримки p95 у 2,1 секунди проти 4,7 секунд у CrewAI, а успішність склала 94% проти 83%, що нагадує про те, що спосіб, яким фреймворк поводиться зі збоями, відображається у цифрах. Microsoft Agent Framework постачає зберігання контрольних точок на основі файлів та стійкі до збоїв суперкроки, що є його способом забезпечення такої ж гарантії.

Яку ціну платить детермінізм

Межа діяльності не є вільною. Простота вбудованого формату зникає, оскільки неправильна версія має менше рядків та є легшою для читання. Вбудований доступ до стану також зникає, адже діяльність бачить лише те, що їй передано, а не внутрішню структуру робочого процесу. Крім того, з’являються три крайні випадки, яких немає у вбудованих LLM. Це справжні інженерні рішення, а не філософські компроміси.

Перший крайній випадок: довгі діяльності та інтервали оновлень

Виклик LLM може зайняти 30 секунд. Граф агента з кількома етапами та викликами інструментів може зайняти п’ять хвилин. За замовчуванням у Temporal значення start_to_close_timeout становить 10 секунд. Якщо діяльність перевищує цей термін, Temporal позначає її як невдалий виконання та повторює спробу, що призводить до повторного виклику моделі, витрати коштів на токени та повторної виконання недетермінованої роботи, яку ретельно ізолювали раніше.

Рішенням є таймаут серцебиття. Будь-яка діяльність, яка триває довше кількох секунд, повинна періодично викликати activity.heartbeat(), що сигналізує Temporal про те, що вона все ще активна. Якщо процес зупиняється, Temporal використовує останній сигнал серцебиття, щоб визначити, на якому етапі знаходилась діяльність; таким чином діяльність, яку можна зберегти у стані перевірки, може продовжитися, а інші — початися з нуля:

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    # Long-running: graph may run 5+ minutes with multiple LLM calls
    for node in graph.stream(initial_state, config):
        activity.heartbeat()  # ← "I'm alive, don't timeout me"
        # ... process node output

Є один недолік. Сигнал серцебиття не зберігає жодного внутрішнього прогресу завдання; він лише запобігає перевищенню тайм-ауту. Якщо працівник зупиниться на півдорозі виконання завдання, повторна спроба почне його знову з початку, і ШІ знову виконає обчислення, оскільки результат записується лише тоді, коли завдання завершується. У випадку завдання тривалістю п’ять хвилин працівник, який зупинився на четвертій хвилині, бере з собою чотири хвилини незахищеної роботи.

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

Другий крайній випадок: стрімінг токенів

Коли ШІ знаходиться безпосередньо в робочому процесі, токени можуть надсилатися безпосередньо у HTTP-відповідь користувача. Коли він розташований у певній дії, токени потрапляють у результат цієї дії — єдине значення, яке записується в історії. Ніщо не потрапляє до користувача, поки дія не завершиться.

Стрімінг є найпоширенішим запереченням проти цього підходу, і воно має під собою підстави. Існує три підходи, які зростають за складністю. Перший передбачає, що результат — це рішення, а не потік чату, що підходить для агента зі збору даних. Другий додає додатковий канал для багатокрокових взаємодій, таких як інтерв’ю, де користувач має бачити кожну відповідь. Цей додатковий канал може бути кінцевою точкою подій, надсилаємих сервером, підтримуваною Redis: система публікує токени в Redis, кінцева точка надсилає їх користувачеві, тоді як Temporal зберігає лише кінцевий результат. Користувач, який перервав процес, може повернутися пізніше та запитати про результат роботи потоку. Токени в потоці є одноразовими; лише збережений результат залишається назавжди.

Третій крайній випадок: політики повторних спроб відрізняються

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

# Side effect — strict, non-retryable. Idempotency key handles safety.
po_result = await workflow.execute_activity(
    create_po_activity,
    args=(supplier, item, quantity, price),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=1),
        maximum_attempts=1,  # ← don't retry. Idempotency key prevents duplicates.
        non_retryable_error_types=["DuplicatePOError"],
    ),
)

# Verification — aggressive retry, but with backoff for eventual consistency
po_verification = await workflow.execute_activity(
    verify_po_exists,
    args=(po_result["po_id"],),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=2),  # ← give the DB time to sync
        maximum_attempts=5,
    ),
)

Політика повторних спроб є частиною контракту повторної реалізації. Коли якась дія зазнає невдачі та відбувається повторна спроба, ці спроби фіксуються в історії, а під час повторної реалізації Temporal вставляє остаточний результат, не перезапускаючи невдалі спроби. Однак дія, яка створює замовлення з параметром maximum_attempts=3, має три спроби його створення, і без ключа ідемпотентності це означає створення трьох замовлень. Ці два механізми працюють разом: політика визначає, коли проводити повторну спробу, а ключ визначає, що відбувається під час неї.

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

Єдине правило, яке потрібно дотримуватися

Серед усього в системі лише LLM опирається повторній обробці, і саме тому він має знаходитися там, де повторна обробка його ніколи не торкнеться. FAQ Temporal ще більше узагальнює це: завжди обгортайте виклики LLM у окрему діяльність. Завжди, а не зазвичай чи тоді, коли є час, адже єдиний раз, коли це можна пропустити, — це падіння о 3 години ранку, коли повторна обробка дає інший результат, і хтось, хто чергує, мусить розібратися з агентом, який залишився у стані збою протягом ночі.

Без контракту на повторну роботу три шари — харнес, луп та граф — є лише організаційними елементами. Вони допомагають у мисленні про систему, але не сприяють її функціонуванню. З наявністю контракту вони стають структурними: луп продовжує роботу з останнього завершеного кроку, а дії імпортуються з історії; стан графа зберігається в одному місці та завантажується дією відновлення; інструменти не виконуються знову, оскільки результати записуються, а ключ ідемпотентності є другою лінією захисту. Шари залишаються окремими у разі збою, і це не лише питання принципу.

Залишається одне обмеження. Існують часові гарантії того, що робочий процес буде правильно відтворений після збою. Однак це не гарантує, що бізнес-логіка безпечна для повторного виконання, і це питання залишається на розсуд розробника. Ідемпотентність — це наступна проблема, яку потрібно вирішити.