~/csslab.dev/blog $ cat langchain-context-vs-state

LangChain 에이전트에 값 넘길 때 context 냐 state 냐

발행12LLM

에이전트에 값을 하나 넘기려는데 자리가 셋임. 잘못 고르면 이렇게 나타남.

  • 도구가 값을 바꿨는데 다음 스텝에서 안 보임.
  • 대화를 이어서 열었더니 넣어둔 값이 사라져 있음.
  • 안 바뀌는 값 하나를 넣었을 뿐인데 저장이 무거워짐.

셋 다 값을 잘못된 자리에 둬서 생김. 자리마다 사는 기간과 저장 여부가 다른데, 그 차이를 보려면 먼저 실행이 어떻게 도는지를 알아야 함.

먼저 — invoke 한 번이 어떻게 도는가

에이전트를 한 번 부르면 그래프가 노드를 여러 번 돎.

invoke 시작
  → 모델 노드   도구를 부를지 판단
  → 도구 노드   실제로 실행
  → 모델 노드   결과를 보고 다시 판단
  → ...         끝날 때까지 반복
invoke 끝

이 한 바퀴 한 바퀴가 스텝임. 앞으로 나올 "실행 도중 바뀐다"는 말은 전부 스텝과 스텝 사이에 값이 달라진다는 뜻임.

값을 어디 둘지는 결국 이 질문임 — 이 값이 스텝 사이에서 어떻게 취급되길 원하는가. 흘러가며 바뀌어야 하는가, 처음부터 끝까지 고정이어야 하는가, 아니면 애초에 내 값이 아니라 프레임워크가 실행을 식별하려고 쓰는 값인가.

하나씩 봄.

state — 스텝 사이를 흘러가는 값

state 는 그래프가 노드에서 노드로 들고 다니는 값임. 스키마를 먼저 선언함.

from langgraph.graph import MessagesState

class State(MessagesState):   # messages 는 상속받고
    step: int                 # 내가 쓸 키를 추가

MessagesState 를 상속하면 messages 키가 딸려 옴. 대화 이력이 여기 쌓임.

노드는 반환값으로 state 를 갱신함. 딕셔너리를 돌려주면 그 키가 갱신됨.

def llm_call(state: State):
    return {
        "messages": [model.invoke(state["messages"])],
        "step": state.get("step", 0) + 1,
    }

이게 "가변"의 실체임. 스텝마다 노드가 덮어쓰므로 지금 값이 몇인지는 물어봐야 알 수 있음.

도구에서 갱신하려면 Command 를 쓴다

노드는 반환값이 곧 state 갱신분이지만, 도구는 그 자리가 이미 차 있음 — 도구의 반환값은 모델에게 돌려줄 결과 문자열로 쓰임. 그래서 "이건 state 갱신이다"라고 표시해 주는 봉투가 필요하고, 그게 Command 임.

from langchain.tools import ToolRuntime, tool
from langgraph.types import Command
from langchain.messages import ToolMessage

@tool
def advance_step(runtime: ToolRuntime) -> Command:
    """진행 스텝을 하나 올린다."""
    n = runtime.state.get("step", 0)      # 지금 state 를 읽고
    return Command(update={               # 갱신분을 봉투에 담아 반환
        "step": n + 1,
        "messages": [ToolMessage("스텝을 올렸습니다", tool_call_id=runtime.tool_call_id)],
    })

messages 를 같이 넣어야 하는 이유 — 모델은 자기가 부른 도구 호출마다 짝이 되는 응답 메시지를 기대함. 평범한 값을 반환하면 프레임워크가 그 ToolMessage 를 알아서 만들어 주는데, Command 로 봉투를 씌우는 순간 그 책임이 내 쪽으로 넘어옴. 빠뜨리면 이력이 깨져서 다음 호출에서 provider 가 거부함.

Command 는 값 갱신만 하는 게 아님.

필드하는 일
updatestate 를 갱신함
goto다음에 갈 노드를 지정함. 도구가 흐름을 직접 꺾음
resumeinterrupt 로 멈춰 있던 실행을 사람 입력으로 되살림

하나 더 — 반환된 Command 를 그래프에 반영해 주는 건 prebuilt ToolNode 임. 도구 호출 노드를 직접 만들었으면 손수 전파해야 하고, 안 하면 갱신이 조용히 사라짐.

checkpoint — state 가 남는 곳

여기까지는 실행 하나가 도는 동안의 이야기임. 그런데 대화는 보통 한 번으로 안 끝남. 그래서 checkpointer 를 붙이면 스텝마다 state 를 통째로 저장함. 그 저장 단위가 checkpoint 임.

안을 열어보면 이렇게 생겼음.

CheckpointTuple(
    config={'configurable': {'thread_id': '1', 'checkpoint_id': '1f029ca3-...'}},
    checkpoint={
        'id': '1f029ca3-...',
        'ts': '2026-05-05T16:01:24+00:00',
        'channel_values': {                      # ← 아까 그 state 가 통째로
            'messages': [HumanMessage(...), AIMessage(...)],
            'step': 3,
        },
    },
    metadata={'source': 'loop', 'step': 4, 'thread_id': '1'},
    parent_config={'configurable': {'checkpoint_id': '1f029ca3-1790-...'}},
)

channel_values 가 곧 state 임. messages 든 내가 만든 step 이든 예외 없이 여기 직렬화돼 들어감. 그러니까 state 에 넣는다는 건 스텝마다 저장된다는 뜻이기도 함.

parent_config 가 이전 checkpoint 를 가리키니 스텝들이 사슬로 이어짐. 특정 지점으로 되감거나 거기서 분기하는 기능이 여기서 나옴.

다시 열기 — 여기서 config 가 필요해진다

저장했으면 꺼내 써야 함. 그런데 저장소에는 여러 대화의 checkpoint 가 섞여 있으니 어느 대화인지 말해 줘야 함. 그 자리가 config 임.

cfg = {"configurable": {"thread_id": "conv_42"}}

graph.invoke({"messages": [...]}, cfg)   # 첫 대화
graph.invoke({"messages": [...]}, cfg)   # 30분 뒤, 같은 thread_id → 이어서

thread_id 를 주면 checkpointer 가 그 스레드의 마지막 checkpoint 를 찾아 channel_values 를 복원함. state 가 자동으로 돌아오는 게 이 대목임.

저장 구조가 (thread_id, checkpoint_ns, checkpoint_id) 를 기본 키로 쓰고, checkpoint_id 는 ULID 라 사전순 정렬이 곧 시간순 정렬임. 그래서 "이 스레드의 마지막 상태"를 훑지 않고 바로 집을 수 있음.

config 에는 thread_id 말고도 실행을 식별·관측하는 값이 들어감.

하는 일
thread_idcheckpointer 가 대화를 찾는 기본 키
run_id실행 하나를 가리키는 식별자. 추적·로깅에서 이걸로 묶음
callbacks · tags · metadata관측 도구가 붙는 자리

공통점은 전부 내 도메인 값이 아니라는 것임. 프레임워크가 실행을 찾고 묶으려고 쓰는 메타임. 노드에서 읽으려면 RunnableConfig 를 인자로 받음.

from langchain_core.runnables import RunnableConfig

def node(state: State, config: RunnableConfig) -> State:
    thread_id = config["configurable"].get("thread_id")

앞의 checkpoint 예시에서 configthread_id · checkpoint_id 가 보였는데, 저건 담긴 내용이 아니라 그 checkpoint 를 찾기 위한 주소임. config 자체는 저장되지 않음.

context — 저장할 이유가 없는 값

이제 남은 자리를 볼 차례임. user_id 를 생각해 봄.

이 값은 대화가 시작될 때 정해지고 끝까지 안 바뀜. state 에 넣으면 동작은 함 — 다만 안 바뀌는 값이 스텝마다 직렬화돼 저장됨. 스텝 20개짜리 실행이면 같은 값을 20번 쓰는 셈임. 저장할 이유가 없는 값임.

그런 값을 두는 자리가 context 임. 스키마를 선언하고 invoke 할 때 넣음.

from dataclasses import dataclass

@dataclass
class Context:
    user_id: str
    tenant_id: str

graph = StateGraph(State, context_schema=Context).compile()

graph.invoke(
    {"messages": [...]},
    cfg,
    context=Context(user_id="u_123", tenant_id="t_9"),
)

도구에서는 runtime.context 로 읽음. 읽기만 됨 — 실행 하나가 도는 동안 아무도 못 바꿈.

@tool
def search_docs(query: str, runtime: ToolRuntime[Context, State]) -> str:
    """문서를 검색한다."""
    return db.search(query, tenant=runtime.context.tenant_id)   # 항상 같은 테넌트

왜 이런 값들이 여기 모이나

context 에 들어가는 것들은 성격이 같음 — 한 요청을 누가 어떤 자격으로 보냈는가.

  • user_id — 지금 이 대화를 하는 사람. 도구가 DB 를 조회할 때 이 사람 것만 긁어야 함.
  • tenant_id — 한 서비스에 회사 여러 곳이 입주해 있을 때(멀티테넌시) 어느 회사인가. A사 직원이 B사 문서를 검색하면 사고임.
  • 권한 — 쓰기까지 되는가 읽기만 되는가.

에이전트가 실행 도중에 자기 테넌트를 갈아탈 일은 없음. 갈아탈 수 있으면 그게 권한 사고임. 바뀌면 안 되는 값이라서 못 바꾸는 자리에 두는 것이기도 함.

대신 매번 다시 넣어야 한다

context 는 checkpoint 에 저장되지 않음. 실행 하나를 위한 값이지 그래프가 들고 다니는 상태가 아니기 때문임. 그래서 같은 대화를 이어서 열어도 context 는 복원되지 않음.

cfg = {"configurable": {"thread_id": "conv_42"}}
ctx = Context(user_id="u_123", tenant_id="t_9")

graph.invoke({"messages": [...]}, cfg, context=ctx)

# 30분 뒤, 같은 대화
graph.invoke({"messages": [...]}, cfg, context=ctx)   # ← 또 넣어야 함

두 번째 호출에서 빠뜨리면 도구가 runtime.context.tenant_id 를 읽는 순간 터짐. "첫 호출에 넣었으니 남아 있겠지"가 흔한 함정임. 매 요청마다 다시 조립해 넣는 게 맞고, 실은 그래야 안전함 — 권한이 그 사이 회수됐으면 새 값이 반영돼야 하니까.

셋의 경계

여기까지 오면 표가 읽힘.

contextstateconfig
누구 값인가내 도메인 값내 도메인 값프레임워크의 실행 메타
실행 도중불변노드·도구가 갱신불변
checkpoint저장 안 됨저장됨주소로만 쓰임
다시 열면다시 넣어야 함자동 복원다시 넣어야 함
넣는 곳invoke(context=)초깃값 + Commandinvoke(config=)
읽는 곳runtime.contextruntime.stateRunnableConfig 인자
user_id, tenant_id, 권한messages, 진행 스텝thread_id, run_id

무엇이 토큰을 먹나

여기서 자주 붙여 생각하는 것 하나를 떼어놓아야 함. state 를 키운다고 토큰을 더 먹지는 않음.

state 는 프롬프트가 아님. 모델에 실제로 가는 건 노드가 직접 넣어 준 것뿐임.

def llm_call(state: State):
    return {"messages": [
        model.invoke([SystemMessage(content="...")] + state["messages"])
        #             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #             이 리스트에 담긴 것만 모델이 봄. state["step"] 은 안 감.
    ]}

state["step"] 이 3이든 30000이든 모델은 모름. 프롬프트에 안 실었으니까. custom 키를 모델에게 보여주고 싶으면 시스템 메시지에 직접 넣거나 미들웨어로 주입해야 하고, 그때부터 토큰을 먹음.

그래서 비용이 이렇게 갈림.

무엇이 커지나무슨 비용
messages토큰. 매 턴 프롬프트에 통째로 들어감
custom state 키직렬화·저장. 스텝마다 다시 씀. 토큰은 안 먹음
context거의 없음. 저장도 안 되고 프롬프트에도 안 감

custom 키를 키웠을 때 아픈 건 요금이 아니라 쓰기임. 1MB 짜리 값을 state 에 넣으면 스텝 20개짜리 실행에서 20번 직렬화됨. 조회한 문서 전문 같은 건 외부 저장소에 두고 식별자만 state 로 들고 다니는 게 맞음.

messages 는 반대로 관리 대상임. 길어지면 요금과 지연이 같이 오르니 요약·잘라내기 전략이 따로 필요한데, 그건 이 글 밖의 주제임.

넘기기 전 다섯 줄 점검

  • 이 값이 스텝 사이에 바뀌는가 — 바뀌면 state, 아니면 context
  • 대화를 다시 열 때 살아 있어야 하는가 — 그러면 state (context 는 매번 다시 넣어야 함)
  • state 에 넣은 값이 스텝마다 직렬화돼도 괜찮은 크기인가
  • custom 키를 .get() 으로 접근하고 있는가 — 미들웨어가 나중에 넣는 키는 첫 호출에 비어 있을 수 있음
  • run_id · thread_id 를 context 에 넣지 않았는가 — 그건 프레임워크 자리임

연결된 문서