テクノロジー

会話状態の持ち方:ステートマシンで対話を制御する

管理者2026.06.11 公開 ・ 19 min read
会話状態の持ち方:ステートマシンで対話を制御する

01はじめに

この記事の対象読者

この記事は、次のような経験を持つ方に向けて書いています。

  • LLMを使った対話システムを実装したことがある
  • 「フェーズが混乱する」「ユーザーの前の発言に引きずられる」などの問題で悩んだことがある
  • プロンプトを長くするほど制御が難しくなると感じている

TypeScript(Node.js v20以降)の基本的な知識があれば読み進められます。 LLMのAPI呼び出し経験が1つ以上あれば、より具体的にイメージできると思います。

この記事で得られること

  • ステートマシンという概念をLLM対話に適用する考え方
  • 状態定義・遷移条件・状態別プロンプトのコード実装
  • Redis / ファイルを使った状態永続化のパターン
  • 実装する際によく直面する落とし穴とその対処

前提と実行環境

  • Node.js v20.18.1
  • TypeScript v5.4
  • OpenAI SDK v4(openai パッケージ)
  • ステートストアとして Redis v7 または JSON ファイルを使用

サンプルコードは Next.js の Route Handler(App Router)での使用を想定していますが、 Express や Hono など他のフレームワークにも同じ考え方が適用できます。


02TL;DR

  • 「いまどのフェーズか」はLLMに判断させず、コードで持つ
  • 状態ごとにプロンプトを切り替えることで、LLMの役割が明確になる
  • 遷移条件をLLMの出力から構造化データとして受け取る設計が安定する
  • 状態はセッション外部に永続化し、再起動・スケールアウトに備える

03なぜLLMだけでは「フェーズ管理」が難しいのか

LLMを使った対話システムを作ると、初期のプロトタイプは驚くほど簡単に動きます。 しかし、本番に近いユースケースを実装しようとすると、ある種の問題が必ず顔を出します。

たとえば、事業計画の作成を支援するエージェントを想定してみます。 「事業アイデアのヒアリング → 競合調査 → 財務シミュレーション → レポート生成」 という4つのフェーズを経て成果物を出すとします。

単純なシステムプロンプトで「現在は○○フェーズです」と書いても、 ユーザーが前のフェーズの話題に戻ったり、いきなり結論を求めたりすると、 LLMはプロンプトの指示より直近の会話の流れに引きずられがちです。

これは「フェーズをLLMの推論に委ねている」ことが根本原因です。 LLMはトークンの連続から次のトークンを予測するモデルであり、 「ルールの番人」として使うのは得意な役割ではありません。

解決策はシンプルです。フェーズの管理はコードが担い、LLMには各フェーズでの対話だけに集中させます。 これがステートマシンをLLM対話に持ち込む動機です。


04ステートマシンの基礎を確認する

ステートマシン(有限状態機械)は、システムが取りうる「状態の集合」と 「状態間の遷移ルール」を明示的に定義する設計パターンです。 UIフレームワーク(XState等)や通信プロトコルの設計でも広く使われています。

LLM対話への適用では、次の4要素を定義します。

要素 説明
状態(State) 対話が今どのフェーズにいるか
遷移(Transition) ある状態から別の状態へ移るきっかけ
アクション(Action) 状態に入ったとき・出るときに実行する処理
コンテキスト(Context) 状態をまたいで保持したいデータ

この4要素をコードで表現することが、本記事の中心的な内容です。


05状態を定義する

まず、状態の型を定義します。 ここでは「事業計画エージェント」に近いシンプルな対話を例に使います。

// src/agent/states.ts

export type ConversationState =
  | "IDLE"
  | "GATHERING_INFO"
  | "CONFIRMING_REQUIREMENTS"
  | "PROCESSING"
  | "PRESENTING_RESULT"
  | "COMPLETED"
  | "ERROR";

export interface StateContext {
  sessionId: string;
  currentState: ConversationState;
  previousState: ConversationState | null;
  collectedData: Record<string, unknown>;
  turnCount: number;
  createdAt: string;
  updatedAt: string;
}

export const INITIAL_CONTEXT: Omit<StateContext, "sessionId"> = {
  currentState: "IDLE",
  previousState: null,
  collectedData: {},
  turnCount: 0,
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
};

ConversationState は文字列リテラルのユニオン型にしています。 enum も使えますが、JSON シリアライズの扱いや型の透明性を考えると、 文字列リテラルのほうが扱いやすいと感じています(これは好みの問題でもあります)。

StateContextcollectedData は汎用的に Record<string, unknown> としていますが、 実プロジェクトでは Zod などでスキーマを明確にすると安全です。


06遷移ルールを定義する

次に、どの状態からどの状態へ遷移できるかをマップで表現します。

// src/agent/transitions.ts

import { ConversationState } from "./states";

export interface TransitionRule {
  from: ConversationState;
  to: ConversationState;
  condition: string; // LLMが判断材料にする説明
  guard?: (context: StateContext) => boolean; // コードで評価する条件
}

import { StateContext } from "./states";

export const TRANSITION_RULES: TransitionRule[] = [
  {
    from: "IDLE",
    to: "GATHERING_INFO",
    condition: "ユーザーが対話を開始した",
  },
  {
    from: "GATHERING_INFO",
    to: "CONFIRMING_REQUIREMENTS",
    condition: "必要情報が一通り揃い、確認フェーズに移れる",
    guard: (ctx) => {
      const required = ["topic", "targetUser", "budget"];
      return required.every((key) => key in ctx.collectedData);
    },
  },
  {
    from: "CONFIRMING_REQUIREMENTS",
    to: "GATHERING_INFO",
    condition: "ユーザーが情報の修正・追加を求めた",
  },
  {
    from: "CONFIRMING_REQUIREMENTS",
    to: "PROCESSING",
    condition: "ユーザーが内容を承認した",
  },
  {
    from: "PROCESSING",
    to: "PRESENTING_RESULT",
    condition: "処理が完了し結果を提示できる状態になった",
  },
  {
    from: "PRESENTING_RESULT",
    to: "COMPLETED",
    condition: "ユーザーが結果に満足し対話を終了した",
  },
  {
    from: "PRESENTING_RESULT",
    to: "GATHERING_INFO",
    condition: "ユーザーが追加の修正や別条件での再計算を求めた",
  },
];

ここで2種類の条件を使い分けています。

condition は自然言語での説明で、後述するLLMへのプロンプトに埋め込みます。 guard はコードで評価する関数で、必須フィールドの存在チェックなど確実に機械的に判定できるものを担います。

この2層構造にする理由は、「自然言語の意図判断はLLMが得意」「データの完全性チェックはコードが得意」という それぞれの強みを活かすためです。


07状態遷移図を確認する

コードを書き進める前に、遷移の全体像を俯瞰しておきます。

stateDiagram-v2
    [*] --> IDLE
    IDLE --> GATHERING_INFO : 開始
    GATHERING_INFO --> CONFIRMING_REQUIREMENTS : 情報が揃った
    CONFIRMING_REQUIREMENTS --> GATHERING_INFO : ユーザーが修正を求めた
    CONFIRMING_REQUIREMENTS --> PROCESSING : 承認
    PROCESSING --> PRESENTING_RESULT : 完了
    PRESENTING_RESULT --> GATHERING_INFO : 再計算を求めた
    PRESENTING_RESULT --> COMPLETED : 終了
    COMPLETED --> [*]

この図を見ると、PRESENTING_RESULT から GATHERING_INFO への戻り矢印があることがわかります。 こういった「後戻り」の遷移をプロンプトだけで管理しようとすると、 LLMが混乱して意図しないフェーズへ飛ぶことがあります。 ステートマシンであれば、許可された遷移だけをコードで受け付けられます。


08LLMから遷移意図を構造化データとして受け取る

状態遷移のトリガーをLLMに判断させる部分を実装します。 ここで大切なのは、「LLMに遷移先の名前を直接出力させない」ことです。

遷移先を直接出力させると、LLMがハルシネーションで存在しない状態名を返すリスクがあります。 代わりに、「今の発言はどの遷移条件に該当するか」を番号で答えさせる設計が安定します。

// src/agent/transition-detector.ts

import OpenAI from "openai";
import { ConversationState, StateContext } from "./states";
import { TRANSITION_RULES, TransitionRule } from "./transitions";

const openai = new OpenAI();

export interface TransitionDetectionResult {
  shouldTransition: boolean;
  targetRule: TransitionRule | null;
  reasoning: string;
}

export async function detectTransition(
  userMessage: string,
  context: StateContext
): Promise<TransitionDetectionResult> {
  const availableRules = TRANSITION_RULES.filter(
    (rule) => rule.from === context.currentState
  );

  if (availableRules.length === 0) {
    return { shouldTransition: false, targetRule: null, reasoning: "遷移ルールなし" };
  }

  const rulesDescription = availableRules
    .map((rule, index) => `${index + 1}. ${rule.condition}`)
    .join("\n");

  const prompt = `あなたはユーザーの発言を分析し、対話フェーズの遷移が必要かどうかを判断します。

現在のフェーズ: ${context.currentState}
ユーザーの発言: "${userMessage}"

以下の遷移条件の中で、ユーザーの発言に最も合致するものがあれば番号で答えてください。
どれにも当てはまらない場合は 0 を答えてください。

遷移条件:
${rulesDescription}

JSON形式で回答してください:
{
  "matchedIndex": <番号(0は遷移なし)>,
  "reasoning": "<判断の理由を1文で>"
}`;

  const response = await openai.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [{ role: "user", content: prompt }],
    response_format: { type: "json_object" },
  });

  const raw = response.choices[0].message.content ?? "{}";
  let parsed: { matchedIndex: number; reasoning: string };

  try {
    parsed = JSON.parse(raw);
  } catch {
    return { shouldTransition: false, targetRule: null, reasoning: "パースエラー" };
  }

  const index = parsed.matchedIndex - 1;

  if (parsed.matchedIndex === 0 || index < 0 || index >= availableRules.length) {
    return { shouldTransition: false, targetRule: null, reasoning: parsed.reasoning };
  }

  const targetRule = availableRules[index];

  // guard 条件をコードで評価する
  if (targetRule.guard && !targetRule.guard(context)) {
    return {
      shouldTransition: false,
      targetRule: null,
      reasoning: `条件は合致しましたが、guard チェックを通過しませんでした。(${parsed.reasoning})`,
    };
  }

  return {
    shouldTransition: true,
    targetRule,
    reasoning: parsed.reasoning,
  };
}

遷移判断は創造性より一貫性が求められます。なお、GPT-5系(gpt-5.4-mini等)ではサンプリングパラメータ(temperature等)が原則指定できず、reasoning_effort 等で挙動を制御します。 response_format: { type: "json_object" } を使うことで、JSONパース失敗を減らせます (ただし OpenAI のモデルに依存した機能のため、他のLLMでは別の方法が必要です)。

実行例を確認します。

入力:
  currentState: "GATHERING_INFO"
  userMessage: "はい、これで情報はすべて揃っています。確認に進みましょう。"

出力:
  {
    "matchedIndex": 1,
    "reasoning": "ユーザーが情報の完了と次フェーズへの移行を明示している"
  }

→ guard チェック通過後、CONFIRMING_REQUIREMENTS へ遷移

09状態ごとにプロンプトを切り替える

ステートマシンの最大の恩恵のひとつが、「状態に応じてシステムプロンプトを差し替えられる」点です。 1つの長大なプロンプトに全フェーズの指示を詰め込む必要がなくなります。

// src/agent/prompts.ts

import { ConversationState, StateContext } from "./states";

export function buildSystemPrompt(
  state: ConversationState,
  context: StateContext
): string {
  const base = `あなたは事業計画の作成を支援するアシスタントです。
現在のフェーズ: ${state}
これまでに収集したデータ: ${JSON.stringify(context.collectedData, null, 2)}`;

  const stateSpecific: Record<ConversationState, string> = {
    IDLE: `
ユーザーに温かく挨拶し、どのような事業計画を検討しているか
大まかなテーマを聞き出してください。
まだ細かい質問はしません。`,

    GATHERING_INFO: `
以下の3点を順番に確認してください(すでに収集済みの項目はスキップしてください):
1. 事業テーマ(topic)
2. ターゲットユーザー(targetUser)
3. 概算予算(budget)

一度に複数の質問を並べず、1回の返答で1点だけ確認します。
ユーザーが答えたら、内容を要約して確認を取ってから次の項目へ進みます。`,

    CONFIRMING_REQUIREMENTS: `
収集した情報を箇条書きで提示し、内容に誤りがないかユーザーに確認します。
承認されたら次のフェーズへ進む意思を伝えてください。
修正を求められたら、どの部分を変更するか具体的に聞いてください。`,

    PROCESSING: `
「現在、事業計画を作成しています。少々お待ちください。」とユーザーに伝えてください。
このフェーズではユーザーへの応答は状況報告のみとし、追加の質問はしません。`,

    PRESENTING_RESULT: `
作成した事業計画の概要を提示します。
構成: 事業概要 → ターゲット分析 → 収益モデル → 課題と対策
各セクションを簡潔に説明し、詳細を見たい箇所があるか確認します。
ユーザーが満足した場合は終了を、追加修正の場合は再度情報収集フェーズへ戻ります。`,

    COMPLETED: `
対話の完了を伝え、生成した事業計画のサマリを最終確認として提示します。
追加サポートが必要かどうかも確認してください。`,

    ERROR: `
エラーが発生したことを丁寧に伝え、最初からやり直すか
前のフェーズに戻るかをユーザーに選択してもらいます。`,
  };

  return `${base}\n\n## このフェーズでの役割\n${stateSpecific[state]}`;
}

このアプローチの利点は、プロンプトの修正範囲が明確になることです。 「情報収集フェーズで質問が多すぎる」という問題があれば、GATHERING_INFO のプロンプトだけ直します。 全体を壊す心配がないため、チューニングが格段に楽になります。


10ステートマシンクラスとしてまとめる

ここまでの要素をまとめたクラスを実装します。

// src/agent/state-machine.ts

import OpenAI from "openai";
import { ConversationState, StateContext } from "./states";
import { detectTransition } from "./transition-detector";
import { buildSystemPrompt } from "./prompts";

const openai = new OpenAI();

export interface TurnResult {
  assistantMessage: string;
  previousState: ConversationState;
  currentState: ConversationState;
  didTransition: boolean;
  transitionReasoning: string;
}

export class ConversationStateMachine {
  constructor(private context: StateContext) {}

  getContext(): StateContext {
    return { ...this.context };
  }

  async processTurn(
    userMessage: string,
    conversationHistory: OpenAI.Chat.ChatCompletionMessageParam[]
  ): Promise<TurnResult> {
    const previousState = this.context.currentState;

    // 1. 遷移が必要かを判定する
    const detectionResult = await detectTransition(userMessage, this.context);

    // 2. 遷移が必要なら状態を更新する
    if (detectionResult.shouldTransition && detectionResult.targetRule) {
      this.context = {
        ...this.context,
        previousState: this.context.currentState,
        currentState: detectionResult.targetRule.to,
        updatedAt: new Date().toISOString(),
      };
    }

    // 3. 現在の状態に応じたプロンプトでLLMを呼び出す
    const systemPrompt = buildSystemPrompt(this.context.currentState, this.context);

    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
      { role: "system", content: systemPrompt },
      ...conversationHistory,
      { role: "user", content: userMessage },
    ];

    const response = await openai.chat.completions.create({
      model: "gpt-5.5",
      messages,
    });

    const assistantMessage =
      response.choices[0].message.content ?? "(応答なし)";

    // 4. ターン数を更新する
    this.context = {
      ...this.context,
      turnCount: this.context.turnCount + 1,
      updatedAt: new Date().toISOString(),
    };

    return {
      assistantMessage,
      previousState,
      currentState: this.context.currentState,
      didTransition: detectionResult.shouldTransition,
      transitionReasoning: detectionResult.reasoning,
    };
  }

  // 収集データを更新するメソッド
  updateCollectedData(updates: Record<string, unknown>): void {
    this.context = {
      ...this.context,
      collectedData: { ...this.context.collectedData, ...updates },
      updatedAt: new Date().toISOString(),
    };
  }
}

processTurn は「遷移判定 → 状態更新 → プロンプト組み立て → LLM呼び出し」の 4ステップを順番に実行します。 呼び出し側からは「ユーザーメッセージを渡して結果を受け取る」だけになるため、 Next.js の Route Handler との結合が簡単です。


11状態を永続化する

会話が複数のHTTPリクエストにまたがる場合、状態をメモリ上に保持するだけでは不足です。 サーバー再起動やスケールアウトで状態が失われます。

ここでは2つのパターンを示します。

Redis を使った永続化

// src/agent/store/redis-store.ts

import { createClient } from "redis";
import { StateContext, INITIAL_CONTEXT } from "../states";

const client = createClient({ url: process.env.REDIS_URL });

// 起動時に接続する(アプリ初期化時に呼び出す)
export async function connectStore(): Promise<void> {
  if (!client.isOpen) {
    await client.connect();
  }
}

const SESSION_TTL_SECONDS = 60 * 60 * 24; // 24時間

export async function loadContext(sessionId: string): Promise<StateContext> {
  const raw = await client.get(`session:${sessionId}`);

  if (!raw) {
    // 新規セッション
    const ctx: StateContext = {
      ...INITIAL_CONTEXT,
      sessionId,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    return ctx;
  }

  return JSON.parse(raw) as StateContext;
}

export async function saveContext(context: StateContext): Promise<void> {
  await client.set(
    `session:${context.sessionId}`,
    JSON.stringify(context),
    { EX: SESSION_TTL_SECONDS }
  );
}

export async function deleteContext(sessionId: string): Promise<void> {
  await client.del(`session:${sessionId}`);
}
// 実行例(Node.js REPL)

import { connectStore, loadContext, saveContext } from "./store/redis-store";

await connectStore();

const ctx = await loadContext("user-abc-123");
console.log(ctx.currentState);
// => "IDLE"

// ... 状態を更新して保存
ctx.currentState = "GATHERING_INFO";
await saveContext(ctx);

const reloaded = await loadContext("user-abc-123");
console.log(reloaded.currentState);
// => "GATHERING_INFO"

JSON ファイルを使った永続化(開発・軽量環境向け)

Redisを用意できない開発環境や、小規模なユースケースでは、 ファイルベースのストアも選択肢になります。

// src/agent/store/file-store.ts

import { promises as fs } from "node:fs";
import path from "node:path";
import { StateContext, INITIAL_CONTEXT } from "../states";

// 環境名を含まない汎用的なパスにする
const STORE_DIR = path.join(process.cwd(), ".session-store");

async function ensureDir(): Promise<void> {
  await fs.mkdir(STORE_DIR, { recursive: true });
}

function filePath(sessionId: string): string {
  // パストラバーサルを防ぐためにバリデーションを入れる
  if (!/^[\w-]+$/.test(sessionId)) {
    throw new Error("Invalid sessionId format");
  }
  return path.join(STORE_DIR, `${sessionId}.json`);
}

export async function loadContext(sessionId: string): Promise<StateContext> {
  await ensureDir();
  const fp = filePath(sessionId);

  try {
    const raw = await fs.readFile(fp, "utf-8");
    return JSON.parse(raw) as StateContext;
  } catch {
    return {
      ...INITIAL_CONTEXT,
      sessionId,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
  }
}

export async function saveContext(context: StateContext): Promise<void> {
  await ensureDir();
  const fp = filePath(context.sessionId);
  await fs.writeFile(fp, JSON.stringify(context, null, 2), "utf-8");
}

ファイルストアはシングルプロセスでの動作を前提にしています。 複数プロセスが同時に同じセッションを更新するとデータ競合が起きるため、 本番環境ではRedisなど競合制御のある仕組みを使う方が安全です。


12Next.js Route Handler に組み込む

ここまでの実装を、Next.js の Route Handler に組み込みます。

// src/app/api/chat/route.ts

import { NextRequest, NextResponse } from "next/server";
import { ConversationStateMachine } from "@/agent/state-machine";
import { loadContext, saveContext } from "@/agent/store/redis-store";
import OpenAI from "openai";

export async function POST(req: NextRequest): Promise<NextResponse> {
  const body = await req.json();
  const { sessionId, userMessage, conversationHistory } = body as {
    sessionId: string;
    userMessage: string;
    conversationHistory: OpenAI.Chat.ChatCompletionMessageParam[];
  };

  if (!sessionId || !userMessage) {
    return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
  }

  // セッションコンテキストをロードする
  const context = await loadContext(sessionId);
  const machine = new ConversationStateMachine(context);

  // 1ターン処理する
  const result = await machine.processTurn(userMessage, conversationHistory ?? []);

  // 更新されたコンテキストを保存する
  await saveContext(machine.getContext());

  return NextResponse.json({
    assistantMessage: result.assistantMessage,
    currentState: result.currentState,
    didTransition: result.didTransition,
    transitionReasoning: result.transitionReasoning,
  });
}
// レスポンス例

{
  "assistantMessage": "情報が揃いました。以下の内容で間違いないか確認させてください。\n\n- 事業テーマ: サブスクリプション型フィットネスアプリ\n- ターゲット: 30代の働く男性\n- 予算: 500万円\n\nこの内容で進めてよろしいでしょうか?",
  "currentState": "CONFIRMING_REQUIREMENTS",
  "didTransition": true,
  "transitionReasoning": "ユーザーが全情報を提供し確認を求めた"
}

conversationHistory をクライアント側から渡している点が気になる方もいるかと思います。 この設計はデモを簡潔にするためで、本番では履歴もサーバー側のセッションストアで管理する方が セキュリティ・一貫性の観点で安全です。


13よくある落とし穴と対処

実際にこの設計を使って実装を進めると、いくつかのパターンで詰まることがあります。 筆者たちが経験したものをまとめます。

遷移が「飛び石」で起きる

ユーザーが「最初に戻って」と言うと、GATHERING_INFOCONFIRMING_REQUIREMENTS の遷移ルールの代わりに GATHERING_INFOIDLE という存在しない遷移がLLMに期待されることがあります。

対処は遷移ルールを網羅的に定義することと、「許可されていない遷移は常に拒否する」という バリデーションをクラス内に持たせることです。

// ConversationStateMachine クラスに追加するメソッド

private isValidTransition(
  from: ConversationState,
  to: ConversationState
): boolean {
  return TRANSITION_RULES.some(
    (rule) => rule.from === from && rule.to === to
  );
}

プロンプトが長くなって精度が落ちる

状態数が増えると buildSystemPrompt が返す文字列が長くなりがちです。 コンテキストウィンドウの上限に近づくと、LLMが指示の後半を無視する傾向が出てきます。

対処としては、現在の状態のプロンプトと「前の状態の簡易サマリ」だけを渡す設計にして、 全状態の説明を常に含めないようにすることが有効です。

guard 条件の設計が難しい

guard をどこまで厳密にするかは悩ましい問題です。 筆者たちの経験では「データの存在チェック」程度にとどめ、内容の妥当性はLLMに任せる方が メンテナンスしやすいと感じています。ただしこれは要件次第なので、一概には言えません。

複数タブ・並列セッションの競合

同一ユーザーが複数タブで操作すると、セッションIDの設計によっては 状態が上書きされることがあります。 セッションIDをタブ単位(例: userId-tabId)にするか、 楽観的ロック(updatedAt のタイムスタンプ比較)を入れる対処が考えられます。


14XState との使い分けについて

ステートマシンのライブラリとして xstate(v5)が著名です。 ビジュアライザーや階層型ステートなど、本記事で実装した素朴な実装よりはるかに豊富な機能があります。

筆者たちがあえてスクラッチ実装を紹介しているのは、XStateの学習コストと抽象度が LLM対話特有の「LLM呼び出しを遷移の中に組み込む」という要件と、 必ずしも相性よく噛み合わないと感じることがあるためです。

XStateのActorモデルとLLMの非同期呼び出しを組み合わせる方法は確かに存在しますが、 チームがXStateに不慣れな場合は、シンプルなクラスベース実装の方が 最初の実装・デバッグが速いケースが多い、というのが私たちの現時点での印象です。

XStateを既に使っているプロジェクトや、複雑な並列状態が必要なユースケースでは、 XStateを採用する方が合理的なことも十分あります。


15BizPlan での設計思想との関係

筆者たちが開発している BizPlan(事業計画エージェント)では、 本記事で紹介した設計に近い考え方でフェーズ管理を実装しています。

具体的には、ヒアリングフェーズ・設計フェーズ・生成フェーズ・修正フェーズを コードで明示的に管理し、各フェーズで専門化されたプロンプトを使い分けています。 実運用の中で「LLMに全体のフロー管理を任せると、利用者の多様な入力パターンに対して 挙動が不安定になる」という経験が、この設計の採用につながりました。

ただし、実際のシステムは本記事の説明を大幅に簡略化したものです。 本番環境ではエラーリカバリ・タイムアウト・非同期処理の複雑さが加わります。


16テストの書き方

ステートマシンはテストしやすい設計です。 状態が明示的であるため、各状態でのLLM応答のモックや、 遷移のユニットテストが書きやすくなります。

// src/agent/__tests__/state-machine.test.ts

import { describe, it, expect, vi } from "vitest";
import { ConversationStateMachine } from "../state-machine";
import { StateContext } from "../states";

// detectTransition をモックする
vi.mock("../transition-detector", () => ({
  detectTransition: vi.fn().mockResolvedValue({
    shouldTransition: true,
    targetRule: { from: "GATHERING_INFO", to: "CONFIRMING_REQUIREMENTS" },
    reasoning: "テスト用モック",
  }),
}));

// OpenAI呼び出しをモックする
vi.mock("openai", () => ({
  default: vi.fn().mockImplementation(() => ({
    chat: {
      completions: {
        create: vi.fn().mockResolvedValue({
          choices: [{ message: { content: "テスト応答" } }],
        }),
      },
    },
  })),
}));

const mockContext: StateContext = {
  sessionId: "test-session",
  currentState: "GATHERING_INFO",
  previousState: null,
  collectedData: { topic: "フィットネス", targetUser: "30代男性", budget: "500万" },
  turnCount: 3,
  createdAt: "2026-06-11T00:00:00.000Z",
  updatedAt: "2026-06-11T00:00:00.000Z",
};

describe("ConversationStateMachine", () => {
  it("遷移後に currentState が更新される", async () => {
    const machine = new ConversationStateMachine(mockContext);
    const result = await machine.processTurn("確認に進みましょう", []);

    expect(result.didTransition).toBe(true);
    expect(result.currentState).toBe("CONFIRMING_REQUIREMENTS");
    expect(result.previousState).toBe("GATHERING_INFO");
  });

  it("ターン数がインクリメントされる", async () => {
    const machine = new ConversationStateMachine(mockContext);
    await machine.processTurn("確認に進みましょう", []);

    expect(machine.getContext().turnCount).toBe(4);
  });
});
// vitest 実行結果

 PASS  src/agent/__tests__/state-machine.test.ts

  ConversationStateMachine
    ✓ 遷移後に currentState が更新される (12ms)
    ✓ ターン数がインクリメントされる (3ms)

Test Files  1 passed (1)
Tests       2 passed (2)

LLMの呼び出しをモックして外に出すことで、遷移ロジック自体のユニットテストが 高速に実行できます。統合テストは別途、実際のLLMを呼び出す形で用意する設計が コストと品質のバランスとして現実的です。


17まとめ

この記事では、LLM対話システムにおける「フェーズ管理をコードで持つ」設計を解説しました。

  • 状態(State)を型で定義し、遷移ルールをデータとして表現する
  • LLMには遷移判定を自然言語で依頼し、結果は構造化データで受け取る
  • guard 関数でコード側のバリデーションを加えることで2層の安全網を作る
  • 状態ごとにプロンプトを差し替えることで、LLMの役割を明確にする
  • セッション外部(Redis等)に状態を永続化して再起動・スケールアウトに備える

最初は「プロンプトを工夫すれば解決できる」と感じるかもしれません。 筆者たちもそう思っていた時期がありました。しかし会話の複雑さが増すにつれ、 「フェーズ管理はコードの仕事」という割り切りが、システム全体の安定性に大きく効いてきます。

本記事のコードは最小限の実装例であり、本番向けには認証・レート制限・ログ収集などが 別途必要になります。設計の骨格として参考にしていただければ幸いです。

関連記事:

  • ハーネス設計入門:対話を成果物に収束させる仕組みの作り方
  • プロンプトをコードで管理する:テンプレートエンジンとの比較

18参考文献

Author
管理者
Agent Store

記事で紹介した技術を、実際の業務でお試しください。

業務に合うエージェントを条件で絞り込んで選べます。すべて無料で、今すぐ利用できます。

エージェント一覧を見る →

コメント

まだコメントはありません。最初のコメントを投稿してみましょう。

コメントを投稿

ゲストコメントは管理者の承認後に公開されます。 ログインするとすぐにコメントが公開されます。

当サイトではCookieを使用しています。詳しくはCookieポリシーをご覧ください。