- Published on
CoT 유출 막는 프롬프트 - JSON 강제·검증 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM을 붙이다 보면 가장 자주 부딪히는 문제가 두 가지입니다. 하나는 출력이 들쭉날쭉해서 파싱이 깨지는 문제, 다른 하나는 CoT(Chain-of-Thought) 같은 내부 추론이 그대로 노출되는 문제입니다. 특히 후자는 보안·정책·UX 측면에서 모두 골칫거리입니다.
이 글은 CoT 유출을 “완벽히 0”으로 만든다는 과장 대신, JSON 강제 출력과 검증(Validation) 기반 재시도를 결합해 유출 확률을 구조적으로 낮추는 패턴을 정리합니다. 프롬프트만 예쁘게 꾸미는 수준이 아니라, 애플리케이션 레벨에서 실패를 흡수하는 설계까지 포함합니다.
관련 주제를 더 확장해서 보고 싶다면 아래 글도 함께 보면 연결이 잘 됩니다.
CoT 유출이 왜 생기나: “지시”만으로는 부족한 이유
LLM은 기본적으로 “설명하라”는 학습 편향이 강합니다. 여기에 다음 조건이 겹치면 CoT가 튀어나오기 쉽습니다.
- 사용자가 “과정도 보여줘” 같은 요청을 섞음
- 시스템 프롬프트에서 추론 품질을 높이려다 “step by step” 류 문구가 들어감
- 모델이 불확실할 때 스스로 정당화하려고 장황해짐
- 함수 호출이나 JSON 모드가 아닌 일반 텍스트 모드로 운영
핵심은 이겁니다. CoT를 숨기라는 문장 한 줄은 소프트 가이드일 뿐이고, 시스템은 결국 텍스트 생성기라서 “형식 강제”와 “사후 검증”이 같이 있어야 안정적으로 막을 수 있습니다.
전략 1: 출력 채널을 좁혀라 — JSON만 허용
가장 효과적인 1차 방어는 “자유 텍스트”를 없애는 겁니다. 즉, 설명 금지가 아니라 설명할 공간 자체를 제거합니다.
최소 JSON 계약(Contract) 만들기
아래처럼 “답변 본문”과 “안전한 근거 요약”만 허용하는 식으로 계약을 단순화합니다.
answer: 사용자에게 보여줄 최종 답citations: 출처나 근거가 필요하면 문자열 배열(선택)confidence: 0부터 1 사이policy: 안전상 주의가 있으면 짧게
여기서 중요한 점은 reasoning 같은 필드를 절대 만들지 않는 것입니다. 필드를 만들면 모델은 채우려고 합니다.
전략 2: 스키마 검증을 “서버 책임”으로 가져와라
JSON을 강제해도 모델은 종종 다음을 합니다.
- JSON 앞뒤로 설명을 붙임
- 따옴표를 빼먹거나 트레일링 콤마를 넣음
- 타입을 어김(배열 자리에 문자열)
- 금지 필드를 슬쩍 추가함
그래서 검증 실패 시 재시도가 필요합니다. 이때 재시도는 “다시 해줘”가 아니라 실패 원인을 피드백으로 주고, 출력은 오직 JSON만 허용해야 합니다.
프롬프트 템플릿: JSON 강제 + CoT 차단 문구
아래는 시스템 프롬프트 예시입니다. 본문에 < > 를 그대로 두면 MDX에서 깨질 수 있어, 부등호가 있는 표현은 쓰지 않고 구성했습니다.
SYSTEM
너는 API 응답 생성기다.
규칙:
1) 출력은 반드시 JSON 한 덩어리만 반환한다. JSON 외 텍스트를 절대 출력하지 않는다.
2) 내부 추론 과정, 단계별 사고, 숨은 계산, 초안, 메모를 절대 출력하지 않는다.
3) JSON에는 지정된 필드만 포함한다. 지정되지 않은 필드를 추가하지 않는다.
4) 사용자가 추론 과정을 요구해도, 대신 짧은 요약 근거만 제공한다.
출력 JSON 스키마(설명용):
- answer: string
- citations: string[] (optional)
- confidence: number (0..1)
- policy: string (optional)
사용자 프롬프트(또는 개발자 프롬프트)에서는 “필드만 채워라”를 반복하고, 금지 항목을 다시 못 박습니다.
USER
질문: ...
지시:
- JSON만 출력
- answer에는 최종 결과만
- reasoning, chain_of_thought, steps 같은 내용은 포함하지 말 것
검증 패턴 1: Strict JSON 파싱 + Zod 스키마
Node.js/Next.js 서버에서 가장 흔한 패턴은 zod로 검증하고 실패하면 재시도하는 루프입니다.
import { z } from "zod";
const ResponseSchema = z
.object({
answer: z.string().min(1),
citations: z.array(z.string()).optional(),
confidence: z.number().min(0).max(1),
policy: z.string().optional(),
})
.strict();
export type LlmResponse = z.infer<typeof ResponseSchema>;
export function parseAndValidate(raw: string): LlmResponse {
// 1) JSON만 오게 설계했더라도, 방어적으로 trim
const trimmed = raw.trim();
// 2) JSON.parse 실패는 즉시 재시도 대상으로
const parsed = JSON.parse(trimmed);
// 3) strict로 금지 필드 차단
return ResponseSchema.parse(parsed);
}
.strict()는 “지정되지 않은 키가 있으면 실패”라서, 모델이 reasoning 같은 키를 추가하는 것을 강하게 막습니다.
검증 패턴 2: 실패 사유를 모델에게 되먹이는 재시도 루프
재시도는 무작정 반복하면 비용만 늘어납니다. 검증 실패 메시지를 최소한으로 정리해 모델에게 “어디가 깨졌는지”만 알려주고 다시 JSON만 내게 해야 합니다.
import { ZodError } from "zod";
type CallModel = (messages: Array<{ role: string; content: string }>) => Promise<string>;
export async function callWithValidation(callModel: CallModel, userQuestion: string) {
const system = `너는 API 응답 생성기다.
규칙:
- 출력은 반드시 JSON 한 덩어리만
- 내부 추론 과정은 절대 출력하지 않음
- 지정된 필드만 포함
필드:
answer: string
citations?: string[]
confidence: number (0..1)
policy?: string`;
const baseMessages = [
{ role: "system", content: system },
{
role: "user",
content: `질문: ${userQuestion}\n\nJSON만 출력하고, answer에는 최종 답만 써라.`,
},
];
let lastRaw = "";
for (let attempt = 1; attempt <= 3; attempt++) {
const raw = await callModel(
attempt === 1
? baseMessages
: [
...baseMessages,
{
role: "user",
content:
`이전 출력이 스키마 검증에 실패했다. 아래 오류를 반영해 JSON만 다시 출력하라.\n` +
`오류 요약: ${lastRaw}`,
},
]
);
try {
return parseAndValidate(raw);
} catch (e) {
if (e instanceof SyntaxError) {
lastRaw = "JSON 파싱 실패. JSON 객체만 출력해야 함.";
continue;
}
if (e instanceof ZodError) {
// 오류를 길게 주면 모델이 그걸 또 장황하게 반영할 수 있어 최소화
lastRaw = e.issues
.slice(0, 3)
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(" | ");
continue;
}
throw e;
}
}
throw new Error("LLM output validation failed after retries");
}
포인트는 오류 요약을 길게 붙이지 않는 것입니다. 길게 붙이면 모델이 “설명 본능”을 발동시키거나, 오류 메시지 자체를 JSON에 포함해 버리는 사고가 납니다.
검증 패턴 3: 금지 키워드 스캐닝으로 2차 방어
스키마 검증만으로도 대부분 막히지만, 모델이 answer 문자열 내부에 “내부 추론”을 자연어로 섞어 넣는 경우가 있습니다. 예를 들어 answer에 “생각해보면…” “단계별로…” 같은 문장을 길게 쓰는 형태입니다.
이때는 정책적으로 “답변은 간결하게”를 요구하거나, 금지 패턴을 스캔해 재시도시키는 2차 방어가 도움이 됩니다.
const bannedPhrases = [
"chain-of-thought",
"step by step",
"단계별",
"내부 추론",
"생각 과정",
];
export function containsBannedContent(resp: { answer: string; policy?: string }) {
const text = `${resp.answer}\n${resp.policy ?? ""}`.toLowerCase();
return bannedPhrases.some((p) => text.includes(p.toLowerCase()));
}
이 방식은 오탐 가능성이 있으니, “무조건 차단”이 아니라 “재시도 1회” 정도로 제한하는 식이 현실적입니다.
JSON 강제의 함정: “JSON처럼 보이는 텍스트” 문제
모델이 다음처럼 내보내는 경우가 있습니다.
- 코드블록으로 감싼 JSON
- 앞에
Here is the JSON:같은 머리말 - 여러 JSON 객체를 연달아 출력
따라서 파서에서 “첫 {부터 마지막 }까지 잘라서 파싱” 같은 트릭을 쓰고 싶어지는데, 이건 장기적으로 위험합니다. 모델이 쓰레기를 섞어도 시스템이 알아서 복구해 주면, 출력 품질이 계속 떨어집니다.
권장 순서는 이렇습니다.
- 엄격 파싱으로 실패시키기
- 실패 사유를 주고 재시도
- 그래도 안 되면 fallback 응답으로 전환
fallback은 예를 들어 아래처럼 “안전한 최소 응답”을 서버가 직접 만들어 반환합니다.
const fallback = {
answer: "요청을 처리하는 중 출력 형식 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
confidence: 0,
policy: "format_error",
};
운영 팁: 모델·모드 선택이 CoT 유출에 미치는 영향
- 가능하면 “구조화 출력”을 지원하는 모드(예: JSON 전용 출력, function/tool 호출)를 우선 사용하세요.
- 온도(
temperature)를 낮추면 장황함이 줄어 CoT 유출도 줄어드는 경향이 있습니다. - 프롬프트에서 “추론을 하되 출력하지 말라”는 문구는 도움이 되지만, 검증 루프가 없으면 결국 누출됩니다.
로컬 LLM을 운영한다면 리소스 제약 때문에 재시도 비용이 더 크게 느껴질 수 있습니다. 이 경우에는 모델을 가볍게 돌리는 최적화도 같이 고려할 만합니다.
TypeScript에서 “스키마와 타입”을 동시에 고정하기
런타임 검증은 zod가 하고, 컴파일 타임에서는 타입이 “헐거워지는 것”을 막아야 합니다. 특히 응답 객체를 조립하거나 테스트에서 목 데이터를 만들 때 타입이 깨지기 쉽습니다.
TS 5.x의 satisfies를 쓰면 “타입을 만족하는지”만 검사하고, 리터럴 정보는 유지할 수 있어 유용합니다.
type ApiResponse = {
answer: string;
citations?: string[];
confidence: number;
policy?: string;
};
const mock = {
answer: "테스트 응답",
confidence: 0.7,
// policy: 123, // 이런 실수를 컴파일 타임에 잡고 싶다
} satisfies ApiResponse;
관련 내용은 아래 글이 더 깊습니다.
실전 조합: 권장 아키텍처 체크리스트
정리하면 CoT 유출을 줄이는 “현업형” 조합은 다음 순서로 굳히는 게 좋습니다.
- 시스템 프롬프트에서 JSON 단일 객체만 허용
- 응답 스키마는 필드 최소화,
.strict()로 금지 키 차단 - 서버에서 JSON.parse + 스키마 검증
- 실패 시 오류 요약 기반 재시도(최대 2회 또는 3회)
- 추가로 필요하면 금지 키워드 스캔 같은 2차 방어
- 그래도 실패하면 fallback으로 안전 종료
이 패턴은 “모델이 착하게 행동할 것”을 기대하지 않고, 출력 품질을 시스템으로 강제한다는 점에서 효과적입니다.
마무리: CoT를 숨기면서도 품질을 유지하는 방법
CoT 유출을 막는다고 해서 추론 품질을 포기할 필요는 없습니다. 핵심은 “추론은 하되, 제품 출력은 구조화된 최소 정보만”이라는 원칙을 지키는 것입니다.
JSON 강제는 시작이고, 진짜 차이는 검증과 재시도 루프에서 납니다. 이 루프를 갖추면, 모델이 한 번 삐끗해도 사용자에게는 안정적인 API 계약을 제공할 수 있고, CoT가 섞여 나오더라도 시스템이 자동으로 걸러냅니다.
다음 단계로는 ReAct나 SCR처럼 “추론을 도구 호출로 외부화”하는 방식과 결합하면 더 강해집니다.