자바로 배우는 AI (5) — Harness

“harness”는 정확한 기술 용어가 아니라 “모델 호출 주변의 잡일 전부” 를 뭉뚱그린 말이다. agent(정의 있음)나 MCP(스펙 있음)와 달라서, 헷갈리는 게 정상이다. 전체 코드: github.com/dadaok/ai-playground

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 되나?
AgentLLM API 호출 + 루프 + 툴, 제어권을 모델에게— (이게 agent 자체)
MCP툴/데이터를 표준 방식으로 공급하는 통로✅ 2·3편은 MCP 없이
Harnessagent를 돌리는 실행 골격 (루프 + 운영 장치)✅ 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줄 루프 하나를 완전히 이해하면 나머지는 저절로 정리된다.


© 2023 Lee. All rights reserved.