AI viết lại code giúp bạn, nhưng bạn vẫn phải tự suy nghĩ
Bài viết phân tích thử nghiệm để AI agent tự động chuyển đổi Campfire Once từ Ruby on Rails sang Rust, Elixir và Go mà không đọc lại code. Kết quả cho thấy các bản viết lại khác nhau về khả năng tương thích ngược, hiệu năng và độ tin cậy, chứng minh rằng lập trình viên vẫn cần tư duy phản biện và hiểu rõ các đánh đổi của hệ thống.
AI viết lại code giúp bạn, nhưng bạn vẫn phải tự suy nghĩ
Bài viết của Piotr Sarnacki phân tích một thử nghiệm đáng chú ý: DHH, cha đẻ của framework Ruby on Rails, đã để một AI agent tự động viết lại ứng dụng Campfire Once từ Ruby on Rails sang Rust, sau đó là Elixir và Go — mà không hề đọc lại code. Kết quả là một bài học sâu sắc về giới hạn của AI trong lập trình: bạn vẫn chưa thể ngừng đọc code, và trên hết, vẫn chưa thể ngừng suy nghĩ.
Khi prompt không đủ cụ thể, mọi quyết định đều là tung đồng xu
Điểm mấu chốt mà nhiều người không nhận ra: nếu prompt của bạn không đủ chi tiết, rất nhiều quyết định kỹ thuật sẽ bị AI chọn một cách ngẫu nhiên. Bởi vì không phải câu hỏi nào cũng có một đáp án đúng duy nhất:
- Có cần giữ tương thích ngược 100% không?
- Ưu tiên độ trễ hay thông lượng?
- Hệ thống được dùng bao nhiêu bộ nhớ khi chịu tải?
- Việc mất thông báo nội dung mới sau khi crash có chấp nhận được không?
Bạn có thể không quan tâm đến vài câu hỏi trong số đó, nhưng chúng vẫn sẽ được AI ngầm trả lời khi nó triển khai thứ mà bạn nghĩ là mình muốn.
Nhìn kỹ vào các bản viết lại, bạn sẽ thấy ngay sự khác biệt. Bản Rust không giữ tương thích ngược hoàn toàn — nó bỏ CSRF token để dễ cache hơn, và thay Redis bằng hàng đợi in-process. Bản Elixir lại gần với bản Rails gốc hơn nhiều. Điều này khiến việc so sánh giữa các ngôn ngữ gần như vô nghĩa, vì những khác biệt này không liên quan gì đến ngôn ngữ lập trình.
Đây không phải là chuyện AI nghe thấy "viết lại bằng Elixir" rồi tự chọn giữ tương thích ngược 100% vì ngôn ngữ đó. Vấn đề nằm ở chỗ AI tự quyết định những điều bạn không nói rõ.
Vấn đề nằm sâu trong code, không chỉ ở tính năng
Nếu chịu khó đọc code — và đúng, chúng ta được khuyên là không nên đọc code nữa — bạn sẽ nhanh chóng nhận ra nhiều thứ không ổn.
Bản Elixir bị phàn nàn vì dùng một tiến trình duy nhất xử lý tuần tự mọi truy vấn SQL, kể cả các truy vấn đọc lẽ ra có thể chạy song song. Nhưng bản Rust cũng chẳng khá hơn: một số thao tác database không được async hóa. Trong Rust, khi dùng async runtime, cơ chế lập lịch là hợp tác (cooperative) chứ không phải chiếm quyền (preemptive). Nghĩa là nếu một tác vụ không nhường quyền, không tác vụ nào khác chạy được trên cùng worker thread.
Cụ thể, khi bạn chạy một truy vấn SQL mất 100ms, bạn không muốn mọi tác vụ khác phải chờ trong khi tác vụ đó vốn đang ngồi không. Vì vậy bạn nên dùng thao tác async I/O để nhường quyền cho runtime trong lúc chờ database trả kết quả. Bản Rust lại có những truy vấn chạy dưới dạng blocking trong tác vụ async.
Điều tương tự đúng với lock. Khi dùng async runtime, lựa chọn an toàn nhất là các async lock như tokio::sync::Mutex. Dùng bản đồng bộ (sync) cũng được nếu bạn chắc chắn lock chỉ giữ trong thời gian rất ngắn. Nhưng nếu bạn giữ một sync lock đến 10ms, mọi tác vụ trên cùng thread đều phải chờ, trong khi chính tác vụ đang block lại chẳng làm gì cả.
Tóm lại, tôi rất tiếc phải thông báo rằng: không, lập trình khó có khả năng đã được "giải quyết", và bạn vẫn cần biết mình đang làm gì.
Benchmark cũng có thể là "rác"
Hóa ra các benchmark do AI tạo ra cũng đáng ngờ, vì chúng chỉ đo thông lượng mà bỏ qua các thuộc tính khác của hệ thống. Zach Daniels đã đo tỷ lệ gửi thông báo bài viết mới dưới tải nặng và phát hiện bản Rust chỉ đạt 1% gửi thành công trong điều kiện tải cao. Không mấy ấn tượng.
Nhưng ngay cả benchmark được cải thiện cũng có thể không đo đúng thứ bạn cần. Cả benchmark của DHH lẫn của Zach đều là closed-loop: dùng N client, mỗi client gửi tin nhắn mới ngay khi nhận được phản hồi cho tin trước đó. Trong thực tế, tải tăng thường đến từ một lượng lớn người dùng cùng hành động một lúc, chứ không phải chờ nhau. Khi đó bạn cần tốc độ đến không đổi (constant arrival rate) — gửi request với tần suất cố định, thay vì phụ thuộc vào tốc độ hệ thống phản hồi.
Nhìn vào kết quả: Rust gửi được 1% trong số 6-7k thông báo, còn Elixir gửi được 100% trong số khoảng 1.7k. Điều này cho thấy Elixir xử lý backpressure tốt hơn — nhưng chỉ đúng khi số lượng request không làm quá tải hệ thống.
Lập trình là nghệ thuật của những đánh đổi
Nhiều người trong cộng đồng Elixir vội vàng kết luận sau khi thấy tỷ lệ gửi 1% của bản Rust: rằng Elixir giỏi xử lý đồng thời hơn hẳn. Đúng, các ngôn ngữ trên nền BEAM như Elixir rất mạnh về concurrency, và viết code đồng thời trong Elixir nhìn chung dễ hơn Rust. Nhưng nó phải trả giá: không có quyền kiểm soát cấp thấp, tốn bộ nhớ hơn, và thường chậm hơn. Đó là lý do tồn tại của rustler.
Khi chạy lại thử nghiệm với tốc độ đến không đổi ở 100 POST/giây, tác giả thu được kết quả khác hẳn:
- Bản Rust: client nhận được khoảng 14% sự kiện
- Bản Elixir: client nhận được khoảng 60% sự kiện
Nghe như Elixir vẫn thắng? Chưa chắc. Ở mức tải này, Rust không có lỗi HTTP nào, còn Elixir bị timeout khoảng 23% request POST.
Còn về độ trễ? Độ trễ gửi sự kiện tệ nhất gần 180 giây ở bản Elixir. Ở bản Rust, khi việc gửi bị chậm, client sẽ bị ngắt kết nối. Khi kết nối lại, trình duyệt sẽ fetch các tin nhắn mới nhất, điều này gần như vô hiệu hóa nhu cầu về những sự kiện đã bị mất.
Theo bạn, trải nghiệm nào tốt hơn: client âm thầm kết nối lại và tải cập nhật mới, hay phải chờ thông báo về một tin nhắn mới trong 3 phút?
Một con số không kể hết câu chuyện
Vì sao bản Rust lại mất nhiều thông báo khi tải nặng? Nó dùng broadcast channel của tokio::sync::broadcast để phát sự kiện tới các client đang kết nối. Một thông báo về tin nhắn mới có thể cần gửi tới nhiều kết nối, nên cách này hoàn toàn hợp lý.
Một đặc tính của broadcast channel là nó có sức chứa giới hạn. Nếu một receiver không theo kịp, nó sẽ nhận lỗi RecvError::Lagged. Trong bản Rust, sức chứa broadcast được đặt là 256. Trong Elixir, tiến trình GenServer xử lý việc gửi sự kiện và mặc định mailbox của GenServer không có giới hạn.
Chỉ cần đổi một dòng trong Rust:
- stream_capacity: 256
+ stream_capacity: 16384
Tỷ lệ gửi ở 100 request/giây tăng từ khoảng 14% lên 90%. Giờ thì Rust hơn Elixir rồi phải không? Không hẳn.
Tác giả cho rằng phiên bản này thực ra tệ hơn, vì trong trường hợp này nên fail fast và buộc client kết nối lại thay vì xử lý cực kỳ chậm. Và đây là điều thú vị: sau khi tăng sức chứa, độ trễ gửi sự kiện tệ nhất tăng từ 11 giây lên hơn 130 giây — giống hệt cách Elixir hành xử trước đó. Theo tác giả, như vậy là tệ hơn hẳn việc ngắt kết nối những client bị chậm.
Ngay cả 11 giây cũng đã là quá dài. Nếu receiver không thể chuyển tiếp sự kiện nhanh hơn thế, tốt hơn là bỏ sự kiện đó.
Bài học: việc đặt ra ràng buộc hợp lý trong hệ thống cực kỳ quan trọng. Elixir không tự động sửa hết mọi vấn đề concurrency cho bạn. Và nếu bạn không tự đặt giới hạn, bạn sẽ sớm gặp phải giới hạn từ bên ngoài. Trong bài stress test 100 request/giây, bản Elixir đạt tới 1.8GB bộ nhớ. Một câu hỏi đáng suy ngẫm: bỏ một vài thông báo hay bị OOM kill — cái nào tốt hơn?
Kết luận
Vậy chúng ta học được gì?
- Bạn vẫn phải tư duy phản biện. Biết mình đang làm gì là lợi thế không thể thay thế.
- Đừng vội kết luận dựa trên một con số duy nhất. Một chỉ số không nói lên toàn bộ câu chuyện.
- Muốn hệ thống đáng tin cậy, bạn phải biết khi nào nên thất bại.
- Benchmark rất khó. Đo đúng thứ cần đo còn khó hơn.
AI có thể viết code nhanh, nhưng nó không thể thay bạn quyết định các đánh đổi — và đó vẫn là phần khó nhất của lập trình.

