LLMタイムアウト問題をpolling非同期ジョブで解決した話

記事のリライトに LLM を叩いていた。

1記事 5,000字ほどを改善指示と合わせて Claude Sonnet に投げると、応答まで3〜5分かかる。フロントエンドから POST してそのまま待たせると、どこかでタイムアウトが発生して 500 が返ってくる。UI 上は「リライト失敗しました」のトースト。しかしサーバ側では実は成功していて、LLM の応答から DB 書き込みまでは完了している。

このミスマッチが UX 的に最悪だった。ユーザーは失敗と思ってもう一度ボタンを押す。2回目のリライトが走り、API 料金も 2 倍になる。

解決策は非同期ジョブ化で明らかだった。ただし「どう非同期化するか」の選択肢はいくつかある。今回は SSE ではなく polling を選んだ判断が地味に効いた。この記事はその判断の背景と、in-memory FIFO で実装した具体コードの記録だ。

タイムアウトの正体

段階的に発生するタイムアウト制限と複数システム層のボトルネック

内訳を追ったところ、タイムアウトは複数の層で発生していた。

  • Claude CLI 自体のタイムアウト: llmCliTimeoutMs = 300000(5分)がデフォルト
  • Hono サーバの request timeout: 明示的に設定していないが、reverse proxy(Cloudflare Tunnel)で 100秒程度
  • ブラウザの fetch timeout: 環境によって 30〜120秒

要するに、一番厳しい proxy / ブラウザ側の上限に引きずられて数分で切れる。LLM 応答が 4 分になった瞬間、UI からは成功に見えなくなる。

同期 vs 非同期の選択肢

一般論として、解決策は 3 つある。

1. 同期 HTTP を延命する

Keep-Alive を長く設定し、proxy timeout を上げ、fetch の AbortSignal を調整する方法だ。根本的な解決にはならない。LLM が 10 分応答しないケースも実際に起きる。スケールしない。

2. 非同期ジョブ + SSE(Server-Sent Events)で progress を push

クライアントが SSE で接続を張りっぱなしにして、サーバ側から progress event を流す方式。リアルタイム感はある。ただし複雑性が増す。コネクション管理・reconnect・proxy 越えの SSE には落とし穴が多い。

3. 非同期ジョブ + polling

サーバはジョブを受けて即 202 を返す。クライアントは N 秒ごとに /jobs/:id を叩いてステータスを確認する方式だ。実装はシンプルで、proxy / load balancer にも優しい。

content-factory では 3 の polling を選んだ。その理由を整理する。

なぜ SSE ではなく polling か

定期的にサーバーへリクエストを送信するポーリング方式によるデータ取得の仕組み

実際に踏んだ SSE の問題点を挙げる。

  • Cloudflare Tunnel / 一部の CDN では、idle connection が途中で切断される
  • EventSource は HTTP/1.1 ベースの運用が多く、HTTP/2 環境ではコネクション寿命が変わる
  • サーバ側のコネクション数が積み上がる
  • クライアントがリロードすると接続が切れ、reconnect ロジックが必要になる

逆に polling の利点はこうだ。

  • 普通の GET リクエストなので、どの proxy / load balancer でも透過する
  • サーバ側は状態を保持するだけで「送信」を意識しない
  • クライアントがリロードしても GET /jobs/active で実行中のジョブを再取得できる
  • 実装は useQueryrefetchInterval に数値を渡すだけ

2秒粒度のリアルタイム性で十分なユースケース(リライトの progress)では、polling の単純さが勝る。SSE が本領を発揮するのは、100ms 単位の更新が必要な画面共有やリアルタイム協調編集のようなケースだ。

実装

サーバ側は in-memory FIFO + maxConcurrency=1 の超軽量ジョブキューで実装した。

RewriteJobService

type RewriteJob = {
  id: string;                       // UUID
  articleId: number;
  status: "pending" | "running" | "completed" | "failed";
  instruction: string;
  result?: { updatedContent: string; improvedCount: number };
  error?: string;
  createdAt: number;
  startedAt?: number;
  completedAt?: number;
};

export class RewriteJobService {
  private jobs = new Map<string, RewriteJob>();
  private queue: string[] = [];
  private running = false;

  async enqueue(articleId: number, instruction: string): Promise<string> {
    const id = crypto.randomUUID();
    const job: RewriteJob = {
      id, articleId, status: "pending", instruction, createdAt: Date.now(),
    };
    this.jobs.set(id, job);
    this.queue.push(id);
    // 実行は fire-and-forget で走らせる
    void this.drain();
    return id;
  }

  findActiveByArticleId(articleId: number): RewriteJob | null {
    for (const job of this.jobs.values()) {
      if (job.articleId === articleId && (job.status === "pending" || job.status === "running")) {
        return job;
      }
    }
    return null;
  }

  getById(id: string): RewriteJob | null {
    return this.jobs.get(id) ?? null;
  }

  private async drain(): Promise<void> {
    if (this.running) return;
    this.running = true;
    try {
      while (this.queue.length > 0) {
        const id = this.queue.shift()!;
        const job = this.jobs.get(id);
        if (!job) continue;
        await this.runJob(job);
      }
    } finally {
      this.running = false;
    }
  }

  private async runJob(job: RewriteJob): Promise<void> {
    job.status = "running";
    job.startedAt = Date.now();
    try {
      const result = await this.rewriteService.execute(job.articleId, job.instruction);
      job.result = result;
      job.status = "completed";
    } catch (error) {
      job.error = error instanceof Error ? error.message : String(error);
      job.status = "failed";
    } finally {
      job.completedAt = Date.now();
    }
  }
}

設計上の 3 つのポイントを押さえておきたい。

  • in-memory Map + array queue: 1日数百件程度なら、過去ジョブがメモリに残っても問題ない
  • maxConcurrency=1: 並行実行を禁止することで、LLM API の rate limit と DB 書き込みの競合を避ける
  • void this.drain(): enqueue した瞬間にバックグラウンド処理を開始し、ユーザーには即 jobId を返す

3 つのエンドポイント

router.post("/articles/:id/rewrite", async (c) => {
  const articleId = parseIdParam(c.req.param("id"));
  const { instruction } = validateRequest(rewriteSchema, await c.req.json());
  const jobId = await rewriteJobService.enqueue(articleId, instruction);
  c.status(202);
  return c.json(successResponse({ jobId }));
});

router.get("/articles/:id/rewrite/active", (c) => {
  const articleId = parseIdParam(c.req.param("id"));
  const job = rewriteJobService.findActiveByArticleId(articleId);
  return c.json(successResponse(job));  // null or RewriteJob
});

router.get("/rewrite-jobs/:jobId", (c) => {
  const job = rewriteJobService.getById(c.req.param("jobId"));
  if (!job) throw new NotFoundError("rewrite_job");
  return c.json(successResponse(job));
});

役割は明確に分かれている。1つ目は ENQUEUE(202 で jobId を返す)、3つ目が polling の対象だ。

2つ目がとくに重要。ユーザーがリライト実行中にブラウザをリロードしても、GET /articles/:id/rewrite/active で現在走っているジョブを再取得できる。jobId を localStorage に保存する必要がない。SSE だと「reconnect して既存の状態を取り戻す」処理が必要になるが、polling + active-by-article なら不要だ。

クライアント側

クライアント側のリアルタイムポーリング実装をコーディングする開発環境

React Query の useQuery で polling を実装した。

function useRunRewrite(articleId: number) {
  const queryClient = useQueryClient();
  const [jobId, setJobId] = useState<string | null>(null);

  // アクティブジョブを最初に取得(リロード復旧)
  const { data: initialActiveJob } = useQuery({
    queryKey: ["rewrite-active", articleId],
    queryFn: () => articlesApi.getActiveRewriteJob(articleId),
    staleTime: 0,
  });

  useEffect(() => {
    if (initialActiveJob && !jobId) setJobId(initialActiveJob.id);
  }, [initialActiveJob]);

  // jobId が決まったら polling
  const { data: job } = useQuery({
    queryKey: ["rewrite-job", jobId],
    queryFn: () => articlesApi.getRewriteJob(jobId!),
    enabled: !!jobId,
    refetchInterval: (data) => {
      if (!data) return 2000;
      if (data.status === "running" || data.status === "pending") return 2000;
      return false;  // completed / failed で停止
    },
  });

  const mutate = useMutation({
    mutationFn: (instruction: string) =>
      articlesApi.enqueueRewrite(articleId, instruction),
    onSuccess: (res) => setJobId(res.jobId),
  });

  return { job, mutate };
}

refetchInterval に関数を渡すことで、実行中は 2 秒間隔・完了したら自動停止を 1 行で表現できる。完了後は job.result が埋まるので、そのまま UI 表示に使える。

アクティビティログで進行を見える化

システムの実行状況をリアルタイムで監視・記録するダッシュボード画面

ジョブの内部状態は polling で把握できる。ただし「全体として何が動いているか」もユーザーに伝えたい。左サイドバーに ActivityLogPanel を配置し、toast 通知を全量ミラーリングする形にした。

const useToasterStore = create<{ logs: LogEntry[]; push: (e: LogEntry) => void; clear: () => void }>(
  (set) => ({
    logs: [],
    push: (e) => set((state) => ({
      logs: [e, ...state.logs].slice(0, 100),
    })),
    clear: () => set({ logs: [] }),
  }),
);

// 既存の toast をラップ
toast.success = new Proxy(toast.success, {
  apply(target, thisArg, args) {
    useToasterStore.getState().push({
      level: "success",
      message: args[0],
      timestamp: Date.now(),
    });
    return Reflect.apply(target, thisArg, args);
  },
});

toast はすぐ消えるが、ActivityLogPanel には最大 100 件残る。「さっき出たメッセージはなんだっけ?」という状況をこれで解消できる。

得たもの / 反省

得たもの

  • 3〜5 分の LLM リライトが UX に響かなくなった: ユーザーはボタンを押した後すぐ別の操作に移り、ActivityLogPanel で状況を把握できる
  • リロード復旧が無料で手に入った: getActiveJob エンドポイントのおかげ
  • 実装量が驚くほど少ない: Service が 80行、ルートが 30行、フックが 40行ほど

反省

  • ジョブがプロセスメモリに載っている: サーバ再起動で pending / running ジョブが消える。小規模運用なら許容範囲だが、本番環境では SQLite か Redis に出すべきだ
  • polling コストはゼロではない: 2秒間隔で GET が飛ぶため、1ジョブあたり 4分 = 120 リクエスト。1日 100記事 × リライト 3回 = 約36,000回 / 日になる。1リクエストあたりの負荷は極小なのでサーバ的には許容範囲だが、把握しておきたい数字だ
  • progress の粒度が粗い: 「今 review 段階」「今 drafting 段階」といった細かい状態は出せない。LLM 応答が 1 回の HTTP で完結するため、中間 state がないからだ。SSE でも同じ制約があるが、LLM ストリーミング API 経由なら token 単位の progress を出せる余地はある

まとめ

非同期タスク処理のためのキューシステム

  • 長時間 LLM 処理には非同期ジョブ + polling で十分(SSE は多くの場合で過剰)
  • in-memory FIFO + maxConcurrency=1 でも個人運用レベルなら問題ない
  • /jobs/active でリロード時の状態復旧を追加コストなしで実現できる
  • React Query の refetchInterval が polling 実装を大きく単純化する
  • ActivityLogPanel で toast を全量ミラーすることで、ユーザーに全体の進行を伝える

SSE はエレガントな技術だが、proxy 越え・reconnect・LB 越しで必ず何かが起きる。個人開発で「数秒粒度の更新で十分」なユースケースなら、polling が圧倒的に素朴で壊れにくい。

同じ構成で LLM 系の長時間処理を抱えているなら、まず in-memory ジョブキュー + polling から始めることを勧める。足りなくなったら SSE / WebSocket / Redis Queue に移行すればいい。多くの個人プロジェクトは、その手前で十分に機能する。

コメント