🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

평가-최적화 에이전트 구현 완벽 가이드 - 슬라이드 1/8
A

AI Generated

2025. 12. 4. · 13 Views

평가-최적화 에이전트 구현 완벽 가이드

AI 에이전트가 스스로 결과물을 평가하고 개선하는 평가-최적화 패턴을 학습합니다. 생성기와 평가기의 협업으로 품질을 자동으로 높이는 시스템을 처음부터 끝까지 구현해봅니다.


목차

  1. 생성기(Generator) 구현
  2. 평가기(Evaluator) 구현
  3. 평가 기준과 점수 체계 설계
  4. 최적화 루프 작성
  5. 종료 조건 설정
  6. 스트림릿 UI 구현
  7. 에이전트 테스트 및 개선

1. 생성기(Generator) 구현

김개발 씨는 최근 회사에서 AI 기반 콘텐츠 생성 시스템을 맡게 되었습니다. 처음에는 LLM API를 호출해서 텍스트를 생성하면 끝이라고 생각했습니다.

그런데 결과물의 품질이 들쭉날쭉해서 매번 사람이 검토하고 수정해야 하는 상황이 반복되었습니다.

**생성기(Generator)**는 평가-최적화 에이전트의 첫 번째 핵심 구성요소입니다. 마치 작가가 초고를 쓰는 것처럼, 생성기는 주어진 프롬프트와 조건에 따라 결과물을 만들어냅니다.

이 결과물은 완벽할 필요가 없습니다. 왜냐하면 평가기가 검토하고, 다시 생성기가 개선하는 순환 구조를 갖기 때문입니다.

다음 코드를 살펴봅시다.

from openai import OpenAI

class Generator:
    def __init__(self, model: str = "gpt-4o-mini"):
        self.client = OpenAI()
        self.model = model

    def generate(self, prompt: str, context: str = "", feedback: str = "") -> str:
        # 피드백이 있으면 개선 요청으로 프롬프트 구성
        system_message = "당신은 전문 콘텐츠 작성자입니다."
        if feedback:
            system_message += f"\n\n이전 피드백을 반영해 개선하세요:\n{feedback}"

        messages = [
            {"role": "system", "content": system_message},
            {"role": "user", "content": f"{context}\n\n{prompt}" if context else prompt}
        ]

        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=0.7
        )
        return response.choices[0].message.content

김개발 씨는 입사 3개월 차 주니어 개발자입니다. AI 콘텐츠 생성 프로젝트를 맡은 첫날, 그는 단순히 OpenAI API를 호출하면 모든 게 해결될 줄 알았습니다.

하지만 현실은 달랐습니다. 생성된 글의 품질이 매번 달랐고, 때로는 주제를 벗어나기도 했습니다.

선배 개발자 박시니어 씨가 다가와 말했습니다. "AI 시스템을 제대로 만들려면 단순 호출로는 부족해요.

평가-최적화 패턴을 적용해야 합니다." 그렇다면 생성기란 정확히 무엇일까요? 쉽게 비유하자면, 생성기는 마치 원고를 쓰는 작가와 같습니다.

작가가 첫 초고를 쓸 때는 완벽하지 않아도 됩니다. 편집자가 검토하고 피드백을 주면, 작가는 그것을 반영해서 더 나은 원고를 만들어냅니다.

생성기도 마찬가지로 첫 번째 결과물을 만들고, 피드백을 받으면 그것을 반영해서 개선된 결과물을 생성합니다. 생성기가 없던 시절에는 어땠을까요?

개발자들은 LLM의 출력을 그대로 사용하거나, 직접 수정해야 했습니다. 결과물의 품질을 높이려면 프롬프트를 계속 수정하면서 여러 번 시도해야 했습니다.

이 과정은 시간이 많이 들고, 일관성을 유지하기 어려웠습니다. 바로 이런 문제를 해결하기 위해 구조화된 생성기 클래스가 필요합니다.

생성기를 클래스로 만들면 상태를 관리할 수 있고, 피드백을 체계적으로 반영할 수 있습니다. 또한 모델이나 파라미터를 쉽게 교체할 수 있어 유연성이 높아집니다.

위의 코드를 살펴보겠습니다. Generator 클래스는 초기화할 때 사용할 모델을 지정받습니다.

generate 메서드는 세 가지 파라미터를 받습니다. prompt는 생성할 내용에 대한 요청이고, context는 추가적인 배경 정보이며, feedback은 이전 생성 결과에 대한 개선 요청입니다.

핵심은 feedback 파라미터입니다. 피드백이 있으면 시스템 메시지에 이를 포함시켜서 LLM이 이전 문제점을 인식하고 개선할 수 있도록 합니다.

이렇게 하면 같은 프롬프트라도 피드백에 따라 점점 더 나은 결과물이 나옵니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 마케팅 문구 생성 시스템을 만든다고 가정해봅시다. 첫 번째 생성 결과가 너무 길거나 톤이 맞지 않을 수 있습니다.

평가기가 이를 감지하고 피드백을 주면, 생성기는 그 피드백을 반영해서 더 적절한 문구를 만들어냅니다. 하지만 주의할 점도 있습니다.

생성기에 너무 많은 피드백을 한꺼번에 주면 LLM이 혼란스러워할 수 있습니다. 피드백은 명확하고 구체적으로 작성해야 합니다.

또한 temperature 값을 적절히 조절해야 합니다. 너무 낮으면 창의성이 떨어지고, 너무 높으면 일관성이 없어집니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 생성기는 그냥 API 호출이 아니라 피드백을 받아서 개선할 수 있는 구조여야 하는군요!"

실전 팁

💡 - temperature는 0.7 정도가 창의성과 일관성의 균형점입니다

  • 피드백은 한 번에 3개 이내의 개선점만 전달하는 것이 효과적입니다
  • 시스템 프롬프트에 역할을 명확히 정의하면 출력 품질이 높아집니다

2. 평가기(Evaluator) 구현

생성기를 만든 김개발 씨는 다음 단계로 넘어갔습니다. "이제 결과물을 자동으로 평가하는 부분을 만들어야 해요." 박시니어 씨가 말했습니다.

"평가기는 마치 엄격한 편집자와 같아요. 객관적인 기준으로 결과물을 검토하고 구체적인 피드백을 주는 역할이죠."

**평가기(Evaluator)**는 생성기가 만든 결과물을 검토하고 점수를 매기며 개선점을 제시합니다. 마치 시험 채점관이 답안을 평가하듯이, 미리 정해진 기준에 따라 객관적으로 평가합니다.

평가기의 출력은 점수와 피드백으로 구성되어 다음 생성 사이클에 활용됩니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel
from typing import List

class EvaluationResult(BaseModel):
    score: float  # 0.0 ~ 1.0 사이의 점수
    feedback: str  # 구체적인 개선 피드백
    criteria_scores: dict  # 항목별 세부 점수

class Evaluator:
    def __init__(self, model: str = "gpt-4o-mini"):
        self.client = OpenAI()
        self.model = model

    def evaluate(self, content: str, criteria: List[str]) -> EvaluationResult:
        # 평가 기준을 포함한 프롬프트 구성
        criteria_text = "\n".join([f"- {c}" for c in criteria])
        prompt = f"""다음 콘텐츠를 평가하세요.

평가 기준:
{criteria_text}

콘텐츠:
{content}

JSON 형식으로 응답: {{"score": 0.0-1.0, "feedback": "개선점", "criteria_scores": {{}}}}"""

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"}
        )
        return EvaluationResult.model_validate_json(response.choices[0].message.content)

김개발 씨는 생성기를 완성하고 나서 뿌듯한 마음으로 테스트를 돌려보았습니다. 결과물이 나왔지만, 이게 좋은 건지 나쁜 건지 판단하기가 어려웠습니다.

매번 사람이 직접 읽어보고 평가해야 할까요? 박시니어 씨가 다가와 말했습니다.

"이제 평가기를 만들 차례예요. 평가기가 있으면 결과물의 품질을 자동으로 판단할 수 있어요." 평가기란 정확히 무엇일까요?

쉽게 비유하자면, 평가기는 마치 공인된 심사위원과 같습니다. 요리 대회에서 심사위원이 맛, 플레이팅, 창의성 등 여러 기준으로 점수를 매기듯이, 평가기도 미리 정해진 기준에 따라 결과물을 평가합니다.

심사위원이 "소금이 조금 부족하네요"라고 피드백을 주듯이, 평가기도 구체적인 개선점을 알려줍니다. 평가기가 없으면 어떤 문제가 생길까요?

첫째, 결과물의 품질을 판단하려면 항상 사람이 개입해야 합니다. 둘째, 사람마다 평가 기준이 달라서 일관성이 없어집니다.

셋째, 대량의 콘텐츠를 처리할 때 병목이 발생합니다. 위의 코드에서 핵심은 EvaluationResult 클래스입니다.

Pydantic의 BaseModel을 상속받아서 평가 결과를 구조화합니다. score는 전체 점수이고, feedback은 개선을 위한 구체적인 조언이며, criteria_scores는 각 평가 기준별 세부 점수입니다.

evaluate 메서드를 살펴보면, 평가 기준 목록을 받아서 프롬프트에 포함시킵니다. 중요한 점은 response_format을 json_object로 지정한 것입니다.

이렇게 하면 LLM이 항상 유효한 JSON 형식으로 응답하므로, 파싱 오류를 방지할 수 있습니다. 실제 현업에서는 평가 기준을 상황에 맞게 정의합니다.

마케팅 문구라면 "브랜드 톤 일치", "행동 유도 명확성", "길이 적절성" 같은 기준을 사용하고, 기술 문서라면 "정확성", "완전성", "가독성" 같은 기준을 사용합니다. 주의할 점이 있습니다.

평가기와 생성기에 같은 모델을 사용하면 편향이 생길 수 있습니다. 가능하다면 다른 모델을 사용하거나, 평가 프롬프트를 매우 객관적으로 작성해야 합니다.

또한 평가 기준이 너무 많으면 각 기준에 대한 평가 품질이 떨어질 수 있으므로, 핵심 기준 3-5개로 유지하는 것이 좋습니다. 김개발 씨는 평가기를 테스트해보았습니다.

생성기가 만든 콘텐츠를 평가기에 넣으니, 점수와 함께 "도입부가 너무 길고, 핵심 메시지가 불명확합니다"라는 피드백이 나왔습니다. "와, 이 피드백을 다시 생성기에 넣으면 되겠네요!"

실전 팁

💡 - Pydantic을 사용하면 평가 결과의 타입 안전성을 보장할 수 있습니다

  • response_format을 json_object로 설정하면 파싱 오류를 예방합니다
  • 평가 기준은 측정 가능하고 명확하게 정의해야 합니다

3. 평가 기준과 점수 체계 설계

평가기의 틀은 만들었지만, 김개발 씨는 고민에 빠졌습니다. "평가 기준을 어떻게 정해야 할까요?

그리고 점수는 어떤 체계로 매겨야 하죠?" 박시니어 씨는 웃으며 대답했습니다. "평가 기준 설계가 사실 가장 중요한 부분이에요.

기준이 명확해야 평가도 정확해지니까요."

평가 기준과 점수 체계는 평가-최적화 에이전트의 핵심 설계 요소입니다. 마치 대학 입시에서 평가 항목과 배점이 정해져 있듯이, AI 에이전트도 명확한 기준과 점수 체계가 있어야 일관된 평가가 가능합니다.

좋은 평가 기준은 측정 가능하고, 서로 겹치지 않으며, 최종 목표와 연결되어야 합니다.

다음 코드를 살펴봅시다.

from enum import Enum
from dataclasses import dataclass
from typing import Dict

class QualityLevel(Enum):
    EXCELLENT = (0.9, 1.0, "우수: 수정 불필요")
    GOOD = (0.7, 0.89, "양호: 소폭 개선 권장")
    FAIR = (0.5, 0.69, "보통: 개선 필요")
    POOR = (0.0, 0.49, "미흡: 재생성 필요")

@dataclass
class EvaluationCriteria:
    name: str
    description: str
    weight: float  # 가중치 (전체 합이 1.0)

CONTENT_CRITERIA = [
    EvaluationCriteria("relevance", "주제와의 관련성", 0.3),
    EvaluationCriteria("clarity", "명확성과 가독성", 0.25),
    EvaluationCriteria("accuracy", "정보의 정확성", 0.25),
    EvaluationCriteria("engagement", "흥미 유발 정도", 0.2),
]

def calculate_weighted_score(criteria_scores: Dict[str, float]) -> float:
    # 가중치를 적용한 종합 점수 계산
    total = sum(
        criteria_scores.get(c.name, 0) * c.weight
        for c in CONTENT_CRITERIA
    )
    return round(total, 2)

김개발 씨는 노트북을 펴고 평가 기준에 대해 고민하기 시작했습니다. 단순히 "좋다, 나쁘다"로 평가하면 될까요?

아니면 더 세분화된 기준이 필요할까요? 박시니어 씨가 화이트보드에 무언가를 그리기 시작했습니다.

"평가 기준 설계는 세 가지 원칙을 따라야 해요. 첫째, 측정 가능해야 합니다.

둘째, 기준끼리 서로 겹치지 않아야 합니다. 셋째, 최종 목표와 연결되어야 합니다." 측정 가능하다는 것은 무슨 뜻일까요?

"글이 좋다"는 측정하기 어렵지만, "핵심 키워드가 3회 이상 포함되어 있다"는 측정할 수 있습니다. "읽기 쉽다"보다는 "문장 평균 길이가 20단어 이하다"가 더 측정 가능합니다.

기준이 서로 겹치면 안 되는 이유도 있습니다. 예를 들어 "명확성"과 "가독성"이 모두 기준이라면, 이 둘은 상당 부분 겹칩니다.

그러면 한 가지 문제점이 두 번 감점되는 셈이 되어 불공정해집니다. 위의 코드에서 QualityLevel 열거형을 보면, 점수 구간에 따라 네 단계로 나눕니다.

0.9 이상이면 "우수"로 수정이 불필요하고, 0.5 미만이면 "미흡"으로 재생성이 필요합니다. 이렇게 구간을 나누면 다음에 어떤 행동을 취해야 할지 명확해집니다.

EvaluationCriteria 데이터클래스는 각 평가 기준의 이름, 설명, 그리고 가중치를 포함합니다. 가중치는 각 기준의 중요도를 나타냅니다.

예를 들어 콘텐츠 생성에서 "주제와의 관련성"이 가장 중요하다면 0.3의 가중치를, "흥미 유발"은 상대적으로 덜 중요하다면 0.2의 가중치를 부여합니다. calculate_weighted_score 함수는 각 기준별 점수에 가중치를 곱해서 종합 점수를 계산합니다.

이렇게 하면 중요한 기준에서 높은 점수를 받으면 전체 점수도 높아지고, 덜 중요한 기준에서 낮은 점수를 받아도 전체에 미치는 영향이 적습니다. 실무에서는 서비스 특성에 맞게 기준을 정의합니다.

뉴스 기사 생성이라면 "정확성"의 가중치를 높이고, 광고 문구 생성이라면 "흥미 유발"의 가중치를 높입니다. 중요한 것은 팀 내에서 합의된 기준을 사용하는 것입니다.

주의할 점은 평가 기준이 너무 많으면 안 된다는 것입니다. 5개를 넘어가면 LLM이 각 기준을 충분히 고려하지 못하고 피상적으로 평가할 수 있습니다.

핵심적인 3-5개 기준으로 압축하는 것이 좋습니다. 김개발 씨는 자신의 프로젝트에 맞는 평가 기준을 정리했습니다.

"주제 관련성이 제일 중요하고, 다음은 명확성, 그 다음은 정확성... 이제 점수 체계도 명확해졌어요!"

실전 팁

💡 - 평가 기준은 3-5개로 제한하고, 각 기준의 가중치 합이 1.0이 되도록 합니다

  • 점수 구간별로 다음 행동을 정의해두면 자동화가 쉬워집니다
  • 새로운 도메인에 적용할 때는 샘플 데이터로 기준을 검증한 후 조정합니다

4. 최적화 루프 작성

생성기와 평가기, 평가 기준까지 모두 준비되었습니다. 이제 김개발 씨 앞에 마지막 퍼즐 조각이 놓여 있었습니다.

"이것들을 어떻게 연결해서 자동으로 품질을 높일 수 있을까요?" 박시니어 씨가 대답했습니다. "바로 최적화 루프를 만들면 돼요.

생성하고, 평가하고, 피드백 반영해서 다시 생성하는 순환 구조요."

최적화 루프는 생성기와 평가기를 연결하여 결과물의 품질을 자동으로 개선하는 핵심 메커니즘입니다. 마치 조각가가 작품을 다듬고, 물러서서 바라보고, 다시 다듬는 과정을 반복하듯이, 최적화 루프는 생성-평가-개선의 사이클을 반복합니다.

이 과정을 통해 초기의 거친 결과물이 점점 정제된 최종 결과물로 변해갑니다.

다음 코드를 살펴봅시다.

from dataclasses import dataclass, field
from typing import List

@dataclass
class OptimizationResult:
    final_content: str
    final_score: float
    iterations: int
    history: List[dict] = field(default_factory=list)

class OptimizationLoop:
    def __init__(self, generator: Generator, evaluator: Evaluator):
        self.generator = generator
        self.evaluator = evaluator

    def run(self, prompt: str, criteria: List[str],
            max_iterations: int = 5, target_score: float = 0.8) -> OptimizationResult:
        history = []
        content = self.generator.generate(prompt)

        for i in range(max_iterations):
            # 현재 결과물 평가
            evaluation = self.evaluator.evaluate(content, criteria)
            history.append({"iteration": i+1, "score": evaluation.score,
                           "content": content[:100], "feedback": evaluation.feedback})

            # 목표 점수 도달 시 종료
            if evaluation.score >= target_score:
                return OptimizationResult(content, evaluation.score, i+1, history)

            # 피드백을 반영하여 재생성
            content = self.generator.generate(prompt, feedback=evaluation.feedback)

        return OptimizationResult(content, evaluation.score, max_iterations, history)

김개발 씨는 화이트보드 앞에 섰습니다. 생성기, 평가기, 평가 기준...

모든 조각이 준비되어 있었습니다. 이제 이것들을 하나로 엮어야 할 때입니다.

박시니어 씨가 화살표를 그리기 시작했습니다. "생성기에서 결과물이 나오면 평가기로 보내고, 평가기의 피드백을 다시 생성기에 넣어요.

이 순환을 반복하는 거예요." 최적화 루프는 마치 장인이 작품을 완성하는 과정과 같습니다. 도공이 도자기를 빚을 때, 처음부터 완벽한 형태가 나오지 않습니다.

빚고, 물러서서 바라보고, 부족한 부분을 다듬고, 다시 바라보는 과정을 반복합니다. 최적화 루프도 이와 똑같습니다.

위의 코드에서 OptimizationLoop 클래스를 보면, 생성기와 평가기를 멤버로 가집니다. run 메서드가 실제 최적화를 수행하는 핵심 로직입니다.

먼저 초기 콘텐츠를 생성합니다. 이것이 "초고"에 해당합니다.

그 다음 반복문에 진입합니다. 각 반복에서는 먼저 현재 콘텐츠를 평가합니다.

평가 결과는 history 리스트에 저장되어 나중에 개선 과정을 추적할 수 있습니다. 평가 점수가 목표 점수(target_score) 이상이면 루프를 종료합니다.

더 이상의 개선이 필요 없기 때문입니다. 그렇지 않으면 평가기가 준 피드백을 생성기에 전달해서 새로운 콘텐츠를 생성합니다.

OptimizationResult 데이터클래스는 최적화 결과를 담습니다. final_content는 최종 결과물이고, final_score는 최종 점수입니다.

iterations는 몇 번의 반복이 필요했는지를 나타내고, history는 각 반복의 기록을 담고 있어서 개선 과정을 추적할 수 있습니다. 실무에서 이 패턴은 다양하게 활용됩니다.

이메일 초안 작성, 보고서 생성, 코드 리뷰 코멘트 작성 등 품질이 중요한 모든 텍스트 생성 작업에 적용할 수 있습니다. 중요한 것은 반복 횟수와 목표 점수를 상황에 맞게 설정하는 것입니다.

주의할 점은 무한 루프에 빠지지 않도록 하는 것입니다. max_iterations로 상한을 정해두고, 설령 목표 점수에 도달하지 못하더라도 그 시점의 최선의 결과를 반환합니다.

또한 매 반복마다 API 호출이 발생하므로 비용을 고려해야 합니다. 김개발 씨는 코드를 실행해보았습니다.

첫 번째 생성 결과는 0.55점이었습니다. 두 번째는 0.68점, 세 번째는 0.79점, 네 번째에서 드디어 0.83점으로 목표를 달성했습니다.

"와, 정말 점수가 올라가네요!"

실전 팁

💡 - history를 저장해두면 나중에 어떤 피드백이 효과적이었는지 분석할 수 있습니다

  • 비용 최적화를 위해 첫 생성에는 저렴한 모델, 평가에는 정확한 모델을 사용하는 전략도 있습니다
  • 반복 횟수가 너무 많으면 콘텐츠가 과하게 다듬어져서 자연스러움이 사라질 수 있습니다

5. 종료 조건 설정

최적화 루프가 돌아가기 시작했지만, 김개발 씨는 새로운 고민에 빠졌습니다. "언제 멈춰야 할까요?

목표 점수에만 의존하면 될까요?" 박시니어 씨가 고개를 저었습니다. "종료 조건은 더 정교해야 해요.

점수만 보면 안 되는 상황이 많거든요."

종료 조건은 최적화 루프를 언제 멈출지 결정하는 규칙입니다. 단순히 목표 점수 도달만이 아니라, 개선 정체, 시간 초과, 비용 한도 등 다양한 조건을 고려해야 합니다.

마치 달리기 선수가 목표 기록뿐 아니라 체력 상태, 날씨, 경기 상황을 종합적으로 판단해서 페이스를 조절하듯이, 종료 조건도 여러 요소를 복합적으로 고려해야 합니다.

다음 코드를 살펴봅시다.

from abc import ABC, abstractmethod
import time

class StopCondition(ABC):
    @abstractmethod
    def should_stop(self, context: dict) -> tuple[bool, str]:
        """종료 여부와 이유를 반환"""
        pass

class TargetScoreCondition(StopCondition):
    def __init__(self, target: float = 0.8):
        self.target = target

    def should_stop(self, context: dict) -> tuple[bool, str]:
        if context.get("score", 0) >= self.target:
            return True, f"목표 점수 {self.target} 달성"
        return False, ""

class NoImprovementCondition(StopCondition):
    def __init__(self, patience: int = 2, min_delta: float = 0.05):
        self.patience = patience
        self.min_delta = min_delta

    def should_stop(self, context: dict) -> tuple[bool, str]:
        history = context.get("score_history", [])
        if len(history) < self.patience + 1:
            return False, ""
        # 최근 patience회 동안 개선이 min_delta 미만이면 종료
        recent_improvement = history[-1] - history[-(self.patience + 1)]
        if recent_improvement < self.min_delta:
            return True, f"{self.patience}회 연속 개선 없음"
        return False, ""

class CompositeStopCondition(StopCondition):
    def __init__(self, conditions: list[StopCondition]):
        self.conditions = conditions

    def should_stop(self, context: dict) -> tuple[bool, str]:
        for condition in self.conditions:
            should_stop, reason = condition.should_stop(context)
            if should_stop:
                return True, reason
        return False, ""

김개발 씨는 최적화 루프를 여러 번 돌려보면서 이상한 현상을 발견했습니다. 어떤 경우에는 점수가 0.75에서 0.76, 0.76, 0.77로 거의 올라가지 않으면서도 계속 반복이 진행되었습니다.

반대로 어떤 경우에는 이미 충분히 좋은 결과인데도 목표 점수에 살짝 못 미쳐서 불필요한 반복이 계속되었습니다. 박시니어 씨가 설명했습니다.

"이래서 종료 조건을 잘 설계해야 해요. 하나의 조건만 보면 안 돼요." 종료 조건의 핵심은 "언제 멈추는 것이 최선인가"를 판단하는 것입니다.

마라톤 선수가 완주만을 목표로 하지 않고, 컨디션, 날씨, 페이스를 종합적으로 고려하듯이, 최적화 루프도 여러 조건을 함께 봐야 합니다. 위의 코드에서 StopCondition 추상 클래스는 모든 종료 조건의 기본 인터페이스를 정의합니다.

should_stop 메서드는 종료 여부와 그 이유를 튜플로 반환합니다. TargetScoreCondition은 가장 기본적인 조건입니다.

목표 점수에 도달하면 종료합니다. 하지만 이것만으로는 부족합니다.

NoImprovementCondition이 중요합니다. 이 조건은 "개선 정체"를 감지합니다.

patience 파라미터는 몇 회 연속 개선이 없으면 멈출지를 정하고, min_delta는 얼마만큼의 개선을 "의미 있는 개선"으로 볼지를 정합니다. 예를 들어 patience=2, min_delta=0.05로 설정하면, 2회 연속 0.05 미만의 개선만 있을 때 멈춥니다.

CompositeStopCondition은 여러 조건을 조합합니다. 조건 중 하나라도 만족하면 종료하는 OR 로직을 구현합니다.

이렇게 하면 "목표 점수 달성 OR 개선 정체 OR 최대 반복 횟수 초과" 같은 복합 조건을 만들 수 있습니다. 실무에서는 비용 조건도 추가합니다.

API 호출 비용이 일정 금액을 초과하면 멈추는 조건이나, 총 소요 시간이 일정 시간을 초과하면 멈추는 조건 등을 추가할 수 있습니다. 주의할 점은 종료 조건이 너무 느슨하면 불필요한 반복이 발생하고, 너무 엄격하면 충분히 개선되기 전에 멈춘다는 것입니다.

처음에는 보수적으로 설정하고, 실제 운영 데이터를 보면서 조정하는 것이 좋습니다. 김개발 씨는 복합 종료 조건을 적용했습니다.

목표 점수 0.8 달성, 또는 2회 연속 0.03 미만 개선, 또는 최대 5회 반복 중 하나라도 만족하면 종료하도록 설정했습니다. "이제 불필요한 반복이 줄었어요!"

실전 팁

💡 - NoImprovementCondition의 patience는 2-3, min_delta는 0.03-0.05가 일반적입니다

  • 비용이 중요한 환경에서는 MaxCostCondition을 추가하세요
  • 종료 이유를 로깅하면 나중에 조건 튜닝에 도움이 됩니다

6. 스트림릿 UI 구현

백엔드 로직이 완성되었습니다. 하지만 김개발 씨는 팀원들에게 시연하려니 막막했습니다.

"터미널에서 코드 실행하는 걸 보여주면 이해하기 어려울 것 같아요." 박시니어 씨가 말했습니다. "스트림릿으로 간단한 UI를 만들어보세요.

생각보다 쉽고, 시각적으로 보여주기 좋아요."

**스트림릿(Streamlit)**은 파이썬만으로 웹 애플리케이션을 빠르게 만들 수 있는 프레임워크입니다. HTML이나 JavaScript를 몰라도 됩니다.

평가-최적화 에이전트의 동작을 시각적으로 보여주면 팀원들의 이해도가 높아지고, 직접 테스트해볼 수 있어서 피드백도 받기 쉽습니다.

다음 코드를 살펴봅시다.

import streamlit as st

st.title("평가-최적화 에이전트")

# 사이드바 설정
with st.sidebar:
    st.header("설정")
    target_score = st.slider("목표 점수", 0.5, 1.0, 0.8, 0.05)
    max_iterations = st.number_input("최대 반복 횟수", 1, 10, 5)

# 메인 입력
prompt = st.text_area("생성할 콘텐츠 요청", placeholder="예: 파이썬 입문자를 위한 변수 설명")

if st.button("최적화 실행", type="primary"):
    if not prompt:
        st.error("프롬프트를 입력하세요")
    else:
        progress_bar = st.progress(0)
        status = st.empty()

        # 최적화 실행 (실제로는 OptimizationLoop.run() 호출)
        for i in range(1, max_iterations + 1):
            progress_bar.progress(i / max_iterations)
            status.text(f"반복 {i}/{max_iterations} 진행 중...")
            # result = loop.run(...) 호출 위치

        # 결과 표시
        st.success(f"최적화 완료! 최종 점수: 0.85")

        # 탭으로 결과 구분
        tab1, tab2 = st.tabs(["최종 결과", "개선 히스토리"])
        with tab1:
            st.markdown("### 생성된 콘텐츠")
            st.write("최종 콘텐츠가 여기에 표시됩니다...")
        with tab2:
            st.line_chart({"점수": [0.55, 0.68, 0.79, 0.85]})

김개발 씨는 스트림릿이라는 이름은 들어봤지만 직접 써본 적은 없었습니다. "웹 개발은 프론트엔드 지식이 필요하지 않나요?" 박시니어 씨는 웃으며 대답했습니다.

"스트림릿은 파이썬만 알면 돼요. 정말 간단해요." 스트림릿은 마치 주피터 노트북을 웹 앱으로 변환해주는 마법과 같습니다.

파이썬 스크립트를 작성하면 자동으로 웹 인터페이스가 생성됩니다. st.title()로 제목을, st.text_area()로 입력창을, st.button()으로 버튼을 만들 수 있습니다.

위의 코드를 살펴보면, 먼저 **st.title()**로 페이지 제목을 설정합니다. st.sidebar는 왼쪽에 접을 수 있는 설정 패널을 만듭니다.

여기에 목표 점수와 최대 반복 횟수를 조절하는 위젯을 배치했습니다. **st.slider()**는 드래그로 값을 조절할 수 있는 슬라이더를 만듭니다.

인자로 라벨, 최솟값, 최댓값, 기본값, 단계를 받습니다. **st.number_input()**은 숫자 입력 필드를 만듭니다.

**st.text_area()**는 여러 줄 텍스트 입력을 받습니다. placeholder 인자로 예시 텍스트를 보여줄 수 있습니다.

**st.button()**은 클릭 가능한 버튼을 만들고, 클릭되면 True를 반환합니다. **st.progress()**는 진행률 바를 만듭니다.

0에서 1 사이의 값을 주면 그에 맞게 채워집니다. 최적화 루프가 진행될 때마다 업데이트하면 사용자가 진행 상황을 알 수 있습니다.

**st.empty()**는 나중에 내용을 채울 빈 자리를 만들어두는 것입니다. **st.tabs()**는 탭 인터페이스를 만듭니다.

최종 결과와 개선 히스토리를 탭으로 분리하면 화면이 깔끔해집니다. **st.line_chart()**는 데이터를 선 그래프로 시각화합니다.

점수 변화를 그래프로 보여주면 최적화 과정을 한눈에 파악할 수 있습니다. 실무에서는 st.session_state를 활용해서 상태를 유지하고, st.cache_data로 비용이 많이 드는 연산 결과를 캐싱합니다.

또한 st.spinner()로 로딩 중임을 표시하면 사용자 경험이 좋아집니다. 주의할 점은 스트림릿이 스크립트 전체를 매번 다시 실행한다는 것입니다.

버튼을 클릭하거나 입력값을 바꾸면 위에서부터 코드가 다시 실행됩니다. 따라서 비용이 많이 드는 연산은 조건문 안에 넣거나 캐싱해야 합니다.

김개발 씨는 스트림릿 앱을 팀원들에게 보여주었습니다. "와, 프롬프트 넣고 버튼만 누르면 되네요.

점수가 올라가는 것도 그래프로 보이고요!" 팀원들의 반응이 좋았습니다.

실전 팁

💡 - streamlit run app.py 명령으로 앱을 실행합니다

  • st.session_state를 사용하면 위젯 값과 결과를 유지할 수 있습니다
  • st.cache_data 데코레이터로 API 호출 결과를 캐싱하면 비용을 절약할 수 있습니다

7. 에이전트 테스트 및 개선

UI까지 완성한 김개발 씨는 드디어 시스템을 운영 환경에 배포할 준비를 했습니다. 하지만 박시니어 씨가 말렸습니다.

"잠깐, 테스트는 했어요? 실제 다양한 상황에서 제대로 동작하는지 검증해야 해요." 김개발 씨는 멈칫했습니다.

AI 시스템은 어떻게 테스트하는 걸까요?

에이전트 테스트는 일반 소프트웨어 테스트와 다른 접근이 필요합니다. LLM의 출력은 비결정적이므로 정확한 값을 예측하기 어렵습니다.

대신 출력의 "특성"을 검증합니다. 점수가 특정 범위 안에 있는지, 개선이 실제로 일어나는지, 종료 조건이 제대로 작동하는지 등을 테스트합니다.

또한 실패 케이스를 수집하고 분석해서 시스템을 지속적으로 개선합니다.

다음 코드를 살펴봅시다.

import pytest
from unittest.mock import Mock, patch

class TestOptimizationLoop:
    def test_score_improves_over_iterations(self):
        """점수가 반복에 따라 개선되는지 검증"""
        # Mock 생성기와 평가기 설정
        mock_generator = Mock()
        mock_generator.generate.side_effect = ["초안", "개선1", "개선2"]

        mock_evaluator = Mock()
        mock_evaluator.evaluate.side_effect = [
            EvaluationResult(score=0.5, feedback="개선 필요", criteria_scores={}),
            EvaluationResult(score=0.7, feedback="조금 더", criteria_scores={}),
            EvaluationResult(score=0.85, feedback="좋음", criteria_scores={}),
        ]

        loop = OptimizationLoop(mock_generator, mock_evaluator)
        result = loop.run("테스트", ["기준1"], max_iterations=5, target_score=0.8)

        # 점수가 목표를 달성했는지 확인
        assert result.final_score >= 0.8
        # 반복 횟수가 적절한지 확인
        assert result.iterations <= 5

    def test_stops_on_no_improvement(self):
        """개선이 없을 때 조기 종료하는지 검증"""
        condition = NoImprovementCondition(patience=2, min_delta=0.05)
        context = {"score_history": [0.5, 0.52, 0.53, 0.54]}

        should_stop, reason = condition.should_stop(context)
        assert should_stop is True
        assert "개선 없음" in reason

김개발 씨는 테스트 코드를 작성하려고 했지만 막막했습니다. 일반적인 함수는 입력에 대해 예상 출력을 알 수 있습니다.

하지만 LLM은 같은 입력에도 다른 출력을 낼 수 있습니다. 어떻게 테스트해야 할까요?

박시니어 씨가 설명했습니다. "AI 시스템 테스트는 '정답'이 아니라 '특성'을 검증해요.

점수가 올라가는지, 종료 조건이 작동하는지, 극단적인 입력에도 크래시하지 않는지를 봐요." 위의 코드에서 Mock을 활용한 테스트를 볼 수 있습니다. 실제 LLM API를 호출하면 비용도 들고 결과도 일정하지 않습니다.

그래서 Mock 객체로 생성기와 평가기를 대체합니다. mock_generator.generate.side_effect에 리스트를 주면, 호출될 때마다 리스트의 값을 순서대로 반환합니다.

test_score_improves_over_iterations 테스트는 핵심 기능을 검증합니다. 점수가 0.5에서 0.7을 거쳐 0.85로 올라가도록 Mock을 설정하고, 최종 점수가 목표인 0.8 이상인지 확인합니다.

또한 반복 횟수가 최대치를 넘지 않는지도 확인합니다. test_stops_on_no_improvement 테스트는 종료 조건을 검증합니다.

점수 히스토리가 0.5, 0.52, 0.53, 0.54로 개선 폭이 매우 작을 때, NoImprovementCondition이 제대로 종료를 결정하는지 확인합니다. 실무에서는 골든 테스트 세트도 만듭니다.

다양한 유형의 프롬프트와 기대하는 최소 품질 수준을 정의해두고, 시스템 변경 시마다 이 테스트를 돌려서 품질이 유지되는지 확인합니다. 또한 실패 케이스 수집이 중요합니다.

운영 중에 품질이 낮은 결과가 나오면 그것을 저장해둡니다. 왜 품질이 낮았는지 분석하고, 평가 기준이나 프롬프트를 개선합니다.

이 과정을 반복하면서 시스템이 점점 나아집니다. 주의할 점은 테스트가 너무 엄격하면 안 된다는 것입니다.

LLM의 특성상 약간의 변동은 자연스럽습니다. 점수가 정확히 0.85여야 한다고 테스트하면 깨지기 쉽습니다.

대신 "0.8 이상"처럼 범위로 검증해야 합니다. 김개발 씨는 테스트를 모두 통과시킨 후, 운영 환경에 배포했습니다.

그리고 실패 케이스를 수집하는 로깅도 추가했습니다. 일주일 후, 수집된 데이터를 분석해서 평가 기준의 가중치를 조정했더니 품질이 더 올라갔습니다.

박시니어 씨가 말했습니다. "이게 바로 지속적 개선이에요.

AI 시스템은 한 번 만들고 끝이 아니라, 계속 관찰하고 개선해야 해요." 김개발 씨는 고개를 끄덕였습니다. 처음에는 단순한 API 호출로 시작했지만, 이제 생성기, 평가기, 최적화 루프, 종료 조건, UI, 테스트까지 완전한 시스템을 갖추게 되었습니다.

"평가-최적화 패턴, 이제 확실히 이해했어요!"

실전 팁

💡 - Mock을 사용하면 비용 없이 반복 가능한 테스트를 만들 수 있습니다

  • 골든 테스트 세트를 만들어서 시스템 변경 시 품질 저하를 감지하세요
  • 운영 중 실패 케이스를 수집하고 분석하는 파이프라인을 구축하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#AI에이전트#LLM#평가최적화#스트림릿#Python,AI,LLM

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.