Step 02 — 첫 Deep Agent

학습 목표

  • deepagents 를 설치하고 peer dependency 가 한 벌인지 검증한다
  • deepagents / deepagents/browser / deepagents/node 세 엔트리포인트를 구분해 고른다
  • createDeepAgent 로 에이전트를 만들고 await 를 어디에 붙여야 하는지 안다
  • CreateDeepAgentParams 17개 옵션 전체의 지도를 그리고, 어느 스텝에서 뭘 배울지 안다
  • 도구를 안 줘도 실리는 내장 도구 8개를 직접 찍어서 확인한다
  • 커스텀 도구를 붙이고, 모델을 바꾸고, stream 으로 실행 흐름을 관찰한다

선행 스텝: Step 01 — Deep Agent란 무엇인가 예상 소요: 60분

Step 01 에서 createDeepAgent 가 "createAgent + 미들웨어 묶음"이라는 걸 봤습니다. 스파이 미들웨어로 도구 8개와 프롬프트 6,979자를 훔쳐보기도 했죠.

이 스텝에서는 그걸 정식으로 다룹니다. 설치부터 시작해서 옵션 17개의 지도를 펼치고, 도구를 붙이고, 모델을 바꾸고, 실행 흐름을 눈으로 봅니다. 새 개념보다 지도를 그리는 게 목적입니다 — 여기서 본 옵션 하나하나가 앞으로 10개 스텝의 목차가 됩니다.


2-1. 설치와 peer dependency

npm install deepagents langchain @langchain/core

세 개를 같이 설치하는 게 핵심입니다. 왜 deepagents 하나로 안 될까요?

deepagents 는 이 패키지들을 peer dependency 로 선언합니다. 직접 찍어 봅시다.

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

const meta = require("deepagents/package.json") as { peerDependencies?: Record<string, string> };
console.log(meta.peerDependencies);

결과 (deepagents@1.11.0 — 결정적입니다)

deepagents 가 요구하는 peer dependency:
  @langchain/core                    ^1.2.0
  @langchain/langgraph               ^1.4.4
  @langchain/langgraph-checkpoint    ^1.1.2
  @langchain/langgraph-sdk           ^1.9.23
  langchain                          ^1.5.0
  langsmith                          ^0.7.1

peer dependency 는 "내가 깔 테니 넌 빠져"가 아니라 "네가 깐 걸 같이 쓰겠다" 는 선언입니다. deepagents 가 만든 AIMessage 와 여러분이 @langchain/core 에서 import 한 AIMessage같은 클래스여야 하기 때문입니다.

설치 직후 반드시 확인할 것

npm ls @langchain/core
deepagent-course@1.0.0
├─┬ @langchain/anthropic@1.5.1
│ └── @langchain/core@1.2.3 deduped
├── @langchain/core@1.2.3
├─┬ 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 하나뿐이라는 게 핵심입니다.

코드로 검증할 수도 있습니다. 여러 패키지 입장에서 각각 @langchain/core 를 resolve 해 보고, 경로가 전부 같으면 한 벌입니다.

const coreResolvedFrom = ["deepagents", "langchain", "@langchain/anthropic"].map((from) => {
  const req = createRequire(require.resolve(`${from}/package.json`));
  return req.resolve("@langchain/core/package.json");
});
const uniquePaths = [...new Set(coreResolvedFrom)];
console.log(`서로 다른 사본 개수: ${uniquePaths.length}`);

결과

@langchain/core 버전: 1.2.3
서로 다른 사본 개수: 1
  ✅ 한 벌입니다 — instanceof 가 정상 동작합니다.

⚠️ 함정 — @langchain/core 가 두 벌이면 instanceof 가 조용히 false 가 된다

peer 버전 범위가 어긋나면 npm 이 친절하게도 사본을 하나 더 깔아 줍니다.

node_modules/@langchain/core                          ← 1.2.3
node_modules/deepagents/node_modules/@langchain/core  ← 1.1.0  ⚠️

이러면 AIMessage서로 다른 두 개의 클래스가 됩니다. deepagents 가 만든 메시지를 여러분이 import 한 클래스로 instanceof 검사하면 에러 없이 그냥 false 입니다.

const last = result.messages.at(-1);
if (last instanceof AIMessage) {   // ← 항상 false
  console.log("도구 호출:", last.tool_calls);  // ← 절대 실행 안 됨
}

조건문이 통째로 죽는데 에러는 없습니다. "왜 도구 호출을 못 잡지?" 하며 몇 시간을 태웁니다.

해결: rm -rf node_modules package-lock.json && npm install. 예방: @langchain/corepackage.json직접 명시하세요. 그래야 npm 이 하나로 고정합니다. 회피: 라이브러리를 만든다면 instanceof 대신 필드 존재 여부(duck typing)로 검사하세요. 이 코스의 src/lib/print.ts 가 그렇게 짜여 있습니다.


2-2. 엔트리포인트 3종

deepagents 는 서브패스 export 를 세 개 가집니다.

import 경로용도
deepagents기본. Node 에서 그냥 이걸 쓰면 됩니다
deepagents/browser브라우저·엣지 런타임. Node 전용 심볼이 빠져 있습니다
deepagents/node명시적 Node. 번들러가 헷갈릴 때

말로는 안 와닿으니 직접 세어 봅시다.

const mainMod = await import("deepagents");
const browserMod = await import("deepagents/browser");
const nodeMod = await import("deepagents/node");

const main = Object.keys(mainMod).sort();
const browser = Object.keys(browserMod).sort();
const browserSet = new Set(browser);
const missingInBrowser = main.filter((k) => !browserSet.has(k));
console.log(missingInBrowser);

결과 (결정적입니다)

deepagents         : export 50개
deepagents/browser : export 41개
deepagents/node    : export 50개

deepagents 에는 있는데 /browser 에는 없는 것 (9개):
  - FilesystemBackend
  - LocalShellBackend
  - SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY
  - createAgentMemoryMiddleware
  - createSettings
  - createSubAgent
  - findProjectRoot
  - listSkills
  - parseSkillMetadata

deepagents 와 /node 의 차이: 0개

빠진 9개의 공통점이 보이시나요? 전부 실제 디스크나 프로세스를 건드리는 것들입니다. FilesystemBackend(실제 파일 읽기/쓰기), LocalShellBackend(셸 실행), findProjectRoot(경로 탐색), listSkills(디렉터리 스캔)… 브라우저에는 fs 모듈이 없으니 당연히 뺀 것입니다.

반대로 StateBackend(메모리 안의 가상 파일시스템)와 StoreBackend양쪽에 다 있습니다. 디스크를 안 쓰니까요.

선택 기준

상황고를 것
Node 서버, CLI, 스크립트deepagents
브라우저, Cloudflare Workers, Vercel Edgedeepagents/browser
Node 인데 번들러가 browser 필드를 잘못 골라 fs 관련 에러가 날 때deepagents/node

Node 환경에서 deepagentsdeepagents/node 는 export 가 완전히 같습니다 (차이 0개). 그래서 평소엔 짧은 쪽을 쓰면 됩니다.

⚠️ 함정 — 번들러가 여러분 몰래 browser 엔트리를 고른다

deepagentspackage.json 을 열어 보면 "." 안에 이런 게 있습니다.

".": {
  "browser": "./dist/browser.js",
  "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}

"browser" 조건이 먼저 걸려 있습니다. webpack/vite 같은 번들러는 기본적으로 browser 조건을 우선하므로, 여러분이 import { FilesystemBackend } from "deepagents" 라고 써도 번들러가 browser.js 를 물어 옵니다. 그럼 FilesystemBackendundefined 가 되고, 런타임에 이렇게 터집니다.

TypeError: FilesystemBackend is not a constructor

import 문은 멀쩡하고 타입 체크도 통과합니다 — tsc 는 index.d.ts 를 보는데 번들러는 browser.js 를 물어 오기 때문입니다. 타입과 런타임이 서로 다른 파일을 보는 상태라 특히 찾기 어렵습니다.

Node 용 번들을 만드는데 이 에러가 나면 deepagents/node 로 명시하세요. 조건부 해석을 우회합니다.

💡 실무 팁 — tsconfig 의 moduleResolutionNodeNext

구버전 "moduleResolution": "node"exports 맵을 아예 못 읽습니다. 그래서 import { FilesystemBackend } from "deepagents/node" 라고 쓰면 Cannot find module 'deepagents/node' or its corresponding type declarations 가 납니다. 모듈은 멀쩡히 있는데도요.

NodeNext 로 두면 tsc 가 Node 와 같은 규칙으로 exports 를 읽습니다. 이 코스의 project/tsconfig.json 이 그렇게 되어 있습니다.


2-3. createDeepAgent 첫 실행

이제 만들어 봅시다.

import { createDeepAgent } from "deepagents";
import { tool } from "langchain";
import * as z from "zod";

const getWeather = tool(({ city }: { city: string }) => `${city}는 언제나 맑음!`, {
  name: "get_weather",
  description: "주어진 도시의 날씨를 알려 줍니다",
  schema: z.object({ city: z.string().describe("도시 이름") }),
});

const agent = await createDeepAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather],
  systemPrompt: "당신은 친절한 날씨 안내원입니다. 한국어로 답하세요.",
});

const result = await agent.invoke({
  messages: [{ role: "user", content: "도쿄 날씨 알려 줘" }],
});

console.log(result.messages.at(-1)?.text);

출력 예시 (모델 응답이므로 매번 다릅니다)

[ 0] human  도쿄 날씨 알려 줘
[ 1] ai     → 도구 호출: get_weather
[ 2] tool   ← get_weather: 도쿄는 언제나 맑음!
[ 3] ai     도쿄는 언제나 맑습니다! 오늘 나들이하기 좋은 날씨네요. ☀️

최종 답변: 도쿄는 언제나 맑습니다! 오늘 나들이하기 좋은 날씨네요. ☀️

await 는 어디에 붙이는가

공식 문서는 await createDeepAgent({...}) 로 씁니다. 이 코스도 그 표기를 따릅니다. 그런데 Step 01 의 1-4 에서 봤듯 deepagents@1.11.0 에서 이 함수는 사실 동기입니다.

const ret = createDeepAgent({ model: MODEL });
console.log(ret instanceof Promise);   // false
console.log(ret.constructor.name);     // "ReactAgent"

그래도 await 를 붙이세요. Promise 가 아닌 값에 await 를 붙이면 JS 는 그냥 그 값을 돌려주므로 손해가 없고, 문서 표기와 같아지고, 향후 버전이 실제로 비동기가 되어도 안 깨집니다.

⚠️ 함정 — 진짜 위험한 건 invoke 앞의 await

createDeepAgentawait 는 있으나 없으나 그만이지만, agent.invoke() 는 진짜 비동기입니다. 여기서 await 를 빠뜨리면:

const result = agent.invoke({ messages: [...] });   // ← await 없음
console.log(result.messages.at(-1)?.text);
// TypeError: Cannot read properties of undefined (reading 'at')

resultPromise 라서 .messagesundefined 입니다. 에러 메시지가 invoke 를 가리키지 않고 한참 아래 줄을 가리켜서 헷갈립니다.

더 고약한 건 await 없이 invoke 를 부르고 결과를 안 쓰는 경우입니다. 에이전트는 백그라운드에서 조용히 돌면서 토큰을 태우고, 예외가 나면 unhandled rejection 으로 프로세스를 죽입니다. 에러 위치는 당연히 엉뚱한 곳입니다.

규칙은 간단합니다. createDeepAgent 에 붙이는 await 는 예의, invoke / stream 에 붙이는 await 는 필수.


2-4. CreateDeepAgentParams — 옵션 17개 전체 지도

createDeepAgent 가 받는 옵션은 17개이고, 전부 optional 입니다. createDeepAgent({}) 도 됩니다.

이 표가 이 스텝의 핵심이자 앞으로 10개 스텝의 목차입니다.

옵션한 줄 설명다루는 곳
model쓸 모델. "provider:model" 문자열 또는 모델 인스턴스2-7
tools붙일 커스텀 도구. 내장 8개에 더해진다2-6
systemPrompt커스텀 지침. 내장 프롬프트 에 붙는다Step 07
subagents위임용 서브에이전트 목록Step 06
middleware표준 미들웨어 에 덧붙일 미들웨어Step 08
backend파일이 실제로 저장될 곳. 기본은 StateBackendStep 05
permissions파일시스템 경로별 접근 제어(glob 규칙)Step 05
interruptOn실행 전 사람 승인이 필요한 도구 지정Step 09
checkpointer실행 간 상태 저장. 대화를 이어가려면 필수Step 10
store스레드를 넘는 장기 메모리 저장소Step 10
memory시작 시 읽어들일 AGENTS.md 경로 목록Step 10
skills필요할 때만 불러올 SKILL.md 디렉터리Step 10
responseFormat구조화된 출력 스키마(zod)Step 11
contextSchema실행마다 주입할 런타임 컨텍스트(userId, API 키 등)Step 11
stateSchemamessages/todos/files 외에 추가할 커스텀 상태Step 11
streamTransformers스트림 변환기. streamEvents(..., { version: "v3" }) 에서 노출Step 11
name에이전트 이름

헷갈리기 쉬운 세 쌍

stateSchema vs contextSchema — 둘 다 zod 스키마를 받지만 수명이 다릅니다.

stateSchemacontextSchema
지속checkpointer 가 있으면 실행 간 유지실행 1회 한정
용도대화 기록, 누적 결과userId, API 키, 기능 플래그
접근미들웨어/훅에서 state 로도구 안에서 runtime.context

checkpointer vs store — 전자는 한 스레드 안의 상태를(대화 이어가기), 후자는 스레드를 넘는 기억을(어제 대화 기억) 담당합니다.

memory vs skills — 둘 다 마크다운 파일을 읽지만, memory(AGENTS.md)는 시작할 때 항상 로드되고, skills(SKILL.md)는 필요할 때만 로드됩니다. 후자를 progressive disclosure 라고 부릅니다. 항상 로드하면 컨텍스트를 먹으니까요 — Step 01 의 1-5 에서 본 유한 자원 문제입니다.

💡 실무 팁 — 17개를 다 외우려 하지 마세요

실무에서 처음 만드는 Deep Agent 는 보통 3~4개만 씁니다.

const agent = await createDeepAgent({
  model,          // 거의 항상
  tools,          // 거의 항상
  systemPrompt,   // 거의 항상
  subagents,      // 작업이 커지면
});

나머지는 문제가 생겼을 때 하나씩 켭니다. 대화가 안 이어지면 checkpointer, 파일을 실제 디스크에 쓰고 싶으면 backend, 위험한 도구가 있으면 interruptOn. 이 표는 외우는 게 아니라 문제가 생겼을 때 찾아보는 색인입니다.


2-5. 기본 제공 도구 관찰

Step 01 의 스파이 미들웨어를 다시 씁니다. tools하나도 안 주고 무엇이 실리는지 봅시다.

import { createMiddleware } from "langchain";
import { AIMessage } from "@langchain/core/messages";

function createSpy(sink: { tools: string[] }) {
  return createMiddleware({
    name: "Spy",
    wrapModelCall: async (request) => {
      sink.tools = (request.tools ?? []).map((t) => (t as { name: string }).name);
      return new AIMessage("(가로챔)");   // handler 를 안 부른다 = 모델 호출 0회
    },
  });
}

const sink = { tools: [] as string[] };
const a = createDeepAgent({ model: MODEL, middleware: [createSpy(sink)] });
await a.invoke({ messages: [{ role: "user", content: "안녕" }] });
console.log(sink.tools.length, sink.tools.sort());

결과 (결정적입니다)

tools 를 안 줬는데 실린 도구: 8개

  edit_file      파일 부분 수정(문자열 치환)
  glob           패턴으로 파일 찾기
  grep           파일 내용 검색
  ls             디렉터리 목록
  read_file      파일 읽기
  task           서브에이전트 띄우기
  write_file     파일 쓰기
  write_todos    할 일 목록 작성/갱신

도구 8개의 정체

도구하는 일어느 미들웨어가기둥
ls디렉터리 목록FilesystemMiddleware2
read_file파일 읽기 (페이지네이션 지원)FilesystemMiddleware2
write_file파일 생성FilesystemMiddleware2
edit_file문자열 치환으로 부분 수정FilesystemMiddleware2
glob패턴으로 파일 찾기 (**/*.ts)FilesystemMiddleware2
grep파일 내용 검색FilesystemMiddleware2
task서브에이전트 띄우기SubAgentMiddleware3
write_todos할 일 목록 작성/갱신TodoListMiddleware1

execute(셸 실행)는 목록에 없습니다. 문서에는 내장 도구로 나오지만 샌드박스 백엔드를 쓸 때만 생깁니다. 기본 StateBackend 는 메모리 안의 가상 파일시스템이라 실행할 셸이 없습니다. Step 05 에서 다룹니다.

⚠️ 함정 — 내장 도구를 끄는 옵션은 없다

17개 옵션을 다시 보세요. tools 는 있어도 disableTools 같은 건 없습니다.createDeepAgent 를 쓰는 한 이 8개는 항상 실립니다.

"우리 에이전트는 파일을 쓰면 안 되는데" 같은 요구가 있어도 옵션으로는 못 끕니다. 방법은 두 가지입니다.

  1. permissions 로 막기 — 도구는 남아 있되 실행이 거부됩니다. 모델은 여전히 시도하고 매번 거부 메시지를 받습니다(토큰 낭비). Step 05
  2. createAgent 로 내려가 직접 조립createFilesystemMiddleware 를 빼고 todoListMiddleware 만 붙입니다. 진짜로 없앨 수 있습니다. Step 08

createDeepAgent묶음 상품입니다. 낱개로 사려면 한 층 내려가야 합니다. 이게 Step 01 에서 "하네스일 뿐"이라는 걸 강조한 실용적 이유입니다.


2-6. 커스텀 도구 추가

내 도구는 내장 8개를 대체하지 않고 더해집니다.

const searchDocs = tool(
  async ({ query }: { query: string }) => {
    return `"${query}" 검색 결과: (예시) LangGraph 는 상태 그래프 기반 프레임워크입니다.`;
  },
  {
    name: "search_docs",
    description:
      "사내 기술 문서를 검색합니다. 프레임워크 사용법이나 API 를 물어볼 때 쓰세요.",
    schema: z.object({ query: z.string().describe("검색어") }),
  },
);

const agent = await createDeepAgent({
  model: MODEL,
  tools: [getWeather, searchDocs],
});

결과 (결정적)

내장 8개 + 커스텀 2개 = 10개
  edit_file, get_weather, glob, grep, ls, read_file, search_docs, task, write_file, write_todos

새로 늘어난 것: get_weather, search_docs

tool() 의 세 요소를 짚고 갑시다.

요소역할
name모델이 부를 이름. snake_case 관례
description모델이 읽는 설명. 언제 쓸지 판단하는 유일한 근거
schemazod 스키마. 인자 모양 + .describe() 로 각 인자 설명

⚠️ 함정 — description 이 곧 프롬프트다

description 을 개발자 주석처럼 대충 쓰면 모델이 그 도구를 안 부릅니다. 에러는 없습니다. 그냥 도구가 없는 것처럼 행동하고, 아는 대로 지어내서 답합니다.

// ❌ 나쁨 — 모델이 언제 써야 할지 모른다
description: "검색"

// ✅ 좋음 — 언제 쓸지가 적혀 있다
description: "사내 기술 문서를 검색합니다. 프레임워크 사용법이나 API 를 물어볼 때 쓰세요."

좋은 description 은 "무엇을 하는가"가 아니라 "언제 써야 하는가" 를 적습니다. 모델이 "지금이 이 도구를 쓸 때인가?"를 판단해야 하기 때문입니다. 필요하면 쓰지 말아야 할 때도 적으세요.

Deep Agent 의 내장 프롬프트가 task 하나 설명하는 데 2,000자 넘게 쓰는 것도 같은 이유입니다(Step 01 연습문제 3번).

모델이 도구를 안 부르면, 모델을 탓하기 전에 description 부터 고치세요.

💡 실무 팁 — 커스텀 도구는 내장 도구와 겹치지 않게

내장 8개가 항상 실린다는 걸 잊고 read_file 이라는 커스텀 도구를 만들면 이름이 충돌합니다. 비슷한 일을 하는 도구가 둘이면 모델은 헷갈려서 엉뚱한 걸 부릅니다.

도구를 만들기 전에 2-5 의 8개 목록을 확인하고, 겹치면 이름을 다르게(read_s3_file 등) 지으세요.


2-7. 모델 교체

세 가지 방법이 있습니다.

// (A) "provider:model" 문자열 — 가장 간단
const byString = createDeepAgent({ model: "anthropic:claude-sonnet-4-6" });

// (B) initChatModel — temperature 같은 파라미터를 줄 때
import { initChatModel } from "langchain/chat_models/universal";
const tuned = await initChatModel("anthropic:claude-sonnet-4-6", { temperature: 0 });
const byInit = createDeepAgent({ model: tuned });

// (C) 클래스 직접 생성 — provider 고유 옵션까지 다 쓸 때
import { ChatAnthropic } from "@langchain/anthropic";
const direct = new ChatAnthropic({ model: "claude-sonnet-4-6", maxTokens: 4096 });
const byClass = createDeepAgent({ model: direct });

// (D) 생략 — Anthropic 기본 모델
const byDefault = createDeepAgent({});

결과

(A) 문자열        : ReactAgent 생성됨
(B) initChatModel : ReactAgent 생성됨 (temperature: 0)
(C) 클래스 직접   : ReactAgent 생성됨 (maxTokens: 4096)
(D) model 생략    : ReactAgent 생성됨 (claude-sonnet-4-5-20250929)

모델 문자열의 형식

anthropic:claude-sonnet-4-6
   ↑             ↑
   provider      모델 식별자 (그대로 provider 에 전달)

공식 문서의 표현을 그대로 옮기면:

"The provider prefix selects the LangChain integration, and everything after the colon is passed through to that provider as the model identifier."

provider 접두사예시필요한 패키지
anthropicanthropic:claude-sonnet-4-6@langchain/anthropic
openaiopenai:gpt-5.5@langchain/openai
google-genaigoogle-genai:gemini-3.5-flash@langchain/google-genai
ollamaollama:llama3.3@langchain/ollama

OpenAI 로 바꾸려면:

npm install @langchain/openai
const agent = await createDeepAgent({ model: "openai:gpt-5.5" });

⚠️ 함정 — model 을 생략하면 Anthropic 이 기본값이다

createDeepAgent({}) 처럼 model 을 안 주면 조용히 claude-sonnet-4-5-20250929 가 쓰입니다(deepagents@1.11.0 의 타입 선언에 명시된 기본값).

"우리는 OpenAI 만 쓰는데 왜 Anthropic 청구서가 오지?" 의 범인이 대개 이겁니다. 게다가 이 기본값은 버전이 올라가면 바뀔 수 있습니다. 코드는 그대로인데 어느 날 모델이 바뀌어 있으면 재현이 안 됩니다.

프로덕션에서는 model 을 항상 명시하세요. 기본값에 기대지 마세요.

💡 실무 팁 — 서브에이전트마다 다른 모델을 쓸 수 있습니다

메인은 똑똑한 모델, 단순 검색 서브에이전트는 싸고 빠른 모델 — 이렇게 섞으면 비용이 크게 줍니다. SubAgent 에도 model 필드가 있습니다. Step 06 에서 다룹니다.

실행 중에 모델을 바꾸는 것도 됩니다. wrapModelCall 미들웨어에서 request.model 을 갈아끼우면 됩니다 — 2-5 에서 만든 스파이와 같은 훅입니다.


2-8. 실행 관찰 — stream 으로 흐름 보기

invoke다 끝나야 결과를 줍니다. Deep Agent 는 수십 턴을 도는데 그동안 화면이 멈춰 있으면 답답할 뿐 아니라, 무엇보다 디버깅이 안 됩니다.

stream 을 쓰면 계획 → 파일 쓰기 → 서브에이전트 흐름이 실시간으로 보입니다.

for await (const [namespace, chunk] of await agent.stream(
  { messages: [{ role: "user", content: RESEARCH }] },
  { streamMode: "updates", subgraphs: true },
)) {
  const who = namespace.length === 0 ? "메인" : `서브(${namespace.join("|")})`;

  for (const [node, update] of Object.entries(chunk as Record<string, unknown>)) {
    const msgs = (update as { messages?: BaseMessage[] })?.messages ?? [];
    for (const m of msgs) {
      const calls = (m as { tool_calls?: { name?: string }[] }).tool_calls ?? [];
      if (calls.length > 0) {
        console.log(`[${who}] ${node} → 도구 호출: ${calls.map((c) => c.name).join(", ")}`);
      } else if (m.getType() === "tool") {
        console.log(`[${who}] ${node}${(m as { name?: string }).name} 결과 (${m.text.length}자)`);
      }
    }
  }
}

출력 예시 (모델 응답이므로 순서와 횟수는 매번 다릅니다)

[메인] model → 도구 호출: write_todos          ← 계획을 세운다
[메인] tools ← write_todos 결과 (52자)
[메인] model → 도구 호출: task                 ← 서브에이전트에 위임
[서브(tools:abc123)] model → 도구 호출: search_docs
[서브(tools:abc123)] tools ← search_docs 결과 (1841자)
[메인] tools ← task 결과 (612자)               ← 요약만 돌아온다
[메인] model → 도구 호출: write_file           ← 파일로 저장
[메인] tools ← write_file 결과 (38자)

이 출력이 Step 01 의 4대 기둥을 그대로 보여 줍니다. write_todos(계획) → task(격리) → write_file(오프로딩).

서브에이전트 줄을 주목하세요. 서브에이전트는 search_docs 결과로 1,841자를 받았는데, 메인에게 돌아온 task 결과는 612자입니다. 1,841자는 서브에이전트의 컨텍스트에서만 살다 사라졌습니다. 이게 격리(isolation)의 실물입니다.

두 가지 필수 인자

인자안 주면
subgraphs: true서브에이전트 내부가 안 보입니다. task 호출과 결과만 보이고 그 안은 깜깜합니다
streamMode기본값이 "values" 라 매번 전체 상태가 통째로 옵니다 (수십 KB)

streamMode 고르기

모드주는 것언제
"updates"각 노드가 바꾼 것만흐름 추적, 디버깅 (가장 유용)
"values"매 스텝의 전체 상태최종 상태만 필요할 때
"messages"LLM 토큰 단위사용자에게 실시간으로 글자 흘리기
"custom"도구가 writer() 로 보낸 것진행률 표시

배열로 여러 개를 동시에 줄 수도 있습니다.

⚠️ 함정 — stream 앞에도 await 가 필요하다

// ❌ 틀림
for await (const chunk of agent.stream({ messages }, { streamMode: "updates" })) { }

// ✅ 맞음
for await (const chunk of await agent.stream({ messages }, { streamMode: "updates" })) { }

agent.stream() 은 async iterable 을 직접 돌려주는 게 아니라 그것의 Promise 를 돌려줍니다. for await 가 Promise 도 어느 정도 다뤄 주긴 하지만, deepagents 의 스트림은 await 를 붙여야 정상 동작합니다. 공식 문서 예제도 전부 await agent.stream(...) 입니다.

subgraphs: true 를 줬는데 await 를 빠뜨리면 [namespace, chunk] 구조분해가 깨져서 namespaceundefined 가 되고, namespace.length 에서 터집니다. 에러 메시지가 stream 을 안 가리켜서 찾기 어렵습니다.

💡 실무 팁 — subgraphs: true 는 켜 두세요

서브에이전트가 안 보이면 Deep Agent 디버깅은 사실상 불가능합니다. task 가 20초 걸렸는데 그 안에서 뭘 했는지 모르면 고칠 수가 없죠.

namespace 로 출처를 구분합니다.

namespace.length === 0                              // 메인 에이전트
namespace.some((s) => s.startsWith("tools:"))       // 서브에이전트

더 편한 방법은 LangSmith 입니다. LANGSMITH_TRACING=true 만 켜면 전체 트리를 웹에서 볼 수 있습니다. Step 11 에서 다룹니다.


정리

주제핵심
설치npm install deepagents langchain @langchain/core — peer 라서 셋 다 명시
검증npm ls @langchain/corededuped한 벌이 보여야 함
엔트리포인트deepagents(기본) / /browser(디스크 관련 9개 빠짐) / /node(기본과 동일)
awaitcreateDeepAgent 는 예의, invoke/stream 은 필수
옵션17개, 전부 optional. 처음엔 model/tools/systemPrompt 3개면 충분
내장 도구8개가 항상 실림. execute 는 샌드박스 백엔드에서만
커스텀 도구내장을 대체하지 않고 추가. description 이 곧 프롬프트
모델"provider:model" / initChatModel / 클래스 직접. 생략하면 Anthropic
관찰await agent.stream(input, { streamMode: "updates", subgraphs: true })

핵심 함정 3가지

  1. @langchain/core 가 두 벌이면 instanceof 가 조용히 false 가 된다. 에러가 안 나서 몇 시간을 태웁니다. npm ls @langchain/corededuped 를 확인하세요.
  2. 내장 도구 8개는 끌 수 없다. disableTools 옵션은 없습니다. 진짜로 빼려면 createAgent 로 내려가 미들웨어를 직접 골라야 합니다(Step 08).
  3. stream 앞에 await 를 빠뜨리면 subgraphs: true 의 구조분해가 깨진다. 에러가 엉뚱한 줄을 가리킵니다. invokestream 앞의 await 는 항상 필수입니다.

버전 특이사항: createDeepAgent 는 문서와 달리 deepagents@1.11.0 에서 동기 함수입니다(Promise 반환 안 함). model 생략 시 기본값은 claude-sonnet-4-5-20250929 이며, 버전에 따라 바뀔 수 있으므로 프로덕션에서는 명시하세요.


연습문제

  1. npm ls @langchain/core 를 실행해 출력을 확인하고, deduped 가 몇 번 나오는지 세세요. 이어서 deepagents/package.jsonpeerDependencies 를 코드로 읽어 찍으세요.
  2. deepagentsdeepagents/browser 의 export 를 각각 세고, browser 에만 없는 것 9개를 나열하세요. 그 9개의 공통점을 한 줄로 설명하세요.
  3. createDeepAgent({}) 처럼 옵션을 하나도 안 주고 에이전트를 만들 수 있나요? 만들어 보고, 스파이 미들웨어로 도구가 몇 개 실리는지 확인하세요.
  4. 계산기 도구(add: 두 수를 더함)를 만들어 Deep Agent 에 붙이고, 도구가 9개가 되는지 확인하세요. 이어서 description"계산" 이라는 두 글자로 바꿔 보고, RUN_LIVE=1 로 "3 더하기 5는?" 을 물었을 때 모델이 그 도구를 부르는지 관찰하세요.
  5. 같은 에이전트를 (a) "anthropic:claude-sonnet-4-6" 문자열로, (b) initChatModel(..., { temperature: 0 }) 로, (c) new ChatAnthropic({ maxTokens: 4096 }) 로 세 번 만들고 셋 다 생성되는지 확인하세요.
  6. (스트리밍) RUN_LIVE=1streamMode: "updates"subgraphs: true 를 주고 "/notes.md 에 오늘 할 일 3개를 써 줘" 를 실행해, 어떤 도구가 어떤 순서로 불리는지 기록하세요. 이어서 subgraphs 를 빼고 다시 돌려 무엇이 안 보이는지 비교하세요.
  7. (옵션 지도) stateSchemacontextSchema 의 차이를 한 줄로 쓰고, 다음 셋을 어디에 둘지 고르세요: (a) 요청자의 userId, (b) 지금까지 작성한 보고서 초안, (c) 외부 API 키.
  8. (심화) 본문 2-8 의 스트리밍 코드를 고쳐서, 서브에이전트가 받은 도구 결과의 총 글자 수메인에게 돌아온 task 결과의 글자 수를 각각 합산해 찍으세요. 두 숫자의 비율이 곧 격리(isolation)가 아껴 준 컨텍스트입니다.

문제만 담긴 파일은 exercise.ts, 정답과 해설은 solution.ts 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 실려 있습니다.


다음 단계

Step 03 — 계획 도구 (write_todos)

4대 기둥의 첫 번째를 분해합니다. write_todos 가 만드는 todos 상태가 왜 대화 속 텍스트보다 강한지, 그리고 모델이 계획을 안 세우거나 너무 많이 세우는 문제를 어떻게 다루는지 봅니다.


실습 파일

이 스텝은 TypeScript 파일 3개로 구성됩니다. 본문(2-1 ~ 2-8)의 예제를 순서대로 담은 practice.ts 를 먼저 실행해 결과를 눈으로 확인하고, 그다음 exercise.ts 의 8개 문제를 직접 풀어본 뒤, 마지막으로 solution.ts 로 채점하고 해설을 읽는 흐름입니다.

세 파일 모두 project/ 폴더에서 실행합니다.

cd docs/reference/deepagent/project
npx tsx ../step-02-quickstart/practice.ts            # API 키 없이 대부분 동작
RUN_LIVE=1 npx tsx ../step-02-quickstart/practice.ts # 실제 호출 (토큰 소모)

[2-3][2-8] 만 실제 모델을 부릅니다. 나머지 여섯 절은 스파이 미들웨어와 패키지 메타데이터만 보므로 키 없이 돌아갑니다.

practice.ts

본문 강의를 따라가며 손으로 쳐볼 예제를 [2-1] ~ [2-8] 주석 번호로 묶어 놓은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응합니다.

  • [2-1]coreResolvedFrom 이 이 파일에서 가장 실용적인 코드입니다. createRequire(require.resolve("deepagents/package.json"))deepagents 입장에서 @langchain/core 를 resolve 하고, langchain 입장에서도 같은 걸 하고, 경로가 전부 같은지 봅니다. new Set(...).size === 1 이면 한 벌입니다. 이 검사를 CI 에 넣어 두면 instanceof 함정을 영구히 막을 수 있습니다.
  • [2-2] 는 세 엔트리포인트를 동적 import 해서 export 를 diff 합니다. 브라우저에 없는 9개(FilesystemBackend, LocalShellBackend, findProjectRoot …)가 전부 디스크·프로세스를 건드리는 것들이라는 게 눈에 보입니다. deepagentsdeepagents/node 의 차이가 0개로 나오는 것도 확인하세요.
  • [2-4]OPTIONS 배열은 본문 표와 같은 내용을 코드로 옮긴 것입니다. 17개 전부와 "어느 스텝에서 다루는지"가 들어 있어, 나중에 "그 옵션이 어디였더라" 할 때 이 블록만 실행해도 됩니다.
  • [2-5]TOOL_ROLE 맵은 도구 이름 옆에 역할을 붙여 줍니다. execute 가 목록에 없다는 걸 마지막 줄에서 명시적으로 짚습니다 — 문서에는 내장 도구로 나오지만 샌드박스 백엔드에서만 생기기 때문입니다.
  • [2-6]searchDocsdescription 을 일부러 길게 썼습니다. "무엇을 하는가"(사내 기술 문서를 검색합니다)와 "언제 쓰는가"(프레임워크 사용법이나 API 를 물어볼 때)를 둘 다 적은 게 포인트입니다. 연습문제 4번에서 이걸 두 글자로 줄여 보면 차이를 체감할 수 있습니다.
  • [2-8] 의 스트리밍 루프는 await agent.stream(...) 에서 await 를 빼면 바로 깨집니다. [namespace, chunk] 구조분해가 실패해 namespace.length 에서 터지죠. 한번 빼 보고 에러 메시지가 얼마나 엉뚱한 곳을 가리키는지 보세요 — 본문 함정의 실물입니다.
/**
 * Step 02 — 첫 Deep Agent
 * 실행: npx tsx docs/reference/deepagent/step-02-quickstart/practice.ts
 *
 * [2-1] [2-2] [2-4] [2-5] [2-6] [2-7] 은 API 키 없이 돌아갑니다.
 * 실제 모델을 부르는 [2-3] 과 [2-8] 만 RUN_LIVE=1 이 필요합니다.
 *     RUN_LIVE=1 npx tsx docs/reference/deepagent/step-02-quickstart/practice.ts
 */
import "dotenv/config";

import { createDeepAgent } from "deepagents";
import { createMiddleware, tool } from "langchain";
import { initChatModel } from "langchain/chat_models/universal";
import { ChatAnthropic } from "@langchain/anthropic";
import { AIMessage, type BaseMessage } from "@langchain/core/messages";
import * as z from "zod";
import { createRequire } from "node:module";

import { printSection, printMessages, printTodos, printFiles } from "../project/src/lib/print.js";

const MODEL = "anthropic:claude-sonnet-4-6";
const RUN_LIVE = process.env["RUN_LIVE"] === "1";

/* ===== [2-1] 설치와 peer dependency =====
 *
 *   npm install deepagents langchain @langchain/core
 *
 * 세 패키지를 "같이" 설치하는 이유가 여기 있습니다. deepagents 는
 * @langchain/core 를 peer dependency 로 요구합니다 — "내가 깔 테니 넌 빠져"가
 * 아니라 "네가 깐 걸 같이 쓰겠다"는 선언입니다.
 *
 * peer 가 어긋나면 npm 이 사본을 하나 더 깔아 주고, 그 순간 instanceof 가
 * 조용히 false 가 됩니다. 그래서 설치 직후 이걸 확인하는 습관이 필요합니다.
 */
printSection("[2-1] 설치 검증 — @langchain/core 가 한 벌인가");

const require = createRequire(import.meta.url);

/** 이 패키지가 선언한 peer dependency 를 읽습니다. */
function peersOf(pkg: string): Record<string, string> {
  const meta = require(`${pkg}/package.json`) as {
    peerDependencies?: Record<string, string>;
  };
  return meta.peerDependencies ?? {};
}

const deepPeers = peersOf("deepagents");
console.log("deepagents 가 요구하는 peer dependency:");
for (const [name, range] of Object.entries(deepPeers)) {
  console.log(`  ${name.padEnd(34)} ${range}`);
}

/* @langchain/core 가 실제로 몇 벌 깔렸는지 봅니다.
   여러 패키지 입장에서 각각 resolve 해 보고, 경로가 전부 같으면 한 벌입니다. */
const coreResolvedFrom = ["deepagents", "langchain", "@langchain/anthropic"].map((from) => {
  const req = createRequire(require.resolve(`${from}/package.json`));
  return req.resolve("@langchain/core/package.json");
});

const uniquePaths = [...new Set(coreResolvedFrom)];
const coreVersion = (require("@langchain/core/package.json") as { version: string }).version;

console.log(`\n@langchain/core 버전: ${coreVersion}`);
console.log(`서로 다른 사본 개수: ${uniquePaths.length}`);
console.log(
  uniquePaths.length === 1
    ? "  ✅ 한 벌입니다 — instanceof 가 정상 동작합니다."
    : "  ⚠️ 두 벌 이상입니다! instanceof 가 조용히 false 가 됩니다.\n" +
        "     rm -rf node_modules package-lock.json && npm install",
);

/* ===== [2-2] 엔트리포인트 3종 =====
 *
 *   deepagents          → Node 기본. 전부 들어 있음
 *   deepagents/browser  → 브라우저 안전. Node 전용 심볼이 빠져 있음
 *   deepagents/node     → 명시적 Node. deepagents 와 동일
 *
 * 말로만 하면 안 와닿으니 직접 세어 봅니다.
 */
printSection("[2-2] 엔트리포인트 3종 — 무엇이 다른가");

const mainMod = await import("deepagents");
const browserMod = await import("deepagents/browser");
const nodeMod = await import("deepagents/node");

const keysOf = (m: object) => Object.keys(m).sort();
const main = keysOf(mainMod);
const browser = keysOf(browserMod);
const node = keysOf(nodeMod);

console.log(`deepagents         : export ${main.length}개`);
console.log(`deepagents/browser : export ${browser.length}개`);
console.log(`deepagents/node    : export ${node.length}개`);

const browserSet = new Set(browser);
const missingInBrowser = main.filter((k) => !browserSet.has(k));

console.log(`\ndeepagents 에는 있는데 /browser 에는 없는 것 (${missingInBrowser.length}개):`);
for (const k of missingInBrowser) console.log(`  - ${k}`);

const nodeSet = new Set(node);
console.log(`\ndeepagents 와 /node 의 차이: ${main.filter((k) => !nodeSet.has(k)).length}개`);
console.log("→ Node 환경에서 'deepagents' 와 'deepagents/node' 는 사실상 같습니다.");

/* ===== [2-3] createDeepAgent 첫 실행 =====
 *
 * 공식 문서 표기를 그대로 따릅니다: await createDeepAgent({...})
 *
 * 주의: 1.11.0 에서 이 함수는 사실 동기입니다(Step 01 의 1-4 함정).
 * 그래도 await 를 붙이는 게 안전합니다 — 손해가 없고 문서 표기와 같습니다.
 * 반대로 agent.invoke() 앞의 await 는 "진짜" 필수입니다.
 */
printSection("[2-3] 첫 Deep Agent 실행");

const getWeather = tool(({ city }: { city: string }) => `${city}는 언제나 맑음!`, {
  name: "get_weather",
  description: "주어진 도시의 날씨를 알려 줍니다",
  schema: z.object({ city: z.string().describe("도시 이름") }),
});

const agent = await createDeepAgent({
  model: MODEL,
  tools: [getWeather],
  systemPrompt: "당신은 친절한 날씨 안내원입니다. 한국어로 답하세요.",
});

if (RUN_LIVE) {
  const result = await agent.invoke({
    messages: [{ role: "user", content: "도쿄 날씨 알려 줘" }],
  });
  const msgs = result.messages as BaseMessage[];
  printMessages(msgs, 120);
  console.log(`\n최종 답변: ${msgs.at(-1)?.text}`);
} else {
  console.log("RUN_LIVE=1 을 붙이면 실제로 호출합니다.");
  console.log(`에이전트 생성 완료: ${agent.constructor.name}`);
}

/* ===== [2-4] CreateDeepAgentParams 전체 옵션 =====
 *
 * 16개 옵션이 있습니다. 여기서는 "지도"만 펼치고, 각각은 해당 스텝에서
 * 본격적으로 다룹니다. 본문의 표와 같은 내용입니다.
 */
printSection("[2-4] CreateDeepAgentParams 옵션 지도");

const OPTIONS: Array<[string, string, string]> = [
  ["model", "쓸 모델. 'provider:model' 문자열 또는 인스턴스", "2-7"],
  ["tools", "에이전트에 붙일 커스텀 도구. 내장 8개에 더해진다", "2-6"],
  ["systemPrompt", "커스텀 지침. 내장 프롬프트 '앞'에 붙는다", "Step 07"],
  ["subagents", "위임용 서브에이전트 목록", "Step 06"],
  ["middleware", "표준 미들웨어 '뒤'에 덧붙일 미들웨어", "Step 08"],
  ["backend", "파일이 실제로 저장될 곳. 기본 StateBackend", "Step 05"],
  ["permissions", "파일시스템 경로별 접근 제어(glob)", "Step 05"],
  ["interruptOn", "실행 전 사람 승인이 필요한 도구 지정", "Step 09"],
  ["checkpointer", "실행 간 상태 저장. 대화를 이어가려면 필수", "Step 10"],
  ["store", "스레드를 넘는 장기 메모리 저장소", "Step 10"],
  ["memory", "시작 시 읽어들일 AGENTS.md 경로 목록", "Step 10"],
  ["skills", "필요할 때만 불러올 SKILL.md 디렉터리", "Step 10"],
  ["responseFormat", "구조화된 출력 스키마(zod)", "Step 11"],
  ["contextSchema", "실행마다 주입할 런타임 컨텍스트(userId 등)", "Step 11"],
  ["stateSchema", "messages/todos/files 외에 추가할 커스텀 상태", "Step 11"],
  ["name", "에이전트 이름", "—"],
  ["streamTransformers", "스트림 변환기. streamEvents v3 에서 노출", "Step 11"],
];

console.log("옵션".padEnd(20) + "설명".padEnd(46) + "다루는 곳");
console.log("─".repeat(88));
for (const [name, desc, where] of OPTIONS) {
  console.log(name.padEnd(20) + desc.padEnd(46) + where);
}
console.log(`\n총 ${OPTIONS.length}개. 전부 optional 입니다 — createDeepAgent({}) 도 됩니다.`);

/* ===== [2-5] 기본 제공 도구 관찰 =====
 *
 * 아무 도구도 안 줬는데 8개가 실립니다. Step 01 의 스파이를 다시 씁니다.
 */
printSection("[2-5] 기본 제공 도구 — 안 줘도 8개가 실린다");

type Observed = { tools: string[]; systemPrompt: string };

function createSpy(sink: Observed) {
  return createMiddleware({
    name: "Spy",
    wrapModelCall: async (request) => {
      sink.tools = (request.tools ?? []).map((t) => (t as { name: string }).name);
      sink.systemPrompt =
        typeof request.systemPrompt === "string" ? request.systemPrompt : "";
      return new AIMessage("(가로챔)");
    },
  });
}

type Invokable = { invoke(input: { messages: { role: string; content: string }[] }): Promise<unknown> };

async function observe(make: (spy: ReturnType<typeof createSpy>) => Invokable): Promise<Observed> {
  const sink: Observed = { tools: [], systemPrompt: "" };
  const a = make(createSpy(sink));
  await a.invoke({ messages: [{ role: "user", content: "안녕" }] });
  return sink;
}

const bare = await observe((spy) => createDeepAgent({ model: MODEL, middleware: [spy] }));

const TOOL_ROLE: Record<string, string> = {
  ls: "디렉터리 목록",
  read_file: "파일 읽기",
  write_file: "파일 쓰기",
  edit_file: "파일 부분 수정(문자열 치환)",
  glob: "패턴으로 파일 찾기",
  grep: "파일 내용 검색",
  task: "서브에이전트 띄우기",
  write_todos: "할 일 목록 작성/갱신",
};

console.log(`tools 를 안 줬는데 실린 도구: ${bare.tools.length}개\n`);
for (const name of [...bare.tools].sort()) {
  console.log(`  ${name.padEnd(14)} ${TOOL_ROLE[name] ?? "?"}`);
}
console.log("\n주의: execute(셸 실행)는 목록에 없습니다 — 샌드박스 백엔드에서만 생깁니다(Step 05).");

/* ===== [2-6] 커스텀 도구 추가 =====
 *
 * 내 도구는 내장 8개를 "대체하지 않고 더해집니다".
 */
printSection("[2-6] 커스텀 도구 추가 — 대체가 아니라 추가");

const searchDocs = tool(
  async ({ query }: { query: string }) => {
    // 실제로는 여기서 검색 API 를 부릅니다. 여기선 흉내만.
    return `"${query}" 검색 결과: (예시) LangGraph 는 상태 그래프 기반 프레임워크입니다.`;
  },
  {
    name: "search_docs",
    // description 이 곧 프롬프트입니다. 부실하면 모델이 이 도구를 안 부릅니다.
    description:
      "사내 기술 문서를 검색합니다. 프레임워크 사용법이나 API 를 물어볼 때 쓰세요.",
    schema: z.object({ query: z.string().describe("검색어") }),
  },
);

const withTools = await observe((spy) =>
  createDeepAgent({ model: MODEL, tools: [getWeather, searchDocs], middleware: [spy] }),
);

console.log(`내장 8개 + 커스텀 2개 = ${withTools.tools.length}개`);
console.log(`  ${[...withTools.tools].sort().join(", ")}`);

const custom = withTools.tools.filter((t) => !bare.tools.includes(t));
console.log(`\n새로 늘어난 것: ${custom.join(", ")}`);
console.log("→ 내장 도구를 끄는 옵션은 없습니다. 끄려면 Step 08 에서 직접 조립해야 합니다.");

/* ===== [2-7] 모델 교체 =====
 *
 * 세 가지 방법이 있습니다.
 *   (A) "provider:model" 문자열   — 가장 간단
 *   (B) initChatModel 로 파라미터까지 지정
 *   (C) provider 클래스를 직접 생성
 */
printSection("[2-7] 모델 교체 — 세 가지 방법");

// (A) 문자열. provider 접두사가 어떤 LangChain 통합을 쓸지 고릅니다.
const byString = createDeepAgent({ model: "anthropic:claude-sonnet-4-6" });
console.log(`(A) 문자열        : ${byString.constructor.name} 생성됨`);

// (B) initChatModel — temperature 같은 파라미터를 줄 때.
const tuned = await initChatModel("anthropic:claude-sonnet-4-6", { temperature: 0 });
const byInit = createDeepAgent({ model: tuned });
console.log(`(B) initChatModel : ${byInit.constructor.name} 생성됨 (temperature: 0)`);

// (C) 클래스 직접 생성 — 그 provider 고유 옵션까지 다 쓸 때.
const direct = new ChatAnthropic({ model: "claude-sonnet-4-6", maxTokens: 4096 });
const byClass = createDeepAgent({ model: direct });
console.log(`(C) 클래스 직접   : ${byClass.constructor.name} 생성됨 (maxTokens: 4096)`);

// (D) 아예 생략하면? → Anthropic 기본 모델이 쓰입니다.
const byDefault = createDeepAgent({});
console.log(`(D) model 생략    : ${byDefault.constructor.name} 생성됨 (claude-sonnet-4-5-20250929)`);

console.log("\nOpenAI 로 바꾸려면 npm install @langchain/openai 후 model: 'openai:gpt-5.5'");
console.log("provider 패키지가 설치돼 있어야 문자열이 해석됩니다.");

/* ===== [2-8] 실행 관찰 — stream 으로 흐름 보기 =====
 *
 * invoke 는 다 끝나야 결과를 줍니다. Deep Agent 는 수십 턴을 도는데
 * 그동안 아무것도 안 보이면 답답하고, 무엇보다 디버깅이 안 됩니다.
 * stream 을 쓰면 계획 → 파일 쓰기 → 서브에이전트 흐름이 실시간으로 보입니다.
 *
 * 핵심 두 가지:
 *   - await agent.stream(...)  ← stream 도 await 가 필요합니다
 *   - subgraphs: true          ← 이게 없으면 서브에이전트 내부가 안 보입니다
 */
printSection("[2-8] stream 으로 계획 → 파일 → 서브에이전트 흐름 보기");

const RESEARCH = "LangGraph 의 특징을 조사해서 /report.md 파일로 정리해 줘. 계획을 먼저 세우세요.";

if (RUN_LIVE) {
  const streamer = await createDeepAgent({ model: MODEL });

  // subgraphs: true 를 주면 [namespace, chunk] 튜플이 옵니다.
  // namespace 가 [] 면 메인 에이전트, "tools:..." 로 시작하면 서브에이전트입니다.
  for await (const [namespace, chunk] of await streamer.stream(
    { messages: [{ role: "user", content: RESEARCH }] },
    { streamMode: "updates", subgraphs: true },
  )) {
    const who = namespace.length === 0 ? "메인" : `서브(${namespace.join("|")})`;

    for (const [node, update] of Object.entries(chunk as Record<string, unknown>)) {
      const msgs = (update as { messages?: BaseMessage[] })?.messages ?? [];
      for (const m of msgs) {
        const calls = (m as { tool_calls?: { name?: string }[] }).tool_calls ?? [];
        if (calls.length > 0) {
          console.log(`[${who}] ${node} → 도구 호출: ${calls.map((c) => c.name).join(", ")}`);
        } else if (m.getType() === "tool") {
          const name = (m as { name?: string }).name;
          console.log(`[${who}] ${node}${name} 결과 (${m.text.length}자)`);
        }
      }
    }
  }
  console.log("\n→ write_todos 로 계획을 세우고, task 로 위임하고, write_file 로 저장하는 흐름이 보입니다.");
} else {
  console.log("RUN_LIVE=1 을 붙이면 실시간 흐름이 보입니다. 기대되는 순서:");
  console.log("  [메인] model → 도구 호출: write_todos     ← 계획");
  console.log("  [메인] tools ← write_todos 결과");
  console.log("  [메인] model → 도구 호출: task            ← 서브에이전트 위임");
  console.log("  [서브(tools:...)] model → 도구 호출: ...  ← subgraphs:true 라서 보임");
  console.log("  [메인] model → 도구 호출: write_file      ← 파일 저장");
}

/* ===== 종합 =====
 * 최종 상태에서 계획과 파일을 함께 봅니다. Step 03, 04 의 예고편입니다. */
printSection("[2-8] 종합 — 최종 상태의 todos 와 files");

if (RUN_LIVE) {
  const finalAgent = await createDeepAgent({ model: MODEL });
  const out = await finalAgent.invoke({
    messages: [{ role: "user", content: "/hello.md 에 인사말을 써 줘" }],
  });
  console.log("── todos ──");
  printTodos(out.todos);
  console.log("── files ──");
  printFiles(out.files, true);
} else {
  console.log("RUN_LIVE=1 을 붙이면 todos 와 files 를 볼 수 있습니다.");
  console.log("→ 자세한 건 Step 03(계획)과 Step 04(파일시스템)에서 다룹니다.");
}

exercise.ts

본문 "연습문제" 8개를 그대로 옮겨 담은 빈칸 채우기용 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되어 있고 그 아래가 비어 있습니다.

  • 파일 상단에 createSpyobserve이미 준비되어 있습니다. 문제 3, 4 에서 그대로 쓰세요.
  • [문제 4] 는 두 부분입니다. 앞부분(도구가 9개가 되는지)은 키 없이 풀리고, 뒷부분(description 을 두 글자로 줄였을 때 모델이 부르는지)은 RUN_LIVE=1 이 필요합니다. 뒷부분이 이 스텝에서 가장 중요한 실험이니 꼭 돌려 보세요.
  • [문제 6][문제 8]RUN_LIVE=1 이 필요합니다. 나머지는 키 없이 풀립니다.
  • [문제 7] 은 코드 없이 주석으로만 답하는 문제입니다.
  • 파일을 그대로 실행하면 문제 번호만 출력되고 결과는 안 나옵니다. 정상입니다.
/**
 * Step 02 — 첫 Deep Agent · 연습문제
 * 실행: npx tsx docs/reference/deepagent/step-02-quickstart/exercise.ts
 *
 * 아래 [문제 N] 블록 밑을 채우세요.
 * 문제 4(뒷부분), 6, 8 만 실제 API 호출이 필요합니다 (RUN_LIVE=1).
 */
import "dotenv/config";

import { createDeepAgent } from "deepagents";
import { createMiddleware, tool } from "langchain";
import { initChatModel } from "langchain/chat_models/universal";
import { ChatAnthropic } from "@langchain/anthropic";
import { AIMessage, type BaseMessage } from "@langchain/core/messages";
import * as z from "zod";
import { createRequire } from "node:module";

import { printSection } from "../project/src/lib/print.js";

const MODEL = "anthropic:claude-sonnet-4-6";
const RUN_LIVE = process.env["RUN_LIVE"] === "1";
const require = createRequire(import.meta.url);

/* ── 준비물: 본문 2-5 의 스파이 미들웨어 ──────────────────────
   문제 3, 4 에서 그대로 씁니다. */

type Observed = { tools: string[]; systemPrompt: string };

function createSpy(sink: Observed) {
  return createMiddleware({
    name: "Spy",
    wrapModelCall: async (request) => {
      sink.tools = (request.tools ?? []).map((t) => (t as { name: string }).name);
      sink.systemPrompt =
        typeof request.systemPrompt === "string" ? request.systemPrompt : "";
      return new AIMessage("(가로챔)");
    },
  });
}

type Invokable = { invoke(input: { messages: { role: string; content: string }[] }): Promise<unknown> };

async function observe(make: (spy: ReturnType<typeof createSpy>) => Invokable): Promise<Observed> {
  const sink: Observed = { tools: [], systemPrompt: "" };
  const a = make(createSpy(sink));
  await a.invoke({ messages: [{ role: "user", content: "안녕" }] });
  return sink;
}

/* ===== [문제 1] peer dependency 확인 =====
 *
 * (A) 터미널에서 `npm ls @langchain/core` 를 실행하고 출력을 확인하세요.
 *     "deduped" 가 몇 번 나오나요?
 * (B) 코드로 deepagents/package.json 의 peerDependencies 를 읽어 찍으세요.
 *
 * 힌트: require("deepagents/package.json") 으로 읽을 수 있습니다.
 */
printSection("[문제 1] deepagents 의 peer dependency");

// 여기에 작성하세요

// → (A) deduped 가 몇 번 나왔나요? 그게 무슨 뜻인가요?
//   (여기에 답을 주석으로)

/* ===== [문제 2] 엔트리포인트 diff =====
 *
 * deepagents 와 deepagents/browser 의 export 개수를 각각 세고,
 * browser 에만 없는 것 9개를 나열하세요.
 * 그 9개의 공통점은 무엇인가요?
 *
 * 힌트: await import("deepagents/browser") 로 동적 import 합니다.
 */
printSection("[문제 2] browser 엔트리에 없는 9개");

// 여기에 작성하세요

// → 9개의 공통점은?
//   (여기에 답을 주석으로)

/* ===== [문제 3] 옵션 없이 만들 수 있나 =====
 *
 * createDeepAgent({}) 처럼 옵션을 하나도 안 주고 에이전트를 만들 수 있나요?
 * 만들어 보고, 스파이로 도구가 몇 개 실리는지 확인하세요.
 */
printSection("[문제 3] createDeepAgent({}) 가 되는가");

// 여기에 작성하세요

/* ===== [문제 4] description 이 곧 프롬프트 =====
 *
 * (A) 계산기 도구(add: 두 수를 더함)를 만들어 Deep Agent 에 붙이고,
 *     도구가 9개가 되는지 확인하세요.
 * (B) description 을 "계산" 이라는 두 글자로 바꾼 도구를 하나 더 만들고,
 *     RUN_LIVE=1 로 "3 더하기 5는?" 을 물었을 때
 *     모델이 그 도구를 부르는지 관찰하세요.
 *
 * 이 스텝에서 가장 중요한 실험입니다. 꼭 (B) 를 돌려 보세요.
 */
printSection("[문제 4] description 을 줄이면 모델이 도구를 부를까");

// (A) 여기에 작성하세요

// (B) 여기에 작성하세요

// → 모델이 도구를 불렀나요? 안 불렀다면 대신 뭘 했나요?
//   (여기에 답을 주석으로)

/* ===== [문제 5] 모델 지정 세 가지 방법 =====
 *
 * 같은 에이전트를 세 가지 방법으로 만들고 셋 다 생성되는지 확인하세요.
 *   (a) "anthropic:claude-sonnet-4-6" 문자열
 *   (b) initChatModel(..., { temperature: 0 })
 *   (c) new ChatAnthropic({ maxTokens: 4096 })
 */
printSection("[문제 5] 모델 지정 세 가지 방법");

// 여기에 작성하세요

/* ===== [문제 6] 스트리밍으로 흐름 보기 =====
 *
 * RUN_LIVE=1 로 streamMode: "updates" 와 subgraphs: true 를 주고
 * "/notes.md 에 오늘 할 일 3개를 써 줘" 를 실행해,
 * 어떤 도구가 어떤 순서로 불리는지 기록하세요.
 *
 * 이어서 subgraphs 를 빼고 다시 돌려 무엇이 안 보이는지 비교하세요.
 *
 * 힌트: for await (const [namespace, chunk] of await agent.stream(...))
 *       await 를 빼면 구조분해가 깨집니다!
 */
printSection("[문제 6] stream 으로 도구 호출 순서 기록");

// 여기에 작성하세요

// → subgraphs 를 빼면 무엇이 안 보이나요?
//   (여기에 답을 주석으로)

/* ===== [문제 7] stateSchema vs contextSchema =====
 *
 * 둘의 차이를 한 줄로 쓰고, 다음 셋을 어디에 둘지 고르세요. (주석만)
 *   (a) 요청자의 userId
 *   (b) 지금까지 작성한 보고서 초안
 *   (c) 외부 API 키
 */
printSection("[문제 7] stateSchema vs contextSchema");

// 차이 →
// (a) userId      →
// (b) 보고서 초안 →
// (c) API 키      →

/* ===== [문제 8] (심화) 격리가 아껴 준 컨텍스트 재기 =====
 *
 * 본문 2-8 의 스트리밍 코드를 고쳐서
 *   - 서브에이전트가 받은 도구 결과의 총 글자 수
 *   - 메인에게 돌아온 task 결과의 글자 수
 * 를 각각 합산해 찍으세요. 두 숫자의 비율이 격리가 아껴 준 컨텍스트입니다.
 *
 * 힌트: namespace.length === 0 이면 메인, 아니면 서브에이전트입니다.
 *       도구 결과는 m.getType() === "tool" 인 메시지의 m.text 입니다.
 */
printSection("[문제 8] 격리가 아껴 준 컨텍스트");

// 여기에 작성하세요

console.log("\n(문제를 다 풀었으면 solution.ts 로 채점하세요)");

solution.ts

8문제의 정답 코드와 해설 주석을 담은 파일입니다. exercise.ts 를 스스로 풀어본 뒤에 열어보세요.

  • [정답 2] 의 9개는 FilesystemBackend, LocalShellBackend, SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY, createAgentMemoryMiddleware, createSettings, createSubAgent, findProjectRoot, listSkills, parseSkillMetadata 입니다. 공통점은 전부 Node 의 fs/process 에 의존한다는 것. 반대로 StateBackendStoreBackend 가 양쪽에 다 있는 이유도 같습니다 — 디스크를 안 쓰니까요.
  • [정답 3] 의 답은 "됩니다". createDeepAgent({}) 는 정상 동작하고 도구 8개가 그대로 실립니다. model 도 기본값(claude-sonnet-4-5-20250929)이 들어갑니다. 17개 옵션이 전부 optional 이라는 게 이런 뜻입니다.
  • [정답 4] 가 이 파일의 하이라이트입니다. description: "계산" 으로 줄이면 모델이 add 도구를 안 부르고 그냥 8이라고 답해 버립니다. 에러도 경고도 없습니다. 도구는 멀쩡히 실려 있는데 모델이 "이게 지금 쓸 도구인지" 판단할 근거가 없어서 안 쓰는 겁니다. 비결정적이라 가끔 부를 때도 있지만, 그게 더 나쁩니다 — 테스트에선 통과하고 프로덕션에서 실패하니까요.
  • [정답 7] 의 답은 (a) contextSchema, (b) stateSchema, (c) contextSchema 입니다. 기준은 "실행이 끝나도 남아야 하는가" 입니다. userId 와 API 키는 매 실행 주입되는 값이고, 보고서 초안은 다음 턴에도 이어져야 하는 상태입니다. (c) 를 stateSchema 라고 답하기 쉬운데, API 키를 state 에 두면 checkpointer 에 그대로 저장됩니다 — 보안 사고입니다.
  • [정답 8] 의 비율이 이 스텝의 결론입니다. 서브에이전트가 소비한 글자 수 대비 메인에게 돌아온 글자 수가 대개 5:1 ~ 20:1 입니다. 그 차이만큼 메인의 컨텍스트가 절약된 것이고, 그게 Step 01 에서 말한 격리(isolation)의 실제 값어치입니다. (모델 응답이라 실행마다 다릅니다)
/**
 * Step 02 — 첫 Deep Agent · 정답과 해설
 * 실행: npx tsx docs/reference/deepagent/step-02-quickstart/solution.ts
 *
 * exercise.ts 를 스스로 풀어본 뒤에 열어보세요.
 * 문제 4(뒷부분), 6, 8 만 RUN_LIVE=1 이 필요합니다.
 */
import "dotenv/config";

import { createDeepAgent } from "deepagents";
import { createMiddleware, tool } from "langchain";
import { initChatModel } from "langchain/chat_models/universal";
import { ChatAnthropic } from "@langchain/anthropic";
import { AIMessage, type BaseMessage } from "@langchain/core/messages";
import * as z from "zod";
import { createRequire } from "node:module";

import { printSection } from "../project/src/lib/print.js";

const MODEL = "anthropic:claude-sonnet-4-6";
const RUN_LIVE = process.env["RUN_LIVE"] === "1";
const require = createRequire(import.meta.url);

type Observed = { tools: string[]; systemPrompt: string };

function createSpy(sink: Observed) {
  return createMiddleware({
    name: "Spy",
    wrapModelCall: async (request) => {
      sink.tools = (request.tools ?? []).map((t) => (t as { name: string }).name);
      sink.systemPrompt =
        typeof request.systemPrompt === "string" ? request.systemPrompt : "";
      return new AIMessage("(가로챔)");
    },
  });
}

type Invokable = { invoke(input: { messages: { role: string; content: string }[] }): Promise<unknown> };

async function observe(make: (spy: ReturnType<typeof createSpy>) => Invokable): Promise<Observed> {
  const sink: Observed = { tools: [], systemPrompt: "" };
  const a = make(createSpy(sink));
  await a.invoke({ messages: [{ role: "user", content: "안녕" }] });
  return sink;
}

/* ===== [정답 1] peer dependency 확인 =====
 *
 * (A) `npm ls @langchain/core` 의 출력에서 "deduped" 는 6번 나옵니다
 *     (@langchain/anthropic, @langchain/langgraph 아래 3개,
 *      deepagents 아래 2개, langchain 아래 1개 — 설치 상태에 따라 다름).
 *
 *     "deduped" 는 "이미 위에서 설치된 걸 그대로 쓴다"는 뜻입니다.
 *     즉 사본을 새로 안 깔았다는 표시입니다. 이게 우리가 원하는 상태입니다.
 *
 *     반대로 이런 게 보이면 위험합니다:
 *       └─┬ deepagents@1.11.0
 *         └── @langchain/core@1.1.0     ← deduped 가 없다 = 사본이 따로 있다!
 *
 * (B) peerDependencies 는 아래 코드로 읽습니다.
 *
 * 해설: peer dependency 는 "네가 깐 걸 같이 쓰겠다"는 선언입니다.
 * deepagents 가 만든 AIMessage 와 여러분이 import 한 AIMessage 가
 * 같은 클래스여야 instanceof 가 동작하기 때문입니다.
 */
printSection("[정답 1] deepagents 의 peer dependency");

const meta = require("deepagents/package.json") as {
  version: string;
  peerDependencies?: Record<string, string>;
};

console.log(`deepagents@${meta.version} 의 peerDependencies:`);
for (const [name, range] of Object.entries(meta.peerDependencies ?? {})) {
  console.log(`  ${name.padEnd(34)} ${range}`);
}

// 사본이 몇 벌인지 코드로 검증 — CI 에 넣어 두면 좋습니다.
const resolvedFrom = ["deepagents", "langchain", "@langchain/anthropic"].map((from) => {
  const req = createRequire(require.resolve(`${from}/package.json`));
  return req.resolve("@langchain/core/package.json");
});
const copies = new Set(resolvedFrom).size;
console.log(`\n@langchain/core 사본 개수: ${copies}`);
console.log(copies === 1 ? "  ✅ 한 벌 — instanceof 정상" : "  ⚠️ 두 벌 이상 — instanceof 가 깨집니다");

/* ===== [정답 2] 엔트리포인트 diff =====
 *
 * 답: browser 에만 없는 9개는
 *   FilesystemBackend, LocalShellBackend, SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY,
 *   createAgentMemoryMiddleware, createSettings, createSubAgent,
 *   findProjectRoot, listSkills, parseSkillMetadata
 *
 * 공통점: 전부 Node 의 fs / process 에 의존합니다.
 *   - FilesystemBackend  → 실제 디스크 읽기/쓰기
 *   - LocalShellBackend  → 셸 프로세스 실행
 *   - findProjectRoot    → 상위 디렉터리 탐색
 *   - listSkills / parseSkillMetadata → 디렉터리 스캔 + 파일 파싱
 *   - createSettings     → 설정 파일 읽기
 *
 * 브라우저엔 fs 모듈이 없으니 넣을 수가 없습니다.
 *
 * 반대로 StateBackend(메모리 안 가상 FS)와 StoreBackend 는 양쪽에 다 있습니다.
 * 디스크를 안 건드리니까요. 이 대비가 "Deep Agent 의 파일시스템은 진짜
 * 파일시스템이 아니다"라는 Step 04 의 주제를 미리 보여 줍니다.
 */
printSection("[정답 2] browser 엔트리에 없는 9개");

const mainMod = await import("deepagents");
const browserMod = await import("deepagents/browser");
const nodeMod = await import("deepagents/node");

const main = Object.keys(mainMod).sort();
const browser = Object.keys(browserMod).sort();
const node = Object.keys(nodeMod).sort();

console.log(`deepagents: ${main.length}개 / browser: ${browser.length}개 / node: ${node.length}개`);

const browserSet = new Set(browser);
const missing = main.filter((k) => !browserSet.has(k));
console.log(`\nbrowser 에만 없는 것 (${missing.length}개):`);
for (const k of missing) console.log(`  - ${k}`);

console.log("\n→ 공통점: 전부 Node 의 fs / process 에 의존합니다.");
console.log("   StateBackend / StoreBackend 는 양쪽에 다 있습니다 (디스크를 안 씀).");

// deepagents 와 /node 는 같은가?
const nodeSet = new Set(node);
console.log(`\ndeepagents 와 /node 의 차이: ${main.filter((k) => !nodeSet.has(k)).length}개 → 사실상 동일`);

/* ===== [정답 3] 옵션 없이 만들 수 있나 =====
 *
 * 답: 됩니다. createDeepAgent({}) 는 정상 동작하고 도구 8개가 그대로 실립니다.
 *
 * 해설: CreateDeepAgentParams 의 17개 필드가 전부 optional 입니다.
 * 심지어 params 자체도 optional 이라 createDeepAgent() 도 됩니다.
 *
 * model 을 안 주면 기본값 claude-sonnet-4-5-20250929 이 쓰입니다.
 * ⚠️ 이게 본문 2-7 의 함정입니다 — "우리는 OpenAI 만 쓰는데 왜
 * Anthropic 청구서가 오지?" 의 범인입니다. 프로덕션에선 항상 명시하세요.
 */
printSection("[정답 3] createDeepAgent({}) 가 되는가");

const bare = await observe((spy) => createDeepAgent({ middleware: [spy] }));
console.log(`createDeepAgent({}) → 생성 성공, 도구 ${bare.tools.length}개`);
console.log(`  ${[...bare.tools].sort().join(", ")}`);
console.log("\n→ 17개 옵션이 전부 optional. model 도 기본값(claude-sonnet-4-5-20250929)이 들어갑니다.");

/* ===== [정답 4] description 이 곧 프롬프트 =====
 *
 * (A) 답: 9개 (내장 8 + add 1). 커스텀 도구는 내장을 대체하지 않고 더해집니다.
 *
 * (B) 답: description 을 "계산" 으로 줄이면 모델이 add 를 "안 부르고"
 *     그냥 8이라고 답해 버립니다.
 *
 * 해설: 이게 이 스텝에서 가장 중요한 실험입니다.
 *
 * 도구는 멀쩡히 실려 있습니다(스파이로 확인하면 9개 그대로). 그런데
 * 모델이 안 부릅니다. 왜? "계산" 두 글자로는 "3 더하기 5" 가 이 도구를
 * 쓸 상황인지 판단할 수가 없기 때문입니다. 게다가 3+5 는 모델이 암산으로
 * 아는 거라 굳이 도구를 쓸 이유도 없습니다.
 *
 * ⚠️ 에러도 경고도 없습니다. 답(8)은 맞습니다. 그래서 "잘 동작하네" 하고
 * 넘어갑니다. 그러다 숫자가 커지거나(모델 암산이 틀림) 도구가 진짜
 * 외부 API 를 부르는 것이었다면(부작용이 안 일어남) 그때 터집니다.
 *
 * 게다가 비결정적이라 가끔은 부릅니다. 그게 더 나쁩니다 — 테스트에선
 * 통과하고 프로덕션에서 실패하니까요.
 *
 * 교훈: 좋은 description 은 "무엇을 하는가"가 아니라 "언제 써야 하는가"를
 * 적습니다. 모델이 판단해야 하는 건 그거니까요.
 */
printSection("[정답 4] description 을 줄이면 모델이 도구를 부를까");

// (A) 제대로 된 description
const addGood = tool(({ a, b }: { a: number; b: number }) => `${a + b}`, {
  name: "add",
  description:
    "두 숫자를 더합니다. 사용자가 덧셈을 요청하거나 두 수의 합이 필요할 때 반드시 이 도구를 쓰세요. 암산하지 마세요.",
  schema: z.object({
    a: z.number().describe("첫 번째 숫자"),
    b: z.number().describe("두 번째 숫자"),
  }),
});

const withAdd = await observe((spy) =>
  createDeepAgent({ model: MODEL, tools: [addGood], middleware: [spy] }),
);
console.log(`(A) 도구 개수: ${withAdd.tools.length}개 (내장 8 + add 1)`);

// (B) 부실한 description — 이름만 같고 설명이 두 글자
const addBad = tool(({ a, b }: { a: number; b: number }) => `${a + b}`, {
  name: "add",
  description: "계산", // ← 이게 문제
  schema: z.object({ a: z.number(), b: z.number() }),
});

if (RUN_LIVE) {
  for (const [label, t] of [
    ["좋은 description", addGood],
    ["나쁜 description(계산)", addBad],
  ] as const) {
    const a = createDeepAgent({ model: MODEL, tools: [t] });
    const out = await a.invoke({ messages: [{ role: "user", content: "3 더하기 5는?" }] });
    const msgs = out.messages as BaseMessage[];
    // 실제로 add 를 불렀는지는 메시지에 tool_calls 가 있는지로 봅니다.
    const called = msgs.some((m) =>
      ((m as { tool_calls?: { name?: string }[] }).tool_calls ?? []).some((c) => c.name === "add"),
    );
    console.log(`\n[${label}] add 도구를 불렀나? ${called ? "✅ 예" : "❌ 아니오"}`);
    console.log(`  최종 답변: ${msgs.at(-1)?.text?.slice(0, 60)}`);
  }
  console.log("\n→ 나쁜 쪽은 도구가 실려 있는데도 안 부르고 암산해 버립니다. 에러 없이.");
} else {
  console.log("\n(B) RUN_LIVE=1 로 실행하면 두 description 의 차이를 볼 수 있습니다.");
  console.log("기대: 좋은 쪽은 add 를 부르고, 나쁜 쪽('계산')은 안 부르고 그냥 8이라고 답함");
}

/* ===== [정답 5] 모델 지정 세 가지 방법 =====
 *
 * 셋 다 됩니다. 고르는 기준:
 *   (a) 문자열       → 파라미터 조정이 필요 없을 때. 가장 간단.
 *   (b) initChatModel → temperature 등 공통 파라미터를 줄 때. provider 중립적.
 *   (c) 클래스 직접   → 그 provider 고유 옵션(thinking, cacheControl 등)까지 쓸 때.
 *
 * 참고: (a) 의 문자열이 동작하려면 해당 provider 패키지가 설치돼 있어야 합니다.
 * "openai:gpt-5.5" 를 쓰려면 npm install @langchain/openai 가 먼저입니다.
 * 안 깔고 쓰면 런타임에 "Unable to import ..." 에러가 납니다.
 */
printSection("[정답 5] 모델 지정 세 가지 방법");

const byString = createDeepAgent({ model: "anthropic:claude-sonnet-4-6" });
console.log(`(a) 문자열        : ${byString.constructor.name}`);

const tuned = await initChatModel("anthropic:claude-sonnet-4-6", { temperature: 0 });
const byInit = createDeepAgent({ model: tuned });
console.log(`(b) initChatModel : ${byInit.constructor.name} (temperature: 0)`);

const direct = new ChatAnthropic({ model: "claude-sonnet-4-6", maxTokens: 4096 });
const byClass = createDeepAgent({ model: direct });
console.log(`(c) 클래스 직접   : ${byClass.constructor.name} (maxTokens: 4096)`);

console.log("\n→ 셋 다 동일한 ReactAgent 를 만듭니다. 파라미터 조정 필요 여부로 고르세요.");

/* ===== [정답 6] 스트리밍으로 흐름 보기 =====
 *
 * 답: subgraphs 를 빼면 "서브에이전트 내부가 통째로 안 보입니다."
 * task 호출과 그 결과만 보이고, 그 안에서 무슨 도구를 몇 번 불렀는지는 깜깜합니다.
 *
 * 해설: subgraphs: true 가 있으면 [namespace, chunk] 튜플이 오고,
 * namespace 로 출처를 구분할 수 있습니다.
 *   []                  → 메인 에이전트
 *   ["tools:abc123"]    → task 로 띄운 서브에이전트
 *
 * 없으면 chunk 만 오고 메인 것만 보입니다.
 *
 * ⚠️ await agent.stream(...) 의 await 를 빼면 [namespace, chunk] 구조분해가
 * 깨져서 namespace 가 undefined 가 되고 namespace.length 에서 터집니다.
 */
printSection("[정답 6] stream 으로 도구 호출 순서 기록");

const STREAM_TASK = "/notes.md 에 오늘 할 일 3개를 써 줘";

if (RUN_LIVE) {
  const agent = await createDeepAgent({ model: MODEL });

  console.log("── subgraphs: true ──");
  for await (const [namespace, chunk] of await agent.stream(
    { messages: [{ role: "user", content: STREAM_TASK }] },
    { streamMode: "updates", subgraphs: true },
  )) {
    const who = namespace.length === 0 ? "메인" : `서브(${namespace.join("|")})`;
    for (const [node, update] of Object.entries(chunk as Record<string, unknown>)) {
      const msgs = (update as { messages?: BaseMessage[] })?.messages ?? [];
      for (const m of msgs) {
        const calls = (m as { tool_calls?: { name?: string }[] }).tool_calls ?? [];
        if (calls.length > 0) {
          console.log(`[${who}] ${node}${calls.map((c) => c.name).join(", ")}`);
        } else if (m.getType() === "tool") {
          console.log(`[${who}] ${node}${(m as { name?: string }).name} (${m.text.length}자)`);
        }
      }
    }
  }
} else {
  console.log("RUN_LIVE=1 로 실행하면 실제 순서를 볼 수 있습니다.");
  console.log("기대 순서: write_todos(계획) → write_file(저장). 간단한 작업이라 task 는 안 쓸 수도 있습니다.");
  console.log("\n→ subgraphs 를 빼면 서브에이전트 내부가 통째로 안 보입니다.");
}

/* ===== [정답 7] stateSchema vs contextSchema =====
 *
 * 차이: stateSchema 는 checkpointer 가 있으면 "실행 간 유지"되고,
 *      contextSchema 는 "실행 1회 한정" 입니다.
 *      즉 기준은 "실행이 끝나도 남아야 하는가" 입니다.
 *
 * (a) userId       → contextSchema
 *     매 실행 호출자가 주입하는 값입니다. 다음 실행 땐 다른 사용자일 수 있죠.
 *     도구 안에서 runtime.context.userId 로 읽습니다.
 *
 * (b) 보고서 초안  → stateSchema
 *     다음 턴에도 이어져야 하는 누적 결과입니다.
 *     (실무에선 이런 큰 텍스트는 state 보다 files 에 두는 게 낫습니다 — Step 04)
 *
 * (c) API 키       → contextSchema
 *     ⚠️ 여기서 많이 틀립니다. stateSchema 라고 답하기 쉬운데, state 는
 *     checkpointer 가 "그대로 저장"합니다. API 키를 state 에 두면 키가
 *     체크포인트 DB 에 평문으로 남습니다 — 보안 사고입니다.
 *     비밀은 실행 1회짜리 context 로 주입하세요.
 */
printSection("[정답 7] stateSchema vs contextSchema");
console.log("차이: state 는 실행 간 유지(checkpointer 저장), context 는 실행 1회 한정");
console.log("");
console.log("(a) userId       → contextSchema  (매 실행 주입되는 값)");
console.log("(b) 보고서 초안  → stateSchema    (다음 턴에도 이어져야 함)");
console.log("(c) API 키       → contextSchema  ← state 에 두면 체크포인트에 평문 저장!");

/* ===== [정답 8] (심화) 격리가 아껴 준 컨텍스트 =====
 *
 * 답: 서브에이전트가 소비한 글자 수 대비 메인에게 돌아온 글자 수가
 * 대개 5:1 ~ 20:1 입니다. (모델 응답이라 실행마다 다릅니다)
 *
 * 해설: 이 비율이 곧 격리(isolation)의 값어치입니다.
 * 서브에이전트가 도구 결과 10,000자를 소비했는데 메인에겐 800자만
 * 돌아왔다면, 메인의 컨텍스트를 9,200자만큼 아낀 것입니다.
 *
 * Step 01 의 1-5 에서 "검색 28번이면 요약이 걸린다" 고 계산했죠.
 * 격리를 쓰면 그 28번이 서브에이전트 안에서 일어나고 메인엔 요약만
 * 남으므로, 같은 예산으로 훨씬 많은 탐색이 가능해집니다.
 *
 * 주의: 간단한 작업에선 모델이 task 를 아예 안 씁니다. 그럼 서브 글자 수가
 * 0 이 나오는데 정상입니다. 위임할 만큼 복잡한 요청을 줘야 합니다.
 */
printSection("[정답 8] 격리가 아껴 준 컨텍스트");

const ISOLATION_TASK =
  "LangGraph 와 CrewAI 를 각각 조사해서 비교표를 만들어 /compare.md 에 저장해 줘. " +
  "각 프레임워크 조사는 서브에이전트에 맡기세요.";

if (RUN_LIVE) {
  const agent = await createDeepAgent({ model: MODEL });

  let subChars = 0; // 서브에이전트가 받은 도구 결과 총합
  let taskChars = 0; // 메인에게 돌아온 task 결과 총합

  for await (const [namespace, chunk] of await agent.stream(
    { messages: [{ role: "user", content: ISOLATION_TASK }] },
    { streamMode: "updates", subgraphs: true },
  )) {
    const isSub = namespace.length > 0;
    for (const update of Object.values(chunk as Record<string, unknown>)) {
      const msgs = (update as { messages?: BaseMessage[] })?.messages ?? [];
      for (const m of msgs) {
        if (m.getType() !== "tool") continue;
        const len = m.text.length;
        const name = (m as { name?: string }).name;
        if (isSub) subChars += len;
        else if (name === "task") taskChars += len;
      }
    }
  }

  console.log(`서브에이전트가 소비한 도구 결과 : ${subChars.toLocaleString()}자`);
  console.log(`메인에게 돌아온 task 결과      : ${taskChars.toLocaleString()}자`);
  if (taskChars > 0) {
    console.log(`\n압축비: ${(subChars / taskChars).toFixed(1)} : 1`);
    console.log(`메인 컨텍스트에서 아낀 양: 약 ${(subChars - taskChars).toLocaleString()}자`);
  } else {
    console.log("\ntask 를 안 썼습니다. 모델이 위임할 필요가 없다고 판단한 것입니다.");
  }
} else {
  console.log("RUN_LIVE=1 로 실행하면 실제 비율을 볼 수 있습니다.");
  console.log("기대: 서브가 소비한 글자 : 메인에 돌아온 글자 = 대략 5:1 ~ 20:1");
  console.log("→ 그 차이만큼 메인의 컨텍스트를 아낀 것입니다. 이게 격리의 값어치입니다.");
}

console.log("\n(정답 확인 끝. Step 03 으로 넘어가세요)");