본문 바로가기
AI Agent/Patterns

에이전트 미들웨어 (Agent middleware)

by AtoN 2024. 11. 11.

에이전트 미들웨어 (Agent middleware)

에이전트 미들웨어는 모델 호출과 도구 실행 전후의 lifecycle에 개입해 에이전트의 동작을 바꾸는 확장 계층입니다. 에이전트의 추론 패턴 자체를 새로 만드는 것이 아니라, 실행 중간에 컨텍스트를 편집하고 호출 횟수를 제한하거나 재시도·폴백·사람 승인을 삽입하는 역할을 합니다.

이런 요구를 agent loop 바깥에서 각각 구현하면 비용 제어, 안전, context 관리, retry 로직이 프레임워크 코드와 뒤섞입니다. 최근 LangChain의 create_agent는 middleware를 공식 확장 지점으로 제공하고, summarization·human-in-the-loop·model/tool call limit·fallback·PII·tool selection 같은 기능을 같은 lifecycle 위에 올립니다.

이 문서에서는 어떤 hook이 상태를 바꾸고 어떤 hook이 호출 자체를 감싸는지, built-in middleware가 어떤 운영 문제를 해결하는지, 여러 middleware를 겹칠 때 순서와 checkpointer가 왜 중요한지를 실제 API 기준으로 봅니다.


1장. Agent Loop 확장과 Middleware의 필요성

AgentExecutor 는 루프를 프레임워크 안에 숨겨서 편했지만 그 안을 못 건드렸습니다. 그래서 다음 같은 요구가 오면 프레임워크를 벗어나야 했습니다.

"토큰 비용 상한을 걸어 주세요"
"위험한 도구는 사람이 승인하게 해 주세요"
"컨텍스트가 길어지면 자동으로 요약해 주세요"
"개인정보는 마스킹해서 모델에 보내 주세요"

콜백으로 우회하거나 직접 짰습니다.

지금은 인자 하나로 끝납니다.

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[...],
    middleware=[
        SummarizationMiddleware(model="openai:gpt-4o-mini"),
        ModelCallLimitMiddleware(run_limit=10),
    ],
)

내장 미들웨어 목록이 곧 문제 목록입니다.

문제 미들웨어
비용 폭주 ModelCallLimitMiddleware, ToolCallLimitMiddleware
컨텍스트 초과 SummarizationMiddleware, ContextEditingMiddleware
개인정보 PIIMiddleware
승인 누락 HumanInTheLoopMiddleware
모델 실패 ModelRetryMiddleware, ModelFallbackMiddleware
도구 실패 ToolErrorMiddleware, ToolRetryMiddleware
도구가 너무 많음 LLMToolSelectorMiddleware
테스트 LLMToolEmulator
셸 실행 ShellToolMiddleware
할 일 관리 TodoListMiddleware
파일 검색 FilesystemFileSearchMiddleware
제공자 내장 검색 ProviderToolSearchMiddleware

이 목록을 보면 에이전트를 운영하며 무엇이 터지는지가 그대로 드러납니다.


2장. Middleware Lifecycle과 Hook 구조

확장 지점은 여섯 개입니다.

훅 시점
before_agent 에이전트 실행 시작 전
before_model 모델 호출 전
after_model 모델 호출 후
after_agent 에이전트 실행 종료 후
wrap_model_call 모델 호출을 감싼다
wrap_tool_call 도구 호출을 감싼다

상태 훅의 시그니처입니다.

def before_model(self, state: StateT, runtime: Runtime[ContextT]) -> dict[str, Any] | None:
    ...

State를 받고 갱신분을 돌려줍니다. LangGraph 노드와 같은 규칙이라 전체가 아니라 바뀐 것만 반환합니다. 미들웨어가 결국 그래프 노드로 들어가기 때문입니다.

wrap_model_call은 다릅니다.

def wrap_model_call(
    self,
    request: ModelRequest[ContextT],
    handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]],
) -> ModelResponse[ResponseT] | AIMessage | ExtendedModelResponse[...]:
    ...

소스의 설명입니다.

핸들러 콜백을 통해 모델 실행을 가로채고 제어한다. 핸들러 콜백이 모델 요청을 실행하고 ModelResponse를 돌려준다. 미들웨어는 핸들러를 여러 번 호출할 수 있다.

마지막 문장이 핵심입니다. 핸들러를 여러 번 부를 수 있으니 실패하면 다시 부르는 재시도, 다른 모델로 바꿔 부르는 폴백, 요청을 고쳐서 다시 부르는 교정이 전부 가능해집니다.

상태 훅은 전후에 무엇을 하는 것이고, wrap은 호출 자체를 통제하는 것입니다.

데코레이터로도 됩니다.

from langchain.agents.middleware import before_model, wrap_model_call

@before_model
def log_state(state, runtime):
    print(f"메시지 {len(state['messages'])}개")
    return None

클래스를 만들 필요 없이 함수 하나로 끝낼 수 있습니다.

동일한 lifecycle에는 async hook도 제공됩니다. 다만 실제로 사용할 수 있는 hook 이름과 signature는 LangChain 버전의 AgentMiddleware reference를 기준으로 확인해야 합니다.


3장. 모델·도구 호출 한도와 Runaway 방지

Agent loop에는 호출 상한을 두는 편이 안전합니다. 종료 조건이 깨졌을 때 비용과 latency가 runaway하는 것을 ModelCallLimitMiddleware와 ToolCallLimitMiddleware 같은 장치로 제한할 수 있습니다.

먼저 ModelCallLimitMiddleware입니다.

ModelCallLimitMiddleware(
    *,
    thread_limit: int | None = None,
    run_limit: int | None = None,
    exit_behavior: Literal["end", "error"] = "end",
)

thread_limit 과 run_limit 을 반드시 구분해야 합니다. run_limit 은 이번 invoke 한 번에서의 상한이고 thread_limit 은 같은 thread_id 누적 상한입니다.

둘 다 필요한 이유가 있습니다. run_limit 은 한 번의 폭주를 막고 thread_limit 은 대화가 길어지며 누적되는 비용을 막습니다. run_limit 만 걸면 한 번에 10번씩 100턴 대화하면 1000번이 됩니다.

exit_behavior는 limit에 도달했을 때 graceful end와 error 중 어떤 계약을 택할지 정합니다. 사용자 대면인지 배치 작업인지보다 호출 상한 도달을 정상 종료로 취급할지 실패로 취급할지에 맞춰 선택해야 합니다.

ToolCallLimitMiddleware도 같은 방식입니다.

ToolCallLimitMiddleware(
    *,
    tool_name: str | None = None,
    thread_limit: int | None = None,
    run_limit: int | None = None,
    exit_behavior: ExitBehavior = "continue",
)

tool_name 을 주면 그 도구에만 걸고 안 주면 전체 도구 호출에 겁니다. 기본 exit_behavior 가 "continue" 인 것이 다른데, 도구를 못 쓰게 막되 에이전트는 계속 돌려 모델이 다른 방법을 찾게 둡니다.

실무에서는 이렇게 조합합니다.

middleware=[
    ModelCallLimitMiddleware(run_limit=15, thread_limit=100, exit_behavior="end"),
    ToolCallLimitMiddleware(tool_name="web_search", run_limit=5),
]

비싼 도구에는 따로 더 낮은 상한을 겁니다.


4장. Context 요약과 편집 전략

에이전트가 돌수록 messages가 길어집니다. 도구 호출과 결과가 매 반복마다 쌓여 결국 컨텍스트 한도를 넘습니다. 체크포인터는 자동으로 자르지 않습니다.

SummarizationMiddleware입니다.

SummarizationMiddleware(
    model: str | BaseChatModel,
    *,
    trigger: ContextSize | TriggerClause | list[...] | None = None,
    keep: ContextSize = ("messages", <기본값>),
    token_counter: TokenCounter = count_tokens_approximately,
    summary_prompt: str = DEFAULT_SUMMARY_PROMPT,
    ...
)

trigger 는 언제 요약할지, keep 은 요약 후 원문으로 남길 분량, model 은 요약에 쓸 모델입니다. 요약용 모델을 본체보다 싼 것으로 두는 것이 보통입니다.

ContextEditingMiddleware도 있습니다.

ContextEditingMiddleware(
    *,
    edits: Iterable[ContextEdit] | None = None,
    token_count_method: Literal["approximate", "model"] = "approximate",
)

요약이 아니라 편집입니다. 내장 편집 규칙 하나가 이름부터 용도를 말하는데, ClearToolUsesEdit 는 오래된 도구 호출과 결과를 지웁니다.

도구 결과를 지우는 이유가 있습니다. 검색 결과나 파일 내용이 컨텍스트의 대부분을 차지하는데, 이미 답에 반영됐으면 원문은 필요 없습니다.

Summarization은 내용을 압축해 요약문으로 대체하므로 정보는 남고 분량이 줍니다. ContextEditing은 특정 항목을 통째로 지우므로 요약 비용이 없고 즉시 줄어듭니다.

실무에서는 섞습니다. 도구 결과는 지우고 대화 흐름은 요약합니다.

ContextEditingMiddleware의 token_count_method는 "approximate"와 "model"을 지원하고, 별도의 token_counter를 주면 그것이 우선합니다. CJK 비중이 높거나 provider tokenizer 차이가 큰 workload에서는 approximate count와 실제 usage의 차이를 측정해 보는 편이 좋습니다.


5장. Human-in-the-Loop와 개인정보 보호

PIIMiddleware입니다.

PIIMiddleware(
    pii_type: Literal["email", "credit_card", "ip", "mac_address", "url"] | str,
    *,
    strategy: Literal["block", "redact", "mask", "hash"] = "redact",
    ...
)

내장 타입은 email, credit_card, ip, mac_address, url 다섯이고 문자열로 커스텀 타입도 줍니다.

전략은 넷입니다.

전략 동작
block 발견하면 막는다. 예외를 던진다
redact 제거한다
mask 일부만 가린다
hash 해시로 바꾼다

절대 나가면 안 되는 신용카드에는 block, 없어도 되는 것에는 redact, 형태는 남겨야 할 때는 mask, 같은 값인지 대조는 해야 할 때는 hash를 씁니다.

타입별로 전략을 나눌 수 있습니다.

middleware=[
    PIIMiddleware("credit_card", strategy="block"),
    PIIMiddleware("email", strategy="mask"),
    PIIMiddleware("ip", strategy="hash"),
]

하나로 뭉뚱그리지 않습니다. 위험도가 다르면 전략도 다릅니다.

HumanInTheLoopMiddleware입니다.

HumanInTheLoopMiddleware(
    interrupt_on={
        "send_email": True,
        "delete_file": True,
        "search_web": False,
    }
)

도구 이름별로 승인 여부를 정합니다.

승인 대기는 실행을 멈추는 것이라 멈춘 상태를 저장할 데가 있어야 합니다.

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[send_email],
    checkpointer=checkpointer,        # 필수
    middleware=[HumanInTheLoopMiddleware(interrupt_on={"send_email": True})],
)

Human-in-the-loop에서 실행을 중단했다가 같은 thread를 재개하려면 LangGraph checkpointer 같은 persistence가 필요합니다. 단순히 승인 UI만 붙이고 상태 저장을 빼면 interrupt 이후의 실행 상태를 복원할 수 없습니다.


6장. Retry·Fallback과 오류 복구

안정성 쪽은 넷입니다.

미들웨어 하는 일
ModelRetryMiddleware 모델 호출 실패 시 재시도
ModelFallbackMiddleware 모델 실패 시 대체 모델
ToolRetryMiddleware 도구 실패 시 재시도
ToolErrorMiddleware 도구 오류를 다루는 방식

재시도와 폴백은 다시 부르기라 상태 훅으로는 표현이 안 됩니다. wrap_model_call 이 핸들러를 여러 번 부를 수 있어서 이 넷이 미들웨어로 구현됩니다.

모델 오류는 대개 일시적이라 다시 부르면 됩니다. 도구 오류는 다릅니다.

오류 대응
인자가 틀렸다 모델에게 알려서 고치게 해야 한다
외부 API 가 죽었다 재시도해도 소용없다
권한이 없다 재시도하면 안 된다

그래서 ToolErrorMiddleware 는 오류를 모델에게 어떻게 전달할지를 다룹니다. 오류를 숨기면 모델이 같은 실수를 반복하므로, 오류 메시지를 도구 결과로 돌려줘야 모델이 인자를 고칩니다.


7장. Tool Selection과 실행 정책

LLMToolSelectorMiddleware는 전체 tool schema를 매번 넘기는 대신 현재 요청에 관련 있는 도구 후보를 먼저 좁히는 용도입니다. 도구 수가 많고 schema가 길수록 context 비용과 선택 혼동이 커질 수 있지만, 임계점은 모델과 tool description에 따라 달라집니다.

LLMToolEmulator는 실제 외부 도구를 호출하지 않고 LLM으로 tool result를 흉내 내 테스트할 때 사용할 수 있습니다. 실제 정확성이나 side effect가 필요한 production execution의 대체재는 아닙니다.

외부 API 를 안 부르고 에이전트 흐름을 테스트한다
비용과 부작용 없이 프롬프트를 튜닝한다
결제나 삭제 같은 되돌릴 수 없는 도구를 개발 중에 막는다

Shell middleware는 agent에 command execution capability를 붙이는 만큼 filesystem boundary, sandbox, approval policy와 함께 설계해야 합니다. 단순히 tool 하나를 추가하는 문제보다 실행 권한의 범위를 정하는 문제가 더 중요합니다.

정책 실행 위치
HostExecutionPolicy 호스트에서 그대로
DockerExecutionPolicy 도커 컨테이너 안에서
CodexSandboxExecutionPolicy 샌드박스 안에서

격리 수준이 정책으로 분리돼 있다는 것이 중요합니다. 개발은 호스트, 운영은 컨테이너로 바꿀 수 있습니다.

나머지 셋입니다.

미들웨어 하는 일
TodoListMiddleware 긴 작업의 할 일 목록을 상태로 관리
FilesystemFileSearchMiddleware 파일 검색 도구를 붙인다
ProviderToolSearchMiddleware 제공자 내장 검색을 쓴다

8장. Custom Middleware 구현 구조

클래스로 만들면 이렇습니다.

from langchain.agents.middleware import AgentMiddleware

class StepLogger(AgentMiddleware):
    def before_model(self, state, runtime):
        print(f"[step] 메시지 {len(state['messages'])}개")
        return None

    def after_model(self, state, runtime):
        last = state["messages"][-1]
        if getattr(last, "tool_calls", None):
            print(f"[tool] {[t['name'] for t in last.tool_calls]}")
        return None

AgentMiddleware는 state schema와 추가 tools를 통해 middleware 자체가 필요한 상태나 capability를 agent runtime에 연결할 수 있습니다. 실제 field와 generic type은 버전에 따라 바뀔 수 있으므로 custom middleware는 reference API와 함께 작성하는 편이 안전합니다.

wrap으로 통제하는 예입니다.

class BudgetGuard(AgentMiddleware):
    def __init__(self, max_calls: int):
        self.max_calls = max_calls
        self.count = 0

    def wrap_model_call(self, request, handler):
        if self.count >= self.max_calls:
            raise RuntimeError("호출 상한 초과")
        self.count += 1
        return handler(request)

핸들러를 부르지 않으면 모델이 안 불리고 두 번 부르면 두 번 불립니다.

미들웨어는 목록 순서대로 겹쳐집니다. 비용 상한과 승인은 바깥에, 재시도와 폴백은 안쪽에 둡니다. 재시도가 바깥에 있으면 상한 검사를 우회해 여러 번 부를 수 있기 때문입니다.


9장. 운영 로직의 Middleware 통합

두 시점을 나란히 놓으면 이렇습니다.

문제 예전 지금
호출 횟수 상한 직접 카운터 구현 ModelCallLimitMiddleware
도구별 상한 없음 ToolCallLimitMiddleware
컨텍스트 요약 메모리 클래스 골라 붙임 SummarizationMiddleware
오래된 도구 결과 정리 수동 ContextEditingMiddleware
개인정보 처리 직접 구현 PIIMiddleware
사람 승인 executor 쪼개기 HumanInTheLoopMiddleware
모델 폴백 체인에 with_fallbacks ModelFallbackMiddleware
도구 오류 전달 직접 처리 ToolErrorMiddleware
도구 과다 프롬프트로 유도 LLMToolSelectorMiddleware
도구 없이 테스트 목 객체 직접 작성 LLMToolEmulator
루프 안 개입 콜백으로 우회 훅 여섯 개

예전에는 프레임워크 밖에서 해결했고 지금은 인자 하나입니다.


10장. Middleware 설계 원칙과 운영 체크리스트

훅 여섯 개가 확장 지점이고, before와 after 계열은 상태를 고치며 wrap 계열은 호출 자체를 통제합니다. wrap_model_call 은 핸들러를 여러 번 부를 수 있어 재시도, 폴백, 교정이 전부 여기서 나옵니다. thread_limit 과 run_limit 은 대화 전체 누적이냐 이번 실행 한 번이냐로 다릅니다.

처음 붙일 세 개입니다.

미들웨어 막는 것
ModelCallLimitMiddleware 비용 사고를 막는다
SummarizationMiddleware 컨텍스트 초과를 막는다
HumanInTheLoopMiddleware 되돌릴 수 없는 도구를 막는다

이 셋이 운영에서 가장 자주 터지는 것들입니다.

붙였다면 확인할 것들입니다.

run_limit 과 thread_limit 을 둘 다 걸었나
exit_behavior 를 용도에 맞게 골랐나
승인이 필요한 도구에 체크포인터를 붙였나
PII 타입마다 전략을 다르게 줬나
미들웨어 순서에서 재시도가 상한보다 안쪽인가
도구 오류가 모델에게 전달되나

용어 정리

용어 한 줄 뜻
미들웨어 에이전트 루프 안에 개입하는 표준 확장 지점
AgentMiddleware 미들웨어의 베이스 클래스. 훅을 구현해 동작을 바꿈
before_model / after_model 모델 호출 전후에 상태를 고치는 훅
before_agent / after_agent 에이전트 실행 시작과 종료 시점의 훅
wrap_model_call 모델 호출을 감싸 통제하는 훅. 핸들러를 여러 번 호출 가능
wrap_tool_call 도구 호출을 감싸 통제하는 훅
run_limit 이번 실행 한 번에서의 상한
thread_limit 같은 thread_id 안에서 누적되는 상한
exit_behavior 상한에 닿았을 때의 동작. 종료할지 예외를 던질지
ClearToolUsesEdit 오래된 도구 호출과 결과를 지우는 컨텍스트 편집 규칙
PII 개인 식별 정보. 이메일, 카드번호, IP 등
interrupt_on 어느 도구에서 사람 승인을 받을지 지정하는 딕셔너리
실행 정책 셸 도구를 호스트, 도커, 샌드박스 중 어디서 돌릴지 정하는 설정
LLMToolEmulator 도구를 실제로 실행하지 않고 결과를 흉내 내는 미들웨어

참고자료

댓글