복잡한 LLM 시스템 프롬프트 최적화 - State Machine을 활용한 최적 응답 설계

🚀 소개

LLM에게 상태가 복잡하게 얽히고, 상태에 따른 적절한 판단과 답변을 줘야하는 경우, 시스템 프롬프트를 잘 작성하기도 어렵고 LLM이 이해도 잘 못하는 문제가 있었습니다.

이번 사례에서는 State Machine(FSM, 유한 상태 기계) 을 활용하여 LLM이 상태를 정확히 인식하고, 해당 상태에서 적절한 응답만 생성하도록 설계한 과정을 소개합니다. 이를 통해 LLM의 일관성을 유지하고 불필요한 응답을 최소화하는 방법을 살펴보겠습니다. 설명의 용의성을 위해 실 활용 사례가 아닌 간략화한 예시를 통해 여러분도 쉽게 이해해보실 수 있을 것 같습니다.

🔹 진행 방법

🧐 기존 방식의 문제점

1️⃣ LLM이 현재 상태를 정확히 인식하지 못함

  • 기존 프롬프트 방식에서는 단순한 조건 나열로 인해 LLM이 맥락을 잘못 이해하는 경우가 많았음.

2️⃣ 일관성 없는 답변 생성

  • 상태에 따라 응답을 제한할 방법이 없어, 동일한 조건에서도 답변이 일관되지 않았음.

3️⃣ LLM 판단의 정량적 평가 어려움

  • 응답 품질을 객관적으로 측정하기 어려웠으며, AI의 의사결정을 검증하는 과정이 부족했음.

✅ 해결책: State Machine(FSM) 도입

🟢 현재 상태를 명확하게 정의 → 모델이 현재 상태를 인식하도록 프롬프트를 개선.

🟢 상태 간 전환 규칙 적용 → 에너지, 배고픔, 피로도 등의 매개변수를 기반으로 상태 전환 로직 추가.

🟢 정량적 평가 가능 → 응답을 수치화하여 모델의 동작을 분석하고 평가할 수 있도록 함.

🔹 State Machine 설계

구글링 하다가 해당 사진을 보고 이 것으로 사례글을 만들어야겠다 생각했습니다

흐��름도의 예

본 시스템에서는 개발자를 모티브로 코딩(CODING), 식사(EATING), 수면(SLEEPING) 이라는 3가지 상태를 정의하고, 상태 간 전환 규칙을 설정했습니다. 먹고 자고 싸는 거 외엔 개발만 하는 미치광이 개발자

📌 상태 전환 규칙:

  • 코딩(CODING) 상태

    • 에너지가 20 이하 → 식사(EATING) 상태로 전환

    • 피로도가 80 이상 → 수면(SLEEPING) 상태로 전환

    • 배고픔이 70 이상 → 식사(EATING) 상태로 전환

  • 식사(EATING) 상태

    • 배고픔이 20 이하 → 코딩(CODING) 상태로 전환

    • 에너지가 30 이하 → 수면(SLEEPING) 상태로 전환

    • 피로도가 70 이상 → 수면(SLEEPING) 상태로 전환

  • 수면(SLEEPING) 상태

    • 피로도가 20 이하 & 에너지가 50 이상 → 코딩(CODING) 상태로 전환

    • 배고픔이 60 이상 → 식사(EATING) 상태로 전환

📌 상태 전환 다이어그램:

🖼️ 이걸 다시 Mermaid로 전환하면 이렇게 그려집니다.

stateDiagram-v2
    [*] --> CODE

    CODE --> EAT: energy <= 20
    CODE --> SLEEP: tiredness >= 80
    CODE --> EAT: hunger >= 70

    EAT --> CODE: hunger <= 20 && energy > 30 && tiredness < 70
    EAT --> SLEEP: hunger <= 20 && (energy <= 30 || tiredness >= 70)

    SLEEP --> CODE: tiredness <= 20 && energy >= 50 && hunger < 60
    SLEEP --> EAT: tiredness <= 20 && energy >= 50 && hunger >= 60

    note right of CODE
        While CODING:
        • Energy -2 to -5/hr
        • Hunger +2 to +4/hr
        • Tiredness +2 to +5/hr
    end note

    note right of EAT
        While EATING:
        • Energy +3 to +6/hr
        • Hunger -5 to -8/hr
        • Tiredness +0 to +2/hr
    end note

    note right of SLEEP
        While SLEEPING:
        • Energy +4 to +7/hr
        • Hunger +1 to +2/hr
        • Tiredness -4 to -8/hr
    end note

🔹 실행

🔹 프롬프트 설계

LLM이 상태를 정확히 이해하고, 각 상태에 맞는 응답만 제공하도록 설계된 프롬프트는 다음과 같습니다.

You are simulating developer status changes.
Current state: {current_state}
Time period: {time_step} hours
Current metrics:
- Energy: {current_energy}
- Hunger: {current_hunger}
- Tiredness: {current_tiredness}

Apply these rules based on current state:
IF CODING:
    - Energy should decrease (-2 to -5 per hour)
    - Hunger should increase (2 to 4 per hour)
    - Tiredness should increase (2 to 5 per hour)

IF EATING:
    - Energy should increase (3 to 6 per hour)
    - Hunger should decrease (-5 to -8 per hour)
    - Tiredness should increase slightly (0 to 2 per hour)

IF SLEEPING:
    - Energy should increase (4 to 7 per hour)
    - Hunger should increase slightly (1 to 2 per hour)
    - Tiredness should decrease (-4 to -8 per hour)

Respond with a JSON object containing status changes (-10 to 10).
Do not use '+' signs for positive numbers.
Example: {{"energy_delta": -5, "hunger_delta": 3, "tiredness_delta": 2}}
Your response must be a single JSON object in this exact format.

📌 프롬프트 설계 원칙

현재 상태를 명확히 인식하도록 유도

상태별 행동을 정량적으로 정의하여 일관된 응답 생성

JSON 포맷으로 응답을 강제하여 후속 처리를 용이하게 함

🔹 코드 구현 및 설명

아래는 State Machine을 활용한 개발자 상태 시뮬레이션 코드 전문입니다.

class State(Enum):
    CODE = "CODING"
    EAT = "EATING"
    SLEEP = "SLEEPING"

@dataclass
class DeveloperStatus:
    energy: float = 100.0    # 0-100
    hunger: float = 0.0      # 0-100
    tiredness: float = 0.0   # 0-100
    
    def update(self, changes: Dict[str, float]):
        self.energy += float(changes['energy_delta'])
        self.hunger += float(changes['hunger_delta'])
        self.tiredness += float(changes['tiredness_delta'])
        
        # Clamp values
        self.energy = max(0.0, min(100.0, self.energy))
        self.hunger = max(0.0, min(100.0, self.hunger))
        self.tiredness = max(0.0, min(100.0, self.tiredness))

현재 개발자가 어떤 행동을 하는 지 보여주는 State와 현재 몸 상태를 나타내는 DeveloperStatus 를 정의합니다.

class DeveloperSimulation:
    def __init__(self, openai_api_key: str):
        self.state = State.CODE
        self.status = DeveloperStatus()
        self.time = 0
        
        self.llm = ChatOpenAI(
            temperature=0.7,
            model="gpt-3.5-turbo",
            openai_api_key=openai_api_key
        )
        
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", SYSTEM_PROMPT),
        ])
    
    def get_status_update(self, time_step: float) -> Dict[str, float]:
        formatted_prompt = self.prompt.format(
            time_step=time_step,
            current_state=self.state.value,
            current_energy=self.status.energy,
            current_hunger=self.status.hunger,
            current_tiredness=self.status.tiredness
        )
        
        response = self.llm.invoke(formatted_prompt)
        
        try:
            return json.loads(response.content)
        except json.JSONDecodeError:
            print(f"\nError parsing response: {response.content}")
            return {
                'energy_delta': -1.0,
                'hunger_delta': 1.0,
                'tiredness_delta': 1.0
            }
    ...

LLM에게 개발자의 행동과 상태를 전달하여 판단을 시킵니다. 시간 경과에 따른 상태 변화 결과를 json포맷으로 응답 받습니다.

    ...
    def check_transition(self) -> State:
        if self.state == State.CODE:
            # 에너지가 너무 낮으면 먼저 음식을 섭취
            if self.status.energy <= 20:
                return State.EAT
            # 피곤하면 수면
            elif self.status.tiredness >= 80:
                return State.SLEEP
            # 배고프면 식사
            elif self.status.hunger >= 70:
                return State.EAT
        elif self.state == State.EAT:
            if self.status.hunger <= 20:  # 배고픔이 해소되면
                if self.status.energy <= 30:  # 에너지가 여전히 낮으면 수면
                    return State.SLEEP
                elif self.status.tiredness >= 70:  # 피곤하면 수면
                    return State.SLEEP
                return State.CODE  # 그외에는 코딩으로 복귀
        elif self.state == State.SLEEP:
            if self.status.tiredness <= 20 and self.status.energy >= 50:  # 충분히 쉬었고 에너지가 회복되었다면
                if self.status.hunger >= 60:  # 배고프면 먼저 식사
                    return State.EAT
                return State.CODE  # 그외에는 코딩
        return self.state
    ...

개발자 상태를 확인하고, 상태를 전환하는 부분입니다.

제가 사용하는 실사용예에서는 특정 조건에 따라 명확하게 상태가 전환되어야 해서 상태 체크/상태 전환은 하드코딩하였습니다. 상태 전환도 프롬프트를 잘 짜면 LLM에게 시킬 수 있을 것 같습니다.

def main():
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("Please set OPENAI_API_KEY environment variable")
        return
        
    dev = DeveloperSimulation(api_key)
    print("Starting developer daily routine simulation with LLM...")
    print("Press Ctrl+C to stop the simulation")
    
    try:
        while True:
            dev.simulate_step(1.5)
            time.sleep(0.3)
    except KeyboardInterrupt:
        print("\n\nSimulation ended!")

if __name__ == "__main__":
    main()

LLM key를 입력하고 시뮬레이션을 동작시킵니다. sleep은 아무리 적게해도 LLM 응답이 최소 response time이라 시뮬레이션 속도가 아주 빠르지는 않습니다.

전체 코드

from enum import Enum
import time
from dataclasses import dataclass
from typing import Dict
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
import os
import json

SYSTEM_PROMPT = """
You are simulating developer status changes.
Current state: {current_state}
Time period: {time_step} hours
Current metrics:
- Energy: {current_energy}
- Hunger: {current_hunger}
- Tiredness: {current_tiredness}

Apply these rules based on current state:
IF CODING:
    - Energy should decrease (-2 to -5 per hour)
    - Hunger should increase (2 to 4 per hour)
    - Tiredness should increase (2 to 5 per hour)

IF EATING:
    - Energy should increase (3 to 6 per hour)
    - Hunger should decrease (-5 to -8 per hour)
    - Tiredness should increase slightly (0 to 2 per hour)

IF SLEEPING:
    - Energy should increase (4 to 7 per hour)
    - Hunger should increase slightly (1 to 2 per hour)
    - Tiredness should decrease (-4 to -8 per hour)

Respond with a JSON object containing status changes (-10 to 10).
Do not use '+' signs for positive numbers.
Example: {{"energy_delta": -5, "hunger_delta": 3, "tiredness_delta": 2}}
Your response must be a single JSON object in this exact format."""

class State(Enum):
    CODE = "CODING"
    EAT = "EATING"
    SLEEP = "SLEEPING"

@dataclass
class DeveloperStatus:
    energy: float = 100.0    # 0-100
    hunger: float = 0.0      # 0-100
    tiredness: float = 0.0   # 0-100
    
    def update(self, changes: Dict[str, float]):
        self.energy += float(changes['energy_delta'])
        self.hunger += float(changes['hunger_delta'])
        self.tiredness += float(changes['tiredness_delta'])
        
        # Clamp values
        self.energy = max(0.0, min(100.0, self.energy))
        self.hunger = max(0.0, min(100.0, self.hunger))
        self.tiredness = max(0.0, min(100.0, self.tiredness))

class DeveloperSimulation:
    def __init__(self, openai_api_key: str):
        self.state = State.CODE
        self.status = DeveloperStatus()
        self.time = 0
        
        self.llm = ChatOpenAI(
            temperature=0.7,
            model="gpt-3.5-turbo",
            openai_api_key=openai_api_key
        )
        
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", SYSTEM_PROMPT),
        ])
    
    def get_status_update(self, time_step: float) -> Dict[str, float]:
        formatted_prompt = self.prompt.format(
            time_step=time_step,
            current_state=self.state.value,
            current_energy=self.status.energy,
            current_hunger=self.status.hunger,
            current_tiredness=self.status.tiredness
        )
        
        response = self.llm.invoke(formatted_prompt)
        
        try:
            return json.loads(response.content)
        except json.JSONDecodeError:
            print(f"\nError parsing response: {response.content}")
            return {
                'energy_delta': -1.0,
                'hunger_delta': 1.0,
                'tiredness_delta': 1.0
            }
    
    def check_transition(self) -> State:
        if self.state == State.CODE:
            # 에너지가 너무 낮으면 먼저 음식을 섭취
            if self.status.energy <= 20:
                return State.EAT
            # 피곤하면 수면
            elif self.status.tiredness >= 80:
                return State.SLEEP
            # 배고프면 식사
            elif self.status.hunger >= 70:
                return State.EAT
        elif self.state == State.EAT:
            if self.status.hunger <= 20:  # 배고픔이 해소되면
                if self.status.energy <= 30:  # 에너지가 여전히 낮으면 수면
                    return State.SLEEP
                elif self.status.tiredness >= 70:  # 피곤하면 수면
                    return State.SLEEP
                return State.CODE  # 그외에는 코딩으로 복귀
        elif self.state == State.SLEEP:
            if self.status.tiredness <= 20 and self.status.energy >= 50:  # 충분히 쉬었고 에너지가 회복되었다면
                if self.status.hunger >= 60:  # 배고프면 먼저 식사
                    return State.EAT
                return State.CODE  # 그외에는 코딩
        return self.state
    
    def simulate_step(self, time_step: float = 0.5):
        status_update = self.get_status_update(time_step)
        self.status.update(status_update)
        self.time += time_step
        
        new_state = self.check_transition()
        if new_state != self.state:
            print(f"\n[Time: {self.time:.1f}h] State transition: {self.state.value} -> {new_state.value}")
            self.state = new_state
            
        print(f"\rState: {self.state.value:<8} | "
              f"Energy: {self.status.energy:>6.1f} | "
              f"Hunger: {self.status.hunger:>6.1f} | "
              f"Tiredness: {self.status.tiredness:>6.1f} | "
              f"Changes: E:{float(status_update['energy_delta']):>+.1f} "
              f"H:{float(status_update['hunger_delta']):>+.1f} "
              f"T:{float(status_update['tiredness_delta']):>+.1f}", end="")

def main():
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("Please set OPENAI_API_KEY environment variable")
        return
        
    dev = DeveloperSimulation(api_key)
    print("Starting developer daily routine simulation with LLM...")
    print("Press Ctrl+C to stop the simulation")
    
    try:
        while True:
            dev.simulate_step(1.5)
            time.sleep(0.3)
    except KeyboardInterrupt:
        print("\n\nSimulation ended!")

if __name__ == "__main__":
    main()

🔹 결과 및 배운 점

개선된 점

✔️ 불필요한 답변 감소 → LLM이 현재 상태를 인식하고 해당 상태에서 적절한 답변만 생성.

✔️ 상태 기반 응답의 일관성 증가 → 동일한 상태에서 일관된 결과를 출력하여 신뢰성 향상.

✔️ 정량적 평가 가능 → 상태 변화 및 응답 데이터를 수치화하여 분석 가능.

⚠️ 시행착오

  • LLM이 자율적으로 판단할 경우 정량적 평가가 어려웠음

→ 해결책: 정량적 평가가 필요한 부분(에너지, 배고픔 등)은 수치화하고, 평가가 어려운 부분(예: 심리적 변화)은 LLM에게 맡기는 방식으로 조정.

🎯 결론

State Machine을 활용한 LLM 프롬프트 최적화는 일관된 응답을 보장하고, 불필요한 정보를 줄이며, 상태 기반 의사결정을 가능하게 하는 효과적인 접근 방식이었다.

🔜 향후 계획

🔹 피드백 기반의 동적 상태 조정

🔹 다양한 응용 사례에서의 테스트 진행

📢 개선할 아이디어가 있다면 알려주세요! 😊

3
1개의 답글

👉 이 게시글도 읽어보세요