오늘 만든 부품(로깅, 마스킹, 입력 가드, 출력 가드)에 호출 횟수 제한과 사람 승인(HITL)까지 create_agent 하나에 끼워 넣고, 4일차 MCP 서버 도구로 일을 시키는 종합 실습입니다.
▶를 누르면 에이전트 내부 그래프, 8개 소스 파일, State, 터미널이 한 단계씩 같이 움직입니다. 터미널 칸에는 run()이 찍는 노드별 추적 출력(질문, LLM 응답, 도구 인자, 가드 통과 전후 변화)이 그 단계에 해당하는 만큼 나옵니다. 메시지, 로그, 출력은 모두 실제로 돌려서 얻은 값입니다.
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번) |
개인정보가 든 질문 + 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이라 먼저 돎).
설치된 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이 바뀐 값으로 답하게 됩니다.
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개에만 겁니다.
① 안전 질문: 승인 없이 MCP 도구 2번 써서 끝까지. ② 출장 신청: HITL에서 멈췄다가 승인으로 재개. ③ 주입 시도: 입력 가드가 LLM 호출 전에 차단. 위 플레이어 탭이 이 3개입니다.
MCP 도구는 비동기 전용이라 ainvoke로 돌리고, 그래서 체크포인터도 AsyncSqliteSaver, 입력 가드도 awrap_model_call을 따로 구현했습니다. 자세한 건 비동기 절.
"쓰는 훅"이 곧 그래프의 노드가 됩니다. before_model, after_model은 이름이 클래스명.훅이름인 노드로 생기고, wrap_model_call은 노드가 아니라 model 노드 안쪽을 감쌉니다.
| # | 미들웨어 | 출처 | 쓰는 훅 | 하는 일 | 이번 실행에서 |
|---|---|---|---|---|---|
| 1 | InputGuardrailMiddleware | guards.py (4-2 감쌈) | wrap_model_callawrap_model_call | 마지막 HumanMessage를 규칙(정규식) → LLM 판별 순서로 검사. 주입이면 handler(진짜 LLM 호출)를 부르지 않고 고정 거절 문구를 바로 반환 | ③에서 차단 규칙에 걸림 |
| 2 | MaskingMiddleware | masking_middleware.py (2-2) | before_model | 마지막 HumanMessage의 사번, 전화, 이메일을 [MASKED_…]로 바꿔 같은 id로 교체 | 통과 세 질문 모두 개인정보 없음 |
| 3 | ToolCallLimitMiddleware | LangChain 기본 | after_model | thread 전체 도구 호출 수를 State에 세고, 10회를 넘는 호출은 막음 (기본 동작 exit_behavior="continue": 막힌 호출엔 에러 ToolMessage) | 1 → 2 → 3회 한도 내 |
| 4 | HumanInTheLoopMiddleware | LangChain 기본 | after_model | LLM이 낸 tool_calls 중 interrupt_on에 있는 도구가 있으면 interrupt()로 멈추고 decisions를 받음 | ②에서 1번 멈춤 승인 |
| 5 | OutputGuardrailMiddleware | guards_output.py (5-1) | after_model | 사용자에게 나가는 최종 답만 검사: 개인정보 → 마스킹, 금지 주제 → 고정 문구, 근거 없는 단정(LLM 판별) → 안내 문구 덧붙임. tool_calls가 붙은 응답은 건드리지 않음 | ①의 최종 답을 고침 (이메일 마스킹 또는 안내 덧붙임, 실행마다 다름) |
| 6 | LoggingMiddleware | logging_middleware.py (2-1) | before_modelafter_model | 모델 호출 전 마지막 메시지 미리보기, 호출 후 tool_calls 이름과 토큰 수를 agent_log.jsonl에 한 줄씩 기록 | 12줄 기록 |
아래는 실제로 만들어진 그래프(agent.get_graph())를 그대로 옮긴 것입니다. 칩 아래 숫자는 middleware=[...] 리스트에서의 순번입니다.
앞에 적은 것이 먼저 실행됩니다. #2 Masking → #6 Logging. 그래서 Logging의 입력 기록은 마스킹이 끝난 뒤의 HumanMessage를 봅니다.
리스트 앞쪽일수록 바깥 껍질입니다. 여기선 #1 하나뿐이라 InputGuardrail이 LLM 호출을 통째로 감쌉니다. handler(request)를 부르면 진짜 LLM 호출, 안 부르고 바로 반환하면 LLM을 아예 안 부름.
뒤에 적은 것이 먼저 실행됩니다. #6 → #5 → #4 → #3. 양파처럼 들어갈 때 순서의 반대로 나옵니다. middleware_order.py에서 A, B, C로 확인한 그 규칙입니다.
guards_output.py 70~76줄에서 tool_calls가 있으면 그냥 통과시킵니다.before_model 쪽은 맞습니다. Logging이 리스트 마지막이라 마스킹 뒤에 기록합니다.
after_model 쪽은 반대입니다. 역순이라 Logging.after_model이 가장 먼저 실행돼 출력 가드보다 앞에서 응답을 봅니다. 지금은 텍스트 대신 tool_calls 이름과 토큰 수만 남겨서 새는 게 없지만, 응답 본문을 기록하도록 고치면 마스킹 전 원문이 로그에 남습니다.
그리고 before_model 쪽도 HumanMessage만 마스킹되므로, 도구 결과(ToolMessage)의 이메일은 로그에 그대로 남았습니다(아래 알아둘 점).
| 시나리오 | thread_id | 도구 호출 | 멈춤 | Bedrock 호출 수 | 걸린 가드 | 화면 출력 (①은 [:] 전체, ②③은 [:80]) |
|---|---|---|---|---|---|---|
| ① 안전 질문 | final_demo |
search_employee → list_projectsMCP · 매번 새 프로세스 |
없음 | |||
| ② 출장 신청 | 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_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 노드를 처음부터 다시. 같은 규칙 |
langchain_mcp_adapters가 만든 도구는 비동기 전용입니다(동기로 부르면 에러). 그래서 asyncio.run(main()), await client.get_tools(), await agent.ainvoke(...)로 갑니다.
ainvoke 중에는 저장, 불러오기도 aput, aget_tuple 같은 비동기 메서드로 불립니다. 동기 SqliteSaver는 이걸 지원하지 않아서 AsyncSqliteSaver를 async with로 엽니다.
ainvoke로 돌면 awrap_model_call이 불립니다. 동기 wrap_model_call만 있으면 비동기 실행에서 쓸 수 없어서 guards.py 43~48줄에 같은 로직을 async로 한 번 더 썼습니다. handler도 await handler(request).
Masking, Logging, OutputGuardrail은 동기 before_model/after_model만 있는데도 ainvoke에서 잘 돌았습니다(실행으로 확인). 다만 안에서 부르는 llm_check, check_grounding은 동기 invoke라 그동안 이벤트 루프가 잠깐 멈춥니다. 이 실습처럼 한 번에 하나씩 돌리면 문제없습니다.
state["messages"][-1]을 기록하는데, 도구를 쓴 다음 바퀴에선 그게 ToolMessage입니다. MaskingMiddleware는 HumanMessage만 고치므로 이메일이 마스킹되지 않았습니다. 실제 agent_log.jsonl 3번째 줄:
check_grounding은 답 텍스트만 보고(도구 결과는 안 봄) 근거가 없다고 판정해 안내 문구를 덧붙였습니다. LLM이 답에 이메일을 쓰면 1단계 개인정보 마스킹에서 먼저 걸려 단정 검사까지 가지 않습니다. 아래는 그때의 실제 추적 출력입니다. LLM 판별이라 매번 결과가 달라질 수 있습니다.
wrap_model_call은 모델을 부를 때마다 돌고, 검사 대상은 늘 "마지막 HumanMessage"입니다. ①에서는 같은 질문을 3번 판별했습니다. 질문 하나당 한 번이면 충분하므로 비용, 지연이 늘어납니다.check_grounding LLM 호출이 1번 생겼습니다.client.get_tools()를 세션 없이 쓰면 도구 호출마다 새 세션(=새 mcp_server.py 프로세스)을 엽니다. 그래서 FastMCP 배너가 ①에서만 3번(get_tools 1 + 도구 2) 찍힙니다. 상태를 유지해야 하는 서버라면 async with client.session("sds_internal")로 세션 하나를 열어 써야 합니다.thread_limit=10은 한 번 실행이 아니라 thread 누적 한도이고, 카운트(thread_tool_call_count)는 State라서 checkpoints.sqlite에 남습니다. 실제로 day5_practice에서 여러 번 실행했더니 메시지 40개, 카운트 10이 쌓여 있었습니다. 한 번 실행에 도구 3번이라 4번째 실행의 list_projects가 11번째 호출로 막혔고(Tool call limit exceeded), 그 뒤로는 LLM이 앞 대화의 "제한에 걸렸다"는 내용을 보고 도구를 부르지도 않고 거절했습니다. 그래서 시작할 때 201~206줄에서 두 thread를 adelete_thread로 비우게 고쳤습니다. DB 파일이 처음 생길 때는 테이블이 없어 지우기가 no such table로 실패하므로, 그 앞에서 await checkpointer.setup()으로 테이블부터 만듭니다(비동기 체크포인터는 첫 저장/조회 때 테이블을 만들어서, 지우기가 먼저 오면 테이블이 없음). 같은 DB의 hitl_flow, interrupt_basic 기록은 건드리지 않습니다.final_demo thread에만 체크포인트가 개 쌓였습니다(노드 3개짜리 interrupt_basic.py는 끝까지 돌려도 5개).True로 주면 실제로는 respond까지 4가지가 허용됩니다.에러 메시지는 McpError: Connection closed 하나지만, 원인은 MCP 서버(자식 프로세스)가 뜨자마자 죽는 것이고 이유가 두 겹입니다.
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개 복사
168줄 "args": ["mcp_server.py"]는 실행한 위치 기준으로 찾습니다. 프로젝트 루트(VS Code 실행 버튼 기본값)에서 돌리면 sds-ax-practice\mcp_server.py를 찾다 실패합니다. day5_practice 폴더 안에서 실행하면 맞습니다. 이건 아직 그대로라 실행 위치만 주의하면 됩니다. 이 페이지의 실행 결과는 지금 코드를 복사한 폴더에서 새 DB로 돌린 것입니다.
cd day5_practice python day5_final.py