Published on

CoT 대신 Structured Output로 환각 줄이기

Authors

서론

LLM 환각을 줄이기 위해 가장 먼저 떠올리는 처방은 CoT(Chain-of-Thought)를 길게 요구하는 것입니다. 하지만 제품 환경에서 CoT는 두 가지 문제를 만듭니다.

  • 출력이 길어져 비용과 지연이 증가합니다.
  • “그럴듯한 추론 서사”가 붙으면서, 검증 불가능한 주장도 더 설득력 있게 보입니다.

반대로 Structured Output(스키마 기반 출력)은 모델이 “무엇을” 말해야 하는지와 “어떤 형태로” 말해야 하는지를 강제합니다. 이 접근은 환각을 완전히 제거하진 못해도, 최소한 다음을 가능하게 합니다.

  • 파싱 실패를 즉시 감지(형식 오류)
  • 필드 단위 검증(값 범위, enum, 정규식)
  • 근거 요구를 구조화(출처 URL, 문서 ID, 스니펫)
  • 후처리에서 자동 리트라이/폴백

이 글은 CoT 대신 Structured Output 중심으로 환각을 줄이는 설계 패턴과 코드 예제를 정리합니다.

CoT가 환각을 줄인다는 오해

CoT가 도움이 되는 경우는 분명 있습니다. 복잡한 다단계 문제에서 중간 단계를 “모델이 스스로 점검”하도록 유도할 수 있기 때문입니다. 다만 제품 관점에서는 다음 이유로 CoT가 환각을 줄인다고 단정하기 어렵습니다.

  • CoT는 검증 메커니즘이 아니라 “서술 방식”입니다.
  • 모델이 틀린 전제를 잡으면, 그 전제를 기반으로 논리적으로 일관된 글을 길게 생산할 수 있습니다.
  • 사용자가 CoT를 읽고 “논리적이니 맞다”고 착각하기 쉽습니다.

즉, CoT는 관찰 가능성을 높이지 못하면(검증/근거/제약이 없으면) 오히려 환각을 더 잘 숨길 수 있습니다.

Structured Output이 환각을 줄이는 핵심 원리

Structured Output은 모델에게 “자유 서술” 대신 “계약(Contract)”을 제공합니다. 계약의 형태는 보통 JSON Schema 또는 타입 정의로 표현됩니다.

핵심은 다음 3가지입니다.

  1. 제약(Constraints)
  • 필수 필드, enum, min/max, 패턴 등으로 모델의 발화를 좁힙니다.
  1. 검증(Validation)
  • 파서와 스키마 검증기로 “틀린 출력”을 자동으로 걸러냅니다.
  1. 복구(Recovery)
  • 실패 시 재질문(retry), 부분 재생성, 규칙 기반 폴백을 설계할 수 있습니다.

여기서 중요한 포인트는 “환각을 안 하게 만들기”가 아니라 “환각을 시스템이 감지하고 처리하게 만들기”입니다.

어떤 상황에서 Structured Output이 특히 강력한가

1) 추출(Extraction) 작업

예: 이메일에서 주문번호, 고객명, 배송지 추출

자유 텍스트로 뽑으면 필드 누락/오타/형식 오류가 잦습니다. 스키마 기반으로 강제하면 누락이 즉시 드러납니다.

2) 라우팅(Routing) / 분류(Classification)

예: 문의 유형을 billing, bug, feature_request 중 하나로 분류

enum으로 제한하면 “새로운 라벨을 발명”하는 환각을 막습니다.

3) 툴 호출(Tool Use)

예: 검색 API, DB 조회, 사내 서비스 호출

파라미터 스키마를 고정하면, 모델이 없는 필드나 잘못된 타입으로 호출하는 오류를 줄입니다.

툴 호출에서 스키마가 조금만 어긋나도 400이 터지기 쉬운데, 이런 경우는 스키마 교정이 효과가 큽니다. 관련해서는 Claude Tool Use 400 오류 - JSON Schema 교정도 함께 참고하면 좋습니다.

설계 패턴 1: “답변”과 “근거”를 분리된 필드로 강제

환각을 줄이는 가장 실용적인 방법은 “근거 없는 답을 못 하게” 만드는 것입니다. 텍스트로 “출처를 달아라”라고 말하는 것보다, 스키마에 citations를 필수로 두는 편이 훨씬 강합니다.

아래 스키마는 답변과 근거를 분리하고, 근거가 없으면 insufficient_evidence로 처리하게 합니다.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "required": ["verdict", "answer", "citations"],
  "properties": {
    "verdict": {
      "type": "string",
      "enum": ["supported", "insufficient_evidence", "contradicted"]
    },
    "answer": { "type": "string", "minLength": 1 },
    "citations": {
      "type": "array",
      "minItems": 0,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["source", "quote"],
        "properties": {
          "source": { "type": "string", "minLength": 1 },
          "quote": { "type": "string", "minLength": 1 }
        }
      }
    }
  }
}

운영 팁:

  • citations를 필수로 두되, 근거가 없으면 verdictinsufficient_evidence로 보내도록 유도합니다.
  • 검색(RAG)을 붙였다면 source에는 문서 ID나 URL, quote에는 실제 스니펫을 넣게 하세요.
  • 후단에서 supported인데 citations가 비어 있으면 강제 리트라이하도록 만들면 환각이 눈에 띄게 줄어듭니다.

설계 패턴 2: “모르는 경우”를 정상 플로우로 만든다

환각은 종종 “모르는 걸 모른다”고 말하지 못해서 생깁니다. 그래서 스키마에 아예 needs_clarification 같은 상태를 넣고, 그때는 질문을 반환하도록 만듭니다.

{
  "type": "object",
  "additionalProperties": false,
  "required": ["status"],
  "properties": {
    "status": {
      "type": "string",
      "enum": ["ok", "needs_clarification", "cannot_answer"]
    },
    "answer": { "type": "string" },
    "clarifying_questions": {
      "type": "array",
      "items": { "type": "string" }
    }
  }
}

이렇게 하면 “애매한 요구사항인데도 일단 그럴듯하게 답을 생성”하는 패턴을 약화시킬 수 있습니다.

설계 패턴 3: 라우팅을 스키마로 고정하고, 본문 생성은 후단에서

많은 팀이 한 번의 호출로 “분류 + 해결책 + 실행 단계”까지 다 뽑으려다 환각을 키웁니다. 대신 다음처럼 2단계로 쪼개는 게 안정적입니다.

  1. 1차 호출: 라우팅만 구조화 출력
  2. 2차 호출: 라우팅 결과에 맞는 전용 프롬프트로 답 생성

라우팅 스키마 예시:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["route", "confidence"],
  "properties": {
    "route": {
      "type": "string",
      "enum": ["db", "auth", "ci", "backend", "frontend", "unknown"]
    },
    "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
    "signals": {
      "type": "array",
      "items": { "type": "string" }
    }
  }
}

이후 routeauth면 인증 전용 플레이북으로, ci면 CI 전용 플레이북으로 보내는 식입니다. 예를 들어 JWT 검증 문제라면 Keycloak OIDC JWT 검증 실패 - kid 불일치 해결 같은 내부 문서를 근거로 붙이는 RAG 구성이 잘 맞습니다.

코드 예제: Node.js에서 Zod로 구조화 출력 검증하기

아래는 “모델 출력은 무조건 JSON”이라는 가정 하에, 파싱과 스키마 검증을 통과한 결과만 다음 단계로 넘기는 예시입니다.

주의: MDX에서 부등호가 본문에 노출되면 빌드 에러가 날 수 있으니, 코드 블록 안에서만 사용합니다.

import { z } from "zod";

const OutputSchema = z.object({
  verdict: z.enum(["supported", "insufficient_evidence", "contradicted"]),
  answer: z.string().min(1),
  citations: z.array(
    z.object({
      source: z.string().min(1),
      quote: z.string().min(1),
    })
  ),
});

type Output = z.infer<typeof OutputSchema>;

export function parseAndValidate(raw: string): Output {
  let json: unknown;
  try {
    json = JSON.parse(raw);
  } catch {
    throw new Error("Model output is not valid JSON");
  }

  const parsed = OutputSchema.safeParse(json);
  if (!parsed.success) {
    throw new Error("Model output does not match schema: " + parsed.error.message);
  }

  // 추가 정책 검증: supported면 citation 최소 1개
  if (parsed.data.verdict === "supported" && parsed.data.citations.length === 0) {
    throw new Error("Policy violation: supported verdict requires citations");
  }

  return parsed.data;
}

이 방식의 장점:

  • 모델이 실수로 텍스트를 섞거나 필드를 빼먹으면 즉시 실패합니다.
  • 실패를 “재시도 트리거”로 바꿀 수 있습니다.
  • 환각이 “조용히” 섞이는 대신 “에러로 크게” 드러납니다.

코드 예제: 재시도 전략(스키마 불일치 시 프롬프트 교정)

스키마 불일치가 발생했을 때 같은 프롬프트로 무한 재시도하면 비용만 늘어납니다. 재시도 프롬프트는 실패 이유를 구체적으로 알려주고, 출력 형식을 더 강하게 제약해야 합니다.

function buildRetryPrompt(originalPrompt: string, errorMessage: string) {
  return [
    originalPrompt,
    "\n\nYou MUST output only valid JSON.",
    "Do not include markdown fences.",
    "Do not include any extra keys.",
    "Fix the following schema/validation error:",
    errorMessage,
  ].join("\n");
}

운영에서는 다음을 추천합니다.

  • 1회는 정상 프롬프트
  • 2회부터는 “오류 메시지 포함 + JSON only + additionalProperties false”를 강조
  • 2~3회 실패하면 폴백(검색 결과만 반환, 사람에게 넘김 등)

Structured Output만으로 부족한 지점과 보완책

Structured Output은 “형식”과 “검증 가능성”을 올려주지만, 사실성 자체를 자동으로 보장하진 않습니다. 그래서 보통 다음 조합이 좋습니다.

  • RAG: 근거 문서 스니펫을 주고 citations.quote에 복사하게 하기
  • 정책 검증: supported일 때 citation 강제, 숫자/날짜 범위 검증
  • 다중 샘플링: 동일 질문을 여러 번 뽑아 합의(Self-Consistency)

CoT를 직접 노출하지 않고도 추론 품질을 높이는 기법은 따로 정리해둔 글이 있습니다. 필요하면 CoT 없이 추론 품질 올리는 SC·ToT 실전도 함께 보세요.

실전 체크리스트

스키마 설계

  • additionalPropertiesfalse로 둘 것(키 발명 방지)
  • enum으로 라벨을 고정할 것(분류 환각 방지)
  • “모름” 상태를 정상 플로우로 포함할 것
  • 근거 필드를 1급 시민으로 둘 것(citations, evidence_ids)

파이프라인

  • 파싱 실패와 스키마 실패를 분리해 로깅할 것
  • 실패 시 재시도 프롬프트를 다르게 할 것
  • 정책 위반(예: citation 없는 supported)을 별도 에러로 취급할 것
  • 장기적으로는 실패 케이스를 모아 스키마/프롬프트를 개선할 것

운영 관점

  • 비용: CoT 장문 출력보다 구조화 출력이 토큰을 절약하는 경우가 많음
  • 품질: “틀린 답이 조용히 통과”하는 비율이 줄어듦
  • 디버깅: 필드 단위로 어디서 문제가 났는지 추적이 쉬움

결론

CoT는 모델이 생각하는 과정을 길게 드러내는 방식이고, Structured Output은 모델의 출력을 “검증 가능한 계약”으로 바꾸는 방식입니다. 환각을 줄이려면 모델에게 더 많은 말을 시키기보다, 시스템이 실패를 감지하고 복구할 수 있게 만들어야 합니다.

정리하면 다음 한 줄이 실전에서 가장 유효합니다.

  • CoT로 설득력 있는 텍스트를 늘리기보다, Structured Output으로 검증 가능한 출력을 강제하라

이 원칙 위에 RAG, 정책 검증, 재시도/폴백을 얹으면 “그럴듯하지만 틀린 답”이 제품에 섞이는 빈도를 의미 있게 낮출 수 있습니다.