← Lecture Note
M10실습75분 · ~18 슬라이드

보고서 생성 에이전트

6단 표준 구조 + 결정적 조립

학습 목표

LLM 은 추출만, Markdown 조립은 결정적 헬퍼가 — 일관성 있는 보고서 생성기를 만든다.

LLM이 보고서를 "그냥 써주길" 기대하면 매번 다른 구조, 다른 어투, 다른 섹션 순서가 나옵니다. M10에서는 LLM은 데이터에서 의미를 추출하는 역할만 맡기고, 실제 문서 구조는 TypeScript 헬퍼가 결정론적으로 조립하는 패턴을 익힙니다.


왜 "결정적 조립"인가

LLM의 창의성은 보고서에서 오히려 노이즈입니다. 같은 회의록을 5번 넣어도 섹션 이름이 바뀌거나 항목 순서가 달라지면 자동화 파이프라인이 깨집니다. 해법은 역할 분리입니다.

역할담당특성
의미 추출Claude (temperature 0.2)확률적이지만 제약으로 수렴
문서 조립TypeScript 헬퍼 함수완전 결정적
저장Vercel BlobKV처럼 사용

temperature를 0.2로 고정하는 것만으로는 부족합니다. JSON Schema를 시스템프롬프트에 명시해 LLM 출력 자체를 구조화해야 합니다.


6단 보고서 구조

강의 전체에서 쓰는 표준 보고서 구조는 다음과 같습니다.

  1. 주요사항(Highlights) — 오늘의 핵심 성과 3개 이내
  2. 일정(Schedule) — 완료/예정 항목 목록
  3. 요약(Summary) — 전체 맥락 1~2문장
  4. 블로킹(Blockers) — 진행을 막는 이슈
  5. 액션(Actions) — 담당자·기한 포함 할 일
  6. 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 } 를 받아 extractReportDatabuildMarkdownReportput(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처럼 사용할 때 키 패턴을 규칙적으로 설계해야 조회·덮어쓰기가 예측 가능하다.
LAB · 실습

학습자별 데이터 분석 보고서 자동 생성기 (예: 일간 활동 보고서)