·

Tiếng Việt: Why Specs Matter In Agentic Workflows

Why Specs Matter In Agentic Workflows

Trong agentic engineering, một spec (đặc tả kỹ thuật) viết tốt trong 20 phút có thể tiết kiệm 3 giờ debug với AI agent — và đó không phải phép màu, mà là hệ quả tất yếu của cách các mô hình ngôn ngữ lớn (LLM) hoạt động.

Hiệu Ứng Khuếch Đại Độ Chính Xác

Nhiều kỹ sư khi mới dùng AI coding agent (như Claude Code, Cursor, hay Copilot Workspace) thường có một ảo tưởng nguy hiểm: nghĩ rằng agent càng "thông minh" thì càng cần ít hướng dẫn. Thực tế ngược lại hoàn toàn. Agent càng mạnh, nó càng khuếch đại đúng những gì bạn đưa vào — kể cả sự mơ hồ. Tôi gọi đây là "hiệu ứng khuếch đại độ chính xác" (precision amplifier effect): một chỉ dẫn mơ hồ không tạo ra một kết quả mơ hồ ở mức tương ứng, nó tạo ra một kết quả rất cụ thể nhưng sai, vì agent buộc phải tự đưa ra hàng chục giả định để lấp đầy khoảng trống bạn để lại, và nó chọn theo xác suất phổ biến nhất trong dữ liệu huấn luyện — không phải theo ý bạn.

Hãy xem một ví dụ thực tế mà tôi gặp khi coach một đội backend. Kỹ sư gõ prompt sau vào agent:

Add rate limiting to our API.

Agent trả về một middleware Express dùng in-memory store, giới hạn 100 request/phút theo IP, dùng thuật toán fixed window. Nhìn qua thì "chạy được". Nhưng thực tế dự án của đội này:

  • Chạy trên 4 instance đằng sau load balancer (in-memory store vô nghĩa, mỗi instance đếm riêng).
  • Cần giới hạn theo user_id (đã login) chứ không phải IP (nhiều user cùng NAT/office network).
  • Cần rate limit khác nhau cho từng tier (free/pro/enterprise).
  • Cần trả về header Retry-After đúng chuẩn để client SDK xử lý.
  • Dùng Redis đã có sẵn trong stack.

Agent không "sai" — nó đã implement đúng một cách hợp lý cho một API generic không có ngữ cảnh nào khác. Vấn đề nằm ở người viết prompt, không nằm ở agent. Đây chính là sai lầm phổ biến nhất tôi thấy: đổ lỗi cho AI "ngu" trong khi vấn đề là spec đầu vào quá nghèo thông tin.

Bây giờ so sánh với một spec chính xác hơn:

Implement rate limiting middleware for our Express API with these requirements:

- Algorithm: sliding window log, backed by Redis (we already use ioredis client
  configured in src/lib/redis.ts).
- Limit key: authenticated user_id from req.user.id (fallback to IP only for
  unauthenticated routes like /login).
- Limits per tier (read tier from req.user.tier):
  - free: 60 requests / minute
  - pro: 600 requests / minute
  - enterprise: 6000 requests / minute
- On exceed: return HTTP 429 with JSON body { "error": "rate_limit_exceeded",
  "retry_after_seconds": <int> } and a Retry-After header.
- Must be safe under concurrent requests across multiple app instances
  (no race condition allowing a burst over the limit due to check-then-increment
  races). Use a Redis Lua script or INCR+EXPIRE pattern that is atomic.
- Add unit tests covering: under limit, exactly at limit, over limit, tier
  boundary, and concurrent request race condition (simulate with Promise.all).
- Do not add new npm dependencies; use existing ioredis client.

Kết quả của lần thứ hai gần như luôn implement đúng ngay từ lần đầu, vì agent không còn phải đoán — nó chỉ còn phải thực thi. Đây chính là bản chất của đòn bẩy: bạn không làm cho AI "giỏi hơn", bạn loại bỏ không gian để nó đoán sai. Chi phí soạn spec (5-10 phút) được đánh đổi lấy việc tránh 2-3 vòng lặp sửa lỗi, mỗi vòng tốn 15-30 phút review + giải thích lại.

Mẹo: Khi review lại một đoạn code AI generate mà thấy "sai", đừng sửa code trước. Hãy tự hỏi: "Prompt/spec ban đầu có đủ thông tin để suy ra được lựa chọn đúng không?" Nếu không, sửa spec trước, sinh lại code, rồi mới review — bạn sẽ tiết kiệm được rất nhiều vòng lặp về sau vì cùng một class lỗi không lặp lại.

Spec-First So Với Prompt-and-Iterate

Có hai trường phái làm việc với AI coding agent mà tôi quan sát được rõ rệt trong các đội ngũ engineering: prompt-and-iterate (prompt rồi sửa dần) và spec-first (viết spec trước rồi mới prompt). Cả hai đều "work" theo nghĩa cuối cùng đều ra được sản phẩm, nhưng chi phí thời gian và chất lượng đầu ra khác nhau rất lớn, đặc biệt với các tính năng có độ phức tạp trung bình trở lên.

Lấy ví dụ thực tế: xây dựng luồng checkout cho một e-commerce app, cần xử lý inventory lock, áp dụng discount code, tính thuế theo địa chỉ, và tạo order idempotent (tránh double-charge khi user bấm nút 2 lần).

Cách tiếp cận prompt-and-iterate thường diễn ra như sau:

Prompt 1: "Build a checkout endpoint that creates an order from the cart."
→ Agent tạo endpoint cơ bản, không có idempotency, không lock inventory.

Prompt 2: "It double-charges when the user double-clicks, fix that."
→ Agent thêm một debounce ở frontend (sai hướng — vấn đề nằm ở backend).

Prompt 3: "No, I mean the backend should be idempotent even if two requests
arrive at the same time. Use an idempotency key."
→ Agent thêm idempotency key nhưng lưu trong biến in-memory (sai với multi-instance).

Prompt 4: "We run multiple instances, store idempotency keys in the database
with a unique constraint instead."
→ Agent sửa lại, ổn hơn nhưng chưa xử lý race condition khi trừ inventory.

Prompt 5: "Two customers can still both succeed in buying the last item in
stock. Add a proper inventory lock."
→ ... tiếp tục lặp.

Tôi từng đo thời gian thực tế cho pattern này với một kỹ sư senior: 5 vòng prompt, tổng cộng 95 phút, bao gồm cả thời gian đọc lại code, hiểu tại sao agent chọn cách đó, và giải thích lại vấn đề theo cách khác để agent "hiểu đúng ý". Vấn đề cốt lõi: mỗi vòng lặp, agent không có bức tranh toàn cảnh — nó chỉ thấy fix request gần nhất, dẫn đến giải pháp local-optimal nhưng global-broken (sửa chỗ này lại hở chỗ khác).

Cách tiếp cận spec-first đầu tư trước vào việc suy nghĩ toàn cảnh:

Implement POST /api/checkout that converts a cart into an order.

Requirements:
1. Idempotency: client sends header Idempotency-Key (UUID). Store it in a
   dedicated `idempotency_keys` table (key, request_hash, response_body,
   created_at) with a unique constraint on key. If the same key arrives again
   with the same request_hash, return the cached response without
   re-executing side effects. If key exists with a different request_hash,
   return 409 Conflict.
2. Inventory: use a database transaction with SELECT ... FOR UPDATE on the
   product rows being purchased, to prevent overselling under concurrent
   checkouts. Roll back the transaction if any item's available stock is
   below the requested quantity.
3. Discount codes: validate against the `discount_codes` table (check
   expiry, usage limit, min order value) inside the same transaction.
4. Tax: compute using the existing `calculateTax(address, subtotal)` in
   src/lib/tax.ts — do not reimplement tax logic.
5. On success: create an `orders` row with status "pending_payment", return
   201 with the order object.
6. Add integration tests for: concurrent checkout on last unit of stock
   (only one should succeed), duplicate idempotency key returns cached
   result, expired discount code is rejected.

Ask me clarifying questions first if anything about payment processing
integration is unclear — do not assume a payment provider.

Với cách này, agent có đủ ngữ cảnh để sinh ra giải pháp đúng ngay từ đầu ở khoảng 80-90% trường hợp trong kinh nghiệm của tôi, thời gian tổng cộng (viết spec + review 1 lần) thường chỉ khoảng 30-35 phút — chưa kể một lợi ích âm thầm nhưng cực kỳ quan trọng: spec này trở thành tài liệu sống, có thể đưa vào PR description, có thể tái sử dụng khi cần agent khác (hoặc con người khác) tiếp tục maintain tính năng.

Điều cần lưu ý: spec-first không có nghĩa là bạn phải viết spec hoàn hảo 100% trước khi bắt đầu. Với các task nhỏ, rõ ràng (sửa 1 dòng, đổi tên biến, thêm 1 field đơn giản), prompt-and-iterate hoàn toàn hợp lý — chi phí viết spec sẽ vượt quá lợi ích. Ranh giới thực tế: nếu tính năng có hơn một "quyết định thiết kế" ẩn (concurrency, error handling, data model, integration với hệ thống khác), hãy viết spec trước.

Mẹo: Nếu bạn không chắc nên chọn cách nào, hãy tự hỏi: "Nếu mình giao task này bằng lời nói cho một intern qua Slack, mình có cần viết dài hơn 2 câu không?" Nếu câu trả lời là có, đó là dấu hiệu bạn cần một spec, không phải một prompt.

Spec Là Một Bản Hợp Đồng Giữa Kỹ Sư Và Agent

Mô hình tư duy hữu ích nhất tôi dùng để giải thích khái niệm spec cho các kỹ sư quen với lập trình truyền thống là: spec chính là interface contract, còn agent chính là implementation. Trong lập trình hướng đối tượng hay thiết kế API, chúng ta đã quen với việc định nghĩa interface/contract rõ ràng (input, output, invariant, side effect) rồi để cho bất kỳ implementation nào tuân thủ nó — miễn thỏa mãn contract, chi tiết implementation là tự do. Spec dành cho AI agent vận hành theo đúng logic đó, chỉ khác là "implementation" ở đây là một LLM sinh code thay vì một class cụ thể.

Khi contract mơ hồ, hậu quả y hệt như trong bất kỳ hệ thống phần mềm nào có interface không rõ ràng: mỗi bên (ở đây là bạn và agent) tự hiểu theo một cách, và lỗi chỉ lộ ra khi tích hợp — tức là sau khi code đã được viết, review, thậm chí đã merge. Ví dụ, nếu spec chỉ nói "trả về lỗi khi user không tồn tại" mà không nói rõ status code, format response, agent có thể chọn 404 với body { "message": "not found" }, trong khi phần còn lại của codebase dùng convention { "error": { "code": "USER_NOT_FOUND", "message": "..." } } với status 422. Đây không phải "bug" theo nghĩa logic sai — nó là hợp đồng không được đặc tả đầy đủ, và agent đã điền vào chỗ trống bằng một lựa chọn hợp lý nhưng không khớp với hệ thống thực tế.

Điều quan trọng cần cân bằng: một spec tốt ràng buộc đúng không gian nghiệm (solution space) mà không bóp nghẹt sự sáng tạo trong chi tiết implementation. Đây là ranh giới nhiều người mới viết spec hay phạm sai lầm ở cả hai hướng cực đoan:

  • Quá lỏng lẻo: "Viết function xử lý upload file, đảm bảo an toàn và hiệu quả." — Không có định nghĩa "an toàn" (validate MIME type? scan virus? giới hạn size?), không có định nghĩa "hiệu quả" (streaming? chunk size bao nhiêu?). Agent phải đoán, và đoán sai là chuyện thường.
  • Quá chặt chẽ, vi phạm giới hạn của contract tốt: Spec chỉ định luôn tên biến, cấu trúc từng dòng code, thậm chí viết sẵn cả function rồi bảo agent "gõ y hệt vào file". Lúc này bạn không còn tận dụng được khả năng của agent nữa — bạn đang tự tay lập trình nhưng gõ chậm hơn qua trung gian là AI.

Một spec tốt nằm ở giữa: nó đặc tả rõ behavior, boundary, và invariant (điều gì phải luôn đúng — ví dụ "không bao giờ được oversell inventory"), nhưng để agent tự quyết định chi tiết cách viết code, đặt tên biến cục bộ, hay chọn cấu trúc control flow miễn tuân thủ convention có sẵn của codebase. Cách kiểm tra nhanh một spec có "vừa đủ" hay không: đọc lại spec và tự hỏi "Nếu đưa spec này cho 3 kỹ sư senior khác nhau, họ có sinh ra 3 implementation khác nhau về chi tiết code nhưng behavior giống hệt nhau không?" Nếu câu trả lời là có, spec của bạn đang ở đúng mức trừu tượng.

Một khía cạnh khác của "hợp đồng" này là: giống hợp đồng pháp lý, spec cần định nghĩa rõ điều gì nằm ngoài phạm vi (out of scope). Rất nhiều lỗi từ agent không phải vì nó làm sai điều được yêu cầu, mà vì nó "nhiệt tình" làm thêm những thứ không ai nhờ — refactor luôn cả file không liên quan, đổi luôn version của một dependency, "cải thiện" luôn một hàm gần đó. Một dòng đơn giản như Do not modify files outside src/api/checkout/ hoặc Do not upgrade any package version trong spec giúp giới hạn "blast radius" của thay đổi, một nguyên tắc sống còn khi làm việc trong codebase production.

Mẹo: Luôn thêm một mục "Out of scope" hoặc "Do not" rõ ràng trong spec cho các task chạm vào codebase lớn. Nó rẻ hơn nhiều so với việc phải revert một PR vì agent "tiện tay" sửa luôn những chỗ không ai yêu cầu.

Thực Hành: Viết Một Spec Chính Xác Cho Một Tính Năng Thực Tế

Phần này chúng ta sẽ thực hành viết một spec hoàn chỉnh, sẵn sàng copy-paste, cho một tính năng thực tế: CSV export API có phân trang (paginated CSV export) — một tính năng cực kỳ phổ biến trong các hệ thống backoffice/admin dashboard, và cũng là dạng tính năng dễ bị AI agent implement sai nhất nếu spec mơ hồ (dễ gặp lỗi out-of-memory khi export dataset lớn, dễ quên escape ký tự đặc biệt trong CSV, dễ bỏ sót streaming response).

Bước 1 — Xác định rõ input/output boundary. Trước khi viết bất kỳ dòng spec nào, hãy tự trả lời: dữ liệu đầu vào là gì (bao nhiêu record, có thể lên tới bao nhiêu), định dạng output chính xác ra sao, ai là người tiêu thụ output này (browser download trực tiếp hay client gọi qua axios).

Bước 2 — Liệt kê các invariant và edge case bắt buộc. Với export CSV, các edge case kinh điển: dataset rỗng, field chứa dấu phẩy/xuống dòng/dấu ngoặc kép, dataset quá lớn để load hết vào memory, timeout khi HTTP request kéo dài.

Bước 3 — Xác định rõ những gì KHÔNG nằm trong phạm vi, để agent không tự ý mở rộng (ví dụ: không cần hỗ trợ export Excel, không cần async job queue nếu chưa yêu cầu).

Bước 4 — Viết spec theo cấu trúc: Context → Requirements → Acceptance Criteria → Out of scope. Cấu trúc này giúp cả người và agent dễ scan và không bỏ sót phần nào.

Bước 5 — Đưa spec vào agent kèm một câu lệnh (prompt) rõ ràng, tham chiếu trực tiếp tới file spec thay vì dán toàn bộ nội dung lặp lại nhiều lần trong hội thoại.

Dưới đây là spec hoàn chỉnh, có thể lưu thành file specs/csv-export.md trong repo và tái sử dụng:


## Context
Admin dashboard needs to let staff export the `orders` table to CSV,
filtered by date range and status. The table can contain millions of rows
in production, so the export must not load the entire result set into
memory at once.

## Requirements

1. Endpoint: `GET /api/admin/orders/export.csv`
   Query params: `from` (ISO date, required), `to` (ISO date, required),
   `status` (optional, one of: pending, paid, shipped, cancelled).

2. Response must be streamed as `text/csv` with header
   `Content-Disposition: attachment; filename="orders_<from>_<to>.csv"`.
   Do not buffer the full result set in memory — query and stream rows in
   pages of 1000 using a database cursor or `LIMIT/OFFSET` pagination,
   writing each page to the response stream before fetching the next.

3. CSV columns, in this exact order:
   `order_id, created_at (ISO 8601 UTC), customer_email, status, subtotal,
   tax, total, currency`

4. CSV formatting must follow RFC 4180: fields containing a comma, double
   quote, or newline must be wrapped in double quotes, with internal double
   quotes escaped as `""`. Use an existing well-tested CSV library
   (e.g. `csv-stringify` for Node) rather than hand-rolled string
   concatenation.

5. If `from` > `to`, or either is not a valid ISO date, respond with
   `400 Bad Request` and JSON body `{ "error": "invalid_date_range" }`
   before starting to stream (validate before writing any response body).

6. If the query matches zero rows, still return a `200` with just the
   header row (no data rows) — do not return an error for empty result.

7. Only users with role `admin` or `support` may call this endpoint
   (reuse existing `requireRole()` middleware from src/middleware/auth.ts).

8. Add an integration test that generates 2500 seeded orders and asserts:
   the response contains exactly 2500 data rows + 1 header row, rows are
   correctly paginated under the hood (mock/spy the DB call to assert it
   was called in pages of 1000, not a single query for all rows), and a
   field containing a comma is correctly quoted in the output.

## Acceptance Criteria
- [ ] Streams response without loading full result set into memory
- [ ] CSV correctly escapes special characters per RFC 4180
- [ ] Invalid date range returns 400 before streaming starts
- [ ] Empty result returns 200 with header-only CSV
- [ ] Endpoint enforces admin/support role
- [ ] Integration test passes with 2500-row dataset

## Out of scope
- Do not implement Excel (.xlsx) export
- Do not add a background job queue; this must be a synchronous streaming
  endpoint
- Do not modify the existing `/api/admin/orders` (JSON, non-export) endpoint

Và đây là prompt thực tế để đưa spec này vào một AI coding agent như Claude Code:

Read the spec at specs/csv-export.md and implement it.

Before writing code:
- Check src/routes/admin/orders.ts to follow existing route registration
  conventions.
- Check package.json to see if a CSV library is already installed; if not,
  propose one (prefer csv-stringify) before adding it.

After implementing:
- Run the existing test suite to make sure nothing else broke.
- Write the integration test described in requirement 8.
- Show me a summary of every file you changed and why, mapped back to the
  requirement number in the spec.

If any requirement is ambiguous or conflicts with existing code patterns,
stop and ask me instead of guessing.

Lưu ý cách prompt này làm ba việc quan trọng: (1) trỏ agent đọc spec như nguồn sự thật duy nhất thay vì diễn giải lại bằng lời, tránh sai lệch giữa spec gốc và lời kể lại; (2) yêu cầu agent khảo sát codebase hiện có trước khi viết, giảm rủi ro "phát minh lại bánh xe" hoặc phá vỡ convention; (3) yêu cầu agent liên kết ngược từng thay đổi với từng requirement trong spec, biến việc review của bạn thành một checklist đối chiếu 1-1 thay vì đọc code mò mẫm từ đầu.

Mẹo: Luôn lưu spec thành file riêng trong repo (thư mục specs/) thay vì chỉ gõ trong khung chat. Khi agent hoặc đồng nghiệp cần quay lại sửa tính năng 3 tháng sau, spec chính là tài liệu thiết kế sẵn có — không cần đoán lại "tại sao code viết thế này".

Những điểm chính

  1. Sự mơ hồ không tạo ra kết quả mơ hồ — nó tạo ra kết quả cụ thể nhưng sai, vì agent buộc phải đoán và chọn theo xác suất phổ biến nhất, không phải theo ý định thật của bạn.
  2. Spec-first thắng prompt-and-iterate ở mọi tính năng có từ một quyết định thiết kế ẩn trở lên (concurrency, data model, error handling, tích hợp hệ thống khác) — chi phí viết spec trước luôn rẻ hơn tổng chi phí của nhiều vòng sửa lỗi.
  3. Spec là interface contract giữa bạn và agent: nó phải đặc tả rõ behavior, boundary, invariant, và phạm vi ngoài (out of scope) — nhưng để agent tự do quyết định chi tiết implementation.
  4. Cấu trúc spec chuẩn: Context → Requirements (đánh số rõ ràng) → Acceptance Criteria → Out of scope. Cấu trúc này giúp cả người và AI agent dễ scan, dễ đối chiếu.
  5. Luôn lưu spec thành file trong repo, tham chiếu trực tiếp trong prompt thay vì dán lại toàn văn — spec trở thành tài liệu thiết kế sống, tái sử dụng được cho maintenance về sau.
  6. Yêu cầu agent hỏi lại khi mơ hồ thay vì tự đoán — đây là một dòng đơn giản trong prompt nhưng giảm đáng kể rủi ro của các giả định sai lệch âm thầm.