agent ที่วน tool call ไม่รู้จบ คือบิลค่า API ที่บานปลายและ request ที่ไม่มีวันจบ เมื่อผู้ให้บริการเจ้าหนึ่ง ล่ม ทั้งระบบต้องไม่ล่มตาม โมดูลนี้ว่าด้วยการตั้งเพดานการทำงาน ตรวจจับ loop ใน DAG/state machine และ fallback ข้ามผู้ให้บริการพร้อม backoff
ปัญหา: agent วนไม่รู้จบ
agent ที่ใช้ state machine หรือ DAG (เช่น LangGraph, Hermes) อาจติดอยู่ในวงจร: เรียก tool A → ได้ผลที่ ทำให้เรียก tool B → ที่พากลับมา tool A อีกครั้ง โดยไม่คืบหน้า ถ้าไม่มีเพดาน มันจะวนจนหมด token, หมดเงิน, หรือ timeout หลังจากเผาทรัพยากรไปมหาศาล
เพดาน state transition
ป้องกันชั้นแรกที่ถูกที่สุด: นับจำนวนก้าวแล้วหยุดเมื่อเกินเพดาน ทุก agent loop ต้องมีตัวนับนี้
1from dataclasses import dataclass, field23class StepBudgetExceeded(Exception):4 pass56@dataclass7class StepBudget:8 max_steps: int = 259 used: int = 01011 def tick(self) -> None:12 self.used += 113 if self.used > self.max_steps:14 raise StepBudgetExceeded(15 f"เกินเพดาน {self.max_steps} ก้าว - น่าจะติด loop"16 )ตรวจจับ loop ใน state machine
เพดานจำนวนก้าวจับ loop ได้ก็จริง แต่ช้า ตัวจับที่ฉลาดกว่าคือดู “ลายเซ็นของสถานะ” ถ้าเราเห็น state เดิม (node + อาร์กิวเมนต์เดิม) ซ้ำเกินจำนวนที่กำหนด แปลว่าวนอยู่กับที่ ตัดได้ทันที
1import hashlib2import json3from collections import Counter45class LoopDetected(Exception):6 pass78class LoopGuard:9 def __init__(self, max_repeats: int = 3):10 self.max_repeats = max_repeats11 self.seen: Counter[str] = Counter()1213 def _signature(self, node: str, payload: dict) -> str:14 # ลายเซ็น = node + อาร์กิวเมนต์ที่ normalize แล้ว15 body = json.dumps(payload, sort_keys=True, ensure_ascii=False)16 return hashlib.sha1(f"{node}:{body}".encode()).hexdigest()1718 def visit(self, node: str, payload: dict) -> None:19 sig = self._signature(node, payload)20 self.seen[sig] += 121 if self.seen[sig] > self.max_repeats:22 raise LoopDetected(23 f"node '{node}' ทำงานซ้ำด้วย input เดิม {self.seen[sig]} ครั้ง"24 )LangGraph: recursion limit
LangGraph มี recursion_limit built-in ใช้มันเป็นเพดานแข็ง แล้วเสริม LoopGuard ใน node เพื่อตัด loop ที่คืบช้า ๆ ก่อนถึงเพดาน
1from langgraph.graph import StateGraph, END2from langgraph.errors import GraphRecursionError3from .loop_detector import LoopGuard, LoopDetected45def build_agent():6 graph = StateGraph(AgentState)7 graph.add_node("plan", plan_node)8 graph.add_node("act", act_node)9 graph.add_edge("plan", "act")10 graph.add_conditional_edges("act", route, {"continue": "plan", "done": END})11 graph.set_entry_point("plan")12 return graph.compile()1314agent = build_agent()1516def run(task: str):17 guard = LoopGuard(max_repeats=3)18 try:19 return agent.invoke(20 {"task": task, "guard": guard},21 # เพดานแข็งของ LangGraph เอง22 config={"recursion_limit": 25},23 )24 except (GraphRecursionError, LoopDetected) as exc:25 # ไม่ปล่อยให้ crash - คืน partial result + ธง ให้ระบบจัดการต่อ26 return {"status": "halted", "reason": str(exc)}Fallback หลายผู้ให้บริการ
ผู้ให้บริการเจ้าเดียวล่มได้เสมอ (rate limit, 5xx, timeout) ออกแบบ client ให้ลองเรียงจากเจ้าหลักไปเจ้าสำรอง (Claude → Gemini) ด้วย interface เดียวกัน แยกระหว่าง error ที่ควร fallback (5xx, timeout, overload) กับ error ที่ไม่ควร (4xx จาก input ของเราเอง)
1import logging2from dataclasses import dataclass3from typing import Callable45log = logging.getLogger("llm")67class RetriableError(Exception):8 """5xx, overload, timeout - ลองเจ้าถัดไปได้"""910class FatalError(Exception):11 """4xx จาก input เรา - fallback ไปก็พังเหมือนเดิม อย่าลอง"""1213@dataclass14class Provider:15 name: str16 call: Callable[[str], str]1718def complete(prompt: str, providers: list[Provider]) -> str:19 last: Exception | None = None20 for provider in providers: # Claude ก่อน แล้ว Gemini21 try:22 return retry_with_backoff(lambda: provider.call(prompt))23 except FatalError:24 raise # input เราผิด ไม่ต้อง fallback25 except RetriableError as exc:26 log.warning("provider %s ล้มเหลว fallback ต่อ: %s", provider.name, exc)27 last = exc28 raise RuntimeError(f"ผู้ให้บริการล้มเหลวทั้งหมด: {last}")Exponential backoff + jitter
ก่อนจะ fallback ลองเจ้าเดิมซ้ำสักสองสามครั้งแบบเว้นช่วงเพิ่มขึ้น (exponential) พร้อม jitter เพื่อกระจาย การ retry ของหลาย client ไม่ให้ถล่มผู้ให้บริการพร้อมกัน
1import random2import time3from typing import Callable, TypeVar45T = TypeVar("T")67def retry_with_backoff(8 fn: Callable[[], T],9 *,10 max_attempts: int = 3,11 base_delay: float = 1.0,12 max_delay: float = 20.0,13) -> T:14 for attempt in range(max_attempts):15 try:16 return fn()17 except RetriableError:18 if attempt == max_attempts - 1:19 raise20 # exponential: base * 2^attempt, cap ที่ max_delay21 backoff = min(base_delay * (2 ** attempt), max_delay)22 # full jitter: สุ่มในช่วง [0, backoff] กัน thundering herd23 time.sleep(random.uniform(0, backoff))24 raise RuntimeError("unreachable")เช็กลิสต์ production
- ทุก agent loop ต้องมีเพดานก้าว + LoopGuard ห้ามมี loop ที่ไม่มีทางออก
- เมื่อหยุดเพราะ loop ให้คืน partial result + สถานะ ไม่ใช่ crash เงียบ ๆ
- แยก error ที่ควร fallback (5xx/timeout) ออกจากที่ไม่ควร (4xx จาก input เรา)
- retry ด้วย exponential backoff + full jitter ก่อนค่อย fallback ข้ามผู้ให้บริการ
- log ทุกครั้งที่ fallback + นับเป็น metric เพื่อรู้ว่าเจ้าหลักเริ่มไม่เสถียร
สรุปสำคัญ
- ทุก agent loop ต้องมีเพดานก้าว + ตัวตรวจจับ loop จากลายเซ็นของสถานะ
- แยก error ที่ควร fallback (5xx/timeout) ออกจากที่ไม่ควร (4xx จาก input เรา)
- retry ด้วย exponential backoff + full jitter ก่อนค่อย fallback ข้ามผู้ให้บริการ
ควิซท้ายบท
01การตั้ง 'เพดานจำนวนก้าว' (step budget) ในทุก agent loop มีไว้เพื่ออะไร
02ตัวตรวจจับ loop ที่ดู 'ลายเซ็นของสถานะ' (node + อาร์กิวเมนต์) ดีกว่าการนับจำนวนก้าวอย่างเดียวอย่างไร
03เมื่อออกแบบ fallback ข้ามผู้ให้บริการ (Claude สู่ Gemini) error ประเภทใดที่ 'ไม่ควร' fallback
04ทำไม 'full jitter' จึงดีกว่าการหน่วงเวลาคงที่ในการ retry