0Pricing
AI Engineering Academy · レッスン

LLMアプリのデバッグが難しい理由

従来のログ記録だけではLLMアプリケーションに不十分な理由、RAGやエージェントパイプラインの障害を診断するために必要な情報、トレーシングのデータモデルを理解します。

「LLMアプリのデバッグが難しい理由」はCoddyKit上の無料AI Engineering Academyレッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはAI Engineering Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 AI Engineering Academyコースには全4レッスンが含まれています。

LLMデバッグ特有の難しさ

従来のソフトウェアは決定論的に失敗します。同じ入力を与えれば常に同じ出力が生成され、スタックトレースによって失敗した行を直接特定できます。LLMアプリケーションでは、こうした前提が成り立ちません。同じプロンプトでも呼び出しごとに異なる出力が生成される可能性があり、失敗は例外ではなく誤った回答として現れることが多く、原因がチェーン内の5ステップ前に使われたプロンプトに埋もれている場合もあります。一般的なロギングやデバッグツールは、そもそもこのような問題に対応するよう設計されていません。

非決定性によって再現が難しくなる

LLMの出力は、デフォルトでは非決定的です。temperature=0に設定しても、バッチ処理や数値精度の影響により、同じプロンプトからわずかに異なる出力が生成されることがあります。そのため、バグは断続的に発生します。20%の確率で失敗するプロンプトも、1回しか実行しなければテストスイートを通過してしまいます。特定の失敗を再現するには、入力だけでなく、失敗時点での正確な入力、モデルのパラメーター、出力を記録する必要があります。

import json
import time

def logged_llm_call(client, messages, model, temperature, **kwargs):
    request_id = f'{int(time.time() * 1000)}-{id(messages)}'
    
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=temperature,
        **kwargs
    )
    
    # Log EVERYTHING needed to reproduce this exact call
    log_entry = {
        'request_id': request_id,
        'model': model,
        'temperature': temperature,
        'messages': messages,
        'response': response.choices[0].message.content,
        'finish_reason': response.choices[0].finish_reason,
        'usage': response.usage.model_dump(),
        'timestamp': time.time()
    }
    write_to_trace_store(log_entry)
    return response

サイレントな失敗:壊れているのではなく間違っている

LLMで最も厄介な失敗は、サイレントな失敗です。API呼び出し自体は成功し(HTTP 200で、例外も発生しない)、回答だけが誤っていたり、幻覚を含んでいたり、不完全だったり、話題から外れていたりします。アプリケーションは何も問題が起きていないかのように誤った回答を処理し、そのままユーザーに返してしまいます。例外やエラーコードだけを監視する従来のモニタリングでは、こうした失敗を決して検出できません。出力品質を意味的に監視する必要があります。

# This succeeds with HTTP 200 but returns wrong information
response = client.chat.completions.create(
    model='gpt-4o',
    messages=[{'role': 'user', 'content': 'What is the boiling point of water at sea level?'}]
)

output = response.choices[0].message.content
# response.status_code: None (not relevant - always 200 if we got here)
# No exception thrown
# But if output is '90 degrees Celsius', it is WRONG and your app will serve bad data

# You need semantic validation:
def validate_boiling_point_answer(text: str) -> bool:
    return '100' in text  # Rough check - real validation is more sophisticated

マルチステップチェーン:どこで間違ったのか

RAGパイプラインやエージェントチェーンでは、最終回答の失敗が、無関係なチャンクを返した検索ステップにさかのぼることがあります。さらにその原因は、重要な文を2つのチャンクに分割してしまったチャンク分割戦略にあり、その背景には技術用語を適切に処理できなかった埋め込みモデルがあるかもしれません。ステップごとのトレーシングがなければ、誤った最終回答しか確認できず、どのステップでエラーが発生したのかを特定できません。

# Without tracing: you see only the final wrong answer
def rag_pipeline_naive(query):
    chunks = retrieve(query)         # step 1 - might return bad chunks
    context = format_context(chunks) # step 2 - might truncate key info
    answer = generate(query, context) # step 3 - LLM gets bad context
    return answer  # WRONG - but why?

# With tracing: you can see each step's input and output
def rag_pipeline_traced(query):
    with trace_span('retrieve') as span:
        chunks = retrieve(query)
        span.set_attribute('num_chunks', len(chunks))
        span.set_attribute('top_chunk_score', chunks[0]['score'] if chunks else 0)
    
    with trace_span('format_context') as span:
        context = format_context(chunks)
        span.set_attribute('context_length', len(context))
    
    with trace_span('generate') as span:
        answer = generate(query, context)
        span.set_attribute('answer_length', len(answer))
    
    return answer  # Now you can diagnose: was retrieve the problem?

トークン数とコストの予想外の増加

計測を行わなければ、月額請求書が届くまでトークン数やコストは見えません。見落としによってシステムプロンプトが500トークンから5000トークンに増えたり、検索関数が5個ではなく20個のチャンクを返したり、LLMを10回ではなく100回呼び出すループが発生したりすると、いずれもコストが気付かないうちに増大します。すべてのLLM呼び出しを計測し、プロンプトのトークン数、補完のトークン数、推定コストを記録して、異常をリアルタイムで確認できるようにしてください。

COST_PER_1K = {'gpt-4o': {'input': 0.005, 'output': 0.015},
               'gpt-4o-mini': {'input': 0.000150, 'output': 0.000600}}

def compute_cost(model: str, usage) -> float:
    pricing = COST_PER_1K.get(model, {'input': 0.005, 'output': 0.015})
    input_cost = (usage.prompt_tokens / 1000) * pricing['input']
    output_cost = (usage.completion_tokens / 1000) * pricing['output']
    return input_cost + output_cost

def instrumented_call(client, model, messages):
    response = client.chat.completions.create(model=model, messages=messages)
    cost = compute_cost(model, response.usage)
    
    # Alert if single call is unexpectedly expensive
    if cost > 0.10:  # more than 10 cents for one call
        print(f'WARNING: Expensive LLM call: ${cost:.4f} ({response.usage.prompt_tokens} prompt tokens)')
    
    metrics.record('llm_cost_usd', cost, tags={'model': model})
    metrics.record('llm_prompt_tokens', response.usage.prompt_tokens)
    return response

レイテンシ:どのステップが遅いのか

ユーザーはLLMのレイテンシを1回の待ち時間として感じますが、実際にはベクトルデータベースへのクエリ、ドキュメントの取得、プロンプトの組み立て、APIのネットワーク呼び出し、トークン生成、レスポンスの解析など、多くの個別ステップの合計です。ステップごとの計測がなければ、遅いレスポンスの原因が検索処理なのかLLM呼び出しなのかを判断できません。各ステップのレイテンシを計測して、実際のボトルネックを特定してください。

import time
from contextlib import contextmanager

@contextmanager
def timed(name: str, metrics_client):
    start = time.monotonic()
    try:
        yield
    finally:
        elapsed_ms = (time.monotonic() - start) * 1000
        metrics_client.histogram(f'step_latency_ms', elapsed_ms, tags={'step': name})
        if elapsed_ms > 2000:  # flag steps taking more than 2 seconds
            print(f'SLOW STEP [{name}]: {elapsed_ms:.0f}ms')

# Usage
def rag_with_timing(query, metrics):
    with timed('embed_query', metrics):
        query_embedding = embed(query)
    
    with timed('vector_search', metrics):
        chunks = vector_db.search(query_embedding, top_k=5)
    
    with timed('llm_generate', metrics):
        answer = generate(query, chunks)
    
    return answer

実際に必要な情報

LLMアプリケーションの障害を診断するには、次の情報を取得して保存する必要があります。完全な入力プロンプト(システムプロンプトとすべてのメッセージ)、使用したモデルとパラメーター(temperature、max_tokens)、完全な出力、トークン数と推定コスト、ステップごとのレイテンシ、ツール呼び出しとその結果、そして1つのユーザーリクエストに含まれるすべてのステップを関連付けるセッションIDまたはリクエストIDです。これが、最低限必要なトレーシングデータセットです。

from dataclasses import dataclass, field
from typing import Optional
import time

@dataclass
class LLMTrace:
    request_id: str
    session_id: str
    step_name: str
    model: str
    temperature: float
    system_prompt: str
    user_messages: list[dict]
    response: str
    finish_reason: str
    prompt_tokens: int
    completion_tokens: int
    cost_usd: float
    latency_ms: float
    tool_calls: list[dict] = field(default_factory=list)
    error: Optional[str] = None
    timestamp: float = field(default_factory=time.time)

    def is_anomalous(self) -> bool:
        return (
            self.cost_usd > 0.10 or
            self.latency_ms > 10000 or
            self.finish_reason == 'length' or  # was cut off
            self.error is not None
        )

リクエストIDによるトレースの関連付け

1つのユーザーリクエストによって、異なるサービスをまたいで10回のLLM呼び出しが発生することがあります。すべての呼び出しに引き継がれる相関IDがなければ、これらの呼び出しを1つのトレースとしてまとめられません。すべてのユーザーリクエストの入口で一意のリクエストIDを生成し、それをすべての下流のLLM呼び出し、データベースクエリ、ログメッセージに渡してください。これにより、特定のユーザーリクエストについて、実行経路全体を再構成できるようになります。

import uuid
from contextvars import ContextVar

# Thread-safe request ID propagation using context variables
request_id_var: ContextVar[str] = ContextVar('request_id', default='unknown')

def handle_user_request(query: str):
    # Set request ID at the entry point
    req_id = str(uuid.uuid4())[:8]
    request_id_var.set(req_id)
    return rag_pipeline(query)

def get_current_request_id() -> str:
    return request_id_var.get()

# Every LLM call logs with the same request_id
def log_llm_call(model, prompt, response):
    logger.info('LLM call', extra={
        'request_id': get_current_request_id(),  # automatically correlates all calls
        'model': model,
        'prompt_length': len(prompt),
        'response_length': len(response)
    })

LLM可観測性スタック

LLMの可観測性スタックは、3つの層で構成されます。ロギングは、すべてのLLM呼び出しの構造化された記録を取得します(LangSmith、Langfuse、カスタムログ)。メトリクスは、リクエスト数、平均レイテンシ、エラー率、日次コストなど、時間経過に伴う集計値を追跡します。トレーシングは、1つのリクエスト内におけるステップの因果関係を記録します。この3つの柱を組み合わせることで、障害の診断、リグレッションの検出、パフォーマンスの最適化に必要な可視性が得られます。

品質低下のアラート

エラーが二値(正常または故障)である従来のソフトウェアとは異なり、LLMの品質は徐々に低下します。プロンプトを変更した結果、例外が1つも発生しないまま回答品質が85%から70%に低下することもあります。本番環境の回答を毎日サンプリングし、自動評価(LLM-as-judgeによるスコアリング)を実行して品質を監視してください。移動平均の品質スコアがしきい値を下回ったら、ユーザーから苦情が寄せられる前にアラートを出します。

可観測性の取り組みを始める

本番障害が発生するまで、可観測性の導入を待ってはいけません。まずは、次の3つの最小限のステップから始めてください。(1) 完全な入力、出力、トークン数を含むすべてのLLM呼び出しをデータベースまたはファイルに記録する、(2) すべてのユーザー操作にリクエストIDを割り当て、すべてのログエントリに含める、(3) パイプラインにステップごとの時間計測を追加する。この3つだけでも、デバッグ作業の80%を数時間ではなく数分で解決できるようになります。

クイックチェック

このレッスンで学んだ、LLMアプリのデバッグが難しい理由についての理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、次のことを学びました。非決定性によってLLMのバグは断続的に発生し、リクエストのコンテキスト全体を取得しなければ再現が難しくなります。サイレントな失敗(API呼び出しは成功しているのに回答が誤っているケース)は従来のエラーモニタリングをすり抜けるため、意味的な品質チェックが必要です。また、マルチステップのRAGおよびエージェントパイプラインの障害を診断するには、リクエストIDによる関連付けとステップごとのトレーシングが最低限必要です。次はLangSmithを使ったトレーシングを実装します。

よくある質問

「LLMアプリのデバッグが難しい理由」レッスンは無料ですか?

はい。「LLMアプリのデバッグが難しい理由」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、AI Engineering Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 AI Engineering Academyコースには全4レッスンが含まれています。

「LLMアプリのデバッグが難しい理由」で何を学びますか?

従来のログ記録だけではLLMアプリケーションに不十分な理由、RAGやエージェントパイプラインの障害を診断するために必要な情報、トレーシングのデータモデルを理解します。 ブラウザで直接実行するハンズオンコードでAI Engineering Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

AI Engineering Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのAI Engineering Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。

「LLMアプリのデバッグが難しい理由」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このAI Engineering Academyレッスンでコードを書いて実行できますか?

はい。すべてのAI Engineering Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. LLMアプリのデバッグが難しい理由
  2. LangSmithによるトレーシング
  3. モデルに依存しない可観測性のためのLangfuse
  4. レイテンシ、コスト、品質低下へのアラート
← AI Engineering Academyに戻る