Khi xây dựng các hệ tác nhân AI phức tạp trong thực tế, các framework cũ (như LangChain Chains) bộc lộ hạn chế lớn: chúng chỉ hỗ trợ các luồng xử lý tuyến tính một chiều đơn giản. Nếu Agent cần thực hiện các vòng lặp phi tuyến, tự kiểm tra mã nguồn, rẽ nhánh điều kiện dựa theo kết quả chạy thử, hoặc tạm ngắt để chờ sự phê duyệt của con người, chúng ta cần một kiến trúc đồ thị trạng thái có hướng.

Giải pháp toàn diện là LangGraph — thư viện chuyên dụng để xây dựng các AI Agent dưới dạng đồ thị có trạng thái (Stateful Multi-Agent Graphs). Bài học này sẽ giúp bạn hiểu sâu sắc nguyên lý hoạt động của cấu trúc Node - Edge, cơ chế quản lý trạng thái chung (State Management), kỹ thuật ngắt tương tác con người (Human-in-the-loop), và tự tay lập trình chu trình Agent tự viết code - tự kiểm thử - tự sửa lỗi tuần hoàn.

✅ Bạn cần gì trước khi bắt đầu
Phần mềm: Ollama kèm một mô hình chat (ollama pull qwen2.5:7b). Không cần cài LangGraph — xem callout ngay dưới đây.

Kiến thức cần có: Bài 17 cho vòng lặp Agent và ba lưới an toàn của nó. Bài này thay vòng for tuyến tính đó bằng một đồ thị có nhánh và có chu trình.
⚡ Dự án cuối bài KHÔNG dùng thư viện LangGraph
Nói thẳng ngay từ đầu để bạn không đi tìm dòng import langgraph trong code: dự án tự cài đặt lại ba ý tưởng của LangGraph bằng thư viện chuẩn Python — State kèm reducer, cạnh điều kiện tạo chu trình, và điểm ngắt trả về checkpoint. Lý do giống mọi bài khác trong series: một thư viện che mất đúng thứ bạn cần nhìn thấy, và cả ba ý tưởng này gói gọn trong chưa tới 100 dòng.

Khi làm sản phẩm thật thì hãy dùng LangGraph — nó lo giúp bạn lưu checkpoint xuống cơ sở dữ liệu, chạy song song nhiều nhánh, và streaming. Nhưng sau bài này bạn sẽ biết chính xác nó đang làm gì bên dưới.

18.1 Sự ra đời của Graph-based Agent

Một luồng công việc AI thực tế thường không đi theo đường thẳng. Ví dụ, trong quy trình viết code: Mô hình viết code ➔ Biên dịch thử ➔ Nếu lỗi, quay lại sửa code kèm thông báo lỗi ➔ Nếu thành công, chuyển tiếp đi lưu trữ. Đây là một đồ thị có chứa vòng lặp (Cyclic Graph).

LangGraph mô hình hóa Agent dưới dạng một Đồ thị (Graph):

  • Nodes (Nút): Đại diện cho các bước xử lý, là các hàm Python nhận vào trạng thái hiện tại, thực hiện tác vụ (gọi LLM, chạy code) và trả về thông tin cập nhật.
  • Edges (Cạnh): Xác định hướng đi tiếp theo giữa các nút. Có hai loại cạnh: Cạnh cố định (chuyển tiếp thẳng từ nút A sang B) và Cạnh điều kiện (Conditional Edges - rẽ nhánh dựa theo kết quả logic của nút vừa chạy).
⚠️ Cạm bẫy: Spaghetti Graph (Đồ thị mạng nhện)
Khi thiết kế đồ thị cho Agent, rất dễ rơi vào cạm bẫy vẽ quá nhiều nút liên kết chéo, tạo thành một ma trận spaghetti phức tạp cực kỳ khó gỡ lỗi và kiểm soát trạng thái. Quy tắc thiết kế sạch: Chia nhỏ đồ thị khổng lồ thành các đồ thị con (Sub-graphs) độc lập, mỗi sub-graph chịu trách nhiệm hoàn thành một tác vụ chuyên biệt duy nhất.

18.2 Quản lý Trạng thái chung (State Management)

Trái tim của LangGraph là đối tượng State (Trạng thái). State là một cấu trúc dữ liệu chung (Pydantic Model hoặc TypedDict) được truyền đi xuyên suốt qua toàn bộ các nút của đồ thị.

Đặc tính của quản lý trạng thái trong LangGraph:

  • Đồng bộ trạng thái: Mỗi nút khi chạy xong sẽ trả về các trường dữ liệu cập nhật. Hệ thống tự động ghi đè hoặc cộng dồn (Reducer) các trường này vào State chung.
  • Reducer Function: Cho phép định nghĩa cách cập nhật dữ liệu đặc thù. Ví dụ, đối với lịch sử trò chuyện chat, ta dùng reducer operator.add để tự động nối đuôi (append) các tin nhắn mới vào danh sách cũ thay vì ghi đè xóa sạch lịch sử.
🔢 Ví dụ tính tay Reducer operator.add (đã kiểm chứng)
Giả sử trường lịch sử chat trong State hiện có ["Xin chào"]. Một nút vừa chạy xong và trả về tin nhắn mới ["Tôi cần giúp đỡ"]. Chạy thử bằng Python (đã kiểm chứng):
  • Với Reducer operator.add: kết quả là ["Xin chào", "Tôi cần giúp đỡ"] — lịch sử cũ được giữ nguyên, tin nhắn mới được nối đuôi vào.
  • Nếu KHÔNG dùng Reducer (ghi đè mặc định): kết quả chỉ còn ["Tôi cần giúp đỡ"] — toàn bộ lịch sử trò chuyện trước đó bị xóa sạch ngay lập tức.
Đây chính là lý do khai báo đúng Reducer cho từng trường trong State là bắt buộc: mặc định LangGraph ghi đè (overwrite), nên bất kỳ trường nào cần "cộng dồn" theo thời gian (lịch sử chat, log lỗi tích lũy) đều phải khai báo Reducer tường minh, nếu không dữ liệu quan trọng sẽ âm thầm biến mất sau mỗi lượt chạy nút.

18.3 Cơ chế Con người phê duyệt (Human-in-the-loop)

Trong môi trường sản xuất của doanh nghiệp, việc để AI tự trị hoàn toàn 100% đưa ra các hành động quan trọng (như gửi email trực tiếp cho khách hàng, thanh toán hóa đơn tài chính, hoặc lưu trực tiếp file code lên server chạy thật) là cực kỳ nguy hiểm.

Cơ chế Human-in-the-loop (Con người phê duyệt) cho phép ta đặt một điểm ngắt (Interrupt) ngay trước khi đồ thị chuyển tới các nút nhạy cảm. Đồ thị sẽ tự động đóng băng trạng thái hiện tại vào đĩa cứng (State Persistence). Hệ thống tạm dừng, gửi thông báo cho người quản trị. Sau khi con người nhấn "Phê duyệt" hoặc chỉnh sửa trực tiếp nội dung trong State, đồ thị mới được kích hoạt chạy tiếp tục từ điểm ngắt.

⚡ Ranh giới an toàn: Chỉ phê duyệt sau khi kiểm định
Cơ chế Human-in-the-loop là chốt chặn bảo mật cuối cùng chống lại sự mất kiểm soát của LLM (Hallucination nguy hại). Một mô hình Agent viết code tốt bắt buộc phải có bước chạy thử trong môi trường hộp cát cô lập (Sandbox) để tự phát hiện lỗi cú pháp trước khi gửi đơn yêu cầu con người phê duyệt lưu file.

18.4 Dự án thực hành bài 18: Agent tự viết — tự chạy thử — tự sửa

Dự án dựng một Graph Engine có trạng thái bằng Python thuần, chạy đúng luồng sau:

Start ➔ Node Coder (viết hàm Python) ➔ Node Tester (chạy thử trong tiến trình riêng) ➔ Cạnh điều kiện: lỗi thì quay lại Coder kèm log lỗi (tối đa 3 lần), đạt thì chuyển sang điểm ngắt phê duyệt.

langgraph_agent.py
"""Lesson 18 project: a stateful agent graph, built on LangGraph's ideas.

Run:  python3 langgraph_agent.py
Needs: Ollama with a chat model (`ollama pull qwen2.5:7b`).

This file does NOT import langgraph. Like every project in this series it uses
the standard library only, and reimplements the three ideas the lesson is about
so you can see them working rather than trust a library:

  * a State object whose fields are merged through per-field reducers,
  * conditional edges that make the graph cyclic (coder -> tester -> coder),
  * a real interrupt: the graph stops and hands back a checkpoint, instead of
    blocking on input() inside a node.

The generated code is executed in a separate process with a timeout, never with
exec() in this one - see the note in run_generated_code.
"""

import json
import operator
import subprocess
import sys
import tempfile
import textwrap
import re
import urllib.error
import urllib.request

OLLAMA = "http://localhost:11434"
CHAT_PREFERRED = ["qwen2.5:14b-instruct", "qwen2.5:7b", "qwen2.5-coder:7b",
                  "llama3.1", "llama3.2"]
MAX_RETRIES = 3


# ---------------------------------------------------------------------------
# 1. State, and the reducers that merge updates into it
# ---------------------------------------------------------------------------

# The whole point of a reducer: it says HOW a field is merged, not just what it
# holds. Fields absent from this map are overwritten, which is the default in
# LangGraph too - and the reason an unreduced history field silently vanishes.
REDUCERS = {
    "history": operator.add,
    "test_log": operator.add,
}


def merge(state, update):
    """Apply one node's return value to the state, field by field."""
    merged = dict(state)
    for key, value in update.items():
        reducer = REDUCERS.get(key)
        merged[key] = reducer(merged.get(key, type(value)()), value) if reducer else value
    return merged


def initial_state(task, expected=None):
    return {"task": task, "code": "", "history": [], "test_log": [],
            "attempts": 0, "approved": None, "expected": expected}


def demo_reducers():
    """Show the difference the reducer map makes, on one field."""
    print("=== What a reducer changes ===")
    state = {"history": ["Xin chào"], "code": "v1"}
    update = {"history": ["Tôi cần giúp đỡ"], "code": "v2"}
    print(f"  state  : {state}")
    print(f"  update : {update}")
    print(f"  merged : {merge(state, update)}")
    print("  'history' has a reducer, so it appends. 'code' has none, so it is")
    print("  overwritten. Forget the reducer and the chat history disappears")
    print("  one node at a time, with no error anywhere.\n")


# ---------------------------------------------------------------------------
# 2. Talking to Ollama - the pattern from Lesson 13
# ---------------------------------------------------------------------------


def installed_models():
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
            return [m["name"] for m in json.loads(response.read())["models"]]
    except urllib.error.URLError:
        return []


def pick(names, preferred):
    for wanted in preferred:
        for name in names:
            if name == wanted or name.startswith(wanted):
                return name
    return None


def chat(prompt, model):
    payload = {"model": model, "stream": False, "options": {"temperature": 0.0},
               "messages": [{"role": "user", "content": prompt}]}
    request = urllib.request.Request(
        f"{OLLAMA}/api/chat", data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request) as response:
            return json.loads(response.read())["message"]["content"]
    except urllib.error.HTTPError as exc:
        detail = json.loads(exc.read() or b"{}").get("error", "no detail")
        raise RuntimeError(f"Ollama answered HTTP {exc.code}: {detail}") from None
    except urllib.error.URLError as exc:
        raise RuntimeError(f"cannot reach Ollama at {OLLAMA} - {exc.reason}") from None


# ---------------------------------------------------------------------------
# 3. Running model-written code without trusting it
# ---------------------------------------------------------------------------


def run_generated_code(code, timeout=5):
    """Execute the model's code in a SEPARATE process, with a time limit.

    Lesson 17 established that you never eval() model output. Running a whole
    generated program is the same problem, larger: exec(code, {}) in this
    process shares the interpreter, the filesystem and the network with the
    agent. A subprocess can at least be killed on timeout and cannot corrupt
    the parent's state. It is still not a security boundary - for untrusted
    input you need a container - but it is the minimum that is honest.
    """
    harness = textwrap.dedent("""
        import json, sys
        {code}
        try:
            print("RESULT:" + json.dumps(solve_problem()))
        except NameError:
            print("FAIL:no function named solve_problem")
        except Exception as exc:
            print("FAIL:" + type(exc).__name__ + ": " + str(exc))
    """).format(code=code)
    with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False,
                                     encoding="utf-8") as handle:
        handle.write(harness)
        path = handle.name
    try:
        finished = subprocess.run([sys.executable, path], capture_output=True,
                                  text=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        return False, f"timed out after {timeout}s"
    output = (finished.stdout or finished.stderr).strip().splitlines()
    last = output[-1] if output else "no output"
    if last.startswith("RESULT:"):
        return True, last[len("RESULT:"):]
    return False, last.removeprefix("FAIL:")


# ---------------------------------------------------------------------------
# 4. The nodes
# ---------------------------------------------------------------------------


def coder_node(state, model):
    print("  [coder] writing solve_problem()")
    prompt = (
        "Bạn là lập trình viên Python. Viết duy nhất một hàm tên solve_problem() "
        "giải quyết yêu cầu sau, và hàm phải RETURN kết quả chứ không print.\n"
        f"Yêu cầu: {state['task']}\n"
        "Chỉ trả về code Python trong khối ```python ... ```, không giải thích."
    )
    if state["test_log"]:
        prompt += f"\nLần chạy trước lỗi: {state['test_log'][-1]}\nHãy sửa lại."
    content = chat(prompt, model)
    match = re.search(r"```(?:python)?(.*?)```", content, re.DOTALL)
    code = (match.group(1) if match else content).strip()
    return {"code": code, "attempts": state["attempts"] + 1,
            "history": [f"coder wrote {len(code)} characters"]}


def tester_node(state):
    """Run the code AND check the answer.

    Checking only that the code ran is the weak test Lesson 10 warned about:
    a function that returns the wrong number still "passes". The expected
    value lives in the state, so the tester compares against it.
    """
    print("  [tester] running it in a subprocess")
    ok, detail = run_generated_code(state["code"])
    if ok and state.get("expected") is not None:
        if detail.strip() != json.dumps(state["expected"]):
            ok, detail = False, f"returned {detail}, expected {state['expected']}"
    print(f"  [tester] {'passed' if ok else 'failed'}: {detail}")
    return {"test_log": [f"{'SUCCESS' if ok else 'FAIL'}: {detail}"],
            "history": [f"tester reported {'success' if ok else 'failure'}"]}


def approval_node(state, decision):
    """The human turn. It takes the decision as an argument, never input()."""
    return {"approved": decision == "y",
            "history": [f"human answered {decision!r}"]}


# ---------------------------------------------------------------------------
# 5. The graph engine: conditional edges, cycles, and one real interrupt
# ---------------------------------------------------------------------------


def route_after_tester(state):
    """A conditional edge. Three outcomes, so the graph is cyclic."""
    if state["test_log"][-1].startswith("SUCCESS"):
        return "approval"
    if state["attempts"] >= MAX_RETRIES:
        return "give_up"
    return "coder"


def demo_retry_cycle():
    """Feed deliberately broken code through tester + router, no model needed.

    This exercises the conditional edge itself: does a failing test really send
    the graph back to the coder, and does the attempt limit really stop it?
    """
    print("=== The conditional edge, driven with code that cannot pass ===")
    state = initial_state("demo", expected=2)
    state = merge(state, {"code": "def solve_problem():\n    return 999"})
    for _ in range(MAX_RETRIES + 1):
        state = merge(state, {"attempts": state["attempts"] + 1})
        state = merge(state, tester_node(state))
        destination = route_after_tester(state)
        print(f"  attempt {state['attempts']} -> route to {destination!r}")
        if destination != "coder":
            break
    print(f"  The edge returned to 'coder' {MAX_RETRIES - 1} times, then gave up")
    print(f"  at the limit of {MAX_RETRIES}. That is the cycle, and its brake.\n")


def run_until_interrupt(state, model):
    """Walk the graph and STOP before the approval node, returning a checkpoint.

    This is what human-in-the-loop means in practice: the process does not sit
    blocked on input(). It saves where it is and returns. A web backend can put
    that checkpoint in a database and resume it days later from a different
    machine - impossible if the node called input().
    """
    node = "coder"
    while True:
        if node == "coder":
            state = merge(state, coder_node(state, model))
            node = "tester"
        elif node == "tester":
            state = merge(state, tester_node(state))
            node = route_after_tester(state)
        elif node == "give_up":
            return state, "give_up"
        elif node == "approval":
            return state, "approval"


def resume(state, decision):
    """Continue from the checkpoint once a human has decided."""
    state = merge(state, approval_node(state, decision))
    return state


def main():
    demo_reducers()

    names = installed_models()
    if not names:
        print("Ollama is not reachable. Start it, then run this again.")
        return
    model = pick(names, CHAT_PREFERRED)
    if not model:
        print("No suitable chat model found. Try: ollama pull qwen2.5:7b")
        return
    print(f"model: {model}\n")

    demo_retry_cycle()

    task = "Trả về phần dư của phép chia 17 cho 5."
    print(f"=== Running the graph: {task} ===")
    state, stopped_at = run_until_interrupt(initial_state(task, expected=2), model)
    print(f"\n  graph paused at: {stopped_at}   after {state['attempts']} attempt(s)")
    print(f"  proposed code:\n{textwrap.indent(state['code'], '    ')}")

    print("\n=== The checkpoint that gets handed to the human ===")
    checkpoint = json.dumps({k: v for k, v in state.items() if k != "code"},
                            ensure_ascii=False)
    print(f"  {checkpoint}")
    print("  Serialisable, so it can go in a database and be resumed later.\n")

    for decision in ("n", "y"):
        final = resume(state, decision)
        verdict = "approved" if final["approved"] else "rejected"
        print(f"=== Resuming the same checkpoint with decision {decision!r} ===")
        print(f"  result: {verdict}")
        print(f"  history: {final['history'][-1]}")
    print("\n  Both runs start from the same checkpoint, so the human's answer")
    print("  is the only thing that differs. The graph itself never re-ran.")


if __name__ == "__main__":
    main()

Reducer: thứ quyết định lịch sử có còn hay không

Mục 18.2 nói rằng thiếu reducer thì dữ liệu âm thầm biến mất. Chương trình chứng minh điều đó trên một lần merge duy nhất, với hai trường: history có reducer, code thì không:

Terminal
=== What a reducer changes ===
  state  : {'history': ['Xin chào'], 'code': 'v1'}
  update : {'history': ['Tôi cần giúp đỡ'], 'code': 'v2'}
  merged : {'history': ['Xin chào', 'Tôi cần giúp đỡ'], 'code': 'v2'}
  'history' has a reducer, so it appends. 'code' has none, so it is
  overwritten. Forget the reducer and the chat history disappears
  one node at a time, with no error anywhere.

Đây là loại lỗi tệ nhất trong hệ thống có trạng thái: không có ngoại lệ nào được ném ra, không có cảnh báo nào. Đồ thị vẫn chạy, chỉ là mỗi lượt qua một nút lại xóa sạch lịch sử trước đó — và bạn chỉ phát hiện khi Agent bắt đầu quên mất nó đã thử gì.

Cạnh điều kiện: đồ thị có thật sự vòng lại không?

Câu hỏi này phải trả lời bằng cách ép nó xảy ra, chứ không chờ mô hình tình cờ viết sai. Chương trình nhét thẳng một đoạn code chắc chắn sai kết quả vào State rồi cho chạy qua Tester và bộ định tuyến:

Terminal
=== The conditional edge, driven with code that cannot pass ===
  [tester] running it in a subprocess
  [tester] failed: returned 999, expected 2
  attempt 1 -> route to 'coder'
  [tester] running it in a subprocess
  [tester] failed: returned 999, expected 2
  attempt 2 -> route to 'coder'
  [tester] running it in a subprocess
  [tester] failed: returned 999, expected 2
  attempt 3 -> route to 'give_up'
  The edge returned to 'coder' 2 times, then gave up
  at the limit of 3. That is the cycle, and its brake.

Hai lần đầu cạnh điều kiện trả về 'coder' — đồ thị quay lại sửa. Đến lần thứ ba, chạm giới hạn MAX_RETRIES, nó trả về 'give_up'. Đó là chu trình, và đó là cái phanh của chu trình. Không có cái phanh này thì Agent sẽ sửa đi sửa lại vô hạn — đúng cạm bẫy Bài 17.

💡 Tester chỉ kiểm "code có chạy không" là một phép kiểm rỗng
Bản đầu tiên của dự án này chỉ kiểm tra hàm solve_problem có tồn tại và chạy không ném ngoại lệ. Một hàm return 999 vẫn vượt qua trót lọt. Đây đúng loại phép kiểm mà Bài 10 đã chỉ ra là gần như vô nghĩa. Bản mới lưu giá trị mong đợi trong State và Tester đối chiếu kết quả thật — nên dòng failed: returned 999, expected 2 ở trên mới có nghĩa.

Chạy thật với mô hình

Terminal
=== Running the graph: Trả về phần dư của phép chia 17 cho 5. ===
  [coder] writing solve_problem()
  [tester] running it in a subprocess
  [tester] passed: 2

  graph paused at: approval   after 1 attempt(s)
  proposed code:
    def solve_problem():
        return 17 % 5

=== The checkpoint that gets handed to the human ===
  {"task": "Trả về phần dư của phép chia 17 cho 5.", "history": ["coder wrote 38 characters", "tester reported success"], "test_log": ["SUCCESS: 2"], "attempts": 1, "approved": null, "expected": 2}
  Serialisable, so it can go in a database and be resumed later.

Điểm ngắt: vì sao không được dùng input()

Bản đầu tiên gọi input() ngay bên trong nút phê duyệt. Nghe thì tiện, nhưng nó khóa cứng Agent vào một tiến trình đang chạy và một bàn phím đang có người ngồi. Không chạy được trong tác vụ nền, không chạy được trong CI, và nếu người quản trị đi ăn trưa thì tiến trình treo suốt buổi.

Human-in-the-loop thật làm khác: đồ thị dừng lại và trả về một checkpoint. Đó là một cấu trúc dữ liệu tuần tự hóa được — cất vào cơ sở dữ liệu, gửi lên giao diện web, và ba ngày sau phục hồi từ một máy khác. Chương trình chứng minh bằng cách phục hồi cùng một checkpoint hai lần với hai quyết định khác nhau:

Terminal
=== Resuming the same checkpoint with decision 'n' ===
  result: rejected
  history: human answered 'n'
=== Resuming the same checkpoint with decision 'y' ===
  result: approved
  history: human answered 'y'

  Both runs start from the same checkpoint, so the human's answer
  is the only thing that differs. The graph itself never re-ran.

Đồ thị không hề chạy lại: không gọi mô hình lần nữa, không chạy lại code. Chỉ có quyết định của con người thay đổi. Đây chính là tính chất khiến checkpoint hữu dụng trong sản xuất — và là thứ input() không bao giờ cho bạn.

⚠️ Cạm bẫy nặng nhất: chạy code do mô hình viết ra
Bài 17 vừa dạy: đừng bao giờ eval() đầu ra của mô hình. Rồi bài này lại yêu cầu chạy nguyên một chương trình do mô hình viết — cùng một vấn đề, ở quy mô lớn hơn nhiều. Bản đầu tiên dùng exec(state.code, local_env): code của mô hình chạy chung tiến trình với Agent, chung quyền truy cập tệp và mạng, và không có cách nào dừng nếu nó rơi vào vòng lặp vô hạn.

Bản mới ghi code ra tệp tạm và chạy bằng subprocesstimeout. Nói cho chính xác: đây vẫn không phải một ranh giới bảo mật — tiến trình con vẫn đọc được tệp của bạn và vẫn ra được Internet. Nó chỉ đảm bảo hai điều: giết được khi treo, và không làm hỏng trạng thái của tiến trình cha. Với code sinh từ đầu vào không tin cậy, mức tối thiểu đúng đắn là một container riêng (Docker, gVisor) hoặc một dịch vụ sandbox chuyên dụng.

Cách chạy dự án này trên máy bạn

  1. Bật Ollama, tải một mô hình chat, rồi chạy python3 langgraph_agent.py. Hai phần đầu (reducer và cạnh điều kiện) chạy được cả khi không có Ollama.
  2. Bài toán mẫu rất dễ nên mô hình thường viết đúng ngay lần đầu — vì thế phần chứng minh chu trình mới phải ép bằng code sai. Muốn thấy Agent tự sửa thật, hãy đổi task sang một yêu cầu khó hơn kèm expected tương ứng.
  3. Rồi thử phá nó theo ba cách:
    • Xóa "history": operator.add khỏi REDUCERS. Chạy lại và xem trường history trong checkpoint: chỉ còn đúng một dòng cuối, toàn bộ dấu vết trước đó bốc hơi. Không có lỗi nào được báo.
    • Đổi MAX_RETRIES thành 1. Cạnh điều kiện bỏ hẳn nhánh quay lại — đồ thị hết chu trình, trở thành tuyến tính như một vòng for thường.
    • Trong demo_retry_cycle, đổi code sai thành "def solve_problem():\n while True: pass". Timeout của subprocess sẽ kích hoạt sau 5 giây và trả về timed out thay vì treo cả chương trình — đây là lý do phải chạy tiến trình riêng thay vì exec.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: Tự cài đặt State kèm reducer theo từng trường, và thấy tận mắt trường thiếu reducer bị ghi đè mất sạch mà không báo lỗi.
  • Đạt được: Dựng cạnh điều kiện tạo chu trình coder → tester → coder, và chứng minh cả chu trình lẫn cái phanh giới hạn số lần sửa bằng cách ép code sai chạy qua.
  • Đạt được: Cài đặt điểm ngắt Human-in-the-loop đúng nghĩa — trả về checkpoint tuần tự hóa được, phục hồi lại được hai lần với hai quyết định, thay vì khóa tiến trình bằng input().
  • Đạt được: Chạy code do mô hình sinh trong tiến trình riêng có timeout, và biết rõ giới hạn của biện pháp đó.

Cầu nối bài tiếp theo: Đến đây mọi thứ đều xoay quanh việc điều khiển một mô hình có sẵn bằng prompt, công cụ và đồ thị. Nhưng có những thứ prompt không dạy được: văn phong riêng của doanh nghiệp, định dạng đầu ra rất đặc thù, hay một miền kiến thức hẹp. Bài 19 chuyển sang can thiệp vào chính trọng số mô hình bằng LoRA.

Tải file code thực hành minh họa bài học

File Python langgraph_agent.py — mã nguồn xây dựng Graph Engine tự động hóa chu trình Coder viết code, Tester chạy kiểm định và Approver duyệt lưu file (chạy python langgraph_agent.py):

Tải về langgraph_agent.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 17: AI Agents & Vòng lặp ReAct Bài 19: Tinh chỉnh mô hình (Fine-tuning LLM) Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận