- Published on
Chain-of-Thought 막히면? Self-Ask+ReAct 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 LLM 앱을 운영하다 보면, 같은 모델이라도 어떤 요청은 술술 풀리는데 어떤 요청은 갑자기 사고 흐름이 끊기거나(막힘), 같은 말만 반복하거나(루프), 근거 없이 결론을 내리거나(환각) 합니다. 흔히 Chain-of-Thought(CoT)로 “생각을 길게” 시키면 해결될 거라 기대하지만, 디버깅 관점에서는 CoT가 오히려 문제를 숨기기도 합니다. 이유는 간단합니다. 긴 추론이 한 덩어리로 뭉쳐 있으면, 어디에서 가정이 틀렸는지, 어떤 정보가 부족한지, 어떤 도구 호출이 필요했는지 관측하기가 어렵습니다.
이 글에서는 CoT가 막힐 때 Self-Ask(스스로 질문을 쪼개기)와 ReAct(Reason + Act: 추론과 행동을 번갈아 수행)를 조합해, 문제를 단계적으로 드러내고 고치는 디버깅 방법을 정리합니다. 특히 “모델이 생각은 하는데 답이 안 나오는 상태”를 관측 가능하게 만드는 설계에 초점을 둡니다.
관련해서 RAG 환경에서 반복/환각이 발생할 때의 체크리스트도 함께 보면 좋습니다. 문제 증상이 비슷하게 나타나는 경우가 많습니다.
CoT가 “막히는” 대표 증상 5가지
운영에서 자주 보는 막힘 유형을 먼저 분류해두면, 디버깅 루프가 빨라집니다.
- 정보 부족형: 답을 내리려면 외부 정보가 필요한데, 모델이 질문을 재정의하지 못하고 계속 내부 추론만 함
- 계획 부재형: 목표는 이해했지만, 하위 과업 분해가 안 되어 첫 단추를 못 끼움
- 제약 위반형: 포맷, 정책, 도메인 제약이 강한데 이를 놓치고 엉뚱한 방향으로 추론
- 도구 미사용형: 검색/DB/코드 실행이 필요한데도 도구를 호출하지 않음(혹은 호출 조건이 프롬프트에 없음)
- 루프형: 같은 근거를 반복하며 결론을 못 내림(대개 불확실성을 처리하는 규칙이 없음)
여기서 중요한 포인트는, “막힘”은 모델 능력 부족만이 아니라 오케스트레이션 설계 부재로도 쉽게 발생한다는 점입니다.
Self-Ask: 막힘을 “질문 그래프”로 바꾸기
Self-Ask는 큰 문제를 작은 질문들로 쪼개고, 각 질문에 대해 필요한 정보와 답변을 순차적으로 채워가는 방식입니다. CoT가 한 번에 길게 달리는 것과 달리, Self-Ask는 중간 산출물이 생기기 때문에 디버깅이 쉬워집니다.
Self-Ask 디버깅에서 관측해야 할 것
- 하위 질문이 제대로 생성되는가(너무 크거나, 너무 많거나, 목표와 무관한가)
- 각 질문이 “추론으로 풀 문제”인지 “조회가 필요한 문제”인지 분류되는가
- 질문 간 의존관계가 명확한가(선행 질문이 없으면 후속 질문이 답이 안 나오는 구조인지)
프롬프트 패턴 예시
아래 예시는 모델이 답을 바로 쓰지 못하도록 하고, 먼저 하위 질문 리스트를 내도록 강제합니다. MDX 환경에서 부등호가 노출되면 빌드 에러가 날 수 있으니, 코드 블록으로만 제공합니다.
System:
너는 문제를 디버깅하는 엔지니어다. 최종 답을 쓰기 전에 반드시 하위 질문을 만들고,
각 질문이 (추론)인지 (조회)인지 태깅하라.
User:
"Chain-of-Thought가 막히는" 현상을 Self-Ask로 디버깅하는 절차를 알려줘.
Assistant:
1) 하위 질문 생성:
- Q1. CoT가 막힌다는 증상을 어떻게 분류할 수 있나? (추론)
- Q2. 정보 부족형일 때 어떤 신호가 나타나나? (추론)
- Q3. 어떤 경우에 외부 도구 호출이 필요하나? (추론)
- Q4. 도구 호출을 어떻게 설계하면 재현 가능한가? (추론)
...
2) 각 질문에 답:
...
3) 최종 요약:
...
이렇게 하면 “모델이 왜 막혔는지”가 최소한 질문 단위로 분해되어 보이기 시작합니다.
Self-Ask가 실패하는 흔한 이유
- 하위 질문이 “조사 가능한 질문”이 아니라 “감상/추상”으로 흐름
- 질문 수가 과도하게 늘어나 토큰 예산을 잠식
- 질문 간 의존성이 꼬여서, 앞 질문이 틀리면 뒤가 연쇄적으로 무너짐
이때는 다음 섹션의 ReAct가 유효합니다. Self-Ask가 “질문을 잘 쪼개는 능력”이라면, ReAct는 “필요할 때 행동(도구 호출)을 강제하는 능력”입니다.
ReAct: 추론이 막힐 때 “행동”으로 탈출시키기
ReAct는 모델이 생각만 하다가 막히는 것을 방지하기 위해, **추론(Reason)**과 **행동(Act)**을 번갈아 수행하게 합니다. 여기서 행동은 보통 다음 중 하나입니다.
- 검색 API 호출
- 사내 문서/위키/티켓 조회
- DB 질의
- 코드 실행(샌드박스)
- 계산기/파서/검증기 호출
핵심은 “모델이 확신이 없을 때는 행동으로 증거를 수집하라”는 규칙을 시스템적으로 넣는 것입니다.
ReAct의 디버깅 포인트 3가지
- 행동 트리거가 명확한가: 언제 도구를 호출해야 하는지 규칙이 없으면 모델은 계속 말로 버팁니다.
- 도구 결과를 검증하는가: 도구 출력이 틀릴 수 있습니다. 검증 단계를 넣지 않으면 환각이 도구 결과처럼 포장됩니다.
- 행동 비용(토큰/지연)을 관리하는가: 무한 검색 루프를 막는 상한이 필요합니다.
간단한 ReAct 오케스트레이션 예시(의사 코드)
아래는 LangChain 같은 프레임워크가 없어도 구현 가능한 최소 형태입니다.
from dataclasses import dataclass
from typing import Literal, Optional
ActionType = Literal["search", "lookup", "compute", "finish"]
@dataclass
class Step:
thought: str
action: ActionType
action_input: Optional[str] = None
MAX_STEPS = 6
def llm_plan(prompt: str) -> Step:
"""LLM이 다음 스텝을 JSON으로 내도록 강제한다고 가정"""
raise NotImplementedError
def tool_search(q: str) -> str:
return f"search_result_for: {q}"
def run_agent(user_input: str) -> str:
context = ""
for i in range(MAX_STEPS):
step = llm_plan(
prompt=(
"너는 ReAct 에이전트다. 다음 중 하나를 선택하라: search, lookup, compute, finish. "
"확신이 없으면 search 또는 lookup을 선택하라. "
"반드시 JSON으로만 답하라.\n"
f"user_input: {user_input}\n"
f"context: {context}\n"
)
)
if step.action == "search":
obs = tool_search(step.action_input or "")
context += f"\n[observation] {obs}"
elif step.action == "finish":
return step.thought
else:
context += "\n[observation] not_implemented"
return "중간 단계에서 종료: step limit"
이 구조의 장점은, CoT가 막히는 순간에도 “다음 행동이 무엇이었는지”가 로그로 남아 재현 가능한 디버깅이 가능해진다는 점입니다.
Self-Ask + ReAct: 함께 쓸 때의 설계 패턴
두 기법을 섞으면 다음과 같은 파이프라인이 됩니다.
- Self-Ask로 하위 질문 생성
- 각 질문을 처리할 때 ReAct로 “추론 vs 도구 호출”을 결정
- 질문별 결과를 합쳐 최종 답 생성
추천 상태 머신
- 상태
DECOMPOSE: 하위 질문 생성 - 상태
SOLVE_Qi: i번째 질문 해결 - 상태
NEED_EVIDENCE: 도구 호출 - 상태
VERIFY: 근거 검증 및 상충 해결 - 상태
SYNTHESIZE: 최종 답 합성
이렇게 상태를 나누면, 장애가 났을 때 “어느 상태에서 실패했는지”가 곧 원인 분석의 출발점이 됩니다. 분산 시스템에서 재시도/중복 처리를 상태 머신으로 잡는 것과 결이 비슷합니다.
실전 디버깅 체크리스트
운영에서 CoT 막힘을 만나면, 아래 순서로 점검하면 빠르게 원인을 좁힐 수 있습니다.
1) 입력을 “정답 가능 형태”로 만들었나
- 질문에 목표/제약/출력 포맷이 명확한가
- 도메인 컨텍스트가 필요한데 누락되지 않았나
- 금지 조건(예: 외부 접근 불가, 내부 정책)이 있는데 모델이 이를 모르는가
Self-Ask는 여기서 특히 효과적입니다. 하위 질문이 제대로 나오지 않으면, 대개 입력 자체가 불완전합니다.
2) 도구 호출 트리거가 프롬프트에 명시됐나
- “모르면 검색” 같은 규칙이 시스템 메시지에 있는가
- 도구 목록과 파라미터 형식이 명확한가
- 도구 호출 실패 시 대체 경로가 있는가
ReAct는 트리거가 없으면 작동하지 않습니다. 모델 입장에서는 “도구를 써도 된다”는 확신이 없으면 말로 해결하려고 합니다.
3) 루프 방지 장치가 있나
- 최대 스텝 수 제한
- 동일 쿼리 반복 검색 금지(최근 N개 쿼리 중복이면 중단)
- 불확실성 처리 규칙(예: 가정 목록을 나열하고 사용자에게 확인 질문)
4) 근거 검증 단계가 있나
- 도구 결과를 그대로 믿지 말고, 상충되는 결과가 나오면 재질의 또는 출처 비교
- RAG라면 인용 스니펫과 답변이 실제로 정합한지 검사
이 부분은 RAG 디버깅에서 특히 중요합니다. 반복/환각은 “근거 검증 부재”로 강화됩니다.
5) 로그를 “질문 단위”로 남기고 있나
CoT 전체를 저장하는 것보다, 다음을 구조화해서 저장하는 편이 훨씬 유용합니다.
- 하위 질문 리스트
- 질문별 선택된 액션(추론/검색/조회)
- 도구 입력과 출력
- 최종 합성 시 사용한 근거 목록
자주 쓰는 프롬프트 템플릿 2종
템플릿 A: Self-Ask 우선(기획/설계형 문제)
System:
너는 시니어 엔지니어다. 답변 전에 반드시 문제를 하위 질문으로 분해하고,
각 질문의 해결 방식(추론/조회/계산)을 명시한다.
User:
{문제}
Assistant:
- 하위 질문:
1. ... (추론)
2. ... (조회)
- 해결:
- Q1 답: ...
- Q2 답: ...
- 최종 답:
...
템플릿 B: ReAct 우선(근거 수집이 필요한 문제)
System:
너는 ReAct 에이전트다.
불확실하면 반드시 도구를 호출해 근거를 확보한다.
가능한 행동: search(query), lookup(key), compute(expr), finish(answer)
반드시 다음 형식으로만 출력:
{"action": "...", "action_input": "..."}
User:
{문제}
이 템플릿은 도구 호출을 강제하기 때문에, “말만 그럴듯하게 하는” 막힘을 빠르게 줄여줍니다.
운영 관점: 막힘은 대개 “리소스/제약” 문제로도 나타난다
LLM 디버깅을 하다 보면, 모델 추론 문제가 아니라 시스템 제약 때문에 막히는 경우도 많습니다.
- 컨텍스트 윈도우가 부족해 핵심 근거가 잘려 나감
- 타임아웃 때문에 도구 호출이 중간에 끊김
- 캐시/세션 꼬임으로 이전 대화가 섞여 들어감
이런 문제는 전형적인 웹/백엔드 장애처럼 접근해야 합니다. 예를 들어 타임아웃/게이트웨이 이슈는 LLM이 아니라 인프라 병목일 수 있습니다.
정리: CoT가 막히면 “더 길게 생각”이 아니라 “더 잘 쪼개고, 더 자주 행동”
- CoT는 결과만 보면 그럴듯하지만, 막힘의 원인을 숨기기 쉽습니다.
- Self-Ask는 문제를 하위 질문으로 쪼개 어디에서 정보가 부족한지를 드러냅니다.
- ReAct는 불확실할 때 도구 호출을 강제해 증거 기반으로 탈출하게 만듭니다.
- 두 방법을 합치면, 질문 그래프와 행동 로그가 남아 재현 가능한 디버깅 루프를 만들 수 있습니다.
다음에 CoT가 막히는 케이스를 만나면, “모델에게 더 길게 생각하라” 대신, Self-Ask로 하위 질문을 만들고 ReAct로 근거를 수집하게 설계해보세요. 막힘이 문제의 본질이라기보다, 관측과 제어가 부족한 오케스트레이션 문제였다는 걸 훨씬 자주 발견하게 됩니다.