본문 바로가기
개발/Backend

요정 프롬프트 개선기

by 이의찬 2026. 1. 4.

0. 들어가며

요즘 사이드 프로젝트로 "요정"이라는 서비스를 만들고 있다. "결국 AI시대에도 핵심적인 역량은 읽기와 쓰기가 아닐까?" 라는 생각이 들어서 만들게 되었는데, 글을 읽고 요약하면 AI가 평가하고 피드백을 제공해주는 학습 서비스다. 

 

요약의 정석: 요정

글을 읽고 요약하는 능력을 체계적으로 훈련할 수 있는 AI 기반 학습 플랫폼

yojeong.ai.kr

'AI가 평가하고, 피드백을 제공'하는게 메인 기능인 서비스다보니 프로젝트를 진행하면서 프롬프트 측면에서 많은 부분을 고민해볼 수 있었다. 마침 11월부터 유메타랩에서 프리랜서로 일하는게 겹치면서 프롬프트 엔지니어링 측면에서 많은 지식을 전달받을 수 있었고, 이 부분들을 서비스에 녹여내려고 노력했다.

이 글에서는 "너는 요약 전문가야. 평가해줘" 수준의 프롬프트에서 내가 프롬프트를 어떻게 개선해나갔는지, 그 과정에서 뭘 배웠는지 정리해보려고 한다.

1. 어떤 문제가 있었나요?

우선 최초의 프롬프트를 살펴보자. 

export const getPrompt = ({
  originalText,
  userSummary,
  numOfCharacter,
}: GeminiPromptInput) => {
  return `
    당신은 텍스트 요약 전문가이자 평가자입니다.

    ### 1단계: AI 요약 생성
    - 원문을 바탕으로 "${numOfCharacter}"자 이내의 논리적 요약을 작성하세요.

    ### 2단계: 사용자 요약 평가
    - 의미적 유사성을 기반으로 0~100점 사이의 similarityScore를 부여합니다.

    ### 3단계: 결과를 JSON 형식으로 출력
    {
      "aiSummary": "요약 결과",
      "similarityScore": 0~100,
      "aiWellUnderstood": ["..."],
      "aiMissedPoints": ["..."],
      "aiImprovements": ["..."]
    }

    원문: "${originalText}"
    사용자 요약: "${userSummary}"
  `;
};

간단한 프롬프트였는데, 일단 최초의 프롬프트는 러프하게 만들어두고 추후 개선해보기로 어느정도 합의가 된 상태였다. 이 시점의 프롬프트 아키텍처는 이렇다.

최초의 프롬프트 아키텍처

1. 글자수를 정상적으로 세지 못한다.

자, 우리 서비스는 사용자가 요약한 글을 입력하면 AI가 요약한 글을 출력해주고, 사용자의 요약에 대한 피드백을 제공한다. 요약의 길이가 너무 길면 의미가 없어진다고 생각해 원문의 길이에 따라서 300자/450자/600자가 사용자가 입력할 수 있는 요약의 제한으로 설정했다.

문제는 AI 요약도 사용자의 제한에 맞춰서 작성하라고 프롬프트를 줘도, 글자수를 초과해서 나오는 경우가 많았다. 하지만 그렇다고 사용자에게는 300자 요약을 연습하려고 해놓고 정작 AI는 400자로 답변하면 바로 창을 꺼버리지 않을까?

2. 피드백과 AI 요약이 따로 논다.

AI 요약에 작성되어 있지 않은 부분을 "놓친 포인트"로 지적하는 문제가 있었다. 물론 중요하게 느껴질 수 있는 부분이였지만, AI 요약에는 언급도 안 된 내용을 사용자가 놓쳤다고 하면 억울하지 않을까?

아, 참고로 AI는 가격 측면에서 최대한 저렴한 AI를 사용해서 부담을 덜고자 Gemini 2.5 flash lite를 사용했다. 자. 그러면 이 상태에서 문제를 하나씩 고쳐보자.

2. 이슈 해결하기

글자수 최적화 이슈

우선 AI가 글자수대로 정확히 출력하지 못하는 이유는 LLM이 토큰 기반으로 출력을 해서 그렇다. 토큰의 숫자와 글자수가 정확히 맞아떨어지지 않기 때문에 AI 입장에서는 '얼추 이 정도면 300자겠지?'라고 생각해도 실제 출력은 오차가 발생할 수 밖에 없다.

요런 느낌

 

개선 과정

1. 원하는 글자수가 나올때까지 재생성하기

LM에게서 글을 넘겨받은 다음, 글자수를 체크하고 만약 원하는 글자수보다 길 경우 다시 요청을 보내는 방식이였다. 확실히 글자수를 맞출 수 있다는 장점은 있었지만, 종료 시간이 불명확하다는 단점이 너무 치명적으로 느껴졌다.

사용자가 요약을 제출하고 평가를 기다리는데, AI 요약 생성에서 재시도가 5번, 10번 돌면 사용자는 바로 이탈하지 않을까?

2. 여러 버전을 한번에 생성하게 하고 가장 수치와 가까운 버전을 선택하기 ✅

이에 반해 선택지 1은 최악의 경우에도 "그나마 가장 가까운 것"을 선택할 수 있어서 더 안정적이라고 판단했다. 또한 추가적으로 구현 과정에서 글자수가 요약 목표 수치에 가장 가까운 것으로 하되, 요약 목표 수치를 초과할 시에는 모자랄 때보다 -3배의 점수를 줘서 가능하면 초과하는 일이 없도록 했다. 사용자가 300자로 작성하는데, AI가 250자를 출력하는게 320자를 출력하는 것보다 낫다고 생각했기 때문이다. 

 외에도 문장의 수로 제한하는 방법이나 토큰의 사용량으로 제한하는 방법도 있지만, 전자는 오차만 더 커질 것이라고 생각했고, 후자는 문장이 출력되다가 그대로 잘려버리는 경우를 봤기에 부정적이였다. 

피드백과 AI 요약 일치시키기

이 문제는 비교적 간단하게 해결했는데, 그냥 피드백을 먼저 생성하고 이를 기반으로 다시 피드백을 작성하도록 했다. 

개선 과정

1. 피드백을 먼저 뽑고, 그걸 기반으로 AI 요약을 생성하기

점수는 확연히 낮아졌지만, 잘한 점이 없는데도 억지로 지어내서 말하는 문제가 있었다. 뉴턴의 만유인력 관련 아티클에 "사과는 빨개"라고 아무 의미 없는 문장을 입력했는데도 "핵심을 잘 파악했습니다"라고 나오는 것처럼...

2. LLM을 2.5 Flash로 변경

LLM을 변경하자 확실히 똑똑하게 잘했지만, 가격이 3배 이상으로 차이나기에 다른 방법을 우선 실행해보려고 했다.

3. 먼저 요약을 작성하고, 이를 참조해서 피드백을 작성하도록 하기 ✅

1번의 방법을 뒤집어서 AI 요약을 먼저 생성하고, 그 요약을 컨텍스트로 넘겨서 피드백을 생성하는 방식으로 변경했다. 이렇게 하니 AI 요약에 실제로 있는 내용만 기준으로 피드백을 생성했고, "AI 요약에 없는 내용을 놓쳤다고 지적하는" 문제가 해결됐다.

3. 점수 일관성 확보하기

로 끝났다면 좋았겠지만, 그러면 애초에 이 글을 쓰지도 않았을 것이다. 테스트를 지속적으로 하면서 새로운 문제를 발견했는데, 점수 일관성이 심각하게 박살나있는게 가장 큰 문제였다.

문제

그래서 어느정도길래 이게 가장 큰 문제라고 했나? 같은 요약인데도 점수가 최대 90점에서 최소 0점까지 매우 큰 차이가 나고, 피드백 역시도 이에 따라서 긍정과 부정을 오가는 경우가 있었다.

그러면 AI는 왜 점수 일관성을 지키지 못하는가? 그 이유는 LLM이 토큰 단위로 텍스트를 생성하는 방식이기 때문이다. AI가 요약 점수로 75점을 준다고 해보자. 이 "75점"도 결국 "7", "5", "점"이라는 토큰의 확률적 선택이다.

즉, AI가 "핵심 포인트 3개 중 2개를 맞췄으니까 66.7점이고, 논리 흐름이 좋으니까 +8점 해서 74.7점, 반올림하면 75점"이라고 계산하는 게 아니라. 그냥 "이 맥락에서 다음에 올 토큰으로 7이 그럴듯하다고 판단해서 75점을 주는 것이다.

(LLM의 동작원리에 더 궁금한 사람이 있다면 https://tech.kakaopay.com/post/how-llm-works/ 를 읽어보길 추천한다.)

개선과정

1. Temperature 조정하기 ✅

Temperature 조정의 경우 위에서 말한 확률적 선택의 무작위성을 조정하는 값이라고 생각하면 된다. 하지만 Temperature가 0이면 항상 가장 높은 확률의 토큰을 선택하는 방식이다. 하지만 이전보다는 차이가 줄었어도 여전히 큰 차이가 나서 다른 방법을 찾아야 했다.

2. 메타 프롬프트로 명확한 규정 만들어주기

그 다음으로 생각한 방법은 명확한 규정을 만들고, 이를 기반으로 점수를 주도록 하는 것이였다. 하지만 곧바로 문제점이 함께 떠올랐는데, 사용자가 어떤 글을 넣을지 모르는데 명확한 규정을 만드는 것이 과연 가능할까? 

이 때 떠오른 방법이 메타 프롬프트 방법인데, 글을 보고 바로 판단하는 것이 아니라 한 단계를 추가해서 우선 어떻게 판단할지에 대한 규정을 AI에게 작성하게 한다. 그리고 그 다음 다른 AI에게 다시 규정에 따라서 점수를 출력하게 하는 것이다.

하지만 이 방법의 문제는 규정을 일관되게 작성하려면 결국 괜찮은 성능의 AI가 필요하다는 것이다. 그리고 우리의 gemini 2.5 flash lite는 안타깝게도 이 "괜찮은 성능"에 미달되는 친구라고 판단해서 포기했다.

3. 등급만 판단하게 하고, 점수 계산은 서버에서 하기 ✅

계속된 실패로 Gemini 2.5 flash lite에 대한 내 기대는 모두 사라졌다. 그래서 LLM이 점수를 직접 출력하기보다 핵심 포인트, 논리 흐름, 표현의 정확성 같은 부분들의 등급만 판단하게 하고, 실제 점수는 서버에서 받아서 계산하는 방식을 도입해보았다.

export const LOGIC_QUALITY_SCORES: Record<LogicQuality, { percentage: number; description: string }> = {
  EXCELLENT: { percentage: 1.0, description: '논리 흐름이 완벽하고 인과관계가 명확함' },
  VERY_GOOD: { percentage: 0.85, description: '논리 흐름이 명확하나 사소한 비약이 있음' },
  GOOD: { percentage: 0.7, description: '전체적으로 논리적이나 일부 연결이 약함' },
  MODERATE: { percentage: 0.5, description: '기본적인 논리는 있으나 흐름이 자연스럽지 않음' },
  WEAK: { percentage: 0.3, description: '논리가 약하고 단편적 나열 위주' },
  POOR: { percentage: 0.0, description: '논리 없음. 무작위 나열' }
};

export const SCORE_WEIGHTS = {
  WITHOUT_CRITICAL: {
    COVERAGE: 50,   // 핵심 포인트 포함도
    LOGIC: 30,      // 논리 흐름
    EXPRESSION: 20  // 표현 정확성
  },
  // 비판적 사고가 있을 경우
  WITH_CRITICAL: {
    COVERAGE: 45,
    LOGIC: 25,
    EXPRESSION: 20,
    CRITICAL: 10    // 비판적 사고 반영도
  }
};

위와 같이 점수 테이블을 상수로 정의하고, LLM에게는 평가만 받아서 그 평가를 기반으로 서버에서 다시 점수를 계산하는 방식이다. 이를 통해서 LLM이 어떤 부분을 어떻게 평가했는지를 확인하는 것이 가능하도록 수정했다.

3-1. Chain-of-Thought (CoT) 도입하기 ✅

어떤 부분을 어떻게 평가했는가에 더해서 왜 이렇게 평가했는지도 확인해보고 싶었다. 그래서 Chain-of-Thought를 도입해서 LLM이 단계별로 분석 과정을 기록하게 했다. CoT는 바로 답을 내는 대신, 생각하는 과정을 거치는 방식이다.

CoT 도입은 평가 이유를 알아내는 것에 더해서 추가적인 성능 향상의 이점도 있다. 사람도 복잡한 문제를 풀 때 머릿속으로 단계를 나눠서 생각하는 것처럼 LLM도 요약에 대한 피드백을 곧바로 물어보는 것보다, "먼저 핵심 포인트를 찾아봐 → 사용자가 뭘 포함했는지 확인해봐 → 논리 흐름은 어때? → 그래서 결론은?"처럼 단계별로 사고하게 하면 판단의 질이 올라간다.

특히 여러 단계의 논리가 필요한 문제에서 효과가 좋은데, 요약 평가도 "핵심 파악 → 비교 → 판단"이라는 여러 단계가 필요하기에 적절한 케이스라고 생각했다.

{
  "analysis": {
    "keyPoints": ["포인트1", "포인트2", "포인트3"],
    "userCoverage": [true, false, true],
    "logicAnalysis": "논리 흐름이 명확하고 인과관계가...",
    "expressionAnalysis": "대체로 객관적이나 일부..."
  },
  "logicQuality": "VERY_GOOD",
  "expressionAccuracy": "GOOD"
}

이렇게 하면 점수가 이상할 때 analysis 필드를 확인해서 LLM이 판단하는 이유를 알 수 있다. 토큰 사용량이 56% 정도 증가하긴 하지만, Flash Lite가 워낙 저렴해서 1,000회당 +$0.044(약 55원) 수준이라 무시할 만했다.

사실 기본적으로 CoT가 적용되어있는 LLM들도 있는데, 해당 모델들은 학습시킬때도 CoT를 고려하여 학습시키기에 더욱 성능이 좋다. 하지만 비싸니까 그냥 Gemini 2.5 Flash lite에 직접 CoT를 구현하는 방식으로 했다. 

3-2. 프롬프트 병렬 실행 ✅

이렇게 CoT까지 적용하고 나니 우리의 Gemini 2.5 Flash Lite에게 한 번에 지나치게 많은 프롬프트를 먹이는게 아닐까? 라는 생각이 들어 프롬프트를 분리했다. 핵심포인트 추출, 논리 분석, 표현 분석, 피드백 생성을 각각 독립적인 프롬프트로 쪼개고, 실행시간에서 손해가 큰 병렬 실행 대신 병렬 실행을 선택했다.

async summaryEvaluation(...): Promise<IntegratedEvaluation> {
  // 1-4단계 병렬 실행
  const [keyPointsResult, logicResult, expressionResult, criticalResult] = 
    await Promise.all([
      this.evaluateKeyPoints(originalText, userSummary, aiSummary),
      this.evaluateLogic(userSummary),
      this.evaluateExpression(userSummary),
      this.evaluateCriticalThinking(userSummary, criticalWeakness, criticalOpposite)
    ]);
  
  // 5단계는 이전 결과를 종합해서 생성
  const feedbackResult = await this.generateFeedback(keyPointsResult, logicResult, ...);
  
  return { ...keyPointsResult, ...logicResult, ...expressionResult, ...feedbackResult };
}

그 결과 점수가 0점에서 최대 90점 널뛰기에서 45~62점 범위로 안정됐다. 또 각 평가가 독립적이니까 한 부분이 실패해도 다른 건 성공하는 에러 격리 효과도 있었고, 병렬 실행으로 속도도 빨라졌다.

3-3. 멀티턴 적용

하지만 아직 나는 만족하지 못했다. 왜냐하면 저 요약의 원문은 5천자가 넘어서 5천자만 자른 긴 글인데, 요약은 "playwright 구조 뜯어보기" 한 줄이 끝이였기 때문이다. 내 기준으로는 0점에서 10점 정도가 적절한 글인데, 아무리 일정하다고 해도 올바른 판단을 하지 못하는 것으로 느껴졌다. 

이 시점에서 휘린님이 멀티턴을 사용해보면 어떻냐는 의견을 주셨다. 멀티턴은 하나의 작업을 여러 번의 대화 턴으로 나눠서 처리하는 방식이다.

지금은 한 번에 병렬적으로 핵심포인트 추출 / 논리 분석 / 표현 분석을 처리하고 있는데, 1턴에서 핵심포인트 추출하고 → 2턴에서 추출한 핵심포인트 기준으로 논리를 분석하고... 이런 식으로 순차적으로 진행하는 거다.

장점은 각 단계가 이전 결과를 참고하니까 더 정교한 분석이 가능하다는 것이다. 하지만 두 가지 이유로 포기했다.

  1. 앞에서 잘못되면 뒤가 다 틀어진다. 병렬 실행은 각 평가가 독립적이라 한 부분이 실패해도 다른 건 성공하는데, 멀티턴은 앞 단계의 결과에 의존하니까 에러가 전파된다.
  2. 멀티턴이 효과를 보려면 이전 컨텍스트를 제대로 활용할 수 있는 사고 능력이 필요하다. "아까 내가 뭘 말했지?"를 기억하고 그걸 바탕으로 다음 판단을 해야 하는데, Gemini 2.5 Flash Lite는 그런 부분에서 너무 약하다고 생각했다.

4. LLM을 2.5 Flash로 변경 

gemini 2.5 flash lite + gemini 2.5 flash lite

gemini flash 2.5 lite로는 더 이상 진전이 어렵지 않을까? 그래서 gemini 2.5 flash로 바꾼다고 가정할시의 비용 계산을 해봤다.

  • 현재 사용자 기준 1회 사용 당 소모 토큰: 1회 요청당 2,500토큰 × 6(병렬 요청) = 15,000 토큰
  • Flash Lite: 100만 토큰에 0.1달러
  • Flash: 100만 토큰에 0.3달러
  • 서비스 기준: - 100회에 0.45달러 - 1만회에 45달러

비용이 3배로 뛰긴 하지만, 1만회에 45달러면 충분히 감당할 수 있는 수준이었다. 프롬프트를 더 쪼개거나 멀티턴을 쓰는 것보다 모델을 바꾸는 게 가장 확실한 해결책이라고 판단하고 모델을 gemini flash 2.5로 교체했다. 그 결과, 내가 의도한대로 점수가 5점에서 15점 정도로 안정적으로 나오기 시작했다.

4. 결과

현재의 프롬프트 아키텍처

4. 추가적으로 하고 싶은 작업

있으면 좋겠다 싶은 부분과 아쉬웠던 부분 개선 두 가지 측면에서 생각중이다.

있으면 좋겠다

AI 요약과 피드백에 대해서 사용자 피드백 받기

프롬프트의 결과물 자체가 정량적인 판단이 어렵고 인간이 정성적으로 판단해야한다. 지속적인 개선을 위해서 사용자에게서 결과물이 만족스러운지 피드백을 받고, 이를 기반으로 개선하면 좋을것 같다.

응답 저장하기

비용을 아끼고 점수의 일관성을 개선할 수 있는 방안에 대해서 고민중인데, '아티클과 사용자 요약을 엮어서 캐싱하는 것'과 '아티클과 프롬프트를 캐싱하는 것' 두 가지를 고민중이다.

전자의 경우에는 동일한 아티클 & 비슷한 입력에 대한 피드백 자체를 캐싱하는 것인데, 처음 떠올린 방법이긴 하지만 이런 사례 자체가 많지 않을 것 같아서 좀 꺼려진다.

후자의 경우는 메타 프롬프트를 만들고, 각 아티클마다 동일한 판단 프롬프트를 사용하도록 캐싱하는 것이다. 근데 이럴 경우 현재의 아키텍처를 좀 뜯어고쳐야해서 고민된다.

아쉬웠던 부분

아쉬웠던 것은 프롬프트의 생성 결과물은 정량적으로 판단이 어렵다. 결국 인간의 판단이 들어가서 정성적으로 봐야한다. 하지만 이걸로는 명확한 설득이 어렵다. 정량적으로 판단할 수 있도록 해보고 싶다.