01はじめに
この記事で得られること
この記事では、私たちが開発・提供しているエージェント「BizPlan」の設計思想を公開します。 具体的には次の4点を扱います。
- フェーズ設計(ヒアリング→分析→骨子→生成→レビュー)の全体像と、各フェーズの役割
- 質問順序をどのように設計したか、なぜその順序なのか
- 事業計画書の成果物スキーマ(TypeScript型定義)
- 各設計判断の「なぜ」——何を試してうまくいかなかったか、どこに落ち着いたか
対象読者
- AIエージェントの設計に関心があるエンジニアやプロダクトマネージャー
- ハーネス設計やマルチフェーズのLLMオーケストレーションを学びたい方
- 事業計画書の生成AIへの適用について考えている方
前提知識として、TypeScriptの基本的な読み書きができると内容をより深く追えます。 LLMアプリケーションの基礎知識(プロンプト・API呼び出し)があると理想的ですが、必須ではありません。
おことわり
本記事ではBizPlanの設計思想を、簡略化した実装例とともに紹介します。 掲載するコードは解説用に再構成したものであり、実際のプロダクションコードそのものではありません。 実コードの詳細(内部APIのエンドポイント・データストア構造・顧客固有の設定値など)は含めていません。 設計の考え方とパターンを共有することが目的です。
実行環境の目安は Node.js v20 系、TypeScript 5.4 以上です。
02TL;DR
- BizPlanは5フェーズのハーネスとして設計しています。各フェーズは独立したプロンプト・スキーマを持ちます。
- 質問順序は「市場→顧客→課題→解決策→収益」の順で構築します。この順序には理由があります。
- 成果物スキーマをZodで定義し、LLMの出力を型安全に受け取る設計にしています。
- フェーズ間の状態受け渡しは
PlanContextという単一オブジェクトで管理します。
03BizPlanとは何か
BizPlanは、事業計画書の作成を支援するAIエージェントです。 起業家・新規事業担当者・副業オーナーなど、「アイデアはあるが文書化が苦手」という方が主なユーザーです。
ユーザーがチャット形式で質問に答えていくと、最終的に事業計画書のドラフトが出力されます。 いわゆる「入力フォームをAIに置き換えた」ものではなく、回答内容に応じて次の質問が変わる対話型の設計です。
なぜ「対話型」にしたか
最初の設計案は、一度に多くの質問をまとめて提示する形式でした。 しかし実際に試したところ、ユーザーが質問の多さに圧倒されて離脱するケースが多く見られました。
対話型に切り替えた後は、完了率が改善しました。 段階的に情報を引き出す設計の方が、ユーザーの認知負荷を下げやすいという経験則と一致する結果です。
ただしこれは私たちの観察であり、すべての事業計画ツールに当てはまるわけではありません。 ユーザー層や利用目的によって最適な設計は変わります。
04全体アーキテクチャ:5フェーズハーネス
BizPlanは次の5つのフェーズから構成されます。
flowchart LR
P1["Phase 1: Intake"] --> P2["Phase 2: Analysis"]
P2 --> P3["Phase 3: Outline"]
P3 --> P4["Phase 4: Generation"]
P4 --> P5["Phase 5: Review"]
P5 -->|"差し戻し"| P3
P5 -->|"差し戻し"| P4
各フェーズの役割を整理します。
| フェーズ | 名称 | 主な処理 | LLMの役割 |
|---|---|---|---|
| 1 | Intake(ヒアリング) | ユーザーへの質問・回答収集 | 質問の生成・回答の検証 |
| 2 | Analysis(分析) | 収集情報の構造化・矛盾検出 | 情報の整合性チェック・補完提案 |
| 3 | Outline(骨子生成) | 事業計画書の構造を設計 | セクション構成の提案 |
| 4 | Generation(本文生成) | 各セクションの本文を生成 | 文章化・論理展開 |
| 5 | Review(レビュー) | 生成物の品質チェック・修正指示 | 批評的レビュー |
この5フェーズは一方向に流れるだけでなく、Phase 5からPhase 3やPhase 4に差し戻すループも持ちます。 差し戻しの条件はスコアリングで判定します(後述)。
フェーズを分けた理由
最初は「1つの大きなプロンプトで全部やる」アプローチを試しました。 問題は2点ありました。
1つ目は出力の不安定さです。 ヒアリング・分析・生成を1つのプロンプトに詰め込むと、LLMが中間状態を「省略」する傾向がありました。 質問が足りないまま本文を生成してしまうなど、手順を飛ばす出力が頻発しました。
2つ目はデバッグの難しさです。 どのフェーズで問題が起きているかを特定しにくく、改善が難しい状態でした。
フェーズを分けることで、各フェーズを独立してテスト・改善できるようになりました。 コードと同様、責務の分離はLLMハーネス設計でも有効です。
05フェーズ1:Intakeの設計
質問順序の設計判断
ヒアリングフェーズでは、次の順序で質問します。
1. 市場・業界(どんな市場を対象にしているか)
2. ターゲット顧客(誰の、どんな状況の人か)
3. 課題(その顧客が持つ具体的な困りごと)
4. 解決策(自社が提供するもの)
5. 競合・差別化(市場に既存の代替手段との違い)
6. 収益モデル(どのように収益を得るか)
7. 実行計画(誰が・いつまでに・何をするか)
この順序には意図があります。
「解決策→課題」ではなく「課題→解決策」の順にしている点が特に重要です。 多くの起業家は解決策(プロダクトのアイデア)から話し始めます。 しかし事業計画書として説得力を持たせるには、課題の深さが先に確立されている必要があります。
解決策を先に聞くと、その後に課題を聞いても「解決策に合わせて課題を作る」逆算が起きやすくなります。 課題を先に聞くことで、解決策の妥当性をより客観的に評価しやすくなります。
同様に、市場を最初に定義することで、ターゲット顧客の絞り込みが具体的になります。 「誰でも使える」という回答を、より具体的なペルソナへ誘導しやすくなります。
質問生成の実装
質問は静的なリストではなく、前の回答を踏まえて動的に生成します。
// src/phases/intake/question-generator.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
import { IntakeContext } from "./types";
const QuestionSchema = z.object({
question: z.string().min(1),
purpose: z.string(), // なぜこの質問をするかの内部メモ(デバッグ用)
followUpTriggers: z.array(z.string()), // 追質問を発火させるキーワード
});
export type Question = z.infer<typeof QuestionSchema>;
const SYSTEM_PROMPT = `
あなたは事業計画書の作成を支援するインタビュアーです。
ユーザーが提供した情報を踏まえ、次に聞くべき質問を1つ生成してください。
質問生成のルール:
- 1回に1つの質問のみ生成します
- 抽象的な質問より具体的な質問を優先します
- ユーザーが既に答えた内容と重複しないようにします
- 「なぜ」を深掘りする質問を優先します
出力はJSON形式で返してください。
`.trim();
export async function generateNextQuestion(
context: IntakeContext
): Promise<Question | null> {
const answeredTopics = Object.keys(context.answers);
const remainingTopics = INTAKE_TOPICS.filter(
(topic) => !answeredTopics.includes(topic)
);
if (remainingTopics.length === 0) {
return null; // 全トピック収集完了
}
const nextTopic = remainingTopics[0];
const userMessage = `
現在のコンテキスト:
${JSON.stringify(context.answers, null, 2)}
次に収集するトピック: ${nextTopic}
このトピックについて、上記のコンテキストを踏まえた質問を生成してください。
`;
const raw = await callLLM({
system: SYSTEM_PROMPT,
user: userMessage,
responseFormat: "json",
});
const parsed = QuestionSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
console.error("Question parsing failed:", parsed.error);
return null;
}
return parsed.data;
}
const INTAKE_TOPICS = [
"market",
"target_customer",
"problem",
"solution",
"competition",
"revenue_model",
"execution_plan",
] as const;
INTAKE_TOPICS の順序が、前述した質問順序の設計判断を反映しています。
配列の先頭から処理するシンプルな実装ですが、このシンプルさが意図した順序を確実に守ります。
実行結果のイメージは次の通りです。
// context.answers が空の場合
generateNextQuestion({ answers: {} })
// => {
// question: "まず、どのような市場・業界を対象にした事業を考えていますか?",
// purpose: "市場規模・競合状況の基盤を作るため",
// followUpTriggers: ["規模", "成長", "トレンド"]
// }
// market が埋まっている場合
generateNextQuestion({ answers: { market: "..." } })
// => {
// question: "その市場の中で、特にどんな状況にいる人・企業を顧客として想定していますか?",
// purpose: "ペルソナの具体化",
// followUpTriggers: ["年齢", "職種", "規模", "状況"]
// }
回答の検証
ユーザーの回答が薄い場合(「わかりません」「まだ決めていません」など)は、 そのまま次に進まずに追質問を生成します。
// src/phases/intake/answer-validator.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
const ValidationResultSchema = z.object({
isAcceptable: z.boolean(),
reason: z.string(),
followUpQuestion: z.string().optional(),
extractedInfo: z.record(z.string()).optional(),
});
export type ValidationResult = z.infer<typeof ValidationResultSchema>;
const VALIDATION_PROMPT = `
ユーザーの回答を評価してください。
評価基準:
- 事業計画書を作成するための具体的な情報が含まれているか
- 「わかりません」「まだ決めていない」「どちらでもいい」等の回答は不十分と判定します
- 長さではなく内容の具体性で判断します
出力はJSON形式で返してください。
`.trim();
export async function validateAnswer(
topic: string,
question: string,
answer: string
): Promise<ValidationResult> {
const userMessage = `
質問のトピック: ${topic}
質問内容: ${question}
ユーザーの回答: ${answer}
この回答は事業計画書作成に十分な情報を含んでいますか?
`;
const raw = await callLLM({
system: VALIDATION_PROMPT,
user: userMessage,
responseFormat: "json",
});
const parsed = ValidationResultSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
// パース失敗時は安全側に倒してacceptableとして扱う
return { isAcceptable: true, reason: "validation_skipped" };
}
return parsed.data;
}
isAcceptable: false のときは followUpQuestion を使って追質問を表示します。
2回連続で不十分な回答が続いた場合はそのトピックをスキップし、後のフェーズで補完を試みます。
06フェーズ2:Analysisの設計
矛盾検出の仕組み
Intakeで収集した情報は、しばしば内部矛盾を含みます。 よくあるパターンは次の3つです。
- ターゲット顧客が広すぎる(「20〜60代の全員」など)
- 課題と解決策が噛み合っていない
- 収益モデルとターゲット顧客の購買力が合っていない
これらを次のスキーマで検出します。
// src/phases/analysis/contradiction-detector.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
import { IntakeAnswers } from "../intake/types";
const ContradictionSchema = z.object({
type: z.enum(["target_too_broad", "problem_solution_mismatch", "revenue_viability", "other"]),
severity: z.enum(["critical", "warning", "info"]),
description: z.string(),
affectedTopics: z.array(z.string()),
suggestion: z.string(),
});
const AnalysisResultSchema = z.object({
contradictions: z.array(ContradictionSchema),
strengthAreas: z.array(z.string()),
weakAreas: z.array(z.string()),
overallReadiness: z.number().min(0).max(100),
});
export type AnalysisResult = z.infer<typeof AnalysisResultSchema>;
export type Contradiction = z.infer<typeof ContradictionSchema>;
const ANALYSIS_PROMPT = `
あなたは経験豊富な事業アドバイザーです。
提供された事業情報を分析し、内部矛盾・強み・弱みを特定してください。
分析の観点:
1. ターゲット顧客の具体性(広すぎないか)
2. 課題と解決策の整合性(解決策は課題を本当に解決するか)
3. 収益モデルの実現可能性(顧客がその価格を払えるか)
4. 競合との差別化の明確さ
5. 実行計画の具体性
severity の基準:
- critical: このまま進めると事業計画書として成立しない
- warning: 弱点だが補足説明で対応可能
- info: 改善できるが必須ではない
overallReadiness は 0〜100 で、次フェーズ(Outline)に進む準備ができている度合いを示します。
70以上でOutlineフェーズに進めます。
出力はJSON形式で返してください。
`.trim();
export async function analyzeIntakeAnswers(
answers: IntakeAnswers
): Promise<AnalysisResult> {
const userMessage = `
以下の事業情報を分析してください:
${JSON.stringify(answers, null, 2)}
`;
const raw = await callLLM({
system: ANALYSIS_PROMPT,
user: userMessage,
responseFormat: "json",
});
const parsed = AnalysisResultSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
throw new Error(`Analysis parsing failed: ${parsed.error.message}`);
}
return parsed.data;
}
overallReadiness スコアが70未満の場合、ユーザーに「まだ情報が足りない点があります」として
Intakeフェーズに戻します。critical な矛盾が1件でもあれば、スコアに関係なく差し戻します。
スコアリングの閾値について
70という数値は経験的なものです。 最初は80に設定していましたが、厳しすぎてほとんどのユーザーがOutlineに進めない状況になりました。 一方で60にすると、情報が薄いまま本文生成に進み、品質が低下しました。 現時点では70が私たちの観察上のバランスポイントですが、ユーザー層によって調整が必要になる可能性があります。
07フェーズ3:Outlineの設計
成果物スキーマの定義
OutlineフェーズはBizPlanの設計で最も時間をかけたフェーズです。 事業計画書のセクション構成を動的に生成しますが、完全に自由な構成を許すと品質がばらつきます。
私たちは「固定スキーマ」と「動的コンテンツ」を分離するアプローチを採りました。
// src/schemas/business-plan.ts
import { z } from "zod";
// --- 固定スキーマ(セクション構成は変わらない) ---
export const ExecutiveSummarySchema = z.object({
vision: z.string().min(10).max(200),
problem: z.string().min(10).max(300),
solution: z.string().min(10).max(300),
targetMarket: z.string().min(10).max(200),
businessModel: z.string().min(10).max(200),
keyMetrics: z.array(z.string()).min(1).max(5),
});
export const MarketAnalysisSchema = z.object({
totalAddressableMarket: z.object({
size: z.string(),
source: z.string().optional(),
description: z.string(),
}),
serviceableAddressableMarket: z.object({
size: z.string(),
rationale: z.string(),
}),
targetSegment: z.object({
persona: z.string(),
characteristics: z.array(z.string()),
painPoints: z.array(z.string()),
}),
competitors: z.array(z.object({
name: z.string(),
positioning: z.string(),
weakness: z.string(),
})).min(1),
differentiators: z.array(z.string()).min(1).max(5),
});
export const RevenueModelSchema = z.object({
type: z.enum([
"subscription",
"transaction_fee",
"one_time_sale",
"freemium",
"advertising",
"licensing",
"other",
]),
description: z.string(),
pricingTiers: z.array(z.object({
name: z.string(),
price: z.string(),
features: z.array(z.string()),
})).optional(),
unitEconomics: z.object({
revenuePerCustomer: z.string(),
estimatedCAC: z.string().optional(),
estimatedLTV: z.string().optional(),
}).optional(),
});
export const ExecutionPlanSchema = z.object({
milestones: z.array(z.object({
phase: z.string(),
duration: z.string(),
keyActivities: z.array(z.string()),
successCriteria: z.string(),
})).min(1).max(6),
teamRequirements: z.array(z.object({
role: z.string(),
responsibility: z.string(),
timeline: z.string(),
})),
risks: z.array(z.object({
description: z.string(),
severity: z.enum(["high", "medium", "low"]),
mitigation: z.string(),
})).min(1),
});
// --- トップレベルスキーマ ---
export const BusinessPlanSchema = z.object({
metadata: z.object({
createdAt: z.string().datetime(),
version: z.number().int().positive(),
status: z.enum(["draft", "review", "final"]),
}),
executiveSummary: ExecutiveSummarySchema,
marketAnalysis: MarketAnalysisSchema,
productDescription: z.object({
overview: z.string(),
keyFeatures: z.array(z.string()).min(1).max(8),
differentiators: z.array(z.string()).min(1),
developmentStatus: z.enum(["concept", "prototype", "mvp", "launched"]),
}),
revenueModel: RevenueModelSchema,
executionPlan: ExecutionPlanSchema,
appendix: z.object({
assumptions: z.array(z.string()),
glossary: z.record(z.string()).optional(),
}).optional(),
});
export type BusinessPlan = z.infer<typeof BusinessPlanSchema>;
export type ExecutiveSummary = z.infer<typeof ExecutiveSummarySchema>;
export type MarketAnalysis = z.infer<typeof MarketAnalysisSchema>;
export type RevenueModel = z.infer<typeof RevenueModelSchema>;
export type ExecutionPlan = z.infer<typeof ExecutionPlanSchema>;
このスキーマ設計で意識した点が3つあります。
1. 文字数制限をスキーマに持たせる
z.string().min(10).max(200) のように長さ制限をスキーマに組み込んでいます。
LLMはプロンプトで「200文字以内で」と指示しても守らないことがあります。
Zodのバリデーションでハードに弾く方が確実です。
2. enumで型を絞る
revenueModel.type のように選択肢がある項目はenumにしています。
「サブスクリプションモデル」「月額課金」「月次課金」のように表記揺れが起きやすい項目は、
特にenumで正規化する効果が大きいです。
3. optional()を使い過ぎない
必須項目を明確にするため、optional() は本当に任意の項目にだけ使います。
LLMは空フィールドで埋めようとする傾向があるため、必須項目にすることで
「ここは情報が必要」というシグナルを出力に強制できます。
Outlineの生成実装
// src/phases/outline/outline-generator.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
import { AnalysisResult } from "../analysis/contradiction-detector";
import { IntakeAnswers } from "../intake/types";
const OutlineItemSchema = z.object({
section: z.string(),
keyPoints: z.array(z.string()).min(1).max(6),
generationHints: z.array(z.string()),
estimatedLength: z.enum(["short", "medium", "long"]),
});
const OutlineSchema = z.object({
title: z.string(),
sections: z.array(OutlineItemSchema),
generationOrder: z.array(z.string()),
totalEstimatedPages: z.number().min(1).max(30),
});
export type Outline = z.infer<typeof OutlineSchema>;
const OUTLINE_PROMPT = `
あなたは事業計画書の構成設計の専門家です。
提供された事業情報と分析結果を踏まえ、事業計画書のアウトラインを生成してください。
アウトライン生成のルール:
- 標準的な事業計画書のセクション構成に従います
- generationHints には生成フェーズで使う指示を具体的に書きます
- generationOrder は依存関係を考慮した生成順序を指定します(executiveSummaryは最後)
- 強みの領域は詳しく、弱みの領域は補足説明を充実させます
セクション: executiveSummary, marketAnalysis, productDescription, revenueModel, executionPlan
出力はJSON形式で返してください。
`.trim();
export async function generateOutline(
answers: IntakeAnswers,
analysis: AnalysisResult
): Promise<Outline> {
const userMessage = `
事業情報:
${JSON.stringify(answers, null, 2)}
分析結果:
${JSON.stringify(analysis, null, 2)}
上記を踏まえてアウトラインを生成してください。
`;
const raw = await callLLM({
system: OUTLINE_PROMPT,
user: userMessage,
responseFormat: "json",
});
const parsed = OutlineSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
throw new Error(`Outline parsing failed: ${parsed.error.message}`);
}
return parsed.data;
}
generationOrder がポイントです。
executiveSummary(エグゼクティブサマリー)を最後に生成する順序を指定しています。
多くのユーザーはエグゼクティブサマリーを「最初のページ」として読みます。 しかし生成は最後にします。他のセクションが揃った後に書いた方が、要約として整合性が高くなるためです。
08フェーズ4:Generationの設計
セクション単位での生成
Generationフェーズでは、Outlineで決めた順序に従い、セクションを1つずつ生成します。 全セクションを一括生成する方法も試しましたが、コンテキストが長くなるほど後半セクションの品質が落ちる傾向が見られました。
// src/phases/generation/section-generator.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
import {
BusinessPlan,
MarketAnalysisSchema,
ExecutiveSummarySchema,
RevenueModelSchema,
ExecutionPlanSchema,
} from "../../schemas/business-plan";
import { Outline, OutlineItem } from "../outline/outline-generator";
import { IntakeAnswers } from "../intake/types";
// セクション名とスキーマのマッピング
const SECTION_SCHEMAS: Record<string, z.ZodTypeAny> = {
executiveSummary: ExecutiveSummarySchema,
marketAnalysis: MarketAnalysisSchema,
productDescription: z.object({
overview: z.string(),
keyFeatures: z.array(z.string()),
differentiators: z.array(z.string()),
developmentStatus: z.enum(["concept", "prototype", "mvp", "launched"]),
}),
revenueModel: RevenueModelSchema,
executionPlan: ExecutionPlanSchema,
};
const BASE_SYSTEM_PROMPT = `
あなたは事業計画書のライターです。
提供された情報をもとに、指定されたセクションの内容を生成してください。
生成ルール:
- 事実と仮定を明確に区別します(仮定には「〜と想定しています」等を使います)
- 数値を使う場合は根拠を示します(「業界平均では〜」「ユーザーヒアリングによると〜」等)
- 過度に楽観的な予測を避けます
- 出力はJSON形式で返してください
`.trim();
export async function generateSection(
sectionName: string,
outlineItem: OutlineItem,
answers: IntakeAnswers,
alreadyGenerated: Partial<BusinessPlan>
): Promise<unknown> {
const schema = SECTION_SCHEMAS[sectionName];
if (!schema) {
throw new Error(`Unknown section: ${sectionName}`);
}
const schemaDescription = JSON.stringify(
(schema as z.ZodObject<z.ZodRawShape>).shape
? Object.keys((schema as z.ZodObject<z.ZodRawShape>).shape)
: [],
null,
2
);
const systemPrompt = `
${BASE_SYSTEM_PROMPT}
生成するセクション: ${sectionName}
このセクションのポイント:
${outlineItem.keyPoints.map((p) => `- ${p}`).join("\n")}
生成のヒント:
${outlineItem.generationHints.map((h) => `- ${h}`).join("\n")}
スキーマの必須フィールド: ${schemaDescription}
`;
const userMessage = `
事業情報(ヒアリング結果):
${JSON.stringify(answers, null, 2)}
既に生成済みのセクション(参照用):
${JSON.stringify(alreadyGenerated, null, 2)}
上記を踏まえて ${sectionName} セクションを生成してください。
`;
const raw = await callLLM({
system: systemPrompt,
user: userMessage,
responseFormat: "json",
});
const parsed = schema.safeParse(JSON.parse(raw));
if (!parsed.success) {
throw new Error(
`Section "${sectionName}" parsing failed: ${parsed.error.message}`
);
}
return parsed.data;
}
export async function generateAllSections(
outline: Outline,
answers: IntakeAnswers
): Promise<Partial<BusinessPlan>> {
const result: Partial<BusinessPlan> = {};
// generationOrder の順序に従って生成
for (const sectionName of outline.generationOrder) {
const outlineItem = outline.sections.find(
(s) => s.section === sectionName
);
if (!outlineItem) continue;
console.log(`Generating section: ${sectionName}`);
const sectionData = await generateSection(
sectionName,
outlineItem,
answers,
result // 既生成分を渡すことで一貫性を保つ
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result as any)[sectionName] = sectionData;
}
return result;
}
alreadyGenerated を生成済みセクションとして渡している点に注目してください。
例えば executionPlan を生成するとき、すでに生成された revenueModel の内容を参照させることで、
収益モデルと実行計画の整合性を保ちやすくしています。
09フェーズ5:Reviewの設計
レビュースコアリング
Reviewフェーズでは、生成された事業計画書をLLMが批評的な目線で評価します。 評価結果によってループバックするか最終成果物として確定するかを決めます。
// src/phases/review/plan-reviewer.ts
import { z } from "zod";
import { callLLM } from "../../lib/llm-client";
import { BusinessPlan } from "../../schemas/business-plan";
const ReviewItemSchema = z.object({
section: z.string(),
criterion: z.string(),
score: z.number().min(0).max(10),
feedback: z.string(),
actionRequired: z.boolean(),
suggestion: z.string().optional(),
});
const ReviewResultSchema = z.object({
overallScore: z.number().min(0).max(100),
items: z.array(ReviewItemSchema),
criticalIssues: z.array(z.string()),
passForRelease: z.boolean(),
loopBackTo: z.enum(["intake", "analysis", "outline", "generation", "none"]).default("none"),
});
export type ReviewResult = z.infer<typeof ReviewResultSchema>;
const REVIEW_PROMPT = `
あなたは事業計画書の審査員です。厳しく、かつ建設的なフィードバックを提供してください。
評価基準(各10点):
1. 市場分析の具体性と信頼性
2. 課題と解決策の整合性
3. 収益モデルの実現可能性
4. 競合分析の網羅性
5. 実行計画の具体性
6. リスク識別と対策の妥当性
7. 数値・根拠の明確さ
8. 文書全体の一貫性
9. 読み手(投資家・銀行等)への説得力
10. 前提・仮定の明示
overallScore は上記の加重平均(各10点×10項目)で計算します。
passForRelease の基準:
- overallScore >= 70
- criticalIssues が0件
loopBackTo の判断:
- 情報不足 → "intake"
- 矛盾・論理的問題 → "analysis"
- 構成問題 → "outline"
- 文章品質問題 → "generation"
- 問題なし → "none"
出力はJSON形式で返してください。
`.trim();
export async function reviewBusinessPlan(
plan: BusinessPlan
): Promise<ReviewResult> {
const userMessage = `
以下の事業計画書を評価してください:
${JSON.stringify(plan, null, 2)}
`;
const raw = await callLLM({
system: REVIEW_PROMPT,
user: userMessage,
responseFormat: "json",
});
const parsed = ReviewResultSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
throw new Error(`Review parsing failed: ${parsed.error.message}`);
}
return parsed.data;
}
loopBackTo フィールドで、どのフェーズに戻るかをLLM自身が判断します。
フェーズ間のループをハードコードせず、レビュー結果に基づいて動的に決まる設計です。
ただし、無限ループを防ぐため最大ループ回数を設けています。 同じフェーズへの差し戻しが2回連続した場合は、ループを打ち切って「改善提案付きのドラフト」として出力します。 完璧な計画書を作ろうとするよりも、ユーザーが手を入れられるドラフトを渡す方が実用的と判断しています。
10フェーズ間の状態管理:PlanContext
各フェーズはすべて PlanContext という単一オブジェクトを介してデータをやり取りします。
// src/types/plan-context.ts
import { IntakeAnswers } from "../phases/intake/types";
import { AnalysisResult } from "../phases/analysis/contradiction-detector";
import { Outline } from "../phases/outline/outline-generator";
import { BusinessPlan } from "../schemas/business-plan";
import { ReviewResult } from "../phases/review/plan-reviewer";
export type PhaseStatus = "pending" | "in_progress" | "completed" | "failed";
export interface PlanContext {
sessionId: string;
userId: string;
createdAt: string;
updatedAt: string;
// 各フェーズのステータス
phases: {
intake: PhaseStatus;
analysis: PhaseStatus;
outline: PhaseStatus;
generation: PhaseStatus;
review: PhaseStatus;
};
// 各フェーズの成果物
intakeAnswers?: IntakeAnswers;
analysisResult?: AnalysisResult;
outline?: Outline;
generatedPlan?: Partial<BusinessPlan>;
reviewResult?: ReviewResult;
// ループ管理
loopHistory: Array<{
fromPhase: string;
toPhase: string;
reason: string;
timestamp: string;
}>;
maxLoops: number;
}
export function createPlanContext(sessionId: string, userId: string): PlanContext {
return {
sessionId,
userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phases: {
intake: "pending",
analysis: "pending",
outline: "pending",
generation: "pending",
review: "pending",
},
loopHistory: [],
maxLoops: 3,
};
}
export function shouldLoop(context: PlanContext): boolean {
const recentLoops = context.loopHistory.slice(-2);
if (recentLoops.length < 2) return true;
// 同じフェーズへの差し戻しが2回連続なら打ち切る
const [prev, curr] = recentLoops;
if (prev.toPhase === curr.toPhase) {
console.warn(`Loop limit reached for phase: ${curr.toPhase}`);
return false;
}
return context.loopHistory.length < context.maxLoops;
}
PlanContext を単一オブジェクトにまとめた理由は、永続化を簡単にするためです。
各フェーズの完了時点でこのオブジェクトをデータストアに保存することで、
ユーザーが途中離脱してセッションが切れても再開できます。
// src/orchestrator.ts(簡略版)
import { PlanContext, createPlanContext, shouldLoop } from "./types/plan-context";
import { generateNextQuestion } from "./phases/intake/question-generator";
import { validateAnswer } from "./phases/intake/answer-validator";
import { analyzeIntakeAnswers } from "./phases/analysis/contradiction-detector";
import { generateOutline } from "./phases/outline/outline-generator";
import { generateAllSections } from "./phases/generation/section-generator";
import { reviewBusinessPlan } from "./phases/review/plan-reviewer";
import { BusinessPlanSchema } from "./schemas/business-plan";
export async function runBizPlan(
sessionId: string,
userId: string,
onQuestion: (question: string) => Promise<string>
): Promise<PlanContext> {
const context = createPlanContext(sessionId, userId);
// Phase 1: Intake
context.phases.intake = "in_progress";
// … ヒアリングループ(省略)…
context.phases.intake = "completed";
// Phase 2: Analysis
context.phases.analysis = "in_progress";
context.analysisResult = await analyzeIntakeAnswers(context.intakeAnswers!);
context.phases.analysis = "completed";
// Phase 3: Outline
context.phases.outline = "in_progress";
context.outline = await generateOutline(
context.intakeAnswers!,
context.analysisResult
);
context.phases.outline = "completed";
// Phase 4 & 5: Generation → Review ループ
let currentPhase: "generation" | "review" = "generation";
while (shouldLoop(context)) {
if (currentPhase === "generation") {
context.phases.generation = "in_progress";
context.generatedPlan = await generateAllSections(
context.outline!,
context.intakeAnswers!
);
context.phases.generation = "completed";
currentPhase = "review";
} else {
context.phases.review = "in_progress";
const fullPlan = BusinessPlanSchema.safeParse({
metadata: {
createdAt: new Date().toISOString(),
version: 1,
status: "draft",
},
...context.generatedPlan,
});
if (!fullPlan.success) {
// スキーマ不一致はgenerationに差し戻す
context.loopHistory.push({
fromPhase: "review",
toPhase: "generation",
reason: "schema_validation_failed",
timestamp: new Date().toISOString(),
});
currentPhase = "generation";
continue;
}
context.reviewResult = await reviewBusinessPlan(fullPlan.data);
context.phases.review = "completed";
if (context.reviewResult.passForRelease) {
break; // 完成
}
const loopTo = context.reviewResult.loopBackTo;
if (loopTo === "none") break;
context.loopHistory.push({
fromPhase: "review",
toPhase: loopTo,
reason: context.reviewResult.criticalIssues.join(", "),
timestamp: new Date().toISOString(),
});
// ループバック先に応じて currentPhase を設定
// (outline・intake への差し戻しは省略)
currentPhase = "generation";
}
}
context.updatedAt = new Date().toISOString();
return context;
}
11設計を振り返って:うまくいったこととうまくいかなかったこと
うまくいったこと
Zodによるスキーマバリデーションは期待以上に効果的でした。
LLMの出力を型安全に受け取れるようになり、想定外のフィールド・型ずれによるバグが大幅に減りました。
safeParse を使ってパース失敗を明示的にハンドリングする設計も、デバッグのしやすさに貢献しています。
フェーズの責務分離も有効でした。 レビューで問題が見つかったとき、どのフェーズのプロンプトを改善すればよいかが明確になりました。 一枚岩のシステムではこの特定が難しく、改善のサイクルが遅くなっていたと思います。
うまくいかなかったこと・現在進行形の課題
数値の扱いは依然として難しいです。
「市場規模は1,000億円」のような数値をLLMが生成しますが、根拠がない場合があります。
現状は source フィールドを必須に近い形で設けて根拠の明示を促していますが、
完全に解決できているわけではありません。
長いヒアリングの離脱も課題です。 Intakeフェーズが7つのトピックをカバーするため、完了までに時間がかかります。 途中保存・再開の仕組みは実装しましたが、そもそも「短く終わる体験」には至っていません。 質問数を減らすか、非同期で質問を送り続けるかなど、引き続き検討中です。
12まとめ
BizPlanのハーネス設計を、フェーズ設計・質問順序・成果物スキーマ・設計判断の理由という観点で紹介しました。
設計の核心をまとめると次の通りです。
- フェーズを分けることで、問題の特定・改善を独立して行えるようになりました
- 質問順序は「市場→顧客→課題→解決策」の順が、論理的整合性の確保に有効でした
- ZodスキーマをLLM出力のバリデーションに使うと、型安全性と開発効率が向上します
- レビューフェーズをループの判断役にすることで、品質ゲートを自律化できます
本記事はケーススタディ連載の第1弾です。 この記事で紹介した設計要素のそれぞれは、過去の設計記事(「エージェントのハーネス設計入門」「成果物ドリブン設計」「質問設計の技術」)で原則を扱っています。 BizPlanはその原則の実践例という位置づけです。
次回は、生成された事業計画書をユーザーが編集・フィードバックする「コパイロットモード」の設計を紹介する予定です。
13参考文献
- Zod 公式ドキュメント
- OpenAI API — Structured Outputs (2026年時点)
- LangGraph — Multi-agent Orchestration (2026年時点)
- YCombinator — How to Write a Business Plan (2026年時点参照)

