ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스킬이 50개 넘으니 엉뚱한 게 불렸다 — 겹침을 임베딩으로 자동 탐지하기
    IT 2026. 7. 29. 21:00
    스킬이 50개 넘으니 엉뚱한 게 불렸다 — 겹침을 임베딩으로 자동 탐지하기

    집에 로컬 AI 서버를 두고 여러 작업을 에이전트에게 맡기다 보면, 어느 순간 스킬(skill)이 수십 개로 불어난다. 여기서 스킬이란 에이전트가 특정 작업을 할 때 따라 읽는 절차 묶음이다 — "이런 요청이 오면 이런 순서로 처리하라"를 적어 둔 설명서에 가깝다. 각 스킬에는 "언제 나를 불러야 하는지"를 적은 한 단락짜리 설명(description)이 붙는다. 에이전트는 사용자 요청이 들어오면 이 설명들을 보고 어떤 스킬을 쓸지 고른다.

    문제는 스킬이 10개를 넘고 30개를 넘어 50개에 가까워지면 시작된다. 어느 날 "캘린더 정리해줘"라고 했는데 엉뚱하게 일기 추출 스킬이 불렸다. 두 스킬의 설명을 나란히 펴 보니 둘 다 "Google Calendar에서 이벤트를 가져와…"로 시작하고 있었다. 설명이 위험하게 비슷했던 것이고, 나는 그걸 사고가 난 뒤에야 알았다.

    이 글은 그 "설명이 겹치는 스킬 쌍"을 사람 눈이 아니라 기계가 사전에 찾아내게 만든 이야기다. 핵심 발상은 한 줄로 요약된다 — 스킬 설명을 의미 벡터로 바꿔 모든 쌍의 거리를 재고, 너무 가까운 쌍을 린트(lint, 코드를 검사해 의심스러운 부분을 자동 경고하는 도구)처럼 경고한다.

    배경 — 스킬이 늘수록 겹침은 사람 눈을 벗어난다

    스킬이 N개면, 서로 겹칠 수 있는 쌍의 수는 N×(N−1)/2개다. 스킬 10개면 45쌍, 50개면 1,225쌍이다. 스킬 하나를 새로 추가할 때마다 기존 모든 스킬과 한 번씩 비교해야 "이 신규 스킬이 기존 어떤 것과 헷갈리지 않는가"를 보장할 수 있다. 50개 상태에서 하나를 더 넣으면 그 한 개를 위해 50번을 비교해야 한다는 뜻이다.

    사람이 이걸 매번 손으로 한다는 건 비현실적이다. 게다가 겹침은 단어가 똑같아서가 아니라 의미가 비슷해서 생긴다. "캘린더 동기화"와 "일정 가져오기"는 글자가 한 글자도 안 겹치지만 하는 일은 거의 같다. 단순 키워드 매칭으로는 이런 쌍을 놓친다.

    diagram

    다이어그램 설명. 이 그림은 겹침이 어떻게 "사고가 난 뒤에야" 드러나는지를 보여준다. 스킬 하나를 추가하면 원칙적으로 기존 전부와 비교해야 하는데(가운데 비교 단계), 사람이 수십 개를 일일이 못 보고 지나친다. 그래서 의미가 비슷한 쌍이 그대로 남고, 한참 뒤 실제 사용자 요청이 들어왔을 때 엉뚱한 스킬이 선택되고 나서야 충돌을 알게 된다. 핵심은 발견 시점이 너무 늦다는 것 — 추가하는 순간이 아니라 실제로 잘못 불린 순간에 알게 되는 구조다. 함정은 "추가할 땐 멀쩡해 보였다"는 점인데, 멀쩡해 보인 이유는 단지 다른 49개와 비교를 안 했기 때문이다.

    문제 — '사고 후'에 알게 되는 비용

    충돌을 실제 요청에서야 발견하면 비용이 두 갈래로 늘어난다. 첫째, 잘못된 스킬이 한 번 실행되며 시간과(에이전트가 GPU를 쓰는 작업이라면) 자원을 낭비한다. 둘째, 디버깅이 어렵다. "왜 일기 스킬이 불렸지?"를 추적하려면 그 시점의 요청 문장, 모든 스킬 설명, 에이전트의 선택 로직을 한꺼번에 펴 봐야 한다. 원인이 "두 설명이 닮았다"라는 사실은 막상 찾으면 허무하지만, 찾기까지가 오래 걸린다.

    그래서 목표를 이렇게 바꿨다 — 충돌 후보를 '추가/수정 시점'에 미리 알려주자. 코드에서 오타나 미사용 변수를 컴파일 전에 린트가 잡아 주듯, 스킬 설명도 추가하는 순간 "이거 기존 ○○와 너무 닮았는데요?"라고 경고가 떠야 한다.

    방법 — 설명을 임베딩해 모든 쌍의 유사도를 잰다

    핵심 도구는 임베딩(embedding)이다. 임베딩은 텍스트를 의미를 담은 숫자 벡터(예: 768개 실수의 배열)로 바꾸는 변환이다 — 뜻이 비슷한 문장은 벡터 공간에서 가까운 위치에 놓인다. 덕분에 "글자가 겹치는가"가 아니라 "뜻이 가까운가"를 거리로 잴 수 있다.

    두 벡터가 얼마나 가까운지는 코사인 유사도(cosine similarity)로 잰다. 두 벡터가 이루는 각도의 코사인 값으로, 방향이 완전히 같으면 1, 직각이면 0이다. 문장 의미 비교에서는 보통 0.0~1.0 사이 값이 나오고, 1에 가까울수록 의미가 닮았다는 뜻이다.

    diagram

    다이어그램 설명. 이 그림은 탐지 파이프라인 전체를 한 줄기로 보여준다. 맨 위에서 각 스킬 설명(한 단락 텍스트)을 임베딩 모델에 통과시켜 의미 벡터로 바꾸고, 가능한 모든 스킬 쌍을 만든 뒤, 쌍마다 코사인 유사도를 계산한다. 마지막 분기에서 유사도가 미리 정한 임계값(threshold) 이상인 쌍만 "겹침 후보"로 경고를 띄우고, 나머지는 통과시킨다. 이 패턴을 고른 이유는 단어가 아니라 의미로 비교해야 "캘린더 동기화 ↔ 일정 가져오기"처럼 글자가 안 겹치는 위험 쌍까지 잡히기 때문이다. 놓치기 쉬운 점은, 임베딩 모델을 한 번 돌리는 비용은 스킬 수에 비례(N번)하지만 쌍 비교는 제곱에 비례(N²번)한다는 것 — 다만 벡터 거리 계산 자체는 매우 싸서 스킬 수십~수백 개 규모에서는 전부 합쳐도 1초 안쪽이다.

    실제 계산의 뼈대는 이렇게 단순하다. 핵심만 발췌했다.

    # 1단: 모든 스킬 설명을 한 번에 임베딩 → (스킬 수, 벡터 차원) 행렬
    #       정규화(normalize)해 두면 코사인 유사도가 단순 내적(dot product)이 된다.
    vecs = embed_model.encode(descriptions, normalize_embeddings=True)
    
    # 2단: 모든 쌍의 유사도를 한 방에 계산.
    #       정규화된 벡터끼리의 행렬곱 = 코사인 유사도 행렬(스킬 × 스킬).
    sim = vecs @ vecs.T   # sim[i][j] = 스킬 i와 j의 의미 유사도
    
    # 3단: 위쪽 삼각형(i<j)만 훑어 임계값을 넘는 쌍을 경고로 모은다.
    #      대각선(자기 자신, =1.0)과 아래쪽 중복은 건너뛴다.
    warnings = []
    for i in range(len(skills)):
        for j in range(i + 1, len(skills)):
            if sim[i][j] >= THRESHOLD:          # 예: 0.80
                warnings.append((skills[i], skills[j], float(sim[i][j])))
    

    코드 설명. 이 코드가 보여주는 건 "탐지의 핵심이 사실 세 줄"이라는 점이다. 먼저 모든 스킬 설명을 한 번에 벡터로 바꾸되 길이를 1로 정규화해 둔다 — 이렇게 하면 코사인 유사도가 그냥 두 벡터의 내적이 되어, 행렬곱 한 번(vecs @ vecs.T)으로 모든 쌍의 유사도를 한꺼번에 얻는다. 마지막 이중 루프는 그 유사도 행렬에서 위쪽 삼각형만 훑어(자기 자신끼리의 1.0과, 같은 쌍을 두 번 세는 아래쪽 절반을 버린다) 임계값 이상인 쌍만 경고 목록에 담는다. 이 패턴을 쓰는 이유는 쌍마다 따로 임베딩을 돌리면 N²번 모델을 호출해 느려지지만, 임베딩은 N번만 하고 비교를 행렬곱으로 미루면 훨씬 싸기 때문이다. 함정 하나 — 정규화를 빠뜨리면 vecs @ vecs.T가 코사인이 아니라 그냥 내적이 되어 값의 스케일이 달라지고, 그러면 임계값이 의미를 잃는다.

    유사도 매트릭스로 한눈에 보기

    경고를 목록으로만 받으면 "어느 쌍이 얼마나 가까운지"의 전체 그림이 안 잡힌다. 그래서 스킬 × 스킬 표(매트릭스)로 펼쳐 위험 쌍을 표시한다. 아래는 5개 스킬을 예로 든 가상의 유사도 표다.

      캘린더 동기화 일기 추출 블로그 발행 썸네일 생성 RAG 검색
    캘린더 동기화 0.83 ⚠️ 0.21 0.12 0.34
    일기 추출 0.83 ⚠️ 0.18 0.10 0.29
    블로그 발행 0.21 0.18 0.71 0.40
    썸네일 생성 0.12 0.10 0.71 0.15
    RAG 검색 0.34 0.29 0.40 0.15

    표 설명. 이 표는 모든 스킬 쌍의 코사인 유사도를 한눈에 펼친 것이다. 대각선은 자기 자신이라 비워 두고(항상 1.0), 표는 대각선을 기준으로 대칭이다. 여기서 캘린더 동기화와 일기 추출이 0.83으로 임계값(0.80)을 넘어 ⚠️ 표시가 붙었다 — 둘 다 Google Calendar에서 이벤트를 가져오는 일을 해서 설명이 닮은 게 숫자로 드러난다. 블로그 발행과 썸네일 생성도 0.71로 꽤 높지만 임계값 아래라 경고는 아니다. 이 표가 목록보다 나은 점은 "위험은 아니지만 가까운" 쌍까지 보인다는 것 — 0.71 같은 값은 지금은 괜찮아도 한쪽 설명을 조금만 넓히면 임계값을 넘을 수 있는, 주의해서 지켜볼 쌍이다.

    스킬을 추가할 때 린트로 자동 경고

    이 탐지를 단발성으로 돌리면 의미가 없다. 핵심은 스킬을 추가하거나 수정할 때마다 자동으로 돌아 경고를 띄우는 것 — 코드 린트가 커밋 전 훅(hook)에서 자동 실행되듯이.

    diagram

    다이어그램 설명. 이 그림은 탐지를 일상 작업 흐름에 어떻게 끼워 넣는지를 보여준다. 스킬을 추가하거나 설명을 고치면(맨 위), 커밋 직전에 훅이 탐지 스크립트를 자동으로 돌려 새 스킬을 기존 전부와 비교한다. 임계값을 넘는 쌍이 없으면 아무 말 없이 통과시켜 작업을 방해하지 않고, 넘는 쌍이 있으면 어떤 스킬과 얼마나 닮았는지를 출력해 사람에게 넘긴다. 이 흐름의 핵심은 마지막 단계가 차단이 아니라 보고라는 점 — 경고는 띄우되 커밋을 막지는 않고, 설명을 고칠지 그냥 둘지는 사람이 정한다. 함정은 이걸 차단(겹치면 커밋 거부)으로 만들고 싶은 유혹인데, 뒤에서 보듯 유사도가 높다고 항상 진짜 충돌은 아니라서 차단으로 만들면 멀쩡한 작업까지 막힌다.

    효과와 트레이드오프

    가장 큰 효과는 발견 시점이 '사고 후'에서 '추가 시점'으로 당겨진 것이다. 앞서 본 캘린더 ↔ 일기 충돌 같은 사례를, 이제는 일기 추출 스킬을 새로 만들어 커밋하려는 순간 "캘린더 동기화와 0.83으로 닮았습니다"라고 알려 준다. 잘못된 스킬이 실제로 한 번 실행되며 시간·자원을 버리는 일도, 그걸 거꾸로 추적하느라 모든 설명을 펴 보는 일도 사라진다.

    다만 두 가지 트레이드오프가 분명하다.

    첫째, 유사도가 높다고 항상 충돌은 아니다(false positive). false positive는 "문제 없는데 문제라고 잘못 울린 경고"를 뜻한다. 캘린더 동기화와 일기 추출은 둘 다 캘린더를 읽지만, 실제로는 트리거 단어가 명확히 갈려서(전자는 "동기화/싱크", 후자는 정확히 "일기") 충돌하지 않을 수도 있다. 그래서 이 시스템은 경고일 뿐 차단이 아니며, 최종 판단은 사람이 한다. "닮았다"는 신호를 주는 것까지가 기계의 일이고, "그래서 고칠 건가"는 사람의 일이다.

    둘째, 임계값 튜닝이 필요하다. 임계값을 너무 낮게(예: 0.5) 잡으면 멀쩡한 쌍까지 줄줄이 경고가 떠 잡음이 되고, 너무 높게(예: 0.95) 잡으면 진짜 위험한 쌍을 놓친다. 정답은 스킬 설명을 쓰는 스타일에 따라 다르다 — 설명을 길고 비슷한 톤으로 쓰면 전반적으로 유사도가 높게 나와 임계값을 올려야 한다. 처음엔 0.80 근처에서 시작해, 경고가 너무 잦으면 올리고 충돌을 놓치면 내리는 식으로 맞춰 간다.

    diagram

    ▲ 수동 검토 방식

    다이어그램 설명. 위 흐름은 사람이 직접 모든 스킬 설명을 대조하는 기존 방식의 한계를 보여준다. 스킬이 늘면 비교할 쌍이 제곱으로 불어나 손으로는 감당이 안 되고, 설령 본다 해도 단어가 다르고 뜻만 같은 쌍은 눈에 잘 안 띈다. 결국 충돌은 실제 요청에서 엉뚱한 스킬이 불린 뒤에야 드러난다. 핵심 약점은 완전성과 시점 두 가지 모두 — 다 보지도 못하고, 본다 해도 늦다.

    diagram

    ▲ 자동 탐지 방식

    다이어그램 설명. 위 흐름은 같은 일을 임베딩 기반 자동 탐지로 바꾼 모습이다. 쌍이 아무리 많아도 행렬곱 한 번으로 전부 계산하니 완전성이 보장되고, 비교 기준이 단어가 아니라 의미라 글자가 다른 위험 쌍까지 걸린다. 결정적으로 충돌이 추가하는 시점에 경고로 드러나 사람이 곧바로 판단할 수 있다. 수동 방식과의 대비 포인트는 "다 본다 + 일찍 본다" — 빠진 비교가 없고, 사고가 나기 전에 신호가 온다. 다만 자동 탐지가 사람을 대체하는 게 아니라는 점은 그대로다. 마지막에 "고칠지 말지"를 사람이 정하는 단계가 남아 있고, 그게 false positive를 걸러 내는 안전판이다.

    정리 — 린트의 발상을 스킬 설계로

    이 작업의 본질은 새로운 기술이 아니라 발상의 이동이다. 우리는 코드에서 오타·미사용 변수·타입 불일치를 사람이 일일이 안 찾고 린트에게 맡긴 지 오래다. 스킬 설명의 "위험한 겹침"도 똑같이 다룰 수 있다 — 의미를 벡터로 재서 너무 가까운 쌍을 자동으로 일러 주면, 사람은 "충돌인가 아닌가"라는 판단에만 집중하면 된다.

    핵심을 다시 추리면 세 가지다. 첫째, 겹침은 단어가 아니라 의미에서 생기므로 임베딩으로 재야 한다. 둘째, 발견 시점을 사고 후가 아니라 추가 시점으로 당기는 게 전부의 목적이다. 셋째, 기계는 경고까지만 하고 최종 판단은 사람이 한다 — 유사도가 높다고 늘 충돌은 아니기 때문이다. 스킬이 수십 개로 불어나는 환경이라면, 이 작은 린트 하나가 "왜 엉뚱한 게 불렸지?"를 찾아 헤매는 밤을 여러 번 줄여 준다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.