index.md 에서 만든 딥 리서치 에이전트를 확장합니다.
이 과제들은 문제를 위한 문제가 아닙니다. 12-10 에서 정리한 "지금 이 프로젝트에 남아 있는 구멍" 과 1:1로 대응합니다. 즉 여러분이 지금 실제로 고쳐야 하는 것들입니다.
정답과 해설은 solutions.md 에 있습니다. 먼저 스스로 풀고 나서 여세요.
규칙
- 원본 파일(
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점.
지금은 프롬프트로 "인용을 달아라"고 부탁할 뿐입니다. 모델이 어기면 그만입니다. 실제로 자주 어깁니다.
세 가지 방식으로 어깁니다.
[n] 이 없음[3] 을 썼는데 출처 목록에 3번이 없음3번이 가장 위험합니다. 그럴듯한 URL 이라 사람이 안 의심합니다.
/report.md 를 기계적으로 검증하는 함수를 만들고, 검증에 실패하면 에이전트가 다시 쓰게 하세요.
ghost_citation: 본문의 [n] 이 출처 목록에 없음unused_source: 출처 목록에 있는데 본문에서 안 쓰임hallucinated_url: 출처 URL 이 allowedUrls 에 없음no_sources_section: ### 출처 섹션 자체가 없음gap_in_numbering: 출처 번호가 1,2,4 처럼 건너뜀그리고 findings 파일들에서 실제 URL 을 긁어모으는 함수도 필요합니다.
/\[(\d+)\]/g 로 뽑되, 출처 섹션은 제외해야 합니다. 안 그러면 출처 목록의 [1] 자체가 본문 인용으로 잡힙니다.[1] 제목: https://... 형식이므로 /^\[(\d+)\]\s*(.+?):\s*(https?:\S+)/gm.allowedUrls 는 /findings/ 로 시작하는 파일만 대상으로 모으세요. /report.md 를 포함하면 자기 자신을 근거로 삼는 순환이 생깁니다.재작성 루프에는 상한을 두세요. 모델이 계속 실패하면 무한 루프가 됩니다.
지금 비평가는 REVISE 를 외쳐도 아무 일도 일어나지 않습니다. 부모가 그 문자열을 읽고 "알아서 판단"할 뿐입니다. 프롬프트에 "구멍은 다시 조사시켜라"고 써 뒀지만, 이건 부탁입니다.
게다가 판정이 자유 문자열이라 부모가 파싱해야 합니다. "판정: REVISE" 인지 "REVISE 입니다" 인지 "수정이 필요합니다" 인지에 따라 파싱이 깨집니다.
responseFormat 을 줘서 구조화된 판정을 받으세요.SubAgent.responseFormat 을 지정하면 서브에이전트의 structuredResponse 가 JSON 직렬화되어 부모에게 ToolMessage 로 전달됩니다. 부모는 그 JSON 을 읽습니다.issues[].researchTopic 을 그대로 task() 지시문에 넣을 수 있게 비평가 프롬프트를 맞춰 두세요 — 이미 CRITIQUE_PROMPT 가 그렇게 되어 있습니다.responseFormat 으로 구조화된 판정을 받는가 (6점)⚠️ 비평가가 항상 REVISE 만 뱉어서 무한 루프에 빠지는 것이 흔합니다. 상한과 함께, 마지막 라운드에서는 "지금 있는 것으로 보고서를 써라" 를 명시적으로 지시하세요.
프롬프트에 "한 응답에서 task() 를 여러 번 호출하면 병렬"이라고 써 뒀지만, 모델은 대개 하나씩 순차로 부릅니다. 조사 3개가 순차면 3배 느립니다.
여러 주제를 실제로 동시에 조사하게 만드세요. 두 가지 방향이 있습니다.
A안 (에이전트에게 맡기기): 프롬프트를 고쳐 병렬 task() 호출을 유도합니다. 쉽지만 보장이 안 됩니다.
B안 (코드로 강제): 부모 에이전트를 쓰지 말고, 바깥 코드에서 조사 서브에이전트를 직접 여러 개 만들어 Promise.all 로 돌립니다. 결과를 모아 부모에게 종합만 시킵니다.
B안을 권합니다. 확실하니까요.
concurrency 로 제한하세요. 10개를 한 번에 띄우면 rate limit 에 걸립니다.files 로 병합해야 합니다.createDeepAgent 로 조사 전용 에이전트를 만들 수 있습니다. 서브에이전트로 안 쓰고 그냥 독립 에이전트로 쓰는 것입니다.Promise.all 을 순차로 돌리면 됩니다. 세마포어를 직접 짜도 좋습니다.Promise.all 은 하나가 reject 하면 전부 버립니다. Promise.allSettled 를 쓰면 일부 실패해도 나머지를 건집니다.⚠️ 병렬 실행은 순서를 보장하지 않습니다. 결과 배열의 순서가
topics순서와 같다고 가정하지 마세요 —Promise.all은 순서를 지켜 주지만allSettled와 섞이거나 청크로 나누면 헷갈리기 시작합니다. 그리고 인용 번호를 매길 때 순서에 의존하면 매 실행마다 번호가 달라집니다.
RESEARCHER_PROMPT 의 "검색 5회 최대"는 부탁입니다. 모델이 8번 검색해도 아무도 안 막습니다.
진짜 상한은 코드로 걸어야 합니다.
커스텀 미들웨어로 하드 상한을 거세요.
web_search 가 N회를 넘으면 더 이상 실행하지 않고, 모델에게 "예산 소진"을 알리는 ToolMessage 를 돌려주세요.wrapToolCall 훅으로 도구 실행을 가로챌 수 있습니다.langchain 의 createMiddleware 헬퍼를 쓰세요. 훅 타입이 전부 추론됩니다. 객체 리터럴에 as 단언을 붙이면 시그니처가 틀려도 통과해 버립니다.wrapToolCall 은 ToolMessage | Command 를 돌려줘야 합니다. { content, tool_call_id } 같은 평범한 객체가 아니라 new ToolMessage({ ... }) 입니다.usage_metadata.input_tokens / output_tokens 에서 읽습니다(스네이크 케이스).💡 이 과제를 풀고 나면 "프롬프트는 부탁이고 구조는 강제다"가 몸으로 이해됩니다.
MemorySaver 는 프로세스 메모리입니다. Ctrl+C 를 누르거나 프로세스가 죽으면 30분간의 조사가 통째로 날아갑니다.
HITL 승인 대기 중에 특히 아픕니다. 사람이 보고서를 검토하러 자리를 비운 사이 프로세스가 죽으면 처음부터 다시 해야 합니다.
프로세스를 껐다 켜도 이어서 진행되게 하세요.
thread_id 를 사용자가 지정할 수 있어야 합니다(crypto.randomUUID() 로는 재개가 불가능합니다).@langchain/langgraph-checkpoint-sqlite 의 SqliteSaver 가 표준 선택입니다. 별도 설치가 필요합니다.BaseCheckpointSaver 를 상속해 JSON 파일로 저장하는 것을 직접 만들어도 됩니다. (권장하진 않습니다 — 직렬화가 생각보다 까다롭습니다.)agent.invoke(null, config) 로 호출하면 마지막 지점부터 이어집니다. 인터럽트 대기 중이면 Command 로 재개합니다.agent.getState(config) 를 쓰세요. next 필드로 어디서 멈춰 있는지 알 수 있습니다.thread_id 를 사용자가 지정할 수 있는가 (3점)⚠️ 함정:
thread_id는 같은데 체크포인터 인스턴스가 다르면 재개가 안 됩니다.MemorySaver를 그대로 두고thread_id만 고정하면 여전히 아무것도 안 됩니다 — 프로세스가 죽으면서 메모리가 사라졌으니까요. 둘 다 고쳐야 합니다.
이 아키텍처(계획 → 위임 → 비평 → 종합)는 리서치 전용이 아닙니다. 하지만 지금 프롬프트에는 "리서치"가 하드코딩되어 있습니다.
리서치가 아닌 것을 같은 아키텍처로 만드세요. 예시 중 하나를 고르거나 직접 정하세요.
| 도메인 | 조사 서브에이전트 | 비평 서브에이전트 | 산출물 |
|---|---|---|---|
| 코드 리뷰 | 파일을 읽고 문제를 찾음 | 지적이 타당한지 심사 | /review.md |
| 여행 계획 | 장소/가격/이동을 조사 | 동선이 현실적인지 심사 | /itinerary.md |
| 경쟁사 분석 | 제품/가격/포지셔닝 조사 | 근거가 충분한지 심사 | /analysis.md |
| 회의록 정리 | 발언에서 액션 아이템 추출 | 누락/오해가 없는지 심사 | /minutes.md |
최소 요구
read_file/grep, 검색 아님)/question.txt 에 해당하는 기준점이 무엇인가?)FilesystemBackend({ rootDir }) 로 진짜 코드를 읽게 할 수 있습니다. 이때 permissions 로 읽기 전용을 강제하세요 — 리뷰어가 코드를 고치면 안 됩니다."프롬프트를 고쳤더니 좋아졌다"를 어떻게 압니까? 지금은 사람이 보고서를 읽고 느낌으로 판단합니다. 그건 개선이 아니라 도박입니다.
모델 출력은 비결정적이라 문자열 비교는 불가능합니다. 하지만 불변식은 검사할 수 있습니다.
질문 세트를 돌려 품질 지표를 뽑는 하네스를 만드세요.
반드시 포함할 검사 (전부 결정적입니다)
| 검사 | 통과 조건 |
|---|---|
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 | "제가 조사한", "찾아보니" 같은 표현 없음 |
verifyCitations 를 재사용하세요.durationMs 와 토큰을 같이 재세요. 품질이 올랐는데 비용이 3배면 개선이 아닐 수 있습니다.💡 이 하네스가 있으면 나머지 과제들의 효과를 숫자로 증명할 수 있습니다. 과제 1을 적용하기 전후로
no_ghost_citations통과율이 얼마나 오르는지 재 보세요.
CLI 는 조사가 끝날 때까지 아무것도 안 보여줍니다. 3분간 커서만 깜빡입니다. 사용자는 죽은 줄 압니다.
진행 상황을 실시간으로 보여주는 최소한의 웹 UI 를 만드세요.
최소 요구
프레임워크는 자유입니다. Node 내장 http + SSE 로도 충분합니다.
agent.streamEvents(input, { version: "v3" }) 로 이벤트를 받습니다. Step 11 — 스트리밍과 프로덕션 참고.run.subagents 스트림으로 서브에이전트 활동을 따로 잡을 수 있습니다. 이게 UI 의 핵심입니다 — "research-subagent 가 검색 중"을 보여줄 수 있습니다.useStream 훅이 정답입니다. 이 과제는 그 전에 원리를 이해하는 것이 목적입니다.⚠️ 함정: 스트리밍 중 도구 호출 인자는 조각나서 옵니다. 부분 JSON 을 그때그때
JSON.parse하면 터집니다. 조각을 다 모은 뒤에 파싱하거나, 아예 완성된 이벤트만 UI 에 쓰세요.
| 과제 | 제출물 |
|---|---|
| 1 | verifyCitations 코드 + 5가지 위반을 잡아내는 테스트 |
| 2 | 루프 코드 + REVISE → 재조사가 도는 실행 로그 |
| 3 | 병렬 코드 + 순차 대비 소요 시간 비교 |
| 4 | 미들웨어 코드 + 상한 초과 시 차단되는 로그 |
| 5 | 재개 코드 + 프로세스 재시작 후 이어지는 로그 |
| 6 | 이식한 도메인의 프롬프트 3개 + 실행 결과 |
| 7 | 하네스 코드 + 통과율 표 |
| 8 | UI 코드 + 스크린샷 |
총 100점. 60점 이상이면 이 코스를 완주한 것입니다.
실행 로그를 붙일 때는 비결정적인 부분을 명시하세요. "이 로그는 모델 응답이라 매번 다릅니다"라고 적는 습관이, 나중에 여러분의 팀이 여러분 코드를 믿을지 말지를 결정합니다.