Nếu một prompt tốt chỉ được viết ra một lần rồi copy-paste rải rác khắp codebase, đội ngũ sẽ phải trả giá nhiều lần cho cùng một lỗi: token bị lãng phí vì instruction dư thừa, output không ổn định vì mỗi nơi sửa một kiểu, và không ai biết prompt "chuẩn" hiện tại là bản nào. Prompt template (mẫu prompt) giải quyết đúng vấn đề này — biến prompt từ một chuỗi ký tự tùy tiện thành một artifact (tài sản) có thể version, test, review và tái sử dụng như bất kỳ đoạn code nào khác. Bài học này đi từ tư duy quản trị prompt như code, đến cách thiết kế kiến trúc template, một catalog các pattern đã được kiểm chứng, kỹ thuật composition cho pipeline phức tạp, và cách test/version/rollback prompt một cách an toàn.
Prompt Template Là Artifact Hạng Nhất Của Codebase
Anti-pattern phổ biến nhất trong các hệ thống dùng LLM là "prompt string rải rác" (scattered prompt strings): mỗi engineer viết một biến prompt = f"..." ngay trong file xử lý nghiệp vụ của mình. Ban đầu tiện, nhưng sau vài tháng, cùng một tác vụ (ví dụ: review code, phân loại ticket, tóm tắt nội dung) có thể tồn tại 4-5 phiên bản prompt khác nhau nằm rải trong 4-5 module, không ai biết bản nào đang chạy production, và khi cần tối ưu token, không ai dám sửa vì sợ vỡ chỗ khác.
Cách khắc phục là coi prompt như code: đưa vào một thư mục riêng (thường gọi là prompts/), review qua pull request giống mọi thay đổi logic khác, và có chủ sở hữu (owner) rõ ràng. Khi một prompt được đóng gói thành module độc lập, nó có thể được unit test, đo token, và tái sử dụng bởi nhiều service khác nhau mà không cần copy-paste.
def review_pull_request(diff: str) -> str:
prompt = f"""You are a senior engineer. Review this diff carefully
and point out any bugs, style issues, or security concerns.
Diff:
{diff}
"""
return call_llm(prompt)
def audit_file(code: str) -> str:
prompt = f"""Please act as an expert reviewer. Look at the code below
and tell me if there are bugs or issues with style/security.
Code:
{code}
"""
return call_llm(prompt)
from dataclasses import dataclass
SYSTEM_PREAMBLE = (
"You are a senior code reviewer. Be concise. "
"Report only actionable findings, ranked by severity."
)
@dataclass
class CodeReviewPrompt:
version: str = "1.2.0"
def build(self, diff: str, max_findings: int = 5) -> list[dict]:
return [
{"role": "system", "content": SYSTEM_PREAMBLE},
{
"role": "user",
"content": (
f"Review this diff. Return at most {max_findings} findings "
f"as a JSON array.\n\nDiff:\n{diff}"
),
},
]
Với cách tổ chức này, khi cần tối ưu token (ví dụ rút ngắn system preamble, hoặc giảm max_findings mặc định), chỉ cần sửa một chỗ, review một PR, và toàn bộ hệ thống được cập nhật đồng bộ. Đây chính là ý nghĩa của "khấu hao" (amortize) chi phí thiết kế prompt — công sức đầu tư một lần được chia đều cho mọi lần sử dụng sau đó.
Mẹo: Đặt một rule lint (hoặc pre-commit hook) chặn các chuỗi f-string dài chứa từ khóa như "You are" hoặc "Please act as" nằm ngoài thư mục prompts/ — đây là dấu hiệu chắc chắn của prompt bị viết tay rải rác ngoài quy trình review.
Thiết Kế Kiến Trúc Template Tái Sử Dụng
Một khi đã tách prompt ra thành module riêng, bước tiếp theo là thiết kế kiến trúc để template có thể tham số hóa (parameterize), đặt tên và version nhất quán, và được tự động chọn đúng theo loại tác vụ (task type). Có hai lựa chọn phổ biến: viết template dưới dạng class Python (như ví dụ trên) hoặc dưới dạng file YAML/Jinja tách biệt hoàn toàn khỏi code logic. File YAML có lợi thế lớn: non-engineer (như QA lead hoặc product manager muốn tinh chỉnh câu chữ) có thể sửa trực tiếp mà không cần đụng vào code, và diff của Git sẽ hiển thị rõ ràng phần nội dung nào thay đổi.
Quy ước đặt tên nên bao gồm: mục đích (ticket_triage), và version (v2). Khi nội dung prompt thay đổi đáng kể (không chỉ sửa lỗi chính tả), tạo file version mới thay vì overwrite — điều này cho phép rollback (khôi phục về phiên bản trước) tức thì bằng cách đổi con trỏ registry.
name: ticket_triage
version: "2.0.0"
description: >
Classifies an incoming support ticket into priority + category,
and extracts a one-line summary. Optimized for token efficiency:
no chain-of-thought, output is compact JSON only.
model_defaults:
max_output_tokens: 120
temperature: 0
variables:
- ticket_body
- customer_tier # "free" | "pro" | "enterprise"
system: |
You are a support ticket triage assistant. Output strict JSON only.
Schema: {"priority": "low|medium|high|urgent", "category": string, "summary": string}
No explanation, no markdown, no extra keys.
user_template: |
Customer tier: {customer_tier}
Ticket:
{ticket_body}
Với file này, phần code chỉ chịu trách nhiệm load, điền biến, và gọi model — logic nghiệp vụ về prompt hoàn toàn nằm trong YAML, dễ review và dễ A/B test.
import yaml
from pathlib import Path
from string import Template
TEMPLATE_DIR = Path("prompts")
def load_template(name: str, version: str) -> dict:
path = TEMPLATE_DIR / f"{name}_{version}.yaml"
with open(path, "r") as f:
return yaml.safe_load(f)
def render(template_cfg: dict, **kwargs) -> list[dict]:
missing = set(template_cfg["variables"]) - kwargs.keys()
if missing:
raise ValueError(f"Missing template variables: {missing}")
user_content = Template(template_cfg["user_template"]).safe_substitute(**kwargs)
return [
{"role": "system", "content": template_cfg["system"]},
{"role": "user", "content": user_content},
]
TASK_TEMPLATE_MAP = {
"ticket_triage": ("ticket_triage", "v2"),
"code_review": ("code_review", "v1"),
"summary": ("summarize_thread", "v3"),
}
def get_template_for_task(task_type: str) -> dict:
if task_type not in TASK_TEMPLATE_MAP:
raise KeyError(f"No template registered for task type: {task_type}")
name, version = TASK_TEMPLATE_MAP[task_type]
return load_template(name, version)
Registry dạng TASK_TEMPLATE_MAP này chính là "single source of truth" (nguồn dữ liệu chuẩn duy nhất) cho việc template nào đang phục vụ task nào. Khi cần chuyển production sang ticket_triage_v3, chỉ cần đổi một dòng trong map, không cần sửa code gọi ở nơi khác.
Mẹo: Ghi model_defaults (như max_output_tokens, temperature) ngay trong file template thay vì hardcode ở nơi gọi — nhờ vậy mỗi template tự "khai báo" ngân sách token của nó, và bạn có thể tổng hợp báo cáo chi phí theo từng template một cách dễ dàng.
Catalog Các Pattern Prompt Tái Sử Dụng
Sau khi có hạ tầng template, phần giá trị thực sự nằm ở nội dung: những "khung" prompt đã được kiểm chứng qua nhiều dự án, tối ưu cho từng loại tác vụ. Dưới đây là các pattern phổ biến nhất mà một đội ngũ nên đưa vào thư viện chung, kèm lưu ý về hiệu quả token.
Pattern phân loại (Classifier Pattern). Dùng khi cần gán input vào một trong số hữu hạn nhãn (label). Cấu trúc: system nêu rõ schema output cố định (thường là JSON một field label), liệt kê đầy đủ các nhãn hợp lệ, cấm model giải thích. Vì output cực ngắn, đây là pattern rẻ nhất về token — điểm mấu chốt để tối ưu là giữ system preamble ngắn và không cho model "nghĩ to" (không chain-of-thought) trừ khi độ chính xác đòi hỏi.
Pattern trích xuất (Extraction Pattern). Dùng để lấy các trường dữ liệu có cấu trúc từ văn bản tự do (ví dụ: trích tên, ngày, số tiền từ email). Cấu trúc: định nghĩa schema JSON rõ ràng (tốt nhất là JSON Schema hoặc Pydantic model), yêu cầu trả null cho field không tìm thấy thay vì bỏ qua field. Token cost tỷ lệ với độ dài input, nên pattern này thường kết hợp với bước rút gọn input (chunk hoặc lọc trước) để tránh nhồi toàn văn bản dài vào context.
Pattern tóm tắt (Summarization Pattern). Cấu trúc: chỉ rõ độ dài output mong muốn (số câu hoặc số từ, không chỉ nói "ngắn gọn"), chỉ rõ đối tượng đọc (audience) để model chọn mức độ chi tiết phù hợp. Mẹo tối ưu token: luôn đặt hard limit bằng max_output_tokens thay vì chỉ dựa vào instruction bằng lời, vì model có thể "quên" giới hạn khi input dài.
Pattern phê bình - tinh chỉnh (Critique/Refine Pattern). Dùng cho các tác vụ cần chất lượng cao (ví dụ sinh code, viết nội dung quan trọng): bước 1 sinh draft, bước 2 dùng một prompt riêng để model tự phê bình draft đó theo checklist cụ thể, bước 3 sinh bản sửa. Đây là pattern tốn token nhất (gấp 2-3 lần một lượt gọi thông thường), nên chỉ nên áp dụng có điều kiện — ví dụ chỉ chạy bước critique khi task được đánh dấu độ ưu tiên cao, không áp dụng tràn lan cho mọi request.
Pattern định tuyến (Router Pattern). Dùng một lời gọi LLM nhỏ, rẻ để quyết định request nên được xử lý bởi template/model nào tiếp theo (ví dụ: câu hỏi đơn giản dùng model nhỏ, câu hỏi phức tạp chuyển sang model lớn hơn). Vì output chỉ là một nhãn định tuyến, chi phí router gần như không đáng kể so với việc tiết kiệm được từ việc tránh gọi model lớn cho mọi request.
CLASSIFIER_SYSTEM = """Classify the input into exactly one label.
Valid labels: {labels}
Output strict JSON: {{"label": "<one of the valid labels>"}}
No explanation."""
def build_classifier_prompt(input_text: str, labels: list[str]) -> list[dict]:
return [
{"role": "system", "content": CLASSIFIER_SYSTEM.format(labels=", ".join(labels))},
{"role": "user", "content": input_text},
]
Mẹo: Với mỗi pattern trong catalog, ghi kèm "token profile" ước tính (ví dụ: input trung bình, output trung bình, chi phí mỗi lượt gọi) ngay trong docstring — team sẽ chọn đúng pattern cho ngân sách của mình mà không cần đo lại từ đầu.
Composition Template Cho Pipeline Phức Tạp
Khi hệ thống có hàng chục loại task, việc viết mỗi template hoàn toàn độc lập sẽ dẫn tới lặp lại boilerplate (ví dụ: cùng một đoạn "you must respond in valid JSON, no markdown fences" xuất hiện trong 20 file). Composition (soạn ghép) giải quyết bài toán này bằng cách chia template thành các fragment (mảnh) nhỏ có thể tái sử dụng: một system preamble chung, một khối instruction đặc thù cho task, và một khối output schema — rồi ghép lại tại thời điểm render.
Lợi ích về token không chỉ nằm ở việc giảm công duy trì, mà còn ở khả năng cache prefix dùng chung. Nhiều API LLM hiện đại hỗ trợ prompt caching (đệm prompt) cho phần đầu của context không đổi giữa các lượt gọi — nếu system preamble và output schema chung được đặt cố định ở đầu request và giống nhau across nhiều task, phần đó có thể được cache ở phía provider, giảm đáng kể token tính phí cho các lượt gọi lặp lại.
SHARED_PREAMBLE = (
"You are an assistant embedded in an internal engineering tool. "
"Always follow the output schema exactly. Never add prose outside JSON."
)
OUTPUT_SCHEMA_JSON_ONLY = (
"Output must be a single valid JSON object. "
"Do not wrap it in markdown code fences. Do not add commentary."
)
from prompts.fragments import SHARED_PREAMBLE, OUTPUT_SCHEMA_JSON_ONLY
def compose_system(task_specific_instruction: str) -> str:
# Order matters for cache hits: keep the shared, unchanging part FIRST,
# so providers that cache prompt prefixes can reuse it across calls.
return "\n\n".join([
SHARED_PREAMBLE,
task_specific_instruction,
OUTPUT_SCHEMA_JSON_ONLY,
])
def build_pipeline_step_prompt(step_instruction: str, payload: str) -> list[dict]:
system = compose_system(step_instruction)
return [
{"role": "system", "content": system},
{"role": "user", "content": payload},
]
TRIAGE_INSTRUCTION = "Classify the ticket priority and category."
EXTRACT_INSTRUCTION = "Extract the customer's account ID and requested action."
SUMMARY_INSTRUCTION = "Summarize the resolution in one sentence for the changelog."
def run_ticket_pipeline(ticket_body: str, call_llm) -> dict:
triage = call_llm(build_pipeline_step_prompt(TRIAGE_INSTRUCTION, ticket_body))
extraction = call_llm(build_pipeline_step_prompt(EXTRACT_INSTRUCTION, ticket_body))
summary = call_llm(build_pipeline_step_prompt(SUMMARY_INSTRUCTION, ticket_body))
return {"triage": triage, "extraction": extraction, "summary": summary}
Ở ví dụ trên, ba bước pipeline dùng chung SHARED_PREAMBLE và OUTPUT_SCHEMA_JSON_ONLY — nếu provider hỗ trợ cache, phần này chỉ tính phí đầy đủ ở lượt gọi đầu, các lượt sau được tính phí thấp hơn nhiều cho đoạn prefix trùng khớp. Đây là lợi ích kép của composition: vừa giảm công duy trì code, vừa giảm chi phí token thực tế.
Mẹo: Luôn đặt các fragment không đổi (preamble, schema chung) ở đầu prompt và fragment thay đổi theo từng request ở cuối — thứ tự này là điều kiện bắt buộc để cơ chế prompt caching của hầu hết provider phát huy hiệu quả.
Testing và Quality Gate Cho Prompt Template
Prompt template là code, và code cần test. Nhưng khác với unit test thông thường (so sánh output y hệt kỳ vọng), test cho prompt template thường phải dựa vào assertion mềm (soft assertion) — kiểm tra cấu trúc, sự hiện diện của các trường bắt buộc, hoặc điểm số từ một bộ eval — vì output của LLM có thể biến thiên nhẹ giữa các lần gọi.
Một bộ test tối thiểu nên gồm ba lớp: (1) test cấu trúc — template render ra đúng số message, đúng role, không thiếu biến; (2) test hồi quy chất lượng (regression) — chạy một tập câu hỏi "golden" (đã biết câu trả lời đúng hoặc tiêu chí đúng) qua model thật hoặc model rẻ, và assert output thỏa các tiêu chí tối thiểu; (3) cổng ngân sách token (token budget gate) — đo số token của prompt đã render và fail CI nếu vượt ngưỡng cho phép, để không ai âm thầm "phình" prompt qua thời gian.
import json
import tiktoken
from prompts.loader import load_template, render
encoder = tiktoken.get_encoding("cl100k_base")
def render_ticket(ticket_body: str, customer_tier: str = "pro") -> list[dict]:
cfg = load_template("ticket_triage", "v2")
return render(cfg, ticket_body=ticket_body, customer_tier=customer_tier)
def test_template_renders_required_roles():
messages = render_ticket("App crashes on login.")
roles = [m["role"] for m in messages]
assert roles == ["system", "user"]
def test_missing_variable_raises():
cfg = load_template("ticket_triage", "v2")
try:
render(cfg, ticket_body="x") # missing customer_tier
assert False, "expected ValueError for missing variable"
except ValueError:
pass
def test_prompt_token_budget():
messages = render_ticket("A" * 500) # a realistically long ticket
total_tokens = sum(len(encoder.encode(m["content"])) for m in messages)
assert total_tokens < 400, f"prompt grew to {total_tokens} tokens, over budget"
GOLDEN_CASES = [
{"ticket": "Cannot log in, urgent, losing sales!", "expect_priority": "urgent"},
{"ticket": "Minor typo on settings page.", "expect_priority": "low"},
]
def test_golden_priority_classification(cheap_llm_client):
for case in GOLDEN_CASES:
messages = render_ticket(case["ticket"])
raw = cheap_llm_client.call(messages)
parsed = json.loads(raw)
assert parsed["priority"] == case["expect_priority"], (
f"expected {case['expect_priority']}, got {parsed['priority']} "
f"for ticket: {case['ticket']}"
)
Test test_prompt_token_budget là "quality gate" (cổng kiểm soát chất lượng) quan trọng nhất về mặt tối ưu chi phí: nó chặn ngay tại CI nếu ai đó thêm một đoạn instruction dài dòng vào template mà không nhận ra tác động token. Nên chạy bộ golden-set với tần suất định kỳ (không phải mọi commit, vì tốn chi phí gọi model thật), còn test cấu trúc và token budget nên chạy trên mọi pull request vì chúng không cần gọi API thật.
Mẹo: Lưu số token "budget" cho phép ngay trong metadata của file template (như model_defaults.max_output_tokens ở ví dụ YAML phần trước) và viết một test chung áp dụng cho toàn bộ thư mục prompts/ — như vậy thêm template mới tự động được bảo vệ bởi quality gate mà không cần viết test riêng cho từng file.
Versioning, Deployment và Rollback Cho Prompt
Prompt template ảnh hưởng trực tiếp tới chất lượng và chi phí hệ thống, vì vậy nó cần được deploy có kiểm soát giống một bản release code — không phải kiểu "sửa xong push thẳng production". Áp dụng semantic versioning (versioning theo ngữ nghĩa: MAJOR.MINOR.PATCH) cho từng template: tăng PATCH khi chỉ sửa câu chữ không ảnh hưởng schema, tăng MINOR khi thêm biến hoặc field output mới nhưng tương thích ngược, tăng MAJOR khi đổi schema output hoặc hành vi model theo cách phá vỡ tương thích với code đang gọi nó.
Với các thay đổi MINOR/MAJOR, nên triển khai theo hình thức canary deployment (triển khai thử nghiệm dần) hoặc staged rollout: cho một phần nhỏ traffic (ví dụ 5%) dùng version mới, theo dõi hai nhóm chỉ số song song — chỉ số chất lượng (tỷ lệ output hợp lệ, độ chính xác trên golden-set, phản hồi người dùng) và chỉ số chi phí (token trung bình mỗi lượt gọi, latency). Chỉ mở rộng traffic khi cả hai nhóm chỉ số ổn định hoặc cải thiện so với version cũ.
import random
ACTIVE_VERSIONS = {
"ticket_triage": {
"stable": "v2",
"canary": "v3",
"canary_traffic_pct": 5, # 5% of requests try the new version
}
}
def resolve_version(template_name: str) -> str:
cfg = ACTIVE_VERSIONS[template_name]
if random.randint(1, 100) <= cfg["canary_traffic_pct"]:
return cfg["canary"]
return cfg["stable"]
def rollback(template_name: str) -> None:
# Rollback = set canary traffic to zero immediately.
# No redeploy needed, no code change, just a config flip.
ACTIVE_VERSIONS[template_name]["canary_traffic_pct"] = 0
print(f"[ROLLBACK] {template_name} canary disabled, all traffic on stable.")
Điểm mấu chốt của thiết kế rollback là nó không cần một lần deploy code mới — chỉ cần đổi một giá trị config (canary_traffic_pct = 0) là toàn bộ traffic quay về bản ổn định ngay lập tức. Đây là lý do việc tách bạch template (file YAML/registry) khỏi code logic (phần đầu bài học) lại quan trọng: rollback prompt trở thành một thao tác config, không phải một lần revert code và build lại.
Sau khi rollback, log lại nguyên nhân (ví dụ: token tăng 40% so với baseline, hoặc tỷ lệ output invalid JSON tăng đột biến) vào changelog của template — dữ liệu này giúp lần thiết kế version tiếp theo tránh lặp lại lỗi cũ.
Mẹo: Luôn theo dõi cả hai chỉ số chất lượng và token cost trong cùng một dashboard theo version của template — chỉ nhìn chất lượng dễ bỏ lọt trường hợp version mới "tốt hơn một chút" nhưng tốn gấp đôi token, một cái giá không đáng để đánh đổi ở quy mô lớn.