Strona główna / Artykuły

Agenci bezpieczne pod kątem odtwarzania: unikanie wywołań LLM w procesie pracy za pomocą elementów czasowych.

Dlaczego agent, który zawiesza się w trakcie działania, nie może ponownie uruchomić swojego LLM: działania jako granica nieokreśloności, mechanizmy odtwarzania, sygnały aktywności, próby ponowne oraz grafy bez stanu.

Wydawca: REACTAPP.TOP16 min czytania3685 słów

Źródła: Fixing Non-Deterministic Replay Bugs in AI Agent Loops

Załóżmy, że agent znajduje się na kroku 7 z 10, gdy jego proces umiera: z powodu braku pamięci, zdarzenia konserwacyjnego lub paniki jądra. Jeśli wywołanie LLM odbywa się w ramach procesu pracy, przywrócenie stanu staje się cichym przepisaniem historii. Proces pracy uruchamia model ponownie, otrzymuje inne odpowiedzi, a agent trafia w inne miejsce niż to, gdzie umarł. Te same tokeny są wypłacane dwa razy, a przywrócony proces różni się od oryginalnego.

To nie jest eksperyment myślowy. Pewnej nocy o godzinie 02:14 agent obsługi zamknął konto klienta, który powinien mieć 30-dniowy okres wstrzymania. Wywołanie narzędzia zakończyło się sukcesem. Gdy inżynier odtworzył tę samą wiadomość klienta, agent zachował się prawidłowo. W analizie postmortem brakowało informacji o prawdziwej przyczynie problemu, którą wypełniono słowami „nie dało się to odtworzyć”. Awaria, której nie da się odtworzyć, nie zniknęła – to awaria, której nie można już badać.

Zasada, która temu przeszkadza, jest prosta: kod, który kieruje działaniem agenta, musi być deterministyczny, a LLM nie jest nim, dlatego LLM musi znajdować się za granicą, która nie musi istnieć. Temporal to silnik używany w przykładach, ale ten wzorzec jest niezależny od konkretnego frameworku. Ten sam model odnosi się do AWS Step Functions, Prefect i Dagster, a także do narzędzia do pobierania danych z bazy danych oraz do domowej tabeli stanu, a późniejsze sekcje pokazują, jak tę koncepcję zastosować w tych rozwiązaniach.

Błąd przed słownictwem

Weźmy agenta do pozyskiwania danych składającego się z pięciu kroków: badanie, ocena, podjęcie decyzji, zatwierdzenie, potwierdzenie. Krok podjęcia decyzji wywołuje LLM, a naturalnym, kuszącym miejscem na to wywołanie jest bezpośrednio w klasie przepływu pracy:

# ❌ 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

Wszystko działa poprawnie. Doświadczony inżynier, który nigdy nie widział Temporal, uznałby to za dobre rozwiązanie. Problemy pojawiają się, gdy pracownik doświadcza awarii po zakończeniu wywołania LLM, a decyzja człowieka wciąż jest w oczekiwaniu.

Rozwiązanie polega na przeniesieniu tego wywołania do konkretnej aktywności – funkcji, którą harmonogram pracy planuje, a której wynik jest zapisywany:

# ✅ 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),
        )

Jeden blok kodu został przeniesiony. Po ponownym uruchomieniu aktywność nie jest wykonywana ponownie, ponieważ proces pracy otrzymuje zapisany wynik, a model nigdy nie jest wywoływany drugi raz. To, dlaczego tak działa, jest prawdziwym tematem badawczym.

Zasada: ponowne uruchomienie musi odtworzyć historię

Gdy proces pracy jest wykonywany, Temporal zapisuje każdy krok do historii zdarzeń – dziennika, który stale się powiększa, zawierającego wpisy takie jak „proces pracy rozpoczęty”, „aktywność zaplanowana”, „aktywność zakończona”, „sygnał otrzymany” oraz „timer uruchomiony”. Zdarzenia są niezmienne, a historia stanowi źródło prawdy.

Po awarii Temporal odtwarza proces pracy. Nowy procesor uruchamia funkcję procesu pracy od jej pierwszego wiersza. Przy każdym wywołaniu, takim jak workflow.execute_activity(...) lub workflow.wait_condition(...), Temporal sprawdza historię. Jeśli istnieje odpowiadające zdarzenie, wprowadza zapisany wynik zamiast wykonywać jakiekolwiek działanie. Jeśli funkcja dokona wywołania, które różni się od tego, co jest zapisane w historii, Temporal wywołuje błąd niedeterministyczności.

To jest źródło zdarzeń. Historia to log, funkcja przepływu pracy to operacja przekładania nad nim, a te same zdarzenia w tym samym porządku, przetwarzane przez tę samą funkcję, zawsze dają ten sam stan. Dokumentacja Temporal mówi o tym jasno: kod przepływu pracy musi być deterministyczny, aby umożliwić odtwarzanie, natomiast operacje niedeterministyczne, takie jak wywołania API, interakcje z modelami językowymi i zapytania do bazy danych, powinny znajdować się w aktywnościach. Aktywność jest wykonywana raz, jej wynik trafia do historii, a dalsze odtwarzania odczytują go stamtąd.

Każdy, kto pracował z Postgresem, widział tę samą strukturę. Log zapisu z góry to rekord, pliki tabel to widok materializowany, a proces odzyskiwania odtwarza ten log. Podręcznik dotyczący źródła zdarzeń formułuje identyczną zasadę: te same zdarzenia, ten sam porządek, ten sam ostateczny stan, bez zegarów, losowych identyfikatorów ani odczytów zależnych od pory dnia w ramach operacji przekładania.

Jak wygląda naruszenie tej zasady

Gdy LLM znajduje się w ramach procesu pracy, dwie rzeczy psują się jednocześnie.

Rachunek się podwaja. Rozmowa, która za pierwszym razem kosztowała trzy centy, kosztuje znów trzy centy przy odtworzeniu. Jedna z zespołów zmierzyła, że niekontrolowane próby ponownego wykonania kosztują około dwóch dolarów w postaci tokenów w ciągu trzydziestu sekund przy jednej rozmowie, natomiast mechanizm przerywania procesu obniżył ten koszt do jednego centa, co stanowi różnicę 200 razy większą.

Wykonanie się różni. Początkowe wykonanie wybrało dostawcę B, natomiast odtworzenie – dostawcę A. Historia już się nie pokrywa, Temporal wykrywa błąd niedeterministyczności, a proces pracy zostaje przerwany.

To nie są rzadkie zdarzenia. Około 0,8% połączeń z Anthropic i OpenAI zawodzi w okresie trzydziestu dni, więc agent wykonujący 12 wywołań modelu na zadanie ma stopę niepowodzeń na poziomie około 9,2%. Jeśli LLM znajduje się w przepływie pracy, każda próba ponowna uruchamia je na nowo. Jeśli natomiast znajduje się w aktywności, próba ponowna otrzymuje zapisany wynik. Sami autorzy Temporal jasno to wyjaśniają: przepływ pracy stanowi warstwę orkiestracji i musi być deterministyczny, aby agenci mogli przetrwać awarie procesów, natomiast aktywności to miejsce, gdzie odbywa się rzeczywista praca, w tym wywołania modeli LLM, używanie narzędzi i żądania API, i mogą one być tak nieprzewidywalne, jak to konieczne.

Dlaczego temperatura zero nie jest tarczą

Zawsze ktoś zarzuca, że temperatura została ustawiona na 0. To nie pomaga z kilku powodów:

  • Zerowa temperatura oznacza chciwe dekodowanie, a nie determinizm. W jednej analizie tysiąc identycznych zapytań dało 80 różnych wyników, spowodowanych różnicami w liczbach zmiennoprzecinkowych zależnymi od partii obliczeń na kartach GPU.
  • Ten sam nazwa modelu nie oznacza tego samego modelu. Dostawcy cicho aktualizują modele, więc model użyty we wtorek może nie odpowiadać temu z środy.
  • Dostawca może być niedostępny podczas ponownego uruchamiania. W takim przypadku proces pozostaje w martwym punkcie, czekając.
  • Nawet odpowiedź identyczna na poziomie bajtów kosztuje pieniądze. Nadal pobierana jest opłata za drugie przetworzenie.

Rozwijacz piszący pod pseudonimem Tisha dobrze opisał ten problem: zespoły spędzają tygodnie na próbach wprowadzenia determinizmu na poziomie bitowym w modelach, ponoszą porażkę i dochodzą do wniosku, że system jest nieprzewidywalny, podczas gdy rzeczywisty determinizm znajduje się na wyższym poziomie, w mechanizmach orkiestracji. Celowali w niewłaściwy poziom.

Aktywność jako błona

Zmiana ma charakter strukturalny. Wewnątrz aktywności wszystko jest dozwolone. Na zewnątrz wszystko musi być deterministyczne.

Przepływ pracy może wywoływać aktywności, czekać na sygnały, ustawiać timery i tworzyć gałęzie, pod warunkiem że warunki tych gałęzi zależą wyłącznie od wyników aktywności oraz danych wprowadzanych przez przepływ pracy. Zabronione są wywołania modelu, żądania HTTP, odczyty z zegara systemowego, generowanie liczb losowych oraz wszystko inne, co może przynieść inny wynik na innej maszynie w innym momencie. Wszystko to znajduje się w ramach aktywności.

Pomyśl o tym jak o anatomii. To, co uruchamia przepływ pracy, to nie sam agent, lecz koordynacja wokół niego, natomiast model jest jedynie usługą, do której agent się odwołuje. Mózg jest nieprzewidywalny, kręgosłup musi być stabilny, a mózg nie znajduje się wewnątrz kręgosłupa.

Jak to wygląda w rzeczywistym kodzie

Uproszczone przykłady powyżej ukrywają jeden szczegół. W rzeczywistym kodzie wywołanie LLM znajduje się w węzłach grafu, a graf ten działa w ramach konkretnej aktywności, więc istnieją dwa granice, a nie jeden:

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

Sam przepływ pracy to czysta orkiestracja – sekwencja wywołań execute_activity i wait_condition i nic więcej. Linia execute_activity stanowi granicę. To, co dzieje się wewnątrz aktywności, jest rejestrowane, a to, co dzieje się na zewnątrz, jest odtwarzane:

@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=(...), ...)

W nim nie ma nic, co wywoływałoby klienta LLM, wysyłało żądanie HTTP, odczytywało aktualną godzinę lub generowało liczbę losową. Zawsze te same dane wejściowe, ta sama historia, ta sama ścieżka wykonywania.

Aktywność buduje graf i go wywołuje, a właśnie ta jedna linia graph.ainvoke() jest źródłem nieokreśloności: wywołania LLM, wywołania narzędzi oraz zapytania do pamięci. Aktywność zwraca słownik, który jest zapisywany, a ponowne uruchomienie pomija cały ten proces:

@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", [])}

Na dole stosu znajduje się węzeł grafu, w którym model faktycznie wykonuje działanie. Tworzy on prompt na podstawie ocenionych dostawców oraz wcześniejszych decyzji, a następnie dokonuje wywołania:

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)

To oznacza trzy warstwy poniżej procesu pracy: aktywność, graf, węzeł, a następnie model LLM. Podczas ponownego uruchomienia żadna z tych warstw nie działa, ponieważ wynik aktywności jest wprowadzany z zewnątrz. Proces pracy jest deterministyczny. Aktywność, graf oraz model nie są deterministyczne, jednak nieoddeterminizm jest ograniczony do granic aktywności. Proces pracy widzi wyłącznie zapisany słownik, ten sam przy każdym ponownym uruchomieniu.

Gdzie granica staje się niejasna

W teorii wzorzec wygląda klarownie, ale w praktyce w kilku miejscach staje się niejasny. Przypadek trwający kilka dni wymaga szczególnej uwagi, ponieważ jego awaria jest subtelna, a dwie warstwy przechowywania mogą się oddalać od siebie bez żadnego ostrzeżenia.

Gdy graf zatrzymuje się w węźle zatwierdzenia za pomocą metody interrupt() z LangGraph, aktywność zostaje zakończona, a słownik jest zwracany z powrotem do przepływu pracy. Następnie przepływ pracy wywołuje metodę workflow.wait_condition(), aby czekać na sygnał od człowieka. W tym momencie w dwóch systemach istnieją dwie kopie stanu:

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]

Oba znajdują się w Postgresie, ale są to oddzielne tabele, którym dane są dostarczane z różnych ścieżek kodu w różnych momentach. Punkt kontrolny LangGraph jest tworzony przez daną aktywność w momencie wystąpienia przerwy; po powrocie aktywności Temporal dodaje zdarzenie o zakończeniu tej aktywności. To oznacza dwa zapisy, dwa systemy i jeden stan logiczny.

Problemy pojawiają się, gdy jeden z zapisów przetrwa, a drugi nie. Jednym ze sposobów łagodzenia tego problemu jest działanie operacyjne: thread_id musi być identyczny z workflow_id Temporal, który pozostaje stabilny podczas ponownych uruchomień w odróżnieniu od run_id; tabela checkpointów musi przechowywać dane tak długo, jak historia Temporal; a funkcja wznowienia działania musi wywołać graph.ainvoke(None, config), ponieważ przekazanie wartości None informuje LangGraph o konieczności załadunku danych z checkpointu, a nie o ponownym uruchomieniu od zera. Błąd w którymkolwiek z tych elementów powoduje odchylenia w strukturze danych. Przepływ pracy się nie zatrzymuje, ale wznowia się z niewłaściwego stanu.

Lepsza odpowiedź: uczynienie grafu bezstanowym

Intuicyjny projekt umożliwia mechanizmowi checkpointer w LangGraph dla Postgresa śledzenie stanu pomiędzy węzłami agentów wraz z historią przechowywaną przez Temporal. Analiza sposobów występowania błędów podczas kilkudniowych przerw ujawnia ukrytą pułapkę, która stanowi klasyczny problem podwójnego zapisu pomiędzy dwoma bazami danych. Jeśli procesor zakończy pracę tuż po zapisaniu punktu kontrolnego przez LangGraph, ale przed zarejestrowaniem tego faktu przez Temporal, silniki zarządzające stanem nie będą się zgadzać, w wyniku czego proces pracy będzie kontynuowany w tle przy niezgodnym stanie.

Zamiast ciągle dodawać reguły synchronizacji dotyczące mapowania wątków, przechowywania danych i ich ponownego ładowania, lepszym rozwiązaniem jest usunięcie drugiego silnika stanów. Bez jego wskaźnika kontrolnego LangGraph staje się jednostką rozumowania bezstanową, działającą w pamięci operacyjnej wewnątrz aktywności. Temporal zarządza całym długoterminowym stanem, historią wykonywania oraz pauzami spowodowanymi interwencją człowieka za pomocą workflow.wait_condition(). LangGraph po prostu przechodzi przez swój cykl rozumowania, przekazuje słownik z wynikami z powrotem do aktywności i kończy pracę. Rezultat: brak podwójnych zapisów, brak ryzyka dezynchronizacji oraz prostsza struktura operacyjna.

Żaden z tych narzędzi nie staje się zbędny. LangGraph nadal doskonale radzi sobie z szybkim routowaniem grafów w pamięci, wywoływaniem narzędzi oraz pętlami rozumowania na poziomie węzłów, natomiast Temporal wyróżnia się trwałością działania przez wiele dni, odzyskiwaniem po awariach oraz zorganizowanym zarządzaniem procesami w sposób rozproszony. Bezzstanowy LangGraph w ramach trwałej aktywności Temporal zapobiega konkurowaniu między nimi o możliwości przechowywania danych i zachowuje zalety obu rozwiązań.

Jedno ograniczenie w wielu systemach

Ramki orkiestracji to tylko jedno z miejsc, w których występuje kontrakt odtwarzania. Każdy system, który odzyskuje funkcjonalność poprzez odtwarzanie działań, składa się z tych samych trzech elementów: zapisu wydarzeń, funkcji czystej, która przekształca je w stan, oraz wszystkiego, co nieprzewidywalne, co jest przechowywane po drugiej stronie zapisanej krawędzi.

Agenci sprawiają, że sytuacja staje się jeszcze trudniejsza. W klasycznym rejestrze opartym na zdarzeniach funkcja apply pozostaje czysta, ponieważ dodanie dziesięciu dolarów na konto zawsze daje ten sam wynik. Logika biznesowa agenta polega na pytaniu LLM o dalsze kroki, a odpowiedź różni się za każdym razem. Jeśli „mózg” znajduje się w ścieżce odtwarzania, odtworzenie jest niemożliwe, więc musi znajdować się poza nią.

Temporal został wybrany jako platforma przykładowa, ponieważ egzekwuje umowę: jego SDK wywołuje błąd związaný z niedeterministyką w przypadku naruszenia zasad, zamiast po cichu dopuścić do niezgodnego działania. Step Functions egzekwuje to za darmo, dzięki temu, że jego deklaratywny język stanów uniemożliwia przypadkowe umieszczenie wywołania LLM w maszynie stanów. Przy użyciu mechanizmu outbox lub dostosowanego pollingu egzekwowanie zasad odbywa się ręcznie. Nic nie powstrzymuje wywołania LLM wewnątrz pętli pollingu, a nic też o tym nie ostrzega. Ograniczenie to jest identyczne we wszystkich tych rozwiązaniach. Jedynym pytaniem jest to, kto wykryje naruszenie: framework, czy osoba pełniąca dyżur o 3 rano.

Ta sama zasada obowiązuje w innych zestawieniach agentów. Niektóre z nich ją egzekwują, mianowicie Temporal oraz Microsoft Agent Framework. Inne dostarczają elementy bazowe i pozostawiają egzekwowanie twórcy, jak to robią LangGraph i AutoGen. Jeszcze inne całkowicie ignorują tę kwestię, np. Celery i CrewAI. Pytanie praktyczne brzmi, w jakim stopniu umowę dotyczącą ponownego odtwarzania musi zostać zbudowana ręcznie.

Dane z zewnętrznych porównań wskazują w tym samym kierunku. Raport dotyczący migracji z Celery do Temporal wskazał na wysokie ryzyko powtórnego wykonywania zadań przy próbach ponownych, szczególnie w przypadku zadań wymagających dużo czasu obliczeniowego GPU. Projekt Temporal-with-CrewAI osiągnął opisaną powyżej architekturę, wykorzystując silnik orkiestracji jako źródło prawdy dotyczące stanu oraz framework agentów jako bezstanową jednostkę rozumowania wywoływaną wewnątrz danej aktywności. W jednym z testów AutoGen kontra CrewAI AutoGen kończyło zadania przy opóźnieniu p95 wynoszącym 2,1 sekundy w porównaniu z 4,7 sekundami u CrewAI, a skuteczność wynosiła 94% wobec 83%, co przypomina, że sposób radzenia sobie frameworkiem z awariami znajduje odzwierciedlenie w liczbach. Microsoft Agent Framework oferuje przechowywanie stanu w formie plików oraz superkroki odporne na awarie, co stanowi jego sposób zapewnienia tej samej gwarancji.

Jakie są koszty determinizmu

Granica aktywności nie jest swobodna. Prostota w formacie wstępnym znika, ponieważ błędna wersja ma mniej wierszy i jest łatwiejsza do odczytania. Znika również bezpośredni dostęp do stanu, gdyż aktywność widzi tylko to, co jej przekazano, a nie wewnętrzne struktury procesu. Pojawiają się również trzy przypadki krawędziowe, których nie ma w przypadku modeli LLM w formacie wstępnym. Są to rzeczywiste decyzje inżynieryjne, a nie filozoficzne kompromisy.

Przypadek krawędziowy nr 1: długie aktywności i komunikaty statusowe

Wywołanie modelu LLM może trwać 30 sekund. Graf agenta z wieloma etapami i wywołaniami narzędzi może potrzebować pięciu minut. Domyślny czas start_to_close_timeout w Temporal wynosi 10 sekund. Jeśli aktywność przekroczy ten czas, Temporal oznacza ją jako nieudaną i próbuje ponownie, co powoduje ponowne wywołanie modelu, ponowną opłatę za tokeny oraz powtórzenie pracy niedeterministycznej, która została starannie odizolowana.

Rozwiązaniem jest timeout serca. Każda aktywność trwająca dłużej niż kilka sekund powinna w regularnych odstępach wywoływać activity.heartbeat(), co sygnalizuje Temporalowi, że nadal istnieje. Jeśli proces ulegnie awarii, Temporal wykorzystuje ostatni sygnał serca, aby ocenić, jak daleko zaszła aktywność, dzięki czemu aktywność umożliwiająca tworzenie punktów kontrolnych może zostać wznowiona, a wszystkie inne restarty mogą rozpocząć się od zera:

@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

Istnieje jednak haczyk. Bicie serca nie zapobiega utracie wewnętrznego postępu działania; jego jedynym zadaniem jest opóźnienie wygaśnięcia terminu. Jeśli proces pracujący zakończy się w połowie wykonywania grafu, ponowna próba rozpocznie działanie od początku, a model LLM zostanie uruchomiony ponownie, ponieważ wynik jest rejestrowany tylko wtedy, gdy działanie zostanie zakończone. W przypadku grafu trwającego pięć minut, proces pracujący, który zatrzyma się po czterech minutach, zabierze ze sobą cztery minuty pracy bez ochrony.

Dlatego przykład dzieli graf na dwa działania – jedno wykonywane do momentu przerwy, a drugie kontynuowane po niej. Każde z nich jest na tyle krótkie, by zostać zakończonym w ramach ustalonego terminu, a wynik każdego z nich jest rejestrowany oddzielnie. Taka podziałka służy lepszej kontrolie nad trwałością danych, a nie organizacji kodu.

Przypadek krawędziowy numer dwa: strumieniowanie tokenów

Gdy LLM znajduje się wewnątrz procesu pracy, tokeny mogą być przekazywane bezpośrednio do odpowiedzi HTTP użytkownika. Gdy znajduje się w ramach konkretnej aktywności, tokeny trafiają do jej wyniku, czyli pojedynczej wartości zwracanej, która jest zapisywana w historii. Nic nie dociera do użytkownika, dopóki aktywność się nie zakończy.

Strumieniowanie jest najczęściej podnoszonym zarzutem wobec tego modelu i ma uzasadnienie. Istnieją trzy podejścia o rosnącej złożoności. Pierwsze zakłada, że wynikiem jest decyzja, a nie strumień rozmowy, co odpowiada potrzebom agenta pozyskiwania danych. Drugie dodaje kanał pomocniczy do interakcji wieloetapowych, takich jak wywiad, gdzie użytkownik musi widzieć każdą odpowiedź. Kanałem tym może być punkt końcowy server-sent events wspierany przez Redis: system publikuje tam tokeny, a punkt końcowy przekazuje je użytkownikowi, podczas gdy Temporal przechowuje tylko ostateczny wynik. Użytkownik, który przerwie proces, może wrócić później i zapytać system o jego rezultat. Tokeny w strumieniu są jednorazowe; to przechowywany wynik pozostaje na zawsze.

Przypadek krawędziowy trzy: różne polityki ponawiania prób

Temporalne próby ponownego wykonania automatycznie zawodzą w przypadku nieudanych działań, jednak to, co oznacza niepowodzenie, różni się w zależności od konkretnego działania. Zapytania odczytu wyłącznie oraz efekty uboczne nie powinny być próbowane ponownie w ten sam sposób. W przypadku efektu ubocznego, takiego jak utworzenie zamówienia, zasady są ścisłe i nie dopuszczają ponownych prób, przy czym klucz idempotencji zapewnia bezpieczeństwo:

# 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,
    ),
)

Polityka ponawiania prób jest częścią umowy o odtworzeniu. Gdy jakakolwiek aktywność zawodzi i jest ponawiana, te próby są rejestrowane w historii, a podczas odtworzenia Temporal wprowadza ostateczny wynik, nie uruchamiając ponownie nieudanych prób. Jednak aktywność, która tworzy zamówienie z ustawieniem maximum_attempts=3, ma trzy możliwości jego utworzenia, a brak klucza idempotencji oznacza powstanie trzech zamówień. Te dwa mechanizmy współpracują: polityka decyduje, kiedy należy spróbować ponownie, a klucz określa, co się stanie w takiej sytuacji.

To są koszty trwałości. Kwestia ta nie dotyczy determinizmu kontra niedeterminizm. Chodzi o trwałość z pewną pośredniością kontra prostotę i niemożność odzyskania, a agent produkcyjny powinien wybrać trwałość ze względu na możliwe awarie produkcji. Dyskusje na temat niezawodności agentów często odbywają się na niewłaściwym poziomie, skupiając się na modelu: lepsze instrukcje, szersze testy, bardziej zaawansowane punkty kontrolne w następnym kwartale. Wszystko to napotyka twardy sufit możliwości.

Jedyna zasada, którą należy zachować

Ze wszystkiego w systemie tylko LLM opiera się ponownemu uruchomieniu, i to jest powód, dla którego powinno znajdować się tam, gdzie nie dotyka go ponowne uruchomienie. FAQ Temporal jeszcze bardziej to uogólnia: zawsze otaczaj wywołania LLM działaniami. Zawsze, a nie zwykle ani wtedy, gdy pozwala na to czas, ponieważ jedynym razem, gdy tego nie robi się, jest awaria o 3 nad ranem, przy której ponowne uruchomienie daje inny wynik, a osoba na dyżurze musi zająć się agentem, który przez całą noc pozostaje w stanie awarii.

Bez umowy o ponowne uruchomienie trzy warstwy – oprawa, pętla i graf – mają jedynie charakter organizacyjny. Pomagają w rozumieniu systemu, ale nie zapewniają jego przetrwania. Dzięki umowie stają się strukturalne: pętla kontynuuje się od ostatniego zakończonego kroku, a działania są wprowadzane na podstawie historii; stan grafu jest przechowywany w jednym miejscu i ładowany przez aktywność kontynuacji; narzędzia nie są uruchamiane ponownie, ponieważ wyniki są zapisywane, a klucz idempotencji stanowi drugą linię obrony. Warstwy pozostają oddzielone w przypadku awarii, i to nie tylko z powodów zasadniczych.

Poziom ograniczeń pozostaje jeden. Istnieją gwarancje czasowe dotyczące poprawnego ponownego uruchomienia procesu po awarii. Nie gwarantują one jednak, że logika biznesowa nadaje się do ponownego uruchomienia, a to pozostaje zadaniem twórcy. Idempotencja to kolejny problem do rozwiązania.

Literatura pokrewna