01はじめに
この記事で扱うテーマと、想定している読み手について最初に書いておきます。
対象読者は「LLMを使った対話エージェントや入力フォームの代替としてのチャット型UIを設計・実装している開発者」です。 具体的には、ユーザーとの対話を通じて情報を集め、何らかの成果物を生成するシステムを作ろうとしている方を想定しています。
読み終えると、次のことが分かります。
- なぜ「一度に多く聞く」と回答率が下がるのか
- 質問を分割するときの粒度の決め方
- 自由入力より選択肢提示が有効なケース、その逆のケース
- ユーザーが答えやすい質問の順序をどう設計するか
- 上記をTypeScript・Next.jsのコードに落とす方法
前提として、TypeScript(Node.js v20 以上)の基本的な知識があることを想定しています。 LLM API(OpenAI API 互換のインターフェース)を直接呼び出した経験があると、より理解しやすいと思います。
実行環境の確認には以下を使いました。
- Node.js: v20.18.1
- TypeScript: v5.4
- Next.js: v14.2
- Zod: v3.23
筆者たちが開発するBizPlan(事業計画エージェント)では、数十項目の事業情報をユーザーから収集する必要があります。 一問一答のウィザード形式や、複数質問を一気に投げる方式など、いくつかのアプローチを試しました。 その過程で「質問の設計次第でユーザーの完答率が大きく変わる」と実感しました。 この記事では、そこから得た知見を設計パターンとコード実装に整理して共有します。
なお、この記事で扱うコードはコンセプト説明用に簡略化しています。 本番環境への適用には、エラーハンドリング・認証・レート制限など追加の考慮が必要です。
02TL;DR
- 1ターンで聞くのは1〜2項目が基本。認知負荷が分散され、回答の完了率が上がる傾向があります。
- 選択肢提示は「ユーザーが語彙を持っていない」場面で特に有効です。自由入力は背景を深掘りしたいときに使います。
- 質問の順序は「易しい→難しい」。答えやすい質問で始めると、会話への参加コストが下がります。
- 前の回答を次の質問に活かすことで、文脈のある対話になります。
- これらをコードに落とすには、質問定義・状態管理・質問選択ロジックの3層に分けると整理しやすいです。
03なぜ質問設計が重要なのか
チャット型のインターフェースは「柔軟に入力できる」という利点がある一方で、ユーザーに何を入力すればよいかが伝わりにくいという難しさも持っています。
従来のフォームUIであれば、必須項目に赤いアスタリスクを付け、入力例(プレースホルダー)を表示し、バリデーションエラーを即座に出す、という設計パターンが確立されています。 チャット型UIにはそれに相当する定番の設計パターンがまだ少なく、どう聞くかはシステム次第です。
筆者たちがBizPlanの開発初期に直面したのは、次のような問題でした。
「事業のターゲット顧客、市場規模、競合環境、自社の強みについて教えてください。」
このような複合質問を投げたとき、ユーザーの反応は大きく3つに分かれました。
- 全項目を丁寧に回答してくれる(少数派)
- 一部だけ答えて後は省略する
- 「どこから答えればいいですか?」と逆質問してくる
完答してくれるのはモチベーションが高い一部のユーザーだけで、大多数は途中で詰まったり、重要な項目を省略したりします。 質問を設計し直すことで、この状況は改善できます。
04原則1:一度に聞くのは1〜2項目
認知負荷と回答品質の関係
ユーザーに複数の質問を一度に投げると、頭の中で「どれに答えるべきか」という優先付けが発生します。 複数の問いに一度に答えるのは、並列処理が苦手な人間にとって意外と高コストです。
特にチャットという非同期・非構造なインターフェースでは、フォームのように項目が視覚的に分離されないため、何項目あるのかもわかりにくくなります。
筆者たちの経験では、1ターンで聞く項目数を絞るだけで、回答の具体性が上がる傾向がありました。 ただし、これはシステムの性質やユーザーの習熟度によっても変わります。一律に「絶対に1問ずつ」と決めるより、対象ユーザーに合わせて調整する視点が大切です。
質問分割の粒度
「1〜2項目」という制限の中でどう分割するかは、情報の依存関係を整理するとやりやすくなります。
flowchart LR
A["ターゲット顧客"] -->|"依存"| B["顧客の課題"]
A -->|"依存"| C["想定する獲得チャネル"]
依存関係がある項目(Bの回答がAを前提とする)は同じターンで聞くか、順番を守って別ターンで聞きます。 独立している項目は任意の順序で分けられます。
05原則2:選択肢を示す
選択肢提示が有効なケース
自由入力をユーザーに求めると、ユーザーはゼロから文章を作らなければなりません。 言語化が難しいテーマや、ユーザーがまだ語彙を持っていないテーマでは、この負荷が回答の質を下げます。
選択肢提示が特に有効な場面は以下の通りです。
- カテゴリ選択(業種、ターゲット属性、ビジネスモデルの型など)
- レベル感の確認(初期段階/成長期/安定期、など相対的な位置づけ)
- 優先度の確認(複数の要素のうちどれを重視するか)
一方で、選択肢が逆効果になる場面もあります。
- ユーザー固有の事情を聞きたいとき(選択肢に収まらない場合が多い)
- 背景の理由や文脈を深掘りしたいとき
- ユーザー自身が言葉を選ぶプロセスに意味があるとき
選択肢の設計
選択肢を提示するときに意識しているのは、以下の3点です。
1. 選択肢の数は3〜5個
2個だと「はい/いいえ」に近くなり、情報が粗くなります。 6個以上になると選択肢を読むだけで疲れます。 選択肢が多くなるときは、まず大分類を選ばせてから絞り込む2段階にする方法が使いやすいです。
2. 「その他(自由記述)」を必ず含める
どんな選択肢設計でも、当てはまらないケースは必ず存在します。 「その他(自由記述)」を含めることで、選択肢に当てはまらない重要な情報を取りこぼさずに済みます。
3. 選択肢は互いに排他的に
「BtoBとBtoC両方」のようなケースに対応したいなら、複数選択可であることを明示します。 排他にできない場合は「最もあてはまるものを1つ」と絞り込む問い方に変えます。
06原則3:答えやすい順に並べる
順序設計の考え方
質問の順序は、対話全体の流れとユーザーの心理的負荷に直接影響します。
一般的に「易しい質問→難しい質問」の順序が機能しやすいです。 これはいわゆる「フット・イン・ザ・ドア」の効果に近く、小さなコミットメントを積み重ねることで会話への参加が続きやすくなります。
BizPlanでは、次のような順序を基本としています。
flowchart TD
A["1. 確認しやすい事実\n(何をやっているか)"]
B["2. 状況の説明\n(どういう状況か)"]
C["3. 意図・目的\n(なぜやるか)"]
D["4. 難易度の高い問い\n(課題・競合・リスクの認識)"]
A --> B --> C --> D
「何をやっているか」は多くの人が答えやすい質問です。 「課題をどう認識しているか」は、深く考えないと答えられない難易度の高い質問です。 この順序を逆にすると、最初の問いでつまずき、その後の対話が続きにくくなります。
前の回答を活かす文脈設計
順序以上に大切なのは、前の回答を次の質問に織り込むことです。
「先ほど教えていただいた〇〇についてですが、」という前置きは、単なる丁寧さではなく、ユーザーに「自分の回答が活かされている」という感覚を与えます。 これがあると、後続の質問に答えるモチベーションが維持されやすくなります。
07実装:質問定義の設計
ここからコードに入ります。
まず、質問をどう定義するかを考えます。 質問の型を明確にしておくと、後の状態管理や質問選択ロジックが整理しやすくなります。
// src/types/question.ts
export type QuestionType = "free" | "single_choice" | "multi_choice" | "scale";
export interface QuestionOption {
value: string;
label: string;
}
export interface Question {
id: string;
field: string; // 対応するデータフィールド名
type: QuestionType;
text: string; // LLMに渡す質問本文
options?: QuestionOption[]; // single_choice / multi_choice のとき使用
required: boolean;
dependsOn?: {
field: string;
value: string; // このフィールドがこの値のとき、この質問を表示する
};
priority: number; // 低い数値ほど早く聞く
}
次に、質問のカタログを定義します。 ここでは事業計画ヒアリングを例にした簡略版を示します。
// src/data/questions.ts
import type { Question } from "../types/question";
export const BUSINESS_PLAN_QUESTIONS: Question[] = [
{
id: "q-business-summary",
field: "businessSummary",
type: "free",
text: "まず、どのような事業を考えているか、一言で教えてください。",
required: true,
priority: 1,
},
{
id: "q-business-model",
field: "businessModel",
type: "single_choice",
text: "事業のモデルはどれに近いですか?",
options: [
{ value: "b2b", label: "BtoB(企業向けサービス)" },
{ value: "b2c", label: "BtoC(個人向けサービス)" },
{ value: "b2b2c", label: "BtoBtoC(企業経由で個人に届ける)" },
{ value: "marketplace", label: "マーケットプレイス(売り手と買い手を繋ぐ)" },
{ value: "other", label: "その他(後で説明します)" },
],
required: true,
priority: 2,
},
{
id: "q-target-customer",
field: "targetCustomer",
type: "free",
text: "ターゲットとなるお客様はどのような方ですか?属性や状況を教えてください。",
required: true,
priority: 3,
},
{
id: "q-target-company-size",
field: "targetCompanySize",
type: "single_choice",
text: "どのような規模の企業を主な対象としていますか?",
options: [
{ value: "startup", label: "スタートアップ・ベンチャー(従業員50名未満)" },
{ value: "sme", label: "中小企業(従業員50〜500名)" },
{ value: "enterprise", label: "大企業(従業員500名以上)" },
{ value: "all", label: "規模を問わない" },
],
required: false,
// BtoBのときだけ聞く
dependsOn: { field: "businessModel", value: "b2b" },
priority: 4,
},
{
id: "q-customer-problem",
field: "customerProblem",
type: "free",
text: "そのお客様が抱えている、解決したい課題は何ですか?",
required: true,
priority: 5,
},
{
id: "q-current-stage",
field: "currentStage",
type: "single_choice",
text: "現在の事業ステージを教えてください。",
options: [
{ value: "idea", label: "アイデア段階(まだ開発・検証していない)" },
{ value: "mvp", label: "MVP段階(プロトタイプや初期版がある)" },
{ value: "growth", label: "成長段階(すでに顧客がいて収益がある)" },
],
required: true,
priority: 6,
},
];
08実装:状態管理
対話の進行状態を管理する型とロジックを作ります。 「どの質問が回答済みか」「現在の回答内容は何か」を追跡するのが主な役割です。
// src/types/session.ts
export interface AnswerRecord {
questionId: string;
field: string;
value: string;
answeredAt: Date;
}
export interface InterviewSession {
sessionId: string;
answers: Record<string, AnswerRecord>; // field をキーとする
currentQuestionId: string | null;
completedAt: Date | null;
}
// src/lib/session-manager.ts
import type { AnswerRecord, InterviewSession } from "../types/session";
export function createSession(sessionId: string): InterviewSession {
return {
sessionId,
answers: {},
currentQuestionId: null,
completedAt: null,
};
}
export function recordAnswer(
session: InterviewSession,
questionId: string,
field: string,
value: string
): InterviewSession {
return {
...session,
answers: {
...session.answers,
[field]: {
questionId,
field,
value,
answeredAt: new Date(),
},
},
};
}
export function getAnsweredFields(session: InterviewSession): string[] {
return Object.keys(session.answers);
}
export function getAnswerValue(
session: InterviewSession,
field: string
): string | undefined {
return session.answers[field]?.value;
}
09実装:質問選択ロジック
現在のセッション状態を見て「次に聞くべき質問」を選ぶロジックです。 ここが質問設計の肝で、依存関係の評価と優先度の並べ替えが主な処理です。
// src/lib/question-selector.ts
import type { Question } from "../types/question";
import type { InterviewSession } from "../types/session";
import { getAnsweredFields, getAnswerValue } from "./session-manager";
/**
* セッション状態を見て、次に聞くべき質問を返す。
* 全ての必須質問が回答済みであればnullを返す。
*/
export function selectNextQuestion(
questions: Question[],
session: InterviewSession
): Question | null {
const answeredFields = getAnsweredFields(session);
// 回答済みでなく、依存条件を満たした質問だけを候補にする
const candidates = questions.filter((q) => {
// すでに回答済みはスキップ
if (answeredFields.includes(q.field)) {
return false;
}
// 依存関係がある場合、条件を満たしているか確認する
if (q.dependsOn) {
const dependentValue = getAnswerValue(session, q.dependsOn.field);
// 依存先がまだ回答されていない場合もスキップ
if (dependentValue === undefined) {
return false;
}
// 依存先の回答が条件と一致しない場合はスキップ
if (dependentValue !== q.dependsOn.value) {
return false;
}
}
return true;
});
if (candidates.length === 0) {
return null;
}
// 優先度の低い(数値が小さい)質問を先に返す
candidates.sort((a, b) => a.priority - b.priority);
return candidates[0];
}
/**
* 必須項目がすべて回答済みかどうかを確認する。
*/
export function isRequiredComplete(
questions: Question[],
session: InterviewSession
): boolean {
const answeredFields = getAnsweredFields(session);
return questions
.filter((q) => q.required)
.every((q) => answeredFields.includes(q.field));
}
動作確認のコードを示します。
// 動作確認用スクリプト(src/scripts/demo-selector.ts)
import { BUSINESS_PLAN_QUESTIONS } from "../data/questions";
import { createSession, recordAnswer } from "../lib/session-manager";
import { selectNextQuestion, isRequiredComplete } from "../lib/question-selector";
let session = createSession("demo-session-001");
// 最初の質問を選ぶ
const q1 = selectNextQuestion(BUSINESS_PLAN_QUESTIONS, session);
console.log("1問目:", q1?.text);
// => 1問目: まず、どのような事業を考えているか、一言で教えてください。
// 回答を記録する
session = recordAnswer(session, q1!.id, q1!.field, "中小企業向けの採用管理SaaS");
// 2問目を選ぶ
const q2 = selectNextQuestion(BUSINESS_PLAN_QUESTIONS, session);
console.log("2問目:", q2?.text);
// => 2問目: 事業のモデルはどれに近いですか?
// BtoBを選択する
session = recordAnswer(session, q2!.id, q2!.field, "b2b");
// 3問目を選ぶ(BtoBを回答したため、企業規模の質問も候補に入る)
const q3 = selectNextQuestion(BUSINESS_PLAN_QUESTIONS, session);
console.log("3問目:", q3?.text);
// => 3問目: ターゲットとなるお客様はどのような方ですか?属性や状況を教えてください。
console.log("必須完了:", isRequiredComplete(BUSINESS_PLAN_QUESTIONS, session));
// => 必須完了: false
10実装:LLMへの質問プロンプト生成
次のステップは、選んだ質問をLLMが自然な文章で伝えるためのプロンプトに変換することです。 選択肢がある場合は選択肢を含め、前の回答がある場合は文脈として挿入します。
// src/lib/prompt-builder.ts
import type { Question } from "../types/question";
import type { InterviewSession } from "../types/session";
import { getAnswerValue } from "./session-manager";
interface BuildQuestionPromptOptions {
question: Question;
session: InterviewSession;
/**
* 直前の質問と回答(文脈として使用する)
*/
previousQuestion?: Question;
}
export function buildQuestionPrompt({
question,
session,
previousQuestion,
}: BuildQuestionPromptOptions): string {
const lines: string[] = [];
// 前の回答への言及(文脈の連続性を作る)
if (previousQuestion) {
const prevAnswer = getAnswerValue(session, previousQuestion.field);
if (prevAnswer) {
lines.push(
`ユーザーは先ほど「${previousQuestion.text}」という質問に対して、「${prevAnswer}」と回答しました。`
);
lines.push("この文脈を踏まえて、次の質問を自然な会話の流れで伝えてください。");
lines.push("");
}
}
// 質問本文
lines.push("次の質問をユーザーに伝えてください。");
lines.push("");
lines.push(`質問: ${question.text}`);
// 選択肢がある場合は番号付きで提示する
if (question.options && question.options.length > 0) {
lines.push("");
lines.push("以下の選択肢を番号付きで見やすく提示してください。");
question.options.forEach((opt, index) => {
lines.push(`${index + 1}. ${opt.label}`);
});
lines.push("");
lines.push(
question.type === "multi_choice"
? "複数選択可能であることを伝えてください。"
: "1つだけ選んでいただくよう伝えてください。"
);
} else {
lines.push("");
lines.push("自由に記述いただくよう伝えてください。長さは問いません。");
}
lines.push("");
lines.push("返答は1〜3文程度で簡潔にまとめてください。質問は1つだけ伝えてください。");
return lines.join("\n");
}
実際に出力されるプロンプトのイメージを示します。
ユーザーは先ほど「まず、どのような事業を考えているか、一言で教えてください。」
という質問に対して、「中小企業向けの採用管理SaaS」と回答しました。
この文脈を踏まえて、次の質問を自然な会話の流れで伝えてください。
次の質問をユーザーに伝えてください。
質問: 事業のモデルはどれに近いですか?
以下の選択肢を番号付きで見やすく提示してください。
1. BtoB(企業向けサービス)
2. BtoC(個人向けサービス)
3. BtoBtoC(企業経由で個人に届ける)
4. マーケットプレイス(売り手と買い手を繋ぐ)
5. その他(後で説明します)
1つだけ選んでいただくよう伝えてください。
返答は1〜3文程度で簡潔にまとめてください。質問は1つだけ伝えてください。
11実装:回答の解釈と正規化
ユーザーの回答は自然言語で来るため、選択肢問題でも「2番です」「BtoBですね」「企業向けです」など様々な表現になります。
これを定義した選択肢の value に正規化するロジックが必要です。
// src/lib/answer-parser.ts
import type { Question } from "../types/question";
interface ParseResult {
success: boolean;
value: string | null;
rawText: string;
}
/**
* 選択肢問題の回答テキストを、定義されたvalueに正規化する。
* LLMを使って解釈するのが最も柔軟だが、ここでは簡易的なルールベース実装を示す。
*/
export function parseChoiceAnswer(
question: Question,
userText: string
): ParseResult {
if (!question.options) {
return { success: true, value: userText.trim(), rawText: userText };
}
const normalized = userText.trim().toLowerCase();
// 番号で答えた場合(「1」「1番」「①」など)
const numberMatch = normalized.match(/^[①-⑨]|^(\d+)[番\.\s]?/);
if (numberMatch) {
const num = numberMatch[1]
? parseInt(numberMatch[1], 10)
: normalized.charCodeAt(0) - "①".charCodeAt(0) + 1;
if (num >= 1 && num <= question.options.length) {
return {
success: true,
value: question.options[num - 1].value,
rawText: userText,
};
}
}
// ラベルの部分一致(「BtoB」「企業向け」など)
const matched = question.options.find(
(opt) =>
opt.value.toLowerCase().includes(normalized) ||
opt.label.toLowerCase().includes(normalized)
);
if (matched) {
return { success: true, value: matched.value, rawText: userText };
}
// どれにも当てはまらなかった場合
return { success: false, value: null, rawText: userText };
}
正規化に失敗した場合は、もう一度聞き直すか、回答をそのまま other として記録するかを選択します。
これをLLMに再解釈させる方式にすると精度が上がりますが、API呼び出しのコストが増えます。
どちらを取るかはシステムの要件次第です。
12実装:対話ループの組み立て(Next.js API Route)
ここまでの部品を組み合わせて、Next.jsのAPI Routeとして対話ループを構築します。
// src/app/api/interview/route.ts
// Next.js v14 App Router のAPI Route
import { NextRequest, NextResponse } from "next/server";
import { BUSINESS_PLAN_QUESTIONS } from "@/data/questions";
import {
createSession,
recordAnswer,
getAnswerValue,
} from "@/lib/session-manager";
import {
selectNextQuestion,
isRequiredComplete,
} from "@/lib/question-selector";
import { buildQuestionPrompt } from "@/lib/prompt-builder";
import { parseChoiceAnswer } from "@/lib/answer-parser";
// セッションの簡易インメモリストレージ(本番ではDBやRedisを使う)
const sessionStore = new Map<string, ReturnType<typeof createSession>>();
interface RequestBody {
sessionId: string;
userMessage?: string; // 初回はundefined、2回目以降は回答テキスト
}
export async function POST(req: NextRequest) {
const body: RequestBody = await req.json();
const { sessionId, userMessage } = body;
// セッションを取得または新規作成する
let session = sessionStore.get(sessionId) ?? createSession(sessionId);
// 前回の質問(文脈継続のために保持する)
const prevQuestion = session.currentQuestionId
? BUSINESS_PLAN_QUESTIONS.find((q) => q.id === session.currentQuestionId)
: undefined;
// ユーザーの回答を記録する(2回目以降のターン)
if (userMessage && prevQuestion) {
let answerValue: string;
if (prevQuestion.type === "free") {
answerValue = userMessage.trim();
} else {
const parsed = parseChoiceAnswer(prevQuestion, userMessage);
if (!parsed.success) {
// 解釈できなかった場合は再質問を返す
return NextResponse.json({
type: "clarification",
message: `もう少し詳しく教えていただけますか?先ほどの質問にある選択肢の番号か、選択肢の内容をそのまま教えていただけると助かります。`,
questionId: prevQuestion.id,
});
}
answerValue = parsed.value!;
}
session = recordAnswer(session, prevQuestion.id, prevQuestion.field, answerValue);
}
// 全ての必須項目が揃ったか確認する
if (isRequiredComplete(BUSINESS_PLAN_QUESTIONS, session)) {
sessionStore.set(sessionId, { ...session, completedAt: new Date() });
return NextResponse.json({
type: "complete",
message: "必要な情報が揃いました。事業計画の生成を開始します。",
answers: session.answers,
});
}
// 次の質問を選ぶ
const nextQuestion = selectNextQuestion(BUSINESS_PLAN_QUESTIONS, session);
if (!nextQuestion) {
// 必須ではないが聞ける質問もない場合
sessionStore.set(sessionId, { ...session, completedAt: new Date() });
return NextResponse.json({
type: "complete",
message: "ヒアリングが完了しました。",
answers: session.answers,
});
}
// LLMへのプロンプトを生成する(ここではモック)
const prompt = buildQuestionPrompt({
question: nextQuestion,
session,
previousQuestion: prevQuestion,
});
// 実際の実装ではここでLLM APIを呼び出す
// const llmResponse = await callLLMAPI(prompt);
// 以下はデモ用のモック
const agentMessage = mockLLMCall(prompt, nextQuestion);
// 現在の質問IDをセッションに記録する
session = { ...session, currentQuestionId: nextQuestion.id };
sessionStore.set(sessionId, session);
return NextResponse.json({
type: "question",
message: agentMessage,
questionId: nextQuestion.id,
questionType: nextQuestion.type,
});
}
// モック関数(実際はLLM API呼び出しに置き換える)
function mockLLMCall(prompt: string, question: ReturnType<typeof selectNextQuestion>): string {
if (!question) return "";
if (question.options) {
const optionList = question.options
.map((o, i) => `${i + 1}. ${o.label}`)
.join("\n");
return `${question.text}\n\n${optionList}`;
}
return question.text;
}
13実装:フロントエンドとの接続(React hooks)
APIと会話するフロント側のカスタムフックを簡略版で示します。
// src/hooks/use-interview.ts
import { useState, useCallback } from "react";
interface Message {
role: "agent" | "user";
content: string;
}
interface UseInterviewReturn {
messages: Message[];
isLoading: boolean;
isComplete: boolean;
sendMessage: (text: string) => Promise<void>;
startInterview: () => Promise<void>;
}
export function useInterview(sessionId: string): UseInterviewReturn {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const callAPI = useCallback(
async (userMessage?: string) => {
setIsLoading(true);
try {
const res = await fetch("/api/interview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, userMessage }),
});
const data = await res.json();
if (data.type === "complete") {
setIsComplete(true);
setMessages((prev) => [
...prev,
{ role: "agent", content: data.message },
]);
} else if (data.type === "question" || data.type === "clarification") {
setMessages((prev) => [
...prev,
{ role: "agent", content: data.message },
]);
}
} finally {
setIsLoading(false);
}
},
[sessionId]
);
const startInterview = useCallback(() => callAPI(undefined), [callAPI]);
const sendMessage = useCallback(
async (text: string) => {
setMessages((prev) => [...prev, { role: "user", content: text }]);
await callAPI(text);
},
[callAPI]
);
return { messages, isLoading, isComplete, sendMessage, startInterview };
}
使用例を示します。
// src/app/interview/page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInterview } from "@/hooks/use-interview";
export default function InterviewPage() {
const sessionId = "user-session-001"; // 実際はユーザーIDやUUIDを使う
const { messages, isLoading, isComplete, sendMessage, startInterview } =
useInterview(sessionId);
const [inputText, setInputText] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
startInterview();
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async () => {
if (!inputText.trim() || isLoading) return;
const text = inputText;
setInputText("");
await sendMessage(text);
};
return (
<div style={{ maxWidth: 640, margin: "0 auto", padding: "1rem" }}>
<div style={{ minHeight: 400 }}>
{messages.map((msg, i) => (
<div
key={i}
style={{
textAlign: msg.role === "user" ? "right" : "left",
margin: "0.5rem 0",
}}
>
<span
style={{
display: "inline-block",
background: msg.role === "user" ? "#e0f0ff" : "#f0f0f0",
padding: "0.5rem 1rem",
borderRadius: 8,
maxWidth: "80%",
whiteSpace: "pre-wrap",
}}
>
{msg.content}
</span>
</div>
))}
<div ref={bottomRef} />
</div>
{!isComplete && (
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
<input
style={{ flex: 1, padding: "0.5rem" }}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="回答を入力..."
disabled={isLoading}
/>
<button onClick={handleSend} disabled={isLoading || !inputText.trim()}>
送信
</button>
</div>
)}
</div>
);
}
14設計を改善する:よくある落とし穴
実際に運用してわかった設計上の課題と、対応方法を紹介します。
落とし穴1:依存条件が複雑になりすぎる
dependsOn の条件が「フィールドAがXかつフィールドBがY」のような複合条件になってくると、定義が難しくなります。
筆者たちの場合、依存関係が複数になった時点でいったん設計を見直し、「この質問は本当に必要か」を確認するようにしました。 複雑な依存関係は、質問の数が多すぎるサインであることが多いです。
// 複合依存の例(シンプルに保つことを推奨します)
export interface Question {
// ...
dependsOn?: {
field: string;
value: string;
}[]; // 配列にして AND 条件とする場合
}
落とし穴2:前の回答を参照する質問テキストが硬直する
「先ほど教えていただいた〇〇についてですが」という文脈参照は、LLMに生成させると自然です。 しかし、静的な質問テキストに前の回答を文字列結合で埋め込むと、不自然な文章になりやすいです。
解決策のひとつは、buildQuestionPrompt でLLMに「自然な言い回しに直す」役割を持たせ、静的テキストはあくまで「何を聞くか」の仕様として扱うことです。
落とし穴3:スキップと「後で答える」への対応
ユーザーが「後で答えます」と言ったとき、システムがどう振る舞うかを決めておく必要があります。 回答を空文字で記録してスキップする方法と、その質問を一時的に候補から外す方法があります。
// スキップを記録する例
export function skipQuestion(
session: InterviewSession,
questionId: string,
field: string
): InterviewSession {
return recordAnswer(session, questionId, field, "__skipped__");
}
// 質問選択時にスキップ済みを除外する
const candidates = questions.filter((q) => {
const answer = session.answers[q.field];
if (answer?.value === "__skipped__") return false;
// ...以下は既存のロジック
});
15設計のバリエーション:グループ化ヒアリング
すべての質問を1問ずつ聞くのではなく、関連する2〜3問をグループにまとめて提示する方式も有効な場合があります。
「1ターン1問」の原則とは逆に見えますが、次のようなケースではグループ化が機能しやすいです。
- 回答が短い(選択肢や数値)質問が複数連続するとき
- ユーザーが「もっとテンポよく進めたい」という印象を持っていることがわかっているとき
- 質問同士が意味的に近く、まとめて提示したほうが文脈が伝わりやすいとき
グループ化する場合も、上限は2〜3問に抑えておくのが扱いやすいです。 それ以上になると「まとめた意味がなくなる」と感じています。
// 質問グループの型定義例
export interface QuestionGroup {
groupId: string;
label: string; // 「会社の基本情報」など
questionIds: string[];
maxPerTurn: number; // このグループ内で1ターンに最大何問まで聞くか
}
16まとめ
この記事では、LLMを使った対話エージェントにおけるヒアリング設計の原則と実装を段階的に解説しました。
- 1ターン1〜2項目を基本とした質問分割
- 語彙を持っていないユーザーへの選択肢提示と、自由入力を活かす場面の使い分け
- 易しい→難しいの順序設計と、前の回答を文脈として活かす方法
- TypeScriptでの実装(質問定義・状態管理・質問選択・プロンプト生成・回答解釈)
どの原則も「絶対にこうすべき」というものではありません。 ユーザーの目的、システムの性格、収集する情報の性質によって、最適な設計は変わります。 筆者たちが紹介したパターンも、BizPlanという特定の文脈で試行錯誤してきたものです。 ぜひ自分たちのシステムに合わせて調整していただければと思います。
対話設計の前段階となる成果物の定義については、こちらの記事も参考になります。 (関連記事: 成果物ドリブン設計:完成形から逆算して対話を組む)
17参考文献
- OpenAI Platform Documentation - Chat Completions (2026年6月時点)
- Zod 公式ドキュメント (v3 対応)
- Next.js 公式ドキュメント - Route Handlers (v14 App Router 対応)
- UX Research: How Many Questions Should You Ask in a Survey? - Nielsen Norman Group

