Agent를 몇 번 써 보고 잘 되는 것 같다고 느끼는 것과 잘한다고 말할 수 있는 것은 다릅니다. 같은 질문을 열 번, 질문을 바꿔 백 번 돌려 몇 번 맞았는지 세어야 하고 틀린 때는 어디서 틀렸는지 들여다봐야 합니다. 그러려면 먼저 실행 한 번을 기록으로 남겨야 합니다.
이 기록을 트레이스(trace)라고 부르고 그 안의 모델 호출 하나, 도구 호출 하나를 스팬(span)이라고 부릅니다. 아래에서 질문을 골라 Agent가 어떤 스팬을 거쳤는지 보세요.
모의 모델 서버로 실제 실행해 얻은 호출 횟수와 토큰입니다. 막대 길이는 구간을 구분하려고 그린 것이고 걸린 시간이 아닙니다.
LangSmith 같은 서비스는 환경 변수만 켜면 LangChain 코드를 고치지 않고도 트레이스를 모아 줍니다. 공식 문서의 설정은 LANGSMITH_TRACING=true와 LANGSMITH_API_KEY이고 프로젝트 이름은 LANGSMITH_PROJECT로 정합니다. 이 글에서는 계정 없이 구조를 보려고 같은 정보를 직접 모았습니다.
LangChain은 모델과 도구가 시작하고 끝날 때마다 콜백(callback)이라는 함수를 불러 줍니다. 그 안에서 시작 시각과 끝 시각, 토큰 수를 적어 두면 트레이스가 됩니다. 업계 공통 규약을 만드는 OpenTelemetry 프로젝트에도 GenAI 전용 규약 저장소가 따로 있는데 안정 상태인지는 확인하지 못했습니다.
class Trace(BaseCallbackHandler): # 1 스팬을 모으는 기록기
def __init__(self): self.spans, self.t0 = {}, time.time()
def on_chat_model_start(self, s, m, *, run_id, **kw): # 2 모델 호출 시작
self.spans[run_id] = {"kind": "llm", "start": time.time() - self.t0}
def on_llm_end(self, r, *, run_id, **kw):
u = r.generations[0][0].message.usage_metadata # 3 토큰 수 적기
self.spans[run_id].update(end=time.time() - self.t0, tokens=(u["input_tokens"], u["output_tokens"]))
# on_tool_start / on_tool_end 도 같은 식으로 도구 스팬을 적고
# tool_names()가 부른 도구 이름을 순서대로 돌려준다BaseCallbackHandler를 이어받아 필요한 시점의 메서드만 고칩니다. 스팬을 run_id로 구분해 사전에 모읍니다.on_tool_start에서 도구 이름도 함께 적습니다.usage_metadata)를 적습니다. 호출할 때 config={"callbacks": [Trace()]}로 넘기면 실행이 기록됩니다.Agent를 평가하려면 두 가지 눈이 필요합니다. 최종 답이 맞았는지 보는 눈과 답에 이르기까지 거친 도구 호출의 순서, 곧 궤적(trajectory)을 보는 눈입니다. LangSmith 평가 문서도 Agent는 올바른 도구를 골랐는지와 거친 궤적으로 평가할 수 있다고 설명합니다.
세 번째 질문은 정답이 틀렸다는 점만 보면 원인을 알 수 없습니다. 경로를 보면 한 번 조회로 끝날 일을 목록 조회로 세 번 돌았다는 것이 드러납니다. 보기 범위를 바꿔 보세요.
평가는 데이터셋(dataset)에서 시작합니다. 질문과 함께 기대하는 정답과 기대하는 도구 경로를 적어 둔 예시 모음입니다. 기대 정답은 Agent에게 보여 주지 않고 채점할 때만 씁니다. Agent를 고칠 때마다 같은 데이터셋으로 다시 돌려 점수가 오르내리는 것을 봅니다.
채점 방법은 두 가지입니다. 코드로 정해진 규칙을 검사하는 방법은 빠르고 확실하지만 정해진 표현만 인정합니다. 심판 모델(LLM-as-judge)이 답을 읽고 합격인지 판단하는 방법은 표현이 달라도 뜻을 보지만 심판도 틀릴 수 있어 일부는 사람이 확인해야 합니다.
DATA = [{"q": "T-1042 상태 알려 줘", "expect": "부품 대기", "path": ["get_ticket"]},
...] # 1 질문 + 기대 답 + 기대 경로
for ex in DATA:
tr = Trace()
r = agent.invoke({"messages": [{"role": "user", "content": ex["q"]}]},
config={"callbacks": [tr]}) # 2 기록하며 실행
ans = r["messages"][-1].content
final_ok = ex["expect"] in ans # 3 규칙: 최종 답
path_ok = tr.tool_names() == ex["path"] # 4 규칙: 경로
v = judge.invoke([("system", "답변을 채점한다."), ("user", f"질문: {ex['q']}\n답변: {ans}")]) # 5 심판출력은 예시입니다. 모의 모델 서버로 직접 실행한 결과이고 심판도 모의 서버라 실제 모델의 채점과 다릅니다. 도구는 가짜 티켓 데이터를 돌려줍니다.
평가를 자주 돌리려면 도구를 진짜로 부르기가 부담스럽습니다. 메일을 보내거나 데이터를 바꾸는 도구라면 시험하다가 실제 일이 벌어집니다. LangChain의 LLMToolEmulator 미들웨어는 도구를 실행하는 대신 모델이 그럴듯한 응답을 만들어 돌려줍니다.
직접 실행해 진짜 도구가 한 번도 불리지 않는 것을 확인했습니다. 다만 흉내 낸 응답은 진짜가 아닙니다. 그래서 도구를 고르고 부르는 흐름을 시험하는 데 쓰고 답의 정확성은 진짜 도구로 따로 확인합니다.
평가표에는 점수 말고 비용도 함께 적어 둡니다. 모델을 부를 때마다 지금까지의 대화를 다시 보내므로 호출이 하나 늘 때마다 입력 토큰이 쌓입니다. 같은 도구를 되풀이한 세 번째 질문은 호출이 네 번이라 입력 토큰이 앞의 두 질문의 2.5배가 넘었습니다.
점수가 같다면 호출이 적고 토큰이 적은 쪽이 낫습니다. 보고 싶은 값을 골라 보세요.
다음 글에서는 문서 한 장이 Agent를 조종하는 프롬프트 인젝션을 포함해 Agent의 보안을 알아보겠습니다.