つれづれなる Agent OPS
eve

EveのTUI実行とtool callingをLangfuseで観測する

Vercelのエージェントフレームワークeveで動かしたtool calling実行を、Langfuseへtrace/span/generationとして送る方法を2パターン試して比較したログ。

TUIで満足して終われない理由

前回の記事 - 「VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた」では、get_weatherツールを追加してeve devのTUI上で呼び出しを確認し、evalでincludes("partly cloudy")が一度落ちて期待値を直すところまでやりました。 TUIは思考プロセスやツール呼び出しがリアルタイムで見えるので、開発中の体験としてはかなり快適でした。

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

ただ、TUIで見えているものは「今、目の前で起きていること」だけです。LLMOpsの観点で言うと、これはその場の観察にすぎません。 あとから「いつ、どのモデルで、どのツールが呼ばれ、どのevalが落ちたか」を振り返って比較するには、TUIのログとは別に、外部の観測基盤に残しておく必要があります。

今回は、その記録先としてLangfuseを使い、Eveの実行をtrace/span/generationとして送る最小構成を作ってみました。

Eveのsend:turn実行でget_weatherが呼ばれ、actions.requestedとaction.resultが出ているターミナル

どこから記録を取るか、2つの選択肢

最初に詰まったのが、「Eveのどこに記録コードを差し込むか」という入口の問題でした。 ドキュメントを読み直すと、大きく2つの選択肢がありそうだと分かりました。

  1. instrumentation.tsまたはHookを使う方法: Eveのセッション・ターン・ステップという実行ライフサイクルに対して、外側からイベントを観測する
  2. Tool定義のexecute内に直接記録コードを埋め込む方法: 自分が書いたツール(get_weather.tsなど)の中で、Langfuse SDKを直接呼ぶ

実際に両方試してみました。 Eve本体のライフサイクルに乗る方が、ツールを増やすたびに記録コードを書き直す必要がなくなるはずなので、instrumentation.tsやHookの方が「正しい」やり方なのかなと思いつつ。

方法1: instrumentation.tsとHookを試す

最初はinput.requestedのようなストリームイベントだけを見ていたので、「tool callの開始・終了イベントはどこから拾うんだ」と迷いました。 ただ、そこで止めずにEveのパッケージ内の型定義まで追うと、入口はちゃんとありました。

まず、Eveにはagent/instrumentation.tsというファイルベースの入口があります。 ここでdefineInstrumentationをexportすると、モデル呼び出しに対するtelemetryが有効になり、step.startedでAI SDKのtelemetry spanに渡すruntimeContextを追加できます。

import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { defineInstrumentation } from "eve/instrumentation";

function getLangfuseAuthorizationHeader() {
  const publicKey = process.env.LANGFUSE_PUBLIC_KEY;
  const secretKey = process.env.LANGFUSE_SECRET_KEY;
  if (!publicKey || !secretKey) return undefined;

  return "Basic " + Buffer.from(`${publicKey}:${secretKey}`).toString("base64");
}

function getLangfuseOtelEndpoint() {
  if (process.env.LANGFUSE_OTEL_ENDPOINT) {
    return process.env.LANGFUSE_OTEL_ENDPOINT;
  }

  const baseUrl = process.env.LANGFUSE_BASE_URL ?? "https://cloud.langfuse.com";
  return baseUrl.replace(/\/$/, "") + "/api/public/otel/v1/traces";
}

export default defineInstrumentation({
  functionId: "vercel-eve-langfuse-observability",
  recordInputs: true,
  recordOutputs: true,
  setup({ agentName }) {
    const authorization = getLangfuseAuthorizationHeader();
    if (!authorization) return;

    const sdk = new NodeSDK({
      resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: agentName,
      }),
      traceExporter: new OTLPTraceExporter({
        url: getLangfuseOtelEndpoint(),
        headers: {
          Authorization: authorization,
          "x-langfuse-ingestion-version": "4",
        },
      }),
    });

    sdk.start();
  },
  events: {
    "step.started"(event) {
      return {
        runtimeContext: {
          "langfuse.trace.name": "eve-session",
          "langfuse.session.id": event.session.id,
          "langfuse.trace.metadata.experiment": "vercel-eve-langfuse-observability",
          "langfuse.trace.metadata.turn_id": event.turn.id,
          "langfuse.trace.metadata.step_index": event.step.index,
        },
      };
    },
  },
});

このファイルは実験環境のagent/instrumentation.tsに追加しました。 npm run startでサーバーを起動すると、OTLP exporterも起動しました。

[instrumentation] Langfuse OTLP exporter started {
  agentName: 'vercel-eve-langfuse-observability',
  endpoint: 'https://cloud.langfuse.com/api/public/otel/v1/traces'
}

Eveサーバー起動時にLangfuse OTLP exporterが開始されたターミナル

もう1つ、Hook側でもaction.resultを購読できました。 これはツール、サブエージェント、スキルの実行結果が確定したあとに流れてくるイベントです。 toolResultFromを使うと、対象のtool定義に対応する結果だけを取り出せます。

import { defineHook } from "eve/hooks";
import { toolResultFrom } from "eve/tools";
import getWeather from "#tools/get_weather";

export default defineHook({
  events: {
    "action.result"(event, ctx) {
      const result = toolResultFrom(event.data.result, getWeather);
      if (!result) return;

      console.info({
        hook: "action.result",
        agent: ctx.agent.name,
        channel: ctx.channel.kind ?? "unknown",
        toolName: result.toolName,
        callId: result.callId,
        output: result.output,
        status: event.data.status,
      });
    },
  },
});

つまり、「Hook/stream eventではtool callを拾えなさそう」という最初の見立ては浅かったです。 少なくともEve 0.11.9では、標準の観測入口としてinstrumentation.tsがあり、結果イベントとしてaction.resultもあります。

では、これでLangfuseまで送れたのか。 最初の検証ではAI Gateway認証不足やLangfuse 401で止まりましたが、認証情報を直して再実行すると、Eveのtool callまで到達できました。

ここで使っているnode scripts/send-turn.mjsは、Eveに最初から入っているコマンドではなく、今回の検証用に用意した小さなスクリプトです。 やっていることは、起動中のEveサーバー(http://127.0.0.1:3000)へEve Clientで1ターン送るだけです。

import { Client } from "eve/client";

const client = new Client({ host: process.env.EVE_HOST ?? "http://127.0.0.1:3000" });
const session = client.session();
const response = await session.send("東京の天気を教えてください。ツールを使ってください。");
const result = await response.result();

console.log(JSON.stringify({
  status: result.status,
  sessionId: result.sessionId,
  message: result.message,
  events: result.events.map((event) => event.type),
}, null, 2));

TUIで手入力してもよいのですが、スクリーンショットや再実行のたびに同じ入力を投げたいので、今回はこのスクリプトを使いました。

実行結果には、actions.requestedaction.resultが出ています。 これは、Eveがget_weatherの呼び出しを要求し、その結果を受け取ったことを示しています。

"actions.requested",
"action.result",
"session.waiting"

最後がsession.completedではなくsession.waitingなのは、会話セッションが次の入力待ちとして残っているためです。 今回の観測目的では、actions.requestedaction.resultが出ていれば十分です。

方法2: Tool execute内に記録コードを埋め込む

比較対象として、もう一つの素朴な方法も試しました。 agent/tools/get_weather.tsexecute関数の中で、直接Langfuse SDKを呼び出す方法です。 これはEveのライフサイクルに乗るというより、自分で書いたtoolの実装内に観測コードを置くアプローチです。

import { Langfuse } from "langfuse";

const langfuse = new Langfuse();

export default defineTool({
  description: "指定した都市の天気を取得する",
  inputSchema: z.object({
    city: z.string(),
  }),
  execute: async ({ city }, ctx) => {
    const trace = langfuse.trace({
      name: "eve-tool-call",
      sessionId: ctx.session.id,
      metadata: {
        tool: "get_weather",
        model: ctx.session.model, // model IDが取れる場合
      },
    });

    const span = trace.span({
      name: "get_weather",
      input: { city },
    });

    const result = await fetchWeather(city);

    span.end({ output: result });
    await langfuse.flushAsync();

    return result;
  },
});

これは動きました。ただ、動いた瞬間に別の問題が見えてきます。

tool側に観測コードを書くと、ツールを増やすたびに同じボイラープレートを毎回コピーすることになる。これは結構つらい。

ツールが1つの今は気にならないレベルですが、5個、10個と増えていくと、langfuse.trace()の呼び出しとflush処理を全ツールに重複させることになります。共通化するなら、executeをラップする薄いヘルパー関数を1つ作り、各ツールはそのヘルパーを通すという構成にするのが妥当そうです。

function withLangfuseTrace(toolName: string, execute: ToolExecute): ToolExecute {
  return async (input, ctx) => {
    const trace = langfuse.trace({
      name: `eve-tool-call:${toolName}`,
      sessionId: ctx.session.id,
    });
    const span = trace.span({ name: toolName, input });
    try {
      const result = await execute(input, ctx);
      span.end({ output: result });
      return result;
    } catch (err) {
      span.end({ output: { error: String(err) }, level: "ERROR" });
      throw err;
    } finally {
      await langfuse.flushAsync();
    }
  };
}

今回の検証では、Tool側に薄いラッパーを噛ませる方式は、実装としては一番短く書けました。 一方で、Eveの設計に沿うならinstrumentation.tsとOTel exporterで外側から拾う構成の方が本命です。

比較のため、Tool側adapterもLangfuse SDKで実送信まで確認しました。 Eveサーバー経由で実行するとOTel traceと混ざるため、検証用にget_weatherexecuteを直接呼ぶ小さなスクリプトを用意しています。 このスクリプトはLANGFUSE_TRACE_ENABLED=truewithLangfuseTraceを有効にし、traceNameeve-tool-call:get_weathersessionIdtool-adapter-check-...として送ります。

npm run verify:tool-adapter

実行結果は次のようになりました。

{
  "ok": true,
  "traceName": "eve-tool-call:get_weather",
  "sessionId": "tool-adapter-check-2026-06-20T21:36:09.106Z"
}

Tool側adapter方式で送ったLangfuse trace detailにget_weatherのinput/outputが表示されている画面

withLangfuseTraceのdry-runでtrace/span payloadが出ているターミナル

ここまで試した時点の比較

最初はLangfuse認証情報の組み合わせが合わず401になりましたが、認証情報を直すとOTel経由とSDK経由の両方でtrace送信を確認できました。 短時間で送信コードを書くならTool側adapterが一番早いです。 ただし、ツールが増える前提でちゃんとやるなら、個別のexecuteに記録処理を混ぜるより、instrumentation.ts + OTelで外側から拾う構成の方が保守しやすそうです。

Hookのaction.resultは、Langfuse traceそのものを作る場所というより、独自ログや補助的な分析イベントを作る場所として使いやすそうでした。

モデルIDとeval結果をmetadataへ残す

trace側のmetadataには、modelフィールドにモデルID(anthropic/claude-haiku-4.5のような文字列)と、sessionIdを入れています。これによって、Langfuse上でモデルごとの呼び出し傾向を後から絞り込めるようになりました。

eval結果については、eve evalの実行とTUI上の対話実行が別プロセスなので、今回はevalの成功/失敗を同じtraceに紐付けることまではできていません。evalランナー側からもLangfuseへ送るには、evals/配下のテストコードの中で同様のtrace呼び出しを行う必要があり、これは次にやりたいことの一つです。

Langfuseのtrace一覧でEveの実行traceが並んでいる画面

Langfuse上で見えるようになったもの

send:turn実行後、Langfuseのtrace一覧にもEve由来のtraceが並びました。

ここで重要なのは、Langfuseに飛んでいるtraceが「何となく増えた」だけではなく、Eveのセッションと紐づけて探せることです。 send:turnの出力に出たsessionIdは、Eveが発行した会話セッションIDです。

wrun_01KVKDVH67B5RBD1ZRMVXTY64Y

instrumentation.tsでは、この値をlangfuse.session.idとしてspanへ渡しています。 そのため、Langfuse側ではtrace名だけでなく、セッションIDやmetadataからも今回の実行を探せます。

trace detailを開くと、langfuse.session.idlangfuse.trace.metadata.experimentなど、step.startedで入れたruntime contextが確認できます。 今回の構成では、Hookのaction.resultはconsoleログとして扱っており、Langfuse上にget_weather専用のtool I/O spanを明示的に作っているわけではありません。 そのため、Langfuse detailで確認する主な対象はtool outputそのものではなく、Eve実行とLangfuse traceを結びつけるmetadataです。

Langfuseのtrace detailでEveのsessionIdとmetadataを確認している画面

ログを画面で眺めてるだけだと気づかなかったけど、こうしてtrace一覧で並べると、同じcityでも返ってくる文言が呼び出しごとに微妙に違うのが分かる。これは地味に発見。

これが今回いちばん収穫だったポイントです。TUIで1回ずつ見ているときは気づかなかった「同じ入力に対する出力のばらつき」が、複数回の実行を並べて比較することで初めて見えるようになりました。これはTUIでは原理的に難しく、Langfuseのような蓄積型の観測基盤を挟む意味そのものだと感じています。

redactionの設計はまだ仮置きです

ユーザー入力やtool resultをどこまで記録するかは、今回は仮の設計のまま進めています。今回のダミーツール(天気取得)では機密性のある情報は扱っていませんが、実際に社内データを扱うエージェントへ同じ仕組みを適用する場合は、span.end()に渡すoutputをそのまま送るのではなく、フィールド単位でマスキングするレイヤーを挟む必要があります。

現時点での仮の方針としては、

  • tool inputのうち、ユーザー識別子や個人情報に該当しうるフィールドは送らない
  • tool outputは、構造のキー名だけ残し、値は型情報程度に丸める
  • 必要に応じて、Langfuse側のフィールド単位の暗号化・マスキング機能を併用する

という整理にしていますが、これはまだ仮説の段階です。実際にPIIを含むデータを扱う場面で運用してみないと、どこまで削ってよいかの感覚はつかめないと思っています。

分かったこと、まだ分かっていないこと

今回の検証で分かったのは次の点です。

  • Eve 0.11.9では、agent/instrumentation.tsstep.startedを拾い、AI SDK telemetry spanへruntimeContextを足せる
  • agent/hooks/*.tsaction.resultを購読でき、toolResultFromで特定toolの実行結果を取り出せる
  • instrumentation.ts + OTLP exporterの構成で、Eveの実行traceをLangfuseへ送れる
  • send:turnの実行では、actions.requestedaction.resultが出ており、tool callの要求と結果まで到達できた
  • Langfuse trace detailでは、langfuse.session.idlangfuse.trace.metadata.experimentからEveの実行とtraceを紐づけて確認できる
  • 今回のHookはconsoleログ用途なので、Langfuse上にget_weather専用のtool I/O spanを作るには追加実装が必要

まだ残っている点は次の通りです。

  • action.resultの内容をLangfuseの独立したspanまたはeventとして残す方法
  • eve eval実行とTUI実行のtraceを同じセッションとして紐付ける方法
  • redactionルールの実運用での妥当性

次にやるなら、Hookで拾ったaction.resultをLangfuse側のspan/eventに変換する薄いレイヤーを足したいです。 そこまでできると、Eve本体のinstrumentation.tsでモデル実行を拾い、Hookでtool resultを補完する構成にできます。

関連記事

DUOps

Author

DUOps(デュオプス)

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

Xを見る

Related