스킬 품질 관리를 위한 루브릭 설계 및 시스템 구현
Source: Toss Tech
안녕하세요, 토스 AI DX Team의 Server Developer 조민규입니다.
저는 AI DX Team에서 토스 서버의 하네스를 만들면서, 그 기능 중 하나로 사내 공용 Skill을 만들어 배포하고 있습니다. 여기서 다루는 Skill은 실제 서비스 런타임에서 동작하는 것이 아니라, 개발자가 코딩 에이전트로 개발할 때 이를 보조하는 개발 단계의 도구입니다.
토스 내부에서는 많은 Skill들이 만들어지고 공유되고 있는데요, 정성껏 만들어서 제공되는 Skill이 한 번도 호출되지 않는 경우도 많았습니다. 본문에 사용 케이스를 여러 개 적어두고 트리거 키워드도 깔아뒀는데도, 정작 스킬은 제대로 호출이 되지 않았죠.
근본적으로 Skill의 품질 문제라는 생각이 들었습니다. 이를 해결하기 위해 Skill의 품질을 관리하기 위한 6 섹션 30 항목의 Rubric을 만들게 되었는데, 이 과정에 대해 정리해 보려고 합니다.
Skill 평가가 어려운 두 가지 이유
Skill은 본질적으로 ‘LLM이 호출하고 LLM이 읽는 산출물’입니다. 코드라면 컴파일러와 테스트가 1차 게이트가 되어 주지만, Skill에는 통과/실패를 딱 떨어지게 검증해 주는 도구가 없습니다.
그래서 결함이 호출되지 않거나, 호출돼도 효과가 없는 형태로 조용히 누적됩니다. 가장 자주 발견되는 Skill 관련 문제 두 가지를 먼저 짚어보겠습니다.
1. 트리거 실패
Skill의 설명 작성이 바람직하지 못해서 코딩 에이전트가 Skill을 올바른 타이밍에 호출하지 못하는 경우입니다. 본문에 아무리 좋은 가이드를 적어둬도 호출이 안 되면 무의미하죠.
대표적으로 작성자가 호출 조건을 Description이 아닌 Skill 본문에 적어두는 패턴이 있습니다. 본문에 “Use when …” 같은 시점 정보를 넣어두면 충분할 거라 생각하기 쉽지만, 코딩 에이전트는 Skill을 호출할지 결정할 때 Description만 봅니다. 본문은 호출이 결정된 다음에야 읽힙니다. 작성자 입장에서는 분명히 트리거 조건을 명시했다고 생각하는데, 정작 코딩 에이전트에게는 보이지 않는 영역에 적어둔 셈이 되죠.
문제는 이 결함이 사람의 눈으로 잡히지 않는다는 점입니다. 코드처럼 빨간 줄이 그어지지도 않고, 작성자 본인은 자기 Skill이 호출되지 않는다는 사실 자체를 모릅니다.
2. 형식 위반으로 인한 호출 실패
name이 kebab-case가 아니거나, name과 폴더명이 일치하지 않으면 코딩 에이전트가 Skill의 존재 자체를 인식하지 못합니다. 트리거 실패와 결과는 비슷하지만, 원인이 형식적이라 정규식으로 즉시 잡아낼 수 있는 결함입니다.
작성자 입장에서는 본인 Skill이 호출되지 않는 이유를 본문에서 찾느라 시간을 낭비하기 쉽습니다. 정작 원인은 frontmatter 한 줄에 있는 경우인데도요.
이러한 사내 Skill의 문제들은 Rubric 설계의 출발점이 됐습니다.
결정적인 것은 규칙 기반으로, 의미적인 것은 모델 기반으로
두 결함의 검증 방식은 정반대였습니다. 첫 번째는 의미 판단이 본질이라 LLM 모델 판정이 필수이고, 두 번째는 형식 기반이라 정규식 한 줄로 결정적으로 잡히는 결함인 것입니다.
이러한 통찰을 바탕으로, 둘을 구분해서 관리해야 한다는 Rubric 전체를 관통하는 단 하나의 설계 원칙을 고안하게 되었습니다. 두 영역을 섞어 처리하면 양쪽 모두 망가지기 때문입니다. 결정적 결함을 LLM 에 맡기면 “거의 맞는 것 같은데…” 로 통과시키는 False Negative가 생기고, 반대로 의미적 판정을 정규식으로 처리하면 키워드 매칭의 한계 때문에 False Positive가 폭발합니다.
그래서 30개 항목을 17개 규칙 / 13개 모델 검사로 명시적으로 구분했습니다. 규칙 검사는 정규식·카운트·AST 파싱처럼 결정적인 도구만 쓰고, 모델 검사에는 LLM만 사용합니다. 두 단계의 책임 영역이 겹치지 않게 만드는 게 핵심입니다.
이 분리는 운영 비용 측면에서도 효과적입니다. 규칙 검사는 무료에 가깝고 매 PR 마다 돌아도 부담이 없습니다. 모델 판정은 비용이 들지만 규칙 검사가 통과한 케이스에만 호출되므로, BLOCKER 단계에서 막힌 Skill 에는 LLM 비용을 쓰지 않습니다.
6 섹션 30 항목 Rubric의 구조
Skill을 6개 섹션, 30개 항목으로 평가하도록 구조를 설계했습니다. 각 항목은 BLOCKER / MAJOR / MINOR 심각도를 가지고, 결과는 S~F 5단계 등급으로 정리됩니다.
| 섹션 | 항목 수 | BLOCKER | 성격 | 타당성 |
|---|---|---|---|---|
| 3 | 0 | 이 Skill이 존재할 가치가 있는가 | ||
| 구조 | 8 | 5 | Skill 파일 형식이 올바른가 | |
| 트리거 | 6 | 1 | 코딩 에이전트가 잘 부를 수 있는가 | |
| 콘텐츠 | 3 | 0 | 본문이 가치 있는가 | |
| 리소스 | 8 | 0 | 파일 구조가 잘 짜여 있는가 | |
| 안전성 | 2 | 2 | 배포해도 안전한가 | |
| 합계 | 30 | 8 |
등급 기준
| 등급 | 조건 | 의미 |
|---|---|---|
| S | BLOCKER 0 + MAJOR 0 | 모범 Skill |
| A | BLOCKER 0 + MAJOR 1-2 | 사용 가능, 소폭 개선 |
| B | BLOCKER 0 + MAJOR 3-4 | 개선 필요 |
| C | BLOCKER 0 + MAJOR 5+ | 대폭 개선 필요 |
| F | BLOCKER 1+ | 배포 불가, 재작성 |
핵심은 BLOCKER 가 하나라도 있으면 무조건 F 라는 점입니다. 등급은 작성자에게 보여주는 압축 신호이고, 실제 Merge 차단은 F인지 아닌지 한 비트로만 결정됩니다. 미세한 등급 차이로 줄다리기하지 않게 만드는 단순화죠.
섹션별 핵심 항목과 설계 의도
타당성 (3 / MAJOR)
이 Skill이 존재할 만한 이유가 있는지를 봅니다. 이때 세 가지 질문을 던집니다.
| ID | 항목 | 측정 | 심각도 |
|---|---|---|---|
| 1-1 | 반복되는 워크플로우인가? | 모델 | MAJOR |
| 1-2 | 프로젝트 한정이 아닌 범용성이 있는가? | 모델 | MAJOR |
| 1-3 | 코딩 에이전트 기본 능력으로 대체 불가능한가? | 모델 | MAJOR |
하나라도 No라면 스킬의 존재 가치에 대한 확신에 의문 부호가 자동으로 붙습니다. 일회성 작업이거나 코딩 에이전트에게 그냥 시켜도 되는 일은 Skill로 만들 가치가 없기 때문입니다.
‘만들지 말았어야 할 Skill’을 잡는 항목이라 다른 섹션과 결이 좀 다릅니다.
구조 (8 / BLOCKER 5)
파일이 형식적으로 올바른지를 봅니다. 8개 항목 중 다섯 가지가 BLOCKER 입니다.
| ID | 항목 | 측정 | 심각도 |
|---|---|---|---|
| 2-1 | YAML frontmatter 파싱 가능 | 규칙 | BLOCKER |
| 2-2 | name 이 kebab-case (≤ 64자) | 규칙 | BLOCKER |
| 2-3 | name 과 폴더명 일치 | 규칙 | BLOCKER |
| 2-4 | description 1-1024자 | 규칙 | BLOCKER |
| 2-5 | description 에 XML 태그 없음 | 규칙 | BLOCKER |
| 2-6 | 허용된 frontmatter 키만 사용 | 규칙 | MAJOR |
| 2-7 | claude / anthropic 예약어 없음 | 규칙 | MAJOR |
| 2-8 | Skill 폴더 내 README.md 없음 | 규칙 | MINOR |
5개 BLOCKER 중 하나만 어겨도 F 등급입니다. 형식 위반은 결정적으로 잡히는 결함이라 100% 규칙 검사로 처리하고, 모델 판정은 들어가지 않습니다.
이 검사를 한 번에 모아서 처리하는 것은 의도된 설계입니다. 결함이 여러 개 있더라도 PR 코멘트 한 번으로 전부 받게 해주는 편이 작성자 입장에서 수정 비용이 가장 적기 때문입니다. frontmatter 파싱이 깨진 경우만 즉시 리턴하고, 그 외에는 끝까지 돌려 결과 리스트를 한 번에 반환합니다.
import re
from pathlib import Path
import yaml
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") # kebab-case
DESC_LEN_RANGE = (1, 1024)
def check_structure(Skill_md_path: str) -> list[str]:
"""구조 섹션의 BLOCKER 5개를 한 번에 검사."""
content = Path(Skill_md_path).read_text(encoding="utf-8")
folder_name = Path(Skill_md_path).parent.name
m = re.match(r"^---\n(.*?)\n---\n(.*)", content, re.DOTALL)
if not m:
return ["BLOCKER: frontmatter 누락"]
try:
fm = yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError as e:
return [f"BLOCKER: frontmatter 파싱 실패 - {e}"]
body = m.group(2)
failures = []
name = fm.get("name", "")
desc = fm.get("description", "")
if not NAME_RE.match(name):
failures.append(f"BLOCKER: name '{name}' 이 kebab-case 가 아님")
if name != folder_name:
failures.append(f"BLOCKER: name '{name}' 와 폴