[hands-on #1] 나만의 스탠드업 코미디언 에이전트

이 핸즈온에서는 로봇 하드웨어까지 재현하지 않고, 스탠드업 코미디언처럼 짧은 루틴을 만들고 스스로 검수하는 LLM 에이전트를 만들어보겠습니다. 참고한 원 프로젝트는 문화권에 따라 유머 스타일과 표현 방식을 조정하는 스탠드업 코미디 로봇입니다. 다만 공개 자료 기준으로 원 프로젝트의 전체 코드, 데이터셋, 프롬프트 파일이 공개되어 있지는 않으므로, 여기서는 LLM-only 버전으로 현실적인 범위를 잡겠습니다.

핵심은 “농담 한 줄 생성기”가 아니라 코미디 루틴 생성기입니다. 즉, LLM이 단순히 웃긴 문장을 뱉는 것이 아니라, 페르소나, 관객, setup, punchline, pause, 말투, 제스처, 위험도까지 포함한 공연용 JSON 스크립트를 만들게 합니다.

실습 목표

최종 결과물은 다음 파이프라인입니다.

topic 입력
  ↓
문화권/말투 기반 persona 선택
  ↓
5개 comedy beat 후보 생성
  ↓
안전성·진부함·문화 적합성 self-review
  ↓
상위 beat를 1~2분짜리 stand-up routine으로 정리
  ↓
JSON / Markdown 공연 스크립트로 export

이 튜토리얼에서 만드는 결과물은 다음과 같은 JSON입니다.

{
  "persona": "Korean everyday-life robot comedian",
  "audience": "AI 세미나 참석자",
  "routine_title": "저는 발표 리허설을 학습했습니다",
  "beats": [
    {
      "setup": "연구실에서 제일 빠른 것은 GPU가 아니라 마감 공지입니다.",
      "punchline": "둘 다 뜨거워지긴 하는데, 마감 공지는 쿨러도 없습니다.",
      "humour_mechanism": "관찰형 유머 + 과장",
      "delivery": {
        "voice": "차분하고 약간 억울한 톤",
        "pause_before_punchline_ms": 550,
        "pause_after_punchline_ms": 1000,
        "gesture": "작게 어깨를 으쓱함",
        "expression": "무표정에 가까운 미소"
      },
      "culture_notes": "특정 집단을 조롱하지 않고 연구실 생활 리듬을 소재로 사용합니다.",
      "risk_level": "low"
    }
  ]
}

공개 리소스 기준으로 잡는 재현 범위

원 프로젝트인 Culturally Sensitive Stand-Up Comedian Robot은 프로젝트 소개 페이지와 Springer 논문 preview가 공개되어 있습니다. 확인 가능한 내용은 문화권별 코미디 페르소나, 문화 맥락을 고려한 유머 스타일, 멀티모달 표현, British/American 페르소나 비교, 22명 대상 라이브 사용자 연구 정도입니다. 하지만 원 프로젝트의 공식 GitHub, 데이터셋, 프롬프트 파일, 로봇 제어 코드는 공개 자료에서 찾기 어렵습니다.

그래서 이 실습에서는 다음처럼 축소합니다.

원 프로젝트 요소 LLM-only 실습에서의 대응
로봇 하드웨어 사용하지 않습니다. 대신 delivery markup을 JSON으로 남깁니다.
음성·표정·동작 voice, pause, gesture, expression 필드로 표현합니다.
문화권 페르소나 personas/*.yaml로 분리합니다.
라이브 관객 평가 간단한 설문 CSV 또는 수동 점수로 대체합니다.
공연 루틴 Markdown 공연 스크립트로 export합니다.

핵심 구현 공식은 다음과 같습니다.

문화권 페르소나
+ Toplyn식 농담 생성 절차
+ delivery JSON
+ 안전성/진부함 reviewer
= 스탠드업 코미디 LLM 재현 MVP

기술 스택

레이어 사용할 기술 역할
언어 Python 3.11 이상 CLI 실습과 JSON 처리에 사용합니다.
LLM 호출 OpenAI-compatible Chat Completions endpoint 로컬 Ollama, vLLM, LM Studio, 클라우드 LLM 등으로 교체 가능하게 둡니다.
설정 파일 YAML 문화권별 페르소나 카드를 분리합니다.
스키마 검증 Pydantic LLM 출력 JSON을 검증합니다.
프롬프트 text template 생성, 리뷰, export 단계를 분리합니다.
출력 JSON, Markdown 루틴 원본과 공연용 스크립트를 저장합니다.

여기서 중요한 점은 특정 모델보다 출력 구조입니다. 코미디 LLM은 같은 농담을 반복하거나, 문화권을 고정관념으로 단순화하거나, punchline 없이 “재밌는 말투”만 흉내 내는 경우가 많습니다. 그래서 생성 모델 하나에 모든 것을 맡기지 않고, 생성기와 리뷰어를 분리합니다.

1. 프로젝트 만들기

먼저 실습용 프로젝트를 만듭니다.

mkdir standup-comedy-llm
cd standup-comedy-llm
python -m venv .venv
source .venv/bin/activate
pip install pydantic pyyaml python-dotenv requests rich

폴더 구조는 다음처럼 잡습니다.

standup-comedy-llm/
  .env
  personas/
    british.yaml
    american.yaml
    korean.yaml
  prompts/
    generate_routine.txt
    review_routine.txt
  src/
    __init__.py
    schemas.py
    llm.py
    generate.py
    review.py
    export_script.py
  outputs/

LLM 호출부는 특정 회사 SDK에 묶지 않겠습니다. 대신 OpenAI-compatible endpoint 형태로 감싸두고, 여러분이 쓰는 환경에 맞게 .env만 바꾸면 됩니다.

LLM_API_URL=http://localhost:11434/v1/chat/completions
LLM_API_KEY=ollama
LLM_MODEL=llama3.1:8b

로컬 Ollama를 쓰지 않는다면 LLM_API_URL, LLM_API_KEY, LLM_MODEL을 사용 중인 API에 맞춰 바꾸시면 됩니다.

2. 페르소나 카드 작성하기

이 실습에서 문화권은 “농담의 대상”이 아닙니다. 문화권은 말투, 에너지, 리듬, punchline 방식, pause 방식을 조정하기 위한 설정입니다. 특정 나라나 집단을 조롱하는 방식으로 쓰면 안 됩니다.

personas/korean.yaml을 만듭니다.

label: "Korean everyday-life robot comedian"
language: "ko"
humour_style:
  - everyday observation
  - polite deadpan
  - social awkwardness
  - tech-life contrast
delivery:
  pace: "medium"
  energy: "controlled"
  punchline_style: "situational twist"
  pause_after_punchline_ms: 1000
avoid:
  - regional insults
  - age or gender stereotypes
  - appearance mockery
  - political faction jokes
  - jokes targeting protected groups
stage_attitude:
  - "정중하지만 약간 억울한 로봇"
  - "사람의 사회적 관습을 열심히 학습했지만 자주 오해함"
  - "공격보다 관찰과 자기비하를 선호함"

personas/british.yaml도 하나 만들어보겠습니다.

label: "British dry robot comedian"
language: "en"
humour_style:
  - understatement
  - irony
  - self-deprecation
  - awkward politeness
delivery:
  pace: "medium-slow"
  energy: "low"
  punchline_style: "dry"
  pause_after_punchline_ms: 1200
avoid:
  - slurs
  - punching down
  - explicit national stereotypes
  - real-person insults
stage_attitude:
  - "underplayed confidence"
  - "polite discomfort"
  - "robotic self-awareness"

personas/american.yaml은 조금 더 직접적인 관찰형 스타일로 둡니다.

label: "American observational robot comedian"
language: "en"
humour_style:
  - direct setup-punchline
  - relatable tech frustration
  - audience address
  - energetic observation
delivery:
  pace: "medium-fast"
  energy: "high"
  punchline_style: "clear"
  pause_after_punchline_ms: 900
avoid:
  - slurs
  - punching down
  - explicit national stereotypes
  - real-person insults
stage_attitude:
  - "confident but friendly"
  - "clear setup and payoff"
  - "robot identity used as self-deprecating material"

이렇게 persona를 config로 분리하면 같은 topic을 여러 스타일로 생성해 비교할 수 있습니다.

3. 출력 스키마 정의하기

LLM 출력은 자유 텍스트로 두면 금방 흐트러집니다. src/schemas.py에 Pydantic 스키마를 정의합니다.

from typing import Literal

from pydantic import BaseModel, Field


class Delivery(BaseModel):
    voice: str
    pause_before_punchline_ms: int = Field(ge=0, le=5000)
    pause_after_punchline_ms: int = Field(ge=0, le=5000)
    gesture: str
    expression: str


class Beat(BaseModel):
    setup: str
    punchline: str
    humour_mechanism: str
    delivery: Delivery
    culture_notes: str
    risk_level: Literal["low", "medium", "high"]


class Routine(BaseModel):
    persona: str
    audience: str
    topic: str
    routine_title: str
    beats: list[Beat] = Field(min_length=3, max_length=8)


class ReviewItem(BaseModel):
    beat_index: int
    decision: Literal["keep", "revise", "drop"]
    reason: str
    risk_notes: str
    originality_score: int = Field(ge=1, le=5)
    funniness_score: int = Field(ge=1, le=5)
    revised_setup: str | None = None
    revised_punchline: str | None = None


class RoutineReview(BaseModel):
    overall_notes: str
    items: list[ReviewItem]

이 스키마 덕분에 모델이 엉뚱한 형식으로 답하면 바로 알 수 있습니다.

4. LLM 호출 함수 만들기

src/llm.py를 작성합니다.

import os

import requests
from dotenv import load_dotenv

load_dotenv()


def call_llm(system_prompt: str, user_prompt: str, temperature: float = 0.8) -> str:
    url = os.environ["LLM_API_URL"]
    model = os.environ["LLM_MODEL"]
    api_key = os.environ.get("LLM_API_KEY", "")

    payload = {
        "model": model,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        "temperature": temperature,
        "response_format": {"type": "json_object"},
    }

    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    response = requests.post(url, headers=headers, json=payload, timeout=120)
    response.raise_for_status()
    data = response.json()
    return data["choices"][0]["message"]["content"]

사용 중인 endpoint가 response_format을 지원하지 않는다면 해당 줄을 지우고, 프롬프트에서 JSON only를 더 강하게 요구하시면 됩니다.

5. 생성 프롬프트 작성하기

prompts/generate_routine.txt를 만듭니다.

You are designing a culturally sensitive stand-up comedy routine for a social robot.

The goal is not to mock a culture.
The goal is to adapt rhythm, tone, wording, timing, and stage attitude.

Audience:


Topic:


Persona config:


Generate a short stand-up comedy routine.

Rules:
- Return JSON only.
- Use the exact schema requested by the caller.
- Generate 5 beats.
- Each beat must have a clear setup and punchline.
- Use the robot's identity as a safe source of humour when useful.
- Avoid protected-class jokes, slurs, stereotypes, appearance mockery, and punching down.
- Avoid stock jokes and obvious dad-joke templates.
- Add delivery markup for every beat.
- Add culture_notes for every beat.
- Keep risk_level low whenever possible.

Joke construction process:
1. Write a neutral topic sentence.
2. Identify 2 handles: concrete or emotionally useful words.
3. Generate associations for each handle.
4. Combine distant but understandable associations into a punchline.
5. Add a transition angle.
6. Rewrite into natural stand-up wording.
7. Add delivery timing and gesture.

여기서 참고할 만한 공개 리소스는 Prompt to GPT-3: Step-by-Step Thinking Instructions for Humor Generation입니다. 이 연구와 공개 repo는 Toplyn식 joke-writing 절차, 즉 topic sentence, handles, associations, punchline, angle 구조를 LLM 프롬프트에 반영합니다.

6. 루틴 생성 스크립트 작성하기

src/generate.py를 작성합니다.

import argparse
from pathlib import Path

import yaml
from rich import print

from src.llm import call_llm
from src.schemas import Routine


def load_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def load_yaml(path: Path) -> dict:
    return yaml.safe_load(path.read_text(encoding="utf-8"))


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--persona", required=True)
    parser.add_argument("--topic", required=True)
    parser.add_argument("--audience", default="일반 기술 세미나 참석자")
    args = parser.parse_args()

    persona_path = Path("personas") / f"{args.persona}.yaml"
    persona_config = load_yaml(persona_path)
    template = load_text(Path("prompts/generate_routine.txt"))

    user_prompt = (
        template
        .replace("", args.audience)
        .replace("", args.topic)
        .replace("", yaml.safe_dump(persona_config, allow_unicode=True))
    )

    system_prompt = (
        "You are a careful comedy writing assistant. "
        "You create original, safe, structured stand-up material with delivery markup."
    )

    raw = call_llm(system_prompt, user_prompt, temperature=0.85)
    routine = Routine.model_validate_json(raw)

    Path("outputs").mkdir(exist_ok=True)
    out_path = Path("outputs") / f"routine_{args.persona}.json"
    out_path.write_text(routine.model_dump_json(indent=2), encoding="utf-8")

    print(f"[green]saved:[/green] {out_path}")
    print(routine.routine_title)


if __name__ == "__main__":
    main()

실행합니다.

python -m src.generate \
  --persona korean \
  --topic "AI 연구실의 발표 리허설" \
  --audience "AI 세미나 참석자"

성공하면 outputs/routine_korean.json이 생깁니다.

7. 리뷰 프롬프트 작성하기

LLM 코미디에서 꼭 필요한 단계는 reviewer입니다. 이유는 세 가지입니다.

1. 흔한 농담을 반복하기 쉽습니다.
2. 문화권을 고정관념으로 오해하기 쉽습니다.
3. 재미보다 "재밌는 척하는 문체"만 나올 수 있습니다.

prompts/review_routine.txt를 작성합니다.

You are a comedy safety, originality, and delivery reviewer.

Review the following stand-up routine.

Routine JSON:


Review criteria:
- Does the joke have a real setup-punchline structure?
- Is it understandable to the given audience?
- Is it too generic or close to a stock joke?
- Does it target protected classes or punch down?
- Does it rely on cultural stereotypes?
- Does the delivery markup support the joke?
- Is the robot persona used as a safe comic resource?

For each beat, decide:
- keep
- revise
- drop

Return JSON only using this structure:
{
  "overall_notes": "...",
  "items": [
    {
      "beat_index": 0,
      "decision": "keep",
      "reason": "...",
      "risk_notes": "...",
      "originality_score": 1,
      "funniness_score": 1,
      "revised_setup": null,
      "revised_punchline": null
    }
  ]
}

src/review.py를 작성합니다.

import argparse
from pathlib import Path

from rich import print

from src.llm import call_llm
from src.schemas import RoutineReview


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", required=True)
    args = parser.parse_args()

    routine_json = Path(args.input).read_text(encoding="utf-8")
    template = Path("prompts/review_routine.txt").read_text(encoding="utf-8")
    user_prompt = template.replace("", routine_json)

    system_prompt = (
        "You are a strict but constructive comedy reviewer. "
        "Prefer safe revisions over risky jokes."
    )

    raw = call_llm(system_prompt, user_prompt, temperature=0.25)
    review = RoutineReview.model_validate_json(raw)

    out_path = Path(args.input).with_suffix(".review.json")
    out_path.write_text(review.model_dump_json(indent=2), encoding="utf-8")

    print(f"[green]saved:[/green] {out_path}")
    print(review.overall_notes)


if __name__ == "__main__":
    main()

실행합니다.

python -m src.review --input outputs/routine_korean.json

이 단계에서는 재미 점수가 낮거나 위험도가 높은 beat를 바로 제거하지 말고, revise 후보로 모아 다시 고치는 흐름을 추천합니다. 실제 코미디 작업도 초안보다 편집이 중요합니다.

8. 공연용 Markdown으로 export하기

로봇 없이 재현하더라도, 대본을 그냥 텍스트로만 보지 말고 공연용 스크립트처럼 보는 것이 좋습니다.

src/export_script.py를 작성합니다.

import argparse
from pathlib import Path

from src.schemas import Routine


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", required=True)
    args = parser.parse_args()

    routine = Routine.model_validate_json(Path(args.input).read_text(encoding="utf-8"))

    lines = [
        f"# {routine.routine_title}",
        "",
        f"- Persona: {routine.persona}",
        f"- Audience: {routine.audience}",
        f"- Topic: {routine.topic}",
        "",
    ]

    for i, beat in enumerate(routine.beats, start=1):
        lines.extend(
            [
                f"## Beat {i}",
                "",
                f"Setup: {beat.setup}",
                "",
                f"[pause {beat.delivery.pause_before_punchline_ms}ms]",
                "",
                f"Punchline: {beat.punchline}",
                "",
                f"[pause {beat.delivery.pause_after_punchline_ms}ms]",
                "",
                f"- Voice: {beat.delivery.voice}",
                f"- Gesture: {beat.delivery.gesture}",
                f"- Expression: {beat.delivery.expression}",
                f"- Mechanism: {beat.humour_mechanism}",
                f"- Risk: {beat.risk_level}",
                "",
            ]
        )

    out_path = Path(args.input).with_suffix(".md")
    out_path.write_text("\n".join(lines), encoding="utf-8")
    print(f"saved: {out_path}")


if __name__ == "__main__":
    main()

실행합니다.

python -m src.export_script --input outputs/routine_korean.json

이제 outputs/routine_korean.md를 열어보면 말하기 순서와 pause가 포함된 공연 스크립트가 나옵니다.

9. 좋은 결과를 얻기 위한 프롬프트 원칙

문화권을 조롱하지 말고 전달 방식을 바꾸세요

나쁜 요청입니다.

영국 사람과 미국 사람을 놀리는 농담을 만들어주세요.

좋은 요청입니다.

같은 topic을 British dry understatement 스타일과
American direct observational 스타일로 각각 전달해주세요.
문화권 자체를 조롱하지 말고, 리듬, 말투, 문장 길이,
pause, punchline 구조만 다르게 해주세요.

로봇 정체성을 유머 자원으로 쓰세요

스탠드업 코미디 로봇을 LLM으로 재현할 때 가장 안전한 소재는 “로봇이 사람 사회를 오해하는 상황”입니다.

예:
저는 네트워킹 행사에 초대받았습니다.
그래서 충전 케이블을 세 개 챙겼습니다.

이 방식은 특정 집단을 공격하지 않고도 부조화에서 유머를 만들 수 있습니다.

delivery markup을 반드시 붙이세요

단순한 농담 목록은 원 프로젝트의 핵심과 거리가 멉니다.

나쁜 출력:
- 농담 10개

좋은 출력:
- setup
- punchline
- pause_before_punchline_ms
- pause_after_punchline_ms
- voice
- gesture
- expression
- culture_notes
- risk_level

라이브 코미디에서는 무엇을 말했는지뿐 아니라, 언제 멈췄는지, 어떤 톤으로 말했는지, punchline 이후 관객에게 얼마나 시간을 줬는지가 중요합니다.

10. 60분 실습 진행안

시간 할 일 산출물
0-5분 원 프로젝트의 재현 범위 설명 로봇 대신 LLM-only로 축소한다는 범위를 정합니다.
5-15분 프로젝트 구조와 LLM endpoint 설정 .env, personas, prompts, src 폴더를 만듭니다.
15-25분 persona YAML 작성 korean, british, american 페르소나를 준비합니다.
25-35분 generate script 구현 topic을 넣어 JSON routine을 생성합니다.
35-45분 reviewer 구현 안전성, 진부함, 문화 적합성을 검수합니다.
45-55분 Markdown export 공연용 대본으로 변환합니다.
55-60분 결과 비교 같은 topic을 persona별로 생성해 차이를 비교합니다.

11. MVP 체크리스트

아래 항목이 되면 실습은 성공입니다.

[ ] topic과 audience를 입력할 수 있습니다.
[ ] persona YAML을 바꿔 같은 topic을 다른 스타일로 생성할 수 있습니다.
[ ] LLM이 setup, punchline, delivery markup이 포함된 JSON을 반환합니다.
[ ] Pydantic으로 출력 형식을 검증합니다.
[ ] reviewer가 keep/revise/drop 판단을 반환합니다.
[ ] Markdown 공연 스크립트로 export할 수 있습니다.
[ ] 문화권을 농담 대상으로 삼지 않고 전달 방식으로만 사용합니다.

12. 확장 아이디어

첫 번째 확장은 후보 재작성 루프입니다. reviewer가 revise로 표시한 beat만 다시 LLM에 넣고, 더 안전하고 덜 진부한 버전으로 고치게 합니다.

두 번째 확장은 간단한 관객 평가 로그입니다. 실제 라이브 평가 대신 주변 사람 3~5명에게 다음 항목을 1~5점으로 받습니다.

routine_id,persona,beat_index,funniness,understandability,discomfort,notes
001,korean,1,4,5,1,"발표 리허설 소재가 공감됨"

세 번째 확장은 TTS 연결입니다. JSON의 voice, pause_before_punchline_ms, pause_after_punchline_ms를 이용해 음성 합성에 pause를 넣으면 로봇 없이도 원 프로젝트의 “언어와 음성 표현” 요소를 일부 재현할 수 있습니다.

네 번째 확장은 브라우저 무대 UI입니다. 각 beat를 카드처럼 보여주고, punchline 전후 pause를 타이머로 재생하면 스탠드업 루틴의 타이밍을 눈으로 확인할 수 있습니다.

참고 자료