Published on

CoT 유출 없이 추론 강화 - SCR·ReAct 실전

Authors

서버 사이드에서 LLM을 붙여 기능을 만들다 보면, “정답은 잘 맞히는데 왜 이렇게 불안하지?”라는 순간이 옵니다. 그 불안의 상당 부분은 추론 과정(CoT, Chain-of-Thought) 노출과 연결됩니다. 사용자가 모델의 내부 추론을 그대로 보게 되면 프롬프트 인젝션에 취약해지고, 정책 위반 문장 생성 가능성이 늘고, 무엇보다 시스템 프롬프트나 내부 규칙이 역추적될 수 있습니다.

그렇다고 CoT를 완전히 없애면 성능이 떨어질까 걱정되죠. 다행히 최근 실무에서는 CoT를 사용자에게 노출하지 않으면서도 추론 품질을 높이는 패턴이 빠르게 정리되고 있습니다. 이 글에서는 그중 현장에서 적용 빈도가 높은 두 축을 다룹니다.

  • SCR: Self-Critique / Self-Check / (Structured) Critique & Refine 류의 “자기 점검 기반 개선” 패턴
  • ReAct: Reason + Act. 생각과 도구 호출을 엮어 문제를 푸는 에이전트 패턴

핵심 목표는 하나입니다.

  • 사용자에게는 간결한 최종 답만 제공
  • 시스템 내부에서는 검증과 도구사용을 통해 정답률과 안정성을 끌어올림

아래 내용은 프레임워크에 종속되지 않게 설명하되, 코드 예시는 TypeScript 기준으로 제공합니다.

왜 CoT를 숨겨야 하나

CoT를 숨기는 목적은 단순히 “보안”만이 아닙니다. 실무에서 체감되는 이점은 다음과 같습니다.

  1. 프롬프트 인젝션 내성 강화
    • 모델이 내부 규칙을 장황하게 노출할수록 공격자는 규칙을 학습하고 우회합니다.
  2. 정책/안전 이슈 감소
    • 추론 과정에서 민감한 가정, 금칙어, 불필요한 정보가 나오는 경우가 있습니다.
  3. 관측성(Observability) 분리
    • 개발자는 내부 추론을 로깅하고 싶지만, 사용자는 결과만 원합니다.
  4. 비용 절감
    • 길게 생각하도록 유도하면 토큰이 늘고 비용이 증가합니다. 내부적으로만 최소한의 검증 루프를 돌리는 편이 효율적입니다.

중요한 포인트는 “CoT를 안 쓰는 것”이 아니라 **“CoT를 사용자에게 노출하지 않는 것”**입니다.

SCR: 자기 점검으로 정답률 올리기

SCR은 구현체가 다양하지만, 실무에서 가장 재현성 좋은 형태는 다음 2단계입니다.

  1. 초안 생성(Draft): 빠르게 답을 만든다.
  2. 자기 점검(Review): 오류/누락/환각을 체크하고, 필요하면 수정한다.

이때 사용자에게는 최종 답만 보여주고, 리뷰 과정은 내부 로깅 또는 메트릭으로만 남깁니다.

SCR 프롬프트 템플릿(개념)

  • Draft 프롬프트: “요구사항을 만족하는 답을 작성하라”
  • Review 프롬프트: “아래 답을 검토하고, 사실성/일관성/요구사항 충족 여부를 체크하라. 문제가 있으면 수정안을 작성하라”

주의할 점은 리뷰 프롬프트에서 “추론 과정을 자세히 적어라” 같은 지시를 넣지 않는 것입니다. 대신 체크리스트 기반으로 구조화합니다.

예를 들면 다음처럼 “검증 결과를 구조화된 JSON”으로 받습니다.

  • issues: 문제 목록
  • fixed_answer: 수정된 최종 답
  • confidence: 신뢰도

이렇게 하면 내부 검증은 강화하면서도 CoT를 길게 쓰지 않게 유도할 수 있습니다.

SCR TypeScript 예제(2패스)

아래 코드는 OpenAI 호환 API 형태를 가정한 “2번 호출” 예시입니다. 실제 SDK는 환경에 맞게 바꾸면 됩니다.

type DraftResult = {
  answer: string;
};

type ReviewResult = {
  issues: Array<{ type: "factual" | "missing" | "unclear" | "policy"; detail: string }>;
  fixed_answer: string;
  confidence: number; // 0~1
};

async function llmJson<T>(prompt: string): Promise<T> {
  // 구현은 사용하는 SDK에 맞게 교체
  const res = await fetch(process.env.LLM_ENDPOINT!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: process.env.LLM_MODEL,
      temperature: 0.2,
      response_format: { type: "json_object" },
      messages: [
        { role: "system", content: "You are a careful assistant. Output valid JSON only." },
        { role: "user", content: prompt }
      ]
    })
  });

  const data = await res.json();
  return JSON.parse(data.choices[0].message.content) as T;
}

export async function scrAnswer(userQuestion: string): Promise<{ answer: string; confidence: number; issues: ReviewResult["issues"] }> {
  const draft = await llmJson<DraftResult>(
    [
      "Create a concise, correct answer.",
      "Do not reveal chain-of-thought.",
      "Question:",
      userQuestion
    ].join("\n")
  );

  const review = await llmJson<ReviewResult>(
    [
      "Review the answer for factual errors, missing constraints, and unclear parts.",
      "Return JSON with keys: issues, fixed_answer, confidence.",
      "Do not include chain-of-thought.",
      "Question:",
      userQuestion,
      "Answer:",
      draft.answer
    ].join("\n")
  );

  return {
    answer: review.fixed_answer?.trim() || draft.answer.trim(),
    confidence: Math.max(0, Math.min(1, review.confidence ?? 0.5)),
    issues: review.issues ?? []
  };
}

이 패턴의 장점은 단순합니다.

  • 초안은 빠르게 생성
  • 리뷰는 “검증”에만 집중
  • 최종 출력은 짧고 안전

SCR의 실무 팁: 체크리스트를 “도메인 규칙”으로 바꾸기

일반적인 체크리스트(사실성/명확성)만으로는 부족할 때가 많습니다. 예를 들어 결제/정산/환불 같은 도메인에서는 다음처럼 바꾸는 게 효과적입니다.

  • 금액 단위/세금 포함 여부 누락 여부
  • 날짜/타임존 처리 명시 여부
  • 예외 케이스(부분 환불, 취소 수수료) 언급 여부
  • 법적 고지/약관 준수 문구 필요 여부

즉, SCR의 핵심은 “모델이 스스로 생각을 길게 하게 하는 것”이 아니라 검증 프레임을 제공해 실수를 줄이는 것입니다.

ReAct: 도구 호출로 ‘확인 가능한’ 추론 만들기

ReAct는 “추론(Reason)”과 “행동(Act, 도구 호출)”을 번갈아 수행하는 방식입니다. 여기서 중요한 실전 포인트는 다음입니다.

  • 모델이 모르는 것을 “그럴듯하게” 만들지 않게 하고
  • 필요한 정보는 도구로 가져오고
  • 마지막에는 결과만 요약해서 사용자에게 준다

예를 들어 다음 같은 문제에서 ReAct가 빛납니다.

  • 사내 문서 검색(RAG) 후 답변
  • DB 조회 후 요약
  • 외부 API 호출 후 상태 설명
  • 여러 단계의 작업(티켓 생성, 코멘트 남기기, 슬랙 알림)

ReAct의 함정: 무한 루프와 과도한 도구 호출

ReAct는 잘못 설계하면 “도구 호출을 계속 반복”하는 루프에 빠집니다. 도구 호출 종료 조건, 최대 스텝 수, 실패 시 폴백이 필수입니다.

이 주제는 운영에서 정말 자주 터집니다. 관련해서는 LangChain Tool Calling 무한루프 끊는 6패턴 글의 패턴들이 그대로 적용됩니다(프레임워크가 LangChain이 아니어도 개념은 동일).

ReAct TypeScript 예제(스텝 제한 + 도구 스키마)

아래는 “검색 도구”와 “요약 도구”를 가진 간단한 에이전트 루프 예시입니다. 핵심은 maxSteps와 “도구 호출 결과를 컨텍스트로만 쓰고, 최종 답은 별도로 생성”하는 구조입니다.

type ToolCall = {
  name: "searchDocs" | "getOrderStatus";
  args: Record<string, unknown>;
};

type AgentStep =
  | { type: "tool"; call: ToolCall }
  | { type: "final"; answer: string };

async function runTool(call: ToolCall): Promise<string> {
  if (call.name === "searchDocs") {
    const q = String(call.args.query ?? "");
    // 실제로는 벡터 검색/RAG 호출
    return `Search results for: ${q}\n- DocA: ...\n- DocB: ...`;
  }
  if (call.name === "getOrderStatus") {
    const orderId = String(call.args.orderId ?? "");
    // 실제로는 DB/API 조회
    return `orderId=${orderId}, status=SHIPPED, updatedAt=2026-02-20`;
  }
  throw new Error("Unknown tool");
}

async function llmNextStep(messages: Array<{ role: "system" | "user" | "assistant"; content: string }>): Promise<AgentStep> {
  // 여기서는 개념을 위해 “다음 스텝을 JSON으로” 받는 방식을 가정
  const res = await fetch(process.env.LLM_ENDPOINT!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: process.env.LLM_MODEL,
      temperature: 0.2,
      response_format: { type: "json_object" },
      messages
    })
  });
  const data = await res.json();
  return JSON.parse(data.choices[0].message.content) as AgentStep;
}

export async function reactAgent(userQuestion: string): Promise<string> {
  const system = [
    "You are an agent that can call tools.",
    "Never reveal chain-of-thought.",
    "Decide the next step as JSON:",
    "- { type: 'tool', call: { name: 'searchDocs'|'getOrderStatus', args: {...} } }",
    "- { type: 'final', answer: '...' }",
    "Use tools when needed. Stop when you can answer.",
    "Maximize correctness over verbosity."
  ].join("\n");

  const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
    { role: "system", content: system },
    { role: "user", content: userQuestion }
  ];

  const maxSteps = 6;
  for (let i = 0; i < maxSteps; i++) {
    const step = await llmNextStep(messages);

    if (step.type === "final") {
      return step.answer.trim();
    }

    if (step.type === "tool") {
      const toolResult = await runTool(step.call);
      // 도구 결과는 컨텍스트로만 제공
      messages.push({ role: "assistant", content: JSON.stringify(step) });
      messages.push({ role: "assistant", content: `ToolResult:\n${toolResult}` });
      continue;
    }

    throw new Error("Invalid step");
  }

  // 폴백: 스텝 초과 시 안전한 종료
  return "필요한 정보를 도구로 확인하는 과정이 길어져 중단했습니다. 질문을 더 구체화해 주세요.";
}

이 예제에서 “CoT 유출 방지”는 두 가지로 달성됩니다.

  • 모델이 출력하는 것은 다음 스텝 JSON 또는 최종 답뿐
  • 도구 결과는 그대로 사용자에게 보여주지 않고, 최종 답에서 요약

SCR + ReAct 조합: 운영에서 가장 강력한 형태

실무에서 추천하는 구조는 다음입니다.

  1. ReAct로 정보를 수집하고 작업을 수행
  2. SCR로 최종 답을 검증/정제

즉, ReAct는 “행동”에 강하고, SCR은 “마무리 품질”에 강합니다.

조합 파이프라인 예시

  • 입력: 사용자 질문
  • ReAct: 문서 검색, DB 조회, 계산 수행
  • Draft: 수집된 근거를 기반으로 답 생성
  • Review(SCR): 누락/오류/정책 위반 체크 후 최종 답 확정

이 조합은 특히 RAG에서 효과가 큽니다. 검색 결과가 부정확하거나 상충할 때, SCR 리뷰 단계에서 “근거 부족”을 감지해 보수적으로 답하게 만들 수 있습니다.

CoT 유출 없이도 디버깅 가능한 로깅 설계

“사용자에게는 숨기되, 개발자는 보고 싶다”가 현실입니다. 해결책은 출력 채널 분리입니다.

  • 사용자 출력: 최종 답만
  • 내부 로그: 단계별 도구 호출, 도구 결과, 리뷰 이슈 목록, 신뢰도

권장 로깅 필드(예시)

  • traceId: 요청 단위 추적
  • steps: ReAct 스텝 목록(도구 이름, 인자, 실행 시간, 성공 여부)
  • evidenceHash: 도구 결과 원문을 저장하기 부담되면 해시만
  • scrIssues: 리뷰 단계에서 발견된 이슈
  • finalConfidence: 신뢰도

이렇게 하면 CoT를 외부로 노출하지 않고도 장애 분석이 가능합니다.

실패 모드와 방어 전략

1) 도구 호출 루프

  • 원인: 종료 조건 부재, 애매한 질문, 도구 결과가 빈 값
  • 방어: maxSteps, 동일 도구 반복 감지, 도구 결과가 비었을 때 즉시 종료/재질문

도구 루프 방어는 앞서 언급한 글(LangChain Tool Calling 무한루프 끊는 6패턴)의 “반복 감지”, “스텝 제한”, “명시적 종료 프롬프트”를 그대로 적용하면 됩니다.

2) SCR 리뷰가 과도하게 보수적

  • 원인: 리뷰 프롬프트가 “확신 없으면 거절”로 과도하게 기울어짐
  • 방어: confidence 임계값을 두고, 낮을 때는 “추가 질문”으로 전환

예: confidence가 0.4 미만이면 답변 대신 “확인을 위해 2가지 정보가 필요” 같은 형태로 유도합니다.

3) 비용 증가

  • 원인: ReAct 다단계 + SCR 2패스
  • 방어: 캐시, 요약 컨텍스트, 조건부 SCR

조건부 SCR 예시

  • 고위험(결제, 보안, 법무) 요청만 SCR 적용
  • temperature 낮추고 1회 호출로 끝나는 경우는 리뷰 생략

로컬 LLM을 쓰는 경우라면, 추론 비용 자체가 OOM이나 지연으로 이어질 수 있습니다. 이때는 Transformers 로컬 LLM OOM - 4bit+KV 캐시 튜닝처럼 KV 캐시/양자화 관점 최적화도 함께 보세요.

프롬프트에 꼭 넣어야 할 문장들(현장용)

아래 문장들은 “CoT를 노출하지 말라”는 선언보다 효과가 좋습니다. 이유는 모델이 무엇을 출력해야 하는지 형태를 제한하기 때문입니다.

  • “Output must be valid JSON only.”
  • “Return only the final answer. Do not include reasoning.”
  • “If you used tools, summarize the result. Do not paste tool output verbatim.”
  • “If information is insufficient, ask a clarifying question instead of guessing.”

또한 스키마 기반 출력은 모델이 장황하게 설명하는 것을 자연스럽게 막아줍니다. TypeScript 프로젝트라면 스키마/타입을 강하게 유지하는 것이 중요하고, 타입 추론이 흔들릴 때는 TS 5.x satisfies로 타입 추론 깨짐 해결하기 같은 패턴이 도움이 됩니다.

체크리스트: 배포 전 점검

  • 최종 사용자 응답에 내부 프롬프트/규칙/도구 결과 원문이 섞이지 않는가
  • ReAct에 maxSteps가 있는가
  • 동일 도구 반복 호출 감지 로직이 있는가
  • 도구 실패 시 폴백(재시도, 다른 도구, 사용자 재질문)이 있는가
  • SCR 리뷰 결과(이슈, 신뢰도)를 로깅하고 있는가
  • 고위험 요청에만 SCR을 강제하는 정책이 있는가

마무리

CoT를 사용자에게 보여주지 않으면서도 추론을 강화하는 가장 현실적인 방법은,

  • ReAct로 “확인 가능한 정보”를 모으고
  • SCR로 “답의 품질”을 마지막에 조여서
  • 사용자에게는 “짧고 검증된 최종 답”만 제공하는 것입니다.

이 접근은 단순히 보안을 높이는 수준을 넘어, 운영 안정성(루프 방지), 비용 통제(조건부 적용), 관측성(내부 로그 구조화)까지 함께 개선합니다. 다음 단계로는 도구 스키마를 더 엄격히 하고, 실패 모드별 폴백을 늘려 “에이전트가 멈추지 않는 시스템”을 만드는 쪽으로 확장해 보세요.