mcpField
L
llm-app-development

llm-app-development

github.com/travisjneuman開発106134 回インストール1 日前に更新
リポジトリ

llm-app-development について

LLMを活用するアプリケーション開発向けのスキルで、RAG、構造化出力、ツール利用、評価、コスト管理、ストリーミングを扱います。チャットボットやアシスタント、検索システム、エージェントを開発する人を対象としています。

主な機能

  1. 01RAGパイプライン設計
  2. 02スキーマ検証付き構造化出力
  3. 03ツール利用とエージェントのパターン
  4. 04コスト管理のためのセマンティックキャッシュ
  5. 05ストリーミング応答処理
  6. 06LLM出力の評価

活用シーン

  1. 01頻繁に更新されるデータに基づく応答生成
  2. 02検証済みモデル出力のアプリ処理への連携
  3. 03デプロイ前の品質変化のテスト
  4. 04繰り返しクエリの遅延とAPI利用量の削減

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/travisjneuman/.claude --skill llm-app-development

インストール後、Claude Code で/llm-app-developmentを入力して読み込まれたか確認します。

詳細

LLM アプリケーション開発

概要

このスキルでは、LLM を活用したアプリケーション開発の全領域を扱います。検索拡張生成(RAG)パイプライン、ベクトルデータベースとの統合、プロンプトエンジニアリングの手法、構造化出力の生成、ツールの利用とエージェント型パターン、評価フレームワーク、コストの最適化、ストリーミング応答の処理について説明します。

チャットボット、AI アシスタント、ナレッジ検索システム、コンテンツ生成ツール、自律型エージェント、または LLM の機能を中核機能に統合するアプリケーションを構築する際に、このスキルを活用してください。


基本原則

  1. 記憶より検索 - モデルのパラメトリックメモリに頼るのではなく、RAG を使って実データに基づく LLM の応答を生成します。これにより、ハルシネーションを減らし、回答を最新の状態に保てます。
  2. 入出力境界の構造化 - 入力(システムプロンプト、コンテキスト)と出力(関数呼び出しや Zod スキーマによる型付き応答)の両方に厳密なスキーマを定義します。後続処理で生の LLM テキストを信頼してはいけません。
  3. リリース前に評価する - すべての LLM 機能に自動評価が必要です。モデルの出力は非決定的です。評価がなければ、回帰や改善を測定できません。
  4. コストを考慮したアーキテクチャ - コストはトークン使用量によって決まります。積極的にキャッシュし、品質要件を満たす最小のモデルを選び、可能な場合はバッチ処理します。
  5. 適切に失敗させる - 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(評価チェックリスト)

コメント