SLM 환경에서 In-schema Chain-of-Thought(CoT) 활용
최근 수행한 PropTech 분야의 업무 자동화 솔루션 프로젝트에서 흥미로운 문제를 만났다. 임대차 계약과 수납, 시설 관리에 이르는 업무 흐름은 이미 앱 안에 충분히 자동화되어 있었지만 정작 그 기능을 제때 호출할 줄 아는 사용자는 많지 않았다. 자동화의 병목이 자동화 로직이 아니라 그 로직에 도달하는 경로에 있었던 셈이다. 그래서 자연어로 의도를 말하면 적절한 기능으로 안내하고 필요한 인자까지 채워 주는 헬퍼 에이전트(Helper Agent)를 얹기로 했다.
제약 조건은 처음부터 분명했다. 이 에이전트는 외부 LLM 프로바이더의 API를 사용할 수 없었고 앱과 동일한 머신에 설치된 소형 언어 모델(SLM)로 동작해야 했다. 모델은 Qwen3 1.7B를 선택했다. 그리고 이 조합, 즉 하이브리드 추론 모델과 제약 디코딩(Constrained decoding)의 조합에서 두 메커니즘이 정면으로 충돌하는 지점을 마주쳤다. 이 글은 그 충돌의 원인과 해법, 그리고 그 과정에서 얻은 설계 원칙에 대한 기록이다.
왜 로컬 SLM이었는가
외부 API를 배제한 결정은 성능의 문제가 아니라 고객사의 온프레미스 요구사항에 따른 것이었다.
- 데이터 경계 — 헬퍼 에이전트의 입력에는 임차인 인적사항, 계약 금액, 연체 이력 같은 정보가 화면 컨텍스트와 함께 실린다. 이 데이터가 프롬프트에 담겨 사업장 밖으로 나가는 순간 그것은 성능 논쟁이 아니라 컴플라이언스 사안이 된다.
- 비용 구조 — 헬퍼는 특별한 순간에 호출되는 기능이 아니라 상시 대기하는 UI 요소다. 호출당 과금 모델에서 상시 호출은 예측 불가능한 변동비가 되며 이는 온프레미스 라이선스로 공급되는 제품의 가격 구조와 맞지 않는다.
- 가용성 — 현장 사무소의 회선 품질과 프로바이더 장애가 앱의 핵심 안내 기능을 정지시켜서는 안 된다.
결국 앱과 함께 배포되는 1.7B급 Qwen3 모델이 검토 대상이 될 수 밖에 없었는데 지금부터가 오늘 논의의 핵심이다. 1.7B 모델은 프런티어 모델과 다른 방식으로 다뤄야 한다. 지시를 이해하지 못해서가 아니라 지시를 일관되게 지키지 못하기 때문이다. 100번 중 97번 올바른 형식을 내놓는 모델은 나머지 3번을 예측할 수 없다는 점에서 신뢰할 수 없다. 그래서 두 개의 보정 장치가 필요했다. 출력의 형태를 강제하는 장치와 판단의 질을 끌어올리는 장치다.
헬퍼 에이전트의 출력은 문장이 아니라 액션 레코드다
헬퍼 에이전트가 최종적으로 생산해야 하는 것은 사용자에게 보여 줄 문장이 아니다. 어떤 화면으로 이동할지, 어떤 필터를 적용할지, 어떤 초안을 어떤 인자로 생성할지를 지정하는 구조화된 액션 레코드다. 이 레코드는 곧바로 앱의 내부 API 호출로 변환된다.
따라서 자유 텍스트를 뽑아 정규식으로 파싱하는 접근은 이 경우에 적합하지 않은데 문제는 실패율 자체가 아니라 실패의 분포에 있기 때문이다. 드물게, 그리고 재현이 어려운 방식으로 오류를 발생하 파서는 운영 단계에서 가장 다루기 힘든 종류의 결함이 된다. 그래서 제약 디코딩을 채택했는데 핵심은 JSON 스키마를 문법(GBNF 등)으로 컴파일한 뒤 디코딩 매 스텝마다 문법이 허용하지 않는 토큰의 로짓(logit)을 마스킹하는 방식이다. 핵심은 사후 검증이 아니라 사전 봉쇄라는 데 있다. 문법에 어긋나는 토큰은 낮은 확률로 선택되는 것이 아니라 애초에 선택될 수 없다. 파싱 실패율이 낮아지는 것이 아니라 구조적으로 0이 된다. 1.7B 모델을 실 운영에 적용하려면 사실상 필수적인 선택이었다.
System 2를 켜다: 하이브리드 추론 모델
두 번째 보정 장치는 사고 유도였다. Qwen3는 하나의 가중치 안에 사고 모드(thinking)와 비사고 모드(non-thinking)를 함께 담은 하이브리드 추론 모델이다. 채팅 템플릿의 enable_thinking 인자나 프롬프트의 /think, /no_think 지시로 전환하며, 사고 모드에서는 최종 답변에 앞서 <think> … </think> 구간을 먼저 생성한다. 권장 샘플링 설정도 모드별로 다르다. 사고 모드에서는 temperature 0.6, top-p 0.95, top-k 20이 권장되고 있다.
헬퍼 에이전트의 라우팅 판단은 단순 분류가 아니었다. "지난달 밀린 거 좀 보여줘" 같은 발화는 현재 화면 상태, 기간 표현의 해석, 유사 기능 간의 미묘한 차이를 함께 따져야 올바른 액션으로 이어진다. System 2가 유효할 법한 문제이었기 때문에 사고 모드를 활성화했는데 그 지점에서 문제가 시작됐다.
충돌: 하나의 토큰 스트림, 두 개의 통제 주체
증상은 처음엔 산발적이고 해석하기 어려웠다. 그러나 원인은 명확했고 심지어 필연적이었다.
제약 디코딩의 상태 머신은 어시스턴트 턴의 첫 토큰부터 작동한다. 스키마의 루트가 오브젝트라면 그 시점에 허용되는 토큰은 {와 선행 공백뿐이다. <think>는 문법이 허용하는 집합에 존재하지 않으므로 마스킹된다. 즉 사고 모드를 활성화한 순간과 문법적 제약을 적용한 순간 같은 토큰 스트림에 서로 다른 두 개의 통제 주체가 붙는다. 실제로 관측되는 결과는 런타임이 둘 중 어느 쪽에 우선권을 주느냐에 따라 다음과 같이 두 갈래로 갈린다.
문법 제약이 우선하는 경우 — <think>가 마스킹되므로 모델은 사고 과정을 거치지 않고 곧바로 JSON을 출력한다. 겉보기에는 정상 동작이지만 이 경우 사고 모드용 샘플링 파라미터로 System 1 출력을 받고 있는 상태이며, 더 나쁜 것은 모델이 학습 분포에서 이탈했다는 점이다. 사고한 뒤 답하도록 후학습된 모델에게 즉답을 강요하면 품질은 비사고 모드보다도 더 나빠질 수 있다. 이때 나타나는 전형적인 증상이 사고의 누수로 갈 곳을 잃은 추론이 스키마상 가장 먼저 열리는 문자열 필드로 새어 들어온다.
{ "action": "사용자의 의도를 먼저 파악해야 한다. 현재 화면은 수납 현황이고 ..." }추론 파서가 우선권을 갖는 경우 — 일부 런타임은 추론 파서를 두어 </think> 이후부터 문법 제약을 적용한다. 형식적으로는 양립하는 것처럼 보이지만 1.7B에서는 다른 문제가 생기는데 사고 구간이 완전히 무제약인 영역이 존재하게 되기 때문이다. 소형 모델은 종료 태그를 제때 내지 못하고 같은 문장을 맴돌기 쉽고 그 사이 토큰은 계속 소모되게 된다. 이 과전의 결과는 JSON 진입 자체에 실패하거나 중도에 절단된 응답을 받게 되고 응답 지연의 분산도 사실상 통제 불가능해진다.
정리하자면 하이브리드 추론 모델은 사고를 최종 답변과 분리된 대역 외(out-of-band) 구간에 두는 것을 전제하고, 제약 디코딩은 스트림 전체가 대역 내(in-band)에서 스키마에 부합할 것을 요구한다. 두 전제는 양립할 수 없다. 즉 어느 한쪽을 끄지 않으면 해결되지 않는다는 뜻이다.
우회 방안 후보 검토
문제를 이렇게 정의하고 나면 선택지는 다음의 셋으로 좁혀진다.
| 우회안 | 방식 | 왜 채택하지 않았는가 |
|---|---|---|
| 2개 패스로 분리 | 1차 호출은 무제약 사고 모드 실행, 2차 호출은 그 결과를 컨텍스트로 넣고 제약 디코딩 | 프리필과 디코딩을 두 번 수행하므로 지연이 사실상 두 배로 증가. 고객사 머신의 한정된 연산 자원을 앱과 나눠 쓰는 온프레미스 조건에서는 적합하지 않음. |
| 추론 파서 연동 문법 | </think> 이후에만 문법을 활성화 |
런타임 특정 기능에 종속되고 고객 환경에 이미 존재하는 추론 서버 위에 얹어야 하는 제품에서 이식성 리스크가 높음. 사고 길이가 여전히 무제약이라는 근본 문제를 해결하지 못함. |
| 사후 복구·재요청 | 출력 오류를 감지해 재시도 | 지연 시간 문제를 악화시킬 뿐 근본 원인에 대한 해결책이 되지 못함. 재시도가 성공한다는 보장도 없음. |
이 방안들 모두 충돌을 관리하는 기능을 부분적으로 수행할 뿐 근본적인 문제 해결책은 되지 못했기 때문에 다른 방안을 강구할 수 밖에 없었다.
해법: 사고를 스키마 안으로 (In-schema CoT)
결정은 단순했다. enable_thinking: false로 사고 모드를 비활성화하고 대신 사고 과정을 출력 스키마의 일부로 편입시켰다. 이 방법은 System 2를 포기한 것이 아니라 사고의 위치를 옮긴 것에 해당한다. 이렇게 시도한 근거는 자기회귀 디코딩의 성질에 있다. 모델은 왼쪽에서 오른쪽으로 토큰을 생성하며 앞선 필드에서 생성된 토큰은 뒤따르는 필드를 디코딩할 때 그대로 컨텍스트가 된다. 사고를 담은 필드가 답을 담은 필드보다 앞에 오면 인과 구조는 <think> 구간과 동일하다. CoT를 작동시키는 것은 태그 자체가 아니라 배열되는 순서라는 것에 착안한 것이다.
{
"type": "object",
"additionalProperties": false,
"required": ["user_goal", "evidence", "rejected", "action", "params", "confidence"],
"properties": {
"user_goal": { "type": "string", "maxLength": 120 },
"evidence": { "type": "array", "maxItems": 3,
"items": { "type": "string", "maxLength": 80 } },
"rejected": { "type": "string", "maxLength": 120 },
"action": { "enum": ["search_listing", "draft_contract",
"list_overdue_rent", "export_report",
"ask_clarification"] },
"params": { "type": "object" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
}앞의 세 필드가 사고 구간이고 뒤의 세 필드가 답변 구간이다. 실제 출력은 다음과 같은 모습이 된다.
{
"user_goal": "지난달 임대료가 미납된 임차인을 확인하려 한다",
"evidence": ["현재 화면: 임대 관리 > 수납 현황", "'지난달'은 직전 월 전체를 가리킨다"],
"rejected": "export_report는 조회가 아니라 파일 생성이므로 의도와 다르다",
"action": "list_overdue_rent",
"params": { "period": "2026-07", "status": "overdue" },
"confidence": 0.82
}문법은 첫 토큰부터 끝까지 단절 없이 적용되고 모델은 답을 내기 전에 목표를 정의하고 근거를 수집하고 후보를 기각한다. 이제 충돌하던 두 메커니즘이 하나의 스트림 안에서 공존하게 된 것이다.
스키마 설계의 네 가지 원칙
In-schema CoT는 필드 몇 개를 추가하는 작업처럼 보이지만 실제로는 다음 원칙들을 지켜야 의도한 기대 효과를 얻을 수 있다.
1. 필드 순서를 고정할 수 있는지 확인 JSON 명세에서 오브젝트 키의 순서는 의미를 갖지 않는다. 그러나 In-schema CoT의 전제는 전적으로 순서다. 스키마를 문법으로 변환하는 컴파일러가 선언된 속성 순서를 그대로 강제하는지 반드시 확인해야 한다. 임의 순서를 허용하는 구현이라면 모델은 action을 첫 필드로 낼 수 있고, 그 순간 사고 필드는 답을 정당화하는 사후 서술로 전락한다. 검증 방법은 간단하다. 답변 필드가 먼저 나올 수 있는 출력이 문법상 유효한지 확인하면 된다.
2. 사고 시간의 허용 범위 통제 maxLength와 maxItems는 단순한 방어 장치가 아니고 사고 모드가 끝내 주지 못했던 것, 즉 추론 길이의 상한을 제공한다. 온프레미스 추론에서 응답 지연을 지배하는 항은 생성 토큰 수이며 이 설계는 그것을 통제 불가능한 변수에서 우리가 정하는 설계 범위 내에서 동작하게 만들어 준다. 초당 30토큰을 내는 환경에서 400토큰의 자유 사고는 13초지만 80토큰으로 제한된 사고는 3초 이내가 된다.
3. 자유 서술은 지양 reasoning 필드 하나에 모든 것을 담게 하지 말고 목표, 근거, 기각 사유처럼 역할이 분명한 슬롯으로 분해하는 것이 좋다. 소형 모델에서는 구조가 능력을 일정 부분 대체하는데 백지에 사고를 적는 일보다 빈칸을 채우는 일이 쉽고 슬롯 이름 자체가 모델에게 무엇을 확인해야 하는지 지시하는 역할을 하기 때문이다. 특히 rejected 같은 반증 슬롯은 성급한 결론을 막는 데 효과가 좋다.
4. 사고 필드를 로그로 보존 앱으로 전달되는 액션 레코드에서 사고 필드는 제거된다. 그러나 그대로 버리기에는 아깝다. 이 필드들은 잘못된 라우팅이 발생했을 때 모델이 무엇을 근거로 그렇게 판단했는지를 알려 주는 관측 데이터이자, 이후 평가셋과 프롬프트 개선의 재료가 된다. <think> 블록에서도 같은 텍스트를 얻을 수는 있지만 그것은 파서를 통과해야 얻어지는 비정형 문자열일 뿐 액션 레코드와 정렬되지 않는다.
무엇을 얻고 무엇을 포기했는가
솔직하게 말하면 포기한 것도 있다. In-schema CoT는 후학습으로 형성된 모델 고유의 사고 분포를 사용하지 않는다. 우리가 설계한 슬롯은 사람이 정의한 틀이지 모델이 학습한 사고 궤적이 아니다. 따라서 다단계 수학이나 복잡한 코드 추론처럼 긴 탐색이 필요한 과제에서는 진짜 사고 모드가 여전히 우월하다.
우리 과제는 그런 종류가 아니었다. 유한한 액션 집합에 대한 라우팅과 인자 추출이었고 필요한 것은 깊은 탐색이 아니라 누락 없는 확인 절차였다. 이런 문제에서는 슬롯화된 사고가 자유 사고보다 오히려 안정적이라는 것을 확인했다. 완화책도 함께 적용했는데 슬롯 이름을 모델이 익숙한 추론 어휘에 맞추고 스키마와 정확히 동형인 예시를 시스템 프롬프트에 한두 개 배치했다.
그러나 과한 구조 설계에 빠지지 않도록 유의해야 하는데 슬롯을 늘릴수록 토큰 비용이 늘고 사고가 형식적으로 흐를 위험도 커지기 때문이다. 슬롯 하나를 추가할 때는 그것이 실제로 오답을 줄이는지 근거가 있어야 하며 스키마를 안정적으로 유지하는 것도 실무상 중요하다. 스키마가 고정되어야 문법 컴파일 결과와 프롬프트 프리픽스를 캐시할 수 있고 그만큼 응답 지연을 줄여 사용성을 개선할 수 있기 때문이다.
정리
하이브리드 추론 모델의 사고 모드는 훌륭한 기능이지만 그 기능이 전제하는 출력 구조가 시스템의 다른 제약 조건과 충돌한다면 사고 기능을 무조건적으로 활성화하는 것만이 답이 될 수는 없다. 우리에게 필요했던 것은 <think> 태그 자체가 아니라 답보다 먼저 오는 추론이었고 그것은 스키마만으로도 충분히 구현할 수 있었다는 것이 핵심 포인트이다. 여러분도 SLM을 활용한 온프레미스 또는 온디바이스 AI 에이전트 실행 환경을 구축한다면 이 글에서 정리한 팁과 방법들이 솔루션 구현에 도움이 되길 바란다.