Step 12 — 종합 프로젝트 : 문제

index.md 에서 만든 딥 리서치 에이전트를 확장합니다.

이 과제들은 문제를 위한 문제가 아닙니다. 12-10 에서 정리한 "지금 이 프로젝트에 남아 있는 구멍" 과 1:1로 대응합니다. 즉 여러분이 지금 실제로 고쳐야 하는 것들입니다.

정답과 해설은 solutions.md 에 있습니다. 먼저 스스로 풀고 나서 여세요.


시작하는 법

cd docs/reference/deepagent
npm install

# .env
#   ANTHROPIC_API_KEY=sk-ant-...   ← 모델 호출에 필수
#   TAVILY_API_KEY=tvly-...        ← 없어도 됩니다 (목 검색으로 동작)

# 먼저 원본이 도는지 확인
npx tsx step-12-final-project/cli.ts "RAG와 파인튜닝의 차이는?"

규칙

  • 원본 파일(agent.ts, tools.ts 등)을 직접 고치지 말고, step-12-final-project/ex/ 같은 폴더를 새로 만들어 작업하세요. 원본을 import 해서 확장하는 게 이상적입니다.
  • 목 검색으로 먼저 개발하세요. 프롬프트를 고칠 때마다 Tavily 를 때리면 돈과 시간이 나갑니다.
  • 타입 검사는 cd project && npx tsc --noEmit 로 돌립니다.

총 8과제. 5개 이상 완성하면 이 코스를 완주한 것입니다.

난이도와 배점은 아래와 같습니다.

#과제난이도배점
1인용(citation) 강제하기★★☆15
2비평 → 재조사 루프★★★20
3병렬 조사★★☆15
4토큰 예산 상한★★★15
5중단 후 재개★★☆10
6다른 도메인으로 이식★★☆10
7평가 하네스★★★10
8웹 UI★★★5

총 100점.


과제 1. 인용(citation) 강제하기 (15점)

문제

지금은 프롬프트로 "인용을 달아라"고 부탁할 뿐입니다. 모델이 어기면 그만입니다. 실제로 자주 어깁니다.

세 가지 방식으로 어깁니다.

  1. 인용 누락: 사실 주장인데 [n] 이 없음
  2. 출처 유령: 본문에 [3] 을 썼는데 출처 목록에 3번이 없음
  3. URL 환각: 출처 목록의 URL 이 findings 에 없는 것 (모델이 지어냄)

3번이 가장 위험합니다. 그럴듯한 URL 이라 사람이 안 의심합니다.

요구사항

/report.md 를 기계적으로 검증하는 함수를 만들고, 검증에 실패하면 에이전트가 다시 쓰게 하세요.

export interface CitationIssue {
  kind: "ghost_citation" | "unused_source" | "hallucinated_url" | "no_sources_section" | "gap_in_numbering";
  detail: string;
}

export function verifyCitations(
  report: string,
  allowedUrls: Set<string>,   // findings 에서 수집한 실제 URL 집합
): CitationIssue[];
  • ghost_citation: 본문의 [n] 이 출처 목록에 없음
  • unused_source: 출처 목록에 있는데 본문에서 안 쓰임
  • hallucinated_url: 출처 URL 이 allowedUrls 에 없음
  • no_sources_section: ### 출처 섹션 자체가 없음
  • gap_in_numbering: 출처 번호가 1,2,4 처럼 건너뜀

그리고 findings 파일들에서 실제 URL 을 긁어모으는 함수도 필요합니다.

export function collectUrlsFromFindings(files: Record<string, unknown>): Set<string>;

힌트

  • 본문 인용 추출: /\[(\d+)\]/g 로 뽑되, 출처 섹션은 제외해야 합니다. 안 그러면 출처 목록의 [1] 자체가 본문 인용으로 잡힙니다.
  • 출처 줄 파싱: [1] 제목: https://... 형식이므로 /^\[(\d+)\]\s*(.+?):\s*(https?:\S+)/gm.
  • allowedUrls/findings/ 로 시작하는 파일만 대상으로 모으세요. /report.md 를 포함하면 자기 자신을 근거로 삼는 순환이 생깁니다.

채점

  • 5가지 위반을 모두 잡아내는가 (8점)
  • 출처 섹션을 본문 인용 추출에서 제외했는가 (3점)
  • 검증 실패 시 재작성 루프가 도는가 (4점)

재작성 루프에는 상한을 두세요. 모델이 계속 실패하면 무한 루프가 됩니다.


과제 2. 비평 서브에이전트가 재조사를 요구하는 루프 (20점)

문제

지금 비평가는 REVISE 를 외쳐도 아무 일도 일어나지 않습니다. 부모가 그 문자열을 읽고 "알아서 판단"할 뿐입니다. 프롬프트에 "구멍은 다시 조사시켜라"고 써 뒀지만, 이건 부탁입니다.

게다가 판정이 자유 문자열이라 부모가 파싱해야 합니다. "판정: REVISE" 인지 "REVISE 입니다" 인지 "수정이 필요합니다" 인지에 따라 파싱이 깨집니다.

요구사항

  1. 비평가에게 responseFormat 을 줘서 구조화된 판정을 받으세요.
const CritiqueVerdict = z.object({
  verdict: z.enum(["PASS", "REVISE"]),
  issues: z.array(
    z.object({
      problem: z.string(),
      location: z.string(),
      /** 부모가 그대로 task() 에 넣을 수 있는 조사 주제 한 문장 */
      researchTopic: z.string(),
    }),
  ),
});
  1. 오케스트레이션을 코드로 짜세요. 프롬프트에 맡기지 말고, 부모 에이전트를 여러 번 invoke 하면서 루프를 직접 도세요.
조사 → 비평 → (REVISE면) 재조사 → 비평 → ... → (PASS거나 한도 초과면) 보고서
  1. 재조사 라운드 상한(예: 2회)을 두세요.

힌트

  • SubAgent.responseFormat 을 지정하면 서브에이전트의 structuredResponseJSON 직렬화되어 부모에게 ToolMessage 로 전달됩니다. 부모는 그 JSON 을 읽습니다.
  • 부모 에이전트가 스스로 루프를 돌게 하는 것과, 바깥 코드가 루프를 돌리는 것은 다릅니다. 후자가 훨씬 통제하기 쉽습니다. 어느 쪽이든 좋지만 상한은 반드시 두세요.
  • 재조사를 시킬 때 issues[].researchTopic 을 그대로 task() 지시문에 넣을 수 있게 비평가 프롬프트를 맞춰 두세요 — 이미 CRITIQUE_PROMPT 가 그렇게 되어 있습니다.

채점

  • responseFormat 으로 구조화된 판정을 받는가 (6점)
  • REVISE 시 실제로 재조사가 도는가 (8점)
  • 라운드 상한이 있는가 (3점)
  • PASS 시 불필요한 재조사를 안 하는가 (3점)

⚠️ 비평가가 항상 REVISE 만 뱉어서 무한 루프에 빠지는 것이 흔합니다. 상한과 함께, 마지막 라운드에서는 "지금 있는 것으로 보고서를 써라" 를 명시적으로 지시하세요.


과제 3. 병렬 조사 (15점)

문제

프롬프트에 "한 응답에서 task() 를 여러 번 호출하면 병렬"이라고 써 뒀지만, 모델은 대개 하나씩 순차로 부릅니다. 조사 3개가 순차면 3배 느립니다.

요구사항

여러 주제를 실제로 동시에 조사하게 만드세요. 두 가지 방향이 있습니다.

A안 (에이전트에게 맡기기): 프롬프트를 고쳐 병렬 task() 호출을 유도합니다. 쉽지만 보장이 안 됩니다.

B안 (코드로 강제): 부모 에이전트를 쓰지 말고, 바깥 코드에서 조사 서브에이전트를 직접 여러 개 만들어 Promise.all 로 돌립니다. 결과를 모아 부모에게 종합만 시킵니다.

B안을 권합니다. 확실하니까요.

export async function researchInParallel(
  topics: string[],
  concurrency = 3,
): Promise<Array<{ topic: string; findings: string; files: Record<string, unknown> }>>;
  • 동시 실행 수를 concurrency 로 제한하세요. 10개를 한 번에 띄우면 rate limit 에 걸립니다.
  • 각 조사의 결과 파일을 하나의 files병합해야 합니다.

힌트

  • createDeepAgent 로 조사 전용 에이전트를 만들 수 있습니다. 서브에이전트로 안 쓰고 그냥 독립 에이전트로 쓰는 것입니다.
  • 동시성 제한은 간단히 청크로 잘라 Promise.all 을 순차로 돌리면 됩니다. 세마포어를 직접 짜도 좋습니다.
  • 파일 병합에서 키 충돌을 조심하세요. 두 조사가 같은 슬러그를 쓰면 하나가 덮입니다.
  • Promise.all 은 하나가 reject 하면 전부 버립니다. Promise.allSettled 를 쓰면 일부 실패해도 나머지를 건집니다.

채점

  • 실제로 동시 실행되는가 (소요 시간으로 증명) (7점)
  • 동시성 제한이 있는가 (3점)
  • 파일 병합이 충돌 없이 되는가 (3점)
  • 일부 실패해도 나머지가 살아남는가 (2점)

⚠️ 병렬 실행은 순서를 보장하지 않습니다. 결과 배열의 순서가 topics 순서와 같다고 가정하지 마세요 — Promise.all 은 순서를 지켜 주지만 allSettled 와 섞이거나 청크로 나누면 헷갈리기 시작합니다. 그리고 인용 번호를 매길 때 순서에 의존하면 매 실행마다 번호가 달라집니다.


과제 4. 토큰 예산 상한 (15점)

문제

RESEARCHER_PROMPT 의 "검색 5회 최대"는 부탁입니다. 모델이 8번 검색해도 아무도 안 막습니다.

진짜 상한은 코드로 걸어야 합니다.

요구사항

커스텀 미들웨어로 하드 상한을 거세요.

  1. 도구 호출 횟수 상한: web_search 가 N회를 넘으면 더 이상 실행하지 않고, 모델에게 "예산 소진"을 알리는 ToolMessage 를 돌려주세요.
  2. 누적 토큰 상한: 누적 토큰이 M 을 넘으면 조사를 중단시키세요.
export function createSearchBudgetMiddleware(options: {
  maxSearches: number;
  maxTokens?: number;
});

힌트

  • 미들웨어는 Step 08 — 미들웨어 조합 에서 다뤘습니다. wrapToolCall 훅으로 도구 실행을 가로챌 수 있습니다.
  • langchaincreateMiddleware 헬퍼를 쓰세요. 훅 타입이 전부 추론됩니다. 객체 리터럴에 as 단언을 붙이면 시그니처가 틀려도 통과해 버립니다.
  • wrapToolCallToolMessage | Command 를 돌려줘야 합니다. { content, tool_call_id } 같은 평범한 객체가 아니라 new ToolMessage({ ... }) 입니다.
  • 카운터를 미들웨어 인스턴스의 클로저 변수로 두면, 그 미들웨어를 공유하는 모든 실행이 카운터를 공유해 버립니다. 실행마다 격리하려면 상태(state)에 넣거나 팩토리를 매번 새로 부르세요. 이게 이 과제의 핵심 함정입니다.
  • 예산 소진 시 throw 하지 마세요. 문자열로 알려줘야 모델이 "그럼 지금까지 걸로 정리하자"고 판단합니다. throw 하면 루프가 죽습니다.
  • 토큰 카운트는 usage_metadata.input_tokens / output_tokens 에서 읽습니다(스네이크 케이스).

채점

  • N회 초과 시 실제로 도구가 실행되지 않는가 (6점)
  • 예산 소진을 문자열로 알려주는가 (throw 하지 않는가) (4점)
  • 카운터가 실행마다 격리되는가 (3점)
  • 토큰 상한도 동작하는가 (2점)

💡 이 과제를 풀고 나면 "프롬프트는 부탁이고 구조는 강제다"가 몸으로 이해됩니다.


과제 5. 중단 후 재개 (10점)

문제

MemorySaver 는 프로세스 메모리입니다. Ctrl+C 를 누르거나 프로세스가 죽으면 30분간의 조사가 통째로 날아갑니다.

HITL 승인 대기 중에 특히 아픕니다. 사람이 보고서를 검토하러 자리를 비운 사이 프로세스가 죽으면 처음부터 다시 해야 합니다.

요구사항

프로세스를 껐다 켜도 이어서 진행되게 하세요.

# 1회차: 조사하다가 승인 대기 중 Ctrl+C
npx tsx step-12-final-project/ex/resumable.ts --thread my-research-1 "질문"

# 2회차: 같은 thread 로 다시 실행 → 승인 지점부터 이어짐
npx tsx step-12-final-project/ex/resumable.ts --thread my-research-1 --approve-pending
  • thread_id 를 사용자가 지정할 수 있어야 합니다(crypto.randomUUID() 로는 재개가 불가능합니다).
  • 체크포인터를 디스크에 저장되는 것으로 바꾸세요.

힌트

  • @langchain/langgraph-checkpoint-sqliteSqliteSaver 가 표준 선택입니다. 별도 설치가 필요합니다.
  • 설치가 부담되면 BaseCheckpointSaver 를 상속해 JSON 파일로 저장하는 것을 직접 만들어도 됩니다. (권장하진 않습니다 — 직렬화가 생각보다 까다롭습니다.)
  • 재개 시 아무 입력 없이 agent.invoke(null, config) 로 호출하면 마지막 지점부터 이어집니다. 인터럽트 대기 중이면 Command 로 재개합니다.
  • 현재 상태를 확인하려면 agent.getState(config) 를 쓰세요. next 필드로 어디서 멈춰 있는지 알 수 있습니다.

채점

  • thread_id 를 사용자가 지정할 수 있는가 (3점)
  • 프로세스 재시작 후 실제로 이어지는가 (5점)
  • 멈춰 있는 지점을 확인할 수 있는가 (2점)

⚠️ 함정: thread_id 는 같은데 체크포인터 인스턴스가 다르면 재개가 안 됩니다. MemorySaver 를 그대로 두고 thread_id 만 고정하면 여전히 아무것도 안 됩니다 — 프로세스가 죽으면서 메모리가 사라졌으니까요. 둘 다 고쳐야 합니다.


과제 6. 다른 도메인으로 이식 (10점)

문제

이 아키텍처(계획 → 위임 → 비평 → 종합)는 리서치 전용이 아닙니다. 하지만 지금 프롬프트에는 "리서치"가 하드코딩되어 있습니다.

요구사항

리서치가 아닌 것을 같은 아키텍처로 만드세요. 예시 중 하나를 고르거나 직접 정하세요.

도메인조사 서브에이전트비평 서브에이전트산출물
코드 리뷰파일을 읽고 문제를 찾음지적이 타당한지 심사/review.md
여행 계획장소/가격/이동을 조사동선이 현실적인지 심사/itinerary.md
경쟁사 분석제품/가격/포지셔닝 조사근거가 충분한지 심사/analysis.md
회의록 정리발언에서 액션 아이템 추출누락/오해가 없는지 심사/minutes.md

최소 요구

  • 도구를 그 도메인에 맞게 바꿀 것 (코드 리뷰라면 read_file/grep, 검색 아님)
  • 프롬프트 3개를 다시 쓸 것
  • 파일 레이아웃을 다시 설계할 것 (/question.txt 에 해당하는 기준점이 무엇인가?)

힌트

  • 코드 리뷰를 고른다면 FilesystemBackend({ rootDir }) 로 진짜 코드를 읽게 할 수 있습니다. 이때 permissions읽기 전용을 강제하세요 — 리뷰어가 코드를 고치면 안 됩니다.
  • "기준점 파일"이 무엇인지 고민하는 게 이 과제의 핵심입니다. 리서치에서는 원 질문이었습니다. 코드 리뷰라면 리뷰 대상 범위와 기준일 것입니다.
  • 비평가에게 조사 도구를 주지 않는 원칙은 도메인이 바뀌어도 그대로입니다.

채점

  • 도구가 도메인에 맞게 바뀌었는가 (3점)
  • 프롬프트 3개가 도메인에 맞게 다시 쓰였는가 (3점)
  • 파일 레이아웃과 기준점이 합리적인가 (2점)
  • 비평가에게 불필요한 권한을 안 줬는가 (2점)

과제 7. 평가 하네스 (10점)

문제

"프롬프트를 고쳤더니 좋아졌다"를 어떻게 압니까? 지금은 사람이 보고서를 읽고 느낌으로 판단합니다. 그건 개선이 아니라 도박입니다.

모델 출력은 비결정적이라 문자열 비교는 불가능합니다. 하지만 불변식은 검사할 수 있습니다.

요구사항

질문 세트를 돌려 품질 지표를 뽑는 하네스를 만드세요.

export interface EvalResult {
  question: string;
  passed: boolean;
  checks: Record<string, boolean>;
  metrics: {
    reportLength: number;
    sourceCount: number;
    citationCount: number;
    findingsCount: number;
    durationMs: number;
  };
}

export async function runEval(questions: string[]): Promise<EvalResult[]>;

반드시 포함할 검사 (전부 결정적입니다)

검사통과 조건
report_exists/report.md 가 존재
has_sources### 출처 섹션 존재
no_ghost_citations본문의 모든 [n] 이 출처 목록에 있음
no_hallucinated_urls출처 URL 이 전부 findings 에 있음
question_saved/question.txt 가 존재
findings_exist/findings/ 에 파일이 1개 이상
no_self_reference"제가 조사한", "찾아보니" 같은 표현 없음
  • 결과를 표로 출력하고, 통과율을 계산하세요.
  • 목 검색으로 돌리세요. 그래야 검색 결과가 고정되어 프롬프트 변경의 효과만 볼 수 있습니다.

힌트

  • 과제 1의 verifyCitations 를 재사용하세요.
  • 모델은 비결정적이므로 같은 질문을 3회 돌려 평균을 내는 게 더 정직합니다. 1회 결과로 "좋아졌다"고 하면 그냥 운입니다.
  • durationMs 와 토큰을 같이 재세요. 품질이 올랐는데 비용이 3배면 개선이 아닐 수 있습니다.
  • 질문 세트는 유형을 섞으세요: 단순 사실("RAG가 뭔가"), 비교("RAG vs 파인튜닝"), 목록("비용 절감 기법들").

채점

  • 7가지 검사가 전부 동작하는가 (5점)
  • 목 검색으로 돌려 재현 가능한가 (2점)
  • 통과율과 지표를 표로 보여주는가 (2점)
  • 반복 실행으로 비결정성을 다루는가 (1점)

💡 이 하네스가 있으면 나머지 과제들의 효과를 숫자로 증명할 수 있습니다. 과제 1을 적용하기 전후로 no_ghost_citations 통과율이 얼마나 오르는지 재 보세요.


과제 8. 웹 UI (5점)

문제

CLI 는 조사가 끝날 때까지 아무것도 안 보여줍니다. 3분간 커서만 깜빡입니다. 사용자는 죽은 줄 압니다.

요구사항

진행 상황을 실시간으로 보여주는 최소한의 웹 UI 를 만드세요.

최소 요구

  • 질문 입력 → 실행
  • 진행 상황 스트리밍 (지금 어느 서브에이전트가 뭘 하는지)
  • 최종 보고서 렌더링
  • HITL 승인 버튼

프레임워크는 자유입니다. Node 내장 http + SSE 로도 충분합니다.

힌트

  • agent.streamEvents(input, { version: "v3" }) 로 이벤트를 받습니다. Step 11 — 스트리밍과 프로덕션 참고.
  • run.subagents 스트림으로 서브에이전트 활동을 따로 잡을 수 있습니다. 이게 UI 의 핵심입니다 — "research-subagent 가 검색 중"을 보여줄 수 있습니다.
  • HITL 승인을 웹으로 하려면 인터럽트 상태를 서버가 들고 있어야 합니다. 과제 5의 영구 체크포인터와 궁합이 좋습니다.
  • 정식 프로덕션이라면 LangSmith Deployments + useStream 훅이 정답입니다. 이 과제는 그 전에 원리를 이해하는 것이 목적입니다.

채점

  • 진행 상황이 실시간으로 보이는가 (2점)
  • 서브에이전트 활동을 구분해 보여주는가 (1점)
  • 보고서가 렌더링되는가 (1점)
  • 승인 버튼이 동작하는가 (1점)

⚠️ 함정: 스트리밍 중 도구 호출 인자는 조각나서 옵니다. 부분 JSON 을 그때그때 JSON.parse 하면 터집니다. 조각을 다 모은 뒤에 파싱하거나, 아예 완성된 이벤트만 UI 에 쓰세요.


제출 & 자기채점

과제제출물
1verifyCitations 코드 + 5가지 위반을 잡아내는 테스트
2루프 코드 + REVISE → 재조사가 도는 실행 로그
3병렬 코드 + 순차 대비 소요 시간 비교
4미들웨어 코드 + 상한 초과 시 차단되는 로그
5재개 코드 + 프로세스 재시작 후 이어지는 로그
6이식한 도메인의 프롬프트 3개 + 실행 결과
7하네스 코드 + 통과율 표
8UI 코드 + 스크린샷

총 100점. 60점 이상이면 이 코스를 완주한 것입니다.

실행 로그를 붙일 때는 비결정적인 부분을 명시하세요. "이 로그는 모델 응답이라 매번 다릅니다"라고 적는 습관이, 나중에 여러분의 팀이 여러분 코드를 믿을지 말지를 결정합니다.


강의로 돌아가기 · 해설 보기