글 검색

공개된 글의 제목과 본문을 검색합니다.

    닫기 Esc

    개요#

    Spring AI의 기본 콘셉은 Structured Output이다.

    즉, AI의 Assistance Message를 POJO로 만들어 준다는 것인데 일반적인 사용법은 아래와 같다.

    java
    ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
            .user(u -> u.text("Generate the filmography of 5 movies for {actor}.")
                        .param("actor", "Tom Hanks"))
            .call()
            .entity(ActorsFilms.class);

    원리는 뭘까#

    위 프롬프트를 보면 알 수 있는 것 처럼 LLM의 요청에 객체의 힌트는 전혀없다.

    하다못해 JSON응답으로 달라고 지정도 안했는데 어떻게 POJO로 만들어 준다는 것일까?

    여기에 대한 답은 ChatClient의 구현체인 DefaultChatClient와 BeanOutputConverter 클래스를 그리고 최종 완성하는 Prompt 객체를 살펴보면 알 수 있다.

    ChatClient#

    Spring AI를 사용하다 보면 가장 많이 볼 수 있는 객체다.

    Interface인데 상당히 많은 기능을 가지고 있지만, 아무래도 주요 기능은 말 그대로 클라이언트 일것인데

    create 시에 인터페이스 내부에 구현된 DefaultChatClientBuilder 구현체에 의해서 DefaultChatClient가 생성된다.

    기본적인 채팅에 관한 기능들을 담고 있기 때문에 통신 시 가장 많이 이용된다.

    그런데 앞서 본 코드에서는 빌더 메시지를 통해 entity를 전달하면 자연스럽게 POJO로 연동해주는데 어떤 비밀이 숨어있는 걸까?

    BeanOutputConverter#

    먼저 알아야 하는게 당연하게도 위 프롬프트에 단 한줄도 ActorsFilms 클래스 구조를 LLM에게 전달한 적이 없다.

    하지만 Spring AI 프레임워크를 통해서 POJO로 응답받는데 이는 BeanOutputConverter 을 통해서 LLM에게 Class의 구조를 전달하여 JSON으로 전달받기 때문이다.

    애초에 이런 구조가 까보지 않으면 추상화가 높게 되어있어 모르고 이용할 수 있는데 이 정도 이용으로 토큰 이용이 기하 급수적으로 증가하지는 않을 수 있지만

    어디까지나 관리 차원에서 알아둬야 할것 같기는 하다.

    JSON으로 클래스 구조를 전달하는 방식은 상당히 간단한다.

    java
    public abstract class ParameterizedTypeReference<T> {
        private final Type type;
    
        protected ParameterizedTypeReference() {
            Class<?> parameterizedTypeReferenceSubclass = findParameterizedTypeReferenceSubclass(this.getClass());
            Type type = parameterizedTypeReferenceSubclass.getGenericSuperclass();
            Assert.isInstanceOf(ParameterizedType.class, type, "Type must be a parameterized type");
            ParameterizedType parameterizedType = (ParameterizedType)type;
            Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
            Assert.isTrue(actualTypeArguments.length == 1, "Number of type arguments must be 1");
            this.type = actualTypeArguments[0];
        }
    
        private ParameterizedTypeReference(Type type) {
            this.type = type;
        }
    
        public Type getType() {
            return this.type;
        }
    
        public boolean equals(@Nullable Object other) {
            boolean var10000;
            if (this != other) {
                label26: {
                    if (other instanceof ParameterizedTypeReference) {
                        ParameterizedTypeReference<?> that = (ParameterizedTypeReference)other;
                        if (this.type.equals(that.type)) {
                            break label26;
                        }
                    }
    
                    var10000 = false;
                    return var10000;
                }
            }
    
            var10000 = true;
            return var10000;
        }
    
        public int hashCode() {
            return this.type.hashCode();
        }
    
        public String toString() {
            return "ParameterizedTypeReference<" + String.valueOf(this.type) + ">";
        }
    
        public static <T> ParameterizedTypeReference<T> forType(Type type) {
            return new ParameterizedTypeReference<T>(type) {
            };
        }
    
        private static Class<?> findParameterizedTypeReferenceSubclass(Class<?> child) {
            Class<?> parent = child.getSuperclass();
            if (Object.class == parent) {
                throw new IllegalStateException("Expected ParameterizedTypeReference superclass");
            } else {
                return ParameterizedTypeReference.class == parent ? child : findParameterizedTypeReferenceSubclass(parent);
            }
        }
    }
    org.springframework.core

    각주를 보면 알겠지만 이는 Spring AI의 자체 기능이라기 보다는 Spring Core의 ParameterizedTypeReference를 사용하는 방식을 취하고 있다.

    복잡하게 생각 할 필요없이 모든 필드를 가져온다고 보면 된다.

    java
    
    	@Override
    	public String getFormat() {
    		String template = """
    				Your response should be in JSON format.
    				Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
    				Do not include markdown code blocks in your response.
    				Remove the ```json markdown from the output.
    				Here is the JSON Schema instance your output must adhere to:
    				```%s```
    				""";
    		return String.format(template, this.jsonSchema);
    	}

    그리고 이를 사용하는 BeanOutputConverter에서 이를 이용하는 getFormat 메소드를 통해서 Prompt에 추가된다고 보면 된다.

    마치며#

    결과적으로 위와 같은 메커니즘을 모른다면 왜 POJO로 변환하는 것인지 그리고 이를 통제하는 방법이나 오류에 대처할 수 없게된다.

    뭐 빠르게 발전하는 만큼이나 당연히 버그가 생기면 빠르게 고칠 것이고 토큰 또한 문제될 정도가 아니니 편의를 위해 굳이 알 필요가 없을지도 모르지만

    결국 개발자가 통제할 수 있는 레벨에서는 알아두는게 좋다고 생각한다.

    추가적으로 이런 거대한 프레임워크를 초창기부터 분석해볼 수 있다는게 참 재밌는거 같다.

    안그래도 복잡도가 높은 프레임워크에서 더 복잡해 진다면 사실 엄두도 안날텐데 친숙한 기능 위주로 분석을 시작하니 재미가 쏠쏠하다.

    전체 글 둘러보기