개발? 새발!
개발? 새발!

LangGraph 대화는 보존하고 LLM 입력만 줄이기: trim_messages 검증 예제

2026.10.08

저장된 대화를 유지하려면 모델 호출 노드 안에서 trim_messages로 입력 목록을 만들고, 그 목록만 model.invoke에 전달하면 돼요. 노드는 새 응답만 messages 업데이트로 반환합니다. 축소한 목록으로 저장 상태를 교체하지 않는 것이 핵심이에요. 공식 LangGraph 문서도 이 패턴을 사용해요. 여기서는 외부 API 없이 입력을 기록하는 가짜 모델로 저장 이력과 호출 입력을 따로 확인해 봅니다. LangGraph 메모리 문서

노트북 메인보드 슬롯에 장착된 Crucial 4GB DDR4 RAM 모듈
실행 중 데이터를 유지하는 RAM의 실물 참고 사진이에요. 예제의 InMemorySaver도 프로세스 메모리를 사용하며, 재시작 후 대화를 복구하려면 별도의 영속 저장 구성이 필요합니다. · Diego Torres Silvestre / Wikimedia Commons · CC BY 2.0 · No edits; Wikimedia supplied image size

저장 이력과 모델 입력 분리 검증

# trim_demo.py
# 설치: python -m pip install -U langgraph langchain-core
# 실행: python trim_demo.py
# 외부 LLM API를 호출하지 않습니다.

from copy import deepcopy

from langchain_core.messages import (
    AIMessage,
    HumanMessage,
    SystemMessage,
    trim_messages,
)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph


class RecordingModel:
    def __init__(self):
        self.inputs = []

    def invoke(self, messages):
        self.inputs.append(deepcopy(messages))
        turn = len(self.inputs)
        return AIMessage(content=f"응답 {turn}", id=f"a{turn}")


model = RecordingModel()


def call_model(state: MessagesState):
    llm_input = trim_messages(
        state["messages"],
        max_tokens=4,       # len을 쓰므로 토큰이 아닌 메시지 개수
        token_counter=len,
        strategy="last",
        start_on="human",
        end_on="human",    # 도구 없는 채팅 예제
        include_system=True,
        allow_partial=False,
    )

    latest = state["messages"][-1]
    if latest.type != "human":
        raise ValueError("이 예제는 사용자 질문 직후에 호출해야 합니다.")
    if not any(m.id == latest.id for m in llm_input):
        raise ValueError("최신 질문이 입력 예산에 들어가지 않습니다.")

    response = model.invoke(llm_input)
    return {"messages": [response]}  # 축소 목록을 상태에 반환하지 않음


builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "trim-demo"}}

questions = [
    "내 이름은 민수야.",
    "Python으로 챗봇을 만들고 있어.",
    "저장 이력과 모델 입력을 분리해 줘.",
]

for turn, question in enumerate(questions, start=1):
    incoming = []
    if turn == 1:
        incoming.append(SystemMessage(content="짧게 답하세요.", id="s"))
    incoming.append(HumanMessage(content=question, id=f"h{turn}"))
    # 같은 스레드에 이번 턴의 새 입력만 전달합니다.
    graph.invoke({"messages": incoming}, config)

saved = graph.get_state(config).values["messages"]
sent = model.inputs[-1]

expected_saved = [
    ("s", "짧게 답하세요."),
    ("h1", questions[0]),
    ("a1", "응답 1"),
    ("h2", questions[1]),
    ("a2", "응답 2"),
    ("h3", questions[2]),
    ("a3", "응답 3"),
]
assert [(m.id, m.content) for m in saved] == expected_saved
assert [m.id for m in sent] == ["s", "h2", "a2", "h3"]
assert len(sent) <= 4
assert not any(m.id == "h1" for m in sent)
assert any(m.id == "h1" for m in saved)

print("마지막 모델 입력:", [m.id for m in sent])
print("저장된 전체 이력:", [m.id for m in saved])
print("검증 통과: 입력은 축소됐고 기존 대화는 보존됐습니다.")

예제는 메시지 개수로 경계를 먼저 확인해요

‘저장 이력과 모델 입력 분리 검증’ 코드를 trim_demo.py로 저장하고, 별도 가상환경에서 python -m pip install -U langgraph langchain-core로 의존성을 설치한 뒤 python trim_demo.py로 실행하세요. API 키나 모델 제공자 패키지는 필요하지 않아요. RecordingModel은 전달받은 메시지를 복사해 기록하고 정해진 답변을 만드는 예제용 객체예요. 답변 품질이나 실제 토큰 사용량을 측정하는 모델은 아닙니다.

여기서는 의도적으로 token_counter=len을 사용해요. 이 설정에서는 max_tokens=4가 ‘4토큰’이 아니라 ‘메시지 최대 4개’를 뜻합니다. 공식 API 예제도 이 방식으로 경계를 확인해요. 처음부터 토크나이저를 연결하면 문장 길이에 따라 선택 결과가 달라지므로, 우선 어떤 메시지가 전달됐는지 ID로 검증하기 쉬운 조건을 잡은 거예요. trim_messages API

코드의 assert는 실행해서 확인할 기대 조건이며, 실행 결과를 측정해 옮긴 기록은 아니에요. 개수만 비교하지 않고 이전 메시지의 ID와 내용까지 확인합니다. 저장 메시지 수가 같아도 과거 내용이 바뀌었을 수 있기 때문이에요. 가짜 모델에 남기는 복사본도 같은 목적입니다. 호출 이후 상태 업데이트와 구분되는 입력 기록을 확보하는 거죠.

저장 상태를 결정하는 것은 노드의 반환값과 리듀서예요

MessagesState에는 messages 업데이트를 처리하는 add_messages 리듀서가 들어 있어요. 따라서 노드가 새 AIMessage 하나만 반환해도 기존 대화에 이어 붙일 수 있습니다. 직접 만든 상태 스키마로 바꾼다면 같은 동작이 자동으로 따라온다고 생각하면 안 돼요. 리듀서 없는 필드는 업데이트 값으로 덮어써지므로, 응답 하나만 반환했는데 과거 대화가 사라진다면 먼저 상태 정의를 확인하세요. 공식 Graph API 문서 원문

반대로 MessagesState에서 return {"messages": llm_input}을 반환한다고 빠진 과거 메시지가 삭제되지는 않아요. add_messages는 두 목록을 ID 기준으로 병합하며, 새 목록에 없는 기존 항목을 자동으로 지우는 방식이 아니거든요. 입력 선택과 저장 변경을 혼동하면 ‘줄였는데 왜 상태는 그대로지?’라는 상황이 생깁니다. 이 글의 목적에는 그대로 남는 것이 맞아요. add_messages API

ID도 보존 조건의 일부예요. 기존 메시지와 같은 ID의 응답을 반환하면 추가가 아니라 해당 메시지의 업데이트가 됩니다. 예제에서 사용자와 응답에 서로 다른 ID를 부여한 이유예요. 실제 서비스에서는 새 응답마다 과거 메시지와 충돌하지 않는 ID를 사용하고, 의도적으로 내용을 수정할 때만 기존 ID를 재사용하세요. add_messages API

실제 토큰 예산으로 바꿀 때는 전체 요청을 계산해요

메시지 개수 검증을 마쳤다면 langchain_core.messages.utils에서 count_tokens_approximately를 가져오고, trim_messages의 token_counter=len을 token_counter=count_tokens_approximately로 바꾸세요. max_tokens도 서비스에서 정한 입력 예산으로 바꾸면 됩니다. 이 함수는 빠른 추정치이며 모델별 정확한 토큰 수를 보장하지 않아요. 정확한 계산이 필요하면 해당 모델의 토크나이저를 사용하는 계수 방법을 선택해야 합니다. 근사 토큰 계산 API

예를 들어 설명용으로 전체 컨텍스트 한도를 16,000, 출력 예약을 2,000, 별도 도구 정의와 추가 입력을 1,000, 여유분을 1,000으로 잡으면 대화에 배정할 예산은 12,000이에요. 특정 모델의 사양이나 권장값이 아니라 예산 배분을 보여 주는 가정입니다. trim 이후 검색 문서나 지시문을 추가한다면 그 분량도 이 계산에 포함하세요. 축소한 목록만 검사하고 나중에 붙는 입력을 빠뜨리지 않는 것이 중요해요.

한국어 대화에 영어 설명문, JSON 도구 인자까지 섞인다면 ‘문자 수를 일정 비율로 나누면 충분하겠지’라고 가정하지 않는 편이 좋아요. 근사 함수의 기본 문자 비율은 일반적인 영어 텍스트를 기준으로 설명돼 있습니다. 운영에 사용할 대표 입력을 골라 모델별 계산값과 차이를 확인한 뒤 여유분을 정하세요. 근사 토큰 계산 API

최근 메시지를 고르더라도 대화 경계는 지켜야 해요

strategy="last"는 최근 부분을 고르고, start_on="human"은 선택된 대화의 시작을 사용자 메시지에 맞춥니다. include_system=True는 원래 목록의 첫 항목이 SystemMessage일 때 이를 유지하는 설정이에요. 중간에 있는 시스템 메시지를 찾아 앞으로 모아 주는 옵션은 아닙니다. 경계를 맞추는 과정에서 선택 항목이 더 줄 수 있으므로 예산을 반드시 꽉 채우는 함수로 생각하지 마세요. trim_messages API

예제는 도구 없는 채팅이어서 end_on="human"을 사용해요. 도구 실행 결과를 받은 뒤 모델을 다시 부르는 노드라면 이 설정을 그대로 복사하면 안 됩니다. 공식 LangGraph 예제는 end_on=("human", "tool")을 사용해요. 이때도 도구 결과에 대응하는 AIMessage의 도구 호출이 함께 남는지 별도로 검증해야 합니다. 끝 메시지 타입을 허용하는 것만으로 모든 도구 호출 조합의 유효성이 증명되지는 않아요. LangGraph 메모리 문서

긴 최신 질문 하나가 예산을 넘는 경우도 따로 처리하세요. allow_partial=False는 메시지를 중간에서 자르지 않는 설정이므로, 예산이 작으면 원하는 질문이 선택되지 않을 수 있어요. 예제는 최신 질문의 ID가 남지 않으면 호출을 중단합니다. 실제 화면에서는 입력을 줄이도록 안내하거나 별도의 문서 분할 경로로 보내는 정책을 붙일 수 있어요. 임의로 부분 메시지를 허용하면 질문의 앞부분이나 조건을 잃을 수 있으니 기능 요구에 맞춰 결정하세요.

저장돼 있다는 것과 모델이 기억한다는 것은 달라요

예제의 첫 질문에는 이름이 들어 있지만, 마지막 모델 입력에서는 그 질문이 제외되도록 구성했어요. 따라서 저장 이력에는 이름이 있어도 해당 호출의 모델이 그 이름을 읽는 것은 아닙니다. ‘아까 말한 이름이 뭐였지?’ 같은 질문까지 지원하려면 중요한 정보의 요약이나 필요한 과거 메시지의 검색을 입력 구성 단계에 추가하는 설계가 필요해요. 원문 보존 테스트가 통과했다고 장기 대화의 답변 품질까지 검증된 것은 아니에요.

또한 이 예제의 InMemorySaver는 실행 중인 프로세스의 메모리에서 확인하기 위한 저장소예요. RAM에 유지하는 실행 중 데이터와 재시작 후에도 복구할 영속 데이터를 구분해야 합니다. 재시작 후에도 대화를 이어 가야 한다면 데이터베이스 기반 체크포인터를 사용하세요. 매 요청에서 체크포인터를 새로 만들지 말고, 같은 대화를 이어 갈 때는 같은 thread_id를 사용하세요. 공식 문서도 운영 환경에서는 데이터베이스 기반 체크포인터를 안내합니다. LangGraph 메모리 문서

입력 축소는 저장 공간 정리 정책도 아니에요. 이 구현은 과거 messages를 제거하지 않으므로 이력은 계속 쌓입니다. 다음 검증은 예제의 짧은 질문 하나를 긴 실제 입력으로 바꾸는 것부터 시작해 보세요. 최신 질문이 입력에 남는지, 선택된 입력이 예산을 만족하는지, 기존 상태의 내용이 보존되는지를 각각 확인하면 저장 문제와 입력 예산 문제를 분리해서 볼 수 있어요.

이 블로그의 다음 이야기도 받아보세요

새 글 구독 (RSS)