Published on

CoT 누출 막기 - JSON만 출력시키는 프롬프트

Authors

서버에서 LLM을 붙이다 보면, 모델이 친절하게 설명을 길게 붙여 주거나(혹은 내부 추론을) 그대로 노출해 버리는 문제가 자주 발생합니다. 특히 API 응답을 그대로 프론트엔드에 전달하는 구조에서는, 한 번의 "형식 이탈"이 곧바로 파서 에러, UI 깨짐, 심하면 프롬프트 인젝션의 발화면 확대까지 이어집니다.

이 글은 CoT(Chain-of-Thought) 누출을 최소화하고, 항상 JSON만 출력하도록 만드는 실전 프롬프트 패턴을 다룹니다. 결론부터 말하면, 프롬프트만으로 100% 보장하기는 어렵고, 스키마 기반 강제 + 런타임 검증 + 재시도 전략을 함께 써야 안정적으로 운영됩니다.

아래 내용은 LLM 공급자(OpenAI/Claude/Gemini 등)와 무관하게 적용 가능한 공통 패턴 위주이며, 스키마 오류나 안전필터로 인해 JSON이 깨지는 사례는 Gemini API 400 Invalid Argument - 안전필터·스키마 오류도 함께 참고하면 좋습니다.

CoT 누출이 왜 문제가 되나

1) 보안 및 정책 이슈

  • 내부 규칙, 시스템 프롬프트의 단서, 필터링 우회 힌트가 노출될 수 있습니다.
  • 사용자가 이를 학습해 다음 요청에서 우회 프롬프트를 강화할 수 있습니다.

2) 제품 품질 이슈

  • "JSON만" 기대하는 클라이언트에서 파싱 실패가 발생합니다.
  • 간헐적으로 텍스트가 섞이면 재현이 어렵고, 장애로 이어집니다.

3) 비용/성능 이슈

  • 불필요한 장문 출력은 토큰 비용과 지연시간을 늘립니다.

핵심 전략: 프롬프트만 믿지 말고, 시스템적으로 고정하라

실전에서는 아래 4가지를 세트로 봅니다.

  1. 출력 계약(Contract) 명시: JSON 외 출력 금지, 추가 키 금지 등
  2. 스키마(형식) 강제: 가능하면 JSON Schema 또는 provider의 structured output 기능 사용
  3. 서버 검증 및 자동 재시도: JSON 파싱 실패 시, "오류만" 전달하고 재생성
  4. 누출 최소화 지시: "추론 과정은 출력하지 말고 최종 결과만"을 명확히

중요한 점은, "CoT를 숨겨라"는 표현만으로는 부족하다는 것입니다. 모델이 실수할 수 있으니, 실패했을 때의 복구 루프가 반드시 필요합니다. 에이전트 루프나 재시도 정책이 폭주하는 문제는 LangChain 에이전트 루프 폭주? 토큰가드로 차단처럼 토큰/횟수 가드로 함께 제어하세요.

JSON만 출력시키는 프롬프트 템플릿

아래는 공급자 중립적인 템플릿입니다. 핵심은 (1) 출력은 JSON 단일 객체 (2) 코드블록 금지 (3) 설명 금지 (4) 스키마 준수 (5) 실패 시에도 JSON으로 에러를 내게 하는 것입니다.

[System]
You are a service that returns ONLY valid JSON.
Do not output any other text, markdown, code fences, or explanations.
Do not include reasoning steps. Output only the final result.

[Developer]
Return a single JSON object that matches this schema exactly:
{
  "ok": boolean,
  "data": object | null,
  "error": {"code": string, "message": string} | null
}
Rules:
- Output must be strictly valid JSON.
- No trailing commas.
- Use double quotes for all keys and string values.
- Do not add extra keys.
- If you cannot comply, return: {"ok": false, "data": null, "error": {"code": "E_SCHEMA", "message": "..."}}

[User]
<task-specific input>

여기서 포인트는 "설명하지 마"가 아니라, 실패 경로도 JSON으로 고정하는 것입니다. 모델이 자신감이 없거나 안전 정책에 걸릴 때도, 텍스트로 변명하지 않고 JSON 에러로 떨어지게 만드는 게 운영 안정성에 중요합니다.

CoT 누출을 더 줄이는 문장 구성 팁

1) "reasoning" 같은 키 자체를 스키마에서 제거

스키마에 reasoning 필드가 있으면 모델은 거기에 내용을 채우려고 합니다. 굳이 필요하지 않다면 제거하세요.

2) "최종 답만"을 반복하되, 금지 항목을 구체화

  • "추론을 출력하지 마"만 쓰면 종종 "추론을 출력하지 않겠습니다" 같은 텍스트가 섞입니다.
  • 그래서 "JSON 외 텍스트 금지"를 더 강하게 둡니다.

3) 예시를 넣을 때도 JSON만

프롬프트에 예시를 넣는 것은 도움이 되지만, 예시가 마크다운 코드블록이면 모델도 코드블록을 따라할 수 있습니다. 예시는 그냥 JSON 텍스트로 넣는 편이 안전합니다.

예시:

Example output:
{"ok": true, "data": {"answer": "..."}, "error": null}

서버 측 검증과 재시도 루프(필수)

프롬프트로 1차 방어를 하더라도, 최종 안정성은 서버에서 결정됩니다.

1) "JSON 파싱 실패"를 즉시 감지

  • 응답이 JSON인지 파싱
  • 스키마 검증
  • 추가 키 존재 여부 확인

2) 실패 시 "수정 프롬프트"로 1회 재시도

재시도 프롬프트는 길게 설명하지 말고, 에러 원인만 전달해 고치게 합니다.

import Ajv from "ajv";

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

const schema = {
  type: "object",
  additionalProperties: false,
  required: ["ok", "data", "error"],
  properties: {
    ok: { type: "boolean" },
    data: { type: ["object", "null"] },
    error: {
      anyOf: [
        { type: "null" },
        {
          type: "object",
          additionalProperties: false,
          required: ["code", "message"],
          properties: {
            code: { type: "string" },
            message: { type: "string" }
          }
        }
      ]
    }
  }
};

const validate = ajv.compile(schema);

export async function callModelWithJsonOnly(client, messages) {
  const res1 = await client.generate(messages);

  const parsed1 = safeJsonParse(res1.text);
  if (parsed1.ok && validate(parsed1.value)) return parsed1.value;

  const errors = parsed1.ok ? validate.errors : [{ message: "invalid json" }];

  const repairMessages = [
    ...messages,
    {
      role: "developer",
      content:
        "Your previous output was invalid. Return ONLY valid JSON that matches the schema. " +
        "Do not include any other text. Validation errors: " +
        JSON.stringify(errors)
    }
  ];

  const res2 = await client.generate(repairMessages);
  const parsed2 = safeJsonParse(res2.text);
  if (parsed2.ok && validate(parsed2.value)) return parsed2.value;

  throw new Error("Model failed to return valid JSON after retry");
}

function safeJsonParse(s) {
  try {
    return { ok: true, value: JSON.parse(s) };
  } catch {
    return { ok: false, value: null };
  }
}

운영 팁:

  • 재시도는 보통 1회면 충분합니다. 2회 이상은 비용만 늘고 루프가 생기기 쉽습니다.
  • 실패 응답은 로깅하되, 개인정보/민감 데이터 마스킹을 적용하세요.

"JSON만"을 깨뜨리는 대표적인 실패 패턴과 대응

1) 코드펜스 출력

모델이 ```json 같은 코드블록을 붙이는 경우가 많습니다.

  • 프롬프트에 "code fences 금지"를 명시
  • 서버에서 앞뒤 코드펜스를 제거하는 전처리를 넣을 수도 있지만, 이는 "조용한 허용"이라 장기적으로 품질이 떨어질 수 있습니다. 가능하면 재시도에서 바로잡는 편이 낫습니다.

2) 서문/후기 텍스트

"물론입니다" 같은 텍스트가 JSON 앞에 붙습니다.

  • 마찬가지로 재시도에서 "non-JSON text detected"만 전달

3) 안전 정책으로 인한 거부 문구

정책 거부 문구가 텍스트로 나가 JSON 계약이 깨질 수 있습니다.

  • "거부 시에도 JSON 에러로"를 강제
  • 공급자별로는 structured output이 거부 상황에서도 스키마를 지키는지 확인이 필요합니다.

4) 스키마 드리프트

모델이 임의로 키를 추가하거나, 타입을 바꿉니다.

  • additionalProperties: false
  • required 명시
  • 문자열 enum으로 코드값을 제한하는 것도 효과적입니다.

실전 예시: "분석"은 내부에서만, API는 JSON만

예를 들어 "요약 API"를 만든다고 합시다. 사용자는 긴 텍스트를 보내고, 서버는 요약 JSON만 반환해야 합니다.

프롬프트(요약)

[System]
Return ONLY valid JSON. No markdown. No extra text.

[Developer]
Schema:
&#123;"ok": boolean, "data": &#123;"summary": string, "keywords": string[] &#125; | null, "error": &#123;"code": string, "message": string&#125; | null&#125;
Rules:
- Do not output reasoning.
- If the input is too long or unclear, return ok=false with an error code.

[User]
Text:
...user text...

기대 출력

&#123;"ok": true, "data": &#123;"summary": "...", "keywords": ["..."]&#125;, "error": null&#125;

여기서도 서버는 반드시 스키마 검증을 수행하고, 실패 시 재시도합니다.

운영 관점 체크리스트

  • 프롬프트에 "JSON only"를 넣는 것만으로 끝내지 않았는가
  • 스키마 검증(Ajv 등)을 서버에서 수행하는가
  • additionalProperties: false로 키 확장을 막았는가
  • 실패 시 JSON 에러 포맷으로 고정했는가
  • 재시도 횟수/토큰 상한을 두었는가
  • 로깅 시 민감정보 마스킹을 했는가

CI에서 프롬프트/스키마 변경이 잦다면, 캐시/재현성 있는 테스트 파이프라인도 중요합니다. 빌드와 테스트 시간을 줄여 반복을 빠르게 만드는 방법은 GitHub Actions 캐시로 Node.js CI 2배 빠르게, 실패 디버깅 같은 접근이 도움이 됩니다.

마무리

CoT 누출을 "완벽히" 막는 단일 프롬프트는 없습니다. 대신 출력 계약을 강하게 정의하고, 스키마로 강제하고, 서버에서 검증 후 재시도하는 3단 방어를 깔면, JSON-only API를 충분히 안정적으로 운영할 수 있습니다.

정리하면:

  • 프롬프트는 규칙을 명확히, 실패 경로도 JSON으로
  • 스키마는 엄격하게, 추가 키 금지
  • 런타임은 파싱/검증/재시도로 품질을 보장

이 조합이 갖춰지면, 모델이 가끔 실수하더라도 서비스는 흔들리지 않습니다.