Ví dụ code: code/ Dự án thực hành: Dự án 04. Phản hồi Runtime và Kiểm soát Phạm vi
Bạn nói với Claude Code "thêm xác thực người dùng vào dự án này", và nó bắt đầu sửa schema cơ sở dữ liệu, viết route, thay đổi component frontend, và tiện thể tái cấu trúc luôn error-handling middleware. Hai tiếng sau bạn kiểm tra: 12 tệp bị sửa, 800 dòng code mới, mà chẳng có tính năng nào chạy được end-to-end.
Agent vốn có xung lực "làm thêm một chút", thấy thứ gì liên quan là xử lý luôn. Vấn đề là làm quá nhiều thứ cùng lúc gần như chắc chắn dẫn đến chẳng cái nào ra hồn.
Bài kỹ thuật "Effective harnesses for long-running agents" của Anthropic nói rất rõ: khi prompt quá rộng, agent có xu hướng "bắt đầu nhiều thứ cùng lúc" thay vì "hoàn thành một thứ trước". Thực hành kỹ thuật của Codex trong OpenAI cũng rút ra kết luận tương tự, các tác vụ không có kiểm soát phạm vi rõ ràng chứng kiến tỷ lệ hoàn thành tụt mạnh. Đây không phải vấn đề mô hình, mà là vấn đề harness. Bạn chưa vạch ranh giới.
Sự chú ý là tài nguyên hữu hạn
Đây không phải phép ẩn dụ, mà là toán. Giả sử dung lượng ngữ cảnh của agent là C, và nó kích hoạt k tác vụ cùng lúc. Mỗi tác vụ nhận trung bình C/k tài nguyên lý luận. Khi C/k tụt xuống dưới ngưỡng tối thiểu để hoàn thành một tác vụ, chẳng cái nào được xong cả.
Hành vi thật của Claude Code rất minh họa. Bảo nó "thêm đăng ký người dùng" và nó có thể:
- Tạo User model
- Viết route đăng ký
- Thấy cần xác minh email, vậy thêm mail service
- Thấy mật khẩu cần hash, vậy mang bcrypt vào
- Thấy error handling chưa nhất quán, vậy tái cấu trúc global error middleware
- Thấy cấu trúc tệp test lộn xộn, vậy sắp xếp lại thư mục
Sáu bước sau, mỗi cái đều làm dở. Không có xác minh end-to-end, code nửa vời cộng lẫn vào nhau rất phức tạp, phiên sau muốn dọn cũng chẳng biết bắt đầu từ đâu.
Dữ liệu thực nghiệm của Anthropic hỗ trợ trực tiếp điều này: agent sử dụng chiến lược "bước tiếp theo nhỏ" (tương đương WIP=1) đạt tỷ lệ hoàn thành tác vụ cao hơn 37% so với agent nhận prompt rộng. Thú vị hơn, số dòng code agent tạo ra có tương quan âm yếu với tỷ lệ hoàn thành tính năng thực tế, viết nhiều code hơn, hoàn thành ít tính năng hơn. Câu "ăn vội nuốt tươi" được dữ liệu xác nhận.
Quy trình WIP=1
Các khái niệm cốt lõi
- Vượt phạm vi (Overreach): Agent kích hoạt nhiều tác vụ hơn mức tối ưu trong một phiên. Chuyện này có thể định lượng được: làm 5 tính năng mà 0 cái pass end-to-end thì gọi là vượt phạm vi.
- Dở dang (Under-finish): Tỷ lệ tác vụ pass xác minh end-to-end trên tổng số tác vụ đã kích hoạt tụt xuống dưới ngưỡng. Code viết ra nhưng test không pass, đó là dở dang.
- Giới hạn WIP (Work-in-Progress Limit): Mượn từ phương pháp Kanban. Ý tưởng cốt lõi: giới hạn số tác vụ đang chảy đồng thời. Với agent, WIP=1 là giá trị mặc định an toàn nhất, xong một cái rồi mới bắt đầu cái tiếp theo.
- Bằng chứng hoàn thành (Completion Evidence): Điều kiện có thể xác minh mà tác vụ phải thoả mãn để chuyển từ "đang làm" sang "xong". Không có cái này, agent sẽ thay "code trông có vẻ ổn" bằng "hành vi pass test".
- Bề mặt phạm vi (Scope Surface): Cấu trúc DAG trong đó mỗi nút là một đơn vị công việc, cạnh là phụ thuộc. Trạng thái chỉ có bốn: not_started, active, blocked, passing.
- Áp lực hoàn thành (Completion Pressure): Lực ràng buộc mà harness tạo ra thông qua giới hạn WIP và yêu cầu bằng chứng hoàn thành, buộc agent phải xong tác vụ hiện tại trước khi mở tác vụ mới.
Vượt phạm vi và dở dang là cộng sinh
Hai vấn đề này không độc lập, chúng khuếch đại lẫn nhau. Vượt phạm vi làm loãng sự chú ý, sự chú ý bị loãng dẫn đến dở dang, và đống code dở dang để lại làm tăng độ phức tạp hệ thống, từ đó lại thúc đẩy vượt phạm vi ở tác vụ tiếp theo. Một vòng xoáy bệnh lý.
Theo ngôn ngữ Kanban: định luật Little cho ta L = lambda * W. Nếu lượng công việc đang chảy L quá lớn (làm quá nhiều thứ cùng lúc), thời gian hoàn thành W của mỗi tác vụ tất yếu tăng. Với agent, điều này nghĩa là mỗi tính năng mất thêm thời gian từ lúc bắt đầu tới lúc hoàn thành đã xác minh, và xác suất thất bại tăng theo.
Đây cũng là vấn đề cũ trong thế giới con người. Steve McConnell từng ghi nhận trong Rapid Development rằng scope creep là nguyên nhân hàng đầu khiến dự án thất bại. Nhưng người ta ít nhất còn có trực giác "mình làm đủ rồi". Agent thì không. Việc nảy ra ý tưởng tiếp theo gần như chẳng tốn thêm token nào, chỉ cần viết thêm "tiện thể sửa luôn cái này" là xong, nhưng mỗi sửa đổi bổ sung lại làm loãng thêm sự chú ý của agent.
Cách làm đúng
1. Thực thi WIP=1
Đây là cách trực tiếp và hiệu quả nhất. Trong harness, hãy nói rõ với agent: chỉ một tác vụ được phép ở trạng thái "active" tại bất kỳ thời điểm nào. Trong CLAUDE.md của Claude Code hoặc AGENTS.md của Codex, viết:
## Quy tắc làm việc
- Làm một tính năng tại một thời điểm
- Chỉ bắt đầu tính năng tiếp theo sau khi tính năng hiện tại pass xác minh end-to-end
- Đừng "tiện thể tái cấu trúc" tính năng B trong khi triển khai tính năng A
2. Định nghĩa bằng chứng hoàn thành rõ ràng cho từng tác vụ
Xong không phải "code đã viết", mà là "xác minh hành vi pass". Trong feature list, mỗi mục cần một lệnh xác minh:
F01: Đăng ký người dùng
Xác minh: curl -X POST /api/register -d '{"email":"test@example.com","password":"123456"}' | jq .status == 201
Trạng thái: passing
3. Đưa bề mặt phạm vi ra ngoài
Dùng một tệp máy đọc được (JSON hoặc Markdown) để ghi lại trạng thái của mọi tác vụ. Bất kỳ phiên mới nào đọc tệp này là biết ngay: tác vụ nào đang hoạt động? Hành vi nào tính là xong? Những xác minh nào đã pass?
4. Theo dõi tỷ lệ hoàn thành đã xác minh
Harness nên liên tục theo dõi VCR (Verified Completion Rate) = số tác vụ đã xác minh / số tác vụ đã kích hoạt. Chặn kích hoạt tác vụ mới khi VCR < 1.0.
Câu chuyện thật
Một dự án REST API với 8 tính năng, hai chiến lược đặt cạnh nhau:
Chế độ không ràng buộc: Agent kích hoạt 5 tính năng cùng lúc trong phiên 1. Tạo ra khoảng 800 dòng trên 12 tệp. Tỷ lệ pass test end-to-end: 20%, chỉ đăng ký người dùng chạy được. 4 tính năng còn lại: database schema đã tạo nhưng thiếu logic xác thực, route khai báo xong nhưng trả về sai định dạng response. Đến cuối phiên 3, chỉ 3 trên 8 tính năng hoàn thành.
Chế độ WIP=1: Phiên 1 chỉ làm đăng ký người dùng. Tạo ra khoảng 200 dòng trên 4 tệp. Test end-to-end: 100% pass. Commit một triển khai sạch, đã xác minh. Đến cuối phiên 4, 7 trên 8 tính năng hoàn thành (tính năng thứ 8 bị chặn bởi phụ thuộc bên ngoài).
Kết quả: tổng code ít hơn (800 so với 1200 dòng) nhưng code hiệu quả hơn. Tỷ lệ hoàn thành: 87.5% so với 37.5%.
Những điểm chính cần nhớ
- WIP=1 là cài đặt mặc định an toàn cho harness agent: xong một rồi mới sang cái tiếp theo, đừng cố song song hoá.
- Bằng chứng hoàn thành phải có thể thực thi: "code trông có vẻ ổn" không tính, "curl trả về 201" mới tính.
- Bề mặt phạm vi phải được đưa ra thành một tệp: không chỉ nhắc tới trong hội thoại, mà phải ghi vào repo ở định dạng máy đọc được.
- Vượt phạm vi và dở dang là cộng sinh: giải quyết được một là giải quyết luôn cái kia.
- "Làm ít mà xong" luôn thắng "làm nhiều mà bỏ dở": số dòng code agent và tỷ lệ hoàn thành tính năng có tương quan âm. Chất lượng bao giờ cũng thắng số lượng.
Đọc thêm
- Effective harnesses for long-running agents - Anthropic — Bài kỹ thuật của Anthropic, thảo luận chi tiết chiến lược "bước tiếp theo nhỏ"
- Harness Engineering - OpenAI — Cách tiếp cận đầy đủ của OpenAI về harness engineering
- Kanban: Successful Evolutionary Change - David Anderson — Nguồn kinh điển về giới hạn WIP
- Rapid Development - Steve McConnell — Dữ liệu thực nghiệm về scope creep là nguyên nhân hàng đầu gây thất bại dự án
Bài tập
-
Nguyên tử hoá tác vụ: Chọn một yêu cầu rộng (ví dụ: "triển khai hệ thống quản lý người dùng") và phân nhỏ thành ít nhất 5 đơn vị công việc nguyên tử. Với mỗi đơn vị, hãy chỉ ra: (a) mô tả một hành vi duy nhất, (b) lệnh xác minh có thể thực thi, (c) các phụ thuộc. Kiểm tra xem việc phân rã có thoả mãn ràng buộc WIP=1 không.
-
Thí nghiệm so sánh: Chạy cùng một dự án hai lần, một lần không ràng buộc, một lần có WIP=1 được thực thi. So sánh: tỷ lệ hoàn thành đã xác minh, tổng số dòng code, tỷ lệ code hiệu quả.
-
Kiểm toán bằng chứng hoàn thành: Rà lại kết quả một lần chạy agent gần đây, phân loại từng thay đổi code thành "hành vi đã xong", "hành vi chưa xong" hoặc "scaffolding". Bổ sung lệnh xác minh còn thiếu cho mỗi hành vi chưa xong.
Code và tài liệu đi kèm
code/index.md
Sử dụng thư mục này cho các ví dụ về:
- các thất bại one-shot
- prompt tác vụ quá lớn
- định hình tác vụ tăng dần
- bề mặt tính năng có cấu trúc
code/next-task-template.md
# Mẫu Tác vụ Tiếp theo
- Tính năng có mức ưu tiên cao nhất hiện tại:
- Tại sao tính năng này là tiếp theo:
- Điều gì được tính là vượt qua:
- Điều gì không được thay đổi trong bước này:
code/scope-surface-example.md
# Ví dụ Bề mặt Phạm vi
Tác vụ:
- Thêm indexing vào ứng dụng kiến thức Electron
Hình dạng phạm vi xấu:
- "Triển khai indexing"
Hình dạng phạm vi tốt hơn:
- Parse các tài liệu đã import
- Chia tài liệu thành các chunk
- Lưu trữ chunk metadata
- Hiển thị trạng thái indexing trong UI
- Thêm hành động reindex
code/scope-tracker.ts
/**
* scope-tracker.ts
*
* Reads a feature list and a change log. Enforces single-active-feature
* policy. Given a log of changes, flags any changes outside the active
* feature scope. Demonstrates how scope drift happens and how the tracker
* catches it.
*
* Run: npx tsx docs/lectures/lecture-07-why-agents-overreach-and-under-finish/code/scope-tracker.ts
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Feature {
id: string;
name: string;
status: "active" | "pending" | "done";
}
interface ChangeLogEntry {
step: number;
file: string;
description: string;
featureId: string; // The feature this change claims to belong to
}
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const features: Feature[] = [
{ id: "F-001", name: "Search endpoint", status: "active" },
{ id: "F-002", name: "Delete endpoint", status: "pending" },
{ id: "F-003", name: "Rate limiting", status: "pending" },
{ id: "F-004", name: "User dashboard", status: "pending" },
];
// A realistic change log where the agent gradually drifts from the active feature
const changeLog: ChangeLogEntry[] = [
{ step: 1, file: "src/routes/search.ts", description: "Add search route handler", featureId: "F-001" },
{ step: 2, file: "src/routes/search.ts", description: "Add query parameter validation", featureId: "F-001" },
{ step: 3, file: "src/routes/search.ts", description: "Add search results pagination", featureId: "F-001" },
{ step: 4, file: "src/routes/delete.ts", description: "Add delete route handler", featureId: "F-002" }, // DRIFT
{ step: 5, file: "src/middleware/rate-limit.ts", description: "Add rate limiter middleware", featureId: "F-003" }, // DRIFT
{ step: 6, file: "src/routes/search.ts", description: "Integrate rate limiter into search", featureId: "F-001" },
{ step: 7, file: "src/dashboard/ui.tsx", description: "Create dashboard layout component", featureId: "F-004" }, // DRIFT
{ step: 8, file: "src/routes/search.ts", description: "Add search response formatting", featureId: "F-001" },
{ step: 9, file: "src/routes/delete.ts", description: "Add delete confirmation logic", featureId: "F-002" }, // DRIFT
{ step: 10, file: "src/routes/search.ts", description: "Add search tests", featureId: "F-001" },
];
// ---------------------------------------------------------------------------
// Scope tracker
// ---------------------------------------------------------------------------
interface ScopeCheckResult {
step: number;
file: string;
description: string;
featureId: string;
inScope: boolean;
activeFeature: string;
}
function trackScope(
featureList: Feature[],
changes: ChangeLogEntry[]
): ScopeCheckResult[] {
const activeFeatures = featureList.filter((f) => f.status === "active");
const activeIds = new Set(activeFeatures.map((f) => f.id));
const activeNames = activeFeatures.map((f) => f.name).join(", ");
return changes.map((change) => ({
step: change.step,
file: change.file,
description: change.description,
featureId: change.featureId,
inScope: activeIds.has(change.featureId),
activeFeature: activeNames,
}));
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function pad(s: string, len: number): string {
return s.length >= len ? s : s + " ".repeat(len - s.length);
}
function run(): void {
const results = trackScope(features, changeLog);
console.log("\n" + "=".repeat(100));
console.log(" SCOPE TRACKER -- Single Active Feature Enforcement");
console.log("=".repeat(100));
console.log("\n Active feature: " + features.filter((f) => f.status === "active").map((f) => `${f.id} (${f.name})`).join(", "));
console.log(" Pending features: " + features.filter((f) => f.status === "pending").map((f) => `${f.id} (${f.name})`).join(", "));
// Detailed change log
console.log("\n" + "-".repeat(100));
const header = `| ${pad("Step", 5)}| ${pad("File", 35)}| ${pad("Description", 40)}| ${pad("Feature", 8)}| ${pad("In Scope", 10)}|`;
const sep = `|${"-".repeat(7)}|${"-".repeat(37)}|${"-".repeat(42)}|${"-".repeat(10)}|${"-".repeat(12)}|`;
console.log(header);
console.log(sep);
let inScopeCount = 0;
let driftCount = 0;
for (const r of results) {
const scopeLabel = r.inScope ? "OK" : "DRIFT";
if (r.inScope) inScopeCount++;
else driftCount++;
const marker = r.inScope ? " " : ">>";
console.log(`${marker}| ${pad(String(r.step), 5)}| ${pad(r.file, 35)}| ${pad(r.description, 40)}| ${pad(r.featureId, 8)}| ${pad(scopeLabel, 10)}|`);
}
// Summary
console.log("\n" + "=".repeat(100));
console.log(" SCOPE DRIFT SUMMARY");
console.log("=".repeat(100) + "\n");
const sHeader = `| ${pad("Metric", 40)}| ${pad("Value", 15)}|`;
const sSep = `|${"-".repeat(42)}|${"-".repeat(17)}|`;
console.log(sHeader);
console.log(sSep);
console.log(`| ${pad("Total changes", 40)}| ${pad(String(results.length), 15)}|`);
console.log(`| ${pad("Changes within active scope (F-001)", 40)}| ${pad(String(inScopeCount), 15)}|`);
console.log(`| ${pad("Changes outside active scope (DRIFT)", 40)}| ${pad(String(driftCount), 15)}|`);
console.log(`| ${pad("Features touched (total)", 40)}| ${pad(String(new Set(results.map((r) => r.featureId)).size), 15)}|`);
// Drift detail
const driftFeatures = [...new Set(results.filter((r) => !r.inScope).map((r) => r.featureId))];
if (driftFeatures.length > 0) {
console.log("\n DRIFTED FEATURES:");
for (const fid of driftFeatures) {
const feat = features.find((f) => f.id === fid);
const driftChanges = results.filter((r) => r.featureId === fid);
console.log(` ${fid} (${feat?.name}): ${driftChanges.length} unauthorized changes`);
}
}
console.log("\n Without a scope tracker, the agent silently worked on " + driftFeatures.length + " unrelated features.");
console.log(" The tracker catches this drift and enforces the single-active-feature policy.\n");
}
run();