llm-app-development について
LLMを活用するアプリケーション開発向けのスキルで、RAG、構造化出力、ツール利用、評価、コスト管理、ストリーミングを扱います。チャットボットやアシスタント、検索システム、エージェントを開発する人を対象としています。
主な機能
- 01RAGパイプライン設計
- 02スキーマ検証付き構造化出力
- 03ツール利用とエージェントのパターン
- 04コスト管理のためのセマンティックキャッシュ
- 05ストリーミング応答処理
- 06LLM出力の評価
活用シーン
- 01頻繁に更新されるデータに基づく応答生成
- 02検証済みモデル出力のアプリ処理への連携
- 03デプロイ前の品質変化のテスト
- 04繰り返しクエリの遅延とAPI利用量の削減
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill llm-app-developmentインストール後、Claude Code で/llm-app-developmentを入力して読み込まれたか確認します。
詳細
LLM アプリケーション開発
概要
このスキルでは、LLM を活用したアプリケーション開発の全領域を扱います。検索拡張生成(RAG)パイプライン、ベクトルデータベースとの統合、プロンプトエンジニアリングの手法、構造化出力の生成、ツールの利用とエージェント型パターン、評価フレームワーク、コストの最適化、ストリーミング応答の処理について説明します。
チャットボット、AI アシスタント、ナレッジ検索システム、コンテンツ生成ツール、自律型エージェント、または LLM の機能を中核機能に統合するアプリケーションを構築する際に、このスキルを活用してください。
基本原則
- 記憶より検索 - モデルのパラメトリックメモリに頼るのではなく、RAG を使って実データに基づく LLM の応答を生成します。これにより、ハルシネーションを減らし、回答を最新の状態に保てます。
- 入出力境界の構造化 - 入力(システムプロンプト、コンテキスト)と出力(関数呼び出しや Zod スキーマによる型付き応答)の両方に厳密なスキーマを定義します。後続処理で生の LLM テキストを信頼してはいけません。
- リリース前に評価する - すべての LLM 機能に自動評価が必要です。モデルの出力は非決定的です。評価がなければ、回帰や改善を測定できません。
- コストを考慮したアーキテクチャ - コストはトークン使用量によって決まります。積極的にキャッシュし、品質要件を満たす最小のモデルを選び、可能な場合はバッチ処理します。
- 適切に失敗させる - LLM は応答を拒否したり、ハルシネーションを起こしたり、タイムアウトしたりすることがあります。すべての呼び出し経路に、フォールバック動作、再試行ロジック、ユーザーに表示するエラー状態が必要です。
主なパターン
パターン 1:RAG パイプラインのアーキテクチャ
使用する場面: LLM が、コンテキストウィンドウの上限を超える非公開、大規模、または頻繁に更新されるナレッジにアクセスする必要がある場合。
実装:
import { OpenAIEmbeddings } from "@langchain/openai";
import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
// 1. Chunking - Split documents into retrieval-friendly segments
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
separators: ["\n\n", "\n", ". ", " "],
});
const chunks = await splitter.splitDocuments(documents);
// 2. Embedding - Convert chunks to vectors
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small",
dimensions: 1536,
});
// 3. Storage - Index in vector database
const vectorStore = await PGVectorStore.fromDocuments(chunks, embeddings, {
postgresConnectionOptions: {
connectionString: process.env.DATABASE_URL,
},
tableName: "documents",
columns: {
idColumnName: "id",
vectorColumnName: "embedding",
contentColumnName: "content",
metadataColumnName: "metadata",
},
});
// 4. Retrieval - Find relevant context for a query
async function retrieve(query: string, k: number = 5) {
const results = await vectorStore.similaritySearchWithScore(query, k);
// Filter by relevance threshold
return results
.filter(([_, score]) => score > 0.7)
.map(([doc]) => doc);
}
// 5. Generation - Augment prompt with retrieved context
async function generateAnswer(query: string): Promise<string> {
const context = await retrieve(query);
const contextText = context
.map((doc) => doc.pageContent)
.join("\n---\n");
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "system",
content: `Answer questions using ONLY the provided context. If the context doesn't contain the answer, say "I don't have information about that."
Context:
${contextText}`,
},
{ role: "user", content: query },
],
temperature: 0.1,
});
return response.choices[0].message.content ?? "";
}
理由: RAG は、ナレッジの保存と推論を分離します。LLM は回答の統合に注力し、ベクトルデータベースは大規模な検索を担います。このアーキテクチャでは、再トレーニングせずにナレッジを更新でき、関連するコンテキストのみを含めることでトークンコストを抑えられます。
パターン 2:Zod スキーマによる構造化出力
使用する場面: LLM の出力を、型付きで検証済みのデータを必要とする後続ロジック、API、または UI の描画に渡す場合。
実装:
import { z } from "zod";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
// Define the output schema
const ProductReviewAnalysis = z.object({
sentiment: z.enum(["positive", "negative", "neutral", "mixed"]),
confidence: z.number().min(0).max(1),
themes: z.array(z.object({
name: z.string(),
sentiment: z.enum(["positive", "negative", "neutral"]),
mentions: z.number(),
})),
summary: z.string().max(200),
actionItems: z.array(z.string()),
});
type ProductReviewAnalysis = z.infer<typeof ProductReviewAnalysis>;
const openai = new OpenAI();
async function analyzeReviews(
reviews: string[]
): Promise<ProductReviewAnalysis> {
const response = await openai.beta.chat.completions.parse({
model: "gpt-4o-2024-08-06",
messages: [
{
role: "system",
content: "Analyze product reviews and extract structured insights.",
},
{
role: "user",
content: `Analyze these reviews:\n${reviews.join("\n---\n")}`,
},
],
response_format: zodResponseFormat(ProductReviewAnalysis, "review_analysis"),
});
const parsed = response.choices[0].message.parsed;
if (!parsed) {
throw new Error("Failed to parse structured output");
}
return parsed;
}
理由: 構造化出力により、脆弱な正規表現による LLM テキストの解析が不要になります。モデルの出力はスキーマに適合する有効な JSON に制限されるため、UI コンポーネントの描画、データベースへの保存、他のサービスへの受け渡しに使える型安全なデータを得られます。
パターン 3:ツールの利用と AI エージェント
使用する場面: LLM がテキストを生成するだけでなく、検索、計算、API 呼び出し、データ変更などのアクションを実行する必要がある場合。
実装:
import OpenAI from "openai";
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "search_knowledge_base",
description: "Search the internal knowledge base for relevant articles",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
category: {
type: "string",
enum: ["billing", "technical", "account"],
description: "Category to filter by",
},
},
required: ["query"],
},
},
},
{
type: "function",
function: {
name: "create_support_ticket",
description: "Create a support ticket for unresolved issues",
parameters: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
},
required: ["title", "description", "priority"],
},
},
},
];
// Tool implementations
const toolHandlers: Record<string, (args: unknown) => Promise<string>> = {
search_knowledge_base: async (args) => {
const { query, category } = args as { query: string; category?: string };
const results = await knowledgeBase.search(query, { category });
return JSON.stringify(results);
},
create_support_ticket: async (args) => {
const ticket = args as { title: string; description: string; priority: string };
const created = await ticketSystem.create(ticket);
return JSON.stringify({ ticketId: created.id, status: "created" });
},
};
// Agentic loop - let the model decide which tools to call
async function agentLoop(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]
): Promise<string> {
const MAX_ITERATIONS = 10;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
tools,
tool_choice: "auto",
});
const message = response.choices[0].message;
messages.push(message);
// If no tool calls, we have the final answer
if (!message.tool_calls || message.tool_calls.length === 0) {
return message.content ?? "";
}
// Execute each tool call
for (const toolCall of message.tool_calls) {
const handler = toolHandlers[toolCall.function.name];
if (!handler) {
throw new Error(`Unknown tool: ${toolCall.function.name}`);
}
const args = JSON.parse(toolCall.function.arguments);
const result = await handler(args);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
throw new Error("Agent exceeded maximum iterations");
}
理由: ツールの利用により、LLM はテキスト生成器から、データベースへのクエリ、API の呼び出し、ワークフローのオーケストレーションを行うアクターへと変わります。エージェント型ループパターンでは、どのツールをいつ使うかをモデルが自律的に判断できる一方、反復回数の上限によって無限実行を防ぎます。
パターン 4:コスト最適化のためのセマンティックキャッシュ
使用する場面: 類似したクエリが頻繁に発生し、鮮度要件上キャッシュが許容される場合。
実装:
import { Redis } from "ioredis";
import { createHash } from "crypto";
interface CacheEntry {
response: string;
embedding: number[];
createdAt: number;
}
class SemanticCache {
private redis: Redis;
private ttlSeconds: number;
private similarityThreshold: number;
constructor(options: {
redisUrl: string;
ttlSeconds?: number;
similarityThreshold?: number;
}) {
this.redis = new Redis(options.redisUrl);
this.ttlSeconds = options.ttlSeconds ?? 3600;
this.similarityThreshold = options.similarityThreshold ?? 0.95;
}
// Exact match cache (fast, cheap)
private exactKey(prompt: string): string {
const hash = createHash("sha256").update(prompt).digest("hex");
return `llm:exact:${hash}`;
}
// Check exact cache first, then semantic similarity
async get(prompt: string): Promise<string | null> {
// 1. Try exact match
const exact = await this.redis.get(this.exactKey(prompt));
if (exact) return exact;
// 2. Try semantic match (more expensive, but catches paraphrases)
const embedding = await getEmbedding(prompt);
const candidates = await this.redis.smembers("llm:semantic:keys");
for (const candidateKey of candidates) {
const raw = await this.redis.get(`llm:semantic:${candidateKey}`);
if (!raw) continue;
const entry: CacheEntry = JSON.parse(raw);
const similarity = cosineSimilarity(embedding, entry.embedding);
if (similarity >= this.similarityThreshold) {
return entry.response;
}
}
return null;
}
async set(prompt: string, response: string): Promise<void> {
const embedding = await getEmbedding(prompt);
const key = createHash("sha256").update(prompt).digest("hex");
// Store exact match
await this.redis.setex(this.exactKey(prompt), this.ttlSeconds, response);
// Store semantic entry
const entry: CacheEntry = {
response,
embedding,
createdAt: Date.now(),
};
await this.redis.setex(
`llm:semantic:${key}`,
this.ttlSeconds,
JSON.stringify(entry)
);
await this.redis.sadd("llm:semantic:keys", key);
}
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, magA = 0, magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
理由: LLM API の呼び出しにはコストと時間がかかります。セマンティックキャッシュは、同一のクエリだけでなく言い換えられたクエリも検出し、よく似たクエリが繰り返されるアプリケーション(FAQ ボット、カスタマーサポート、検索など)のコストを大幅に削減します。
パターン 5:ストリーミング応答
使用する場面: 体感レイテンシが重要な、ユーザー向けのあらゆる LLM インタラクション。
実装:
// Server - Next.js Route Handler with streaming
import { OpenAI } from "openai";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const openai = new OpenAI();
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages,
stream: true,
});
// Convert OpenAI stream to ReadableStream
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? "";
if (text) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// Client - React hook for consuming streams
function useStreamingChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = async (content: string) => {
const userMessage = { role: "user" as const, content };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setIsStreaming(true);
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: updatedMessages }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let assistantContent = "";
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split("\n").filter((line) => line.startsWith("data: "));
for (const line of lines) {
const data = line.slice(6);
if (data === "[DONE]") break;
const parsed = JSON.parse(data);
assistantContent += parsed.text;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "assistant",
content: assistantContent,
};
return updated;
});
}
}
setIsStreaming(false);
};
return { messages, sendMessage, isStreaming };
}
理由: ストリーミングを使わない場合、ユーザーは 2~10 秒間、何も表示されない画面を見続けることになります。ストリーミングではトークンが到着するたびに表示されるため、最初のトークンが表示されるまでの体感レイテンシを 500 ミリ秒未満に短縮できます。これは、ユーザー向けの LLM 機能に必須の要件です。
パターン 6:LLM 出力の評価
使用するタイミング: LLM 機能を本番環境にデプロイする前、および継続的な回帰テストとして使用します。
実装:
import { evaluate } from "braintrust";
// Define evaluation dataset
const dataset = [
{
input: "What is our refund policy?",
expected: "30-day money-back guarantee",
tags: ["policy", "refund"],
},
{
input: "How do I reset my password?",
expected: "Go to Settings > Security > Reset Password",
tags: ["account", "password"],
},
];
// Custom scoring functions
function factualAccuracy(output: string, expected: string): number {
const expectedFacts = expected.toLowerCase().split(/[,.]/).map((s) => s.trim());
const matchedFacts = expectedFacts.filter((fact) =>
output.toLowerCase().includes(fact)
);
return matchedFacts.length / expectedFacts.length;
}
function answerRelevance(output: string, input: string): number {
// Use an LLM as a judge
// Returns 0-1 score for how relevant the answer is to the question
return llmJudge(input, output, "relevance");
}
// Run evaluation
const results = await evaluate("support-bot-v2", {
data: dataset,
task: async (input) => {
return await generateAnswer(input.input);
},
scores: [
{
name: "factual_accuracy",
scorer: (args) => factualAccuracy(args.output, args.expected),
},
{
name: "answer_relevance",
scorer: (args) => answerRelevance(args.output, args.input),
},
{
name: "no_hallucination",
scorer: (args) => {
const hallucinationCheck = detectHallucination(args.output, args.context);
return hallucinationCheck ? 0 : 1;
},
},
],
});
console.log(`Average factual accuracy: ${results.scores.factual_accuracy}`);
console.log(`Average relevance: ${results.scores.answer_relevance}`);
理由: LLM の出力は確率的です。評価を行わなければ、プロンプトの変更、モデルのアップグレード、コンテキストの変更によって品質が向上したのか低下したのかを測定できません。評価テストは LLM エンジニアリングにおけるテストです。
ベクトルデータベース選定ガイド
| データベース | 最適な用途 | スケーリング | マネージド | ローカル開発 |
|---|---|---|---|---|
| pgvector | 既存の Postgres ユーザー、ベクトル数 1,000 万未満 | 垂直 | Supabase/Neon 経由 | Docker |
| Pinecone | サーバーレス、大規模な本番環境 | 水平 | はい(のみ) | モッククライアント |
| Chroma | プロトタイピング、小規模なデータセット | 限定的 | Chroma Cloud | インメモリ |
| Weaviate | ハイブリッド検索(ベクトル + BM25) | 水平 | はい + セルフホスト | Docker |
| Qdrant | パフォーマンスが重要な用途、フィルタリング | 水平 | はい + セルフホスト | Docker |
プロンプトエンジニアリング クイックリファレンス
| 手法 | 使用するタイミング | プレフィックスの例 |
|---|---|---|
| ゼロショット | シンプルで明確に定義されたタスク | "このメールをスパムかどうかに分類してください:" |
| フューショット | パターンの提示が必要な場合 | "例を示します:\n..." |
| モデルの思考 | 推論タスク | 「段階的に考えてください」というプレフィックスではなく、プロバイダーの思考/労力設定を有効にします |
| システムプロンプト | 継続的に適用する動作ルール | role: "system" メッセージ |
| 区切り文字 | コンテキストと指示の分離 | """context""" または <context> タグ |
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
| ドキュメント全体をコンテキストに詰め込む | トークンを浪費し、関連性を薄めます | チャンキングと検索を使った RAG を使用します |
| LLM のテキスト出力を正規表現で解析する | 脆弱で、形式の変更で壊れます | 構造化出力(関数呼び出し、Zod)を使用します |
| 本番環境の前に評価を行わない | 品質を測定できず、回帰も検出できません | スコアリング関数を使った評価スイートを構築します |
| あらゆる用途に単一の巨大なプロンプトを使う | デバッグが難しく、コストが高く、処理が遅くなります | 焦点を絞った小さなプロンプトに分割します |
| 開発中にトークンコストを無視する | 大規模運用時に予想外の請求が発生します | クエリごとのコストを追跡し、予算を設定してキャッシュします |
| リクエスト処理中に LLM を同期呼び出しする | ユーザー体験が遅くなり、タイムアウトのリスクがあります | 応答をストリーミングし、負荷の高い処理にはバックグラウンドジョブを使います |
| モデル名をあちこちにハードコードする | アップグレードや A/B テストが困難になります | 設定に基づいてモデルを選択します |
| API 呼び出しにリトライ処理がない | 一時的な障害がユーザーに見えるエラーを引き起こします | ジッターを加えた指数バックオフを使用します |
チェックリスト
- RAG パイプライン:チャンキング戦略を選定し、テスト済み(サイズ、重複、区切り文字)
- ベクトルデータベースを選定し、コネクションプーリングを設定済み
- 埋め込みモデルを選定済み(次元数、コスト、品質のトレードオフ)
- LLM とコードのすべての境界に対する構造化出力スキーマを定義済み
- ユーザー向け応答にストリーミングを実装済み
- 機能ごとに少なくとも 20 件のテストケースを含む評価スイートを用意済み
- 繰り返しのクエリに対するセマンティックキャッシュまたは完全一致キャッシュ
- エラー処理:リトライ、フォールバック、ユーザーに表示するエラー状態
- コスト追跡:クエリごとのトークン使用量を記録済み
- LLM API 呼び出しにレート制限を設定済み
- システムプロンプトをバージョン管理し、コード外に保存済み
- プロンプトインジェクション対策(入力検証、出力フィルタリング)
関連リソース
- スキル:
application-security(プロンプトインジェクション)、monitoring-observability(LLM 呼び出しのトレース) - ルール:
docs/reference/stacks/react-typescript.md(チャット UI のフロントエンドパターン) - ルール:
docs/reference/checklists/verification-template.md(評価チェックリスト)