Step 12 — 커스텀 미들웨어

학습 목표

  • 6개 훅(beforeAgent / beforeModel / wrapModelCall / afterModel / wrapToolCall / afterAgent)이 언제 불리고 무엇을 받고 무엇을 반환하는지 구분한다
  • createMiddleware() 로 로깅·검증·계측 미들웨어를 직접 만든다
  • wrapModelCall동적 시스템 프롬프트 / 동적 모델 선택 / 폴백을 구현한다
  • wrapToolCall 로 도구 호출에 캐싱·권한 검사·감사 로그를 얹는다
  • stateSchema 로 미들웨어 전용 상태를 추가하고, 리듀서가 왜 필요한지 안다
  • jumpTo 로 루프를 건너뛰거나 조기 종료한다

선행 스텝: Step 11 — 내장 미들웨어 예상 소요: 90분

Step 11 에서 modelRetryMiddleware, piiMiddleware 같은 내장 미들웨어를 조립해 봤습니다. 편했지만 곧 벽에 부딪힙니다. "우리 회사 요금제에 맞춰 토큰 예산을 계산해라", "이 도구는 관리자만 부를 수 있다", "사내 감사 시스템에 도구 호출을 남겨라" — 이런 건 아무도 대신 만들어 주지 않습니다.

이 스텝에서는 미들웨어를 직접 만듭니다. 그런데 미들웨어는 이 코스에서 가장 조용히 틀리기 쉬운 영역이기도 합니다. 상태를 잘못 갱신하면 에러 없이 숫자만 틀리고, handler 를 안 부르면 모델이 아예 안 도는데 아무 경고도 없습니다. 그래서 이 스텝은 "이렇게 하면 됩니다" 만큼 "이렇게 하면 조용히 망합니다" 에 지면을 씁니다.


12-1. 훅 전체 — 6개가 각자 언제 뛰는가

에이전트는 이런 루프를 돕니다.

invoke() → [모델 호출 → 도구 호출] → [모델 호출 → 도구 호출] → ... → 최종 답변

미들웨어는 이 루프의 틈새마다 코드를 끼워 넣는 장치입니다. 훅은 성격이 다른 두 종류로 나뉩니다.

노드형(node-style) 훅 — 상태를 보고 상태를 고친다

beforeAgent, beforeModel, afterModel, afterAgent 네 개입니다. 시그니처가 전부 똑같습니다.

(state, runtime) => 상태업데이트 | undefined

state 를 읽고, 바꾸고 싶은 부분만 담은 객체를 반환합니다. 바꿀 게 없으면 아무것도 반환하지 않습니다.

래퍼형(wrap-style) 훅 — 실행 자체를 감싼다

wrapModelCall, wrapToolCall 두 개입니다. handler 를 받아서 직접 부릅니다.

(request, handler) => 결과

handler(request) 앞뒤로 코드를 넣을 수 있고, request 를 바꿔서 넘길 수도 있고, 여러 번 부를 수도 있고(재시도/폴백), 아예 안 부를 수도 있습니다(캐시/차단).

표로 정리

언제받는 것반환호출 횟수
beforeAgent에이전트 시작 직전(state, runtime)상태 업데이트 | undefinedinvoke 당 1번
beforeModel매 모델 호출 직전(state, runtime)상태 업데이트 | undefined루프 바퀴수만큼
wrapModelCall모델 호출을 감쌈(request, handler)AIMessage | Command루프 바퀴수만큼
afterModel모델 응답 직후, 도구 실행 전(state, runtime)상태 업데이트 | undefined루프 바퀴수만큼
wrapToolCall도구 호출을 감쌈(request, handler)ToolMessage | Command도구 호출 수만큼
afterAgent에이전트 종료 직후(state, runtime)상태 업데이트 | undefinedinvoke 당 1번

state 에는 항상 messages: BaseMessage[] 가 있고, responseFormat 을 쓰면 structuredResponse 도 있습니다. 여기에 stateSchema 로 추가한 필드가 합쳐집니다(12-6).

request 의 내용물은 훅마다 다릅니다.

wrapModelCallModelRequest

필드타입설명
modelAgentLanguageModelLike이번에 쓸 모델. 바꿔치기 가능
messagesBaseMessage[]모델에 보낼 메시지
systemMessageSystemMessage시스템 메시지. 바꿔치기 가능
systemPromptstring문자열 버전. deprecatedsystemMessage 를 쓰세요
tools(ServerTool | ClientTool)[]이번에 노출할 도구. 줄이거나 늘릴 수 있음
toolChoice"auto" | "none" | "required" | {...}도구 강제 여부
responseFormatResponseFormatInput?구조화 출력 스키마
stateTState & AgentBuiltInState현재 상태 (읽기용)
runtimeRuntime<TContext>context, signal, writer
modelSettingsRecord<string, unknown>?bindTools() 에 넘길 추가 설정

wrapToolCallToolCallRequest

필드타입설명
toolCallToolCall{ id?, name, args, type? } — 모델이 요청한 호출
toolClientTool | ServerTool | undefined도구 인스턴스. 동적 등록 도구면 undefined
stateTState & AgentBuiltInState현재 상태
runtimeRuntime<TContext>런타임

실행 순서 — 눈으로 확인

미들웨어를 두 개 겹치면 순서 규칙이 드러납니다.

const tracer = (label: string) =>
  createMiddleware({
    name: `Tracer-${label}`,
    beforeAgent: () => { console.log(`${label} │ beforeAgent`); return; },
    beforeModel: () => { console.log(`${label} │   beforeModel`); return; },
    wrapModelCall: async (request, handler) => {
      console.log(`${label} │     wrapModelCall  →`);
      const response = await handler(request);
      console.log(`${label} │     wrapModelCall  ←`);
      return response;
    },
    afterModel: () => { console.log(`${label} │   afterModel`); return; },
    wrapToolCall: async (request, handler) => {
      console.log(`${label} │     wrapToolCall   →  ${request.toolCall.name}`);
      const result = await handler(request);
      console.log(`${label} │     wrapToolCall   ←  ${request.toolCall.name}`);
      return result;
    },
    afterAgent: () => { console.log(`${label} │ afterAgent`); return; },
  });

const agent = createAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather],
  systemPrompt: "너는 날씨 비서다. 도구로 확인한 사실만 답해라.",
  middleware: [tracer("A"), tracer("B")],   // A 가 바깥, B 가 안쪽
});

await agent.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });

출력 예시 (모델이 도구를 부르느냐에 따라 바퀴 수가 달라집니다)

A │ beforeAgent
B │ beforeAgent
A │   beforeModel
B │   beforeModel
A │     wrapModelCall  →  (모델 부르기 직전)
B │     wrapModelCall  →  (모델 부르기 직전)
B │     wrapModelCall  ←  (모델이 답한 직후)
A │     wrapModelCall  ←  (모델이 답한 직후)
B │   afterModel
A │   afterModel
A │     wrapToolCall   →  get_weather
B │     wrapToolCall   →  get_weather
      [도구 실제 실행] getWeather(서울)
B │     wrapToolCall   ←  get_weather
A │     wrapToolCall   ←  get_weather
A │   beforeModel
B │   beforeModel
...
B │ afterAgent
A │ afterAgent

규칙 세 가지가 보입니다.

  1. before 계열은 등록 순서대로A → B
  2. after 계열은 역순B → A
  3. wrap 계열은 양파처럼 중첩A→ B→ 모델 →B← A←

beforeModel 은 루프를 도는 만큼 여러 번 찍혔지만 beforeAgent 는 맨 위에 딱 한 번인 것도 확인하세요.

💡 실무 팁 — 순서를 어떻게 정하나: middleware: [감사, 권한, 캐시] 처럼 바깥에 둘수록 넓은 관심사를 놓습니다. 감사 로그는 "차단당한 호출"까지 봐야 하니 가장 바깥, 권한 검사는 캐시보다 바깥(거부된 호출이 캐시에 저장되면 안 되니까), 캐시는 실제 도구 바로 앞. 이 순서를 뒤집으면 "권한이 없는데 캐시 HIT 로 결과가 나가는" 사고가 납니다. 배열 순서가 곧 보안 경계입니다.


12-2. createMiddleware() 로 첫 미들웨어 만들기

가장 먼저 만들게 되는 미들웨어는 거의 항상 로깅입니다. 에이전트가 왜 그렇게 답했는지는 "모델에 뭘 넣었고 뭐가 나왔나"를 봐야 아는데, createAgent 는 그걸 기본으로 보여주지 않기 때문입니다.

import { createMiddleware, AIMessage } from "langchain";

const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",

  beforeModel: (state) => {
    console.log(`  [로그] 모델 호출 예정 — 메시지 ${state.messages.length}개`);
    return;                       // 아무것도 안 바꿈 = 통과
  },

  afterModel: (state) => {
    const last = state.messages.at(-1);
    const toolCalls = (last as AIMessage | undefined)?.tool_calls ?? [];

    if (toolCalls.length > 0) {
      console.log(`  [로그] 모델이 도구 ${toolCalls.length}개 요청: ${toolCalls.map((c) => c.name).join(", ")}`);
    } else {
      console.log(`  [로그] 모델이 최종 답변: ${last?.text.slice(0, 40)}...`);
    }
    return;
  },
});

createMiddleware() 의 설정 객체가 받는 필드는 이게 전부입니다.

필드용도
name미들웨어 이름. 에러 메시지와 트레이싱에 찍힙니다
stateSchema미들웨어 전용 상태 (12-6)
contextSchema읽기 전용 호출별 메타데이터
tools이 미들웨어가 추가로 등록할 도구
streamTransformers스트림 변환기
beforeAgent / beforeModel / afterModel / afterAgent노드형 훅
wrapModelCall / wrapToolCall래퍼형 훅

붙여서 돌려 봅니다.

const agent = createAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather, getPopulation],
  systemPrompt: "너는 도시 정보 비서다.",
  middleware: [loggingMiddleware],
});

const result = await agent.invoke({
  messages: [{ role: "user", content: "서울과 부산의 날씨와 인구를 알려줘." }],
});

출력 예시 (모델 응답이므로 매번 다릅니다)

  [로그] 모델 호출 예정 — 메시지 1개
  [로그] 모델이 도구 4개 요청: get_weather, get_population, get_weather, get_population
  [로그] 모델 호출 예정 — 메시지 6개
  [로그] 모델이 최종 답변: 서울은 맑고 24도이며 인구는 약 938만 명...

메시지가 1개 → 6개로 뛴 것에 주목하세요. AIMessage(도구 요청) 1개 + ToolMessage 4개가 붙어서 그렇습니다.

💡 실무 팁: console.log 는 로컬 디버깅용입니다. 실무에서는 이 자리에 구조화된 로거(pino, winston)나 OpenTelemetry span 을 넣습니다. 미들웨어 하나만 바꾸면 전 에이전트에 관측이 깔린다는 게 이 패턴의 진짜 가치입니다. LangSmith 를 쓴다면 LANGSMITH_TRACING=true 만으로 이 정도는 자동으로 잡히니, 커스텀 로깅은 "LangSmith 가 안 잡아주는 우리만의 지표"에 쓰세요 — Step 19 에서 다룹니다.


12-3. beforeModel — 매 모델 호출 전 메시지 손보기

beforeModelstate 를 받아 상태 업데이트를 반환합니다. messages 필드는 append 리듀서를 쓰므로, 반환한 메시지는 기존 목록 뒤에 붙습니다.

모델은 지금이 몇 시인지 모릅니다. 안 알려주면 지어냅니다. 매 호출 직전에 현재 시각을 밀어 넣어 봅시다.

import { createMiddleware, SystemMessage } from "langchain";

const timeInjectorMiddleware = createMiddleware({
  name: "TimeInjectorMiddleware",

  beforeModel: (state) => {
    const now = new Date().toISOString();
    console.log(`  [시각 주입] ${now} (메시지 ${state.messages.length}개 뒤에 append)`);

    return {
      messages: [new SystemMessage(`[시스템] 현재 시각은 ${now} 입니다.`)],
    };
  },
});

출력 예시

  [시각 주입] 2026-07-17T04:12:33.891Z (메시지 1개 뒤에 append)
SYSTEM │ [시스템] 현재 시각은 2026-07-17T04:12:33.891Z 입니다.
HUMAN  │ 지금 몇 시인지 시스템이 알려준 대로만 말해줘.
AI     │ 2026년 7월 17일 04시 12분(UTC)입니다.

동작은 합니다. 그런데 주입한 SystemMessageresult.messages 안에 남아 있습니다. 체크포인터를 쓰면(Step 10) 이게 그대로 저장되고, 다음 턴에도 남고, 그 다음 턴에 또 하나 붙습니다. 10턴이면 낡은 타임스탬프 10개가 대화에 쌓입니다.

⚠️ 함정 (반환값은 "덧붙일 것"이지 "전체 목록"이 아니다): beforeModel 이 반환하는 messagesappend 됩니다. 그래서 이렇게 쓰면 안 됩니다.

beforeModel: (state) => {
  return { messages: [...state.messages, new SystemMessage("...")] };  // ✗
}

기존 목록을 통째로 다시 반환했으니 대화가 두 배로 불어납니다. 에러는 안 납니다 — 토큰 청구서가 두 배가 되고, 모델이 같은 질문을 두 번 본 것처럼 굴 뿐입니다. 반환할 것은 새로 붙일 메시지만 입니다.

⚠️ 함정 (messages 를 통째로 갈아끼우면 tool_call / ToolMessage 짝이 깨진다): "오래된 메시지를 잘라내자"는 생각으로 이런 걸 시도하게 됩니다.

beforeModel: (state) => {
  const recent = state.messages.slice(-4);           // 최근 4개만 남기자
  return { messages: [new RemoveMessage({ id: REMOVE_ALL_MESSAGES }), ...recent] };  // ✗ 위험
}

자른 경계가 하필 AIMessage(tool_calls: [X])ToolMessage(tool_call_id: X) 사이를 지나가면 짝이 깨집니다. 남은 것이 ToolMessage 뿐이면 "대답할 질문이 없는 대답"이, AIMessage 뿐이면 "답을 못 받은 질문"이 됩니다. 두 경우 다 provider 가 400 을 냅니다.

  • Anthropic: messages.N: tool_use ids were found without tool_result blocks immediately after
  • OpenAI: An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'

무서운 건 이게 대화가 짧을 땐 안 터진다는 겁니다. 도구를 부르기 시작하고 대화가 길어져서 자르기가 발동하는 순간, 프로덕션에서 처음 터집니다. 대화를 줄이는 일은 직접 하지 말고 내장 summarizationMiddleware 나 컨텍스트 편집 미들웨어를 쓰세요(Step 11). 그것들은 이 짝을 지켜 줍니다.

그리고 애초에 시스템 프롬프트를 동적으로 바꾸는 데 beforeModel 을 쓰면 안 됩니다. 대화 기록을 오염시키니까요. 그건 다음 절의 일입니다.


12-4. wrapModelCall — 모델 호출 감싸기

wrapModelCall 은 미들웨어의 주력입니다. handler 를 손에 쥐고 있으므로 할 수 있는 일이 급격히 늘어납니다.

12-4-1. 동적 시스템 프롬프트

12-3 의 문제를 이걸로 풉니다. request.systemMessage 를 바꾸면 이번 모델 호출에만 적용되고 state.messages 에는 남지 않습니다.

const dynamicPromptMiddleware = createMiddleware({
  name: "DynamicPromptMiddleware",

  wrapModelCall: async (request, handler) => {
    const turn = request.messages.filter((m) => m.getType() === "human").length;
    const extra = turn > 1 ? " 사용자가 이미 여러 번 물었다. 더 짧게 답해라." : "";

    return handler({
      ...request,                                       // ← 스프레드로 나머지 보존
      systemMessage: request.systemMessage.concat(extra),
    });
  },
});

request.systemMessage.concat(...) 은 기존 시스템 메시지에 덧붙입니다. createAgent({ systemPrompt }) 로 준 원본을 날리지 않습니다.

출력 예시

  [동적 프롬프트] 사용자 턴 2회 → 추가 지시 있음
AI     │ 대기 중 큰 입자가 붉은 파장을 남기고 짧은 파장을 흩뿌리기 때문입니다.

주목: result.messages 에 추가 지시가 안 보입니다.

12-3 과 정반대입니다. 동적 시스템 프롬프트는 wrapModelCall 로 하세요.

💡 실무 팁 — 캐시를 깨뜨리지 마세요: 시스템 프롬프트에 타임스탬프처럼 매번 바뀌는 값을 넣으면 Anthropic/OpenAI 의 프롬프트 캐시가 매번 미스납니다. 시스템 프롬프트는 대화 맨 앞이라 캐시 효율이 가장 큰 자리인데, 거기에 밀리초 단위 시각을 박으면 캐시가 전부 무효화되어 입력 토큰 비용이 몇 배로 뜁니다. 꼭 시각을 줘야 한다면 분/시간 단위로 뭉개세요(2026-07-17T04:00). 반대로 캐시를 의도적으로 걸고 싶으면 구조화 콘텐츠에 cache_control 을 답니다.

systemMessage: request.systemMessage.concat(
  new SystemMessage({
    content: [{
      type: "text",
      text: "긴 사내 규정 문서 ...",
      cache_control: { type: "ephemeral", ttl: "5m" },
    }],
  }),
)

12-4-2. 동적 모델 선택

짧은 대화는 싼 모델로, 길어지면 비싼 모델로 — request.model 을 바꿔서 넘기면 끝입니다.

import { createMiddleware, initChatModel } from "langchain";

const models = {
  cheap: await initChatModel("anthropic:claude-haiku-4-5-20251001"),
  strong: await initChatModel("anthropic:claude-sonnet-4-6"),
};

const routerMiddleware = createMiddleware({
  name: "ModelRouterMiddleware",
  wrapModelCall: (request, handler) => {
    const useStrong = request.messages.length > 4;
    console.log(`  [모델 라우팅] 메시지 ${request.messages.length}개 → ${useStrong ? "sonnet(비쌈)" : "haiku(쌈)"}`);

    return handler({ ...request, model: useStrong ? models.strong : models.cheap });
  },
});

const agent = createAgent({
  model: models.strong,     // ← 기본값일 뿐, 미들웨어가 매번 덮어씁니다
  tools: [getWeather],
  middleware: [routerMiddleware],
});

OpenAI 를 섞고 싶다면 await initChatModel("openai:gpt-5.5") 를 넣으면 됩니다 — provider 를 가로질러 라우팅할 수 있는 게 이 패턴의 장점입니다.

12-4-3. 폴백을 직접 구현

handler여러 번 부를 수 있다는 게 wrapModelCall 의 핵심입니다.

const fallbackMiddleware = createMiddleware({
  name: "FallbackMiddleware",

  wrapModelCall: async (request, handler) => {
    try {
      return await handler({ ...request, model: brokenModel });   // ← await 필수
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.log(`  [폴백] 1차 모델 실패 → 2차 모델로 재시도`);
      console.log(`         원인: ${message.slice(0, 70)}...`);

      return await handler({ ...request, model: goodModel });
    }
  },
});

출력 예시 (provider 에러 메시지는 버전에 따라 다릅니다)

  [폴백] 1차 모델 실패 → 2차 모델로 재시도
         원인: 404 {"type":"error","error":{"type":"not_found_error","message":"m...
AI     │ 안녕하세요!

⚠️ 함정 (await 하나로 try/catch 가 죽는다): 위 코드에서 await 를 빼고 이렇게 쓰면 폴백이 절대 동작하지 않습니다.

try {
  return handler({ ...request, model: brokenModel });   // ✗ await 없음
} catch (error) {
  /* 여기는 영원히 실행되지 않습니다 */
}

handlerPromise 를 돌려줍니다. return handler(...) 는 Promise 를 만들자마자 반환하고 함수를 빠져나갑니다. 모델 호출이 나중에 실패해도 그 rejection 은 이미 떠난 try/catch 를 잡을 수 없습니다. 그대로 위로 올라가 에이전트가 죽습니다.

무서운 점: 에러 메시지가 지극히 정상적입니다("404 not_found_error"). 폴백이 안 걸렸다는 단서가 어디에도 없어서, "폴백을 넣었는데 왜 안 되지?" 로 반나절을 씁니다. wrapModelCall / wrapToolCall 안에서 handler 앞의 await 는 선택이 아닙니다. try/finally 로 시간을 재는 경우도 마찬가지입니다 — await 가 없으면 항상 0ms 가 찍힙니다.

⚠️ 함정 (handler 를 안 부르면 모델이 아예 안 돈다): wrapModelCallAIMessage 또는 Command 를 반환해야 합니다. 그런데 handler부르지 않고 Command 만 반환하면 어떻게 될까요?

wrapModelCall: async (request, handler) => {
  return new Command({ update: { myCounter: 1 } });   // ✗ handler 를 안 불렀음
}

모델은 호출되지 않습니다. 에러도 안 납니다. 프레임워크는 handler 가 돌려준 마지막 AIMessage 를 붙여 주는데, handler 를 안 불렀으니 붙일 게 없습니다. 결과는 답변이 없는 빈 턴입니다. 에이전트가 도구 루프를 돌다가 아무 진전 없이 recursionLimit 까지 돌고 죽거나, 마지막 메시지가 사용자 질문인 채로 끝납니다.

반대로 handler부른 뒤 Command 를 반환하는 건 완전히 정상입니다 — 프레임워크가 handler 의 응답을 추적해 두었다가 알아서 붙여 줍니다. 12-8 이 정확히 이 패턴입니다. 규칙은 하나입니다: handler 를 부르지 않을 거면 AIMessage 를 직접 만들어 반환하세요.


12-5. wrapToolCall — 도구 호출 가로채기

도구는 에이전트가 바깥 세계를 건드리는 유일한 통로입니다. 그래서 가로챌 이유가 많습니다.

12-5-1. 캐싱

같은 인자로 같은 도구를 또 부르면 실제 실행을 건너뜁니다.

const toolCache = new Map<string, string>();

const cachingMiddleware = createMiddleware({
  name: "CachingMiddleware",

  wrapToolCall: async (request, handler) => {
    const key = `${request.toolCall.name}:${JSON.stringify(request.toolCall.args)}`;

    const hit = toolCache.get(key);
    if (hit !== undefined) {
      console.log(`  [캐시 HIT] ${key} → 도구를 실행하지 않음`);
      return new ToolMessage({
        content: hit,
        tool_call_id: request.toolCall.id!,   // ← "이번" 호출의 id
        name: request.toolCall.name,
      });
    }

    console.log(`  [캐시 MISS] ${key}`);
    const result = await handler(request);

    if (ToolMessage.isInstance(result)) {
      toolCache.set(key, result.text);        // ← 내용만 저장
    }
    return result;
  },
});

⚠️ 함정 (ToolMessage 를 통째로 캐시하면 두 번째 호출에서 400 이 난다): 가장 자연스러운 구현은 이겁니다.

const cache = new Map<string, ToolMessage>();
wrapToolCall: async (request, handler) => {
  const key = `${request.tool.name}:${JSON.stringify(request.toolCall.args)}`;
  if (cache.has(key)) return cache.get(key);      // ✗ 옛날 tool_call_id 가 박혀 있음
  const result = await handler(request);
  cache.set(key, result);
  return result;
}

ToolMessage 에는 tool_call_id 가 박혀 있고, 이 id 는 호출마다 모델이 새로 만듭니다. 캐시된 메시지를 그대로 돌려주면 "이번 턴의 AIMessage 가 요청한 id" 와 "돌려준 ToolMessage 의 id" 가 어긋납니다. 짝이 깨져서 provider 가 400 을 냅니다.

이 함정이 특히 고약한 이유: 1회차는 항상 성공합니다(캐시 MISS 라 진짜 결과가 나가니까). 2회차부터 터지므로 단순 테스트를 통과해 버립니다. 답은 위 코드처럼 내용(result.text)만 캐시하고 ToolMessage 는 매번 새로 만드는 것입니다.

참고로 request.toolundefined 일 수 있습니다(동적 등록 도구). 캐시 키는 request.tool.name 말고 request.toolCall.name 으로 만드세요.

12-5-2. 권한 검사

handler안 부르고 ToolMessage 를 직접 만들어 돌려주면 도구는 실행되지 않고, 모델은 "거부당했다"는 사실을 텍스트로 읽습니다.

const ALLOWED_TOOLS = new Set(["get_weather", "get_population"]);

const permissionMiddleware = createMiddleware({
  name: "PermissionMiddleware",

  wrapToolCall: (request, handler) => {
    if (!ALLOWED_TOOLS.has(request.toolCall.name)) {
      console.log(`  [권한 거부] ${request.toolCall.name} — handler 를 부르지 않고 차단`);

      return new ToolMessage({
        content: `권한 없음: '${request.toolCall.name}' 도구는 이 사용자에게 허용되지 않았습니다. 다른 방법을 찾거나 사용자에게 알리세요.`,
        tool_call_id: request.toolCall.id!,
        name: request.toolCall.name,
        status: "error",
      });
    }
    return handler(request);
  },
});

출력 예시

  [권한 거부] delete_record — handler 를 부르지 않고 차단
AI     │ 죄송합니다. delete_record 도구를 사용할 권한이 없어 레코드를 삭제할 수 없습니다.
         관리자에게 문의해 주세요.

도구는 실행되지 않았는데 에이전트는 살아서 사용자에게 사정을 설명했습니다. 이게 거부의 올바른 모양입니다.

⚠️ 함정 (미들웨어에서 던진 예외는 에이전트 전체를 죽인다): 거부를 이렇게 표현하고 싶어집니다.

wrapToolCall: (request, handler) => {
  if (!allowed) throw new Error("권한 없음");    // ✗
  return handler(request);
}

이러면 agent.invoke()통째로 실패합니다. 부분 결과도, 답변도, 여태 돈 도구 결과도 전부 사라지고 사용자는 스택트레이스를 봅니다. 모델에게는 복구할 기회조차 없습니다 — 모델은 자기가 거부당했다는 사실을 아예 모릅니다.

던져진 에러는 MiddlewareError 로 감싸져서 올라옵니다. 원본 에러의 name 은 보존되고 cause 에 원본이 들어갑니다.

try {
  await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });
} catch (error) {
  MiddlewareError.isInstance(error)   // → true
  (error as Error).message            // → "여기서 던지면 어떻게 될까요?"
  (error as Error).cause              // → 원본 Error
}

예외로 다뤄야 할 건 "정말 복구 불가능한 것"(설정 누락, 프로그래밍 버그)뿐입니다. "모델이 알아서 대처했으면 하는 것"은 전부 ToolMessage 로 돌려주세요. 조기 종료가 목적이라면 jumpTo: "end" 를 쓰고요(12-7). 예외: interrupt() 가 던지는 GraphInterrupt 는 제어 흐름이라 감싸지 않고 그대로 통과합니다 — Step 13 의 HITL 이 이걸로 동작합니다.

12-5-3. 감사 로그

관찰만 하고 아무것도 바꾸지 않는 미들웨어입니다.

const auditMiddleware = createMiddleware({
  name: "AuditMiddleware",

  wrapToolCall: async (request, handler) => {
    const startedAt = Date.now();
    try {
      const result = await handler(request);
      console.log(`  [감사] ok    ${request.toolCall.name} ${JSON.stringify(request.toolCall.args)} (${Date.now() - startedAt}ms)`);
      return result;
    } catch (error) {
      console.log(`  [감사] FAIL  ${request.toolCall.name} (${Date.now() - startedAt}ms) — ${String(error)}`);
      throw error;                        // ← 삼키지 말고 그대로 올립니다
    }
  },
});

감사자는 에러를 삼키면 안 됩니다. catch 하고 throw 를 빼먹으면 위쪽 미들웨어가 실패를 못 보고, 도구가 실패했는데 성공한 것처럼 흘러갑니다.

세 개를 겹치기

const agent = createAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather, getPopulation, deleteRecord],
  systemPrompt: "너는 도시 정보 비서다. 사용자가 요청하면 주저하지 말고 도구를 써라.",
  middleware: [auditMiddleware, permissionMiddleware, cachingMiddleware],
});

순서가 곧 의미입니다: 감사(바깥) → 권한(중간) → 캐시(안쪽) → 실제 도구.

출력 예시

─ 1회차: 서울 날씨 ─
  [캐시 MISS] get_weather:{"city":"서울"}
      [도구 실제 실행] getWeather(서울)
  [감사] ok    get_weather {"city":"서울"} (2ms)

─ 2회차: 같은 질문 (캐시 HIT 기대) ─
  [캐시 HIT] get_weather:{"city":"서울"} → 도구를 실행하지 않음
  [감사] ok    get_weather {"city":"서울"} (0ms)

─ 3회차: 금지된 도구 요청 ─
  [권한 거부] delete_record — handler 를 부르지 않고 차단
  [감사] ok    delete_record {"id":"abc-123"} (0ms)

2회차에서 [도구 실제 실행] 이 사라졌고, 3회차에서도 감사 로그는 남았습니다(권한 미들웨어가 감사보다 안쪽이라서). 만약 순서를 [권한, 감사, 캐시] 로 뒤집었다면 차단된 호출은 감사 로그에 안 남습니다 — 보안 관점에서 최악입니다.

💡 실무 팁 — 캐시 수명: 위 Map 은 프로세스가 살아 있는 동안 무한히 자랍니다. 실무에서는 TTL 과 최대 크기가 필요합니다(lru-cache 등). 그리고 부수효과가 있는 도구는 절대 캐시하면 안 됩니다send_email 을 캐시하면 두 번째 메일이 안 갑니다. 캐시는 "같은 입력이면 같은 출력이고 부작용이 없는" 조회성 도구에만 거세요.


12-6. 상태 확장 — stateSchema

미들웨어는 자기만의 필드를 에이전트 상태에 추가할 수 있습니다. 방법이 둘인데, 어느 쪽을 고르느냐가 정확성을 좌우합니다.

방법 1: zod object — 리듀서 없음

import * as z from "zod";

const trackingStateSchema = z.object({
  modelCallCount: z.number().default(0),
});

const middleware = createMiddleware({
  name: "incrementAfterModel",
  stateSchema: trackingStateSchema,
  afterModel: (state) => {
    return { modelCallCount: state.modelCallCount + 1 };   // 새 총합을 반환
  },
});

간단합니다. 하지만 리듀서가 없어서 나중에 쓴 값이 이깁니다(last-write-wins).

방법 2: StateSchema + ReducedValue — 리듀서 있음

import { StateSchema, ReducedValue } from "@langchain/langgraph";
import * as z from "zod";

const CounterState = new StateSchema({
  // 더하기 리듀서 — 누적됩니다
  modelCallCount: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  // 덮어쓰기 리듀서
  lastModelName: new ReducedValue(z.string().default(""), {
    reducer: (_current: string, next: string) => next,
  }),
  // _ 로 시작하면 private — invoke() 결과에 안 실립니다
  _internalNote: new ReducedValue(z.string().default(""), {
    reducer: (_current: string, next: string) => next,
  }),
});

const counterMiddleware = createMiddleware({
  name: "CounterMiddleware",
  stateSchema: CounterState,

  afterModel: (state) => {
    console.log(`  [카운터] 지금까지 모델 호출 ${state.modelCallCount}회`);

    return {
      modelCallCount: 1,          // ← "증가분". "새 총합" 이 아닙니다
      lastModelName: "claude-sonnet-4-6",
      _internalNote: "이 필드는 결과에 안 보입니다",
    };
  },
});

리듀서가 current + next 이므로 반환할 것은 증가분 1 입니다. 방법 1 처럼 state.modelCallCount + 1 을 반환하면 리듀서가 또 더해서 값이 폭주합니다. 리듀서를 바꾸면 반환값의 의미도 같이 바뀝니다.

출력 예시

  [카운터] 지금까지 모델 호출 0회
  [카운터] 지금까지 모델 호출 1회
modelCallCount (public)   2
lastModelName (public)    claude-sonnet-4-6
_internalNote (private)   (결과에 없음 — 의도된 동작)

_internalNote 가 결과에 없습니다. _ 로 시작하는 필드는 private 이라 invoke() 결과에서 걸러집니다. 미들웨어 내부 플래그를 사용자에게 노출하지 않으려면 이걸 쓰세요.

어느 쪽을 쓸까

zod objectStateSchema + ReducedValue
리듀서없음 (last-write-wins)있음
반환값 의미최종 값리듀서 입력(증가분 등)
적합한 것플래그, 최신 값 하나카운터, 누적기, 집계
병렬 업데이트✗ 덮어써짐✓ 합쳐짐

⚠️ 함정 (state 를 mutate 하면 안 된다 — 새 객체를 반환해야 리듀서가 돈다): 이렇게 쓰고 싶어집니다.

afterModel: (state) => {
  state.modelCallCount++;              // ✗ 아무 일도 일어나지 않습니다
  state.toolCounts["get_weather"] = 3; // ✗ 더 나쁩니다
  return;
}

LangGraph 는 훅의 반환값을 리듀서에 넣어 다음 상태를 만듭니다. state 객체를 직접 고치는 건 리듀서 파이프라인 바깥의 일이라 그냥 무시됩니다. 타입 에러도, 런타임 에러도 없습니다 — 카운터가 영원히 0 일 뿐입니다.

두 번째 줄은 더 위험합니다. 상태는 스냅샷으로 관리되는데 스냅샷을 mutate 하면 체크포인터에 저장된 과거 상태까지 바뀌어 타임트래블과 재개(resume)가 깨집니다. 리듀서 안에서도 마찬가지입니다 — current 를 건드리지 말고 { ...current } 로 새 객체를 만드세요.

규칙: 훅은 순수 함수처럼 쓰세요. 읽기만 하고, 바꿀 것은 반환하세요.

⚠️ 함정 (병렬 도구 호출에서 덮어쓰기 리듀서는 조용히 값을 잃는다): 도구별 호출 횟수를 세는 필드를 zod object(또는 (_a, b) => b 리듀서)로 만들었다고 합시다. 모델이 도구를 3개 병렬로 부르면 wrapToolCall 이 3번 거의 동시에 돌고, 각각 { get_weather: 1 } 을 반환합니다. 덮어쓰기 리듀서면 최종 결과는 { get_weather: 1 }3번 불렀는데 1로 남습니다.

에러도 경고도 없습니다. 그냥 숫자가 틀립니다. 그리고 이건 모델이 병렬로 부를 때만 재현되므로 로컬에서는 잘 안 보입니다. 카운터·누적기는 반드시 합치기 리듀서를 쓰세요.


12-7. 흐름 제어 — jumpTo

노드형 훅은 반환 객체에 jumpTo 를 실어 루프의 다음 목적지를 바꿀 수 있습니다. 갈 수 있는 곳은 딱 3개입니다.

jumpTo의미
"model"모델 호출로 되돌아간다 (다시 시키기)
"tools"도구 실행으로 건너뛴다
"end"즉시 종료한다

금칙어가 있으면 모델을 아예 부르지 않고 끝내 봅시다.

const BLOCKED_WORDS = ["비밀번호", "주민등록번호"];

const guardMiddleware = createMiddleware({
  name: "GuardMiddleware",

  beforeModel: {
    canJumpTo: ["end"],                    // ← 이 선언이 없으면 동작하지 않습니다
    hook: (state) => {
      const lastHuman = [...state.messages].reverse().find((m) => m.getType() === "human");
      const text = lastHuman?.text ?? "";

      const hit = BLOCKED_WORDS.find((w) => text.includes(w));
      if (hit !== undefined) {
        console.log(`  [가드] 금칙어 '${hit}' 감지 → 모델을 부르지 않고 즉시 종료`);

        return {
          messages: [new AIMessage("죄송합니다. 해당 정보는 다룰 수 없습니다.")],
          jumpTo: "end" as const,
        };
      }
      return;
    },
  },
});

훅이 함수가 아니라 { canJumpTo, hook } 객체인 것에 주목하세요.

출력 예시

─ 정상 질문 (모델이 돎) ─
AI     │ 서울은 현재 맑고 24도입니다.

─ 금칙어 질문 (모델을 안 부르고 조기 종료) ─
  [가드] 금칙어 '비밀번호' 감지 → 모델을 부르지 않고 즉시 종료
AI     │ 죄송합니다. 해당 정보는 다룰 수 없습니다.

두 번째 케이스에서 모델 호출이 0번 일어났습니다. 토큰도 0, 지연도 0입니다. 가드레일을 프롬프트로 거는 것보다 훨씬 싸고 확실합니다.

⚠️ 함정 (canJumpTo 없이 jumpTo 만 반환하면 조용히 무시된다): 이건 이 절에서 가장 많이 걸리는 함정입니다.

beforeModel: (state) => {
  if (blocked) return { messages: [...], jumpTo: "end" };   // ✗ 안 먹습니다
}

타입 에러도 런타임 에러도 없습니다. jumpTo 는 그냥 무시되고 모델이 평소대로 돕니다. messages 업데이트는 적용되므로 "차단 메시지도 붙고 모델 답변도 붙는" 기괴한 결과가 나옵니다.

이유는 LangGraph 의 구조에 있습니다. canJumpTo 는 "이 노드에서 저기로 가는 엣지를 그래프에 깔아 달라"는 컴파일 시점 선언입니다. 선언이 없으면 갈 길 자체가 없어서 점프가 성립하지 않습니다. jumpTo 를 쓸 거면 반드시 { canJumpTo: [...], hook: ... } 형태로 바꾸세요.

덤으로 TypeScript 함정 하나: jumpTo: "end" 를 그냥 쓰면 TS 가 타입을 string 으로 넓혀서 JumpToTarget 과 안 맞는다고 합니다. jumpTo: "end" as const 로 쓰세요.

⚠️ 함정 (jumpTo: "model" 은 무한 루프를 만들 수 있다): afterModel 에서 "답변이 마음에 안 들면 다시 시키기"를 구현하면 이런 모양이 됩니다.

afterModel: {
  canJumpTo: ["model"],
  hook: (state) => {
    if (state.messages.at(-1)!.text.length < 20) {
      return { messages: [new SystemMessage("너무 짧다")], jumpTo: "model" as const };   // ✗ 가드 없음
    }
    return;
  },
}

모델이 계속 짧게 답하면 영원히 되돌아갑니다. recursionLimit(기본 25)에 닿아 GraphRecursionError 로 죽는데, 그때까지의 토큰은 이미 다 청구된 뒤입니다. 되돌리기 횟수를 상태로 세고 상한을 두세요 — 연습문제 7 이 이겁니다. 원칙: 미들웨어가 루프를 만들 수 있다면, 그 루프를 끊는 조건도 미들웨어가 책임진다.


12-8. 실전 — 비용 추적 미들웨어 + 토큰 예산 초과 시 중단

여기까지 배운 걸 전부 합칩니다. 요구사항은 이렇습니다.

  • 모델 호출마다 실제 토큰 사용량을 읽어 비용을 계산한다
  • 누적 토큰이 예산을 넘으면 모델을 더 이상 부르지 않고 종료한다
  • 끝날 때 최종 리포트를 남긴다

쓰는 재료: stateSchema(누적) + wrapModelCall(계측) + beforeModel + jumpTo(중단) + afterAgent(리포트).

import { createAgent, createMiddleware, AIMessage } from "langchain";
import { Command, StateSchema, ReducedValue } from "@langchain/langgraph";
import * as z from "zod";

// 100만 토큰당 USD. 실제 단가는 provider 문서를 보세요.
const PRICE_PER_MTOK = { input: 3.0, output: 15.0 } as const;

const BudgetState = new StateSchema({
  inputTokens: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  outputTokens: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  costUsd: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  budgetExceeded: new ReducedValue(z.boolean().default(false), {
    reducer: (_current: boolean, next: boolean) => next,
  }),
});

const createCostTrackingMiddleware = (maxTotalTokens: number) =>
  createMiddleware({
    name: "CostTrackingMiddleware",
    stateSchema: BudgetState,

    // 1) 예산을 이미 넘겼으면 모델을 아예 부르지 않는다.
    beforeModel: {
      canJumpTo: ["end"],
      hook: (state) => {
        const used = state.inputTokens + state.outputTokens;

        if (used >= maxTotalTokens) {
          console.log(`  [예산] ${used} / ${maxTotalTokens} 토큰 — 초과. 모델 호출 중단.`);

          return {
            messages: [
              new AIMessage(
                `토큰 예산(${maxTotalTokens})을 소진해 작업을 중단했습니다. ` +
                  `지금까지 ${used} 토큰, 약 $${state.costUsd.toFixed(4)} 를 썼습니다.`,
              ),
            ],
            budgetExceeded: true,
            jumpTo: "end" as const,
          };
        }

        console.log(`  [예산] ${used} / ${maxTotalTokens} 토큰 사용 — 계속 진행`);
        return;
      },
    },

    // 2) 모델 호출을 감싸 실제 사용량을 읽는다.
    wrapModelCall: async (request, handler) => {
      const response = await handler(request);

      // usage_metadata 는 optional 입니다. 제공자가 안 주면 undefined 입니다.
      const usage = response.usage_metadata;
      if (usage === undefined) {
        console.log("  [비용] usage_metadata 없음 — 이번 호출은 집계 불가");
        return response;
      }

      const cost =
        (usage.input_tokens / 1_000_000) * PRICE_PER_MTOK.input +
        (usage.output_tokens / 1_000_000) * PRICE_PER_MTOK.output;

      console.log(`  [비용] in=${usage.input_tokens} out=${usage.output_tokens} → $${cost.toFixed(6)}`);

      // handler() 를 이미 불렀으므로 AI 메시지는 프레임워크가 알아서 붙여 줍니다.
      return new Command({
        update: {
          inputTokens: usage.input_tokens,
          outputTokens: usage.output_tokens,
          costUsd: cost,
        },
      });
    },

    // 3) 끝날 때 리포트.
    afterAgent: (state) => {
      console.log(
        `\n  [최종] 입력 ${state.inputTokens} + 출력 ${state.outputTokens} = ` +
          `${state.inputTokens + state.outputTokens} 토큰 / $${state.costUsd.toFixed(6)}` +
          `${state.budgetExceeded ? "  ← 예산 초과로 중단됨" : ""}`,
      );
      return;
    },
  });

일부러 예산을 아주 작게 잡아 중단이 실제로 걸리는 걸 봅니다.

const agent = createAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather, getPopulation],
  systemPrompt: "너는 도시 정보 비서다. 도시마다 도구를 하나씩 따로 호출해라.",
  middleware: [createCostTrackingMiddleware(1500)],
});

const result = await agent.invoke({
  messages: [{ role: "user", content: "서울, 부산, 제주의 날씨와 인구를 전부 조사해서 표로 정리해줘." }],
});

출력 예시 (토큰 수는 모델 응답에 따라 매번 다릅니다)

  [예산] 0 / 1500 토큰 사용 — 계속 진행
  [비용] in=512 out=143 → $0.003681
  [예산] 655 / 1500 토큰 사용 — 계속 진행
  [비용] in=891 out=207 → $0.005778
  [예산] 1753 / 1500 토큰 — 초과. 모델 호출 중단.

  [최종] 입력 1403 + 출력 350 = 1753 토큰 / $0.009459  ← 예산 초과로 중단됨

최종 답변:
AI     │ 토큰 예산(1500)을 소진해 작업을 중단했습니다. 지금까지 1753 토큰, 약 $0.0095 를 썼습니다.

입력 토큰   1403
출력 토큰   350
비용(USD)   $0.009459
예산 초과   true

동작을 뜯어봅시다.

  • wrapModelCallCommand 를 반환하는데 AI 메시지가 사라지지 않았습니다. handler(request) 를 먼저 불렀기 때문에 프레임워크가 응답을 추적해 두었다가 messages 에 붙여 줍니다. handler 를 안 불렀다면 답변 없는 빈 턴이 됐을 겁니다(12-4-3 함정).
  • 예산 검사는 beforeModel 에서 합니다. wrapModelCall 에서 검사하면 이미 늦습니다 — 거기서는 이번 호출을 막을 수 있어도 "막았다"는 사실을 상태에 남기고 루프를 끝내기가 번거롭습니다. "부를지 말지"는 노드형 훅, "부르는 방법"은 래퍼형 훅입니다.
  • 1753 / 1500 처럼 예산을 넘겨서 멈춥니다. 모델을 부르기 전에는 이번 호출이 몇 토큰을 쓸지 알 수 없기 때문입니다. 정확한 상한이 필요하면 beforeModel 에서 메시지를 세어 추정하거나(model.getNumTokens()), 예산을 실제보다 낮게 잡아 여유를 두세요.

💡 실무 팁 — 단가를 코드에 박지 마세요: PRICE_PER_MTOK 를 상수로 박아 두면 provider 가 가격을 바꿀 때 조용히 틀린 청구서를 만듭니다. 실무에서는 모델 이름 → 단가 맵을 설정 파일이나 원격 설정으로 빼고, 모르는 모델이면 0 이 아니라 경고를 찍으세요. usage_metadataundefined 인 경우도 마찬가지입니다 — 조용히 0으로 넘기면 "비용이 0인 에이전트"가 만들어집니다. input_token_details.cache_read 는 단가가 다르므로(캐시 읽기는 훨씬 쌉니다) 정확한 비용을 원하면 그것도 따로 계산해야 합니다.

💡 실무 팁 — 예산은 스레드 단위입니다: 위 미들웨어의 토큰은 state 에 있으므로 thread_id 마다 따로 셉니다(Step 10). "이 대화가 얼마나 썼나"에는 맞지만 "이 사용자가 이번 달에 얼마나 썼나"에는 안 맞습니다. 후자는 상태가 아니라 외부 저장소(Store, DB)에 있어야 합니다 — Step 15 에서 다룹니다. "제어에 쓰는 값"과 "기록에 남길 값"의 자리를 구분하세요.


정리

종류대표 용도
beforeAgent노드형초기화, 사용자 정보 로드 (invoke 당 1번)
beforeModel노드형사전 검사, 가드레일, 예산 초과 시 중단
wrapModelCall래퍼형동적 프롬프트/모델, 폴백, 재시도, 계측
afterModel노드형응답 검증, 되돌리기, 카운터
wrapToolCall래퍼형캐싱, 권한, 감사, 타임아웃, 인자 검열
afterAgent노드형최종 리포트, 정리 (invoke 당 1번)

순서: before 계열 → 등록 순서 / after 계열 → 역순 / wrap 계열 → 양파처럼 중첩.

상태: 카운터·누적기는 StateSchema + ReducedValue(합치기 리듀서). _ 로 시작하는 필드는 결과에서 숨겨집니다.

흐름 제어: jumpTo"model" / "tools" / "end" 세 곳. 반드시 { canJumpTo, hook } 형태로.

핵심 함정 5가지

  1. state 를 mutate 하면 아무 일도 안 일어난다. state.count++ 는 무시됩니다. 리듀서는 훅의 반환값만 봅니다. 새 객체를 반환하세요. 스냅샷을 mutate 하면 체크포인터의 과거 상태까지 오염됩니다.
  2. handler 를 안 부르면 모델이 아예 안 돈다. wrapModelCall 에서 handler 없이 Command 만 반환하면 답변 없는 빈 턴이 됩니다. 에러도 안 납니다. 부르지 않을 거면 AIMessage 를 직접 만드세요.
  3. 미들웨어에서 던진 예외는 에이전트 전체를 죽인다. 부분 결과도 없이 invoke() 가 통째로 실패합니다. "거부"는 throw 가 아니라 ToolMessagejumpTo: "end" 로 표현하세요.
  4. beforeModel 에서 messages 를 통째로 갈아끼우면 tool_call/ToolMessage 짝이 깨져 provider 400. 대화가 짧을 땐 안 터지고, 도구를 쓰기 시작해 길어지는 순간 프로덕션에서 터집니다. 반환할 것은 덧붙일 메시지만 입니다.
  5. await 하나가 try/catch 를 죽인다. return handler(req) 는 Promise 를 즉시 반환하므로 rejection 이 catch 를 그냥 통과합니다. 폴백·재시도·시간 측정이 전부 조용히 죽습니다. return await handler(req).

보너스 함정: canJumpTo 없는 jumpTo 는 무시된다 / ToolMessage 를 통째로 캐시하면 tool_call_id 가 어긋나 2회차부터 400 / 병렬 도구 호출에서 덮어쓰기 리듀서는 값을 잃는다.


연습문제

  1. 메시지 개수 제한 — 대화 메시지가 maxMessages 개 이상이면 모델을 부르지 않고 AIMessage("대화가 너무 길어졌습니다.") 를 넣고 종료하는 미들웨어를 만드세요. (힌트: { canJumpTo, hook })
  2. 지연시간 측정 — 모델 호출이 몇 ms 걸렸는지 재서 찍는 미들웨어를 만드세요. 모델이 에러를 던져도 시간은 찍혀야 합니다. (힌트: try/finally, 그리고 await)
  3. 도구 타임아웃 — 도구가 timeoutMs 안에 안 끝나면 포기하고 에러 ToolMessage 를 반환하는 미들웨어를 만드세요. throw 하면 안 됩니다. (힌트: Promise.race)
  4. 도구별 호출 횟수 — 어떤 도구가 몇 번 불렸는지 { get_weather: 3 } 같은 객체로 누적하는 미들웨어를 만드세요. 모델이 도구를 병렬로 불러도 합계가 맞아야 합니다. (힌트: 합치기 리듀서)
  5. 재시도 — 모델 호출이 실패하면 지수 백오프(100ms, 200ms, 400ms...)로 최대 maxRetries 번 재시도하는 미들웨어를 만드세요. 다 실패하면 마지막 에러를 던집니다. return handler(request) 라고 쓰면 왜 재시도가 안 도는지 설명할 수 있어야 합니다.
  6. 인자 검열send_emailbody 인자에서 카드번호 패턴(/\d{4}-\d{4}-\d{4}-\d{4}/g)을 [REDACTED] 로 바꾼 뒤 도구를 실행하는 미들웨어를 만드세요. request.toolCall.args 를 직접 수정하면 안 됩니다 — 왜일까요?
  7. 짧은 답변 되돌리기 — 모델이 도구를 안 부르고 답변이 20자 미만이면 "다시 답해라"라고 시키고 모델로 되돌아가는 미들웨어를 만드세요. 되돌리기는 최대 1번만 — 안 그러면 무한 루프입니다.
  8. 속도 제한 — 모델 호출이 초당 maxPerSecond 회를 넘지 않게 강제하고, 총 대기 시간을 상태에 누적하는 미들웨어를 만드세요. 생각해 볼 것: "마지막 호출 시각"을 모듈 변수에 둘까 state 에 둘까? 둘의 차이는 무엇일까요?

문제만 담긴 파일은 exercise.ts, 정답과 해설은 solution.ts 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 실려 있습니다.


다음 단계

Step 13 — Human-in-the-Loop

wrapToolCall 로 도구를 가로챌 수 있다는 걸 배웠습니다. 그럼 "가로챈 다음 사람에게 물어보고 결정한다"도 가능하겠죠. 그게 HITL 이고, interrupt() 로 구현됩니다. 12-5 에서 "GraphInterrupt 만은 MiddlewareError 로 안 감싸진다"고 한 게 바로 이 때문입니다.


실습 파일

이 스텝은 TypeScript 파일 3개로 구성됩니다. 본문(12-1 ~ 12-8)의 예제를 순서대로 담은 practice.ts 를 먼저 실행해 훅이 뛰는 순서를 눈으로 확인하고, 그다음 exercise.ts 의 8문제를 직접 풀어본 뒤, 마지막으로 solution.ts 로 채점하고 해설을 읽는 흐름입니다.

세 파일 모두 ANTHROPIC_API_KEY 가 필요하고 실제로 모델을 호출합니다. 실행은 프로젝트 루트에서 npx tsx docs/reference/langchain/step-12-middleware-custom/practice.ts 입니다. OpenAI 로 바꾸려면 모델 문자열을 "openai:gpt-5.5" 로 고치고 OPENAI_API_KEY 를 넣으면 됩니다 — 미들웨어 코드는 한 줄도 안 바뀝니다.

practice.ts

본문 강의를 따라가며 손으로 쳐볼 예제를 [12-1] ~ [12-9] 주석 번호로 묶어 놓은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응합니다. 전부 돌리면 30~60초 걸리니, 특정 절만 보고 싶으면 맨 아래 main() 의 호출부를 주석 처리하세요.

  • [12-1]tracer(label) 이 이 파일의 백미입니다. 6개 훅에 전부 로그만 심은 미들웨어를 두 개 겹쳐(middleware: [tracer("A"), tracer("B")]) 놓아서, "before 는 A→B / after 는 B→A / wrap 은 A→ B→ 모델 →B← A←" 라는 규칙이 출력에 그대로 그려집니다. 표로 외우려 하지 말고 이 출력을 한 번 보세요.
  • [12-4-3]initChatModel("anthropic:claude-이런-모델-없음") 은 오타가 아니라 의도된 것입니다. 존재하지 않는 모델 이름은 생성 시점에는 통과하고 invoke 시점에 provider 가 404 를 던지므로, 폴백이 실제로 발동하는 걸 API 키만으로 재현할 수 있습니다.
  • [12-5]middleware: [auditMiddleware, permissionMiddleware, cachingMiddleware] 순서를 바꿔 가며 돌려 보세요. [권한, 감사, 캐시] 로 뒤집으면 차단된 delete_record 호출이 감사 로그에서 사라집니다. 배열 순서가 보안 경계라는 게 실감납니다.
  • [12-5] 의 도구들은 실행될 때 [도구 실제 실행] 을 찍습니다. 2회차에서 이 줄이 안 찍히는 것이 캐시가 먹었다는 증거이고, delete_record 에서 이 줄이 찍히면 권한 검사가 뚫린 것입니다.
  • [12-9] 는 본문에 없는 보너스 절로, 미들웨어에서 throw 했을 때 MiddlewareError.isInstance(error)true 가 되고 error.cause 에 원본이 담기는 걸 직접 확인합니다. 12-5 의 "예외는 에이전트를 죽인다" 함정을 손으로 겪어 보는 자리입니다.
/**
 * Step 12 — 커스텀 미들웨어
 * 실행: npx tsx docs/reference/langchain/step-12-middleware-custom/practice.ts
 *
 * 본문 12-1 ~ 12-8 의 예제를 순서대로 담았습니다.
 * 모델을 실제로 호출하므로 ANTHROPIC_API_KEY 가 필요하고, 전부 돌리면 30~60초 걸립니다.
 * 특정 절만 보고 싶으면 파일 맨 아래 main() 의 호출부를 주석 처리하세요.
 */
import "dotenv/config";

import {
  AIMessage,
  MiddlewareError,
  SystemMessage,
  ToolMessage,
  createAgent,
  createMiddleware,
  initChatModel,
  tool,
} from "langchain";
import { Command, ReducedValue, StateSchema } from "@langchain/langgraph";
import * as z from "zod";

import { printSection, printMessages, printKV, requireEnv } from "../project/src/lib/print.js";

requireEnv("ANTHROPIC_API_KEY");

/* ===== 공용 도구 =====
 *
 * 미들웨어를 관찰하려면 "도구를 부르는 에이전트"가 필요합니다.
 * 네트워크 의존성을 없애려고 전부 가짜 데이터로 즉답합니다.
 */

const getWeather = tool(
  ({ city }) => {
    console.log(`      [도구 실제 실행] getWeather(${city})`);
    const table: Record<string, string> = {
      서울: "맑음, 24도",
      부산: "흐림, 21도",
      제주: "비, 19도",
    };
    return table[city] ?? `${city} 날씨 정보 없음`;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름 (예: 서울)") }),
  },
);

const getPopulation = tool(
  ({ city }) => {
    console.log(`      [도구 실제 실행] getPopulation(${city})`);
    const table: Record<string, string> = {
      서울: "약 938만 명",
      부산: "약 329만 명",
      제주: "약 67만 명",
    };
    return table[city] ?? `${city} 인구 정보 없음`;
  },
  {
    name: "get_population",
    description: "특정 도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름") }),
  },
);

/** 위험한 도구 — 12-5 의 권한 검사 대상 */
const deleteRecord = tool(
  ({ id }) => {
    console.log(`      [도구 실제 실행] deleteRecord(${id})  ← 이게 찍히면 권한 검사가 뚫린 것`);
    return `레코드 ${id} 삭제됨`;
  },
  {
    name: "delete_record",
    description: "데이터베이스에서 레코드를 영구 삭제합니다.",
    schema: z.object({ id: z.string().describe("삭제할 레코드 ID") }),
  },
);

/* ===== [12-1] 훅 전체 — 6개 훅이 언제 불리는가 ===== */

/**
 * 6개 훅에 전부 로그만 심은 미들웨어를 만듭니다.
 * label 을 받아 두 개를 겹쳐 놓으면 "before 는 순서대로 / after 는 역순 / wrap 은 중첩"
 * 이라는 규칙이 출력으로 그대로 드러납니다.
 */
const tracer = (label: string) =>
  createMiddleware({
    name: `Tracer-${label}`,

    // 에이전트 실행 전체에서 딱 한 번. 루프를 몇 바퀴 돌든 한 번입니다.
    beforeAgent: () => {
      console.log(`${label} │ beforeAgent`);
      return;
    },

    // 모델 호출마다. 루프를 3바퀴 돌면 3번 불립니다.
    beforeModel: () => {
      console.log(`${label} │   beforeModel`);
      return;
    },

    // 모델 호출을 감싸는 훅. handler() 앞뒤로 코드가 갈라집니다.
    wrapModelCall: async (request, handler) => {
      console.log(`${label} │     wrapModelCall  →  (모델 부르기 직전)`);
      const response = await handler(request);
      console.log(`${label} │     wrapModelCall  ←  (모델이 답한 직후)`);
      return response;
    },

    // 모델 응답 직후, 도구가 실행되기 전.
    afterModel: () => {
      console.log(`${label} │   afterModel`);
      return;
    },

    // 도구 호출마다. 모델이 도구를 2개 부르면 2번 불립니다.
    wrapToolCall: async (request, handler) => {
      console.log(`${label} │     wrapToolCall   →  ${request.toolCall.name}`);
      const result = await handler(request);
      console.log(`${label} │     wrapToolCall   ←  ${request.toolCall.name}`);
      return result;
    },

    // 에이전트 실행 전체에서 딱 한 번, 맨 마지막.
    afterAgent: () => {
      console.log(`${label} │ afterAgent`);
      return;
    },
  });

async function hookOrder(): Promise<void> {
  printSection("[12-1] 훅 전체 — 실행 순서 관찰");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather],
    systemPrompt: "너는 날씨 비서다. 도구로 확인한 사실만 답해라.",
    // 배열 순서가 곧 바깥→안쪽 순서입니다. A 가 바깥, B 가 안쪽.
    middleware: [tracer("A"), tracer("B")],
  });

  await agent.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });

  console.log(`
읽는 법:
  beforeAgent   A → B      (등록 순서대로)
  beforeModel   A → B      (등록 순서대로)
  wrapModelCall A→ B→ 모델 →B← A←   (양파처럼 중첩)
  afterModel    B → A      (역순!)
  afterAgent    B → A      (역순!)
`);
}

/* ===== [12-2] createMiddleware() 로 첫 미들웨어 만들기 ===== */

/**
 * 가장 쓸모 있는 첫 미들웨어는 로깅입니다.
 * 에이전트가 왜 그렇게 답했는지는 "모델에 뭘 넣었고 뭐가 나왔나"를 봐야 알 수 있는데,
 * createAgent 는 그걸 기본으로 보여주지 않습니다.
 */
const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",

  beforeModel: (state) => {
    console.log(`  [로그] 모델 호출 예정 — 메시지 ${state.messages.length}개`);
    // 아무것도 안 바꿀 거면 undefined 를 반환합니다(= 통과).
    return;
  },

  afterModel: (state) => {
    const last = state.messages.at(-1);
    const toolCalls = (last as AIMessage | undefined)?.tool_calls ?? [];

    if (toolCalls.length > 0) {
      console.log(`  [로그] 모델이 도구 ${toolCalls.length}개 요청: ${toolCalls.map((c) => c.name).join(", ")}`);
    } else {
      console.log(`  [로그] 모델이 최종 답변: ${last?.text.slice(0, 40)}...`);
    }
    return;
  },
});

async function firstMiddleware(): Promise<void> {
  printSection("[12-2] createMiddleware() — 로깅 미들웨어");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather, getPopulation],
    systemPrompt: "너는 도시 정보 비서다.",
    middleware: [loggingMiddleware],
  });

  const result = await agent.invoke({
    messages: [{ role: "user", content: "서울과 부산의 날씨와 인구를 알려줘." }],
  });

  console.log("\n최종 답변:");
  printMessages(result.messages.at(-1)!);
}

/* ===== [12-3] beforeModel — 매 모델 호출 전 메시지 손보기 ===== */

/**
 * beforeModel 은 state 를 받아 "상태 업데이트"를 반환합니다.
 * messages 는 append 리듀서를 쓰므로, 반환한 메시지는 기존 뒤에 "붙습니다".
 *
 * 여기서는 매 모델 호출 직전에 현재 시각을 SystemMessage 로 밀어 넣습니다.
 * (모델은 지금이 몇 시인지 모릅니다 — 알려주지 않으면 지어냅니다.)
 */
const timeInjectorMiddleware = createMiddleware({
  name: "TimeInjectorMiddleware",

  beforeModel: (state) => {
    const now = new Date().toISOString();
    console.log(`  [시각 주입] ${now} (메시지 ${state.messages.length}개 뒤에 append)`);

    // ⚠️ 반환값은 "덧붙일 것"이지 "전체 목록"이 아닙니다.
    //    state.messages 를 통째로 돌려주면 대화가 두 배로 불어납니다.
    return {
      messages: [new SystemMessage(`[시스템] 현재 시각은 ${now} 입니다.`)],
    };
  },
});

/**
 * beforeModel 로 "동적 시스템 프롬프트"를 흉내 낼 수는 있지만,
 * 그건 대화 기록(messages)을 영구히 오염시킵니다 — 주입한 SystemMessage 가
 * 체크포인터에 그대로 저장되고, 다음 턴에도 남아 있습니다.
 * 진짜 동적 시스템 프롬프트는 12-4 의 wrapModelCall 로 합니다.
 */
async function beforeModelDemo(): Promise<void> {
  printSection("[12-3] beforeModel — 메시지 손보기");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [],
    systemPrompt: "너는 간결한 비서다.",
    middleware: [timeInjectorMiddleware],
  });

  const result = await agent.invoke({
    messages: [{ role: "user", content: "지금 몇 시인지 시스템이 알려준 대로만 말해줘." }],
  });

  printMessages(result.messages);

  console.log(`
주목: 주입한 SystemMessage 가 result.messages 안에 남아 있습니다.
      대화 기록을 더럽히지 않으려면 12-4 의 wrapModelCall 을 쓰세요.
`);
}

/* ===== [12-4] wrapModelCall — 모델 호출 감싸기 ===== */

/**
 * (1) 동적 시스템 프롬프트
 *
 * wrapModelCall 은 request.systemMessage 를 갈아끼울 수 있습니다.
 * 이건 "이번 모델 호출에만" 적용되고 state.messages 에는 안 남습니다.
 * 12-3 과 결정적으로 다른 점입니다.
 */
const dynamicPromptMiddleware = createMiddleware({
  name: "DynamicPromptMiddleware",

  wrapModelCall: async (request, handler) => {
    const turn = request.messages.filter((m) => m.getType() === "human").length;
    const extra = turn > 1 ? " 사용자가 이미 여러 번 물었다. 더 짧게 답해라." : "";

    console.log(`  [동적 프롬프트] 사용자 턴 ${turn}회 → 추가 지시${extra === "" ? " 없음" : " 있음"}`);

    // request 를 통째로 바꾸지 말고, 스프레드로 필요한 필드만 덮어씁니다.
    return handler({
      ...request,
      systemMessage: request.systemMessage.concat(extra),
    });
  },
});

async function dynamicPromptDemo(): Promise<void> {
  printSection("[12-4-1] wrapModelCall — 동적 시스템 프롬프트");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [],
    systemPrompt: "너는 친절한 비서다.",
    middleware: [dynamicPromptMiddleware],
  });

  const result = await agent.invoke({
    messages: [
      { role: "user", content: "하늘은 왜 파랗지?" },
      { role: "assistant", content: "빛의 산란 때문입니다." },
      { role: "user", content: "그럼 노을은 왜 빨갛지?" },
    ],
  });

  printMessages(result.messages.at(-1)!);

  console.log(`
주목: result.messages 에 추가 지시가 안 보입니다.
      systemMessage 는 이번 모델 호출에만 쓰이고 상태에 저장되지 않습니다.
      12-3 의 beforeModel 방식과 정반대입니다.
`);
}

/**
 * (2) 동적 모델 선택
 *
 * 짧은 대화는 싼 모델로, 길고 복잡해지면 비싼 모델로.
 * request.model 을 바꿔서 handler 에 넘기면 됩니다.
 */
async function dynamicModelDemo(): Promise<void> {
  printSection("[12-4-2] wrapModelCall — 동적 모델 선택");

  const models = {
    cheap: await initChatModel("anthropic:claude-haiku-4-5-20251001"),
    strong: await initChatModel("anthropic:claude-sonnet-4-6"),
  };

  const routerMiddleware = createMiddleware({
    name: "ModelRouterMiddleware",
    wrapModelCall: (request, handler) => {
      const useStrong = request.messages.length > 4;
      console.log(`  [모델 라우팅] 메시지 ${request.messages.length}개 → ${useStrong ? "sonnet(비쌈)" : "haiku(쌈)"}`);

      return handler({ ...request, model: useStrong ? models.strong : models.cheap });
    },
  });

  const agent = createAgent({
    // 이 model 은 "기본값"일 뿐, 미들웨어가 매번 덮어씁니다.
    model: models.strong,
    tools: [getWeather],
    systemPrompt: "너는 날씨 비서다.",
    middleware: [routerMiddleware],
  });

  await agent.invoke({ messages: [{ role: "user", content: "서울 날씨는?" }] });
}

/**
 * (3) 폴백을 직접 구현
 *
 * handler 를 여러 번 부를 수 있다는 게 wrapModelCall 의 힘입니다.
 * 첫 모델이 죽으면 두 번째 모델로 다시 부릅니다.
 */
async function fallbackDemo(): Promise<void> {
  printSection("[12-4-3] wrapModelCall — 폴백 직접 구현");

  // 존재하지 않는 모델 이름 → invoke 시점에 provider 가 404 를 던집니다.
  const brokenModel = await initChatModel("anthropic:claude-이런-모델-없음");
  const goodModel = await initChatModel("anthropic:claude-sonnet-4-6");

  const fallbackMiddleware = createMiddleware({
    name: "FallbackMiddleware",

    wrapModelCall: async (request, handler) => {
      try {
        // ⚠️ await 를 반드시 붙여야 합니다.
        //    `return handler(...)` 로 쓰면 Promise 가 그대로 반환되고,
        //    거부(rejection)는 이 try/catch 를 그냥 통과해 버립니다.
        return await handler({ ...request, model: brokenModel });
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`  [폴백] 1차 모델 실패 → 2차 모델로 재시도`);
        console.log(`         원인: ${message.slice(0, 70)}...`);

        return await handler({ ...request, model: goodModel });
      }
    },
  });

  const agent = createAgent({
    model: goodModel,
    tools: [],
    systemPrompt: "너는 간결한 비서다.",
    middleware: [fallbackMiddleware],
  });

  const result = await agent.invoke({
    messages: [{ role: "user", content: "안녕이라고만 답해줘." }],
  });

  printMessages(result.messages.at(-1)!);
}

/* ===== [12-5] wrapToolCall — 도구 호출 가로채기 ===== */

/**
 * (1) 캐싱
 *
 * 같은 인자로 같은 도구를 또 부르면 실제 실행을 건너뜁니다.
 *
 * ⚠️ 핵심: ToolMessage 객체를 통째로 캐시하면 안 됩니다.
 *    ToolMessage 에는 tool_call_id 가 박혀 있는데, 이건 호출마다 새로 생깁니다.
 *    옛날 id 가 박힌 메시지를 돌려주면 provider 가 400 을 냅니다.
 *    → "내용"만 캐시하고 ToolMessage 는 매번 새로 만듭니다.
 */
const toolCache = new Map<string, string>();

const cachingMiddleware = createMiddleware({
  name: "CachingMiddleware",

  wrapToolCall: async (request, handler) => {
    const key = `${request.toolCall.name}:${JSON.stringify(request.toolCall.args)}`;

    const hit = toolCache.get(key);
    if (hit !== undefined) {
      console.log(`  [캐시 HIT] ${key} → 도구를 실행하지 않음`);
      // tool_call_id 는 반드시 "이번" 호출의 id 를 씁니다.
      return new ToolMessage({
        content: hit,
        tool_call_id: request.toolCall.id!,
        name: request.toolCall.name,
      });
    }

    console.log(`  [캐시 MISS] ${key}`);
    const result = await handler(request);

    // handler 는 ToolMessage 또는 Command 를 돌려줍니다.
    // Command 는 상태를 직접 조작하는 도구가 쓰는 것이라 캐시 대상이 아닙니다.
    if (ToolMessage.isInstance(result)) {
      toolCache.set(key, result.text);
    }
    return result;
  },
});

/**
 * (2) 권한 검사
 *
 * handler 를 아예 안 부르고 ToolMessage 를 직접 만들어 돌려주면
 * 도구는 실행되지 않고, 모델은 "거부당했다"는 사실을 텍스트로 읽습니다.
 *
 * throw 하면 안 됩니다 — 에이전트 전체가 죽습니다(12-5 함정 참고).
 */
const ALLOWED_TOOLS = new Set(["get_weather", "get_population"]);

const permissionMiddleware = createMiddleware({
  name: "PermissionMiddleware",

  wrapToolCall: (request, handler) => {
    if (!ALLOWED_TOOLS.has(request.toolCall.name)) {
      console.log(`  [권한 거부] ${request.toolCall.name} — handler 를 부르지 않고 차단`);

      return new ToolMessage({
        content: `권한 없음: '${request.toolCall.name}' 도구는 이 사용자에게 허용되지 않았습니다. 다른 방법을 찾거나 사용자에게 알리세요.`,
        tool_call_id: request.toolCall.id!,
        name: request.toolCall.name,
        status: "error",
      });
    }
    return handler(request);
  },
});

/**
 * (3) 감사 로그
 *
 * 누가 어떤 도구를 언제 어떤 인자로 불렀고 몇 ms 걸렸는지 남깁니다.
 * 실무에선 console.log 대신 DB/Datadog 로 보냅니다.
 */
const auditMiddleware = createMiddleware({
  name: "AuditMiddleware",

  wrapToolCall: async (request, handler) => {
    const startedAt = Date.now();
    try {
      const result = await handler(request);
      console.log(
        `  [감사] ok    ${request.toolCall.name} ${JSON.stringify(request.toolCall.args)} (${Date.now() - startedAt}ms)`,
      );
      return result;
    } catch (error) {
      console.log(`  [감사] FAIL  ${request.toolCall.name} (${Date.now() - startedAt}ms) — ${String(error)}`);
      throw error; // 삼키지 말고 그대로 올립니다. 감사자는 관찰만 합니다.
    }
  },
});

async function wrapToolCallDemo(): Promise<void> {
  printSection("[12-5] wrapToolCall — 캐싱 / 권한 / 감사");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather, getPopulation, deleteRecord],
    systemPrompt: "너는 도시 정보 비서다. 사용자가 요청하면 주저하지 말고 도구를 써라.",
    // 순서: 감사(바깥) → 권한(중간) → 캐시(안쪽) → 실제 도구
    // 권한을 캐시보다 바깥에 둬야 "거부된 호출이 캐시에 저장되는 일"이 없습니다.
    middleware: [auditMiddleware, permissionMiddleware, cachingMiddleware],
  });

  console.log("\n─ 1회차: 서울 날씨 ─");
  await agent.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });

  console.log("\n─ 2회차: 같은 질문 (캐시 HIT 기대) ─");
  await agent.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });

  console.log("\n─ 3회차: 금지된 도구 요청 ─");
  const blocked = await agent.invoke({
    messages: [{ role: "user", content: "레코드 abc-123 을 delete_record 도구로 삭제해줘." }],
  });
  printMessages(blocked.messages.at(-1)!);
}

/* ===== [12-6] 상태 확장 — stateSchema ===== */

/**
 * 미들웨어는 자기만의 상태 필드를 에이전트 상태에 추가할 수 있습니다.
 *
 * 방법 1: zod object — 리듀서 없음. 나중에 쓴 값이 이깁니다(last-write-wins).
 * 방법 2: StateSchema + ReducedValue — 리듀서 있음. 누적이 됩니다.
 *
 * 카운터/누적기는 반드시 방법 2 를 쓰세요. 방법 1 로 누적을 하면
 * 병렬 도구 호출 같은 상황에서 조용히 값이 덮어써집니다.
 */

const CounterState = new StateSchema({
  // 모델을 몇 번 불렀나 — 더하기 리듀서로 누적
  modelCallCount: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  // 마지막으로 쓴 모델 이름 — 덮어쓰기 리듀서
  lastModelName: new ReducedValue(z.string().default(""), {
    reducer: (_current: string, next: string) => next,
  }),
  // _ 로 시작하면 private — agent.invoke() 결과에 안 실립니다.
  _internalNote: new ReducedValue(z.string().default(""), {
    reducer: (_current: string, next: string) => next,
  }),
});

const counterMiddleware = createMiddleware({
  name: "CounterMiddleware",
  stateSchema: CounterState,

  afterModel: (state) => {
    // ⚠️ state.modelCallCount++ 처럼 state 를 직접 건드리면 안 됩니다.
    //    반드시 "업데이트 객체"를 반환해야 리듀서가 돕니다.
    console.log(`  [카운터] 지금까지 모델 호출 ${state.modelCallCount}회`);

    // 리듀서가 더하기니까 "증가분 1" 을 반환합니다. "새 총합" 이 아닙니다.
    return {
      modelCallCount: 1,
      lastModelName: "claude-sonnet-4-6",
      _internalNote: "이 필드는 결과에 안 보입니다",
    };
  },
});

async function stateSchemaDemo(): Promise<void> {
  printSection("[12-6] 상태 확장 — stateSchema");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather, getPopulation],
    systemPrompt: "너는 도시 정보 비서다.",
    middleware: [counterMiddleware],
  });

  const result = await agent.invoke({
    messages: [{ role: "user", content: "서울과 제주의 날씨를 각각 알려줘." }],
  });

  printKV({
    "modelCallCount (public)": result.modelCallCount,
    "lastModelName (public)": result.lastModelName,
    "_internalNote (private)": (result as Record<string, unknown>)["_internalNote"] ?? "(결과에 없음 — 의도된 동작)",
  });
}

/* ===== [12-7] 흐름 제어 — jumpTo ===== */

/**
 * node 계열 훅(beforeAgent/beforeModel/afterModel/afterAgent)은
 * 반환 객체에 jumpTo 를 실어 루프의 다음 목적지를 바꿀 수 있습니다.
 *
 * jumpTo 로 갈 수 있는 곳은 딱 3개: "model" | "tools" | "end"
 *
 * ⚠️ jumpTo 를 쓰려면 훅을 { canJumpTo, hook } 형태로 선언해야 합니다.
 *    그냥 함수로 쓰고 jumpTo 만 반환하면 그래프에 엣지가 없어서 동작하지 않습니다.
 */

const BLOCKED_WORDS = ["비밀번호", "주민등록번호"];

const guardMiddleware = createMiddleware({
  name: "GuardMiddleware",

  beforeModel: {
    // 이 훅이 어디로 점프할 수 있는지 미리 선언 → 그래프에 엣지가 생깁니다.
    canJumpTo: ["end"],

    hook: (state) => {
      const lastHuman = [...state.messages].reverse().find((m) => m.getType() === "human");
      const text = lastHuman?.text ?? "";

      const hit = BLOCKED_WORDS.find((w) => text.includes(w));
      if (hit !== undefined) {
        console.log(`  [가드] 금칙어 '${hit}' 감지 → 모델을 부르지 않고 즉시 종료`);

        // 답변을 직접 만들어 넣고 end 로 점프합니다. 모델은 아예 안 돕니다.
        return {
          messages: [new AIMessage("죄송합니다. 해당 정보는 다룰 수 없습니다.")],
          jumpTo: "end" as const,
        };
      }
      return;
    },
  },
});

async function jumpToDemo(): Promise<void> {
  printSection("[12-7] 흐름 제어 — jumpTo");

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather],
    systemPrompt: "너는 비서다.",
    middleware: [guardMiddleware],
  });

  console.log("\n─ 정상 질문 (모델이 돎) ─");
  const ok = await agent.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });
  printMessages(ok.messages.at(-1)!);

  console.log("\n─ 금칙어 질문 (모델을 안 부르고 조기 종료) ─");
  const blocked = await agent.invoke({
    messages: [{ role: "user", content: "내 비밀번호가 뭐였지?" }],
  });
  printMessages(blocked.messages.at(-1)!);
}

/* ===== [12-8] 실전 — 비용 추적 + 토큰 예산 ===== */

/**
 * 여기까지 배운 걸 전부 씁니다.
 *
 * - stateSchema(ReducedValue)   : 토큰을 누적
 * - wrapModelCall               : usage_metadata 를 읽어 비용 계산, Command 로 상태 갱신
 * - beforeModel + jumpTo        : 예산 초과면 모델을 안 부르고 종료
 * - afterAgent                  : 최종 리포트
 */

// 100만 토큰당 USD. 값은 예시이므로 실제 단가는 provider 문서를 보세요.
const PRICE_PER_MTOK = {
  input: 3.0,
  output: 15.0,
} as const;

const BudgetState = new StateSchema({
  inputTokens: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  outputTokens: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  costUsd: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
  budgetExceeded: new ReducedValue(z.boolean().default(false), {
    reducer: (_current: boolean, next: boolean) => next,
  }),
});

const createCostTrackingMiddleware = (maxTotalTokens: number) =>
  createMiddleware({
    name: "CostTrackingMiddleware",
    stateSchema: BudgetState,

    // 1) 예산을 이미 넘겼으면 모델을 아예 부르지 않는다.
    beforeModel: {
      canJumpTo: ["end"],
      hook: (state) => {
        const used = state.inputTokens + state.outputTokens;

        if (used >= maxTotalTokens) {
          console.log(`  [예산] ${used} / ${maxTotalTokens} 토큰 — 초과. 모델 호출 중단.`);

          return {
            messages: [
              new AIMessage(
                `토큰 예산(${maxTotalTokens})을 소진해 작업을 중단했습니다. ` +
                  `지금까지 ${used} 토큰, 약 $${state.costUsd.toFixed(4)} 를 썼습니다.`,
              ),
            ],
            budgetExceeded: true,
            jumpTo: "end" as const,
          };
        }

        console.log(`  [예산] ${used} / ${maxTotalTokens} 토큰 사용 — 계속 진행`);
        return;
      },
    },

    // 2) 모델 호출을 감싸 실제 사용량을 읽는다.
    wrapModelCall: async (request, handler) => {
      const response = await handler(request);

      // usage_metadata 는 optional 입니다. 제공자가 안 주면 undefined 입니다.
      const usage = response.usage_metadata;
      if (usage === undefined) {
        console.log("  [비용] usage_metadata 없음 — 이번 호출은 집계 불가");
        return response;
      }

      const cost =
        (usage.input_tokens / 1_000_000) * PRICE_PER_MTOK.input +
        (usage.output_tokens / 1_000_000) * PRICE_PER_MTOK.output;

      console.log(
        `  [비용] in=${usage.input_tokens} out=${usage.output_tokens} → $${cost.toFixed(6)}`,
      );

      // wrapModelCall 에서 상태를 갱신하려면 Command 를 반환합니다.
      // handler() 를 이미 불렀으므로 프레임워크가 AI 메시지는 알아서 붙여 줍니다.
      // (handler 를 안 불렀다면 이 Command 는 답변 없는 빈 턴을 만듭니다.)
      return new Command({
        update: {
          inputTokens: usage.input_tokens,
          outputTokens: usage.output_tokens,
          costUsd: cost,
        },
      });
    },

    // 3) 끝날 때 리포트.
    afterAgent: (state) => {
      console.log(
        `\n  [최종] 입력 ${state.inputTokens} + 출력 ${state.outputTokens} = ` +
          `${state.inputTokens + state.outputTokens} 토큰 / $${state.costUsd.toFixed(6)}` +
          `${state.budgetExceeded ? "  ← 예산 초과로 중단됨" : ""}`,
      );
      return;
    },
  });

async function costTrackingDemo(): Promise<void> {
  printSection("[12-8] 실전 — 비용 추적 + 토큰 예산");

  // 일부러 아주 작게 잡아 중단이 실제로 걸리는 걸 봅니다.
  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather, getPopulation],
    systemPrompt: "너는 도시 정보 비서다. 도시마다 도구를 하나씩 따로 호출해라.",
    middleware: [createCostTrackingMiddleware(1500)],
  });

  const result = await agent.invoke({
    messages: [
      {
        role: "user",
        content: "서울, 부산, 제주의 날씨와 인구를 전부 조사해서 표로 정리해줘.",
      },
    ],
  });

  console.log("\n최종 답변:");
  printMessages(result.messages.at(-1)!);

  printKV({
    "입력 토큰": result.inputTokens,
    "출력 토큰": result.outputTokens,
    "비용(USD)": `$${result.costUsd.toFixed(6)}`,
    "예산 초과": result.budgetExceeded,
  });
}

/* ===== [12-9] 함정 확인 — 미들웨어에서 던진 예외 ===== */

/**
 * 미들웨어가 throw 하면 에이전트 전체가 죽습니다.
 * 다만 던져진 에러는 MiddlewareError 로 감싸져서 어느 미들웨어가 범인인지 알려줍니다.
 */
async function errorDemo(): Promise<void> {
  printSection("[12-9] 함정 — 미들웨어 예외는 에이전트를 죽인다");

  const throwingMiddleware = createMiddleware({
    name: "ThrowingMiddleware",
    beforeModel: () => {
      throw new Error("여기서 던지면 어떻게 될까요?");
    },
  });

  const agent = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [],
    middleware: [throwingMiddleware],
  });

  try {
    await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });
    console.log("여기는 실행되지 않습니다.");
  } catch (error) {
    console.log(`MiddlewareError.isInstance(error) = ${MiddlewareError.isInstance(error)}`);
    console.log(`error.message                     = ${(error as Error).message}`);
    console.log(`error.cause                       = ${String((error as Error).cause)}`);
    console.log("\n→ invoke() 가 통째로 실패했습니다. 부분 결과도, 답변도 없습니다.");
    console.log("  미들웨어에서 '거부'를 표현하고 싶다면 throw 가 아니라");
    console.log("  ToolMessage 를 돌려주거나(12-5) jumpTo:'end' 를 쓰세요(12-7).");
  }
}

/* ===== 실행 ===== */

async function main(): Promise<void> {
  await hookOrder(); // [12-1]
  await firstMiddleware(); // [12-2]
  await beforeModelDemo(); // [12-3]
  await dynamicPromptDemo(); // [12-4-1]
  await dynamicModelDemo(); // [12-4-2]
  await fallbackDemo(); // [12-4-3]
  await wrapToolCallDemo(); // [12-5]
  await stateSchemaDemo(); // [12-6]
  await jumpToDemo(); // [12-7]
  await costTrackingDemo(); // [12-8]
  await errorDemo(); // [12-9]
}

main().catch((error: unknown) => {
  console.error(error);
  process.exit(1);
});

exercise.ts

본문 "연습문제" 8개를 그대로 옮겨 담은 빈칸 채우기용 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되어 있고, 그 아래 createMiddleware({ name }) 만 남아 있으니 훅을 채워 넣으면 됩니다.

  • main() 안의 검증 코드가 전부 주석 처리되어 있습니다. 문제를 하나 풀 때마다 해당 블록의 주석을 풀어 확인하세요. 그냥 실행하면 섹션 제목만 8개 찍히고 끝납니다 — 정상입니다.
  • slowSearch 도구는 일부러 setTimeout 으로 3초를 채웁니다. 문제 3 에서 timeoutMs=1000 을 주면 반드시 타임아웃되도록 만들어 둔 것이라, 네트워크 상황과 무관하게 재현됩니다.
  • sendEmail 도구는 문제 6 의 검열 대상입니다. body 에 카드번호가 섞여 들어오도록 프롬프트를 짜 두었습니다.
  • 문제 5 의 힌트에 ⚠️ 이 문제의 진짜 함정 이라고 적어 둔 대목이 이 파일에서 가장 중요합니다. 답을 보기 전에 await 를 뺀 버전과 넣은 버전을 둘 다 돌려서 로그가 몇 줄 찍히는지 세어 보세요.
  • 문제 8 의 "모듈 변수 vs state" 는 코드를 짜는 문제가 아니라 설계를 고르는 문제입니다. 양쪽으로 다 만들어 보고 thread_id 를 바꿔 가며 돌리면 차이가 드러납니다.
/**
 * Step 12 — 커스텀 미들웨어 · 연습문제
 * 실행: npx tsx docs/reference/langchain/step-12-middleware-custom/exercise.ts
 *
 * 각 [문제 N] 블록 아래 빈 곳을 채우세요.
 * 채우기 전에는 타입 에러가 나거나 미들웨어가 아무 일도 하지 않는 게 정상입니다.
 *
 * 정답은 solution.ts 에 있습니다. 먼저 스스로 풀어 보세요.
 */
import "dotenv/config";

import {
  AIMessage,
  ToolMessage,
  createAgent,
  createMiddleware,
  initChatModel,
  tool,
} from "langchain";
import { Command, ReducedValue, StateSchema } from "@langchain/langgraph";
import * as z from "zod";

import { printSection, printMessages, printKV, requireEnv } from "../project/src/lib/print.js";

requireEnv("ANTHROPIC_API_KEY");

/* ===== 공용 도구 ===== */

const getWeather = tool(
  ({ city }) => {
    const table: Record<string, string> = { 서울: "맑음, 24도", 부산: "흐림, 21도", 제주: "비, 19도" };
    return table[city] ?? `${city} 날씨 정보 없음`;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string() }),
  },
);

/** 일부러 느린 도구 — 문제 3 의 타임아웃 대상 */
const slowSearch = tool(
  async ({ query }) => {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return `'${query}' 검색 결과: (3초 걸렸습니다)`;
  },
  {
    name: "slow_search",
    description: "웹을 검색합니다. 느립니다.",
    schema: z.object({ query: z.string() }),
  },
);

/** 문제 6 의 검열 대상 — 인자에 민감정보가 섞여 들어옵니다 */
const sendEmail = tool(
  ({ to, body }) => `${to} 에게 발송 완료: ${body}`,
  {
    name: "send_email",
    description: "이메일을 보냅니다.",
    schema: z.object({ to: z.string(), body: z.string() }),
  },
);

/* ===== [문제 1] beforeModel + jumpTo 로 메시지 개수 제한 =====
 *
 * 대화 메시지가 maxMessages 개 이상이면 모델을 부르지 않고 즉시 종료하는
 * 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - beforeModel 훅을 쓸 것
 *   - 한계에 도달하면 AIMessage("대화가 너무 길어졌습니다.") 를 넣고 종료
 *   - 한계 미만이면 아무것도 안 하고 통과
 *
 * 힌트: jumpTo 를 쓰려면 훅을 { canJumpTo, hook } 형태로 선언해야 합니다.
 *       그냥 함수로 쓰고 jumpTo 만 반환하면 조용히 무시됩니다.
 */

const createMessageLimitMiddleware = (maxMessages: number) =>
  createMiddleware({
    name: "MessageLimitMiddleware",
    // 여기에 beforeModel 훅을 작성하세요.
  });

/* ===== [문제 2] wrapModelCall 로 지연시간 측정 =====
 *
 * 모델 호출이 몇 ms 걸렸는지 재서 콘솔에 찍는 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - handler() 호출 전후로 Date.now() 를 재서 차이를 출력
 *   - 모델이 에러를 던져도 걸린 시간은 찍혀야 함 (실패도 시간이 든다)
 *   - 응답 자체는 그대로 반환 (관찰만 하고 바꾸지 않는다)
 *
 * 힌트: try/finally. 그리고 handler 앞의 await 를 빠뜨리지 마세요.
 */

const latencyMiddleware = createMiddleware({
  name: "LatencyMiddleware",
  // 여기에 wrapModelCall 훅을 작성하세요.
});

/* ===== [문제 3] wrapToolCall 로 도구 타임아웃 =====
 *
 * 도구가 timeoutMs 안에 안 끝나면 포기하고 에러 ToolMessage 를 돌려주는
 * 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - Promise.race 로 handler(request) 와 타이머를 경쟁시킬 것
 *   - 타임아웃이면 throw 하지 말고 ToolMessage 를 반환할 것
 *     (throw 하면 에이전트 전체가 죽습니다 — 모델이 스스로 복구할 기회를 주세요)
 *   - ToolMessage 의 tool_call_id 는 request.toolCall.id 를 쓸 것
 *   - status: "error" 를 붙일 것
 *
 * slowSearch 는 3초 걸리므로 timeoutMs=1000 이면 반드시 타임아웃됩니다.
 */

const createToolTimeoutMiddleware = (timeoutMs: number) =>
  createMiddleware({
    name: "ToolTimeoutMiddleware",
    // 여기에 wrapToolCall 훅을 작성하세요.
  });

/* ===== [문제 4] stateSchema 로 도구별 호출 횟수 집계 =====
 *
 * 어떤 도구가 몇 번 불렸는지를 상태에 누적하는 미들웨어를 만드세요.
 * 최종 결과는 { get_weather: 2, slow_search: 1 } 같은 객체여야 합니다.
 *
 * 요구사항:
 *   - StateSchema + ReducedValue 를 쓸 것
 *   - 리듀서는 두 객체를 "합쳐야" 합니다 (덮어쓰기 아님)
 *     예: {a:1} 에 {a:1, b:1} 이 오면 → {a:2, b:1}
 *   - wrapToolCall 에서 Command 로 업데이트를 반환할 것
 *
 * 힌트: 리듀서 안에서 state 를 직접 수정하지 말고 새 객체를 만들어 반환하세요.
 */

const ToolStatsState = new StateSchema({
  // 여기에 toolCallCounts 필드를 작성하세요.
});

const toolStatsMiddleware = createMiddleware({
  name: "ToolStatsMiddleware",
  stateSchema: ToolStatsState,
  // 여기에 wrapToolCall 훅을 작성하세요.
});

/* ===== [문제 5] wrapModelCall 재시도 미들웨어 =====
 *
 * 모델 호출이 실패하면 최대 maxRetries 번까지 다시 시도하는 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - 재시도 사이에 지수 백오프(exponential backoff)를 넣을 것
 *     (1차 100ms, 2차 200ms, 3차 400ms ...)
 *   - 마지막 시도까지 실패하면 마지막 에러를 그대로 throw
 *   - 시도 횟수를 콘솔에 찍을 것
 *
 * ⚠️ 이 문제의 진짜 함정: try 블록 안에서 `return handler(request)` 라고 쓰면
 *    재시도가 절대 동작하지 않습니다. 왜인지 설명할 수 있어야 합니다.
 *    (답은 solution.ts 주석에)
 */

const createRetryMiddleware = (maxRetries: number) =>
  createMiddleware({
    name: "RetryMiddleware",
    // 여기에 wrapModelCall 훅을 작성하세요.
  });

/* ===== [문제 6] wrapToolCall 로 인자 검열 =====
 *
 * send_email 도구의 body 인자에서 신용카드 번호처럼 보이는 문자열을
 * "[REDACTED]" 로 바꾼 뒤 도구를 실행하는 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - 정규식 /\d{4}-\d{4}-\d{4}-\d{4}/g 로 찾을 것
 *   - request.toolCall.args 를 직접 수정(mutate)하지 말 것 — 새 객체를 만들 것
 *   - 검열한 인자로 handler 를 호출할 것
 *   - 검열이 일어났으면 콘솔에 알릴 것
 *
 * 힌트: handler({ ...request, toolCall: { ...request.toolCall, args: 새인자 } })
 */

const redactMiddleware = createMiddleware({
  name: "RedactMiddleware",
  // 여기에 wrapToolCall 훅을 작성하세요.
});

/* ===== [문제 7] afterModel + jumpTo "model" 로 되돌리기 =====
 *
 * 모델이 도구를 하나도 안 부르고 답변이 20자 미만이면
 * "너무 짧다, 다시 답해라" 라고 시키고 모델로 되돌아가는 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - afterModel 훅 + canJumpTo: ["model"]
 *   - 되돌릴 때 SystemMessage 나 HumanMessage 로 지적을 넣을 것
 *   - ⚠️ 무한 루프 방지: 되돌리기는 최대 1번만. 상태로 세세요.
 *     (안 그러면 모델이 계속 짧게 답할 때 recursionLimit 까지 돌다 죽습니다)
 *
 * 힌트: stateSchema 에 retryCount 를 두고, 이미 1 이면 되돌리지 마세요.
 */

const RetryState = new StateSchema({
  // 여기에 retryCount 필드를 작성하세요.
});

const expandAnswerMiddleware = createMiddleware({
  name: "ExpandAnswerMiddleware",
  stateSchema: RetryState,
  // 여기에 afterModel 훅을 작성하세요.
});

/* ===== [문제 8] 종합 — 속도 제한(rate limit) 미들웨어 =====
 *
 * 모델 호출이 초당 maxPerSecond 회를 넘지 않도록 강제하는 미들웨어를 만드세요.
 *
 * 요구사항:
 *   - wrapModelCall 에서 마지막 호출 시각을 기억하고,
 *     간격이 부족하면 sleep 한 뒤 handler 를 부를 것
 *   - 얼마나 기다렸는지 콘솔에 찍을 것
 *   - stateSchema 로 총 대기 시간(ms)을 누적할 것
 *   - handler 를 부른 뒤 Command 로 대기 시간을 상태에 반영할 것
 *
 * 생각해 볼 것: 이 미들웨어의 "마지막 호출 시각"을 모듈 스코프 변수에 둘까요,
 * 아니면 state 에 둘까요? 둘의 차이는 무엇일까요? (solution.ts 참고)
 */

const RateLimitState = new StateSchema({
  // 여기에 totalWaitMs 필드를 작성하세요.
});

const createRateLimitMiddleware = (maxPerSecond: number) =>
  createMiddleware({
    name: "RateLimitMiddleware",
    stateSchema: RateLimitState,
    // 여기에 wrapModelCall 훅을 작성하세요.
  });

/* ===== 실행 =====
 *
 * 문제를 풀면서 아래 주석을 하나씩 풀어 확인하세요.
 */

async function main(): Promise<void> {
  printSection("[문제 1] 메시지 개수 제한");
  // const agent1 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [getWeather],
  //   middleware: [createMessageLimitMiddleware(2)],
  // });
  // const r1 = await agent1.invoke({
  //   messages: [
  //     { role: "user", content: "안녕" },
  //     { role: "assistant", content: "안녕하세요" },
  //     { role: "user", content: "서울 날씨는?" },
  //   ],
  // });
  // printMessages(r1.messages.at(-1)!);

  printSection("[문제 2] 지연시간 측정");
  // const agent2 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [],
  //   middleware: [latencyMiddleware],
  // });
  // await agent2.invoke({ messages: [{ role: "user", content: "안녕이라고만 답해줘." }] });

  printSection("[문제 3] 도구 타임아웃");
  // const agent3 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [slowSearch],
  //   systemPrompt: "너는 검색 비서다. 질문을 받으면 slow_search 를 써라.",
  //   middleware: [createToolTimeoutMiddleware(1000)],
  // });
  // const r3 = await agent3.invoke({
  //   messages: [{ role: "user", content: "타입스크립트 뉴스 검색해줘." }],
  // });
  // printMessages(r3.messages);

  printSection("[문제 4] 도구별 호출 횟수");
  // const agent4 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [getWeather],
  //   systemPrompt: "너는 날씨 비서다. 도시마다 도구를 따로 호출해라.",
  //   middleware: [toolStatsMiddleware],
  // });
  // const r4 = await agent4.invoke({
  //   messages: [{ role: "user", content: "서울, 부산, 제주 날씨 알려줘." }],
  // });
  // printKV({ toolCallCounts: JSON.stringify(r4.toolCallCounts) });

  printSection("[문제 5] 재시도");
  // const broken = await initChatModel("anthropic:claude-없는-모델");
  // const agent5 = createAgent({
  //   model: broken,
  //   tools: [],
  //   middleware: [createRetryMiddleware(3)],
  // });
  // try {
  //   await agent5.invoke({ messages: [{ role: "user", content: "안녕" }] });
  // } catch (error) {
  //   console.log(`3번 다 실패했습니다: ${(error as Error).message.slice(0, 60)}`);
  // }

  printSection("[문제 6] 인자 검열");
  // const agent6 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [sendEmail],
  //   systemPrompt: "너는 이메일 비서다. 사용자가 준 문장을 그대로 body 에 넣어 보내라.",
  //   middleware: [redactMiddleware],
  // });
  // const r6 = await agent6.invoke({
  //   messages: [
  //     { role: "user", content: "a@b.com 으로 '내 카드는 1234-5678-9012-3456 입니다' 를 보내줘." },
  //   ],
  // });
  // printMessages(r6.messages);

  printSection("[문제 7] 짧은 답변 되돌리기");
  // const agent7 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [],
  //   systemPrompt: "너는 비서다.",
  //   middleware: [expandAnswerMiddleware],
  // });
  // const r7 = await agent7.invoke({ messages: [{ role: "user", content: "'네' 라고만 답해줘." }] });
  // printMessages(r7.messages);
  // printKV({ retryCount: r7.retryCount });

  printSection("[문제 8] 속도 제한");
  // const agent8 = createAgent({
  //   model: "anthropic:claude-sonnet-4-6",
  //   tools: [getWeather],
  //   systemPrompt: "너는 날씨 비서다. 도시마다 도구를 따로 호출해라.",
  //   middleware: [createRateLimitMiddleware(1)],
  // });
  // const r8 = await agent8.invoke({
  //   messages: [{ role: "user", content: "서울, 부산, 제주 날씨를 순서대로 알려줘." }],
  // });
  // printKV({ "총 대기(ms)": r8.totalWaitMs });
}

main().catch((error: unknown) => {
  console.error(error);
  process.exit(1);
});

solution.ts

8문제의 정답과 해설 주석을 담은 파일입니다. exercise.ts 를 스스로 풀어본 뒤에 열어 보세요. 각 정답 위 주석에 "왜 이렇게 짜야 하는가"와 "이렇게 짜면 조용히 틀린다"가 함께 적혀 있고, main() 의 각 블록 위에는 기대 결과가 달려 있어 채점표로 바로 쓸 수 있습니다.

  • [정답 5] 가 이 파일의 하이라이트입니다. return await handler(request) 에서 await 하나가 미들웨어의 전부입니다. 이걸 빼면 [재시도] 시도 1/3 만 찍히고 끝납니다 — 2/3, 3/3 이 안 찍히는 게 유일한 단서인데, 에러 메시지는 정상이라 알아채기가 매우 어렵습니다. 공식 문서의 재시도 예제조차 이 모양이니 남 얘기가 아닙니다.
  • [정답 3]Promise.race판별 유니온({ kind: "done", value } / { kind: "timeout" })으로 감쌉니다. Symbol 센티널로 race 하면 TS 가 타입을 좁혀 주지 못해 결국 as 캐스팅을 쓰게 되기 때문입니다. 그리고 Promise.resolve(handler(request)) 로 감싼 이유도 주석에 있습니다 — handler 의 반환 타입은 PromiseOrValue<...> 라서 Promise 가 아닐 수도 있습니다. handler(request).then(...) 이라고 쓰면 tscProperty 'then' does not exist 로 잡아 줍니다. 덤으로: Promise.race 는 진 쪽을 취소하지 않습니다. 도구는 백그라운드에서 3초를 마저 채웁니다.
  • [정답 4] 의 리듀서는 { ...current } 로 새 객체를 만든 뒤 병합합니다. 덮어쓰기 리듀서((_a, b) => b)로 바꿔서 돌려 보면 도시 3개를 조회했는데 카운트가 1 로 남는 걸 볼 수 있습니다 — 모델이 병렬로 부를 때만 재현되므로 운이 나쁘면 몇 번 돌려야 합니다. 그리고 wrapToolCall 에서 Command 를 반환할 때는 messages: [result] 를 직접 실어 줘야 합니다. wrapModelCall 과 달리 프레임워크가 대신 붙여 주지 않습니다.
  • [정답 6]CARD_PATTERN.lastIndex = 0 은 있어야 합니다. /g 플래그가 붙은 정규식은 test()lastIndex 를 남기므로, 리셋하지 않으면 같은 문자열인데도 다음 호출에서 false 가 나옵니다. 미들웨어와 무관한 순수 JS 함정이지만 검열이 조용히 새는 원인이 되므로 넣어 두었습니다.
  • [정답 8] 의 결론은 "마지막 호출 시각은 모듈 변수, 총 대기 시간은 state" 입니다. rate limit 은 프로세스가 provider 를 때리는 속도에 대한 제약이라 대화 단위가 아니고, state 에 두면 thread_id 마다 리셋되어 사용자 100명이 동시에 100번 때립니다 — 429 를 막으려고 만든 미들웨어가 429 를 못 막습니다. 반대로 총 대기 시간은 "이 대화가 얼마나 기다렸나"라 state 가 맞습니다. 주석에 적었듯 모듈 변수 방식도 서버 인스턴스가 여러 대면 안 통합니다 — 진짜 프로덕션에는 Redis 가 필요합니다.
/**
 * Step 12 — 커스텀 미들웨어 · 정답과 해설
 * 실행: npx tsx docs/reference/langchain/step-12-middleware-custom/solution.ts
 *
 * exercise.ts 를 스스로 풀어 본 뒤에 열어 보세요.
 * 각 정답 위 주석에 "왜 이렇게 짜야 하는가"와 "이렇게 짜면 조용히 틀린다"를 적어 두었습니다.
 */
import "dotenv/config";

import {
  AIMessage,
  SystemMessage,
  ToolMessage,
  createAgent,
  createMiddleware,
  initChatModel,
  tool,
} from "langchain";
import { Command, ReducedValue, StateSchema } from "@langchain/langgraph";
import * as z from "zod";

import { printSection, printMessages, printKV, requireEnv } from "../project/src/lib/print.js";

requireEnv("ANTHROPIC_API_KEY");

/* ===== 공용 도구 ===== */

const getWeather = tool(
  ({ city }) => {
    const table: Record<string, string> = { 서울: "맑음, 24도", 부산: "흐림, 21도", 제주: "비, 19도" };
    return table[city] ?? `${city} 날씨 정보 없음`;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string() }),
  },
);

const slowSearch = tool(
  async ({ query }) => {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return `'${query}' 검색 결과: (3초 걸렸습니다)`;
  },
  {
    name: "slow_search",
    description: "웹을 검색합니다. 느립니다.",
    schema: z.object({ query: z.string() }),
  },
);

const sendEmail = tool(({ to, body }) => `${to} 에게 발송 완료: ${body}`, {
  name: "send_email",
  description: "이메일을 보냅니다.",
  schema: z.object({ to: z.string(), body: z.string() }),
});

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/* ===== [정답 1] beforeModel + jumpTo 로 메시지 개수 제한 =====
 *
 * 핵심은 `{ canJumpTo, hook }` 형태입니다.
 *
 * 이렇게 쓰면 조용히 틀립니다:
 *
 *   beforeModel: (state) => {
 *     if (...) return { messages: [...], jumpTo: "end" };   // ← 안 먹습니다
 *   }
 *
 * 타입 에러도 안 나고 런타임 에러도 안 납니다. 그냥 jumpTo 가 무시되고
 * 모델이 평소대로 돕니다. canJumpTo 는 "그래프에 이 엣지를 깔아 달라"는
 * 선언이고, 선언이 없으면 갈 길이 없어서 점프가 성립하지 않습니다.
 *
 * 또 하나: `jumpTo: "end"` 를 그냥 쓰면 TS 가 string 으로 넓혀 버립니다.
 * `as const` 를 붙이거나 반환 타입을 명시해야 JumpToTarget 에 맞습니다.
 */
const createMessageLimitMiddleware = (maxMessages: number) =>
  createMiddleware({
    name: "MessageLimitMiddleware",
    beforeModel: {
      canJumpTo: ["end"],
      hook: (state) => {
        if (state.messages.length >= maxMessages) {
          console.log(`  [제한] 메시지 ${state.messages.length}개 ≥ ${maxMessages} → 종료`);
          return {
            messages: [new AIMessage("대화가 너무 길어졌습니다.")],
            jumpTo: "end" as const,
          };
        }
        return;
      },
    },
  });

/* ===== [정답 2] wrapModelCall 로 지연시간 측정 =====
 *
 * try/finally 를 쓰는 이유: 모델이 던져도 finally 는 돕니다.
 * "실패한 호출이 얼마나 걸렸나"는 타임아웃을 튜닝할 때 가장 중요한 숫자인데,
 * try 뒤에만 측정하면 실패 케이스가 통계에서 통째로 빠집니다.
 *
 * 그리고 `const response = await handler(request)` 의 await 가 핵심입니다.
 * await 없이 `const response = handler(request)` 로 쓰면 Promise 를 받는 즉시
 * finally 가 돌아서 "0ms" 가 찍힙니다 — 조용히, 매번, 틀린 값으로.
 */
const latencyMiddleware = createMiddleware({
  name: "LatencyMiddleware",
  wrapModelCall: async (request, handler) => {
    const startedAt = Date.now();
    try {
      return await handler(request);
    } finally {
      console.log(`  [지연] 모델 호출 ${Date.now() - startedAt}ms`);
    }
  },
});

/* ===== [정답 3] wrapToolCall 로 도구 타임아웃 =====
 *
 * throw 대신 ToolMessage 를 돌려주는 게 요점입니다.
 *
 * throw 하면: 에이전트 전체가 죽고 사용자는 스택트레이스를 봅니다.
 * ToolMessage 를 돌려주면: 모델이 "아 이 도구는 느려서 실패했구나" 를 읽고
 * 다른 도구를 쓰거나 사용자에게 사정을 설명합니다. 에이전트가 살아 있습니다.
 *
 * status: "error" 는 provider 에게도 "이건 실패한 결과다" 를 알려 줍니다.
 *
 * ⚠️ Promise.race 는 진 쪽을 취소하지 않습니다. 도구는 백그라운드에서
 *    계속 3초를 채우고 끝납니다. 진짜로 중단시키려면 도구가 AbortSignal 을
 *    받아야 하고, 그 시그널은 request.runtime.signal 로 옵니다.
 */
const createToolTimeoutMiddleware = (timeoutMs: number) =>
  createMiddleware({
    name: "ToolTimeoutMiddleware",
    wrapToolCall: async (request, handler) => {
      // 판별 유니온(discriminated union)으로 감싸서 경쟁시킵니다.
      // Symbol 같은 센티널로 race 하면 TS 가 타입을 좁혀 주지 못해
      // 결국 as 캐스팅을 쓰게 됩니다. kind 태그를 붙이면 캐스팅 없이 좁혀집니다.
      //
      // ⚠️ Promise.resolve(...) 로 감싼 이유: handler 의 반환 타입은
      //    PromiseOrValue<ToolMessage | Command> 입니다 — Promise 가 아니라
      //    값을 그대로 돌려줄 수도 있습니다(동기 도구). handler(request).then(...)
      //    이라고 쓰면 tsc 가 "Property 'then' does not exist" 로 잡아 줍니다.
      const raced = await Promise.race([
        Promise.resolve(handler(request)).then((value) => ({ kind: "done" as const, value })),
        sleep(timeoutMs).then(() => ({ kind: "timeout" as const })),
      ]);

      if (raced.kind === "timeout") {
        console.log(`  [타임아웃] ${request.toolCall.name}${timeoutMs}ms 를 넘겼습니다`);
        return new ToolMessage({
          content: `도구 '${request.toolCall.name}' 이 ${timeoutMs}ms 안에 응답하지 않아 중단했습니다. 다른 방법을 시도하거나 사용자에게 알리세요.`,
          tool_call_id: request.toolCall.id!,
          name: request.toolCall.name,
          status: "error",
        });
      }

      return raced.value;
    },
  });

/* ===== [정답 4] stateSchema 로 도구별 호출 횟수 집계 =====
 *
 * 리듀서가 "합치기"여야 하는 이유:
 *
 * 모델이 도구를 3개 병렬로 부르면 wrapToolCall 이 3번, 거의 동시에 돕니다.
 * 각각 { get_weather: 1 } 을 반환하죠. 리듀서가 덮어쓰기(`(_a, b) => b`)면
 * 최종 결과는 { get_weather: 1 } — 3번 불렀는데 1로 남습니다.
 * 에러도 경고도 없습니다. 그냥 숫자가 틀립니다.
 *
 * 리듀서 안에서 current 를 직접 수정하지 않는 것도 중요합니다.
 * LangGraph 는 상태를 스냅샷으로 관리하는데, 스냅샷을 mutate 하면
 * 체크포인터에 저장된 과거 상태까지 바뀌어 타임트래블이 깨집니다.
 */
type ToolCounts = Record<string, number>;

const ToolStatsState = new StateSchema({
  toolCallCounts: new ReducedValue(z.record(z.string(), z.number()).default({}), {
    reducer: (current: ToolCounts, next: ToolCounts): ToolCounts => {
      // 새 객체를 만듭니다 — current 를 건드리지 않습니다.
      const merged: ToolCounts = { ...current };
      for (const [name, count] of Object.entries(next)) {
        merged[name] = (merged[name] ?? 0) + count;
      }
      return merged;
    },
  }),
});

const toolStatsMiddleware = createMiddleware({
  name: "ToolStatsMiddleware",
  stateSchema: ToolStatsState,
  wrapToolCall: async (request, handler) => {
    const result = await handler(request);

    // wrapToolCall 에서 상태를 갱신하려면 Command 를 반환합니다.
    // handler 의 결과(ToolMessage)를 messages 에 직접 실어 줘야 합니다 —
    // wrapModelCall 과 달리 wrapToolCall 은 프레임워크가 대신 붙여 주지 않습니다.
    if (ToolMessage.isInstance(result)) {
      return new Command({
        update: {
          messages: [result],
          toolCallCounts: { [request.toolCall.name]: 1 },
        },
      });
    }

    return result;
  },
});

/* ===== [정답 5] wrapModelCall 재시도 미들웨어 =====
 *
 * ⚠️ 이 문제의 함정 (공식 문서 예제조차 이 모양입니다):
 *
 *   for (...) {
 *     try {
 *       return handler(request);      // ← await 없음
 *     } catch (e) { ... }
 *   }
 *
 * handler 는 async 함수라 Promise 를 돌려줍니다. `return handler(request)` 는
 * Promise 를 만들자마자 반환하고 함수를 빠져나갑니다. 모델 호출이 나중에
 * 실패해도 그 rejection 은 이미 떠난 try/catch 를 잡을 수 없습니다.
 * 결과: 재시도 로직이 통째로 죽은 코드가 되고, 첫 실패가 그대로 위로 올라갑니다.
 * 에러 메시지는 정상이라 "재시도를 넣었는데 왜 한 번만 시도하지?" 로 몇 시간을 씁니다.
 *
 * `return await handler(request)` — await 하나가 이 미들웨어의 전부입니다.
 *
 * 참고: 실무에서 재시도는 직접 짜지 말고 내장 modelRetryMiddleware 를 쓰세요(Step 11).
 * 여기서는 wrapModelCall 이 handler 를 여러 번 부를 수 있다는 걸 보이려고 짭니다.
 */
const createRetryMiddleware = (maxRetries: number) =>
  createMiddleware({
    name: "RetryMiddleware",
    wrapModelCall: async (request, handler) => {
      let lastError: unknown;

      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          console.log(`  [재시도] 시도 ${attempt}/${maxRetries}`);
          return await handler(request); // ← await 가 핵심
        } catch (error) {
          lastError = error;

          if (attempt === maxRetries) break;

          const backoff = 100 * 2 ** (attempt - 1); // 100, 200, 400...
          console.log(`  [재시도] 실패 → ${backoff}ms 후 재시도`);
          await sleep(backoff);
        }
      }

      throw lastError;
    },
  });

/* ===== [정답 6] wrapToolCall 로 인자 검열 =====
 *
 * mutate 금지가 요점입니다.
 *
 *   request.toolCall.args["body"] = redacted;   // ← 이렇게 하지 마세요
 *
 * 이건 AIMessage 안에 들어 있는 tool_calls 배열의 객체를 직접 고치는 것입니다.
 * 그 AIMessage 는 이미 state.messages 에 들어가 체크포인터에 저장돼 있습니다.
 * 즉 "모델이 실제로 뭘 요청했는가"라는 기록이 사후에 조작됩니다.
 * 감사 로그가 거짓말을 하게 되고, 디버깅할 때 "모델이 카드번호를 안 보냈는데
 * 왜 검열 로그가 찍히지?" 라는 미궁에 빠집니다.
 *
 * 새 객체를 만들어 handler 에 넘기면 원본 기록은 그대로 남고
 * 도구에만 검열된 값이 갑니다.
 */
const CARD_PATTERN = /\d{4}-\d{4}-\d{4}-\d{4}/g;

const redactMiddleware = createMiddleware({
  name: "RedactMiddleware",
  wrapToolCall: (request, handler) => {
    const args = request.toolCall.args;
    const body = args["body"];

    if (typeof body !== "string" || !CARD_PATTERN.test(body)) {
      return handler(request);
    }

    // 정규식에 /g 가 붙어 있으면 test() 가 lastIndex 를 남깁니다.
    // 리셋하지 않으면 다음 호출에서 같은 문자열인데도 false 가 나옵니다.
    CARD_PATTERN.lastIndex = 0;

    const redacted = body.replace(CARD_PATTERN, "[REDACTED]");
    console.log(`  [검열] 카드번호 패턴 발견 → 마스킹`);

    return handler({
      ...request,
      toolCall: {
        ...request.toolCall,
        args: { ...args, body: redacted },
      },
    });
  },
});

/* ===== [정답 7] afterModel + jumpTo "model" 로 되돌리기 =====
 *
 * 무한 루프 방지가 이 문제의 전부입니다.
 *
 * retryCount 가드를 빼면: 모델이 계속 짧게 답할 때마다 model 로 되돌아가고,
 * 되돌아간 모델이 또 짧게 답하고... recursionLimit(기본 25)에 닿아
 * GraphRecursionError 로 죽습니다. 그것도 25번치 토큰을 다 쓴 뒤에요.
 *
 * "미들웨어가 루프를 만들 수 있다면, 그 루프를 끊는 조건도 미들웨어가 책임진다"
 * 가 원칙입니다.
 */
const RetryState = new StateSchema({
  retryCount: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
});

const expandAnswerMiddleware = createMiddleware({
  name: "ExpandAnswerMiddleware",
  stateSchema: RetryState,
  afterModel: {
    canJumpTo: ["model"],
    hook: (state) => {
      const last = state.messages.at(-1);
      const toolCalls = (last as AIMessage | undefined)?.tool_calls ?? [];

      // 도구를 부르는 중이면 아직 최종 답변이 아닙니다 — 건드리지 않습니다.
      if (toolCalls.length > 0) return;

      const text = last?.text ?? "";
      if (text.length >= 20) return;

      // 이미 한 번 되돌렸으면 포기합니다. 이게 루프 차단기입니다.
      if (state.retryCount >= 1) {
        console.log(`  [확장] 이미 1번 되돌렸음 → 짧은 답변 그대로 수용`);
        return;
      }

      console.log(`  [확장] 답변이 ${text.length}자뿐 → 모델로 되돌림`);
      return {
        messages: [new SystemMessage("답변이 너무 짧습니다. 두 문장 이상으로 다시 답하세요.")],
        retryCount: 1,
        jumpTo: "model" as const,
      };
    },
  },
});

/* ===== [정답 8] 종합 — 속도 제한(rate limit) 미들웨어 =====
 *
 * "마지막 호출 시각을 모듈 변수에 둘까 state 에 둘까?" 의 답:
 *
 * 모듈 변수(클로저)에 둡니다. 이유:
 *
 * - rate limit 은 "이 프로세스가 provider 를 때리는 속도"에 대한 제약입니다.
 *   대화(thread) 단위가 아니라 프로세스 단위의 관심사입니다.
 * - state 에 두면 thread_id 마다 리셋됩니다. 사용자 100명이 각자 스레드를
 *   열면 각 스레드가 "나는 방금 처음 호출했다"고 믿고 동시에 100번 때립니다.
 *   429 를 막으려고 만든 미들웨어가 429 를 막지 못합니다.
 *
 * 반대로 totalWaitMs(관측값)는 "이 대화가 얼마나 기다렸나"라서 state 가 맞습니다.
 * 같은 미들웨어 안에서도 "제어에 쓰는 값"과 "기록에 남길 값"의 자리가 다릅니다.
 *
 * ⚠️ 모듈 변수 방식은 프로세스가 여러 개면(서버 인스턴스 N대) 안 통합니다.
 *    진짜 프로덕션에서는 Redis 같은 공유 저장소가 필요합니다.
 */
const RateLimitState = new StateSchema({
  totalWaitMs: new ReducedValue(z.number().default(0), {
    reducer: (current: number, next: number) => current + next,
  }),
});

const createRateLimitMiddleware = (maxPerSecond: number) => {
  const minIntervalMs = 1000 / maxPerSecond;
  let lastCallAt = 0; // ← 프로세스 전역. state 가 아닙니다.

  return createMiddleware({
    name: "RateLimitMiddleware",
    stateSchema: RateLimitState,
    wrapModelCall: async (request, handler) => {
      const elapsed = Date.now() - lastCallAt;
      const waitMs = Math.max(0, minIntervalMs - elapsed);

      if (waitMs > 0) {
        console.log(`  [속도제한] ${waitMs.toFixed(0)}ms 대기`);
        await sleep(waitMs);
      }
      lastCallAt = Date.now();

      await handler(request);

      // handler 를 이미 불렀으므로 AI 메시지는 프레임워크가 붙여 줍니다.
      // 여기서는 상태 업데이트만 실어 보냅니다.
      return new Command({ update: { totalWaitMs: waitMs } });
    },
  });
};

/* ===== 실행 ===== */

async function main(): Promise<void> {
  /* [정답 1] 기대: 모델을 안 부르고 "대화가 너무 길어졌습니다." 가 나옵니다. */
  printSection("[정답 1] 메시지 개수 제한");
  const agent1 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather],
    middleware: [createMessageLimitMiddleware(2)],
  });
  const r1 = await agent1.invoke({
    messages: [
      { role: "user", content: "안녕" },
      { role: "assistant", content: "안녕하세요" },
      { role: "user", content: "서울 날씨는?" },
    ],
  });
  printMessages(r1.messages.at(-1)!);

  /* [정답 2] 기대: "[지연] 모델 호출 ###ms" 가 찍힙니다. 값은 매번 다릅니다. */
  printSection("[정답 2] 지연시간 측정");
  const agent2 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [],
    middleware: [latencyMiddleware],
  });
  await agent2.invoke({ messages: [{ role: "user", content: "안녕이라고만 답해줘." }] });

  /* [정답 3] 기대: 타임아웃 ToolMessage 가 나오고, 모델이 그걸 읽고 사정을 설명합니다.
   *          에이전트는 죽지 않습니다 — 그게 핵심입니다. */
  printSection("[정답 3] 도구 타임아웃");
  const agent3 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [slowSearch],
    systemPrompt: "너는 검색 비서다. 질문을 받으면 slow_search 를 써라.",
    middleware: [createToolTimeoutMiddleware(1000)],
  });
  const r3 = await agent3.invoke({
    messages: [{ role: "user", content: "타입스크립트 뉴스 검색해줘." }],
  });
  printMessages(r3.messages);

  /* [정답 4] 기대: { get_weather: 3 } — 모델이 도시 3개를 각각 조회했다면.
   *          모델이 병렬로 부르든 순차로 부르든 합계는 3이어야 합니다. */
  printSection("[정답 4] 도구별 호출 횟수");
  const agent4 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather],
    systemPrompt: "너는 날씨 비서다. 도시마다 도구를 따로 호출해라.",
    middleware: [toolStatsMiddleware],
  });
  const r4 = await agent4.invoke({
    messages: [{ role: "user", content: "서울, 부산, 제주 날씨 알려줘." }],
  });
  printKV({ toolCallCounts: JSON.stringify(r4.toolCallCounts) });

  /* [정답 5] 기대: "시도 1/3", "시도 2/3", "시도 3/3" 이 순서대로 찍힌 뒤 실패.
   *          await 를 빼면 "시도 1/3" 만 찍히고 바로 실패합니다 — 그게 함정입니다. */
  printSection("[정답 5] 재시도");
  const broken = await initChatModel("anthropic:claude-없는-모델");
  const agent5 = createAgent({
    model: broken,
    tools: [],
    middleware: [createRetryMiddleware(3)],
  });
  try {
    await agent5.invoke({ messages: [{ role: "user", content: "안녕" }] });
  } catch (error) {
    console.log(`3번 다 실패했습니다: ${(error as Error).message.slice(0, 60)}...`);
  }

  /* [정답 6] 기대: "[검열] 카드번호 패턴 발견" 이 찍히고,
   *          도구 결과에는 [REDACTED] 가 들어갑니다.
   *          그런데 AIMessage 의 tool_calls 에는 원본 카드번호가 그대로 남아 있습니다 —
   *          기록은 보존하고 도구에만 검열된 값을 주는 게 의도한 동작입니다. */
  printSection("[정답 6] 인자 검열");
  const agent6 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [sendEmail],
    systemPrompt: "너는 이메일 비서다. 사용자가 준 문장을 그대로 body 에 넣어 보내라.",
    middleware: [redactMiddleware],
  });
  const r6 = await agent6.invoke({
    messages: [
      { role: "user", content: "a@b.com 으로 '내 카드는 1234-5678-9012-3456 입니다' 를 보내줘." },
    ],
  });
  printMessages(r6.messages);

  /* [정답 7] 기대: retryCount 는 0 또는 1. 모델이 "네" 라고만 답하면 1번 되돌리고,
   *          두 번째 답이 또 짧아도 더는 되돌리지 않습니다. */
  printSection("[정답 7] 짧은 답변 되돌리기");
  const agent7 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [],
    systemPrompt: "너는 비서다.",
    middleware: [expandAnswerMiddleware],
  });
  const r7 = await agent7.invoke({ messages: [{ role: "user", content: "'네' 라고만 답해줘." }] });
  printMessages(r7.messages);
  printKV({ retryCount: r7.retryCount });

  /* [정답 8] 기대: 모델 호출이 2번 이상이면 두 번째부터 ~1000ms 씩 대기합니다.
   *          totalWaitMs 는 그 합. 첫 호출은 대기 0 입니다. */
  printSection("[정답 8] 속도 제한");
  const agent8 = createAgent({
    model: "anthropic:claude-sonnet-4-6",
    tools: [getWeather],
    systemPrompt: "너는 날씨 비서다. 도시마다 도구를 따로 호출해라.",
    middleware: [createRateLimitMiddleware(1)],
  });
  const r8 = await agent8.invoke({
    messages: [{ role: "user", content: "서울, 부산, 제주 날씨를 순서대로 알려줘." }],
  });
  printKV({ "총 대기(ms)": r8.totalWaitMs });
}

main().catch((error: unknown) => {
  console.error(error);
  process.exit(1);
});