NTT DATA TECH
👣

Amazon Bedrock AgentCore Observabilityを支えるADOTの実装に迫る

に公開

🧭 はじめに

2025年10月にGAとなったAmazon Bedrock AgentCoreにおける「AgentCore Observability」とは、生成AIエージェントの内部動作をOpenTelemetry(OTel)ベースで観測できる仕組みです。
リクエスト処理時間やエラー率だけでなく、「どんなプロンプトが送られ」「どんな応答が返ったのか」といったLLM固有の情報まで観測することが特徴です。

しかし、この観測の裏側では、従来までのOTelベースのトレースでは扱えない課題が存在しました。

  • プロンプトや応答は巨大なテキストであり、スパンが肥大化する
  • 機密情報を含む可能性があり、安全に扱う必要がある
  • SDKやフレームワークごとに属性構造がバラバラ

この課題を解決するために、AgentCore Observabilityを内部で支えている仕組みの一つが、ADOT SDKに拡張として実装された LLOHandler です。

本記事ではLLOHandlerのソースコードを読み解きながら、Bedrock AgentCoreがどのように生成AIの可観測性を実現しているかを解説します。

🧩 Observabilityの進化:LLMをどう計測するか

従来のObservabilityは、メトリクス・ログ・トレースの3本柱でした。その中でもトレースは「サービス呼び出しの流れ」を追跡するものであり、基本的には「入力テキスト」や「応答構造」は観測対象外でした。しかし、生成AIを活用するエージェントやLLMアプリケーションでは、これだけでは動作を十分に評価できません。

なぜならば、こうしたアプリケーションでは、「入力」「出力」「ツール呼び出し」「再プロンプト」などが複雑に連鎖するからです。それぞれの呼び出しは別のLLMプロバイダやツールを経由し、応答内容によって次の処理が動的に変化します。つまり、“なぜその出力になったのか”を追跡するには、テキスト内容そのものを含めて観測しなければなりません。

さらに、OpenAIなど外部のLLMプロバイダを呼び出す場合は、「トークン数」「推論コスト」「スロットル」などのメトリクスも絡みます。
しかし現状では、これらの情報がスパンやメトリクスとして統一的に記録されていないため、
• 各フレームワークごとにログ構造が異なり、可視化や分析が難しい
• プロンプト本文を含むトレースは肥大化し、コストやプライバシー上のリスクが高い
といった課題が生まれています。

この「LLM特有の構造化されていない観測データ」を扱うために導入されたのが、AgentCoreの LLOHandlerだと思われます。

尚、OpenTelemetryコミュニティではこのようなLLMアプリケーション特有の情報を扱いやすくするために、ガイドライン・命名ルール(Semantic conventions)やSDKライブラリが開発中の段階です。(2025年11月時点)
この中においてもプロンプト本文を含むトレースの取り扱いについて言及されており、その思想を汲み取ったものがADOTに実装されているようです。(Link: Capturing instructions, inputs, and outputs)

🧱 AgentCore Observabilityの全体構成

LLOHandlerは、AgentCore Observabilityの中で次の位置にあります。

  1. AgentCore Runtime上で動作するアプリケーションがトレースを生成
    A. 各フレームワークが提供するSDKを用いた自動計装
      (Langchain / Strands / CrewAI / etc...)
    B. コード変更を伴う手動計装
  2. LLOHandlerがLLMデータを抽出・イベント化
  3. トレースや作成されたイベントをエンドポイントにOTLPで送信
    (Cloudwatch Logs Endpoint / X-Ray Endpoint)
  4. CloudWatch上で各トレース単位でプロンプト/応答単位の可視化が可能

🏗️ ADOT内部で動くLLOHandlerの役割

LLOHandlerはAgentCoreのOTelパイプライン内で動作し、
スパンの属性やイベントから「LLMプロンプト/応答/ツール結果」などを抽出して整形します。

llo_handler.py

処理の流れは次の3ステップです:

  1. 抽出 (Collect)
    スパン属性・イベントから LLOっぽい キーを検出(例:gen_ai.prompt.0.content)

  2. イベント出力 (Emit)
    input / output のメッセージをまとめ、1スパン1イベントとして出力

  3. 削除 (Filter)
    元スパンからLLOのキーを除去して、プライバシーと容量を保護

🧠 コード解説:LLOHandlerの中身を読む

パターン定義:LLO_PATTERNS

LLOHandlerは「どの属性がLLOなのか」を判断するために、正規表現ベースのパターン辞書を持っています。

"llm.input_messages.{index}.message.content": {
    "type": PatternType.INDEXED,
    "regex": r"^llm\.input_messages\.(\d+)\.message\.content$",
    "role_key": "llm.input_messages.{index}.message.role",
    "default_role": "user",
    "source": "input",
},

これは、例えばLangChainやStrandsで発行される以下のような属性を対象にします:

llm.input_messages.0.message.content = "ユーザーの入力文"
llm.input_messages.0.message.role = "user"
llm.output_messages.0.message.content = "アシスタントの返答"

パターンにマッチしたキーをLLOとして扱い、role_keyでそのメッセージの発話者を補完します。
ちなみに、role_keyは以下の4種から選択され正規化されます。

ROLE_SYSTEM = "system"       # システムプロンプト
ROLE_USER = "user"           # ユーザプロンプト
ROLE_ASSISTANT = "assistant" # エージェントプロンプト
ROLE_TOOL = "tool"           # ツール実行結果等

このパターン辞書には他にも、Traceloop/OpenInference/CrewAI/Strandsなどのさまざまなフレームワークがサポートされています。

メイン処理: process_spans

def process_spans(self, spans: Sequence[ReadableSpan]) -> List[ReadableSpan]:
    for span in spans:
        all_llo_attributes = self._collect_llo_attributes_from_span(span)
        if all_llo_attributes:
            self._emit_llo_attributes(span, all_llo_attributes)
        self._update_span_attributes(span, self._filter_attributes(span.attributes))
        self._filter_span_events(span)

ざっくり言うと:

  1. スパン内の属性・イベントを走査し、LLOを集める
  2. それをまとめて Generative AI Events として出力
    Semantic conventions for Generative AI events
  3. 元スパンからLLOを削除して「安全なスパン」にする

抽出したメッセージは、次のような構造でまとめられます:

{
  "input": {
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "東京の天気は?"}
    ]
  },
  "output": {
    "messages": [
      {"role": "assistant", "content": "今日は晴れです。"}
    ]
  }
}

このイベントは OpenTelemetry の EventLoggerProvider によって出力され、
CloudWatch Logsに“Event”ログとして送信されます。

フィルタ処理: _filter_attributes / _filter_span_events

生成AIのプロンプトは往々にして機密情報を含むため、元のスパンからはLLOを完全に除去します。

def _filter_attributes(self, attributes: types.Attributes) -> types.Attributes:
    filtered = {}
    for key, value in attributes.items():
        if not self._is_llo_attribute(key):
            filtered[key] = value
    return filtered

_is_llo_attribute(key)は前述のLLO_PATTERNSをもとに正規表現マッチを行うため、LLM関連のキーのみを安全に抽出する事が可能です。

こうして、スパンには操作情報のみが残り、プロンプト本文は別イベントとして隔離されます。

🪄 LLOHandler処理フロー図

🔍 なぜこの仕組みが必要なのか

  1. スパンの肥大化を防ぐ

LLMの入力や出力は数k〜MB級のテキストになることもあります。
それをスパン属性に埋め込むと、トレースが巨大化してコスト・速度・メモリを圧迫します。
→ LLOHandlerはそれを独立したイベントとして切り出します。

  1. セキュリティとプライバシーの確保

ユーザー入力には個人情報や業務データが含まれる可能性があります。
スパンは外部システムへ転送されることも多いため、LLOを安全にフィルタすることが重要です。

  1. フレームワーク間の互換性

各SDK(Traceloop, Strands, CrewAI, LangChainなど)はそれぞれ異なる属性命名を使っています。LLOHandlerはこれを正規化レイヤとして統一する役割を担います。

🚀 まとめ

  • LLOHandlerはLLMトレース専用のOpenTelemetryプロセッサ
  • スパンからプロンプト・応答を安全に分離し、構造化イベントに変換
  • Bedrock AgentCore Observability が実現する「生成AIの可観測性」の中核を担う

現在develop statusであるOpenTelemetryのGenAI SemConv仕様が整備されるにつれて、LLOHandlerのような仕組みは “LLM Observabilityのデファクト”になることが期待されます。

今回はソースコードを読み解くことで、2025年10月にGAを迎えたAgentCore Observabilityもそうした標準化の最前線に立っていることが分かりました。AgentCoreだけでなく、DatadogなどのAPM製品においても類似のサービスが提供され始めています。今後はそういった各種サービスの違いや特徴についても検証していきたいと考えてます。

🧾 参考リンク

NTT DATA TECH
NTT DATA TECH
設定によりコメント欄が無効化されています