diff --git a/README.md b/README.md index 650aebb..5919565 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ planner.set_goal( - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다. - 재시도(attempt>0)에서는 `max_tokens`를 줄여(기본 `900`) 분석 텍스트가 길어져 JSON이 잘리는 패턴을 줄입니다. - `JSON-only repair` 프롬프트가 붙는 경우에는 `max_tokens`를 더 낮추고(기본 `250`) `temperature`도 더 낮춰(기본 `0.0`) JSON 포맷 준수율을 올리려 요청합니다. +- `JSON-only repair`를 한 번 적용했는데도 비JSON 텍스트가 계속 오면, 불필요한 API 재시도 반복 대신 즉시 상태 기반 휴리스틱 폴백으로 전환합니다. 또한 LLM이 `actions=[]`를 반환하면 같은 이유로 휴리스틱 플랜으로 즉시 대체합니다. - `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 3회 재시도한 뒤, **상태 요약에 나온 광맥(앵커) 좌표가 있으면 `mine_resource`(먼 경우 `move` 후 채굴)로 폴백**하고, 광맥 정보가 없을 때만 `explore` 방향을 순환하며 탐색합니다(동일 방향 탐색 루프 완화). - GLM이 `HTTP 429 (Rate limit)`에 걸리면 `Retry-After`가 있으면 그만큼 기다렸다가(없으면 기본 백오프) 재시도하도록 처리합니다. - GLM HTTP 읽기 제한 시간은 기본 120초이며, `GLM_HTTP_TIMEOUT_SECONDS`로 조정할 수 있습니다. 광맥은 플레이어와 200타일 이상 떨어진 경우에만 폴백에서 `move`를 끼우며, 임계값은 `GLM_FALLBACK_MOVE_THRESHOLD`(기본 200)로 바꿀 수 있습니다. diff --git a/ai_planner.py b/ai_planner.py index 7d2f942..91e0e75 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -204,6 +204,7 @@ class AIPlanner: # JSON 스키마를 위반한 경우(예: 분석 텍스트만 반환)에는 재시도 프롬프트를 강화한다. repair_suffix = "" + repair_applied_once = False for attempt in range(3): try: @@ -219,6 +220,19 @@ class AIPlanner: OSError, ) as e: detail = describe_glm_exception(e) + parse_no_brace = isinstance(e, ValueError) and "JSON 파싱 실패 ('{' 없음)" in str(e) + + # JSON-only repair를 이미 한 번 적용했는데도 여전히 비JSON 텍스트만 오는 경우: + # 추가 API 호출을 반복하지 말고 즉시 상태 기반 휴리스틱으로 진행한다. + if parse_no_brace and repair_applied_once: + if _glm_debug_enabled(): + print("[GLM][디버그] repair 후에도 비JSON 응답 -> 즉시 휴리스틱 폴백") + plan = self._fallback_plan_from_summary( + state_summary, + last_error=detail, + ) + break + if attempt < 2: if isinstance(e, ValueError) and ( str(e).startswith("JSON 파싱 실패") @@ -231,6 +245,7 @@ class AIPlanner: "아래 JSON 스키마를 그대로(키/구조 동일) 반환하세요:\n" "{\"thinking\":\"\",\"current_goal\":\"\",\"actions\":[{\"action\":\"wait\",\"params\":{\"seconds\":1},\"reason\":\"repair\"}],\"after_this\":\"재시도\"}" ) + repair_applied_once = True if _glm_debug_enabled(): print("[GLM][디버그] JSON-only repair 프롬프트 적용") # 429 Rate limit이면 prompt 길이를 늘리면 안 되므로 repair_suffix를 끈다. @@ -275,10 +290,24 @@ class AIPlanner: if thinking: print(f"\n[AI] 판단:\n{thinking}\n") + actions = plan.get("actions", []) + if not isinstance(actions, list): + actions = [] + if not actions: + # 모델이 형식은 맞췄지만 actions가 비어 있으면 main 루프가 10초 대기 재시도만 반복한다. + # 이런 경우도 즉시 휴리스틱 플랜으로 전환해 진행을 유지한다. + plan = self._fallback_plan_from_summary( + state_summary, + last_error="LLM returned empty actions", + ) + thinking = plan.get("thinking", "") + if thinking: + print(f"\n[AI] 판단:\n{thinking}\n") + actions = plan.get("actions", []) + print(f"[AI] 현재 목표: {plan.get('current_goal', '')}") print(f"[AI] 완료 후: {plan.get('after_this', '')}") - actions = plan.get("actions", []) actions = self._ensure_move_before_build_actions(actions) print(f"[AI] {len(actions)}개 행동 계획됨") return actions