01はじめに
この記事で得られること
この記事では、私たちが開発・提供しているエージェント「BizPlan」の全体アーキテクチャを紹介します。 具体的には次の点を扱います。
- システム全体を構成する6レイヤーの役割と、各層の責務の境界
- 「約6割が再利用可能な汎用基盤、約4割がドメイン固有」という設計思想の背景
- ハーネス核(LLMハーネス層)の主要コンポーネント:Function Callingループ・RAG自動注入・ストリーム抽象
- リクエストが認証からSSEレスポンスに至るまでの全体フロー
- 新しいドメインを追加する際に、基盤部分を改修せずに済む拡張設計の考え方
対象読者
- AIエージェントのシステム設計に関心があるエンジニア
- マルチエージェント基盤の設計パターンを学びたい方
- LLMアプリケーションを「スケールできる構造」で作るための参考を探している方
前提知識として、TypeScriptの基本的な読み書きと、LLMのAPI呼び出し(プロンプト・Function Calling)の概念を理解していると内容を追いやすいです。
おことわり
本記事は公開可能な範囲で設計思想を紹介するものです。 セキュリティの観点から、実際の構成・名称・数値の一部は簡略化・変更しています。 掲載するコードは解説用に再構成したものであり、プロダクションコードそのものではありません。 実際のディレクトリ構成・APIルート数・内部モデル名の網羅的な列挙は省略しています。
実行環境の目安は Node.js v20 系、TypeScript 5.4 以上です。
関連記事
本記事は以下の記事の姉妹編です。 BizPlanが「何をするか(5フェーズハーネス)」は姉妹記事で解説しており、本記事はそれを「どう支えるか(レイヤー構造と基盤設計)」を扱います。
- 関連記事: BizPlanの裏側:事業計画エージェントのハーネス設計を公開します
- 関連記事: ハーネス設計入門:対話を成果物に収束させる仕組みの作り方
- 関連記事: エージェントの責務分割:1エージェント1タスク設計の考え方
02TL;DR
- BizPlanは6つのレイヤーが単方向に依存する構造を持ちます。上位層ほどドメイン固有、下位層ほど汎用です。
- 全体の約6割は他のAIドメインにも再利用できる汎用基盤です。新ドメインを追加する際に書くのは、主にプロンプトとツール定義だけで済みます。
- ハーネス核(LLMハーネス層)が最も重要な資産です。Function Callingループ・リトライ・メトリクス記録・RAG注入を一箇所に集約し、ドメインの知識を一切持ちません。
- HTTP非依存のストリーム抽象(
StreamEvent)により、LLMの出力がAPIの実装詳細に依存しない設計になっています。
03なぜレイヤー構造にしたか
BizPlanの最初期プロトタイプは、単一のエージェントクラスにすべての責務を詰め込んだ構造でした。 LLM呼び出し・認証チェック・データ保存・プロンプト組み立てが1つのファイルに混在し、変更のたびに全体への影響を確認しなければならない状態でした。
この構造の課題は主に2点ありました。
1つ目は、テストの困難さです。 LLM呼び出しを検証しようとすると、DBアクセスや認証処理も同時に走ります。 単体テストを書けず、結合テストのみで品質を担保しなければならない状態が続きました。
2つ目は、再利用性の欠如です。 BizPlanで試した設計パターンを別のドメインに転用しようとしても、ドメイン固有のコードと基盤コードが絡み合っており、切り出すのが困難でした。
レイヤー構造への移行は、この2つの課題を解消することが主な目的でした。 「各層は1つ下の層にしか依存しない」という単方向依存のルールを設けることで、変更の影響範囲を予測しやすくなりました。
046レイヤーの全体マップ
現在のBizPlanは、次の6つのレイヤーから構成されます。
flowchart TD
L1["① プレゼンテーション層
UIコンポーネント・ページルーティング
← ドメイン固有 + 汎用"]
L2["② API層 / 認可
認証→検証→委譲→レスポンス の統一パターン
← 汎用"]
L3["③ エージェント実装層
オーケストレータ・ドメイン専門家エージェント
← ドメイン固有"]
L4["④ LLMハーネス層(核)
BaseAgent・BaseExpertAgent・StreamEvent・ToolContext
← ハーネス核(最重要)"]
L5["⑤ 共通基盤層
認証・LLMクライアント・AIガード・RAG・メトリクス等
← 汎用"]
L6["⑥ データ / 永続化層
ORM・ベクトルDB・スキーマ定義
← 汎用 + ドメイン固有"]
L1 -->|依存| L2
L2 -->|依存| L3
L3 -->|依存| L4
L4 -->|依存| L5
L5 -->|依存| L6
note["依存方向: ① → ⑥(下位層は上位層を知らない)"]
style note fill:#f9f9f9,stroke:#ccc,color:#666
上位層ほどドメイン(事業計画)の知識を持ちます。 下位層ほど汎用で、ドメインに関する知識を持ちません。 これにより、下位層を改修せずに上位層だけを差し替えて別ドメインへ適用できます。
05各層の責務
① プレゼンテーション層
ユーザーが操作する画面全体を担当します。 保護されたダッシュボード・事業計画の編集画面・パブリックな閲覧ページなど、複数の画面カテゴリを含みます。
UIコンポーネントライブラリは汎用部品として切り出しており、ドメイン固有の画面はそれらを組み合わせて構築します。 チャートやデータ入力フォームなどのドメイン固有コンポーネントは、別の名前空間に分離しています。
この層で注意しているのは、「UIコンポーネントにビジネスロジックを書かない」という原則です。 コンポーネントはAPIを呼ぶか、状態を表示するかのどちらかに留め、判断ロジックは下位層に委譲します。
② API 層 / 認可
HTTPリクエストを受け取り、認証・入力検証を行ったうえで下位層に処理を委譲します。 すべてのAPIエンドポイントは、次の統一パターンに従います。
// 解説用に再構成した統一パターンの骨格
export async function POST(req: Request) {
// 1. 認証:セッションを確認し、未認証なら 401 を返す
const session = await auth();
if (!session) {
return ApiResponse.unauthorized();
}
// 2. 入力検証:Zod スキーマでリクエストボディを検証する
const parsed = RequestSchema.safeParse(await req.json());
if (!parsed.success) {
return ApiResponse.badRequest(parsed.error);
}
// 3. 権限確認:操作対象のリソースへのアクセス権を確認する
const authorized = await checkOwnership(session.user.id, parsed.data.planId);
if (!authorized) {
return ApiResponse.forbidden();
}
// 4. 委譲:ロジックは下位層(エージェント・サービス)に任せる
const result = await someService.execute(parsed.data);
// 5. レスポンス:統一形式で返す
return ApiResponse.ok(result);
}
この「認証→検証→権限→委譲→レスポンス」のパターンをすべてのエンドポイントで遵守することで、API層に独自ロジックが混入しにくくなります。
ストリーミングAPIは少し異なります。
エージェントからの出力を AsyncGenerator として受け取り、Server-Sent Events(SSE)形式に変換してクライアントへ流します。
この変換ロジックもAPI層の責務です。LLMハーネス層は「何を流すか」だけを担当し、「どう流すか(SSEのフォーマット)」はAPI層が担当します。
// SSE 変換の骨格(解説用)
export async function GET(req: Request) {
const session = await auth();
if (!session) return ApiResponse.unauthorized();
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// エージェントは StreamEvent を yield するだけで、HTTP を知らない
for await (const event of agent.streamChat(messages)) {
const data = `data: ${JSON.stringify(event)}\n\n`;
controller.enqueue(encoder.encode(data));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
③ エージェント実装層
BizPlanのドメインロジックが集中する層です。 複数のエージェントが協調して動作します。
エージェントは大きく2種類に分かれます。
オーケストレータは全体の進行を管理します。 ユーザーとの対話を受け取り、処理の流れを制御し、必要に応じてドメイン専門家エージェントをツール経由で呼び出します。 複数のフェーズ(ヒアリング・分析・生成など)を跨いで状態を管理するのもオーケストレータの役割です。
ドメイン専門家エージェントは特定テーマの深掘りを担当します。 市場分析・法規制・財務などの領域ごとに専門家エージェントが存在し、オーケストレータからの問い合わせに答えます。 各専門家エージェントはRAGを標準装備しており、ナレッジベースを参照しながら回答を生成します。
各エージェントのファイル構成は統一されています。
agent-name/
Agent.ts # エージェント本体(BaseAgent または BaseExpertAgent を継承)
prompts.ts # system prompt・プロンプトテンプレート
tools.ts # ツール定義とハンドラ
types.ts # このエージェント固有の型定義
この統一構成により、新しいエージェントを追加する際の学習コストが下がります。 どこに何を書くかが明確なため、既存エージェントを参照しながら新しいエージェントを実装できます。
④ LLM ハーネス層(ハーネス核)
全体で最も重要なレイヤーです。 ここに詳細な解説を割き、次のセクションで掘り下げます。
概要だけ先に述べると、この層はLLMとのやり取りに必要な「共通の面倒ごと」をすべて引き受けます。 Function Callingのループ制御・レート制限によるリトライ・メトリクスの記録・RAGの注入・ストリームの抽象化といった処理が、ドメインの知識をまったく持たずに実装されています。
これにより、エージェント実装層は「どの順序で何を考えるか(プロンプトとツール)」だけに集中できます。
⑤ 共通基盤層
複数の層から横断的に利用される共通サービス群です。 認証クライアント・LLMクライアント・AIガード・ベクトル検索・ストレージ・コスト計測・メトリクス・レート制限・キャッシュ・バリデーションスキーマなどを含みます。
この層の特徴は、主要コンポーネントが「プロバイダ抽象」を持っている点です。 例えばLLMクライアントは、Azure OpenAI・OpenAI・Anthropic・その他のマネージドAPIを環境変数の切り替えだけで差し替えられます。 ストレージやキャッシュバックエンドも同様に差し替え可能な構造にしています。
これにより、本番環境・ステージング環境・ローカル開発環境で異なるバックエンドを使いながら、アプリケーションコードを変えずに済みます。
⑥ データ / 永続化層
データの定義と保存を担当します。 ORMとしてPrismaを使い、PostgreSQLへ永続化します。 ベクトル検索にはpgvectorを使用しており、RAGのナレッジ検索と通常のRDBクエリを同一DBで扱えます。
スキーマは「基盤系モデル」と「ドメイン系モデル」が混在します。
基盤系モデルはユーザー・セッション・メトリクス・AIガードログ・レート制限バケット・ナレッジチャンクなどです。 これらは新しいドメインに転用する際も共通して使えます。
ドメイン系モデルは事業計画・ヒアリングセッション・評価レポートなど、BizPlan固有のエンティティです。 これらは別ドメインを構築する際には書き直します。
06LLM ハーネス層の詳細設計
BaseAgent:Function Calling ループの骨格
BaseAgent はすべてのエージェントの基底クラスです。
LLMとのやり取りに関する「共通の面倒ごと」を実装し、サブクラスはドメイン固有のプロンプトとツールだけを定義します。
最も核心的な処理はFunction Callingのループです。 LLMがツール呼び出しを返した場合、その結果を会話履歴に追加して再度LLMを呼び出す、というサイクルを繰り返します。
// Function Calling ループの骨格(解説用に再構成)
abstract class BaseAgent {
// サブクラスが実装するインターフェース
abstract getTools(): ToolDefinition[];
abstract getToolHandlers(): Map<string, ToolHandler>;
abstract setToolContext(context: ToolContext): void;
private readonly MAX_ITERATIONS = 10; // 暴走防止の上限
async *streamChat(
messages: Message[],
systemPrompt: string
): AsyncGenerator<StreamEvent> {
const tools = this.getTools();
const handlers = this.getToolHandlers();
let iteration = 0;
let reachedLimit = false;
const conversationHistory = [...messages];
while (iteration < this.MAX_ITERATIONS) {
iteration++;
// LLM を呼び出す(プロバイダ抽象を通じて)
const response = await this.llmClient.streamCompletion({
model: this.modelId,
system: systemPrompt,
messages: conversationHistory,
tools,
tool_choice: "auto",
max_completion_tokens: 4096,
});
// ストリーミングのデルタを蓄積して完成形を再構築する
const assembled = await this.assembleStream(response);
// テキスト断片をそのまま StreamEvent として yield する
for (const chunk of assembled.textChunks) {
yield { type: "text", content: chunk };
}
// ツール呼び出しがなければ正常終了
if (assembled.toolCalls.length === 0) {
break;
}
// 次の反復が上限を超える場合は上限到達フラグを立てて終了
if (iteration >= this.MAX_ITERATIONS) {
reachedLimit = true;
break;
}
// ツール呼び出しを処理して会話履歴に追加する
const toolResults = await this.executeToolCalls(
assembled.toolCalls,
handlers
);
conversationHistory.push({
role: "assistant",
tool_calls: assembled.toolCalls,
});
for (const result of toolResults) {
conversationHistory.push({
role: "tool",
tool_call_id: result.toolCallId,
content: result.content,
});
// ツール実行結果も StreamEvent として通知する
yield { type: "tool_result", toolName: result.toolName, content: result.content };
}
}
// 上限到達をメトリクスに記録する(例外にはしない)
if (reachedLimit) {
await this.metrics.record({ event: "max_iterations_reached", agentId: this.agentId });
yield { type: "warning", content: "最大反復数に到達しました。処理を終了します。" };
}
}
}
ポイントは3点あります。
反復上限による暴走防止です。
Function Callingループは理論上無限に続く可能性があります。
MAX_ITERATIONS を設けることで、ループが際限なく続く事態を防ぎます。
上限到達時は例外を投げるのではなく、メトリクスに記録してクライアントに通知する設計にしています。
例外で終了すると部分的な出力がクライアントに届かなくなるため、このような扱いにしています。
レート制限リトライの透過的処理です。
LLMのAPIはリクエスト数やトークン数の制限に達すると HTTP 429 を返します。
BaseAgent はこれを自動的に検出し、Retry-After ヘッダーの値を尊重しながら指数バックオフで再試行します。
サブクラスはこの処理を意識する必要がありません。
// リトライロジックの骨格(解説用)
private async callWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
let attempt = 0;
while (attempt <= maxRetries) {
try {
return await fn();
} catch (error) {
if (!isRateLimitError(error) || attempt === maxRetries) {
throw error;
}
const retryAfter = extractRetryAfter(error) ?? Math.pow(2, attempt) * 1000;
await sleep(retryAfter);
attempt++;
}
}
throw new Error("最大リトライ数を超えました");
}
メトリクス自動記録の強制です。 すべてのエージェント実行でメトリクス(実行時間・使用トークン数・ツール呼び出し回数・成功率)が記録されます。 オプトイン式にしていないのは意図的な選択です。 任意にしてしまうと、新しいエージェントを追加した際にメトリクスが欠落しがちになるためです。
ToolContext:コンテキストの一元管理
ツールハンドラが複数になると、各ハンドラが共通して必要とする情報(プランID・ユーザーID・セッションIDなど)を何度も受け渡す問題が起きます。
ToolContext はこれを解決するための仕組みです。
// ToolContext の定義(解説用)
interface ToolContext {
planId: string;
userId: string;
sessionId?: string;
// 必要に応じて拡張する
}
// ToolHandler の統一シグネチャ
type ToolHandler = (
params: Record<string, unknown>,
context: ToolContext
) => Promise<ToolResult>;
エージェントの初期化時に一度 setToolContext でコンテキストをセットすると、以降のすべてのツールハンドラに同じコンテキストが渡されます。
各ハンドラはシグネチャを統一でき、テスト時にはコンテキストをモックに差し替えることで単体テストが書きやすくなります。
// エージェント初期化から実行までの流れ(解説用。クラス名は架空のものです)
const agent = new DialogueAgent();
agent.setToolContext({
planId: "plan-abc123",
userId: "user-xyz789",
});
// 以降の streamChat 呼び出しで、すべてのツールハンドラが
// planId・userId を参照できる
for await (const event of agent.streamChat(messages, systemPrompt)) {
// ...
}
StreamEvent:HTTP 非依存のストリーム抽象
LLMの出力をクライアントに届けるまでには、いくつかの変換ステップがあります。
LLMのデルタストリーム → エージェントの StreamEvent → SSE フォーマット → クライアント
この中で、エージェントが関与するのは StreamEvent までです。
SSEへの変換はAPI層の責務です。
// StreamEvent の型定義(解説用)
type StreamEvent =
| { type: "text"; content: string }
| { type: "tool_result"; toolName: string; content: string }
| { type: "phase_change"; from: string; to: string }
| { type: "warning"; content: string }
| { type: "done" };
この設計により、エージェントのテストではSSEサーバーを起動せずに、StreamEvent の列をそのまま検証できます。
また将来的に配信プロトコルを変更しても(WebSocketへの切り替えなど)、エージェントのコードを一切変えずに済みます。
// テストでの使い方のイメージ(解説用)
const events: StreamEvent[] = [];
for await (const event of agent.streamChat(testMessages, testPrompt)) {
events.push(event);
}
// SSE サーバーを起動せずに StreamEvent だけを検証できる
const textEvents = events.filter((e) => e.type === "text");
expect(textEvents.length).toBeGreaterThan(0);
BaseExpertAgent:RAG の標準装備
専門家エージェントは BaseExpertAgent を継承します。
BaseExpertAgent は BaseAgent を継承しつつ、RAG(Retrieval-Augmented Generation)を標準装備します。
// BaseExpertAgent の骨格(解説用)
abstract class BaseExpertAgent extends BaseAgent {
// ナレッジ検索のクエリをサブクラスが実装する
abstract buildKnowledgeQuery(messages: Message[]): string;
async *streamChat(
messages: Message[],
systemPrompt: string
): AsyncGenerator<StreamEvent> {
// 1. ナレッジベースから関連情報を検索する
const query = this.buildKnowledgeQuery(messages);
const knowledgeChunks = await this.knowledgeClient.search(query, {
topK: 5,
threshold: 0.75,
});
// 2. 検索結果を system prompt に自動注入する
const enrichedPrompt = this.injectKnowledge(systemPrompt, knowledgeChunks);
// 3. ガードレール(プロンプトインジェクション対策)を適用する
const guardedPrompt = await applyGuardrailPolicy(enrichedPrompt);
// 4. 親クラス(BaseAgent)の Function Calling ループを呼ぶ
yield* super.streamChat(messages, guardedPrompt);
}
private injectKnowledge(prompt: string, chunks: KnowledgeChunk[]): string {
if (chunks.length === 0) return prompt;
const knowledgeSection = chunks
.map((c) => `## 参考情報\n${c.content}`)
.join("\n\n");
return `${prompt}\n\n---\n${knowledgeSection}`;
}
}
専門家エージェントはこのクラスを継承し、buildKnowledgeQuery と prompts.ts / tools.ts を実装するだけです。
RAGの検索ロジックやガードレールの適用は BaseExpertAgent が担うため、専門家エージェントごとに同じ処理を書く必要がありません。
LLMClient:プロバイダ抽象
LLMClient はLLM APIへのリクエストを抽象化します。
インターフェースは1つで、バックエンドとして複数のプロバイダを切り替えられます。
// LLMClient インターフェースの骨格(解説用)
interface LLMClient {
streamCompletion(params: CompletionParams): Promise<CompletionStream>;
complete(params: CompletionParams): Promise<CompletionResult>;
}
// 統一されたリクエスト形式(独自の正準形。system はトップレベルに分離して保持し、各プロバイダ形式へは LLMClient 実装内で変換する)
interface CompletionParams {
model: string;
system: string;
messages: Message[];
tools?: ToolDefinition[];
tool_choice?: "auto" | "none" | "required";
max_completion_tokens?: number;
// temperature 等のサンプリングパラメータは使用しない
}
現在対応しているプロバイダは Azure OpenAI・OpenAI・Anthropic・その他のマネージドAPIです。
正準形は CompletionParams で定義した独自形式で、system をトップレベルに分離して保持します。
各プロバイダへのリクエストへの変換は LLMClient の実装内で行うため、エージェントのコードはプロバイダの差異を意識せずに済みます。
07リクエストフローの全体像
ユーザーがBizPlanのチャット画面で送信ボタンを押してから、SSEでレスポンスが返るまでの流れを追います。
flowchart TD
C1["クライアント
POST /api/plans/planId/chat/stream
(パスは解説用の例です)"]
S1["① ミドルウェア
認証ゲート:セッションを確認して保護ルートを判定する"]
S2["② APIエンドポイント
auth() でセッション確認
Zod で入力検証
inputGuard() で AIガード(プロンプトインジェクション・ブロックリスト検査)
レート制限チェック(ユーザー単位・IP単位・プラン単位など複数層)"]
S3["③ エージェント生成・コンテキスト注入
new DialogueAgent() を生成する(クラス名は解説用の例です)
setToolContext でコンテキストをセットする"]
S4["④ Function Callingループ(BaseAgent)
LLMClient 経由で LLM を呼び出す
ツール呼び出し → ドメイン専門家エージェントをネスト起動(RAG注入あり)
結果を tool_result として親の会話履歴に追加する
StreamEvent を yield する"]
S5["⑤ メトリクス記録(BaseAgent が自動実行)
実行時間・使用トークン・ツール呼び出し回数・成功率を永続化する"]
S6["② APIエンドポイント(ストリーム変換)
StreamEvent を SSE 形式に変換する
outputGuard() で出力を検査する(PIIマスク・ブロックリスト)"]
C2["クライアント(SSE で受信)"]
C1 --> S1
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> S5
S5 --> S6
S6 --> C2
このフローで注目したい点は2つあります。
AIガードが入口と出口の両方にある点です。 入力ガードはプロンプトインジェクションや不適切なリクエストを弾きます。 出力ガードは個人情報のマスクやブロックリストへの適合を確認します。 エージェント本体の外側でガードを入れることで、エージェントのコードをシンプルに保てます。
レート制限を複数の粒度で設ける点です。 ユーザー単位・IPアドレス単位・プラン単位など、複数の粒度でレート制限を設けています。 これにより、1ユーザーが大量リクエストを出しても他ユーザーへの影響を最小化できます。
08オーケストレータと専門家エージェントの協調
BizPlanでは、オーケストレータが専門家エージェントをツール経由でネスト呼び出しします。
この設計パターンの利点は、専門家エージェントを「ツール」として扱えることです。 オーケストレータは「どの専門家に何を聞くか」を決めるだけで、専門家の内部実装(RAGの設定・プロンプト・ツール定義)を知る必要がありません。
// オーケストレータのツール定義の例(解説用)
const tools: ToolDefinition[] = [
{
type: "function",
function: {
name: "consult_domain_expert",
description:
"ドメイン専門家エージェントに市場・競合・法規制などの専門的な分析を依頼します。",
parameters: {
type: "object",
properties: {
topic: {
type: "string",
description: "相談するトピック(例:市場規模分析、競合優位性など)",
},
query: {
type: "string",
description: "専門家への具体的な問い合わせ内容",
},
},
required: ["topic", "query"],
},
},
},
];
// ツールハンドラの実装(解説用)
const handlers: Map<string, ToolHandler> = new Map([
[
"consult_domain_expert",
async (params, context) => {
const { topic, query } = params as { topic: string; query: string };
// 専門家エージェントをネスト起動する
const expert = createExpertAgent(topic);
expert.setToolContext(context); // ToolContext を引き継ぐ
const response = await expert.consult(query);
return { content: response };
},
],
]);
この設計には注意点もあります。 ネストの深さが増すほど、トレースが難しくなります。 私たちは原則「オーケストレータ→専門家」の1段ネストに留めており、専門家がさらに別の専門家を呼び出す構造は避けています。 呼び出し深度が深くなると、メトリクスの集計やデバッグが複雑になるためです。
09汎用性のマップ:何が再利用できて何が書き換えになるか
BizPlanのコードベースを「新しいドメインへ転用する」という観点で分類すると、次のようになります。
| 分類 | 対象 | 新ドメインでの扱い |
|---|---|---|
| ハーネス核 | LLMハーネス層全体(BaseAgent・StreamEvent・ToolContext・LLMClient) | そのまま再利用。改修不要 |
| 汎用基盤 | 認証・AIガード・RAGクライアント・ストレージ・メトリクス・レート制限・キャッシュ | そのまま再利用。ENV 切替で別環境に適合 |
| 半汎用 | BaseExpertAgent を継承した専門家エージェントの「枠」 | 枠(RAG注入・ガードレール)は再利用。プロンプトとツールは差し替え |
| ドメイン固有 | オーケストレータのプロンプト・ツール定義、事業計画固有のスキーマ・UI | 新ドメインの仕様に合わせて再実装 |
全体の設計比率として、「約6割が汎用基盤・約4割がドメイン固有」を目指しています。 この比率は現在の実装から算出した目安であり、正確な数値ではありません。
重要なのは比率そのものではなく、「ドメイン固有のコードをどこまで上位層に押し上げられるか」という設計上の問いを持ち続けることです。 ドメインロジックが下位層に染み出してしまうと、次のドメインを追加するたびに基盤を改修しなければならなくなります。
10新しいドメインを追加するには
上記の設計を前提に、新しいドメイン(例えば「補助金申請書作成エージェント」)を追加する場合の手順は次のようになります。
1. エージェント本体を作成する
└── BaseAgent を継承したクラスを1つ作成する
2. system prompt を書く(prompts.ts)
└── 新ドメインの指示・役割・出力形式を記述する
3. ツール定義とハンドラを書く(tools.ts)
└── ドメイン固有の操作(データ保存・外部API呼び出しなど)を実装する
4. ドメイン固有のDBスキーマを追加する
└── 汎用スキーマはそのまま流用し、ドメイン固有のモデルだけ追加する
5. API エンドポイントを追加する
└── 統一パターン(認証→検証→委譲)に従って実装する
LLMハーネス層・共通基盤層・API層の統一パターン・汎用スキーマはすべて改修不要です。 書くのは「このドメインで何をするか」の部分だけです。
この設計に至った動機の1つは、「BizPlanで作ったものを次のプロダクトでも使いたい」という具体的なニーズでした。 汎用基盤をドメイン固有から切り離すことで、次のプロダクトの立ち上げコストを下げることを意図しています。
11横断的関心事:全層に適用される仕組み
レイヤー構造に収まらない処理がいくつかあります。 これらは「横断的関心事(cross-cutting concerns)」と呼ばれる領域です。
認証 / 認可
認証ミドルウェアがすべてのリクエストに対して認証ゲートとして機能します。 加えて各APIエンドポイントでもセッションを確認し、リソースへのアクセス権をチェックします。
二重チェックになっている理由は、ミドルウェアは「ルートへのアクセス可否」を判断し、エンドポイントは「特定リソースへのアクセス可否」を判断するためです。
例えば /plans/[id]/... へのアクセスはミドルウェアでは認証済みであれば許可されますが、その id がリクエストしたユーザーのものかどうかはエンドポイントで確認します。
AIガード
入力と出力の両方にガードを設けています。 実体は共通基盤層にあり、API層から呼び出されます。 プロバイダ抽象になっているため、利用するモデレーションAPIを環境変数で切り替えられます。
メトリクス計測
BaseAgent が自動的に記録します。 実行時間・使用トークン・ツール呼び出し回数・エラー率などを永続化しています。 これにより、どのエージェントがコストを使っているか、どのツール呼び出しが失敗しやすいかを把握できます。
レート制限
複数の粒度(ユーザー単位・IP単位・プラン単位など)でレート制限を設けています。 バックエンドはメモリ(開発環境)とRedis(本番環境)を ENV で切り替えます。
12設計上のトレードオフ
この設計を採用した際のトレードオフも正直に書いておきます。
層が増えるほど追う場所が増えるという点があります。 「この処理がどこで実行されているか」を追うには、複数のファイルを見る必要があります。 処理の流れが明確になる一方で、コードジャンプの回数は増えます。
抽象化のコストがあるという点もあります。
LLMClient や StreamEvent のような抽象を導入すると、実際の処理がどこで行われているかを知るためにインターフェースと実装を両方読む必要があります。
小規模なプロジェクトではこのコストが見合わないこともあります。
私たちの場合は、複数のドメインへの転用という具体的なメリットがあるため、このトレードオフを受け入れています。
初期設計コストが高い点も正直なところです。 レイヤー構造を最初から設計・維持するには、チームで共通認識を持つ必要があります。 「なぜこの層に書くのか」という判断を全員が同じ基準でできるようになるまで、一定の学習コストがかかります。
13まとめ
BizPlanのアーキテクチャを6つのレイヤーという観点で整理しました。
重要なポイントを振り返ります。
- 6レイヤーは「上位層ほどドメイン固有・下位層ほど汎用」という原則で設計されています
- 単方向依存のルールにより、変更の影響範囲を予測しやすくなります
- LLMハーネス層(ハーネス核)が最も重要な資産です。Function Callingループ・リトライ・メトリクス・RAG注入を一箇所に集約し、ドメインを知りません
StreamEventによる HTTP 非依存の抽象化により、エージェントのテストと配信プロトコルの変更が容易になりますToolContextによりツールハンドラのシグネチャが統一され、コンテキストの受け渡しが整理されます- 全体の設計思想は「約6割の汎用基盤を保ちながら、上位層でドメインを表現する」という分離にあります
BizPlanの5フェーズハーネス(ヒアリング→分析→骨子→生成→レビュー)は、本記事で紹介した6レイヤー基盤の上に乗っています。 「何をするか(ハーネス設計)」と「どう支えるか(レイヤー構造)」を分けて読むことで、AIエージェントシステム設計の全体像がより具体的に見えてくるかと思います。
14参考文献
- 関連記事: BizPlanの裏側:事業計画エージェントのハーネス設計を公開します
- 関連記事: ハーネス設計入門:対話を成果物に収束させる仕組みの作り方
- 関連記事: エージェントの責務分割:1エージェント1タスク設計の考え方
- OpenAI API Reference - Function Calling
- Anthropic API Reference
- pgvector - Open-source vector similarity search for Postgres
- Prisma Documentation

