feat: mining_state 설정 최적화 및 GLM 예외 처리 개선
- `mine_resource` 함수에서 채굴 상태를 매 루프마다 반복 설정하던 로직을 제거하여, 우클릭 유지와 유사한 동작으로 최적화 - GLM API 호출 실패 시 예외 처리 로직을 개선하여, 다양한 오류 원인을 로그로 출력하고 상태 요약 기반의 폴백 로직 추가 - 새로운 연결 검사 스크립트 추가 및 README.md에 GLM API 연결 문제 디버깅 섹션 추가 - 관련 문서 및 주의사항 업데이트
This commit is contained in:
20
README.md
20
README.md
@@ -122,13 +122,30 @@ planner.set_goal(
|
||||
|
||||
---
|
||||
|
||||
## GLM API 연결 문제 디버깅
|
||||
|
||||
타임아웃·연결 오류가 나면 **원인을 로그로 먼저 구분**하는 것이 좋습니다.
|
||||
|
||||
1. **전용 검사 스크립트** (에이전트와 동일한 `urllib` 경로):
|
||||
|
||||
```bash
|
||||
ZAI_API_KEY="your-key" python scripts/glm_connection_check.py
|
||||
```
|
||||
|
||||
2. **실행 시 상세 로그**: `GLM_DEBUG=1`을 켜면 재시도마다 `[GLM 원인] …` 한 줄에 `URLError.reason`, SSL/소켓 `errno`, DNS 힌트 등이 붙고, 스택 트레이스도 출력됩니다.
|
||||
|
||||
3. **자주 있는 원인**: Docker/서버에서 **외부 HTTPS(443) 차단**, **프록시 필요**( `HTTPS_PROXY` ), **DNS 실패**, **API 키 만료·오타**, **응답이 느려 타임아웃**( `GLM_HTTP_TIMEOUT_SECONDS` 증가 ).
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
|
||||
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
||||
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
||||
- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다.
|
||||
- `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 재시도 후 폴백(explore)합니다.
|
||||
- `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 3회 재시도한 뒤, **상태 요약에 나온 광맥(앵커) 좌표가 있으면 `mine_resource`(먼 경우 `move` 후 채굴)로 폴백**하고, 광맥 정보가 없을 때만 `explore` 방향을 순환하며 탐색합니다(동일 방향 탐색 루프 완화).
|
||||
- GLM HTTP 읽기 제한 시간은 기본 120초이며, `GLM_HTTP_TIMEOUT_SECONDS`로 조정할 수 있습니다. 광맥은 플레이어와 200타일 이상 떨어진 경우에만 폴백에서 `move`를 끼우며, 임계값은 `GLM_FALLBACK_MOVE_THRESHOLD`(기본 200)로 바꿀 수 있습니다.
|
||||
- `ai_planner.py`의 `_call_glm()`에서 GLM 지연 원인 분석을 위한 타이밍 로그가 출력됩니다.
|
||||
- `total`: 요청 시작~콘텐츠 반환까지 전체 소요
|
||||
- `http_read`: HTTP 응답 본문 수신까지 소요
|
||||
@@ -144,6 +161,7 @@ planner.set_goal(
|
||||
- 또한 `main inventory`가 비어 캐시 fallback이 동작하면, 로드된 캐시의 `items/total`도 함께 출력합니다.
|
||||
- `mine_resource`에서 실패한 채굴 타일 제외(`exclude`)는 Lua와 Python 양쪽에서 정수 타일 좌표(`tx, ty`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다.
|
||||
- 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다.
|
||||
- `mine_resource`는 `LuaControl.mining_state`를 **해당 타깃에 대해 한 번만** 설정합니다. 이전에는 대기 루프마다 같은 값을반복 설정해 우클릭을 연타하는 것과 비슷한 동작이었으며, 이제는 인간이 우클릭을 누른 채로 두는 것에 가깝게 유지합니다.
|
||||
- `mine_resource`는 move 후 `p.position` 근처(반경 1.2)에서 실제로 해당 광물 엔티티가 있는지 재확인하고, 없으면 채굴을 시도하지 않고 다음 후보로 넘어갑니다.
|
||||
- `scan_resources()`는 패치 평균 중심(`center_x/y`)만이 아니라 플레이어 기준 가장 가까운 실제 엔티티 좌표(`anchor_x/y`)도 함께 반환하며, `summarize_for_ai()`에서는 앵커를 기준으로 거리/추천을 계산합니다.
|
||||
- `mine_resource`의 초기 “후보 엔티티 탐색” 반경은 패치 중심 오차를 흡수하기 위해 250으로 확장해, 이동 직후 바로 실패하는 케이스를 줄입니다.
|
||||
|
||||
@@ -391,16 +391,17 @@ if res and #res > 0 then rcon.print("YES") else rcon.print("NO") end
|
||||
continue
|
||||
|
||||
# 3. 현재 위치에서 채굴 시도
|
||||
# 우클릭 “유지”: mining_state를 매 루프마다 다시 쓰면 릴리즈 후 재클릭처럼 동작해
|
||||
# 진행이 끊기고 RCON도 과도하게 호출된다. 한 번만 설정하고 틱 동안 그대로 둔다.
|
||||
self.rcon.lua(P + f"""
|
||||
p.update_selected_entity({{x = {mine_x}, y = {mine_y}}})
|
||||
p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}}
|
||||
""")
|
||||
stall_count = 0
|
||||
last_item = self._get_item_count(ore)
|
||||
mined_this_tile = False
|
||||
|
||||
for tick in range(300): # 최대 30초
|
||||
# mining_state로 “우클릭 유지”에 가까운 수동 채굴을 시뮬레이션
|
||||
self.rcon.lua(P + f"""
|
||||
p.update_selected_entity({{x = {mine_x}, y = {mine_y}}})
|
||||
p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}}
|
||||
""")
|
||||
time.sleep(0.1)
|
||||
|
||||
if tick % 8 == 7:
|
||||
|
||||
215
ai_planner.py
215
ai_planner.py
@@ -18,6 +18,7 @@ import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import traceback
|
||||
|
||||
|
||||
GLM_API_URL = "https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
@@ -118,6 +119,56 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션
|
||||
## 절대 중요: 순수 JSON만 출력하세요. ```json 같은 마크다운 블록, 설명 텍스트, 주석 없이 오직 { } 만."""
|
||||
|
||||
|
||||
def _glm_debug_enabled() -> bool:
|
||||
v = os.environ.get("GLM_DEBUG", "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def describe_glm_exception(exc: BaseException) -> str:
|
||||
"""
|
||||
GLM HTTP 호출 실패 시 원인 분류용 문자열.
|
||||
- URLError.reason 안의 SSL/소켓 예외, errno 등을 함께 적어
|
||||
'TimeoutError' 한 줄만으로는 알 수 없는 정보를 남긴다.
|
||||
"""
|
||||
parts: list[str] = [type(exc).__name__]
|
||||
msg = str(exc).strip()
|
||||
if msg:
|
||||
parts.append(msg[:800])
|
||||
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
r = exc.reason
|
||||
parts.append(f"URLError.reason={type(r).__name__}: {r!s}"[:500])
|
||||
if isinstance(r, OSError):
|
||||
parts.append(f"reason.errno={getattr(r, 'errno', None)!r}")
|
||||
we = getattr(r, "winerror", None)
|
||||
if we is not None:
|
||||
parts.append(f"reason.winerror={we!r}")
|
||||
inner = getattr(r, "__cause__", None) or getattr(r, "__context__", None)
|
||||
if inner is not None:
|
||||
parts.append(f"chained={type(inner).__name__}: {inner!s}"[:300])
|
||||
|
||||
if isinstance(exc, OSError) and not isinstance(exc, urllib.error.URLError):
|
||||
parts.append(f"errno={exc.errno!r}")
|
||||
if getattr(exc, "winerror", None) is not None:
|
||||
parts.append(f"winerror={exc.winerror!r}")
|
||||
|
||||
# 힌트(자주 나오는 케이스)
|
||||
low = " ".join(parts).lower()
|
||||
if "timed out" in low or "timeout" in low:
|
||||
parts.append(
|
||||
"hint=응답 수신 지연/차단 — GLM_HTTP_TIMEOUT_SECONDS 증가, "
|
||||
"서버·Docker 네트워크 egress, 프록시(HTTPS_PROXY) 확인"
|
||||
)
|
||||
if "connection refused" in low or "econnrefused" in low:
|
||||
parts.append("hint=대상 포트 닫힘 또는 잘못된 호스트(프록시/방화벽)")
|
||||
if "certificate" in low or "ssl" in low or "tls" in low:
|
||||
parts.append("hint=SSL 검증 실패 — 기업 프록시/자체 CA, 시스템 시간 오차")
|
||||
if "name or service not known" in low or "getaddrinfo" in low:
|
||||
parts.append("hint=DNS 실패 — 컨테이너/호스트 resolv.conf")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
class AIPlanner:
|
||||
def __init__(self):
|
||||
self.api_key = os.environ.get("ZAI_API_KEY", "")
|
||||
@@ -126,6 +177,8 @@ class AIPlanner:
|
||||
|
||||
self.step = 0
|
||||
self.feedback_log: list[dict] = []
|
||||
# GLM 전부 실패 시 explore 방향 순환 (동일 방향 탐색 루프 완화)
|
||||
self._fallback_explore_turn = 0
|
||||
self.long_term_goal = (
|
||||
"완전 자동화 달성: "
|
||||
"석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → "
|
||||
@@ -154,23 +207,33 @@ class AIPlanner:
|
||||
raw = self._call_glm(user_message, attempt=attempt)
|
||||
plan = self._parse_json(raw)
|
||||
break
|
||||
except (ValueError, json.JSONDecodeError, TimeoutError, ConnectionError, urllib.error.URLError) as e:
|
||||
except (
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
TimeoutError,
|
||||
ConnectionError,
|
||||
urllib.error.URLError,
|
||||
OSError,
|
||||
) as e:
|
||||
detail = describe_glm_exception(e)
|
||||
if attempt < 2:
|
||||
print(
|
||||
f"[경고] GLM 처리 실패 (시도 {attempt+1}/3): "
|
||||
f"{type(e).__name__} 재시도..."
|
||||
)
|
||||
time.sleep(1 + attempt * 2)
|
||||
print(f" [GLM 원인] {detail}")
|
||||
if _glm_debug_enabled():
|
||||
traceback.print_exc()
|
||||
time.sleep(2 + attempt * 3)
|
||||
continue
|
||||
print(f"[오류] GLM 처리 3회 실패. 기본 탐색 행동 사용.")
|
||||
plan = {
|
||||
"thinking": "API 응답 파싱 실패로 기본 탐색 수행",
|
||||
"current_goal": "주변 탐색",
|
||||
"actions": [
|
||||
{"action": "explore", "params": {"direction": "east", "max_steps": 200}, "reason": "자원 탐색"},
|
||||
],
|
||||
"after_this": "자원 발견 후 채굴 시작"
|
||||
}
|
||||
print(f"[오류] GLM 처리 3회 실패. 상태 요약 기반 휴리스틱 폴백 사용.")
|
||||
print(f" [GLM 원인] {detail}")
|
||||
if _glm_debug_enabled():
|
||||
traceback.print_exc()
|
||||
plan = self._fallback_plan_from_summary(
|
||||
state_summary,
|
||||
last_error=detail,
|
||||
)
|
||||
|
||||
thinking = plan.get("thinking", "")
|
||||
if thinking:
|
||||
@@ -220,6 +283,14 @@ class AIPlanner:
|
||||
prompt_chars = len(user_message)
|
||||
system_chars = len(SYSTEM_PROMPT)
|
||||
max_tokens = 2000
|
||||
http_timeout = float(os.environ.get("GLM_HTTP_TIMEOUT_SECONDS", "120"))
|
||||
|
||||
if _glm_debug_enabled():
|
||||
print(
|
||||
f"[GLM][디버그] POST {GLM_API_URL} | "
|
||||
f"timeout={http_timeout}s | payload_bytes={len(payload)} | "
|
||||
f"model={GLM_MODEL}"
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
GLM_API_URL,
|
||||
@@ -235,7 +306,7 @@ class AIPlanner:
|
||||
t_payload0 = time.perf_counter()
|
||||
# payload 직렬화 직후(대략)부터 타임라인 측정
|
||||
_t0 = time.perf_counter()
|
||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||
with urllib.request.urlopen(req, timeout=http_timeout) as resp:
|
||||
raw_text = resp.read().decode("utf-8")
|
||||
t_read_done = time.perf_counter()
|
||||
|
||||
@@ -262,7 +333,14 @@ class AIPlanner:
|
||||
)
|
||||
return content
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}")
|
||||
body = ""
|
||||
try:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
raise ConnectionError(
|
||||
f"GLM API HTTP {e.code}: {body[:1200]}"
|
||||
) from e
|
||||
|
||||
def _parse_json(self, raw: str) -> dict:
|
||||
text = raw.strip()
|
||||
@@ -354,3 +432,114 @@ class AIPlanner:
|
||||
self.long_term_goal = goal
|
||||
self.feedback_log.clear()
|
||||
print(f"[AI] 새 목표: {goal}")
|
||||
|
||||
def _fallback_plan_from_summary(self, state_summary: str, last_error: str = "") -> dict:
|
||||
"""
|
||||
GLM 실패 시에도 상태 요약(주변 패치·기억된 광맥 좌표)이 있으면
|
||||
무한 explore 루프 대신 mine_resource / move 를 선택한다.
|
||||
"""
|
||||
pos = self._parse_player_position(state_summary)
|
||||
if pos is None:
|
||||
px = py = None
|
||||
else:
|
||||
px, py = pos
|
||||
anchors = self._parse_ore_anchors(state_summary)
|
||||
|
||||
ore_priority = {"iron-ore": 0, "copper-ore": 1, "coal": 2, "stone": 3}
|
||||
best: tuple[str, int, int, float] | None = None
|
||||
best_key: tuple | None = None
|
||||
|
||||
for ore, ox, oy in anchors:
|
||||
if px is not None and py is not None:
|
||||
dist = ((ox - px) ** 2 + (oy - py) ** 2) ** 0.5
|
||||
else:
|
||||
dist = 0.0
|
||||
key = (ore_priority.get(ore, 9), dist)
|
||||
if best_key is None or key < best_key:
|
||||
best_key = key
|
||||
best = (ore, ox, oy, dist)
|
||||
|
||||
if best is not None:
|
||||
ore, ox, oy, dist = best
|
||||
move_threshold = float(os.environ.get("GLM_FALLBACK_MOVE_THRESHOLD", "200"))
|
||||
actions: list[dict] = []
|
||||
if px is not None and py is not None and dist > move_threshold:
|
||||
actions.append({
|
||||
"action": "move",
|
||||
"params": {"x": ox, "y": oy},
|
||||
"reason": f"GLM 폴백: 광맥까지 약 {dist:.0f}타일 — 먼저 이동",
|
||||
})
|
||||
actions.append({
|
||||
"action": "mine_resource",
|
||||
"params": {"ore": ore, "count": 35},
|
||||
"reason": "GLM 폴백: 상태에 표시된 인근 광맥 채굴",
|
||||
})
|
||||
err_note = f" ({last_error})" if last_error else ""
|
||||
return {
|
||||
"thinking": (
|
||||
f"GLM API를 사용할 수 없어 상태 요약의 광맥({ore}, 앵커 {ox},{oy})으로 "
|
||||
f"채굴을 시도합니다.{err_note}"
|
||||
),
|
||||
"current_goal": f"{ore} 채굴 (휴리스틱)",
|
||||
"actions": actions,
|
||||
"after_this": "GLM 복구 시 정상 계획으로 복귀",
|
||||
}
|
||||
|
||||
dirs = [
|
||||
"east", "north", "south", "west",
|
||||
"northeast", "northwest", "southeast", "southwest",
|
||||
]
|
||||
self._fallback_explore_turn += 1
|
||||
direction = dirs[(self._fallback_explore_turn - 1) % len(dirs)]
|
||||
err_note = f" ({last_error})" if last_error else ""
|
||||
return {
|
||||
"thinking": (
|
||||
f"GLM API 실패이며 상태 요약에서 광맥 좌표를 찾지 못해 "
|
||||
f"{direction} 방향으로 탐색합니다.{err_note}"
|
||||
),
|
||||
"current_goal": "주변 탐색 (휴리스틱)",
|
||||
"actions": [
|
||||
{
|
||||
"action": "explore",
|
||||
"params": {"direction": direction, "max_steps": 200},
|
||||
"reason": "GLM 폴백: 광맥 정보 없음 — 탐색",
|
||||
},
|
||||
],
|
||||
"after_this": "자원 발견 후 채굴",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_player_position(text: str) -> tuple[float, float] | None:
|
||||
m = re.search(
|
||||
r"-\s*위치:\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)",
|
||||
text,
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
|
||||
@staticmethod
|
||||
def _parse_ore_anchors(text: str) -> list[tuple[str, int, int]]:
|
||||
"""주변 스캔 '앵커' 좌표와 기억된 광맥 (tile_x, tile_y) 파싱."""
|
||||
seen: set[tuple[str, int, int]] = set()
|
||||
out: list[tuple[str, int, int]] = []
|
||||
|
||||
for m in re.finditer(
|
||||
r"-\s*(iron-ore|copper-ore|coal|stone):\s*\d+타일\s*\(\s*앵커:\s*(-?\d+)\s*,\s*(-?\d+)\s*\)",
|
||||
text,
|
||||
):
|
||||
t = (m.group(1), int(m.group(2)), int(m.group(3)))
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
|
||||
for m in re.finditer(
|
||||
r"-\s*(iron-ore|copper-ore|coal|stone):\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)",
|
||||
text,
|
||||
):
|
||||
t = (m.group(1), int(m.group(2)), int(m.group(3)))
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
|
||||
return out
|
||||
|
||||
53
docs/plan.md
53
docs/plan.md
@@ -1,3 +1,19 @@
|
||||
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
- `mine_resource` 내부에서 약 0.1초마다 `update_selected_entity` + `mining_state = { mining = true, ... }`를 RCON으로 재전송하고 있었음.
|
||||
- 플레이어는 보통 우클릭을 **누른 채 유지**하는데, 스크립트는 매번 상태를 다시 써서 **뗐다 누르는(re-trigger)** 것에 가까운 동작이 됨.
|
||||
|
||||
### 변경
|
||||
- 이동·근처 광물 확인 후, 채굴 루프 **진입 직전에 한 번만** `mining_state` 설정.
|
||||
- 루프에서는 `sleep`과 인벤토리/채굴 진행률 **읽기만** 수행. 종료·목표 달성 시에만 `mining = false`.
|
||||
|
||||
### 범위
|
||||
- `action_executor.py` — `mine_resource`
|
||||
- `README.md` — 동작 설명 한 줄 보강
|
||||
|
||||
---
|
||||
|
||||
## 채굴 실패 시 제외 좌표 반복 버그 수정 계획
|
||||
|
||||
### 문제 재현/관찰
|
||||
@@ -202,7 +218,7 @@
|
||||
### 변경 목표
|
||||
1. `AIPlanner.decide()`의 재시도 예외 범위를 `TimeoutError`, `ConnectionError`, `urllib.error.URLError`까지 확장
|
||||
2. 해당 오류가 발생하면 동일한 “plan 응답 받기” 재시도로 복구 시도
|
||||
3. 3회 연속 실패 시에는 기존과 동일하게 `explore` 기본 행동으로 폴백
|
||||
3. 3회 연속 실패 시에는 상태 요약 기반 휴리스틱(광맥 있으면 채굴/이동, 없으면 explore 방향 순환)으로 폴백
|
||||
|
||||
### 구현 범위
|
||||
- `ai_planner.py`
|
||||
@@ -211,3 +227,38 @@
|
||||
### README 업데이트
|
||||
- GLM read timeout/연결 오류 발생 시 재시도 동작을 `주의사항`에 추가
|
||||
|
||||
---
|
||||
|
||||
## GLM 전부 실패 시 explore 무한 루프 완화 (상태 기반 폴백)
|
||||
|
||||
### 문제 관찰
|
||||
- GLM API가 `TimeoutError`/`ConnectionError`로 연속 실패하면 폴백이 항상 `explore`(고정 east)만 선택됨
|
||||
- 이미 `주변 자원 패치`·`기억된 광맥`에 철/석탄 좌표가 있는데도 탐색만 반복되어 진행이 멈춤
|
||||
|
||||
### 변경 목표
|
||||
1. `state_reader.summarize_for_ai()` 텍스트에서 플레이어 위치·앵커/기억 광맥 좌표를 정규식으로 추출
|
||||
2. 광맥이 있으면 `mine_resource` 우선(거리가 크면 `move` 선행), 없을 때만 `explore` 방향 순환
|
||||
3. 기본 HTTP 타임아웃 90초→120초, 재시도 간격 소폭 완화
|
||||
|
||||
### 구현 범위
|
||||
- `ai_planner.py`: `_fallback_plan_from_summary`, `_parse_player_position`, `_parse_ore_anchors`, 환경변수 `GLM_HTTP_TIMEOUT_SECONDS`, `GLM_FALLBACK_MOVE_THRESHOLD`
|
||||
|
||||
### README 업데이트
|
||||
- 폴백 동작 및 환경변수 설명 반영
|
||||
|
||||
---
|
||||
|
||||
## GLM 예외 상세 로그 + 연결 검사 스크립트
|
||||
|
||||
### 목표
|
||||
- `TimeoutError`/`ConnectionError` 한 줄만으로는 DNS·SSL·프록시·HTTP 본문 오류를 구분하기 어려움
|
||||
- `URLError.reason`·체인 예외·errno를 항상 출력하고, `GLM_DEBUG=1`에서 스택까지 확보
|
||||
- `scripts/glm_connection_check.py`로 최소 POST만 수행해 네트워크/API 키를 분리 진단
|
||||
|
||||
### 구현
|
||||
- `ai_planner.describe_glm_exception()`, `_glm_debug_enabled()`
|
||||
- `scripts/glm_connection_check.py`
|
||||
|
||||
### README
|
||||
- `GLM API 연결 문제 디버깅` 절 추가
|
||||
|
||||
|
||||
87
scripts/glm_connection_check.py
Normal file
87
scripts/glm_connection_check.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GLM API 연결만 검사 (main.py와 동일한 urllib + URL).
|
||||
에이전트 실행 전/타임아웃 원인 조사용.
|
||||
|
||||
ZAI_API_KEY=... python scripts/glm_connection_check.py
|
||||
GLM_DEBUG=1 GLM_HTTP_TIMEOUT_SECONDS=180 python scripts/glm_connection_check.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# 프로젝트 루트를 path에 넣어 ai_planner 상수 재사용
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from ai_planner import ( # noqa: E402
|
||||
GLM_API_URL,
|
||||
GLM_MODEL,
|
||||
describe_glm_exception,
|
||||
_glm_debug_enabled,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
key = os.environ.get("ZAI_API_KEY", "").strip()
|
||||
if not key:
|
||||
print("[오류] ZAI_API_KEY 가 비어 있습니다.")
|
||||
return 2
|
||||
|
||||
timeout = float(os.environ.get("GLM_HTTP_TIMEOUT_SECONDS", "120"))
|
||||
payload = json.dumps({
|
||||
"model": GLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Reply with exactly: OK"},
|
||||
],
|
||||
"max_tokens": 16,
|
||||
"temperature": 0,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
GLM_API_URL,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
if _glm_debug_enabled():
|
||||
print(f"[디버그] POST {GLM_API_URL}")
|
||||
print(f"[디버그] timeout={timeout}s payload_bytes={len(payload)} model={GLM_MODEL}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
dt = time.perf_counter() - t0
|
||||
except Exception as e:
|
||||
dt = time.perf_counter() - t0
|
||||
print(f"[실패] {dt:.2f}s 경과 후 예외")
|
||||
print(f" {describe_glm_exception(e)}")
|
||||
return 1
|
||||
|
||||
dt = time.perf_counter() - t0
|
||||
print(f"[성공] HTTP 응답 수신 {dt:.2f}s, 본문 {len(raw)}자")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
preview = (content or "").strip().replace("\n", " ")[:200]
|
||||
print(f" assistant 미리보기: {preview!r}")
|
||||
except Exception as e:
|
||||
print(f"[경고] JSON 파싱 실패: {e}")
|
||||
print(f" 원문 앞 400자: {raw[:400]!r}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user