72일차 자료 추가(답 제외)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-25 22:57:12 +09:00
1 parent aaf77d4e18
commit febde9757c
63 files changed
+12260

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+232
View File
@@ -0,0 +1,232 @@
# AGENTS.md · n8n 실행 흐름 최적화·안정성 작업 규칙 (72일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 71일차 규칙과 같고, 3절이 오늘 만드는 **헬프데스크 · 상태 점검의 계약**, 5절에 **시간 기록 · 캐시 · 타임아웃 · 재시도 · 오류 출력**이 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 빠르게 하는 요청이면 **어느 단계를 줄이거나 건너뛰는지**, 안정성 요청이면 **어느 노드가 실패하면 어디로 가는지**도 함께 보여 준다.
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이 잠깐 재시작됨).
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` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음, LLM은 모두 위 OpenAI Credential) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**72일차 판** `Last3Mon/72/data-api`: 71일차 + 고장 스위치 + 캐시) |
| 비교 기준 | `reference/baseline-helpdesk.json` (웹훅 `base-helpdesk`, 고치지 않는다) |
### 데이터 API (72일차 판)
| 요청 | 응답 |
| --- | --- |
| `GET /faq?q=<질문>` | `{"query", "results": [{"id": "FAQ-003", "title", "answer", "score"}]}` · 없으면 `results: []` |
| `GET /tickets/<id>?requester=<이름>` | 접수 건 · 없으면 404 · 남의 건이면 403 |
| `GET /cache/<key>` | `{"key", "value", "hits"}` · 없으면 HTTP 404 `{"error": "not cached"}` |
| `POST /cache/<key>` 본문 `{"value": {...}, "ttl": 600}` | `{"stored": true}` (ttl 초가 지나면 사라짐) |
| `POST /cache-clear` · `GET /cache-stats` | 캐시 비우기 · `{"entries", "hits", "misses"}` |
| `GET /admin/fault` · `POST /admin/fault` | 고장 스위치 `{"faq": "ok"|"slow"|"error", "tickets": ..., "slow_seconds": 8}` (연습용, **사람이** `flow.py fault`로 바꿈) |
| `GET /health` | `{"status": "ok"}` (고장 스위치와 상관없음) |
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
| --- | --- | --- | --- |
| 헬프데스크 | `my-work/helpdesk.json` · `POST /webhook/my-helpdesk` (처음은 `reference/baseline-helpdesk.json`을 복사) | `question`(필수), `user`, `no_cache`(true면 캐시를 읽지도 쓰지도 않음), `budget_ms`(Lab 01 과제 2, 기본 3000) | `{"route", "category", "answer", "sources", "llm_calls", "cached", "steps", "timings"}` + 실습마다 더하는 칸 (아래) |
| 상태 점검 | `my-work/health.json` · `POST /webhook/my-health` | 없음 | `{"status": "ok"|"degraded"|"down", "checks": {"data_api", "faq", "llm"}, "failed", "seconds"}` |
- `route`: `자동 답변` · `간단 답변`(LLM 없이 데이터 그대로) · `담당자 연결`. `category`: `faq` · `ticket` · `other`.
- `llm_calls`: 이번 요청에서 실제로 부른 LLM 노드 수. `steps`: 지나간 단계 이름 목록(예: `["rule", "faq", "ticket", "answer", "check"]`). 둘 다 **워크플로우가 정해진 값으로** 넣는다 (LLM에게 쓰게 하지 않는다).
- `timings`: 단계별 걸린 시간 ms (`classify_ms`, `data_ms`, `answer_ms`, `review_ms`, ... `total_ms`). 지나가지 않은 단계는 넣지 않는다.
- 빈 질문은 HTTP 400 `{"error": "question is required"}` (LLM을 부르지 않는다).
- **접수 현황(`ticket`)은 캐시하지 않는다** (상태가 바뀐다). 캐시 키는 질문을 앞뒤 공백 제거 · 소문자 · 연속 공백 하나로 바꾼 글자.
실습마다 더하는 칸 (요청에 나올 때만):
| 실습 | 칸 | 뜻 |
| --- | --- | --- |
| Lab 01 과제 1 | `timings.faq_ms` · `timings.ticket_ms`, `slowest_step` | 데이터 조회를 둘로 나눔, `total_ms`를 뺀 가장 큰 단계 이름 (`_ms` 뺀 것) |
| Lab 01 과제 2 | `llm_ms`, `budget_ms`, `over_budget` | LLM 단계(`classify` · `answer` · `review`) 시간의 합 (`timings` **밖**에 둔다: `slowest_step` 계산에 섞이지 않게), 이번 요청의 시간 예산, `total_ms > budget_ms` |
| Lab 02 과제 2 | `cache_by` | 캐시에서 왔으면 `"question"`(질문 키) 또는 `"faq"`(FAQ 번호 키), 아니면 `null` |
| Lab 03 | `reason` | `담당자 연결` · `간단 답변`의 까닭: `data_api_down` · `tickets_down` · `breaker_open` · `llm_down` |
캐시 키 (데이터 API `/cache/<key>`):
| 키 | 값 | ttl | 실습 |
| --- | --- | --- | --- |
| 질문을 고친 글자 (예: `vpn 접속이 자꾸 끊겨요. 어떻게 하나요?`) | `{route, category, answer, sources}` | 600 | Lab 02 예제 2 |
| `faq-<FAQ 번호>` (예: `faq-FAQ-003`) | 같음 | 600 | Lab 02 과제 2 |
| `breaker-faq` | `{"open": true, "since": <ms>}` | 30 | Lab 03 과제 2 (차단기) |
## 4. 워크플로우 파일 규칙
```json
{
"name": "72 IT helpdesk (my)",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}
```
- 파일 하나에 워크플로우 하나. `id`는 쓰지 않아도 된다 (`push`가 파일 이름으로 정한다).
- 노드 `name`은 영어로, 워크플로우 안에서 겹치지 않게. 노드마다 `type`, `typeVersion`, `position: [x, y]`, `parameters`가 필요하다.
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로 보낸다. 갈래마다 Respond 노드를 따로 두어도 된다.
- 요청 본문은 `$json.body.question`처럼 `body` 아래에 있다. 다른 노드 뒤에서는 `$('Question webhook').first().json.body`.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
### Webhook · If · Respond (71일차와 같음)
```json
{"name": "Question webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-helpdesk", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Cached?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [880, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.value !== undefined }}", "rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 글자 비교: `"operator": {"type": "string", "operation": "notEquals"}`, `"rightValue": "unknown"`.
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2600, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
### AI Agent + OpenAI chat model
```json
{"name": "Answer", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [1500, 300],
"parameters": {"promptType": "define", "text": "=질문: {{ $('Question webhook').first().json.body.question }}\n...",
"options": {"systemMessage": "You are the company IT help desk. ...", "maxIterations": 1}}}
```
```json
{"name": "Answer model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [1500, 520],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent마다 Chat model 노드를 하나씩 둔다. 결과는 `$json.output`(글자). Chat model `options`에 `"timeout": 8000`(ms), `"maxRetries": 0`을 둘 수 있다.
### HTTP Request (데이터 API · 캐시)
```json
{"name": "Search FAQ", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300],
"parameters": {"url": "http://data-api:8800/faq", "sendQuery": true,
"queryParameters": {"parameters": [{"name": "q", "value": "={{ $('Question webhook').first().json.body.question }}"}]},
"options": {"timeout": 2000}},
"retryOnFail": true, "maxTries": 2, "waitBetweenTries": 500, "onError": "continueErrorOutput"}
```
```json
{"name": "Store in cache", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2200, 180],
"parameters": {"method": "POST", "url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, answer: $json.answer }, ttl: 600 }) }}",
"options": {"response": {"response": {"neverError": true}}}}}
```
- `options.timeout`: 이 시간(ms) 안에 답이 없으면 실패. 없으면 **끝없이 기다린다**.
- 노드 설정 `retryOnFail` · `maxTries` · `waitBetweenTries`(ms): 실패하면 다시. `parameters` 밖, 노드 바로 아래에 쓴다.
- 노드 설정 `"onError": "continueErrorOutput"`: 실패하면 워크플로우를 멈추지 않고 **두 번째 출력**(오류 출력)으로 보낸다. 연결은 `main`의 1번 칸 (아래 6절). `"continueRegularOutput"`은 실패해도 보통 출력으로 (결과에 `error`가 담김).
- `neverError: true`: **404 같은 4xx를** 실패가 아니라 보통 결과로 받는다 (캐시 없음 404를 읽을 때). 이 n8n(2.42.6)에서 실제로 재 보니(10/24) **500은 그래도 실패**로 보고 재시도 · 오류 출력으로 간다. 그리고 `retryOnFail`이 켜져 있으면 **404도 재시도를 다 한 뒤에** 보통 결과로 받는다 (시간을 씀).
- `fullResponse: true`(`neverError`와 함께): 결과가 `{"statusCode", "headers", "body"}`가 되고 **500도 보통 결과**로 바로 온다 (재시도 · 오류 출력 없음). 상태 코드를 보고 **워크플로우가 직접** 나눌 때 쓴다 (Lab 03 예제 3).
| 설정 (`retryOnFail` 3번, 기다림 1초) | 404 | 500 |
| --- | --- | --- |
| 없음 | 재시도 뒤 오류 출력 (2.1초) | 재시도 뒤 오류 출력 (2.1초) |
| `neverError` | 재시도 뒤 **보통** 출력 (2.2초) | 재시도 뒤 오류 출력 (2.2초) |
| `neverError` + `fullResponse` | 바로 보통 출력, `statusCode: 404` (0.1초) | 바로 보통 출력, `statusCode: 500` (0.1초) |
- Agent 노드에도 `"onError": "continueErrorOutput"`을 둘 수 있다 (LLM이 실패하면 오류 출력으로).
### Code (시간 기록 · 결과 모양)
```json
{"name": "Start clock", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300],
"parameters": {"jsCode": "return [{ json: { t: Date.now() } }];"}}
```
```json
{"name": "After answer", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1720, 300],
"parameters": {"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"}}
```
- 단계가 끝날 때마다 `Date.now()`를 남기고, 마지막 Code에서 앞 기록과의 차이를 `timings`로 만든다.
- 지나가지 않았을 수 있는 노드는 `$('노드').isExecuted`(true/false)로 먼저 확인한 뒤 읽는다. `steps`, `llm_calls`도 이것으로 센다.
- 반드시 `[{ json: {...} }]` 배열을 돌려준다. Code 노드 안에는 `URL` 클래스가 없다.
### HTTP Request (상태 코드를 직접 보기, Lab 03 예제 3)
```json
{"name": "Get ticket", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [880, 300],
"parameters": {"url": "=http://data-api:8800/tickets/...", "sendQuery": true, "queryParameters": {"parameters": [{"name": "requester", "value": "={{ ... }}"}]},
"options": {"timeout": 2000, "response": {"response": {"fullResponse": true, "neverError": true}}}},
"retryOnFail": true, "maxTries": 2, "onError": "continueErrorOutput"}
```
```json
{"name": "Ticket check", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [980, 460],
"parameters": {"jsCode": "const status = Number($json.statusCode ?? 200);\nreturn [{ json: { ticket: status >= 500 ? {} : ($json.body ?? {}), ticket_status: status } }];"}}
```
- 결과의 접수 건은 `$json.body`에 있다. 뒤에서 `$('Get ticket').first().json`을 읽던 곳은 `$('Ticket check').first().json.ticket`으로 바꾼다.
- `timeout`(느림)은 여전히 실패로 오류 출력에 간다.
### Edit Fields (Set) — 정해진 답
```json
{"name": "Data down", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1500, 820],
"parameters": {"assignments": {"assignments": [
{"id": "d1", "name": "route", "value": "담당자 연결", "type": "string"},
{"id": "d2", "name": "answer", "value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", "type": "string"}]}, "options": {}}}
```
## 6. 연결 (`connections`)
```json
{
"Has question?": {"main": [[{"node": "Start clock", "type": "main", "index": 0}], [{"node": "Reject empty", "type": "main", "index": 0}]]},
"Search FAQ": {"main": [[{"node": "Get ticket", "type": "main", "index": 0}], [{"node": "Data down", "type": "main", "index": 0}]]},
"Answer model": {"ai_languageModel": [[{"node": "Answer", "type": "ai_languageModel", "index": 0}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
- `onError: continueErrorOutput`인 노드의 `main`도 `[ [성공 쪽], [실패 쪽] ]`.
- 두 갈래가 한 노드로 다시 들어와도 된다 (한 번에 한 갈래만 지나가므로). 다시 모인 노드에서는 `$json`으로 앞 결과를 읽는다.
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.question }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ value: $json }) }}"`
- URL에 질문 같은 글자를 넣을 때는 `encodeURIComponent(...)`.
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-helpdesk",
"before": [{"url": "http://localhost:8800/cache-clear", "body": {}}],
"after": [{"url": "http://localhost:8800/admin/fault", "body": {"faq": "ok", "tickets": "ok"}}],
"cases": [
{"name": "같은 질문", "request": "requests/faq-vpn-again.json",
"expect": {"equals": {"cached": true, "llm_calls": 0}, "contains": {"answer": ["인증서"]}, "max_seconds": 1}},
{"name": "FAQ 오류", "before": [{"url": "http://localhost:8800/admin/fault", "body": {"faq": "error"}}],
"body": {"question": "비밀번호를 잊어버렸어요", "no_cache": true}, "expect": {"equals": {"route": "담당자 연결"}}},
{"name": "LLM 멈춤", "before": [{"llm": "down"}], "after": [{"llm": "up"}], "request": "requests/faq-vpn-nocache.json",
"expect": {"equals": {"route": "간단 답변"}}}
]
}
```
- `before` · `after`: 테스트 파일 전체 또는 케이스 하나의 앞뒤에 한 번. `{"webhook": ...}`(n8n), `{"url": ...}`(데이터 API에 POST), `{"llm": "down"|"up"}`(OpenAI Credential을 일부러 망가뜨렸다 되돌림). `after`는 실패해도 반드시 실행된다.
- `max_seconds`: 응답이 이 시간 안에 와야 함. `equals` · `contains` · `not_contains` · `status`는 71일차와 같다. 필드 이름은 점으로 내려간다 (`checks.faq`).
- `uv run tools/flow.py test <파일> -v`: `-v`(`--verbose`)는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 준다 (보고서 `runs/*.json`에도 같은 것이 남음). `call ... -v`도 보낸 요청 JSON을 응답 위에 함께 보여 준다. **모든 명령이 `-v`를 받는다**: `bench -v`는 응답 JSON마다 · 정렬한 시간 · 단계별 평균 ms, `check -v` · `push -v`는 노드 · 연결 목록과 실행한 docker 명령, `fault -v` · `doctor -v`는 보낸 요청 · 받은 응답 (API 키는 `sk-***`로 가림).
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만).
- 고장 스위치(`/admin/fault`)나 `flow.py llm`을 워크플로우 안에서 부르지 않는다 (연습은 사람이 한다).
- 빠르게 하려고 **답의 내용 검사(테스트의 `contains`)를 없애지 않는다.** 빨라졌으면 품질이 그대로인지 원래 테스트로 확인한다.
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+403
View File
@@ -0,0 +1,403 @@
# Lab 01 · 재고, 단계별 시간을 남기고, 병목 찾기
- 관련 강의: [1단계 · 재기와 병목](../../Lecture/72/01-STAGE1-MEASURE-BOTTLENECK.md)
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 8분 · 예제 2 12분 · 예제 3 8분 · 과제 1 10분 · 과제 2 12분
- 준비: [실습 준비](../../Lecture/72/00-DAY72-PREP.md)의 `doctor` → `ready`, `base-helpdesk` 게시
- 목표: 고치기 전에 **잰다.** 같은 요청을 여러 번 불러 p50·p95를 보고, 워크플로우가 단계별 시간을 결과에 남기게 해 **어디가 느린지** 숫자로 찾는다.
```mermaid
flowchart LR
E1["예제 1<br/>bench로 기준 재기"] --> E2["예제 2<br/>단계별 시간 남기기"] --> E3["예제 3<br/>요청 종류별로 재고<br/>LLM 몫 계산"] --> T1["과제 1<br/>데이터 조회 나누기 ·<br/>가장 느린 단계"] --> T2["과제 2<br/>시간 예산<br/>over_budget"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/72` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/72` 에서 `bench`·`push`·`test`를 **내가** 실행한다 |
`cp templates/PERF-REPORT-TEMPLATE.md my-work/PERF.md` 후 표를 채워 갑니다.
## 예제 1 · bench로 기준 재기 (8분, 에이전트 없이)
비교 기준 `base-helpdesk`는 질문마다 **분류 LLM → FAQ·접수 조회 → 답 LLM → 검토 LLM** 순서로 일합니다.
```bash
uv run tools/flow.py call base-helpdesk requests/faq-vpn.json -v
uv run tools/flow.py bench base-helpdesk requests/faq-vpn.json -n 5
uv run tools/flow.py bench base-helpdesk requests/ticket-own.json -n 5
uv run tools/flow.py bench base-helpdesk requests/off-topic.json -n 5
```
`PERF.md` 1절을 채웁니다. 그리고 브라우저 n8n **Executions** → `72 IT helpdesk baseline` 실행 하나 → 노드마다 오른쪽 위에 걸린 시간을 봅니다.
### 이해하기
**왜 먼저 재나.** 오늘 하루는 느린 실습 워크플로우(IT 헬프데스크)를 빠르고 튼튼하게 고치는 날입니다. 그런데 고치기 전에 지금이 몇 초인지 모르면, 고친 뒤에 "빨라졌다"고 말할 근거가 없습니다. 이 예제는 **바꾸기 전 기준 숫자**를 남기는 일입니다. 오늘 뒤의 모든 실습은 이 숫자와 비교합니다.
**지금 워크플로우가 하는 일.** 질문 하나가 오면 아래 순서로 차례차례 일합니다. 한 단계가 끝나야 다음 단계가 시작하므로, 전체 시간은 단계 시간의 **합**입니다.
```mermaid
flowchart LR
Q["질문"] --> C{{"분류 LLM<br/>faq · ticket · other"}} --> D["FAQ 검색 ·<br/>접수 조회 (데이터 API)"] --> A{{"답 LLM"}} --> R{{"검토 LLM<br/>말투 다듬기"}} --> S["결과"]
```
**두 명령의 차이.**
| 명령 | 하는 일 | 보는 것 |
| --- | --- | --- |
| `call ... -v` | 한 번 부르고 요청 · 응답 JSON을 그대로 보여 줌 | 답이 제대로 오는지, 응답에 어떤 칸이 있는지 |
| `bench ... -n 5` | 같은 요청을 5번 부르고 한 번마다 시간을 적은 뒤 p50 · p95 · max · 실패 수 | "보통 얼마, 느린 쪽 얼마" |
- **p50**은 5번을 빠른 순서로 세웠을 때 가운데(3번째), **p95**는 느린 쪽(5번이면 5번째 = max)입니다. 평균 하나만 적으면 "가끔 아주 느림"이 묻힙니다 (교안 1단계 2절).
- **첫 호출**: push 직후에는 n8n이 막 다시 켜져 연결 · 준비에 시간이 더 듭니다. 첫 `call`이 눈에 띄게 느린 것이 정상이고, 그래서 `PERF.md` 1절에 **따로 적는 칸**이 있습니다.
- 질문 세 종류(VPN FAQ · 내 접수 건 · IT와 상관없는 점심 메뉴)를 따로 재는 까닭: 질문마다 할 일이 다르니 시간도 다를 것 같지요? 실제로 그런지 숫자로 확인합니다.
**Executions 화면.** n8n 브라우저 화면의 Executions에서 실행 하나를 열면 노드마다 걸린 시간이 보입니다. 지금은 눈으로 한 번씩만 볼 수 있지만, 예제 2에서 이 시간을 **결과에 직접 남기게** 만듭니다.
**이 예제가 끝나면 손에 있는 것**: 세 종류 질문의 첫 호출 · p50 · p95 · 실패 수, 그리고 "느린 곳은 데이터 API인가 LLM인가"에 대한 첫 짐작.
<details><summary>▶ 1단계 · 무엇을 보나</summary>
- p50(가운데)과 p95(느린 쪽) 중 사용자가 "느리다"고 느끼는 것은 어느 쪽일까요?
- n8n을 다시 켠 직후(또는 push 직후) **첫 호출**은 다른 호출과 같은가요? bench 첫 줄을 봅니다.
- 세 요청 중 LLM이 할 일이 거의 없는데도 같은 시간이 걸리는 것은?
</details>
<details><summary>▶ 2단계 · Executions 화면에서</summary>
노드 시간을 더해 보면 전체와 거의 같나요? 데이터 API 호출(HTTP Request)과 LLM(Agent) 중 어느 쪽이 대부분인가요? 이 답이 오늘 무엇을 고칠지 정합니다.
</details>
## 예제 2 · 단계별 시간을 결과에 남기기 (12분)
**만들 것**: 기준 워크플로우를 복사해, 결과에 **단계별 시간 · 지나간 단계 · LLM 호출 수**를 남기기. 하는 일(답)은 그대로.
```bash
cp reference/baseline-helpdesk.json my-work/helpdesk.json
```
| 조건 | 값 |
| --- | --- |
| 파일 · 웹훅 | `my-work/helpdesk.json` · `my-helpdesk` (복사한 파일의 웹훅 경로와 이름을 바꿈) |
| 결과에 더할 것 | `timings`(단계별 ms + `total_ms`), `steps`, `llm_calls`, `cached`(지금은 늘 false) — [AGENTS.md](AGENTS.md) 3절 |
| 기대 결과 | `cases/lab01.json` (먼저 열어 읽기) |
### 이해하기
**왜 결과에 시간을 남기나.** `bench`는 사용자가 기다린 **전체** 시간만 알려 줍니다. 그 시간이 어느 단계에서 쓰였는지는 모릅니다. Executions 화면으로 한 번씩 볼 수는 있지만, 질문 수백 개를 모아 보기는 어렵습니다. 그래서 워크플로우가 스스로 **단계별 시간 · 지나간 단계 · LLM을 몇 번 불렀는지**를 결과에 적게 합니다. 업계에서는 이것을 계측(instrumentation)이라고 하고, 큰 시스템은 OpenTelemetry 같은 도구로 같은 일을 합니다.
**어떻게 재나.** 단계가 **끝날 때마다** 그 순간의 시각(ms)을 남기는 작은 Code 노드를 끼워 넣고, 마지막에 앞뒤 시각을 빼면 단계 시간입니다.
```mermaid
flowchart LR
S["시각 t0"] --> C{{"분류"}} --> T1["시각 t1"] --> D["데이터"] --> T2["시각 t2"] --> A{{"답"}} --> T3["시각 t3"] --> R{{"검토"}} --> T4["시각 t4"] --> B["결과 만들기<br/>classify = t1 − t0 …<br/>total = 끝 − t0"]
```
- 시각 노드를 단계 **앞**에 두면 차이가 0이나 음수가 됩니다. "끝난 뒤"가 핵심입니다.
- 단계를 지나가지 않는 질문도 있습니다 (예: 빈 질문, Lab 02의 규칙 분류). 지나가지 않은 노드를 읽으면 오류가 나므로, 읽기 전에 **지나갔는지 먼저 확인**하는 방법을 씁니다 (AGENTS.md 5절).
- `steps`와 `llm_calls`는 LLM에게 쓰게 하지 않습니다. 어느 노드를 지나갔는지는 **워크플로우가 정확히 압니다.** LLM에게 "몇 번 불렀는지 적어"라고 하면 틀린 숫자를 지어낼 수 있습니다.
**하는 일(답)은 그대로.** 이 예제는 "재게" 만드는 것이지 "바꾸는" 것이 아닙니다. 답의 내용이 달라지면 안 됩니다. 요청문에 "답은 그대로, 결과에 칸만 더한다"를 꼭 넣는 까닭입니다.
**테스트 `cases/lab01.json`이 보는 것.**
| 케이스 | 확인하는 것 |
| --- | --- |
| VPN FAQ · 단계별 시간 | `timings`에 단계 칸들이 있고 숫자인지 |
| VPN FAQ · 전체 시간 · LLM 호출 수 | `total_ms`, `steps`에 답 단계, `llm_calls`가 숫자인지 |
| 접수 현황 | 칸을 더해도 접수 답(상태 "처리 중")이 그대로인지 |
| 빈 질문 | 지금처럼 HTTP 400으로 거절하는지 |
**결과 읽기.** `call ... -v` 응답의 `timings`를 보고 단계 칸을 더해 `total_ms`와 비교합니다. 몇십 ms 차이는 시각 노드 · 결과 만들기에 쓴 시간입니다. 가장 큰 칸이 오늘의 병목 후보입니다.
> 접수 케이스가 가끔 실패할 수 있습니다: 검토 LLM이 "처리 중"을 다른 말로 바꿔 쓰는 경우입니다. 워크플로우 잘못이 아니므로 한 번 더 돌려 봅니다 (Lab 02 과제 1에서 이 문제가 사라집니다).
<details><summary>▶ 1단계 · 시간은 어디서 재나</summary>
n8n Code 노드에서 `Date.now()`로 지금 시각(ms)을 얻을 수 있습니다. 단계가 **끝날 때마다** 시각을 남기고, 마지막에 앞 시각과의 차이를 구하면 단계별 시간입니다. [AGENTS.md](AGENTS.md) 5절에 시각을 남기는 노드 예가 있습니다.
</details>
<details><summary>▶ 2단계 · steps와 llm_calls는 누가 쓰나</summary>
LLM에게 "몇 번 불렀는지 적어"라고 하면 안 됩니다. 어느 노드를 지나갔는지는 **워크플로우가** 압니다. 지나갔는지 확인하는 방법도 AGENTS.md 5절에 있습니다 (69일차 협업 기록 `steps`와 같은 생각).
</details>
<details><summary>▶ 3단계 · 보내기 전 점검표</summary>
- [ ] 바탕 파일과 새 웹훅 경로 · 워크플로우 이름이 들어 있다
- [ ] "답은 그대로, 결과 필드만 더한다"가 들어 있다
- [ ] 단계 이름(`classify`, `data`, `answer`, `review`)을 정했다
- [ ] 테스트 파일 경로가 들어 있다
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바탕 파일 · 새 웹훅 경로 · 이름 ② "답은 그대로" ③ 시각을 남길 자리(단계마다 끝난 뒤) ④ 결과에 더할 칸 ⑤ 지나갔는지 확인하는 방법 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 은 reference/baseline-helpdesk.json 을 복사한 것이야. 웹훅 경로를 my-helpdesk, 이름을 "72 IT helpdesk (my)"로 바꾸고,
답은 그대로 두고 결과에 다음을 더해 줘 (AGENTS.md 3절, 5절의 시각 남기기).
- Has question? 다음에 Start clock, Parse category 뒤에 After classify, Get ticket 뒤에 ____① , Answer 뒤에 After answer, Review 뒤에 After review (각각 ____② ).
- Build result 에서 timings {classify_ms, ____③ , answer_ms, review_ms, total_ms}, steps(지나간 단계), llm_calls(지나간 Agent 수), cached:false.
지나갔는지는 ____④ 로.
기대 결과 cases/lab01.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 다른 시각 노드 이름을 보면 "After + 끝난 단계" 모양입니다. FAQ · 접수 조회(데이터)가 끝난 뒤의 노드를 같은 규칙으로 짓습니다.
- ② 지금 시각을 ms 숫자로 돌려주는 자바스크립트 함수입니다. AGENTS.md 5절 `Start clock` Code 예에 있습니다.
- ③ 데이터 조회 단계의 칸입니다. 다른 칸처럼 `<단계>_ms`로 짓고, AGENTS.md 3절 `timings` 설명의 칸 목록과 맞춥니다.
- ④ 노드가 실행됐는지를 true · false로 알려 주는 n8n 표현입니다. AGENTS.md 5절 Code 설명 셋째 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v
```
**관찰**: `timings`를 `PERF.md` 2절에 적습니다. 단계 시간의 합은 `total_ms`와 비슷한가요? 가장 오래 걸린 단계는?
## 예제 3 · 요청 종류별로 재고, LLM이 차지하는 몫 계산 (8분, 에이전트 없이)
예제 2의 워크플로우 그대로 **질문 종류를 바꿔 가며** 잽니다. 같은 워크플로우라도 질문에 따라 지나가는 단계가 다르고, 걸리는 시간도 다릅니다.
```bash
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5 # FAQ 질문 (캐시 없이)
uv run tools/flow.py bench my-helpdesk requests/ticket-own.json -n 5 # 접수 번호 질문
uv run tools/flow.py bench my-helpdesk requests/off-topic.json -n 5 # IT와 상관없는 질문
uv run tools/flow.py call my-helpdesk requests/ticket-own.json -v
uv run tools/flow.py call my-helpdesk requests/off-topic.json -v
```
`PERF.md` 2-1절 표를 채웁니다.
| 질문 종류 | p50 | p95 | `classify_ms` | `data_ms` | `answer_ms` | `review_ms` | `total_ms` | LLM 몫 (%) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| FAQ (VPN) | | | | | | | | |
| 접수 번호 (T-1002) | | | | | | | | |
| IT 밖 (점심 메뉴) | | | | | | | | |
LLM 몫 = (`classify_ms` + `answer_ms` + `review_ms`) ÷ `total_ms` × 100. `timings`는 예제 2의 `call ... -v` 응답에서 읽습니다 (FAQ 줄은 예제 2에서 이미 봄).
### 이해하기
**왜 질문 종류별로 재나.** 한 서비스에는 여러 종류의 요청이 섞여 들어옵니다. 모두 섞어서 재면 "어느 종류가 느린지"가 숨습니다. 업계에서는 이것을 작업 구성(workload mix)이라고 하고, 부하 시험도 실제 요청 비율을 흉내 내어 합니다.
**세 질문이 지나가는 길.**
| 질문 | 데이터 API에서 찾는 것 | LLM이 하는 일 |
| --- | --- | --- |
| FAQ (VPN이 끊겨요) | FAQ 검색 결과가 있음 | 분류 · FAQ로 답 쓰기 · 검토 |
| 접수 번호 (T-1002 어떻게 됐나요) | 내 접수 건이 있음 | 분류 · 상태로 답 쓰기 · 검토 |
| IT 밖 (점심 메뉴) | 아무것도 없음 | 분류 · "찾지 못했다"는 답 쓰기 · 검토 |
- 점심 메뉴 질문은 쓸 데이터가 하나도 없는데도 LLM 세 번을 똑같이 거칩니다. 시간이 다른 질문과 거의 같다면, **할 필요가 없는 일**을 하고 있다는 뜻입니다.
- `requests/faq-vpn-nocache.json`은 `no_cache: true`가 붙은 VPN 질문입니다. 지금은 캐시가 없지만, Lab 02에서 캐시를 만든 뒤에도 같은 조건으로 다시 잴 수 있게 처음부터 이것을 씁니다.
**LLM 몫 계산.** `call ... -v` 응답의 `timings`에서 LLM 단계(분류 · 답 · 검토) 시간을 더해 `total_ms`로 나눕니다.
> 예 (지어낸 숫자): 분류 600 + 답 1100 + 검토 900 = 2600ms, 전체 2700ms → 2600 ÷ 2700 × 100 ≈ 96%
이 몫이 크면 데이터 API를 아무리 빠르게 해도 전체는 거의 그대로입니다 (교안 1단계 7절 암달의 법칙). 큰 덩어리부터 줄여야 합니다.
**`bench`와 `call`의 숫자가 조금 다른 까닭.** `bench`는 밖에서 잰 시간(웹훅을 받고 돌려주는 시간까지), `timings.total_ms`는 워크플로우 안에서 잰 시간입니다. 몇십 ms 차이는 정상입니다.
**이 예제가 끝나면 손에 있는 것**: 종류별 p50 · p95와 LLM 몫. "Lab 02에서 어느 LLM 호출을 줄일까"의 근거가 됩니다.
<details><summary>▶ 1단계 · 무엇을 비교하나</summary>
세 줄의 `data_ms`는 비슷한가요, 다른가요? 세 줄의 LLM 몫은? "IT와 상관없는 질문"은 데이터가 하나도 없는데 왜 시간이 비슷하게 걸릴까요? 어느 단계를 건너뛸 수 있을지 2단계(Lab 02)를 떠올리며 한 줄 적습니다.
</details>
<details><summary>▶ 2단계 · 평균 하나로 말하면 안 되는 까닭</summary>
세 종류가 하루에 똑같이 들어온다고 하면 전체 p50은 어디쯤일까요? 한 종류만 아주 느리면 그 종류를 묻는 사람은 늘 느리다고 느낍니다. **종류별로** 재는 습관이 오늘 Lab 02 · 03에서 "어디를 고칠지"를 정합니다.
</details>
## 과제 1 · 데이터 조회를 나눠 재고, 가장 느린 단계를 알려 주기 (10분)
팀장이 요청했습니다.
> "어디가 느린지 매번 Executions를 열어 볼 수는 없어요. 데이터 조회도 FAQ와 접수 현황을 따로 재 주시고, 결과에 가장 오래 걸린 단계를 같이 주세요."
기대 결과는 `cases/lab01-challenge.json`에 있습니다.
| 케이스 | 기대 |
| --- | --- |
| FAQ · 접수 시간 따로 | `timings`에 `faq_ms`, `ticket_ms` |
| 가장 오래 걸린 단계 | `slowest_step`이 단계 이름 (`classify` · `answer` · `review` 중 하나가 나와야 정상) |
| 접수 현황도 같은 모양 | 답은 그대로, `slowest_step` 있음 |
**먼저 생각해 보기 (3분)**: `slowest_step`을 LLM이 아니라 무엇으로 정할까요? 예제 2의 `timings`만 있으면 계산할 수 있나요?
### 이해하기
**팀장의 말을 바꿔 보면.** "매번 Executions를 열 수 없다" = 사람 눈이 아니라 **결과만 보고** 어디가 느린지 알고 싶다. "FAQ와 접수 현황을 따로" = 지금 `data_ms` 하나로 합쳐진 데이터 조회를 둘로 나눠야 한다.
**왜 데이터 조회를 나누나.** 지금은 FAQ 검색과 접수 조회가 둘 다 빠르니(수십 ms) 합쳐도 문제가 없어 보입니다. 하지만 Lab 03에서 FAQ만 멈추거나 접수 API만 멈추는 고장을 다룹니다. 그때 "어느 쪽이 느린가"를 숫자로 보려면 칸이 따로 있어야 합니다. **나중에 쓸 정보를 미리 남기는** 설계입니다.
```mermaid
flowchart LR
T1["분류 끝 시각"] --> F["FAQ 검색"] --> X["새 시각"] --> G["접수 조회"] --> T2["데이터 끝 시각"]
X -.-> M["FAQ 시간 = 새 시각 − 분류 끝<br/>접수 시간 = 데이터 끝 − 새 시각"]
```
**`slowest_step`은 계산입니다.** `timings`에서 전체(`total_ms`)를 뺀 단계 칸 중 가장 큰 것의 이름을 고르면 됩니다. 판단할 것이 아니라 **세는 것**이므로 LLM이 아니라 Code가 합니다. 숫자가 있으면 판단하지 않고 셉니다.
**결과에서 기대할 것.**
- 데이터 단계 둘은 아주 작고, 가장 느린 단계는 LLM 단계 중 하나일 것입니다.
- 답 LLM과 검토 LLM의 시간이 비슷해서 실행마다 1등이 바뀔 수 있습니다. push 직후 첫 호출에서는 첫 LLM 단계(분류)가 준비 시간을 떠안아 1등이 되기도 합니다. 그래서 테스트는 LLM 단계 셋 중 **하나면** 통과로 봅니다.
**테스트 두 개를 함께 돌리는 까닭.** 새 테스트(`lab01-challenge`)만 통과하고 예제 2의 테스트(`lab01`)가 깨지면, 새 칸을 만들다가 원래 칸을 망가뜨린 것입니다. 고칠 때마다 **앞 테스트도 다시** 돌리는 것이 오늘 내내 쓰는 습관입니다.
<details><summary>▶ 1단계 · 나누려면 시각이 하나 더</summary>
FAQ 조회와 접수 조회 사이에 시각을 하나 더 남기면 됩니다. 그러면 `data_ms`가 둘로 나뉩니다. 이름은 테스트가 찾는 이름으로.
</details>
<details><summary>▶ 2단계 · 고치는 요청문의 모양</summary>
고칠 파일, 더할 시각, 새 필드 이름, 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건. 한 번에 이 일만 시킵니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 더할 시각과 그 자리 ③ 나눌 칸 이름 두 개 ④ `slowest_step`을 정하는 방법(LLM 아님) ⑤ 두 테스트 모두 통과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Search FAQ 와 Get ticket 사이에 ____① (Date.now()) 를 더해, timings 의 data_ms 를 ____② 와 ____③ 로 나눠 줘.
total_ms 를 뺀 timings 중 ____④ 단계 이름(_ms 뺀 것)을 slowest_step 으로. cases/lab01-challenge.json, cases/lab01.json 둘 다 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 예제 2에서 만든 시각 노드들의 이름 규칙("After + 끝난 단계")을 따릅니다. 이 노드 앞에서 끝난 단계는 무엇인가요?
- ② 테스트가 찾는 칸 이름 그대로 써야 합니다. `cases/lab01-challenge.json`의 `expect`를 열어 봅니다 (AGENTS.md 3절 "실습마다 더하는 칸"의 Lab 01 과제 1 줄도 같음).
- ③ ②와 짝이 되는 나머지 하나입니다. 무엇을 둘로 나누는지 생각합니다.
- ④ "가장 느린"을 숫자로 말하면 어떤 값을 고르는 것인가요? 한 낱말입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json -v
```
**더 생각해 보기**: 사용자가 기다리는 시간 중 LLM이 차지하는 비율은? 2단계에서 무엇을 줄이면 가장 빨라질까요? `PERF.md`에 한 줄로 적습니다.
## 과제 2 · 시간 예산을 정하고, 넘었는지 결과에 남기기 (12분)
팀장이 다시 요청했습니다.
> "사용자들이 '3초 넘으면 느리다'고 해요. 요청마다 3초를 넘었는지 결과에 남겨 주세요. 시험할 때는 기준을 바꿔 보고 싶으니 요청에서 정할 수 있게 해 주시고요. 그리고 넘었을 때 LLM 탓인지 바로 보이게 LLM이 쓴 시간도 같이요."
기대 결과는 `cases/lab01-challenge2.json`에 있습니다 (먼저 열어 읽기).
| 케이스 | 기대 |
| --- | --- |
| 예산 안 (`budget_ms: 60000`) | `over_budget: false`, `budget_ms: 60000`, `llm_ms`가 숫자, 답에 "인증서" |
| 예산 넘음 (`budget_ms: 500`) | `over_budget: true`, `budget_ms: 500` |
| 예산을 안 주면 | `budget_ms: 3000` (기본값) |
| 접수 현황도 같은 모양 | `budget_ms: 3000`, `category: ticket`, `llm_ms`, `slowest_step` 그대로 |
**먼저 생각해 보기 (3분)**: 시간 예산(목표 시간)은 누가 정하나요? 넘었을 때 워크플로우가 **답을 멈춰야** 할까요, **기록만** 해야 할까요? `llm_ms`를 `timings` 안에 넣으면 과제 1의 `slowest_step`은 어떻게 될까요?
### 이해하기
**시간 예산이란.** "한 요청은 이 시간 안에 끝나야 한다"는 목표입니다. 기준이 있어야 "빠르다 · 느리다"를 말할 수 있습니다. 운영에서는 이것을 모아 **SLO**(서비스 수준 목표, 예: "요청의 95%가 3초 안")로 씁니다 (교안 1단계 9절).
**세 칸의 뜻.**
| 칸 | 뜻 | 왜 필요한가 |
| --- | --- | --- |
| `budget_ms` | 이번 요청의 예산 (본문으로 바꿀 수 있고, 없으면 기본값) | 시험할 때 일부러 작게 · 크게 주어 두 결과를 다 확인하려고 |
| `over_budget` | 전체 시간이 예산을 넘었나 (true · false) | 하루치를 모아 "몇 %가 넘었나"를 세려고 |
| `llm_ms` | LLM 단계 시간의 합 | 넘었을 때 "LLM 탓인가"를 바로 보려고 |
**넘으면 멈추나?** 아닙니다. 거의 다 만든 답을 버리면 사용자는 더 오래 기다리고도 답을 못 받습니다. 예산은 **기록하고 모아서 고칠 곳을 정하는** 데 씁니다. 오래 걸리는 외부 서비스를 끊는 일은 Lab 03의 timeout이 합니다. 둘을 헷갈리지 않습니다.
**기본값과 숫자 바꾸기.** 본문에 `budget_ms`가 없을 수도 있고, 있어도 글자(`"500"`)로 올 수도 있습니다. 글자 `"500"`과 숫자 `3023`을 비교하면 엉뚱한 결과가 나올 수 있으므로 **숫자로 바꾼 뒤** 비교합니다.
**`llm_ms`를 어디에 두나.** 과제 1의 `slowest_step`은 `timings` 칸 중 가장 큰 것을 고릅니다. LLM 단계를 모두 더한 값을 `timings` 안에 넣으면 늘 그것이 1등이 되어 `slowest_step`이 망가집니다. 그래서 결과의 다른 자리에 둡니다 (AGENTS.md 3절 "실습마다 더하는 칸").
**테스트 `cases/lab01-challenge2.json`의 네 케이스.**
- 아주 넉넉한 예산 → 넘지 않아야 함, 아주 작은 예산(0.5초) → 반드시 넘어야 함. 두 끝을 주면 계산이 맞는지 확실히 알 수 있습니다.
- 예산을 안 주면 기본값이 들어가는지.
- 접수 질문에도 같은 칸이 있는지 (질문 종류마다 칸이 달라지면 모아 보기 어렵습니다).
> 기본 예산 3초에서는 지금 워크플로우가 3초 근처라 실행마다 넘기도 하고 안 넘기도 합니다. 그래서 테스트는 기본값 케이스에서 넘었는지는 보지 않고 `budget_ms` 값만 봅니다. Lab 02에서 LLM을 줄이면 넉넉히 들어옵니다.
<details><summary>▶ 1단계 · 기본값</summary>
본문에 `budget_ms`가 없을 때 3000을 쓰는 것은 한 줄입니다 (`?? 3000`). 숫자로 바꾸는 것도 잊지 않습니다 (본문은 글자일 수도 있음).
</details>
<details><summary>▶ 2단계 · `llm_ms`는 어디에</summary>
`slowest_step`은 `timings`에서 `total_ms`만 빼고 가장 큰 칸을 고릅니다. LLM 단계를 모두 더한 `llm_ms`가 `timings` 안에 있으면 늘 그것이 가장 큽니다. 그래서 `llm_ms`는 결과의 **맨 바깥**에 둡니다 ([AGENTS.md](AGENTS.md) 3절 "실습마다 더하는 칸").
</details>
<details><summary>▶ 3단계 · 운영에서는</summary>
`over_budget: true`가 하루에 몇 번 나왔는지 세면 "3초 안에 답한 비율"이 됩니다. 이것이 운영에서 말하는 **목표 수준(SLO)** 의 씨앗입니다. 오늘은 결과에 남기기까지만 합니다 (76일차 에러 처리 · 79일차 운영 대시보드에서 다시).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 노드(결과를 만드는 Code) ② 예산의 이름 · 기본값 · 숫자로 ③ `llm_ms`를 무엇의 합으로 · 어디에 ④ `over_budget` 계산 ⑤ 지금 칸은 그대로 ⑥ 테스트 셋**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Build result 에서, 본문 ____① (없으면 ____② , 숫자로)를 읽어
결과에 llm_ms(classify · answer · review 의 _ms 합, timings ____③ 에 둔다), budget_ms, over_budget( ____④ > budget_ms)을 더해 줘.
timings, slowest_step 은 그대로. cases/lab01-challenge2.json, cases/lab01-challenge.json, cases/lab01.json 모두 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 요청 본문에서 읽는 칸 이름입니다. 결과에도 같은 이름으로 나갑니다 (AGENTS.md 3절 "입력" 칸).
- ② 본문에 없을 때 쓸 기본값입니다. 위 조건 표와 AGENTS.md 3절 "입력" 칸 괄호에 있습니다. 단위는 ms.
- ③ `timings` 안과 밖 중 어디일까요? `slowest_step`은 `timings` 칸 중 가장 큰 것을 고른다는 점을 생각합니다 (교안 1단계 9절).
- ④ 요청 하나 전체가 걸린 시간을 담은 칸입니다. 결과 안의 어디에 있는지까지 점으로 이어 씁니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.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 test cases/lab01.json -v
```
**더 생각해 보기**: 예제 3의 세 종류 중 기본 예산 3초를 자주 넘는 것은? 넘는 종류만 2단계에서 먼저 고치면 됩니다. `PERF.md`에 한 줄로 적습니다.
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `call my-helpdesk`가 404 | 복사한 파일의 웹훅 경로가 아직 `base-helpdesk`. 경로를 바꿨는지, push했는지 |
| `push` 뒤 `base-helpdesk`가 안 됨 | 같은 경로를 쓰는 파일을 push하면 예전 것이 게시 해제됩니다. `uv run tools/flow.py push reference/baseline-helpdesk.json` |
| `timings`의 값이 음수·0 | 시각을 남기는 노드의 순서. 단계가 **끝난 뒤**에 있는지 |
| `Referenced node is unexecuted` | 지나가지 않은 노드를 읽음. `isExecuted`로 먼저 확인 |
| `slowest_step`이 늘 `llm` | `llm_ms`를 `timings` 안에 넣었음. 결과 맨 바깥으로 |
| `over_budget`이 늘 `false` | `budget_ms`를 글자로 비교함. `Number(...)`로 |
| 계속 안 됨 | `cp reference/lab01-example2-timings.json my-work/helpdesk.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: 기준 p50 · p95 · 첫 호출을 `PERF.md` 1절에 적었다.
- [ ] 예제 2: `cases/lab01.json` 통과, `timings`를 2절에 적었다.
- [ ] 예제 3: 질문 종류 세 줄의 p50 · p95 · 단계 시간 · LLM 몫을 적었다.
- [ ] 과제 1: `cases/lab01-challenge.json` 통과, `lab01`도 통과.
- [ ] 과제 2: `cases/lab01-challenge2.json` 통과, `lab01-challenge` · `lab01`도 통과.
- [ ] `my-work/PROMPTS.md`에 요청문을 모았다.
+453
View File
@@ -0,0 +1,453 @@
# Lab 02 · 규칙 먼저, 캐시, LLM 한 번 — 빠르게, 품질은 그대로
- 관련 강의: [2단계 · 실행 흐름 최적화](../../Lecture/72/02-STAGE2-OPTIMIZE-FLOW.md)
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 10분 · 예제 2 10분 · 예제 3 6분 · 과제 1 12분 · 과제 2 12분
- 준비: Lab 01의 `my-work/helpdesk.json` (없으면 `cp reference/lab01-example2-timings.json my-work/helpdesk.json` 후 push)
- 목표: Lab 01에서 찾은 병목(LLM 호출)을 **한 가지씩** 줄이고, 바꿀 때마다 **다시 재고** 원래 테스트로 품질을 확인한다.
```mermaid
flowchart LR
E1["예제 1<br/>규칙으로 되면<br/>분류 LLM 생략"] --> E2["예제 2<br/>같은 질문은 캐시"] --> E3["예제 3<br/>캐시 적중률과 시간"] --> T1["과제 1<br/>LLM 한 번 · 2.5초 안"] --> T2["과제 2<br/>FAQ 번호로<br/>한 번 더 캐시"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 바꿀 때마다 `PERF.md` 3절에 한 줄씩 적습니다.
## 예제 1 · 규칙으로 되면 분류 LLM을 부르지 않기 (10분)
**만들 것**: 접수 번호(`T-1002` 같은)가 있으면 `ticket`, FAQ 검색 결과가 있으면 `faq`로 **규칙이** 정하고, 규칙으로 모를 때만 분류 LLM을 부르게 하기.
| 조건 | 값 |
| --- | --- |
| 파일 | `my-work/helpdesk.json` 고치기 |
| `steps` | 규칙으로 정했으면 `rule`, LLM이 정했으면 `classify` |
| 기대 결과 | `cases/lab02.json` 앞 네 케이스 (나머지는 예제 2) |
### 이해하기
**Lab 01에서 알게 된 것.** 시간의 대부분이 LLM 세 번(분류 · 답 · 검토)이었습니다. 그중 **분류**는 "이 질문이 FAQ냐, 접수냐, 그 밖이냐"를 고르는 일입니다. 그런데 이 중 많은 경우는 LLM 없이도 확실히 알 수 있습니다.
| 질문의 단서 | 확실히 아는 것 |
| --- | --- |
| `T-1002`처럼 접수 번호 모양이 있음 | 접수 질문 |
| FAQ 검색에서 결과가 나옴 | FAQ로 답할 수 있는 질문 |
| 둘 다 아님 | 모름 → 이때만 LLM에게 묻는다 |
이렇게 **싼 규칙을 먼저, 모를 때만 비싼 LLM**을 쓰는 것을 단계적 처리(cascade) 또는 라우팅이라고 합니다. 고객센터가 자주 묻는 질문은 자동 응답으로, 나머지만 상담원에게 넘기는 것과 같습니다.
**순서를 바꿔야 하는 까닭.** "FAQ 결과가 있으면 faq"라는 규칙은 FAQ 검색을 **먼저** 해야 쓸 수 있습니다. 지금은 분류가 먼저입니다. 데이터 조회는 수십 ms라 앞으로 옮겨도 손해가 거의 없습니다.
```mermaid
flowchart LR
subgraph 지금
A1{{"분류 LLM"}} --> B1["데이터 조회"] --> C1{{"답"}}
end
subgraph 바꾼 뒤
B2["데이터 조회"] --> R{"규칙으로<br/>아나?"}
R -->|예| C2{{"답"}}
R -->|아니오| A2{{"분류 LLM"}} --> C2
end
```
**두 길이 다시 만날 때.** 규칙 길과 LLM 길이 같은 답 노드로 들어갑니다. 답 노드는 "분류 결과"를 한 가지 방법으로 읽어야 하므로, 두 길의 마지막 노드가 **같은 모양**으로 분류를 내놓게 합니다. 결과의 `steps`에는 어느 길로 왔는지(규칙 · LLM)를 남겨, 나중에 "규칙이 몇 %를 처리했나"를 셀 수 있게 합니다.
**규칙의 위험.** 규칙은 빠르고 늘 같지만 정해 둔 것밖에 모릅니다. FAQ 낱말이 들어 있지만 FAQ가 답이 아닌 질문도 있을 수 있습니다. 그래서 규칙도 테스트합니다: `cases/lab02.json`에는 규칙으로 정해지는 질문(프린터 FAQ · 접수 번호 · 없는 접수 번호)과 **규칙으로 모르는 질문**이 함께 있습니다.
**결과에서 기대할 것.** 규칙으로 정한 질문은 `llm_calls`가 하나 줄고 시간도 그만큼 줄어듭니다. `cases/lab02.json`의 뒤쪽 캐시 케이스는 예제 2에서 만들 것이니 지금은 실패가 정상입니다.
<details><summary>▶ 1단계 · 순서를 바꿔야 하나</summary>
지금은 분류 → 데이터 조회 순서입니다. "FAQ 검색 결과가 있으면 faq"라는 규칙을 쓰려면 무엇이 먼저 있어야 할까요? 68일차 "나누기와 유형은 LLM, 담당과 빠진 입력은 규칙"을 떠올립니다.
</details>
<details><summary>▶ 2단계 · 두 길이 다시 만나는 곳</summary>
규칙으로 정한 길과 LLM이 정한 길이 같은 답 노드로 들어갑니다. 답 노드가 분류를 **어느 길에서 왔든 같은 이름**으로 읽게 하려면? (두 길의 마지막 노드가 같은 모양 `{category}`를 내놓게)
</details>
<details><summary>▶ 3단계 · 규칙이 틀리면</summary>
규칙으로 `faq`가 됐는데 사실 FAQ와 상관없는 질문이라면? 테스트의 "규칙으로 모르면 LLM이 분류" 케이스는 어떤 질문인가요?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바뀐 노드 순서 ② 규칙 셋(접수 번호 · FAQ 결과 · 나머지) ③ 규칙으로 모를 때만 분류 LLM ④ 두 길이 같은 모양으로 만나기 ⑤ `steps`에 무엇을 ⑥ 기대 결과(앞 네 케이스)**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 순서를 바꿔 줘: Has question? → Start clock → Search FAQ → Get ticket → After data → Rule classify(Code) → Known?(If).
Rule classify: 질문에 T-1234 같은 번호( ____① )가 있으면 ticket, FAQ 결과가 ____② 이면 faq, 아니면 unknown. {category, by:'rule'}.
Known? true → Answer, false → Classify → Parse category → After classify → Answer. Answer 는 $json.category 로 분류를 읽는다.
Build result: 분류는 ____③ 가 실행됐으면 그것, 아니면 Rule classify. steps 에 ____④ 또는 classify.
나머지(답, 검토, timings)는 그대로. 기대 결과 cases/lab02.json 앞 네 케이스.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 정규식입니다. `T-1002`와 `t1002`가 모두 잡혀야 합니다: 하이픈은 있어도 없어도, 대소문자는 가리지 않고, 숫자는 몇 자리인가요?
- ② FAQ 검색 결과 `results`가 몇 개일 때 "FAQ로 답할 수 있다"고 볼까요? (AGENTS.md 2절 `/faq` 응답 모양)
- ③ LLM이 분류하는 길에서 분류 결과를 마지막으로 만든 노드입니다. 위 줄의 false 쪽 순서를 봅니다.
- ④ 규칙 길을 지났다는 표시입니다. AGENTS.md 3절 `steps` 예에 있는 단계 이름을 봅니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py bench my-helpdesk requests/faq-printer.json -n 5
```
## 예제 2 · 같은 질문은 캐시에서 (10분)
**만들 것**: FAQ 답은 데이터 API의 캐시(`/cache/<key>`)에 저장하고, 같은 질문이 오면 **LLM 없이** 바로 답하기.
| 조건 | 값 |
| --- | --- |
| 캐시 키 | 질문을 앞뒤 공백 제거 · 소문자 · 연속 공백 하나로 ([AGENTS.md](AGENTS.md) 3절) |
| 결과 | 캐시에서 왔으면 `cached: true`, `llm_calls: 0` |
| 캐시하지 않는 것 | 접수 현황(`ticket`), 본문 `no_cache: true` |
| 기대 결과 | `cases/lab02.json` 전체 (`before`가 캐시를 비움) |
### 이해하기
**캐시란.** 한 번 만든 답을 저장해 두었다가, 같은 질문이 오면 다시 만들지 않고 저장한 답을 돌려주는 것입니다. 오늘은 데이터 API에 있는 작은 저장소(`/cache/<key>`)를 씁니다. 운영에서는 Redis 같은 저장소가 같은 일을 합니다.
```mermaid
flowchart LR
Q["질문"] --> K["캐시 키 만들기"] --> L{"캐시에 있나"}
L -->|있음| H["저장한 답 · LLM 0번"]
L -->|없음| W["원래 흐름<br/>(데이터 · 규칙 · LLM)"] --> S["답을 저장"] --> O["답"]
```
- **확인은 일하기 전, 저장은 답을 만든 뒤.** 이 순서가 바뀌면 캐시가 아무 일도 하지 않습니다. 이 방식을 캐시 어사이드(cache-aside)라고 부릅니다.
- **캐시 키**: 같은 답을 받아야 할 질문이 같은 키를 갖게 하는 이름표입니다. `" VPN 접속이…"`와 `"vpn 접속이…"`는 사람 눈에는 같은 질문이므로, 앞뒤 공백 · 대소문자 · 연속 공백을 다듬은 글자를 키로 씁니다.
- **없을 때 404**: 데이터 API는 캐시에 없으면 HTTP 404를 돌려줍니다. 이것은 고장이 아니라 "없음"이라는 정상 답이므로, 404에서 워크플로우가 멈추지 않게 해야 합니다.
- **수명(ttl)**: 오늘은 600초가 지나면 저절로 지워집니다. FAQ 원문이 바뀌어도 최대 10분 뒤에는 새 답이 나갑니다.
**캐시하면 안 되는 것.**
| 답 | 캐시해도 되나 |
| --- | --- |
| VPN FAQ 답 | 됨: 같은 질문이면 언제 · 누구에게나 같은 답 |
| T-1002 접수 상태 | **안 됨**: 10분 뒤 상태가 바뀌면 예전 답이 나감 |
| `no_cache: true`를 보낸 요청 | 읽지도 쓰지도 않음 (재기 · 테스트용) |
**테스트를 흔드는 캐시.** 앞 테스트가 남긴 캐시 때문에 다음 테스트 결과가 달라질 수 있습니다. 그래서 `cases/lab02.json`은 맨 앞 `before`에서 캐시를 비우고 시작합니다. 손으로 시험할 때도 결과가 이상하면 먼저 캐시를 비워 봅니다.
**결과에서 기대할 것.** 처음 묻는 VPN은 평소 시간, 띄어쓰기만 다른 같은 질문은 0.1초 안팎에 `cached: true`, `llm_calls: 0`. 접수 질문은 두 번 물어도 매번 새로 조회합니다.
<details><summary>▶ 1단계 · 어디서 확인하고 어디서 저장하나</summary>
캐시는 **일을 하기 전에** 보고, 답을 만든 **뒤에** 저장합니다. 캐시에 없을 때 데이터 API는 404를 돌려줍니다. 404에서 워크플로우가 멈추지 않게 하는 옵션이 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 2단계 · 캐시하면 안 되는 것</summary>
"T-1002 처리 상태"를 캐시하면 10분 뒤 상태가 바뀌어도 예전 답이 나갑니다. 캐시해도 되는 답과 안 되는 답을 나누는 기준을 요청문에 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 캐시 키 만드는 법 ② 확인은 일하기 **전**, 404에서 멈추지 않게 ③ 캐시에 있으면 무엇을 돌려주나 ④ 저장은 답을 만든 **뒤**, 무엇만 ⑤ 캐시하지 않는 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 캐시를 더해 줘 (AGENTS.md 2절 데이터 API, 3절 캐시 규칙).
Start clock 다음: Cache key(Code: question 을 ____① , use_cache = ____② )
→ Cache lookup(GET http://data-api:8800/cache/<encodeURIComponent(key)>, ____③ ) → Cached?(value 있고 use_cache)
true → Cached result(route·category·answer·sources + llm_calls 0, cached true, steps ['cache'], timings.total_ms) → Send cached
false → 지금 흐름(Search FAQ ...).
Build result 뒤: Cache it?(category faq 이고 use_cache) true → Store in cache(POST /cache/<key> {value:{route,category,answer,sources}, ttl 600}) → Send result.
____④ 는 저장하지 않는다. 기대 결과 cases/lab02.json 전체, lab01 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 띄어쓰기 · 대소문자만 다른 두 질문이 같은 키가 되게 하는 다듬기 세 가지입니다. AGENTS.md 3절 마지막 줄.
- ② 요청 본문의 어느 칸이 "캐시를 쓰지 마"라는 뜻인가요? (AGENTS.md 3절 "입력" 칸) 그 칸이 true가 **아닐** 때 참이 되는 식입니다.
- ③ 캐시에 없으면 데이터 API가 404를 돌려줍니다. 404에서 워크플로우가 멈추지 않게 하는 HTTP Request 옵션입니다 (AGENTS.md 5절).
- ④ 상태가 바뀌어서 캐시하면 안 되는 답입니다. AGENTS.md 3절의 굵은 글씨 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py test cases/lab01.json -v # 원래 테스트도
curl -s localhost:8800/cache-stats
```
**관찰**: 두 번째 VPN 질문은 몇 초였나요? `cache-stats`의 hits · misses는? 캐시가 맞을 확률(적중률)이 낮은 질문은 어떤 질문일까요?
## 예제 3 · 캐시 적중률과 시간 재기 (6분, 에이전트 없이)
예제 2의 캐시가 **얼마나** 도움이 되는지 숫자로 봅니다.
```bash
curl -s -XPOST localhost:8800/cache-clear
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 # 첫 번은 캐시에 없음, 나머지 4번은 있음
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5 # 캐시를 쓰지 않음
curl -s localhost:8800/cache-stats
```
`PERF.md` 3-1절에 적습니다.
| 재기 | 첫 호출 | p50 | max | 적중 (`hits`) · 못 찾음 (`misses`) |
| --- | --- | --- | --- | --- |
| 캐시 씀 (`faq-vpn`) | | | | |
| 캐시 안 씀 (`faq-vpn-nocache`) | | | | |
### 이해하기
**적중률이란.** 캐시를 찾았을 때 있었던 비율입니다. 데이터 API의 `/cache-stats`가 `hits`(있었음) · `misses`(없었음)를 셉니다.
> 적중률 = hits ÷ (hits + misses)
**적중률이 정하는 평균 시간.** 캐시가 아무리 빨라도, 맞는 경우가 적으면 평균은 거의 그대로입니다.
> 평균 ≈ 적중률 × 캐시 시간 + (1 − 적중률) × 원래 시간
| 적중률 | 캐시 0.1초 · 원래 2초일 때 평균 (계산 예) |
| --- | --- |
| 10% | 0.1 × 0.1 + 0.9 × 2 = 1.81초 |
| 30% | 0.3 × 0.1 + 0.7 × 2 = 1.43초 |
| 60% | 0.6 × 0.1 + 0.4 × 2 = 0.86초 |
**두 번의 bench가 보여 주는 것.**
- 캐시를 비우고 같은 VPN 질문 5번: 첫 번은 캐시에 없어 평소 시간, 나머지 넷은 캐시에서 아주 빠르게. 그래서 **p50은 아주 작고 p95 · max는 첫 번의 시간**이 됩니다. 캐시는 "보통"을 크게 줄이지만 "처음 묻는 질문"은 줄이지 못한다는 뜻입니다.
- `no_cache`로 5번: 매번 원래 흐름. 이것이 캐시 없는 기준입니다.
**숫자가 무엇을 세는지 먼저 확인.** `no_cache` 요청도 캐시를 **조회는** 할 수 있습니다 (쓰지는 않음). 그러면 hits · misses에 함께 세어집니다. 도구가 내놓는 숫자는 늘 "무엇을 셌나"를 먼저 봅니다.
**실제 서비스라면.** 사람들은 같은 것을 **다른 말로** 묻습니다. 질문 글자를 키로 쓰는 캐시는 글자가 같아야만 맞으므로 실제 적중률이 낮을 수 있습니다. 이 생각이 과제 2(FAQ 번호로 캐시)로 이어집니다.
<details><summary>▶ 1단계 · 적중률로 평균 시간 어림하기</summary>
캐시에서 오면 약 0.1초, 아니면 약 2초라고 할 때, 하루 질문의 30%가 캐시에서 온다면 평균은 얼마쯤일까요? (0.3 × 0.1 + 0.7 × 2) 적중률이 10%라면? **캐시가 빨라도 적중률이 낮으면 평균은 거의 그대로**입니다.
</details>
<details><summary>▶ 2단계 · 적중률을 올리려면</summary>
지금 캐시 키는 "질문 글자"입니다. "VPN이 자꾸 끊겨요"와 "VPN 접속이 자꾸 끊겨요"는 다른 키입니다. 같은 답을 받을 질문들이 **같은 키**를 갖게 하려면 무엇을 키로 쓸 수 있을까요? (과제 2)
</details>
## 과제 1 · LLM 한 번, 2.5초 안 (12분)
팀장이 요청했습니다.
> "캐시는 같은 질문에만 도움이 되잖아요. 처음 묻는 FAQ · 접수 질문도 2.5초 안에 답해 주세요. 대신 답의 내용은 지금과 같아야 합니다."
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5
```
| 케이스 | 기대 |
| --- | --- |
| VPN · 비밀번호 · 접수 현황 (캐시 없이) | `llm_calls: 1`, 답에 핵심 내용, 2.5초 안 |
| 규칙으로 모르는 질문 | `llm_calls: 2` (분류 + 답) |
| 빈 질문 | 400, 1초 안 (LLM 없이) |
**먼저 생각해 보기 (5분)**: 지금 남은 LLM 호출은 무엇무엇인가요? 그중 **셀 수 있는 검사**로 바꿀 수 있는 것은? (69일차 "셀 수 있는 검사는 규칙, 뜻을 봐야 하는 검사만 LLM")
### 이해하기
**팀장의 말을 바꿔 보면.** 캐시는 **두 번째 같은 질문**에만 도움이 됩니다. 처음 묻는 질문을 빠르게 하려면 흐름 자체에서 LLM을 줄여야 합니다. 지금 남은 LLM은 (규칙으로 정해진 질문이면) 답 · 검토 두 번입니다. 목표는 한 번입니다.
**검토 LLM이 실제로 하는 일.** Executions에서 답 LLM의 출력과 검토 LLM의 출력을 나란히 보면, 대부분 **말투만** 다듬습니다. 그런데 가끔 사실을 바꿔 쓰기도 합니다 (Lab 01에서 접수 상태 "처리 중"이 다른 말로 바뀌어 테스트가 실패한 일). 검토가 오히려 틀림을 만든 것입니다.
**두 가지 검사 방법.**
| 방법 | 잘하는 것 | 약한 것 |
| --- | --- | --- |
| 코드 검사 | 빠름(ms), 늘 같음: 빈 답, 한국어인지, 너무 긴지 | 뜻은 모름 |
| LLM 검토 | 뜻을 봄: 사실과 맞나, 없는 약속 | 느리고(약 1초) 비싸고, 흔들림 |
말투는 처음부터 **답 LLM의 지시문**으로 정하면 검토가 필요 없습니다. 셀 수 있는 것은 코드가 봅니다 (69일차 "셀 수 있는 검사는 규칙, 뜻을 봐야 하는 검사만 LLM").
```mermaid
flowchart LR
subgraph 지금
A1{{"답 LLM"}} --> R1{{"검토 LLM"}} --> B1["결과"]
end
subgraph 바꾼 뒤
A2{{"답 LLM<br/>(지시문에 말투)"}} --> R2["검사 Code<br/>빈 답 · 한국어 · 길이"] --> B2["결과"]
end
```
**잃는 것도 있습니다.** 검토가 하던 "뜻을 보는" 일은 이제 아무도 하지 않습니다. 그 일이 꼭 필요한 업무(예: 규제가 있는 답)라면 검토를 남기고 다른 곳을 줄여야 합니다. 바꾼 것과 잃은 것을 `PERF.md` 3절에 함께 적습니다.
**테스트 `cases/lab02-challenge.json`의 조건.**
- 처음 묻는 FAQ · 접수 질문(캐시 없이)이 LLM 한 번, 2.5초 안.
- 규칙으로 모르는 질문은 분류 + 답 두 번 (분류는 남김).
- 빈 질문은 LLM 없이 바로 거절.
- 시간 기대가 있는 테스트는 첫 호출에 흔들리므로, 이 파일의 `before`가 먼저 한 번 불러 둡니다(워밍업). `--repeat 3`으로 세 번씩 돌려 운이 아닌지 확인합니다.
**결과에서 기대할 것.** LLM 하나를 빼면 약 1초가 줄어듭니다 (Lab 01 예제 3의 검토 단계 시간과 비슷한 만큼). 같은 요청으로 `bench`를 다시 재어 전 · 후를 같은 날 비교합니다.
<details><summary>▶ 1단계 · 검토 LLM은 무엇을 하나</summary>
Executions에서 검토 LLM의 입력과 출력을 비교해 봅니다. 답의 **사실**이 바뀌나요, 말투만 바뀌나요? 말투는 답 LLM의 지시문으로도 정할 수 있지 않을까요?
</details>
<details><summary>▶ 2단계 · 무엇을 남길까</summary>
검토 LLM을 없애면 잃는 것이 있습니다. 빈 답, 한국어가 아님, 너무 김 같은 것은 Code로 확인할 수 있습니다. `steps`의 이름도 바뀌겠지요 (예: `check`).
</details>
<details><summary>▶ 3단계 · 확인 순서</summary>
`lab02-challenge` → `lab02` → `lab01` 순서로 모두. 그리고 `bench`로 p50 · p95를 다시 재 `PERF.md` 3절에 적습니다. 첫 호출이 느리면 테스트가 실패할 수 있습니다. 이 테스트 파일의 `before`가 무엇을 하는지 열어 봅니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 지울 노드(검토 LLM과 그 모델) ② 그 자리에 둘 Code와 셀 수 있는 검사 셋 ③ 말투는 어디서 정하나 ④ `steps` · `llm_calls`가 어떻게 바뀌나 ⑤ "나머지는 그대로" ⑥ 테스트 셋 모두**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에서 Review(검토 LLM)와 Review model 을 지우고, 그 자리에 Review check(Code)를 둬:
Answer 의 output 을 trim, 비었으면 empty, 한글이 없으면 not_korean, ____① 자 넘으면 자르고 too_long 을 review_problems 에.
Answer 지시문에 " ____② "가 이미 있는지 확인. steps 에는 review 대신 ____③ , llm_calls 는 ____④ .
나머지는 그대로. cases/lab02-challenge.json, lab02.json, lab01.json 모두 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 답 길이의 상한입니다. 교안 2단계 6절의 검사 표에 있습니다. 내 업무라면 몇 자가 알맞을지도 생각해 봅니다.
- ② 검토 LLM이 하던 말투 다듬기를 대신할 지시입니다. 말투 하나와 길이 제한 하나를 넣습니다.
- ③ 검토 대신 코드 검사를 지났다는 짧은 단계 이름입니다. 보고서에서 알아볼 수 있으면 내가 정해도 됩니다.
- ④ 지운 노드는 세면 안 됩니다. 이제 요청 하나에 LLM 노드를 몇 개 지나가나요? (테스트의 `llm_calls` 기대도 확인)
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02-challenge.json -v --repeat 3
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5
```
**더 생각해 보기**: 더 작은 모델(예: 분류만 작은 모델)로 바꾸는 방법도 있습니다. 그때 무엇을 다시 재야 할까요? (75일차 비용에서 다시)
## 과제 2 · FAQ 번호로 한 번 더 캐시 (12분)
팀장이 다시 요청했습니다.
> "캐시 적중이 생각보다 적어요. 다들 VPN이 끊긴다고 묻는데, 말이 조금씩 달라서 매번 LLM을 부르더라고요. 같은 FAQ로 답하는 질문이면 말이 달라도 저장해 둔 답을 쓰게 해 주세요. 그리고 어떤 캐시에서 왔는지 결과에 남겨 주세요."
기대 결과는 `cases/lab02-challenge2.json`에 있습니다 (`before`가 캐시를 비움).
| 케이스 | 기대 |
| --- | --- |
| 처음 묻는 VPN | `cached: false` |
| 다른 말로 묻는 VPN ("VPN 연결이 계속 끊어지는데요") | `cached: true`, `cache_by: "faq"`, `llm_calls: 0`, 1초 안 |
| 같은 말 (띄어쓰기 · 대소문자만 다름) | `cached: true`, `cache_by: "question"` |
| `no_cache: true` | 두 캐시 모두 쓰지 않음 |
| 다른 FAQ (프린터 처음) | `cached: false` |
| 접수 현황 | `cached: false` |
**먼저 생각해 보기 (3분)**: FAQ 번호를 알려면 무엇을 먼저 해야 하나요? 그러면 두 번째 캐시는 흐름의 **어디에** 들어가나요? 질문 캐시(예제 2)와 무엇이 다른가요? 잃는 것은 무엇일까요?
### 이해하기
**팀장의 말을 바꿔 보면.** 질문 캐시는 글자가 같아야 맞습니다. 사람들은 같은 것을 다른 말로 묻습니다 ("VPN이 끊겨요" · "VPN 연결이 계속 끊어지는데요"). 그런데 이 두 질문은 결국 **같은 FAQ**(같은 FAQ 번호)로 답합니다. 그렇다면 "질문 글자"가 아니라 "FAQ 번호"를 키로 쓰면 더 많이 맞습니다.
**키를 넓게 잡을수록.**
| 키 | 같은 키가 되는 질문 | 적중 | 잘못 맞을 위험 |
| --- | --- | --- | --- |
| 질문을 다듬은 글자 (예제 2) | 띄어쓰기 · 대소문자만 다른 질문 | 낮음 | 거의 없음 |
| FAQ 번호 (이 과제) | 같은 FAQ로 답하는 질문 모두 | 높음 | 답이 질문에 꼭 맞지 않을 수 있음 |
| 뜻 (임베딩, 오늘 안 함) | 비슷한 뜻의 질문 | 더 높음 | 비슷하지만 다른 질문에 같은 답 |
**두 번째 캐시의 자리.** FAQ 번호는 FAQ 검색을 해야 알 수 있습니다. 그래서 이 캐시는 질문 캐시처럼 맨 앞이 아니라 **FAQ 검색 뒤, LLM 앞**에 들어갑니다. 데이터 조회(수십 ms)는 하고 LLM(1초 이상)만 건너뜁니다.
```mermaid
flowchart LR
Q["질문"] --> C1{"질문 캐시"}
C1 -->|있음| A1["답 · 어느 캐시: 질문"]
C1 -->|없음| D["FAQ 검색 · 규칙"] --> C2{"FAQ 번호 캐시"}
C2 -->|있음| A2["답 · 어느 캐시: FAQ"]
C2 -->|없음| L{{"LLM"}} --> S["두 키에 저장"]
```
**어느 캐시에서 왔는지 남기기.** 결과에 `cache_by`를 두어 질문 캐시 · FAQ 번호 캐시 · 캐시 아님을 구분합니다. 나중에 "FAQ 번호 캐시가 하루에 몇 번 맞았나"를 세어 이 캐시가 쓸모 있는지 판단할 수 있습니다.
**캐시하지 않는 것은 그대로.** 접수 현황과 `no_cache` 요청은 두 캐시 모두 읽지도 쓰지도 않습니다. FAQ가 아닌 질문은 FAQ 번호가 없으니 이 캐시를 쓸 수 없습니다.
**테스트 `cases/lab02-challenge2.json`의 순서가 중요합니다.** 캐시를 비운 뒤 (1) 처음 VPN → 캐시에 저장, (2) 다른 말 VPN → FAQ 번호 캐시에 맞아야 함, (3) 처음 질문과 띄어쓰기만 다른 것 → 질문 캐시, (4) `no_cache` → 두 캐시 모두 안 씀, (5) 다른 FAQ(프린터) 처음 → 캐시 없음, (6) 접수 → 캐시 안 함. 앞 케이스가 저장한 것을 뒤 케이스가 쓰므로 순서대로 읽습니다.
**잃는 것.** FAQ 번호 캐시의 답은 **처음 물은 사람의 질문**에 맞춰 쓴 답입니다. "VPN이 끊겨요"에 맞춘 답이 "VPN 설치는 어떻게 해요?"에도 나갈 수 있습니다 (둘 다 같은 FAQ라면). 답이 FAQ 원문 요약이면 괜찮고, 질문마다 달라야 하면 이 캐시는 맞지 않습니다.
<details><summary>▶ 1단계 · 두 캐시의 자리</summary>
질문 캐시는 일하기 **전**(FAQ 검색보다 앞)에 봅니다. FAQ 번호 캐시는 FAQ 검색이 끝나야 번호를 알 수 있으므로 **FAQ 검색 뒤, LLM 앞**에 봅니다. 데이터 조회(수십 ms)는 하고, LLM(1초 이상)만 건너뜁니다.
</details>
<details><summary>▶ 2단계 · 저장은 두 번</summary>
답을 만든 뒤 질문 키에도, `faq-<FAQ 번호>` 키에도 저장합니다 ([AGENTS.md](AGENTS.md) 3절 캐시 키 표). 접수 현황 · `no_cache`는 둘 다 저장하지 않습니다.
</details>
<details><summary>▶ 3단계 · 잃는 것</summary>
FAQ 번호 캐시에서 온 답은 **처음 물은 사람의 질문**에 맞춰 쓴 답입니다. 다른 사람이 조금 다른 것을 물어도 같은 답이 나갑니다. 답이 FAQ 원문 요약이라 괜찮은지, 질문마다 달라야 하는지 `PERF.md` 3절에 한 줄 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 두 번째 캐시를 볼 자리 ② 키 만드는 법과 쓰지 않는 경우 ③ 있으면 무엇을 돌려주나(`cache_by`) ④ 없으면 원래 흐름으로 ⑤ 저장 자리 ⑥ 질문 캐시 · 결과에 `cache_by` ⑦ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 FAQ 번호 캐시를 더해 줘 (AGENTS.md 3절 캐시 키 표).
Rule classify 다음: FAQ cache key(Code: category 가 faq 이고 use_cache 이고 FAQ 결과가 있으면 faq_key = ' ____① ' + 첫 FAQ id, 아니면 'none')
→ FAQ cache lookup(GET /cache/<faq_key>, neverError) → FAQ cached?(value 있고 faq_key 가 'none' 아님)
true → FAQ cached result(llm_calls 0, cached true, cache_by ' ____② ', steps ['faq', 'rule', 'cache']) → Send
false → Back to rule(Code: Rule classify 의 결과를 그대로) → Known?
Store in cache 뒤에 Store by FAQ(POST /cache/<faq_key>, 같은 value, ttl ____③ ) → Result again.
질문 캐시의 Cached result 에는 cache_by ' ____④ ', Build result 에는 cache_by null.
기대 결과 cases/lab02-challenge2.json, lab02-challenge.json, lab02.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① AGENTS.md 3절 캐시 키 표의 둘째 줄을 봅니다. FAQ 번호 **앞에** 붙는 글자입니다.
- ② AGENTS.md 3절 "실습마다 더하는 칸"의 Lab 02 과제 2 줄을 봅니다. 어느 키로 찾았는지에 따라 값이 둘입니다.
- ③ 질문 캐시(예제 2)와 같게 둡니다. 캐시 키 표의 ttl 칸.
- ④ ②와 짝이 되는 나머지 값입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py test cases/lab02-challenge.json -v
uv run tools/flow.py test cases/lab02.json -v
curl -s localhost:8800/cache-stats
```
**더 생각해 보기**: 예제 3의 적중률 계산을 다시 해 봅니다. FAQ가 6개뿐이라면 FAQ 질문의 적중률은 어디까지 오를 수 있을까요? 그 대신 FAQ 원문이 바뀌면 캐시는 언제 새 답을 내나요? (ttl 600초)
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 캐시 케이스가 늘 `cached: false` | 캐시 키가 저장할 때와 찾을 때 같은지 (`call`로 두 번 부르고 `curl -s localhost:8800/cache-stats`) |
| 캐시 조회에서 워크플로우가 멈춤 (404) | 캐시 조회 HTTP 노드에 `neverError` |
| 테스트를 다시 돌리면 결과가 다름 | 캐시가 남아 있음. `curl -s -XPOST localhost:8800/cache-clear` 또는 `no_cache: true` |
| `llm_calls`가 기대와 다름 | `Build result`가 세는 노드 이름이 실제 노드 이름과 같은지 |
| `cache_by`가 늘 `null` | 두 캐시 결과 Code에 `cache_by`를 넣었는지, `FAQ cached?`가 true 쪽으로 가는지 (`call ... -v`) |
| 다른 말 VPN이 `cached: false` | 저장할 때 `faq_key`를 쓰는지 (`curl -s localhost:8800/cache/faq-FAQ-003`) |
| 계속 안 됨 | `reference/lab02-example1-rules.json` 또는 `reference/lab02-example2-cache.json`을 `my-work/helpdesk.json`으로 복사 후 push |
## 확인
- [ ] 예제 1: `cases/lab02.json` 앞 네 케이스 통과, 규칙 질문에 `classify`가 없다.
- [ ] 예제 2: `cases/lab02.json` 9/9, `lab01`도 통과, 캐시 적중 시간을 적었다.
- [ ] 예제 3: 캐시 씀 · 안 씀 bench와 hits · misses, 적중률 어림을 적었다.
- [ ] 과제 1: `cases/lab02-challenge.json` 3번 반복 통과, `lab02` · `lab01` 통과, 바꾸기 전·후 p50 · p95.
- [ ] 과제 2: `cases/lab02-challenge2.json` 통과, `lab02-challenge` · `lab02`도 통과, 잃는 것 한 줄.
- [ ] `PERF.md` 3절에 바꾼 것마다 한 줄 (잃은 것도).
+498
View File
@@ -0,0 +1,498 @@
# Lab 03 · 멈춰도 답하게: 타임아웃 · 재시도 · 대체 경로 · 상태 점검
- 관련 강의: [3단계 · 운영 관점의 안정성](../../Lecture/72/03-STAGE3-STABILITY.md)
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 10분 · 예제 2 10분 · 예제 3 10분 · 과제 1 10분 · 과제 2 10분
- 준비: Lab 02의 `my-work/helpdesk.json` (없으면 `cp reference/lab02-example2-cache.json my-work/helpdesk.json` 후 push)
- 목표: 데이터 API나 LLM이 **느려지거나 멈췄을 때** 워크플로우가 끝없이 기다리거나 500으로 끝나지 않고, **정해진 시간 안에 정해진 답**을 하게 만든다. 그리고 무엇이 멈췄는지 바로 알 수 있는 상태 점검을 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>데이터 API 고장<br/>timeout · 재시도 · 담당자"] --> E2["예제 2<br/>LLM 멈춤<br/>간단 답변"] --> E3["예제 3<br/>접수 API만 고장<br/>부분 고장"] --> T1["과제 1<br/>상태 점검 my-health"] --> T2["과제 2<br/>차단기<br/>기다리지 않기"]
```
> 고장은 **일부러** 냅니다. `uv run tools/flow.py fault faq slow|error|ok`, `uv run tools/flow.py llm down|up`. 연습이 끝나면 반드시 되돌립니다 (테스트 파일은 `after`로 되돌림).
## 예제 1 · 데이터 API가 느리거나 오류일 때 (10분)
먼저 지금 워크플로우로 겪어 봅니다.
```bash
uv run tools/flow.py fault faq slow # FAQ가 8초 뒤에 답함
uv run tools/flow.py call my-helpdesk '{"question": "비밀번호를 잊어버렸어요", "no_cache": true}' -v
uv run tools/flow.py fault faq error # FAQ가 500
uv run tools/flow.py call my-helpdesk '{"question": "비밀번호를 잊어버렸어요", "no_cache": true}' -v
uv run tools/flow.py fault all ok
```
`PERF.md` 4절 "고치기 전"을 채웁니다. 사용자는 몇 초를 기다렸고, 무엇을 받았나요?
**만들 것**: FAQ · 접수 조회가 **2초 안에** 답하지 않거나 실패하면 한 번 더 해 보고, 그래도 안 되면 `담당자 연결`로 정해진 답.
| 조건 | 값 |
| --- | --- |
| 기다리는 시간 | 2초, 다시 해 보기 1번 (0.5초 뒤) |
| 실패했을 때 | HTTP 200, `route: 담당자 연결`, 이유 (예: `reason: data_api_down`) |
| 기대 결과 | `cases/lab03.json` (케이스마다 `before`로 고장, `after`로 되돌림) |
### 이해하기
**외부 서비스는 반드시 멈춥니다.** 데이터 API, LLM, 어떤 서비스든 언젠가는 느려지거나 오류를 냅니다. 안정성 설계는 "멈추지 않게"가 아니라 **"멈춰도 정해진 시간 안에 정해진 답을 하게"** 만드는 것입니다.
**고치기 전에 겪은 두 가지.**
| 고장 | 사용자가 겪는 것 | 까닭 |
| --- | --- | --- |
| FAQ가 느림 (8초 뒤 답) | 8초 넘게 기다린 뒤에야 답 | 기다리는 한도가 없어 끝까지 기다림 |
| FAQ가 오류 (500) | 곧바로 HTTP 500, 아무 안내 없음 | 오류에서 워크플로우가 멈춤 |
느림이 오류보다 나쁠 때가 많습니다. 기다리는 동안 n8n도 그 일을 붙잡고 있어, 사람이 몰리면 다른 요청까지 밀립니다.
**세 가지 설정.**
| 설정 | 하는 일 | 비유 |
| --- | --- | --- |
| 기다리는 한도 (timeout) | 이 시간 안에 답이 없으면 실패로 봄 | 전화를 몇 번 울려도 안 받으면 끊기 |
| 다시 해 보기 (retry) | 실패하면 잠깐 쉬고 한 번 더 | 한 번 더 걸어 보기 |
| 실패해도 다른 길로 (오류 출력) | 멈추지 않고 두 번째 출력으로 보냄 | 안 되면 "담당자에게 전해 드릴게요" |
```mermaid
flowchart LR
F["FAQ 검색<br/>2초 기다림"] -->|성공| N["평소 흐름"]
F -->|실패| W["0.5초 쉬고 한 번 더"] -->|성공| N
W -->|또 실패| D["정해진 답<br/>담당자 연결"]
```
**최악의 시간을 먼저 계산합니다.** 기다림 2초, 한 번 더, 사이 0.5초면 최악 = 2 + 0.5 + 2 = 4.5초에 다른 단계 시간이 조금 더해집니다. 테스트(`cases/lab03.json`)의 느림 케이스는 이 안쪽(7초 안)을 기대합니다. 재시도를 늘리면 최악도 늘어납니다. 재시도는 "잠깐 끊긴" 고장에 쓰는 것이지 "계속 느린" 고장에 쓰는 것이 아닙니다 (계속 느리면 과제 2의 차단기).
**정해진 답에 까닭 남기기.** `route: 담당자 연결`만이 아니라 `reason`(예: `data_api_down`)을 두면, 운영자가 나중에 "오늘 데이터 고장으로 넘긴 것이 몇 건"인지 셀 수 있습니다.
**캐시는 고장에도 도움이 됩니다.** 질문 캐시를 FAQ 검색 **앞**에서 보므로, FAQ가 멈춰도 캐시에 있는 질문은 평소처럼 답합니다. 테스트 마지막 케이스가 이것을 확인합니다 (먼저 한 번 물어 캐시에 넣고 → FAQ를 고장 내고 → 같은 질문).
**테스트의 `before` · `after`.** 케이스 앞에서 고장을 켜고, 뒤에서 **반드시** 끕니다 (실패해도). 손으로 고장을 켰다면 직접 `fault all ok`로 되돌립니다.
<details><summary>▶ 1단계 · 세 가지 설정</summary>
기다리는 시간(timeout), 다시 해 보기(retry), 실패해도 멈추지 않고 다른 길로(error output). [AGENTS.md](AGENTS.md) 5절에 세 가지가 어디에 쓰이는지 있습니다. 하나는 `parameters` 안, 둘은 노드 바로 아래입니다.
</details>
<details><summary>▶ 2단계 · 최악의 시간은 얼마인가</summary>
timeout 2초 × 2번 + 기다림 0.5초. 테스트의 `max_seconds`는 몇 초인가요? retry를 3번으로 늘리면?
</details>
<details><summary>▶ 3단계 · 캐시가 있으면</summary>
FAQ가 멈춰도 캐시에 있는 질문은 답할 수 있어야 합니다. 테스트의 마지막 케이스는 어떤 순서로 무엇을 하나요? 캐시 확인이 FAQ 조회보다 **앞에** 있어야 하는 이유는?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 어느 노드에 ② 기다리는 시간 ③ 다시 해 보기 횟수 · 기다림 ④ 실패하면 어디로(오류 출력) ⑤ 정해진 답의 칸(route · answer · reason) ⑥ 그대로 둘 설정 ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Search FAQ 와 Get ticket 에 options.timeout ____① , 노드 설정 retryOnFail true, maxTries ____② , waitBetweenTries 500,
onError ____③ 을 넣어 줘. 두 노드의 오류 출력(main 1번)은 Data down(Set: route 담당자 연결, answer "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", reason data_api_down)
→ Send data down(Respond). Get ticket 은 404 를 받아야 하니 ____④ 는 그대로 둔다. 기대 결과 cases/lab03.json, lab02 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 단위는 ms입니다. 위 조건 표의 "몇 초 안에"를 ms로 바꿉니다.
- ② 처음 한 번 + 다시 한 번입니다. 최악의 시간(시도 × timeout + 기다림)이 `cases/lab03.json`의 느림 케이스 `max_seconds`를 넘지 않는지 계산합니다 (교안 3단계 3절).
- ③ 실패해도 멈추지 않고 **두 번째 출력**으로 보내는 값입니다 (AGENTS.md 5절).
- ④ 4xx를 보통 결과로 받게 해 주는 옵션입니다. Lab 02 예제 2의 캐시 조회에도 썼습니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py test cases/lab02.json -v # 평소에는 그대로인지
```
## 예제 2 · LLM이 멈췄을 때 (10분)
OpenAI 키가 만료되거나, 서비스가 멈추거나, 사용 한도를 넘으면 LLM 노드가 실패합니다. 일부러 겪어 봅니다.
```bash
uv run tools/flow.py llm down
uv run tools/flow.py call my-helpdesk requests/faq-vpn-nocache.json -v
uv run tools/flow.py llm up # 반드시 되돌리기
```
**만들 것**: LLM 노드(분류 · 답)가 실패하면, LLM 없이 **데이터 그대로** 답하기.
| 질문 | LLM이 멈췄을 때 |
| --- | --- |
| FAQ가 있는 질문 | `route: 간단 답변`, FAQ 원문 + "자동 답변을 잠시 쓸 수 없다"는 안내 |
| 접수 번호 질문 | `route: 간단 답변`, 상태와 메모를 그대로 |
| 그 밖 | `route: 담당자 연결` |
| 기대 결과 | `cases/lab03-llm-down.json` (`before`가 `llm down`, `after`가 `llm up`) |
### 이해하기
**LLM이 멈추는 경우.** API 키 만료, 사용 한도 초과, 회사 쪽 장애, 네트워크. 오늘은 `llm down`으로 키를 일부러 틀리게 바꿔 이 상황을 만듭니다. 지금 워크플로우는 LLM 노드가 실패하면 그대로 멈춰 HTTP 500을 돌려줍니다.
**데이터는 이미 있습니다.** LLM이 실패하는 시점에는 FAQ 검색 결과와 접수 건을 **이미 조회했습니다.** LLM이 하던 일은 "그 데이터로 친절한 문장 쓰기"였습니다. 문장을 다듬지 못하더라도 데이터 자체는 사용자에게 줄 수 있습니다.
```mermaid
flowchart LR
D["데이터 조회<br/>(이미 끝남)"] --> L{{"LLM"}}
L -->|성공| A["평소 답"]
L -->|실패| S["간단 답변 Code<br/>데이터 그대로"]
S --> T["접수 건 → 상태 · 메모 그대로"]
S --> F["FAQ → 원문 + 안내 한 줄"]
S --> H["둘 다 없음 → 담당자"]
```
**대안(fallback)의 순서.** 다른 회사 LLM으로 바꾸는 방법도 있지만, 키 · 비용 · 품질을 하나 더 관리해야 합니다. 오늘은 **데이터로 답할 수 있는 것은 데이터로** 답합니다. 데이터도 없으면 사람에게 넘깁니다.
**사용자에게 솔직하게.** 평소 답과 같은 척하지 않습니다. FAQ 원문을 그대로 줄 때는 "자동 답변 기능을 잠시 쓸 수 없어 원문으로 안내한다"는 한 줄을 붙입니다. 결과의 `route`도 평소(`자동 답변`)와 다르게 두어, 운영자가 "오늘 간단 답변이 몇 건이었나"를 셀 수 있게 합니다.
**LLM 노드도 오류 출력을 가질 수 있습니다.** HTTP 노드처럼 Agent 노드에도 "실패하면 두 번째 출력으로" 설정을 둘 수 있습니다 (AGENTS.md 5절). 분류 LLM과 답 LLM 중 어느 것이 먼저 실패하든 같은 간단 답변으로 가게 잇습니다.
**테스트 `cases/lab03-llm-down.json`.** `before`가 `llm down`, `after`가 `llm up`입니다. FAQ 질문 · 접수 질문 · 모르는 질문 · 빈 질문 네 가지가 각각 맞는 대안으로 가는지 봅니다. 테스트가 중간에 멈춰도 `after`는 실행되지만, 혹시 LLM이 계속 실패하면 직접 `llm up`을 합니다.
<details><summary>▶ 1단계 · 데이터는 이미 있다</summary>
LLM이 실패하는 시점에는 FAQ 결과와 접수 건을 **이미 조회했습니다.** 그것을 그대로 쓰는 답을 Code로 만들 수 있습니다. 어느 노드의 오류 출력을 어디로 이을까요?
</details>
<details><summary>▶ 2단계 · 사용자에게 무엇을 알리나</summary>
평소 답과 같은 척하지 않고, 지금은 간단한 답이라는 것을 알립니다. `route`를 다르게 두는 이유는? (운영자가 나중에 세어 볼 수 있게)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 어느 LLM 노드에 오류 출력 ② 오류 출력을 어디로 ③ 접수 번호가 있을 때의 답 ④ FAQ가 있을 때의 답과 안내 문장 ⑤ 둘 다 아닐 때 ⑥ 공통 칸(`llm_calls` · `reason`) ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Classify 와 Answer Agent 에 onError continueErrorOutput 을 넣고, 두 노드의 오류 출력을 Simple answer(Code) → Send simple answer 로 이어 줘.
Simple answer: 질문에 접수 번호가 있고 Get ticket 결과에 status 가 있으면 route ____① , "<번호> 접수 건은 현재 '<status>' 상태입니다. <note>".
FAQ 결과가 있으면 route ____① , 첫 FAQ answer + " ( ____② )", sources [그 id].
아니면 route ____③ . 모두 llm_calls 0, reason ____④ . 기대 결과 cases/lab03-llm-down.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① AGENTS.md 3절의 `route` 세 값 중 "LLM 없이 데이터 그대로"인 것입니다 (두 곳에 같은 값).
- ② 사용자에게 평소 답이 아니라는 것을 알리는 안내 한 문장입니다. 내가 정합니다 (교안 3단계 5절 "같은 척하지 않는다").
- ③ 데이터로도 답할 수 없을 때입니다. 사람에게 넘기는 `route`.
- ④ AGENTS.md 3절 Lab 03 `reason` 값들 중 LLM이 멈춘 경우입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-llm-down.json -v
uv run tools/flow.py test cases/lab02.json -v # llm up 이 된 뒤 평소대로인지
```
## 예제 3 · 접수 API만 멈췄을 때 (부분 고장, 10분)
먼저 예제 2까지 만든 워크플로우로 겪어 봅니다.
```bash
uv run tools/flow.py fault tickets error # 접수 API만 500 (FAQ는 멀쩡)
uv run tools/flow.py call my-helpdesk requests/ticket-own.json -v
uv run tools/flow.py call my-helpdesk requests/faq-vpn-nocache.json -v
uv run tools/flow.py fault all ok
uv run tools/flow.py test cases/lab03-ticket.json -v # 고치기 전
```
`PERF.md` 4절에 적습니다: 접수 질문과 **VPN 질문**은 각각 무엇을 받았나요? FAQ는 멀쩡한데 VPN 질문은 왜 그렇게 됐을까요?
**만들 것**: 접수 API가 500이면 **접수 질문만** `담당자 연결`(`reason: tickets_down`)로 보내고, 접수와 상관없는 FAQ 질문은 평소대로 답하기. 없는 접수 번호(404)는 고장이 아니므로 지금처럼.
| 질문 | 접수 API 500일 때 |
| --- | --- |
| 접수 번호 질문 (T-1002) | `route: 담당자 연결`, `reason: tickets_down`, 상태를 지어내지 않음, 3초 안 |
| FAQ 질문 (VPN) | `route: 자동 답변`, 답에 "인증서" |
| 없는 접수 번호 (T-9999, 평소) | `route: 자동 답변`, "찾지 못" 같은 말 |
| 기대 결과 | `cases/lab03-ticket.json` |
### 이해하기
**부분 고장.** 시스템 전체가 아니라 **일부만** 멈추는 고장입니다. 오늘은 접수 API만 500을 내고 FAQ는 멀쩡한 상황을 만듭니다. 실제 서비스에서 가장 흔한 고장 모양입니다.
**고치기 전에 겪는 이상한 일.** 예제 1에서 접수 조회가 실패하면 `Data down`(담당자 연결)으로 가게 이었습니다. 그런데 **VPN 질문도** 접수 조회를 거칩니다 (접수 번호가 없으면 없는 번호로 조회). 그래서 접수 API가 멈추면 접수와 상관없는 VPN 질문까지 담당자로 넘어갑니다. 멀쩡한 기능까지 함께 멈춘 것입니다.
```mermaid
flowchart LR
subgraph 지금
G1["접수 조회 500"] -->|오류 출력| D1["모든 질문이 담당자로"]
end
subgraph 바꾼 뒤
G2["접수 조회<br/>(상태 코드를 보통 결과로)"] --> C{"500 이상이고<br/>접수 번호 질문?"}
C -->|예| T["접수 질문만 담당자<br/>reason: tickets_down"]
C -->|아니오| N["평소 흐름<br/>(VPN은 그대로 답)"]
end
```
**왜 오류 출력으로는 안 되나.** 오류 출력은 "실패했다"만 알 뿐, 그 요청이 어떤 질문인지 가리지 않고 한 길로 보냅니다. 질문에 따라 다르게 하려면 실패를 **보통 결과로 받아** 상태 코드와 질문을 함께 보고 워크플로우가 직접 나눠야 합니다.
**404와 500은 다릅니다.**
| 상태 코드 | 뜻 | 할 일 |
| --- | --- | --- |
| 200 | 접수 건이 있음 | 평소대로 |
| 404 | 그런 번호가 없음 (정상적인 답) | 평소대로 "찾지 못했다" |
| 500 이상 | 접수 시스템이 멈춤 | 접수 질문이면 담당자, 아니면 평소대로 |
**옵션은 재 보고 씁니다.** 이 n8n(2.42.6)에서 어떤 옵션 조합이 500을 보통 결과로 주는지는 강사가 직접 재 본 표가 AGENTS.md 5절에 있습니다. 결과 모양이 바뀌므로(`statusCode` · `body`), 뒤에서 접수 건을 읽던 곳(답 LLM의 지시문, 예제 2의 간단 답변)도 **모두** 새 자리에서 읽게 바꿔야 합니다. 한 곳이라도 남으면 답에 접수 상태가 빠집니다.
**덤으로 빨라지는 것.** 예제 1에서 접수 조회에 재시도를 켰더니, 접수 번호가 없는 평소 질문(없는 번호 → 404)도 재시도를 하느라 0.5초씩 더 기다리고 있었습니다. 상태 코드를 보통 결과로 받으면 404에 재시도하지 않으므로 평소 시간도 줄어듭니다. **안정성을 위해 넣은 설정이 평소를 느리게 만들 수 있다**는 예입니다.
**이 원칙의 이름.** 고장 난 부분만 줄이고 나머지는 평소대로: 우아한 저하(graceful degradation). 의존 서비스마다 실패를 따로 다루는 설계를 격벽(bulkhead)이라고 부릅니다.
<details><summary>▶ 1단계 · 오류 출력은 "어느 질문인지"를 모른다</summary>
예제 1에서 `Get ticket`의 오류 출력을 `Data down`에 이었습니다. 접수 API가 500이면 **모든 질문**이 그 길로 갑니다 (FAQ 질문도 접수 조회를 거치니까요). 질문에 따라 다르게 하려면, 실패를 오류 출력이 아니라 **보통 결과**로 받아 워크플로우가 직접 보고 나눠야 합니다.
</details>
<details><summary>▶ 2단계 · 상태 코드를 받으려면</summary>
[AGENTS.md](AGENTS.md) 5절의 표를 봅니다. 이 n8n에서는 `neverError`만으로는 500이 여전히 실패이고, `fullResponse`를 함께 켜야 `statusCode`가 담긴 보통 결과가 옵니다. 그러면 접수 건은 `$json.body`로 옮겨 갑니다. 뒤에서 `Get ticket`을 읽던 곳(답 Agent, 간단 답변 Code)도 함께 바꿔야 합니다.
</details>
<details><summary>▶ 3단계 · 404와 500</summary>
404(없는 번호)는 "답할 수 있는 결과"이고, 500은 "시스템이 멈춤"입니다. `statusCode >= 500`일 때만 고장으로 봅니다. 그리고 질문에 접수 번호가 있을 때만 `담당자 연결`입니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① `Get ticket`의 응답 설정 ② 바로 뒤에 둘 Code와 그 결과 칸 ③ 고장으로 보는 조건 둘 ④ 고장일 때의 정해진 답 ⑤ `Get ticket`을 읽던 곳 바꾸기 ⑥ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Get ticket 응답 설정에 ____① 와 neverError 를 켜 줘 (결과가 {statusCode, body}).
Get ticket 보통 출력 다음에 Ticket check(Code): ticket = statusCode ____② 이면 {} 아니면 body, ticket_status = statusCode,
tickets_down = statusCode ____② 이고 질문에 접수 번호(/T-?\d{4}/i)가 있을 때만 true.
→ Tickets down?(If) true → Tickets down(Set: route 담당자 연결, category ticket, answer "접수 현황 시스템이 응답하지 않아 담당자에게 연결합니다. 잠시 뒤 다시 물어봐 주세요.", reason ____③ , llm_calls 0) → Send tickets down
false → 지금 흐름(After data …).
$('Get ticket').first().json 을 읽던 곳은 모두 ____④ 로 바꾼다. 기대 결과 cases/lab03-ticket.json, lab03.json, lab03-llm-down.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 상태 코드 · 머리글 · 본문을 통째로 받게 하는 옵션입니다. AGENTS.md 5절 `neverError` 표의 셋째 줄.
- ② 서버 고장을 뜻하는 상태 코드 범위입니다. 404(없는 번호)는 고장이 아니라는 점을 생각합니다 (두 곳에 같은 조건).
- ③ AGENTS.md 3절 Lab 03 `reason` 값들 중 접수 API만 멈춘 경우입니다.
- ④ 이제 접수 건은 `Ticket check` 결과의 어느 칸에 있나요? AGENTS.md 5절 `Ticket check` 예 아래 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-ticket.json -v
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py test cases/lab03-llm-down.json -v
```
## 과제 1 · 상태 점검 워크플로우 (10분)
팀장이 요청했습니다.
> "어제 오후에 답이 이상하다는 문의가 몰렸는데, 데이터 쪽 문제인지 AI 쪽 문제인지 아무도 몰랐어요. 버튼 하나로 지금 무엇이 멀쩡한지 알려 주는 걸 만들어 주세요. 그리고 점검하다가 점검이 멈추면 안 됩니다."
| 조건 | 값 |
| --- | --- |
| 파일 · 웹훅 | `my-work/health.json` · `my-health` ([AGENTS.md](AGENTS.md) 3절) |
| 결과 | `status`(`ok` · `degraded` · `down`), `checks`(`data_api` · `faq` · `llm`), `failed`, `seconds` |
| 기대 결과 | `cases/lab03-challenge.json` (평소 · FAQ 오류 · FAQ 느림 · LLM 멈춤) |
**먼저 생각해 보기 (5분)**: 상태 점검이 FAQ를 8초 기다리면 점검의 뜻이 있을까요? 하나가 실패했을 때 나머지 점검은 계속해야 할까요?
### 이해하기
**상태 점검이란.** "지금 무엇이 멀쩡하고 무엇이 멈췄나"를 묻는 작은 요청입니다. 문의가 몰렸을 때 가장 먼저 열어 보는 화면이고, 운영에서는 기계(알림 시스템 · 부하 분산기)도 이 결과를 읽습니다.
**무엇을 점검하나.** 오늘 실습 워크플로우가 기대는 것 셋입니다.
| 점검 | 방법 | 실패하면 |
| --- | --- | --- |
| 데이터 API가 살아 있나 | `/health` 부르기 | 데이터 전부 못 씀 |
| FAQ 검색이 되나 | 실제로 짧은 검색 한 번 | FAQ 답을 못 씀 |
| LLM이 답하나 | 아주 짧은 질문 한 번 ("한 낱말로 답해") | 자동 답변을 못 씀 |
`/health`만 보면 안 되는 까닭: 오늘 고장 스위치는 `/health`에 영향을 주지 않습니다. 살아는 있는데 FAQ는 못 하는 상태를 놓칩니다. **실제 일과 같은 길**로 한 번씩 해 봐야 압니다.
**점검이 멈추면 안 됩니다.**
- 점검마다 **짧은 기다리는 한도**. FAQ가 8초 느리면 점검도 8초 걸리면 점검의 뜻이 없습니다.
- 하나가 실패해도 **다음 점검을 계속**. 오류 출력으로 빠지면 나머지가 멀쩡한지 모릅니다. 실패해도 보통 출력으로 계속 가는 설정을 씁니다 (AGENTS.md 5절).
```mermaid
flowchart LR
W(["my-health"]) --> A["데이터 API 점검"] --> F["FAQ 점검"] --> L{{"LLM 점검"}} --> S["Code가 정함<br/>ok · degraded · down"]
```
**상태는 코드가 정합니다.** LLM에게 "상태를 판단해"라고 하면 안 됩니다: LLM이 멈췄을 수도 있으니까요. 세 점검의 참 · 거짓으로 Code가 정합니다.
- `ok`: 모두 됨.
- `degraded`: 일부만 됨 (사용자에게 줄 답이 조금은 있음).
- `down`: 사용자에게 줄 것이 없음.
`degraded`와 `down`의 정확한 기준은 내가 정해 요청문에 적습니다. 어떤 것이 멈추면 "아무것도 못 한다"고 볼지 생각해 봅니다 (예제 1 · 2에서 무엇이 멈춰도 무엇은 답할 수 있었나).
**결과 모양.** `status`, 점검별 참 · 거짓(`checks`), 실패한 것 이름(`failed`), 걸린 시간(`seconds`). 사람도 기계도 같은 모양을 읽습니다 (AGENTS.md 3절).
**테스트 `cases/lab03-challenge.json`.** 평소 · FAQ 오류 · FAQ 느림 · LLM 멈춤 네 경우마다 상태가 맞는지, 그리고 FAQ가 느릴 때도 점검이 **빨리 끝나는지** 봅니다.
<details><summary>▶ 1단계 · 점검 하나하나</summary>
데이터 API는 `/health`, FAQ는 실제 검색 한 번, LLM은 아주 짧은 질문 한 번. 점검마다 **짧은 timeout**과 "실패해도 다음으로"가 필요합니다. 오류 출력 대신 보통 출력으로 계속 가는 설정도 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 2단계 · ok · degraded · down을 누가 정하나</summary>
LLM에게 "상태를 판단해"라고 하지 않습니다 (LLM이 멈췄을 수도 있으니까요). 세 점검의 참·거짓으로 Code가 정합니다. `degraded`와 `down`의 기준을 직접 정해 요청문에 적습니다.
</details>
<details><summary>▶ 3단계 · 운영에서는</summary>
n8n에는 일정 시간마다 실행하는 Schedule Trigger가 있습니다. 5분마다 점검해 `degraded`이면 알림을 보내려면 무엇을 더해야 할까요? (오늘은 웹훅으로만, 76일차 에러 처리에서 다시)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 새 파일 · 웹훅 ② 점검 셋(무엇을 어떻게 부르나) ③ 점검마다 짧은 timeout과 "실패해도 다음으로" ④ LLM 점검의 질문과 시간 ⑤ 참 · 거짓을 정하는 법 ⑥ `ok` · `degraded` · `down` 기준 ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/health.json 을 새로 만들어 줘 (웹훅 my-health, AGENTS.md 3절).
Start(Code t0) → Check data API(GET http://data-api:8800/health, timeout 2000, onError ____① , alwaysOutputData)
→ Check FAQ(GET /faq?q=vpn, 같은 설정) → Check LLM(Agent "Reply with the single word OK.", onError ____① , alwaysOutputData;
모델 options timeout 8000, maxRetries 0) → Summarize(Code): data_api = ____② , faq = results 가 배열, llm = ____③ .
failed = 거짓인 것 이름. status: 모두 참 ok, ____④ , 아니면 down. seconds.
기대 결과 cases/lab03-challenge.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 실패해도 **보통** 출력으로 다음 점검에 넘어가게 하는 값입니다. 오류 출력으로 보내는 값과 다릅니다 (AGENTS.md 5절).
- ② `/health`가 평소 돌려주는 JSON 모양을 봅니다 (AGENTS.md 2절 표 마지막 줄). 그 모양이면 참.
- ③ LLM에게 무엇이라고 답하라고 했나요? 대문자 · 마침표가 붙어도 참이 되게 씁니다.
- ④ `degraded`의 기준입니다. "일부는 된다"를 어느 점검의 참 · 거짓으로 말할까요? 위 조건 표와 교안 3단계 8절 `status` 표. 내가 정해 적어도 됩니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/health.json
uv run tools/flow.py test cases/lab03-challenge.json -v
uv run tools/flow.py doctor # 끝나면: 고장 스위치 · OpenAI 키가 평소대로인지
```
## 과제 2 · 차단기: 멈춘 곳은 잠깐 부르지 않기 (10분)
팀장이 다시 요청했습니다.
> "FAQ가 멈췄을 때 담당자로 넘기는 건 좋은데, 사람마다 5초씩 기다렸다가 넘어가요. 한 번 멈춘 걸 알았으면 잠깐은 기다리지 말고 바로 넘겨 주세요. 다시 살아나면 저절로 평소대로 돌아와야 하고요."
| 조건 | 값 |
| --- | --- |
| 기억하는 곳 | 데이터 API 캐시 키 `breaker-faq`, ttl **30초** ([AGENTS.md](AGENTS.md) 3절 캐시 키 표) |
| FAQ가 실패했을 때 | 지금처럼 `담당자 연결`(`reason: data_api_down`) + `breaker-faq` 저장 |
| `breaker-faq`가 있을 때 | FAQ를 부르지 않고 바로 `담당자 연결`, `reason: breaker_open`, 1초 안 |
| 기대 결과 | `cases/lab03-challenge2.json` (평소 · FAQ 느림 처음 · 다음 질문 · 되살아남) |
**먼저 생각해 보기 (2분)**: 30초가 지나면 무슨 일이 생기나요? 그때 FAQ가 아직 멈춰 있다면? 질문 캐시(예제 2)에 있는 질문은 차단기가 열려 있어도 답해야 할까요?
### 이해하기
**예제 1만으로 남는 문제.** FAQ가 계속 느리면, 질문하는 **사람마다** 4~5초씩 기다린 뒤 담당자로 넘어갑니다. 첫 사람에게서 이미 "FAQ가 멈췄다"를 알았는데도요. 그동안 멈춘 FAQ 서비스에는 요청이 계속 들어가 회복도 늦어집니다.
**차단기(circuit breaker).** 집의 전기 차단기처럼, 문제가 생기면 잠깐 **길을 끊습니다.**
```mermaid
stateDiagram-v2
[*] --> 닫힘
닫힘 --> 열림: FAQ 실패 (표시를 저장)
열림 --> 닫힘: 정한 시간이 지나 표시가 사라짐
닫힘: 닫힘 · FAQ를 부름
열림: 열림 · FAQ를 부르지 않고 바로 담당자
```
| 때 | 하는 일 |
| --- | --- |
| 평소 (표시 없음) | FAQ를 평소처럼 부름 |
| FAQ가 실패 | 예제 1처럼 담당자로 넘기고, **"FAQ 멈춤" 표시를 저장** |
| 표시가 있음 | FAQ를 부르지 않고 바로 담당자 (`reason`을 다르게 두어 구분), 1초 안 |
| 정한 시간이 지남 | 표시가 저절로 사라짐 → 다음 질문이 다시 FAQ를 불러 봄. 아직 멈춰 있으면 또 표시 |
**표시를 어디에 두나.** 웹훅 워크플로우는 요청마다 새로 시작해 앞 요청을 기억하지 못합니다. 그래서 표시는 **바깥**에 둡니다: 오늘은 데이터 API 캐시의 정해진 키, 운영에서는 Redis 같은 저장소. 저장할 때 수명(ttl)을 주면 닫는 일을 따로 만들 필요가 없습니다.
**얼마나 열어 둘까.** 짧으면 자주 다시 불러 보느라 기다리는 사람이 늘고, 길면 FAQ가 되살아난 뒤에도 한동안 담당자로 넘깁니다. 오늘은 30초로 정했습니다 (위 조건 표).
**캐시와의 순서.** 질문 캐시에 있는 질문은 FAQ를 부르지 않고도 답할 수 있으므로, 차단기보다 **먼저** 봅니다. 차단기가 열려 있어도 캐시에 있는 질문은 평소처럼 답합니다.
**테스트 `cases/lab03-challenge2.json`의 순서.** (1) 평소 → (2) FAQ를 느리게 하고 첫 질문 → 기다린 뒤 담당자, 표시 저장 → (3) 다음 질문 → 기다리지 않고 바로 담당자 → (4) FAQ를 되돌리고 캐시(표시 포함)를 비운 뒤 → 평소 답. (3)이 1초 안이어야 하는 것이 이 과제의 핵심입니다.
> 이 과제 뒤에 `cases/lab03.json`을 돌리면, 앞 케이스(FAQ 느림)가 연 차단기가 아직 30초 안이라 다음 케이스(FAQ 오류)가 `reason: breaker_open`으로 아주 빨리 끝날 수 있습니다. 차단기가 제대로 일한 것입니다 (테스트는 `route`만 봅니다).
<details><summary>▶ 1단계 · 차단기를 보는 자리</summary>
질문 캐시를 본 **뒤**(캐시에 있으면 FAQ와 상관없이 답함), FAQ 검색 **앞**에 `breaker-faq`를 봅니다. 없으면(404) 평소대로, 있으면 바로 정해진 답.
</details>
<details><summary>▶ 2단계 · 여는 곳과 닫히는 때</summary>
FAQ 검색의 오류 출력에서 `breaker-faq`를 저장한 뒤 `Data down`으로 갑니다. 닫는 노드는 따로 만들지 않습니다: **ttl 30초가 지나면 데이터 API가 지웁니다.** 그 뒤 첫 질문이 다시 FAQ를 불러 보고, 아직 멈춰 있으면 또 엽니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 차단기를 볼 자리 ② 열려 있을 때의 정해진 답(`reason`) ③ 여는 자리와 저장할 값 · ttl ④ 닫는 법 ⑤ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 FAQ 차단기를 더해 줘 (AGENTS.md 3절 캐시 키 표).
Cached? false → Breaker check(GET http://data-api:8800/cache/ ____① , neverError) → Breaker open?(value 있음)
true → Breaker open(Set: route 담당자 연결, answer "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", reason ____② , llm_calls 0) → Send breaker open
false → Search FAQ.
Search FAQ 의 오류 출력 → Open breaker(POST /cache/ ____① {value:{open:true, since: Date.now()}, ttl ____③ }, neverError) → ____④ .
닫는 노드는 만들지 않는다 (ttl 이 지나면 데이터 API가 지움). 기대 결과 cases/lab03-challenge2.json, lab03.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 차단기 상태를 저장하는 캐시 키입니다. AGENTS.md 3절 캐시 키 표의 마지막 줄 (두 곳에 같은 키).
- ② AGENTS.md 3절 Lab 03 `reason` 값들 중 차단기가 열린 경우입니다.
- ③ 초 단위입니다. 위 조건 표와 캐시 키 표의 ttl 칸. 너무 길거나 짧으면 어떻게 되는지는 교안 3단계 7절.
- ④ 차단기를 연 뒤 **이번** 사용자에게는 어떤 답이 나가야 하나요? 예제 1에서 만든 정해진 답 노드입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-challenge2.json -v
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py doctor # 끝나면: 고장 스위치 · OpenAI 키가 평소대로인지
```
마지막으로 `PERF.md` 4~6절을 채우고, [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웁니다.
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 테스트 뒤에도 FAQ가 이상함 | `uv run tools/flow.py fault all ok` |
| 테스트 뒤에도 LLM이 모두 실패 | `uv run tools/flow.py llm up` (키를 `.env`에서 다시 등록) |
| 오류 출력으로 안 가고 워크플로우가 멈춤 | 그 노드에 `onError: continueErrorOutput`이 있는지, 두 번째 출력이 연결됐는지 |
| 재시도가 안 됨 | `fullResponse`를 켠 노드는 500도 보통 결과로 받아 재시도하지 않습니다. `neverError`만이면 500은 재시도합니다 ([AGENTS.md](AGENTS.md) 5절 표) |
| 예제 3 뒤 답에 접수 상태가 안 나옴 | `$('Get ticket').first().json`을 읽던 곳이 남음. `$('Ticket check').first().json.ticket`으로 |
| 차단기가 안 열림 | 오류 출력에 `Open breaker`가 이어졌는지, `curl -s localhost:8800/cache/breaker-faq` |
| 차단기가 안 닫힘 | ttl 30을 넣었는지. 급하면 `curl -s -XPOST localhost:8800/cache-clear` |
| 느릴 때 담당자 연결이 7초를 넘음 | timeout · 재시도 횟수 · 기다림의 합 |
| 계속 안 됨 | `reference/lab03-example1-resilient.json`, `reference/lab03-example2-llm-fallback.json` |
## 확인
- [ ] 예제 1: 고치기 전 시간 · 결과를 적었고, `cases/lab03.json` 통과, `lab02`도 통과.
- [ ] 예제 2: `cases/lab03-llm-down.json` 통과, `llm up` 뒤 `lab02` 통과.
- [ ] 예제 3: 고치기 전 두 질문의 결과를 적었고, `cases/lab03-ticket.json` 통과, `lab03` · `lab03-llm-down`도 통과.
- [ ] 과제 1: `cases/lab03-challenge.json` 통과.
- [ ] 과제 2: `cases/lab03-challenge2.json` 통과, `lab03`도 통과, 끝난 뒤 `doctor` → `ready`.
- [ ] `my-work/PERF.md`, `my-work/TEST-REPORT.md`.
+179
View File
@@ -0,0 +1,179 @@
# 72일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-26 (월) · 72일차
- 주제: Workflow 실행 흐름 최적화와 운영 관점 개선 (빠르게, 멈춰도 답하게)
- 실습 폴더: [Last3Mon/72](README.md) · 강의 자료: [Lecture/72](../../Lecture/72/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + **72일차 판 데이터 API**(고장 스위치 · 캐시) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · 기준 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1교시 강의 · 2교시<br/>Lab 01 실습 5개<br/>재기 · 병목 · 예산"] --> L2["3교시 강의 · 4교시<br/>Lab 02 실습 5개<br/>규칙 · 캐시 · LLM 한 번"] --> L3["5교시 강의 · 6교시<br/>Lab 03 실습 5개<br/>고장 · 부분 고장 · 차단기"] --> V["7~8교시<br/>tbench"] --> E["마친 뒤<br/>되돌리기 · 73 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 10분 | 자료 받기 · n8n · 72일차 데이터 API · 기준 게시 · doctor | [PREP](../../Lecture/72/00-DAY72-PREP.md) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (재기와 병목) | [개요](../../Lecture/72/00-DAY72-OVERVIEW.md) 1~14장, [1단계](../../Lecture/72/01-STAGE1-MEASURE-BOTTLENECK.md) | |
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-MEASURE-BOTTLENECK.md) | `lab01-challenge` · `lab01-challenge2` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 (실행 흐름 최적화) | 개요 15~21장, [2단계](../../Lecture/72/02-STAGE2-OPTIMIZE-FLOW.md) | |
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-OPTIMIZE-FLOW.md) | `lab02-challenge` · `lab02-challenge2` 통과 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 (운영 관점의 안정성) | 개요 22~28장, [3단계](../../Lecture/72/03-STAGE3-STABILITY.md) | |
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-STABILITY.md) | `lab03-challenge` · `lab03-challenge2` · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tbench`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TBENCH](VIBE-REQ-TBENCH.md) → [VIBE-Q-TBENCH](VIBE-Q-TBENCH.md) | `Doc/*.md` · R1~R3 · R6~R8 · R14 · `pytest` |
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 73일차 예고 | [4단계 정리](../../Lecture/72/04-STAGE4-WRAPUP.md), [진행](../../Lecture/72/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
## 1. 전날 준비 (약 10분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 **72일차** 데이터 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/72/data-api
docker compose up -d --force-recreate # 71일차 판을 72일차 판(+ 고장 스위치 · 캐시)으로 바꿈
curl -s localhost:8800/admin/fault # {"faq": "ok", "tickets": "ok", "slow_seconds": 8}
```
### 1.3 기준 워크플로우 게시 · 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/72
uv run tools/flow.py push reference/baseline-helpdesk.json
uv run tools/flow.py call base-helpdesk requests/faq-vpn.json -v
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.4 미리 읽기 (선택, 5분)
[PREP 3절](../../Lecture/72/00-DAY72-PREP.md): 10번 중 9번은 2초, 1번은 9초라면 "느리다"고 해야 할까? (p50 · p95)
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 66일차 `~/vibe/turl` (없어도 됨).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (같은 명령 그대로). 기준 워크플로우는 게시된 채로 남아 있습니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/72`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`bench`·`push`·`test`).
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `cp templates/PERF-REPORT-TEMPLATE.md my-work/PERF.md`, `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서 (오늘은 "재고 → 바꾸고 → 다시 재기")
```mermaid
flowchart LR
A["① 재기<br/>bench · call"] --> B["② 바꿀 것 하나<br/>요청문을 메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ 다시 재기 ·<br/>PERF.md 한 줄"]
```
- **한 번에 한 가지만** 바꿉니다. 빨라졌으면 원래 테스트(`lab01`, `lab02`)로 품질이 그대로인지 확인합니다.
- 캐시가 남아 있으면 결과가 달라집니다. 이상하면 `curl -s -XPOST localhost:8800/cache-clear`.
- 고장 연습(`fault`, `llm down`)은 끝나면 **반드시 되돌립니다** (`fault all ok`, `llm up`).
## 4. 1~2교시 · 1단계 · 재기와 병목
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-MEASURE-BOTTLENECK.md) (50분, 실습 5개).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
| --- | --- | --- | --- |
| 예제 1 (8분, 에이전트 없이) | 기준 재기 · Executions 노드 시간 보기 | `base-helpdesk` | `bench base-helpdesk requests/faq-vpn.json -n 5` (`ticket-own`, `off-topic`도), `PERF.md` 1절 |
| 예제 2 (12분) | 결과에 `timings` · `steps` · `llm_calls` (답은 그대로) | `cp reference/baseline-helpdesk.json my-work/helpdesk.json` → 웹훅 `my-helpdesk`로 | `push my-work/helpdesk.json` → `test cases/lab01.json` |
| 예제 3 (8분, 에이전트 없이) | 질문 종류 셋(FAQ · 접수 · IT 밖)을 재고 LLM 몫 계산 | (예제 2 그대로) | `bench my-helpdesk requests/ticket-own.json -n 5` 등 셋, `call ... -v`, `PERF.md` 2절 표 |
| 과제 1 (10분) | `faq_ms` · `ticket_ms`로 나누고 `slowest_step` | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge.json`, `test cases/lab01.json` |
| 과제 2 (12분) | 시간 예산 `budget_ms`(기본 3000) · `over_budget` · `llm_ms` | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge2.json`, `lab01-challenge`, `lab01` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 첫 호출과 그 뒤 호출의 차이, 단계 시간 중 LLM이 차지하는 몫.
## 5. 3~4교시 · 2단계 · 실행 흐름 최적화
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-OPTIMIZE-FLOW.md) (50분, 실습 5개).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (10분) | 데이터 조회를 앞으로, 규칙으로 분류되면 분류 LLM 생략 (`steps`에 `rule`) | `push my-work/helpdesk.json` → `test cases/lab02.json` (앞 네 케이스), `bench my-helpdesk requests/faq-printer.json -n 5` |
| 예제 2 (10분) | FAQ 답은 캐시, 같은 질문은 LLM 없이 (`cached: true`). 접수 현황 · `no_cache`는 캐시 안 함 | `test cases/lab02.json` 9/9, `test cases/lab01.json`, `curl -s localhost:8800/cache-stats` |
| 예제 3 (6분, 에이전트 없이) | 캐시 씀 · 안 씀 bench, `cache-stats`, 적중률 어림 | `bench my-helpdesk requests/faq-vpn.json -n 5`, `... faq-vpn-nocache.json -n 5` |
| 과제 1 (12분) | 처음 묻는 질문도 LLM 한 번 · 2.5초 안, 답 내용은 그대로 | 고치기 전 `test cases/lab02-challenge.json` · `bench ... faq-vpn-nocache -n 5` → 고친 뒤 `test cases/lab02-challenge.json --repeat 3`, `lab02`, `lab01`, 다시 `bench` |
| 과제 2 (12분) | 같은 FAQ로 답하는 질문은 말이 달라도 캐시 (`faq-<번호>` 키, `cache_by`) | `test cases/lab02-challenge2.json`, `lab02-challenge`, `lab02` |
- `PERF.md` 3절에 바꾼 것마다 한 줄: `llm_calls`, p50, p95, 품질 테스트, **잃은 것**.
## 6. 5~6교시 · 3단계 · 운영 관점의 안정성
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-STABILITY.md) (50분, 실습 5개).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (10분) | 먼저 겪기: `fault faq slow` · `fault faq error`로 `call` → `fault all ok`. 그다음 FAQ · 접수 조회에 timeout 2초 · 재시도 1번 · 실패하면 `담당자 연결` | `push my-work/helpdesk.json` → `test cases/lab03.json`, `test cases/lab02.json` |
| 예제 2 (10분) | 먼저 겪기: `llm down` → `call` → `llm up`. 그다음 LLM이 실패하면 데이터 그대로 `간단 답변` | `test cases/lab03-llm-down.json` (자동으로 down · up), `test cases/lab02.json` |
| 예제 3 (10분) | 먼저 겪기: `fault tickets error` → 접수 · VPN 질문 `call` → `fault all ok`. 그다음 접수 질문만 `담당자 연결`(`tickets_down`), FAQ는 평소대로 | `test cases/lab03-ticket.json`, `lab03`, `lab03-llm-down` |
| 과제 1 (10분) | 상태 점검 `my-health`: 데이터 API · FAQ · LLM, `ok` · `degraded` · `down` | `my-work/health.json` → `push` → `test cases/lab03-challenge.json` |
| 과제 2 (10분) | 차단기: FAQ가 실패하면 30초 동안 FAQ를 부르지 않고 바로 `담당자 연결`(`breaker_open`) | `test cases/lab03-challenge2.json`, `lab03`, 끝나면 `doctor` |
- 보고서: `PERF.md` 4~6절, `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
## 7. 7~8교시 · 바이브 코딩 `tbench`
[VIBE-REQ-TBENCH](VIBE-REQ-TBENCH.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TBENCH](VIBE-Q-TBENCH.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1단계 · TUI 1단계 · 설치까지, 나머지는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 URL · 웹훅을 N번 불러 p50 · p95 · max · 실패를 표와 스파크라인으로 보여 주고 바꾸기 전 · 후를 비교하는 TUI. 정답본 `VIBE-A-TBENCH.md`는 수업이 끝나면 `git pull`로 받습니다.
```bash
mkdir -p ~/vibe/tbench/Doc && cd ~/vibe/tbench
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~3단계 · TUI 1~2단계 · 설치) · commit | 단계마다 체크리스트 |
| 16:20~16:44 | 4 구현: 엔진 1단계(R1 · R6 · R7 · R8) → TUI 1단계(R2), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tbench` | `cd ~/aio-01d4-stu/Last3Mon/72 && tbench ...` |
- 한 단계씩 요청하고, 매번 **직접 실행**하고, 단계마다 `git commit`.
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/72/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 73일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `uv run tools/flow.py fault all ok`, `uv run tools/flow.py llm up`, `doctor` → `ready` | 고장 연습을 되돌려 두기 (내일도 같은 n8n · 데이터 API) |
| `PROMPTS.md`, `PERF.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (성능 · 안정성 보고) |
| `my-work/*.json`, `~/vibe/tbench` 지우지 않기 | 79일차 `tops`에서 다시 씀 |
| 73일차 준비: [00-DAY73-PREP](../../Lecture/73/00-DAY73-PREP.md) | 입력 · 출력 검증과 보안 |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| doctor의 fault switch ✘ | 데이터 API가 예전 판. 1.2의 72일차 `--force-recreate` |
| `! fault switch is on` | `uv run tools/flow.py fault all ok` |
| 모든 LLM 단계가 실패 | `llm down` 상태로 남음. `uv run tools/flow.py llm up` |
| 테스트를 다시 돌리면 결과가 다름 | 캐시. `curl -s -XPOST localhost:8800/cache-clear` |
| `call my-helpdesk`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 |
| `base-helpdesk`가 안 됨 | `push reference/baseline-helpdesk.json` |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 72일차 데이터 API, 기준 게시, `doctor` → `ready`
- [ ] Lab 01: 기준 p50 · p95, 종류별 표, `lab01`, `lab01-challenge`, `lab01-challenge2`
- [ ] Lab 02: `lab02` 9/9, 적중률, `lab02-challenge`, `lab02-challenge2`, 바꾸기 전 · 후 p50 · p95
- [ ] Lab 03: `lab03`, `lab03-llm-down`, `lab03-ticket`, `lab03-challenge`, `lab03-challenge2`, `PERF.md` · `TEST-REPORT.md`
- [ ] `tbench`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install`
- [ ] 고장 되돌리기, 73일차 준비
+87
View File
@@ -0,0 +1,87 @@
# 72일차 실습 · Workflow 실행 흐름 최적화와 운영 관점 개선 (바이브 코딩)
- 일자: 2026-10-26 (72일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/72](../../Lecture/72/README.md)
- 하루 수강 안내: [LECTURE-72.md](LECTURE-72.md) (수업 준비부터 마칠 때까지 순서대로)
- 67~71일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 새 기능이 아니라 **같은 워크플로우를 빠르게, 멈춰도 답하게** 고칩니다.
## 오늘 고치는 것: 느리고, 가끔 멈추는 IT 헬프데스크
`reference/baseline-helpdesk.json`(웹훅 `base-helpdesk`)은 질문마다 LLM을 세 번 부릅니다 (분류 → 답 → 검토). 이것을 **재고 → 병목을 찾고 → 한 가지씩 고치고 → 다시 잽니다.** 그리고 데이터 API나 LLM이 멈춰도 정해진 답을 하게 만듭니다.
```mermaid
flowchart LR
Q(["질문"]) --> C{"캐시?"}
C -->|있음| A0(["바로 답 · LLM 0번"])
C -->|없음| D["FAQ · 접수 조회<br/>timeout · 재시도"]
D -->|실패| H(["담당자 연결"])
D --> R{"규칙으로<br/>분류되나?"}
R -->|예| L{{"답 LLM 1번"}}
R -->|아니오| K{{"분류 LLM"}} --> L
L -->|실패| S(["간단 답변<br/>FAQ 원문"])
L --> V["답 검사 (Code)"] --> A(["자동 답변 + timings"])
```
## 일하는 방식 (67~71일차와 같음)
| 누가 | 무엇을 |
| --- | --- |
| 나 | 재기(`bench`, `timings`), 무엇을 바꿀지 정하기, 요청문 쓰기, 한 가지씩 바꾸고 다시 재기, 고장 연습 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
| `tools/flow.py` | check · push · call · test · **bench**(N번 불러 p50·p95) · **fault**(데이터 API 고장 스위치) · **llm down/up**(LLM 멈춤 연습) |
> **빨라졌다면 품질이 그대로인지** 원래 테스트로 확인합니다. 빠른데 틀린 답은 개선이 아닙니다.
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`)는 요청문을 바로 주지 않습니다. 막히면 `▶` 를 위에서부터 하나씩 펼칩니다: 생각할 거리 → **빈칸 채우기**(요청문 뼈대의 빈칸 4개 `____①`~`____④`를 내가 채움, 아래 빈칸 힌트는 답이 아니라 찾을 곳). 빈칸을 채운 모양은 실습 문서에 없고, 수업 뒤 공개되는 정답본(`LAB-0x-A-*.md`)의 요청문 예로 비교합니다. `VIBE-Q-TBENCH.md`는 요청서 → `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 구현 → 설치 순서입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
- 바이브 코딩은 **요청서(`VIBE-REQ-TBENCH.md`)부터** 읽고 `~/vibe/tbench/Doc/TODO.md`를 직접 쓴 뒤, `VIBE-Q-TBENCH.md`의 8단계(SPEC → IMP-PLAN → 구현 → 문제 해결 → 확인 → `uv tool install` → 다른 곳에서 사용)로 갑니다. 정답본 `VIBE-A-TBENCH.md`는 수업이 끝나면 올라옵니다.
## 구성
```text
Last3Mon/72/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 타임아웃·재시도·오류 출력 5절)
├── LAB-01-Q-MEASURE-BOTTLENECK.md # 1단계 · bench → 단계별 시간 → 가장 느린 단계
├── LAB-02-Q-OPTIMIZE-FLOW.md # 2단계 · 규칙 먼저 → 캐시 → LLM 한 번
├── LAB-03-Q-STABILITY.md # 3단계 · 타임아웃·재시도·대체 경로 → LLM 멈춤 → 상태 점검
├── VIBE-REQ-TBENCH.md # 7교시 바이브 코딩 · 요청서 (가장 먼저, 여기서 Doc/TODO.md를 씀)
├── VIBE-Q-TBENCH.md # 7교시 바이브 코딩 · tbench 8단계 (TODO → SPEC → IMP-PLAN → 구현 → 설치), 요구 14개
├── *-A-*.md # 정답본 (VIBE-A는 수업이 끝난 뒤 올라옴, LAB-A는 아직 없음)
├── answers/ # 정답 워크플로우 · 시나리오 실행 기록 (수업이 끝난 뒤 올라옴)
├── tools/flow.py
├── data-api/ # 72일차 판 데이터 API (71일차 + 고장 스위치 + 캐시)
├── cases/ # lab01 · lab01-challenge(2) · lab02 · lab02-challenge(2) · lab03 · lab03-llm-down · lab03-ticket · lab03-challenge(2)
├── requests/ # 질문 10종 + 상태 점검
├── reference/ # baseline-helpdesk(비교 기준) + 막혔을 때 이어 가는 워크플로우
├── templates/ # 성능·안정성 보고서 · 테스트 보고서
├── my-work/
└── runs/ # 테스트 · bench 보고서
```
## 준비
[실습 준비](../../Lecture/72/00-DAY72-PREP.md)를 끝내 주세요. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate # 72일차 판으로 바꿈 (고장 스위치 · 캐시)
cd ~/aio-01d4-stu/Last3Mon/72 && uv run tools/flow.py push reference/baseline-helpdesk.json
uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py bench base-helpdesk requests/faq-vpn.json -n 5` | 같은 요청 N번, 응답마다 시간, p50 · p95 · max · 실패 수, `runs/bench-*.json` | p50 2.61초 · p95 2.83초 |
| `uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v` | 한 번 부르고 결과(`timings` 포함) 보기 | |
| `uv run tools/flow.py test cases/lab02.json -v` | 테스트 (`before` · `after`로 캐시 비우기, 고장 켜고 끄기). `-v`는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 줌. **모든 명령이 `-v`를 받음** (`bench -v`: 응답마다 · 정렬한 시간 · 단계별 평균 ms, `check`/`push -v`: 노드 · 연결 · docker 명령, `fault`/`doctor -v`: 요청 · 응답) | |
| `uv run tools/flow.py fault faq slow` · `fault faq error` · `fault all ok` | 데이터 API의 FAQ를 느리게(8초) · 오류(500)로 · 되돌리기 | |
| `uv run tools/flow.py llm down` · `llm up` | n8n의 OpenAI Credential을 일부러 틀린 키로 · `.env`의 키로 되돌리기 | 바로 적용 (재시작 없음) |
> `llm down`을 했다면 반드시 `llm up`. 테스트 파일의 `after`는 실패해도 되돌립니다.
+280
View File
@@ -0,0 +1,280 @@
# 7교시 바이브 코딩 · `tbench` 만들기
> **먼저 [VIBE-REQ-TBENCH.md](VIBE-REQ-TBENCH.md)(요청서)를 읽고 `~/vibe/tbench/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/) (`httpx` 등은 자유)
- 결과물: URL · 웹훅을 **N번** 불러 응답 시간 p50 · p95 · max, 실패 수와 실패 종류를 표와 **스파크라인**으로 보여 주는 TUI. 결과를 남겨 **바꾸기 전 · 후를 비교**하고, 기준을 주면 통과 · 실패를 종료 코드로 알립니다. `uv tool install`로 설치해 **어느 폴더에서나** `tbench`로 씁니다. 오늘 `flow.py bench`로 한 일을 내 도구로.
```text
$ tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 20
┌ tbench · POST /webhook/my-helpdesk · 20회 · 동시 1 ─────────────────┐
│ ▁▂▁▁▃▁▂█▁▁▂▁▁▁▂▁▁▁▁▂ │
│ 완료 20/20 실패 0 p50 1.15s p95 1.31s max 3.02s (첫 호출 제외 p50 1.14s) │
│ ┌ # ─ 상태 ─ 시간 ─ 메모 ──────────────────────────────────────────┐ │
│ │ 1 200 3.02s first call │ │
│ │ 2 200 1.14s │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ 단계 평균: classify 0 · data 22 · answer 1080 · total 1130 ms │
│ [s] 저장 [c] 비교 [h] 기록 [q] 끝 │
└──────────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 시간 (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단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit |
| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `cd ~/aio-01d4-stu/Last3Mon/72 && tbench ...` |
50분 안 목표: **R1 · R3 · R6 · R7 · R8(엔진 1단계) + R2(TUI 1단계) + R14(설치)**. R4 · R5 · R9~R13은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
## 0. 준비 (1분)
```bash
mkdir -p ~/vibe/tbench/Doc && cd ~/vibe/tbench
git init
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
[VIBE-REQ-TBENCH.md](VIBE-REQ-TBENCH.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
# tbench SPEC
## 1. 요구 (확인할 수 있게)
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 |
| --- | --- | --- | --- | --- |
| R1 | | ① | | 꼭 |
## 2. 숨은 요구
## 3. 범위 밖 (하지 않을 것)
## 4. 정해 둘 것 (시간을 재는 범위, 백분위 계산 방법, 저장 폴더, 설정 파일 위치)
```
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
### 요구 사항 (요청서에서 뽑은 것, 14개)
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
| --- | --- | --- | --- | --- |
| R1 | URL과 본문 파일(JSON)을 받아 N번 부르고, 한 번마다 상태 · 시간, 끝에 p50 · p95 · max · 실패 수 (`--plain` 글자 출력) | ① | `tbench <url> --body-file x.json -n 10 --plain`, 백분위 테스트 (1~10초 → 정한 방법의 값), 가짜 서버(3번째마다 500) n=6 → 실패 2 | 꼭 |
| R2 | TUI: 결과가 **오는 대로** 표에 한 줄씩, 위에 스파크라인과 요약 | ② | `tbench <url> --body-file x.json -n 20`, 가짜 서버 지연 0.5초에서 한 줄씩 늘어남 | 꼭 |
| R3 | 결과를 JSON으로 저장하고 두 파일을 비교해 p50 · p95 · max · 실패의 차이(%)를 표로. URL · 본문 해시 · N이 다르면 **경고** | ③ | `tbench compare a.json b.json`, 본문이 다른 두 파일 → 경고 줄 | 꼭 |
| R4 | 동시에 k개씩 (`--concurrency k`) | ④ | 가짜 서버 지연 0.3초 × 6번: k=1 약 1.8초, k=3 약 0.6초 | 나중 |
| R5 | 만들어 둔 요청 고르기: `requests/*.json` 폴더(또는 66일차 `turl` 저장분)에서 TUI로 고르기 | ⑤ | TUI에서 요청 목록 → 하나 고르면 그 본문으로 실행 | 나중 |
| R6 | 첫 호출을 표에 `first call`로 표시, 요약에 **첫 호출 제외 p50**, `--warmup N`(재기 전에 N번 불러 버림) | ⑥ | 가짜 서버가 첫 응답만 2초 → 표 1번 줄에 `first call`, 제외 p50이 따로, `--warmup 1`이면 시간 목록에서 빠짐 | 꼭 |
| R7 | 실패 종류를 따로 셈: `connect`(연결 안 됨) · `timeout`(`--timeout` 넘음) · `http_5xx` · `http_4xx` | ⑦ | 없는 포트 → connect, 지연 3초 + `--timeout 1` → timeout, 가짜 500 → http_5xx | 꼭 |
| R8 | 머리글 넣기(`--header "K: V"` 여러 번), 저장 · 기록 파일에서 `Authorization` · 키 머리글은 `***` | (끝 부탁) | `--header "Authorization: Bearer sk-test"`로 저장 → `grep -c sk-test out.json` → 0 | 꼭 |
| R9 | 응답 JSON에 `timings`가 있으면 단계별 평균(ms) 표 | ⑧ | 내 헬프데스크로 → `classify` · `data` · `answer` · `review` · `total` 평균 줄, `timings`가 없는 응답이면 표를 숨김 | 나중 |
| R10 | 기준 검사: `--max-p95 2.5` · `--max-failures 0` → 끝에 `PASS` · `FAIL` 한 줄, **종료 코드 0 · 1** | ⑨ | 기준을 넘는 가짜 서버 → `echo $?` 1, 넘지 않으면 0 | 나중 |
| R11 | 한 번마다 한 줄 기록: `--log runs.jsonl` (시각 · 번호 · 상태 · 시간 · 실패 종류) | ⑩ | `wc -l runs.jsonl` = N, 줄마다 JSON | 나중 |
| R12 | 설정 저장: `tbench profile save <이름> <url> --body-file ...`, `tbench run <이름>` (설정 파일은 **집 폴더**에, 어느 폴더에서나 같은 설정) | ⑪ | 다른 폴더에서 `tbench run vpn` → 같은 URL · 본문으로 실행 | 나중 |
| R13 | 기록 목록: `tbench history` → 저장한 결과의 날짜 · URL · N · p50 · p95 · 실패 표 (기본 저장 폴더) | ⑫ | 두 번 저장 → 두 줄, 최신이 위 | 나중 |
| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tbench` | 첫 문단 "어느 폴더에서나" | `cd /tmp && tbench --help`, `uv tool list`에 `tbench` | 꼭 |
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
| 숨은 요구 | 어디서 나왔나 |
| --- | --- |
| 시간은 **보내기 직전 ~ 본문을 다 받을 때까지.** 실패도 한 번으로 세고 시간 목록과 따로 둔다. 기본 `--timeout` 30초 | ① "한 번 한 번", ⑦ "오래 걸려서 포기" |
| p50 · p95 계산 방법을 하나로 정하고 **테스트로 고정** (예: nearest-rank, 1~10초 → p50 5 · p95 10) | ① — 방법마다 값이 달라 비교가 흔들림 |
| 부르는 동안 화면이 멈추지 않는다 (Textual worker) | ② "하나 올 때마다 바로" |
| 저장 파일에 **URL · 메서드 · 본문 해시 · N · 동시 수 · warmup · 시작 시각**을 같이 남긴다 | ③ "다른 질문으로 잰 걸 비교", ⑫ "언제 무엇을" |
| 기본 저장 폴더 · 설정 파일 위치를 **집 폴더 아래 한 곳**으로 (예: `~/.tbench/`) | ⑪ ⑫ + "어느 폴더에서나" |
| 엔진(부르기 · 계산 · 저장)과 화면(TUI)을 **나눈다.** `--plain` · `compare` · `history`는 화면 없이 엔진만 씀 | ⑨ "화면을 안 봐도", 테스트하기 쉬움 |
| 테스트는 **pytest 안에서 띄운 가짜 서버**(일부러 느리게 · 가끔 500 · 첫 응답만 느리게)로. 진짜 헬프데스크를 부르지 않는다 | "진짜 헬프데스크를 수십 번 두드리는 건 곤란 (OpenAI 요금)" |
| `pyproject.toml`에 명령 이름(`[project.scripts] tbench = ...`)이 있어야 `uv tool install`이 된다 | R14 |
범위 밖 (오늘 하지 않음): 그래프를 이미지 파일로 저장, 웹 화면, 여러 주소를 한꺼번에, 수천 번 부하 시험, 결과를 서버로 보내기.
`git add Doc/SPEC.md && git commit -m "SPEC"`
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진이 내놓은 결과를 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
```markdown
# tbench IMP-PLAN
## 엔진 1단계 · 부르고 재기 (R1 · R6 · R7 · R8)
- [ ] runner: 한 번 부르기 → {i, status, seconds, error_kind}
- [ ] stats: p50 · p95 · max · 실패 종류별 수 (nearest-rank, 테스트)
- [ ] 가짜 서버 fixture (지연 · N번째마다 500 · 첫 응답만 느림)
- [ ] --plain 출력, --warmup, --header (가리기)
- 확인: uv run pytest -q, uv run tbench <url> -n 5 --plain
## 엔진 2단계 · 남기고 비교 (R3 · R13 · R11)
## 엔진 3단계 · 동시 · 기준 · 응답 분석 · 설정 (R4 · R10 · R9 · R12)
## TUI 1단계 · 오는 대로 보이기 (R2)
## TUI 2단계 · 고르기 · 비교 · 기록 화면 (R5 · R3 · R13)
## 설치 (R14)
```
<details><summary>▶ 왜 엔진 먼저인가</summary>
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `--plain`). 화면부터 만들면 숫자가 맞는지 눈으로만 봅니다.
- `compare` · `history` · R10의 종료 코드는 화면 없이 써야 합니다 (배포 전에 자동으로 돌림).
- TUI는 엔진이 결과를 **하나 낼 때마다** 받아 표에 더하기만 하면 됩니다. 그래서 엔진의 "한 번 부르기"가 결과를 하나씩 돌려주게(콜백 · 제너레이터) 정해 둡니다.
</details>
<details><summary>▶ 단계 크기</summary>
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다.
</details>
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
## 4. 구현 시작 (24분: 엔진 1단계 → TUI 1단계)
이제 에이전트를 켭니다. 첫 요청은 늘 같습니다.
```text
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고,
IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘.
```
그다음 **한 단계씩**: "엔진 1단계만 만들어 줘. 끝나면 IMP-PLAN의 체크를 표시하고 확인 명령을 알려 줘" → 내가 확인 → commit → 다음 단계.
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. 오늘 `tbench`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`. 다음 날 이어 갈 때도 "IMP-PLAN의 체크 안 된 첫 단계부터"로 시작하면 됩니다.
<details><summary>▶ 엔진 1단계 · 시간은 어디서 어디까지</summary>
"보내기 직전부터 응답 본문을 다 받을 때까지"인가요, "첫 바이트까지"인가요? 실패(연결 거부, timeout)도 한 번으로 셀까요? 실패 종류(R7)는 예외 종류(연결 오류 · 시간 초과)와 상태 코드로 나눕니다. 요청문에 정해 줍니다.
</details>
<details><summary>▶ 엔진 1단계 · p95를 직접 계산</summary>
10개로 p95를 구하면 어느 값이 될까요? 계산 방법을 하나로 정하고, 테스트로 고정합니다 (예: 1~10초 열 개 → p50과 p95가 몇인지). 오늘 1단계 강의 2절의 손 계산과 같은 방법이면 비교하기 쉽습니다.
</details>
<details><summary>▶ TUI 1단계 · 화면이 멈추지 않게</summary>
N번 부르는 동안 화면이 굳으면 안 됩니다. Textual에서 오래 걸리는 일을 따로 돌리는 방법(worker)을 요청문에 적습니다. 엔진이 결과를 하나 낼 때마다 표 · 스파크라인 · 요약이 바뀌어야 합니다. 첫 호출(R6)은 메모 칸에.
</details>
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
- 엔진 2단계: 저장 칸에 조건을 함께(R3), 기본 저장 폴더에 쌓기(R13), 한 줄 기록(R11).
- 엔진 3단계: 동시 k개(R4, 결과를 보고 판단), 기준 검사 종료 코드(R10), 응답의 `timings` 평균(R9), 설정 파일(R12).
- TUI 2단계: 요청 고르기(R5, `turl` 저장 형식은 사람마다 다르니 내 것을 먼저 열어 보고 요청문에), 비교 · 기록 화면.
</details>
## 5. 바이브 코딩으로 문제 해결 팁
| 이럴 때 | 이렇게 |
| --- | --- |
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tbench ...`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다. "안 돼요"만 쓰지 않습니다 |
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
| 에이전트가 요청하지 않은 것까지 만듦 | "SPEC 3절 범위 밖"을 가리키고, 그 부분을 빼 달라고 합니다. 단계마다 commit해 두면 되돌리기 쉽습니다 |
| 화면이 다 끝난 뒤에야 나옴 · 굳음 | "엔진을 worker에서 돌리고 결과마다 표에 더하게" |
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console`, 앱은 `uv run textual run --dev <파일>`로 (print · 오류가 콘솔에 보임) |
| 테스트가 진짜 헬프데스크를 부름 | "pytest 안에서 가짜 서버(`http.server`)를 띄워서. 진짜 n8n을 부르지 않는다" |
| p95가 늘 max와 같음 | n이 작으면 그럴 수 있습니다 (5개면 p95 = 5번째). 계산 방법을 테스트로 고정 |
| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 |
| 설치한 `tbench`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
## 6. 결과 확인
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
```bash
cd ~/vibe/tbench
uv run pytest -q # 가짜 서버 테스트
uv run tbench http://localhost:5678/webhook/my-helpdesk \
--body-file ~/aio-01d4-stu/Last3Mon/72/requests/faq-vpn-nocache.json -n 5 --plain # R1 · R6 · R7
uv run tbench http://localhost:5678/webhook/my-helpdesk \
--body-file ~/aio-01d4-stu/Last3Mon/72/requests/faq-vpn-nocache.json -n 10 --save before.json # R2 · R3
grep -c "sk-" before.json # R8: 0
```
- [ ] R1 `--plain`으로 상태 · 시간과 p50 · p95 · max · 실패.
- [ ] R2 결과가 오는 대로 표 · 스파크라인.
- [ ] R3 저장 · 비교 표 (다른 본문이면 경고).
- [ ] R6 첫 호출 표시 · 제외 p50 · `--warmup`.
- [ ] R7 실패 종류별 수.
- [ ] R8 저장 파일에 키 없음.
- [ ] (이어서) R4 · R5 · R9 · R10 · R11 · R12 · R13.
## 7. `uv tool install`로 설치
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
```toml
[project.scripts]
tbench = "tbench.cli:main"
```
```bash
cd ~/vibe/tbench
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
# uv tool install . # 지금 상태를 복사해 설치 (고치면 --force 로 다시)
uv tool list # tbench 가 보이는가
which tbench # ~/.local/bin/tbench
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
```
- 지우기: `uv tool uninstall tbench`.
- `--editable`이 아닌 설치는 **설치할 때의 코드**를 씁니다. 고친 뒤 `uv tool install --force .`.
## 8. 다른 곳에서 사용
설치했으면 프로젝트 폴더 밖에서 씁니다. 오늘 Lab 02의 바꾸기 전 · 후를 내 도구로 비교해 봅니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/72
tbench --help
uv run tools/flow.py push reference/lab02-example2-cache.json
tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 10 --save ~/before.json
uv run tools/flow.py push my-work/helpdesk.json # 내 Lab 02 과제 1 결과
tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 10 --save ~/after.json
tbench compare ~/before.json ~/after.json
```
- (R10을 만들었다면) `tbench ... --max-p95 2.5; echo $?` → 74일차 배포 전 검사에서 그대로 씁니다.
- (R12를 만들었다면) `tbench profile save vpn http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json` → 어느 폴더에서나 `tbench run vpn`.
- `~/vibe/tbench`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다.
## 발표 (8교시, 3분)
| 내용 | |
| --- | --- |
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 5절의 어느 팁으로 풀었나 |
| 시연 | 다른 폴더에서 `tbench compare`로 오늘 헬프데스크의 전 · 후 |
## 확인
- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다.
- [ ] IMP-PLAN의 엔진 1단계 · TUI 1단계를 체크했다 (단계마다 commit).
- [ ] R1 · R2 · R3 · R6 · R7 · R8 확인, `uv run pytest -q`(가짜 서버).
- [ ] R14 `uv tool install`, 다른 폴더에서 `tbench`.
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
+108
View File
@@ -0,0 +1,108 @@
# 7교시 바이브 코딩 · 요청서 `tbench` (가장 먼저 읽기)
- 순서: **이 요청서** → 내 정리 `~/vibe/tbench/Doc/TODO.md` (8분) → [VIBE-Q-TBENCH.md](VIBE-Q-TBENCH.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TBENCH.md`(정답본)로 확인
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 68 · 69 · 71일차와 같은 생각입니다.
```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/>다른 곳에서 쓰기"]
```
## 왜 요청서부터인가
VIBE-Q 문서를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 말을 확인할 수 있는 요구로 바꾸는 일은 에이전트가 아니라 내가 합니다. 오늘 오전 Lab 01에서 고치기 전에 **먼저 잰 것**과 같은 일입니다: 기준이 있어야 "좋아졌다"를 말할 수 있습니다.
| 단계 | 누가 | 결과 |
| --- | --- | --- |
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 |
| 확인 | **나** | 직접 `uv run tbench …`, `pytest`, 요구 표에 체크 |
## 요청 (헬프데스크 운영 담당자에게서 온 메시지, 그대로)
> 오늘 헬프데스크 빨라진 거 봤어요. 그런데 저는 n8n 화면도 `flow.py`도 잘 몰라요. 앞으로 뭘 바꿀 때마다 **제가 직접** "빨라졌나, 느려졌나"를 확인할 수 있는 작은 프로그램이 있으면 좋겠어요. 터미널에서 돌아가면 되고, **제 노트북 어느 폴더에서나** `tbench`라고 치면 되게 해 주세요.
>
> ① 주소 하나랑 보낼 내용을 주면 **여러 번** 불러서, 한 번 한 번 얼마나 걸렸는지, **몇 번 실패했는지** 알려 주세요. 평균만 주시면 안 돼요. 사용자들은 "평소엔 괜찮은데 **가끔 엄청 느리다**"고 하거든요. 보통 얼마인지, 느린 쪽은 얼마인지 따로 보고 싶어요.
>
> ② 20번, 50번 부르면 한참 걸리잖아요. 다 끝날 때까지 **멍하니 기다리기 싫어요.** 하나 올 때마다 바로 보이고, 들쭉날쭉한 **모양**을 한눈에 보고 싶어요.
>
> ③ 바꾸기 전에 한 번, 바꾼 뒤에 한 번 재서 **숫자로 비교**하고 싶어요. 어제 잰 것과도 비교할 수 있게 남겨 주세요. 그런데 지난번에 누가 **다른 질문으로 잰 걸** 비교해서 "두 배 빨라졌다"고 했다가 망신당했어요.
>
> ④ 월요일 아침엔 여러 사람이 **한꺼번에** 물어봐요. 그때도 버티는지 보고 싶어요.
>
> ⑤ 보낼 내용을 매번 새로 쓰기 귀찮아요. 이미 만들어 둔 요청들(`requests` 폴더나, 66일차에 만든 `turl`에 저장한 것)을 **골라서** 쓰고 싶어요.
>
> ⑥ 고치고 나서 다시 올리면 **첫 번째만 유난히 느리더라고요.** 그 한 번 때문에 결과가 나빠 보이는 건 싫어요. 첫 번을 빼고도 보고 싶고, 미리 몇 번 불러 두고 재는 것도 되면 좋겠어요.
>
> ⑦ 실패했다고만 하지 말고 **왜** 실패했는지요. 연결이 아예 안 된 건지, 너무 오래 걸려서 포기한 건지, 서버가 오류를 낸 건지에 따라 연락할 사람이 달라요.
>
> ⑧ 헬프데스크가 결과에 **단계별 시간**(`timings`)을 주잖아요. 그것도 모아서 단계마다 평균을 보여 주면, 어디가 느린지 개발자한테 바로 말할 수 있겠어요.
>
> ⑨ 나중에는 제가 화면을 안 봐도 되게, "**느린 쪽이 2.5초를 넘으면 실패**"처럼 기준을 주면 통과 · 실패만 알려 주는 것도 필요해요. 배포 전에 자동으로 돌려 보려고요.
>
> ⑩ 한 번 한 번의 기록이 **줄줄이** 파일에 남으면 좋겠어요. 나중에 다른 도구로 모아 보게요.
>
> ⑪ 매번 주소랑 옵션을 길게 치기 귀찮아요. 자주 쓰는 설정은 **파일에 저장**해 두고 이름으로 부르고 싶어요.
>
> ⑫ 그동안 잰 것들이 **목록으로** 보이면 좋겠어요. 언제, 무엇을, 얼마로 쟀는지요.
>
> 아, 어떤 주소는 열쇠(키)가 머리글에 들어가요. **남기는 파일에 키가 찍히면 안 돼요.** 그리고 이 프로그램을 시험할 때마다 **진짜 헬프데스크를 수십 번 두드리는 건** 곤란해요 (그때마다 OpenAI 요금이 나가요).
## 함께 받은 것
| 파일 · 자료 | 메모 |
| --- | --- |
| `~/aio-01d4-stu/Last3Mon/72/requests/*.json` | 보낼 내용(본문) 예. `faq-vpn-nocache.json`은 캐시 없이 재는 VPN 질문 |
| 오늘 `uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5`의 출력 | `p50 · p95 · max · failures` 한 줄 (Lab 01 예제 1) |
| 오늘 `my-work/PERF.md` 1 · 3절 | 바꾸기 전 · 후를 사람이 손으로 적은 표 |
| 오늘 헬프데스크 응답의 `timings` | `classify_ms` · `data_ms` · `answer_ms` · `review_ms` · `total_ms` (⑧) |
| 66일차 `~/vibe/turl` (있으면) | 저장한 요청의 모양은 각자 다름 |
- 재 볼 주소: 내 헬프데스크 `http://localhost:5678/webhook/my-helpdesk` (POST, JSON 본문).
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
`~/vibe/tbench/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁 둘을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
```markdown
# tbench TODO
## 만들고 싶은 것 (한 문단)
어느 폴더에서나 `tbench`로 웹훅을 여러 번 불러 ...
## 요청의 말 → 할 일
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
| --- | --- | --- | --- |
| ① | 여러 번, 몇 번 실패, 보통 · 느린 쪽 | | 꼭 |
| ⑥ | 첫 번만 느림 | | |
## 요청에 없지만 걱정되는 것
## 오늘 50분 안에 못 할 것 같은 것
```
예 하나만 보여 드립니다.
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
| --- | --- | --- |
| ① "평균만 주시면 안 돼요 … 보통 얼마인지, 느린 쪽은 얼마인지 따로" | "N번 부르고 한 번마다 상태 · 시간을 보여 주고, 끝에 보통(p50) · 느린 쪽(p95) · 가장 느린(max) · 실패 수" | R1: 백분위 계산 방법을 정해 테스트로 고정 (1~10초 → p50 · p95가 몇), 가짜 서버로 실패 수 확인 |
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자에게 **가장 먼저 필요한 것**은 무엇일까요? (힌트: 첫 문단 "빨라졌나, 느려졌나를 직접 확인") 그것 없이는 나머지가 의미 없는 것부터 "꼭"으로.
</details>
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
- ① "한 번 한 번 얼마나 걸렸는지": 시간은 **어디서 어디까지**인가요? 끝없이 기다리면?
- ② "하나 올 때마다 바로": 부르는 동안 화면이 멈추면 안 됩니다.
- ③ "다른 질문으로 잰 걸 비교": 결과 파일에 무엇이 함께 남아야 하나요?
- ⑨ "통과 · 실패만": 사람이 아니라 **다른 프로그램**이 결과를 읽으려면 무엇으로 알려야 하나요? (종료 코드)
- "진짜 헬프데스크를 수십 번 두드리는 건 곤란" → 시험할 때 무엇을 대신 부르나요?
- "어느 폴더에서나 `tbench`" → 내 프로젝트 폴더 밖에서도 실행되려면?
</details>
## 그다음
[VIBE-Q-TBENCH.md](VIBE-Q-TBENCH.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
+56
View File
@@ -0,0 +1,56 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "FAQ · 접수 시간 따로",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"faq_ms"
]
}
}
},
{
"name": "접수 조회 시간",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"ticket_ms"
]
}
}
},
{
"name": "가장 오래 걸린 단계",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
},
{
"name": "접수 현황도 같은 모양",
"request": "requests/ticket-own.json",
"expect": {
"contains": {
"answer": [
"처리 중"
],
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
}
]
}
+104
View File
@@ -0,0 +1,104 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "예산 안 (넉넉한 예산)",
"body": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"no_cache": true,
"budget_ms": 60000
},
"expect": {
"equals": {
"over_budget": false,
"budget_ms": 60000
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
],
"answer": [
"인증서"
]
}
}
},
{
"name": "예산 넘음 (0.5초 예산)",
"body": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"no_cache": true,
"budget_ms": 500
},
"expect": {
"equals": {
"over_budget": true,
"budget_ms": 500
}
}
},
{
"name": "예산을 안 주면 3초",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"budget_ms": 3000
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
}
},
{
"name": "접수 현황도 같은 모양",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"budget_ms": 3000,
"category": "ticket"
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
],
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "VPN FAQ · 단계별 시간",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "faq"
},
"contains": {
"answer": [
"인증서"
],
"timings": [
"answer_ms"
]
}
}
},
{
"name": "VPN FAQ · 전체 시간 · LLM 호출 수",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"total_ms"
],
"steps": [
"answer"
],
"llm_calls": [
"1",
"2",
"3"
]
}
}
},
{
"name": "접수 현황",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"answer": [
"처리 중"
],
"timings": [
"answer_ms"
]
}
}
},
{
"name": "빈 질문",
"request": "requests/empty-question.json",
"expect": {
"status": 400
}
}
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"webhook": "my-helpdesk",
"before": [
{
"webhook": "my-helpdesk",
"body": {
"question": "워밍업: 프린터 출력이 안 돼요",
"user": "이준호",
"no_cache": true
}
}
],
"cases": [
{
"name": "VPN · LLM 한 번 · 2.5초 안",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"llm_calls": 1,
"cached": false
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 2.5
}
},
{
"name": "비밀번호 · LLM 한 번",
"body": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"no_cache": true
},
"expect": {
"equals": {
"llm_calls": 1
},
"contains": {
"answer": [
"비밀번호 찾기",
"휴대폰"
]
},
"max_seconds": 2.5
}
},
{
"name": "접수 현황 · LLM 한 번",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"llm_calls": 1
},
"contains": {
"answer": [
"처리 중"
]
},
"max_seconds": 2.5
}
},
{
"name": "규칙으로 모르면 분류 + 답 두 번",
"expect": {
"equals": {
"llm_calls": 2
}
},
"body": {
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
"user": "이준호",
"no_cache": true
}
},
{
"name": "빈 질문은 LLM 없이",
"request": "requests/empty-question.json",
"expect": {
"status": 400,
"max_seconds": 1
}
}
]
}
+104
View File
@@ -0,0 +1,104 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "처음 묻는 VPN (캐시 없음)",
"request": "requests/faq-vpn.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "다른 말로 묻는 VPN → FAQ 번호 캐시",
"body": {
"question": "VPN 연결이 계속 끊어지는데요",
"user": "최유진"
},
"expect": {
"equals": {
"cached": true,
"cache_by": "faq",
"llm_calls": 0
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 1
}
},
{
"name": "같은 말은 질문 캐시",
"request": "requests/faq-vpn-again.json",
"expect": {
"equals": {
"cached": true,
"cache_by": "question",
"llm_calls": 0
},
"max_seconds": 1
}
},
{
"name": "no_cache면 FAQ 번호 캐시도 안 씀",
"body": {
"question": "VPN 연결이 계속 끊어지는데요",
"user": "최유진",
"no_cache": true
},
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "다른 FAQ는 따로 (프린터 처음)",
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "접수 현황은 캐시 안 함",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"처리 중"
]
}
}
}
]
}
+158
View File
@@ -0,0 +1,158 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "예제 1 · 프린터 FAQ는 규칙으로",
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"category": "faq"
},
"contains": {
"steps": [
"rule"
],
"answer": [
"대기열",
"전원"
]
},
"not_contains": {
"steps": [
"classify"
]
}
}
},
{
"name": "예제 1 · 접수 번호는 규칙으로",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"steps": [
"rule"
],
"answer": [
"처리 중"
]
},
"not_contains": {
"steps": [
"classify"
]
}
}
},
{
"name": "예제 1 · 없는 접수 번호",
"request": "requests/ticket-missing.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"answer": [
"찾지 못",
"확인되지",
"없습니다",
"존재하지"
]
},
"not_contains": {
"answer": [
"처리 중",
"처리 완료",
"접수됨",
"반려"
]
}
}
},
{
"name": "예제 1 · 규칙으로 모르면 LLM이 분류",
"expect": {
"contains": {
"steps": [
"classify"
]
}
},
"body": {
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
"user": "이준호",
"no_cache": true
}
},
{
"name": "예제 2 · 처음 묻는 VPN",
"request": "requests/faq-vpn.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "예제 2 · 같은 질문 (띄어쓰기·대소문자 다름)",
"request": "requests/faq-vpn-again.json",
"expect": {
"equals": {
"cached": true,
"llm_calls": 0
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 1
}
},
{
"name": "예제 2 · no_cache면 캐시 안 씀",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"cached": false
}
}
},
{
"name": "예제 2 · 접수 현황은 캐시 안 함 (1)",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
}
}
},
{
"name": "예제 2 · 접수 현황은 캐시 안 함 (2)",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"처리 중"
]
}
}
}
]
}
+103
View File
@@ -0,0 +1,103 @@
{
"webhook": "my-health",
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소",
"request": "requests/health.json",
"expect": {
"equals": {
"status": "ok",
"checks.llm": true,
"checks.faq": true
}
}
},
{
"name": "FAQ 오류",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.faq": false,
"checks.data_api": true
}
}
},
{
"name": "FAQ 느림 (8초) · 점검은 빨리 끝남",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.faq": false
},
"max_seconds": 5
}
},
{
"name": "LLM 멈춤",
"before": [
{
"llm": "down"
}
],
"after": [
{
"llm": "up"
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.llm": false,
"checks.faq": true
}
}
}
]
}
+106
View File
@@ -0,0 +1,106 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
},
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "평소",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "FAQ 느림 · 처음 한 번은 기다린 뒤 담당자",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "data_api_down"
},
"max_seconds": 7
}
},
{
"name": "FAQ 느림 · 다음 질문은 기다리지 않음 (차단기 열림)",
"body": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "breaker_open"
},
"max_seconds": 1
}
},
{
"name": "FAQ가 돌아오고 차단기를 비우면 평소대로",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
},
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"body": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"no_cache": true
},
"expect": {
"equals": {
"route": "자동 답변"
},
"contains": {
"answer": [
"대기열",
"전원"
]
}
}
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
},
{
"llm": "down"
}
],
"after": [
{
"llm": "up"
}
],
"cases": [
{
"name": "VPN · FAQ 원문으로",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"status": 200,
"equals": {
"route": "간단 답변"
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "접수 현황 · 상태 그대로",
"request": "requests/ticket-own.json",
"expect": {
"status": 200,
"equals": {
"route": "간단 답변"
},
"contains": {
"answer": [
"처리 중"
]
}
}
},
{
"name": "모르는 질문 · 담당자",
"request": "requests/off-topic.json",
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "빈 질문",
"request": "requests/empty-question.json",
"expect": {
"status": 400
}
}
]
}
+123
View File
@@ -0,0 +1,123 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소 접수 현황",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "ticket"
},
"contains": {
"answer": [
"처리 중"
]
}
}
},
{
"name": "접수 API 500 · 접수 질문은 담당자",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"tickets": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/ticket-own.json",
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "tickets_down"
},
"not_contains": {
"answer": [
"처리 중",
"처리 완료",
"접수됨",
"반려"
]
},
"max_seconds": 3
}
},
{
"name": "접수 API 500 · FAQ 질문은 그대로 답",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"tickets": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/faq-vpn-nocache.json",
"expect": {
"status": 200,
"equals": {
"route": "자동 답변",
"category": "faq"
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "없는 접수 번호는 그대로 (404는 고장이 아님)",
"request": "requests/ticket-missing.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "ticket"
},
"contains": {
"answer": [
"찾지 못",
"확인되지",
"없습니다",
"존재하지"
]
}
}
}
]
}
+123
View File
@@ -0,0 +1,123 @@
{
"webhook": "my-helpdesk",
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "FAQ가 느릴 때 (8초)",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
},
"max_seconds": 7
}
},
{
"name": "FAQ가 오류일 때 (500)",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
},
"max_seconds": 5
}
},
{
"name": "캐시에 있으면 FAQ가 멈춰도 답",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
},
{
"webhook": "my-helpdesk",
"request": "requests/faq-printer.json"
},
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"route": "자동 답변",
"cached": true
}
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# Sample company data API for day 72 (day 71 API + fault switch + cache), 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
@@ -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가 만든 결과는 사람이 확인한 뒤에 사용한다.
+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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+6
View File
@@ -0,0 +1,6 @@
{
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다."},
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다."},
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다."},
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
}
+173
View File
@@ -0,0 +1,173 @@
"""Sample company data API for the day 72 labs (day 71 API + fault switch + cache; 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 /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", "slow_seconds": 8}
POST /admin/fault body {"faq": "ok"|"slow"|"error", "tickets": ..., "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"}
"""
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", "slow_seconds": 8}
CACHE: dict[str, dict] = {}
STATS = {"hits": 0, "misses": 0}
def load(name):
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
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 == "/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 == "/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})
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"):
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 == "/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})
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 (`helpdesk.json`, `health.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 처음에는 `cp reference/baseline-helpdesk.json my-work/helpdesk.json`.
- `PERF.md`: [성능 · 안정성 보고서](../templates/PERF-REPORT-TEMPLATE.md)를 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,442 @@
{
"name": "72 IT helpdesk baseline (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "base-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "f7f86794-1216-5eae-93b9-9e69bb515ce3",
"position": [
0,
300
],
"id": "73fa6963-23cf-5fab-be79-21db3461385e"
},
{
"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": "108e39d5-615a-517a-9060-e09e7c4379f9"
},
{
"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": "1d7d4c12-dba9-5291-95eb-fbcf440257be"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
440,
300
],
"id": "e44002cd-d810-5fbe-9eef-d0d9e897b974"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
440,
520
],
"id": "555c72bf-fdc6-5560-9c4a-386b1cf78fe7"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
660,
300
],
"id": "169c07e6-bc8e-5307-9502-4a8114c854fa"
},
{
"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": [
880,
300
],
"id": "7459ee39-7124-57c0-8bdf-c596f65f7deb"
},
{
"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": [
1100,
300
],
"id": "70d6cf5f-6064-5790-a7e6-628e044f95b9"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $('Parse category').first().json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
1320,
300
],
"id": "bd94eff5-ddeb-5a04-bfb7-e8d62ca7ed45"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
520
],
"id": "e40a7e46-cf90-5b11-a629-6f92fe5a043c"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
1540,
300
],
"id": "540513f3-f96b-5672-913f-ba4846dd768b"
},
{
"name": "Review model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1540,
520
],
"id": "6f1ffb5c-b821-593b-8cd2-ebb68d63a277"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nreturn [{ json: result }];"
},
"position": [
1760,
300
],
"id": "a749f7a2-8109-50f4-a3c4-dd1dbe29f8e8"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1980,
300
],
"id": "86789b60-2654-528e-aef9-adf67767dbd3"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"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,562 @@
{
"name": "72 IT helpdesk + timings (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "f7eddb61-8a29-5748-9dfe-a33e4db5fb4e"
},
{
"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": "f10269df-f57b-55be-9527-7d99273541e5"
},
{
"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": "ae5fdf1d-0e8e-5b9e-a375-d1c2a8159ff0"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "34e026d9-8555-5c58-98bc-0c8cb3433674"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
660,
300
],
"id": "8ed1076b-57e7-5692-a07b-0dfc406d717d"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
660,
520
],
"id": "115afa58-b938-5f5f-aa27-b90886caafef"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
880,
300
],
"id": "e3a4245a-0d3e-52f9-8bba-fac408559c43"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1100,
300
],
"id": "6367ccc8-0f9a-53b5-ab75-b5b97411bcf4"
},
{
"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": [
1320,
300
],
"id": "e9049433-bf26-5f16-9025-ee8e45168e66"
},
{
"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": [
1540,
300
],
"id": "015582e0-c7ba-5450-afdb-31fe69bd4ed0"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "724f4528-be4b-5a27-95fa-dc4cbe9adc54"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $('Parse category').first().json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
1980,
300
],
"id": "73f8249d-0f63-5a27-ba24-7c0160dd4f37"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1980,
520
],
"id": "253ae3fd-037d-50f8-a521-974f74f1ea02"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2200,
300
],
"id": "c91805b8-8a84-5d53-8ef1-24f414f43b4f"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "58a6606c-b82d-585d-98c2-ad0b50d978d2"
},
{
"name": "Review model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
520
],
"id": "9738bf2a-5100-51be-ab12-e932ef259273"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2640,
300
],
"id": "aafed179-dfd6-5b4d-affa-ba03915a19bf"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['classify', t('After classify')], ['data', t('After data')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
2860,
300
],
"id": "4da41b13-2fc9-5423-a782-fb61727ea33a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3080,
300
],
"id": "2fa9e489-3282-5de4-9c91-f536e53b568a"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After 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,637 @@
{
"name": "72 IT helpdesk + rules first (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "943597e2-d5eb-5f3c-b36b-f18ee54db8d7"
},
{
"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": "ca5c79c8-323b-5eac-b1c7-50d7383f1fc5"
},
{
"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": "030b2d85-73de-567f-95ce-d25c8729794d"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "2240260c-639c-501a-9347-9da083549304"
},
{
"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": [
660,
300
],
"id": "aabca858-7174-5c55-b7f5-ae3ab4e6a565"
},
{
"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": [
880,
300
],
"id": "92beb818-7b68-528c-9d35-f1c1645ea215"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1100,
300
],
"id": "dbf68168-4cb7-55db-8ba9-9752ff11f684"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1320,
300
],
"id": "36d285f0-4fb3-5697-9053-5ae5e3e8d0b1"
},
{
"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": [
1540,
300
],
"id": "fd83a3d5-bc74-5739-8185-1924a7939ed8"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
1760,
300
],
"id": "63d9e328-246e-5bd4-9596-40e9b9715464"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1760,
520
],
"id": "8aabd2dc-204a-5730-9633-1bd0858969ba"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
1980,
300
],
"id": "1150b73c-a088-5d4f-ac61-38faec69bc23"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2200,
300
],
"id": "337a90fe-b0bf-5e29-82ec-c1178ec1eeec"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "e23b0a82-1cb0-50e9-bf9c-ea5eb3b053f9"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
520
],
"id": "ce2907bb-2524-54e7-9e0a-92bed701764f"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2640,
300
],
"id": "a0321f1e-05f2-5e4f-b7cf-f0eb06eaf0f1"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
2860,
300
],
"id": "5ee9442d-00be-5492-83bb-a99147e6e282"
},
{
"name": "Review model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2860,
520
],
"id": "b496eea1-f048-550c-9893-515dcc94c84e"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3080,
300
],
"id": "c955c14a-8a9b-538f-94db-27d3fd579751"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3300,
300
],
"id": "185dc7f9-4954-5cef-ba24-f45145bd192a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3520,
300
],
"id": "4dd5ed77-a95a-5969-a9f7-78ab7d32fb21"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"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
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After 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,894 @@
{
"name": "72 IT helpdesk + cache (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "c9a72036-8a27-592b-bd8b-38a2db4d6d06"
},
{
"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": "6c786a1e-1989-51a4-90a5-db92e73869b1"
},
{
"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": "a4a18f10-bc62-5d90-9b67-68c9feee398e"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "997a12bd-2387-5f2b-9bd8-41d0fd96b3c0"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "f5beb6a3-5fd1-5dd9-9940-8180d0461375"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "e4ac7f07-094d-5369-877c-b6124bb369be"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "d7643f14-e849-5965-98c1-0984a9164992"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "1eb4a1d1-0752-549e-8419-f421001f6ce7"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "ef4c0dcc-6923-515a-9a47-16b96f870458"
},
{
"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": [
1320,
300
],
"id": "c0ca4c44-24ab-5e35-bc09-3e1a46b5eaf2"
},
{
"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": [
1540,
300
],
"id": "8bfa2f94-cfd3-5c47-9907-9cbe520709f5"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "ec991b78-5d32-523e-8650-1b705d664347"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "3aea9b36-7147-5a1e-b214-dde00e3b07a8"
},
{
"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": [
2200,
300
],
"id": "cb84100b-77a0-54e2-ab8a-82439f9a06a2"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "24cdba37-3d33-5b58-bdf8-843fdceb921d"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
520
],
"id": "14a4b607-e7e0-5946-a6dd-429db1749efe"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "e2e83cde-1e8e-57a3-9873-e21b349e8e9c"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "52489cc0-dc17-55fd-abc1-d0560f984a09"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
3080,
300
],
"id": "af3fff36-6c0f-561b-892c-c114ba138f00"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
3080,
520
],
"id": "a17533bf-9c80-521d-b6e3-1a50309fecba"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "f8f2abf1-fe1f-5e26-bbdf-a7265cc0af82"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
3520,
300
],
"id": "db2e207e-bf3f-573b-b812-8903cfe2e8b0"
},
{
"name": "Review model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
3520,
520
],
"id": "45781a30-03fe-5886-8936-2d8a9ae3bd31"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "e4200614-0722-52ad-b86b-166d8325db9e"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "aca47054-6aa3-5773-91c4-1b71155360a1"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "6173c5a7-0f27-5472-b65d-7b1e1e4a8c2a"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "131ba1d7-a5c6-59d8-b998-6d56b6dcbb90"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "9fa5ecbb-8ba2-53cf-9ed0-e39b0f0abce4"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "6452676c-2624-523c-a63e-cb1f2cdf88ce"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"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
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,938 @@
{
"name": "72 IT helpdesk + timeout and fallback (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "d723516c-f180-54b9-87ca-9d10479930ca"
},
{
"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": "b2ec2f0b-bda5-57b7-90bb-a12187a87ada"
},
{
"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": "e8162a04-f42f-5d77-9fb2-8b48bb83fa93"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "8298edad-e01f-5083-a050-e61d239cdd06"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "947ac5f9-250f-59f0-a8c2-4f178d3e27bf"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "bca22ac4-98e9-5840-9565-0a08abd392dd"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "c6f11d8c-4cf4-56da-a0c2-86b43d5875b7"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "87241232-65a7-5929-8aab-f793cd5eb252"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "c9ad7484-9647-56c6-bc21-489ae4ba90c9"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1320,
300
],
"id": "7fadfe95-f11a-53e1-9381-6eaabe3b0e33"
},
{
"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
}
},
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1540,
300
],
"id": "63fcb7b8-6a86-5ff2-9ec7-dbf12d90ba53"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "e03ac097-cb39-5e96-b35f-6fef794dccc0"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "ad169caa-8acb-5747-9d80-de0809af7722"
},
{
"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": [
2200,
300
],
"id": "217e4a57-9524-59ca-8a5c-8d802a6ed928"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "84844dd8-a4c4-5466-8bfa-0164024d6889"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
520
],
"id": "f97e877f-20e8-56a3-bbe5-504dceb6297f"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "79533502-a17e-5fce-a050-1f2577b3ef9f"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "bd326baa-dd79-5020-9634-e80a1503221f"
},
{
"name": "Data down",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "d1",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "d2",
"name": "answer",
"value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.",
"type": "string"
},
{
"id": "d3",
"name": "reason",
"value": "data_api_down",
"type": "string"
}
]
},
"options": {}
},
"position": [
3080,
820
],
"id": "277957fb-f7b9-52f0-a345-6b0210360668"
},
{
"name": "Send data down",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3300,
820
],
"id": "1a26c632-4637-5a77-bf25-ad0068c539fe"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"position": [
3080,
300
],
"id": "df2b22cd-48a6-5b76-b884-ed5f116bee5a"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
3080,
520
],
"id": "933cc07a-882b-5803-a339-ce7fff18d24e"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "1126ce89-1de8-5dfd-af02-dfe16f941d0c"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($('Answer').first().json.output ?? '').trim();\nconst problems = [];\nif (!answer) problems.push('empty');\nif (!/[가-힣]/.test(answer)) problems.push('not_korean');\nif (answer.length > 400) { answer = answer.slice(0, 400); problems.push('too_long'); }\nreturn [{ json: { ...$json, answer, review_problems: problems } }];"
},
"position": [
3520,
300
],
"id": "4ceb73fa-4d1a-5166-9713-53a66329aa4b"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "c27c79b7-4a80-5197-9eb8-cc8ea3af07de"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = $('Review check').first().json.answer;\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('check');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 0;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "4e59eba2-74ef-5ad9-9322-f2a61dd24c2e"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "b02df770-a2da-5ff5-8506-be2aa57f15c4"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "5e7d5d4d-1b3e-53cb-a622-f8b9057b7948"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "a3625557-95dd-5b42-b0cc-a71bf5dfb881"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "ec557367-5b2f-59d5-b7e1-ad7965d5b893"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"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
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"Data down": {
"main": [
[
{
"node": "Send data down",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,993 @@
{
"name": "72 IT helpdesk + LLM fallback (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "da213575-a9a7-5052-9259-1235f4e340f0"
},
{
"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": "4ea38fda-f28a-5e1d-a504-05874f837f02"
},
{
"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": "ff743009-a8ea-5c5f-8b83-9e29a2c60111"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "f1cc57e6-9966-5f49-90c4-cbd65be68041"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "3c70cd68-4a52-59c2-a5c3-d30c86914021"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "506d8e43-b396-596a-8197-08f2aa58381b"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "1113fdf6-f964-5534-8d8a-dd41344c2725"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "82a7f0f0-356b-5164-baaa-a478f74db65e"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "addd6ac5-76a5-5da7-ac2b-84c005eb7520"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1320,
300
],
"id": "52b6a8a9-bf1f-5b58-8644-0077cfd01dd0"
},
{
"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
}
},
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1540,
300
],
"id": "0a16cd98-b769-5067-b867-beabec01b00d"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "ba6f2475-7cea-5c3d-95a6-dc6125a0441c"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "a2e2c597-6870-5bcc-833c-a44526b343f3"
},
{
"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": [
2200,
300
],
"id": "e0758a6f-de55-5309-a696-3ed0755ab1f0"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"onError": "continueErrorOutput",
"position": [
2420,
300
],
"id": "05ed7c16-110e-53ab-9b03-ce872d616695"
},
{
"name": "Classify model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
520
],
"id": "5a7c30c2-472e-54b3-89c2-85b53e3f432b"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "1407c587-1e10-5d76-b43a-9bf6570eb619"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "77f26c5e-fd71-59c5-b02f-cee875e87ee7"
},
{
"name": "Data down",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "d1",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "d2",
"name": "answer",
"value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.",
"type": "string"
},
{
"id": "d3",
"name": "reason",
"value": "data_api_down",
"type": "string"
}
]
},
"options": {}
},
"position": [
3080,
820
],
"id": "7db7288e-627d-5b0f-8634-2bd12ba22e35"
},
{
"name": "Send data down",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3300,
820
],
"id": "e582596a-b12d-550c-8ba8-ea5661be5b32"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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.",
"maxIterations": 1
}
},
"onError": "continueErrorOutput",
"position": [
3080,
300
],
"id": "a0b37e8a-ba45-5496-baf5-3fe7eb4e11cb"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
3080,
520
],
"id": "eadbe0ed-8031-5d85-91c2-258b144dcc6d"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "2a0a5546-0677-5a3b-89b9-cfb1887a696b"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($('Answer').first().json.output ?? '').trim();\nconst problems = [];\nif (!answer) problems.push('empty');\nif (!/[가-힣]/.test(answer)) problems.push('not_korean');\nif (answer.length > 400) { answer = answer.slice(0, 400); problems.push('too_long'); }\nreturn [{ json: { ...$json, answer, review_problems: problems } }];"
},
"position": [
3520,
300
],
"id": "24064ede-b012-51e6-8e8a-a40032982e5b"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "d6ea34a2-56fe-5d19-b1fe-a07a2176b6cf"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = $('Review check').first().json.answer;\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('check');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 0;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "ddb667d7-58f4-591a-99b1-9fdee196e7a9"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "be269673-e6ef-5484-adb9-d2b266f59f97"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "1d551b65-ef7f-527e-8339-ce95e2272e2d"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "056a1325-c8d0-501f-9dab-599a8380748e"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "0bc9ba7d-cc11-5f62-a05e-107089fe53b4"
},
{
"name": "Simple answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nlet results = [];\ntry { results = $('Search FAQ').first().json.results ?? []; } catch (error) { results = []; }\nlet ticket = {};\ntry { ticket = $('Get ticket').first().json ?? {}; } catch (error) { ticket = {}; }\nconst isTicket = /T-?\\d{4}/i.test(String(body.question ?? ''));\nif (isTicket && ticket.status) {\n return [{ json: { route: '간단 답변', category: 'ticket', answer: `${ticket.ticket_id} 접수 건은 현재 '${ticket.status}' 상태입니다. ${ticket.note ?? ''}`.trim(),\n sources: [], llm_calls: 0, reason: 'llm_down' } }];\n}\nif (!isTicket && results.length > 0) {\n return [{ json: { route: '간단 답변', category: 'faq', answer: `${results[0].answer} (자동 답변 기능을 잠시 쓸 수 없어 FAQ 원문으로 안내합니다.)`,\n sources: [results[0].id], llm_calls: 0, reason: 'llm_down' } }];\n}\nreturn [{ json: { route: '담당자 연결', category: 'other', answer: '지금은 자동 답변을 할 수 없어 담당자에게 연결합니다.', sources: [], llm_calls: 0, reason: 'llm_down' } }];"
},
"position": [
4400,
1060
],
"id": "9d9bceef-74c6-5bea-8e09-8d6fca3dd417"
},
{
"name": "Send simple answer",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4620,
1060
],
"id": "3ab6f0cb-6d8f-5292-86c1-86b2645c74b5"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"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
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
],
[
{
"node": "Simple answer",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"Data down": {
"main": [
[
{
"node": "Send data down",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Simple answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Simple answer": {
"main": [
[
{
"node": "Send simple answer",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because it is too large. Load diff
+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": "최유진"}
@@ -0,0 +1 @@
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호", "no_cache": true}
+1
View File
@@ -0,0 +1 @@
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "오늘 점심 메뉴 추천해 주세요", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-9999 접수 건은 어떻게 됐나요?", "user": "박서연"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
@@ -0,0 +1,84 @@
# 성능 · 안정성 보고서 · IT 헬프데스크
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/PERF.md`로 복사해 채웁니다.
## 1. 기준 재기 (Lab 01)
`uv run tools/flow.py bench base-helpdesk requests/<요청>.json -n 5`
| 요청 | p50 | p95 | max | 첫 호출 | 실패 |
| --- | --- | --- | --- | --- | --- |
| `faq-vpn` | | | | | |
| `ticket-own` | | | | | |
| `off-topic` | | | | | |
## 2. 단계별 시간 (Lab 01 · `timings`)
| 요청 | classify | data (faq · ticket) | answer | review | total | 가장 오래 걸린 단계 |
| --- | --- | --- | --- | --- | --- | --- |
| `faq-vpn` | | | | | | |
| `ticket-own` | | | | | | |
- LLM을 부를 필요가 없었던 요청 · 단계는?
### 2-1. 질문 종류별 (Lab 01 예제 3)
| 질문 종류 | p50 | p95 | classify | data | answer | review | total | LLM 몫 (%) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| FAQ (VPN) | | | | | | | | |
| 접수 번호 (T-1002) | | | | | | | | |
| IT 밖 (점심 메뉴) | | | | | | | | |
### 2-2. 시간 예산 (Lab 01 과제 2)
| `budget_ms` | `total_ms` | `llm_ms` | `over_budget` | `slowest_step` |
| --- | --- | --- | --- | --- |
| 60000 | | | | |
| 500 | | | | |
| (기본 3000) | | | | |
## 3. 한 가지씩 바꾸기 (Lab 02)
| 바꾼 것 (한 번에 하나) | `llm_calls` | p50 | p95 | 품질 테스트 (`lab01` · `lab02`) | 잃은 것 |
| --- | --- | --- | --- | --- | --- |
| 기준 | | | | | |
| 규칙 먼저 | | | | | |
| 캐시 (같은 질문 두 번째) | | | | | |
| 검토 → 검사 (과제 1) | | | | | |
| FAQ 번호 캐시 (과제 2, 다른 말 질문) | | | | | |
### 3-1. 캐시 적중률 (Lab 02 예제 3)
| 재기 | 첫 호출 | p50 | max | hits · misses |
| --- | --- | --- | --- | --- |
| 캐시 씀 | | | | |
| 캐시 안 씀 | | | | |
- 적중률 10% · 30%일 때 평균 어림:
## 4. 멈췄을 때 (Lab 03)
| 상황 | 고치기 전 (시간 · 결과) | 고친 뒤 (시간 · route) |
| --- | --- | --- |
| FAQ가 느림 (8초) | | |
| FAQ가 오류 (500) | | |
| LLM이 멈춤 (`llm down`) | | |
| 캐시에 있는 질문 + FAQ 오류 | | |
| 접수 API만 오류 · 접수 질문 (예제 3) | | |
| 접수 API만 오류 · VPN 질문 (예제 3) | | |
| FAQ 느림 · 다음 질문 (과제 2 차단기) | | |
## 5. 상태 점검 (Lab 03 과제)
| 상황 | `status` | `checks` | 걸린 시간 |
| --- | --- | --- | --- |
| 평소 | | | |
| FAQ 오류 | | | |
| FAQ 느림 | | | |
| LLM 멈춤 | | | |
## 6. 운영 관점 개선 제안 (3줄)
-
@@ -0,0 +1,27 @@
# 테스트 보고서 · IT 헬프데스크 (빠르게 · 멈춰도 답하게)
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/helpdesk.json`, `my-work/health.json`
## 1. 통과한 테스트
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab01.json` (시간 기록) | Passed /4 |
| `cases/lab02.json` (규칙 · 캐시) | Passed /9 |
| `cases/lab02-challenge.json` (LLM 한 번) | Passed /5 |
| `cases/lab03.json` (데이터 API 고장) | Passed /4 |
| `cases/lab03-llm-down.json` (LLM 멈춤) | Passed /4 |
| `cases/lab03-challenge.json` (상태 점검) | Passed /4 |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 빨라진 만큼 잃은 것
- 검토 LLM을 없애서 잃은 것은? 캐시를 써서 생길 수 있는 문제는?
-
+850
View File
@@ -0,0 +1,850 @@
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote.
uv run tools/flow.py doctor # is everything ready?
uv run tools/flow.py check my-work/helpdesk.json # find mistakes before n8n sees the file
uv run tools/flow.py push my-work/helpdesk.json # import + publish (one n8n restart)
uv run tools/flow.py off my-health # unpublish by file or webhook path
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json [-v]
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 10 # p50 · p95 · max · failures
uv run tools/flow.py test cases/lab02.json [-v] [--repeat 3] [--no-before] # -v: print request and response JSON
uv run tools/flow.py fault faq slow|error|ok # make the data API slow or failing (drills)
uv run tools/flow.py llm down|up # outage drill: a broken OpenAI key in n8n, then restore
Every command takes -v (--verbose) for details: the request and response JSON of each call (call, test, bench,
fault, doctor), the nodes and links of a workflow (check, push), and the docker / n8n CLI commands that ran.
Run it from the day folder (Last3Mon/72). n8n is the day 65 install (~/day65-n8n, Docker project
day65-n8n), started from Last3Mon/65/n8n. Environment overrides:
N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1)
N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = Path(__file__).resolve().parent / ".cache"
def n8n_home() -> Path:
"""Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n."""
if os.getenv("N8N_HOME"):
return Path(os.environ["N8N_HOME"]).expanduser()
env_file = ROOT.parent / "65" / "n8n" / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("N8N_HOME="):
value = line.split("=", 1)[1].strip().strip("'\"")
if value:
return (env_file.parent / Path(value).expanduser()).resolve()
return Path.home() / "day65-n8n"
N8N_HOME = n8n_home()
START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)"
def find_container() -> str:
"""The n8n container of the day 65 project, whichever folder it was started from."""
if os.getenv("N8N_CONTAINER"):
return os.environ["N8N_CONTAINER"]
if shutil.which("docker"):
result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n",
"--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"],
capture_output=True, text=True)
names = result.stdout.split()
if names:
return names[0]
return "day65-n8n-n8n-1"
CONTAINER = find_container()
N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/")
# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only).
RECOMMENDED = {
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.respondToWebhook": 1.1,
"n8n-nodes-base.if": 2.2,
"n8n-nodes-base.switch": 3.2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.httpRequestTool": 4.2,
"@n8n/n8n-nodes-langchain.agent": 3.1,
"@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3,
"@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1,
}
USES_DATA_API = True # day 72: FAQ, tickets, fault switch and cache come from the data API
AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 72
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
VERBOSE = False # -v on any command (set in main)
# ---------------------------------------------------------------- helpers
def die(message: str) -> None:
print(f"{BAD} {message}", file=sys.stderr)
sys.exit(1)
def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
if not shutil.which("docker"):
die("docker command not found. Start Docker first.")
result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True)
if VERBOSE:
show_command(args, result)
if check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
def show_json(label: str, value: object) -> None:
"""Print one JSON value under a label, indented below the result line (-v). API keys are masked."""
text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value
print(f" {label}")
for row in SECRET.sub("sk-***", text).splitlines():
print(f" {row}")
def detail(label: str, value: object) -> None:
"""show_json, only with -v."""
if VERBOSE:
show_json(label, value)
def show_command(args: tuple[str, ...], result: subprocess.CompletedProcess) -> None:
"""-v: one docker command and the end of its output. Data dumps (cat of an export) are not printed."""
command = " ".join(arg if len(arg) <= 90 else arg[:87] + "..." for arg in args)
print(f" $ docker {command} → exit {result.returncode}")
if "cat /tmp" in " ".join(args):
return
rows = [row for row in (result.stdout + result.stderr).splitlines() if row.strip()]
for row in rows[-6:]:
print(f" {SECRET.sub('sk-***', row)[:160]}")
def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check)
def container_running() -> bool:
if not shutil.which("docker"):
return False
result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True)
return result.stdout.strip() == "true"
def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
start = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start
text = raw.decode("utf-8", errors="replace")
try:
payload: object = json.loads(text) if text.strip() else ""
except json.JSONDecodeError:
payload = text
return status, payload, time.monotonic() - start
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as error:
die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}")
def resolve(path: str) -> Path:
candidate = Path(path)
if candidate.exists() or candidate.is_absolute():
return candidate
return ROOT / path
def node_versions() -> dict[str, list[float]]:
"""Node types and versions that this n8n really has (exported once, then cached)."""
cache = CACHE / "node-versions.json"
if cache.exists():
return json.loads(cache.read_text())
if not container_running():
return {}
n8n("export:nodes", "--output=/tmp/course-nodes.json")
raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout
versions: dict[str, set[float]] = {}
for node in json.loads(raw):
value = node.get("version")
versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value])
result = {name: sorted(values) for name, values in versions.items()}
CACHE.mkdir(exist_ok=True)
cache.write_text(json.dumps(result))
return result
def walk_strings(value: object, where: str = ""):
if isinstance(value, str):
yield where, value
elif isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{where}.{key}" if where else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{where}[{index}]")
def webhooks(workflow: dict) -> list[tuple[str, str]]:
found = []
for node in workflow.get("nodes", []):
if node.get("type") == "n8n-nodes-base.webhook":
parameters = node.get("parameters", {})
found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/")))
return found
# ---------------------------------------------------------------- check
def check_workflow(workflow: object) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(workflow, dict):
return ["the file must be one workflow object { name, nodes, connections }"], warnings
if not str(workflow.get("name", "")).strip():
errors.append("missing workflow 'name'")
nodes = workflow.get("nodes")
connections = workflow.get("connections")
if not isinstance(nodes, list) or not nodes:
return errors + ["'nodes' must be a non-empty list"], warnings
if not isinstance(connections, dict):
errors.append("'connections' must be an object")
connections = {}
known = node_versions()
names: dict[str, dict] = {}
for index, node in enumerate(nodes):
label = f"node #{index + 1}"
if not isinstance(node, dict):
errors.append(f"{label} is not an object")
continue
name = node.get("name")
if not name:
errors.append(f"{label} has no 'name'")
continue
label = f"node '{name}'"
if name in names:
errors.append(f"{label} appears twice (node names must be unique)")
names[name] = node
node_type, version = node.get("type"), node.get("typeVersion")
if not node_type or version is None:
errors.append(f"{label} needs 'type' and 'typeVersion'")
elif known and node_type not in known:
errors.append(f"{label}: type '{node_type}' does not exist in this n8n")
elif known and version not in known[node_type]:
errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})")
elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]:
warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})")
if not isinstance(node.get("parameters"), dict):
errors.append(f"{label} needs 'parameters' (use {{}} when empty)")
position = node.get("position")
if not (isinstance(position, list) and len(position) == 2):
errors.append(f"{label} needs 'position': [x, y]")
for kind, credential in (node.get("credentials") or {}).items():
credential_id = (credential or {}).get("id")
if credential_id not in COURSE_CREDENTIALS:
errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}")
for where, text in walk_strings(node.get("parameters", {})):
if SECRET.search(text):
errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.")
if "{{" in text and "}}" in text and not text.startswith("="):
warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)")
targets_of: dict[str, set[str]] = {}
for source, outputs in connections.items():
if source not in names:
errors.append(f"connections: source '{source}' is not a node name")
continue
if not isinstance(outputs, dict):
errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}")
continue
for kind, branches in outputs.items():
if kind not in CONNECTION_TYPES:
errors.append(f"connections['{source}']: unknown connection type '{kind}'")
for branch in branches or []:
for link in branch or []:
target = (link or {}).get("node")
if target not in names:
errors.append(f"connections['{source}'] points to '{target}', which is not a node name")
elif link.get("type") != kind:
errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'")
else:
targets_of.setdefault(target, set()).add(kind)
if kind != "main":
targets_of.setdefault(f"{source}::from", set()).add(kind)
has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values())
for name, node in names.items():
node_type = node.get("type", "")
parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {}
if node_type == "n8n-nodes-base.webhook":
if not str(parameters.get("path", "")).strip("/"):
errors.append(f"node '{name}': webhook needs a 'path'")
if parameters.get("responseMode") == "responseNode" and not has_respond:
errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node")
if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()):
errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'")
if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"):
if "ai_tool" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'")
if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"):
if "ai_languageModel" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'")
is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def show_workflow(workflow: object) -> None:
"""-v: the nodes, links and webhooks of a workflow file."""
if not VERBOSE or not isinstance(workflow, dict):
return
print(f" workflow '{workflow.get('name')}' · {len(workflow.get('nodes') or [])} node(s)")
for node in workflow.get("nodes") or []:
if isinstance(node, dict):
extra = [key for key in ("onError", "retryOnFail") if key in node]
print(f" {node.get('name')} · {node.get('type')} v{node.get('typeVersion')}" + (f" · {', '.join(extra)}" if extra else ""))
print(" links")
for source, outputs in (workflow.get("connections") or {}).items():
for kind, branches in (outputs or {}).items():
for index, branch in enumerate(branches or []):
targets = ", ".join(str((link or {}).get("node")) for link in branch or [])
if targets:
print(f" {source} --{kind}[{index}]--> {targets}")
for method, hook in webhooks(workflow):
print(f" webhook {method} /webhook/{hook}")
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
workflow = load_json(path)
show_workflow(workflow)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
show_workflow(workflow)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
exported = exported_workflows()
detail("published now", [f"{item.get('id')} · {item.get('name')} · " + ", ".join(f"/{hook}" for _, hook in webhooks(item))
for item in exported if item.get("active")])
wanted: dict[str, str] = {}
for item in args.files:
path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None
if path:
workflow = load_json(path)
workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path)
wanted[workflow_id] = item
hooks = {hook for _, hook in webhooks(workflow)}
else:
hooks = {item.strip("/")}
for other in exported:
if other.get("active") and hooks & {hook for _, hook in webhooks(other)}:
wanted[other["id"]] = item
active = {item.get("id") for item in exported if item.get("active")}
for workflow_id, item in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {item} (id {workflow_id})")
docker("restart", CONTAINER)
wait_until_ready([])
print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>")
return 0
# ---------------------------------------------------------------- call / test
def request_body(spec: str | dict, base: Path = ROOT) -> object:
if isinstance(spec, dict):
return spec
text = spec.strip()
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError as error:
die(f"request is not valid JSON: {error.msg}")
path = Path(text)
if not path.exists():
path = base / text
return load_json(path)
def cmd_call(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip('/')
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
print(f"HTTP {status} · {seconds:.1f}s")
if args.verbose:
show_json(f"→ request POST /webhook/{hook}", body)
show_json(f"← response HTTP {status}", payload)
return 0 if 200 <= status < 300 else 1
print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload)
return 0 if 200 <= status < 300 else 1
def field(payload: object, dotted: str) -> object:
value = payload
for part in dotted.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return None
return value
def as_text(value: object) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def judge(expect: dict, status: int, payload: object, seconds: float = 0.0) -> list[str]:
problems = []
if "max_seconds" in expect and seconds > float(expect["max_seconds"]):
problems.append(f"took {seconds:.1f}s (expected at most {expect['max_seconds']}s)")
if status != expect.get("status", 200):
problems.append(f"status {status} (expected {expect.get('status', 200)})")
for name, wanted in (expect.get("equals") or {}).items():
actual = field(payload, name)
if actual != wanted:
problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})")
for name, words in (expect.get("contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
if not any(word in text for word in words):
problems.append(f"{name} has none of {words}")
for name, words in (expect.get("not_contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
leaked = [word for word in words if word in text]
if leaked:
problems.append(f"{name} must not have {leaked}")
return problems
def cmd_test(args: argparse.Namespace) -> int:
path = resolve(args.cases)
suite = load_json(path)
hook = (args.webhook or suite.get("webhook", "")).strip("/")
cases = suite.get("cases", [])
if not hook or not cases:
die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list")
# "before"/"after": calls made once around the cases, e.g. turn a fault on and off again.
if not args.no_before:
for step in suite.get("before", []):
run_step("before", step)
try:
return run_cases(args, path, suite, hook, cases)
finally:
for step in suite.get("after", []):
run_step("after", step)
def run_step(label: str, step: dict) -> None:
"""A step is {"webhook": path} (POST to n8n), {"url": ...} (POST to the data API, e.g. the fault switch)
or {"llm": "down"|"up"} (the outage drill)."""
if step.get("llm") in ("down", "up"):
code = cmd_llm(argparse.Namespace(state=step["llm"]))
if code and label == "before":
die("could not change the OpenAI credential (llm drill)")
if step["llm"] == "down":
time.sleep(1)
return
if step.get("url"):
target = str(step["url"])
else:
target = f"{N8N_URL}/webhook/{str(step.get('webhook', '')).strip('/')}"
body = request_body(step.get("body") or step.get("request", "{}"))
status, payload, seconds = http("POST", target, body)
shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120]
print(f"{OK if 200 <= status < 300 else BAD} {label}: POST {target.replace(N8N_URL, '')} · HTTP {status} · {seconds:.1f}s · {shown}")
detail(f"→ request POST {target.replace(N8N_URL, '')}", body)
detail(f"← response HTTP {status}", payload)
if not 200 <= status < 300 and label == "before":
die(f"the 'before' step failed ({target}). Is it pushed / is the data API the day 72 version?")
def run_cases(args: argparse.Namespace, path: Path, suite: dict, hook: str, cases: list) -> int:
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
for step in case.get("before", []):
run_step("before", step)
try:
passed += run_case(args, case, hook, results)
finally:
for step in case.get("after", []):
run_step("after", step)
return finish(path, hook, results, passed)
def run_case(args: argparse.Namespace, case: dict, hook: str, results: list) -> int:
passed = 0
body = request_body(case.get("body") or case.get("request", "{}"))
case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook
for run in range(1, args.repeat + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{case_hook}", body)
problems = judge(case.get("expect", {}), status, payload, seconds)
passed += not problems
mark = OK if not problems else BAD
suffix = f" #{run}" if args.repeat > 1 else ""
print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s" + ("" if not problems else " · " + "; ".join(problems)))
if args.verbose:
show_json(f"→ request POST /webhook/{case_hook}", body)
show_json(f"← response HTTP {status}", payload)
results.append({"case": case.get("name"), "webhook": case_hook, "run": run, "passed": not problems, "problems": problems,
"seconds": round(seconds, 1), "status": status, "request": body, "response": payload})
return passed
def finish(path: Path, hook: str, results: list, passed: int) -> int:
total = len(results)
print(f"Passed {passed}/{total}")
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
report = runs / f"{path.stem}-{stamp}.json"
report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total,
"results": results}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if passed == total else 1
# ---------------------------------------------------------------- bench / fault / llm
def percentile(values: list[float], share: float) -> float:
ordered = sorted(values)
if not ordered:
return 0.0
# nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th)
index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1))
return ordered[index]
def cmd_bench(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip("/")
print(f"POST {N8N_URL}/webhook/{hook} × {args.n}")
detail(f"→ request POST /webhook/{hook} (the same body every time)", body)
seconds_list, failures, rows = [], 0, []
for run in range(1, args.n + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
good = 200 <= status < 300
failures += not good
seconds_list.append(seconds)
extra = ""
if isinstance(payload, dict):
extra = " · " + ", ".join(f"{key}={as_text(payload[key])[:40]}" for key in ("route", "llm_calls", "cached") if key in payload)
print(f"{OK if good else BAD} #{run} · HTTP {status} · {seconds:.2f}s{extra}")
detail(f"← response HTTP {status}", payload)
rows.append({"run": run, "status": status, "seconds": round(seconds, 3), "response": payload})
p50, p95 = percentile(seconds_list, 0.50), percentile(seconds_list, 0.95)
print(f"p50 {p50:.2f}s · p95 {p95:.2f}s · max {max(seconds_list):.2f}s · failures {failures}/{args.n}")
if VERBOSE:
print(" sorted " + " ".join(f"{value:.2f}" for value in sorted(seconds_list)))
steps: dict[str, list[float]] = {}
for row in rows:
timings = row["response"].get("timings") if isinstance(row["response"], dict) else None
for key, value in (timings or {}).items():
if isinstance(value, (int, float)):
steps.setdefault(key, []).append(value)
if steps:
print(" timings (average ms, runs) " + " · ".join(f"{key} {sum(values) / len(values):.0f} ({len(values)})"
for key, values in steps.items()))
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
report = runs / f"bench-{hook}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
report.write_text(json.dumps({"webhook": hook, "request": body, "n": args.n, "p50": round(p50, 3), "p95": round(p95, 3),
"max": round(max(seconds_list), 3), "failures": failures, "runs": rows},
ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if not failures else 1
def cmd_fault(args: argparse.Namespace) -> int:
targets = ["faq", "tickets"] if args.target == "all" else [args.target]
body: dict = {target: args.mode for target in targets}
if args.seconds is not None:
body["slow_seconds"] = args.seconds
status, payload, seconds = http("POST", "http://localhost:8800/admin/fault", body, timeout=10)
print(f"{OK if status == 200 else BAD} fault switch: {as_text(payload)}")
detail("→ request POST http://localhost:8800/admin/fault", body)
detail(f"← response HTTP {status} · {seconds:.2f}s", payload)
return 0 if status == 200 else 1
def cmd_llm(args: argparse.Namespace) -> int:
"""Outage drill: register the course OpenAI credential with a broken key (down) or the real key from .env (up)."""
script = ROOT.parent / "65" / "n8n" / "register-credentials.sh"
if not script.exists():
die(f"{script} not found")
env = dict(os.environ, N8N_CONTAINER=CONTAINER)
if args.state == "down":
env["OPENAI_API_KEY"] = "sk-invalid-key-for-drill-000000"
else:
env.pop("OPENAI_API_KEY", None)
result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True)
detail(f"$ bash {script.relative_to(ROOT.parent)} → exit {result.returncode}", (result.stdout + result.stderr).strip())
if result.returncode != 0:
print(result.stderr.strip() or result.stdout.strip())
return 1
print(f"{OK} OpenAI credential: " + ("broken key (drill). Restore with: uv run tools/flow.py llm up" if args.state == "down"
else "real key from .env restored"))
return 0
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "", seen: object = None) -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
if seen is not None:
detail("seen", seen)
installed = (N8N_HOME / "compose.yml").exists()
print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else
"\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>"))
running = container_running()
line(running, f"n8n container {CONTAINER}", START_HINT)
status, payload, _ = http("GET", f"{N8N_URL}/healthz", timeout=5)
line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again",
{"GET": "/healthz", "status": status, "response": payload})
status, payload, _ = http("GET", "http://localhost:8800/health", timeout=5)
if USES_DATA_API:
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate (start n8n first)",
{"GET": "/health", "status": status, "response": payload})
fault_status, fault, _ = http("GET", "http://localhost:8800/admin/fault", timeout=5)
line(fault_status == 200, "fault switch /admin/fault (day 72 data API)",
"the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate",
{"GET": "/admin/fault", "status": fault_status, "response": fault})
stats_status, stats, _ = http("GET", "http://localhost:8800/cache-stats", timeout=5)
detail("cache GET /cache-stats", {"status": stats_status, "response": stats})
if fault_status == 200 and isinstance(fault, dict) and (fault.get("faq") != "ok" or fault.get("tickets") != "ok"):
print(f"{WARN} fault switch is on: {fault}\n → uv run tools/flow.py fault all ok")
else:
print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)")
if running:
probe = docker("exec", CONTAINER, "node", "-e",
"fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))",
check=False).stdout.strip()
if USES_DATA_API:
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)",
{"from n8n": "GET http://data-api:8800/health", "status": probe})
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; "
"cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout
try:
found = json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
found = []
ids = {item.get("id") for item in found}
detail("credentials in n8n (id · name · type, no secrets)",
[f"{item.get('id')} · {item.get('name')} · {item.get('type')}" for item in found])
for credential_id, name in COURSE_CREDENTIALS.items():
line(credential_id in ids, f"credential '{name}' ({credential_id})",
"cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh")
catalog = node_versions()
line(bool(catalog), "node catalog (for check)", seen={"node types": len(catalog), "cache": str(CACHE.relative_to(ROOT))})
for hook in AGENT_HOOKS:
_, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5)
line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}",
"uv run tools/flow.py push <agent workflow file>", {"GET": f"/webhook/{hook}", "response": payload})
agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)]
line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"),
"install one of codex / claude / agy (see Lecture/72/00-DAY72-PREP.md)", {name: shutil.which(name) for name in agents})
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
global VERBOSE
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="store_true",
help="details: request and response JSON, workflow nodes and links, docker commands")
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", parents=[common], help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", parents=[common], help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", parents=[common], help="import and publish workflow files (one n8n restart)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_push)
item = commands.add_parser("off", parents=[common], help="unpublish workflow files or webhook paths (a role that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", parents=[common], help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-notice")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", parents=[common], help="run a test case file")
item.add_argument("cases")
item.add_argument("--repeat", type=int, default=1, help="run every case N times")
item.add_argument("--webhook", help="override the webhook path in the case file")
item.add_argument("--no-before", action="store_true", help="skip the 'before' steps")
item.set_defaults(run=cmd_test)
item = commands.add_parser("bench", parents=[common], help="POST the same request N times: p50, p95, max, failures")
item.add_argument("path", help="webhook path, e.g. my-helpdesk")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.add_argument("-n", type=int, default=10, help="how many calls (default 10)")
item.set_defaults(run=cmd_bench)
item = commands.add_parser("fault", parents=[common], help="data API fault switch for drills")
item.add_argument("target", choices=["faq", "tickets", "all"])
item.add_argument("mode", choices=["ok", "slow", "error"])
item.add_argument("--seconds", type=float, help="how long 'slow' waits (default 8)")
item.set_defaults(run=cmd_fault)
item = commands.add_parser("llm", parents=[common], help="outage drill: break (down) or restore (up) the OpenAI credential in n8n")
item.add_argument("state", choices=["down", "up"])
item.set_defaults(run=cmd_llm)
args = parser.parse_args()
VERBOSE = args.verbose
return args.run(args)
if __name__ == "__main__":
sys.exit(main())
+441
View File
@@ -0,0 +1,441 @@
# 72일차 개요 · Workflow 실행 흐름 최적화와 운영 관점 개선
> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-26
> 30장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~6장을 보고, 나머지 장은 각 단계 강의(1 · 3 · 5교시) 때 함께 띄웁니다.
> 숫자는 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini`, 10/18 수업 준비 · 10/23~24 다시 잼).
| 장 | 범위 | 띄우는 때 |
| --- | --- | --- |
| 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["재기"] --> F["병목"] --> C["한 가지 바꾸기"] --> R["다시 재기"]
R --> M
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
class M,R d
class F,C o
```
**72일차**
# Workflow 실행 흐름 최적화와 운영 관점 개선
빠르게, 그리고 멈춰도 답하게 · 재고 나서 고친다
<sub>노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-26</sub>
---
## 2 / 30 · 오늘 할 수 있게 될 세 가지
| | 목표 | 내용 |
| :-: | --- | --- |
| **①** | **재서 병목을 찾는다** | p50 · p95 · 첫 호출 · `bench` · 단계별 `timings` · 종류별 재기 · 시간 예산 |
| **②** | **품질은 그대로 빠르게 만든다** | 규칙 먼저 · 캐시 · 적중률 · 캐시 키 설계 · 검토를 검사로 · 잃은 것 |
| **③** | **멈춰도 정해진 답을 한다** | timeout · 재시도 · 오류 출력 · LLM 없이 답 · 부분 고장 · 차단기 · 상태 점검 |
---
## 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개 |
| - | 13:00~14:00 | 점심 |
| 5 | 14:00~14:50 | **3단계 강의** 운영 관점의 안정성 |
| 6 | 15:00~15:50 | **Lab 03** 실습 5개 |
| 7 | 16:00~16:50 | 바이브 코딩 `tbench` (요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치) |
| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 실전 응용) · 73일차 예고 |
> 강의 한 시간, 실습 한 시간. 실습은 Lab마다 **예제 3개 + 과제 2개**.
---
## 4 / 30 · 오늘 고칠 것: 느린 실습 워크플로우 (IT 헬프데스크)
```mermaid
flowchart LR
W(["질문"]) --> C{{"분류 LLM"}} --> D["FAQ · 접수"] --> A{{"답 LLM"}} --> R{{"검토 LLM"}} --> S(["답"])
```
| `base-helpdesk` (VPN 질문) | 10/18 | 10/24 |
| --- | --- | --- |
| LLM 호출 | 3번 | 3번 |
| push 직후 첫 호출 | 6.5초 | 4.4초 |
| 그 뒤 5번 | p50 2.61초 · p95 2.83초 | p50 2.69초 · p95 2.98초 |
| FAQ가 8초 느릴 때 | 9.0초 기다림 | 11.5초 |
| FAQ · LLM 오류 | HTTP 500 | HTTP 500 |
---
## 5 / 30 · 일하는 순서 (오늘은 고치기)
```mermaid
sequenceDiagram
participant 나
participant 에이전트 as ① CLI 에이전트
participant 도구 as ② flow.py
나->>도구: bench · call -v (재기)
나->>에이전트: 요청문 (바꿀 것 하나 · 그대로 둘 것 · 기대 결과)
에이전트-->>나: my-work/helpdesk.json · check ✔
나->>도구: push · test -v · bench (다시 재기 + 품질)
나->>도구: fault · llm down (고장 연습)
```
---
## 6 / 30 · 오늘의 실습 15개
| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 |
| --- | --- | --- | --- | --- | --- |
| **Lab 01** 재기 | bench 기준 | 단계별 `timings` | 종류별 재기 · LLM 몫 | FAQ · 접수 나누기 · `slowest_step` | 시간 예산 `over_budget` |
| **Lab 02** 빠르게 | 규칙 먼저 | 질문 캐시 | 적중률 재기 | 검토 → 검사 (LLM 한 번) | FAQ 번호 캐시 `cache_by` |
| **Lab 03** 안정성 | timeout · 재시도 | LLM 없이 답 | 접수 API만 멈춤 | 상태 점검 `my-health` | 차단기 `breaker-faq` |
> 예제 3은 대부분 **재기만** 합니다 (Lab 03 예제 3은 만들기). 과제는 팀장의 말로 옵니다.
---
## 7 / 30 · 1단계 · 재기 전에 고치지 않기
| 짐작 | 실측 (10/24) |
| --- | --- |
| "데이터 API가 느릴 것" | FAQ · 접수 조회 합쳐 **18~43ms** |
| "IT 밖 질문은 금방 끝날 것" | 점심 메뉴도 **2.48초** (LLM 세 번) |
| "push 하면 바로 평소 속도" | 첫 호출 **4.4초** |
> 재기 → 병목 → **한 가지** 바꾸기 → 다시 재기 (품질도)
---
## 8 / 30 · 1단계 · p50 · p95 손으로
```text
1.1 1.2 1.2 1.3 1.3 1.4 1.4 1.5 1.6 9.8 (초, 예시)
```
| 평균 | p50 | p95 | max |
| --- | --- | --- | --- |
| 2.18초 | 1.3초 | 9.8초 | 9.8초 |
> 평균 2초는 **아무에게도 맞지 않는다.** 보통(p50)과 느린 쪽(p95)을 함께.
---
## 9 / 30 · 1단계 · 첫 호출과 같은 날 비교
| 날 | 첫 호출 | p50 | p95 |
| --- | --- | --- | --- |
| 10/18 | 6.5초 | 2.61초 | 2.83초 |
| 10/23 | 6.3초 | 3.14초 | 3.22초 |
| 10/24 | 4.4초 | 2.69초 | 2.98초 |
> 같은 워크플로우도 날마다 0.5초 다르다 → **바꾸기 전 · 후는 같은 날**. 첫 호출은 따로 (워밍업).
---
## 10 / 30 · 1단계 · 어디서 재나
| 어디서 | 보이는 것 | 한계 |
| --- | --- | --- |
| `flow.py bench` | 사용자가 기다린 시간, p50 · p95, 실패 | 안은 모름 |
| Executions | 노드마다 시간 | 한 번씩 눈으로 |
| 결과의 `timings` | 단계마다 ms (매번, 테스트로도) | 직접 만듦 (2교시 Lab 01 예제 2) |
---
## 11 / 30 · 1단계 · 단계별 시간 (Lab 01 예제 2)
```mermaid
flowchart LR
S["t0"] --> C{{"classify"}} --> T1["t1"] --> D["data"] --> T2["t2"] --> A{{"answer"}} --> T3["t3"] --> R{{"review"}} --> T4["t4"]
```
| classify | data | answer | review | total |
| --- | --- | --- | --- | --- |
| 616 ms | 18 ms | 1124 ms | 920 ms | 2698 ms |
> 10/24 VPN 한 번. `steps` · `llm_calls`도 **워크플로우가** 남긴다.
---
## 12 / 30 · 1단계 · 종류별로 재기 (Lab 01 예제 3)
| 질문 | p50 | total | LLM 몫 |
| --- | --- | --- | --- |
| FAQ (VPN) | 2.91초 | 2698 ms | 98.6% |
| 접수 (T-1002) | 2.99초 | 2647 ms | 97.8% |
| IT 밖 (점심) | 2.48초 | 2482 ms | 98.7% |
> 세 종류 모두 **LLM이 98%**. 병목은 HTTP가 아니라 LLM 호출 수.
---
## 13 / 30 · 1단계 · 어림 계산과 가장 느린 단계 (Lab 01 과제 1)
| 없애면 | 남는 시간 | 줄어듦 |
| --- | --- | --- |
| 데이터 조회 | 2.68초 | 0.7% |
| 분류 LLM | 2.08초 | 23% |
| 검토 LLM | 1.78초 | 34% |
| 분류 + 검토 | 1.16초 | 57% |
`slowest_step`: 10/24 네 번 중 answer 2 · review 1 · classify 1 (첫 호출) — 실행마다 바뀐다.
---
## 14 / 30 · 1단계 · 시간 예산 (Lab 01 과제 2)
| 예산 | `total_ms` | `llm_ms` | `over_budget` |
| --- | --- | --- | --- |
| 500 | 2609 | 2563 | true |
| 3000 (기본) | 3023 | 2973 | **true** |
| 3000 (접수) | 2904 | 2858 | false |
> 예산은 **사용자 쪽에서** 정한다. 넘어도 답은 보내고 **기록**한다 → 모으면 목표 수준(SLO).
---
## 15 / 30 · 2단계 · LLM 호출 줄이기
```mermaid
flowchart LR
Q(["질문"]) --> K{"캐시?"} -->|있음| Z(["0.05초"])
K -->|없음| D["데이터"] --> R{"규칙?"} -->|예| A{{"답 1번"}}
R -->|아니오| C{{"분류"}} --> A
A --> V["검사 (Code)"]
```
| LLM | 꼭 LLM이어야 하나 |
| --- | --- |
| 분류 | 번호 · FAQ 결과로 **규칙이 대부분** |
| 답 | 예 |
| 검토 | 말투는 지시문, 빈 답 · 길이 · 한국어는 **코드** |
---
## 16 / 30 · 2단계 · 규칙 먼저 (Lab 02 예제 1)
```mermaid
flowchart LR
D["FAQ · 접수 조회"] --> R{"규칙"}
R -->|"T-1234"| T["ticket"]
R -->|"FAQ 결과"| F["faq"]
R -->|"모름"| L{{"분류 LLM"}}
```
> 규칙을 쓰려면 **데이터 조회가 먼저** (20~40ms라 손해 없음). 규칙도 테스트한다.
---
## 17 / 30 · 2단계 · 캐시해도 되나 (Lab 02 예제 2)
| 답 | 캐시 | 까닭 |
| --- | --- | --- |
| VPN FAQ 답 | 예 | 같은 질문이면 같은 답 |
| "T-1002 상태" | **아니오** | 상태가 바뀜 |
| 본인 접수 건 | **아니오** | 남의 답이 나갈 수 있음 |
> 같은 질문 두 번째 **0.05초**, `llm_calls: 0` (10/24). 캐시는 **테스트도** 바꾼다 → `before`에 비우기, `no_cache`.
---
## 18 / 30 · 2단계 · 적중률 (Lab 02 예제 3)
| 적중률 | 평균 (어림, 0.05초 · 2.0초) |
| --- | --- |
| 0% | 2.00초 |
| 10% | 1.81초 |
| 30% | 1.42초 |
| 60% | 0.83초 |
> 캐시가 빨라도 **적중률이 낮으면 평균은 그대로**. 사람들은 같은 것을 다른 말로 묻는다.
---
## 19 / 30 · 2단계 · 캐시 키 설계 (Lab 02 과제 2)
| 키 | 적중 | 위험 |
| --- | --- | --- |
| 질문을 고친 글자 | 낮음 | 거의 없음 |
| **FAQ 번호** `faq-FAQ-003` | 높음 | 답이 질문에 맞춰지지 않음 |
| 뜻 (임베딩) | 높음 | 다른 질문에 같은 답 (오늘 안 함) |
| 10/24 | 결과 |
| --- | --- |
| "VPN 연결이 계속 끊어지는데요" (다른 말) | `cache_by: faq`, LLM 0번, **0.1초** (전에는 1.2초) |
---
## 20 / 30 · 2단계 · 검토를 검사로 (Lab 02 과제 1)
| 날 | LLM 2번 | LLM 1번 |
| --- | --- | --- |
| 10/18 | 2.01초 | **1.15초** |
| 10/23 | 2.39초 | **1.32초** |
| 10/24 | 2.22초 | **1.12초** |
(캐시 없이 VPN p50) · 검토 LLM은 10/24에 "처리 중"을 "승인 대기 중"으로 바꿔 써서 테스트를 깼다.
---
## 21 / 30 · 2단계 · 빨라진 만큼 잃은 것
| 바꾼 것 | 잃은 것 |
| --- | --- |
| 규칙 먼저 | 규칙이 틀리면 엉뚱한 분류 |
| 질문 캐시 | 오래된 답 · 남의 답 → **접수 현황은 캐시 안 함** |
| FAQ 번호 캐시 | 질문에 맞춘 답 |
| 검토 → 검사 | 뜻을 보는 검토 |
> 품질 테스트는 **답의 내용**을 본다. 구조(`llm_calls: 3`)를 고정한 테스트는 최적화를 막는다.
---
## 22 / 30 · 3단계 · 멈추는 방식
| 고장 | 고치기 전 |
| --- | --- |
| FAQ 느림 (8초) | 9.0 · 13.2 · 11.5초 기다림 |
| FAQ 오류 (500) | 0.1~0.2초 만에 HTTP 500 |
| LLM 키 틀림 | HTTP 500 |
| 접수 API만 오류 | FAQ 질문까지 담당자 연결 |
> 느림 → timeout · 오류 → 정해진 답 · 일부만 → 그 부분만 · 계속 → 차단기
---
## 23 / 30 · 3단계 · timeout · 재시도 · 오류 출력 (Lab 03 예제 1)
```mermaid
flowchart LR
H["HTTP<br/>timeout 2초"] -->|실패| R["0.5초 뒤<br/>한 번 더"] -->|실패| E["오류 출력 →<br/>담당자 연결"]
```
| 설정 | 최악 |
| --- | --- |
| 2초 × 2번 + 0.5초 | **4.5초** (실측 4.6~4.7초) |
| 2초 × 3번 + 0.5초 × 2 | 7.0초 |
> 재시도는 "잠깐 끊긴" 고장에. "계속 느린" 고장에는 차단기.
---
## 24 / 30 · 3단계 · 옵션은 재 보고 쓴다
| 설정 | 404 | 500 |
| --- | --- | --- |
| 없음 | 재시도 → 오류 출력 | 재시도 → 오류 출력 |
| `neverError` | 재시도 → **보통** | 재시도 → **오류** 출력 |
| + `fullResponse` | 바로 보통 (`statusCode`) | 바로 보통 (`statusCode: 500`) |
> 10/24 시험 워크플로우로 잼. 수업 준비 때 적은 "neverError는 500도 성공"은 **틀렸다.** 짐작하지 않고 잰다.
---
## 25 / 30 · 3단계 · LLM 없이 답하기 (Lab 03 예제 2)
| `llm down` | 고치기 전 | 고친 뒤 |
| --- | --- | --- |
| VPN | 500 | `간단 답변` FAQ 원문 |
| T-1002 | 500 | `간단 답변` 상태 그대로 |
| 점심 메뉴 | 500 | `담당자 연결` |
> 데이터로 답할 수 있는 것은 데이터로. 평소 답과 **같은 척하지 않는다.**
---
## 26 / 30 · 3단계 · 부분 고장 (Lab 03 예제 3)
| 접수 API 500일 때 | Lab 03 예제 2까지 | Lab 03 예제 3 정답 |
| --- | --- | --- |
| T-1002 | 담당자 (`data_api_down`) | 담당자 (`tickets_down`) |
| **VPN** | **담당자** | **자동 답변** |
| T-9999 (404) | 자동 답변 | 자동 답변 |
> 멈춘 **부분만** 줄이고 나머지는 평소대로 (우아한 저하). `fullResponse`로 상태 코드를 보고 나눈다.
> 덤: Lab 03 예제 1의 재시도가 404에도 걸려 평소 VPN `data_ms`가 605ms였다 → Lab 03 예제 3 뒤 38ms.
---
## 27 / 30 · 3단계 · 차단기 (Lab 03 과제 2)
```mermaid
stateDiagram-v2
[*] --> 닫힘
닫힘 --> 열림: FAQ 실패 → breaker-faq 저장 (30초)
열림 --> 닫힘: 30초 뒤 저절로
```
| FAQ 느림 | 결과 |
| --- | --- |
| 처음 질문 | 4.8초 뒤 담당자 (`data_api_down`) |
| 다음 질문 | **0.1초** 담당자 (`breaker_open`) — 차단기 없으면 4.6초 |
---
## 28 / 30 · 3단계 · 상태 점검 (Lab 03 과제 1)
```mermaid
flowchart LR
W(["my-health"]) --> A["/health"] --> F["FAQ 검색"] --> L{{"LLM 'OK'"}} --> S["ok · degraded · down"]
```
| 원칙 | 10/23 |
| --- | --- |
| 점검마다 짧은 timeout | FAQ 느림에도 3.1초 |
| 하나가 실패해도 끝까지 | FAQ 오류 → `degraded` |
| 상태는 코드가 | LLM 멈춤 → `degraded` |
---
## 29 / 30 · 7교시 · 바이브 코딩 `tbench`
```mermaid
flowchart LR
T["Doc/TODO.md"] --> S["Doc/SPEC.md"] --> P["Doc/IMP-PLAN.md<br/>엔진 → TUI"] --> I["단계별 구현"] --> C["확인"] --> U["uv tool install"] --> O["다른 폴더에서"]
```
| 요구 14개 | |
| --- | --- |
| 엔진 | R1 N번 · p50 · p95 · 실패 / R3 저장 · 비교 / R4 동시 / R6 첫 호출 · warmup / R7 실패 종류 / R8 키 가리기 / R9 `timings` 평균 / R10 기준 · 종료 코드 / R11 JSON Lines / R12 설정 / R13 기록 목록 |
| TUI | R2 오는 대로 표 · 스파크라인 / R5 요청 고르기 |
| 설치 | R14 `uv tool install` · 어느 폴더에서나 |
> 50분 안: 엔진 1단계 + TUI 1단계 + 설치. 큰 일은 **다단계로**, 단계마다 확인 · 체크 · commit.
--- | --- |
| R1 | N번 부르고 p50 · p95 · max · 실패 |
| R2 | TUI · 스파크라인 · 오는 대로 표 |
| R3 | 저장 · 바꾸기 전후 비교 (조건이 다르면 경고) |
| R4 | 동시에 k개 |
| R5 | turl 요청 다시 쓰기 |
`요청서 → SPEC 10분 → 1 글자로 → 2 TUI → 3 저장 · 비교 · 동시 → 4 정리` · 테스트는 가짜 서버로
---
## 30 / 30 · 오늘의 정리 · 73일차 예고
| 단계 | 한 줄 |
| --- | --- |
| 1단계 | 고치기 전에 잰다. p95 · 첫 호출 · 단계별 · 종류별 · 시간 예산 |
| 2단계 | 병목(LLM)을 규칙 · 캐시 · 검사로 줄이고, 키를 설계하고, 품질을 다시 확인 |
| 3단계 | timeout · 재시도 · 대체 답 · 부분 고장 · 차단기로 정해진 시간에 정해진 답, 상태 점검 |
| 바이브 코딩 | 재는 도구도 테스트한다 (가짜 서버) |
- 73일차: **입력·출력 검증과 응답 필터링, Workflow 보안** — 들어오는 질문과 나가는 답을 검사하고, 지시문 공격 · 개인정보를 막기
- 바이브 코딩: `tscan` · 폴더 · git에 올리기 전 파일에서 API 키 · 개인정보를 찾아 가려 보여 주기
+91
View File
@@ -0,0 +1,91 @@
# 72일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
- 수업일: 2026-10-26 (72일차) · Workflow 실행 흐름 최적화와 운영 관점 개선
- 준비 시간: 약 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/>(72일차 판)"] --> B["2. 기준 워크플로우 게시 ·<br/>doctor ready"] --> C["3. 미리 읽기"]
```
---
## 1. n8n과 데이터 API 켜기 (전날 필수, 3분)
오늘은 **72일차 판 데이터 API**(71일차 + 고장 스위치 + 캐시)를 씁니다. 같은 이름이라 이 폴더에서 다시 만들면 바뀝니다.
```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/72/data-api
docker compose up -d --force-recreate
curl -s localhost:8800/admin/fault # {"faq": "ok", "tickets": "ok", "slow_seconds": 8}
```
- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** Ollama는 쓰지 않습니다.
- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시.
- `.../day65-n8n/compose.yml: no such file or directory` → 홈이 아닌 곳에 설치함. `Last3Mon/65/n8n/.env`에 `N8N_HOME=<설치 폴더>` ([67일차 준비](../67/00-DAY67-PREP.md) 1절).
## 2. 기준 워크플로우 게시와 준비 확인 (전날 필수, 3분)
오늘 고칠 **느린 실습 워크플로우**(IT 헬프데스크)를 게시해 둡니다 (만들지 않고 게시만).
```bash
cd ~/aio-01d4-stu/Last3Mon/72
uv run tools/flow.py push reference/baseline-helpdesk.json
uv run tools/flow.py call base-helpdesk requests/faq-vpn.json -v # route 자동 답변, 처음 한 번은 느림
uv run tools/flow.py doctor
```
```text
✔ n8n container day65-n8n-n8n-1
✔ n8n http://localhost:5678
✔ data API http://localhost:8800
✔ fault switch /admin/fault (day 72 data API)
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ CLI coding agent: codex, claude
ready
```
| ✘ 항목 | 할 일 |
| --- | --- |
| n8n container · data API | 1절 |
| fault switch | 데이터 API가 예전 판 → 1절 `--force-recreate` |
| `! 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 "느리다"는 몇 초인가
실습 워크플로우에 질문을 보내고 3초를 기다리는 것과 9초를 기다리는 것은 다릅니다. 그런데 10번 중 9번은 2초, 1번은 9초라면 "느리다"고 말해야 할까요? 오늘은 이것을 **p50 · p95**로 잽니다.
### 3.2 용어
| 용어 | 한 줄 설명 |
| --- | --- |
| p50 · p95 | 응답 시간을 줄 세웠을 때 가운데(50%) · 느린 쪽 95% 위치의 값 |
| 병목 | 전체 시간을 정하는 가장 느린 단계 |
| 캐시 | 한 번 만든 답을 저장해 두고 같은 질문에 다시 쓰기 |
| timeout · 재시도 | 이 시간 안에 답이 없으면 실패로 보기 · 실패하면 한 번 더 |
| 대체 경로 (fallback) | 한 단계가 실패했을 때 멈추지 않고 가는 다른 길 |
## 4. 당일 준비물
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저
- 66일차 `~/vibe/turl` (7교시 `tbench`에서 저장한 요청을 다시 씀, 없어도 됨)
## 확인
- [ ] `curl -s localhost:8800/admin/fault`가 `ok`
- [ ] `call base-helpdesk`가 답을 돌려줌
- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready`
+406
View File
@@ -0,0 +1,406 @@
# 1단계 · 재기와 병목
- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/72/LAB-01-Q-MEASURE-BOTTLENECK.md) 실습 5개 (10:00~10:50)
- 이 단계가 끝나면: 고치기 전에 응답 시간을 p50 · p95로 재고, 워크플로우가 단계별 시간을 결과에 남기게 해 병목을 숫자로 찾고, 시간 예산을 정해 넘었는지 기록할 수 있다.
- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini`). 10/18은 수업 준비, 10/24는 실습을 5개로 늘린 뒤 다시 잰 값입니다. 출력 전부는 수업 뒤 공개되는 `Last3Mon/72/answers/LAB-TEST-RESULT.md`에 있습니다.
- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/72/LAB-01-Q-MEASURE-BOTTLENECK.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/72` 기준입니다.
```mermaid
flowchart LR
A["1. 재기 전에<br/>고치지 않기"] --> B["2. 시간을 말하는 말<br/>p50 · p95 · max"] --> C["3. 첫 호출"] --> D["4. 어디서 재나"] --> E["5. 단계별 시간"]
E --> F["6. 종류별로 재기"] --> G["7. 어림 계산"] --> H["8. 가장 느린 단계"] --> I["9. 시간 예산"] --> J["10. 요청문으로"]
```
## 0. 이 시간 (50분)
| 시간 | 절 · 개요 | 하는 일 |
| --- | --- | --- |
| 09:00~09:06 | 개요 1~6장 | 오늘 하루, 실습 기준 워크플로우, 실습 15개 |
| 09:06~09:09 | 1 · 개요 7장 | 재고 고치는 순서, 병목, 짐작과 실측이 다른 예 |
| 09:09~09:19 | 2 · 개요 8장 | 꼬리 지연과 백분위, p50 · p95 손으로 계산, 날마다 다른 기준 |
| 09:19~09:23 | 3 · 개요 9장 | 콜드 스타트, 첫 호출 다루는 법 |
| 09:23~09:29 | 4 · 5 · 개요 10~11장 | 밖에서 · 안에서 재기, 관측 3기둥, 계측, `timings` 칸 계산 |
| 09:29~09:36 | 6 · 7 · 개요 12~13장 | 종류별 실측, 암달의 법칙, 어림 계산 |
| 09:36~09:44 | 8 · 9 · 개요 13~14장 | 임계 경로와 `slowest_step`, SLI · SLO · SLA · 오류 예산, 시간 예산 |
| 09:44~09:50 | 10 · 11 · 확인 질문 | 요청문, 자주 하는 실수, 질문 |
### 2교시 Lab 01에서 이어서 하는 것
| 2교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
| --- | --- | --- | --- |
| 시작 (10:00) | — | 보고서 서식 복사 | `cp templates/PERF-REPORT-TEMPLATE.md my-work/PERF.md` (오늘 표를 모두 여기에 적음) |
| Lab 01 예제 1 (10:00~10:08, 에이전트 없이) | 2 · 3 | 기준 워크플로우를 5번씩 재고, 첫 호출을 따로 적기 | `bench base-helpdesk requests/faq-vpn.json -n 5` 등 셋 → `my-work/PERF.md` 1절 |
| Lab 01 예제 2 (10:08~10:20) | 4 · 5 | 에이전트에게 `timings` · `steps` · `llm_calls`를 결과에 남기게 하기 | `my-work/helpdesk.json` (기준을 복사), 테스트 `cases/lab01.json` → `PERF.md` 2절 |
| Lab 01 예제 3 (10:20~10:28, 에이전트 없이) | 6 · 7 | 질문 세 종류를 재고 LLM 몫 계산 | `bench my-helpdesk requests/faq-vpn-nocache.json` · `ticket-own` · `off-topic` → `PERF.md` 2-1절 |
| Lab 01 과제 1 (10:28~10:38) | 8 | `faq_ms` · `ticket_ms`로 나누고 `slowest_step` | 테스트 `cases/lab01-challenge.json` |
| Lab 01 과제 2 (10:38~10:50) | 9 · 10 | `budget_ms` · `over_budget` · `llm_ms` | 테스트 `cases/lab01-challenge2.json` → `PERF.md` 2-2절 |
## 1. 재기 전에 고치지 않기
### 일반 설명
성능을 고치는 일은 어느 시스템이든 같은 고리를 돕니다: **재고 → 가장 큰 곳을 찾고 → 한 가지만 바꾸고 → 다시 잰다.** "섣부른 최적화는 모든 악의 뿌리"(도널드 커누스)라는 말이 흔히 인용되는 까닭은, 재지 않고 고친 곳이 대개 병목이 아니기 때문입니다.
- **병목(bottleneck)**: 전체 시간의 대부분을 차지하는 한 곳. 병 목이 좁으면 병을 아무리 키워도 빨리 따라지지 않는 것과 같습니다.
- 단계가 **차례로** 실행되면 전체 시간 = 단계 시간의 합입니다. 그래서 가장 큰 단계를 줄여야 전체가 줄어듭니다.
- 한 번에 **한 가지만** 바꿉니다. 둘을 함께 바꾸면 무엇이 효과였는지(또는 무엇이 망가뜨렸는지) 알 수 없습니다.
- 빨라졌는지만 보지 않고 **결과가 그대로인지**도 봅니다. 빨라지려고 일을 덜 하면 품질이 떨어질 수 있습니다.
### 오늘 실습에서
"느린 것 같으니 병렬로 바꾸자", "모델을 바꾸자"는 **짐작**입니다. 66일차에 일을 두 갈래로 나눴지만 빨라지지 않았던 것을 기억합니다 (n8n v1은 갈래를 하나씩 실행). 고치기 전에 잽니다.
```mermaid
flowchart LR
M["재기"] --> F["병목 찾기"] --> C["한 가지 바꾸기"] --> R["다시 재기"]
R -->|품질 확인| M
```
| 순서 | 질문 | 오늘 쓰는 것 |
| --- | --- | --- |
| 재기 | 지금 몇 초인가? 매번 같은가? | `flow.py bench` |
| 병목 찾기 | 그 시간 중 어느 단계가 대부분인가? | 결과의 `timings` |
| 바꾸기 | 그 단계를 줄이거나 건너뛸 수 있나? | 2단계 (규칙 · 캐시 · 검사 코드) |
| 다시 재기 | 빨라졌나? **답은 그대로인가?** | `bench` + 원래 테스트 |
짐작과 실측이 다른 예 (오늘 실습):
| 짐작 | 실측 (10/24) | 그래서 |
| --- | --- | --- |
| "사내 데이터 API가 느릴 것" | FAQ · 접수 조회를 합쳐 **18~43ms** | 데이터 API를 빠르게 해도 거의 그대로 |
| "IT와 상관없는 질문은 금방 끝날 것" | 점심 메뉴 질문도 **2.48초** (LLM 세 번) | 건너뛸 단계가 있다 (2단계) |
| "push 하면 바로 평소 속도" | push 직후 첫 호출 **4.4초** (10/18 6.5초) | 첫 호출은 따로 적는다 |
## 2. 시간을 말하는 말: 평균 · p50 · p95 · max
### 일반 설명
응답 시간은 거의 늘 **한쪽으로 긴 꼬리**를 가집니다. 대부분은 비슷하게 빠르고, 가끔 아주 느린 것이 섞입니다. 까닭은 여러 가지입니다: 네트워크가 잠깐 막힘, 서버가 다른 일로 바쁨(대기열), 메모리 정리(GC), LLM이 평소보다 긴 답을 씀.
- 이런 분포에서 **평균**은 꼬리에 끌려가 "보통"도 "느린 쪽"도 말해 주지 못합니다. 그래서 업계는 **백분위(percentile)** 로 말합니다: p50(중앙값), p90 · p95 · p99(느린 쪽).
- 꼬리가 중요한 까닭: 한 화면이 서비스를 여러 번 부르면 그중 하나만 느려도 화면이 느립니다. 예를 들어 한 번 부를 때 1%가 느린 서비스를 100번 부르는 화면은 1 − 0.99¹⁰⁰ ≈ **63%** 가 느린 호출을 하나 이상 만납니다 (계산 예). 큰 서비스가 p99 · p99.9까지 보는 까닭입니다.
- 백분위를 세는 방법은 도구마다 조금 다릅니다 (가장 가까운 자리를 고르는 nearest-rank, 두 자리 사이를 나누는 보간). 오늘은 nearest-rank: 작은 것부터 줄 세우고 **올림(비율 × 개수)번째**. 숫자를 비교할 때는 같은 도구 · 같은 방법으로 잰 것끼리.
- 개수가 적으면 백분위가 흔들립니다. p95를 믿으려면 적어도 수십 번, p99는 수백 번이 필요합니다.
### 오늘 실습에서
같은 요청을 여러 번 부르면 시간이 매번 조금씩 다릅니다. 그 목록을 한 숫자로 말하는 방법이 여럿입니다.
| 말 | 뜻 | 계산 (시간을 작은 것부터 줄 세움) |
| --- | --- | --- |
| 평균 | 다 더해서 개수로 나눔 | 한두 번 아주 느리면 크게 끌려감 |
| **p50** (가운데) | "보통 이 정도" | 줄의 가운데 |
| **p95** (느린 쪽) | 100번 중 95번은 이보다 빠름 | 앞에서 95% 자리 |
| max | 가장 느린 한 번 | 맨 끝 |
| 실패 수 | 답을 못 받은 횟수 | 시간 목록과 따로 |
**손으로 계산해 보기.** 10번 잰 시간이 아래와 같다고 합시다 (예시 숫자).
```text
1.1 1.2 1.2 1.3 1.3 1.4 1.4 1.5 1.6 9.8 (초, 작은 것부터)
```
| 말 | 값 | 어떻게 |
| --- | --- | --- |
| 평균 | 2.18초 | 합 21.8 ÷ 10 — 한 번의 9.8초 때문에 "보통 2초"처럼 보임 |
| p50 | 1.3초 | 가운데 (5번째) |
| p95 | 9.8초 | 10개 중 95% 자리 = 10번째 (자리 = 올림(0.95 × 10)) |
| max | 9.8초 | |
- 사용자 열 명 중 아홉은 1.6초 안에 받았고 한 명은 10초를 기다렸습니다. 평균 2초라는 말은 **아무에게도 맞지 않습니다.**
- 사용자가 "느리다"고 기억하는 것은 느린 쪽입니다. 그래서 p50과 p95를 **함께** 말합니다.
- 개수가 적으면 p95와 max가 같아집니다 (5번이면 p95 = 5번째 = max). 5번은 "대강", 20번 이상이면 p95가 의미를 갖기 시작합니다. 오늘 실습은 시간 때문에 5번입니다.
오늘 기준 워크플로우 (`base-helpdesk`, VPN 질문, push 직후 첫 호출 뒤 5번):
| 날 | 첫 호출 | p50 | p95 | 실패 |
| --- | --- | --- | --- | --- |
| 10/18 | 6.5초 | 2.61초 | 2.83초 | 0 |
| 10/23 | 6.3초 | 3.14초 | 3.22초 | 0 |
| 10/24 | 4.4초 | 2.69초 | 2.98초 | 0 |
같은 워크플로우 · 같은 질문인데 날마다 0.5초쯤 다릅니다 (OpenAI 쪽 응답 시간, 네트워크). 그래서 **바꾸기 전 · 후를 같은 날, 같은 조건으로** 잽니다. 어제 잰 숫자와 오늘 바꾼 숫자를 비교하면 안 됩니다.
## 3. 첫 호출 (콜드 스타트)
### 일반 설명
**콜드 스타트(cold start)** 는 "처음 한 번"만 드는 준비 비용입니다. 서버리스 함수(AWS Lambda 등)가 오랜만에 불릴 때, 자바 같은 언어가 처음 실행한 코드를 최적화(JIT)하기 전, 데이터베이스 연결 풀이 비어 있을 때 모두 같은 일이 생깁니다.
| 처음에만 드는 일 | 예 |
| --- | --- |
| 연결 만들기 | TCP 연결 + TLS 인증 주고받기 (왕복 몇 번), 그 뒤로는 연결을 다시 씀 |
| 불러오기 · 초기화 | 프로그램 · 설정 · 라이브러리를 메모리에 올림 |
| 비어 있는 캐시 | 처음에는 모든 것을 새로 계산 |
- 다루는 법은 셋입니다: **따로 적기**(평소 숫자와 섞지 않음), **미리 불러 두기**(워밍업), **아예 줄이기**(늘 켜 두기, 연결 재사용).
- 운영에서는 배포 직후, 서버가 늘어날 때(오토스케일), 밤새 아무도 안 쓴 뒤 아침 첫 요청이 콜드 스타트를 만납니다.
### 오늘 실습에서
n8n이 다시 켜진 뒤(오늘은 `push`할 때마다) 첫 호출은 눈에 띄게 느립니다.
| 까닭 | 설명 |
| --- | --- |
| 연결 열기 | OpenAI · 데이터 API와 처음 연결 (TLS 등) |
| 준비 | 워크플로우 · 노드를 처음 불러오고 Code 노드 실행기(runner)를 깨움 |
| 다루는 법 | 어디서 |
| --- | --- |
| 첫 호출을 **따로 적는다** | Lab 01 예제 1에서 `my-work/PERF.md` 1절 표의 "첫 호출" 칸 (`PERF.md`는 Lab 01을 시작할 때 `templates/PERF-REPORT-TEMPLATE.md`를 복사해 만든 내 보고서) |
| 재기 전에 한 번 불러 둔다 (워밍업) | 4교시 Lab 02 과제 1의 테스트 `cases/lab02-challenge.json`: 맨 위 `before`에 워밍업 호출이 있음 (과제 1에서 열어 봄) |
| 운영에서는 배포 직후 한 번 불러 둔다 | 74일차 배포에서 다시 |
> 수업 준비 때 첫 호출 때문에 시간 기대(`max_seconds`)가 있는 케이스가 흔들렸습니다. 테스트에 워밍업을 넣은 까닭입니다.
## 4. 어디서 재나
### 일반 설명
재는 자리는 크게 둘입니다.
| 재는 자리 | 무엇을 아나 | 예 |
| --- | --- | --- |
| **밖에서** (블랙박스) | 사용자가 실제로 기다린 시간, 성공 · 실패 | 부하 도구(k6 · Locust), 합성 점검(synthetic monitoring), 실제 사용자 측정(RUM) |
| **안에서** (화이트박스) | 그 시간이 어느 단계에서 쓰였나 | 로그, 단계별 시간, 추적(trace) |
업계에서 말하는 **관측 가능성(observability)** 의 세 기둥이 이것입니다.
- **지표(metrics)**: 모아서 센 숫자 (p95, 실패율, 분당 요청 수). 추세 · 알림에 씀.
- **로그(logs)**: 일이 하나 일어날 때마다 남기는 기록. 무슨 일이 있었는지 찾아볼 때.
- **추적(traces)**: 요청 **하나**가 지나간 길과 단계(span)별 시간. 어디가 느린지 볼 때.
밖에서 잰 숫자로 "느리다"를 알고, 안에서 잰 숫자로 "어디가"를 찾습니다. 둘 중 하나만으로는 모자랍니다.
### 오늘 실습에서
| 어디서 | 보이는 것 | 한계 |
| --- | --- | --- |
| `flow.py bench` (밖에서) | 사용자가 기다린 전체 시간, p50 · p95 · max, 실패 수 | 안에서 어디가 느린지는 모름 |
| n8n **Executions** 화면 | 노드마다 걸린 시간 | 한 번씩 눈으로, 여러 번 모으기 어려움 |
| 워크플로우가 남긴 `timings` | 단계마다 ms, 결과와 함께 | 직접 만들어야 함 (2교시 Lab 01 예제 2에서 만듦) |
```mermaid
flowchart LR
U["사용자 (bench)"] -->|전체 2.7초| W["n8n 웹훅"]
W --> S1["분류 LLM"] --> S2["데이터 조회"] --> S3["답 LLM"] --> S4["검토 LLM"]
S4 --> W
W -.->|timings 로 보임| T["classify · data · answer · review"]
```
- 밖에서 잰 시간(`bench`)과 안에서 잰 `total_ms`는 조금 다릅니다. 밖은 웹훅을 받고 돌려주는 시간까지 포함합니다. 오늘 VPN 한 번: `call` 2.7초, `total_ms` 2698ms (10/24) — 거의 같습니다.
## 5. 단계별 시간을 결과에 남기기
### 일반 설명
코드에 시간을 재는 줄을 넣는 일을 **계측(instrumentation)** 이라고 합니다. 원리는 간단합니다: 단계 앞뒤에서 시각을 읽고 차이를 남깁니다.
- 시각은 같은 시계로 읽어야 뺄 수 있습니다. 컴퓨터 시계는 시간 맞추기(NTP)로 가끔 앞뒤로 튈 수 있어서, 정밀하게 잴 때는 늘 앞으로만 가는 시계(monotonic: 자바스크립트 `performance.now()`, 파이썬 `time.monotonic()`)를 씁니다. 오늘처럼 ms 단위로 단계를 비교할 때는 `Date.now()`로 충분합니다.
- 계측에도 시간이 듭니다. 아주 짧은 단계를 아주 많이 재면 재는 비용이 커집니다. 오늘은 Code 노드 몇 개라 수 ms입니다.
- 업계 도구는 이것을 표준으로 만든 것입니다: **OpenTelemetry**는 단계(span)마다 시작 · 끝 시각과 이름을 남기고, 모아서 한 요청의 폭포 그림(waterfall)으로 보여 줍니다. 오늘 `timings`는 그 아주 작은 판입니다.
- 시간과 함께 **어느 길로 왔는지**(지나간 단계, 부른 외부 서비스 수)도 남깁니다. 시간은 길에 따라 다르기 때문입니다.
### 오늘 실습에서
단계가 끝날 때마다 Code 노드에서 `Date.now()`(지금 시각, ms)를 남기고, 마지막에 앞 시각과의 차이를 계산합니다.
```mermaid
flowchart LR
S["Start clock<br/>t0"] --> C{{"Classify"}} --> T1["After classify<br/>t1"] --> D["FAQ · 접수"] --> T2["After data<br/>t2"] --> A{{"Answer"}} --> T3["After answer<br/>t3"] --> R{{"Review"}} --> T4["After review<br/>t4"] --> B["Build result<br/>timings"]
```
| 칸 | 계산 | 10/24 VPN 한 번 |
| --- | --- | --- |
| `classify_ms` | t1 − t0 | 616 |
| `data_ms` | t2 − t1 | 18 |
| `answer_ms` | t3 − t2 | 1124 |
| `review_ms` | t4 − t3 | 920 |
| `total_ms` | 끝 − t0 | 2698 |
- 단계 칸을 더하면 2678ms. `total_ms`와 20ms 차이는 시각을 남기는 Code 노드들과 결과를 만드는 시간입니다.
- **시각 노드는 단계가 끝난 뒤에.** 앞에 두면 값이 0이나 음수가 됩니다 (Lab 01 문서 끝 "막혔을 때" 표 첫 줄).
- 지나가지 않은 단계(예: 규칙으로 분류해 Classify를 건너뜀)는 칸을 넣지 않습니다. `$('노드').isExecuted`로 먼저 확인합니다.
- `steps`(지나간 단계)와 `llm_calls`(부른 LLM 수)도 워크플로우가 정해진 값으로 남깁니다. 69일차 협업 기록과 같은 생각: **어느 길로 왔는지는 워크플로우가 안다.** LLM에게 "몇 번 불렀는지 적어"라고 하지 않습니다.
## 6. 요청 종류별로 재기 (2교시 Lab 01 예제 3)
### 일반 설명
한 서비스에 오는 요청은 종류가 섞여 있습니다(**작업 구성**, workload mix). 종류마다 지나가는 길이 다르면 시간도 다릅니다.
- 모두 섞어 잰 숫자는 "어느 종류가 느린지"를 숨깁니다. 빠른 종류가 많으면 느린 종류의 문제가 평균 · p50에 묻힙니다.
- 그래서 **종류별로 따로** 재고, 하루에 종류마다 몇 건이 오는지(비율)도 함께 봅니다. 고칠 순서는 "느림 × 건수"로 정합니다.
- 부하 시험(load test)도 실제 요청 비율을 흉내 낸 시나리오로 합니다. 한 가지 요청만 반복하면 실제와 다른 결론이 납니다.
### 오늘 실습에서
같은 워크플로우에 질문 세 종류를 5번씩 (10/24, Lab 01 예제 2에서 만든 `my-helpdesk`, 캐시 없음). Lab 01 예제 3에서 각자 같은 표를 `my-work/PERF.md` 2-1절에 채웁니다.
| 질문 종류 | p50 | p95 | classify | data | answer | review | total | LLM 몫 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| FAQ (VPN) | 2.91초 | 3.08초 | 616 | 18 | 1124 | 920 | 2698 | 98.6% |
| 접수 번호 (T-1002) | 2.99초 | 3.12초 | 818 | 43 | 877 | 895 | 2647 | 97.8% |
| IT 밖 (점심 메뉴) | 2.48초 | 2.84초 | 732 | 19 | 740 | 978 | 2482 | 98.7% |
(단계 칸은 ms, `uv run tools/flow.py call my-helpdesk <요청> -v` 한 번의 값. LLM 몫 = (classify + answer + review) ÷ total)
- **세 종류 모두 LLM이 98% 안팎.** 데이터 API는 20~40ms입니다.
- 점심 메뉴 질문은 데이터가 하나도 없는데도 LLM을 세 번 부르고 "찾지 못했습니다"류의 답을 씁니다. 2단계에서 줄일 수 있는 첫 후보입니다.
- 접수 번호 질문은 10/24 Lab 01 예제 2의 테스트(`cases/lab01.json`)에서 한 번 실패했습니다: 검토 LLM이 "처리 중"을 "승인 대기 중"으로 바꿔 썼습니다. **검토 LLM이 사실을 바꿔 쓸 수 있다**는 것도 4교시 Lab 02 과제 1(검토 LLM을 검사 코드로 바꾸기)의 근거입니다.
## 7. 어림 계산: 어디를 고치면 얼마나 빨라지나
### 일반 설명
어디를 고칠지 정하기 전에 "고치면 얼마나 줄어드나"를 계산해 봅니다. 이것을 정리한 것이 **암달의 법칙(Amdahl's law)** 입니다.
> 전체에서 비율 p를 차지하는 부분을 s배 빠르게 하면, 전체는 1 ÷ ((1 − p) + p ÷ s)배 빨라진다.
| 고치는 부분의 비율 p | 그 부분을 | 전체 |
| --- | --- | --- |
| 1% | 10배 빠르게 | 약 1.01배 (거의 그대로) |
| 50% | 2배 빠르게 | 약 1.33배 |
| 98% | 2배 빠르게 | 약 1.96배 |
| 98% | 없앰 | 약 50배 (남은 2%만) |
- 작은 부분을 아무리 빠르게 해도 전체는 거의 그대로입니다. **큰 덩어리부터.**
- 어림은 방향을 정하는 데 쓰고, 고친 뒤에는 반드시 다시 잽니다. 단계끼리 영향을 주기도 하기 때문입니다 (예: 앞 단계가 빨라지면 뒤 단계가 콜드 스타트를 떠안음).
### 오늘 실습에서
전체 2.7초(VPN) 중 각 단계를 **없앤다면** 얼마가 되는지 어림합니다.
| 없애는 것 | 줄어드는 시간 | 남는 시간 | 줄어드는 비율 |
| --- | --- | --- | --- |
| 데이터 조회 (18ms) | 0.02초 | 2.68초 | 0.7% |
| 분류 LLM (616ms) | 0.62초 | 2.08초 | 23% |
| 검토 LLM (920ms) | 0.92초 | 1.78초 | 34% |
| 분류 + 검토 | 1.54초 | 1.16초 | 57% |
- 데이터 조회를 열 배 빠르게 해도 사용자는 모릅니다. **큰 덩어리부터.**
- 2단계 실측과 맞춰 봅니다: 10/24 캐시 없이 VPN p50이 Lab 02 예제 2까지(규칙 + 캐시, LLM 2번) 2.22초 → Lab 02 과제 1에서 검토를 검사로 바꾼 뒤(LLM 1번) 1.12초. 어림한 "검토를 빼면 약 1초 줄어듦"과 맞습니다.
- 어림은 **방향**을 정하는 데 씁니다. 결과는 반드시 다시 잽니다.
## 8. 가장 느린 단계 (2교시 Lab 01 과제 1)
### 일반 설명
차례로 실행되는 흐름에서는 가장 오래 걸린 단계가 곧 가장 큰 병목입니다. 단계가 **동시에** 실행되는 흐름이라면, 전체 시간은 가장 긴 줄기(**임계 경로**, critical path)가 정합니다. 임계 경로 밖의 단계를 빠르게 해도 전체는 줄지 않습니다.
- 매번 사람이 화면을 열어 찾는 대신, 결과에 "가장 느린 단계"를 **계산해 남기면** 많은 요청을 모아 셀 수 있습니다 ("오늘 느린 요청의 70%는 답 단계").
- 가장 느린 단계는 실행마다 바뀔 수 있습니다. 비슷한 단계가 둘이면 1등이 자주 바뀌므로, 한 번이 아니라 **여러 번 모아서** 봅니다.
### 오늘 실습에서
`timings`에서 `total_ms`를 뺀 칸 중 가장 큰 칸의 이름을 `slowest_step`으로 결과에 남깁니다. 매번 Executions를 열지 않아도 결과만 보고 어디가 느린지 압니다.
10/24 Lab 01 과제 1 정답으로 과제 1 테스트 `cases/lab01-challenge.json`의 네 번 (ms):
| 질문 | classify | faq | ticket | answer | review | total | `slowest_step` |
| --- | --- | --- | --- | --- | --- | --- | --- |
| VPN (push 직후 첫 호출) | 1365 | 16 | 14 | 1196 | 993 | 3614 | classify |
| VPN | 774 | 17 | 13 | 971 | 815 | 2601 | answer |
| VPN | 680 | 17 | 10 | 1044 | 1007 | 2769 | answer |
| 접수 현황 | 723 | 13 | 11 | 1029 | 1162 | 2951 | review |
- 답 LLM과 검토 LLM이 비슷해서 실행마다 1등이 바뀝니다. push 직후 첫 호출에서는 분류(첫 LLM)가 1등이었습니다 (첫 호출의 준비 시간이 첫 LLM 단계에 붙음). 그래서 테스트는 `classify` · `answer` · `review` 중 하나면 통과로 봅니다.
- `slowest_step`은 LLM이 아니라 **Code가 계산**합니다. 숫자가 있으면 판단하지 않고 셉니다.
- 데이터 조회를 `faq_ms` · `ticket_ms`로 나눈 까닭: 3단계에서 "어느 쪽이 멈췄나"를 볼 때 둘을 따로 봐야 합니다.
## 9. 시간 예산 (2교시 Lab 01 과제 2)
### 일반 설명
"빠르다"는 기준이 있어야 말할 수 있습니다. 업계에서 쓰는 말은 셋입니다.
| 말 | 뜻 | 예 |
| --- | --- | --- |
| **SLI** (Service Level Indicator, 서비스 수준 지표) | 서비스가 잘 되고 있는지 **재는 숫자**. 무엇을 재나 | "3초 안에 답한 요청의 비율" |
| **SLO** (Service Level Objective, 서비스 수준 목표) | 그 지표가 지켜야 할 **목표 값**. 팀이 스스로 정함 | "한 달 동안 95% 이상" |
| **SLA** (Service Level Agreement, 서비스 수준 협약) | 고객과 맺은 **계약**. 목표를 못 지키면 어떻게 되나 (보상) | "95% 밑이면 요금 일부 돌려줌" |
- **오류 예산(error budget)** = 1 − SLO. 목표가 95%면 5%까지는 늦어도 됩니다. 예산이 남으면 새 기능을 내고, 다 쓰면 안정화에 집중합니다 (구글 SRE의 방식).
- **시간 예산 나누기**: 전체 목표(예: 3초)를 단계마다 나눠 줍니다 (분류 0.5초, 데이터 0.2초, 답 2초 …). 각 단계는 자기 몫을 넘었는지로 봅니다.
- 목표는 **사용자 쪽에서** 정합니다. "지금 2.7초니까 목표 3초"가 아니라 "사용자가 3초 넘으면 답답해하니 3초"입니다.
- 넘은 요청을 버리지 않고 **기록해서 모으는** 것이 먼저입니다. 모아야 어디를 고칠지 보입니다.
### 오늘 실습에서
"빠르다 · 느리다"는 기준이 있어야 말할 수 있습니다. 그 기준이 **시간 예산**(목표 시간)입니다.
| 말 | 뜻 | 오늘 |
| --- | --- | --- |
| 시간 예산 (`budget_ms`) | 한 요청이 이 안에 끝나야 함 | 기본 3000ms, 요청 본문으로 바꿀 수 있음 |
| 넘었나 (`over_budget`) | `total_ms > budget_ms` | 결과에 남김 (답은 그대로 보냄) |
| LLM 시간 (`llm_ms`) | 분류 · 답 · 검토의 합 | 넘었을 때 LLM 탓인지 바로 보임 |
| 목표 수준 (SLO) | "요청의 95%가 3초 안" 같은 약속 | 오늘은 씨앗만 (79일차 운영 대시보드) |
- 예산은 **사용자 쪽에서** 정합니다 ("3초 넘으면 느리다"). 워크플로우를 만든 사람이 "우리 워크플로우는 2.7초니까 예산 3초"라고 정하는 것이 아닙니다.
- 넘었다고 답을 멈추지 않습니다. 이미 거의 다 만든 답을 버리면 사용자는 더 오래 기다립니다. **기록하고, 모아서, 고칠 곳을 정합니다.**
- `llm_ms`를 `timings` 안에 넣으면 늘 가장 커서 `slowest_step`이 망가집니다. 그래서 결과 맨 바깥에 둡니다.
**세어 보기 (예시).** 하루 200건 중 `over_budget: true`가 24건이면 3초 안에 답한 비율은 (200 − 24) ÷ 200 = 88%. 목표가 95%라면 모자랍니다. 넘은 24건의 `slowest_step`을 세어 보면 무엇부터 고칠지 보입니다.
10/24 Lab 01 과제 2 정답으로 과제 2 테스트 `cases/lab01-challenge2.json` (VPN · 접수, 캐시 없이). Lab 01 과제 2에서 각자 `PERF.md` 2-2절에 같은 표를 채웁니다:
| 예산 | `total_ms` | `llm_ms` | `over_budget` |
| --- | --- | --- | --- |
| 60000 | 3357 (push 직후) | 3295 | false |
| 500 | 2609 | 2563 | true |
| 주지 않음 → 3000 | 3023 | 2973 | **true** |
| 주지 않음 → 3000 (접수 현황) | 2904 | 2858 | false |
- 기본 예산 3초에서 VPN이 3023ms로 **23ms 넘었습니다.** 지금 워크플로우는 3초 근처라 실행마다 `true` · `false`가 바뀝니다. 그래서 과제 2 테스트는 기본값일 때 `budget_ms: 3000`만 보고, 4교시 Lab 02에서 LLM을 줄이면 넉넉히 들어옵니다.
- `llm_ms`가 `total_ms`의 98%: 넘은 까닭이 LLM이라는 것이 결과만 봐도 보입니다.
## 10. 요청문으로
Lab과 다른 업무로 예를 듭니다 (주문 문의 워크플로우, **실습에는 없는 예**: `my-work/order.json`은 가상의 파일). Lab 01 예제 2 · 과제 1 · 과제 2의 요청문을 쓸 때 같은 칸을 채웁니다.
| 넣을 것 | 예 |
| --- | --- |
| 바탕 · 바꿀 것 | "`my-work/order.json`은 그대로 두고 결과에 필드만 더한다" |
| 시각을 남길 곳 | "주문 조회 뒤, 답 LLM 뒤" |
| 결과 모양 | `timings: {lookup_ms, answer_ms, total_ms}`, `steps`, `llm_calls` |
| 지나가지 않은 단계 | "지나가지 않은 노드는 `isExecuted`로 확인하고 빼기" |
| 예산 | "본문 `budget_ms`(없으면 2000), `over_budget`, `llm_ms`는 `timings` 밖" |
| 기대 결과 | 테스트 파일 |
> "빠르게 해 줘"로 시작하지 않습니다. **"재게 해 줘"** 가 먼저입니다.
## 11. 자주 하는 실수
| 실수 | 생기는 일 | 바로잡기 |
| --- | --- | --- |
| 평균 하나만 적음 | "가끔 10초"가 안 보임 | p50 · p95 · max |
| push 직후 바로 잼 | 첫 호출 4~6초가 섞임 | 첫 호출을 따로, 또는 워밍업 |
| 다른 날 숫자와 비교 | 날마다 0.5초쯤 다름 | 같은 날 전 · 후 |
| 시각 노드를 단계 앞에 | `timings`가 0 · 음수 | 단계가 끝난 뒤 |
| LLM에게 `llm_calls`를 쓰게 함 | 틀린 숫자 | 워크플로우가 `isExecuted`로 셈 |
| 빨라진 것만 봄 | 답이 바뀐 것을 놓침 | 원래 테스트도 다시 |
## 확인 질문
<details><summary>1. 5번 잰 시간이 2.4 · 2.5 · 2.6 · 2.6 · 7.9초입니다. p50 · p95 · 평균은? 사용자에게 무엇이라고 말하나요?</summary>
p50 2.6초, p95 7.9초(5개 중 5번째), 평균 3.6초. "보통 2.6초, 가끔 8초 가까이"라고 말합니다. 7.9초가 첫 호출이었는지 먼저 확인합니다.
</details>
<details><summary>2. 데이터 API를 2배 빠르게 하는 일과 검토 LLM을 없애는 일 중 무엇을 먼저 하나요?</summary>
검토 LLM. 데이터 API는 전체의 1%가 안 되어(18ms) 2배 빨라져도 0.01초입니다. 검토 LLM은 약 0.9초(34%)입니다. 대신 검토가 하던 일 중 무엇을 잃는지 적습니다 (2단계).
</details>
<details><summary>3. 시간 예산 3초를 넘은 요청에 답을 보내지 않으면 어떻게 되나요?</summary>
사용자는 3초를 기다리고도 답을 못 받습니다. 예산은 **기록하고 모아서** 고칠 곳을 정하는 데 씁니다. 답을 멈추는 것은 3단계의 timeout(외부 서비스를 기다리는 한도)이 하는 일이고, 그때도 "정해진 답"을 보냅니다.
</details>
## 정리
- [ ] 재기 → 병목 → 한 가지 바꾸기 → 다시 재기 (품질도)
- [ ] p50 · p95 · max를 함께, 첫 호출은 따로, 같은 날 전 · 후
- [ ] 단계별 시간 · 지나간 단계 · LLM 수는 워크플로우가 결과에 남긴다
- [ ] 질문 종류마다 재고, 큰 덩어리부터 어림한다 (오늘은 LLM이 98%)
- [ ] 시간 예산은 사용자 쪽에서 정하고, 넘었는지는 기록한다
+353
View File
@@ -0,0 +1,353 @@
# 2단계 · 실행 흐름 최적화
- 교시: 3교시 강의 (11:00~11:50) · 4교시 [Lab 02](../../Last3Mon/72/LAB-02-Q-OPTIMIZE-FLOW.md) 실습 5개 (12:00~12:50)
- 이 단계가 끝나면: LLM 호출을 규칙 · 캐시 · 검사 코드로 줄이고, 캐시 키를 설계해 적중률을 올리고, 바꿀 때마다 다시 재서 빨라진 만큼 품질이 그대로인지 확인할 수 있다.
- 숫자: 강사 노트북 실측 (10/18 수업 준비, 10/23~24 다시 잼). 같은 표 안의 전 · 후는 같은 날 잰 값입니다.
- 실습 가리키기: "Lab 02 예제 1"은 4교시 [Lab 02 문서](../../Last3Mon/72/LAB-02-Q-OPTIMIZE-FLOW.md)의 그 절, "Lab 01 …"은 2교시 실습입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/72` 기준이고, `PERF.md`는 Lab 01을 시작할 때 만든 내 보고서 `my-work/PERF.md`입니다.
```mermaid
flowchart LR
A["1. 줄일 수 있는<br/>LLM 호출"] --> B["2. 규칙 먼저"] --> C["3. 캐시"] --> D["4. 적중률"] --> E["5. 캐시 키 설계"]
E --> F["6. 검토를 검사로"] --> G["7. 잃은 것 적기"] --> H["8. 테스트도 바뀐다"] --> I["9. 요청문으로"]
```
## 0. 이 시간 (50분)
| 시간 | 절 · 개요 | 하는 일 |
| --- | --- | --- |
| 11:00~11:06 | 1 · 개요 15장 | LLM 호출의 시간 · 비용, 줄이는 방법, Lab 01 숫자: LLM 98% |
| 11:06~11:16 | 2 · 3 · 개요 16~17장 | 싼 것 먼저(규칙), 캐시 방식 · TTL · 무효화 · 키, 캐시해도 되는 것 |
| 11:16~11:26 | 4 · 5 · 개요 18~19장 | 적중률 공식 · 실측, 캐시 키 설계, FAQ 번호 캐시 |
| 11:26~11:36 | 6 · 7 · 개요 20~21장 | 코드 검사와 LLM 검토, 검토 → 검사 실측, 얻은 것 · 잃은 것 |
| 11:36~11:44 | 8 · 9 | 테스트 격리, 캐시가 테스트를 바꾼 이야기, 요청문 |
| 11:44~11:50 | 확인 질문 | 질문 |
### 4교시 Lab 02에서 이어서 하는 것
바탕은 Lab 01 과제 2까지 고친 `my-work/helpdesk.json`입니다 (없으면 `cp reference/lab01-example2-timings.json my-work/helpdesk.json` 후 push).
| 4교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
| --- | --- | --- | --- |
| Lab 02 예제 1 (12:00~12:10) | 1 · 2 | 데이터 조회를 앞으로, 규칙으로 되면 분류 LLM을 건너뜀 | `bench my-helpdesk requests/faq-printer.json -n 5`, 테스트 `cases/lab02.json` |
| Lab 02 예제 2 (12:10~12:20) | 3 · 8 | 질문 캐시 (데이터 API `/cache`) | 테스트 `cases/lab02.json` · `cases/lab01.json`, `requests/faq-vpn-again.json` |
| Lab 02 예제 3 (12:20~12:26, 에이전트 없이) | 4 | 캐시를 쓸 때 · 안 쓸 때를 재고 적중률 계산 | `bench my-helpdesk requests/faq-vpn.json` · `faq-vpn-nocache.json` -n 5, `curl localhost:8800/cache-stats` → `PERF.md` 3-1절 |
| Lab 02 과제 1 (12:26~12:38) | 6 · 7 | 검토 LLM을 검사 Code로 (LLM 한 번, 2.5초 안) | 테스트 `cases/lab02-challenge.json` (`--repeat 3`) → `PERF.md` 3절 |
| Lab 02 과제 2 (12:38~12:50) | 5 · 7 | FAQ 번호로 두 번째 캐시 (`cache_by`) | 테스트 `cases/lab02-challenge2.json` → `PERF.md` 3절 |
## 1. 줄일 수 있는 LLM 호출
### 일반 설명
LLM 호출은 보통 워크플로우에서 가장 느리고 비싼 단계입니다.
- **시간**: 첫 글자가 나오기까지의 시간(TTFT) + 답 글자 수 × 글자 하나 만드는 시간. 답을 한 토큰씩 차례로 만들기 때문에 **답이 길수록** 오래 걸립니다. 입력(지시문 · 자료)이 길어도 첫 글자가 늦어집니다.
- **비용**: 입력 토큰과 출력 토큰 수만큼 돈이 듭니다. 호출 수가 곧 비용입니다.
- **흔들림**: 같은 질문에도 시간과 답이 조금씩 다릅니다.
줄이는 방법은 쉬운 것부터 이 순서로 생각합니다.
| 방법 | 뜻 |
| --- | --- |
| 없애기 | 규칙 · 코드로 되는 일은 LLM에게 맡기지 않음 |
| 다시 쓰기 | 같은 일의 결과를 저장해 두고 씀 (캐시) |
| 줄이기 | 짧은 지시문, 짧은 답 ("3문장 이내"), 작은 모델 |
| 동시에 | 서로 기다릴 필요 없는 호출을 함께 (도구가 지원할 때) |
| 기다림을 덜 느끼게 | 답을 나오는 대로 보여 주기 (스트리밍) |
### 오늘 실습에서
2교시 Lab 01 예제 3에서 잰 대로 시간의 98%가 LLM 세 번이었습니다. 하나씩 "꼭 LLM이어야 하나"를 묻습니다.
| LLM 호출 | 하는 일 | 10/24 VPN 한 번 | 꼭 LLM이어야 하나 |
| --- | --- | --- | --- |
| 분류 | faq · ticket · other | 616ms | 번호가 있으면 ticket, FAQ가 찾아지면 faq — **규칙으로 대부분** |
| 답 | 데이터로 답 쓰기 | 1124ms | 예 (오늘 남기는 것) |
| 검토 | 말투 다듬기 | 920ms | 말투는 답 지시문으로, 빈 답 · 길이 · 한국어는 **코드로** |
같은 답이 다시 나오는 질문(같은 FAQ)은 아예 일을 하지 않아도 됩니다 → **캐시**.
```mermaid
flowchart LR
Q["질문"] --> C{"캐시에 있나"}
C -->|있음| R1["바로 답<br/>LLM 0번"]
C -->|없음| D["데이터 조회"] --> RU{"규칙으로<br/>분류되나"}
RU -->|예| A{{"답 LLM"}}
RU -->|아니오| L{{"분류 LLM"}} --> A
A --> K["검사 Code"] --> S["캐시에 저장 · 답"]
```
4교시 Lab 02가 하는 일이 이 그림입니다. Lab 01에서 고친 워크플로우(LLM 3번)에서 한 칸씩 바꿉니다: 규칙은 예제 1, 캐시는 예제 2, 검사 Code는 과제 1.
## 2. 규칙 먼저 (4교시 Lab 02 예제 1)
### 일반 설명
**싼 것 먼저, 모를 때만 비싼 것**: 이것을 단계적 처리(cascade) 또는 라우팅(routing)이라고 합니다. 고객센터가 자주 묻는 질문은 자동 응답으로, 나머지만 상담원에게 넘기는 것과 같습니다.
- 규칙(키워드, 번호 모양, 검색 결과 유무)은 빠르고 · 늘 같고 · 왜 그렇게 됐는지 설명할 수 있습니다. 대신 정해 둔 것밖에 모릅니다.
- 그래서 규칙은 **확실할 때만** 답하고, 모르면 LLM에게 넘깁니다. 규칙이 틀리게 답하는 비율(정밀도)이 낮아야 합니다.
- 규칙도 코드입니다. 맞는 예 · 틀리는 예 · 모르는 예를 테스트로 남겨 둡니다. 시간이 지나 질문이 바뀌면 규칙도 다시 봅니다.
### 오늘 실습에서
```mermaid
flowchart LR
D["FAQ · 접수 조회"] --> R{"규칙"}
R -->|"T-1234"| T["ticket"]
R -->|"FAQ 결과 있음"| F["faq"]
R -->|"모름"| L{{"분류 LLM"}}
T & F & L --> A{{"답"}}
```
- 규칙을 쓰려면 **데이터 조회가 먼저** 와야 합니다 (순서를 바꿈). 데이터 조회는 20~40ms라 먼저 해도 손해가 거의 없습니다 (1단계 6절, Lab 01 예제 3에서 잰 표).
- 두 길(규칙 · LLM)이 답 노드에서 만납니다. 두 길의 끝이 같은 모양(`{category}`)을 내놓게 합니다.
- 68일차 "나누기와 유형은 LLM, 담당과 빠진 입력은 규칙"과 같은 생각입니다. **셀 수 있는 것은 규칙.**
| 10/24 | Lab 01 예제 2 워크플로우 (LLM 3번) | Lab 02 예제 1 정답: 규칙 먼저 (LLM 2번) |
| --- | --- | --- |
| 프린터 질문 bench p50 | (재지 않음) | 2.45초 |
| 테스트 `cases/lab02.json` | — | 7/9 (예제 2에서 만들 캐시 케이스가 아직 실패, 접수 답에서 검토 LLM이 "처리 중"을 바꿔 씀) |
- 수업 준비 때(10/18): 접수 질문 2.4~2.8초 → 1.9~2.3초 (LLM 3번 → 2번).
- **규칙이 틀리면?** 예를 들어 "프린터 설치 담당자가 누구예요?"처럼 FAQ 낱말(`프린터` · `설치`)이 들어 있지만 FAQ가 답이 아닌 질문은 규칙으로 `faq`가 됩니다. 반대로 FAQ 낱말이 없는 질문("회의실 화면 공유가 안 돼요")은 LLM이 분류해야 합니다. `cases/lab02.json`에 "규칙으로 모르면 LLM이 분류" 케이스가 있는 까닭입니다. 규칙도 테스트합니다.
## 3. 캐시 (4교시 Lab 02 예제 2)
### 일반 설명
**캐시**는 한 번 만든 결과를 가까운 곳에 저장해 두고, 같은 요청이 오면 다시 만들지 않고 돌려주는 것입니다. 브라우저, CDN, 데이터베이스 앞의 Redis, LLM 응답 저장이 모두 캐시입니다.
| 방식 | 하는 일 | 오늘 |
| --- | --- | --- |
| **캐시 어사이드** (cache-aside) | 일하기 전에 캐시를 보고, 없으면 일한 뒤 저장 | ✔ (워크플로우가 직접) |
| 읽기 대행 (read-through) | 캐시가 알아서 원본을 읽어 옴 | |
| 쓰기 동시 (write-through) | 원본을 바꿀 때 캐시도 함께 | |
- **수명(TTL)**: 저장한 값이 저절로 사라지는 시간. 짧으면 적중이 줄고, 길면 오래된 답이 나갑니다.
- **무효화(invalidation)**: 원본이 바뀌었을 때 캐시를 지우는 일. "컴퓨터 과학에서 어려운 것은 캐시 무효화와 이름 짓기 두 가지"라는 농담이 있을 만큼 어렵습니다.
- **키**: 답을 바꾸는 것은 **모두** 키에 들어가야 합니다 (질문, 사람마다 다르면 사람, 자료가 바뀌면 자료 판번호). 하나라도 빠지면 다른 사람 · 다른 상황의 답이 나갑니다.
- 캐시해도 되는 것: **같은 키면 언제 · 누구에게나 같은 답**. 개인 정보가 든 답, 시시각각 바뀌는 상태는 캐시하지 않습니다.
### 오늘 실습에서
| 캐시해도 되나 | 이유 |
| --- | --- |
| "VPN이 끊겨요" 같은 FAQ 답 | 같은 질문이면 같은 답 |
| "T-1002 상태" | **안 됨.** 상태가 바뀌면 예전 답이 나감 |
| 사람마다 다른 답 (본인 접수 건) | **안 됨.** 남의 답이 나갈 수 있음 |
| `no_cache: true`를 보낸 요청 | 읽지도 쓰지도 않음 (재기 · 테스트용) |
```mermaid
flowchart LR
K["Cache key<br/>질문 → trim · 소문자 · 공백 하나"] --> G["GET /cache/키<br/>(없으면 404)"] --> I{"있나"}
I -->|예| H["Cached result<br/>llm_calls 0"]
I -->|아니오| W["원래 흐름"] --> P["POST /cache/키<br/>ttl 600"]
```
- 캐시 키: 질문을 앞뒤 공백 제거 · 소문자 · 연속 공백 하나로. `" vpn 접속이 자꾸 끊겨요. 어떻게 하나요? "`와 `"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?"`가 같은 키가 됩니다 (Lab 02 예제 2 테스트가 보내는 `requests/faq-vpn-again.json`).
- 수명(ttl): 오늘은 600초. FAQ가 바뀌면 캐시를 비워야 합니다 (`/cache-clear`).
- 캐시 조회는 **일하기 전**, 저장은 답을 만든 **뒤**.
- 10/24 Lab 02 예제 2 정답으로 `cases/lab02.json` 9/9: 같은 VPN 질문 두 번째가 **0.1초**, `llm_calls: 0`.
## 4. 적중률 (4교시 Lab 02 예제 3)
### 일반 설명
캐시의 효과는 **적중률(hit rate)** 이 정합니다.
> 평균 시간 ≈ 적중률 × 캐시 시간 + (1 − 적중률) × 원래 시간
- 캐시가 아무리 빨라도 적중률이 낮으면 평균은 거의 그대로입니다.
- 적중률은 요청이 얼마나 **반복되는지**에 달렸습니다. 실제 서비스의 요청은 흔히 일부 인기 항목에 몰리고 나머지는 드문드문 옵니다 (롱테일). 인기 항목이 많을수록 캐시가 잘 맞습니다.
- 처음 켠 캐시는 비어 있어(cold cache) 적중률이 낮습니다. 잠시 지나야 오른 값이 나옵니다.
- 도구가 세는 hits · misses가 **무엇을 세는지** 먼저 확인합니다 (조회만 해도 세는지, 저장만 하는 요청도 세는지).
### 오늘 실습에서
캐시가 빨라도 **맞는(적중) 비율**이 낮으면 평균은 거의 그대로입니다.
10/24 실측 (같은 VPN 질문 5번, 캐시를 비운 뒤). Lab 02 예제 3에서 각자 같은 표를 `PERF.md` 3-1절에 채웁니다:
| 재기 | 첫 호출 | p50 | max |
| --- | --- | --- | --- |
| 캐시 씀 (`faq-vpn`) | 2.07초 | **0.05초** | 2.07초 (첫 호출) |
| 캐시 안 씀 (`faq-vpn-nocache`) | — | 1.99초 | 2.24초 |
- 같은 질문 5번 중 첫 번만 LLM(2.07초), 나머지 넷은 캐시(0.04~0.06초). 그래서 p50은 0.05초인데 p95 · max는 첫 호출 2.07초입니다.
- 그 뒤 `/cache-stats`는 `hits 9 · misses 1`이었습니다. `no_cache` 요청도 캐시를 **조회는** 하므로(쓰지는 않음) hits로 셉니다. 숫자가 무엇을 세는지 먼저 확인합니다.
**어림 계산.** 캐시에서 오면 0.05초, 아니면 2.0초(위 표)라고 하면:
| 적중률 | 평균 (어림) | 계산 |
| --- | --- | --- |
| 0% | 2.00초 | 1.0 × 2.0 |
| 10% | 1.81초 | 0.1 × 0.05 + 0.9 × 2.0 |
| 30% | 1.42초 | 0.3 × 0.05 + 0.7 × 2.0 |
| 60% | 0.83초 | 0.6 × 0.05 + 0.4 × 2.0 |
- 하루 질문이 모두 다른 글자라면 질문 캐시의 적중률은 0%에 가깝습니다. 사람들은 같은 것을 **다른 말로** 묻습니다.
- 그래서 다음 절의 질문: **무엇을 키로** 할까?
## 5. 캐시 키 설계 (4교시 Lab 02 과제 2)
### 일반 설명
키를 **넓게** 잡으면(여러 질문이 같은 키) 적중이 늘고, **좁게** 잡으면 잘못 맞을 위험이 줄어듭니다. 키 설계는 이 둘의 저울질입니다.
- 정규화(공백 · 대소문자 정리)는 거의 위험 없이 적중을 조금 올립니다.
- 중간 결과로 키를 잡을 수도 있습니다 (오늘: FAQ 번호). 다른 말로 물어도 같은 자료로 답하는 질문이 모입니다.
- **의미 캐시(semantic cache)**: 질문을 임베딩으로 바꿔 가까운 질문이면 같은 답을 씁니다. 적중은 크게 늘지만 "비슷하지만 다른 질문"에 같은 답이 나갈 수 있어, 얼마나 가까워야 같은 것으로 볼지(임계값)를 시험으로 정합니다.
- 잘못 맞은 답(false hit)의 비용이 클수록(돈 · 개인 정보 · 안전) 키를 좁게 잡습니다.
### 오늘 실습에서
같은 답을 받아야 할 질문들이 같은 키를 갖게 하는 것이 키 설계입니다.
| 키 | 같은 키가 되는 질문 | 적중 | 잘못 맞을 위험 | 오늘 |
| --- | --- | --- | --- | --- |
| 질문 글자 그대로 | 글자가 똑같아야 | 아주 낮음 | 없음 | — |
| 질문을 고친 글자 (trim · 소문자 · 공백) | 띄어쓰기 · 대소문자만 다른 질문 | 낮음 | 거의 없음 | Lab 02 예제 2 |
| **FAQ 번호** (`faq-FAQ-003`) | 같은 FAQ로 답하는 질문 모두 | 높음 | 답이 질문마다 다르지 않음 | Lab 02 과제 2 |
| 뜻 (임베딩으로 가까운 질문) | 비슷한 뜻의 질문 | 높음 | 비슷하지만 다른 질문에 같은 답 | 오늘 하지 않음 |
FAQ 번호 캐시는 FAQ 검색이 끝나야 번호를 알 수 있으므로 **두 번째 캐시**로 둡니다.
```mermaid
flowchart LR
Q["질문"] --> C1{"질문 캐시"}
C1 -->|있음| A1["답 · cache_by question"]
C1 -->|없음| D["FAQ · 접수 조회<br/>(수십 ms)"] --> R["규칙"] --> C2{"FAQ 번호 캐시<br/>faq-FAQ-003"}
C2 -->|있음| A2["답 · cache_by faq<br/>LLM 0번"]
C2 -->|없음| L{{"LLM"}} --> S["두 키에 저장"]
```
10/24 Lab 02 과제 2 정답으로 과제 2 테스트 `cases/lab02-challenge2.json` (캐시를 비운 뒤 차례로):
| 질문 | 결과 | 시간 |
| --- | --- | --- |
| "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?" (처음) | `cached: false` | 2.5초 (push 직후 첫 호출) |
| "VPN 연결이 계속 끊어지는데요" (다른 말) | `cached: true`, `cache_by: "faq"`, `llm_calls: 0` | **0.1초** |
| 첫 질문을 띄어쓰기만 바꿔 | `cached: true`, `cache_by: "question"` | 0.0초 |
| 다른 말 + `no_cache` | `cached: false` | 1.2초 |
- Lab 02 과제 2를 하기 전 워크플로우(과제 1 정답)로는 "다른 말"이 `cached: false`, LLM 1번, 1.2초였습니다 (10/24, 4/6).
- 데이터 조회는 하고 LLM만 건너뜁니다. 그래서 질문 캐시(0.1초 이하)보다는 조금 느리지만 LLM보다는 훨씬 빠릅니다.
- **잃는 것**: FAQ 번호 캐시의 답은 **처음 물은 사람의 질문**에 맞춰 쓴 답입니다. "VPN이 끊겨요"에 맞춘 답이 "VPN 설치는 어떻게 해요?"에도 나갈 수 있습니다 (둘 다 FAQ-003). 답이 FAQ 원문 요약이면 괜찮고, 질문마다 달라야 하면 이 캐시는 맞지 않습니다.
- 접수 현황은 여기서도 캐시하지 않습니다. 사람마다 · 시간마다 다르니까요.
## 6. 검토 LLM을 검사 코드로 (4교시 Lab 02 과제 1)
### 일반 설명
LLM의 답을 검사하는 방법은 둘입니다.
| 방법 | 잘하는 것 | 약한 것 |
| --- | --- | --- |
| **코드 검사** (가드레일 · 검증기) | 빠름, 늘 같음, 설명 가능: 빈 답, 길이, 형식(JSON 모양), 금지어, 언어 | 뜻은 모름 |
| **LLM 검토** (LLM-as-judge) | 뜻을 봄: 사실과 맞나, 말투, 없는 약속 | 느리고 비쌈, 흔들림, 검토가 오히려 사실을 바꿔 쓸 수 있음 |
- 업계에서는 **코드 검사를 먼저** 두고, 뜻을 꼭 봐야 하는 업무에만 LLM 검토를 남기는 경우가 많습니다.
- 말투 같은 것은 검토로 고치기보다 처음 답을 쓰는 지시문으로 정하는 것이 싸고 빠릅니다.
### 오늘 실습에서
검토 LLM이 한 일을 Executions에서 비교하면 대부분 **말투만** 바꿨습니다. 그런데 10/24에는 접수 상태 "처리 중"을 "승인 대기 중"으로 바꿔 써서 Lab 01 예제 2의 테스트 `cases/lab01.json`이 한 번 실패했습니다. 말투는 답 LLM의 지시문("합니다체, 3문장 이내")으로 정하고, 셀 수 있는 것은 코드가 봅니다.
| 검사 | 코드 | 걸리면 |
| --- | --- | --- |
| 빈 답 | `answer.trim() === ''` | `review_problems`에 `empty` |
| 한국어인가 | `/[가-힣]/.test(answer)` | `not_korean` |
| 너무 김 | `answer.length > 400` | 400자로 자르고 `too_long` |
캐시 없이 같은 VPN 질문 5번:
| 날 | Lab 02 예제 2까지 (규칙 + 캐시, LLM 2번) | Lab 02 과제 1: 검토를 검사로 (LLM 1번) |
| --- | --- | --- |
| 10/18 | p50 2.01초 · p95 2.23초 | p50 **1.15초** · p95 1.16초 |
| 10/23 | p50 2.39초 · p95 2.84초 | p50 **1.32초** · p95 1.35초 |
| 10/24 | p50 2.22초 · p95 2.45초 | p50 **1.12초** · p95 1.32초 |
- 날마다 숫자는 다르지만 **LLM 하나를 빼면 약 1초 줄어든다**는 것은 같습니다 (1단계 7절 어림과 맞음).
- Lab 02 과제 1 테스트 `cases/lab02-challenge.json`은 2.5초 안을 기대합니다. 10/24 15번 모두 통과했습니다 (`--repeat 3`, 0.9~1.6초).
## 7. 잃은 것도 적는다
### 일반 설명
성능을 위한 변경은 거의 늘 무언가와 맞바꿉니다 (속도 ↔ 정확도, 속도 ↔ 최신성, 속도 ↔ 단순함). 개발 팀은 이것을 **설계 결정 기록(ADR, Architecture Decision Record)** 으로 남깁니다: 무엇을 정했나, 왜, 다른 선택지는, 무엇을 잃었나.
- 몇 달 뒤 누군가 "왜 검토가 없지?"라고 물을 때, 기록이 없으면 같은 논의를 처음부터 다시 합니다.
- 잃은 것을 적어 두면 상황이 바뀌었을 때(예: 규제 업무가 추가됨) 무엇을 되돌려야 할지 바로 압니다.
### 오늘 실습에서
| 바꾼 것 | 얻은 것 | 잃은 것 |
| --- | --- | --- |
| 규칙 먼저 | LLM 1번 줄임 | 규칙이 틀리면 엉뚱한 분류 (규칙을 테스트로) |
| 질문 캐시 | 같은 질문 0.1초 | 오래된 답 · 남의 답 위험, 테스트가 복잡해짐 |
| FAQ 번호 캐시 | 다른 말로 물어도 LLM 0번 | 답이 질문에 맞춰지지 않음 |
| 검토 → 검사 | LLM 1번 줄임 | 뜻을 보는 검토는 사라짐 (69일차의 "사실에 없는 약속" 검사) |
Lab 02 과제 1 · 과제 2를 마칠 때 `my-work/PERF.md` 3절에 바꾼 것마다 한 줄씩, **잃은 것까지** 적습니다. 운영에서 "왜 이렇게 했지?"라고 물을 때 답이 됩니다.
## 8. 캐시는 테스트도 바꾼다
### 일반 설명
테스트끼리 **상태를 나눠 쓰면** 순서에 따라 결과가 바뀝니다 (앞 테스트가 남긴 캐시 · 데이터). 이렇게 "가끔 실패하는" 테스트를 플래키(flaky) 테스트라고 하고, 쌓이면 아무도 테스트를 믿지 않게 됩니다.
- **격리**: 테스트 앞에서 상태를 비우거나(초기화), 테스트마다 다른 키를 씁니다.
- **겉으로 드러난 결과를 본다**: "LLM을 3번 불렀나" 같은 내부 구조를 기대하면, 정당하게 고친 코드도 실패합니다. 품질 테스트는 답의 내용을, 구조는 그 구조를 만든 단계의 테스트에서만 봅니다.
- LLM 문장은 여러 표현을 허용하는 기대로 씁니다 (여러 낱말 중 하나, 숫자 · 고유명사처럼 바뀌면 안 되는 것만 정확히).
### 오늘 실습에서
> 수업 준비 때 "규칙으로 모르는 질문"이 캐시에 들어가 다음 테스트에서 0.1초로 나왔습니다. 그래서 테스트에 `no_cache: true`, 테스트 전에 캐시 비우기(`before`)를 둡니다.
| 테스트를 흔드는 것 | 오늘 한 일 |
| --- | --- |
| 앞 테스트가 남긴 캐시 | `cases/lab02*.json` 맨 위 `before`에 `/cache-clear` |
| 재는 요청이 캐시에 맞음 | `no_cache: true` (Lab 02 예제 3 · 과제 1의 `bench`가 쓰는 `requests/faq-vpn-nocache.json`) |
| 구조를 보는 기대 (`llm_calls: 3`, `steps`에 `review`) | 품질 테스트는 **답의 내용**만, 구조는 그 단계 테스트에서만 |
| LLM 문장 | 여러 표현 중 하나 (`찾지 못` · `확인되지` · `없습니다` · `존재하지`) |
> 수업 준비 때 처음 쓴 품질 테스트에 `llm_calls: 3`, `steps`에 `review` 같은 **구조**가 들어 있어 정당하게 빨라진 워크플로우가 실패했습니다.
## 9. 요청문으로
Lab과 다른 업무로 예를 듭니다 (사내 식당 메뉴 안내 워크플로우, **실습에는 없는 예**). Lab 02 예제 1 · 2 · 과제 1 · 2의 요청문을 쓸 때 같은 칸을 채웁니다.
| 넣을 것 | 예 |
| --- | --- |
| 무엇을 줄이나 | "날짜가 있으면 규칙으로, 없을 때만 LLM이 날짜를 읽게" |
| 캐시 키 | "날짜 + 식당 이름. 질문 글자가 아니라" |
| 캐시하지 않는 것 | "오늘 남은 수량 (바뀜)" |
| 결과에 남길 것 | `cached`, `cache_by`, `llm_calls` |
| 품질 확인 | "빨라진 뒤에도 원래 테스트가 통과" |
> "빠르게 해 줘"가 아니라 **"이 LLM 호출을 이 조건일 때 건너뛰어 줘"** 처럼 한 가지씩.
## 확인 질문
<details><summary>1. 캐시를 붙였더니 같은 질문은 0.1초가 됐습니다. 하루 평균은 얼마나 줄까요?</summary>
적중률에 달렸습니다. 같은 질문이 하루 10%라면 평균 2.0초 → 약 1.81초 (4절 표). 적중률을 먼저 잽니다 (`/cache-stats`의 hits · misses).
</details>
<details><summary>2. "T-1002 상태"를 FAQ 번호 캐시처럼 접수 번호로 캐시하면 안 되나요?</summary>
안 됩니다. 접수 상태는 시간에 따라 바뀌고, 사람마다(본인 건) 다릅니다. 캐시해도 되는 것은 **같은 키면 언제 · 누구에게나 같은 답**인 것뿐입니다.
</details>
<details><summary>3. 검토 LLM을 지웠더니 1초 빨라지고 테스트도 통과했습니다. 끝인가요?</summary>
잃은 것을 적어야 끝입니다. 검토가 하던 "뜻을 보는" 일(사실에 없는 약속, 무례한 말투)은 이제 아무도 안 봅니다. 그것이 필요한 업무라면 검토를 남기고 다른 곳을 줄입니다.
</details>
## 정리
- [ ] 병목인 LLM 호출을 규칙 · 캐시 · 검사 코드로 줄인다
- [ ] 캐시하면 안 되는 답이 있다 (바뀌는 것, 사람마다 다른 것)
- [ ] 캐시의 효과는 적중률이 정한다. 적중률은 키 설계가 정한다
- [ ] 한 가지씩 바꾸고, 바꿀 때마다 같은 날 다시 잰다
- [ ] 빨라진 만큼 품질을 원래 테스트로, 잃은 것도 적는다
+376
View File
@@ -0,0 +1,376 @@
# 3단계 · 운영 관점의 안정성
- 교시: 5교시 강의 (14:00~14:50) · 6교시 [Lab 03](../../Last3Mon/72/LAB-03-Q-STABILITY.md) 실습 5개 (15:00~15:50)
- 이 단계가 끝나면: 데이터 API나 LLM이 느려지거나 멈췄을 때 정해진 시간 안에 정해진 답을 하도록 timeout · 재시도 · 대체 경로를 넣고, 일부만 멈춘 고장(부분 고장)을 그 부분만 넘기고, 차단기로 멈춘 곳을 잠깐 부르지 않고, 무엇이 멈췄는지 알려 주는 상태 점검을 만들 수 있다.
- 숫자: 강사 노트북 실측 (10/18 수업 준비, 10/23~24 다시 잼). 고장은 72일차 데이터 API의 고장 스위치(`flow.py fault`)와 `flow.py llm down`으로 일부러 냈습니다.
- 실습 가리키기: "Lab 03 예제 1"은 6교시 [Lab 03 문서](../../Last3Mon/72/LAB-03-Q-STABILITY.md)의 그 절, "Lab 02 …"는 4교시 실습입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/72` 기준이고, `PERF.md`는 Lab 01을 시작할 때 만든 내 보고서 `my-work/PERF.md`입니다.
```mermaid
flowchart LR
A["1. 멈추는 방식"] --> B["2. timeout ·<br/>재시도 · 오류 출력"] --> C["3. 최악의 시간"] --> D["4. 옵션을 재 보고 쓰기"]
D --> E["5. LLM 없이 답하기"] --> F["6. 부분 고장"] --> G["7. 차단기"] --> H["8. 상태 점검"] --> I["9. 고장 연습"]
```
## 0. 이 시간 (50분)
| 시간 | 절 · 개요 | 하는 일 |
| --- | --- | --- |
| 14:00~14:07 | 1 · 개요 22장 | 멈추는 모양 넷, 연쇄 장애, 고치기 전에 겪은 일 |
| 14:07~14:17 | 2 · 3 · 개요 23장 | timeout · 재시도 · backoff · 멱등성 · 오류 출력, 최악의 시간 |
| 14:17~14:21 | 4 · 개요 24장 | 옵션 직접 확인, `neverError` · `fullResponse` 실측 |
| 14:21~14:29 | 5 · 6 · 개요 25~26장 | 대안(fallback), LLM 없이 답하기, 우아한 저하 · 격벽, 접수 API만 멈췄을 때 |
| 14:29~14:40 | 7 · 8 · 개요 27~28장 | 차단기 3상태와 실측, 상태 점검 |
| 14:40~14:45 | 9 | 카오스 엔지니어링, 고장 연습 명령 · 규칙 |
| 14:45~14:50 | 확인 질문 | 질문 |
### 6교시 Lab 03에서 이어서 하는 것
바탕은 Lab 02까지 고친 `my-work/helpdesk.json`입니다 (없으면 `cp reference/lab02-example2-cache.json my-work/helpdesk.json` 후 push). 실습마다 **고치기 전에 고장을 먼저 겪고** 고칩니다.
| 6교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
| --- | --- | --- | --- |
| Lab 03 예제 1 (15:00~15:10) | 1 · 2 · 3 · 4 | FAQ 느림 · 오류를 겪고 timeout · 재시도 · 오류 출력 → `Data down` | `fault faq slow` · `error`, 테스트 `cases/lab03.json` → `PERF.md` 4절 |
| Lab 03 예제 2 (15:10~15:20) | 5 | LLM이 멈추면 데이터로 `간단 답변` | `llm down` · `up`, 테스트 `cases/lab03-llm-down.json` |
| Lab 03 예제 3 (15:20~15:30) | 4 · 6 | 접수 API만 멈출 때 그 부분만 넘기기 (`fullResponse` · `Ticket check`) | `fault tickets error`, 테스트 `cases/lab03-ticket.json` → `PERF.md` 4절 |
| Lab 03 과제 1 (15:30~15:40) | 8 | 상태 점검 워크플로우 `my-health` | 새 파일 `my-work/health.json`, 테스트 `cases/lab03-challenge.json` → `PERF.md` 5절 |
| Lab 03 과제 2 (15:40~15:50) | 7 · 9 | 차단기 `breaker-faq` (30초) | 테스트 `cases/lab03-challenge2.json` → `PERF.md` 4~6절, `my-work/TEST-REPORT.md` |
## 1. 멈추는 방식
### 일반 설명
외부 서비스(데이터베이스, API, LLM)는 반드시 언젠가 멈춥니다. 멈추는 모양은 크게 넷입니다.
| 모양 | 뜻 | 예 |
| --- | --- | --- |
| 오류 (fail-fast) | 바로 실패를 알려 줌 | HTTP 500, 연결 거부 |
| **느림** (fail-slow) | 답은 오는데 아주 늦게, 또는 끝없이 | 과부하, 잠금 대기 |
| 일부만 | 기능 하나만 멈춤 | 접수 API만 고장 |
| 계속 | 한동안 멈춘 상태가 이어짐 | 장애 30분 |
- **느림이 오류보다 위험한** 경우가 많습니다. 기다리는 동안 일꾼(스레드 · 실행)이 붙잡혀 다른 요청도 밀리고, 결국 멀쩡한 기능까지 느려집니다. 이것이 번지는 것을 **연쇄 장애(cascading failure)** 라고 합니다.
- 그래서 안정성 설계의 첫 원칙은 **빨리 실패하기(fail fast)**: 오래 기다리지 말고, 정해진 시간 안에 정해진 답을 합니다.
### 오늘 실습에서
일부러 고장을 내 본 결과 (고치기 전, 4교시 Lab 02 예제 2 정답 `reference/lab02-example2-cache.json`). Lab 03의 각 실습 첫머리에서 각자 같은 고장을 먼저 겪습니다:
| 고장 | 사용자가 겪은 것 | 날 |
| --- | --- | --- |
| FAQ가 느림 (8초 뒤 답) | **9.0초** · **13.2초** · **11.5초** 기다린 뒤 평소 답 (기다리는 한도가 없음) | 10/18 · 10/23 · 10/24 |
| FAQ가 오류 (500) | 0.1~0.2초 만에 **HTTP 500** `Error in workflow` | 10/18 · 10/23 |
| LLM 키가 틀림 | 1.3초 만에 **HTTP 500** | 10/23 |
| 접수 API만 오류 (500) | 접수 질문도, **FAQ 질문도** `담당자 연결` (Lab 03 예제 2까지 고친 워크플로우, 6절) | 10/24 |
| 멈추는 방식 | 무엇이 나쁜가 |
| --- | --- |
| 느림 | 사용자는 기다리고, 그동안 n8n도 일을 붙잡고 있음. 오류보다 나쁠 수 있음 |
| 오류 (500) | 사용자에게 아무것도 알려 주지 않음 |
| 일부만 멈춤 | 멀쩡한 기능까지 같이 멈추면 고장이 커 보임 |
| 계속 멈춤 | 사람마다 같은 시간을 기다리고, 멈춘 서비스에 요청이 계속 감 |
오늘 3단계는 이 넷을 차례로 다룹니다: timeout(느림) → 오류 출력과 정해진 답(오류) → 부분 고장 → 차단기(계속 멈춤).
## 2. timeout · 재시도 · 오류 출력 (6교시 Lab 03 예제 1)
### 일반 설명
밖을 부르는 모든 곳에 세 가지를 정합니다.
- **timeout**: 얼마나 기다릴지. 없으면 끝없이 기다릴 수 있습니다. 보통 그 서비스의 평소 p99보다 조금 길게 잡습니다.
- **재시도(retry)**: 잠깐 끊긴(일시적) 고장에만 효과가 있습니다. 계속 고장 난 곳에 재시도하면 기다림만 늘고, 힘든 서비스에 요청을 더 보내 회복을 늦춥니다(재시도 폭풍).
- 재시도 사이 기다림은 **지수적으로 늘리고**(0.5초 → 1초 → 2초, exponential backoff) **조금씩 무작위로 흩어**(jitter) 모두가 같은 순간에 다시 부르지 않게 합니다.
- **같은 요청을 두 번 보내도 결과가 같은가(멱등성, idempotency)** 를 먼저 봅니다. 조회(GET)는 괜찮지만 "주문 만들기"(POST)를 재시도하면 주문이 두 번 생길 수 있습니다. 이럴 때는 요청마다 고유 키(idempotency key)를 붙여 서버가 중복을 알아보게 합니다.
- **실패했을 때 갈 곳**: 오류를 그대로 사용자에게 던지지 않고, 정해진 답 · 담당자 연결 · 간단한 답으로 보냅니다. 그리고 왜 그 길로 갔는지(`reason`)를 남겨 나중에 셀 수 있게 합니다.
### 오늘 실습에서
```mermaid
flowchart LR
H["HTTP Request<br/>timeout 2초"] -->|실패| R["0.5초 뒤<br/>한 번 더"]
R -->|성공| N["다음 단계"]
R -->|실패| E["오류 출력 →<br/>담당자 연결"]
H -->|성공| N
```
| 설정 | n8n | 오늘 | 어디에 쓰나 |
| --- | --- | --- | --- |
| 기다리는 한도 | `options.timeout` (ms) | 2000 | `parameters.options` 안 |
| 다시 해 보기 | `retryOnFail` · `maxTries` · `waitBetweenTries` | 2번, 0.5초 | 노드 바로 아래 (`parameters` 밖) |
| 실패해도 멈추지 않기 | `onError: continueErrorOutput` | 오류 출력 → `Data down` | 노드 바로 아래, 연결은 `main`의 1번 칸 |
- timeout이 없으면 n8n은 **끝없이** 기다립니다. 외부를 부르는 노드에는 모두 timeout을 둡니다.
- 정해진 답에는 `reason`(`data_api_down`)을 둡니다. 운영자가 나중에 "오늘 데이터 고장으로 넘긴 것이 몇 건"인지 셀 수 있게.
- 캐시를 FAQ 조회 **앞에** 둔 덕에, FAQ가 멈춰도 캐시에 있는 질문은 0.1초에 답했습니다 (10/23 Lab 03 예제 1 테스트 `cases/lab03.json` 4/4).
## 3. 최악의 시간 계산
### 일반 설명
재시도까지 넣었으면 **최악의 시간**을 미리 계산합니다.
> 최악 ≈ 시도 횟수 × timeout + (시도 횟수 − 1) × 재시도 사이 기다림
- 이 값이 사용자 쪽 시간 예산(1단계 9절)보다 길면 설정이 틀린 것입니다.
- 여러 층이 겹치면 **바깥 timeout이 안쪽보다 길어야** 합니다. 안쪽이 아직 재시도 중인데 바깥이 먼저 포기하면, 안쪽 일은 헛수고가 되고 결과도 버려집니다.
- 큰 시스템은 요청에 "남은 시간"(deadline)을 실어 보내, 안쪽 단계가 남은 시간보다 오래 기다리지 않게 합니다.
### 오늘 실습에서
| 설정 | 계산 | 최악 |
| --- | --- | --- |
| timeout 2초, 2번, 기다림 0.5초 | 2 + 0.5 + 2 | **4.5초** |
| timeout 2초, 3번, 기다림 0.5초 | 2 + 0.5 + 2 + 0.5 + 2 | 7.0초 |
| timeout 5초, 2번, 기다림 1초 | 5 + 1 + 5 | 11초 |
- 실측: FAQ 느림에서 **4.6~4.7초** 뒤 `담당자 연결` (10/18 4.6초, 10/23 4.7초). 계산 4.5초 + 다른 단계.
- Lab 03 예제 1 테스트 `cases/lab03.json`의 FAQ 느림 케이스는 7초 안을 기대합니다. retry를 3번으로 늘리면 7.0초라 아슬아슬합니다.
- **재시도는 공짜가 아닙니다.** 느린 서비스에 재시도를 늘리면 기다림도 늘고, 이미 힘든 서비스에 요청을 더 보냅니다. 재시도는 "잠깐 끊긴" 고장에 쓰는 것이지 "계속 느린" 고장에 쓰는 것이 아닙니다 (그것은 7절 차단기).
## 4. 옵션은 재 보고 쓴다: `neverError` · `fullResponse`
### 일반 설명
라이브러리 · 도구의 옵션은 **이름이 주는 느낌과 실제 동작이 다를** 수 있고, 판(version)마다 바뀌기도 합니다. 안정성에 쓰는 옵션은 특히 그렇습니다. 고장이 났을 때만 동작하므로 평소에는 틀린 줄 모릅니다.
- 문서를 읽고, **작은 시험으로 직접 확인**합니다: 일부러 404 · 500 · 느림을 만들어 어디로 가는지, 몇 초 걸리는지.
- 확인한 판번호와 결과를 적어 둡니다. 판을 올리면 다시 확인합니다.
### 오늘 실습에서
캐시 조회는 "없음"을 404로 받습니다. 404에서 워크플로우가 멈추지 않게 `neverError`를 켰습니다. 그런데 이 옵션은 정확히 무엇을 하나요? 문서를 읽는 것만으로는 헷갈려서 **작은 시험 워크플로우로 재 봤습니다** (10/24, `retryOnFail` 3번 · 기다림 1초, 접수 API. 강사가 따로 잰 것으로 실습에는 없습니다. 이 결과를 Lab 03 예제 3에서 씁니다).
| 설정 | 404 (없는 번호) | 500 (접수 API 고장) |
| --- | --- | --- |
| 없음 | 재시도 뒤 **오류 출력** (2.1초) | 재시도 뒤 오류 출력 (2.1초) |
| `neverError` | 재시도 뒤 **보통 출력** (2.2초) | 재시도 뒤 **오류 출력** (2.2초) |
| `neverError` + `fullResponse` | 바로 보통 출력, `statusCode: 404` (0.1초) | 바로 보통 출력, `statusCode: 500` (0.1초) |
배운 것:
- 이 n8n(2.42.6)에서 `neverError`는 4xx만 보통 결과로 받고, **500은 여전히 실패**입니다. 수업 준비 때(10/18) 교안에는 "500도 성공으로 본다"고 적었는데, 재 보니 틀렸습니다. **옵션은 이름이나 기억으로 짐작하지 않고 잽니다.**
- `neverError`라도 `retryOnFail`이 켜져 있으면 **404도 재시도를 다 하고** 보통 결과로 받습니다. 시간을 버립니다.
- `fullResponse`를 함께 켜면 상태 코드를 받아 **워크플로우가 직접** 나눌 수 있습니다. 대신 결과 모양이 `{statusCode, body}`로 바뀌어 뒤에서 읽는 곳도 바꿔야 합니다.
## 5. LLM 없이 답하기 (6교시 Lab 03 예제 2)
### 일반 설명
핵심 서비스가 멈췄을 때 쓸 **대안(fallback)** 을 미리 정합니다. 흔한 순서는 이렇습니다.
| 순서 | 대안 | 대가 |
| --- | --- | --- |
| 1 | 다른 모델 · 다른 회사 LLM | 키 · 비용 · 품질을 하나 더 관리 |
| 2 | 저장해 둔 답 (캐시) | 있는 질문만 |
| 3 | 데이터 원문 그대로 · 정해진 답 | 덜 친절함 |
| 4 | 사람에게 넘기기 | 사람 시간 |
- 대안으로 답할 때는 **평소와 같은 척하지 않습니다.** "지금은 간단히 안내합니다"를 알리고, 결과에도 구분(`route`)을 남깁니다.
- 대안도 테스트합니다. 쓰지 않던 대안은 막상 필요할 때 고장 나 있기 쉽습니다.
### 오늘 실습에서
LLM이 실패하는 시점에는 데이터(FAQ · 접수 건)를 이미 조회했습니다.
| 질문 | LLM이 멈췄을 때 | `route` | 10/23 |
| --- | --- | --- | --- |
| FAQ가 있음 | FAQ 원문 + "자동 답변 기능을 잠시 쓸 수 없어 FAQ 원문으로 안내합니다." | `간단 답변` | 3.6초 (분류 LLM이 실패할 때까지) |
| 접수 번호 | "T-1002 접수 건은 현재 '처리 중' 상태입니다. …" | `간단 답변` | 0.4초 |
| 그 밖 | 담당자에게 | `담당자 연결` | 1.2초 |
- `route`를 다르게 두면 운영자가 "오늘 간단 답변이 몇 건이었나"를 셀 수 있습니다 (77일차 로그 분석).
- 다른 모델 · 다른 회사의 LLM으로 바꾸는 방법도 있지만, 키 · 비용 · 품질을 또 관리해야 합니다. **데이터로 답할 수 있는 것은 데이터로.**
- Lab 03 예제 2 테스트 `cases/lab03-llm-down.json`: 고치기 전 1/4 (빈 질문만), 고친 뒤 4/4.
- 사용자에게 **평소 답과 같은 척하지 않습니다.** "잠시 쓸 수 없다"를 알립니다.
## 6. 부분 고장: 멈춘 부분만 넘기기 (6교시 Lab 03 예제 3)
### 일반 설명
**우아한 저하(graceful degradation)** 는 일부가 고장 나도 전체를 멈추지 않고, 고장 난 기능만 줄여서 계속 서비스하는 것입니다. 쇼핑몰의 추천 기능이 멈춰도 검색 · 결제는 되게 하는 것이 예입니다.
- 이것을 위한 설계를 **격벽(bulkhead)** 이라고 부릅니다. 배의 칸막이처럼, 한 칸에 물이 들어도 다른 칸은 멀쩡하게 의존 서비스마다 실패를 따로 다룹니다.
- 흔한 실수는 모든 실패를 **한 군데로** 모으는 것입니다. 그러면 어느 부분이 멈췄든 모든 요청이 같은 실패 길로 갑니다.
- 실패를 "어느 서비스의, 어떤 실패인지"(상태 코드, timeout)로 구분해 받고, 그 서비스가 필요한 요청만 대안으로 보냅니다.
### 오늘 실습에서
접수 API만 멈추면 어떻게 될까요? Lab 03 예제 2까지 만든 워크플로우로 겪어 봤습니다 (10/24). Lab 03 예제 3 첫머리에서 각자 `fault tickets error`로 같은 것을 겪습니다.
| 질문 | Lab 03 예제 2까지 | Lab 03 예제 3 정답 |
| --- | --- | --- |
| 접수 번호 (T-1002) | `담당자 연결`, `reason: data_api_down` (0.6초) | `담당자 연결`, `reason: tickets_down` (0.1초) |
| **FAQ (VPN)** | `담당자 연결` (0.6초) — FAQ는 멀쩡한데 | **`자동 답변`**, 답에 "인증서" (1.1초) |
| 없는 번호 (T-9999, 평소) | `자동 답변` | `자동 답변` (404는 고장이 아님) |
(Lab 03 예제 3 테스트 `cases/lab03-ticket.json`: 예제 2까지의 워크플로우 2/4 → 예제 3 정답 4/4)
```mermaid
flowchart LR
G["Get ticket<br/>fullResponse"] --> C["Ticket check<br/>statusCode 보기"] --> D{"500 이상이고<br/>접수 번호 질문?"}
D -->|예| T["담당자 연결<br/>reason tickets_down"]
D -->|아니오| N["평소 흐름<br/>(FAQ 질문은 그대로 답)"]
```
- 왜 FAQ 질문까지 넘어갔나: 모든 질문이 접수 조회를 지나가고, 오류 출력은 "어느 질문인지"를 모르고 한 길로 보냅니다.
- 고치는 법: 실패를 **보통 결과로 받아**(`fullResponse`) 상태 코드와 질문을 함께 보고 나눕니다.
- 원칙: **고장 난 부분만 줄이고, 나머지는 평소대로.** 이것을 "우아한 저하(graceful degradation)"라고 부릅니다.
- 덤: Lab 03 예제 1에서 `Get ticket`에 재시도를 켠 뒤로, 접수 번호가 없는 질문(`/tickets/NONE` → 404)도 0.5초 기다렸다가 한 번 더 부르고 있었습니다 (4절 표: `neverError`에서도 404를 재시도). `fullResponse`로 바꾸면 404는 바로 옵니다.
| 10/24 VPN 질문의 `data_ms` | 값 |
| --- | --- |
| Lab 02 예제 2 (재시도 없음) | 32~76ms |
| Lab 03 예제 1 정답 (재시도 켬) | **605ms** |
| Lab 03 예제 3 정답 (`fullResponse`) | 38ms |
안정성을 위해 넣은 설정이 **평소 요청을 0.5초 느리게** 만들고 있었습니다. 고친 뒤에는 평소 시간도 다시 잽니다.
## 7. 차단기: 멈춘 곳은 잠깐 부르지 않기 (6교시 Lab 03 과제 2)
### 일반 설명
**차단기(circuit breaker)** 는 전기 차단기에서 온 이름입니다. 계속 실패하는 서비스를 잠시 **부르지 않아서** 사용자의 기다림을 없애고, 힘든 서비스가 회복할 틈을 줍니다 (마이클 나이가드 『Release It!』에서 널리 알려짐).
```mermaid
stateDiagram-v2
[*] --> 닫힘
닫힘 --> 열림: 실패가 기준 이상
열림 --> 반열림: 정한 시간이 지남
반열림 --> 닫힘: 시험 호출 성공
반열림 --> 열림: 시험 호출 실패
```
| 상태 | 하는 일 |
| --- | --- |
| 닫힘 (closed) | 평소처럼 부르고 실패를 셈 |
| 열림 (open) | 부르지 않고 바로 대안 |
| 반열림 (half-open) | 몇 번만 시험 삼아 불러 보고 결과로 닫거나 다시 엶 |
- 정할 것: 몇 번(또는 몇 %) 실패하면 열지, 얼마나 열어 둘지, 반열림에서 몇 번 시험할지.
- 차단기 상태는 요청들이 **함께 보는 곳**에 둡니다 (같은 프로그램의 메모리, 여러 서버라면 Redis 같은 저장소).
- 자바의 resilience4j, 서비스 메시 Envoy · Istio의 outlier detection 같은 도구가 이 일을 합니다.
### 오늘 실습에서
Lab 03 예제 1 뒤에도 FAQ가 계속 느리면, **사람마다** 4.6초씩 기다린 뒤 담당자로 넘어갑니다. 멈춘 것을 이미 알았는데도요.
```mermaid
stateDiagram-v2
[*] --> 닫힘
닫힘 --> 열림: FAQ 실패 (breaker-faq 저장, 30초)
열림 --> 닫힘: 30초 지나 데이터 API가 지움
닫힘: 닫힘 · FAQ를 부름
열림: 열림 · FAQ를 부르지 않고 바로 담당자 연결
```
| 차단기 | 오늘 |
| --- | --- |
| 기억하는 곳 | 데이터 API 캐시 `breaker-faq` (워크플로우는 요청마다 새로 시작하므로, 기억은 바깥에) |
| 여는 때 | FAQ 검색의 오류 출력 (timeout · 500) |
| 열려 있으면 | FAQ를 부르지 않고 바로 `담당자 연결`, `reason: breaker_open` |
| 닫히는 때 | ttl 30초. 그 뒤 첫 질문이 다시 FAQ를 불러 보고, 아직 멈춰 있으면 또 엶 |
10/24 Lab 03 과제 2 정답으로 과제 2 테스트 `cases/lab03-challenge2.json` (FAQ 느림 8초):
| 차례 | 질문 | 결과 | 시간 |
| --- | --- | --- | --- |
| 1 | 평소 VPN | `자동 답변` | 2.7초 (push 직후) |
| 2 | FAQ 느림 · 처음 | `담당자 연결`, `reason: data_api_down` | 4.8초 |
| 3 | FAQ 느림 · 다음 질문 | `담당자 연결`, `reason: breaker_open` | **0.1초** |
| 4 | FAQ가 돌아오고 캐시를 비운 뒤 | `자동 답변` | 1.1초 |
(Lab 03 예제 3 정답으로는 3번이 4.6초 · `data_api_down`이라 3/4)
- 차단기는 사용자를 기다리게 하지 않는 것과 함께, **멈춘 서비스에 요청을 더 보내지 않는** 일도 합니다. 되살아나는 중인 서비스에 요청이 몰리면 다시 넘어집니다.
- 30초는 정한 값입니다. 짧으면 자주 다시 불러 보고(기다리는 사람이 늘고), 길면 되살아난 뒤에도 한동안 담당자로 넘깁니다.
- 질문 캐시에 있는 질문은 차단기를 보기 **전에** 답하므로, 차단기가 열려 있어도 답합니다.
## 8. 상태 점검 (6교시 Lab 03 과제 1: `my-work/health.json`, 웹훅 `my-health`)
### 일반 설명
상태 점검(health check)은 "지금 이 서비스를 믿고 일을 맡겨도 되나"를 묻는 작은 요청입니다. 쿠버네티스는 이것을 세 가지로 나눕니다.
| 점검 | 묻는 것 | 실패하면 |
| --- | --- | --- |
| liveness | 프로그램이 살아 있나 | 다시 시작 |
| readiness | 지금 요청을 받을 준비가 됐나 (의존 서비스 포함) | 요청을 보내지 않음 |
| startup | 처음 켜지는 중인가 | 다른 점검을 잠시 미룸 |
- 얕은 점검(살아만 있나)은 싸지만 "살아 있는데 일은 못 하는" 상태를 놓칩니다. 깊은 점검(실제 일과 같은 길)은 정확하지만 비싸므로 짧은 timeout과 적당한 간격으로.
- 점검 결과는 사람과 기계가 함께 읽습니다: 알림 시스템, 부하 분산기, 대시보드. 그래서 **코드가 정한 모양**(`ok` · `degraded` · `down`)으로 돌려줍니다.
- 밖에서 주기적으로 실제 요청을 흉내 내 보내는 것을 합성 점검(synthetic monitoring)이라고 합니다.
### 오늘 실습에서
```mermaid
flowchart LR
W(["my-health"]) --> A["데이터 API<br/>/health"] --> F["FAQ 검색"] --> L{{"LLM 'OK'"}} --> S["Code: ok ·<br/>degraded · down"]
```
| 원칙 | 이유 | 10/23 |
| --- | --- | --- |
| 점검마다 짧은 timeout | 점검이 8초를 기다리면 점검이 아님 | FAQ 느림에서도 점검 3.1초 |
| 하나가 실패해도 다음 점검 | 무엇이 멀쩡한지까지 알아야 함 (`continueRegularOutput`) | FAQ 오류 → `degraded` 1.0초 |
| 상태는 코드가 | LLM이 멈췄을 수도 있음 | LLM 멈춤 → `degraded` 0.9초 |
| 실제 일과 같은 길로 | `/health`만 보면 FAQ 고장을 모름 | `/health`는 고장 스위치와 상관없이 ok |
| `status` | 기준 (Lab 03 과제 1 정답) |
| --- | --- |
| `ok` | 셋 모두 참 |
| `degraded` | 데이터 API나 LLM 중 하나라도 참 (일부는 됨) |
| `down` | 둘 다 거짓 |
- 운영에서는 Schedule Trigger로 5분마다 점검하고 `degraded`면 알립니다 (76일차 에러 처리).
- 점검 결과와 차단기는 함께 씁니다: 점검이 FAQ를 `false`로 보면 차단기를 미리 열어 둘 수도 있습니다 (오늘은 하지 않음).
## 9. 고장 연습
### 일반 설명
**카오스 엔지니어링(chaos engineering)** 은 일부러 고장을 내서 시스템이 예상대로 버티는지 확인하는 방법입니다. 넷플릭스의 Chaos Monkey(운영 서버를 무작위로 끔)가 유명하고, 클라우드에는 AWS Fault Injection Service 같은 도구도 있습니다. 팀이 날을 잡아 함께 하는 연습을 게임 데이(game day)라고 부릅니다.
- 순서: **가설**("FAQ가 멈춰도 5초 안에 담당자 연결") → 작은 범위로 고장 내기 → 관찰 · 기록 → 되돌리기 → 고치기.
- 고장의 범위(blast radius)를 작게 시작하고, **언제든 멈출 수 있는 스위치**를 먼저 준비합니다.
- 연습이 끝나면 반드시 원래대로 돌리고 확인합니다. 되돌리지 않은 연습 고장이 진짜 장애가 됩니다.
### 오늘 실습에서
| 명령 | 하는 일 |
| --- | --- |
| `flow.py fault faq slow` · `error` · `fault tickets error` · `fault all ok` | 데이터 API 고장 스위치 |
| `flow.py llm down` · `llm up` | OpenAI Credential을 틀린 키로 · 되돌리기 (재시작 없이 바로) |
| `curl -s -XPOST localhost:8800/cache-clear` | 캐시 · 차단기 비우기 |
- Lab 03 테스트 파일(`cases/lab03*.json`)은 `before`로 고장을 켜고 `after`로 **반드시** 끕니다 (실패해도). 손으로 고장을 켰다면 실습이 끝날 때 `fault all ok` · `llm up`.
- 운영에서 처음 겪는 고장은 비쌉니다. 일부러, 미리, 정해진 시간에 연습합니다.
- 연습 순서: **고치기 전에 먼저 겪는다** → 고친다 → 같은 고장으로 다시 겪는다 → 평소 테스트도 다시.
- 끝나면 `doctor` → `ready` (고장 스위치 · OpenAI 키가 평소대로).
## 확인 질문
<details><summary>1. timeout 3초, 재시도 3번, 기다림 1초면 최악은 몇 초인가요? Lab 03 예제 1 테스트 `cases/lab03.json`의 7초 안 기대를 지키나요?</summary>
3 + 1 + 3 + 1 + 3 = 11초. 지키지 못합니다. 느린 고장에 재시도를 늘리면 기다림이 늘어납니다. 계속 느리면 차단기(7절)를 씁니다.
</details>
<details><summary>2. 접수 API만 멈췄는데 VPN 질문도 담당자로 갔습니다. 왜, 그리고 어떻게 고치나요?</summary>
모든 질문이 접수 조회를 지나가고, `Get ticket`의 오류 출력이 질문을 가리지 않고 `Data down`으로 보냈기 때문입니다. `fullResponse`로 상태 코드를 보통 결과로 받아, "500이고 접수 번호 질문"일 때만 담당자로 보냅니다.
</details>
<details><summary>3. 차단기를 n8n 워크플로우 안의 변수로 기억하면 안 되나요?</summary>
웹훅 워크플로우는 요청마다 새로 시작해 앞 요청의 값을 갖고 있지 않습니다. 그래서 바깥(오늘은 데이터 API 캐시, 운영에서는 Redis 같은 저장소)에 둡니다. 저장할 때 ttl을 주면 닫는 일도 저절로 됩니다.
</details>
## 정리
- [ ] 느림 · 오류 · LLM 멈춤 · 일부만 멈춤을 일부러 겪어 본다
- [ ] timeout · 재시도 · 오류 출력으로 정해진 시간 안에 정해진 답, 최악의 시간을 계산한다
- [ ] 옵션은 짐작하지 않고 재 본다 (`neverError`는 500을 막지 않는다)
- [ ] 데이터로 답할 수 있으면 LLM 없이, 멈춘 부분만 넘기고, `route` · `reason`으로 구분
- [ ] 계속 멈추면 차단기로 잠깐 부르지 않는다
- [ ] 상태 점검은 짧게, 끝까지, 코드로
> 다음: 8교시 [4단계 정리](04-STAGE4-WRAPUP.md) — 1~3단계 전체의 의미 · 효과 · 회사에서 쓰는 법.
+210
View File
@@ -0,0 +1,210 @@
# 4단계 · 정리: 오늘 한 일의 의미 · 효과 · 실전 응용
- 교시: 8교시 17:10~17:45 (35분, 바이브 코딩 발표 10분 뒤) · 자료: 이 문서 + [개요](00-DAY72-OVERVIEW.md) 30장
- 이 시간이 끝나면: 오늘 1~3단계에서 한 일이 **무엇을 위한 것이었는지**, 숫자로 **얼마나 효과가 있었는지**, 회사에서는 같은 생각을 **어떤 이름과 도구로** 쓰는지 말할 수 있고, 최종 프로젝트에 무엇을 가져갈지 정할 수 있다.
- 숫자: 오늘 교안의 실측(강사 노트북, 10/18 · 10/23~24)입니다. "예시"라고 적은 계산은 가정을 둔 어림입니다.
```mermaid
flowchart LR
A["1. 오늘 한 장"] --> B["2. 왜 '운영'인가"] --> C["3. 효과를 숫자로"] --> D["4. 회사에서 부르는 이름"]
D --> E["5. 산업별 응용"] --> F["6. 운영 대시보드"] --> G["7. 하지 말아야 할 때"] --> H["8. 직무 · 면접"] --> I["9. 최종 프로젝트로"]
```
## 0. 이 시간 (35분)
| 시간 | 절 | 하는 일 |
| --- | --- | --- |
| 17:10~17:15 | 1 · 2 | 오늘 한 장, 만들기와 운영하기 |
| 17:15~17:20 | 3 | 오늘 숫자로 본 효과, 하루 1,000건이면 (예시) |
| 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단계 · 재기 | p50 · p95 · 첫 호출, 단계별 `timings`, 종류별 재기, 시간 예산 | 기준 VPN p50 2.69초 · 첫 호출 4.4초, **LLM이 시간의 98%** |
| 2단계 · 빠르게 | 규칙 먼저, 질문 캐시, 적중률, FAQ 번호 캐시, 검토를 검사로 | 캐시 없이 VPN p50 2.22 → **1.12초**, 캐시 적중 **0.05초**, 다른 말 질문 1.2 → **0.1초** |
| 3단계 · 멈춰도 답하게 | timeout · 재시도 · 오류 출력, LLM 없이 답, 부분 고장, 차단기, 상태 점검 | FAQ 느림 11.5초 → **4.6초** 담당자 연결 → 다음 사람 **0.1초**(차단기), LLM 멈춤 HTTP 500 → `간단 답변` |
| 바이브 코딩 | 재는 도구 `tbench` (TODO → SPEC → IMP-PLAN → 설치) | 요구 14개 |
한 문장으로: **재고 나서 고치고, 고친 만큼 다시 재고, 멈출 때를 미리 정해 둔다.**
## 2. 왜 "운영"인가: 만들기와 운영하기
64~71일차는 **동작하는 워크플로우를 만드는 일**이었습니다. 오늘은 그것을 **날마다 쓰는 사람들 앞에 계속 두는 일**입니다.
| | 만들기 (64~71일차) | 운영하기 (오늘 · 72일차부터) |
| --- | --- | --- |
| 질문 | "답이 맞나?" | "얼마나 빨리, 얼마나 자주 맞나? 멈추면?" |
| 기준 | 테스트 케이스 통과 | 시간 예산 · 실패율 · 비용 · 복구 시간 |
| 실패 | 고치면 끝 | 언젠가 반드시 옴 → **어떻게 실패할지**를 정함 |
| 보는 사람 | 만든 사람 | 쓰는 사람, 운영자, 비용을 내는 사람 |
- 회사에서 AI 서비스가 "시범(PoC)은 좋았는데 실제로 못 쓰는" 까닭은 대개 정확도보다 **느림 · 비용 · 장애 · 설명할 수 없음**입니다. 오늘 한 일은 이 넷을 다룹니다.
- LLM은 같은 질문에도 매번 다른 시간 · 다른 문장으로 답합니다 (오늘 검토 LLM이 "처리 중"을 바꿔 쓴 일). 그래서 일반 서비스보다 **재기 · 기록 · 대체 경로**가 더 중요합니다.
## 3. 효과를 숫자로
### 3.1 오늘 실측 전 · 후
| 무엇 | 전 | 후 | 바꾼 것 |
| --- | --- | --- | --- |
| VPN 질문 p50 (캐시 없이) | 2.69초 (LLM 3번, 기준) | 1.12초 (LLM 1번) | 규칙 먼저 + 검토를 검사로 |
| 같은 질문 다시 | 2.07초 | 0.05초 | 질문 캐시 |
| 다른 말로 같은 FAQ | 1.2초 | 0.1초 | FAQ 번호 캐시 |
| FAQ가 8초 느릴 때 | 11.5초 기다린 뒤 평소 답 | 4.6초 → 담당자 연결 | timeout 2초 · 재시도 1번 |
| FAQ가 계속 느릴 때, 다음 사람 | 4.6초 | 0.1초 | 차단기 30초 |
| 접수 API만 멈췄을 때 VPN 질문 | 담당자 연결 | 평소 답 | 부분 고장 처리 |
| LLM 키가 틀렸을 때 | HTTP 500 | FAQ 원문 · 상태 그대로 (`간단 답변`) | 데이터로 대체 |
### 3.2 하루 1,000건이면 (예시)
가정: 하루 질문 1,000건, 그중 규칙으로 분류되는 FAQ · 접수 질문 80%, 나머지 20%.
| 단계 | 질문당 LLM 호출 | 하루 LLM 호출 |
| --- | --- | --- |
| 기준 (분류 · 답 · 검토) | 3 | 3,000 |
| 규칙 먼저 + 검토를 검사로 | 규칙 1 · 나머지 2 | 0.8 × 1,000 × 1 + 0.2 × 1,000 × 2 = **1,200** |
| + 캐시 (FAQ 질문의 30%가 캐시에서 온다고 하면) | 캐시 0 | 1,200 − (FAQ 질문 수 × 0.3)만큼 더 줄어듦 |
- LLM 호출 수는 **비용과 거의 비례**합니다 (75일차에 토큰과 가격으로 다시 계산).
- 기다리는 시간: 사용자 1,000명이 질문마다 1.5초씩 덜 기다리면 하루 **약 25분**(1,500초)입니다. 사내 도구에서 "빨라졌다"는 사람들의 일하는 시간입니다.
- 이 어림은 **가정**에 달려 있습니다. 실제 비율은 운영 기록(77일차 로그)에서 세어 다시 계산합니다.
## 4. 회사에서는 같은 생각을 이렇게 부른다
오늘 한 일에는 업계에서 쓰는 이름이 있습니다. 이름을 알면 문서 · 도구 · 채용 공고를 읽을 수 있습니다.
| 오늘 한 일 | 업계에서 부르는 이름 | 자주 보는 도구 · 자료 |
| --- | --- | --- |
| p50 · p95 · max | 지연 시간 백분위 (latency percentiles), 꼬리 지연 (tail latency) | Prometheus 히스토그램, Grafana |
| 시간 예산 · `over_budget` | SLI · **SLO** (목표 수준) · 에러 예산 (error budget) | Google SRE 책의 SLO 장 |
| 단계별 `timings` · `steps` | 분산 추적 (tracing), 스팬 (span) | OpenTelemetry, Datadog · New Relic 같은 APM |
| LLM 호출마다 시간 · 입출력 기록 | LLM 관측 (LLM observability) | LangSmith, Langfuse, Arize Phoenix |
| `bench` · `tbench` | 부하 · 성능 시험 (load testing), 성능 기준 검사 (CI performance gate) | k6, Locust, JMeter |
| 규칙 먼저, 모를 때만 LLM | 라우팅 · 캐스케이드 (싼 방법부터) | 연구 예: FrugalGPT (LLM 캐스케이드) |
| 질문 캐시 · FAQ 번호 캐시 | 응답 캐시, 의미 캐시 (semantic cache) | Redis, GPTCache. 모델 쪽에는 프롬프트 캐싱 (OpenAI · Anthropic 등) |
| 검토 LLM을 검사 코드로 | 가드레일, 출력 검증 (validator) | Guardrails AI, NVIDIA NeMo Guardrails (73일차) |
| timeout · 재시도 | 복원력 패턴: 시간 제한, 지수 백오프 + 지터 재시도 | 각 언어의 재시도 라이브러리, AWS 아키텍처 글 |
| 오류 출력 → 정해진 답, LLM 없이 답 | 대체 경로 (fallback), 우아한 저하 (graceful degradation) | |
| 접수 API만 넘기기 | 격벽 (bulkhead), 부분 장애 격리 | |
| 차단기 `breaker-faq` | **서킷 브레이커** (circuit breaker) | resilience4j (Java), Polly (.NET), Envoy · Istio의 이상 감지 |
| 다른 회사 모델로 바꾸기 (오늘은 안 함) | 모델 게이트웨이 · 멀티 프로바이더 대체 | LiteLLM, OpenRouter |
| `my-health` | 헬스 체크, liveness · readiness, 합성 모니터링 | Kubernetes probe, 외부 감시 서비스 |
| `fault` · `llm down` 고장 연습 | **카오스 엔지니어링**, 게임 데이 | Netflix Chaos Monkey, Gremlin, AWS Fault Injection Service |
- 이름은 달라도 순서는 같습니다: **재고 → 목표를 정하고 → 넘으면 알리고 → 멈출 때를 정해 둔다.**
- 오늘은 n8n 노드와 데이터 API 캐시로 직접 만들었습니다. 회사에서는 이것을 플랫폼(게이트웨이 · 서비스 메시 · 관측 도구)이 대신해 주는 경우가 많습니다. 그래도 **무엇을 설정해야 하는지**는 오늘 만든 사람이 압니다.
## 5. 산업별 응용 (예)
아래는 오늘 한 일을 다른 업무에 옮긴 예입니다 (실제 특정 회사의 사례가 아니라 흔한 모양).
| 업무 | 오늘의 무엇을 | 이렇게 |
| --- | --- | --- |
| 은행 · 통신사 고객 상담 챗봇 | 규칙 먼저 · 캐시 · 시간 예산 · 담당자 연결 | "요금제 변경 방법" 같은 FAQ는 캐시, 본인 계좌 · 요금은 **캐시 금지**, 3초 예산을 넘는 비율을 날마다 보고, LLM 장애 때 상담원 연결 |
| 쇼핑몰 주문 문의 | 부분 고장 · 차단기 | 배송 조회 API가 멈추면 배송 질문만 "잠시 뒤"로, 환불 규정 질문은 평소대로 |
| 사내 IT · 인사 헬프데스크 (오늘) | 전부 | 같은 FAQ로 묶어 캐시, 접수 현황은 캐시 금지, 상태 점검을 5분마다 |
| 제조 설비 알림 요약 | timeout · 데이터로 대체 · 상태 점검 | LLM이 늦으면 원래 알림 문장을 그대로 보내고, 설비 데이터 API 점검을 따로 |
| 병원 · 금융처럼 규제가 있는 곳 | **검토를 지우지 않는 판단** | 빨라지는 것보다 "사실에 없는 약속"을 막는 검토가 중요하면 검토 LLM을 남기고 다른 곳을 줄임 (7절) |
| 사내 문서 RAG (71일차) | 캐시 키 설계 · 적중률 | 같은 규정 섹션으로 답하는 질문은 섹션 번호로 캐시, 규정이 바뀌면 캐시 비우기 |
**토론 거리**: 내 회사(또는 가고 싶은 회사)의 AI 기능 하나를 골라, 위 표의 한 줄을 채워 봅니다.
## 6. 운영 대시보드에 올릴 숫자 여섯
오늘 결과에 남긴 칸만으로 운영 지표가 됩니다 (79일차 `tops` · 운영 대시보드에서 실제로 모읍니다).
| 지표 | 결과의 어느 칸에서 | 무엇을 알 수 있나 |
| --- | --- | --- |
| 종류별 p50 · p95 | `timings.total_ms`, `category` | 어느 종류가 느린가 |
| 예산을 넘은 비율 | `over_budget` | SLO를 지키나 |
| LLM 호출 수 (요청당) | `llm_calls` | 비용 (75일차) |
| 캐시 적중률 | `cached`, `cache_by` | 키 설계가 맞나 |
| 대체 경로 비율 (까닭별) | `route`, `reason` | 무엇이 자주 멈추나 (`data_api_down` · `tickets_down` · `breaker_open` · `llm_down`) |
| 상태 점검 | `my-health`의 `status` | 지금 무엇이 멀쩡한가 |
- 오늘 `reason`을 꼭 넣게 한 까닭이 이것입니다. **까닭이 기록되지 않은 실패는 고칠 수 없습니다.**
## 7. 하지 말아야 할 때 (흔한 실수)
| 하기 쉬운 일 | 왜 위험한가 | 대신 |
| --- | --- | --- |
| 재기 전에 최적화 | 데이터 API(1%)를 고치느라 하루를 씀 | 1단계 어림 계산부터 |
| 사람마다 다른 답을 캐시 | 남의 계좌 · 접수 건이 나감 (사고) | 캐시 키에 사람이 들어가면 캐시하지 않음 |
| 무엇이든 재시도 | 같은 접수를 두 번 만들 수 있음 (POST) | "다시 해도 같은 결과"인 요청만 재시도, 아니면 중복 방지 키(idempotency key) |
| 재시도 · timeout을 크게 | 최악의 시간이 11초를 넘음 | 최악의 시간을 계산하고 예산 안으로 |
| 차단기를 오래 열어 둠 | 되살아났는데도 계속 담당자로 넘김 | 짧게(오늘 30초), 열리고 닫힌 횟수를 기록 |
| 규제 업무에서 검토를 지움 | 빨라졌지만 틀린 약속이 나감 | 품질 기준이 먼저, 그다음 속도 |
| 옵션을 기억으로 씀 | 오늘 `neverError`처럼 생각과 다르게 동작 | 작은 시험으로 재 보고 씀 |
## 8. 직무와 면접
오늘 한 일은 다음 직무들이 날마다 하는 일입니다: **AI · LLM 애플리케이션 엔지니어, MLOps · LLMOps 엔지니어, SRE(사이트 신뢰성 엔지니어), 백엔드 · 플랫폼 엔지니어.** 채용 공고의 "성능 최적화", "장애 대응", "관측성(observability)", "SLO 운영"이 오늘 내용입니다.
면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법:
<details><summary>1. "응답이 느리다는 불만이 들어오면 무엇부터 하나요?"</summary>
평균이 아니라 p50 · p95를 종류별로 재고, 단계별 시간으로 병목을 찾습니다. 오늘 실측: LLM이 98%, 데이터 API 1% 미만이라 LLM 호출 수를 줄였고, VPN p50이 2.22초에서 1.12초가 됐습니다. 고친 뒤에는 원래 품질 테스트를 다시 돌렸습니다.
</details>
<details><summary>2. "LLM 제공사가 장애일 때 서비스는 어떻게 되나요?"</summary>
LLM 노드의 실패를 오류 출력으로 받아, 이미 조회한 데이터(FAQ 원문 · 접수 상태)로 답하고 `route`를 `간단 답변`으로 구분해 셉니다. 다른 제공사로 바꾸는 방법도 있지만 키 · 비용 · 품질을 또 관리해야 해서, 데이터로 답할 수 있는 것은 데이터로 했습니다.
</details>
<details><summary>3. "캐시를 넣으면 무엇이 위험한가요?"</summary>
바뀌는 답(접수 상태)과 사람마다 다른 답이 다른 사람에게 나가는 것. 그리고 테스트가 앞 테스트의 캐시 때문에 흔들리는 것. 그래서 캐시 금지 대상을 정하고, 테스트 앞에 캐시를 비웠습니다. 효과는 적중률로 재고, 키를 FAQ 번호로 바꿔 다른 말 질문도 0.1초로 만들었습니다 (잃는 것: 질문에 맞춘 답).
</details>
<details><summary>4. "서킷 브레이커는 왜 필요하고 무엇을 정해야 하나요?"</summary>
멈춘 서비스를 사람마다 기다리지 않게 하고, 되살아나는 서비스에 요청을 몰지 않으려고. 여는 조건(실패), 열려 있을 때의 답, 닫히는 때(오늘은 30초 ttl)를 정합니다. 오늘 다음 질문이 4.6초에서 0.1초가 됐습니다.
</details>
<details><summary>5. "재시도는 몇 번이 좋나요?"</summary>
정답은 없고 **최악의 시간**으로 정합니다. timeout 2초 · 2번 · 기다림 0.5초면 4.5초, 3번이면 7초. 시간 예산 안에 들어오는 만큼만, "잠깐 끊긴" 고장에만. 계속 느리면 재시도 대신 차단기입니다.
</details>
## 9. 최종 프로젝트로 가져갈 것
최종 프로젝트(85일차~)의 워크플로우에 아래를 넣으면 "운영을 생각한 프로젝트"가 됩니다.
- [ ] 결과에 `timings` · `steps` · `llm_calls` · `route` · `reason` (설명할 수 있는 결과)
- [ ] 주요 요청 종류별 p50 · p95를 **바꾸기 전 · 후 같은 날** 재서 보고서에 (`tbench compare`)
- [ ] 시간 예산 하나 (예: 3초)와 넘은 비율
- [ ] 캐시해도 되는 것 · 안 되는 것 표
- [ ] 외부 호출마다 timeout, 실패하면 정해진 답, 최악의 시간 계산
- [ ] LLM이 멈췄을 때의 답 (데이터로, 또는 담당자 연결)
- [ ] 상태 점검 워크플로우 하나
- [ ] 고장 연습 한 번 (켜고 → 겪고 → 고치고 → 되돌리기)과 그 기록
## 10. 다음 일차와의 연결
| 일차 | 오늘의 무엇을 잇나 |
| --- | --- |
| 73 | 입력 · 출력 검증과 보안 — 오늘 검토를 검사로 바꾼 자리에 "지시문 공격 · 개인정보" 검사 |
| 74 | 배포 — `tbench --max-p95`로 배포 전 성능 검사, 배포 직후 워밍업 |
| 75 | 비용 — `llm_calls`를 토큰 · 가격으로 |
| 76 | 에러 처리 — 상태 점검을 Schedule로, `degraded`면 알림 |
| 77 | 로그 분석 — `route` · `reason` · `over_budget`을 모아 세기 |
| 79 | 운영 대시보드 · `tops` — 6절의 지표 여섯 |
## 토론 (5분)
1. 오늘 바꾼 것 중 **내 업무 · 관심 분야에서 가장 먼저 쓸 것** 하나와 그 까닭.
2. 오늘 "빨라진 것"과 "잃은 것" 중, 내가 운영자라면 되돌리고 싶은 것이 있나요?
## 정리
- [ ] 오늘 한 일은 **운영**: 느림 · 비용 · 장애 · 설명할 수 없음을 다룬다
- [ ] 효과는 같은 날 전 · 후 숫자로, 큰 그림은 가정을 밝힌 어림으로
- [ ] 같은 생각을 업계에서는 SLO · 추적 · 캐시 · 가드레일 · 서킷 브레이커 · 카오스 엔지니어링이라고 부른다
- [ ] 결과에 남긴 칸이 곧 운영 지표가 된다 (`reason`까지)
- [ ] 최종 프로젝트에 체크리스트 8개를 가져간다
+87
View File
@@ -0,0 +1,87 @@
# 7·8교시 · 바이브 코딩 (`tbench`)
- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 10분 · [4단계 정리](04-STAGE4-WRAPUP.md) 35분 · 예고 5분)
- 과제: [VIBE-REQ-TBENCH](../../Last3Mon/72/VIBE-REQ-TBENCH.md)(요청서, 요청의 말 ①~⑫) → [VIBE-Q-TBENCH](../../Last3Mon/72/VIBE-Q-TBENCH.md)의 8단계: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용 → 수업 뒤 `VIBE-A-TBENCH.md` 공개
- 요구 14개(R1~R14). 50분 안에는 **엔진 1단계(R1 · R6 · R7 · R8) + TUI 1단계(R2) + 설치(R14)**, 나머지는 IMP-PLAN의 다음 단계로 집에서.
- 68 · 69 · 71일차와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다.
- 오늘은 `tbench`: URL · 웹훅을 N번 불러 응답 시간 p50 · p95 · max, 실패 수를 표와 스파크라인으로 보여 주고, 바꾸기 전 · 후를 비교하는 TUI. 오늘 `flow.py bench`로 한 일을 내 도구로.
```mermaid
flowchart LR
R["요청서 → TODO · SPEC ·<br/>IMP-PLAN 20분"] --> A["7교시<br/>엔진 · TUI · 설치 30분"] --> B["발표<br/>10분"] --> C["4단계 정리<br/>35분"] --> F["73일차 예고<br/>5분"]
```
## 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~3단계 · TUI 1~2단계 · 설치) · commit | 단계마다 체크리스트 |
| 16:20~16:44 | 4 구현: 엔진 1단계(R1 · R6 · R7 · R8) → TUI 1단계(R2), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tbench` | `cd ~/aio-01d4-stu/Last3Mon/72 && tbench ...` |
16:00에 화면에 띄울 것: 요청서의 ①~⑫와 예 하나(① → TODO 한 줄 → R1). "평균 하나로는 '가끔 느리다'가 안 보입니다. 몇 번째 값 · 실패 수로 바꿉니다." 16:09 "SPEC으로", 16:15 "IMP-PLAN으로 · 엔진과 TUI를 나눠서", 16:20 "에이전트 켜기 · 엔진 1단계만", 16:44 "설치하고 다른 폴더에서"를 알립니다.
| 볼 것 | 막혔을 때 한마디 |
| --- | --- |
| TODO · SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 Doc/TODO.md부터. 확인 방법이 없으면 됐는지 모릅니다" |
| IMP-PLAN이 한 덩어리 · 첫 요청에 14개 | "엔진 1단계만. 단계마다 확인 · 체크 · commit" |
| `tbench`를 다른 폴더에서 못 찾음 | "`uv tool list`, `uv tool update-shell` 뒤 새 터미널" |
| SPEC에 부탁만 ("빠르게 재", "보기 좋게") | "1~10초 열 개면 p95가 몇이어야 하나요? 숫자로" |
| 다 끝난 뒤에야 화면이 나옴 | "worker로 부르고 결과마다 표에 더하게" |
| p95 계산이 들쭉날쭉 | "계산 방법 하나를 정해 테스트로 고정" |
| 테스트가 진짜 n8n을 부름 | "pytest 안에서 가짜 서버 (일부러 느리게 · 가끔 500)" |
| 다른 요청끼리 비교 | "저장할 때 url · 본문 해시를 같이" |
| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tbench`" |
수업 준비 때 tbench를 미리 만들어 보지는 않았습니다. 강사 바이브 코딩(4절)은 8교시가 4단계 정리로 바뀌어 **시간이 남으면 · 수업 뒤** 하고, 걸린 시간을 정답본(`VIBE-A-TBENCH.md`)에 남깁니다.
## 2. 8교시 · 발표 (10분)
- 1~2명. **자기 실습 워크플로우의 바꾸기 전 · 후를 tbench로 비교한 사람**을 먼저 고릅니다.
- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / IMP-PLAN을 어떻게 나눴나 / 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳 / 비교 표 시연.
## 3. 강사 정리 (8교시 4단계 정리 안에서, 1절 표와 함께)
| | n8n 실습 | tbench |
| --- | --- | --- |
| 재기 | `flow.py bench`, 결과의 `timings` | 직접 만든 p50 · p95 |
| 첫 호출 | push 뒤 느림 → 워밍업 (Lab 01 예제 1 · Lab 02 과제 1) | 첫 호출 표시 · 빼고 계산 |
| 비교 | `my-work/PERF.md` 3절에 한 줄씩 (4교시 Lab 02) | `tbench compare` |
| 고장 | `fault` · `llm down` (6교시 Lab 03) | 가짜 서버 (느림 · 500) |
| 같은 조건 | 같은 요청 파일, `no_cache` | url · 본문 해시 · n 확인 |
## 4. 강사 바이브 코딩 (시간이 남으면 · 수업 뒤, 15분)
| 주제 | 요청문 요지 | 보여 줄 것 |
| --- | --- | --- |
| 단계별 시간 그래프 | "응답 JSON에 timings가 있으면 단계별 평균을 막대로" | 오늘 Lab의 `timings`를 tbench가 읽음 |
| 목표 시간 검사 | "--max-p95 2.5 를 넘으면 종료 코드 1 (CI에서 쓰게)" | 74일차 배포 전 검사(릴리스 게이트)의 씨앗 |
| tops 연결 | "결과 저장 형식을 JSON Lines로, 79일차 tops가 읽게" | 도구끼리 같은 형식 |
걸린 시간과 결과를 `Last3Mon/72/VIBE-A-TBENCH.md`에 적어 둡니다.
## 5. 보충 · 팁 (읽을거리, 4단계 정리 7절과 함께)
| 팁 | 설명 |
| --- | --- |
| 요청서 → 요구가 먼저 | 확인 방법(몇 번째 값 · 실패 수 · 같은 칸)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 |
| 재는 도구도 테스트 | 가짜 서버로 "3번째마다 500"이 실패 2개로 세지는지 |
| 같은 조건에서 비교 | 요청 · 횟수 · 캐시 상태 · 첫 호출 |
| 평균 대신 p95 | 사용자는 느린 쪽을 기억한다 |
| 화면은 굳지 않게 | 오래 걸리는 일은 worker |
| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 |
## 6. 73일차 예고 (5분)
- 오늘: 빠르게, 멈춰도 답하게. 내일: **입력·출력 검증과 응답 필터링, Workflow 보안** — 질문에 숨은 지시문 공격, 답에 섞인 개인정보 · 내부 정보를 막습니다.
- 바이브 코딩: `tscan` (폴더 · git staged 파일에서 API 키 · `.env` 값 · 주민번호 · 전화번호를 찾아 가린 채로 보여 주고, pre-commit 훅으로 등록). 오늘 `tbench`는 지우지 마세요. 79일차 `tops`에서 씁니다.
## 확인 (강사)
- [ ] 16:00 요청서 → TODO(에이전트 없이) → SPEC → IMP-PLAN 20분을 지켰다, 16:20부터 엔진 1단계.
- [ ] 7교시 시작 전 가짜 서버로 테스트하라는 말을 화면에 띄웠다.
- [ ] 수업 뒤: `VIBE-A-TBENCH.md`의 "자주 빠뜨리는 것"·강사 바이브 코딩 시간을 채우고 **학생 저장소에 VIBE-A만** 올린다 (LAB-A · `answers/`는 요청할 때).
- [ ] 발표자 1~2명, 강사 정리 표, 강사 바이브 코딩 1개(시간 기록), 팁, 예고.
- [ ] 수업이 끝날 때 학생들의 고장 스위치 · OpenAI 키가 평소대로인지 (`doctor`).
+40
View File
@@ -0,0 +1,40 @@
# 72일차 · Workflow 실행 흐름 최적화와 운영 관점 개선 (바이브 코딩)
- 일자: 2026-10-26 (72일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 학습 내용: Workflow 실행 흐름 최적화, 운영 관점 개선 전략 적용
- 실습 폴더: [Last3Mon/72](../../Last3Mon/72/README.md)
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`) + 72일차 데이터 API(고장 스위치 · 캐시) + **CLI 코딩 에이전트**
- **실습 준비: [00-DAY72-PREP.md](00-DAY72-PREP.md) (수업 전날까지 1~2절 완료)**
## 오늘의 시간표
| 교시 | 시간 | 내용 | 자료 |
| --- | --- | --- | --- |
| 1 | 09:00~09:50 | 오늘의 개요 · **1단계 강의** · 재기와 병목 | [개요](00-DAY72-OVERVIEW.md) 1~14장, [1단계](01-STAGE1-MEASURE-BOTTLENECK.md) |
| 2 | 10:00~10:50 | **Lab 01** 실습 5개 (bench · 단계별 시간 · 종류별 재기 · 가장 느린 단계 · 시간 예산) | [Lab 01](../../Last3Mon/72/LAB-01-Q-MEASURE-BOTTLENECK.md) |
| 3 | 11:00~11:50 | **2단계 강의** · 실행 흐름 최적화 | 개요 15~21장, [2단계](02-STAGE2-OPTIMIZE-FLOW.md) |
| 4 | 12:00~12:50 | **Lab 02** 실습 5개 (규칙 먼저 · 캐시 · 적중률 · LLM 한 번 · FAQ 번호 캐시) | [Lab 02](../../Last3Mon/72/LAB-02-Q-OPTIMIZE-FLOW.md) |
| - | 13:00~14:00 | 점심 | |
| 5 | 14:00~14:50 | **3단계 강의** · 운영 관점의 안정성 | 개요 22~28장, [3단계](03-STAGE3-STABILITY.md) |
| 6 | 15:00~15:50 | **Lab 03** 실습 5개 (timeout · LLM 없이 · 접수 API만 고장 · 상태 점검 · 차단기) | [Lab 03](../../Last3Mon/72/LAB-03-Q-STABILITY.md) |
| 7 | 16:00~16:50 | 바이브 코딩 · `tbench` 만들기 (각자, 요청서 → TODO → SPEC → IMP-PLAN → 구현 → 설치) | [VIBE-REQ-TBENCH](../../Last3Mon/72/VIBE-REQ-TBENCH.md) → [VIBE-Q-TBENCH](../../Last3Mon/72/VIBE-Q-TBENCH.md), [진행](05-VIBE-CODING.md) |
| 8 | 17:00~17:50 | 바이브 코딩 발표 10분 · **4단계 정리 35분** (의미 · 효과 · 실전 응용) · 73일차 예고 5분 | [진행](05-VIBE-CODING.md), [4단계 정리](04-STAGE4-WRAPUP.md) |
```mermaid
flowchart LR
P["개요"] --> S1["1단계<br/>bench · 단계별 시간 · 병목 · 예산"] --> S2["2단계<br/>규칙 먼저 · 캐시 · 키 설계 · LLM 줄이기"] --> S3["3단계<br/>timeout · 대체 경로 · 부분 고장 · 차단기 · 상태 점검"] --> V["7교시<br/>바이브 코딩 tbench"] --> W["8교시<br/>발표 · 4단계 정리"]
```
## 자료 목록
| 파일 | 내용 |
| --- | --- |
| `00-DAY72-PREP.md` | 실습 준비: n8n · 72일차 판 데이터 API 켜기, 기준 워크플로우 게시, `flow.py doctor` (수업 전) |
| `00-DAY72-OVERVIEW.md` | 개요 30장 (PPT 대신 Markdown, 가로줄로 장 구분, 실습 15개 한눈에) |
| `01-STAGE1-MEASURE-BOTTLENECK.md` | 재기 전에 고치지 않기, p50 · p95 손 계산, 첫 호출, 어디서 재나, 단계별 시간, 종류별 실측, 어림 계산, 가장 느린 단계, 시간 예산(SLO), 확인 질문 |
| `02-STAGE2-OPTIMIZE-FLOW.md` | 줄일 수 있는 LLM, 규칙 먼저, 캐시(무엇을 캐시하면 안 되나), 적중률 어림, 캐시 키 설계(FAQ 번호), 검토를 검사로, 잃은 것, 테스트도 바뀐다, 확인 질문 |
| `03-STAGE3-STABILITY.md` | 멈추는 방식, timeout · 재시도 · 오류 출력, 최악의 시간, `neverError` · `fullResponse` 실측, LLM 없이 답, 부분 고장, 차단기, 상태 점검, 고장 연습, 확인 질문 |
| `04-STAGE4-WRAPUP.md` | 8교시 35분 · 정리: 오늘 한 장, 왜 운영인가, 효과를 숫자로, 업계 이름 · 도구 (SLO · 추적 · 캐시 · 가드레일 · 서킷 브레이커 · 카오스), 산업별 응용, 운영 지표 여섯, 하지 말아야 할 때, 직무 · 면접 질문, 최종 프로젝트 체크리스트 |
| `05-VIBE-CODING.md` | 7교시 바이브 코딩 진행 (tbench), 8교시 발표, 강사 바이브 코딩(시간이 남으면), 팁 |