ping: JSON 이 안 나오면 설정 조합을 바꿔가며 자동 진단, LLM_MERGE_SYSTEM 옵션

- 게이트웨이/모델이 system 역할을 무시해 평문으로 답하는 경우(팀원 VM /api/ito + 339 실측)
  LLM_MERGE_SYSTEM=1 로 system 을 user 앞에 합쳐 보낸다
- summarize.ping 이 (그대로 → merge → merge+json_mode off) 순으로 시도해 되는 .env 값을 알려준다. --real 은 실제 조각 추출 프롬프트로

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 15:52:29 +09:00
co-authored by Claude Fable 5.1
parent 0f70c0d245
commit db50ec9d46
5 changed files with 95 additions and 39 deletions
+1
View File
@@ -24,6 +24,7 @@ LLM_TIMEOUT_S=180
#LLM_BODY_MODEL=/mnt/models # body 의 model (FabriX 는 고정값) #LLM_BODY_MODEL=/mnt/models # body 의 model (FabriX 는 고정값)
#LLM_JSON_MODE=1 # response_format 을 서버가 거부(400)하면 0 #LLM_JSON_MODE=1 # response_format 을 서버가 거부(400)하면 0
#LLM_MAX_TOKENS=0 # 0 이면 안 보냄 #LLM_MAX_TOKENS=0 # 0 이면 안 보냄
#LLM_MERGE_SYSTEM=0 # system 지시를 무시하는 게이트웨이/모델이면 1 (python -m summarize.ping 이 알려준다)
#FABRIX_CLIENT_KEY= # x-generative-ai-client #FABRIX_CLIENT_KEY= # x-generative-ai-client
#FABRIX_OPENAPI_TOKEN= # x-openapi-token (Bearer 는 코드가 붙임) #FABRIX_OPENAPI_TOKEN= # x-openapi-token (Bearer 는 코드가 붙임)
#FABRIX_USER_EMAIL= # x-generative-ai-user-email (신원 정보) #FABRIX_USER_EMAIL= # x-generative-ai-user-email (신원 정보)
+3
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
llm_json_mode: bool = True llm_json_mode: bool = True
# 출력 토큰 상한. 0 이면 보내지 않음 (OpenRouter 는 잔액 견적에 쓰고, 추론 폭주 방어도 됨) # 출력 토큰 상한. 0 이면 보내지 않음 (OpenRouter 는 잔액 견적에 쓰고, 추론 폭주 방어도 됨)
llm_max_tokens: int = 0 llm_max_tokens: int = 0
# system 메시지를 user 메시지 앞에 합쳐 하나로 보낸다 — 게이트웨이/모델이 system 역할을 무시할 때
# (실측 2026-09-21: 팀원 VM 게이트웨이(/api/ito) + 모델 339 가 "JSON 만" system 지시를 무시했다)
llm_merge_system: bool = False
# FabriX 헤더 값 — x-generative-ai-client / x-openapi-token(Bearer 접두는 코드가 붙임) / x-generative-ai-user-email # FabriX 헤더 값 — x-generative-ai-client / x-openapi-token(Bearer 접두는 코드가 붙임) / x-generative-ai-user-email
fabrix_client_key: str = "" fabrix_client_key: str = ""
fabrix_openapi_token: str = "" fabrix_openapi_token: str = ""
+5 -4
View File
@@ -100,13 +100,14 @@ class OpenAICompatClient(LLMClient):
return h return h
def body(self, system: str, user: str) -> dict: def body(self, system: str, user: str) -> dict:
if settings.llm_merge_system:
messages = [{"role": "user", "content": system + "\n\n" + user}]
else:
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
body = { body = {
"model": settings.llm_body_model or self.model, "model": settings.llm_body_model or self.model,
"temperature": 0.1, "temperature": 0.1,
"messages": [ "messages": messages,
{"role": "system", "content": system},
{"role": "user", "content": user},
],
} }
if settings.llm_json_mode: if settings.llm_json_mode:
body["response_format"] = {"type": "json_object"} body["response_format"] = {"type": "json_object"}
+69 -29
View File
@@ -1,11 +1,15 @@
"""LLM 접속 점검 — Stage 3 를 돌리기 전에 규격(주소·헤더·body)이 맞는지 한 번 호출해 본다. """LLM 접속 점검 — Stage 3 를 돌리기 전에 규격(주소·헤더·body)이 맞는지 호출해 본다.
python -m summarize.ping # .env 대로 한 번 호출 python -m summarize.ping # .env 대로 호출. JSON 이 안 나오면 설정 조합을 바꿔가며 다시 시도
python -m summarize.ping --show # 보낼 주소·헤더(비밀값은 가림)·body 만 출력, 호출 안 함 python -m summarize.ping --show # 보낼 주소·헤더(비밀값은 가림)·body 만 출력, 호출 안 함
python -m summarize.ping --real # 실제 Stage 3 프롬프트(조각 추출)로 호출 — 짧은 시험 프롬프트보다 확실
성공하면 모델 답(JSON)과 usage 를, 실패하면 HTTP 상태와 서버 응답 본문을 그대로 찍는다. 성공하면 모델 답(JSON)과 usage 를, 실패하면 HTTP 상태와 서버 응답 본문을 그대로 찍는다.
400 이면 대개 body 규격(response_format / model) 문제 → LLM_JSON_MODE=0, LLM_BODY_MODEL 조정. 400 이면 대개 body 규격(response_format / model) 문제 → LLM_JSON_MODE=0, LLM_BODY_MODEL 조정.
401/403 이면 헤더(토큰·클라이언트키·이메일) 문제. 404 면 주소(LLM_BASE_URL + LLM_CHAT_PATH). 401/403 이면 헤더(토큰·클라이언트키·이메일) 문제. 404 면 주소(LLM_BASE_URL + LLM_CHAT_PATH).
JSON 이 안 나오면(모델이 인사말 등 평문으로 답하면) 아래 조합을 차례로 시도해 되는 것을 알려준다:
1) 그대로 2) LLM_MERGE_SYSTEM=1 (system 을 user 에 합침) 3) 2 + LLM_JSON_MODE=0
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,10 +20,12 @@ import time
import urllib.error import urllib.error
from config.settings import settings from config.settings import settings
from summarize.llm_client import OpenAICompatClient from summarize.llm_client import OpenAICompatClient, _extract_json
SYSTEM = "너는 JSON 만 출력하는 도우미다." SYSTEM = ("너는 ABAP 코드 분석 도구의 일부다. 답은 반드시 JSON 객체 하나만 출력한다. "
USER = '다음 스키마로만 답해라: {"ok": true, "model_says": "<짧은 인사 한 줄>"}' "인사말·설명·코드펜스 없이 JSON 만 쓴다.")
USER = ('[작업] ping\n아래 스키마 그대로 JSON 만 출력하라. 다른 문장은 쓰지 마라.\n'
'{"ok": true, "model_says": "<짧은 인사 한 줄>"}')
_SECRET_HEADERS = {"authorization", "x-openapi-token", "x-generative-ai-client"} _SECRET_HEADERS = {"authorization", "x-openapi-token", "x-generative-ai-client"}
@@ -30,9 +36,36 @@ def _mask(v: str) -> str:
return v[:8] + "" + v[-4:] return v[:8] + "" + v[-4:]
def _real_prompt() -> tuple[str, str]:
"""실제 조각 추출 프롬프트 한 벌 (DB 에 unit 이 있으면 첫 unit, 없으면 내장 샘플)."""
from summarize import runner
system = runner.SYSTEM_PROMPT if hasattr(runner, "SYSTEM_PROMPT") else SYSTEM
try:
from tests.test_llm_backends import UNIT_PROMPT_SAMPLE # 테스트가 쓰는 샘플 프롬프트
return system, UNIT_PROMPT_SAMPLE
except Exception: # noqa: BLE001
return system, USER
def _call(client: OpenAICompatClient, system: str, user: str) -> tuple[bool, str, dict]:
"""(JSON 파싱 성공?, 본문 또는 오류, raw) — HTTP 오류는 여기서 예외로 올린다."""
raw = client._post_once(system, user)
try:
content = raw["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
return False, "응답 형태가 OpenAI 규격(choices[0].message.content)이 아닙니다: "
+ json.dumps(raw, ensure_ascii=False)[:1500], raw
try:
_extract_json(content)
return True, content, raw
except Exception: # noqa: BLE001
return False, content, raw
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description="LLM 접속 점검") ap = argparse.ArgumentParser(description="LLM 접속 점검")
ap.add_argument("--show", action="store_true", help="요청만 출력하고 호출하지 않음") ap.add_argument("--show", action="store_true", help="요청만 출력하고 호출하지 않음")
ap.add_argument("--real", action="store_true", help="실제 Stage 3 프롬프트로 호출")
args = ap.parse_args() args = ap.parse_args()
try: try:
@@ -41,43 +74,50 @@ def main() -> None:
print(f"설정 오류: {e}", file=sys.stderr) print(f"설정 오류: {e}", file=sys.stderr)
sys.exit(2) sys.exit(2)
system, user = _real_prompt() if args.real else (SYSTEM, USER)
headers = {k: (_mask(v) if k.lower() in _SECRET_HEADERS else v) for k, v in client.headers().items()} headers = {k: (_mask(v) if k.lower() in _SECRET_HEADERS else v) for k, v in client.headers().items()}
print(f"규격 : {client.provider}") print(f"규격 : {client.provider}")
print(f"주소 : {client.url}") print(f"주소 : {client.url}")
print(f"헤더 : {json.dumps(headers, ensure_ascii=False)}") print(f"헤더 : {json.dumps(headers, ensure_ascii=False)}")
body = client.body(SYSTEM, USER) body = client.body(system, user)
print(f"body : {json.dumps({k: v for k, v in body.items() if k != 'messages'}, ensure_ascii=False)}") print(f"body : {json.dumps({k: v for k, v in body.items() if k != 'messages'}, ensure_ascii=False)}"
f" messages={[m['role'] for m in body['messages']]}")
if args.show: if args.show:
return return
# 시도 조합: (merge_system, json_mode). 첫 조합은 .env 그대로
base = (settings.llm_merge_system, settings.llm_json_mode)
combos = [base] + [c for c in [(True, True), (True, False), (False, False)] if c != base]
for i, (merge, jmode) in enumerate(combos, 1):
settings.llm_merge_system, settings.llm_json_mode = merge, jmode
label = f"LLM_MERGE_SYSTEM={int(merge)} LLM_JSON_MODE={int(jmode)}"
print(f"\n[{i}/{len(combos)}] {label}" + (" (.env 그대로)" if i == 1 else ""))
t0 = time.time() t0 = time.time()
try: try:
raw = client._post_once(SYSTEM, USER) ok, text, raw = _call(client, system, user)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
text = e.read().decode("utf-8", errors="replace") body_text = e.read().decode("utf-8", errors="replace")
print(f"\n실패 HTTP {e.code} ({int((time.time() - t0) * 1000)} ms)") print(f" 실패 HTTP {e.code} ({int((time.time() - t0) * 1000)} ms) 서버 응답: {body_text[:800]}")
print(f"서버 응답: {text[:2000]}") if e.code in (401, 403, 404):
sys.exit(1) sys.exit(1) # 헤더/주소 문제는 조합을 바꿔도 소용없다
continue
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"\n실패 {type(e).__name__}: {e}") print(f" 실패 {type(e).__name__}: {e}")
sys.exit(1) sys.exit(1)
ms = int((time.time() - t0) * 1000) ms = int((time.time() - t0) * 1000)
print(f"\n성공 ({ms} ms)") print(f" 응답 ({ms} ms): {text[:300].replace(chr(10), ' ')}")
try: print(f" usage: {json.dumps(raw.get('usage') or {}, ensure_ascii=False)}")
content = raw["choices"][0]["message"]["content"] if ok:
except (KeyError, IndexError, TypeError): print(" JSON 파싱: OK")
print("응답 형태가 OpenAI 규격(choices[0].message.content)이 아닙니다:") if i == 1:
print(json.dumps(raw, ensure_ascii=False)[:2000]) print("\n결론: 지금 .env 그대로 Stage 3 를 돌리면 된다.")
sys.exit(1) else:
print(f"모델 답 : {content[:500]}") print(f"\n결론: .env 에 다음 두 줄을 넣어라 (그 뒤 python -m summarize.ping 으로 재확인):\n"
print(f"usage : {json.dumps(raw.get('usage') or {}, ensure_ascii=False)}") f" LLM_MERGE_SYSTEM={int(merge)}\n LLM_JSON_MODE={int(jmode)}")
from summarize.llm_client import _extract_json return
try: print(" JSON 파싱: 실패 — 모델이 JSON 대신 평문으로 답했다")
_extract_json(content)
print("JSON 파싱: OK") print("\n결론: 어떤 조합으로도 JSON 이 안 나온다. 위 응답을 그대로 전달하라 (모델 번호를 바꾸거나 프롬프트를 손봐야 한다).")
except Exception as e: # noqa: BLE001
print(f"JSON 파싱: 실패 ({e}) — LLM_JSON_MODE 와 프롬프트 확인")
sys.exit(1) sys.exit(1)
+11
View File
@@ -263,3 +263,14 @@ def test_provider_requires_its_credentials(monkeypatch):
_configure(monkeypatch, llm_provider="bogus", llm_api_key="k") _configure(monkeypatch, llm_provider="bogus", llm_api_key="k")
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
llm_client.OpenAICompatClient() llm_client.OpenAICompatClient()
def test_merge_system_folds_system_into_user(monkeypatch):
from config.settings import settings
from summarize import llm_client
_configure(monkeypatch, llm_api_key="k")
monkeypatch.setattr(settings, "llm_merge_system", True)
msgs = llm_client.OpenAICompatClient().body("SYS", "USR")["messages"]
assert msgs == [{"role": "user", "content": "SYS\n\nUSR"}]
monkeypatch.setattr(settings, "llm_merge_system", False)
assert [m["role"] for m in llm_client.OpenAICompatClient().body("SYS", "USR")["messages"]] == ["system", "user"]