실습 환경 (project/)
이 페이지의 목적
- Step 01~12 의
.ts 파일이 실제로 돌아가는 환경을 만든다
- 왜 이 패키지 조합인지, 왜 이 tsconfig 인지 이해한다
@langchain/core 중복 설치라는 가장 흔한 사고를 미리 막는다
예상 소요: 15분
DeepAgents 코스의 모든 실습 파일은 이 project/ 폴더를 실행 환경으로 씁니다. 스텝 폴더(step-01-why-deep-agents/ 등)는 project/ 바깥에 있지만, 의존성과 타입은 전부 여기 것을 빌려 씁니다. 그래서 설치는 딱 한 번만 하면 됩니다.
MySQL 코스가 Docker 컨테이너 하나를 띄워 놓고 모든 스텝이 거기에 쿼리를 흘려 넣었던 것과 같은 구조입니다. 여기서는 컨테이너 대신 node_modules 가 그 역할을 합니다.
요구 사항
| 항목 | 버전 | 확인 |
|---|
| Node.js | 22 이상 | node -v |
| npm | 10 이상 | npm -v |
| Anthropic API 키 | — | console.anthropic.com |
Node 22 를 요구하는 이유는 두 가지입니다. 하나는 이 코스의 실습 파일이 전부 top-level await 를 쓰기 때문이고(await agent.invoke(...) 를 함수로 감싸지 않고 파일 최상단에서 부릅니다), 다른 하나는 deepagents 가 의존하는 LangChain v1 계열이 Node 20 미만을 지원하지 않기 때문입니다.
설치
cd docs/reference/deepagent/project
npm install
cp .env.example .env
# .env 를 열어 ANTHROPIC_API_KEY 를 실제 키로 바꾸세요
설치가 끝나면 바로 검증합니다. 이 명령이 이 코스에서 가장 중요한 한 줄입니다.
출력 (이 구조는 결정적입니다 — 이대로 나와야 정상)
deepagent-course@1.0.0 /.../docs/reference/deepagent/project
├─┬ @langchain/anthropic@1.5.1
│ └── @langchain/core@1.2.3 deduped
├── @langchain/core@1.2.3
├─┬ @langchain/langgraph@1.4.8
│ ├── @langchain/core@1.2.3 deduped
│ └─┬ @langchain/langgraph-checkpoint@1.1.3
│ └── @langchain/core@1.2.3 deduped
├─┬ deepagents@1.11.0
│ ├── @langchain/core@1.2.3 deduped
│ └── langchain@1.5.3 deduped
└─┬ langchain@1.5.3
└── @langchain/core@1.2.3 deduped
핵심은 deduped 라는 단어와, @langchain/core 가 1.2.3 하나뿐이라는 사실입니다. 이게 깨졌을 때 무슨 일이 벌어지는지는 아래 함정에서 다룹니다.
⚠️ 함정 (이 코스 전체를 통틀어 1위) — @langchain/core 가 두 벌 설치되면 instanceof 가 조용히 false 가 된다
deepagents, langchain, @langchain/anthropic, @langchain/langgraph 는 모두 @langchain/core 를 peer dependency 로 요구합니다. 즉 "내가 설치하는 게 아니라 네가 설치한 걸 같이 쓰겠다"는 선언입니다. 그런데 버전 범위가 어긋나면 npm 이 친절하게도 각자에게 다른 사본을 하나씩 더 깔아 줍니다:
node_modules/@langchain/core ← 1.2.3
node_modules/deepagents/node_modules/@langchain/core ← 1.1.0 ⚠️ 두 벌!
이러면 AIMessage 클래스가 서로 다른 두 개의 클래스가 됩니다. deepagents 가 만든 AIMessage 를 여러분이 import 한 AIMessage 로 instanceof 검사하면 에러 없이 그냥 false 가 나옵니다. 메시지 필터링이 조용히 전부 빗나가고, "왜 도구 호출을 못 잡지?" 하며 몇 시간을 태웁니다.
에러가 안 난다는 게 이 함정의 본질입니다. 그래서 npm run check:core 로 deduped 를 눈으로 확인하는 습관이 필요합니다. 이미 꼬였다면:
rm -rf node_modules package-lock.json && npm install
이 코스의 src/lib/print.ts 가 instanceof 대신 "그 필드가 있는지"만 보는 duck typing 으로 짜여 있는 것도 같은 이유입니다.
실행 방법
스텝 파일은 project/ 안에서 상대경로로 부릅니다.
# project/ 안에서
npx tsx ../step-01-why-deep-agents/practice.ts
npx tsx ../step-02-quickstart/practice.ts
# 저장소 루트에서 부르고 싶다면
npx tsx docs/reference/deepagent/step-02-quickstart/practice.ts
tsx 는 TypeScript 를 트랜스파일만 하고 타입 검사는 건너뜁니다. 덕분에 빠르지만, 타입 오류가 있어도 그냥 돌아갑니다. 타입은 따로 검사하세요.
npm run typecheck # tsc --noEmit — 스텝 폴더의 .ts 까지 전부 검사
파일별 해설
package.json
의존성이 왜 이렇게 묶여 있는지가 핵심입니다.
deepagents — 주인공. createDeepAgent 와 백엔드/미들웨어가 전부 여기 있습니다.
langchain — tool(), createAgent(), createMiddleware() 가 여기 있습니다. deepagents 가 이걸 감싸는 구조라 둘 다 필요합니다(Step 01 의 1-3 참고).
@langchain/core — 위 두 패키지의 peer dependency. 직접 명시해야 버전이 한 벌로 고정됩니다. 이걸 빼면 npm 이 알아서 깔아 주긴 하는데, 그때 버전이 갈릴 수 있습니다.
@langchain/anthropic — model: "anthropic:..." 문자열을 실제 모델로 바꿔 주는 provider 어댑터. 문자열만 쓰더라도 이 패키지가 설치돼 있어야 initChatModel 이 찾아냅니다.
@langchain/langgraph — MemorySaver, InMemoryStore 등. Deep Agent 의 checkpointer/store 가 이걸 씁니다.
zod — 도구 스키마와 responseFormat / contextSchema 를 정의합니다.
"type": "module" 과 engines.node >= 22 가 tsconfig 의 NodeNext 와 짝을 이룹니다.
{
"name": "deepagent-course",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "DeepAgents(TypeScript) 학습 코스 Step 01~12 실습 환경",
"engines": {
"node": ">=22"
},
"scripts": {
"step": "tsx",
"typecheck": "tsc --noEmit",
"check:core": "npm ls @langchain/core"
},
"dependencies": {
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"deepagents": "^1.11.0",
"dotenv": "^17.2.1",
"langchain": "^1.5.3",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^22.15.3",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
}
}
tsconfig.json
moduleResolution: "NodeNext" 가 이 파일의 핵심입니다. deepagents 는 "." / "./browser" / "./node" 세 개의 서브패스 export 를 가지는데(Step 02 의 2-2), 구버전 "node" 해석기는 이 exports 맵을 못 읽어서 deepagents/node 의 타입을 못 찾습니다. NodeNext 로 두면 tsc 가 실제 Node 와 같은 규칙으로 읽으므로, "브라우저 엔트리엔 없는 심볼을 썼다" 같은 실수를 타입 단계에서 잡아 줍니다.
include 에 "../step-*/**/*.ts" 가 있는 것도 눈여겨보세요. 스텝 폴더가 project/ 밖에 있어도 npm run typecheck 한 번으로 전부 검사됩니다.
{
"compilerOptions": {
/* ── 모듈 시스템 ───────────────────────────────────────────────
package.json 의 "type": "module" 과 짝을 이룹니다.
NodeNext 는 Node.js 의 실제 ESM 해석 규칙을 그대로 따라가므로,
"타입체크는 통과했는데 실행하면 못 찾는" 사고를 막아 줍니다.
deepagents 는 "." / "./browser" / "./node" 세 개의 서브패스 export 를
가집니다(Step 02 의 2-2 참고). NodeNext 로 두어야 tsc 가 이 exports 맵을
실제 Node 와 같은 규칙으로 읽고, "deepagents/node 에는 있는데
deepagents/browser 에는 없는" 심볼을 타입 단계에서 잡아 줍니다.
moduleResolution 을 "node"(구버전)로 두면 서브패스 타입을 못 찾습니다. */
"module": "NodeNext",
"moduleResolution": "NodeNext",
/* ── 언어 수준 ────────────────────────────────────────────────
Node 22 는 ES2022 를 전부 지원합니다. top-level await 도 됩니다.
이 코스의 실습 파일은 전부 top-level await 를 씁니다
(agent.invoke 가 비동기이므로). */
"target": "ES2022",
"lib": ["ES2022"],
/* ── 엄격 모드 ────────────────────────────────────────────────
Deep Agent 의 state 는 files / todos 처럼 optional 인 필드가 많습니다.
strict 가 아니면 "todos 가 undefined 인데 .length 를 읽는" 코드가
조용히 통과했다가 런타임에 터집니다. */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
/* ── 실행 편의 ────────────────────────────────────────────────
tsx 는 트랜스파일만 하고 타입체크는 하지 않습니다.
타입 검사는 `npm run typecheck` 로 따로 돌립니다. */
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node"]
},
/* 스텝 폴더의 .ts 파일들은 project/ 밖(../step-NN-slug/)에 있지만,
의존성과 타입은 이 프로젝트 것을 씁니다. 그래서 include 에 넣습니다. */
"include": ["src/**/*.ts", "../step-*/**/*.ts"],
"exclude": ["node_modules"]
}
.env.example
이 파일을 .env 로 복사해서 쓰는 이유는, .env.example 은 커밋하고 .env 는 커밋하지 않기 때문입니다. 예시 파일은 "어떤 키가 필요한지"를 팀에 알려 주는 문서고, 실제 값은 각자 로컬에만 둡니다.
ANTHROPIC_API_KEY 하나만 채우면 Step 01~12 대부분이 돌아갑니다. createDeepAgent 는 model 을 생략하면 Anthropic 모델(claude-sonnet-4-5-20250929)을 기본으로 쓰기 때문입니다.
# ─────────────────────────────────────────────────────────────
# 이 파일을 .env 로 복사한 뒤 실제 키를 채우세요.
#
# cp .env.example .env
#
# .env 는 .gitignore 에 등록되어 있습니다. 절대 커밋하지 마세요.
# 반대로 이 .env.example 은 커밋합니다 — 값이 아니라 "어떤 키가 필요한지"를
# 팀에 알려 주는 문서 역할을 합니다.
# ─────────────────────────────────────────────────────────────
# ── 기본 모델 제공자 (이 코스의 표준) ────────────────────────
# createDeepAgent 는 model 을 생략하면 Anthropic 모델을 기본으로 씁니다.
# 즉 이 키만 있으면 Step 01~12 대부분이 그대로 돌아갑니다.
# 발급: https://console.anthropic.com/settings/keys
# 형식: sk-ant-api03-... (앞뒤 공백/따옴표 없이 그대로 붙여넣기)
ANTHROPIC_API_KEY=sk-ant-api03-여기에-발급받은-키를-붙여넣으세요
# ── 대안 제공자 (선택) ────────────────────────────────────────
# model: "openai:gpt-5.5" 로 바꿔 실습하려면 채우세요 (Step 02 의 2-7 참고).
# 이 경우 @langchain/openai 를 추가로 설치해야 합니다:
# npm install @langchain/openai
# 발급: https://platform.openai.com/api-keys
# OPENAI_API_KEY=sk-proj-...
# ── LangSmith 관측 (선택, Step 11 에서 사용) ──────────────────
# 켜면 모든 모델 호출과 서브에이전트 실행이 LangSmith 대시보드에 기록됩니다.
# Deep Agent 는 내부에서 수십 번씩 모델을 부르므로, 무슨 일이 있었는지
# 보려면 사실상 이게 가장 편한 방법입니다.
# LANGSMITH_TRACING=true
# LANGSMITH_API_KEY=lsv2_pt_...
# LANGSMITH_PROJECT=deepagent-course
.gitignore
.env 차단이 첫 번째 목적입니다. 세 줄의 순서가 중요합니다 — .env.* 로 전부 막은 다음 !.env.example 로 하나만 되돌립니다. 순서를 뒤집으면 예시 파일까지 같이 막힙니다.
workspace/ 와 .deepagents/ 도 막아 뒀습니다. Step 05 에서 FilesystemBackend 를 쓰면 에이전트가 실제 디스크에 파일을 씁니다. 그 결과물이 통째로 커밋되는 사고를 미리 막는 것입니다.
# ── 비밀 정보 (가장 중요) ─────────────────────────────────────
# .env 로 시작하는 모든 파일을 막되, .env.example 만 예외로 되돌립니다.
# 순서가 중요합니다 — 나중 줄이 앞 줄을 덮어씁니다.
.env
.env.*
!.env.example
# 혹시 모를 키 파일들
*.pem
*.key
secrets/
# ── 의존성 / 빌드 산출물 ──────────────────────────────────────
node_modules/
dist/
build/
*.tsbuildinfo
# ── Deep Agent 가 만들어 내는 것들 ────────────────────────────
# FilesystemBackend(Step 05) 로 실습하면 에이전트가 실제 디스크에 파일을 씁니다.
# 그 작업 폴더가 통째로 커밋되지 않도록 막습니다.
workspace/
.deepagents/
# Step 01 연습문제 3번이 시스템 프롬프트를 여기에 덤프합니다.
prompt.txt
# ── 로컬 캐시 / 로그 ──────────────────────────────────────────
.cache/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-error.log*
# ── 에디터 / OS ───────────────────────────────────────────────
.DS_Store
.idea/
.vscode/*
!.vscode/extensions.json
src/lib/print.ts
Deep Agent 의 invoke 결과에는 messages 가 40개씩 들어 있는 게 예사입니다. 그대로 console.log 하면 터미널이 수백 줄로 덮여서 정작 봐야 할 흐름이 묻힙니다. 이 파일은 그걸 한 줄에 하나씩 요약해 줍니다.
printSection("[2-5] 기본 도구 관찰") — 본문 소제목과 1:1 대응하는 구분선. practice.ts 를 통째로 돌렸을 때 "지금 출력이 몇 절 것인지" 찾게 해 줍니다.
printMessages(messages) — 도구 호출은 → 도구 호출: write_todos, 도구 결과는 ← write_todos: ... 로 방향을 표시합니다. Deep Agent 의 "계획 → 실행 → 관찰" 루프가 눈에 보입니다.
printTodos(todos) / printFiles(files) — write_todos 와 가상 파일시스템의 결과를 봅니다. Step 03, Step 04 에서 본격적으로 씁니다.
instanceof 를 쓰지 않습니다. 위 함정에서 본 @langchain/core 중복 문제 때문입니다. 대신 "tool_calls" 필드가 배열인가만 봅니다 — 이 경우엔 duck typing 이 instanceof 보다 튼튼합니다.
/**
* 출력 포맷 헬퍼 — Deep Agents 코스 Step 01~12 공용
*
* Deep Agent 를 invoke 하면 결과 객체 안에 messages 수십 개, files, todos 가
* 한꺼번에 들어 있습니다. 그걸 그냥 console.log 하면 터미널이 수백 줄로
* 덮여서 정작 봐야 할 "무슨 일이 있었는가"가 묻힙니다. 그걸 정리하는 파일입니다.
*
* 사용:
* import { printSection, printMessages } from "../project/src/lib/print.js";
* ^^^
* ESM(NodeNext) 에서는 .ts 파일을 import 할 때도 확장자를 ".js" 로 씁니다.
* TypeScript 가 "컴파일 후의 경로"를 기준으로 해석하기 때문입니다.
* 확장자를 빼거나 ".ts" 로 쓰면 tsc 가 에러를 냅니다.
*/
import type { BaseMessage } from "@langchain/core/messages";
/* ===== 색상 ===== */
// 터미널이 색을 지원하지 않거나 출력이 파일로 리다이렉트되면 색 코드를 빼야
// 깨진 글자(ESC[36m 같은 것)가 안 보입니다.
const useColor = process.stdout.isTTY === true && process.env["NO_COLOR"] === undefined;
const ESC = "\u001b"; // ANSI 이스케이프 시작 문자
const paint = (code: string, s: string): string =>
useColor ? `${ESC}[${code}m${s}${ESC}[0m` : s;
const dim = (s: string) => paint("2", s);
const bold = (s: string) => paint("1", s);
const cyan = (s: string) => paint("36", s);
const green = (s: string) => paint("32", s);
const yellow = (s: string) => paint("33", s);
const magenta = (s: string) => paint("35", s);
/* ===== 섹션 구분선 ===== */
/**
* 본문의 소제목([2-5] 같은 것)에 대응하는 구분선을 찍습니다.
* practice.ts 를 통째로 실행했을 때 "지금 출력이 몇 번 절의 결과인지"를
* 스크롤하며 찾을 수 있게 해 줍니다.
*/
export function printSection(title: string): void {
const line = "─".repeat(60);
console.log(`\n${dim(line)}`);
console.log(` ${bold(cyan(title))}`);
console.log(dim(line));
}
/* ===== 타입 가드 =====
*
* BaseMessage 에는 tool_calls 가 없습니다. 그 필드는 AIMessage 에만 있습니다.
* instanceof AIMessage 로 좁힐 수도 있지만, instanceof 는 @langchain/core 가
* 두 벌 설치되면 조용히 false 가 됩니다(Step 02 의 함정 참고).
* 그래서 여기서는 "그 필드가 실제로 있는지"만 봅니다 — duck typing 이
* 이 경우엔 instanceof 보다 튼튼합니다.
*/
type ToolCallLike = { name?: string; args?: unknown };
function getToolCalls(m: BaseMessage): ToolCallLike[] {
const tc = (m as { tool_calls?: unknown }).tool_calls;
return Array.isArray(tc) ? (tc as ToolCallLike[]) : [];
}
/** content 는 문자열일 수도, 콘텐츠 블록 배열일 수도 있습니다. 텍스트만 뽑습니다. */
function textOf(m: BaseMessage): string {
const c = m.content;
if (typeof c === "string") return c;
if (!Array.isArray(c)) return "";
return c
.map((b) => (typeof b === "string" ? b : ((b as { text?: string }).text ?? "")))
.join("");
}
/** 메시지의 역할 이름. getType() 은 "human" | "ai" | "tool" | "system" 등을 돌려줍니다. */
function roleOf(m: BaseMessage): string {
return typeof m.getType === "function" ? m.getType() : "unknown";
}
function truncate(s: string, n: number): string {
const flat = s.replace(/\s+/g, " ").trim();
return flat.length <= n ? flat : `${flat.slice(0, n)}…`;
}
/* ===== 메시지 목록 ===== */
/**
* 메시지 배열을 한 줄에 하나씩 요약합니다.
*
* [ 3] ai → 도구 호출: write_todos, write_file
* [ 4] tool ← write_todos: Updated todo list to [...]
*
* Deep Agent 의 결과에는 messages 가 40개씩 들어 있는 게 예사라
* 전문을 찍으면 못 읽습니다. 기본은 요약, 필요하면 maxLen 을 키우세요.
*/
export function printMessages(messages: BaseMessage[], maxLen = 100): void {
messages.forEach((m, i) => {
const idx = dim(`[${String(i).padStart(2)}]`);
const role = roleOf(m);
const calls = getToolCalls(m);
if (calls.length > 0) {
const names = calls.map((c) => c.name ?? "?").join(", ");
console.log(`${idx} ${magenta(role.padEnd(6))} → 도구 호출: ${bold(names)}`);
return;
}
// ToolMessage 는 어느 도구의 결과인지가 핵심 정보입니다.
const toolName = (m as { name?: string }).name;
const prefix = role === "tool" && toolName ? `← ${yellow(toolName)}: ` : "";
const body = truncate(textOf(m), maxLen);
console.log(`${idx} ${green(role.padEnd(6))} ${prefix}${body}`);
});
}
/* ===== todos ===== */
/** write_todos 가 만든 계획. shape 은 { content, status } 입니다. */
export type Todo = { content: string; status: "pending" | "in_progress" | "completed" };
const TODO_MARK: Record<Todo["status"], string> = {
pending: "☐",
in_progress: "▶",
completed: "☑",
};
export function printTodos(todos: Todo[] | undefined): void {
if (!todos || todos.length === 0) {
console.log(dim(" (todos 없음 — 에이전트가 계획이 필요 없다고 판단했습니다)"));
return;
}
for (const t of todos) {
console.log(` ${TODO_MARK[t.status]} ${t.content} ${dim(`(${t.status})`)}`);
}
}
/* ===== files ===== */
/**
* 가상 파일시스템의 내용. state 의 files 는
* { [경로]: { content: string | Uint8Array, ... } } 형태입니다.
* (구버전 v1 포맷은 content 가 string[] 이라 둘 다 받아 줍니다.)
*/
export function printFiles(files: Record<string, unknown> | undefined, showContent = false): void {
const paths = Object.keys(files ?? {});
if (paths.length === 0) {
console.log(dim(" (파일 없음)"));
return;
}
for (const p of paths.sort()) {
const raw = (files as Record<string, { content?: unknown }>)[p]?.content;
const text = Array.isArray(raw) ? raw.join("\n") : typeof raw === "string" ? raw : "";
const bytes = typeof raw === "string" || Array.isArray(raw) ? `${text.length}자` : "(바이너리)";
console.log(` ${bold(p)} ${dim(bytes)}`);
if (showContent && text) {
for (const line of text.split("\n")) console.log(dim(` │ ${line}`));
}
}
}
다음 단계
→ Step 01 — Deep Agent란 무엇인가