つれづれなる Agent OPS
つれづれなる Agent OPS
運用観測

ループは回った。でも何が効いたのか分からない

生成、評価、フィードバック、再生成のループをLangfuseへ送り、最終結果だけでなく試行ごとの改善経路を追えるかを検証します。

Xでシェア
Markdown を表示

ループ設計をコードに落とすと、出力品質は一発のプロンプトではなく、生成、評価、フィードバック、再生成の流れで扱えるようになります。ここまでは前回までで見えてきました。

ただ、実行ログを眺める段階で別の違和感が出ました。

ループは回っている。でも、何が効いたのかが分からない。

最終出力だけを見れば、返信案が3つの評価条件を満たしたかどうかは判断できます。しかし、運用で見たいのはそこだけではありません。何回目で止まったのか、どのルーブリックが最後まで残ったのか、評価側の基準を変えるべきなのか、生成側のプロンプトを変えるべきなのか。この判断には、試行ごとのログが必要です。

分かったのは、ループを観測するときに「最終的に3条件を満たしたか」だけを残しても足りない、ということです。あとから改善判断に使うには、1回の実行全体をTrace、各試行の生成・評価ログをObservation、評価項目ごとの合否をScoreとして分ける必要がありました。ループ設計シリーズの第5回では、記事用に用意した検証スクリプトで固定のループ実行を作り、その送信用データを実際にLangfuseへ送りました。画面映えするログを増やす話ではなく、あとから改善判断に使える粒度で送るための回です。

LLM Lab ルーブリックとEvalは最初から正しく作れないので、失敗ログで育てる 粗いルーブリックと具体化したルーブリックの差を確認し、Evalを失敗ログで育てる考え方を整理しています。 https://llm-lab.dev/posts/llm-loop-engineering-rubric-eval/ LLM Lab APIで最小ループを組むと、プロンプト改善ではなく停止条件の設計になる 生成と評価を分離した最小ループを実装し、成功と最大試行回数による停止の両経路を検証しています。 https://llm-lab.dev/posts/llm-loop-engineering-minimal-api/

最終出力だけではループは観測できない

第3回では、生成と評価を分け、最大試行回数で必ず止める最小ループを作りました。第4回では、ルーブリックが粗いと弱い出力でも評価条件を満たした扱いになってしまうことを確認しました。

この2つをつなげると、観測すべき対象が少し変わります。完成した文章だけを残しても、ループのどこで改善したのかは追えません。

  • 何回目の試行か
  • 生成時に渡したフィードバック
  • 評価時の未達ルーブリック
  • ルーブリックのバージョン
  • 停止理由
  • 最終出力の扱い方

未達ルーブリックの推移を落とすと、ループはほぼブラックボックスになります。たとえば1回目では3項目落ち、2回目では1項目だけ残り、3回目で3条件をすべて満たしたとします。この場合、プロンプトが全体的に良くなったのではなく、フィードバックによってどの評価項目が消えたかを追う必要があります。

「AIが改善した」では粗すぎます。「誰が対応するのかだけが最後まで書けなかった」まで見えて、初めて次の改善先を決められます。

Langfuseに載せる粒度を先に決める

LangfuseのObservability Overviewでは、LLMアプリケーションのリクエスト、プロンプト、レスポンス、トークン使用量、レイテンシ、ツール実行などを追うためのApplication Tracingが説明されています。また、Evaluation Overviewでは、ライブTraceやDataset、Experiment、Scoreを使ってLLMアプリケーションの振る舞いを継続的に評価する流れが整理されています。

この前提に立つと、ループ設計で最初に決めるべきなのは「Langfuseへ送るかどうか」ではなく、「何をTraceにし、何をObservationにし、何をScoreにするか」です。ループをそのまま1つの巨大なログとして送ってしまうと、後から見る人は結局どこで失敗したのか分かりません。

送信用データでは、次のように分けました。

観測単位何を表すか
Trace1つのタスクに対するループ実行全体
Observation各試行の生成、各試行の評価
Scoreルーブリック項目ごとの合否
Metadata何回目の試行か、停止理由、ルーブリックのバージョン、出力ハッシュ

ループ設計では、再生成に効いたフィードバックを知りたいので、ルーブリック項目ごとにScoreを分けるほうが扱いやすくなります。

検証スクリプトでLangfuseへ送る

使ったのは、記事用に用意した自作の検証スクリプトです。Langfuse公式CLIではありません。固定の問い合わせ返信タスクを入力にして、モック生成、ルーブリック評価、フィードバック、再生成を3回まで実行します。その結果を、LangfuseのTrace、Observation、Scoreとして送れるJSONへ変換します。

外部LLM APIは呼びません。生成結果はモックです。一方で、Langfuseについては公開鍵、秘密鍵、Base URLを環境変数で渡し、明示的に送信を有効化した場合だけ実送信します。デフォルトではdry-runとしてJSONとHTMLレポートだけを作るようにし、認証情報がない環境でも送信用データの形を確認できるようにしました。

実送信は次の考え方です。TraceとObservationはLangfuseのingestion APIへまとめて送り、ScoreはScore APIへ送ります。再実行時に同じTraceへScoreが重複して見えることを避けるため、実送信時だけrunIdを付与してtraceId、observationId、scoreIdを分けています。これは地味ですが、検証記事の再現性ではかなり大事でした。同じtraceIdへ何度も送ると、後から見たときに「ループが何回あったのか」ではなく「検証スクリプトを何回叩いたのか」が混ざります。

検証スクリプトが作るJSONの考え方は、概ね次の形です。

内部ラベル読み替え
rubric.conclusion_first結論が先にあるか
rubric.two_actions次の行動が2つ以上書かれているか
rubric.owner_named誰が対応するのかが書かれているか
{
  "trace": {
    "name": "support-reply-loop",
    "metadata": {
      "rubricVersion": "support-reply-rubric-v2",
      "maxAttempts": 3,
      "stopReason": "passed",
      "rawContentStored": false
    }
  },
  "observations": [
    { "type": "generation", "name": "attempt-1.generate" },
    { "type": "span", "name": "attempt-1.evaluate" }
  ],
  "scores": [
    { "name": "rubric.conclusion_first", "value": 0 },
    { "name": "rubric.two_actions", "value": 0 },
    { "name": "rubric.owner_named", "value": 0 }
  ]
}

rawContentStored: false は、公開用レポートで生成本文を残さないための目印として入れています。実運用でLangfuseへプロンプトや出力本文を残すかどうかは、扱うデータの性質によって判断すべきです。この記事のレポートでは、生成本文そのものではなく、出力ハッシュ、文字数、未達項目、停止理由だけを残す形にしました。

実Langfuseへ送ると改善の経路が見えた

実送信の結果は、1 Trace、6 Observations、9 Scoresになりました。3回の試行それぞれに生成と評価のObservationがあるため、Observationは6件です。ルーブリックは3項目なので、3試行でScoreは9件になります。

実送信したループ実行をTrace、Observation、Scoreへ分解した検証レポート

送信後は、Langfuse Public APIでTrace名、Observation件数、Score件数を読み戻しました。ObservationはtraceIdで6件確認でき、Scoreは一覧から今回のtraceIdで絞ると9件でした。ここで少し詰まったのは、Score一覧の取得結果をそのまま件数として見ると、過去に送った別TraceのScoreも混ざって見えることです。最初は「9件のはずなのに増えている」と見えました。はいはい、なるほど。同じ一覧を見ていた。

この確認で、記事として言えることが変わりました。dry-runの構造が正しい、ではなく、Langfuseへ送ってもTrace、Observation、Scoreの分解が崩れず、試行ごとの改善経路を追えるところまで確認できました。

このレポートで見たいのは、最終的に3条件を満たしたことだけではありません。Score履歴を見ると、1回目では3項目すべてが未達、2回目では「誰が対応するのかが書かれているか」だけが未達、3回目で全項目が達成になっています。

このループでは、「結論を先に書く」「次の行動を2つ以上書く」は2回目で解消し、「誰が対応するのかを書く」だけが最後まで残りました。ここまで見えると、次の改善先はかなり具体的になります。

  • 生成プロンプトに「誰が対応するのかを書く」と最初から入れる
  • 担当者が書かれていないときのフィードバック文をより具体化する
  • 問い合わせ種別によって、誰を担当者として書くべきかを変える
  • 担当者が分からない場合は、人間確認へフォールバックする

これは、単に「3回目で3条件を満たした」というログからは出てきません。ループの観測では、成功した事実よりも、成功するまでに消えた失敗の順序が重要です。

Langfuseに何でも入れればよいわけではない

観測ログを増やすと安心します。しかし、LLMアプリのログには、ユーザー入力、生成出力、業務文脈、評価コメントが含まれます。Langfuseのような観測基盤へ送る前に、何を保存し、何を保存しないかを決める必要があります。

今回の送信用データでは、生成本文全文を入れない制約を置きました。代わりに、出力ハッシュ、文字数、何回目の試行か、未達ルーブリックを残しています。もちろん、実際のデバッグでは本文が必要な場面もあります。その場合でも、少なくとも次の判断は分けたほうがよいです。

  • 開発中だけ本文を残すのか
  • 本番でも本文を残すのか
  • マスキング後の本文だけ残すのか
  • 評価結果とメタデータだけ残すのか
  • 何日後に削除するのか

個人的には、最初から全文ログ前提にするより、まず本文なしで改善経路が追えるかを確認するほうが安全だと感じました。今回も、本文を送らなくても「何回目の試行で、どのルーブリックが消えたか」は追えました。足りなければ保存対象を増やす。最初から広げすぎない。

ループ観測の最小チェックリスト

この検証から見ると、Langfuseでループを観測するときの最小チェックリストは次のようになります。

  1. 1つのユーザータスク、または1つの業務処理を1 Traceにする
  2. 生成と評価を別Observationにする
  3. 何回目の試行かを、すべてのObservation metadataへ入れる
  4. ルーブリック項目ごとにScoreを分ける
  5. rubricVersionをTraceと評価Observationに入れる
  6. stopReasonをTrace metadataへ入れる
  7. 最大試行回数で止まったケースを成功ケースと同じ形式で残す
  8. 生成本文を保存するかどうかを、実装前に決める
  9. 再送信や再実行でScoreが重複しないようにrunIdを分ける

この形にしておくと、後から見たときに「モデルが悪い」「プロンプトが悪い」「評価基準が悪い」を少し切り分けやすくなります。完全に自動で原因特定できるわけではありませんが、少なくとも最終出力だけを見て悩む状態からは抜け出せます。

ループは回すだけでは足りない

Langfuseでループを観測するときは、ログの量を増やす前に粒度を決める必要があります。1つのループ実行をTraceとして扱い、各試行の生成と評価をObservationに分け、ルーブリック項目ごとの合否をScoreとして残す。実際にLangfuseへ送っても、この形にすると改善の経路を追いやすくなりました。

最終出力だけを見ると、ループは「3条件を満たしたかどうか」で終わります。しかし、試行ごとの未達項目を見ると、どの失敗が消え、どの失敗が最後まで残ったかが分かります。そこまで見えて初めて、プロンプトを直すのか、ルーブリックを直すのか、フォールバックを入れるのかを判断できます。

今回の検証で見えた制約もあります。本文を保存していないので、生成文そのものの文体崩れや言い回しの問題までは追えません。また、Scoreの読み戻しは一覧から今回のtraceIdで絞って確認したため、運用で集計するなら取得条件と集計単位を別途決める必要があります。残る設計範囲は、本文保存、マスキング、保持期間、score名の命名規則、再送時のrunId設計です。

ループは回すだけでは足りない。観測できる粒度まで設計して、ようやく運用に乗せられます。

DUOps

Author

DUOps(デュオプス)

LLMOps、Agent、MCP、Langfuse、Cloudflare 周辺の実装と運用を、個人で試しながら記録しています。

Xを見る

コメント

Related