# Chấm quỹ đạo gọi tool của agent trên Langfuse: từ bộ dữ liệu vàng đến cổng chặn trong CI

> Agent trả lời đúng vẫn có thể gọi thừa tool, bỏ cuộc giữa chừng hoặc đi sai đường. Bài này hướng dẫn bạn chấm cả con đường đó bằng một bộ dataset nhỏ và vài chục dòng Python.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-eval-quy-dao-agent-voi-langfuse/

Một agent hoàn tiền trả lời khách "Đơn của bạn đã được hoàn tiền", và câu đó đúng. Có điều trên đường đi, nó đã tra cùng một đơn hàng hai lần.

Nếu chỉ chấm câu trả lời cuối, bạn sẽ không bao giờ thấy lần gọi thừa ấy. Lần này nó chỉ làm agent chậm hơn, nhưng nếu tool bị gọi lặp là một tool ghi dữ liệu, cái giá sẽ lớn hơn nhiều.

Tài liệu cookbook của Langfuse gọi kiểu chấm chỉ nhìn đáp án là black-box, vì nó bỏ qua mọi bước trung gian. Đánh giá quỹ đạo thì ngược lại: khi đáp án sai, nó chỉ ra lỗi xảy ra ở đúng bước nào trong quá trình suy luận.

Với một FDE, đây là khác biệt giữa câu "agent hơi kém" và câu "agent bỏ qua bước kiểm tra chính sách ở 3 trên 10 ca".

Bài này dẫn bạn đi trọn một vòng. Bạn sẽ dựng một dataset có quỹ đạo vàng, viết các hàm chấm, chạy experiment trên Langfuse, đọc kết quả rồi gắn vào CI. Bạn cần Python, một tài khoản Langfuse và một agent bất kỳ có gọi tool. Ví dụ agent hoàn tiền dưới đây là tình huống giả định để minh họa.

## Bước 1: Quỹ đạo vàng phải do người viết

Cookbook của Langfuse nói thẳng rằng bạn cần một bộ dữ liệu "gold standard" gồm input cùng output hoặc quỹ đạo mong đợi. Phần khó nhất của cả quy trình không nằm ở code. Khó nhất là ngồi với người vận hành nghiệp vụ và hỏi: với yêu cầu này, một nhân viên giỏi sẽ làm những bước nào, theo thứ tự nào?

Với agent hoàn tiền, giả sử quy trình đúng gồm ba bước: tra đơn, kiểm tra chính sách hoàn tiền, tạo lệnh hoàn tiền. Ghi lại thành một danh sách tên tool:

```python
expected = ["lookup_order", "check_refund_policy", "create_refund"]
```

**Kiểm tra:** mỗi tool trong danh sách phải có tên trùng khớp từng ký tự với tên tool agent thực sự gọi. Nếu không, mọi điểm số phía sau đều sai.

## Bước 2: Đưa quỹ đạo vào dataset Langfuse

Langfuse định nghĩa dataset là một tập input và expected output dùng để kiểm thử ứng dụng. Mỗi item có input, còn expected output là tùy chọn, và chính chỗ đó dùng để chứa quỹ đạo. Cookbook đặt chuỗi tool call mong đợi ở trường `expected_output.trajectory`.

Đoạn code dưới đây là phác thảo. Tên hàm và tham số có thể khác nhau giữa các phiên bản SDK, nên hãy đối chiếu với tài liệu của phiên bản bạn cài.

```python
# Phác thảo — đối chiếu chữ ký hàm với tài liệu Langfuse SDK
langfuse.create_dataset_item(
dataset_name="refund-agent-trajectory",
input={"message": "Tôi muốn hoàn tiền đơn 1234"},
expected_output={
"trajectory": ["lookup_order", "check_refund_policy", "create_refund"]
},
)
```

**Kiểm tra:** mở dataset trên giao diện Langfuse và xác nhận mỗi item hiển thị đủ cả input lẫn `trajectory`.

## Bước 3: Ba kiểu khớp, ba câu hỏi khác nhau

Tài liệu đánh giá agent của Vertex AI (Google Cloud) mô tả ba kiểu khớp, và mỗi kiểu trả lời một câu hỏi riêng. Exact match yêu cầu đúng các tool call, đúng thứ tự, không thừa không thiếu.

In-order match chấp nhận tool call thừa, miễn đủ các tool tham chiếu theo đúng thứ tự. Any-order match chỉ cần đủ các tool tham chiếu, thứ tự không quan trọng.

```python
def exact_match(actual, expected):
return 1.0 if actual == expected else 0.0

def in_order_match(actual, expected):
it = iter(actual)
return 1.0 if all(tool in it for tool in expected) else 0.0

def any_order_match(actual, expected):
# Đơn giản hóa: dùng set nên bỏ qua tool lặp lại
return 1.0 if set(expected) <= set(actual) else 0.0
```

Giờ áp vào ca agent tra đơn hai lần: `actual = ["lookup_order", "lookup_order", "check_refund_policy", "create_refund"]`. Exact match cho 0 vì quỹ đạo không giống hệt chuẩn. In-order match cho 1 vì các bước bắt buộc vẫn đúng thứ tự, và any-order match cũng cho 1 vì không thiếu bước nào.

Cùng một lần chạy mà nhận một điểm 0 và hai điểm 1. Điểm nhị phân cho biết có lệch hay không, nhưng không cho biết lệch nhiều đến đâu. Đó là lý do cần thêm một thước đo liên tục.

## Bước 4: Precision và recall theo LCS

Pydantic Evals tính hai chỉ số dựa trên chuỗi con chung dài nhất (LCS) giữa quỹ đạo thực tế và quỹ đạo mong đợi: precision bằng LCS chia độ dài quỹ đạo thực tế, recall bằng LCS chia độ dài quỹ đạo mong đợi. Pydantic cũng quy định khi cả hai quỹ đạo đều rỗng thì mọi chế độ đều được 1.0.

```python
def lcs_len(a, b):
dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(len(a)):
for j in range(len(b)):
dp[i+1][j+1] = dp[i][j] + 1 if a[i] == b[j] else max(dp[i][j+1], dp[i+1][j])
return dp[-1][-1]

def precision_recall(actual, expected):
if not actual and not expected:
return 1.0, 1.0
l = lcs_len(actual, expected)
p = l / len(actual) if actual else 0.0   # quy ước riêng cho ca một bên rỗng
r = l / len(expected) if expected else 0.0
return p, r
```

Tính tay để chắc mình hiểu. Ca tra đơn hai lần có LCS bằng 3 và quỹ đạo thực tế dài 4, nên precision = 3/4 = 0,75 và recall = 3/3 = 1. Ca thứ hai: agent chỉ gọi `lookup_order` rồi trả lời luôn. LCS bằng 1, precision = 1/1 = 1, còn recall = 1/3, khoảng 0,33.

Decagon đưa ra một cách đọc rất gọn: precision thấp mà recall cao là agent lãng phí, precision cao mà recall thấp là agent bỏ cuộc sớm. Hai ca trên rơi đúng vào hai kiểu đó, và mỗi kiểu cần một cách sửa riêng.

Agent lãng phí thường cần prompt hoặc bộ nhớ tốt hơn để không gọi lại tool. Agent bỏ cuộc sớm thường cần điều kiện dừng chặt hơn.

**Điểm mấu chốt:** Đáp án đúng chưa đủ: hãy chấm cả con đường agent đi tới đáp án.

## Bước 5: Chạy experiment trên dataset có version

Langfuse cho phép chạy experiment trực tiếp trên dataset có version qua SDK, bằng `run_experiment` cùng một task function. Task function nhận một item, gọi agent và trả về danh sách tên tool agent đã gọi. Cách lấy danh sách đó tùy framework agent bạn dùng.

```python
# Phác thảo — tên tham số cần đối chiếu tài liệu SDK
RECALLS = []  # gom recall từng item để dùng ở Bước 6

def task(item):
return run_agent_and_collect_tool_names(item.input)  # hàm của bạn

def trajectory_eval(output, expected_output):
p, r = precision_recall(output, expected_output["trajectory"])
RECALLS.append(r)
return {"precision": p, "recall": r}

dataset.run_experiment(name="baseline-v1", task=task, evaluators=[trajectory_eval])
```

**Kiểm tra:** mỗi item phải có một trace cùng điểm precision và recall. Hãy mở một trace có recall thấp và tìm đúng bước agent dừng lại. Đó chính là giá trị của glass-box.

## Bước 6: Biến điểm số thành cổng chặn

Cookbook của Langfuse gợi ý đưa đánh giá vào CI/CD để chặn những lần triển khai làm tụt điểm trên benchmark dataset. Cách đơn giản nhất là lưu recall trung bình của lần chạy gần nhất vào một file, rồi cho build fail nếu lần chạy mới thấp hơn.

Script dưới đây là bản đơn giản hóa: nó lấy `RECALLS` gom được ở Bước 5, không đọc kết quả từ API của Langfuse. Gọi nó ngay sau `run_experiment` trong job CI.

```python
# ci_gate.py — bản đơn giản hóa
import json, statistics, sys
from pathlib import Path

BASELINE = Path("eval/baseline_recall.json")

def gate(recalls):
current = statistics.mean(recalls)
if BASELINE.exists():
previous = json.loads(BASELINE.read_text())["recall"]
if current < previous:
print(f"FAIL: recall giảm từ {previous:.2f} xuống {current:.2f}")
sys.exit(1)
BASELINE.parent.mkdir(parents=True, exist_ok=True)
BASELINE.write_text(json.dumps({"recall": current}))
print(f"OK: recall trung bình = {current:.2f}")

gate(RECALLS)
```

Hãy commit file baseline vào repo và chỉ cập nhật nó khi merge vào nhánh chính. Nếu để mọi nhánh tự ghi đè, một nhánh kém có thể hạ chuẩn cho nhánh sau. Nên gate thêm cả precision theo cùng cách, vì recall một mình không bắt được agent lãng phí.

**Kiểm tra:** cố tình làm hỏng agent, chẳng hạn sửa prompt để nó bỏ qua `check_refund_policy`. Recall của ca hoàn tiền tụt từ 1 xuống 2/3, recall trung bình giảm, và job CI phải kết thúc với mã lỗi 1. Nếu build vẫn xanh, cổng của bạn chưa hoạt động.

Với agent có quyền ghi như `create_refund`, nên cho cổng này chạy trên mọi thay đổi prompt, không chỉ trên thay đổi code.

## Ba lỗi hay gặp

Lỗi đầu tiên là khớp cứng khi nghiệp vụ có nhiều đường đi hợp lệ. Nếu tra đơn bằng mã đơn hay bằng số điện thoại đều đúng, exact match sẽ phạt oan agent. Decagon khuyên dùng LLM-as-a-judge đọc toàn bộ trace theo một rubric cho những trường hợp như vậy, còn khớp cứng nên dành cho các quy trình có thứ tự bắt buộc.

Lỗi thứ hai là quên quyết định có tính tool call bị lỗi hay không. Pydantic có tùy chọn `include_failed` cho đúng câu hỏi này. Một lần gọi `create_refund` thất bại rồi thử lại sẽ cho precision rất khác tùy cách bạn chọn, vì vậy hãy chọn một lần rồi ghi rõ trong tài liệu.

Riêng với hàm `any_order_match` ở Bước 3, lần thử lại đó vô hình hoàn toàn. Hàm dùng set, nên `create_refund` xuất hiện một hay hai lần đều cho điểm 1. Muốn bắt tool lặp, hãy dựa vào precision theo LCS hoặc đổi sang so `collections.Counter` thay cho set.

Lỗi thứ ba là để dataset chỉ toàn ca "đường thẳng". Hãy thêm những ca mà quỹ đạo đúng là rỗng, ví dụ khách chỉ chào hỏi; theo quy tắc của Pydantic, cả hai cùng rỗng thì được 1.0. Ca đáng lo là khi quỹ đạo mong đợi rỗng mà agent vẫn gọi, chẳng hạn, `lookup_order`:

| Thước đo | Điểm | Vì sao |
|---|---|---|
| Exact match | 0 | Quỹ đạo thực tế khác danh sách rỗng |
| In-order match | 1 | Danh sách mong đợi rỗng nên điều kiện "đủ tool tham chiếu" luôn đúng |
| Any-order match | 1 | Cùng lý do: tập rỗng luôn nằm trong tập thực tế |
| Precision | 0 | LCS bằng 0, chia cho độ dài quỹ đạo thực tế |
| Recall | 0 | Quy ước riêng của hàm `precision_recall` ở trên, không phải của Pydantic |

Vì vậy với ca quỹ đạo rỗng, hãy chấm bằng exact match hoặc precision, đừng tin in-order và any-order.

## Ở phía khách hàng, kỹ năng này trông thế nào

Tại site khách hàng, buổi làm việc quan trọng nhất thường là buổi bạn ngồi với nhân viên vận hành để viết quỹ đạo vàng. Bộ dataset đó trở thành hợp đồng ngầm giữa bạn và khách về chuyện thế nào là "agent làm đúng". Khi khách hỏi "bản mới có tốt hơn không?", bạn trả lời bằng recall trước và sau, không phải bằng cảm giác.

Khi đọc JD của các vị trí FDE hay AI engineer, hãy để ý các cụm như "evaluation", "eval harness", "regression testing for agents". Trong CV, đừng chỉ viết "xây dựng agent".

Hãy viết rằng bạn dựng benchmark quỹ đạo cho agent, chẩn đoán được lỗi gọi thừa và lỗi bỏ cuộc sớm, rồi gắn cổng chặn hồi quy vào CI. Nếu có thể, ghi kèm recall trước và sau khi bạn sửa agent, đo trên chính dataset đó.

Agent sẽ ngày càng được giao nhiều quyền hơn trong hệ thống của khách. Người được tin để ký duyệt bản deploy tiếp theo sẽ là người chỉ ra được agent đã đi qua những bước nào.

**Thử ngay tuần này:**

- Viết 10 dataset item cho một agent bạn đang làm, mỗi item kèm expected_output.trajectory do chính bạn vẽ tay.
- Chạy hàm precision/recall theo LCS trên 10 item đó và phân loại từng lỗi: gọi thừa hay bỏ cuộc sớm.
- Thêm một bước CI fail build khi recall trung bình thấp hơn lần chạy trước.

## Nguồn

- [Datasets (Langfuse documentation)](https://langfuse.com/docs/evaluation/experiments/datasets)

- [Agent Evaluation - How to Evaluate LLM Agents (Langfuse)](https://langfuse.com/guides/cookbook/example_pydantic_ai_mcp_agent_evaluation)

- [Evaluate Gen AI agents (Google Cloud Documentation)](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/evaluation-agents)

- [Agentic Evaluators (Pydantic AI docs)](https://pydantic.dev/docs/ai/evals/evaluators/agentic/)

- [What is trajectory evaluation? Scoring agent paths, not just answers | Decagon](https://decagon.ai/glossary/what-is-trajectory-evaluation)
