Chapter 8 — Memory Management: Sliding Window Context & Conversation Trimming
ทำไมต้องชื่อนี้และเรียนไปเพื่ออะไร?
ใน Day 6 และ 7 เราสร้าง ReAct Engine และ CLI ได้สำเร็จแล้ว แต่ถ้าสังเกตดูในโค้ด engine.py ทุกครั้งที่ Agent คิด ทำ สังเกต (Thought ➔ Action ➔ Observation) มันจะใช้คำสั่ง self.messages.append(...) ยัดประวัติข้อความสะสมลงในลิสต์เรื่อยๆ แบบไม่มีที่สิ้นสุด!
ปัญหาระดับวิกฤตบน MacBook M3 16GB คือ: เมื่อ Agent ทำงานหลายรอบ (Multi-turn เช่น วนลูปแก้โค้ด 6-10 รอบ) หรือเปิด Interactive Shell คุยไป 15 คำถาม ประวัติข้อความจะบวมพองจนทะลุ Context Window Limit (เช่น 8K หรือ 16K Tokens) ผลลัพธ์คือ:
1. Ollama OOM (Out of Memory): Unified Memory บน Mac เต็ม โมเดลแครช หรือระบบบีบอัด RAM จนเครื่องกระตุก
2. Quadratic KV-Cache Slowdown: เวลาคำนวณ Attention พุ่งสูงจาก 1 วินาที เป็น 45 วินาทีต่อการตอบ 1 คำ!
3. Attention Degradation: โมเดลเริ่ม "หลอน (Hallucinate)" ลืม System Prompt กฎเหล็ก และลืมเป้าหมายหลักเดิม
Day 8 เราจึงต้องสร้าง Memory Management Module โดยใช้เทคนิค Sliding Window with Anchor Pinning (ปักหมุด System Prompt + รูดหน้าต่างตัดประวัติข้อความเก่าทิ้งอัตโนมัติ) เพื่อการันตีว่า ไม่ว่า Agent จะทำงานต่อเนื่องนานแค่ไหน Token จะถูกจำกัดไว้ในกรอบคงที่เสมอ รันไว กินแรมน้อย และเสถียร 100%!
โค้ดประจำ Day 8 จะเพิ่มแพ็กเกจใหม่ memory/ และอัปเกรด core/engine.py ให้เรียกใช้งาน Trimmer ก่อนส่งเข้า Ollama ทุกครั้ง:
ทำหน้าที่เป็น Context Window Gatekeeper ตรวจสอบประวัติข้อความก่อนส่งให้ LLM หากมีจำนวนรอบการสนทนาเกินขีดจำกัด (เช่น เกิน 6 รอบล่าสุด) จะตัดทอนข้อความเก่าทิ้ง แต่ยังคงตรึง System Prompt บรรทัดแรกไว้เสมอ 100%
สคริปต์รันบน Terminal เพื่อทดลองยิงข้อความจำลอง 12 รอบ (Turns) แล้วสั่งให้ Memory Trimmer ทำงาน ยืนยันว่าประวัติข้อความที่ส่งเข้าโมเดลจะไม่เคยเกินงบประมาณ (Budget) ที่ตั้งไว้
ห้ามตัดทิ้งเด็ดขาด! เพราะเป็นที่อยู่ของตัวตน กฎเกณฑ์ JSON Schema และรายชื่อ Tools
ถูกตัดทิ้งอัตโนมัติ เพื่อไม่ให้ KV Cache ระเบิดแรม 16GB ของ Mac M3
เก็บเฉพาะคำสั่งล่าสุด + Tool Call ล่าสุด + Observation ผลเทสล่าสุดไว้คิดต่อ
1. ค่า Attention Complexity เป็น $O(N^2)$ ยิ่งคุยยาว การคำนวณแต่ละโทเค็นจะยิ่งช้าลงทวีคูณ
2. Unified Memory จะถูก KV-Cache ยึดไปจนหมด บีบให้ OS ทำ Memory Swap ลง SSD ทำให้เครื่องอืด
3. โมเดลขนาด 7B จะเกิด "Lost in the Middle" ลืมคำสั่งหลักที่อยู่หัวข้อความ
1. ปริมาณ Token คงที่สม่ำเสมอเสมอ (< 2,500 Tokens ตลอดกาล)
2. ความเร็วในการตอบสนอง (Inference Speed) เร็วคงเส้นคงวา ~35 tokens/sec บน Mac M3
3. ตัวตนและกฎเกณฑ์ (System Prompt) แน่นปึ้ก ไม่เคยเลือนหาย เพราะมี Anchor Pinning ล็อกไว้ที่ Index 0 เสมอ
# 1.1 ไฟล์ memory/__init__.py
"""
Package บริหารจัดการความจำและ Context Window ของ Agent
"""
from memory.context import SlidingWindowContext
__all__ = ["SlidingWindowContext"]
# -------------------------------------------------------------
# 1.2 ไฟล์ memory/context.py
from typing import List, Dict, Any
class SlidingWindowContext:
"""
ตัวจัดการ Context Window แบบ Sliding Window พร้อมกลไก Anchor Pinning
สำหรับควบคุมปริมาณ Token ไม่ให้บวมพองบน Apple Silicon Mac 16GB
"""
def __init__(self, keep_last_turns: int = 6):
"""
:param keep_last_turns: จำนวนข้อความล่าสุดที่ต้องการรักษาไว้ (ค่าเริ่มต้น: 6 ข้อความ)
"""
self.keep_last_turns = keep_last_turns
def trim(self, messages: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""
ตัดทอนประวัติข้อความให้กระชับ โดยยังคงรักษา System Prompt บรรทัดแรกไว้เสมอ 100%
:param messages: ลิสต์ของข้อความทั้งหมด เช่น [{'role': 'system', ...}, {'role': 'user', ...}]
:return: ลิสต์ข้อความที่ถูกตัดทอนแล้ว ปลอดภัยต่อ Context Window
"""
# หากประวัติข้อความยังมีขนาดไม่เกินงบประมาณ (System Prompt 1 อัน + จำนวนรอบที่กำหนด) ไม่ต้องตัด
if len(messages) <= self.keep_last_turns + 1:
return messages
# 1. [Anchor Pinning]: แยก System Prompt ตัวแรกสุดออกมาเสมอ
system_prompt = messages[0]
# 2. [Sliding Window]: ดึงเฉพาะ N ข้อความล่าสุดที่อยู่ท้ายลิสต์
recent_messages = messages[-self.keep_last_turns:]
# 3. ตรวจสอบความสมบูรณ์ของประวัติ: ถ้าข้อความแรกของ window ลอยมาเป็น tool observation
# เพื่อป้องกันไม่ให้โมเดลสับสนว่า observation นี้เป็นของ tool อะไร เราจะคงคู่บริบทไว้
pruned_result = [system_prompt] + recent_messages
return pruned_result
def estimate_tokens(self, messages: List[Dict[str, str]]) -> int:
"""
คำนวณประเมินจำนวน Token คร่าวๆ (heuristic: 1 token ~ 4 ตัวอักษรภาษาอังกฤษ หรือ 1.5 ตัวอักษรไทย)
ช่วยให้ประเมินการใช้หน่วยความจำได้โดยไม่ต้องลงไลบรารี tokenizer เพิ่ม
"""
total_chars = sum(len(msg.get("content", "")) for msg in messages)
# ประมาณการแบบปลอดภัย (Conservative Estimate)
return int(total_chars / 3.2)
ชำแหละไวยากรณ์สำคัญใน memory/context.py
system_prompt = messages[0]
Anchor Pinning: ข้อความที่ index 0 คือ System Prompt ที่บรรจุกฎเหล็ก ReAct และ JSON Schema หากเผลอตัดตัวนี้ทิ้ง โมเดลจะกลายเป็นคนความจำเสื่อมและตอบผิดไวยากรณ์ทันที
recent_messages = messages[-self.keep_last_turns:]
Negative Index Slicing ใน Python: การใช้ [-N:] คือการกวาดเอาเฉพาะ N รายการท้ายสุดของ List ออกมาอย่างรวดเร็ว ทำงานแบบ $O(K)$ ในหน่วยความจำ RAM
return [system_prompt] + recent_messages
การนำ System Prompt มาต่อหัวให้กับหน้าต่างข้อความล่าสุด เกิดเป็นข้อความชุดใหม่ที่ทั้งเบา กระชับ และคงกฎเหล็กไว้ครบถ้วน 100%
import json
from typing import List, Dict, Any, Optional
from core.client import OllamaLLMClient
from core.parser import StructuredOutputParser, JSONDecodeError
from core.dispatcher import ToolDispatcher, ToolExecutionError
from memory.context import SlidingWindowContext
class MaxIterationsReachedError(Exception):
"""Exception เมื่อลูปทำงานเกินขีดจำกัดสูงสุดที่ตั้งไว้ (Guardrail)"""
pass
class ReActAgentEngine:
"""
แกนกลางควบคุมลูป ReAct (Reason ➔ Act ➔ Observe) แบบอัตโนมัติ
[อัปเกรด Day 8]: มี SlidingWindowContext ควบคุมไม่ให้ Context บวมพอง
"""
def __init__(
self,
client: OllamaLLMClient,
dispatcher: ToolDispatcher,
max_iterations: int = 6,
memory_window: int = 6
):
self.client = client
self.dispatcher = dispatcher
self.max_iterations = max_iterations
# ติดตั้ง Memory Manager ประจำตัว Engine
self.memory = SlidingWindowContext(keep_last_turns=memory_window)
self.messages: List[Dict[str, str]] = []
def run(self, user_prompt: str, system_prompt: str) -> str:
"""
เริ่มลูป ReAct อัตโนมัติ: ควบคุมงบประมาณ Token ทุกรอบก่อนส่งเข้าโมเดล
"""
self.messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
for step in range(1, self.max_iterations + 1):
# [จุดสำคัญ Day 8]: รูดหน้าต่างตัดทอนข้อความก่อนยิงเรียก Ollama ทุกครั้ง
active_context = self.memory.trim(self.messages)
est_tokens = self.memory.estimate_tokens(active_context)
print(f"\n🔄 [Step {step}/{self.max_iterations}] Agent ประมวลผล (Messages: {len(active_context)} | ~{est_tokens} Tokens)...")
# ยิงข้อความที่ผ่านการ Trim แล้วเข้า Ollama
raw_response = self.client.chat(active_context, temperature=0.1, json_mode=True)
try:
decision = StructuredOutputParser.parse(raw_response)
except JSONDecodeError as err:
print(f"⚠️ โมเดลส่ง JSON ไม่สมบูรณ์: {err}")
self.messages.append({"role": "assistant", "content": raw_response})
self.messages.append({
"role": "user",
"content": "Error: Your previous response was not valid JSON. Please respond strictly in the defined JSON schema."
})
continue
thought = decision.get("thought", "ไม่มี Thought ระบุ")
print(f"💡 Thought: {thought}")
# 1. ตรวจสอบเงื่อนไขจบงาน
if "final_answer" in decision:
final_answer = decision["final_answer"]
print(f"🎯 Final Answer: {final_answer}")
return final_answer
# 2. หากโมเดลต้องการเรียก Tool
if "tool" in decision:
tool_name = decision.get("tool")
arguments = decision.get("arguments", {})
print(f"🛠️ Action: เรียกใช้ Tool '{tool_name}' ด้วยค่า: {arguments}")
try:
observation = self.dispatcher.dispatch(tool_name, arguments)
except ToolExecutionError as err:
observation = f"Tool Execution Error: {str(err)}"
except Exception as err:
observation = f"System Error while executing tool: {str(err)}"
obs_summary = str(observation).strip()
if len(obs_summary) > 200:
print(f"👁️ Observation: {obs_summary[:200]}... [ตัดทอน]")
else:
print(f"👁️ Observation:\n{obs_summary}")
# บันทึกประวัติรอบนี้เข้า self.messages สะสมไว้
self.messages.append({"role": "assistant", "content": raw_response})
self.messages.append({
"role": "user",
"content": f"Observation from {tool_name}:\n{observation}"
})
else:
self.messages.append({"role": "assistant", "content": raw_response})
self.messages.append({
"role": "user",
"content": "Invalid State: You must provide either a 'tool' or a 'final_answer'."
})
raise MaxIterationsReachedError(
f"Agent reached maximum iterations limit ({self.max_iterations}) without completing the task."
)
from memory.context import SlidingWindowContext
def main():
print("🚀 เริ่มทดสอบ Day 8: Sliding Window Memory Management บน Mac M3...")
# ตั้งค่างบประมาณ: เก็บ System Prompt + ข้อความ 6 อันล่าสุด
memory_manager = SlidingWindowContext(keep_last_turns=6)
# 1. จำลองประวัติบทสนทนาขนาดยาว 13 ข้อความ (System 1 + สนทนาไปมา 12 ข้อความ)
simulated_messages = [
{"role": "system", "content": "You are a Senior Autonomous SE Agent."}, # Index 0: Anchor
{"role": "user", "content": "รอบที่ 1: ช่วยตรวจไฟล์ A หน่อย"},
{"role": "assistant", "content": "รอบที่ 1: กำลังอ่านไฟล์ A..."},
{"role": "user", "content": "รอบที่ 2: ช่วยตรวจไฟล์ B หน่อย"},
{"role": "assistant", "content": "รอบที่ 2: กำลังอ่านไฟล์ B..."},
{"role": "user", "content": "รอบที่ 3: ช่วยรันเทสรอบที่ 1"},
{"role": "assistant", "content": "รอบที่ 3: เทสล้มเหลวที่จุด X"},
{"role": "user", "content": "รอบที่ 4: ช่วยแก้โค้ดที่จุด X"},
{"role": "assistant", "content": "รอบที่ 4: แก้ไขโค้ดเรียบร้อย"},
{"role": "user", "content": "รอบที่ 5: ช่วยรันเทสรอบที่ 2"},
{"role": "assistant", "content": "รอบที่ 5: เทสผ่านเรียบร้อย"},
{"role": "user", "content": "รอบที่ 6: ช่วยสรุปผลลัพธ์ให้หน่อย"},
{"role": "assistant", "content": "รอบที่ 6: ผลลัพธ์สมบูรณ์ 100%"}
]
print(f"📊 จำนวนข้อความดั้งเดิมก่อนตัด: {len(simulated_messages)} ข้อความ")
print(f"📈 ประมาณการ Token ก่อนตัด: ~{memory_manager.estimate_tokens(simulated_messages)} Tokens")
# 2. สั่ง Trim ข้อความ
trimmed_messages = memory_manager.trim(simulated_messages)
print("\n" + "=" * 60)
print(f"✂️ ผลลัพธ์หลังผ่าน SlidingWindowContext.trim(): {len(trimmed_messages)} ข้อความ")
print(f"📉 ประมาณการ Token หลังตัด: ~{memory_manager.estimate_tokens(trimmed_messages)} Tokens (ประหยัดแรม Mac M3)")
print("=" * 60)
# 3. ตรวจสอบว่า Anchor ยังอยู่ครบไหม
assert trimmed_messages[0]["role"] == "system", "❌ ผิดพลาด: System Prompt หลุดหาย!"
assert trimmed_messages[0]["content"] == "You are a Senior Autonomous SE Agent.", "❌ เนื้อหา System Prompt เพี้ยน!"
assert len(trimmed_messages) == 7, f"❌ คาดหวัง 7 ข้อความ (System 1 + 6 ล่าสุด) แต่ได้ {len(trimmed_messages)}"
print("✅ ตรวจสอบข้อความที่ถูกเก็บไว้ใน Context Window:")
for idx, msg in enumerate(trimmed_messages):
prefix = "📌 [ANCHOR]" if idx == 0 else f"🪟 [WINDOW {idx}]"
print(f" {prefix} ({msg['role']}): {msg['content'][:50]}")
print("\n🎉 ผ่านการทดสอบ 100%! ข้อความเก่ารอบที่ 1-3 ถูก Evict ทิ้งอย่างสะอาดเอี่ยม ป้องกัน Token ระเบิดแรมแน่นอน!")
if __name__ == "__main__":
main()
เปิด Terminal บน MacBook M3 แล้วทำตาม 4 ขั้นตอนนี้เพื่อติดตั้ง Memory Management ให้ Agent ของคุณทันที:
cd ~/se_agent
source .venv/bin/activate
mkdir -p memory
touch memory/__init__.py memory/context.py test_sliding_window.py
- นำโค้ดใน ส่วนที่ 4 (ไฟล์ที่ 1.1) ไปวางใน memory/__init__.py
- นำโค้ดใน ส่วนที่ 4 (ไฟล์ที่ 1.2) ไปวางใน memory/context.py
- นำโค้ดใน ส่วนที่ 4 (ไฟล์ที่ 2) ไปอัปเดตทับไฟล์ core/engine.py
- นำโค้ดใน ส่วนที่ 4 (ไฟล์ที่ 3) ไปวางใน test_sliding_window.py
python test_sliding_window.py
🎉 ผลลัพธ์: คุณจะเห็นว่าข้อความจำลอง 13 ข้อความ ถูกตัดทอนเหลือ 7 ข้อความ (System Prompt ตัวเดิมถูกตรึงไว้ + 6 ข้อความล่าสุด) ประหยัด Token ไปกว่า 50% และป้องกัน OOM บน Mac 16GB ได้อย่างสมบูรณ์แบบ!
ข้อที่ 1: วัตถุประสงค์หลักของการทำ "Sliding Window Context & Memory Management" ใน Day 8 คืออะไร?
ข้อที่ 2: ปรากฏการณ์ "Attention Quadratic Slowdown ($O(N^2)$)" เมื่อปล่อยให้ข้อความสะสมยาวเกินไปส่งผลอย่างไร?
ข้อที่ 3: เทคนิค "Anchor Pinning" ในการทำ Sliding Window หมายถึงอะไร?
ข้อที่ 4: หากเผลอตัด System Prompt ทิ้งไปพร้อมกับข้อความเก่า ผลลัพธ์ที่ตามมาคืออะไร?
ข้อที่ 5: ไวยากรณ์ Python `messages[-self.keep_last_turns:]` มีการทำงานอย่างไร?
ข้อที่ 6: ปรากฏการณ์ "Lost in the Middle" ในโมเดลภาษาขนาด 7B-14B หมายถึงอะไร?
ข้อที่ 7: ค่า `keep_last_turns = 6` หมายถึงการรักษาประวัติข้อความอย่างไร?
ข้อที่ 8: ในการจัดการหน่วยความจำบน Mac M3 16GB อะไรคือผลกระทบเมื่อเกิด "Memory Swap"?
ข้อที่ 9: ฟังก์ชัน `estimate_tokens()` ใน `SlidingWindowContext` ใช้วิธีใดในการประเมิน Token โดยไม่ต้องลง Library เพิ่ม?
ข้อที่ 10: หากเราตัดข้อความเก่าทิ้งไป แล้ว Agent ต้องการข้อมูลที่เคยคุยเมื่อ 20 นาทีก่อน เราจะแก้ปัญหานี้ใน Day 9 อย่างไร?
ข้อที่ 11: การที่ `core/engine.py` เรียกใช้ `self.memory.trim()` ก่อนส่งเข้า `self.client.chat()` สอดคล้องกับหลักการใด?
ข้อที่ 12: การแยกโมดูล `memory/context.py` ออกมาจาก `core/engine.py` มีประโยชน์อย่างไรตาม Clean Architecture?
ข้อที่ 13: ในระบบ Interactive Shell CLI ที่ผู้ใช้คุยต่อเนื่องเป็นชั่วโมง Sliding Window มีบทบาทสำคัญอย่างไร?
ข้อที่ 14: หลังจากที่เราสร้าง Sliding Window Memory สำเร็จใน Day 8 ใน **Day 9 (ChromaDB Local Vector Store)** เราจะสร้างอะไรต่อ?
ข้อที่ 15: ในฐานะวิศวกรซอฟต์แวร์ เมื่อคุณสามารถควบคุม Context Window และ Memory ของ Local Agent ได้สำเร็จ รู้สึกอย่างไร?
🏁 สรุปหมุดเชื่อมโยง Day 8 (Chapter Wrap-up)
ใน Day 8 — Memory Management: Sliding Window Context & Conversation Trimming คุณได้สร้างโมดูล memory/context.py นำเทคนิค Anchor Pinning มาตรึง System Prompt ไว้ที่ Index 0 อย่างมั่นคง พร้อมตัดทอนประวัติข้อความเก่าทิ้งด้วย Sliding Window ควบคุมงบประมาณ Token ให้คงที่สม่ำเสมอ รันเร็ว ไม่กินแรม Mac M3 16GB และไม่มีทางเกิด OOM ตอนนี้สมองกลของคุณมีความจำระยะสั้นที่เสถียรระดับโปรดักชันแล้ว พร้อมก้าวเข้าสู่ Day 9: ChromaDB Local Vector Store & Code Embeddings เพื่อสร้างคลังความจำระยะยาวสืบค้นโค้ดตามความหมายต่อไปครับ!