Published on

CoT 유출 막는 프롬프트 - JSON 스키마 강제

Authors

서버에서 LLM을 붙이다 보면, 사용자에게는 결과만 보여주고 싶은데 모델이 중간 추론(Chain-of-Thought, 이하 CoT)을 길게 노출하는 문제가 자주 발생합니다. 특히 고객 응대, 보안 정책, 내부 로직이 섞인 에이전트에서 CoT가 그대로 출력되면 정보 유출로 이어질 수 있습니다.

이 글은 CoT를 "완전히 없애는" 마법이 아니라, 출력 표면을 강하게 통제해서 유출 가능성을 낮추는 실무 패턴을 다룹니다. 핵심은 두 가지입니다.

  1. 출력은 오직 JSON 으로 제한
  2. JSON 스키마(또는 동등한 구조 검증)를 통과하지 못하면 재시도

이 방식은 프롬프트만으로 해결하려는 접근보다 안정적이고, 프런트/백엔드 경계에서 계약을 만들 수 있다는 점에서 운영 친화적입니다.

CoT 유출이 생기는 전형적인 경로

CoT 유출은 보통 아래 상황에서 발생합니다.

  • 사용자 프롬프트가 "과정을 설명해줘" 같은 요청을 포함
  • 시스템 프롬프트에 "단계별로 생각해" 류의 지시가 남아 있음
  • 모델이 답을 만들기 위해 내부적으로 생성한 추론 텍스트가 그대로 출력 채널에 섞임
  • 도구 호출(tool call) 실패 후, 모델이 디버깅 텍스트를 장황하게 출력

특히 마지막 케이스는 흔합니다. API 호출이 실패하면 모델이 "내가 무엇을 하려 했고 왜 안 됐는지"를 친절히 설명하면서 내부 힌트(엔드포인트, 키, 정책, 프롬프트 일부)를 같이 내보내기도 합니다.

그래서 결과 포맷을 강제하고, 그 외 텍스트는 폐기(또는 재시도)하는 게 가장 현실적인 방어선입니다.

전략: "설명 금지"가 아니라 "형식 강제"

"CoT를 출력하지 마" 같은 문장은 도움이 되지만, 모델이 언제나 100% 지키는 계약은 아닙니다. 반면 JSON 스키마 강제는 다음 장점이 있습니다.

  • 출력이 구조화되면, UI/후처리에서 "허용된 필드만" 사용 가능
  • 스키마 검증 실패 시, 응답 전체를 폐기하고 재시도 가능
  • 로그/관측에서도 민감 텍스트가 저장되는 경로를 줄일 수 있음

요점은 모델을 믿지 말고 파이프라인을 믿는 것입니다.

프롬프트 템플릿: JSON만, 추가 텍스트 금지

아래는 가장 기본적인 시스템 프롬프트 예시입니다. 중요한 건 JSON 외 텍스트를 출력하면 실패라는 규칙과, "추론을 쓰지 말라"가 아니라 "필드에 담지 말라"를 명확히 하는 것입니다.

[System]
You are a JSON API.
Return ONLY valid JSON.
Do not wrap in markdown.
Do not include any extra keys.
Do not include reasoning, chain-of-thought, analysis, or hidden steps.
If you are unsure, set required fields conservatively and use the error field.

You must follow this JSON Schema exactly.

여기에 스키마를 그대로 붙이거나, 모델 API가 스키마를 네이티브로 지원하면 그 기능을 사용합니다.

JSON 스키마 설계: "결과"와 "근거"를 분리

스키마는 가능한 단순하게 시작하는 게 좋습니다. CoT 유출을 막으려면 특히 아래 원칙이 유효합니다.

  • reasoning 같은 필드를 만들지 않는다
  • 꼭 필요하면 evidence짧은 인용/근거 목록 정도로 제한한다
  • 에러 상황을 위해 error 필드를 명시해 모델이 장황한 설명을 밖으로 흘리지 않게 한다

예시 스키마:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["status", "answer"],
  "properties": {
    "status": {
      "type": "string",
      "enum": ["ok", "error"]
    },
    "answer": {
      "type": "string",
      "description": "User-facing final answer only. No chain-of-thought."
    },
    "evidence": {
      "type": "array",
      "items": { "type": "string" },
      "maxItems": 3
    },
    "error": {
      "type": "object",
      "additionalProperties": false,
      "required": ["code"],
      "properties": {
        "code": { "type": "string" },
        "message": { "type": "string" }
      }
    }
  }
}

additionalProperties: false는 매우 중요합니다. 모델이 임의로 analysis 필드를 추가하는 걸 구조적으로 차단합니다.

런타임 검증: AJV로 스키마 통과만 허용

프롬프트만으로는 부족합니다. 서버에서 실제로 JSON 파싱과 스키마 검증을 하고, 실패하면 재시도하거나 안전한 에러로 대체해야 합니다.

Node.js 예시(AJV):

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true, strict: true });

const schema = {
  type: "object",
  additionalProperties: false,
  required: ["status", "answer"],
  properties: {
    status: { type: "string", enum: ["ok", "error"] },
    answer: { type: "string" },
    evidence: { type: "array", items: { type: "string" }, maxItems: 3 },
    error: {
      type: "object",
      additionalProperties: false,
      required: ["code"],
      properties: {
        code: { type: "string" },
        message: { type: "string" }
      }
    }
  }
} as const;

const validate = ajv.compile(schema);

export function parseAndValidate(raw: string) {
  let data: unknown;
  try {
    data = JSON.parse(raw);
  } catch {
    return { ok: false as const, error: "invalid_json" };
  }

  const valid = validate(data);
  if (!valid) {
    return {
      ok: false as const,
      error: "schema_validation_failed",
      details: validate.errors
    };
  }

  return { ok: true as const, data };
}

이제 애플리케이션은 answer만 UI에 노출하고, 나머지는 내부 로깅/관측에서 최소화할 수 있습니다.

TypeScript에서 스키마와 타입의 드리프트를 줄이고 싶다면 satisfies를 함께 쓰는 패턴이 유용합니다. 객체가 스키마/타입 요구를 만족하는지 컴파일 타임에 체크할 수 있습니다. 관련해서는 TS 5.x satisfies로 타입 좁히기 실무 패턴도 함께 참고하면 좋습니다.

재시도 전략: "스키마 실패하면 고쳐서 다시"

실무에서는 모델이 다음을 자주 합니다.

  • JSON 앞뒤로 설명 텍스트를 붙임
  • 따옴표를 빠뜨리거나 trailing comma를 넣음
  • 스키마에 없는 키를 추가

따라서 1회 호출로 끝내기보다, 검증 실패 시 재시도하는 루프가 안정적입니다.

의사 코드:

type LlmCall = (input: { messages: Array<{ role: string; content: string }> }) => Promise<string>;

export async function callWithSchemaGuard(llmCall: LlmCall, messages: any[]) {
  const maxAttempts = 3;

  let lastRaw = "";

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const raw = await llmCall({ messages });
    lastRaw = raw;

    const parsed = parseAndValidate(raw);
    if (parsed.ok) return parsed.data;

    messages = [
      ...messages,
      {
        role: "system",
        content:
          "Your previous response was invalid. Return ONLY valid JSON matching the schema. " +
          "Do not include any extra text. Fix formatting and remove extra keys."
      },
      { role: "assistant", content: raw }
    ];
  }

  return {
    status: "error",
    answer: "요청을 처리하는 중 형식 오류가 발생했습니다.",
    error: { code: "SCHEMA_GUARD_FAILED", message: "Model output did not match schema" },
    evidence: []
  };
}

재시도 메시지에 이전 응답을 붙이는 이유는 모델이 "무엇을 고쳐야 하는지"를 명확히 알게 하기 위해서입니다. 다만 이때도 이전 응답에 민감 정보가 섞일 수 있으니 저장/로그 정책을 함께 점검해야 합니다.

Next.js(App Router)에서의 적용 포인트

Next.js App Router에서 LLM 호출을 API Route나 Server Action에 붙일 때는 다음을 주의하세요.

  • 모델 원문 응답을 그대로 console.log로 남기지 않기
  • 스트리밍 응답을 그대로 프런트에 흘리지 말고, 서버에서 스키마 검증 후 전달하기
  • 캐시/리밸리데이션과 결합 시, 잘못된 응답이 캐시되지 않게 하기

RSC 및 fetch 캐시 설정이 얽히면 의도치 않은 결과가 반복 노출될 수 있습니다. 성능/캐시 관점은 Next.js App Router TTFB 느림 - RSC 캐시·fetch 설정도 같이 보면 좋습니다.

"JSON만" 강제가 깨지는 대표 케이스와 대응

1) 마크다운 코드블록으로 감싸는 경우

모델이 ```json 블록을 붙이면 JSON 파싱이 실패합니다.

  • 1차 방어: 시스템 프롬프트에 "Do not wrap in markdown" 명시
  • 2차 방어: 파서 단계에서 앞뒤 코드펜스를 제거하는 전처리(가능하면 최소화)

전처리 예시:

export function stripCodeFences(s: string) &#123;
  return s
    .replace(/^\s*```(?:json)?\s*/i, "")
    .replace(/\s*```\s*$/i, "")
    .trim();
&#125;

다만 전처리는 "관대한 파서"가 되어 공격면을 키울 수 있으니, 가능한 한 재시도로 해결하고 전처리는 최후 수단으로 두는 편이 안전합니다.

2) 스키마 외 필드로 추론을 밀어 넣는 경우

additionalProperties: false로 대부분 막을 수 있습니다. 그래도 answer에 장황한 추론을 섞어 넣는 건 막기 어렵습니다.

이때는 정책을 추가합니다.

  • answer 길이 제한(예: 800자)
  • 금칙어/패턴(예: Step 1, Thought:) 탐지 시 재시도
  • 사용자에게는 요약만 보여주고, 근거는 evidence 최대 3개로 제한

3) 도구 호출 실패 후 디버깅 텍스트 출력

도구 실패는 운영에서 늘 일어납니다. 이때 모델이 디버깅을 길게 쓰지 않도록,

  • 스키마에 error.code를 두고, 실패 시 그 코드만 채우게 유도
  • 서버는 status: error면 고정된 사용자 메시지를 반환

즉, 실패의 "설명"을 모델에게 맡기지 않는 것이 포인트입니다.

보안 관점 체크리스트

  • 모델 출력 원문을 저장해야 한다면, 보존 기간과 접근 권한을 최소화
  • 사용자 입력에 시스템 프롬프트를 노출하지 않도록 프롬프트 템플릿을 분리
  • 관측(로그/트레이싱)에서 raw 응답을 기본 비활성화
  • 스키마 검증 실패율을 메트릭으로 수집해 프롬프트/모델 변경 시 회귀 탐지

CI에서 스키마/타입 변경이 잦다면 캐시/빌드 파이프라인도 같이 흔들립니다. 캐시 미스가 잦아지면 배포 속도가 느려지고, 핫픽스가 늘어납니다. 이 주제는 GitHub Actions 캐시가 안 먹을 때 - key·restore-keys·권한처럼 파이프라인 관점 점검도 병행하는 게 좋습니다.

결론: CoT는 "요청"이 아니라 "계약"으로 막는다

CoT 유출을 줄이는 가장 실용적인 방법은, "추론을 쓰지 마"라고 부탁하는 게 아니라 출력 계약을 JSON 스키마로 고정하고, 서버에서 파싱·검증·재시도로 지키는 것입니다.

  • 프롬프트: JSON만, 추가 텍스트 금지
  • 스키마: additionalProperties: false, 불필요한 필드 제거
  • 런타임: AJV 등으로 검증, 실패 시 재시도
  • 운영: 원문 로그 최소화, 실패 메시지 고정

이 조합이면 모델이 가끔 규칙을 어기더라도, 사용자에게 CoT가 노출되는 경로를 상당히 줄일 수 있습니다.