07번 문서가 로컬 스모크 테스트(Qdrant만 붙이고 AI 키 없이)를 다들었다면, 이 문서는 실제 k3s 클러스터에 배포된 pod에 대고, 진짜 Gemini 키로 두 엔드포인트를 호출한 기록이다. 이 과정에서 코드 버그 두 개를 실제로 발견해서 고쳤다.
kubectl port-forward -n c4i svc/threat-intel-ai-service 18000:8000 &
curl -X POST http://localhost:18000/ingest/doc -F "[email protected]"
curl -N -X POST http://localhost:18000/chat -d '{"question": "저고도 자폭 드론이 접근할 때 대응 절차가 어떻게 되나?"}'
curl -N -X POST http://localhost:18000/chat -d '{"question": "과거에 DRONE-001 표적이 탐지된 이력이 있었나?"}'
두 질문 모두 LangGraph 분류가 의도대로 갈렸다. pattern_search가 반환한 세 건이 실제로 앞서 consumer group이 소비한 30개 이벤트 중 DRONE-001 것들이라는 걸 observed_at 타임스탬프로 교차 확인했다.
두 질문 다 route → sources로 바로 넘어가고 토큰 스트림이 한 글자도 없었다.
google.api_core.exceptions.NotFound: 404 This model models/gemini-2.5-flash is no longer available to new users.
genai.list_models()로 확인해보니 카탈로그엔 여전히 나열되지만 이 계정(new user 취급)으로는 실제 생성 호출이 거부되는 상태였다. 카탈로그에 있다고 호출 가능을 보장하지 않는다는 걸 실측으로 확인한 셈.
# Before
gemini_chat_model: str = "gemini-2.5-flash"
# After
gemini_chat_model: str = "gemini-flash-latest"
-latest 별칭을 쓴 이유: 특정 버전 이름을 하드코딩하면 이번처럼 Google이 구버전을 신규 계정에서 차단할 때마다 코드를 고쳐야 한다. 다만 "지금 정확히 어떤 모델이 응답하는지"를 고정할 수 없다는 트레이드오프는 있다.
버그 ①만으로는 "왜 클라이언트가 에러 없이 그냥 토큰 없이 끝나는지"가 설명 안 되었다. 원래 코드:
def produce() -> None:
try:
for token in generate_stream(prompt):
loop.call_soon_threadsafe(queue.put_nowait, token)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None) # 예외가 나도 그냥 종료 신호만 보냄
generate_stream()이 예외를 던지면 except가 없으니 produce() 자체가 예외로 끝나는데, 이건 loop.run_in_executor(None, produce)가 리턴한 Future에 담긴다. 그 Future를 아무도 await하지 않았기 때문에 예외가 그냥 사라졌다 — asyncio가 나중에 가비지 컴렉션 시점에 "Future exception was never retrieved"라고 로그만 남기고, 클라이언트 입장에서는 마치 "빈 답변이 정상 응답인 것처럼" 보였다.
고친 방식 — 큐를 통해 예외를 명시적으로 컸슈머 쪽에 전달:
queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue()
def produce() -> None:
try:
for token in generate_stream(prompt):
loop.call_soon_threadsafe(queue.put_nowait, token)
except Exception as exc:
loop.call_soon_threadsafe(queue.put_nowait, exc)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
# 컸슈머 쪽
while True:
item = await queue.get()
if item is None:
break
if isinstance(item, BaseException):
yield f"event: error\ndata: {json.dumps({'message': str(item)})}\n\n"
break
yield f"data: {json.dumps({'token': item})}\n\n"
에러가 나도 그 뒤 sources/done은 그대로 내려준다 — 검색 자체는 성공했으니, 생성이 실패해도 "무엇을 근거로 찾으려 했는지"는 클라이언트에게 남겨주는 게 낫다고 판단했다.
교훈: run_in_executor(...)로 스레드에 작업을 넘기고 그 Future를 버리면, 그 안에서 난 예외는 기본적으로 아무 데도 보고되지 않는다.