← tất cả bài viết
6 phút đọc

Cái mạch nối Node và Python tôi cứ đáp xuống

Vì sao tôi với lấy Python bên cạnh Node, và cái mạch nối giữa hai bên thường nằm ở đâu.

Sáu tháng gần đây, backend mặc định của tôi không còn là Node nữa — mà là Node Python. Không phải vì Python đang hot trở lại, cũng không phải vì Node làm tôi thất vọng. Đơn giản là những việc cứ rơi vào tay tôi có một hình dáng tách đôi rất gọn giữa hai bên, và cuối cùng tôi đã thôi không chống lại nó nữa.

Buổi chiều tôi bỏ cuộc khi parse PDF bằng Node

Chuyện bắt đầu ở Remolution. Bọn tôi làm SaaS tuyển dụng, hơn 100 khách hàng enterprise, và một mảng không nhỏ của sản phẩm là: recruiter upload CV của ứng viên, hệ thống extract các trường có cấu trúc — tên, liên hệ, kinh nghiệm, học vấn, kỹ năng — và hiển thị chúng trong pipeline. Nghe đơn giản. Nhưng không hề.

Cái CV làm tôi vỡ đầu là một file PDF scan, do một khách hàng enterprise upload đâu đó năm 2024. Layout hai cột, một sidebar có mấy thanh skill nhỏ xíu, kèm một ảnh chữ ký nhúng vào — OCR được, nhưng chỉ vừa đủ. pdf-parse trả về một mớ text trong đó cột phải bị xen kẽ vào cột trái, dòng nọ vào dòng kia. pdfjs-dist extract được các từ kèm tọa độ, nhưng ghép lại thành thứ tự đọc được là một dự án mà tôi không hề muốn ship.

Tôi dành gần như cả buổi chiều thứ Ba để cố bắt Node làm việc này. Đến 9 giờ tối tôi có một extractor chạy được cho đúng cái CV đó — và ba CV mới đã fail nằm sẵn trong hàng đợi support. Tôi gập laptop lại, và sáng hôm sau viết lại toàn bộ thành một service Python nho nhỏ. pdfplumber cho tôi layout-aware extraction trong khoảng 40 dòng. Cái hack lúc 9 giờ tối hôm trước dài tầm 400 dòng.

Cái mạch nối (seam) không phải là một thất bại kiến trúc — nó là chỗ mỗi ngôn ngữ làm tốt nhất phần việc của mình.

Mạch nối thực sự nằm ở đâu

Với tôi, mạch nối luôn có cùng một hình dáng: lấy dữ liệu có cấu trúc ra từ input không có cấu trúc. PDF, CV, form scan, các trường free-text, ảnh chụp màn hình, phần thân của một email user copy-paste vào textarea. Input không có schema; output thì cần có. Phép biến đổi đó là chỗ Python kiếm cơm.

Xung quanh mạch nối, Node vẫn giữ tất cả. Request rơi vào một route handler của Next.js, auth chạy, file được lưu lên blob storage, một row được insert, một event được emit. Không có gì trong số đó nên đổi ngôn ngữ cả. Phía Node của mạch nối nhanh, type tốt, dễ suy luận, và App Router cho tôi streaming với Server Actions miễn phí.

Phía Python là phần mà trước đây tôi cứ phải dán ghép năm cái library với một đống regex. Khi tôi để nó có runtime riêng, code ngắn lại và bug ít đi.

Cái gì Node giữ, cái gì Python lấy

Cách chia tôi đáp xuống, gần như là:

Node giữ HTTP routing, auth, render path của Next.js, server actions, mọi thứ dưới 200ms, mọi thứ chạm vào session của user, mọi thứ trả JSON về browser. Node giỏi cái bề mặt của app. Nhiều năm rồi vẫn vậy.

Python lấy mọi thứ mà input là một file tôi không thể tin được hình dáng của nó. Parse PDF. OCR. Chấm điểm resume. Phân loại document. Mọi chỗ mà câu "đội viết library đã giải quyết vấn đề này rồi" là đúng — và trong thế giới document-extraction, cái đội đó gần như luôn là Python.

Còn một nhóm thứ ba — mọi thứ liên quan tới ML — mà tôi từng tưởng Node xử được bằng ONNX hay gọi API bên thứ ba. Đôi khi được thật. Nhưng khoảnh khắc tôi cần ghép một pre-processor, một model, và một post-processor với cùng một bộ type chia sẻ, tôi lại quay về Python — và vui hơn.

Cái interface tôi cứ đáp xuống

Tôi đã thử ba hình dáng trước khi yên vị.

Đầu tiên là child_process.spawn. Rẻ, không qua network, dễ demo. Nó fail ngay khoảnh khắc tiến trình Python cần sống lâu hơn một request — riêng việc warm-up model đã ngốn gần một giây mỗi lần gọi.

Thứ hai là một queue. Node enqueue một job, một worker Python pick lên, kết quả trả về qua channel. Đúng cho việc batch, sai cho trường hợp user đang ngồi trước UI chờ upload xong.

Cái tôi đáp xuống cuối cùng là lựa chọn buồn tẻ nhất: một sidecar FastAPI, HTTP giữa hai bên, schema Pydantic làm contract. Route Next.js validate upload, post file sang service Python, await response, trả về client. Model luôn warm. Schema được chia sẻ qua một TypeScript type được generate ra. Cả hệ thống là hai service trên cùng một network — mà trên Vercel với Fluid Compute hỗ trợ cả Node và Python runtime native, gần như không có nghi thức gì cả.

Điều tôi muốn nói với chính tôi ngày trước

Đừng cố bắt Node parse PDF nữa. Đừng cố bắt Python phục vụ request surface. Hai runtime không phải là một sự thất bại về gu — nó là một sự khớp đúng giữa việc và công cụ. Cái giá phải trả là thêm một deploy target. Cái lợi là code không làm bạn muốn nghỉ việc lúc 9 giờ tối.

Cái tôi vẫn còn làm sai

Tôi cứ đánh giá thấp công sức đồng bộ schema giữa hai phía. Pydantic ở phía Python thì thích lắm; Zod ở phía Node thì cũng ổn. Nhưng khi shape thay đổi — thêm field mới, đổi tên một enum, một object lồng vào — tôi có hai chỗ phải update, và không có compiler nào hét vào mặt tôi khi tôi sửa bên này mà quên bên kia.

Cách sửa tôi đang thử là generate cả hai phía từ một nguồn duy nhất: một JSON Schema document nhỏ mà cả Pydantic lẫn Zod cùng tiêu thụ. Đây đúng kiểu vấn đề tooling mà trước đây tôi hay phẩy tay cho qua. Sáu tháng vào dự án, việc phẩy tay đang ngốn của tôi khoảng một giờ mỗi tuần cho mấy con bug tích hợp lằng nhằng, nên cuối cùng tôi cũng đi sửa nó.

Tôi đứng ở đâu sau tất cả

Mảng agentic tôi đang làm ở PSA bây giờ — storefront tự vận hành của ShopQuantum.AI, doanh nghiệp ảo 59 nhân viên của FangBot.AI — có cùng một mạch nối, chỉ to hơn. Bề mặt deterministic là Node. Bề mặt probabilistic, đầy document, kiểu "lấy giùm tao structure ra từ đống hỗn loạn này" là Python. Các tool call băng qua mạch nối liên tục, và kỷ luật tôi học được ở Remolution cũng chính là cái tôi đang dùng bây giờ: quyết định mỗi mảng việc thuộc phía nào của mạch nối, và đừng để lựa chọn runtime trở thành thứ bạn phải đánh nhau với.

Mạch nối ổn mà. Nó luôn ổn. Tôi chỉ mất quá nhiều thời gian giả vờ rằng một ngôn ngữ sẽ làm được cả hai việc, chỉ vì một deploy target nghe có vẻ đơn giản hơn hai. Cái deploy target là phần rẻ. Cái giờ đồng hồ lúc 9 giờ tối thứ Ba trong cõi extract CV — đó mới là phần đắt. Tôi thà trả nó một lần, trong config container, còn hơn trả nó mỗi tuần, bằng regex.