11장. 프레임워크 기반 에이전트 구성
실행 추적 — 답변이 아니라 과정을 본다
출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.
한 줄 요약
agent.invoke는 끝난 결과만 돌려줍니다. 9장에서는 루프 안에 print를 넣어 과정을 지켜봤는데, 루프가 프레임워크 안으로 들어가면 그 자리가 없습니다. 대신 agent.stream 으로 단계마다 상태를 받아 어떤 도구를 불렀고 무엇을 받았는지 기록합니다. 이 기록을 실행 추적(trace) 이라고 합니다.
1. 답변만 봐서는 알 수 없는 것
에이전트가 이렇게 답했다고 합시다.
LED 무드등은 19,900원이며, 현재 재고는 110개입니다.
문장만 보면 맞는지 틀리는지 알 수 없습니다. 모델은 도구를 부르지 않고도 이런 문장을 쓸 수 있습니다. 3장에서 모델이 하루마켓이 "신선한 상품"을 판다고 지어낸 것을 봤습니다.
판단하려면 과정이 필요합니다.
| 답변 앞에 남은 기록 | 판단 |
|---|---|
search_products → check_stock 호출과 결과가 있다 |
답의 숫자를 도구 결과와 맞춰 볼 수 있다 |
| 도구 호출이 하나도 없다 | 가격과 재고는 지어낸 것이다 |
숫자가 들어 있는 답변인데 도구 호출 기록이 없다면, 문장이 아무리 그럴듯해도 믿을 수 없습니다. 실행 추적은 디버깅을 편하게 하는 도구이기 전에 답을 믿을 근거입니다.
2. invoke와 stream
| 방법 | 돌려주는 것 | 쓰는 때 |
|---|---|---|
agent.invoke(입력) |
다 끝난 뒤의 상태 하나 | 결과만 필요할 때 |
agent.stream(입력, stream_mode="values") |
단계가 끝날 때마다 그 시점의 전체 상태 | 과정을 지켜볼 때 |
상태(state) 는 에이전트가 지금까지 쌓은 것을 담은 딕셔너리입니다. 여기서는 messages 하나가 들어 있습니다.
stream_mode="values"로 받으면 messages가 이렇게 자라는 것이 보입니다.
| 받은 순서 | messages의 길이 |
새로 붙은 것 |
|---|---|---|
| 1 | 1 | HumanMessage (질문) |
| 2 | 2 | AIMessage (도구 요청) |
| 3 | 3 | ToolMessage (도구 결과) |
| 4 | 4 | AIMessage (최종 답변) |
3. 새로 붙은 메시지만 골라 낸다
매번 전체가 오므로, 그대로 출력하면 앞의 메시지가 계속 되풀이됩니다. 지금까지 본 개수를 기억해 두고 그 뒤만 봅니다.
seen = 0
for chunk in agent.stream(
{"messages": [{"role": "user", "content": question}]},
stream_mode="values",
):
msgs = chunk["messages"]
for msg in msgs[seen:]: # 새로 추가된 메시지만 출력
...
seen = len(msgs)
msgs[seen:]는 "seen번째부터 끝까지"입니다. 처음에는 seen이 0이라 전부, 그다음부터는 새로 붙은 것만 나옵니다.
4. 메시지 종류에 따라 다르게 적는다
새 메시지가 무엇인지에 따라 기록을 달리합니다.
kind = msg.__class__.__name__
if kind == "AIMessage" and getattr(msg, "tool_calls", None):
for tc in msg.tool_calls:
print(f" [추적] 도구 결정 → {tc['name']}({tc['args']})")
elif kind == "ToolMessage":
print(f" [추적] 도구 결과 ← {str(msg.content)}")
elif kind == "AIMessage" and msg.content:
... # 최종 답변
| 조건 | 뜻 | 9장의 로그에서는 |
|---|---|---|
AIMessage이고 tool_calls가 있다 |
모델이 도구를 골랐다 | Action: 도구이름(인자) |
ToolMessage |
도구가 결과를 돌려줬다 | (출력하지 않았다) |
AIMessage이고 tool_calls가 없다 |
최종 답변 | 루프 종료 |
msg.__class__.__name__은 그 객체의 클래스 이름을 문자열로 꺼내는 파이썬 표현입니다. tool_calls의 각 항목은 name(도구 이름)과 args(인자)를 가진 딕셔너리입니다.
화살표의 방향에 뜻을 담았습니다. →는 모델이 내보낸 결정, ←는 우리 코드가 실행해 돌려준 결과입니다. 도구를 고른 것은 모델이고, 실행한 것은 프레임워크 안에서 도는 우리 함수입니다.
5. content는 문자열이 아닐 수 있다
최종 답변을 꺼내는 부분에 분기가 하나 더 있습니다.
if isinstance(msg.content, str):
final_text = msg.content
else:
final_text = "".join(
b.get("text", "") for b in msg.content
if isinstance(b, dict))
AIMessage.content는 문자열일 때도 있고, 조각의 목록일 때도 있습니다. 이 과정의 기본 모델로 실행하면 목록으로 옵니다.
[{'type': 'text', 'text': 'LED 무드등의 가격은 19,900원이며, …', 'extras': {…}}]
이것을 문자열이라고 믿고 그대로 출력하면 고객 화면에 대괄호와 중괄호가 찍힙니다. 그래서 목록이면 text 조각만 모아 이어 붙입니다.
4장에서 "모델의 출력 형식을 믿지 말고 확인한다"고 했습니다. 프레임워크가 돌려주는 값에도 같은 원칙이 적용됩니다.
6. 회귀 세트 — 고칠 때마다 다시 돌리는 질문 묶음
「따라하기」의 코드는 질문 세 개를 차례로 보냅니다.
| 질문 | 확인하려는 것 | 기대하는 도구 |
|---|---|---|
| 제 최근 주문 상태 알려주세요. | 주문번호를 묻지 않고 직접 찾는가 | get_my_orders |
| 무드등 가격이랑 재고 알려주세요. | 검색한 뒤 재고까지 이어서 확인하는가 | search_products → check_stock |
| 결제가 이중으로 된 것 같아요. … 상담원 연결해 주세요. | 해결할 수 없는 문의를 접수하는가 | create_ticket |
프롬프트나 도구를 고친 뒤 전에 되던 것이 여전히 되는지 다시 확인하는 것을 회귀 테스트(regression test) 라고 하고, 그때 쓰는 고정된 질문 묶음이 회귀 세트입니다. 3장에서 "프롬프트를 고치면 표준 질문을 다시 돌린다"고 한 습관의 작은 판입니다.
확인하는 대상은 답변의 문장이 아닙니다. 기대한 도구가 추적에 나타났는가입니다.
핵심 정리
- 답변의 문장만으로는 지어낸 것인지 조회한 것인지 알 수 없습니다. 과정을 봐야 합니다.
agent.stream(..., stream_mode="values")는 단계마다 전체 상태를 줍니다. 새로 붙은 메시지만 골라 봅니다.tool_calls가 있는AIMessage는 도구 결정,ToolMessage는 도구 결과,tool_calls가 없는AIMessage는 최종 답변입니다.content는 문자열이 아니라 조각의 목록일 수 있습니다. 형태를 확인하고 꺼냅니다.- 회귀 세트는 고칠 때마다 다시 돌려, 기대한 도구가 불렸는지 확인하는 질문 묶음입니다.