Published on

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

Authors

LLM을 제품에 붙이다 보면 곧바로 마주치는 딜레마가 있습니다. 정답률을 올리려면 더 깊은 추론이 필요한데, 전통적인 CoT(Chain-of-Thought)를 그대로 노출하면 다음 문제가 생깁니다.

  • 보안·프라이버시: 내부 규칙, 운영 데이터, 고객 식별 정보가 추론 과정에 섞여 노출될 수 있음
  • 프롬프트 인젝션 표면 확대: 모델이 “생각 과정을 보여줘” 같은 공격에 취약해짐
  • 정책·컴플라이언스: 특정 도메인에서는 추론 로그 자체가 규제 대상이 되기도 함

이 글은 CoT를 유출하지 않으면서도 추론 품질을 높이는 대표 패턴인 ReAct와 **Self-Consistency(SC)**를 실전 기준으로 정리합니다. 핵심은 간단합니다.

  • 모델에게는 내부적으로 충분히 “생각”할 여지를 주되
  • 사용자에게는 **최종 답변과 근거 요약(증거, 출처, 계산 결과)**만 제공
  • 평가·로그에는 구조화된 메타데이터만 남겨 재현 가능성을 확보

아키텍처 관점에서 보면, 운영 환경에서의 장애/병목을 추적하듯이 LLM 추론도 관측 가능하게 만드는 것이 중요합니다. 예를 들어 DB 병목을 pg_stat_statements로 추적하듯, 추론도 “툴 호출 횟수, 실패율, 재시도, 합의율” 같은 지표로 관리해야 합니다. 관련해서는 PostgreSQL 쿼리 폭주? pg_stat_statements로 병목 추적처럼 관측과 진단의 관점을 LLM에도 그대로 가져오면 좋습니다.

CoT 유출이 왜 위험한가: 실제 운영에서의 리스크

CoT는 단순히 “중간 풀이”가 아닙니다. 운영 시스템에서는 다음이 섞이기 쉽습니다.

  • 내부 정책 문구, 가격 규칙, 위험 점수 계산식
  • 고객 데이터 일부(요약 과정에서 유출)
  • 시스템 프롬프트(가드레일), 라우팅 규칙

또한 공격자는 CoT를 이용해 모델의 취약한 규칙을 역추론합니다. 예를 들어 “어떤 단어가 필터를 우회하는지”, “어떤 조건에서 툴을 호출하는지”가 드러나면 인젝션이 쉬워집니다.

따라서 목표는 다음과 같이 재정의하는 게 좋습니다.

  • 모델 내부에서는 추론을 최대한 활용
  • 외부 출력에서는 추론 과정을 노출하지 않고 검증 가능한 결과물만 제시

이때 ReAct와 SC는 “더 생각하게 만들기”가 아니라, 생각을 시스템적으로 구조화해 품질을 올리는 접근입니다.

패턴 1: ReAct — 추론과 행동(툴)을 교차시켜 정확도 올리기

ReAct는 ReasoningActing을 번갈아 수행합니다. 핵심은 모델이 애매한 기억에 의존하지 않고 필요할 때 외부 도구로 검증하게 만드는 것입니다.

운영에서 ReAct가 특히 유효한 영역은 다음과 같습니다.

  • 최신 정보 조회(검색, 사내 위키, 티켓 시스템)
  • 정형 계산(세금, 환율, 비용 산정)
  • 시스템 상태 진단(로그/메트릭 기반)

예를 들어 EKS에서 503이 나오는 상황을 LLM이 진단한다고 합시다. 모델이 “그럴듯한 추측”을 늘어놓는 대신, Ingress/Service/Pod 상태를 툴로 확인하며 진행해야 합니다. 이런 진단형 워크플로우는 EKS에서 503 Service Unavailable 원인 10분 진단처럼 체크리스트 기반으로 잘 구조화할수록 ReAct 성능이 좋아집니다.

ReAct를 CoT 유출 없이 쓰는 요령

ReAct를 그대로 구현하면 모델이 Thought를 출력하려고 합니다. 제품에서는 다음 원칙을 추천합니다.

  • 모델에게는 내부적으로 “계획/검증”을 하게 하되
  • 출력 포맷은 AnswerEvidence(툴 결과 요약)만 허용
  • 툴 호출은 구조화된 JSON 이벤트로만 기록

즉, “생각”은 내부 상태로 두고, 사용자에게는 행동 결과(관측값) 중심으로 설명합니다.

예제: Node.js로 ReAct 루프 만들기(툴 호출 포함)

아래 예제는 OpenAI 호환 API 형태로 작성했지만, 구조는 대부분의 LLM SDK에 그대로 적용됩니다.

// react-agent.ts
import OpenAI from "openai";

type ToolResult = {
  tool: string;
  input: unknown;
  output: unknown;
};

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function tool_search(query: string) {
  // 실제로는 사내 검색/벡터DB/웹 검색 등을 연결
  return {
    topHits: [
      { title: "Runbook: EKS 503 triage", url: "https://internal/runbook/eks-503", snippet: "Check ingress, endpoints, pod readiness..." }
    ]
  };
}

async function tool_kubectl(args: { namespace: string; resource: string }) {
  // 실제로는 RBAC 걸린 실행기(또는 관측 API)로 대체
  return {
    namespace: args.namespace,
    resource: args.resource,
    summary: "3 pods Running, 1 pod CrashLoopBackOff, endpoints empty"
  };
}

const tools = {
  tool_search,
  tool_kubectl
};

function safeUserFacingAnswer(answer: string, evidence: ToolResult[]) {
  // CoT 대신 증거 요약만 노출
  return {
    answer,
    evidence: evidence.map(e => ({ tool: e.tool, output: e.output }))
  };
}

export async function reactSolve(userQuestion: string) {
  const evidence: ToolResult[] = [];

  const system = [
    "You are an operations assistant.",
    "Do not reveal chain-of-thought.",
    "Use tools when needed.",
    "When you respond to the user, output only JSON with keys: answer, evidence.",
    "Evidence must be brief and based on tool outputs.",
    "If unsure, ask a clarifying question in answer."
  ].join("\n");

  let messages: any[] = [
    { role: "system", content: system },
    { role: "user", content: userQuestion }
  ];

  for (let step = 0; step < 6; step++) {
    const resp = await client.chat.completions.create({
      model: "gpt-4.1-mini",
      messages,
      temperature: 0.2,
      // function calling 또는 tool calling을 쓰는 방식이 가장 안전
      tools: [
        {
          type: "function",
          function: {
            name: "tool_search",
            description: "Search runbooks/docs.",
            parameters: {
              type: "object",
              properties: { query: { type: "string" } },
              required: ["query"]
            }
          }
        },
        {
          type: "function",
          function: {
            name: "tool_kubectl",
            description: "Get cluster resource summary.",
            parameters: {
              type: "object",
              properties: {
                namespace: { type: "string" },
                resource: { type: "string" }
              },
              required: ["namespace", "resource"]
            }
          }
        }
      ]
    });

    const msg = resp.choices[0].message;

    // 툴 호출이 있으면 실행
    if (msg.tool_calls && msg.tool_calls.length > 0) {
      messages.push(msg);

      for (const call of msg.tool_calls) {
        const name = call.function.name as keyof typeof tools;
        const input = JSON.parse(call.function.arguments || "{}");
        const output = await tools[name](input as any);

        evidence.push({ tool: name, input, output });

        messages.push({
          role: "tool",
          tool_call_id: call.id,
          content: JSON.stringify(output)
        });
      }

      continue;
    }

    // 최종 응답은 JSON으로만 받는다
    const parsed = JSON.parse(msg.content || "{}");
    return safeUserFacingAnswer(parsed.answer, evidence);
  }

  return safeUserFacingAnswer(
    "추가 정보가 필요합니다. 대상 네임스페이스와 서비스/인그레스 이름을 알려주세요.",
    evidence
  );
}

이 구현에서 CoT 유출을 막는 포인트

  • 모델 출력 계약을 answer/evidence로 제한
  • evidence툴 출력 기반 요약만 허용
  • 중간 단계는 툴 호출 이벤트로만 남고, “생각”은 남기지 않음

운영 로그에는 tool, input, output을 남기되, input에 민감정보가 포함되지 않도록 마스킹 레이어를 두는 것이 좋습니다.

패턴 2: Self-Consistency(SC) — 여러 번 풀어 합의로 품질 올리기

SC는 동일 질문을 여러 샘플로 풀고(온도 약간 높임), 그 결과를 투표/합의로 결정합니다. 단순해 보이지만, 다음 상황에서 효과가 큽니다.

  • 문제 풀이/분류/요약처럼 “답 후보가 몇 개로 수렴”하는 작업
  • 단일 샘플에서 편향이 강한 경우
  • 긴 문맥에서 한 번씩 실수하는 경우

중요한 점은 SC가 “모델을 더 똑똑하게” 만드는 게 아니라, 불안정성을 평균화해 준다는 것입니다.

SC도 CoT 없이 가능하다

SC를 구현할 때 흔히 “각 샘플의 CoT를 비교”하려고 하는데, 제품에서는 그럴 필요가 없습니다.

  • 각 샘플은 최종 답만 내게 한다
  • 합의 단계는 별도 모델(또는 같은 모델)로 판정만 한다
  • 사용자는 합의된 최종 답 + 근거 요약만 본다

예제: Python으로 SC 투표(결과만 수집)

# self_consistency.py
from collections import Counter
import json
from openai import OpenAI

client = OpenAI()

def sample_answer(question: str, seed: int):
  system = "\n".join([
    "You are a helpful assistant.",
    "Do not reveal chain-of-thought.",
    "Return only JSON: {\"final\": string}."
  ])

  resp = client.chat.completions.create(
    model="gpt-4.1-mini",
    temperature=0.8,
    messages=[
      {"role": "system", "content": system},
      {"role": "user", "content": question + "\nUse concise final answer."}
    ],
  )

  content = resp.choices[0].message.content or "{}"
  return json.loads(content).get("final", "")

def self_consistent(question: str, k: int = 7):
  answers = [sample_answer(question, i) for i in range(k)]
  counts = Counter(answers)
  best, freq = counts.most_common(1)[0]
  return {
    "final": best,
    "agreement": freq / k,
    "candidates": counts.most_common(5)
  }

if __name__ == "__main__":
  q = "EKS에서 503이 발생할 때 가장 먼저 확인할 3가지는?"
  print(self_consistent(q, k=9))

실전 팁: 합의율을 SLO로 둬라

SC는 agreement가 낮을수록 “질문이 모호하거나”, “컨텍스트가 부족하거나”, “툴 검증이 필요”하다는 신호입니다.

  • agreement0.4 이하이면: 사용자에게 уточ 질문(clarifying question) 유도
  • agreement가 낮은데도 바로 답해야 하면: ReAct로 전환해 근거를 수집

이 방식은 인프라에서 error rate를 보고 자동으로 디버깅 플로우를 바꾸는 것과 유사합니다. EKS에서 파드가 ContainerCreating에 멈추면 CNI/CSI/권한을 순서대로 점검하듯, LLM도 합의율 기반으로 플로우를 전환할 수 있습니다. 참고로 운영 체크리스트 예시는 EKS Pod가 ContainerCreating에 멈출 때 10분 진단 같은 글이 좋은 템플릿입니다.

ReAct와 SC를 같이 쓰는 실전 조합

둘은 경쟁 관계가 아니라 조합이 좋습니다.

  • 1단계(SC): 빠르게 여러 번 답해 합의율 확인
  • 2단계(ReAct): 합의율이 낮거나, 고위험 답변이면 툴로 검증
  • 3단계(SC): 툴 결과를 포함한 컨텍스트로 다시 합의(최종 안정화)

이렇게 하면 비용을 통제하면서도 품질을 끌어올릴 수 있습니다.

라우팅 의사코드

if risk == high:
  use ReAct (tools required)
else:
  run SC
  if agreement < threshold:
    use ReAct
    run SC again with evidence
return final

여기서 risk는 도메인에 따라 정의합니다.

  • 결제/환불/장애 조치: high
  • 단순 FAQ: low

CoT 유출 방지 체크리스트(프롬프트만으로는 부족)

CoT 유출을 막을 때 “시스템 프롬프트에 쓰면 끝”이라고 생각하기 쉽지만, 실전에서는 출력 계층과 로깅 계층이 더 중요합니다.

1) 출력은 스키마로 강제

  • 자연어로 “생각을 말하지 마”는 약합니다.
  • JSON schema 또는 function calling으로 출력 형태를 고정하세요.

예: answer, evidence, confidence, next_questions 정도로 제한.

2) 로그에는 CoT 대신 이벤트만

  • 저장: 툴 호출명, 입력 파라미터(마스킹), 응답 요약, 합의율, 토큰 수, 지연시간
  • 금지: 모델 원문 CoT, 시스템 프롬프트 전문, 사용자 원문 중 민감정보

3) “설명”은 CoT가 아니라 검증 가능한 근거로

사용자가 납득해야 하는 영역에서는 다음을 권장합니다.

  • 계산 결과(수식 자체가 아니라 입력과 결과)
  • 툴에서 관측한 상태 요약
  • 출처 링크(가능하면)

즉, “내가 이렇게 생각했어”가 아니라 “이 데이터를 봤고 그래서 이렇게 결론” 구조로 바꿉니다.

평가: 정답률만 보지 말고 안정성과 재현성을 보자

ReAct·SC를 도입하면 단순 정확도 외에 관리해야 할 지표가 늘어납니다.

  • 툴 호출 성공률: 실패하면 환각이 늘어남
  • 평균 툴 호출 횟수: 비용과 지연시간에 직결
  • 합의율 분포: 질문 난이도/모호성의 대리지표
  • fallback 비율: “모르겠다/추가 질문”으로 전환되는 비율

A/B 테스트에서는 다음 두 가지를 분리해서 측정하는 게 좋습니다.

  • SC만 적용했을 때의 개선폭
  • SC + ReAct 라우팅을 적용했을 때의 개선폭

마무리: “생각”을 숨기고, “관측”을 보여줘라

ReAct와 Self-Consistency는 CoT를 외부로 노출하지 않아도 충분히 강력합니다. 핵심은 모델의 내적 추론을 사용자에게 그대로 보여주는 대신, 툴 기반 관측과 합의 기반 안정성을 제품 레이어에서 설계하는 것입니다.

  • ReAct: 불확실한 기억 대신 검증 가능한 행동으로 정확도 상승
  • SC: 샘플링을 통해 불안정성을 평균화하고 합의율로 리스크를 계량
  • CoT 보호: 스키마 강제, 이벤트 로깅, 근거 요약 중심 UX

이 조합을 적용하면 “정답률”뿐 아니라 운영에서 중요한 재현성, 감사 가능성, 보안성까지 함께 가져갈 수 있습니다.