들어가며
안녕하세요. AI Services Lab 팀의 ML 엔지니어 이여운입니다. 저희 팀에서는 사용자에게 유해한 오픈챗이 노출되지 않도록 다양한 모델을 개발하고 있습니다. 이번 글에서는 오픈챗의 이름(name)과 설명 글(description)을 기반으로 해당 오픈챗이 유해한지 판단하는 모델을 개발한 과정을 소개하고자 합니다.
오픈챗 모니터링 소개
오픈챗 생성 시 사용자는 오픈챗 이름을 필수로 정해야 하며, 부가로 본 오픈챗을 설명하는 글을 남길 수 있습니다. 서비스를 운영하는 측에서는 오픈챗의 이름과 설명 글이 새롭게 생성되거나 수정될 때 운영 정책에 어긋나는 표현이 포함되어 있는지 검수할 필요가 있습니다. LINE은 글로벌 메신저인 만큼 하루에도 매우 많은 오픈챗이 생성되고 수정되며, 저희 팀에서는 방대한 양을 효율적으로 검수하기 위한 모니터링 모델을 개발해 사람이 직접 검수하는 양을 줄이고 있습니다.
기존 모니터링 모델은 여러 국가에서 이미 좋은 성능을 보이며 검수량을 크게 줄이는 데 기여했습니다. 다만 판단 기준을 더 세분화할 필요가 있는 일부 국가에서는 기존 모델로 자동 검수를 적용할 수 없었습니다. 따라서 이번 프로젝트의 목표는 자동 검수 범위를 확대할 수 있도록 모델 성능을 개선하는 것이었습니다. 동시에 이미 자동 검수를 적용하고 있는 국가에서 더 정확하게 검수할 수 있는 방법을 찾기 위한 다양한 실험을 진행했습니다.
데이터 정제
학습에는 기존에 수동 검수한 오픈챗 이름과 설명 글을 활용했습니다. 단, 검수 가이드라인이 지속적으로 업데이트되는 것을 고려해 현재 가이드라인이 적용되는 기간의 데이터만 추출해 학습 데이터로 사용했습니다.
데이터를 분석해 보니 오픈챗 이름과 설명 글이 동일한데도 서로 다른 징계 결과가 부여된 경우가 적지 않았습니다. 이를 해결하기 위해 동일한 이름과 설명 글에 서로 다른 징계가 부여된 경우, 다음 기준에 따라 최종 라벨을 결정했습니다.
먼저 가장 수위가 높은 징계가 2회 이상 부여되면 해당 징계를 최종 라벨로 선택했습니다. 단, 최고 수위 징계가 한 번만 등장하면 노이즈일 수 있기 때문에 최종 라벨로 선택하지 않았습니다. 그 외의 경우에는 보수적으로 라벨링하기 위해 두 번째로 수위가 높은 징계를 최종 라벨로 선택했습니다. 징계 사유가 서로 다른 경우에는 가장 빈도가 높은 징계 사유를 최종 라벨로 선택했습니다. 만약 빈도가 동일하다면, 전체 데이터에서 더 낮은 빈도로 등장하는 징계 사유를 우선했습니다. 이는 TF-IDF의 아이디어를 참고한 방식입니다. 전체 데이터에서 자주 등장하지 않는 사유일수록 해당 데이터를 더 구체적으로 설명할 가능성이 높다고 판단했기 때문입니다.
# Pseudocode for data cleansing
FOR EACH group IN GROUP_BY(records, key = (name, description)):
codes = SORT_DESC(group.penalty_codes, key = severity_rank)
top_code = FIRST(codes)
# Use the most severe penalty if it appears at least twice
IF COUNT(codes, top_code) >= 2:
final_code = top_code
# Otherwise, use the second-most severe penalty
ELSE:
final_code = SECOND_HIGHEST(codes, key = severity_rank)
reason_count = COUNT_BY_VALUE(group.penalty_reasons)
candidates = ARGMAX_ALL(reason_count)
# Use the most frequent reason
IF SIZE(candidates) = 1:
final_reason = FIRST(candidates)
# Break ties by choosing the globally rarer reason
ELSE:
final_reason = ARGMIN(candidates, key = global_reason_frequency)
EMIT(group.name, group.description, final_code, final_reason)
오픈챗 모니터링 모델
사전 학습 모델 선정
사전 학습 모델은 다음 조건을 기준으로 검토했습니다.
- 디코더 기반 모델일 것
- 안전성 모더레이션(safety moderation) 과제로 튜닝된 모델일 것
- 2B(20억) 내외의 크기일 것
- Apache 라이선스로 제공되는 모델일 것
최근에는 기존에 인코더 모델이 유리하다고 여겨졌던 분류 과제에서도 디코더 모델이 좋은 성능을 보이는 사례가 늘고 있습니다. 이에 따라 인코더를 활용한 기존 모델과 다르게, 디코더 모델을 활용해 실험해 보고자 했습니다. 또한 언어나 세부 도메인이 다르더라도, 이미 안전성 모더레이션 과제로 튜닝된 모델을 활용하면 유해성 판단에 더 유리하다고 판단했습니다. 모델 크기 역시 중요한 고려 사항이었습니다. LINE에서 실시간으로 생성되거나 수정되는 수많은 오픈챗을 모니터링해야 하므로, 지나치게 큰 모델은 서빙 비용과 응답 속도 측면에서 부담이 될 수 있습니다. 따라서 2B 내외의 비교적 가벼운 모델을 후보로 고려했습니다. 마지막으로 라이선스 이슈를 방지하기 위해 상업적 활용이 가능한 Apache 라이선스 기반 모델을 우선 검토했습니다.
최종적으로 Granite Guardian 3.1 2B 모델을 선택했습니다. Granite Guardian은 IBM Research에서 공개한 모델로, "입력이 유해한가?"라는 질문에 대한 대답으로 "Yes" 또는 "No"라는 토큰을 생성할 확률로 유해성 분류를 수행합니다. 일반적인 생성형 모델은 "Yes" 또는 "No"가 아닌 긴 설명문이나 예상하지 못한 형식의 응답을 생성할 가능성이 있습니다. 반면 Granite Guardian은 정해진 후보 토큰의 생성 확률을 비교해 유해성을 판단하므로, 언어 모델의 생성 능력을 활용하면서도 출력 형식이 달라질 문제가 없다는 장점이 있습니다. 또한 토큰 생성 확률을 모델의 신뢰도(confidence)로 보고 임곗값(threshold)을 조정하며 운영에서 요구하는 성능 기준에 맞추기도 용이합니다.
학습
오픈챗 모니터링 모델도 Granite Guardian의 기본 방식을 따르되, 오픈챗 데이터와 운영 요구 사항에 맞게 몇 가지를 수정했습니다.
먼저 오픈챗 이름과 설명 글을 검수한 결과는 단순한 "Yes" 또는 "No"의 이진 분류로 표현하기 어렵습니다. 오픈챗 이름이나 설명 글의 내용에 따라 징계 수위가 낮아지기도 하고 높아지기도 합니다. 또한 오픈챗을 징계할 때에는 단순히 유해 여부만 판단하는 것이 아니라 어떤 사유로 징계하는지도 함께 안내해야 합니다. 즉, 모델은 징계 수위뿐 아니라 징계 사유까지 예측할 수 있어야 합니다. 이를 위해 아래와 같은 형식의 프롬프트를 구성했습니다.
<|start_of_role|>system<|end_of_role|>
You are a safety monitoring agent.<|end_of_text|>
<|start_of_role|>user<|end_of_role|>
{오픈챗 이름 및 설명 글}<|end_of_text|>
<|start_of_role|>assistant<|end_of_role|>
Action:{징계 코드 토큰}
Reason:{징계 사유 토큰}<|end_of_text|>
학습에는 다음 토큰 예측에 일반적으로 사용하는 Cross Entropy Loss를 사용했습니다. 다만 손실(loss)을 계산할 때 전체 프롬프트가 아니라 모델 응답 영역에 대해서만 계산했습니다. 저희의 목표는 모델이 입력으로 주어진 오픈챗의 이름이나 설명 글을 더 잘 생성하는 것이 아니라, 주어진 오픈챗에 대한 징계 코드와 사유를 더 정확히 예측하는 것이기 때문입니다.
또한 효율적으로 학습시키기 위해 전체 파라미터를 업데이트하는 대신 LoRA를 적용했습니다. LoRA는 기존 모델의 파라미터는 고정한 채, 학습을 통해 반영해야 하는 변화량을 작은 행렬 두 개의 곱으로 근사하고 이 행렬들만 업데이트하는 방식입니다. 이를 통해 학습에 필요한 파라미터 수와 메모리 사용량을 크게 줄이면서 사전 학습 과정에서 모델이 습득한 능력을 최대한 유지할 수 있었습니다.
추론
추론 시에는 각 징계 코드 토큰의 생성 확률을 비교했습니다. Granite Guardian과 같은 트랜스포머(Transformer) 기반 디코더는 문장을 생성할 때 모델이 알고 있는 전체 토큰이 이다음에 올 점수를 계산합니다. 이 중 징계 코드에 해당하는 토큰의 점수만 추출한 뒤 확률로 변환하고, 이를 각 징계 코드에 해당할 확률로 해석했습니다.
실제 운영에서 사용하는 징계 코드와 징계 사유 코드는 임의의 알파벳과 숫자로 구성되어 있어 토크나이저를 적용하면 여러 토큰으로 나뉩니다. 하지만 각 징계 코드가 생성될 확률을 계산하려면 징계 코드가 하나의 토큰으로 표현되는 것이 편리합니다. 또한 임의의 코드값을 그대로 사용하는 것보다, 그 자체로 의미가 있는 자연어 토큰을 사용하는 편이 모델이 학습하기에도 더 유리할 것으로 판단했습니다. 이에 따라 각 징계 코드와 징계 사유를 하나의 토큰으로 표현되는 자연어로 매핑해 사용했습니다.
추론 과정 을 코드로 구현하면 아래와 같습니다.
class OndfPredictor:
...
self.penalty_code_ids = [self.tokenizer.convert_tokens_to_ids(t) for t in PENALTY_CODE_TOKENS]
self.penalty_reason_ids = [self.tokenizer.convert_tokens_to_ids(t) for t in PENALTY_REASON_TOKENS]
# exclude <bos>
self.inner_template = self.tokenizer('\nReason:', add_special_tokens=False).input_ids
...
# (1) predict penalty_code
outputs = self.model(**inputs, use_cache=True)
logits = outputs.logits
past_key_values = outputs.past_key_values # cache for (2)
last_token_logits = logits[:, -1, :]
penalty_code_logits = last_token_logits[:, self.penalty_code_ids]
penalty_code_probs = torch.softmax(penalty_code_logits, dim=1)
pred_code_scores, pred_code_ids = torch.max(penalty_code_probs, dim=1)
# map subset indices back to original token IDs & add '\nReason:' tokens after penalty_code
pred_code_ids = torch.tensor([[self.penalty_code_ids[i]] + self.inner_template for i in pred_code_ids.cpu().tolist()]).to(self.device)
# (2) predict penalty_reason
outputs_step2 = self.model(
input_ids=pred_code_ids,
past_key_values=past_key_values,
use_cache=True # use KV-Caching
)
last_token_logits_2 = outputs_step2.logits[:, -1, :]
penalty_reason_logits = last_token_logits_2[:, self.penalty_reason_ids]
penalty_reason_probs = torch.softmax(penalty_reason_logits, dim=1)
pred_reason_scores, pred_reason_ids = torch.max(penalty_reason_probs, dim=1)
# map subset indices back to original token IDs
pred_reason_ids = [self.penalty_reason_ids[i] for i in pred_reason_ids.cpu().tolist()]
먼저 각 징계 코드에 해당할 확률을 추출합니다. 이후 가장 높은 확률의 징계 코드가 생성되었다고 가정하고, 해당 징계 코드에 대한 징계 사유를 이어서 생성하도록 합니다. 이때 징계 코드가 추가된 것을 제외하면 입력 프롬프트는 이전 단계와 동일하므로 효율적인 추론을 위해 KV 캐싱(Key-Value Caching)을 적용했습니다.
KV 캐싱은 이전 토큰들에 대해 계산한 키(key)와 밸류(value)를 재사용하는 기법입니다. 트랜스포머 기반 디코더는 다음 토큰을 생성할 때 토큰 간의 어텐션(attention) 점수를 계산합니다. 이때 각 토큰의 쿼리(query), 키, 밸류값이 필요한데, 새로운 토큰이 계속 추가되더라도 기존 토큰의 키와 밸류는 변하지 않습니다. 따라서 이를 매번 다시 계산하지 않고 재사용하면 중복 계산을 줄이고, 추론 속도도 높일 수 있습니다. 이렇게 징계 사유를 생성한 뒤 징계 코드와 같은 방식으로 각 징계 사유에 해당할 확률을 추출합니다.
성능 평가
학습 기간 이후 세 국가에서 오프라인 평가를 진행했습니다. A 국가는 약 10일, B 국가와 C 국가는 약 한 달 간의 데이터로 오프라인 평가를 진행했습니다. B 국가와 C 국가의 평가 기간이 더 긴 이유는 이미 자동 처리가 적용되고 있어 수동 처리되는 데이터가 적기 때문입니다.
성능을 평가하는 지표로는 ‘정밀도(precision)’와 ‘재현율(recall)’을 모두 고려할 수 있는 F1을 사용했습니다. F1을 선택한 이유는 다음과 같습니다.
대부분의 오픈챗이 ‘문제없음’에 해당하므로 문제없는 오픈챗을 문제없다고 정확히 판단하는 것이 가장 중요합니다. ‘정상 클래스 정밀도’는 모델이 문제없다고 판단한 오픈챗 중 실제로 문제없는 오픈챗의 비율로, ‘문제없음’으로 자동 처리한 결과가 얼마나 정확한지 나타내는 지표입니다. ‘정상 클래스 재현율’은 실제로 문제없는 오픈챗 중 모델이 문제없다고 판단한 오픈챗의 비율로, 자동으로 ‘문제없음’ 처리를 얼마나 많이 할 수 있는지 나타내는 지표입니다. 더 적은 오픈챗을 문제없다고 판단할수록 정상 클래스 정밀도는 높아지지만, 반대로 정상 클래스 재현율은 낮아집니다. 더 많은 오픈챗을 문제없다고 판단할수록 정상 클래스 재현율은 높아지지만, 반대로 정상 클래스 정밀도는 낮아집니다. 이처럼 정밀도와 재현율은 하나를 높이면 하나가 낮아지는 상충 관계인데요, 이들을 한 번에 고려하기 위해 정밀도와 재현율의 조화 평균인 F1을 사용합니다.
또한 LINE은 AI를 활용한 자동 처리에 매우 엄격한 기준을 적용하고 있으며, 자동 처리를 적용하기 위해 요구하는 정밀도가 매우 높습니다. 따라서 임곗값을 도입해 단순히 가장 높은 확률의 징계 코드로 분류하는 것이 아니라 다른 징계 코드보다 확률이 높더라도 그 확률이 임곗값 미만이면 사람이 수동으로 처리할 수 있도록 했습니다. 이후 결과에 따라 임곗값을 조정하면서 높은 정밀도를 유지할 수 있었습니다.
다음은 성능 평가 결과 그래프입니다(정확한 수치는 보안상 공개하지 못하는 점 양해바랍니다). 정상 클래스 F1이 모든 지역에서 기존 모델보다 큰 폭으로 개선되었습니다. NG 클래스 정밀도는 모델이 문제 있다고 판단한 오픈챗 중 실제로 문제 있는 오픈챗의 비율, NG 클래스 재현율은 실제로 문제 있는 오픈챗 중 모델이 문제 있다고 판단한 오픈챗의 비율입니다. 이들을 함께 고려할 수 있는 NG 클래스 F1 역시 모든 지역에서 기존 모델보다 큰 폭으로 개선되었습니다.

참고로 위 그래프에서 B 국가와 C 국가의 기존 모델 정상 클래스 F1 수치가 낮게 나타난 이유는 앞서 말씀드렸듯 기존 모델로 자동 처리한 데이터는 제외하고 평가했기 때문입니다. 편향을 줄이기 위해 자동 처리 여부와 관계없이 전체 데이터로도 성능을 비교했으며, 이 경우에도 위 그래프에서 나타난 것처럼 새로 개발한 모델이 기존 모델보다 더 높은 성능을 보였습니다.
또한 오프라인 평가 이후 진행한 온라인 평가에서도 새로 개발한 모델이 좋은 성능을 보여 자동 검수 적용 범위를 확대할 수 있었습니다.
성능 개선 요소 분석
이러한 성능 개선에 어떤 요소가 얼마만큼 기여했는지 알아보기 위해 동일한 모델을 사용하여 ‘분류 방식’과 ‘임곗값 설정 여부에 따른 성능’을 비교했습니다.
먼저 분류 방식입니다. 일반적인 분류 과제에서는 모델의 마지막 은닉 상태(hidden state) 위에 분류 헤드(classification head)를 추가하고, 이 분류 헤드가 각 클래스에 해당할 점수를 예측합니다. 이러한 ‘분류 헤드 기반 방식’과 ‘후보 토큰을 생성할 확률을 기반으로 분류하는 방식’의 성능을 비교했습니다(앞서와 마찬가지로 정확한 수치는 보안상 공개하지 못하는 점 양해바랍니다).
아래는 비교 결과 그래프입니다. 아래 그래프에서 확인할 수 있듯이 정상 클래스 F1과 NG 클 래스 F1 모두 후보 토큰을 생성할 확률 기반으로 분류했을 때 개선되는 것을 확인할 수 있었습니다.

다음으로 임곗값 설정 여부에 따른 정밀도(아래 왼쪽 그래프)와 재현율(아래 오른쪽 그래프) 성능 차이를 살펴보겠습니다.

임곗값 설정 없이 정상 클래스 토큰을 생성할 확률이 가장 높으면 ‘문제없음’으로 분류하는 방식은 자동 처리를 위해 달성해야 하는 정밀도 기준을 넘지 못했습니다. 그러나 임곗값을 설정해 정상 클래스 토큰을 생성할 확률이 가장 높더라도 이 확률이 임곗값보다 높아야만 ‘문제없음’으로 분류하는 방식의 경우에는 정밀도 기준을 넘어 자동 처리를 적용할 수 있었습니다.
다만 정밀도와 재현율은 상충 관계인 만큼 임곗값 설정 후 오른쪽 그래프처럼 재현율은 감소했습니다. 그러나 정밀도가 충분히 높지 않으면 재현율이 아무리 높아도 자동 처리를 적용할 수 없으므로, 저희에게는 정밀도를 개선하는 것이 더 효과적이었습니다.
마치며
지금까지 오픈챗의 이름과 설명 글을 기반으로 오픈챗이 유해한지 판단하는 모델의 개발 과정을 소개해 드렸습니다. 모델을 개발하는 과정에서 학습 데이터를 정제하고, 해당 과제에 유리한 사전 학습 모델을 선정한 뒤 운영 측의 조건을 만족하도록 추론 방식을 설계하고 임곗값을 조정하는 과정을 하나씩 살펴봤습니다.
추후에는 데이터의 품질에 따라 학습 가중치를 설정하거나, 자동 처리되지 못한 데이터를 더 큰 모델로 다시 추론하는 모델 캐스케이딩(model cascading) 방식을 구상하고 있습니다.
앞으로도 LINE 서비스를 이용하는 모든 사용자가 안전하고 건강한 커뮤니티를 즐길 수 있도록 성능을 계속 개선해 나가고자 합니다. 끝까지 읽어주셔서 감사합니다.


