AI
Evaluasi AI Agent: Cara Menguji Tool Call dan Trajectory
Oktober 202612 menit baca

Evaluasi trajectory menilai jalur yang ditempuh agent, bukan hanya jawaban akhirnya. Yang dicek adalah tool apa yang dipanggil, argumen yang dikirim, urutan pemanggilan, apakah tujuan tercapai, dan berapa langkah yang dihabiskan. Cara ini menangkap kegagalan seperti bertindak pada record yang salah, yang biasanya tertutup oleh pesan akhir yang lancar.
Agent bisa memberi balasan yang terdengar benar setelah memanggil tool yang salah, memakai id customer yang keliru, atau menulis sebelum membaca record. Cek jawaban akhir hanya membandingkan pesan terakhir, sehingga run seperti itu tetap lulus. Menilai trace memindahkan pengecekan ke tempat keputusan benar-benar dibuat.
promptfoo menyediakan trajectory:tool-used, trajectory:tool-args-match, trajectory:tool-sequence, trajectory:step-count, dan trajectory:goal-success yang dinilai oleh model. tool-args-match mendukung mode partial dan exact, sedangkan tool-sequence mendukung mode in_order dan exact. Semuanya membaca data trace OpenTelemetry, jadi tracing harus diaktifkan untuk eval.
Panggil endpoint NestJS dengan HTTP provider promptfoo dan aktifkan tracing dengan OTLP receiver bawaannya. promptfoo mengirim header traceparent, sehingga HTTP instrumentation OpenTelemetry di NestJS bergabung ke trace yang sama. Bungkus setiap eksekusi tool dengan span yang punya atribut tool.name dan tool.arguments agar assertion trajectory bisa membacanya.
Google ADK memakai kriteria tool_trajectory_avg_score dengan tipe pencocokan EXACT, IN_ORDER, atau ANY_ORDER. Setiap invocation bernilai 1.0 jika cocok atau 0.0 jika tidak, dan skor kasus adalah rata-ratanya, dengan threshold default 1.0. Kriteria ini tidak didukung ketika eval memakai user simulation di ADK.

Ringkasan Utama
Evaluasi AI agent harus menilai trajectory, bukan hanya jawaban akhir: tool apa yang dipilih agent, argumen yang dikirim, urutan pemanggilan, apakah tujuan tercapai, dan berapa langkah yang dihabiskan. Kirim span tool lewat OpenTelemetry, ubah production trace menjadi test case, lalu uji dengan assertion trajectory promptfoo di CI.
Bayangkan sebuah agent helpdesk ERP yang menangani permintaan credit note. Seorang staf customer service mengetik bahwa PT Sinar Jaya meminta credit note untuk dua unit rusak pada sebuah invoice. Agent membalas dengan ringkasan yang sopan, terlihat benar, dan lengkap dengan nomor draft. Eval yang hanya mengecek jawaban akhir menilainya lulus. Trace-nya bercerita lain: agent mencari customer dengan sebagian nama, memilih PT Sinar Jaya yang salah dari tiga kandidat, lalu membuat draft untuk invoice yang tidak pernah ia ambil. Jawabannya enak dibaca karena model memang pandai menulis; pekerjaan di baliknya salah.
Celah itulah alasan evaluasi AI agent membutuhkan unit yang berbeda dari evaluasi prompt. Ada post lain di situs ini tentang LLM eval di CI untuk prompt tunggal: golden dataset, assertion, dan pass-rate gate. Post ini turun satu tingkat, ke jalur multi-langkah yang ditempuh agent. Isinya: apa saja yang perlu dinilai dalam trajectory, cara membangun dataset dari production trace, cara membuat agent NestJS mengirim span yang bisa dibaca eval, dan cara menulis assertion trajectory promptfoo, ditutup dengan perbandingan OpenAI trace grading dan evaluasi Google ADK. Setiap nama assertion dan opsi di bawah diambil dari dokumentasi vendor yang berlaku saat ini.
Pengecekan jawaban akhir membandingkan satu string dengan ekspektasi. Satu run agent adalah rangkaian keputusan, dan kegagalan yang paling mahal biasanya terjadi di tengahnya, tertutup oleh pesan akhir yang lancar. Tabel berikut memetakan kegagalan agent yang umum ke apa yang bisa dilihat oleh tiap jenis pengecekan.
| Kegagalan dalam run | Yang terlihat oleh cek jawaban akhir | Yang ditangkap cek trajectory |
|---|---|---|
| Tool benar, record salah: agent mencari customer yang keliru | Balasan masuk akal yang menyebut nama customer, biasanya lulus | tool-args-match gagal karena id customer atau nomor invoice berbeda |
| Menulis sebelum membaca: draft dibuat tanpa mengambil invoice | Tidak ada, balasan tetap menyebut nomor draft | tool-sequence gagal karena get_invoice tidak mendahului operasi tulis |
| Aksi terlarang: agent mem-posting dokumen alih-alih membuat draft | Sering tidak ada, atau balasan yang terdengar makin membantu | Cek tool-used yang dinegasikan langsung gagal begitu tool posting muncul |
| Looping: pencarian yang sama diulang delapan kali sebelum menjawab | Jawaban benar, hanya lebih lambat dan lebih mahal | step-count dengan max gagal, dan batas token menangkap biayanya |
| Argumen halusinasi: flag tambahan seperti force: true | Sama sekali tidak ada | tool-args-match dalam mode exact menolak key yang tidak diharapkan |
Polanya: pesan akhir adalah artefak paling tidak informatif dari sebuah run agent. Pesan itu ditulis paling akhir, oleh komponen yang paling mahir terdengar benar. Cek trajectory memindahkan assertion ke tempat keputusan dibuat, yang juga tempat perbaikannya nanti dilakukan.
Lima properti mencakup hampir semua kegagalan agent yang layak memblokir merge. Masing-masing cocok dengan jenis pengecekan yang berbeda, dan memisahkannya memberi tahu bagian agent mana yang mengalami regresi, bukan satu skor campuran.
Nilai properti yang deterministik dengan cek deterministik, dan simpan model grader untuk keberhasilan tujuan. Model grader yang ditanya apakah tool call-nya masuk akal akan tidak konsisten, padahal perbandingan string bisa memberi hasil yang pasti dan gratis.
Test case trajectory terbaik adalah run yang sudah pernah salah. Edge case karangan menguji apa yang Anda bayangkan; trace menguji apa yang benar-benar diketik user, termasuk nama perusahaan yang ambigu dan nomor invoice yang diingat setengah, yang membuat agent salah jalan.
Kasus yang diturunkan dari skenario pembuka terlihat seperti ini. Assertion-nya menggambarkan jalur, bukan kalimat, sehingga test tetap valid walaupun balasan agent ditulis ulang.
# evals/helpdesk/credit-note-ambiguous-customer.yaml
# Derived from a production trace. Names and numbers are fictional, but the
# shape that broke the agent is kept: three customers share "Sinar Jaya".
- description: 'Credit note, ambiguous customer name, partial damage'
vars:
query: >-
PT Sinar Jaya Abadi wants a credit note for 2 damaged units
on INV-2026-08812
assert:
# The disambiguating lookup must carry the full legal name, not "Sinar Jaya".
- type: trajectory:tool-args-match
value:
name: find_customer
args:
legal_name: 'PT Sinar Jaya Abadi'
# Read the invoice before drafting against it. in_order (the default)
# tolerates extra calls in between, e.g. a stock-return lookup.
- type: trajectory:tool-sequence
value:
steps:
- find_customer
- get_invoice
- create_credit_note_draft
# On the write tool, exact mode rejects invented extras such as force: true.
# ignore drops the key the agent generates fresh on every call.
- type: trajectory:tool-args-match
value:
name: create_credit_note_draft
mode: exact
args:
invoice_no: 'INV-2026-08812'
lines:
- line_no: 1
qty: 2
reason: damaged
ignore:
- idempotency_keypromptfoo membaca trajectory dari span OpenTelemetry. Dokumentasi tracing-nya menyebut bahwa promptfoo menyertakan header traceparent saat memanggil HTTP target, dan aplikasi di balik target bisa menambahkan child span di bawah trace tersebut. promptfoo mengenali tool call dari atribut span seperti tool.name, dan argumen dari atribut seperti tool.arguments, dengan nilai string di-parse sebagai JSON bila memungkinkan. Jadi agent yang berjalan di NestJS butuh dua hal: OpenTelemetry yang dijalankan sebelum aplikasi, dengan HTTP instrumentation untuk menangkap traceparent yang masuk, dan span di sekeliling setiap eksekusi tool.
File tracer diimpor paling awal di main.ts. Wrapper ini menjadi satu-satunya tempat tool dieksekusi, yang sekaligus memberi satu titik untuk menerapkan timeout dan audit log nantinya.
// src/tracing.ts — import this on the FIRST line of main.ts, before NestFactory,
// or the HTTP module is loaded un-instrumented and the traceparent is ignored.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import {
BatchSpanProcessor,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
const exporter = new OTLPTraceExporter({
// promptfoo's built-in receiver listens on 127.0.0.1:4318 by default.
url: process.env.OTEL_TRACES_URL ?? 'http://127.0.0.1:4318/v1/traces',
});
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'erp-helpdesk-agent' }),
// Under eval, flush every span immediately so it lands before the response.
spanProcessors: [
process.env.AGENT_EVAL_MODE === '1'
? new SimpleSpanProcessor(exporter)
: new BatchSpanProcessor(exporter),
],
// Extracts the incoming W3C traceparent, so our spans join promptfoo's trace.
instrumentations: [new HttpInstrumentation()],
});
sdk.start();// src/agent/traced-tool.ts — every tool call in the agent loop goes through here.
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('erp-helpdesk-agent');
export function tracedTool<T>(
name: string,
args: Record<string, unknown>,
run: () => Promise<T>,
): Promise<T> {
// startActiveSpan parents this span under the request span that
// HttpInstrumentation created from promptfoo's traceparent.
return tracer.startActiveSpan(`execute_tool ${name}`, async (span) => {
// These two keys are what trajectory:tool-used and tool-args-match read.
span.setAttribute('tool.name', name);
span.setAttribute('tool.arguments', JSON.stringify(args));
try {
const result = await run();
// Truncate: an invoice with 400 lines should not become a 2 MB attribute.
span.setAttribute('tool.output', JSON.stringify(result).slice(0, 2000));
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
// Usage inside the agent loop, for each tool call the model requests:
// const invoice = await tracedTool('get_invoice', call.args, () =>
// this.erp.getInvoice(call.args.invoice_no as string),
// );Gunakan simple span processor saat mengekspor ke receiver eval. Batching processor menahan span di memori dan mengirimnya belakangan, sehingga HTTP response bisa sampai ke promptfoo lebih dulu daripada span tool, dan run yang sebenarnya benar gagal di semua assertion trajectory. Di production, batching processor tetap pilihan yang tepat.
Argumen tool sering berisi nama customer dan nominal. Receiver promptfoo menerima daftar redactAttributes yang mengganti nilai atribut yang cocok sebelum disimpan, serta pengaturan retentionDays untuk memangkas trace lama. Dokumentasinya memperingatkan bahwa pola yang pendek terlalu luas cakupannya, karena key token juga cocok dengan gen_ai.usage.input_tokens, jadi tulis nama key atribut yang persis ingin disembunyikan.
Setelah span mengalir, config mengarahkan HTTP provider ke endpoint NestJS, mengaktifkan tracing dengan OTLP receiver bawaan di port default 4318, lalu membuat assertion atas jalurnya. Keluarga assertion-nya adalah trajectory:tool-used, trajectory:tool-args-match, trajectory:tool-sequence, trajectory:step-count, dan trajectory:goal-success yang dinilai oleh model.
# promptfooconfig.yaml
description: ERP helpdesk agent — trajectory evals
tracing:
enabled: true
failOnReceiverStartFailure: true # no receiver means no trajectories: fail loudly
otlp:
http:
redactAttributes: ['authorization', 'customer_npwp']
storage:
type: sqlite
retentionDays: 14
prompts:
- '{{query}}'
providers:
- id: http
config:
url: http://localhost:3000/agent/run
method: POST
headers:
Content-Type: application/json
body:
message: '{{query}}'
transformResponse: json.reply
defaultTest:
assert:
# Policy for every request: the agent drafts, a human posts.
- type: not-trajectory:tool-used
value: post_credit_note
# Loop guard: more than 12 tool steps is a regression even if the answer is right.
- type: trajectory:step-count
value:
type: tool
max: 12
tests:
- file://evals/helpdesk/*.yamlBeberapa opsi mengerjakan sebagian besar tugas. tool-used menerima satu nama, daftar nama, atau pola glob dengan jumlah min dan max. tool-args-match default-nya mode partial, di mana properti yang diharapkan dicocokkan secara rekursif sebagai subset; mode exact menolak argumen tambahan apa pun, dan daftar defaults serta ignore membuat mode exact bisa menoleransi default pagination dan id yang selalu berubah seperti idempotency key. tool-sequence default-nya in_order, yang mengizinkan pemanggilan lain di antara langkah yang diharapkan, sedangkan exact mensyaratkan urutan di trace sama persis langkah demi langkah. Setiap assertion promptfoo bisa dinegasikan dengan prefix not-, dan begitulah tool terlarang diekspresikan.
Letakkan aturan yang berlaku untuk semua request di defaultTest, jangan diulang per kasus. Tidak ada tool posting, tidak ada operasi tulis sebelum operasi baca yang sesuai, dan batas jumlah langkah adalah kebijakan, bukan test case, dan mendeklarasikannya sekali berarti kasus baru tidak mungkin lupa.
trajectory:goal-success dinilai oleh model dan pada contoh di dokumentasi menerima provider secara eksplisit. Beri tujuan yang ditulis sebagai hasil yang bisa diverifikasi grader, misalnya customer dan invoice mana yang harus dirujuk draft, bukan instruksi samar untuk bersikap membantu. Karena ini penilaian model, anggap kegagalan di sini sebagai sinyal untuk membaca trace, dan biarkan cek deterministik yang memblokir merge.
Aturan yang tidak bisa diekspresikan assertion bawaan masuk ke assertion javascript, yang menerima trace sebagai context.trace.spans. Mekanisme yang sama menangani dimensi biaya: jumlahkan pemakaian token di semua span dan gagalkan run yang melewati batas. Batas di bawah hanya contoh yang perlu dikalibrasi dari baseline run Anda sendiri, bukan rekomendasi.
# Appended to defaultTest.assert in promptfooconfig.yaml
# Goal success: model-graded, so it reports; the deterministic checks block.
- type: trajectory:goal-success
value: >-
Create a credit note DRAFT for the customer and invoice named in the
request, for the damaged quantity only, and tell the user the draft
number without claiming it has been posted.
provider: openai:gpt-6-luna
metric: goal_success
# Business rule for every case: no draft without having fetched the
# invoice it credits. Per-case tool-sequence checks then pin the order.
- type: javascript
value: |
const names = context.trace.spans
.filter((s) => s.attributes['tool.name'])
.map((s) => s.attributes['tool.name']);
if (!names.includes('create_credit_note_draft')) return true;
return names.includes('get_invoice');
# Cost ceiling from our own GenAI spans. 40k is an example: set it from
# the p95 of your baseline runs, then tighten.
- type: javascript
value: |
const used = context.trace.spans.reduce((total, s) =>
total
+ Number(s.attributes['gen_ai.usage.input_tokens'] ?? 0)
+ Number(s.attributes['gen_ai.usage.output_tokens'] ?? 0), 0);
return used <= 40000;promptfoo juga punya assertion cost, tetapi dokumentasinya mencatat assertion itu butuh provider yang mengembalikan informasi biaya, dan HTTP target biasa umumnya tidak melakukannya. Menghitung token dari span sendiri berfungsi untuk backend apa pun, dan cek step-count memberi sinyal kedua yang tidak bergantung pada model untuk mendeteksi loop.
promptfoo bukan satu-satunya cara menilai trajectory. OpenAI dan Google sama-sama menyediakan evaluasi yang memahami trajectory, masing-masing terikat ke stack agent miliknya. Perbedaannya lebih penting daripada yang terlihat dari daftar fitur.
| Opsi | Tempat cek disimpan | Pencocokan trajectory | Paling cocok untuk |
|---|---|---|---|
| Assertion trajectory promptfoo | YAML di repository, dijalankan dari CLI di CI | tool-used, tool-args-match mode partial atau exact, tool-sequence mode in_order atau exact, step-count, goal-success yang dinilai model | Stack apa pun yang mengirim span OpenTelemetry, termasuk agent NestJS buatan sendiri |
| OpenAI trace grading | Dashboard OpenAI di Logs, Traces, lalu Grade all ke dashboard evaluasi | Grader yang diterapkan ke trace dari aplikasi Agents SDK; opsi run mencakup model, rentang tanggal, dan tool call | Tim yang sudah melakukan tracing lewat OpenAI Agents SDK dan ingin trace yang dinilai tanpa tooling baru |
| Evaluasi Google ADK | File test.json atau evalset.json, dijalankan dengan adk eval, pytest, atau UI adk web | tool_trajectory_avg_score dengan EXACT, IN_ORDER, atau ANY_ORDER; tiap invocation bernilai 1.0 atau 0.0 lalu dirata-rata per kasus, threshold default 1.0 | Agent yang dibangun dengan ADK, terutama flow multi-turn yang memakai user simulation |
Dua catatan mengubah pilihan. Dokumentasi graders OpenAI menyatakan bahwa OpenAI sedang men-deprecate graders sebagai bagian dari workflow evals dan fine-tuning yang didukungnya, dan merujuk ke halaman deprecations untuk jadwalnya, jadi suite eval yang hanya ada di dashboard adalah migrasi yang tinggal menunggu waktu. Dokumentasi ADK mencatat bahwa tool_trajectory_avg_score, response_match_score, dan final_response_match_v2 tidak didukung bersama user simulation, sehingga run multi-turn yang disimulasikan beralih ke kriteria berbasis rubrik seperti rubric_based_tool_use_quality_v1. Cek yang tinggal di repository Anda, terhadap span yang Anda kirim sendiri, adalah yang bertahan ketika vendor berubah.
Jangan jalankan eval terhadap tool tulis yang live. Arahkan agent NestJS ke ERP staging atau lapisan tool yang di-stub, karena eval trajectory yang membuat credit note sungguhan adalah insiden production yang kebetulan disertai laporan test.
Aturan yang perlu dibawa: agent dinilai dari apa yang ia lakukan, bukan dari apa yang ia katakan. Jadikan setiap tool call sebagai span, ubah run yang pernah salah menjadi trajectory yang dibekukan, cek pemilihan tool, argumen, dan urutan secara deterministik, simpan model grader untuk keberhasilan tujuan, dan pasang batas untuk langkah dan token. Jawaban akhir tetap penting, tetapi itu hal terakhir yang dicek, bukan satu-satunya.
Sumber