75일차 자료 추가 (답 제외): 실습 15개 · 실행 결과, gpt-5.4-nano · 단가, 교안 개요 30장 · 4단계 정리, 바이브 코딩 tcost 요청서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-28 22:38:01 +09:00
1 parent d663d045e3
commit 01dc892303
70 files changed
+10890

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+176
View File
@@ -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/<path>` |
| 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/<id>?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 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+566
View File
@@ -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<br/>자원 · 단계별 토큰"] --> E2["예제 2<br/>cost_usd 계산"] --> E3["예제 3<br/>질문 종류별 비용<br/>한 달 어림"] --> T1["과제 1<br/>요청마다 기록"] --> T2["과제 2<br/>기록이 멈춰도<br/>답은 나감"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 오늘은 호출마다 실제로 돈이 듭니다 (질문 하나에 약 $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<br/>입력 · 출력"}} --> A{{"answer<br/>입력 · 출력"}} --> R{{"review<br/>입력 · 출력"}} --> O(["답 + usage"])
D[("사내 규정 전체")] -.->|매번 붙여 보냄| A
```
**무엇을 보나.** `usage.by_step`은 단계마다 입력 · 출력 토큰입니다. 세 단계 중 **한 단계의 입력**이 전체의 대부분이라면, 줄일 곳이 그곳입니다 (Lab 02 예제 1). VPN 질문과 접수 질문의 입력이 비슷하다면, 질문과 관계없이 **매번 같이 보내는 것**이 있다는 뜻입니다.
**기대 결과.** 세 단계 중 `answer`의 입력이 가장 많고, 질문 종류가 달라도 입력 토큰이 비슷합니다. `COST.md` 2절에 VPN 질문의 단계별 토큰을 적습니다.
<details><summary>▶ 1단계 · 자원은 무엇을 보나</summary>
질문을 보내기 전과 보낸 직후에 `docker stats --no-stream`을 한 번씩 봅니다. CPU는 오르나요? n8n의 메모리는 몇 MiB인가요? 실행 기록이 수만 건 쌓이면 무엇이 늘까요?
</details>
<details><summary>▶ 2단계 · 계산해 보기</summary>
단가는 **1M(1,000,000) 토큰당** 달러입니다. VPN 질문의 입력 · 출력 토큰 합을 `gpt-4.1-mini` 단가로 계산하면? 하루 1,000개 · 한 달 22일이면? (손으로 한 번 해 보면 예제 2의 식이 보입니다)
</details>
### 실행 결과 · 자원과 단계별 토큰
수업 준비 때(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<br/>/prices"] -.-> B2["Build result<br/>단계마다 토큰 × 단가"] --> 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에서 손으로 계산한 값과 같습니다.
<details><summary>▶ 1단계 · 단가는 어디서</summary>
`curl -s localhost:8800/prices`의 모양을 봅니다. `models` 안에 모델 이름마다 `input` · `output`. n8n에서는 앞에 HTTP 노드를 하나 두고, 계산하는 Code가 `$('그 노드').first().json.models`를 읽습니다.
</details>
<details><summary>▶ 2단계 · 단계마다 다른 모델</summary>
`Build result`에는 단계 목록(`steps`: classify · answer · review와 각 모델 이름)이 이미 있습니다. 그 모델 이름으로 단가를 찾아 단계마다 계산하게 요청합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 경로 ② 단가를 읽는 노드와 주소 ③ 계산식과 단위 ④ 결과 칸 이름 ⑤ 반올림 ⑥ 기대 결과**. 아래 빈칸 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` 설명의 반올림 자리입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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절에 종류별 평균과 한 달 어림 두 가지(가중 · 단순 평균)를 적습니다.
<details><summary>▶ 1단계 · 왜 다 비슷한가</summary>
예제 1에서 `answer` 단계의 입력 토큰이 VPN 질문과 접수 질문에서 거의 같았습니다. 질문이 몇 글자든 같이 보내는 것이 있다면, 비용의 대부분은 질문과 관계없이 정해집니다.
</details>
<details><summary>▶ 2단계 · 어림을 믿을 수 있나</summary>
LLM 답의 길이는 실행마다 조금씩 다릅니다. 같은 파일을 한 번 더 돌리면 비용이 똑같이 나올까요? 어림은 "대략 이 정도"를 정하는 것이고, 실제 숫자는 기록(과제 1)과 보고서(Lab 03 과제)로 다시 봅니다.
</details>
### 실행 결과 · 질문 종류별 비용
수업 준비 때(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<br/>답 + usage"] --> R["Record usage<br/>POST /usage<br/>단계마다 한 줄"] --> A["Result again<br/>답을 다시 꺼냄"] --> S(["응답"])
R -.-> D[("기록<br/>/usage/summary<br/>/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`로 사람별 비용을 봅니다.
<details><summary>▶ 1단계 · 한 요청을 묶는 번호</summary>
AGENTS.md 5절 "사용량 기록"에 이번 실행의 번호를 읽는 표현식이 있습니다. 호출 세 줄에 같은 번호를 쓰면 한 요청으로 묶입니다. 요약의 `requests`가 9로 나오면 무엇이 잘못됐을까요?
</details>
<details><summary>▶ 2단계 · 기록 응답이 오류면</summary>
기록 API가 400 · 500을 돌려주면 HTTP 노드는 보통 실패하고, 워크플로우가 멈춥니다. 오류 응답도 성공 쪽으로 받는 옵션이 있습니다 (AGENTS.md 5절, 74일차 Lab 02 과제 2). 기록이 아예 멈추는 경우는 과제 2에서 다룹니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 넣을 노드와 주소 ② 한 줄의 칸 ③ 한 요청을 묶는 번호 ④ 누가 물었나 ⑤ 응답을 다시 꺼내는 곳 ⑥ 기대 결과**. 아래 빈칸 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와 같은 이름).
- ④ 사용자에게 보낼 답을 만든 노드입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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<br/>8초 기다림"] --> S1(["늦은 답"])
end
subgraph 바꾼 뒤
B2["Build result"] --> R2["Record usage<br/>timeout 3초<br/>실패해도 계속"] --> A2["Result again<br/>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. 마지막 케이스가 "기록이 빠지면 예산에서도 빠진다"를 숫자로 보여 줍니다.
<details><summary>▶ 1단계 · 어디서 기다리나</summary>
`fault usage slow` 상태의 `call -v`에서 걸린 시간을 봅니다. LLM 세 번에 몇 초, 나머지 몇 초는 어디서? n8n 화면의 Executions에서 노드마다 걸린 시간을 봅니다.
</details>
<details><summary>▶ 2단계 · 실패해도 같은 길로</summary>
n8n 노드의 Settings에 "On Error" 설정이 있습니다. 72 · 73 · 74일차에 쓴 두 갈래 값과 다른, **같은 출력으로 계속**하는 값입니다 (AGENTS.md에는 없으니 n8n 화면의 Settings에서 이름을 찾아 봅니다).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 노드와 기다리는 한도 ② 실패했을 때의 설정 ③ `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 '{}'`로 모양을 봅니다).
- ④ 이 과제에서 바뀌면 안 되는 것입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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`.
+573
View File
@@ -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<br/>보내는 것 줄이기<br/>(토큰 수)"] --> E2["예제 2<br/>단계마다 맞는 모델<br/>(단가)"] --> E3["예제 3<br/>답까지 작은 모델이면?"] --> T1["과제 1<br/>LLM 한 번 · $0.0002<br/>(호출 수)"] --> T2["과제 2<br/>긴 질문의<br/>비용 상한"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 바꿀 때마다 `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 모든 칸<br/>접수 건 모든 칸"] --> A1
end
subgraph 바꾼 뒤
S2["지시문 (답 규칙만)"] --> A2{{"Answer<br/>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 비용의 전 · 후를 적습니다.
<details><summary>▶ 1단계 · 무엇을 뺐는지 확인</summary>
고친 뒤 `uv run tools/flow.py diff reference/baseline-cost.json my-work/cost.json`으로 `Answer`의 글자 수가 얼마나 줄었는지 봅니다 (74일차 `diff`).
</details>
<details><summary>▶ 2단계 · 규정 전체가 필요한 질문은 없나</summary>
"노트북 교체 주기는?"처럼 FAQ에 없고 규정에만 있는 질문이 오면 어떻게 될까요? 지금 지시문은 그런 질문에 "찾지 못했습니다"라고 하게 돼 있습니다. 규정이 필요하다면 전체가 아니라 71일차처럼 **검색해서 관련 부분만** 보내는 것이 답입니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 지시문에서 뺄 것 ② 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에서 바꿀 단계입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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<br/>mini → nano"}} --> A{{"Answer<br/>mini 그대로"}} --> R{{"Review<br/>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절에 한 줄.
<details><summary>▶ 1단계 · 계산도 함께</summary>
모델 이름을 노드에서만 바꾸고 `Build result`의 단계 목록(`steps`)은 그대로 두면 어떻게 될까요? 결과의 `model`은 `gpt-4.1-mini`인데 실제로는 nano를 불렀고, 비용도 mini 단가로 계산됩니다. 74일차 "한 곳에서 정하고 모두 그것을 읽는다"를 떠올립니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바꿀 단계 ② 새 모델 이름 ③ 그대로 둘 단계 ④ 계산이 따라가게 할 것 ⑤ 기대 결과**. 아래 빈칸 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`에서 읽은 것입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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로 둡니다.
<details><summary>▶ 1단계 · 테스트를 더 엄하게 하면</summary>
`cases/lab02.json`을 복사해 `my-work/lab02-strict.json`으로 만들고, 케이스마다 `"not_contains": {"answer": ["**", "ticket not found"]}`를 더해 두 웹훅에 돌려 봅니다. 기대 결과를 **더하는** 것은 괜찮습니다 (원래 테스트를 바꾸는 것은 안 됨).
</details>
<details><summary>▶ 2단계 · 누가 정하나</summary>
이 결정은 에이전트가 아니라 **사람이** 합니다. 숫자(얼마 아끼나)와 답 글자(무엇을 잃나)를 함께 놓고, 74일차처럼 golden 게이트를 지나야 바꿉니다.
</details>
### 실행 결과 · 답까지 작은 모델이면?
수업 준비 때(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<br/>(Code, 0원)"] --> K{"unknown?"}
K -->|아니오 · 대부분| A{{"Answer (mini)"}}
K -->|예| C{{"Classify (nano)"}} --> P["Parse category"] --> A
A --> B["Build result<br/>지나간 단계만 셈"]
```
**규칙이 모르는 것만 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.
<details><summary>▶ 1단계 · 어느 것을 없애나</summary>
세 단계 중 규칙으로 바꿀 수 있는 것은? 다른 단계가 이미 하고 있는 일을 하는 단계는? 남는 하나가 정말 LLM이 필요한 단계입니다.
</details>
<details><summary>▶ 2단계 · 확인 순서</summary>
`lab02-challenge` → `lab02` → `lab01` → `lab01-challenge`. 마지막으로 `flow.py usage`로 기록이 그대로 남는지 (단계가 줄었으니 `calls`도 줄어야 합니다).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 규칙 분류의 세 갈래 ② 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 표현입니다 (`$('노드').____`).
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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<br/>max_question_chars 500"] --> P["Prepare question<br/>앞 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절에 긴 로그 질문의 비용 전 · 후.
<details><summary>▶ 1단계 · 얼마나 드나</summary>
`call my-cost requests/long-printer.json`의 `usage.by_step.answer.prompt_tokens`와 `cost_usd`를 짧은 질문(`faq-printer`)과 비교합니다. 몇 배인가요?
</details>
<details><summary>▶ 2단계 · 상한은 어디서</summary>
예제 · 과제마다 "바뀌는 값은 밖에서 읽기"였습니다. 오늘 사람이 정하는 값(예산 · 한도 · 글자 수)은 모두 `GET /budget`에 있습니다 (`curl -s localhost:8800/budget`). Lab 03 예제 1에서도 같은 노드를 씁니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 상한을 읽는 곳 ② 줄이는 법 ③ 줄인 질문을 쓸 곳 ④ 결과 칸 ⑤ 기대 결과**. 아래 빈칸 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`의 "짧은 질문은 그대로" 케이스가 보는 칸입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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절 (잃은 것도).
+606
View File
@@ -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<br/>하루 예산 →<br/>한도 초과 원문"] --> E2["예제 2<br/>1분 한도 → 429"] --> E3["예제 3<br/>예산을 바꿔 가며"] --> T1["과제 1<br/>비용 보고서"] --> T2["과제 2<br/>바꾸면 얼마<br/>(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?<br/>over = false"}
W -->|예| R["Rule classify → Answer<br/>(평소대로)"]
W -->|아니오| O["Over budget<br/>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.
<details><summary>▶ 1단계 · 어디에 두나</summary>
예산 확인은 LLM보다 **앞에**, FAQ · 접수 조회보다는 **뒤에** 둡니다. 왜일까요? (원문으로 답하려면 무엇이 먼저 있어야 하나요?)
</details>
<details><summary>▶ 2단계 · 예산을 어디서 세나</summary>
`used_usd`는 기록된 `cost_usd`의 합입니다 (Lab 01 과제). Lab 01 과제 2의 `recorded: false`인 요청은 여기에 들어가지 않습니다. 기록이 빠질수록 예산은 실제보다 남아 보입니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 넣을 곳과 보는 값 ② 넘었을 때 답의 재료 ③ 결과의 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을 부르지 않았으니?
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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<br/>최근 60초 · 그 사람"] --> L{"count <<br/>user_limit_per_min?"}
L -->|예| N["Get prices → … (평소대로)"]
L -->|아니오| X(["429 잠시 후<br/>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.
<details><summary>▶ 1단계 · 기록이 있어야 셉니다</summary>
`count`는 **기록된** 요청 수입니다. 이준호의 1 · 2번째 요청이 `/usage`에 기록되지 않으면 세 번째도 통과합니다. `uv run tools/flow.py usage`로 이준호의 줄이 쌓이는지 봅니다.
</details>
<details><summary>▶ 2단계 · 한도의 값</summary>
1분 2번은 연습용입니다. 실제로는 몇 번이 좋을까요? 사람이 한 질문을 고쳐 다시 묻는 경우, 여러 사람이 공용 계정 하나로 묻는 경우도 생각합니다. `COST.md` 6절에 까닭과 함께 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 세는 창의 길이 ② 비교할 한도 ③ 넘었을 때의 응답 코드와 본문 ④ 그 갈래에서 하지 않는 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/cost.json 의 Get budget 바로 뒤(가장 앞)에 Recent requests(HTTP GET http://data-api:8800/usage/user/<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).
- ③ "너무 자주 부른다"는 표준 응답 코드입니다.
- ④ 맨 앞에서 돌려보내는 까닭입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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번째<br/>남음"] --> B["2번째<br/>남음 → 넘음"] --> C["3번째부터<br/>한도 초과 · $0"]
```
**넘지 않게 하려면.** "남은 금액이 한 건의 최대 비용보다 적으면 멈춘다"로 바꾸면 넘지 않습니다. 이것이 가능하려면 **한 건의 최대 비용**을 알아야 합니다. Lab 02 과제 2의 입력 상한(`max_question_chars`)과 예제 1의 출력 상한(`max_completion_tokens`)이 그것을 정합니다. 상한이 있어야 예산도 지킬 수 있습니다.
**`한도 초과` 답은 기록에 없습니다.** `usage`의 `route`별 합계에는 `자동 답변`만 보입니다. 한도 초과 답은 LLM을 부르지 않아 기록할 호출이 없기 때문입니다. 그러면 "오늘 몇 명이 원문만 받았나"를 셀 수 없습니다. 운영에서는 이것도 세어야 예산이 적절했는지 판단합니다 (77일차 로그).
**기대 결과.** 예산 $0.0003에서는 앞의 한두 질문만 `자동 답변`이고 나머지는 `한도 초과 · 0 calls · $0`. `usage`의 사용액이 예산을 조금 넘습니다. `--reset` 뒤에는 여섯 모두 `자동 답변`.
<details><summary>▶ 1단계 · 하루가 바뀌면</summary>
오늘 데이터 API의 `used_usd`는 기록 전체의 합입니다 (연습용). 실제로는 "오늘 0시부터"를 셉니다. 날짜가 바뀌면 누가 0으로 돌릴까요? 사람이 매일 `--clear`를 하면 될까요?
</details>
<details><summary>▶ 2단계 · 예산은 누가 정하나</summary>
예산은 기술이 아니라 **결정**입니다. 한 달 예산을 22일로 나눈 값, 지난달 기록의 평균 × 1.2, 사용자 수 × 한 사람 몫 등 근거를 정해 `COST.md` 6절에 적습니다. 과제 1의 보고서가 그 근거를 줍니다.
</details>
### 실행 결과 · 예산을 바꿔 가며
수업 준비 때(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<br/>requests_per_day · workdays"]) --> S["Get summary"] --> B["Get budget"] --> M["Make report<br/>(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}'`.
<details><summary>▶ 1단계 · LLM이 필요한가</summary>
필요 없습니다. Code 노드 하나가 두 API의 응답을 읽어 계산합니다. LLM 노드를 넣으면 테스트의 `equals`가 흔들립니다.
</details>
<details><summary>▶ 2단계 · 숫자가 조금 다르면</summary>
`requests`(요청 수)와 `calls`(호출 수)를 헷갈리면 평균이 크게 달라집니다. 반올림을 평균에 먼저 하고 곱하면 한 달 예상이 조금 달라집니다 (반올림은 마지막에).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 새 파일 · 웹훅 ② 읽을 두 곳 ③ 평균의 분모 ④ 일하는 날 기본값 ⑤ 반올림 ⑥ 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절 "비용 보고서의 금액은 …"의 자리입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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<br/>토큰 합"] --> C["× 새 모델 단가<br/>(/prices)"] --> W(["what_if<br/>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절을 채웁니다.
<details><summary>▶ 1단계 · 먼저 손으로</summary>
`before`의 네 줄에서 입력 토큰 합 · 출력 토큰 합을 구하고, nano 단가(입력 0.20 · 출력 1.25)로 곱해 봅니다. 그 값이 `what_if.cost_usd`입니다.
</details>
<details><summary>▶ 2단계 · 400은 어디서</summary>
Code 노드는 HTTP 상태를 정하지 못합니다. Code가 `error`를 담아 내보내면, 뒤의 If가 갈라 400을 보내는 Respond로 잇습니다 (74일차 "없는 릴리스는 400"과 같은 모양).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 다시 계산할 때 쓰는 값 ② 아끼는 돈의 식 ③ 없는 모델의 응답 ④ 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의 테스트가 그대로 통과해야 합니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```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`.
+178
View File
@@ -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["전날<br/>준비 · base-cost 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1교시 강의 · 2교시<br/>Lab 01 실습 5개<br/>토큰 · 비용 · 기록"] --> L2["3교시 강의 · 4교시<br/>Lab 02 실습 5개<br/>줄이기 · 모델 · 호출 수 · 긴 질문"] --> L3["5교시 강의 · 6교시<br/>Lab 03 실습 5개<br/>예산 · 한도 · 보고서 · what-if"] --> V["7~8교시<br/>tcost"] --> E["마친 뒤<br/>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["① 재기<br/>call · test 줄 끝 비용"] --> B["② 요청문을<br/>메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test -v"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> 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일차 준비
+93
View File
@@ -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["긴 질문 줄이기<br/>max_question_chars"] --> D["FAQ · 접수 조회"] --> B{"오늘 예산?"} -->|넘음| O(["한도 초과<br/>FAQ 원문 · LLM 0번"])
B --> R{"규칙으로<br/>분류?"} -->|예| A{{"답 LLM 1번<br/>필요한 것만 · 출력 상한"}}
R -->|아니오| C{{"분류 nano"}} --> A
A --> U["사용량 기록 /usage<br/>(멈춰도 답은 나감)"] --> S(["답 + usage + cost_usd"])
P[("/prices 단가")] -.-> A
U -.-> REP["비용 보고서<br/>한 달 · 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 토큰).
+288
View File
@@ -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<br/>할 일 정리"] --> S["2. Doc/SPEC.md<br/>확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> I["4. 구현<br/>단계마다 요청 · 확인"]
I --> H["5. 막히면<br/>문제 해결 팁"] --> I
I --> C["6. 결과 확인<br/>요구 표 체크"] --> 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분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다.
<details><summary>▶ TODO와 SPEC은 무엇이 다른가</summary>
TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. TODO에서 빠진 것은 SPEC에서도 빠지므로, TODO는 빠짐없이, SPEC은 정확하게.
</details>
`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. 정해 둘 것 (단가 파일 모양 · 위치, 반올림, 평균의 분모, 출력 가정의 기본값, 종료 코드)
```
<details><summary>▶ 확인 방법은 손으로 계산할 수 있는 숫자로</summary>
"비용을 계산한다"는 확인 방법이 아닙니다. "입력 1,000 · 출력 100토큰, `gpt-4.1-mini`(0.40 · 1.60) → $0.00056"이 확인 방법입니다. 오늘 Lab 03 과제 1 테스트처럼 **정확히 하나인 답**을 적습니다. 보고서도 "결과 셋 중 400 하나 → 요청 2 · skipped 1"처럼 **나와야 할 것과 나오면 안 되는 것**을 둘 다 적습니다.
</details>
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `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)
```
<details><summary>▶ 왜 엔진 먼저인가</summary>
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tcost count`). 화면부터 만들면 금액이 맞는지 눈으로만 봅니다.
- 배포 전 비용 관문(R10)은 화면 없이 돌아야 합니다. 74일차 게이트처럼 종료 코드로 다음 단계를 정합니다.
- TUI는 엔진에게 "이 폴더의 지시문 · 보고서 숫자"를 받아 표에 넣기만 하면 됩니다.
</details>
<details><summary>▶ 단계 크기</summary>
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다. 오늘 Lab 02의 "한 번에 한 가지만 줄이고 다시 잰다"와 같은 생각입니다.
</details>
`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`.
<details><summary>▶ 엔진 1단계 · 단가 파일의 모양</summary>
오늘 데이터 API의 `prices.json`(`models` 안에 `input` · `output`)을 그대로 따르고 `checked` · `source`를 더하면, 내 도구와 n8n이 **같은 단가표**를 쓸 수 있습니다. 단가를 코드에 숫자로 쓰지 않는 것은 오늘 Lab 01 예제 2와 같습니다.
</details>
<details><summary>▶ 엔진 1단계 · 날짜를 어떻게 시험하나</summary>
"30일 넘으면 알림"을 오늘 날짜로 시험하면 테스트가 날마다 달라집니다. 알림 함수가 "오늘"을 받게 하면(`warning(today)`), 테스트는 확인한 날짜 + 45일을 넘겨 알림이 나오는지 봅니다.
</details>
<details><summary>▶ 엔진 2단계 · 보고서의 모양</summary>
`runs/*.json`을 하나 열어 봅니다. `results`마다 `request`(누가) · `response`(답). `response`에 `usage.by_step`이 있을 수도 없을 수도 있습니다 (400 응답, 429, 요약 케이스, 다른 날의 보고서). 없는 것은 건너뛰고 **몇 개를 건너뛰었는지** 알려 주게 합니다. 시간대는 보고서 파일 이름의 시각(`-20261028-153000`)에서.
</details>
<details><summary>▶ TUI 1단계 · 화면 나누기</summary>
계산(단가 · 세기 · 모으기)과 화면을 나눕니다 (`tver` · `tscan`과 같음). 탭마다 같은 엔진 함수를 부릅니다. 시간대별 막대는 `▁▂▃▄▅▆▇█` 여덟 칸으로 충분합니다.
</details>
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
- 엔진 3단계: 워크플로우의 LLM 단계(R4: HTTP `chat/completions` 노드의 `jsonBody`에서 모델 · system 글 · `max_completion_tokens`), 두 모델 비교(R7), 모르는 모델(R12).
- 엔진 4단계: 예산으로 하루 몇 번(R9), `--json` · `--max-usd` · 종료 코드(R10), 폴더 나누기(R11).
</details>
## 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에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
+108
View File
@@ -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["요청서<br/>(사람의 말)"] --> TODO["Doc/TODO.md<br/>만들고 싶은 것 ·<br/>요청의 말 정리"] --> SPEC["Doc/SPEC.md<br/>확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·<br/>직접 확인"] --> USE["uv tool install<br/>다른 곳에서 쓰기"]
```
## 왜 요청서부터인가
요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 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` |
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자가 "매달 초에 물어보거든요"라고 한 것은 무엇인가요? 그리고 비교 · 예산 · 화면을 만들려면 먼저 무엇이 있어야 하나요? (단가 · 토큰 → 비용 식 → 보고서 모으기 → 화면)
</details>
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
- ① "토큰이 몇 개": 내가 센 토큰과 OpenAI가 센 토큰이 같을까요? 다르면 무엇으로 맞춰 볼까요? (보고서의 `prompt_tokens`)
- ③ "한 달이 넘었으면": 오늘 날짜를 프로그램이 직접 읽으면 테스트가 날마다 달라집니다. 어떻게 시험할까요?
- ④ "LLM 단계마다": n8n 워크플로우의 LLM은 HTTP 노드(오늘)일 수도, AI Agent 노드(66~74일차)일 수도 있습니다. 지시문이 다른 노드에서 오면(표현식) 몇 토큰이라고 할까요?
- ⑤ "보고서": 400 · 429 응답이나 요약 케이스처럼 `usage`가 없는 결과는? 건너뛰었다고 알려 줘야 하지 않을까요?
- ⑥ "평균": 호출 수로 나눌까요, 요청 수로 나눌까요? (Lab 03 과제 1)
- 끝 "OpenAI를 부르지 않기": 토큰을 세는 라이브러리가 처음에 인코딩 파일을 내려받는 것은 괜찮을까요?
</details>
## 그다음
[VIBE-Q-TCOST.md](VIBE-Q-TCOST.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
+72
View File
@@ -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"
]
}
}
}
]
}
+104
View File
@@ -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
}
}
}
]
}
+54
View File
@@ -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
}
}
]
}
+55
View File
@@ -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
}
}
]
}
+139
View File
@@ -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
}
}
}
]
}
+96
View File
@@ -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": [
"대기열"
]
}
}
}
]
}
+72
View File
@@ -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": [
"대기열"
]
}
}
}
]
}
+144
View File
@@ -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
}
}
}
]
}
+141
View File
@@ -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
}
}
}
]
}
+53
View File
@@ -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
}
}
]
}
+67
View File
@@ -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
}
}
]
}
+93
View File
@@ -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
]
}
}
}
]
}
+27
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500}
@@ -0,0 +1,17 @@
# 업무용 장비 지급·반납 규정
## 1. 지급과 교체
- 업무용 노트북은 입사일에 지급한다.
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
## 2. 분실과 도난
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
## 3. 반납
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
## 4. 개인 장비 사용
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
@@ -0,0 +1,14 @@
# 메일·문서 보안 규정
## 1. 메일함
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
## 2. 첨부 파일
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
## 3. 문서 등급과 보관
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
@@ -0,0 +1,12 @@
# 회의실 이용 규정
## 1. 예약
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
## 2. 장비
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
- 프로젝터는 B회의실과 대회의실에 있다.
## 3. 사용 후
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
@@ -0,0 +1,18 @@
# 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다.
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
## 3. 로그인 실패와 잠금
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
+13
View File
@@ -0,0 +1,13 @@
# 개인정보 처리 지침
## 1. 수집
- 업무에 꼭 필요한 개인정보만 수집한다.
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
## 2. 이용과 보관
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
## 3. 유출 대응
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
@@ -0,0 +1,15 @@
# 원격근무·VPN 이용 규정
## 1. 접속 원칙
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
## 2. VPN 인증서
- VPN 인증서의 유효 기간은 1년이다.
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
- VPN 접속 기록은 1년 동안 보관된다.
@@ -0,0 +1,11 @@
# 보안 사고 대응 절차
## 1. 피싱 메일
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
## 2. 악성코드 감염 의심
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
@@ -0,0 +1,14 @@
# 소프트웨어 사용 규정
## 1. 설치와 승인
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
## 2. 금지 사항
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
## 3. 생성형 AI 서비스
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
+26
View File
@@ -0,0 +1,26 @@
[
{
"name": "이준호",
"dept": "영업팀"
},
{
"name": "박서연",
"dept": "재무팀"
},
{
"name": "최유진",
"dept": "인사팀"
},
{
"name": "한지훈",
"dept": "개발팀"
},
{
"name": "김민지",
"dept": "IT팀"
},
{
"name": "정우성",
"dept": "IT팀"
}
]
+14
View File
@@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+9
View File
@@ -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}
}
}
@@ -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 합니다체.
@@ -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].
@@ -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.
+12
View File
@@ -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": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"}
}
}
}
+6
View File
@@ -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"}}
}
+406
View File
@@ -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=<question> -> {"query": ..., "results": [FAQ entries]}
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
GET /tickets/<id>?requester=<name>
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
GET /employees -> {"employees": [{"name", "dept"}]}
GET /employees/<name> -> {"name", "dept"}, or 404 {"error": "unknown employee"}
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
GET /docs/<name> -> {"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> -> {"key", "value", "hits"} or 404 {"error": "not cached"}
POST /cache/<key> body {"value": <anything>, "ttl": 600} (PUT works too)
POST /cache-clear -> {"cleared": <count>}
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": <count>}
Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory):
GET /prompts/<name> -> {"name", "versions": ["p1", ...]}
GET /prompts/<name>/<ver> -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts")
GET /releases/<name> -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}}
POST /releases/<name> 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/<name>/policy body {"require_gate": true|false}
POST /releases/<name>/reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay)
POST /gates/<name> body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...}
-> {"stored": true, "count"}
GET /gates/<name> -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept)
POST /gates/<name>/clear -> {"cleared": <count>}
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/<name>?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()
+5
View File
@@ -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`: 오늘 쓴 요청문을 모아 둡니다.
+337
View File
@@ -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분 동안 사용하지 않으면 자동으로 잠Line truncated
"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"
}
}
@@ -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분 동안 사용하지 않으면 자동으로 잠Line truncated
"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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
{"question": "", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "메일함 용량이 꽉 찼대요", "user": "김민지"}
+1
View File
@@ -0,0 +1 @@
{"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?", "user": "한지훈"}
+1
View File
@@ -0,0 +1 @@
{"question": "프린터 출력이 안 돼요", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호"}
+1
View File
@@ -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-drivLine truncated
+1
View File
@@ -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=reLine truncated
+1
View File
@@ -0,0 +1 @@
{"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1001 어떻게 됐나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
@@ -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):
@@ -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일 | | |
File diff suppressed because it is too large. Load diff
+420
View File
@@ -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["재기<br/>토큰 · 비용 · 기록"] --> R["줄이기<br/>보내는 것 · 모델 · 호출 수"] --> K["지키기<br/>예산 · 한도 · 보고서"]
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 사용량 관리와 비용 최적화
질문 하나에 얼마인지 재고, 품질은 그대로 두고 줄이고, 넘치지 않게 지킨다
<sub>노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-29</sub>
---
## 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<br/>+ 사내 규정 전체"}} --> 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["① 재기<br/>call · test 줄 끝 비용"] --> B["② 요청문을<br/>메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test -v"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> 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<br/>모델 → 단가")] -.-> B["Build result<br/>단계마다 토큰 × 그 모델 단가"] --> O(["usage.by_step.*.cost_usd<br/>+ 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{"규칙이 아나?<br/>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["재기<br/>usage · 기록"] --> R["줄이기<br/>한 칸씩 · 품질 테스트"] --> K["지키기<br/>예산 · 한도 · 상한"] --> P["보고<br/>정기적으로 · 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`로 남기기만 했습니다. 내일은 실패한 일을 **모아 두고 다시 처리하고, 사람에게 알리는** 운영 체계를 만듭니다.
+96
View File
@@ -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 켜기<br/>(75일차 판)"] --> B["2. 기준 게시 ·<br/>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`
+461
View File
@@ -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["기능별<br/>헬프데스크 · 요약 · 번역"] --> S["단계별<br/>분류 · 답 · 검토"] --> 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`를 남긴다
+359
View File
@@ -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["품질 테스트 고정<br/>(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 아래로 내려가지 않았습니다. 무엇이 남았나요?
## 정리
- [ ] 비용 = 호출 수 × 호출당 토큰 × 단가, 한 번에 한 칸씩, 가장 큰 몫부터
- [ ] 품질 테스트를 먼저 고정하고, 바꿀 때마다 다시 돌리고 답을 읽는다
- [ ] 필요한 것만 보내고(데이터 최소화), 출력에 상한을 둔다
- [ ] 쉬운 단계만 작은 모델, 단가표로 효과를 계산하고 계산의 모델 이름도 함께
- [ ] 가장 싼 호출은 하지 않은 호출 — 규칙으로 먼저, 중복 단계 빼기
- [ ] 입력 · 출력 상한으로 요청 하나의 최대 비용을 정한다
+332
View File
@@ -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 · 접수 조회<br/>(데이터, 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는 코드가 계산하고, 가정을 밝히고, 바꿀지는 품질 게이트로
+212
View File
@@ -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. 왜 비용이<br/>운영의 일인가"] --> 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"가 오늘 내용입니다.
면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법:
<details><summary>1. "LLM 비용이 너무 많이 나옵니다. 어디부터 보나요?"</summary>
먼저 요청 하나의 비용을 단계별로 나눠 봅니다. 오늘 헬프데스크는 VPN 질문 하나가 $0.00104였고, 응답의 `usage`를 단계별로 모아 보니 입력의 89%가 답 단계 하나(1,781토큰)에서 나왔습니다. 지시문에 사내 규정 전체를 붙여 보내고 있었기 때문입니다. 질문 종류가 달라도 비용이 비슷했던 것도 그 고정비 때문이었습니다. 가장 큰 몫부터 줄였습니다.
</details>
<details><summary>2. "비용을 줄이면서 품질은 어떻게 지키나요?"</summary>
줄이기 전에 품질 테스트를 고정하고, 한 번에 한 가지만 바꾸고, 바꿀 때마다 테스트와 함께 답을 직접 읽었습니다. 보내는 것을 줄이자 답 단계 입력이 1,781에서 165토큰, 비용이 $0.001007에서 $0.000346이 됐고 테스트는 그대로였습니다. 반대로 답 단계까지 작은 모델로 바꾼 비교본은 테스트를 다 통과했지만 마크다운 · 군더더기가 섞여서, 15%를 아끼는 대신 바꾸지 않기로 했습니다.
</details>
<details><summary>3. "작은 모델로 바꾸면 얼마나 싸지나요?"</summary>
단가표로 계산해 봐야 합니다. 오늘 두 모델은 입력이 절반, 출력은 0.78배라서 "몇 배"가 아니었습니다. 입력이 많은 단계일수록 효과가 큽니다. 쉬운 단계(분류 · 검토)만 바꾸고 답은 그대로 두었고, 바꾸기 전에는 기록된 토큰으로 what-if를 계산했습니다 (모두 작은 모델이면 34% 덜). what-if는 같은 토큰 · 품질을 가정하므로 결정은 품질 게이트로 합니다.
</details>
<details><summary>4. "한 사용자가 API를 몰아 쓰거나 아주 긴 입력을 보내면?"</summary>
세 겹으로 막습니다. 사람마다 1분 한도를 두고 넘으면 가장 앞에서 429와 `retry_after`를 줍니다 (0.0초 · $0). 입력에 글자 수 상한을 두어 6,000자 넘는 로그 질문이 $0.001196에서 $0.000237이 되게 했고, 출력에도 `max_completion_tokens` 상한을 둡니다. 그리고 하루 예산을 넘으면 LLM 없이 데이터 원문으로 답합니다. 두 상한이 있어야 요청 하나의 최대 비용을 말할 수 있고, 그래야 예산도 지킬 수 있습니다.
</details>
<details><summary>5. "예산을 넘지 않게 하려면?"</summary>
"쓴 돈이 예산 이상이면 멈춘다"는 넘은 뒤에 멈춥니다. 오늘 예산 $0.0003에서 세 번째 질문까지 답해 $0.000430, 43%를 넘었습니다. 넘지 않으려면 부르기 전에 한 건의 최대 비용만큼 예산에서 미리 떼어 두거나, 남은 돈이 그보다 적으면 멈춰야 합니다. 그 최대 비용은 입력 · 출력 상한으로 정해집니다. 그리고 80%에서 먼저 알림을 줍니다.
</details>
## 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개를 가져간다
+93
View File
@@ -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 ·<br/>IMP-PLAN 20분"] --> A["7교시<br/>엔진 1 · 2 · TUI · 설치 30분"] --> B["발표<br/>10분"] --> C["4단계 정리<br/>35분"] --> F["76일차 예고<br/>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`).
+41
View File
@@ -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단계<br/>자원 · 토큰 · 비용<br/>종류별 · 기록"] --> S2["2단계<br/>보내는 것 · 모델<br/>호출 수 · 긴 입력"] --> S3["3단계<br/>예산 · 한도<br/>보고서 · what-if"] --> V["7교시<br/>tcost"] --> W["8교시<br/>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), 발표, 강사 정리, 강사 바이브 코딩, 팁, 예고 |