ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • graph.invoke vs graph.stream — 에이전트 실행의 두 가지 방식
    IT 2026. 6. 26. 22:00
    graph.invoke vs graph.stream — 에이전트 실행의 두 가지 방식

    LangGraph 그래프를 완성했다. 이제 실행해야 한다. 실행 방법은 두 가지다: graph.invokegraph.stream. 단순히 "빠른 것"과 "느린 것"의 차이가 아니다. 두 방식은 에이전트의 내부 동작을 어떻게 관찰하느냐의 차이다—그리고 이것이 디버깅에서 결정적인 역할을 한다.

    invoke: 결과만 받는 동기 실행

    graph.invoke는 그래프 전체를 실행하고 최종 상태를 반환한다. 에이전트가 중간에 도구를 세 번 호출하든 한 번도 안 하든, 호출자 입장에서는 모른다. 완료됐을 때 최종 messages 리스트만 돌아온다.

    from langchain_core.messages import HumanMessage
    
    # 입력: messages 리스트로 감싸서 전달
    result = graph.invoke({"messages": [HumanMessage(content="3 곱하기 7은?")]})
    
    # 출력: 최종 상태 딕셔너리. messages[-1]이 마지막 응답
    final = result["messages"][-1].content
    print(final)  # "3 곱하기 7은 21입니다."
    

    코드가 간결하다. 입력을 넣으면 최종 답이 나온다. 사용자에게 결과만 보여주면 되는 프로덕션 환경에서는 이것으로 충분하다. 그러나 "왜 이 답이 나왔는가"를 추적할 수 없다는 문제가 있다. 에이전트가 잘못된 도구를 골랐거나, 엉뚱한 인자를 넘겼거나, 루프를 예상보다 많이 돌았어도 결과만 보면 알 수 없다.

    diagram

    invoke는 그래프 내부에서 노드가 몇 번 실행되든 모두 마친 뒤에 단 한 번 값을 반환한다. 루프 구조(agent → tools → agent → ...)가 내부에서 반복되지만 호출자는 완료까지 대기한다. messages에는 전체 대화 이력이 누적되어 있어 사후 분석이 가능하지만, 실행 중에는 아무것도 볼 수 없다. 놓치기 쉬운 함정: 위처럼 조건부 분기가 agent 노드에 걸린 표준 ReAct 루프에서는 tools 노드가 무조건 agent로 되돌아가므로 마지막 메시지는 항상 최종 AIMessage다—도구 실행 후 LLM이 그 결과를 평가하는 단계가 반드시 한 번 더 붙기 때문이다. 다만 그래프 위상이 다르면 얘기가 달라진다. tools 노드가 END로 직접 연결되거나(도구 결과를 그대로 반환), interrupt_after=["tools"]로 도구 실행 직후 일시정지하면 result["messages"][-1]이 ToolMessage일 수 있다. 즉 임의의 그래프를 다룰 때는 마지막 메시지 타입을 가정하지 말고, 필요하면 AIMessage인 마지막 항목을 명시적으로 찾아야 한다.

    stream: 노드마다 이벤트를 방출하는 실시간 실행

    graph.stream은 각 노드가 실행될 때마다 그 결과를 이벤트로 방출한다. stream_mode="updates"를 사용하면 각 이벤트는 "이 노드가 이 내용을 상태에 추가했다"는 업데이트만 담는다.

    for event in graph.stream(
        {"messages": [HumanMessage(content="3 곱하기 7은?")]},
        stream_mode="updates"  # 전체 상태 대신 각 노드의 변경사항만 방출
    ):
        for node_name, node_output in event.items():
            if "messages" not in node_output:
                continue
    
            msg = node_output["messages"][-1]
    
            # 도구 호출 단계: tool_calls 필드 존재 여부로 판별
            if hasattr(msg, 'tool_calls') and msg.tool_calls:
                tool = msg.tool_calls[0]
                print(f"[{node_name}] 도구 호출: {tool['name']}({tool['args']})")
    
            # 텍스트 응답 단계: ToolMessage 또는 최종 AIMessage
            elif msg.content:
                print(f"[{node_name}] 응답: {msg.content}")
    

    실행하면 이런 출력이 나온다:

    [agent] 도구 호출: multiply({'a': 3, 'b': 7})
    [tools] 응답: 21
    [agent] 응답: 3 곱하기 7은 21입니다.
    

    diagram

    stream은 그래프가 내부적으로 진행되는 매 단계를 바깥으로 노출한다. 호출자는 완료를 기다리지 않고 각 노드의 결과를 순서대로 처리할 수 있다. 이벤트 하나는 {노드이름: 상태_업데이트} 형태다. invoke와 비교하면 같은 그래프가 같은 경로로 실행되지만, 관찰자가 내부를 들여다볼 수 있느냐가 다르다. 놓치기 쉬운 함정은 stream_mode 기본값이 "values"라는 것이다—"values"는 매 노드마다 전체 상태를 방출하므로 messages 리스트가 계속 중복해서 출력된다. "updates"를 명시해야 각 노드의 변경분만 받을 수 있다. 더 중요한 건 두 모드의 이벤트 모양이 다르다는 점이다. "updates"의 이벤트는 {노드이름: 상태_업데이트} 형태라 위 코드처럼 event.items()로 노드명과 변경분을 분리할 수 있지만, "values"의 이벤트는 상태 딕셔너리 그 자체({"messages": [...]})다—같은 순회 코드를 돌리면 node_name 자리에 "messages" 문자열이 들어와 깨진다. 그래서 단계별 추적 코드는 stream_mode="updates"를 반드시 명시해야 한다.

    stream 이벤트 구조 분해

    diagram

    이벤트 하나를 분해하면 계층 구조가 보인다. 최상단의 node_name이 어느 노드가 실행됐는지 알려주고, node_output 안의 messages 리스트에 그 노드가 추가한 메시지가 들어 있다. 메시지 타입이 세 종류라는 것이 핵심이다: 도구 호출 결정(AIMessage with tool_calls), 도구 실행 결과(ToolMessage), 최종 답변(AIMessage with content). 이 구조를 이해하면 에이전트의 전체 추론 과정을 단계별로 로깅할 수 있다. 놓치기 쉬운 함정은 hasattr(msg, 'tool_calls')만으로는 부족하다는 것이다—tool_calls 속성이 있어도 빈 리스트일 수 있으므로 msg.tool_calls의 truthy 여부를 함께 확인해야 한다.

    어느 상황에 무엇을 쓸까

    두 방식의 선택 기준은 명확하다. 최종 답변만 필요한 프로덕션 코드라면 invoke가 간결하다. 에이전트가 왜 그런 결정을 내렸는지 추적하거나, 중간 결과를 사용자에게 실시간으로 보여줘야 한다면 stream이 필요하다. 개발 중이라면 stream을 먼저 쓰는 것이 좋다. 에이전트가 어떤 도구를 어떤 순서로 호출하는지 눈으로 확인하지 않으면 예상과 다른 동작을 놓치기 쉽다.

    흥미로운 점은 두 방식이 동일한 그래프를 실행한다는 것이다. invoke와 stream 사이에 성능 차이는 거의 없다—stream이 각 이벤트를 방출할 때 약간의 오버헤드가 있을 뿐이다. 결국 "무엇을 관찰하고 싶은가"의 문제다. LLM 기반 에이전트의 내부 동작은 불투명하기 때문에, stream으로 각 단계를 추적하는 것이 에이전트를 이해하고 개선하는 가장 빠른 방법이다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.