Галоўная / Артыкулы

Агенты, бяспечныя для паўтору: выносім выклікі LLM з працэсу праз Temporal

Чаму агент, які падае пасярэдзіне, не павінен паўторна выклікаць LLM: activities як мяжа недэтэрмінаванасці, механіка паўтору, heartbeats, паўторныя спробы і графы без стану.

Выдавец: 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, бачыў тую ж структуру. Лог перадзапісу — гэта запісь, файлы табелей — гэта матэрыялізаваныя відношэнні, а вярненне да пачатковага стану адтворае гэты лог. У падручніку па методу апошырання з адзінакоў таксама зазначана тая ж законамасць: тыя ж адзінакі, той жа парадак, той жа канечны стан, без гадзіннікаў, адыякратных ID чы запытоў пра час дзёні ў самай обработцы.

Як выглядае наявнасць праваляння

Калі LLM знаходзіцца ў рамках працэсу работы, адразу ж вылучаюцца два прынцыпы.

Сума вырастае удвое. Калі спачатку календар вызова коштаў тры цэнты, то пад час повторнага атрымання яго такса таксама становіцца тры цэнты. Адна команда зафіксавала, што неконтрольаваныя спробы павторэння за трыдцаць секунд для адной розмовы спалювалі блізка да два долараў у вачох токенаў, а функцыя перэрыву ціўкі зменіла гэту суму да цэнты, тое ў 200 разоў менш.

Ход задачы змініцца. Пачатковая рэалізацыя выбрала прадавца B, а павторны запуск — прадавца A. Історыя вжытакоў больш не збігаецца, Temporal выдае паведамленне пра недэтэрмінізм, і працэс работы стае недзейнальным.

Гэта не рэдкія з’явы. Апрыксама 0,8% вызоў да Anthropic і OpenAI не выкананыя за перыяд у трыдцяць дзён, таму агент, які выкананы 12 вызоў модэлю на адну задачу, сталкніваецца захворованасцю на адну задачу прыблізна 9,2%. Калі LLM знаходзіцца ў рабочым прайсепту, кожны пракат выкананы занова. Калі ён знаходзіцца ў актывацыі, пракат отрымае зафіксаваны рэзультат. Самыя пісанні Temporal чыста паказваюць граніцу: рабочы прайсепт — гэта слой оркестрацыі і павінен быць дэтэрміністычным, каб агенты выжылі пасля збоев процэса, тады як актывацыі — гэта там, дзе выканана рэальная работа, укладаючы ў сябе вызы модэляў, запуск інструментаў і запыты API, і яны можу быць настолькі неперакладнымі, насколькі таго трэба.

Чаму тэмпература нуль не ўтрамбоўка

Заўжды ёсць тыя, хто пратымуе, што тэмпература была задана як 0. Гэта не дапамагае з кальколькі прычын:

  • Тэплература нуля — гэта жадная дэкодавання, а не дэтэрмінізм. У аднай з аналізаў тысяча ідэнтычных запыткаў даўала 80 разных рэзультатаў, чыя прычына — разлікі у плаваючых канстантах, залежныя ад групавага апрацоўкі на GPU.
  • Той самы назва модэлю не значыць той самы модэль. Прадаўцы тыхо апдэйтуюць модэлі, таму модэль, якая была выкарыстоўвана у втокар, можа не падходзіць да модэлю з сэргудня.
  • Прадаўца можа не працаваць пад час перагляду. У такім случае рабочы працэс застаецца у стане чакання.
  • Нават адпаведны па байтах адказ купуецца за грошы. За другую апрацоўку даных таксаванне ўсё равно было заплацена.

Распрацоўшчык, які піша пад іменам Tisha, добра апісаў гэтую проблему: каманды тыяты дніў намагаюцца прымусіць модэль да бітовага дэтэрмінізму, трапляюць на невыкарыстанне і выводзяць, што сістэму немагчыма зрозумець, хоча справжні дэтэрмінізм знаходзіцца на вышэйшым роўні — у оркестрацыі. Яны націліліся на неправы роўнь.

Актывацыя як мембрана

Змена ў такім разе ў структурным аспекце. Унутрэй актывацыі дазволеныя ўсе можлівасці. За ўзнешняй межы ўсё павінна быць дэтэрміністычным.

Рабочы план можа вызываць актывацыі, чакаць сігналы, настаўляць таймеры і робіць розгалужэння, за ўмовы, што умовы розгалужэння залежаць толькі ад рэзультатаў актывацый і вхідных дадзеных рабочага плану. Забраняюцца вызывы модэляў, 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)

Гэта трыя слоі ніжэй за рабочым практыкам: актывнасць, граф, вузел, а потым LLM. Пад пераглядам ніхто з іх не выкананы, таму што рэзультат актывнасці ўводзіцца як даны. Рабочы практык ёсць дэтэрміністычны. Актывнасць, граф і мадэль не ўсё такія, але недэтэрмінізм захаваны на межы актывнасці. Рабочы практык бачыць толькі запісаныя словнікі, тыя ж самыя пад кожным пераглядам.

Дзе межа становіцца нечыстай

У тэорыяй мадэль выглядае чыста, але на практыцы ёсць некалькі момантов, дзе яна становіцца нечыстай. Случак, які трывае калькі дзён, заслуговае на увагу, таму што яго абяканне ёсць тонкім, і два слоі перазберагаючага можу адстаць адзін ад другога без паведамлення.

Калі граф зупініваецца на вузле затверджэння за дапамою 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 павінен быць workflow_id Temporal, які застаецца стабільным праз паўтарныя запускі, на вядзіму ад run_id; табела чэкпоінта павінна зберагаць даныя столькі часу, сколькі і історыя Temporal; а таксама функцыя адновлення павінна выклікваць graph.ainvoke(None, config), каліж таму, што параметр None паведамляе LangGraph аб неабяжнасці завантажыць даныя з чэкпоінта, а не запускваць працэс значна. Якшчо ў чым-небудзь памыліцца, шары сістэмы пачнуць сабе падкідваць. Сам працэс не зламаецца, але будзе адновляцца з неправільнага стану.

Лепшая адпаведзь: зробіць граф безстановым

Інтуітывны дызайн дазваляе чэкпойнтэру LangGraph для Postgres сочыць за станам на вузлах агентаў разам з історыяй Temporal. Аналіз спосабаў абвалення пад час кількадзённых паўз прыводзіць да адкрыцця скрытай прыемкі — класычнай проблемы двойнага запісу между двума базамі дадзеных. Калі працоўнік зупініцца сяродзін як толькі LangGraph запішае свой чэкпойнт, але яшчэ не паўтарыць Temporal завершэнне, два механізма стану не пагадваюцца, і рабочы працэс тыхо продовжыцца з несувязаным станам.

У зменшэнні колькасці правіл сінхронізацыі, якія стосуюцца картавання нітак, зберагчэння дадзейнаў і ўтварэння новых версій, лепшым рашэннем ёсць адзёрванне другога двыжка станоў. Без яго контрольнага пункта LangGraph стае елементам, які працуе толькі ў памяці і не мае станоў, і які функцыонуе ў межах актывацыі. Temporal контролюе ўсе давялікі станы, історыю выканання і паўзы, калі людзі втручаюцца, за дапамогою workflow.wait_condition(). LangGraph проста працуе за своім цыклам разумовых вырахункаў, перадае словнік рэзультатаў назад актывацыі і завершае працу. Рэзультат: жадных двойных запісаў, жадной небяспекі несінхронізацыі і простэйшая структура керування.

Ніякі з інструментаў не стаюць зайвымі. LangGraph застаецца эфектывным у шырокамасштабнай рутаванні графаў у памяці, вызыванні інструментаў і віконанні логічных цыклаў на рэвэлах, тады як Temporal застаецца сільным у стабільнасці працы праўільна дзеяў працы ў тэчэнні дзён, вяснаванні пасля збою і распадзеленай оркестрацыі. LangGraph без стану, які працуе ў межах стабільнай актывацыі Temporal, запобегае ўзаімнай конкурэнцыі ў паводзе пастаноўкі дадзеных і пашантуюе сілы обох інструментаў.

Адна абмежэнне ў багатых сістэмах

Фрэймворкі оркестрацыі — тое ўсё, дзе прымае форму контракт на перэгляд дадзеных. Кожная сістэма, якая вяснаваецца праз перэгляд дадзеных, складаецца з трох частак: запісу падзей, чыстай функцыі, якая ператварае іх у стан, і всіх неперакладзімых элементаў, якія зберагаюцца на іншай стороне запісанага рэвэла.

Агенты робяць гэта яшчэ складней. У класычным рэўіту, базаваным на запісах падзей, функцыя apply застаецца чыстай, адколі дадзенне дзесяці долераў на рахунак завжды дае аднаковы рэзультат. Бізнес-логіка агента — запытаць LLM, што робіць, і адпаведзь кожы раз разлічная. Калі «мозг» знаходзіцца на шляху перэгляду, перэгляд стае немагчымым, таму «мозг» павінен знаходзіцца за межамі гэтага шляху.

Temporal быў выбраны як прыкладная платформа, таму што ён прыменяе правіла контракту: яго SDK выклікае памылку, якая свідчыць пра недэтэрмінізм, у разе ўзламу, замест таго, каб тыха працаваць некорэктна. Step Functions прыменяе гэтыя правіла безкоштовна, адколі яго дэкларатыўны язык станоў не дазволяе віпадкова включыць вызов LLM у машыну станоў. За дапамогою функціі outbox або спецыяльнага поллінгу прымененне правіл адбываецца ручна. Нічога не заважае вызову LLM у цыкле поллінгу, і нічога не павядомляе пра гэта. Усія правіла ў всіх платформах ідэнтычныя. Едыны вопыт — хто зафіксуе узлам: фрэймворк чы той, хто працуе у 3 гадзіны ночы.

Такая ж самая структура выкладкі прыменяецца і да іншых стакаў агентаў. Дзеяныя з іх яе прыменяюць, а самэй Temporal і Microsoft Agent Framework. Дзеяныя з іх задаюць прымітывы і заставляюць таго, хто стварае, самога яе прыменяць, як гэта робяць LangGraph і AutoGen. Дзеяныя з іх ўзагалі ігнаруюць гэты момент, як Celery і CrewAI. Практычны вопыт заключаецца у тым, якую частку кантракта replay трэба ствараць вручную.

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

Какія збыткі носіць дэтэрмінізм

Межа дзеяння не ўзлегка візначаецца. Простата форма напісу знікае, адколі неправільная версія мае менш ліній і легча да чытання. Усунуўся таксама прамы доступ да стану, таму ў дзеянні бачыцца толькі тое, што яму перадаецца, а не внутрашня структура працэсу. А таксама з’являюцца тры крайнія варыянты, якіх няма ў прамым LLM. Гэта рэальныя інжынерныя рашэнні, а не філасофскія компромісы.

Першы крайні варыянт: дзейнія дужыя і частыя перапыткі

Выклік LLM можа зайняць 30 секунд. Граф агента з калькамі з інструментамі можа зайняць пяць хвілін. Стандартны час start_to_close_timeout у Temporal становіць 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

Ёсць адзін недагодзін. Стан серцебітання не захоўвае жадных внутрашніх працэсаў актывацыі; ён толькі запячэюе настанню таймауту. Калі працоўнік зупініцца на паўпаце графа, перапрытак пачынае актывацыю зноў з самага пачатку, і LLM запрацоўвае ўтрохты, таму што рэзультат фіксуецца толькі тады, калі актывацыя завершыцца. У разе графа трываласцю пяць хвілін, працоўнік, які зупініцца на чэтвёртай хвіліне, забірае з сабой чатыры хвіліны незахаванай працы.

Самэ гэтаму прыклад раздзеляе граф на две актывацыі: адну, якая працуе да моменту перарыву, і другую, якая продовжывае працу пасля яго. Кожная з іх дастатнька короткая, каб завершыцца за час таймауту, і кожны рэзультат фіксуецца незалежна. Такое раздзеленне існуе для крашчай стабільнасі, а не для організаціі коду.

Другі крайні случай: стрымінг токенав

Калі LLM знаходзіцца ў працэсе выканання безпосерэдньа, токены можаць прайсці адразу ў HTTP-адказку корыстніка. Калі ж ён знаходзіцца ў адзінай з дзеянняў, токены прайходзяць да выходных даных гэтага дзеяння, тое ўсьмо ў вачынку, які фіксуецца ў історыі. Нічога не доходзіць да корыстніка, пакуль дзеяння не будзе завершана.

Стрымінг ўтварае найчастэйшую працоўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную працэўную пр

Актывацыі, якія выконваюцца па часовым прыемліванні, автаматычна збываюцца без успеху, але такі неудачы маюць разны значэнні для разных актывацыяў. Апыты пошуку толькі для чытання і парагонныя эфекты не павінны перайсці па аднаковым чынніку. Для парагонных эфектаў, такіх як стварэнне замовлення на пакупку, правіла ёсць строгімі — такіе актывацыі не можна перайсці, пры чым ключ ідэмпотэнцыі забезпечвае безпеку:

# 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 гадзіны ночы, калі выкананне заданняяў занова дае іншы рэзультат, і хтось, хто працуе, павінен распрацаваць сітуацыю, калі агент застаецца ў стане аббіекцыі працы пра ноч.

Без контракта на павернуцье працы тры слоі — харнес, луп і граф — маюць толькі арганізацыйны характар. Яны дапамагаюць у розумеенні сістэмы, але не спрыяюць яе выжыццю. З наявнасцю контракта яны стаюць структурнымі: луп павернуецца да пасляпэўнага завершанага крока, пры чым дзеянні ін’ўектуюцца з історыі; стан графа зберагаецца ў аднам месцы і завантажваецца праз дзеянню павернуцья; інструменты не запускаюцца занова, адтолькі ўсі рэзультаты фіксуюцца, а ключ ідэмпотэнцыі служыць другой лініёй захавання. У разе абякання слоі застаюцца аддзельнымі, і гэта не толькі па прынцыпу.

Застаецца ўсё ж адна мерыя. Гэта часовыя гарантыі таго, што рабочы пракцэс будзе павернуцься да правільнага выканання пасля збою. Гэта не гарантуе, што бізнес-логіка будзе безпечная для павернуцья, і гэта пытанне застаецца на адпаведальнасці разрабоў. Ідэмпотэнцыя — гэта наступная проблема, якую трэба рашыць.