자바로 배우는 AI (5) — Harness
“harness”는 정확한 기술 용어가 아니라 “모델 호출 주변의 잡일 전부” 를 뭉뚱그린 말이다. agent(정의 있음)나 MCP(스펙 있음)와 달라서, 헷갈리는 게 정상이다. 전체 코드: github.com/dadaok/ai-playground
- 3단계 루프 vs 6단계 harness
- 미니 harness 구현
- 실행 로그 — 각 장치가 작동하는 순간
- 전체 그림 — 5편이 다 이어진다
- agent · MCP · harness — 서로 다른 축
- 진짜 harness = Claude Code
- 정리 — 시리즈 전체
3단계 루프 vs 6단계 harness
2편의 3단계는 “맨몸 에이전트 루프”였다. 모델 호출 + 루프. 그게 전부.
harness는 그 루프에 “모델한테 묻는 것과 무관한” 코드 덩어리들을 두른 것이다.
| harness에 추가된 것 | 이게 지능인가? | 정체 |
|---|---|---|
| 툴이 뭐가 있고 어디서 오는지 관리 (레지스트리) | ❌ | 운영 |
| 위험한 작업 전 사용자에게 확인 (권한 게이트) | ❌ | 운영 |
| 대화 길어지면 요약해서 줄이기 (컨텍스트 관리) | ❌ | 운영 |
| 최대 턴 수 제한 (턴 예산) | ❌ | 운영 |
이 부품들은 “더 똑똑하게 답하기”와 무관하다. 전부 “현실에서 안전하고 지속가능하게 굴리기” 위한 배관이다.
자바 개발자 비유: 모델 호출 = 비즈니스 로직 doWork(), harness = 그걸 감싸는 애플리케이션 서버/프레임워크. 엔드포인트 하나 짤 때마다 Tomcat을 새로 안 만드는 것과 같다.
미니 harness 구현
Step6Harness 는 위 4가지를 직접 만들어 harness가 뭘 더 해주는지 보여준다. 게다가 calculator 도구를 4편의 MCP 서버에서 가져온다 (모든 편이 여기서 이어진다).
(1) 툴 레지스트리 — 로컬 툴 + MCP 툴 통합
record RegisteredTool(String name, String description, JsonNode inputSchema,
boolean requiresApproval, Function<JsonNode, String> execute) {}
// 로컬 툴
register(new RegisteredTool("save_note", "메모를 notes/ 폴더에 파일로 저장한다.",
saveNoteSchema, true /* 파일 쓰기라 승인 필요 */, args -> { ... }));
// MCP 서버(4편)의 툴을 그대로 편입
for (McpStdioClient.McpTool t : mcp.listTools()) {
register(new RegisteredTool(t.name(), t.description(), t.inputSchema(),
false /* 계산기는 안전 → 자동 실행 */,
args -> mcp.callTool(t.name(), M.convertValue(args, Map.class))));
}
(2) 권한 게이트
if (tool.requiresApproval()) {
System.out.println(" ⚠️ [harness] 승인 요청 — 툴 '" + call.name() + "' 실행");
System.out.println(" 인자: " + args);
System.out.print(" 실행할까요? [y/N] ");
if (!"y".equalsIgnoreCase(readLine().trim())) {
return "사용자가 실행을 거부했습니다.";
}
}
(3) 컨텍스트 관리 (compaction)
대화가 임계치를 넘으면 과거를 LLM으로 요약해 교체한다. 1편에서 본 “매 요청마다 전체 messages를 다시 보낸다”는 사실 때문에 필요해진 장치다.
private void maybeCompact() {
if (estimateChars() < COMPACT_THRESHOLD_CHARS || conversation.size() <= 3) return;
List<MessageParam> old = conversation.subList(0, conversation.size() - 2); // 최근 2개는 원본 유지
Message summary = client.messages().create(MessageCreateParams.builder()
.model(MODEL).maxTokens(400L)
.system("아래 대화 로그를 이후 작업에 필요한 사실만 남겨 5줄 이내로 요약하라.")
.addUserMessage(dump(old))
.build());
// old 전체를 "[이전 대화 요약] ..." 한 개로 치환
conversation.clear();
conversation.add(userMessage("[이전 대화 요약]\n" + text(summary)));
conversation.addAll(recentTwo);
System.out.println("[harness] 🗜 컨텍스트 압축: " + before + "자 → " + estimateChars() + "자");
}
(4) 루프 + 턴 예산
for (int turn = 1; turn <= MAX_TURNS; turn++) {
maybeCompact(); // (3)
MessageCreateParams.Builder params = MessageCreateParams.builder()
.model(MODEL).maxTokens(1024L)
.system("너는 자동화 비서다. 필요한 툴을 사용해 목표를 완수하라.")
.messages(conversation);
for (Tool t : anthropicTools()) params.addTool(t); // (1) 레지스트리 → Anthropic 툴 목록
Message response = client.messages().create(params.build());
conversation.add(response.toParam());
if (!response.stopReason().equals(Optional.of(StopReason.TOOL_USE))) return; // 완료
List<ContentBlockParam> results = new ArrayList<>();
for (ContentBlock block : response.content()) {
if (block.toolUse().isEmpty()) continue;
ToolUseBlock call = block.toolUse().get();
results.add(ContentBlockParam.ofToolResult(ToolResultBlockParam.builder()
.toolUseId(call.id())
.content(dispatch(call)) // (1)+(2) 레지스트리 조회 + 권한 게이트
.build()));
}
conversation.add(MessageParam.builder()
.role(MessageParam.Role.USER).contentOfBlockParams(results).build());
}
실행 로그 — 각 장치가 작동하는 순간
목표: “128×7 계산하고 그 결과를 answer.txt 메모로 저장해줘”
[harness] MCP 서버 연결: java -jar build/libs/mcp-calc-server.jar
[harness] 툴 2개 등록: [save_note, calculator] ← (1) 레지스트리 (로컬 + MCP)
── turn 1 ──
[harness] 툴 실행: calculator {"op":"*","a":128,"b":7} ← MCP 서버로 위임, 자동 실행
[harness] 결과: 896.0
── turn 2 ──
⚠️ [harness] 승인 요청 — 툴 'save_note' 실행 ← (2) 권한 게이트
인자: {"filename":"answer.txt","text":"896"}
실행할까요? [y/N] y
[harness] 결과: 저장됨: notes/answer.txt
[harness] 목표 완료.
전체 그림 — 5편이 다 이어진다
Step6Harness (harness)
├─ 2편 에이전트 루프 (LLM 호출 → tool_use → 실행 → 반복)
├─ 권한 게이트 ← 2편의 tool_use 를 가로채서
├─ 컨텍스트 압축 ← 1편 messages 배열이 커지는 문제 해결
├─ 레지스트리
│ ├─ save_note (로컬 툴, 2편 방식)
│ └─ calculator ──McpStdioClient──▶ 4편 MCP 서버
└─ (RAG 를 붙이려면 3편 검색을 툴로 등록하면 끝)
agent · MCP · harness — 서로 다른 축
시리즈 내내 헷갈렸던 부분을 마지막으로 정리한다.
| 개념 | 한 문장 | 없어도 agent 되나? |
|---|---|---|
| Agent | LLM API 호출 + 루프 + 툴, 제어권을 모델에게 | — (이게 agent 자체) |
| MCP | 툴/데이터를 표준 방식으로 공급하는 통로 | ✅ 2·3편은 MCP 없이 |
| Harness | agent를 돌리는 실행 골격 (루프 + 운영 장치) | ✅ 2편은 harness 없이 직접 루프 |
- Agent = 제어 흐름 (모델이 다음 행동을 정함)
- MCP = 툴 공급 방식 (외부 프로세스를 표준 규격으로 연결)
- Harness = agent를 돌리는 프레임워크 (그 안에서 내장 툴 + MCP 툴을 씀)
진짜 harness = Claude Code
지금 개발할 때 쓰는 Claude Code가 바로 harness다. 규모만 훨씬 클 뿐, 이번에 만든 4가지를 실물로 확인할 수 있다.
/context→ 컨텍스트 관리- 파일 수정 시 뜨는 승인 프롬프트 → 권한 게이트
/compact→ 수동 컨텍스트 압축/mcp→ 연결된 MCP 서버 상태- 서브에이전트 → harness가 하위 agent를 또 돌리는 것
Claude Agent SDK = 이 Claude Code를 라이브러리로 쓰는 것 (현재 자바 미지원, Python/TS).
정리 — 시리즈 전체
밑바닥엔 딱 하나만 있다.
반복:
응답 = LLM API 호출(대화 + 툴 목록)
if 응답에 tool_use 있음: 툴 실행 → 결과를 대화에 추가 → 계속
else: 끝
나머지 용어는 전부 이 루프의 각 부품을 “누가 채우냐” 를 부르는 말이다.
- Tool = 루프 안 “툴 실행” 자리에 꽂히는 것
- RAG = 그 툴/프롬프트로 외부 지식을 넣는 방식
- MCP = 툴을 외부 프로세스에서 표준으로 가져오는 배달 방식
- Harness = 이 루프를 내가 안 짜고 대신 돌려주는 프로그램
새 개념을 자꾸 쌓기보다, 이 20줄 루프 하나를 완전히 이해하면 나머지는 저절로 정리된다.