Streamlit + LLM 데모 파이프라인 구조 정리

모와이·2026년 2월 22일

SKN23기 프로젝트

목록 보기
3/6

흐름

UI → 오케스트레이션 → 도구 호출 → 근거 정리(Evidence) → LLM 생성 → 스키마 검증(Validator) → (필요 시) Repair

1. 전체 동작 흐름(요청 1건 처리)

사용자가 Streamlit에서 질문 + 사진 업로드 후 “분석 실행”을 누르면 다음 순서로 동작한다.

  1. main.py : 입력 수집(텍스트/이미지)

  2. pipeline.run() : 요청 1건 처리 엔트리포인트

  3. router.decide() : 어떤 도구를 쓸지 결정(needs_vision, needs_rag …)

  4. visionclient* : (조건부) 사진 분석 결과 JSON 반환

  5. ragretriever* : (조건부) 근거 문서 조각(passages) 반환

  6. evidence.build() : 도구 결과를 LLM 입력용으로 정리

  7. generator.generate_report() : LLM 호출 → FinalReport(JSON) 생성

  8. validate_report() : 스키마 검사(Pydantic)

  9. (실패 시) repair_json() : JSON 형식만 고치도록 1회 재요청

  10. 결과 저장(outputs/runs/…) + UI 출력

2. 왜 main.py에서 이미지를 bytes로 읽어서 리스트로 만들까?

Streamlit의 file_uploader는 UploadedFile 객체를 반환한다.
하지만 이 객체는 UI 내부 타입이라 다른 모듈(tools/orchestrator)로 넘기기엔 불편하다.

그래서 f.read()로 바이너리(bytes) 로 표준화해서 넘긴다.

  • HTTP API로 보내기 쉬움(multipart/form-data)

  • 디버깅/재현이 쉬움(같은 bytes를 저장해두면 동일 입력 재현 가능)

  • 모듈 경계가 깔끔해짐(UI 타입이 백엔드로 새지 않음)

결과적으로 images_bytes: List[bytes]는 “도구 호출/저장/테스트”에 가장 안전한 입력 포맷

3. Router는 도구를 어떻게 판단할까?

Router는 “도구를 호출할지 말지”를 정하는 의사결정 레이어다.
데모에서는 가장 단순한 형태로:

  • 사진이 있는가?

  • 질문이 “진단/추천/성분” 의도를 갖는가?

를 기준으로 needs_vision, needs_rag 같은 플래그를 만든다.

왜 키워드 포함 여부로 판단하나?

LLM/외부 API 호출은 비용/지연이 크다.
그래서 라우터에서 “불필요한 호출”을 줄이기 위해 의도를 대략 추정한다.

예:

  • “피부 진단해줘” → vision 필요

  • “루틴 추천해줘” → rag(근거) 필요

  • “이 성분 뭐야” → ocr/db/rag 필요(추후 확장)

데모는 룰 기반이지만, 나중에 LLM 분류(intent classifier)로 바꿔도 구조는 동일하다.

Vision mock은 실제 API에서는 어떻게 바뀌나?

현재 vision_client_mock.py는 고정된 JSON을 반환한다.
실제 서비스에서는 이 파일이 B의 딥러닝 추론 API 호출 클라이언트로 교체된다.

  • 입력: images_bytes[]

  • 처리: HTTP로 Vision 서버에 이미지 업로드

  • 출력: vision_result.json

json 예시

LLM은 이미지를 직접 “보는” 게 아니라, 이 JSON을 근거로 리포트를 작성

{
  "quality_flags": ["low_light", "blur"],
  "findings": [
    {"name": "redness", "score": 0.72, "confidence": 0.82, "region": "cheek"},
    {"name": "dryness", "score": 0.64, "confidence": 0.76, "region": "cheek"}
  ],
  "red_flags": []
}

rag_passages는 뭐고 어디서 오나?

rag_passages는 RAG의 Retrieval 결과, 즉 근거 문서 조각(passages) 이다.

  • 데모: rag_retriever_mock.search()가 고정 스니펫을 반환

  • 실제: rag_retriever_chroma.search()가 ChromaDB에서 top-k를 검색해 반환

json 예시

이 “근거 조각”을 LLM 입력에 붙여서 답하게 만드는 것이 RAG의 핵심

[
  {"source_id": "doc:redness-002", "snippet": "홍조가 두드러질 경우 ...", "score": 0.12},
  {"source_id": "doc:skin-barrier-001", "snippet": "장벽 강화(세라마이드, 판테놀)...", "score": 0.18}
]

Evidence 단계가 왜 필요하고, 환각을 어떻게 줄이나?

Evidence는 “환각을 검출하는 장치”라기보다,
LLM이 참조할 정보를 정리해서 입력을 통제하는 장치다.

LLM 환각이 늘어나는 조건은 보통:

  • 입력이 너무 넓거나(무엇이 근거인지 불명확)

  • 도구 결과가 난잡하거나(raw가 그대로 들어감)

  • 근거가 부족한데도 단정하도록 유도될 때

Evidence 단계에서는:

  • vision 결과 중 신뢰도 높은 것만 추출

  • rag_passages 중 핵심 근거만 추려서 전달

  • 사진 품질 플래그/신뢰도 낮음 등을 “불확실성 신호”로 포함

이렇게 “근거 범위를 좁히고 명확하게” 하면 LLM이 지어낼 여지가 줄어든다.

Generator는 RAG의 G(Generation). 어떤 결과물이 나오나?

Generator는 evidence bundle을 입력으로 받아 최종 리포트를 만든다.

  • 입력: evidence_bundle (user_text + vision_result + rag_passages …)

  • 처리: OpenAI 호출(“JSON만 출력”, “근거 기반으로만 답하라” 지시)

  • 출력: FinalReport(JSON)

FinalReport는 UI/DB가 바로 쓰도록 구조화된 결과로 만든다.

예:

  • summary

  • observations(사진 기반)

  • recommendations(AM/PM/Lifestyle/Ingredients)

  • warnings/red_flags

  • citations(source_id/snippet)

Validator + Schema는 왜 필요한가?

LLM은 종종:

  • JSON을 깨뜨리거나

  • 필드를 누락하거나

  • 타입을 바꾸는(숫자→문자열) 문제를 일으킨다.

그래서 FinalReport 스키마(Pydantic)를 만들고,
validate_report()로 형식을 강제한다.

이건 “내용이 맞는지”를 보장하는 게 아니라,

  • UI가 깨지지 않게

  • 저장(DB)이 안정적으로 되게
    하는 형식 안정화 장치

9. Repair는 어떤 방식인가?

Validator에서 실패하면, 1회만 Repair를 시도한다.

Repair는:

  • 에러 메시지 + 스키마 힌트 + 깨진 출력물을 묶어서

  • LLM에게 “내용은 유지하고 형식만 JSON으로 고쳐라”라고 요청하는 방식이다.

이걸 넣으면 운영에서 흔한 문제인
“LLM이 갑자기 JSON 밖에 텍스트를 섞어서 죽는 상황”을 크게 줄일 수 있다.

profile
공부하는거 정리하는 블로그

0개의 댓글