0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LangSmithで5つのLLMを比較!トレーシング・エラーハンドリング完全ガイド【牡丹プロジェクト技術解説 Phase 1】

0
Last updated at Posted at 2025-11-05

はじめに:AI VTuber「牡丹プロジェクト」とは

本記事は、AI VTuber三姉妹(Kasho、牡丹、ユリ)の記憶製造機システムの技術解説シリーズ第1弾です。

プロジェクト概要

「牡丹プロジェクト」は、過去の記憶を持つAI VTuberを実現するプロジェクトです。三姉妹それぞれが固有の記憶・個性・価値観を持ち、複数のLLMプロバイダーを使い分けながら配信を行います。

三姉妹の構成

  • Kasho(長女): 論理的・分析的、慎重でリスク重視、保護者的な姉
  • 牡丹(次女): ギャル系、感情的・直感的、明るく率直、行動力抜群
  • ユリ(三女): 統合的・洞察的、調整役、共感力が高い

GitHubリポジトリ

本プロジェクトのコードは以下で公開しています:


Phase 1: LangSmith統合の重要性

LLMアプリケーション開発において、モデルの性能比較エラー追跡は避けて通れない課題です。

本記事では、LangSmithを使って5つのLLMプロバイダーをトレーシングし、レイテンシ比較とエラーハンドリングを実装した事例を紹介します。

🎯 この記事で分かること

  • LangSmithの基本的な統合方法(Ollama/OpenAI/Gemini対応)
  • 動的なトレース名設定で見やすいダッシュボードを実現
  • Ollama初回ロード時間を除外する測定手法
  • Geminiのfinish_reason問題とエラーハンドリング
  • 実際のベンチマーク結果とスクリーンショット

📦 対象プロバイダー

  • Ollama(ローカルLLM):qwen2.5:3b / 7b / 14b
  • OpenAI API:gpt-4o-mini
  • Google Gemini API:gemini-2.5-flash

LangSmithとは

LangSmithは、LangChainが提供するLLMアプリケーションのObservability(可観測性)プラットフォームです。

主な機能

機能 説明
Tracing LLM呼び出しの入力・出力・レイテンシを自動記録
Monitoring コスト・トークン数・エラー率をリアルタイム可視化
Debugging エラーのスタックトレースと再現テスト
Evaluation プロンプトのA/Bテストと品質評価

なぜLangSmithが必要か

LLMアプリケーション開発では、以下のような問題が発生します:

  • 異なるモデルの性能が比較しづらい
  • エラーの原因がログから追いにくい
  • プロンプト変更の影響が見えない
  • 本番環境のコストが予測できない

LangSmithを使うことで、これらの問題を一元管理できます。


実装:LangSmith統合

1. インストール

pip install langsmith httpx openai google-generativeai

2. 環境変数設定

.envファイルに以下を追加:

# LangSmith
LANGSMITH_API_KEY=lsv2_pt_...your_key_here
LANGSMITH_TRACING=true
LANGSMITH_PROJECT=botan-llm-benchmark-v3

# LLM API Keys
OPENAI_API_KEY=sk-proj-...
GOOGLE_API_KEY=AIza...

LangSmith APIキーはこちらから取得できます(無料枠あり)。

3. トレーシングモジュール実装

動的トレース名の設定

LangSmithの@traceableデコレータでは、固定の関数名がダッシュボードに表示されます。これをモデル名で動的に変更します。

from langsmith import traceable
from typing import Dict, Any

class TracedLLM:
    def __init__(
        self,
        provider: str = "ollama",
        model: str = "qwen2.5:14b",
        project_name: str = "botan-project"
    ):
        self.provider = provider
        self.model = model
        self.project_name = project_name
        self.langsmith_enabled = os.getenv("LANGSMITH_TRACING", "false").lower() == "true"

    def generate(
        self,
        prompt: str,
        temperature: float = 0.7,
        max_tokens: int = 1024,
        metadata: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        # メタデータからトレース名を取得
        trace_name = metadata.get("model_name", self.model) if metadata else self.model

        # トレース対象の関数を定義
        def do_generate(input_prompt: str) -> str:
            if self.provider == "ollama":
                full_result = self._ollama_generate(input_prompt, temperature, max_tokens)
            elif self.provider == "openai":
                full_result = self._openai_generate(input_prompt, temperature, max_tokens)
            elif self.provider == "gemini":
                full_result = self._gemini_generate(input_prompt, temperature, max_tokens)
            else:
                raise ValueError(f"Unknown provider: {self.provider}")

            # エラーがあれば例外を投げる(LangSmithがキャプチャ)
            if "error" in full_result:
                raise RuntimeError(full_result["error"])

            # レスポンステキストのみ返す(Output列に表示)
            do_generate.full_result = full_result
            return full_result.get("response", "")

        # トレーシングを適用
        if self.langsmith_enabled:
            traced_func = traceable(
                run_type="llm",
                name=trace_name,  # 動的な名前
                project_name=self.project_name
            )(do_generate)
            try:
                response_text = traced_func(prompt)
                result = do_generate.full_result
            except RuntimeError:
                result = do_generate.full_result
        else:
            # トレースなしで実行
            response_text = do_generate(prompt)
            result = do_generate.full_result

        # メタデータ追加
        if metadata:
            result["metadata"] = metadata
        result["timestamp"] = datetime.now().isoformat()

        return result

ポイント解説

  1. 動的トレース名: metadata["model_name"]をトレース名に使用
  2. エラーの例外化: RuntimeErrorを投げることでLangSmithのError列に表示
  3. Output表示: 辞書全体ではなくresponseテキストのみ返す

Ollamaのウォームアップ問題

課題

Ollamaは初回実行時にモデルをメモリにロードするため、初回レイテンシが10〜20倍遅いです。

ollama_14b: 1回目 13,677ms → 2回目 1,173ms(11.7倍高速化)

解決策:選択的トレーシング

ウォームアップ実行はトレースせず、2回目の測定実行のみトレースします。

def warmup_ollama(model: str, prompt: str, ollama_url: str = "http://localhost:11434"):
    """Warmup Ollama model (no tracing)"""
    try:
        with httpx.Client(timeout=120.0) as client:
            response = client.post(
                f"{ollama_url}/api/generate",
                json={
                    "model": model,
                    "prompt": prompt,
                    "stream": False,
                    "options": {"temperature": 0.7, "num_predict": 100}
                }
            )
            response.raise_for_status()
            return response.json()
    except Exception as e:
        print(f"⚠️ Warmup failed: {str(e)}")
        return None

# ベンチマーク実行
for config in models:
    is_ollama = config["provider"] == "ollama"

    # Ollamaのみウォームアップ
    if is_ollama:
        print("Warmup run (no trace)...")
        warmup_ollama(config["model"], test_prompt)

    # 測定実行(トレースあり)
    print("Measurement run (traced to LangSmith)...")
    llm = TracedLLM(
        provider=config["provider"],
        model=config["model"],
        project_name="botan-llm-benchmark-v3"
    )
    result = llm.generate(
        prompt=test_prompt,
        temperature=0.7,
        max_tokens=100,
        metadata={"model_name": config["name"]}
    )

Geminiのエラーハンドリング

問題:finish_reason=2でレスポンスが空

Gemini APIでは、以下の異常状態が発生することがあります:

finish_reason: 2  # MAX_TOKENS
content.parts: 0  # レスポンスが空

これは矛盾した状態で、適切にエラーとして扱う必要があります。

実装:finish_reasonチェック

def _gemini_generate(self, prompt: str, temperature: float, max_tokens: int):
    start_time = time.time()

    try:
        import google.generativeai as genai
        genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))

        # 安全フィルターを緩和
        safety_settings = [
            {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
        ]

        model = genai.GenerativeModel(self.model, safety_settings=safety_settings)
        response = model.generate_content(
            prompt,
            generation_config=genai.types.GenerationConfig(
                temperature=temperature,
                max_output_tokens=max_tokens
            )
        )

        latency_ms = (time.time() - start_time) * 1000

        # 候補がない場合
        if not response.candidates:
            return {
                "response": "",
                "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
                "latency_ms": latency_ms,
                "model": self.model,
                "provider": self.provider,
                "error": "Response blocked: No candidates returned"
            }

        candidate = response.candidates[0]
        finish_reason = candidate.finish_reason

        # テキスト抽出
        try:
            response_text = response.text
        except ValueError:
            if candidate.content and candidate.content.parts:
                response_text = candidate.content.parts[0].text
            else:
                response_text = ""

        # finish_reason != STOP かつ レスポンスが空ならエラー
        if finish_reason != 1 and not response_text:  # 1 = STOP
            finish_reason_names = {
                0: "FINISH_REASON_UNSPECIFIED",
                1: "STOP",
                2: "MAX_TOKENS",
                3: "SAFETY",
                4: "RECITATION",
                5: "OTHER"
            }
            reason_name = finish_reason_names.get(finish_reason, f"UNKNOWN({finish_reason})")

            if finish_reason == 3:  # SAFETY
                error_msg = f"Blocked by safety filter: {reason_name}"
            else:
                error_msg = f"Generation failed: {reason_name} (no content returned)"

            return {
                "response": "",
                "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
                "latency_ms": latency_ms,
                "model": self.model,
                "provider": self.provider,
                "error": error_msg
            }

        # トークン数取得
        tokens = {
            "prompt_tokens": response.usage_metadata.prompt_token_count,
            "completion_tokens": response.usage_metadata.candidates_token_count,
            "total_tokens": response.usage_metadata.total_token_count
        }

        return {
            "response": response_text,
            "tokens": tokens,
            "latency_ms": latency_ms,
            "model": self.model,
            "provider": self.provider
        }

    except Exception as e:
        latency_ms = (time.time() - start_time) * 1000
        return {
            "response": "",
            "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
            "latency_ms": latency_ms,
            "model": self.model,
            "provider": self.provider,
            "error": str(e)
        }

ベンチマーク結果

実行環境

  • 日付: 2025年11月5日
  • OS: WSL2 Linux
  • CPU: AMD Ryzen 9 9950X(16コア/32スレッド)
  • GPU: NVIDIA RTX 4060 Ti 16GB

LangSmithダッシュボード:全体像

LangSmithベンチマーク結果

📊 測定結果

Name Input Output Error Latency
ollama_3b Tell me about AI VTubers in one sentence. AI VTubers are digital characters an... - 0.40s
ollama_7b Tell me about AI VTubers in one sentence. AI VTubers are virtual broadcasters ... - 0.59s
ollama_14b Tell me about AI VTubers in one sentence. AI VTubers are virtual YouTubers wh... - 1.21s
openai_4o-mini Tell me about AI VTubers in one sentence. AI VTubers are virtual characters po... - 1.85s
gemini_2.5-flash Tell me about AI VTubers in one sentence. null ⚠️ RuntimeError('Ge... 2.30s

🏆 レイテンシ比較

  1. ollama_3b: 384ms(最速)
  2. ollama_7b: 588ms
  3. ollama_14b: 1,210ms
  4. openai_4o-mini: 1,649ms
  5. gemini_2.5-flash: FAILED

統計情報

  • Total Tokens: 0 / $0.00
  • Run Count: 5
  • Error Rate: 0% → 20%(Gemini失敗を含む)

Geminiエラー詳細

Geminiエラー詳細

エラー内容

RuntimeError('Generation failed: MAX_TOKENS (no content returned)')

Traceback (most recent call last):
  File "/home/koshikawa/AI-Vtuber-Project/src/core/llm_tracing.py", line 352, in do_generate
    raise RuntimeError(full_result["error"])
RuntimeError: Generation failed: MAX_TOKENS (no content returned)

詳細

  • Input Prompt: "Tell me about AI VTubers in one sentence."
  • Output: null(空)
  • finish_reason: 2(MAX_TOKENS)
  • Error: LangSmithのError列に正しく表示

このエラーは、Gemini APIがfinish_reason=MAX_TOKENSを返しながらレスポンスが空という矛盾した状態です。


LangSmithダッシュボードの見方

Name列

修正前: ollama_generate(全モデル同じ名前)
修正後: ollama_3b, ollama_7b, gemini_2.5-flash(モデルごとに識別)

動的トレース名により、一目でどのモデルの実行か判別できます。

Output列

修正前: {"response": "...", "tokens": {...}, ...}(辞書全体)
修正後: "AI VTubers are virtual characters..."(テキストのみ)

関数の戻り値を文字列にすることで、LangSmithが自動的にクリーンに表示します。

Error列

修正前: 空(エラーが表示されない)
修正後: RuntimeError: Generation failed: MAX_TOKENS (no content returned)

エラー発生時にRuntimeErrorを投げることで、LangSmithが例外をキャプチャしてスタックトレースと共に表示します。


トラブルシューティング

Q1. トレースがLangSmithに表示されない

原因1: 環境変数が設定されていない

export LANGSMITH_API_KEY=lsv2_pt_...
export LANGSMITH_TRACING=true
export LANGSMITH_PROJECT=your-project-name

原因2: プロジェクト名が間違っている

コード内で動的に設定する場合:

os.environ["LANGSMITH_PROJECT"] = "botan-llm-benchmark-v3"

確認方法

echo $LANGSMITH_API_KEY
echo $LANGSMITH_TRACING

Q2. Ollamaの初回実行が遅い

原因

Ollamaはモデルを初回実行時にメモリにロードします。

解決策

ウォームアップ実行を追加し、2回目以降の測定値を使用します。

# 1回目: ウォームアップ(トレースなし)
warmup_ollama(model, prompt)

# 2回目: 測定(トレースあり)
result = llm.generate(prompt, metadata={"model_name": "ollama_3b"})

Q3. Geminiがfinish_reason=3でブロックされる

原因

Geminiの安全フィルターがデフォルトで厳しく設定されています。

解決策

safety_settingsで全カテゴリをBLOCK_NONEに設定します。

safety_settings = [
    {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
    {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
    {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
    {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]

model = genai.GenerativeModel(self.model, safety_settings=safety_settings)

注意: 本番環境では適切なコンテンツフィルタリングを実装してください。


まとめ

Phase 1 の成果

項目 内容
実装内容 LangSmith統合(Ollama/OpenAI/Gemini対応)
トレーシング 動的トレース名、選択的トレーシング
エラーハンドリング Geminiのfinish_reason問題に対応
ベンチマーク 5モデルのレイテンシ比較

ベンチマーク結果のまとめ

モデル レイテンシ トークン数 状態
ollama_3b 384ms 74 ✅ 成功
ollama_7b 588ms 69 ✅ 成功
ollama_14b 1,210ms 73 ✅ 成功
openai_4o-mini 1,649ms 52 ✅ 成功
gemini_2.5-flash - 0 ❌ エラー

得られた知見

  1. Ollamaの優位性: ウォームアップ後は384ms(最速)で応答
  2. OpenAI vs Gemini: 今回Geminiが失敗したが、通常は同等の性能
  3. エラートレーシングの重要性: LangSmithでエラーを可視化できることが開発効率向上に寄与

Phase 1-5の完成状況

Phase 内容 記事 状態
Phase 1 LangSmithマルチプロバイダートレーシング 本記事
Phase 2 VLM (Vision Language Model) 統合 記事
Phase 3 LLM as a Judge実装 記事
Phase 4 三姉妹討論システム実装(起承転結) 記事
Phase 5 センシティブ判定システム実装 記事

次のステップ

  • Phase 2: VLM統合(画像理解AI)
  • Phase 3: LLM as a Judge(品質評価システム)
  • Phase 4: 三姉妹討論システム(起承転結)
  • Phase 5: センシティブ判定システム

参考資料

関連記事


おわりに

LangSmithを導入することで、複数のLLMプロバイダーを一元管理し、エラーも含めて可視化できました。

特に以下の点が開発効率向上に貢献しました:

  • 🔍動的トレース名で各モデルの実行を識別
  • ウォームアップ除外で正確なレイテンシ測定
  • 🐛エラーの例外化でLangSmithダッシュボードに表示

今後は、LangSmithの評価機能(Evaluation)を使ったプロンプト改善や、コスト最適化にも取り組んでいきます。

質問・コメントお待ちしています!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?