LLM記事生成をステートマシンで品質向上|対話型フロー設計

プログラミング

ブログ記事を LLM に書かせる運用を始めて、初期に全部ハマったのが「雑に投げると雑な記事が返ってくる」問題。

「Claude Code の使い方について記事書いて」とだけ投げると、LLM は「Claude Code とは…基本的な使い方…応用例…まとめ」みたいな誰でも書ける記事を出してくる。読者の想定が違うから刺さらない、主張が浅いからオリジナリティが無い、結果として公開しない / 大幅リライトで時間を食う。

答えは明らかで、事前に要件を詰める 必要がある。ただ、毎回手でテーマ / 想定読者 / 主張 / 制約を書き出すのも面倒。

そこで content-factory では LLM と対話しながら要件を順番に聞き出す「インタラクティブ記事生成」フロー を作った。6 ヶ月で 30 本以上の記事生成を運用した経験から、state machine で focus を遷移させる実装の詳細を書く。

やりたかったこと

ユーザーが「記事書きたい」と思ったら:

  1. 「テーマは?」と聞かれる
  2. 答える
  3. 「想定読者は?」と聞かれる
  4. 答える
  5. 「伝えたいことは?」と聞かれる
  6. 答える
  7. 「素材メモや参考リンクは?」と聞かれる(あればペースト、無ければ「無し」で OK)
  8. 「他に制約ある?(文字数・トーン・避けたい表現など)」
  9. 「では以下の内容で記事を作成します」と要約して確認
  10. OK を出すと draft 生成が走る

各ステップで LLM が文脈に応じた質問を生成し、ユーザーの自由記述を受け取る。自由度は残しつつ、欠けている要件は必ず埋める。

Stateless な API 設計

ステートレスなAPI設計でクライアント・サーバ間がリクエスト・レスポンスを行う通信フロー図

素朴に実装すると「セッションオブジェクトをサーバに持つ」形になる。でも:

  • サーバ再起動で session が消える
  • ユーザーがリロードすると状態ロスト
  • 複数タブで同じフローを進めたいとき競合する

解決策は state を全部クライアント側に持たせて、毎リクエストで丸ごと送り返す:

// POST /articles/interactive/reply
type InteractiveReply = {
  state: InteractiveState | null;       // 初回は null
  userMessage: string;
  articleType?: ArticleType;
};

type InteractiveState = {
  articleType: ArticleType;
  answers: {
    theme?: string;
    audience?: string;
    goal?: string;
    sourceNotes?: string;
    constraints?: string;
  };
  currentFocus: "theme" | "audience" | "goal" | "source_notes" | "constraints" | "confirm";
  turnCount: number;
  isReadyForDraft: boolean;
  suggestedTitle?: string;
};

サーバ側のメモリには何も残さない。stateless。各ターンで state を受け取り、更新して返すだけ。

フロントエンドは localStorage に state を保存する。リロードしても継続できる。Custom GPTs と異なり、state をクライアントで完全制御できるためリロード耐性・マルチタブ対応が可能 という大きな利点がある。サーバ側には履歴を残さず、ユーザーが明示的に「作成」を選ぶまで draft も生成されない。

State Machine の構造

5 つの focus + 1 つの確認フェーズ:

theme → audience → goal → source_notes → constraints → confirm → ready

getNextInteractiveFocus で次に聞くべき項目を決める:

function getNextInteractiveFocus(answers): InteractiveFocus {
  if (!answers.theme?.trim())       return "theme";
  if (!answers.audience?.trim())    return "audience";
  if (!answers.goal?.trim())        return "goal";
  if (!answers.sourceNotes?.trim()) return "source_notes";
  if (!answers.constraints?.trim()) return "constraints";
  return "confirm";
}

シンプルな「先頭から未入力項目を探す」だけ。ユーザーが戻って編集したときもこの関数で再計算すれば次の focus が決まる。

ユーザー回答の適用

ユーザー回答を集約するインタラクティブフォーム

applyInteractiveAnswer が state の answers を更新:

function applyInteractiveAnswer(state, userMessage): InteractiveState {
  const trimmed = userMessage.trim();
  if (!trimmed) return state;

  const answers = { ...state.answers };

  switch (state.currentFocus) {
    case "theme":        answers.theme = trimmed; break;
    case "audience":     answers.audience = trimmed; break;
    case "goal":         answers.goal = trimmed; break;
    case "source_notes": answers.sourceNotes = trimmed; break;
    case "constraints":  answers.constraints = trimmed; break;

    case "confirm": {
      // "OK", "了解", "進めて" 等は肯定、それ以外は追加要望として constraints に追記
      const isAffirmative = /^(ok|OK|了解|お願いします|進めて|この内容で|これで|作成して)/.test(trimmed);
      if (!isAffirmative) {
        answers.constraints = [answers.constraints, `追加要望: ${trimmed}`]
          .filter((v): v is string => Boolean(v?.trim()))
          .join("\n");
      }
      break;
    }
  }

  return { ...state, answers, turnCount: state.turnCount + 1 };
}

ポイント:

  • 空入力は状態を変えない: 誤クリック対策
  • confirm フェーズの曖昧な返事は「追加要望」として constraints に追記: 「あ、やっぱりもっとカジュアルな口調で」みたいな遅れた追加指示を拾う
  • 肯定パターンを日本語 regex で判定: 厳密でなくても「OK / 了解 / 進めて / お願いします」など典型形でカバー

LLM への質問生成プロンプト

各 turn で LLM にこの構造のプロンプトを投げる:

あなたは記事の要件ヒアリングの専門家です。
これまでの対話:
- テーマ: {answers.theme}
- 想定読者: {answers.audience}
- 目標: {answers.goal}
- 素材メモ: {answers.sourceNotes}
- 制約: {answers.constraints}

次に聞くべき項目: {interactiveFocusLabels[nextFocus]}

聞く対象は {interactiveFocusLabels[nextFocus]} です。
1 つだけ、自然な日本語で質問を投げかけてください。
既に回答された内容に触れて、文脈を示した上で質問すると良いです。

interactiveFocusLabels:

const interactiveFocusLabels = {
  theme:        "テーマ",
  audience:     "想定読者",
  goal:         "記事で一番伝えたいこと",
  source_notes: "素材メモ",
  constraints:  "制約や入れたい要素",
  confirm:      "確認",
};

結果として LLM は:

これまでのお話だと「Claude Code の使い方」という広いテーマのようですね。
想定読者 はどんな方を考えていますか?プログラミング初心者、中級者、それとも既に Claude Code を使っていて応用を知りたい人でしょうか?

みたいに、前の回答を踏まえた質問を生成する。機械的な「テーマは?」「読者は?」の羅列より遥かに自然。

確認フェーズ

要件確認と承認を示すチェックリストのデザイン

全項目が埋まったら currentFocus: "confirm" に遷移。LLM に要件サマリを作らせる:

以下の内容で記事を作成しようと思います。

テーマ: Claude Code の使い方
想定読者: 既に Claude Code を使い始めていて、基本以上を知りたい中級者
伝えたいこと: カスタムスキルと hook でワークフローを自動化する具体例
素材メモ: 手元のカスタムスキル定義ファイル、hook 使用例
制約: 3000〜5000 字、実例 3 つ以上、コードスニペット含める

この内容で進めてよろしいでしょうか?「OK」や「進めて」でスタート、
変更点があれば教えてください。

ユーザーが「OK」「進めて」等で応答すると isReadyForDraft: true になり、draft 生成 API に state を渡す流れ。

逆に「もう少し口語的に」等の応答だと、それが constraints に追記される(上述の applyInteractiveAnswer 参照)。

Draft 生成への橋渡し

確認 OK が出たら、state の中身をそのまま draft 生成 API に流し込む:

const metadata = await generateMetadataViaLLM(
  llmService,
  buildDraftContext(state.answers),
  state.suggestedTitle ?? state.answers.theme,
  state.articleType,
);

const article = await articleGeneratorService.generate({
  mainKeyword: state.answers.theme,
  targetAudience: state.answers.audience,
  goal: state.answers.goal,
  sourceNotes: state.answers.sourceNotes,
  constraints: state.answers.constraints,
  articleType: state.articleType,
  // ...
});

interactive フローで集めた情報が全部 draft 生成の入力になる。「何を聞いたか」と「どう draft するか」が結合している設計。

UI 側

会話形式のメッセージング画面デザイン

チャット UI に近い見た目で実装:

[System] 「Claude Code の使い方」について記事を書きたいのですね。
         想定読者はどんな方を考えていますか?

[User]   Claude Code を既に使っている中級者

[System] 中級者向けですね。この記事で一番伝えたいことは何ですか?
         「基本 + α で何ができるか」「具体的なワークフロー」「他ツールとの比較」等。

[User]   カスタムスキルと hook でワークフローを自動化する実例

[System] 素材メモや参考リンクはありますか?なければ「無し」で OK。
...

各 turn で {state, userMessage} を POST し、返ってきた {state, assistantMessage} を描画。state は localStorage に保存。

ハマりどころ

answers が空文字で上書きされないよう trim チェック

ユーザーが空メッセージを送信しちゃった時に state を戻すのを忘れて、answers が “” になってループから抜けなくなったバグがあった。applyInteractiveAnswer の冒頭で trim して空ならそのまま return するガードが重要。

肯定パターンが不足

最初は /^(OK|ok)/ だけで判定してたら、「了解です」「お願いします」で抜けられず confirm にループしていた。日本語で肯定を表すパターンを拾っていく必要がある。

得たもの

要件整理から最終稿までの段階的な改善プロセス

実運用してみた効果(個人プロジェクト content-factory での 6 ヶ月の運用データより):

  • 初稿の品質向上: 想定読者を明示するだけで draft の焦点が合う。個人の主観だが、体感で初稿が「そのまま公開できるレベル」に達する確率が大幅に上がった
  • リライト回数が減る: 導入前は週 5〜6 件の記事生成のうち 60%〜70% が大幅リライト対象だったが、このフローを導入後は約 20%〜30% に低下(詳細な運用ログは未集計のため、主観的な観測)
  • 自分の思考の整理になる: 「想定読者は誰か」「伝えたいことは何か」を言語化することで、記事自体の方向性が固まり、執筆を依頼する側の判断精度も向上

「LLM に任せる前に、LLM に聞き出させる」という一段挟む設計。ちょっと遠回りに見えて、実は最短ルート。

現状の制限と回避策

このフローにはいくつか既知の制限がある。個人ツール前提のため検証・署名は省略しており、本番環境や複数ユーザーでの運用には追加の考慮が必要。

  • articleType の途中変更に未対応: 最初に「解説記事」で始めて、途中で「やっぱりレビュー記事にしたい」と気づくケースで、現状は最初からやり直す必要がある。暫定回避策は confirm フェーズで constraints に「レビュー形式で」と追記すること。今後 UI 側で articleType 変更オプションを追加予定
  • state リセットと履歴管理: サーバ側に state を保持しないため、生成後に「あの要件に戻りたい」となると state の復元ができない。本番環境では state 履歴を DB に保存する実装が必要
  • 複数タブでの state 競合: localStorage はタブ間で共有されるため、同じ state を複数タブで編集するとデータロスの可能性がある。同一ユーザーによる同時編集は現在非対応

これらの制限が発生した場合、修正リリース後に本記事を更新する予定です。

まとめ

  • 記事要件を 5 focus + confirm の state machine で扱う
  • API は stateless:state を毎回クライアント↔サーバで往復
  • next focus は単純に「先頭から未入力を探す」だけで十分
  • confirm フェーズの曖昧な返事は constraints に追記して拾う
  • 肯定判定は日本語の典型パターンで OK / 完璧でなくていい

ChatGPT の Custom GPTs みたいな形で「要件を聞き出してから回答する」パターンは一般化できる。この記事で書いた state machine は「記事生成」だけでなく、レポート作成・プレゼン資料・メール文面など、要件が 5〜10 項目ある生成タスク全般 に応用できる。

書く側が「書く前に整理」を強制される UX は地味に強力で、個人開発ツールで LLM を使うなら導入を強く推奨したい。


次アクション

  • コード全量は GitHub – content-factory で公開しています。state machine の実装詳細や最新の制限事項は README・issues を参照ください
  • 試した感想や改善提案は X @sasaki_sidejob までどうぞ
  • 次回は「このフローをレポート生成・プレゼン資料・提案書へ応用する」を予定しています。多タイプの生成タスクへの拡張パターンを書く予定です

コメント