LangCahin이나 LangGraph로 에이전트를 구현하다 보면 다음과 같은 코드 구조를 자주 본다.
model_with_tools = llm.bind_tools([exa_search])
response = await model_with_tools.ainvoke(messages)
그리고 이후 다음과 같은 조건문이 등장한다
if response.tool_calls:
여기서 다음과 같은 질문이 생긴다
messages 입력
↓
LLM 실행
↓
LLM이 tool 필요 여부 판단
├─ 필요 없음 → 일반 응답 반환
└─ 필요 있음 → tool_calls 포함 응답 반환
↓
Python 코드가 tool_calls 확인
↓
tool 실행
↓
다음 노드 이동
중요한 점은 다음과 같다
LLM은 tool을 실행하지 않는다. tool을 사용 여부를 계획(tool_calls)만 생성한다.
model_with_tools = llm.bind_tools([exa_search])
이 코드의 의미는 LLM에게 사용할 수 있는 tool 목록을 알려준다
response = await model_with_tools.invoke(messages)
입력(messages)를 받은 LLM이 내부적으로 판단해서 생성한다.
가능한 결과는 두 가지이다
messages = [
SystemMessage(content = "You are a researcher"),
HumanMessage(content = "안녕")
]
response = await llm.ainvoke(messages)
실제 결과는 다음과 같다
content = "안녕하세요! 어떻게 도와드릴까요?"
resonse.tool_calls는 []가 나타난다
messages = [
SystemMessage(content = "You are a researcher"),
HumanMessage(content = "LangGraph agent architecture 검색해줘")
]
response = await llm.ainvoke(messages)
content = ""
response.tool_calls는 [{'name': 'exa_search', 'args': {'query': 'LangGraph agent architecture'}, 'id': 'id', 'type': 'tool_call'}] 같이 나타났다
실제 response에 포함되는 정보는 tool_calls, response_metadata, usage_metadata로 구성되어 있다.
tool_calls 구조
| 필드 | 예시 | 의미 | 사용 목적 |
|---|---|---|---|
name | "exa_search" | 호출할 tool 이름 | tool 선택 |
args | {"query": "LangGraph"} | tool 입력 파라미터 | tool 실행 |
id | "call_xxx" | tool 호출 식별자 | 응답 매칭 |
response_metadata 구조
| 필드 | 예시 | 의미 | 중요도 |
|---|---|---|---|
model_name | "gpt-4o-mini" | 사용된 모델 이름 | 중간 |
finish_reason | "stop" | 응답 종료 이유 | 높음 |
token_usage | {...} | 토큰 사용 통계 | 중간 |
system_fingerprint | "fp_xxx" | 서버 식별값 | 낮음 |
service_tier | "default" | 서비스 레벨 | 낮음 |
usage_metadata 구조
| 필드 | 예시 | 의미 | 활용 |
|---|---|---|---|
input_tokens | 49 | 입력 토큰 수 | 비용 분석 |
output_tokens | 11 | 출력 토큰 수 | 비용 분석 |
total_tokens | 60 | 총 토큰 수 | 비용 계산 |
겉으로는 동일하지만 내부에서 누가 tool을 실행하느냐가 다르다
tool_llm = llm.bind_tools([tool1])
response = tool_llm.invoke(messages)
research_agent = create_agent(
llm,
tools = [tool1, tool2],
system_prompt = prompt
)
예시를 통해서 차이를 봐보자
"LangGraph 구조 검색해줘"라는 동일한 질문을 각각 넣었다고 가정하자
AIMessage(
content="검색하겠습니다",
tool_calls=[{
"name": "search_web",
"args": {"query": "LangGraph 구조"}
}]
)
다음과 같은 response를 받는다. 여기서 중요한 점은 검색이 아직 실행되지 않았다는 것이다.
즉 search_web 실행이 필요하다는 계획만 생성된 상태이다.
그래서 아래와 같은 코드를 통해서 직접 실행을 해줘야한다
for tc in response.tool_calls:
result = search_web.invoke(tc['args'])
agent = create_agent(llm, tools = [search_web])
result = agent.invoke(messages)
이 경우 내부에서 자동으로 다음과 같이 실행된다.
LLM 실행
→ tool_calls 생성
→ search_web 실행
→ 결과 다시 LLM 전달
→ 최종 응답 생성
그래서 반환값은 이미 "LangGraph 구조는 다음과 같습니다..." 처럼 검색 완료된 상태이다.
LangGraph 구조를 보면 다음과 같다
researcher node
↓
tool 실행 필요?
↓ yes
tools node 이동
↓
다시 researcher node
즉, 노드 이동을 직접 제어해야 한다. 하지만 create_agent를 쓰면 제어가 어려워지게 된다.
| 항목 | bind_tools | create_agent |
|---|---|---|
| tool 실행 여부 판단 | LLM | LLM |
| tool 실행 | 개발자 | LangChain |
| loop 관리 | 개발자 | LangChain |
| LangGraph 사용 적합성 | 매우 높음 | 낮음 |
| 코드 제어권 | 높음 | 낮음 |