핵심 한 줄
“그냥 LLM 호출”과 “advisor/MCP에 완전 위임” 둘 다 거부했다. LLM에 도구를 주되 네 개의 우리로 가뒀다 — 화이트리스트 도구 · 결정론 핸들러 · 루프 상한 5회 · graceful degrade. LLM은 “무엇을(WHAT)“만 정하고, “어떻게(HOW)“는 결정론 Java가 한다. 이것이 환각 차단선이다.
시리즈 — LLM을 장애대응 파이프에 안전하게 엮기
1. 배경 — 자율성 스펙트럼의 양 끝을 모두 버리다
장애대응 파이프에서 LLM은 수집된 컨텍스트를 읽고 분석을 내리는 노드다. 여기서 에이전트를 설계하는 두 극단이 있다.
- 순수 LLM 1회 호출: 데모는 되지만 에이전트 설계 역량을 못 보여준다. 도구도 못 쓴다.
- advisor/MCP에 완전 위임: 무한 루프, 환각된 도구 호출, 통제 불능 비용 위험.
이 프로젝트의 목표는 진단 정확도(비대상)가 아니라 통제 가능한 에이전트 설계 시연이다. 그래서 둘 다 버리고 그 사이를 택했다.
2. 결정
LLM에 도구를 주되 제한적 tool-calling으로 가둔다. 네 개의 우리(cage):
- 화이트리스트 도구 — GitHub 조회 + patch 제안 도구만 노출
- 결정론 핸들러 — 실제 실행은 LLM이 아니라 서비스 코드에서만
- 루프 상한 —
MAX_TOOL_ROUNDS=5 - 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은 파이프의 통제 가능한 한 노드가 된다.