태스크 분해 — 긴 입력에서 앞부분이 사라질 때
시리즈 프롬프트를 코드처럼 다루기의 다섯 번째 글.
터진 문제
생활기록부는 짧지 않다. 교과 세특, 창체, 수상, 독서가 다 들어오면 입력이 길어진다. 그 긴 입력 뒤에 “추출하고, 분류하고, 추천하고, 근거를 인용해라”를 한 번에 시켰다.
증상은 일정했다. 뒤쪽 작업은 잘 되고 앞쪽 작업이 부실해졌다. 추천은 그럴듯한데, 그 근거가 될 추출 단계가 대충 넘어가 있었다.
여기서 구조화를 더 해봤지만 효과가 작았다. 구조화는 지시가 섞였을 때 듣는다. 이건 다른 문제였다. 한 번의 호출에 작업이 너무 많았다.
Before — 한 번에 다 시키기
<작업>
1. 생활기록부에서 활동을 모두 추출한다.
2. 추출한 활동을 학문 영역으로 분류한다.
3. 영역별 비중을 보고 학과 3개를 고른다.
4. 각 추천의 근거를 원문에서 인용한다.
5. 학생이 읽을 리포트 문장으로 정리한다.
한 호출로 5단계. 편하지만 문제가 둘이다.
- 어느 단계에서 틀렸는지 알 수 없다. 결과만 보고 역추적해야 한다.
- 1단계 품질이 나빠도 모델은 멈추지 않고 5단계까지 간다. 나쁜 입력 위에 그럴듯한 출력이 쌓인다.
After — 단계를 쪼개고 중간을 검사하기
def build_report(record_text: str) -> Report:
activities = extract_activities(record_text) # 1단계: 추출만
if len(activities) < MIN_ACTIVITIES:
raise InsufficientEvidence(activities)
domains = classify_domains(activities) # 2단계: 분류만
majors = recommend_majors(domains, activities) # 3단계: 선정만
return write_report(majors, record_text) # 4단계: 서술만
각 호출의 프롬프트는 짧아졌다. 작업이 하나뿐이라 지시도 몇 줄이면 됐다.
분해의 진짜 이득은 검사 지점
단계를 쪼개서 좋아진 건 모델 성능보다 내 관측력이었다.
activities = extract_activities(record_text)
if len(activities) < MIN_ACTIVITIES:
raise InsufficientEvidence(activities)
이 세 줄이 핵심이다. 한 번에 돌릴 때는 “근거가 부족한 생활기록부”와 “모델이 추출을 게을리한 경우”를 구분할 수 없었다. 쪼개고 나니 1단계 출력만 보면 구분이 됐다.
(단계별 중단 조건과 그 처리 방식을 적는다.)
단계를 나누는 기준
- 출력 형태가 바뀌는 지점에서 자른다. 리스트를 만드는 작업과 산문을 쓰는 작업은 한 호출에 두지 않는다.
- 검사하고 싶은 지점에서 자른다. 중간 결과를 볼 이유가 없으면 굳이 자르지 않는다.
- 실패했을 때 되돌리고 싶은 지점에서 자른다. 1단계만 재시도할 수 있으면 전체 재시도보다 싸다.
무엇이 좋아졌는가
(품질 변화와 함께 늘어난 지연·비용을 같이 적는다.)
이 기법이 안 통하는 경우
- 단계 간 맥락이 많이 필요할 때. 2단계가 1단계의 뉘앙스까지 알아야 하면, 쪼개는 순간 그 뉘앙스가 사라진다. 중간 산출물을 구조화하면 할수록 정보가 깎인다.
- 지연이 중요한 화면. 호출이 4번이면 체감 대기도 늘어난다. 사용자가 기다리는 화면이면 분해 단계를 줄이거나 스트리밍을 섞어야 했다.
- 작업이 원래 하나일 때. 억지로 쪼개면 단계마다 같은 입력을 다시 넣게 되고, 토큰만 중복된다.
정리
한 번에 다 시키는 프롬프트는 잘 될 때는 제일 싸고, 틀릴 때는 제일 비싸다. 틀린 지점을 모르기 때문이다. 분해는 품질을 올리는 기법이라기보다 디버깅 가능하게 만드는 기법에 가까웠다.
다음 글: 평가셋과 프롬프트 회귀 테스트 — 여기까지 한 걸 실제로 측정한 이야기.