LLM記事生成を並列化:5分→1分以下の実装と文脈品質の保ち方

ブログ記事を LLM に書かせる時、記事全体を 1 回で生成する か セクションごとに分けて生成する かで分岐がある。

  • 1 回で生成: 文脈は繋がるがトークン数が巨大になり、長文になるほど各セクションが短くなりがち。Gemini の 1M context 以外だと収まらないこともある
  • セクション別生成: 各セクションに focus できる。ただし順番や文脈連続性をどうするかが問題

content-factory ではセクション別生成を選んだ。で、次に出てくるのが「セクション生成を並列化していいか」という話。5 セクションを逐次で生成すると 3〜4 分、並列化すれば 1 分以下になる。

結論から言うと 並列化はできるが、文脈連続性とのトレードオフ になる。この記事では逐次 / 並列の実装差、previousSectionTail で文脈を繋ぐ逐次モードの工夫、1〜5 並列で切り替えられる実装を書く。

逐次モードの基本形

複数ステップが順番に繋がるプロセスフローの図解

LLM に各セクションを順番に書かせる。前のセクションの末尾を次のセクションに渡して連続性を保つ:

async generateBodySequential(h2Sections: Section[]): Promise<string> {
  const bodyParts: string[] = [];
  let previousSectionTail: string | null = null;

  for (const section of h2Sections) {
    const content = await this.generateSectionContent(
      section,
      mainKeyword,
      previousSectionTail,  // ← 前セクション末尾 200 字
    );
    bodyParts.push(content);
    previousSectionTail = content.slice(-200);
  }

  return bodyParts.join("\n\n");
}

ポイントは previousSectionTail = content.slice(-200)。セクション末尾の 200 字を次のプロンプトに挿入する:

前セクション末尾:
{previousSectionTail ?? "(最初のセクション)"}

---

続くセクション「{section.heading}」を書いてください。

これで LLM は「さっき何を書いたか」を意識してブリッジ(「前述の通り〜」「これを踏まえて〜」)を自然に入れられる。

文脈連続性は高い。ただし 5 セクション × 1 セクションあたり 30〜60 秒 = 2.5〜5 分 かかる。体感としては遅い。

並列モードの実装

前セクション末尾の参照を 諦めて、Promise.all でバッチ並列:

async generateBodyParallel(h2Sections: Section[], concurrency: number): Promise<string> {
  const bodyParts: string[] = new Array(h2Sections.length).fill("");

  for (let i = 0; i < h2Sections.length; i += concurrency) {
    const batch = h2Sections.slice(i, i + concurrency);
    const results = await Promise.all(
      batch.map((section) =>
        this.generateSectionContent(
          section,
          mainKeyword,
          null,  // ← previousSectionTail 無し
        ),
      ),
    );
    results.forEach((content, j) => {
      bodyParts[i + j] = content;
    });
  }

  return bodyParts.join("\n\n");
}

ポイント:

  • 配列 index で位置を保持: bodyParts[i + j] = content で並行実行しても最終的な並び順は維持
  • バッチ単位: 5 セクションを 2 並列なら [0,1] → [2,3] → [4] の順でバッチ実行
  • previousSectionTail は null: 参照できないので諦める

5 セクションを 3 並列にすると 1 セクションあたり約 45 秒 → 実時間 90 秒 まで短縮できる。ほぼ 3 倍速。

並列の代償

パズルのピースがばらばらに配置された状態、セクション間の断絶を表現

並列モードで書かれた記事を読むと、だいたい 10〜20% くらい「違和感」が出る:

  • 各セクションが完結しすぎて、記事全体の流れが断絶する
  • 同じ例え・同じ固有名詞が複数セクションに重複して出る(LLM が知らないので)
  • 主張の粒度が揃わない(あるセクションは結論的、別のセクションは問題提起的、みたいな不揃い)

逐次モードと比べると、文章としての「ひと続き感」 が落ちる。

これを後工程でどう補正するか、あるいは「そこまでの品質は要らない」と割り切るかの判断が要る。

統合モードの切り替え

content-factory では ユーザーが 1〜5 で並列度を切り替えできる 設計:

type LLMSettings = {
  parallelSections: number;  // 1 = 逐次, 2〜5 = 並列
  // ...
};

UI にはスライダーを置いた。デフォルトは 1(逐次)。ユーザーが「速度優先」と思えば 3 にする、みたいな運用。

実装側はエントリポイントで分岐:

private async generateBody(
  h2Sections: Section[],
  concurrency: number,
  // ...
): Promise<string> {
  const effectiveConcurrency = Math.max(1, Math.min(5, Math.floor(concurrency)));

  if (effectiveConcurrency <= 1) {
    return this.generateBodySequential(h2Sections);
  }
  return this.generateBodyParallel(h2Sections, effectiveConcurrency);
}

Math.max(1, Math.min(5, ...)) で 1〜5 にクランプ。ユーザーが 100 とかを設定しても LLM rate limit で詰まるだけなので上限を 5 に設定。

Rate limit との兼ね合い

ネットワークのボトルネック現象により流量が制限される様子を示す概念図

並列度を上げると当然 LLM API の rate limit に当たりやすくなる。content-factory で使っている主要モデルの実測 / 公称値は以下:

モデル RPM(公称 or 体感) 体感の限界並列
Gemini 2.5 Flash(free tier) 5 RPM(AI Studio 公称) 2〜3
Gemini 2.5 Flash(paid tier) 1000 RPM 5+
Codex CLI(subscription) 公称値なし、体感では軽負荷時 3〜5 req/分程度 1〜3
Claude Sonnet(CLI subscription) 公称値なし、体感では連続 2〜3 回でも詰まることあり 1〜2

Claude Sonnet CLI で並列 3 以上にすると体感で詰まることが多い。その場合は withRetry(指数バックオフ)で透過的に吸収するか、並列度を下げるか、別モデルに逃すかの選択になる。

content-factory では withRetry を挟んであるので 1 回の rate limit hit は透過的に吸収される。でも連続して詰まると maxRetries 超過で失敗する。その場合は部分失敗が発生する。

部分失敗時の補償

エラー発生時の全体リセットと再試行メカニズム

並列で 5 セクション生成、1 セクションが rate limit で失敗した場合どうするか。

現状の content-factory は 全体が throw して全部失敗 にしている(atomicity 優先)。理由:

  1. 部分的に生成された記事を保存すると「どこから欠けているか」のフラグ管理が要る
  2. ユーザーから見て「5 セクションのうち 1 つ欠けた下書き」より「エラーで全部やり直し」のほうが mental model がシンプル
  3. 失敗時のコストは「LLM 呼び出し 5 回分」。1 記事あたり数円なので許容

より本格的には「失敗したセクションだけリトライして差し込み」も可能だが、複雑性が跳ね上がるので採用しなかった。個人開発だと「全部やり直し」の素朴さが勝つ。

実運用の設定感

実際にユーザー(俺)はどう設定しているか:

  • デフォルト運用: parallelSections: 1(逐次)。文脈連続性を取る
  • 速度優先のテスト記事: parallelSections: 3。品質はやや落ちるが 3 倍速く出る
  • 長文記事(8 セクション以上): parallelSections: 2。完全並列だと文脈が崩れすぎるので、半分並列で妥協

トレードオフを理解した上で、記事のタイプ別に並列度を変える のが実務的。

まとめ

複数の処理が並行実行されるパイプラインシステム

  • LLM でセクション別生成するなら、逐次 / 並列の選択肢がある
  • 逐次は文脈連続性が高い が時間がかかる。previousSectionTail で前セクション末尾を渡すと自然な繋ぎになる
  • 並列は速い が文脈が独立する。重複表現や主張のブレが増える
  • Promise.all + バッチ単位の concurrency で実装、Math.max(1, Math.min(5, ...)) でクランプ
  • Rate limit を考慮して上限を 5 にする。Claude 系は 1〜2 並列が無難
  • ユーザーが切り替えられる設計 にすると、記事タイプ別の最適化が効く
  • 部分失敗は全体 throw で素朴に扱う(個人運用なら許容)

同じ悩みを持っている人(LLM で長文を分割生成したいが速度が気になる)は、逐次をデフォルト + 並列オプション という二段構えがおすすめ。毎回全力並列にすると「書き直しが増えて結局時間かかってた」状況になりやすい。

並列度を自分で設定できるようにすれば、記事によって「速度を取るか質を取るか」を都度選べる。5 並列固定より遥かに実用的。

コメント