FDE PulseViệc làm FDE đang mở 316Mới đăng 7 ngày qua 10Chủ đề nổi bật: Đào tạo kỹ năng FDE tại Đông Nam Á

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

Thực hành saga: khi agent hỏng giữa chừng trên nhiều hệ thống

Agent của bạn đã giữ hàng trong kho và tạo chứng từ trong ERP thì hệ thống thứ ba từ chối. Thứ cứu bạn lúc đó là một nhật ký ghi rõ cách hoàn tác từng bước đã làm, chứ không phải lệnh rollback.

Đồ hoạsaga_log.json khi credit note hỏng và bù cũng hỏng
  1. 1reserve_stock: doneKho đã giữ hàng; log ghi kèm lệnh hoàn tác release_stock
  2. 2create_credit_note: failedHết retry, orchestrator chuyển sang bù; email không bao giờ được gửi
  3. 3reserve_stock: needs_humanrelease_stock cũng hỏng; saga dừng, run_saga trả về needs_human
  4. 4reserve_stock: undoneSau khi sửa, gọi lại compensate từ cùng file log; chỉ bước còn dở được bù

Chỉ cần đọc log là biết bước nào đã chạy, đã bù hay đang chờ người xử lý.

Đồ hoạ: FDE Times

Tóm tắt nhanh

  • Giao dịch bù là một giao dịch mới chạy sau khi bước cũ đã commit, và logic của nó phụ thuộc vào từng nghiệp vụ.
  • Ghi lại từng bước kèm cách hoàn tác nó, gắn idempotency key cho mỗi lần gọi, retry trước và chỉ bù khi không thể đi tiếp.
  • Bước không thể đảo ngược như gửi email phải đứng sau mọi bước kiểm tra. Nếu chính bước bù hỏng, dừng lại và gọi người.
Chia sẻLinkedInFacebookX

Thử hình dung agent xử lý yêu cầu hoàn tiền của khách hàng. Nó giữ lại hàng trong hệ thống kho, tạo credit note trong ERP rồi gửi email xác nhận. Bước hai trả về lỗi, nhưng bước một đã commit xong từ trước. Hệ thống kho lúc này đang giữ một lô hàng không ai cần đến.

Không có transaction nào bao trùm cả ba hệ thống để rollback. Saga sinh ra cho đúng tình huống này: thao tác lớn được chia thành chuỗi giao dịch cục bộ, và khi một bước hỏng thì saga chạy các giao dịch bù để đảo ngược những bước trước đó, theo mô tả của Azure Architecture Center.

Với một FDE, đây là việc hằng ngày chứ không phải lý thuyết hệ phân tán. Agent càng được cấp nhiều quyền ghi vào hệ thống của khách thì câu “hỏng giữa chừng thì sao” càng đến sớm trong buổi review. Bài này dựng một orchestrator nhỏ bằng Python để bạn có câu trả lời cụ thể.

Bạn sẽ dựng gì, và cần gì?

Kết quả là một orchestrator chạy ba bước tuần tự. Nó ghi log từng bước, retry khi gặp lỗi tạm thời, bù theo log khi không thể đi tiếp và gắn cờ cần người duyệt khi chính bước bù thất bại. Bạn chỉ cần Python 3 và một trình soạn thảo.

Mọi hệ thống bên ngoài ở đây đều là hàm giả lập. Code đã được đơn giản hoá để dạy nguyên lý, chưa dùng được cho production.

Bước 1: Bước nào không thể quay lại?

Trước khi viết dòng code nào, hãy phân loại từng bước. Tài liệu saga của Microsoft gọi bước không thể quay lại là pivot transaction: khi pivot thành công, các bước bù không còn ý nghĩa, và những bước chạy sau nó phải retry được cho tới khi xong.

Email gửi cho khách là ví dụ điển hình vì không ai thu hồi được nó. Nguyên tắc thiết kế đi kèm khá rõ: bước không thể đảo ngược chỉ được chạy khi mọi bước kiểm tra quan trọng đã qua.

STEPS = [
    {"name": "reserve_stock", "kind": "compensable", "undo": "release_stock"},
    {"name": "create_credit_note", "kind": "compensable", "undo": "void_credit_note"},
    {"name": "send_customer_email", "kind": "pivot", "undo": None},
]

Kiểm tra: mọi bước có undo là None phải nằm cuối danh sách. Nếu agent buộc phải gửi email giữa chừng, đó là dấu hiệu nên tách thành hai workflow, mỗi workflow kết thúc bằng bước không thể đảo ngược của riêng nó.

Bước 2: Ghi lại cách hoàn tác ngay khi làm

Cơ chế cốt lõi là ghi thông tin về từng bước cùng cách hoàn tác nó. Khi thao tác thất bại, workflow tua ngược qua các bước đã xong. Log phải nằm ngoài bộ nhớ của tiến trình và được đọc lại khi khởi động, nếu không thì một lần crash sẽ xoá sạch tiến độ.

import json, os

class SagaLog:
    def __init__(self, path):
        self.path = path
        self.entries = []
        if os.path.exists(path):  # chạy lại sau crash: nạp tiến độ cũ
            with open(path) as f:
                self.entries = json.load(f)

    def record(self, step, status, undo=None):
        self.entries.append({"step": step, "status": status, "undo": undo})
        with open(self.path, "w") as f:
            json.dump(self.entries, f, indent=2)

    def is_undone(self, step):
        return any(e["step"] == step and e["status"] == "undone" for e in self.entries)

Kiểm tra: chạy thử record, tắt tiến trình, rồi tạo lại SagaLog với cùng đường dẫn. entries phải chứa đúng những dòng đã ghi, mỗi dòng cho biết bước nào đã xong và lệnh nào sẽ đảo ngược nó.

Bước 3: Gọi trùng có làm hỏng dữ liệu không?

Retry nghĩa là một bước có thể chạy nhiều lần, nên mỗi bước phải là lệnh idempotent. Stripe là một ví dụ thực tế: với mỗi idempotency key, Stripe lưu status code và body của request đầu tiên, dù request đó thành công hay thất bại, rồi trả lại đúng kết quả ấy cho mọi lần retry dùng cùng key.

def idem_key(saga_id, action):
    return f"{saga_id}:{action}"

_seen = {}  # giả lập phía hệ thống khách lưu kết quả theo key
def fake_reserve_stock(key):
    if key not in _seen:
        _seen[key] = {"reserved": 5}
    return _seen[key]

Kiểm tra: gọi fake_reserve_stock hai lần với cùng key. Kho chỉ được giữ hàng một lần.

Bước 4: Retry trước, bù sau

Một lỗi mạng thoáng qua không đáng để huỷ cả giao dịch. Chỉ bù khi không thể đi tiếp, tức là đã hết lượt retry hoặc lỗi được xác định là không tạm thời.

class Transient(Exception): pass
class Permanent(Exception): pass

def run_with_retry(fn, key, attempts=3):
    for _ in range(attempts):
        try:
            return fn(key)
        except Transient:
            continue
    raise Permanent(f"hết retry: {key}")

def run_saga(saga_id, steps, systems, log, priority=()):
    done = []
    for s in steps:
        try:
            run_with_retry(systems[s["name"]], idem_key(saga_id, s["name"]))
            log.record(s["name"], "done", s["undo"])
            done.append(s)
        except Permanent:
            log.record(s["name"], "failed")
            # trả về "compensated" hoặc "needs_human" tuỳ kết quả bù
            return compensate(saga_id, done, systems, log, priority)
    return "completed"

Đây chính là kiểu orchestration trong tài liệu saga của Microsoft: orchestrator gửi request, lưu và diễn giải trạng thái của từng tác vụ, rồi xử lý phục hồi bằng giao dịch bù. Cái giá phải trả là orchestrator trở thành một điểm lỗi tiềm tàng. Đó là thêm một lý do để log phải nằm trên đĩa.

Bước 5: Bù theo thứ tự nào, và nếu bù cũng hỏng?

Mặc định là đảo ngược thứ tự đã chạy, nhưng không bắt buộc. Kho dữ liệu nào nhạy cảm với bất nhất hơn thì nên được hoàn tác trước. Ở ví dụ này, ERP kế toán có lẽ quan trọng hơn kho.

Chính bước bù cũng có thể hỏng. Vì vậy hệ thống cần ghi tiến độ để chạy tiếp từ đúng điểm lỗi. Có những lúc phải dừng lại, cần người can thiệp và gửi cảnh báo nêu rõ lý do.

def compensate(saga_id, done, systems, log, priority=()):
    ordered = sorted(reversed(done), key=lambda s: s["name"] not in priority)
    for s in ordered:
        if log.is_undone(s["name"]):
            continue  # đã bù ở lần chạy trước, bỏ qua
        try:
            run_with_retry(systems[s["undo"]], idem_key(saga_id, s["undo"]))
            log.record(s["name"], "undone")
        except Permanent as e:
            log.record(s["name"], "needs_human", str(e))  # lý do nằm ở trường undo cho gọn
            return "needs_human"  # dừng, chờ người duyệt
    return "compensated"

Trạng thái trả về là điểm dễ bị bỏ sót. Nếu run_saga vẫn báo “compensated” trong khi bước bù đã dừng giữa chừng, phía gọi sẽ tưởng mọi thứ đã sạch sẽ, còn kho vẫn đang giữ hàng. Vì vậy needs_human phải là một trạng thái riêng mà caller kiểm tra được để bắn cảnh báo.

Kiểm tra: cho create_credit_note ném Permanent. Log phải ghi reserve_stock done, create_credit_note failed rồi reserve_stock undone, run_saga trả về "compensated" và email không bao giờ được gửi. Sau đó cho release_stock cũng hỏng: log phải dừng ở needs_human và run_saga trả về "needs_human". Sửa release_stock rồi gọi lại compensate với một SagaLog mới mở từ cùng file: bước nào đã có undone sẽ bị bỏ qua, chỉ bước còn dở được bù tiếp.

Bước 6: Hai saga cùng chạm vào một khách hàng

Giả sử khách gửi hai yêu cầu liên tiếp. Nếu hai saga chạy song song trên cùng một đơn hàng, bước bù của saga này có thể chạy chen vào giữa bước tiến của saga kia. Pattern Sequential Convoy xử lý chuyện này bằng cách gom message theo một khoá như order ID. Mỗi nhóm được xử lý tuần tự, còn các nhóm khác nhau vẫn chạy song song.

Cái bẫy của cách làm này là poison message. Một message hỏng lặp đi lặp lại sẽ chặn mọi message phía sau trong cùng session. Bạn cần đếm số lần giao message và chuyển nó sang dead-letter queue khi vượt ngưỡng retry. Phần này không có trong code mẫu, nhưng đừng bỏ qua khi lên production.

Những lỗi hay gặp

Lỗi phổ biến nhất là coi bước bù như nút Undo. Wikipedia định nghĩa giao dịch bù là một giao dịch mới đảo ngược hiệu ứng của một giao dịch đã commit, nên nó khác rollback.

Bước bù cũng không đưa hệ thống về trạng thái ban đầu. Nó phải tính đến các công việc đang chạy đồng thời, và logic ấy tuỳ thuộc vào từng ứng dụng.

Lỗi thứ hai là quên rằng saga không có isolation. Kết quả của bước gốc hiển thị cho các hệ thống khác trước khi nó được bù, nên có thể xảy ra lost update hoặc dirty read. Có vài đối sách quen thuộc: semantic lock (một cờ ở tầng ứng dụng báo rằng bản ghi đang được cập nhật), commutative update và đọc lại giá trị trước khi ghi.

Lỗi thứ ba đến từ agent. Với những trường hợp mơ hồ hoặc tác động lớn, workflow nên dừng lại chờ người duyệt. Đừng để LLM tự quyết có nên bù hay không. Hãy để orchestrator quyết theo quy tắc, còn agent chỉ đề xuất.

Ở site khách hàng, kỹ năng này trông thế nào?

Trong tuần đầu, đừng viết orchestrator ngay. Hãy ngồi với đội vận hành của khách và hỏi về từng API mà agent sẽ ghi vào: API có nhận idempotency key không, lệnh hoàn tác là gì, và ai được phép xử lý khi việc hoàn tác thất bại. Bảng trả lời đó chính là STEPS của bạn.

Khi đọc JD của các vị trí FDE, hãy để ý những cụm như “workflow nhiều hệ thống”, “tích hợp ERP/CRM” hay “agent có quyền ghi”. Trong CV, một dòng như “thiết kế saga cho agent hoàn tiền: tách bước pivot, idempotency key cho mọi lệnh ghi, có quy trình escalate khi bù thất bại” nói nhiều hơn mọi chữ “có kinh nghiệm với hệ phân tán”.

Agent chắc chắn sẽ có lúc hỏng giữa chừng. Điều khách nhớ sau sự cố là bạn có mở ngay được file log cho thấy bước nào đã chạy, bước nào đã bù và bước nào đang chờ người duyệt.

5 nguồn
Đọc tiếp trên lộ trình · Chặng 3: AI ứng dụngViết MCP server Python cho một CRM giả lập: từ một file đến Claude CodeTrước khi được chạm vào CRM thật của khách hàng, bạn có thể dựng một bản giả lập trong một buổi chiều và dùng nó để chốt cách thiết kế tool cho agent.