핵심 한 줄

“그냥 LLM 호출”과 “advisor/MCP에 완전 위임” 둘 다 거부했다. LLM에 도구를 주되 네 개의 우리로 가뒀다 — 화이트리스트 도구 · 결정론 핸들러 · 루프 상한 5회 · graceful degrade. LLM은 “무엇을(WHAT)“만 정하고, “어떻게(HOW)“는 결정론 Java가 한다. 이것이 환각 차단선이다.

시리즈 — LLM을 장애대응 파이프에 안전하게 엮기


1. 배경 — 자율성 스펙트럼의 양 끝을 모두 버리다

장애대응 파이프에서 LLM은 수집된 컨텍스트를 읽고 분석을 내리는 노드다. 여기서 에이전트를 설계하는 두 극단이 있다.

  • 순수 LLM 1회 호출: 데모는 되지만 에이전트 설계 역량을 못 보여준다. 도구도 못 쓴다.
  • advisor/MCP에 완전 위임: 무한 루프, 환각된 도구 호출, 통제 불능 비용 위험.

이 프로젝트의 목표는 진단 정확도(비대상)가 아니라 통제 가능한 에이전트 설계 시연이다. 그래서 둘 다 버리고 그 사이를 택했다.


2. 결정

LLM에 도구를 주되 제한적 tool-calling으로 가둔다. 네 개의 우리(cage):

  1. 화이트리스트 도구 — GitHub 조회 + patch 제안 도구만 노출
  2. 결정론 핸들러 — 실제 실행은 LLM이 아니라 서비스 코드에서만
  3. 루프 상한MAX_TOOL_ROUNDS=5
  4. graceful degrade — 어디서 깨져도 placeholder Analysis로 흡수

3. 대안 비교

방식자율성위험에이전트 설계 시연채택
순수 LLM 1회 호출없음낮음약함 (도구 없음)X
advisor/MCP 완전 위임높음무한 루프·환각 호출·비용 폭주강하나 통제 불가X
프레임워크 내장 tool 루프루프 제어를 프레임워크가 쥠X
직접 굴리는 제한적 루프중(의도적 하향)상한·화이트리스트로 통제강함O

4. 선택 이유

tool-calling 루프를 프레임워크에 위임하지 않고 직접 손에 쥐었다. ToolCallingChatOptions에 도구를 화이트리스트로 명시하고 internalToolExecutionEnabled(false)로 실행 루프를 내 코드로 격리한다. 루프는 5회로 막고, 실제 도구 실행(GitHub HTTP 호출 등)은 LLM이 아니라 GithubChangeService/PatchProposalTool 안에서만 일어난다.

핵심 원칙: 모델은 “무엇을 부를지(WHAT)“만 정하고, “어떻게 부를지(HOW)“는 결정론 코드가 한다. LLM이 도구 호출을 결정해도, 그 호출의 실제 수행·검증은 전부 Java 안이라 환각이 부작용으로 새어나갈 수 없다 — 이것이 환각 차단선이다.


5. 설계 상세

루프를 직접 돌리는 구조:

// analyzer/TriageAgent.java
ToolCallingChatOptions opts =
    ToolCallingChatOptions.builder()
        .toolCallbacks(ToolCallbacks.from(githubTool, patchTool))  // ① 화이트리스트
        .internalToolExecutionEnabled(false)                       // ② 실행을 내 루프로 격리
        .build();
 
try {
  Prompt prompt = new Prompt(messages, opts);
  ChatResponse resp = chatModel.call(prompt);
  int rounds = 1;
  while (resp.hasToolCalls() && rounds < MAX_TOOL_ROUNDS) {       // ③ 루프 상한 5
    ToolExecutionResult exec = toolCallingManager.executeToolCalls(prompt, resp);
    prompt = new Prompt(exec.conversationHistory(), opts);
    resp = chatModel.call(prompt);
    rounds++;
  }
  analysis = converter.convert(extractJson(resp.getResult().getOutput().getText()));
} catch (Exception e) {                                            // ④ graceful degrade
  analysis = new Analysis("분석 미완 — tool-loop 상한 또는 변환 실패", "수동 확인 필요", List.of());
}

네 개의 우리 자세히

  • ① 화이트리스트 (toolCallbacks=ToolCallbacks.from(githubTool, patchTool)): 이번 호출에 줄 도구 목록을 코드가 못 박는다. @Tool은 “호출 가능 표식”일 뿐, 실제로 노출되는 건 여기 적힌 것뿐.
  • ② 실행 격리 (internalToolExecutionEnabled(false)): 도구 실행을 프레임워크에 맡기지 않고 executeToolCalls를 내가 호출. 루프의 모든 반복을 내가 통제.
  • ③ 루프 상한 (MAX_TOOL_ROUNDS=5): 모델이 도구를 무한 호출해도 5회에서 끊긴다.
  • ④ degrade: 모델 호출·도구 루프·출력 변환 어디서 깨져도 placeholder Analysis로 흡수 → 동기 webhook과 POST /triage가 절대 500을 안 낸다.

환각 차단선 — 도구 레벨에서도

도구 자체도 안전하게 설계했다.

  • GithubTool: 생성자에서 조회 기준 시각을 고정(시간 환각 차단), 실패 시 throw가 아니라 문자열 반환 → 모델이 읽고 진행, 분석이 안 죽는다.
  • PatchProposalTool.readFile: 빈 경로/절대 경로/..를 결정론으로 차단. 진짜 안전판은 단일 repo GitHub API만 쓰고 로컬 FS는 안 건드림 = scope 축소.

출력 계약은 프롬프트가 아니라 코드가 강제

BeanOutputConverter<Analysis> converter = new BeanOutputConverter<>(Analysis.class);
// ...
analysis = converter.convert(extractJson(resp.getResult().getOutput().getText()));

extractJson은 모델이 JSON을 ```json 펜스나 산문으로 감싸도 첫 {~마지막 }만 뽑아내는 가드다. “JSON으로 답해줘”라는 부탁이 아니라 타입 변환으로 계약을 강제한다.

근거: analyzer/TriageAgent.java, AnalyzerConfig, GithubTool, PatchProposalTool, ADR-0004.


6. 결과 / 트레이드오프

보장된 것

  • 모델이 도구를 무한 호출·환각해도 5회 상한·화이트리스트·결정론 실행으로 통제된다.
  • 어느 단계가 깨져도 degraded Analysis로 흡수 → 파이프가 500을 안 낸다.

감수한 한계

  • 자율성을 의도적으로 낮췄다 — 복잡한 다단계 추론은 못 한다.
  • 하지만 목표가 진단 정확도가 아니라 통제 가능성이라, 자율성보다 예측가능성·안전을 택한 트레이드오프다.

7. 교훈

에이전트 설계 = 자율성의 경계 긋기

“도구를 얼마나 줄까”가 아니라 **“자율성을 어디까지 허용할까”**가 에이전트 설계의 본질이다. 화이트리스트·루프 상한·결정론 핸들러로 LLM을 가두고, “무엇을”만 모델이 정하게 하면, LLM은 파이프의 통제 가능한 한 노드가 된다.


관련 글