Published on

Chain-of-Thought 없이 성능↑ 구조화 프롬프트

Authors

서로 다른 팀원이 같은 프롬프트를 써도 결과가 들쭉날쭉하고, 운영 환경에서 모델이 갑자기 장황해지거나(혹은 반대로 너무 짧아지거나) 중요한 제약을 놓치는 경험은 흔합니다. 많은 사람이 이를 해결하려고 Chain-of-Thought를 직접 요구하지만, 제품 관점에서는 꼭 좋은 선택이 아닙니다. 내부 추론을 길게 노출시키면 토큰 비용이 늘고, 응답이 장황해지며, 정책상 또는 보안상 민감한 추론을 드러낼 위험도 커집니다.

대신 “구조화 프롬프트(Structured Prompting)”를 쓰면, 모델이 추론을 길게 쓰지 않더라도 결과를 안정적으로 만들 수 있습니다. 핵심은 모델에게 생각 과정을 쓰라고 요구하는 것이 아니라, 출력의 형태, 검증 규칙, 실패 시 행동을 명시해 “결과물”을 통제하는 것입니다.

이 글은 다음을 목표로 합니다.

  • Chain-of-Thought를 노출하지 않고도 품질을 올리는 프롬프트 구조
  • JSON 스키마 기반 출력 강제와 검증 루프
  • 운영에서 자주 터지는 실패 모드(환각, 누락, 포맷 붕괴) 대응
  • 코드 예제 포함: Node.js로 스키마 검증 및 재시도

관련해서 런타임에서 모델 호출이 멈추거나 지연되는 문제까지 함께 다룰 때는 Assistants API v2 run이 queued나 in_progress에 멈출 때 실전 디버깅 체크리스트도 같이 읽어두면 운영 안정성에 도움이 됩니다.

왜 Chain-of-Thought를 직접 요구하지 않는가

Chain-of-Thought를 “보여 달라”고 요구하면 디버깅에는 편하지만, 제품에는 다음 비용이 따릅니다.

  • 비용 증가: 추론을 길게 쓰면 출력 토큰이 늘고 지연이 증가합니다.
  • 일관성 저하: 장황한 설명이 오히려 핵심 요구사항을 흐리기도 합니다.
  • 정책·보안 리스크: 내부 reasoning이 의도치 않게 민감한 정보를 포함할 수 있습니다.
  • 평가 난이도: 답변이 길수록 채점 기준이 흔들립니다(정답은 맞는데 설명이 이상한 경우 등).

구조화 프롬프트는 “추론”이 아니라 “산출물”을 설계 대상으로 삼습니다. 즉, 모델이 머릿속으로는 충분히 추론하되, 밖으로는 짧고 검증 가능한 형태로 내보내게 합니다.

구조화 프롬프트의 핵심 구성 요소 5가지

구조화 프롬프트는 보통 아래 5개 블록으로 설계하면 안정적으로 동작합니다.

1) 역할과 범위(Role, Scope)

  • 모델이 무엇을 하는지
  • 무엇을 하지 않는지
  • 대상 독자(개발자, 고객, 운영자 등)

2) 입력 계약(Input Contract)

  • 입력으로 제공되는 데이터의 필드 의미
  • 누락 가능 필드와 기본값
  • 신뢰할 수 없는 입력(사용자 입력) 취급 규칙

3) 출력 계약(Output Contract)

  • 출력 포맷(대부분 JSON)
  • 필수 필드, 타입, enum
  • 길이 제한, 금칙어, 언어

4) 품질 기준(Quality Rubric)

  • 정답성, 완전성, 간결성
  • 근거 요구 수준(링크, 인용, 로그 등)
  • 불확실성 처리(모르면 모른다고 말하기)

5) 실패 시 전략(Fallback)

  • 정보가 부족하면 무엇을 질문할지
  • 포맷에 실패하면 어떻게 재출력할지
  • 위험하면 중단하고 안전한 대안을 제시할지

이 5가지를 “명시적으로” 주면, Chain-of-Thought를 요구하지 않아도 결과가 훨씬 안정됩니다.

실전 템플릿: JSON 스키마 기반 구조화 프롬프트

아래는 운영에서 가장 재현성이 좋은 패턴입니다. 출력은 반드시 JSON 하나로 제한하고, 모델이 설명을 덧붙이지 못하게 합니다.

주의: MDX 환경에서는 부등호 문자가 일반 텍스트로 노출되면 빌드 에러가 날 수 있으니, 코드 블록 안에만 두거나 인라인 코드는 백틱으로 감싸야 합니다.

[System]
너는 소프트웨어 엔지니어링 어시스턴트다.
목표: 사용자의 요청을 해결 가능한 작업 단위로 구조화해 JSON으로만 출력한다.
금지: 출력에 설명 문장, 마크다운, 코드펜스, 주석을 포함하지 마라.

[Developer]
출력은 아래 JSON 스키마를 반드시 만족해야 한다.
- language: "ko"
- fields:
  - intent: string (요청의 핵심)
  - assumptions: string[] (필요한 가정)
  - missing_info_questions: string[] (부족한 정보 질문)
  - plan: { step: number, action: string, expected_output: string }[]
  - risks: { risk: string, mitigation: string }[]
  - final_answer: string (사용자에게 줄 최종 답변)

품질 기준:
- final_answer는 10문장 이내
- plan은 3~7단계
- 모르면 모른다고 말하고 missing_info_questions에 질문을 추가

[User]
요청: ...
컨텍스트: ...
제약: ...

이 템플릿의 포인트는 다음입니다.

  • 모델이 무조건 구조화된 산출물을 내게 강제
  • “추론을 보여 달라”가 아니라 “검증 가능한 필드로 정리하라”로 유도
  • missing_info_questions로 불확실성을 밖으로 드러내되, 장황한 reasoning은 숨김

체크리스트 프롬프트: 누락을 줄이는 가장 싼 방법

구조화의 또 다른 축은 “체크리스트”입니다. 모델은 종종 중요한 제약을 한두 개 놓치는데, 체크리스트는 이를 크게 줄입니다.

예를 들어 운영 디버깅 글을 쓰게 한다면, 아래처럼 “반드시 포함할 섹션”을 명시합니다.

너는 테크니컬 블로거다.
아래 체크리스트를 모두 만족하는 글만 작성하라.

체크리스트:
1) 증상 재현 조건
2) 원인 후보 3가지 이상
3) 확인 명령어 또는 로그 포인트 5개 이상
4) 안전한 롤백/완화책
5) 재발 방지(모니터링, 알람, 테스트)

출력 형식:
- 섹션 제목은 고정: "재현", "원인", "진단", "해결", "재발 방지"
- 각 섹션은 3문단 이내

이 방식은 Chain-of-Thought 없이도 “누락”을 줄이는 데 특히 효과적입니다. 실제로 장애 분석류 콘텐츠는 체크리스트만 잘 줘도 품질이 급상승합니다. 예를 들어 SSH 끊김/지연처럼 로그 기반으로 접근하는 글 구조는 리눅스 SSH 접속 지연·끊김, auth.log로 추적하기 같은 형태로 고정하기 좋습니다.

실패 모드 3가지와 대응 프롬프트

구조화 프롬프트에서도 실패는 납니다. 다만 “어떻게 실패할지”를 예상하고 프롬프트와 코드로 방어하면, 운영 품질이 크게 올라갑니다.

1) 포맷 붕괴(JSON 깨짐)

증상

  • 따옴표 누락
  • JSON 앞뒤로 설명 문장 추가
  • 배열/필드 누락

대응

  • “JSON만 출력”을 System 또는 Developer에 두기
  • 스키마 검증 후 실패 시 재시도
  • 재시도 프롬프트에는 “이전 출력의 오류”를 짧게 전달

2) 요구사항 누락(제약 미준수)

증상

  • 길이 제한 무시
  • 특정 필드 비어 있음
  • 금칙어 포함

대응

  • 체크리스트와 스키마에 제약을 이중으로 넣기
  • plan에 “제약 검증 단계”를 포함시키기

3) 환각(없는 사실 생성)

증상

  • 근거 없는 수치/레퍼런스
  • 존재하지 않는 API/옵션

대응

  • “확실하지 않으면 추정하지 말고 질문” 규칙
  • 근거 필드 추가: evidence 또는 source 배열
  • 운영에서는 “출처 없는 단정 문장 금지” 룰이 유효

코드 예제: Node.js에서 JSON 스키마 검증하고 자동 재시도

아래 예시는 모델 출력이 스키마를 만족하지 않으면, 에러 메시지를 첨부해 한 번 더 재요청하는 패턴입니다. 실제로 이 루프 하나만 넣어도 체감 품질이 크게 좋아집니다.

import Ajv from "ajv";

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

const schema = {
  type: "object",
  additionalProperties: false,
  required: [
    "intent",
    "assumptions",
    "missing_info_questions",
    "plan",
    "risks",
    "final_answer"
  ],
  properties: {
    intent: { type: "string", minLength: 1 },
    assumptions: { type: "array", items: { type: "string" } },
    missing_info_questions: { type: "array", items: { type: "string" } },
    plan: {
      type: "array",
      minItems: 3,
      maxItems: 7,
      items: {
        type: "object",
        additionalProperties: false,
        required: ["step", "action", "expected_output"],
        properties: {
          step: { type: "integer", minimum: 1 },
          action: { type: "string", minLength: 1 },
          expected_output: { type: "string", minLength: 1 }
        }
      }
    },
    risks: {
      type: "array",
      items: {
        type: "object",
        additionalProperties: false,
        required: ["risk", "mitigation"],
        properties: {
          risk: { type: "string", minLength: 1 },
          mitigation: { type: "string", minLength: 1 }
        }
      }
    },
    final_answer: { type: "string", minLength: 1, maxLength: 1200 }
  }
};

const validate = ajv.compile(schema);

async function callModel(messages) {
  // 여기에 OpenAI/타사 SDK 호출을 연결하세요.
  // 반환은 반드시 "문자열"이어야 합니다.
  return "{}";
}

function safeJsonParse(text) {
  try {
    return { ok: true, value: JSON.parse(text) };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
}

export async function structuredPromptRun(userText) {
  const baseMessages = [
    {
      role: "system",
      content:
        "너는 소프트웨어 엔지니어링 어시스턴트다. JSON만 출력하라."
    },
    {
      role: "developer",
      content:
        "출력은 지정된 스키마를 만족해야 한다. 설명 문장 금지."
    },
    { role: "user", content: userText }
  ];

  let lastText = await callModel(baseMessages);

  for (let attempt = 1; attempt <= 2; attempt++) {
    const parsed = safeJsonParse(lastText);
    if (!parsed.ok) {
      lastText = await callModel([
        ...baseMessages,
        {
          role: "user",
          content:
            "이전 출력은 JSON 파싱에 실패했다. JSON만 다시 출력하라. 오류: " +
            parsed.error
        }
      ]);
      continue;
    }

    const ok = validate(parsed.value);
    if (ok) return parsed.value;

    const errors = (validate.errors || [])
      .map((e) => `${e.instancePath} ${e.message}`)
      .join("; ");

    lastText = await callModel([
      ...baseMessages,
      {
        role: "user",
        content:
          "이전 출력은 스키마 검증에 실패했다. JSON만 다시 출력하라. 검증 오류: " +
          errors
      }
    ]);
  }

  throw new Error("모델 출력이 스키마를 만족하지 못했습니다.");
}

이 패턴은 “Chain-of-Thought 공개” 없이도 다음을 달성합니다.

  • 출력 포맷 안정화
  • 필수 필드 누락 방지
  • 운영에서 재현 가능한 품질

구조화 프롬프트를 더 강하게 만드는 운영 팁

로그를 남길 포인트를 구조에 포함하라

운영에서 중요한 건 “왜 이런 답이 나왔는지”를 추적하는 것입니다. CoT를 노출하지 않더라도, 아래 정도는 남기면 충분합니다.

  • model, temperature, top_p
  • 입력 길이, 출력 길이
  • 스키마 검증 실패 횟수
  • 재시도 시 사용한 에러 요약

장애 대응 글을 쓰듯이, LLM 호출도 체크리스트화하면 디버깅 시간이 줄어듭니다. 인프라 관점의 실패 모드(재시도 폭증, 타임아웃, 큐 적체)는 쿠버네티스 환경이라면 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅 같은 방식으로 관측 포인트를 먼저 고정하는 게 효과적입니다.

“정답” 대신 “검증 가능한 중간 산출물”을 요구하라

예를 들어 “PostgreSQL 데드락 해결법 알려줘” 대신 아래처럼 구조를 요구하면 환각이 줄고 실무에 바로 쓰기 좋아집니다.

  • 관측된 증상 요약
  • 가능한 원인 후보
  • 확인 쿼리/로그 포인트
  • 안전한 완화책
  • 재발 방지 체크리스트

이런 글 구조는 실제 장애 글쓰기에도 그대로 적용됩니다. 데이터베이스 트러블슈팅 스타일은 PostgreSQL 데드락(40P01) 원인·해결 9단계처럼 “단계”로 고정하면 모델도 안정적으로 따라옵니다.

예시: Chain-of-Thought 없이도 ‘생각한 것 같은’ 답을 얻는 프롬프트

아래는 사용자가 “구조화 프롬프트를 팀에 도입하는 가이드”를 요청했을 때 쓸 수 있는 입력 예시입니다.

요청: 우리 팀이 LLM을 운영에 붙이려는데, Chain-of-Thought를 노출하지 않고도 답변 품질을 올리고 싶다.
컨텍스트: Node.js 백엔드, 프롬프트 템플릿은 여러 서비스에서 공유.
제약: 출력은 JSON, plan은 3~6단계, final_answer는 8문장 이내.
원하는 것: 도입 체크리스트, 실패 모드, 재시도 전략.

이 입력이 위의 스키마 템플릿과 결합되면, 모델은 장황한 추론을 쓰지 않고도 충분히 “잘 정리된 결과물”을 내게 됩니다.

결론: CoT를 요구하지 말고, 계약을 설계하라

Chain-of-Thought 없이 성능을 올리는 가장 현실적인 방법은 “추론 공개”가 아니라 “출력 계약”입니다.

  • JSON 스키마로 형태를 고정하고
  • 체크리스트로 누락을 막고
  • 실패 모드를 상정해 재시도 루프를 만들면

모델은 내부적으로는 충분히 추론하되, 외부로는 짧고 검증 가능한 결과만 내보냅니다. 운영에서 원하는 것은 결국 “그럴듯한 설명”이 아니라 “재현 가능한 산출물”이기 때문에, 구조화 프롬프트는 제품 품질과 비용을 동시에 개선하는 가장 강력한 레버입니다.