第 9 章 AI AgentsLangChainLangGraph

第 9 章 定制化与高级评估

AgentVista

#@title  Clone AgentVista and install dependencies
!git clone https://github.com/Nicolepcx/AgentVista.git
%cd AgentVista

!pip -q install --upgrade pip setuptools wheel
!pip -q install -r requirements.txt
!pip -q install huggingface_hub pyarrow pillow
# @title Download AgentVista dataset from Hugging Face
import os
from huggingface_hub import snapshot_download

DATASET_DIR = "/content/datasets"

snapshot_download(
    repo_id="Warrieryes/AgentVista",
    repo_type="dataset",
    local_dir=DATASET_DIR,
    local_dir_use_symlinks=False,
)

print("Dataset downloaded to:", DATASET_DIR)
print("Top level files:", os.listdir(DATASET_DIR)[:20])
/usr/local/lib/python3.12/dist-packages/huggingface_hub/file_download.py:982: UserWarning: `local_dir_use_symlinks` parameter is deprecated and will be ignored. The process to download files to a local folder has been updated and do not rely on symlinks anymore. You only need to pass a destination folder as`local_dir`.
For more details, check out https://huggingface.co/docs/huggingface_hub/main/en/guides/download#download-files-to-local-folder.
  warnings.warn(
Fetching 4 files:   0%|          | 0/4 [00:00
data/train-00001-of-00002.parquet:   0%|          | 0.00/244M [00:00
data/train-00000-of-00002.parquet:   0%|          | 0.00/294M [00:00
README.md: 0.00B [00:00, ?B/s]
.gitattributes: 0.00B [00:00, ?B/s]
Dataset downloaded to: /content/datasets
Top level files: ['.cache', '.gitattributes', 'data', 'README.md']
# @title Enter API keys
import os
from getpass import getpass

# Harness LLM judge (AgentVista accuracy_score): after inference, compares model output to ground truth.
# Required env vars (see AgentVista-main/utils/general_qa_tool.py):
#   VERIFIER_API_KEY   — same OpenRouter key is fine (set below).
#   VERIFIER_END_POINT — OpenRouter chat-completions URL (set below; also set again in the next cell).
#   VERIFIER_MODEL_NAME — optional; defaults to gpt-4o in the harness if unset; we set an OpenRouter slug below.
OPENROUTER_CHAT = "https://openrouter.ai/api/v1/chat/completions"
os.environ["REASONING_END_POINT"] = OPENROUTER_CHAT
os.environ["VERIFIER_END_POINT"] = OPENROUTER_CHAT
# Cheap, text-only judge on OpenRouter (change if you prefer e.g. openai/gpt-4.1)
os.environ["VERIFIER_MODEL_NAME"] = os.environ.get("VERIFIER_MODEL_NAME", "gpt-5.4-mini")

os.environ["REASONING_API_KEY"] = getpass("OpenRouter API key: ")
os.environ["SERPAPI_KEY"] = getpass("Serper API key: ")

jina_key = getpass("Jina API key (press Enter to skip): ")
os.environ["JINA_API_KEY"] = jina_key if jina_key.strip() else ""

verifier_key = getpass("Verifier API key via OpenRouter (press Enter to reuse reasoning key): ")
os.environ["VERIFIER_API_KEY"] = verifier_key if verifier_key.strip() else os.environ["REASONING_API_KEY"]

print("Keys set.")
print("Harness judge: VERIFIER_END_POINT =", os.environ["VERIFIER_END_POINT"])
print("Harness judge: VERIFIER_MODEL_NAME =", os.environ["VERIFIER_MODEL_NAME"])
OpenRouter API key: ··········
Serper API key: ··········
Jina API key (press Enter to skip): ··········
Verifier API key via OpenRouter (press Enter to reuse reasoning key): ··········
Keys set.
Harness judge: VERIFIER_END_POINT = https://openrouter.ai/api/v1/chat/completions
Harness judge: VERIFIER_MODEL_NAME = gpt-5.4-mini
# @title Configure OpenRouter for multi model evaluation
import os

OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"

MODELS = [
    "openai/gpt-5.4",
    "qwen/qwen3.5-35b-a3b",
    "google/gemini-3.1-pro-preview",
]

os.environ["REASONING_END_POINT"] = OPENROUTER_ENDPOINT
os.environ["VERIFIER_END_POINT"] = OPENROUTER_ENDPOINT
# If you skipped cell 3, still pick a judge model for OpenRouter (text-only scoring).
os.environ.setdefault("VERIFIER_MODEL_NAME", "openai/gpt-5.4-mini")
os.environ["ENABLED_TOOLS"] = "web_search,image_search,visit,code_interpreter"

print("OpenRouter endpoint:", OPENROUTER_ENDPOINT)
print("Harness judge model (VERIFIER_MODEL_NAME):", os.environ.get("VERIFIER_MODEL_NAME"))
print("Enabled tools:", os.environ["ENABLED_TOOLS"])
print("\nModels selected for comparison:")
for m in MODELS:
    print(" ", m)
OpenRouter endpoint: https://openrouter.ai/api/v1/chat/completions
Harness judge model (VERIFIER_MODEL_NAME): gpt-5.4-mini
Enabled tools: web_search,image_search,visit,code_interpreter

Models selected for comparison:
  openai/gpt-5.4
  qwen/qwen3.5-35b-a3b
  google/gemini-3.1-pro-preview
# @title Load parquet dataset and export embedded images safely
import os
import io
import json
import pandas as pd
import numpy as np
from glob import glob
from PIL import Image

DATASET_DIR = "/content/datasets"
EXPORT_DIR = os.path.join(DATASET_DIR, "exported_images")
os.makedirs(EXPORT_DIR, exist_ok=True)

parquet_files = glob(os.path.join(DATASET_DIR, "**", "*.parquet"), recursive=True)

if not parquet_files:
    raise FileNotFoundError("No parquet files found in dataset directory.")

print("Found parquet files:", parquet_files)

dfs = [pd.read_parquet(p) for p in parquet_files]
df = pd.concat(dfs, ignore_index=True)

print("Total samples loaded:", len(df))
print("Columns:", df.columns.tolist())


def unwrap_object(x):
    """
    Recursively unwrap numpy object arrays, singleton tuples, and nested containers.
    """
    while True:
        if isinstance(x, np.ndarray):
            if x.dtype == object:
                if x.size == 1:
                    x = x.item()
                    continue
                return x.tolist()
            return x

        if isinstance(x, tuple):
            if len(x) == 1:
                x = x[0]
                continue
            return list(x)

        if isinstance(x, list):
            if len(x) == 1 and not isinstance(x[0], (str, bytes, bytearray, dict)):
                x = x[0]
                continue
            return x

        return x


def save_one_image(img_obj, out_path_base):
    """
    Save one image object to disk and return its absolute path.
    Supports bytes, dicts, PIL images, and numeric numpy arrays.
    """
    img_obj = unwrap_object(img_obj)

    if isinstance(img_obj, (bytes, bytearray)):
        img = Image.open(io.BytesIO(img_obj)).convert("RGB")
        out_path = f"{out_path_base}.png"
        img.save(out_path)
        return out_path

    if isinstance(img_obj, dict):
        if "bytes" in img_obj and img_obj["bytes"] is not None:
            img = Image.open(io.BytesIO(img_obj["bytes"])).convert("RGB")
            out_path = f"{out_path_base}.png"
            img.save(out_path)
            return out_path
        if "path" in img_obj and img_obj["path"]:
            return img_obj["path"]

    if isinstance(img_obj, Image.Image):
        out_path = f"{out_path_base}.png"
        img_obj.convert("RGB").save(out_path)
        return out_path

    if isinstance(img_obj, np.ndarray):
        if img_obj.dtype == object:
            raise TypeError(f"Still received object ndarray after unwrap: shape={img_obj.shape}")
        img = Image.fromarray(img_obj)
        out_path = f"{out_path_base}.png"
        img.save(out_path)
        return out_path

    raise TypeError(f"Unsupported image object type after unwrap: {type(img_obj)}")


def normalize_images(images_field, sample_idx):
    """
    Convert the parquet images field into a list of relative image paths.
    """
    images_field = unwrap_object(images_field)

    if isinstance(images_field, (bytes, bytearray, dict, Image.Image, np.ndarray)):
        images_field = [images_field]

    if isinstance(images_field, tuple):
        images_field = list(images_field)

    if not isinstance(images_field, list):
        raise TypeError(f"Unexpected images field type: {type(images_field)}")

    rel_paths = []
    for j, img_obj in enumerate(images_field):
        out_base = os.path.join(EXPORT_DIR, f"sample_{sample_idx}_img_{j}")
        saved_path = save_one_image(img_obj, out_base)
        rel_path = os.path.relpath(saved_path, DATASET_DIR)
        rel_paths.append(rel_path)

    return rel_paths


print("\nExample domains:")
print(sorted(df["domain"].dropna().unique().tolist()))
Found parquet files: ['/content/datasets/data/train-00000-of-00002.parquet', '/content/datasets/data/train-00001-of-00002.parquet']
Total samples loaded: 209
Columns: ['images', 'problem', 'answer', 'domain', 'subdomain']

Example domains:
['academics', 'commerce', 'culture', 'entertainment', 'geography', 'society', 'technology']
# @title Create a stratified AgentVista subset for side by side model comparison
import os
import json

DOMAINS = [
    "commerce",
    "entertainment",
    "technology",
]

SAMPLES_PER_DOMAIN = 5
STRATIFIED_VAL_PATH = os.path.join(DATASET_DIR, "val_domain_smoke_test.json")

subset = []
sample_counter = 0

for domain in DOMAINS:
    domain_df = df[df["domain"] == domain].copy()
    if len(domain_df) == 0:
        continue

    take = min(SAMPLES_PER_DOMAIN, len(domain_df))
    sampled = domain_df.sample(n=take, random_state=42)

    for _, row in sampled.iterrows():
        image_paths = normalize_images(row["images"], sample_counter)

        subset.append({
            "question_id": f"sample_{sample_counter}",
            "question": row["problem"],
            "problem": row["problem"],
            "images": image_paths,
            "solution": row["answer"],
            "answer": row["answer"],
            "domain": row["domain"],
            "subdomain": row["subdomain"],
        })
        sample_counter += 1

with open(STRATIFIED_VAL_PATH, "w", encoding="utf-8") as f:
    json.dump(subset, f, ensure_ascii=False, indent=2)

print("Saved stratified subset to:", STRATIFIED_VAL_PATH)
print("Number of samples:", len(subset))
print("Domains included:", sorted(set(x["domain"] for x in subset)))
print("\nFirst sample preview:")
print(json.dumps(subset[0], indent=2, ensure_ascii=False)[:1500])
Saved stratified subset to: /content/datasets/val_domain_smoke_test.json
Number of samples: 15
Domains included: ['commerce', 'entertainment', 'technology']

First sample preview:
{
  "question_id": "sample_0",
  "question": "\nIt is now October 2025, and I need to rent office space for a team of 5 employees, and the space must meet the requirements in the local workplace safety regulations regarding the minimum area per employee. Considering the current market rental levels and typical utility costs in this area, which room number has the lowest total monthly cost (rent plus utilities) while meeting the legal space requirements for 5 employees?",
  "problem": "\nIt is now October 2025, and I need to rent office space for a team of 5 employees, and the space must meet the requirements in the local workplace safety regulations regarding the minimum area per employee. Considering the current market rental levels and typical utility costs in this area, which room number has the lowest total monthly cost (rent plus utilities) while meeting the legal space requirements for 5 employees?",
  "images": [
    "exported_images/sample_0_img_0.png"
  ],
  "solution": "Room 28",
  "answer": "Room 28",
  "domain": "commerce",
  "subdomain": "Transaction and Price Calculation"
}
# @title Validate subset structure and exported images
import os
import json
from PIL import Image
import matplotlib.pyplot as plt

with open(STRATIFIED_VAL_PATH, "r", encoding="utf-8") as f:
    subset_records = json.load(f)

print("Loaded subset records:", len(subset_records))

required_fields = ["question_id", "question", "images", "answer", "domain", "subdomain"]
for i, rec in enumerate(subset_records[:5]):
    missing = [k for k in required_fields if k not in rec]
    if missing:
        raise ValueError(f"Sample {i} is missing required fields: {missing}")

print("Schema check passed for first 5 samples.")

missing_images = []
bad_images = []
image_summary = []

for rec in subset_records:
    qid = rec["question_id"]
    for img_rel in rec["images"]:
        img_abs = os.path.join(DATASET_DIR, img_rel)

        if not os.path.exists(img_abs):
            missing_images.append((qid, img_rel))
            continue

        try:
            with Image.open(img_abs) as img:
                width, height = img.size
                image_summary.append({
                    "question_id": qid,
                    "image": img_rel,
                    "format": img.format,
                    "mode": img.mode,
                    "width": width,
                    "height": height,
                })
        except Exception as e:
            bad_images.append((qid, img_rel, str(e)))

print("Total exported images checked:", len(image_summary))
print("Missing images:", len(missing_images))
print("Unreadable images:", len(bad_images))

if missing_images[:5]:
    print("\nExample missing images:")
    for x in missing_images[:5]:
        print(x)

if bad_images[:5]:
    print("\nExample unreadable images:")
    for x in bad_images[:5]:
        print(x)

if missing_images or bad_images:
    raise ValueError("Image validation failed. Fix paths or exports before continuing.")

print("\nImage validation passed.")

# Show a few examples visually
n_show = min(4, len(subset_records))
fig, axes = plt.subplots(n_show, 1, figsize=(10, 4 * n_show))
if n_show == 1:
    axes = [axes]

for ax, rec in zip(axes, subset_records[:n_show]):
    first_img = rec["images"][0]
    img_path = os.path.join(DATASET_DIR, first_img)
    img = Image.open(img_path)
    ax.imshow(img)
    ax.set_title(f'{rec["question_id"]} | {rec["domain"]} | {rec["subdomain"]}\n{rec["question"][:120]}')
    ax.axis("off")

plt.tight_layout()
plt.show()
Loaded subset records: 15
Schema check passed for first 5 samples.
Total exported images checked: 18
Missing images: 0
Unreadable images: 0

Image validation passed.

ch00-img.png

# @title Tiny sanity check on 2 samples with one model before the full run
import os
import json
import subprocess

SANITY_PATH = os.path.join(DATASET_DIR, "val_sanity_check.json")

with open(STRATIFIED_VAL_PATH, "r", encoding="utf-8") as f:
    full_subset = json.load(f)

sanity_subset = full_subset[:2]

with open(SANITY_PATH, "w", encoding="utf-8") as f:
    json.dump(sanity_subset, f, ensure_ascii=False, indent=2)

sanity_model = MODELS[0]
sanity_output_dir = "/content/agentvista_sanity_run"

os.makedirs(sanity_output_dir, exist_ok=True)

env = os.environ.copy()
env["REASONING_MODEL_NAME"] = sanity_model
env["VERIFIER_MODEL_NAME"] = sanity_model

cmd = [
    "python", "infer.py",
    "--input-file", SANITY_PATH,
    "--image-folder", DATASET_DIR,
    "--output-dir", sanity_output_dir,
    "--max-turns", "8",
    "--max-images", "10",
    "--max-total-tokens", "16000",
    "--skip-completed",
]

print("Running sanity check model:", sanity_model)
result = subprocess.run(cmd, env=env, text=True, capture_output=True)

print(result.stdout[-4000:])
if result.returncode != 0:
    print("\nError output:")
    print(result.stderr[-4000:])
Running sanity check model: openai/gpt-5.4
ck
[Visit] Fallback to Jina API after other methods failed...
[Visit] Jina API request failed (attempt 1/3): HTTPSConnectionPool(host='r.jina.ai', port=443): Read timed out. (read timeout=20)
[Visit] Jina API request failed (attempt 2/3): HTTPSConnectionPool(host='r.jina.ai', port=443): Read timed out. (read timeout=20)
[Visit] Jina API request failed (attempt 3/3): HTTPSConnectionPool(host='r.jina.ai', port=443): Read timed out. (read timeout=20)
[Image Processing] Skipping image extraction for visit (returns text summary, images should not be extracted)
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 10
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=I’ll first extract the relevant room areas from the floor plan and identify which rooms could legall...
  ... and 7 more messages
[Reasoning] Using content field as reasoning_text: 1679 chars
[Tool Call] web_search with params: {'query': 'Тольятти офис коммунальные услуги в среднем 5000 руб 20 м2 октябрь 2025 аренда офис 20 м2 12000 Юбилейная 31', 'max_results': 10}
[Tool Cache] Reusing existing instance of web_search
[WebSearch] Searching for: Тольятти офис коммунальные услуги в среднем 5000 руб 20 м2 октябрь 2025 аренда офис 20 м2 12000 Юбилейная 31 (attempt 1/3)
[WebSearch] Found 10 results
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 12
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=I’ll first extract the relevant room areas from the floor plan and identify which rooms could legall...
  ... and 9 more messages
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 2
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
[Reasoning] Using content field as reasoning_text: 817 chars
[Tool Call] web_search with params: {'query': '2025-12-01 HKD to CNY exchange rate 1 HKD CNY December 1 2025', 'max_results': 5}
[Tool Cache] Created new instance of web_search
[WebSearch] Searching for: 2025-12-01 HKD to CNY exchange rate 1 HKD CNY December 1 2025 (attempt 1/3)
[WebSearch] Found 5 results
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 4
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=I need identify the circled order details and total HKD, then convert using HKD→RMB exchange rate on...
  ... and 1 more messages
Results saved to /content/agentvista_sanity_run/results.jsonl

--- Evaluation Summary ---
Total samples: 2
Overall accuracy: 0.5000
Average turns: 4.00
Summary saved to /content/agentvista_sanity_run/summary_metrics.json
---
# @title Inspect sanity check predictions before full evaluation
import os
import json
import pandas as pd

sanity_results_path = "/content/agentvista_sanity_run/results.jsonl"

sanity_rows = []
if os.path.exists(sanity_results_path):
    with open(sanity_results_path, "r", encoding="utf-8") as f:
        for line in f:
            sanity_rows.append(json.loads(line))

sanity_df = pd.DataFrame(sanity_rows)
print("Sanity result columns:", sanity_df.columns.tolist())

cols = [c for c in ["question_id", "final_answer", "ground_truth", "reasoning_text"] if c in sanity_df.columns]
display(sanity_df[cols].head(10))
Sanity result columns: ['question_id', 'prompt', 'final_answer', 'ground_truth', 'conversation_history', 'accuracy_score', 'trajectory_text', 'trajectory_score', 'trajectory_analysis']
question_id                                       final_answer ground_truth
0    sample_0  Room **28** is the cheapest compliant choice.\...      Room 28
1    sample_1  你一共支付了 **約 54.49 元人民幣**(60 港幣 × 2025年12月1日匯率 1...         54.6
# @title Inspect sanity predictions in detail
import pandas as pd
import json
import os

sanity_results_path = "/content/agentvista_sanity_run/results.jsonl"

rows = []
with open(sanity_results_path, "r", encoding="utf-8") as f:
    for line in f:
        rows.append(json.loads(line))

sanity_df = pd.DataFrame(rows)
display(sanity_df[["question_id", "prompt", "final_answer", "ground_truth", "accuracy_score"]])
question_id                                             prompt  \
0    sample_0  It is now October 2025, and I need to rent off...   
1    sample_1  This is a photo I took on December 1st, 2025, ...   

                                        final_answer ground_truth  \
0  Room **28** is the cheapest compliant choice.\...      Room 28   
1  你一共支付了 **約 54.49 元人民幣**(60 港幣 × 2025年12月1日匯率 1...         54.6   

   accuracy_score  
0             1.0  
1             0.0
# @title Run AgentVista on the same subset for all selected models
import os
import subprocess

INPUT_FILE = STRATIFIED_VAL_PATH
IMAGE_FOLDER = DATASET_DIR
BASE_OUTPUT_DIR = "/content/agentvista_multi_model_runs"

os.makedirs(BASE_OUTPUT_DIR, exist_ok=True)

for model_name in MODELS:
    safe_name = model_name.replace("/", "__")
    output_dir = os.path.join(BASE_OUTPUT_DIR, safe_name)
    os.makedirs(output_dir, exist_ok=True)

    env = os.environ.copy()
    env["REASONING_MODEL_NAME"] = model_name
    env["VERIFIER_MODEL_NAME"] = model_name

    cmd = [
        "python", "infer.py",
        "--input-file", INPUT_FILE,
        "--image-folder", IMAGE_FOLDER,
        "--output-dir", output_dir,
        "--max-turns", "10",
        "--max-images", "20",
        "--max-total-tokens", "24000",
        "--skip-completed",
    ]

    print("\nRunning model:", model_name)
    print("Output directory:", output_dir)

    result = subprocess.run(cmd, env=env, text=True, capture_output=True)

    print(result.stdout[-3000:])
    if result.returncode != 0:
        print("\nError output:")
        print(result.stderr[-3000:])
Running model: openai/gpt-5.4
Output directory: /content/agentvista_multi_model_runs/openai__gpt-5.4
he chart (likely dots.vlm1) and identify benchmark comparison details for OCR, Document, and Chart tasks versus Gemini 2.5 Pro; specifically which benchmark names show the open-source model outperforming Gemini 2.5 Pro.'}
[Tool Cache] Created new instance of visit
[Visit] Fetching URL: https://huggingface.co/spaces/opencompass/open_vlm_leaderboard
[Visit] Goal: Find the top open-source model in the chart (likely dots.vlm1) and identify benchmark comparison details for OCR, Document, and Chart tasks versus Gemini 2.5 Pro; specifically which benchmark names show the open-source model outperforming Gemini 2.5 Pro.
[Visit] Trying Jina API first...
[Visit] Jina API extraction successful: 1044 characters
[Visit] Extracted 1044 characters
[Visit] Calling API to summarize content (model: openai/gpt-5.4)...
[Visit] API summarization successful (evidence: 661 chars, summary: 361 chars)
[Image Processing] Skipping image extraction for visit (returns text summary, images should not be extracted)
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 11
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=I first inspect the provided image. It is a scatter plot of model performance over time, with blue p...
  ... and 8 more messages
[Reasoning] Using content field as reasoning_text: 686 chars
[Tool Call] web_search with params: {'query': 'dots.vlm1 Gemini 2.5 Pro OCR Document Chart benchmark comparison dots.vlm1 benchmark names', 'max_results': 10}
[Tool Cache] Reusing existing instance of web_search
[WebSearch] Searching for: dots.vlm1 Gemini 2.5 Pro OCR Document Chart benchmark comparison dots.vlm1 benchmark names (attempt 1/3)
[WebSearch] Found 10 results
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: openai/gpt-5.4
Messages count: 13
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=I first inspect the provided image. It is a scatter plot of model performance over time, with blue p...
  ... and 10 more messages
Results saved to /content/agentvista_multi_model_runs/openai__gpt-5.4/results.jsonl

--- Evaluation Summary ---
Total samples: 15
Overall accuracy: 0.1333
Average turns: 2.80
Summary saved to /content/agentvista_multi_model_runs/openai__gpt-5.4/summary_metrics.json
------------------------

Inference finished.


Running model: qwen/qwen3.5-35b-a3b
Output directory: /content/agentvista_multi_model_runs/qwen__qwen3.5-35b-a3b
tVQA"'}
[Tool Cache] Reusing existing instance of web_search
[WebSearch] Searching for: "MiMo-VL 7B-RL" "Gemini 2.5 Pro" "DocVQA" "ChartQA" "TextVQA" (attempt 1/3)
[API] No fallback API configured, using primary API with 2 retries
[Primary API] API Request attempt 1:
Model: qwen/qwen3.5-35b-a3b
Messages count: 16
Temperature: 0.0
  Message 0: role=system, content_preview=You are a visual reasoning agent. Your goal is to answer questions about images.

# AVAILABLE TOOLS:...
  Message 1: role=user, content_preview=[{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD...
  Message 2: role=assistant, content_preview=
  ... and 13 more messages
[Reasoning] Preserved reasoning_details in tool call message
[Tool Call] web_search with 
# @title Load and merge outputs from all models
import os
import json
import pandas as pd

all_rows = []

for model_name in MODELS:
    safe_name = model_name.replace("/", "__")
    results_path = os.path.join(BASE_OUTPUT_DIR, safe_name, "results.jsonl")

    if not os.path.exists(results_path):
        print("Missing results for:", model_name)
        continue

    with open(results_path, "r", encoding="utf-8") as f:
        for line in f:
            row = json.loads(line)
            row["model"] = model_name
            all_rows.append(row)

results_df = pd.DataFrame(all_rows)

print("Loaded rows:", len(results_df))
print("Columns:", results_df.columns.tolist())
display(results_df.head(10))
Loaded rows: 45
Columns: ['question_id', 'prompt', 'final_answer', 'ground_truth', 'conversation_history', 'accuracy_score', 'trajectory_text', 'trajectory_score', 'trajectory_analysis', 'model']
question_id                                             prompt  \
0    sample_0  It is now October 2025, and I need to rent off...   
1    sample_1  This is a photo I took on December 1st, 2025, ...   
2    sample_2  I bought the light blue suitcase shown in the ...   
3    sample_3  I'm hosting a Halloween party and need red win...   
4    sample_4  I took this photo at a really cool pop-up stor...   
5    sample_5  How many individual shooting zone units are th...   
6    sample_6  Please observe this group of images, whose bac...   
7    sample_7  I've been tracking this player count graph bec...   
8    sample_8  I want to know the best finishing position tha...   
9    sample_9  Please tell me the names of the current first-...   

                                        final_answer  \
0  Assuming the standard office workplace rule st...   
1  你一共付了 **60港币**。  \n按 **2025年12月1日** 约 **1 港币 =...   
2  You would most likely need to pay **210 RMB** ...   
3  The wine that best matches your criteria is **...   
4  The pop-up appears to be the **MINISO x Disney...   
5                                                 31   
6                                         1, 3, 4, 6   
7  The game is **Monster Hunter Wilds**.\n\nUsing...   
8                                          2nd place   
9  I can’t tell the swimmers’ names from this ima...   

                                      ground_truth  \
0                                          Room 28   
1                                             54.6   
2                                           690RMB   
3      Традиции Абхазии (Traditsii Abkhazii), 6.82   
4   29 days (November 29th to December 28th, 2025)   
5                                               58   
6                                          2, 3, 6   
7                     Monster Hunter Wilds; 59.86%   
8                                                3   
9  First-place: Ryan Held\nLast-place: Drew Kibler   

                                conversation_history  accuracy_score  \
0  [{'role': 'system', 'content': 'You are a visu...             0.0   
1  [{'role': 'system', 'content': 'You are a visu...             1.0   
2  [{'role': 'system', 'content': 'You are a visu...             0.0   
3  [{'role': 'system', 'content': 'You are a visu...             0.0   
4  [{'role': 'system', 'content': 'You are a visu...             0.0   
5  [{'role': 'system', 'content': 'You are a visu...             0.0   
6  [{'role': 'system', 'content': 'You are a visu...             0.0   
7  [{'role': 'system', 'content': 'You are a visu...             0.0   
8  [{'role': 'system', 'content': 'You are a visu...             0.0   
9  [{'role': 'system', 'content': 'You are a visu...             0.0   

                                     trajectory_text  trajectory_score  \
0  **system**: You are a visual reasoning agent. ...               0.0   
1  **system**: You are a visual reasoning agent. ...               1.0   
2  **system**: You are a visual reasoning agent. ...               0.0   
3  **system**: You are a visual reasoning agent. ...               0.0   
4  **system**: You are a visual reasoning agent. ...               0.0   
5  **system**: You are a visual reasoning agent. ...               0.0   
6  **system**: You are a visual reasoning agent. ...               0.0   
7  **system**: You are a visual reasoning agent. ...               0.0   
8  **system**: You are a visual reasoning agent. ...               0.0   
9  **system**: You are a visual reasoning agent. ...               0.0   

                                 trajectory_analysis           model  
0  the ground truth answer is room 28, but the mo...  openai/gpt-5.4  
1  the predicted answer computes a total of about...  openai/gpt-5.4  
2  the ground truth is 690 rmb, but the model pre...  openai/gpt-5.4  
3  the predicted answer identifies the correct wi...  openai/gpt-5.4  
4  the predicted answer matches the start and end...  openai/gpt-5.4  
5  the 
# @title Standardize result fields, recover domain, and compute correctness
import os
import json
import re
import pandas as pd

def find_first_existing(df, candidates):
    for c in candidates:
        if c in df.columns:
            return c
    return None

pred_col = find_first_existing(results_df, [
    "prediction", "pred", "model_answer", "final_answer", "response"
])

gold_col = find_first_existing(results_df, [
    "solution", "answer", "ground_truth", "gold_answer"
])

qid_col = find_first_existing(results_df, [
    "question_id", "doc_id", "id"
])

domain_col = find_first_existing(results_df, [
    "domain"
])

# Harness column names that may hold LLM-judge scores (AgentVista: accuracy_score).
# Populated only when VERIFIER_API_KEY and VERIFIER_END_POINT are set (VERIFIER_MODEL_NAME
# selects the judge on OpenRouter; see cells 3–4). See AgentVista-main/README.md.
# Without API key + endpoint, harness accuracy_score stays 0 for every row.
score_col = find_first_existing(results_df, [
    "accuracy_score", "score", "is_correct", "correct", "accuracy"
])

verifier_configured = bool(
    os.environ.get("VERIFIER_API_KEY") and os.environ.get("VERIFIER_END_POINT")
)

print("Prediction column:", pred_col)
print("Gold column:", gold_col)
print("Question id column:", qid_col)
print("Domain column from results:", domain_col)
print("Score column:", score_col)

if pred_col is None:
    raise ValueError("Could not find a prediction column in results.jsonl.")

if gold_col is None:
    raise ValueError("Could not find a ground truth column in results.jsonl.")

if qid_col is None:
    raise ValueError("Could not find a question id column in results.jsonl.")


def normalize_text(x):
    if x is None:
        return ""
    x = str(x).strip().lower()
    x = re.sub(r"\s+", " ", x)
    return x


with open(STRATIFIED_VAL_PATH, "r", encoding="utf-8") as f:
    subset_records = json.load(f)

subset_meta = pd.DataFrame(subset_records)[["question_id", "domain", "subdomain", "question"]]

if domain_col is None:
    results_df = results_df.merge(
        subset_meta,
        left_on=qid_col,
        right_on="question_id",
        how="left"
    )
    domain_col = "domain"

results_df["pred_norm"] = results_df[pred_col].apply(normalize_text)
results_df["gold_norm"] = results_df[gold_col].apply(normalize_text)

results_df["accuracy_binary_exact"] = (
    results_df["pred_norm"] == results_df["gold_norm"]
).astype(float)

if verifier_configured and score_col is not None:
    results_df["accuracy_binary"] = results_df[score_col].astype(float)
    print(
        "Using harness accuracy_score (verifier configured). "
        "Column accuracy_binary_exact is normalized string exact match for comparison."
    )
else:
    results_df["accuracy_binary"] = results_df["accuracy_binary_exact"]
    if score_col is not None and not verifier_configured:
        print(
            "Verifier env vars not set; accuracy_binary uses normalized exact match "
            "(raw harness accuracy_score is 0 without VERIFIER_API_KEY / VERIFIER_END_POINT)."
        )

# Optional relaxed diagnostic metric
results_df["contains_match"] = results_df.apply(
    lambda r: float(
        r["gold_norm"] in r["pred_norm"] or r["pred_norm"] in r["gold_norm"]
        if r["pred_norm"] and r["gold_norm"] else 0.0
    ),
    axis=1
)

print("\nColumns after recovery:")
print(results_df.columns.tolist())

display(
    results_df[
        [
            c
            for c in [
                qid_col,
                "model",
                domain_col,
                pred_col,
                gold_col,
                "accuracy_binary",
                "accuracy_binary_exact",
                "contains_match",
            ]
            if c in results_df.columns
        ]
    ].head(20)
)
Prediction column: final_answer
Gold column: ground_truth
Question id column: question_id
Domain column from results: None
Score column: accuracy_score
Using harness accuracy_score (verifier configured). Column accuracy_binary_exact is normalized string exact match for comparison.

Columns after recovery:
['question_id', 'prompt', 'final_answer', 'ground_truth', 'conversation_history', 'accuracy_score', 'trajectory_text', 'trajectory_score', 'trajectory_analysis', 'model', 'domain', 'subdomain', 'question', 'pred_norm', 'gold_norm', 'accuracy_binary_exact', 'accuracy_binary', 'contains_match']
question_id                 model         domain  \
0     sample_0        openai/gpt-5.4       commerce   
1     sample_1        openai/gpt-5.4       commerce   
2     sample_2        openai/gpt-5.4       commerce   
3     sample_3        openai/gpt-5.4       commerce   
4     sample_4        openai/gpt-5.4       commerce   
5     sample_5        openai/gpt-5.4  entertainment   
6     sample_6        openai/gpt-5.4  entertainment   
7     sample_7        openai/gpt-5.4  entertainment   
8     sample_8        openai/gpt-5.4  entertainment   
9     sample_9        openai/gpt-5.4  entertainment   
10   sample_10        openai/gpt-5.4     technology   
11   sample_11        openai/gpt-5.4     technology   
12   sample_12        openai/gpt-5.4     technology   
13   sample_13        openai/gpt-5.4     technology   
14   sample_14        openai/gpt-5.4     technology   
15    sample_0  qwen/qwen3.5-35b-a3b       commerce   
16    sample_1  qwen/qwen3.5-35b-a3b       commerce   
17    sample_2  qwen/qwen3.5-35b-a3b       commerce   
18    sample_3  qwen/qwen3.5-35b-a3b       commerce   
19    sample_4  qwen/qwen3.5-35b-a3b       commerce   

                                         final_answer  \
0   Assuming the standard office workplace rule st...   
1   你一共付了 **60港币**。  \n按 **2025年12月1日** 约 **1 港币 =...   
2   You would most likely need to pay **210 RMB** ...   
3   The wine that best matches your criteria is **...   
4   The pop-up appears to be the **MINISO x Disney...   
5                                                  31   
6                                          1, 3, 4, 6   
7   The game is **Monster Hunter Wilds**.\n\nUsing...   
8                                           2nd place   
9   I can’t tell the swimmers’ names from this ima...   
10  **llava-v1.6-vicuna-7b** has the highest Arena...   
11  The two wear modes visible are:\n\n1. **Smeari...   
12  With the No. 2 and No. 3 gray gears mounted in...   
13                                                 11   
14  The provided scatter plot shows that the top-p...   
15                                            Room 32   
16  Error: All API attempts failed (primary API only)   
17                                            280 RMB   
18  Traditions of Abkhazia (Cabernet Sukhumskoe), ...   
19  Error: All API attempts failed (primary API only)   

                                         ground_truth  accuracy_binary  \
0                                             Room 28              0.0   
1                                                54.6              1.0   
2                                              690RMB              0.0   
3         Традиции Абхазии (Traditsii Abkhazii), 6.82              0.0   
4      29 days (November 29th to December 28th, 2025)              0.0   
5                                                  58              0.0   
6                                             2, 3, 6              0.0   
7                        Monster Hunter Wilds; 59.86%              0.0   
8                                                   3              0.0   
9     First-place: Ryan Held\nLast-place: Drew Kibler              0.0   
10                               Qwen2‑VL‑7B‑Instruct              0.0   
11  Micro-vibration and adhesive wear; they degrad...              0.0   
12                                      1, 3, 2, 4, R              1.0   
13                           23 (or beteen 22 and 24)              0.0   
14                       charxiv(dq), DOCVQA, ChartQA              0.0   
15                                            Room 28              0.0   
16                                               54.6              0.0   
17                                             690RMB              0.0   
18        Традиции Абхазии (Traditsii Abkhazii), 6.82              0.0   
19     29 days (November 29th to December 28th, 2025)              0.0   

    accuracy_binary_exact  contains_match  
0                     0.0             0.0  
1     
# @title Build a side by side comparison table
compare_cols = [c for c in [qid_col, domain_col, gold_col, pred_col] if c in results_df.columns]
sample_view = results_df[["model", "accuracy_binary"] + compare_cols].copy()

pivot_values = ["accuracy_binary"]
if pred_col is not None:
    pivot_values.append(pred_col)

wide_df = sample_view.pivot_table(
    index=[c for c in [qid_col, domain_col, gold_col] if c in sample_view.columns],
    columns="model",
    values=pivot_values,
    aggfunc="first"
).reset_index()

display(wide_df.head(20))
question_id         domain  \
model                              
0        sample_0       commerce   
1        sample_1       commerce   
2       sample_10     technology   
3       sample_11     technology   
4       sample_12     technology   
5       sample_13     technology   
6       sample_14     technology   
7        sample_2       commerce   
8        sample_3       commerce   
9        sample_4       commerce   
10       sample_5  entertainment   
11       sample_6  entertainment   
12       sample_7  entertainment   
13       sample_8  entertainment   
14       sample_9  entertainment   

                                            ground_truth  \
model                                                      
0                                                Room 28   
1                                                   54.6   
2                                   Qwen2‑VL‑7B‑Instruct   
3      Micro-vibration and adhesive wear; they degrad...   
4                                          1, 3, 2, 4, R   
5                               23 (or beteen 22 and 24)   
6                           charxiv(dq), DOCVQA, ChartQA   
7                                                 690RMB   
8            Традиции Абхазии (Traditsii Abkhazii), 6.82   
9         29 days (November 29th to December 28th, 2025)   
10                                                    58   
11                                               2, 3, 6   
12                          Monster Hunter Wilds; 59.86%   
13                                                     3   
14       First-place: Ryan Held\nLast-place: Drew Kibler   

                    accuracy_binary                                      \
model google/gemini-3.1-pro-preview openai/gpt-5.4 qwen/qwen3.5-35b-a3b   
0                               0.0            0.0                  0.0   
1                               0.0            1.0                  0.0   
2                               0.0            0.0                  0.0   
3                               0.0            0.0                  0.0   
4                               0.0            1.0                  1.0   
5                               0.0            0.0                  0.0   
6                               0.0            0.0                  0.0   
7                               0.0            0.0                  0.0   
8                               0.0            0.0                  0.0   
9                               0.0            0.0                  0.0   
10                              0.0            0.0                  0.0   
11                              0.0            0.0                  0.0   
12                              0.0            0.0                  0.0   
13                              0.0            0.0                  0.0   
14                              0.0            0.0                  0.0   

                                            final_answer  \
model                      google/gemini-3.1-pro-preview   
0      Error: Reached max turns without a definitive ...   
1                                                  54.48   
2                                   llava-v1.6-vicuna-7b   
3      Error: Reached max turns without a definitive ...   
4      Error: Reached max turns without a definitive ...   
5                                                      1   
6      tags.\nDone.\nI will just write the thought pr...   
7      Error: Reached max turns without a definitive ...   
8      The wine that meets your criteria with the hig...   
9                                                     30   
10                Error: Could not parse model response.   
11                                            3, 4, 5, 6   
12     Error: Reached max turns without a definitive ...   
13     Error: Reached max turns without a definitive ...   
14                Error: Could not parse model response.   

                                                          \
model          
# @title Plot per domain accuracy by model
import matplotlib.pyplot as plt

domain_summary = (
    results_df.groupby(["model", domain_col], as_index=False)["accuracy_binary"]
    .mean()
)

pivot_domain = domain_summary.pivot(index=domain_col, columns="model", values="accuracy_binary")
display(pivot_domain)

ax = pivot_domain.plot(kind="bar", figsize=(12, 6))
ax.set_title("AgentVista mini evaluation by domain")
ax.set_xlabel("Domain")
ax.set_ylabel("Exact match accuracy")
ax.legend(title="Model")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()
model          google/gemini-3.1-pro-preview  openai/gpt-5.4  \
domain                                                         
commerce                                 0.0             0.2   
entertainment                            0.0             0.0   
technology                               0.0             0.2   

model          qwen/qwen3.5-35b-a3b  
domain                               
commerce                        0.0  
entertainment                   0.0  
technology                      0.2

ch01-img.png

# @title Plot overall accuracy by model
import matplotlib.pyplot as plt

overall = (
    results_df.groupby("model", as_index=False)["accuracy_binary"]
    .mean()
    .sort_values("accuracy_binary", ascending=False)
)

display(overall)

plt.figure(figsize=(8, 5))
plt.bar(overall["model"], overall["accuracy_binary"])
plt.title("Overall exact match accuracy on the AgentVista mini subset")
plt.xlabel("Model")
plt.ylabel("Accuracy")
plt.xticks(rotation=25, ha="right")
plt.tight_layout()
plt.show()
model  accuracy_binary
1                 openai/gpt-5.4         0.133333
2           qwen/qwen3.5-35b-a3b         0.066667
0  google/gemini-3.1-pro-preview         0.000000

ch02-img.png

# @title Show how to extend the schema with your own use case data
import json

custom_example = [
    {
        "question_id": "custom_001",
        "question": "Look at the screenshot and identify whether the shown product matches the requested specification. Then verify the latest price online and answer with the exact product name and price.",
        "images": ["custom_images/product_case_1.png"],
        "solution": "Example Ground Truth",
        "answer": "Example Ground Truth",
        "domain": "commerce",
        "subdomain": "product_matching"
    },
    {
        "question_id": "custom_002",
        "question": "Inspect the dashboard screenshot, identify the failed component, and verify the vendor documentation for the correct remediation step.",
        "images": ["custom_images/ops_case_1.png"],
        "solution": "Restart the ingestion connector and revalidate the schema mapping",
        "answer": "Restart the ingestion connector and revalidate the schema mapping",
        "domain": "technology",
        "subdomain": "troubleshooting"
    }
]

print(json.dumps(custom_example, indent=2, ensure_ascii=False))
[
  {
    "question_id": "custom_001",
    "question": "Look at the screenshot and identify whether the shown product matches the requested specification. Then verify the latest price online and answer with the exact product name and price.",
    "images": [
      "custom_images/product_case_1.png"
    ],
    "solution": "Example Ground Truth",
    "answer": "Example Ground Truth",
    "domain": "commerce",
    "subdomain": "product_matching"
  },
  {
    "question_id": "custom_002",
    "question": "Inspect the dashboard screenshot, identify the failed component, and verify the vendor documentation for the correct remediation step.",
    "images": [
      "custom_images/ops_case_1.png"
    ],
    "solution": "Restart the ingestion connector and revalidate the schema mapping",
    "answer": "Restart the ingestion connector and revalidate the schema mapping",
    "domain": "technology",
    "subdomain": "troubleshooting"
  }
]

外部评估管线(Langfuse)

用外部评估管线评估 Langfuse Agent Traces

本 cookbook 演示如何把 Langfuse 用作外部评估管线的 trace 存储、过滤层和可选的分值可视化层。

Tracing 告诉你发生了什么:延迟、工具调用、重试和输出。它并不会告诉你更换后的模型是否仍然满足你应用特定的质量标准。因此,我们会拉取有代表性的 traces,在 Langfuse 之外打分,可选地把结果写回,并在更换模型之前把关键失败提升为可回放的 benchmark 集合。

最务实的架构选择是:不要让 notebook 只依赖 LLM 评判。

而是混合使用三类信号:

  • 硬检查:用于你可以直接核实的项,例如重试预算、schema 有效性、工具成功、必要的交接标签或数值容差。
  • 结构化 rubric 检查:用于充分性或完整性等维度。
  • trace 级评判检查:当路径本身重要(而不只是最终答案)时使用。

这种组合与 Langfuse 在生产中最常见的用途相匹配:存储 traces、筛选有代表性的样本、可视化自定义分值。打分逻辑本身应体现良好的评估实践,而不是关注语气或愉悦度等风格特征。


学完本 cookbook 后,你将能够:

  • 使用时间窗口和标签过滤器,从 Langfuse 拉取有代表性的生产 traces。
  • 为 Agent 工作流和结果定义多层评估标准。
  • 使用硬检查、rubric 检查或两者结合在外部对 traces 打分。
  • 可选地把选定的分值写回 Langfuse。
  • 把关键失败提升为可回放的 benchmark 集合。
  • 在重新部署前,基于该 benchmark 集合比较候选模型版本。
  • 当单条 trace 指标不够时,可选地使用 RULER 等相对轨迹排序。

注意:虽然本 cookbook 用的是 Jupyter notebook,但在生产中你应该使用自己偏好的编排工具。只需把代码提取到 .py 文件中,并确保所有依赖在运行时可用即可。

(准备工作)把有代表性的支持 Agent traces 载入 Langfuse

在这个演示中,我们避免使用通用的纯文本示例,而是采用一个轻量的支持 Agent 工作流。每条合成 trace 都会包含足够的结构来评估 Agent 行为:重试次数、交接目标、必需步骤、工具结果和最终答案。

在生产中,你应该先拉取真实 traces。这里我们先创建有代表性的合成 traces,以便让 notebook 的其余部分专注于评估管线本身。

你可以在此处获取 Langfuse API 密钥,在此处获取 OpenAI API 密钥。

%pip install langfuse openai deepeval
# Store your secrets in a local .env file or your notebook environment.
# Do not hardcode credentials directly in the notebook.
import os
from dotenv import load_dotenv

load_dotenv()

# Get keys for your project from the project settings page: https://cloud.langfuse.com
LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY")
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
os.environ["LANGFUSE_BASE_URL"] = os.getenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")

if not LANGFUSE_PUBLIC_KEY or not LANGFUSE_SECRET_KEY:
    raise ValueError(
        "Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY in your environment before running this notebook."
    )

os.environ["LANGFUSE_PUBLIC_KEY"] = LANGFUSE_PUBLIC_KEY
os.environ["LANGFUSE_SECRET_KEY"] = LANGFUSE_SECRET_KEY
from dotenv import load_dotenv
import os

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

if not OPENAI_API_KEY:
    print("OPENAI_API_KEY not found.")
    OPENAI_API_KEY = input("Enter OPENAI_API_KEY: ").strip()
    os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY

print("OPENAI_API_KEY is set.")
print("Langfuse credentials are set.")
OPENAI_API_KEY is set.
Langfuse credentials are set.

让我们定义一小批有代表性的支持场景。每个用例都包含我们预期的工作流、必需的运维步骤、正确的交接目标,以及以后可用于正确性检查的参考答案。

from copy import deepcopy

from langfuse.openai import openai


def infer_issue_type(case):
    text = case.get("customer_request", "").lower()
    if "refund" in text or "charged" in text or "invoice" in text or "subscription" in text:
        return "billing_issue"
    if "ship" in text or "arrive" in text or "delivery" in text:
        return "shipping_issue"
    if "log in" in text or "password" in text or "access" in text:
        return "account_access_issue"
    return "general_support"


def infer_task_definition(case):
    return (
        f"Resolve this {case['workflow']} support request with the required steps, "
        f"correct handoff ({case['expected_handoff']}), and a clear final answer."
    )


def infer_expected_plan(case):
    plan = [case["required_step"]]
    if case["expected_handoff"] != "none":
        plan.append(f"handoff_to_{case['expected_handoff']}")
    plan.append("respond_to_customer")
    return plan


def infer_expected_tools(case):
    tools = [
        {
            "name": case["required_step"],
            "description": f"Core {case['workflow']} lookup step.",
            "input": {"case_id": case["case_id"], "workflow": case["workflow"]},
            "output": {},
        }
    ]
    if case["expected_handoff"] != "none":
        tools.append(
            {
                "name": "create_handoff",
                "description": "Escalation handoff tool.",
                "input": {"target": case["expected_handoff"], "reason": case["workflow"]},
                "output": {},
            }
        )
    return tools


def enrich_tool_call(case, call):
    return {
        "name": str(call.get("name", "unknown_tool")),
        "status": str(call.get("status", "unknown")),
        "input": call.get("input", {"case_id": case["case_id"], "request": case["customer_request"]}),
        "output": call.get("output", {"status": str(call.get("status", "unknown"))}),
        "description": call.get("description", f"Support tool in {case['workflow']} workflow."),
    }


base_cases = [
    {
        "case_id": "refund_double_charge",
        "workflow": "billing",
        "agent_type": "triage_agent",
        "customer_request": "I was charged twice for order 8129. Please fix it.",
        "required_step": "lookup_order",
        "expected_handoff": "billing_specialist",
        "reference_answer": "I found the duplicate charge and handed this to billing for refund review.",
        "max_retries": 2,
        "simulated_retry_count": 1,
        "simulated_handoff_target": "billing_specialist",
        "simulated_steps_completed": ["lookup_order", "verify_duplicate_charge", "handoff_to_billing_specialist", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_order", "status": "success", "input": {"order_id": "8129"}},
            {"name": "create_handoff", "status": "success", "input": {"target": "billing_specialist"}},
        ],
        "simulated_final_answer": "I confirmed the duplicate charge for order 8129 and escalated this to billing for refund processing. You will receive an update within one business day.",
    },
    {
        "case_id": "shipping_status_no_handoff_needed",
        "workflow": "shipping",
        "agent_type": "triage_agent",
        "customer_request": "Where is my order 9912 and when should it arrive?",
        "required_step": "lookup_shipment",
        "expected_handoff": "none",
        "reference_answer": "Order 9912 is in transit and should arrive tomorrow.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["lookup_shipment", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_shipment", "status": "success", "input": {"order_id": "9912"}},
        ],
        "simulated_final_answer": "I checked order 9912 and it is in transit. Carrier ETA is tomorrow.",
    },
    {
        "case_id": "reset_password_wrong_handoff",
        "workflow": "account_access",
        "agent_type": "triage_agent",
        "customer_request": "I cannot log in after changing my password.",
        "required_step": "lookup_account_status",
        "expected_handoff": "account_security",
        "reference_answer": "I checked your account and routed this to account security to restore access.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "billing_specialist",
        "simulated_steps_completed": ["lookup_account_status", "handoff_to_billing_specialist", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_account_status", "status": "success", "input": {"user_id": "user_42"}},
            {"name": "create_handoff", "status": "success", "input": {"target": "billing_specialist"}},
        ],
        "simulated_final_answer": "I escalated this and someone will follow up.",
    },
    {
        "case_id": "cancel_subscription_missing_step",
        "workflow": "billing",
        "agent_type": "triage_agent",
        "customer_request": "Please cancel my subscription at the end of this billing cycle.",
        "required_step": "lookup_subscription",
        "expected_handoff": "none",
        "reference_answer": "I located your subscription and set it to cancel at cycle end.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_subscription", "status": "failed", "input": {"subscription_id": "sub_01"}},
        ],
        "simulated_final_answer": "Your subscription is all set and should stop soon.",
    },
    {
        "case_id": "invoice_request_retry_exceeded",
        "workflow": "billing",
        "agent_type": "billing_agent",
        "customer_request": "Can you send me the invoice for March?",
        "required_step": "lookup_invoice",
        "expected_handoff": "none",
        "reference_answer": "I found your March invoice and sent it to your email.",
        "max_retries": 1,
        "simulated_retry_count": 3,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["lookup_invoice", "lookup_invoice", "lookup_invoice", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_invoice", "status": "failed", "input": {"month": "March"}},
            {"name": "lookup_invoice", "status": "failed", "input": {"month": "March"}},
            {"name": "lookup_invoice", "status": "success", "input": {"month": "March"}},
        ],
        "simulated_final_answer": "I finally found the invoice after several retries and sent it.",
    },
]

# Additional comparable scenarios so RULER can rank 3-5 similar answers per group.
comparative_variants = []
for i, tone in enumerate([
    "empathetic_and_precise",
    "neutral_and_correct",
    "vague_and_weak",
], start=1):
    template = deepcopy(base_cases[0])
    template["case_id"] = f"refund_double_charge_variant_{i}"
    if tone == "empathetic_and_precise":
        template["simulated_final_answer"] = "I understand how frustrating a double charge is. I confirmed the duplicate charge on order 8129 and escalated it to billing for immediate refund review."
    elif tone == "neutral_and_correct":
        template["simulated_final_answer"] = "Duplicate charge on order 8129 was confirmed and the case has been handed to billing for refund processing."
    else:
        template["simulated_final_answer"] = "We will look into it and get back to you."
    comparative_variants.append(template)

cases = base_cases + comparative_variants

for case in cases:
    case["task_definition"] = infer_task_definition(case)
    case["expected_plan"] = infer_expected_plan(case)
    case["expected_tools"] = infer_expected_tools(case)
    case["scenario_group_id"] = f"{case['workflow']}::{infer_issue_type(case)}::{case['expected_handoff']}"
    case["simulated_tool_calls"] = [enrich_tool_call(case, c) for c in case.get("simulated_tool_calls", [])]

print(f"Prepared enriched cases: {len(cases)}")
cases[0]
Prepared enriched cases: 8
{'case_id': 'refund_double_charge',
 'workflow': 'billing',
 'agent_type': 'triage_agent',
 'customer_request': 'I was charged twice for order 8129. Please fix it.',
 'required_step': 'lookup_order',
 'expected_handoff': 'billing_specialist',
 'reference_answer': 'I found the duplicate charge and handed this to billing for refund review.',
 'max_retries': 2,
 'simulated_retry_count': 1,
 'simulated_handoff_target': 'billing_specialist',
 'simulated_steps_completed': ['lookup_order',
  'verify_duplicate_charge',
  'handoff_to_billing_specialist',
  'respond_to_customer'],
 'simulated_tool_calls': [{'name': 'lookup_order',
   'status': 'success',
   'input': {'order_id': '8129'},
   'output': {'status': 'success'},
   'description': 'Support tool in billing workflow.'},
  {'name': 'create_handoff',
   'status': 'success',
   'input': {'target': 'billing_specialist'},
   'output': {'status': 'success'},
   'description': 'Support tool in billing workflow.'}],
 'simulated_final_answer': 'I confirmed the duplicate charge for order 8129 and escalated this to billing for refund processing. You will receive an update within one business day.',
 'task_definition': 'Resolve this billing support request with the required steps, correct handoff (billing_specialist), and a clear final answer.',
 'expected_plan': ['lookup_order',
  'handoff_to_billing_specialist',
  'respond_to_customer'],
 'expected_tools': [{'name': 'lookup_order',
   'description': 'Core billing lookup step.',
   'input': {'case_id': 'refund_double_charge', 'workflow': 'billing'},
   'output': {}},
  {'name': 'create_handoff',
   'description': 'Escalation handoff tool.',
   'input': {'target': 'billing_specialist', 'reason': 'billing'},
   'output': {}}],
 'scenario_group_id': 'billing::billing_issue::billing_specialist'}

接下来,我们将每个用例记录一条合成 trace 到 Langfuse。本准备部分的目的不是构建一个真实的支持 Agent,而是创建带有外部评估器之后可以检查的工作流信号的有代表性 traces:重试、工具结果、交接和最终答案。

import json
from langfuse import observe, get_client, propagate_attributes

langfuse = get_client()

@observe()
def log_support_trace(case):
    payload = {
        "case_id": case["case_id"],
        "workflow": case["workflow"],
        "agent_type": case["agent_type"],
        "customer_request": case["customer_request"],
        "required_step": case["required_step"],
        "expected_handoff": case["expected_handoff"],
        "reference_answer": case["reference_answer"],
        "max_retries": case["max_retries"],
        "retry_count": case["simulated_retry_count"],
        "handoff_target": case["simulated_handoff_target"],
        "steps_completed": case["simulated_steps_completed"],
        "tool_calls": case["simulated_tool_calls"],
        "final_answer": case["simulated_final_answer"],
        "task_definition": case["task_definition"],
        "expected_plan": case["expected_plan"],
        "expected_tools": case["expected_tools"],
        "scenario_group_id": case["scenario_group_id"],
    }

    with propagate_attributes(
        tags=["ext_eval_pipelines", "support_agent", case["workflow"], case["agent_type"]],
        trace_name=f"Support case: {case['case_id']}"
    ):
        return payload

for case in cases:
    logged_trace = log_support_trace(case)
    print(json.dumps({"case_id": logged_trace["case_id"], "scenario_group_id": logged_trace["scenario_group_id"]}, indent=2))

langfuse.flush()
print("Traces flushed to Langfuse.")
{
  "case_id": "refund_double_charge",
  "scenario_group_id": "billing::billing_issue::billing_specialist"
}
{
  "case_id": "shipping_status_no_handoff_needed",
  "scenario_group_id": "shipping::shipping_issue::none"
}
{
  "case_id": "reset_password_wrong_handoff",
  "scenario_group_id": "account_access::account_access_issue::account_security"
}
{
  "case_id": "cancel_subscription_missing_step",
  "scenario_group_id": "billing::billing_issue::none"
}
{
  "case_id": "invoice_request_retry_exceeded",
  "scenario_group_id": "billing::billing_issue::none"
}
{
  "case_id": "refund_double_charge_variant_1",
  "scenario_group_id": "billing::billing_issue::billing_specialist"
}
{
  "case_id": "refund_double_charge_variant_2",
  "scenario_group_id": "billing::billing_issue::billing_specialist"
}
{
  "case_id": "refund_double_charge_variant_3",
  "scenario_group_id": "billing::billing_issue::billing_specialist"
}
Traces flushed to Langfuse.

现在你应该能在 Langfuse 界面的 Traces 部分看到有代表性的支持 Agent traces。

为什么仅靠追踪不够

Tracing 能展示延迟、重试、工具调用和输出等运维行为。这是必要的,但仍不完整:当你更换模型或调整编排时,你还需要知道系统是否仍然满足工作流特定的质量期望,比如采取正确的交接、完成必需步骤,以及给出充分且正确的最终答案。

拉取有代表性的生产 trace

从 Langfuse 拉取 traces 很简单。重要的不是随意拉取最近的 traces,而是选择有代表性的。实践中,你通常先按时间窗口过滤,再按标签、工作流、Agent 类型或与你即将做出的发布决策相关的其他切片缩小集合。

下面的示例保留了 Langfuse 的拉取逻辑,并演示如何在获取后于 Python 中叠加额外的过滤器。

from langfuse import get_client
from datetime import datetime, timedelta
import json

BATCH_SIZE = 10
TOTAL_TRACES = 50
WORKFLOW_FILTERS = {"billing", "shipping", "returns", "account_access"}
AGENT_TYPE_FILTERS = {"triage_agent", "billing_agent", "returns_agent"}

langfuse = get_client()

now = datetime.now()
five_am_today = datetime(now.year, now.month, now.day, 5, 0)
five_am_yesterday = five_am_today - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)

def parse_trace_payload(trace):
    if isinstance(trace.output, dict):
        return trace.output
    if isinstance(trace.output, str):
        return json.loads(trace.output)
    raise TypeError("Unsupported trace output format")

def is_support_case_trace(trace):
    trace_name = getattr(trace, "name", "") or ""
    if trace_name.startswith("Support case:"):
        return True

    try:
        payload = parse_trace_payload(trace)
    except Exception:
        return False

    required_keys = {"workflow", "agent_type", "required_step", "final_answer"}
    return required_keys.issubset(payload.keys())

def matches_eval_filters(trace):
    try:
        payload = parse_trace_payload(trace)
    except Exception:
        return False

    return (
        payload.get("workflow") in WORKFLOW_FILTERS
        and payload.get("agent_type") in AGENT_TYPE_FILTERS
    )

def fetch_support_traces(from_timestamp, to_timestamp):
    raw_batch = langfuse.api.trace.list(
        page=1,
        limit=BATCH_SIZE,
        tags="ext_eval_pipelines",
        from_timestamp=from_timestamp,
        to_timestamp=to_timestamp,
    ).data

    support_traces = [trace for trace in raw_batch if is_support_case_trace(trace)]
    filtered_traces = [trace for trace in support_traces if matches_eval_filters(trace)]
    return raw_batch, support_traces, filtered_traces

raw_batch, support_traces, traces_batch = fetch_support_traces(
    from_timestamp=five_am_yesterday,
    to_timestamp=now,
)

if not traces_batch:
    raw_batch, support_traces, traces_batch = fetch_support_traces(
        from_timestamp=seven_days_ago,
        to_timestamp=now,
    )
    print("No matching traces found in the last day, so the search was widened to the last 7 days.")

print(f"Tagged traces fetched: {len(raw_batch)}")
print(f"Support-case traces found: {len(support_traces)}")
print(f"Representative traces in first batch: {len(traces_batch)}")

if not traces_batch:
    raise ValueError(
        "No support-agent traces matched the evaluation filters. Re-run the prep cell above, then run this fetch cell again."
    )
Tagged traces fetched: 10
Support-case traces found: 10
Representative traces in first batch: 10

定义多层评估标准

在本 notebook 中,我们将从五个维度对每条 trace 打分:

  • retry_budget_respected
  • correct_handoff
  • required_step_completed
  • final_answer_sufficient
  • final_answer_correct

前三个是理想的硬检查。后两个通常是结构化 rubric 检查或 trace 级评判发挥作用的地方。

硬检查应该尽可能多地承担工作:

  • retry_count <= max_retries
  • 必要的工具调用成功
  • 需要升级时存在必要的交接标签
  • 必需的工作流步骤出现在轨迹中

然后在严格的程序化比较不够的地方,添加基于 rubric 的充分性和正确性检查。

当路径本身重要(而不只是最终答案)时,trace 级评判才合适。这正是轨迹评判器或 RULER 这类系统在概念上很契合的地方:当失败只有在完整路径中才可见时,对照一个明确的目标达成 rubric 来比较轨迹。

在下面的代码中,我们保持生产模式简单:对工作流合规性做硬检查,外加对充分性和正确性的外部评判式打分。

from deepeval.metrics import (
    GEval,
    TaskCompletionMetric,
    ArgumentCorrectnessMetric,
    StepEfficiencyMetric,
)
from deepeval.test_case import LLMTestCaseParams, LLMTestCase, ToolCall

JUDGE_MODEL = "gpt-4o"
DEFAULT_THRESHOLD = 0.7


def normalize_retry_count(value, fallback):
    try:
        return int(value)
    except (TypeError, ValueError):
        return fallback


def normalize_list(value):
    if isinstance(value, list):
        return value
    if value is None:
        return []
    return [value]


def normalize_tool_calls(value):
    normalized_calls = []
    for call in normalize_list(value):
        if isinstance(call, dict):
            normalized_calls.append(
                {
                    "name": str(call.get("name", "unknown_tool")),
                    "status": str(call.get("status", "unknown")),
                    "input": call.get("input", {}),
                    "output": call.get("output", {}),
                    "description": str(call.get("description", "")),
                }
            )
        else:
            normalized_calls.append({"name": str(call), "status": "unknown", "input": {}, "output": {}, "description": ""})
    return normalized_calls


def normalize_expected_tools(value, required_step):
    if value:
        source = value
    else:
        source = [{"name": required_step, "input": {}, "output": {}, "description": ""}]
    normalized = []
    for tool in normalize_list(source):
        if isinstance(tool, dict):
            normalized.append(
                {
                    "name": str(tool.get("name", "unknown_tool")),
                    "input": tool.get("input", {}),
                    "output": tool.get("output", {}),
                    "description": str(tool.get("description", "")),
                }
            )
        else:
            normalized.append({"name": str(tool), "input": {}, "output": {}, "description": ""})
    return normalized


def normalize_payload(payload):
    normalized = dict(payload)
    normalized["retry_count"] = normalize_retry_count(normalized.get("retry_count"), normalized.get("max_retries", 0) + 1)
    normalized["steps_completed"] = [str(item) for item in normalize_list(normalized.get("steps_completed"))]
    normalized["tool_calls"] = normalize_tool_calls(normalized.get("tool_calls"))
    normalized["handoff_target"] = str(normalized.get("handoff_target", "none"))
    normalized["expected_handoff"] = str(normalized.get("expected_handoff", "none"))
    normalized["required_step"] = str(normalized.get("required_step", ""))
    normalized["final_answer"] = str(normalized.get("final_answer", ""))
    normalized["reference_answer"] = str(normalized.get("reference_answer", ""))
    normalized["customer_request"] = str(normalized.get("customer_request", ""))
    normalized["workflow"] = str(normalized.get("workflow", "unknown"))
    normalized["agent_type"] = str(normalized.get("agent_type", "unknown"))
    normalized["max_retries"] = normalize_retry_count(normalized.get("max_retries"), 0)
    normalized["task_definition"] = str(normalized.get("task_definition", normalized["customer_request"]))
    normalized["expected_plan"] = [str(step) for step in normalize_list(normalized.get("expected_plan", [normalized["required_step"]])) if str(step)]
    normalized["expected_tools"] = normalize_expected_tools(normalized.get("expected_tools"), normalized["required_step"])
    normalized["scenario_group_id"] = str(normalized.get("scenario_group_id", f"{normalized['workflow']}::{normalized['required_step']}::{normalized['expected_handoff']}"))
    return normalized


def payload_to_tool_calls(payload_tools):
    return [
        ToolCall(
            name=tool.get("name", "unknown_tool"),
            description=tool.get("description", ""),
            input=tool.get("input", {}),
            output=tool.get("output", {}),
        )
        for tool in payload_tools
    ]


def build_llm_test_case(payload):
    payload = normalize_payload(payload)
    return LLMTestCase(
        input=payload["customer_request"],
        actual_output=payload["final_answer"],
        tools_called=payload_to_tool_calls(payload["tool_calls"]),
        expected_tools=payload_to_tool_calls(payload["expected_tools"]),
    )


def retry_budget_respected(payload):
    payload = normalize_payload(payload)
    return payload["retry_count"] <= payload["max_retries"]


def correct_handoff(payload):
    payload = normalize_payload(payload)
    return payload["handoff_target"] == payload["expected_handoff"]


def required_step_completed(payload):
    payload = normalize_payload(payload)
    return payload["required_step"] in payload["steps_completed"]


def tool_success(payload):
    payload = normalize_payload(payload)
    relevant_calls = [call for call in payload["tool_calls"] if payload["required_step"] in call["name"]]
    if not relevant_calls:
        return False
    return all(call["status"] == "success" for call in relevant_calls)


def trajectory_summary(payload):
    payload = normalize_payload(payload)
    tool_lines = [f"- {call['name']}: {call['status']} | input={call['input']}" for call in payload["tool_calls"]]
    return f"""
Customer request: {payload['customer_request']}
Workflow: {payload['workflow']}
Agent type: {payload['agent_type']}
Task definition: {payload['task_definition']}
Expected plan: {payload['expected_plan']}
Retry count: {payload['retry_count']} of {payload['max_retries']}
Expected handoff: {payload['expected_handoff']}
Observed handoff: {payload['handoff_target']}
Required step: {payload['required_step']}
Completed steps: {', '.join(payload['steps_completed'])}
Tool calls:
{chr(10).join(tool_lines) if tool_lines else '- none recorded'}
Final answer: {payload['final_answer']}
Reference answer: {payload['reference_answer']}
""".strip()


def final_answer_sufficient(payload):
    payload = normalize_payload(payload)
    metric = GEval(
        name="final_answer_sufficient",
        criteria=(
            "Determine whether the final answer is sufficient for the customer's request. "
            "The answer should be actionable, context-aware, and should not hide missing work behind vague language."
        ),
        evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
        model=JUDGE_MODEL,
    )
    test_case = LLMTestCase(input=trajectory_summary(payload), actual_output=payload["final_answer"])
    metric.measure(test_case)
    return {"score": metric.score, "reason": metric.reason}


def final_answer_correct(payload):
    payload = normalize_payload(payload)
    metric = GEval(
        name="final_answer_correct",
        criteria="Assess whether the final answer is correct given the workflow details and reference answer.",
        evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT, LLMTestCaseParams.EXPECTED_OUTPUT],
        model=JUDGE_MODEL,
    )
    test_case = LLMTestCase(
        input=trajectory_summary(payload),
        actual_output=payload["final_answer"],
        expected_output=payload["reference_answer"],
    )
    metric.measure(test_case)
    return {"score": metric.score, "reason": metric.reason}


def score_task_completion(payload):
    metric = TaskCompletionMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        task=normalize_payload(payload)["task_definition"],
        include_reason=True,
        async_mode=False,
    )
    metric.measure(build_llm_test_case(payload))
    return {"score": float(metric.score), "reason": metric.reason}



def score_argument_correctness(payload):
    metric = ArgumentCorrectnessMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        include_reason=True,
        async_mode=False,
    )
    metric.measure(build_llm_test_case(payload))
    return {"score": float(metric.score), "reason": metric.reason}


def score_step_efficiency(payload):
    payload = normalize_payload(payload)
    metric = StepEfficiencyMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        include_reason=True,
        async_mode=False,
    )
    try:
        metric.measure(build_llm_test_case(payload))
        return {"score": float(metric.score), "reason": metric.reason}
    except UnboundLocalError as exc:
        # DeepEval StepEfficiencyMetric can fail internally (prompt not set) for some payloads.
        required = payload.get("required_step", "")
        steps = payload.get("steps_completed", [])
        max_retries = max(int(payload.get("max_retries", 0)), 0)
        retries = max(int(payload.get("retry_count", 0)), 0)
        base_plan_len = 2
        observed_len = len(steps)
        missing_required_penalty = 0.5 if required and required not in steps else 0.0
        extra_step_penalty = max(0, observed_len - base_plan_len) * 0.1
        retry_penalty = 0 if max_retries == 0 else min(retries / max_retries, 1.0) * 0.4
        score = max(0.0, min(1.0, 1.0 - missing_required_penalty - extra_step_penalty - retry_penalty))
        reason = (
            "Fallback score: StepEfficiencyMetric failed internally "
            f"({exc.__class__.__name__}). "
            f"Derived from required-step presence, extra steps ({observed_len}), retries ({retries}/{max_retries})."
        )
        return {"score": float(score), "reason": reason}

把评估逻辑封装到函数中,可以让管线易于测试和版本化。每个函数接收一个 trace payload,并返回一个布尔值、分类值或数值分值,这些分值以后可以写回 Langfuse。

example_payload = parse_trace_payload(traces_batch[0])

example_scores = {
    "retry_budget_respected": retry_budget_respected(example_payload),
    "correct_handoff": correct_handoff(example_payload),
    "required_step_completed": required_step_completed(example_payload),
    "required_tool_succeeded": tool_success(example_payload),
    "final_answer_sufficient": final_answer_sufficient(example_payload),
    "final_answer_correct": final_answer_correct(example_payload),
}

example_scores
Output()
Output()
{'retry_budget_respected': True,
 'correct_handoff': True,
 'required_step_completed': True,
 'required_tool_succeeded': True,
 'final_answer_sufficient': {'score': 0.43177048236403187,
  'reason': "The Actual Output acknowledges the issue but lacks specificity and actionable steps. It does not directly address the customer's request for a resolution, as it fails to mention the duplicate charge verification or the handoff to the billing specialist. While the handoff was correctly observed, the response is vague and does not provide immediate reassurance or clarity on the next steps, unlike the Reference answer."},
 'final_answer_correct': {'score': 0.6528943405057316,
  'reason': "The Actual Output does not match the Expected Output, as it lacks the confirmation of finding the duplicate charge and the handoff for refund review. However, the workflow was followed correctly with all required steps completed, including the lookup and handoff. The discrepancy in the final answer is not justified by the workflow details, as the Expected Output provides a more complete response to the customer's request."}}

在外部给 trace 打分

使用外部评判逻辑、硬检查,或两者结合。关键是打分逻辑要放在追踪系统之外,这样它才能随你的应用一起演进。Langfuse 可以存储 trace,并可选地存储分值,但"什么算好的行为"由你的评估器来定义。

你可以使用任何评估库。这里的独立检查用 deepeval 就够了;当你需要显式的整条轨迹比较时,RULER 式的轨迹评判是很好的下一步。

evaluated_traces = []

for trace in traces_batch:
    payload = normalize_payload(parse_trace_payload(trace))

    sufficiency = final_answer_sufficient(payload)
    correctness = final_answer_correct(payload)
    task_completion = score_task_completion(payload)
    argument_correctness = score_argument_correctness(payload)
    step_efficiency = score_step_efficiency(payload)

    evaluated_traces.append(
        {
            "trace_id": trace.id,
            "case_id": payload["case_id"],
            "workflow": payload["workflow"],
            "agent_type": payload["agent_type"],
            "scenario_group_id": payload["scenario_group_id"],
            "payload": payload,
            "scores": {
                "retry_budget_respected": float(retry_budget_respected(payload)),
                "correct_handoff": float(correct_handoff(payload)),
                "required_step_completed": float(required_step_completed(payload) and tool_success(payload)),
                "final_answer_sufficient": sufficiency["score"],
                "final_answer_correct": correctness["score"],
                "task_completion": task_completion["score"],
                "argument_correctness": argument_correctness["score"],
                "step_efficiency": step_efficiency["score"],
            },
            "reasons": {
                "final_answer_sufficient": sufficiency["reason"],
                "final_answer_correct": correctness["reason"],
                "task_completion": task_completion["reason"],
                "argument_correctness": argument_correctness["reason"],
                "step_efficiency": step_efficiency["reason"],
            },
        }
    )

evaluated_traces[0]
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
ERROR:opentelemetry.sdk._shared_internal:Exception while exporting Span.
Traceback (most recent call last):
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connectionpool.py", line 534, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connection.py", line 565, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/http/client.py", line 1450, in getresponse
    response.begin()
  File "/usr/lib/python3.12/http/client.py", line 336, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/http/client.py", line 297, in _read_status
    line = str(self.fp.readline(_MAXLINE + 1), "iso-8859-1")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/socket.py", line 720, in readinto
    return self._sock.recv_into(b)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/ssl.py", line 1251, in recv_into
    return self.read(nbytes, buffer)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/ssl.py", line 1103, in read
    return self._sslobj.read(len, buffer)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TimeoutError: The read operation timed out

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/local/lib/python3.12/dist-packages/requests/adapters.py", line 667, in send
    resp = conn.urlopen(
           ^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connectionpool.py", line 841, in urlopen
    retries = retries.increment(
              ^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/urllib3/util/retry.py", line 474, in increment
    raise reraise(type(error), error, _stacktrace)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/urllib3/util/util.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connectionpool.py", line 787, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connectionpool.py", line 536, in _make_request
    self._raise_timeout(err=e, url=url, timeout_value=read_timeout)
  File "/usr/local/lib/python3.12/dist-packages/urllib3/connectionpool.py", line 367, in _raise_timeout
    raise ReadTimeoutError(
urllib3.exceptions.ReadTimeoutError: HTTPSConnectionPool(host='cloud.langfuse.com', port=443): Read timed out. (read timeout=4.999996662139893)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.12/dist-packages/opentelemetry/sdk/_shared_internal/__init__.py", line 179, in _export
    self._exporter.export(
  File "/usr/local/lib/python3.12/dist-packages/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py", line 182, in export
    resp = self._export(serialized_data, deadline_sec - time())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py", line 157, in _export
    resp = self._session.post(
           ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/requests/sessions.py", line 637, in post
    return self.request("POST", url, data=data, json=json, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/requests/sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-packages/requests/sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/dist-
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
{'trace_id': '86712dc66d68c17630301a01cf54b8f4',
 'case_id': 'refund_double_charge_variant_3',
 'workflow': 'billing',
 'agent_type': 'triage_agent',
 'scenario_group_id': 'billing::billing_issue::billing_specialist',
 'payload': {'case_id': 'refund_double_charge_variant_3',
  'workflow': 'billing',
  'agent_type': 'triage_agent',
  'customer_request': 'I was charged twice for order 8129. Please fix it.',
  'required_step': 'lookup_order',
  'expected_handoff': 'billing_specialist',
  'reference_answer': 'I found the duplicate charge and handed this to billing for refund review.',
  'max_retries': 2,
  'retry_count': 1,
  'handoff_target': 'billing_specialist',
  'steps_completed': ['lookup_order',
   'verify_duplicate_charge',
   'handoff_to_billing_specialist',
   'respond_to_customer'],
  'tool_calls': [{'name': 'lookup_order',
    'status': 'success',
    'input': {'order_id': '8129'},
    'output': {'status': 'success'},
    'description': 'Support tool in billing workflow.'},
   {'name': 'create_handoff',
    'status': 'success',
    'input': {'target': 'billing_specialist'},
    'output': {'status': 'success'},
    'description': 'Support tool in billing workflow.'}],
  'final_answer': 'We will look into it and get back to you.',
  'task_definition': 'Resolve this billing support request with the required steps, correct handoff (billing_specialist), and a clear final answer.',
  'expected_plan': ['lookup_order',
   'handoff_to_billing_specialist',
   'respond_to_customer'],
  'expected_tools': [{'name': 'lookup_order',
    'input': {'case_id': 'refund_double_charge_variant_3',
     'workflow': 'billing'},
    'output': {},
    'description': 'Core billing lookup step.'},
   {'name': 'create_handoff',
    'input': {'target': 'billing_specialist', 'reason': 'billing'},
    'output': {},
    'description': 'Escalation handoff tool.'}],
  'scenario_group_id': 'billing::billing_issue::billing_specialist'},
 'scores': {'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 1.0,
  'final_answer_sufficient': 0.48608231468037266,
  'final_answer_correct': 0.6493722174093651,
  'task_completion': 0.6,
  'argument_correctness': 0.0,
  'step_efficiency': 0.6000000000000001},
 'reasons': {'final_answer_sufficient': "The Actual Output acknowledges the issue but lacks specificity and actionable steps. It does not directly address the customer's request for a fix or provide immediate solutions. While the correct handoff to the billing specialist was made, the response is vague and does not confirm the duplicate charge was found or that a refund review is underway, as indicated in the reference answer.",
  'final_answer_correct': "The Actual Output does not match the Expected Output exactly, as it lacks specific information about finding the duplicate charge and the handoff for refund review. However, the workflow steps were completed correctly, including the required 'lookup_order' and the correct handoff to 'billing_specialist'. The discrepancy in the final answer is not fully justified by the Input, as the Expected Output provides a more detailed response.",
  'task_completion': 'The system successfully initiated the process by looking up the order and creating a handoff for further investigation. However, it did not provide a clear final answer or complete all required steps to fully resolve the billing support request.',
  'argument_correctness': 'The score is 0.00 because the tool calls were incorrect due to missing input parameters, which are essential for processing the request accurately.',
  'step_efficiency': 'Fallback score: StepEfficiencyMetric failed internally (UnboundLocalError). Derived from required-step presence, extra steps (4), retries (1/2).'}}

对于基于 LLM 的检查,请保留理由。它们对后续调试和审查临界用例很有用。对于硬检查,布尔值本身通常就足够了;但对于基于评判的分值,解释会成为审计线索的一部分。

5. 可选:把分值写回 Langfuse

这一步是可选的。如果你希望管线保持完全独立,可以在导出 traces、在外部打分、并把 benchmark 集合写入磁盘或其他存储之后停止。

如果你希望在与存储 traces 相同的系统里进行分值可视化和过滤,Langfuse 会再次变得特别有用。这种情况下,把布尔值、数值分值和评论附加回原始 trace,以便后续分析。

WRITE_SCORES_TO_LANGFUSE = False

if WRITE_SCORES_TO_LANGFUSE:
    for evaluated in evaluated_traces:
        for metric_name, metric_value in evaluated["scores"].items():
            kwargs = {
                "trace_id": evaluated["trace_id"],
                "name": metric_name,
                "value": float(metric_value),
            }
            if metric_name in evaluated["reasons"]:
                kwargs["comment"] = evaluated["reasons"][metric_name]
            langfuse.create_score(**kwargs)

    print(f"Pushed scores for {len(evaluated_traces)} traces.")
else:
    print("Skipping Langfuse score write-back. Set WRITE_SCORES_TO_LANGFUSE = True to enable it.")
Skipping Langfuse score write-back. Set WRITE_SCORES_TO_LANGFUSE = True to enable it.

把关键失败提升为可回放的 benchmark 集合

这是从 tracing 通向回归测试的缺失桥梁。一旦关键失败被提升为带有其工作流元数据、参考答案、先前分值和评估器备注的可回放 benchmark 用例,这条 trace 的价值就会大幅提升。

这个 benchmark 集合也是你可以开始追踪更丰富文本质量标准的地方。像 deepeval 这样的独立指标可以逐条 trace 地对充分性和正确性打分。之后,如果你想对同一个 benchmark 用例比较多个候选轨迹,可以在其上叠加 RULER 之类的相对评判器。

import json

critical_failures = [
    evaluated for evaluated in evaluated_traces
    if (
        evaluated["scores"]["retry_budget_respected"] < 1
        or evaluated["scores"]["correct_handoff"] < 1
        or evaluated["scores"]["required_step_completed"] < 1
        or evaluated["scores"]["final_answer_sufficient"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["final_answer_correct"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["task_completion"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["argument_correctness"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["step_efficiency"] < DEFAULT_THRESHOLD
    )
]

benchmark_set_path = "benchmark_set.jsonl"

with open(benchmark_set_path, "w", encoding="utf-8") as benchmark_file:
    for failure in critical_failures:
        benchmark_file.write(
            json.dumps(
                {
                    "case_id": failure["case_id"],
                    "workflow": failure["workflow"],
                    "agent_type": failure["agent_type"],
                    "scenario_group_id": failure["scenario_group_id"],
                    "benchmark_type": "critical_failure_regression",
                    "payload": failure["payload"],
                    "reference_answer": failure["payload"]["reference_answer"],
                    "scores": failure["scores"],
                    "reasons": failure["reasons"],
                    "text_quality_dimensions": [
                        "final_answer_sufficient",
                        "final_answer_correct",
                        "task_completion",
                        "argument_correctness",
                    ],
                }
            ) + "\n"
        )

print(f"Benchmark cases written: {len(critical_failures)}")
print(f"Benchmark set path: {benchmark_set_path}")
Benchmark cases written: 10
Benchmark set path: benchmark_set.jsonl

在 benchmark 集合上比较候选模型版本

现在可以把 benchmark 集合用作轻量回归套件。与其因为新模型在几次抽查中表现不错就提升它,不如在重新部署前重新运行 benchmark 用例、在外部重新打分,并比较聚合结果。

下面的代码单元会针对候选模型重放 benchmark 用例,并汇总所得分值。实践中,你应该在更改生产模型、路由逻辑或工具编排之前运行这一步。

这里仍然是独立地为每次运行打分。这很有用,但有时还不够。如果几个候选轨迹看起来都合理,而你希望评判器在目标达成或文本质量上对它们进行相互之间的相对排序,就在 benchmark 集合之上叠加 RULER 之类的相对评估器。

import statistics

with open(benchmark_set_path, "r", encoding="utf-8") as benchmark_file:
    benchmark_cases = [json.loads(line) for line in benchmark_file]


def run_candidate_agent(payload, model_name):
    payload = normalize_payload(payload)

    prompt = f"""
You are a customer support agent. Return JSON only with these keys:
retry_count, handoff_target, steps_completed, tool_calls, final_answer.

Constraints:
- steps_completed must be a JSON array of strings.
- tool_calls must be a JSON array of objects with keys: name, status, input, output, description.
- retry_count must be an integer.
- handoff_target must be a string.
- final_answer must be a string.

Task definition: {payload['task_definition']}
Expected plan: {payload['expected_plan']}
Expected tools: {payload['expected_tools']}
Customer request: {payload['customer_request']}
Workflow: {payload['workflow']}
Required step: {payload['required_step']}
Expected handoff: {payload['expected_handoff']}
Reference answer: {payload['reference_answer']}
""".strip()

    response = openai.chat.completions.create(
        model=model_name,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[{"role": "user", "content": prompt}],
    )

    candidate = json.loads(response.choices[0].message.content)
    return normalize_payload(
        {
            **payload,
            "retry_count": candidate.get("retry_count", payload["max_retries"] + 1),
            "handoff_target": candidate.get("handoff_target", "none"),
            "steps_completed": candidate.get("steps_completed", []),
            "tool_calls": candidate.get("tool_calls", []),
            "final_answer": candidate.get("final_answer", ""),
        }
    )


def evaluate_candidate_model(model_name, benchmark_cases):
    model_scores = []
    for benchmark_case in benchmark_cases:
        payload = run_candidate_agent(benchmark_case["payload"], model_name)
        sufficiency = final_answer_sufficient(payload)
        correctness = final_answer_correct(payload)
        task_completion = score_task_completion(payload)
        argument_correctness = score_argument_correctness(payload)
        step_efficiency = score_step_efficiency(payload)

        model_scores.append(
            {
                "retry_budget_respected": float(retry_budget_respected(payload)),
                "correct_handoff": float(correct_handoff(payload)),
                "required_step_completed": float(required_step_completed(payload) and tool_success(payload)),
                "final_answer_sufficient": sufficiency["score"],
                "final_answer_correct": correctness["score"],
                "task_completion": task_completion["score"],
                "argument_correctness": argument_correctness["score"],
                "step_efficiency": step_efficiency["score"],
            }
        )

    if not model_scores:
        return {"model": model_name, "cases": 0}

    keys = model_scores[0].keys()
    return {
        "model": model_name,
        "cases": len(model_scores),
        **{key: statistics.mean(score[key] for score in model_scores) for key in keys},
    }


model_comparison = [
    evaluate_candidate_model("gpt-5.4-mini", benchmark_cases),
    evaluate_candidate_model("gpt-5.4-nano", benchmark_cases),
]

model_comparison
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
[{'model': 'gpt-5.4-mini',
  'cases': 10,
  'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 0.3,
  'final_answer_sufficient': 0.9973172197306286,
  'final_answer_correct': 1.0,
  'task_completion': 0.685,
  'argument_correctness': 0.0,
  'step_efficiency': 0.93},
 {'model': 'gpt-5.4-nano',
  'cases': 10,
  'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 1.0,
  'final_answer_sufficient': 0.9787851153416378,
  'final_answer_correct': 0.8791574751323176,
  'task_completion': 0.785,
  'argument_correctness': 0.35,
  'step_efficiency': 0.95}]

这样便形成了一个连贯的外部评估闭环:埋点记录 traces、运行统一的逐 trace 指标、导出一份 benchmark 集合、比较候选模型,并使用 RULER 的对比排序来识别相似用例中的薄弱答案。

外部评估管线(LangSmith)

用外部评估管线评估 LangSmith Agent Traces

本 cookbook 演示如何把 LangSmith 用作外部评估管线的 trace 存储、过滤层和可选的反馈层。我们面向 美国托管的 LangSmith API(https://api.smith.langchain.com)。

Tracing 告诉你发生了什么:延迟、工具调用、重试和输出。它并不会告诉你更换后的模型是否仍然满足你应用特定的质量标准。因此,我们会推送 traces、拉取有代表性的 runs、在需要时于核心可观测性 UI 之外打分、可选地把 feedback 附加到 runs 上,并在更换模型之前把关键失败提升为可回放的 benchmark 集合。

最务实的架构选择是:不要让 notebook 只依赖 LLM 评判。

而是混合使用三类信号:

  • 硬检查:用于你可以直接核实的项,例如重试预算、schema 有效性、工具成功、必要的交接标签或数值容差。
  • 结构化 rubric 检查:用于充分性或完整性等维度。
  • trace 级评判检查:当路径本身重要(而不只是最终答案)时使用。

这种组合与 LangSmith 在生产中最常见的用途相匹配:把 runs 存储到项目中、筛选有代表性的样本、可视化或查询 feedback。打分逻辑本身应体现良好的评估实践,而不是关注语气或愉悦度等风格特征。


学完本 cookbook 后,你将能够:

  • 使用标签和结构化输出,把合成的支持 Agent traces 推送到 LangSmith。
  • 使用时间窗口和标签过滤器(通过 SDK),从 LangSmith 项目中拉取有代表性的根 runs
  • 为 Agent 工作流和结果定义多层评估标准。
  • 使用硬检查、rubric 检查或两者结合(包括 deepeval 指标)在外部对 traces 打分。
  • 可选地把 feedback 附加到 LangSmith 中的 runs,以便之后在 UI 中过滤。
  • 把关键失败提升为可回放的 benchmark 集合。
  • 在重新部署前,基于该 benchmark 集合比较候选模型版本。
  • 了解 RULER 式分组排序如何对开放式任务补充逐 trace 分值。
  • 了解条件追踪tracing_context)如何把可观测性与应用逻辑(合规、租户、成本)联系起来。

注意:虽然本 cookbook 用的是 Jupyter notebook,但在生产中你应该使用自己偏好的编排工具。只需把代码提取到 .py 文件中,并确保所有依赖在运行时可用即可。

(准备工作)把有代表性的支持 Agent traces 载入 LangSmith

在这个演示中,我们避免使用通用的纯文本示例,而是采用一个轻量的支持 Agent 工作流。每条合成 trace 都会包含足够的结构来评估 Agent 行为:重试次数、交接目标、必需步骤、工具结果和最终答案。

在生产中,你应该先摄入真实 traces(例如来自你的插桩应用)。这里我们先创建有代表性的合成 traces,以便让 notebook 的其余部分专注于评估管线本身。

你可以从 LangSmith 设置获取 LangSmith API 密钥,从 OpenAI获取 OpenAI API 密钥。

除非你的工作区位于欧盟区域,否则请使用 美国 API 主机:设置 LANGCHAIN_ENDPOINT=https://api.smith.langchain.com。欧盟请改用 https://eu.api.smith.langchain.com

本 notebook 的 LangSmith 项目名 在凭据代码单元中设置(变量 LANGSMITH_PROJECT_NAME),而不是通过 LANGCHAIN_PROJECT 环境变量。

如果在记录 runs 时看到 /sessions 返回 403 Forbidden:请使用项目 default(notebook 默认值),在使用自定义名称前先LangSmith UI 中创建项目,确认你的 API 密钥属于同一个组织,并且如果你的工作区位于欧盟区域,请把 LANGCHAIN_ENDPOINT 设置为 欧盟 主机(https://eu.api.smith.langchain.com)。

文档索引与条件追踪

LangChain 和 LangSmith 的文档索引位于 docs.langchain.com/llms.txt。在深链之前,请先用该文件来发现页面。

条件追踪与应用逻辑

当全局启用了追踪(例如通过 LANGSMITH_TRACING / LANGCHAIN_TRACING_V2)时,span 和 run 默认都会发送到 LangSmith。在生产中,你通常需要请求级控制,而不必重写整个技术栈:对含大量 PII 的路径跳过追踪、把租户路由到不同的项目,或者为控制成本而关闭低价值流量的追踪。

在 Python 中,tracing_context 上下文管理器会覆盖位于 with 块内执行代码的全局设置。典型模式:

  • 对敏感 payload 禁用追踪with ls.tracing_context(enabled=False): ...
  • 按租户或区域路由with ls.tracing_context(project_name="client-acme", tags=[...], metadata={...}): ...
  • 功能开关:仅当你的应用逻辑认为该请求值得可观测性开销时才启用追踪

优先级(从高到低):tracing_context → 程序化配置 → 环境变量。参见官方指南:条件追踪

采样 vs 条件追踪:条件追踪是确定性的(你按请求决定)。采样 则是概率性的,用于高流量下的成本控制。两者可以结合使用。

相关:无需环境变量的追踪屏蔽输入和输出为 traces 添加元数据和标签

下面,本 notebook 在代码中使用固定的项目名,并为演示 harness 使用 @traceable。在真实应用中,你通常会为 handler 入口包装 tracing_context,让追踪遵循你自己的路由和合规规则。

示例(本 notebook 后面不会执行——请在你自己应用中适配):

import langsmith as ls
from langsmith import traceable

@traceable
def handle(customer_id: str, text: str) -> str:
    return text

### Skip tracing entirely (PII, zero-retention tenant, etc.)
with ls.tracing_context(enabled=False):
    handle("regulated-tenant", "...")

### Route to a tenant- or region-specific project with tags and metadata
with ls.tracing_context(
    enabled=True,
    project_name="acme-support",
    tags=["production", "region-us"],
    metadata={"customer_id": "acme"},
):
    handle("acme", "...")
%pip install langsmith openai deepeval
# Store your secrets in a local .env file or your notebook environment.
# Do not hardcode credentials directly in the notebook.
import os
from dotenv import load_dotenv

load_dotenv()

# US-hosted LangSmith (default). For EU, set LANGCHAIN_ENDPOINT=https://eu.api.smith.langchain.com
LS_API_URL = os.getenv("LANGCHAIN_ENDPOINT", "https://api.smith.langchain.com")
os.environ["LANGCHAIN_ENDPOINT"] = LS_API_URL

api_key = os.getenv("LANGCHAIN_API_KEY") or os.getenv("LANGSMITH_API_KEY")
if not api_key:
    raise ValueError(
        "Set LANGCHAIN_API_KEY (or LANGSMITH_API_KEY) in your environment before running this notebook."
    )
os.environ["LANGCHAIN_API_KEY"] = api_key
os.environ["LANGSMITH_API_KEY"] = api_key

# LangSmith project for this cookbook — set here (no LANGCHAIN_PROJECT env var required).
# Use "default" unless you created a dedicated project in the UI first. A non-existent name often
# causes LangSmithError 403 on GET /sessions when the SDK resolves the project.
# EU workspace: set LANGCHAIN_ENDPOINT=https://eu.api.smith.langchain.com above.
LANGSMITH_PROJECT_NAME = "default"
os.environ["LANGCHAIN_PROJECT"] = LANGSMITH_PROJECT_NAME

# Enable tracing for @traceable (LangSmith accepts LANGCHAIN_* and LANGSMITH_* mirrors)
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGSMITH_TRACING_V2"] = "true"
from dotenv import load_dotenv
import os

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

if not OPENAI_API_KEY:
    print("OPENAI_API_KEY not found.")
    OPENAI_API_KEY = input("Enter OPENAI_API_KEY: ").strip()
    os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY

print("OPENAI_API_KEY is set.")
print("LangSmith credentials are set.")
print(f"LangSmith project (this notebook): {os.environ['LANGCHAIN_PROJECT']}")
OPENAI_API_KEY is set.
LangSmith credentials are set.
LangSmith project (this notebook): default

让我们定义一小批有代表性的支持场景。每个用例都包含我们预期的工作流、必需的运维步骤、正确的交接目标,以及以后可用于正确性检查的参考答案。

cases = [
    {
        "case_id": "refund_double_charge",
        "workflow": "billing",
        "agent_type": "triage_agent",
        "customer_request": "I was charged twice for order 8129. Please fix it.",
        "required_step": "lookup_order",
        "expected_handoff": "billing_specialist",
        "reference_answer": "I found the duplicate charge on order 8129 and handed this to billing for refund review.",
        "max_retries": 2,
        "simulated_retry_count": 1,
        "simulated_handoff_target": "billing_specialist",
        "simulated_steps_completed": ["lookup_order", "verify_duplicate_charge", "handoff_to_billing"],
        "simulated_tool_calls": [
            {"name": "lookup_order", "status": "success"},
            {"name": "create_billing_handoff", "status": "success"}
        ],
        "simulated_final_answer": "I found the duplicate charge on order 8129 and handed your case to billing for refund review. You should receive an update within one business day."
    },
    {
        "case_id": "shipping_status_no_handoff_needed",
        "workflow": "shipping",
        "agent_type": "triage_agent",
        "customer_request": "Where is my order 9912 and when should it arrive?",
        "required_step": "lookup_shipment",
        "expected_handoff": "none",
        "reference_answer": "Order 9912 is in transit and should arrive tomorrow based on the shipment lookup.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["lookup_shipment", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_shipment", "status": "success"}
        ],
        "simulated_final_answer": "I checked shipment 9912 and it is currently in transit. The latest carrier estimate shows delivery tomorrow."
    },
    {
        "case_id": "reset_password_wrong_handoff",
        "workflow": "account_access",
        "agent_type": "triage_agent",
        "customer_request": "I cannot log in after changing my password. Can someone help me reset access?",
        "required_step": "lookup_account_status",
        "expected_handoff": "account_security",
        "reference_answer": "I checked your account status and handed the case to account security so they can restore access safely.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "billing_specialist",
        "simulated_steps_completed": ["lookup_account_status", "handoff_to_billing"],
        "simulated_tool_calls": [
            {"name": "lookup_account_status", "status": "success"},
            {"name": "create_billing_handoff", "status": "success"}
        ],
        "simulated_final_answer": "I reviewed the account issue and escalated it for follow-up. Please wait for the next update."
    },
    {
        "case_id": "cancel_subscription_missing_step",
        "workflow": "billing",
        "agent_type": "triage_agent",
        "customer_request": "Please cancel my subscription at the end of this billing cycle.",
        "required_step": "lookup_subscription",
        "expected_handoff": "none",
        "reference_answer": "I found your subscription and queued it to cancel at the end of the current billing cycle.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_subscription", "status": "failed"}
        ],
        "simulated_final_answer": "Your subscription is all set and should stop soon."
    },
    {
        "case_id": "invoice_request_retry_exceeded",
        "workflow": "billing",
        "agent_type": "billing_agent",
        "customer_request": "Can you send me the invoice for March?",
        "required_step": "lookup_invoice",
        "expected_handoff": "none",
        "reference_answer": "I found your March invoice and sent it to the email on file.",
        "max_retries": 1,
        "simulated_retry_count": 3,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["lookup_invoice", "email_invoice"],
        "simulated_tool_calls": [
            {"name": "lookup_invoice", "status": "success"},
            {"name": "email_invoice", "status": "success"}
        ],
        "simulated_final_answer": "I sent your March invoice to the email on file."
    },
    {
        "case_id": "wrong_fact_in_final_answer",
        "workflow": "returns",
        "agent_type": "returns_agent",
        "customer_request": "What is the return deadline for order 4481?",
        "required_step": "lookup_return_policy",
        "expected_handoff": "none",
        "reference_answer": "Order 4481 is eligible for return within 30 days of delivery.",
        "max_retries": 1,
        "simulated_retry_count": 0,
        "simulated_handoff_target": "none",
        "simulated_steps_completed": ["lookup_return_policy", "respond_to_customer"],
        "simulated_tool_calls": [
            {"name": "lookup_return_policy", "status": "success"}
        ],
        "simulated_final_answer": "Order 4481 can be returned within 14 days of delivery."
    }
]

for case in cases:
    print(f"{case['case_id']}: workflow={case['workflow']}, agent_type={case['agent_type']}")
refund_double_charge: workflow=billing, agent_type=triage_agent
shipping_status_no_handoff_needed: workflow=shipping, agent_type=triage_agent
reset_password_wrong_handoff: workflow=account_access, agent_type=triage_agent
cancel_subscription_missing_step: workflow=billing, agent_type=triage_agent
invoice_request_retry_exceeded: workflow=billing, agent_type=billing_agent
wrong_fact_in_final_answer: workflow=returns, agent_type=returns_agent

接下来,我们将每个用例用 @traceable 记录一条合成的根 run 到 LangSmith。本准备部分的目的不是构建一个真实的支持 Agent,而是创建带有外部评估器之后可以检查的工作流信号的有代表性 runs:重试、工具结果、交接和最终答案。

标签(ext_eval_pipelinessupport_agent、workflow、agent type)让我们之后可以用 Client.list_runs 检索到正确的切片。

import json
import os

from langsmith import Client, traceable

ls_client = Client(api_url=os.environ["LANGCHAIN_ENDPOINT"])
project_name = os.environ["LANGCHAIN_PROJECT"]


def log_support_trace(case):
    payload = {
        "case_id": case["case_id"],
        "workflow": case["workflow"],
        "agent_type": case["agent_type"],
        "customer_request": case["customer_request"],
        "required_step": case["required_step"],
        "expected_handoff": case["expected_handoff"],
        "reference_answer": case["reference_answer"],
        "max_retries": case["max_retries"],
        "retry_count": case["simulated_retry_count"],
        "handoff_target": case["simulated_handoff_target"],
        "steps_completed": case["simulated_steps_completed"],
        "tool_calls": case["simulated_tool_calls"],
        "final_answer": case["simulated_final_answer"],
    }

    @traceable(
        name=f"Support case: {case['case_id']}",
        tags=[
            "ext_eval_pipelines",
            "support_agent",
            case["workflow"],
            case["agent_type"],
        ],
        project_name=project_name,
    )
    def _emit_payload():
        return payload

    return _emit_payload()


for case in cases:
    logged_trace = log_support_trace(case)
    print(
        json.dumps(
            {
                "case_id": logged_trace["case_id"],
                "retry_count": logged_trace["retry_count"],
                "handoff_target": logged_trace["handoff_target"],
                "final_answer": logged_trace["final_answer"],
            },
            indent=2,
        )
    )

ls_client.flush()
print("Traces flushed to LangSmith.")
{
  "case_id": "refund_double_charge",
  "retry_count": 1,
  "handoff_target": "billing_specialist",
  "final_answer": "I found the duplicate charge on order 8129 and handed your case to billing for refund review. You should receive an update within one business day."
}
{
  "case_id": "shipping_status_no_handoff_needed",
  "retry_count": 0,
  "handoff_target": "none",
  "final_answer": "I checked shipment 9912 and it is currently in transit. The latest carrier estimate shows delivery tomorrow."
}
{
  "case_id": "reset_password_wrong_handoff",
  "retry_count": 0,
  "handoff_target": "billing_specialist",
  "final_answer": "I reviewed the account issue and escalated it for follow-up. Please wait for the next update."
}
{
  "case_id": "cancel_subscription_missing_step",
  "retry_count": 0,
  "handoff_target": "none",
  "final_answer": "Your subscription is all set and should stop soon."
}
{
  "case_id": "invoice_request_retry_exceeded",
  "retry_count": 3,
  "handoff_target": "none",
  "final_answer": "I sent your March invoice to the email on file."
}
{
  "case_id": "wrong_fact_in_final_answer",
  "retry_count": 0,
  "handoff_target": "none",
  "final_answer": "Order 4481 can be returned within 14 days of delivery."
}
Traces flushed to LangSmith.

现在你应该能在 LangSmith 项目中看到有代表性的支持 Agent runs。打开 smith.langchain.com,在凭据单元中选择与 LANGSMITH_PROJECT_NAME 匹配的项目(默认为 default),然后浏览 Runs——如有需要,可按标签 ext_eval_pipelines 过滤。

本 notebook 不嵌入静态截图;LangSmith 的 UI 变化频繁,请直接查看实时项目视图。

为什么仅靠追踪不够

Tracing 能展示延迟、重试、工具调用和输出等运维行为。这是必要的,但仍不完整:当你更换模型或调整编排时,你还需要知道系统是否仍然满足工作流特定的质量期望,比如采取正确的交接、完成必需步骤,以及给出充分且正确的最终答案。

拉取有代表性的生产 trace

使用 Client.list_runs 从 LangSmith 拉取 runs 很简单。重要的不是随意拉取最近的 runs,而是选择有代表性的。实践中,你通常先按时间窗口过滤,再按标签、工作流、Agent 类型或与你即将做出的发布决策相关的其他切片缩小集合。

下面的代码先向 LangSmith 请求带 has(tags, "ext_eval_pipelines") runs。某些工作区或 SDK 版本即使在 traces 存在时也可能返回空列表,因此它会回退到列出最近的根 runs,并在 Python 中匹配该标签。如果项目非常繁忙(例如 default 项目),它还会把 API 时间窗口扩大到最近七天。

import json
import os
from datetime import datetime, timedelta, timezone

from langsmith import Client

BATCH_SIZE = 10
TOTAL_TRACES = 100
EVAL_TAG = "ext_eval_pipelines"
WORKFLOW_FILTERS = {"billing", "shipping", "returns", "account_access"}
AGENT_TYPE_FILTERS = {"triage_agent", "billing_agent", "returns_agent"}

LS_API_URL = os.environ["LANGCHAIN_ENDPOINT"]
PROJECT_NAME = os.environ["LANGCHAIN_PROJECT"]
ls_client = Client(api_url=LS_API_URL)

now = datetime.now(timezone.utc)
five_am_today = datetime(now.year, now.month, now.day, 5, 0, tzinfo=timezone.utc)
five_am_yesterday = five_am_today - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)


def parse_trace_payload(run):
    out = run.outputs
    if out is None:
        raise TypeError("Run has no outputs")
    if isinstance(out, dict):
        if set(out.keys()) == {"output"} and isinstance(out["output"], dict):
            return out["output"]
        if isinstance(out.get("output"), dict) and "case_id" in out["output"]:
            return out["output"]
        if "case_id" in out and "workflow" in out:
            return out
    if isinstance(out, str):
        return json.loads(out)
    raise TypeError("Unsupported run output format")


def is_support_case_trace(run):
    trace_name = getattr(run, "name", "") or ""
    if trace_name.startswith("Support case:"):
        return True

    try:
        payload = parse_trace_payload(run)
    except Exception:
        return False

    required_keys = {"workflow", "agent_type", "required_step", "final_answer"}
    return required_keys.issubset(payload.keys())


def matches_eval_filters(run):
    try:
        payload = parse_trace_payload(run)
    except Exception:
        return False

    return (
        payload.get("workflow") in WORKFLOW_FILTERS
        and payload.get("agent_type") in AGENT_TYPE_FILTERS
    )


def run_tags(run):
    tags = getattr(run, "tags", None) or []
    if tags:
        return list(tags)
    extra = getattr(run, "extra", None) or {}
    return list(extra.get("tags") or [])


def run_start_utc(run):
    st = run.start_time
    if st is None:
        return None
    if isinstance(st, str):
        st = datetime.fromisoformat(st.replace("Z", "+00:00"))
    if st.tzinfo is None:
        st = st.replace(tzinfo=timezone.utc)
    return st


def in_time_window(run, from_ts, to_ts):
    st = run_start_utc(run)
    if st is None:
        return True
    return from_ts <= st <= to_ts


def fetch_support_traces(from_timestamp, to_timestamp, verbose=True):
    """Pull root runs; prefer server-side tag filter, fall back if it returns nothing."""

    def query_runs(*, tag_filter: bool, start_time, limit: int):
        kw = dict(
            project_name=PROJECT_NAME,
            start_time=start_time,
            is_root=True,
            limit=limit,
        )
        if tag_filter:
            kw["filter"] = f'has(tags, "{EVAL_TAG}")'
        return list(ls_client.list_runs(**kw))

    # 1) Tag filter + API start_time (fast path when it matches your LangSmith version)
    candidates = query_runs(tag_filter=True, start_time=from_timestamp, limit=TOTAL_TRACES)
    if verbose and not candidates:
        print(
            "Note: list_runs with has(tags, ...) returned 0 rows; "
            "retrying without the server tag filter and matching tags in Python."
        )

    if not candidates:
        candidates = query_runs(tag_filter=False, start_time=from_timestamp, limit=TOTAL_TRACES)

    # 2) Still empty — widen API window to 7d (busy "default" projects may need this)
    if not candidates:
        if verbose:
            print("Note: widening API start_time to the last 7 days to find recent roots.")
        candidates = query_runs(
            tag_filter=False, start_time=seven_days_ago, limit=TOTAL_TRACES
        )

    if not candidates:
        if verbose:
            print(
                "Note: listing the most recent root runs with no start_time filter "
                f"(cap {TOTAL_TRACES}); check project name if this is still empty."
            )
        candidates = list(
            ls_client.list_runs(
                project_name=PROJECT_NAME,
                is_root=True,
                limit=TOTAL_TRACES,
            )
        )

    windowed = [r for r in candidates if in_time_window(r, from_timestamp, to_timestamp)]
    if not windowed and candidates:
        tag_only = [r for r in candidates if EVAL_TAG in run_tags(r)]
        if tag_only:
            if verbose:
                print(
                    "Note: no runs fell in the requested time window; "
                    "using tag-matched runs from the query batch instead."
                )
            windowed = tag_only
    tagged = [r for r in windowed if EVAL_TAG in run_tags(r)]
    raw_batch = tagged[:BATCH_SIZE] if tagged else windowed[:BATCH_SIZE]

    support_traces = [t for t in raw_batch if is_support_case_trace(t)]
    filtered_traces = [t for t in support_traces if matches_eval_filters(t)]
    return raw_batch, support_traces, filtered_traces


raw_batch, support_traces, traces_batch = fetch_support_traces(
    from_timestamp=five_am_yesterday,
    to_timestamp=now,
)

if not traces_batch:
    raw_batch, support_traces, traces_batch = fetch_support_traces(
        from_timestamp=seven_days_ago,
        to_timestamp=now,
    )
    print("No matching traces found in the last day, so the search was widened to the last 7 days.")

print(f"Tagged traces fetched: {len(raw_batch)}")
print(f"Support-case traces found: {len(support_traces)}")
print(f"Representative traces in first batch: {len(traces_batch)}")

if not traces_batch:
    raise ValueError(
        "No support-agent traces matched the evaluation filters. "
        "Re-run the prep cell in this kernel, then this cell. "
        f"Confirm runs land in project {PROJECT_NAME!r} (same LANGCHAIN_PROJECT as tracing) "
        f"and runs include tag {EVAL_TAG!r} (see LangSmith UI)."
    )
Tagged traces fetched: 10
Support-case traces found: 10
Representative traces in first batch: 10

定义多层评估标准

在本 notebook 中,我们将从五个维度对每条 trace 打分:

  • retry_budget_respected
  • correct_handoff
  • required_step_completed
  • final_answer_sufficient
  • final_answer_correct

前三个是理想的硬检查。后两个通常是结构化 rubric 检查或 trace 级评判发挥作用的地方。

硬检查应该尽可能多地承担工作:

  • retry_count <= max_retries
  • 必要的工具调用成功
  • 需要升级时存在必要的交接标签
  • 必需的工作流步骤出现在轨迹中

然后在严格的程序化比较不够的地方,添加基于 rubric 的充分性和正确性检查。

当路径本身重要(而不只是最终答案)时,trace 级评判才合适。这正是轨迹评判器或 RULER 这类系统在概念上很契合的地方:当失败只有在完整路径中才可见时,对照一个明确的目标达成 rubric 来比较轨迹。

在下面的代码中,我们保持生产模式简单:对工作流合规性做硬检查,外加对充分性和正确性的外部评判式打分。

from deepeval.metrics import (
    GEval,
    TaskCompletionMetric,
    ArgumentCorrectnessMetric,
    StepEfficiencyMetric,
)
from deepeval.test_case import LLMTestCaseParams, LLMTestCase, ToolCall

JUDGE_MODEL = "gpt-4o"
DEFAULT_THRESHOLD = 0.7


def normalize_retry_count(value, fallback):
    try:
        return int(value)
    except (TypeError, ValueError):
        return fallback


def normalize_steps_completed(value):
    if isinstance(value, list):
        return [str(item) for item in value]
    if isinstance(value, str):
        return [value]
    return []


def normalize_list(value):
    if isinstance(value, list):
        return value
    if value is None:
        return []
    return [value]


def normalize_tool_calls(value):
    normalized_calls = []
    for call in normalize_list(value):
        if isinstance(call, dict):
            normalized_calls.append(
                {
                    "name": str(call.get("name", "unknown_tool")),
                    "status": str(call.get("status", "unknown")),
                    "input": call.get("input", {}),
                    "output": call.get("output", {}),
                    "description": str(call.get("description", "")),
                }
            )
        else:
            normalized_calls.append(
                {
                    "name": str(call),
                    "status": "unknown",
                    "input": {},
                    "output": {},
                    "description": "",
                }
            )
    return normalized_calls


def normalize_payload(payload):
    normalized = dict(payload)
    normalized["retry_count"] = normalize_retry_count(
        normalized.get("retry_count"), normalized.get("max_retries", 0) + 1
    )
    normalized["steps_completed"] = normalize_steps_completed(normalized.get("steps_completed"))
    normalized["tool_calls"] = normalize_tool_calls(normalized.get("tool_calls"))
    normalized["handoff_target"] = str(normalized.get("handoff_target", "none"))
    normalized["expected_handoff"] = str(normalized.get("expected_handoff", "none"))
    normalized["required_step"] = str(normalized.get("required_step", ""))
    normalized["final_answer"] = str(normalized.get("final_answer", ""))
    normalized["reference_answer"] = str(normalized.get("reference_answer", ""))
    normalized["customer_request"] = str(normalized.get("customer_request", ""))
    normalized["workflow"] = str(normalized.get("workflow", "unknown"))
    normalized["agent_type"] = str(normalized.get("agent_type", "unknown"))
    normalized["max_retries"] = normalize_retry_count(normalized.get("max_retries"), 0)
    normalized["task_definition"] = str(
        normalized.get(
            "task_definition",
            f"Resolve {normalized.get('workflow','support')} request with required step {normalized.get('required_step','unknown')}."
        )
    )
    return normalized


def payload_to_tool_calls(payload_tools):
    return [
        ToolCall(
            name=tool.get("name", "unknown_tool"),
            description=tool.get("description", ""),
            input=tool.get("input", {}),
            output=tool.get("output", {}),
        )
        for tool in payload_tools
    ]


def build_llm_test_case(payload):
    payload = normalize_payload(payload)
    return LLMTestCase(
        input=payload["customer_request"],
        actual_output=payload["final_answer"],
        tools_called=payload_to_tool_calls(payload["tool_calls"]),
    )


def retry_budget_respected(payload):
    payload = normalize_payload(payload)
    return payload["retry_count"] <= payload["max_retries"]


def correct_handoff(payload):
    payload = normalize_payload(payload)
    return payload["handoff_target"] == payload["expected_handoff"]


def required_step_completed(payload):
    payload = normalize_payload(payload)
    return payload["required_step"] in payload["steps_completed"]


def tool_success(payload):
    payload = normalize_payload(payload)
    relevant_calls = [call for call in payload["tool_calls"] if payload["required_step"] in call["name"]]
    if not relevant_calls:
        return False
    return all(call["status"] == "success" for call in relevant_calls)


def trajectory_summary(payload):
    payload = normalize_payload(payload)
    tool_lines = [f"- {call['name']}: {call['status']}" for call in payload["tool_calls"]]
    return f"""
Customer request: {payload['customer_request']}
Workflow: {payload['workflow']}
Agent type: {payload['agent_type']}
Retry count: {payload['retry_count']} of {payload['max_retries']}
Expected handoff: {payload['expected_handoff']}
Observed handoff: {payload['handoff_target']}
Required step: {payload['required_step']}
Completed steps: {', '.join(payload['steps_completed'])}
Tool calls:
{chr(10).join(tool_lines) if tool_lines else '- none recorded'}
Final answer: {payload['final_answer']}
Reference answer: {payload['reference_answer']}
""".strip()


def final_answer_sufficient(payload):
    payload = normalize_payload(payload)
    metric = GEval(
        name="final_answer_sufficient",
        criteria=(
            "Determine whether the final answer is sufficient for the customer's request. "
            "The answer should reflect workflow context and include a clear next action when needed."
        ),
        evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
        model=JUDGE_MODEL,
    )
    test_case = LLMTestCase(input=trajectory_summary(payload), actual_output=payload["final_answer"])
    metric.measure(test_case)
    return {"score": metric.score, "reason": metric.reason}


def final_answer_correct(payload):
    payload = normalize_payload(payload)
    metric = GEval(
        name="final_answer_correct",
        criteria="Assess whether the final answer is correct given the workflow details and reference answer.",
        evaluation_params=[
            LLMTestCaseParams.INPUT,
            LLMTestCaseParams.ACTUAL_OUTPUT,
            LLMTestCaseParams.EXPECTED_OUTPUT,
        ],
        model=JUDGE_MODEL,
    )
    test_case = LLMTestCase(
        input=trajectory_summary(payload),
        actual_output=payload["final_answer"],
        expected_output=payload["reference_answer"],
    )
    metric.measure(test_case)
    return {"score": metric.score, "reason": metric.reason}


def score_task_completion(payload):
    p = normalize_payload(payload)
    metric = TaskCompletionMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        task=p["task_definition"],
        include_reason=True,
        async_mode=False,
    )
    metric.measure(build_llm_test_case(p))
    return {"score": float(metric.score), "reason": metric.reason}


def score_argument_correctness(payload):
    metric = ArgumentCorrectnessMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        include_reason=True,
        async_mode=False,
    )
    metric.measure(build_llm_test_case(payload))
    return {"score": float(metric.score), "reason": metric.reason}


def score_step_efficiency(payload):
    payload = normalize_payload(payload)
    metric = StepEfficiencyMetric(
        threshold=DEFAULT_THRESHOLD,
        model=JUDGE_MODEL,
        include_reason=True,
        async_mode=False,
    )
    try:
        metric.measure(build_llm_test_case(payload))
        return {"score": float(metric.score), "reason": metric.reason}
    except UnboundLocalError as exc:
        # DeepEval StepEfficiencyMetric can fail internally for some payload shapes.
        # Deterministic fallback: penalize retries and extra steps beyond a minimal plan.
        required = payload.get("required_step", "")
        steps = payload.get("steps_completed", [])
        max_retries = max(int(payload.get("max_retries", 0)), 0)
        retries = max(int(payload.get("retry_count", 0)), 0)

        base_plan_len = 2  # required step + response
        observed_len = len(steps)
        missing_required_penalty = 0.5 if required and required not in steps else 0.0
        extra_step_penalty = max(0, observed_len - base_plan_len) * 0.1
        retry_penalty = 0 if max_retries == 0 else min(retries / max_retries, 1.0) * 0.4

        score = max(0.0, min(1.0, 1.0 - missing_required_penalty - extra_step_penalty - retry_penalty))
        reason = (
            "Fallback score used because StepEfficiencyMetric failed internally "
            f"({exc.__class__.__name__}). "
            f"Derived from required-step presence, extra steps ({observed_len}), and retry usage ({retries}/{max_retries})."
        )
        return {"score": float(score), "reason": reason}

把评估逻辑封装到函数中,可以让管线易于测试和版本化。每个函数接收一个 trace payload,并返回一个布尔值、分类值或数值分值;如果你选择,这些分值以后可以作为 feedback 写回 LangSmith。

example_payload = parse_trace_payload(traces_batch[0])

example_scores = {
    "retry_budget_respected": retry_budget_respected(example_payload),
    "correct_handoff": correct_handoff(example_payload),
    "required_step_completed": required_step_completed(example_payload),
    "required_tool_succeeded": tool_success(example_payload),
    "final_answer_sufficient": final_answer_sufficient(example_payload),
    "final_answer_correct": final_answer_correct(example_payload),
}

example_scores
Output()
Output()
{'retry_budget_respected': True,
 'correct_handoff': True,
 'required_step_completed': True,
 'required_tool_succeeded': True,
 'final_answer_sufficient': {'score': 0.44777806741033865,
  'reason': "The Actual Output addresses the customer's request by providing a return deadline for order 4481, aligning with step 1. However, it does not match the Reference answer, indicating a discrepancy in the return policy information. The response is concise and directly answers the request, fulfilling step 4, but lacks additional workflow context or a suggested next action, which affects steps 2 and 3."},
 'final_answer_correct': {'score': 0.39385741192404006,
  'reason': "The Actual Output does not match the Expected Output, as it states a 14-day return period instead of the correct 30-day period. The Input aligns with the workflow details, as the required step 'lookup_return_policy' was completed successfully. However, the discrepancy in the return period is not justified by the Input or workflow details, indicating a failure in accurately retrieving or conveying the return policy information."}}

在外部给 trace 打分

使用外部评判逻辑、硬检查,或两者结合。关键是打分逻辑要放在追踪系统之外,这样它才能随你的应用一起演进。LangSmith 可以存储 run,并可选地在 run 上存储 feedback,但"什么算好的行为"由你的评估器来定义。

你可以使用任何评估库。这里的独立数值检查用 deepeval 就够了;当你需要显式的组间相对比较(尤其是开放式答案)时,RULER 式的轨迹评判是很好的下一步。

evaluated_traces = []

for trace in traces_batch:
    payload = normalize_payload(parse_trace_payload(trace))

    sufficiency = final_answer_sufficient(payload)
    correctness = final_answer_correct(payload)
    task_completion = score_task_completion(payload)
    argument_correctness = score_argument_correctness(payload)
    step_efficiency = score_step_efficiency(payload)

    evaluated_traces.append(
        {
            "trace_id": str(trace.id),
            "case_id": payload["case_id"],
            "workflow": payload["workflow"],
            "agent_type": payload["agent_type"],
            "payload": payload,
            "scores": {
                "retry_budget_respected": float(retry_budget_respected(payload)),
                "correct_handoff": float(correct_handoff(payload)),
                "required_step_completed": float(required_step_completed(payload)),
                "required_tool_succeeded": float(tool_success(payload)),
                "final_answer_sufficient": sufficiency["score"],
                "final_answer_correct": correctness["score"],
                "task_completion": task_completion["score"],
                "argument_correctness": argument_correctness["score"],
                "step_efficiency": step_efficiency["score"],
            },
            "reasons": {
                "final_answer_sufficient": sufficiency["reason"],
                "final_answer_correct": correctness["reason"],
                "task_completion": task_completion["reason"],
                "argument_correctness": argument_correctness["reason"],
                "step_efficiency": step_efficiency["reason"],
            },
        }
    )

evaluated_traces[0]
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
{'trace_id': '019d2066-0bf9-7d82-90ca-a6dc210fcb27',
 'case_id': 'wrong_fact_in_final_answer',
 'workflow': 'returns',
 'agent_type': 'returns_agent',
 'payload': {'agent_type': 'returns_agent',
  'case_id': 'wrong_fact_in_final_answer',
  'customer_request': 'What is the return deadline for order 4481?',
  'expected_handoff': 'none',
  'final_answer': 'Order 4481 can be returned within 14 days of delivery.',
  'handoff_target': 'none',
  'max_retries': 1,
  'reference_answer': 'Order 4481 is eligible for return within 30 days of delivery.',
  'required_step': 'lookup_return_policy',
  'retry_count': 0,
  'steps_completed': ['lookup_return_policy', 'respond_to_customer'],
  'tool_calls': [{'name': 'lookup_return_policy',
    'status': 'success',
    'input': {},
    'output': {},
    'description': ''}],
  'workflow': 'returns',
  'task_definition': 'Resolve returns request with required step lookup_return_policy.'},
 'scores': {'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 1.0,
  'required_tool_succeeded': 1.0,
  'final_answer_sufficient': 0.4721572213322875,
  'final_answer_correct': 0.49714501380217674,
  'task_completion': 0.6,
  'argument_correctness': 0.0,
  'step_efficiency': 1.0},
 'reasons': {'final_answer_sufficient': "The Actual Output addresses the customer's request by providing a return deadline for order 4481, aligning with step 1. However, it contradicts the reference answer, which states a 30-day return period, indicating a potential error in the lookup process. The response is concise and directly answers the request, fulfilling step 4, but lacks additional context or next steps, which could enhance clarity and guidance for the customer, as per steps 2 and 3.",
  'final_answer_correct': "The Actual Output does not match the Expected Output, as the return period stated is 14 days instead of the expected 30 days. The Input aligns with the workflow details, as the required step 'lookup_return_policy' was completed successfully, and no handoff was needed. However, the discrepancy in the return period is not justified by the Input or workflow details, indicating a partial misalignment in the process.",
  'task_completion': 'The system correctly identified the return window for order 4481, which is a crucial part of resolving a return request. However, it did not perform the required step of looking up the return policy, which may include additional conditions or steps necessary for processing the return.',
  'argument_correctness': 'The score is 0.00 because the tool call failed due to missing input parameters, which are essential for processing the request accurately.',
  'step_efficiency': 'Fallback score used because StepEfficiencyMetric failed internally (UnboundLocalError). Derived from required-step presence, extra steps (2), and retry usage (0/1).'}}

对于基于 LLM 的检查,请保留理由。它们对后续调试和审查临界用例很有用。对于硬检查,布尔值本身通常就足够了;但对于基于评判的分值,解释会成为审计线索的一部分。

可选:在 LangSmith 中给 runs 附加 feedback

这一步是可选的。如果你希望管线保持完全独立,可以在导出 runs、在外部打分、并把 benchmark 集合写入磁盘或其他存储之后停止。

如果你希望在与存储 traces 相同的系统里进行分值可视化和过滤,LangSmith 会再次变得特别有用。这种情况下,使用 SDK(Client.create_feedback)把布尔值、数值分值和评论附加回原始的 run,以便在 UI 中进行后续分析。

WRITE_SCORES_TO_LANGSMITH = False

if WRITE_SCORES_TO_LANGSMITH:
    for evaluated in evaluated_traces:
        rid = evaluated["trace_id"]
        for metric_name, metric_value in evaluated["scores"].items():
            kwargs = {
                "run_id": rid,
                "key": metric_name,
                "score": float(metric_value),
            }
            if metric_name in evaluated["reasons"]:
                kwargs["comment"] = evaluated["reasons"][metric_name]
            ls_client.create_feedback(**kwargs)

    print(f"Pushed feedback for {len(evaluated_traces)} runs.")
else:
    print(
        "Skipping LangSmith feedback write-back. Set WRITE_SCORES_TO_LANGSMITH = True to enable it."
    )
Skipping LangSmith feedback write-back. Set WRITE_SCORES_TO_LANGSMITH = True to enable it.

把关键失败提升为可回放的 benchmark 集合

这是从 tracing 通向回归测试的缺失桥梁。一旦关键失败被提升为带有其工作流元数据、参考答案、先前分值和评估器备注的可回放 benchmark 用例,这条 trace 的价值就会大幅提升。

这个 benchmark 集合也是你可以开始追踪更丰富文本质量标准的地方。像 deepeval 这样的独立指标可以逐条 trace 地对充分性和正确性打分。之后,如果你想对同一个 benchmark 用例比较多个候选轨迹,可以在其上叠加 RULER 之类的相对评判器。

import json

critical_failures = [
    evaluated
    for evaluated in evaluated_traces
    if (
        evaluated["scores"]["retry_budget_respected"] < 1
        or evaluated["scores"]["correct_handoff"] < 1
        or evaluated["scores"]["required_step_completed"] < 1
        or evaluated["scores"]["required_tool_succeeded"] < 1
        or evaluated["scores"]["final_answer_sufficient"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["final_answer_correct"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["task_completion"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["argument_correctness"] < DEFAULT_THRESHOLD
        or evaluated["scores"]["step_efficiency"] < DEFAULT_THRESHOLD
    )
]

benchmark_set_path = "benchmark_set_langsmith.jsonl"

with open(benchmark_set_path, "w", encoding="utf-8") as benchmark_file:
    for failure in critical_failures:
        benchmark_file.write(
            json.dumps(
                {
                    "case_id": failure["case_id"],
                    "workflow": failure["workflow"],
                    "agent_type": failure["agent_type"],
                    "benchmark_type": "critical_failure_regression",
                    "payload": failure["payload"],
                    "reference_answer": failure["payload"]["reference_answer"],
                    "scores": failure["scores"],
                    "reasons": failure["reasons"],
                    "text_quality_dimensions": [
                        "final_answer_sufficient",
                        "final_answer_correct",
                        "task_completion",
                        "argument_correctness",
                        "step_efficiency",
                    ],
                }
            )
            + "\n"
        )

print(f"Benchmark cases written: {len(critical_failures)}")
print(f"Benchmark set path: {benchmark_set_path}")
Benchmark cases written: 10
Benchmark set path: benchmark_set_langsmith.jsonl

在 benchmark 集合上比较候选模型版本

现在可以把 benchmark 集合用作轻量回归套件。与其因为新模型在几次抽查中表现不错就提升它,不如在重新部署前重新运行 benchmark 用例、在外部重新打分,并比较聚合结果。

在本 notebook 中查看均值,并逐例深挖失败原因;也可以在你自己的工具中重新加载 benchmark JSONL。

下面的代码单元会针对候选模型重放 benchmark 用例,并汇总所得分值。实践中,你应该在更改生产模型、路由逻辑或工具编排之前运行这一步。

这里仍然是独立地为每次运行打分。这很有用,但有时还不够。如果几个候选轨迹看起来都合理,而你希望评判器在目标达成或文本质量上对它们进行相互之间的相对排序,就在 benchmark 集合之上叠加 RULER 之类的相对评估器。

import statistics

from openai import OpenAI

openai_client = OpenAI()

with open(benchmark_set_path, "r", encoding="utf-8") as benchmark_file:
    benchmark_cases = [json.loads(line) for line in benchmark_file]


def run_candidate_agent(payload, model_name):
    payload = normalize_payload(payload)

    prompt = f"""
You are a customer support agent. Return JSON only with these keys:
retry_count, handoff_target, steps_completed, tool_calls, final_answer.

Constraints:
- steps_completed must be a JSON array of strings.
- tool_calls must be a JSON array of objects, each with keys name, status, input, output, description.
- retry_count must be an integer.
- handoff_target must be a string.
- final_answer must be a string.

Customer request: {payload['customer_request']}
Workflow: {payload['workflow']}
Required step: {payload['required_step']}
Expected handoff: {payload['expected_handoff']}
Reference answer: {payload['reference_answer']}
""".strip()

    response = openai_client.chat.completions.create(
        model=model_name,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[{"role": "user", "content": prompt}],
        timeout=120,
    )

    candidate = json.loads(response.choices[0].message.content)
    return normalize_payload(
        {
            **payload,
            "retry_count": candidate.get("retry_count", payload["max_retries"] + 1),
            "handoff_target": candidate.get("handoff_target", "none"),
            "steps_completed": candidate.get("steps_completed", []),
            "tool_calls": candidate.get("tool_calls", []),
            "final_answer": candidate.get("final_answer", ""),
        }
    )


def evaluate_candidate_model(model_name, benchmark_cases):
    model_scores = []
    for benchmark_case in benchmark_cases:
        payload = run_candidate_agent(benchmark_case["payload"], model_name)
        sufficiency = final_answer_sufficient(payload)
        correctness = final_answer_correct(payload)
        task_completion = score_task_completion(payload)
        argument_correctness = score_argument_correctness(payload)
        step_efficiency = score_step_efficiency(payload)
        model_scores.append(
            {
                "retry_budget_respected": float(retry_budget_respected(payload)),
                "correct_handoff": float(correct_handoff(payload)),
                "required_step_completed": float(required_step_completed(payload)),
                "required_tool_succeeded": float(tool_success(payload)),
                "final_answer_sufficient": sufficiency["score"],
                "final_answer_correct": correctness["score"],
                "task_completion": task_completion["score"],
                "argument_correctness": argument_correctness["score"],
                "step_efficiency": step_efficiency["score"],
            }
        )

    if not model_scores:
        return {"model": model_name, "cases": 0}

    return {
        "model": model_name,
        "cases": len(model_scores),
        **{k: statistics.mean(s[k] for s in model_scores) for k in model_scores[0].keys()},
    }


model_comparison = [
    evaluate_candidate_model("gpt-5.4", benchmark_cases),
    evaluate_candidate_model("gpt-5.4-mini", benchmark_cases),
]

model_comparison
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
Output()
[{'model': 'gpt-5.4',
  'cases': 10,
  'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 0.4,
  'required_tool_succeeded': 0.4,
  'final_answer_sufficient': 0.999470363254377,
  'final_answer_correct': 0.9999999999999999,
  'task_completion': 0.77,
  'argument_correctness': 0.0,
  'step_efficiency': 0.61},
 {'model': 'gpt-5.4-mini',
  'cases': 10,
  'retry_budget_respected': 1.0,
  'correct_handoff': 1.0,
  'required_step_completed': 0.9,
  'required_tool_succeeded': 0.0,
  'final_answer_sufficient': 0.9923726642647523,
  'final_answer_correct': 0.9893146250363849,
  'task_completion': 0.75,
  'argument_correctness': 0.2,
  'step_efficiency': 0.94}]

用 RULER 对 Trace 答案排序(LangSmith)

RULER 对 Trace 答案排序(LangSmith)

本 notebook 拉取ch09_example_external_evaluation_pipelines_langsmith.ipynb 相同的 LangSmith 根 runs:相同的项目标签ext_eval_pipelines)、过滤器payload 解析

注意: ch09_example_external_evaluation_pipelines_new_.ipynbLangfuse cookbook。此处的 RULER 只看到 LangSmith 中的 runs。请使用 LangSmith 外部评估 notebook(或将日志复制到 LangSmith),这样 traces 才会落入同一个项目。

%pip install langsmith openpipe-art nest-asyncio python-dotenv
import os
from dotenv import load_dotenv

load_dotenv()

# US-hosted LangSmith (default). For EU, set LANGCHAIN_ENDPOINT=https://eu.api.smith.langchain.com
LS_API_URL = os.getenv("LANGCHAIN_ENDPOINT", "https://api.smith.langchain.com")
os.environ["LANGCHAIN_ENDPOINT"] = LS_API_URL

api_key = os.getenv("LANGCHAIN_API_KEY") or os.getenv("LANGSMITH_API_KEY")
if not api_key:
    raise ValueError(
        "Set LANGCHAIN_API_KEY (or LANGSMITH_API_KEY) in your environment before running this notebook."
    )
os.environ["LANGCHAIN_API_KEY"] = api_key
os.environ["LANGSMITH_API_KEY"] = api_key

# LangSmith project for this cookbook — set here (same as LangSmith external-eval notebook).
# Use "default" unless you created a dedicated project in the UI first.
LANGSMITH_PROJECT_NAME = "default"
os.environ["LANGCHAIN_PROJECT"] = LANGSMITH_PROJECT_NAME

# Enable tracing for @traceable (mirrors LangSmith external-eval notebook)
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGSMITH_TRACING_V2"] = "true"
from dotenv import load_dotenv
import os

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
    print("OPENAI_API_KEY not found.")
    OPENAI_API_KEY = input("Enter OPENAI_API_KEY: ").strip()
    os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY

print("OPENAI_API_KEY is set.")
print("LangSmith credentials are set.")
print(f"LangSmith project (this notebook): {os.environ['LANGCHAIN_PROJECT']}")
OPENAI_API_KEY is set.
LangSmith credentials are set.
LangSmith project (this notebook): default
import json
import os
from datetime import datetime, timedelta, timezone
from collections import defaultdict

from langsmith import Client

BATCH_SIZE = 10
TOTAL_TRACES = 100
EVAL_TAG = "ext_eval_pipelines"
WORKFLOW_FILTERS = {"billing", "shipping", "returns", "account_access"}
AGENT_TYPE_FILTERS = {"triage_agent", "billing_agent", "returns_agent"}

LS_API_URL = os.environ["LANGCHAIN_ENDPOINT"]
PROJECT_NAME = os.environ["LANGCHAIN_PROJECT"]
ls_client = Client(api_url=LS_API_URL)

now = datetime.now(timezone.utc)
five_am_today = datetime(now.year, now.month, now.day, 5, 0, tzinfo=timezone.utc)
five_am_yesterday = five_am_today - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)


def parse_trace_payload(run):
    out = run.outputs
    if out is None:
        raise TypeError("Run has no outputs")
    if isinstance(out, dict):
        if set(out.keys()) == {"output"} and isinstance(out["output"], dict):
            return out["output"]
        if isinstance(out.get("output"), dict) and "case_id" in out["output"]:
            return out["output"]
        if "case_id" in out and "workflow" in out:
            return out
    if isinstance(out, str):
        return json.loads(out)
    raise TypeError("Unsupported run output format")


def is_support_case_trace(run):
    trace_name = getattr(run, "name", "") or ""
    if trace_name.startswith("Support case:"):
        return True
    try:
        payload = parse_trace_payload(run)
    except Exception:
        return False
    required_keys = {"workflow", "agent_type", "required_step", "final_answer"}
    return required_keys.issubset(payload.keys())


def matches_eval_filters(run):
    try:
        payload = parse_trace_payload(run)
    except Exception:
        return False
    return (
        payload.get("workflow") in WORKFLOW_FILTERS
        and payload.get("agent_type") in AGENT_TYPE_FILTERS
    )


def run_tags(run):
    tags = getattr(run, "tags", None) or []
    if tags:
        return list(tags)
    extra = getattr(run, "extra", None) or {}
    return list(extra.get("tags") or [])


def run_start_utc(run):
    st = run.start_time
    if st is None:
        return None
    if isinstance(st, str):
        st = datetime.fromisoformat(st.replace("Z", "+00:00"))
    if st.tzinfo is None:
        st = st.replace(tzinfo=timezone.utc)
    return st


def in_time_window(run, from_ts, to_ts):
    st = run_start_utc(run)
    if st is None:
        return True
    return from_ts <= st <= to_ts


def fetch_support_traces(from_timestamp, to_timestamp, verbose=True):
    def query_runs(*, tag_filter: bool, start_time, limit: int):
        kw = dict(
            project_name=PROJECT_NAME,
            start_time=start_time,
            is_root=True,
            limit=limit,
        )
        if tag_filter:
            kw["filter"] = f'has(tags, "{EVAL_TAG}")'
        return list(ls_client.list_runs(**kw))

    candidates = query_runs(tag_filter=True, start_time=from_timestamp, limit=TOTAL_TRACES)
    if verbose and not candidates:
        print(
            "Note: list_runs with has(tags, ...) returned 0 rows; "
            "retrying without the server tag filter and matching tags in Python."
        )
    if not candidates:
        candidates = query_runs(tag_filter=False, start_time=from_timestamp, limit=TOTAL_TRACES)
    if not candidates:
        if verbose:
            print("Note: widening API start_time to the last 7 days to find recent roots.")
        candidates = query_runs(tag_filter=False, start_time=seven_days_ago, limit=TOTAL_TRACES)
    if not candidates:
        if verbose:
            print(
                "Note: listing the most recent root runs with no start_time filter "
                f"(cap {TOTAL_TRACES}); check project name if this is still empty."
            )
        candidates = list(
            ls_client.list_runs(project_name=PROJECT_NAME, is_root=True, limit=TOTAL_TRACES)
        )

    windowed = [r for r in candidates if in_time_window(r, from_timestamp, to_timestamp)]
    if not windowed and candidates:
        tag_only = [r for r in candidates if EVAL_TAG in run_tags(r)]
        if tag_only:
            if verbose:
                print(
                    "Note: no runs fell in the requested time window; "
                    "using tag-matched runs from the query batch instead."
                )
            windowed = tag_only
    tagged = [r for r in windowed if EVAL_TAG in run_tags(r)]
    raw_batch = tagged[:BATCH_SIZE] if tagged else windowed[:BATCH_SIZE]

    support_traces = [t for t in raw_batch if is_support_case_trace(t)]
    filtered_traces = [t for t in support_traces if matches_eval_filters(t)]
    return raw_batch, support_traces, filtered_traces


raw_batch, support_traces, traces_batch = fetch_support_traces(
    from_timestamp=five_am_yesterday,
    to_timestamp=now,
)
if not traces_batch:
    raw_batch, support_traces, traces_batch = fetch_support_traces(
        from_timestamp=seven_days_ago,
        to_timestamp=now,
    )
    print("No matching traces found in the last day, so the search was widened to the last 7 days.")

print(f"Tagged traces fetched: {len(raw_batch)}")
print(f"Support-case traces found: {len(support_traces)}")
print(f"Representative traces in first batch: {len(traces_batch)}")

if not traces_batch:
    raise ValueError(
        "No support-agent traces matched the evaluation filters. "
        "Re-run the logging cells in ch09_example_external_evaluation_pipelines_langsmith.ipynb, then this cell. "
        f"Confirm runs land in project {PROJECT_NAME!r} (same LANGCHAIN_PROJECT as tracing) "
        f"and runs include tag {EVAL_TAG!r}."
    )

traces = [
    {"trace_id": str(run.id), "payload": parse_trace_payload(run)}
    for run in traces_batch
]
print(f"Rows for RULER grouping: {len(traces)}")
Tagged traces fetched: 10
Support-case traces found: 10
Representative traces in first batch: 10
Rows for RULER grouping: 10
def scenario_group_id(payload):
    return payload.get("scenario_group_id") or (
        f"{payload.get('workflow','unknown')}::{payload.get('required_step','unknown')}::{payload.get('expected_handoff','none')}"
    )

groups = defaultdict(list)
for row in traces:
    groups[scenario_group_id(row["payload"])].append(row)

grouped = {gid: rows[:5] for gid, rows in groups.items() if len(rows) >= 3}
print(f"Comparable groups (>=3 traces): {len(grouped)}")
Comparable groups (>=3 traces): 0
import asyncio
import nest_asyncio
from art.rewards import ruler


def build_messages(payload):
    return [
        {"role": "system", "content": "Judge answer text quality for customer support."},
        {
            "role": "user",
            "content": (
                f"Customer request: {payload.get('customer_request','')}\n"
                f"Workflow: {payload.get('workflow','')}\n"
                f"Required step: {payload.get('required_step','')}"
            ),
        },
        {"role": "assistant", "content": payload.get("final_answer", "")},
    ]


async def run_ruler(grouped_rows, judge_model="openai/o3"):
    ranked_rows = []
    for gid, rows in grouped_rows.items():
        message_lists = [build_messages(r["payload"]) for r in rows]
        scores = await ruler(
            message_lists,
            judge_model,
            rubric=(
                "Rank by empathy, technical relevance, policy correctness, and actionability. "
                "Prefer clear next steps. Penalize vague answers."
            ),
        )
        tmp = []
        for i, score in enumerate(scores):
            tmp.append(
                {
                    "group_id": gid,
                    "trace_id": rows[i]["trace_id"],
                    "case_id": rows[i]["payload"].get("case_id"),
                    "final_answer": rows[i]["payload"].get("final_answer"),
                    "ruler_score": score.score,
                    "ruler_explanation": score.explanation,
                }
            )
        tmp.sort(key=lambda x: x["ruler_score"], reverse=True)
        for rank, row in enumerate(tmp, start=1):
            row["rank_in_group"] = rank
            row["weak_answer_flag"] = rank == len(tmp)
        ranked_rows.extend(tmp)
    return ranked_rows


nest_asyncio.apply()
ruler_rankings = asyncio.run(run_ruler(grouped))
print(f"Total ranked rows: {len(ruler_rankings)}")
Total ranked rows: 0
weak_answers = [r for r in ruler_rankings if r["weak_answer_flag"]]
weak_answers[:10]
[]

在 benchmark 整理中使用 RULER 输出

  • weak_answer_flag=True 的行作为 benchmark 集合的额外候选。
  • 把 DeepEval/硬检查失败与 RULER 薄弱答案标志作为独立信号分开保留。
  • trace_id/case_id 合并,并标注来源(deterministicllm_judgecomparative_ruler)。