5장. LLM 외부 도구 연동
에이전트에게 고리 맡기기
심부름 한 번에 끝나는 일도 있지만, "제주 상품 가운데 제일 싼 것으로 어른 둘 요금을 알려 줘"는 두 번이 필요합니다. 먼저 상품을 찾고, 찾은 상품코드로 요금을 계산해야 합니다. 일 잘하는 비서는 이럴 때 한 번 다녀와서 결과를 보고, 다음 심부름을 스스로 정해 또 다녀옵니다. 더 다녀올 것이 없으면 그제야 보고합니다. 에이전트가 이 비서입니다.
네 상자는 한 바퀴를 이루고, 넷째 상자에서 첫째로 돌아갑니다. 모델을 부르고(1), 답에 tool_calls 가 있으면(2) 도구를 실행해 결과를 기록에 붙이고(3), 늘어난 기록으로 다시 모델을 부릅니다(4). tool_calls 가 비어 있는 답이 나오면 고리를 빠져나와 그 답을 최종 답으로 냅니다. 여기서 비서가 에이전트, 심부름 한 번이 고리 한 바퀴입니다. 비유와 다른 점은, 비서는 지치면 쉬지만 에이전트는 모델이 계속 도구를 부르면 끝없이 돌 수 있어서 바퀴 수의 상한을 두어야 한다는 것입니다.
손으로 돌리기와 에이전트
| 견줄 것 | 손으로 돌리기(실습 2·3) | create_agent(실습 5) |
|---|---|---|
| 모델 부르기 | model_with_tools.invoke 를 우리가 부른다 | 에이전트가 부른다 |
| 도구 실행 | 우리 코드가 tool_calls 를 돌며 실행한다 | 에이전트가 실행한다 |
| 몇 바퀴 | 우리가 정한 만큼 | tool_calls 가 빌 때까지 |
| 주는 것 | 모델, 도구, 메시지 | create_agent(model, tools=[...], system_prompt="...") |
| 돌려받는 것 | 마지막 AIMessage | {"messages": [...]}, 맨 끝이 최종 답 |
| 좋은 때 | 고리의 짜임을 배울 때, 한 번만 부를 때 | 몇 번 부를지 모를 때 |
짧은 예시
에이전트 속의 고리를 직접 짜 봅니다. 모델 자리에는 "모델이 차례로 낼 법한 답 두 개"를 미리 적어 두었습니다(지어낸 답입니다). 진짜 에이전트라면 next(scripted) 자리에서 모델을 부릅니다.
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
PRICES = {"P01": 39, "P06": 33}
@tool
def calc_price(code: str, adults: int, children: int = 0) -> str:
"""상품코드와 인원으로 총 여행 요금(만 원)을 계산한다. 아동 요금은 어른의 80% 이다."""
price = PRICES[code]
return f"{code} 어른 {adults}명, 아동 {children}명: {round(price * adults + price * 0.8 * children, 1)}만 원"
# 모델이 차례로 낼 법한 답: 첫 번째는 도구 호출, 두 번째는 최종 답(지어낸 것)
scripted = iter([
AIMessage("", tool_calls=[{"name": "calc_price", "args": {"code": "P06", "adults": 2, "children": 2}, "id": "call_1"}]),
AIMessage("P06 상품은 어른 2명, 아이 2명이면 모두 118.8만 원입니다."),
])
messages = [HumanMessage("P06 에 어른 2명, 아이 2명이면 얼마예요?")]
for turn in range(1, 6): # 끝없이 돌지 않게 다섯 바퀴를 상한으로 둔다
ai = next(scripted) # 진짜 에이전트라면 model_with_tools.invoke(messages)
messages.append(ai)
if not ai.tool_calls: # 부를 도구가 없으면 고리를 빠져나간다
break
for call in ai.tool_calls:
messages.append(calc_price.invoke(call))
print(f"{turn}바퀴: 도구 {len(ai.tool_calls)}개 실행 → {messages[-1].content}")
print("바퀴 수:", turn, "| 메시지:", [m.type for m in messages])
print("최종 답:", messages[-1].content)1바퀴: 도구 1개 실행 → P06 어른 2명, 아동 2명: 118.8만 원
바퀴 수: 2 | 메시지: ['human', 'ai', 'tool', 'ai']
최종 답: P06 상품은 어른 2명, 아이 2명이면 모두 118.8만 원입니다.첫 바퀴에서 도구를 한 번 실행했고, 둘째 바퀴의 답에는 tool_calls 가 없어 고리를 빠져나왔습니다. 메시지는 human, ai, tool, ai 의 네 개로, 앞 절 그림의 차례 그대로입니다. create_agent 는 이 고리를 진짜 모델로 대신 돌려 주고, 상한도 따로 정해 둡니다.
코드가 의미하는 것
이 코드는 비서가 심부름을 다녀올 때마다 장부에 적고, 더 다녀올 곳이 없으면 보고하는 고리를 그대로 짠 것입니다. 모델 자리에는 미리 적어 둔 답 두 개(지어낸 것)를 넣었습니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
scripted = iter([...]) 와 next(scripted) | 미리 적은 답을 하나씩 꺼낸다 | 진짜 에이전트에서 model_with_tools.invoke(messages) 가 설 자리입니다. 모델을 부를 때마다 다음 답이 나온다고 본 것입니다 |
for turn in range(1, 6) | 고리를 다섯 바퀴까지만 돌린다 | 앞 그림에서 말한 바퀴 수의 상한입니다. 모델이 끝없이 도구를 불러도 프로그램이 멈추게 합니다 |
if not ai.tool_calls: break | 요청이 없으면 고리를 빠져나간다 | 에이전트가 "일이 끝났다"고 보는 기준입니다. 글 답만 있는 메시지가 곧 최종 답입니다 |
messages.append(calc_price.invoke(call)) | 요청마다 실행해 결과를 기록에 붙인다 | 다음 바퀴에서 모델이 이 결과를 읽습니다. 기록이 바퀴마다 길어지는 까닭입니다 |
결과에서 읽을 것은 두 가지입니다.
- 고리는 두 바퀴 돌았습니다. 첫 바퀴에서 도구 1개를 실행해 118.8만 원을 얻고, 둘째 바퀴의 답에
tool_calls가 없어바퀴 수: 2로 끝났습니다. 바퀴마다 모델을 한 번씩 부르므로 진짜 에이전트라면 모델 호출도 두 번입니다. - 최종 답은 도구 결과를 옮긴 것입니다. 최종 답의 118.8만 원은 첫 바퀴의 도구 결과와 같습니다. 이 예시에서는 우리가 그렇게 적어 넣었고, 진짜 모델은 기록의 tool 메시지를 읽고 이 숫자를 옮겨 씁니다.
주의할 점
- 에이전트는 모델을 여러 번 부르므로 토큰과 시간이 질문마다 다릅니다. 도구 결과가 길면 그 글이 다음 호출의 입력이 되어 토큰이 빠르게 늘어납니다.
- 에이전트가 같은 도구를 되풀이해 부르면 시스템 지시나 도구 설명이 모호한 경우가 많습니다. 실습 5와 프로젝트처럼 어떤 도구를 몇 번 불렀는지 찍어 봅니다.
create_agent는 LangChain 1.x 의 새 이름입니다. 인터넷의 옛 글에 나오는initialize_agent,AgentExecutor는 langchain 1.x 본체에 없고, 4장의 옛 메모리 클래스처럼langchain-classic패키지로 옮겨졌습니다.