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?

図解とサンプルコードで、ローカルLLMのオブザーバビリティ

Last updated at Posted at 2025-10-18

本書の目的

クラウドサーバーでローカル LLM を実行し、生成AIを活用したアプリケーションの内部状態をモニタリングしたときの検証結果です。

  • サーバーには Kagoya の VPS を使用しており、スペックは 4 vCPU・メモリ 4GB の汎用的な構成です。LLM を動かすには非力なため、軽量な AI モデルを選定しました
  • ローカル LLM のランタイムには「Ollama」を選び、軽量AIモデルには「tinyllama」と「Granite3.2:2b」(IBM製)を使用しています
  • オブザーバビリティには「Datadog LLM Observability」を導入し、生成AIを使ったアプリの可観測性を検証しました

一番気になるポイント

ローカルLLMなのできっと、サーバー性能に依存してテキスト生成のパフォーマンスが大幅に遅延します。従って、コンピュートリソースがボトルネックになると思われるので実測してみる。

生成AIチャットアプリ

ユーザーが操作するWEBクライアント画面と、サーバー内部のアーキテクチャ構成を図解します。
image.png

アプリ機能

  • WEBクライアントは、AIモデルの選択[tinyllama]もしくは[granite]を選べます。プロンプトの入力と生成AIの応答結果を表示する機能のみ
  • Ollama呼び出しアプリは、WEBクライアントからのリクエストを/chatエンドポイントで受け取り、LLMプロキシに転送するAPIゲートウェイとして機能します
  • LLM プロキシは、ロジックとLLMの中間に位置し、AIモデルに応じてローカルAPIにHTTPリクエストを送信し、指定されたAIモデルとプロンプト応答を取得します
  • OllamaAIモデルは、ローカル環境におけるAIモデル実行基盤として構成されています

なお、Ollama呼び出しアプリとLLMプロキシを分離したのは、ロジックの実装とAIモデルごとの通信を分離することで、コード構成の可読性を高めるためです。

文章要約を実行(tinyllama)

応答時間は約11.6秒でした。ダッシュボードで、どんなモニタリングができるのでしょうか。
image.png

APM > Traces

時系列にアプリ実行リクエスト数, エラー数, レイテンシー(13.1秒), HTTPステータス[200]を確認できた。
image.png

APM > Traces > Waterfall

関数の遷移とそれぞれにかかる実行時間をみる。Ollama呼び出しから応答までが13.1秒を占めている。
image.png

APM > Traces > Waterfall > Infrastructure

13秒間だけだがCPUが80%以上使われ、メモリが1GBから2GBに増加したことが確認できる。
image.png

APM > Traces > Waterfall > Processes

Ollama Runner がCPU全体の73%をしめて実行されている。
image.png

LLM Observbility > Overview

サマリーは、エラー率, 平均応答時間, トークン(コスト), 時系列で実行数ほかを見ることができる。
image.png

LLM Observbility > Traces

生成AIのInputプロンプトとOutputプロンプトが列挙される。
image.png

正常なテキスト生成

質問や指示のInputテキストと、生成されたテキストの内容を閲覧できる(できてしまう)
image.png

エラー検出

タイムアウト(30秒)のためエラーし、Outputプロンプト取得に失敗している
image.png

Input/Outputプロンプトをマスクする?

ユーザーが入力した質問や応答をIT管理者が閲覧できてしまう。LLMアプリ開発時のハルシネーション等テキスト生成の品質向上のためには役に立つが、ユーザーが入力した質問には秘密情報も含まれる可能性がある。LLM Observability SDK ではDatadogダッシュボード(Intake Server)に送信する情報を隠すこともできた。11文字目以降を非表示にしています。
image.png

Pythonコードの場合は LLMObs.annotate( input_data=mask_text(prompt, 10), の部分で最初の10文字のみ表示し、残りはマスクすることができた。これで本番アプリでも、第三者の情報を制限することができそうだ。

# プロンプトを非表示にする
def mask_text(text, visible):
    if not text:
        return ""
    return text[:visible] + "*11文字目以降を非表示にしています*" if len(text) > visible else text

# Datadog に記録
def log_llmobs(name, prompt, output, metadata, metrics=None, error=None):
    with LLMObs.workflow(name=name) as span:
        LLMObs.annotate(
            input_data=mask_text(prompt, 10),
            output_data=mask_text(output, 10),
            metadata=metadata,
            metrics=metrics or {}
        )

文章要約を実行(Granite3.2:2b)

応答時間は1回目約29.6秒, 2回目7.2秒でした。この違いが生じる原因を調査できるか探ります。
image.png

APM > Traces > Waterfall > Infrastructure

生成AIアプリを呼び出すと、メモリ利用量が2GBから3GBに増加している。2回目はメモリ使用量3GBのままなので、教科書通りAIモデルをメモリにロードしている時間と判断できそうだ。
image.png

再検証

しばらくするとメモリが1GB近くまで解放され、再度、生成AIを呼び出し同じ傾向であることを確認できた。
image.png

End to End の可視化

情報システムの内部状態を可視化する
image.png

Datadog LLM Observability 情報一覧

今回検証できた情報を確認する。

LLM応答の指標

OpenAIなどクラウドサービスのLLMでは自動評価されるが、ローカルLLMでは非対応。ただし、独自ロジックでコーディングは可能。

指標 内容(検証できたか) 未検証の理由
quality 応答の正確性(×) ローカルLLMでは非対応
security 機密情報の漏洩検出(×) 〃 同上
hallucination ハルシネーション検出(×) 〃 同上
toxicity 有害的表現の検出(×) 〃 同上
bias バイアス検出(×) 〃 同上
failure_to_answer 未回答の検出(×) 〃 同上
topic_relevance 関連性の低い応答(×) 〃 同上

パフォーマンス・運用のためのメトリックス

メトリクス 内容(検証できたか) 未検証の理由
latency プロンプト応答時間(〇)
token_usage トークン数(〇)
error_rate エラー率(〇)
cost_estimation モデル利用コスト(〇)

トレース情報

項目名 内容(検証できたか) 未検証の理由
prompt プロンプトInput(〇)
response プロンプトOutput(〇)
model AIモデル名(〇)
endpoint 呼び出されたAPI(〇)
request_id リクエストID
user_session セッション単位のトレース分析

セキュリティ・プライバシー

項目名 内容(検証できたか) 未検証の理由
PII_detection 個人情報(PII)の検出
prompt_injection 悪意あるプロンプト操作の検出
safety_score 応答の安全性スコア

LLM Observability 以外の情報一覧

ローカルLLMアプリは、サーバーの性能パフォーマンスや、ユーザーのリクエスト数などの情報も大切。

顧客体験・WEBクライアント(RUM)

項目名 内容(検証できたか) 未検証の理由
page_load_time ページ読み込み時間(〇)
user_action_trace ユーザー操作のトレース(〇)
error_tracking JSエラーなど(〇)
geo_location ユーザーのアクセス元(〇)
device/browser_info デバイスやブラウザ情報(〇)

バックエンド・アプリケーション(APM)

APIゲートウェイやLLMプロキシを対象とするAPM

項目名 内容(検証できたか) 未検証の理由
service_latency アプリの応答時間(〇)
error_rate エラー率(〇)
trace APMとLLMのトレース相関(〇)
throughput 単位時間あたりの処理量

コードサンプル

WEBクライアント

llm.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ローカルLLMチャット</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
  <style>
    body { font-family: sans-serif; padding: 2em; }
    #response { margin-top: 1em; white-space: pre-line; }
    textarea {
      width: 60%;
      height: 100px;
      font-size: 16px;
    }
    button {
      margin-top: 0.5em;
      padding: 0.5em 1em;
      font-size: 16px;
    }
    #loader {
      margin-top: 1em;
      display: none;
    }
    select {
      margin-top: 1em;
      font-size: 16px;
    }
  </style>
</head>

<body>
  <h1>ローカルLLMチャット</h1>
  <p>Datadog LLM Observability, 4vCPUサーバーメモリ4GBなので遅いです</p>

  <label for="modelSelect">使用するモデル:</label>
  <select id="modelSelect">
    <option value="/chat">tinyllamaPOST/chat</option>
    <option value="/granite">granite3.2:2b /granite</option>
  </select><br><br>

  <textarea id="messageInput" placeholder="質問を入力 (Shift+Enterで改行)"></textarea><br>
  <button onclick="sendMessage()">AIに聞く</button>

  <div id="loader"><i class="fa fa-cog fa-spin fa-3x fa-fw"></i></div>
  <div id="response"></div>
  <div id="meta" style="margin-top: 1em; font-size: 0.9em; color: #555;"></div>

  <script>
    const messageInput = document.getElementById('messageInput');
    const modelSelect = document.getElementById('modelSelect');
    const responseDiv = document.getElementById('response');
    const loader = document.getElementById('loader');
    const metaDiv = document.getElementById('meta');

    messageInput.addEventListener('keydown', function (e) {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        sendMessage();
      }
    });

    async function sendMessage() {
      const message = messageInput.value.trim();
      const endpoint = modelSelect.value;

      if (!message) {
        responseDiv.textContent = 'メッセージを入力してください。';
        return;
      }

      responseDiv.textContent = '';
      metaDiv.textContent = '';
      loader.style.display = 'block';

      const startTime = Date.now();
      const url = `https://ドメイン名${endpoint}`;

      try {
        const res = await fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ message })
        });

        const data = await res.json();
        responseDiv.textContent = data.reply || '返答がありませんでした。';
        metaDiv.textContent = `応答時間: ${Date.now() - startTime}ms`;
      } catch (err) {
        responseDiv.textContent = 'エラーが発生しました。';
        console.error(err);
      } finally {
        loader.style.display = 'none';
      }
    }
  </script>
</body>
</html>

APIゲートウェイ

app.js
const tracer = require('dd-trace').init({
  tags: {
    host: 'ome',
    env: 'prod',
    service: 'granite-ai-node_app2',
    version: '0.0.3',
    application: 'granite-ai'
  }
});

const express = require('express');
const axios = require('axios');
const app = express();
const port = 3000;

app.use(express.json());

// [/chat] → Python proxy(非ストリーミング)
app.post('/chat', async (req, res) => {
  const userMessage = req.body.message || '';
  if (!userMessage) {
    return res.status(400).json({ error: 'No message provided' });
  }

  try {
    const response = await axios.post('http://localhost:5002/chat', {
      message: userMessage
    }, {
      headers: {
        'Content-Type': 'application/json'
      }
    });

    res.json(response.data);
  } catch (error) {
    console.error('Python proxy error (/chat):', error.response?.data || error.message);
    res.status(500).json({
      error: 'Error communicating with Python proxy',
      detail: error.response?.data || error.message
    });
  }
});

// サーバー起動
app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});

LLM プロキシ

llm_proxy.py
from flask import Flask, request, Response, jsonify
from ddtrace.llmobs import LLMObs
from ddtrace import patch_all, patch
import requests
import json
import os
from dotenv import load_dotenv

# 環境変数の読み込み
load_dotenv()

# Datadog 初期化
LLMObs.enable(
    ml_app="granite-ai",
    service="llm-worker"
)
patch_all()
patch(flask=True)

app = Flask(__name__)

# モデル設定辞書(ルートとモデルの紐付け)
MODEL_CONFIG = {
    "/chat": {
        "model": "tinyllama:latest",
        "max_tokens": 40,
        "stream": False
    },
    "/granite": {
        "model": "granite3.2:2b",
        "max_tokens": 40,
        "stream": False
    }
}

# メタデータ生成
def build_metadata(model, stream, endpoint, max_tokens):
    return {
        "model": model,
        "stream": stream,
        "provider": "ollama",
        "endpoint": endpoint,
        "max_tokens": max_tokens
    }

# Datadog LLM Observability メトリクス生成
def build_metrics(result):
    input_tokens = result.get("prompt_eval_count", 0)
    output_tokens = result.get("eval_count", 0)
    return {
        "input_tokens": input_tokens,
        "output_tokens": output_tokens,
        "total_tokens": input_tokens + output_tokens,
        "quality": 0,        # 0: 問題なし, 1: 品質低下(例:意味不明、誤答など)
        "security": 0        # 0: 安全, 1: 機密漏洩や不適切な内容の可能性あり
    }

# プロンプトを非表示にする
def mask_text(text, visible):
    if not text:
        return ""
    return text[:visible] + "*11文字目以降を非表示にしています*" if len(text) > visible else text

# Datadog に記録
def log_llmobs(name, prompt, output, metadata, metrics=None, error=None):
    with LLMObs.workflow(name=name) as span:
        if error:
            span.set_tag("error", True)
            metadata["error"] = error
            output = f"[ERROR] {error}"
        LLMObs.annotate(
            input_data=mask_text(prompt, 10),
            output_data=mask_text(output, 10),
            metadata=metadata,
            metrics=metrics or {}
        )

# Ollama API 呼び出し
def call_ollama_api(model, prompt, max_tokens=128, stream=False):
    response = requests.post(
        "http://localhost:11434/api/generate",
        json={
            "model": model,
            "prompt": prompt,
            "max_tokens": max_tokens,
            "stream": stream
        },
        stream=stream
    )
    if response.status_code != 200:
        raise Exception(handle_response_error(response))
    return response

# エラー整形
def handle_response_error(response):
    try:
        error_info = response.json().get("error", "")
        return error_info or response.text
    except Exception:
        return response.text

# 共通エンドポイント処理(非ストリーミング)
def handle_non_streaming(endpoint, prompt, config):
    model = config["model"]
    max_tokens = config["max_tokens"]
    stream = config["stream"]
    metadata = build_metadata(model, stream, endpoint, max_tokens)

    try:
        response = call_ollama_api(model, prompt, max_tokens, stream)
        result = response.json()
        output = result.get("response", "") or "[WARN] Model returned empty response."
        metrics = build_metrics(result)
        metadata.update(metrics)

        log_llmobs(f"{endpoint}_request", prompt, output, metadata, metrics)

        return jsonify({
            "reply": output,
            "eval_count": metrics["output_tokens"],
            "prompt_eval_count": metrics["input_tokens"],
            "duration_ms": result.get("eval_duration", 0)
        })

    except Exception as e:
        log_llmobs(f"{endpoint}_error", prompt, "", metadata, error=str(e))
        return jsonify({ "error": str(e), "prompt": prompt, "output": "" }), 500


# POSTリクエストからプロンプトを安全に抽出
def extract_prompt():
    data = request.get_json(silent=True) or {}
    return data.get("message") or data.get("prompt") or "質問はなんですか?"

# ルーティング定義(すべてPOSTに統一)
for route, config in MODEL_CONFIG.items():
    app.add_url_rule(
        route,
        route,
        lambda route=route, config=config: handle_non_streaming(route, extract_prompt(), config),
        methods=["POST"]
    )

# アプリ起動
if __name__ == '__main__':
    app.run(port=5002)

まとめ:DatadogによるローカルLLMアプリの可観測性を検証

本検証では、Kagoya VPS 上に構築した軽量なローカル LLM 実行環境(Ollama + tinyllama / Granite3.2:2b)を用いて、生成AIアプリのパフォーマンスと可観測性を Datadog で可視化して、以下の知見が得られました:

  • 初回リクエストはモデルロードの影響で遅延(最大29秒)、2回目以降は高速化(7秒前後)
  • CPU使用率は最大80%以上、メモリは最大3GBまで増加
  • Datadog APM ではレイテンシ、エラー率、プロセス負荷を可視化
  • LLM Observability ではプロンプトの入出力、トークン数、応答時間、コストを確認可能
  • PIIマスキングやプロンプトの匿名化もコードで制御可能

一方で、Datadog の「Managed Evaluations(品質・セキュリティ評価)」はクラウドLLM向けであり、ローカルLLMでは非対応でした。ただし、アプリ側で評価ロジックを実装すれば、カスタムメトリクスとして送信可能です。
今回の検証を通じて、ローカルLLMでも十分に可観測性を確保できること、そしてDatadog を活用すれば生成AIアプリの内部挙動をエンドツーエンドで把握できることが確認できました。

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?