에이전트 = 기록 가능한 상태 전이다
M1에서는 /api/chat 엔드포인트를 하나 만들었다.
사용자가 메시지를 보내면 service가 Spring AI ChatClient를 호출하고, 문자열 응답을 돌려주는 구조였다.
이 단계의 목적은 에이전트를 만드는 것이 아니라, 애플리케이션 안에서 LLM을 호출하는 가장 작은 통로를 확인하는 것이었다.
M2에서는 앞으로 AgentRuntime에서 계속 쓰게 될 기본 언어를 먼저 정했다.
- AgentMessage
- AgentEvent
- AgentRunOptions
- AgentPromptCommand
- IdGenerator
아직 아무 일도 못 하는데 왜 타입부터 만들까
“아직 파일도 못 읽고, 코드도 못 고치고, 테스트도 못 돌리는데 왜 타입부터 만들지?”라고 생각할 수 있다.
중요한 것은 그 과정이 기록 가능해야 한다는 점이다.
- 어떤 사용자 메시지가 들어왔는지
- 모델이 어떤 응답을 만들었는지
- 어떤 도구 호출이 시작되었는지
- 도구 호출이 성공했는지 실패했는지
- 정책 때문에 도구 호출이 거부되었는지
- 어디에서 에러가 났는지
이 정보가 남아야 나중에 디버깅도 하고, replay도 하고, 평가도 할 수 있다.
Agent를 상태 전이로 보기
처음 에이전트를 보면 마법처럼 느껴진다.
AI가 README를 읽고
AI가 코드를 수정하고
AI가 테스트를 실행하고
AI가 결과를 설명한다
사용자 입장에서는 이렇게 보여도 괜찮다.
하지만 개발자 입장에서는 조금 다르게 봐야 한다.
사용자 메시지가 추가된다
→ agent 실행이 시작된다
→ 모델 응답 조각이 흘러온다
→ 도구 호출이 시작된다
→ 도구 호출이 끝난다
→ assistant 메시지가 완료된다
→ agent 실행이 끝난다
이렇게 보면 에이전트도 결국 상태가 변하는 시스템이다.
주문 시스템을 예로 들면 조금 비슷하다.
ORDER_CREATED
→ PAYMENT_REQUESTED
→ PAYMENT_APPROVED
→ SHIPMENT_REQUESTED
→ SHIPPED
주문 시스템에서 이벤트를 남기듯, 에이전트도 실행 과정을 이벤트로 남긴다.
이번 M2에서 잡은 이벤트 이름은 다음과 같다.
agent_started
user_message_appended
assistant_delta
assistant_completed
tool_call_started
tool_call_finished
tool_call_denied
agent_error
agent_completed
물론 M2에서 이 흐름을 실제로 실행하지는 않는다. 아직 AgentRuntime도 없고, SSE endpoint도 없다.
중요한 것은 관점이다.
에이전트를 “LLM이 알아서 해주는 것”으로 보면 구조를 잡기 어렵다. 반대로 “메시지와 이벤트가 쌓이면서 상태가 바뀌는 시스템”으로 보면 Spring 개발자에게 익숙한 방식으로 다룰 수 있다.
Message와 Event를 구분하기
M2에서 가장 먼저 정리한 개념은 Message와 Event다.
처음에는 둘이 비슷해 보인다. 둘 다 시간순으로 쌓이고, 둘 다 JSON으로 저장될 수 있다.
하지만 역할이 다르다.
Message는 대화나 작업의 본문에 가깝다. 나중에 모델에게 다시 넣거나, 세션을 복원하거나, replay할 때 필요한 기록이다.
이번 M2에서는 세 가지 메시지만 만들었다.
public sealed interface AgentMessage
permits UserMessage, AssistantMessage, ToolResultMessage {
AgentMessageKind kind();
String id();
String sessionId();
String parentId();
Instant createdAt();
}
UserMessage는 사용자가 보낸 입력이다.
public record UserMessage(
String id,
String sessionId,
String parentId,
Instant createdAt,
String text
) implements AgentMessage {
...
}
AssistantMessage는 모델이 만든 응답이다.
public record AssistantMessage(
String id,
String sessionId,
String parentId,
Instant createdAt,
String text,
AssistantStopReason stopReason
) implements AgentMessage {
...
}
ToolResultMessage는 도구 실행 결과를 모델에게 다시 전달하거나 세션에 남기기 위한 메시지다.
public record ToolResultMessage(
String id,
String sessionId,
String parentId,
Instant createdAt,
String toolCallId,
String toolName,
String resultText,
boolean error
) implements AgentMessage {
...
}
반면 Event는 실행 중 관찰 가능한 신호다.
예를 들어 agent 실행이 시작되었다거나, assistant 응답 조각이 왔다거나, 도구 호출이 거부되었다는 사실은 event로 표현한다.
public sealed interface AgentEvent permits
AgentStartedEvent,
AgentCompletedEvent,
UserMessageAppendedEvent,
AssistantDeltaEvent,
AssistantCompletedEvent,
ToolCallStartedEvent,
ToolCallFinishedEvent,
ToolCallDeniedEvent,
AgentErrorEvent {
AgentEventType type();
String eventId();
String sessionId();
Instant createdAt();
}
이 구분이 중요하다.
Message는 대화의 본문이다. Event는 진행 상황이다.
나중에 SSE를 붙이면 event가 클라이언트로 흘러갈 수 있다.
agent_started
assistant_delta
tool_call_started
tool_call_finished
assistant_completed
agent_completed
나중에 JSONL session store를 붙이면 message와 event를 각각 append-only log로 남길 수 있다.
M2는 아직 저장하지 않는다. 대신 무엇을 저장할지 먼저 정한다.
이번 M2에서 ToolCall 타입을 따로 만들지 않은 이유
이번 M2에서는 도구와 관련해 최소한만 남겼다.
- ToolResultMessage: 도구 실행 결과를 메시지로 남길 자리
- ToolCallStartedEvent: 도구 호출 시작 이벤트
- ToolCallFinishedEvent: 도구 호출 완료 이벤트
- ToolCallDeniedEvent: 정책에 의해 도구 호출이 거부된 이벤트
- IdGenerator.toolCallId(): tool call을 추적할 ID 생성
이 정도면 다음 단계로 넘어가기에 충분하다.
아직 실제 read, ls, grep, bash를 만들지 않았으므로 tool model을 더 깊게 추상화하지 않았다.
실행 옵션은 안전한 기본값부터
에이전트는 나중에 파일을 읽고, 코드를 쓰고, 명령을 실행할 수 있다.
그래서 실행 옵션의 기본값은 처음부터 보수적으로 잡았다.
public record AgentRunOptions(
boolean allowWrite,
boolean allowBash,
int maxTurns,
int maxToolCalls,
Duration timeout
) {
public static AgentRunOptions safeDefaults() {
return new AgentRunOptions(false, false, 6, 12, Duration.ofSeconds(120));
}
}
기본값은 다음과 같다.
allowWrite = false
allowBash = false
maxTurns = 6
maxToolCalls = 12
timeout = 120 seconds
이 값들은 아직 실제 runtime에서 쓰이지 않는다. 하지만 M8에서 AgentRuntime을 만들 때 이 옵션이 실행 경계를 정하게 된다.
특히 allowWrite=false, allowBash=false가 중요하다.
이 프로젝트의 기본 방향은 read-only agent를 먼저 만들고, 쓰기와 bash는 나중에 명시적으로 열어주는 것이다.
AgentPromptCommand는 런타임 입력 계약이다
M2에서는 AgentPromptCommand도 만들었다.
public record AgentPromptCommand(
String sessionId,
String userText,
Path workspaceRoot,
AgentRunOptions options
) {
...
}
이 타입은 나중에 AgentRuntime.prompt(...)에 들어갈 입력 계약이다.
여기에는 네 가지 정보가 들어간다.
- 어떤 session에서 실행되는가
- 사용자가 어떤 text를 보냈는가
- 어떤 workspace를 기준으로 실행되는가
- 어떤 실행 옵션을 적용할 것인가
아직 AgentRuntime은 없다.
하지만 런타임을 만들기 전에 입력 모양을 고정해두면 다음 단계에서 API, session store, runtime을 연결할 때 기준점이 생긴다.
ID는 사람이 읽을 수 있게
세션과 메시지와 이벤트는 나중에 JSONL 파일에 저장된다.
그래서 ID도 처음부터 조금 읽기 쉽게 만들었다.
public final class IdGenerator {
public static String sessionId() {
return prefixed("s");
}
public static String messageId() {
return prefixed("m");
}
public static String eventId() {
return prefixed("e");
}
public static String toolCallId() {
return prefixed("tc");
}
}
결과는 이런 식이다.
s_...
m_...
e_...
tc_...
UUID만 던져도 동작은 한다. 하지만 JSONL을 사람이 열어볼 때는 prefix가 있으면 훨씬 읽기 쉽다.
MVP의 session store는 사람이 직접 열어봐도 이해 가능해야 한다. 그래서 여기서부터 작은 가독성을 챙겼다.
record라고 해서 아무 값이나 받아도 되는 것은 아니다
처음에는 record만 만들면 충분해 보인다.
하지만 세션 로그에 null ID나 빈 tool name이 들어가면 나중에 원인을 찾기 어려워진다.
그래서 이번 M2에서는 compact constructor로 필수값 검증을 넣었다.
예를 들어 AgentPromptCommand는 빈 sessionId, 빈 userText, null workspaceRoot, null options를 거부한다.
public AgentPromptCommand {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId must not be blank");
}
if (userText == null || userText.isBlank()) {
throw new IllegalArgumentException("userText must not be blank");
}
Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null");
Objects.requireNonNull(options, "options must not be null");
}
메시지와 이벤트 record도 같은 방향으로 검증한다.
빈 ID는 받지 않는다. null createdAt도 받지 않는다. 도구 이름, call id, denial reason처럼 나중에 디버깅에 필요한 필드도 비워두지 않는다.
이건 복잡한 validation framework가 아니다. 그냥 생성 시점에 말이 안 되는 값을 막는 정도다.
JSON 테스트는 문자열 포함 여부만 보지 않는다
M2의 테스트는 비즈니스 로직 테스트라기보다 타입 계약 테스트에 가깝다.
이 타입들은 나중에 다음 위치에 쓰일 수 있다.
- JSONL session log
- SSE event payload
- API response
- debugging log
그러면 JSON 모양이 안정적이어야 한다.
처음에는 "type":"agent_started" 같은 문자열이 포함되는지만 확인할 수도 있다. 하지만 그 방식은 약하다.
예를 들어 같은 field가 중복으로 나가거나, 예상하지 못한 field가 끼어들어도 테스트가 통과할 수 있다.
그래서 이번 테스트에서는 JsonNode로 변환한 뒤 field set과 값을 같이 확인했다.
JsonNode startedJson = objectMapper.valueToTree(started);
assertThat(fieldNames(startedJson))
.containsExactlyInAnyOrder("type", "eventId", "sessionId", "createdAt");
assertThat(startedJson.get("type").asText()).isEqualTo("agent_started");
assertThat(startedJson.get("eventId").asText()).isEqualTo("e_1");
assertThat(startedJson.get("sessionId").asText()).isEqualTo("s_1");
이 테스트는 다음을 확인한다.
- type 값이 안정적인가
- 필요한 field가 모두 있는가
- 예상하지 않은 field가 섞이지 않았는가
- Java time이 timestamp 숫자가 아니라 ISO 문자열로 나가는가
M3에서 JSONL session store를 만들 때 이 테스트가 기준이 된다.
이번 단계에서 하지 않은 것
M2에서는 일부러 하지 않은 일이 많다.
- AgentRuntime 구현 안 함
- SessionStore 구현 안 함
- JSONL 저장 안 함
- 실제 LLM 호출과 연결 안 함
- Spring AI tool calling 연결 안 함
- read/write/bash 도구 구현 안 함
- SSE endpoint 구현 안 함
- 독립 ToolCall, 독립 ToolResult 타입을 미리 만들지 않음
- sealed interface 다형성 역직렬화를 미리 붙이지 않음
이 제한이 중요하다.
상태 전이를 이야기하면 바로 저장소를 만들고 싶어진다. JSONL로 남길지, DB에 넣을지, replay를 어떻게 할지 고민하게 된다.
하지만 M2는 저장 기술을 고르는 단계가 아니다. 저장할 언어를 정하는 단계다.
이번 단계의 완료 기준
M2가 끝났다고 말하기 위해 다음을 확인했다.
- AgentMessage sealed interface가 있다.
- UserMessage, AssistantMessage, ToolResultMessage가 있다.
- AgentEvent sealed interface가 있다.
- runtime, assistant, tool call, denial, error event record가 있다.
- AgentRunOptions.safeDefaults()가 read-only 기본값을 갖는다.
- AgentPromptCommand가 runtime 입력 계약을 표현한다.
- IdGenerator가 session/message/event/tool-call ID를 만든다.
- JSON serialization 테스트가 있다.
- invalid required field 테스트가 있다.
- 외부 LLM API 없이 ./gradlew.bat test가 통과한다.
이번 단계에서 중요한 테스트는 다음 두 가지였다.
./gradlew.bat test --tests "com.example.pispringai.agent.*" --console=plain
./gradlew.bat test --console=plain
테스트는 실제 Gemini나 OpenAI API를 호출하지 않는다.
M2는 모델 호출을 늘리는 단계가 아니라, 앞으로 모델 호출과 도구 실행을 기록할 타입 계약을 만드는 단계다.
마무리
M2에서 만든 것은 눈에 띄는 기능이 아니다.
사용자 입장에서는 아직 코딩 에이전트처럼 느껴지지 않는다. 여전히 /api/chat은 M1에서 만든 단순 chat endpoint다.
하지만 내부적으로는 중요한 바닥을 깔았다.
에이전트는 결국 기록이다.
어떤 메시지가 들어왔고, 어떤 응답이 생성되었고, 어떤 도구 호출이 시도되었고, 어떤 결과가 돌아왔는지 기록할 수 있어야 한다.
그 기록 가능한 구조가 있어야 나중에 session store, replay, debugging, evaluation을 붙일 수 있다.
M2는 그 언어를 정한 단계다.
다음 M3에서는 이 타입들을 실제 JSONL 파일로 저장하고 다시 읽는 SessionStore를 만들 차례다.
[codex] Add M2 agent domain model by dd3ok · Pull Request #4 · dd3ok/pi-spring-ai
'개발 > AI' 카테고리의 다른 글
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (4) - 파일 읽기 전 안전 장치 (0) | 2026.06.29 |
|---|---|
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (3) - 작업 일지 기록 (0) | 2026.06.29 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (1) - chat 붙이기 (0) | 2026.06.26 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (0) - init (0) | 2026.06.26 |
| 에이전트 토큰을 줄이는 방법 (Caveman vs RTK) (0) | 2026.05.26 |