ReAct Agent ทำงานอย่างไร: เจาะลึก LangGraph และ LangChain เบื้องหลัง

ภาพประกอบ: ReAct Agent ทำงานอย่างไร: เจาะลึก LangGraph และ LangChain เบื้องหลัง

บทความก่อนหน้า อธิบายวงจร ReAct แบบกว้าง ๆ ว่า agent คิด เรียก tool ดูผล แล้วคิดต่อ บทความนี้พาไปดูโค้ดเบื้องหลังวงจรนั้น โดยแยกหน้าที่ของสองไลบรารีที่ใช้จริงใน AGENT LITE และ LOCAL AGENT TH: LangChain และ LangGraph

LangChain กับ LangGraph ทำหน้าที่คนละชั้น

สองชื่อนี้มักถูกพูดถึงคู่กันจนสับสนว่าเป็นตัวเดียวกัน แต่จริงๆ แบ่งหน้าที่ชัดเจน:

Library ทำหน้าที่อะไร เปรียบเทียบง่ายๆ
LangChain ห่อหุ้มโมเดล + tool ให้เรียกใช้แบบเดียวกันได้ ไม่ว่าจะเป็นโมเดลจากค่ายไหน, แปลง tool เป็น schema ที่โมเดลอ่านได้ (bind_tools), แปลงคำตอบโมเดลกลับเป็น object ที่โค้ดอ่านง่าย “ล่าม” ระหว่างโค้ดกับโมเดล
LangGraph คุมว่า “ต่อไปจะทำอะไร” — เดินลูปกี่รอบ, เมื่อไรควรเรียก tool ต่อ, เมื่อไรควรจบ, เก็บ state (ประวัติข้อความ) ระหว่างรอบ “ตัวคุมเวที” ที่สั่งว่าใครพูดต่อ

พูดสั้นๆ: LangChain ทำให้พูดคุยกับโมเดล/tool ได้ ส่วน LangGraph ทำให้พูดคุยนั้น “วนเป็นลูปอย่างมีระเบียบ” จนกว่าจะจบงาน — ReAct agent ที่ใช้งานจริงในเว็บนี้ (AGENT LITE, LOCAL AGENT TH) ใช้ทั้งคู่ร่วมกัน ไม่ได้เขียนลูป while เองทั้งหมด

ภาพรวมวงจร ReAct แบบ LangGraph

┌─────────────────────────────────────────────┐
│                                               │
│   ผู้ใช้ถาม ──▶ [ AGENT NODE ]                │
│                     │                        │
│         โมเดลอ่าน history + tool schema      │
│         แล้วตัดสินใจ 1 ใน 2 อย่าง             │
│                     │                        │
│         ┌───────────┴───────────┐            │
│         ▼                       ▼            │
│   มี tool_calls?            ไม่มี tool_calls  │
│         │                       │            │
│         ▼                       ▼            │
│   [ TOOL NODE ]              [ จบ ]          │
│    รัน tool จริง             ตอบผู้ใช้         │
│         │                                    │
│         └──────── กลับเข้า AGENT NODE ───────▶│
│              (วนซ้ำได้หลายรอบ)                │
└─────────────────────────────────────────────┘

นี่คือ conditional edge ที่เป็นหัวใจของ LangGraph — เส้นทางจาก agent node ไม่ได้ไปทางเดียวตายตัว แต่มีโค้ดเช็คเงื่อนไข (ปกติชื่อฟังก์ชันว่า should_continue) คอยดูผลลัพธ์ล่าสุดจากโมเดลทุกรอบ แล้วเลือกเส้นทางให้เอง

State: สิ่งเดียวที่ไหลผ่านทุก node

LangGraph ทำงานบนแนวคิด StateGraph — มี object ก้อนเดียว (AgentState) ที่ไหลผ่านทุก node ในกราฟ แต่ละ node แก้ไข state แล้วส่งต่อ ไม่ได้ pass พารามิเตอร์แยกกันไปมา

สำหรับ ReAct agent ส่วนสำคัญที่สุดใน state มีแค่ตัวเดียว:

AgentState = {
  messages: [HumanMessage, AIMessage, ToolMessage, AIMessage, ...]
}

messages คือ list ที่ยาวขึ้นเรื่อยๆ ทุกครั้งที่มีข้อความใหม่เกิดขึ้น (คำถามผู้ใช้, คำตอบ/การเรียก tool ของโมเดล, ผลลัพธ์จาก tool) — agent node ทุกรอบเห็นประวัติทั้งหมดตั้งแต่ต้นจนถึงปัจจุบัน ไม่ใช่แค่ข้อความล่าสุด นี่คือเหตุผลที่โมเดล “จำ” ผลลัพธ์ tool ก่อนหน้าได้ตลอดทั้งคำถามเดียว

Reducer: ทำไม state ถึง “ต่อเพิ่ม” ไม่ใช่ “เขียนทับ”

จุดที่มักเข้าใจผิดกันบ่อย: แต่ละ node ใน LangGraph ไม่ได้ return state ก้อนใหม่ทั้งหมด — มัน return แค่ ส่วนที่อยากอัปเดต เท่านั้น (เช่น {"messages": [AIMessage(...)]} แค่ข้อความใหม่ 1 ข้อความ) แล้ว LangGraph เป็นคนตัดสินใจว่าจะ “รวม” ค่าใหม่เข้ากับ state เดิมอย่างไร ผ่านกลไกที่เรียกว่า reducer

พฤติกรรมเริ่มต้นถ้าไม่ระบุอะไรคือ เขียนทับ (ค่าใหม่แทนที่ค่าเก่าไปเลย) แต่ field messages ถูกประกาศเป็นพิเศษด้วย Annotated[list[BaseMessage], add_messages]add_messages คือ reducer ที่บอก LangGraph ว่า “อย่าทับ ให้ต่อท้าย” ทุกครั้งที่ node ไหนคืนข้อความใหม่มา มันจะถูกเติมเข้า list เดิม ไม่ใช่แทนที่ทั้งก้อน:

node คืนค่า:        {"messages": [AIMessage("...")]}
reducer add_messages: state.messages = state.messages + [AIMessage("...")]
                      (ไม่ใช่ state.messages = [AIMessage("...")] )

นี่คือกลไกจริงที่ทำให้ประวัติสนทนาไม่หายระหว่างวนลูป — ถ้า messages ใช้ reducer เขียนทับแบบ default, ทุกรอบที่ agent node ทำงานจะเห็นแค่ข้อความล่าสุดข้อความเดียว ลืมทุกอย่างก่อนหน้าทันที

ไล่ทีละขั้น: ข้อความไหลอย่างไรในคำถามเดียว

ตัวอย่างคำถาม "ไฟล์ contract.pdf อยู่ไหน" ที่ต้องเรียก tool 1 ครั้งก่อนตอบ:

รอบที่ 1 — messages ก่อนเข้า agent node:
  [ HumanMessage("ไฟล์ contract.pdf อยู่ไหน") ]

  agent node เรียกโมเดล → โมเดลไม่รู้คำตอบตรงๆ → ตัดสินใจเรียก tool
  ผลลัพธ์ที่ต่อเข้า messages:
  [ HumanMessage(...),
    AIMessage(tool_calls=[{name: "rag_search", args: {query: "contract.pdf"}}]) ]

  conditional edge เช็ค AIMessage ล่าสุด → มี tool_calls → ไปที่ TOOL NODE

─────────────────────────────────────────────

รอบที่ 2 — tool node รัน rag_search() จริง ต่อผลลัพธ์เข้า messages:
  [ HumanMessage(...),
    AIMessage(tool_calls=[...]),
    ToolMessage(content="พบที่ /Users/.../contract.pdf") ]

  กลับเข้า agent node → โมเดลอ่าน ToolMessage ล่าสุด → มีคำตอบพอแล้ว
  ต่อเข้า messages:
  [ ..., AIMessage(content="ไฟล์อยู่ที่ /Users/.../contract.pdf") ]

  conditional edge เช็ค AIMessage ล่าสุด → ไม่มี tool_calls → จบ (END)

สังเกตว่า messages ไม่เคยถูกลบ ระหว่างทาง มีแต่ต่อเพิ่ม — นี่คือกลไกที่ทำให้โมเดล “มองเห็นทุกอย่างที่เกิดขึ้นในคำถามนี้” ได้ในทุกรอบที่ agent node ถูกเรียกซ้ำ (ถ้าคุยยาวข้ามหลายคำถามจนใกล้เพดาน context ถึงจะมีระบบบีบอัด/ตัด messages เก่าเข้ามาช่วย — รายละเอียดอยู่ในบทความของ LOCAL AGENT TH ด้านล่าง)

tool_calls มาจากไหน — บทบาทของ LangChain ตรงนี้

ตอนโมเดล “ตัดสินใจเรียก tool” จริงๆ แล้วมันไม่ได้ไปสั่งอะไรเอง — มันแค่ตอบข้อความที่มีโครงสร้างตายตัว (structured output) ตามฟอร์แมต function-calling ของผู้ให้บริการโมเดลนั้นๆ

LangChain มีหน้าที่ 2 อย่างตรงจุดนี้:

  1. bind_tools(tools) — แปลง schema ของ tool ทุกตัว (ชื่อ, description, parameters) ให้อยู่ในรูปแบบที่โมเดลนั้นๆ เข้าใจ ก่อนส่งไปพร้อมคำถาม
  2. parse คำตอบกลับ — เมื่อโมเดลตอบมาเป็น raw text/JSON ตามฟอร์แมตของผู้ให้บริการ LangChain แปลงกลับเป็น AIMessage object ที่มี field .tool_calls เป็น list ของ Python dict อ่านง่าย — โค้ดฝั่ง LangGraph (conditional edge) แค่เช็ค if message.tool_calls: โดยไม่ต้องรู้เลยว่าโมเดลเบื้องหลังเป็นยี่ห้อไหน

นี่คือจุดที่ทำให้เปลี่ยนโมเดล (เช่นจาก Qwen ไปโมเดลอื่น) โดยไม่ต้องแก้โค้ด LangGraph เลยแม้แต่บรรทัดเดียว — ตราบใดที่โมเดลนั้นรองรับ function calling

Tool ล้มเหลวจะเกิดอะไรขึ้น

ในโลกจริง tool พังได้เสมอ — ไฟล์ไม่มีอยู่, เว็บที่จะดึงข้อมูลตอบ timeout, argument ที่โมเดลส่งมาไม่ตรง type ที่ต้องการ ถ้า tool node ปล่อยให้ exception หลุดออกมาตรงๆ ทั้งกราฟจะพังทันที ตอบผู้ใช้ไม่ได้เลย

LangGraph’s tool node (ToolNode) จึงมีพฤติกรรมดักจับ error ไว้เป็นค่าเริ่มต้น: ถ้า tool โยน exception ระหว่างรัน มันจะไม่ทำให้กราฟล้ม แต่ห่อข้อความ error นั้นเป็น ToolMessage ธรรมดา (มี flag บอกว่าเป็น error) แล้วส่งกลับเข้า messages เหมือนผลลัพธ์ปกติ:

[ ..., AIMessage(tool_calls=[{name: "read_file", args: {path: "missing.pdf"}}]) ]

                    ▼  tool node รัน read_file() → FileNotFoundError

[ ..., ToolMessage(content="Error: ไม่พบไฟล์ missing.pdf", is_error=True) ]

                    ▼  กลับเข้า agent node ตามปกติ
โมเดลอ่าน error message เอง → ตัดสินใจต่อ (ลองใหม่ด้วย path อื่น, ขอข้อมูลเพิ่มจากผู้ใช้, ฯลฯ)

พูดอีกแบบ: error ก็เป็นแค่ข้อมูลอีกก้อนหนึ่งใน context ให้โมเดลอ่าน ไม่ใช่เหตุการณ์พิเศษที่ต้องหยุดทั้งระบบ — โมเดลที่ดีพอจะ “เห็น” ว่า tool ล้มเหลวแล้วปรับแผนเองได้ในรอบถัดไปทันที

กันวนไม่รู้จบ: recursion limit

เพราะ conditional edge วนกลับเข้า agent node ได้เรื่อยๆ ตราบใดที่โมเดลยังเรียก tool ต่อ ในทางทฤษฎีลูปนี้ไม่มีจุดจบในตัวเอง ถ้าโมเดลติดวนเรียก tool ผิดซ้ำๆ (เช่นเจอ error แล้วลองคำสั่งเดิมซ้ำไม่เปลี่ยน) กราฟจะวนไม่รู้จบและกิน token/เวลาไม่จำกัด

LangGraph จึงมี recursion_limit เป็นเพดานจำนวนครั้งที่ node ไหนก็ตามใน graph ถูกเรียกได้ต่อ 1 การรัน (ค่า default มักตั้งไว้ราว 25 แต่ปรับได้ตามงาน) — ถ้าชนเพดานนี้ LangGraph จะโยน error หยุดการทำงานทันที แทนที่จะปล่อยให้วนต่อไม่จบ ฝั่งแอปจับ error นี้แล้วแจ้งผู้ใช้ว่า “งานซับซ้อนเกินไป ลองแบ่งเป็นคำถามย่อยดู” แทนที่จะปล่อยให้ค้าง

จำข้ามคำถามได้อย่างไร: checkpointer

ทุกอย่างที่อธิบายมาเป็น memory ระยะสั้นmessages list อยู่ในความจำแค่ระหว่างการรัน 1 ครั้งเท่านั้น พอ process จบ state ก็หายไป ถ้าปิดแล้วเปิดใหม่ agent จะจำอะไรไม่ได้เลย

การคุยต่อเนื่องข้ามหลายคำถามในหน้าต่างแชทเดียว (ไม่ใช่ข้าม session จริงแบบ ENDMEMEX) ทำได้ด้วย checkpointer — LangGraph เก็บ snapshot ของ state ไว้หลังจบแต่ละ node โดยผูกกับ thread_id (เช่น รหัสห้องแชทนั้นๆ) พอคำถามถัดไปมาพร้อม thread_id เดิม กราฟจะโหลด state (รวม messages ทั้งหมด) กลับมาต่อ ไม่ต้องเริ่มนับหนึ่งใหม่:

คำถามที่ 1 (thread_id="chat-1") ──▶ checkpointer บันทึก messages หลังจบ
คำถามที่ 2 (thread_id="chat-1") ──▶ checkpointer โหลด messages เดิมกลับมาก่อน แล้วค่อยต่อ
คำถามที่ 3 (thread_id="chat-2") ──▶ thread ใหม่ ไม่เห็น messages ของ chat-1 เลย

นี่คือกลไกที่ต่างจาก memory ข้าม session จริง (จำบริบทโปรเจกต์ข้ามวัน แม้ปิดแอปไปแล้ว) ซึ่งเป็นงานคนละชั้น — ENDMEMEX ในเว็บนี้ทำหน้าที่นั้นแยกออกไปต่างหาก ไม่ได้พึ่ง checkpointer ของ LangGraph

จากทฤษฎีสู่ของจริง: กราฟที่ AGENT LITE และ LOCAL AGENT TH ใช้

ผลิตภัณฑ์ทั้งสองในเว็บนี้ใช้โครงกราฟเดียวกันเป๊ะๆ กับ diagram ข้างบน — START → react → END ผ่าน LangGraph node เดียว ไม่ได้แตกเป็นหลาย node ตามชื่อ tool แต่ละตัว:

   START ──▶ [ react node ] ──(มี tool_calls)──▶ [ tool node ]
                  ▲                                    │
                  └────────────────────────────────────┘

            (ไม่มี tool_calls)

                 END

เหตุผลที่เลือกออกแบบเป็น node เดียววนซ้ำ แทนที่จะแยก node ต่อ tool: main agent เห็น full message history และผลลัพธ์ tool ทั้งหมดในลูปเดียว แล้วตัดสินใจเองว่าจะเรียก tool ต่อหรือตอบเลย — ลด overhead จากการส่ง state ข้าม node หลายจุดโดยไม่จำเป็น ยิ่งกราฟซับซ้อนเท่าไร ยิ่งมีจุดที่ state หลุด/ไม่ sync กันได้มากขึ้นเท่านั้น

LOCAL AGENT TH ต่อยอดจากกราฟฐานนี้อีกชั้น ด้วยการเติม planner node ไว้ก่อนเข้า react node เพื่อตัดสินใจว่าคำถามนี้ควรตอบตรงๆ หรือควรบังคับวางแผนก่อน:

   START ──▶ [ planner node ] ──▶ [ react node ] ──▶ ... ──▶ END
                                       ▲    │
                                       └────┘ (วน tool เหมือนเดิม)

โครงหลักไม่เปลี่ยน — ยังเป็น react loop เดิม แค่มีชั้นก่อนหน้าคอยกำหนดทิศทางตั้งต้นให้ชัดขึ้น (อ่านรายละเอียดกลไก planner แบบเต็มได้ในบทความ LOCAL AGENT TH ด้านล่าง)

Prebuilt function vs ประกอบกราฟเอง

LangGraph มีฟังก์ชันสำเร็จรูปชื่อ create_react_agent ที่สร้างกราฟ START → agent → tool → agent → ... → END แบบในบทความนี้ให้ทั้งหมดในบรรทัดเดียว — AGENT LITE ใช้ตัวนี้ตรงๆ เป็นแกนหลัก ไม่ได้เขียนกราฟเอง เพราะโครงสร้างที่ต้องการตรงกับสิ่งที่ prebuilt function ให้มาพอดี

แต่ prebuilt function ออกแบบมาให้มีแค่ node เดียว (agent + tool) ตายตัว — ถ้าต้องการ node เพิ่มเติมก่อนหรือหลังลูปหลัก (เช่น planner node ของ LOCAL AGENT TH) ต้องประกอบ StateGraph เอง ด้วยมือ: ประกาศ node, ผูก edge, เขียน conditional edge function เอง — ยืดหยุ่นกว่าแต่ต้องเข้าใจกลไกในบทความนี้ทั้งหมดก่อนเริ่ม ไม่มีฟังก์ชันสำเร็จรูปช่วยแล้ว

สรุปเป็นภาพเดียว

LangChain    = ล่ามระหว่างโค้ด ↔ โมเดล/tool (bind_tools, parse ผลลัพธ์)
LangGraph    = ตัวคุมเวที (state graph, conditional edge, วนลูปจนจบ)
AgentState   = messages list ที่ไหลผ่านทุก node
add_messages = reducer ที่สั่งให้ "ต่อท้าย" ไม่ใช่ "เขียนทับ" ทุกครั้งที่ node คืนข้อความใหม่
ToolNode     = ดัก error จาก tool อัตโนมัติ ห่อเป็น ToolMessage ส่งกลับให้โมเดลอ่านเอง
recursion_limit = เพดานกันวนไม่รู้จบ ถ้าโมเดลเรียก tool ผิดซ้ำเกินกำหนด
checkpointer + thread_id = memory ระยะสั้นข้ามคำถามในแชทเดียว (คนละชั้นกับ ENDMEMEX)
ReAct loop   = agent node ↔ tool node วนกันจนกว่า AIMessage ล่าสุดจะไม่มี tool_calls

อ่านเพิ่มเติม

ความคิดเห็น

กำลังโหลดความคิดเห็น...