- Published on
Chain-of-Thought 없이 추론 - ReAct·RAP 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 LLM 에이전트를 운영하다 보면 “추론은 잘 하되, Chain-of-Thought는 노출하지 말자”라는 요구를 자주 만나게 됩니다. 이유는 명확합니다. 내부 정책상 민감한 중간 사고를 남기고 싶지 않거나, 사용자에게 불필요한 장황함을 보여주고 싶지 않거나, 프롬프트 인젝션에 의해 내부 reasoning이 유출되는 리스크를 줄이고 싶기 때문입니다.
그렇다고 추론 자체를 포기할 수는 없습니다. 이때 실무에서 유용한 접근이 ReAct 와 RAP 입니다. 둘 다 “모델이 생각을 길게 쓰지 않아도(혹은 쓰더라도 외부로 노출하지 않아도)”, 행동과 관찰을 통해 문제를 풀게 만드는 패턴입니다.
이 글은 다음을 목표로 합니다.
- Chain-of-Thought를 사용자에게 출력하지 않는 형태로 에이전트 품질을 올리는 방법
ReAct와RAP의 차이와 실전 적용 포인트- 도구 호출(툴콜) 실패, 재시도, 관찰값 오염을 다루는 운영 관점 체크리스트
- Node.js 기반 최소 구현 예제
왜 Chain-of-Thought를 숨기면서도 추론이 가능한가
LLM의 성능은 “생각을 길게 쓰는 것” 자체가 아니라, 문제를 잘 분해하고 검증 루프를 돌리는 구조에서 크게 개선됩니다. Chain-of-Thought는 그 구조를 텍스트로 드러내는 한 방식일 뿐입니다.
실무에서는 다음처럼 바꿔치기합니다.
- 긴 내적 독백 대신,
행동(Action)을 먼저 실행 - 실행 결과
관찰(Observation)을 근거로 다음 행동을 결정 - 최종적으로 사용자에게는
결론(Final)만 간결하게 제공
이때 핵심은 “모델이 스스로 검증 가능한 외부 근거(관찰)”를 많이 갖게 만드는 것입니다. 즉, 추론을 텍스트로 노출하기보다 도구 호출과 상태 전이를 통해 구현합니다.
ReAct 패턴: Thought 대신 Action-Observation 루프
ReAct 는 Reasoning과 Acting을 번갈아 수행하는 패턴으로 알려져 있습니다. 하지만 운영 관점에서는 이렇게 이해하는 편이 유용합니다.
- 모델이 해야 할 일을
행동단위로 쪼갠다 - 각 행동은 툴 호출로 매핑한다
- 툴 결과를
관찰로 기록한다 - 모델은 관찰을 바탕으로 다음 행동을 선택한다
중요한 점은, “Thought를 사용자에게 보여주지 않아도” 루프는 그대로 돌아간다는 것입니다. 서버 내부 로그에만 남기거나(또는 아예 남기지 않거나), 모델에게도 reasoning 을 요구하지 않는 프롬프트로 구성할 수 있습니다.
ReAct에 적합한 문제
- 외부 데이터 조회가 필요한 QnA: 검색, DB 조회, 로그 탐색
- 운영 자동화: 장애 진단, 설정 검증, 권한 확인
- 멀티스텝 업무: 티켓 생성, 요약, 검증, 후속 액션
예를 들어 AWS 권한 문제나 OIDC 설정 이슈는 “추측”으로 해결하기보다, 실제 설정과 로그를 확인하는 툴 기반 루프가 훨씬 안전합니다. 비슷한 진단 흐름은 다음 글의 스타일과도 맞닿아 있습니다.
RAP 패턴: “계획-실행-수정”을 구조화해 안정성 올리기
RAP 는 문헌마다 약어가 조금씩 다르게 쓰이지만, 실무적으로는 다음 3요소로 이해하면 적용이 쉽습니다.
Plan혹은Proposal: 현재 상태에서 가능한 해결책 후보를 만든다Act: 후보 중 하나를 실행한다Reflect혹은Revise: 실행 결과가 목표를 만족하는지 검증하고 수정한다
ReAct가 “행동-관찰” 루프에 초점을 둔다면, RAP는 후보를 만들고, 검증하고, 수정하는 루프를 더 명시적으로 넣는 느낌입니다.
RAP가 특히 강한 케이스는 다음입니다.
- 잘못된 툴 호출이 비용이 큰 경우(예: 결제, 배포, 데이터 변경)
- 결과의 정합성 검증이 중요한 경우(예: 보안 설정, 정책 문서 생성)
- 실패 시 대체 경로가 필요한 경우(예: 1차 검색 실패 시 2차 소스 사용)
“CoT 없이”를 달성하는 3가지 출력 설계
1) 사용자 출력에서 reasoning을 제거
모델에게는 내부적으로 reasoning을 하게 두더라도, 사용자에게는 final 만 반환합니다. 대부분의 상용 API는 reasoning 과 content 를 분리하거나, 시스템 프롬프트로 “중간 과정은 출력하지 말라”를 강제할 수 있습니다.
2) 애초에 reasoning을 요구하지 않는 프롬프트
가장 깔끔한 방법은 “중간 사고를 쓰지 말고, 필요한 경우 툴을 호출하라”고 지시하는 것입니다. 즉, 모델의 텍스트 출력은 아래 두 가지로 제한합니다.
- 툴 호출
- 최종 답변
3) 서버가 상태 머신을 쥐고, 모델은 선택만 하게 만들기
모델이 자유롭게 장문 추론을 쓰지 못하도록, 서버가 다음 중 하나를 강제합니다.
- 선택지 기반 액션:
action은 미리 정의된 enum 중 하나만 - 툴 호출 스키마를 엄격히: JSON schema 검증
- 관찰값은 서버가 요약해 제공: 모델이 원본 로그를 직접 다루지 않게
이 방식은 디버깅에도 유리합니다. 툴콜이 깨질 때는 모델 문제가 아니라 서버 어댑터 문제인 경우가 많고, 이런 케이스는 SSE나 툴콜 파서 로그를 보면 빨리 잡힙니다.
실전 구현: Node.js로 ReAct 루프 만들기
아래 예시는 “사용자 질문을 받고, 필요한 경우 검색 툴을 호출한 뒤, 관찰값을 기반으로 최종 답변을 작성”하는 최소 ReAct 루프입니다. 특정 벤더 SDK에 종속되지 않도록, callLLM 은 추상화했습니다.
1) 툴 정의와 스키마
툴콜을 안전하게 하려면 “자유 텍스트로 도구를 호출”하게 두지 말고, JSON 스키마로 입력을 고정하는 편이 좋습니다.
// tools.ts
export type ToolName = "web_search" | "get_doc";
export type ToolCall =
| { name: "web_search"; args: { query: string } }
| { name: "get_doc"; args: { id: string } };
export async function runTool(call: ToolCall) {
switch (call.name) {
case "web_search": {
// 실제로는 사내 검색, OpenSearch, Serp API 등으로 대체
const { query } = call.args;
return {
results: [
{ title: "Result A", snippet: `About: ${query}`, url: "https://example.com/a" },
{ title: "Result B", snippet: `More: ${query}`, url: "https://example.com/b" }
]
};
}
case "get_doc": {
const { id } = call.args;
return { id, content: `Document content for ${id}` };
}
}
}
2) 모델 출력 형식: tool 또는 final 만
모델이 Chain-of-Thought를 쓰지 않도록, 출력 채널을 엄격히 제한합니다.
// protocol.ts
export type AgentStep =
| { type: "tool"; call: { name: string; args: any } }
| { type: "final"; answer: string };
export function safeJsonParse(input: string) {
try {
return { ok: true as const, value: JSON.parse(input) };
} catch (e) {
return { ok: false as const, error: e };
}
}
3) ReAct 루프 실행기
- 최대 스텝 수 제한
- 툴 결과를
observation으로 누적 - 모델이
final을 내면 종료
// agent.ts
import { runTool, ToolCall } from "./tools";
import { AgentStep, safeJsonParse } from "./protocol";
type Message = { role: "system" | "user" | "assistant"; content: string };
async function callLLM(messages: Message[]): Promise<string> {
// 벤더 SDK 호출로 교체
// 여기서는 모델이 JSON만 반환한다고 가정
return "";
}
export async function runReAct(question: string) {
const system: Message = {
role: "system",
content: [
"You are an agent.",
"Do not output reasoning.",
"Return ONLY JSON.",
"Output must be either:",
"{\"type\":\"tool\",\"call\":{\"name\":string,\"args\":object}}",
"or {\"type\":\"final\",\"answer\":string}"
].join("\n")
};
const messages: Message[] = [
system,
{ role: "user", content: question }
];
const observations: any[] = [];
const maxSteps = 6;
for (let step = 0; step < maxSteps; step++) {
// 관찰값을 모델에게 주되, 원본 로그를 과다 노출하지 않도록 요약 가능
if (observations.length > 0) {
messages.push({
role: "assistant",
content: JSON.stringify({
type: "observation",
items: observations
})
});
}
const raw = await callLLM(messages);
const parsed = safeJsonParse(raw);
if (!parsed.ok) {
// 모델이 포맷을 어기면 재시도 프롬프트로 교정
messages.push({
role: "assistant",
content: "Invalid JSON. Return ONLY valid JSON for the protocol."
});
continue;
}
const out = parsed.value as AgentStep;
if (out.type === "final") {
return { answer: out.answer, observations };
}
if (out.type === "tool") {
const call = out.call as ToolCall;
const toolResult = await runTool(call);
observations.push({ call, result: toolResult });
continue;
}
// 방어적 처리
messages.push({
role: "assistant",
content: "Protocol violation. Output must be tool or final."
});
}
return {
answer: "I could not finish within the step limit.",
observations
};
}
이 구조의 장점은 분명합니다.
- 사용자에게는 중간 추론이 노출되지 않음
- 모델이 헛소리를 하더라도, 관찰 기반으로 다음 스텝에서 정정될 여지가 큼
- 실패 지점이
툴,파서,스텝 제한,관찰 품질등으로 명확히 분리됨
RAP 적용: 후보 생성과 검증을 분리해 실패율 낮추기
ReAct 루프는 빠르지만, “첫 액션 선택이 잘못되면” 몇 스텝을 낭비할 수 있습니다. RAP 스타일을 섞으면 다음이 좋아집니다.
- 먼저
proposal을 여러 개 만들고 - 각 proposal에 필요한 관찰을 수집한 뒤
validator가 통과한 것만 최종 답으로 채택
아래는 간단한 형태의 “검증기(validator) 분리” 예시입니다.
// rap.ts
type Proposal = { id: string; plan: string; requiredTools: string[] };
export function validateAnswer(answer: string) {
// 실무에서는 규칙 기반 + LLM 기반 평가를 섞기도 함
const tooVague = answer.length < 80;
const hasSteps = answer.includes("1)") || answer.includes("- ");
return {
ok: !tooVague && hasSteps,
reasons: [
...(tooVague ? ["too_short"] : []),
...(!hasSteps ? ["no_actionable_steps"] : [])
]
};
}
export async function runRAP(question: string) {
// 1) proposal 생성(모델)
const proposals: Proposal[] = [
{ id: "p1", plan: "Search docs and summarize", requiredTools: ["web_search"] },
{ id: "p2", plan: "Fetch internal doc by id", requiredTools: ["get_doc"] }
];
// 2) 가장 적합한 proposal을 고르거나, 둘 다 실행해 비교
// 3) 실행 결과로 답 생성
const draft = `1) Check logs\n2) Verify config\n3) Apply fix\n...`;
// 4) 검증 실패 시 수정 루프
const v = validateAnswer(draft);
if (!v.ok) {
const revised = draft + "\n\n(Added more concrete steps.)";
return { answer: revised, validator: v };
}
return { answer: draft, validator: v };
}
핵심은 “모델이 길게 생각을 쓰는 것”이 아니라, 초안과 검증을 분리해 품질을 끌어올리는 데 있습니다. 검증기는 규칙 기반으로 시작해도 되고, 운영이 성숙해지면 별도의 LLM 평가자로 고도화할 수 있습니다.
운영 체크리스트: ReAct·RAP에서 자주 터지는 지점
1) 관찰(Observation) 오염
- 툴 결과가 너무 길어 모델 컨텍스트를 오염시킴
- 로그에 민감정보가 섞여 그대로 모델에 전달됨
대응
- 관찰은 “원본”과 “요약본”을 분리 저장
- 모델에는 요약본만 전달
- 마스킹 규칙을 둔다(토큰, 키, 이메일 등)
2) 툴콜 실패와 재시도 정책
- 네트워크 오류, 429, 타임아웃
- 잘못된 파라미터로 인한 400
대응
- 실패 유형별 재시도 횟수 분리
idempotency key적용(변경 작업 툴은 특히 중요)- 툴 실패를 관찰로 남겨 다음 스텝에서 다른 경로를 선택하게
3) “모델이 도구만 계속 호출”하는 루프
대응
maxSteps제한은 필수- 동일 툴 동일 인자 반복 시 중단
- 비용 상한선(툴 비용, 토큰 비용) 적용
4) 디버깅 가능성 확보
Chain-of-Thought를 숨기면 디버깅이 어려워질 수 있습니다. 대신 다음을 남깁니다.
- 스텝별
tool call과tool result해시 - 파서 실패 원문(단, 민감정보 제거)
- 최종 답변과 사용된 근거 URL, 문서 ID
여기서 중요한 건 “중간 사고”가 아니라 “실제 실행과 근거”입니다. 장애 분석 글들이 보통 로그와 설정을 근거로 결론을 내리는 것처럼, 에이전트도 같은 방식으로 운영해야 합니다.
결론: CoT를 버리는 게 아니라, 구조로 대체하자
Chain-of-Thought를 사용자에게 노출하지 않는다고 해서 추론을 포기하는 것은 아닙니다. 오히려 실무에서는 다음이 더 중요합니다.
- ReAct로
행동-관찰루프를 만들고 - RAP로
후보-검증-수정루프를 추가해 안정성을 올리며 - 관찰 품질, 툴콜 스키마, 재시도 정책, 로깅을 제품 수준으로 다듬는 것
정리하면, “추론 텍스트”가 아니라 “검증 가능한 실행”이 품질을 만듭니다. ReAct와 RAP는 그 실행을 체계화하는 가장 현실적인 패턴입니다.