day5_practice/day5_final.py · 5일차 마지막 실습

미들웨어 6겹 에이전트 플레이어

오늘 만든 부품(로깅, 마스킹, 입력 가드, 출력 가드)에 호출 횟수 제한과 사람 승인(HITL)까지 create_agent 하나에 끼워 넣고, 4일차 MCP 서버 도구로 일을 시키는 종합 실습입니다. ▶를 누르면 에이전트 내부 그래프, 8개 소스 파일, State, 터미널이 한 단계씩 같이 움직입니다. 터미널 칸에는 run()이 찍는 노드별 추적 출력(질문, LLM 응답, 도구 인자, 가드 통과 전후 변화)이 그 단계에 해당하는 만큼 나옵니다. 메시지, 로그, 출력은 모두 실제로 돌려서 얻은 값입니다.

⚠ 프로젝트 루트에서 실행하면 에러가 납니다 → 실행 위치 주의
준비 ① before_model ② model (+wrap) ③ after_model (역순) ④ tools ⏸ 사람 대기 ▶ resume 끝
① before_model : 리스트 순서대로 ③ after_model : 리스트 역순으로 tool_calls 있음 tool_calls 없음 다음 바퀴 역순 시작 🔌 mcp_server.py 별도 프로세스 (stdio) 새 프로세스 기동! 🙋 사람 decisions: approve… 💾 AsyncSqliteSaver checkpoints.sqlite 저장! START Masking .before_model Logging .before_model 🛡 InputGuardrail (wrap) model Bedrock Haiku 4.5 Logging .after_model OutputGuardrail .after_model HumanInTheLoop .after_model ToolCallLimit .after_model tools 도구 실행 END

🎒 State["messages"]

🖥️ 터미널 출력

추적 출력 읽기: run()이 찍는 것

day5_final.py의 run()(113~160줄)은 노드가 끝날 때마다 그 노드가 State에 쓴 값을 노드 실행 직전 State와 비교해 한 덩어리씩 찍습니다. 같은 id의 메시지를 다시 쓰면 교체되는 규칙(add_messages)이라, 메시지 id로 전후를 맞춰 봅니다. 메시지 내용만 찍는 것이고 그래프 동작은 ainvoke와 똑같습니다.

출력 모양뜻이번 실행에서 나온 곳
[노드] 통과 (변경 없음)훅이 None을 반환 = State를 안 바꿈대부분의 미들웨어 노드. 가드가 "문제없음"이라고 판단한 경우
[노드] 새 메시지 추가
🙋 / 🤖 / 📦 / 🔧
처음 보는 id의 메시지. 🤖 LLM 응답, 📦 도구 결과, 🔧 LLM이 요청한 도구와 인자model, tools
[노드] ✏️ 메시지 변경
변경 전: … / 변경 후: …
같은 id인데 내용이 다름 = 가드가 메시지를 고쳐 끼움. 앞부분이 같으면 덧붙은 부분만 보여줌①의 OutputGuardrail (이메일 마스킹 / 안내 문구 덧붙임)
[노드] 같은 메시지를 다시 씀같은 id, 내용·인자도 같음②의 HITL 재개 (approve)
[노드] State 값 변경: {…}messages 말고 다른 State 키를 바꿈ToolCallLimit 카운트, HITL의 hitl_edited_tool_calls
[⏸ interrupt] 사람 승인 대기updates에 __interrupt__가 옴 = 그래프 정지. 승인 대기 중인 도구와 인자②의 1차 실행 끝
🛡 가드가 만든 AIMessage(토큰 정보 없음)토큰 사용량(usage_metadata)이 없는 AIMessage = LLM이 아니라 미들웨어가 직접 만든 것③의 거절 문구, ①에서 출력 가드가 고쳐 다시 만든 응답
[guard] 입력 검사 '…' -> 통과/차단입력 가드는 노드가 아니라 model 노드 안쪽(wrap)이라 위 비교에 안 잡힘. 그래서 guards.py 31줄에서 직접 찍음모델을 부를 때마다 (①에서 3번)
① 출력 가드 전후 (이번 실행 실제 출력)

      
기본 3시나리오엔 없는 변화 (별도 테스트 실제 출력)

개인정보가 든 질문 + edit로 재개했을 때입니다. main()에는 없는 시나리오입니다.

🙋 사용자 질문: 내 사번 25010042, 연락처 010-2345-6789야. 부산 2일 출장을 예산 80만 원으로 신청해줘
[masking] 개인정보 마스킹 적용
  [MaskingMiddleware.before_model] ✏️ 메시지 변경 (같은 id로 교체)
     변경 전: 🙋 사용자: 내 사번 25010042, 연락처 010-2345-6789야. …
     변경 후: 🙋 사용자: 내 사번 [MASKED_EMP_ID], 연락처 [MASKED_PHONE]야. …
  [LoggingMiddleware.before_model] 통과 (변경 없음)
  [guard] 입력 검사 '내 사번 [MASKED_EMP_ID], 연락처 [MAS' -> 통과
  …
▶ 재개 값(resume): {'decisions': [{'type': 'edit', 'edited_action': {'name': 'request_business_trip', 'args': {'destination': '부산', 'days': 2, 'budget': 500000}}}]}
  [HumanInTheLoopMiddleware.after_model] 같은 메시지를 다시 씀 (내용, 인자 변경 없음)
            …(budget 800000 그대로인 원래 tool_calls)
  [HumanInTheLoopMiddleware.after_model] State 값 변경: {'hitl_edited_tool_calls': {'tooluse_…': {… 'budget': 500000}}}
  [HumanInTheLoopMiddleware.after_model] ✏️ 사람이 도구 인자를 수정 (도구 실행 때 이 값으로 바꿔 실행)
     인자 변경: request_business_trip.budget: 800000 → 500000

입력 가드가 마스킹된 문장을 검사하는 것도 보입니다(마스킹이 before_model이라 먼저 돎).

edit는 AIMessage를 안 고칩니다 (그래서 State 값을 따로 읽음)

설치된 langchain 1.4.7의 HITL은 edit를 받아도 LLM이 낸 tool_calls는 기록용으로 그대로 두고, 고친 인자를 State의 hitl_edited_tool_calls에 넣어 뒀다가 도구를 실행하는 순간(wrap_tool_call) 바꿔 끼웁니다. 메시지 비교만으로는 "변경 없음"으로 보이므로, run() 152~158줄에서 이 값을 원래 인자와 키별로 비교해 찍습니다. 도구 결과에도 "a human reviewer replaced this tool call" 안내가 붙어 LLM이 바뀐 값으로 답하게 됩니다.

한 장 요약: 이 실습이 하는 일

🧩 오늘 만든 부품을 한 Agent에 조립

create_agent(middleware=[...])에 미들웨어 6개를 끼웁니다. 4개는 오늘 앞 실습에서 만든 것(guards.py가 다시 내보냄), 2개는 LangChain 기본 제공(ToolCallLimitMiddleware, HumanInTheLoopMiddleware)입니다. 직접 짠 노드 없이 끼우는 순서만으로 동작이 정해집니다.

🔌 도구는 두 종류

4일차 mcp_server.py가 주는 조회 도구 4개(search_employee, list_team_members, get_asset, list_projects)는 별도 프로세스에서 돌고, 출장 도구 2개(request_business_trip, approve_business_trip)는 이 파일 안의 로컬 함수입니다. 사람 승인은 출장 도구 2개에만 겁니다.

🎬 시나리오 3개로 각 부품을 확인

① 안전 질문: 승인 없이 MCP 도구 2번 써서 끝까지. ② 출장 신청: HITL에서 멈췄다가 승인으로 재개. ③ 주입 시도: 입력 가드가 LLM 호출 전에 차단. 위 플레이어 탭이 이 3개입니다.

⚡ 전부 비동기(async)

MCP 도구는 비동기 전용이라 ainvoke로 돌리고, 그래서 체크포인터도 AsyncSqliteSaver, 입력 가드도 awrap_model_call을 따로 구현했습니다. 자세한 건 비동기 절.

부품표: 미들웨어 6개가 각각 하는 일

"쓰는 훅"이 곧 그래프의 노드가 됩니다. before_model, after_model은 이름이 클래스명.훅이름인 노드로 생기고, wrap_model_call은 노드가 아니라 model 노드 안쪽을 감쌉니다.

#미들웨어출처쓰는 훅하는 일이번 실행에서
1InputGuardrailMiddlewareguards.py (4-2 감쌈)wrap_model_call
awrap_model_call
마지막 HumanMessage를 규칙(정규식) → LLM 판별 순서로 검사. 주입이면 handler(진짜 LLM 호출)를 부르지 않고 고정 거절 문구를 바로 반환③에서 차단 규칙에 걸림
2MaskingMiddlewaremasking_middleware.py (2-2)before_model마지막 HumanMessage의 사번, 전화, 이메일을 [MASKED_…]로 바꿔 같은 id로 교체통과 세 질문 모두 개인정보 없음
3ToolCallLimitMiddlewareLangChain 기본after_modelthread 전체 도구 호출 수를 State에 세고, 10회를 넘는 호출은 막음 (기본 동작 exit_behavior="continue": 막힌 호출엔 에러 ToolMessage)1 → 2 → 3회 한도 내
4HumanInTheLoopMiddlewareLangChain 기본after_modelLLM이 낸 tool_calls 중 interrupt_on에 있는 도구가 있으면 interrupt()로 멈추고 decisions를 받음②에서 1번 멈춤 승인
5OutputGuardrailMiddlewareguards_output.py (5-1)after_model사용자에게 나가는 최종 답만 검사: 개인정보 → 마스킹, 금지 주제 → 고정 문구, 근거 없는 단정(LLM 판별) → 안내 문구 덧붙임. tool_calls가 붙은 응답은 건드리지 않음①의 최종 답을 고침 (이메일 마스킹 또는 안내 덧붙임, 실행마다 다름)
6LoggingMiddlewarelogging_middleware.py (2-1)before_model
after_model
모델 호출 전 마지막 메시지 미리보기, 호출 후 tool_calls 이름과 토큰 수를 agent_log.jsonl에 한 줄씩 기록12줄 기록

실행 순서: 리스트에 적은 순서 ≠ 실행되는 순서

아래는 실제로 만들어진 그래프(agent.get_graph())를 그대로 옮긴 것입니다. 칩 아래 숫자는 middleware=[...] 리스트에서의 순번입니다.

#2Masking.before_model입력 마스킹
→
#6Logging.before_model입력 기록
→
#1 감쌈InputGuardrail ⟮ model ⟯검사 후 LLM 호출
→
#6Logging.after_model응답 기록
→
#5OutputGuardrail.after_model최종 답 검사
→
#4HumanInTheLoop.after_model위험 도구면 멈춤
→
#3ToolCallLimit.after_model호출 수 세고 분기
→
분기tools / ENDtools면 #2로 돌아감
before_model = 리스트 순서

앞에 적은 것이 먼저 실행됩니다. #2 Masking → #6 Logging. 그래서 Logging의 입력 기록은 마스킹이 끝난 뒤의 HumanMessage를 봅니다.

wrap_model_call = 바깥에서 감쌈

리스트 앞쪽일수록 바깥 껍질입니다. 여기선 #1 하나뿐이라 InputGuardrail이 LLM 호출을 통째로 감쌉니다. handler(request)를 부르면 진짜 LLM 호출, 안 부르고 바로 반환하면 LLM을 아예 안 부름.

after_model = 리스트 역순

뒤에 적은 것이 먼저 실행됩니다. #6 → #5 → #4 → #3. 양파처럼 들어갈 때 순서의 반대로 나옵니다. middleware_order.py에서 A, B, C로 확인한 그 규칙입니다.

왜 이 순서로 적었나
  • #1 입력 가드를 맨 앞(=가장 바깥): 위험한 입력이면 LLM 비용이 들기 전에 끊습니다.
  • #5 출력 가드가 #4 HITL보다 먼저 실행(역순이라): 출력 가드가 tool_calls 붙은 응답을 새 AIMessage로 바꿔 버리면 tool_calls가 사라져 HITL이 멈출 거리 자체가 없어집니다. 그래서 guards_output.py 70~76줄에서 tool_calls가 있으면 그냥 통과시킵니다.
  • #3 호출 제한이 가장 마지막에 실행: 사람이 승인(또는 거절)한 뒤의 tool_calls로 셉니다. 멈춰 있는 동안에는 세지 않습니다(실제로 ②의 카운트는 재개 후에 2 → 3).
주석 "마지막: 정제된 내용만 기록"은 반만 맞음

before_model 쪽은 맞습니다. Logging이 리스트 마지막이라 마스킹 뒤에 기록합니다.

after_model 쪽은 반대입니다. 역순이라 Logging.after_model이 가장 먼저 실행돼 출력 가드보다 앞에서 응답을 봅니다. 지금은 텍스트 대신 tool_calls 이름과 토큰 수만 남겨서 새는 게 없지만, 응답 본문을 기록하도록 고치면 마스킹 전 원문이 로그에 남습니다.

그리고 before_model 쪽도 HumanMessage만 마스킹되므로, 도구 결과(ToolMessage)의 이메일은 로그에 그대로 남았습니다(아래 알아둘 점).

시나리오 3개 실제 결과 비교

시나리오thread_id도구 호출멈춤Bedrock 호출 수걸린 가드화면 출력 (①은 [:] 전체, ②③은 [:80])
① 안전 질문final_demo search_employee → list_projects
MCP · 매번 새 프로세스
없음
② 출장 신청final_demo
(①에 이어짐)
request_business_trip
로컬 함수
1번 → 승인 5번
입력 LLM 판별 2 + 모델 2 + 단정 검사 1
HITL interrupt
③ 주입 시도attack_demo 없음 없음 1번
모델 0, 단정 검사 1
입력 가드 1차 규칙(정규식)

Bedrock 호출 수는 코드 흐름으로 센 값입니다. 입력 가드의 LLM 판별은 모델을 부를 때마다 돌고(①은 모델을 3번 불러서 3번), 단정 검사는 tool_calls 없는 최종 답에만 돕니다. ③은 규칙에서 끊겨 LLM 판별까지 가지 않았고, agent_log.jsonl의 after_model 기록에 total_tokens가 없는 것으로 모델 미호출을 확인했습니다.

HITL: 직접 만든 gate(hitl_flow.py) vs 미들웨어

hitl_flow.py (직접 구현)day5_final.py (HumanInTheLoopMiddleware)
위험 판단RISKY_TOOLS 집합 + route()가 gate로 보냄interrupt_on={"도구명": True}. 도구 이름 기준인 건 똑같음
멈추는 곳gate 노드 안의 interrupt()HumanInTheLoopMiddleware.after_model 노드 안의 interrupt()
state.next = ('HumanInTheLoopMiddleware.after_model',)
질문지 (interrupt 값){"tool", "args", "question"} 우리가 정한 모양{"action_requests": [...], "review_configs": [...]} 정해진 모양. 위험 도구 여러 개를 한 번에 묶어서 물음
답 (resume 값){"decision": "approve"}{"decisions": [{"type": "approve"}]} — 물어본 도구 수만큼 리스트. 개수가 다르면 ValueError
수정{"decision": "modify", "args": {"budget": 500000}}{"type": "edit", "edited_action": {"name": "request_business_trip", "args": {"destination": "부산", "days": 2, "budget": 500000}}}
거절{"decision": "reject", "reason": "..."}{"type": "reject", "message": "..."} → 모델에게 "User rejected the tool call ..." ToolMessage
그 외—{"type": "respond", "message": "..."} 도구 대신 사람이 결과를 직접 써 줌. True로 주면 이것까지 4가지가 허용됨(실제 질문지의 allowed_decisions 확인)
ToolMessage 짝 맞추기gate에서 직접 ToolMessage(tool_call_id=…)미들웨어가 알아서 (거절, respond는 가짜 ToolMessage를 만들어 넣음)
재개 시gate 노드를 처음부터 다시after_model 노드를 처음부터 다시. 같은 규칙

왜 전부 async인가

MCP 도구 → ainvoke 강제

langchain_mcp_adapters가 만든 도구는 비동기 전용입니다(동기로 부르면 에러). 그래서 asyncio.run(main()), await client.get_tools(), await agent.ainvoke(...)로 갑니다.

체크포인터도 Async

ainvoke 중에는 저장, 불러오기도 aput, aget_tuple 같은 비동기 메서드로 불립니다. 동기 SqliteSaver는 이걸 지원하지 않아서 AsyncSqliteSaver를 async with로 엽니다.

wrap 훅은 a 버전이 따로 필요

ainvoke로 돌면 awrap_model_call이 불립니다. 동기 wrap_model_call만 있으면 비동기 실행에서 쓸 수 없어서 guards.py 43~48줄에 같은 로직을 async로 한 번 더 썼습니다. handler도 await handler(request).

before/after 훅은 동기 그대로 OK

Masking, Logging, OutputGuardrail은 동기 before_model/after_model만 있는데도 ainvoke에서 잘 돌았습니다(실행으로 확인). 다만 안에서 부르는 llm_check, check_grounding은 동기 invoke라 그동안 이벤트 루프가 잠깐 멈춥니다. 이 실습처럼 한 번에 하나씩 돌리면 문제없습니다.

알아둘 점 (실제로 돌려 보고 찾은 것)

실행 전에 할 것 (McpError: Connection closed가 나는 이유)

에러 메시지는 McpError: Connection closed 하나지만, 원인은 MCP 서버(자식 프로세스)가 뜨자마자 죽는 것이고 이유가 두 겹입니다.

① day5_practice에 데이터 JSON이 없음 → 지금은 만들어 둠

mcp_server.py 19~22줄이 자기 폴더의 employees.json, assets.json, projects.json을 읽는데, 이 세 파일은 day4_practice에만 있습니다. README.txt의 "시작 전에 할 것 1)"이 바로 이것입니다. 지금 day5_practice에는 세 파일이 만들어져 있어서 해결된 상태입니다.

cd day5_practice
python make_data.py        # 또는 day4_practice의 JSON 3개 복사
② MCP 서버 경로가 상대경로

168줄 "args": ["mcp_server.py"]는 실행한 위치 기준으로 찾습니다. 프로젝트 루트(VS Code 실행 버튼 기본값)에서 돌리면 sds-ax-practice\mcp_server.py를 찾다 실패합니다. day5_practice 폴더 안에서 실행하면 맞습니다. 이건 아직 그대로라 실행 위치만 주의하면 됩니다. 이 페이지의 실행 결과는 지금 코드를 복사한 폴더에서 새 DB로 돌린 것입니다.

cd day5_practice
python day5_final.py