실습 환경 (project/)

이 페이지의 목적

  • Step 01~12 의 .ts 파일이 실제로 돌아가는 환경을 만든다
  • 왜 이 패키지 조합인지, 왜 이 tsconfig 인지 이해한다
  • @langchain/core 중복 설치라는 가장 흔한 사고를 미리 막는다

예상 소요: 15분

DeepAgents 코스의 모든 실습 파일은 이 project/ 폴더를 실행 환경으로 씁니다. 스텝 폴더(step-01-why-deep-agents/ 등)는 project/ 바깥에 있지만, 의존성과 타입은 전부 여기 것을 빌려 씁니다. 그래서 설치는 딱 한 번만 하면 됩니다.

MySQL 코스가 Docker 컨테이너 하나를 띄워 놓고 모든 스텝이 거기에 쿼리를 흘려 넣었던 것과 같은 구조입니다. 여기서는 컨테이너 대신 node_modules 가 그 역할을 합니다.


요구 사항

항목버전확인
Node.js22 이상node -v
npm10 이상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 를 실제 키로 바꾸세요

설치가 끝나면 바로 검증합니다. 이 명령이 이 코스에서 가장 중요한 한 줄입니다.

npm run check:core

출력 (이 구조는 결정적입니다 — 이대로 나와야 정상)

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/core1.2.3 하나뿐이라는 사실입니다. 이게 깨졌을 때 무슨 일이 벌어지는지는 아래 함정에서 다룹니다.

⚠️ 함정 (이 코스 전체를 통틀어 1위) — @langchain/core 가 두 벌 설치되면 instanceof 가 조용히 false 가 된다

deepagents, langchain, @langchain/anthropic, @langchain/langgraph 는 모두 @langchain/corepeer dependency 로 요구합니다. 즉 "내가 설치하는 게 아니라 네가 설치한 걸 같이 쓰겠다"는 선언입니다. 그런데 버전 범위가 어긋나면 npm 이 친절하게도 각자에게 다른 사본을 하나씩 더 깔아 줍니다:

node_modules/@langchain/core            ← 1.2.3
node_modules/deepagents/node_modules/@langchain/core   ← 1.1.0  ⚠️ 두 벌!

이러면 AIMessage 클래스가 서로 다른 두 개의 클래스가 됩니다. deepagents 가 만든 AIMessage 를 여러분이 import 한 AIMessageinstanceof 검사하면 에러 없이 그냥 false 가 나옵니다. 메시지 필터링이 조용히 전부 빗나가고, "왜 도구 호출을 못 잡지?" 하며 몇 시간을 태웁니다.

에러가 안 난다는 게 이 함정의 본질입니다. 그래서 npm run check:corededuped 를 눈으로 확인하는 습관이 필요합니다. 이미 꼬였다면:

rm -rf node_modules package-lock.json && npm install

이 코스의 src/lib/print.tsinstanceof 대신 "그 필드가 있는지"만 보는 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 와 백엔드/미들웨어가 전부 여기 있습니다.
  • langchaintool(), createAgent(), createMiddleware() 가 여기 있습니다. deepagents 가 이걸 감싸는 구조라 둘 다 필요합니다(Step 01 의 1-3 참고).
  • @langchain/core — 위 두 패키지의 peer dependency. 직접 명시해야 버전이 한 벌로 고정됩니다. 이걸 빼면 npm 이 알아서 깔아 주긴 하는데, 그때 버전이 갈릴 수 있습니다.
  • @langchain/anthropicmodel: "anthropic:..." 문자열을 실제 모델로 바꿔 주는 provider 어댑터. 문자열만 쓰더라도 이 패키지가 설치돼 있어야 initChatModel 이 찾아냅니다.
  • @langchain/langgraphMemorySaver, 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 대부분이 돌아갑니다. createDeepAgentmodel 을 생략하면 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란 무엇인가