AI 에이전트 개발 실전 #7 실전 프로젝트: 이슈 트리아지 에이전트
6편까지로 시리즈의 부품이 모두 모였습니다. 마지막 글에서는 그 부품들을 묶어 실제로 쓸 만한 에이전트 하나를 완성합니다. 저장소에 쌓이는 GitHub 이슈를 읽고, 종류를 분류하고, 라벨과 답변 초안을 제안하는 이슈 트리아지 에이전트입니다.
무엇을 만드는가 #
오픈소스든 사내 저장소든, 이슈는 쌓이는 속도가 처리 속도보다 빠릅니다. 트리아지(triage)는 원래 응급실에서 밀려드는 환자를 긴급도에 따라 분류하는 일을 가리키는 의료 용어로, 개발에서는 쌓이는 이슈를 종류와 우선순위로 분류하는 단계를 뜻합니다. 새 이슈를 읽고 버그인지 기능 요청인지 질문인지 가르고, 라벨을 붙이고, 필요하면 첫 답변을 다는 일입니다. 반복적이지만 판단이 필요해서 에이전트에 잘 맞습니다.
설계는 시리즈의 원칙을 따릅니다. 읽기는 자유롭게, 쓰기는 승인을 거쳐서입니다. 이슈 조회와 분석은 에이전트가 알아서 하고, 라벨을 실제로 붙이거나 코멘트를 게시하는 일은 사람이 승인해야 실행됩니다.
도구 구성 #
도구는 네 개입니다. GitHub REST API를 그대로 쓰므로 토큰 하나면 됩니다.
import requests
API = "https://api.github.com"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}
MAX_BODY = 3000 # 4편: 도구 결과 다이어트
def list_open_issues(repo: str) -> str:
"""라벨이 없는 열린 이슈를 최신순으로 최대 10건 반환한다."""
r = requests.get(f"{API}/repos/{repo}/issues",
params={"state": "open", "per_page": 30}, headers=HEADERS)
r.raise_for_status()
unlabeled = [i for i in r.json() if not i["labels"] and "pull_request" not in i]
lines = [f"#{i['number']} {i['title']}" for i in unlabeled[:10]]
return "\n".join(lines) or "라벨 없는 열린 이슈가 없습니다."
def get_issue(repo: str, number: int) -> str:
"""이슈 번호로 제목과 본문을 조회한다. 분류 전에 반드시 본문을 읽는다."""
r = requests.get(f"{API}/repos/{repo}/issues/{number}", headers=HEADERS)
r.raise_for_status()
issue = r.json()
body = (issue["body"] or "")[:MAX_BODY]
return f"제목: {issue['title']}\n본문:\n{body}"
def add_labels(repo: str, number: int, labels: list) -> str:
"""이슈에 라벨을 붙인다. 되돌릴 수 있지만 공개 저장소에 보이는 변경이므로 승인 대상."""
r = requests.post(f"{API}/repos/{repo}/issues/{number}/labels",
json={"labels": labels}, headers=HEADERS)
r.raise_for_status()
return f"#{number}에 라벨 {labels}를 붙였습니다."
def post_comment(repo: str, number: int, body: str) -> str:
"""이슈에 코멘트를 게시한다. 외부에 공개되는 행동이므로 반드시 승인 대상."""
r = requests.post(f"{API}/repos/{repo}/issues/{number}/comments",
json={"body": body}, headers=HEADERS)
r.raise_for_status()
return f"#{number}에 코멘트를 게시했습니다."tools 스키마 정의는 2편의 원칙대로 작성합니다. 특히 라벨 파라미터는 enum으로 저장소의 라벨 체계(bug, enhancement, question, documentation)에 못 박습니다. 자유 문자열로 두면 존재하지 않는 라벨이 생깁니다.
승인 게이트 #
쓰기 도구 두 개는 실행 전에 사람의 확인을 거칩니다. 2편에서 본 게이트를 그대로 씁니다. 데모에서는 터미널 입력으로 충분합니다.
NEEDS_APPROVAL = {"add_labels", "post_comment"}
def execute_tool(block) -> dict:
if block.name in NEEDS_APPROVAL:
print(f"\n[승인 요청] {block.name} {block.input}")
if input("실행할까요? (y/n) ").strip().lower() != "y":
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": "사용자가 승인하지 않았습니다. 제안만 정리해서 보고하세요.",
"is_error": True,
}
try:
return {"type": "tool_result", "tool_use_id": block.id,
"content": run_tool(block.name, block.input)}
except Exception as e:
return {"type": "tool_result", "tool_use_id": block.id,
"content": f"도구 실행 실패: {e}", "is_error": True}실서비스라면 input() 자리에 슬랙 알림이나 웹 UI의 승인 버튼이 들어갑니다. 구조는 같습니다. 에이전트는 승인을 기다리고, 거부되면 그 사실을 읽고 계획을 바꿉니다.
시스템 프롬프트 #
행동 규칙은 3편의 원칙대로 구체적인 행동으로 적습니다.
SYSTEM = """너는 GitHub 이슈 트리아지 에이전트다.
작업 절차:
1. list_open_issues로 라벨 없는 이슈를 확인하고 처리 계획을 나열한다.
2. 이슈마다 get_issue로 본문을 읽은 뒤 분류한다. 제목만으로 분류하지 않는다.
3. 분류 기준: 오류 재현 내용이 있으면 bug, 새 기능 제안이면 enhancement,
사용법 질문이면 question, 문서 개선이면 documentation.
4. add_labels로 라벨을 제안한다. 확신이 없으면 라벨을 붙이지 말고 이유를 보고한다.
5. question 이슈에만 답변 초안을 작성해 post_comment로 제안한다.
규칙:
- 한 번 승인이 거부된 행동은 다시 시도하지 않는다.
- 마지막에 처리한 이슈, 붙인 라벨, 보류한 이슈를 표로 정리해 보고한다.
"""루프는 1편의 run_agent를 그대로 쓰면 됩니다. run_agent("owner/repo 저장소의 새 이슈를 트리아지해줘.") 한 줄로 에이전트가 돌기 시작합니다.
평가 — 골든셋으로 분류 품질 재기 #
LLM 앱 개발 실전 12편에서 답의 품질 평가를 다뤘습니다. 에이전트에서도 원리는 같고, 평가 단위가 “태스크 성공"으로 바뀝니다. 트리아지에서 가장 측정하기 쉬운 것은 분류 정확도입니다. 이미 사람이 라벨을 붙인 과거 이슈를 골든셋(golden set, 정답을 미리 확정해 둔 평가용 데이터 묶음)으로 삼습니다.
GOLDEN = [
{"number": 101, "expected": ["bug"]},
{"number": 95, "expected": ["question"]},
{"number": 88, "expected": ["enhancement"]},
# 과거 이슈 20건 정도면 변화를 감지하기에 충분하다
]
def evaluate(repo: str) -> float:
correct = 0
for case in GOLDEN:
proposed = triage_one(repo, case["number"]) # 승인 게이트 없이 제안만 받는 모드
if set(proposed) == set(case["expected"]):
correct += 1
else:
print(f"#{case['number']}: 기대 {case['expected']} / 제안 {proposed}")
return correct / len(GOLDEN)
print(f"분류 정확도: {evaluate('owner/repo'):.0%}")이 숫자가 있으면 시스템 프롬프트의 분류 기준을 고치거나 모델을 바꿀 때, 좋아졌는지 나빠졌는지를 감으로가 아니라 수치로 확인할 수 있습니다. 프롬프트를 고칠 때마다 돌리는 회귀 테스트인 셈입니다.
여기서 더 나아가려면 #
- 정기 실행 — cron으로 하루 한 번 돌리고, 승인 요청을 슬랙으로 받으면 운영 도구가 됩니다.
- 서브에이전트 — 이슈가 많은 날은 5편의 병렬 위임으로 이슈별 분석을 동시에 돌릴 수 있습니다.
- MCP 서버화 — GitHub 도구 네 개를 6편처럼 서버로 분리하면, 트리아지 에이전트 외의 클라이언트에서도 같은 도구를 쓸 수 있습니다.
시리즈를 마치며 #
일곱 편에 걸쳐 에이전트를 입문 수준에서 실전 수준으로 끌어올렸습니다. 돌아보면 이렇습니다.
- 루프는
stop_reason전부와 도구 에러를 처리해야 무너지지 않습니다(1편). - 에이전트의 품질은 도구의 description, 스키마, 에러 메시지에서 갈립니다(2편).
- 계획을 먼저 세우고 변경 후 검증하는 규칙이 자기 수정을 만듭니다(3편).
- 긴 작업은 도구 결과 다이어트, 정리, 압축, 스크래치패드로 버팁니다(4편).
- 컨텍스트 격리가 필요하면 서브에이전트로 일을 나눕니다(5편).
- 여러 클라이언트가 공유할 도구는 MCP 서버로 분리합니다(6편).
- 그리고 되돌리기 어려운 행동에는 반드시 사람의 승인을 둡니다(7편).
에이전트 개발의 무게중심은 신기한 데모가 아니라, 실패를 견디는 루프와 좋은 도구와 측정 가능한 품질에 있습니다. 이 시리즈가 그 토대가 되기를 바랍니다. 다음에 또 만나요!