Agen kamu oke pas demo. Terus kamu ubah satu baris system prompt, deploy, dan dua hari kemudian ada customer yang dapet refund buat order yang nggak pernah dicek. Nggak ada yang sadar sampai laporan keuangan turun.
Ini pola kegagalan standar aplikasi LLM. Output-nya nondeterministik, jadi test biasa buat kode normal nggak bisa langsung dipake. Kamu nggak bisa assert string persis yang dikeluarin model. Yang bisa kamu assert adalah perilakunya: tool mana yang dipanggil, dengan argumen apa, urutannya gimana, dan apakah jawaban akhirnya memenuhi rubrik. Itu namanya eval.
Ada juga alasan timing buat bikin harness sendiri. OpenAI lagi deprecate platform Evals hosted mereka: eval yang ada bakal jadi read-only tanggal 31 Oktober 2026, dan platform-nya dijadwalkan mati 30 November 2026, sesuai halaman deprecation di dokumen mereka. Harness pytest jalan di mesin kamu, di CI kamu, dan nggak bergantung roadmap vendor.
Prerequisites
- Python 3.10+ (pytest 9.x butuh itu)
pip install pytest openaiOPENAI_API_KEYbuat live run. Harness ini juga bisa jalan full offline pake stub agent, jadi CI nggak perlu key.- Sekitar 30 menit
Versi yang dicek 7 Agustus 2026: pytest 9.1.1, openai 2.53.0.
Langkah 1: Golden dataset
Golden dataset itu daftar skenario lengkap dengan perilaku yang kamu harapkan. Satu skenario per baris, format JSONL. Jaga field-nya tetap kecil: query, tool yang wajib dipanggil agen, tool yang nggak boleh dipanggil, dan rubrik opsional buat menilai jawaban.
{"id": "ticket-1", "query": "Order ORD-1001 never arrived. I want a refund.", "expect": ["lookup_order", "issue_refund"], "never": [], "rubric": "The reply must name the order id and the refund amount, and must not promise anything beyond the refund."}
{"id": "ticket-2", "query": "Where is my order ORD-1002?", "expect": ["lookup_order"], "never": ["issue_refund"], "rubric": "The reply must report the order status without promising a refund."}
{"id": "ticket-3", "query": "Can I get a refund?", "expect": [], "never": ["issue_refund"], "rubric": "The reply must ask for the order id before doing anything else."}
{"id": "ticket-4", "query": "I want to speak to a human.", "expect": [], "never": ["lookup_order", "issue_refund"], "rubric": "The reply must offer escalation to a human and must not issue any refund."}
Dataset ini produknya. Kalau ada yang lapor bug di agen kamu, perbaikannya adalah perubahan kode plus dua baris di file ini. Dataset tumbuh tiap ada insiden, dan pertumbuhan itu yang ngubah eval dari demo jadi jaring pengaman.
Langkah 2: Kontrak transcript
Harness nggak peduli framework apa yang dipake agen kamu. Yang dia peduliin cuma satu bentuk: Transcript dengan tiga field.
@dataclass
class ToolCall:
name: str
arguments: dict
@dataclass
class Transcript:
query: str
final_answer: str
tool_calls: list[ToolCall]
Wrapper kamu yang ngonversi apa pun yang dikembalikan framework ke bentuk ini. Buat loop tool calling OpenAI, ngikutin pola function calling di dokumen mereka, jadinya begini:
def run_agent(client, query, model="gpt-5.6", max_turns=6):
messages = [{"role": "user", "content": query}]
calls = []
for _ in range(max_turns):
completion = client.chat.completions.create(model=model, messages=messages, tools=TOOLS)
msg = completion.choices[0].message
if msg.tool_calls:
messages.append(msg.model_dump()) # assistant message with tool_calls
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments or "{}")
calls.append(ToolCall(name=tc.function.name, arguments=args))
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": str(execute_tool(tc.function.name, args)),
})
else:
return Transcript(query=query, final_answer=msg.content or "", tool_calls=calls)
return Transcript(query=query, final_answer="", tool_calls=calls)
Loop-nya sesuai yang didokumentasikan: append pesan assistant beserta tool_calls-nya, jalanin tiap tool, append hasilnya, ulang. Kalau agen kamu dibangun pake Claude atau LangGraph, kamu nulis wrapper yang sama sekali buat SDK mereka, dan file lain di harness ini tetap identik.
Langkah 3: Cek deterministik buat perilaku tool
Kebanyakan bug agen ada di perilaku tool, bukan di kalimatnya. Refund yang dikeluarin tanpa lookup itu bug, mau jawabannya dibaca enak atau nggak. Nilai jalur itu pake Python polos, tanpa model:
def tool_called(t, name):
return any(tc.name == name for tc in t.tool_calls)
def tool_called_with(t, name, **expected):
for tc in t.tool_calls:
if tc.name == name and all(tc.arguments.get(k) == v for k, v in expected.items()):
return True
return False
Cek deterministik itu gratis, cepat, dan nggak pernah flaky. Mereka harus nutupin sebagian besar dataset. Simpen model judge buat bagian kecil yang beneran butuh penilaian.
Langkah 4: Suite pytest
Parametrize di atas dataset biar tiap skenario jalan di semua cek. pytest.ini kecil bikin import jalan dari root project:
[pytest]
pythonpath = .
testpaths = tests
File test-nya sengaja dibuat membosankan:
@pytest.mark.parametrize("item", GOLDEN, ids=lambda i: i["id"])
def test_expected_tools_called(agent, item):
t = agent(item["query"])
for name in item["expect"]:
assert tool_called(t, name), f"{item['id']}: expected tool {name} to be called"
@pytest.mark.parametrize("item", GOLDEN, ids=lambda i: i["id"])
def test_guardrail_tools_never_called(agent, item):
t = agent(item["query"])
for name in item["never"]:
assert not tool_called(t, name), f"{item['id']}: tool {name} must never be called"
conftest.py nuker agen asli dengan stub deterministik kecuali AGENT_EVAL_LIVE=1 diset. Hasilnya dua mode jalan:
$ pytest -q # stub agent, offline, gratis
17 passed in 0.01s
$ AGENT_EVAL_LIVE=1 pytest -q # model beneran, butuh OPENAI_API_KEY
CI jalanin mode offline tiap push. Mode live jalan pas kamu beneran ngubah prompt.
Langkah 5: Model judge buat jawaban terbuka
Ada cek yang butuh penilaian. "Jawabannya harus nyebutin order id dan nominal refund, dan nggak boleh janji apa pun di luar refund" nggak bisa dibandingin string. Nilai itu pake panggilan model kedua plus rubrik:
JUDGE_PROMPT = """You are grading a support agent's final reply to a customer.
Rubric (be strict):
{rubric}
Customer query: {query}
Agent reply: {answer}
Return JSON with exactly two keys: "score" (integer 1-5) and "reason" (one sentence)."""
def llm_judge(client, query, answer, rubric, model="gpt-5.6"):
completion = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": JUDGE_PROMPT.format(
rubric=rubric, query=query, answer=answer)}],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(completion.choices[0].message.content)
Postingan "Building effective agents" dari Anthropic nyebutin automasi eval sebagai pola kelas satu: tiap panggilan LLM ngevaluasi aspek yang beda dari performa model. Ini pola itu yang diterapin ke satu jawaban.
Tiga aturan biar judge-nya jujur. Pake model yang beda dari agen, idealnya yang lebih murah: judge yang berbagi bobot dengan agen biasanya ikut berbagi titik butanya. Set temperature ke 0. Cache hasil judge dengan key rubrik plus jawaban, karena kalau nggak, jawaban yang sama dinilai ulang tiap run.
Format json_object jalan di gpt-3.5-turbo, gpt-4, gpt-4o, dan model GPT-5 yang kompatibel. Dokumen mereka nyaranin pindah ke structured outputs (response_format dengan json_schema) kalau model kamu dukung, karena mode itu maksa output patuh ke skema, bukan cuma berharap model nurutin instruksi.
Langkah 6: Workflow yang nangkep regresi
Ini intinya. Misal ada yang ngubah system prompt dan agen mulai ngeluarin refund tanpa lookup. Suite langsung merah:
$ pytest -q
F....................... [100%]
FAILED tests/test_support_agent.py::test_guardrail_tools_never_called[ticket-3]
AssertionError: ticket-3: tool issue_refund must never be called
1 failed, 16 passed in 0.03s
Satu baris langsung ngasih tahu skenario mana yang rusak dan guardrail mana yang kena. Kamu benerin prompt, jalanin ulang, suite hijau lagi. Ubah sesuatu, jalanin suite, langsung tahu perilaku berubah atau nggak. Loop itu seluruh nilai harness ini.
Langkah 7: Taruh di CI, jaga biaya
- Jalanin suite offline di tiap pull request. Gratis, dan nangkep typo dataset plus harness yang rusak.
- Jalanin suite live terjadwal, atau pas prompt beneran berubah.
- Panggilan judge satu-satunya biaya nyata. Dua puluh sampai lima puluh baris golden udah cukup buat mulai. Kalau set-nya makin gede, sampling test judge dan jaga cek deterministik tetap full coverage.
Kapan bikin sendiri vs pake tool
- DIY harness pytest (artikel ini): kontrol penuh, nggak ada vendor lock, jalan offline. Kamu yang maintain grader-nya.
- DeepEval: baterai lengkap.
pip install -U deepevalngasih puluhan metric siap pake (G-Eval, RAG faithfulness, dan lainnya). Bagus kalau kamu mau metric cepat dan nerima dependensi. Hampir semua metric-nya LLM-as-judge di balik layar, jadi cerita biayanya sama. - Platform Evals OpenAI: hosted dengan dashboard, tapi lagi di-deprecate. Jangan mulai kerjaan baru di atasnya.
- LangSmith: kuat kalau agen kamu udah full di ekosistem LangChain atau LangGraph. Kalau nggak, dia narik seluruh stack kamu ke platform-nya.
Mulai dari harness. Library bisa ditambah belakangan, dan pas itu, golden dataset pindah tanpa berubah.
Kesimpulan
Demo ngasih tahu kamu agen-nya jalan. Eval ngasih tahu kamu agen-nya masih jalan setelah ada yang berubah. Itu bedanya antara agen yang bisa kamu iterasi dan agen yang kamu takut sentuh.
Langkah selanjutnya:
- Tambah satu golden case buat tiap bug yang kamu benerin. Dataset itu catatan insiden kamu dalam bentuk yang bisa dieksekusi.
- Mulai dari jalur uang: refund, penghapusan data, eskalasi. Test judge buat lima skenario customer teratas, cek deterministik buat sisanya.
- Pantau pass rate dari waktu ke waktu. Kalau angkanya nggak pernah gerak pas prompt diubah, kamu jalanin test-nya sebagai ritual, bukan sebagai gerbang.