이 핸즈온에서는 로봇 하드웨어까지 재현하지 않고, 스탠드업 코미디언처럼 짧은 루틴을 만들고 스스로 검수하는 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 없이 “재밌는 말투”만 흉내 내는 경우가 많습니다. 그래서 생성 모델 하나에 모든 것을 맡기지 않고, 생성기와 리뷰어를 분리합니다.
먼저 실습용 프로젝트를 만듭니다.
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에 맞춰 바꾸시면 됩니다.
이 실습에서 문화권은 “농담의 대상”이 아닙니다. 문화권은 말투, 에너지, 리듬, 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을 여러 스타일로 생성해 비교할 수 있습니다.
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]
이 스키마 덕분에 모델이 엉뚱한 형식으로 답하면 바로 알 수 있습니다.
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를 더 강하게 요구하시면 됩니다.
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 프롬프트에 반영합니다.
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이 생깁니다.
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 후보로 모아 다시 고치는 흐름을 추천합니다. 실제 코미디 작업도 초안보다 편집이 중요합니다.
로봇 없이 재현하더라도, 대본을 그냥 텍스트로만 보지 말고 공연용 스크립트처럼 보는 것이 좋습니다.
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가 포함된 공연 스크립트가 나옵니다.
나쁜 요청입니다.
영국 사람과 미국 사람을 놀리는 농담을 만들어주세요.
좋은 요청입니다.
같은 topic을 British dry understatement 스타일과
American direct observational 스타일로 각각 전달해주세요.
문화권 자체를 조롱하지 말고, 리듬, 말투, 문장 길이,
pause, punchline 구조만 다르게 해주세요.
스탠드업 코미디 로봇을 LLM으로 재현할 때 가장 안전한 소재는 “로봇이 사람 사회를 오해하는 상황”입니다.
예:
저는 네트워킹 행사에 초대받았습니다.
그래서 충전 케이블을 세 개 챙겼습니다.
이 방식은 특정 집단을 공격하지 않고도 부조화에서 유머를 만들 수 있습니다.
단순한 농담 목록은 원 프로젝트의 핵심과 거리가 멉니다.
나쁜 출력:
- 농담 10개
좋은 출력:
- setup
- punchline
- pause_before_punchline_ms
- pause_after_punchline_ms
- voice
- gesture
- expression
- culture_notes
- risk_level
라이브 코미디에서는 무엇을 말했는지뿐 아니라, 언제 멈췄는지, 어떤 톤으로 말했는지, punchline 이후 관객에게 얼마나 시간을 줬는지가 중요합니다.
| 시간 | 할 일 | 산출물 |
|---|---|---|
| 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별로 생성해 차이를 비교합니다. |
아래 항목이 되면 실습은 성공입니다.
[ ] topic과 audience를 입력할 수 있습니다.
[ ] persona YAML을 바꿔 같은 topic을 다른 스타일로 생성할 수 있습니다.
[ ] LLM이 setup, punchline, delivery markup이 포함된 JSON을 반환합니다.
[ ] Pydantic으로 출력 형식을 검증합니다.
[ ] reviewer가 keep/revise/drop 판단을 반환합니다.
[ ] Markdown 공연 스크립트로 export할 수 있습니다.
[ ] 문화권을 농담 대상으로 삼지 않고 전달 방식으로만 사용합니다.
첫 번째 확장은 후보 재작성 루프입니다. 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를 타이머로 재생하면 스탠드업 루틴의 타이밍을 눈으로 확인할 수 있습니다.