0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでHedged Requestsを実装する:LLM/STT APIの尾遅延を面接で説明する

0
Posted at

背景資料: https://aceround.app/blog/ja/backend-developer-interview-ai

この記事では、バックエンド面接でよく出る「外部 API のレスポンスが突然遅くなったらどうするか」という話を、Hedged Requests の実装問題として切り出します。LLM API、STT API、検索 API のように尾遅延がユーザー体験へ直撃する処理では、単純な retry より Hedged Requests のほうが説明しやすい場面があります。

Hedged Requests の概要

結論

Hedged Requests は、最初のリクエストが一定時間返らない場合だけ、同じ論理リクエストを別経路へ投げる方式です。先に成功したレスポンスを採用し、残りは AbortController でキャンセルします。

ポイントは 3 つです。

  • retry は「失敗してから再試行」しますが、hedge は「遅いだけの処理」にも効きます。
  • 全リクエストを常に二重化するのではなく、p95/p99 付近だけに追加コストを払います。
  • 副作用のある POST、課金処理、在庫更新などにはそのまま使ってはいけません。使うなら idempotency key が必要です。

面接で説明するなら、まずこう言えば十分です。

p50 は速いが p99 が悪い外部依存に対して、閾値を超えた時だけバックアップリクエストを出し、最初の成功を採用して残りをキャンセルします。追加トラフィックと尾遅延のトレードオフを取る設計です。

いつ使うべきか

Hedged Requests が向いているのは、以下の条件を満たす処理です。

  • 読み取り、検索、生成、文字起こしなど、同じ入力に対して複数回呼んでも破壊的な副作用がない
  • p50 は十分速いが、たまに極端に遅い
  • timeout まで待つより、少し余分なリクエストを払ってでも UX を守りたい
  • 呼び出し先がキャンセルを受け取れる、または少なくともクライアント側で結果を破棄できる

LLM/STT のリアルタイム処理だと、平均 latency よりも p95/p99 の悪化が目立ちます。面接中の音声認識やリアルタイム回答生成では、1 回の 400ms スパイクでも会話のテンポが崩れます。そこで「80ms 返らなければ backup を開始する」のような設計が候補になります。

最小実装

以下は依存なしで動く TypeScript 実装です。Bun でも Node.js 22 でも動きます。

type Attempt<T> = (attempt: number, signal: AbortSignal) => Promise<T>;

class HedgeTimeoutError extends Error {
  constructor(readonly attempt: number, readonly timeoutMs: number) {
    super(`attempt ${attempt} timed out after ${timeoutMs}ms`);
  }
}

async function hedgedRequest<T>({
  attempt,
  hedgeAfterMs,
  timeoutMs,
  maxAttempts = 2,
}: {
  attempt: Attempt<T>;
  hedgeAfterMs: number;
  timeoutMs: number;
  maxAttempts?: number;
}): Promise<{ value: T; winner: number; hedged: boolean }> {
  const controllers: AbortController[] = [];
  const errors: unknown[] = [];

  let launched = 0;
  let pending = 0;
  let settled = false;
  let hedgeTimer: ReturnType<typeof setTimeout> | undefined;

  return new Promise((resolve, reject) => {
    const clearEverything = () => {
      if (hedgeTimer) clearTimeout(hedgeTimer);
      for (const controller of controllers) {
        if (!controller.signal.aborted) controller.abort();
      }
    };

    const launch = () => {
      const current = launched++;
      pending++;

      const controller = new AbortController();
      controllers[current] = controller;

      const timeout = setTimeout(() => {
        controller.abort(new HedgeTimeoutError(current, timeoutMs));
      }, timeoutMs);

      attempt(current, controller.signal)
        .then((value) => {
          if (settled) return;
          settled = true;
          clearTimeout(timeout);
          clearEverything();
          resolve({ value, winner: current, hedged: launched > 1 });
        })
        .catch((err) => {
          clearTimeout(timeout);
          if (settled) return;

          errors.push(err);
          pending--;

          if (launched < maxAttempts) {
            launch();
            return;
          }

          if (pending === 0) {
            settled = true;
            clearEverything();
            reject(new AggregateError(errors, "all hedged attempts failed"));
          }
        });
    };

    launch();
    hedgeTimer = setTimeout(() => {
      if (!settled && launched < maxAttempts) launch();
    }, hedgeAfterMs);
  });
}

attempt は attempt 番号を受け取ります。0 なら primary、1 なら backup provider、というように呼び分けられます。

デモ用の疑似 provider

次に、たまに 400ms 台まで遅くなる primary provider と、安定して 90ms 前後で返る backup provider を作ります。

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        reject(signal.reason ?? new Error("aborted"));
      },
      { once: true },
    );
  });
}

async function fakeProvider(
  name: string,
  latencyMs: number,
  signal: AbortSignal,
): Promise<string> {
  await sleep(latencyMs, signal);
  return `${name}:${latencyMs}ms`;
}

async function main() {
  const primaryLatencies = [42, 44, 430, 43, 45, 410, 44, 46, 420, 43];
  const normal: number[] = [];
  const hedged: number[] = [];
  const winners: Record<number, number> = {};

  for (const primaryLatency of primaryLatencies) {
    const normalStart = performance.now();
    await fakeProvider("primary", primaryLatency, new AbortController().signal);
    normal.push(Math.round(performance.now() - normalStart));

    const hedgedStart = performance.now();
    const result = await hedgedRequest({
      hedgeAfterMs: 80,
      timeoutMs: 700,
      attempt: (attemptNo, signal) => {
        const latency = attemptNo === 0 ? primaryLatency : 90;
        const name = attemptNo === 0 ? "primary" : "backup";
        return fakeProvider(name, latency, signal);
      },
    });
    hedged.push(Math.round(performance.now() - hedgedStart));
    winners[result.winner] = (winners[result.winner] ?? 0) + 1;
  }

  const p95 = (values: number[]) => values.toSorted((a, b) => a - b)[
    Math.ceil(values.length * 0.95) - 1
  ];

  console.log({
    normal,
    hedged,
    normalP95Ms: p95(normal),
    hedgedP95Ms: p95(hedged),
    winners,
  });
}

await main();

実行例です。

bun run hedged-request-demo.ts
{
  normal: [ 43, 45, 430, 44, 45, 410, 44, 47, 421, 43 ],
  hedged: [ 44, 45, 172, 44, 46, 172, 45, 47, 171, 44 ],
  normalP95Ms: 430,
  hedgedP95Ms: 172,
  winners: { "0": 7, "1": 3 }
}

primary が普通に速い 7 回では primary が勝ちます。primary が 400ms 台まで遅い 3 回だけ、80ms 後に起動した backup が勝ちます。常に二重リクエストを出すのではなく、遅くなりそうな時だけ追加コストを払うのが重要です。

retry との違い

retry は失敗に強くするための仕組みです。たとえば HTTP 500、ネットワークエラー、timeout の後に再試行します。

Hedged Requests は尾遅延に強くするための仕組みです。失敗していなくても、「まだ返ってこない」時点で backup を走らせます。

観点 retry hedged request
起動条件 失敗後 一定時間返らない時
主目的 可用性 p95/p99 latency 改善
コスト 失敗時だけ増える 遅い成功時にも増える
危険な用途 副作用あり処理 副作用あり処理、課金処理

実運用では両方を混ぜることもあります。ただし、retry + hedge はリクエスト数が急増しやすいので、上限、rate limit、circuit breaker を一緒に設計します。

面接で聞かれやすいトレードオフ

1. hedgeAfterMs をどう決めるか

固定値で始めるなら、直近の p90 から p95 あたりを使います。p50 に近すぎると backup が出すぎます。p99 に近すぎると効果が薄くなります。

実サービスなら、endpoint ごとに rolling percentile を持たせ、以下のように調整します。

  • 通常時: p95 を hedge threshold にする
  • コストが高い provider: p98 まで遅らせる
  • リアルタイム UI: p90 まで早める

2. backup は同じ provider でよいか

同じ provider に同じリクエストを投げるだけでも、キュー詰まりや局所的な slow worker には効くことがあります。ただし、provider 全体が遅い場合は効きません。

LLM/STT なら、以下のように分ける設計が現実的です。

  • primary: 品質またはコストが最適な provider
  • backup: 少し高いが安定した provider
  • fallback: 品質を落としても timeout を避ける provider

3. キャンセルできない処理はどうするか

AbortController はクライアント側の待機を止められますが、相手側の計算が本当に止まるかは API 次第です。キャンセルが伝わらない provider では、負荷と課金だけが残る可能性があります。

その場合は、hedge を使う前に以下を確認します。

  • API が abort signal または request cancellation をサポートしているか
  • 生成済みの結果を破棄しても課金されるか
  • 同じ入力を複数回送って規約や rate limit に引っかからないか

4. 副作用はどう扱うか

副作用のある処理に素の Hedged Requests を使うのは危険です。たとえば、決済 API に同じ POST を 2 回送る設計は論外です。

どうしても使うなら、先に idempotency key を設計します。

Idempotency-Key: user_123:order_456:submit_v1

サーバー側がこの key を見て、「同じ論理操作は 1 回だけ実行する」と保証できて初めて hedge の議論に進めます。

本番に入れる時のチェックリスト

Hedged Requests を入れるなら、実装より観測のほうが重要です。

  • hedge_started_total: backup を起動した回数
  • hedge_won_total: backup が勝った回数
  • hedge_extra_cost_estimate: 追加リクエストのコスト
  • primary_aborted_total: primary をキャンセルした回数
  • latency_p50/p95/p99: 導入前後の percentile
  • provider 別の error rate と timeout rate

特に hedge_started_total が急に増えた場合、外部 provider の劣化だけでなく、自分たちの threshold 設計ミスも疑います。

まとめ

Hedged Requests は派手なアルゴリズムではありません。しかし、システム設計面接ではかなり説明しやすいテーマです。

  • p99 latency を下げるため、遅い時だけ backup を出す
  • 最初の成功を採用し、残りをキャンセルする
  • 追加トラフィック、課金、副作用、idempotency を必ず説明する
  • retry、timeout、circuit breaker と役割を分けて話す

「外部 API がたまに遅いです。どうしますか?」と聞かれた時、timeout を長くするだけでは弱いです。Hedged Requests まで説明できると、単なる実装者ではなく、本番 latency とコストのトレードオフを見ているエンジニアとして伝わります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?