개요
Spring AI의 기본 콘셉은 Structured Output이다.
즉, AI의 Assistance Message를 POJO로 만들어 준다는 것인데 일반적인 사용법은 아래와 같다.
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);
Java
복사
원리는 뭘까
위 프롬프트를 보면 알 수 있는 것 처럼 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으로 클래스 구조를 전달하는 방식은 상당히 간단한다.
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);
}
}
}
Java
복사
org.springframework.core
각주를 보면 알겠지만 이는 Spring AI의 자체 기능이라기 보다는 Spring Core의 ParameterizedTypeReference를 사용하는 방식을 취하고 있다.
복잡하게 생각 할 필요없이 모든 필드를 가져온다고 보면 된다.
@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);
}
Java
복사
그리고 이를 사용하는 BeanOutputConverter에서 이를 이용하는 getFormat 메소드를 통해서 Prompt에 추가된다고 보면 된다.
마치며
결과적으로 위와 같은 메커니즘을 모른다면 왜 POJO로 변환하는 것인지 그리고 이를 통제하는 방법이나 오류에 대처할 수 없게된다.
뭐 빠르게 발전하는 만큼이나 당연히 버그가 생기면 빠르게 고칠 것이고 토큰 또한 문제될 정도가 아니니 편의를 위해 굳이 알 필요가 없을지도 모르지만
결국 개발자가 통제할 수 있는 레벨에서는 알아두는게 좋다고 생각한다.
추가적으로 이런 거대한 프레임워크를 초창기부터 분석해볼 수 있다는게 참 재밌는거 같다.
안그래도 복잡도가 높은 프레임워크에서 더 복잡해 진다면 사실 엄두도 안날텐데 친숙한 기능 위주로 분석을 시작하니 재미가 쏠쏠하다.