---
title: "Eveのeval失敗ログをLangfuse Datasetに残して評価条件を改善する"
description: "Eveの評価で一度落ちた天気ツールの失敗ログをLangfuse Datasetに登録し、失敗runと修正後runを比較しながら評価条件を改善する検証ログ。"
lang: "ja"
canonical: "https://llm-lab.dev/posts/vercel-eve-eval-langfuse-dataset/"
source: "https://llm-lab.dev/posts/vercel-eve-eval-langfuse-dataset.md"
publishedAt: "2026-07-02"
updatedAt: "2026-07-02"
category: "AgentOps"
tags:
  - "vercel"
  - "eve"
  - "langfuse"
  - "eval"
  - "agent"
---

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

import LinkCard from "../../components/LinkCard.astro";

> [!NOTE]
> この記事で確認したこと
>
> Eveの天気ツールevalで落ちたケースを、失敗runと修正後runの両方として同じLangfuse Dataset itemに保存しました。失敗していたのはツール呼び出しではなく、`partly cloudy`という内部値が最終返答にそのまま出ると期待した評価条件でした。
>
> Dataset item、Trace、Dataset run item、Scoreを分けると、v1は最終返答チェックが不安定で落ち、v2はツール出力とユーザー向け返答を分けたことで通った、という改善理由を後から説明できます。

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

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

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

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

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

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

```ts
// 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で、ツール呼び出しは通ったが最終返答の文字列チェックだけ失敗した画面](/images/posts/vercel-eve-eval-langfuse-dataset/eve-eval-fail.webp)

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

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

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

```ts
// 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が成功した画面](/images/posts/vercel-eve-eval-langfuse-dataset/eve-eval-success.webp)

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

LangfuseのEvaluationは、テストケースをDatasetとして持ち、プロンプト、モデル、コード変更をExperimentとして比較する考え方に向いています。公式ドキュメントでも、[Dataset](https://langfuse.com/docs/evaluation/experiments/datasets)は入力と期待出力の集合として説明され、[SDKベースのExperiment](https://langfuse.com/docs/evaluation/experiments/experiments-via-sdk)で同じデータに対する変更前後の比較に使えるとされています。

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

| 要素 | 保存したい内容 | 理由 |
|---|---|---|
| Dataset item | ユーザー入力と期待するツール出力 | 次回も同じ条件で再実行するため |
| Trace | 実際のツール出力と最終返答 | そのrunで何が起きたかを追うため |
| Dataset run item | どのrunで、どのDataset itemを試したか | モデル変更やプロンプト変更を比較するため |
| Score | `tool_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だけを出せるようにしています。

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

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

```json
{
  "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へ分ける構造](/images/posts/vercel-eve-eval-langfuse-dataset/dataset-payload-dry-run.webp)

## 実際にLangfuseへ送信する

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

```bash
LANGFUSE_SYNC=1 npm run sync:eval-dataset
```

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

```json
{
  "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の入力、期待するツール出力、最終返答チェックを保存した画面](/images/posts/vercel-eve-eval-langfuse-dataset/langfuse-dataset-item.webp)

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

![LangfuseのExperimentsタブで、失敗runと改善runのscore差分を比較している画面](/images/posts/vercel-eve-eval-langfuse-dataset/langfuse-experiment-runs.webp)

ここで重要なのは、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から継続的な評価履歴へ変わります。
