Agent Loop — วนจนกว่าจะได้คำตอบ

จาก 0 ถึง AI Agent — บันทึกการเรียนรู้ผ่านโปรเจกต์จริง

ตอนที่ 4: Agent Loop — วนจนกว่าจะได้คำตอบ


ตอนที่แล้วเรานิยาม tool ไว้แล้ว ตอนนี้มาดูส่วนที่สำคัญที่สุดของโปรเจกต์ — Agent Loop หรือวงจรที่ทำให้ AI “คิดแล้วทำ แล้วคิดต่อ” ได้


run_tool(): ฝั่ง Python ทำงานจริง

เมื่อ Claude ตัดสินใจเรียก tool โปรแกรมของเราต้องรันฟังก์ชันจริงๆ แล้วส่งผลกลับไป หน้าที่นี้อยู่ใน run_tool():

def run_tool(name: str, inputs: dict) -> str:
    if name == "list_files":
        files = []
        for f in DOCS_FOLDER.iterdir():
            if f.is_file():
                first_line = f.read_text().split('\n')[0][:80]
                files.append({"filename": f.name, "preview": first_line})
        return json.dumps(files)

    if name == "read_file":
        path = DOCS_FOLDER / inputs["filename"]
        if not path.exists():
            return f"ERROR: {inputs['filename']} not found"
        return path.read_text()

    return f"ERROR: unknown tool {name}"

ดูแล้วเข้าใจง่ายมาก:

  • list_files: วนดูทุกไฟล์ในโฟลเดอร์ docs/ แล้วดึงบรรทัดแรกมาเป็น preview (80 ตัวอักษร) — Claude จะใช้ preview นี้ตัดสินใจว่าไฟล์ไหนน่าอ่าน
  • read_file: เปิดอ่านไฟล์ตามชื่อที่ Claude ส่งมา ถ้าไม่เจอก็บอก error กลับไป
  • 💡 Preview บรรทัดแรก — เทคนิคเล็กๆ แต่ช่วยได้เยอะ Claude จะเลือกอ่านไฟล์ที่ “น่าจะตอบคำถามได้” โดยดูจากชื่อไฟล์ + preview แทนที่จะเปิดทุกไฟล์


    Agent Loop: หัวใจของทุกอย่าง

    นี่คือโค้ดที่ทำให้มันเป็น “Agent” จริงๆ:

    def ask(question: str) -> str:
        messages = [{"role": "user", "content": question}]
    
        while True:
            response = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                tools=tools,
                system=SYSTEM_PROMPT,
                messages=messages
            )
    
            if response.stop_reason == "end_turn":
                return next(b.text for b in response.content if b.type == "text")
    
            if response.stop_reason == "tool_use":
                messages.append({"role": "assistant", "content": response.content})
    
                results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"  → {block.name}({block.input})")
    
                        if block.name == "return_answer":
                            return DocAnswer(**block.input)
    
                        result = run_tool(block.name, block.input)
                        results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result
                        })
    
                messages.append({"role": "user", "content": results})

    ดูซับซ้อน แต่ถ้าแยกทีละส่วนจะเห็นว่าตรงไปตรงมามาก


    แยกทีละขั้น

    ขั้น 1: เริ่มต้นด้วยคำถาม

    messages = [{"role": "user", "content": question}]

    สร้าง “ประวัติการสนทนา” เริ่มต้นด้วยคำถามของเรา

    ขั้น 2: ส่งให้ Claude คิด

    response = client.messages.create(
        model="claude-sonnet-4-6",
        tools=tools,          # บอกว่ามี tool อะไรให้ใช้
        system=SYSTEM_PROMPT, # กฎของบ้าน
        messages=messages     # ประวัติการสนทนาทั้งหมด
    )

    ขั้น 3: ดูว่า Claude ตัดสินใจอะไร

    Claude จะตอบกลับมาพร้อม stop_reason ซึ่งมีสองกรณี:

  • "end_turn" → Claude คิดว่าเสร็จแล้ว ตอบเป็นข้อความธรรมดา → จบ loop
  • "tool_use" → Claude อยากเรียก tool → เราต้องรัน tool แล้วส่งผลกลับ → วนซ้ำ
  • ขั้น 4: ถ้าเป็น tool_use — รัน tool แล้วส่งผลกลับ

    # เพิ่ม response ของ Claude เข้าไปใน history
    messages.append({"role": "assistant", "content": response.content})
    
    # วนดู tool ที่ Claude อยากเรียก
    for block in response.content:
        if block.type == "tool_use":
            # ถ้าเป็น return_answer — หยุดเลย!
            if block.name == "return_answer":
                return DocAnswer(**block.input)
    
            # ไม่งั้น รัน tool แล้วเก็บผล
            result = run_tool(block.name, block.input)
            results.append({...})
    
    # ส่งผลของ tool กลับให้ Claude
    messages.append({"role": "user", "content": results})
    # → วนกลับไปขั้น 2

    ทำไม return_answer ถึงพิเศษ?

    สังเกตว่า return_answer ถูก intercept ก่อนจะถึง run_tool():

    if block.name == "return_answer":
        return DocAnswer(**block.input)  # ← หยุด loop ทันที

    เพราะ return_answer ไม่ใช่ tool จริง — มันคือ สัญญาณ ว่า Claude ได้คำตอบแล้ว

    ถ้าเราส่งมันเข้า run_tool() โปรแกรมจะ error เพราะไม่มี case รองรับ แต่เราดักไว้ก่อน แล้วแปลง input ของมันเป็น DocAnswer object ทันที


    ภาพรวม Loop ทั้งหมด

    คำถาม → Claude → tool_use: list_files
                          ↓
                  Python รัน list_files
                          ↓
                  ส่งผลกลับ → Claude → tool_use: read_file
                                           ↓
                                   Python รัน read_file
                                           ↓
                                   ส่งผลกลับ → Claude → tool_use: return_answer
                                                             ↓
                                                       หยุด → ได้คำตอบ

    สรุปตอนที่ 4

    Agent Loop ทำงานด้วยหลักการง่ายๆ — ส่งคำถามให้ Claude ถ้าอยากเรียก tool เราก็รันให้แล้วส่งผลกลับ วนซ้ำจนกว่า Claude จะบอกว่าเสร็จ

    ส่วนที่ elegant ที่สุดคือ return_answer — tool ปลอมที่ทำหน้าที่เป็นสัญญาณหยุด และบังคับให้ Claude ส่งคำตอบในรูปแบบที่เรากำหนด

    ตอนหน้าเราจะมาดูว่า Pydantic ช่วยจัดการ structured output ยังไง


    📚 Series: จาก 0 ถึง AI Agent — บันทึกการเรียนรู้ผ่านโปรเจกต์จริง

    📚 Series: จาก 0 ถึง AI Agent — บันทึกการเรียนรู้ผ่านโปรเจกต์จริง

    Source code ทั้งหมดอยู่ที่ github.com/nipitpongpan/ask-my-docs


    Posted

    in

    ,

    by

    Tags:

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *