ブログ記事を LLM に書かせる運用を始めて、初期に全部ハマったのが「雑に投げると雑な記事が返ってくる」問題。
「Claude Code の使い方について記事書いて」とだけ投げると、LLM は「Claude Code とは…基本的な使い方…応用例…まとめ」みたいな誰でも書ける記事を出してくる。読者の想定が違うから刺さらない、主張が浅いからオリジナリティが無い、結果として公開しない / 大幅リライトで時間を食う。
答えは明らかで、事前に要件を詰める 必要がある。ただ、毎回手でテーマ / 想定読者 / 主張 / 制約を書き出すのも面倒。
そこで content-factory では LLM と対話しながら要件を順番に聞き出す「インタラクティブ記事生成」フロー を作った。6 ヶ月で 30 本以上の記事生成を運用した経験から、state machine で focus を遷移させる実装の詳細を書く。
やりたかったこと
ユーザーが「記事書きたい」と思ったら:
- 「テーマは?」と聞かれる
- 答える
- 「想定読者は?」と聞かれる
- 答える
- 「伝えたいことは?」と聞かれる
- 答える
- 「素材メモや参考リンクは?」と聞かれる(あればペースト、無ければ「無し」で OK)
- 「他に制約ある?(文字数・トーン・避けたい表現など)」
- 「では以下の内容で記事を作成します」と要約して確認
- OK を出すと draft 生成が走る
各ステップで LLM が文脈に応じた質問を生成し、ユーザーの自由記述を受け取る。自由度は残しつつ、欠けている要件は必ず埋める。
Stateless な 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 までどうぞ
- 次回は「このフローをレポート生成・プレゼン資料・提案書へ応用する」を予定しています。多タイプの生成タスクへの拡張パターンを書く予定です


コメント