LLMOps 28

Agent 출력, '느낌' 대신 '코드'로 평가하기

평가라고 하면 사람이 결과를 읽고 느낌으로 점수 주는 것만 떠올리기 쉽다.

근거 · 교안 p101-p106

Agent 출력, '느낌' 대신 '코드'로 평가하기 대표 이미지

1장: 루나: 평가는 ‘느낌’이 아니라 ‘코드’로 시작해요

솔라의 손가락이 노트북 트랙패드 위에서 멈칫했다. 화면에는 방금 막 읽기 시작한 기술 문서가 떠 있었다. 여러 문장들 사이에서 유독 한 문장이 눈에 걸렸다. 아는 단어들의 조합이었지만, 솔라가 평소 생각하던 의미와는 어딘가 어긋나는 느낌이었다.

“평가… 함수… 검증…”

솔라는 문장을 소리 내어 다시 읽어보았다.

"테스트 데이터셋을 구성하고, 영화 추천 Agent의 출력이 리스트 안 딕셔너리 2개 형식을 지키는지 규칙 평가 함수로 검증한다."

잠시 생각에 잠겨 있던 솔라는 거실 테이블 맞은편에 앉아 있는 언니 루나를 불렀다.

“언니, 나 뭐 하나만 물어볼게. Agent를 ‘평가’한다고 하면 보통 결과물이 얼마나 좋은지, 얼마나 똑똑한지 같은 걸 사람이 보고 판단하는 거 아니야? ‘이 대답은 마음에 드네’, ‘이건 좀 엉뚱한데’ 하면서 점수를 매기는 것처럼 말이야.”

루나는 고개를 들어 솔라를 바라보았다.

“응, 보통은 그렇게 생각하기 쉽지.”

“그런데 이 문장은 좀 이상해.” 솔라가 자신의 노트북 화면을 가리켰다. “마치 기계가 정해진 답을 채점하는 것처럼 말하잖아. ‘리스트 안 딕셔너리 2개 형식’을 ‘규칙 평가 함수로 검증’한다니. 평가는 원래 주관적인 느낌 같은 거 아니었어?”

솔라의 말에는 ‘평가’란 본질적으로 사람이 자신의 인상과 경험을 바탕으로 내리는 판단이라는 믿음이 묻어 있었다. 좋은 글을 평가할 때처럼, 정답이 없는 영역에 대한 섬세한 감식안 같은 것 말이다. 하지만 화면 속 문장은 평가를 마치 수학 문제 풀이처럼 다루고 있었다.

루나는 잠시 생각하더니, 말없이 자신의 노트북을 열어 무언가를 화면에 띄웠다. 그리고 노트북을 돌려 솔라에게 보여주었다.

화면에는 ‘영화 추천 Agent 출력 예시’라는 제목 아래 두 개의 텍스트 블록이 나란히 놓여 있었다.

[ 출력 A ]

[
  {"rank": 1, "title": "인셉션", "reason": "꿈이라는 독창적 소재를 복잡하지만 흥미로운 서사로 풀어냈습니다."},
  {"rank": 2, "title": "매트릭스", "reason": "가상현실과 현실의 경계에 대한 철학적 질문을 던지는 액션의 걸작입니다."}
]

[ 출력 B ]

음.. 제가 추천해 드릴 첫 번째 영화는 바로 인셉션! 진짜 재밌어요. 그리고 또 하나는, 매트릭스도 빼놓을 수 없죠. 이건 액션이 정말 최고예요.

“어떤 출력이 더 나아 보여?” 루나가 물었다.

“음, 당연히 A지.” 솔라는 망설임 없이 대답했다. “B는 그냥 친구랑 대화하는 것 같고, A는 깔끔하게 정리되어 있어서 정보를 파악하기 훨씬 쉽잖아. 이걸 나중에 앱 화면에 보여준다고 생각하면 A 형식을 써야지.”

“좋아. 그럼 ‘깔끔하게 정리되어 있다’는 건 뭘까? 우리가 이 Agent에게 ‘반드시 A처럼 출력해야 해!’라고 규칙을 알려줘야 한다면, 뭐라고 설명해 줄 수 있을까? ‘느낌’ 말고, 아주 구체적인 조건으로 말이야.”

루나의 질문에 솔라는 다시 두 출력을 꼼꼼히 뜯어보기 시작했다. ‘깔끔하다’는 자신의 첫인상을 해체해보기 시작한 것이다.

“일단… A는 전체가 꺾쇠괄호 []로 묶여 있으니까 리스트 형태여야 하고. 그리고 중괄호 {}로 묶인 덩어리가 두 개 들어있네. 영화 하나당 덩어리 하나씩.”

솔라의 손가락이 허공에서 괄호 모양을 그렸다.

“맞아, 또 뭐가 있을까?”

“각 덩어리 안에는 꼭 rank, title, reason이라는 항목이 있어야 해. B에는 순위나 구체적인 추천 이유가 빠져 있잖아. 순서도 중요해. 1순위가 먼저 나오고, 2순위가 다음에 나와야 하고. 아, 그리고 딱 두 개만 추천해야 한다는 것도 규칙이 될 수 있겠다.”

하나씩 규칙을 찾아내던 솔라는 문득 깨달았다는 듯 고개를 들었다. 자신이 방금 늘어놓은 조건들이 바로 ‘객관적인 기준’이었다. ‘추천 영화가 내 취향인가?’ 같은 주관적 감상과는 완전히 다른 차원의 이야기였다.

루나가 조용히 미소 지으며 말했다.

“바로 그거야. 솔라 네가 방금 말한 ‘리스트 형식일 것’, ‘두 개의 항목을 가질 것’, ‘각 항목은 rank, title, reason 필드를 포함할 것’ 같은 명확한 조건들. 이게 바로 ‘규칙’이야. 그리고 우리에겐 이런 규칙들을 코드로서 확인하고, 지켰으면 1점, 아니면 0점을 주는 자동 채점 프로그램을 만들 수 있는 능력이 있지. 그게 바로 ‘규칙 평가 함수’인 거고.”

루나의 설명을 들은 솔라는 다시 자신의 노트북 화면 속 문장을 바라보았다.

"테스트 데이터셋을 구성하고, 영화 추천 Agent의 출력이 리스트 안 딕셔너리 2개 형식을 지키는지 규칙 평가 함수로 검증한다."

처음엔 생경하게만 느껴졌던 문장이 이제는 명확한 그림으로 다가왔다. 이것은 추천 내용이 얼마나 감동적인지를 ‘평가’하는 것이 아니었다. Agent가 정해진 ‘출력 형식’이라는 약속을 얼마나 잘 지켰는지 ‘검증’하는 과정이었던 것이다. 사람의 주관적인 느낌이 끼어들 여지가 없는, 코드에 의한 객관적인 확인 절차.

“아…” 솔라가 나지막이 탄성을 뱉었다. “그러니까 추천이 좋은지 나쁜지는 나중 문제고, 일단 우리가 원하는 그릇에 결과물을 제대로 담아왔는지부터 확인한다는 거구나.”

“정확해. 그릇이 깨지거나 모양이 다르면 내용물이 아무리 좋아도 쓸 수가 없으니까.”

이제 평가가 꼭 주관적인 느낌에만 의존하는 것은 아니라는 사실을 받아들인 솔라의 마음속에 새로운 궁금증이 고개를 들었다.

“알겠어. 약속된 형식을 코드’로’ 검사한다는 건 이해했어. 그런데 코드’가’ 어떻게 이게 리스트인지, 그 안에 딕셔너리가 두 개 있는지, 특정 키가 빠짐없이 들어있는지를 알아챈다는 거지? 사람은 눈으로 보면 바로 알지만, 코드는 어떻게 그걸 판단하는 거야?“

2장: 솔라: ‘규칙’이 코드가 된다고요? 어떻게 구체화하죠?

솔라는 이전의 대화가 남긴 질문을 곱씹고 있었다. ‘사람은 눈으로 척 보면 알지만, 코드는 어떻게 그걸 판단하는 걸까?’ 코드의 세계는 0과 1의 냉정한 논리로 이루어져 있다. 그곳에 ‘리스트처럼 생긴 것’, ‘깔끔하게 정돈된 느낌’ 같은 모호한 인간의 감각이 끼어들 자리는 없어 보였다.

그때 루나가 아무 말 없이 자신의 노트북 화면을 다시 솔라 쪽으로 돌렸다. 화면에는 이전과 다른, 새로운 텍스트 창이 열려 있었다. 이전처럼 Agent의 출력 예시를 보여주는 것이 아니었다. 그 대신, 마치 무언가를 단호하게 명령하는 듯한 한 줄의 지침이 적혀 있었다.

[ 출력 형식 규칙 ] 반드시 리스트 안에 딕셔너리 2개가 포함된 형태로 출력해야 합니다. 예시: [{"rank": 1, "title": "영화 제목", "reason": "추천 이유"}, {"rank": 2, "title": "영화 제목", "reason": "추천 이유"}]

솔라는 화면을 물끄러미 바라보았다. 이것이 바로 어제 이야기했던 ‘규칙’의 실체인 것 같았다. 하지만 여전히 무언가 명쾌하게 잡히지 않았다.

“이게… 평가의 기준이 되는 ‘규칙’이라는 거지?” 솔라가 입을 열었다. “그런데 이걸 코드가 어떻게 알아듣는다는 거야? Agent의 출력물이랑 이 예시 문자열이랑 글자 하나하나 비교하는 건 아닐 거 아냐. 그랬다간 영화 제목만 바뀌어도 전부 실패로 나올 텐데.”

솔라의 생각은 ‘규칙’이란 여전히 사람 개발자가 참고하기 위한 설명서나 주석 같은 것이라는 생각에 머물러 있었다. 코드가 이 ‘설명’을 읽고 그 의미를 ‘이해’해서 평가를 수행할 것이라는, 다소 막연한 상상이었다.

“좋은 지적이야. 문자열을 통째로 비교하는 건 아니야.” 루나가 말했다. “그럼 역할을 바꿔보자. 솔라 네가 지금부터 아주 엄격하고 글자 그대로만 따지는 로봇 검사관이 되는 거야. 저 규칙을 들고, 내가 주는 출력물을 검사해야 해. 자, 여기 첫 번째 검사 대상.”

루나는 새로운 출력물 하나를 화면에 보여주었다.

[ 검사 대상 1 ] "제가 추천하는 영화는 '인셉션'과 '매트릭스'입니다. 둘 다 정말 명작이죠!"

“자, 로봇 검사관. 이 출력물은 합격이야, 불합격이야?”

“당연히 불합격이지.” 솔라가 즉시 대답했다. “규칙에 있는 꺾쇠괄호 []도 없고, 아무것도 안 맞잖아.”

“좋아. 불합격 사유를 규칙에 근거해서, 아주 구체적인 단계로 설명해 줄래? ‘느낌’ 말고, 네가 확인한 사실만 가지고.”

루나의 말에 솔라는 잠시 생각에 잠겼다. ‘로봇 검사관’의 입장이 되어보기로 했다. 감정이나 느낌을 배제하고, 오직 주어진 규칙과 대상만을 기계적으로 비교하는 것이다.

“음… 첫 번째. 나는 출력물이 리스트인지 확인하라는 명령을 받았어. 그런데 이 출력물은 그냥 문자열이야. 리스트가 아니야. 여기서 이미 탈락.”

“훌륭한 첫 단계야. 그럼 이건 어때?”

루나가 다음 검사 대상을 화면에 올렸다.

[ 검사 대상 2 ]

[
  {"rank": 1, "title": "인셉션"},
  {"rank": 2, "title": "매트릭스", "reason": "철학적 질문을 던지는 액션의 걸작입니다."}
]

“이건…” 솔라는 잠시 망설였다. “일단 첫 번째 관문은 통과했네. 전체가 []로 감싸인 리스트 형태니까. 그리고 리스트 안에 덩어리도 두 개 들어있고. 합격…인가?”

“정말? 규칙을 다시 꼼꼼히 봐.”

솔라는 다시 맨 위 출력 형식 규칙으로 시선을 옮겼다. 예시까지 찬찬히 뜯어보던 솔라의 눈이 가늘어졌다.

“아니다. 불합격이야. 두 번째 단계에서 걸리네.”

“어떤 단계?”

“규칙에는 각 덩어리, 그러니까 딕셔너리 안에 rank, title, reason이라는 키가 모두 있어야 한다고 예시로 보여주고 있어. 그런데 첫 번째 영화 ‘인셉션’에는 reason 키가 빠져있어. 그래서 탈락.”

솔라는 자신이 방금 수행한 검사 과정을 되짚어 보았다.

  1. 전체 출력이 리스트 형식인가?
  2. 리스트 안에 항목이 정확히 2개인가?
  3. 리스트 안의 각 항목이 딕셔너리 형식인가?
  4. 각 딕셔너리 안에 rank, title, reason 키가 모두 존재하는가?

이것은 더 이상 ‘깔끔하다’, ‘정리되어 있다’ 같은 주관적인 감상이 아니었다. 참(True)과 거짓(False)으로 명확하게 나뉘는, 코드가 한 줄 한 줄 실행할 수 있는 구체적인 ‘검증 절차’의 목록이었다.

“알겠다!” 솔라가 무릎을 쳤다. “코드가 ‘규칙’을 사람처럼 이해하는 게 아니었어. 우리가 규칙을 ‘코드가 이해할 수 있는 작은 단계들’로 잘게 쪼개서 알려주는 거였구나. ‘리스트여야 한다’, ‘항목은 두 개여야 한다’, ‘특정 키를 가져야 한다’ 같은 아주 단순한 확인 절차들의 연속으로 말이야.”

방금 전까지 모호한 설명서처럼 보였던 출력 형식 규칙이 이제는 명확한 설계도로 보이기 시작했다. 평가 함수란, 결국 이 설계도를 바탕으로 지어진 자동화된 검사 기계였던 것이다.

“바로 그거야.” 루나가 조용히 고개를 끄덕였다. “사람의 ‘느낌’을 코드로 번역할 수는 없지만, ‘형식’, ‘개수’, ‘필드 유무’ 같은 명확한 기준은 얼마든지 코드로 번역할 수 있어. 우리가 할 일은 그 번역의 규칙을 구체적으로 정의하는 거고.”

솔라는 자신이 방금 머릿속으로 만들었던 네 단계의 체크리스트를 떠올렸다. 이제 이 명확한 규칙들이 실제 코드로 어떻게 변신하는지 보고 싶다는 강한 호기심이 밀려왔다.

“좋아. 그럼 이 체크리스트, 이 네 가지 단계를 진짜 파이썬 코드로 만들면 어떻게 되는 거야? ‘리스트인지 확인’하는 코드, ‘키가 있는지 확인’하는 코드를 직접 보고 싶어.”

3장: 루나: 규칙을 코드로, perform_eval의 마법

솔라의 질문이 끝나자, 루나는 대답 대신 노트북을 다시 자기 앞으로 가져갔다. 타닥, 타닥. 몇 번의 키보드 소리가 나더니, 루나는 화면을 다시 솔라에게 돌려주었다. 이전과는 또 다른, 새로운 코드 편집기 창이 화면을 채우고 있었다. 그 안에는 파이썬 함수 하나가 덩그러니 놓여 있었다.

def perform_eval(run, example):
    # 여기에 평가 로직을 구현합니다.
    pass

솔라는 화면을 빤히 들여다보았다. perform_eval. ‘평가를 수행하다’라는 뜻의 함수 이름. 지난번 대화에서 자신이 만들었던 네 단계의 체크리스트를 코드로 보고 싶다고 말하자, 언니가 그 대답으로 이 텅 빈 함수를 보여준 것이다. 하지만 솔라는 선뜻 이해가 되지 않았다.

“이게… 그 체크리스트를 담을 함수라고?” 솔라가 물었다. “근데 run이랑 example은 뭐야? 우리가 검사할 건 Agent의 출력물 하나 아니었어?”

솔라의 머릿속은 복잡했다. 단순한 확인 절차일 거라 생각했는데, run이나 example처럼 모르는 단어가 등장하자 평가 함수 구현이 생각보다 훨씬 복잡한 시스템일지도 모른다는 막연한 불안감이 피어올랐다.

“그 두 개는 평가 시스템이 이 함수에게 일을 시킬 때 필요한 정보들이야. 지금 당장 중요한 건 아니니 잠시 옆에 둬도 괜찮아.” 루나는 화면의 특정 부분을 손가락으로 가리켰다. “우리가 집중할 건 이거야. Agent가 내놓은 최종 결과물. 그건 run.outputs['output']이라는 곳에 들어있어.”

“아, run이라는 바구니 안에 outputs라는 상자가 있고, 그 안에 output이라는 이름표가 붙은 결과물이 들어있는 거구나.”

“맞아. 자, 그럼 이제 진짜 로봇 검사관 코드를 짜볼까? 네가 만들었던 체크리스트 첫 번째 항목이 뭐였지?”

솔라는 지난번 ‘로봇 검사관 놀이’를 떠올렸다. “첫 번째는… ‘전체 출력이 리스트 형식인가?‘였어.”

“좋아. Agent의 출력물은 기본적으로 글자 덩어리, 즉 문자열로 와. 우리가 봤던 [{...}, {...}] 같은 모양의 문자열이지. 이걸 파이썬이 이해하는 리스트나 딕셔너리로 바꿔주는 과정이 먼저 필요해.”

루나는 코드 편집기에 몇 줄을 추가했다.

import json

def perform_eval(run, example):
    output_str = run.outputs["output"]
    try:
        data = json.loads(output_str)
    except json.JSONDecodeError:
        # 문자열이 JSON 형식이 아니면 여기서 실패
        return {"key": "format", "score": 0}

    # 여기에 이어서 다음 검사를 추가합니다.

json.loads라는 게 그 마법을 부리는 주문이구나. 문자열을 진짜 데이터 구조로 바꿔주는.” 솔라는 고개를 끄덕였다. “만약 형식이 엉망이라 loads가 실패하면 except로 가서… 어? 이건 뭐지? 왜 score: 0을 반환해? 그냥 실패했다는 뜻으로 False를 반환하면 안 돼?”

드디어 핵심에 닿은 질문이었다. 솔라는 평가의 결과가 합격/불합격, 즉 True/False일 거라 예상했다. 하지만 루나의 코드는 { "key": "format", "score": 0 }이라는 낯선 딕셔너리를 반환하고 있었다.

“LangSmith 같은 평가 시스템과 약속된 ‘보고서 양식’이라고 생각하면 편해.” 루나가 설명했다. “score는 점수야. 1점은 합격, 0점은 불합격이지. 그리고 key는 우리가 어떤 항목을 채점했는지 알려주는 이름표고. 지금 우리는 ‘형식(format)‘을 검사하고 있으니, 이름표를 format이라고 붙인 거야.”

“아하! 그냥 ‘불합격’이라고 두루뭉술하게 말하는 게 아니라, ‘format 항목에서 0점을 받았습니다’라고 정확하게 알려주는 거구나.”

이제 keyscore의 의미를 이해한 솔라는 자신감이 붙었다. “좋아, 그럼 다음 체크리스트를 코드로 옮겨보자. 첫 관문을 통과했으니 data는 이제 진짜 리스트일 거야. 그럼 두 번째 체크, ‘리스트 안에 항목이 정확히 2개인가?’ 이건 이렇게 하면 되지 않을까?”

솔라는 허공에 코드를 쓰듯 손가락을 움직였다. “if len(data) != 2: 면, 불합격!”

루나는 미소 지으며 솔라가 말한 코드를 그대로 함수 안에 추가했다.

    # ... 이전 코드 ...
    except json.JSONDecodeError:
        return {"key": "format", "score": 0}

    if not isinstance(data, list) or len(data) != 2:
        return {"key": "format", "score": 0}

    # ... 다음 검사 ...

“리스트가 아니거나, 길이가 2가 아니면 탈락. 완벽해. 그럼 마지막 관문은? ‘각 항목이 rank, title, reason 키를 모두 가진 딕셔너리인가?’”

“그건… 리스트를 돌면서 하나씩 확인해야겠네.” 솔라는 잠시 고민하더니 입을 열었다. “반복문으로 data의 각 항목을 꺼내서, 그게 딕셔너리인지 먼저 보고, 그 다음에 키들이 다 있는지 확인하면 돼.”

솔라의 머릿속에 있던 체크리스트가 명확한 코드의 논리로 바뀌는 순간이었다. 루나는 솔라의 설계를 바탕으로 함수의 나머지 부분을 완성했다.

import json

def perform_eval(run, example):
    output_str = run.outputs["output"]
    try:
        data = json.loads(output_str)
    except json.JSONDecodeError:
        return {"key": "format", "score": 0, "comment": "출력이 JSON 형식이 아닙니다."}

    if not isinstance(data, list) or len(data) != 2:
        return {"key": "format", "score": 0, "comment": "리스트가 아니거나 항목이 2개가 아닙니다."}

    required_keys = {"rank", "title", "reason"}
    for item in data:
        if not isinstance(item, dict) or not required_keys.issubset(item.keys()):
            return {"key": "format", "score": 0, "comment": "딕셔너리가 아니거나 필수 키가 누락되었습니다."}

    # 모든 검사를 통과
    return {"key": "format", "score": 1}

완성된 코드를 본 솔라는 나지막이 감탄했다. 복잡하고 대단한 알고리즘이 아니었다. 자신이 생각했던 단순한 확인 절차들이 if 문이라는 이름의 관문이 되어 차례대로 서 있는 모습이었다. 어떤 출력물이든 이 관문들을 순서대로 통과해야만 비로소 ‘합격’이라는 1점을 받을 수 있는 구조.

“알겠다. 평가 함수라는 게, 결국 우리가 정한 규칙들을 하나씩 통과시키는 자동화된 검문소 같은 거였어.”

솔라의 눈에는 이제 perform_eval 함수가 더 이상 낯선 코드로 보이지 않았다. 그것은 자신과 루나가 함께 설계한, 명확한 목적을 가진 ‘규칙 검사기’였다.

“그래, 바로 그거야. 그리고 이 검사기는 사람의 ‘느낌’에 따라 결과를 바꾸지 않아. 언제나 동일한 규칙으로, 객관적인 점수를 내놓지.”

솔라는 완성된 코드를 보며 만족스럽게 고개를 끄덕였다. 이제 손안에 강력한 도구가 쥐어진 기분이었다. 그러자 자연스럽게 다음 질문이 떠올랐다.

“좋아, 이렇게 완벽한 규칙 검사기를 만들었어. 그럼 이제 얘를 어떻게 우리 영화 추천 Agent한테 붙여서 일을 시키는 거야? 이 함수랑 Agent를 어떻게 연결하지?“

4장: 솔라: 내 평가 함수, Agent와 LangSmith에 연결하기

솔라의 손가락이 키보드 위를 맴돌다 멈췄다. 방금 전 언니와 함께 완성한 ‘규칙 검사기’ perform_eval 함수가 화면 한쪽에 말끔하게 정리되어 있었다. 하지만 솔라의 시선은 그곳에 머물지 않고, 화면 반대편에 있는 또 다른 코드 블록으로 향했다. 그곳에는 이 모든 일의 시작이었던 movie_recommendation_agent가 자리 잡고 있었다.

두 코드 덩어리는 같은 파일 안에 있었지만, 마치 서로를 전혀 모르는 사이처럼 멀찍이 떨어져 있었다. 왼쪽에는 영화를 추천하는 Agent, 오른쪽에는 그 결과를 검사할 평가 함수. 그 사이의 넓은 공백이 솔라가 지난번에 던졌던 마지막 질문을 더욱 커다랗게 만들고 있었다. ‘어떻게 둘을 연결하지?’ 마치 강 이편에 배가 있고 저편에 짐이 있는데, 그 둘을 이어줄 다리가 없는 형국이었다.

“음… 언니.” 솔라가 입을 열었다. “이 perform_eval 함수를 Agent 코드 안으로 가져와서, Agent가 마지막에 자기 결과물을 스스로 검사하게 만들어야 하나? 아니면 반대로 이 평가 함수가 Agent를 호출하는 건가? 대체 누가 누구를 먼저 부르는 거야?”

솔라의 머릿속에서는 두 함수를 억지로 잇기 위한 복잡한 선들이 얽히고 있었다. Agent 코드 끝에 perform_eval(...)을 추가하는 상상, 평가 함수가 movie_recommendation_agent.run(...)을 호출하는 상상을 해봤지만, 어느 쪽이든 어색하고 부자연스러웠다. Agent는 추천만 잘하면 되고, 평가 함수는 검사만 잘하면 될 텐데, 왜 서로의 내부 사정을 알아야만 하는 걸까.

루나는 솔라의 질문에 바로 답하는 대신, 새로운 비유를 꺼냈다.

“가수를 뽑는 오디션 프로그램을 생각해 봐. 심사위원들이 참가자들의 집을 일일이 찾아다니면서 노래를 듣지 않잖아.”

“그렇지. 모든 참가자가 방송국 무대라는 한 곳으로 와서 노래를 부르지.”

“바로 그거야. LangSmith의 평가 시스템도 마찬가지야. 평가하고 싶은 대상이 제각각 다른 방식으로 작동하면 심사하기가 너무 힘들어. 그래서 모든 참가자, 즉 모든 Agent가 노래를 부를 ‘표준 무대’를 하나 만들어야 해. 그 무대의 이름이 바로 target_fn이야.”

루나는 코드 편집기 아래쪽에 새로운 함수 껍데기를 추가했다.

# 평가 대상이 올라갈 표준 무대
def target_fn(inputs):
    # 여기에 우리 Agent를 올려서 실행시키면 돼.
    pass

“이게 평가 시스템과 우리 Agent 사이의 약속된 연결 통로구나.” 솔라가 target_fn이라는 이름을 보며 중얼거렸다.

“맞아. 평가 시스템은 오직 target_fn만 호출할 거야. 그럼 이 무대 위에서 우리 영화 추천 Agent가 공연하게 하려면, 저 함수 안에 어떤 코드를 넣어야 할까?”

루나의 질문은 힌트였다. 솔라는 잠시 고민하더니, 해답을 찾았다는 듯 손가락으로 화면을 가리켰다.

“간단하네. inputs를 받아서 우리 Agent한테 그대로 넘겨주고, Agent가 뱉어낸 결과물을 반환해주면 되잖아.”

솔라의 말대로 루나가 함수를 완성했다. Agent를 target_fn이라는 표준화된 함수로 감싸는, 아주 간단한 포장 작업이었다.

def target_fn(inputs):
    # 영화 추천 Agent를 호출하고 결과를 반환
    return movie_recommendation_agent.invoke(inputs)

이제 솔라의 눈앞에는 세 개의 조각이 놓여 있었다.

  1. 참가자: 영화 추천 Agent (movie_recommendation_agent)
  2. 심사위원: 규칙 평가 함수 (perform_eval)
  3. 표준 무대: Agent를 감싼 함수 (target_fn)

“좋아. 이제 참가자를 무대 위에 세웠어. 심사위원도 대기 중이고. 그런데… 누가 심사위원한테 저 무대를 보고 채점하라고 알려주는 거지? 오디션 총감독 같은 역할이 필요하잖아.”

솔라의 질문에 루나가 미소 지으며 마지막 한 줄을 타이핑하기 시작했다. LangSmith 라이브러리에서 evaluate라는 함수를 불러오는 코드였다.

from langsmith.evaluation import evaluate

# 평가 데이터셋
dataset = [
    {"question": "요즘 볼만한 액션 영화 2편만 추천해줘."}
]

# 평가 실행!
experiment_results = evaluate(
    target_fn,                      # 참가자가 올라선 '무대'
    data=dataset,                   # 참가자에게 던져질 '미션'
    evaluators=[perform_eval],      # '심사위원' 목록
    experiment_prefix="format-check"
)

그 코드가 화면에 나타나는 순간, 흩어져 있던 모든 조각이 제자리를 찾았다. 솔라는 나지막이 탄성을 내뱉었다.

“아! evaluate 함수가 바로 그 총감독이었구나!”

솔라의 머릿속에서 복잡하게 얽혔던 선들이 한순간에 정리되었다. evaluate라는 함수가 모든 것을 조율하는 지휘자였다. 이 함수는 target_fn이라는 무대 위에서 Agent를 실행시키고(data를 미션으로 던져주며), 그 결과를 evaluators 목록에 있는 perform_eval 심사위원에게 전달하여 채점하게 했다. 참가자와 심사위원은 서로를 전혀 알 필요가 없었다. 그저 총감독의 지휘에 따르면 되는 것이었다.

“이제 알겠어. Agent 코드도, 평가 함수 코드도 수정할 필요가 전혀 없었네. 그냥 각자의 역할에 맞는 조각들을 만들고, evaluate라는 접착제로 붙여주기만 하면 되는 거였어.”

“정확해.” 루나가 말했다. “이렇게 각자 역할을 분리해야 Agent는 추천 로직에만, 평가 함수는 검증 로직에만 집중할 수 있어. 훨씬 깔끔하고 관리하기도 쉽지.”

루나는 코드를 실행했다. 잠시 후, 터미널에 새로운 메시지가 한 줄 나타났다.

View experiment results at: https://smith.langchain.com/o/....

솔라는 화면에 나타난 URL을 뚫어져라 쳐다보았다. 방금 자신이 연결한 평가 시스템이 실제로 작동하여 결과를 만들어냈다는 증거였다. 심장이 두근거렸다.

“실행됐다! 그럼 이제 저 주소로 들어가면 점수를 볼 수 있는 거야? 우리가 만든 검사 규칙대로 1점 아니면 0점이 찍혀 나오는 건가? 결과는 어떻게 생겼어?“

5장: 루나: 대시보드에서 객관적 평가 결과를 확인해요

솔라의 질문이 채 끝나기도 전에, 루나는 이미 마우스 커서를 움직여 터미널에 나타난 파란색 URL을 클릭했다. 찰나의 로딩 화면이 지나가고, 노트북 화면은 익숙한 코드 편집기나 터미널의 검은 배경 대신, 수많은 선과 표로 가득 찬 하얀 웹페이지로 바뀌었다. LangSmith 대시보드였다. 페이지 상단에는 방금 전 루나가 코드에서 지정했던 format-check라는 실험 이름이 선명하게 보였다.

화면 중앙에는 테이블이 펼쳐져 있었다. 하지만 솔라가 예상했던 단순한 점수판과는 거리가 멀었다. Input, Output, Latency, Tokens 등 알 수 없는 영어 단어로 된 열들이 빼곡했다. 솔라의 눈은 점수를 찾기 위해 빠르게 화면을 훑었지만, 한눈에 들어오는 ‘1점’이나 ‘0점’ 같은 표시는 보이지 않았다. 모든 것이 너무 많고 복잡해 보였다.

“실행됐다면서… 점수는 어디서 봐?” 솔라의 목소리에는 기대감과 함께 약간의 당혹감이 섞여 있었다. “이건 그냥… 복잡한 로그 기록 같은데. 우리가 만든 perform_eval은 어디에 있는 거야?”

솔라의 마음속에서는 ‘결과’란 시험지 채점처럼 명확한 숫자 하나일 것이라는 예상이 무너지고 있었다. 이 복잡한 데이터의 홍수 속에서 자신이 만든 규칙 검사기가 어떻게 작동했는지, 그 결과가 어디에 시각화되어 있는지 도무지 알 수가 없었다. 어쩌면 시스템 어딘가에 결과가 저장되기는 했지만, 그것을 해석하는 것은 또 다른 복잡한 과정일지 모른다는 생각마저 들었다.

루나는 말없이 화면의 스크롤바를 오른쪽으로 조금 움직였다. 복잡한 열들이 왼쪽으로 스쳐 지나가자, 테이블의 맨 끝에서 익숙한 단어로 된 새로운 열이 나타났다.

Feedback (format)

그 열의 이름은 바로 자신들이 perform_eval 함수 안에서 결과 딕셔너리를 반환할 때 key 값으로 지정했던 'format'이었다. 그리고 그 열 아래, 첫 번째 행에는 초록색 배경의 숫자 ‘1’이 선명하게 찍혀 있었다.

“아…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 그 순간 흩어져 있던 모든 퍼즐 조각이 맞춰졌다. 복잡해 보이기만 하던 대시보드는 사실 매우 정직한 보고서였다. 우리가 코드에서 return {"key": "format", "score": 1}이라고 돌려주었던 약속이, 화면 위에 Feedback (format)이라는 이름의 열과 그 안의 값 ‘1’로 정확하게 구현되어 있었던 것이다.

“우리가 만든 평가 함수의 key가 그대로 열 이름이 되는 거였구나.” 솔라가 신기하다는 듯 화면을 가리켰다. “그냥 점수만 덜렁 보여주는 게 아니라, ‘어떤 규칙’에 대한 점수인지를 명확하게 알려주는 거였어.”

루나가 고개를 끄덕이며 말했다. “만약 우리가 출력물의 감성적인 부분을 평가하는 sentiment라는 이름의 평가 함수를 또 만들었다면, 저 옆에 Feedback (sentiment)라는 열이 하나 더 생겼을 거야. 평가 규칙이 늘어날수록, 이 보고서의 열도 함께 늘어나는 거지.”

이제야 대시보드를 어떻게 읽어야 하는지 깨달은 솔라는 다시 테이블의 처음으로 돌아가 전체를 훑어보기 시작했다. 첫 번째 Input 열에는 ‘요즘 볼만한 액션 영화 2편만 추천해줘.’라는 질문이 있었다. 그리고 Output 열에는 Agent가 생성한, 리스트와 딕셔너리로 깔끔하게 정돈된 영화 추천 결과가 들어있었다.

솔라는 Output 열의 내용을 소리 내어 읽어보며, 머릿속으로 자신이 만들었던 네 단계의 체크리스트를 다시 한번 상기했다.

Output을 보니, 전체가 리스트 형태고… 안에 딕셔너리가 두 개 들어있고… 각 딕셔너리마다 rank, title, reason 키가 모두 빠짐없이 들어있네.”

솔라는 말을 마치고 다시 Feedback (format) 열의 숫자 ‘1’을 바라보았다. 이제 그 숫자는 단순한 ‘합격’ 점수로 보이지 않았다. 그것은 ‘리스트 형식 준수’, ‘항목 개수 준수’, ‘필수 키 포함’이라는 여러 관문을 모두 통과했음을 증명하는, 객관적인 데이터에 기반한 명백한 ‘인증 마크’였다.

처음 ‘평가는 주관적인 느낌’이라고 생각했던 솔라의 모습은 온데간데없었다. 지금 솔라의 눈앞에는 Agent의 출력이 형식, 개수, 필드, 순번과 같은 명확한 기준에 따라 잘 검증되었음을 보여주는, 코드에 의한 객관적 평가의 최종 결과 보고서가 펼쳐져 있었다. 느낌이 비집고 들어올 틈이 없는, 재현 가능한 검증의 증거였다.

“이제야 알겠다.” 솔라가 만족스러운 표정으로 말했다. “평가라는 게 그냥 ‘좋다/나쁘다’를 넘어서, 우리가 정한 약속을 ‘지켰다/안 지켰다’를 이렇게 명확하게 확인하는 과정이었구나. 이제 누가 이 Agent가 형식을 잘 지키냐고 물어보면, 내 느낌을 말하는 대신 그냥 이 대시보드를 보여주면 되겠네.”