diff --git a/Last3Mon/75/.gitignore b/Last3Mon/75/.gitignore new file mode 100644 index 0000000..be55978 --- /dev/null +++ b/Last3Mon/75/.gitignore @@ -0,0 +1,3 @@ +runs/ +tools/.cache/ +my-work/*.json diff --git a/Last3Mon/75/AGENTS.md b/Last3Mon/75/AGENTS.md new file mode 100644 index 0000000..f7f21fb --- /dev/null +++ b/Last3Mon/75/AGENTS.md @@ -0,0 +1,176 @@ +# AGENTS.md · n8n 사용량·비용 관리 작업 규칙 (75일차 실습 폴더) + +이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 74일차 규칙과 같고, 3절이 오늘 만드는 **비용이 보이는 헬프데스크 · 비용 보고서의 계약**, 5절에 **토큰이 보이는 LLM 호출(HTTP) · 단가 · 사용량 기록 · 예산 · 한도 · 질문 길이 상한** 노드가 더해졌습니다. + +## 1. 작업 순서 (항상 이 순서) + +1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 비용 요청이면 **LLM 호출이 몇 번, 어느 모델로, 무엇을 보내는지**도 함께 보여 준다. +2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다). +3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다. +4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄. + ```bash + uv run tools/flow.py push my-work/<이름>.json + uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json -v + ``` +5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨). **테스트는 실제 OpenAI 호출이라 돈이 든다.** 같은 테스트를 여러 번 반복하지 않는다. +6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다. +7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다. + +## 2. 환경 (바꾸지 말 것) + +| 항목 | 값 | +| --- | --- | +| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 | +| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) | +| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/` | +| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`. 모델 이름: `gpt-4.1-mini`, `gpt-5.4-nano` (같은 Credential). `gpt-4.1-nano`는 OpenAI가 더 이상 받지 않음 | +| 다른 Credential | 쓰지 않음 (Ollama 등 없음) | +| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**75일차 판** `Last3Mon/75/data-api`: 74일차 + 단가 · 사용량 기록 · 예산 · 한도) | +| 비교 기준 | `reference/baseline-cost.json` (웹훅 `base-cost`), `reference/lab02-example3-nano-answer.json` (웹훅 `nano-answer`). 고치지 않는다 | + +### 데이터 API (75일차 판) + +| 요청 | 응답 | +| --- | --- | +| `GET /prices` | `{"currency": "USD", "unit": "per 1M tokens", "models": {"gpt-4.1-mini": {"input": 0.4, "output": 1.6}, "gpt-5.4-nano": {"input": 0.2, "output": 1.25}}}` | +| `POST /usage` | 본문 `{"items": [{"request_id", "user", "step", "model", "prompt_tokens", "completion_tokens", "cost_usd", "route"}]}` (LLM 호출 하나에 항목 하나) → `{"stored": <항목 수>, "total"}` · 고장 스위치 `usage`가 `error`면 **500**, `slow`면 8초 뒤 **503**(저장하지 않음) | +| `GET /usage/summary` | `{"requests"(request_id 수), "calls", "prompt_tokens", "completion_tokens", "cost_usd", "by_model", "by_user", "by_route"}` (`by_*`마다 `calls` · `prompt_tokens` · `completion_tokens` · `cost_usd`) | +| `GET /usage/user/<이름>?window=60` | `{"user", "window", "count"}` — 그 사람의 최근 N초 요청 수 (이동 창) | +| `GET /budget` | `{"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"}` (`used_usd`는 기록된 `cost_usd`의 합) | +| `POST /budget` · `/budget/reset` · `/usage-clear` | 예산 · 한도 바꾸기 · 처음으로(`data/budget.json`: $1.0 · 20번/분 · 500자) · 기록 비우기 (**사람이** `flow.py budget` · `usage --clear`로, 테스트가 `before` · `after`로) | +| `POST /admin/fault` | `{"usage": "ok" \| "slow" \| "error"}` (`faq` · `tickets` · `prompts`도) · `flow.py fault usage error` | +| `GET /faq?q=` · `GET /tickets/?requester=` | 72~74일차와 같음 (접수 건에는 `assignee` · `contact`도 있음) | +| `/prompts` · `/releases` · `/gates` · `/audit` · `/cache` | 72~74일차와 같음 (오늘 쓰지 않음) | + +## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로) + +| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 | +| --- | --- | --- | --- | +| 헬프데스크 | `my-work/cost.json` · `POST /webhook/my-cost` (처음은 `reference/baseline-cost.json`을 복사) | `question`(필수), `user` | `{"route", "answer", "sources", "llm_calls", "usage": {"prompt_tokens", "completion_tokens", "by_step": {"<단계>": {"model", "prompt_tokens", "completion_tokens", "cost_usd"}}}, "cost_usd", "recorded", "trimmed", "question_chars"}` | +| 비용 보고서 | `my-work/cost-report.json` · `POST /webhook/my-cost-report` | `requests_per_day`(없으면 지금 요청 수), `workdays`(기본 22), `what_if_model`(있으면) | `{"requests", "calls", "cost_usd", "avg_usd", "by_model": {"<모델>": cost}, "top_user", "projected_month_usd", "daily_budget_usd", "remaining_usd", "over_budget", "what_if": {"model", "cost_usd", "saving_usd", "projected_month_usd"}}` | + +- `cost_usd` = Σ(입력 토큰 × input 단가 + 출력 토큰 × output 단가) / 1,000,000, 단계마다 **그 단계 모델의 단가**로. 단가는 **`/prices`에서** 읽는다 (워크플로우에 숫자를 쓰지 않는다). 소수 8자리에서 반올림. +- `route`: `자동 답변` · `한도 초과`(하루 예산을 넘음 — LLM 없이 FAQ · 접수 원문, `llm_calls` 0 · `cost_usd` 0) · `잠시 후`(사용자별 1분 한도를 넘음 — HTTP 429, `retry_after: 60`). +- `recorded`(Lab 01 과제 2): 기록 API가 `stored` ≥ 1을 돌려주면 true. 기록이 실패 · 3초 넘게 걸려도 **답은 그대로 보낸다.** +- `trimmed` · `question_chars`(Lab 02 과제 2): 질문이 `/budget`의 `max_question_chars`보다 길면 앞 `floor(상한 × 0.4)`자 + `' … '` + 뒤 나머지 글자만 LLM · FAQ 검색에 쓴다. `question_chars`는 원래 글자 수. +- 순서: 사용자별 한도(가장 앞) → 단가 · 질문 줄이기 → FAQ · 접수 조회 → 하루 예산 → 규칙 분류 → (모를 때만) LLM 분류 → 답 LLM → 기록. +- 비용 보고서의 금액은 소수 6자리에서 반올림 (반올림은 마지막에). `projected_month_usd = (cost_usd ÷ requests) × requests_per_day × workdays`. `top_user`는 비용이 가장 큰 사람. 보고서는 **LLM을 쓰지 않는다.** +- what-if: 기록된 모든 호출의 입력 · 출력 토큰 합을 `what_if_model`의 단가로 다시 계산. `saving_usd = cost_usd − what_if.cost_usd`. 단가표에 없는 모델이면 HTTP 400 `{"error": "unknown model", "model", "known": [...]}`. `what_if_model`이 없으면 `what_if`도 없다. + +## 4. 워크플로우 파일 규칙 + +```json +{ + "name": "75 IT helpdesk (my)", + "nodes": [ ... ], + "connections": { ... }, + "settings": { "executionOrder": "v1" } +} +``` + +- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. 노드마다 `type`, `typeVersion`, `position`, `parameters`. +- **API 키를 JSON에 쓰지 않는다.** OpenAI는 Credential id로만 부른다 (아래 5절의 `predefinedCredentialType`). +- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로. +- 요청 본문은 `$json.body.question`처럼 `body` 아래. 다른 노드 뒤에서는 `$('Question webhook').first().json.body`. + +## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인) + +### 토큰이 보이는 LLM 호출 — HTTP Request로 OpenAI를 직접 +AI Agent 노드는 답 글자만 돌려주고 **토큰 수를 주지 않는다.** 오늘은 HTTP Request로 OpenAI를 불러 응답의 `usage`를 쓴다. +```json +{"name": "Answer", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 300], + "parameters": {"method": "POST", "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", "nodeCredentialType": "openAiApi", + "sendBody": true, "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are ...\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question }] }) }}", + "options": {"timeout": 60000}}, + "credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}} +``` +- 결과: `$json.choices[0].message.content`(답 글자), `$json.usage.prompt_tokens`, `$json.usage.completion_tokens`. +- `max_completion_tokens`: 답(출력 토큰)의 상한. 너무 작으면 답이 잘린다. **`gpt-5.4-nano`는 `max_tokens`를 받지 않는다** (HTTP 400 `Unsupported parameter`). 두 모델 모두 `max_completion_tokens`로 쓴다 (`check`가 `max_tokens`를 찾아 알려 줌). +- 노드 이름을 단계 이름과 맞추면(`Classify` · `Answer` · `Review`) 마지막 Code에서 `$('Answer').first().json.usage`로 모으기 쉽다. + +### 단가 · 예산 · 한도 (HTTP) +```json +{"name": "Get prices", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300], "parameters": {"url": "http://data-api:8800/prices", "options": {}}} +``` +```json +{"name": "Get budget", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300], "parameters": {"url": "http://data-api:8800/budget", "options": {}}} +``` +```json +{"name": "Recent requests", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300], + "parameters": {"url": "=http://data-api:8800/usage/user/{{ encodeURIComponent($('Question webhook').first().json.body.user ?? '') }}?window=60", "options": {}}} +``` + +### 단계마다 비용 (Code) +```javascript +const prices = $('Get prices').first().json.models; +const p = prices[s.model] ?? { input: 0, output: 0 }; +row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8; +``` + +### 사용량 기록 (HTTP POST, 항목 여러 개) +```json +{"name": "Record usage", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2000, 300], + "parameters": {"method": "POST", "url": "http://data-api:8800/usage", "sendBody": true, "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": {"response": {"response": {"neverError": true}}}}} +``` +- `$execution.id`: 이번 실행의 번호 (한 요청의 여러 LLM 호출을 묶는 `request_id`). +- `neverError`면 기록 API의 4xx · 5xx 응답도 성공 쪽으로 온다. 시간 초과 · 연결 실패는 노드 설정 `onError`로 다룬다 (노드 바로 아래, `parameters` 밖). +- 기록 노드 다음에는 결과를 다시 꺼내 응답한다: `return [{ json: $('Build result').first().json }];` + +### 429 응답 +```json +{"name": "Too many", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [1100, 820], + "parameters": {"respondWith": "json", "responseBody": "={{ JSON.stringify({ route: '잠시 후', answer: '...', retry_after: 60, llm_calls: 0, cost_usd: 0 }) }}", "options": {"responseCode": 429}}} +``` + +### 그 밖 (72~74일차와 같음) +- Webhook · If · Code · Search FAQ(HTTP `/faq`, 쿼리 `q`) · Get ticket(HTTP `/tickets`, `neverError`) · Respond to Webhook. +- 지나가지 않았을 수 있는 노드는 `$('노드').isExecuted`로 확인한 뒤 읽는다 (규칙으로 분류를 건너뛸 때): `const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };` +- 오류 응답: `"responseBody": "={{ $json }}"`, `"options": {"responseCode": 400}`. Code 노드는 HTTP 상태를 정하지 못하므로 If로 갈라 Respond로. + +## 6. 연결 (`connections`) + +```json +{ + "Within budget?": {"main": [[{"node": "Rule classify", "type": "main", "index": 0}], [{"node": "Over budget", "type": "main", "index": 0}]]}, + "Answer": {"main": [[{"node": "Build result", "type": "main", "index": 0}]]} +} +``` +- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`. +- HTTP로 부르는 LLM은 보통 노드라 `main`으로 잇는다 (`ai_languageModel` 아님). + +## 7. 표현식 주의 + +- 표현식은 `=`로 시작한다. `JSON.stringify({ ... })` 안의 글자에 `'`가 있으면 깨지기 쉽다 — 지시문은 `"`로 감싸거나 앞 노드(Set)에 두고 읽는다. `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다. +- 숫자 계산은 Code에서. 반올림: `Math.round(x * 1e8) / 1e8` (보고서는 `1e6`). + +## 8. 테스트 파일 (`cases/*.json`) + +```json +{ + "webhook": "my-cost", + "before": [{"url": "http://localhost:8800/usage-clear", "body": {}}], + "cases": [ + {"name": "VPN · 비용 상한", "request": "requests/faq-vpn.json", + "expect": {"equals": {"llm_calls": 1}, "range": {"cost_usd": [0.000001, 0.0002]}, "contains": {"answer": ["인증서"]}}}, + {"name": "요약 확인", "url": "http://localhost:8800/usage/summary", "body": {}, "expect": {"equals": {"requests": 1}}} + ] +} +``` +- `range`: 숫자가 `[최소, 최대]` 안. `url`: 웹훅 대신 그 주소에 POST (데이터 API 확인용). +- `equals` · `contains` · `not_contains` · `status` · `max_seconds`, `before` · `after`는 72~74일차와 같다. 점이 들어간 키(`gpt-4.1-mini`)는 경로로 못 읽으니 `contains`로 JSON 글자를 찾는다. +- 줄 끝의 `· 자동 답변 · 1 call · $0.000156`은 결과의 `route` · `llm_calls` · `cost_usd`다. +- `uv run tools/flow.py test <파일> -v`: 케이스마다 요청 · 응답 JSON. `--webhook nano-answer`: 같은 파일을 다른 웹훅에. **모든 명령이 `-v`를 받는다** (74일차와 같음). +- `uv run tools/flow.py usage [--clear]` · `budget [--daily 0.001] [--per-min 2] [--chars 2000] [--reset]` · `fault usage error|slow|ok` (사람이). +- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다. + +## 9. 하지 말 것 + +- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). 단가(`prices.json`) · 예산(`budget.json`)은 사람이 정한다. +- 워크플로우 안에서 `/budget`, `/usage-clear`, `/admin/fault`에 POST 하지 않는다 (예산 · 기록 비우기 · 고장 스위치는 사람이). +- 비용을 줄이려고 **답의 내용 검사(테스트의 `contains`)를 없애지 않는다.** 단가를 워크플로우에 숫자로 쓰지 않는다. +- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다. +- `.env`나 API 키를 읽어서 출력하지 않는다. diff --git a/Last3Mon/75/CLAUDE.md b/Last3Mon/75/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/Last3Mon/75/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md b/Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md new file mode 100644 index 0000000..bc94dc0 --- /dev/null +++ b/Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md @@ -0,0 +1,566 @@ +# Lab 01 · 자원과 토큰을 재고, 비용을 계산하고, 기록하기 + +- 관련 강의: [1단계 · 사용량 재기](../../Lecture/75/01-STAGE1-MEASURE-USAGE.md) +- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 12분 · 예제 3 8분 · 과제 1 10분 · 과제 2 10분 +- 준비: [실습 준비](../../Lecture/75/00-DAY75-PREP.md)의 `doctor` → `ready`, `base-cost` 게시 +- 목표: 질문 하나가 쓰는 **자원 · 토큰 · 돈**을 단계별로 재고, 단가표로 비용을 계산해 결과에 남기고, 질문 종류마다 비용을 비교해 한 달을 어림하고, 요청마다 기록해 합계를 본다. 기록이 멈춰도 답은 나가게 한다. + +```mermaid +flowchart LR + E1["예제 1
자원 · 단계별 토큰"] --> E2["예제 2
cost_usd 계산"] --> E3["예제 3
질문 종류별 비용
한 달 어림"] --> T1["과제 1
요청마다 기록"] --> T2["과제 2
기록이 멈춰도
답은 나감"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 오늘은 호출마다 실제로 돈이 듭니다 (질문 하나에 약 $0.001 이하). 같은 테스트를 수십 번 반복하지 않습니다. + +## 시작하기 · 터미널 두 개 + +| 터미널 | 하는 일 | +| --- | --- | +| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/75` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 | +| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/75` 에서 `call` · `push` · `test` · `usage`를 **내가** 실행한다 | + +`cp templates/COST-PLAN-TEMPLATE.md my-work/COST.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다. + +## 예제 1 · 자원과 단계별 토큰 (10분, 에이전트 없이) + +오늘 고칠 헬프데스크 `base-cost`는 질문마다 LLM을 세 번(분류 · 답 · 검토) 부릅니다. 먼저 무엇이 얼마나 드는지 봅니다. + +```bash +docker stats --no-stream # 컨테이너마다 CPU · 메모리 +uv run tools/flow.py call base-cost requests/faq-vpn.json +uv run tools/flow.py call base-cost requests/ticket-own.json +curl -s localhost:8800/prices # 1M 토큰당 달러 +``` + +`COST.md` 1 · 2절을 채웁니다. `usage.by_step`에서 입력 토큰이 가장 많은 단계는? 그 단계가 무엇을 보내는지 n8n 화면에서 `Answer` 노드를 열어 봅니다. + +### 이해하기 + +**"비용"에는 세 가지가 있습니다.** + +| 무엇 | 어디서 드나 | 오늘 재는 방법 | +| --- | --- | --- | +| 컴퓨팅 자원 | 내 컴퓨터(서버)의 CPU · 메모리 · 디스크 | `docker stats` | +| API 사용량 | LLM 공급자에게 보낸 · 받은 **토큰** | 응답의 `usage` (`prompt_tokens` · `completion_tokens`) | +| 돈 | 토큰 × 단가 | `/prices`의 단가로 계산 (예제 2) | + +LLM은 OpenAI 서버에서 계산합니다. 그래서 질문을 보내는 동안에도 내 노트북의 CPU는 거의 오르지 않습니다. 내 쪽 자원에서 눈여겨볼 것은 n8n의 **메모리**입니다. 실행 기록이 쌓이면 늘어납니다. 반대로 돈은 거의 전부 **토큰**에서 나옵니다. + +**토큰은 LLM이 글을 세는 단위입니다.** 글자도 낱말도 아니고, 모델이 글을 잘게 나눈 조각입니다. 영어는 대략 낱말 하나가 토큰 1~2개, 한국어는 글자 1~2개가 토큰 하나쯤입니다. 공급자는 **보낸 토큰(입력, `prompt_tokens`)**과 **받은 토큰(출력, `completion_tokens`)**을 따로 세고, 값도 따로 매깁니다. + +```mermaid +flowchart LR + Q(["질문"]) --> C{{"classify
입력 · 출력"}} --> A{{"answer
입력 · 출력"}} --> R{{"review
입력 · 출력"}} --> O(["답 + usage"]) + D[("사내 규정 전체")] -.->|매번 붙여 보냄| A +``` + +**무엇을 보나.** `usage.by_step`은 단계마다 입력 · 출력 토큰입니다. 세 단계 중 **한 단계의 입력**이 전체의 대부분이라면, 줄일 곳이 그곳입니다 (Lab 02 예제 1). VPN 질문과 접수 질문의 입력이 비슷하다면, 질문과 관계없이 **매번 같이 보내는 것**이 있다는 뜻입니다. + +**기대 결과.** 세 단계 중 `answer`의 입력이 가장 많고, 질문 종류가 달라도 입력 토큰이 비슷합니다. `COST.md` 2절에 VPN 질문의 단계별 토큰을 적습니다. + +
▶ 1단계 · 자원은 무엇을 보나 + +질문을 보내기 전과 보낸 직후에 `docker stats --no-stream`을 한 번씩 봅니다. CPU는 오르나요? n8n의 메모리는 몇 MiB인가요? 실행 기록이 수만 건 쌓이면 무엇이 늘까요? +
+ +
▶ 2단계 · 계산해 보기 + +단가는 **1M(1,000,000) 토큰당** 달러입니다. VPN 질문의 입력 · 출력 토큰 합을 `gpt-4.1-mini` 단가로 계산하면? 하루 1,000개 · 한 달 22일이면? (손으로 한 번 해 보면 예제 2의 식이 보입니다) +
+ +### 실행 결과 · 자원과 단계별 토큰 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +`docker stats --no-stream` (질문을 보내는 동안) + +```text +NAME CPU % MEM USAGE / LIMIT +day67-data-data-api-1 0.02% 20.3MiB / 7.818GiB +day65-n8n-runners-1 0.21% 100.5MiB / 7.818GiB +day65-n8n-n8n-1 0.27% 642.6MiB / 7.818GiB +``` + +`call base-cost requests/faq-vpn.json` (줄임) + +```text +HTTP 200 · 4.3s + "llm_calls": 3, + "usage": { + "prompt_tokens": 1999, + "completion_tokens": 150, + "by_step": { + "classify": { "model": "gpt-4.1-mini", "prompt_tokens": 78, "completion_tokens": 6 }, + "answer": { "model": "gpt-4.1-mini", "prompt_tokens": 1781, "completion_tokens": 78 }, + "review": { "model": "gpt-4.1-mini", "prompt_tokens": 140, "completion_tokens": 66 } + } + } +``` + +`call base-cost requests/ticket-own.json` (줄임) + +```text +HTTP 200 · 3.3s + "usage": { "prompt_tokens": 1973, "completion_tokens": 97, + "by_step": { "classify": { … 71 … }, "answer": { … 1791 … }, "review": { … 111 … } } } +``` + +**왜 이렇게 나왔나** + +- CPU가 1%도 오르지 않은 것은 LLM 계산이 **OpenAI 서버에서** 일어나기 때문입니다. 내 노트북은 요청을 보내고 기다리기만 합니다. 메모리는 n8n이 가장 많이 씁니다 (실행 기록이 쌓이면 늘어남). +- VPN 질문 입력 1,999토큰 중 **1,781(89%)이 `answer` 단계 하나**입니다. 접수 질문도 `answer`가 1,791로 거의 같습니다. 질문이 무엇이든 `Answer`가 지시문 뒤에 사내 규정 전체를 매번 붙여 보내기 때문입니다. +- 결과에 `cost_usd`가 없습니다. 토큰만 알려 주고 얼마인지는 계산하지 않는 워크플로우라서입니다 (예제 2에서 더함). 손 계산: (1,999 × 0.40 + 150 × 1.60) ÷ 1,000,000 = $0.00104. + +## 예제 2 · 결과에 비용 남기기 (12분) + +**만들 것**: 기준을 복사해, 단계마다 비용을 계산하고 합(`cost_usd`)을 결과에. 단가는 `/prices`에서. + +```bash +cp reference/baseline-cost.json my-work/cost.json +``` + +| 조건 | 값 | +| --- | --- | +| 파일 · 웹훅 | `my-work/cost.json` · `my-cost` (경로 · 이름을 바꿈) | +| 단가 | `GET http://data-api:8800/prices` (워크플로우에 숫자를 쓰지 않음) | +| 계산 | [AGENTS.md](AGENTS.md) 3절 `cost_usd` 식, 단계별 `usage.by_step.<단계>.cost_usd` | +| 기대 결과 | `cases/lab01.json` | + +### 이해하기 + +**지금 무엇이 문제인가.** `base-cost`는 토큰은 알려 주지만 **얼마인지**는 말하지 않습니다. 사람이 매번 단가표를 보고 곱해야 합니다. 결과에 `cost_usd`가 있으면 테스트가 "질문 하나에 $0.0002 이하"처럼 **비용을 기대 결과로** 확인할 수 있고 (Lab 02 과제), 기록하면 하루 합계와 예산도 셀 수 있습니다 (과제 1, Lab 03). + +```mermaid +flowchart LR + subgraph 지금 + A1["Build result"] --> O1(["usage 토큰만"]) + end + subgraph 바꾼 뒤 + P2["Get prices
/prices"] -.-> B2["Build result
단계마다 토큰 × 단가"] --> O2(["usage + cost_usd"]) + end +``` + +**식은 하나입니다.** + +```text +단계 비용 = (입력 토큰 × input 단가 + 출력 토큰 × output 단가) ÷ 1,000,000 +cost_usd = 단계 비용의 합 (소수 8자리에서 반올림) +``` + +**단가는 밖에서 읽습니다.** 단가를 Code에 숫자로 쓰면 가격이 바뀔 때마다 워크플로우를 고쳐야 하고, 고치는 것을 잊으면 비용 숫자가 조용히 틀립니다. 70일차 설정 표, 74일차 지시문 저장소와 같은 생각: **바뀌는 값은 한 곳(밖)에 두고 읽는다.** 오늘 단가는 `data-api/data/prices.json`에 있고 사람이 가격표를 보고 정합니다. + +**단계마다 그 단계의 모델 단가로.** 지금은 세 단계가 모두 `gpt-4.1-mini`지만 Lab 02 예제 2에서 일부가 `gpt-5.4-nano`로 바뀝니다. 그때 고치지 않아도 되게, 처음부터 **단계의 모델 이름으로 단가를 찾게** 만듭니다. + +**테스트 케이스.** + +| 케이스 | 보내는 것 | 기대 | +| --- | --- | --- | +| VPN · 비용과 단계별 사용량 | VPN 질문 | `cost_usd` · `usage.by_step.answer.cost_usd`가 0보다 크고 $0.01 이하, "인증서" | +| 접수 현황 · 비용 | T-1002 (박서연) | `cost_usd` 범위, "처리 중" | +| 빈 질문은 LLM 없이 | `question: ""` | 400 (그대로) | + +**`range`**: 테스트 파일의 `"range": {"cost_usd": [0.000001, 0.01]}`는 숫자가 그 사이에 있는지 봅니다 (AGENTS.md 8절). 테스트 줄 끝에 `· 3 calls · $0.00…`이 나오면 결과에 `llm_calls` · `cost_usd`가 있다는 뜻입니다. + +**기대 결과.** `lab01` 3/3. `call my-cost`의 `cost_usd`가 예제 1에서 손으로 계산한 값과 같습니다. + +
▶ 1단계 · 단가는 어디서 + +`curl -s localhost:8800/prices`의 모양을 봅니다. `models` 안에 모델 이름마다 `input` · `output`. n8n에서는 앞에 HTTP 노드를 하나 두고, 계산하는 Code가 `$('그 노드').first().json.models`를 읽습니다. +
+ +
▶ 2단계 · 단계마다 다른 모델 + +`Build result`에는 단계 목록(`steps`: classify · answer · review와 각 모델 이름)이 이미 있습니다. 그 모델 이름으로 단가를 찾아 단계마다 계산하게 요청합니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 파일 · 웹훅 경로 ② 단가를 읽는 노드와 주소 ③ 계산식과 단위 ④ 결과 칸 이름 ⑤ 반올림 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json (baseline 을 복사한 것) 의 웹훅 경로를 ____① 로, 이름을 "75 IT helpdesk (my)" 로 바꿔 줘. +Has question? 뒤에 Get prices(HTTP GET http://data-api:8800/ ____② ) 를 넣어 줘. +Build result 에서 단계마다 cost_usd = (prompt_tokens × input + completion_tokens × output) ÷ ____③ 를 +그 단계 model 의 단가로 계산해 usage.by_step.<단계>.cost_usd 에, 합을 cost_usd 에 넣어 줘. 소수 ____④ 자리에서 반올림. +기대 결과 cases/lab01.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① `cases/lab01.json`의 맨 위 `webhook` 값입니다 (AGENTS.md 3절 표에도). +- ② AGENTS.md 2절 데이터 API 표에서 단가를 주는 주소입니다. +- ③ 단가의 단위(`unit`)를 봅니다. "1M 토큰당"을 숫자로. +- ④ AGENTS.md 3절 `cost_usd` 설명의 반올림 자리입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab01.json -v +uv run tools/flow.py call my-cost requests/faq-vpn.json +``` + +### 실행 결과 · 결과에 비용 남기기 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고친 뒤 (`test cases/lab01.json -v`, 결과 줄만) + +```text +✔ VPN · 비용과 단계별 사용량 · 5.1s · 자동 답변 · 3 calls · $0.001040 +✔ 접수 현황 · 비용 · 3.3s · 자동 답변 · 3 calls · $0.000946 +✔ 빈 질문은 LLM 없이 · 0.0s +Passed 3/3 +``` + +**왜 이렇게 나왔나** + +- 줄 끝에 `· 3 calls · $0.001040`이 나온 것은 결과에 `llm_calls` · `cost_usd`가 생겼기 때문입니다 (`flow.py`가 두 칸이 있으면 보여 줌). +- VPN $0.001040은 예제 1의 손 계산 $0.00104와 같습니다. `Get prices`의 단가로 단계마다 계산해 더했기 때문입니다. +- 빈 질문은 `Has question?`에서 400으로 끝나 LLM을 부르지 않으므로 비용 표시가 없습니다. +- 고치기 전(`base-cost`를 복사한 그대로)에는 웹훅 `my-cost`가 아직 없으므로 404입니다. 경로를 바꾸고 push해야 테스트가 시작됩니다. + +## 예제 3 · 질문 종류별 비용, 한 달 어림 (8분, 에이전트 없이) + +팀장이 묻습니다. "전 직원이 쓰면 한 달에 얼마예요?" 질문 하나의 비용은 알았습니다. 그런데 질문마다 비용이 같을까요? + +```bash +uv run tools/flow.py test cases/lab01-mix.json # 일곱 질문, 줄마다 호출 수 · 비용 +``` + +`COST.md` 3절을 채웁니다. 종류별 평균과 하루 비중(FAQ 60% · 접수 30% · 그 밖 10%, 연습용 가정)으로 한 달을 어림합니다. + +### 이해하기 + +**한 달 비용 = 질문 하나의 비용 × 하루 질문 수 × 일하는 날.** 식은 간단하지만 "질문 하나의 비용"을 무엇으로 잡느냐에 따라 답이 크게 달라집니다. VPN 하나만 재서 곱하면, 더 싸거나 비싼 질문이 섞일 때 틀립니다. + +**종류별로 재고, 비중으로 섞습니다 (가중 평균).** + +```text +한 달 ≈ (FAQ 평균 × 0.6 + 접수 평균 × 0.3 + 그 밖 평균 × 0.1) × 1,000 × 22 +``` + +비중은 실제로는 지난 기록에서 셉니다 (과제 1의 기록, 77일차 로그 분석). 오늘은 기록이 아직 없으니 가정한 값을 씁니다. + +**무엇을 보나.** + +| 볼 것 | 질문 | +| --- | --- | +| 일곱 질문의 비용 차이 | 크게 다른가, 비슷한가? 비슷하다면 왜? (예제 1의 "매번 같이 보내는 것") | +| 호출 수 | 일곱 질문 모두 3번인가? 질문이 쉬워도 3번인가? | +| 가장 싼 질문과 비싼 질문 | 무엇이 차이를 만드나 (출력 길이? FAQ 개수?) | + +**고정비와 변동비.** 질문과 관계없이 매번 드는 몫(같이 보내는 긴 글, 늘 부르는 단계)을 **고정비**, 질문에 따라 달라지는 몫(질문 · FAQ · 답의 길이)을 **변동비**라고 생각해 봅니다. 고정비가 크면 질문 종류가 달라도 비용이 비슷하고, 줄일 때도 **고정비부터** 줄이는 것이 효과가 큽니다 (Lab 02). + +**기대 결과.** 7/7 (이 파일은 비용을 보여 주기만 하고 기대는 HTTP 200뿐). 일곱 질문 모두 3 calls, 비용이 서로 비슷합니다. `COST.md` 3절에 종류별 평균과 한 달 어림 두 가지(가중 · 단순 평균)를 적습니다. + +
▶ 1단계 · 왜 다 비슷한가 + +예제 1에서 `answer` 단계의 입력 토큰이 VPN 질문과 접수 질문에서 거의 같았습니다. 질문이 몇 글자든 같이 보내는 것이 있다면, 비용의 대부분은 질문과 관계없이 정해집니다. +
+ +
▶ 2단계 · 어림을 믿을 수 있나 + +LLM 답의 길이는 실행마다 조금씩 다릅니다. 같은 파일을 한 번 더 돌리면 비용이 똑같이 나올까요? 어림은 "대략 이 정도"를 정하는 것이고, 실제 숫자는 기록(과제 1)과 보고서(Lab 03 과제)로 다시 봅니다. +
+ +### 실행 결과 · 질문 종류별 비용 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +`test cases/lab01-mix.json` (예제 2 정답) + +```text +✔ FAQ · VPN · 3.2s · 자동 답변 · 3 calls · $0.001040 +✔ FAQ · 프린터 · 3.9s · 자동 답변 · 3 calls · $0.000960 +✔ FAQ · 비밀번호 · 3.5s · 자동 답변 · 3 calls · $0.000953 +✔ FAQ · 메일 · 3.4s · 자동 답변 · 3 calls · $0.000965 +✔ 접수 · T-1002 · 3.0s · 자동 답변 · 3 calls · $0.000946 +✔ 접수 · T-1001 · 3.2s · 자동 답변 · 3 calls · $0.000890 +✔ 규칙에 없는 질문 · 2.6s · 자동 답변 · 3 calls · $0.000830 +Passed 7/7 +``` + +**왜 이렇게 나왔나** + +- 일곱 질문이 **$0.00083 ~ 0.00104로 비슷합니다.** 질문마다 다른 몫(질문 · FAQ · 답의 길이)보다 매번 같은 몫(규정 전체 약 1,600토큰)이 훨씬 크기 때문입니다 — 고정비. +- 모두 3 calls입니다. 쉬운 질문도 분류 · 답 · 검토를 다 거치기 때문입니다 (Lab 02 과제 1에서 줄임). +- 가장 싼 질문(회의실, $0.000830)은 FAQ 검색에 걸린 것이 없어 FAQ 글을 덜 보냈고 답도 짧았습니다. +- 종류별 평균: FAQ $0.000980 · 접수 $0.000918 · 그 밖 $0.000830. 비중 60 · 30 · 10%면 $0.000946 × 1,000 × 22 ≈ **$20.8**, 단순 평균 ≈ $20.7. 고정비가 커서 두 어림이 거의 같습니다. + +## 과제 1 · 요청마다 기록하고 합계 보기 (10분) + +> "한 번 물을 때 얼마인지는 알겠는데, 오늘 전체로 얼마 썼는지, 누가 많이 쓰는지 알고 싶어요." + +```bash +uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전 +``` + +**만들 것**: 답을 만든 뒤 데이터 API `POST /usage`에 LLM 호출 하나당 한 줄을 남기기. 응답은 그대로. + +| 조건 | 값 | +| --- | --- | +| 기록 | `Build result` 뒤 `Record usage`, `usage.by_step`의 단계마다 한 줄 (같은 요청은 같은 `request_id`) | +| 한 줄 | `{request_id, user, step, model, prompt_tokens, completion_tokens, cost_usd, route}` (AGENTS.md 2절) | +| 응답 | 기록 뒤에도 `Build result`의 결과 그대로 | +| 기대 결과 | `cases/lab01-challenge.json` (요청 3개 뒤 `/usage/summary`의 `requests`가 3) | + +### 이해하기 + +**답에 남긴 비용은 그 사람만 봅니다.** 예제 2의 `cost_usd`는 질문한 사람의 화면에만 있습니다. 오늘 전체 합계, 사람별 · 모델별 합계, 그리고 Lab 03의 **하루 예산 · 사용자별 한도**는 모두 **어딘가에 쌓인 기록**이 있어야 셀 수 있습니다. 기록이 없으면 예산도 없습니다. + +```mermaid +flowchart LR + B["Build result
답 + usage"] --> R["Record usage
POST /usage
단계마다 한 줄"] --> A["Result again
답을 다시 꺼냄"] --> S(["응답"]) + R -.-> D[("기록
/usage/summary
/budget")] +``` + +**요청 수와 호출 수는 다릅니다.** 질문 하나에 LLM을 세 번 부르면 기록은 세 줄입니다. 이 세 줄이 **한 요청**이라는 것을 알리려고 같은 `request_id`를 붙입니다. n8n에는 실행마다 번호가 있습니다 (AGENTS.md 5절). `/usage/summary`는 `request_id`가 다른 것만 세어 `requests`, 줄 수를 `calls`로 줍니다. + +| 요약 칸 | 질문 3개 · 각 3단계일 때 | +| --- | --- | +| `requests` | 3 | +| `calls` | 9 | +| `by_user` | 사람마다 호출 수 · 토큰 · 비용 | +| `by_model` | 모델마다 | + +**기록 뒤에 답을 다시 꺼내는 까닭.** n8n은 앞 노드의 출력이 다음 노드의 입력이 됩니다. `Record usage`(HTTP)를 지나면 `$json`은 기록 API의 응답(`{"stored": 3}`)이 됩니다. 그래서 응답하기 전에 `Build result`의 결과를 다시 꺼냅니다. + +**테스트 케이스.** + +| 케이스 | 보내는 것 | 기대 | +| --- | --- | --- | +| 기록 1 · VPN | 이준호 | "인증서" | +| 기록 2 · 접수 | 박서연 | "처리 중" | +| 기록 3 · 다른 사람 | 한지훈 · 비밀번호 | "비밀번호 찾기" | +| 사용량 요약 · 요청 3건 | 웹훅이 아니라 `/usage/summary` (`url`) | `requests` 3, 세 사람, `gpt-4.1-mini` | + +테스트 파일의 `before`가 기록을 먼저 비웁니다 (`/usage-clear`). 케이스에 `url`이 있으면 웹훅 대신 그 주소를 부릅니다 (AGENTS.md 8절). + +**기대 결과.** 고치기 전: 답 세 개는 ✔, 요약은 ✘ (`requests` 0). 고친 뒤 4/4. `flow.py usage`로 사람별 비용을 봅니다. + +
▶ 1단계 · 한 요청을 묶는 번호 + +AGENTS.md 5절 "사용량 기록"에 이번 실행의 번호를 읽는 표현식이 있습니다. 호출 세 줄에 같은 번호를 쓰면 한 요청으로 묶입니다. 요약의 `requests`가 9로 나오면 무엇이 잘못됐을까요? +
+ +
▶ 2단계 · 기록 응답이 오류면 + +기록 API가 400 · 500을 돌려주면 HTTP 노드는 보통 실패하고, 워크플로우가 멈춥니다. 오류 응답도 성공 쪽으로 받는 옵션이 있습니다 (AGENTS.md 5절, 74일차 Lab 02 과제 2). 기록이 아예 멈추는 경우는 과제 2에서 다룹니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 넣을 노드와 주소 ② 한 줄의 칸 ③ 한 요청을 묶는 번호 ④ 누가 물었나 ⑤ 응답을 다시 꺼내는 곳 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Build result 뒤에 Record usage(HTTP POST http://data-api:8800/usage) 를 넣어 줘. +본문은 items: usage.by_step 의 단계마다 한 줄 {request_id, user, step, model, prompt_tokens, completion_tokens, cost_usd, route}. +request_id 는 ____① , user 는 웹훅 본문의 ____② . +기록 응답이 오류여도 멈추지 않게 options.response 에 ____③ , 그다음 Result again(Code) 이 ____④ 의 결과를 다시 꺼내 Send result 로. +기대 결과 cases/lab01-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① n8n이 실행마다 주는 번호를 읽는 표현식입니다 (AGENTS.md 5절 "사용량 기록"). +- ② 요청 본문에서 사람 이름이 들어 있는 칸입니다 (`requests/faq-vpn.json`을 열어 봅니다). +- ③ 404 · 500 같은 응답도 실패로 보지 않게 하는 HTTP 노드 옵션입니다 (74일차 Lab 02 과제 2와 같은 이름). +- ④ 사용자에게 보낼 답을 만든 노드입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab01-challenge.json -v +uv run tools/flow.py usage +``` + +### 실행 결과 · 요청마다 기록 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (예제 2 정답, `test cases/lab01-challenge.json -v`) + +```text +✔ 기록 1 · VPN · 3.5s · 자동 답변 · 3 calls · $0.001040 +✔ 기록 2 · 접수 · 3.0s · 자동 답변 · 3 calls · $0.000946 +✔ 기록 3 · 다른 사람 · 3.4s · 자동 답변 · 3 calls · $0.000955 +✘ 사용량 요약 · 요청 3건 · 0.0s · $0.000000 · requests = 0 (expected 3); cost_usd = 0.0 (expected 1e-06..0.05); by_user has none of ['이준호', '박서연', '한지훈']; by_model has none of ['gpt-4.1-mini'] +Passed 3/4 +``` + +고친 뒤 (`test … -v` 결과 줄, `flow.py usage`) + +```text +✔ 기록 1 · VPN · 5.0s · 자동 답변 · 3 calls · $0.001040 +✔ 기록 2 · 접수 · 3.5s · 자동 답변 · 3 calls · $0.000959 +✔ 기록 3 · 다른 사람 · 3.4s · 자동 답변 · 3 calls · $0.000953 +✔ 사용량 요약 · 요청 3건 · 0.0s · $0.002951 +Passed 4/4 + +requests 3 · calls 9 · tokens in 5950 / out 357 · $0.002951 + model gpt-4.1-mini 9 calls · in 5950 · out 357 · $0.002951 + user 이준호 3 calls · in 1999 · out 150 · $0.001040 + user 박서연 3 calls · in 1973 · out 106 · $0.000959 + user 한지훈 3 calls · in 1978 · out 101 · $0.000953 + route 자동 답변 9 calls · in 5950 · out 357 · $0.002951 +budget $1.0 / used $0.002951 · remaining $0.997049 · over False · per user 20/min · question 500 chars +``` + +**왜 이렇게 나왔나** + +- 고치기 전에도 답 세 개는 ✔입니다. 답 · 비용은 예제 2 그대로 맞기 때문입니다. 요약만 ✘(`requests = 0`)인 것은 **아무 데도 기록하지 않아** 데이터 API가 모르기 때문입니다. +- 고친 뒤 `requests 3 · calls 9`: 질문 3개 × 단계 3개 = 9줄이 기록됐고, 같은 요청의 세 줄이 같은 번호로 묶여 요청은 3입니다. 번호가 줄마다 다르면 `requests`가 9로 나옵니다. +- `budget`의 `used $0.002951`은 기록된 `cost_usd`의 합입니다. 기록이 쌓여야 Lab 03의 예산 · 한도가 셀 수 있습니다. + +## 과제 2 · 기록이 멈춰도 답은 나가게 (10분) + +> "기록 서버를 점검하느라 껐더니 헬프데스크가 한참 멈췄다가 답했대요. 기록 때문에 직원이 기다리면 안 돼요. 대신 기록이 빠진 답은 나중에 셀 수 있게 표시해 주세요." + +먼저 겪어 봅니다. + +```bash +uv run tools/flow.py fault usage slow # 기록 API가 8초 늦게 답함 +uv run tools/flow.py call my-cost requests/faq-vpn.json -v # 몇 초 걸리나 +uv run tools/flow.py fault usage ok # 반드시 되돌리기 +uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전 (케이스마다 고장을 켜고 끔) +``` + +**만들 것**: 기록 API가 **오류를 내거나 3초 안에 답하지 않아도** 답은 그대로 보내고, 기록됐는지를 `recorded: true|false`로. + +| 조건 | 값 | +| --- | --- | +| 기다리는 한도 | `Record usage` timeout 3초 | +| 실패하면 | 멈추지 않고 같은 출력으로 다음 노드 (`Result again`) | +| 결과 | 과제 1 결과 + `recorded` (기록 응답의 `stored`가 1 이상이면 true) | +| 기대 결과 | `cases/lab01-challenge2.json` 4개, `lab01-challenge`도 그대로 | + +### 이해하기 + +**기록은 답을 위한 것이 아닙니다.** 사용자에게 중요한 것은 답이고, 기록은 운영하는 쪽의 일입니다. 그런데 과제 1의 워크플로우는 기록이 끝나야 답을 보냅니다. 기록 API가 느리면 사용자가 그만큼 기다리고, 아예 멈추면 답도 못 받을 수 있습니다. **부가 기능이 본 기능을 막지 않게** 합니다. + +```mermaid +flowchart LR + subgraph 지금 + B1["Build result"] --> R1["Record usage
8초 기다림"] --> S1(["늦은 답"]) + end + subgraph 바꾼 뒤 + B2["Build result"] --> R2["Record usage
timeout 3초
실패해도 계속"] --> A2["Result again
recorded false"] --> S2(["답 · 3초 안"]) + end +``` + +**계속 가되, 표시를 남깁니다.** 74일차 지시문 저장소처럼 여기도 **계속 가는 쪽(fail-open)**입니다. 다만 값이 있습니다. 기록되지 않은 요청은 Lab 03의 하루 예산 · 사용자별 한도에 **세어지지 않습니다.** 예산이 실제보다 적게 쓴 것처럼 보입니다. 그래서 `recorded: false`를 답에 남겨, 나중에 몇 건이 빠졌는지 셀 수 있게 합니다. + +| | 73일차 판별 LLM | 74일차 지시문 저장소 | 오늘 사용량 기록 | +| --- | --- | --- | --- | +| 멈추면 | 막는다 | 기본 지시문으로 답 | 답은 그대로, 기록만 빠짐 | +| 남기는 표시 | `guard_down` | `prompt_source: fallback` | `recorded: false` | +| 치르는 값 | 사용자가 기다림 | 지시문이 최신이 아닐 수 있음 | 예산 · 한도가 덜 셈 | + +**오류 출력이 두 갈래가 아니어도 됩니다.** 74일차는 실패하면 **다른 길**(기본 지시문)로 가야 해서 오류 출력을 따로 이었습니다 (`continueErrorOutput`). 오늘은 실패해도 **같은 길**(답을 다시 꺼내 보냄)로 가면 되므로, 실패한 항목을 같은 출력으로 흘려보내는 설정을 씁니다. 그 항목에는 `stored`가 없으니 `recorded`가 false가 됩니다. + +**테스트 케이스.** + +| 케이스 | 고장 | 기대 | +| --- | --- | --- | +| 기록 API 고장(500) · 답은 나감 | `usage error` | `recorded: false`, "인증서" | +| 기록 API 느림(8초) · 답은 나감 | `usage slow` | `recorded: false`, "대기열", 15초 안 | +| 고친 뒤 · 기록됨 | 없음 | `recorded: true`, "비밀번호 찾기" | +| 요약 · 기록된 요청은 1건 | `/usage/summary` | `requests` 1 (고장 난 두 건은 빠짐) | + +**기대 결과.** 고치기 전에는 `recorded`가 없어 세 케이스가 ✘, 느림 케이스는 시간도 오래 걸립니다. 고친 뒤 4/4. 마지막 케이스가 "기록이 빠지면 예산에서도 빠진다"를 숫자로 보여 줍니다. + +
▶ 1단계 · 어디서 기다리나 + +`fault usage slow` 상태의 `call -v`에서 걸린 시간을 봅니다. LLM 세 번에 몇 초, 나머지 몇 초는 어디서? n8n 화면의 Executions에서 노드마다 걸린 시간을 봅니다. +
+ +
▶ 2단계 · 실패해도 같은 길로 + +n8n 노드의 Settings에 "On Error" 설정이 있습니다. 72 · 73 · 74일차에 쓴 두 갈래 값과 다른, **같은 출력으로 계속**하는 값입니다 (AGENTS.md에는 없으니 n8n 화면의 Settings에서 이름을 찾아 봅니다). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 고칠 노드와 기다리는 한도 ② 실패했을 때의 설정 ③ `recorded`를 정하는 값 ④ 그대로 둘 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Record usage 에 options.timeout ____① 과 onError ____② 를 넣어 줘. +Result again 은 Build result 의 결과에 recorded 를 더해: 기록 응답의 ____③ 가 1 이상이면 true, 아니면 false. +기록이 실패해도 사용자에게 가는 answer · cost_usd 는 ____④ . +기대 결과 cases/lab01-challenge2.json, cases/lab01-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① 단위는 ms입니다. 위 조건 표의 "3초". +- ② n8n Settings의 On Error 값 중 "같은 출력으로 계속"하는 것의 JSON 이름입니다 (`continue`로 시작). +- ③ 기록 API가 성공하면 돌려주는 칸입니다 (`curl -s -XPOST localhost:8800/usage -d '{}'`로 모양을 봅니다). +- ④ 이 과제에서 바뀌면 안 되는 것입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab01-challenge2.json -v +uv run tools/flow.py test cases/lab01-challenge.json -v +uv run tools/flow.py doctor # fault switch가 모두 ok인지 +``` + +### 실행 결과 · 기록이 멈춰도 답은 나가게 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (과제 1 정답, `test cases/lab01-challenge2.json -v` 결과 줄) + +```text +✘ 기록 API 고장(500) · 답은 나감 · 3.4s · 자동 답변 · 3 calls · $0.001040 · recorded = null (expected false) +✘ 기록 API 느림(8초) · 답은 나감 · 11.2s · 자동 답변 · 3 calls · $0.000962 · recorded = null (expected false) +✘ 고친 뒤 · 기록됨 · 3.5s · 자동 답변 · 3 calls · $0.000953 · recorded = null (expected true) +✔ 요약 · 기록된 요청은 1건 · 0.0s · $0.000953 +Passed 1/4 +``` + +고친 뒤 + +```text +✔ 기록 API 고장(500) · 답은 나감 · 4.4s · 자동 답변 · 3 calls · $0.001040 +✔ 기록 API 느림(8초) · 답은 나감 · 6.1s · 자동 답변 · 3 calls · $0.000960 +✔ 고친 뒤 · 기록됨 · 3.7s · 자동 답변 · 3 calls · $0.000955 +✔ 요약 · 기록된 요청은 1건 · 0.0s · $0.000955 +Passed 4/4 +``` + +**왜 이렇게 나왔나** + +- 고치기 전 고장(500) 케이스도 답은 나왔습니다. 과제 1에서 기록 응답이 오류여도 받아 넘기게 했기 때문입니다. ✘는 `recorded` 칸이 아직 없어서입니다. +- 고치기 전 느림 케이스가 **11.2초**: LLM 세 번(약 3초) 뒤 기록 API의 8초를 끝까지 기다렸습니다. 고친 뒤 **6.1초**는 3초에 기다리기를 그만두고 답을 보냈기 때문입니다. +- 요약 `requests` 1은 고치기 전 · 후 모두 같습니다. 고장 · 느림 두 건은 기록 서버가 저장하지 않았고, 정상인 한 건만 남았습니다. 기록이 빠진 만큼 예산에서도 빠진다는 것을 이 숫자가 보여 줍니다. +- 수업 준비 때 처음에는 느림 모드의 서버가 8초 뒤 **결국 저장**해 다음 테스트의 요청 수가 하나 많았습니다 (1단계 강의 8절). 지금 데이터 API는 늦게 답하고 저장하지 않습니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| `usage`가 비어 있음 | LLM 노드가 AI Agent면 토큰이 없습니다. HTTP로 OpenAI를 부르는지 (AGENTS.md 5절) | +| `cost_usd`가 너무 큼 (예: 1040) | 1,000,000으로 나눴는지 | +| `cost_usd`가 0 | 단계의 모델 이름과 `/prices`의 모델 이름이 같은지 | +| OpenAI 400 `max_tokens` | `gpt-5.4-nano`는 `max_completion_tokens`만 받습니다 (`check`가 알려 줌) | +| OpenAI 401 | Credential이 망가짐: `uv run tools/flow.py llm up` | +| 요약의 `requests`가 9 | `request_id`가 호출마다 다름 | +| 응답이 `{"stored": 3}` | 기록 뒤에 답을 다시 꺼내지 않음 | +| `fault` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault usage ok` | +| 계속 안 됨 | `cp reference/lab01-example2-cost.json my-work/cost.json` 후 push | + +## 확인 + +- [ ] 예제 1: `COST.md` 1 · 2절 (자원 · 단계별 토큰 · 손 계산). +- [ ] 예제 2: `cases/lab01.json` 3/3. +- [ ] 예제 3: `lab01-mix` 7/7, `COST.md` 3절 (종류별 평균 · 한 달 어림 두 가지). +- [ ] 과제 1: `cases/lab01-challenge.json` 4/4, `flow.py usage`로 사람별 비용. +- [ ] 과제 2: `cases/lab01-challenge2.json` 4/4, `fault usage ok`로 되돌림. +- [ ] `my-work/PROMPTS.md`. diff --git a/Last3Mon/75/LAB-02-Q-REDUCE-COST.md b/Last3Mon/75/LAB-02-Q-REDUCE-COST.md new file mode 100644 index 0000000..d60e8c4 --- /dev/null +++ b/Last3Mon/75/LAB-02-Q-REDUCE-COST.md @@ -0,0 +1,573 @@ +# Lab 02 · 보내는 것 줄이기, 단계마다 맞는 모델, LLM 한 번, 긴 질문의 상한 + +- 관련 강의: [2단계 · 비용 줄이기](../../Lecture/75/02-STAGE2-REDUCE-COST.md) +- 소요 시간: 50분 (4교시 한 시간) · 예제 1 10분 · 예제 2 8분 · 예제 3 7분 · 과제 1 13분 · 과제 2 12분 +- 준비: Lab 01의 `my-work/cost.json` (없으면 `cp reference/lab01-example2-cost.json my-work/cost.json` 후 push. 기록 · `recorded`는 Lab 01 과제를 끝낸 것) +- 목표: 비용을 정하는 세 가지 — **보내는 토큰 수 · 단가 · 호출 수** — 를 한 가지씩 줄이고, 바꿀 때마다 품질 테스트로 확인한다. 싸진 것이 품질을 잃은 것은 아닌지 답을 읽어 보고, 아주 긴 질문에도 비용 상한을 둔다. + +```mermaid +flowchart LR + E1["예제 1
보내는 것 줄이기
(토큰 수)"] --> E2["예제 2
단계마다 맞는 모델
(단가)"] --> E3["예제 3
답까지 작은 모델이면?"] --> T1["과제 1
LLM 한 번 · $0.0002
(호출 수)"] --> T2["과제 2
긴 질문의
비용 상한"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 바꿀 때마다 `COST.md` 5절에 한 줄. + +**비용 = 호출 수 × 호출당 토큰 × 토큰 단가.** 오늘 Lab 02는 이 곱의 세 칸을 하나씩 줄입니다. 한 번에 하나만 바꾸고 다시 재는 것은 72일차 성능 튜닝 · 74일차 버전 관리와 같습니다. + +## 예제 1 · 보내는 것 줄이기 (10분) + +Lab 01 예제 1에서 `answer` 단계 입력의 대부분이 무엇이었나요? + +```bash +uv run tools/flow.py test cases/lab02.json -v # 고치기 전 +``` + +**만들 것**: 답에 필요한 것만 보내기. 답 길이에도 상한. + +| 조건 | 값 | +| --- | --- | +| 지시문 | 사내 규정 전체(참고 자료)를 빼고 답 규칙만 | +| FAQ · 접수 건 | 답에 쓰는 필드만 (FAQ: `id` · `title` · `answer` / 접수: `ticket_id` · `title` · `status` · `note` · `error`) | +| 답 길이 | `max_completion_tokens` 200 | +| 기대 결과 | `cases/lab02.json` 앞 세 케이스 (`answer` 단계 입력 600토큰 이하, 품질 그대로) | + +### 이해하기 + +**"혹시 몰라서" 붙인 글이 비용의 대부분입니다.** `base-cost`의 `Answer`는 지시문 끝에 사내 규정 문서 여덟 개를 통째로 붙입니다. 그런데 지시문은 "FAQ와 접수 건으로만 답하라"고 합니다. 규정 전체는 답을 더 좋게 만들지 못하면서 **질문마다** 함께 보내져, `answer` 단계 입력 약 1,780토큰의 대부분을 차지합니다. Lab 01 예제 3의 "고정비"가 이것입니다. + +```mermaid +flowchart LR + subgraph 지금 + S1["지시문 + 규정 전체"] --> A1{{"Answer"}} + F1["FAQ 모든 칸
접수 건 모든 칸"] --> A1 + end + subgraph 바꾼 뒤 + S2["지시문 (답 규칙만)"] --> A2{{"Answer
max_completion_tokens 200"}} + F2["FAQ 3칸 · 접수 5칸"] --> A2 + end +``` + +**필요한 것만 보내는 것이 RAG의 생각입니다.** 71일차에 문서 전체 대신 **질문과 관련된 부분만** 찾아서 보냈습니다. 비용으로 보면 이것도 같은 이야기입니다. 관련 없는 글은 돈만 쓰고, 때로는 모델을 헷갈리게 합니다. + +**빼면 안전해지는 것도 있습니다.** 접수 건에는 연락처(`contact`) · 담당자(`assignee`)가 있습니다. 답에 쓰지 않는 칸을 LLM에 보내지 않으면 토큰도 줄고, 73일차처럼 개인정보가 답으로 새어 나갈 길도 하나 줄어듭니다. + +**출력에도 상한을 둡니다.** 출력 토큰은 입력보다 비쌉니다 (`gpt-4.1-mini` 4배). `max_completion_tokens`는 답의 최대 길이입니다. 너무 작으면 답이 잘리니, 지시문의 "3문장 이내"와 맞는 값을 둡니다. 오늘 쓰는 두 모델 모두 이 이름을 받습니다 (`gpt-5.4-nano`는 예전 이름 `max_tokens`를 받지 않습니다). + +**테스트 케이스 (`lab02.json`).** + +| 케이스 | 예제 | 기대 | +| --- | --- | --- | +| VPN · 보내는 양 줄이기 | 1 | `usage.by_step.answer.prompt_tokens` ≤ 600, "인증서" | +| 접수 · 보내는 양 줄이기 | 1 | 같은 상한, "처리 중" | +| 비밀번호 · 품질 그대로 | 1 | "비밀번호 찾기" | +| 분류는 작은 모델 | 2 | `usage.by_step.classify.model` = `gpt-5.4-nano` | +| 프린터 · 품질 그대로 | 2 | "대기열" | + +**기대 결과.** 고치기 전: 앞의 두 케이스(입력 1,780토큰쯤)와 예제 2 케이스 ✘. 고친 뒤 앞 세 케이스 ✔ (예제 2 케이스는 아직 ✘). `COST.md` 5절에 `answer` 입력 토큰과 VPN 비용의 전 · 후를 적습니다. + +
▶ 1단계 · 무엇을 뺐는지 확인 + +고친 뒤 `uv run tools/flow.py diff reference/baseline-cost.json my-work/cost.json`으로 `Answer`의 글자 수가 얼마나 줄었는지 봅니다 (74일차 `diff`). +
+ +
▶ 2단계 · 규정 전체가 필요한 질문은 없나 + +"노트북 교체 주기는?"처럼 FAQ에 없고 규정에만 있는 질문이 오면 어떻게 될까요? 지금 지시문은 그런 질문에 "찾지 못했습니다"라고 하게 돼 있습니다. 규정이 필요하다면 전체가 아니라 71일차처럼 **검색해서 관련 부분만** 보내는 것이 답입니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 지시문에서 뺄 것 ② FAQ · 접수 건에서 남길 칸 ③ 답 길이 상한의 이름과 값 ④ 그대로 둘 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Answer 지시문에서 ____① 을 빼고 답 규칙만 남겨 줘. +사용자 메시지에는 질문과, FAQ 는 id · title · ____② 만, 접수 건은 ticket_id · title · status · note · error 만 보내 줘 (contact · assignee 는 빼고). +답 길이 상한은 ____③ 200. +Classify · Review 단계는 ____④ . +기대 결과 cases/lab02.json 앞 세 케이스. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① n8n 화면에서 `Answer` 노드의 본문을 열면 지시문 뒤에 붙은 긴 제목이 보입니다. +- ② FAQ 한 항목(`curl -s 'localhost:8800/faq?q=VPN'`)에서 실제 답 글자가 들어 있는 칸입니다. +- ③ 위 "출력에도 상한을 둡니다"의 이름입니다 (`gpt-5.4-nano`도 받는 이름). +- ④ 한 번에 하나만 바꿉니다. 예제 2에서 바꿀 단계입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py diff reference/baseline-cost.json my-work/cost.json +``` + +### 실행 결과 · 보내는 것 줄이기 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (Lab 01 과제 2 정답, `test cases/lab02.json -v` 결과 줄) + +```text +✘ 예제 1 · VPN · 보내는 양 줄이기 · 3.4s · 자동 답변 · 3 calls · $0.001007 · usage.by_step.answer.prompt_tokens = 1781 (expected 1..600) +✘ 예제 1 · 접수 · 보내는 양 줄이기 · 3.5s · 자동 답변 · 3 calls · $0.000936 · usage.by_step.answer.prompt_tokens = 1791 (expected 1..600) +✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.5s · 자동 답변 · 3 calls · $0.000955 +✘ 예제 2 · 분류는 작은 모델 · 3.2s · 자동 답변 · 3 calls · $0.000830 · usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano) +✔ 예제 2 · 프린터 · 품질 그대로 · 3.5s · 자동 답변 · 3 calls · $0.000960 +Passed 2/5 +``` + +고친 뒤 (결과 줄, `diff`) + +```text +✔ 예제 1 · VPN · 보내는 양 줄이기 · 4.7s · 자동 답변 · 3 calls · $0.000346 +✔ 예제 1 · 접수 · 보내는 양 줄이기 · 3.2s · 자동 답변 · 3 calls · $0.000284 +✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.1s · 자동 답변 · 3 calls · $0.000294 +✘ 예제 2 · 분류는 작은 모델 · 2.7s · 자동 답변 · 3 calls · $0.000183 · usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano) +✔ 예제 2 · 프린터 · 품질 그대로 · 3.2s · 자동 답변 · 3 calls · $0.000312 +Passed 4/5 + +~ 'Answer' parameters.jsonBody: changed (3491 → 822 chars) +``` + +**왜 이렇게 나왔나** + +- 고치기 전 ✘ 둘: `answer` 입력이 1,781 · 1,791토큰으로 상한 600을 넘었습니다. 규정 전체가 붙어 있기 때문입니다. +- 고친 뒤 `answer` 입력은 VPN 165 · 접수 152 (`-v`의 `usage.by_step.answer`)로 **91% 줄었고**, VPN 비용이 $0.001007 → **$0.000346**. 답의 낱말(인증서 · 처리 중 · 비밀번호 찾기)은 그대로라 품질 케이스도 ✔. +- `diff`가 바뀐 곳을 `Answer` **한 곳**으로 보여 줍니다 (3,491 → 822자). 한 번에 한 칸만 바꿨다는 확인입니다. +- 남은 ✘ 하나(분류 모델)는 예제 2에서 고칠 것이라 **예상한 결과**입니다. + +## 예제 2 · 단계마다 맞는 모델 (8분) + +**만들 것**: 분류 · 검토 같은 쉬운 단계는 작은 모델(`gpt-5.4-nano`), 답은 지금 모델. + +| 조건 | 값 | +| --- | --- | +| 바꿀 곳 | `Classify` · `Review`의 모델 이름 (계산도 그 모델 단가로) | +| 그대로 | `Answer`는 `gpt-4.1-mini` | +| 기대 결과 | `cases/lab02.json` 5/5, `lab01`도 | + +### 이해하기 + +**모든 일에 같은 모델을 쓸 까닭은 없습니다.** 분류는 세 낱말(faq · ticket · other) 중 하나를 고르는 일이고, 검토는 말투를 다듬는 일입니다. 답은 사실(인증서 재발급 · 처리 상태)을 틀리면 안 됩니다. 일의 어려움에 맞춰 모델을 고르는 것을 **모델 라우팅(model routing)** 또는 **계단식 모델(cascade)**이라고 부릅니다. + +```mermaid +flowchart LR + C{{"Classify
mini → nano"}} --> A{{"Answer
mini 그대로"}} --> R{{"Review
mini → nano"}} +``` + +**단가표를 다시 봅니다.** + +| 모델 | 입력 ($/1M) | 출력 ($/1M) | mini 대비 | +| --- | --- | --- | --- | +| `gpt-4.1-mini` | 0.40 | 1.60 | | +| `gpt-5.4-nano` | 0.20 | 1.25 | 입력 절반, 출력 약 0.78배 | + +작은 모델이 늘 몇 배씩 싼 것은 아닙니다. 오늘 두 모델은 **입력은 절반, 출력은 조금 쌉니다.** 그래서 입력이 많은 단계일수록 바꾼 효과가 큽니다. 단가는 공급자 · 시기마다 바뀌므로 가격표를 보고 직접 계산해야 합니다. + +**74일차를 떠올립니다.** 74일차 릴리스 2.0.0(답까지 nano)은 강한 golden 게이트를 통과했지만, "비용을 본 뒤 배포"로 미뤘습니다. 오늘은 먼저 **쉬운 단계만** 바꾸고, 답까지 바꾸면 얼마나 아끼고 무엇을 잃는지는 예제 3에서 재 봅니다. 한 번에 하나씩입니다. + +**기대 결과.** `lab02` 5/5, `lab01` 3/3. VPN 질문 비용이 예제 1보다 조금 더 줄어듭니다. `COST.md` 5절에 한 줄. + +
▶ 1단계 · 계산도 함께 + +모델 이름을 노드에서만 바꾸고 `Build result`의 단계 목록(`steps`)은 그대로 두면 어떻게 될까요? 결과의 `model`은 `gpt-4.1-mini`인데 실제로는 nano를 불렀고, 비용도 mini 단가로 계산됩니다. 74일차 "한 곳에서 정하고 모두 그것을 읽는다"를 떠올립니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 바꿀 단계 ② 새 모델 이름 ③ 그대로 둘 단계 ④ 계산이 따라가게 할 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 ____① · Review 두 단계의 model 을 ____② 로 바꿔 줘. Answer 는 ____③ . +Build result 의 단계 목록(steps) 의 model 도 같이 바꿔, 비용이 그 모델의 ____④ 로 계산되게. +기대 결과 cases/lab02.json 전체, cases/lab01.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① 세 낱말 중 하나를 고르는 쉬운 단계입니다. +- ② `curl -s localhost:8800/prices`에 있는 작은 모델 이름입니다 (AGENTS.md 2절). +- ③ 사실을 틀리면 안 되는 단계입니다. 이 단계를 바꿀지는 예제 3에서 재 보고 정합니다. +- ④ Lab 01 예제 2에서 `/prices`에서 읽은 것입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py test cases/lab01.json +``` + +### 실행 결과 · 단계마다 맞는 모델 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고친 뒤 (`test cases/lab02.json -v` 결과 줄, `test cases/lab01.json`) + +```text +✔ 예제 1 · VPN · 보내는 양 줄이기 · 4.3s · 자동 답변 · 3 calls · $0.000278 +✔ 예제 1 · 접수 · 보내는 양 줄이기 · 3.1s · 자동 답변 · 3 calls · $0.000204 +✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.6s · 자동 답변 · 3 calls · $0.000244 +✔ 예제 2 · 분류는 작은 모델 · 2.7s · 자동 답변 · 3 calls · $0.000144 +✔ 예제 2 · 프린터 · 품질 그대로 · 3.7s · 자동 답변 · 3 calls · $0.000270 +Passed 5/5 + +✔ VPN · 비용과 단계별 사용량 · 3.3s · 자동 답변 · 3 calls · $0.000278 +✔ 접수 현황 · 비용 · 3.5s · 자동 답변 · 3 calls · $0.000204 +✔ 빈 질문은 LLM 없이 · 0.0s +Passed 3/3 +``` + +**왜 이렇게 나왔나** + +- 고치기 전은 예제 1의 "고친 뒤"와 같습니다 (4/5, 분류 모델 ✘). +- VPN $0.000346 → **$0.000278** (약 20% 덜). 많이 줄지 않은 것은 분류 · 검토가 입력이 적은 단계이고, nano의 단가가 입력 절반 · 출력 0.78배라서입니다. +- 답은 mini 그대로라 품질 케이스와 `lab01`이 모두 ✔입니다. 비용이 nano 단가로 계산된 것도 줄 끝 금액이 줄어든 것으로 보입니다 (계산의 모델 이름을 함께 바꾸지 않으면 금액이 그대로 나옴). + +## 예제 3 · 답까지 작은 모델이면? (7분, 에이전트 없이) + +"분류 · 검토가 nano로 잘 되면, 답도 nano로 하면 더 싸지 않나요?" 바꾸기 전에 비교본으로 재 봅니다. `reference/lab02-example3-nano-answer.json`은 예제 2 정답에서 **답 단계만** nano로 바꾼 것입니다 (웹훅 `nano-answer`, 고치지 않음). + +```bash +uv run tools/flow.py push reference/lab02-example3-nano-answer.json +uv run tools/flow.py test cases/lab02.json --webhook nano-answer -v # 통과 수와 답 글자 +uv run tools/flow.py test cases/lab01-mix.json # 내 것(답 mini) 일곱 질문 비용 +uv run tools/flow.py test cases/lab01-mix.json --webhook nano-answer # 답 nano 일곱 질문 비용 +``` + +`COST.md` 5절 "예제 3" 줄에 통과 수, 일곱 질문 평균 비용 둘, 그리고 **답을 읽고 본 것**을 적습니다. + +### 이해하기 + +**통과했다고 같은 품질은 아닙니다.** 오늘 테스트는 답에 **꼭 있어야 할 낱말**(인증서 · 처리 중 · 대기열)이 있는지만 봅니다. 낱말이 있어도 말투 · 형식 · 군더더기가 달라질 수 있습니다. `-v`로 나온 답 글자를 **사람이 직접 읽어** 봅니다. + +| 읽으며 볼 것 | 예 | +| --- | --- | +| 형식 | 메일 · 메신저에 그대로 붙을 글인가 (마크다운 기호 `**`가 섞였나) | +| 군더더기 | 묻지 않은 것을 덧붙였나 ("접수 건은 찾지 못했습니다" 같은 말) | +| 안쪽 말이 새나 | 데이터 API의 오류 글자(`ticket not found`)가 답에 나왔나 | +| 말투 | 합니다체를 지켰나 | + +**아끼는 돈과 잃는 것을 같이 적습니다.** 일곱 질문 평균 비용이 얼마나 줄었는지 숫자로 보고, 위 표에서 본 것을 같이 놓고 정합니다. 출력 단가 차이가 크지 않으면(1.60 → 1.25) 답 단계는 바꿔도 많이 아끼지 못합니다. "싸진 만큼 잃은 것"이 더 크면 바꾸지 않는 것이 맞는 결정입니다. 테스트에 형식 검사(`not_contains: ["**"]`)를 더할지도 생각해 봅니다. + +**기대 결과.** `lab02 --webhook nano-answer`는 낱말 검사만으로는 대부분 통과합니다. 일곱 질문의 평균 비용 차이는 크지 않고, 답 글자에서 위 표의 차이가 보입니다. 오늘 `my-work/cost.json`의 답은 mini로 둡니다. + +
▶ 1단계 · 테스트를 더 엄하게 하면 + +`cases/lab02.json`을 복사해 `my-work/lab02-strict.json`으로 만들고, 케이스마다 `"not_contains": {"answer": ["**", "ticket not found"]}`를 더해 두 웹훅에 돌려 봅니다. 기대 결과를 **더하는** 것은 괜찮습니다 (원래 테스트를 바꾸는 것은 안 됨). +
+ +
▶ 2단계 · 누가 정하나 + +이 결정은 에이전트가 아니라 **사람이** 합니다. 숫자(얼마 아끼나)와 답 글자(무엇을 잃나)를 함께 놓고, 74일차처럼 golden 게이트를 지나야 바꿉니다. +
+ +### 실행 결과 · 답까지 작은 모델이면? + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +`test cases/lab02.json --webhook nano-answer -v` (결과 줄과 답 글자 몇 개) + +```text +✔ 예제 1 · VPN · 보내는 양 줄이기 · 5.0s · 자동 답변 · 3 calls · $0.000297 + "answer": "FAQ에 따르면 VPN 접속이 끊길 때는 … 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 ticket_id가 없어 상태를 확인할 수 없으며, 요청하신 티켓 기록은 찾지 못했습니다. …" +✔ 예제 1 · 접수 · 보내는 양 줄이기 · 2.8s · 자동 답변 · 3 calls · $0.000190 + "answer": "T-1002의 처리 상태는 **처리 중**이며, **인증서 발급 승인 대기 중**입니다. …" +✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.7s · 자동 답변 · 3 calls · $0.000179 + "answer": "… 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 접수 건은 찾지 못했습니다." +✔ 예제 2 · 분류는 작은 모델 · 3.2s · 자동 답변 · 3 calls · $0.000196 +✔ 예제 2 · 프린터 · 품질 그대로 · 3.3s · 자동 답변 · 3 calls · $0.000204 +Passed 5/5 +``` + +`lab01-mix` 일곱 질문 (내 것 → `nano-answer`) + +```text +FAQ · VPN $0.000278 → $0.000217 +FAQ · 프린터 $0.000270 → $0.000245 +FAQ · 비밀번호 $0.000241 → $0.000179 +FAQ · 메일 $0.000297 → $0.000209 +접수 · T-1002 $0.000204 → $0.000190 +접수 · T-1001 $0.000195 → $0.000153 +규칙에 없는 질문 $0.000144 → $0.000196 +평균 $0.000233 → $0.000198 (약 15% 덜) +``` + +**왜 이렇게 나왔나** + +- 낱말 검사가 **5/5 통과**한 것은 테스트가 "인증서 · 처리 중 · 대기열" 같은 낱말이 있는지만 보기 때문입니다. 그 낱말은 nano 답에도 있습니다. +- 그런데 답을 읽으면 마크다운 `**`, 데이터 API의 칸 이름(`ticket_id`)이 섞인 안쪽 말, 묻지 않은 "접수 건은 찾지 못했습니다"가 있습니다. 테스트가 보지 않는 것이라 통과했습니다. +- 평균이 15%만 준 것은 답 단계의 출력 단가 차이가 크지 않아서입니다 (1.60 → 1.25). 회의실 질문은 nano가 더 길게 답해 오히려 비쌌습니다. +- 그래서 **바꾸지 않는 것**이 이 예제의 결론입니다. 숫자(15%)와 답 글자를 함께 놓고 사람이 정합니다. + +## 과제 1 · LLM 한 번, 질문 하나에 $0.0002 이하 (13분) + +> "지금도 질문 하나에 $0.0002가 넘어요. 전 직원이 쓰면 부담돼요. FAQ · 접수 질문은 $0.0002 이하로 해 주세요. 답의 내용은 지금과 같아야 합니다." + +```bash +uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| 분류 | 규칙(Code)으로: 질문에 `T-숫자 4개`면 ticket, FAQ 검색 결과가 있으면 faq, 둘 다 아니면 unknown | +| LLM 분류 | unknown일 때만 `Classify`(nano) | +| 검토 | 뺀다 (답 단계의 지시문이 이미 말투를 정함) | +| 기대 결과 | `cases/lab02-challenge.json` 7개, `lab02` · `lab01` · `lab01-challenge`도 | + +### 이해하기 + +**가장 싼 호출은 하지 않은 호출입니다.** 예제 1 · 2로 호출당 토큰과 단가를 줄였습니다. 남은 것은 **호출 수**입니다. 72일차 성능 튜닝에서 LLM을 줄인 방법 — 규칙으로 정할 수 있는 것은 규칙으로, 코드로 할 수 있는 검사는 코드로 — 이 비용에서도 그대로 통합니다. + +```mermaid +flowchart LR + T["Get ticket"] --> R["Rule classify
(Code, 0원)"] --> K{"unknown?"} + K -->|아니오 · 대부분| A{{"Answer (mini)"}} + K -->|예| C{{"Classify (nano)"}} --> P["Parse category"] --> A + A --> B["Build result
지나간 단계만 셈"] +``` + +**규칙이 모르는 것만 LLM에게.** 질문에 `T-1002`가 있으면 접수 질문이 분명하고, FAQ 검색에 걸리면 FAQ 질문입니다. 이 둘이 아닐 때만 LLM에게 묻습니다. 대부분의 질문은 LLM을 한 번(답)만 부르고, 규칙이 모르는 질문만 두 번입니다. + +**검토 단계를 빼도 되나.** 검토는 "합니다체 · 3문장 · 데이터에 없는 말 빼기"를 했습니다. 답 단계의 지시문이 이미 같은 규칙을 말합니다. 같은 일을 두 번 시키는 데 돈을 쓰고 있었는지, 빼고 나서 품질 테스트로 확인합니다. 검토가 정말 필요하다면 LLM 대신 **코드로 검사**할 수 있는 것(길이 · 금지어)부터 찾습니다 (69일차 검토자 Code). + +**지나가지 않은 노드는 세지 않습니다.** 규칙으로 분류하면 `Classify`가 실행되지 않습니다. `Build result`가 그 노드를 읽으면 오류가 납니다. 실행됐는지 먼저 보고 읽게 합니다 (AGENTS.md 5절 "그 밖"). + +**테스트 케이스.** + +| 케이스 | 기대 | +| --- | --- | +| FAQ 넷 · 접수 둘 | `llm_calls: 1`, `cost_usd` ≤ 0.0002, 답에 핵심 낱말 | +| 규칙으로 모르는 질문 (회의실 화면 공유) | `llm_calls: 2` (분류 + 답) | + +**기대 결과.** 고치기 전(예제 2 정답): 일곱 케이스 모두 ✘ (호출 3번, 비용이 상한을 넘는 것이 대부분). 고친 뒤 7/7, `lab02` 5/5, `lab01` 3/3, `lab01-challenge` 4/4. + +
▶ 1단계 · 어느 것을 없애나 + +세 단계 중 규칙으로 바꿀 수 있는 것은? 다른 단계가 이미 하고 있는 일을 하는 단계는? 남는 하나가 정말 LLM이 필요한 단계입니다. +
+ +
▶ 2단계 · 확인 순서 + +`lab02-challenge` → `lab02` → `lab01` → `lab01-challenge`. 마지막으로 `flow.py usage`로 기록이 그대로 남는지 (단계가 줄었으니 `calls`도 줄어야 합니다). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 규칙 분류의 세 갈래 ② LLM 분류를 부르는 조건 ③ 뺄 단계 ④ 답 글자를 가져올 곳 ⑤ 지나가지 않은 노드 다루기 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Get ticket 뒤에 Rule classify(Code) 를 넣어 질문에 T-숫자 4개가 있으면 'ticket', FAQ 검색 결과가 있으면 ' ____① ', 둘 다 아니면 'unknown'. +Known?(If) 로 unknown 일 때만 Classify(nano) → Parse category → Answer, 아니면 바로 Answer. +____② 단계는 빼고, 답은 ____③ 의 글을 그대로 쓴다. +Build result 는 지나간 LLM 단계만 세어 llm_calls 와 비용을 (지나가지 않은 노드는 ____④ 로 확인). +기대 결과 cases/lab02-challenge.json, cases/lab02.json, cases/lab01.json, cases/lab01-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① 분류 세 낱말 중 FAQ 검색에 걸린 질문의 낱말입니다. +- ② 답 단계의 지시문이 이미 하고 있는 일을 다시 하는 단계입니다. +- ③ 이제 LLM 답을 만드는 단 하나의 단계입니다. +- ④ AGENTS.md 5절 "그 밖"의 n8n 표현입니다 (`$('노드').____`). + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab02-challenge.json -v +uv run tools/flow.py test cases/lab02.json +uv run tools/flow.py test cases/lab01.json +uv run tools/flow.py test cases/lab01-challenge.json +``` + +### 실행 결과 · LLM 한 번 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (예제 2 정답, `test cases/lab02-challenge.json -v` 결과 줄) + +```text +✘ faq-vpn · LLM 한 번 · $0.0002 이하 · 3.0s · 자동 답변 · 3 calls · $0.000278 · llm_calls = 3 (expected 1); cost_usd = 0.00027815 (expected 1e-06..0.0002) +✘ faq-password · LLM 한 번 · $0.0002 이하 · 3.6s · 자동 답변 · 3 calls · $0.000241 · llm_calls = 3 (expected 1); cost_usd = 0.0002407 (expected 1e-06..0.0002) +✘ faq-printer · LLM 한 번 · $0.0002 이하 · 3.5s · 자동 답변 · 3 calls · $0.000267 · llm_calls = 3 (expected 1); cost_usd = 0.0002674 (expected 1e-06..0.0002) +✘ faq-mail · LLM 한 번 · $0.0002 이하 · 3.5s · 자동 답변 · 3 calls · $0.000297 · llm_calls = 3 (expected 1); cost_usd = 0.00029665 (expected 1e-06..0.0002) +✘ ticket-own · LLM 한 번 · $0.0002 이하 · 4.0s · 자동 답변 · 3 calls · $0.000238 · llm_calls = 3 (expected 1); cost_usd = 0.000238 (expected 1e-06..0.0002) +✘ ticket-done · LLM 한 번 · $0.0002 이하 · 3.2s · 자동 답변 · 3 calls · $0.000164 · llm_calls = 3 (expected 1) +✘ 규칙으로 모르는 질문은 분류 + 답 · 3.1s · 자동 답변 · 3 calls · $0.000144 · llm_calls = 3 (expected 2) +Passed 0/7 +``` + +고친 뒤 (결과 줄, 앞의 테스트) + +```text +✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 2.8s · 자동 답변 · 1 call · $0.000156 +✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000138 +✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000150 +✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.5s · 자동 답변 · 1 call · $0.000166 +✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000115 +✔ ticket-done · LLM 한 번 · $0.0002 이하 · 0.9s · 자동 답변 · 1 call · $0.000108 +✔ 규칙으로 모르는 질문은 분류 + 답 · 1.5s · 자동 답변 · 2 calls · $0.000097 +Passed 7/7 + +lab02 Passed 5/5 · lab01 Passed 3/3 · lab01-challenge Passed 4/4 (✔ 사용량 요약 · 요청 3건 · $0.000409) +``` + +**왜 이렇게 나왔나** + +- 고치기 전 일곱 모두 ✘: 모든 질문이 3 calls이고, 다섯은 비용도 $0.0002를 넘었습니다. ticket-done($0.000164)은 비용은 통과했지만 호출 수로 ✘입니다. +- 고친 뒤 FAQ · 접수 여섯은 **1 call**: 질문에 `T-1002` 같은 번호가 있거나 FAQ 검색에 걸리면 규칙이 분류해 LLM 분류를 건너뛰고, 검토 단계를 뺐기 때문입니다. +- 회의실 질문은 규칙이 모르므로 작은 모델 분류 + 답으로 **2 calls**입니다. 그래도 $0.000097로 가장 쌉니다 (FAQ · 접수 글을 거의 보내지 않음). +- 호출이 줄어 시간도 3초대 → 1초대입니다. 기록된 질문 3개의 합계는 Lab 01 때 $0.002951에서 **$0.000409**로 약 7분의 1입니다. + +## 과제 2 · 아주 긴 질문에도 비용 상한 (12분) + +> "어떤 직원이 프린터 로그를 통째로 붙여 넣고 '출력이 안 돼요'라고 물었는데, 그 한 번이 평소 질문의 일곱 배 넘게 들었어요. 누가 일부러 아주 길게 보내면 예산이 금방 바닥나겠어요. 긴 질문도 비용이 정해진 만큼만 들게 해 주세요." + +```bash +uv run tools/flow.py call my-cost requests/long-printer.json # 로그 6,000자 + 끝에 질문 +uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전 +``` + +**만들 것**: 질문이 정해진 글자 수(`max_question_chars`, 사람이 `/budget`에서 정함, 기본 500)보다 길면 **앞 40% + 뒤 60%만** 남기고 가운데를 줄여 LLM에 보내기. + +| 조건 | 값 | +| --- | --- | +| 상한 | `GET /budget`의 `max_question_chars` (워크플로우에 숫자를 쓰지 않음) | +| 줄이는 법 | 앞 `floor(상한 × 0.4)`자 + `' … '` + 뒤 나머지 글자 | +| 쓰는 곳 | `Search FAQ`의 `q`, `Rule classify`, `Answer` 모두 줄인 질문 | +| 결과 | 과제 1 결과 + `trimmed` (true/false) + `question_chars` (원래 글자 수) | +| 기대 결과 | `cases/lab02-challenge2.json` 4개, `lab02-challenge`도 그대로 | + +### 이해하기 + +**입력 길이에 비례하는 비용은 누가 정하나.** 비용은 보낸 토큰에 비례합니다. 질문 길이에 상한이 없으면 **한 요청의 비용에도 상한이 없습니다.** 실수(로그 붙여 넣기)든 일부러(73일차 "아주 긴 입력으로 괴롭히기")든, 한 사람이 예산을 혼자 다 쓸 수 있습니다. 예제 1의 `max_completion_tokens`가 **출력**의 상한이었다면, 이것은 **입력**의 상한입니다. 둘이 다 있어야 "한 요청은 최대 얼마"를 말할 수 있습니다. + +```mermaid +flowchart LR + Q(["질문 6,000자"]) --> B["Get budget
max_question_chars 500"] --> P["Prepare question
앞 200자 … 뒤 300자"] + P --> F["Search FAQ"] --> R["Rule classify"] --> A{{"Answer"}} + P -.->|trimmed true · question_chars 6298| O(["결과"]) +``` + +**왜 앞과 뒤를 남기나.** 사람은 질문을 **앞에** 쓰고 자료를 붙이거나 ("VPN이 끊겨요. 로그는 아래…"), 자료를 붙이고 **끝에** 묻습니다 ("…이런 로그가 나와요. 출력이 안 돼요"). 가운데는 대개 반복되는 로그입니다. 그래서 앞과 뒤를 남깁니다. 테스트 파일에 두 모양이 다 있습니다. + +**다른 방법과 비교.** + +| 방법 | 비용 | 사용자 | 오늘 | +| --- | --- | --- | --- | +| 거절 (HTTP 413 "너무 김") | 0 | 다시 써야 함 | | +| 앞뒤만 남기기 | 상한 안 | 대개 답을 받음 | ✔ | +| LLM으로 요약한 뒤 답 | 요약 호출이 또 듦 (긴 글 그대로) | 답을 받음 | | + +**상한 값은 사람이 정합니다.** 500자는 연습용입니다. 너무 작으면 정상 질문도 잘리고, 너무 크면 상한의 뜻이 없습니다. `flow.py budget --chars 2000`처럼 사람이 바꿉니다. 테스트 마지막 케이스가 "사람이 상한을 늘리면 자르지 않는다"를 확인합니다. + +**테스트 케이스.** + +| 케이스 | 보내는 것 | 기대 | +| --- | --- | --- | +| 긴 로그 + 끝에 질문 · 프린터 | 6,298자 | `trimmed` true, `llm_calls` 1, `cost_usd` ≤ 0.0003, "대기열" | +| 앞에 질문 + 긴 로그 · VPN | 6,103자 | 같은 기대, "인증서" | +| 짧은 질문은 그대로 | VPN 24자 | `trimmed` false, `question_chars` 24, "인증서" | +| 사람이 상한을 늘리면 자르지 않음 | `before`가 상한 10000, 프린터 로그 | `trimmed` false, "대기열" | + +긴 질문의 상한이 $0.0003인 까닭: 질문을 500자로 줄여도 FAQ · 답(출력)의 비용은 그대로 듭니다. 줄인 질문 500자만큼이 짧은 질문보다 더 듭니다. + +**기대 결과.** 고치기 전(과제 1 정답): 긴 질문 두 케이스가 비용 상한을 넘고 (`trimmed`도 없음), 나머지 두 케이스도 `trimmed` · `question_chars`가 없어 ✘ (0/4). 고친 뒤 4/4, `lab02-challenge` 7/7. `COST.md` 5절에 긴 로그 질문의 비용 전 · 후. + +
▶ 1단계 · 얼마나 드나 + +`call my-cost requests/long-printer.json`의 `usage.by_step.answer.prompt_tokens`와 `cost_usd`를 짧은 질문(`faq-printer`)과 비교합니다. 몇 배인가요? +
+ +
▶ 2단계 · 상한은 어디서 + +예제 · 과제마다 "바뀌는 값은 밖에서 읽기"였습니다. 오늘 사람이 정하는 값(예산 · 한도 · 글자 수)은 모두 `GET /budget`에 있습니다 (`curl -s localhost:8800/budget`). Lab 03 예제 1에서도 같은 노드를 씁니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 상한을 읽는 곳 ② 줄이는 법 ③ 줄인 질문을 쓸 곳 ④ 결과 칸 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Has question? 뒤에 Get budget(HTTP GET http://data-api:8800/budget) 을, Get prices 뒤에 Prepare question(Code) 을 넣어 줘. +질문이 Get budget 의 ____① 보다 길면 앞 40% 와 뒤 ____② % 만 남기고 가운데를 ' … ' 로 줄여 줘. +Search FAQ 의 q, Rule classify, Answer 는 모두 원래 질문 대신 ____③ 의 question 을 쓰게. +결과에 trimmed(true/false) 와 ____④ (원래 글자 수) 를 더해 줘. +기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① `curl -s localhost:8800/budget`에서 글자 수 상한을 뜻하는 칸입니다. +- ② 앞이 40%면 뒤는? +- ③ 줄인 질문을 만드는 새 노드의 이름입니다. +- ④ `cases/lab02-challenge2.json`의 "짧은 질문은 그대로" 케이스가 보는 칸입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab02-challenge2.json -v +uv run tools/flow.py test cases/lab02-challenge.json +``` + +### 실행 결과 · 긴 질문의 비용 상한 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (과제 1 정답, `test cases/lab02-challenge2.json -v` 결과 줄) + +```text +✘ 긴 로그 + 끝에 질문 · 프린터 · 1.3s · 자동 답변 · 1 call · $0.001196 · trimmed = null (expected true); cost_usd = 0.001196 (expected 1e-06..0.0003) +✘ 앞에 질문 + 긴 로그 · VPN · 1.4s · 자동 답변 · 1 call · $0.001099 · trimmed = null (expected true); cost_usd = 0.0010992 (expected 1e-06..0.0003) +✘ 짧은 질문은 그대로 · 1.2s · 자동 답변 · 1 call · $0.000156 · trimmed = null (expected false); question_chars = null (expected 24) +✘ 사람이 한도를 늘리면 자르지 않음 · 1.2s · 자동 답변 · 1 call · $0.001196 · trimmed = null (expected false) +Passed 0/4 +``` + +고친 뒤 + +```text +✔ 긴 로그 + 끝에 질문 · 프린터 · 2.3s · 자동 답변 · 1 call · $0.000237 +✔ 앞에 질문 + 긴 로그 · VPN · 1.1s · 자동 답변 · 1 call · $0.000260 +✔ 짧은 질문은 그대로 · 1.1s · 자동 답변 · 1 call · $0.000141 +✔ 사람이 한도를 늘리면 자르지 않음 · 1.3s · 자동 답변 · 1 call · $0.001196 +Passed 4/4 + +lab02-challenge Passed 7/7 +``` + +**왜 이렇게 나왔나** + +- 고치기 전 긴 로그 질문은 **$0.001196** — 같은 프린터 질문을 짧게 물을 때($0.000150)의 약 8배입니다. 6,000자 넘는 로그가 그대로 FAQ 검색 · 답 단계에 들어갔기 때문입니다. +- 고친 뒤 **$0.000237 · $0.000260**: 질문을 앞 200자 + 뒤 300자로 줄여 보냈습니다. 프린터는 질문이 끝에, VPN은 앞에 있어서 둘 다 남았고, 답에 "대기열" · "인증서"가 그대로 나왔습니다. +- 그래도 짧은 질문($0.000141 ~ 0.000156)보다 비싼 것은 줄인 500자만큼의 입력과, 답(출력) · FAQ 몫이 그대로 들기 때문입니다. 그래서 기대가 $0.0002가 아니라 $0.0003입니다. +- 마지막 케이스는 `before`가 상한을 10000으로 늘려 자르지 않았고, 비용도 고치기 전과 같은 $0.001196입니다. 상한 값을 **사람이** 정한다는 것을 보여 줍니다. 테스트의 `after`가 상한을 500으로 되돌립니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| 줄였더니 답이 짧게 잘림 | `max_completion_tokens`가 너무 작음, 또는 지시문의 "3문장 이내"와 맞지 않음 | +| OpenAI 400 `Unsupported parameter: 'max_tokens'` | `max_completion_tokens`로 (`check`가 알려 줌) | +| nano 단계 비용이 mini로 계산됨 | `Build result`의 단계 목록 모델 이름 | +| 접수 답에 "처리 중"이 없음 | 줄인 필드에 `status` · `note`가 남아 있는지 | +| 규칙으로 건너뛴 단계에서 오류 | 지나가지 않은 노드는 `isExecuted`로 확인 | +| 긴 질문이 그대로 감 | `Search FAQ` · `Answer`가 원래 질문(`body.question`)을 읽고 있지 않은지 | +| 테스트 뒤 상한이 그대로 10000 | `uv run tools/flow.py budget --reset` | +| 계속 안 됨 | `reference/lab02-example1-lean.json`, `reference/lab02-example2-models.json` | + +## 확인 + +- [ ] 예제 1: `cases/lab02.json` 앞 세 케이스, `answer` 입력 토큰 전 · 후. +- [ ] 예제 2: `cases/lab02.json` 5/5, `lab01`도 통과. +- [ ] 예제 3: `nano-answer`의 통과 수 · 평균 비용 · 답을 읽고 본 것, 바꿀지 결정과 까닭. +- [ ] 과제 1: `cases/lab02-challenge.json` 7/7, `lab02` · `lab01` · `lab01-challenge` 통과. +- [ ] 과제 2: `cases/lab02-challenge2.json` 4/4, 긴 로그 질문 비용 전 · 후. +- [ ] `COST.md` 5절 (잃은 것도). diff --git a/Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md b/Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md new file mode 100644 index 0000000..2474194 --- /dev/null +++ b/Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md @@ -0,0 +1,606 @@ +# Lab 03 · 하루 예산, 사용자별 한도, 비용 보고서, 바꾸면 얼마 + +- 관련 강의: [3단계 · 예산과 한도](../../Lecture/75/03-STAGE3-BUDGET-AND-LIMITS.md) +- 소요 시간: 50분 (6교시 한 시간) · 예제 1 10분 · 예제 2 10분 · 예제 3 7분 · 과제 1 13분 · 과제 2 10분 +- 준비: Lab 02의 `my-work/cost.json` (없으면 `cp reference/lab02-example2-models.json my-work/cost.json` 후 push. LLM 한 번 · 긴 질문 상한은 Lab 02 과제를 끝낸 것) +- 목표: 비용이 **넘치지 않게** 한다. 하루 예산을 넘으면 LLM 없이 답하고, 한 사람이 몰아서 부르면 잠시 막고, 예산을 바꿔 가며 언제 멈추는지 보고, 지금까지의 사용량으로 한 달 비용과 "모델을 바꾸면 얼마"를 계산한다. + +```mermaid +flowchart LR + E1["예제 1
하루 예산 →
한도 초과 원문"] --> E2["예제 2
1분 한도 → 429"] --> E3["예제 3
예산을 바꿔 가며"] --> T1["과제 1
비용 보고서"] --> T2["과제 2
바꾸면 얼마
(what-if)"] +``` + +> 예산 · 한도 값은 **사람이** 정합니다 (`uv run tools/flow.py budget --daily 0.5`). 테스트 파일은 `before`로 바꾸고 `after`로 처음 상태(`/budget/reset`, `/usage-clear`)로 되돌립니다. 손으로 바꿨다면 끝에 `uv run tools/flow.py budget --reset`. + +## 예제 1 · 하루 예산 (10분) + +```bash +uv run tools/flow.py budget # daily_usd · used_usd · over +uv run tools/flow.py test cases/lab03.json -v # 고치기 전 +``` + +**만들 것**: LLM을 부르기 전에 오늘 예산을 보고, 넘었으면 **LLM 없이** 데이터 그대로 답하기. + +| 조건 | 값 | +| --- | --- | +| 확인 | `Get budget`(Lab 02 과제 2에서 넣은 노드)의 `over` | +| 넘었을 때 | `route: 한도 초과`, `llm_calls: 0`, `cost_usd: 0`, 접수 건이면 상태 · 메모, FAQ면 첫 FAQ 원문 + "(오늘 자동 답변 한도를 넘어 … 안내합니다.)" | +| 기대 결과 | `cases/lab03.json` | + +### 이해하기 + +**예산은 "얼마까지 쓰나"를 사람이 정한 것입니다.** Lab 01 과제에서 기록을 쌓았으니, 데이터 API는 오늘 쓴 돈(`used_usd` = 기록된 `cost_usd`의 합)을 압니다. 사람이 정한 하루 예산(`daily_usd`)을 넘으면 `over: true`. 워크플로우는 LLM을 부르기 **전에** 이것을 봅니다. + +```mermaid +flowchart LR + T["Get ticket"] --> W{"Within budget?
over = false"} + W -->|예| R["Rule classify → Answer
(평소대로)"] + W -->|아니오| O["Over budget
LLM 없이 원문"] --> S(["한도 초과 · $0"]) +``` + +**멈추는 대신 낮춰서 답합니다.** 예산을 넘었다고 "오늘은 끝"이라고 하면 직원은 아무 도움도 못 받습니다. FAQ 원문과 접수 상태는 **데이터 API가 이미 갖고 있어서** LLM 없이도 줄 수 있습니다. 문장이 덜 다듬어졌을 뿐 내용은 같습니다. 이것을 **우아한 저하(graceful degradation)**라고 합니다. + +**72일차의 대체 답과 무엇이 다른가.** + +| | 72일차 | 오늘 | +| --- | --- | --- | +| 언제 | LLM이 **멈췄을 때** | LLM은 멀쩡한데 **쓰지 않기로** 했을 때 | +| 누가 정하나 | 장애가 | 사람이 정한 예산이 | +| `route` | `간단 답변` | `한도 초과` | + +같은 모양의 Code를 다시 쓸 수 있지만 `route`는 다르게 둡니다. 나중에 로그를 보는 사람이 "장애였나, 예산이었나"를 가를 수 있어야 하기 때문입니다. + +**테스트 케이스.** + +| 케이스 | `before` | 기대 | +| --- | --- | --- | +| 한도를 넘으면 · FAQ 원문 | 예산 $0.001, 이미 $0.002 쓴 기록 한 줄 | `한도 초과`, `cost_usd` 0, `llm_calls` 0, "인증서" | +| 한도를 넘으면 · 접수 원문 | (같은 상태) | `한도 초과`, "처리 중" | +| 한도 안이면 평소대로 | 처음 상태로 | `자동 답변`, 비용 $0.0002 이하 | + +**기대 결과.** 고치기 전: 넘은 상태에서도 `자동 답변`이라 앞 두 케이스 ✘. 고친 뒤 3/3. + +
▶ 1단계 · 어디에 두나 + +예산 확인은 LLM보다 **앞에**, FAQ · 접수 조회보다는 **뒤에** 둡니다. 왜일까요? (원문으로 답하려면 무엇이 먼저 있어야 하나요?) +
+ +
▶ 2단계 · 예산을 어디서 세나 + +`used_usd`는 기록된 `cost_usd`의 합입니다 (Lab 01 과제). Lab 01 과제 2의 `recorded: false`인 요청은 여기에 들어가지 않습니다. 기록이 빠질수록 예산은 실제보다 남아 보입니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 넣을 곳과 보는 값 ② 넘었을 때 답의 재료 ③ 결과의 route · 호출 수 · 비용 ④ 평소 갈래는 그대로 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Get ticket 뒤에 Within budget?(If) 을 넣어 Get budget 의 ____① 이 false 일 때만 Rule classify 로, 넘었으면 Over budget(Code) → Send over budget. +Over budget 은 LLM 없이: 접수 건이 있으면 그 상태 · 메모, FAQ 가 있으면 첫 FAQ 의 ____② 원문에 "(오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)" 를 붙여. +결과는 route ' ____③ ', llm_calls 0, cost_usd ____④ , usage 의 토큰도 0. +기대 결과 cases/lab03.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① `uv run tools/flow.py budget -v`의 응답에서 넘었는지를 true/false로 주는 칸입니다. +- ② FAQ 한 항목에서 답 글자가 든 칸입니다 (Lab 02 예제 1에서 남긴 세 칸 중 하나). +- ③ AGENTS.md 3절 `route` 세 가지 중 예산을 넘었을 때의 것입니다. +- ④ LLM을 부르지 않았으니? + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab03.json -v +``` + +### 실행 결과 · 하루 예산 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (Lab 02 과제 2 정답, `budget` · `test cases/lab03.json -v` 결과 줄) + +```text +budget $1.0 / used $0.006822 · remaining $0.993178 · over False · per user 20/min · question 500 chars +✘ 한도를 넘으면 · FAQ 원문 · 1.1s · 자동 답변 · 1 call · $0.000154 · route = 자동 답변 (expected 한도 초과); cost_usd = 0.000154 (expected 0); llm_calls = 1 (expected 0) +✘ 한도를 넘으면 · 접수 원문 · 1.2s · 자동 답변 · 1 call · $0.000115 · route = 자동 답변 (expected 한도 초과); cost_usd = 0.0001152 (expected 0) +✔ 한도 안이면 평소대로 · 1.4s · 자동 답변 · 1 call · $0.000141 +Passed 1/3 +``` + +고친 뒤 + +```text +✔ 한도를 넘으면 · FAQ 원문 · 1.0s · 한도 초과 · 0 calls · $0.000000 +✔ 한도를 넘으면 · 접수 원문 · 0.2s · 한도 초과 · 0 calls · $0.000000 +✔ 한도 안이면 평소대로 · 1.6s · 자동 답변 · 1 call · $0.000156 +Passed 3/3 +``` + +**왜 이렇게 나왔나** + +- 첫 `budget` 줄의 `used $0.006822`는 앞 실습에서 쌓인 기록입니다. 테스트의 `before`가 기록을 비우고 예산을 $0.001로, 이미 $0.002를 쓴 한 줄을 넣어 "넘은 상태"를 만듭니다. +- 고치기 전 앞 두 케이스 ✘: 넘은 상태인데도 `자동 답변`으로 LLM을 불렀습니다 (1 call · $0.000154). 워크플로우가 예산을 보지 않기 때문입니다. +- 고친 뒤 **`한도 초과 · 0 calls · $0`**: LLM 앞에서 `over`를 보고, FAQ 원문 · 접수 상태를 그대로 보냈습니다. LLM을 부르지 않아 시간도 0.2 ~ 1.0초입니다. +- 세 번째 케이스는 `before`가 처음 상태로 되돌려 평소대로 답합니다 ($0.000156). 테스트의 `after`가 예산 · 기록을 처음 상태로 되돌립니다. + +## 예제 2 · 사용자별 1분 한도 (10분) + +```bash +uv run tools/flow.py test cases/lab03-limit.json -v # 고치기 전 +``` + +**만들 것**: 같은 사람이 1분에 `user_limit_per_min`번을 넘게 부르면 HTTP 429로 잠시 막기. 다른 사람은 그대로. + +| 조건 | 값 | +| --- | --- | +| 센다 | `GET /usage/user/<이름>?window=60`의 `count` (기록된 요청 수) | +| 넘었을 때 | HTTP 429 `{route: 잠시 후, answer, retry_after: 60, llm_calls: 0, cost_usd: 0}` | +| 기대 결과 | `cases/lab03-limit.json` (한도 2: 이준호 세 번째가 429), `lab03` · `lab02-challenge`도 | + +### 이해하기 + +**하루 예산은 모두의 것입니다.** 한 사람이 스크립트로 1분에 수백 번 부르거나, 같은 질문을 계속 고쳐 묻으면 하루 예산이 오전에 바닥납니다. 그러면 나머지 직원은 오후 내내 `한도 초과` 원문만 받습니다. 그래서 **사람마다** 짧은 시간의 한도를 둡니다. 이것을 **속도 제한(rate limiting)**이라고 합니다. + +```mermaid +flowchart LR + Q(["질문 · user"]) --> B["Get budget"] --> C["Recent requests
최근 60초 · 그 사람"] --> L{"count <
user_limit_per_min?"} + L -->|예| N["Get prices → … (평소대로)"] + L -->|아니오| X(["429 잠시 후
LLM · 조회 없이 0.0초"]) +``` + +**429는 "잠시 후 다시"라는 약속입니다.** HTTP 429 `Too Many Requests`는 "너무 자주 부른다"는 표준 응답이고, `retry_after`(또는 `Retry-After` 헤더)로 몇 초 뒤에 다시 부르면 되는지 알려 줍니다. 부르는 쪽 프로그램은 이것을 보고 기다렸다가 다시 시도할 수 있습니다 (72일차 재시도). + +**가장 앞에 둡니다.** 한도를 넘은 요청에는 FAQ · 접수 조회도, LLM도 할 까닭이 없습니다. 맨 앞에서 바로 돌려보내면 **0원 · 0.0초**입니다. 예제 1의 예산 확인이 "조회 뒤, LLM 앞"이었던 것과 다릅니다. 원문 답이 필요 없기 때문입니다. + +**고정 창과 이동 창.** "최근 60초 동안 몇 번"을 세는 방식을 **이동 창(sliding window)**이라고 합니다. "매 분 0초부터 몇 번"은 고정 창입니다. 고정 창은 59초와 61초에 몰아서 부르면 짧은 사이에 두 배를 부를 수 있습니다. 데이터 API의 `/usage/user/<이름>?window=60`은 이동 창으로 셉니다. + +**테스트 케이스.** + +| 케이스 | 기대 | +| --- | --- | +| 이준호 1번째 | 200 | +| 이준호 2번째 (다른 질문) | 200 | +| 이준호 3번째 · 1분에 2번 넘음 | 429, `잠시 후`, `cost_usd` 0, **1초 안** | +| 다른 사람은 그대로 (한지훈) | 200 | + +`before`가 한도를 2로 낮추고, `after`가 처음 상태(한도 20)로 되돌립니다. + +**기대 결과.** 고치기 전: 세 번째 케이스 ✘ (200). 고친 뒤 4/4, `lab03` 3/3, `lab02-challenge` 7/7. + +
▶ 1단계 · 기록이 있어야 셉니다 + +`count`는 **기록된** 요청 수입니다. 이준호의 1 · 2번째 요청이 `/usage`에 기록되지 않으면 세 번째도 통과합니다. `uv run tools/flow.py usage`로 이준호의 줄이 쌓이는지 봅니다. +
+ +
▶ 2단계 · 한도의 값 + +1분 2번은 연습용입니다. 실제로는 몇 번이 좋을까요? 사람이 한 질문을 고쳐 다시 묻는 경우, 여러 사람이 공용 계정 하나로 묻는 경우도 생각합니다. `COST.md` 6절에 까닭과 함께 적습니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 세는 창의 길이 ② 비교할 한도 ③ 넘었을 때의 응답 코드와 본문 ④ 그 갈래에서 하지 않는 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost.json 의 Get budget 바로 뒤(가장 앞)에 Recent requests(HTTP GET http://data-api:8800/usage/user/?window= ____① ) 를 넣어 줘. +Under user limit?(If) 로 count 가 Get budget 의 ____② 보다 작으면 Get prices 로, 아니면 Too many(Respond, HTTP ____③ ). +Too many 의 본문은 {route: '잠시 후', answer, retry_after: 60, llm_calls: 0, cost_usd: 0}. 이 갈래는 FAQ · 접수 조회도 ____④ . +기대 결과 cases/lab03-limit.json, cases/lab03.json, cases/lab02-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① "1분"을 초로. +- ② `uv run tools/flow.py budget`에 나오는 "per user …/min"의 칸 이름입니다 (`-v`로 JSON). +- ③ "너무 자주 부른다"는 표준 응답 코드입니다. +- ④ 맨 앞에서 돌려보내는 까닭입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost.json +uv run tools/flow.py test cases/lab03-limit.json -v +uv run tools/flow.py test cases/lab03.json +uv run tools/flow.py test cases/lab02-challenge.json +``` + +### 실행 결과 · 사용자별 1분 한도 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (예제 1 정답, `test cases/lab03-limit.json -v` 결과 줄) + +```text +✔ 이준호 1번째 · 1.3s · 자동 답변 · 1 call · $0.000156 +✔ 이준호 2번째 · 1.6s · 자동 답변 · 1 call · $0.000152 +✘ 이준호 3번째 · 1분에 2번 넘음 · 1.3s · 자동 답변 · 1 call · $0.000156 · took 1.3s (expected at most 1s); status 200 (expected 429); route = 자동 답변 (expected 잠시 후); cost_usd = 0.0001556 (expected 0) +✔ 다른 사람은 그대로 · 1.2s · 자동 답변 · 1 call · $0.000136 +Passed 3/4 +``` + +고친 뒤 (결과 줄, 앞의 테스트) + +```text +✔ 이준호 1번째 · 2.1s · 자동 답변 · 1 call · $0.000141 +✔ 이준호 2번째 · 1.6s · 자동 답변 · 1 call · $0.000150 +✔ 이준호 3번째 · 1분에 2번 넘음 · 0.0s · 잠시 후 · 0 calls · $0.000000 +✔ 다른 사람은 그대로 · 1.9s · 자동 답변 · 1 call · $0.000136 +Passed 4/4 + +lab03 Passed 3/3 · lab02-challenge Passed 7/7 +``` + +**왜 이렇게 나왔나** + +- `before`가 한도를 2로 낮췄습니다. 고치기 전 이준호의 세 번째도 200 · 1.3초 · 돈을 썼습니다. 한도를 보지 않기 때문입니다. +- 고친 뒤 세 번째는 **429 · 잠시 후 · 0.0초 · $0**: 가장 앞에서 "이준호의 최근 60초 요청 수(2) ≥ 한도(2)"를 보고 바로 돌려보냈습니다. FAQ · 접수 조회도 LLM도 하지 않아 0.0초입니다. +- 1 · 2번째가 셀 수 있었던 것은 Lab 01 과제의 **기록** 덕분입니다. 기록이 없으면 세 번째도 통과합니다. +- 한지훈은 따로 세므로 그대로 200입니다. 앞에 노드를 더해도 `lab03` · `lab02-challenge`가 그대로라 평소 동작은 바뀌지 않았습니다. + +## 예제 3 · 예산을 바꿔 가며 보기 (7분, 에이전트 없이) + +예산을 아주 작게 정하고 질문 여섯 개를 차례로 보냅니다. 몇 번째부터 멈추고, 멈출 때까지 얼마를 썼나요? + +```bash +uv run tools/flow.py budget --daily 0.0003 # 사람이 예산을 정함 +uv run tools/flow.py test cases/lab03-drill.json # 여섯 질문, 줄마다 route · 호출 수 · 비용 +uv run tools/flow.py usage # 쓴 돈 · 예산 · route별 +uv run tools/flow.py budget --reset # 처음 상태 ($1.0) +uv run tools/flow.py test cases/lab03-drill.json # 같은 여섯 질문을 다시 +uv run tools/flow.py usage --clear +``` + +`COST.md` 6절 "예제 3"에 몇 번째부터 `한도 초과`였는지, 예산을 얼마나 넘었는지 적습니다. + +### 이해하기 + +**예산은 넘은 뒤에 멈춥니다.** 예제 1의 워크플로우는 "지금까지 쓴 돈이 예산 이상이면" 멈춥니다. 예산이 조금 남아 있으면 다음 질문은 평소대로 답하고, 그 질문의 비용만큼 예산을 **넘습니다.** 그래서 실제로 쓴 돈은 "예산 + 마지막 한 건"까지 갑니다. + +```mermaid +flowchart LR + A["1번째
남음"] --> B["2번째
남음 → 넘음"] --> C["3번째부터
한도 초과 · $0"] +``` + +**넘지 않게 하려면.** "남은 금액이 한 건의 최대 비용보다 적으면 멈춘다"로 바꾸면 넘지 않습니다. 이것이 가능하려면 **한 건의 최대 비용**을 알아야 합니다. Lab 02 과제 2의 입력 상한(`max_question_chars`)과 예제 1의 출력 상한(`max_completion_tokens`)이 그것을 정합니다. 상한이 있어야 예산도 지킬 수 있습니다. + +**`한도 초과` 답은 기록에 없습니다.** `usage`의 `route`별 합계에는 `자동 답변`만 보입니다. 한도 초과 답은 LLM을 부르지 않아 기록할 호출이 없기 때문입니다. 그러면 "오늘 몇 명이 원문만 받았나"를 셀 수 없습니다. 운영에서는 이것도 세어야 예산이 적절했는지 판단합니다 (77일차 로그). + +**기대 결과.** 예산 $0.0003에서는 앞의 한두 질문만 `자동 답변`이고 나머지는 `한도 초과 · 0 calls · $0`. `usage`의 사용액이 예산을 조금 넘습니다. `--reset` 뒤에는 여섯 모두 `자동 답변`. + +
▶ 1단계 · 하루가 바뀌면 + +오늘 데이터 API의 `used_usd`는 기록 전체의 합입니다 (연습용). 실제로는 "오늘 0시부터"를 셉니다. 날짜가 바뀌면 누가 0으로 돌릴까요? 사람이 매일 `--clear`를 하면 될까요? +
+ +
▶ 2단계 · 예산은 누가 정하나 + +예산은 기술이 아니라 **결정**입니다. 한 달 예산을 22일로 나눈 값, 지난달 기록의 평균 × 1.2, 사용자 수 × 한 사람 몫 등 근거를 정해 `COST.md` 6절에 적습니다. 과제 1의 보고서가 그 근거를 줍니다. +
+ +### 실행 결과 · 예산을 바꿔 가며 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +예산 $0.0003 (`budget --daily 0.0003` → `test cases/lab03-drill.json` → `usage`) + +```text +budget $0.0003 / used $0.000934 · remaining $-0.000634 · over True · per user 20/min · question 500 chars +✔ 1번째 · VPN · 1.1s · 자동 답변 · 1 call · $0.000141 +✔ 2번째 · 프린터 · 1.0s · 자동 답변 · 1 call · $0.000152 +✔ 3번째 · 비밀번호 · 1.3s · 자동 답변 · 1 call · $0.000136 +✔ 4번째 · 메일 · 0.1s · 한도 초과 · 0 calls · $0.000000 +✔ 5번째 · 접수 · 0.1s · 한도 초과 · 0 calls · $0.000000 +✔ 6번째 · VPN 다시 · 0.0s · 한도 초과 · 0 calls · $0.000000 +Passed 6/6 + +requests 3 · calls 3 · tokens in 498 / out 144 · $0.000430 + route 자동 답변 3 calls · in 498 · out 144 · $0.000430 +budget $0.0003 / used $0.000430 · remaining $-0.000130 · over True · per user 20/min · question 500 chars +``` + +`budget --reset` ($1.0) 뒤 같은 테스트 + +```text +✔ 1번째 · VPN · 1.4s · 자동 답변 · 1 call · $0.000141 +✔ 2번째 · 프린터 · 1.2s · 자동 답변 · 1 call · $0.000152 +✔ 3번째 · 비밀번호 · 1.6s · 자동 답변 · 1 call · $0.000136 +✔ 4번째 · 메일 · 1.8s · 자동 답변 · 1 call · $0.000166 +✔ 5번째 · 접수 · 1.4s · 자동 답변 · 1 call · $0.000133 +✔ 6번째 · VPN 다시 · 1.5s · 자동 답변 · 1 call · $0.000156 +Passed 6/6 +``` + +**왜 이렇게 나왔나** + +- 첫 줄의 `used $0.000934`는 앞 단계에서 남은 기록이라 이미 넘어 보입니다. 테스트의 `before`가 기록을 비우고 시작하므로 실제로는 0부터 셉니다. +- 1 · 2번째까지 $0.000293으로 예산 $0.0003 **아래**라서 3번째도 평소대로 답했고, 그 뒤 합이 $0.000430이 되어 **예산을 $0.00013(43%) 넘었습니다.** 예산을 넘은 **뒤에** 멈추기 때문입니다. +- 4번째부터는 `한도 초과 · $0`입니다. `usage`의 route별 합계에 `자동 답변` 세 줄만 있는 것은 한도 초과 답이 LLM을 부르지 않아 기록할 호출이 없어서입니다. +- `--reset` 뒤에는 예산 $1.0이라 여섯 모두 `자동 답변`입니다. 끝나면 `usage --clear`로 기록을 비웁니다. + +## 과제 1 · 비용 보고서 워크플로우 (13분) + +> "매달 초에 이번 달 AI 비용이 얼마나 나올지 물어봐요. 지금까지 쓴 걸로 한 달 예상, 모델별 · 사람별로 누가 많이 쓰는지, 예산은 얼마나 남았는지 버튼 하나로 알려 주세요." + +```bash +uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전 (웹훅이 없음) +``` + +| 조건 | 값 | +| --- | --- | +| 파일 · 웹훅 | `my-work/cost-report.json` · `my-cost-report` (새 워크플로우, [AGENTS.md](AGENTS.md) 3절) | +| 읽는 곳 | `/usage/summary`, `/budget` | +| 계산 | Code가 (LLM 없이). 금액은 소수 6자리에서 반올림 | +| 기대 결과 | `cases/lab03-challenge.json` (`before`가 알려진 사용량 4줄을 넣음 → 숫자가 정확히 맞아야 함) | + +### 이해하기 + +**보고서의 숫자는 코드가 계산합니다.** 이 보고서에 LLM이 필요할까요? 합계 · 평균 · 곱셈은 코드가 **틀리지 않고 · 공짜로 · 늘 같게** 합니다. LLM에게 계산을 맡기면 틀릴 수도, 돈도 듭니다. LLM은 숫자를 **설명하는** 일에 씁니다 (79일차). + +```mermaid +flowchart LR + W(["POST my-cost-report
requests_per_day · workdays"]) --> S["Get summary"] --> B["Get budget"] --> M["Make report
(Code)"] --> R(["보고서 JSON"]) +``` + +**계산은 넷입니다.** + +```text +avg_usd = cost_usd ÷ requests (요청 수. calls 아님) +projected_month_usd = avg_usd × requests_per_day × workdays (requests_per_day가 없으면 지금 요청 수, workdays 기본 22) +top_user = by_user 에서 cost_usd 가 가장 큰 사람 +remaining_usd = 예산 − 쓴 돈 (/budget) +``` + +**먼저 손으로 계산합니다 (3분).** `cases/lab03-challenge.json`의 `before`가 넣는 네 줄을 열어 봅니다. 요청은 몇 개(`request_id`가 다른 것)? 호출은? 비용 합은? 하루 100건 · 22일이면 한 달은? 가장 많이 쓴 사람은? 손 계산이 테스트의 기대값과 맞으면, 에이전트에게 무엇을 요청할지 분명해집니다. + +**정확한 숫자를 테스트합니다.** 지금까지의 테스트는 LLM 답이라 `contains`(낱말) · `range`(범위)였습니다. 이 보고서는 입력이 정해져 있으면 결과도 **정확히** 하나입니다. 그래서 `equals`로 소수점까지 봅니다. 0.0019890000001처럼 나오면 실패하므로 반올림 자리를 계약대로 맞춥니다. + +**테스트 케이스.** + +| 케이스 | 기대 | +| --- | --- | +| 요약 숫자 | `requests`, `calls`, `cost_usd`, `avg_usd`, `top_user` | +| 모델별 · 한 달 예상 | `projected_month_usd`, `by_model`의 mini 몫 | +| 작은 모델 몫 | `by_model`의 nano 몫 | +| 예산 남은 금액 | `daily_budget_usd`, `remaining_usd`, `over_budget` false | +| 예산을 넘으면 | `before`가 예산을 $0.001로 → `over_budget` true | + +**기대 결과.** 고치기 전: 웹훅이 없어 5개 모두 ✘ (404). 고친 뒤 5/5. 그다음 실제 사용량으로 `call my-cost-report '{"requests_per_day": 1000}'`. + +
▶ 1단계 · LLM이 필요한가 + +필요 없습니다. Code 노드 하나가 두 API의 응답을 읽어 계산합니다. LLM 노드를 넣으면 테스트의 `equals`가 흔들립니다. +
+ +
▶ 2단계 · 숫자가 조금 다르면 + +`requests`(요청 수)와 `calls`(호출 수)를 헷갈리면 평균이 크게 달라집니다. 반올림을 평균에 먼저 하고 곱하면 한 달 예상이 조금 달라집니다 (반올림은 마지막에). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 새 파일 · 웹훅 ② 읽을 두 곳 ③ 평균의 분모 ④ 일하는 날 기본값 ⑤ 반올림 ⑥ LLM 없이 ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost-report.json 에 새 워크플로우: 웹훅 ____① 에서 Get summary(GET http://data-api:8800/usage/summary) → Get budget(GET http://data-api:8800/budget) → Make report(Code) → Send report. +avg_usd = cost_usd ÷ ____② (calls 아님). projected_month_usd = avg_usd × requests_per_day × workdays (기본 ____③ , requests_per_day 가 없으면 지금 요청 수). +top_user 는 by_user 에서 cost_usd 가 가장 큰 사람. by_model 은 모델마다 cost_usd. 금액은 소수 ____④ 자리에서 반올림 (반올림은 마지막에). LLM 은 쓰지 않는다. +결과 칸은 AGENTS.md 3절 비용 보고서. 기대 결과 cases/lab03-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① `cases/lab03-challenge.json`의 맨 위 `webhook` 값입니다. +- ② 요약에서 `request_id`가 다른 것을 센 칸입니다. +- ③ AGENTS.md 3절 비용 보고서의 `workdays` 기본값입니다. +- ④ AGENTS.md 3절 "비용 보고서의 금액은 …"의 자리입니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost-report.json +uv run tools/flow.py test cases/lab03-challenge.json -v +uv run tools/flow.py test cases/lab02-challenge.json # 실제 사용량 일곱 건을 쌓고 +uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000}' # 그 평균으로 한 달 +``` + +### 실행 결과 · 비용 보고서 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (`test cases/lab03-challenge.json -v` 결과 줄) + +```text +✘ 요약 숫자 · 0.0s · status 404 (expected 200); requests = null (expected 3); calls = null (expected 4); cost_usd = null (expected 0.001989); avg_usd = null (expected 0.000663); top_user = null (expected 박서연) +✘ 모델별 · 한 달 예상 · 0.0s · status 404 (expected 200); projected_month_usd = null (expected 1.4586); by_model has none of ['"gpt-4.1-mini": 0.001944'] +✘ 작은 모델 몫 · 0.0s · status 404 (expected 200); by_model has none of ['"gpt-5.4-nano": 4.5e-05'] +✘ 예산 남은 금액 · 0.0s · status 404 (expected 200); daily_budget_usd = null (expected 1.0); remaining_usd = null (expected 0.998011); over_budget = null (expected false) +✘ 예산을 넘으면 · 0.0s · status 404 (expected 200); over_budget = null (expected true) +Passed 0/5 +``` + +고친 뒤 (결과 줄, 실제 사용량으로 `call`) + +```text +✔ 요약 숫자 · 0.8s · $0.001989 +✔ 모델별 · 한 달 예상 · 0.0s · $0.001989 +✔ 작은 모델 몫 · 0.1s · $0.001989 +✔ 예산 남은 금액 · 0.0s · $0.001989 +✔ 예산을 넘으면 · 0.1s · $0.001989 +Passed 5/5 + +$ uv run tools/flow.py test cases/lab02-challenge.json # 실제 일곱 건을 쌓음 (7/7) +$ uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000}' +HTTP 200 · 0.1s + "requests": 7, + "cost_usd": 0.000889, + "avg_usd": 0.000127, + "top_user": "이준호", + "projected_month_usd": 2.795257, +``` + +**왜 이렇게 나왔나** + +- 고치기 전 다섯 모두 **404**: `my-cost-report` 웹훅이 아직 없기 때문입니다 (새 워크플로우). +- 고친 뒤 숫자가 손 계산과 **정확히** 같습니다: 네 줄 중 `request_id`가 다른 것 3 · 줄 4 · 합 $0.001989 · 평균 0.001989 ÷ 3 = $0.000663 · 100건 × 22일 = $1.4586. Code가 계산해서 늘 같은 값이고, 그래서 `equals`로 소수점까지 시험할 수 있습니다. +- 줄 끝 `$0.001989`는 보고서 결과의 `cost_usd`(넣은 기록의 합)입니다. 보고서는 LLM을 부르지 않으므로 calls 표시가 없습니다. +- 실제 사용량(과제 1 정답의 일곱 질문)으로는 평균 $0.000127, 하루 1,000건이면 **한 달 $2.80**입니다. Lab 01 예제 3의 어림 $20.8의 약 7분의 1입니다. + +## 과제 2 · 모델을 바꾸면 얼마? (10분) + +> "답까지 전부 작은 모델로 바꾸면 한 달에 얼마 아끼는지, 바꾸기 전에 숫자로 보고 싶어요. 보고서에서 모델 이름만 넣으면 계산해 주세요." + +```bash +uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전 (과제 1 정답) +``` + +**만들 것**: 보고서 본문에 `what_if_model`이 있으면, 기록된 **모든 호출의 토큰**을 그 모델의 단가로 다시 계산해 `what_if`에. + +| 조건 | 값 | +| --- | --- | +| 입력 | 과제 1 + `what_if_model` (예: `gpt-5.4-nano`) | +| 계산 | 입력 토큰 합 × 그 모델 input + 출력 토큰 합 × output, ÷ 1,000,000 | +| 결과 | 과제 1 + `what_if: {model, cost_usd, saving_usd, projected_month_usd}` | +| 없는 모델 | HTTP 400 `{error: "unknown model", model, known: [...]}` | +| 기대 결과 | `cases/lab03-challenge2.json` 5개, `lab03-challenge`도 그대로 | + +### 이해하기 + +**바꾸기 전에 계산합니다.** 모델을 바꾸는 것은 74일차처럼 릴리스 · 게이트를 거치는 일입니다. 그 전에 "바꾸면 얼마 아끼나"를 알면 **해 볼 만한지** 정할 수 있습니다. 기록에는 모델마다 입력 · 출력 토큰이 있으니, 단가만 바꿔 다시 곱하면 됩니다. 이렇게 "만약 ~라면"을 계산하는 것을 **what-if 분석**이라고 합니다. + +```mermaid +flowchart LR + S["by_model
토큰 합"] --> C["× 새 모델 단가
(/prices)"] --> W(["what_if
cost · saving · 한 달"]) +``` + +**이 숫자를 그대로 믿으면 안 되는 까닭.** what-if는 **같은 토큰 수**를 가정합니다. 실제로는 다릅니다. + +| 가정 | 실제 (Lab 02 예제 3) | +| --- | --- | +| 토큰 수가 같다 | 모델마다 답 길이 · 토큰 나누는 법이 다름 | +| 품질이 같다 | 낱말 검사는 통과해도 형식 · 군더더기가 달라짐 | + +그래서 what-if는 "**아끼는 돈의 대략**"이고, 바꿀지는 품질 게이트(74일차)로 정합니다. 반대로 "아끼는 돈이 음수"가 나오면(더 비싼 모델) 바꿀 까닭이 없다는 것이 바로 보입니다. 테스트에 그런 케이스도 있습니다. + +**모르는 이름은 막습니다.** 단가표에 없는 모델 이름이 오면 0원으로 계산해 "100% 아낀다"는 거짓 숫자를 내기 쉽습니다. 74일차 "없는 릴리스는 400"처럼, 모르는 이름은 400과 알려진 목록으로 돌려보냅니다. + +**테스트 케이스.** + +| 케이스 | 본문 | 기대 | +| --- | --- | --- | +| 모두 nano였다면 | `what_if_model` nano, 100건 · 22일 | `what_if`의 네 칸이 정확히 | +| 원래 숫자는 그대로 | (같음) | `cost_usd` · `projected_month_usd`가 과제 1과 같음 | +| 모두 mini였다면 · 아끼는 돈이 음수 | `what_if_model` mini | `saving_usd` 음수 | +| 없는 모델은 400 | `gpt-9` | 400, `unknown model`, `known`에 nano | +| what_if 없이 부르면 과제 1과 같음 | `{}` | `what_if` 없음 | + +**기대 결과.** 고치기 전(과제 1 정답): nano · mini 케이스와 400 케이스 ✘ (`what_if` 없음, 200), "원래 숫자는 그대로"와 마지막 케이스는 ✔ (2/5). 고친 뒤 5/5, `lab03-challenge` 5/5. 실제 사용량으로 `call`해 `COST.md` 7절을 채웁니다. + +
▶ 1단계 · 먼저 손으로 + +`before`의 네 줄에서 입력 토큰 합 · 출력 토큰 합을 구하고, nano 단가(입력 0.20 · 출력 1.25)로 곱해 봅니다. 그 값이 `what_if.cost_usd`입니다. +
+ +
▶ 2단계 · 400은 어디서 + +Code 노드는 HTTP 상태를 정하지 못합니다. Code가 `error`를 담아 내보내면, 뒤의 If가 갈라 400을 보내는 Respond로 잇습니다 (74일차 "없는 릴리스는 400"과 같은 모양). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 다시 계산할 때 쓰는 값 ② 아끼는 돈의 식 ③ 없는 모델의 응답 ④ what_if 가 없을 때 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/cost-report.json 의 Get budget 뒤에 Get prices(GET http://data-api:8800/prices) 를 넣어 줘. +본문에 what_if_model 이 있으면 Make report 에서 by_model 의 prompt_tokens · completion_tokens 합을 그 모델의 ____① 로 다시 계산해 +what_if: {model, cost_usd, saving_usd, projected_month_usd} 를 더해 줘. saving_usd = 지금 cost_usd − ____② . +단가표에 없는 모델이면 Known model?(If) 로 갈라 HTTP ____③ {error: 'unknown model', model, known}. +what_if_model 이 없으면 결과는 과제 1 과 ____④ . +기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① `/prices`에서 모델마다 주는 두 숫자입니다. +- ② 새 모델로 계산한 비용의 칸 이름입니다 (`what_if.____`). +- ③ 74일차 "없는 릴리스"와 같은 응답 코드입니다. +- ④ 과제 1의 테스트가 그대로 통과해야 합니다. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/cost-report.json +uv run tools/flow.py test cases/lab03-challenge2.json -v +uv run tools/flow.py test cases/lab03-challenge.json +uv run tools/flow.py test cases/lab02-challenge.json +uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000, "what_if_model": "gpt-5.4-nano"}' +``` + +마지막으로 `COST.md` 6 · 7절, [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웁니다. + +### 실행 결과 · 모델을 바꾸면 얼마 + +수업 준비 때(10/28) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM 문장 · 토큰 · 비용 · 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (과제 1 정답, `test cases/lab03-challenge2.json -v` 결과 줄) + +```text +✘ 모두 nano였다면 · 0.0s · $0.001989 · what_if.model = null (expected gpt-5.4-nano); what_if.cost_usd = null (expected 0.00117); what_if.saving_usd = null (expected 0.000819); what_if.projected_month_usd = null (expected 0.858) +✔ 원래 숫자는 그대로 · 0.1s · $0.001989 +✘ 모두 mini였다면 · 아끼는 돈이 음수 · 0.0s · $0.001989 · what_if.cost_usd = null (expected 0.002016); what_if.saving_usd = null (expected -2.7e-05) +✘ 없는 모델은 400 · 0.0s · $0.001989 · status 200 (expected 400); error = null (expected unknown model); known has none of ['gpt-5.4-nano'] +✔ what_if 없이 부르면 과제 1과 같음 · 0.0s · $0.001989 +Passed 2/5 +``` + +고친 뒤 (결과 줄, 실제 사용량으로 `call`) + +```text +✔ 모두 nano였다면 · 0.9s · $0.001989 +✔ 원래 숫자는 그대로 · 0.1s · $0.001989 +✔ 모두 mini였다면 · 아끼는 돈이 음수 · 0.0s · $0.001989 +✔ 없는 모델은 400 · 0.2s +✔ what_if 없이 부르면 과제 1과 같음 · 0.0s · $0.001989 +Passed 5/5 + +lab03-challenge Passed 5/5 · lab02-challenge Passed 7/7 +$ uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000, "what_if_model": "gpt-5.4-nano"}' + "requests": 7, + "cost_usd": 0.000929, + "projected_month_usd": 2.920971, + "what_if": { "model": "gpt-5.4-nano", "cost_usd": 0.00061, "saving_usd": 0.00032, "projected_month_usd": 1.9162 } +``` + +**왜 이렇게 나왔나** + +- 고치기 전 "원래 숫자"와 "what_if 없이" 둘은 ✔입니다. 과제 1 정답이 원래 숫자를 이미 맞게 내기 때문입니다. 나머지는 `what_if` 칸이 없고, 모르는 모델 `gpt-9`도 200으로 그냥 답해서 ✘입니다. +- 고친 뒤 nano: 입력 합 3,600 × 0.20 + 출력 합 360 × 1.25 = $0.00117, 아끼는 돈 $0.001989 − $0.00117 = **$0.000819**. mini: $0.002016로 지금보다 비싸 아끼는 돈이 **음수**(−$0.000027)입니다 — 넣은 기록 중 하나가 이미 nano였기 때문입니다. +- `gpt-9`는 단가표에 없어 **400**과 알려진 목록이 나왔습니다. 0원으로 계산했다면 "100% 아낀다"는 거짓 숫자가 나왔을 것입니다. +- 실제 일곱 건으로는 nano였다면 $0.000929 → $0.00061 (**34% 덜**), 한 달 $2.92 → $1.92입니다. 같은 토큰 수를 가정한 숫자라, Lab 02 예제 3에서 실제로 답 단계만 바꿔 잰 15% · 나빠진 형식과 함께 봐야 합니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| 예산을 넘겼는데 그대로 답함 | 예산 확인이 LLM보다 앞에 있는지, `over`를 읽는지 | +| 한도 초과 답에 FAQ가 없음 | 예산 확인이 `Search FAQ` · `Get ticket` **뒤**에 있는지 | +| 429가 안 남 | 기록(`Record usage`)이 되고 있는지 (`flow.py usage`), `count`와 한도를 비교하는 순서 | +| 테스트 뒤에도 429 · 한도 초과 | `uv run tools/flow.py budget --reset`, `uv run tools/flow.py usage --clear` | +| 보고서 숫자가 조금 다름 | 반올림 자리 · 순서, `requests`와 `calls` | +| `by_model.gpt-4.1-mini`가 없다고 나옴 | 이름에 점이 있어 경로로 못 읽습니다. 테스트는 `contains`로 JSON 글자를 찾습니다 (AGENTS.md 8절) | +| what-if 400이 200으로 감 | Code가 `error`를 내보내고, If가 그것으로 가르는지 | +| 계속 안 됨 | `reference/lab03-example1-budget.json`, `reference/lab03-example2-limit.json` | + +## 확인 + +- [ ] 예제 1: `cases/lab03.json` 3/3. +- [ ] 예제 2: `cases/lab03-limit.json` 4/4, `lab03` · `lab02-challenge`도 통과. +- [ ] 예제 3: 예산 $0.0003에서 몇 번째부터 멈추는지, 예산을 얼마나 넘었는지, `--reset`으로 되돌림. +- [ ] 과제 1: `cases/lab03-challenge.json` 5/5, 내 사용량의 한 달 예상. +- [ ] 과제 2: `cases/lab03-challenge2.json` 5/5, 실제 사용량의 what-if와 믿으면 안 되는 까닭. +- [ ] `COST.md`, `TEST-REPORT.md`. diff --git a/Last3Mon/75/LECTURE-75.md b/Last3Mon/75/LECTURE-75.md new file mode 100644 index 0000000..fb7bd52 --- /dev/null +++ b/Last3Mon/75/LECTURE-75.md @@ -0,0 +1,178 @@ +# 75일차 수강 안내 · 수업 준비부터 마칠 때까지 + +- 일자: 2026-10-29 (목) · 75일차 +- 주제: 컴퓨팅 자원 · API 사용량 관리와 비용 최적화 (재기 · 줄이기 · 예산과 한도) +- 실습 폴더: [Last3Mon/75](README.md) · 강의 자료: [Lecture/75](../../Lecture/75/README.md) +- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, `gpt-5.4-nano`) + **75일차 판 데이터 API**(단가 · 사용량 기록 · 예산 · 한도) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.** +- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요. + +```mermaid +flowchart LR + P["전날
준비 · base-cost 게시"] --> M["아침
켜기 · doctor"] --> L1["1교시 강의 · 2교시
Lab 01 실습 5개
토큰 · 비용 · 기록"] --> L2["3교시 강의 · 4교시
Lab 02 실습 5개
줄이기 · 모델 · 호출 수 · 긴 질문"] --> L3["5교시 강의 · 6교시
Lab 03 실습 5개
예산 · 한도 · 보고서 · what-if"] --> V["7~8교시
tcost"] --> E["마친 뒤
reset · 76 준비"] +``` + +## 0. 하루 한눈에 + +| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 | +| --- | --- | --- | --- | --- | +| 전날 | 약 10분 | 자료 받기 · n8n · 75일차 데이터 API · `base-cost` 게시 · doctor | [PREP](../../Lecture/75/00-DAY75-PREP.md) | `ready` | +| 1 | 09:00~09:50 | 개요 · 1단계 강의 (사용량 재기) | [개요](../../Lecture/75/00-DAY75-OVERVIEW.md) 1~14장, [1단계](../../Lecture/75/01-STAGE1-MEASURE-USAGE.md) | | +| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-MEASURE-USAGE.md) | `lab01-challenge` · `lab01-challenge2` 통과 | +| 3 | 11:00~11:50 | 2단계 강의 (비용 줄이기) | 개요 15~21장, [2단계](../../Lecture/75/02-STAGE2-REDUCE-COST.md) | | +| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-REDUCE-COST.md) | `lab02-challenge` 7/7 · `lab02-challenge2` 4/4 | +| - | 13:00~14:00 | 점심 | | | +| 5 | 14:00~14:50 | 3단계 강의 (예산과 한도) | 개요 22~28장, [3단계](../../Lecture/75/03-STAGE3-BUDGET-AND-LIMITS.md) | | +| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-BUDGET-AND-LIMITS.md) | `lab03-challenge` · `lab03-challenge2` 통과, `budget --reset` | +| 7 | 16:00~16:50 | 바이브 코딩 `tcost`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TCOST](VIBE-REQ-TCOST.md) → [VIBE-Q-TCOST](VIBE-Q-TCOST.md) | `Doc/*.md` · R1~R3 · R5 · R6 · R8 · R13 · R14 · `pytest` | +| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 76일차 예고 | [4단계 정리](../../Lecture/75/04-STAGE4-WRAPUP.md), [진행](../../Lecture/75/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 | + +## 1. 전날 준비 (약 10분) + +### 1.1 자료 받기 + +```bash +cd ~/aio-01d4-stu && git pull +``` + +### 1.2 n8n과 **75일차** 데이터 API 켜기 (Ollama는 끔) + +```bash +sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔 +cd ~/aio-01d4-stu/Last3Mon/65/n8n +docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감) +docker compose start +cd ~/aio-01d4-stu/Last3Mon/75/data-api +docker compose up -d --force-recreate # 74일차 판을 75일차 판(+ 단가 · 사용량 기록 · 예산 · 한도)으로 바꿈 +sleep 3 # 데이터 API가 뜰 때까지 +curl -s localhost:8800/prices +``` + +- 74일차 연습을 되돌리지 않았다면: `cd ~/aio-01d4-stu/Last3Mon/74 && uv run tools/flow.py fault prompts ok`. 릴리스 · 카나리는 데이터 API를 새로 띄우면 처음으로 돌아갑니다. + +### 1.3 기준 게시 · 준비 확인 + +```bash +cd ~/aio-01d4-stu/Last3Mon/75 +uv run tools/flow.py push reference/baseline-cost.json +uv run tools/flow.py call base-cost requests/faq-vpn.json # usage.by_step 이 보이면 됨 (약 $0.001) +uv run tools/flow.py doctor # 마지막 줄 ready +``` + +### 1.4 미리 읽기 (선택, 5분) + +[PREP 3절](../../Lecture/75/00-DAY75-PREP.md): 질문 하나에 얼마가 들까? 어디에 가장 많이 들까? 그리고 7교시 [요청서](VIBE-REQ-TCOST.md). + +### 준비물 + +노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 72일차 `~/vibe/tbench` · 74일차 `~/vibe/tver` (없어도 됨). + +## 2. 수업 당일 아침 (09:00 전) + +1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (기록 · 예산은 처음으로 돌아갑니다 — 괜찮음). +2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/75`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`call`·`push`·`test`·`usage`·`budget`). +3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`, `uv run tools/flow.py budget`. +4. `cp templates/COST-PLAN-TEMPLATE.md my-work/COST.md`, `my-work/PROMPTS.md`. + +## 3. 실습할 때마다 같은 순서 (오늘은 "재기 → 줄이기 → 품질 확인") + +```mermaid +flowchart LR + A["① 재기
call · test 줄 끝 비용"] --> B["② 요청문을
메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가
my-work · check"] --> E["⑤ 내가
push · test -v"] + E -->|✘| F["⑥ 실패 줄 · 보고서 경로
그대로 붙여 주기"] --> C + E -->|✔| G["⑦ 답 읽기 · COST.md 한 줄"] +``` + +- 예산 · 한도 · 글자 수 상한은 **내가** `flow.py budget`으로 정합니다. 끝나면 `uv run tools/flow.py budget --reset`. +- 테스트 줄 끝의 `· 자동 답변 · 1 call · $0.000156`이 오늘의 숫자입니다. +- `fault usage error`(Lab 01 과제 2)는 끝나면 **반드시 되돌립니다** (`fault usage ok`). + +## 4. 1~2교시 · 1단계 · 사용량 재기 + +1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-MEASURE-USAGE.md) (50분, 실습 5개). + +| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) | +| --- | --- | --- | --- | +| 예제 1 (10분, 에이전트 없이) | 자원 · 단계별 토큰 · 단가 | `base-cost` | `docker stats --no-stream`, `call base-cost requests/faq-vpn.json` · `ticket-own.json`, `curl -s localhost:8800/prices` → `COST.md` 1 · 2절 | +| 예제 2 (12분) | 단계마다 `cost_usd`, 단가는 `/prices`에서 | `cp reference/baseline-cost.json my-work/cost.json` → 웹훅 `my-cost`로 | `push my-work/cost.json` → `test cases/lab01.json -v` | +| 예제 3 (8분, 에이전트 없이) | 일곱 질문의 비용, 한 달 어림 | (그대로) | `test cases/lab01-mix.json` → `COST.md` 3절 | +| 과제 1 (10분) | 요청마다 `/usage`에 기록 (`request_id`) | `my-work/cost.json` 고치기 | `test cases/lab01-challenge.json -v`, `usage` | +| 과제 2 (10분) | 먼저 겪기: `fault usage slow` → `call` → `fault usage ok`. 그다음 기록이 멈춰도 답은 나감 (`recorded`) | `my-work/cost.json` 고치기 | `test cases/lab01-challenge2.json -v` (자동으로 고장 켜고 끔), `lab01-challenge`, `doctor` | + +(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.) + +## 5. 3~4교시 · 2단계 · 비용 줄이기 + +3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-REDUCE-COST.md) (50분, 실습 5개). + +| 순서 | 할 일 | 확인 | +| --- | --- | --- | +| 예제 1 (10분) | 규정 전체를 빼고, FAQ · 접수 건은 필요한 칸만, `max_completion_tokens` 200 | `test cases/lab02.json -v` (전 · 후), `diff reference/baseline-cost.json my-work/cost.json` | +| 예제 2 (8분) | 분류 · 검토를 `gpt-5.4-nano`, 답은 mini | `test cases/lab02.json -v`, `test cases/lab01.json` | +| 예제 3 (7분, 에이전트 없이) | 답까지 nano인 비교본: 통과 수 · 평균 비용 · **답 읽기** | `push reference/lab02-example3-nano-answer.json`, `test cases/lab02.json --webhook nano-answer -v`, `test cases/lab01-mix.json` (내 것 · `--webhook nano-answer`) | +| 과제 1 (13분) | 규칙 분류, 모를 때만 LLM 분류, 검토 빼기 → LLM 한 번 · $0.0002 | `test cases/lab02-challenge.json -v`, `lab02` · `lab01` · `lab01-challenge` | +| 과제 2 (12분) | 긴 질문은 앞 40% + 뒤 60%만 (`max_question_chars`) | `call my-cost requests/long-printer.json`, `test cases/lab02-challenge2.json -v`, `lab02-challenge` | + +## 6. 5~6교시 · 3단계 · 예산과 한도 + +5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-BUDGET-AND-LIMITS.md) (50분, 실습 5개). + +| 순서 | 할 일 | 확인 | +| --- | --- | --- | +| 예제 1 (10분) | 예산을 넘으면 LLM 없이 원문 (`한도 초과`) | `budget`, `test cases/lab03.json -v` (전 · 후) | +| 예제 2 (10분) | 같은 사람이 1분에 한도를 넘으면 429 (가장 앞에서) | `test cases/lab03-limit.json -v`, `lab03` · `lab02-challenge` | +| 예제 3 (7분, 에이전트 없이) | 예산 $0.0003으로 여섯 질문, 몇 번째부터 멈추나 | `budget --daily 0.0003` → `test cases/lab03-drill.json` → `usage` → `budget --reset` → 다시 → `usage --clear` | +| 과제 1 (13분) | 비용 보고서 `my-cost-report` (LLM 없이 Code가 계산) | `push my-work/cost-report.json` → `test cases/lab03-challenge.json -v`, `test cases/lab02-challenge.json` → `call my-cost-report '{"requests_per_day": 1000}'` | +| 과제 2 (10분) | `what_if_model`로 "모두 이 모델이면 얼마", 없는 모델 400 | `test cases/lab03-challenge2.json -v`, `lab03-challenge`, `call my-cost-report '{"requests_per_day": 1000, "what_if_model": "gpt-5.4-nano"}'` | + +- 기록: `COST.md` 6 · 7절, `TEST-REPORT.md`. + +## 7. 7~8교시 · 바이브 코딩 `tcost` + +[VIBE-REQ-TCOST](VIBE-REQ-TCOST.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TCOST](VIBE-Q-TCOST.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1 · 2단계 · TUI 1단계 · 설치까지, 나머지는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 LLM을 부르기 **전에** 글 · 워크플로우의 토큰과 비용을 계산하고, 테스트 보고서의 실제 사용량을 모아 한 달을 예상하는 TUI. 정답본 `VIBE-A-TCOST.md`는 수업이 끝나면 `git pull`로 받습니다. + +```bash +mkdir -p ~/vibe/tcost/Doc && cd ~/vibe/tcost +git init +# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy) +``` + +| 시간 | 단계 | 확인 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (손으로 계산한 금액) | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~4단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: 엔진 1단계(R1~R3 · R13) → 엔진 2단계(R5 · R6) → TUI 1단계(R8), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tcost` | `cd ~/aio-01d4-stu/Last3Mon/75 && tcost usage runs --per-day 1000` | + +- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/75/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 76일차 예고. + +## 8. 마친 뒤 + +| 할 일 | 이유 | +| --- | --- | +| `uv run tools/flow.py budget --reset`, `usage --clear`, `fault usage ok`, `doctor` → `ready` | 내일 처음 상태로 | +| `PROMPTS.md`, `COST.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (비용 계획) | +| `my-work/*.json`, `~/vibe/tcost` 지우지 않기 | 76일차 에러 처리 · 79일차 `tops` | +| 76일차 준비: [00-DAY76-PREP](../../Lecture/76/00-DAY76-PREP.md) | 에러 처리와 예외 대응 | + +## 9. 막혔을 때 (오늘 공통) + +| 증상 | 할 일 | +| --- | --- | +| doctor의 prices · budget ✘ | 데이터 API가 예전 판. 1.2의 75일차 `--force-recreate` · `sleep 3` | +| doctor에 `budget is not the start state` | `uv run tools/flow.py budget --reset` | +| doctor에 `fault switch is on` | `uv run tools/flow.py fault all ok` | +| OpenAI 400 `Unsupported parameter: 'max_tokens'` | `gpt-5.4-nano`는 `max_completion_tokens`만 (`check`가 알려 줌) | +| `gpt-4.1-nano` 오류 | OpenAI가 더 이상 받지 않음. `gpt-5.4-nano`로 (`check`가 알려 줌) | +| 테스트 뒤에도 429 · 한도 초과 | `budget --reset`, `usage --clear` | +| `call my-cost`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 | +| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 | + +## 확인 + +- [ ] 전날 75일차 데이터 API, `base-cost` 게시, `doctor` → `ready` +- [ ] Lab 01: `COST.md` 1~4절, `lab01` 3/3, `lab01-mix` 7/7, `lab01-challenge` 4/4, `lab01-challenge2` 4/4 +- [ ] Lab 02: `lab02` 5/5, 답까지 nano 비교와 결정, `lab02-challenge` 7/7, `lab02-challenge2` 4/4, `COST.md` 5절 +- [ ] Lab 03: `lab03` 3/3, `lab03-limit` 4/4, 예산 $0.0003 연습, `lab03-challenge` 5/5, `lab03-challenge2` 5/5, `COST.md` 6 · 7절 +- [ ] `tcost`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1 · 2단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install` +- [ ] `budget --reset` · `usage --clear` · `fault usage ok`, 76일차 준비 diff --git a/Last3Mon/75/README.md b/Last3Mon/75/README.md new file mode 100644 index 0000000..ec9f318 --- /dev/null +++ b/Last3Mon/75/README.md @@ -0,0 +1,93 @@ +# 75일차 실습 · 컴퓨팅 자원·API 사용량 관리와 비용 최적화 (바이브 코딩) + +- 일자: 2026-10-29 (75일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 운영 및 관리 역량 강화 +- 강의 자료: [Lecture/75](../../Lecture/75/README.md) +- 하루 수강 안내: [LECTURE-75.md](LECTURE-75.md) (수업 준비부터 마칠 때까지 순서대로) +- 67~74일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 질문 하나에 **토큰이 몇 개, 돈이 얼마** 드는지 재고, **품질은 그대로 두고** 줄이고, 넘치지 않게 **예산 · 한도**를 둡니다. + +## 오늘 고치는 것: 비싼 헬프데스크 + +`reference/baseline-cost.json`(웹훅 `base-cost`)은 질문마다 LLM을 세 번(분류 · 답 · 검토) 부르고, 답 단계에 **사내 규정 전체**를 붙여 보냅니다. 수업 준비 때 잰 VPN 질문 하나가 **$0.00104**(입력 1,999 · 출력 150토큰)였습니다. 이것을 재고 → 줄이고 → 한도 안에서 답하게 합니다. + +```mermaid +flowchart LR + Q(["질문 · user"]) --> LIM{"1분 한도?"} -->|넘음| X(["429 잠시 후 · $0"]) + LIM --> CUT["긴 질문 줄이기
max_question_chars"] --> D["FAQ · 접수 조회"] --> B{"오늘 예산?"} -->|넘음| O(["한도 초과
FAQ 원문 · LLM 0번"]) + B --> R{"규칙으로
분류?"} -->|예| A{{"답 LLM 1번
필요한 것만 · 출력 상한"}} + R -->|아니오| C{{"분류 nano"}} --> A + A --> U["사용량 기록 /usage
(멈춰도 답은 나감)"] --> S(["답 + usage + cost_usd"]) + P[("/prices 단가")] -.-> A + U -.-> REP["비용 보고서
한 달 · what-if"] +``` + +## 일하는 방식 (67~74일차와 같음) + +| 누가 | 무엇을 | +| --- | --- | +| 나 | 토큰 · 비용 재기, 무엇을 줄일지 정하기, 요청문, 품질 테스트로 확인 · **답 읽기**, 예산 · 한도 정하기(`flow.py budget`) | +| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 | +| `tools/flow.py` | check · push · call · test(`range`, `url`, 줄 끝에 호출 수 · 비용) · diff · bench · **usage** · **budget** · **fault usage** · llm · doctor (모두 `-v`) | + +> **싸졌다면 품질이 그대로인지** 원래 테스트로 확인하고 답을 읽습니다. 72일차의 "빠른데 틀린 답"처럼 "싼데 나빠진 답"도 개선이 아닙니다. + +## 실습 15개 (Lab마다 예제 3 · 과제 2) + +| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 | +| --- | --- | --- | --- | --- | --- | +| [Lab 01](LAB-01-Q-MEASURE-USAGE.md) | 자원 · 단계별 토큰 (에이전트 없이) | 결과에 `cost_usd` | 질문 종류별 비용 · 한 달 어림 (에이전트 없이) | 요청마다 기록 | 기록이 멈춰도 답은 나감 | +| [Lab 02](LAB-02-Q-REDUCE-COST.md) | 보내는 것 줄이기 | 단계마다 맞는 모델 | 답까지 작은 모델이면? (에이전트 없이) | LLM 한 번 · $0.0002 | 긴 질문의 비용 상한 | +| [Lab 03](LAB-03-Q-BUDGET-AND-LIMITS.md) | 하루 예산 → 원문 | 1분 한도 → 429 | 예산을 바꿔 가며 (에이전트 없이) | 비용 보고서 | 바꾸면 얼마 (what-if) | + +## 실습 문서 읽는 법 + +- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TCOST.md`)에는 **복사해 붙일 요청문이 없습니다.** 실습마다 `### 이해하기`(왜 · 지금 → 바뀐 뒤 · 테스트 케이스 · 기대 결과)를 읽고, 막히면 `▶` 를 하나씩 펼칩니다. 요청문은 `▶ 빈칸 채우기`의 빈칸 4개(`____①`~`____④`)를 채워서 씁니다. +- 쓴 요청문은 `my-work/PROMPTS.md`에, 잰 숫자와 결정은 `my-work/COST.md`에, 테스트 결과는 `my-work/TEST-REPORT.md`에 모아 둡니다. + +## 구성 + +```text +Last3Mon/75/ +├── README.md +├── LECTURE-75.md # 하루 수강 안내 +├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 토큰이 보이는 LLM 호출 · 단가 · 기록 · 예산 · 한도 노드 5절) +├── LAB-01-Q-MEASURE-USAGE.md # 1단계 · 자원 · 단계별 토큰 · cost_usd · 종류별 비용 · 기록 · 기록이 멈추면 +├── LAB-02-Q-REDUCE-COST.md # 2단계 · 보내는 것 줄이기 · 단계마다 모델 · 답까지 nano? · LLM 한 번 · 긴 질문 +├── LAB-03-Q-BUDGET-AND-LIMITS.md # 3단계 · 하루 예산 · 사용자별 한도 · 예산 바꿔 보기 · 비용 보고서 · what-if +├── VIBE-REQ-TCOST.md # 7교시 바이브 코딩 요청서 (가장 먼저 읽기) +├── VIBE-Q-TCOST.md # 7교시 바이브 코딩 · tcost (부르기 전 토큰 · 비용 계산, 보고서 모으기 TUI) +├── tools/flow.py +├── data-api/ # 75일차 판 데이터 API (74일차 + 단가 · 사용량 기록 · 예산 · 한도) +│ └── data/prices.json · budget.json +├── cases/ # lab01 · lab01-mix · lab01-challenge(2) · lab02 · lab02-challenge(2) · lab03 · lab03-limit · lab03-drill · lab03-challenge(2) +├── requests/ # long-printer · long-vpn (6,000자 넘는 로그 질문) 포함 +├── reference/ # baseline-cost(비교 기준) · lab02-example3-nano-answer(비교용) + 막혔을 때 이어 가는 워크플로우 +├── templates/ # 비용 계획 · 테스트 보고서 +├── my-work/ +└── runs/ +``` + +## 준비 + +[실습 준비](../../Lecture/75/00-DAY75-PREP.md)를 끝내 주세요. 요약: + +```bash +cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기 +cd ~/aio-01d4-stu/Last3Mon/75/data-api && docker compose up -d --force-recreate && sleep 3 # 75일차 판 +cd ~/aio-01d4-stu/Last3Mon/75 && uv run tools/flow.py push reference/baseline-cost.json +uv run tools/flow.py doctor # 마지막 줄 ready +``` + +## `flow.py` 명령 (오늘 더해진 것) + +| 명령 | 하는 일 | 수업 준비 때 | +| --- | --- | --- | +| `uv run tools/flow.py call base-cost requests/faq-vpn.json` | 단계별 토큰(`usage.by_step`) 보기 | 입력 1,999 · 출력 150 토큰 (답 단계 입력 1,781) | +| `uv run tools/flow.py test cases/lab02-challenge.json -v` | `range`로 비용 상한까지 확인, 줄 끝에 `route · 호출 수 · 비용` | 고친 뒤 VPN `1 call · $0.000156` | +| `uv run tools/flow.py usage` · `usage --clear` | 기록된 사용량 · 비용(모델별 · 사람별 · route별) · 예산 요약 · 비우기 | | +| `uv run tools/flow.py budget` · `--daily 0.0003` · `--per-min 2` · `--chars 2000` · `--reset` | 하루 예산 · 사용자별 1분 한도 · 질문 글자 수 상한 보기 · 바꾸기 · 처음으로 | 처음 $1.0 · 20번 · 500자 | +| `uv run tools/flow.py fault usage error\|slow\|ok` | 기록 API 고장 연습 (끝나면 `ok`) | | +| `docker stats --no-stream` | n8n · 데이터 API 컨테이너의 CPU · 메모리 | n8n 642.6MiB · runners 100.5MiB · data-api 20.3MiB | + +> 오늘은 실제 OpenAI 호출에 돈이 듭니다 (질문 하나에 약 $0.0001~$0.0012). 같은 테스트를 수십 번 반복하지 않습니다. 단가는 `data-api/data/prices.json` (OpenAI 모델 문서, 2026-10-28 확인: `gpt-4.1-mini` 입력 $0.40 · 출력 $1.60, `gpt-5.4-nano` 입력 $0.20 · 출력 $1.25 / 1M 토큰). diff --git a/Last3Mon/75/VIBE-Q-TCOST.md b/Last3Mon/75/VIBE-Q-TCOST.md new file mode 100644 index 0000000..2a05e87 --- /dev/null +++ b/Last3Mon/75/VIBE-Q-TCOST.md @@ -0,0 +1,288 @@ +# 7교시 바이브 코딩 · `tcost` 만들기 + +> **먼저 [VIBE-REQ-TCOST.md](VIBE-REQ-TCOST.md)(요청서)를 읽고 `~/vibe/tcost/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다. + +```mermaid +flowchart LR + T["1. Doc/TODO.md
할 일 정리"] --> S["2. Doc/SPEC.md
확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> I["4. 구현
단계마다 요청 · 확인"] + I --> H["5. 막히면
문제 해결 팁"] --> I + I --> C["6. 결과 확인
요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"] +``` + +- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다 +- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), 토큰 세는 라이브러리(`tiktoken`) +- 결과물: 글 파일 · 워크플로우의 **토큰을 세고**, 단가표로 모델마다 **부르기 전에 비용을 계산**하고, 테스트 보고서(`runs/*.json`)의 **실제 사용량을 모아** 한 달을 예상하는 TUI. 단가는 날짜 · 출처와 함께 파일에 두고, 모르는 모델은 0원으로 계산하지 않고, **OpenAI를 부르지 않습니다.** `uv tool install`로 설치해 **어느 폴더에서나** `tcost`로 씁니다. 오늘 n8n을 돌려서 잰 것을 돌리기 **전에** 내 도구로. + +```text +$ tcost reference runs +┌ tcost · reference runs · prices 2026-10-28 ───────────────────────────────┐ +│ [Prompts] Usage Compare │ +│ prompt tokens gpt-4.1-mini /1,000 gpt-5.4-nano /1,000 │ +│ baseline-cost.json · Answer 1670 $0.6680 $0.3340 │ +│ lab02-example1-lean.json · Answer 75 $0.0300 $0.0150 │ +│ ... │ +│ 19 prompt(s) · 51 report(s) · [r] reload │ +│ [r] Reload [q] Quit │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다. +> +> **시험할 때 OpenAI를 부르지 않습니다.** `tcost`는 이 컴퓨터에서 토큰을 세고, 보고서는 이미 있는 파일을 읽기만 합니다. + +## 시간 (50분) + +| 시간 | 단계 | 결과 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 `Doc/TODO.md` (요청서 읽고 직접) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → 아래 요구 표와 비교 · 고치기 | 요구마다 확인 방법 | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 단계 · TUI 단계) | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: **엔진 1단계 → 엔진 2단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `cd ~/aio-01d4-stu/Last3Mon/75 && tcost usage runs --per-day 1000` | + +50분 안 목표: **R1 · R2 · R3 · R13(엔진 1단계) + R5 · R6(엔진 2단계) + R8(TUI 1단계) + R14(설치)**. R4 · R7 · R9~R12는 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다. + +## 0. 준비 (1분) + +```bash +mkdir -p ~/vibe/tcost/Doc && cd ~/vibe/tcost +git init +# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy) +``` + +## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이) + +[VIBE-REQ-TCOST.md](VIBE-REQ-TCOST.md)의 "할 일" 틀대로 씁니다. 요청의 말 ①~⑫를 **내 말로** 옮기고, 꼭 · 나중을 표시하고, 걱정되는 것과 50분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다. + +
▶ TODO와 SPEC은 무엇이 다른가 + +TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. TODO에서 빠진 것은 SPEC에서도 빠지므로, TODO는 빠짐없이, SPEC은 정확하게. +
+ +`git add Doc/TODO.md && git commit -m "TODO"` + +## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분) + +TODO의 할 일마다 **확인 방법**(시험 파일 · 정확한 금액 · 건너뛴 수 · 종료 코드)을 붙여 SPEC을 만듭니다. 에이전트에게 "TODO.md를 읽고 SPEC.md 초안을 써 줘. 요구마다 확인 방법을 붙여"라고 시켜도 됩니다. 그러면 **반드시 내가 읽고 고칩니다.** + +```markdown +# tcost SPEC + +## 1. 요구 (확인할 수 있게) +| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 | +| --- | --- | --- | --- | --- | +| R1 | | ① | | 꼭 | + +## 2. 숨은 요구 +## 3. 범위 밖 (하지 않을 것) +## 4. 정해 둘 것 (단가 파일 모양 · 위치, 반올림, 평균의 분모, 출력 가정의 기본값, 종료 코드) +``` + +
▶ 확인 방법은 손으로 계산할 수 있는 숫자로 + +"비용을 계산한다"는 확인 방법이 아닙니다. "입력 1,000 · 출력 100토큰, `gpt-4.1-mini`(0.40 · 1.60) → $0.00056"이 확인 방법입니다. 오늘 Lab 03 과제 1 테스트처럼 **정확히 하나인 답**을 적습니다. 보고서도 "결과 셋 중 400 하나 → 요청 2 · skipped 1"처럼 **나와야 할 것과 나오면 안 되는 것**을 둘 다 적습니다. +
+ +쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다. + +### 요구 사항 (요청서에서 뽑은 것, 14개) + +| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 | +| --- | --- | --- | --- | --- | +| R1 | `tcost count <파일>`: 토큰 수와 모델마다 한 번 · 1,000번 비용 표 | ① | 입력 1,000토큰 · mini → $0.0004 · 1,000번 $0.40 | 꼭 | +| R2 | `--out N`: 출력 토큰을 가정해 더함 | ② | 1,000 · `--out 100` · mini → $0.00056 | 꼭 | +| R3 | 단가는 파일(`checked` 날짜 · `source` · `models`), `--prices`로 바꿈, 30일 넘으면 알림 | ③ | 날짜를 45일 전으로 → 알림, 날짜 없음 → 알림 | 꼭 | +| R4 | `tcost workflow <파일>`: LLM 단계마다 노드 · 모델 · 고정 지시문 토큰 · 출력 상한 | ④ | `baseline-cost.json`의 `Answer` 1,000토큰 넘음, `lab02-example1-lean.json` 200 아래 | 나중 | +| R5 | `tcost usage <폴더>`: 보고서의 `usage.by_step`을 모아 요청 · 호출 · 토큰 · 비용, 모델별 · 사람별 · 시간대별, 없는 것은 skipped | ⑤ | 결과 셋(하나는 400) → 요청 2 · skipped 1 | 꼭 | +| R6 | `--per-day N --days D`: 평균(요청당) × N × D = 한 달 | ⑥ | 요청 2 · $0.0007442 · 1,000건 · 22일 → $8.1862 | 꼭 | +| R7 | `tcost compare <파일> --model a --model b`: 나란히, 몇 % 싼지 | ⑦ | mini vs nano → "… % cheaper than gpt-4.1-mini" | 나중 | +| R8 | TUI: 탭 Prompts · Usage · Compare, 시간대별 막대, `[r]` 다시 읽기 (`tcost <폴더> [폴더 …]`) | ⑧ | `tcost reference runs` → 세 탭이 채워짐 | 꼭 | +| R9 | `tcost budget <달러> <파일>`: 모델마다 하루 몇 번 | ⑨ | $1.0 · nano 쪽이 더 많음 | 나중 | +| R10 | `--json`, `--max-usd X`: 한 번이 X를 넘으면 종료 코드 1, 오류는 2 | ⑩ | 긴 글 `--out 200 --max-usd 0.0002` → 1 | 나중 | +| R11 | 폴더: `.txt` · `.prompt` 글, `nodes` · `connections` 워크플로우, `results` 보고서, 나머지(`.md` 문서 · 테스트 파일) skipped | ⑪ | `cases/lab01.json` → skipped | 나중 | +| R12 | 단가표에 없는 모델 → 오류(알려진 목록), 0원으로 계산하지 않음 | ⑫ | `--model gpt-4.1-nano` → 종료 코드 2 · `gpt-5.4-nano`가 목록에 | 나중 | +| R13 | OpenAI를 부르지 않음: 토큰은 이 컴퓨터에서, 테스트도 네트워크 없이 | 끝 | 테스트에 API 키 · 네트워크 호출 없음 | 꼭 | +| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tcost`, 단가 파일도 함께 | 첫 문단 | `cd /tmp && tcost --help`, `uv tool list`에 `tcost` | 꼭 | + +숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것): + +| 숨은 요구 | 어디서 나왔나 | +| --- | --- | +| 비용 식 · 반올림을 오늘 데이터 API 계약과 같게 (소수 8자리) | ① · ⑤ — 그래야 보고서의 `cost_usd`와 내 계산을 나란히 놓을 수 있음 | +| 평균은 **요청 수**로 나눈다 (`calls`가 아님) | ⑥ (Lab 03 과제 1) | +| "오늘 날짜"를 함수에 넘길 수 있게 (테스트가 날마다 같게) | ③ | +| 고정 지시문만 센다고 화면에 밝힌다 (질문 · FAQ는 실행 때 붙음) | ④ — 실제 입력보다 작게 나오는 까닭 | +| 엔진(단가 · 세기 · 모으기)과 화면(TUI)을 **나눈다.** `count` · `usage` · `--json`은 화면 없이 엔진만 | ⑩ "다른 프로그램이 읽는", 테스트하기 쉬움 | +| `pyproject.toml`에 명령 이름과 단가 파일을 함께 넣어야 설치한 뒤에도 단가를 찾는다 | R14 | + +범위 밖 (오늘 하지 않음): OpenAI 사용량 API로 실제 청구액 가져오기, 캐시 입력 단가, 여러 통화, n8n을 직접 실행해 재기. + +`git add Doc/SPEC.md && git commit -m "SPEC"` + +## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분) + +SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진이 내놓은 숫자를 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침). + +```markdown +# tcost IMP-PLAN + +## 엔진 1단계 · 단가와 토큰 (R1 · R2 · R3 · R13) +- [ ] prices.load(): checked · source · models, --prices +- [ ] cost(model, in, out): (in × input + out × output) / 1,000,000, 소수 8자리 +- [ ] warning(today): 30일 넘음 · 날짜 없음 +- [ ] tokens.count(): tiktoken o200k_base +- [ ] cli: tcost count <파일> [--out N] [--model M] +- [ ] tests: $0.0004 · $0.00056 · 날짜 경고 +- 확인: uv run pytest -q, uv run tcost count <파일> --out 100 + +## 엔진 2단계 · 보고서 모으기 · 한 달 (R5 · R6) +## TUI 1단계 · 탭 셋 (R8) +## 설치 (R14) +## 엔진 3단계 · 워크플로우 · 비교 · 모르는 모델 (R4 · R7 · R12) +## 엔진 4단계 · 예산 · 다른 프로그램 · 폴더 (R9 · R10 · R11) +``` + +
▶ 왜 엔진 먼저인가 + +- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tcost count`). 화면부터 만들면 금액이 맞는지 눈으로만 봅니다. +- 배포 전 비용 관문(R10)은 화면 없이 돌아야 합니다. 74일차 게이트처럼 종료 코드로 다음 단계를 정합니다. +- TUI는 엔진에게 "이 폴더의 지시문 · 보고서 숫자"를 받아 표에 넣기만 하면 됩니다. +
+ +
▶ 단계 크기 + +한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다. 오늘 Lab 02의 "한 번에 한 가지만 줄이고 다시 잰다"와 같은 생각입니다. +
+ +`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"` + +## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계) + +이제 에이전트를 켭니다. 첫 요청은 늘 같습니다. + +```text +Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고, +IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘. +``` + +그다음 **한 단계씩**: "엔진 1단계만 만들어 줘. 끝나면 IMP-PLAN의 체크를 표시하고 확인 명령을 알려 줘" → 내가 확인 → commit → 다음 단계. + +> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. 오늘 `tcost`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`. + +
▶ 엔진 1단계 · 단가 파일의 모양 + +오늘 데이터 API의 `prices.json`(`models` 안에 `input` · `output`)을 그대로 따르고 `checked` · `source`를 더하면, 내 도구와 n8n이 **같은 단가표**를 쓸 수 있습니다. 단가를 코드에 숫자로 쓰지 않는 것은 오늘 Lab 01 예제 2와 같습니다. +
+ +
▶ 엔진 1단계 · 날짜를 어떻게 시험하나 + +"30일 넘으면 알림"을 오늘 날짜로 시험하면 테스트가 날마다 달라집니다. 알림 함수가 "오늘"을 받게 하면(`warning(today)`), 테스트는 확인한 날짜 + 45일을 넘겨 알림이 나오는지 봅니다. +
+ +
▶ 엔진 2단계 · 보고서의 모양 + +`runs/*.json`을 하나 열어 봅니다. `results`마다 `request`(누가) · `response`(답). `response`에 `usage.by_step`이 있을 수도 없을 수도 있습니다 (400 응답, 429, 요약 케이스, 다른 날의 보고서). 없는 것은 건너뛰고 **몇 개를 건너뛰었는지** 알려 주게 합니다. 시간대는 보고서 파일 이름의 시각(`-20261028-153000`)에서. +
+ +
▶ TUI 1단계 · 화면 나누기 + +계산(단가 · 세기 · 모으기)과 화면을 나눕니다 (`tver` · `tscan`과 같음). 탭마다 같은 엔진 함수를 부릅니다. 시간대별 막대는 `▁▂▃▄▅▆▇█` 여덟 칸으로 충분합니다. +
+ +
▶ 다음 단계들 (8교시 뒤 · 집에서) + +- 엔진 3단계: 워크플로우의 LLM 단계(R4: HTTP `chat/completions` 노드의 `jsonBody`에서 모델 · system 글 · `max_completion_tokens`), 두 모델 비교(R7), 모르는 모델(R12). +- 엔진 4단계: 예산으로 하루 몇 번(R9), `--json` · `--max-usd` · 종료 코드(R10), 폴더 나누기(R11). +
+ +## 5. 바이브 코딩으로 문제 해결 팁 + +| 이럴 때 | 이렇게 | +| --- | --- | +| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tcost count ...`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 | +| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다 | +| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 | +| 금액이 1,000,000배 큼 | "단가는 1M 토큰당. ÷ 1,000,000" | +| 0.00056000000001 같은 숫자 | "비용은 소수 8자리에서 반올림" | +| 모르는 모델이 $0으로 나옴 | "단가표에 없는 모델은 오류, 목록을 보여 줘" | +| 에이전트가 테스트에서 OpenAI를 부름 | "테스트는 네트워크 없이. 토큰은 tiktoken으로 이 컴퓨터에서" | +| 설치한 `tcost`가 단가 파일을 못 찾음 | "`pyproject.toml`에 prices.json을 wheel에 넣어 줘" (패키지 안 파일) | +| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` | +| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 | +| 설치한 `tcost`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) | + +## 6. 결과 확인 + +`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만: + +```bash +cd ~/vibe/tcost +L=~/aio-01d4-stu/Last3Mon/75 +uv run pytest -q +echo "Answer in Korean in at most 3 sentences." > /tmp/short.txt +uv run tcost count /tmp/short.txt --out 100 # R1 · R2: 모델마다 한 번 · 1,000번 +uv run tcost --prices $L/data-api/data/prices.json count /tmp/short.txt # R3: 날짜 없는 단가표 → 알림 +uv run tcost usage $L/runs --per-day 1000 # R5 · R6: 요청 · skipped · 한 달 +uv run tcost $L/reference $L/runs # R8: 세 탭 +``` + +- [ ] R1 토큰 · 모델마다 비용. +- [ ] R2 출력 가정. +- [ ] R3 단가 파일 · 날짜 알림. +- [ ] R5 보고서 모으기 · skipped. +- [ ] R6 한 달. +- [ ] R8 TUI 세 탭. +- [ ] R13 테스트에 네트워크 없음. +- [ ] (이어서) R4 · R7 · R9 · R10 · R11 · R12. + +## 7. `uv tool install`로 설치 + +`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다. + +```toml +[project.scripts] +tcost = "tcost.cli:main" +``` + +```bash +cd ~/vibe/tcost +uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함) +uv tool list # tcost 가 보이는가 +which tcost # ~/.local/bin/tcost +# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶 +``` + +- 지우기: `uv tool uninstall tcost`. + +## 8. 다른 곳에서 사용 + +설치했으면 오늘 Lab에 씁니다. + +```bash +cd ~/aio-01d4-stu/Last3Mon/75 +tcost workflow reference/baseline-cost.json my-work/cost.json # (R4) 줄이기 전 · 후 고정 지시문 +tcost usage runs --per-day 1000 # 오늘 내가 돌린 테스트의 실제 비용 · 한 달 +python3 -c "import json; print(json.load(open('requests/long-printer.json'))['question'])" > /tmp/long.txt +tcost compare /tmp/long.txt --model gpt-4.1-mini --model gpt-5.4-nano --out 60 # (R7) 긴 로그 질문을 두 모델로 +tcost reference my-work runs # TUI +``` + +- `~/vibe/tcost`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다. + +## 발표 (8교시, 3분) + +| 내용 | | +| --- | | +| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) | +| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 | +| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 | +| 막힌 곳 | 5절의 어느 팁으로 풀었나 (1M 단위 · 반올림 · 모르는 모델 · 보고서 모양) | +| 시연 | 내 `runs`의 실제 비용과, 기준 · 내 워크플로우의 고정 지시문 비교 | + +## 확인 + +- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다. +- [ ] IMP-PLAN의 엔진 1 · 2단계와 TUI 1단계를 체크했다 (단계마다 commit). +- [ ] R1 · R2 · R3 · R5 · R6 · R8 · R13 확인, `uv run pytest -q`. +- [ ] R14 `uv tool install`, 다른 폴더에서 `tcost`. +- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감). diff --git a/Last3Mon/75/VIBE-REQ-TCOST.md b/Last3Mon/75/VIBE-REQ-TCOST.md new file mode 100644 index 0000000..70cf1d9 --- /dev/null +++ b/Last3Mon/75/VIBE-REQ-TCOST.md @@ -0,0 +1,108 @@ +# 7교시 바이브 코딩 · 요청서 `tcost` (가장 먼저 읽기) + +- 순서: **이 요청서** → 내 정리 `~/vibe/tcost/Doc/TODO.md` (8분) → [VIBE-Q-TCOST.md](VIBE-Q-TCOST.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TCOST.md`(정답본)로 확인 +- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench` · 73일차 `tscan` · 74일차 `tver`와 같은 순서입니다. + +```mermaid +flowchart LR + REQ["요청서
(사람의 말)"] --> TODO["Doc/TODO.md
만들고 싶은 것 ·
요청의 말 정리"] --> SPEC["Doc/SPEC.md
확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·
직접 확인"] --> USE["uv tool install
다른 곳에서 쓰기"] +``` + +## 왜 요청서부터인가 + +요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 03 과제 1에서 보고서 숫자를 **먼저 손으로 계산**해 본 것과 같습니다: 무엇이 맞는지 알아야 맞았는지 말할 수 있습니다. + +| 단계 | 누가 | 결과 | +| --- | --- | --- | +| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 | +| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 | +| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 | +| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 | +| 확인 | **나** | 직접 `uv run tcost …`, `pytest`, 요구 표에 체크 | + +## 요청 (재무 · 운영 담당에게서 온 메시지, 그대로) + +> 오늘 헬프데스크 비용을 재는 거 봤어요. 그런데 얼마인지 알려면 **n8n에서 실제로 돌려 봐야** 하잖아요. 돌릴 때마다 돈이 들고요. 지시문을 고치거나 모델을 바꾸기 **전에** "이렇게 바꾸면 얼마"를 미리 계산해 보는 작은 도구가 있으면 좋겠어요. 터미널에서 돌아가면 되고, **어느 폴더에서나** `tcost`라고 치면 되게 해 주세요. +> +> ① 지시문 같은 글 파일을 주면 **토큰이 몇 개**인지, 우리가 쓰는 모델마다 **한 번 보내면 얼마, 1,000번이면 얼마**인지 표로 보여 주세요. +> +> ② 돈은 보내는 것만 드는 게 아니잖아요. **답(출력)도** 몇 토큰쯤 나온다고 치고 계산할 수 있게 해 주세요. 출력이 더 비싸다면서요. +> +> ③ 단가는 자꾸 바뀐대요. 프로그램 안에 숫자를 박지 말고 **파일로** 두고, 그 파일에 **언제 · 어디서** 확인한 가격인지 같이 적어 주세요. 확인한 지 **한 달이 넘었으면** 알려 주세요. +> +> ④ 워크플로우 파일을 주면 그 안의 **LLM 단계마다** 어느 모델로 무엇을 보내는지, 매번 같이 보내는 지시문이 몇 토큰인지 보여 주세요. 오늘 "규정 전체를 매번 보낸다"를 찾은 것처럼요. +> +> ⑤ 오늘 Lab의 **테스트 보고서**(`runs` 폴더)에는 실제로 쓴 토큰과 `cost_usd`가 있잖아요. 그걸 모아서 합 · 평균, **모델별 · 사람별 · 시간대별**로 보여 주세요. +> +> ⑥ 그 평균으로 "하루 몇 건 · 한 달 며칠이면 **한 달에 얼마**"를 바로 계산해 주세요. 매달 초에 물어보거든요. +> +> ⑦ 같은 지시문을 **모델 두 개로** 나란히 놓고, 싼 쪽이 몇 퍼센트 싼지 보여 주세요. +> +> ⑧ **화면에서** 볼 수 있으면 좋겠어요. 지시문 · 사용량 · 비교를 탭으로 나누고, 시간대별로는 막대 같은 걸로요. +> +> ⑨ 하루 예산을 주면 "이 예산이면 **하루 몇 번** 부를 수 있다"를 모델마다 알려 주세요. +> +> ⑩ 나중에 배포 전에 자동으로 돌릴 거라, **다른 프로그램이 읽을 수 있는** 결과도 주세요. 그리고 "한 번에 얼마 넘으면 안 됨"을 주면, **넘을 때 실패하는 종료 코드**를 주세요. +> +> ⑪ 폴더를 통째로 주면 **글 파일 · 워크플로우 · 보고서**를 알아서 나눠 읽어 주세요. 테스트 케이스 파일이나 메모를 워크플로우인 줄 알면 안 돼요. +> +> ⑫ 오늘 `gpt-4.1-nano`처럼 이름이 바뀌거나 없어진 모델이 있잖아요. **단가표에 없는 모델**을 넣으면 0원으로 계산하지 말고 알려 주세요. "공짜"라고 나오면 큰일 나요. +> +> 아, 그리고 이 도구는 **OpenAI를 부르면 안 돼요.** 미리 계산하려고 만드는 건데 계산하느라 돈이 들면 안 되잖아요. 시험할 때도요. + +## 함께 받은 것 + +| 파일 · 자료 | 메모 | +| --- | --- | +| `reference/baseline-cost.json` · `reference/lab02-example1-lean.json` | 시험 자료. `Answer` 지시문이 크게 다름 (Lab 02 예제 1) | +| 오늘 Lab의 `runs/*.json` | 테스트 보고서. 결과마다 `request` · `response`(`usage.by_step` · `cost_usd`가 있을 수도 없을 수도) | +| `data-api/data/prices.json` | 오늘 단가 (1M 토큰당 달러, 2026-10-28 OpenAI 모델 문서에서 확인) | +| `requests/long-printer.json` | 아주 긴 질문 (Lab 02 과제 2). 글 파일로 꺼내 `count`에 | +| 토큰 세는 라이브러리 | 예: `tiktoken` (`o200k_base`). 이 컴퓨터에서 셈 | + +## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이) + +`~/vibe/tcost/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다. + +```markdown +# tcost TODO + +## 만들고 싶은 것 (한 문단) +어느 폴더에서나 `tcost`로 LLM을 부르기 전에 토큰과 비용을 미리 계산하고, 테스트 보고서의 실제 사용량을 모아 ... + +## 요청의 말 → 할 일 +| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 | +| --- | --- | --- | --- | +| ① | 글 파일의 토큰 · 모델마다 비용 | | 꼭 | +| ② | 출력도 | | | +| ... | | | | +| 끝 | OpenAI를 부르지 않기 | | | + +## 요청에 없지만 걱정되는 것 +## 오늘 50분 안에 못 할 것 같은 것 +``` + +예 하나만 보여 드립니다. + +| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 | +| --- | --- | --- | +| ⑫ "단가표에 없는 모델은 0원으로 계산하지 말고" | "모르는 모델 이름이면 계산하지 않고 멈춤, 아는 이름 목록을 보여 줌" | R12: `tcost count x.txt --model gpt-4.1-nano` → 오류 · 종료 코드 2 · 메시지에 `gpt-5.4-nano` | + +
▶ 1단계 · 꼭 할 것과 나중에 할 것 + +요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자가 "매달 초에 물어보거든요"라고 한 것은 무엇인가요? 그리고 비교 · 예산 · 화면을 만들려면 먼저 무엇이 있어야 하나요? (단가 · 토큰 → 비용 식 → 보고서 모으기 → 화면) +
+ +
▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗) + +- ① "토큰이 몇 개": 내가 센 토큰과 OpenAI가 센 토큰이 같을까요? 다르면 무엇으로 맞춰 볼까요? (보고서의 `prompt_tokens`) +- ③ "한 달이 넘었으면": 오늘 날짜를 프로그램이 직접 읽으면 테스트가 날마다 달라집니다. 어떻게 시험할까요? +- ④ "LLM 단계마다": n8n 워크플로우의 LLM은 HTTP 노드(오늘)일 수도, AI Agent 노드(66~74일차)일 수도 있습니다. 지시문이 다른 노드에서 오면(표현식) 몇 토큰이라고 할까요? +- ⑤ "보고서": 400 · 429 응답이나 요약 케이스처럼 `usage`가 없는 결과는? 건너뛰었다고 알려 줘야 하지 않을까요? +- ⑥ "평균": 호출 수로 나눌까요, 요청 수로 나눌까요? (Lab 03 과제 1) +- 끝 "OpenAI를 부르지 않기": 토큰을 세는 라이브러리가 처음에 인코딩 파일을 내려받는 것은 괜찮을까요? +
+ +## 그다음 + +[VIBE-Q-TCOST.md](VIBE-Q-TCOST.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다. diff --git a/Last3Mon/75/cases/lab01-challenge.json b/Last3Mon/75/cases/lab01-challenge.json new file mode 100644 index 0000000..b772805 --- /dev/null +++ b/Last3Mon/75/cases/lab01-challenge.json @@ -0,0 +1,72 @@ +{ + "webhook": "my-cost", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "기록 1 · VPN", + "request": "requests/faq-vpn.json", + "expect": { + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "기록 2 · 접수", + "request": "requests/ticket-own.json", + "expect": { + "contains": { + "answer": [ + "처리 중", + "대기 중" + ] + } + } + }, + { + "name": "기록 3 · 다른 사람", + "request": "requests/faq-password.json", + "expect": { + "contains": { + "answer": [ + "비밀번호 찾기", + "휴대폰" + ] + } + } + }, + { + "name": "사용량 요약 · 요청 3건", + "url": "http://localhost:8800/usage/summary", + "body": {}, + "expect": { + "equals": { + "requests": 3 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.05 + ] + }, + "contains": { + "by_user": [ + "이준호", + "박서연", + "한지훈" + ], + "by_model": [ + "gpt-4.1-mini" + ] + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab01-challenge2.json b/Last3Mon/75/cases/lab01-challenge2.json new file mode 100644 index 0000000..c5917c4 --- /dev/null +++ b/Last3Mon/75/cases/lab01-challenge2.json @@ -0,0 +1,104 @@ +{ + "webhook": "my-cost", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "usage": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "usage": "ok" + } + } + ], + "cases": [ + { + "name": "기록 API 고장(500) · 답은 나감", + "request": "requests/faq-vpn.json", + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "usage": "error" + } + } + ], + "expect": { + "equals": { + "recorded": false + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "기록 API 느림(8초) · 답은 나감", + "request": "requests/faq-printer.json", + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "usage": "slow", + "slow_seconds": 8 + } + } + ], + "expect": { + "equals": { + "recorded": false + }, + "contains": { + "answer": [ + "대기열" + ] + }, + "max_seconds": 15 + } + }, + { + "name": "고친 뒤 · 기록됨", + "request": "requests/faq-password.json", + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "usage": "ok" + } + } + ], + "expect": { + "equals": { + "recorded": true + }, + "contains": { + "answer": [ + "비밀번호 찾기", + "휴대폰" + ] + } + } + }, + { + "name": "요약 · 기록된 요청은 1건", + "url": "http://localhost:8800/usage/summary", + "body": {}, + "expect": { + "equals": { + "requests": 1 + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab01-mix.json b/Last3Mon/75/cases/lab01-mix.json new file mode 100644 index 0000000..cff7640 --- /dev/null +++ b/Last3Mon/75/cases/lab01-mix.json @@ -0,0 +1,54 @@ +{ + "webhook": "my-cost", + "cases": [ + { + "name": "FAQ · VPN", + "request": "requests/faq-vpn.json", + "expect": { + "status": 200 + } + }, + { + "name": "FAQ · 프린터", + "request": "requests/faq-printer.json", + "expect": { + "status": 200 + } + }, + { + "name": "FAQ · 비밀번호", + "request": "requests/faq-password.json", + "expect": { + "status": 200 + } + }, + { + "name": "FAQ · 메일", + "request": "requests/faq-mail.json", + "expect": { + "status": 200 + } + }, + { + "name": "접수 · T-1002", + "request": "requests/ticket-own.json", + "expect": { + "status": 200 + } + }, + { + "name": "접수 · T-1001", + "request": "requests/ticket-done.json", + "expect": { + "status": 200 + } + }, + { + "name": "규칙에 없는 질문", + "request": "requests/no-keyword.json", + "expect": { + "status": 200 + } + } + ] +} diff --git a/Last3Mon/75/cases/lab01.json b/Last3Mon/75/cases/lab01.json new file mode 100644 index 0000000..61cd176 --- /dev/null +++ b/Last3Mon/75/cases/lab01.json @@ -0,0 +1,55 @@ +{ + "webhook": "my-cost", + "cases": [ + { + "name": "VPN · 비용과 단계별 사용량", + "request": "requests/faq-vpn.json", + "expect": { + "range": { + "cost_usd": [ + 1e-06, + 0.01 + ], + "usage.prompt_tokens": [ + 1, + 20000 + ], + "usage.by_step.answer.cost_usd": [ + 1e-06, + 0.01 + ] + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "접수 현황 · 비용", + "request": "requests/ticket-own.json", + "expect": { + "range": { + "cost_usd": [ + 1e-06, + 0.01 + ] + }, + "contains": { + "answer": [ + "처리 중", + "대기 중" + ] + } + } + }, + { + "name": "빈 질문은 LLM 없이", + "request": "requests/empty-question.json", + "expect": { + "status": 400 + } + } + ] +} diff --git a/Last3Mon/75/cases/lab02-challenge.json b/Last3Mon/75/cases/lab02-challenge.json new file mode 100644 index 0000000..9846165 --- /dev/null +++ b/Last3Mon/75/cases/lab02-challenge.json @@ -0,0 +1,139 @@ +{ + "webhook": "my-cost", + "cases": [ + { + "name": "faq-vpn · LLM 한 번 · $0.0002 이하", + "request": "requests/faq-vpn.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "faq-password · LLM 한 번 · $0.0002 이하", + "request": "requests/faq-password.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "비밀번호 찾기", + "휴대폰" + ] + } + } + }, + { + "name": "faq-printer · LLM 한 번 · $0.0002 이하", + "request": "requests/faq-printer.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "대기열", + "전원" + ] + } + } + }, + { + "name": "faq-mail · LLM 한 번 · $0.0002 이하", + "request": "requests/faq-mail.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "보관함", + "삭제" + ] + } + } + }, + { + "name": "ticket-own · LLM 한 번 · $0.0002 이하", + "request": "requests/ticket-own.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "처리 중", + "대기 중" + ] + } + } + }, + { + "name": "ticket-done · LLM 한 번 · $0.0002 이하", + "request": "requests/ticket-done.json", + "expect": { + "equals": { + "llm_calls": 1 + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + }, + "contains": { + "answer": [ + "처리 완료", + "완료" + ] + } + } + }, + { + "name": "규칙으로 모르는 질문은 분류 + 답", + "request": "requests/no-keyword.json", + "expect": { + "equals": { + "llm_calls": 2 + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab02-challenge2.json b/Last3Mon/75/cases/lab02-challenge2.json new file mode 100644 index 0000000..0766a7e --- /dev/null +++ b/Last3Mon/75/cases/lab02-challenge2.json @@ -0,0 +1,96 @@ +{ + "webhook": "my-cost", + "before": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + } + ], + "after": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + } + ], + "cases": [ + { + "name": "긴 로그 + 끝에 질문 · 프린터", + "request": "requests/long-printer.json", + "expect": { + "equals": { + "llm_calls": 1, + "trimmed": true + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0003 + ] + }, + "contains": { + "answer": [ + "대기열" + ] + } + } + }, + { + "name": "앞에 질문 + 긴 로그 · VPN", + "request": "requests/long-vpn.json", + "expect": { + "equals": { + "llm_calls": 1, + "trimmed": true + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0003 + ] + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "짧은 질문은 그대로", + "request": "requests/faq-vpn.json", + "expect": { + "equals": { + "trimmed": false, + "question_chars": 24 + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "사람이 한도를 늘리면 자르지 않음", + "request": "requests/long-printer.json", + "before": [ + { + "url": "http://localhost:8800/budget", + "body": { + "max_question_chars": 10000 + } + } + ], + "expect": { + "equals": { + "trimmed": false + }, + "contains": { + "answer": [ + "대기열" + ] + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab02.json b/Last3Mon/75/cases/lab02.json new file mode 100644 index 0000000..849553a --- /dev/null +++ b/Last3Mon/75/cases/lab02.json @@ -0,0 +1,72 @@ +{ + "webhook": "my-cost", + "cases": [ + { + "name": "예제 1 · VPN · 보내는 양 줄이기", + "request": "requests/faq-vpn.json", + "expect": { + "range": { + "usage.by_step.answer.prompt_tokens": [ + 1, + 600 + ] + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "예제 1 · 접수 · 보내는 양 줄이기", + "request": "requests/ticket-own.json", + "expect": { + "range": { + "usage.by_step.answer.prompt_tokens": [ + 1, + 600 + ] + }, + "contains": { + "answer": [ + "처리 중", + "대기 중" + ] + } + } + }, + { + "name": "예제 1 · 비밀번호 · 품질 그대로", + "request": "requests/faq-password.json", + "expect": { + "contains": { + "answer": [ + "비밀번호 찾기", + "휴대폰" + ] + } + } + }, + { + "name": "예제 2 · 분류는 작은 모델", + "request": "requests/no-keyword.json", + "expect": { + "equals": { + "usage.by_step.classify.model": "gpt-5.4-nano" + } + } + }, + { + "name": "예제 2 · 프린터 · 품질 그대로", + "request": "requests/faq-printer.json", + "expect": { + "contains": { + "answer": [ + "대기열" + ] + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab03-challenge.json b/Last3Mon/75/cases/lab03-challenge.json new file mode 100644 index 0000000..72bca1f --- /dev/null +++ b/Last3Mon/75/cases/lab03-challenge.json @@ -0,0 +1,144 @@ +{ + "webhook": "my-cost-report", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + }, + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage", + "body": { + "items": [ + { + "request_id": "r1", + "user": "이준호", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1000, + "completion_tokens": 100, + "cost_usd": 0.00056, + "route": "자동 답변" + }, + { + "request_id": "r1", + "user": "이준호", + "step": "classify", + "model": "gpt-5.4-nano", + "prompt_tokens": 100, + "completion_tokens": 20, + "cost_usd": 4.5e-05, + "route": "자동 답변" + }, + { + "request_id": "r2", + "user": "박서연", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 2000, + "completion_tokens": 200, + "cost_usd": 0.00112, + "route": "자동 답변" + }, + { + "request_id": "r3", + "user": "이준호", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 500, + "completion_tokens": 40, + "cost_usd": 0.000264, + "route": "자동 답변" + } + ] + } + } + ], + "after": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "요약 숫자", + "body": { + "requests_per_day": 100, + "workdays": 22 + }, + "expect": { + "equals": { + "requests": 3, + "calls": 4, + "cost_usd": 0.001989, + "avg_usd": 0.000663, + "top_user": "박서연" + } + } + }, + { + "name": "모델별 · 한 달 예상", + "body": { + "requests_per_day": 100, + "workdays": 22 + }, + "expect": { + "equals": { + "projected_month_usd": 1.4586 + }, + "contains": { + "by_model": [ + "\"gpt-4.1-mini\": 0.001944" + ] + } + } + }, + { + "name": "작은 모델 몫", + "body": {}, + "expect": { + "contains": { + "by_model": [ + "\"gpt-5.4-nano\": 4.5e-05" + ] + } + } + }, + { + "name": "예산 남은 금액", + "body": {}, + "expect": { + "equals": { + "daily_budget_usd": 1.0, + "remaining_usd": 0.998011, + "over_budget": false + } + } + }, + { + "name": "예산을 넘으면", + "body": {}, + "before": [ + { + "url": "http://localhost:8800/budget", + "body": { + "daily_usd": 0.001 + } + } + ], + "expect": { + "equals": { + "over_budget": true + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab03-challenge2.json b/Last3Mon/75/cases/lab03-challenge2.json new file mode 100644 index 0000000..7b6374a --- /dev/null +++ b/Last3Mon/75/cases/lab03-challenge2.json @@ -0,0 +1,141 @@ +{ + "webhook": "my-cost-report", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + }, + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage", + "body": { + "items": [ + { + "request_id": "r1", + "user": "이준호", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1000, + "completion_tokens": 100, + "cost_usd": 0.00056, + "route": "자동 답변" + }, + { + "request_id": "r1", + "user": "이준호", + "step": "classify", + "model": "gpt-5.4-nano", + "prompt_tokens": 100, + "completion_tokens": 20, + "cost_usd": 4.5e-05, + "route": "자동 답변" + }, + { + "request_id": "r2", + "user": "박서연", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 2000, + "completion_tokens": 200, + "cost_usd": 0.00112, + "route": "자동 답변" + }, + { + "request_id": "r3", + "user": "이준호", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 500, + "completion_tokens": 40, + "cost_usd": 0.000264, + "route": "자동 답변" + } + ] + } + } + ], + "after": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "모두 nano였다면", + "body": { + "requests_per_day": 100, + "workdays": 22, + "what_if_model": "gpt-5.4-nano" + }, + "expect": { + "equals": { + "what_if.model": "gpt-5.4-nano", + "what_if.cost_usd": 0.00117, + "what_if.saving_usd": 0.000819, + "what_if.projected_month_usd": 0.858 + } + } + }, + { + "name": "원래 숫자는 그대로", + "body": { + "requests_per_day": 100, + "workdays": 22, + "what_if_model": "gpt-5.4-nano" + }, + "expect": { + "equals": { + "cost_usd": 0.001989, + "projected_month_usd": 1.4586 + } + } + }, + { + "name": "모두 mini였다면 · 아끼는 돈이 음수", + "body": { + "what_if_model": "gpt-4.1-mini" + }, + "expect": { + "equals": { + "what_if.cost_usd": 0.002016, + "what_if.saving_usd": -2.7e-05 + } + } + }, + { + "name": "없는 모델은 400", + "body": { + "what_if_model": "gpt-9" + }, + "expect": { + "status": 400, + "equals": { + "error": "unknown model" + }, + "contains": { + "known": [ + "gpt-5.4-nano" + ] + } + } + }, + { + "name": "what_if 없이 부르면 과제 1과 같음", + "body": {}, + "expect": { + "equals": { + "what_if": null, + "requests": 3 + } + } + } + ] +} diff --git a/Last3Mon/75/cases/lab03-drill.json b/Last3Mon/75/cases/lab03-drill.json new file mode 100644 index 0000000..5d19d5e --- /dev/null +++ b/Last3Mon/75/cases/lab03-drill.json @@ -0,0 +1,53 @@ +{ + "webhook": "my-cost", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "1번째 · VPN", + "request": "requests/faq-vpn.json", + "expect": { + "status": 200 + } + }, + { + "name": "2번째 · 프린터", + "request": "requests/faq-printer.json", + "expect": { + "status": 200 + } + }, + { + "name": "3번째 · 비밀번호", + "request": "requests/faq-password.json", + "expect": { + "status": 200 + } + }, + { + "name": "4번째 · 메일", + "request": "requests/faq-mail.json", + "expect": { + "status": 200 + } + }, + { + "name": "5번째 · 접수", + "request": "requests/ticket-own.json", + "expect": { + "status": 200 + } + }, + { + "name": "6번째 · VPN 다시", + "request": "requests/faq-vpn.json", + "expect": { + "status": 200 + } + } + ] +} diff --git a/Last3Mon/75/cases/lab03-limit.json b/Last3Mon/75/cases/lab03-limit.json new file mode 100644 index 0000000..7997abc --- /dev/null +++ b/Last3Mon/75/cases/lab03-limit.json @@ -0,0 +1,67 @@ +{ + "webhook": "my-cost", + "before": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + }, + { + "url": "http://localhost:8800/budget", + "body": { + "user_limit_per_min": 2 + } + } + ], + "after": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "이준호 1번째", + "request": "requests/faq-vpn.json", + "expect": { + "status": 200 + } + }, + { + "name": "이준호 2번째", + "body": { + "question": "프린터 출력이 안 돼요", + "user": "이준호" + }, + "expect": { + "status": 200 + } + }, + { + "name": "이준호 3번째 · 1분에 2번 넘음", + "request": "requests/faq-vpn.json", + "expect": { + "status": 429, + "equals": { + "route": "잠시 후", + "cost_usd": 0 + }, + "max_seconds": 1 + } + }, + { + "name": "다른 사람은 그대로", + "request": "requests/faq-password.json", + "expect": { + "status": 200 + } + } + ] +} diff --git a/Last3Mon/75/cases/lab03.json b/Last3Mon/75/cases/lab03.json new file mode 100644 index 0000000..daf5371 --- /dev/null +++ b/Last3Mon/75/cases/lab03.json @@ -0,0 +1,93 @@ +{ + "webhook": "my-cost", + "after": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "cases": [ + { + "name": "한도를 넘으면 · FAQ 원문", + "request": "requests/faq-vpn.json", + "before": [ + { + "url": "http://localhost:8800/usage-clear", + "body": {} + }, + { + "url": "http://localhost:8800/budget", + "body": { + "daily_usd": 0.001 + } + }, + { + "url": "http://localhost:8800/usage", + "body": { + "request_id": "seed", + "user": "seed", + "step": "answer", + "model": "gpt-4.1-mini", + "cost_usd": 0.002 + } + } + ], + "expect": { + "equals": { + "route": "한도 초과", + "cost_usd": 0, + "llm_calls": 0 + }, + "contains": { + "answer": [ + "인증서" + ] + } + } + }, + { + "name": "한도를 넘으면 · 접수 원문", + "request": "requests/ticket-own.json", + "expect": { + "equals": { + "route": "한도 초과", + "cost_usd": 0 + }, + "contains": { + "answer": [ + "처리 중" + ] + } + } + }, + { + "name": "한도 안이면 평소대로", + "request": "requests/faq-vpn.json", + "before": [ + { + "url": "http://localhost:8800/budget/reset", + "body": {} + }, + { + "url": "http://localhost:8800/usage-clear", + "body": {} + } + ], + "expect": { + "equals": { + "route": "자동 답변" + }, + "range": { + "cost_usd": [ + 1e-06, + 0.0002 + ] + } + } + } + ] +} diff --git a/Last3Mon/75/data-api/compose.yml b/Last3Mon/75/data-api/compose.yml new file mode 100644 index 0000000..4b3c309 --- /dev/null +++ b/Last3Mon/75/data-api/compose.yml @@ -0,0 +1,27 @@ +# Sample company data API for day 75 (day 74 API + prices + usage + budget), attached to the day 65 n8n network +# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default). +# docker compose up -d (run from this folder, after day 65 n8n is running) +# from n8n : http://data-api:8800 +# from WSL : http://localhost:8800 +name: day67-data + +services: + data-api: + image: python:3.12-slim + command: python /app/server.py + environment: + - DATA_DIR=/data + - PYTHONUNBUFFERED=1 + ports: + - "8800:8800" + volumes: + - ./server.py:/app/server.py:ro + - ./data:/data:ro + networks: + - n8n + restart: unless-stopped + +networks: + n8n: + external: true + name: day65-n8n_default diff --git a/Last3Mon/75/data-api/data/budget.json b/Last3Mon/75/data-api/data/budget.json new file mode 100644 index 0000000..6c9b313 --- /dev/null +++ b/Last3Mon/75/data-api/data/budget.json @@ -0,0 +1 @@ +{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500} diff --git a/Last3Mon/75/data-api/data/docs/device-issue-return.md b/Last3Mon/75/data-api/data/docs/device-issue-return.md new file mode 100644 index 0000000..31f66bf --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/device-issue-return.md @@ -0,0 +1,17 @@ +# 업무용 장비 지급·반납 규정 + +## 1. 지급과 교체 +- 업무용 노트북은 입사일에 지급한다. +- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다. + +## 2. 분실과 도난 +- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다. +- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다. + +## 3. 반납 +- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. +- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다. + +## 4. 개인 장비 사용 +- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다. +- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다. diff --git a/Last3Mon/75/data-api/data/docs/email-and-documents.md b/Last3Mon/75/data-api/data/docs/email-and-documents.md new file mode 100644 index 0000000..ca38d0d --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/email-and-documents.md @@ -0,0 +1,14 @@ +# 메일·문서 보안 규정 + +## 1. 메일함 +- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다. +- 오래된 메일은 보관함으로 옮겨 용량을 확보한다. + +## 2. 첨부 파일 +- 메일 첨부 파일은 25MB까지 보낼 수 있다. +- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다. +- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다. + +## 3. 문서 등급과 보관 +- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다. +- 업무 문서는 3년 동안 보관한 뒤 정리한다. diff --git a/Last3Mon/75/data-api/data/docs/meeting-room.md b/Last3Mon/75/data-api/data/docs/meeting-room.md new file mode 100644 index 0000000..4aed1c6 --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/meeting-room.md @@ -0,0 +1,12 @@ +# 회의실 이용 규정 + +## 1. 예약 +- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다. +- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다. + +## 2. 장비 +- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다. +- 프로젝터는 B회의실과 대회의실에 있다. + +## 3. 사용 후 +- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다. diff --git a/Last3Mon/75/data-api/data/docs/password-policy.md b/Last3Mon/75/data-api/data/docs/password-policy.md new file mode 100644 index 0000000..c069749 --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/password-policy.md @@ -0,0 +1,18 @@ +# 비밀번호·계정 보안 규정 + +## 1. 비밀번호 만들기 +- 비밀번호는 12자 이상으로 만든다. +- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다. +- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다. + +## 2. 변경 주기 +- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다. +- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다. + +## 3. 로그인 실패와 잠금 +- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다. +- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다. + +## 4. 다단계 인증 +- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다. +- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다. diff --git a/Last3Mon/75/data-api/data/docs/privacy.md b/Last3Mon/75/data-api/data/docs/privacy.md new file mode 100644 index 0000000..a5ba949 --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/privacy.md @@ -0,0 +1,13 @@ +# 개인정보 처리 지침 + +## 1. 수집 +- 업무에 꼭 필요한 개인정보만 수집한다. +- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다. + +## 2. 이용과 보관 +- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다. +- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다. + +## 3. 유출 대응 +- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다. +- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다. diff --git a/Last3Mon/75/data-api/data/docs/remote-work-vpn.md b/Last3Mon/75/data-api/data/docs/remote-work-vpn.md new file mode 100644 index 0000000..ae0c917 --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/remote-work-vpn.md @@ -0,0 +1,15 @@ +# 원격근무·VPN 이용 규정 + +## 1. 접속 원칙 +- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다. +- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다. + +## 2. VPN 인증서 +- VPN 인증서의 유효 기간은 1년이다. +- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다. +- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다. + +## 3. 재택근무 중 보안 +- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다. +- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다. +- VPN 접속 기록은 1년 동안 보관된다. diff --git a/Last3Mon/75/data-api/data/docs/security-incident.md b/Last3Mon/75/data-api/data/docs/security-incident.md new file mode 100644 index 0000000..a6b514e --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/security-incident.md @@ -0,0 +1,11 @@ +# 보안 사고 대응 절차 + +## 1. 피싱 메일 +- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다. +- 메일 화면의 '피싱 신고' 버튼으로 신고한다. +- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다. + +## 2. 악성코드 감염 의심 +- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다. +- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다. +- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다. diff --git a/Last3Mon/75/data-api/data/docs/software-usage.md b/Last3Mon/75/data-api/data/docs/software-usage.md new file mode 100644 index 0000000..edabe96 --- /dev/null +++ b/Last3Mon/75/data-api/data/docs/software-usage.md @@ -0,0 +1,14 @@ +# 소프트웨어 사용 규정 + +## 1. 설치와 승인 +- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다. +- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다. +- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다. + +## 2. 금지 사항 +- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다. + +## 3. 생성형 AI 서비스 +- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다. +- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다. +- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다. diff --git a/Last3Mon/75/data-api/data/employees.json b/Last3Mon/75/data-api/data/employees.json new file mode 100644 index 0000000..7b4a980 --- /dev/null +++ b/Last3Mon/75/data-api/data/employees.json @@ -0,0 +1,26 @@ +[ + { + "name": "이준호", + "dept": "영업팀" + }, + { + "name": "박서연", + "dept": "재무팀" + }, + { + "name": "최유진", + "dept": "인사팀" + }, + { + "name": "한지훈", + "dept": "개발팀" + }, + { + "name": "김민지", + "dept": "IT팀" + }, + { + "name": "정우성", + "dept": "IT팀" + } +] diff --git a/Last3Mon/75/data-api/data/faq.json b/Last3Mon/75/data-api/data/faq.json new file mode 100644 index 0000000..10eb7f1 --- /dev/null +++ b/Last3Mon/75/data-api/data/faq.json @@ -0,0 +1,14 @@ +[ + {"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."}, + {"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."}, + {"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."}, + {"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."}, + {"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."}, + {"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."} +] diff --git a/Last3Mon/75/data-api/data/prices.json b/Last3Mon/75/data-api/data/prices.json new file mode 100644 index 0000000..9068a2c --- /dev/null +++ b/Last3Mon/75/data-api/data/prices.json @@ -0,0 +1,9 @@ +{ + "currency": "USD", + "unit": "per 1M tokens", + "note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.", + "models": { + "gpt-4.1-mini": {"input": 0.40, "output": 1.60}, + "gpt-5.4-nano": {"input": 0.20, "output": 1.25} + } +} diff --git a/Last3Mon/75/data-api/data/prompts/helpdesk-p1.md b/Last3Mon/75/data-api/data/prompts/helpdesk-p1.md new file mode 100644 index 0000000..72a2224 --- /dev/null +++ b/Last3Mon/75/data-api/data/prompts/helpdesk-p1.md @@ -0,0 +1 @@ +You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체. diff --git a/Last3Mon/75/data-api/data/prompts/helpdesk-p2.md b/Last3Mon/75/data-api/data/prompts/helpdesk-p2.md new file mode 100644 index 0000000..a76b826 --- /dev/null +++ b/Last3Mon/75/data-api/data/prompts/helpdesk-p2.md @@ -0,0 +1,2 @@ +You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체. +When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. diff --git a/Last3Mon/75/data-api/data/prompts/helpdesk-p3.md b/Last3Mon/75/data-api/data/prompts/helpdesk-p3.md new file mode 100644 index 0000000..25cbf94 --- /dev/null +++ b/Last3Mon/75/data-api/data/prompts/helpdesk-p3.md @@ -0,0 +1 @@ +You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead. diff --git a/Last3Mon/75/data-api/data/releases.json b/Last3Mon/75/data-api/data/releases.json new file mode 100644 index 0000000..be2b51b --- /dev/null +++ b/Last3Mon/75/data-api/data/releases.json @@ -0,0 +1,12 @@ +{ + "helpdesk": { + "active": "1.0.0", + "releases": { + "1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"}, + "1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"}, + "1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"}, + "2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"}, + "2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"} + } + } +} diff --git a/Last3Mon/75/data-api/data/tickets.json b/Last3Mon/75/data-api/data/tickets.json new file mode 100644 index 0000000..9d9d3ea --- /dev/null +++ b/Last3Mon/75/data-api/data/tickets.json @@ -0,0 +1,6 @@ +{ + "T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}}, + "T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}}, + "T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}}, + "T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}} +} diff --git a/Last3Mon/75/data-api/server.py b/Last3Mon/75/data-api/server.py new file mode 100644 index 0000000..5033244 --- /dev/null +++ b/Last3Mon/75/data-api/server.py @@ -0,0 +1,406 @@ +"""Sample company data API for the day 75 labs (day 74 API + prices + usage records + budget; +Python standard library only). + +GET /health -> {"status": "ok"} (never affected by the fault switch) +GET /faq?q= -> {"query": ..., "results": [FAQ entries]} +GET /tickets/ -> ticket record, or 404 {"error": "ticket not found"} +GET /tickets/?requester= + -> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else +GET /employees -> {"employees": [{"name", "dept"}]} +GET /employees/ -> {"name", "dept"}, or 404 {"error": "unknown employee"} +GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown) +GET /docs/ -> {"name", "title", "text"}, or 404 {"error": "doc not found"} + +Day 72 additions (state lives in memory; restarting the container resets it): +GET /admin/fault -> {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8} +POST /admin/fault body {"faq": "ok"|"slow"|"error", "tickets": ..., "prompts": ..., "usage": ..., "slow_seconds": 8} + "slow" waits slow_seconds before answering, "error" answers HTTP 500. +GET /cache/ -> {"key", "value", "hits"} or 404 {"error": "not cached"} +POST /cache/ body {"value": , "ttl": 600} (PUT works too) +POST /cache-clear -> {"cleared": } +GET /cache-stats -> {"entries", "hits", "misses"} + +Day 73 addition (in memory, newest 1000 kept): +POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"} +GET /audit -> {"events": [{"t", ...the body}]} +POST /audit-clear -> {"cleared": } + +Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory): +GET /prompts/ -> {"name", "versions": ["p1", ...]} +GET /prompts// -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts") +GET /releases/ -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}} +POST /releases/ body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null} + When require_gate is on, a release can become active or canary only if its latest gate + record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}. +POST /releases//policy body {"require_gate": true|false} +POST /releases//reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay) +POST /gates/ body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...} + -> {"stored": true, "count"} +GET /gates/ -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept) +POST /gates//clear -> {"cleared": } + +Day 75 additions (usage and budget in memory): +GET /prices -> {"currency", "unit", "models": {"gpt-4.1-mini": {"input", "output"}, ...}} (USD per 1M tokens) +POST /usage body {"items": [{"request_id", "user", "step", "model", "prompt_tokens", + "completion_tokens", "cost_usd", "route"}]} (one item per LLM call, or one item + without "items") -> {"stored": n, "total"} (fault switch "usage": error -> 500, + slow -> waits slow_seconds, then 503 and nothing is stored) +GET /usage/summary -> {"requests" (distinct request_id), "calls", "prompt_tokens", "completion_tokens", + "cost_usd", "by_model", "by_user", "by_route"} (POST works too) +GET /usage/user/?window=60 -> {"user", "window", "count"} distinct requests of that user in the last N seconds +POST /usage-clear -> {"cleared": n} +GET /budget -> {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"} +POST /budget body {"daily_usd": 0.5} and/or {"user_limit_per_min": 3} and/or {"max_question_chars": 500} +POST /budget/reset -> back to data/budget.json +""" + +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data")) +PORT = int(os.getenv("PORT", "8800")) + +LOCK = threading.Lock() +FAULT = {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8} +CACHE: dict[str, dict] = {} +STATS = {"hits": 0, "misses": 0} +AUDIT: list[dict] = [] +RELEASES: dict = {} +GATES: dict[str, list[dict]] = {} +USAGE: list[dict] = [] +BUDGET: dict = {} +BUDGET_KEYS = {"daily_usd": float, "user_limit_per_min": int, "max_question_chars": int} + + +def budget(): + if not BUDGET: + BUDGET.update(load("budget.json")) + used = round(sum(float(item.get("cost_usd") or 0) for item in USAGE), 8) + return {**BUDGET, "used_usd": used, "remaining_usd": round(BUDGET["daily_usd"] - used, 8), "over": used >= BUDGET["daily_usd"]} + + +def summary(): + def add(table, key, item): + row = table.setdefault(str(key), {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}) + row["calls"] += 1 + row["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + row["completion_tokens"] += int(item.get("completion_tokens") or 0) + row["cost_usd"] = round(row["cost_usd"] + float(item.get("cost_usd") or 0), 8) + total = {"requests": 0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0} + by_model, by_user, by_route = {}, {}, {} + seen = set() + for index, item in enumerate(USAGE): + key = item.get("request_id") or f"item-{index}" + if key not in seen: + seen.add(key) + total["requests"] += 1 + total["calls"] += 1 + total["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + total["completion_tokens"] += int(item.get("completion_tokens") or 0) + total["cost_usd"] = round(total["cost_usd"] + float(item.get("cost_usd") or 0), 8) + add(by_model, item.get("model", "?"), item) + add(by_user, item.get("user", "?"), item) + add(by_route, item.get("route", "?"), item) + return {**total, "by_model": by_model, "by_user": by_user, "by_route": by_route} + + +def load(name): + return json.loads((DATA_DIR / name).read_text(encoding="utf-8")) + + +def releases(): + if not RELEASES: + for name, item in load("releases.json").items(): + RELEASES[name] = {"active": item["active"], "canary": None, "require_gate": False, "releases": item["releases"]} + return RELEASES + + +def last_gate(name, release): + for record in reversed(GATES.get(name, [])): + if record.get("release") == release: + return record + return None + + +def search_faq(query, top_k=2): + words = {word for word in query.lower().split() if len(word) > 1} + scored = [] + for item in load("faq.json"): + keywords = {keyword.lower() for keyword in item["keywords"]} + score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords)) + if score: + scored.append({"score": score, **item}) + scored.sort(key=lambda item: item["score"], reverse=True) + return scored[:top_k] + + +class Handler(BaseHTTPRequestHandler): + def send_json(self, status, body): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def url(self): + # Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged. + return urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace")) + + def body(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + def faulty(self, target): + """Apply the fault switch to one endpoint group. Returns True when the response was already sent.""" + mode = FAULT.get(target, "ok") + if mode == "slow": + time.sleep(float(FAULT.get("slow_seconds", 8))) + elif mode == "error": + self.send_json(500, {"error": f"{target} service is failing (fault switch)"}) + return True + return False + + def do_GET(self): + url = self.url() + if url.path == "/health": + return self.send_json(200, {"status": "ok"}) + if url.path == "/admin/fault": + return self.send_json(200, FAULT) + if url.path == "/audit": + with LOCK: + return self.send_json(200, {"events": list(AUDIT)}) + if url.path == "/prices": + return self.send_json(200, load("prices.json")) + if url.path == "/usage/summary": + with LOCK: + return self.send_json(200, summary()) + if url.path.startswith("/usage/user/"): + name = unquote(url.path.removeprefix("/usage/user/")) + window = float(parse_qs(url.query).get("window", ["60"])[0]) + now = time.time() + with LOCK: + count = len({item.get("request_id") or f"item-{i}" for i, item in enumerate(USAGE) + if item.get("user") == name and now - item["at"] <= window}) + return self.send_json(200, {"user": name, "window": window, "count": count}) + if url.path == "/budget": + with LOCK: + return self.send_json(200, budget()) + if url.path == "/cache-stats": + with LOCK: + return self.send_json(200, {"entries": len(CACHE), **STATS}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + with LOCK: + entry = CACHE.get(key) + if entry and entry["expires"] > time.time(): + entry["hits"] += 1 + STATS["hits"] += 1 + return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]}) + CACHE.pop(key, None) + STATS["misses"] += 1 + return self.send_json(404, {"error": "not cached", "key": key}) + if url.path == "/faq": + if self.faulty("faq"): + return None + query = parse_qs(url.query).get("q", [""])[0] + return self.send_json(200, {"query": query, "results": search_faq(query)}) + if url.path == "/employees": + return self.send_json(200, {"employees": load("employees.json")}) + if url.path.startswith("/employees/"): + name = unquote(url.path.removeprefix("/employees/")).strip() + for person in load("employees.json"): + if person["name"] == name: + return self.send_json(200, person) + return self.send_json(404, {"error": "unknown employee", "name": name}) + if url.path == "/docs": + docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()} + for f in sorted((DATA_DIR / "docs").glob("*.md"))] + return self.send_json(200, {"docs": docs}) + if url.path.startswith("/docs/"): + name = unquote(url.path.removeprefix("/docs/")).strip() + path = DATA_DIR / "docs" / f"{name}.md" + if "/" in name or not path.exists(): + return self.send_json(404, {"error": "doc not found", "name": name}) + text = path.read_text(encoding="utf-8") + return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text}) + if url.path.startswith("/tickets/"): + if self.faulty("tickets"): + return None + ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper() + ticket = load("tickets.json").get(ticket_id) + if ticket is None: + return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id}) + requester = parse_qs(url.query).get("requester", [None])[0] + if requester is not None and requester.strip() != ticket["requester"]: + return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id}) + return self.send_json(200, {"ticket_id": ticket_id, **ticket}) + if url.path.startswith("/prompts/"): + if self.faulty("prompts"): + return None + parts = [unquote(p) for p in url.path.removeprefix("/prompts/").split("/") if p] + folder = DATA_DIR / "prompts" + if len(parts) == 1: + versions = sorted(f.stem.removeprefix(parts[0] + "-") for f in folder.glob(f"{parts[0]}-*.md")) + return self.send_json(200 if versions else 404, {"name": parts[0], "versions": versions}) + if len(parts) == 2 and "/" not in parts[1] and (folder / f"{parts[0]}-{parts[1]}.md").exists(): + text = (folder / f"{parts[0]}-{parts[1]}.md").read_text(encoding="utf-8").strip() + return self.send_json(200, {"name": parts[0], "version": parts[1], "text": text}) + return self.send_json(404, {"error": "prompt not found", "path": url.path}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + with LOCK: + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + return self.send_json(200, {"name": name, **item}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + with LOCK: + return self.send_json(200, {"name": name, "gates": list(GATES.get(name, []))}) + return self.send_json(404, {"error": "unknown path"}) + + def do_POST(self): + url = self.url() + body = self.body() + if body is None or not isinstance(body, dict): + return self.send_json(400, {"error": "body must be a JSON object"}) + if url.path == "/admin/fault": + for target in ("faq", "tickets", "prompts", "usage"): + if target in body: + if body[target] not in ("ok", "slow", "error"): + return self.send_json(400, {"error": f"{target} must be ok, slow or error"}) + FAULT[target] = body[target] + if "slow_seconds" in body: + FAULT["slow_seconds"] = max(0.0, min(float(body["slow_seconds"]), 60.0)) + return self.send_json(200, FAULT) + if url.path == "/audit": + with LOCK: + AUDIT.append({"t": round(time.time(), 3), **body}) + del AUDIT[:-1000] + count = len(AUDIT) + return self.send_json(200, {"stored": True, "count": count}) + if url.path == "/usage": + if FAULT.get("usage") == "slow": + # a hung record service: answers late and keeps nothing (a caller that gave up must not be counted later) + time.sleep(float(FAULT.get("slow_seconds", 8))) + return self.send_json(503, {"error": "usage service timed out (fault switch)", "stored": 0}) + if self.faulty("usage"): + return None + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + USAGE.append({**item, "at": time.time()}) + return self.send_json(200, {"stored": len(items), "total": len(USAGE)}) + if url.path == "/usage/summary": + with LOCK: + return self.send_json(200, summary()) + if url.path == "/usage-clear": + with LOCK: + count = len(USAGE) + USAGE.clear() + return self.send_json(200, {"cleared": count}) + if url.path in ("/budget", "/budget/reset"): + with LOCK: + if url.path == "/budget/reset": + BUDGET.clear() + budget() + for key, kind in BUDGET_KEYS.items(): + if key in body: + BUDGET[key] = kind(body[key]) + return self.send_json(200, budget()) + if url.path == "/audit-clear": + with LOCK: + count = len(AUDIT) + AUDIT.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/cache-clear": + with LOCK: + count = len(CACHE) + CACHE.clear() + STATS.update(hits=0, misses=0) + return self.send_json(200, {"cleared": count}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + if not key or "value" not in body: + return self.send_json(400, {"error": "key in the path and value in the body are required"}) + ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0)) + with LOCK: + CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0} + return self.send_json(200, {"key": key, "stored": True, "ttl": ttl}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + clear = name.endswith("/clear") + name = name.removesuffix("/clear") + with LOCK: + if clear: + count = len(GATES.pop(name, [])) + return self.send_json(200, {"cleared": count}) + if not body.get("release") or body.get("verdict") not in ("promote", "rollback"): + return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"}) + records = GATES.setdefault(name, []) + records.append({"t": round(time.time(), 3), **body}) + del records[:-200] + return self.send_json(200, {"stored": True, "count": len(records)}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + action = "" + for suffix in ("/reset", "/policy"): + if name.endswith(suffix): + action, name = suffix[1:], name.removesuffix(suffix) + with LOCK: + if action == "reset": + RELEASES.pop(name, None) + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + if action == "reset": + return self.send_json(200, {"name": name, **item}) + if action == "policy": + if not isinstance(body.get("require_gate"), bool): + return self.send_json(400, {"error": "require_gate must be true or false"}) + item["require_gate"] = body["require_gate"] + return self.send_json(200, {"name": name, **item}) + wanted = [] + if "active" in body: + if body["active"] not in item["releases"]: + return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])}) + wanted.append(body["active"]) + canary = body.get("canary") + if "canary" in body and canary is not None: + if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \ + or not 0 <= int(canary.get("percent", -1)) <= 100: + return self.send_json(400, {"error": "canary needs a known release and percent 0..100"}) + canary = {"release": canary["release"], "percent": int(canary["percent"])} + wanted.append(canary["release"]) + if item["require_gate"]: + for release in wanted: + gate = last_gate(name, release) + if not gate or gate.get("verdict") != "promote": + return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate}) + if "active" in body: + item["active"] = body["active"] + if "canary" in body: + item["canary"] = canary + return self.send_json(200, {"name": name, **item}) + return self.send_json(404, {"error": "unknown path"}) + + do_PUT = do_POST + + def log_message(self, fmt, *args): + print(f"{self.address_string()} {fmt % args}", flush=True) + + +if __name__ == "__main__": + print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True) + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/Last3Mon/75/my-work/README.md b/Last3Mon/75/my-work/README.md new file mode 100644 index 0000000..14dceec --- /dev/null +++ b/Last3Mon/75/my-work/README.md @@ -0,0 +1,5 @@ +# 수업 결과물 + +- CLI 코딩 에이전트가 만든 워크플로우 JSON (`cost.json`, `cost-report.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 처음에는 `cp reference/baseline-cost.json my-work/cost.json`. +- `COST.md`: [비용 계획](../templates/COST-PLAN-TEMPLATE.md)을 복사해 채웁니다. +- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다. diff --git a/Last3Mon/75/reference/baseline-cost.json b/Last3Mon/75/reference/baseline-cost.json new file mode 100644 index 0000000..a07ddf5 --- /dev/null +++ b/Last3Mon/75/reference/baseline-cost.json @@ -0,0 +1,337 @@ +{ + "name": "75 IT helpdesk baseline (provided)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "base-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "ed2b30c2-2c16-5aaa-8ef1-30e5db0ed1b8", + "position": [ + 0, + 300 + ], + "id": "40660866-5455-5c0b-9b7d-738dd69e81b6" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "bc6dc9fb-3a9a-598e-87b5-84c56b87bf41" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "9f9c9beb-967b-5ef1-b638-79897016df4a" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Question webhook').first().json.body.question }}" + } + ] + } + }, + "position": [ + 440, + 300 + ], + "id": "e42438af-4520-5622-994e-6a726a8c787d" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 660, + 300 + ], + "id": "be57699b-a3ee-587d-991b-a7266e49e31a" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 880, + 300 + ], + "id": "f6ffca0c-45a6-526a-9ede-878a9e9090e0" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.\\n- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.\\n- VPN 접속 기록은 1년 동안 보관된다.\\n\\n# 보안 사고 대응 절차\\n\\n## 1. 피싱 메일\\n- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.\\n- 메일 화면의 '피싱 신고' 버튼으로 신고한다.\\n- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.\\n\\n## 2. 악성코드 감염 의심\\n- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.\\n- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.\\n- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.\\n\\n# 소프트웨어 사용 규정\\n\\n## 1. 설치와 승인\\n- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.\\n- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.\\n- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.\\n\\n## 2. 금지 사항\\n- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.\\n\\n## 3. 생성형 AI 서비스\\n- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.\\n- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.\\n- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify($('Search FAQ').first().json.results ?? []) + '\\n접수 건: ' + JSON.stringify($('Get ticket').first().json) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 1100, + 300 + ], + "id": "f343fe5b-4ba5-58d6-9880-2f88e6e56b37" + }, + { + "name": "Review", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 1320, + 300 + ], + "id": "f0ccdaf6-2e82-5b98-9b1d-61479d53fc9a" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step } };\nreturn [{ json: result }];" + }, + "position": [ + 1540, + 300 + ], + "id": "e888a357-ae1a-5a73-bad7-aab2e26e2f93" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1760, + 300 + ], + "id": "01f85767-942d-5931-bd59-036b1567805e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Review", + "type": "main", + "index": 0 + } + ] + ] + }, + "Review": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab01-example2-cost.json b/Last3Mon/75/reference/lab01-example2-cost.json new file mode 100644 index 0000000..5fbbe63 --- /dev/null +++ b/Last3Mon/75/reference/lab01-example2-cost.json @@ -0,0 +1,362 @@ +{ + "name": "75 IT helpdesk + cost (reference)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0", + "position": [ + 0, + 300 + ], + "id": "3ccc3834-a583-51bd-a80f-b20124e32b64" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "f3591be9-5676-5bb7-ab46-6984719af080" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "baa4796d-564b-5cc0-949a-d79738b12ba7" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Question webhook').first().json.body.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "e2005518-7a82-579a-acda-cc74b33968a9" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 2420, + 300 + ], + "id": "07e553a6-f8eb-5513-a566-db99f5b11d29" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.\\n- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.\\n- VPN 접속 기록은 1년 동안 보관된다.\\n\\n# 보안 사고 대응 절차\\n\\n## 1. 피싱 메일\\n- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.\\n- 메일 화면의 '피싱 신고' 버튼으로 신고한다.\\n- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.\\n\\n## 2. 악성코드 감염 의심\\n- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.\\n- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.\\n- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.\\n\\n# 소프트웨어 사용 규정\\n\\n## 1. 설치와 승인\\n- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.\\n- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.\\n- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.\\n\\n## 2. 금지 사항\\n- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.\\n\\n## 3. 생성형 AI 서비스\\n- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.\\n- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.\\n- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify($('Search FAQ').first().json.results ?? []) + '\\n접수 건: ' + JSON.stringify($('Get ticket').first().json) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 2640, + 300 + ], + "id": "fd329729-e718-56ff-805c-aa298d26c0b2" + }, + { + "name": "Review", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 2860, + 300 + ], + "id": "f289c264-5e1f-523d-8e18-67ba4cbb166c" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "d85f4d2b-0957-5a81-a54b-35ba55509871" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Review", + "type": "main", + "index": 0 + } + ] + ] + }, + "Review": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab02-example1-lean.json b/Last3Mon/75/reference/lab02-example1-lean.json new file mode 100644 index 0000000..827b974 --- /dev/null +++ b/Last3Mon/75/reference/lab02-example1-lean.json @@ -0,0 +1,423 @@ +{ + "name": "75 IT helpdesk + lean prompt (reference)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0", + "position": [ + 0, + 300 + ], + "id": "3ccc3834-a583-51bd-a80f-b20124e32b64" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "f3591be9-5676-5bb7-ab46-6984719af080" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "baa4796d-564b-5cc0-949a-d79738b12ba7" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Question webhook').first().json.body.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "e2005518-7a82-579a-acda-cc74b33968a9" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 2420, + 300 + ], + "id": "07e553a6-f8eb-5513-a566-db99f5b11d29" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2640, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "fd329729-e718-56ff-805c-aa298d26c0b2" + }, + { + "name": "Review", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 2860, + 300 + ], + "id": "f289c264-5e1f-523d-8e18-67ba4cbb166c" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "d85f4d2b-0957-5a81-a54b-35ba55509871" + }, + { + "name": "Record usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/usage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 3000 + } + }, + "position": [ + 3740, + 300 + ], + "onError": "continueRegularOutput", + "id": "a46b3503-5039-5cb6-890e-69149fe23f55" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];" + }, + "position": [ + 3960, + 300 + ], + "id": "1b8127ae-a43b-5096-8f94-c96892a085db" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Review", + "type": "main", + "index": 0 + } + ] + ] + }, + "Review": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Record usage", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record usage": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab02-example2-models.json b/Last3Mon/75/reference/lab02-example2-models.json new file mode 100644 index 0000000..e929444 --- /dev/null +++ b/Last3Mon/75/reference/lab02-example2-models.json @@ -0,0 +1,423 @@ +{ + "name": "75 IT helpdesk + small models (reference)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0", + "position": [ + 0, + 300 + ], + "id": "3ccc3834-a583-51bd-a80f-b20124e32b64" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "f3591be9-5676-5bb7-ab46-6984719af080" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "baa4796d-564b-5cc0-949a-d79738b12ba7" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Question webhook').first().json.body.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "e2005518-7a82-579a-acda-cc74b33968a9" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2420, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "07e553a6-f8eb-5513-a566-db99f5b11d29" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2640, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "fd329729-e718-56ff-805c-aa298d26c0b2" + }, + { + "name": "Review", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2860, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "f289c264-5e1f-523d-8e18-67ba4cbb166c" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-5.4-nano\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "d85f4d2b-0957-5a81-a54b-35ba55509871" + }, + { + "name": "Record usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/usage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 3000 + } + }, + "position": [ + 3740, + 300 + ], + "onError": "continueRegularOutput", + "id": "a46b3503-5039-5cb6-890e-69149fe23f55" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];" + }, + "position": [ + 3960, + 300 + ], + "id": "1b8127ae-a43b-5096-8f94-c96892a085db" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Review", + "type": "main", + "index": 0 + } + ] + ] + }, + "Review": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Record usage", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record usage": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab02-example3-nano-answer.json b/Last3Mon/75/reference/lab02-example3-nano-answer.json new file mode 100644 index 0000000..ef5af23 --- /dev/null +++ b/Last3Mon/75/reference/lab02-example3-nano-answer.json @@ -0,0 +1,423 @@ +{ + "name": "75 IT helpdesk, answer on nano too (compare)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "nano-answer", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "5e6c96ea-4e5c-519f-856d-8c51d0aca7f1", + "position": [ + 0, + 300 + ], + "id": "e81c1d81-9bfa-5705-85d6-6bb9af778dac" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "2a7fa1d5-b6c4-5d3c-8aa3-629ab1cee629" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "35acd167-b6ce-5200-98a3-a78e154f7921" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "d9a552b8-3be7-5d86-9f91-d54ab54eb204" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Question webhook').first().json.body.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "b7a3a1e8-444c-5de0-a606-1e185b9b9cc4" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "8453f9f2-2e94-53ea-b642-3d7d4588dfaa" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2420, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "f75f2a89-03b2-5b47-9727-291ca19df673" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2640, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "5efff901-9caf-5f71-b382-247b020bccf6" + }, + { + "name": "Review", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2860, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "a6950b06-d98e-5b8b-a9e9-36da0617802f" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-5.4-nano\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "ee78f30e-6b80-58ab-b322-f14e158b9ac1" + }, + { + "name": "Record usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/usage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 3000 + } + }, + "position": [ + 3740, + 300 + ], + "onError": "continueRegularOutput", + "id": "140f6419-d9a4-5ea2-bd75-f8590b7ce6cc" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];" + }, + "position": [ + 3960, + 300 + ], + "id": "d8cf1ae5-e8ea-5254-8bbf-2ec8cb2467c6" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6e0a5ef6-c94d-5215-8dd3-c7101cd147e8" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Review", + "type": "main", + "index": 0 + } + ] + ] + }, + "Review": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Record usage", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record usage": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab03-example1-budget.json b/Last3Mon/75/reference/lab03-example1-budget.json new file mode 100644 index 0000000..7aa095b --- /dev/null +++ b/Last3Mon/75/reference/lab03-example1-budget.json @@ -0,0 +1,623 @@ +{ + "name": "75 IT helpdesk + daily budget (reference)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0", + "position": [ + 0, + 300 + ], + "id": "3ccc3834-a583-51bd-a80f-b20124e32b64" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "f3591be9-5676-5bb7-ab46-6984719af080" + }, + { + "name": "Get budget", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/budget", + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "4d62a650-0746-5e07-b811-2da4fdd28051" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "baa4796d-564b-5cc0-949a-d79738b12ba7" + }, + { + "name": "Prepare question", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const q = String($('Question webhook').first().json.body.question ?? '');\nconst max = Number($('Get budget').first().json.max_question_chars ?? 500);\nif (q.length <= max) return [{ json: { question: q, trimmed: false, question_chars: q.length } }];\nconst head = Math.floor(max * 0.4);\nconst question = q.slice(0, head) + ' … ' + q.slice(q.length - (max - head));\nreturn [{ json: { question, trimmed: true, question_chars: q.length } }];" + }, + "position": [ + 1320, + 300 + ], + "id": "07d55a70-f59d-584f-bbae-3cfcf59973ac" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Prepare question').first().json.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "e2005518-7a82-579a-acda-cc74b33968a9" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca" + }, + { + "name": "Within budget?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ !$('Get budget').first().json.over }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 1980, + 300 + ], + "id": "26f8a52b-1937-5bb0-ab2d-2915cd25b97c" + }, + { + "name": "Over budget", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst t = $('Get ticket').first().json;\nlet answer = '오늘 자동 답변 한도를 넘어 담당자에게 연결합니다.';\nif (t.status) answer = `${t.ticket_id} 접수 건은 현재 '${t.status}' 상태입니다. ${t.note ?? ''} (오늘 자동 답변 한도를 넘어 원문으로 안내합니다.)`;\nelse if (results.length) answer = `${results[0].answer} (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)`;\nreturn [{ json: { route: '한도 초과', answer, sources: results.map((r) => r.id), llm_calls: 0,\n usage: { prompt_tokens: 0, completion_tokens: 0, by_step: {} }, cost_usd: 0 } }];" + }, + "position": [ + 2200, + 600 + ], + "id": "094e42df-cd03-5518-9e78-2f7406b8062b" + }, + { + "name": "Send over budget", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2420, + 600 + ], + "id": "36894aff-0586-586d-8b30-a9db7f8fd1a4" + }, + { + "name": "Rule classify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const q = String($('Prepare question').first().json.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nconst category = /T-?\\d{4}/i.test(q) ? 'ticket' : (results.length ? 'faq' : 'unknown');\nreturn [{ json: { category } }];" + }, + "position": [ + 2420, + 300 + ], + "id": "ff9310ae-451e-5825-bb13-67013e490b85" + }, + { + "name": "Known?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.category }}", + "rightValue": "unknown", + "operator": { + "type": "string", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2640, + 300 + ], + "id": "14570236-0f99-5931-8173-d03f18288d68" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Prepare question').first().json.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2860, + 520 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "07e553a6-f8eb-5513-a566-db99f5b11d29" + }, + { + "name": "Parse category", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const raw = String($json.choices?.[0]?.message?.content ?? '').replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nreturn [{ json: { category } }];" + }, + "position": [ + 3080, + 520 + ], + "id": "1a1ea3bc-20b6-5218-b12e-9bafa39f3d8c" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Prepare question').first().json.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 3300, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "fd329729-e718-56ff-805c-aa298d26c0b2" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Answer').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nconst prepared = $('Prepare question').first().json;\nresult.trimmed = prepared.trimmed;\nresult.question_chars = prepared.question_chars;\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "d85f4d2b-0957-5a81-a54b-35ba55509871" + }, + { + "name": "Record usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/usage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 3000 + } + }, + "position": [ + 3740, + 300 + ], + "onError": "continueRegularOutput", + "id": "a46b3503-5039-5cb6-890e-69149fe23f55" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];" + }, + "position": [ + 3960, + 300 + ], + "id": "1b8127ae-a43b-5096-8f94-c96892a085db" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get budget", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get budget": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Prepare question", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare question": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Within budget?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Within budget?": { + "main": [ + [ + { + "node": "Rule classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Over budget", + "type": "main", + "index": 0 + } + ] + ] + }, + "Rule classify": { + "main": [ + [ + { + "node": "Known?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Known?": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Record usage", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record usage": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Over budget": { + "main": [ + [ + { + "node": "Send over budget", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse category", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse category": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/reference/lab03-example2-limit.json b/Last3Mon/75/reference/lab03-example2-limit.json new file mode 100644 index 0000000..d960015 --- /dev/null +++ b/Last3Mon/75/reference/lab03-example2-limit.json @@ -0,0 +1,717 @@ +{ + "name": "75 IT helpdesk + user limit (reference)", + "nodes": [ + { + "name": "Question webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-cost", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0", + "position": [ + 0, + 300 + ], + "id": "3ccc3834-a583-51bd-a80f-b20124e32b64" + }, + { + "name": "Has question?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.body.question }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2" + }, + { + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"question is required\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "f3591be9-5676-5bb7-ab46-6984719af080" + }, + { + "name": "Get budget", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/budget", + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "4d62a650-0746-5e07-b811-2da4fdd28051" + }, + { + "name": "Recent requests", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/usage/user/{{ encodeURIComponent($('Question webhook').first().json.body.user ?? '') }}?window=60", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "4513f242-e767-5b7c-8cea-ebab898fb26e" + }, + { + "name": "Under user limit?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.count < $('Get budget').first().json.user_limit_per_min }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "2f2deb36-19ae-51ac-95d8-c68d3952084b" + }, + { + "name": "Too many", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ route: '잠시 후', answer: '요청이 많아 잠시 뒤에 다시 물어봐 주세요.', retry_after: 60, llm_calls: 0, cost_usd: 0 }) }}", + "options": { + "responseCode": 429 + } + }, + "position": [ + 1100, + 820 + ], + "id": "f55bf388-38db-5d84-b9de-af4379de138a" + }, + { + "name": "Get prices", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/prices", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "baa4796d-564b-5cc0-949a-d79738b12ba7" + }, + { + "name": "Prepare question", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const q = String($('Question webhook').first().json.body.question ?? '');\nconst max = Number($('Get budget').first().json.max_question_chars ?? 500);\nif (q.length <= max) return [{ json: { question: q, trimmed: false, question_chars: q.length } }];\nconst head = Math.floor(max * 0.4);\nconst question = q.slice(0, head) + ' … ' + q.slice(q.length - (max - head));\nreturn [{ json: { question, trimmed: true, question_chars: q.length } }];" + }, + "position": [ + 1320, + 300 + ], + "id": "07d55a70-f59d-584f-bbae-3cfcf59973ac" + }, + { + "name": "Search FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "q", + "value": "={{ $('Prepare question').first().json.question }}" + } + ] + } + }, + "position": [ + 1540, + 300 + ], + "id": "e2005518-7a82-579a-acda-cc74b33968a9" + }, + { + "name": "Get ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "requester", + "value": "={{ $('Question webhook').first().json.body.user ?? '' }}" + } + ] + } + }, + "position": [ + 1760, + 300 + ], + "id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca" + }, + { + "name": "Within budget?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ !$('Get budget').first().json.over }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 1980, + 300 + ], + "id": "26f8a52b-1937-5bb0-ab2d-2915cd25b97c" + }, + { + "name": "Over budget", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst t = $('Get ticket').first().json;\nlet answer = '오늘 자동 답변 한도를 넘어 담당자에게 연결합니다.';\nif (t.status) answer = `${t.ticket_id} 접수 건은 현재 '${t.status}' 상태입니다. ${t.note ?? ''} (오늘 자동 답변 한도를 넘어 원문으로 안내합니다.)`;\nelse if (results.length) answer = `${results[0].answer} (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)`;\nreturn [{ json: { route: '한도 초과', answer, sources: results.map((r) => r.id), llm_calls: 0,\n usage: { prompt_tokens: 0, completion_tokens: 0, by_step: {} }, cost_usd: 0 } }];" + }, + "position": [ + 2200, + 600 + ], + "id": "094e42df-cd03-5518-9e78-2f7406b8062b" + }, + { + "name": "Send over budget", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2420, + 600 + ], + "id": "36894aff-0586-586d-8b30-a9db7f8fd1a4" + }, + { + "name": "Rule classify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const q = String($('Prepare question').first().json.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nconst category = /T-?\\d{4}/i.test(q) ? 'ticket' : (results.length ? 'faq' : 'unknown');\nreturn [{ json: { category } }];" + }, + "position": [ + 2420, + 300 + ], + "id": "ff9310ae-451e-5825-bb13-67013e490b85" + }, + { + "name": "Known?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.category }}", + "rightValue": "unknown", + "operator": { + "type": "string", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2640, + 300 + ], + "id": "14570236-0f99-5931-8173-d03f18288d68" + }, + { + "name": "Classify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Prepare question').first().json.question }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 2860, + 520 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "07e553a6-f8eb-5513-a566-db99f5b11d29" + }, + { + "name": "Parse category", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const raw = String($json.choices?.[0]?.message?.content ?? '').replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nreturn [{ json: { category } }];" + }, + "position": [ + 3080, + 520 + ], + "id": "1a1ea3bc-20b6-5218-b12e-9bafa39f3d8c" + }, + { + "name": "Answer", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Prepare question').first().json.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}", + "options": { + "timeout": 60000 + } + }, + "position": [ + 3300, + 300 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "id": "fd329729-e718-56ff-805c-aa298d26c0b2" + }, + { + "name": "Build result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Answer').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nconst prepared = $('Prepare question').first().json;\nresult.trimmed = prepared.trimmed;\nresult.question_chars = prepared.question_chars;\nreturn [{ json: result }];" + }, + "position": [ + 3520, + 300 + ], + "id": "d85f4d2b-0957-5a81-a54b-35ba55509871" + }, + { + "name": "Record usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/usage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 3000 + } + }, + "position": [ + 3740, + 300 + ], + "onError": "continueRegularOutput", + "id": "a46b3503-5039-5cb6-890e-69149fe23f55" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];" + }, + "position": [ + 3960, + 300 + ], + "id": "1b8127ae-a43b-5096-8f94-c96892a085db" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 4180, + 300 + ], + "id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e" + } + ], + "connections": { + "Question webhook": { + "main": [ + [ + { + "node": "Has question?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has question?": { + "main": [ + [ + { + "node": "Get budget", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get budget": { + "main": [ + [ + { + "node": "Recent requests", + "type": "main", + "index": 0 + } + ] + ] + }, + "Recent requests": { + "main": [ + [ + { + "node": "Under user limit?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Under user limit?": { + "main": [ + [ + { + "node": "Get prices", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Too many", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get prices": { + "main": [ + [ + { + "node": "Prepare question", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare question": { + "main": [ + [ + { + "node": "Search FAQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search FAQ": { + "main": [ + [ + { + "node": "Get ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get ticket": { + "main": [ + [ + { + "node": "Within budget?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Within budget?": { + "main": [ + [ + { + "node": "Rule classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Over budget", + "type": "main", + "index": 0 + } + ] + ] + }, + "Rule classify": { + "main": [ + [ + { + "node": "Known?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Known?": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Build result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build result": { + "main": [ + [ + { + "node": "Record usage", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record usage": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Over budget": { + "main": [ + [ + { + "node": "Send over budget", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse category", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse category": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/75/requests/empty-question.json b/Last3Mon/75/requests/empty-question.json new file mode 100644 index 0000000..7dae121 --- /dev/null +++ b/Last3Mon/75/requests/empty-question.json @@ -0,0 +1 @@ +{"question": "", "user": "이준호"} diff --git a/Last3Mon/75/requests/faq-mail.json b/Last3Mon/75/requests/faq-mail.json new file mode 100644 index 0000000..046267e --- /dev/null +++ b/Last3Mon/75/requests/faq-mail.json @@ -0,0 +1 @@ +{"question": "메일함 용량이 꽉 찼대요", "user": "김민지"} diff --git a/Last3Mon/75/requests/faq-password.json b/Last3Mon/75/requests/faq-password.json new file mode 100644 index 0000000..6729e6d --- /dev/null +++ b/Last3Mon/75/requests/faq-password.json @@ -0,0 +1 @@ +{"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?", "user": "한지훈"} diff --git a/Last3Mon/75/requests/faq-printer.json b/Last3Mon/75/requests/faq-printer.json new file mode 100644 index 0000000..7e1a435 --- /dev/null +++ b/Last3Mon/75/requests/faq-printer.json @@ -0,0 +1 @@ +{"question": "프린터 출력이 안 돼요", "user": "최유진"} diff --git a/Last3Mon/75/requests/faq-vpn.json b/Last3Mon/75/requests/faq-vpn.json new file mode 100644 index 0000000..e3661f8 --- /dev/null +++ b/Last3Mon/75/requests/faq-vpn.json @@ -0,0 +1 @@ +{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호"} diff --git a/Last3Mon/75/requests/long-printer.json b/Last3Mon/75/requests/long-printer.json new file mode 100644 index 0000000..db5177a --- /dev/null +++ b/Last3Mon/75/requests/long-printer.json @@ -0,0 +1 @@ +{"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:33:35 spooler[2231]: job 4147 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:34:05 spooler[2231]: job 4148 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:34:35 spooler[2231]: job 4149 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:35:05 spooler[2231]: job 4150 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:35:35 spooler[2231]: job 4151 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:36:05 spooler[2231]: job 4152 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:36:35 spooler[2231]: job 4153 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:37:05 spooler[2231]: job 4154 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:37:35 spooler[2231]: job 4155 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:38:05 spooler[2231]: job 4156 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:38:35 spooler[2231]: job 4157 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:39:05 spooler[2231]: job 4158 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:39:35 spooler[2231]: job 4159 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n로그는 이렇습니다. 프린터 출력이 안 돼요. 어떻게 하나요?", "user": "최유진"} diff --git a/Last3Mon/75/requests/long-vpn.json b/Last3Mon/75/requests/long-vpn.json new file mode 100644 index 0000000..45ecef4 --- /dev/null +++ b/Last3Mon/75/requests/long-vpn.json @@ -0,0 +1 @@ +{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요? 아래는 접속 프로그램 로그입니다.\n2026-10-29 08:10:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:10:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:11:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:11:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:12:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:12:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:13:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:13:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:14:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:14:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:15:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:15:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:16:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:16:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:17:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:17:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:18:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:18:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:19:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:19:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:20:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:20:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:21:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:21:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:22:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:22:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:23:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:23:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:24:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:24:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting\n2026-10-29 08:25:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:25:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:26:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:26:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:27:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:27:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:28:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:28:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:29:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:29:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:30:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:30:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:31:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:31:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:32:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:32:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:33:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:33:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:34:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:34:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:35:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:35:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:36:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:36:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:37:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:37:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:38:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:38:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:39:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:39:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting", "user": "이준호"} diff --git a/Last3Mon/75/requests/no-keyword.json b/Last3Mon/75/requests/no-keyword.json new file mode 100644 index 0000000..7262ada --- /dev/null +++ b/Last3Mon/75/requests/no-keyword.json @@ -0,0 +1 @@ +{"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?", "user": "이준호"} diff --git a/Last3Mon/75/requests/ticket-done.json b/Last3Mon/75/requests/ticket-done.json new file mode 100644 index 0000000..9faca95 --- /dev/null +++ b/Last3Mon/75/requests/ticket-done.json @@ -0,0 +1 @@ +{"question": "T-1001 어떻게 됐나요?", "user": "이준호"} diff --git a/Last3Mon/75/requests/ticket-own.json b/Last3Mon/75/requests/ticket-own.json new file mode 100644 index 0000000..b32e3ee --- /dev/null +++ b/Last3Mon/75/requests/ticket-own.json @@ -0,0 +1 @@ +{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"} diff --git a/Last3Mon/75/templates/COST-PLAN-TEMPLATE.md b/Last3Mon/75/templates/COST-PLAN-TEMPLATE.md new file mode 100644 index 0000000..d4c6d09 --- /dev/null +++ b/Last3Mon/75/templates/COST-PLAN-TEMPLATE.md @@ -0,0 +1,76 @@ +# 비용 계획 · IT 헬프데스크 + +- 이름: +- 사용한 CLI 에이전트: codex / claude / agy +- 이 파일을 `my-work/COST.md`로 복사해 채웁니다. 단가는 `curl -s localhost:8800/prices` (1M 토큰당 달러). + +## 1. 자원 (Lab 01 예제 1 · `docker stats --no-stream`) + +| 컨테이너 | CPU | 메모리 | +| --- | --- | --- | +| n8n | | | +| runners | | | +| data-api | | | + +- 질문을 보내는 동안 CPU가 크게 오르나요? LLM은 어디서 계산되나요? + +## 2. 단계별 토큰 · 비용 (Lab 01 예제 1 · 2) + +| 질문 | 단계 | 모델 | 입력 토큰 | 출력 토큰 | 비용 ($) | +| --- | --- | --- | --- | --- | --- | +| VPN | classify | | | | | +| VPN | answer | | | | | +| VPN | review | | | | | +| VPN | 합 | | | | | + +- 입력 토큰이 가장 많은 단계와 그 까닭: +- 계산식: (입력 × input 단가 + 출력 × output 단가) ÷ 1,000,000 = + +## 3. 질문 종류별 비용과 한 달 어림 (Lab 01 예제 3 · `lab01-mix`) + +| 종류 | 질문 | 비용 ($) | 종류 평균 ($) | 하루 비중 | +| --- | --- | --- | --- | --- | +| FAQ | VPN · 프린터 · 비밀번호 · 메일 | | | 60% | +| 접수 | T-1002 · T-1001 | | | 30% | +| 그 밖 | 회의실 화면 공유 | | | 10% | + +- 하루 1,000건 · 22일, 종류별 평균으로: 한 달 $ +- 일곱 개 단순 평균으로: 한 달 $ (차이는?) + +## 4. 기록 (Lab 01 과제 1 · 2) + +| 확인 | 값 | +| --- | --- | +| 요청 3건 뒤 `requests` · `calls` | | +| 가장 많이 쓴 사람 | | +| 기록 API가 멈췄을 때 사용자가 받은 것 · `recorded` | | + +## 5. 한 가지씩 줄이기 (Lab 02) + +| 바꾼 것 | 답 단계 입력 토큰 | LLM 호출 | VPN 비용 ($) | 품질 테스트 | 잃은 것 | +| --- | --- | --- | --- | --- | --- | +| 기준 (Lab 01) | | | | | | +| 예제 1 · 보내는 것 줄이기 | | | | | | +| 예제 2 · 분류 · 검토를 nano | | | | | | +| 예제 3 · 답까지 nano (비교만) | | | | | | +| 과제 1 · LLM 한 번 | | | | | | +| 과제 2 · 긴 질문 자르기 (긴 로그 질문) | | | | | | + +## 6. 예산 · 한도 (Lab 03 예제) + +| 정한 값 | 이유 | +| --- | --- | +| 하루 예산 $ | | +| 사용자별 1분 요청 수 | | +| 질문 글자 수 상한 | | +| 한도를 넘으면 사용자에게 | | + +- 예제 3: 예산 $0.0003에서 몇 번째 질문부터 `한도 초과`? 예산을 얼마나 넘었나? + +## 7. 한 달 예상 · 바꾸면 얼마 (Lab 03 과제 · `my-cost-report`) + +| 하루 요청 | 평균 비용 | 한 달 (22일) | 예산과 비교 | 모두 nano였다면 | 아끼는 돈 | +| --- | --- | --- | --- | --- | --- | +| | | | | | | + +- what-if 숫자를 그대로 믿으면 안 되는 까닭 (Lab 02 예제 3): diff --git a/Last3Mon/75/templates/TEST-REPORT-TEMPLATE.md b/Last3Mon/75/templates/TEST-REPORT-TEMPLATE.md new file mode 100644 index 0000000..7ea1ff8 --- /dev/null +++ b/Last3Mon/75/templates/TEST-REPORT-TEMPLATE.md @@ -0,0 +1,38 @@ +# 테스트 보고서 · IT 헬프데스크 (비용) + +- 이름: +- 사용한 CLI 에이전트: codex / claude / agy +- 워크플로우 파일: `my-work/cost.json`, `my-work/cost-report.json` + +## 1. 통과한 테스트 + +| 테스트 파일 | 결과 | +| --- | --- | +| `cases/lab01.json` (비용) | Passed /3 | +| `cases/lab01-challenge.json` (기록) | Passed /4 | +| `cases/lab01-challenge2.json` (기록이 멈춰도) | Passed /4 | +| `cases/lab02.json` (줄이기 · 모델) | Passed /5 | +| `cases/lab02-challenge.json` (LLM 한 번 · $0.0002) | Passed /7 | +| `cases/lab02-challenge2.json` (긴 질문) | Passed /4 | +| `cases/lab03.json` (하루 예산) | Passed /3 | +| `cases/lab03-limit.json` (사용자별 한도) | Passed /4 | +| `cases/lab03-challenge.json` (비용 보고서) | Passed /5 | +| `cases/lab03-challenge2.json` (바꾸면 얼마) | Passed /5 | + +## 2. 실패와 고친 내용 + +| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 | +| --- | --- | --- | --- | --- | +| | | | | | + +## 3. 싸진 만큼 잃은 것 + +- + +## 4. 질문 하나의 비용 (처음 → 끝) + +| | 처음 (`base-cost`) | 끝 (`my-cost`) | +| --- | --- | --- | +| VPN 질문 비용 ($) | | | +| LLM 호출 | | | +| 하루 1,000건 · 22일 | | | diff --git a/Last3Mon/75/tools/flow.py b/Last3Mon/75/tools/flow.py new file mode 100644 index 0000000..ec91e50 --- /dev/null +++ b/Last3Mon/75/tools/flow.py @@ -0,0 +1,1138 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote. + + uv run tools/flow.py doctor # is everything ready? + uv run tools/flow.py check my-work/helpdesk.json # find mistakes before n8n sees the file + uv run tools/flow.py push my-work/helpdesk.json # import + publish (one n8n restart) + uv run tools/flow.py off my-cost # unpublish by file or webhook path + uv run tools/flow.py diff reference/baseline-cost.json my-work/cost.json [--full] # what changed, node by node + uv run tools/flow.py call base-cost requests/faq-vpn.json [-v] # look at usage.by_step and cost_usd + uv run tools/flow.py test cases/lab02.json [-v] [--repeat 3] [--set user=이준호] # "range": {"cost_usd": [0, 0.0002]} + uv run tools/flow.py bench my-cost requests/faq-vpn.json -n 10 # p50 · p95 · max · failures + uv run tools/flow.py usage [--clear] # recorded calls, tokens and cost by model / user / route + uv run tools/flow.py budget [--daily 0.001] [--per-min 2] [--reset] # the daily budget and per-user limit + uv run tools/flow.py cache [--clear] # the answer cache (Lab 02 task 2) + uv run tools/flow.py fault usage slow|error|ok # make the data API slow or failing (drills) + uv run tools/flow.py llm down|up # outage drill: a broken OpenAI key in n8n, then restore + +Every command takes -v (--verbose) for details: the request and response JSON of each call (call, test, bench, +fault, doctor), the nodes and links of a workflow (check, push), and the docker / n8n CLI commands that ran. + +Run it from the day folder (Last3Mon/75). n8n is the day 65 install (~/day65-n8n, Docker project +day65-n8n), started from Last3Mon/65/n8n. Environment overrides: + N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1) + N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CACHE = Path(__file__).resolve().parent / ".cache" +def n8n_home() -> Path: + """Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n.""" + if os.getenv("N8N_HOME"): + return Path(os.environ["N8N_HOME"]).expanduser() + env_file = ROOT.parent / "65" / "n8n" / ".env" + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("N8N_HOME="): + value = line.split("=", 1)[1].strip().strip("'\"") + if value: + return (env_file.parent / Path(value).expanduser()).resolve() + return Path.home() / "day65-n8n" + + +N8N_HOME = n8n_home() +START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)" + + +def find_container() -> str: + """The n8n container of the day 65 project, whichever folder it was started from.""" + if os.getenv("N8N_CONTAINER"): + return os.environ["N8N_CONTAINER"] + if shutil.which("docker"): + result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n", + "--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"], + capture_output=True, text=True) + names = result.stdout.split() + if names: + return names[0] + return "day65-n8n-n8n-1" + + +CONTAINER = find_container() +N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/") + +# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only). +RECOMMENDED = { + "n8n-nodes-base.webhook": 2, + "n8n-nodes-base.respondToWebhook": 1.1, + "n8n-nodes-base.if": 2.2, + "n8n-nodes-base.switch": 3.2, + "n8n-nodes-base.set": 3.4, + "n8n-nodes-base.code": 2, + "n8n-nodes-base.httpRequest": 4.2, + "n8n-nodes-base.httpRequestTool": 4.2, + "@n8n/n8n-nodes-langchain.agent": 3.1, + "@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3, + "@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1, +} +USES_DATA_API = True # day 75: FAQ, tickets, prices, usage records, budget and the answer cache come from the data API +AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 75 (base-cost is pushed by hand) +COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama) +CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"} +SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}") + +OK, BAD, WARN = "✔", "✘", "!" +VERBOSE = False # -v on any command (set in main) + + +# ---------------------------------------------------------------- helpers +def die(message: str) -> None: + print(f"{BAD} {message}", file=sys.stderr) + sys.exit(1) + + +def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + if not shutil.which("docker"): + die("docker command not found. Start Docker first.") + result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True) + if VERBOSE: + show_command(args, result) + if check and result.returncode != 0: + die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}") + return result + + +def show_json(label: str, value: object) -> None: + """Print one JSON value under a label, indented below the result line (-v). API keys are masked.""" + text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value + print(f" {label}") + for row in SECRET.sub("sk-***", text).splitlines(): + print(f" {row}") + + +def detail(label: str, value: object) -> None: + """show_json, only with -v.""" + if VERBOSE: + show_json(label, value) + + +def show_command(args: tuple[str, ...], result: subprocess.CompletedProcess) -> None: + """-v: one docker command and the end of its output. Data dumps (cat of an export) are not printed.""" + command = " ".join(arg if len(arg) <= 90 else arg[:87] + "..." for arg in args) + print(f" $ docker {command} → exit {result.returncode}") + if "cat /tmp" in " ".join(args): + return + rows = [row for row in (result.stdout + result.stderr).splitlines() if row.strip()] + for row in rows[-6:]: + print(f" {SECRET.sub('sk-***', row)[:160]}") + + +def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check) + + +def container_running() -> bool: + if not shutil.which("docker"): + return False + result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True) + return result.stdout.strip() == "true" + + +def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]: + data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) + start = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status, raw = response.status, response.read() + except urllib.error.HTTPError as error: + status, raw = error.code, error.read() + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start + text = raw.decode("utf-8", errors="replace") + try: + payload: object = json.loads(text) if text.strip() else "" + except json.JSONDecodeError: + payload = text + return status, payload, time.monotonic() - start + + +def load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + die(f"file not found: {path}") + except json.JSONDecodeError as error: + die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}") + + +def resolve(path: str) -> Path: + candidate = Path(path) + if candidate.exists() or candidate.is_absolute(): + return candidate + return ROOT / path + + +def node_versions() -> dict[str, list[float]]: + """Node types and versions that this n8n really has (exported once, then cached).""" + cache = CACHE / "node-versions.json" + if cache.exists(): + return json.loads(cache.read_text()) + if not container_running(): + return {} + n8n("export:nodes", "--output=/tmp/course-nodes.json") + raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout + versions: dict[str, set[float]] = {} + for node in json.loads(raw): + value = node.get("version") + versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value]) + result = {name: sorted(values) for name, values in versions.items()} + CACHE.mkdir(exist_ok=True) + cache.write_text(json.dumps(result)) + return result + + +def walk_strings(value: object, where: str = ""): + if isinstance(value, str): + yield where, value + elif isinstance(value, dict): + for key, item in value.items(): + yield from walk_strings(item, f"{where}.{key}" if where else key) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from walk_strings(item, f"{where}[{index}]") + + +def webhooks(workflow: dict) -> list[tuple[str, str]]: + found = [] + for node in workflow.get("nodes", []): + if node.get("type") == "n8n-nodes-base.webhook": + parameters = node.get("parameters", {}) + found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/"))) + return found + + +# ---------------------------------------------------------------- check +def check_workflow(workflow: object) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(workflow, dict): + return ["the file must be one workflow object { name, nodes, connections }"], warnings + if not str(workflow.get("name", "")).strip(): + errors.append("missing workflow 'name'") + nodes = workflow.get("nodes") + connections = workflow.get("connections") + if not isinstance(nodes, list) or not nodes: + return errors + ["'nodes' must be a non-empty list"], warnings + if not isinstance(connections, dict): + errors.append("'connections' must be an object") + connections = {} + + known = node_versions() + names: dict[str, dict] = {} + for index, node in enumerate(nodes): + label = f"node #{index + 1}" + if not isinstance(node, dict): + errors.append(f"{label} is not an object") + continue + name = node.get("name") + if not name: + errors.append(f"{label} has no 'name'") + continue + label = f"node '{name}'" + if name in names: + errors.append(f"{label} appears twice (node names must be unique)") + names[name] = node + node_type, version = node.get("type"), node.get("typeVersion") + if not node_type or version is None: + errors.append(f"{label} needs 'type' and 'typeVersion'") + elif known and node_type not in known: + errors.append(f"{label}: type '{node_type}' does not exist in this n8n") + elif known and version not in known[node_type]: + errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})") + elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]: + warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})") + if not isinstance(node.get("parameters"), dict): + errors.append(f"{label} needs 'parameters' (use {{}} when empty)") + position = node.get("position") + if not (isinstance(position, list) and len(position) == 2): + errors.append(f"{label} needs 'position': [x, y]") + for kind, credential in (node.get("credentials") or {}).items(): + credential_id = (credential or {}).get("id") + if credential_id not in COURSE_CREDENTIALS: + errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}") + for where, text in walk_strings(node.get("parameters", {})): + if SECRET.search(text): + errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.") + if "{{" in text and "}}" in text and not text.startswith("="): + warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)") + # day 75: OpenAI no longer serves gpt-4.1-nano, and gpt-5.4-nano refuses max_tokens over HTTP + if "gpt-4.1-nano" in text: + errors.append(f"{label}: parameters.{where} uses gpt-4.1-nano (OpenAI no longer serves it). Use gpt-5.4-nano.") + if "chat/completions" in str(node.get("parameters", {}).get("url", "")) and re.search(r"\bmax_tokens\b", text): + errors.append(f"{label}: parameters.{where} has max_tokens. Use max_completion_tokens (gpt-5.4-nano refuses max_tokens).") + + targets_of: dict[str, set[str]] = {} + for source, outputs in connections.items(): + if source not in names: + errors.append(f"connections: source '{source}' is not a node name") + continue + if not isinstance(outputs, dict): + errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}") + continue + for kind, branches in outputs.items(): + if kind not in CONNECTION_TYPES: + errors.append(f"connections['{source}']: unknown connection type '{kind}'") + for branch in branches or []: + for link in branch or []: + target = (link or {}).get("node") + if target not in names: + errors.append(f"connections['{source}'] points to '{target}', which is not a node name") + elif link.get("type") != kind: + errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'") + else: + targets_of.setdefault(target, set()).add(kind) + if kind != "main": + targets_of.setdefault(f"{source}::from", set()).add(kind) + + has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values()) + for name, node in names.items(): + node_type = node.get("type", "") + parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {} + if node_type == "n8n-nodes-base.webhook": + if not str(parameters.get("path", "")).strip("/"): + errors.append(f"node '{name}': webhook needs a 'path'") + if parameters.get("responseMode") == "responseNode" and not has_respond: + errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node") + if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()): + errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'") + if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"): + if "ai_tool" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'") + if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"): + if "ai_languageModel" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'") + is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links + if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \ + and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \ + and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower(): + warnings.append(f"node '{name}': nothing is connected into it (it never runs)") + if not webhooks(workflow): + warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path") + return errors, warnings + + +def show_workflow(workflow: object) -> None: + """-v: the nodes, links and webhooks of a workflow file.""" + if not VERBOSE or not isinstance(workflow, dict): + return + print(f" workflow '{workflow.get('name')}' · {len(workflow.get('nodes') or [])} node(s)") + for node in workflow.get("nodes") or []: + if isinstance(node, dict): + extra = [key for key in ("onError", "retryOnFail") if key in node] + print(f" {node.get('name')} · {node.get('type')} v{node.get('typeVersion')}" + (f" · {', '.join(extra)}" if extra else "")) + print(" links") + for source, outputs in (workflow.get("connections") or {}).items(): + for kind, branches in (outputs or {}).items(): + for index, branch in enumerate(branches or []): + targets = ", ".join(str((link or {}).get("node")) for link in branch or []) + if targets: + print(f" {source} --{kind}[{index}]--> {targets}") + for method, hook in webhooks(workflow): + print(f" webhook {method} /webhook/{hook}") + + +def cmd_check(args: argparse.Namespace) -> int: + path = resolve(args.file) + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {warning}") + for error in errors: + print(f"{BAD} {error}") + if errors: + print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.") + return 1 + print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else "")) + return 0 + + +# ---------------------------------------------------------------- push +def stable_id(path: Path) -> str: + return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15] + + +def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3) + if status == 200: + break + time.sleep(1) + else: + return False + for method, path in paths: + while time.monotonic() < deadline: + # A webhook that is registered for POST answers a GET with a hint, not "is not registered". + status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5) + text = json.dumps(payload, ensure_ascii=False) + if "not registered for" in text or status not in (0, 404, 500, 502, 503): + break + time.sleep(1) + time.sleep(2) # webhooks of the other published workflows finish registering right after + return time.monotonic() < deadline + + +def exported_workflows() -> list[dict]: + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; " + "cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout + try: + return json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + return [] + + +def prepare(path: Path) -> dict | None: + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {path.name}: {warning}") + if errors: + for error in errors: + print(f"{BAD} {path.name}: {error}") + return None + workflow = dict(workflow) + if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))): + workflow["id"] = stable_id(path) + workflow["active"] = False + workflow.setdefault("settings", {}).setdefault("executionOrder", "v1") + for node in workflow["nodes"]: + node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}"))) + if node["type"] == "n8n-nodes-base.webhook": + node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}"))) + return workflow + + +def cmd_push(args: argparse.Namespace) -> int: + paths = [resolve(file) for file in args.files] + workflows = [prepare(path) for path in paths] + if None in workflows: + print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check ).") + return 1 + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + mine_ids = {workflow["id"] for workflow in workflows} + hooks = {hook for workflow in workflows for hook in webhooks(workflow)} + + # Another published workflow with the same webhook path would block this one. + for other in exported_workflows(): + if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)): + n8n("unpublish:workflow", f"--id={other['id']}") + print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path") + + for workflow in workflows: + docker("exec", "-i", CONTAINER, "sh", "-c", + "cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; " + "status=$?; rm -f /tmp/course-workflow.json; exit $status", + stdin=json.dumps(workflow, ensure_ascii=False)) + n8n("publish:workflow", f"--id={workflow['id']}") + # The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s). + docker("restart", CONTAINER) + ready = wait_until_ready(sorted(hooks)) + for workflow in workflows: + print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})") + for method, hook in sorted(webhooks(workflow)): + print(f" {method} {N8N_URL}/webhook/{hook}") + if not ready: + print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.") + return 0 + + +def cmd_off(args: argparse.Namespace) -> int: + """Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down.""" + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + exported = exported_workflows() + detail("published now", [f"{item.get('id')} · {item.get('name')} · " + ", ".join(f"/{hook}" for _, hook in webhooks(item)) + for item in exported if item.get("active")]) + wanted: dict[str, str] = {} + for item in args.files: + path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None + if path: + workflow = load_json(path) + workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path) + wanted[workflow_id] = item + hooks = {hook for _, hook in webhooks(workflow)} + else: + hooks = {item.strip("/")} + for other in exported: + if other.get("active") and hooks & {hook for _, hook in webhooks(other)}: + wanted[other["id"]] = item + active = {item.get("id") for item in exported if item.get("active")} + for workflow_id, item in wanted.items(): + if workflow_id in active: + n8n("unpublish:workflow", f"--id={workflow_id}") + print(f"{OK} unpublished {item} (id {workflow_id})") + docker("restart", CONTAINER) + wait_until_ready([]) + print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push ") + return 0 + + +# ---------------------------------------------------------------- call / test +def request_body(spec: str | dict, base: Path = ROOT) -> object: + if isinstance(spec, dict): + return spec + text = spec.strip() + if text.startswith("{"): + try: + return json.loads(text) + except json.JSONDecodeError as error: + die(f"request is not valid JSON: {error.msg}") + path = Path(text) + if not path.exists(): + path = base / text + return load_json(path) + + +def cmd_call(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + hook = args.path.strip('/') + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + print(f"HTTP {status} · {seconds:.1f}s") + if args.verbose: + show_json(f"→ request POST /webhook/{hook}", body) + show_json(f"← response HTTP {status}", payload) + return 0 if 200 <= status < 300 else 1 + print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload) + return 0 if 200 <= status < 300 else 1 + + +def field(payload: object, dotted: str) -> object: + value = payload + for part in dotted.split("."): + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list) and part.isdigit() and int(part) < len(value): + value = value[int(part)] + else: + return None + return value + + +def as_text(value: object) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + + +def judge(expect: dict, status: int, payload: object, seconds: float = 0.0) -> list[str]: + problems = [] + if "max_seconds" in expect and seconds > float(expect["max_seconds"]): + problems.append(f"took {seconds:.1f}s (expected at most {expect['max_seconds']}s)") + if status != expect.get("status", 200): + problems.append(f"status {status} (expected {expect.get('status', 200)})") + for name, wanted in (expect.get("equals") or {}).items(): + actual = field(payload, name) + if actual != wanted: + problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})") + for name, bounds in (expect.get("range") or {}).items(): + actual = field(payload, name) + low, high = bounds + if not isinstance(actual, (int, float)) or isinstance(actual, bool) or not low <= actual <= high: + problems.append(f"{name} = {as_text(actual)[:40]} (expected {low}..{high})") + for name, words in (expect.get("contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + if not any(word in text for word in words): + problems.append(f"{name} has none of {words}") + for name, words in (expect.get("not_contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + leaked = [word for word in words if word in text] + if leaked: + problems.append(f"{name} must not have {leaked}") + return problems + + +def cmd_test(args: argparse.Namespace) -> int: + path = resolve(args.cases) + suite = load_json(path) + hook = (args.webhook or suite.get("webhook", "")).strip("/") + cases = suite.get("cases", []) + if not hook or not cases: + die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list") + # "before"/"after": calls made once around the cases, e.g. turn a fault on and off again. + if not args.no_before: + for step in suite.get("before", []): + run_step("before", step) + try: + return run_cases(args, path, suite, hook, cases) + finally: + for step in suite.get("after", []): + run_step("after", step) + + +def run_step(label: str, step: dict) -> None: + """A step is {"webhook": path} (POST to n8n), {"url": ...} (POST to the data API, e.g. the fault switch) + or {"llm": "down"|"up"} (the outage drill). "status": 409 means that status is expected (e.g. a refused deploy).""" + if step.get("llm") in ("down", "up"): + code = cmd_llm(argparse.Namespace(state=step["llm"])) + if code and label == "before": + die("could not change the OpenAI credential (llm drill)") + if step["llm"] == "down": + time.sleep(1) + return + if step.get("url"): + target = str(step["url"]) + else: + target = f"{N8N_URL}/webhook/{str(step.get('webhook', '')).strip('/')}" + body = request_body(step.get("body") or step.get("request", "{}")) + status, payload, seconds = http("POST", target, body) + shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120] + good = status == step["status"] if "status" in step else 200 <= status < 300 + print(f"{OK if good else BAD} {label}: POST {target.replace(N8N_URL, '')} · HTTP {status} · {seconds:.1f}s · {shown}") + detail(f"→ request POST {target.replace(N8N_URL, '')}", body) + detail(f"← response HTTP {status}", payload) + if not good and label == "before": + die(f"the 'before' step failed ({target}). Is it pushed / is the data API the day 75 version?") + + +def parse_sets(items: list[str] | None) -> dict: + """--set release=1.1.0 --set no_cache=true → {"release": "1.1.0", "no_cache": true}""" + out: dict = {} + for item in items or []: + key, _, value = item.partition("=") + try: + out[key] = json.loads(value) + except json.JSONDecodeError: + out[key] = value + return out + + +def run_cases(args: argparse.Namespace, path: Path, suite: dict, hook: str, cases: list) -> int: + print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}") + results, passed = [], 0 + for case in cases: + for step in case.get("before", []): + run_step("before", step) + try: + passed += run_case(args, case, hook, results) + finally: + for step in case.get("after", []): + run_step("after", step) + return finish(path, hook, results, passed) + + +def run_case(args: argparse.Namespace, case: dict, hook: str, results: list) -> int: + passed = 0 + body = request_body(case.get("body") or case.get("request", "{}")) + if getattr(args, "set", None) and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook + # "url": POST to that address instead of the webhook (e.g. check /usage/summary after the questions) + target = str(case["url"]) if case.get("url") else f"{N8N_URL}/webhook/{case_hook}" + shown = target.replace(N8N_URL, "") + for run in range(1, args.repeat + 1): + status, payload, seconds = http("POST", target, body) + problems = judge(case.get("expect", {}), status, payload, seconds) + passed += not problems + mark = OK if not problems else BAD + suffix = f" #{run}" if args.repeat > 1 else "" + print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s{cost_note(payload)}" + ("" if not problems else " · " + "; ".join(problems))) + if args.verbose: + show_json(f"→ request POST {shown}", body) + show_json(f"← response HTTP {status}", payload) + results.append({"case": case.get("name"), "webhook": case_hook if not case.get("url") else shown, "run": run, "passed": not problems, "problems": problems, + "seconds": round(seconds, 1), "status": status, "request": body, "response": payload}) + return passed + + +def cost_note(payload: object) -> str: + """' · 자동 답변 · 1 call · $0.000123' when the answer says how many LLM calls it made and what they cost (day 75).""" + if not isinstance(payload, dict) or not isinstance(payload.get("cost_usd"), (int, float)): + return "" + calls = payload.get("llm_calls") + route = f" · {payload['route']}" if isinstance(payload.get("route"), str) else "" + return route + (f" · {calls} call{'s' if calls != 1 else ''}" if isinstance(calls, int) else "") + f" · ${payload['cost_usd']:.6f}" + + +LAST_RESULTS: list = [] + + +def finish(path: Path, hook: str, results: list, passed: int) -> int: + LAST_RESULTS[:] = results + total = len(results) + print(f"Passed {passed}/{total}") + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + report = runs / f"{path.stem}-{stamp}.json" + report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total, + "results": results}, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if passed == total else 1 + + +# ---------------------------------------------------------------- gate / diff / release +def cmd_gate(args: argparse.Namespace) -> int: + """A release gate: run a test file and decide PASS/FAIL from the pass rate and p95 of the case times.""" + cmd_test(args) + results = LAST_RESULTS + total = len(results) + passed = sum(1 for item in results if item["passed"]) + times = [item["seconds"] for item in results if item.get("status") == 200] + p95 = percentile(times, 0.95) if times else 0.0 + rate = passed / total if total else 0.0 + reasons = [] + if rate < args.min_pass: + reasons.append(f"pass rate {rate:.0%} < {args.min_pass:.0%}") + if args.max_p95 is not None and p95 > args.max_p95: + reasons.append(f"p95 {p95:.1f}s > {args.max_p95}s") + sets = parse_sets(args.set) + label = ", ".join(f"{k}={v}" for k, v in sets.items()) or "as is" + print(f"gate [{label}]: pass {passed}/{total} ({rate:.0%}) · p95 {p95:.1f}s → " + + ("PASS" if not reasons else "FAIL · " + "; ".join(reasons))) + if sets.get("release") and not args.no_record: + record = {"release": str(sets["release"]), "verdict": "rollback" if reasons else "promote", "passed": passed, + "total": total, "p95": round(p95, 2), "cases": Path(args.cases).name, "source": "flow.py gate"} + status, payload, _ = http("POST", f"http://localhost:8800/gates/{args.name}", record, timeout=10) + print(f"{OK if status == 200 else WARN} gate record → /gates/{args.name}: {record['release']} {record['verdict']}" + + ("" if status == 200 else f" (not stored: HTTP {status})")) + detail(f"← response HTTP {status}", payload) + return 0 if not reasons else 1 + + +def flat(value: object, prefix: str = "") -> dict: + if isinstance(value, dict): + out: dict = {} + for key, item in value.items(): + out.update(flat(item, f"{prefix}.{key}" if prefix else str(key))) + return out + if isinstance(value, list): + out = {} + for index, item in enumerate(value): + out.update(flat(item, f"{prefix}[{index}]")) + return out or {prefix: []} + return {prefix: value} + + +def cmd_diff(args: argparse.Namespace) -> int: + """Node-by-node difference of two workflow files: added/removed nodes, changed settings, links.""" + old, new = load_json(resolve(args.old)), load_json(resolve(args.new)) + old_nodes = {node["name"]: node for node in old.get("nodes", [])} + new_nodes = {node["name"]: node for node in new.get("nodes", [])} + print(f"{old.get('name', args.old)} → {new.get('name', args.new)}") + changed = 0 + for name in sorted(set(new_nodes) - set(old_nodes)): + changed += 1 + print(f"+ node '{name}' ({new_nodes[name].get('type', '?').split('.')[-1]})") + for name in sorted(set(old_nodes) - set(new_nodes)): + changed += 1 + print(f"- node '{name}' ({old_nodes[name].get('type', '?').split('.')[-1]})") + skip = ("position", "id", "webhookId") + for name in sorted(set(old_nodes) & set(new_nodes)): + a = flat({k: v for k, v in old_nodes[name].items() if k not in skip}) + b = flat({k: v for k, v in new_nodes[name].items() if k not in skip}) + for key in sorted(set(a) | set(b)): + if a.get(key) == b.get(key): + continue + changed += 1 + before, after = as_text(a.get(key, "(none)")), as_text(b.get(key, "(none)")) + if len(before) > 70 or len(after) > 70: + print(f"~ '{name}' {key}: changed ({len(before)} → {len(after)} chars)") + if args.full or args.verbose: + print(f" before: {before}\n after: {after}") + else: + print(f"~ '{name}' {key}: {before} → {after}") + + def links(workflow: dict) -> set: + return {(src, kind, dst["node"]) for src, kinds in workflow.get("connections", {}).items() + for kind, outs in kinds.items() for out in outs for dst in out} + + for src, kind, dst in sorted(links(new) - links(old)): + changed += 1 + print(f"+ link {src} → {dst} ({kind})") + for src, kind, dst in sorted(links(old) - links(new)): + changed += 1 + print(f"- link {src} → {dst} ({kind})") + print(f"{changed} change(s)") + return 0 + + +def bucket(user: str) -> int: + """Same rule as AGENTS.md: sum of the character codes of the user name, modulo 100.""" + return sum(ord(ch) for ch in user) % 100 + + +def cmd_release(args: argparse.Namespace) -> int: + url = f"http://localhost:8800/releases/{args.name}" + if args.reset: + status, payload, _ = http("POST", url + "/reset", {}, timeout=10) + elif args.require_gate: + status, payload, _ = http("POST", url + "/policy", {"require_gate": args.require_gate == "on"}, timeout=10) + elif args.version or args.canary or args.no_canary: + body: dict = {} + if args.version: + body["active"] = args.version + if args.canary: + body["canary"] = {"release": args.canary, "percent": args.percent} + if args.no_canary: + body["canary"] = None + status, payload, _ = http("POST", url, body, timeout=10) + else: + status, payload, _ = http("GET", url, timeout=10) + detail(f"← response HTTP {status}", payload) + if status == 409 and isinstance(payload, dict): + gate = payload.get("last_gate") + print(f"{BAD} refused: {payload.get('release')} has no passing gate record" + + (f" (last gate: {gate.get('verdict')} {gate.get('passed')}/{gate.get('total')} · {gate.get('source')})" if gate else " (never gated)") + + "\n → run the gate (flow.py gate ... --set release=) or my-release-check first") + return 1 + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + _, gates, _ = http("GET", f"http://localhost:8800/gates/{args.name}", timeout=10) + latest = {} + for record in (gates.get("gates", []) if isinstance(gates, dict) else []): + latest[record.get("release")] = record + canary = payload.get("canary") + print(f"{OK} {args.name}: active {payload['active']}" + + (f" · canary {canary['release']} {canary['percent']}%" if canary else " · no canary") + + (" · deploy needs a passing gate" if payload.get("require_gate") else "")) + for version, info in sorted(payload.get("releases", {}).items()): + mark = "*" if version == payload["active"] else ("c" if canary and version == canary["release"] else " ") + gate = latest.get(version) + seen = f" gate {gate['verdict']} {gate.get('passed')}/{gate.get('total')}" if gate else "" + print(f" {mark} {version} prompt {info.get('prompt_version')} model {info.get('model')} {info.get('note', '')}{seen}") + if canary: + names = ["이준호", "박서연", "최유진", "한지훈", "김민지", "정우성"] + print(" canary users: " + (", ".join(f"{n}({bucket(n)})" for n in names if bucket(n) < canary["percent"]) or "none")) + return 0 + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +def cmd_bench(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + if args.set and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + hook = args.path.strip("/") + print(f"POST {N8N_URL}/webhook/{hook} × {args.n}") + detail(f"→ request POST /webhook/{hook} (the same body every time)", body) + seconds_list, failures, rows = [], 0, [] + for run in range(1, args.n + 1): + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + good = 200 <= status < 300 + failures += not good + seconds_list.append(seconds) + extra = "" + if isinstance(payload, dict): + extra = " · " + ", ".join(f"{key}={as_text(payload[key])[:40]}" for key in ("route", "llm_calls", "cached") if key in payload) + if isinstance(payload.get("version"), dict): + extra += f", release={payload['version'].get('release')}" + print(f"{OK if good else BAD} #{run} · HTTP {status} · {seconds:.2f}s{extra}") + detail(f"← response HTTP {status}", payload) + rows.append({"run": run, "status": status, "seconds": round(seconds, 3), "response": payload}) + p50, p95 = percentile(seconds_list, 0.50), percentile(seconds_list, 0.95) + print(f"p50 {p50:.2f}s · p95 {p95:.2f}s · max {max(seconds_list):.2f}s · failures {failures}/{args.n}") + if VERBOSE: + print(" sorted " + " ".join(f"{value:.2f}" for value in sorted(seconds_list))) + steps: dict[str, list[float]] = {} + for row in rows: + timings = row["response"].get("timings") if isinstance(row["response"], dict) else None + for key, value in (timings or {}).items(): + if isinstance(value, (int, float)): + steps.setdefault(key, []).append(value) + if steps: + print(" timings (average ms, runs) " + " · ".join(f"{key} {sum(values) / len(values):.0f} ({len(values)})" + for key, values in steps.items())) + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + report = runs / f"bench-{hook}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json" + report.write_text(json.dumps({"webhook": hook, "request": body, "n": args.n, "p50": round(p50, 3), "p95": round(p95, 3), + "max": round(max(seconds_list), 3), "failures": failures, "runs": rows}, + ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if not failures else 1 + + +# ---------------------------------------------------------------- usage / budget (day 75) +def cmd_usage(args: argparse.Namespace) -> int: + if args.clear: + status, payload, _ = http("POST", "http://localhost:8800/usage-clear", {}, timeout=10) + print(f"{OK if status == 200 else BAD} usage cleared: {as_text(payload)}") + return 0 if status == 200 else 1 + status, payload, seconds = http("GET", "http://localhost:8800/usage/summary", timeout=10) + detail(f"← GET /usage/summary · HTTP {status} · {seconds:.2f}s", payload) + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"requests {payload['requests']} · calls {payload['calls']} · tokens in {payload['prompt_tokens']}" + f" / out {payload['completion_tokens']} · ${payload['cost_usd']:.6f}") + for title in ("by_model", "by_user", "by_route"): + for key, row in sorted(payload.get(title, {}).items(), key=lambda kv: -kv[1]["cost_usd"]): + print(f" {title[3:]:<6} {key:<14} {row['calls']:>4} calls · in {row['prompt_tokens']:>7}" + f" · out {row['completion_tokens']:>6} · ${row['cost_usd']:.6f}") + show_budget() + return 0 + + +def show_budget() -> dict: + status, b, seconds = http("GET", "http://localhost:8800/budget", timeout=10) + detail(f"← GET /budget · HTTP {status} · {seconds:.2f}s", b) + if isinstance(b, dict) and "daily_usd" in b: + print(f"budget ${b['daily_usd']} / used ${b['used_usd']:.6f} · remaining ${b['remaining_usd']:.6f} · over {b['over']}" + f" · per user {b.get('user_limit_per_min')}/min · question {b.get('max_question_chars')} chars") + return b + print(f"{BAD} budget: {as_text(b)}") + return {} + + +def cmd_budget(args: argparse.Namespace) -> int: + """Show or change the daily budget, the per-user limit and the question length limit (people decide these).""" + body = {key: value for key, value in (("daily_usd", args.daily), ("user_limit_per_min", args.per_min), + ("max_question_chars", args.chars)) if value is not None} + if args.reset or body: + target = "http://localhost:8800/budget/reset" if args.reset else "http://localhost:8800/budget" + status, payload, seconds = http("POST", target, body, timeout=10) + detail(f"→ request POST {target}", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + if status != 200: + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"{OK} " + ("budget back to data/budget.json" if args.reset else f"budget changed: {as_text(body)}")) + return 0 if show_budget() else 1 + + +def cmd_fault(args: argparse.Namespace) -> int: + targets = ["faq", "tickets", "prompts", "usage"] if args.target == "all" else [args.target] + body: dict = {target: args.mode for target in targets} + if args.seconds is not None: + body["slow_seconds"] = args.seconds + status, payload, seconds = http("POST", "http://localhost:8800/admin/fault", body, timeout=10) + print(f"{OK if status == 200 else BAD} fault switch: {as_text(payload)}") + detail("→ request POST http://localhost:8800/admin/fault", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + return 0 if status == 200 else 1 + + +def cmd_llm(args: argparse.Namespace) -> int: + """Outage drill: register the course OpenAI credential with a broken key (down) or the real key from .env (up).""" + script = ROOT.parent / "65" / "n8n" / "register-credentials.sh" + if not script.exists(): + die(f"{script} not found") + env = dict(os.environ, N8N_CONTAINER=CONTAINER) + if args.state == "down": + env["OPENAI_API_KEY"] = "sk-invalid-key-for-drill-000000" + else: + env.pop("OPENAI_API_KEY", None) + result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True) + detail(f"$ bash {script.relative_to(ROOT.parent)} → exit {result.returncode}", (result.stdout + result.stderr).strip()) + if result.returncode != 0: + print(result.stderr.strip() or result.stdout.strip()) + return 1 + print(f"{OK} OpenAI credential: " + ("broken key (drill). Restore with: uv run tools/flow.py llm up" if args.state == "down" + else "real key from .env restored")) + return 0 + + +# ---------------------------------------------------------------- doctor +def cmd_doctor(args: argparse.Namespace) -> int: + failures = 0 + + def line(good: bool, text: str, hint: str = "", seen: object = None) -> None: + nonlocal failures + failures += not good + print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}")) + if seen is not None: + detail("seen", seen) + + installed = (N8N_HOME / "compose.yml").exists() + print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else + "\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>")) + running = container_running() + line(running, f"n8n container {CONTAINER}", START_HINT) + status, payload, _ = http("GET", f"{N8N_URL}/healthz", timeout=5) + line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again", + {"GET": "/healthz", "status": status, "response": payload}) + status, payload, _ = http("GET", "http://localhost:8800/health", timeout=5) + if USES_DATA_API: + line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/75/data-api && docker compose up -d --force-recreate (start n8n first)", + {"GET": "/health", "status": status, "response": payload}) + fault_status, fault, _ = http("GET", "http://localhost:8800/admin/fault", timeout=5) + price_status, prices, _ = http("GET", "http://localhost:8800/prices", timeout=5) + budget_status, b, _ = http("GET", "http://localhost:8800/budget", timeout=5) + models = prices.get("models", {}) if isinstance(prices, dict) else {} + line(price_status == 200 and budget_status == 200 and "gpt-5.4-nano" in models and isinstance(b, dict) + and "max_question_chars" in b, + "prices /prices · budget /budget (day 75 data API)", + "the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/75/data-api && docker compose up -d --force-recreate && sleep 3", + {"GET": "/prices", "status": price_status, "response": prices, "budget": b}) + if isinstance(b, dict) and "daily_usd" in b: + start = load_json(ROOT / "data-api" / "data" / "budget.json") + if any(b.get(key) != value for key, value in start.items()): + print(f"{WARN} budget is not the start state: {as_text({key: b.get(key) for key in start})}" + f"\n → uv run tools/flow.py budget --reset") + if fault_status == 200 and isinstance(fault, dict) and any(fault.get(key, "ok") != "ok" for key in ("faq", "tickets", "prompts", "usage")): + print(f"{WARN} fault switch is on: {fault}\n → uv run tools/flow.py fault all ok") + else: + print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)") + if running: + probe = docker("exec", CONTAINER, "node", "-e", + "fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))", + check=False).stdout.strip() + if USES_DATA_API: + line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)", + {"from n8n": "GET http://data-api:8800/health", "status": probe}) + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; " + "cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout + try: + found = json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + found = [] + ids = {item.get("id") for item in found} + detail("credentials in n8n (id · name · type, no secrets)", + [f"{item.get('id')} · {item.get('name')} · {item.get('type')}" for item in found]) + for credential_id, name in COURSE_CREDENTIALS.items(): + line(credential_id in ids, f"credential '{name}' ({credential_id})", + "cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh") + catalog = node_versions() + line(bool(catalog), "node catalog (for check)", seen={"node types": len(catalog), "cache": str(CACHE.relative_to(ROOT))}) + for hook in AGENT_HOOKS: + _, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5) + line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}", + "uv run tools/flow.py push ", {"GET": f"/webhook/{hook}", "response": payload}) + agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)] + line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"), + "install one of codex / claude / agy (see Lecture/75/00-DAY75-PREP.md)", {name: shutil.which(name) for name in agents}) + print("ready" if not failures else f"{failures} item(s) to fix") + return 0 if not failures else 1 + + +def main() -> int: + global VERBOSE + common = argparse.ArgumentParser(add_help=False) + common.add_argument("-v", "--verbose", action="store_true", + help="details: request and response JSON, workflow nodes and links, docker commands") + parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("doctor", parents=[common], help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor) + item = commands.add_parser("check", parents=[common], help="find mistakes in a workflow file") + item.add_argument("file") + item.set_defaults(run=cmd_check) + item = commands.add_parser("push", parents=[common], help="import and publish workflow files (one n8n restart)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_push) + item = commands.add_parser("off", parents=[common], help="unpublish workflow files or webhook paths (a role that is down)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_off) + item = commands.add_parser("call", parents=[common], help="POST one request to a webhook path") + item.add_argument("path", help="webhook path, e.g. my-notice") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.set_defaults(run=cmd_call) + item = commands.add_parser("test", parents=[common], help="run a test case file") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1, help="run every case N times") + item.add_argument("--webhook", help="override the webhook path in the case file") + item.add_argument("--no-before", action="store_true", help="skip the 'before' steps") + item.add_argument("--set", action="append", help="add key=value to every request body (value is JSON when it parses)") + item.set_defaults(run=cmd_test) + item = commands.add_parser("gate", parents=[common], help="release gate: run a test file, PASS/FAIL from pass rate and p95") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1) + item.add_argument("--webhook") + item.add_argument("--no-before", action="store_true") + item.add_argument("--set", action="append", help="add key=value to every request body, e.g. release=1.1.0") + item.add_argument("--min-pass", type=float, default=1.0, help="share of cases that must pass (default 1.0)") + item.add_argument("--max-p95", type=float, help="seconds") + item.add_argument("--name", default="helpdesk", help="release name for the gate record (default helpdesk)") + item.add_argument("--no-record", action="store_true", help="do not store the verdict in /gates") + item.set_defaults(run=cmd_gate) + item = commands.add_parser("diff", parents=[common], help="compare two workflow files node by node") + item.add_argument("old") + item.add_argument("new") + item.add_argument("--full", action="store_true", help="print long values (prompts) before and after (-v does too)") + item.set_defaults(run=cmd_diff) + item = commands.add_parser("release", parents=[common], help="show or change the active release / canary in the data API") + item.add_argument("name") + item.add_argument("version", nargs="?") + item.add_argument("--canary") + item.add_argument("--percent", type=int, default=30) + item.add_argument("--no-canary", action="store_true") + item.add_argument("--reset", action="store_true", help="active 1.0.0, no canary, deploy policy off") + item.add_argument("--require-gate", choices=["on", "off"], help="deploy only releases whose last gate record is promote") + item.set_defaults(run=cmd_release) + item = commands.add_parser("bench", parents=[common], help="POST the same request N times: p50, p95, max, failures") + item.add_argument("path", help="webhook path, e.g. my-helpdesk") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.add_argument("-n", type=int, default=10, help="how many calls (default 10)") + item.add_argument("--set", action="append", help="add key=value to the request body, e.g. release=2.0.0") + item.set_defaults(run=cmd_bench) + item = commands.add_parser("usage", parents=[common], help="recorded LLM calls, tokens and cost by model / user / route, and the budget") + item.add_argument("--clear", action="store_true", help="empty the usage records (the budget counts from 0 again)") + item.set_defaults(run=cmd_usage) + item = commands.add_parser("budget", parents=[common], help="show or change the daily budget and the limits") + item.add_argument("--daily", type=float, help="daily budget in USD, e.g. 0.001") + item.add_argument("--per-min", type=int, help="requests per user per minute, e.g. 2") + item.add_argument("--chars", type=int, help="longest question sent as it is, e.g. 500") + item.add_argument("--reset", action="store_true", help="back to data/budget.json") + item.set_defaults(run=cmd_budget) + item = commands.add_parser("fault", parents=[common], help="data API fault switch for drills") + item.add_argument("target", choices=["faq", "tickets", "prompts", "usage", "all"]) + item.add_argument("mode", choices=["ok", "slow", "error"]) + item.add_argument("--seconds", type=float, help="how long 'slow' waits (default 8)") + item.set_defaults(run=cmd_fault) + item = commands.add_parser("llm", parents=[common], help="outage drill: break (down) or restore (up) the OpenAI credential in n8n") + item.add_argument("state", choices=["down", "up"]) + item.set_defaults(run=cmd_llm) + args = parser.parse_args() + VERBOSE = args.verbose + return args.run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lecture/75/00-DAY75-OVERVIEW.md b/Lecture/75/00-DAY75-OVERVIEW.md new file mode 100644 index 0000000..337322f --- /dev/null +++ b/Lecture/75/00-DAY75-OVERVIEW.md @@ -0,0 +1,420 @@ +# 75일차 개요 · 컴퓨팅 자원·API 사용량 관리와 비용 최적화 + +> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-29 +> 30장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~6장을 보고, 나머지 장은 각 단계 강의(1 · 3 · 5교시) 때 함께 띄웁니다. +> 숫자는 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini` · `gpt-5.4-nano`, 10/28 정답 워크플로우를 학생 순서대로 1회). 단가는 OpenAI 모델 문서에서 2026-10-28에 확인한 값입니다. + +| 장 | 범위 | 띄우는 때 | +| --- | --- | --- | +| 1~6 | 표지 · 목표 · 시간표 · 비싼 헬프데스크 · 일하는 순서 · 실습 15개 | 1교시 시작 | +| 7~14 | 1단계 · 사용량 재기 | 1교시 강의 | +| 15~21 | 2단계 · 비용 줄이기 | 3교시 강의 | +| 22~28 | 3단계 · 예산과 한도 | 5교시 강의 | +| 29~30 | 바이브 코딩 · 정리와 예고 (8교시 35분 정리는 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)) | 7교시 · 8교시 | + +--- + +## 1 / 30 · 표지 + +```mermaid +flowchart LR + M["재기
토큰 · 비용 · 기록"] --> R["줄이기
보내는 것 · 모델 · 호출 수"] --> K["지키기
예산 · 한도 · 보고서"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + class M,K o + class R d +``` + +**75일차** + +# 컴퓨팅 자원·API 사용량 관리와 비용 최적화 + +질문 하나에 얼마인지 재고, 품질은 그대로 두고 줄이고, 넘치지 않게 지킨다 + +노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-29 + +--- + +## 2 / 30 · 오늘 할 수 있게 될 세 가지 + +| | 목표 | 내용 | +| --- | --- | --- | +| 1 | **잰다** | 자원 · 토큰 · 돈을 구분하고, 단계마다 비용을 결과에 남기고, 종류별로 한 달을 어림하고, 요청마다 기록한다 (멈춰도 답은 나감) | +| 2 | **줄인다** | 보내는 것 · 단가 · 호출 수를 한 칸씩, 품질 테스트와 답 읽기로 확인하고, 긴 입력에도 상한을 둔다 | +| 3 | **지킨다** | 하루 예산을 넘으면 원문으로, 몰아 부르면 429, 예산이 언제 멈추는지 재고, 보고서 · what-if는 코드로 | + +--- + +## 3 / 30 · 오늘의 시간표 + +| 교시 | 시간 | 내용 | +| --- | --- | --- | +| 1 | 09:00~09:50 | 개요 · **1단계 강의** · 사용량 재기 | +| 2 | 10:00~10:50 | **Lab 01 실습 5개** · 자원 · 비용 · 종류별 비용 · 기록 · 기록이 멈추면 | +| 3 | 11:00~11:50 | **2단계 강의** · 비용 줄이기 | +| 4 | 12:00~12:50 | **Lab 02 실습 5개** · 보내는 것 · 모델 · 답까지 nano? · LLM 한 번 · 긴 질문 | +| - | 13:00~14:00 | 점심 | +| 5 | 14:00~14:50 | **3단계 강의** · 예산과 한도 | +| 6 | 15:00~15:50 | **Lab 03 실습 5개** · 하루 예산 · 1분 한도 · 예산 바꿔 보기 · 보고서 · what-if | +| 7 | 16:00~16:50 | 바이브 코딩 `tcost`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | +| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 76일차 예고 | + +--- + +## 4 / 30 · 오늘 고치는 것: 비싼 헬프데스크 + +```mermaid +flowchart LR + Q(["질문"]) --> C{{"분류 LLM"}} --> A{{"답 LLM
+ 사내 규정 전체"}} --> R{{"검토 LLM"}} --> O(["답"]) +``` + +| 수업 준비 때 | VPN 질문 하나 | LLM 호출 | +| --- | --- | --- | +| 시작 (`base-cost`) | **$0.001040** (입력 1,999 · 출력 150) | 3 | +| 하루 뒤 (Lab 02 과제 1) | **$0.000156** | 1 | + +- 72~74일차와 같은 IT 문의 헬프데스크 (FAQ · 접수 건). 오늘은 **같은 답을 더 싸게**, 그리고 **넘치지 않게**. + +--- + +## 5 / 30 · 일하는 순서 (오늘은 "재기 → 줄이기 → 품질 확인") + +```mermaid +flowchart LR + A["① 재기
call · test 줄 끝 비용"] --> B["② 요청문을
메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가
my-work · check"] --> E["⑤ 내가
push · test -v"] + E -->|✘| F["⑥ 실패 줄 · 보고서 경로
그대로 붙여 주기"] --> C + E -->|✔| G["⑦ 답 읽기 · COST.md 한 줄"] +``` + +- 예산 · 한도 · 글자 수 상한은 **내가** `flow.py budget`으로. 끝나면 `budget --reset`. +- 오늘은 호출마다 실제로 돈이 듭니다 (질문 하나에 약 $0.0001~$0.0012). 같은 테스트를 수십 번 반복하지 않습니다. + +--- + +## 6 / 30 · 오늘의 실습 15개 + +| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 | +| --- | --- | --- | --- | --- | --- | +| Lab 01 | 자원 · 단계별 토큰 (에이전트 없이) | 결과에 `cost_usd` | 종류별 비용 · 한 달 (에이전트 없이) | 요청마다 기록 | 기록이 멈춰도 답 | +| Lab 02 | 보내는 것 줄이기 | 단계마다 맞는 모델 | 답까지 nano면? (에이전트 없이) | LLM 한 번 · $0.0002 | 긴 질문의 상한 | +| Lab 03 | 하루 예산 → 원문 | 1분 한도 → 429 | 예산을 바꿔 가며 (에이전트 없이) | 비용 보고서 | 바꾸면 얼마 (what-if) | + +- 실습 문서에 복사해 붙일 요청문은 없습니다. 막히면 `▶`, 요청문은 **빈칸 4개**를 채워서. + +--- + +## 7 / 30 · 1단계 · 왜 비용인가 + +| | 일반 웹 서비스 | LLM API | +| --- | --- | --- | +| 무엇에 돈을 내나 | 서버 시간 · 저장 · 전송 | **보낸 · 받은 토큰** | +| 요청 하나의 비용 | 작고 거의 같음 | 요청마다 크게 다름 | +| 실수 하나의 크기 | 작음 | 지시문에 긴 자료 → **모든 요청**이 비쌈 | + +- FinOps 세 단계: **알기**(1단계) → **줄이기**(2단계) → **운영하기**(3단계). 재지 않고 줄이면 무엇을 잃었는지 모릅니다. +- 쓸모 있는 숫자는 **요청 하나의 비용** (단위 경제). + +--- + +## 8 / 30 · 1단계 · 자원: 내 컴퓨터와 공급자 (Lab 01 예제 1) + +| 컨테이너 (`docker stats`) | CPU | 메모리 | +| --- | --- | --- | +| n8n | 0.27% | 642.6MiB | +| runners | 0.21% | 100.5MiB | +| data-api | 0.02% | 20.3MiB | + +- API로 부르면 LLM 계산은 공급자 서버에서 → 내 CPU는 거의 쓰지 않음. 돈은 **토큰**에서. +- 내 쪽에서 늘어나는 것: 동시 실행 · 실행 기록 · 로그 (3단계 7절, 76 · 77일차). + +--- + +## 9 / 30 · 1단계 · 토큰과 단가 + +```text +비용 = (입력 토큰 × 입력 단가 + 출력 토큰 × 출력 단가) ÷ 1,000,000 +``` + +| 모델 (1M 토큰당 $) | 입력 | 캐시된 입력 | 출력 | +| --- | --- | --- | --- | +| `gpt-4.1-mini` | 0.40 | 0.10 | 1.60 | +| `gpt-5.4-nano` | 0.20 | 0.02 | 1.25 | + +- VPN 질문: (1,999 × 0.40 + 150 × 1.60) ÷ 10⁶ = **$0.00104**. 하루 1,000번 · 22일 ≈ $22.9. +- **출력이 더 비싸고, 가격은 바뀝니다** (단가표에 확인한 날짜). + +--- + +## 10 / 30 · 1단계 · 토큰이 보이는 호출 (Lab 01 예제 1 · 2) + +| `base-cost` VPN | 입력 | 출력 | +| --- | --- | --- | +| classify | 78 | 6 | +| answer | **1,781** | 78 | +| review | 140 | 66 | + +- 응답의 `usage`가 청구의 기준. AI Agent 노드는 답만 주고 토큰을 버림 → 오늘은 **HTTP로 OpenAI를 직접**. +- 입력의 89%가 `answer` 하나. 접수 질문도 1,791 → **매번 같이 보내는 것**이 있다. + +--- + +## 11 / 30 · 1단계 · 단계별 비용, 단가는 밖에 (Lab 01 예제 2) + +```mermaid +flowchart LR + P[("/prices
모델 → 단가")] -.-> B["Build result
단계마다 토큰 × 그 모델 단가"] --> O(["usage.by_step.*.cost_usd
+ cost_usd"]) +``` + +- 단가를 코드에 숫자로 → 가격 · 모델이 바뀌면 **조용히 틀림**. 70일차 설정 표 · 74일차 저장소와 같은 생각. +- 모르는 모델은 0원이 아니라 **오류**. 소수 8자리. +- 결과: VPN **$0.001040** (손 계산과 같음), `lab01` 3/3. + +--- + +## 12 / 30 · 1단계 · 종류별 비용과 한 달 (Lab 01 예제 3) + +| 종류 | 평균 (`lab01-mix`) | 비중 (가정) | +| --- | --- | --- | +| FAQ 넷 | $0.000980 | 60% | +| 접수 둘 | $0.000918 | 30% | +| 그 밖 | $0.000830 | 10% | + +- 가중 평균 $0.000946 × 1,000건 × 22일 ≈ **한 달 $20.8**. +- 일곱 질문이 **$0.00083 ~ 0.00104로 비슷** → 고정비(규정 전체)가 대부분. 고정비부터 줄인다. + +--- + +## 13 / 30 · 1단계 · 기록: 요청 · 호출 · 사람 (Lab 01 과제 1) + +| 요약 (`/usage/summary`) | 질문 3개 뒤 | +| --- | --- | +| requests (요청) | 3 | +| calls (호출) | 9 | +| 합계 | $0.002951 | +| 사람별 | 이준호 $0.001040 · 박서연 $0.000959 · 한지훈 $0.000953 | + +- 호출마다 한 줄, 같은 요청은 같은 `request_id` (`$execution.id`). 기록이 없으면 **예산 · 한도도 없다**. +- 기록에는 숫자와 분류 칸만 (질문 원문 · 개인정보 · 키는 남기지 않음). + +--- + +## 14 / 30 · 1단계 · 기록이 멈추면 (Lab 01 과제 2) + +| 기록 API | 고치기 전 | 고친 뒤 (`timeout` 3초 · 실패해도 계속) | +| --- | --- | --- | +| 고장 (500) | 답 · `recorded` 없음 | 답 · `recorded: false` | +| 느림 (8초) | **11.2초** | **6.1초** · `recorded: false` | + +- 부가 기능(기록)이 본 기능(답)을 막지 않게. 값: 빠진 기록은 **예산에서도 빠짐** → 표시를 남김. +- 수업 준비 때: 3초에 포기했는데 서버가 8초 뒤 **결국 저장** → 다음 테스트의 요청 수가 하나 많았음. **포기해도 상대의 일은 끝나지 않을 수 있다.** + +--- + +## 15 / 30 · 2단계 · 비용의 세 칸 + +```text +요청 하나의 비용 ≈ 호출 수 × 호출당 토큰 × 토큰 단가 +``` + +| 칸 | 줄이는 방법 | 오늘 | +| --- | --- | --- | +| 호출당 토큰 | 필요한 것만 보내기 · RAG · 출력 상한 · 긴 입력 자르기 | 예제 1 · 과제 2 | +| 토큰 단가 | 단계에 맞는 작은 모델 · 캐시 · 배치 | 예제 2 · 3 | +| 호출 수 | 규칙으로 먼저 · 중복 단계 빼기 · 캐시 | 과제 1 | + +- **품질 테스트를 먼저 고정**하고, 한 번에 한 칸, 가장 큰 몫부터. + +--- + +## 16 / 30 · 2단계 · 보내는 것 줄이기 (Lab 02 예제 1) + +| | 전 | 후 | +| --- | --- | --- | +| `answer` 입력 (VPN) | 1,781 | **165** (91% 줄임) | +| VPN 비용 | $0.001007 | **$0.000346** | +| 품질 테스트 | ✔ | ✔ | + +- 규정 전체 빼기, FAQ 3칸 · 접수 5칸 (연락처 · 담당자 빼기 = **데이터 최소화**), `max_completion_tokens` 200. +- 규정이 필요하면 통째로가 아니라 **검색해서 관련 부분만** (71일차 RAG). + +--- + +## 17 / 30 · 2단계 · 단계마다 맞는 모델 (Lab 02 예제 2) + +| 모델 | 입력 | 출력 | +| --- | --- | --- | +| `gpt-4.1-mini` | 0.40 | 1.60 | +| `gpt-5.4-nano` | 0.20 (절반) | 1.25 (약 0.78배) | + +- 분류 · 검토(쉬운 일)만 nano, 답은 mini → VPN $0.000346 → **$0.000278**. +- "작은 모델이면 몇 배 싸다"는 늘 맞지 않음 → **단가표로 계산**. 노드와 함께 **계산의 모델 이름도**. + +--- + +## 18 / 30 · 2단계 · 싸졌는데 나빠졌다면 (Lab 02 예제 3) + +| 답 단계 | 낱말 검사 | 일곱 질문 평균 | +| --- | --- | --- | +| mini (내 것) | 5/5 | $0.000233 | +| nano (비교본) | **5/5** | $0.000198 (약 15% 덜) | + +- nano 답을 읽으면: `**처리 중**` 마크다운, "ticket_id가 없어…" 안쪽 말, "접수 건은 찾지 못했습니다" 군더더기. +- **통과 ≠ 같은 품질.** 사람이 읽고, 찾은 것은 테스트로. 오늘은 답을 mini로 둔다. + +--- + +## 19 / 30 · 2단계 · 가장 싼 호출은 하지 않은 호출 (Lab 02 과제 1) + +```mermaid +flowchart LR + Q(["질문"]) --> R{"규칙이 아나?
T-숫자 · FAQ 검색"} + R -->|예 · 대부분| A{{"답 LLM 1번"}} + R -->|아니오| C{{"분류 nano"}} --> A +``` + +| | 전 | 후 | +| --- | --- | --- | +| FAQ · 접수 | 3 calls · 다섯이 $0.0002 넘음 | **1 call · $0.000108 ~ 0.000166** | +| `lab02-challenge` | 0/7 | **7/7** | +| 시간 | 3초대 | 1초대 | + +--- + +## 20 / 30 · 2단계 · 긴 입력의 상한 (Lab 02 과제 2) + +| 6,000자 넘는 로그 질문 | 전 | 후 (앞 200 + 뒤 300자) | +| --- | --- | --- | +| 프린터 (끝에 질문) | **$0.001196** (짧은 질문의 7배 넘게) | **$0.000237** · "대기열" | +| VPN (앞에 질문) | $0.001099 | $0.000260 · "인증서" | + +- 입력 상한이 없으면 **요청 하나의 비용에도 상한이 없다** (Denial of Wallet). +- 입력 상한 + 출력 상한 → 요청 하나의 **최대 비용** → 예산을 지킬 수 있다. + +--- + +## 21 / 30 · 2단계 · 한 번에 한 칸씩 + +| 단계 | 바꾼 칸 | VPN 비용 | 호출 | +| --- | --- | --- | --- | +| 기준 | | $0.001007 | 3 | +| 예제 1 | 호출당 토큰 | $0.000346 | 3 | +| 예제 2 | 단가 | $0.000278 | 3 | +| 과제 1 | 호출 수 | **$0.000156** | 1 | + +- 바꿀 때마다 `COST.md`에 한 줄: 바꾼 것 · 전 → 후 · 품질 테스트 · **잃은 것**. + +--- + +## 22 / 30 · 3단계 · 넘치지 않게 + +| 장치 | 막는 것 | 오늘 | +| --- | --- | --- | +| 예산 | 전체가 너무 많이 | 하루 `daily_usd` $1.0 | +| 속도 제한 | 한 사람이 짧은 시간에 몰아서 | 1분 `user_limit_per_min` 20 | +| 요청 상한 | 요청 하나가 너무 큼 | `max_question_chars` 500 · `max_completion_tokens` 200 | + +- 공급자 한도는 **계정 전체**에 걸림 → 한 사람 때문에 모두 막힘. 그래서 내 쪽에 사람별 한도. +- 값은 **사람이** 정하고(`/budget`), 워크플로우는 읽기만. + +--- + +## 23 / 30 · 3단계 · 하루 예산, 넘으면 원문 (Lab 03 예제 1) + +```mermaid +flowchart LR + D["FAQ · 접수 조회"] --> B{"예산 안?"} + B -->|예| L{{"LLM 답"}} + B -->|아니오| O(["한도 초과 · 원문 · $0"]) +``` + +- 멈추는 대신 **낮춰서 답하기** (graceful degradation). 확인은 LLM 앞 · 데이터 뒤. +- 72일차 LLM 장애의 `간단 답변`과 `route`를 구분 → 장애인지 예산인지 가른다. +- 결과: `한도 초과 · 0 calls · $0`, "인증서" · "처리 중" (3/3). + +--- + +## 24 / 30 · 3단계 · 사용자별 한도와 429 (Lab 03 예제 2) + +| 세는 방법 | 특징 | +| --- | --- | +| 고정 창 | 쉬움, 경계에서 두 배 | +| 이동 창 (오늘) | 고르게 막음 | +| 토큰 버킷 · 새는 통 | 짧은 몰림 허용 · 속도를 고르게 | + +- **가장 앞**에서 429 + `retry_after` → 이준호 세 번째: **0.0초 · $0**, 다른 사람은 그대로. +- 세는 곳은 하나 (서버가 여러 대여도). 사람은 로그인 정보로 가린다. + +--- + +## 25 / 30 · 3단계 · 예산은 넘은 뒤 멈춘다 (Lab 03 예제 3) + +| 예산 $0.0003 | 결과 | 누적 | +| --- | --- | --- | +| 1 · 2번째 | 자동 답변 | $0.000293 (아래) | +| 3번째 | 자동 답변 | **$0.000430 (43% 넘음)** | +| 4 · 5 · 6번째 | 한도 초과 · $0 | 그대로 | + +- 넘지 않으려면 "남은 돈 < 한 건 최대 비용이면 멈춤" → **상한이 있어야 예산도 지킨다**. +- 경고는 멈추기 **전에** (80%). 한도 초과 답도 기록해야 몇 명이 원문을 받았는지 센다. + +--- + +## 26 / 30 · 3단계 · 비용 보고서 (Lab 03 과제 1) + +| 넣은 네 줄 | 손 계산 = 보고서 | +| --- | --- | +| 요청 · 호출 · 합계 | 3 · 4 · $0.001989 | +| 평균 (요청 수로) · 100건 × 22일 | $0.000663 · **$1.4586** | +| 가장 많이 쓴 사람 · 남은 예산 | 박서연 · $0.998011 | + +- 숫자는 **코드가** 계산 (틀리지 않고 · 공짜 · 늘 같게), LLM은 설명만 (79일차). +- 실제 사용량 일곱 건 · 하루 1,000건 → **한 달 $2.80** (아침 어림 $20.8의 약 7분의 1). + +--- + +## 27 / 30 · 3단계 · 바꾸면 얼마: what-if (Lab 03 과제 2) + +| what-if (넣은 네 줄) | 비용 | 아끼는 돈 | +| --- | --- | --- | +| 모두 nano | $0.00117 | $0.000819 | +| 모두 mini | $0.002016 | **−$0.000027** | + +- 실제 사용량: $0.000929 → nano였다면 $0.00061 (34% 덜). **같은 토큰 · 같은 품질을 가정한 숫자.** +- 계산은 "해 볼 만한가", 결정은 "재고 · 읽고 · 게이트로" (74일차). 모르는 모델은 400. + +--- + +## 28 / 30 · 3단계 · 운영: 재기 → 줄이기 → 지키기 + +```mermaid +flowchart LR + M["재기
usage · 기록"] --> R["줄이기
한 칸씩 · 품질 테스트"] --> K["지키기
예산 · 한도 · 상한"] --> P["보고
정기적으로 · what-if"] --> M +``` + +- 누가 값을 정하나(사람) · 언제 보나(매주 · 매달) · 넘으면 무엇을 주나(원문 · 429) · 무엇을 남기나(`COST.md`). +- 내 쪽 자원(동시 실행 · 실행 기록)도 같은 보고서에. + +--- + +## 29 / 30 · 7교시 · 바이브 코딩 `tcost` + +- LLM을 부르기 **전에** 글 · 워크플로우의 토큰과 비용을 계산하고, 테스트 보고서(`runs/*.json`)의 **실제 사용량을 모아** 한 달을 예상하는 TUI. **OpenAI를 부르지 않습니다.** +- 순서: [요청서](../../Last3Mon/75/VIBE-REQ-TCOST.md) → `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → `uv tool install` → 다른 폴더에서. +- 요구 14개 (R1~R14). 50분 안에 엔진 1 · 2단계 · TUI 1단계 · 설치. + +```mermaid +flowchart LR + R["요청서"] --> T["TODO"] --> S["SPEC"] --> P["IMP-PLAN"] --> B["엔진 → TUI"] --> I["uv tool install"] +``` + +--- + +## 30 / 30 · 오늘의 정리 · 76일차 예고 + +| 오늘 | 한 줄 | +| --- | --- | +| 1단계 | 응답의 `usage`로 단계마다 비용, 단가는 밖에, 종류별로 한 달, 호출마다 기록 (멈춰도 답) | +| 2단계 | 보내는 것 · 단가 · 호출 수를 한 칸씩, 통과해도 답을 읽고, 입력 · 출력 상한 | +| 3단계 | 예산을 넘으면 원문, 몰아 부르면 429, 예산은 넘은 뒤 멈춘다, 보고서 · what-if는 코드로 | + +**76일차 · 에러 처리와 예외 대응**: 오늘 기록 API가 멈췄을 때 답은 보냈지만, 빠진 기록은 `recorded: false`로 남기기만 했습니다. 내일은 실패한 일을 **모아 두고 다시 처리하고, 사람에게 알리는** 운영 체계를 만듭니다. diff --git a/Lecture/75/00-DAY75-PREP.md b/Lecture/75/00-DAY75-PREP.md new file mode 100644 index 0000000..6ca788e --- /dev/null +++ b/Lecture/75/00-DAY75-PREP.md @@ -0,0 +1,96 @@ +# 75일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것 + +- 수업일: 2026-10-29 (75일차) · 컴퓨팅 자원·API 사용량 관리와 비용 최적화 +- 준비 시간: 약 10분 +- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu). +- **수업 전날까지** 1~2절을 끝내 주세요. + +```mermaid +flowchart LR + A["1. n8n · 데이터 API 켜기
(75일차 판)"] --> B["2. 기준 게시 ·
doctor ready"] --> C["3. 미리 읽기"] +``` + +--- + +## 1. n8n과 데이터 API 켜기 (전날 필수, 3분) + +오늘은 **75일차 판 데이터 API**(74일차 + 단가 · 사용량 기록 · 예산 · 한도)를 씁니다. 같은 이름이라 이 폴더에서 다시 만들면 바뀝니다. + +```bash +sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔 +cd ~/aio-01d4-stu/Last3Mon/65/n8n +docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감) +docker compose start +cd ~/aio-01d4-stu/Last3Mon/75/data-api +docker compose up -d --force-recreate +sleep 3 # 데이터 API가 뜰 때까지 +curl -s localhost:8800/prices # {"currency": "USD", "unit": "per 1M tokens", ... "gpt-5.4-nano" ... +``` + +- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** 오늘은 이 키로 쓴 토큰과 돈을 직접 잽니다. 작은 모델은 `gpt-5.4-nano`입니다 (`gpt-4.1-nano`는 OpenAI가 더 이상 받지 않음). +- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시. + +## 2. 기준 게시와 준비 확인 (전날 필수, 3분) + +오늘 비용을 잴 **비싼 헬프데스크**를 게시해 둡니다 (만들지 않고 게시만). + +```bash +cd ~/aio-01d4-stu/Last3Mon/75 +uv run tools/flow.py push reference/baseline-cost.json +uv run tools/flow.py call base-cost requests/faq-vpn.json # usage.by_step 에 단계별 토큰 (한 번에 약 $0.001) +uv run tools/flow.py doctor +``` + +```text +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ prices /prices · budget /budget (day 75 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready +``` + +| ✘ 항목 | 할 일 | +| --- | --- | +| n8n container · data API | 1절 | +| prices · budget | 데이터 API가 예전 판 → 1절 `--force-recreate` · `sleep 3` | +| `! budget is not the start state` | `uv run tools/flow.py budget --reset` | +| `! fault switch is on` | `uv run tools/flow.py fault all ok` | +| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` | +| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 | + +--- + +## 3. 미리 읽어 오면 좋은 내용 (선택, 약 5분) + +### 3.1 질문 하나에 얼마일까 + +LLM API는 **토큰**(글자 조각) 수만큼 돈을 받습니다. 보낸 것(입력)과 받은 것(출력)의 단가가 다르고, 모델마다 다릅니다. 지시문에 사내 규정을 통째로 붙여 매번 보내면 어떻게 될까요? 2절의 `call base-cost`에서 `usage.by_step`의 입력 토큰이 가장 많은 단계를 찾아 와도 좋습니다. + +### 3.2 용어 + +| 용어 | 한 줄 설명 | +| --- | --- | +| 토큰 | LLM이 글을 세는 단위 (한국어는 대략 글자 1~2개에 1토큰) | +| 입력 · 출력 토큰 | 보낸 지시문 · 질문 · 자료 / 받은 답 (출력이 더 비쌈) | +| 단가 | 1M(백만) 토큰당 달러, 입력 · 출력 · 모델마다 다름 | +| 계량 (metering) | 요청마다 쓴 양을 재서 남기기 | +| 예산 · 한도 | 하루에 쓸 돈의 상한 · 한 사람이 짧은 시간에 부를 수 있는 횟수 | +| 429 | "요청이 너무 많다"는 HTTP 상태 코드 | + +### 3.3 7교시 요청서 + +[VIBE-REQ-TCOST](../../Last3Mon/75/VIBE-REQ-TCOST.md)를 한 번 읽어 옵니다. 재무 · 운영 담당이 원하는 것 열두 가지 중 "꼭"은 무엇일까요? + +## 4. 당일 준비물 + +- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저 + +## 확인 + +- [ ] `curl -s localhost:8800/prices`에 `gpt-4.1-mini` · `gpt-5.4-nano` 단가 +- [ ] `call base-cost`가 `usage.by_step`을 돌려줌 +- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready` diff --git a/Lecture/75/01-STAGE1-MEASURE-USAGE.md b/Lecture/75/01-STAGE1-MEASURE-USAGE.md new file mode 100644 index 0000000..e8383f5 --- /dev/null +++ b/Lecture/75/01-STAGE1-MEASURE-USAGE.md @@ -0,0 +1,461 @@ +# 1단계 · 사용량 재기 + +- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md) 실습 5개 (10:00~10:50) +- 이 단계가 끝나면: AI 워크플로우가 쓰는 것 세 가지(컴퓨팅 자원 · API 토큰 · 돈)를 구분하고, 토큰과 단가로 질문 하나의 비용을 계산하고, 단계마다 비용을 결과에 남기고, 질문 종류별 비용으로 한 달을 어림하고, 요청마다 사용량을 기록하고, 기록이 멈춰도 답은 나가게 할 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini` · `gpt-5.4-nano`, 10/28 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 1회). 단가는 OpenAI 모델 문서에서 2026-10-28에 확인한 값입니다. 출력 전부는 수업 뒤 공개되는 `Last3Mon/75/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/75` 기준입니다. + +```mermaid +flowchart LR + A["1. 왜 비용인가"] --> B["2. 자원"] --> C["3. 토큰과 단가"] --> D["4. 토큰이 보이는 호출"] --> E["5. 단계별 비용"] + E --> F["6. 종류별 비용 · 한 달"] --> G["7. 기록"] --> H["8. 기록이 멈추면"] --> I["9. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 09:00~09:06 | 개요 1~6장 | 오늘 하루, 비싼 헬프데스크, 실습 15개 | +| 09:06~09:12 | 1 · 개요 7장 | 쓴 만큼 내는 비용, FinOps, 요청 하나의 비용 | +| 09:12~09:17 | 2 · 개요 8장 | 내 컴퓨터의 자원과 공급자의 자원 | +| 09:17~09:24 | 3 · 개요 9장 | 토큰, 입력 · 출력 단가, 계산 | +| 09:24~09:29 | 4 · 개요 10장 | 응답의 `usage`, 숨기는 노드, 관측 도구 | +| 09:29~09:34 | 5 · 개요 11장 | 단계별 비용, 단가는 밖에 | +| 09:34~09:39 | 6 · 개요 12장 | 고정비 · 변동비, 가중 평균, 한 달 어림 | +| 09:39~09:45 | 7 · 8 · 개요 13~14장 | 기록(요청 · 호출 · 사람), 기록이 멈추면 | +| 09:45~09:50 | 9 · 10 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 2교시 Lab 01에서 이어서 하는 것 + +| 2교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| 시작 (10:00) | — | 비용 계획 복사 | `cp templates/COST-PLAN-TEMPLATE.md my-work/COST.md` (오늘 잰 숫자를 모두 여기에) | +| Lab 01 예제 1 (10:00~10:10, 에이전트 없이) | 2 · 3 | 자원 · 단계별 토큰 · 단가 보기, 손 계산 | `docker stats --no-stream`, `call base-cost requests/faq-vpn.json` → `COST.md` 1 · 2절 | +| Lab 01 예제 2 (10:10~10:22) | 4 · 5 | 에이전트에게 단계마다 `cost_usd`를 넣게 하기 | `my-work/cost.json` (기준을 복사), 테스트 `cases/lab01.json` | +| Lab 01 예제 3 (10:22~10:30, 에이전트 없이) | 6 | 일곱 질문의 비용, 한 달 어림 | `test cases/lab01-mix.json` → `COST.md` 3절 | +| Lab 01 과제 1 (10:30~10:40) | 7 | 요청마다 `/usage`에 기록 | 테스트 `cases/lab01-challenge.json`, `flow.py usage` | +| Lab 01 과제 2 (10:40~10:50) | 8 | 기록이 멈춰도 답은 나감 | 테스트 `cases/lab01-challenge2.json` (`fault usage error` · `slow`) | + +## 1. 왜 비용인가: "청구서를 보고 놀라는 날" + +### 일반 설명 + +예전에는 서버를 사면 한 달 비용이 정해져 있었습니다. 클라우드와 API는 다릅니다. **쓴 만큼 냅니다 (pay as you go).** 처음에는 싸고 편하지만, 쓰는 사람이 늘거나 한 번에 쓰는 양이 늘면 비용도 그만큼 늘어납니다. 그리고 그 사실을 **청구서가 오고 나서야** 아는 일이 흔합니다. + +LLM API는 이 성질이 더 강합니다. + +| | 일반 웹 서비스 | LLM API | +| --- | --- | --- | +| 무엇에 돈을 내나 | 서버 시간 · 저장 공간 · 전송량 | **보낸 · 받은 토큰** | +| 요청 하나의 비용 | 아주 작고 거의 같음 | 요청마다 크게 다름 (보낸 글의 길이, 답의 길이, 모델) | +| 누가 비용을 정하나 | 주로 개발자 | 개발자 + **사용자**(질문 길이) + 지시문 · 자료를 붙이는 방식 | +| 실수 하나의 크기 | 작음 | 지시문에 긴 자료를 붙이면 **모든 요청**이 비싸짐 | + +**FinOps.** 클라우드 비용을 엔지니어 · 재무 · 사업이 함께 관리하는 방법을 FinOps라고 부릅니다. FinOps Foundation은 이것을 세 단계로 설명합니다. + +| 단계 | 하는 일 | 오늘 | +| --- | --- | --- | +| 알기 (Inform) | 누가 · 무엇에 · 얼마를 쓰는지 보이게 | 1단계 · 재기 · 기록 | +| 줄이기 (Optimize) | 같은 결과를 더 싸게 | 2단계 · 줄이기 | +| 운영하기 (Operate) | 예산 · 한도 · 보고를 습관으로 | 3단계 · 예산 · 한도 · 보고서 | + +순서가 중요합니다. **재지 않고 줄이면** 무엇이 줄었는지도, 무엇을 잃었는지도 모릅니다. 72일차 성능 튜닝에서 "먼저 재고, 하나씩 바꾸고, 다시 잰다"를 했습니다. 비용도 같습니다. + +**요청 하나의 비용 (unit cost).** 전체 청구액만 보면 "많이 썼다"만 압니다. 운영에서 쓸모 있는 숫자는 **요청 하나에 얼마**입니다. 이 숫자가 있으면 사용자가 두 배가 될 때 비용이 얼마가 될지, 새 기능이 요청당 얼마를 더하는지, 가격을 얼마로 매겨야 남는지를 말할 수 있습니다. 이것을 단위 경제(unit economics)라고 부릅니다. + +| 숫자 | 뜻 | 어디에 쓰나 | +| --- | --- | --- | +| 요청 하나의 비용 | 질문 하나에 드는 돈 | 한 달 예상, 기능 비교 | +| 사용자 한 명의 하루 비용 | 한 사람이 하루에 쓰는 돈 | 한도 정하기 (3단계) | +| 기능별 비용 | 분류 · 답 · 검토 각각 | 줄일 곳 찾기 (2단계) | +| 하루 · 한 달 합계 | 청구액 | 예산과 비교 (3단계) | + +**청구액이 갑자기 느는 흔한 까닭.** 운영 중인 LLM 앱의 비용이 갑자기 늘었을 때 원인을 찾아보면 대개 아래 중 하나입니다. 어느 것이든 **요청 하나의 비용**이나 **요청 수** 중 하나가 바뀐 것이고, 기록이 있으면 어느 쪽인지 바로 보입니다. + +| 원인 | 무엇이 늘었나 | 알아채는 법 | +| --- | --- | --- | +| 지시문에 자료를 더 붙임 | 요청 하나의 입력 토큰 | 단계별 입력 토큰이 갑자기 큼 | +| 대화 기록을 통째로 다시 보냄 | 대화가 길수록 입력 토큰 | 같은 사람의 요청마다 입력이 커짐 | +| 재시도 · 반복 호출 | 호출 수 | 요청 수는 같은데 호출 수가 늘어남 | +| 더 비싼 모델로 바꿈 | 단가 | 모델별 합계의 비중이 바뀜 | +| 누가 자동으로 많이 부름 (스크립트 · 고장 난 루프) | 요청 수 | 한 사람 · 한 시간대에 몰림 | +| 아주 긴 입력 (로그 · 문서 붙여 넣기) | 몇몇 요청의 입력 토큰 | 평균은 조금, 최대값이 크게 | + +**비용은 품질 · 속도와 함께 봅니다.** 비용만 보면 가장 싼 답은 "답하지 않기"입니다. 그래서 비용을 줄일 때는 **품질 테스트**(오늘 Lab의 `contains`)와 **시간**(72일차 p95)을 같이 봅니다. 셋 중 하나만 좋아지고 다른 것이 나빠지면, 그것은 개선이 아니라 맞바꿈입니다. 맞바꿈이 괜찮은지는 사람이 정합니다. + +### 오늘 실습에서 + +오늘의 헬프데스크는 72~74일차와 같은 IT 문의 창구입니다 (FAQ 검색 + 접수 건 조회 + 답). 다만 오늘 시작하는 `base-cost`는 **일부러 비싸게** 만든 것입니다. 질문마다 LLM을 세 번(분류 · 답 · 검토) 부르고, 답 단계에 사내 규정 문서 여덟 개를 통째로 붙여 보냅니다. + +| 시간 | 할 일 | 실습 | +| --- | --- | --- | +| 1~2교시 | 자원 · 토큰 · 비용 재기, 결과에 비용, 종류별 비용 · 한 달, 기록, 기록이 멈추면 | Lab 01 | +| 3~4교시 | 보내는 것 줄이기, 단계마다 모델, 답까지 작은 모델이면?, LLM 한 번, 긴 질문의 상한 | Lab 02 | +| 5~6교시 | 하루 예산, 사용자별 한도, 예산을 바꿔 보기, 비용 보고서, 바꾸면 얼마 | Lab 03 | + +하루 동안 VPN 질문 하나의 비용은 이렇게 바뀝니다 (수업 준비 때 실측). + +| 때 | VPN 질문 하나 | LLM 호출 | +| --- | --- | --- | +| 시작 (`base-cost`) | **$0.001040** | 3 | +| Lab 02 예제 1 뒤 (보내는 것 줄이기) | $0.000346 | 3 | +| Lab 02 예제 2 뒤 (분류 · 검토 nano) | $0.000278 | 3 | +| Lab 02 과제 1 뒤 (LLM 한 번) | **$0.000156** | 1 | + +## 2. 자원: 내 컴퓨터가 쓰는 것과 공급자가 쓰는 것 (2교시 Lab 01 예제 1) + +### 일반 설명 + +**컴퓨팅 자원**은 프로그램이 돌아가는 데 쓰는 것입니다. + +| 자원 | 무엇 | 모자라면 | +| --- | --- | --- | +| CPU | 계산 | 느려짐, 응답 시간이 늘어남 | +| 메모리 | 지금 쓰는 데이터를 올려 두는 곳 | 프로그램이 강제로 꺼짐 (OOM) | +| 디스크 | 저장 (실행 기록 · 로그 · 데이터베이스) | 저장 실패, 서비스 멈춤 | +| 네트워크 | 다른 서버와 주고받기 | 느림, 시간 초과 | + +**LLM을 어디서 돌리느냐에 따라 자원과 비용이 갈립니다.** + +| | 직접 돌리기 (self-host, 66일차 전의 Ollama) | API로 부르기 (오늘) | +| --- | --- | --- | +| LLM 계산 | 내 컴퓨터의 CPU · GPU · 메모리 | 공급자의 서버 | +| 내 컴퓨터가 쓰는 것 | 많음 (모델 크기만큼 메모리) | 거의 없음 (요청을 보내고 기다림) | +| 돈 | 장비 · 전기 (쓰지 않아도 듦) | 토큰 (쓴 만큼) | +| 늘어날 때 | 장비를 더 사야 | 그냥 더 부름 (비용도 그만큼) | + +API로 부르면 LLM 계산은 내 쪽 자원을 거의 쓰지 않습니다. 그래도 내 쪽에서 자원을 쓰는 것이 있습니다. + +| 내 쪽에서 자원을 쓰는 것 | 늘어나는 까닭 | 다루는 법 | +| --- | --- | --- | +| 워크플로우 엔진 (n8n) 메모리 | 동시에 도는 실행, 큰 데이터(긴 글 · 파일) | 동시 실행 수 한도, 큰 데이터는 나눠서 | +| 실행 기록 (execution data) | 실행마다 입력 · 출력을 저장 | 보관 기간 · 개수 정하기 (오래된 것 지우기) | +| 로그 · 기록 | 요청마다 한 줄씩 | 보관 기간, 필요한 칸만 | +| 대기 | 느린 API를 기다리는 동안 연결 · 메모리를 잡고 있음 | timeout (72일차) | + +**컨테이너는 자원에 한도를 둘 수 있습니다.** Docker는 컨테이너마다 쓸 수 있는 메모리 · CPU의 상한을 정할 수 있습니다. 상한이 없으면 한 컨테이너가 컴퓨터 전체를 써서 다른 서비스까지 멈추게 할 수 있습니다. 운영 서버에서는 상한을 두고, `docker stats` 같은 도구로 지금 얼마나 쓰는지 봅니다. + +**동시 실행도 자원입니다.** 워크플로우 엔진은 요청 하나를 처리하는 동안 실행 하나를 잡고 있습니다. LLM 답을 기다리는 몇 초 동안에도 그 실행은 메모리를 씁니다. 동시에 들어오는 요청이 많으면 기다리는 실행이 쌓여 메모리가 늘고, 엔진이 받을 수 있는 수를 넘으면 뒤의 요청은 줄을 서거나 실패합니다. 그래서 운영에서는 **동시 실행 수의 상한**을 정하고, 넘치는 요청은 큐에 넣거나 "잠시 후"로 돌려보냅니다 (3단계의 429와 같은 생각). + +**자원 비용과 API 비용은 성질이 다릅니다.** 서버 자원은 대개 **쓰지 않아도** 시간만큼 냅니다 (켜 둔 서버). API 비용은 **부르지 않으면 0**입니다. 그래서 자원은 "얼마나 큰 서버가 필요한가"(용량 계획)로, API는 "요청 하나에 얼마 · 하루에 몇 번"(사용량 관리)으로 관리합니다. 오늘은 주로 뒤쪽입니다. + +### 오늘 실습에서 + +Lab 01 예제 1의 `docker stats --no-stream` (수업 준비 때): + +| 컨테이너 | CPU | 메모리 | +| --- | --- | --- | +| n8n | 0.27% | 642.6MiB | +| runners (Code 노드를 돌리는 곳) | 0.21% | 100.5MiB | +| data-api | 0.02% | 20.3MiB | + +질문을 보내는 동안에도 CPU는 1%를 넘지 않았습니다. LLM은 OpenAI 서버에서 계산하기 때문입니다. 오늘 돈이 드는 곳은 내 노트북이 아니라 **토큰**입니다. n8n의 메모리는 실행 기록이 쌓이면 늘어나므로, 76 · 77일차에 실행 기록을 다룰 때 다시 봅니다. + +## 3. 토큰과 단가 (2교시 Lab 01 예제 1) + +### 일반 설명 + +**토큰은 LLM이 글을 세는 단위입니다.** 모델은 글을 글자나 낱말이 아니라 **토큰**이라는 조각으로 나눠 읽고 씁니다. 자주 나오는 글자 묶음은 한 토큰이 되고, 드문 것은 여러 토큰으로 나뉩니다. 그래서 같은 뜻이라도 언어 · 글의 종류에 따라 토큰 수가 다릅니다. + +| 글 | 토큰이 되는 모양 (대략) | +| --- | --- | +| 영어 문장 | 낱말 하나가 토큰 1~2개 | +| 한국어 문장 | 글자 1~2개가 토큰 하나쯤 (모델의 토큰 나누는 법에 따라 다름) | +| 숫자 · 기호 · 로그 | 잘게 나뉘어 생각보다 많음 | +| JSON | 따옴표 · 괄호 · 칸 이름도 모두 토큰 | + +토큰을 나누는 규칙(토크나이저)은 모델 계열마다 다릅니다. 같은 글을 다른 모델로 보내면 토큰 수가 조금 다를 수 있습니다. 정확한 수는 **공급자가 응답에 알려 주는 값**이 기준이고, 부르기 전에 미리 셀 때는 그 모델 계열의 토크나이저(예: OpenAI는 `tiktoken`)로 어림합니다 (7교시 `tcost`). + +**입력과 출력은 따로 셉니다.** + +| | 무엇 | 예 | +| --- | --- | --- | +| 입력 토큰 (`prompt_tokens`) | 보낸 것 전부: 지시문(system) + 질문 + 붙인 자료 + 앞 대화 | FAQ · 접수 건 · 규정 | +| 출력 토큰 (`completion_tokens`) | 모델이 만든 답 | 답 문장 | + +**단가는 1M(백만) 토큰당 달러입니다.** 그리고 대개 **출력이 입력보다 비쌉니다.** 출력은 모델이 토큰을 하나씩 만들어 내야 하므로 계산이 더 들기 때문입니다. + +```text +비용 = (입력 토큰 × 입력 단가 + 출력 토큰 × 출력 단가) ÷ 1,000,000 +``` + +오늘 쓰는 두 모델의 단가 (OpenAI 모델 문서, 2026-10-28 확인, 1M 토큰당 달러): + +| 모델 | 입력 | 캐시된 입력 | 출력 | 출력 ÷ 입력 | +| --- | --- | --- | --- | --- | +| `gpt-4.1-mini` | 0.40 | 0.10 | 1.60 | 4배 | +| `gpt-5.4-nano` | 0.20 | 0.02 | 1.25 | 약 6배 | + +- **캐시된 입력**: 공급자가 같은 앞부분(긴 지시문)을 최근에 본 적이 있으면 그 부분을 싸게 받는 것입니다. 지시문을 앞에, 바뀌는 질문을 뒤에 두면 캐시가 잘 맞습니다. 오늘 계산은 캐시를 빼고 합니다 (보수적으로). +- **배치(Batch)**: 바로 답이 필요 없는 일(밤새 문서 요약 등)은 모아서 보내면 더 싸게 받는 방식도 있습니다. 실시간 헬프데스크에는 맞지 않습니다. +- **가격은 바뀝니다.** 공급자는 단가를 내리기도 하고, 새 모델을 내고 옛 모델을 지원 종료하기도 합니다 (74일차 `gpt-4.1-nano`). 그래서 단가표에는 **언제 확인했는지**를 같이 적습니다. + +**계산을 손으로 한 번 해 봅니다.** 입력 2,000 · 출력 150토큰, `gpt-4.1-mini`라면: + +```text +(2,000 × 0.40 + 150 × 1.60) ÷ 1,000,000 = (800 + 240) ÷ 1,000,000 = $0.00104 +``` + +아주 작은 숫자입니다. 하지만 하루 1,000번 · 한 달 22일이면 $0.00104 × 22,000 ≈ $22.9입니다. 직원 수 · 질문 수가 늘거나, 같은 일을 하는 워크플로우가 여러 개면 곱해집니다. **작은 숫자에 큰 숫자를 곱하는 것**이 API 비용의 성질입니다. + +**부르기 전에 세 보는 방법.** 지시문 · 자료를 바꾸기 전에 비용을 알고 싶으면, 그 글을 토크나이저로 세어 단가를 곱합니다. 공급자의 토큰 세기 도구(OpenAI `tiktoken`, 다른 공급자의 token count API)를 쓰면 API를 실제로 부르지 않고도 어림할 수 있습니다. 다만 실제 요청에는 실행 때 붙는 질문 · 검색 결과 · 대화 기록이 더해지므로, 미리 센 값은 **하한**에 가깝습니다. 7교시 `tcost`가 이것을 만듭니다. + +**토큰을 쓰는 것은 돈만이 아닙니다.** 모델마다 한 번에 받을 수 있는 토큰 수(컨텍스트 창)의 상한이 있고, 입력이 길수록 응답이 늦어집니다. 긴 입력은 비용 · 속도 · 한도를 모두 씁니다. + +### 오늘 실습에서 + +Lab 01 예제 1의 `call base-cost requests/faq-vpn.json` (수업 준비 때): + +| 단계 | 입력 | 출력 | +| --- | --- | --- | +| classify | 78 | 6 | +| answer | **1,781** | 78 | +| review | 140 | 66 | +| 합 | 1,999 | 150 | + +입력의 89%가 `answer` 단계 하나에서 나옵니다. 접수 질문(T-1002)도 입력 1,973 · `answer` 1,791로 거의 같습니다. 질문이 VPN이든 접수 상태든 **매번 같이 보내는 것**이 있다는 뜻입니다. 위 손 계산이 바로 이 VPN 질문입니다 ($0.00104). + +## 4. 토큰이 보이는 호출 (2교시 Lab 01 예제 2) + +### 일반 설명 + +LLM API는 답과 함께 **얼마나 썼는지**를 돌려줍니다. OpenAI Chat Completions 응답의 `usage`가 그것입니다. + +```json +{ + "choices": [{"message": {"content": "VPN 클라이언트를 최신 버전으로 …"}}], + "usage": {"prompt_tokens": 1781, "completion_tokens": 78, "total_tokens": 1859} +} +``` + +Anthropic · Google 등 다른 공급자도 이름은 조금 달라도(`input_tokens` · `output_tokens` 등) 같은 정보를 줍니다. **이 숫자가 청구의 기준**이므로, 비용을 재려면 이것을 받아 두어야 합니다. + +**편한 도구가 숫자를 숨기기도 합니다.** 프레임워크 · 노코드 도구의 "AI Agent" 같은 상자는 답 글자만 다음 단계로 넘기고 `usage`를 버리는 경우가 있습니다. 편하지만, 그러면 비용을 잴 수 없습니다. 방법은 두 가지입니다. + +| 방법 | 장점 | 단점 | +| --- | --- | --- | +| 공급자 API를 직접 부름 (HTTP) | `usage`를 그대로 받음, 무엇을 보내는지 다 보임 | 대화 · 도구 호출을 직접 다뤄야 함 | +| 관측(observability) 도구를 붙임 | 기존 흐름을 크게 안 바꿈, 화면이 좋음 | 도구를 하나 더 운영 | + +**LLM 관측 도구.** LLM 호출마다 입력 · 출력 · 토큰 · 비용 · 시간을 모아 보여 주는 도구들이 있습니다 (예: Langfuse, Helicone, LangSmith, 클라우드의 모니터링 서비스). OpenTelemetry에도 생성형 AI 호출을 기록하는 공통 규칙(GenAI semantic conventions)이 정해지고 있습니다. 이름은 달라도 모으는 것은 같습니다: **어느 요청의 · 어느 단계가 · 어느 모델로 · 몇 토큰 · 얼마 · 몇 초.** 오늘은 이것을 n8n과 데이터 API로 직접 만들어 봅니다 (과제 1). + +**토큰 수와 함께 남길 것.** + +| 칸 | 왜 | +| --- | --- | +| 모델 이름 (실제로 불린 것) | 단가가 모델마다 다름. 74일차처럼 모델이 바뀐 날을 찾음 | +| 단계 (분류 · 답 · 검토) | 어디서 많이 쓰는지 (2단계) | +| 요청 번호 | 한 요청의 여러 호출을 묶음 (7절) | +| 누가 · 어떤 경로 | 사람별 · 경로별 합 (3단계) | + +**내 기록과 공급자 청구는 맞춰 봅니다.** 내가 남긴 토큰 · 비용의 합과 공급자 사용량 화면(대시보드)의 숫자가 조금 다를 수 있습니다. 기록이 빠진 요청(8절), 테스트 · 실험으로 부른 것, 단가표가 오래된 것, 캐시 · 배치 할인이 그 까닭입니다. 운영하는 쪽은 정기적으로(예: 매주) 두 숫자를 나란히 놓고, 차이가 크면 어디서 새는지 찾습니다. 내 기록은 **나눠 보기**(누가 · 어디서)에, 공급자 청구는 **실제로 낼 돈**에 씁니다. + +### 오늘 실습에서 + +오늘 워크플로우는 OpenAI를 **HTTP Request 노드로 직접** 부릅니다 (AGENTS.md 5절). 그래서 노드의 출력에 `usage`가 그대로 있고, 마지막 `Build result`(Code)가 단계마다 모아 `usage.by_step`으로 결과에 넣습니다. 66~74일차의 AI Agent 노드는 답만 주고 토큰 수를 주지 않아서 오늘은 쓰지 않습니다. + +## 5. 단계별 비용, 단가는 밖에 (2교시 Lab 01 예제 2) + +### 일반 설명 + +**비용을 어디에 붙이나 (cost attribution).** 청구서는 "OpenAI $123"처럼 한 줄로 옵니다. 줄일 곳을 찾으려면 그 돈이 **어느 기능 · 어느 단계 · 누구**에게서 나왔는지 나눠야 합니다. 그래서 요청마다, 단계마다 비용을 계산해 붙입니다. + +```mermaid +flowchart LR + B["청구서 $123"] --> F["기능별
헬프데스크 · 요약 · 번역"] --> S["단계별
분류 · 답 · 검토"] --> U["사람 · 팀별"] +``` + +**단가는 코드 밖에 둡니다.** 단가를 코드에 숫자로 박으면 두 가지 사고가 납니다. 가격이 바뀌었는데 코드를 안 고쳐 비용 숫자가 **조용히 틀리고**, 모델을 바꿨는데 단가를 그대로 둬 **다른 모델의 값**으로 계산합니다. 그래서 단가표를 한 곳(설정 · 데이터)에 두고, 계산하는 쪽은 **모델 이름으로 찾아** 씁니다. 70일차 설정 표, 74일차 릴리스 · 지시문 저장소와 같은 생각입니다: **바뀌는 값은 한 곳에 두고, 모두 그것을 읽는다.** + +| 단가를 | 가격이 바뀌면 | 모델을 바꾸면 | +| --- | --- | --- | +| 코드에 숫자로 | 코드를 모두 찾아 고침 (빠뜨리기 쉬움) | 단가도 같이 고쳐야 함 | +| 단가표에 (모델 이름 → 단가) | 단가표 한 곳만 | 계산은 저절로 그 모델 값 | + +**모르는 모델은 0원이 아닙니다.** 단가표에 없는 모델 이름이 오면 "없으니 0"으로 계산하기 쉽습니다. 그러면 그 모델은 공짜로 보이고, 비용 보고서는 거짓말을 합니다. 없는 이름은 오류로 알려야 합니다 (Lab 03 과제 2, 7교시 `tcost`). + +**반올림을 정해 둡니다.** 요청 하나의 비용은 $0.000156처럼 아주 작아서, 계산할 때 소수점 아래가 길게 나옵니다 (0.00015560000000001). 자리를 정하지 않으면 합계 · 테스트가 흔들립니다. 오늘 계약은 **소수 8자리**입니다. + +### 오늘 실습에서 + +Lab 01 예제 2는 `Get prices`(HTTP `GET /prices`)를 앞에 두고, `Build result`가 단계마다 그 단계 모델의 단가로 `cost_usd`를 계산해 `usage.by_step.<단계>.cost_usd`와 합계 `cost_usd`를 결과에 넣습니다. 수업 준비 때 VPN 질문의 결과가 **$0.001040** — 3절의 손 계산과 같았습니다. 테스트는 `range`로 비용이 0보다 크고 $0.01 이하인지 봅니다. 줄 끝에 `· 자동 답변 · 3 calls · $0.001040`처럼 호출 수와 비용이 나옵니다. + +## 6. 질문 종류별 비용과 한 달 어림 (2교시 Lab 01 예제 3) + +### 일반 설명 + +**한 달 비용 = 요청 하나의 비용 × 하루 요청 수 × 일하는 날.** 식은 간단하지만, "요청 하나의 비용"을 무엇으로 잡느냐가 어렵습니다. 요청마다 비용이 다르기 때문입니다. + +**고정비와 변동비로 나눠 봅니다.** + +| | 무엇 | 예 | +| --- | --- | --- | +| 고정비 | 질문과 관계없이 매번 드는 몫 | 늘 보내는 긴 지시문, 늘 부르는 단계 | +| 변동비 | 질문마다 달라지는 몫 | 질문 길이, 검색된 FAQ 개수, 답의 길이 | + +고정비가 크면 어떤 질문이든 비용이 비슷하고, 줄일 때는 **고정비부터** 줄이는 것이 효과가 큽니다. 고정비가 작아지면 그다음에 변동비(긴 질문, 긴 답)가 눈에 띕니다 (Lab 02). + +**종류마다 재고, 비중으로 섞습니다 (가중 평균).** + +```text +한 달 ≈ Σ(종류별 평균 비용 × 그 종류의 비중) × 하루 요청 수 × 일하는 날 +``` + +비중은 지난 기록에서 셉니다 (과제 1의 기록, 77일차 로그 분석). 기록이 없으면 가정한 값을 쓰고, **가정이라고 적어 둡니다.** + +**평균만 보면 안 되는 때.** 대부분의 질문은 싸지만 가끔 아주 비싼 질문(긴 로그를 붙인 질문)이 온다면, 평균은 그 몇 개에 끌려 올라갑니다. 72일차에 시간을 p50 · p95로 본 것처럼, 비용도 **분포**를 봅니다. "질문 하나의 최대 비용"이 정해져 있지 않으면 한 달 예상도 믿기 어렵습니다 (Lab 02 과제 2의 상한). + +**어림은 범위로 말합니다.** LLM 답의 길이는 실행마다 조금씩 달라서, 같은 질문도 비용이 약간 다릅니다. 그래서 한 달 예상은 "$20.8"보다 "약 $21, 사용량이 두 배면 $42"처럼 **가정과 함께** 말합니다. 실제 숫자는 기록을 쌓은 뒤 보고서로 다시 봅니다 (3단계). + +### 오늘 실습에서 + +Lab 01 예제 3의 `test cases/lab01-mix.json` (수업 준비 때, 예제 2 정답): + +| 종류 | 질문 | 비용 | 종류 평균 | +| --- | --- | --- | --- | +| FAQ | VPN · 프린터 · 비밀번호 · 메일 | $0.001040 · 0.000960 · 0.000953 · 0.000965 | $0.000980 | +| 접수 | T-1002 · T-1001 | $0.000946 · 0.000890 | $0.000918 | +| 그 밖 | 회의실 화면 공유 | $0.000830 | $0.000830 | + +일곱 질문 모두 3 calls, 비용이 **$0.00083 ~ $0.00104로 비슷합니다.** 규정 전체를 매번 보내는 고정비가 비용의 대부분이기 때문입니다. 비중 60 · 30 · 10%(연습용 가정)로 섞으면 $0.000946 × 1,000건 × 22일 ≈ **한 달 $20.8**, 일곱 개 단순 평균이면 ≈ $20.7. 고정비가 커서 두 어림이 거의 같습니다. Lab 02로 고정비를 없애면 질문마다 $0.000097 ~ $0.000166으로 벌어집니다 (변동비가 보임). + +## 7. 기록: 요청 · 호출 · 사람 (2교시 Lab 01 과제 1) + +### 일반 설명 + +답에 비용을 남기면 **질문한 사람만** 그 숫자를 봅니다. 오늘 전체로 얼마를 썼는지, 누가 많이 쓰는지, 예산을 넘었는지는 **어딘가에 쌓인 기록**이 있어야 압니다. 쓴 양을 재서 남기는 것을 **계량(metering)**이라고 부릅니다. 전기 계량기처럼, 재지 않으면 나눌 수도 막을 수도 없습니다. + +**요청과 호출은 다릅니다.** + +| | 뜻 | 질문 3개 · 각 3단계 | +| --- | --- | --- | +| 요청 (request) | 사용자가 한 번 물은 것 | 3 | +| 호출 (call) | LLM을 한 번 부른 것 | 9 | + +기록은 **호출마다 한 줄**이 정확합니다 (단계 · 모델마다 토큰 · 단가가 다르므로). 그 줄들이 같은 요청이라는 것을 알리려고 **같은 요청 번호**를 붙입니다. 분산 시스템에서 한 요청이 여러 서비스를 지나갈 때 같은 추적 번호(trace id)를 붙이는 것과 같습니다 (77일차). + +**태그를 붙입니다.** 기록 한 줄에 사람 · 팀 · 기능 · 경로를 같이 남기면 나중에 어떤 칸으로든 나눠 합칠 수 있습니다. + +| 나눠 보기 | 쓰는 곳 | +| --- | --- | +| 사람 · 팀별 | 사용자별 한도 (3단계), 부서에 비용을 알려 주기 (showback) · 나눠 청구하기 (chargeback) | +| 모델별 | 모델을 바꾸면 얼마 (3단계 과제 2) | +| 경로별 (`자동 답변` · `한도 초과` · `잠시 후`) | 예산 · 한도가 얼마나 자주 걸리나 | +| 단계별 | 줄일 곳 (2단계) | + +**기록에 남기지 않을 것.** 비용 기록은 여러 사람이 오래 봅니다. 그래서 **질문 · 답의 원문**이나 연락처 같은 개인정보는 넣지 않고, 숫자와 분류 칸(누가 · 어느 단계 · 어느 모델 · 몇 토큰 · 얼마 · 어느 경로)만 남깁니다. 원문이 필요한 분석은 따로 권한을 둔 곳에서 합니다 (73일차 감사 로그). "누가"도 이름 대신 사번 · 익명 번호로 남기는 회사가 많습니다. + +| 칸 | 비용 기록에 | 까닭 | +| --- | --- | --- | +| 요청 번호 · 단계 · 모델 · 토큰 · 비용 · 경로 · 시각 | 남김 | 합치고 나누는 데 필요 | +| 사람 | 남김 (가능하면 번호로) | 사람별 한도 · 합계 | +| 질문 · 답 원문 | 남기지 않음 | 개인정보 · 사내 정보가 섞임 | +| API 키 · 비밀번호 | 절대 남기지 않음 | 기록은 많은 사람이 읽음 (73일차) | + +**기록은 예산 · 한도의 바탕입니다.** "오늘 $1 넘으면 LLM을 쓰지 않는다"(예산), "한 사람이 1분에 20번 넘으면 잠시 막는다"(한도)는 모두 기록을 세어야 할 수 있습니다. 기록이 빠지면 예산 · 한도도 그만큼 덜 셉니다 (8절). + +### 오늘 실습에서 + +Lab 01 과제 1은 `Build result` 뒤에 `Record usage`(HTTP `POST /usage`)를 넣어 `usage.by_step`의 단계마다 한 줄 `{request_id, user, step, model, prompt_tokens, completion_tokens, cost_usd, route}`를 남깁니다. `request_id`는 n8n의 실행 번호(`$execution.id`)입니다. 수업 준비 때 질문 3개 뒤 `/usage/summary`가 **requests 3 · calls 9 · $0.002951**, 사람별 이준호 $0.001040 · 박서연 $0.000959 · 한지훈 $0.000953이었습니다. 기록 노드를 지나면 다음 노드의 입력이 기록 API의 응답(`{"stored": 3}`)이 되므로, 응답하기 전에 `Build result`의 결과를 다시 꺼냅니다. + +## 8. 기록이 멈추면 (2교시 Lab 01 과제 2) + +### 일반 설명 + +**부가 기능이 본 기능을 막으면 안 됩니다.** 사용자에게 중요한 것은 답이고, 기록은 운영하는 쪽의 일입니다. 그런데 기록이 끝나야 답을 보내게 만들면, 기록 서버가 느릴 때 사용자가 기다리고, 멈추면 답도 못 받습니다. 부가 기능(기록 · 알림 · 통계)은 **실패해도 본 기능이 계속되게** 만듭니다. + +| 방법 | 어떻게 | 오늘 | +| --- | --- | --- | +| 시간 제한 | 기록을 N초까지만 기다림 | `timeout` 3초 | +| 실패해도 계속 | 기록이 실패하면 그 사실만 남기고 답을 보냄 | `onError: continueRegularOutput` | +| 나중에 보내기 | 기록을 큐에 넣고 따로 보냄 (비동기) | (오늘은 하지 않음) | +| 표시 남기기 | 기록이 빠졌다는 표시 | `recorded: false` | + +**계속 가는 쪽의 값.** 74일차 지시문 저장소처럼 여기도 **계속 가는 쪽(fail-open)**입니다. 그런데 값이 있습니다. 기록이 빠진 요청은 예산 · 한도에 세어지지 않아, 실제보다 덜 쓴 것처럼 보입니다. 그래서 빠졌다는 표시(`recorded: false`)를 남겨 나중에 몇 건이 빠졌는지 셀 수 있게 합니다. + +| | 73일차 판별 LLM | 74일차 지시문 저장소 | 오늘 사용량 기록 | +| --- | --- | --- | --- | +| 멈추면 | 막는다 (fail-closed) | 기본 지시문으로 답 | 답은 그대로, 기록만 빠짐 | +| 남기는 표시 | `guard_down` | `prompt_source: fallback` | `recorded: false` | +| 치르는 값 | 사용자가 기다림 | 지시문이 최신이 아닐 수 있음 | 예산 · 한도가 덜 셈 | + +**시간 초과로 포기해도 상대의 일은 끝나지 않을 수 있습니다.** 내 쪽에서 3초에 포기해도, 상대 서버는 그 요청을 계속 처리해 **결국 저장할 수** 있습니다. 그러면 나는 "기록 안 됨"이라고 남겼는데 실제로는 기록된 상태가 됩니다. 분산 시스템에서는 이런 일이 흔해서, 같은 기록이 두 번 들어가도 한 번으로 세게 하는 장치(같은 요청 번호면 한 번만 세기, 멱등성 idempotency)를 둡니다. 오늘 요약이 `request_id`로 요청 수를 세는 것도 그 하나입니다. + +### 오늘 실습에서 + +Lab 01 과제 2 (수업 준비 때): + +| `cases/lab01-challenge2.json` | 고치기 전 (과제 1 정답) | 고친 뒤 | +| --- | --- | --- | +| 기록 API 고장 (`usage error`, 500) | 답은 나감 · `recorded` 없음 | `recorded: false` | +| 기록 API 느림 (`usage slow`, 8초) | **11.2초** 걸려 답 · `recorded` 없음 | **6.1초** · `recorded: false` | +| 고친 뒤 · 기록됨 | `recorded` 없음 | `recorded: true` | +| 요약 · 기록된 요청 | 1건 | 1건 | + +**수업 준비 중에 실제로 일어난 일.** 처음 데이터 API는 느림 모드에서 8초를 기다린 뒤 **결국 저장**했습니다. 워크플로우는 3초에 포기하고 `recorded: false`로 답했는데, 늦게 저장된 한 줄이 다음 테스트를 시작한 뒤에 들어와 그 테스트의 요청 수가 하나 많았습니다. 위 "포기해도 상대의 일은 끝나지 않는다"가 실제로 일어난 것입니다. 실습용 데이터 API는 "늦게 답하고 저장하지 않음"(멈춘 기록 서버)으로 바꿨습니다. + +## 9. 요청문(프롬프트)으로 + +**요청문(프롬프트)이란.** 실습에서 CLI 코딩 에이전트(codex · claude · agy)에게 보내는 글입니다. 에이전트는 이 글만 보고 n8n 워크플로우 JSON을 만들거나 고칩니다. 이 절은 이 단계에서 배운 것을 **요청문(프롬프트)에 어떤 칸으로 적을지** 실습 전에 정리하는 자리입니다. 실습 문서의 `▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대`가 이 칸을 실제 문장으로 만든 것이고, 빈칸 ①~④는 그 문장 속 정확한 값입니다. + +| 칸 | 에이전트에게 알려 주는 것 | 빠지면 생기는 일 | +| --- | --- | --- | +| 무엇을 | 만들거나 바꿀 기능 | 에이전트가 짐작해서 다른 것을 만듦 | +| 어디에 | 고칠 파일 · 노드 · 자리 | 엉뚱한 노드를 고치거나 새 파일을 만듦 | +| 어떤 모양으로 | 결과 JSON의 칸 이름 · 값 · 응답 코드 | 테스트가 보는 칸 이름과 달라 실패 | +| 그대로 · 하지 말 것 | 건드리면 안 되는 것 | 다른 곳까지 바뀌어 무엇 때문인지 모르게 됨 | +| 확인 | 통과해야 할 테스트 파일 | "다 됐다"를 판단할 기준이 없음 | + +67~71일차의 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)와 같은 뜻입니다. 파일 · 입력이 "어디에", 처리가 "무엇을", 결과 모양이 "어떤 모양으로", 기대 결과가 "확인"입니다. 72일차부터는 **이미 있는 워크플로우를 고치는** 요청이 많아 "그대로"가 따로 한 칸이 됐습니다. + +오늘도 요청문의 뼈대는 같습니다: **무엇을 · 어디에 · 어떤 모양으로 · 무엇은 그대로 · 무엇으로 확인**. 비용 요청에서는 하나가 더 붙습니다: **LLM을 몇 번 · 어느 모델로 · 무엇을 보내는가, 그리고 숫자는 어디서 읽는가(단가 · 예산)**. + +아래 표의 열은 이 단계에서 에이전트를 쓰는 실습, 줄은 위 다섯 칸입니다. 각 칸에 그 실습에서 채울 것을 적었습니다. + +| 요청문에 | Lab 01 예제 2 | Lab 01 과제 1 | Lab 01 과제 2 | +| --- | --- | --- | --- | +| 무엇을 | 단계마다 비용 | 요청마다 기록 | 기록이 멈춰도 답 | +| 어디에 | `Has question?` 뒤 `Get prices`, `Build result` | `Build result` 뒤 `Record usage` · `Result again` | `Record usage`의 설정, `Result again` | +| 어떤 모양으로 | `usage.by_step.<단계>.cost_usd`, `cost_usd` (8자리) | 단계마다 한 줄, 같은 `request_id` | `recorded: true \| false` | +| 그대로 | 단가를 숫자로 쓰지 않음 (`/prices`에서) | 응답은 `Build result` 그대로 | `answer` · `cost_usd` | +| 확인 | `cases/lab01.json` | `cases/lab01-challenge.json` | `cases/lab01-challenge2.json` | + +예를 들어 Lab 01 예제 2 열을 위에서부터 이어 읽으면 요청문(프롬프트)이 됩니다 (화살표 오른쪽은 다섯 칸 중 어디인지): + +```text +단계마다 비용을 계산해 결과에 넣어 줘. ← 무엇을 +Has question? 뒤에 Get prices(GET /prices), 계산은 Build result 에서. ← 어디에 +usage.by_step.<단계>.cost_usd 와 합 cost_usd, 소수 8자리. ← 어떤 모양으로 +단가는 숫자로 쓰지 말고 /prices 에서 그 단계 모델 이름으로 찾아. ← 그대로 +cases/lab01.json 이 통과해야 해. ← 확인 +``` + +## 10. 자주 하는 실수 + +| 실수 | 왜 문제인가 | 이렇게 | +| --- | --- | --- | +| 재지 않고 "아마 여기가 비쌀 것" | 엉뚱한 곳을 줄임 | 단계별 토큰부터 (`usage.by_step`) | +| 1,000,000으로 나누지 않음 | 비용이 백만 배 | 단가 단위(1M 토큰당)를 식에 | +| 단가를 코드에 숫자로 | 가격 · 모델이 바뀌면 조용히 틀림 | 단가표에서 모델 이름으로 | +| 모르는 모델을 0원으로 | 공짜로 보이는 거짓 보고서 | 오류로 알림 | +| 출력 토큰을 빼고 계산 | 출력이 더 비싸서 크게 틀림 | 입력 · 출력 따로 | +| VPN 하나만 재서 곱함 | 종류마다 비용이 다르면 틀림 | 종류별로 재고 비중으로 | +| 기록을 호출마다 다른 번호로 | 요청 수가 호출 수로 보임 | 같은 요청은 같은 `request_id` | +| 기록이 끝나야 답을 보냄 | 기록 서버가 느리면 사용자가 기다림 | timeout + 실패해도 계속 + `recorded` | + +## 확인 질문 + +1. LLM API 비용이 일반 웹 서비스의 서버 비용과 다른 점 두 가지는? +2. 입력 2,000 · 출력 150토큰, 입력 $0.40 · 출력 $1.60(1M당)이면 한 번에 얼마인가요? 하루 1,000번 · 22일이면? +3. 질문 종류가 달라도 비용이 거의 같았습니다. 무엇을 알려 주나요? 무엇부터 줄이면 효과가 클까요? +4. 단가를 워크플로우 Code 안에 숫자로 적어 두면 어떤 사고가 날 수 있나요? +5. 질문 하나에 LLM을 세 번 부르면 기록은 몇 줄이고, "요청 수"는 어떻게 세나요? +6. 기록 API가 멈췄을 때 답은 보내고 `recorded: false`를 남깁니다. 이렇게 하면 무엇을 잃나요? 수업 준비 때 "포기했는데 늦게 저장된" 일은 왜 일어났나요? + +## 정리 + +- [ ] 쓰는 것 셋: 컴퓨팅 자원 · API 토큰 · 돈 — API로 부르면 돈은 거의 토큰에서 +- [ ] 비용 = (입력 × 입력 단가 + 출력 × 출력 단가) ÷ 1,000,000, 출력이 더 비싸다 +- [ ] 응답의 `usage`를 받고, 단계마다 그 모델의 단가(단가표에서)로 계산한다 +- [ ] 고정비 · 변동비로 나눠 보고, 종류별 · 비중으로 한 달을 어림한다 +- [ ] 호출마다 기록하되 같은 요청은 같은 번호, 기록이 멈춰도 답은 나가고 `recorded`를 남긴다 diff --git a/Lecture/75/02-STAGE2-REDUCE-COST.md b/Lecture/75/02-STAGE2-REDUCE-COST.md new file mode 100644 index 0000000..d111f8e --- /dev/null +++ b/Lecture/75/02-STAGE2-REDUCE-COST.md @@ -0,0 +1,359 @@ +# 2단계 · 비용 줄이기 + +- 교시: 3교시 강의 (11:00~11:50) · 4교시 [Lab 02](../../Last3Mon/75/LAB-02-Q-REDUCE-COST.md) 실습 5개 (12:00~12:50) +- 이 단계가 끝나면: 비용을 정하는 세 칸(호출 수 · 호출당 토큰 · 단가)을 구분하고, 한 번에 한 칸씩 줄이고, 바꿀 때마다 품질 테스트와 답 읽기로 잃은 것이 없는지 확인하고, 아주 긴 입력에도 요청 하나의 비용에 상한을 둘 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini` · `gpt-5.4-nano`, 10/28 정답 워크플로우를 학생 순서대로 1회). 출력 전부는 수업 뒤 공개되는 `Last3Mon/75/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 02 예제 1"은 4교시 [Lab 02 문서](../../Last3Mon/75/LAB-02-Q-REDUCE-COST.md)의 그 절입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/75` 기준입니다. + +```mermaid +flowchart LR + A["1. 비용의 세 칸"] --> B["2. 보내는 것 줄이기"] --> C["3. 단계마다 맞는 모델"] --> D["4. 싸졌는데 나빠졌다면"] + D --> E["5. 하지 않은 호출"] --> F["6. 긴 입력의 상한"] --> G["7. 줄인 것을 남기기"] --> H["8. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 11:00~11:07 | 1 · 개요 15장 | 호출 수 × 토큰 × 단가, 줄이는 방법 지도, 품질 테스트를 먼저 | +| 11:07~11:15 | 2 · 개요 16장 | 필요한 것만 보내기, 필드 줄이기, 출력 상한, 캐시 | +| 11:15~11:22 | 3 · 개요 17장 | 모델 라우팅, 단가표를 다시 보기 | +| 11:22~11:29 | 4 · 개요 18장 | 통과했는데 나빠진 답, 사람이 읽기, 맞바꿈 | +| 11:29~11:36 | 5 · 개요 19장 | 규칙으로 먼저, 중복 단계 빼기 | +| 11:36~11:43 | 6 · 개요 20~21장 | 입력 · 출력 상한 = 요청 하나의 최대 비용, Denial of Wallet | +| 11:43~11:50 | 7 · 8 · 9 · 확인 질문 | 줄인 것을 남기기, 요청문, 자주 하는 실수 | + +### 4교시 Lab 02에서 이어서 하는 것 + +| 4교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 02 예제 1 (12:00~12:10) | 2 | 규정 전체를 빼고 필요한 칸만, 출력 상한 | `my-work/cost.json`, 테스트 `cases/lab02.json` (앞 세 케이스), `diff` | +| Lab 02 예제 2 (12:10~12:18) | 3 | 분류 · 검토를 `gpt-5.4-nano` | 테스트 `cases/lab02.json` 5개, `cases/lab01.json` | +| Lab 02 예제 3 (12:18~12:25, 에이전트 없이) | 4 | 답까지 nano인 비교본을 재고 **읽기** | `push reference/lab02-example3-nano-answer.json`, `test cases/lab02.json --webhook nano-answer -v`, `lab01-mix` 둘 | +| Lab 02 과제 1 (12:25~12:38) | 5 | 규칙 분류 · 검토 빼기 → LLM 한 번 | 테스트 `cases/lab02-challenge.json` 7개 | +| Lab 02 과제 2 (12:38~12:50) | 6 | 긴 질문은 앞 40% + 뒤 60%만 | `requests/long-printer.json`, 테스트 `cases/lab02-challenge2.json` | + +## 1. 비용의 세 칸: 호출 수 × 호출당 토큰 × 단가 + +### 일반 설명 + +1단계의 식을 요청 하나로 묶으면 이렇습니다. + +```text +요청 하나의 비용 = Σ (그 요청의 호출마다) 입력 토큰 × 입력 단가 + 출력 토큰 × 출력 단가 + ≈ 호출 수 × 호출당 토큰 × 토큰 단가 +``` + +비용을 줄이는 방법은 모두 이 세 칸 중 하나를 줄이는 것입니다. + +| 칸 | 줄이는 방법 (업계에서 쓰는 이름) | 오늘 | +| --- | --- | --- | +| 호출당 토큰 | 보내는 자료 줄이기 · 필요한 부분만 찾아 보내기(RAG) · 지시문 다듬기 · 출력 상한 · 긴 입력 자르기 | 예제 1 · 과제 2 | +| 토큰 단가 | 단계에 맞는 작은 모델(모델 라우팅) · 캐시된 입력 할인 · 배치 할인 | 예제 2 · 3 | +| 호출 수 | 규칙으로 먼저 정하기 · 중복 단계 빼기 · 같은 질문의 답 다시 쓰기(캐시, 72일차) | 과제 1 | + +**한 번에 한 칸씩.** 세 칸을 한꺼번에 바꾸면 비용이 줄어도 무엇 덕분인지, 답이 나빠져도 무엇 때문인지 모릅니다. 72일차 성능 튜닝 · 74일차 버전 관리의 "한 번에 하나만 바꾸고 다시 잰다"가 여기서도 같습니다. + +**품질 테스트를 먼저 고정합니다.** 비용을 줄이는 일은 늘 품질을 깎을 위험이 있습니다. 그래서 줄이기 전에 **"이것은 지켜야 한다"를 테스트로 적어 두고**, 바꿀 때마다 그 테스트를 다시 돌립니다. 테스트를 통과시키려고 기대 결과를 바꾸면 안 됩니다 (AGENTS.md 9절: 답의 내용 검사를 없애지 않는다). + +```mermaid +flowchart LR + Q["품질 테스트 고정
(contains)"] --> M["재기"] --> C["한 칸만 바꾸기"] --> T["테스트 · 비용 다시"] --> R["답 읽기"] + R -->|잃은 것 없음| K["남기기"] + R -->|잃은 것 있음| B["되돌리거나 맞바꿈을 결정"] +``` + +**숨은 비용도 셉니다.** 청구서에는 사용자가 받은 답만이 아니라 **부른 것 전부**가 들어갑니다. + +| 숨은 비용 | 어디서 | 줄이는 법 | +| --- | --- | --- | +| 재시도 | 실패 · 시간 초과 뒤 다시 부름 (72일차) | 재시도 횟수 상한, 실패 원인부터 | +| 실패한 호출 | 형식이 틀려 버린 답, 중간에 끊긴 답 | 출력 형식을 단순하게, 짧게 | +| 버려지는 호출 | 결과를 쓰지 않는 단계 (오늘 `base-cost`의 분류는 결과를 쓰지 않음) | 쓰지 않는 단계 빼기 | +| 테스트 · 실험 | 개발 · 수업 준비 때 돌린 것 | 같은 테스트를 수십 번 돌리지 않기, 작은 표본 | + +**어디부터 줄이나.** 1단계에서 잰 단계별 토큰을 봅니다. **가장 큰 몫**부터입니다. 전체의 89%가 한 단계의 입력이라면, 다른 곳을 아무리 줄여도 11% 안에서 움직입니다. 이것은 72일차에 가장 오래 걸리는 단계부터 줄인 것과 같습니다 (병목부터). + +### 오늘 실습에서 + +하루 동안 VPN 질문 하나의 비용 (수업 준비 때, 같은 질문): + +| 단계 | 바꾼 칸 | `answer` 입력 | 호출 | 비용 | 품질 테스트 | +| --- | --- | --- | --- | --- | --- | +| 기준 (Lab 01 과제 2 정답) | | 1,781 | 3 | $0.001007 | ✔ | +| 예제 1 · 보내는 것 줄이기 | 호출당 토큰 | **165** | 3 | $0.000346 | ✔ | +| 예제 2 · 분류 · 검토 nano | 단가 | 165 | 3 | $0.000278 | ✔ | +| 과제 1 · LLM 한 번 | 호출 수 | 약 165 | **1** | **$0.000156** | ✔ | + +## 2. 보내는 것 줄이기 (4교시 Lab 02 예제 1) + +### 일반 설명 + +**"혹시 필요할지 몰라서" 붙인 글이 비용의 대부분이 되기 쉽습니다.** 지시문에 사내 규정 · 매뉴얼 · 예시를 통째로 붙이면 답이 좋아질 것 같지만, 모델은 그 글을 **요청마다** 다시 읽고 공급자는 **요청마다** 돈을 받습니다. 그리고 관련 없는 긴 글은 모델을 헷갈리게 해 답이 오히려 나빠지기도 합니다. + +**무엇을 보낼지 고르는 것도 설계입니다.** 요즘은 이것을 컨텍스트 엔지니어링(context engineering)이라고 부릅니다. 지시문 · 자료 · 대화 기록 중 **이번 답에 필요한 것만** 골라 보내는 일입니다. + +| 보내는 것 | 줄이는 방법 | +| --- | --- | +| 참고 자료 (규정 · 매뉴얼) | 통째로 대신 질문과 관련된 부분만 검색해서 (RAG, 71일차) | +| 데이터 (FAQ · 접수 건) | 답에 쓰는 칸만 (`id` · `title` · `answer` / `status` · `note`) | +| 지시문 | 같은 말을 두 번 하지 않기, 예시는 꼭 필요한 만큼 | +| 대화 기록 | 최근 몇 개만, 또는 요약해서 | +| 출력 | 상한(`max_completion_tokens`), "3문장 이내" 같은 지시 | + +**RAG에도 비용이 있습니다.** 필요한 부분만 찾아 보내면 답 단계의 입력은 줄지만, 찾는 데에도 돈이 듭니다. 문서를 벡터로 바꾸는 임베딩(71일차 `text-embedding-3-large`)은 색인을 만들 때 문서 전체에 한 번, 질문마다 질문에 한 번 듭니다. 대개 답 LLM에 긴 자료를 매번 보내는 것보다 훨씬 싸지만, 문서가 자주 바뀌어 색인을 자주 다시 만들면 그 비용도 셉니다. **줄인 곳 옆에 새로 생긴 비용**까지 합쳐야 진짜 절약입니다. + +**예시(few-shot)도 비용입니다.** 지시문에 "이런 질문엔 이렇게 답하라"는 예시를 여러 개 붙이면 답의 모양이 고르게 되지만, 예시의 토큰도 **요청마다** 냅니다. 예시가 정말 효과가 있는지 예시 없이 같은 테스트를 돌려 비교하고, 효과가 있는 만큼만 남깁니다. 긴 지시문을 짧게 줄여 주는 압축 기법들(prompt compression)도 있지만, 압축하면 뜻이 바뀔 수 있어 역시 테스트로 확인합니다. + +**칸을 빼면 안전해지기도 합니다.** 접수 건에는 연락처 · 담당자 같은 칸이 있습니다. 답에 쓰지 않는 칸을 LLM에 보내지 않으면 토큰도 줄고, 73일차처럼 개인정보가 답으로 새어 나갈 길도 하나 줄어듭니다. 개인정보 보호에서 말하는 **데이터 최소화**(필요한 것만 다룬다)와 같은 원칙입니다. + +**출력 상한.** 출력은 입력보다 비쌉니다 (`gpt-4.1-mini`는 4배). `max_completion_tokens`는 답의 최대 길이입니다. 너무 작으면 답이 잘리고, 너무 크면 상한의 뜻이 없습니다. 지시문의 "3문장 이내"와 맞는 값을 둡니다. 그리고 공급자 · 모델마다 이름이 다를 수 있습니다 (`gpt-5.4-nano`는 예전 이름 `max_tokens`를 받지 않음). + +**고정된 앞부분은 캐시가 받아 줍니다.** 공급자는 같은 앞부분을 최근에 받은 적이 있으면 그 입력을 싸게 받기도 합니다 (캐시된 입력, 1단계 3절). 그래서 바뀌지 않는 지시문은 **앞에**, 질문처럼 바뀌는 것은 **뒤에** 둡니다. 다만 캐시는 할인일 뿐이고, 필요 없는 글을 빼는 것이 먼저입니다. + +### 오늘 실습에서 + +Lab 02 예제 1 (수업 준비 때): + +| | 전 (`answers/08`) | 후 (`answers/09`) | +| --- | --- | --- | +| `answer` 입력 · VPN | 1,781 | **165** (91% 줄임) | +| `answer` 입력 · 접수 | 1,791 | 152 | +| VPN 비용 | $0.001007 | **$0.000346** | +| 품질 (인증서 · 처리 중 · 비밀번호 찾기) | ✔ | ✔ | +| `diff` | | `Answer` 본문 3,491 → 822자, 한 곳만 | + +규정 전체는 답을 더 좋게 만들지 않았습니다. 지시문이 "FAQ와 접수 건으로만 답하라"고 했기 때문입니다. 규정이 필요한 질문이 있다면 통째로가 아니라 71일차처럼 **검색해서 관련 부분만** 보내는 것이 답입니다. + +## 3. 단계마다 맞는 모델 (4교시 Lab 02 예제 2) + +### 일반 설명 + +**모든 일에 같은 모델을 쓸 까닭은 없습니다.** 세 낱말 중 하나를 고르는 분류와, 사실을 틀리면 안 되는 답은 어려움이 다릅니다. 일의 어려움에 맞춰 모델을 고르는 것을 **모델 라우팅(model routing)**이라고 부릅니다. 쉬운 모델로 먼저 해 보고 자신이 없을 때만 큰 모델로 넘기는 방식은 **계단식(cascade)**이라고 부릅니다. + +| 일 | 어려움 | 맞는 모델 | +| --- | --- | --- | +| 분류 · 낱말 뽑기 · 형식 바꾸기 | 쉬움 | 작은 모델 | +| 말투 다듬기 | 쉬움~보통 | 작은 모델, 또는 코드 검사 | +| 사실에 맞는 답 · 판단 | 어려움 | 검증된 모델 | +| 긴 추론 · 계획 | 어려움 | 큰 모델 | + +**라우팅을 맡는 도구도 있습니다.** 여러 공급자 · 모델을 한 주소로 부르고, 규칙(작업 종류 · 길이 · 비용 한도)에 따라 모델을 골라 주는 게이트웨이 · 라우터 도구들이 있습니다 (예: LiteLLM, OpenRouter, 클라우드 공급자의 모델 게이트웨이). 이런 도구를 써도 **어느 일에 어느 모델이 충분한지**는 내 테스트로 정해야 합니다. 도구는 고른 결과를 실행해 줄 뿐입니다. + +**단가표를 다시 봅니다.** "작은 모델이면 몇 배 싸다"는 늘 맞지 않습니다. 오늘 두 모델은 입력이 절반, 출력은 약 0.78배입니다. 그래서 **입력이 많은 단계**를 바꿀수록 효과가 크고, 출력이 많은 단계는 덜합니다. 공급자 · 시기마다 단가가 달라서 바꾸기 전에 직접 계산해야 합니다. + +| 모델 | 입력 ($/1M) | 출력 ($/1M) | +| --- | --- | --- | +| `gpt-4.1-mini` | 0.40 | 1.60 | +| `gpt-5.4-nano` | 0.20 (절반) | 1.25 (약 0.78배) | + +**추론하는 모델은 생각에도 토큰을 씁니다.** 요즘 모델 중에는 답하기 전에 속으로 추론하는 모델(reasoning model)이 있고, 그 추론에 쓴 토큰은 화면에 보이지 않아도 **출력 토큰으로 청구됩니다** (OpenAI 응답의 `usage.completion_tokens_details.reasoning_tokens`). 추론 정도를 고르는 옵션(`reasoning_effort` 등)이 있으면 쉬운 일에는 낮게 둡니다. 오늘 `gpt-5.4-nano`는 기본값이 추론을 하지 않는 쪽이라 수업 준비 때 `reasoning_tokens`가 0이었습니다. 모델을 바꿀 때는 단가만이 아니라 **같은 일에 토큰을 얼마나 쓰는지**도 재야 합니다. + +**바꾼 단계는 평가로 확인합니다.** 작은 모델이 그 일을 정말 잘하는지는 **그 단계만의 테스트**로 봅니다. 74일차처럼 모델만 바꾼 릴리스를 만들어 golden 게이트를 지나게 하는 것이 정석입니다. 그리고 모델 이름을 바꿀 때 **비용 계산의 모델 이름도 같이** 바꿔야 합니다. 노드만 바꾸면 실제로는 nano를 부르는데 기록은 mini라고 하고, 비용도 mini 단가로 계산됩니다 (74일차 "한 곳에서 정하고 모두 그것을 읽는다"). + +### 오늘 실습에서 + +Lab 02 예제 2 (수업 준비 때): 분류 · 검토를 `gpt-5.4-nano`로 바꾸자 VPN 질문이 $0.000346 → **$0.000278**. 분류 · 검토는 입력이 많은 단계가 아니라 줄어든 폭이 작았습니다. `lab02` 5/5 · `lab01` 3/3, 답은 mini 그대로라 품질 테스트도 그대로 통과했습니다. + +## 4. 싸졌는데 나빠졌다면 (4교시 Lab 02 예제 3) + +### 일반 설명 + +**통과했다고 같은 품질은 아닙니다.** 오늘 테스트는 답에 **꼭 있어야 할 낱말**이 있는지만 봅니다. 낱말이 있어도 형식 · 말투 · 군더더기 · 안쪽 말이 새는지는 잡지 못합니다. 비용을 줄이다 품질이 조금씩 나빠지는 것을 **품질 회귀(quality regression)**라고 부르고, 테스트가 약하면 아무도 모르는 사이에 쌓입니다. + +| 테스트가 잡는 것 | 테스트가 놓치기 쉬운 것 | +| --- | --- | +| 꼭 있어야 할 낱말 | 형식 (마크다운 기호가 메신저에 그대로 나감) | +| 응답 코드 · 칸 | 군더더기 (묻지 않은 말) | +| 비용 · 시간 범위 | 안쪽 말 (데이터 API의 오류 글자 · 칸 이름) | +| | 말투 · 친절함 | + +**사람이 읽습니다.** 그래서 모델 · 지시문을 바꾸면 테스트와 함께 **답 몇 개를 사람이 직접 읽습니다.** 읽고 찾은 것은 다음부터 테스트로 만듭니다 (`not_contains: ["**"]`). 답이 많으면 다른 LLM에게 기준표를 주고 채점하게 하는 방법(LLM-as-a-judge)도 쓰지만, 그 채점도 사람이 표본을 확인합니다. + +**평가 세트를 키웁니다.** 사람이 읽다가 찾은 나쁜 답은 그냥 고치고 끝내지 않고, **테스트 케이스로 남깁니다.** 그러면 다음에 누가 모델 · 지시문을 바꿔도 같은 실수를 다시 잡습니다. 74일차의 강한 golden이 이렇게 자랐습니다. 비용 작업은 "더 싼 쪽으로 바꿔 볼까?"가 자주 되풀이되므로, 평가 세트가 탄탄할수록 다음 절약을 빠르고 안전하게 할 수 있습니다. + +**아끼는 돈과 잃는 것을 나란히 놓습니다.** 결정은 사람이 합니다. + +| 놓고 볼 것 | 예 | +| --- | --- | +| 아끼는 돈 | 요청 하나 · 한 달 몇 % | +| 잃는 것 | 형식 · 말투 · 정확도 · 시간 | +| 되돌리는 값 | 나빠진 답을 받은 사용자, 고치는 시간 | + +아끼는 돈이 작고 잃는 것이 보이면 **바꾸지 않는 것**이 맞는 결정입니다. 이것도 개선 작업의 정상적인 결론입니다. + +### 오늘 실습에서 + +Lab 02 예제 3 (수업 준비 때, 비교본 `nano-answer` = 예제 2 정답에서 답 단계만 nano): + +| | 답 mini (내 것) | 답 nano | +| --- | --- | --- | +| `lab02` 낱말 검사 | 5/5 | **5/5** | +| 일곱 질문 평균 비용 (`lab01-mix`) | $0.000233 | $0.000198 (약 15% 덜) | + +답을 읽으면 nano 쪽에 접수 답의 `**처리 중**` 같은 마크다운, "접수 건은 ticket_id가 없어 상태를 확인할 수 없으며…" 같은 묻지 않은 안쪽 말, "접수 건은 찾지 못했습니다" 같은 군더더기가 있었습니다. 15%를 아끼려고 이것을 받을지는 사람이 정합니다. 오늘은 **답은 mini로 둡니다.** 74일차 2.0.0(답까지 nano)이 golden을 통과하고도 "비용을 본 뒤"로 미룬 것을 오늘 숫자로 본 셈입니다. + +## 5. 가장 싼 호출은 하지 않은 호출 (4교시 Lab 02 과제 1) + +### 일반 설명 + +토큰과 단가를 줄인 뒤에 남는 것은 **호출 수**입니다. 호출을 없애면 그 호출의 비용 · 시간 · 실패 가능성이 한꺼번에 사라집니다. + +| 방법 | 언제 | 예 | +| --- | --- | --- | +| 규칙으로 먼저 | 판단이 분명한 경우가 대부분일 때 | 질문에 `T-1002`가 있으면 접수, FAQ 검색에 걸리면 FAQ | +| 중복 단계 빼기 | 두 단계가 같은 일을 할 때 | 답의 지시문이 이미 말투를 정하는데 검토가 또 함 | +| 코드로 검사 | 검사할 것이 길이 · 금지어 · 형식일 때 | 69일차 검토자 Code | +| 답 다시 쓰기 (캐시) | 같은 질문이 자주 올 때 | 72일차 FAQ 답 캐시 (사람별 데이터는 캐시하지 않음) | + +**규칙이 모르는 것만 LLM에게.** 규칙은 공짜이고 빠르고 늘 같습니다. 대신 모든 경우를 알지 못합니다. 그래서 **규칙이 분명히 아는 것은 규칙으로**, 모르는 것만 LLM에게 넘깁니다. 대부분의 요청이 규칙으로 처리되면 평균 비용이 크게 줄고, 드문 질문만 비용이 조금 더 듭니다. + +```mermaid +flowchart LR + Q(["질문"]) --> R{"규칙이 아나?"} + R -->|예 · 대부분| A{{"답 LLM 1번"}} + R -->|아니오 · 드묾| C{{"분류 LLM (작은 모델)"}} --> A +``` + +**캐시는 같은 질문이 반복될 때만.** 같은 질문의 답을 저장해 두고 다시 쓰면 호출이 0이 됩니다 (72일차). 다만 답이 **사람마다 다른** 질문(내 접수 건, 내 휴가 일수)은 캐시하면 남의 답이 나갑니다. 그리고 원본(FAQ)이 바뀌면 저장해 둔 답도 낡습니다. 캐시는 "같은 질문 · 같은 답 · 자주"가 모두 맞을 때 씁니다. + +**뺀 단계가 하던 일을 확인합니다.** 단계를 빼기 전에 그 단계가 실제로 무엇을 했는지 봅니다. 검토 단계가 말투만 다듬었다면 빼도 되지만, 사실을 고치고 있었다면 빼면 안 됩니다. 빼고 나서 **품질 테스트가 그대로인지**로 확인합니다. + +### 오늘 실습에서 + +Lab 02 과제 1 (수업 준비 때): + +| | 고치기 전 (예제 2 정답) | 고친 뒤 | +| --- | --- | --- | +| FAQ 넷 · 접수 둘 | 3 calls · $0.000164 ~ 0.000297 (다섯이 $0.0002 넘음) | **1 call · $0.000108 ~ 0.000166** | +| 규칙으로 모르는 질문 (회의실) | 3 calls | 2 calls · $0.000097 | +| `lab02-challenge` | 0/7 | **7/7** | +| 시간 | 3초대 | 1초대 | + +품질 테스트(`lab02` 5/5 · `lab01` 3/3 · `lab01-challenge` 4/4)도 그대로였습니다. 질문 3개를 기록한 합계가 Lab 01 때 $0.002951에서 **$0.000409**로, 약 7분의 1이 됐습니다. + +## 6. 긴 입력의 상한 (4교시 Lab 02 과제 2) + +### 일반 설명 + +**입력 길이에 상한이 없으면 요청 하나의 비용에도 상한이 없습니다.** 비용은 보낸 토큰에 비례합니다. 사용자가 로그 · 문서를 통째로 붙여 넣으면 그 한 번이 평소의 몇 배가 됩니다. 실수일 수도 있고, 일부러일 수도 있습니다. 공격자가 아주 긴 입력을 계속 보내 **상대의 돈을 바닥내는** 공격을 Denial of Wallet이라고 부릅니다 (73일차 "아주 긴 입력으로 괴롭히기"의 비용 쪽 얼굴). + +**두 상한이 있어야 최대 비용을 말할 수 있습니다.** + +```text +요청 하나의 최대 비용 ≈ (최대 입력 토큰 × 입력 단가 + 최대 출력 토큰 × 출력 단가) ÷ 1,000,000 + ↑ 입력 상한 (오늘 과제 2) ↑ 출력 상한 (예제 1의 max_completion_tokens) +``` + +최대 비용을 알면 예산을 지킬 수 있습니다 (3단계: "남은 예산이 한 건의 최대 비용보다 적으면 멈춘다"). + +**긴 입력을 다루는 방법.** + +| 방법 | 비용 | 사용자 | 언제 | +| --- | --- | --- | --- | +| 거절 (HTTP 413 "너무 김") | 0 | 다시 써야 함 | 길면 안 되는 입력 (검색어 등) | +| 앞뒤만 남기기 | 상한 안 | 대개 답을 받음 | 질문이 앞이나 끝에 있는 붙여 넣기 | +| 나눠서 요약한 뒤 답 | 요약 호출이 또 듦 | 답을 받음 | 문서 전체가 필요할 때 | +| 검색해서 관련 부분만 | 상한 안 | 답을 받음 | 긴 자료에서 찾을 때 (RAG) | + +**출력 상한에 걸린 답은 표시가 남습니다.** 답이 출력 상한에 닿아 중간에 끊기면 공급자 응답의 끝난 까닭이 `length`로 옵니다 (OpenAI `finish_reason`). 정상으로 끝나면 `stop`입니다. 끊긴 답이 자주 나오면 상한이 너무 작거나 지시문이 너무 긴 답을 부르고 있는 것이므로, 기록에 이 값도 남겨 셉니다. + +**상한 값은 사람이 정하고 밖에 둡니다.** 너무 작으면 정상 질문도 잘리고, 너무 크면 상한의 뜻이 없습니다. 값은 단가처럼 **코드 밖**(설정 · 데이터)에 두고, 바꾸면 테스트로 확인합니다. 그리고 잘랐다는 사실(`trimmed`)을 결과에 남겨, 얼마나 자주 잘리는지 나중에 셀 수 있게 합니다. + +### 오늘 실습에서 + +Lab 02 과제 2 (수업 준비 때, 상한 500자 = 앞 200 + 뒤 300): + +| 질문 | 고치기 전 (과제 1 정답) | 고친 뒤 | +| --- | --- | --- | +| 프린터 로그 + 끝에 질문 (6,298자) | **$0.001196** (짧은 질문의 7배 넘게) | **$0.000237** · "대기열" | +| 앞에 질문 + VPN 로그 (6,103자) | $0.001099 | $0.000260 · "인증서" | +| 짧은 질문 (24자) | | `trimmed: false` · `question_chars: 24` | +| 사람이 상한을 10000으로 | | 자르지 않음 · $0.001196 | + +수업 준비 때 처음에는 긴 질문도 $0.0002 이하를 기대했는데, 500자로 줄여도 $0.00024 ~ 0.00026, 300자로 줄여도 $0.00021 ~ 0.00022였습니다. 답(출력)과 FAQ만으로 약 $0.0001이 들기 때문입니다. 그래서 기대를 **$0.0003 이하**(줄이기 전의 약 4분의 1)로 정했습니다. 입력을 아무리 줄여도 **출력 · 고정 자료의 몫은 남는다**는 것이 이 숫자에서 보입니다. + +## 7. 줄인 것을 남기기 + +### 일반 설명 + +비용을 줄인 작업은 **무엇을 바꿔 · 얼마를 아끼고 · 무엇을 잃었는지**를 한 줄씩 남깁니다. 나중에 품질 문제가 생기면 이 기록에서 "어느 절약 때문인지"를 찾고, 단가가 바뀌면 다시 계산합니다. 74일차 변경 기록(CHANGELOG)의 비용판입니다. + +| 칸 | 예 | +| --- | --- | +| 바꾼 것 (한 칸만) | `Answer`에서 규정 전체를 뺌 | +| 비용 전 → 후 | VPN $0.001007 → $0.000346 | +| 품질 테스트 | `lab02` 앞 세 케이스 ✔ | +| 잃은 것 | 규정에만 있는 질문은 "찾지 못했습니다" | +| 되돌리는 법 | 이전 워크플로우 (74일차 릴리스) | + +### 오늘 실습에서 + +`my-work/COST.md` 5절에 Lab 02의 다섯 실습을 한 줄씩 적습니다. 특히 예제 3의 "바꾸지 않기로 한 결정"과 그 까닭도 한 줄입니다. + +## 8. 요청문(프롬프트)으로 + +**요청문(프롬프트)이란.** 실습에서 CLI 코딩 에이전트(codex · claude · agy)에게 보내는 글입니다. 에이전트는 이 글만 보고 n8n 워크플로우 JSON을 만들거나 고칩니다. 이 절은 이 단계에서 배운 것을 **요청문(프롬프트)에 어떤 칸으로 적을지** 실습 전에 정리하는 자리입니다. 실습 문서의 `▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대`가 이 칸을 실제 문장으로 만든 것이고, 빈칸 ①~④는 그 문장 속 정확한 값입니다. + +| 칸 | 에이전트에게 알려 주는 것 | 빠지면 생기는 일 | +| --- | --- | --- | +| 무엇을 | 만들거나 바꿀 기능 | 에이전트가 짐작해서 다른 것을 만듦 | +| 어디에 | 고칠 파일 · 노드 · 자리 | 엉뚱한 노드를 고치거나 새 파일을 만듦 | +| 어떤 모양으로 | 결과 JSON의 칸 이름 · 값 · 응답 코드 | 테스트가 보는 칸 이름과 달라 실패 | +| 그대로 · 하지 말 것 | 건드리면 안 되는 것 | 다른 곳까지 바뀌어 무엇 때문인지 모르게 됨 | +| 확인 | 통과해야 할 테스트 파일 | "다 됐다"를 판단할 기준이 없음 | + +67~71일차의 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)와 같은 뜻입니다. 파일 · 입력이 "어디에", 처리가 "무엇을", 결과 모양이 "어떤 모양으로", 기대 결과가 "확인"입니다. 72일차부터는 **이미 있는 워크플로우를 고치는** 요청이 많아 "그대로"가 따로 한 칸이 됐습니다. + +비용을 줄이는 요청에서는 **"그대로"가 가장 중요한 칸**입니다. 한 번에 한 칸만 바꾸려면 나머지를 그대로 두라고 적어야 하고, 품질 테스트(답의 낱말)를 지키라고 적어야 합니다. + +아래 표의 열은 이 단계에서 에이전트를 쓰는 실습, 줄은 위 다섯 칸입니다. + +| 요청문에 | Lab 02 예제 1 | Lab 02 예제 2 | Lab 02 과제 1 | Lab 02 과제 2 | +| --- | --- | --- | --- | --- | +| 무엇을 | 보내는 것 줄이기 | 쉬운 단계를 작은 모델로 | LLM 한 번 | 긴 질문 줄이기 | +| 어디에 | `Answer` 본문 | `Classify` · `Review`, `Build result`의 단계 목록 | `Get ticket` 뒤 `Rule classify` · `Known?`, `Review` 빼기 | `Get budget` · `Prepare question`, FAQ 검색 · 규칙 · 답이 읽는 질문 | +| 어떤 모양으로 | FAQ 3칸 · 접수 5칸, `max_completion_tokens` 200 | `usage.by_step.classify.model` = `gpt-5.4-nano` | `llm_calls` 1 (모를 때 2) | `trimmed` · `question_chars` | +| 그대로 | 분류 · 검토 단계 | 답은 `gpt-4.1-mini` | 답의 내용 (품질 테스트) | 과제 1의 결과 · 상한 값은 `/budget`에서 | +| 확인 | `cases/lab02.json` 앞 셋 | `cases/lab02.json` · `lab01` | `cases/lab02-challenge.json` · `lab02` · `lab01` · `lab01-challenge` | `cases/lab02-challenge2.json` · `lab02-challenge` | + +예를 들어 Lab 02 예제 1 열을 위에서부터 이어 읽으면 요청문(프롬프트)이 됩니다 (화살표 오른쪽은 다섯 칸 중 어디인지): + +```text +답 단계에 보내는 것을 줄여 줘. ← 무엇을 +my-work/cost.json 의 Answer 본문에서. ← 어디에 +규정 전체는 빼고, FAQ 는 id · title · answer, 접수 건은 다섯 칸만, max_completion_tokens 200. ← 어떤 모양으로 +Classify · Review 는 그대로. ← 그대로 +cases/lab02.json 앞 세 케이스가 통과해야 해. ← 확인 +``` + +## 9. 자주 하는 실수 + +| 실수 | 왜 문제인가 | 이렇게 | +| --- | --- | --- | +| 세 칸을 한꺼번에 바꿈 | 무엇 덕분 · 무엇 때문인지 모름 | 한 번에 한 칸, `COST.md`에 한 줄 | +| 작은 곳부터 줄임 | 전체가 거의 안 줄어듦 | 가장 큰 몫(단계별 토큰)부터 | +| 테스트를 통과시키려고 기대를 바꿈 | 품질이 몰래 나빠짐 | 워크플로우를 고친다 | +| 통과했으니 같은 품질이라고 봄 | 형식 · 군더더기 · 안쪽 말을 놓침 | 답을 읽고, 찾은 것은 테스트로 | +| 노드의 모델만 바꿈 | 기록 · 비용이 다른 모델로 계산됨 | 계산의 모델 이름도 함께 | +| 출력 상한을 너무 작게 | 답이 잘림 | 지시문의 길이와 맞게 | +| 긴 입력을 그대로 둠 | 한 사람이 예산을 다 씀 | 입력 상한 · 출력 상한 → 최대 비용 | +| 상한 값을 코드에 | 바꿀 때마다 워크플로우 수정 | `/budget`처럼 밖에 | + +## 확인 질문 + +1. 비용을 정하는 세 칸은? 오늘 Lab 02의 다섯 실습은 각각 어느 칸을 줄였나요? +2. 답 단계 입력이 1,781에서 165토큰으로 줄었는데 품질 테스트는 그대로였습니다. 무엇을 알려 주나요? +3. 작은 모델의 단가가 입력은 절반, 출력은 0.78배입니다. 어느 단계를 바꾸면 효과가 큰가요? +4. 답까지 nano로 바꾼 비교본이 테스트를 다 통과했는데도 바꾸지 않았습니다. 왜인가요? +5. 규칙으로 분류하고 검토를 뺐습니다. 검토를 빼도 되는지 무엇으로 확인했나요? +6. 긴 질문을 500자로 줄여도 $0.0002 아래로 내려가지 않았습니다. 무엇이 남았나요? + +## 정리 + +- [ ] 비용 = 호출 수 × 호출당 토큰 × 단가, 한 번에 한 칸씩, 가장 큰 몫부터 +- [ ] 품질 테스트를 먼저 고정하고, 바꿀 때마다 다시 돌리고 답을 읽는다 +- [ ] 필요한 것만 보내고(데이터 최소화), 출력에 상한을 둔다 +- [ ] 쉬운 단계만 작은 모델, 단가표로 효과를 계산하고 계산의 모델 이름도 함께 +- [ ] 가장 싼 호출은 하지 않은 호출 — 규칙으로 먼저, 중복 단계 빼기 +- [ ] 입력 · 출력 상한으로 요청 하나의 최대 비용을 정한다 diff --git a/Lecture/75/03-STAGE3-BUDGET-AND-LIMITS.md b/Lecture/75/03-STAGE3-BUDGET-AND-LIMITS.md new file mode 100644 index 0000000..1e0f6db --- /dev/null +++ b/Lecture/75/03-STAGE3-BUDGET-AND-LIMITS.md @@ -0,0 +1,332 @@ +# 3단계 · 예산과 한도 + +- 교시: 5교시 강의 (14:00~14:50) · 6교시 [Lab 03](../../Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md) 실습 5개 (15:00~15:50) +- 이 단계가 끝나면: 비용이 넘치지 않게 하는 장치(하루 예산 · 사용자별 한도 · 요청 하나의 상한)를 구분하고, 예산을 넘으면 LLM 없이 낮춰서 답하고, 한 사람이 몰아 부르면 429로 잠시 막고, 예산이 언제 멈추는지 재고, 지금까지의 기록으로 한 달 비용과 "모델을 바꾸면 얼마"를 코드로 계산할 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini` · `gpt-5.4-nano`, 10/28 정답 워크플로우를 학생 순서대로 1회). 출력 전부는 수업 뒤 공개되는 `Last3Mon/75/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 03 예제 1"은 6교시 [Lab 03 문서](../../Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md)의 그 절입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/75` 기준입니다. + +```mermaid +flowchart LR + A["1. 넘치지 않게"] --> B["2. 하루 예산"] --> C["3. 사용자별 한도 · 429"] --> D["4. 예산은 넘은 뒤 멈춘다"] + D --> E["5. 비용 보고서"] --> F["6. 바꾸면 얼마"] --> G["7. 내 쪽 자원 관리"] --> H["8. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 14:00~14:07 | 1 · 개요 22장 | 예산 · 한도 · 상한, 공급자 한도와 내 한도 | +| 14:07~14:14 | 2 · 개요 23장 | 넘으면 낮춰서 답하기, 어디에 두나 | +| 14:14~14:22 | 3 · 개요 24장 | 속도 제한 방식, 429와 다시 시도 | +| 14:22~14:28 | 4 · 개요 25장 | 넘은 뒤 멈춤, 경고 단계, 하루가 바뀔 때 | +| 14:28~14:36 | 5 · 개요 26장 | 보고서는 코드가 계산, 정확한 테스트 | +| 14:36~14:42 | 6 · 개요 27장 | what-if, 가정의 한계 | +| 14:42~14:46 | 7 · 개요 28장 | 동시 실행 · 실행 기록 | +| 14:46~14:50 | 8 · 9 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 6교시 Lab 03에서 이어서 하는 것 + +| 6교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 03 예제 1 (15:00~15:10) | 2 | 예산을 넘으면 LLM 없이 원문 | `budget`, 테스트 `cases/lab03.json` | +| Lab 03 예제 2 (15:10~15:20) | 3 | 사람마다 1분 한도 → 429 | 테스트 `cases/lab03-limit.json`, `lab03` · `lab02-challenge` | +| Lab 03 예제 3 (15:20~15:27, 에이전트 없이) | 4 | 예산 $0.0003으로 여섯 질문 | `budget --daily 0.0003` → `test cases/lab03-drill.json` → `usage` → `budget --reset` | +| Lab 03 과제 1 (15:27~15:40) | 5 | 비용 보고서 `my-cost-report` | `my-work/cost-report.json`, 테스트 `cases/lab03-challenge.json` | +| Lab 03 과제 2 (15:40~15:50) | 6 | `what_if_model` | 테스트 `cases/lab03-challenge2.json` | + +## 1. 넘치지 않게: 예산 · 한도 · 상한 + +### 일반 설명 + +1 · 2단계로 **얼마인지 알고, 줄였습니다.** 그래도 쓰는 사람이 늘거나, 누가 몰아서 부르거나, 아주 긴 질문이 오면 비용은 넘칠 수 있습니다. 운영에서는 넘치기 **전에** 멈추는 장치를 둡니다. + +| 장치 | 무엇을 막나 | 단위 | 오늘 | +| --- | --- | --- | --- | +| 예산 (budget) | 전체가 너무 많이 씀 | 하루 · 한 달 금액 | Lab 03 예제 1 | +| 속도 제한 (rate limit) | 한 사람 · 한 프로그램이 짧은 시간에 몰아 부름 | 분당 · 초당 요청 수 | Lab 03 예제 2 | +| 할당량 (quota) | 한 사람 · 한 팀이 오래 많이 씀 | 하루 · 한 달 요청 수 · 금액 | (사람별 하루 한도로 넓힐 수 있음) | +| 요청 상한 | 요청 하나가 너무 큼 | 입력 글자 수 · 출력 토큰 | Lab 02 과제 2 · 예제 1 | + +**공급자 쪽 한도와 내 쪽 한도.** 공급자도 한도를 둡니다. API 계정마다 분당 요청 수 · 분당 토큰 수(rate limit)가 정해져 있고, 넘으면 429를 받습니다. 계정 전체의 월 사용 한도를 정해 둘 수도 있습니다. 그런데 공급자 한도는 **계정 전체**에 걸리므로, 한 사람이 몰아 부르면 **모든 직원**이 막힙니다. 그래서 내 서비스 안에서 사람마다 · 기능마다 더 작은 한도를 둡니다. + +| | 공급자 한도 | 내 한도 | +| --- | --- | --- | +| 단위 | 계정 · 모델 | 사람 · 팀 · 기능 | +| 넘으면 | 모든 요청이 429 | 그 사람 · 그 기능만 | +| 누가 정하나 | 공급자 (요금제) | 우리 | +| 목적 | 공급자 서버 보호 | 예산 · 공정성 | + +**넘었을 때 무엇을 주나도 설계입니다.** "오류"로 끝내면 사용자는 아무것도 못 받습니다. 낮춰서 답하기(2절), 잠시 뒤 다시 하라는 안내(3절), 담당자 연결 중 무엇을 줄지 정합니다. 그리고 **넘었다는 사실을 기록**해서 한도가 적절했는지 나중에 판단합니다. + +**팀마다 몫을 나누기도 합니다.** 여러 부서가 같은 AI 서비스를 쓰면, 회사는 부서마다 한 달 몫(할당량)을 정하고 쓴 만큼을 알려 주거나(showback) 부서 예산에서 떼어 가기도(chargeback) 합니다. 그러려면 1단계의 기록에 **팀 · 기능 칸**이 있어야 합니다. 몫이 정해지면 각 팀은 자기 기능의 비용을 스스로 줄일 까닭이 생깁니다. + +**값은 사람이 정합니다.** 예산 · 한도는 기술이 아니라 **결정**입니다. 한 달 예산을 일하는 날로 나눈 값, 지난달 기록의 평균에 여유를 둔 값, 사용자 수 × 한 사람 몫 등 근거를 정해 적습니다. 그래서 값은 코드 밖(`/budget`)에 두고, 사람이 `flow.py budget`으로 바꿉니다. 워크플로우는 **읽기만** 합니다 (AGENTS.md 9절). + +### 오늘 실습에서 + +오늘 데이터 API의 `/budget`이 사람이 정한 값 셋을 줍니다 (`data/budget.json`, 처음 상태). + +| 값 | 처음 | 쓰는 곳 | +| --- | --- | --- | +| `daily_usd` | $1.0 | 예제 1 (넘으면 `한도 초과`) | +| `user_limit_per_min` | 20 | 예제 2 (넘으면 429) | +| `max_question_chars` | 500 | Lab 02 과제 2 (넘으면 앞뒤만) | + +`used_usd`는 기록된 `cost_usd`의 합이고 `over`는 `used_usd ≥ daily_usd`입니다. 그래서 Lab 01 과제의 기록이 없으면 예산도 없습니다. + +## 2. 하루 예산, 넘으면 낮춰서 답하기 (6교시 Lab 03 예제 1) + +### 일반 설명 + +**예산을 넘으면 멈추는 대신 낮춰서 답합니다.** 예산을 넘었다고 "오늘은 끝"이라고 하면 직원은 오후 내내 도움을 못 받습니다. 그런데 헬프데스크가 답하는 재료(FAQ 원문 · 접수 상태)는 **이미 데이터에 있습니다.** LLM이 문장을 다듬지 않을 뿐, 내용은 줄 수 있습니다. 이것을 **우아한 저하(graceful degradation)**라고 합니다. + +| 수준 | 무엇을 주나 | 비용 | +| --- | --- | --- | +| 평소 | LLM이 다듬은 답 | 요청마다 | +| 낮춤 | 데이터 원문 + "오늘 자동 답변 한도를 넘어 원문으로 안내합니다" | 0 | +| 더 낮춤 | "담당자에게 연결합니다" | 0 | + +**어디에 두나.** 예산 확인은 LLM보다 **앞**, 데이터 조회보다는 **뒤**에 둡니다. 낮춰서 답하려면 데이터가 먼저 있어야 하고, LLM을 부른 뒤에 확인하면 이미 돈을 쓴 뒤이기 때문입니다. + +```mermaid +flowchart LR + D["FAQ · 접수 조회
(데이터, 0원)"] --> B{"예산 안?"} + B -->|예| L{{"LLM 답"}} + B -->|아니오| O(["원문으로 답 · $0"]) +``` + +**무엇을 먼저 지킬지 정해 둡니다.** 예산이 바닥났을 때 모든 기능을 똑같이 낮출 필요는 없습니다. 보안 사고 신고처럼 **꼭 LLM이 있어야 하는 일**은 따로 남은 몫을 두고, 덜 급한 일(요약 · 말투 다듬기)부터 낮춥니다. 이것을 미리 정해 두지 않으면, 예산이 바닥난 날 가장 중요한 문의도 원문만 받습니다. + +**사용자에게 솔직하게 알립니다.** 낮춘 답에는 "오늘 자동 답변 한도를 넘어 원문으로 안내합니다"처럼 **왜 평소와 다른지**를 한 줄 붙입니다. 아무 말 없이 답의 질만 떨어지면 사용자는 서비스가 고장 났다고 생각하고 문의가 늘어납니다. 언제 다시 평소대로 되는지(내일 · 담당자 연결)도 알려 주면 더 좋습니다. + +**장애로 낮춘 것과 예산으로 낮춘 것은 구분합니다.** 72일차에는 LLM이 **멈췄을 때** 데이터 그대로 답했습니다 (`간단 답변`). 오늘은 LLM이 멀쩡한데 **쓰지 않기로** 한 것입니다. 답의 모양은 비슷해도 `route`를 다르게 두면, 나중에 로그를 보는 사람이 "장애였나, 예산이었나"를 가를 수 있습니다. 원인이 다르면 대응도 다릅니다 (장애는 고치고, 예산은 값을 다시 정함). + +### 오늘 실습에서 + +Lab 03 예제 1 (수업 준비 때): 예산 $0.001에 이미 $0.002를 쓴 상태로 시험했습니다. 고치기 전에는 그래도 `자동 답변`으로 LLM을 불렀고 ($0.000154), 고친 뒤에는 **`한도 초과` · 0 calls · $0**에 FAQ 원문("인증서")과 접수 상태("처리 중")가 나갔습니다. 한도 안이면 평소대로 $0.000156. + +## 3. 사용자별 한도와 429 (6교시 Lab 03 예제 2) + +### 일반 설명 + +**하루 예산은 모두의 것입니다.** 한 사람이 스크립트로 1분에 수백 번 부르면 예산이 오전에 바닥나고, 나머지 직원은 원문만 받습니다. 그래서 **사람마다** 짧은 시간의 한도를 둡니다. 이것을 **속도 제한(rate limiting)**이라고 합니다. 공정성 · 비용 · 서버 보호를 함께 얻습니다. + +**세는 방법 넷.** + +| 방법 | 어떻게 | 특징 | +| --- | --- | --- | +| 고정 창 (fixed window) | 매 분 0초부터 몇 번 | 쉬움. 59초와 61초에 몰면 짧은 사이에 두 배 | +| 이동 창 (sliding window) | 지금부터 거꾸로 60초 동안 몇 번 | 고르게 막음. 기록을 시각과 함께 남겨야 함 (오늘) | +| 토큰 버킷 (token bucket) | 일정한 속도로 채워지는 통에서 하나씩 꺼냄 | 짧은 몰림은 허용, 오래 몰리면 막음. 공급자들이 많이 씀 | +| 새는 통 (leaky bucket) | 들어온 것을 일정한 속도로만 내보냄 | 내보내는 속도가 고름 | + +**429는 "잠시 뒤 다시"라는 약속입니다.** HTTP 429 `Too Many Requests`는 표준 응답이고, `Retry-After`(헤더 · 또는 본문의 `retry_after`)로 몇 초 뒤에 다시 부르면 되는지 알려 줍니다. 부르는 프로그램은 이것을 보고 기다렸다가 다시 시도합니다. 바로바로 다시 부르면 한도에 또 걸리므로, 72일차처럼 **기다리는 시간을 늘려 가며**(exponential backoff) 다시 시도합니다. + +**가장 앞에 둡니다.** 한도를 넘은 요청에는 데이터 조회도 LLM도 할 까닭이 없습니다. 맨 앞에서 돌려보내면 **0원 · 거의 0초**입니다. 예산 확인(2절)과 자리가 다른 까닭은 원문 답이 필요 없기 때문입니다. + +**횟수 대신 토큰 · 금액으로 셀 수도 있습니다.** 요청 수로만 세면 짧은 질문 20번과 긴 로그 질문 20번이 같은 한도를 씁니다. 그런데 비용은 몇 배 차이가 납니다. 그래서 공급자들은 분당 요청 수와 함께 **분당 토큰 수**에도 한도를 두고, 서비스 안에서도 "한 사람이 하루 $0.05까지"처럼 금액으로 셀 수 있습니다. 오늘은 요청 수로 세고, 긴 질문은 Lab 02 과제 2의 상한이 따로 막습니다. + +**세는 곳은 하나여야 합니다.** 워크플로우 엔진을 여러 대로 늘리면, 각자 자기 메모리에 횟수를 세면 한 사람이 서버마다 한도만큼 부를 수 있습니다. 그래서 횟수는 **모두가 같이 보는 곳**(데이터베이스, Redis 같은 저장소, 오늘은 데이터 API)에서 셉니다. 공급자 · API 게이트웨이 · 클라우드의 한도 기능도 같은 이유로 한곳에서 셉니다. + +**한도 값을 정할 때 생각할 것.** + +| 생각할 것 | 예 | +| --- | --- | +| 정상 사용자의 최대 | 사람이 1분에 몇 번 묻나 (고쳐 묻기 포함) | +| 공용 계정 · 자동화 | 여러 사람이 한 이름으로, 다른 시스템이 부를 때 | +| 무엇으로 사람을 가르나 | 로그인 정보 (사용자가 보낸 이름은 바꿀 수 있음 — 67일차) | +| 넘었을 때 알림 | 몇 번 넘으면 담당자에게 (76일차) | + +### 오늘 실습에서 + +Lab 03 예제 2 (수업 준비 때, 한도 2): 이준호의 세 번째 요청이 고치기 전에는 200 · 1.3초 · $0.000156이었고, 고친 뒤에는 **429 · 잠시 후 · 0.0초 · $0**. 다른 사람(한지훈)은 그대로 200. 데이터 API의 `/usage/user/<이름>?window=60`은 기록된 요청을 이동 창으로 셉니다. 오늘 실습의 "사람"은 요청 본문의 `user`라서 바꿔 보낼 수 있습니다. 실제 서비스에서는 로그인 정보로 가릅니다. + +## 4. 예산은 넘은 뒤 멈춘다 (6교시 Lab 03 예제 3) + +### 일반 설명 + +**"쓴 돈 ≥ 예산이면 멈춘다"는 넘은 뒤에 멈춥니다.** 예산이 조금이라도 남아 있으면 다음 요청은 평소대로 답하고, 그 요청의 비용만큼 예산을 넘습니다. 그래서 실제로 쓴 돈은 "예산 + 마지막 한 건"까지 갑니다. 동시에 여러 요청이 들어오면 여러 건만큼 넘을 수도 있습니다. + +| 방식 | 어떻게 | 넘을 수 있나 | +| --- | --- | --- | +| 넘은 뒤 멈춤 (오늘) | 쓴 돈 ≥ 예산이면 멈춤 | 마지막 몇 건만큼 | +| 미리 잡기 (reserve) | 부르기 전에 "최대 비용"만큼 예산에서 떼어 두고, 끝나면 실제 비용으로 정산 | 넘지 않음 | +| 여유 두기 | 남은 돈 < 한 건의 최대 비용이면 멈춤 | 거의 넘지 않음 | + +미리 잡기 · 여유 두기를 하려면 **한 건의 최대 비용**을 알아야 합니다. 그것을 정해 주는 것이 2단계의 입력 상한 · 출력 상한입니다. **상한이 있어야 예산도 지킬 수 있습니다.** + +**경고는 멈추기 전에.** 예산의 80% · 90%에 닿으면 담당자에게 먼저 알립니다 (76일차 알림). 멈춘 뒤에 알게 되면 직원들이 이미 원문만 받고 있습니다. 클라우드 공급자의 예산 기능도 대개 "몇 % 넘으면 알림"을 함께 둡니다. + +| 단계 | 행동 | +| --- | --- | +| 80% | 담당자에게 알림 | +| 100% | 낮춰서 답하기 (원문) | +| 그 뒤 | 다음 날 0시에 다시 0부터, 또는 사람이 예산을 늘림 | + +**하루가 바뀌면 누가 0으로 돌리나.** "하루 예산"은 "오늘 0시부터 쓴 돈"을 세야 합니다. 기록에 시각이 있으면 세는 쪽이 오늘 것만 더합니다. 사람이 매일 손으로 지우는 방식은 빠뜨리기 쉽습니다. 그리고 "오늘"이 어느 시간대 기준인지도 정해 둡니다 (서버 시각과 사무실 시각이 다를 수 있음). + +**갑자기 늘면 먼저 알아챕니다.** 예산 안이라도 평소의 몇 배로 갑자기 늘면 무언가 잘못된 신호입니다 (고장 난 루프, 누가 자동으로 부름, 긴 입력 공격). 시간대별 · 사람별 사용량을 평소와 비교해 크게 벗어나면 알리는 것을 **이상 탐지(anomaly detection)**라고 합니다. 단순하게는 "지난 7일 같은 시간대 평균의 3배를 넘으면 알림" 같은 규칙으로 시작합니다 (76 · 77일차). + +**넘어서 낮춘 답도 셉니다.** 낮춰서 답한 요청은 LLM을 부르지 않아 **비용 기록에 남지 않습니다.** 그러면 "오늘 몇 명이 원문만 받았나"를 셀 수 없고, 예산이 적절했는지도 판단할 수 없습니다. 비용이 0인 요청도 한 줄 남기는 것이 좋습니다. + +### 오늘 실습에서 + +Lab 03 예제 3 (수업 준비 때, 예산 $0.0003): + +| 질문 | 결과 | 쓴 돈 (누적) | +| --- | --- | --- | +| 1 · VPN | `자동 답변` $0.000141 | $0.000141 | +| 2 · 프린터 | `자동 답변` $0.000152 | $0.000293 (예산 아래) | +| 3 · 비밀번호 | `자동 답변` $0.000136 | **$0.000430 (넘음)** | +| 4 · 5 · 6 | **`한도 초과` · $0** | 그대로 | + +예산을 $0.00013, **43%** 넘었습니다. `usage`의 route별 합계에는 `자동 답변` 세 줄만 있고, 한도 초과 세 건은 기록에 없었습니다. `--reset`($1.0) 뒤에는 여섯 모두 `자동 답변`. + +## 5. 비용 보고서 (6교시 Lab 03 과제 1) + +### 일반 설명 + +**보고서의 숫자는 코드가 계산합니다.** 합계 · 평균 · 곱셈은 코드가 **틀리지 않고 · 공짜로 · 늘 같게** 합니다. LLM에게 숫자 계산을 맡기면 틀릴 수도, 돈도 들고, 같은 입력에 다른 숫자가 나올 수도 있습니다. LLM은 숫자를 **설명하는** 데 씁니다 (79일차: "이번 달은 지난달보다 왜 늘었나"를 글로). + +| 일 | 맡길 곳 | +| --- | --- | +| 합 · 평균 · 예측 · 순위 | 코드 | +| 숫자를 읽기 쉬운 글로 설명 | LLM (숫자는 코드가 준 것만 쓰게) | +| 무엇을 할지 결정 | 사람 | + +**비용 보고서에 들어갈 것.** + +| 칸 | 질문 | 오늘 | +| --- | --- | --- | +| 합계 · 요청 수 · 호출 수 | 얼마를 · 몇 번 | `cost_usd` · `requests` · `calls` | +| 요청 하나의 평균 | 단위 비용 | `avg_usd` (요청 수로 나눔) | +| 모델별 · 사람별 | 어디서 · 누가 | `by_model` · `top_user` | +| 한 달 예상 | 이대로면 얼마 | `projected_month_usd` | +| 예산 대비 | 남았나 · 넘었나 | `remaining_usd` · `over_budget` | + +**예측은 단순한 것부터.** 오늘의 한 달 예상은 "평균 × 하루 요청 × 일하는 날"입니다. 실제로는 요일 · 월말 · 신입 입사 같은 패턴이 있어서, 기록이 쌓이면 지난 몇 주의 추세로 예측합니다. 하지만 처음에는 **가정을 밝힌 단순한 식**이 낫습니다. 복잡한 예측은 맞았는지 틀렸는지 설명하기 어렵습니다. + +**보고서는 정해진 때에 봅니다.** 비용 보고서는 만들어 두는 것보다 **정기적으로 보는 것**이 중요합니다. 매주 · 매달 초에 같은 칸(합계 · 평균 · 모델별 · 사람별 · 예산 대비)을 보고, 지난번과 다르면 까닭을 적습니다. FinOps의 "운영하기" 단계가 이것입니다. 숫자가 자동으로 나오면 사람은 **판단**에 시간을 씁니다. + +**정확한 숫자를 테스트합니다.** LLM 답은 `contains` · `range`로 시험했지만, 보고서는 입력이 정해지면 결과가 **정확히 하나**입니다. 그래서 알려진 기록 몇 줄을 넣고 `equals`로 소수점까지 봅니다. 반올림 자리와 순서(반올림은 마지막에)를 계약으로 정해 두지 않으면 0.0019890000001 같은 값에서 실패합니다. + +### 오늘 실습에서 + +Lab 03 과제 1 (수업 준비 때): 테스트가 넣은 네 줄로 요청 3 · 호출 4 · $0.001989 · 평균 $0.000663 · 100건 × 22일 **$1.4586** · 가장 많이 쓴 사람 박서연 · 남은 예산 $0.998011 — 손 계산과 정확히 같았습니다 (5/5). 그다음 실제 사용량(과제 1 정답의 일곱 질문)으로 하루 1,000건을 넣으면 평균 $0.000127, **한 달 $2.80**. 1단계 예제 3의 어림 $20.8에서 약 7분의 1입니다. + +## 6. 바꾸면 얼마: what-if (6교시 Lab 03 과제 2) + +### 일반 설명 + +**바꾸기 전에 계산합니다.** 모델을 바꾸는 것은 74일차처럼 릴리스 · 게이트 · 카나리를 거치는 일입니다. 그 전에 "바꾸면 얼마 아끼나"를 알면 **해 볼 만한지** 먼저 정할 수 있습니다. 기록에는 모델마다 입력 · 출력 토큰이 있으니, 단가만 바꿔 다시 곱하면 됩니다. 이렇게 "만약 ~라면"을 계산하는 것을 **what-if 분석** · 시나리오 분석이라고 합니다. + +```text +what-if 비용 = (기록된 입력 토큰 합 × 새 모델 입력 단가 + 기록된 출력 토큰 합 × 새 모델 출력 단가) ÷ 1,000,000 +아끼는 돈 = 지금 비용 − what-if 비용 (음수면 더 비쌈) +``` + +**가정을 밝힙니다.** what-if는 **같은 토큰 수 · 같은 품질**을 가정합니다. 실제로는 다릅니다. + +| 가정 | 실제 | +| --- | --- | +| 토큰 수가 같다 | 모델마다 답 길이 · 토큰 나누는 법 · 추론 토큰이 다름 | +| 품질이 같다 | 형식 · 군더더기 · 정확도가 달라질 수 있음 (2단계 4절) | +| 요청 수가 같다 | 싸지면 더 많이 쓰기도 함 | + +그래서 what-if는 "**아끼는 돈의 대략**"이고, 바꿀지는 품질 게이트로 정합니다. 반대로 아끼는 돈이 **음수**(더 비싼 모델)거나 아주 작으면, 게이트를 돌릴 것도 없이 바꿀 까닭이 없다는 것이 바로 보입니다. + +**여러 값을 넣어 봅니다 (민감도).** what-if는 한 숫자보다 **여러 가정**을 나란히 볼 때 쓸모가 큽니다. 하루 요청이 500 · 1,000 · 2,000일 때, 모델이 mini · nano일 때 한 달이 얼마인지 표로 놓으면, 어떤 가정이 결과를 가장 크게 흔드는지 보입니다. 대개 **요청 수**가 모델보다 크게 흔듭니다. 그래서 모델을 바꾸기 전에 "요청 수가 늘면 어떻게 할지"(한도 · 예산)를 먼저 정해 두는 것이 순서입니다. + +**모르는 이름은 막습니다.** 단가표에 없는 모델 이름을 0원으로 계산하면 "100% 아낀다"는 거짓 숫자가 나옵니다. 74일차 "없는 릴리스는 400"처럼, 모르는 이름은 오류와 알려진 목록으로 돌려보냅니다. + +### 오늘 실습에서 + +Lab 03 과제 2 (수업 준비 때): 테스트가 넣은 네 줄이 모두 nano였다면 $0.00117 (아끼는 돈 $0.000819, 한 달 $1.4586 → $0.858), 모두 mini였다면 $0.002016로 아끼는 돈 **−$0.000027**. 없는 모델 `gpt-9`는 400. 실제 사용량 일곱 건으로는 $0.000929 → nano였다면 $0.00061 (34% 덜). 2단계 예제 3에서 **실제로** 답 단계만 nano로 바꿔 잰 것은 15% 덜이었고 답의 형식이 나빠졌습니다 (구성이 달라 바로 비교할 수는 없음). 계산은 "해 볼 만한가", 결정은 "재고 읽고 게이트로". + +## 7. 내 쪽 자원 관리: 동시 실행 · 실행 기록 + +### 일반 설명 + +오늘 비용의 대부분은 API 토큰이지만, 워크플로우 엔진을 운영하는 쪽에서는 **내 서버의 자원**도 관리합니다 (1단계 2절). + +| 자원 | 무엇이 늘리나 | 관리 | +| --- | --- | --- | +| 동시 실행 | 몰리는 요청, 느린 API를 기다리는 실행 | 동시 실행 수 상한, 넘치면 큐 · 429 | +| 실행 기록 (입력 · 출력 저장) | 실행마다, 큰 데이터일수록 | 보관 기간 · 개수 정하기, 실패한 것만 남기기 | +| 로그 · 사용량 기록 | 요청마다 | 보관 기간, 원문 대신 숫자 | +| 컨테이너 메모리 · CPU | 위 모두 | 컨테이너 상한, `docker stats`로 보기 | + +n8n도 이것을 설정으로 둡니다. 예를 들어 동시에 도는 운영 실행 수의 상한(`N8N_CONCURRENCY_PRODUCTION_LIMIT`), 오래된 실행 기록을 지우는 설정(`EXECUTIONS_DATA_PRUNE` · `EXECUTIONS_DATA_MAX_AGE`), 성공한 실행은 저장하지 않는 설정 등입니다. 값은 서버 크기 · 요청 수 · 얼마나 오래 되짚어 봐야 하는지(77일차 로그 분석)를 보고 정합니다. + +**자원 비용도 같은 보고서에.** 서버 · 저장 공간처럼 켜 둔 만큼 드는 비용은 API 비용과 성질이 다르지만(1단계 2절), 한 서비스의 비용으로 함께 봐야 합니다. "요청 하나에 API $0.00016 + 서버 몫 얼마"처럼 합치면 직접 돌리기(self-host)와 API를 비교할 때도, 사용자가 늘 때 무엇이 먼저 늘어나는지 볼 때도 쓸모가 있습니다. + +**자원 한도도 "넘치면 무엇을 주나"를 정합니다.** 동시 실행이 꽉 찼을 때 새 요청을 무한히 기다리게 하면 사용자도 서버도 지칩니다. 3절의 429처럼 "잠시 뒤 다시"를 빨리 주는 것이 낫습니다. + +### 오늘 실습에서 + +오늘 실습은 강사 노트북 · 학생 노트북 한 대에서 한 사람이 부르므로 동시 실행 · 실행 기록의 상한은 바꾸지 않습니다. 1단계 예제 1의 `docker stats`(n8n 642.6MiB)를 기억해 두고, 76일차(실패한 실행 모으기) · 77일차(실행 기록 · 로그 분석)에서 다시 봅니다. + +## 8. 요청문(프롬프트)으로 + +**요청문(프롬프트)이란.** 실습에서 CLI 코딩 에이전트(codex · claude · agy)에게 보내는 글입니다. 에이전트는 이 글만 보고 n8n 워크플로우 JSON을 만들거나 고칩니다. 이 절은 이 단계에서 배운 것을 **요청문(프롬프트)에 어떤 칸으로 적을지** 실습 전에 정리하는 자리입니다. 실습 문서의 `▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대`가 이 칸을 실제 문장으로 만든 것이고, 빈칸 ①~④는 그 문장 속 정확한 값입니다. + +| 칸 | 에이전트에게 알려 주는 것 | 빠지면 생기는 일 | +| --- | --- | --- | +| 무엇을 | 만들거나 바꿀 기능 | 에이전트가 짐작해서 다른 것을 만듦 | +| 어디에 | 고칠 파일 · 노드 · 자리 | 엉뚱한 노드를 고치거나 새 파일을 만듦 | +| 어떤 모양으로 | 결과 JSON의 칸 이름 · 값 · 응답 코드 | 테스트가 보는 칸 이름과 달라 실패 | +| 그대로 · 하지 말 것 | 건드리면 안 되는 것 | 다른 곳까지 바뀌어 무엇 때문인지 모르게 됨 | +| 확인 | 통과해야 할 테스트 파일 | "다 됐다"를 판단할 기준이 없음 | + +67~71일차의 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)와 같은 뜻입니다. 파일 · 입력이 "어디에", 처리가 "무엇을", 결과 모양이 "어떤 모양으로", 기대 결과가 "확인"입니다. 72일차부터는 **이미 있는 워크플로우를 고치는** 요청이 많아 "그대로"가 따로 한 칸이 됐습니다. + +예산 · 한도 요청에서는 **"어디에"(순서)**가 특히 중요합니다. 같은 확인이라도 LLM 앞이냐 뒤냐, 데이터 조회 앞이냐 뒤냐에 따라 비용과 답이 달라집니다. 그리고 "값은 `/budget`에서 읽고 워크플로우가 바꾸지 않는다"를 "그대로"에 적습니다. + +| 요청문에 | Lab 03 예제 1 | Lab 03 예제 2 | Lab 03 과제 1 | Lab 03 과제 2 | +| --- | --- | --- | --- | --- | +| 무엇을 | 예산을 넘으면 원문 | 사람마다 1분 한도 | 비용 보고서 | 바꾸면 얼마 | +| 어디에 | `Get ticket` 뒤 (LLM 앞) `Within budget?` · `Over budget` | `Get budget` 바로 뒤 (가장 앞) `Recent requests` · `Under user limit?` | 새 파일 `my-work/cost-report.json` | `Get prices`, `Make report`, `Known model?` | +| 어떤 모양으로 | `한도 초과` · `llm_calls` 0 · `cost_usd` 0 | 429 `{route: 잠시 후, retry_after: 60, …}` | AGENTS.md 3절 보고서 (6자리) | `what_if: {model, cost_usd, saving_usd, projected_month_usd}`, 없는 모델 400 | +| 그대로 | 평소 갈래 · 예산 값은 읽기만 | 평소 갈래 | LLM 없이 | 과제 1의 결과 | +| 확인 | `cases/lab03.json` | `cases/lab03-limit.json` · `lab03` · `lab02-challenge` | `cases/lab03-challenge.json` | `cases/lab03-challenge2.json` · `lab03-challenge` | + +예를 들어 Lab 03 예제 2 열을 위에서부터 이어 읽으면 요청문(프롬프트)이 됩니다 (화살표 오른쪽은 다섯 칸 중 어디인지): + +```text +같은 사람이 1분에 한도를 넘게 부르면 잠시 막아 줘. ← 무엇을 +my-work/cost.json 의 Get budget 바로 뒤, 가장 앞에 Recent requests 와 Under user limit?. ← 어디에 +넘으면 HTTP 429 {route: '잠시 후', answer, retry_after: 60, llm_calls: 0, cost_usd: 0}. ← 어떤 모양으로 +한도 안이면 지금 갈래 그대로, 한도 값은 /budget 의 user_limit_per_min 을 읽기만. ← 그대로 +cases/lab03-limit.json, cases/lab03.json, cases/lab02-challenge.json 이 통과해야 해. ← 확인 +``` + +## 9. 자주 하는 실수 + +| 실수 | 왜 문제인가 | 이렇게 | +| --- | --- | --- | +| 예산을 넘으면 "오류" | 직원이 아무 도움도 못 받음 | 데이터 원문으로 낮춰서 답 | +| 예산 확인을 LLM 뒤에 | 이미 돈을 쓴 뒤 | LLM 앞, 데이터 조회 뒤 | +| 한도 확인을 맨 뒤에 | 막을 요청도 조회 · LLM을 함 | 가장 앞 | +| 장애와 예산을 같은 `route`로 | 원인을 가를 수 없음 | `간단 답변` · `한도 초과` · `잠시 후` | +| 예산 · 한도를 워크플로우가 바꿈 | 누가 정했는지 모름, 사고 | 사람이 `/budget`에, 워크플로우는 읽기만 | +| 상한 없이 예산만 | 마지막 한 건이 얼마나 넘을지 모름 | 입력 · 출력 상한으로 최대 비용 | +| 보고서 숫자를 LLM이 | 틀림 · 돈 · 흔들림 | 코드가 계산, LLM은 설명만 | +| 평균을 호출 수로 나눔 | 한 달이 작게 나옴 | 요청 수로 | +| what-if를 그대로 믿고 바꿈 | 토큰 · 품질이 다를 수 있음 | 재고 · 읽고 · 게이트로 | + +## 확인 질문 + +1. 공급자의 rate limit이 있는데 내 서비스 안에 사람별 한도를 또 두는 까닭은? +2. 예산을 넘었을 때 FAQ 원문으로 답하는 것과 72일차 LLM 장애 때의 간단 답변은 무엇이 같고 무엇이 다른가요? +3. 예산 확인과 사람별 한도 확인은 워크플로우의 다른 자리에 둡니다. 각각 어디이고 왜인가요? +4. 예산 $0.0003에서 실제로는 $0.000430까지 썼습니다. 왜 넘었고, 넘지 않게 하려면 무엇이 필요한가요? +5. 비용 보고서의 숫자를 LLM에게 계산하게 하면 안 되는 까닭 셋은? +6. what-if가 "모두 nano면 34% 덜"이라고 했습니다. 이 숫자만 보고 바꾸면 안 되는 까닭은? + +## 정리 + +- [ ] 넘치지 않게: 예산(전체) · 속도 제한(사람 · 짧은 시간) · 요청 상한(요청 하나) +- [ ] 예산을 넘으면 LLM 없이 데이터 원문으로, 확인은 LLM 앞 · 데이터 뒤, `route`는 원인별로 +- [ ] 사람별 한도는 가장 앞, 429와 `retry_after`, 값은 사람이 정하고 워크플로우는 읽기만 +- [ ] 예산은 넘은 뒤 멈춘다 — 경고는 먼저, 넘지 않으려면 상한으로 최대 비용 +- [ ] 보고서 · what-if는 코드가 계산하고, 가정을 밝히고, 바꿀지는 품질 게이트로 diff --git a/Lecture/75/04-STAGE4-WRAPUP.md b/Lecture/75/04-STAGE4-WRAPUP.md new file mode 100644 index 0000000..9b15b99 --- /dev/null +++ b/Lecture/75/04-STAGE4-WRAPUP.md @@ -0,0 +1,212 @@ +# 4단계 · 정리: 오늘 한 일의 의미 · 효과 · 실전 응용 + +- 교시: 8교시 17:10~17:45 (35분, 바이브 코딩 발표 10분 뒤) · 자료: 이 문서 + [개요](00-DAY75-OVERVIEW.md) 30장 +- 이 시간이 끝나면: 오늘 1~3단계에서 한 일이 **무엇을 지키기 위한 것이었는지**, 숫자로 **무엇을 줄였고 무엇을 치렀는지**, 회사에서는 같은 생각을 **어떤 이름과 도구로** 쓰는지 말할 수 있고, 최종 프로젝트에 무엇을 가져갈지 정할 수 있다. +- 숫자: 오늘 교안의 실측(강사 노트북, 10/28 정답 워크플로우 1회, 단가는 2026-10-28 OpenAI 모델 문서)입니다. "예시"라고 적은 계산은 가정을 둔 어림입니다. + +```mermaid +flowchart LR + A["1. 오늘 한 장"] --> B["2. 왜 비용이
운영의 일인가"] --> C["3. 효과와 값을 숫자로"] --> D["4. 회사에서 부르는 이름"] + D --> E["5. 산업별 응용"] --> F["6. 비용 대시보드"] --> G["7. 하지 말아야 할 때"] --> H["8. 직무 · 면접"] --> I["9. 최종 프로젝트로"] +``` + +## 0. 이 시간 (35분) + +| 시간 | 절 | 하는 일 | +| --- | --- | --- | +| 17:10~17:15 | 1 · 2 | 오늘 한 장, 왜 비용이 운영의 일인가 | +| 17:15~17:20 | 3 | 오늘 숫자로 본 효과와 값, 한 달로 보면 (예시) | +| 17:20~17:28 | 4 · 5 | 같은 생각의 업계 이름 · 도구, 산업별 응용 예 | +| 17:28~17:33 | 6 · 7 | 비용 지표 여섯 개, 하지 말아야 할 때 | +| 17:33~17:40 | 8 · 9 | 직무와 면접 질문, 최종 프로젝트 체크리스트 | +| 17:40~17:45 | 10 · 토론 | 다음 일차와의 연결, 질문 둘 | + +## 1. 오늘 한 장 + +| 단계 | 한 일 | 오늘 숫자 | +| --- | --- | --- | +| 1단계 · 재기 | 자원 · 토큰 · 비용, 단계별 `cost_usd`, 종류별 한 달 어림, 요청마다 기록, 기록이 멈춰도 답 | VPN 하나 **$0.00104** (입력의 **89%**가 답 단계), 한 달 어림 **$20.8**, 기록 느림 11.2초 → **6.1초** | +| 2단계 · 줄이기 | 보내는 것 · 단계마다 모델 · 답까지 nano? · LLM 한 번 · 긴 질문 상한 | 답 단계 입력 1,781 → **165**, VPN $0.001007 → **$0.000156**, 답 nano는 **15%** 덜 들지만 형식이 나빠짐, 긴 질문 $0.001196 → **$0.000237** | +| 3단계 · 지키기 | 하루 예산 → 원문, 1분 한도 → 429, 예산 바꿔 보기, 보고서, what-if | 한도 초과 **$0**, 429 **0.0초**, 예산 $0.0003을 **43%** 넘음, 실제 사용량 한 달 **$2.80**, what-if nano **34%** 덜 | +| 바이브 코딩 | 부르기 전에 토큰 · 비용을 계산하는 `tcost` (TODO → SPEC → IMP-PLAN → 설치) | 요구 14개 | + +한 문장으로: **질문 하나에 얼마인지 재고, 품질 테스트를 지키며 한 칸씩 줄이고, 넘치지 않게 예산 · 한도 · 상한을 둔다.** + +## 2. 왜 비용이 운영의 일인가 + +72일차는 "빠르게, 멈춰도 답하게", 73일차는 "속지 않게, 새지 않게", 74일차는 "바꿔도 망가지지 않게"였습니다. 오늘은 "**써도 감당할 수 있게**"입니다. LLM 서비스는 쓰는 사람이 늘수록 비용이 **그대로 따라 늘고**, 한 사람 · 한 지시문의 실수가 모든 요청의 비용을 바꿉니다. + +| | 만들기 | 운영하기 (비용 관리) | +| --- | --- | --- | +| 질문 | "답이 맞나?" | "질문 하나에 얼마? 한 달에 얼마? 넘치면?" | +| 기준 | 테스트 통과 | 같은 테스트를 지키면서 **비용 전 · 후** | +| 실패 | 오류 | **청구서** (오류처럼 보이지 않음) | +| 정하는 사람 | 개발자 | 개발자 + 재무 · 사업 (예산) + 사용자 (질문 길이) | + +- 비용 사고는 대개 **조용합니다.** `base-cost`는 모든 테스트를 통과하면서 질문마다 필요 없는 규정 약 1,600토큰을 보냈고, 긴 로그 질문은 아무 오류 없이 평소의 7배 넘게 썼습니다. 재지 않으면 청구서가 올 때까지 모릅니다. +- 비용을 줄이는 일은 **품질과의 맞바꿈**이 되기 쉽습니다. 답까지 nano로 바꾼 비교본은 테스트를 다 통과했지만 답의 형식이 나빠졌습니다. 그래서 숫자와 함께 **답을 읽고**, 결정은 사람이 합니다. + +## 3. 효과와 값을 숫자로 + +### 3.1 오늘 실측 전 · 후 + +| 무엇 | 전 | 후 | 바꾼 것 | +| --- | --- | --- | --- | +| VPN 질문 하나의 비용 | $0.001040 (3 calls) | **$0.000156** (1 call) | 보내는 것 · 단계마다 모델 · 규칙 분류 · 검토 빼기 | +| 답 단계 입력 토큰 | 1,781 | **165** | 규정 전체 빼기, 필요한 칸만 | +| 질문 3개의 기록 합계 | $0.002951 (calls 9) | **$0.000409** | 위와 같음 | +| 긴 로그 질문 (6,298자) | $0.001196 | **$0.000237** | 입력 상한 (앞 200 + 뒤 300자) | +| 기록 API가 느릴 때 | 11.2초 | **6.1초** | timeout 3초 + 실패해도 계속 | +| 예산을 넘은 질문 | LLM을 부름 | **$0 · 원문** | 예산 확인 (LLM 앞) | +| 1분 한도를 넘은 요청 | 200 · $0.000156 | **429 · 0.0초 · $0** | 한도 확인 (가장 앞) | + +**치른 값**: + +- **규정에만 있는 질문**은 이제 "찾지 못했습니다"입니다 (규정 전체를 뺐으므로). 필요하면 검색해서 관련 부분만 보내는 일(71일차)을 따로 해야 합니다. +- **검토 단계**를 뺐으므로 말투 · 형식은 답 단계의 지시문에만 달렸습니다. +- **기록이 빠진 요청**은 예산 · 한도에서도 빠집니다 (`recorded: false`로 셀 수 있게만 함). +- **예산은 넘은 뒤 멈춥니다** (오늘 43% 넘음). 넘지 않으려면 최대 비용으로 미리 잡는 방식이 필요합니다. + +### 3.2 한 달로 보면 (예시) + +가정: 하루 질문 1,000건 · 한 달 22일 (오늘 실습의 가정, 질문 종류의 비중은 실습에서 쓴 FAQ 60 · 접수 30 · 그 밖 10%). + +| | 아침 (`base-cost`) | 저녁 (Lab 02 과제 1 정답) | +| --- | --- | --- | +| 요청 하나의 평균 | $0.000946 (가중 평균, Lab 01 예제 3) | $0.000127 (실제 일곱 질문, Lab 03 과제 1) | +| 한 달 | **약 $20.8** | **약 $2.80** | +| 하루 예산 $1.0이면 | 하루 약 1,050건에서 멈춤 | 하루 약 7,900건까지 | + +- 두 평균은 재는 방법이 조금 다릅니다 (아침은 가정한 비중, 저녁은 실제 일곱 질문). 그래도 **약 7분의 1**이라는 크기는 같은 질문 하나($0.00104 → $0.000156)에서도 보입니다. +- 사용자가 10배가 되면 아침 기준은 한 달 $208, 저녁 기준은 $28입니다. **요청 하나의 비용**을 줄이는 것이 규모가 커질수록 중요한 까닭입니다. + +## 4. 회사에서는 같은 생각을 이렇게 부른다 + +| 오늘 한 것 | 업계 이름 | 쓰는 도구 · 표준 (예) | +| --- | --- | --- | +| 재기 → 줄이기 → 지키기 | FinOps (Inform · Optimize · Operate) | FinOps Foundation 프레임워크, 클라우드 비용 관리 화면 | +| 응답의 `usage`, 단계별 비용 | LLM 관측 (observability), 비용 할당 | Langfuse, Helicone, LangSmith, OpenTelemetry GenAI 규칙 | +| 요청마다 기록 · 사람별 합 | 계량 (metering), showback · chargeback | 사용량 기록 테이블, 태그 · 레이블 | +| 단가표를 밖에 · 모르는 모델은 오류 | 가격 카탈로그, 설정 외부화 | 공급자 가격 페이지, 사내 설정 저장소 | +| 필요한 것만 보내기 | 컨텍스트 엔지니어링, 데이터 최소화, RAG | 검색 색인, 프롬프트 템플릿 | +| 단계마다 맞는 모델 | 모델 라우팅 · 계단식(cascade) | LiteLLM, OpenRouter, 클라우드 모델 게이트웨이 | +| 같은 앞부분 할인 · 모아서 보내기 | 프롬프트 캐싱, 배치 처리 | 공급자의 캐시된 입력 단가, Batch API | +| 규칙으로 먼저 · 검토 빼기 | 호출 줄이기, 규칙 기반 라우팅 | Code 노드, 의도 분류 규칙 | +| 입력 · 출력 상한 | 요청 크기 제한, 무제한 소비 방지 | `max_completion_tokens`, OWASP LLM Top 10의 Unbounded Consumption (Denial of Wallet) | +| 하루 예산 · 원문으로 낮추기 | 예산 · 알림, 우아한 저하 | 클라우드 예산 알림(AWS Budgets 등), 공급자 사용 한도 | +| 1분 한도 · 429 | 속도 제한, 할당량 | API 게이트웨이(Kong · AWS API Gateway usage plan), nginx `limit_req`, Redis 카운터 | +| 비용 보고서 · what-if | 비용 보고 · 예측, 시나리오 분석 | 스프레드시트 · BI 대시보드, 월간 비용 리뷰 | +| `tcost` | 부르기 전 토큰 계산 | `tiktoken`, 공급자 token count API | + +- 오늘은 n8n 노드 · 작은 데이터 API · `flow.py`로 직접 만들었습니다. 회사에서는 게이트웨이 · 관측 도구가 대신해 주는 경우가 많습니다. 그래도 **무엇을 재고, 어느 칸부터 줄이고, 예산 · 한도를 얼마로 둘지**는 오늘처럼 사람이 정합니다. + +## 5. 산업별 응용 (예) + +아래는 오늘 한 일을 다른 업무에 옮긴 예입니다 (실제 특정 회사의 사례가 아니라 흔한 모양). + +| 업무 | 오늘의 무엇을 | 이렇게 | +| --- | --- | --- | +| 쇼핑몰 상담 챗봇 | 규칙으로 먼저 · 단계마다 모델 | 주문 번호가 있으면 조회만, 일반 문의만 LLM, 분류는 작은 모델 | +| 콜센터 통화 요약 | 입력 상한 · 배치 | 긴 통화는 나눠 요약, 실시간이 아닌 요약은 모아서 밤에 (배치 할인) | +| 사내 문서 검색 (71일차) | 보내는 것 줄이기 · 비용 보고서 | 문서 전체 대신 검색 결과 상위 몇 개만, 부서별 사용량을 매달 보고 | +| SaaS의 AI 기능 (고객사별) | 사람별 한도 · 할당량 · showback | 요금제마다 월 몫, 넘으면 429 · 업그레이드 안내, 고객사별 비용으로 가격을 정함 | +| 사내 IT 헬프데스크 (오늘) | 전부 | 예산을 넘으면 FAQ 원문, 몰아 부르면 429, 매달 보고서 | +| 개발팀 코드 리뷰 봇 | what-if · 모델 라우팅 | 작은 변경은 작은 모델, 큰 변경만 큰 모델, 바꾸기 전에 지난달 기록으로 what-if | + +**토론 거리**: 내 회사(또는 가고 싶은 회사)의 AI 기능 하나를 골라, "요청 하나에 얼마일까 · 가장 큰 몫은 어디일까"를 어림해 봅니다. + +## 6. 비용 대시보드에 올릴 숫자 여섯 + +오늘 기록(`/usage`)에 남긴 칸만으로 비용 지표가 됩니다 (77일차 로그 분석 · 79일차 운영 대시보드에서 실제로 모읍니다). + +| 지표 | 어느 칸에서 | 무엇을 알 수 있나 | +| --- | --- | --- | +| 요청 하나의 평균 비용 | `cost_usd` ÷ 요청 수 | 단위 비용이 늘었나 (지시문 · 모델 변경 뒤) | +| 요청당 호출 수 | `calls` ÷ `requests` | 재시도 · 단계가 늘었나 | +| 모델별 비용 비중 | `by_model` | 비싼 모델이 어디서 쓰이나 | +| 사람 · 팀별 상위 | `by_user` | 몰아 쓰는 사람 · 자동화가 있나 | +| 경로별 수 | `by_route` (`자동 답변` · `한도 초과` · `잠시 후`) | 예산 · 한도가 너무 빡빡하거나 느슨한가 | +| 예산 대비 · 한 달 예상 | `/budget`, 보고서 | 이번 달 안에 넘을까 | + +- 오늘 기록 한 줄에 `request_id` · `user` · `step` · `model` · `route`를 넣게 한 까닭이 이것입니다. **나눠 볼 칸이 없으면 줄일 곳도 찾지 못합니다.** + +## 7. 하지 말아야 할 때 (흔한 실수) + +| 하기 쉬운 일 | 왜 위험한가 | 대신 | +| --- | --- | --- | +| 재지 않고 줄임 | 엉뚱한 곳을 줄이고 무엇을 잃었는지 모름 | 단계별 토큰부터 | +| 세 칸을 한꺼번에 | 무엇 덕분 · 무엇 때문인지 모름 | 한 칸씩, `COST.md`에 한 줄 | +| 싸지면 무조건 좋다 | 품질이 몰래 나빠짐 (답 nano의 형식) | 테스트 + 답 읽기, 맞바꿈은 사람이 | +| 단가를 코드에 숫자로 | 가격 · 모델이 바뀌면 조용히 틀림 | 단가표, 확인한 날짜 | +| 모르는 모델을 0원으로 | "100% 아낀다"는 거짓 | 오류 · 알려진 목록 | +| 입력 길이를 그대로 받음 | 한 사람이 예산을 다 씀 | 입력 · 출력 상한 | +| 예산을 넘으면 오류 | 직원이 아무 도움도 못 받음 | 데이터 원문으로 낮춰서 | +| 보고서 숫자를 LLM이 | 틀림 · 돈 · 흔들림 | 코드가 계산, LLM은 설명 | +| what-if를 그대로 믿음 | 토큰 · 품질이 다를 수 있음 | 재고 · 읽고 · 게이트로 (74일차) | + +## 8. 직무와 면접 + +오늘 한 일은 다음 직무들이 날마다 하는 일입니다: **AI · LLM 애플리케이션 엔지니어, LLMOps · 플랫폼 엔지니어, FinOps · 클라우드 비용 담당, 백엔드 엔지니어, AI 서비스 기획 · 운영.** 채용 공고의 "LLM 비용 최적화", "토큰 사용량 관리", "프롬프트 최적화", "모델 선택 · 라우팅", "rate limiting", "FinOps"가 오늘 내용입니다. + +면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법: + +
1. "LLM 비용이 너무 많이 나옵니다. 어디부터 보나요?" + +먼저 요청 하나의 비용을 단계별로 나눠 봅니다. 오늘 헬프데스크는 VPN 질문 하나가 $0.00104였고, 응답의 `usage`를 단계별로 모아 보니 입력의 89%가 답 단계 하나(1,781토큰)에서 나왔습니다. 지시문에 사내 규정 전체를 붙여 보내고 있었기 때문입니다. 질문 종류가 달라도 비용이 비슷했던 것도 그 고정비 때문이었습니다. 가장 큰 몫부터 줄였습니다. +
+ +
2. "비용을 줄이면서 품질은 어떻게 지키나요?" + +줄이기 전에 품질 테스트를 고정하고, 한 번에 한 가지만 바꾸고, 바꿀 때마다 테스트와 함께 답을 직접 읽었습니다. 보내는 것을 줄이자 답 단계 입력이 1,781에서 165토큰, 비용이 $0.001007에서 $0.000346이 됐고 테스트는 그대로였습니다. 반대로 답 단계까지 작은 모델로 바꾼 비교본은 테스트를 다 통과했지만 마크다운 · 군더더기가 섞여서, 15%를 아끼는 대신 바꾸지 않기로 했습니다. +
+ +
3. "작은 모델로 바꾸면 얼마나 싸지나요?" + +단가표로 계산해 봐야 합니다. 오늘 두 모델은 입력이 절반, 출력은 0.78배라서 "몇 배"가 아니었습니다. 입력이 많은 단계일수록 효과가 큽니다. 쉬운 단계(분류 · 검토)만 바꾸고 답은 그대로 두었고, 바꾸기 전에는 기록된 토큰으로 what-if를 계산했습니다 (모두 작은 모델이면 34% 덜). what-if는 같은 토큰 · 품질을 가정하므로 결정은 품질 게이트로 합니다. +
+ +
4. "한 사용자가 API를 몰아 쓰거나 아주 긴 입력을 보내면?" + +세 겹으로 막습니다. 사람마다 1분 한도를 두고 넘으면 가장 앞에서 429와 `retry_after`를 줍니다 (0.0초 · $0). 입력에 글자 수 상한을 두어 6,000자 넘는 로그 질문이 $0.001196에서 $0.000237이 되게 했고, 출력에도 `max_completion_tokens` 상한을 둡니다. 그리고 하루 예산을 넘으면 LLM 없이 데이터 원문으로 답합니다. 두 상한이 있어야 요청 하나의 최대 비용을 말할 수 있고, 그래야 예산도 지킬 수 있습니다. +
+ +
5. "예산을 넘지 않게 하려면?" + +"쓴 돈이 예산 이상이면 멈춘다"는 넘은 뒤에 멈춥니다. 오늘 예산 $0.0003에서 세 번째 질문까지 답해 $0.000430, 43%를 넘었습니다. 넘지 않으려면 부르기 전에 한 건의 최대 비용만큼 예산에서 미리 떼어 두거나, 남은 돈이 그보다 적으면 멈춰야 합니다. 그 최대 비용은 입력 · 출력 상한으로 정해집니다. 그리고 80%에서 먼저 알림을 줍니다. +
+ +## 9. 최종 프로젝트로 가져갈 것 + +최종 프로젝트(85일차~)의 워크플로우에 아래를 넣으면 "운영을 생각한 프로젝트"가 됩니다. + +- [ ] LLM 호출마다 `usage`를 받아 단계 · 모델별 `cost_usd`, 단가는 밖에 (확인한 날짜와 함께) +- [ ] 요청마다 사용량 기록 (`request_id` · 사람 · 단계 · 모델 · 경로, 원문은 남기지 않음), 기록이 멈춰도 답은 나감 +- [ ] 질문 종류별 비용과 한 달 예상 (가정을 적어서) +- [ ] 보내는 것은 필요한 것만, 출력 상한 +- [ ] 단계마다 맞는 모델 (바꾸기 전에 단가표 · what-if, 바꾼 뒤 품질 테스트 · 답 읽기) +- [ ] 규칙으로 먼저, 쓰지 않는 단계는 빼기 +- [ ] 입력 상한 · 출력 상한 → 요청 하나의 최대 비용 +- [ ] 하루 예산(넘으면 낮춰서 답) · 사람별 한도(429), 값은 사람이 정하고 밖에 +- [ ] 비용 보고서 (코드가 계산), `COST.md`에 바꾼 것 · 아낀 것 · 잃은 것 + +## 10. 다음 일차와의 연결 + +| 일차 | 오늘의 무엇을 잇나 | +| --- | --- | +| 76 | 에러 처리 · 예외 대응 — 오늘 `recorded: false`로 남긴 빠진 기록 같은 실패를 모아 두고 다시 처리 · 알림 | +| 77 | 로그 분석 — 오늘의 사용량 기록을 사람별 · 경로별 · 시간대별로 분석, 비용이 갑자기 느는 때 찾기 | +| 78 | 성능 기준(SLO) — 비용 · 시간 · 품질을 함께 목표로 | +| 79 | 운영 대시보드 · `tops` — 6절의 비용 지표 여섯, `tcost`로 부르기 전 계산 | + +## 토론 (5분) + +1. 오늘 줄인 것 중 **품질과 맞바꾼 것**은 무엇이었나요? 그 맞바꿈은 누가 정해야 할까요? +2. 내 업무의 AI 기능에 하루 예산을 둔다면, 넘었을 때 사용자에게 무엇을 주는 것이 맞을까요 (원문 · 담당자 연결 · 잠시 후)? + +## 정리 + +- [ ] 비용도 **운영**: 써도 감당할 수 있게, 그리고 요청 하나의 비용을 안다 +- [ ] 효과는 같은 날 전 · 후 숫자로 ($0.00104 → $0.000156, 1,781 → 165토큰, 긴 질문 $0.0012 → $0.00024, 한도 초과 · 429는 $0) +- [ ] 같은 생각을 업계에서는 FinOps · LLM 관측 · 모델 라우팅 · 프롬프트 캐싱 · 속도 제한 · 예산 알림이라고 부른다 +- [ ] 기록의 칸이 곧 비용 지표가 된다 +- [ ] 최종 프로젝트에 체크리스트 9개를 가져간다 diff --git a/Lecture/75/05-VIBE-CODING.md b/Lecture/75/05-VIBE-CODING.md new file mode 100644 index 0000000..39a506c --- /dev/null +++ b/Lecture/75/05-VIBE-CODING.md @@ -0,0 +1,93 @@ +# 7·8교시 · 바이브 코딩 (`tcost`) + +- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 10분 · [4단계 정리](04-STAGE4-WRAPUP.md) 35분 · 예고 5분) +- 과제: [VIBE-REQ-TCOST](../../Last3Mon/75/VIBE-REQ-TCOST.md)(요청서, 요청의 말 ①~⑫) → [VIBE-Q-TCOST](../../Last3Mon/75/VIBE-Q-TCOST.md)의 8단계: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용 → 수업 뒤 `VIBE-A-TCOST.md` 공개 +- 요구 14개(R1~R14). 50분 안에는 **엔진 1단계(R1 · R2 · R3 · R13) + 엔진 2단계(R5 · R6) + TUI 1단계(R8) + 설치(R14)**, 나머지는 IMP-PLAN의 다음 단계로 집에서. +- 72일차 `tbench` · 73일차 `tscan` · 74일차 `tver`와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다. +- 오늘은 `tcost`: LLM을 부르기 **전에** 글 파일 · 워크플로우의 토큰과 비용을 계산하고, 테스트 보고서(`runs/*.json`)의 실제 사용량을 모아 한 달을 예상하는 TUI. OpenAI를 부르지 않습니다. 오늘 n8n을 돌려서 잰 것을 돌리기 전에 내 도구로. + +```mermaid +flowchart LR + R["요청서 → TODO · SPEC ·
IMP-PLAN 20분"] --> A["7교시
엔진 1 · 2 · TUI · 설치 30분"] --> B["발표
10분"] --> C["4단계 정리
35분"] --> F["76일차 예고
5분"] +``` + +> 이 문서는 7 · 8교시 진행 문서입니다. 8교시의 정리 35분은 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)으로 합니다 (74일차부터 4단계 정리를 따로 두었습니다). + +## 1. 7교시 · 각자 만들기 (50분) + +| 시간 | 단계 | 확인 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (손으로 계산한 금액 · 건너뛴 수) | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~4단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: 엔진 1단계 → 엔진 2단계 → TUI 1단계, 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tcost` | `cd ~/aio-01d4-stu/Last3Mon/75 && tcost usage runs --per-day 1000` | + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| TODO · SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 Doc/TODO.md부터. 손으로 계산한 금액이 SPEC에 없으면 맞았는지 모릅니다" | +| IMP-PLAN이 한 덩어리 · 첫 요청에 14개 | "엔진 1단계만. 단계마다 확인 · 체크 · commit" | +| 금액이 백만 배 | "단가는 1M 토큰당. ÷ 1,000,000" | +| 0.00056000000001 | "비용은 소수 8자리에서 반올림" | +| 모르는 모델이 $0 | "단가표에 없으면 오류, 목록을 보여 줘" | +| 테스트가 OpenAI를 부름 | "테스트는 네트워크 없이, 토큰은 tiktoken" | +| 보고서의 400 · 요약 케이스에서 오류 | "`usage.by_step`이 없는 결과는 skipped로 세고 넘어가기" | +| 평균이 이상하게 작음 | "호출 수가 아니라 요청 수로 나누기" | +| 설치한 `tcost`가 단가 파일을 못 찾음 | "prices.json을 wheel에 넣어 줘" | +| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tcost`" | + +정답 예(R1~R14를 모두 넣은 `tcost`와 정답본 `VIBE-A-TCOST.md`)는 수업 뒤 공개됩니다. 강사 바이브 코딩(4절)은 시간이 남으면 · 수업 뒤 하고, 걸린 시간을 정답본(`VIBE-A-TCOST.md`) 6절에 남깁니다. + +## 2. 8교시 · 발표 (10분) + +- 1~2명. **자기 `runs`의 실제 비용과 한 달 예상을 보여 줄 수 있는 사람**, 기준 · 내 워크플로우의 고정 지시문 토큰을 비교한 사람을 먼저 고릅니다. +- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / IMP-PLAN을 어떻게 나눴나 / 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳 / 시연. + +## 3. 강사 정리 (8교시 4단계 정리 안에서, 1절 표와 함께) + +| | n8n 헬프데스크 (오늘 Lab) | tcost | +| --- | --- | --- | +| 토큰 세기 | 응답의 `usage` (부른 뒤) | `tiktoken` (부르기 전) | +| 단가 | 데이터 API `/prices` | 단가 파일 (`checked` 날짜 · 출처) | +| 비용 식 | 단계마다 × 그 모델 단가, 8자리 (Lab 01 예제 2) | 같은 식 (R1 · R2) | +| 모으기 | `/usage/summary` (Lab 01 과제 1) | `runs/*.json` (R5) | +| 한 달 | 보고서 `projected_month_usd` (Lab 03 과제 1) | `--per-day --days` (R6) | +| 바꾸면 얼마 | what-if (Lab 03 과제 2) | `compare` (R7) | +| 모르는 모델 | 400 `unknown model` | 종료 코드 2 (R12) | + +- 풀이 거리: 긴 프린터 질문을 `tcost count --out 60`으로 미리 세면 2,613토큰 · $0.001141, n8n에서 실제로 돌리면 $0.001196이었습니다 (강사 본). 미리 센 값은 질문 글만이라 **하한**에 가깝습니다 (지시문 · FAQ가 더해짐). + +## 4. 강사 바이브 코딩 (시간이 남으면 · 수업 뒤, 15분) + +| 주제 | 요청문 요지 | 보여 줄 것 | +| --- | --- | --- | +| 실제와 맞춰 보기 | "보고서의 질문 글을 세어 prompt_tokens 와 차이를 % 로" | 내 셈과 공급자 셈의 차이 | +| 캐시 입력 단가 | "cached_tokens 가 있으면 cached input 단가로" | 같은 앞부분이 싸지는 것 | +| 비용 관문 붙이기 | "tcost workflow --max-usd 를 tver save 앞에" | 저장 = 비용 관문 통과 (74일차 정책과 같은 생각) | + +걸린 시간과 결과를 `Last3Mon/75/VIBE-A-TCOST.md` 6절에 적어 둡니다. + +## 5. 보충 · 팁 (읽을거리, 4단계 정리 7절과 함께) + +| 팁 | 설명 | +| --- | --- | +| 요청서 → 요구가 먼저 | 확인 방법(정확한 금액 · 건너뛴 수 · 종료 코드)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 | +| 숫자 검사는 쉬운 숫자로 | 1,000토큰 × $0.40 = $0.0004 같은 손 계산으로 먼저 테스트 | +| 날짜는 넘겨받게 | "오늘"을 함수 인자로 받으면 테스트가 날마다 같음 | +| 없는 것은 0이 아니라 skipped · 오류 | 0으로 세면 평균 · 비용이 거짓말을 함 | +| 단가는 파일로 | 코드에 박지 않고, 확인한 날짜와 함께 | +| 미리 센 값은 하한 | 실제 요청에는 실행 때 붙는 것이 더해짐 | +| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 | + +## 6. 76일차 예고 (5분) + +- 오늘: 재기 · 줄이기 · 예산과 한도. 내일: **에러 처리와 예외 대응, 안정적 운영 체계** — 오늘 기록 API가 멈췄을 때 답은 보냈지만 빠진 기록은 `recorded: false`로 남기기만 했습니다. 내일은 실패한 일을 모아 두고(DLQ) 알리고 다시 처리합니다. +- 바이브 코딩: `tretry`. 오늘 `tcost`는 지우지 마세요. 79일차 `tops`에서 씁니다. + +## 확인 (강사) + +- [ ] 16:00 요청서 → TODO(에이전트 없이) → SPEC → IMP-PLAN 20분을 지켰다, 16:20부터 엔진 1단계. +- [ ] 7교시 시작 전 시험 자료(`reference/baseline-cost.json` · `lab02-example1-lean.json`, 오늘 `runs/`)를 화면에 띄웠다. +- [ ] 수업 뒤: `VIBE-A-TCOST.md`의 강사 바이브 코딩 시간을 채우고, 요청이 있을 때 학생 저장소에 정답(LAB-A · `answers/` · VIBE-A · `tcost/`)을 올린다. +- [ ] 발표자 1~2명, 강사 정리 표, 팁, 예고. +- [ ] 수업이 끝날 때 학생들의 예산 · 기록 · 고장 스위치가 처음 상태인지 (`budget --reset`, `usage --clear`, `fault usage ok`, `doctor`). diff --git a/Lecture/75/README.md b/Lecture/75/README.md new file mode 100644 index 0000000..12bde83 --- /dev/null +++ b/Lecture/75/README.md @@ -0,0 +1,41 @@ +# 75일차 · 컴퓨팅 자원·API 사용량 관리와 비용 최적화 (바이브 코딩) + +- 일자: 2026-10-29 (75일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 운영 및 관리 역량 강화 +- 학습 내용: 컴퓨팅 자원 및 API 사용량 관리, 비용 최적화 전략 적용 +- 실습 폴더: [Last3Mon/75](../../Last3Mon/75/README.md) +- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`, `gpt-5.4-nano`) + 75일차 데이터 API(단가 · 사용량 기록 · 예산 · 한도) + **CLI 코딩 에이전트** +- **실습 준비: [00-DAY75-PREP.md](00-DAY75-PREP.md) (수업 전날까지 1~2절 완료)** +- 수업 형식: 1 · 3 · 5교시 강의, 2 · 4 · 6교시 실습 5개씩 (예제 3 · 과제 2), 7교시 바이브 코딩, 8교시 발표 · 4단계 정리 + +## 오늘의 시간표 + +| 교시 | 시간 | 내용 | 자료 | +| --- | --- | --- | --- | +| 1 | 09:00~09:50 | 개요 · 1단계 강의 · 사용량 재기 | [개요](00-DAY75-OVERVIEW.md) 1~14장, [1단계](01-STAGE1-MEASURE-USAGE.md) | +| 2 | 10:00~10:50 | Lab 01 실습 5개 (자원 · 토큰 · 비용 · 종류별 비용 · 기록 · 기록이 멈추면) | [Lab 01](../../Last3Mon/75/LAB-01-Q-MEASURE-USAGE.md) | +| 3 | 11:00~11:50 | 2단계 강의 · 비용 줄이기 | 개요 15~21장, [2단계](02-STAGE2-REDUCE-COST.md) | +| 4 | 12:00~12:50 | Lab 02 실습 5개 (보내는 것 · 모델 · 답까지 nano? · LLM 한 번 · 긴 질문) | [Lab 02](../../Last3Mon/75/LAB-02-Q-REDUCE-COST.md) | +| - | 13:00~14:00 | 점심 | | +| 5 | 14:00~14:50 | 3단계 강의 · 예산과 한도 | 개요 22~28장, [3단계](03-STAGE3-BUDGET-AND-LIMITS.md) | +| 6 | 15:00~15:50 | Lab 03 실습 5개 (하루 예산 · 1분 한도 · 예산 바꿔 보기 · 보고서 · what-if) | [Lab 03](../../Last3Mon/75/LAB-03-Q-BUDGET-AND-LIMITS.md) | +| 7 | 16:00~16:50 | 바이브 코딩 `tcost`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TCOST](../../Last3Mon/75/VIBE-REQ-TCOST.md), [진행](05-VIBE-CODING.md) | +| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 76일차 예고 | [4단계 정리](04-STAGE4-WRAPUP.md), [진행](05-VIBE-CODING.md) | + +```mermaid +flowchart LR + P["개요"] --> S1["1단계
자원 · 토큰 · 비용
종류별 · 기록"] --> S2["2단계
보내는 것 · 모델
호출 수 · 긴 입력"] --> S3["3단계
예산 · 한도
보고서 · what-if"] --> V["7교시
tcost"] --> W["8교시
4단계 정리"] +``` + +## 자료 목록 + +| 파일 | 내용 | +| --- | --- | +| `00-DAY75-PREP.md` | 실습 준비: n8n · 75일차 판 데이터 API 켜기, 기준(`base-cost`) 게시, `flow.py doctor` (수업 전) | +| `00-DAY75-OVERVIEW.md` | 개요 30장 (PPT 대신 Markdown, 가로줄로 장 구분) | +| `01-STAGE1-MEASURE-USAGE.md` | 왜 비용인가 · FinOps, 자원, 토큰과 단가, 토큰이 보이는 호출, 단계별 비용 · 단가는 밖에, 종류별 비용 · 한 달, 기록, 기록이 멈추면 (실제 사례) | +| `02-STAGE2-REDUCE-COST.md` | 비용의 세 칸, 보내는 것 줄이기, 단계마다 맞는 모델, 싸졌는데 나빠졌다면, 하지 않은 호출, 긴 입력의 상한, 줄인 것을 남기기 | +| `03-STAGE3-BUDGET-AND-LIMITS.md` | 넘치지 않게, 하루 예산 · 낮춰서 답하기, 사용자별 한도 · 429, 예산은 넘은 뒤 멈춘다, 비용 보고서, what-if, 내 쪽 자원 관리 | +| `04-STAGE4-WRAPUP.md` | 8교시 35분: 오늘의 의미 · 효과 숫자 · 업계 이름 · 산업별 응용 · 비용 지표 · 면접 · 최종 프로젝트 체크리스트 | +| `05-VIBE-CODING.md` | 7 · 8교시 바이브 코딩 진행 (tcost), 발표, 강사 정리, 강사 바이브 코딩, 팁, 예고 |