Агентная система для тех, кто вкатывается: собираем агента поддержки от одного файла до Temporal, саги и circuit breaker

Клиент пишет в поддержку: «Посылка не пришла, верните деньги». Агента, который на это ответит, можно написать за вечер. Цикл из части 1, три инструмента, и в демо он отвечает правильно. А потом процесс перезапускается посреди возврата, провайдер модели ложится на полчаса, клиент нажимает «отправить» дважды. И оказывается, что агент это малая часть системы.

Это третья, последняя часть серии. В первой разобрали агентный цикл, токены и кэш. Во второй собрали практики: ретраи, идемпотентность, саги, Temporal, подтверждение человеком, проверки. Здесь соберём из них одну систему. Начнём с агента в одном файле и на каждом шаге будем упираться в проблему. Каждая проблема добавит компонент, а схема системы будет расти вместе с текстом. Код, как и раньше, псевдокод: имена на английском, как в настоящем Python, комментарии на русском.

Схемы рисую по C4. Это способ рисовать архитектуру с разным приближением, как карту. Context показывает систему коробкой среди людей и внешних систем. Container раскрывает её на приложения и хранилища. Component раскрывает одно приложение на части. Первые два уровня авторы C4 советуют всем командам, третий только когда он что-то добавляет. Контейнер в C4 это не Docker, а всё, что должно работать, чтобы работала система: сервис, база, очередь.

TL;DR

  • Агент в одном процессе теряет всё при перезапуске. Обращение и историю диалога храните в базе, а сам процесс обработки ведите в Temporal: workflow на обращение, Workflow ID равен id диалога.

  • У Temporal своя база, и бизнес-данные в ней не хранят. Воркеры с вашим кодом запускаете вы, сервис Temporal их не выполняет.

  • Ретрай и circuit breaker разные вещи. Ретрай повторяет временный сбой, circuit breaker перестаёт звать сервис, который лежит. Его место перед провайдером модели, в LLM-шлюзе.

  • Модель решает, деньги двигает код: возврат идёт сагой, где возврат денег это точка невозврата. Крупные суммы одобряет оператор.

  • Не всё должен делать агент. Роутер на маленькой модели отправляет FAQ в один вызов, «где заказ» в цепочку, и только спорный возврат в агента.

  • Добавляйте компонент, когда появилась боль, которую он лечит. Чек-лист в конце.

Шаг 0. Агент в одном файле

Начнём с того, что умеем после части 1. Скрипт держит историю диалога в памяти, вызывает модель и выполняет инструменты, которые она попросила. Инструментов три: track_order_status смотрит статус заказа у службы доставки, get_refund_policy достаёт правила возврата, refund_payment возвращает деньги. Похожий track_order_status есть в гайде OpenAI по агентам, где пример тоже про поддержку.

# Псевдокод: агент поддержки в одном файлеhistory = []def handle(message): history.append(message) while True: response = llm(history, tools=[track_order_status, get_refund_policy, refund_payment]) history.append(response) if response.stop_reason != "tool_use": return response.text for call in response.tool_calls: history.append(run_tool(call))

На уровне Context система выглядит так. Клиент пишет, оператор иногда подключается. Снаружи провайдер модели, магазин с заказами, служба доставки, платёжная система и база знаний со статьями.

Уровень Context: система поддержки среди людей и внешних системУровень Context: система поддержки среди людей и внешних систем

C4, уровень 1. Внешние системы не наши, мы их только вызываем.

В демо это работает. Проблемы начинаются, когда клиентов становится больше одного. История лежит в памяти процесса, поэтому любой перезапуск стирает все диалоги. Скрипт обслуживает одного клиента за раз. И если процесс упал между refund_payment и ответом клиенту, никто не знает, вернули деньги или нет.

Шаг 1. API и база

Первое, что добавляем, это вход и память. Скрипт из шага 0 делал всё сразу: принимал сообщение, звал модель и ждал её ответа. Делим его на две программы.

API это веб-сервис, в который чат отправляет сообщение клиента. Он проверяет запрос, сохраняет сообщение в базу, ставит там задачу «обработать диалог» и сразу отвечает чату, что сообщение принято. Модель API не вызывает, поэтому отвечает за миллисекунды.

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

История диалога теперь лежит в базе, и её переживёт любой перезапуск. Готовые хранилища истории есть в SDK. В OpenAI Agents SDK это сессии: SQLite, Redis, SQLAlchemy, MongoDB и свои реализации.

Шаг 1: API, воркер и база приложенияШаг 1: API, воркер и база приложения

C4, уровень 2. Оранжевой рамкой выделено то, что добавили на этом шаге.

Зачем делить. Агент думает над одним обращением от секунд до минуты. Если бы это делал API, каждое обращение держало бы соединение открытым всё это время. Поэтому долгая работа уходит воркерам: API остаётся быстрым, а воркеров добавляют под нагрузку.

Но главная проблема осталась. Воркер упал на середине обращения. Он успел проверить статус заказа и вызвать refund_payment, а ответ получить не успел. Новый воркер возьмёт обращение заново. Вызвать возврат ещё раз? Если первый прошёл, клиент получит деньги дважды. Не вызывать? Если не прошёл, клиент не получит ничего. База знает, что сказал клиент, но не знает, на каком шаге остановилась обработка.

Шаг 2. Temporal: каждое обращение это workflow

Здесь нужен durable execution, который мы разбирали во второй части. Temporal записывает каждый шаг в журнал. После падения другой воркер проигрывает журнал и продолжает с того же места. Готовые шаги второй раз не выполняются: их результаты берутся из журнала.

Важно понять, из чего Temporal состоит физически, потому что тут путаются чаще всего. Temporal Service это сервер плюс его собственная база. Сервер внутри делится на четыре сервиса. Frontend принимает вызовы, ограничивает их частоту и проверяет права. History хранит состояние workflow, Matching держит очереди задач, Worker выполняет внутренние фоновые задачи. База у Temporal своя: PostgreSQL, MySQL или Cassandra. Её единственную он требует для работы.

А ваш код Temporal не выполняет. Документация говорит прямо: воркеры живут вне Temporal Service, их пишет и запускает команда приложения. Воркер сам опрашивает очередь задач и берёт новую, только когда свободен. Так нагрузка распределяется между воркерами без балансировщика. Если воркер упал, задача остаётся в очереди и дождётся следующего.

Шаг 2: Temporal Service со своей базой, воркеры опрашивают очередиШаг 2: Temporal Service со своей базой, воркеры опрашивают очереди

Temporal хранит ход обработки, база приложения хранит данные.

Агентный цикл ложится сюда так. Цикл живёт в workflow, вызов модели и каждый вызов инструмента становятся activities. Так устроена официальная интеграция Temporal с OpenAI Agents SDK: цикл, выбор инструментов и передача задачи между агентами идут в workflow, вызовы модели идут activities и при восстановлении не повторяются. По словам самого Temporal, на нём работает веб-агент Codex от OpenAI. Это слова вендора, разбора от OpenAI я не нашёл.

Как обращение попадает в workflow. ID workflow задаёте вы, и он должен нести бизнес-смысл, например номер заказа. Возьмём id диалога. Temporal гарантирует, что открытым может быть только одно выполнение с таким ID. Клиент нажал «отправить» дважды, второй запуск не создаст дубль. Ещё удобнее Update-with-Start. Это один вызов из API: если workflow для этого диалога уже идёт, новое сообщение уходит в него, если нет, workflow стартует. Для этого вызову обязательно указывают политику конфликта ID, здесь Use Existing. API при этом может дождаться ответа. Задачу в базу API больше не ставит: очередь задач теперь держит Temporal, а база приложения хранит только сами сообщения.

# Псевдокод: API принимает сообщение клиента@post("/chat/{dialog_id}")def new_message(dialog_id, text): db.save_message(dialog_id, text) return temporal.update_with_start( workflow=support_case, workflow_id=f"dialog-{dialog_id}", id_conflict_policy=USE_EXISTING, # workflow уже идёт: сообщение уходит в него update="new_message", arg=text)

Писать в workflow можно тремя способами. Signal это асинхронное сообщение без ответа. Update это синхронная запись: отправитель ждёт результат или ошибку, а workflow может проверить запрос до принятия. Query это чтение текущего состояния, оно ничего не пишет в журнал. Новое сообщение клиента удобно слать Update, решение оператора Signal, статус для интерфейса читать Query.

И почему не хранить всё в Temporal, раз он и так хранит журнал. Журнал одного выполнения ограничен: 51 200 событий или 50 МБ. После этого выполнение завершается. Для долгого диалога есть Continue-As-New: workflow передаёт своё состояние новому выполнению с тем же ID и чистым журналом. Но данные клиента, заказы и историю переписки, которую читает оператор, держите в базе приложения. Ещё одна причина: Workflow ID хранится открытым текстом и виден в интерфейсе и логах, поэтому персональных данных в нём быть не должно.

Шаг 3. LLM-шлюз и circuit breaker

Провайдер модели лёг на полчаса. Temporal делает то, что должен: повторяет activity с растущей паузой, по умолчанию без ограничения числа попыток. Сто воркеров с тысячей открытых обращений дружно долбят упавший API. Каждая попытка ждёт таймаута и держит соединение. Когда провайдер поднимется, на него разом обрушится вся очередь.

Ретрай рассчитан на сбой, который пройдёт за секунды. Для долгого отказа есть другой паттерн, circuit breaker, автоматический выключатель. Мартин Фаулер описывает его так. Обёртка вокруг вызова считает сбои. После порога она размыкается, и следующие вызовы сразу получают ошибку, без похода к сервису. Через время выключатель пропускает пробный вызов. Прошёл, значит выключатель замыкается обратно.

Microsoft в своём описании паттерна подчёркивает разницу. Ретрай повторяет операцию в расчёте на успех. Circuit breaker не даёт выполнить операцию, которая почти наверняка провалится. Их можно сочетать: ретрай идёт через выключатель. Но ретрай должен понимать ошибку выключателя и прекращать попытки, если сбой не временный.

Где поставить выключатель. Воркеров много, и у каждого своя память. Если выключатель живёт в памяти воркера, каждый из них будет отдельно узнавать, что провайдер лёг. Поэтому состояние выключателя должно быть общим. Удобное место это LLM-шлюз, отдельный сервис между воркерами и провайдерами. Например, LiteLLM умеет то, что нужно. Он выводит модель из ротации, если за короткое время она упала больше заданного числа раз (в примере документации больше трёх сбоев, и модель «остывает» 30 секунд), переключает на запасную модель и держит это состояние в Redis, общем для всех инстансов.

Шаг 3: LLM-шлюз с circuit breaker между воркерами и провайдерамиШаг 3: LLM-шлюз с circuit breaker между воркерами и провайдерами

Выключатель в шлюзе общий для всех воркеров. Состояние в Redis.

Дальше мой совет, а не правило из документации. Пусть activity вызова модели различает два вида ошибок. Временная ошибка от провайдера повторяется политикой Temporal. Ошибка «выключатель разомкнут» означает, что провайдер лежит, и workflow решает сам: уйти на запасную модель, подождать дольше или отдать обращение оператору.

# Псевдокод: вызов модели через шлюз@activitydef call_llm(history): response = gateway.chat(history, model="main", fallback="backup") if response.error == "all_models_cooling_down": raise NonRetryableError("провайдеры недоступны") # ретраи Temporal не жгём return response@workflowdef support_case(...): try: response = activity(call_llm, history, retry=retry_policy) except NonRetryableError: return hand_off_to_operator("модель недоступна")

Те же выключатели ставят перед любым внешним API, который может лечь надолго: службой доставки, платёжкой. Правило одно: состояние выключателя общее для всех воркеров.

Шаг 4. Сага и подтверждение оператором

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

Здесь добавлю понятие, которое в описании паттерна у Microsoft называется pivot transaction, точка невозврата. Шаги до неё можно отменить. После неё отменять уже нечего, и оставшиеся шаги нужно довести до конца, повторяя сколько угодно. У нас точка невозврата это возврат денег. Этикетку можно отменить. Деньги ушли, значит письмо клиенту и обновление заказа обязаны выполниться, даже если для этого понадобится сутки ретраев.

# Псевдокод: возврат как сага внутри workflowdef refund(decision): label = activity(create_return_label, decision.order, key=decision.id) try: activity(refund_payment, decision.amount, key=decision.id) # точка невозврата except PermanentError: activity(cancel_return_label, label) # компенсация raise # дальше только вперёд: повторять до успеха activity(notify_customer, decision, key=decision.id, retry=FOREVER) activity(update_order, decision, key=decision.id, retry=FOREVER)

Каждое действие получает ключ идемпотентности: id решения. Если Temporal повторит шаг после сбоя, платёжная система по тому же ключу вернёт старый ответ, а не спишет деньги снова. Сага в Temporal пишется обычным кодом в workflow. Microsoft называет такой вариант оркестрацией: один координатор знает все шаги и сам запускает компенсации.

Крупный возврат модель не должна проводить сама. В гайде OpenAI среди поводов звать человека прямо названы отмена заказа, крупный возврат и платёж. В системе появляется консоль оператора. Workflow показывает ей решение и ждёт сигнал. Ожидание записано в журнал, поэтому оператор может ответить через час, а воркеры за это время успеют перезапуститься. Как это устроено и какие правила у подтверждения, во второй части.

Шаг 4: консоль оператора, сага через доставку и платёжкуШаг 4: консоль оператора, сага через доставку и платёжку

Ждущий workflow не занимает воркер: его состояние лежит в журнале.

Шаг 5. Цепочка или агент

Система стала надёжной, но дорогой и медленной. Каждое обращение проходит полный агентный цикл, даже вопрос «как вернуть товар?», на который есть статья в базе знаний. А ведь обращения бывают трёх видов. На вопрос из базы знаний хватает одного вызова модели с поиском. «Где мой заказ» решается цепочкой: достать номер, вызвать track_order_status, написать ответ. И только спорный возврат требует суждения: заказ мог прийти в пункт выдачи, клиент мог не забрать его в срок, правила для разных товаров разные.

Цепочкой (workflow, agents chain) называют систему, где шаги заранее прописаны в коде. Агентом (AgentsLLM) называют систему, где модель сама решает, какой шаг сделать дальше. Так их разделяют Anthropic, OpenAI и LangGraph. Microsoft добавляет к этому лестницу сложности: прямой вызов модели, один агент с инструментами, несколько агентов. Совет у них простой: брать самый низкий уровень, который надёжно решает задачу. Если хватает хорошего промпта, агент не нужен.

Разделение по типам обращений Anthropic описывает как паттерн «роутинг» и приводит именно пример поддержки: общие вопросы, возвраты и техподдержка уходят в разные процессы со своими промптами и инструментами. Роутер сам по себе ещё не агент. Модель там делает одно решение и ни в каком цикле не крутится.

Внутри воркера теперь видно несколько компонентов. Это уровень Component в C4.

Уровень Component: что внутри воркера агентаУровень Component: что внутри воркера агента

C4, уровень 3. Пунктирной оранжевой рамкой выделены места, где решает модель; деньги двигают обычный код и сага.

# Псевдокод: одно сообщение клиента внутри workflowkind = activity(classify, message, model="small") # роутер: один вызовif kind == "faq": answer = activity(answer_from_kb, message) # один вызовelif kind == "where_is_order": status = activity(track_order_status, message.order) answer = activity(write_reply, status) # цепочкаelse: decision = agent_loop(message, tools=READ_ONLY_TOOLS) # агент check_rules(decision) # код: лимиты, порог if decision.amount > THRESHOLD: approved = wait_for_signal("operator_decision") # Signal из консоли if not approved: return hand_off_to_operator(decision) refund(decision) # сага

Обратите внимание на две детали. Агент получает только инструменты чтения: статус заказа, правила, историю клиента. Он предлагает решение, но денег не двигает. Деньги двигает сага, обычный код, после проверки правил и, при крупной сумме, одобрения оператора.

Вторая деталь это модель под задачу. OpenAI советует ставить маленькую модель на классификацию, а решение о возврате доверять сильной. Разница заметная: тысяча классификаций по 2 000 входных токенов стоит $0,23 на Claude Haiku 5.5 и $4,50 на Sonnet 5.5 по прайсу на конец октября 2026 года.

Шаг 6. Трейсы и outbox

Оператор открывает жалобу: «Агент отказал в возврате, хотя посылка не пришла». Почему он так решил? Какие инструменты вызвал, что они вернули, сколько стоило обращение? Без трейса ответов нет.

Добавляем сбор трейсов. Каждое обращение получает трейс, где видно каждый вызов модели и инструмента, токены, время и цену. Формат стандартный, OpenTelemetry с соглашениями для GenAI, о нём было во второй части. Я бы связывал трейс с Workflow ID: тогда из жалобы оператор переходит и к журналу Temporal, и к трейсу вызовов.

Последняя мелочь касается событий. После возврата CRM должна узнать, что заказ закрыт, а аналитика, что был возврат. Если воркер сначала обновит базу, а потом упадёт до отправки события в брокер, CRM никогда не узнает о возврате. Для этого есть паттерн transactional outbox. Событие пишется в таблицу outbox в той же транзакции, что и изменение заказа. Отдельный процесс читает таблицу и отправляет события в брокер. Запись и событие либо появятся обе, либо ни одна.

Итог: система поддержки целикомИтог: система поддержки целиком

C4, уровень 2, финальная версия. Номера на контейнерах показывают, где стоит каждый паттерн.

Что получилось

Агент из одного файла превратился в систему из десятка компонентов. Удобнее всего свести, какой паттерн где стоит и от чего защищает:

  • Workflow на обращение (Temporal): падение воркера не теряет ход обработки.

  • Workflow ID = id диалога: двойная отправка не создаёт второй обработчик.

  • Ретраи (политика activities в Temporal): короткие сбои модели и API.

  • Circuit breaker (LLM-шлюз, общее состояние в Redis): долгий отказ провайдера не забивает воркеры.

  • Ключ идемпотентности (каждое действие до внешней системы): повтор шага не возвращает деньги дважды.

  • Сага с точкой невозврата (код в workflow): сбой посреди возврата не оставляет систему наполовину.

  • Подтверждение оператором (Signal из консоли): крупный возврат не проходит без человека.

  • Роутер (цепочка на маленькой модели): агент работает только там, где без суждения никак.

  • Outbox (база приложения): событие не теряется между записью и отправкой.

  • Трейсы (OpenTelemetry): понятно, почему агент решил так и сколько это стоило.

Главное в этой схеме не список компонентов, а порядок их появления. Каждый появился, когда без него что-то ломалось. В маленьком проекте половина из них может не понадобиться ещё долго.

Чек-лист: какой компонент добавлять при какой боли

  1. Диалоги теряются при перезапуске: история в базу, вход через API.

  2. Непонятно, на каком шаге упала обработка: durable execution, workflow на обращение.

  3. Дубли от двойной отправки: Workflow ID из бизнес-идентификатора, Update-with-Start.

  4. Бизнес-данные просятся в Temporal: нет, они в базе приложения; длинным workflow Continue-As-New.

  5. Провайдер лёг, и воркеры повисли: LLM-шлюз с circuit breaker и запасной моделью, ошибка выключателя не ретраится.

  6. Действие в нескольких системах: сага, найдите точку невозврата, после неё только вперёд.

  7. Повтор шага опасен: ключ идемпотентности до внешней системы.

  8. Действие с деньгами: агент только предлагает, код проверяет, оператор одобряет крупное.

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

  10. Непонятно, почему агент так решил: трейсы, связанные с Workflow ID.

  11. События теряются между базой и брокером: outbox.

Агент, который отвечает «посылка не пришла, верните деньги», занимает в этой системе одну коробку из десяти. Остальные девять нужны не модели, а деньгам клиента и спокойствию оператора. Почти все они знакомы по обычному бэкенду. Новое только одно: внутри теперь есть компонент, который каждый раз решает по-разному. Вся архитектура вокруг него нужна, чтобы это не стало проблемой.

С какой коробки из этой схемы вы начали бы у себя, а какую выкинули бы?

Серия «AI-инженерия для тех, кто вкатывается»

Это цикл из трёх статей.

  1. «AI-инженерия для тех, кто вкатывается: агенты, MCP, токены и кэш с цифрами»

  2. «AI-агент в проде для тех, кто вкатывается: инструменты, RAG, надёжность и проверки»

  3. «Агентная система для тех, кто вкатывается: собираем агента поддержки от одного файла до Temporal, саги и circuit breaker» (вы читаете её сейчас)

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев