- Published on
CoT 누출 없이 추론 강제 - JSON 스키마+RAG
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM을 붙이다 보면 곧바로 부딪히는 딜레마가 있습니다.
- 답변 품질을 올리려면 모델이 “충분히 생각”하도록 유도해야 한다
- 하지만 그 “생각 과정(CoT)”이 그대로 사용자에게 노출되면 보안·컴플라이언스·프롬프트 유출·환각 증폭 측면에서 리스크가 커진다
이 글은 CoT를 사용자에게 노출하지 않으면서도, 모델이 추론을 수행하도록 강제하고, 결과를 검증 가능한 형태로 고정하는 실전 패턴을 다룹니다. 핵심은 두 가지입니다.
- JSON 스키마로 출력 구조를 강제해 “생각은 하되, 결과는 정해진 필드로만” 내보내게 만들기
- RAG로 근거를 강제하고, 근거와 답변의 정합성을 “기계적으로” 체크하는 루프 만들기
아래 내용은 특정 벤더에 종속되지 않지만, 예시는 OpenAI 스타일의 스키마 기반 응답과 일반적인 벡터 검색 RAG를 기준으로 설명합니다.
왜 CoT를 숨기면서도 추론을 강제해야 하나
CoT 노출의 대표 리스크
- 프롬프트/시스템 정책 누출: 모델이 내부 지시를 요약하거나 재진술하면서 정책이 노출될 수 있습니다.
- 보안 정보 혼입: RAG 문서나 로그에서 가져온 민감정보가 “생각 과정”에 섞여 출력될 수 있습니다.
- 법무/컴플라이언스: 결정의 근거를 “설명”해야 하는 경우가 많지만, CoT는 설명이 아니라 내부 추론의 흔적이라 오히려 위험할 수 있습니다.
- 사용자 경험: 장황한 중간 생각이 UX를 해치고, 잘못된 중간 추론이 신뢰를 떨어뜨립니다.
그럼에도 추론을 강제해야 하는 이유
- 모델이 단답형으로 튀거나, 근거 없이 확신하는 답을 내는 것을 줄이려면 “내부적으로는” 충분히 검토하도록 유도해야 합니다.
- 특히 RAG에서는 검색 결과의 문맥을 비교·종합하는 과정이 필요합니다.
정리하면, 추론은 필요하지만 노출은 불필요합니다. 따라서 “추론은 하되, 출력은 통제”가 목표입니다.
접근 1: JSON 스키마로 출력 표준화하기
스키마 강제의 목적은 단순히 JSON으로 예쁘게 받는 것이 아닙니다.
- 모델이 답변을 특정 슬롯에만 채우도록 제한
- 근거를 citations 같은 필드로 강제
- 불확실하면 unknown 또는 needs_more_info 같은 상태로 내보내게 강제
- 후처리/검증/로그 마스킹을 자동화
권장 출력 스키마 예시
아래는 “최종 답변”과 “근거”를 분리하고, CoT 대신 검증 가능한 요약 근거만 담게 하는 스키마입니다.
{
"type": "object",
"additionalProperties": false,
"required": ["answer", "citations", "confidence", "status"],
"properties": {
"status": {
"type": "string",
"enum": ["ok", "needs_more_info", "cannot_answer"]
},
"answer": {
"type": "string",
"description": "사용자에게 보여줄 최종 답변. 내부 추론/체인오브쏘트는 포함하지 않는다."
},
"citations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["doc_id", "quote"],
"properties": {
"doc_id": {"type": "string"},
"quote": {"type": "string", "description": "근거 문서에서 발췌한 짧은 인용"}
}
}
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"followups": {
"type": "array",
"items": {"type": "string"},
"description": "추가로 필요한 질문(선택)"
}
}
}
포인트는 다음과 같습니다.
additionalProperties: false로 스키마 밖 출력을 차단합니다.citations.quote는 “생각 과정”이 아니라 근거 텍스트의 일부만 허용합니다.status를 강제해 “모르면 모른다”를 구조적으로 표현합니다.
모델 프롬프트 설계: CoT를 직접 요구하지 말기
CoT를 숨기고 싶다면, 프롬프트에서 “단계별로 생각해봐” 같은 문구를 무심코 넣지 않는 게 중요합니다. 대신 다음을 요구합니다.
- 답변은 스키마에만 채울 것
- 근거는
citations로만 제시할 것 - 불확실하면
needs_more_info로 전환하고followups를 채울 것
이 방식은 “추론을 하지 말라”가 아니라, “추론을 출력하지 말라”에 가깝습니다.
구현 예시: 스키마 기반 응답 받기
아래는 Node.js에서 스키마 기반 JSON 출력을 요청하는 예시입니다(개념 코드).
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const schema = {
name: "rag_answer",
schema: {
type: "object",
additionalProperties: false,
required: ["status", "answer", "citations", "confidence"],
properties: {
status: { type: "string", enum: ["ok", "needs_more_info", "cannot_answer"] },
answer: { type: "string" },
citations: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["doc_id", "quote"],
properties: {
doc_id: { type: "string" },
quote: { type: "string" }
}
}
},
confidence: { type: "number", minimum: 0, maximum: 1 },
followups: { type: "array", items: { type: "string" } }
}
}
};
export async function answerWithSchema({ question, contextDocs }: {
question: string;
contextDocs: Array<{ id: string; text: string }>;
}) {
const context = contextDocs
.map(d => `doc_id=${d.id}\n${d.text}`)
.join("\n\n---\n\n");
const res = await client.responses.create({
model: "gpt-4.1-mini",
input: [
{
role: "system",
content: [
{
type: "text",
text:
"너는 RAG 기반 QA 엔진이다. 출력은 반드시 JSON 스키마를 만족해야 한다. " +
"내부 추론 과정(Chain-of-Thought)을 노출하지 말고, 근거는 citations.quote에 문서에서 직접 인용한 짧은 문장으로만 제시하라. " +
"문맥으로 답할 수 없으면 status를 needs_more_info 또는 cannot_answer로 설정하라."
}
]
},
{
role: "user",
content: [
{ type: "text", text: `질문: ${question}` },
{ type: "text", text: `컨텍스트:\n${context}` }
]
}
],
text: {
format: {
type: "json_schema",
json_schema: schema
}
}
});
// SDK에 따라 res.output_text 또는 파싱된 JSON을 얻는 방식이 다릅니다.
const json = JSON.parse(res.output_text);
return json;
}
스키마 기반 호출에서 자주 터지는 에러(필드 누락, enum 불일치, 타입 오류)는 운영에서 매우 흔합니다. 이 부분은 별도의 디버깅 체크리스트가 필요합니다. 관련해서는 OpenAI Responses API 400 에러 - schema·tool 호출 디버깅을 함께 참고하면 시행착오를 크게 줄일 수 있습니다.
접근 2: RAG로 근거를 강제하고 “검증 루프” 만들기
JSON 스키마만으로는 “그럴듯한 거짓말”을 완전히 막지 못합니다. 그래서 RAG가 들어갑니다. 중요한 건 단순히 검색해서 붙이는 것이 아니라, 다음을 시스템적으로 강제하는 것입니다.
- 답변은 컨텍스트에 있는 내용으로만 작성
citations.quote는 컨텍스트에서 직접 발췌- 검증 단계에서 quote가 실제로 컨텍스트에 존재하는지 확인
RAG 파이프라인 권장 구조
- 질의 전처리(언어 감지, 의도 분류, 키워드 확장)
- 벡터 검색(TopK) + 필요 시 키워드 검색(BM25) 하이브리드
- 컨텍스트 정리(중복 제거, 길이 제한, 섹션 단위 스니펫)
- LLM 호출(스키마 강제)
- 사후 검증
citations.quote가 실제 컨텍스트에 포함되는지doc_id가 유효한지- 금칙어/민감정보 필터
- 실패 시 재시도 전략
- TopK 확대
- 다른 쿼리로 재검색
needs_more_info로 전환
citations 검증: 가장 값싼 “환각 방지 장치”
citations.quote를 “문서에서 직접 인용한 문자열”로 강제했으면, 서버에서 다음을 검증할 수 있습니다.
- quote 문자열이 해당 문서 스니펫에 부분 문자열로 존재하는지
- 존재하지 않으면 결과를 폐기하고 재시도
이 검증은 모델을 믿지 않고도 할 수 있는, 매우 강력한 안전장치입니다.
function verifyCitations(result: any, docs: Array<{ id: string; text: string }>) {
const docMap = new Map(docs.map(d => [d.id, d.text]));
for (const c of result.citations ?? []) {
const text = docMap.get(c.doc_id);
if (!text) return { ok: false, reason: "unknown_doc_id" };
if (typeof c.quote !== "string" || c.quote.length < 5) {
return { ok: false, reason: "invalid_quote" };
}
if (!text.includes(c.quote)) {
return { ok: false, reason: "quote_not_in_context" };
}
}
return { ok: true };
}
운영에서는 includes만으로는 부족할 때가 많습니다(공백 정규화, 줄바꿈, OCR 텍스트 등). 그럴 때는 다음을 추가합니다.
- 공백/개행 정규화 후 비교
- n-gram 유사도 기반 “거의 일치” 허용(단, 너무 느슨하면 다시 환각 통로가 됩니다)
벡터 DB 튜닝이 답변 품질을 좌우한다
RAG 품질은 결국 “검색 품질”이 좌우합니다. 특히 TopK가 작거나 인덱스 파라미터가 맞지 않으면, 모델이 참고할 만한 근거를 못 받습니다. 그러면 스키마는 맞춰도 citations가 빈약해지고, confidence가 높게 나오는 등 이상 행동이 발생합니다.
Milvus를 쓴다면 인덱스 선택과 파라미터가 중요합니다. 실제 튜닝 관점은 Milvus IVF_FLAT vs HNSW 성능 튜닝 실전에서 자세히 다룬 바 있으니, RAG 성능 병목이 의심될 때 함께 점검해 보세요.
“추론 강제”를 CoT 없이 구현하는 패턴 3가지
1) Plan-then-Answer를 “숨겨진 필드”로 하지 말고 “검증 가능한 필드”로
일부는 analysis 필드 같은 곳에 계획을 담게 하고 UI에서 숨기려 합니다. 하지만 로그, APM, 고객사 전달 등 경로가 많아 결국 새나갈 가능성이 큽니다.
대신 계획을 다음으로 치환합니다.
followups: 추가 질문citations: 근거 인용status: 답변 가능 여부
즉 “추론 흔적”이 아니라 “검증 가능한 산출물”을 남깁니다.
2) 자기검증(Self-check)을 “두 번째 호출”로 분리
한 번의 호출에서 답변과 검증을 동시에 하게 하면, 모델이 자기모순을 합리화하는 경우가 있습니다. 운영에서는 다음처럼 나누는 게 더 안정적입니다.
- 1차: 답변 생성(스키마 A)
- 2차: 검증 전용(스키마 B)
- 입력: 1차 답변 + 컨텍스트
- 출력:
verdict(pass/fail) + 실패 사유 + 수정 제안
2차 검증은 사용자에게 노출할 필요가 없고, 실패하면 1차를 폐기하고 재시도합니다.
3) “모르면 모른다”를 정책이 아니라 스키마로 강제
프롬프트로만 “모르면 모른다”를 넣으면, 모델은 종종 무시합니다. 하지만 스키마에 status를 넣고, 서버가 다음을 정책으로 걸면 강제력이 생깁니다.
citations가 비어 있는데status=ok면 실패 처리confidence가 높은데citations가 약하면 실패 처리
이런 규칙은 제품 요구사항에 맞게 점진적으로 강화할 수 있습니다.
운영 체크리스트: 실패 모드와 대응
1) 스키마는 맞는데 내용이 빈약하다
- 원인: 검색 TopK 부족, chunk가 너무 작거나 너무 큼, 쿼리 확장 실패
- 대응: TopK 확대, 하이브리드 검색 도입, chunk 재설계(헤더 포함), reranker 도입
2) citations가 컨텍스트에 없다(환각 인용)
- 원인: 모델이 “그럴듯한 인용”을 생성
- 대응: 위에서 소개한 quote 포함 여부 검증을 통과 못 하면 재시도
3) 스키마 불일치로 400/파싱 실패
- 원인: enum 오타, required 누락, 타입 미스매치, SDK의 출력 필드 사용 실수
- 대응: 스키마를 최소화하고 점진적으로 확장, 실패 시 원문 로그 저장, 에러 케이스 회귀 테스트
- 참고: OpenAI Responses API 400 에러 - schema·tool 호출 디버깅
4) 지연시간 증가
- 원인: RAG 검색 + 2단 호출(검증) + 재시도
- 대응: 캐시(질문-문서 후보), TopK 제한, 검증 모델을 더 작은 모델로 분리, 스트리밍은 최종 answer만
결론: CoT 대신 “구조화 + 근거 + 검증”으로 품질을 만든다
CoT를 숨기는 것은 단순히 출력에서 생각 과정을 제거하는 문제가 아닙니다. 추론을 강제하면서도 안전하게 운영 가능한 형태로 바꾸는 설계가 필요합니다.
- JSON 스키마로 출력 형태를 고정하면, 모델의 출력을 제품이 다룰 수 있는 데이터로 만들 수 있습니다.
- RAG로 근거를 강제하고,
citations.quote를 서버에서 검증하면 환각을 비용 대비 크게 줄일 수 있습니다. - 필요하면 2단 호출로 검증을 분리해 안정성을 올릴 수 있습니다.
이 조합은 “모델이 얼마나 똑똑한가”보다 “시스템이 얼마나 검증 가능하게 설계됐는가”에 초점을 맞춥니다. 운영 환경에서 LLM을 신뢰 가능한 컴포넌트로 만들고 싶다면, CoT를 공개하는 대신 스키마와 RAG 검증 루프로 품질을 끌어올리는 방향이 장기적으로 훨씬 안전합니다.