โปรเจกต์ที่ 2 · Phase 2: ReAct Loop & Research Agent
บทความนี้เป็นส่วนหนึ่งของ series “เส้นทางสู่ AI Agent Engineer” ถ้าคุณยังไม่ได้อ่าน Phase 1 แนะนำให้อ่านก่อนที่ Ask My Docs — Tool-Calling Agent เพราะเราจะต่อยอดจากแนวคิดนั้นโดยตรง
จากที่แล้วมา… เราทำอะไรไปแล้ว?
ใน Phase 1 เราสร้าง Ask My Docs — agent ที่รับ query แล้วเรียกใช้ tools แบบ single-turn ผู้ใช้ถาม → Claude เรียก tool → ได้คำตอบ จบ
แต่โลกจริงมันไม่ง่ายขนาดนั้น
ลองนึกถึงการค้นคว้าข้อมูลจริงๆ คุณไม่ได้ค้นหาครั้งเดียวแล้วเขียนรายงานได้เลย คุณต้องค้น → อ่าน → ประเมิน → ค้นเพิ่ม → สังเคราะห์ → เขียน นั่นคือ loop ที่ต้องวนซ้ำหลายรอบ
Phase 2 นี้เราจะสร้าง agent ที่คิดและทำงานแบบ วนซ้ำอัตโนมัติ ด้วย pattern ที่ชื่อว่า ReAct
ReAct Pattern คืออะไร?
ReAct ย่อมาจาก Reasoning + Acting เป็น pattern ที่ตีพิมพ์ในงานวิจัยปี 2022 แนวคิดหลักคือให้ LLM สลับกันระหว่างสองขั้นตอน:
Thought → ฉันต้องการข้อมูลเรื่อง X ก่อน
Action → เรียกใช้ tool: web_search("X")
Observation → ได้ผลลัพธ์: "..."
Thought → ข้อมูลยังไม่ละเอียดพอ ต้องอ่านเพิ่ม
Action → เรียกใช้ tool: fetch_page("https://...")
Observation → ได้เนื้อหา: "..."
Thought → มีข้อมูลเพียงพอแล้ว เขียนรายงานได้
Action → เรียกใช้ tool: save_file("report.md", "...")สังเกตว่า agent ไม่ได้ถูก “โปรแกรม” ว่าต้องทำ 3 ขั้นตอนนี้ — Claude ตัดสินใจเองว่าจะค้นต่อหรือพอแล้ว โดยอาศัย context ที่สะสมอยู่ใน conversation history
นี่คือความแตกต่างสำคัญจาก Phase 1:
| Phase 1 (Ask My Docs) | Phase 2 (Research Agent) | |
|---|---|---|
| จำนวน tool calls | 1 ครั้ง | หลายครั้ง (วนซ้ำ) |
| ผู้ตัดสินใจว่าพอแล้ว | โปรแกรมเมอร์ | Claude เอง |
| ความซับซ้อน | Single-turn | Multi-turn loop |
| ผลลัพธ์ | ตอบคำถาม | สร้างไฟล์รายงาน |
โครงสร้างโปรเจกต์
├── main.py # Agent loop หัวใจหลักของโปรเจกต์
├── tools.py # Tool definitions + implementations
├── requirements.txt # Dependencies
└── .env # เก็บ API key (ไม่ commit ขึ้น Git)
แค่ 2 ไฟล์หลัก โปรเจกต์นี้ตั้งใจให้เรียบง่ายเพื่อให้เห็น pattern ได้ชัดที่สุด
มาเริ่มกัน — Setup โปรเจกต์
Prerequisites
- Python 3.9 ขึ้นไป
- Anthropic API key (สร้างได้ที่ console.anthropic.com)
ขั้นตอนติดตั้ง
bash
# 1. Clone repo
git clone https://github.com/nipitpongpan/research-agent.git
cd research-agent
# 2. สร้าง virtual environment
python3 -m venv .venv # macOS/Linux
py -m venv .venv # Windows
# 3. Activate
source .venv/bin/activate # macOS/Linux
.venv\Scripts\activate # Windows
# 4. ติดตั้ง dependencies
pip install -r requirements.txt
# 5. สร้างไฟล์ .env
echo "ANTHROPIC_API_KEY=sk-ant-xxxxxxxx" > .env
requirements.txt มีอะไรบ้าง?
| Package | หน้าที่ |
|---|---|
anthropic | Anthropic Python SDK สำหรับเรียก Claude API |
httpx | HTTP client แบบ async-capable สำหรับค้นเว็บและ fetch หน้า |
beautifulsoup4 | Parse HTML ดึงเนื้อหาออกจากหน้าเว็บ |
python-dotenv | โหลด .env file เป็น environment variables |
ทำความเข้าใจ tools.py — เครื่องมือที่ Agent ใช้
tools.py มีสองส่วนหลัก: Tool Schemas (บอก Claude ว่ามี tools อะไร) และ Tool Implementations (โค้ดที่รันจริง)
ส่วนที่ 1: Tool Schemas (TOOLS list)
python — tools.py
TOOLS = [
{
"name": "web_search",
"description": "Search the web for current information. Returns a list of results with title, URL, and snippet.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
},
{
"name": "fetch_page",
"description": "Fetch and read the text content of a webpage by URL. Use this to read details after web_search.",
"input_schema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Full URL including https://"}
},
"required": ["url"]
}
},
{
"name": "save_file",
"description": "Save research results to a local file. Use this as the LAST step.",
"input_schema": {
"type": "object",
"properties": {
"filename": {"type": "string", "description": "Filename e.g. 'report.md'"},
"content": {"type": "string", "description": "Full text content to save"}
},
"required": ["filename", "content"]
}
}
]
description ของแต่ละ tool สำคัญมาก นี่คือข้อความที่ Claude อ่านเพื่อตัดสินใจว่าจะใช้ tool ไหน และเมื่อไหร่ สังเกตว่า save_file บอกว่า “Use this as the LAST step” — นั่นคือ instruction ให้ Claude รู้ว่า tool นี้ต้องใช้เป็น action สุดท้ายส่วนที่ 2: Tool Implementations
web_search() — ค้นเว็บด้วย DuckDuckGo
python
def web_search(query: str) -> str:
"""DuckDuckGo HTML search — no API key needed."""
headers = {"User-Agent": "Mozilla/5.0"}
params = {"q": query, "kl": "us-en"}
resp = httpx.get(
"https://html.duckduckgo.com/html/",
params=params, headers=headers, timeout=10
)
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for r in soup.select(".result")[:5]: # ดึงแค่ 5 ผลลัพธ์แรก
title_el = r.select_one(".result__title")
url_el = r.select_one(".result__url")
snippet_el = r.select_one(".result__snippet")
title = title_el.get_text(strip=True) if title_el else "No title"
url = url_el.get_text(strip=True) if url_el else "No URL"
snippet = snippet_el.get_text(strip=True) if snippet_el else "No snippet"
results.append(f"Title: {title}\nURL: {url}\nSnippet: {snippet}")
return "\n\n".join(results)
ทำไมถึงใช้ DuckDuckGo HTML แทน Google?
เพราะ Google Search API มีค่าใช้จ่าย แต่ DuckDuckGo มี HTML endpoint ที่ไม่ต้องใช้ API key เลย เราแค่ส่ง HTTP GET ธรรมดาแล้ว parse HTML ที่ได้กลับมา
ทำไม limit แค่ 5 ผลลัพธ์?
เพราะ context window ของ Claude มีขนาดจำกัด ถ้าส่งผลลัพธ์มากเกินไป จะเปลือง tokens และทำให้ต้นทุนสูงขึ้น 5 ผลแรกมักเพียงพอสำหรับการค้นคว้าทั่วไป
fetch_page() — อ่านเนื้อหาจากหน้าเว็บ
python
def fetch_page(url: str) -> str:
"""Fetch webpage and return cleaned text (max 3000 chars)."""
headers = {"User-Agent": "Mozilla/5.0"}
resp = httpx.get(url, headers=headers, timeout=15, follow_redirects=True)
soup = BeautifulSoup(resp.text, "html.parser")
# ลบส่วนที่ไม่จำเป็นออก
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
lines = [l for l in text.splitlines() if l.strip()]
return "\n".join(lines)[:3000] # จำกัดที่ 3000 ตัวอักษร
การ “ทำความสะอาด” HTML มีความสำคัญมาก: ถ้าเราส่ง HTML ดิบๆ ให้ Claude จะมี JavaScript, CSS, navigation menu, footer ปะปนอยู่ด้วย สิ่งเหล่านี้ไม่มีประโยชน์ต่อการค้นคว้า แต่กินพื้นที่ context เราจึงต้อง decompose() ออกก่อน
ทำไม cap ที่ 3000 ตัวอักษร? บทความยาวๆ อาจมี 20,000+ ตัวอักษร ถ้าส่งทั้งหมดจะทำให้ context ใหญ่มากและค่า API สูงขึ้น
save_file() — บันทึกรายงานลงไฟล์
python
def save_file(filename: str, content: str) -> str:
path = Path(filename)
path.write_text(content, encoding="utf-8")
return f"Saved to {path.resolve()}"
Simple มาก แค่บันทึก content ที่ Claude เขียนลง .md file ที่ระบุ
run_tool() — Dispatcher
python
def run_tool(name: str, inputs: dict) -> str:
if name == "web_search":
query = inputs["query"]
for attempt in range(3): # ลองใหม่ได้ 3 ครั้ง
result = web_search(query)
if not result.startswith("Error:"):
return result # สำเร็จ — คืนทันที
if attempt < 2:
time.sleep(2 ** attempt) # exponential backoff: 1s, 2s
return result
elif name == "fetch_page":
return fetch_page(inputs["url"])
elif name == "save_file":
return save_file(inputs["filename"], inputs["content"])
return f"Unknown tool: {name}"
run_tool() ทำหน้าที่เป็น router — รับชื่อ tool และ inputs จาก Claude แล้วเรียก function ที่ถูกต้อง
สังเกต exponential backoff ใน web_search: ถ้า DuckDuckGo ตอบ error จะรอ 1 วินาที ลองใหม่ ถ้ายังไม่ได้จะรอ 2 วินาที pattern นี้ช่วยรับมือกับ rate limiting ได้ดีมาก
ทำความเข้าใจ main.py — หัวใจของ Agent Loop
python — main.py
from dotenv import load_dotenv
import anthropic
from tools import TOOLS, run_tool
load_dotenv() # โหลด API key จากไฟล์ .env
client = anthropic.Anthropic()
System Prompt — บทบาทและกฎของ Agent
python
SYSTEM = """You are a research agent. Your job is to:
1. Search the web to gather information on the given topic
2. Fetch relevant pages to get full details
3. Write a clear, structured research report in Markdown
4. Save the report using save_file as your FINAL action
IMPORTANT:
- Always trust tool results over your training knowledge
- Always end by saving the report with save_file
- Report should have: Summary, Key Findings, Sources"""
System prompt นี้ทำสิ่งสำคัญ 3 อย่าง:
- กำหนด persona — “You are a research agent”
- กำหนดกระบวนการ — ค้น → อ่าน → เขียน → บันทึก
- กำหนดข้อจำกัด — ไว้วางใจ tool results, ต้องจบด้วย save_file
run_agent() — The ReAct Loop
มาอ่านทีละส่วน:
เริ่มต้น conversation
python
def run_agent(user_message: str, max_iterations: int = 10) -> str:
messages = [{"role": "user", "content": user_message}]
print(f"\n🔍 Research query: {user_message}\n{'='*50}")
เรียก Claude API ในแต่ละ iteration
python
for i in range(max_iterations):
print(f"\n--- Iteration {i + 1} ---")
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
system=SYSTEM,
tools=TOOLS,
messages=messages, # ส่ง history ทั้งหมดทุกครั้ง
)
print(f"Stop reason: {response.stop_reason}")
กรณีที่ Claude เสร็จแล้ว (end_turn)
python
if response.stop_reason == "end_turn":
final = next(b.text for b in response.content if b.type == "text")
print(f"\n✅ Final answer: {final}")
return final
กรณีที่ Claude ต้องการเรียก tool (tool_use) — หัวใจของ ReAct
python
if response.stop_reason == "tool_use":
# ขั้นที่ 1 (Action): บันทึก response ของ Claude ลง history
messages.append({"role": "assistant", "content": response.content})
# ขั้นที่ 2 (Execute): รัน tools และเก็บผลลัพธ์
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"🔧 Calling tool: {block.name}({block.input})")
result = run_tool(block.name, block.input)
print(f" Result: {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # ต้อง match กับ id ที่ Claude ส่งมา
"content": result,
})
# ขั้นที่ 3 (Observe): ส่งผลลัพธ์กลับให้ Claude reason ต่อ
messages.append({"role": "user", "content": tool_results})
tool_use blocks ที่บอกว่า Claude จะเรียก tool อะไรtool_use ก็เรียก run_tool() เพื่อรันจริงtool_result กลับให้ Claude เป็น user message — Claude จะอ่าน observation นี้แล้วตัดสินใจว่าจะทำอะไรต่อtool_use_id ต้อง match กับ id ที่ Claude ส่งมาใน tool_use block Anthropic API ใช้ id นี้จับคู่ว่า result นี้ตอบ tool call ไหน ถ้า mismatch จะ errorภาพรวมของ Loop ทั้งหมด
┌────────────────────────────────────────────────┐
│ run_agent() Loop │
│ │
│ messages = [user_query] │
│ │
│ iteration 1: │
│ ┌─ Claude ──────────────────────────────────┐ │
│ │ Thought: ต้องค้นหาข้อมูลก่อน │ │
│ │ → stop_reason: "tool_use" │ │
│ │ → tool: web_search("MCP 2025") │ │
│ └───────────────────────────────────────────┘ │
│ ↓ run_tool() → ได้ผลลัพธ์ │
│ ↓ append tool_result ลง messages │
│ │
│ iteration 2: │
│ ┌─ Claude ──────────────────────────────────┐ │
│ │ Thought: ต้องอ่านรายละเอียดเพิ่ม │ │
│ │ → stop_reason: "tool_use" │ │
│ │ → tool: fetch_page("https://...") │ │
│ └───────────────────────────────────────────┘ │
│ ↓ run_tool() → ได้เนื้อหา │
│ ↓ append tool_result ลง messages │
│ │
│ iteration 3: │
│ ┌─ Claude ──────────────────────────────────┐ │
│ │ Thought: มีข้อมูลพอแล้ว เขียนรายงาน │ │
│ │ → stop_reason: "tool_use" │ │
│ │ → tool: save_file("report.md", "...") │ │
│ └───────────────────────────────────────────┘ │
│ ↓ บันทึกไฟล์ │
│ │
│ iteration 4: │
│ ┌─ Claude ──────────────────────────────────┐ │
│ │ → stop_reason: "end_turn" ✅ │ │
│ │ → return final text │ │
│ └───────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘รันโปรเจกต์จริง
แก้ไข main.py ส่วนล่างสุด:
python — main.py
if __name__ == "__main__":
# เปลี่ยน question ตามที่ต้องการ
question = "Research the Model Context Protocol (MCP) and save a summary report as mcp_report.md"
run_agent(question)
แล้วรัน:
bash
python main.py
# หรือบน Windows:
py main.py
ตัวอย่าง Output ที่จะเห็นใน Terminal
==================================================
— Iteration 1 —
Stop reason: tool_use
🔧 Calling tool: web_search({‘query’: ‘Model Context Protocol MCP 2025’})
Result: Title: Model Context Protocol – Anthropic
URL: modelcontextprotocol.io
Snippet: MCP is an open protocol that standardizes…
— Iteration 2 —
Stop reason: tool_use
🔧 Calling tool: fetch_page({‘url’: ‘https://modelcontextprotocol.io’})
Result: Model Context Protocol
MCP is an open protocol that enables seamless integration…
— Iteration 3 —
Stop reason: tool_use
🔧 Calling tool: web_search({‘query’: ‘MCP servers tools examples 2025’})
Result: …
— Iteration 4 —
Stop reason: tool_use
🔧 Calling tool: save_file({‘filename’: ‘mcp_report.md’, ‘content’: ‘…’})
Result: Saved to /home/user/research-agent/mcp_report.md
— Iteration 5 —
Stop reason: end_turn
✅ Final answer: I’ve completed the research on MCP and saved the report…
ตัวอย่าง Queries ที่ลองได้
python
# เปรียบเทียบ 2 เทคโนโลยี
question = "Research ChromaDB vs Pinecone for RAG applications. Save as vectordb_comparison.md"
# ทำความเข้าใจ concept
question = "Research the ReAct prompting pattern for AI agents. Save as react_report.md"
# เปรียบเทียบหลาย frameworks
question = """Research LangChain, LlamaIndex, and CrewAI.
Compare their main use cases and save to frameworks_comparison.md"""
# ติดตาม topic ใหม่
question = "Research the latest developments in multimodal AI models in 2025. Save as multimodal_ai.md"
สิ่งที่น่าสังเกตจาก Conversation History
ถ้าเปิด messages list ดูระหว่าง loop จะเห็น pattern แบบนี้:
python
[
# รอบที่ 1: user ถาม
{"role": "user", "content": "Research MCP..."},
# รอบที่ 1: Claude ตอบด้วย tool_use
{"role": "assistant", "content": [
{"type": "text", "text": "I'll research this for you..."},
{"type": "tool_use", "id": "toolu_01", "name": "web_search",
"input": {"query": "MCP 2025"}}
]},
# รอบที่ 1: เราส่งผลลัพธ์กลับ
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Title: MCP..."}
]},
# รอบที่ 2: Claude ตอบด้วย tool_use อีก
{"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "fetch_page",
"input": {"url": "https://..."}}
]},
# ... วนซ้ำไปเรื่อยๆ ...
]
ข้อจำกัดที่ต้องรู้
| ข้อจำกัด | รายละเอียด |
|---|---|
| JavaScript pages | fetch_page ใช้ static HTML parsing เท่านั้น หน้าที่โหลด content ด้วย React/Vue จะได้เนื้อหาไม่ครบ |
| DuckDuckGo rate limit | ถ้าค้นบ่อยมากอาจโดน block ชั่วคราว |
| Page truncation | fetch_page ตัดที่ 3,000 ตัวอักษร บทความยาวจะถูกตัดทิ้ง |
| ไม่มี memory ระหว่าง runs | แต่ละครั้งที่รัน run_agent() เริ่มใหม่จาก 0 |
| Max 10 iterations | hard stop ป้องกัน runaway agent ที่อาจทำให้ค่า API บานปลาย |
Toy Tools — โค้ด 3 ตัวที่ไม่ได้ใช้จริง
ใน tools.py มี tools 3 ตัวเพิ่มเติมที่ไม่ได้ใช้ในการ research จริง:
python
# ใน TOOLS list — training wheels ที่ยังเหลืออยู่
{"name": "calculator", ...}
{"name": "word_count", ...}
{"name": "reverse_string", ...}
tools พวกนี้เป็น “training wheels” ที่ใช้ระหว่างเรียนรู้วิธีทำ tool use ก่อนจะสร้าง tools จริง มันยังคงอยู่ใน code แต่ system prompt ไม่ได้บอกให้ Claude ใช้ เลยแทบไม่มีโอกาสถูกเรียก
ถ้าอยาก “clean up” ลบออกได้เลย — agent จะทำงานเหมือนเดิมทุกประการ
Leave a Reply