이삿짐센터에 짐을 맡기면 상자를 나르고 쌓는 일은 대신해 주지만 귀중품은 직접 챙기라고 합니다. 맡길 수 있는 일과 맡길 수 없는 일이 따로 있습니다.
직접 짠 Agent 루프도 마찬가지입니다. LangChain의 create_agent는 모델을 부르고 도구를 실행하고 결과를 붙이는 24줄을 한 번의 호출로 대신합니다. 그런데 줄마다 어디로 옮겨 갔는지 알아야 프레임워크가 동작하지 않을 때 고칠 수 있습니다. 아래 그림은 24줄을 기능별 짐 상자로 나눈 것입니다. 상자를 눌러 트럭에 실어 보세요.
줄 수는 앞 글에서 짠 루프에 오류 처리를 더한 코드를 기능별로 센 값입니다. create_agent 호출은 줄여 쓴 예시입니다. 직접 짠 루프에 쓰는 줄 수는 코드 스타일마다 조금씩 다릅니다.
create_agent에는 모델과 도구와 역할(system_prompt)을 넘깁니다. 모델은 앞서 쓰던 "회사:모델" 문자열을 그대로 쓸 수 있습니다. 호출은 invoke이고 입력은 메시지 목록을 "messages" 칸에 담은 딕셔너리입니다.
결과도 같은 모양의 딕셔너리입니다. result["messages"]에 주고받은 메시지가 순서대로 담기고 마지막 메시지가 최종 답입니다. 직접 짠 루프에서 return이 하던 일을 이제 마지막 메시지가 맡습니다.
from langchain.agents import create_agent
# 1 모델·도구·역할을 한 번에 넘긴다
agent = create_agent(
"anthropic:claude-sonnet-5-5",
tools=[get_ticket, list_my_tickets],
system_prompt="너는 IT 헬프데스크 상담원이다.",
context_schema=UserContext,
)
# 2 호출하면 루프가 돈다
result = agent.invoke(
{"messages": [{"role": "user", "content": "내 티켓 상태 알려줘"}]},
context=UserContext(user_id="kim"))
# 3 마지막 메시지가 최종 답이다
print(result["messages"][-1].content)출력은 예시 답입니다. 코드는 langchain 1.4 기준이고 도구 두 개의 정의는 생략했습니다.
system_prompt는 모델에게 역할을 알리는 글이라서 모든 호출 맨 앞에 붙습니다. 도구를 비워 두면 도구 없이 모델만 한 번 부르는 Agent가 됩니다.messages 칸에 질문을 담고 context=로 사용자 정보를 넘깁니다. 이 호출 안에서 모델 호출과 도구 실행이 반복되다가 모델이 도구 없이 답하면 끝납니다.result["messages"]에 쌓여 있고 그중 마지막 메시지가 사용자에게 보여 줄 답입니다.invoke는 모든 단계가 끝난 뒤에 결과를 줍니다. 중간 과정을 보려면 stream을 씁니다. 스트리밍(streaming)은 결과를 다 만들기 전에 조각으로 받는 방식입니다. 모델 글자를 조각으로 받을 때 쓰던 이름과 같습니다.
stream_mode로 조각의 종류를 고릅니다. "updates"는 단계가 끝날 때마다 어느 노드가 무엇을 새로 냈는지를 주고 "values"는 그 시점의 전체 상태(메시지 전부)를 줍니다. 스크롤하며 조각이 하나씩 도착하는 모습을 보세요.
질문이 시작 표시를 지나 model 노드로 들어갑니다. 아직 받은 조각은 없습니다.
모델이 list_my_tickets 도구를 요청했습니다. 단계 하나가 끝나 조각 하나가 도착했습니다.
도구가 실행되어 티켓 번호 T-1042를 돌려줍니다. 이 결과는 tool 메시지로 쌓입니다.
모델이 번호를 읽고 이번에는 get_ticket을 요청합니다.
도구가 "부품 대기, 도착 예정 10월 18일"을 돌려줍니다.
모델이 도구 없이 글로 답하자 길이 끝으로 갑니다. 출력에서 model과 tools가 번갈아 나오는 모양이 직접 짠 루프의 한 바퀴입니다.
# 4 단계마다 어느 노드가 무엇을 냈는지 찍는다
for chunk in agent.stream(
msg, context=ctx, stream_mode="updates"):
for node, update in chunk.items():
last = update["messages"][-1]
calls = getattr(last, "tool_calls", None)
call = calls[0]["name"] if calls else last.content
print(node, "→", last.type, call)출력은 예시 답입니다. msg와 ctx는 위 호출에 넘긴 값입니다.
{"model": {...}}나 {"tools": {...}} 모양의 조각이 하나씩 옵니다. 열쇠가 노드 이름이고 값이 그 노드가 새로 만든 메시지입니다.LangChain 문서에 따르면 Agent는 LangGraph 위에 만들어집니다. LangGraph는 일의 흐름을 그래프(graph)로 짜는 도구입니다. 그래프는 할 일 한 칸인 노드(node)와 다음에 갈 곳을 가리키는 화살표인 엣지(edge)로 이루어진 지도입니다.
create_agent가 돌려주는 값은 이렇게 짠 그래프를 실행할 수 있게 완성한 객체입니다. agent.get_graph()로 안쪽을 열어 볼 수 있습니다. 실제로 열어 보면 노드는 model과 tools 둘이고 시작과 끝 표시가 붙어 있습니다.
모델 노드 뒤에서 길이 갈립니다. 응답에 도구 요청이 있으면 tools로 가고 없으면 끝납니다. 도구 목록이 비어 있으면 tools 노드 자체가 없습니다. 그림에서 직접 고르면 길이 켜집니다.
노드 이름은 get_graph()로 확인한 값이고 화살표 방향은 문서와 Mermaid 출력을 줄여 그린 것입니다. 켜진 길이 이번 호출에서 지나가는 길입니다.
graph TD;
__start__([__start__])
model(model)
tools(tools)
__start__ --> model;
model -.-> __end__;
model -.-> tools;
tools -.-> model;agent.get_graph().draw_mermaid()의 출력을 줄여 옮긴 것입니다. 점선 화살표는 조건에 따라 갈라지는 길입니다.
모델이 도구를 두 번 부른 뒤 답하는 경우를 직접 확인한 결과를 계단으로 그렸습니다. 한도 5에서는 실패했고 6에서는 성공했습니다. 6단계 칸은 일이 끝났다는 표시로 가는 단계입니다.
직접 짠 루프의 max_turns는 모델을 부르는 바퀴를 셌습니다. create_agent의 한도는 노드가 한 번 실행되는 단계를 셉니다. 모델 노드 한 번과 도구 노드 한 번이 각각 한 단계라서 바퀴 하나가 두 단계입니다.
한도는 config={"recursion_limit": 숫자}로 정합니다. 한도에 걸리면 GraphRecursionError가 납니다. 확인해 보니 이 Agent의 기본 한도는 9999였습니다. 사실상 제한이 없는 값이라서 직접 낮추지 않으면 모델이 도구만 부르는 상황에서 오래 돕니다.
그림에서 도구를 두 번 쓰는 작업은 한도 5에서 실패하고 6에서 성공합니다. 마지막 모델 응답 뒤에 끝으로 가는 단계까지 세는 것으로 보입니다. 한도는 넉넉하게 정해야 합니다.
from langgraph.errors import GraphRecursionError
# 5 단계 수 한도를 정하고 걸리면 직접 처리한다
try:
agent.invoke(msg, context=ctx, config={"recursion_limit": 12})
except GraphRecursionError:
print("단계 한도에 걸렸습니다")config에 recursion_limit을 넣고 초과 예외를 잡습니다. 직접 짠 루프의 마지막 줄이 "N번 안에 끝내지 못했습니다"를 돌려주던 일을 except가 맡습니다. 12는 예시이고 평소 단계 수를 재어 여유 있게 정합니다.루프는 맡기고 한도와 오류 처리는 직접 정합니다.
도구 이름을 잘못 부르면 "그런 도구는 없고 이 중에서 고르라"는 안내가 ToolMessage(도구 결과를 담는 메시지)로 돌아가 모델이 고칩니다. 도구 안에서 난 에러는 다릅니다. 기본값에서는 아무도 처리하지 않아 그대로 위로 던져지고 Agent가 멈춥니다. 앞서 본 방식으로 오류 결과로 바꿔야 합니다. 다음 글에서는 이 루프의 앞뒤에 코드를 끼워 넣는 장치인 Middleware를 알아보겠습니다.