Агенты, устойчивые к повторной обработке: изоляция вызовов LLM от рабочего процесса с использованием временных механизмов.
Почему агент, выходящий из строя в процессе работы, не должен снова запускать свой LLM: действия как граница недетерминизма, механизмы повторной обработки, показатели активности, попытки повтора и графы без состояния.
Источники: Fixing Non-Deterministic Replay Bugs in AI Agent Loops
Представьте агента на 7-м из 10 этапов, когда его рабочий процесс погибает: из-за нехватки памяти, из-за события технического обслуживания или из-за сбоя ядра. Если вызов большой языковой модели происходит внутри рабочего процесса, восстановление превращается в бесшумную перезапись истории. Рабочий процесс снова запускает модель, получает другой ответ, и агент оказывается не там, где погиб. За одни и те же токены выплачивают дважды, а восстановленная попытка — это уже другая попытка, отличная от первоначальной.
Это не мысленный эксперимент. Однажды ночью в 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, использование больших языков моделирования и запросы к базе данных, должны выполняться в рамках отдельных задач. Каждая такая задача выполняется один раз, ее результат записывается в историю, а при повторной обработке данные читаются именно оттуда.
Любой, кто работал с Postgres, видел аналогичную структуру. Журнал предварительных записей служит источником данных, файлы таблиц представляют собой материализованное представление, а процесс восстановления выполняет чтение из этого журнала. В руководстве по источниковедению событий изложен абсолютно тот же принцип: одни и те же события, один и тот же порядок, одно и то же конечное состояние, без использования часов, случайных идентификаторов или данных, полученных в определенное время суток в процессе обработки.
Как выглядит нарушение
Когда большая языковая модель встраивается в рабочий процесс, сразу же возникают две проблемы.
Стоимость удваивается. Звонок, который в первый раз стоил три цента, будет стоить ещё три цента при повторном выполнении. Одна команда измерила, что неконтролируемые попытки повтора потребляют около двух долларов в виде токенов за тридцать секунд одного разговора, тогда как механизм прерывания сокращал эту стоимость до одного цента, что составляет разницу в 200 раз.
Ход выполнения меняется. Изначальное выполнение выбрало поставщика B, а повторное — поставщика A. История уже не совпадает, Temporal выдает ошибку недетерминированности, и рабочий процесс останавливается.
Это не редкие ситуации. Примерно 0,8% звонков в Anthropic и OpenAI терпят неудачу в течение тридцатидневного периода, поэтому агент, выполняющий 12 вызовов модели на задачу, сталкивается с вероятностью неудачи около 9,2% на каждую задачу. Если большая языковая модель находится в рабочем потоке, каждая попытка повтора запускает её заново. Если же она находится в задаче, попытка повтора получает уже зарегистрированный результат. Сами авторы Temporal четко разграничивают эти понятия: рабочий поток представляет собой слой оркестрации и должен быть детерминированным, чтобы агенты могли выживать при сбоях процессов, тогда как задачи — это место, где происходит реальная работа, включая вызовы больших языковых моделей, использование инструментов и запросы к API, и они могут быть настолько непредсказуемыми, насколько это необходимо.
Почему значение температуры 0 не является защитой
Всегда находятся те, кто возражает, утверждая, что значение температуры установлено в 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 на загрузку данных из чекпоинта, а не на повторный запуск с нуля. Любая ошибка в этих аспектах приводит к смещению состояния слоев. Сам workflow не падает, но возобновляется из неверного состояния.
Лучшее решение: сделать граф безсостоянийным
Интуитивный дизайн позволяет механизму проверки состояния Postgres в LangGraph отслеживать состояние на узлах агентов наряду с историей данных в Temporal. Анализ модов сбоев во время многодневных перерывов выявляет скрытую проблему — классическую проблему двойного записи между двумя базами данных. Если рабочий процесс останавливается сразу после того, как LangGraph записывает свой контрольный пункт, но до того, как Temporal фиксирует завершение операции, двигатели управления состоянием приходят к разногласиям, в результате чего рабочий процесс бесшумно возобновляется с несоответствующим состоянием.
Вместо того чтобы постоянно добавлять правила синхронизации, связанные с отображением потоков, хранением данных и их перезагрузкой, более разумным решением является устранение второго движка состояний. Без его механизма контроля LangGraph превращается в безсостоянийный блок обработки информации в памяти, действующий внутри активности. Temporal отвечает за всё долгосрочное состояние, историю выполнения и паузы, вызванные действием человека, с помощью метода workflow.wait_condition(). LangGraph просто выполняет свой цикл обработки, возвращает словарь с результатами обратно в активность и завершает работу. Результат: отсутствие двойных записей, отсутствие риска десинхронизации и более простая структура управления.
Ни один из инструментов не становится избыточным. LangGraph остаётся сильным в быстром маршрутизировании графов в памяти, вызове инструментов и циклах рассуждений на уровне узлов, в то время как Temporal превосходит в долговечности на протяжении дней, восстановлении после сбоев и распределённой оркестрации. Безсостоянийный LangGraph внутри надёжной активности Temporal предотвращает конкуренцию между ними за сохранение данных и сохраняет сильные стороны обоих.
Одно ограничение во многих системах
Фреймворки оркестрации — это лишь одно из мест, где проявляется принцип повторной обработки. Любая система, восстанавливающаяся путём повторной обработки, состоит из трёх компонентов: записи событий, чистой функции, которая преобразует их в состояние, и всего того, что непредсказуемо, что хранится по другую сторону зарегистрированной связи.
Агенты делают ситуацию ещё сложнее. В классическом реестре, основанном на событиях, функция apply остаётся чистой, поскольку зачисление десяти долларов на счёт всегда приводит к одинаковому результату. Бизнес-логика агента заключается в том, чтобы спросить у большой языковой модели, что делать, причём ответ каждый раз различается. Если «мозг» находится в пути воспроизведения, воспроизведение становится невозможным, поэтому «мозг» должен находиться вне этого пути.
Платформа Temporal была выбрана в качестве примера потому, что она обеспечивает соблюдение условий контракта: её SDK выдает ошибку, связанную с недетерминированностью, при нарушении, вместо того чтобы тихо привести к различному результату выполнения. Service Step Functions обеспечивает это бесплатно, поскольку её декларативный язык описания состояний делает невозможным случайное включение вызова LLM в машину состояний. При использовании механизма выдачи сообщений или пользовательского опроса контроль за соблюдением условий осуществляется вручную. Ничто не мешает выполнению вызова LLM внутри цикла опроса, и ничто не предупреждает об этом. Условия ограничений одинаковы во всех случаях. Единственный вопрос заключается в том, кто обнаружит нарушение: фреймворк или тот, кто находится на дежурстве в 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 устойчив к повторной обработке, и именно поэтому он должен находиться там, где повторная обработка к нему никогда не доберётся. В разделе часто задаваемых вопросов Temporal это объясняется ещё подробнее: вызовы LLM всегда следует оборачивать в операцию. Всегда, а не обычно и не тогда, когда есть время, потому что единственный случай, когда это можно пропустить, — это сбой в 3 часа ночи, при котором повторная обработка приводит к другому результату, и кто-то из сотрудников, находящихся на дежурстве, должен разбираться с агентом, оставшимся в неисправном состоянии на всю ночь.
При отсутствии контракта на повторную обработку три уровня — харнес, цикл и граф — имеют лишь организационное значение. Они помогают структурировать мышление о системе, но не способствуют её выживанию. С наличием контракта они становятся структурными элементами: цикл возобновляется с последнего завершённого шага, при этом действия подгружаются из истории; состояние графа хранится в одном месте и загружается действием возобновления; инструменты не запускаются заново, поскольку результаты записываются, а ключ идемпотентности служит дополнительной защитой. При сбоях уровни остаются разделёнными, и это не просто вопрос принципа.
Остаётся одно ограничение. Существуют временные гарантии того, что рабочий процесс будет корректно повторён после сбоя. Однако они не гарантируют безопасность повторной обработки бизнес-логики, и эта проблема остаётся за создателем системы. Следующей проблемой, которую необходимо решить, является идемпотентность.