11_오픈 소스 기여 종합 가이드 1건
들어가며
해당 글은 open source 기여 내용을 기재하였다.
vLLM 프로젝트에 올린 PR #46793 — [Frontend] Support bad_words in the /v1/completions endpoint 이 merge 되었다.
한 줄로 요약하면 이렇다.
"Chat API에서는 되던 기능이 옛날 completions API에서는 안 되던 문제" 를 메꾼 작업
aws 에서 vllm 을 이용하며 서빙하다 시작하게 되었다.
오픈소스에서 "기능 일관성(feature parity)"을 맞추는 일이 왜 중요한지 잘 보여주는 사례라 기록으로 남긴다.
1. bad_words 가 뭔가
bad_words 는 "이 단어들은 절대 생성하지 마" 라고 모델에게 지시하는 옵션이다.
예를 들어 사내 챗봇이 특정 금칙어나 경쟁사 이름을 뱉으면 안 될 때, 프롬프트로 사정하는 대신 샘플링 단계에서 아예 해당 토큰이 나올 확률을 0으로 눌러버린다. 훨씬 확실하고 우회가 어렵다.
vLLM 내부에서 이 값을 실제로 받아 처리하는 곳은 SamplingParams 다. 중요한 건 SamplingParams 는 이미 bad_words 를 완벽하게 지원하고 있었다 는 점이다. 엔진은 준비가 다 돼 있었는데, 입구(API) 하나가 막혀 있던 상황이었다.
2. 문제 — 두 엔드포인트의 온도차
vLLM은 OpenAI 호환 API를 두 벌 제공한다.
| 엔드포인트 | 용도 | bad_words 지원 |
|---|---|---|
/v1/chat/completions | 최신 챗 형식 (messages 기반) | ✅ 지원 |
/v1/completions | legacy 형식 (prompt 기반) | ❌ 미지원 |
같은 엔진, 같은 SamplingParams 를 쓰는데 한쪽 문에는 손잡이가 있고 다른 쪽 문에는 없었다. legacy 라고는 하지만 /v1/completions 는 여전히 수많은 서비스가 쓰는 현역 엔드포인트다. 이 API를 쓰는 사용자는 "왜 나는 금칙어 설정을 못 하지?" 하고 막히게 된다. 이런 엔드포인트 간 기능 불일치 는 사용자 입장에서 가장 당황스러운 종류의 버그다. 되는 게 정상인데 안 되니까.
원인은 단순했다. 챗 쪽 요청 모델(ChatCompletionRequest )에는 bad_words 필드가 정의돼 있고 to_sampling_params() 에서 엔진으로 넘겨주는데, completions 쪽 요청 모델(CompletionRequest )에는 그 두 부분이 통째로 빠져 있었다.
3. 무엇을, 왜 고쳤나
수정 파일은 딱 하나, vllm/entrypoints/openai/completion/protocol.py 다. 챗 쪽 구현을 그대로 거울처럼(mirror) 따라가면 되는 작업이라 판단했다.
(1) 요청 모델에 필드 추가 — 사용자가 API로 bad_words 를 보낼 수 있게 입구를 뚫는다.
# class CompletionRequest 내부
allowed_token_ids: list[int] | None = None
prompt_logprobs: int | None = None
+ bad_words: list[str] = Field(default_factory=list)
default_factory=list 로 기본값을 빈 리스트로 뒀다. 값을 안 보내면 아무 단어도 막지 않는, 기존과 동일한 동작이다. 챗 엔드포인트의 기본값과도 정확히 일치시켜 두 API가 똑같이 행동하도록 했다.
(2) 엔진으로 전달 — 받은 값을 실제 처리하는 SamplingParams 로 넘긴다.
# def to_sampling_params(...) 내부
logit_bias=self.logit_bias,
allowed_token_ids=self.allowed_token_ids,
+ bad_words=self.bad_words,
extra_args=extra_args or None,
필드를 정의만 하고 엔진에 안 넘기면 값은 그냥 버려진다. 이 한 줄이 "입구에서 받은 값"을 "실제 동작"으로 연결하는 배선(plumbing)이다. 정리하면 이렇다.
| 단계 | 하는 일 | 이 PR에서 한 것 |
|---|---|---|
| ① API 요청 수신 | 사용자가 bad_words 를 JSON으로 전송 | 필드 정의 추가 |
| ② SamplingParams 전달 | 받은 값을 엔진 파라미터로 넘김 | bad_words=self.bad_words 배선 |
| ③ 실제 토큰 억제 | 해당 토큰 확률을 0으로 | 기존 엔진이 이미 처리 |
즉 새로 만든 로직은 없다. 이미 완성돼 있던 엔진 기능에 입구만 열어준 것이다. 오픈소스 기여에서 가장 안전하고 리뷰가 잘 통과되는 유형 — "기존 검증된 패턴을 그대로 복제해 일관성을 맞추는" 변경이다.
4. 테스트 — CPU만으로 도는 단위 테스트 2개
GPU 없이도 CI에서 빠르게 검증되도록, 무거운 추론 대신 파라미터 변환 로직만 콕 집어 확인하는 단위 테스트를 붙였다.
def test_completion_request_bad_words_to_sampling_params():
"""bad_words 가 SamplingParams 로 전달되는지 (챗과 동일 동작)"""
request = CompletionRequest(
model="test-model", prompt="Hello",
bad_words=["foo", "bar"], max_tokens=10,
)
sampling_params = request.to_sampling_params(
max_tokens=10, default_sampling_params={},
)
assert sampling_params.bad_words == ["foo", "bar"] # 값이 그대로 전달됐는가
def test_completion_request_bad_words_default_empty():
"""값을 안 보내면 빈 리스트 (챗 엔드포인트와 동일 기본값)"""
request = CompletionRequest(model="test-model", prompt="Hello", max_tokens=10)
assert request.bad_words == []
sampling_params = request.to_sampling_params(
max_tokens=10, default_sampling_params={},
)
assert sampling_params.bad_words == []
두 테스트가 각각 검증하는 것:
| 테스트 | 검증 내용 | 왜 필요한가 |
|---|---|---|
..._to_sampling_params | 보낸 값이 엔진까지 도달 | 배선이 실제로 연결됐는지 |
..._default_empty | 미지정 시 빈 리스트 | 기존 사용자 동작이 안 깨지는지 (하위 호환) |
리뷰어가 가장 먼저 묻는 두 질문 — "새 기능 되나?", "기존 거 안 깨지나?" — 에 코드로 미리 답을 준 셈이다.
5. 결과
| 항목 | 내용 |
|---|---|
| PR 번호 | #46793 |
| 제목 | Frontend Support bad_words in the /v1/completions endpoint |
| 상태 | Merged |
| 변경 규모 | +39 / -0, 파일 2개 |
| 변경 핵심 | 필드 정의 1줄 + 엔진 배선 1줄 + 단위 테스트 2개 |
제출 전에 bad_words 관련 기존 PR들을 미리 검색해, 이 작업이 중복이 아님(기존 PR들은 tokenizer 변환/캐싱 버그 수정이지 completions 엔드포인트에 필드를 추가하는 건 없었음)을 확인하고 PR 본문에 근거로 남겼다. 이 사전 확인이 리뷰 왕복을 크게 줄여준다.
마무리
aws 에서 vllm 쓰는 과정에서, 처음으로 오픈 소스 기여를 해보았다.
- 엔진 기능과 API 노출은 별개다. 내부적으로 지원하는 기능이라도 입구가 안 열려 있으면 사용자에겐 "없는 기능"이다.
- 일관성이 곧 사용자 경험이다. 한 엔드포인트에서 되는 게 다른 곳에서 안 되면, 그것 자체가 버그로 체감된다.
본 글은 과학기술정보통신부·정보통신산업진흥원 「2026년 오픈소스 AI·SW 개발·활용 지원사업」의 지원으로 수행된 연구 결과입니다.