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 อย่างตรงจุดนี้:
bind_tools(tools)— แปลง schema ของ tool ทุกตัว (ชื่อ, description, parameters) ให้อยู่ในรูปแบบที่โมเดลนั้นๆ เข้าใจ ก่อนส่งไปพร้อมคำถาม- parse คำตอบกลับ — เมื่อโมเดลตอบมาเป็น raw text/JSON ตามฟอร์แมตของผู้ให้บริการ LangChain แปลงกลับเป็น
AIMessageobject ที่มี 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
อ่านเพิ่มเติม
- Tool คืออะไร AI Agent เรียกใช้อย่างไร — ความเข้าใจพื้นฐานก่อนบทความนี้
- ENDEAVOR AGENT LITE ทำงานอย่างไร — ตัวอย่าง react node เดียวจริงบนโมเดลเล็ก
- ENDEAVOR LOCAL AGENT TH ทำงานอย่างไร — กราฟที่มี planner node เพิ่มเข้ามา
ความคิดเห็น
กำลังโหลดความคิดเห็น...