0

Tool chạy 2 lần sau khi agent crash — đo thật trên ba framework

Bạn cho một AI agent gọi tool trừ tiền thẻ, ghi đơn hàng, gửi email. Tiến trình agent bị giết giữa chừng (crash, OOM-kill, deploy restart) đúng lúc tool đang chạy. Bạn khởi động lại, agent "resume" từ trạng thái đã lưu. Câu hỏi: tool vừa gọi có chạy lại không? Nếu có, khách bị trừ tiền hai lần.

Bài này đo bằng một công cụ tên plugpull (repo công khai, code mở), chạy trên ba framework agent phổ biến: LangGraph, OpenAI Agents SDK, Microsoft Agent Framework. Lệnh và output dưới đây là output thật, dẫn nguồn cụ thể để bạn tự chạy lại.

Vì sao chuyện này xảy ra: checkpoint không lưu "giữa chừng"

Mọi framework agent có state đều dùng cơ chế checkpoint: lưu một bản chụp trạng thái tại các mốc cố định, để khi crash thì resume từ bản chụp gần nhất thay vì chạy lại từ đầu. Nhưng checkpoint chỉ lưu trước hoặc sau một bước, không lưu trong lúc bước đó chạy. Nếu tiến trình bị giết đúng lúc tool đang gọi ra ngoài (API, DB, cổng thanh toán), framework không biết tool đã chạy xong hay chưa — nó chỉ biết "bước này chưa có checkpoint sau", nên resume chạy lại bước đó từ đầu.

Sơ đồ dưới minh hoạ đúng một tình huống: tool gọi ra ngoài xong nhưng process bị giết trước khi checkpoint kịp lưu.

thời gian  ───────────────────────────────────────────────────▶

framework:  [checkpoint A đã lưu] ---- tool đang chạy ---- [checkpoint B lẽ ra lưu ở đây]
                                            │
                                       process bị KILL
                                            │
                                    tool ĐÃ gọi ra ngoài
                                    (provider đã ghi nhận effect)
                                            │
                                       nhưng KHÔNG có
                                    checkpoint xác nhận điều đó
                                            │
resume:                          framework chỉ thấy checkpoint A
                                  → chạy lại bước tool từ đầu
                                            │
                                  provider ghi nhận effect LẦN 2

Khoảng trống giữa "effect đã xảy ra ở thế giới ngoài" và "checkpoint xác nhận điều đó" chính là nơi side effect bị nhân đôi (hoặc, ở chiều ngược lại, bị mất hẳn).

Cách đo: plugpull

plugpull là một CLI mã nguồn mở: nó chạy một agent thật trên framework thật, SIGKILL đúng lúc tool đang gọi ra ngoài (hai điểm giết: before-effect — trước khi provider ghi nhận, after-effect — sau khi ghi nhận nhưng tool chưa trả kết quả), rồi resume và đếm số lần provider giả thấy effect. Verdict là PASS (đúng 1 effect) hoặc FAIL (0 hoặc ≥2). Điểm giết được xác nhận bằng dữ kiện kernel (pid process gọi lấy từ socket, kiểm nằm trong process group bị kill), không chỉ suy đoán theo thời gian.

Kết quả 1 — LangGraph 1.2.11: đúng như docs đã cảnh báo

PASS   kill-mid-tool  before-effect  langgraph 1.2.11 durability=async (default)  the provider saw exactly 1 charge_card effect
FAIL   kill-mid-tool  after-effect   langgraph 1.2.11 durability=async (default)  effect duplicated: the provider saw 2 charge_card effects after resume (expected 1)
PASS   kill-mid-tool  before-effect  langgraph 1.2.11 durability=sync             the provider saw exactly 1 charge_card effect
FAIL   kill-mid-tool  after-effect   langgraph 1.2.11 durability=sync             effect duplicated: the provider saw 2 charge_card effects after resume (expected 1)

(Nguồn: finding-langgraph-kill-mid-tool.md, kiểm 2026-09-14.)

after-effect FAIL ở cả hai chế độ durability. Nhưng đây không phải bug — tài liệu LangGraph nói thẳng: "A task that started but did not finish may run again on that resume, so design side effects to be idempotent" (LangGraph docs, Functional API, kiểm 2026-09-14). Tool trong ví dụ cố tình không có idempotency key, để đo đúng cái giá phải trả nếu bạn bỏ qua khuyến cáo đó. Dùng tool_call_id làm key thì PASS ổn định ở durability="sync" (20/20 lần), nhưng ở durability="async" (mặc định của LangGraph) key đó không ổn định — đo 20 lần thì after-effect PASS 11, FAIL 9, vì id có thể chưa kịp lưu trước khi crash (con số này dao động giữa các lần đo; một lượt đo khác từng ra 8/12).

Kết quả 2 — OpenAI Agents SDK 0.22.2: bug đã có người báo, chưa merge fix

Đóng góp ở đây không phải tìm ra bug này. Issue openai-agents-python#4775 đã có người khác báo trước, PR sửa #4906 đã tồn tại. Đóng góp thật: tái hiện được bằng đúng một lệnh.

PASS   lost-session-write  write-lost     openai-agents 0.22.2  write 4: all 5 Session items stored exactly once
FAIL   lost-session-write  ack-lost       openai-agents 0.22.2  write 4: the staged input duplicated: stored 2 times after resume (expected 1)

Docs của SDK hứa: "Across serialization, resume, and replay-safe retries, the SDK preserves one durable InputItem occurrence" (Results, kiểm 2026-09-14). Thực tế đo được là 2 lần — khác LangGraph, đây một bug so với chính tài liệu của framework, không phải hành vi đã được cảnh báo trước.

Kiểm lại lúc 2026-09-16: issue #4775 vẫn open; PR #4906 vẫn open, chưa merge (đầu 58de902e), còn một review "changes requested" cũ. Áp patch PR đó thì verdict đổi thành PASS / PASS.

Kết quả 3 — Microsoft Agent Framework 1.18.0: mất một, nhân đôi cái kia

Bug này tinh vi hơn: nó không chỉ chạy lại một tool, mà làm hai bước checkpoint đổi chỗ kết quả cho nhau. Kịch bản: hai đơn hàng A ($42) và B ($7) thanh toán đồng thời qua asyncio.gather(checkout("A"), checkout("B")). Đơn A tra cứu chậm hơn, nên pay("B") bắt đầu trước pay("A"). Process bị giết ngay trong pay("A").

$ ... repro_without_plugpull.py concurrent
start exit code: -9 (-9 = SIGKILL)
ledger at the kill: {"B": 1}
resume outputs: [['order B: charged $7', 'order B: charged $7']]
ledger after resume: {"B": 2}
BUG: order A charged 0 times, order B charged 2 times (expected 1 each)

(Nguồn: finding-agent-framework-8292.md, kiểm 2026-09-15.)

Nguyên nhân: framework cache kết quả mỗi bước theo (tên_bước, thứ_tự_bắt_đầu), bỏ qua tham số. Lúc chạy đầu, pay của B bắt đầu trước, nhận index 0; pay của A nhận index 1. Khi resume, lookup của B trả ngay từ cache nên pay("A") lại bắt đầu trước, nhận index 0 và nhận nhầm kết quả của B. pay("B") nhận index 1, cache trống, chạy lại — bị trừ tiền lần hai.

Điều kiện kích hoạt: hai lệnh gọi cùng một @step bắt đầu theo thứ tự khác nhau giữa lần chạy đầu và lúc resume. Đảo thứ tự gather(B, A) thì cả hai chỉ bị trừ một lần — thứ tự bắt đầu không đổi giữa hai lần chạy.

Kiểm lại lúc 2026-09-16 (qua gh api): issue microsoft/agent-framework#8292 vẫn open, nhãn reproduced, 2 bình luận (một bot triage, một người nói đang tìm hướng sửa) — vẫn chưa có PR sửa, không đổi so với lần kiểm 2026-09-15.

Giới hạn của phép đo này

  • Ca LangGraph "async before-effect FAIL" là hiếm và phụ thuộc thời điểm, không phải bug thứ ba. Khi ép ổ đĩa tải nặng, nó xảy ra 1/40 lần trên Python 3.10 và 3/40 lần trên 3.13 — tức phần lớn thời gian không xảy ra. Đừng đọc nhầm thành "LangGraph có 3 bug", chỉ có 1 (đã tài liệu hoá) cộng một hiện tượng cần thêm bằng chứng mới kết luận được cơ chế.
  • Verdict chính không chỉ đo trên macOS. Trên SHA 6c68e3c, bộ test (123 test) chạy xanh trên Linux thật qua Docker: arm64 123 passed ở cả Python 3.10 và 3.13, amd64 giả lập 123 passed ở 3.13. Trên 5985623 (chỉ đổi văn bản so với 6c68e3c), CI GitHub Actions ubuntu x86_64 thật cũng xanh ở cả hai bản Python — CI không công khai số test đã chạy, chỉ ghi hai job thành công. Ví dụ Agent Framework (agent.py concurrent) cũng chạy trên Linux arm64, cho đúng 5/5 FAIL, giống macOS. Thứ chỉ đo trên một máytần suất ca hiếm LangGraph async before-effect (1/40, 3/40) — ép ổ đĩa tải nặng trên đúng một máy Mac; máy khác có thể khác.
  • Agent Framework: chỉ thử FileCheckpointStorage với resume qua get_latest; chưa thử InMemoryCheckpointStorage hay các cách resume khác. API này còn gắn nhãn experimental.
  • plugpull chỉ thấy một chiều của bug Agent Framework (chiều mất tiền), vì harness giữ đúng lệnh gọi đầu tiên. Chiều nhân đôi phải dùng một script riêng, không qua plugpull, để thấy.
  • Không framework nào được publisher hứa sửa theo mốc thời gian cụ thể — trạng thái "open" hôm nay có thể đổi bất cứ lúc nào.

Ai viết bài này

Nội dung và code đo trong bài này do AI viết (agent hỗ trợ lập trình); người phụ trách dự án đọc và duyệt trước khi công bố. Không có LLM nào được gọi khi chạy các kịch bản đo — toàn bộ output ở trên đến từ chạy tool thật, không phải mô phỏng.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí