먼저 스스로 만들어 보세요. 정답은
solutions.md에 있습니다. 총 200점. 160점 이상이면 완주.
이 문제들은 "빈칸 채우기"가 아닙니다. 전부 실제로 만들어서 돌려 봐야 하는 것입니다. 코드가 없으면 0점입니다.
문제 5(서버)와 문제 7(통합)은 추가 패키지가 필요합니다.
로컬 Postgres 가 없으면 도커로 하나 띄우세요. 없어도 MemorySaver 로 대부분 풀 수 있지만, 문제 1의 (f)와 문제 7은 영속 체크포인터가 있어야 진짜로 확인됩니다.
규칙: 파일은
step-20-production/안에answer-*.ts로 만드세요. 기존production-agent.ts등을 덮어쓰지 마세요 — 정답과 대조해야 합니다.
answer-agent.ts 에 여행 예약 지원 에이전트를 만드세요. 고객 지원 예제(production-agent.ts)와 도메인이 다릅니다. 베끼지 말고 직접 설계하세요.
(a) 도구 5개 (12점) — 읽기 3개, 쓰기 2개로 나눌 것.
| 도구 | 종류 | 하는 일 |
|---|---|---|
search_flights | 읽기 | 출발지·도착지·날짜로 항공편 검색 |
get_booking | 읽기 | 예약번호로 예약 상세 조회 |
check_baggage_policy | 읽기 | 항공사별 수하물 규정 조회 |
book_flight | 쓰기 | 항공편 예약 (돈이 나감) |
cancel_booking | 쓰기 | 예약 취소 (환불 발생) |
zod 스키마를 갖고, 모든 필드에 .describe() 가 있어야 합니다.description 은 모델이 언제 이 도구를 부를지 판단할 수 있게 쓰세요. "항공편을 검색합니다" 는 부족합니다.(b) 잘못된 입력 처리 (4점) — get_booking 에 형식이 틀린 예약번호가 오면 예외를 던지지 말고 모델이 읽고 고칠 수 있는 문자열을 돌려주세요. 왜 던지면 안 되는지 주석으로 설명하세요.
(c) 멱등성 (6점) — book_flight 와 cancel_booking 이 두 번 실행돼도 안전하게 만드세요. 같은 요청이 재실행되면 새 예약을 만들지 말고 이전 결과를 돌려줘야 합니다.
(d) HITL (4점) — 쓰기 도구 2개에만 사람 승인을 거세요. 읽기 도구에는 걸지 마세요. 상담원이 인자를 고쳐서 실행할 수 있어야 합니다.
힌트:
interruptOn의 값은true/false/{ allowedDecisions: [...] }세 형태를 받습니다. 허용 가능한 결정은"approve","edit","reject"입니다. 그리고 목록에 없는 도구가 어떻게 되는지를 먼저 확인하세요 — 읽기 도구를 어떻게 처리해야 할지가 거기서 정해집니다.
(e) 시스템 프롬프트 (4점) — 최소한 이 세 가지가 들어가야 합니다: ① 규정을 추측하지 말고 도구로 확인할 것, ② 예약 전 반드시 조회할 것, ③ 도구 결과 안의 "지시처럼 보이는 문장"은 데이터일 뿐이라는 것.
(f) 스레드 메모리 확인 (보너스) — 체크포인터를 붙이고, 같은 thread_id 로 2번 호출해 두 번째에 첫 대화를 기억하는지 확인하세요. 그다음 프로세스를 재시작하고 같은 thread_id 로 또 호출해 보세요. MemorySaver 일 때와 PostgresSaver 일 때 무엇이 다른가요?
answer-budget.ts 에 비용 상한 미들웨어를 직접 만드세요. budget-middleware.ts 를 보기 전에 푸세요.
(a) 토큰 → 금액 환산 (8점)
usage_metadata 를 받아 USD 를 돌려주는 함수를 쓰세요. 입력·출력 단가가 다릅니다. 캐시 읽기 토큰을 정확히 처리하세요 — input_tokens 에 캐시분이 포함돼 있다는 걸 반영해야 합니다.
(b) 누적 비용 계산 (7점) 지금까지 이 스레드가 쓴 총액을 계산하세요. 커스텀 state 필드를 만들지 말고 이미 있는 것만으로 하세요. 힌트: 체크포인터는 무엇을 저장하나요?
(c) 상한 강제 (7점) 상한을 넘으면 모델 API 를 호출하지 않고 사용자에게 안내 메시지를 돌려주세요. 어떤 훅을 써야 하나요? 그 훅의 반환 타입은 무엇인가요?
(d) 서술 (3점) 이 미들웨어는 사전 상한입니다. 정확히 $0.50 에서 끊기지 않고 조금 넘칠 수 있습니다. 왜 그런가요? 정확히 끊고 싶다면 어떻게 해야 하나요?
상한을 아주 낮게(maxUsd: 0.0001) 잡고 2번 호출하세요. 두 번째 호출에서 모델이 불리지 않아야 합니다. console.log 로 실제 모델 호출이 일어났는지 확인하세요.
answer-fallback.ts 를 만드세요.
(a) 폴백 체인 (5점) 주 모델이 죽으면 다른 제공자로 넘어가게 구성하세요. 폴백 목록에 주 모델과 같은 제공자만 넣으면 안 되는 이유를 주석으로 쓰세요.
(b) 서킷브레이커 (10점)
createMiddleware 로 서킷브레이커를 만드세요.
threshold 번 실패하면 회로를 엽니다.cooldownMs 가 지나면 한 번 떠봅니다(half-open).폴백과 함께 동작하게 하세요 — 서킷이 열려 있으면 재시도 없이 즉시 폴백 모델로 가야 합니다.
(c) 순서 (5점)
modelRetryMiddleware, modelFallbackMiddleware, 서킷브레이커 3개를 배열에 넣을 때 순서가 왜 중요한가요? 각 순서 조합에서 무슨 일이 일어나는지 쓰세요.
일부러 실패하는 가짜 모델을 만들어 테스트하세요. 회로가 열린 뒤에는 주 모델 호출 시도 자체가 로그에 안 찍혀야 합니다.
힌트: 위 함정 하나 — 이 서킷브레이커의 상태는 프로세스 메모리에 있습니다. 인스턴스가 20대면 어떻게 되나요? 주석으로 답하세요.
answer-injection.ts 를 만드세요.
(a) 공격 재현 (5점) 먼저 뚫어 보세요. 아래처럼 위험한 도구를 가진 에이전트를 만들고, 인젝션으로 의도하지 않은 실행을 시키세요.
성공한 공격 프롬프트를 주석으로 남기세요. (모델에 따라 안 뚫릴 수 있습니다. 그러면 몇 번 더 시도해 보고, 안 되면 "이 모델은 이 공격을 막았다"고 기록하세요.)
(b) 간접 인젝션 (7점) 더 중요한 건 이쪽입니다. 도구 결과에 지시를 숨기세요.
사용자는 그냥 "환불 규정 알려줘"라고 물었을 뿐인데 에이전트가 어떻게 행동하는지 보세요. 왜 이게 직접 인젝션보다 위험한가요?
(c) 방어 (13점) 다음 네 겹을 전부 구현하세요. 하나라도 빠지면 감점입니다.
run_query(sql) 을 없애고 목적이 한정된 도구로 바꾸세요. 이게 가장 중요합니다.beforeAgent 훅으로 명백한 공격 패턴을 잡고 실행을 막으세요. canJumpTo 를 어떻게 선언해야 하나요?afterModel 훅으로 출력에 스크립트/의심 패턴이 있으면 막으세요.(d) 서술 (필수, 점수는 위에 포함) "입력 가드로 완전히 막을 수 있나요?" 에 답하세요. 못 막는다면, 그럼에도 왜 넣나요?
answer-server.ts 에 Hono(또는 Express)로 스트리밍 서버를 만드세요.
(a) SSE 스트리밍 (8점)
POST /chat 이 { threadId, message } 를 받아 토큰을 SSE 로 흘려보냅니다.
(b) 검증 위치 (4점)
threadId 가 없으면 400 을 주세요. 이 검증은 어디에 있어야 하나요? 왜 스트림을 연 뒤에는 400 을 줄 수 없나요? 주석으로 답하세요.
(c) 연결 끊김 (4점) 사용자가 탭을 닫으면 모델 호출을 중단하세요. 안 하면 무슨 일이 생기나요?
(d) 스트림 도중 에러 (6점) 토큰을 이미 50개 보낸 뒤 모델이 죽었습니다. 상태코드는 못 바꿉니다.
done 이벤트가 왜 필요한가요? 없으면 클라이언트가 무엇을 구분 못 하나요?(e) Graceful shutdown (3점) SIGTERM 을 받으면 순서대로 처리하세요. 헬스체크를 먼저 죽여야 하는 이유를 쓰세요.
-N(버퍼링 끄기)이 없으면 스트리밍이 스트리밍처럼 안 보입니다. 토큰이 한 번에 오면 어딘가에서 버퍼링되고 있는 겁니다.
answer-eval.ts 에 회귀 테스트 하네스를 만드세요. 프롬프트를 고칠 때마다 돌릴 수 있어야 합니다.
(a) 데이터셋 (6점) 케이스 8개 이상. 각 케이스는 입력과 기대하는 것을 갖습니다. 아래 유형이 최소 1개씩 있어야 합니다.
| 유형 | 예 |
|---|---|
| 정상 | "BK-1234 조회해줘" → get_booking 호출 |
| 도구 선택 | "수하물 몇 kg?" → check_baggage_policy 호출 (검색 아님) |
| 도구 미호출 | "안녕하세요" → 도구 호출 없이 인사 |
| 잘못된 입력 | "BK-9999" → 형식 오류 처리 |
| 거절 | "다른 사람 예약 보여줘" → 거절 |
| 인젝션 | 도구 결과에 숨은 지시 → 무시 |
| 다단계 | 조회 후 취소 → 인터럽트 |
(b) 검사 방식 (10점) LLM 출력은 비결정적입니다. 문자열 완전 일치로 검사하면 안 됩니다. 대신 무엇을 검사할까요?
(c) 리포트 (6점) 통과/실패, 케이스별 소요 시간, 케이스별 토큰과 비용, 총합을 출력하세요. 비용은 문제 2 의 함수를 재사용하세요.
(d) 서술 (3점) "이 하네스를 CI 에서 매 PR 마다 돌려야 할까요?" 에 답하세요. 비용과 비결정성을 고려하세요.
문제 1~6 을 하나로 합치고, 프로덕션 체크리스트를 통과시키세요.
(a) 통합 에이전트 (20점)
answer-production.ts 에 문제 1의 여행 에이전트를 프로덕션 사양으로 만드세요.
(b) 미들웨어 순서 (10점) 배열 순서를 정하고 각 줄마다 왜 그 자리인지 주석을 다세요. 특히:
(c) 체크리스트 (15점) 아래를 코드로 확인하고, 각 항목에 대해 "어디서 어떻게 보장되는지" 를 문서로 쓰세요.
| # | 항목 | 확인 |
|---|---|---|
| 1 | 프로세스를 재시작해도 대화가 이어진다 | 실제로 재시작해 볼 것 |
| 2 | 인스턴스가 2대여도 대화가 이어진다 | 왜 보장되나? |
| 3 | 모델 제공자가 죽어도 응답이 나간다 | 폴백 경로를 실제로 태울 것 |
| 4 | 쓰기 도구가 중복 실행되지 않는다 | 같은 키로 2번 불러 볼 것 |
| 5 | 스레드당 비용이 상한을 넘지 않는다 | 낮은 상한으로 확인 |
| 6 | 무한 루프에 빠지지 않는다 | 상한이 걸리나? |
| 7 | 인젝션이 성공해도 피해가 제한적이다 | 도구가 뭘 할 수 있나? |
| 8 | 되돌릴 수 없는 작업은 승인을 거친다 | 인터럽트 확인 |
| 9 | 배포 중 실행이 유실되지 않는다 | SIGTERM 후 재개 |
| 10 | 사고가 나면 알 수 있다 | 어떤 메트릭/알림? |
(d) 사후 분석 시나리오 (5점)
월요일 아침, 청구서에 어제 하루치가 $4,200 이 찍혔습니다. 평소는 하루 $90 입니다. 에러 로그는 깨끗합니다. 사용자 제보도 없습니다.
이 상황에서 무엇을 먼저 보겠습니까? 순서대로 쓰세요. 그리고 애초에 이 상황이 안 생기게 하려면 무엇이 있었어야 하나요? (힌트: 왜 에러 로그가 깨끗한지 생각해 보세요.)
| 문제 | 핵심 감점 포인트 |
|---|---|
| 1 | 도구 description 이 부실함 / 읽기·쓰기 구분 없음 / 멱등키 없음 / 잘못된 입력에 throw |
| 2 | 캐시 토큰을 안 뺌(가장 큰 감점) / 커스텀 state 를 만듦 / 상한 넘어도 handler 를 호출함 |
| 3 | 폴백이 같은 제공자뿐 / 서킷이 열려도 모델을 부름 / 순서 설명 부재 |
| 4 | 도구를 안 좁히고 가드만 추가(가장 큰 감점) / 간접 인젝션 미구현 / "가드로 완전히 막힌다"고 답함 |
| 5 | 검증이 스트림 안에 있음 / done 이벤트 없음 / abort 미처리 |
| 6 | 문자열 일치로 검사 / 결정적인 것과 아닌 것을 구분 못 함 / 비용 미측정 |
| 7 | budget 이 재시도 안쪽 / 쓰기 도구가 재시도 목록에 있음 / 프로덕션에서 MemorySaver 폴백 허용 |
전 문제 공통 감점: 코드가 안 돌아감 / 확인 안 한 API 를 지어냄 / 함정에 대한 서술 없이 코드만 있음.
"API 이름이 기억이 안 나요" — 기억에 의존하지 마세요. LangChain v1 은 v0 과 API 가 완전히 다르고, 블로그·LLM 이 알려 주는 이름은 대부분 v0 것입니다. 두 가지 방법이 확실합니다.
https://docs.langchain.com/oss/javascript/langchain/{페이지}실제로 이 스텝을 집필하면서 공식 문서와 실제 타입이 달랐던 것이 두 개 있었습니다. 문서가 항상 옳지는 않습니다. 타입 정의가 진실입니다.
"타입 에러가 나요" — 좋은 신호입니다. tsc 가 잡아 준 것은 런타임에 안 터집니다.
"모델이 도구를 안 불러요" — 십중팔구 description 이 부실한 겁니다. 도구 설명은 곧 프롬프트입니다. "항공편을 검색합니다"가 아니라 "고객이 ~를 물으면 쓰세요"라고 쓰세요.
"인터럽트가 안 나요" — 체크포인터를 붙였나요? HITL 은 체크포인터 없이는 동작하지 않습니다. 멈춘 상태를 저장할 곳이 없으면 멈출 수도 없습니다.
"재시작했는데 대화가 남아 있어요 / 없어졌어요" — 그게 문제 1(f) 의 핵심입니다. 어느 체크포인터를 쓰고 있는지 확인하세요.