커스텀 미들웨어 만들기
커스텀 미들웨어 만들기 (Custom middleware)
에이전트 실행 흐름의 특정 지점마다 훅을 구현해서 동작을 가로채는, 나만의 커스텀 미들웨어를 만드는 방법을 살펴볼게요. 에이전트가 모델을 부르기 전후, 도구를 실행하기 전후 등 원하는 시점에 코드를 끼워 넣을 수 있죠. 미리 만들어 둔 미들웨어가 컵라면이라면, 커스텀 미들웨어는 직접 국물을 내는 요리라고 할 수 있어요.
훅(Hooks)이 뭔가요?
미들웨어는 에이전트 실행을 가로채기 위해 두 가지 스타일의 훅을 제공해요.
- 노드 스타일 훅 (Node-style hooks): 특정 실행 지점에서 순차적으로 실행됩니다.
- 래핑 스타일 훅 (Wrap-style hooks): 각 모델 호출이나 도구 호출을 감싸서 실행되죠.
노드 스타일 훅
특정 실행 지점에서 순차적으로 실행돼요. 로깅, 검증, 상태 업데이트에 주로 쓰입니다. 필요에 따라 어떤 훅을 쓸지 고르면 돼요.
| 훅 | 실행 시점 |
|---|---|
before_agent |
에이전트 시작 전 (호출당 1회) |
before_model |
각 모델 호출 전 |
after_model |
각 모델 응답 후 |
after_agent |
에이전트 완료 후 (호출당 1회) |
랩 스타일 훅은 각 호출을 감싸서 실행에 대한 제어권을 줍니다.
| 훅 | 실행 시점 |
|---|---|
wrap_model_call |
각 모델 호출을 감싸서 |
wrap_tool_call |
각 도구 호출을 감싸서 |
데코레이터로 만드는 예시
from langchain.agents.middleware import before_model, after_model, AgentState
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing import Any
@before_model(can_jump_to=["end"])
def check_message_limit(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
if len(state["messages"]) >= 50:
return {
"messages": [AIMessage("Conversation limit reached.")],
"jump_to": "end"
}
return None
@after_model
def log_response(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"Model returned: {state['messages'][-1].content}")
return None
클래스로 만드는 예시
from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing import Any
class MessageLimitMiddleware(AgentMiddleware):
def __init__(self, max_messages: int = 50):
super().__init__()
self.max_messages = max_messages
@hook_config(can_jump_to=["end"])
def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
if len(state["messages"]) >= self.max_messages:
return {
"messages": [AIMessage("Conversation limit reached.")],
"jump_to": "end"
}
return None
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"Model returned: {state['messages'][-1].content}")
return None
랩 스타일 훅 (Wrap-style hooks)
실행을 가로채고 핸들러가 언제 호출되는지 제어해요. 재시도(retry), 캐싱, 변환(transformation)에 주로 쓰입니다. 핸들러를 0번 호출할지(short-circuit), 1번 호출할지(정상 흐름), 여러 번 호출할지(재시도 로직)를 직접 결정할 수 있어요.
wrap_model_call— 각 모델 호출을 감쌈wrap_tool_call— 각 도구 호출을 감쌈
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from typing import Callable
@wrap_model_call
def retry_model(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
for attempt in range(3):
try:
return handler(request)
except Exception as e:
if attempt == 2:
raise
print(f"Retry {attempt + 1}/3 after error: {e}")
상태 업데이트 (State updates)
노드 스타일과 랩 스타일 훅 모두 에이전트 상태를 업데이트할 수 있는데, 방식이 달라요.
- 노드 스타일 훅 (
before_agent,before_model,after_model,after_agent): 딕셔너리를 직접 반환해요. 이 딕셔너리는 그래프의 리듀서(reducers)를 통해 에이전트 상태에 반영됩니다. - 랩 스타일 훅 (
wrap_model_call,wrap_tool_call): 모델 호출의 경우ExtendedModelResponse에Command를 담아 상태 업데이트를 주입하고, 도구 호출의 경우Command를 직접 반환해요. 요약 트리거 지점, 사용량 메타데이터, 요청이나 응답에서 계산한 커스텀 필드처럼 모델·도구 호출 중 실행되는 로직을 기반으로 상태를 추적하거나 업데이트할 때 사용합니다.
from langchain.agents.middleware import after_model, AgentState
from langgraph.runtime import Runtime
from typing import Any
from typing_extensions import NotRequired
class TrackingState(AgentState):
model_call_count: NotRequired[int]
@after_model(state_schema=TrackingState)
def increment_after_model(state: TrackingState, runtime: Runtime) -> dict[str, Any] | None:
return {"model_call_count": state.get("model_call_count", 0) + 1}
래핑 스타일 훅 쪽은 이렇게 Command를 사용해요.
from typing import Callable
from langchain.agents.middleware import (
wrap_model_call,
ModelRequest,
ModelResponse,
AgentState,
ExtendedModelResponse
)
from langgraph.types import Command
from typing_extensions import NotRequired
class UsageTrackingState(AgentState):
"""Agent state with token usage tracking."""
last_model_call_tokens: NotRequired[int]
@wrap_model_call(state_schema=UsageTrackingState)
def track_usage(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ExtendedModelResponse:
response = handler(request)
return ExtendedModelResponse(
model_response=response,
command=Command(update={"last_model_call_tokens": 150}),
)
Command는 그래프의 리듀서를 거치므로 업데이트가 올바르게 적용되고, 메시지는 기존 상태를 덮어쓰지 않고 덧붙여져요(additive).
여러 미들웨어 합성 (Composition with multiple middleware)
여러 미들웨어 레이어가 ExtendedModelResponse를 반환하면 그 Command들이 합성됩니다.
- 리듀서를 통한 적용: 각
Command는 별도의 상태 업데이트가 됩니다. 메시지의 경우 덧붙이는 방식이죠. - 충돌 시 바깥쪽이 승리: 리듀서가 없는 상태 필드의 경우 안쪽부터 적용한 뒤 바깥쪽이 마지막에 적용되어, 충돌하는 키에서는 가장 바깥쪽 미들웨어의 값이 우선합니다.
- Retry-safe: 바깥쪽 미들웨어가
handler()를 여러 번 호출하는 로직(예: 재시도)을 가진 경우, 이전 호출의Command는 폐기됩니다.
from typing import Annotated, Callable
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ExtendedModelResponse,
ModelRequest,
ModelResponse,
)
from langchain.messages import SystemMessage
from langgraph.types import Command
from typing_extensions import NotRequired
def _last_wins(_a: str, b: str) -> str:
"""Reducer: last writer wins (outer overwrites inner)."""
return b
class CustomMiddlewareState(AgentState):
"""Agent state: trace_layer uses last-wins (outer wins), messages use additive reducer."""
# Non-reducer field with last-wins: both middleware write; outermost value wins
trace_layer: NotRequired[Annotated[str, _last_wins]]
class OuterMiddleware(AgentMiddleware):
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ExtendedModelResponse:
response = handler(request)
return ExtendedModelResponse(
model_response=response,
command=Command(update={
"trace_layer": "outer",
"messages": [SystemMessage(content="[Outer ran]")],
}),
)
class InnerMiddleware(AgentMiddleware):
"""Adds trace_layer and message. Outer adds to same keys; trace_layer: outer wins, messages: additive."""
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
):
response = handler(request)
return ExtendedModelResponse(
model_response=response,
command=Command(update={
"trace_layer": "inner",
"messages": [SystemMessage(content="[Inner ran]")],
}),
)
미들웨어 만들기 (Create middleware)
미들웨어를 만드는 방법은 두 가지예요.
데코레이터 기반 미들웨어
단일 훅 미들웨어에 빠르고 간단한 방식입니다. 개별 함수를 데코레이터로 감싸죠. 사용 가능한 데코레이터:
- 노드 스타일:
@before_agent,@before_model,@after_model,@after_agent - 랩 스타일:
@wrap_model_call,@wrap_tool_call - 편의용:
@dynamic_prompt
from langchain.agents.middleware import (
before_model,
wrap_model_call,
AgentState,
ModelRequest,
ModelResponse,
)
from langchain.agents import create_agent
from langgraph.runtime import Runtime
from typing import Any, Callable
@before_model
def log_before_model(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"About to call model with {len(state['messages'])} messages")
return None
@wrap_model_call
def retry_model(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
for attempt in range(3):
try:
return handler(request)
except Exception as e:
if attempt == 2:
raise
print(f"Retry {attempt + 1}/3 after error: {e}")
agent = create_agent(
model="gpt-5.5",
middleware=[log_before_model, retry_model],
tools=[...],
)
데코레이터를 쓸 때: 단일 훅만 필요할 때, 복잡한 설정이 필요 없을 때, 빠른 프로토타이핑을 할 때 좋아요.
클래스 기반 미들웨어
여러 훅이나 설정이 필요한 복잡한 미들웨어에 더 강력한 방식입니다. 같은 훅에 대해 동기·비동기 구현을 모두 정의하거나, 하나의 미들웨어에서 여러 훅을 조합할 때 클래스를 써요.
AgentMiddleware 서브클래스는 에이전트 팩토리가 컴파일 시점에 읽어들이는 세 가지 클래스 속성을 선언할 수 있습니다:
state_schema— 커스텀 필드로 에이전트 상태를 확장합니다 (Custom state schema 참고).tools— 미들웨어와 함께 제공되는 추가 도구를 등록합니다 (예: to-do 리스트 미들웨어의write_todos).transformers— 스코프 인지형 스트림 변압기 팩토리를 등록합니다 (Custom stream transformers 참고).
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
)
from langgraph.runtime import Runtime
from typing import Any, Callable
class LoggingMiddleware(AgentMiddleware):
def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"About to call model with {len(state['messages'])} messages")
return None
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"Model returned: {state['messages'][-1].content}")
return None
async def abefore_model(
self, state: AgentState, runtime: Runtime
) -> dict[str, Any] | None:
# Async version of before_model
return None
async def aafter_model(
self, state: AgentState, runtime: Runtime
) -> dict[str, Any] | None:
# Async version of after_model
print(f"Model returned: {state['messages'][-1].content}")
return None
agent = create_agent(
model="gpt-5.5",
middleware=[LoggingMiddleware()],
tools=[...],
)
클래스를 쓸 때: 같은 훅의 동기·비동기 구현이 모두 필요할 때, 하나의 미들웨어에 여러 훅이 필요할 때, 복잡한 설정(설정 가능한 임계값, 커스텀 모델 등)이 필요할 때, 초기화 시 설정으로 여러 프로젝트에 재사용할 때 좋아요.
커스텀 상태 스키마 (Custom state schema)
미들웨어가 훅 사이에서 상태를 추적해야 한다면, 에이전트 상태를 커스텀 속성으로 확장할 수 있어요. 이렇게 하면:
- 실행 전반에 걸쳐 카운터·플래그 같은 값을 유지하고
before_model에서after_model로 또는 다른 미들웨어 사이에서 데이터를 공유하며- 요율 제한, 사용량 추적, 사용자 컨텍스트, 감사 로깅 같은 횡단 관심사를 핵심 에이전트 로직 수정 없이 추가하고
- 축적된 상태를 바탕으로 실행을 계속할지, 다른 노드로 점프할지, 동작을 동적으로 바꿀지 결정할 수 있어요.
from langchain.agents import create_agent
from langchain.messages import HumanMessage
from langchain.agents.middleware import AgentState, before_model, after_model
from typing_extensions import NotRequired
from typing import Any
from langgraph.runtime import Runtime
class CustomState(AgentState):
model_call_count: NotRequired[int]
user_id: NotRequired[str]
@before_model(state_schema=CustomState, can_jump_to=["end"])
def check_call_limit(state: CustomState, runtime: Runtime) -> dict[str, Any] | None:
count = state.get("model_call_count", 0)
if count > 10:
return {"jump_to": "end"}
return None
@after_model(state_schema=CustomState)
def increment_counter(state: CustomState, runtime: Runtime) -> dict[str, Any] | None:
return {"model_call_count": state.get("model_call_count", 0) + 1}
agent = create_agent(
model="gpt-5.5",
middleware=[check_call_limit, increment_counter],
tools=[],
)
# Invoke with custom state
result = agent.invoke({
"messages": [HumanMessage("Hello")],
"model_call_count": 0,
"user_id": "user-123",
})
실행 순서 (Execution order)
여러 미들웨어를 쓸 때 실행되는 방식을 이해해야 해요.
agent = create_agent(
model="gpt-5.5",
middleware=[middleware1, middleware2, middleware3],
tools=[...],
)
핵심 규칙:
before_*훅: 앞에서 뒤로 (첫 번째부터 마지막까지)after_*훅: 뒤에서 앞으로 (역순)wrap_*훅: 중첩 (첫 번째 미들웨어가 나머지를 모두 감쌈)
에이전트 점프 (Agent jumps)
미들웨어에서 일찍 빠져나오려면 jump_to가 있는 딕셔너리를 반환하세요. 점프 가능한 대상:
'end'— 에이전트 실행의 끝으로 점프 (또는 첫 번째after_agent훅)'tools'— 도구 노드로 점프'model'— 모델 노드로 점프 (또는 첫 번째before_model훅)
from langchain.agents.middleware import after_model, hook_config, AgentState
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing import Any
@after_model
@hook_config(can_jump_to=["end"])
def check_for_blocked(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
last_message = state["messages"][-1]
if "BLOCKED" in last_message.content:
return {
"messages": [AIMessage("I cannot respond to that request.")],
"jump_to": "end"
}
return None
모범 사례 (Best practices)
- 미들웨어는 하나의 일만 잘하게 유지하세요.
- 에러를 우아하게 처리하세요 — 미들웨어 에러가 에이전트를 죽이지 않게요.
- 알맞은 훅 타입을 쓰세요: 순차 로직(로깅, 검증)엔 노드 스타일, 제어 흐름(재시도, 폴백, 캐싱)엔 랩 스타일.
- 커스텀 상태 속성은 명확히 문서화하세요.
- 통합 전에 미들웨어를 독립적으로 단위 테스트하세요.
- 실행 순서를 고려해서 중요한 미들웨어를 리스트 앞쪽에 두세요.
- 가능하면 내장 미들웨어를 쓰세요.