노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
CEO & Fullstack Engineer
1. 결정론적 매칭이 놓친 3,067곡
멜로밍은 사용자 노래책의 곡을 Musixmatch 트랙에 매칭해 가사를 제공합니다. 매칭 파이프라인은 백엔드 서비스 안에 있고 15분 주기 백필 크론이 PENDING 상태의 global_songs 행을 처리합니다.
1차 매칭은 두 단계입니다. 먼저 Musixmatch matcher.track.get 에 원문 제목과 아티스트를 그대로 넣어 직접 매칭을 시도합니다. 404 가 나고 제목에 비라틴 문자가 섞여 있으면 Serper 로 구글 검색을 한 번 하고 상위 5개 결과를 LLM 에 한 번 넣어 로마자 표기나 영문 제목 후보를 뽑은 뒤, 후보 3개까지 다시 matcher.track.get 을 두드립니다. 이 경로의 곡당 비용은 Serper 약 $0.001 과 LLM 약 $0.001 입니다.
이 파이프라인으로 사용자 노래책의 MATCHED 비율은 53% 를 넘겼습니다. 문제는 그 뒤에 남은 것들이었습니다.
| 상태 | 곡 수 | 의미 |
|---|---|---|
UNMATCHED | 1,350 | 후보 자체를 못 찾음 |
MANUAL_NEEDED | 1,717 | 후보는 있는데 아티스트 검증 실패 |
| 합계 | 3,067 | 거의 전부 채널 5개 이상에서 사용 중 |
채널 수가 많다는 것은 사용자 노래책에서 눈에 보이는 곡이라는 뜻입니다. 원인은 대체로 다섯 가지였습니다. 동명이곡, 한국어와 영문 표기 불일치, 한국어와 일본어 표기 불일치, 한자 표기, 아티스트 별칭 인식 실패입니다.
사람이 하면 어렵지 않습니다. 검색해보고, 결과가 애매하면 다른 표기로 다시 찾고, 가사를 한 줄 확인해서 같은 곡인지 판단합니다. 여러 단계를 거치고, 중간 결과에 따라 다음 행동이 달라지는 작업입니다.
이것이 one-shot LLM 호출과 에이전트의 차이입니다. 1차 매칭의 LLM 은 검색 결과를 한 번 보고 후보를 뱉는 것으로 끝납니다. 남은 3,067곡은 그 한 번으로 안 되는 곡들만 모인 집합입니다.
그래서 남은 곡을 배치로 회수하는 별도 서비스를 만들었습니다. 이 글은 그 에이전트가 무엇을 했는지보다, 프로덕션 데이터를 쓰기 권한으로 건드리는 자율 에이전트에 무엇을 걸어야 했는지를 적은 기록입니다.
2. PoC 가 알려준 것과 숨긴 것
정식 저장소를 만들기 전에 별도 PoC 디렉터리에서 검증했습니다. 샘플은 전부 이전에 UNMATCHED 또는 MANUAL_NEEDED 였던 곡 15개였습니다.
결과는 15곡 중 14곡이 MATCHED + lyrics_verified=true, 93% 였습니다. 나머지 1건은 후보를 찾았지만 가사 검증에 실패해 보수적으로 MANUAL_NEEDED 로 남았습니다.
| 곡 | 아티스트 | 이전 상태 | 결과 | 소요 |
|---|---|---|---|---|
| 혜성 | 윤하 | UNMATCHED | MATCHED HIGH | 96초 |
| 드라이 플라워 | 유우리 | MANUAL_NEEDED | MATCHED HIGH | 67초 |
| 밤하늘의 별을 | 경서 | MANUAL_NEEDED | MATCHED HIGH | 42초 |
| 너의 의미 | 아이유 | MANUAL_NEEDED | MATCHED HIGH | 61초 |
| 팬서비스 | honeyworks | UNMATCHED | MATCHED HIGH | 749초 |
| 늘(EVER) | hebi | UNMATCHED | MANUAL_NEEDED LOW | 83초 |
재미있는 것은 매칭 경로입니다. 드라이 플라워 는 한국어 제목으로 Musixmatch 를 두드려서는 후보가 0건이었고, 에이전트가 검색으로 ドライフラワー 와 優里 라는 원문 표기를 찾아낸 뒤에야 Dried Flower 로 걸렸습니다. 밤하늘의 별을 은 Shiny Star(2020) 와 KyoungSeo 라는 영문 표기로 존재했고, 영어 버전 트랙이 별도로 있어서 가사 본문 대조로 한국어 버전을 골라야 했습니다.
실패한 1건도 정확히 실패했습니다. 늘(EVER) 은 ISRC 와 재생 시간까지 일치하는 후보를 찾았지만 Musixmatch 쪽 has_lyrics=0 이라 가사 본문이 없었고, 대조할 대상이 없으니 lyrics_verified=false 로 두고 MANUAL_NEEDED 를 반환했습니다.
여기까지가 PoC 가 알려준 것입니다. 총 소요는 1,605초, 곡당 평균 107초였고, 비용은 곡당 $0.10 에서 $0.20 사이로 추정됐습니다. 3,067곡 전량이면 $300 에서 $600 입니다. 충분히 감당 가능한 숫자였습니다.
그런데 이 평균 뒤에 숨어 있던 것이 있습니다. 곡별 편차입니다.
| 지표 | 최소 | 중앙값 부근 | 최대 |
|---|---|---|---|
| 소요 시간 | 31.6초 | 60초 부근 | 748.5초 |
| 도구 호출 수 | 6회 | 11회 부근 | 27회 |
최장 곡은 12분 반을 썼고, 도구 호출이 가장 많았던 곡은 27회를 썼습니다. 두 값은 서로 다른 곡에서 나왔습니다. 평균 107초만 보고 배치를 설계했다면 30분 주기 크론에서 5곡을 처리하는 계획은 최악의 경우 성립하지 않습니다.
검증 샘플은 성공률을 알려주지만 꼬리는 알려주지 않습니다. 15곡 중 1곡이 748초였다면, 3,067곡 중 200곡이 748초입니다.
3. 회수 대상은 SQL 이 고릅니다
에이전트에게 “알아서 회수할 곡을 찾아라”라고 하지 않았습니다. 대상 선정은 결정론적 SQL 이 합니다. 에이전트가 결정하는 것은 한 곡을 어떻게 판단하느냐뿐입니다.
백엔드 DB 에는 읽기 전용 계정으로만 붙었고, 실제 쿼리는 이렇습니다.
SELECT gs.id, gs.title, ga.canonical_name AS artist, gs.channel_count
FROM global_songs gs
JOIN global_artists ga ON ga.id = gs.global_artist_id
WHERE gs.matcher_status IN ('UNMATCHED', 'MANUAL_NEEDED')
AND gs.matcher_attempts < 5
AND (
gs.matcher_last_at IS NULL
OR gs.matcher_last_at < (NOW() - INTERVAL 24 HOUR)
)
ORDER BY gs.channel_count DESC, gs.matcher_attempts ASC, gs.id ASC
LIMIT 5각 절에 이유가 하나씩 붙어 있습니다.
channel_count DESC는 영향도 가중입니다. 같은 비용이면 많은 채널이 쓰는 곡을 먼저 회수합니다.matcher_attempts ASC는 신선한 곡에 먼저 기회를 줍니다. 여러 번 실패한 곡보다 처음 보는 곡의 성공 확률이 높습니다.matcher_attempts < 5는 안 되는 곡에 영구히 매달리지 않기 위한 상한입니다.- 24시간 쿨다운은 같은 곡이 30분마다 다시 잡히는 것을 막습니다.
이 조건들은 처음부터 있었던 것이 아닙니다. 구현하면서 matcher_attempts 를 올리는 책임이 백엔드의 자동 매처 안에만 있다는 사실이 드러났습니다. 에이전트가 실패해도 matcher_attempts 는 영원히 그대로였고, 그러면 매 tick 마다 같은 상위 5곡이 무한히 다시 뽑힙니다. 그래서 백엔드에 실패를 기록하는 전용 엔드포인트를 먼저 만들었습니다. 매칭 결과를 반영하는 성공 적용 엔드포인트와, 시도 이력만 갱신하는 실패 기록 엔드포인트가 짝을 이룹니다.
실패 기록 엔드포인트는 matcher_status 를 바꾸지 않습니다. UNMATCHED 는 UNMATCHED 로 남고 MANUAL_NEEDED 는 MANUAL_NEEDED 로 남습니다. matcher_attempts 와 matcher_last_at 만 올립니다. 실패는 상태 변화가 아니라 시도 이력이기 때문입니다.
dry-run 은 아무것도 안 하는 모드가 아닙니다
여기서 실제로 사고가 하나 났습니다.
램프업 첫 단계는 RECOVER_DRY_RUN=true 였습니다. 판단만 하고 백엔드에는 아무것도 쓰지 않는 모드입니다. 그런데 실패 기록 엔드포인트를 호출하지 않는다는 것은 matcher_last_at 이 갱신되지 않는다는 뜻이고, 그러면 위 SQL 의 쿨다운 절이 아무 역할도 하지 못합니다.
결과는 실행 기록에 그대로 남았습니다. run 11, 13, 14 와 run 16, 18, 19 의 outcome_track_id 가 같았습니다. 이무진 신호등, 최유리 숲, 잔나비를 두 번씩 처리했습니다. dry-run 단계에서 쓴 LLM 비용의 절반이 중복 처리였습니다.
고친 방식은 쿨다운을 백엔드 상태에 의존하지 않게 만드는 것이었습니다. 에이전트 자체 DB 에서 최근 24시간 안에 건드린 global_song_id 를 먼저 뽑아 NOT IN 으로 제외했습니다.
async def _recent_self_processed_ids(hours: int) -> list[int]:
"""dry-run 에서는 backend matcher_last_at 이 갱신되지 않으므로
자체 DB 의 처리 이력이 쿨다운의 진짜 기준이 됩니다."""
cutoff = datetime.now(UTC) - timedelta(hours=hours)
...dry-run 은 “아무 일도 일어나지 않는 모드”가 아니라 “부작용의 일부만 일어나는 모드”입니다. 그리고 그 부작용 중에 자기 자신의 다음 입력을 결정하는 것이 섞여 있으면 dry-run 에서만 나타나는 루프가 생깁니다.
4. 자율성의 비용 구조는 다릅니다
일반적인 배치 작업은 비용을 예측할 수 있습니다. 처리할 행이 N개면 쿼리도 N번입니다.
에이전트는 다릅니다. 곡 하나를 처리하는 데 도구를 몇 번 부를지 미리 알 수 없습니다. 검색이 애매하면 다시 검색하고 후보가 여럿이면 각각을 확인합니다. 그게 에이전트를 쓰는 이유이기도 합니다.
그런데 이 성질은 곡 하나가 예산 전체를 소진할 수 있다는 뜻이기도 합니다.
PoC 코드에는 상한이 있었습니다.
class Settings(BaseSettings):
...
# Agent budget - hard cap per song.
max_iterations: int = 8이 값은 아무것도 막지 않았습니다. settings.max_iterations 를 agent.run_sync() 에 전달하는 코드가 어디에도 없었기 때문입니다. 이름도 기본값도 상한인데 실제 동작은 무제한이었습니다.
증거는 앞 절의 실측치입니다. 상한이 8이라고 적힌 설정 아래에서 도구 호출 27회짜리 곡과 748초짜리 곡이 나왔습니다. 값이 8인데 27이 나왔다는 사실 자체가 그 설정이 죽어 있다는 증명입니다.
검증 단계에서는 이 사실이 드러나지 않습니다. 15곡을 돌려도 결과는 나오고, 성공률도 93% 이고 로그도 정상입니다. 상한을 넘었다는 신호는 어디에도 없습니다. 그 상한이 실제로는 존재하지 않기 때문입니다.
정식 저장소에서는 매 호출에 usage_limits 를 명시적으로 전달했습니다.
def _usage_limits() -> UsageLimits:
return UsageLimits(
request_limit=12, # RECOVER_AGENT_REQUEST_LIMIT
tool_calls_limit=12, # RECOVER_AGENT_TOOL_CALLS_LIMIT
total_tokens_limit=300_000, # RECOVER_AGENT_TOTAL_TOKENS_LIMIT
response_tokens_limit=8_000, # RECOVER_AGENT_RESPONSE_TOKENS_LIMIT
)
result = await matcher_agent().run(
user_prompt,
deps=deps,
usage_limits=_usage_limits(),
)tool_calls_limit=12 는 PoC 실측 최대치 27의 절반 아래입니다. 27회를 쓴 곡도 결국은 성공했지만 그 성공 하나를 위해 예산의 상한을 열어두는 대신 12에서 자르고 실패로 기록하는 쪽을 택했습니다. 회수 배치는 전량 회수를 목표로 잡지 않았습니다. 감당 가능한 비용 안에서 되는 만큼만 가져옵니다.
total_tokens_limit=300_000 은 곡당 약 $0.40 에 해당하는 하드 캡입니다. 비용 추정에 쓰는 상수는 이렇습니다.
# 정확한 금액은 OpenRouter 대시보드가 기준입니다.
# 이 상수는 일일 USD 캡을 소리 없이 태우지 않기 위한 소프트 가드입니다.
_COST_PER_1M_INPUT_USD = 3.0
_COST_PER_1M_OUTPUT_USD = 15.0상한에 닿으면 pydantic-ai 가 UsageLimitExceeded 를 던집니다. 이 예외는 그냥 삼키지 않고 별도 종료 상태로 기록했습니다.
except UsageLimitExceeded as e:
metrics.labels_run("USAGE_LIMIT").inc()
await db.finalize_run(run_id, status="USAGE_LIMIT", ...)
if not settings.recover_dry_run:
apply.apply_agent_fail(global_song_id, "agent usage_limit")USAGE_LIMIT 을 ERROR 와 구분한 이유는 대응이 다르기 때문입니다. ERROR 는 고쳐야 할 결함이고 USAGE_LIMIT 은 설계대로 동작한 결과입니다. 다만 이것이 자주 발생하면 상한이나 모델을 재검토해야 하므로 1시간에 3건을 넘으면 경고가 울리도록 알람을 따로 걸었습니다.
상한은 걸어둔 것으로 끝이 아니라, 실제로 걸리는지 확인해야 합니다. 한도에 닿는 입력을 일부러 만들어 테스트하지 않으면 한도가 없는 것과 같은 상태로 운영에 들어갑니다.
5. 도구는 좁게, 아주 좁게
에이전트에게 준 도구는 6개입니다.
| 도구 | 역할 |
|---|---|
mxm_matcher_track_get | 원문 제목과 아티스트로 직접 매칭 |
mxm_track_search | 퍼지 검색, 후보 최대 10개 |
mxm_track_get | 후보의 ISRC, Spotify ID, 앨범 등 메타데이터 검증 |
mxm_track_lyrics_get | Musixmatch 에 저장된 가사 본문 조회 |
serper_search | 구글 검색, 로마자와 한자와 영문 변형 발굴 |
web_fetch | 가사 사이트 본문 조회 |
앞의 다섯 개는 대상 API 가 고정되어 있습니다. 마지막 하나가 문제입니다.
web_fetch 는 에이전트가 조종하는 HTTP 클라이언트를 서버 안에 두는 일입니다. 요청 대상은 모델의 출력으로 결정되고 모델의 출력은 검색 결과의 영향을 받고, 검색 결과는 외부에서 들어온 곡 제목의 영향을 받습니다.
PoC 는 검색이 돌려준 URL 을 그대로 가져왔습니다. 실행 로그에 남은 실제 요청 대상에는 azlyrics.com 과 lyricbot.tistory.com 이 있습니다. 두 곳 모두 나쁜 사이트는 아닙니다. 문제는 어디로 나갈지를 아무도 결정하지 않았다는 것입니다.
정식 저장소에서는 네 겹으로 막았습니다.
호스트 허용 목록
_WEB_FETCH_ALLOWED_HOSTS: frozenset[str] = frozenset(
{
"genie.co.kr", "www.genie.co.kr",
"melon.com", "www.melon.com",
"music.bugs.co.kr",
"vibe.naver.com",
"music.naver.com",
"namu.wiki",
"en.wikipedia.org", "ko.wikipedia.org", "ja.wikipedia.org",
"music.youtube.com",
"www.musixmatch.com",
"lyrics.com", "www.lyrics.com",
"j-lyric.net", "www.j-lyric.net",
"utaten.com", "www.utaten.com",
"uta-net.com", "www.uta-net.com",
}
)21개 항목이 전부입니다. 국내 가사 사이트, 일본어 가사 사이트, 위키 세 개 언어판, 그리고 Musixmatch 자체입니다. 목록에 없으면 요청 자체를 하지 않습니다. 차단 목록이 아니라 허용 목록입니다. 무엇을 막을지 열거하는 방식은 언제나 빠뜨린 것이 남습니다.
사설 대역 차단
허용 목록만으로는 부족합니다. 허용된 호스트 이름이 내부 주소로 해석되면 그대로 통과하기 때문입니다.
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return True # 이름 해석 실패는 안전하지 않은 것으로 간주합니다
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_private # 10.x, 172.16-31.x, 192.168.x
or ip.is_loopback # 127.x
or ip.is_link_local # 169.254.x
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
return True169.254.x 를 막는 이유가 특히 분명합니다. 클라우드 메타데이터 엔드포인트가 그 대역에 있습니다. 서버 측 요청 위조는 에이전트에 웹 도구를 붙이는 순간 기본으로 고려해야 하는 위험입니다.
한계도 있습니다. DNS 는 요청당 한 번만 해석하므로 검사 직후에 응답이 바뀌는 DNS 리바인딩은 방어 범위 밖입니다. 30분 주기 배치 크론이라는 사용 형태에서는 감수 가능한 위험으로 판단했습니다.
리다이렉트 미추적
with httpx.Client(timeout=15.0, follow_redirects=False) as client:
resp = client.get(url, headers=headers)
if 300 <= resp.status_code < 400:
return {"url": url, "status": resp.status_code,
"error": f"redirect to {resp.headers.get('location', '?')}"}허용된 호스트가 301 로 허용되지 않은 호스트를 가리키면 허용 목록이 무력화됩니다. 그래서 리다이렉트를 따라가지 않고 에러로 돌려줍니다. 에이전트가 새 URL 로 다시 호출하면 그 URL 이 다시 검사를 받습니다. 검사를 통과한 요청이 아니라 검사를 통과한 응답만 신뢰합니다.
응답 길이 제한
if len(text) > max_chars: # max_chars=4000
text = text[:max_chars] + "...(truncated)"가사 한 줄만 대조하면 되는데 페이지 전체를 컨텍스트에 넣을 이유가 없습니다. 비용과 프롬프트 주입 표면이 동시에 줄어듭니다.
이 네 가지는 전부 테스트로 고정했습니다. 목록에 없는 호스트 거부, file:// 스킴 거부, 허용 호스트가 127.0.0.1 로 해석될 때 거부, 리다이렉트 거부, 본문 절단입니다. 외부 HTTP 는 respx 로 모킹했고 DNS 해석은 monkeypatch 로 바꿔치기했습니다.
테스트 규칙 자체는 세 가지로 정해뒀습니다. LLM 호출은 실제 모델 대신 pydantic_ai.models.test.TestModel 로 대체하고, DB 는 aiosqlite 로 격리하고 외부 API 는 respx 로 모킹합니다. 다만 저장소에 실제로 남은 테스트는 두 파일뿐입니다. 출력 스키마 검증과 web_fetch 하드닝입니다. 커버리지는 얇지만 고정한 두 곳이 각각 “잘못된 결과가 적용되는 경로”와 “서버가 임의의 곳으로 나가는 경로”라는 점은 의도적이었습니다. 자율 시스템에서는 정상 동작보다 경계를 먼저 못 박아야 합니다.
가사 본문은 남기지 않습니다
가져온 가사 본문은 어디에도 저장하지 않습니다. 대조에 쓴 뒤 해시만 남깁니다.
def lyrics_match_sample_hash(self) -> str | None:
"""감사 로그용. 저작물 원문을 노출하지 않기 위해 SHA256 앞 16자만 남깁니다."""
if not self.lyrics_match_sample:
return None
return hashlib.sha256(self.lyrics_match_sample.encode("utf-8")).hexdigest()[:16]자체 DB 컬럼도 lyrics_match_sample_hash CHAR(16) 하나뿐이고, 추적 로그에도 본문을 넣지 않습니다. Logfire 로 pydantic-ai 와 httpx 와 sqlalchemy 를 자동 계측하고 있었기 때문에, 아무 생각 없이 두면 span 속성에 가사 본문이 그대로 실려 나갈 수 있었습니다.
가사는 저작물이고 디버깅 편의를 위해 남긴 로그가 곧 저작물 사본이 되는 상황을 피해야 합니다. “나중에 볼 수 있게 일단 남겨두자”가 가장 위험한 기본값입니다.
6. 주장을 검증으로 바꿉니다
에이전트가 “이 곡이 맞습니다”라고 말하는 것은 그 자체로는 근거가 아닙니다. 확신에 찬 오답은 LLM 의 기본 실패 모드입니다.
그래서 성공 조건을 에이전트의 판단이 아니라 확인 가능한 사실로 정의했습니다.
Musixmatch 가사 본문과 웹 가사 페이지 본문에서 공통으로 등장하는 구별 가능한 한 줄을 찾았을 때만 매칭으로 인정합니다.
모델은 설정값이고 스키마는 계약입니다
구현 스택은 pydantic-ai 이고 LLM 호출은 OpenRouter 게이트웨이만 통과합니다. 제공자 API 키를 직접 들고 있지 않고 모델도 코드에 박지 않습니다.
@cache
def _model() -> OpenRouterModel:
return OpenRouterModel(settings.musixmatch_agent_model, provider=_provider())운영값은 Vault 의 MUSIXMATCH_AGENT_MODEL 이고 실제로 쓴 값은 x-ai/grok-4.3 이었습니다. 벤치에서 매칭률이 가장 높으면서 비용이 적정했다는 이유로 골랐고, 1차 매칭의 대체 검색 경로가 이미 같은 모델을 쓰고 있었습니다.
모델 ID 를 하드코딩하지 않은 이유는 교체 절차를 짧게 만들기 위해서입니다. 모델을 바꾸는 데 필요한 것은 이 한 줄뿐입니다.
vault kv patch <service-secret-path> \
MUSIXMATCH_AGENT_MODEL=anthropic/claude-sonnet-4-7교체가 한 줄이면 교체 후에 무엇이 달라지는지도 한 줄로 확인할 수 있어야 합니다. 그 확인 기준이 다음 항목입니다.
출력 형식은 Pydantic 모델로 강제했습니다.
class MatchResult(BaseModel):
status: Literal["MATCHED", "MANUAL_NEEDED", "UNMATCHED"]
mxm_track_id: int | None = None
confidence: Literal["HIGH", "MEDIUM", "LOW"] = "LOW"
lyrics_verified: bool = False
lyrics_match_sample: str | None = None
reason: str
@model_validator(mode="after")
def _matched_requires_track_id(self) -> MatchResult:
if self.status == "MATCHED" and self.mxm_track_id is None:
raise ValueError("status=MATCHED requires mxm_track_id")
return selfoutput_type=MatchResult 로 지정하면 형식이 어긋난 출력은 성공이 아니라 실패가 됩니다. 그리고 검증기가 하나 더 있습니다. MATCHED 인데 트랙 ID 가 없는 응답은 애초에 객체가 되지 못합니다.
적용 시점에도 한 번 더 확인합니다. 스키마를 통과했다는 것과 적용해도 된다는 것은 다르기 때문입니다.
if (
verdict.status == "MATCHED"
and verdict.lyrics_verified
and verdict.mxm_track_id is not None
):
apply.apply_match(global_song_id, verdict.mxm_track_id)
else:
apply.apply_agent_fail(global_song_id, verdict.reason or verdict.status)MATCHED 이면서 lyrics_verified=false 인 응답은 적용되지 않고 실패로 기록됩니다. 모델이 스스로 정한 상태보다 증거의 유무가 우선합니다.
실제로 남은 증거는 이런 모양입니다. 아이유 너의 의미 는 Musixmatch 에 Meaning of you 라는 영문 제목으로 있었고, 공통으로 발견된 한 줄은 너의 그 한 마디 말도 그 웃음도 나에겐 커다란 의미 였습니다. 스웨덴세탁소 그래도 나 사랑하지 는 Musixmatch 쪽 가사가 영어 번역본이어서, Even if your belly comes out and you grow bald 와 배가 나오고 대머리가 돼도 를 대응시켜 검증했습니다.
같은 도메인의 다른 LLM 경로와 비교
멜로밍에는 LLM 이 곡을 고르는 경로가 하나 더 있습니다. 채팅에서 !노래책추가 를 처리하는 경로입니다. 이쪽의 성공 조건은 스칼라 신뢰도입니다.
export class GlobalSongMatcherService {
static readonly AUTO_THRESHOLD = 0.8;
...
autoAcceptable: clamped >= GlobalSongMatcherService.AUTO_THRESHOLD,
}신청곡 매칭은 0.5에서 0.7 사이를 쓰고, 노래책 등록은 0.8 을 씁니다. 노래책은 영구 등록이라 노이즈 비용이 크기 때문에 더 보수적입니다. 이 방식은 후보 목록이 정해져 있고 그중 하나를 고르는 문제에는 잘 맞습니다. 잘못 고르면 사용자가 바로 알아채고 지울 수 있습니다.
에이전트 쪽은 성격이 다릅니다. 후보 목록이 정해져 있지 않고 잘못 매칭되면 다른 곡의 가사가 방송 화면에 뜹니다. 그래서 같은 도메인인데도 성공 조건을 다르게 정의했습니다.
| 노래책 추가 LLM | 회수 에이전트 | |
|---|---|---|
| 성공 조건 | confidence >= 0.8 | lyrics_verified == true |
| 근거의 성격 | 모델의 자기 평가 | 두 출처의 본문 일치 |
| 모델 교체 시 | 임계값의 의미가 바뀜 | 기준이 그대로 유지됨 |
| 오답의 비용 | 사용자가 삭제 | 다른 곡 가사 노출 |
임계값은 모델에 종속되고 외부 사실은 모델에 종속되지 않습니다. 모델을 바꾸면 0.8 이 무엇을 뜻하는지 다시 측정해야 하지만 “가사 한 줄이 일치했다”는 기준은 모델이 바뀌어도 같은 것을 의미합니다.
시스템 프롬프트의 마지막 문단도 같은 방향으로 적었습니다. 잘못된 매칭 = 사용자에게 다른 곡 가사 노출. 의심스러우면 MANUAL_NEEDED 입니다. PoC 에서 늘(EVER) 한 건이 이 지시대로 물러섰습니다.
7. MANUAL_NEEDED 는 실패가 아니라 대기열입니다
앞 절에서 에이전트가 자신 없을 때 물러서게 만들었습니다. 그러면 물러선 곡은 어떻게 될까요. 그냥 쌓아두면 회수 대상만 늘어날 뿐입니다.
핵심은 에이전트가 물러설 때 빈손으로 물러서지 않는다는 점입니다. 확신이 부족해 MANUAL_NEEDED 로 내려갈 때도 자기가 찾은 후보 트랙 ID 는 행에 저장해 둡니다. 그래서 이 상태는 “실패”가 아니라 “사람이 한 번 봐야 하는 제안”입니다.
어드민에 검수 큐가 붙어 있습니다.
const baseWhere: Prisma.GlobalSongWhereInput = {
matcherStatus: { in: ['MANUAL_NEEDED', 'MATCHED_DUP_OF_OTHER'] },
};목록에는 곡 제목과 아티스트뿐 아니라 판단에 필요한 맥락이 함께 나옵니다. 이 곡을 쓰는 채널 수, 지금까지 시도 횟수, 마지막 시도 시각, 그리고 에이전트가 남긴 후보입니다. 채널 수가 많은 곡부터 처리하면 같은 시간에 더 많은 사용자에게 가사가 붙습니다. 시도 횟수가 이미 높은 곡은 자동으로는 안 되는 곡이라는 신호입니다.
운영자가 후보를 보고 맞다고 판단하면 확정 버튼 하나로 끝납니다.
async confirmManualMatch(globalSongId: number) {
// ...
if (gs.matcherStatus !== 'MANUAL_NEEDED') {
throw new BadRequestException(`... is not MANUAL_NEEDED (current=${gs.matcherStatus})`);
}
if (!gs.mxmTrackId) {
throw new BadRequestException(`... has no mxmTrackId suggestion: use manual search instead`);
}
// Operator confirmed → MANUAL source (final decision is human, even if
// the suggestion originally came from ALTERNATE_LLM).
return this.match(globalSongId, gs.mxmTrackId);
}제안은 LLM 이 했어도 최종 결정의 출처는 사람으로 기록합니다. 나중에 이 매칭이 왜 이렇게 됐는지 추적할 때, 자동으로 확정된 것과 사람이 확인한 것은 신뢰도가 다릅니다. 그 차이를 데이터에 남기지 않으면 둘을 구분할 방법이 사라집니다.
확정 경로에 가드가 두 개 걸린 것도 의도적입니다. 이미 다른 상태로 넘어간 행을 확정하려 하거나, 후보 없이 확정 버튼을 누르는 것을 막습니다. 후자의 경우 별도의 수동 검색 화면으로 보냅니다. 사람이 개입하는 자리일수록 잘못 누를 수 있다는 전제로 짜야 합니다.
후보가 아예 쓸 만하지 않으면 운영자가 직접 검색해서 다른 트랙을 고릅니다. 자동으로 다시 돌려보게 하거나, 매칭을 지워 처음 상태로 되돌리거나, 가사만 다시 받아오거나, 가사 언어를 고칠 수도 있습니다. 에이전트가 만질 수 있는 것보다 사람이 만질 수 있는 것이 더 많습니다.
확정 경로에는 멱등성도 걸려 있습니다. 이미 같은 mxmTrackId 로 MATCHED_* 상태가 된 행은 트랙도 가사도 다시 조회하지 않고 그대로 반환합니다.
사람이 버튼을 두 번 누르는 것은 흔한 일입니다. 그때마다 비싼 가사 조회 쿼터를 태우면 안 됩니다. 자동화의 재시도만 멱등성을 고려하고 사람의 중복 클릭은 빠뜨리기 쉬운데, 실제로 쿼터를 태우는 것은 후자입니다.
정리하면 이 시스템에서 사람과 에이전트의 역할은 이렇게 나뉩니다.
| 에이전트 | 사람 | |
|---|---|---|
| 대상 선정 | 하지 않음 (SQL 이 결정) | 큐에서 우선순위 판단 |
| 후보 탐색 | 검색과 대조 | 후보가 없을 때 직접 검색 |
| 확정 | 가사 검증을 통과한 건만 | 제안 확인 후 확정 |
| 되돌리기 | 불가 | 매칭 삭제, 재시도, 가사 재조회 |
에이전트에게 준 것은 확신이 있을 때만 쓰는 자동 확정 권한이고, 확신이 없을 때 갈 곳을 함께 만들어 준 것입니다. 이 대기열이 없으면 임계값을 낮춰 억지로 자동 확정하려는 압력이 생깁니다. 사람이 처리할 경로가 있어야 에이전트를 보수적으로 유지할 수 있습니다.
8. 리스로 멱등성을 만듭니다
배치는 30분마다 돕니다. 그런데 곡당 최대 12분 반이 걸릴 수 있고 한 tick 이 5곡을 처리하므로 최악의 경우 한 실행이 65분 동안 살아 있을 수 있습니다. 이전 실행이 끝나기 전에 다음 실행이 시작되는 상황을 반드시 다뤄야 합니다.
세 겹으로 처리했습니다.
스케줄러 수준
spec:
schedule: "*/30 * * * *"
timeZone: "UTC"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 0
activeDeadlineSeconds: 1500concurrencyPolicy: Forbid 는 이전 Job 이 살아 있으면 새 Job 을 만들지 않습니다. activeDeadlineSeconds: 1500 은 25분입니다. 곡당 최대 13분에 5곡을 곱하면 65분이 나오지만 그 시간을 다 쓰도록 두는 대신 25분에서 잘라 다음 주기를 확보하는 쪽을 택했습니다. 13분이라는 숫자는 PoC 최장 곡 748초를 올림한 값입니다.
backoffLimit: 0 도 의도적입니다. 실패한 Job 을 즉시 재시도하면 같은 실패가 폭주합니다. 다음 크론까지 기다리게 두는 편이 안전합니다.
곡 단위 리스
자체 DB 에 곡당 하나의 실행 행을 만들고, 만료 시각을 붙였습니다. Alembic 마이그레이션의 실제 정의입니다.
op.create_table(
"agent_musixmatch_run",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("global_song_id", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=True),
# LEASED / SUCCESS / FAILED / TIMEOUT / USAGE_LIMIT / ERROR
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("duration_seconds", sa.Integer(), nullable=True),
sa.Column("llm_tool_calls", sa.Integer(), nullable=True),
sa.Column("llm_requests", sa.Integer(), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("cost_usd", sa.Numeric(precision=8, scale=4), nullable=True),
sa.Column("outcome_track_id", sa.Integer(), nullable=True),
sa.Column("lyrics_match_sample_hash", sa.String(length=16), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(), nullable=True),
sa.Column("reason", sa.String(length=500), nullable=True),
sa.Column("dry_run", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
op.create_index("idx_agent_run_status_lease", "agent_musixmatch_run",
["status", "lease_expires_at"])한 테이블이 네 가지 역할을 겸합니다. 진행 중인 작업의 잠금, 실행 이력, 비용 원장, 감사 기록입니다. 곡 하나를 처리할 때 정확히 한 행이 생기고 그 행이 끝까지 따라갑니다.
리스 획득은 이렇게 동작합니다.
async with agent_session() as session, session.begin():
existing_q = select(AgentMusixmatchRun.id).where(
AgentMusixmatchRun.global_song_id == global_song_id,
AgentMusixmatchRun.status == "LEASED",
AgentMusixmatchRun.lease_expires_at >= now,
)
if (await session.execute(existing_q)).scalar_one_or_none() is not None:
return None # 살아 있는 리스가 있으면 잡지 않습니다
run = AgentMusixmatchRun(
global_song_id=global_song_id,
started_at=now,
status="LEASED",
lease_expires_at=now + timedelta(minutes=15),
dry_run=dry_run,
)여기에는 한계가 하나 있습니다. 리스 획득이 UNIQUE 제약이 아니라 SELECT 후 INSERT 라서, 두 Pod 가 완전히 동시에 같은 곡을 잡으면 둘 다 삽입될 수 있습니다. 이 경합을 실제로 막는 것은 concurrencyPolicy: Forbid 이고 리스는 그 위에 얹힌 두 번째 방어선입니다. 방어선이 두 겹일 때는 각각이 무엇을 막고 무엇을 못 막는지 따로 알고 있어야 합니다. 그러지 않으면 실제로는 한 겹만 일하고 있는데 두 겹이라고 믿게 됩니다.
죽은 프로세스 회수
리스에는 항상 같은 문제가 따라옵니다. 리스를 잡은 프로세스가 죽으면 그 곡은 영원히 잠깁니다.
async def reap_stale_leases() -> list[int]:
"""만료된 LEASED 행을 TIMEOUT 으로 바꾸고 해당 곡 ID 를 돌려줍니다."""
q = select(AgentMusixmatchRun).where(
AgentMusixmatchRun.status == "LEASED",
AgentMusixmatchRun.lease_expires_at < now,
)
for row in rows:
row.status = "TIMEOUT"
row.finished_at = now
row.lease_expires_at = None
row.reason = "lease expired before agent finished"회수한 뒤에는 리스만 푸는 게 아니라 백엔드의 실패 기록 엔드포인트를 호출합니다. 시도 횟수가 올라가야 계속 죽는 곡이 무한히 재시도되지 않습니다.
한 tick 의 전체 흐름입니다.
행 하나가 거치는 상태 전이입니다.
finalize_run 은 종료 상태를 쓸 때 lease_expires_at 을 None 으로 되돌립니다. 리스 해제와 결과 기록이 한 트랜잭션 안에서 일어나므로 결과가 남았는데 잠금이 남아 있는 상태가 생기지 않습니다.
리스를 설계할 때는 잡는 쪽이 아니라 죽는 쪽을 먼저 봐야 합니다. 정상 경로는 저절로 동작하고 문제는 항상 리스를 들고 사라진 프로세스에서 생깁니다.
9. 스스로 멈추는 조건
매 tick 은 곡을 고르기 전에 멈춰야 하는지부터 확인합니다. 게이트를 통과하지 못하면 SELECT 조차 하지 않고 종료합니다.
async def _gate_check(metrics) -> str | None:
if settings.recover_kill_switch:
return "kill_switch"
today_count, today_cost = await db.today_run_count_and_cost()
if today_count >= settings.recover_daily_cap:
return "daily_cap"
if today_cost >= settings.recover_daily_usd_cap:
return "daily_usd_cap"
matched_rate = await db.recent_matched_rate()
if matched_rate is not None and matched_rate < settings.recover_min_matched_rate:
return "low_matched_rate"
return None전체 가드레일은 환경 변수로 노출했습니다. 값은 Vault 에 있고 ExternalSecret 이 1분 주기로 동기화하므로 재배포 없이 다음 tick 부터 반영됩니다.
RECOVER_KILL_SWITCH=false
RECOVER_DRY_RUN=true
RECOVER_BATCH_SIZE=5
RECOVER_DAILY_CAP=10
RECOVER_DAILY_USD_CAP=20
RECOVER_MIN_MATCHED_RATE=0.30
RECOVER_MAX_ATTEMPTS_PER_SONG=5
RECOVER_SONG_COOLDOWN_HOURS=24
RECOVER_LEASE_MINUTES=15
RECOVER_AGENT_REQUEST_LIMIT=12
RECOVER_AGENT_TOOL_CALLS_LIMIT=12
RECOVER_AGENT_TOTAL_TOKENS_LIMIT=300000
RECOVER_AGENT_RESPONSE_TOKENS_LIMIT=8000| 설정 | 막는 것 |
|---|---|
RECOVER_KILL_SWITCH | 즉시 정지 |
RECOVER_DRY_RUN | 실제 반영 없이 판단만 관찰 |
RECOVER_DAILY_CAP | 폭주로 인한 대량 오적용 |
RECOVER_DAILY_USD_CAP | 예상 밖 비용 |
RECOVER_MIN_MATCHED_RATE | 전제가 깨진 상태에서의 계속 실행 |
RECOVER_MAX_ATTEMPTS_PER_SONG | 안 되는 곡에 영구히 재시도 |
UsageLimits 4종 | 곡 하나가 예산 전체를 소진 |
회수율 게이트가 잡는 것
가드레일 중 성격이 가장 다른 것은 RECOVER_MIN_MATCHED_RATE=0.30 입니다.
async def recent_matched_rate(window: int = 50) -> float | None:
"""최근 50건의 종료된 run 중 SUCCESS 비율입니다.
50건이 안 되면 None 을 돌려주고 호출부는 판단을 보류합니다."""
...
if len(rows) < window:
return None
return sum(1 for r in rows if r["status"] == "SUCCESS") / len(rows)이 게이트는 개별 에러를 잡지 않습니다. 전제가 깨진 상황을 잡습니다. 외부 API 의 응답 형식이 바뀌었거나, 모델이 교체되면서 지시를 다르게 해석하거나, 남은 곡이 이 방법으로는 원래 안 되는 것들만 남았을 때 성공률이 먼저 떨어집니다.
에러율은 이런 상황을 잡지 못합니다. 모든 호출이 200 으로 성공하면서 아무것도 못 찾는 상태가 가능하기 때문입니다. “정상적으로 실패하고 있다”를 감지하려면 에러가 아니라 성과를 봐야 합니다.
다만 이 게이트에는 한계가 있습니다. 50건이 쌓이기 전에는 None 을 돌려주고 그냥 통과합니다. 그리고 램프업 첫 단계의 일일 상한은 10건입니다. 즉 이 게이트가 처음 판단을 내리기까지 상당한 시간이 걸립니다. 초기 단계에서 가장 필요한 안전장치가 초기 단계에서 가장 늦게 깨어나는 구조였고, 이 기간의 안전은 dry-run 과 사람의 표본 검사가 맡았습니다.
처음부터 전부 열지 않았습니다
램프업은 네 단계로 계획했습니다. 조정하는 값은 두 개뿐입니다.
| 단계 | RECOVER_DRY_RUN | RECOVER_DAILY_CAP | 판단 기준 |
|---|---|---|---|
| Day 1-2 | true | 10 | 적용 없이 판단만 기록 |
| Day 3-7 | false | 10 | 사람 표본 검사 필수 |
| Day 8-14 | false | 50 | 오매칭 5% 미만 확인 |
| Day 15+ | false | 300 | 전량 회수까지 약 13일 |
단계 변경은 배포가 아니라 Vault 값 변경입니다.
vault kv patch <service-secret-path> \
RECOVER_DRY_RUN=false RECOVER_DAILY_CAP=50자율 시스템을 배포할 때 첫 설정값은 최종 설정값이 아닙니다. 상한을 낮게 잡고 관찰하면서 여는 것 자체가 설계의 일부이고 그러려면 설정 변경 경로가 재배포보다 짧아야 합니다.
10. 선언한 지표와 실제로 올라간 지표
크론 작업은 수명이 짧아서 Prometheus 가 스크레이프할 시점을 잡기 어렵습니다. 그래서 매 tick 이 끝날 때 push gateway 로 한 번 밀어 넣는 방식을 썼습니다.
def push(self) -> None:
url = settings.prometheus_push_gateway_url
if not url:
return
try:
push_to_gateway(url, job=_SERVICE, registry=self.registry)
except Exception as e:
# 지표 전송 실패로 크론 자체를 실패시키지 않습니다
log.warning("metrics.push.failed", error=str(e)[:200])추적은 세 겹입니다. structlog 로 구조화 로그를 남기고, Logfire 토큰이 있으면 pydantic-ai 와 httpx 와 sqlalchemy 를 자동 계측하고, Prometheus 로 집계 지표를 밀어 넣습니다. 알람 룰은 별도 저장소에 7개를 등록했습니다.
# critical
increase(agent_apply_total{outcome="error"}[10m]) > 5
increase(agent_cost_usd_total[1h]) > 15
increase(agent_unwind_detected_total[24h]) > 10
# warning
increase(agent_cost_usd_total[1h]) > 5
agent_recovery_rate_ratio < 0.30
sum(increase(agent_run_total{status="USAGE_LIMIT"}[1h])) > 3
(time() - max(agent_run_total)) > 7200마지막 규칙이 특히 마음에 들었습니다. 30분마다 run 이 올라와야 정상이므로 2시간 동안 아무것도 올라오지 않으면 크론이 죽었거나 push gateway 가 끊겼다는 신호입니다. 자동화가 조용히 멈춘 상태를 잡는 알람입니다.
그런데 이 절에는 4장과 정확히 같은 실패가 하나 더 있습니다.
agent_unwind_detected_total 은 관리자가 에이전트의 매칭을 수동으로 해제한 건수를 세는 지표입니다. 오매칭이 누적되고 있다는 가장 직접적인 신호이고 24시간에 10건을 넘으면 critical 알람이 울리도록 걸어뒀습니다. 지표는 metrics.py 에 Counter 로 선언되어 있고 라벨 헬퍼도 있습니다.
self.unwind_detected = Counter(
"agent_unwind_detected_total",
"Number of admin-cleared agent matches detected this tick",
("service",),
registry=registry,
)
def labels_unwind(self) -> Counter:
return self.unwind_detected.labels(_SERVICE)labels_unwind() 를 호출하는 코드는 저장소 어디에도 없습니다. 감지 로직 자체가 없기 때문입니다. 배치 unwind 감지는 다음 버전 과제로 미뤄뒀고 초기 운영에서는 관리자 UI 의 단건 해제와 표본 검사로 대신하고 있었습니다. 이름만 있고 구현이 없는 지표는 이것만이 아니었습니다. agent_tool_calls_total 은 아예 Counter 로 만들어지지도 않았습니다.
결과적으로 이 critical 알람은 영원히 울리지 않는 알람입니다. 그리고 조용한 알람은 “문제 없음”과 구분되지 않습니다.
이것은 PoC 의 max_iterations=8 과 같은 실패입니다. 설정에 이름이 있고 알람 규칙까지 걸려 있는데 그 값을 실제로 움직이는 코드가 없습니다. 상한은 넘겨보면 드러나기라도 하지만 지표는 0 이 정상값이라 더 오래 숨습니다.
선언한 지표는 한 번은 일부러 올려봐야 합니다. 0 이 “안전하다”인지 “관측되지 않는다”인지는 그때만 구분됩니다.
실제로 관측 가능했던 경로는 따로 있었습니다. 백엔드에 읽기 전용 조회 API 를 붙여 관리자 화면에서 실행 이력을 볼 수 있게 했습니다. 커서 페이징이 붙은 실행 이력 목록, 당일 요약, 곡별 이력 세 가지입니다.
에이전트 자체 DB 는 백엔드 DB 와 분리되어 있어서 크로스 DB 조인을 할 수 없었습니다. 그래서 전용 읽기 전용 계정으로 실행 이력을 가져온 뒤 곡 제목과 아티스트는 애플리케이션에서 병합했습니다. 권한을 넓히는 대신 조인을 포기했습니다.
11. 킬 스위치 3단, 그리고 selfHeal
정지 수단은 세 단계로 준비했습니다. 각 단계의 소요 시간과 지속성이 다릅니다.
1단계, 수 초
kubectl -n <namespace> patch cronjob <recover-cronjob> \
-p '{"spec":{"suspend":true}}'
kubectl -n <namespace> delete job -l app=<agent-label>가장 빠릅니다. 진행 중인 Job 까지 지웁니다. 그런데 이 단계에는 함정이 있습니다.
ArgoCD 가 selfHeal 로 운영되고 있으면 다음 동기화 때 suspend: true 를 매니페스트의 suspend: false 로 되돌립니다. 반대 방향도 마찬가지입니다. 초기 운영에서 크론을 켤 때 kubectl patch 로 unsuspend 했는데, ArgoCD 가 매니페스트의 suspend: true 로 즉시 되돌려 첫 tick 이후 다시 멈춘 일이 있었습니다. 결국 매니페스트 자체를 고쳐야 했습니다.
GitOps 환경에서 kubectl patch 는 정지가 아니라 출혈 억제입니다. 몇 분 안에 원래 상태로 돌아오는 것을 전제로 써야 합니다.
2단계, 약 1분
vault kv patch <service-secret-path> RECOVER_KILL_SWITCH=trueExternalSecret 의 refreshInterval 이 1분이라, 다음 tick 이 시작될 때 게이트가 즉시 종료합니다. 크론 자체는 계속 발화하지만 SELECT 도 LLM 호출도 하지 않고 끝납니다. GitOps 가 되돌리지 않는 값이므로 여기서부터는 정지가 유지됩니다.
3단계, 약 3분
배포 저장소에서 spec.suspend: true 로 PR 을 만들고 머지합니다. ArgoCD 동기화 후 크론이 영구 정지하고 재가동은 그 PR 을 되돌리는 것으로만 가능합니다.
세 단계는 서로 다른 층을 맡습니다. 1단계는 지금 도는 것을 멈추고, 2단계는 다음에 도는 것을 무력화하고 3단계는 상태 자체를 바꿉니다. 가장 빠른 수단이 가장 오래가는 수단은 아닙니다.
12. 마무리
이 배치에서 에이전트가 결정하는 것은 한 곡을 어떻게 판단하느냐뿐입니다. 회수 대상은 결정론적 SQL 이 고르고, 곡 하나가 쓸 수 있는 요청과 도구 호출과 토큰은 호출 단위 하드 캡으로 잘랐고, 나갈 수 있는 곳은 허용 목록에 적힌 호스트로 좁혔습니다.
성공 조건도 모델의 자기 평가가 아니라 Musixmatch 가사와 웹 가사 페이지에 공통으로 나타나는 한 줄로 잡았고, 증거를 찾지 못한 곡은 후보만 남긴 채 어드민 검수 큐로 넘어갑니다. 동시 실행은 concurrencyPolicy: Forbid 와 곡 단위 리스 두 겹으로 막고, 리스를 들고 죽은 프로세스는 다음 tick 이 TIMEOUT 으로 회수합니다.
처음부터 전부 열지는 않았습니다. dry-run 으로 시작해 일일 상한을 단계적으로 올렸고 정지 수단은 즉시 반응, 설정 반영, 영속 정지 세 층으로 나눠 뒀습니다. 그 과정에서 max_iterations 와 agent_unwind_detected_total 이 같은 자리에서 걸렸습니다. 둘 다 이름과 기본값을 갖췄고 지표 쪽은 critical 알람까지 걸려 있었는데 정작 그 값을 움직이는 코드가 없었습니다. 상한은 27회라는 실측치가 드러냈지만 지표는 0 이 정상값이라 더 오래 숨었습니다.
시리즈 · 제품에 LLM 넣기
2 / 4LLM을 실제 사용자 흐름에 넣으면서 붙인 판정 기준과 안전장치.
- 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
- 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
- 3.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정
- 4.Vibe Infra로 장애를 만들고, 장애에서 배우다
시리즈 · 노래책과 신청곡
2 / 5개떡같이 말해도 알아듣고, 키를 바꾸고, 클립으로 남기기까지 노래책을 이루는 조각들.
- 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
- 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
- 3.확장 프로그램 없이 브라우저에서 영상 키 조절하기
- 4.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
- 5.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정