つれづれなる Agent OPS
つれづれなる Agent OPS
AgentOps

Eveのeval失敗ログをLangfuse Datasetに残して評価条件を改善する

Eveの評価で一度落ちた天気ツールの失敗ログをLangfuse Datasetに登録し、失敗runと修正後runを比較しながら評価条件を改善する検証ログ。

Xでシェア
Markdown を表示

この記事は「 Vercel Eve 入門 」シリーズの第 6 回です — 6 記事

Eveで天気ツール付きのエージェントを作ったとき、最初のevalは一度落ちました。原因は単純で、ツール出力にはcondition: "partly cloudy"が入っていたものの、最終返答ではモデルがそれを日本語へ言い換えたためです。

つれづれなる Agent OPS VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた Eveにツールとevalを足し、TUIでのツール呼び出しとevalまわりを確認した実装ログ。 https://llm-lab.dev/posts/vercel-eve-deep-dive/

その場では、partly cloudyを最終返答に期待するのをやめ、ツール出力はt.calledTool(...)で、ユーザー向け返答は26°Cや「ダミー」のような表示文言で見る形に直しました。これはevalとしては正しい修正です。

ただ、AgentOpsとして見ると、ここで終わるのはもったいない。失敗したevalは「赤だったログ」ではなく、次にモデルやプロンプトを変えたときに再利用できるテストケースです。今回は、この失敗をLangfuse Datasetに登録し、評価条件を分ける前のrunと、修正後のrunを同じDataset itemにぶら下げて比較しました。

落ちたのはツールではなく期待値だった

対象にしたevalは、固定値を返すダミー天気ツールです。ツール自体は、東京の気温を26°C、天気条件をpartly cloudyとして返します。

// agent/tools/get_weather.ts
export async function fetchWeather({ city }) {
  return {
    city,
    temperatureC: 26,
    condition: "partly cloudy",
    note: "this is dummy data from a local check, not a real weather API",
  };
}

最初の失敗は、ツール出力ではなく最終返答に対してincludes("partly cloudy")を期待したことでした。モデルはツール結果を読んだうえで、日本語の自然文として「曇り時々晴れ」と返しました。ユーザー体験としては自然ですが、文字列一致のevalとしては失敗です。

最初のEve evalで、ツール呼び出しは通ったが最終返答の文字列チェックだけ失敗した画面

失敗していたのはツール呼び出しではなく、最終返答に英語の内部値がそのまま出ると期待した評価条件のほうでした。

この失敗から見えたのは、Agent evalでは「内部で何をしたか」と「最後にどう言ったか」を同じ粒度で検証しないほうがよい、ということです。ツール呼び出しとツール出力は構造化された事実として検証できます。一方、最終返答はユーザー向けの表現なので、同義語、翻訳、要約が入ります。

修正後は、partly cloudyをツール出力側で見て、最終返答では温度やダミーデータであることを確認する形にしました。

// evals/weather.eval.ts
await t.send("東京の天気を教えてください");
t.completed();
t.calledTool("get_weather", {
  output: { city: "Tokyo", condition: "partly cloudy" },
});
t.check(t.reply, includes("26°C"));
t.check(t.reply, includes("ダミー"));

期待値を分けたあと、Eve evalが成功した画面

Langfuseへ残すなら何を分けるべきか

LangfuseのEvaluationは、テストケースをDatasetとして持ち、プロンプト、モデル、コード変更をExperimentとして比較する考え方に向いています。公式ドキュメントでも、Datasetは入力と期待出力の集合として説明され、SDKベースのExperimentで同じデータに対する変更前後の比較に使えるとされています。

今回の失敗ログをそのまま保存するなら、少なくとも次の4つに分ける必要があります。

要素保存したい内容理由
Dataset itemユーザー入力と期待するツール出力次回も同じ条件で再実行するため
Trace実際のツール出力と最終返答そのrunで何が起きたかを追うため
Dataset run itemどのrunで、どのDataset itemを試したかモデル変更やプロンプト変更を比較するため
Scoretool_output_condition、返答チェック、overall_eval_passどこが通り、どこが落ちたかを分離するため

ここで重要なのは、失敗を1個のpass: falseに潰さないことです。今回のケースでは、ツール呼び出しは成功しています。失敗したのは「最終返答に英語のツール値がそのまま出る」という期待です。これを1つの失敗として保存すると、次回見返したときに、ツール実行が壊れたのか、返答表現が変わっただけなのかが分からなくなります。

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

今回用意したnpm run sync:eval-datasetは、Eveに標準で入っているCLIではありません。記事用の検証スクリプトです。入力として、Eve evalの失敗runと修正後runを抽象化したJSONを読み、Langfuse APIへDataset、Dataset item、Trace、Dataset run item、Scoreを送ります。

認証情報がない環境でもデータ形状を確認できるように、dry-runではJSONだけを出せるようにしています。

npm run sync:eval-dataset -- --dry-run

変換元のfixtureは2本あります。1本目は、最終返答にpartly cloudyが含まれることを期待して落ちたv1 runです。2本目は、ツール出力と最終返答のチェックを分けたv2 runです。

{
  "evalName": "weather",
  "runName": "weather-eval-v1-final-reply-condition-check",
  "input": {
    "message": "東京の天気を教えてください"
  },
  "datasetExpected": {
    "tool": {
      "name": "get_weather",
      "output": {
        "city": "Tokyo",
        "condition": "partly cloudy"
      }
    }
  }
}

Langfuseへ送るときは、同じDataset itemに対して次の2つのrunを作ります。ここで比較したいのはモデルそのものではなく、評価条件の設計です。

Run評価条件overall_eval_pass
weather-eval-v1-final-reply-condition-check最終返答にpartly cloudyが含まれることを期待する0
weather-eval-v2-split-tool-and-reply-checksツール出力でpartly cloudyを見て、最終返答では26°Cと「ダミー」を見る1

つまり、v1は失敗を含むrunですが、構造化して見ると「ツール出力は正しい」「最終返答の文字列期待が不適切」という判断になります。v2では、同じ入力と同じツール出力を使いながら、評価条件を分けたことでoverall_eval_passが0から1に変わります。この差分をLangfuseに残すと、あとから「モデルが良くなった」のか「評価条件を直した」のかを分けて追いやすくなります。

Eve evalの失敗runと改善runをLangfuse Dataset、run、scoreへ分ける構造

実際にLangfuseへ送信する

実際に送る場合は、Langfuseの公開鍵、秘密鍵、Base URLを環境変数で設定し、明示的に送信を有効化します。

LANGFUSE_SYNC=1 npm run sync:eval-dataset

今回はこのコマンドで、Langfuseにeve/weather-eval-regressionというDatasetを作成し、同じDataset itemに2つのrunを紐づけました。出力は次のような形です。IDは環境ごとに変わるため省略しています。

{
  "ok": true,
  "datasetName": "eve/weather-eval-regression",
  "runs": [
    {
      "runName": "weather-eval-v1-final-reply-condition-check",
      "scores": [
        "tool_output_condition",
        "reply_includes_partly_cloudy",
        "overall_eval_pass"
      ]
    },
    {
      "runName": "weather-eval-v2-split-tool-and-reply-checks",
      "scores": [
        "tool_output_condition",
        "reply_includes_temperature",
        "reply_mentions_dummy_data",
        "overall_eval_pass"
      ]
    }
  ]
}

Langfuse側のDataset itemには、入力の「東京の天気を教えてください」と、期待するツール出力、最終返答で確認する文言をまとめて残しています。

LangfuseのDataset itemに、Eve evalの入力、期待するツール出力、最終返答チェックを保存した画面

Experimentsタブでは、同じDataset itemに対してv1とv2のrunが並びます。v1はreply_includes_partly_cloudyoverall_eval_passがFalse、v2は分割後の返答チェックとoverall_eval_passがTrueになっています。

LangfuseのExperimentsタブで、失敗runと改善runのscore差分を比較している画面

ここで重要なのは、Langfuseに「修正後は成功した」という最終結果だけを置かないことです。v1の失敗runも残し、v2の改善runも残す。そうすると、あとからDataset runを見たときに、失敗がモデルの応答品質ではなく評価条件の置き方に由来していたことを説明できます。

eval失敗を改善ループへ戻す

今回の小さな失敗から得た示唆は、かなり実務寄りです。Agent evalでは、失敗したassertionを直すだけでは不十分です。その失敗が、ツール選択、ツール出力、最終返答、評価条件のどこで起きたのかを分けて保存しないと、次回の改善に使えません。

特に今回のpartly cloudyのようなケースは、モデルが間違えたというより、評価者側が「内部値がそのまま最終返答に出る」と期待していました。これはプロンプト改善の材料というより、eval設計の材料です。

AgentOpsの運用では、こういう地味な失敗をDatasetへ戻す流れが効いてきます。最初から完璧なevalを作るのではなく、落ちたケースを残し、scoreを分け、評価条件を修正したrunを同じDataset itemに追加する。そうすると、失敗ログが単なる赤い出力ではなく、改善ループの資産になります。

正直、これは派手な話ではありません。でも、運用で効くのはこういう地味な分解です。

まとめ

今回確認したことは次の3点です。

  1. Eve evalのpartly cloudy失敗は、ツール呼び出しの失敗ではなく、最終返答への期待値設計の失敗だった。
  2. Langfuse Datasetへ残すときは、入力、期待するツール出力、実出力、run、scoreを分けたほうが、後続の比較に使いやすい。
  3. v1失敗runとv2改善runを同じDataset itemに紐づけると、overall_eval_passの0→1を「評価条件の改善」として説明しやすい。

残る比較軸は、同じDataset itemに対してモデルやinstructionsを変えたrunをもう1本作ることです。そこまでできると、Eveのローカルevalが、単発のgateから継続的な評価履歴へ変わります。

DUOps

Author

DUOps(デュオプス)

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

Xを見る

コメント

Related