출력 검증 · 파싱 — 깨질 때 대비
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
- --- 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. API 응답 수신stop_reason 을 먼저 본다
- 2. JSON.parse문법이 맞는지만 본다. 실패하면 잘렸을 가능성이 크다
- 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
- --- 시도 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_reason 이 end_turn 으로 바뀌며 여섯 항목이 전부 무사히 나왔다.
정리하면
JSON.parse 성공은 안전의 절반이다. stop_reason 으로 잘렸는지 확인하고, 스키마 검증으로 모양을 확인하고, 둘 중 하나라도 실패하면 재시도한다. 이 세 단계가 갖춰져야 진짜로 믿을 수 있는 출력이 된다.
- 01
max_tokens 를 일부러 낮춰 잘리는 것을 확인했다
stop_reason 이 max_tokens 로 나왔고, 잘린 문자열 때문에 JSON.parse 가 실패했다.
npx tsx --env-file=.env truncated.ts - 02
validate 함수로 스키마를 직접 확인했다
JSON.parse 성공과 별개로, 배열인지 deadline·task 가 문자열인지 검사한다.
- 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강 "도구 사용 — 모델이 함수를 부른다"에서 다시 만난다.