Cloud Run × FastAPI でアプリログに trace を付ける(google-cloud-logging だけでは足りなかった話)

テックドクターでエンジニアをしている星野です。

弊社では Python と FastAPI を使うことが多く、アプリケーションの実行環境として Cloud Run を利用しています。

Cloud Run で障害調査をしていると、たくさんのログの中でアプリケーションのログが混ざってしまい、どの HTTP リクエストで発生したものなのか分からず苦労することがあります。

Cloud Run のリクエストログは自動でリクエストごとに出力しますが、アプリケーションのログは標準ではリクエストログと紐づきません。

この記事ではその解決策として、

  • アプリケーションのログとリクエストを紐づけるには trace が使えること
  • ただしFastAPI では google-cloud-logging を使うだけでは trace が付かない。OpenTelemetry を追加すれば紐づけ可能であること

の2点を紹介します。

trace でログを紐づける方法

Cloud Run はリクエストを受け取ると、traceparent ヘッダを付与し、その trace ID をリクエストログに記録します。

アプリケーションのログにも同じ trace ID を付与すれば、Cloud Logging でコンテナログとリクエストログが関連付けられ、1 リクエスト分のログをまとめて確認できます。

trace ID はリクエストのヘッダから自前で取り出すこともできますが、google-cloud-logging を使えばライブラリ側で付与してくれます。

google-cloud-logging を試す

依存パッケージの追加
fastapi==0.115.6
uvicorn[standard]==0.34.0
google-cloud-logging==3.11.3
setup_logging() を呼ぶ

基本的な書き方は次の通りです

import logging
import google.cloud.logging

client = google.cloud.logging.Client()
client.setup_logging()

logger = logging.getLogger(__name__)

setup_logging() は Python 標準の logging に Cloud Logging 用のハンドラを追加します。出力したログは Cloud Logging で見られるようになります。
こうすることにより、Flask の場合は、setup_logging() を呼ぶだけでアプリケーションのログがリクエストログと同じ trace に紐づきます。

ただし……FastAPI では trace が付かない

次に、FastAPI でも同じように書いてみます。

import logging

import google.cloud.logging
from fastapi import FastAPI

client = google.cloud.logging.Client()
client.setup_logging()
logger = logging.getLogger(__name__)

app = FastAPI()


@app.get("/log")
def emit_logs():
    logger.info("info level log from /log")
    logger.warning("warning level log from /log")
    logger.error("error level log from /log")
    return {"status": "ok"}

これを Cloud Run にデプロイします。

gcloud run deploy cloud-logging-trace-demo \
  --source . --region asia-northeast1 --allow-unauthenticated

デプロイしたエンドポイントに /log でリクエストを送り、そのリクエストの trace で Cloud Logging を検索したところ、アプリケーションのログには trace が付いていませんでした。

画面キャプチャ

リクエストログ(GET /log)の下に、アプリケーションのログがぶら下がっていません。リクエストログには trace が付いていますが、アプリケーションのログには付いていないため、リクエストログとアプリケーションのログは紐づきませんでした。

公式ドキュメント Integration with Python Web Frameworks によると、google-cloud-logging がリクエストから trace を自動取得できるのは FlaskDjango のみで、FastAPI はサポート対象に含まれていなかったためです。

trace の取得元は次のいずれかです。

  • OpenTelemetry のアクティブな span
  • Flask または Django のリクエストコンテキスト

FastAPI はどちらにも該当しないため、setup_logging() だけではアプリケーションのログに trace が付きませんでした。

逆に言えば、OpenTelemetry の span を用意すれば trace を取得できます。次は OpenTelemetry で span を生成してみます。

OpenTelemetry で trace を付与する

FastAPI でリクエストごとに OpenTelemetry の span を生成します。setup_logging() はその span から trace を取得します。

依存パッケージの追加

OpenTelemetry 関連を追加します。

opentelemetry-sdk==1.29.0
opentelemetry-instrumentation-fastapi==0.50b0
OpenTelemetry の設定

FastAPIInstrumentor を使って、リクエストごとに span を生成します。traceparent ヘッダはデフォルトで読み込まれるため、追加の設定は不要です。

import logging

import google.cloud.logging
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider

trace.set_tracer_provider(TracerProvider())

# google-cloud-logging は OpenTelemetry のアクティブな span から trace を取得する
client = google.cloud.logging.Client()
client.setup_logging()
logger = logging.getLogger(__name__)

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)  # リクエストごとに span を生成する


@app.get("/log")
def emit_logs():
    logger.info("info level log from /log")
    logger.warning("warning level log from /log")
    logger.error("error level log from /log")
    return {"status": "ok"}

 

検証結果

この状態で再デプロイし、/log にリクエストを送ったところ、アプリケーションのログとリクエストログが同じ trace に紐づきました。

画面キャプチャ

リクエストログ(GET /log)の下に、アプリケーションのログがぶら下がって表示されています。アプリケーションのログを JSON で確認すると、trace / spanId / traceSampled が付与されています。

{
  "logName": "projects/<project>/logs/run.googleapis.com%2Fstderr",
  "severity": "INFO",
  "trace": "projects/<project>/traces/c744c3b23c8f0b9acbff17b154197e73",
  "spanId": "e3eccc394a71c4d7",
  "traceSampled": true,
  "labels": { "python_logger": "app.main" }
}

同じ trace のリクエストログは以下の通りです。

{
  "logName": "projects/<project>/logs/run.googleapis.com%2Frequests",
  "httpRequest": { "status": 200 },
  "trace": "projects/<project>/traces/c744c3b23c8f0b9acbff17b154197e73",
  "spanId": "3229e032d02683b5"
}

spanId はアプリケーションのログとリクエストログで異なりますが、trace が一致していれば、リクエストログの下にアプリケーションのログをまとめて表示することができます。

なお今回はログに trace を付けるのが目的のため、span を Cloud Trace に送る exporter は設定していません。Cloud Trace のスパンツリーで span の親子関係まで見たい場合は、別途 exporter の設定が必要です。

trace を自前で付与する場合

依存パッケージを増やしたくない場合は、ライブラリを使わず、リクエストの traceparent ヘッダから trace ID を取り出し、ログの logging.googleapis.com/trace フィールドに設定して出力する方法もあります。traceparent がない環境では、フォールバックとして X-Cloud-Trace-Context ヘッダから取り出します。

# traceparent: 00-<trace-id>-<span-id>-<flags>
traceparent = request.headers.get("traceparent")
if traceparent:
    trace_id = traceparent.split("-")[1]
else:
    # フォールバック: X-Cloud-Trace-Context は <trace-id>/<span-id>;o=<flags>
    trace_id = request.headers.get("X-Cloud-Trace-Context", "").split("/")[0]

log_entry = {
    "message": "manual trace example",
    "logging.googleapis.com/trace": f"projects/{project}/traces/{trace_id}",
}
print(json.dumps(log_entry))  # 構造化ログとして出力する

 

まとめ

Flask や Django では setup_logging() だけで trace が付きますが、FastAPI では OpenTelemetry の追加が必要になります。

リクエストログとアプリケーションのログが同じ trace でまとまると、原因のリクエストを見つけやすくなります。

Cloud Run で FastAPI を動かしていてまだ設定されていないなら、今回のように google-cloud-logging と OpenTelemetry を組み合わせる構成を試すのがよさそうです。

補足として、setup_logging() は実行環境を自動判定するため、ローカルで動かすとログはターミナルに出力されず Cloud Logging API へ直接送信しようとします。送信には Application Default Credentials(ADC)による認証が必要で、認証がないとエラーになります。ローカルでターミナルに出力したい場合は、setup_logging() を呼ばないようにする必要があります。

データサイエンスチームのAI活用進化記 (2026年5月版)── コピペから一気通貫へ

データサイエンス(DS)チームのマネジメントを担当している深見です。

この数ヶ月で、データサイエンティストの仕事のやり方が大きく変わり始めました。かつては「エンジニアが中心」と思っていたAIの普及による変化が、データサイエンティストを含む幅広い職種に及んでいると感じられます。

今回は、テックドクターの社内研究業務におけるAIツールの活用について、導入の経緯・業務の変化・課題と今後の展望をご紹介します。

DSチームでも、ChatGPTやGeminiの導入に始まり、最近ではClaudeを日常的に使うようになっています。つい先日まで「研究へのAI活用はまだ先の話」と思っていたのが嘘のように、今ではなくてはならない存在になっています。

Claudeの導入で何が変わったか

詳細は後述しますが、Claude導入後に起きた変化を先にお伝えします。

  • 解析用のコードを自分で書かなくてもよくなった
  • 「ちょっと試してみたい」ことが、すぐに・簡単に実現できるようになった
  • 特徴量の選定やパラメータの設定など、無数のパターンを網羅的に探索できるようになった
  • HTMLファイルでの結果出力により、わかりやすさ・インタラクティブ性が向上した

これらの積み重ねにより、 新たな技術の導入や基礎研究に充てられる時間が増加しました。基礎研究から面白い成果が生まれるなど、正のスパイラルが回り始めています。

テックドクターにおけるDSチームの研究業務

変化の背景を理解していただくため、まずテックドクターのDSチームが担う業務内容を紹介します。

  • 論文調査・仮説立案
  • データ前処理・解析
  • QC(Quality Control)
  • 結果報告 
  • 学会・論文発表

テックドクターはウェアラブルデータの医療活用という新しい分野に挑戦しており、研究だけでなく、社内外へのわかりやすい説明・アピールも重要な業務です。幅広い業務をこなす必要があるため、AIとの連携による効率化の恩恵を受けやすい環境でもありました。

AI活用の変遷

DSチームがどのようにAIツールを使いこなしてきたか、3つのフェーズに分けて振り返ります。

フェーズ1(初期):コードをコピペする時代

最初はChatGPTやGeminiを使い、可視化や解析のためのコードを生成してColabに貼り付けて実行していました。エラーが出ればエラー内容を転記してコードを再生成・再貼り付けという繰り返し。自分でゼロからコードを書くよりは多少早い、という程度でした。会話と実行環境が分断されているため、コンテキストを保ちながら作業することが難しく、生産性の向上は限定的でした。

画面キャプチャ
ChatGPTによるコード作成

フェーズ2(中期):環境に組み込む

転機となったのは、Google Colaboratory(Colab)に搭載されたGeminiがある程度実用的に使えるようになったことです。解析方針の立案・コード実行・結果考察まで、一通りの作業を同じ環境の中で進められるようになりました。特に結果考察の文章まで生成できるようになったのは大きな変化だったと思います。正確性については検証が必要とはいえ、これまで計画立案や解析以上に時間を取られることも多かった部分を一定サポートしてもらえるようになったのは非常に助かりました。

こうしてデータの把握や簡単な解析であれば任せられるレベルになりましたが、一方で一連のタスクの途中で頻繁にフリーズしたり、エラーを解消できず同じ処理を繰り返すなど、まだ「任せ切る」には実力不足という場面も多くありました。

画面キャプチャ
Colab with Gemini

フェーズ3(現在):一気通貫の時代へ

現在はClaude Codeを中心に活用しており、業務の幅と質が大きく変わりました。具体的には以下のような使い方をしています。

  • BigQueryからのデータ抽出の半自動化
    • BQとの通信、基本SQLスクリプトなどをskillとして用意しagentに実行させる
  • データ加工 → 解析 → 報告資料作成までを一貫して依頼
    • 解析指示書を作成して実行させる 
  • QC(データ品質チェック)の自動化
    • チェック項目や外れ値条件などをskillとして用意してagentに実行させる
  • 論文作成の支援
    • 関連分野の論文検索や要約、初期文面案の作成を行い人がチェックする

以前は「コードを書く→実行する→結果を整形する→資料を作る」という各ステップを人が繋いでいましたが、今はそのフロー全体をAIと協働して進められるようになりました。

QC作業においても、人手でチェックしていた内容をAIに任せられるようになり、見逃しが減るとともに網羅性が高まっています。多くのメンバーはデータの品質チェックまでを含めた「解析指示書」を最初に作成し、それを実行させるような使い方をしています。

画面キャプチャ
Claudeによる自動QC:1,000件を超えるチェックでもミスを確実に検出

大きく変わったところ

フェーズ3を迎えて特に大きく変わったと感じる3つの点について、詳しくご紹介します。

研究業務の効率化

最も大きな変化は、解析完了までの時間が大幅に短縮されたことです。

一例としては、 Claude導入前は特徴量ごとに細かい規定をしたい場合はそれに対応したPythonコードを自分で書く必要がありました。小さなミスや抜け漏れが発生しやすく、QCにも多くの時間がかかっていました。こういった非効率をClaudeは解決してくれました。

また、関連する文献の調査で知り得た解析手法などのコードの実装も、ある程度任せられるようになりました。論文などで見かけたアルゴリズムの実装は、ひとまず動く状態のコードを作成するだけでも時間がかかることが多かったですが、この時間が大幅に短縮されました。

こういった時間が大幅に削減された結果、「やってみたいが時間がかかる」として見送っていたアイデアを、隙間時間に気軽に試せるようになりました。大量の組み合わせを網羅的に探索することも現実的になり、解析の深さ・幅が広がっています。

アウトプットの変化:スライドからHTMLファイルへ

地味ながら効いているのが、アウトプット形式の変化です。従来はスライドでの結果共有が主流でしたが、現在はインタラクティブなHTMLファイルでの共有が増えています。

HTMLファイルであれば、グラフの表示範囲を変更したり、表でフィルタリングをかけたりがブラウザ上でインタラクティブに可能です。これまでは解析者が代表的なものを選んでスライドにまとめるか、大量の画像をAppendixとして貼り付けるしかありませんでしたが、閲覧者がその場で見たいものだけを選んで確認できるようになりました。結果として報告の「伝わりやすさ」が大幅に向上しています。

画面キャプチャ
これまでのスライドイメージ
HTMLによるアウトプットイメージ。プルダウンやフィルタリングによる動的な表示が可能に。

分析以外の業務への展開

Claudeの活用は解析業務にとどまりません。ドキュメント整備・社内外への説明資料の作成など、これまでDSが時間を取られていた周辺業務にも活用が広がりました。作成できるコンテンツの幅が広がったことで、ウェアラブルデータの医療活用という専門性の高い内容を、より多くのステークホルダーにわかりやすく届けられるようになっています。

画面キャプチャ
理解促進用HTMLコンテンツ:特徴量カタログ

課題

活用が進む一方で、課題も見えてきています。

  • 成果物のクオリティチェックは依然として人が必須
  • 論文・ドキュメントには厳密な確認が不可欠
    • 架空の引用やロジックのすり替えが頻発する
  • 大量に生産される結果に対して、人のレビューが追いつかない

AIは優秀な補助者ではありますが、すべてを任せられる状態にはまだありません。特に機械学習モデルの構築では、データリークを発生させてでも結果をよく見せようとするなど、指示の意図を汲みすぎる傾向が見られます。Claudeだけに頼るのではなく、GeminiなどほかのAIによる相互チェックの導入も検討しています。

まとめ

冒頭に書いたことの繰り返しになりますが、この数ヶ月で、データサイエンティストの仕事のやり方は本当に大きく変わりました。
特に感じるのは、ジュニアとシニアの差が開いているという点です。AIは知識・経験の差を補うどころか、もともと「できる人」がさらに多くのことを実現できる道具になっています。また、意欲の差も結果に直結するようになりました。これまでは「時間がかかる」「学習コストが高い」として見送っていたアイデアが、隙間時間で実行できるようになったからです。

その結果、「これをやってみたい」「こうしてみたらどうだろう」という意志・意欲のある人がどんどん成果を出す一方、言われたことだけをこなしている人は相対的に埋もれていく状況が生まれています。

AIは道具です。その道具を最大限に活かすのは、使い手の好奇心と主体性だと改めて感じています。

似顔絵
書いた人:深見

AI時代に、チケットというUIはまだ最適なのか

バックエンドエンジニアの伊藤です。

最近、AIコーディングエージェントを使った開発が当たり前になる中で、改めてチケットには何を書くのが最適なんだろう、そもそもチケットが生まれた経緯・役割はなんだったのだろう、AI時代にはチケットはどう変わるのだろう、ということを考えています。

ここ1、 2年で、AIによってコードを書く速度が格段に速くなってきました。簡単な修正であれば、実装そのものは数分で終わります。バグの再現条件を渡し、関連しそうなファイルを読ませ、修正案を出させ、テストまで書かせることもできます。

一方で、その作業をチケットに起こし、背景を書き、スコープを書き、受け入れ条件を書き、担当者を設定し、ステータスを動かす時間の方が重く感じることがありました。

ここで疑問が生まれます。

チケットは、AI時代のソフトウェア開発においても、最適なインターフェースといえるだろうか?

本稿では、チケットというUIそのものについて再考します。

本記事の概略

本記事の概略を先に書いておきます。

  • チケットは、実装が遅く、人間同士のハンドオフ(人から人への作業の引き渡し)が多く、文脈を一箇所に圧縮して渡す必要があった時代に適したインターフェース
  • AIによって実装、分解、レビュー補助、テスト生成が速くなると、チケットにすべてを詰め込むモデルは、少しずつ重くなっていく。
  • 短期的にはチケットの構造化が重要になるが、中長期では、チケットは開発の中心的なUIではなく、意図や証跡を見るための一つのビューになっていく
  • これから必要なのは、チケットだけを中心に置く開発ではなく、意図(Intent)を意識した開発。Ticket-firstではなく Intent-aware の開発。

チケットは何を解決していたのか

チケット、Issue、Bug report、Work item。呼び方はいろいろありますが、起源をたどると、もともとは未解決の不具合や変更要求を追跡するための仕組みでした。

たとえば Bugzilla は、自らを defect-tracking system または bug-tracking system と説明しています。Bugzilla の説明では、開発チームが未解決の bugs、problems、issues、enhancement requests、change requests を追跡するための仕組みとされています*1

しかし、チケットは単なるバグ管理表にとどまりませんでした。現代のIssue Tracking Systemは、requirements、development tasks、maintenance itemsなど、ソフトウェア開発に関わる複数の成果物を管理する基盤として使われています*2

つまりチケットは、以下のような役割を持っています。

役割 内容
入口 要望、バグ、問い合わせ、改善案を受け取る
キュー 後でやるかもしれない作業を貯める
優先順位づけ 何を先にやるべきかを決める
ハンドオフ PdM、エンジニア、QA、CS(カスタマーサクセス/カスタマーサポート)の間で仕事を渡す
責任の明確化 誰が担当するかを決める
進捗管理 Todo / Doing / Review / Done を可視化する
合意形成 仕様やスコープの認識を揃える
検証条件 何をもって完了とするかを定義する
証跡 なぜその変更をしたかを後から追えるようにする
メトリクス 件数、滞留、リードタイム、担当負荷を見る

言い換えると、チケットは「作業にまつわる不確実性を、一つのカードにまとめたもの」とも言えます。

チケットは文脈圧縮インターフェースだった

顧客の不満、PMの判断、エンジニアへの作業依頼、仕様のメモ、優先度、担当者、受け入れ条件、関連 PR、QA 結果……などなど、関係者ごとに散らばる前提や判断をチケットという一枚のカードに圧縮する。この働きは、「文脈圧縮」と呼ぶことができそうです。

チケットが多様な文脈を一枚のカードに圧縮することで、チームは非同期に仕事を進めることができました。

一方で、これまでチケットが有効だった背景には、いくつかの前提があったと考えています。

まず、実装には時間がかかりました。そのため、事前に何を作るかを整理し、優先順位をつけ、担当者を決める価値がありました。

次に、仕事は人間が順番に処理していました。そのため、チケットはキューとして機能しました。

さらに、開発には多くのハンドオフがありました。PMが背景を説明し、エンジニアが仕様を解釈し、QAが期待挙動を確認し、CSが顧客影響を追います。その間で文脈が失われないように、一枚のチケットに情報を圧縮して渡す必要がありました。

つまりチケットは、実装が遅く、ハンドオフが多く、非同期コミュニケーションにコストがかかる時代に適したインターフェースだった、と整理できます。

少し強く言うと、チケットは「実装が遅い時代の文脈圧縮フォーマット」とも言い換えられるかもしれません。しかし、AIコーディングエージェントが高速でコードを生成していく今、その前提はいま崩れつつあります。

※もちろん、規制対応、監査、大規模分散開発のように、チケットが提供してきた他の機能、たとえば証跡や責任の所在の明示は、AI時代でも変わらず必要になる場面があります。チケットの存在意義そのものを否定したいわけではありません。
これからのチケットの在り方については後半で触れたいと思います。

チケット以外の思想から学べること

チケット中心の開発を相対化する視点は私だけの意見ではなく、すでにいくつかの言説が存在しています。

ひとつはShape Upです。Basecampは、未着手アイデアを大量に抱えるバックログを「大きな重荷」だと表現しています*3。古いアイデアを定期的に整理することに時間が取られ、現在のプロジェクトを進める力が削がれる。だからこそ、少数のwell-shaped、リスクを下げた選択肢にbetするべき、という考え方です。

もうひとつはSciitという研究です。Issue trackerとSCM(ソースコード管理)が分離していると、開発者が両方の情報を手作業で整合させる必要があり、手間が大きいと指摘しています*4。Sciit はそのうえで、issueをSCMの中で(コードと同等に扱う)構成要素として置き、進捗や担当者をリポジトリの状態から推測できるようにする方向を示しています。

そしてGitHubのSpec Kitがあります。Spec Kitは、Spec-driven developmentという方法論を実装するためのツールキットです*5。これまでは「コードが正で、仕様は足場のようなもの」だったが、Spec Kitではむしろ「仕様が実装を生む中心的な成果物になる」という整理が示されています。Microsoft for Developersのブログでも、AI エージェントに正しい出力を出させるためには、まず良い文脈(context)が必要であり、決めないまま進めると codebase が de-facto specification になってしまう、と指摘されています*6

これらに共通するのは、チケットを保存形式・操作 UI・実行単位として一枚にまとめる前提を疑う動きだと考えます。バックログを軽くする、issueをSCMに埋め込む、仕様を実装の中心に置く。それぞれアプローチは違いますが、チケットが暗黙に担ってきた多くの責務を、別のレイヤーに分けていく方向性で重なっています。

チケットが担っていた役割を分解する

つまり、すべての情報をチケットが持ち続ける必要はないのかもしれません。
改めて、チケットがこれまで担ってきた役割を、AI時代に合う形で考え直してみましょう。

役割 従来の置き場 AI時代の移行先(候補)
入口
・要望
チケット ドキュメント、対話、フィードバック
背景
・意図
チケット 設計ドキュメント、ADR
仕様
・制約
チケット 実行可能な仕様(executable spec)、テスト
作業の実行 チケット AI エージェントが動的に生成する作業単位、PR
進捗管理 チケット PR、commit、test、deploy、AI エージェントのログから自動観測
検証 チケット テスト、CI、合成テスト、AI レビュー
証跡 チケット PR、commit、AI エージェントの実行履歴
振り返り チケット メトリクス、ダッシュボード
決定 チケット ADR、設計ドキュメント

具体的な移行先(情報の管理方法)については後で考えたいと思います。

ここでは一旦、役割の方に注目してください。これらの役割に共通するのは、作業そのものではなく、その背後にある「何を実現したいのか」という意図と、それを支える判断・制約・検証だった、と整理できます。チケットが守っていた中核は、作業(Task)よりも、その背後にある意図(Intent)だったとも言い換えられるのではないでしょうか。

AI時代のソフトウェア開発では、この「意図(Intent)」がより重要になってきていると感じます。

AIによって変わったこと

AI時代のソフトウェア開発では、単にコードを書くだけではなく、調査、分解、実装、テスト生成、レビュー補助、要約、トリアージ。ソフトウェア開発の周辺工程全体にAIが入り始めています。

象徴的な発信もあります。Linear は、2026 年 3 月 24 日にマーケティング上のメッセージとして "Issue tracking is dead." を打ち出しました*7。Linear によれば、従来のIssue trackingはPMが作業をスコープし、エンジニアが後から拾うハンドオフモデルのために作られたものだったとされています。今後のシステムは、ハンドオフではなく、文脈(context)とAIエージェント(agents)を中心に設計される、というのがLinearの主張です。

DORAの2025年の報告書も、AIは組織の既存の強みと弱みを増幅するものであり、AI導入による効果はツール単体ではなく組織システム全体に依存すると指摘しています*8。同報告書は、速度向上が下流の不安定さを露わにする、内部プラットフォーム品質がAI価値を決定づける、といった具体的な論点も提示しています。

ここから読み取れるのは、AI時代のボトルネックが、実装そのものから、意図の明確化、検証、責任分界へと移りつつある可能性です。
何を作るべきか、どこまで作るべきか、何を守るべきか、どうなれば正しいと言えるのか。そういった「意図(Intent)」こそが、より重要になっていく。このことはここ1, 2年で急速に共通認識になりつつあります。

なぜIntentが重要なのか

ここでいうIntentは、単なる目的文ではありません。

「なぜこれをやるのか」「何を満たせば成功か」「どこまでやれば終わりか」「どの制約を守るのか」「どんな副作用を避けたいのか」これらをまとめた、開発の起点です。

作業(Task)と意図(Intent)の違いを下記の表にまとめました。

Task Intent
粒度 作業単位 目的単位
寿命 完了したら閉じる 達成後も参照されることが多い
多い 少ない
主な担い手 AI エージェント 人間
役割 実行 判断・検証

AI時代にIntentの重要性が増すと考えられる理由は、AIが作業を高速・大量に実行できる場面が増えてきたからです。

人間の開発者であれば、曖昧なチケットでも文脈から意図を補完して、「たぶんこういうことだろう」と判断できます。AIも確認はできますが、人間より速く・大量にコードを生成できるため、曖昧な指示を誤って解釈してしまった場合に積み上がる量が桁違いに大きくなります。

そのため、AI時代には「実装できること」よりも、「意図に沿って実装されていること」の方が重要になっていくと考えています。

Taskは完了すれば閉じます。しかしIntentは、作業が完了した後も、後続の意思決定で参照されることが多い情報です。「なぜこれを作ったのか」「制約は何だったか」「どうなれば成功か」を、後から検証・参照できることが重要です。

この考え方に立つと、開発の中心にあるのが必ずしもチケットである必要はなくなります。

むしろ、これまでどおりチケットを中心に扱うと、Intentはチケット内に閉じ込められることになります。こういった方法では、複数のチケットやPRが同じIntentを参照する場面で、参照の起点がチケットの中に隠れてしまいます。また、チケットが閉じた後も生き続ける情報の追跡もしにくくなる問題もあります。

では、Intentを軸にすると、具体的にどういう情報の置き方になるのか。次節で見ていきます。

Intentを軸に、開発情報をつなげて考える

本稿では、大それた新しいシステムや大きな方法論を提案したいわけではありません。

たとえば、ある改善について次のようなIntentがあったとします。

Intent:
ユーザーが問い合わせをせずに、自分で退会手続きを完了できるようにする

この Intent の周辺には、次のような情報が紐づきます。

例えばこういった形で必要な情報を管理可能です。

  • 背景や意思決定はNotionや設計ドキュメントに置けばよい
  • 作業単位や担当はLinearやGitHub Issuesに置けばよい
  • 実装の証跡はPRに残る
  • 検証結果はCIやテストレポートに残る
  • リリース状況はDeploymentに残る
  • 運用後の変化はメトリクスや外形監視で確認できる

情報を(従来のチケットのように)一箇所に集めてはいません。適切に残されたそれぞれの情報が、どのIntentに紐づいているのか。そのつながりを追えることが重要です。(ここで新しいシステム名や方法論を提案したいわけではありませんが、 便宜的に「Intent Graph的な構造」と呼ぶことはできそうです)

もちろん、これをきれいに整備するのは簡単ではありません。Intentを軸にして、開発に関わる情報をつなげて扱いたい、という思いはありつつも、私たち自身もこの形をきれいに整備できているわけではないのです。現実には、背景はNotionにあり、作業はLinearにあり、実装はGitHubのPRにあり、結果はアプリ上にあり、それらのつながりは人間の記憶に依存していることも多いのが実情です。

実運用上は、Intent IDをどこに置くかという規約と、ツール間のwebhookやリンクの整備が必要になります。メトリクスからIntentへの逆引き(ある数値変動がどのIntentに紐づくか)を機械的に追うのも、現状ではまだ弱い部分です。このあたりの人間の規律と運用コストの部分で課題は残りますが、今後あるべき姿としてはこういった構造なのではないか、と考えています。

※「Linearや Jira の階層機能(Initiative / Project / Issue / Sub-issue)で十分では?」と思われるかもしれません。
たしかに、既存ツールの組み合わせで近い構造は組めるはずです。そもそも、ある程度成熟したチーム・組織であれば、すでに似たようなことはやっているのではないかと思います。ただ、今あるツールをうまく使うよりもより良いUIがあれば、より広く実践される可能性はあると思います。

Ticket-firstからIntent-awareへ

昨今、チケットの構造化に関する話題が増えています。背景、目的、制約、非スコープ、受け入れ条件を今まで以上にきちんと書きましょう、という流れです。これは短期的には正しい方向です。曖昧なチケットは、曖昧な実装を生みます。AIは曖昧な指示に対しても、それらしい実装を高速に返してしまうことがあるため、目的と制約を明示する価値はむしろ高まります。

しかし中長期では、状況は変わってくると考えています。

「良いチケットを書きましょう」という議論は、チケットが開発の中心にあることを前提にしています。本当に考えたいのは、チケットというUIが、AI時代の開発における最適な中心であり続けるのか、という問いです。

チケットはこれまで、意図、作業、責任、進捗、検証、証跡を一枚にまとめる便利な箱でした。その便利さは、実装が遅く、ハンドオフが多く、文脈を一箇所に圧縮する必要があった時代には大きな価値を持っていました。

しかしAIが文脈を保持し、関連するIntent、過去の判断、制約、テスト結果を踏まえて作業できるようになると、すべてをチケットに書く必要は薄れていきます。

細かい実装ToDoは、AIエージェントが動的に生成するようになっていきます。進捗ステータスは、PRやCI/CDから自動観測される方向に進みます。バグ調査は、ログや監視アラートから直接AIエージェントが始める運用も、商用ツール(SentryのAutofix、DatadogのBits AI等)で部分的に現れ始めています。

仕様は、ドキュメントだけでなく、実行可能な仕様(executable spec)としてCIに組み込まれていきます。

一方で、人間の判断は残ります。何を作るべきか。なぜ今やるのか。どんな副作用を避けたいのか。法務・セキュリティ・倫理上、何を許容しないか。これらはIntentの領域であり、AIが代替するのは難しい部分です。

AIが高速に実装できるようになるほど、「何を作るか」よりも、「なぜ作るのか」「何を守るのか」「どうなれば成功なのか」が重要になります。DORA の 2025 年の報告書が指摘するように、速度向上は下流の不安定さを露わにし、その不安定さを支えるのは、内部プラットフォームの品質や、小さく変更を進める運用のような、組織システム側の力です*9

だからこそ、これから必要なのは、チケットが担っていた役割を分解し、Intentを軸に再配置することです。Intentを意識して、ドキュメント、チケット、PR、テスト、監視、メトリクスをつなげていく方向に、少しずつ組み直していくことです。

その過程で、チケットは消えるのではなく、置かれる位置が変わると考えています。

チケットは、作業管理の唯一の中心ではなくなっていきます。作業の記録(record of work)であると同時に、Intentを軸につながった情報を、人間が見るための一つのビューになっていくのではないかと思います。

似顔絵
書いた人:伊藤

参考文献・引用元

*1:Bugzilla Project, "About - What is Bugzilla?", Bugzilla.org. URL: https://www.bugzilla.org/about/ (閲覧日: 2026-04-27)。Bugzilla が defect-tracking / bug-tracking system であり、bugs、problems、issues、enhancement、change requests を追跡するための仕組みである、という説明の出典です。

*2:Lloyd Montgomery, "Issue Tracking Ecosystems: Context and Best Practices," arXiv:2507.06704, 2025. URL: https://arxiv.org/abs/2507.06704 (閲覧日: 2026-04-27)。Issue Tracking Systems が requirements、development tasks、maintenance items などを扱う基盤であり、issue 間リンクや外部ツールとのリンクによって traceability を支える、という整理の出典です。

*3:Ryan Singer / Basecamp, "Bets, Not Backlogs," Shape Up, Chapter 7. URL: https://basecamp.com/shapeup/2.1-chapter-07 (閲覧日: 2026-04-27)。バックログを大きな重荷と捉え、古いアイデアの整理ではなく少数の well-shaped な options に bet する、という考え方の出典です。

*4:Edwards Nystrom, Dhitiwat Jongsuebchoke, Tim Storer, "Sciit: Embedding issue tracking in source control management," Science of Computer Programming, Vol. 206, Article 102628, 2021. DOI: 10.1016/j.scico.2021.102628. URL: https://doi.org/10.1016/j.scico.2021.102628 (閲覧日: 2026-04-27)。Issue tracker と SCM を分離して管理する摩擦、issue を SCM 内の first-class change control item として扱い、status や participants などを SCM の状態から推測する設計の出典です。

*5:GitHub, "Spec Kit Documentation," GitHub Pages. URL: https://github.github.com/spec-kit/ (閲覧日: 2026-04-27)。Spec-Driven Development が仕様を実行可能なものとして扱い、仕様が working implementation を直接生成するという説明、および intent-driven development の整理の出典です。

*6:Den Delimarsky, "Diving Into Spec-Driven Development With GitHub Spec Kit," Microsoft for Developers Blog, 2025-09-15. URL: https://developer.microsoft.com/blog/spec-driven-development-spec-kit (閲覧日: 2026-04-27)。AI agent に正しい出力を出させるには良い context が必要であり、何をなぜ作るのかを決めないまま進めると codebase が de-facto specification になる、という説明の出典です。

*7:Linear, "Issue tracking is dead," Linear.app/next, 2026-03-24. URL: https://linear.app/next (閲覧日: 2026-04-27)。従来の issue tracking が PM からエンジニアへの handoff model に合わせて作られたこと、今後は context と agents を中心に開発システムが変わる、という主張の出典です。

*8:DORA / Google Cloud, "State of AI-assisted Software Development 2025," DORA Research, 2025. URL: https://dora.dev/dora-report-2025/ (閲覧日: 2026-04-27)。AI は組織の既存の強みと弱みを増幅するものであり、AI 導入の効果はツール単体ではなく組織システムに依存する、という整理の出典です。

*9:DORA / Google Cloud, "State of AI-assisted Software Development 2025," DORA Research, 2025. URL: https://dora.dev/dora-report-2025/ (閲覧日: 2026-04-27)。AI は組織の既存の強みと弱みを増幅するものであり、AI 導入の効果はツール単体ではなく組織システムに依存する、という整理の出典です。

LLMアプリケーションを改善したければLangfuseを使ってみてほしい~トレース確認からプロンプトの評価までの流れ

1. はじめに

LLMアプリケーションを開発・運用していると、ぶつかりがちな壁があります。「なぜこの出力になったのか分からず改善が進まない」「プロンプトやモデルを変更したいけれど影響範囲が読めない」などです。

私たちテックドクターでは、こうした課題に対して、LLMアプリケーションのオブザーバビリティ・評価プラットフォームであるLangfuseを使うことで対処しています。

この記事では、弊社のヘルスケアアプリケーション開発の実例を交えながら、トレースによる挙動の可視化、プロンプトの一元管理、データセットの構築、そして簡易的なプロンプト評価(Prompt Experiments)の実行まで、Langfuse活用の一連の流れを解説していきます。

こんな人に読んでほしい
  • LLMアプリケーションの開発・運用に携わるエンジニア
  • Langfuseの導入を検討している方



2. 導入背景

弊社ではLLMを活用したアプリケーションを開発しています。チャットボットへの相談、バイタルデータの要約、メッセージ履歴のサマリー生成などの機能を提供しており、運用中のプロンプトは結構な数になります。
その開発を進める中で、次の2つの課題が目立つようになりました。

LLMの出力の原因を追いにくい

ユーザーから「回答がおかしい」と報告を受けても、そのときの入力や会話履歴を追跡するのが困難でした。
アプリケーションログを手がかりにLLMへの入力を再構成していたのですが、手作業となり時間がかかるうえに会話のコンテキストが欠落しやすいです。原因の特定がなかなか進みませんでした。

プロンプト変更の効果を簡単に確認する手段がない

プロンプトの改善にはエンジニアだけでなく、ドメイン知識を持つ非エンジニアメンバーも関わることがあります。そのため、非エンジニアでも簡単にGUIからプロンプトの検証ができる環境がほしいと考えました。
弊社ではこうした課題への対策として、Langfuseを導入しました。


3. Langfuseの概要

Langfuseとは

Langfuse は、LLMアプリケーション向けのオープンソースのLLMエンジニアリングプラットフォームです。

LLMへの入出力やトークン消費量をトレースとして記録・可視化できるほか、プロンプトのバージョン管理やデータセットを用いた評価まで、一つのプラットフォームで完結して行うことができます。SaaS版(US / EU / HIPAAリージョン)およびセルフホスティング環境で利用可能です。

Langfuseを導入すると、主に次のようなことができるようになります。

  • オブザーバビリティ……LLMへの入出力、処理の流れ、レイテンシ、トークン数・コストをWeb UIで確認できます。ユーザーIDやセッションIDでフィルタリングして、特定ユーザーの会話を追跡することも可能です。
  • プロンプトの管理……プロンプトをコードから分離し、Langfuse上で作成・編集できます。バージョン管理やラベル付け(production / staging)にも対応しており、コードのデプロイなしにプロンプトの切り替えやロールバックが行えます。
  • 評価……データセット(入力と期待出力のペア)を用意し、プロンプトやLLMアプリケーションの出力品質を評価できます。

参考: Langfuse Overview

なお、本記事で扱う主な機能は以下の4つです。

名称 機能
Tracing LLMの入出力やレイテンシ、トークン数・コストを階層的に記録・可視化します
Prompt Management プロンプトのバージョン管理とラベル付け(production / staging)を行います
Datasets 評価用の入力データ(と期待出力)をまとめて管理します
Prompt Experiments プロンプト × データセットの組み合わせで評価を実行し、結果を比較します

このほかにも、ユーザーフィードバックをスコアとして記録する機能、LLM-as-a-Judge(LLMを評価者として出力品質を自動判定する手法)による自動評価、Annotation Queue(評価対象をキューに溜めて人手でラベリングを進める機能)などがありますが、本記事ではとくに上記4つに焦点を当てます。

導入方法

Python SDKを使う場合

(SaaS版の場合)パッケージをインストールして環境変数を設定するだけで利用を開始できます。

pip install langfuse

# .env
LANGFUSE_SECRET_KEY="sk-lf-..."
LANGFUSE_PUBLIC_KEY="pk-lf-..."
LANGFUSE_BASE_URL="<https://cloud.langfuse.com>"  # EU region
# LANGFUSE_BASE_URL="<https://us.cloud.langfuse.com>"  # US region


Langfuseクライアントは get_client() で初期化します。環境変数を設定していれば、引数なしで認証情報が自動的に読み込まれます。

from langfuse import get_client

langfuse = get_client()


Langfuse Python SDKにはトレースの方法が複数用意されています。
以下はコンテキストマネージャを使用する例です。start_as_current_observation() を使うとブロック内の処理が自動的にトレースとして記録されます。 ほかにも、デコレータ を使用したり、手動で観測値を設定 したりすることもできます。

from langfuse import get_client

langfuse = get_client()

# コンテキストマネージャを使用してスパンを作成する
with langfuse.start_as_current_observation(
    as_type="span",
    name="process-request"
) as span:
    span.update(output="Processing complete")

    # LLM呼び出しにおいて、ネストされた生成を作成する. LLM呼び出しを記録する場合は as_type="generation" を指定
    with langfuse.start_as_current_observation(
        as_type="generation",
        name="llm-call",
        model="gpt-4o"
    ) as generation:
        # ここにLLMを呼び出すためのロジックを記述します
        generation.update(output="Generated response")

# すべてのスパンは、それぞれのコンテキストブロックを抜ける際に自動的にクローズされます。

参考: Python SDK - Getting Started / Instrumentation


フレームワークインテグレーションを使う場合

別のトレース方法として、フレームワークインテグレーションを使う方法も紹介します。

Langfuse SDKはOpenTelemetryベースで構築されているため、Pydantic AIやGoogle ADKなどのOTel対応フレームワークを使っている場合は、@observe() デコレータやコンテキストマネージャを使わなくてもトレースを自動記録できます。Langfuseはインテグレーションが充実しており、さまざまなLLMアプリケーションフレームワークと手軽に統合できます。

例えば、以下はPydantic AIを使用する例です。

from langfuse import get_client
from pydantic_ai import Agent

# Langfuseクライアントを初期化
langfuse = get_client()

# Pydantic AIのすべてのエージェントでインストルメンテーションを有効化
Agent.instrument_all()

# エージェントを作成して実行するだけでトレースが自動記録される
agent = Agent("openai:gpt-4o", instrument=True)
result = agent.run_sync("こんにちは")

参考: Pydantic AI Integration

以上がLangfuseの概要です。次のセクションからは、LLMアプリケーション改善の流れを追う形で、Langfuseの各機能について詳しくご紹介していきたいと思います。
トレースの確認、プロンプトの管理、プロンプト評価の実行の順で見ていきましょう。


4. トレースでLLMの挙動を確認する(Tracingについて)

なにか問題が起きたとき、トレース機能によってLLMへの入出力や処理の流れを追跡できます。

トレースの階層構造

Langfuseのトレースは、LLMアプリケーションの1回のリクエスト処理(ユーザー入力から応答生成まで)の実行を階層構造で記録します。 トレースの中にはobservationと呼ばれる個々の処理ステップがネストされており、それぞれのobservationには処理の種類を表すtypeが割り当てられます。 代表的なobservation typeは以下の3つです。

  • Span: 任意の処理区間(前処理、後処理など)
  • Generation: LLM呼び出し。フレームワークインテグレーション経由であれば入力プロンプト、出力テキスト、モデル名、トークン数、コストが記録される。
  • Tool: 天気予報のAPI呼び出しのような、ツール呼び出しを表す

このほかにも用途に応じた複数のobservation typeが用意されており、フレームワークインテグレーション経由では、自動的に適切なtypeが設定されます。(コンテキストマネージャ経由の場合は自分でtypeを指定することになります)

ここからは、記録されたトレースを管理画面上でどのように確認・活用するかを見ていきましょう。

一覧表示からトレースを確認する

管理画面のトレース一覧を開くと、記録されたトレースが表示されます。

画面キャプチャ

一覧画面では、トレース名・実行日時・レイテンシ、実際の入出力、使用トークン数とコスト、付与されたタグやメタデータ、ユーザーID・セッションIDなどを確認できます。

フィルター機能を使えば、ユーザーID・セッションID・タグ・日時範囲などの条件でトレースを絞り込めます。

また、入出力を含む各カラムに対してテキスト検索も可能なので、目当てのログ情報を容易に探し当てることができます。

一覧から任意のトレースを選択すると詳細画面が開き、内容がツリー形式で表示されます。 各ノードを選択すると、その処理ステップの入力・出力・実行時間・トークン数などが表示されます。

このように詳細画面で関連するトレース情報を一覧できることで、いわゆるAgentic Workflowと呼ばれるような、LLMを多段階的に呼び出すアプリケーションを構築している場合は、デバッグがとても捗ります。

セッション単位の会話追跡

弊社では活用していませんが、トレースにセッションIDを付与しておくと、管理画面から同一セッションに属する複数のトレースをまとめて閲覧できます。

会話の流れの中で「どの時点から出力がおかしくなったのか」を時系列で追えるので、こちらもデバッグに役立ちそうです。

ユーザーフィードバックの記録と活用

Langfuseでは、トレースに対して スコア(Score) を紐づけて記録できます。SDK経由でプログラムから記録したり、管理画面のUI上で人手で記録することも可能です。

弊社のアプリではユーザーがLLMの回答に対してアプリ内でGood/Badの評価を行える仕組みを実装しており、その結果をLangfuse上でトレースに紐づくスコアとして記録しています。

from langfuse import get_client

langfuse = get_client()

langfuse.create_score(
    trace_id="trace-id",
    name="user-feedback",
    value=1,       # Good: 1, Bad: 0
    data_type="NUMERIC",
    comment="ユーザーからのフィードバック",
)


記録されたスコアはトレース詳細画面に表示されます。トレース一覧画面ではスコアによるフィルタリングも可能で、たとえば「Bad評価が付いたトレースだけを抽出して、共通するパターンを分析する」といった使い方もできます。
参考: Tracing / Scores

5. プロンプトを管理する(Prompt Managementについて)

トレースで問題のあるプロンプトを特定できたら、改善を行います。そのとき役に立つのがプロンプト管理の機能です。

Prompt Managementの概要

Langfuseの Prompt Management 機能を使うと、プロンプトをコードから分離してLangfuse上でバージョン管理できます。 各バージョンには productionstaging といったラベルを付けられ、コードのデプロイなしにプロンプトの切り替えやロールバックが可能です。
プロンプトの形式にはtext(単一文字列)とchat(メッセージ配列)の2種類があります。チャットボットのようなアプリケーションでは chat 形式を使い、各メッセージの rolecontent を定義する必要があります。

バージョン管理とラベル

プロンプトを保存するたびにバージョン番号が自動でインクリメントされます。

各バージョンにはラベルを付与でき、アプリケーションからは「production ラベルの付いた最新バージョン」を取得する、という運用が可能です。

たとえば、新しいプロンプトをまず staging ラベルで作成し、評価(後述するPrompt Experiments)を経て問題がなければ production ラベルに昇格させる、というワークフローが実現できます。

variablesとmessage placeholder

プロンプト内に動的な値を埋め込むことで、プロンプトテンプレートとして使用できます。埋め込む値としてはvariables、prompt references、message placeholders がサポートされています。本記事では、主に variables と message placeholder を扱います。

  • variables: {{変数名}} の形式でプロンプト本文に記述し、実行時に文字列へ置換します。
あなたはヘルスケアアシスタントです。
ユーザーのタイムゾーンは {{TIMEZONE}} です。
現在時刻は {{CURRENT_TIME}} です。
{{LANGUAGE}} で回答してください。

  • message placeholder: chat形式のプロンプトで使う仕組みです。会話履歴のような可変長のメッセージ配列を動的に展開するためのもので、 placeholder タイプのメッセージを追加することで設定できます。

ここで定義したmessage placeholderの使用方法は、次のDatasetのセクションで説明します。

[
  { "role": "system", "content": "あなたはヘルスケアアシスタントです。" },
  { "type": "placeholder", "name": "MESSAGE_HISTORY" },
  { "role": "user", "content": "{{USER_PROMPT}}" }
]


UIでのプロンプト作成・編集

管理画面のプロンプト一覧から新規作成ボタンをクリックすると、プロンプトの作成画面が開きます。

ここでプロンプトの識別名、形式(text / chat)、プロンプト本文を入力して保存することで、プロンプトの追加が行えます。必要に応じて、モデル名や temperature といったパラメータも設定できます。

既存のプロンプトを編集する場合は、プロンプト一覧から対象を選択し、本文を修正して保存します。保存するたびに自動的に新しいバージョンとして追加され、過去のバージョンもすべて保持されるため、いつでも差分の確認やロールバックが可能です。

SDKでのプロンプト作成

プロンプトはUIだけでなく、Python SDKからもプログラムで作成・更新できます。

from langfuse import get_client

langfuse = get_client()

# text形式のプロンプトを作成
langfuse.create_prompt(
    name="health-assistant",
    type="text",
    prompt="あなたはヘルスケアアシスタントです。{{LANGUAGE}} で回答してください。",
    commit_message="create initial version",
    tags=["base"],
)

# chat形式のプロンプトを作成
langfuse.create_prompt(
    name="health-assistant-chat",
    type="chat",
    prompt=[
        {"role": "system", "content": "あなたはヘルスケアアシスタントです。"},
        {"type": "placeholder", "name": "MESSAGE_HISTORY"},
        {"role": "user", "content": "{{USER_PROMPT}}"},
    ],
    commit_message="create initial version",
    tags=["eval"],
)

弊社では、アプリケーションが実際に使用するプロンプトはGoogleのCloud Spannerで管理しており、Langfuse上には評価中のプロンプトを保存しています。両者のプロンプトの不整合を防ぐために、Spanner側のプロンプトをマスタデータとして、これをLangfuse側に反映するスクリプトを用意しています。
参考: Prompt Management Overview / Variables / Message Placeholders

6. データセットを用意する(Datasetについて)

ここまで、Langfuseのトレースおよびプロンプトの管理機能について説明してきました。これから説明するDataset、Prompt Experimentsはプロンプトの評価に関する機能です。

データセットの概要

データセット(Dataset)は、プロンプト評価に使用する入力データの集合です。
Langfuseのデータセットは、おもに以下の要素で構成されています。

  • Dataset: DatasetItemの集合。
  • DatasetItem: 個別の入出力ペア。input(入力値)、expected_output(期待する出力、任意)、metadata(カスタム属性、任意)を持つ
  • DatasetRun: データセットに対して評価を実行した結果。

主なデータセットの構築方法としては、トレースから取り込む方法、1から手動で作成する方法、CSVファイルからインポートする方法などがあります。

トレースからデータを追加する

本番やステージング環境で記録されたトレースをもとに、評価用データを作成できます。

具体的には以下の手順でDatasetを作成できます。

  1. トレース詳細画面で、データセットに追加したいobservationを選択する
  2. データセットへの追加ボタンをクリックする
  3. 次項「データセットアイテムの構造」に記載の形式に合わせて整形し、保存する

実際のユーザー入力をそのまま評価用データとして再利用できるため、現実的なテストケースを手早く蓄積できるのが利点です。

ただし、トレースに記録されたデータはプロンプトのvariablesやplaceholderの構造とは形式が異なることが多いため、データセットに追加する段階でプロンプトの変数構造に合わせた整形が必要になります。

手動でデータを作成する

プロダクトの要件を満たす典型的な入力パターンや、特定のエッジケースをあらかじめ用意しておく方法です。まだトレースが十分に蓄積されていない初期段階のデータセット構築に向いています。

なお、CSVファイルからデータセットを一括インポートすることも可能です。大量のテストケースをスプレッドシート等で管理している場合に便利です。

データセットアイテムの構造

データセットアイテムの inputexpected_output には値を格納できます。UIのPrompt Experimentsで利用する場合は、input はJSON objectである必要があり、実行時にそのJSONキーを見て、プロンプトテンプレートの同名のvariablesやmessage placeholderに値が自動で埋め込まれます。そのため、input のキー名はプロンプト側の変数名と一致させておく必要があります。

たとえば、セクション5で紹介したchat形式のプロンプトは次のような構造でした。

[
  { "role": "system", "content": "あなたはヘルスケアアシスタントです。" },
  { "type": "placeholder", "name": "MESSAGE_HISTORY" },
  { "role": "user", "content": "{{USER_PROMPT}}" }
]


このプロンプトに対してPrompt Experimentsを実行するには、データセットアイテムの inputMESSAGE_HISTORYUSER_PROMPT というキーが含まれている必要があります。弊社ではこれに加え、プロンプト内で使用している TIMEZONECURRENT_TIMELANGUAGE も共通で含めるようにしています。

フィールド 説明
USER_PROMPT ユーザーの入力テキスト
MESSAGE_HISTORY 会話履歴のメッセージ配列(会話履歴を使うプロンプトの場合)
TIMEZONE ユーザーのタイムゾーン(例: Asia/Tokyo)
CURRENT_TIME エージェント実行時の日時
LANGUAGE ユーザーの言語(例: 日本語)

実際の input は以下のようになります。

{
  "MESSAGE_HISTORY": [
    { "role": "user", "content": "寝つきが悪くて困っています" },
    { "role": "assistant", "content": "最近の生活習慣はいかがですか?" }
  ],
  "TIMEZONE": "Asia/Tokyo",
  "USER_PROMPT": "何か改善できることはありますか?",
  "CURRENT_TIME": "2026-03",
  "LANGUAGE": "日本語"
}

参考: Datasets


7. プロンプトを評価する(Prompt Experimentsについて)

UIから評価を実行する

プロンプトとデータセットの準備ができたら、Prompt Experimentsで評価を実行します。

  1. 管理画面のデータセット一覧から対象のデータセットを開く
  2. Experiment実行ボタンをクリックし、Prompt Experimentを選択する
  3. 名称、評価対象プロンプト、LLMのAPI接続設定、対象データセットなどを入力する。(LLM-as-a-Judgeによる自動評価を行いたい場合はEvaluatorもあわせて設定する)
  4. 実行を開始する

結果の確認と比較

実行が完了したら、Dataset Runs画面で結果を確認します。以下はテスト的に評価を実行した際の結果画面です。

画面キャプチャ

結果画面では、前述した inputexpected_output に加え、LLMからのレスポンス、そのコストとレイテンシなどを横並びで確認できます。

期待どおりの結果が得られなければ、Prompt Managementでプロンプトを修正し、再度Experimentを実行します。このサイクルを繰り返すことで、プロンプトの品質を段階的に高めていくことができます。
参考: Prompt Experiments (UI) / Datasets


8. まとめ

本記事では、Langfuseを使ったLLMアプリケーション改善の流れを、トレースの確認からプロンプト評価の実行まで一通り紹介しました。

トレースによって、問題が起きたときにLLMへの入出力や処理の流れを管理画面上で追跡できるようになります。

そのうえで、Prompt Managementでプロンプトをバージョン管理し、データセットを用意してPrompt Experimentsで修正前後の出力を並べて比較することで、プロンプトの改善サイクルを効率的に回すことができます。

今回は目視での確認・比較を中心に紹介しましたが、Langfuseにはこのほかにも、LLMを評価者として出力品質を自動スコアリングするLLM-as-a-Judgeや、Python SDKによるLLMアプリケーションの評価といった機能も用意されています。更に評価を自動化したい場合はこれらの機能の使用を検討すると良いかもしれません。




書いた人:大瀧

「自由な解析」から「確かなエビデンス」へ。ウェアラブルデータ解析におけるSAP(統計解析計画書)の重要性

はじめまして。テックドクターでデータ解析を担当している藤野です。

私はテックドクターへ参画する前は、約10年間にわたり医薬品開発における治験の統計解析に従事してきました。現在はその経験を活かし、ウェアラブルデバイスのデータを用いて、医療現場で活用されるデジタルバイオマーカー(※1)の開発に取り組んでいます。

ウェアラブルデータは、従来の臨床試験のデータに比べてデータ量が膨大で、項目の種類も多岐にわたります。そのため、解析の自由度が非常に高く、データサイエンティストとして非常に面白い領域です。しかしその反面、解析者の意図によって「都合の良い結果」を導き出してしまうバイアス(P-hacking等)が入り込みやすいという危険性もあります。

このように都合の良い解析とならないために、治験における解析の「規律」であるSAP(統計解析計画書)の考え方をウェアラブルデータ解析においても実践しています。
本記事では、ウェアラブルデータ解析においてなぜSAPが必要なのか、そして具体的にどのような点に留意して作成すべきかをご紹介します。

※1 デジタルバイオマーカー……デジタルデバイスで測定した『日常データ』をもとにした、病気の早期発見や治療につながる客観的指標(過去記事参照

SAP(統計解析計画書)とは

SAP(Statistical Analysis Plan:統計解析計画書)とは、解析を始める前に「どのようなデータを使い、どのような解析を行うか」を細かく記載した文書のことです。
医薬品開発で必須とされる国際的なガイドラインである「ICH-E9(臨床試験のための統計的原則)」(※2)に基づいて、解析の客観性と再現性を担保することを目的としています。

※2 PMDA(独立行政法人 医薬品医療機器総合機構)「ICH-E9(臨床試験のための統計的原則)」, URL: https://www.pmda.go.jp/int-activities/int-harmony/ich/0031.html

なぜ機械学習全盛の今、SAPが必要なのか

最新のアルゴリズム開発とは対極にある「古い慣習」のように思えるかもしれません。しかし、実はアルゴリズム開発(機械学習)の前段階として、統計的な検証を行うことには大きなメリットがあります。

①客観性の担保
解析前に解析方法を決定することで、有意差が出るまで条件を変えて解析し直すことを防ぎます。

②エビデンスの作成
解析プロセスを文書化しておくことで、社内外のステークホルダーに信頼されるエビデンスを作成します。

これらはSAPの一般的なメリットですが、くわえてウェアラブルデータ解析特有の課題への対策としてもSAPは有効です。次にご説明します。

ウェアラブルデータ解析における「信頼性」の課題

ウェアラブルデータ解析においては、その自由度の高さから、特有のリスクが存在します。

多重性の問題
心拍、睡眠、歩数など、ウェアラブルデバイスからは大量の変数が得られます。
これらを総当たりで検定にかければ、偶然有意差が見つかるリスクが高まります。

欠測バイアス(※3)の問題
日常生活では、未装着や充電切れなどによるデータの欠測が発生します。
欠測の扱いで結果が大きく変わってしまうことがあります。

これらの日常のデータを信頼できるエビデンスとするために、SAPによる事前の規定がとても有効です。

※3 バイアス……解析者の思い込みやデータの偏りによって、真実とは異なる結果が出てしまうことを指します。

イメージ図

SAPの実践例と重要なポイント

具体的に、どのような点をSAPで定義すべきでしょうか。事例をもとに3点紹介します。

有効なデータと解析対象の定義

ウェアラブルデータの場合、「データが存在する=解析に使える」とは限りません。解析前に、評価の基盤となるデータの質を定義しておく必要があります。

歩数データの例:
次のような基準をあらかじめ規定しておきます。

  • 心拍データが記録されている時間を装着時間とし、「1日の装着時間が70%(16.8時間)以上」の日を有効装着日と規定する
  • さらに、評価期間7日間のうち、少なくとも4日以上の有効装着日が存在する被験者のみを解析対象とする
  • 最後に、各有効装着日の1日の歩数を算出し、評価期間中の平均値を解析に用いる

データ有効性の判定チャート

生データから加工変数への変換ロジック

高頻度な原データを解析可能な形に集約する計算式を、SAPで明文化します。

心拍変動の例:
以下のように基準を設定します。

  • 15分間隔の区間集計で算出するロジック
  • 「30bpm以下の心拍数」など生理学的にあり得ない値を異常値として除外する閾値
「探索」と「検証」の切り分け

自由にデータを深掘りして分析することで新たな知見を得るための探索的な解析項目と、事前に決められた手順で仮説を検証するための解析項目を明確に分けます。

SAP導入の効果とメリット

ここまで主にSAPがデータ解析作業にもたらす利点を紹介してきましたが、それ以外にもSAPの導入によって、プロジェクト全体に様々なメリットがあります。

解析結果の頑健性(ロバストネス)の向上
解析を事前に規定することで、「データを見てからルール(データの採用基準や統計手法)を決める」という後出しジャンケンを防ぎます。これにより、誰が解析しても結論の変わらない安定した解析結果を得ることができます。

ロバストネス向上のイメージ図

チーム内外の円滑な議論
解析方針が詳細にドキュメント化されているため、社内のレビューはもちろん、外部のステークホルダー(スポンサー・アカデミア・CRO等)や規制当局(PMDA等)との議論がスムーズになります。

将来的な治験への布石
探索的な臨床試験の段階からSAPを導入することで、将来的に治験に進む際に、スムーズに移行できる下地となります。

また、現場で実務を担うステークホルダーにも具体的なメリットがあります。

ステークホルダー 具体的なメリット
データサイエンティスト 個別のデータ採否に迷う必要がなく、解析の再現性と効率が向上します。
統計解析担当者 欠測や中間事象(服薬不遵守や併用薬の使用)に対して、治験の標準的な考え方を適用でき、ウェアラブルデータ以外の解析と整合性が取れます。
薬事・マーケティング担当者 事前に合意・規定されたプロセスに基づくエビデンスとなるため、社内外への説明に客観的な裏付けができます。

実践を通じた気づき

10年の治験解析経験を経て、今ウェアラブルデータ解析の舞台で改めて感じるのは、SAPは決して「自由な解析を制限するもの」ではないということです。むしろ、解析結果に胸を張って「これは信頼できる結果です」と言うための武器になります。

まとめ

ウェアラブルデバイスという新しい領域だからこそ、先人が臨床試験で築き上げてきた「統計の作法」が非常に強力な武器になります。

テックドクターでは、これからも最新のテクノロジーと統計学的手法を組み合わせ、デジタルバイオマーカーの社会実装に向けた信頼性の高い解析を追求していきます。


似顔絵
書いた人:藤野

Agentic Workflow導入記:巨大プロンプトで起こる諸問題を「分割」と「構造化」で解決した話

こんにちは、テックドクターの佐藤です。

今回は、アプリ開発において Agentic Workflow(自律的なエージェントワークフロー)を組み込んだ際に直面した問題と、解決方法について書きます。

その問題とは「巨大プロンプトの限界」。それを解決に導いたのは、マルチエージェントアーキテクチャ化、そしてPydanticAIを用いた型安全な連携でした。

ヘルスケアアプリ「ポポラス」にAgentic Workflowを実装

昨年11月、「ポポラス」というスマートフォンアプリをリリースしました。

populus-app.com

ポポラスは、ユーザーのHealthKitやFitbitのデータに基づき、健康に関するパーソナライズされた受け答えを提供するヘルスケアエージェントアプリです。ユーザーからの「昨日の活動量はどうだった?」といった質問に対し、実際の歩数や睡眠データを参照しながら、自然で適切なアドバイスを行うことを目的としています。

ポポラスはユーザーの質問に対する回答生成に、単一のLLMではなく、複数の専門エージェントが協調して動くAgentic Workflowを採用しています。

AgenticWorkflowとは、AIやプログラムが「自分で考えて動く」ようにする仕組み、あるいはそのための考え方のことです。AIがある目的を達成するために、都度人間の指示を受けるのではなく、自分で手順を考えたり、必要な作業を順番に進めたりできるようにします。

詳しくはこちらのエントリで解説しているので読んでみてください。
techblog.technology-doctor.com

最初のアプローチ:万能な「神エージェント」

開発当初、私たちは全てのタスク(データ取得、分析、応答生成)を一つの大きな「メインAgent」に担わせる設計を採用しました。

当初の実装構成

アーキテクチャ……ユーザー入力と履歴を、一つの巨大なプロンプトを持つエージェントに直接渡す。

ツール群……HealthKitDataFetcherFitbitDataProcessorHealthAnalyzerGetNowなど、あらゆるツールを一度に渡していました。

プロンプト……ひとつのコンテキスト内に全ての機能とガードレール(AIの挙動が意図した範囲から外れないような制御)を詰め込んでいました。

構成図
構成イメージ

最初のアプローチで問題だった点

その結果、この「ひとつの巨大なAgent + 多数のツール」という構成では、期待通りの回答を得られませんでした。具体的には以下の問題が発生しました。

  • 推論の不安定化と無限ループ
    普通の質問をしただけでも、エージェントがツール利用の判断を誤り、データ取得処理のループに入ってしまう現象("An unexpected error occurred")が頻発しました。
  • トークン消費とコストの増大
    本来不要なツールまで呼び出してしまったり、プロンプト自体が巨大であるため入力トークン数が肥大化したりして、APIコストを圧迫しました。
  • コンテキスト理解の失敗(エッジケース)
    「昨日の歩数は?」と質問した後に「じゃあ一昨日は?」と追撃した場合などに、文脈を正しく引き継げず、適切な日付のデータを取得できないケースが多発しました。
  • デバッグの困難さ
    処理がブラックボックス化していたため、意図しない挙動(ハルシネーションなど)が起きた際、原因がプロンプトにあるのかツールの選択ミスにあるのかを特定するのが極めて困難でした。

結論として、「巨大なプロンプトと多数のツールを機械に丸投げして最適な答えを出させる」というやり方ではうまくいかないことが判明しました。

改善したアプローチ:役割分担と構造化

これらの問題を解決するために、「タスクの細分化(Task Decomposition)」と「Pydanticによる入出力の構造化」を行う設計へ移行しました。

協調型Agentic Workflowへの変更

全体の構成図を見てください。単一のAgentではなく、役割を持った専門のAgent群が連携するワークフローに変更しました。

構成図
改善された構成

異なる役割を持った4つのAgentを定義しました。

Agent 役割
RouterAgent ユーザーの意図を汲み取り、適切なエージェントへ振り分ける司令塔。
FetchAgent / AnalysisAgent データ取得と分析に特化した実働部隊。
EmpathyAgent ユーザーに寄り添う会話担当。
SorryAgent 「株価を教えて」や「医療診断して」など、アプリのスコープ外または危険な質問に対して、適切に断りを入れる担当。

これらが連携してユーザーへの応答を生成します。

PydanticAIにより入出力を縛る

Agent間の連携で最も重要なのが、次のAgentが作業しやすいように情報を整形して渡すことでした。
PydanticAI(あるいはPydanticそのもの)を活用し、モデルのフィールドにdescriptionを詳しく書くことで、LLMに対して「この項目には何を入れるべきか」を強く示唆できます。

一例として、司令塔でありユーザーの質問を直接受け取る立場であるRouterAgentの出力定義はこのようにしました。

RoterAgentの出力定義

from pydantic import BaseModel, Field
from typing import Literal

class RouterDecision(BaseModel):
    """
    ユーザーの意図を分析し、次のアクションを決定するためのモデル
    """
    target_agent: Literal["FetchAgent", "EmpathyAgent", "SorryAgent"] = Field(
        description="ユーザーの要求を満たすのに最適なエージェントを選択する。"
        "健康データが必要ならFetchAgent、日常会話ならEmpathyAgent、"
        "スコープ外(株価や医療診断など)ならSorryAgentを選択。"
    )
    
    context_query: str = Field(
        description="次のエージェントに渡すための、具体的かつ自己完結した指示文。"
        "例:ユーザーが『じゃあ一昨日は?』と言った場合、ここには"
        "『2025年10月13日の歩数データを取得してください』のように"
        "日付と目的を補完して正規化したテキストを出力すること。"
    )
    
    reasoning: str = Field(
        description="なぜそのエージェントを選択したかの理由。デバッグ用。"
    )


実際のLLM出力例
※文脈:昨日の歩数の話の続き

# User: 「じゃあ一昨日は?」
# Result:
# {
#   "target_agent": "FetchAgent",
#   "context_query": "2025-10-13の歩数データを取得",
#   "reasoning": "直前の会話履歴から歩数の話題と判断し、日付を一昨日に特定したため"
# }

このように、Field(description=...) に「日付を補完して正規化せよ」といった具体的な指示を埋め込むことで、プロンプトの一部として機能させます。

これにより、後続のFetchAgentには曖昧な「一昨日」という言葉ではなく「2025-10-13」といった確定情報が渡されることになります。FetchAgentはこれを使ってAPIを叩くだけで済みます。

マルチエージェント化によるメリット

マルチエージェント化したことにより、多くのメリットが得られました。

Agentに特化したプロンプトが書けるようになった

マルチエージェント構成では、「そのエージェントに関係ない禁止事項」を書く必要がなくなります。

例えば、

  • RouterAgent には「ツールを直接呼び出してはいけない」「最終回答を生成してはいけない」という制約だけを書けばよい
  • FetchAgent には「ユーザーに話しかけてはいけない」「指定されたツールだけを呼ぶ」「推論をしない(※API経由で正確なデータを取得させるため)」という制約だけを書けばよい
  • SummarizeAgent には「取得済みデータを要約することだけに集中する」「新しいデータ取得はしない」という制約だけを書けばよい

というように、「その役割の逸脱を防ぐためのガードレール」だけを、最小限かつ強い言葉で書けるようになります。

結果として、

  • プロンプト1つあたりの制約の数が激減
  • 各制約の意味が曖昧にならない
  • モデルが「何をしてはいけないか」をほぼ確実に守る

という状態を作ることができました。

出力が安定した

もう一つの非常に大きなメリットは、出力フォーマットが安定したことです。

巨大プロンプトで全タスクをやらせていた頃は、

  • たまにJSONが壊れる
  • たまに説明文が混ざる
  • たまにキーが変わる
  • たまに謎の自然文が出る

といった「機械処理するには致命的だが、人間が見ると一見それっぽい」出力が頻発していました。

マルチエージェント化すると、

  • RouterAgent……{ intent: "...", normalized_text: "..." } しか返さない
  • FetchAgent……{ tool_results: [...] } しか返さない
  • SummarizeAgent……{ summary: "...", insight: "..." } しか返さない

というように、各エージェントに「一種類のJSON構造しか返させない」設計にできます。

これにより、

  • パースエラーがほぼゼロになる
  • 後段の処理が例外処理だらけにならない
  • ログの可読性とデバッグ効率が爆上がりする

という、「正しい状態」に一気に寄せられました。

デバッグ可能性が劇的に向上した

マルチエージェント構成にして一番「これは正解だった」と感じたのは、デバッグのしやすさです。

巨大プロンプト時代は、なにかを失敗した際に「どこで?」「なぜ?」「どの指示を誤解した?」「モデルが悪い?プロンプトが悪い?設計が悪い?」が完全にブラックボックスでした。

マルチエージェント化すると,

  • RouterAgent のログを見る → 意図分類が間違っていることがわかる
  • FetchAgent のログを見る → ツール引数の組み立てが間違っていることがわかる
  • SummarizeAgent のログを見る → 要約ロジックが微妙であることがわかる

というように、壊れている責務の場所が一瞬で特定できるようになります。

最初のアプローチとの比較

最初の「巨大プロンプト方式」との違いを一覧表にまとめました。

項目 初期設計(失敗) 改善後の設計(成功)
Agent構成 1つの巨大なAgent 複数Agentが連携
(Router, Fetch, Analysis, Empathy, Sorry)
プロンプト 全機能を網羅した巨大プロンプト タスクごとに特化した
小規模プロンプト
データ連携 自然言語(曖昧) Pydanticモデル
(型定義+Descriptionによる指示)
エラー対応 プロンプト内で禁止事項を羅列 SorryAgentへ遷移させて分離
安定性 低い(ループ、ハルシネーション多発) 高い(責任分界点が明確)
デバッグ容易性 低い(原因が分からない) 高い(原因が明確化した)
ガードレール 巨大なプロンプトに内包 最後の出力プロンプトの
Agentのみに適応

まとめ

ヘルスケアアプリのような複雑な要件をAgentic Workflowで実現するためには、「何でもできる神エージェント」を作ろうとせず、「専門家のチーム」を作ることが成功の鍵でした。

特に、PydanticモデルのDescriptionを活用して文脈(Context)を構造化データに変換するテクニックは、LLMの推論精度を劇的に向上させます。また、SorryAgentのような「断り担当」を設けることで、他のエージェントが余計なノイズに惑わされず、本来の性能を発揮できるようになりました。

今後は、この構造を活かしつつ、RAG Agent なども追加して「ポポラスって名前の由来は?」などより幅広い質問に答えられるようにしていく予定です。

また、今後のエントリではLLMのデバッグを劇的に楽にしたLangFuseの導入などにも触れたいと思います。


似顔絵
書いた人:佐藤


CSRFの生の挙動を知りたい~FastAPIとZAPを用いて検証してみた

初めまして、テックドクターでエンジニアをしている金子です。

CSRF(クロスサイト・リクエスト・フォージェリ)は非常に古典的な攻撃手法ですが、現代のWeb開発においてもその発生原理を正確に理解しておくことは重要だと思っています。

今回の記事では、CSRFについて、実際に手を動かして検証してみます。

この記事でやること

CSRFの基本的な仕組みを理解するために、わざと脆弱なサーバーを作成します。加えて「罠サイト」も用意して攻撃を再現し、何が起きているのかを脆弱性診断ツールのOWASP ZAPで観察します。

また、防御策としてCSRFトークンの有効性についても検証していきます。

※CSRF攻撃において、被害者に踏ませるためのページを本記事では「罠サイト」と呼びます。罠サイトは攻撃者が用意するもので、被害者がページを開いただけで標的サイトへリクエストが飛ぶ仕掛けが仕込まれています。

こんな人におすすめ

  • CSRFの名前は知っているが、実際どうやって攻撃が成立するのかピンときていない
  • フレームワークが守ってくれているため、生の脆弱な挙動を見たことがない
  • 攻撃の原理を自分の言葉で説明できるようになりたい

検証環境

  • ターゲットサーバー:FastAPI
  • 罠サイト(攻撃者):HTML + JavaScript(JSフレームワークは使いません)
  • ツール:OWASP ZAP、FoxyProxy

※今回は簡易的な検証のため、ターゲットサーバー、罠サイト共にローカル環境に設置します。

第1章:CSRFの仕組み

CSRFとは

CSRF(Cross-Site Request Forgery) は、日本語では「リクエスト強要」とも呼ばれます。被害者のブラウザを踏み台にして、本人の意図しないリクエストをWebアプリケーションに送信させる攻撃手法です。

攻撃の本質は「認証済みセッションの悪用」にあります。Webアプリケーションはリクエストに含まれるCookieを見て「この人は誰か」を判断しますが、そのリクエストが「本人の意思で送られたものか」までは検証していないことが多いです。CSRFではこの点が悪用されます。

攻撃者は罠サイトを用意し、被害者がそのページを開いた瞬間、被害者のブラウザから標的サイトへ不正なリクエストを自動送信させます。このとき被害者がたまたま標的サイトにログイン中であれば、ブラウザにより自動的にCookieが不正リクエストにも付与されます。そのため、サーバ側から見れば正規ユーザーからの正当なリクエストと区別がつかず、不正リクエストが処理されてしまうというわけです。

CSRFのしくみ(引用:IPA(独立行政法人 情報処理推進機構)より)

攻撃が成立する条件

CSRF攻撃が成立するには、以下の3つの条件が同時に満たされる必要があります。

  • 被害者が標的サイトにログイン済みである……ブラウザに標的サイトのセッションCookieが保存されており、有効な状態であること。ログアウト済み、またはセッション切れの場合は攻撃が成立しません。
  • 標的サイトがリクエストの正当性を検証していない……サーバ側で、CSRFトークンの検証、Refererヘッダのチェックなどを行っていない状態。つまり、「誰から送られたリクエストか」は見ているものの、「本人が意図して送ったリクエストか」までは確認しない実装になっている必要があります。
  • 被害者が攻撃者の用意した罠ページにアクセスする……メール内のリンク、SNSの投稿、不正広告など経路は様々です。被害者がそのページを開いた瞬間、隠されたフォームやimgタグによって標的サイトへリクエストが飛びます。多くの場合、被害者は攻撃が行われたことに気づきません。

※CSRFの仕組みについて、より詳細に知りたい方は次のサイトを参考にしてみてください。
安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ) | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構

具体的なインシデント例

PC遠隔操作事件(2012年)……Web掲示板のCSRF脆弱性を悪用し、無関係な一般市民のPCから犯罪予告を投稿させた事件です。被害者は誤認逮捕され、そのうち一人の大学生は自主退学に追い込まれました。サービス側のアクセスログには被害者のIPアドレスしか残らないため、当初は被害者が犯人だと疑われるなど、CSRFの「踏み台」としての恐ろしさを社会問題として浮き彫りにした事例です。

第2章:脆弱な環境を作る

では実際に検証を進めていきましょう。
まずは攻撃ターゲットとして、脆弱性を持つサーバーを構築します。

FastAPIによる簡単なサーバーの実装

このサーバーは下記のような機能を持っています。

  • ログイン機能(ログイン時、ブラウザにCookieをセット)
  • ユーザー情報としてID、パスワードのほか残高情報を持つ
  • パスワード変更APIが存在(CSRFトークンなし)

実装コードはこのようにしました。

from fastapi import FastAPI, Response, Cookie, HTTPException
from pydantic import BaseModel

app = FastAPI()

# 簡易ユーザーDB
users = {"alice": {"password": "pass123", "balance": 10000}}
sessions = {}

@app.post("/login")
def login(username: str, password: str, response: Response):
    if users.get(username, {}).get("password") == password:
        session_id = f"session_{username}"
        sessions[session_id] = username
        response.set_cookie("session_id", session_id, samesite="none", secure=False)
        return {"message": "ログイン成功"}
    raise HTTPException(401, "認証失敗")

class TransferRequest(BaseModel):
    to_user: str
    amount: int

@app.post("/transfer")  # ← CSRFトークンなし!
def transfer(req: TransferRequest, session_id: str = Cookie(None)):
    username = sessions.get(session_id)
    if not username:
        raise HTTPException(401, "未ログイン")
    users[username]["balance"] -= req.amount
    return {"message": f"{req.amount}円を送金しました"}

標的サイトの作成

次に標的となるサイトのページを作成しました。以下のような機能を持っています。

  • credentials: 'include' でCookieがやり取りする

実装コードの一部がこちらです。(重要な箇所のみ。HTML、CSSの全部、JavaScriptの一部は割愛します)

// ログイン時(ポイント:credentials: 'include')
  const res = await fetch(`${API_BASE}/token`, {
      method: 'POST',
      body: formData,
      credentials: 'include'  // ← Cookieを受け取る
  });

// 送金時(ポイント:Cookieが自動送信される)
  const res = await fetch(`${API_BASE}/transfers`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
      credentials: 'include'  // ← Cookieが自動で付く
  });

実装した画面

上記により、このような画面ができました。

  • ログイン画面

画面キャプチャ

  • 新規登録画面

画面キャプチャ

  • ダッシュボード

画面キャプチャ

第3章:罠サイトを作る

次に罠サイトを作成します。これは実際のCSRF攻撃においては攻撃者が作成するものです。下記のような機能を持っています。

  • 隠しフォームを持つ
  • フォーム自動送信のJavaScriptが存在(今回はボタンクリック時)

実装コード(CSSは割愛)

<form id="csrfForm" action="https://localhost:8443/api/transfers" method="POST">
      <input type="hidden" name="receiver_user_id" value="attacker">
      <input type="hidden" name="amount" value="5000">
  </form>
  
  <script>
  // 被害者がクリックすると...
  document.getElementById('claimBtn').addEventListener('click', async () => {
      await fetch('https://localhost:8443/api/transfers', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
              receiver_user_id: 'attacker',
              amount: 5000
          }),
          credentials: 'include'  // ← 被害者のCookieが送られる!
      });
  });
  </script>

実装した画面

画面キャプチャ

ここまでで、検証に必要な環境の作成が完了しました。

第4章:OWASP ZAPで検証

いよいよ、ツールを使ってCSRFの挙動を検証していきます。使用するツールは2つです。

OWASP ZAP

OWASP(Open Web Application Security Project)が開発するオープンソースのセキュリティテストツールです。ローカルプロキシとして動作し、ブラウザとWebサーバー間を流れるHTTP通信をすべて傍受・記録できます。

主な機能は以下の通りです。

  • リクエスト/レスポンスの可視化……ブラウザが裏で送っているリクエストの中身(ヘッダ、Cookie、POSTパラメータ等)を確認できる
  • リクエストの改ざん・再送信……傍受したリクエストを編集して再送信することで、パラメータ操作の影響を検証できる
  • 自動スキャン……既知の脆弱性パターンを自動検出する機能もあります。(本章では使用しません)
FoxyProxy

プロキシ設定の切り替えを簡単にするブラウザ拡張機能です。ChromeFirefoxに対応しています。
通常、ZAPのようなローカルプロキシを使うにはブラウザのネットワーク設定を手動で変更する必要がありますが、FoxyProxyを使えばワンクリックで「ZAP経由」と「直接接続」を切り替えられます。検証作業中に頻繁にプロキシのON/OFFを行う場面で便利です。

攻撃の再現

下準備として、被害者のアカウントと攻撃者のアカウントをそれぞれ作成しました。単純に作成するだけなので、画像の貼付は行いません。作成時の条件とユーザIDのみ記載します。

  • 被害者のユーザーID:test_target1
  • 攻撃者のユーザーID:attacker1
  • 初期値として設定した残高:いずれのユーザーも10,000円

以下、攻撃成立までのフローです。

1. 被害者ユーザーとして正規サイトにログインします。
画面キャプチャ
画面キャプチャ
ここでZAPを確認すると、バックエンドからバックエンドからアクセストークンが発行され、Cookieに保存されたことが分かります。
画面キャプチャ


2. 別タブで罠サイトを開きます。
画面キャプチャ
画面キャプチャ


3. 「景品を受け取る」ボタンをクリックします。

その結果、Javascriptにより攻撃者に送金をするリクエストがバックエンドに送信されます。
このとき、ZAPでCookieヘッダーを見てみると、被害者がログインしたときと同じAccessTokenが使われてしまっていることがわかります。

画面キャプチャ

リクエストヘッダーとJSONペイロードも確認してみましょう。

receiver_user_id(送金を受けるアカウント)には攻撃者のアカウントが、amount(送金額)には5000円が設定されています。(このとき、sender_id(送金者)のような送金者を特定するパラメータがありませんが、バックエンドではCookieのアクセストークンによって送金者を特定できるので、仕組みとしてはsender_idを指定しなくても送金処理を実行できます。)

送金完了後に被害者のダッシュボードで画面を更新すると、残高が5000に減っていることが分かります。

画面キャプチャ

また、攻撃者のアカウントにログインすると残高が15,000に増えていることが分かります。

画面キャプチャ

以上で、CSRF攻撃が成立するまでの挙動が確認できました。

第5章:CSRFへの防御策を検証する

攻撃について理解したところで、次は防御策についても検証してみたいと思います。
CSRFへの防御策のひとつに、CSRFトークンの実装が挙げられます。

CSRFトークンの仕組み

  1. 発行: ユーザーがフォームのある画面(送金画面など)を開いたとき、サーバーは乱数で作った、「予測不可能なトークン」を生成し、HTMLの隠しフィールド(input type="hidden")に埋め込んでユーザーに渡します
  2. 送信:ユーザーが送信ボタンを押すと、入力データと一緒にこの「トークン」もサーバーへ送られます
  3. 検証: サーバーは、送られてきたトークンが、ステップ1で自分が発行したものと一致するかを確認します。
    • 一致すれば……「正規の画面からの操作だ」と判断して処理を実行
    • 不一致/トークンなしなら……「不正なリクエストだ」と判断して拒否

CSRFトークンの実装

実際に実装してみましょう。全てを掲載すると長くなるので、実装したコードのうち重要な部分のみを例として掲載します。

1. CSRFトークンの生成とCookie設定(ログイン時)

# auth.py - ログイン時にCSRFトークンをCookieにセット
import secrets

def generate_csrf_token() -> str:
    return secrets.token_hex(32)

# ログインエンドポイント内
csrf_token = generate_csrf_token()
response.set_cookie(
    key="csrf_token",
    value=csrf_token,
    httponly=False,  # JSから読み取り可能にする(重要)
    samesite="none",
    secure=True,
)


2. CSRFトークンの検証(サーバー側)

# auth.py - CSRFトークン検証関数
def verify_csrf_token(
    csrf_token_cookie: str | None = Cookie(alias="csrf_token"),
    csrf_token_header: str | None = Header(alias="X-CSRF-Token"),
) -> None:
    """CookieとヘッダーのCSRFトークンを比較"""
    if csrf_token_cookie is None or csrf_token_header is None:
        raise HTTPException(status_code=403, detail="CSRF token missing")

    if csrf_token_cookie != csrf_token_header:
        raise HTTPException(status_code=403, detail="CSRF token mismatch")

# transfer.py - 送金エンドポイントで検証を適用
@router.post("/transfers")
def transfer_money(
    request: TransferRequest,
    current_user: Annotated[UserAuth, Depends(get_current_user)],
    _csrf: Annotated[None, Depends(verify_csrf_token)],  # ← これを追加
):
    ...


3. CSRFトークンをヘッダーに含める(フロントエンド)

// dashboard.html - CookieからCSRFトークンを取得してヘッダーに含める
function getCsrfToken() {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
        const [name, value] = cookie.trim().split('=');
        if (name === 'csrf_token') return value;
    }
    return null;
}

// 送金リクエスト
const res = await fetch('/api/transfers', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()  // ← これを追加
    },
    body: JSON.stringify(data),
    credentials: 'include'
});

再検証

ここまででCSRFトークンの実装が完了しました。

では再びOWASP ZAPを用いて、防御策がちゃんと機能しているか検証してみましょう。

1. 被害者のアカウントにログインします。
画面キャプチャ
ZAPを確認すると、アクセストークンとCSRFトークンが発行されていることがわかります。
画面キャプチャ


2. 別タブで罠サイトを開き、「景品を受け取る」ボタンをクリックします。

ZAPを確認してみると……
画面キャプチャ
X-CSRF-Tokenがリクエストのヘッダーに存在しないことと、403エラーが出て不正なリクエストが処理されていないことが確認できます。

無事、CSRF攻撃を防御することができました!


3. 念のため、正常時のリクエストも確認します。

現在の残高は5,000円です。

画面キャプチャ

ダッシュボードより2,500円の送金操作を行いました。

画面キャプチャ

送金が実施され、残高が2,500円になりました。

ZAPでリクエストの内容を確認してみましょう。
X-CSRF-Tokenがリクエストのヘッダーに含まれていることが分かります。
画面キャプチャ

送金先である、攻撃者のアカウントにログインをして残高を確認します。
画面キャプチャ
残高が増えていました。

正常なリクエストは、正常に処理されることが確認できました。

他の防御方法

なお、CSRF対策としてCSRFトークンが唯一の手段ではありません。
他にも下記のような対策が考えられます。

  • SameSite属性の設定
  • Referer/Originヘッダの検証

本記事の趣旨から逸れるのでこれらについて詳しくは説明しませんが、複数の防御策を組み合わせ、耐攻撃性の高い実装をすることが重要です。

まとめ

今回の検証を通じて、現代のフレームワークは開発者が意識せずともCSRFから守ってくれているという事実を、肌で感じることができました。

最近はAIを使って実装をする機会が増えましたが、生成されたコードでは「何となく動くけれど、仕組みはよく分からない」という状態に陥ることも少なくありません。

こうした「何となく」の知識を確かなものにするためには、時間をかけて手を動かし、実践するプロセスもやはり重要だと改めて感じました。


似顔絵
書いた人:金子