LLM이 보고서를 "그냥 써주길" 기대하면 매번 다른 구조, 다른 어투, 다른 섹션 순서가 나옵니다. M10에서는 LLM은 데이터에서 의미를 추출하는 역할만 맡기고, 실제 문서 구조는 TypeScript 헬퍼가 결정론적으로 조립하는 패턴을 익힙니다.
왜 "결정적 조립"인가
LLM의 창의성은 보고서에서 오히려 노이즈입니다. 같은 회의록을 5번 넣어도 섹션 이름이 바뀌거나 항목 순서가 달라지면 자동화 파이프라인이 깨집니다. 해법은 역할 분리입니다.
| 역할 | 담당 | 특성 |
|---|---|---|
| 의미 추출 | Claude (temperature 0.2) | 확률적이지만 제약으로 수렴 |
| 문서 조립 | TypeScript 헬퍼 함수 | 완전 결정적 |
| 저장 | Vercel Blob | KV처럼 사용 |
temperature를 0.2로 고정하는 것만으로는 부족합니다. JSON Schema를 시스템프롬프트에 명시해 LLM 출력 자체를 구조화해야 합니다.
6단 보고서 구조
강의 전체에서 쓰는 표준 보고서 구조는 다음과 같습니다.
- 주요사항(Highlights) — 오늘의 핵심 성과 3개 이내
- 일정(Schedule) — 완료/예정 항목 목록
- 요약(Summary) — 전체 맥락 1~2문장
- 블로킹(Blockers) — 진행을 막는 이슈
- 액션(Actions) — 담당자·기한 포함 할 일
- Flow — 오늘의 컨디션/집중도 지표 (1~5 점수)
이 순서는 코드에서 하드코딩됩니다. LLM이 순서를 바꿀 여지가 없습니다.
시스템프롬프트 설계
const REPORT_SYSTEM_PROMPT = `
당신은 일간 활동 보고서 추출기입니다.
사용자의 원문(회의록·메모·로그)에서 구조화 데이터를 추출하여
반드시 아래 JSON Schema를 따르는 JSON 객체만 반환하세요.
설명, 마크다운 펜스, 여분의 텍스트는 절대 포함하지 마세요.
Schema:
{
"highlights": string[], // 최대 3개
"schedule": { "done": string[], "upcoming": string[] },
"summary": string, // 1~2문장
"blockers": string[], // 없으면 빈 배열
"actions": { "task": string, "owner": string, "due": string }[],
"flow": number // 1(최저)~5(최고) 정수
}
`;
중요:
반드시 JSON만 반환지시를 해도 LLM이 가끔 마크다운 펜스를 붙입니다. 파싱 전에text.replace(/^```json\n?/, '').replace(/\n?```$/, '')로 방어하세요.
LLM 호출 — callLLM()과 Vercel AI Gateway
강의 전체에서 통일된 callLLM() 래퍼를 사용합니다. 보고서 추출은 claude-sonnet-4-5를 쓰되 Vercel AI Gateway의 provider/model 문자열로 라우팅합니다.
// lib/report-agent.ts
import { callLLM } from '@/lib/llm';
export interface ReportData {
highlights: string[];
schedule: { done: string[]; upcoming: string[] };
summary: string;
blockers: string[];
actions: { task: string; owner: string; due: string }[];
flow: number;
}
export async function extractReportData(rawText: string): Promise<ReportData> {
const raw = await callLLM({
model: 'anthropic/claude-sonnet-4-5', // Vercel AI Gateway 형식
system: REPORT_SYSTEM_PROMPT,
messages: [{ role: 'user', content: rawText }],
temperature: 0.2,
max_tokens: 1024,
});
const cleaned = raw.trim()
.replace(/^```json\n?/, '')
.replace(/\n?```$/, '');
return JSON.parse(cleaned) as ReportData;
}
결정적 Markdown 조립
추출된 데이터를 받아 Markdown을 조립하는 함수는 순수 함수(pure function) 로 작성합니다. LLM을 호출하지 않으며, 같은 입력에는 항상 같은 출력이 나옵니다.
// lib/report-builder.ts
import type { ReportData } from './report-agent';
export function buildMarkdownReport(data: ReportData, date: string): string {
const flowBar = '■'.repeat(data.flow) + '□'.repeat(5 - data.flow);
const sections: string[] = [
`## 📋 일간 보고서 — ${date}`,
`### 🌟 주요사항\n${data.highlights.map(h => `- ${h}`).join('\n')}`,
`### 📅 일정\n**완료**\n${data.schedule.done.map(d => `- [x] ${d}`).join('\n')}\n\n**예정**\n${data.schedule.upcoming.map(u => `- [ ] ${u}`).join('\n')}`,
`### 📝 요약\n${data.summary}`,
`### 🚧 블로킹\n${data.blockers.length ? data.blockers.map(b => `- ⚠️ ${b}`).join('\n') : '- 없음'}`,
`### ✅ 액션 아이템\n| 할 일 | 담당 | 기한 |\n|---|---|---|\n${data.actions.map(a => `| ${a.task} | ${a.owner} | ${a.due} |`).join('\n')}`,
`### 💡 Flow\n\`${flowBar}\` ${data.flow}/5`,
];
return sections.join('\n\n');
}
이 헬퍼가 6단 구조의 순서, 이모지, 표 형식을 모두 소유합니다. LLM 출력의 변동성은 extractReportData의 JSON 경계에서 차단됩니다.
일관성 측정 — 5회 반복 테스트
temperature 0.2가 실제로 일관성을 보장하는지 확인하려면 같은 입력으로 5회 호출해 필드별 분산을 측정합니다.
// scripts/consistency-check.ts
async function measureConsistency(rawText: string, runs = 5) {
const results = await Promise.all(
Array.from({ length: runs }, () => extractReportData(rawText))
);
const flowValues = results.map(r => r.flow);
const highlightCounts = results.map(r => r.highlights.length);
console.table({
'flow 분산': Math.max(...flowValues) - Math.min(...flowValues),
'highlights 개수 분산': Math.max(...highlightCounts) - Math.min(...highlightCounts),
'blockers 없음 비율': results.filter(r => r.blockers.length === 0).length / runs,
});
}
팁: temperature 0.2에서도
flow점수가 ±1 흔들릴 수 있습니다. 수용 범위를 정해두고 테스트를 자동화하면 모델 버전 업그레이드 시 회귀를 잡을 수 있습니다.
실습 (Lab)
목표: 하루치 활동 메모를 붙여넣으면 표준 6단 보고서 Markdown을 자동 생성하고 Vercel Blob에 저장하는 API 라우트를 만든다.
단계별 안내
Step 1 — 프로젝트 준비
app/
api/
report/
route.ts ← POST: 원문 수신 → 추출 → 조립 → Blob 저장
lib/
report-agent.ts
report-builder.ts
llm.ts ← 기존 callLLM() 재사용
Step 2 — 환경변수 설정
ANTHROPIC_API_KEY(또는 Vercel AI Gateway 토큰)BLOB_READ_WRITE_TOKEN(Vercel Blob)
Step 3 — API 라우트 구현
app/api/report/route.ts에서 { rawText, date, userId } 를 받아 extractReportData → buildMarkdownReport → put(blob) 순서로 연결합니다. Blob 키는 reports/${userId}/${date}.md 패턴으로 고정해 날짜별 덮어쓰기가 되도록 합니다.
Step 4 — 5회 일관성 테스트
scripts/consistency-check.ts를 실행해 flow 분산이 1 이내, highlights 개수 분산이 0인지 확인합니다. 실패하면 시스템프롬프트의 제약 문구를 강화하세요.
Step 5 — (선택) 간단한 UI
app/report/page.tsx에 textarea + 버튼을 두고 API를 호출한 뒤 결과 Markdown을 react-markdown으로 렌더링합니다.
핵심 정리
- LLM은 추출만, 조립은 코드가 — 역할을 분리해야 보고서 구조가 일관된다.
- temperature 0.2 + JSON Schema 두 가지가 함께 있어야 LLM 출력을 예측 가능하게 만든다.
- 결정적 Markdown 헬퍼는 순수 함수로 작성해 단위 테스트가 가능하게 한다.
- Vercel AI Gateway
provider/model문자열로 Claude와 OpenAI를 동일 인터페이스로 교체할 수 있다. - 5회 반복 일관성 테스트를 CI에 포함시키면 모델 업그레이드 시 회귀를 자동 감지할 수 있다.
- Vercel Blob을 KV처럼 사용할 때 키 패턴을 규칙적으로 설계해야 조회·덮어쓰기가 예측 가능하다.
학습자별 데이터 분석 보고서 자동 생성기 (예: 일간 활동 보고서)