Step 07 — 도구 호출 루프 직접 구현

학습 목표

  • 에이전트가 모델 + 도구 + while 루프일 뿐임을 코드로 확인한다
  • bindToolsinvoketool_calls → 도구 실행 → ToolMessage → 반복 의 한 바퀴를 손으로 돌린다
  • 루프 종료 조건을 tool_calls 기준으로만 판정한다 (content 유무로 판정하지 않는다)
  • AIMessage 에 담긴 여러 tool_callsPromise.all병렬 실행하고, 짝을 tool_call_id 로 맞춘다
  • 최대 반복 횟수로 무한 루프를 막고, recursionLimit 의 정체를 이해한다
  • 도구 실패를 예외로 터뜨리지 않고 ToolMessage 로 모델에게 되돌려 회복시킨다
  • 직접 만든 ~80줄 미니 에이전트가 createAgent같은 결과를 내는 것을 확인한다

선행 스텝: Step 06 — 도구(Tool) 정의 예상 소요: 80분

Step 06 에서 tool() 로 도구를 정의하고 bindTools 로 모델에 붙였습니다. 그리고 모델이 tool_calls 를 돌려주는 것까지 봤습니다. 거기서 이야기가 끊겼습니다 — 그래서 누가 그 도구를 실행하나요?

답은 "우리"입니다. 그리고 실행 결과를 모델에게 돌려주고, 모델이 또 다른 도구를 요청하면 또 실행하고, 더 이상 요청이 없을 때까지 반복하는 것 — 그게 에이전트의 전부입니다. 이번 스텝에서는 createAgent 를 쓰지 않고 이 루프를 손으로 만듭니다.

왜 이런 우회를 하냐면, 다음 스텝부터 나올 모든 기능(스트리밍, 메모리, 미들웨어, HITL, 멀티 에이전트)이 전부 이 루프의 특정 지점에 무언가를 끼워 넣는 일이기 때문입니다. 루프를 모르면 그 기능들은 외워야 할 마법 주문이 됩니다. 루프를 알면 "아, 그건 도구 실행 직전에 끼어드는 거구나" 하고 지도 위에 찍을 수 있습니다. 이 스텝을 마치면 createAgent 의 동작을 예측할 수 있어야 합니다.

이번 스텝에서 쓸 도구는 3개입니다. get_weather(도시 → 날씨), get_population(도시 → 인구, 일부러 1초 지연), calculate(사칙연산). 데이터는 코드 안의 상수 테이블이라 결과를 눈으로 검산할 수 있습니다.


7-1. 에이전트 = 모델 + 도구 + 루프. 그게 전부다

에이전트를 정의하는 문장은 공식 문서에도 한 줄입니다.

"An agent is a model calling tools in a loop until a given task is complete." — LangChain Agents 문서

이 문장을 의사코드로 옮기면 이렇습니다. 정말 이게 전부입니다.

messages = [사용자 질문]
반복:
    ai = 모델.invoke(messages)          # 모델에게 물어본다
    messages.push(ai)
    만약 ai.tool_calls 가 비었으면:
        return ai.text                  # 끝
    각 tool_call 마다:
        결과 = 도구를 실행한다           # ← 이 줄만 우리가 한다
        messages.push(ToolMessage(결과))

그런데 이 의사코드에서 가장 중요한 줄은 주석이 붙은 그 줄입니다. 모델은 도구를 실행하지 않습니다. 실행을 "요청"할 뿐입니다.

당연한 말 같지만 실감하기 전까지는 계속 헷갈립니다. 직접 봅시다.

const modelWithTools = model.bindTools(tools);

const response = await modelWithTools.invoke([
  new HumanMessage("서울 날씨 어때?"),
]);

console.log("응답 타입      :", response.constructor.name);
console.log("response.text  :", JSON.stringify(response.text));
console.log("tool_calls     :", JSON.stringify(response.tool_calls, null, 2));

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

응답 타입      : AIMessage
response.text  : "서울의 날씨를 확인해드리겠습니다."
tool_calls     : [
  {
    "name": "get_weather",
    "args": {
      "city": "서울"
    },
    "id": "toolu_01A9x2mKp3nQr7sT8uVwXyZa",
    "type": "tool_call"
  }
]

여기서 날씨는 아직 조회되지 않았습니다. WEATHER_DB 는 열린 적조차 없습니다. 모델이 한 일은 {name: "get_weather", args: {city: "서울"}, id: "toolu_..."} 라는 JSON 조각을 만든 것뿐입니다.

생각해 보면 당연합니다. 모델은 HTTP 너머 남의 서버에 있습니다. 우리 프로세스의 getWeather 함수를 실행할 방법이 물리적으로 없습니다. 모델이 할 수 있는 건 "이 함수를 이 인자로 불러줘" 라고 말하는 것뿐이고, 그 말을 듣고 실제로 함수를 부르는 건 우리 애플리케이션 코드입니다.

tool_calls 의 각 원소는 이 형태입니다. @langchain/coreToolCall 타입이며, 이 shape 은 결정적입니다.

필드타입설명
namestring호출할 도구 이름
argsRecord<string, any>스키마에 맞게 파싱된 인자
idstring | undefined이 호출의 고유 ID. 결과를 되돌릴 때 이 값이 열쇠
type"tool_call" | undefined판별용 태그

💡 실무 팁: id 의 접두사는 provider 마다 다릅니다. Anthropic 은 toolu_..., OpenAI 는 call_... 로 시작합니다. 이 값을 절대 우리가 지어내면 안 됩니다. 모델이 준 것을 그대로 되돌려줘야 합니다. 로그를 볼 때 이 접두사만 봐도 어느 provider 를 탔는지 알 수 있어서 디버깅에 유용합니다.

⚠️ 함정: bindTools 는 모델 객체를 변경하지 않습니다. 도구가 붙은 새 객체를 반환합니다.

model.bindTools(tools);              // ❌ 반환값을 버렸다. model 은 그대로다
const m = model.bindTools(tools);    // ✅

첫 줄처럼 쓰고 나서 model.invoke(...) 를 부르면 에러는 나지 않습니다. 모델은 도구가 있는 줄도 모르니 그냥 "죄송하지만 실시간 날씨 정보에 접근할 수 없습니다" 같은 그럴듯한 답변을 합니다. 에러 없이 조용히 에이전트가 아닌 것이 됩니다. tool_calls 가 항상 비어 있다면 이걸 가장 먼저 의심하세요.


7-2. 손으로 만드는 ReAct 루프

이제 한 바퀴를 손으로 돌립니다. while 을 쓰지 않고 일부러 펼쳐서 씁니다. 질문은 도구가 여러 번, 여러 종류 필요하도록 골랐습니다.

"서울 인구와 부산 인구를 더하면 몇 명이야?"

이걸 풀려면 get_population 을 두 번 부르고, 그 결과로 calculate 를 한 번 불러야 합니다. 최소 3바퀴입니다.

1바퀴 — 모델에게 물어본다

const modelWithTools = model.bindTools(tools);
const messages: BaseMessage[] = [
  new HumanMessage("서울 인구와 부산 인구를 더하면 몇 명이야?"),
];

const ai1 = await modelWithTools.invoke(messages);
messages.push(ai1);   // ★ AIMessage 를 반드시 배열에 넣는다

messages.push(ai1) 을 빠뜨리면 안 됩니다. 모델 API 는 stateless 입니다. 이전 호출을 기억하지 못하므로, 매 바퀴마다 지금까지의 대화 전체를 다시 보내야 합니다. 에이전트의 "기억"은 결국 이 messages 배열 하나가 전부입니다.

1바퀴 — 우리가 도구를 실행하고 결과를 되돌린다

for (const toolCall of ai1.tool_calls ?? []) {
  const selected = findTool(toolCall.name);           // 이름으로 도구를 찾고
  const output = await selected.invoke(toolCall.args); // 실행한다 → 순수 결과값(문자열)

  messages.push(
    new ToolMessage({
      content: String(output),
      tool_call_id: toolCall.id!,   // ★ 모델이 준 id 를 그대로
      name: toolCall.name,
    }),
  );
}

ToolMessage 의 필드는 이렇습니다 (@langchain/coreToolMessageFields).

필드필수설명
content도구가 돌려준 결과. 모델이 실제로 읽는 유일한 필드
tool_call_id어떤 tool_call 에 대한 답인지. toolCall.id정확히 같아야
name도구 이름. 로그 가독성용
status"success" | "error"
artifact모델에게 안 보내는 부가 데이터

tool_call_id 가 이 스텝의 심장입니다. "어떤 요청에 대한 답인지" 를 잇는 유일한 끈입니다.

⚠️ 함정 (이번 스텝 1번 함정): ToolMessagemessages 에 넣지 않고 다음 invoke 를 부르면 provider 가 400 을 던집니다.

const ai = await modelWithTools.invoke(messages);
messages.push(ai);
// ← 여기서 ToolMessage 를 안 넣고
await modelWithTools.invoke(messages);   // 💥 400 Bad Request

Anthropic 은 대략 이렇게 말합니다: messages: tool_use ids were found without tool_result blocks immediately after. OpenAI 는 이렇게 말합니다: An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.

주목할 점 두 가지입니다. 첫째, 이건 LangChain 이 아니라 provider 가 내는 에러입니다. LangChain 은 우리 messages 배열을 그대로 직렬화해 보낼 뿐, 짝이 맞는지 검사해 주지 않습니다. 그래서 에러 메시지도 provider 말투입니다. 둘째, tool_calls 가 N개면 ToolMessage 도 정확히 N개여야 합니다. "도구 하나가 실패했으니 그건 빼고 보내자" 는 생각이 바로 이 에러를 만듭니다 (7-6 에서 다룹니다).

그나마 이 함정은 시끄럽게 터져서 다행입니다. 진짜 무서운 건 7-3 입니다.

2바퀴, 3바퀴 — 같은 걸 반복한다

const ai2 = await modelWithTools.invoke(messages);   // 도구 결과가 붙은 배열을 통째로
messages.push(ai2);
// → 이번엔 calculate 를 요청할 것이다. 도구를 실행하고 ToolMessage 를 또 push...

const ai3 = await modelWithTools.invoke(messages);
messages.push(ai3);
console.log("최종 답변:", ai3.text);

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

[1바퀴] 모델이 요청한 도구: get_population, get_population
         └ 실행 결과: 서울의 인구는 9,386,000명입니다.
         └ 실행 결과: 부산의 인구는 3,293,000명입니다.

[2바퀴] 모델이 요청한 도구: calculate
         └ 실행 결과: 12679000

[3바퀴] tool_calls 개수: 0
최종 답변: 서울 인구 9,386,000명과 부산 인구 3,293,000명을 더하면 총 12,679,000명입니다.

최종 messages 배열은 이렇게 생겼습니다.

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

----- 7-2 최종 메시지 배열 (총 7개) -----
[0] Human    | 서울 인구와 부산 인구를 더하면 몇 명이야?
[1] AI       | text="두 도시의 인구를 조회하겠습니다." tool_calls=[get_population({"city":"서울"}), get_population({"city":"부산"})]
[2] Tool     | name=get_population id=toolu_01Ab... status=success content="서울의 인구는 9,386,000명입니다."
[3] Tool     | name=get_population id=toolu_01Cd... status=success content="부산의 인구는 3,293,000명입니다."
[4] AI       | text="" tool_calls=[calculate({"a":9386000,"b":3293000,"op":"add"})]
[5] Tool     | name=calculate id=toolu_01Ef... status=success content="12679000"
[6] AI       | text="서울 인구 9,386,000명과 부산 인구 3,293,000명을 더하면 총 12,679,000명입니다." tool_calls=[]

이게 에이전트의 전부입니다. Human → AI → Tool → Tool → AI → Tool → AI. 이 손동작을 while 로 감싸면 그게 에이전트고, 그 while 에 이름을 붙인 게 createAgent 입니다.

한 가지 더 눈여겨볼 것: [4]text 가 빈 문자열입니다. 반면 [1] 은 text 와 tool_calls 를 둘 다 갖고 있습니다. 이 비일관성이 다음 절의 함정으로 이어집니다.

💡 실무 팁 — tool.invoke(toolCall) 지름길: 위에서는 invoke(toolCall.args) 로 순수 결과값을 받아 ToolMessage 를 손으로 만들었습니다. 원리를 보려고 그랬습니다. 사실 LangChain 도구는 toolCall 객체를 통째로 받으면 ToolMessage 를 직접 돌려줍니다.

// 인자만 넘기면 → 순수 결과값 (string)
const raw = await getWeather.invoke({ city: "서울" });

// toolCall 을 통째로 넘기면 → ToolMessage
const msg = await getWeather.invoke({
  name: "get_weather",
  args: { city: "부산" },
  id: "call_demo_123",
  type: "tool_call",
});
console.log(msg.constructor.name);  // "ToolMessage"
console.log(msg.tool_call_id);      // "call_demo_123"

타입 수준에서도 TInput extends ToolCall ? ToolMessage : TOutput 로 갈립니다. tool_call_id 를 손으로 채우다 틀릴 일이 없어지므로 실전에서는 이 형태를 쓰세요. 다만 지금은 루프를 배우는 중이니, 무엇이 자동화되고 있는지 알고 쓰는 게 중요합니다.


7-3. 루프 종료 조건 — tool_calls 가 비면 끝

의사코드의 이 줄을 다시 봅시다.

만약 ai.tool_calls 가 비었으면:
    return ai.text

정답은 이것 하나뿐입니다. 그런데 여기서 아주 그럴듯한 오답이 하나 있습니다.

// ✅ 올바른 종료 판정
const isDone = (m: AIMessage) => (m.tool_calls ?? []).length === 0;

// ❌ 틀린 종료 판정 — "모델이 뭐라도 말했으면 끝난 거 아냐?"
const wrongIsDone = (m: AIMessage) => m.text.length > 0;

두 함수를 실제 응답에 물려 봅시다.

const noTool = await modelWithTools.invoke([new HumanMessage("안녕! 너는 누구야?")]);
const needTool = await modelWithTools.invoke([new HumanMessage("제주 날씨 알려줘")]);

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

[도구 불필요] tool_calls 길이: 0
[도구 불필요] text 있음?     : true

[도구 필요]   tool_calls 길이: 1
[도구 필요]   text 있음?     : true   ← text 가 있어도 tool_calls 가 있으면 아직 안 끝났습니다
[도구 필요]   text 내용    : "제주 날씨를 확인해볼게요."

isDone(noTool)   = true    ← 종료
isDone(needTool) = false   ← 계속
wrongIsDone(needTool) = true    ← text 로 판단하면 도구를 실행도 안 하고 끝내버립니다

needTool 이 문제입니다. texttool_calls 가 동시에 있습니다.

이건 버그가 아니라 정상입니다. AIMessagecontent 는 문자열이 아니라 블록의 배열이라서, [{type: "text", ...}, {type: "tool_use", ...}] 처럼 둘이 공존할 수 있습니다. 모델은 "제주 날씨를 확인해볼게요" 라고 말하면서 동시에 도구를 부르는 게 자연스럽습니다. 사람도 그렇게 합니다.

⚠️ 함정 (이번 스텝 2번 함정 — 가장 조용한 것): 종료 조건을 content/text 유무로 판단하면 안 됩니다. wrongIsDone 을 쓰면 도구를 실행조차 하지 않고 루프를 끝내고, "제주 날씨를 확인해볼게요." 를 최종 답변으로 사용자에게 내놓습니다.

이게 왜 최악이냐면 — 에러가 안 납니다. 로그도 깨끗합니다. 400 도 안 뜹니다. 테스트에서 "도구 안 부르는 질문"만 던져봤다면 통과합니다. 사용자만 날씨를 영영 못 받습니다. 게다가 답변이 "확인해볼게요" 라서 언뜻 정상 응답처럼 보입니다.

반대 방향의 오답도 있습니다. text 가 비었으면 계속 돌리는 코드는, 모델이 도구 없이 빈 답변을 준 순간 무한 루프에 빠집니다.

종료는 tool_calls 하나로만 판단하세요. text 는 종료 여부와 무관합니다.

그 외의 종료 조건들

tool_calls 가 비는 것이 정상 종료이고, 실무에서는 아래 조건들이 추가로 붙습니다.

종료 조건성격대응
tool_calls 가 빔정상 종료ai.text 를 답으로 반환
최대 반복 횟수 도달비정상throw 하거나 "미완료" 표시. 조용히 넘기면 안 됨 (7-5)
특정 도구 호출 (final_answer 등)정상그 도구를 종료 신호로 약속
사용자 중단 (AbortSignal)취소진행 중 요청 정리
예산/토큰 초과비정상부분 결과 반환 또는 요약 후 재개
HITL 승인 대기일시 정지상태 저장 후 중단 → 나중에 재개 (Step 13)

지금 우리 루프는 위의 1번과 2번만 다룹니다. 나머지는 각 스텝에서 다시 만납니다. 중요한 건 "루프를 빠져나온 이유가 여러 개" 라는 사실이고, 이걸 구분하지 않으면 7-5 의 함정에 빠집니다.


7-4. 병렬 도구 호출 — 한 AIMessagetool_calls 가 여러 개일 때

7-2 의 출력을 다시 보세요.

[1] AI | tool_calls=[get_population({"city":"서울"}), get_population({"city":"부산"})]

AIMessagetool_calls 가 2개 들어 있었습니다. 모델은 서로 의존하지 않는 도구들을 한 번에 요청할 수 있습니다. 서울 인구와 부산 인구는 서로 관계가 없으니 굳이 순서대로 물어볼 이유가 없습니다.

이걸 어떻게 실행하느냐로 성능이 갈립니다. get_population 에는 1초 지연이 걸려 있습니다.

// (A) 순차 실행 — 도구 개수 × 1초
for (const toolCall of ai.tool_calls ?? []) {
  const output = await findTool(toolCall.name).invoke(toolCall.args);
  sequential.push(new ToolMessage({
    content: String(output),
    tool_call_id: toolCall.id!,
    name: toolCall.name,
  }));
}

// (B) 병렬 실행 — 약 1초
const parallel = await Promise.all(
  (ai.tool_calls ?? []).map(async (toolCall) => {
    const output = await findTool(toolCall.name).invoke(toolCall.args);
    return new ToolMessage({
      content: String(output),
      tool_call_id: toolCall.id!,   // ← 클로저로 잡은 toolCall 에서 직접
      name: toolCall.name,
    });
  }),
);

출력 예시 (모델 응답이므로 매번 다릅니다. 시간은 환경에 따라 다릅니다)

tool_calls 개수: 3
  - get_population({"city":"서울"}) id=toolu_01Ab...
  - get_population({"city":"부산"}) id=toolu_01Cd...
  - get_population({"city":"제주"}) id=toolu_01Ef...

(A) 순차 실행: 3021ms
(B) 병렬 실행: 1008ms  ← 약 1/3

도구가 3개면 3배, 10개면 10배 차이입니다. 실제 도구는 대부분 네트워크 I/O(DB 조회, API 호출)라서 이 차이가 그대로 사용자 대기 시간이 됩니다.

⚠️ 함정 (이번 스텝 3번 함정): 병렬 실행에서 ToolMessage 의 짝을 배열 인덱스로 맞추면 안 됩니다.

// ❌ 위험한 코드
const outputs = await Promise.all(toolCalls.map((c) => findTool(c.name).invoke(c.args)));
const toolMessages = outputs.map((output, i) => new ToolMessage({
  content: String(output),
  tool_call_id: toolCalls[i].id!,   // ← 인덱스로 짝 맞추기
  name: toolCalls[i].name,
}));

"Promise.all 은 순서를 보장하잖아요?" — 맞습니다. Promise.all 이 보장하는 건 결과 배열의 순서입니다 (실행 완료 순서가 아니라). 그래서 위 코드는 지금은 동작합니다. 그게 함정입니다.

문제는 그 다음입니다. 첫째, provider 는 배열 순서가 아니라 tool_call_id 로 짝을 찾습니다. id 만 맞으면 ToolMessage 를 어떤 순서로 넣든 됩니다. 반대로 순서가 맞아도 id 가 틀리면 400 입니다. 즉 배열 순서는 애초에 짝짓기에 쓰이지 않습니다.

둘째, 진짜 사고는 Promise.all 을 벗어날 때 납니다. 실무에서는 리팩터링이 들어옵니다 — 도구 실행을 큐에 넣거나, Promise.allSettled 로 바꾸거나, 일부는 캐시에서 즉답하고 일부만 실행하거나, 실패한 것만 재시도하거나. 그 순간 "i번째 결과는 i번째 tool_call 의 것" 이라는 가정이 조용히 깨집니다. 인덱스로 짝을 맞춘 코드는 그때 부산의 인구를 서울 질문에 붙여 놓고도 에러 없이 잘 돌아갑니다. 모델은 그 틀린 숫자로 태연히 계산합니다.

위의 (B) 처럼 map 콜백 안에서 toolCall 을 클로저로 잡으면 인덱스를 쓸 일 자체가 없어집니다. 인덱스 버그를 구조적으로 막는 방법입니다.

💡 실무 팁: 병렬 실행에는 대가가 있습니다. 부작용이 있는 도구(파일 쓰기, 결제, 이메일 발송)를 무조건 Promise.all 로 돌리면 경합이 생깁니다. 모델이 같은 파일에 두 번 쓰기를 동시에 요청할 수도 있습니다. 실무에서는 도구를 "읽기(병렬 안전) / 쓰기(직렬화 필요)" 로 나누고, 쓰기 도구는 순차 처리하거나 락을 겁니다. 또 하나 — 도구 하나가 30초 걸리면 Promise.all 전체가 30초를 기다립니다. 도구별 타임아웃을 걸고, 타임아웃도 에러 ToolMessage 로 되돌리세요 (7-6).


7-5. 무한 루프 방어 — recursionLimit 의 정체

7-1 의 의사코드에는 치명적인 결함이 있습니다.

반복:        # ← 언제까지?

상한이 없습니다. 모델이 같은 도구를 계속 요청하면 루프는 영원히 돕니다. 그리고 매 바퀴가 유료 API 호출입니다.

더 나쁜 건 비용이 선형이 아니라는 점입니다. 매 바퀴마다 messages 배열이 길어지고, 그 배열 전체를 다시 보냅니다. 즉 입력 토큰이 바퀴마다 누적됩니다. 10바퀴째의 호출은 1바퀴째보다 훨씬 비쌉니다. 비용은 대략 이차로 증가합니다. 밤새 돌면 청구서가 폭발합니다.

해결은 간단합니다. 세면 됩니다.

const MAX_ITERATIONS = 5;

let iteration = 0;
let finished = false;   // ★ 루프를 빠져나온 '이유' 를 기록하는 플래그

while (iteration < MAX_ITERATIONS) {
  iteration += 1;
  const ai = await modelWithTools.invoke(messages);
  messages.push(ai);

  if ((ai.tool_calls ?? []).length === 0) {
    finished = true;          // ← 정상 종료
    break;
  }

  const toolMessages = await Promise.all(/* ... 도구 실행 ... */);
  messages.push(...toolMessages);
}

if (!finished) {
  // ← 상한 도달. 여기가 핵심입니다.
  console.log(`상한 도달! ${MAX_ITERATIONS}바퀴를 다 썼는데 아직 tool_calls 가 남아 있습니다.`);
}

⚠️ 함정 (이번 스텝 4번 함정): finished 플래그가 이 코드의 요점입니다. while 을 빠져나오는 길이 두 개인데(정상 종료 / 상한 도달) 플래그 없이는 구분할 수 없습니다.

구분하지 않으면 무슨 일이 벌어질까요? 상한에 걸려 잘려나간 미완성 상태의 마지막 AIMessage 를 "완성된 답변" 으로 착각해 사용자에게 줍니다. 그 AIMessagetool_calls 만 있고 text 는 비어 있을 수도 있습니다. 그러면 사용자는 빈 답변을 받습니다. 아니면 "잠시만요, 확인해볼게요" 를 받습니다. 에러도 안 나고 로그도 깨끗한데 답만 없습니다.

상한 도달은 예외 상황입니다. throw 하거나, 최소한 "미완료" 를 명시적으로 표시하세요.

recursionLimit — 프레임워크 버전의 같은 것

createAgent 에도 당연히 이 상한이 있습니다. 이름이 recursionLimit 입니다.

const agent = createAgent({ model, tools });

await agent.invoke(
  { messages: [{ role: "user", content: "서울과 부산 인구를 더해줘" }] },
  { recursionLimit: 1 },   // ← configurable 안이 아니라 config 최상위
);

출력 예시 (에러 shape 은 결정적입니다)

에러 이름   : GraphRecursionError
에러 메시지 : Recursion limit of 1 reached without hitting a stop condition. You can increase the limit by setting the `recursionLimit` config key.

우리 runAgent 가 던지던 그 에러의 프레임워크 버전입니다. 알아둘 사실이 세 가지 있습니다.

항목
기본값25
단위super-step (바퀴가 아님)
초과 시GraphRecursionError throw
지정 위치invoke 의 config 최상위 (configurable 안이 아님)

⚠️ 함정: recursionLimit: 25 는 "도구를 25번 부를 수 있다" 는 뜻이 아닙니다. 단위는 LangGraph 의 super-step 입니다. 공식 문서 표현으로는 "A super-step can be considered a single iteration over the graph nodes. Nodes that run in parallel are part of the same super-step, while nodes that run sequentially belong to separate super-steps."

에이전트의 한 바퀴 = 모델 노드(1 step) + 도구 노드(1 step) = 2 step 입니다. 그러니 25 는 대략 12바퀴입니다. 절반으로 나눠 생각하세요.

그리고 { configurable: { recursionLimit: 25 } } 로 넣으면 조용히 무시됩니다. 에러가 안 납니다 — configurable 은 아무 키나 받는 자유 영역이라 오타를 잡아주지 않기 때문입니다. 그냥 기본값 25 로 돌아갑니다. thread_idconfigurable 에, recursionLimit최상위에. 헷갈리기 쉬우니 외워두세요.

💡 실무 팁 — 상한에 자꾸 걸린다면 그건 증상이지 원인이 아닙니다. recursionLimit 을 100 으로 올리는 건 대개 오답입니다. 진짜 원인은 보통 이 셋 중 하나입니다.

  1. 도구 설명이 부실해서 모델이 뭘 골라야 할지 몰라 이것저것 찔러본다 → description 을 고치세요 (Step 06).
  2. 도구가 결과를 안 주거나 애매하게 줘서 모델이 같은 도구를 반복 호출한다 → content 를 명확하게.
  3. 작업이 진짜로 크다 → 이땐 서브에이전트로 쪼개는 게 맞습니다 (Step 18).

프로덕션에서는 상한을 넉넉하지 않게 잡는 게 안전합니다. 상한은 안전벨트지 성능 옵션이 아닙니다.


7-6. 에러 처리 — 도구 실패를 ToolMessage 로 모델에게 돌려주기

지금까지 우리 루프에는 try/catch 가 없었습니다. 도구가 throw 하면 어떻게 될까요?

await getWeather.invoke({ city: "도쿄" });   // WEATHER_DB 에 도쿄가 없다

출력 예시 (에러 메시지는 우리 도구가 만든 것이라 결정적입니다)

→ 던져진 에러: '도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주
→ 이 예외가 while 루프를 뚫고 나가면 대화가 통째로 죽습니다.

예외가 루프를 뚫고 나가면 대화 전체가 끝납니다. 사용자는 500 을 받습니다. 그런데 생각해 보면 이건 과잉 반응입니다. 도구 하나가 실패한 것뿐인데 대화를 죽일 이유가 없습니다. 사람이라면 "도쿄는 안 되는구나, 그럼 다른 걸 해보자" 하겠죠.

모델도 그럴 수 있습니다. 실패했다고 알려주기만 하면 됩니다.

const toolMessages = await Promise.all(
  (ai.tool_calls ?? []).map(async (toolCall) => {
    try {
      const output = await findTool(toolCall.name).invoke(toolCall.args);
      return new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
        status: "success",
      });
    } catch (error) {
      // ★ 에러도 '결과'입니다. tool_call_id 를 채워서 반드시 돌려줍니다.
      return new ToolMessage({
        content: `도구 실행 실패: ${(error as Error).message}`,
        tool_call_id: toolCall.id!,
        name: toolCall.name,
        status: "error",
      });
    }
  }),
);

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

  [1바퀴] get_weather:error

최종 답변: 죄송합니다. 현재 도쿄의 날씨 정보는 제공되지 않습니다. 조회 가능한 도시는 서울, 부산, 제주입니다. 이 중에서 확인하고 싶은 도시가 있으신가요?

----- 7-6 최종 메시지 배열 (총 5개) -----
[0] System   | 너는 날씨 비서다. 도구가 에러를 돌려주면 사용자에게 사과하고 가능한 대안을 안내해라.
[1] Human    | 도쿄 날씨 알려줘
[2] AI       | text="" tool_calls=[get_weather({"city":"도쿄"})]
[3] Tool     | name=get_weather id=toolu_01Ab... status=error content="도구 실행 실패: '도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주"
[4] AI       | text="죄송합니다. 현재 도쿄의 날씨 정보는..." tool_calls=[]

모델이 스스로 회복했습니다. 우리는 아무 분기도 안 썼습니다. 그냥 실패 사실을 말해줬을 뿐입니다. 이게 에이전트가 강력한 이유입니다 — 예외 처리를 if 로 짜는 게 아니라 모델에게 상황을 알려주고 판단을 맡기는 것입니다.

status 는 모델이 안 읽는다

⚠️ 함정: status: "error""success" 로 바꿔도 모델의 답변은 거의 달라지지 않습니다. 모델이 실제로 읽는 것은 content이기 때문입니다. status 는 LangChain 이 메시지 객체에 붙여 두는 메타데이터이고, provider 포맷으로 나갈 때 "이 결과는 에러였다" 정도의 플래그로만 전달되거나 아예 무시됩니다.

모델을 움직인 것은 "'도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주" 라는 문장 자체입니다. status 를 정확히 채워놓고 content"Error" 라고만 적으면 모델은 아무것도 못 합니다.

그러면 status 는 왜 채우나요? 사람과 코드를 위해서입니다. 로그를 필터링하거나, "에러가 2번 연속이면 중단" 같은 정책을 우리 코드가 판단할 때 씁니다. 모델용이 아니라 우리용입니다.

💡 실무 팁 — 에러 메시지도 프롬프트입니다. 도구가 실패했을 때 content 에 뭘 적느냐가 회복률을 결정합니다.

예시모델의 반응
"Error"뭘 해야 할지 모름. 같은 호출을 반복하거나 포기
"TypeError: Cannot read properties of undefined (reading 'temp')"스택 트레이스는 모델에게 소음. 토큰만 먹음
"HTTP 429"재시도해야 할지 판단 불가
"'도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주"대안을 제시하거나 사용자에게 되물음
"요청이 너무 많습니다. 10초 후 재시도하세요."다른 도구를 먼저 처리하거나 안내

원칙은 "무엇이 / 왜 실패했고 / 어떤 선택지가 있는지" 입니다. 내부 예외를 그대로 String(error) 로 흘려보내지 말고, 도구 안에서 모델이 읽을 문장으로 번역하세요. 반대로 민감 정보(DB 접속 문자열, 내부 경로, 토큰)가 에러 메시지에 섞여 모델에게 전달되지 않도록 주의해야 합니다.

⚠️ 함정 (7-2 함정의 재확인): 절대 하면 안 되는 것 — 실패한 도구의 ToolMessage 를 빼고 보내기.

// ❌ 이렇게 하면 400
catch (error) {
  console.error(error);
  return null;             // 그리고 filter(Boolean) 으로 걸러냄
}

"실패했으니 결과가 없다" 는 자연스러운 생각이지만, provider 는 tool_calls N개에 ToolMessage N개를 요구합니다. 실패해도 자리는 채워야 합니다. 도구 3개 중 1개가 실패했을 때 2개만 보내면 나머지 2개의 성공 결과까지 함께 400 으로 날아갑니다.


7-7. 종합 — 우리가 만든 루프 vs createAgent

이제 7-2 ~ 7-6 을 전부 합칩니다. 이게 완성된 미니 에이전트입니다.

async function runAgent(options: {
  input: string;
  systemPrompt?: string;
  maxIterations?: number;
  verbose?: boolean;
}): Promise<{ messages: BaseMessage[]; output: string; iterations: number }> {
  const { input, systemPrompt, maxIterations = 10, verbose = false } = options;

  // 1) 모델에 도구 스펙을 붙인다.
  const modelWithTools = model.bindTools(tools);

  // 2) 대화 상태 = 메시지 배열. 이게 에이전트의 전체 기억입니다.
  const messages: BaseMessage[] = [];
  if (systemPrompt) messages.push(new SystemMessage(systemPrompt));
  messages.push(new HumanMessage(input));

  // 3) 루프.
  for (let iteration = 1; iteration <= maxIterations; iteration++) {
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);

    const toolCalls = ai.tool_calls ?? [];
    if (verbose) {
      console.log(`  [${iteration}바퀴] tool_calls=${toolCalls.length}`);
    }

    // 4) 종료 조건 — tool_calls 가 비었으면 끝. text 유무로 판단하지 않습니다.
    if (toolCalls.length === 0) {
      return { messages, output: ai.text, iterations: iteration };
    }

    // 5) 도구를 병렬로 실행하고, 결과를 ToolMessage 로 되돌린다.
    const toolMessages = await Promise.all(
      toolCalls.map(async (toolCall) => {
        const selected = toolsByName[toolCall.name];

        // 모델이 없는 도구를 지어낼 수도 있습니다. 이것도 에러로 되돌립니다.
        if (!selected) {
          return new ToolMessage({
            content: `'${toolCall.name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }

        try {
          const output = await selected.invoke(toolCall.args);
          return new ToolMessage({
            content: String(output),
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "success",
          });
        } catch (error) {
          return new ToolMessage({
            content: `도구 실행 실패: ${(error as Error).message}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
      }),
    );
    messages.push(...toolMessages);
  }

  // 6) 상한 도달. 조용히 넘어가지 않고 명시적으로 알립니다.
  throw new Error(
    `최대 반복 횟수(${maxIterations})를 넘었습니다. 도구 설명이 부실하거나 모델이 같은 도구를 반복 호출하고 있을 수 있습니다.`,
  );
}

약 80줄입니다. 이게 에이전트입니다.

같은 질문, 두 구현

const question = "서울과 부산의 인구를 더하면 몇 명이야? 계산은 도구로 해줘.";
const systemPrompt = "너는 도시 정보 비서다. 숫자 계산은 반드시 calculate 도구를 써라.";

// (A) 우리가 손으로 만든 것
const mine = await runAgent({ input: question, systemPrompt, verbose: true });

// (B) 프레임워크
const agent = createAgent({ model, tools, systemPrompt });
const result = await agent.invoke({
  messages: [{ role: "user", content: question }],
});

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

(A) runAgent (우리가 손으로 만든 것)
  [1바퀴] tool_calls=2 (get_population, get_population)
  [2바퀴] tool_calls=1 (calculate)
  [3바퀴] tool_calls=0
  → 답변: 서울과 부산의 인구를 합하면 12,679,000명입니다.
  → 바퀴 수: 3
  → 최종 메시지 개수: 8

(B) createAgent (프레임워크)
  → 답변: 서울(9,386,000명)과 부산(3,293,000명)의 인구를 더하면 12,679,000명입니다.
  → 최종 메시지 개수: 8

두 메시지 배열의 타입 시퀀스가 같은지 비교:
  (A) System → Human → AI → Tool → Tool → AI → Tool → AI
  (B) System → Human → AI → Tool → Tool → AI → Tool → AI

같습니다. 답도 같고(12,679,000 = 9,386,000 + 3,293,000, 검산 가능합니다), 메시지 배열의 모양도 같습니다. createAgent 는 우리 runAgent같은 루프입니다. 마법이 아닙니다.

그러면 createAgent 에는 뭐가 더 있나

기능우리 runAgentcreateAgent어디서 배우나
모델 + 도구 + 루프✅ 직접 구현✅ 동일이번 스텝
종료 조건 (tool_calls 기준)✅ 동일이번 스텝
병렬 도구 실행Promise.all✅ 동일이번 스텝
반복 상한maxIterationsrecursionLimit (기본 25 super-step)이번 스텝
도구 에러 → ToolMessagetry/catch✅ 내장 + toolRetryMiddleware이번 스텝
tool_call_id 짝 맞추기✅ 수동✅ 자동이번 스텝
시스템 프롬프트SystemMessagesystemPromptStep 04
구조화된 출력responseFormatstructuredResponseStep 05
스트리밍stream / streamEventsStep 09
대화 저장 (체크포인트)checkpointer + thread_idStep 10
미들웨어 훅middleware (wrapToolCall 등)Step 11, 12
Human-in-the-Loop (중단/재개)humanInTheLoopMiddlewareStep 13
런타임 컨텍스트 주입contextSchemaruntime.contextStep 14
관측(trace)✅ 콜백/LangSmith 연동Step 19

핵심은 위쪽 6줄이 이미 우리 손에 있다는 것입니다. createAgent 가 추가로 주는 것들은 전부 "이 루프의 어느 지점에 무엇을 끼워 넣느냐" 의 문제입니다.

  • 스트리밍 = modelWithTools.invoke 를 청크 단위로 흘리는 것
  • 체크포인터 = 매 바퀴 끝에 messages 배열을 저장하는 것
  • 미들웨어 = selected.invoke(toolCall.args) 앞뒤를 감싸는 것
  • HITL = 도구 실행 직전에 루프를 멈추고 상태를 저장했다가 나중에 재개하는 것

이제 이 문장들이 전부 우리 runAgent 코드의 몇 번째 줄을 가리키는지 보이나요? 그게 이 스텝의 목적이었습니다.

💡 실무 팁 — 그래서 직접 만들어 쓰나요? 아니요. createAgent 를 쓰세요. 위 표의 아래쪽 기능들을 직접 구현하면 코드가 80줄에서 수천 줄이 되고, 그건 여러분의 제품이 아니라 프레임워크를 만드는 일입니다. 이번 스텝의 목적은 대체가 아니라 예측입니다. 프레임워크를 블랙박스로 두지 않는 것. 프로덕션에서 GraphRecursionError 가 뜨거나, tool_call_id 400 이 뜨거나, 도구가 안 불리거나 할 때 — 루프를 아는 사람은 5분 만에 고치고, 모르는 사람은 GitHub 이슈를 뒤집니다.

💡 실무 팁: 이 스텝의 코드는 provider 를 전혀 타지 않습니다. initChatModel("anthropic:claude-sonnet-4-6")initChatModel("openai:gpt-5.5") 로 바꾸고 환경변수를 OPENAI_API_KEY 로 두면 runAgent한 글자도 안 바뀝니다. tool_calls / ToolMessage / tool_call_id 는 LangChain 이 provider 차이를 흡수해 만든 공통 추상이기 때문입니다. 달라지는 건 id 접두사(toolu_ vs call_) 정도입니다. 이게 LangChain 을 쓰는 가장 실질적인 이유입니다.


정리

에이전트의 한 바퀴:

bindTools → invoke → tool_calls 확인 → (비었나?) → 도구 실행 → ToolMessage 추가 → 반복
                                          ↓ 예
                                       ai.text 반환
개념요점
모델의 역할도구를 실행하지 않는다. tool_calls 라는 요청만 만든다
우리의 역할도구를 실행하고 결과를 ToolMessage 로 되돌린다
messages 배열에이전트의 기억 전부. 모델은 stateless 라 매 바퀴 통째로 다시 보낸다
tool_call_id요청과 응답을 잇는 유일한 끈. 순서가 아니다
종료 조건tool_calls.length === 0 하나뿐. text 는 무관
병렬 실행AIMessage 의 여러 tool_callsPromise.all
반복 상한필수. createAgentrecursionLimit 기본 25 super-step ≈ 12바퀴
도구 에러throw 하지 말고 ToolMessage 로 되돌려 모델이 회복하게 한다
createAgent같은 루프. 스트리밍/메모리/미들웨어/HITL 이 더 있을 뿐

핵심 함정 3가지

  1. ToolMessage 누락 → provider 400. tool_calls 가 N개면 ToolMessage 도 정확히 N개. 하나라도 빠지면 tool_use ids were found without tool_result blocks. 실패한 도구도 자리는 채워야 합니다. LangChain 이 검사해 주지 않으므로 에러는 provider 서버에서 납니다.
  2. 종료를 content/text 로 판정 → 도구를 실행도 안 하고 끝난다. 모델은 texttool_calls동시에 보냅니다. "확인해볼게요" 를 최종 답변으로 내놓으면서 에러도 로그도 남기지 않습니다. 종료는 tool_calls 로만 판정하세요.
  3. 병렬 결과를 인덱스로 짝짓기 → 지금은 되고 나중에 조용히 틀린다. Promise.all 이 순서를 보장해서 당장은 동작하지만, 큐/allSettled/캐시로 리팩터링하는 순간 엉뚱한 결과가 엉뚱한 질문에 붙습니다. 짝은 tool_call_id 로. map 콜백 안에서 toolCall 을 클로저로 잡으면 인덱스를 쓸 일이 없어집니다.

보너스 함정: 상한 도달을 정상 종료와 구분하지 않으면 미완성 결과를 완성된 답으로 착각합니다. recursionLimitconfigurable 안에 넣으면 조용히 무시됩니다(최상위에 넣으세요). bindTools 의 반환값을 버리면 에러 없이 에이전트가 아닌 것이 됩니다.


연습문제

  1. toolsbindTools 로 붙인 모델에게 "부산 날씨 어때?" 라고 물어, tool_calls 의 개수 / 첫 번째 tool_callname·args·id / response.text 를 각각 출력하세요. 그리고 주석으로 답하세요 — 이 시점에 WEATHER_DB 는 조회되었나요? (예/아니오, 왜?)
  2. "제주 날씨 알려줘" 로 시작해 한 바퀴를 손으로 돌리세요. while 을 쓰지 말고 펼쳐서 쓰세요: Human 넣기 → invoke → AIMessage push → 도구 실행 → ToolMessage push → invoke 한 번 더 → 최종 답변 출력. 마지막에 messages.length 를 출력하세요. 몇 개가 나와야 할까요?
  3. (함정) 문제 2와 똑같이 하되 ToolMessage 를 만드는 단계를 통째로 건너뛰고 바로 다시 invoke 하세요. try/catch 로 감싸 에러의 namemessage 를 출력하고, 주석으로 답하세요 — 이건 LangChain 이 낸 에러인가요, provider 가 낸 에러인가요? HTTP 상태 코드는?
  4. (a) 올바른 종료 판정 isDone(m: AIMessage): boolean 을 구현하세요. (b) 흔한 오답 wrongIsDone (text 가 있으면 끝)도 구현하세요. (c) "안녕, 반가워!"(도구 불필요)와 "서울 날씨 알려줘"(도구 필요)에 대해 두 함수의 판정을 각각 출력하고, 판정이 갈리는 케이스를 찾아 주석으로 설명하세요.
  5. "서울, 부산, 제주 인구를 각각 알려줘"tool_calls 를 여러 개 받아, (a) for 순차 실행 시간(ms), (b) Promise.all 병렬 실행 시간(ms) 을 각각 재서 비교 출력하세요. 그리고 주석으로 답하세요 — Promise.all 이 결과 순서를 보장하는데도 짝을 인덱스가 아니라 tool_call_id 로 맞춰야 하는 이유는?
  6. MAX_ITERATIONS = 3 상한을 건 while 루프로 "서울과 부산과 제주 인구를 모두 더하면?" 을 처리하세요. 루프를 빠져나온 이유(정상 종료 / 상한 도달)를 반드시 구분해서 출력하고, 실제 사용한 바퀴 수도 출력하세요. 주석으로 답하세요 — 상한을 안 걸면 최악의 경우 무슨 일이 벌어지나요? recursionLimit 기본값은 몇이고 그 단위는 무엇인가요?
  7. "도쿄 날씨 알려줘" 를 처리하세요. get_weatherthrow 하므로, 이를 try/catch 로 잡아 status: "error"ToolMessage 로 모델에게 되돌리고, 모델이 사과하며 대안을 안내하고 정상 종료하는 것을 확인하세요. 주석으로 답하세요 — status"success" 로 바꿔도 답변이 달라지나요? 왜?
  8. (종합) runAgent 를 완성하세요 — systemPrompt, maxIterations 상한, tool_calls 기준 종료, Promise.all 병렬 실행, 도구 에러 회복, 모델이 없는 도구 이름을 지어낸 경우도 ToolMessage 로 회복, 상한 도달 시 throw. 그다음 createAgent 에게 같은 질문·같은 systemPrompt 를 주고 두 결과의 메시지 타입 시퀀스가 같은지 비교 출력하세요.

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


다음 단계

Step 08 — createAgent, 첫 에이전트

우리가 만든 80줄을 한 줄로 줄입니다. 대신 이제는 그 한 줄 안에서 무슨 일이 벌어지는지 예측할 수 있는 상태로 갑니다. recursionLimit, responseFormat, systemPrompt 가 각각 우리 runAgent 의 몇 번째 줄에 해당하는지 짚어가며 진행합니다.


실습 파일

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

세 파일 모두 자기완결적입니다. 도구 3개(get_weather, get_population, calculate)와 모델 초기화가 각 파일 상단에 그대로 들어 있어, 복사해서 바로 돌릴 수 있습니다. 실행 전에 project/.envANTHROPIC_API_KEY 가 있어야 합니다.

npx tsx docs/reference/langchain/step-07-tool-loop/practice.ts

세 파일이 공유하는 설계 결정이 하나 있습니다. toolsByName 레지스트리를 AnyTool 이라는 최소 인터페이스({ name, invoke })로 단순화한 것입니다. 도구마다 schema 가 달라 배열 원소의 타입이 유니온이 되는데, 그 상태로 invoke 를 부르면 TypeScript 가 파라미터 타입을 교집합으로 좁혀 컴파일 에러를 냅니다. 이 스텝은 루프의 원리에 집중하는 곳이라 타입 곡예를 피했습니다 — 그리고 이것도 createAgent 가 대신 풀어주는 문제 중 하나입니다.

⚠️ 비용 주의: practice.ts 를 한 번 통째로 실행하면 모델을 20회 이상 호출합니다. solution.ts 도 비슷합니다. 학습용으로는 큰 금액이 아니지만, 파일 하단의 await sectionX() 줄을 주석 처리해 필요한 절만 돌리는 것을 권합니다. 특히 get_population 에는 1초 지연이 있어 전체 실행에 1~2분 걸립니다.

practice.ts

본문을 따라가며 손으로 쳐볼 예제를 [7-1] ~ [7-7] 주석 번호로 묶어 놓은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응하므로, 본문을 읽다 막히면 같은 번호의 블록을 찾아 실행해 보면 됩니다.

  • [7-2] 가 이 파일의 심장입니다. while 을 일부러 쓰지 않고 1바퀴 → 2바퀴 → 3바퀴를 펼쳐서 씁니다. 각 바퀴가 "모델에게 물어본다 / 우리가 도구를 실행한다" 두 덩어리로 나뉘어 있어, 루프의 한 주기가 정확히 무엇인지 눈으로 셀 수 있습니다. 마지막의 printMessages 출력이 Human → AI → Tool → Tool → AI → Tool → AI 로 나오는 것을 꼭 확인하세요.
  • [7-2 보너스]getWeather.invoke({city:"서울"})getWeather.invoke({name, args, id, type:"tool_call"}) 을 나란히 실행해 반환 타입이 갈리는 것(string vs ToolMessage)을 보여줍니다. 본문 팁의 지름길을 직접 확인하는 블록입니다.
  • [7-3]isDonewrongIsDone한 응답에 동시에 물려서 판정이 갈리는 순간을 잡아냅니다. 다만 모델이 매번 text 를 함께 보내지는 않으므로, wrongIsDone(needTool) = true 가 안 나올 수도 있습니다. 몇 번 다시 실행해 보세요. 이 비결정성 자체가 함정의 무서움입니다 — 열 번 중 아홉 번 통과하는 버그입니다.
  • [7-4] 는 순차와 병렬을 같은 tool_calls 로 두 번 실행해 시간을 잽니다. 도구를 두 번 실행하는 셈이라 실전에선 하면 안 되는 짓이지만, 3021ms vs 1008ms 를 한 화면에서 보려고 그렇게 했습니다. 마지막의 id 순서 비교 출력은 "순서는 같지만 그게 요점이 아니다" 를 말하기 위한 것입니다.
  • [7-5]finished 플래그를 눈여겨보세요. while 을 빠져나온 이유가 두 가지인데, 이 플래그가 없으면 "상한 도달"과 "정상 종료"를 구분할 수 없습니다. 본문 함정과 짝지어 읽으세요.
  • [7-7 보너스]createAgentrecursionLimit: 1 을 걸어 GraphRecursionError 를 일부러 터뜨립니다. 에러 이름과 메시지를 직접 보는 것이 목적입니다. 프로덕션에서 이 에러를 만났을 때 당황하지 않으려면 한 번은 봐 둬야 합니다. recursionLimitconfigurable 밖 최상위에 있다는 것도 이 블록에서 확인하세요.
/**
 * Step 07 — 도구 호출 루프 직접 구현
 * 실행: npx tsx docs/reference/langchain/step-07-tool-loop/practice.ts
 *
 * 이 파일은 본문 7-1 ~ 7-7 의 예제를 순서대로 담고 있습니다.
 * 모델 응답은 비결정적이므로 출력은 실행할 때마다 달라집니다.
 * (특히 모델이 도구를 몇 번에 나눠 부르는지는 매번 다를 수 있습니다.)
 */
import "dotenv/config";
import {
  initChatModel,
  tool,
  createAgent,
  AIMessage,
  HumanMessage,
  ToolMessage,
  SystemMessage,
} from "langchain";
import type { BaseMessage } from "@langchain/core/messages";
import * as z from "zod";

/* ===== 공통 준비 — 도구 3개와 모델 ===== */

// 도구가 조회할 가짜 데이터. 결정적(deterministic)이라 결과를 눈으로 검산할 수 있습니다.
const WEATHER_DB: Record<string, string> = {
  서울: "맑음, 기온 28도, 습도 55%",
  부산: "흐림, 기온 26도, 습도 78%",
  제주: "비, 기온 24도, 습도 90%",
};

const POPULATION_DB: Record<string, number> = {
  서울: 9_386_000,
  부산: 3_293_000,
  제주: 675_000,
};

const getWeather = tool(
  ({ city }) => {
    const found = WEATHER_DB[city];
    // 일부러 던집니다. 7-6 에서 이 에러를 ToolMessage 로 바꿔 모델에게 돌려줍니다.
    if (!found) {
      throw new Error(
        `'${city}' 의 날씨 데이터가 없습니다. 지원 도시: ${Object.keys(WEATHER_DB).join(", ")}`,
      );
    }
    return found;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({
      city: z.string().describe("도시 이름. 예: 서울, 부산, 제주"),
    }),
  },
);

const getPopulation = tool(
  async ({ city }) => {
    // 병렬 실행 효과를 눈으로 보기 위해 일부러 1초 지연을 넣습니다.
    await new Promise((resolve) => setTimeout(resolve, 1000));
    const found = POPULATION_DB[city];
    if (found === undefined) {
      throw new Error(
        `'${city}' 의 인구 데이터가 없습니다. 지원 도시: ${Object.keys(POPULATION_DB).join(", ")}`,
      );
    }
    return `${city}의 인구는 ${found.toLocaleString("ko-KR")}명입니다.`;
  },
  {
    name: "get_population",
    description: "특정 도시의 인구를 조회합니다.",
    schema: z.object({
      city: z.string().describe("도시 이름. 예: 서울, 부산, 제주"),
    }),
  },
);

const calculate = tool(
  ({ a, b, op }) => {
    switch (op) {
      case "add":
        return String(a + b);
      case "sub":
        return String(a - b);
      case "mul":
        return String(a * b);
      case "div":
        if (b === 0) throw new Error("0 으로 나눌 수 없습니다.");
        return String(a / b);
    }
  },
  {
    name: "calculate",
    description: "두 수의 사칙연산을 계산합니다. 암산 대신 반드시 이 도구를 쓰세요.",
    schema: z.object({
      a: z.number().describe("첫 번째 피연산자"),
      b: z.number().describe("두 번째 피연산자"),
      op: z.enum(["add", "sub", "mul", "div"]).describe("연산 종류"),
    }),
  },
);

const tools = [getWeather, getPopulation, calculate];

/**
 * 도구 이름 → 도구 객체 조회용 레지스트리.
 *
 * 도구마다 schema 가 달라서 배열 원소의 타입이 유니온이 됩니다. 그 상태로 invoke 를 부르면
 * TypeScript 가 파라미터 타입을 교집합으로 좁혀 버려서 에러가 납니다. 여기서는 루프의 원리에
 * 집중하기 위해 최소 인터페이스로 단순화합니다. (createAgent 는 내부에서 이 문제를 대신 풀어줍니다.)
 */
type AnyTool = { name: string; invoke: (input: any) => Promise<any> };
const toolsByName: Record<string, AnyTool> = Object.fromEntries(
  tools.map((t) => [t.name, t as unknown as AnyTool]),
);

/**
 * 이름으로 도구를 찾습니다.
 *
 * 모델은 존재하지 않는 도구 이름을 지어낼 수 있습니다(hallucination). 레지스트리 조회 결과가
 * undefined 일 수 있다는 뜻이고, TypeScript 의 noUncheckedIndexedAccess 가 바로 이 지점을
 * 잡아 줍니다. 7-1 ~ 7-6 에서는 일단 던지고, 7-7 의 runAgent 에서는 이 상황마저
 * ToolMessage 로 바꿔 모델에게 돌려주는 '어른스러운' 처리를 합니다.
 */
function findTool(name: string): AnyTool {
  const found = toolsByName[name];
  if (!found) {
    throw new Error(
      `'${name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
    );
  }
  return found;
}

// 모델은 문자열 한 줄로 만듭니다. OpenAI 를 쓰려면 "openai:gpt-5.5" 로 바꾸고
// 환경변수를 OPENAI_API_KEY 로 두면 아래 코드는 한 글자도 안 바뀝니다.
const model = await initChatModel("anthropic:claude-sonnet-4-6");

/** 메시지 배열을 한눈에 보기 위한 출력 헬퍼 */
function printMessages(messages: BaseMessage[], title: string): void {
  console.log(`\n----- ${title} (총 ${messages.length}개) -----`);
  for (const [i, m] of messages.entries()) {
    if (m instanceof SystemMessage) {
      console.log(`[${i}] System   | ${m.text}`);
    } else if (m instanceof HumanMessage) {
      console.log(`[${i}] Human    | ${m.text}`);
    } else if (m instanceof AIMessage) {
      const calls = (m.tool_calls ?? [])
        .map((c) => `${c.name}(${JSON.stringify(c.args)})`)
        .join(", ");
      console.log(
        `[${i}] AI       | text=${JSON.stringify(m.text)} tool_calls=[${calls}]`,
      );
    } else if (m instanceof ToolMessage) {
      console.log(
        `[${i}] Tool     | name=${m.name} id=${m.tool_call_id} status=${m.status} content=${JSON.stringify(m.text)}`,
      );
    }
  }
}

/* ===== [7-1] 에이전트 = 모델 + 도구 + 루프 ===== */
async function section7_1(): Promise<void> {
  console.log("\n=== [7-1] 모델은 도구를 '실행'하지 않는다. '요청'만 한다 ===");

  // bindTools 는 모델을 바꾸지 않습니다. 도구 스펙이 붙은 "새 모델 객체"를 돌려줍니다.
  const modelWithTools = model.bindTools(tools);

  const response = await modelWithTools.invoke([
    new HumanMessage("서울 날씨 어때?"),
  ]);

  console.log("응답 타입      :", response.constructor.name); // AIMessage
  console.log("response.text  :", JSON.stringify(response.text));
  console.log("tool_calls     :", JSON.stringify(response.tool_calls, null, 2));

  // 핵심: 여기서 날씨는 아직 조회되지 않았습니다. WEATHER_DB 는 열린 적도 없습니다.
  // 모델이 한 일은 "get_weather 를 이런 인자로 불러줘" 라는 '요청'을 만든 것뿐입니다.
  console.log(
    "\n실제로 도구를 실행한 사람은? → 아무도 없음. 실행은 우리(애플리케이션) 몫입니다.",
  );
}

/* ===== [7-2] 손으로 만드는 ReAct 루프 ===== */
async function section7_2(): Promise<void> {
  console.log("\n=== [7-2] 루프를 펼쳐서 한 바퀴씩 손으로 돌려보기 ===");

  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [
    new HumanMessage("서울 인구와 부산 인구를 더하면 몇 명이야?"),
  ];

  // --- 1바퀴: 모델에게 물어본다 ---
  const ai1 = await modelWithTools.invoke(messages);
  messages.push(ai1); // ★ AIMessage 를 반드시 배열에 넣어야 합니다.
  console.log(
    "\n[1바퀴] 모델이 요청한 도구:",
    (ai1.tool_calls ?? []).map((c) => c.name).join(", ") || "(없음)",
  );

  // --- 1바퀴: 우리가 도구를 실행하고 ToolMessage 로 되돌린다 ---
  for (const toolCall of ai1.tool_calls ?? []) {
    const selected = findTool(toolCall.name);
    const output = await selected.invoke(toolCall.args); // 순수 결과값(문자열)

    // ★ ToolMessage 의 tool_call_id 는 반드시 toolCall.id 와 같아야 합니다.
    //   이게 "어떤 요청에 대한 답인지" 를 잇는 유일한 끈입니다.
    messages.push(
      new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      }),
    );
    console.log(`         └ 실행 결과: ${output}`);
  }

  // --- 2바퀴: 도구 결과가 붙은 메시지 배열을 통째로 다시 넣는다 ---
  const ai2 = await modelWithTools.invoke(messages);
  messages.push(ai2);
  console.log(
    "\n[2바퀴] 모델이 요청한 도구:",
    (ai2.tool_calls ?? []).map((c) => c.name).join(", ") || "(없음 → 종료 조건)",
  );

  // 인구 두 개를 받았으니 이제 calculate 를 부를 겁니다. 한 바퀴 더 돕니다.
  for (const toolCall of ai2.tool_calls ?? []) {
    const selected = findTool(toolCall.name);
    const output = await selected.invoke(toolCall.args);
    messages.push(
      new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      }),
    );
    console.log(`         └ 실행 결과: ${output}`);
  }

  const ai3 = await modelWithTools.invoke(messages);
  messages.push(ai3);
  console.log("\n[3바퀴] tool_calls 개수:", (ai3.tool_calls ?? []).length);
  console.log("최종 답변:", ai3.text);

  printMessages(messages, "7-2 최종 메시지 배열");

  // 여기까지가 에이전트의 전부입니다. 이 손동작을 while 로 감싸면 그게 에이전트입니다.
}

/* ===== [7-2 보너스] tool.invoke(toolCall) 지름길 ===== */
async function section7_2_shortcut(): Promise<void> {
  console.log("\n=== [7-2 보너스] tool.invoke 에 toolCall 통째로 넘기기 ===");

  // 인자(args)만 넘기면 → 순수 결과값이 나옵니다.
  const raw = await getWeather.invoke({ city: "서울" });
  console.log("invoke(args)     →", raw.constructor?.name ?? typeof raw, ":", raw);

  // toolCall 객체(= {name, args, id}) 를 통째로 넘기면 → ToolMessage 가 나옵니다.
  const asMessage = await getWeather.invoke({
    name: "get_weather",
    args: { city: "부산" },
    id: "call_demo_123",
    type: "tool_call",
  });
  console.log("invoke(toolCall) →", asMessage.constructor.name);
  console.log("  tool_call_id :", asMessage.tool_call_id);
  console.log("  name         :", asMessage.name);
  console.log("  content      :", asMessage.text);

  // tool_call_id 를 손으로 채우다 틀릴 일이 없으니 실전에서는 이 형태를 씁니다.
}

/* ===== [7-3] 루프 종료 조건 ===== */
async function section7_3(): Promise<void> {
  console.log("\n=== [7-3] 종료 조건은 tool_calls 가 비었는지로만 판단한다 ===");

  const modelWithTools = model.bindTools(tools);

  // 도구가 전혀 필요 없는 질문 → 1바퀴 만에 tool_calls 가 빈 채로 끝납니다.
  const noTool = await modelWithTools.invoke([
    new HumanMessage("안녕! 너는 누구야?"),
  ]);
  console.log("\n[도구 불필요] tool_calls 길이:", (noTool.tool_calls ?? []).length);
  console.log("[도구 불필요] text 있음?     :", noTool.text.length > 0);

  // 도구가 필요한 질문 → tool_calls 가 채워집니다.
  const needTool = await modelWithTools.invoke([
    new HumanMessage("제주 날씨 알려줘"),
  ]);
  console.log("\n[도구 필요]   tool_calls 길이:", (needTool.tool_calls ?? []).length);

  // ★ 함정: text 가 있다고 끝난 게 아닙니다.
  //   많은 모델이 "제주 날씨를 확인해볼게요" 같은 말과 tool_calls 를 '동시에' 보냅니다.
  console.log(
    "[도구 필요]   text 있음?     :",
    needTool.text.length > 0,
    "← text 가 있어도 tool_calls 가 있으면 아직 안 끝났습니다",
  );
  console.log("[도구 필요]   text 내용    :", JSON.stringify(needTool.text));

  // 올바른 종료 판정 — 오직 이것뿐입니다.
  const isDone = (m: AIMessage) => (m.tool_calls ?? []).length === 0;
  console.log("\nisDone(noTool)   =", isDone(noTool), "  ← 종료");
  console.log("isDone(needTool) =", isDone(needTool), "  ← 계속");

  // 틀린 종료 판정 예시 (절대 쓰지 마세요)
  const wrongIsDone = (m: AIMessage) => m.text.length > 0;
  console.log(
    "wrongIsDone(needTool) =",
    wrongIsDone(needTool),
    "  ← text 로 판단하면 도구를 실행도 안 하고 끝내버립니다",
  );
}

/* ===== [7-4] 병렬 도구 호출 ===== */
async function section7_4(): Promise<void> {
  console.log("\n=== [7-4] 한 AIMessage 안에 tool_calls 가 여러 개일 때 ===");

  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [
    new HumanMessage("서울, 부산, 제주 인구를 각각 알려줘."),
  ];

  const ai = await modelWithTools.invoke(messages);
  messages.push(ai);
  console.log("tool_calls 개수:", (ai.tool_calls ?? []).length);
  for (const c of ai.tool_calls ?? []) {
    console.log(`  - ${c.name}(${JSON.stringify(c.args)}) id=${c.id}`);
  }

  // (A) 순차 실행 — 도구마다 1초씩 걸리므로 3초 이상 걸립니다.
  const t0 = Date.now();
  const sequential: ToolMessage[] = [];
  for (const toolCall of ai.tool_calls ?? []) {
    const output = await findTool(toolCall.name).invoke(toolCall.args);
    sequential.push(
      new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      }),
    );
  }
  console.log(`\n(A) 순차 실행: ${Date.now() - t0}ms`);

  // (B) 병렬 실행 — Promise.all 은 입력 배열 순서대로 결과를 돌려주므로
  //     tool_call_id 짝도 자동으로 맞습니다.
  const t1 = Date.now();
  const parallel = await Promise.all(
    (ai.tool_calls ?? []).map(async (toolCall) => {
      const output = await findTool(toolCall.name).invoke(toolCall.args);
      return new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      });
    }),
  );
  console.log(`(B) 병렬 실행: ${Date.now() - t1}ms  ← 약 1/3`);

  messages.push(...parallel);
  const final = await modelWithTools.invoke(messages);
  console.log("\n최종 답변:", final.text);

  // ★ 함정: "첫 번째 tool_call 은 서울이겠지" 라고 순서를 가정하면 안 됩니다.
  //   짝을 잇는 것은 배열 순서가 아니라 tool_call_id 입니다.
  console.log("\n[검산] 순차 결과와 병렬 결과의 id 순서가 같은가?");
  console.log("  순차:", sequential.map((m) => m.tool_call_id).join(", "));
  console.log("  병렬:", parallel.map((m) => m.tool_call_id).join(", "));
}

/* ===== [7-5] 무한 루프 방어 ===== */
async function section7_5(): Promise<void> {
  console.log("\n=== [7-5] 최대 반복 횟수 — recursionLimit 의 정체 ===");

  const modelWithTools = model.bindTools(tools);
  const MAX_ITERATIONS = 5; // 상한이 없으면 API 비용이 무한대로 갑니다.

  const messages: BaseMessage[] = [
    new HumanMessage("서울과 부산과 제주 인구를 모두 더하면?"),
  ];

  let iteration = 0;
  let finished = false;

  while (iteration < MAX_ITERATIONS) {
    iteration += 1;
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);
    console.log(
      `[${iteration}/${MAX_ITERATIONS}바퀴] tool_calls=${(ai.tool_calls ?? []).length}`,
    );

    if ((ai.tool_calls ?? []).length === 0) {
      finished = true;
      console.log("→ 정상 종료:", ai.text);
      break;
    }

    const toolMessages = await Promise.all(
      (ai.tool_calls ?? []).map(async (toolCall) => {
        const output = await findTool(toolCall.name).invoke(toolCall.args);
        return new ToolMessage({
          content: String(output),
          tool_call_id: toolCall.id!,
          name: toolCall.name,
        });
      }),
    );
    messages.push(...toolMessages);
  }

  // ★ while 을 빠져나온 이유가 두 가지입니다. 이걸 구분하지 않으면
  //   "상한에 걸려 강제 종료된 것"을 "정상 완료"로 착각합니다.
  if (!finished) {
    console.log(
      `→ 상한 도달! ${MAX_ITERATIONS}바퀴를 다 썼는데 아직 tool_calls 가 남아 있습니다.`,
    );
    console.log("  LangGraph 였다면 여기서 GraphRecursionError 가 던져집니다.");
  }

  console.log(`\n실제 사용한 바퀴 수: ${iteration}, 정상 종료 여부: ${finished}`);

  // 참고: createAgent 의 recursionLimit 기본값은 25 이고, 이건 "바퀴 수"가 아니라
  //      "그래프 super-step 수"입니다. 한 바퀴 = 모델 노드 + 도구 노드 = 2 step 이라
  //      25 는 대략 12바퀴에 해당합니다. 7-7 에서 실제로 걸어봅니다.
}

/* ===== [7-6] 에러 처리 ===== */
async function section7_6(): Promise<void> {
  console.log("\n=== [7-6] 도구가 던진 에러를 ToolMessage 로 모델에게 돌려주기 ===");

  // (A) 방어 없이: 도구가 throw 하면 루프 전체가 폭발합니다.
  console.log("\n(A) try/catch 없이 도구를 부르면:");
  try {
    await getWeather.invoke({ city: "도쿄" });
  } catch (error) {
    console.log("  → 던져진 에러:", (error as Error).message);
    console.log("  → 이 예외가 while 루프를 뚫고 나가면 대화가 통째로 죽습니다.");
  }

  // (B) 에러를 ToolMessage 로 감싸서 모델에게 되돌려주면, 모델이 스스로 고칩니다.
  console.log("\n(B) 에러를 ToolMessage(status: 'error') 로 돌려주면:");
  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [
    new SystemMessage(
      "너는 날씨 비서다. 도구가 에러를 돌려주면 사용자에게 사과하고 가능한 대안을 안내해라.",
    ),
    new HumanMessage("도쿄 날씨 알려줘"),
  ];

  for (let i = 0; i < 4; i++) {
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);
    if ((ai.tool_calls ?? []).length === 0) {
      console.log("\n최종 답변:", ai.text);
      break;
    }

    const toolMessages = await Promise.all(
      (ai.tool_calls ?? []).map(async (toolCall) => {
        try {
          const output = await findTool(toolCall.name).invoke(toolCall.args);
          return new ToolMessage({
            content: String(output),
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "success",
          });
        } catch (error) {
          // ★ 핵심: 에러도 '결과'입니다. 반드시 tool_call_id 를 채워서 돌려줘야
          //   메시지 배열이 짝을 이루고, 모델이 다음 수를 둘 수 있습니다.
          return new ToolMessage({
            content: `도구 실행 실패: ${(error as Error).message}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
      }),
    );
    messages.push(...toolMessages);
    console.log(
      `  [${i + 1}바퀴] ${toolMessages.map((m) => `${m.name}:${m.status}`).join(", ")}`,
    );
  }

  printMessages(messages, "7-6 최종 메시지 배열");

  // status: "error" 는 메시지 객체에 남는 표시일 뿐, 모델이 읽는 것은 content 입니다.
  // 그래서 content 에 "무엇이 왜 실패했고 어떻게 고칠 수 있는지" 를 적어야 모델이 회복합니다.
}

/* ===== [7-7] 미니 에이전트 vs createAgent ===== */

/**
 * ~80줄짜리 미니 에이전트. 7-2 ~ 7-6 에서 배운 것을 전부 합쳤습니다.
 * createAgent 가 하는 일의 뼈대가 정확히 이것입니다.
 */
async function runAgent(options: {
  input: string;
  systemPrompt?: string;
  maxIterations?: number;
  verbose?: boolean;
}): Promise<{ messages: BaseMessage[]; output: string; iterations: number }> {
  const { input, systemPrompt, maxIterations = 10, verbose = false } = options;

  // 1) 모델에 도구 스펙을 붙인다.
  const modelWithTools = model.bindTools(tools);

  // 2) 대화 상태 = 메시지 배열. 이게 에이전트의 전체 기억입니다.
  const messages: BaseMessage[] = [];
  if (systemPrompt) messages.push(new SystemMessage(systemPrompt));
  messages.push(new HumanMessage(input));

  // 3) 루프.
  for (let iteration = 1; iteration <= maxIterations; iteration++) {
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);

    const toolCalls = ai.tool_calls ?? [];
    if (verbose) {
      console.log(
        `  [${iteration}바퀴] tool_calls=${toolCalls.length}` +
          (toolCalls.length ? ` (${toolCalls.map((c) => c.name).join(", ")})` : ""),
      );
    }

    // 4) 종료 조건 — tool_calls 가 비었으면 끝. text 유무로 판단하지 않습니다.
    if (toolCalls.length === 0) {
      return { messages, output: ai.text, iterations: iteration };
    }

    // 5) 도구를 병렬로 실행하고, 결과를 ToolMessage 로 되돌린다.
    const toolMessages = await Promise.all(
      toolCalls.map(async (toolCall) => {
        const selected = toolsByName[toolCall.name];

        // 모델이 없는 도구를 지어낼 수도 있습니다. 이것도 에러로 되돌립니다.
        if (!selected) {
          return new ToolMessage({
            content: `'${toolCall.name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }

        try {
          const output = await selected.invoke(toolCall.args);
          return new ToolMessage({
            content: String(output),
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "success",
          });
        } catch (error) {
          return new ToolMessage({
            content: `도구 실행 실패: ${(error as Error).message}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
      }),
    );
    messages.push(...toolMessages);
  }

  // 6) 상한 도달. 조용히 넘어가지 않고 명시적으로 알립니다.
  throw new Error(
    `최대 반복 횟수(${maxIterations})를 넘었습니다. 도구 설명이 부실하거나 모델이 같은 도구를 반복 호출하고 있을 수 있습니다.`,
  );
}

async function section7_7(): Promise<void> {
  console.log("\n=== [7-7] 우리가 만든 루프 vs createAgent ===");

  const question = "서울과 부산의 인구를 더하면 몇 명이야? 계산은 도구로 해줘.";
  const systemPrompt = "너는 도시 정보 비서다. 숫자 계산은 반드시 calculate 도구를 써라.";

  // (A) 우리가 만든 미니 에이전트
  console.log("\n(A) runAgent (우리가 손으로 만든 것)");
  const mine = await runAgent({ input: question, systemPrompt, verbose: true });
  console.log("  → 답변:", mine.output);
  console.log("  → 바퀴 수:", mine.iterations);
  console.log("  → 최종 메시지 개수:", mine.messages.length);

  // (B) createAgent — 같은 모델, 같은 도구, 같은 프롬프트
  console.log("\n(B) createAgent (프레임워크)");
  const agent = createAgent({ model, tools, systemPrompt });
  const result = await agent.invoke({
    messages: [{ role: "user", content: question }],
  });
  const lastMessage = result.messages.at(-1) as AIMessage;
  console.log("  → 답변:", lastMessage.text);
  console.log("  → 최종 메시지 개수:", result.messages.length);

  // 메시지 배열의 '모양'이 같습니다. 이름만 다를 뿐 같은 루프입니다.
  printMessages(mine.messages, "(A) runAgent 의 메시지");
  printMessages(result.messages as BaseMessage[], "(B) createAgent 의 메시지");

  console.log(
    "\n두 메시지 배열의 타입 시퀀스가 같은지 비교:",
  );
  const shape = (ms: BaseMessage[]) =>
    ms.map((m) => m.constructor.name.replace("Message", "")).join(" → ");
  console.log("  (A)", shape(mine.messages));
  console.log("  (B)", shape(result.messages as BaseMessage[]));
}

/* ===== [7-7 보너스] recursionLimit 를 실제로 터뜨려 보기 ===== */
async function section7_7_recursion(): Promise<void> {
  console.log("\n=== [7-7 보너스] recursionLimit 를 1로 두면 ===");

  const agent = createAgent({ model, tools });

  try {
    // recursionLimit 은 configurable 안이 아니라 config 최상위에 둡니다.
    await agent.invoke(
      { messages: [{ role: "user", content: "서울과 부산 인구를 더해줘" }] },
      { recursionLimit: 1 },
    );
    console.log("(예상과 달리 성공했습니다 — 모델이 도구를 안 불렀을 수 있습니다)");
  } catch (error) {
    // GraphRecursionError. 우리 runAgent 가 던지던 그 에러의 프레임워크 버전입니다.
    console.log("에러 이름   :", (error as Error).name);
    console.log("에러 메시지 :", (error as Error).message);
  }
}

/* ===== 실행 ===== */
await section7_1();
await section7_2();
await section7_2_shortcut();
await section7_3();
await section7_4();
await section7_5();
await section7_6();
await section7_7();
await section7_7_recursion();

console.log("\n=== Step 07 practice 끝 ===");

exercise.ts

본문 "연습문제" 8개를 그대로 옮겨 담은 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되고 그 아래 // TODO: 여기에 작성하세요 자리가 비어 있습니다.

  • 상단의 공통 준비 블록(도구 3개, model, toolsByName, findTool)은 이미 완성되어 있습니다. 수정하지 말고 그대로 쓰세요. 여러분이 풀 것은 루프이지 도구 정의가 아닙니다.
  • 파일을 그대로 실행하면 아무것도 출력되지 않습니다. 정상입니다. 맨 아래 // await exercise1(); 처럼 전부 주석 처리되어 있으니, 풀고 싶은 문제의 주석을 풀어 실행하세요.
  • [문제 4] 만 함수 시그니처(isDone, wrongIsDone)가 미리 적혀 있고 return false; 로 채워져 있습니다. 타입체크를 통과시키려는 자리채움이니 몸통을 바꿔 쓰세요.
  • [문제 3]일부러 에러를 내는 문제입니다. 에러가 나야 정답입니다. try/catch 를 빼먹으면 파일이 거기서 죽습니다.
  • [문제 8]runAgent 는 문제 2~7 의 답을 전부 합친 것입니다. 앞 문제들을 건너뛰고 8번부터 시작하지 마세요. 특히 "모델이 없는 도구 이름을 지어낸 경우"는 앞 문제에 안 나오는 새 요구사항이니, findTool 을 쓰지 말고 toolsByName 을 직접 조회해야 한다는 힌트를 놓치지 마세요.
  • 파일 맨 아래 void [...] 두 줄은 "선언만 하고 안 썼다" 는 TypeScript 경고를 막기 위한 것입니다. 신경 쓰지 마세요.
/**
 * Step 07 — 도구 호출 루프 직접 구현 · 연습문제
 * 실행: npx tsx docs/reference/langchain/step-07-tool-loop/exercise.ts
 *
 * 아래 [문제 1] ~ [문제 8] 의 빈 곳을 채우세요.
 * 파일 맨 아래에서 풀고 싶은 문제의 주석을 풀어 실행하면 됩니다.
 *
 * 공통 준비(도구 3개, 모델, 레지스트리)는 이미 되어 있습니다. 그대로 쓰세요.
 */
import "dotenv/config";
import {
  initChatModel,
  tool,
  createAgent,
  AIMessage,
  HumanMessage,
  ToolMessage,
  SystemMessage,
} from "langchain";
import type { BaseMessage } from "@langchain/core/messages";
import * as z from "zod";

/* ===== 공통 준비 (수정하지 마세요) ===== */

const WEATHER_DB: Record<string, string> = {
  서울: "맑음, 기온 28도, 습도 55%",
  부산: "흐림, 기온 26도, 습도 78%",
  제주: "비, 기온 24도, 습도 90%",
};

const POPULATION_DB: Record<string, number> = {
  서울: 9_386_000,
  부산: 3_293_000,
  제주: 675_000,
};

const getWeather = tool(
  ({ city }) => {
    const found = WEATHER_DB[city];
    if (!found) {
      throw new Error(
        `'${city}' 의 날씨 데이터가 없습니다. 지원 도시: ${Object.keys(WEATHER_DB).join(", ")}`,
      );
    }
    return found;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름. 예: 서울, 부산, 제주") }),
  },
);

const getPopulation = tool(
  async ({ city }) => {
    await new Promise((resolve) => setTimeout(resolve, 1000)); // 일부러 느리게
    const found = POPULATION_DB[city];
    if (found === undefined) {
      throw new Error(
        `'${city}' 의 인구 데이터가 없습니다. 지원 도시: ${Object.keys(POPULATION_DB).join(", ")}`,
      );
    }
    return `${city}의 인구는 ${found.toLocaleString("ko-KR")}명입니다.`;
  },
  {
    name: "get_population",
    description: "특정 도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름. 예: 서울, 부산, 제주") }),
  },
);

const calculate = tool(
  ({ a, b, op }) => {
    switch (op) {
      case "add":
        return String(a + b);
      case "sub":
        return String(a - b);
      case "mul":
        return String(a * b);
      case "div":
        if (b === 0) throw new Error("0 으로 나눌 수 없습니다.");
        return String(a / b);
    }
  },
  {
    name: "calculate",
    description: "두 수의 사칙연산을 계산합니다. 암산 대신 반드시 이 도구를 쓰세요.",
    schema: z.object({
      a: z.number().describe("첫 번째 피연산자"),
      b: z.number().describe("두 번째 피연산자"),
      op: z.enum(["add", "sub", "mul", "div"]).describe("연산 종류"),
    }),
  },
);

const tools = [getWeather, getPopulation, calculate];

type AnyTool = { name: string; invoke: (input: any) => Promise<any> };
const toolsByName: Record<string, AnyTool> = Object.fromEntries(
  tools.map((t) => [t.name, t as unknown as AnyTool]),
);

function findTool(name: string): AnyTool {
  const found = toolsByName[name];
  if (!found) {
    throw new Error(
      `'${name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
    );
  }
  return found;
}

const model = await initChatModel("anthropic:claude-sonnet-4-6");

/* ===== [문제 1] 모델은 도구를 실행하지 않는다 =====
 *
 * tools 를 bindTools 로 붙인 모델에게 "부산 날씨 어때?" 라고 물어보세요.
 * 그리고 돌아온 AIMessage 에서 아래 3가지를 각각 출력하세요.
 *   - tool_calls 의 개수
 *   - 첫 번째 tool_call 의 name / args / id
 *   - response.text (도구를 부르는 응답인데 text 가 비어 있나요? 있나요?)
 *
 * 마지막으로 주석으로 답하세요:
 *   → 이 시점에 WEATHER_DB 는 조회되었나요? (예/아니오, 왜?)
 */
async function exercise1(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 2] 한 바퀴를 손으로 돌리기 =====
 *
 * "제주 날씨 알려줘" 로 시작해서 아래 순서를 손으로 구현하세요. while 을 쓰지 말고 펼쳐서 쓰세요.
 *   1. messages 배열에 HumanMessage 를 넣는다
 *   2. modelWithTools.invoke(messages) → AIMessage 를 받아 messages 에 push
 *   3. AIMessage 의 tool_calls 를 순회하며 findTool 로 도구를 찾아 실행
 *   4. 결과를 ToolMessage 로 만들어 messages 에 push (tool_call_id 를 꼭 채우세요)
 *   5. modelWithTools.invoke(messages) 를 한 번 더 → 최종 답변 출력
 *
 * 마지막에 messages.length 를 출력하세요. 몇 개가 나와야 할까요?
 */
async function exercise2(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 3] (함정) ToolMessage 를 빼먹으면 =====
 *
 * 문제 2와 똑같이 하되, 3~4 단계를 통째로 건너뛰세요.
 * 즉 tool_calls 가 담긴 AIMessage 만 messages 에 넣고, ToolMessage 없이 바로 invoke 를 부릅니다.
 *
 *   const messages = [new HumanMessage("제주 날씨 알려줘")];
 *   const ai = await modelWithTools.invoke(messages);
 *   messages.push(ai);
 *   // ← 여기서 ToolMessage 를 안 넣고
 *   await modelWithTools.invoke(messages);   // 무슨 일이 벌어질까요?
 *
 * try/catch 로 감싸서 에러의 name 과 message 를 출력하세요.
 * 그리고 주석으로 답하세요:
 *   → 이건 LangChain 이 낸 에러인가요, provider(Anthropic) 가 낸 에러인가요?
 *   → HTTP 상태 코드는 몇 번인가요?
 */
async function exercise3(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 4] 종료 조건 =====
 *
 * (a) 올바른 종료 판정 함수를 구현하세요.
 *       function isDone(m: AIMessage): boolean
 *     tool_calls 가 비었을 때만 true 여야 합니다.
 *
 * (b) 흔히 저지르는 틀린 판정 함수도 구현하세요.
 *       function wrongIsDone(m: AIMessage): boolean   // text 가 있으면 끝났다고 본다
 *
 * (c) "안녕, 반가워!" (도구 불필요) 와 "서울 날씨 알려줘" (도구 필요) 두 질문에 대해
 *     isDone / wrongIsDone 을 각각 출력하고, 두 함수의 판정이 갈리는 케이스를 찾아
 *     주석으로 설명하세요.
 *
 * 힌트: 모델은 "서울 날씨를 확인해볼게요" 같은 text 와 tool_calls 를 동시에 보낼 수 있습니다.
 *       (이건 매번 나오지 않을 수 있으니 몇 번 돌려 보세요.)
 */
function isDone(_m: AIMessage): boolean {
  // TODO: 여기에 작성하세요
  return false;
}

function wrongIsDone(_m: AIMessage): boolean {
  // TODO: 여기에 작성하세요
  return false;
}

async function exercise4(): Promise<void> {
  // TODO: (c) 여기에 작성하세요
}

/* ===== [문제 5] 병렬 도구 호출 =====
 *
 * "서울, 부산, 제주 인구를 각각 알려줘" 를 물어 tool_calls 를 3개 받아내세요.
 * (모델이 3개를 한 번에 안 부를 수도 있습니다. 그러면 몇 개든 받은 만큼으로 진행하세요.)
 *
 * (a) for 루프로 순차 실행하고 걸린 시간(ms)을 재세요.
 * (b) Promise.all 로 병렬 실행하고 걸린 시간(ms)을 재세요.
 * (c) 두 시간을 비교해 출력하세요. get_population 에는 1초 지연이 있습니다.
 *
 * 마지막으로 주석으로 답하세요:
 *   → Promise.all 이 결과 순서를 보장하는데도, ToolMessage 의 짝을 배열 인덱스가 아니라
 *     tool_call_id 로 맞춰야 하는 이유는 무엇일까요?
 */
async function exercise5(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 6] 무한 루프 방어 =====
 *
 * while 루프에 MAX_ITERATIONS = 3 상한을 걸고 "서울과 부산과 제주 인구를 모두 더하면?" 을 처리하세요.
 *
 * 요구사항:
 *   - 루프를 빠져나온 이유를 반드시 구분하세요. (정상 종료 / 상한 도달)
 *   - 상한에 걸리면 "상한 도달" 이라고 명시적으로 출력하세요. 조용히 넘어가면 안 됩니다.
 *   - 실제로 몇 바퀴를 썼는지 출력하세요.
 *
 * 그리고 주석으로 답하세요:
 *   → 상한을 아예 안 걸면 최악의 경우 무슨 일이 벌어지나요?
 *   → createAgent 의 recursionLimit 기본값은 몇이고, 그 단위는 '바퀴'인가요 '무엇'인가요?
 */
async function exercise6(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 7] 에러 처리 =====
 *
 * "도쿄 날씨 알려줘" 를 처리하세요. WEATHER_DB 에 도쿄가 없으므로 get_weather 는 throw 합니다.
 *
 * 요구사항:
 *   - 도구 실행을 try/catch 로 감싸고, 에러를 ToolMessage 로 바꿔 모델에게 돌려주세요.
 *   - 그 ToolMessage 의 status 를 "error" 로 두세요.
 *   - content 에는 "무엇이 왜 실패했고 어떤 선택지가 있는지" 를 담으세요.
 *   - 모델이 사과하고 대안을 안내하며 정상 종료하는 것을 확인하세요.
 *
 * 그리고 주석으로 답하세요:
 *   → status: "error" 를 "success" 로 바꿔도 모델의 답변이 달라지나요? 왜 그럴까요?
 */
async function exercise7(): Promise<void> {
  // TODO: 여기에 작성하세요
}

/* ===== [문제 8] 미니 에이전트 완성 + createAgent 와 비교 =====
 *
 * 아래 runAgent 를 완성하세요. 문제 2~7 에서 배운 것을 전부 합치면 됩니다.
 *   - systemPrompt 가 있으면 SystemMessage 로 맨 앞에 넣기
 *   - maxIterations 상한 (기본 10)
 *   - 종료 조건은 tool_calls 기준
 *   - 도구는 Promise.all 로 병렬 실행
 *   - 도구 에러는 ToolMessage(status: "error") 로 회복
 *   - 모델이 없는 도구 이름을 지어낸 경우도 ToolMessage 로 회복 (findTool 대신 toolsByName 직접 조회)
 *   - 상한 도달 시 throw
 *
 * 그다음, 완성한 runAgent 와 createAgent 에게 같은 질문/같은 systemPrompt 를 주고
 * 두 결과의 '메시지 타입 시퀀스'(예: Human → AI → Tool → AI)가 같은지 비교해 출력하세요.
 */
async function runAgent(_options: {
  input: string;
  systemPrompt?: string;
  maxIterations?: number;
  verbose?: boolean;
}): Promise<{ messages: BaseMessage[]; output: string; iterations: number }> {
  // TODO: 여기에 작성하세요
  return { messages: [], output: "", iterations: 0 };
}

async function exercise8(): Promise<void> {
  // TODO: runAgent 와 createAgent 를 비교하세요
}

/* ===== 실행 — 풀고 싶은 문제의 주석을 푸세요 ===== */
// await exercise1();
// await exercise2();
// await exercise3();
// await exercise4();
// await exercise5();
// await exercise6();
// await exercise7();
// await exercise8();

// 아래 두 줄은 "선언만 하고 안 썼다" 는 경고를 막기 위한 것입니다. 신경 쓰지 마세요.
void [exercise1, exercise2, exercise3, exercise4, exercise5, exercise6, exercise7, exercise8];
void [isDone, wrongIsDone, runAgent, createAgent, ToolMessage, SystemMessage, findTool, model];

solution.ts

8문제의 정답 코드와 해설 주석을 담은 파일입니다. exercise.ts 를 스스로 풀어본 뒤에 여세요. 정답 코드보다 그 아래 // → 로 시작하는 해설 주석이 본체입니다.

  • [정답 3] 의 해설에 Anthropic 과 OpenAI 의 실제 에러 메시지를 나란히 적어 두었습니다. tool_use ids were found without tool_result blocks immediately after (Anthropic) vs An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id' (OpenAI). 문구는 달라도 같은 말입니다. 이 400 은 LangChain 이 아니라 provider 서버가 냅니다 — LangChain 은 짝이 맞는지 검사해 주지 않습니다.
  • [정답 4] 가 이 파일에서 가장 중요합니다. wrongIsDone 을 쓰면 도구를 실행조차 하지 않고 "서울 날씨를 확인해볼게요!" 를 최종 답변으로 내놓는데, 에러도 로그도 남지 않습니다. 반대로 "text 가 비었으면 계속" 이라는 오답은 무한 루프를 만듭니다. 종료 조건에서 text 를 쳐다보는 순간 둘 중 하나에 빠진다는 것이 요점입니다.
  • [정답 5] 의 해설은 "Promise.all 은 순서를 보장하잖아요?" 라는 반론에 정면으로 답합니다. 맞습니다 — 그래서 지금은 동작하고, 그게 함정입니다. 진짜 이유 두 가지(provider 는 배열 순서를 안 본다 / 큐·allSettled·캐시로 리팩터링하는 순간 가정이 깨진다)를 짚습니다.
  • [정답 6] 의 요점은 finished 플래그 하나입니다. 그리고 상한을 안 걸면 비용이 선형이 아니라 이차로 증가한다는 것(매 바퀴 messages 전체를 다시 보내므로 입력 토큰이 누적)을 짚습니다. recursionLimit 25 는 25바퀴가 아니라 약 12바퀴입니다.
  • [정답 7] 의 해설에 에러 content 작성 원칙을 ❌/✅ 표로 정리했습니다. "Error" 도 스택 트레이스도 안 됩니다. 모델을 움직이는 것은 status 가 아니라 content 문장이고, status 는 우리 코드와 로그를 위한 것입니다. 그리고 실패한 도구의 ToolMessage 를 빼고 보내면 정답 3의 400 으로 직행합니다.
  • [정답 8]EXPECTED = 12_679_000 (= 9,386,000 + 3,293,000) 으로 자동 검산까지 합니다. runAgentcreateAgent 의 답변에 이 숫자가 들어 있는지 확인해 두 구현이 같은 결과를 내는 것을 증명합니다. 마지막 주석의 목록(recursionLimit, checkpointer, stream, middleware, responseFormat, interrupt)이 Step 08~13 의 예고편입니다.
/**
 * Step 07 — 도구 호출 루프 직접 구현 · 정답과 해설
 * 실행: npx tsx docs/reference/langchain/step-07-tool-loop/solution.ts
 *
 * exercise.ts 를 스스로 풀어본 뒤에 여세요.
 * 모델 응답은 비결정적이므로 출력 숫자(바퀴 수, 메시지 개수)는 실행마다 달라질 수 있습니다.
 * 반면 객체의 shape (tool_call_id, status, 메시지 타입 순서)은 결정적입니다.
 */
import "dotenv/config";
import {
  initChatModel,
  tool,
  createAgent,
  AIMessage,
  HumanMessage,
  ToolMessage,
  SystemMessage,
} from "langchain";
import type { BaseMessage } from "@langchain/core/messages";
import * as z from "zod";

/* ===== 공통 준비 (exercise.ts 와 동일) ===== */

const WEATHER_DB: Record<string, string> = {
  서울: "맑음, 기온 28도, 습도 55%",
  부산: "흐림, 기온 26도, 습도 78%",
  제주: "비, 기온 24도, 습도 90%",
};

const POPULATION_DB: Record<string, number> = {
  서울: 9_386_000,
  부산: 3_293_000,
  제주: 675_000,
};

const getWeather = tool(
  ({ city }) => {
    const found = WEATHER_DB[city];
    if (!found) {
      throw new Error(
        `'${city}' 의 날씨 데이터가 없습니다. 지원 도시: ${Object.keys(WEATHER_DB).join(", ")}`,
      );
    }
    return found;
  },
  {
    name: "get_weather",
    description: "특정 도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름. 예: 서울, 부산, 제주") }),
  },
);

const getPopulation = tool(
  async ({ city }) => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    const found = POPULATION_DB[city];
    if (found === undefined) {
      throw new Error(
        `'${city}' 의 인구 데이터가 없습니다. 지원 도시: ${Object.keys(POPULATION_DB).join(", ")}`,
      );
    }
    return `${city}의 인구는 ${found.toLocaleString("ko-KR")}명입니다.`;
  },
  {
    name: "get_population",
    description: "특정 도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시 이름. 예: 서울, 부산, 제주") }),
  },
);

const calculate = tool(
  ({ a, b, op }) => {
    switch (op) {
      case "add":
        return String(a + b);
      case "sub":
        return String(a - b);
      case "mul":
        return String(a * b);
      case "div":
        if (b === 0) throw new Error("0 으로 나눌 수 없습니다.");
        return String(a / b);
    }
  },
  {
    name: "calculate",
    description: "두 수의 사칙연산을 계산합니다. 암산 대신 반드시 이 도구를 쓰세요.",
    schema: z.object({
      a: z.number().describe("첫 번째 피연산자"),
      b: z.number().describe("두 번째 피연산자"),
      op: z.enum(["add", "sub", "mul", "div"]).describe("연산 종류"),
    }),
  },
);

const tools = [getWeather, getPopulation, calculate];

type AnyTool = { name: string; invoke: (input: any) => Promise<any> };
const toolsByName: Record<string, AnyTool> = Object.fromEntries(
  tools.map((t) => [t.name, t as unknown as AnyTool]),
);

function findTool(name: string): AnyTool {
  const found = toolsByName[name];
  if (!found) {
    throw new Error(
      `'${name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
    );
  }
  return found;
}

const model = await initChatModel("anthropic:claude-sonnet-4-6");

/* ===== [정답 1] 모델은 도구를 실행하지 않는다 ===== */
async function solution1(): Promise<void> {
  console.log("\n=== [정답 1] ===");

  const modelWithTools = model.bindTools(tools);
  const response = await modelWithTools.invoke([new HumanMessage("부산 날씨 어때?")]);

  const toolCalls = response.tool_calls ?? [];
  console.log("tool_calls 개수:", toolCalls.length); // 대개 1

  const first = toolCalls[0];
  if (first) {
    console.log("  name:", first.name); // "get_weather"
    console.log("  args:", JSON.stringify(first.args)); // {"city":"부산"}
    console.log("  id  :", first.id); // "toolu_..." (Anthropic) / "call_..." (OpenAI)
  }

  console.log("text:", JSON.stringify(response.text));

  // → WEATHER_DB 는 조회되지 않았습니다.
  //
  //   모델이 만든 것은 "get_weather 를 {city:'부산'} 으로 불러줘" 라는 '요청'(JSON)일 뿐입니다.
  //   모델은 우리 프로세스 밖(HTTP 너머)에 있어서 우리 함수를 실행할 방법이 아예 없습니다.
  //   도구를 실제로 실행하는 주체는 언제나 우리 애플리케이션 코드입니다.
  //   이 비대칭이 Step 07 전체의 출발점입니다 — 모델은 '요청', 실행은 '우리'.
  //
  //   id 의 접두사가 provider 마다 다른 것에 주목하세요. 우리는 이 값을 절대 지어내면 안 되고,
  //   모델이 준 것을 그대로 ToolMessage.tool_call_id 에 되돌려줘야 합니다.
}

/* ===== [정답 2] 한 바퀴를 손으로 돌리기 ===== */
async function solution2(): Promise<void> {
  console.log("\n=== [정답 2] ===");

  const modelWithTools = model.bindTools(tools);

  // 1. Human
  const messages: BaseMessage[] = [new HumanMessage("제주 날씨 알려줘")];

  // 2. 모델 호출 → AIMessage push
  const ai = await modelWithTools.invoke(messages);
  messages.push(ai);

  // 3~4. 도구 실행 → ToolMessage push
  for (const toolCall of ai.tool_calls ?? []) {
    const output = await findTool(toolCall.name).invoke(toolCall.args);
    messages.push(
      new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!, // ★ 모델이 준 id 를 그대로
        name: toolCall.name,
      }),
    );
  }

  // 5. 도구 결과가 붙은 배열을 통째로 다시
  const final = await modelWithTools.invoke(messages);
  messages.push(final);

  console.log("최종 답변:", final.text);
  console.log("messages.length:", messages.length); // 4

  // → 4개입니다: Human → AI(tool_calls) → Tool → AI(최종 답변).
  //
  //   여기서 가장 중요한 것은 5번에서 messages '전체'를 다시 넣는다는 점입니다.
  //   모델 API 는 stateless 입니다. 이전 호출을 기억하지 못하므로, 매 바퀴마다
  //   지금까지의 대화를 통째로 다시 보내야 합니다. 그래서 에이전트의 '기억'은
  //   결국 이 messages 배열 하나가 전부입니다.
  //
  //   흔한 실수: final 을 push 하지 않는 것. 지금은 대화가 끝나서 티가 안 나지만,
  //   이 messages 를 다음 턴에 이어 쓰면 모델의 마지막 답변이 사라진 채로 대화가 이어집니다.
}

/* ===== [정답 3] (함정) ToolMessage 를 빼먹으면 ===== */
async function solution3(): Promise<void> {
  console.log("\n=== [정답 3] ===");

  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [new HumanMessage("제주 날씨 알려줘")];

  const ai = await modelWithTools.invoke(messages);
  messages.push(ai);
  console.log("tool_calls 개수:", (ai.tool_calls ?? []).length);

  // ToolMessage 를 일부러 빼고 바로 다시 호출합니다.
  try {
    await modelWithTools.invoke(messages);
    console.log(
      "(에러가 안 났다면 모델이 이번엔 도구를 안 불렀을 수 있습니다. 다시 실행해 보세요.)",
    );
  } catch (error) {
    console.log("에러 name   :", (error as Error).name);
    console.log("에러 message:", (error as Error).message);
  }

  // → 이건 provider(Anthropic) 가 낸 에러입니다. HTTP 400 (Bad Request) 입니다.
  //
  //   LangChain 은 우리 messages 배열을 그대로 provider 포맷으로 직렬화해서 보낼 뿐,
  //   "tool_calls 에 대응하는 ToolMessage 가 다 있는지" 를 검사해 주지 않습니다.
  //   검사는 provider 서버에서 일어나고, 그래서 에러 메시지도 provider 말투입니다.
  //   Anthropic 은 대략 이렇게 말합니다:
  //     "messages: tool_use ids were found without tool_result blocks immediately after"
  //   OpenAI 는 이렇게 말합니다:
  //     "An assistant message with 'tool_calls' must be followed by tool messages
  //      responding to each 'tool_call_id'"
  //
  //   교훈이 두 개입니다.
  //   (1) tool_calls 가 N개면 ToolMessage 도 정확히 N개여야 합니다. 하나라도 빠지면 400.
  //       "도구 하나가 실패했으니 그건 빼고 보내자" 는 생각이 바로 이 에러를 만듭니다.
  //       실패한 것도 ToolMessage 로 채워 보내야 합니다 (정답 7).
  //   (2) 이건 조용히 틀리는 게 아니라 시끄럽게 터지는 함정이라 그나마 다행입니다.
  //       진짜 무서운 함정은 정답 4 쪽입니다.
}

/* ===== [정답 4] 종료 조건 ===== */
function isDone(m: AIMessage): boolean {
  // 오직 이것뿐입니다. tool_calls 가 비었으면 끝.
  return (m.tool_calls ?? []).length === 0;
}

function wrongIsDone(m: AIMessage): boolean {
  // 흔한 오답: "모델이 뭐라도 말했으면 끝난 거 아냐?"
  return m.text.length > 0;
}

async function solution4(): Promise<void> {
  console.log("\n=== [정답 4] ===");

  const modelWithTools = model.bindTools(tools);

  const noTool = await modelWithTools.invoke([new HumanMessage("안녕, 반가워!")]);
  const needTool = await modelWithTools.invoke([new HumanMessage("서울 날씨 알려줘")]);

  console.log("\n[도구 불필요] isDone =", isDone(noTool), "/ wrongIsDone =", wrongIsDone(noTool));
  console.log("  text:", JSON.stringify(noTool.text.slice(0, 40)));

  console.log(
    "\n[도구 필요]   isDone =",
    isDone(needTool),
    "/ wrongIsDone =",
    wrongIsDone(needTool),
  );
  console.log("  text      :", JSON.stringify(needTool.text));
  console.log("  tool_calls:", (needTool.tool_calls ?? []).map((c) => c.name).join(", "));

  // → 판정이 갈리는 케이스: "도구도 부르면서 말도 하는" AIMessage 입니다.
  //
  //   모델은 tool_calls 와 text 를 '동시에' 보낼 수 있습니다. AIMessage 의 content 는
  //   블록 배열이라 [{type:"text", ...}, {type:"tool_use", ...}] 처럼 둘이 공존합니다.
  //   이때:
  //     isDone      → false (tool_calls 가 있으니 계속) ✅
  //     wrongIsDone → true  (text 가 있으니 끝) ❌
  //
  //   wrongIsDone 을 쓰면 무슨 일이 벌어질까요? 도구를 실행조차 하지 않고 루프를 끝내고,
  //   "서울 날씨를 확인해볼게요!" 라는 text 를 최종 답변으로 사용자에게 내놓습니다.
  //   에러는 안 납니다. 로그도 깨끗합니다. 사용자만 날씨를 영영 못 받습니다.
  //
  //   이게 이 스텝에서 가장 조용한 함정입니다. 반대 방향의 오답도 있습니다:
  //     "text 가 비었으면 계속"  → 도구 없이 빈 답변을 준 경우 무한 루프
  //   종료 조건은 tool_calls 하나로만 판단하세요. text 는 종료와 무관합니다.
}

/* ===== [정답 5] 병렬 도구 호출 ===== */
async function solution5(): Promise<void> {
  console.log("\n=== [정답 5] ===");

  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [
    new HumanMessage("서울, 부산, 제주 인구를 각각 알려줘."),
  ];

  const ai = await modelWithTools.invoke(messages);
  messages.push(ai);
  const toolCalls = ai.tool_calls ?? [];
  console.log("tool_calls 개수:", toolCalls.length);

  // (a) 순차
  const t0 = Date.now();
  const sequential: ToolMessage[] = [];
  for (const toolCall of toolCalls) {
    const output = await findTool(toolCall.name).invoke(toolCall.args);
    sequential.push(
      new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      }),
    );
  }
  const seqMs = Date.now() - t0;

  // (b) 병렬
  const t1 = Date.now();
  const parallel = await Promise.all(
    toolCalls.map(async (toolCall) => {
      const output = await findTool(toolCall.name).invoke(toolCall.args);
      return new ToolMessage({
        content: String(output),
        tool_call_id: toolCall.id!,
        name: toolCall.name,
      });
    }),
  );
  const parMs = Date.now() - t1;

  // (c) 비교
  console.log(`순차: ${seqMs}ms`);
  console.log(`병렬: ${parMs}ms`);
  console.log(`→ ${(seqMs / Math.max(parMs, 1)).toFixed(1)}배 빠름`);

  messages.push(...parallel);
  const final = await modelWithTools.invoke(messages);
  console.log("최종 답변:", final.text);

  // → Promise.all 이 순서를 보장하는데도 tool_call_id 로 짝을 맞춰야 하는 이유:
  //
  //   Promise.all 이 보장하는 것은 '결과 배열의 순서'이지 '실행 완료 순서'가 아닙니다.
  //   그건 맞습니다. 문제는 그 다음입니다.
  //
  //   (1) provider 는 messages 배열의 순서가 아니라 tool_call_id 로 짝을 찾습니다.
  //       id 만 맞으면 ToolMessage 를 어떤 순서로 넣든 동작합니다. 반대로 순서가 맞아도
  //       id 가 틀리면 400 입니다. 즉 배열 순서는 애초에 짝짓기에 쓰이지 않습니다.
  //
  //   (2) 진짜 사고는 Promise.all 을 벗어날 때 납니다. 실무에서는 도구 실행을 큐에 넣거나,
  //       allSettled 로 바꾸거나, 일부는 캐시에서 즉답하고 일부만 실행하는 식으로
  //       리팩터링이 들어옵니다. 그 순간 "i번째 결과는 i번째 tool_call 의 것" 이라는
  //       가정이 조용히 깨집니다. 인덱스로 짝을 맞춘 코드는 그때 엉뚱한 도시의 인구를
  //       엉뚱한 질문에 붙여 놓고도 에러 없이 잘 돌아갑니다.
  //
  //   그래서 짝은 처음부터 id 로 맞춥니다. 아래처럼 map 안에서 toolCall 을 클로저로 잡으면
  //   인덱스를 쓸 일 자체가 없어집니다 — 이게 인덱스 버그를 구조적으로 막는 방법입니다.
  console.log("\n[검산] 순차/병렬 결과의 id 순서:");
  console.log("  순차:", sequential.map((m) => m.tool_call_id).join(", "));
  console.log("  병렬:", parallel.map((m) => m.tool_call_id).join(", "));
}

/* ===== [정답 6] 무한 루프 방어 ===== */
async function solution6(): Promise<void> {
  console.log("\n=== [정답 6] ===");

  const modelWithTools = model.bindTools(tools);
  const MAX_ITERATIONS = 3;

  const messages: BaseMessage[] = [
    new HumanMessage("서울과 부산과 제주 인구를 모두 더하면?"),
  ];

  let iteration = 0;
  let finished = false; // ★ 루프를 빠져나온 '이유' 를 기록하는 플래그

  while (iteration < MAX_ITERATIONS) {
    iteration += 1;
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);
    console.log(
      `[${iteration}/${MAX_ITERATIONS}] tool_calls=${(ai.tool_calls ?? []).length}`,
    );

    if ((ai.tool_calls ?? []).length === 0) {
      finished = true;
      console.log("→ 정상 종료:", ai.text);
      break;
    }

    const toolMessages = await Promise.all(
      (ai.tool_calls ?? []).map(async (toolCall) => {
        const output = await findTool(toolCall.name).invoke(toolCall.args);
        return new ToolMessage({
          content: String(output),
          tool_call_id: toolCall.id!,
          name: toolCall.name,
        });
      }),
    );
    messages.push(...toolMessages);
  }

  if (!finished) {
    console.log(`→ 상한 도달! ${MAX_ITERATIONS}바퀴를 다 썼는데 tool_calls 가 남아 있습니다.`);
  }
  console.log(`사용한 바퀴: ${iteration}, 정상 종료: ${finished}`);

  // → 상한을 안 걸면: 모델이 같은 도구를 계속 부르는 루프에 빠질 수 있고, 그러면
  //   호출 1회당 API 과금이 계속 발생합니다. 게다가 메시지 배열이 매 바퀴 길어지므로
  //   입력 토큰이 누적해서 늘어납니다 — 비용이 선형이 아니라 이차로 증가합니다.
  //   밤새 돌면 청구서가 폭발합니다. 상한은 선택이 아니라 필수입니다.
  //
  // → createAgent 의 recursionLimit 기본값은 25 입니다.
  //   단위는 '바퀴'가 아니라 LangGraph 의 super-step 입니다.
  //   한 바퀴 = 모델 노드 1 step + 도구 노드 1 step = 2 step 이므로,
  //   25 는 대략 12바퀴에 해당합니다. 25번 도구를 부를 수 있다는 뜻이 아닙니다.
  //   초과하면 GraphRecursionError 가 던져집니다.
  //
  // ★ 이 문제의 핵심은 finished 플래그입니다.
  //   플래그 없이 while 을 빠져나오면 "정상 종료"와 "상한 도달"을 구분할 수 없습니다.
  //   그러면 상한에 걸려 잘려나간 미완성 결과를 완성된 답으로 착각해서 사용자에게 줍니다.
  //   에러도 안 나고 로그도 깨끗한데 답만 틀립니다. 반드시 구분하세요.
}

/* ===== [정답 7] 에러 처리 ===== */
async function solution7(): Promise<void> {
  console.log("\n=== [정답 7] ===");

  const modelWithTools = model.bindTools(tools);
  const messages: BaseMessage[] = [
    new SystemMessage(
      "너는 날씨 비서다. 도구가 에러를 돌려주면 사용자에게 사과하고 가능한 대안을 안내해라.",
    ),
    new HumanMessage("도쿄 날씨 알려줘"),
  ];

  for (let i = 1; i <= 4; i++) {
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);

    if ((ai.tool_calls ?? []).length === 0) {
      console.log("\n최종 답변:", ai.text);
      break;
    }

    const toolMessages = await Promise.all(
      (ai.tool_calls ?? []).map(async (toolCall) => {
        try {
          const output = await findTool(toolCall.name).invoke(toolCall.args);
          return new ToolMessage({
            content: String(output),
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "success",
          });
        } catch (error) {
          // ★ 에러도 '결과'입니다. tool_call_id 를 채워서 반드시 돌려주세요.
          //   content 에 원인과 선택지를 담아야 모델이 회복할 수 있습니다.
          return new ToolMessage({
            content: `도구 실행 실패: ${(error as Error).message}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
      }),
    );
    messages.push(...toolMessages);
    console.log(
      `[${i}바퀴] ${toolMessages.map((m) => `${m.name}:${m.status}`).join(", ")}`,
    );
  }

  // → status 를 "success" 로 바꿔도 모델의 답변은 거의 달라지지 않습니다.
  //
  //   모델이 실제로 읽는 것은 content 뿐이기 때문입니다. status 는 LangChain 이
  //   메시지 객체에 붙여 두는 메타데이터이고, provider 포맷으로 나갈 때 대부분
  //   "이 tool_result 는 에러였다" 정도의 플래그로만 전달되거나 아예 무시됩니다.
  //   모델을 움직이는 것은 "'도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주"
  //   라는 문장 자체입니다.
  //
  //   그래서 에러 content 를 쓸 때의 원칙:
  //     ❌ "Error"                          → 모델이 뭘 해야 할지 모릅니다
  //     ❌ "TypeError: undefined is not..."  → 스택 트레이스는 모델에게 소음입니다
  //     ✅ "'도쿄' 의 날씨 데이터가 없습니다. 지원 도시: 서울, 부산, 제주"
  //        → 무엇이/왜 실패했고 어떤 선택지가 있는지. 모델이 스스로 고칩니다.
  //   에러 메시지도 프롬프트입니다.
  //
  //   그러면 status 는 왜 채우나요? 사람과 코드를 위해서입니다. 로그를 필터링하거나
  //   "에러가 2번 연속이면 중단" 같은 정책을 우리 코드가 판단할 때 씁니다.
  //
  // ★ 그리고 절대 하면 안 되는 것: 실패한 도구의 ToolMessage 를 '빼고' 보내는 것.
  //   그건 정답 3의 400 에러로 직행합니다. 실패해도 자리는 채워야 합니다.
}

/* ===== [정답 8] 미니 에이전트 완성 + createAgent 와 비교 ===== */
async function runAgent(options: {
  input: string;
  systemPrompt?: string;
  maxIterations?: number;
  verbose?: boolean;
}): Promise<{ messages: BaseMessage[]; output: string; iterations: number }> {
  const { input, systemPrompt, maxIterations = 10, verbose = false } = options;

  const modelWithTools = model.bindTools(tools);

  const messages: BaseMessage[] = [];
  if (systemPrompt) messages.push(new SystemMessage(systemPrompt));
  messages.push(new HumanMessage(input));

  for (let iteration = 1; iteration <= maxIterations; iteration++) {
    const ai = await modelWithTools.invoke(messages);
    messages.push(ai);

    const toolCalls = ai.tool_calls ?? [];
    if (verbose) {
      console.log(
        `  [${iteration}바퀴] tool_calls=${toolCalls.length}` +
          (toolCalls.length ? ` (${toolCalls.map((c) => c.name).join(", ")})` : ""),
      );
    }

    // 종료 조건 — tool_calls 기준. text 는 보지 않습니다.
    if (toolCalls.length === 0) {
      return { messages, output: ai.text, iterations: iteration };
    }

    const toolMessages = await Promise.all(
      toolCalls.map(async (toolCall) => {
        // findTool 대신 직접 조회 — 없는 도구도 에러로 회복하기 위해서입니다.
        const selected = toolsByName[toolCall.name];
        if (!selected) {
          return new ToolMessage({
            content: `'${toolCall.name}' 이라는 도구는 없습니다. 사용 가능: ${Object.keys(toolsByName).join(", ")}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
        try {
          const output = await selected.invoke(toolCall.args);
          return new ToolMessage({
            content: String(output),
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "success",
          });
        } catch (error) {
          return new ToolMessage({
            content: `도구 실행 실패: ${(error as Error).message}`,
            tool_call_id: toolCall.id!,
            name: toolCall.name,
            status: "error",
          });
        }
      }),
    );
    messages.push(...toolMessages);
  }

  // 상한 도달 — 조용히 미완성 결과를 돌려주지 않고 명시적으로 던집니다.
  throw new Error(`최대 반복 횟수(${maxIterations})를 넘었습니다.`);
}

async function solution8(): Promise<void> {
  console.log("\n=== [정답 8] ===");

  const question = "서울과 부산의 인구를 더하면 몇 명이야? 계산은 도구로 해줘.";
  const systemPrompt = "너는 도시 정보 비서다. 숫자 계산은 반드시 calculate 도구를 써라.";

  const shape = (ms: BaseMessage[]) =>
    ms.map((m) => m.constructor.name.replace("Message", "")).join(" → ");

  // (A) 우리 것
  const mine = await runAgent({ input: question, systemPrompt, verbose: true });
  console.log("\n(A) runAgent");
  console.log("  답변:", mine.output);
  console.log("  바퀴:", mine.iterations);
  console.log("  시퀀스:", shape(mine.messages));

  // (B) 프레임워크
  const agent = createAgent({ model, tools, systemPrompt });
  const result = await agent.invoke({
    messages: [{ role: "user", content: question }],
  });
  const lastMessage = result.messages.at(-1) as AIMessage;
  console.log("\n(B) createAgent");
  console.log("  답변:", lastMessage.text);
  console.log("  시퀀스:", shape(result.messages as BaseMessage[]));

  // 검산: 두 답 모두 9,386,000 + 3,293,000 = 12,679,000 이 나와야 합니다.
  const EXPECTED = 12_679_000;
  const hit = (s: string) =>
    s.replace(/[,\s]/g, "").includes(String(EXPECTED)) ||
    s.includes(EXPECTED.toLocaleString("ko-KR"));
  console.log("\n[검산] 기대값:", EXPECTED.toLocaleString("ko-KR"));
  console.log("  (A) 포함?", hit(mine.output));
  console.log("  (B) 포함?", hit(lastMessage.text));

  // → 시퀀스가 같습니다: System → Human → AI → Tool → Tool → AI → Tool → AI
  //   (모델이 인구 두 개를 한 번에 부르면 Tool 이 2개 연속, 나눠 부르면 달라집니다.)
  //
  //   답도 같고 메시지 모양도 같습니다. createAgent 는 우리 runAgent 와 '같은 루프'입니다.
  //   다만 아래 것들이 더 있습니다 (본문 7-7 표 참고):
  //     - recursionLimit (우리의 maxIterations)
  //     - checkpointer (대화 저장 — Step 10)
  //     - stream / streamEvents (Step 09)
  //     - middleware (Step 11~12)
  //     - responseFormat (구조화 출력 — Step 05)
  //     - interrupt (HITL — Step 13)
  //   전부 "이 루프의 어느 지점에 무엇을 끼워 넣느냐" 의 문제입니다.
  //   루프를 손으로 만들어 봤으니, 앞으로 나올 기능들이 루프의 어디에 붙는지
  //   지도 위에 찍을 수 있게 되었습니다.
}

/* ===== 실행 ===== */
await solution1();
await solution2();
await solution3();
await solution4();
await solution5();
await solution6();
await solution7();
await solution8();

console.log("\n=== Step 07 solution 끝 ===");