Step 19 — 관측·테스트·평가

학습 목표

  • 에이전트 디버깅이 왜 일반 코드와 다른지 설명하고, 궤적(trajectory) 을 messages 배열에서 추출한다
  • 환경변수 3개로 LangSmith 트레이싱을 켜고, runName/tags/metadata 로 나중에 찾을 수 있게 라벨링한다
  • LangSmith 없이 ConsoleCallbackHandler, streamMode: "debug", 커스텀 로깅 미들웨어로 디버깅한다
  • fakeModel() 로 모델을 갈아끼워 에이전트를 빠르고 결정적으로 테스트한다
  • 데이터셋 + 평가자로 평가(eval) 를 돌리고, 궤적 평가로 "운 좋게 맞은 답"을 잡아낸다
  • 평가를 CI 게이트로 만들어 회귀를 막고, LangGraph Studio 로 시각적으로 디버깅한다

선행 스텝: Step 18 — 멀티 에이전트 예상 소요: 80분

Step 01부터 18까지 우리는 에이전트를 만드는 법을 배웠습니다. 이제 만든 것이 실제로 잘 도는지 확인하는 법을 배웁니다. 이 스텝이 없으면 여러분의 에이전트는 "데모에서는 되는데 실서비스에서 왜 이러는지 모르겠는 것"이 됩니다.

에이전트 개발의 실제 시간 배분은 대략 이렇습니다: 만드는 데 20%, 왜 이상하게 동작하는지 알아내는 데 80%. 이 스텝은 그 80%를 줄이는 도구들에 관한 것입니다. 순서는 관측(무슨 일이 있었나) → 테스트(코드가 안 깨졌나) → 평가(품질이 안 떨어졌나) 입니다.

평가 지표의 이론(정확성, 환각, 문맥 관련성 등)과 LLM Judge의 편향 문제는 이 사이트의 별도 문서에서 이미 다룹니다 — 평가LLM Judge 를 먼저 읽으면 좋습니다. 이 스텝은 그 이론을 LangChain/LangSmith로 어떻게 실행하는가에만 집중합니다.

이 스텝의 실습은 API 키가 필요 없습니다. fakeModel() 덕분에 practice.ts 전체가 네트워크 없이 돕니다. 실제 모델을 부르는 블록은 19-2 하나뿐이고, 키가 없으면 스스로 건너뜁니다.


19-1. 에이전트는 왜 디버깅이 어려운가

일반 함수를 디버깅할 땐 입력을 넣고 출력을 봅니다. 틀리면 중단점을 걸고 한 줄씩 따라갑니다. 에이전트에서는 이게 통하지 않습니다. 이유가 세 가지 있습니다.

문제일반 코드에이전트
비결정성같은 입력 → 같은 출력같은 입력 → 매번 다른 궤적. 어제 되던 게 오늘 안 된다
다단계스택 트레이스 하나모델 호출 N번 + 도구 호출 M번이 얽힘. 어느 단계에서 틀어졌는지 안 보인다
블랙박스로직이 코드에 있다결정 로직이 모델 가중치 안에 있다. 왜 그 도구를 골랐는지 코드에 안 적혀 있다

가장 큰 함정은 invoke() 의 반환값만 보는 습관입니다. 반환값에는 최종 답 하나가 들어 있고, 그 사이에 무슨 일이 있었는지는 눈에 띄지 않습니다.

const model = fakeModel()
  .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "call_1" }])
  .respond(new AIMessage("서울은 맑고 23도입니다."));

const agent = createAgent({ model, tools });
const result = await agent.invoke({ messages: [new HumanMessage("서울 날씨 알려줘")] });

// 흔히 이렇게만 봅니다 — 여기엔 정보가 거의 없습니다.
console.log("최종 답:", result.messages.at(-1)?.text);

// 사실은 이만큼 일어났습니다. messages 배열이 곧 "궤적(trajectory)" 입니다.
for (const m of result.messages) {
  const toolCalls = (m as AIMessage).tool_calls ?? [];
  const suffix = toolCalls.length ? ` → tool_calls: ${toolCalls.map((t) => t.name).join(", ")}` : "";
  console.log(`  [${m.getType()}] ${JSON.stringify(m.text).slice(0, 40)}${suffix}`);
}

console.log("모델 호출 횟수:", model.callCount);

출력

최종 답: 서울은 맑고 23도입니다.

실제로 벌어진 일 (messages 배열 전체):
  [human] "서울 날씨 알려줘"
  [ai] "서울 날씨 알려줘" → tool_calls: get_weather
  [tool] "서울: 맑음, 23도"
  [ai] "서울은 맑고 23도입니다."
모델 호출 횟수: 2

한 줄짜리 답 뒤에 메시지 4개와 모델 호출 2번이 숨어 있었습니다. 관측의 출발점은 이겁니다: result.messages 배열이 곧 궤적이고, 그것이 여러분의 스택 트레이스입니다. 도구를 어떤 인자로 불렀는지, 도구가 뭘 돌려줬는지, 모델이 그걸 보고 뭐라 했는지가 전부 여기 있습니다.

tool_calls 는 AI 메시지에만, 그것도 있을 때만 붙습니다. 최종 답 메시지에는 없으므로 ?? [] 방어가 필요합니다 — 이건 19-7에서 궤적 추출 함수를 만들 때 다시 나옵니다.

💡 실무 팁: "에이전트가 이상해요" 라는 제보를 받으면 가장 먼저 할 일은 그 실행의 messages 배열 전체를 찍어 보는 것입니다. 열에 아홉은 모델이 이상한 게 아니라 (1) 도구가 이상한 값을 돌려줬거나 (2) 도구 설명이 부실해서 모델이 엉뚱한 도구를 골랐거나 (3) 도구를 아예 안 불렀거나 셋 중 하나입니다. 셋 다 messages 배열에 그대로 보입니다.


19-2. LangSmith 트레이싱 켜기

messages 배열을 매번 console.log 로 찍는 건 한계가 있습니다. 실행이 하루 10만 건이면? 어제 오후 3시에 특정 사용자가 겪은 오류를 찾아야 한다면? 이때 필요한 게 트레이스 저장소이고, LangChain의 기본 선택지가 LangSmith 입니다.

핵심은 코드를 한 줄도 안 고쳐도 된다는 것입니다. 환경변수만 세팅하면 LangChain이 알아서 트레이스를 올립니다.

# .env
LANGSMITH_TRACING=true            # ← 이게 스위치. "true" 문자열이어야 합니다
LANGSMITH_API_KEY=lsv2_pt_...     # smith.langchain.com 에서 발급
LANGSMITH_PROJECT=langchain-course # 선택. 없으면 "default" 프로젝트로 들어갑니다

이러면 끝입니다. 평소처럼 실행하면 됩니다.

const agent = createAgent({
  model: "anthropic:claude-sonnet-4-6", // OpenAI 대안: "openai:gpt-5.5"
  tools,
  systemPrompt: "너는 한국 도시 정보 도우미다. 도구를 써서 답하라.",
});

const result = await agent.invoke({ messages: [{ role: "user", content: "제주 날씨랑 인구 알려줘" }] });
// → 이 실행이 통째로 smith.langchain.com 에 트레이스로 올라갑니다

트레이스 읽는 법

LangSmith에서 트레이스를 열면 트리가 보입니다. 용어부터 정리합시다.

용어
Run하나의 단계. 모델 호출 1번, 도구 호출 1번이 각각 하나의 Run
Trace입력에서 출력까지의 Run 전체 트리. invoke() 한 번 = 트레이스 하나
Project트레이스를 모아두는 폴더. LANGSMITH_PROJECT 로 지정

트레이스를 열었을 때 볼 순서는 이렇습니다.

  1. 맨 아래(마지막) Run부터 거꾸로 봅니다. 최종 답이 틀렸다면 그 답을 만든 마지막 모델 호출이 뭘 입력으로 받았는지가 첫 단서입니다.
  2. 도구 Run의 output 을 봅니다. 도구가 이상한 값을 줬으면 모델 탓이 아닙니다.
  3. 모델 Run의 input 을 봅니다. 여기에 시스템 프롬프트 + 전체 대화가 있습니다. 컨텍스트가 잘렸거나 이상한 게 섞였는지 보입니다.
  4. 지연시간과 토큰을 봅니다. 어느 단계가 느린지, 컨텍스트가 얼마나 부풀었는지 보입니다.

선택적 트레이싱

전부가 아니라 특정 실행만 추적하고 싶을 때가 있습니다. 환경변수 대신 트레이서를 콜백으로 넘깁니다.

import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain";

const tracer = new LangChainTracer();
await agent.invoke({ messages: [{ role: "user", content: "..." }] }, { callbacks: [tracer] });

// 프로젝트를 코드에서 동적으로 지정할 수도 있습니다
const testTracer = new LangChainTracer({ projectName: "email-agent-test" });

⚠️ 함정 (이 스텝에서 가장 중요합니다): LANGSMITH_TRACING=true 는 여러분의 프롬프트·도구 인자·모델 응답을 통째로 외부 서버로 보냅니다. 이건 버그가 아니라 설계입니다 — 트레이스를 보려면 데이터가 거기 있어야 하니까요. 문제는 이걸 모르고 켜는 것입니다. 고객 이름, 주민번호, 내부 문서, 계약서가 프롬프트에 들어간다면 그게 전부 올라갑니다. 에러도 안 나고 경고도 없습니다. 조용히 잘 됩니다.

방어법 세 가지:

  1. .env 를 환경별로 분리 — 로컬은 true, 운영은 정책 검토 후 결정. .env 를 커밋하지 마세요.
  2. 익명화langsmith/anonymizercreateAnonymizer 로 패턴을 마스킹한 뒤 전송합니다.
    import { createAnonymizer } from "langsmith/anonymizer";
    const anonymizer = createAnonymizer([
      { pattern: /\b\d{3}-?\d{2}-?\d{4}\b/, replace: "<ssn>" },
    ]);
  3. 애초에 안 넣기Step 11piiMiddleware 로 마스킹한 뒤 트레이싱합니다.

규제 산업(금융·의료)이라면 켜기 전에 법무/보안 검토를 받으세요. "일단 켜고 나중에 정리" 가 가장 위험합니다 — 한 번 올라간 데이터는 되돌릴 수 없습니다.

💡 실무 팁: LANGSMITH_PROJECT 를 환경별로 나누세요(myagent-dev, myagent-staging, myagent-prod). 안 나누면 개발 중 삽질 트레이스와 운영 트레이스가 default 에 섞여서 둘 다 못 씁니다. CI 평가용 프로젝트(myagent-eval)도 따로 두는 게 좋습니다.


19-3. 트레이스에 메타데이터·태그 붙이기

트레이싱을 켜고 일주일이 지나면 트레이스가 수만 개 쌓입니다. 그때 "어제 결제 실패한 그 사용자의 실행"을 찾아야 합니다. 미리 라벨을 붙여두지 않았다면 못 찾습니다.

라벨은 invoke()두 번째 인자(config) 에 넣습니다.

await agent.invoke(
  { messages: [new HumanMessage("안녕")] },
  {
    runName: "greeting-flow",                        // 트레이스 목록에 표시될 이름
    tags: ["production", "weather-agent", "v1.0"],   // 필터용 (완전 일치 검색)
    metadata: {
      userId: "user-123",           // 특정 사용자 민원 추적
      sessionId: "sess-456",
      environment: "production",
      promptVersion: "2026-07-17",  // 프롬프트 버전 — A/B 비교의 핵심
    },
  }
);

세 가지를 구분해서 씁니다.

필드타입용도
runNamestring트레이스 목록에서 이 실행이 뭔지 한눈에"greeting-flow", "checkout-agent"
tagsstring[]필터링. 값 종류가 적어야 함 (저카디널리티)["production", "v1.0"]
metadataobject되짚기. 값 종류가 많아도 됨 (고카디널리티){ userId, sessionId, requestId }

tagsmetadata 의 구분이 헷갈린다면 기준은 하나입니다: "이 값으로 트레이스를 묶어서 볼 것인가(tags), 특정 하나를 찾아갈 것인가(metadata)".

⚠️ 함정: tagsuserId 를 넣지 마세요. 태그는 필터 UI에 목록으로 뜨는데, 사용자가 만 명이면 태그가 만 개가 됩니다. 필터가 쓸모없어지고 UI가 느려집니다. 고카디널리티 값은 전부 metadata 로 보내세요.

metadata.promptVersion 은 특히 값어치가 큽니다. 프롬프트를 고친 뒤 "고치기 전/후 실패율" 을 비교하려면 각 트레이스가 어느 프롬프트로 돌았는지 알아야 합니다. 이걸 안 붙여놓으면 나중에 절대 복원할 수 없습니다.

console.log("fakeModel.calls[0].options 의 키:", Object.keys(model.calls[0].options ?? {}));

출력

fakeModel.calls[0].options 의 키: [
  'writer',
  'interrupt',
  'control',
  'executionInfo',
  'signal',
  'promptIndex'
]

⚠️ 함정: config의 tags/metadata콜백/트레이서로 흘러가지, 모델 호출 옵션에는 안 들어갑니다. 위 출력이 그 증거입니다 — fakeModel 이 기록한 calls[0].optionstagsmetadata 도 없습니다. 그래서 "메타데이터가 잘 붙었는지" 를 fakeModel 로 단위 테스트할 수는 없습니다. 라벨링이 제대로 됐는지는 LangSmith UI에서 눈으로 확인하거나, 커스텀 콜백 핸들러를 붙여서 검증해야 합니다.

💡 실무 팁: 라벨은 애플리케이션 진입점 한 곳에서 붙이세요. 호출 지점마다 손으로 넣으면 반드시 빠뜨립니다. 웹 요청 핸들러에서 requestId/userId 를 뽑아 config를 만드는 헬퍼 함수 하나를 두고 전 호출이 그걸 쓰게 하는 게 정석입니다.


19-4. LangSmith 없이 디버깅

LangSmith를 못 쓰는 상황이 있습니다. 보안 정책상 외부 전송이 금지됐거나, CI 안이거나, 그냥 지금 당장 5초 안에 뭔가 보고 싶을 때. 세 가지 방법이 있습니다.

(a) 콘솔 콜백 — 가장 빠른 방법

import { ConsoleCallbackHandler } from "@langchain/core/tracers/console";

await agent.invoke(
  { messages: [new HumanMessage("부산 날씨?")] },
  { callbacks: [new ConsoleCallbackHandler()] }  // ← 이 한 줄
);

출력 (일부 — 실제로는 훨씬 깁니다)

[chain/start] [1:chain:LangGraph] Entering Chain run with input: {
  "messages": [ ... ]
}
[tool/start] [1:chain:LangGraph > 6:chain:tools > 7:tool:get_weather] Entering Tool run with input: "{"city":"부산"}"
[tool/end] [1:chain:LangGraph > 6:chain:tools > 7:tool:get_weather] [0ms] Exiting Tool run with output: "..."

[tool/start] 줄의 입력: {"city":"부산"} 이 핵심입니다 — 모델이 도구에 정확히 어떤 인자를 넘겼는지 가 보입니다. 도구가 이상하게 동작할 때 90%는 여기서 원인이 드러납니다.

⚠️ 함정: ConsoleCallbackHandler모든 것을 전부 찍습니다. 메시지 객체를 통째로 JSON 직렬화하기 때문에, 대화가 조금만 길어져도 터미널이 수천 줄로 폭발해서 정작 보고 싶은 게 파묻힙니다. 위 출력에서 ... 로 줄인 부분이 실제로는 수백 줄입니다. 일회성 디버깅용으로만 쓰고, 상시 로깅에는 절대 쓰지 마세요(운영에 켜두면 로그 비용이 폭발합니다). 상시 로깅은 (c)의 커스텀 미들웨어로 필요한 것만 찍는 게 맞습니다.

(b) streamMode: "debug" — 노드 단위로 보기

에이전트는 내부적으로 LangGraph 그래프입니다(Step 17). streamMode: "debug" 를 주면 노드가 시작하고 끝날 때마다 이벤트를 받습니다.

for await (const ev of await agent.stream(
  { messages: [new HumanMessage("제주 인구?")] },
  { streamMode: "debug" }
)) {
  // 이벤트 구조는 결정적입니다: { step, type, timestamp, payload }
  const e = ev as { step: number; type: string; payload: { name?: string } };
  console.log(`  step=${e.step} type=${e.type} node=${e.payload?.name}`);
}

출력

  step=1 type=task node=model_request
  step=1 type=task_result node=model_request
  step=2 type=task node=tools
  step=2 type=task_result node=tools
  step=3 type=task node=model_request
  step=3 type=task_result node=model_request

이 출력은 (모델 응답과 달리) 구조가 결정적입니다. 읽는 법:

필드
step1, 2, 3그래프의 몇 번째 단계인가
type"task"노드 실행 시작. payload.input 에 입력이 들어있음
type"task_result"노드 실행 완료. payload.result 에 결과가 들어있음
payload.name"model_request", "tools"어느 노드인가

model_request → tools → model_request 라는 왕복이 눈에 보입니다. 이게 에이전트 루프의 정체입니다. step이 계속 늘어나기만 하고 안 끝난다면 무한 루프이고, recursionLimit 을 확인할 때입니다.

streamMode"debug" 외에도 "values", "updates", "messages", "checkpoints", "tasks", "custom", "tools" 가 있습니다(Step 09 참고). 디버깅엔 "debug", 사용자에게 보여줄 땐 "messages" 입니다.

(c) 커스텀 로깅 미들웨어 — 팀 표준으로

필요한 것만, 일관된 포맷으로 찍고 싶다면 미들웨어를 만듭니다(Step 12).

const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",
  // 모델 호출을 감싸서 지연시간을 잰다
  wrapModelCall: async (request, handler) => {
    const started = Date.now();
    const response = await handler(request);
    console.log(`  [model] ${Date.now() - started}ms`);
    return response;
  },
  // 도구 호출을 감싸서 이름과 인자를 남긴다
  wrapToolCall: async (request, handler) => {
    const started = Date.now();
    console.log(`  [tool:start] ${request.toolCall.name} args=${JSON.stringify(request.toolCall.args)}`);
    const response = await handler(request);
    console.log(`  [tool:end]   ${request.toolCall.name} (${Date.now() - started}ms)`);
    return response;
  },
});

const agent = createAgent({ model, tools, middleware: [loggingMiddleware] });

출력

  [model] 1ms
  [tool:start] get_weather args={"city":"서울"}
  [tool:end]   get_weather (0ms)
  [model] 0ms

(fakeModel 이라 1ms입니다. 실제 모델이면 수백~수천 ms가 나옵니다.)

이 방식의 장점은 여러분이 포맷을 통제한다는 것입니다. JSON 구조화 로그로 찍어서 Datadog·CloudWatch로 보낼 수도 있고, 도구 인자에서 민감 필드만 지우고 찍을 수도 있습니다. 외부로 아무것도 안 나갑니다.

⚠️ 함정: wrapToolCall 에서 handler(request) 의 결과를 반드시 return 해야 합니다. 로그만 찍고 return 을 빼먹으면 도구 결과가 undefined 가 되어 사라집니다. 에러는 안 납니다 — 모델이 빈 ToolMessage 를 받고 "정보를 못 가져왔다" 며 엉뚱한 답을 하거나, 같은 도구를 무한히 다시 부릅니다. 미들웨어를 붙였더니 에이전트가 갑자기 멍청해졌다면 이걸 먼저 의심하세요.

세 방법의 선택 기준:

방법언제장점단점
ConsoleCallbackHandler지금 당장 5초 안에한 줄이면 끝출력 폭발, 상시 사용 불가
streamMode: "debug"루프 구조가 궁금할 때노드 흐름이 한눈에, 구조가 결정적메시지 내용은 직접 파야 함
커스텀 미들웨어운영 상시 로깅포맷 통제, 외부 전송 없음직접 만들어야 함
LangSmith팀·운영·평가검색·비교·평가 전부데이터 외부 전송

19-5. 에이전트 테스트

여기서 발상을 하나 뒤집어야 합니다. "LLM은 비결정적이라 테스트가 안 된다"는 건 핑계입니다. 에이전트에서 비결정적인 부분은 모델뿐이고, 나머지는 전부 평범한 코드입니다.

구성요소결정적인가테스트 방법
도구(tool)✅ 완전히 결정적그냥 단위 테스트. 모델 필요 없음
미들웨어✅ 결정적단위 테스트
에이전트 루프✅ 결정적fakeModel 로 모델만 갈아끼우면 결정적
모델의 판단❌ 비결정적통합 테스트 / 평가(19-6)

(a) 도구 단위 테스트 — 모델이 필요 없다

check("서울 날씨 조회", (await getWeather.invoke({ city: "서울" })) === "서울: 맑음, 23도");
check("없는 도시는 '정보 없음'", (await getWeather.invoke({ city: "런던" })) === "런던: 날씨 정보 없음");
check("인구는 천단위 콤마", (await getPopulation.invoke({ city: "부산" })) === "부산: 3,300,000명");

출력

  ✓ 서울 날씨 조회
  ✓ 없는 도시는 '정보 없음'
  ✓ 인구는 천단위 콤마

이게 왜 중요하냐면 — 도구가 틀리면 모델이 아무리 똑똑해도 답이 틀립니다. 그리고 그건 모델 없이 1ms 만에 잡힙니다. 에이전트 테스트 노력의 상당 부분은 여기 있어야 합니다.

특히 폴백 경로("정보 없음")를 꼭 테스트하세요. 이 문자열이 모델에게 그대로 전달되고, 모델은 이걸 보고 사용자에게 뭐라고 할지 결정합니다. 폴백 문자열이 애매하면("null", "") 모델이 그걸 데이터로 착각하고 이상한 답을 합니다.

(b) fakeModel() 로 모델 갈아끼우기

langchain 은 테스트용 가짜 모델을 내장하고 있습니다. 빌더 스타일로 응답을 미리 대본처럼 짜 넣습니다.

import { fakeModel } from "langchain";

const model = fakeModel()
  .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "call_1" }])
  .respond(new AIMessage("서울은 맑고 23도입니다."));

const agent = createAgent({ model, tools });
const result = await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });

check("메시지 4개 (human → ai → tool → ai)", result.messages.length === 4);
check("도구가 실제로 실행되어 ToolMessage 가 생김", result.messages[2].getType() === "tool");
check("ToolMessage 내용이 도구의 실제 반환값", result.messages[2].text === "서울: 맑음, 23도");
check("최종 답은 AI 메시지", result.messages.at(-1)?.getType() === "ai");
check("모델은 2번 호출됨", model.callCount === 2);

출력

  ✓ 메시지 4개 (human → ai → tool → ai)
  ✓ 도구가 실제로 실행되어 ToolMessage 가 생김
  ✓ ToolMessage 내용이 도구의 실제 반환값
  ✓ 최종 답은 AI 메시지
  ✓ 모델은 2번 호출됨
  (참고) tool_calls 를 담은 AI 메시지의 content = "서울 날씨?"

주목할 점: 도구는 진짜로 실행됩니다. 가짜인 건 모델뿐입니다. ToolMessage 의 내용 "서울: 맑음, 23도" 는 실제 getWeather 가 계산한 값입니다. 즉 "모델이 이 도구를 이 인자로 부르면, 우리 코드가 올바르게 동작하는가" 를 검증하는 것입니다.

fakeModel() 의 API 전체입니다.

메서드설명
.respond(message)다음 호출에 반환할 AIMessage 를 큐에 넣기
.respond(error)다음 호출에 던질 Error 를 큐에 넣기
.respond(factory)(messages) => BaseMessage | Error 동적 응답
.respondWithTools(toolCalls)도구 호출을 담은 AI 메시지를 큐에 넣기. name/args 필수, id 선택
.alwaysThrow(error)큐와 무관하게 매번 이 에러를 던지기
.structuredResponse(value).withStructuredOutput() 이 반환할 값 지정
.bindTools(tools)도구 바인딩. 응답 큐와 호출 기록을 공유
.calls호출 기록 { messages, options }[] (읽기 전용)
.callCount호출 횟수

응답은 FIFO 로 소비됩니다 — invoke() 한 번에 큐에서 하나씩.

⚠️ 함정 (가장 자주 틀리는 것): 큐에 넣을 응답 개수를 잘못 세는 것입니다. 도구를 1번 부르는 궤적은 모델 응답이 2개 필요합니다(도구 호출 결정 1번 + 결과 보고 최종 답 1번). 도구를 2번 부르면 3개입니다. 공식은 도구 호출 횟수 + 1 입니다. 모자라면 이렇게 터집니다:

FakeModel: no response queued for invocation 1 (1 total queued).

(invocation 번호는 0부터 셉니다.) 이 에러가 나면 대본이 짧은 것이지 코드가 틀린 게 아닙니다.

⚠️ 함정: respondWithTools() 가 만드는 AI 메시지의 content 는 입력 메시지를 그대로 되돌려줍니다. 위 출력의 (참고) 줄을 보세요 — content가 "서울 날씨?" 입니다. 모델이 그렇게 답한 게 아니라 fake가 입력을 에코한 것입니다. 여기에 단언을 걸면(expect(msg.content).toBe("...")) 의미 없는 테스트가 됩니다. respondWithTools 로 만든 메시지는 tool_calls 검증하세요. id 를 생략하면 fake_tc_1 처럼 자동 생성됩니다.

(c) 에러 경로 테스트 — fake의 진가

실제 모델로는 재현이 거의 불가능한 상황을 fake로는 1초 만에 만들 수 있습니다. 모델 장애 시 재시도가 도는지, 폴백 모델로 넘어가는지를 테스트하려면 장애를 일으킬 수 있어야 합니다.

const failing = fakeModel().alwaysThrow(new Error("service unavailable"));
const agent = createAgent({ model: failing, tools });
let caught = "";
try {
  await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });
} catch (e) {
  caught = (e as Error).message;
}
check("모델 장애가 위로 전파된다", caught === "service unavailable");

출력

  ✓ 모델 장애가 위로 전파된다
  큐 소진 에러: FakeModel: no response queued for invocation 1 (1 total queued).
  ✓ 큐 소진 시 명확한 에러

이걸 modelRetryMiddleware(Step 11)와 조합하면 "3번 재시도 후 포기하는가" 를 API 키 없이, 0.1초 만에 검증할 수 있습니다. rate limit(.respond(new Error("rate limit exceeded"))), 타임아웃, 부분 실패 전부 같은 방식입니다.

(d) 통합 테스트 — 실제 모델을 쓰는 층

fakeModel 로 못 잡는 게 하나 있습니다: 모델이 실제로 어떤 도구를 고르는가. 대본을 짜 넣었으니 당연합니다. 이건 실제 모델로만 확인됩니다.

LangChain은 vitest/jest용 커스텀 매처를 제공합니다.

// vitest.setup.ts
import { langchainMatchers } from "@langchain/core/testing";
expect.extend(langchainMatchers);
// agent.int.test.ts
test("agent calls weather tool", async () => {
  const agent = createAgent({ model: "claude-sonnet-4-6", tools: [getWeather] });
  const result = await agent.invoke({
    messages: [new HumanMessage("What's the weather in SF?")],
  });

  const aiMsg = result.messages.find((m) => AIMessage.isInstance(m) && m.tool_calls?.length);
  expect(aiMsg).toContainToolCall({ name: "get_weather" });
  expect(result.messages.at(-1)).toBeAIMessage();
});

주요 매처: toBeAIMessage, toBeHumanMessage, toBeToolMessage, toBeSystemMessage, toHaveToolCalls, toHaveToolCallCount, toContainToolCall, toHaveToolMessages, toHaveBeenInterrupted, toHaveStructuredResponse, 그리고 스트리밍용 toHaveStreamText/toHaveStreamToolCalls/toHaveStreamUsage.

통합 테스트는 분리해서 돌립니다. 파일명을 *.int.test.ts 로 두고 별도 모드로 실행합니다.

// vitest.config.ts
import { configDefaults, defineConfig } from "vitest/config";

export default defineConfig((env) => {
  if (env.mode === "int") {
    return {
      test: {
        testTimeout: 100_000,          // 실제 모델은 느립니다
        include: ["**/*.int.test.ts"],
        setupFiles: ["dotenv/config"],
      },
    };
  }
  return {
    test: {
      testTimeout: 30_000,
      exclude: ["**/*.int.test.ts", ...configDefaults.exclude],  // 평소엔 제외
    },
  };
});
{
  "scripts": {
    "test": "vitest",
    "test:integration": "vitest --mode int"
  }
}

키가 없는 환경에서는 알아서 건너뛰게 합니다.

test.skipIf(!process.env.OPENAI_API_KEY)("agent responds with tool call", async () => { /* ... */ });

⚠️ 함정: 단위 테스트에서 실제 모델을 부르지 마세요. 세 가지가 동시에 망가집니다. (1) 느리다 — 테스트 1개에 3초, 100개면 5분. 아무도 안 돌립니다. (2) 비싸다 — CI가 PR마다 돌면 월 청구서가 실서비스보다 커집니다. (3) 불안정하다(flaky) — 모델이 같은 입력에 다르게 답해서 아무 코드 변경 없이 빨간불이 뜹니다. 그게 반복되면 팀은 실패를 무시하기 시작하고, 그 순간 테스트 스위트는 죽습니다. temperature: 0 을 줘도 결정성은 보장되지 않습니다 — 인프라·양자화·배치 처리 때문에 같은 요청도 다른 답이 나옵니다.

규칙: 단위 테스트 = fakeModel(매 커밋). 통합 테스트 = 실제 모델(CI/배포 전만).

💡 실무 팁 — 통합 테스트 비용 줄이기: (1) 도구 호출 여부만 볼 거면 작은 모델을 쓰세요 ("gemini-3.1-flash-lite" 등). (2) modelArgs: { maxTokens: 256 } 로 응답 길이를 자르세요 — 어차피 tool_calls 만 볼 거면 긴 답이 필요 없습니다. (3) 테스트 1개당 동작 1개, 단일 턴으로. (4) 첫 실행에서 HTTP 응답을 녹화하고 이후엔 재생하는 방식도 있습니다.

const agent = createAgent({
  model: "gemini-3.1-flash-lite",
  tools: [getWeather],
  modelArgs: { maxTokens: 256 },
});

19-6. 평가(evaluation)

테스트는 "코드가 안 깨졌나" 를 묻습니다(통과/실패). 평가는 "품질이 얼마나 좋은가" 를 묻습니다(점수). 프롬프트를 고쳤을 때 테스트는 다 통과하는데 답변 품질이 떨어질 수 있습니다. 그걸 잡는 게 평가입니다.

평가의 최소 구성은 네 가지입니다.

  1. 데이터셋 — 입력과 기대 결과의 목록
  2. 실행 함수 — 입력을 받아 에이전트를 돌리는 함수
  3. 평가자(evaluator) — 출력에 점수를 매기는 함수
  4. 집계 — 점수를 모아 판단

데이터셋

type Example = {
  id: string;
  input: string;
  referenceOutput: string;   // 기대 답변
  referenceTools: string[];  // 기대 도구 호출 순서 (19-7 에서 사용)
};

const dataset: Example[] = [
  { id: "ex-1", input: "서울 날씨 알려줘", referenceOutput: "맑음, 23도", referenceTools: ["get_weather"] },
  { id: "ex-2", input: "부산 인구는?", referenceOutput: "3,300,000명", referenceTools: ["get_population"] },
  { id: "ex-3", input: "제주 날씨랑 인구 둘 다", referenceOutput: "비, 21도 / 670,000명",
    referenceTools: ["get_weather", "get_population"] },
];

💡 실무 팁: 데이터셋은 상상해서 만들지 마세요. 운영 트레이스에서 실패한 실행을 가져다 넣으세요. 19-2/19-3에서 트레이싱과 라벨링을 해둔 이유가 이겁니다. LangSmith UI에서 실패한 트레이스를 데이터셋에 바로 추가할 수 있습니다. 20개짜리 진짜 실패 사례가 200개짜리 상상 데이터셋보다 낫습니다.

평가자 (1) — 결정적 매칭

쓸 수 있으면 이걸 쓰세요. 싸고, 빠르고, 안 틀립니다.

function containsEvaluator(outputs: { answer: string }, referenceOutput: string) {
  const keys = referenceOutput.split(" / ");
  const hit = keys.filter((k) => outputs.answer.includes(k)).length;
  return { key: "contains", score: hit / keys.length };
}

평가자 (2) — LLM-as-judge

정답이 하나로 안 떨어질 때(요약 품질, 어조, 도움이 되는가) 모델에게 채점을 시킵니다. 이론과 편향 종류는 LLM Judge 에 정리돼 있습니다. 여기서는 실행 방법만 봅니다.

async function llmJudgeEvaluator(outputs: { answer: string }, referenceOutput: string) {
  // 실전에서는 아래 model 을 "anthropic:claude-sonnet-4-6" 등으로 바꿉니다.
  const judge = fakeModel().structuredResponse({
    score: outputs.answer.includes(referenceOutput.split(" / ")[0]) ? 1 : 0,
    reasoning: "기대 답의 핵심 키워드 포함 여부로 판정",
  });
  const judged = (await judge
    .withStructuredOutput(z.object({ score: z.number(), reasoning: z.string() }))
    .invoke(`기대: ${referenceOutput}\n실제: ${outputs.answer}\n0~1 로 채점하라.`)) as {
    score: number;
    reasoning: string;
  };
  return { key: "llm_judge", score: judged.score, comment: judged.reasoning };
}

(as { score, reasoning }fakeModelwithStructuredOutput{ raw, parsed } 까지 포함한 유니온 타입을 반환하도록 선언돼 있어서 붙인 것입니다. 실제 모델에서는 이 단언 없이 바로 좁혀집니다.)

핵심은 withStructuredOutput 으로 점수 형식을 강제하는 것입니다(Step 05). judge에게 자유 텍스트로 답하게 하면 "음, 대체로 좋은데 8점 정도?" 같은 걸 파싱해야 합니다. reasoning 필드를 같이 받는 것도 중요합니다 — judge가 왜 그 점수를 줬는지 봐야 judge를 검증할 수 있습니다.

평가 실행

출력

  ex-1: contains=1.00 llm_judge=1.00
  ex-2: contains=1.00 llm_judge=1.00
  ex-3: contains=1.00 llm_judge=1.00
  → ex-3 도 최종 답 기준으로는 만점입니다. 다음 절에서 이게 왜 위험한지 봅니다.

전부 만점입니다. 그런데 ex-3 의 에이전트는 사실 get_population 을 부르지 않았습니다. 인구 숫자를 지어냈는데 우연히 맞았습니다. 다음 절이 이 이야기입니다.

⚠️ 함정 (평가 데이터셋 오염, contamination): 데이터셋의 기대 답을 프롬프트나 도구 설명에 넣지 마세요. "예: '서울 날씨는?' 이라고 물으면 '맑음, 23도' 라고 답해라" 같은 few-shot 예시가 평가 데이터셋과 겹치는 순간, 점수는 거짓으로 오릅니다. 에이전트가 문제를 푼 게 아니라 답을 외운 것이기 때문입니다. 점수는 95%인데 실서비스 만족도는 그대로인 상황이 이렇게 생깁니다. 평가 데이터셋과 프롬프트 예시는 물리적으로 분리하고, 데이터셋에 새 예제를 추가할 때 프롬프트에 이미 있는 문구가 아닌지 확인하세요.

⚠️ 함정: LLM-as-judge도 틀립니다. judge는 그냥 또 하나의 LLM이고, 위치 편향(먼저 나온 답을 선호), 길이 편향(긴 답을 선호), 자기 선호 편향(자기 계열 모델의 답을 선호)이 있습니다. 그래서 judge 자체를 검증해야 합니다: 사람이 채점한 20~50개 샘플을 준비하고, judge 점수와 사람 점수의 일치율을 재세요. 일치율이 낮으면 고쳐야 할 건 에이전트가 아니라 judge 프롬프트입니다. 검증 안 된 judge로 매긴 점수는 숫자처럼 생긴 노이즈입니다. 자세한 편향 목록과 완화법은 LLM Judge 를 보세요.


19-7. 궤적(trajectory) 평가 — 이 스텝의 핵심

19-6의 평가는 전부 최종 답만 봤습니다. 그런데 에이전트의 가치는 최종 답이 아니라 거기 도달하는 과정에 있습니다. 질문을 바꿔야 합니다: "답이 맞았나?" 가 아니라 "올바른 도구를 올바른 순서로 불렀나?"

궤적은 새로 만드는 게 아닙니다. 19-1에서 봤듯 messages 배열에 이미 있습니다.

function extractTrajectory(messages: BaseMessage[]): string[] {
  return messages
    .filter((m): m is AIMessage => m.getType() === "ai")
    .flatMap((m) => (m.tool_calls ?? []).map((tc) => tc.name));
}

이제 기대 궤적과 비교합니다. 비교 모드가 네 가지 있습니다.

type MatchMode = "strict" | "unordered" | "superset" | "subset";

function trajectoryMatch(actual: string[], reference: string[], mode: MatchMode): boolean {
  const sortedA = [...actual].sort();
  const sortedR = [...reference].sort();
  switch (mode) {
    case "strict":     // 같은 도구를 같은 순서로
      return actual.length === reference.length && actual.every((t, i) => t === reference[i]);
    case "unordered":  // 같은 도구 집합, 순서 무관
      return sortedA.length === sortedR.length && sortedA.every((t, i) => t === sortedR[i]);
    case "superset":   // 기대한 건 다 불렀고, 추가로 더 불러도 OK
      return reference.every((t) => actual.includes(t));
    case "subset":     // 기대 범위를 벗어난 도구는 안 불렀다
      return actual.every((t) => reference.includes(t));
  }
}
모드통과 조건언제 쓰나
strict같은 도구, 같은 순서순서가 계약일 때. 예: 권한 조회 후에만 결제
unordered같은 도구 집합, 순서 무관대부분의 경우. 필요한 정보는 다 가져오되 순서는 모델 자유
superset기대한 도구를 모두 포함"최소한 이것들은 불러라" (추가 호출 허용)
subset기대 범위 밖 도구 없음안전 경계. "이 범위 밖은 건드리지 마라"

최종 답과 궤적이 갈리는 순간

출력

  ex-1: 궤적=[get_weather] 기대=[get_weather] strict=PASS 최종답=PASS
  ex-2: 궤적=[get_population] 기대=[get_population] strict=PASS 최종답=PASS
  ex-3: 궤적=[get_weather] 기대=[get_weather → get_population] strict=FAIL 최종답=PASS
  → ex-3: 최종답 PASS / 궤적 FAIL. get_population 을 안 부르고 인구를 '지어냈습니다'.
    최종 답만 봤다면 환각을 만점으로 통과시켰을 겁니다.

ex-3 을 보세요. 최종답은 PASS, 궤적은 FAIL 입니다.

무슨 일이 있었냐면: 에이전트는 get_weather 만 부르고 get_population 은 건너뛴 채, 인구 숫자 "670,000명" 을 자기 기억에서 지어냈습니다. 이번엔 우연히 맞았습니다. 그래서 최종답 평가는 만점을 줬습니다.

이게 왜 심각하냐면:

  • 그 숫자는 출처가 없습니다. 도구를 안 거쳤으니 검증도 안 됐습니다.
  • 데이터가 바뀌면 즉시 틀립니다. 도구를 불렀다면 항상 최신값을 줬을 텐데, 모델 기억은 학습 시점에 고정입니다.
  • 그리고 아무도 모릅니다. 최종답 평가는 계속 100%를 보고할 테니까요.

⚠️ 함정 (이 스텝의 결론): 최종 답만 평가하면 "운 좋게 맞은 답"과 "제대로 풀어서 맞은 답"을 구분할 수 없습니다.ex-3 처럼 도구를 건너뛰고 환각으로 답했는데 우연히 맞으면, 최종답 평가는 만점을 줍니다. 그 에이전트는 실서비스에서 데이터가 바뀌는 순간 조용히 틀리기 시작합니다. RAG나 도구 기반 에이전트라면 궤적 평가는 선택이 아니라 필수입니다 — 에이전트의 존재 이유가 "지어내지 말고 도구로 확인해라" 인데, 그걸 검사하지 않으면 에이전트를 쓰는 의미가 없습니다.

⚠️ 함정: 반대로 strict 를 남발하지 마세요. 모델이 get_population → get_weather 순으로 불러도 똑같이 정답인 경우가 대부분입니다. 그런데 strict 는 이걸 FAIL로 처리합니다. 그러면 아무 문제 없는 변경에 테스트가 빨간불이 뜨고, 팀은 곧 그 실패를 무시하게 됩니다. 순서가 진짜 요구사항일 때만 strict, 기본은 unordered.

agentevals — 직접 만들지 않아도 됩니다

trajectoryMatch 는 원리를 이해하려고 직접 짠 것입니다. 실무에서는 LangChain의 agentevals 패키지를 씁니다.

npm install agentevals @langchain/core
import { createTrajectoryMatchEvaluator } from "agentevals";

const evaluator = createTrajectoryMatchEvaluator({
  trajectoryMatchMode: "strict",  // "strict" | "unordered" | "superset" | "subset"
});

기준 궤적이 없거나 "효율적이었나" 같은 미묘한 걸 보고 싶으면 궤적용 LLM judge를 씁니다.

import { createTrajectoryLLMAsJudge, TRAJECTORY_ACCURACY_PROMPT } from "agentevals";

const evaluator = createTrajectoryLLMAsJudge({
  model: "openai:o3-mini",
  prompt: TRAJECTORY_ACCURACY_PROMPT,
});

// 기준 궤적을 함께 줄 수도 있습니다
import { TRAJECTORY_ACCURACY_PROMPT_WITH_REFERENCE } from "agentevals";

createTrajectoryLLMAsJudge 는 기준 궤적이 없어도 동작합니다(모델이 rubric으로 판단). 유연하지만 LLM 호출 비용이 들고 덜 결정적입니다. 가능하면 createTrajectoryMatchEvaluator, 안 되면 judge 순으로 고르세요.


19-8. 회귀 방지 — CI에 평가 붙이기

평가를 손으로 돌리면 안 돌립니다. 바쁘면 건너뛰고, 건너뛴 날 사고가 납니다. CI에 붙여야 의미가 생깁니다.

핵심 발상: CI의 목적은 점수를 보여주는 게 아니라 기준 미달이면 빌드를 깨는 것입니다. 대시보드는 아무도 안 봅니다. 빨간 X는 봅니다.

const THRESHOLD = 0.9;  // 궤적 정확도 최소치
let trajPass = 0;
for (const ex of dataset) {
  const out = await runAgentOn(ex);
  if (trajectoryMatch(extractTrajectory(out.messages), ex.referenceTools, "strict")) trajPass++;
}
const score = trajPass / dataset.length;
console.log(`  궤적 정확도: ${(score * 100).toFixed(0)}% (기준 ${THRESHOLD * 100}%)`);
if (score < THRESHOLD) {
  console.log(`  → CI 라면 여기서 process.exit(1). 기준 미달이므로 머지 차단.`);
}

출력

  궤적 정확도: 67% (기준 90%)
  → CI 라면 여기서 process.exit(1). 기준 미달이므로 머지 차단.

LangSmith로 실행하기

점수를 시간에 따라 추적하려면 LangSmith에 실험으로 기록합니다. 두 가지 방법이 있습니다.

(1) vitest/jest 통합 — 테스트처럼 쓰고 결과가 LangSmith에 쌓입니다.

import * as ls from "langsmith/vitest";

ls.describe("trajectory accuracy", () => {
  ls.test(
    "accurate trajectory",
    {
      inputs: { messages: [/* ... */] },
      referenceOutputs: { messages: [/* ... */] },
    },
    async ({ inputs, referenceOutputs }) => {
      // 에이전트 실행 + 평가자 호출
    }
  );
});

(2) evaluate 함수 — LangSmith에 올려둔 데이터셋 이름으로 한 번에 돌립니다.

import { evaluate } from "langsmith/evaluation";

await evaluate(runAgent, {
  data: "your_dataset_name",       // LangSmith 데이터셋 이름
  evaluators: [trajectoryEvaluator],
});

둘 다 아래 환경변수가 필요합니다.

export LANGSMITH_API_KEY="your_langsmith_api_key"
export LANGSMITH_TRACING="true"

CI 파이프라인 구성

층을 나눠서 각각 다른 주기로 돌리는 게 핵심입니다.

주기모델비용/시간실패 시
도구 단위 테스트매 커밋없음0원 / 1초즉시 차단
에이전트 테스트 (fakeModel)매 커밋없음0원 / 5초즉시 차단
통합 테스트 (*.int.test.ts)PR작은 모델수백원 / 2분차단
평가 (궤적 + judge)PR / 배포 전실제 모델수천원 / 10분기준 미달 시 차단
# .github/workflows/ci.yml (발췌)
jobs:
  unit:
    steps:
      - run: npm test              # fakeModel — 키 불필요, 매번
  eval:
    if: github.event_name == 'pull_request'
    steps:
      - run: npm run test:integration
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
          LANGSMITH_TRACING: "true"
          LANGSMITH_PROJECT: "myagent-eval"   # 운영 트레이스와 섞이지 않게

💡 실무 팁 — 기준선(baseline)을 어떻게 정하나: 처음부터 90%를 요구하면 CI가 항상 빨간불이라 아무도 안 봅니다. 현실적인 순서는 (1) 현재 점수를 측정한다(예: 72%), (2) 기준을 현재보다 살짝 아래로 잡는다(70%), (3) 개선될 때마다 기준을 올린다. 이러면 CI는 "더 좋아져라"가 아니라 "나빠지지 마라" 는 회귀 게이트가 됩니다. 그게 CI가 해야 할 일입니다.

⚠️ 함정: 평가 CI를 모든 푸시마다 돌리지 마세요. 실제 모델 호출이 들어가므로 (1) 청구서가 폭발하고 (2) 10분씩 걸려서 개발 흐름이 끊기고 (3) 모델 비결정성 때문에 같은 코드가 어떤 날은 통과, 어떤 날은 실패합니다. PR 단위 또는 배포 전으로 제한하고, 점수가 경계선에서 흔들린다면 여러 번 돌려 평균을 내거나 기준을 낮추세요.


19-9. LangGraph Studio로 시각적 디버깅

지금까지는 전부 텍스트였습니다. Studio 는 에이전트를 그래프로 보면서 디버깅하는 무료 시각 도구입니다.

Studio는 코드가 아니라 설정입니다. 세 가지가 필요합니다.

1) 에이전트를 export 하기

// src/agent.ts
export const agent = createAgent({ model: "anthropic:claude-sonnet-4-6", tools, systemPrompt: "..." });

2) langgraph.json

{
  "dependencies": ["."],
  "graphs": {
    "agent": "./src/agent.ts:agent"
  },
  "env": ".env"
}

"agent": "./src/agent.ts:agent""파일경로:export이름" 형식입니다. export 이름이 안 맞으면 Studio가 그래프를 못 찾습니다.

3) 개발 서버 실행

npx @langchain/langgraph-cli dev

접속:

  • API: http://127.0.0.1:2024
  • Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024

(Safari라면 --tunnel 플래그를 붙여야 합니다 — Safari가 localhost로의 보안 연결을 막습니다.)

Studio에서 할 수 있는 것:

기능설명
그래프 시각화노드와 엣지를 그림으로. 실행 중 어느 노드가 활성인지 실시간 표시
핫 리로드코드를 고치면 즉시 반영. 서버 재시작 불필요
전체 실행 추적프롬프트, 도구 인자, 토큰/지연 지표 전부
스레드 리플레이임의의 지점부터 다시 실행. 5단계에서 틀어졌으면 5단계 상태를 고쳐서 거기서부터 재실행
예외 캡처에러를 그때의 상태와 함께 보여줌

스레드 리플레이가 Studio의 진짜 가치입니다. 텍스트 로그로 디버깅하면 매번 처음부터 다시 돌려야 합니다 — 10단계짜리 에이전트에서 9단계가 틀렸다면 앞의 8단계를 매번 다시 (돈 내고) 실행해야 합니다. Studio는 그 지점의 상태를 고쳐서 거기서부터 재실행할 수 있습니다. 이건 체크포인터(Step 10)가 있어서 가능한 것입니다.

💡 실무 팁: Studio를 쓸 때 LANGSMITH_TRACING=false 로 두면 트레이스를 안 올리면서도 Studio는 씁니다(로컬 서버가 직접 상태를 보여주므로). 민감 데이터를 다루는 개발 단계에서 유용한 조합입니다.

⚠️ 함정: langgraph.jsongraphs 값에는 에이전트 인스턴스를 export한 이름을 적어야 합니다. 함수(createMyAgent)를 export해놓고 그 이름을 적으면 Studio가 그래프를 인식하지 못합니다. 또 "env": ".env" 를 빠뜨리면 API 키를 못 읽어서 모델 호출이 전부 실패합니다 — Studio는 셸 환경변수가 아니라 이 설정을 봅니다.


19-10. 종합 — 3층 구조

세 가지는 각각 다른 질문에 답합니다. 하나로 다른 걸 대신할 수 없습니다.

1층 관측(observability): 무슨 일이 있었나?   → LangSmith 트레이스 / ConsoleCallbackHandler / streamMode debug
2층 테스트(test):        코드가 안 깨졌나?   → 도구 단위 테스트 + fakeModel (빠름, 결정적, 무료)
3층 평가(eval):          품질이 안 떨어졌나? → 데이터셋 + 궤적 평가자 + LLM judge (느림, 비쌈, CI 게이트)

그리고 세 층은 순환합니다. 이게 실제 운영 루프입니다.

  1. 관측으로 운영에서 실패한 실행을 발견한다 (트레이스)
  2. 그 실패를 평가 데이터셋에 추가한다
  3. 고친다
  4. 테스트로 안 깨졌는지, 평가로 그 실패가 재현 안 되는지 확인한다
  5. CI가 앞으로 영원히 그 회귀를 막는다

19-3에서 라벨링을 강조한 이유가 5번에 있습니다. 라벨 없는 트레이스는 1번에서 못 찾고, 못 찾으면 이 루프가 시작되지 않습니다.


정리

도구무엇을 하나import / 설정
LangSmith 트레이싱모든 실행을 저장·검색LANGSMITH_TRACING=true, LANGSMITH_API_KEY, LANGSMITH_PROJECT
LangChainTracer특정 실행만 선택적 추적@langchain/core/tracers/tracer_langchain
runName/tags/metadata트레이스 라벨링invoke(input, { runName, tags, metadata })
createAnonymizer민감 패턴 마스킹 후 전송langsmith/anonymizer
ConsoleCallbackHandler즉석 콘솔 디버깅@langchain/core/tracers/console
streamMode: "debug"노드 단위 이벤트 (task/task_result)agent.stream(input, { streamMode: "debug" })
createMiddleware커스텀 로깅/감사langchain
fakeModel()모델을 대본으로 대체langchain
langchainMatchersvitest/jest 커스텀 매처@langchain/core/testing
agentevals궤적 평가자npm i agentevals
evaluate / langsmith/vitestLangSmith 실험 실행langsmith/evaluation
LangGraph Studio시각적 디버깅·리플레이langgraph.json + npx @langchain/langgraph-cli dev

fakeModel() 응답 개수 공식: 도구 호출 횟수 + 1

핵심 함정 3가지

  1. 트레이싱은 데이터를 외부로 보낸다. LANGSMITH_TRACING=true 한 줄이 프롬프트·도구 인자·응답을 전부 LangSmith 서버로 올립니다. 조용히 잘 됩니다 — 그래서 위험합니다. 민감정보가 프롬프트에 들어간다면 켜기 전에 검토하고, createAnonymizerpiiMiddleware 로 방어하세요.
  2. 최종 답만 평가하면 운 좋게 맞은 답을 못 걸러낸다. 도구를 건너뛰고 환각으로 답했는데 우연히 맞으면 만점이 나옵니다. 그 에이전트는 데이터가 바뀌는 순간 조용히 틀리기 시작합니다. 궤적 평가로 "올바른 도구를 올바른 순서로 불렀나"를 봐야 합니다.
  3. 단위 테스트에서 실제 모델을 부르면 느리고 비싸고 불안정하다. flaky한 테스트는 무시당하고, 무시당하는 테스트는 없는 것과 같습니다. temperature: 0 도 결정성을 보장하지 않습니다. 단위 테스트는 fakeModel, 실제 모델은 통합 테스트/평가 층으로.

추가로 기억할 것: LLM-as-judge도 틀립니다 — 사람이 채점한 샘플로 judge 자체를 먼저 검증하세요. 평가 데이터셋이 프롬프트 예시와 겹치면(오염) 점수가 거짓으로 오릅니다.


연습문제

  1. 도구 단위 테스트getWeather / getPopulation 을 모델 없이 호출해 (1) 제주 날씨 정확값 (2) 없는 도시("런던")의 폴백 문자열 (3) 인구의 천단위 콤마를 검증하세요.
  2. 2단계 도구 호출 — "제주 날씨랑 인구 알려줘" 에 대해 get_weather → get_population → 최종 답 순으로 동작하도록 fakeModel 을 스크립팅하고, 메시지 6개 / ToolMessage 2개 / callCount === 3 을 검증하세요. (힌트: 응답 개수 공식)
  3. 궤적 추출messages 에서 호출된 도구 이름을 순서대로 뽑는 extractTrajectory(messages) 를 구현하고 문제 2의 결과에 적용하세요.
  4. unordered 매칭actual = ["get_population","get_weather"], reference = ["get_weather","get_population"] 에 대해 strict 는 FAIL, unordered 는 PASS 가 나오는 trajectoryMatch 를 구현하세요.
  5. 환각을 통과시키는 평가get_weather 만 부르고 인구를 지어내는 에이전트에 대해 최종답 평가(PASS)와 궤적 평가(FAIL)를 둘 다 돌려 결과가 갈리는 것을 보이고, 왜 최종답 평가만으로는 위험한지 설명하세요.
  6. 감사 로그 미들웨어createMiddlewarewrapToolCall 로 호출된 도구의 이름과 인자를 auditLog 배열에 기록하세요. (힌트: handler(request) 를 반드시 return)
  7. 회귀 게이트 — 3개짜리 데이터셋으로 궤적 정확도를 계산하고 기준(0.9) 미달 시 "REGRESSION" 을 출력하세요. 정확도는 몇 %이고 어느 예제가 왜 깨지나요?
  8. 트레이스 라벨 설계 — "특정 사용자가 어제 겪은 오류를 다시 찾아야 한다"는 상황에서 runName/tags/metadata 를 설계하세요. 그리고 (a) 사용자 이메일을 metadata 에 넣어도 될까요? (b) tagsmetadata 는 어떻게 나눠 쓰나요?

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


다음 단계

Step 20 — 프로덕션과 종합 프로젝트

이 스텝에서 만든 트레이싱·테스트·평가는 Step 20에서 배포 파이프라인에 그대로 들어갑니다. 관측 없이 배포하는 것은 계기판 없이 비행하는 것과 같습니다.


실습 파일

이 스텝은 TypeScript 파일 3개로 구성됩니다. practice.ts 를 먼저 실행해 본문의 출력을 눈으로 확인하고, exercise.ts 의 8문제를 직접 푼 뒤, solution.ts 로 채점하고 해설을 읽는 흐름입니다.

세 파일 모두 API 키 없이 실행됩니다. fakeModel() 이 실제 모델 자리를 대신하기 때문입니다. 실제 모델을 부르는 곳은 practice.ts[19-2] 블록 하나뿐이고, ANTHROPIC_API_KEY 가 없으면 스스로 건너뜁니다. 실행은 npx tsx docs/reference/langchain/step-19-observability-eval/practice.ts 입니다.

practice.ts

본문 [19-1] ~ [19-10] 을 순서대로 담은 파일입니다. 절 번호가 본문 소제목과 1:1로 대응합니다.

  • 파일 상단의 getWeather / getPopulation 두 도구가 이 스텝 전체의 공용 소재입니다. 둘 다 하드코딩된 표를 조회하는 순수 함수 — 즉 결정적이라서 [19-5a] 에서 모델 없이 그대로 단위 테스트됩니다.
  • [19-3] 의 마지막 console.log 가 함정 확인용입니다. model.calls[0].options 의 키를 찍어서 거기에 tags/metadata없다는 것을 눈으로 보여줍니다. 트레이스 라벨을 fakeModel 로 검증하려다 실패하는 경험을 미리 하게 하는 장치입니다.
  • [19-4a]ConsoleCallbackHandler 는 실행하면 터미널이 수백 줄로 폭발합니다. 정상입니다 — 본문에서 말한 "출력 폭발" 을 직접 겪으라고 일부러 남겨뒀습니다. 그 사이에서 [tool/start] ... get_weather ... input: {"city":"부산"} 줄을 찾아보세요.
  • [19-6][19-7]같은 runAgentOn 함수를 두 번 돌립니다. [19-6] 은 최종답만 보고 3개 전부 만점을 주고, [19-7] 은 궤적을 봐서 ex-3 을 잡아냅니다. 이 대비가 이 스텝의 핵심이라 일부러 두 번 실행합니다.
  • scriptedex-3일부러 틀리게 만든 대본입니다(get_weather 만 부르고 인구는 지어냄). 고치지 마세요 — 이게 틀려야 [19-7][19-8] 의 교훈이 성립합니다. [19-8] 의 궤적 정확도 67%는 여기서 나옵니다.
/**
 * Step 19 — 관측·테스트·평가
 * 실행: npx tsx docs/reference/langchain/step-19-observability-eval/practice.ts
 *
 * 이 파일은 [19-1] ~ [19-10] 블록으로 나뉘며, 본문 소제목과 1:1 대응합니다.
 *
 * 중요: 이 파일은 대부분 `fakeModel()` 로 동작하므로 **API 키 없이도 실행됩니다.**
 * 실제 모델을 호출하는 블록은 [19-2] 하나뿐이고, 키가 없으면 스스로 건너뜁니다.
 */
import "dotenv/config";
import {
  createAgent,
  createMiddleware,
  fakeModel,
  tool,
  AIMessage,
  HumanMessage,
  type BaseMessage,
} from "langchain";
import { ConsoleCallbackHandler } from "@langchain/core/tracers/console";
import * as z from "zod";

/* ===== 공통: 이 스텝 내내 쓰는 도구 2개 ===== */

// 도구는 결정적입니다. 같은 입력 → 항상 같은 출력. 그래서 단위 테스트가 됩니다.
const getWeather = tool(
  ({ city }) => {
    const table: Record<string, string> = {
      서울: "맑음, 23도",
      부산: "흐림, 26도",
      제주: "비, 21도",
    };
    const found = table[city];
    if (!found) return `${city}: 날씨 정보 없음`;
    return `${city}: ${found}`;
  },
  {
    name: "get_weather",
    description: "도시의 현재 날씨를 조회합니다. city 는 한국 도시명입니다.",
    schema: z.object({ city: z.string().describe("도시명, 예: 서울") }),
  }
);

const getPopulation = tool(
  ({ city }) => {
    const table: Record<string, number> = { 서울: 9_400_000, 부산: 3_300_000, 제주: 670_000 };
    const found = table[city];
    if (found === undefined) return `${city}: 인구 정보 없음`;
    return `${city}: ${found.toLocaleString("ko-KR")}명`;
  },
  {
    name: "get_population",
    description: "도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string().describe("도시명, 예: 서울") }),
  }
);

const tools = [getWeather, getPopulation];

/* ===== [19-1] 에이전트는 왜 디버깅이 어려운가 ===== */

// invoke() 의 반환값만 보면 "최종 답" 하나입니다.
// 그 사이에 모델이 몇 번 불렸고 어떤 도구를 어떤 인자로 불렀는지는 안 보입니다.
{
  console.log("\n===== [19-1] 최종 답만 보면 아무것도 모른다 =====");

  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "call_1" }])
    .respond(new AIMessage("서울은 맑고 23도입니다."));

  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage("서울 날씨 알려줘")] });

  // 흔히 이렇게만 봅니다 — 여기엔 정보가 거의 없습니다.
  console.log("최종 답:", result.messages.at(-1)?.text);

  // 사실은 이만큼 일어났습니다. messages 배열이 곧 "궤적(trajectory)" 입니다.
  console.log("\n실제로 벌어진 일 (messages 배열 전체):");
  for (const m of result.messages) {
    const toolCalls = (m as AIMessage).tool_calls ?? [];
    const suffix = toolCalls.length ? ` → tool_calls: ${toolCalls.map((t) => t.name).join(", ")}` : "";
    console.log(`  [${m.getType()}] ${JSON.stringify(m.text).slice(0, 40)}${suffix}`);
  }

  // 모델이 몇 번 불렸나? fakeModel 은 이것도 세어 줍니다.
  console.log("모델 호출 횟수:", model.callCount); // 2 (도구 호출 결정 1번 + 최종 답 1번)
}

/* ===== [19-2] LangSmith 트레이싱 켜기 ===== */

// 코드 변경 없음. 환경변수 3개만 세팅하면 자동으로 트레이스가 올라갑니다.
//   LANGSMITH_TRACING=true
//   LANGSMITH_API_KEY=lsv2_...
//   LANGSMITH_PROJECT=langchain-course   (선택. 없으면 "default")
{
  console.log("\n===== [19-2] LangSmith 트레이싱 =====");
  console.log("LANGSMITH_TRACING =", process.env.LANGSMITH_TRACING ?? "(미설정)");
  console.log("LANGSMITH_API_KEY =", process.env.LANGSMITH_API_KEY ? "(설정됨)" : "(미설정)");
  console.log("LANGSMITH_PROJECT =", process.env.LANGSMITH_PROJECT ?? "(미설정 → default)");

  // 이 블록만 실제 모델을 씁니다. 키가 없으면 건너뜁니다.
  if (process.env.ANTHROPIC_API_KEY) {
    const agent = createAgent({
      model: "anthropic:claude-sonnet-4-6", // OpenAI 대안: "openai:gpt-5.5"
      tools,
      systemPrompt: "너는 한국 도시 정보 도우미다. 도구를 써서 답하라.",
    });
    const result = await agent.invoke({ messages: [{ role: "user", content: "제주 날씨랑 인구 알려줘" }] });
    console.log("실제 모델 응답:", result.messages.at(-1)?.text);
    console.log("→ LANGSMITH_TRACING=true 였다면 smith.langchain.com 에 트레이스가 올라갔습니다.");
  } else {
    console.log("(ANTHROPIC_API_KEY 없음 → 실제 모델 호출 건너뜀)");
  }
}

/* ===== [19-3] 트레이스에 메타데이터/태그/runName 붙이기 ===== */

// 트레이스는 쌓이면 수만 개가 됩니다. 나중에 찾으려면 미리 라벨을 붙여야 합니다.
// runName / tags / metadata 는 invoke() 의 두 번째 인자(config)에 넣습니다.
{
  console.log("\n===== [19-3] runName / tags / metadata =====");

  const model = fakeModel().respond(new AIMessage("네, 도와드리겠습니다."));
  const agent = createAgent({ model, tools });

  await agent.invoke(
    { messages: [new HumanMessage("안녕")] },
    {
      runName: "greeting-flow", // 트레이스 목록에 표시될 이름
      tags: ["production", "weather-agent", "v1.0"], // 필터용 (완전 일치 검색)
      metadata: {
        userId: "user-123", // 특정 사용자 민원 추적
        sessionId: "sess-456",
        environment: "production",
        promptVersion: "2026-07-17", // 프롬프트 버전 — A/B 비교의 핵심
      },
    }
  );
  console.log("runName='greeting-flow', tags=[production, weather-agent, v1.0] 로 실행됨");

  // ⚠️ 함정: config 의 tags/metadata 는 트레이서로 갑니다.
  // fakeModel 이 기록하는 calls[i].options 에는 들어오지 않습니다.
  console.log("fakeModel.calls[0].options 의 키:", Object.keys(model.calls[0].options ?? {}));
  console.log("→ tags/metadata 가 없습니다. 이걸로는 라벨을 검증할 수 없습니다.");
}

/* ===== [19-4] LangSmith 없이 디버깅 ===== */

// (a) 콘솔 콜백 — 가장 빠른 방법. 모든 체인/모델/도구 시작·종료를 stdout 에 찍습니다.
{
  console.log("\n===== [19-4a] ConsoleCallbackHandler =====");

  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "부산" }, id: "call_1" }])
    .respond(new AIMessage("부산은 흐리고 26도입니다."));

  const agent = createAgent({ model, tools });
  await agent.invoke(
    { messages: [new HumanMessage("부산 날씨?")] },
    { callbacks: [new ConsoleCallbackHandler()] } // ← 이 한 줄
  );
  console.log("(위 [chain/start] ... 로그가 ConsoleCallbackHandler 의 출력입니다)");
}

// (b) streamMode: "debug" — 노드 단위로 무슨 일이 일어나는지 이벤트로 받습니다.
{
  console.log("\n===== [19-4b] streamMode: 'debug' =====");

  const model = fakeModel()
    .respondWithTools([{ name: "get_population", args: { city: "제주" }, id: "call_1" }])
    .respond(new AIMessage("제주 인구는 67만명입니다."));

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

  for await (const ev of await agent.stream(
    { messages: [new HumanMessage("제주 인구?")] },
    { streamMode: "debug" }
  )) {
    // 이벤트 구조는 결정적입니다: { step, type, timestamp, payload }
    //   type: "task"        → 노드 실행 시작 (payload.input 있음)
    //   type: "task_result" → 노드 실행 완료 (payload.result 있음)
    const e = ev as { step: number; type: string; payload: { name?: string } };
    console.log(`  step=${e.step} type=${e.type} node=${e.payload?.name}`);
  }
}

// (c) 커스텀 로깅 미들웨어 — 팀 전체에 일관된 로그를 강제하고 싶을 때.
{
  console.log("\n===== [19-4c] 커스텀 로깅 미들웨어 =====");

  const loggingMiddleware = createMiddleware({
    name: "LoggingMiddleware",
    // 모델 호출을 감싸서 지연시간을 잰다
    wrapModelCall: async (request, handler) => {
      const started = Date.now();
      const response = await handler(request);
      console.log(`  [model] ${Date.now() - started}ms`);
      return response;
    },
    // 도구 호출을 감싸서 이름과 인자를 남긴다
    wrapToolCall: async (request, handler) => {
      const started = Date.now();
      console.log(`  [tool:start] ${request.toolCall.name} args=${JSON.stringify(request.toolCall.args)}`);
      const response = await handler(request);
      console.log(`  [tool:end]   ${request.toolCall.name} (${Date.now() - started}ms)`);
      return response;
    },
  });

  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "call_1" }])
    .respond(new AIMessage("서울은 맑고 23도입니다."));

  const agent = createAgent({ model, tools, middleware: [loggingMiddleware] });
  await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });
}

/* ===== [19-5] 에이전트 테스트 ===== */

// 아주 작은 테스트 러너. 실제로는 vitest 를 씁니다(본문 19-5 참고).
let passed = 0;
let failed = 0;
function check(label: string, cond: boolean) {
  if (cond) {
    passed++;
    console.log(`  ✓ ${label}`);
  } else {
    failed++;
    console.log(`  ✗ ${label}`);
  }
}

// (a) 도구 단위 테스트 — 모델이 전혀 필요 없습니다. 빠르고 결정적입니다.
{
  console.log("\n===== [19-5a] 도구 단위 테스트 (모델 없음) =====");

  check("서울 날씨 조회", (await getWeather.invoke({ city: "서울" })) === "서울: 맑음, 23도");
  check("없는 도시는 '정보 없음'", (await getWeather.invoke({ city: "런던" })) === "런던: 날씨 정보 없음");
  check("인구는 천단위 콤마", (await getPopulation.invoke({ city: "부산" })) === "부산: 3,300,000명");
}

// (b) 에이전트 테스트 — 모델을 fakeModel 로 갈아끼웁니다.
{
  console.log("\n===== [19-5b] fakeModel 로 에이전트 테스트 =====");

  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "call_1" }])
    .respond(new AIMessage("서울은 맑고 23도입니다."));

  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });

  check("메시지 4개 (human → ai → tool → ai)", result.messages.length === 4);
  check("도구가 실제로 실행되어 ToolMessage 가 생김", result.messages[2].getType() === "tool");
  check("ToolMessage 내용이 도구의 실제 반환값", result.messages[2].text === "서울: 맑음, 23도");
  check("최종 답은 AI 메시지", result.messages.at(-1)?.getType() === "ai");
  check("모델은 2번 호출됨", model.callCount === 2);

  // ⚠️ respondWithTools 의 content 는 입력 메시지를 그대로 되돌려줍니다.
  // 즉 "서울 날씨?" 가 AI 메시지 content 로 들어옵니다. 여기에 단언하지 마세요.
  console.log("  (참고) tool_calls 를 담은 AI 메시지의 content =", JSON.stringify(result.messages[1].text));
}

// (c) fakeModel 로 에러 경로 테스트 — 실제 모델로는 재현이 거의 불가능한 상황.
{
  console.log("\n===== [19-5c] 에러 경로 테스트 =====");

  const failing = fakeModel().alwaysThrow(new Error("service unavailable"));
  const agent = createAgent({ model: failing, tools });
  let caught = "";
  try {
    await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });
  } catch (e) {
    caught = (e as Error).message;
  }
  check("모델 장애가 위로 전파된다", caught === "service unavailable");

  // 큐가 비면 fakeModel 은 던집니다. 메시지는 결정적입니다.
  const oneShot = fakeModel().respond(new AIMessage("한 번만"));
  await oneShot.invoke([new HumanMessage("a")]);
  let queueErr = "";
  try {
    await oneShot.invoke([new HumanMessage("b")]);
  } catch (e) {
    queueErr = (e as Error).message;
  }
  console.log("  큐 소진 에러:", queueErr);
  check("큐 소진 시 명확한 에러", queueErr.startsWith("FakeModel: no response queued"));
}

/* ===== [19-6] 평가 — 데이터셋 + 평가자 ===== */

// 평가의 최소 구성: (1) 데이터셋 (2) 실행 함수 (3) 평가자 (4) 집계
type Example = {
  id: string;
  input: string;
  referenceOutput: string; // 기대 답변
  referenceTools: string[]; // 기대 도구 호출 순서 (19-7 에서 사용)
};

const dataset: Example[] = [
  { id: "ex-1", input: "서울 날씨 알려줘", referenceOutput: "맑음, 23도", referenceTools: ["get_weather"] },
  { id: "ex-2", input: "부산 인구는?", referenceOutput: "3,300,000명", referenceTools: ["get_population"] },
  {
    id: "ex-3",
    input: "제주 날씨랑 인구 둘 다",
    referenceOutput: "비, 21도 / 670,000명",
    referenceTools: ["get_weather", "get_population"],
  },
];

// 평가자 (1): 결정적 매칭 — 싸고 빠르고 안 틀립니다. 쓸 수 있으면 이걸 쓰세요.
function containsEvaluator(outputs: { answer: string }, referenceOutput: string) {
  const keys = referenceOutput.split(" / ");
  const hit = keys.filter((k) => outputs.answer.includes(k)).length;
  return { key: "contains", score: hit / keys.length };
}

// 평가자 (2): LLM-as-judge — 여기서는 실제 모델 대신 fakeModel 로 시늉만 냅니다.
// 이론은 /ai/9990-LLMJudge 참고. 여기서는 "어떻게 실행하는가" 만 봅니다.
async function llmJudgeEvaluator(outputs: { answer: string }, referenceOutput: string) {
  // 실전에서는 아래 model 을 "anthropic:claude-sonnet-4-6" 등으로 바꾸고
  // withStructuredOutput 으로 점수를 강제합니다.
  const judge = fakeModel().structuredResponse({
    score: outputs.answer.includes(referenceOutput.split(" / ")[0]) ? 1 : 0,
    reasoning: "기대 답의 핵심 키워드 포함 여부로 판정",
  });
  // 참고: fakeModel 의 withStructuredOutput 은 { raw, parsed } 형태까지 포함한
  // 유니온 타입을 반환하도록 선언돼 있어서, 여기서는 결과 타입을 명시해 줍니다.
  // 실제 모델(ChatAnthropic 등)에서는 이 단언 없이 바로 좁혀집니다.
  const judged = (await judge
    .withStructuredOutput(z.object({ score: z.number(), reasoning: z.string() }))
    .invoke(`기대: ${referenceOutput}\n실제: ${outputs.answer}\n0~1 로 채점하라.`)) as {
    score: number;
    reasoning: string;
  };
  return { key: "llm_judge", score: judged.score, comment: judged.reasoning };
}

// 스크립트로 시연하려면 각 예제의 응답을 미리 정해야 합니다(fakeModel).
const scripted: Record<string, () => ReturnType<typeof fakeModel>> = {
  "ex-1": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "c1" }])
      .respond(new AIMessage("서울은 맑음, 23도 입니다.")),
  "ex-2": () =>
    fakeModel()
      .respondWithTools([{ name: "get_population", args: { city: "부산" }, id: "c1" }])
      .respond(new AIMessage("부산 인구는 3,300,000명 입니다.")),
  // ex-3 은 일부러 "운 좋게 맞은" 케이스로 만듭니다 — 도구를 하나만 부르고도 답은 맞습니다.
  "ex-3": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "제주" }, id: "c1" }])
      .respond(new AIMessage("제주는 비, 21도 이고 인구는 670,000명 입니다.")),
};

async function runAgentOn(example: Example) {
  const model = scripted[example.id]();
  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage(example.input)] });
  return { answer: result.messages.at(-1)?.text ?? "", messages: result.messages };
}

{
  console.log("\n===== [19-6] 평가 실행 =====");
  for (const ex of dataset) {
    const out = await runAgentOn(ex);
    const c = containsEvaluator(out, ex.referenceOutput);
    const j = await llmJudgeEvaluator(out, ex.referenceOutput);
    console.log(`  ${ex.id}: contains=${c.score.toFixed(2)} llm_judge=${j.score.toFixed(2)}`);
  }
  console.log("  → ex-3 도 최종 답 기준으로는 만점입니다. 다음 절에서 이게 왜 위험한지 봅니다.");
}

/* ===== [19-7] 궤적(trajectory) 평가 ===== */

// 궤적 = 에이전트가 부른 도구의 이름과 순서. messages 배열에서 뽑아냅니다.
function extractTrajectory(messages: BaseMessage[]): string[] {
  return messages
    .filter((m): m is AIMessage => m.getType() === "ai")
    .flatMap((m) => (m.tool_calls ?? []).map((tc) => tc.name));
}

// 궤적 평가자 — agentevals 의 trajectoryMatchMode 를 직접 구현해 본 것입니다.
type MatchMode = "strict" | "unordered" | "superset" | "subset";
function trajectoryMatch(actual: string[], reference: string[], mode: MatchMode): boolean {
  const sortedA = [...actual].sort();
  const sortedR = [...reference].sort();
  switch (mode) {
    case "strict": // 같은 도구를 같은 순서로
      return actual.length === reference.length && actual.every((t, i) => t === reference[i]);
    case "unordered": // 같은 도구 집합, 순서 무관
      return sortedA.length === sortedR.length && sortedA.every((t, i) => t === sortedR[i]);
    case "superset": // 기대한 건 다 불렀고, 추가로 더 불러도 OK
      return reference.every((t) => actual.includes(t));
    case "subset": // 기대 범위를 벗어난 도구는 안 불렀다
      return actual.every((t) => reference.includes(t));
  }
}

{
  console.log("\n===== [19-7] 궤적 평가 =====");
  for (const ex of dataset) {
    const out = await runAgentOn(ex);
    const traj = extractTrajectory(out.messages);
    const strict = trajectoryMatch(traj, ex.referenceTools, "strict");
    const answerOk = containsEvaluator(out, ex.referenceOutput).score === 1;
    console.log(
      `  ${ex.id}: 궤적=[${traj.join(" → ")}] 기대=[${ex.referenceTools.join(" → ")}] ` +
        `strict=${strict ? "PASS" : "FAIL"} 최종답=${answerOk ? "PASS" : "FAIL"}`
    );
  }
  console.log("  → ex-3: 최종답 PASS / 궤적 FAIL. get_population 을 안 부르고 인구를 '지어냈습니다'.");
  console.log("    최종 답만 봤다면 환각을 만점으로 통과시켰을 겁니다.");
}

/* ===== [19-8] 회귀 방지 — CI 게이트 ===== */

// CI 는 "점수를 보여주는 것" 이 아니라 "기준 미달이면 빌드를 깨는 것" 입니다.
{
  console.log("\n===== [19-8] 회귀 게이트 =====");

  const THRESHOLD = 0.9; // 궤적 정확도 최소치
  let trajPass = 0;
  for (const ex of dataset) {
    const out = await runAgentOn(ex);
    if (trajectoryMatch(extractTrajectory(out.messages), ex.referenceTools, "strict")) trajPass++;
  }
  const score = trajPass / dataset.length;
  console.log(`  궤적 정확도: ${(score * 100).toFixed(0)}% (기준 ${THRESHOLD * 100}%)`);
  if (score < THRESHOLD) {
    console.log(`  → CI 라면 여기서 process.exit(1). 기준 미달이므로 머지 차단.`);
  } else {
    console.log(`  → 통과.`);
  }
}

/* ===== [19-9] LangGraph Studio ===== */

// Studio 는 코드가 아니라 설정입니다. 아래 내용을 project/langgraph.json 에 두고
//   npx @langchain/langgraph-cli dev
// 를 실행하면 됩니다. 본문 19-9 참고.
{
  console.log("\n===== [19-9] Studio =====");
  console.log(
    JSON.stringify(
      {
        dependencies: ["."],
        graphs: { agent: "./src/agent.ts:agent" },
        env: ".env",
      },
      null,
      2
    )
  );
  console.log("→ npx @langchain/langgraph-cli dev  후 https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024");
}

/* ===== [19-10] 종합 ===== */

{
  console.log("\n===== [19-10] 종합 =====");
  console.log(`단위 테스트: ${passed} 통과 / ${failed} 실패`);
  console.log(`
관측·테스트·평가 3층 요약
  1층 관측(observability): 무슨 일이 있었나?      → LangSmith 트레이스 / ConsoleCallbackHandler / streamMode debug
  2층 테스트(test):        코드가 안 깨졌나?      → 도구 단위 테스트 + fakeModel (빠름, 결정적, 무료)
  3층 평가(eval):          품질이 안 떨어졌나?    → 데이터셋 + 궤적 평가자 + LLM judge (느림, 비쌈, CI 게이트)
`);
}

exercise.ts

본문 연습문제 8개를 [문제 N] 주석 블록으로 담은 빈칸 채우기 파일입니다. 각 블록 아래 // 여기에 작성 자리를 채우세요.

  • 공용 도구 2개와 [문제 7]dataset / scripted이미 작성되어 있습니다. 여러분이 만들 것은 평가 로직과 게이트뿐입니다.
  • [문제 2] 가 이 파일에서 가장 자주 막히는 곳입니다. 응답을 2개만 큐에 넣고 FakeModel: no response queued for invocation 2 를 만나게 될 텐데, 이건 코드가 틀린 게 아니라 대본이 짧은 것입니다. 본문 19-5의 응답 개수 공식(도구 호출 횟수 + 1)을 다시 보세요.
  • [문제 5][문제 7]답이 "FAIL이 나오는 것" 인 문제입니다. 평가가 통과하도록 고치려 들지 마세요 — 에이전트가 실제로 잘못했고, 그걸 잡아내는 게 목적입니다.
  • [문제 8] 은 코드보다 주석으로 적는 답이 중요합니다. (a)와 (b)를 스스로 적어본 뒤 solution과 비교하세요.
  • 파일을 그대로 실행하면 각 문제의 헤더만 출력되고 그 아래는 비어 있습니다. 정상입니다.
/**
 * Step 19 — 관측·테스트·평가 / 연습문제
 * 실행: npx tsx docs/reference/langchain/step-19-observability-eval/exercise.ts
 *
 * 각 [문제 N] 아래를 채우세요. 정답은 solution.ts 에 있습니다.
 * 이 파일은 API 키 없이 돌아가야 합니다 — 실제 모델을 부르지 마세요.
 */
import "dotenv/config";
import {
  createAgent,
  createMiddleware,
  fakeModel,
  tool,
  AIMessage,
  HumanMessage,
  type BaseMessage,
} from "langchain";
import * as z from "zod";

/* ===== 공통 도구 (practice.ts 와 동일) ===== */

const getWeather = tool(
  ({ city }) => {
    const table: Record<string, string> = { 서울: "맑음, 23도", 부산: "흐림, 26도", 제주: "비, 21도" };
    const found = table[city];
    if (!found) return `${city}: 날씨 정보 없음`;
    return `${city}: ${found}`;
  },
  {
    name: "get_weather",
    description: "도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string() }),
  }
);

const getPopulation = tool(
  ({ city }) => {
    const table: Record<string, number> = { 서울: 9_400_000, 부산: 3_300_000, 제주: 670_000 };
    const found = table[city];
    if (found === undefined) return `${city}: 인구 정보 없음`;
    return `${city}: ${found.toLocaleString("ko-KR")}명`;
  },
  {
    name: "get_population",
    description: "도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string() }),
  }
);

const tools = [getWeather, getPopulation];

/* ===================================================================
 * [문제 1] 도구 단위 테스트
 *
 * getWeather / getPopulation 을 모델 없이 직접 호출해서 아래 3가지를 검증하세요.
 *   (1) getWeather({ city: "제주" }) === "제주: 비, 21도"
 *   (2) 표에 없는 도시("런던")는 "런던: 날씨 정보 없음" 을 반환한다
 *   (3) getPopulation({ city: "서울" }) 에 천단위 콤마가 들어있다
 *
 * 통과/실패를 console.log 로 출력하세요.
 * 힌트: await getWeather.invoke({ city: "제주" })
 * =================================================================== */
console.log("\n===== [문제 1] 도구 단위 테스트 =====");
// 여기에 작성

/* ===================================================================
 * [문제 2] fakeModel 로 2단계 도구 호출 에이전트 테스트
 *
 * "제주 날씨랑 인구 알려줘" 에 대해 에이전트가
 *   get_weather → get_population → 최종 답
 * 순서로 동작하도록 fakeModel 을 스크립팅하고 createAgent 로 실행하세요.
 *
 * 그리고 아래를 검증하세요.
 *   (1) result.messages 의 길이가 6 이다 (human, ai, tool, ai, tool, ai)
 *   (2) ToolMessage 가 2개다
 *   (3) model.callCount === 3
 *
 * 힌트: fakeModel().respondWithTools([...]).respondWithTools([...]).respond(new AIMessage("..."))
 * 힌트: 도구 호출 1번당 모델 호출이 1번씩 더 필요합니다.
 * =================================================================== */
console.log("\n===== [문제 2] 2단계 도구 호출 =====");
// 여기에 작성

/* ===================================================================
 * [문제 3] 궤적 추출 함수
 *
 * messages 배열에서 "호출된 도구 이름을 순서대로" 뽑는 extractTrajectory 를 구현하세요.
 *
 *   function extractTrajectory(messages: BaseMessage[]): string[]
 *
 * 문제 2 의 결과에 적용해서 ["get_weather", "get_population"] 이 나오는지 확인하세요.
 *
 * 힌트: AI 메시지만 고르고(m.getType() === "ai"), 각 메시지의 tool_calls 를 flatMap 하세요.
 * 힌트: tool_calls 는 없을 수 있습니다 (최종 답 메시지).
 * =================================================================== */
console.log("\n===== [문제 3] 궤적 추출 =====");
// 여기에 작성

/* ===================================================================
 * [문제 4] 궤적 평가자 — unordered 모드
 *
 * 아래 두 궤적은 "순서만 다르고 내용은 같습니다".
 *   actual    = ["get_population", "get_weather"]
 *   reference = ["get_weather", "get_population"]
 *
 * strict 모드로는 FAIL, unordered 모드로는 PASS 가 나오는
 * trajectoryMatch(actual, reference, mode) 를 구현하고 둘 다 출력하세요.
 *
 * 힌트: unordered 는 정렬 후 비교. 길이도 함께 봐야 합니다.
 * 힌트: 실무에서는 agentevals 의 createTrajectoryMatchEvaluator({ trajectoryMatchMode })
 *       를 쓰면 되지만, 원리를 알기 위해 직접 만들어 봅니다.
 * =================================================================== */
console.log("\n===== [문제 4] unordered 궤적 매칭 =====");
// 여기에 작성

/* ===================================================================
 * [문제 5] "운 좋게 맞은" 답 잡아내기
 *
 * 아래 에이전트는 get_weather 만 부르고서 인구까지 "지어내서" 답합니다.
 *   fakeModel()
 *     .respondWithTools([{ name: "get_weather", args: { city: "부산" }, id: "c1" }])
 *     .respond(new AIMessage("부산은 흐림, 26도 이고 인구는 3,300,000명 입니다."))
 *
 * 이 에이전트를 실행하고 두 가지 평가를 모두 돌려서, 결과가 갈리는 것을 보이세요.
 *   (1) 최종답 평가: 답에 "흐림, 26도" 와 "3,300,000명" 이 둘 다 있는가? → PASS 가 나올 것
 *   (2) 궤적 평가: 기대 궤적 ["get_weather", "get_population"] 과 strict 매칭 → FAIL 이 나올 것
 *
 * 그리고 "왜 (1) 만으로는 위험한가" 를 console.log 로 한 줄 설명하세요.
 * =================================================================== */
console.log("\n===== [문제 5] 환각을 통과시키는 평가 =====");
// 여기에 작성

/* ===================================================================
 * [문제 6] 도구 호출을 기록하는 미들웨어
 *
 * createMiddleware 로 auditMiddleware 를 만드세요.
 *   - wrapToolCall 에서 호출된 도구의 이름과 인자를 배열 auditLog 에 push
 *   - 그 미들웨어를 붙인 에이전트를 fakeModel 로 실행
 *   - 실행 후 auditLog 를 출력
 *
 * 기대: [{ name: "get_weather", args: { city: "서울" } }]
 *
 * 힌트: wrapToolCall: async (request, handler) => { ...; return handler(request); }
 * 힌트: request.toolCall.name / request.toolCall.args
 * =================================================================== */
console.log("\n===== [문제 6] 감사 로그 미들웨어 =====");
// 여기에 작성

/* ===================================================================
 * [문제 7] 회귀 게이트
 *
 * 아래 3개짜리 데이터셋으로 궤적 정확도를 계산하고,
 * 기준(0.9) 미달이면 "REGRESSION" 을 출력하는 게이트를 만드세요.
 * (실제 CI 라면 process.exit(1) 자리입니다 — 여기서는 출력만 하세요.)
 *
 * 각 예제의 에이전트 응답은 아래 scripted 를 그대로 쓰세요.
 * 정확도는 몇 %가 나오고, 어느 예제가 깨지나요? 주석으로 답을 적으세요.
 * =================================================================== */
console.log("\n===== [문제 7] 회귀 게이트 =====");

const dataset = [
  { id: "ex-1", input: "서울 날씨?", referenceTools: ["get_weather"] },
  { id: "ex-2", input: "부산 인구?", referenceTools: ["get_population"] },
  { id: "ex-3", input: "제주 날씨랑 인구", referenceTools: ["get_weather", "get_population"] },
];

const scripted: Record<string, () => ReturnType<typeof fakeModel>> = {
  "ex-1": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "c1" }])
      .respond(new AIMessage("서울은 맑음, 23도 입니다.")),
  "ex-2": () =>
    fakeModel()
      .respondWithTools([{ name: "get_population", args: { city: "부산" }, id: "c1" }])
      .respond(new AIMessage("부산 인구는 3,300,000명 입니다.")),
  "ex-3": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "제주" }, id: "c1" }])
      .respond(new AIMessage("제주는 비, 21도 이고 인구는 670,000명 입니다.")),
};

// 여기에 작성
// → 정확도는 몇 %인가요? (여기에 답)
// → 어느 예제가 왜 깨지나요? (여기에 답)

/* ===================================================================
 * [문제 8] 트레이스 라벨 설계 (코드 + 서술)
 *
 * "특정 사용자가 어제 겪은 오류를 다시 찾아야 한다" 는 상황을 가정합니다.
 * 에이전트를 실행하면서 runName / tags / metadata 를 적절히 붙이세요.
 *
 * 조건:
 *   - runName 으로 이 플로우를 식별할 수 있어야 한다
 *   - tags 로 환경(production)과 에이전트 버전을 필터할 수 있어야 한다
 *   - metadata 로 특정 사용자와 세션을 되짚을 수 있어야 한다
 *
 * 그리고 아래 질문에 주석으로 답하세요.
 *   (a) 사용자 이메일을 metadata 에 넣어도 될까요? 왜 그런가요?
 *   (b) tags 와 metadata 는 어떻게 나눠 쓰는 게 좋을까요?
 * =================================================================== */
console.log("\n===== [문제 8] 트레이스 라벨 설계 =====");
// 여기에 작성
// (a) (여기에 답)
// (b) (여기에 답)

solution.ts

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

  • [정답 2] 의 해설이 응답 개수 공식을 단계별로 풀어 놓습니다: 모델#1(도구 호출 결정) → 모델#2(결과 보고 다음 도구 결정) → 모델#3(최종 답). 출력의 human → ai → tool → ai → tool → ai 6단계와 짝지어 보세요.
  • [정답 4]unordered 구현에 주석으로 함정이 하나 박혀 있습니다 — 길이 비교(sortedA.length === sortedR.length)를 빼먹으면 ["get_weather"]["get_weather","get_weather"] 와 같다고 판정됩니다. 중복 호출을 못 잡게 되는 미묘한 버그입니다.
  • [정답 5] 가 이 파일의 하이라이트입니다. 출력이 최종답 평가: PASS / 궤적 평가: FAIL 로 갈립니다. 인구 "3,300,000명" 은 get_population 이 아니라 모델 기억에서 나온 값이고, 우연히 맞았을 뿐입니다.
  • [정답 6]return handler(request); 줄에 붙은 주석("이 return 을 빼면 도구 결과가 사라집니다")이 본문 19-4c의 함정과 짝입니다. 실제로 return 을 지우고 돌려보면 auditLog 는 그대로인데 에이전트 동작만 이상해지는 걸 볼 수 있습니다.
  • [정답 7]unordered 모드로 채점하는데도 67% 가 나옵니다. ex-3 은 순서 문제가 아니라 도구를 아예 안 부른 것이라서 모드를 바꿔도 구제되지 않습니다. 실패 목록에 ex-3: 기대 [get_weather, get_population] / 실제 [get_weather] 로 정확히 찍힙니다.
  • [정답 8] 은 이메일 대신 내부 ID(u_8f3a91)를 쓰는 것으로 답합니다. 트레이싱이 데이터를 외부로 보낸다는 19-2의 함정이 여기서 실제 설계 결정으로 이어집니다.
/**
 * Step 19 — 관측·테스트·평가 / 정답과 해설
 * 실행: npx tsx docs/reference/langchain/step-19-observability-eval/solution.ts
 *
 * exercise.ts 를 스스로 풀어본 뒤에 열어보세요.
 */
import "dotenv/config";
import {
  createAgent,
  createMiddleware,
  fakeModel,
  tool,
  AIMessage,
  HumanMessage,
  type BaseMessage,
} from "langchain";
import * as z from "zod";

/* ===== 공통 도구 ===== */

const getWeather = tool(
  ({ city }) => {
    const table: Record<string, string> = { 서울: "맑음, 23도", 부산: "흐림, 26도", 제주: "비, 21도" };
    const found = table[city];
    if (!found) return `${city}: 날씨 정보 없음`;
    return `${city}: ${found}`;
  },
  {
    name: "get_weather",
    description: "도시의 현재 날씨를 조회합니다.",
    schema: z.object({ city: z.string() }),
  }
);

const getPopulation = tool(
  ({ city }) => {
    const table: Record<string, number> = { 서울: 9_400_000, 부산: 3_300_000, 제주: 670_000 };
    const found = table[city];
    if (found === undefined) return `${city}: 인구 정보 없음`;
    return `${city}: ${found.toLocaleString("ko-KR")}명`;
  },
  {
    name: "get_population",
    description: "도시의 인구를 조회합니다.",
    schema: z.object({ city: z.string() }),
  }
);

const tools = [getWeather, getPopulation];

/* 간단한 단언 헬퍼 */
function check(label: string, cond: boolean) {
  console.log(`  ${cond ? "✓" : "✗"} ${label}`);
}

/* ===================================================================
 * [정답 1] 도구 단위 테스트
 *
 * 해설: 도구는 순수 함수입니다 — 모델도, 네트워크도, API 키도 필요 없습니다.
 * 에이전트 테스트의 90%는 사실 여기서 끝나야 합니다. 도구가 틀리면
 * 모델이 아무리 똑똑해도 답이 틀리는데, 이건 모델 없이 1ms 만에 잡힙니다.
 * =================================================================== */
console.log("\n===== [정답 1] 도구 단위 테스트 =====");
{
  check("제주 날씨", (await getWeather.invoke({ city: "제주" })) === "제주: 비, 21도");
  check("없는 도시 폴백", (await getWeather.invoke({ city: "런던" })) === "런던: 날씨 정보 없음");
  check("천단위 콤마", (await getPopulation.invoke({ city: "서울" })).includes("9,400,000"));

  // 함정: 도구의 "정보 없음" 경로를 테스트 안 하면, 모델이 그 문자열을 받고
  // 어떻게 반응하는지도 영영 모릅니다. 폴백 경로도 반드시 테스트하세요.
}

/* ===================================================================
 * [정답 2] fakeModel 로 2단계 도구 호출 에이전트 테스트
 *
 * 해설: 핵심은 "도구 호출 1번당 모델 호출이 1번 더 필요하다" 입니다.
 * 도구를 2번 부르는 궤적은 모델 응답이 3개 필요합니다.
 *   모델#1 → get_weather 호출 결정
 *   모델#2 → (도구 결과 보고) get_population 호출 결정
 *   모델#3 → 최종 답
 * 큐에 2개만 넣으면 "FakeModel: no response queued for invocation 2" 로 터집니다.
 * =================================================================== */
console.log("\n===== [정답 2] 2단계 도구 호출 =====");
{
  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "제주" }, id: "c1" }])
    .respondWithTools([{ name: "get_population", args: { city: "제주" }, id: "c2" }])
    .respond(new AIMessage("제주는 비, 21도 이고 인구는 670,000명 입니다."));

  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage("제주 날씨랑 인구 알려줘")] });

  check("메시지 6개", result.messages.length === 6);
  check("ToolMessage 2개", result.messages.filter((m) => m.getType() === "tool").length === 2);
  check("모델 3번 호출", model.callCount === 3);

  console.log("  궤적:", result.messages.map((m) => m.getType()).join(" → "));
}

/* ===================================================================
 * [정답 3] 궤적 추출 함수
 *
 * 해설: 궤적은 새로 만드는 게 아니라 messages 배열에 이미 들어 있습니다.
 * AI 메시지의 tool_calls 만 순서대로 모으면 됩니다.
 * 최종 답 AI 메시지에는 tool_calls 가 없으므로 `?? []` 로 방어해야 합니다.
 * =================================================================== */
console.log("\n===== [정답 3] 궤적 추출 =====");

function extractTrajectory(messages: BaseMessage[]): string[] {
  return messages
    .filter((m): m is AIMessage => m.getType() === "ai")
    .flatMap((m) => (m.tool_calls ?? []).map((tc) => tc.name));
}

{
  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "제주" }, id: "c1" }])
    .respondWithTools([{ name: "get_population", args: { city: "제주" }, id: "c2" }])
    .respond(new AIMessage("제주는 비, 21도 이고 인구는 670,000명 입니다."));

  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage("제주 날씨랑 인구 알려줘")] });

  const traj = extractTrajectory(result.messages);
  console.log("  궤적:", traj);
  check("['get_weather','get_population']", traj.join(",") === "get_weather,get_population");
}

/* ===================================================================
 * [정답 4] 궤적 평가자 — unordered 모드
 *
 * 해설: 어떤 모드를 쓸지가 곧 "무엇을 요구사항으로 볼 것인가" 입니다.
 *   strict    — 순서까지 계약이다 (예: 권한 조회 후에만 결제)
 *   unordered — 순서는 모델 자유, 필요한 정보는 다 가져와야 한다
 *   superset  — 최소한 이것들은 불러라 (추가 호출 허용)
 *   subset    — 이 범위 밖의 도구는 부르지 마라 (안전 경계)
 * 대부분의 경우 unordered 가 맞습니다. strict 를 남발하면 모델이
 * 똑같이 정답인 다른 순서로 풀었을 때 테스트가 거짓으로 깨집니다.
 * =================================================================== */
console.log("\n===== [정답 4] unordered 궤적 매칭 =====");

type MatchMode = "strict" | "unordered" | "superset" | "subset";
function trajectoryMatch(actual: string[], reference: string[], mode: MatchMode): boolean {
  const sortedA = [...actual].sort();
  const sortedR = [...reference].sort();
  switch (mode) {
    case "strict":
      return actual.length === reference.length && actual.every((t, i) => t === reference[i]);
    case "unordered":
      // 함정: 길이 비교를 빼먹으면 ["get_weather"] 가
      // ["get_weather","get_weather"] 와 같다고 나옵니다.
      return sortedA.length === sortedR.length && sortedA.every((t, i) => t === sortedR[i]);
    case "superset":
      return reference.every((t) => actual.includes(t));
    case "subset":
      return actual.every((t) => reference.includes(t));
  }
}

{
  const actual = ["get_population", "get_weather"];
  const reference = ["get_weather", "get_population"];
  console.log("  strict   :", trajectoryMatch(actual, reference, "strict") ? "PASS" : "FAIL"); // FAIL
  console.log("  unordered:", trajectoryMatch(actual, reference, "unordered") ? "PASS" : "FAIL"); // PASS
  console.log("  → 같은 일을 했는데 strict 는 FAIL 입니다. 순서가 진짜 요구사항일 때만 strict 를 쓰세요.");
}

/* ===================================================================
 * [정답 5] "운 좋게 맞은" 답 잡아내기 — 이 스텝의 핵심
 *
 * 해설: 이게 최종답 평가만 돌리면 안 되는 이유입니다.
 * 에이전트는 get_population 을 부르지 않았습니다. 인구 숫자는 도구에서
 * 온 게 아니라 모델이 **기억에서 지어낸 것**입니다. 이번엔 우연히 맞았지만
 * 데이터가 바뀌면 즉시 틀립니다. 최종답 평가는 이걸 만점으로 통과시킵니다.
 * 궤적 평가만이 "출처 없이 답했다" 를 잡아냅니다.
 * =================================================================== */
console.log("\n===== [정답 5] 환각을 통과시키는 평가 =====");
{
  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "부산" }, id: "c1" }])
    .respond(new AIMessage("부산은 흐림, 26도 이고 인구는 3,300,000명 입니다."));

  const agent = createAgent({ model, tools });
  const result = await agent.invoke({ messages: [new HumanMessage("부산 날씨랑 인구 알려줘")] });
  const answer = result.messages.at(-1)?.text ?? "";

  // (1) 최종답 평가
  const answerOk = answer.includes("흐림, 26도") && answer.includes("3,300,000명");
  console.log(`  최종답 평가: ${answerOk ? "PASS" : "FAIL"} — "${answer}"`);

  // (2) 궤적 평가
  const traj = extractTrajectory(result.messages);
  const trajOk = trajectoryMatch(traj, ["get_weather", "get_population"], "strict");
  console.log(`  궤적 평가  : ${trajOk ? "PASS" : "FAIL"} — 실제 궤적 [${traj.join(", ")}]`);

  console.log(
    "  → 왜 위험한가: 인구 숫자가 get_population 이 아니라 모델의 기억에서 나왔습니다.\n" +
      "    지금은 우연히 맞았지만 출처가 없으므로 데이터가 바뀌면 조용히 틀립니다.\n" +
      "    최종답만 보면 이 환각이 만점으로 통과합니다."
  );
}

/* ===================================================================
 * [정답 6] 도구 호출을 기록하는 미들웨어
 *
 * 해설: wrapToolCall 은 반드시 handler(request) 의 결과를 **반환**해야 합니다.
 * 로그만 찍고 return 을 빼먹으면 도구 결과가 사라져 대화가 조용히 깨집니다.
 * 이 미들웨어는 감사(audit) 로그뿐 아니라 "모델이 부르면 안 될 도구를 불렀는가"
 * 를 잡는 런타임 가드로도 확장할 수 있습니다.
 * =================================================================== */
console.log("\n===== [정답 6] 감사 로그 미들웨어 =====");
{
  const auditLog: Array<{ name: string; args: Record<string, unknown> }> = [];

  const auditMiddleware = createMiddleware({
    name: "AuditMiddleware",
    wrapToolCall: async (request, handler) => {
      auditLog.push({ name: request.toolCall.name, args: request.toolCall.args });
      return handler(request); // ← 이 return 을 빼면 도구 결과가 사라집니다
    },
  });

  const model = fakeModel()
    .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "c1" }])
    .respond(new AIMessage("서울은 맑고 23도입니다."));

  const agent = createAgent({ model, tools, middleware: [auditMiddleware] });
  await agent.invoke({ messages: [new HumanMessage("서울 날씨?")] });

  console.log("  auditLog:", JSON.stringify(auditLog));
  check("도구 1건 기록", auditLog.length === 1 && auditLog[0].name === "get_weather");
}

/* ===================================================================
 * [정답 7] 회귀 게이트
 *
 * 해설: 정확도는 67% (2/3) 이고 ex-3 이 깨집니다.
 * ex-3 은 get_weather 만 부르고 get_population 을 건너뛰었습니다.
 * 최종 답은 그럴듯하지만 궤적이 기대와 다릅니다 — [정답 5] 와 같은 환각입니다.
 *
 * CI 의 요점은 점수를 "보여주는" 게 아니라 기준 미달이면 **빌드를 깨는** 것입니다.
 * 대시보드는 아무도 안 봅니다. 빨간 X 는 봅니다.
 * =================================================================== */
console.log("\n===== [정답 7] 회귀 게이트 =====");

const dataset = [
  { id: "ex-1", input: "서울 날씨?", referenceTools: ["get_weather"] },
  { id: "ex-2", input: "부산 인구?", referenceTools: ["get_population"] },
  { id: "ex-3", input: "제주 날씨랑 인구", referenceTools: ["get_weather", "get_population"] },
];

const scripted: Record<string, () => ReturnType<typeof fakeModel>> = {
  "ex-1": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "서울" }, id: "c1" }])
      .respond(new AIMessage("서울은 맑음, 23도 입니다.")),
  "ex-2": () =>
    fakeModel()
      .respondWithTools([{ name: "get_population", args: { city: "부산" }, id: "c1" }])
      .respond(new AIMessage("부산 인구는 3,300,000명 입니다.")),
  "ex-3": () =>
    fakeModel()
      .respondWithTools([{ name: "get_weather", args: { city: "제주" }, id: "c1" }])
      .respond(new AIMessage("제주는 비, 21도 이고 인구는 670,000명 입니다.")),
};

{
  const THRESHOLD = 0.9;
  const failures: string[] = [];

  for (const ex of dataset) {
    const agent = createAgent({ model: scripted[ex.id](), tools });
    const result = await agent.invoke({ messages: [new HumanMessage(ex.input)] });
    const traj = extractTrajectory(result.messages);
    const ok = trajectoryMatch(traj, ex.referenceTools, "unordered");
    if (!ok) failures.push(`${ex.id}: 기대 [${ex.referenceTools.join(", ")}] / 실제 [${traj.join(", ")}]`);
  }

  const score = (dataset.length - failures.length) / dataset.length;
  console.log(`  궤적 정확도: ${(score * 100).toFixed(0)}% (기준 ${THRESHOLD * 100}%)`);
  for (const f of failures) console.log(`    실패 — ${f}`);

  if (score < THRESHOLD) {
    console.log("  REGRESSION — 실제 CI 라면 여기서 process.exit(1) 로 머지를 막습니다.");
  } else {
    console.log("  통과 — 머지 가능.");
  }
}
// → 정확도: 67% (3개 중 2개 통과)
// → ex-3 이 깨집니다. get_population 을 부르지 않고 인구를 지어냈기 때문입니다.

/* ===================================================================
 * [정답 8] 트레이스 라벨 설계
 *
 * 해설:
 * (a) 넣으면 안 됩니다. LANGSMITH_TRACING=true 는 프롬프트·도구 인자·응답을
 *     통째로 외부(LangSmith 서버)로 보냅니다. metadata 도 예외가 아닙니다.
 *     이메일은 개인정보이므로 해시하거나 내부 ID 로 바꿔서 넣어야 합니다.
 *     본문 값 자체를 가려야 한다면 langsmith/anonymizer 의 createAnonymizer 를 쓰거나,
 *     애초에 piiMiddleware 로 마스킹한 뒤 트레이싱하세요.
 *
 * (b) tags 는 "값이 몇 개 안 되고 필터링할 것" (환경, 버전, 기능 플래그).
 *     metadata 는 "값이 무한하고 나중에 되짚을 것" (userId, sessionId, requestId).
 *     tags 에 userId 를 넣으면 태그가 수만 개로 불어나 필터가 쓸모없어집니다.
 * =================================================================== */
console.log("\n===== [정답 8] 트레이스 라벨 설계 =====");
{
  // 개인정보는 그대로 넣지 않고 내부 ID / 해시로 바꿉니다.
  const internalUserId = "u_8f3a91"; // 이메일 대신 내부 ID
  const model = fakeModel().respond(new AIMessage("네, 확인했습니다."));
  const agent = createAgent({ model, tools });

  await agent.invoke(
    { messages: [new HumanMessage("서울 날씨?")] },
    {
      runName: "weather-query", // 이 플로우의 식별자
      tags: ["production", "agent-v1.2"], // 카디널리티 낮음 → 필터용
      metadata: {
        userId: internalUserId, // 카디널리티 높음 → 되짚기용
        sessionId: "sess_20260717_001",
        requestId: "req_abc123",
      },
    }
  );

  console.log("  runName=weather-query tags=[production, agent-v1.2] metadata={userId, sessionId, requestId}");
  console.log("  (a) 이메일은 metadata 에 넣지 않습니다 — 트레이싱은 데이터를 외부로 내보냅니다.");
  console.log("  (b) tags = 저카디널리티 필터 / metadata = 고카디널리티 추적.");
}