학습 자료

출력 검증 · 파싱 — 깨질 때 대비


Article

10강에서 assistant: "[" prefill 로 코드펜스 문제를 잡았다. 그런데 그 강의 끝에서 짚었듯, JSON.parse 성공은 안전을 보장하지 않는다. 오늘은 미뤄둔 두 가지 위험, 중간에 잘리는 것모양이 틀린 것을 실제로 겪고 검증·재시도로 막는다. 여기까지 끝내면 Course A 의 결과물, 똑똑한 CLI 어시스턴트가 완성된다.

max_tokens 가 부족하면 JSON 이 잘린다

my-ai-assistant 폴더에 truncated.ts 를 만든다. 항목이 많은 긴 메모를 주되, max_tokens일부러 40으로 낮게 잡는다.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const SYSTEM_PROMPT = `<role>
너는 메모 정리 비서다. 사용자가 두서없이 적은 메모에서 할 일을 뽑아 목록으로 만든다.
</role>

<task>
입력 메모에서 "해야 할 일"을 모두 찾아낸다. 마감이 있으면 그대로 살리고, 없으면 "미정"으로 표시한다.
</task>

<output_format>
JSON 배열로만 답한다. 각 원소는 { "deadline": string, "task": string } 형태다.
마감이 급한 순서로 정렬한다. 마감이 없는 항목은 맨 뒤에 둔다.
코드펜스나 설명 문장을 절대 덧붙이지 않는다.
</output_format>`;

const LONG_NOTE =
  "내일까지 3분기 마케팅 예산안 초안을 작성해서 팀장님한테 검토받아야 하고, 이번 주 금요일에는 신규 입사자 온보딩 자료도 준비해야 하고, 다음 주 월요일 오전에는 협력사 미팅 자료를 완성해서 공유해야 하고, 다다음 주까지 연간 예산 보고서 1차 검토도 끝내야 하고, 이번 달 안에 신규 채용 공고 초안도 손봐야 함. 시간 날 때 회사 근처 도서관에 반납할 책도 챙기기.";

async function organizeTruncated() {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 40, // 일부러 낮게 잡는다 — 항목이 많은 메모라 다 못 쓰고 잘린다
    system: SYSTEM_PROMPT,
    messages: [
      { role: "user", content: LONG_NOTE },
      { role: "assistant", content: "[" },
    ],
  });

  const fullText = "[" + (response.content[0].type === "text" ? response.content[0].text : "");
  console.log(`--- stop_reason ---\n${response.stop_reason}`);
  console.log(`--- 원본 응답(이어붙인 결과) ---\n${fullText}`);

  try {
    const parsed = JSON.parse(fullText);
    console.log("--- JSON.parse 성공 ---");
    console.log(parsed);
  } catch (err) {
    console.log("--- JSON.parse 실패 ---");
    console.log((err as Error).message);
  }
}

await organizeTruncated();

실행한다.

npx tsx --env-file=.env truncated.ts
터미널 — npx tsx --env-file=.env truncated.ts
  • --- stop_reason ---
  • max_tokens
  • --- 원본 응답(이어붙인 결과) ---
  • [
  • { "deadline": "내일", "task": "3분기 마케팅 예산안 초안 작성 후 팀장님
  • --- JSON.parse 실패 ---
  • Unterminated string in JSON at position 57 (line 2 column 56)

response.stop_reason"max_tokens" 다. 모델이 할 말을 다 마친 게 아니라 한도에 걸려 강제로 멈췄다는 뜻이다. 문자열이 따옴표도 못 닫고 끊겼으니 JSON.parse 가 실패하는 게 당연하다.

문법은 맞는데 모양이 틀릴 수도 있다

JSON.parse 를 통과해도 안심할 수 없다. parsed[0].deadline 이 없거나 숫자로 왔는데 우리 코드가 문자열 메서드를 부르면, 파싱 단계가 아니라 한참 뒤 실제로 그 값을 쓰는 순간 에러가 난다. 그래서 파싱과는 별개로, "모양이 우리가 기대한 스키마와 맞는가"를 확인하는 단계가 필요하다.

응답 하나가 안전해지기까지 세 단계
  1. 1. API 응답 수신stop_reason 을 먼저 본다
  2. 2. JSON.parse문법이 맞는지만 본다. 실패하면 잘렸을 가능성이 크다
  3. 3. 스키마 검증배열인지, deadline·task 가 문자열인지 직접 확인한다
세 단계 중 하나라도 실패하면, 그냥 죽지 않고 재시도로 넘어간다.

검증 함수와 재시도 루프를 만든다

index.ts 를 만든다. validate 함수로 모양을 확인하고, 실패하면 max_tokens 를 늘려 재시도한다.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const SYSTEM_PROMPT = `<role>
너는 메모 정리 비서다. 사용자가 두서없이 적은 메모에서 할 일을 뽑아 목록으로 만든다.
</role>

<task>
입력 메모에서 "해야 할 일"을 모두 찾아낸다. 마감이 있으면 그대로 살리고, 없으면 "미정"으로 표시한다.
</task>

<output_format>
JSON 배열로만 답한다. 각 원소는 { "deadline": string, "task": string } 형태다.
마감이 급한 순서로 정렬한다. 마감이 없는 항목은 맨 뒤에 둔다.
코드펜스나 설명 문장을 절대 덧붙이지 않는다.
</output_format>`;

type TodoItem = { deadline: string; task: string };

// JSON.parse 가 성공해도 "모양"이 우리가 기대한 것과 다를 수 있다.
// 배열인지, 각 원소가 deadline·task 를 문자열로 갖는지 직접 확인한다.
function validate(data: unknown): data is TodoItem[] {
  if (!Array.isArray(data)) return false;
  return data.every(
    (item) =>
      typeof item === "object" &&
      item !== null &&
      typeof (item as Record<string, unknown>).deadline === "string" &&
      typeof (item as Record<string, unknown>).task === "string",
  );
}

async function organize(note: string, maxTokens = 300, attempt = 1): Promise<TodoItem[]> {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: maxTokens,
    system: SYSTEM_PROMPT,
    messages: [
      { role: "user", content: note },
      { role: "assistant", content: "[" },
    ],
  });

  const fullText = "[" + (response.content[0].type === "text" ? response.content[0].text : "");
  console.log(`\n--- 시도 ${attempt} (max_tokens=${maxTokens}, stop_reason=${response.stop_reason}) ---`);
  console.log(fullText);

  let parsed: unknown;
  try {
    parsed = JSON.parse(fullText);
  } catch (err) {
    console.log(`파싱 실패: ${(err as Error).message}`);
    if (attempt >= 3) throw new Error("재시도 3회 모두 실패했다.");
    // 잘려서 깨졌을 가능성이 크니, 다음 시도에서는 토큰 여유를 넉넉히 준다.
    return organize(note, maxTokens * 4, attempt + 1);
  }

  if (!validate(parsed)) {
    console.log("검증 실패: 배열이 아니거나 deadline·task 필드가 비어 있다.");
    if (attempt >= 3) throw new Error("재시도 3회 모두 검증에 실패했다.");
    return organize(note, maxTokens, attempt + 1);
  }

  console.log("파싱 + 검증 성공.");
  console.log(
    `usage: input=${response.usage.input_tokens} output=${response.usage.output_tokens}`,
  );
  return parsed;
}

const LONG_NOTE =
  "내일까지 3분기 마케팅 예산안 초안을 작성해서 팀장님한테 검토받아야 하고, 이번 주 금요일에는 신규 입사자 온보딩 자료도 준비해야 하고, 다음 주 월요일 오전에는 협력사 미팅 자료를 완성해서 공유해야 하고, 다다음 주까지 연간 예산 보고서 1차 검토도 끝내야 하고, 이번 달 안에 신규 채용 공고 초안도 손봐야 함. 시간 날 때 회사 근처 도서관에 반납할 책도 챙기기.";

// 일부러 40으로 시작한다 — 항목이 많아 첫 시도는 잘릴 것이다. 재시도 루프가 알아서 키운다.
const result = await organize(LONG_NOTE, 40);
console.log("\n=== 최종 결과 ===");
console.table(result);

실행한다.

npx tsx --env-file=.env index.ts
터미널 — npx tsx --env-file=.env index.ts
  • --- 시도 1 (max_tokens=40, stop_reason=max_tokens) ---
  • [
  • { "deadline": "내일", "task": "3분기 마케팅 예산안 초안 작성 및 팀장님
  • 파싱 실패: Unterminated string in JSON at position 57 (line 2 column 56)
  • --- 시도 2 (max_tokens=160, stop_reason=max_tokens) ---
  • [
  • {
  • "deadline": "내일",
  • "task": "3분기 마케팅 예산안 초안 작성 및 팀장님 검토 받기"
  • },
  • {
  • "deadline": "이번 주 금요일",
  • "task": "신규 입사자 온보딩 자료 준비"
  • },
  • {
  • "deadline": "다음 주 월요일 오전",
  • "task": "협력사 미팅 자료 완성 및 공유"
  • },
  • {
  • "deadline": "다다음
  • 파싱 실패: Unterminated string in JSON at position 242 (line 15 column 22)
  • --- 시도 3 (max_tokens=640, stop_reason=end_turn) ---
  • [
  • { "deadline": "내일", "task": "3분기 마케팅 예산안 초안 작성 후 팀장님 검토" },
  • { "deadline": "이번 주 금요일", "task": "신규 입사자 온보딩 자료 준비" },
  • { "deadline": "다음 주 월요일 오전", "task": "협력사 미팅 자료 완성 및 공유" },
  • { "deadline": "다다음 주", "task": "연간 예산 보고서 1차 검토" },
  • { "deadline": "이번 달 안", "task": "신규 채용 공고 초안 검토" },
  • { "deadline": "미정", "task": "회사 근처 도서관에 책 반납" }
  • ]
  • 파싱 + 검증 성공.
  • usage: input=470 output=266
  • === 최종 결과 ===
  • ┌─────────┬───────────────────────┬──────────────────────────────────┐
  • │ (index) │ deadline │ task │
  • ├─────────┼───────────────────────┼──────────────────────────────────┤
  • │ 0 │ '내일' │ '3분기 마케팅 예산안 초안 작성 후 팀장님 검토' │
  • │ 1 │ '이번 주 금요일' │ '신규 입사자 온보딩 자료 준비' │
  • │ 2 │ '다음 주 월요일 오전' │ '협력사 미팅 자료 완성 및 공유' │
  • │ 3 │ '다다음 주' │ '연간 예산 보고서 1차 검토' │
  • │ 4 │ '이번 달 안' │ '신규 채용 공고 초안 검토' │
  • │ 5 │ '미정' │ '회사 근처 도서관에 책 반납' │
  • └─────────┴───────────────────────┴──────────────────────────────────┘

실제로 세 번 만에 성공했다. 40 토큰은 첫 항목의 task 도 못 끝내고 끊겼고, 160 토큰은 네 번째 항목 중간에 끊겼다. 640 토큰에 가서야 stop_reasonend_turn 으로 바뀌며 여섯 항목이 전부 무사히 나왔다.

시도별 max_tokens 와 결과
시도 1 (실패)40토큰
시도 2 (실패)160토큰
시도 3 (성공)640토큰
실제 usage 는 input=470 · output=266 토큰. 재시도 두 번은 같은 note 를 다시 보내므로 그만큼 입력 토큰도 반복 소모된다.

정리하면

JSON.parse 성공은 안전의 절반이다. stop_reason 으로 잘렸는지 확인하고, 스키마 검증으로 모양을 확인하고, 둘 중 하나라도 실패하면 재시도한다. 이 세 단계가 갖춰져야 진짜로 믿을 수 있는 출력이 된다.

다시 짚어보기
  1. 01

    max_tokens 를 일부러 낮춰 잘리는 것을 확인했다

    stop_reason 이 max_tokens 로 나왔고, 잘린 문자열 때문에 JSON.parse 가 실패했다.

    npx tsx --env-file=.env truncated.ts
  2. 02

    validate 함수로 스키마를 직접 확인했다

    JSON.parse 성공과 별개로, 배열인지 deadline·task 가 문자열인지 검사한다.

  3. 03

    재시도 루프로 실제 3번 만에 성공시켰다

    40 → 160 → 640 토큰으로 키워가며 재시도했고, usage 로 실제 비용도 확인했다.

    npx tsx --env-file=.env index.ts

Part 3 이 끝났다. 8강에서 지시를 구조로 쓰고, 9강에서 예시로 감을 잡히고, 10~11강에서 그 출력을 프로그램이 믿고 쓸 수 있는 JSON 으로 다졌다. 1강의 빈 폴더에서 시작해 여기까지 왔다. 키를 안전하게 넣는 법, 응답을 뜯어보는 법, 멀티턴과 스트리밍, 토큰과 비용, 구조화된 지시와 예시, 그리고 오늘의 검증까지 거쳤다. 이 열한 강을 거치며 만든 게 바로 Course A 의 결과물, 프롬프트 잘 쓰는 CLI 어시스턴트다.

Course A 완결이다. 여기까지 따라왔다면 이미 실전에서 쓸 수 있는 LLM 호출 코드를 손에 쥔 것이다. 다음은 Course B, 이 CLI 어시스턴트에 도구(tool use)를 쥐어주고, 내 문서를 검색하게 하고(RAG), 에이전트로 키워 Next.js 로 배포하는 여정이다. 12강 "도구 사용 — 모델이 함수를 부른다"에서 다시 만난다.

출력 검증 · 파싱 — 깨질 때 대비 — 디코드랩(DCODELAB)