Vòng lặp tool calling tự viết ở bài trước đủ cho một agent đơn giản, nhưng nó có một điểm yếu: toàn bộ trạng thái nằm trong biến cục bộ của một hàm Python đang chạy. Quy trình càng có nhiều nhánh ("hỏi trạng thái thì tra, đòi hoàn tiền thì kiểm tra hạn mức, tiền lớn thì chờ người duyệt") thì hàm càng rối, và nếu người duyệt trả lời sau hai tiếng thì tiến trình đã tắt từ lâu. LangGraph giải quyết đúng chỗ đó: bạn mô tả quy trình như một đồ thị gồm trạng thái, node và cạnh, còn thư viện lo việc chạy, lưu checkpoint, tạm dừng và chạy tiếp. Bài này dựng một quy trình chăm sóc khách hàng bằng LangGraph 0.5.3, chạy thật với mô hình nhỏ và kiểm tra những cạm bẫy mà tài liệu cảnh báo.
Môi trường: Apple M4, 16 GB, Python 3.12, langgraph==0.5.3, langgraph-checkpoint-sqlite==2.0.11, openai==1.78.1, mô hình qwen2.5:1.5b chạy bằng Ollama qua endpoint tương thích OpenAI http://localhost:11434/v1 (không cần API key thật). Bài dùng lại kỹ thuật trả JSON có kiểm tra của Trích xuất dữ liệu có cấu trúc từ HTML bằng LLM và Pydantic cho bước phân loại tin nhắn.
Khi nào cần LangGraph thay vì vòng lặp tự viết
Anthropic, trong bài Building effective agents, phân biệt workflow (LLM và công cụ được điều phối theo đường đi code định sẵn) với agent (LLM tự quyết bước tiếp theo), đồng thời cảnh báo rằng framework có thể thêm các lớp trừu tượng che mất prompt bên dưới và khiến việc gỡ lỗi khó hơn. Lời khuyên là bắt đầu bằng gọi API trực tiếp và chỉ dùng framework khi hiểu nó làm gì. Quy trình trong bài này thuộc loại routing workflow: phân loại đầu vào rồi chuyển tới nhánh chuyên biệt. Lưu ý: "nhiều bước" ở đây là một workflow có nhánh trong một đồ thị duy nhất, chưa phải hệ nhiều agent phối hợp với nhau; đa agent là chủ đề riêng. Dùng LangGraph ở đây vì ba nhu cầu cụ thể:
- Trạng thái tường minh (LangGraph gọi là State): mọi dữ liệu đi qua quy trình nằm trong một đối tượng
Statemà bạn khai báo, dễ in ra, dễ kiểm thử. - Tạm dừng và chạy tiếp (LangGraph lưu checkpoint): dừng chờ người duyệt rồi tiếp tục sau nhiều giờ, kể cả sau khi tiến trình khởi động lại.
- Lịch sử có thể xem lại: mỗi bước để lại một checkpoint, giúp debug và kiểm toán.
README của kho langchain-ai/langgraph cho biết LangGraph lấy cảm hứng từ Pregel và Apache Beam, còn giao diện công khai lấy cảm hứng từ NetworkX. Điểm đáng chú ý với bài toán này là đồ thị LangGraph được phép có chu trình, nên vòng "thử lại" hay "hỏi lại" biểu diễn được trực tiếp. Cạm bẫy đi kèm được bàn ở phần sau.
Mô hình của LangGraph: state, node, edge và reducer
Theo tài liệu Graph API, một đồ thị LangGraph có bốn khái niệm chính:
- State: dữ liệu dùng chung, lưu trong các kênh (channel). Mỗi kênh mặc định bị ghi đè bởi giá trị mới.
- Reducer: hàm gộp giá trị hiện tại với bản cập nhật của node. Khai báo bằng
Annotated[list[str], operator.add]thì cập nhật được nối thêm thay vì ghi đè. - Node: hàm Python nhận state, trả về một phần cập nhật (không cần trả toàn bộ state).
- Edge: cạnh thường (luôn đi từ node này sang node kia), cạnh điều kiện (hàm chọn node tiếp theo) và điểm vào
START.
Nhiều cạnh đi ra từ một node sẽ chạy song song trong super-step kế tiếp; các node tuần tự nằm ở những super-step khác nhau. Để nhìn rõ cơ chế gộp state, step1_linear.py dựng đồ thị tối giản không có LLM: node a viết hoa chữ, rẽ song song sang b và c.
class S(TypedDict):
text: str # không reducer: ghi đè
steps: Annotated[list[str], operator.add] # có reducer: nối thêm
def a(s: S): return {"text": s["text"].upper(), "steps": ["a"]}
def b(s: S): return {"steps": ["b"]}
def c(s: S): return {"steps": ["c"], "text": s["text"] + "!"}Output thật của invoke và của stream_mode="updates":
{'text': 'XIN CHAO!', 'steps': ['a', 'b', 'c']}
--- stream_mode='updates' ---
{'a': {'text': 'XIN CHAO', 'steps': ['a']}}
{'b': {'steps': ['b']}}
{'c': {'steps': ['c'], 'text': 'XIN CHAO!'}}Trường steps có reducer nên gom đủ ['a','b','c']; trường text không có reducer nên giá trị của node ghi sau cùng thắng. Chế độ updates cho thấy từng node trả gì, hữu ích khi gỡ lỗi LangGraph.
Dựng quy trình chăm sóc khách hàng bằng LangGraph
Quy trình LangGraph thực tế: khách nhắn tin, mô hình phân loại ý định, rồi đi một trong các nhánh: tra trạng thái đơn, hoàn tiền (nhỏ thì tự động, từ 1.000.000 đồng thì chờ người duyệt), hoặc chuyển nhân viên khi không rõ đơn. Cuối cùng một node soạn thư trả lời từ các dữ kiện đã xác định. State của quy trình:
class State(TypedDict, total=False):
message: str
intent: str
order_id: str | None
amount_vnd: int | None
facts: str
approved: bool
reply: str
trace: Annotated[list[str], operator.add]Nguyên tắc thiết kế quan trọng: mô hình chỉ làm hai việc nó giỏi là phân loại và viết câu chữ; mọi quyết định có hậu quả (đơn có tồn tại không, tiền có vượt hạn mức không, có hoàn tiền không) nằm trong code Python thường. Node triage của LangGraph gọi mô hình qua client.beta.chat.completions.parse với lớp Pydantic Triage (intent chỉ nhận refund, order_status hoặc other), temperature 0. Hàm định tuyến không tin mô hình khi mã đơn lạ:
def route(s: State) -> Literal["order_status", "refund_check", "handoff"]:
if s["intent"] in ("order_status", "refund") and s.get("order_id") not in ORDERS:
return "handoff" # thiếu hoặc sai mã đơn: chuyển nhân viên, không đoán
return {"order_status": "order_status", "refund": "refund_check"}.get(s["intent"], "handoff")Khi cần vừa cập nhật state vừa chọn node kế, tài liệu LangGraph khuyên dùng Command thay cho cạnh điều kiện. Node refund_check áp dụng hạn mức và trả về cả bản cập nhật lẫn đích đến:
def refund_check(s: State) -> Command[Literal["human_approval", "do_refund"]]:
amount = s.get("amount_vnd") or ORDERS[s["order_id"]]["total_vnd"]
amount = min(amount, ORDERS[s["order_id"]]["total_vnd"])
goto = "human_approval" if amount >= APPROVAL_THRESHOLD else "do_refund"
return Command(update={"amount_vnd": amount, "trace": [f"refund_check amount={amount} -> {goto}"]}, goto=goto)Đồ thị LangGraph được ghép bằng StateGraph: thêm tám node, cạnh START → triage, cạnh điều kiện từ triage, và cho các nhánh kết thúc cùng đổ về write_reply. Các node dùng Command tự khai báo đích qua kiểu trả về nên không cần thêm cạnh. Sơ đồ Mermaid do get_graph().draw_mermaid() sinh ra (file step2_output.txt) cho thấy 13 cạnh, trong đó 7 cạnh động nét đứt.
Tài liệu cảnh báo: với mỗi node, hoặc dùng cạnh tĩnh hoặc định tuyến động, vì trộn cả hai có thể chạy cả hai đường. Trong code LangGraph trên, bốn node cuối chỉ có cạnh tĩnh, còn triage, refund_check, human_approval chỉ định tuyến động.
Chạy thật: ba nhánh của LangGraph
step2_run.py chạy năm tình huống với InMemorySaver. Trường trace ghi lại các node đã đi qua. Tình huống hoàn tiền nhỏ (480.000 đồng, dưới hạn mức nên tự động):
== 2. Hoàn tiền nhỏ (480.000 đ) -> không cần duyệt
['triage -> refund', 'refund_check amount=480000 -> do_refund', 'do_refund', 'write_reply']
REFUND_LOG: [{'order_id': 'DH1002', 'amount_vnd': 480000}]Thư do mô hình 1,5B viết còn gượng nên nó chỉ soạn câu chữ từ dữ kiện đã chốt; nhánh đi và số tiền hoàn đọc từ trace và REFUND_LOG.
Tình huống thứ ba là hoàn 3.900.000 đồng, vượt hạn mức. Node human_approval gọi interrupt():
def human_approval(s: State) -> Command[Literal["do_refund", "reject"]]:
decision = interrupt({"question": "Duyệt hoàn tiền?", "order_id": s["order_id"], "amount_vnd": s["amount_vnd"]})
ok = bool(decision.get("approve"))
return Command(update={"approved": ok, "trace": [f"human_approval -> {'duyệt' if ok else 'từ chối'}"]},
goto="do_refund" if ok else "reject")== 3. Hoàn tiền lớn (3.900.000 đ) -> dừng chờ người duyệt
kết quả invoke: {'intent': 'refund', 'order_id': 'DH1003', 'amount_vnd': 3900000, 'trace': ['triage -> refund', 'refund_check amount=3900000 -> human_approval'], '__interrupt__': [Interrupt(value={'question': 'Duyệt hoàn tiền?', 'order_id': 'DH1003', 'amount_vnd': 3900000}, resumable=True, ns=['human_approval:55eeee35-0d5b-5d52-ea66-e261acb83419'])]}
trạng thái tiếp theo: ('human_approval',)
REFUND_LOG trước khi duyệt: [{'order_id': 'DH1002', 'amount_vnd': 480000}]Theo tài liệu về interrupt của LangGraph, interrupt() tạm dừng đồ thị tại điểm gọi, lưu state và trả payload cho bên gọi qua khoá __interrupt__; nó bắt buộc có checkpointer và cần thread_id trong config["configurable"] để biết nạp state nào. Kết quả trên khớp: get_state(cfg).next là ('human_approval',) và REFUND_LOG chưa có dòng của đơn DH1003, nghĩa là chưa đồng nào bị hoàn. Chạy tiếp bằng cách gọi lại với Command(resume=...), giá trị resume trở thành giá trị trả về của interrupt():
-- người duyệt chấp thuận --
['triage -> refund', 'refund_check amount=3900000 -> human_approval', 'human_approval -> duyệt', 'do_refund', 'write_reply']
REFUND_LOG: [{'order_id': 'DH1002', 'amount_vnd': 480000}, {'order_id': 'DH1003', 'amount_vnd': 3900000}]
== 4. Cùng tình huống, người duyệt từ chối
['triage -> refund', 'refund_check amount=3900000 -> human_approval', 'human_approval -> từ chối', 'reject', 'write_reply']
REFUND_LOG: 2 dòng
== 5. Tin nhắn không rõ đơn -> chuyển nhân viên
['triage -> other', 'handoff', 'write_reply']Cùng một đồ thị LangGraph, hai quyết định khác nhau dẫn tới hai nhánh khác nhau, và khi từ chối thì REFUND_LOG giữ nguyên 2 dòng. Đây chính là mẫu "con người trong vòng lặp" với LangGraph: quyền hành động thật nằm ở node do_refund, và đường duy nhất tới node đó với số tiền lớn đi qua human_approval.
Checkpoint bền của LangGraph: dừng ở tiến trình này, chạy tiếp ở tiến trình khác
Trong LangGraph, InMemorySaver mất hết khi tiến trình thoát, nên không dùng được cho việc duyệt kéo dài. Tài liệu persistence liệt kê InMemorySaver (RAM), SqliteSaver (file cục bộ, hợp phát triển) và PostgresSaver (cơ sở dữ liệu máy chủ, hợp triển khai thật). step4_sqlite.py chứng minh tính bền bằng hai tiến trình Python tách biệt cùng dùng file checkpoints.sqlite và cùng thread_id:
conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
app = sg.build(SqliteSaver(conn))
cfg = {"configurable": {"thread_id": "don-DH1003"}}
if sys.argv[1] == "start":
out = app.invoke({"message": "...", "trace": []}, cfg)
else:
out = app.invoke(Command(resume={"approve": sys.argv[2] == "yes"}), cfg)dừng tại: ('human_approval',) | payload: {'question': 'Duyệt hoàn tiền?', 'order_id': 'DH1003', 'amount_vnd': 3900000}
--- tiến trình mới ---
trước khi resume, next = ('human_approval',) | trace đã lưu: ['triage -> refund', 'refund_check amount=3900000 -> human_approval']
trace cuối: ['triage -> refund', 'refund_check amount=3900000 -> human_approval', 'human_approval -> duyệt', 'do_refund', 'write_reply']
REFUND_LOG: [{'order_id': 'DH1003', 'amount_vnd': 3900000}]
số checkpoint đã lưu: 7Tiến trình thứ hai không có biến nào của tiến trình đầu nhưng nạp lại đúng vị trí dừng cùng trace đã lưu, chạy tiếp và ghi hoàn tiền. Bảy checkpoint là bảy trạng thái liên tiếp: đầu vào chưa xử lý (step -1), sau khi ghi đầu vào (step 0, next là triage) và sau lần lượt năm node triage, refund_check, human_approval, do_refund, write_reply (step 1 đến 5). step7_history.py in get_state_history của đồ thị này, cột next lần lượt là __start__, triage, refund_check, human_approval, do_refund, write_reply và rỗng. Mỗi bước sinh một checkpoint, nên nếu chạy lâu dài cần có chính sách xoá checkpoint cũ để file không phình ra.
Cạm bẫy 1: node chạy lại từ đầu khi resume
Đây là lỗi tinh vi nhất của LangGraph khi dùng interrupt(). Tài liệu viết rằng khi resume, node "khởi động lại toàn bộ từ đầu", nên mọi đoạn code đứng trước interrupt() sẽ chạy lại; vì vậy side effect đặt trước interrupt() phải idempotent, hoặc tách ra node khác. step3_pitfalls.py tái hiện bằng một node gửi mail "đang chờ duyệt" rồi mới hỏi duyệt:
== 1. Side effect đặt TRƯỚC interrupt bị chạy hai lần
số mail đã gửi: 2 ["gửi mail 'đang chờ duyệt'", "gửi mail 'đang chờ duyệt'"]
Cách sửa: tách side effect ra node riêng chạy TRƯỚC node chứa interrupt
số mail đã gửi: 1Khách nhận hai mail thay vì một; nếu side effect là chuyển tiền thì nặng hơn nhiều. Cách sửa trong LangGraph là tách notify thành node riêng trước node ask, nên resume chỉ chạy lại ask. Trong đồ thị chăm sóc khách hàng, human_approval gọi interrupt() ngay đầu, còn ghi REFUND_LOG nằm ở node do_refund riêng. Tài liệu còn dặn không bọc interrupt() trong try/except trần vì nó dùng ngoại lệ để tạm dừng.
Cạm bẫy 2: vòng lặp không có điều kiện dừng
Đồ thị LangGraph cho phép chu trình, nên lỗi "thử lại mãi" là có thật. LangGraph chặn bằng recursion_limit, số super-step tối đa của một lần chạy; vượt ngưỡng thì ném GraphRecursionError. Trong LangGraph 0.5.3 đang dùng, mã nguồn thư viện đặt mặc định là 25 (đọc từ biến môi trường LANGGRAPH_DEFAULT_RECURSION_LIMIT, mặc định "25"). Vì mặc định có thể thay đổi giữa các phiên bản, hãy tự đặt recursion_limit tường minh thay vì dựa vào mặc định. Thí nghiệm dùng giới hạn 8 cho một vòng luôn thử lại:
== 2. Vòng lặp 'thử lại' không có điều kiện dừng
GraphRecursionError: Recursion limit of 8 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
Cách sửa: đếm số lần ở state và thoát có chủ đích
{'tries': 3}Ở bản đầu, bộ định tuyến luôn trả về attempt nên đồ thị chạy tới khi chạm giới hạn 8. Bản sửa đếm tries trong state (có reducer operator.add) và trả END khi đủ 3 lần. Đây cùng nguyên tắc với bài tool calling, áp dụng cho LangGraph: mọi vòng lặp của agent cần điều kiện thoát nằm trong code, có số lần tối đa, không nhờ mô hình tự dừng.
Cạm bẫy 3: hai node song song cùng ghi một trường
Từ bài học ở phần mô hình state, step6_conflict.py cho hai node b và c chạy song song và cùng ghi trường text không có reducer:
InvalidUpdateError: At key 'text': Can receive only one value per step. Use an Annotated key to handle multiple values.LangGraph từ chối thay vì chọn bừa, và thông báo chỉ thẳng cách sửa: khai báo kênh bằng Annotated kèm reducer, hoặc cho từng node ghi một trường riêng. Lỗi này lộ ngay lần chạy đầu, khác lỗi chạy lại side effect chỉ lộ khi resume.
Đo node phân loại trong đồ thị LangGraph: nhánh nào dễ sai
Một đồ thị LangGraph chỉ chắc bằng node yếu nhất của nó. Với mô hình 1,5B, node triage là điểm rủi ro, nên step5_triage_eval.py đo trên 12 tin nhắn tiếng Việt có nhãn (3 hỏi trạng thái, 3 hoàn tiền, 4 không liên quan, 2 tin thiếu mã đơn):
other DH1003 | nhãn: order_status DH1003 | DH1003 sao chưa thấy cập nhật gì vậy shop <-- lệch
other DH#### | nhãn: order_status None | Đơn của tôi đâu rồi??? <-- lệch
refund DH#### | nhãn: refund None | Tôi bị trừ tiền hai lần, hoàn lại cho tôi <-- lệch
intent đúng: 10/12 | order_id đúng: 10/12(Chỉ trích ba dòng lệch; step5_output.txt có đủ 12 dòng.) Hai kiểu lỗi đáng chú ý. Thứ nhất, câu "DH1003 sao chưa thấy cập nhật gì vậy shop" bị xếp vào other, nên khách hỏi đơn bị chuyển nhân viên: lỗi an toàn (chậm chứ không sai tiền). Thứ hai, với hai tin không có mã đơn, mô hình bịa mã DH#### (chép nguyên mẫu từ prompt). Nhờ route kiểm tra order_id in ORDERS, mã bịa này bị chặn và chuyển nhân viên. Bài học khi dùng LangGraph: validate đầu ra của mô hình ở ranh giới giữa các node, và 10/12 là mẫu nhỏ chỉ để chỉ ra loại lỗi, không phải độ chính xác tổng quát; muốn tin được cần bộ kiểm thử lớn hơn như cách đã làm ở đánh giá RAG.
Khi nào không nên dùng LangGraph
- Luồng tuyến tính ngắn, chưa cần LangGraph: ba bước gọi nối tiếp thì ba dòng Python rõ hơn một đồ thị. Lời khuyên của Anthropic về việc tránh thêm lớp trừu tượng khi chưa cần vẫn đúng.
- Không cần tạm dừng, không cần lưu: một agent chạy vài giây trong một request, dùng vòng lặp ở bài tool calling là đủ.
- Phụ thuộc phiên bản: API của LangGraph thay đổi nhanh. Ghim phiên bản LangGraph trong
requirements.txtvà đọc ghi chú phát hành khi nâng cấp.
Lỗi thường gặp khi viết LangGraph
- Quên truyền checkpointer khi
compileđồ thị LangGraph hoặc quênthread_id:interrupt()không dùng được. - Dùng
InMemorySaverở production rồi mất trạng thái khi pod khởi động lại. - Đặt side effect (gửi mail, ghi DB, gọi thanh toán) trước
interrupt()trong cùng node. - Vừa có cạnh tĩnh vừa có định tuyến động từ một node, khiến cả hai đường cùng chạy.
- Không đặt
recursion_limittường minh và không có biến đếm trong state cho vòng thử lại. - Tin
order_idhayintentdo mô hình sinh ra mà không kiểm tra với dữ liệu thật.
Checklist cho agent LangGraph trong sản phẩm
- Khai báo
Statecủa LangGraph tường minh; trường nào nhiều node cùng ghi thì có reducer. - Mô hình chỉ phân loại và viết chữ; hành động có hậu quả nằm trong node code thường, có kiểm tra điều kiện.
- Với LangGraph, hành động tiền bạc vượt hạn mức đi qua
interrupt(); node chứainterrupt()không có side effect không idempotent. - Checkpointer bền (SQLite khi phát triển, PostgreSQL khi chạy thật), có chính sách xoá checkpoint cũ.
- Mọi vòng lặp LangGraph có biến đếm và
recursion_limittường minh. - Ghi
tracemỗi node để kiểm toán; đo node phân loại bằng bộ câu hỏi có nhãn trước khi lên sản phẩm. - Ghim phiên bản LangGraph và chạy lại bộ kiểm thử khi nâng cấp.
Mã nguồn nằm trong code/s7-42: support_graph.py, llm.py, các script step1 tới step7 và các file *_output.txt là output đã dán ở trên. Thư trả lời do mô hình 1,5B sinh ra có thể khác nhau giữa các lần chạy (mã ns của interrupt cũng là giá trị ngẫu nhiên); phần ổn định cần kiểm tra là nhánh đã đi, trace và REFUND_LOG.
Bài tiếp theo
Model Context Protocol: viết MCP server bằng Python cho agent. Đồ thị ở đây gọi các hàm Python viết sẵn trong cùng tiến trình; khi công cụ nằm ở dịch vụ khác và nhiều agent cần dùng chung, bài sau giới thiệu giao thức MCP để công bố công cụ theo một chuẩn chung.



