# Dự án tổng hợp: ứng dụng hỏi đáp tự ghi trace và đếm token cho từng câu hỏi

> Ứng dụng nào cũng trả lời được câu hỏi trong buổi demo. Khi đưa lên production, bạn còn phải chỉ ra được nó đã trả lời thế nào và mỗi câu trả lời tốn bao nhiêu token.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-ung-dung-ai-tron-goi-co-observability/

Thử hình dung bạn vừa demo xong một ứng dụng hỏi đáp tài liệu nội bộ cho khách hàng. Câu trả lời đúng. Ngay sau đó trưởng nhóm vận hành hỏi hai câu: câu trả lời này dựa trên đoạn tài liệu nào, và nếu cả công ty cùng dùng thì mỗi tháng tốn bao nhiêu?

Nếu không trả lời được, sản phẩm của bạn mới chỉ là một bản demo. Dự án dưới đây thêm phần còn thiếu: mỗi request đều để lại trace và số token, và một dashboard cộng các con số đó thành chi phí.

Nếu bạn nhắm tới vai trò FDE, đây là loại bài tập nên có trong portfolio. Bạn không chỉ làm cho ứng dụng chạy, mà còn phải chứng minh được nó chạy thế nào.

## Bạn sẽ xây gì, và cần chuẩn bị gì

AWS định nghĩa full stack development là phát triển cả frontend lẫn backend của ứng dụng. Ứng dụng hỏi đáp này có đủ bốn lớp theo cách AWS mô tả. Giao diện là nơi người dùng gõ câu hỏi. Lớp API nhận tương tác từ frontend và chuyển xuống storage. Lớp business logic là lõi của backend. Lớp storage quản lý và lưu dữ liệu của ứng dụng.

Bạn cần Python, SQLite (đi kèm thư viện chuẩn của Python), một web framework bạn đã quen và quyền gọi một LLM bất kỳ. Code dưới đây là **bản đơn giản hoá** để thấy cấu trúc. Hai hàm `truy_xuat` và `goi_llm` là chỗ bạn gắn retrieval và SDK LLM của riêng mình.

## Bước 1: thiết kế storage trước khi viết prompt

Phần lớn người làm dự án đầu tay chỉ lưu câu trả lời. Ở đây storage cần hai bảng ngay từ đầu: một bảng ghi từng bước của request, một bảng ghi token.

```sql
CREATE TABLE traces (
trace_id TEXT, step TEXT, detail TEXT, created_at TEXT
);
CREATE TABLE usage (
trace_id TEXT, query_type TEXT,
input_tokens INTEGER, output_tokens INTEGER, created_at TEXT
);
```

**Kiểm tra:** chạy `.schema` trong sqlite3 và thấy đủ hai bảng. Đừng bỏ cột `query_type`: đến bước 4, dashboard sẽ dựa vào nó để cắt chi phí theo loại câu hỏi.

Vậy giá trị của `query_type` lấy từ đâu? Cách đơn giản nhất cho bài tập là để người dùng tự chọn trên giao diện, chẳng hạn một dropdown gồm "tra cứu chính sách" và "so sánh hợp đồng". Cách này dễ làm, nhưng số liệu chỉ đúng khi người dùng chọn đúng.

Cách thứ hai là gán tự động ở lớp business logic bằng một classifier: vài luật theo từ khoá, hoặc một lần gọi LLM ngắn. Nếu phân loại bằng LLM, hãy ghi cả lần gọi đó vào trace và bảng usage, vì nó cũng tốn token. Nên bắt đầu bằng dropdown, lưu lại câu hỏi thật, rồi dùng chính dữ liệu đó để thử classifier sau.

## Bước 2: trace phải ghi cả các bước ở giữa

Một câu trả lời đúng chưa chắc đã đi qua các bước đúng. Nếu chỉ lưu kết quả cuối, bạn không biết model đã dựa vào đoạn tài liệu nào. IBM liệt kê trace cùng metrics và log trong số dữ liệu cần thu từ ứng dụng LLM; với ứng dụng hỏi đáp, giá trị của trace nằm chính ở những bước ở giữa này.

JetBrains lấy ví dụ vòng lặp ReAct: khi từng "thought" được ghi lại dưới dạng text, bạn có trace đầy đủ của quá trình suy luận. Nhờ vậy bạn đánh giá được các bước đó, kể cả khi câu trả lời cuối trông vẫn đúng.

Với ứng dụng hỏi đáp, "các bước" là câu hỏi đầu vào, những đoạn tài liệu được truy xuất và câu trả lời. Trace và token đều được ghi ở lớp business logic:

```python
# Bản đơn giản hoá: truy_xuat và goi_llm là hàm của bạn
import sqlite3, uuid
from datetime import datetime, timezone

db = sqlite3.connect("qa.db")

def now():
return datetime.now(timezone.utc).isoformat()

def ghi_trace(trace_id, step, detail):
db.execute("INSERT INTO traces VALUES (?,?,?,?)",
(trace_id, step, detail, now()))

def xu_ly_cau_hoi(cau_hoi, query_type):
trace_id = str(uuid.uuid4())
ghi_trace(trace_id, "nhan_cau_hoi", cau_hoi)
doan = truy_xuat(cau_hoi)
ghi_trace(trace_id, "truy_xuat", " | ".join(doan))
kq = goi_llm(cau_hoi, doan)  # trả về text và số token
ghi_trace(trace_id, "tra_loi", kq["text"])
db.execute("INSERT INTO usage VALUES (?,?,?,?,?)",
(trace_id, query_type, kq["input_tokens"],
kq["output_tokens"], now()))
db.commit()
return {"trace_id": trace_id, "answer": kq["text"]}
```

Endpoint API chỉ cần gọi `xu_ly_cau_hoi` rồi trả JSON về giao diện. Nhớ trả về cả `trace_id`. Khi người dùng báo một câu trả lời sai, bạn tìm ngay được trace tương ứng thay vì phải đoán.

**Kiểm tra:** gửi một câu hỏi, rồi chạy `SELECT step FROM traces WHERE trace_id = '...'`. Kết quả đúng là ba dòng theo thứ tự nhận câu hỏi, truy xuất, trả lời.

## Bước 3: tiền được tính theo token

Câu hỏi "mỗi tháng tốn bao nhiêu" chỉ trả lời được khi bạn đếm token. IBM khuyên theo dõi số token được xử lý, nhất là khi token gắn với chi phí của model.

Ở phía request, số token của prompt là nguồn chi phí chính, theo hướng dẫn về quy ước GenAI của OpenTelemetry trên blog OpenObserve. Trong OpenTelemetry, con số này là span attribute `gen_ai.usage.input_tokens`.

Vì thế, khi chuyển từ SQLite sang OpenTelemetry, cột `input_tokens` của bạn tương ứng với attribute đó. Để tính tiền, nhân token với đơn giá trong bảng giá của nhà cung cấp model bạn dùng. Hãy để đơn giá ở file cấu hình, đừng viết cứng vào code.

## Bước 4: dashboard phải trả lời ba câu hỏi

Một con số tổng chi phí không cho bạn biết phải sửa ở đâu. Những lãng phí nhỏ dồn lại rất lớn theo thời gian, nên JetBrains khuyên theo dõi token liên tục và cắt chi phí theo từng request, theo loại câu hỏi và theo thời gian. Ba cách cắt đó là ba truy vấn:

```sql
-- Theo loại câu hỏi
SELECT query_type, COUNT(*) AS so_request,
AVG(input_tokens) AS input_tb, SUM(input_tokens) AS input_tong
FROM usage GROUP BY query_type;

-- Theo ngày
SELECT substr(created_at, 1, 10) AS ngay,
SUM(input_tokens), SUM(output_tokens)
FROM usage GROUP BY ngay ORDER BY ngay;

-- 10 request đắt nhất, kèm trace_id để mở trace
SELECT trace_id, query_type, input_tokens
FROM usage ORDER BY input_tokens DESC LIMIT 10;
```

Lấy một ví dụ giả định để thấy vì sao cần cắt theo loại. Câu hỏi tra cứu chính sách trung bình dùng 2.000 input token. Câu hỏi so sánh hai hợp đồng kéo về nhiều đoạn hơn và dùng 6.000. Nếu mỗi ngày có 1.000 câu hỏi so sánh, riêng phần chênh 4.000 token mỗi câu đã thành 4.000.000 token mỗi ngày.

Truy vấn thứ ba giúp bạn tìm ra nguyên nhân. Mở trace của request đắt nhất, bạn sẽ biết bước truy xuất kéo về quá nhiều đoạn hay prompt bị lặp. Muốn giảm chi phí, hãy sửa ở chính bước đó.

Giao diện dashboard có thể chỉ là một trang gọi ba truy vấn này rồi vẽ bảng. Để làm bài tập thì vậy là đủ.

## Những lỗi khiến dashboard nói sai

Lỗi đầu tiên là chỉ ghi câu trả lời cuối. Khi đó dashboard có số liệu chi phí nhưng không giải thích được vì sao chi phí cao, vì trace thiếu bước truy xuất.

Lỗi thứ hai là lấy tên attribute OpenTelemetry trên mạng về dùng mà không cố định phiên bản. Theo OpenObserve, các quy ước cho agent và tool orchestration vẫn ở trạng thái Development. Trang spec metrics GenAI chính thức của OpenTelemetry cũng đã chuyển sang repository semantic-conventions-genai riêng, nên hãy ghi phiên bản spec bạn dùng vào README và tra tên hiện hành ở repository mới.

Lỗi thứ ba là lẫn evaluation với observability. JetBrains phân biệt như sau: evaluation xác định agent *có thể* làm được việc hay không, còn observability xác định nó *có đang* làm được hay không. Dashboard chi phí không thay được bộ test chất lượng. Bạn cần cả hai.

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

Khi đến khách hàng, việc nên làm đầu tiên là hỏi họ muốn cắt chi phí theo chiều nào: theo phòng ban, theo loại tài liệu hay theo người dùng. Câu trả lời quyết định các cột trong bảng `usage`, trước khi bạn viết dòng prompt nào.

Khi đọc JD, nếu mô tả công việc nhắc đến việc vận hành hay giám sát ứng dụng LLM, hãy đưa dự án này lên đầu. Trong CV, đừng viết "xây chatbot RAG". Hãy viết rằng bạn đã xây ứng dụng hỏi đáp full stack, mỗi request có trace ba bước và dashboard chi phí theo loại câu hỏi, kèm link repo.

**Điểm mấu chốt:** Câu trả lời đúng đủ để qua buổi demo. Muốn được dùng thật, hệ thống phải cho thấy nó trả lời dựa vào đâu và tốn bao nhiêu.

Một dự án cá nhân có đủ bốn lớp và hai bảng dữ liệu này cho thấy bạn hiểu rằng khách hàng không chỉ mua câu trả lời. Họ còn cần biết câu trả lời đến từ đâu và tốn bao nhiêu.

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

- Dựng bảng usage trong SQLite với các cột trace_id, query_type, input_tokens, output_tokens và created_at, rồi ghi một dòng cho mỗi câu hỏi gửi vào ứng dụng của bạn.
- Chạy 30 câu hỏi thuộc ba loại khác nhau rồi viết truy vấn GROUP BY query_type để tìm loại tốn input token nhất.
- Trong README của dự án, chụp màn hình một trace đầy đủ cùng bảng chi phí theo loại câu hỏi. Đây là bằng chứng bạn có thể dẫn link trong CV.

## Nguồn

- [What is Full Stack Development?](https://aws.amazon.com/what-is/full-stack-development/)

- [What is LLM observability?](https://www.ibm.com/think/topics/llm-observability)

- [LLM Evaluation and AI Observability for Agent Monitoring](https://blog.jetbrains.com/pycharm/2026/05/llm-evaluation-and-ai-observability-for-agent-monitoring/)

- [OpenTelemetry GenAI Semantic Conventions: A Practical Guide](https://openobserve.ai/blog/opentelemetry-genai-semantic-conventions/)

- [Semantic conventions for generative AI metrics](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/)
