golang

HTTP-запрос закончился, а LLM ещё работает: как проектировать AI-задачи на Go

  • вторник, 13 октября 2026 г. в 00:00:04
https://habr.com/ru/articles/1093482/

Когда пишешь обычный HTTP API на Go, использование context.Context быстро становится привычкой.

Пришёл запрос, получили r.Context(), передали его в сервис, затем в репозиторий и во все внешние вызовы. Если клиент отключился или истёк таймаут, связанные операции отменяются.

Для большинства backend-задач это именно то поведение, которого мы хотим.

Зачем продолжать выполнять SQL-запрос, если результат уже некому возвращать? Зачем держать соединение с внешним API, если пользователь закрыл страницу?

Но когда в систему добавляется LLM, эта привычная логика начинает работать не всегда так, как ожидается.

Представим SaaS, который анализирует документы. Пользователь загружает PDF, нажимает кнопку и ждёт результат. Backend извлекает текст, отправляет его модели, получает структурированный ответ и сохраняет результат анализа.

Пока всё укладывается в один HTTP-запрос, архитектура выглядит совершенно нормально.

Проблемы начинаются, когда пользователь закрывает вкладку через пять секунд после отправки документа.

Что теперь должно произойти с задачей?

Почему обычный HTTP context здесь не всегда подходит

Начнём с простого примера.

func (h *Handler) Analyze(
    w http.ResponseWriter,
    r *http.Request,
) {
    result, err := h.analyzer.Run(
        r.Context(),
        r.Body,
    )

    if err != nil {
        http.Error(
            w,
            "analysis failed",
            http.StatusInternalServerError,
        )
        return
    }

    json.NewEncoder(w).Encode(result)
}

Код упрощён, но сама идея знакомая.

Handler получает запрос и передаёт его context в сервис. Сервис использует тот же context для обращения к провайдеру LLM.

В Go контекст входящего серверного запроса отменяется, когда закрывается соединение клиента, отменяется HTTP/2-запрос или завершается ServeHTTP. Это стандартное поведение net/http. Go Packages

Теперь представим последовательность событий:

12:00:00  POST /api/analyze

12:00:01  Backend отправил запрос LLM

12:00:05  Пользователь закрыл вкладку

12:00:05  r.Context() отменён

12:00:06  Backend прекращает ожидание ответа

На уровне Go всё выглядит правильно.

Контекст отменён. Внешний HTTP-запрос, если клиентская библиотека корректно поддерживает cancellation, должен завершиться.

Но здесь появляется вопрос: а что произошло на стороне LLM-провайдера?

Ответ зависит от конкретного API и того, на каком этапе была отменена операция.

Возможно, провайдер тоже прекратил генерацию.

Возможно, запрос уже начал выполняться и часть токенов была потрачена.

Возможно, результат был сформирован, но backend его не получил.

Из локального context.Canceled нельзя достоверно определить, какое из этих событий произошло.

Именно здесь возникает проблема.

Мы отменили ожидание ответа, но не обязательно отменили работу или расходы во внешней системе.

Отмена HTTP-запроса не равна отмене бизнес-операции

Мне кажется, это главное различие, которое стоит учитывать при проектировании AI-сервисов.

В обычном CRUD API пользователь отправляет запрос, backend выполняет небольшую операцию и возвращает результат.

Даже если соединение прервалось, часто можно просто повторить запрос или проверить состояние ресурса.

В AI-приложении ситуация сложнее.

Предположим, пользователь запустил анализ договора.

На выполнение уходит 40 секунд. За это время система обращается к модели, получает результат, проверяет его и сохраняет.

Если пользователь случайно обновил страницу на двадцатой секунде, должен ли анализ прекратиться?

Я бы ответил: не обязательно.

Если пользователь явно нажал «Отменить анализ», это одно событие.

Если браузер потерял соединение, это совершенно другое.

Но если вся работа привязана к r.Context(), мы фактически приравниваем эти события.

Получается, что транспортный уровень начинает управлять жизненным циклом бизнес-операции.

Для простых коротких запросов это нормально.

Для длительной платной AI-задачи такое поведение уже стоит пересмотреть.

Почему goroutine не решает проблему

Первое решение, которое приходит в голову, выглядит примерно так:

go func() {
    ctx := context.Background()

    result, err := analyzer.Run(ctx, input)

    if err != nil {
        log.Error("analysis failed", "error", err)
        return
    }

    repository.Save(ctx, result)
}()

Теперь работа не зависит от HTTP context.

Пользователь может закрыть вкладку, а goroutine продолжит выполнять анализ.

Выглядит как решение.

Но мы просто обменяли одну проблему на несколько других.

Во-первых, что произойдёт при перезапуске приложения?

Если контейнер остановился, goroutine исчезнет вместе с процессом.

Во-вторых, где хранится информация о запущенной операции?

Как пользователь узнает, что задача ещё выполняется? Как избежать её повторного запуска? Как восстановить обработку после сбоя?

В-третьих, как ограничить количество таких goroutine?

Если пользователи одновременно запустят несколько сотен анализов, приложение может создать столько же конкурентных обращений к модели.

И наконец, кто отвечает за retry, лимиты, отмену и учёт стоимости?

Goroutine сама по себе не является системой выполнения фоновых задач.

Она всего лишь позволяет запустить код конкурентно.

А что насчёт context.WithoutCancel?

В Go есть более аккуратный способ отвязать context от родительской отмены.

Начиная с Go 1.21 доступен context.WithoutCancel.

ctx := context.WithoutCancel(r.Context())

Полученный context сохраняет значения родительского контекста, но не наследует его отмену и deadline. Сам по себе он не имеет механизма автоматической отмены. Go Packages

Для некоторых коротких операций это полезно.

Например, когда нужно гарантировать попытку отправки небольшого audit-события после завершения HTTP-запроса.

Но использовать WithoutCancel как универсальное решение для LLM-задач я бы не стал.

Он не гарантирует сохранность работы при перезапуске процесса. Не предоставляет retry, не ограничивает concurrency и не создаёт устойчивого состояния задачи.

Кроме того, вместе с context можно сохранить request-scoped значения, хотя для фонового выполнения часто лучше явно передать необходимые данные: идентификатор пользователя, организации и trace ID.

И главное, если отвязать долгую работу от отмены, ей всё равно нужен собственный timeout.

ctx, cancel := context.WithTimeout(
    context.WithoutCancel(r.Context()),
    90*time.Second,
)
defer cancel()

Это уже безопаснее с точки зрения ограничения времени выполнения, но проблема надёжного восстановления никуда не делась.

Для длительных бизнес-операций я бы предпочёл вообще не создавать рабочий context из HTTP context.

AI-анализ как отдельная сущность

На мой взгляд, более надёжная модель начинается с довольно простого изменения.

Нужно перестать считать анализ документа продолжением HTTP-запроса.

HTTP-запрос лишь создаёт задачу.

Сама задача существует отдельно.

Архитектура становится такой:

              HTTP API
                 |
                 v
           Create AI Job
                 |
                 v
             PostgreSQL
                 |
                 v
               Worker
                 |
                 v
             LLM API
                 |
                 v
          Validate Result
                 |
                 v
            Save Result

Внешний API может выглядеть так:

POST /api/ai/jobs
Idempotency-Key: 1b7c4e...

HTTP/1.1 202 Accepted

Ответ:

{
  "job_id": "8c2f...",
  "status": "queued"
}

После этого frontend может получать состояние через:

GET /api/ai/jobs/8c2f...

Теперь закрытие вкладки больше не означает автоматическую потерю задачи.

Пользователь может вернуться позже и получить уже готовый результат.

При этом 202 Accepted означает, что система приняла запрос к обработке, а не гарантирует успешное выполнение.

Настоящая гарантия начинается с того, что запись о задаче надёжно сохранена.

Можно ли обойтись одним PostgreSQL?

Для небольшого AI SaaS я бы не спешил сразу добавлять Kafka, RabbitMQ или отдельную систему оркестрации.

Если приложение уже использует PostgreSQL, на первом этапе его может быть вполне достаточно.

Например, можно создать таблицу:

CREATE TABLE ai_jobs (
    id UUID PRIMARY KEY,

    tenant_id UUID NOT NULL,

    idempotency_key TEXT NOT NULL,
    request_hash BYTEA NOT NULL,

    status TEXT NOT NULL,

    payload JSONB NOT NULL,
    result JSONB,

    attempts INTEGER NOT NULL DEFAULT 0,

    run_token UUID,
    lease_until TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    UNIQUE (tenant_id, idempotency_key)
);

Здесь интересны не столько сами поля, сколько их назначение.

status показывает состояние задачи.

attempts позволяет ограничивать количество повторных выполнений.

lease_until помогает обнаруживать зависшие задачи.

run_token нужен, чтобы старый worker не мог перезаписать результат нового запуска.

А idempotency_key защищает от повторного создания одной и той же операции.

Payload здесь показан как JSONB для простоты. В реальном сервисе я бы отдельно продумал хранение документов, сроки удаления и доступ к чувствительным данным.

Почему idempotency здесь особенно важна

Представим, что frontend отправил запрос на создание AI-задачи.

Backend сохранил её в PostgreSQL.

Но соединение оборвалось до того, как клиент получил ответ.

Пользователь нажимает кнопку повторно.

Без идемпотентности приложение может создать вторую задачу и повторно отправить тот же документ модели.

В результате мы получим два анализа и потенциально две платные операции.

При этом пользователь хотел выполнить только одну.

Поэтому клиент должен передавать стабильный Idempotency-Key, который не меняется при повторной отправке одной логической операции.

Backend проверяет пару:

tenant_id + idempotency_key

Если задача уже существует, возвращает её идентификатор и текущее состояние.

Важно учитывать ещё одну деталь.

Один и тот же ключ не должен позволять подменять параметры запроса.

Поэтому полезно хранить fingerprint исходных данных.

Если ключ прежний, а содержимое запроса другое, корректнее вернуть конфликт, например HTTP 409.

Иначе идемпотентность постепенно превратится в источник труднообъяснимых ошибок.

Как несколько worker могут забирать задачи

Для PostgreSQL есть довольно удобный механизм: FOR UPDATE SKIP LOCKED.

Он позволяет нескольким worker выбирать доступные строки, не ожидая освобождения уже заблокированных другими worker задач.

PostgreSQL прямо отмечает применимость этого механизма для queue-like таблиц, хотя для обычных согласованных выборок такое поведение не подходит. PostgreSQL

Упрощённый запрос на получение задачи может выглядеть так:

WITH picked AS (
    SELECT id
    FROM ai_jobs
    WHERE (
        status = 'queued'
        OR (
            status = 'running'
            AND lease_until < now()
        )
    )
    AND attempts < 3
    ORDER BY created_at, id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE ai_jobs AS j
SET
    status = 'running',
    attempts = j.attempts + 1,
    run_token = gen_random_uuid(),
    lease_until = now() + interval '3 minutes'
FROM picked
WHERE j.id = picked.id
RETURNING
    j.id,
    j.run_token,
    j.payload;

Это лишь часть реализации. Для полноценной очереди понадобятся обработка окончательно исчерпанных попыток, backoff, индексы и ограничения конкурентности.

Но сама схема уже решает важную проблему.

Несколько worker могут безопасно забирать разные задачи.

При этом блокировка строки удерживается только в рамках короткой транзакции получения задачи.

Мы не держим открытую транзакцию PostgreSQL все 40 секунд, пока работает LLM.

После получения задания worker выполняет внешний вызов самостоятельно.

У worker должен быть собственный context

И вот здесь снова появляется context.Context.

Только теперь он управляет не HTTP-запросом, а конкретной попыткой выполнения фоновой задачи.

Например:

func (w *Worker) process(
    workerCtx context.Context,
    job Job,
) error {
    ctx, cancel := context.WithTimeout(
        workerCtx,
        90*time.Second,
    )
    defer cancel()

    result, err := w.llm.Generate(
        ctx,
        job.Payload,
    )

    if err != nil {
        return err
    }

    return w.repository.Complete(
        ctx,
        job.ID,
        job.RunToken,
        result,
    )
}

Этот код показывает идею, а не готовый production worker.

В частности, запись результата лучше выполнять с отдельным ограниченным context, когда нужно сохранить состояние после завершения основной попытки. Кроме того, здесь не показаны продление lease и обработка shutdown.

Но главное различие уже видно.

HTTP context отвечает за HTTP-запрос.

Worker context отвечает за конкретную попытку выполнения.

А жизненный цикл самой задачи хранится в PostgreSQL.

Теперь cancellation одного соединения не уничтожает состояние бизнес-операции.

Что произойдёт при падении worker?

Предположим, worker получил задачу и установил:

status = running

lease_until = 12:03:00

После этого он отправил запрос модели.

Через 20 секунд контейнер завершился.

Никакого callback от него уже не будет.

Но после истечения lease другой worker сможет подобрать задачу и повторить выполнение.

Так появляется механизм восстановления после сбоя.

Здесь, однако, есть тонкость.

Старый worker может не умереть, а просто очень долго выполнять запрос.

Например, он потерял соединение с базой, но продолжает ожидать ответ внешнего провайдера.

В это время lease истёк, и другой worker уже забрал задачу.

Теперь оба потенциально могут попытаться сохранить результат.

Именно поэтому нужен run_token.

Завершение задачи должно выполняться условно:

UPDATE ai_jobs
SET
    status = 'succeeded',
    result = $3,
    run_token = NULL,
    lease_until = NULL
WHERE id = $1
  AND run_token = $2
  AND status = 'running';

Если задача уже принадлежит другому worker, UPDATE не изменит ни одной строки.

Старый запуск не сможет перезаписать результат нового.

Для долгих операций lease обычно нужно периодически продлевать, причём только при совпадении актуального run_token.

Но даже эта схема не решает одну из самых неприятных проблем AI-интеграций.

Exactly once заканчивается на границе внешнего API

Представим ситуацию.

Worker отправил запрос LLM.

Модель выполнила работу.

Провайдер отправил результат.

Worker получил ответ, но упал до сохранения результата в PostgreSQL.

После истечения lease другой worker повторит задачу.

На уровне нашей базы всё выглядит корректно.

Там по-прежнему одна задача.

Но на стороне провайдера могли выполниться уже два обращения.

И если оба вызова тарифицировались, затраты тоже могут оказаться двойными.

Это стандартная проблема распределённых систем: локальная транзакция PostgreSQL не может атомарно зафиксировать выполнение внешнего LLM API.

Фактически у нас получается at-least-once execution с защитой от повторного принятия результата.

Ровно одно выполнение внешнего вызова мы не гарантируем.

Если конкретный провайдер поддерживает идемпотентность на своей стороне для нужного endpoint, это может помочь. Но такую гарантию необходимо проверять отдельно, а не предполагать по аналогии с другими API.

И здесь важно не обещать пользователю или бизнесу больше, чем система действительно обеспечивает.

Одна сохранённая задача не обязательно означает один оплаченный запрос к модели.

В AI SaaS нужно разделять две стоимости

Мне кажется, здесь полезно различать две сущности.

Первая — стоимость выполнения операций у LLM-провайдера.

Вторая — стоимость, которую мы списываем с пользователя внутри своего продукта.

Это не обязательно одно и то же.

Если провайдер выполнил две попытки из-за сбоя worker, мы можем заплатить за обе.

Но пользователь при этом не должен автоматически получать два списания за один заказанный анализ.

Поэтому я бы разделял учёт на уровне:

AI Job
   |
   +-- Attempt 1
   |      provider_cost
   |      failed / unknown
   |
   +-- Attempt 2
          provider_cost
          succeeded

А пользовательское списание связывал с логической бизнес-операцией и заранее определённой тарифной политикой.

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

При этом стоит хранить историю попыток отдельно.

Иначе позже будет трудно объяснить, почему расход по LLM API вырос, хотя число завершённых пользовательских задач почти не изменилось.

Особенно неприятны случаи с неопределённым исходом.

Когда worker получил timeout, мы не всегда можем достоверно сказать, сколько токенов фактически потратил провайдер.

Поэтому unknown иногда честнее, чем failed_without_cost.

Почему нельзя просто поставить retry на любую ошибку

После появления очереди возникает соблазн сделать универсальное правило:

error → retry

Но не каждая ошибка означает, что нужно повторять запрос.

Временная недоступность провайдера и некорректный пользовательский документ требуют разной обработки.

Если модель постоянно получает payload, который не проходит обязательную проверку, три повторных обращения обычно не сделают проблему лучше.

Если же провайдер вернул временную ошибку доступности, повторная попытка может быть оправдана.

Отдельно стоит относиться к timeout после отправки запроса провайдеру.

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

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

Для дорогих задач автоматический retry вообще может иметь отдельную политику.

И это ещё одна причина, почему выполнение AI-задачи лучше держать в явном orchestration-слое, а не прятать в HTTP handler.

А как тогда показывать генерацию пользователю?

На этом месте обычно появляется возражение.

Для некоторых AI-приложений важен streaming.

Пользователь хочет видеть ответ сразу, а не ждать, пока задача завершится в фоне.

Но streaming и жизненный цикл задачи не обязательно должны быть одним и тем же.

Можно сохранить асинхронную модель, а передачу событий организовать отдельно.

Например:

POST /api/ai/jobs
        |
        v
   Job created
        |
        v
   Background worker
        |
        +----> Result storage
        |
        +----> Event stream
                    |
                    v
                Frontend

Frontend может подписываться на события выполнения через SSE.

Если соединение прервётся, подписка завершится.

Но сама задача продолжит выполняться согласно своей политике.

Пользователь откроет страницу заново, получит текущее состояние и, при необходимости, восстановит просмотр результата.

Правда, здесь тоже есть технический нюанс.

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

SSE сам по себе не делает поток долговечным.

И для небольшого сервиса я бы сначала реализовал сохранение итогового результата и обычный polling.

Streaming добавлял бы тогда, когда он действительно нужен продукту.

Отмена пользователем должна быть отдельной операцией

Теперь вернёмся к ситуации с закрытой вкладкой.

В описанной архитектуре потеря соединения больше не отменяет задачу автоматически.

Но пользователю всё равно может понадобиться кнопка «Отменить».

Для этого лучше иметь явный endpoint:

POST /api/ai/jobs/{id}/cancel

Он не должен просто искать какой-то CancelFunc в памяти одного инстанса приложения.

Лучше зафиксировать намерение отмены в состоянии задачи.

Например:

cancel_requested = true

Worker периодически проверяет это состояние и прекращает работу, когда это допустимо.

Если LLM-вызов уже выполняется, worker может попробовать отменить исходящий запрос.

Но и здесь нельзя обещать, что провайдер обязательно прекратит генерацию или не выставит счёт.

Поэтому на уровне продукта стоит различать отмену пользовательской задачи и успешную отмену внешнего исполнения.

Это разные события.

Какие метрики здесь действительно интересны

После перехода к фоновым AI-задачам обычных HTTP-метрик становится недостаточно.

Latency у endpoint создания задачи может быть 30 миллисекунд.

Но это ничего не говорит о том, что пользователь получил результат только через минуту.

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

Ещё важнее видеть случаи восстановления после истечения lease и ситуации с неизвестным исходом внешнего вызова.

Для AI SaaS появляется интересная связка метрик:

jobs_created

jobs_accepted

job_retry_rate

lease_expired_count

provider_attempts

provider_unknown_outcomes

cost_per_accepted_job

С последней метрикой мы снова возвращаемся к экономике продукта.

Если стоимость одного вызова модели уменьшилась, но число повторных попыток выросло, итоговая стоимость готового результата может вообще не улучшиться.

Поэтому сравнивать провайдеров только по цене токенов здесь тоже недостаточно.

Нужно ли так усложнять каждый AI endpoint?

Нет.

Если приложение генерирует короткий ответ за пару секунд и этот ответ не нужно сохранять, обычный синхронный HTTP-вызов вполне оправдан.

Пользователь закрыл соединение, операция отменена. Это ожидаемое поведение.

Я бы не создавал durable job для каждого обращения к модели только потому, что внутри используется LLM.

Граница появляется тогда, когда операция обладает хотя бы несколькими свойствами: она длительная, дорогостоящая, результат нужно сохранить, пользователь может вернуться к нему позже или повторное выполнение способно создать проблемы.

Иными словами, важно не то, используется ли AI.

Важно, является ли операция самостоятельной бизнес-задачей.

Что в итоге

В привычной модели Go backend мы часто связываем время жизни работы с временем жизни HTTP-запроса.

Это хорошая модель для множества обычных API.

Но у длительной AI-операции может быть собственный жизненный цикл, собственная стоимость и собственные правила восстановления.

В таком случае я бы разделял три уровня.

HTTP-запрос отвечает за создание задачи и получение её состояния.

Worker отвечает за конкретную попытку выполнения.

А база данных хранит состояние бизнес-операции независимо от обоих.

Тогда context.Context остаётся полезным инструментом для управления временем выполнения и отменой конкретных операций.

Просто он перестаёт быть единственным источником истины о существовании задачи.

На мой взгляд, самое интересное в production AI именно здесь.

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

То есть к обычным backend-проблемам.

Только теперь ошибка retry может стоить не нескольких лишних миллисекунд, а вполне реальных денег.

А как вы обрабатываете долгие LLM-операции: оставляете их внутри HTTP request или сразу выносите в отдельные jobs? Особенно интересно, как решаете случаи, когда провайдер уже выполнил запрос, а worker не успел сохранить результат.