Orchestrating Long-Running AI Workflows: Durable Execution, Checkpointing, and Human-in-the-Loop for AI Pipelines How token-aware rate limiting works for LLM APIs, why request-count limits fail, and implementation patterns across API gateways and proxy layers. 2026-10-13T12:00:00.000Z Deep Dives Deep Dives deep-divereferencearchitecture

Orchestrating Long-Running AI Workflows: Durable Execution, Checkpointing, and Human-in-the-Loop for AI Pipelines

How token-aware rate limiting works for LLM APIs, why request-count limits fail, and implementation patterns across API gateways and proxy layers.

The post you bookmark. One topic, covered end to end.

How token-aware rate limiting works for LLM APIs, why request-count limits fail, and implementation patterns across API gateways and proxy layers.

Orchestrating Long-Running AI Workflows: Durable Execution, Checkpointing, and Human-in-the-Loop for AI Pipelines

Every AI workflow that matters eventually outgrows a single request-response cycle. A document processing pipeline that extracts, classifies, summarizes, and routes content across multiple models. A code migration agent that analyzes a codebase over hours, proposes changes, waits for human approval, then applies them. A research workflow that spawns parallel searches, synthesizes results, and iterates until a quality threshold is met. These workflows run for minutes, hours, or days — and the standard approaches to building them (background jobs, task queues, cron-triggered scripts) fail in predictable and painful ways.

Durable execution frameworks solve this by treating workflow state as a first-class concern: every step is checkpointed, every failure is recoverable, and the orchestration logic reads like straightforward code rather than a state machine bolted onto a message broker.

Table of Contents

Why Traditional Job Queues Break for AI Workloads {#why-traditional-job-queues-break-for-ai-workloads}

A typical background job (Celery, Sidekiq, BullMQ) assumes tasks complete in seconds to low minutes. AI workflows violate this assumption in several ways:

Duration. A Claude Fable 5.1 agentic coding session can run for 30+ minutes. A multi-document research pipeline with iterative retrieval and synthesis can run for hours. Worker timeouts, connection limits, and deployment cycles all conspire against long-running tasks.

Non-determinism. The same LLM call with identical inputs can produce different outputs. A workflow that retries a failed step by replaying from the beginning may get different intermediate results, invalidating downstream state.

Cost of re-execution. If a 15-step pipeline fails on step 14 and the only recovery option is replaying from step 1, the cost — both in tokens and wall-clock time — is prohibitive. At $10/M input tokens for Fable 5.1 or GPT-6 Astra, re-executing 13 successful LLM calls because of one transient failure burns real money.

Branching and parallelism. AI workflows frequently fan out (run multiple models in parallel, search multiple sources concurrently) and fan in (aggregate results, pick the best output). Task queues can model this with chord/group primitives, but the coordination logic becomes brittle fast.

Human gates. Many production AI workflows require human approval at critical junctures — before applying code changes, before sending customer communications, before committing financial transactions. A workflow must be able to pause indefinitely, persist its state, and resume when a human acts.

Diagram

Traditional job queues and AI workflow requirements collide at predictable failure modes that durable execution addresses.

Durable Execution: The Core Abstraction {#durable-execution-the-core-abstraction}

Durable execution is a programming model where workflow code executes like normal sequential code, but the runtime transparently persists the result of each step. If the process crashes, the runtime replays the workflow from the last checkpoint, skipping steps whose results were already recorded.

The key insight: the workflow function itself is the source of truth for orchestration logic. No separate state machine definition, no DAG configuration file, no visual workflow builder. The code is the workflow.

Here is the mental model:

Diagram

The durable execution loop: code executes, the runtime persists step results, and on failure, replay skips already-completed steps.

Three properties make this work for AI:

  1. Deterministic replay. The runtime replays the workflow function but substitutes recorded results for completed steps instead of re-executing them. This means the workflow doesn’t re-call an LLM for a step that already succeeded — it uses the cached response.

  2. Indefinite duration. Workflows can sleep for hours or days (waiting for a human approval, for example) without consuming a worker thread. The state lives in durable storage, and the workflow resumes when its wake condition is met.

  3. Automatic retries with backoff. Each step can have its own retry policy. A rate-limited LLM API call (HTTP 429) retries with exponential backoff; a validation failure doesn’t retry at all.

The Framework Landscape: Temporal, Inngest, Trigger.dev, and Others {#the-framework-landscape}

FrameworkLanguage SupportHosting ModelState StorageAI-Specific FeaturesPricing Model
TemporalGo, Java, Python, TypeScript, .NETSelf-hosted or Temporal CloudCassandra, PostgreSQL, MySQLNone built-in; strong primitivesCloud: per-action pricing
InngestTypeScript, Python, GoManaged cloud or self-hostedBuilt-in (managed)AI middleware, streaming step supportFree tier + usage-based
Trigger.devTypeScriptManaged cloud or self-hostedBuilt-in (managed)AI SDK integrations, realtime logsFree tier + usage-based
RestateTypeScript, Java, Kotlin, Go, PythonSelf-hosted or Restate CloudRocksDB-backed embeddedVirtual objects for stateful agentsOpen source (BSL)
HatchetTypeScript, Python, GoSelf-hosted or managedPostgreSQLConcurrency control, rate limitingOpen source + managed

Temporal

The most mature durable execution framework. Temporal models workflows as functions that call activities (individual steps). Each activity execution is recorded in an event history. On replay, the workflow function re-executes, but activity calls return their previously recorded results instead of running again.

Temporal’s strength for AI workloads is its expressiveness: workflows are real code with loops, conditionals, and error handling. Its weakness is operational complexity — running Temporal requires a cluster (server + database + workers), and the learning curve for its replay semantics is steep.

# Temporal workflow for a multi-model document processing pipeline
from temporalio import workflow, activity
from datetime import timedelta

@activity.defn
async def extract_text(document_url: str) -> str:
    # OCR or PDF extraction
    ...

@activity.defn
async def classify_document(text: str) -> str:
    # LLM call to classify document type
    response = await anthropic.messages.create(
        model="claude-sonnet-5",
        messages=[{"role": "user", "content": f"Classify this document:\n{text[:4000]}"}]
    )
    return response.content[0].text

@activity.defn
async def summarize(text: str, doc_type: str) -> str:
    # LLM call with type-specific prompt
    ...

@activity.defn
async def route_document(summary: str, doc_type: str) -> str:
    # Determine destination based on classification
    ...

@workflow.defn
class DocumentPipeline:
    @workflow.run
    async def run(self, document_url: str) -> dict:
        text = await workflow.execute_activity(
            extract_text, document_url,
            start_to_close_timeout=timedelta(minutes=5),
            retry_policy=RetryPolicy(maximum_attempts=3)
        )
        
        doc_type = await workflow.execute_activity(
            classify_document, text,
            start_to_close_timeout=timedelta(minutes=2),
            retry_policy=RetryPolicy(maximum_attempts=3)
        )
        
        summary = await workflow.execute_activity(
            summarize, text, doc_type,
            start_to_close_timeout=timedelta(minutes=5),
        )
        
        destination = await workflow.execute_activity(
            route_document, summary, doc_type,
            start_to_close_timeout=timedelta(minutes=1),
        )
        
        return {"type": doc_type, "summary": summary, "routed_to": destination}

If the process crashes after classify_document completes but before summarize finishes, Temporal replays the workflow: extract_text and classify_document return their recorded results instantly, and summarize re-executes from scratch.

Inngest

Inngest takes a different approach: instead of replay-based determinism, it uses a step-function model where each step.run() call is a separate execution unit. The framework manages the execution graph, and each step’s result is persisted server-side.

Inngest has invested specifically in AI workflow primitives. Its step.ai namespace provides first-class support for LLM calls with automatic token tracking, and its streaming step support allows workflows to stream intermediate LLM output to clients while still checkpointing the final result.

// Inngest function for iterative research with quality gate
import { inngest } from "./client";

export const researchWorkflow = inngest.createFunction(
  { id: "research-pipeline", retries: 2 },
  { event: "research/started" },
  async ({ event, step }) => {
    const query = event.data.query;
    
    // Step 1: Generate search queries
    const searchQueries = await step.run("generate-queries", async () => {
      const response = await openai.chat.completions.create({
        model: "gpt-5.6-terra",
        messages: [{ role: "user", content: `Generate 5 search queries for: ${query}` }],
      });
      return JSON.parse(response.choices[0].message.content);
    });
    
    // Step 2: Execute searches in parallel
    const searchResults = await Promise.all(
      searchQueries.map((q: string, i: number) =>
        step.run(`search-${i}`, async () => {
          return await searchAPI.search(q);
        })
      )
    );
    
    // Step 3: Synthesize with quality scoring
    let synthesis = await step.run("synthesize", async () => {
      const response = await anthropic.messages.create({
        model: "claude-sonnet-5",
        messages: [{ role: "user", content: `Synthesize: ${JSON.stringify(searchResults)}` }],
      });
      return response.content[0].text;
    });
    
    // Step 4: Quality check — may loop
    const quality = await step.run("quality-check", async () => {
      const response = await openai.chat.completions.create({
        model: "gpt-5.6-terra",
        messages: [{ role: "user", content: `Rate quality 1-10: ${synthesis}` }],
      });
      return parseInt(response.choices[0].message.content);
    });
    
    if (quality < 7) {
      // Wait for human review instead of auto-iterating indefinitely
      const approval = await step.waitForEvent("human-review", {
        event: "research/reviewed",
        timeout: "24h",
      });
      if (approval?.data.action === "retry") {
        // Re-trigger with refined query
        await step.sendEvent("retry-research", {
          name: "research/started",
          data: { query: approval.data.refinedQuery },
        });
      }
    }
    
    return { synthesis, quality };
  }
);

Trigger.dev

Trigger.dev focuses on TypeScript-first developer experience with strong integration into the Vercel/Next.js ecosystem. Its v3 architecture runs tasks in long-lived cloud workers with no timeout limits, making it suitable for extended AI operations. It provides real-time log streaming during task execution, which is valuable for debugging multi-step AI pipelines.

Restate

Restate introduces “virtual objects” — durable, addressable entities with methods that execute with exactly-once semantics. This maps well to stateful AI agents: each agent is a virtual object that maintains conversation history, tool state, and execution progress across arbitrarily many interactions.

Diagram

The durable execution landscape: each framework trades off operational complexity against AI-specific ergonomics.

Workflow Patterns for AI Pipelines {#workflow-patterns-for-ai-pipelines}

Pattern 1: Sequential Chain with Typed Checkpoints

The simplest pattern. Each LLM call is a checkpointed step, and the output type flows into the next step. If any step fails, only that step re-executes.

Diagram

Sequential chain: each step checkpointed, failure at any point only re-executes from that step.

Pattern 2: Fan-Out / Fan-In with Model Routing

Send the same input to multiple models in parallel, then aggregate results. This is common for consensus-based workflows (e.g., three models classify a document independently, and the majority vote wins) or for best-of-N generation.

// Fan-out to multiple models, fan-in with voting
const classifications = await Promise.all([
  step.run("classify-claude", () => 
    classify(text, "claude-sonnet-5")),
  step.run("classify-gpt", () => 
    classify(text, "gpt-5.6-terra")),
  step.run("classify-gemini", () => 
    classify(text, "gemini-3.8-flash")),
]);

const finalClass = await step.run("vote", () => 
  majorityVote(classifications)
);

Each step.run is independently checkpointed. If the GPT call fails and retries, the Claude and Gemini results are already persisted and won’t re-execute.

Pattern 3: Iterative Refinement with Budget Caps

An LLM generates output, a quality evaluator scores it, and if the score is below threshold, the workflow loops with feedback. A budget cap prevents infinite iteration.

@workflow.defn
class RefinementWorkflow:
    @workflow.run
    async def run(self, task: str) -> dict:
        budget_remaining = 500_000  # token budget
        attempt = 0
        max_attempts = 5
        
        while attempt < max_attempts and budget_remaining > 0:
            result = await workflow.execute_activity(
                generate, task, attempt,
                start_to_close_timeout=timedelta(minutes=10),
            )
            budget_remaining -= result["tokens_used"]
            
            score = await workflow.execute_activity(
                evaluate, result["output"],
                start_to_close_timeout=timedelta(minutes=2),
            )
            budget_remaining -= score["tokens_used"]
            
            if score["value"] >= 8:
                return {"output": result["output"], "score": score["value"], 
                        "attempts": attempt + 1}
            
            task = f"{task}\n\nPrevious attempt scored {score['value']}/10. "
                   f"Feedback: {score['feedback']}"
            attempt += 1
        
        return {"output": result["output"], "score": score["value"],
                "attempts": attempt, "budget_exhausted": budget_remaining <= 0}

Pattern 4: Supervisor with Delegated Sub-Workflows

A supervisor workflow analyzes the task, breaks it into sub-tasks, spawns child workflows for each, monitors their progress, and synthesizes results. This maps to the supervisor agent orchestration pattern but with durable execution guarantees.

Diagram

Supervisor pattern: the parent workflow spawns child workflows and aggregates their results, all with durable checkpointing.

Each child workflow runs independently with its own retry policies and checkpoints. If Worker B fails after 20 minutes, Workers A and C continue unaffected, and Worker B retries from its last checkpoint.

Pattern 5: Event-Driven Pipeline with External Triggers

Workflows that react to external events — a webhook from a CI system, a Slack message, a database change — and coordinate AI processing in response. The workflow starts, processes some steps, then pauses waiting for an external signal.

// Trigger.dev: code review workflow that waits for CI
export const codeReviewTask = task({
  id: "ai-code-review",
  run: async (payload: { prUrl: string }) => {
    const diff = await fetchPRDiff(payload.prUrl);
    
    const review = await generateReview(diff);
    await postReviewComment(payload.prUrl, review);
    
    // Wait for CI to complete (up to 2 hours)
    const ciResult = await waitForEvent({
      event: `ci/completed/${payload.prUrl}`,
      timeout: "2h",
    });
    
    if (ciResult.status === "failed") {
      const diagnosis = await diagnoseCIFailure(ciResult.logs, diff);
      await postReviewComment(payload.prUrl, diagnosis);
    }
    
    return { review, ciResult };
  },
});

Checkpointing LLM State {#checkpointing-llm-state}

Checkpointing LLM calls requires deciding what to persist and at what granularity. There are tradeoffs at each level.

What to Checkpoint

LevelPersisted DataRecovery CapabilityStorage Cost
Response onlyFinal LLM output textCan skip the call on replayLow
Full responseOutput + token counts + model + finish reasonCan skip call + audit trailLow-medium
Request + responseFull prompt + full responseCan debug, compare, replay to different modelMedium-high
Streaming chunksEach SSE chunk as it arrivesCan resume mid-streamHigh

For most production AI workflows, persisting the full response object (including token usage) is the right default. It enables cost tracking, debugging, and audit trails without the storage overhead of persisting every request payload.

Persisting request payloads makes sense when: (a) prompts are dynamically generated and hard to reproduce, (b) regulatory requirements demand full audit trails, or (c) the workflow needs the ability to retry a failed step against a different model using the same prompt.

Streaming with Checkpoints

Long LLM responses — especially from models with 128K output limits like Fable 5.1 or GPT-6 Astra — can take minutes to complete. Streaming the response to the user while also checkpointing the final result requires careful coordination.

// Inngest: stream to client while checkpointing final result
const analysis = await step.run("long-analysis", async () => {
  const stream = await anthropic.messages.create({
    model: "claude-fable-5.1",
    max_tokens: 32000,
    stream: true,
    messages: [{ role: "user", content: analysisPrompt }],
  });
  
  let fullResponse = "";
  for await (const event of stream) {
    if (event.type === "content_block_delta") {
      fullResponse += event.delta.text;
      // Emit to client via SSE or WebSocket
      await emitProgress({ partial: event.delta.text });
    }
  }
  
  // The return value is what gets checkpointed
  return fullResponse;
});

The workflow framework checkpoints the final fullResponse string. If the process restarts after this step completed, replay returns the checkpointed string without re-calling the LLM. The streaming was a side effect that doesn’t need to be replayed.

Conversation History as Workflow State

For multi-turn agent workflows, the conversation history grows with each step. Rather than storing the full history in each checkpoint, store it once and reference it by ID.

Diagram

Conversation history stored in an append-only log, referenced by ID in workflow checkpoints, avoiding duplication.

Human-in-the-Loop Patterns {#human-in-the-loop-patterns}

Production AI workflows frequently need human judgment at specific points. Durable execution frameworks handle this naturally because a workflow can pause for an arbitrarily long time and resume when a signal arrives.

Approval Gates

The most common pattern. The workflow reaches a decision point, notifies a human, and waits for approval.

# Temporal: human approval for generated content
@workflow.defn
class ContentGenerationWorkflow:
    def __init__(self):
        self._approval_result = None
    
    @workflow.signal
    async def approve(self, decision: dict):
        self._approval_result = decision
    
    @workflow.run
    async def run(self, brief: str) -> dict:
        draft = await workflow.execute_activity(
            generate_content, brief,
            start_to_close_timeout=timedelta(minutes=10),
        )
        
        # Notify human reviewer
        await workflow.execute_activity(
            send_review_notification, draft,
            start_to_close_timeout=timedelta(seconds=30),
        )
        
        # Wait for approval signal — can be hours or days
        await workflow.wait_condition(
            lambda: self._approval_result is not None,
            timeout=timedelta(days=7),
        )
        
        if self._approval_result is None:
            return {"status": "timed_out"}
        
        if self._approval_result["approved"]:
            await workflow.execute_activity(
                publish_content, draft,
                start_to_close_timeout=timedelta(minutes=1),
            )
            return {"status": "published", "content": draft}
        else:
            # Re-generate with feedback
            revised = await workflow.execute_activity(
                generate_content, 
                f"{brief}\n\nFeedback: {self._approval_result['feedback']}",
                start_to_close_timeout=timedelta(minutes=10),
            )
            return {"status": "revised", "content": revised}

The workflow is consuming zero compute resources while waiting for the human signal. The state lives in Temporal’s persistence layer (Cassandra or PostgreSQL), and the workflow resumes on the next available worker when the signal arrives.

Escalation Chains

When a human doesn’t respond within a timeout, escalate to the next person or fall back to an automated decision.

Diagram

Escalation chain: the workflow automatically escalates unanswered approvals, with a final automated fallback for low-risk decisions.

Collaborative Editing

The AI generates a draft, a human edits it, the AI refines based on the edits, and the cycle repeats. This pattern is common in Claude Cowork-style workflows where the AI is a persistent collaborator.

const collaborativeEdit = inngest.createFunction(
  { id: "collaborative-doc" },
  { event: "doc/started" },
  async ({ event, step }) => {
    let document = await step.run("initial-draft", () =>
      generateDraft(event.data.brief)
    );
    
    for (let round = 0; round < 5; round++) {
      // Send draft to human, wait for edits
      await step.run(`notify-${round}`, () =>
        sendForReview(document, event.data.reviewerEmail)
      );
      
      const edits = await step.waitForEvent(`edits-${round}`, {
        event: "doc/edited",
        match: "data.docId",
        timeout: "48h",
      });
      
      if (!edits || edits.data.action === "approve") break;
      
      // AI refines based on human edits
      document = await step.run(`refine-${round}`, () =>
        refineDocument(document, edits.data.changes, edits.data.comments)
      );
    }
    
    return { finalDocument: document };
  }
);

Error Handling and Retry Strategies for LLM Calls {#error-handling-and-retry-strategies}

LLM API calls fail in distinct categories, and each category needs a different retry strategy.

Error TypeHTTP StatusRetry?Strategy
Rate limited429YesExponential backoff, respect Retry-After header
Server error500, 502, 503YesExponential backoff, max 3-5 attempts
Context length exceeded400No (or with modification)Truncate input, switch to longer-context model
Content filtered400 (safety)MaybeRephrase prompt, or escalate to human
Authentication401, 403NoFix credentials, alert ops
Timeout—YesIncrease timeout, switch to faster model
Malformed output200 (but unparseable)YesRetry with stricter prompt, add JSON mode

Model Fallback Chains

When a primary model fails consistently, fall back to an alternative. This is especially important for availability — no single provider has 100% uptime.

@activity.defn
async def llm_call_with_fallback(prompt: str, task_type: str) -> dict:
    models = [
        {"provider": "anthropic", "model": "claude-sonnet-5", "timeout": 60},
        {"provider": "openai", "model": "gpt-5.6-terra", "timeout": 60},
        {"provider": "google", "model": "gemini-3.8-flash", "timeout": 45},
    ]
    
    last_error = None
    for model_config in models:
        try:
            result = await call_model(prompt, model_config)
            return {
                "output": result.text,
                "model_used": model_config["model"],
                "tokens": result.usage,
            }
        except RateLimitError:
            last_error = "rate_limited"
            continue
        except ServerError:
            last_error = "server_error"
            continue
        except ContentFilterError as e:
            # Don't fallback for safety blocks — they're likely 
            # to trigger on other models too
            raise ApplicationError(f"Content filtered: {e}", non_retryable=True)
    
    raise ApplicationError(f"All models failed. Last error: {last_error}")
Diagram

Model fallback chain: each model is tried in sequence, with non-retryable errors (content filtering) short-circuiting the chain.

Parsing Retries

LLM structured output — even with JSON mode enabled — sometimes fails to parse. A common pattern is to retry with a more explicit prompt before giving up.

const result = await step.run("extract-entities", async () => {
  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await openai.chat.completions.create({
      model: "gpt-5.6-terra",
      response_format: { type: "json_object" },
      messages: [{ 
        role: "user", 
        content: attempt === 0 
          ? basePrompt 
          : `${basePrompt}\n\nYou MUST respond with valid JSON matching this schema: ${schema}. Previous attempt failed to parse.`
      }],
    });
    
    try {
      return JSON.parse(response.choices[0].message.content);
    } catch (e) {
      if (attempt === 2) throw new Error("Failed to parse after 3 attempts");
    }
  }
});

Cost Control in Long-Running Workflows {#cost-control-in-long-running-workflows}

Long-running AI workflows can accumulate substantial costs, especially with iterative patterns that loop unpredictably. Durable execution frameworks provide the infrastructure to implement cost controls as first-class workflow logic.

Token Budgets

Assign each workflow run a token budget. Track cumulative usage across steps and halt execution when the budget is exhausted.

@workflow.defn
class BudgetedWorkflow:
    def __init__(self):
        self._total_input_tokens = 0
        self._total_output_tokens = 0
        self._budget_usd = 0.0
        self._max_budget_usd = 5.0  # $5 per run
    
    def _track_cost(self, usage: dict, model: str):
        # Pricing per 1M tokens (input/output)
        pricing = {
            "claude-sonnet-5": (2.0, 10.0),
            "gpt-5.6-terra": (2.0, 12.0),
            "gemini-3.8-flash": (0.75, 3.75),
        }
        input_rate, output_rate = pricing.get(model, (5.0, 15.0))
        cost = (usage["input"] * input_rate + usage["output"] * output_rate) / 1_000_000
        self._budget_usd += cost
        self._total_input_tokens += usage["input"]
        self._total_output_tokens += usage["output"]
    
    def _check_budget(self):
        if self._budget_usd >= self._max_budget_usd:
            raise ApplicationError(
                f"Budget exhausted: ${self._budget_usd:.2f} / ${self._max_budget_usd:.2f}",
                non_retryable=True,
            )

Model Tiering Within Workflows

Use expensive models only for steps that need them. Route simple classification to Gemini 3.8 Flash at $0.75/M input (introductory pricing through December 31, 2026; $1.50/M thereafter), use Claude Sonnet 5 for nuanced analysis, and reserve Fable 5.1 or GPT-6 Astra for complex reasoning steps.

Prompt Caching

For workflows that make multiple LLM calls with overlapping context (e.g., the same system prompt or document), prompt caching reduces costs. Anthropic charges $0.25/M for cache reads on Fable 5.1 (vs. $10/M for uncached input). OpenAI’s GPT-6 Astra charges cached input at a discount as well.

Structure workflows to maximize cache hits by keeping the static context (system prompt, reference documents) at the front of the message array and varying only the final user message across steps.

Observability for AI Workflows {#observability-for-ai-workflows}

Standard workflow observability (step durations, success/failure rates) is necessary but insufficient for AI pipelines. AI-specific metrics include:

Token usage per step and per run. Track cumulative input/output tokens across all steps, broken down by model. This feeds into cost dashboards and budget alerts.

Output quality scores. If the workflow includes evaluation steps (quality scoring, validation), these scores should be tracked as time-series metrics to detect drift.

Model latency by provider. LLM API latency varies by provider, model, time of day, and input length. Track P50/P95/P99 latency per model per step to inform model routing decisions.

Retry rates by error type. A spike in 429 retries indicates approaching rate limits. A spike in parse failures suggests a model behavior change.

Diagram

AI workflow observability: workflow engines emit standard traces plus AI-specific metrics (tokens, quality, cost) to specialized dashboards.

Integration with LLM observability tools is straightforward. Langfuse, Helicone, and Braintrust all accept OpenTelemetry spans or custom trace data. Wrap each LLM call in a trace that captures the model, prompt hash, token usage, latency, and the workflow run ID for correlation.

# Wrapping an LLM activity with observability
@activity.defn
async def llm_with_tracing(prompt: str, model: str) -> dict:
    span = tracer.start_span("llm_call", attributes={
        "model": model,
        "prompt_hash": hashlib.sha256(prompt.encode()).hexdigest()[:16],
        "workflow_run_id": activity.info().workflow_run_id,
    })
    
    start = time.monotonic()
    try:
        response = await call_model(prompt, model)
        latency = time.monotonic() - start
        
        span.set_attributes({
            "input_tokens": response.usage.input_tokens,
            "output_tokens": response.usage.output_tokens,
            "latency_ms": int(latency * 1000),
            "finish_reason": response.stop_reason,
        })
        
        return {
            "output": response.content[0].text,
            "usage": {
                "input": response.usage.input_tokens,
                "output": response.usage.output_tokens,
            },
            "model": model,
            "latency_ms": int(latency * 1000),
        }
    except Exception as e:
        span.set_status(StatusCode.ERROR, str(e))
        raise
    finally:
        span.end()

Production Architecture {#production-architecture}

A production AI workflow system has several components beyond the workflow engine itself.

Diagram

Production architecture: the API layer triggers workflows, the engine dispatches to workers, workers call LLMs through a gateway, and the engine persists state.

Worker Sizing

AI workflow workers are I/O-bound (waiting on LLM API responses), not CPU-bound. A single worker can handle many concurrent workflow executions. Size worker pools based on concurrent LLM calls, not CPU cores.

For Temporal, this means tuning max_concurrent_activities and max_concurrent_workflow_tasks. A worker with max_concurrent_activities=50 can have 50 LLM API calls in flight simultaneously, which is usually plenty.

Deployment Considerations

Worker versioning. When deploying new workflow code, existing in-flight workflows are running the old version. Temporal handles this with task queue versioning — old workflows complete on old workers, new workflows start on new workers. Inngest and Trigger.dev handle this at the platform level.

Idempotency. Every step that has side effects (sending an email, posting a Slack message, writing to a database) must be idempotent, because replays and retries can re-execute steps. Use idempotency keys derived from the workflow run ID and step name.

Secrets rotation. Long-running workflows may outlive API key rotations. Store secrets in a vault (AWS Secrets Manager, HashiCorp Vault) and fetch them at activity execution time, not at workflow start time. This way, a workflow that runs for days picks up rotated credentials automatically.

When to Use Which Framework

Temporal is the right choice for teams that need maximum control over execution semantics, have polyglot codebases (Go, Java, Python, TypeScript), and can absorb the operational overhead of running a Temporal cluster (or paying for Temporal Cloud). It’s the most battle-tested option for mission-critical workflows.

Inngest is the right choice for TypeScript/Python teams that want managed infrastructure, AI-specific primitives, and a lower operational bar. Its step-function model is easier to reason about than Temporal’s replay semantics, at the cost of less flexibility.

Trigger.dev is the right choice for TypeScript-only teams already in the Vercel ecosystem that want tight integration with their existing deployment pipeline.

Restate is the right choice for teams building stateful agent systems where the “virtual object” abstraction maps naturally to their domain model. Its embedded state (no external database required for state) reduces operational complexity.

Hatchet is worth evaluating for teams that need fine-grained concurrency control and rate limiting at the workflow engine level, particularly for high-throughput AI pipelines that need to respect per-model token-per-minute limits.

Summary {#summary}

Long-running AI workflows need durable execution because LLM calls are expensive to re-execute, AI pipelines run for minutes to days, and human-in-the-loop patterns require indefinite pauses. The core abstraction is straightforward: write sequential workflow code, let the runtime checkpoint each step’s result, and on failure, replay skips already-completed steps.

Key decisions for production deployments:

  • Checkpoint granularity. Persist full LLM response objects (including token usage) as the default. Add request payloads when audit or model-switching flexibility justifies the storage cost.
  • Retry policies. Differentiate by error type: exponential backoff for rate limits and server errors, no retry for content filtering, parsing retries with stricter prompts for malformed output.
  • Model fallback. Chain multiple providers for availability. Route by task complexity to control costs.
  • Human gates. Use the framework’s signal/wait primitives to pause workflows indefinitely for human decisions. Implement escalation chains with timeouts.
  • Cost controls. Token budgets per workflow run, model tiering within workflows, and prompt caching across steps.
  • Observability. Track tokens, cost, latency, and quality scores per step and per run. Correlate with workflow run IDs for end-to-end debugging.

The gap between “AI demo” and “AI in production” is largely an orchestration gap. Durable execution frameworks close it by making the hard problems — state persistence, failure recovery, human coordination, cost control — infrastructure concerns rather than application code concerns.

Further Reading {#further-reading}