68일차 교안·실습(복합 업무 시나리오 · Multi-Agent 라우터) 추가, 결석 수강생용 최소 준비 문서 포함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-19 22:05:32 +09:00
1 parent a57360d4d9
commit 587ca5c2b3
42 files changed
+5481

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+199
View File
@@ -0,0 +1,199 @@
# AGENTS.md · n8n Multi-Agent 워크플로우 작업 규칙 (68일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 67일차 규칙과 같고, 여러 Agent를 부르는 라우터용 노드(5절)가 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 업무가 여럿이면 **업무 → 담당 Agent** 표도 함께 보여 준다.
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
```
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>`, **n8n 안에서도** `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` (67일차 `Last3Mon/67/data-api`) |
## 3. 이미 있는 Agent (`agents/`, 고치지 말 것)
라우터는 이 Agent들을 **HTTP로 부른다.** 내용을 바꾸지 말고 계약(입력 · 결과)만 지킨다.
| Agent | 파일 | 웹훅 | 입력 (요청 본문) | 결과 |
| --- | --- | --- | --- | --- |
| IT 문의 Agent (67일차) | `agents/it-agent.json` | `POST /webhook/it-agent` | `question`, `user` (둘 다 필수, 없으면 400) | `route`(자동 답변·담당자 연결), `answer`, `tools_used`, `sources` |
| 회의록 Agent (66일차) | `agents/meeting-agent.json` | `POST /webhook/meeting-agent` | `title`, `notes` (`notes` 필수, 없으면 400 `{"error": "notes are required"}`) | `route`, `title`, `count`, `summary`, `actions: [{owner, task, due}]` |
- IT 문의 Agent는 남의 접수 건을 보여 주지 않고 `route: 담당자 연결`로 돌려준다 (67일차 규칙).
- 라우터의 요청 본문: `message`(직원 요청 문장), `user`(요청한 사람), 회의록이 있으면 `title`, `notes`.
## 4. 워크플로우 파일 규칙
```json
{
"name": "68 Assistant router (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 노드로 보낸다.
- 요청 본문은 `$json.body.message`, `$json.body.user`처럼 `body` 아래에 있다.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
아래 `type`/`typeVersion`을 그대로 쓴다. 다른 노드가 필요하면 써도 되지만 `flow.py check`가 이 n8n에 있는지 확인한다.
### Webhook · If · Respond to Webhook
```json
{"name": "Assistant webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-assistant", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Has message?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [200, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.body.message }}", "rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 숫자 비교는 `"operator": {"type": "number", "operation": "equals"}` (`gt`, `lt`도 있음).
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2360, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
- 오류 응답: `"responseBody": "{ \"error\": \"message is required\" }"`, `"options": {"responseCode": 400}`
### AI Agent + OpenAI chat model (업무 나누기, Supervisor)
```json
{"name": "Split tasks", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [420, 300],
"parameters": {"promptType": "define", "text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {"systemMessage": "You split one employee message into separate tasks. ... Return only JSON, no code fence: {\"tasks\": [...]}", "maxIterations": 2}}}
```
```json
{"name": "OpenAI chat model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [380, 520],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent의 결과는 `$json.output`(글자). JSON을 달라고 해도 **글자**로 오므로 다음 Code 노드에서 `JSON.parse` 한다 (앞뒤 ```` ``` ````를 지우고, 실패하면 빈 목록).
- `returnIntermediateSteps: true`면 `$json.intermediateSteps`에 Tool 호출 기록: `[{ "action": { "tool": "Ask_IT_agent", "toolInput": {...} }, "observation": "..." }]`. Tool 이름은 노드 이름의 빈칸이 `_`로 바뀐 것.
### Code (JavaScript) · 여러 항목 돌려주기
```json
{"name": "One item per task", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [880, 300],
"parameters": {"jsCode": "return $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"}}
```
- 반드시 `[{ json: {...} }, ...]` 배열을 돌려준다. 항목이 여럿이면 다음 노드는 **항목마다** 한 번씩 실행된다.
- 앞 노드는 `$('노드 이름').first().json`, 같은 항목의 앞 노드 값은 `$('노드 이름').item.json`.
### Switch (업무 유형별로 나누기)
```json
{"name": "Route by type", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [1080, 300],
"parameters": {"rules": {"values": [
{"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "r-it", "leftValue": "={{ $json.type }}", "rightValue": "it", "operator": {"type": "string", "operation": "equals"}}],
"combinator": "and"}, "renameOutput": true, "outputKey": "it"},
{"conditions": {"...": "meeting 같은 모양"}, "renameOutput": true, "outputKey": "meeting"}]}, "options": {}}}
```
- 규칙 순서대로 출력 0, 1, 2 …. 연결의 `main`도 같은 순서의 칸.
### HTTP Request (다른 Agent 부르기)
```json
{"name": "Call IT agent", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1300, 100],
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/it-agent", "sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {"timeout": 60000, "response": {"response": {"neverError": true}}}}}
```
- `neverError: true`가 없으면 부른 Agent가 400·404·500을 돌려줄 때 **워크플로우 전체가 멈춘다** (응답 500).
- 응답 본문이 그대로 다음 노드의 `$json`이 된다 (`$json.route`, `$json.answer` …).
### HTTP Request Tool (Supervisor가 Agent를 Tool로 부를 때)
```json
{"name": "Ask IT agent", "type": "n8n-nodes-base.httpRequestTool", "typeVersion": 4.2, "position": [480, 520],
"parameters": {"toolDescription": "Ask the company IT agent one IT question ... Input: one question in Korean.",
"method": "POST", "url": "http://localhost:5678/webhook/it-agent", "sendBody": true,
"bodyParameters": {"parameters": [
{"name": "question", "value": "={{ $fromAI('question', 'One IT question in Korean', 'string') }}"},
{"name": "user", "value": "={{ $('Assistant webhook').first().json.body.user }}"}]},
"options": {"timeout": 60000, "response": {"response": {"neverError": true}}}}}
```
- `$fromAI`는 LLM이 정하는 값. **LLM이 정하면 안 되는 값**(요청한 사람, 회의록 원문)은 웹훅 입력에서 표현식으로 가져온다.
### Edit Fields (Set) · Merge · Aggregate (결과 모으기)
```json
{"name": "IT result", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1520, 100],
"parameters": {"assignments": {"assignments": [
{"id": "a0", "name": "index", "value": "={{ $('Route by type').item.json.index }}", "type": "number"},
{"id": "a1", "name": "route", "value": "={{ $json.route || '담당자 연결' }}", "type": "string"},
{"id": "a2", "name": "answer", "value": "={{ $json.answer }}", "type": "string"}]}, "options": {}}}
```
```json
{"name": "Collect results", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [1760, 300], "parameters": {"numberInputs": 3}}
```
```json
{"name": "All results", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "position": [1960, 300],
"parameters": {"aggregate": "aggregateAllItemData", "destinationFieldName": "results", "options": {}}}
```
- Merge는 갈래를 하나로 모은다 (입력 수 = `numberInputs`, 연결의 `index`로 몇 번 입력인지 정함). 데이터가 오지 않은 입력이 있어도 동작한다.
- Aggregate는 여러 항목을 `results` 배열 **한 항목**으로 묶는다. 그 뒤 Code에서 `index`로 정렬해 답을 만든다.
## 6. 연결 (`connections`)
```json
{
"Has message?": {"main": [[{"node": "Split tasks", "type": "main", "index": 0}], [{"node": "Reject empty", "type": "main", "index": 0}]]},
"OpenAI chat model": {"ai_languageModel": [[{"node": "Split tasks", "type": "ai_languageModel", "index": 0}]]},
"Route by type": {"main": [[{"node": "Call IT agent", "type": "main", "index": 0}], [{"node": "Call meeting agent", "type": "main", "index": 0}], [{"node": "Other result", "type": "main", "index": 0}]]},
"IT result": {"main": [[{"node": "Collect results", "type": "main", "index": 0}]]},
"Meeting result": {"main": [[{"node": "Collect results", "type": "main", "index": 1}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If·Switch의 `main`은 출력 순서대로 칸이 나뉜다.
- Chat model은 `ai_languageModel`, Tool은 `ai_tool`로 **Agent에** 연결한다 (`main` 아님).
- Merge로 들어가는 연결은 `index`가 입력 번호 (0, 1, 2 …).
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.message }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ question: $json.text }) }}"`
- Agent·HTTP 뒤 노드에서 웹훅 입력은 `$('Assistant webhook').first().json.body`로 읽는다 (`$json`은 바로 앞 노드 결과).
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-assistant",
"cases": [
{"name": "복합 요청", "request": "requests/composite.json",
"expect": {"status": 200, "equals": {"count": 3, "results.1.agent": "meeting-agent"},
"contains": {"results.0.answer": ["처리 중"]}, "not_contains": {"reply": ["정우성"]}}},
{"name": "직접 쓴 요청", "body": {"message": "", "user": "이준호"}, "expect": {"status": 400}},
{"name": "다른 웹훅으로", "webhook": "it-agent", "body": {"question": "VPN", "user": "이준호"}, "expect": {}}
]
}
```
- 필드 이름은 점으로 내려간다: `results.0.answer` = `results` 첫 항목의 `answer`.
- `equals`: 값이 같아야 함 · `contains`: 목록 중 **하나라도** 들어 있어야 함 (배열·객체는 JSON 글자로 바꿔 찾음) · `not_contains`: 하나도 없어야 함 · `status`: HTTP 코드(기본 200)
- `uv run tools/flow.py test <파일> --repeat 3`: LLM 답은 매번 조금씩 다르므로 3번 이상 돌려 본다.
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `agents/`, `requests/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만).
- 이미 있는 Agent의 일을 라우터 안에서 다시 만들지 않는다 (FAQ·접수 현황을 라우터가 직접 조회하지 않는다).
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+146
View File
@@ -0,0 +1,146 @@
# Lab 01 · 복합 요청을 업무로 나누기
- 관련 강의: [1단계 · 복합 업무 시나리오 분석](../../Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md)
- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분)
- 준비: [실습 준비](../../Lecture/68/00-DAY68-PREP.md)의 `uv run tools/flow.py doctor` → `ready`
- 목표: 요청 하나에 섞인 업무를 **사람이 먼저 분석**하고, 그 분석대로 업무를 나누는 워크플로우를 **내가 쓴 요청문**으로 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>업무 분석표<br/>(사람)"] --> E2["예제 2<br/>업무 나누기<br/>워크플로우"] --> T["과제<br/>담당 Agent · 빠진 입력"]
```
> 이 실습에는 **복사해 붙일 요청문이 없습니다.** 요청문은 직접 씁니다. 막히면 아래 `▶` 를 하나씩 펼쳐 보세요. 펼칠수록 구체적이 되지만, 정답 요청문은 나오지 않습니다.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/68` 후 `codex` (또는 `claude`, `agy`) 실행. 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/68` 에서 `push`·`test`를 **내가** 실행한다 |
- 에이전트는 이 폴더의 [AGENTS.md](AGENTS.md)를 읽고 일합니다. `agy`가 읽지 않으면 첫 요청 앞에 "AGENTS.md를 먼저 읽어"를 붙입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
## 예제 1 · 업무 분석표 (10분, 에이전트 없이)
`cp templates/MULTI-AGENT-DESIGN-TEMPLATE.md my-work/DESIGN.md` 후 1절 표를 **직접** 채웁니다. 요청 파일은 `requests/`에 있습니다 (`cat requests/composite.json`).
| 요청 파일 | 직원 요청 |
| --- | --- |
| `composite` | T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요. (회의록 첨부) |
| `two-it` | 비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼다고 나와요. |
| `it-and-other` | 프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요. |
| `meeting-no-notes` | 이번 회의록 할 일 정리 부탁해요 (회의록 첨부 **없음**) |
| 내 업무 | 64일차 Lab 03의 내 업무 시나리오에서 하나 |
<details><summary>▶ 1단계 · 무엇을 기준으로 나누나</summary>
"한 사람이 한 번에 처리할 수 있는 일"이 업무 하나입니다. 접속사(그리고, 또, ~고)에서 끊기지만, 끊긴 조각이 같은 일을 말하면 하나로 둡니다. 유형은 지금 있는 Agent(IT 문의, 회의록) 기준으로 나누고, 맡을 Agent가 없으면 `other`입니다.
</details>
<details><summary>▶ 2단계 · 빠뜨리기 쉬운 칸</summary>
- **필요한 입력**: 업무를 처리하려면 무엇이 있어야 하나요? 접수 현황은 "누가 물었는지", 회의록 정리는 "회의록 원문"이 필요합니다. 요청 문장에 없고 **요청 본문의 다른 칸**에 있을 수도 있습니다 (`requests/*.json`을 열어 봅니다).
- **업무 관계**: 한 업무의 결과가 다른 업무에 필요한가요? 필요 없다면 "독립"이고, 순서와 상관없이 따로 처리할 수 있습니다.
</details>
**확인**: 옆 사람과 표를 바꿔 봅니다. 업무 개수가 같은가요? 다르면 어느 쪽 기준이 Agent에게 넘기기 좋은가요?
## 예제 2 · 업무 나누기 워크플로우 (15분)
**만들 것**: 직원 요청 문장을 받아 업무 목록으로 나누는 워크플로우. 아직 Agent를 부르지 않고 **나누기만** 합니다.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/split.json` |
| 웹훅 | POST, 경로 `my-split`, 요청 문장은 본문의 `message` |
| 나누는 방법 | LLM(OpenAI `gpt-4.1-mini`)이 나누고, 결과 글자를 코드로 읽어 목록으로 |
| 결과 | `{"count": 3, "tasks": [{"type": "it", "text": "..."}]}` · 유형은 `it`, `meeting`, `other` 셋 |
| 기대 결과 | `cases/lab01.json` (먼저 열어 읽기) |
<details><summary>▶ 1단계 · 예제 1의 표를 요청문으로</summary>
예제 1에서 정한 **나누는 기준과 유형 정의**를 그대로 요청문에 옮깁니다. 에이전트는 이 기준을 LLM 지시문(systemMessage)에 넣게 됩니다. 유형마다 "어떤 요청이 여기에 드는지" 예를 한두 개씩 적어 보세요.
</details>
<details><summary>▶ 2단계 · LLM의 답을 믿지 않기</summary>
LLM에게 JSON으로 달라고 해도 앞뒤에 다른 글자가 붙거나, 정해지지 않은 유형을 쓰기도 합니다. 결과를 읽는 쪽에서 **무엇을 확인하고, 읽지 못하면 어떻게 할지** 요청문에 적었나요? 업무의 순서(요청에 나온 순서)도 지켜야 테스트의 `tasks.0`, `tasks.1`이 맞습니다.
</details>
<details><summary>▶ 3단계 · 보내기 전 점검표</summary>
- [ ] 파일 경로, 웹훅 경로, 입력 필드
- [ ] 유형 셋과 각각의 정의
- [ ] 결과 JSON 모양 (필드 이름 `count`, `tasks`, `type`, `text`)
- [ ] 빈 요청이면 어떻게 할지 (테스트 파일의 마지막 케이스)
- [ ] 테스트 파일 경로
</details>
<details><summary>▶ 4단계 · 테스트가 실패하면</summary>
실패한 줄과 보고서 경로를 그대로 붙입니다. 업무 개수가 틀렸다면, LLM이 받은 문장과 돌려준 글자를 n8n **Executions**에서 직접 보고 "어느 기준이 모호했는지"를 먼저 묻습니다. 지시문에 예를 더하는 것과 기준을 바꾸는 것은 다른 고침입니다.
</details>
```bash
uv run tools/flow.py push my-work/split.json
uv run tools/flow.py test cases/lab01.json --repeat 3
uv run tools/flow.py call my-split requests/composite.json
```
**관찰**: 예제 1의 내 분석표와 워크플로우의 결과가 같은가요? `composite`의 업무 텍스트에 `T-1002`가 남아 있나요 (다음 Agent가 번호를 알아야 합니다)?
## 과제 · 누가 맡는지, 무엇이 빠졌는지 (15분)
팀장이 요청했습니다.
> "업무마다 어느 Agent가 맡는지 같이 적어 주세요. 그리고 처리에 꼭 필요한 게 빠졌으면 무엇이 빠졌는지도요. 그건 LLM이 짐작하지 말고 규칙으로 정해 주세요."
기대 결과는 `cases/lab01-challenge.json`에 있습니다.
| 케이스 | 기대 |
| --- | --- |
| 복합 요청 | `tasks[].agent`에 `it-agent`, `meeting-agent`. 빠진 것 없음 |
| 회의록 없는 회의 요청 | `agent: meeting-agent`, `missing: ["notes"]` |
| 기타 | `agent: 담당자 연결`, `missing: []` |
| IT · 입력 다 있음 | `agent: it-agent`, `missing: []` |
**먼저 생각해 보기 (3분)**: 업무 유형별로 "꼭 필요한 입력"을 적어 봅니다. [AGENTS.md](AGENTS.md) 3절의 Agent 입력 칸이 힌트입니다.
<details><summary>▶ 1단계 · "규칙으로"의 뜻</summary>
어느 Agent가 맡는지는 유형이 정해지면 **표 하나로** 정해집니다. 빠진 입력도 요청 본문에 그 칸이 비었는지만 보면 됩니다. 둘 다 LLM에게 물어볼 일이 아닙니다. 요청문에서 "LLM 지시문을 고치지 말고 결과를 읽는 코드에서" 정하라고 할 수 있습니다.
</details>
<details><summary>▶ 2단계 · 원래 테스트도</summary>
고치는 요청입니다. 고칠 파일, 새 필드 이름과 예시 값, 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건을 적었나요?
</details>
<details><summary>▶ 3단계 · 하나 더 생각하기</summary>
IT 문의 Agent는 `user`가 없으면 400을 돌려줍니다. 테스트에는 없지만, 이것도 "빠진 입력"으로 표시할지 정해 보세요. Lab 03 과제에서 다시 만납니다.
</details>
```bash
uv run tools/flow.py push my-work/split.json
uv run tools/flow.py test cases/lab01-challenge.json --repeat 3
uv run tools/flow.py test cases/lab01.json
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `check`에 ✘ | 에이전트에게 그 줄을 그대로 붙여 주고 고쳐 달라고 합니다 |
| `push` 후 `call`이 404 | 몇 초 뒤 다시. 계속이면 `uv run tools/flow.py doctor` |
| `count`가 매번 다름 | `--repeat 3` 결과를 보고, 다른 답이 나온 요청을 Executions에서 확인 |
| 에이전트가 계속 엉뚱하게 고침 | 요청을 더 작게 나눕니다. 그래도 안 되면 `cp reference/lab01-example2-split.json my-work/split.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: `my-work/DESIGN.md` 1절 분석표를 채웠다.
- [ ] 예제 2: `cases/lab01.json` 통과.
- [ ] 과제: `cases/lab01-challenge.json` 3번 반복 통과, 원래 테스트도 통과.
- [ ] `my-work/PROMPTS.md`에 요청문을 모았다.
+143
View File
@@ -0,0 +1,143 @@
# Lab 02 · 여러 Agent에게 나눠 맡기는 라우터
- 관련 강의: [2단계 · Multi-Agent 워크플로우 설계](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md)
- 소요 시간: 약 70분 (예제 1 10분 · 예제 2 25분 · 과제 35분)
- 준비: Lab 01의 `my-work/split.json` (없으면 `cp reference/lab01-example2-split.json my-work/split.json`)
- 목표: 이미 있는 Agent 둘의 **계약**을 정리하고, 업무를 유형별로 나눠 그 Agent들을 부르는 **라우터**를 만든다. 같은 일을 **Supervisor** 방식으로도 만들어 비교한다.
```mermaid
flowchart LR
E1["예제 1<br/>Agent 계약 정리"] --> E2["예제 2<br/>라우터"] --> T["과제<br/>Supervisor와 비교"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · Agent 계약 정리 (10분)
**할 일**: 두 Agent를 직접 불러 보고, 에이전트에게 Agent 파일을 읽혀 **계약 표**를 `my-work/DESIGN.md` 2절에 쓰게 한다. 워크플로우는 만들지 않는다.
```bash
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 준비 때 했으면 생략
uv run tools/flow.py call it-agent requests/agent-it.json
uv run tools/flow.py call meeting-agent requests/agent-meeting.json
uv run tools/flow.py call meeting-agent '{"title": "주간 회의"}'
```
| 계약 표의 칸 | |
| --- | --- |
| Agent · 맡는 업무 · 웹훅 | |
| 입력 (필수인 것) | |
| 결과 (필드 이름) | |
| 실패할 때 돌려주는 것 | 400? `route: 담당자 연결`? |
<details><summary>▶ 1단계 · 에이전트에게 무엇을 읽게 하나</summary>
`agents/` 폴더의 두 파일이 근거입니다. 웹훅 노드, 입력을 검사하는 If 노드, 마지막 Respond 노드를 보면 계약이 나옵니다. "워크플로우를 만들지 말고 문서만"이라고 분명히 적습니다.
</details>
<details><summary>▶ 2단계 · 에이전트의 표를 믿기 전에</summary>
위 `call` 세 번의 결과와 에이전트가 쓴 표를 비교합니다. 필드 이름 하나라도 다르면 라우터가 그 값을 읽지 못합니다. [AGENTS.md](AGENTS.md) 3절과도 맞춰 봅니다.
</details>
## 예제 2 · 라우터 (25분)
**만들 것**: Lab 01의 업무 나누기를 바탕으로, 업무마다 담당 Agent를 HTTP로 부르고 결과를 하나로 모아 답하는 워크플로우.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/assistant.json` (오늘 끝까지 이 파일을 고쳐 나감) |
| 바탕 | `my-work/split.json` (그 파일은 그대로 둔다) |
| 웹훅 | POST, 경로 `my-assistant`, 본문 `message`, `user`, 회의록이 있으면 `title`, `notes` |
| 분배 | `it` → IT 문의 Agent, `meeting` → 회의록 Agent, `other` → Agent를 부르지 않고 `담당자 연결` |
| 결과 | `{"count": 3, "results": [{"index", "type", "text", "agent", "route", "answer"}], "reply": "..."}` |
| 기대 결과 | `cases/lab02.json` |
<details><summary>▶ 1단계 · 흐름부터 정하기</summary>
`DESIGN.md` 3절에 mermaid로 흐름을 먼저 그려 봅니다: 나누기 → 업무 하나씩 → 유형별 갈래 → Agent 부르기 → 결과 모양 맞추기 → 모으기 → 답. 이 흐름을 요청문에 그대로 순서대로 적으면 에이전트가 덜 헤맵니다. [2단계 교안](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md) 4절의 노드 이름을 참고합니다.
</details>
<details><summary>▶ 2단계 · Agent에게 무엇을 넘기나</summary>
IT 문의 Agent에는 **업무 텍스트**와 **요청한 사람**, 회의록 Agent에는 **회의록 제목과 원문**을 넘깁니다. 이 중 LLM이 만든 값은 무엇이고, 웹훅 본문에서 그대로 가져와야 하는 값은 무엇인가요? 예제 1의 계약 표를 요청문에 붙이거나 "AGENTS.md 3절의 계약대로"라고 적습니다.
</details>
<details><summary>▶ 3단계 · 결과를 한 모양으로</summary>
두 Agent의 결과 모양이 다릅니다 (IT는 `answer`, 회의록은 `summary`·`actions`). 모으기 전에 **같은 모양**(`index, type, text, agent, route, answer`)으로 맞춰야 `reply`를 만들 수 있습니다. 회의록 결과의 `answer`에 무엇을 넣을지, `actions`도 남길지 정합니다. 결과 순서는 요청에 나온 순서(`index`).
</details>
<details><summary>▶ 4단계 · 보내기 전 점검표</summary>
- [ ] 바탕 파일과 새 파일 경로, 새 웹훅 경로
- [ ] 유형별 분배와 각 Agent 주소 (n8n 안에서 부르는 주소)
- [ ] 넘길 값과 그 값의 출처
- [ ] 결과 한 항목의 모양, 전체 결과 모양
- [ ] `cases/lab02.json`
</details>
```bash
uv run tools/flow.py push my-work/assistant.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py call my-assistant requests/composite.json
```
**관찰**: n8n **Executions**에서 `my-assistant` 한 번의 실행을 엽니다. `Call IT agent` 노드는 항목 몇 개를 처리했나요(IT 업무가 2개면)? 같은 시간에 `it-agent`, `meeting-agent`의 실행도 따로 생겼나요? `test` 결과에서 복합 요청과 단일 요청의 시간을 비교해 메모합니다 (Lab 03에서 씁니다).
## 과제 · Supervisor로도 만들어 비교 (35분)
팀장이 물었습니다.
> "업무를 미리 나누지 말고, AI 하나가 알아서 필요한 Agent를 부르게 하면 더 간단하지 않아요? 해 보고 어느 쪽이 나은지 알려 주세요."
**만들 것**: 같은 요청을 받아, AI Agent 하나(Supervisor)가 두 Agent를 **Tool로** 골라 부르고 답을 쓰는 워크플로우.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/supervisor.json` (라우터와 별도) |
| 웹훅 | POST, 경로 `my-supervisor`, 입력은 라우터와 같음 |
| 결과 | `{"reply": "...", "agents_used": ["it-agent", ...]}` (실제로 부른 Agent, 겹치지 않게) |
| 기대 결과 | `cases/lab02-challenge.json` |
**먼저 생각해 보기 (3분)**: 라우터에서 코드가 하던 일(나누기, 분배, 모으기) 중 Supervisor에서는 누가 하나요?
<details><summary>▶ 1단계 · Agent를 Tool로</summary>
67일차에 FAQ API를 HTTP Request **Tool**로 붙였던 것처럼, 이번에는 다른 Agent의 웹훅을 Tool로 붙입니다. Tool 설명(`toolDescription`)이 "언제 이 Agent를 부를지"의 근거가 됩니다.
</details>
<details><summary>▶ 2단계 · LLM에게 맡기면 안 되는 값</summary>
Tool의 입력 중 **LLM이 채워도 되는 값**(질문 문장)과 **채우면 안 되는 값**(요청한 사람, 회의록 원문)을 나눕니다. 회의록 원문을 LLM이 다시 써서 넘기면 무엇이 달라질 수 있을까요?
</details>
<details><summary>▶ 3단계 · agents_used는 누가 쓰나</summary>
`agents_used`를 LLM의 답에서 찾게 할 수도, Agent의 Tool 호출 기록에서 찾게 할 수도 있습니다. 67일차 Lab 01 과제의 `sources`와 같은 질문입니다.
</details>
<details><summary>▶ 4단계 · 비교하기</summary>
```bash
uv run tools/flow.py test cases/lab02-challenge.json --repeat 3
uv run tools/flow.py test cases/lab02.json --webhook my-supervisor # 라우터의 테스트를 Supervisor에
```
`DESIGN.md` 3절 아래에 표로 정리합니다: 복합 요청 시간, 같은 요청 3번의 결과가 같은가, 결과 모양(계약)이 테스트에 맞는가, Agent를 하나 더 붙일 때 고칠 곳. 두 번째 명령이 왜 실패하는지도 적습니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 응답이 500, 아무 결과 없음 | 부른 Agent가 오류를 돌려줬을 수 있습니다. Executions에서 어느 노드가 빨간지 보고 그 내용을 에이전트에게 붙입니다 (Lab 03에서 다룸) |
| `Call ... agent`가 404 | 그 Agent가 게시되지 않음: `uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json` |
| `results`가 한 개뿐 | 모으기(Merge·Aggregate) 전에 항목이 하나로 줄었는지 Executions에서 확인 |
| 라우터가 계속 안 됨 | `cp reference/lab02-example2-router.json my-work/assistant.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: 두 Agent를 직접 불렀고, 계약 표가 `call` 결과와 맞다.
- [ ] 예제 2: `cases/lab02.json` 통과.
- [ ] 과제: `cases/lab02-challenge.json` 통과, 라우터와 Supervisor 비교 표.
+133
View File
@@ -0,0 +1,133 @@
# Lab 03 · 빠진 입력, 꺼진 Agent, 어려운 테스트
- 관련 강의: [3단계 · 검증과 장애 격리, 확장 고려](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md)
- 소요 시간: 약 80분 (예제 1 20분 · 예제 2 20분 · 과제 40분)
- 준비: Lab 02의 `my-work/assistant.json` (없으면 `cp reference/lab02-example2-router.json my-work/assistant.json` 후 push)
- 목표: Multi-Agent에서 **한 곳의 문제가 전체 답을 망치지 않게** 만든다. 테스트로 문제를 드러내고, 고치고, 측정값으로 확장 계획을 쓴다.
```mermaid
flowchart LR
E1["예제 1<br/>빠진 입력"] --> E2["예제 2<br/>Agent가 꺼졌을 때"] --> T["과제<br/>어려운 테스트 · 확장 계획"]
```
> 복사해 붙일 요청문은 없습니다. 이번 Lab은 **실패를 먼저 보고** 요청문을 씁니다.
## 예제 1 · 빠진 입력 (20분)
먼저 그대로 돌려 봅니다.
```bash
uv run tools/flow.py push my-work/assistant.json
uv run tools/flow.py test cases/lab03.json
```
| 케이스 | 기대 |
| --- | --- |
| 회의록 없는 회의 요청 | HTTP 200, `results.0.route: 입력 필요`, 답에 "회의록"을 보내 달라는 말 |
| IT 둘 · 둘 다 답 | `count: 2`, 두 업무 모두 `자동 답변` |
| 복합 요청 · 오류 없음 | `count: 3`, 어느 결과에도 `오류` 없음 |
| 기타만 · 빈 요청 | 담당자 연결 · 400 |
<details><summary>▶ 1단계 · 보고서 읽기</summary>
실패한 케이스의 `status`와 `response`를 `runs/lab03-....json`에서 봅니다. 응답이 500이면 라우터 **전체**가 멈춘 것입니다. n8n **Executions**에서 어느 노드가 빨간지, 그 노드가 받은 응답이 무엇인지 확인합니다. 회의록 Agent의 계약(예제 1의 표)에서 `notes`가 없을 때 무엇을 돌려준다고 했나요?
</details>
<details><summary>▶ 2단계 · 어디서 막을까</summary>
두 가지 방법이 있습니다: (가) Agent를 부르고 오류가 오면 처리, (나) **부르기 전에** 빠진 입력을 보고 Agent를 부르지 않음. Lab 01 과제에서 만든 `missing`이 있다면 어느 쪽이 쉬운가요? 직원에게 무엇이라고 답해야 다음에 제대로 보낼 수 있을까요?
</details>
<details><summary>▶ 3단계 · 요청문 점검</summary>
- [ ] 실패한 케이스 이름, 보고서 경로를 그대로 붙였다
- [ ] 원인 설명을 먼저 요구했다
- [ ] 새 결과 `route` 값과 답 문장을 정했다
- [ ] `cases/lab02.json`도 계속 통과해야 한다고 적었다
</details>
## 예제 2 · Agent가 꺼졌을 때 (20분)
회의록 Agent를 일부러 끕니다.
```bash
uv run tools/flow.py off agents/meeting-agent.json
uv run tools/flow.py test cases/lab03-down.json
```
| 케이스 (회의록 Agent 꺼짐) | 기대 |
| --- | --- |
| 복합 요청 | HTTP 200, `count: 3`. IT 업무는 그대로 `자동 답변`("처리 중"), 회의록 업무는 `담당자 연결`, 답에 "응답하지 않아" |
| 회의록만 | HTTP 200, `담당자 연결` |
| IT만 | 그대로 `자동 답변` |
**먼저 생각해 보기**: 고치기 전 복합 요청의 응답에 T-1002 상태가 들어 있나요? 회의록 Agent 하나가 꺼졌는데 IT 답까지 사라졌다면 왜일까요?
<details><summary>▶ 1단계 · 오류를 "결과"로 바꾸기</summary>
Agent를 부르는 노드가 404·500을 받으면 n8n은 기본으로 워크플로우를 멈춥니다. 멈추지 않고 그 응답을 다음 노드로 넘기는 설정이 있습니다 ([AGENTS.md](AGENTS.md) 5절 HTTP Request). 넘겨받은 뒤 결과 칸(`route`, `answer`)이 비어 있으면 무엇으로 채울지 정합니다.
</details>
<details><summary>▶ 2단계 · 얼마나 기다릴까</summary>
꺼진 Agent는 바로 404가 오지만, **느린** Agent는 응답이 안 올 수도 있습니다. 직원이 얼마나 기다려도 될지 정하고 요청문에 적습니다.
</details>
<details><summary>▶ 3단계 · 다시 켜고 확인</summary>
고친 뒤 `lab03-down`을 통과하면, 회의록 Agent를 **다시 켜고** 나머지 테스트도 봅니다.
```bash
uv run tools/flow.py push agents/meeting-agent.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py test cases/lab03.json --repeat 3
```
</details>
## 과제 · 어려운 테스트 쓰고 고치기, 확장 계획 (40분)
팀장이 요청했습니다.
> "다음 주부터 전 직원이 씁니다. 지금 테스트는 우리가 고친 것만 확인하잖아요. 실제로 들어올 만한 어려운 요청으로 테스트를 더 만들고, 통과시키세요. 그리고 사람이 몰리면 어떻게 할지 숫자로 설명해 주세요."
1. `cases/my-cases.json`에 어려운 케이스 **5개 이상**을 직접 씁니다 (형식은 [AGENTS.md](AGENTS.md) 8절, `body`로 직접 써도 됨).
2. 지금 라우터로 돌려 실패를 보고, 에이전트에게 고치게 합니다 (테스트의 기대 결과는 바꾸지 않음).
3. `my-work/DESIGN.md` 5·6절을 채웁니다. 6절의 시간은 **내 `runs/` 보고서의 값**으로 씁니다.
```bash
uv run tools/flow.py test cases/my-cases.json --repeat 3
uv run tools/flow.py test cases/lab03.json
uv run tools/flow.py test cases/lab02.json
```
<details><summary>▶ 1단계 · 어디서 어려운 요청이 나오나</summary>
다섯 곳에서 찾아봅니다: ① 입력 칸이 비거나 이상함 ② 업무 수가 많거나 섞임 ③ 67일차에 지킨 규칙(남의 접수 건)이 라우터를 거쳐도 지켜지나 ④ 붙어 온 자료와 요청이 안 맞음 ⑤ 다른 언어·다른 표기. 각 케이스의 기대 결과를 **업무 규칙 한 줄**로 먼저 적고 테스트로 옮깁니다.
</details>
<details><summary>▶ 2단계 · 좋은 케이스의 조건</summary>
- 기대 결과가 **하나로** 정해진다 (사람마다 다르게 판단하면 테스트가 아님)
- 통과해도 의미가 있다: 지금 통과하는 케이스도 "고칠 때 깨지지 않게" 남겨 둔다
- `not_contains`로 **나오면 안 되는 것**(남의 이름, 오류)을 확인한다
</details>
<details><summary>▶ 3단계 · 확장 계획 생각하기</summary>
보고서에서 IT 하나, 회의록 하나, 복합 요청의 시간을 찾습니다. 복합 요청 시간은 각 업무 시간의 합에 가까운가요, 가장 긴 것에 가까운가요? 21명이 동시에 보내면 n8n 하나가 몇 개를 동시에 처리해야 하나요? [3단계 교안](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) 5절의 수직·수평 확장 표를 보고 내 측정값에 맞는 첫 조치를 고릅니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `off` 후 다른 테스트도 이상함 | 회의록 Agent를 다시 켭니다: `uv run tools/flow.py push agents/meeting-agent.json` |
| 고쳤는데 `lab02`가 깨짐 | 결과 모양(계약)이 바뀌었는지 확인: `results.N`의 필드 이름 |
| 계속 안 됨 | `cp reference/lab03-example2-isolated.json my-work/assistant.json` 후 push로 과제를 이어 갑니다 |
## 확인
- [ ] 예제 1: `cases/lab03.json` 통과 (빠진 입력은 Agent를 부르지 않고 `입력 필요`).
- [ ] 예제 2: 회의록 Agent가 꺼져도 `lab03-down` 통과, 다시 켠 뒤 `lab02`·`lab03` 통과.
- [ ] 과제: `cases/my-cases.json` 5개 이상 3번 반복 통과, `DESIGN.md` 5·6절에 측정값.
- [ ] [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웠다.
+186
View File
@@ -0,0 +1,186 @@
# 68일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-20 (화) · 68일차
- 주제: 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계
- 실습 폴더: [Last3Mon/68](README.md) · 강의 자료: [Lecture/68](../../Lecture/68/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + 67일차 데이터 API + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · Agent 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>업무 나누기"] --> L2["3~4교시<br/>Lab 02<br/>라우터 · Supervisor"] --> L3["5~6교시<br/>Lab 03<br/>빠진 입력 · 꺼진 Agent"] --> V["7~8교시<br/>tflow"] --> E["마친 뒤<br/>남기기 · 69 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 15분 | 자료 받기 · n8n · 데이터 API · 두 Agent 게시 · doctor | [PREP](../../Lecture/68/00-DAY68-PREP.md) (65~67일차를 못 했다면 [PREPARE-68-FROM-65](PREPARE-68-FROM-65.md), 약 30분) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (복합 업무 시나리오 분석) | [개요](../../Lecture/68/00-DAY68-OVERVIEW.md), [1단계](../../Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md) | |
| 2 | 10:00~10:50 | Lab 01 예제 1 · 2 · 과제 | [Lab 01](LAB-01-Q-SCENARIO-AND-SPLIT.md) | `lab01-challenge` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 · Lab 02 예제 1 · 2 | [2단계](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md), [Lab 02](LAB-02-Q-ROUTER-AGENTS.md) | `lab02` 통과 |
| 4 | 12:00~12:50 | Lab 02 과제 35분 (Supervisor 비교) · 풀이 | Lab 02 | `lab02-challenge` · 비교 표 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 · Lab 03 예제 1 · 2 | [3단계](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md), [Lab 03](LAB-03-Q-VERIFY-AND-ISOLATE.md) | `lab03` · `lab03-down` 통과 |
| 6 | 15:00~15:50 | Lab 03 과제 40분 · 풀이 | Lab 03 | 내 케이스 5개 · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tflow` 각자 만들기 | [VIBE-Q-TFLOW](VIBE-Q-TFLOW.md) | R1~R5 · `pytest` |
| 8 | 17:00~17:50 | 발표 · 강사 정리 · 강사 바이브 코딩 · 팁 · 69일차 예고 | [진행](../../Lecture/68/04-VIBE-CODING.md) | |
## 1. 전날 준비 (약 15분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 데이터 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/67/data-api
docker compose up -d
curl -s localhost:8800/health # {"status": "ok"}
```
- LLM은 **OpenAI API 키**(`Last3Mon/.env`) 하나로 씁니다. n8n은 `start`로 켭니다 (`up -d`는 Ollama 컨테이너를 다시 만듦).
### 1.3 오늘 부를 Agent 두 개 게시 (만들지 않고 게시만)
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 수업 준비 때 약 19초
uv run tools/flow.py call it-agent requests/agent-it.json # route 자동 답변, T-1002 처리 중
uv run tools/flow.py call meeting-agent requests/agent-meeting.json # count 4
```
`! unpublished ...`가 나오면 같은 경로를 쓰던 예전 워크플로우를 게시 해제한 것입니다 (정상).
### 1.4 준비 확인
```bash
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.5 미리 읽기 (선택, 5분)
[PREP 4절](../../Lecture/68/00-DAY68-PREP.md): "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요." 이 문장에 업무가 몇 개이고, 각각 누가 맡으면 좋을지.
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, **67일차 `my-work/it-agent.json`** (7교시에 씀).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다. 두 Agent는 게시된 채로 남아 있습니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/68`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`push`·`call`·`test`).
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서
```mermaid
flowchart LR
A["① 기대 결과 읽기<br/>cases/*.json"] --> B["② 요청문을<br/>메모장에 먼저"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ PROMPTS.md"]
```
- 요청문 다섯 가지(파일 · 입력 · 처리 · 결과 모양 · 기대 결과), 한 번에 한 가지 변화.
- 막히면 `▶`를 하나씩, 그래도 안 되면 `reference/`로 이어 가기.
- 오늘은 **두 Agent(`it-agent`, `meeting-agent`)를 고치지 않습니다.** 부르기만 합니다.
## 4. 1~2교시 · 1단계 · 복합 요청을 업무로 나누기
[Lab 01](LAB-01-Q-SCENARIO-AND-SPLIT.md) (약 40분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분, 에이전트 없이) | `cp templates/MULTI-AGENT-DESIGN-TEMPLATE.md my-work/DESIGN.md` 후 1절 업무 분석표를 **직접** 채우기 (`cat requests/composite.json`) | | 표 |
| 예제 2 (15분) | 요청 문장을 업무 목록으로 **나누기만** (Agent는 아직 안 부름) | `my-work/split.json` · `my-split` | `push my-work/split.json` → `test cases/lab01.json --repeat 3`, `call my-split requests/composite.json` |
| 과제 (15분) | 업무마다 맡을 Agent, 빠진 입력은 LLM이 아니라 규칙으로 | `my-work/split.json` 고치기 | `test cases/lab01-challenge.json --repeat 3`, `test cases/lab01.json` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 내 분석표와 워크플로우 결과가 같은지, 업무 글에 `T-1002`가 남아 있는지.
## 5. 3~4교시 · 2단계 · 여러 Agent에게 나눠 맡기기
[Lab 02](LAB-02-Q-ROUTER-AGENTS.md) (약 70분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분) | 두 Agent를 직접 불러 계약(입력 · 결과) 표 정리 | | `call it-agent requests/agent-it.json`, `call meeting-agent requests/agent-meeting.json`, `call meeting-agent '{"title": "주간 회의"}'` |
| 예제 2 (25분) | 라우터: 업무마다 담당 Agent를 HTTP로 부르고 결과를 하나로 | `my-work/assistant.json` · `my-assistant` (바탕 `split.json`은 그대로 둠) | `push my-work/assistant.json` → `test cases/lab02.json`, `call my-assistant requests/composite.json` |
| 과제 (35분, 4교시) | Supervisor: AI Agent 하나가 두 Agent를 Tool로 골라 부르기, 라우터와 비교 | `my-work/supervisor.json` · `my-supervisor` | `test cases/lab02-challenge.json`, `DESIGN.md` 비교 표 |
- 볼 것: Executions에서 `my-assistant` 한 번에 `it-agent` · `meeting-agent` 실행이 따로 생기는지, 복합 요청과 단일 요청의 시간 (Lab 03에서 씀).
## 6. 5~6교시 · 3단계 · 빠진 입력, 꺼진 Agent, 어려운 테스트
[Lab 03](LAB-03-Q-VERIFY-AND-ISOLATE.md) (약 80분).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (20분) | 빠진 입력이면 Agent를 부르지 않고 `입력 필요` | `push my-work/assistant.json` → `test cases/lab03.json` |
| 예제 2 (20분) | 회의록 Agent를 끄고도 나머지 업무는 답하게 | `off agents/meeting-agent.json` → `test cases/lab03-down.json`, 고친 뒤 **다시 켜기** `push agents/meeting-agent.json` → `test cases/lab02.json`, `test cases/lab03.json` |
| 과제 (40분, 6교시) | 어려운 요청 5개 이상 `cases/my-cases.json`, 통과시키기, 사람이 몰릴 때의 확장 계획을 숫자로 (`DESIGN.md` 5·6절) | `test cases/my-cases.json --repeat 3`, `test cases/lab03.json`, `test cases/lab02.json` |
- 보고서: `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md` 후 채우기.
- `off` 뒤 다른 테스트가 이상하면 회의록 Agent가 꺼져 있는 것입니다. `push agents/meeting-agent.json`.
## 7. 7~8교시 · 바이브 코딩 `tflow`
[VIBE-Q-TFLOW](VIBE-Q-TFLOW.md): n8n 워크플로우 JSON을 트리와 mermaid로 보여 주는 TUI. 67일차 `it-agent.json`과 오늘 `my-work/*.json`을 열어 봅니다.
```bash
mkdir -p ~/vibe/tflow && cd ~/vibe/tflow
git init
codex # 또는 claude, agy
```
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:03 | 0. 준비 | |
| 16:03~16:15 | 1. 글자로 트리와 mermaid (R1 · R4) | 갈래가 보이고 끝없이 반복되지 않음, `--mermaid` |
| 16:15~16:30 | 2. TUI · 상세 · 경고 (R2 · R5) | Agent 아래 모델 1 · Tool 2, 망가진 파일 경고 |
| 16:30~16:43 | 3. 한국어 설명과 캐시 (R3) | 아래 `export` 후, 두 번째는 캐시에서 |
| 16:43~16:50 | 4. 정리 | `uv run pytest -q`, `PROMPTS.md` |
```bash
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
```
- 8교시: 1~2명 발표, 강사 정리 · 시연 · 팁, 69일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `PROMPTS.md`, `DESIGN.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 |
| 회의록 Agent가 켜져 있는지 (`call meeting-agent requests/agent-meeting.json`) | 꺼 둔 채로 두지 않기 |
| **`~/vibe/tflow` 지우지 않기** | 69일차에 오늘 만든 워크플로우를 여는 데 씀 |
| 69일차 준비: [00-DAY69-PREP](../../Lecture/69/00-DAY69-PREP.md) 1~2절 | 내일은 데이터 API를 쓰지 않음 (켜 두어도 됨) |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| `network day65-n8n_default not found` | n8n이 꺼져 있음. 1.2의 n8n부터 |
| `call it-agent`가 404 | 1.3 다시 (`push agents/...`) |
| doctor의 agent webhook ✘ | 1.3 다시 |
| `check`에 ✘ | 그 줄을 그대로 에이전트에게 |
| `status 500`이 30초 넘게 | OpenAI 쪽 일시 오류일 수 있음. 한 번 더 |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 두 Agent 게시, `doctor` → `ready`
- [ ] Lab 01: `lab01`, `lab01-challenge`
- [ ] Lab 02: `lab02`, `lab02-challenge`, 라우터 · Supervisor 비교 표
- [ ] Lab 03: `lab03`, `lab03-down`, 다시 켠 뒤 `lab02` · `lab03`, 내 케이스 5개, 보고서
- [ ] `tflow`: R1~R5, `pytest`, 단계마다 commit
- [ ] 69일차 준비
+173
View File
@@ -0,0 +1,173 @@
# 68일차를 처음 시작하는 수강생 준비 (65~67일차를 못 했을 때)
- 대상: 65·66·67일차 수업이나 준비를 하지 못해, 오늘(68일차) 실습 환경이 없는 수강생
- 목표: **최소 명령**으로 68일차 `doctor`가 `ready`가 되게 하기. 지난 실습 내용은 나중에 따라잡습니다.
- 왜 65일차부터인가: 68일차는 **65일차에 설치한 n8n**(`~/day65-n8n`)과 **67일차의 데이터 API**를 그대로 씁니다. 66·67일차 실습 결과물은 없어도 됩니다 (오늘 필요한 Agent 둘은 `agents/`에 들어 있음).
```mermaid
flowchart LR
Z["0. 저장소 · uv"] --> A["1. Docker"] --> B["2. API 키 .env"] --> C["3. n8n 설치"] --> D["4. Credential"] --> E["5. 데이터 API"] --> F["6. Agent 둘 게시"] --> G["7. CLI 에이전트"] --> H["8. doctor<br/>ready"]
```
## 어디부터 하나
| 나는 | 시작할 절 | 걸리는 시간 |
| --- | --- | --- |
| 64일차도 못 함 (WSL에 저장소 · uv가 없음) | 0절 | 약 40분 (이미지 다운로드 포함) |
| 64일차는 함, 65일차부터 못 함 | 1절 (Docker가 있으면 2절) | 약 30분 |
| 65일차는 함 (n8n · Credential 있음), 66·67일차만 못 함 | 5절 | 약 10분 |
- 모든 명령은 **WSL(Ubuntu) 터미널**에서 합니다.
- 각 절의 자세한 설명과 문제 해결은 [65일차 SETUP-N8N](../65/SETUP-N8N.md), [68일차 실습 준비](../../Lecture/68/00-DAY68-PREP.md)에 있습니다.
---
## 0. 수업 저장소와 uv (64일차를 못 했을 때만, 3분)
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh # 설치 뒤 터미널을 닫고 다시 열기
cd ~ && git clone https://gl.isitsoft.kr/publicproj/aio-01d4-stu.git
uv --version
```
이미 저장소가 있으면 `cd ~/aio-01d4-stu && git pull`만 합니다.
## 1. Docker (65일차를 못 했을 때, 10분)
```bash
curl -fsSL "https://gl.isitsoft.kr/publicproj/install-sh/-/raw/main/install-docker.sh?ref_type=heads" | bash
```
WSL 터미널을 **모두 닫고 다시 열고** (docker 그룹 적용):
```bash
sudo systemctl enable --now docker
docker run --rm hello-world # "Hello from Docker!"
```
`docker version`의 Server 줄이 나오면 이미 설치된 것이니 넘어갑니다.
## 2. API 키 `.env` (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon
[ -f .env ] || cp .env.example .env
nano .env # OPENAI_API_KEY= 뒤에 과정에서 받은 키 (Ctrl+O, Enter, Ctrl+X)
grep -c '^OPENAI_API_KEY=sk-' .env # 1 이면 됨 (cat으로 열어 화면에 띄우지 않기)
```
키는 이 파일 한 곳에만 둡니다. 문서 · 워크플로우 · 채팅에 붙여 넣지 않습니다.
## 3. n8n 설치 (이미지 다운로드 5~15분)
```bash
cd ~
curl -fsSL https://get.n8n.io | N8N_DIR=./day65-n8n sh -s -- --version 2.42.6
```
```text
✔ Started n8n 2.42.6 and sandbox services
n8n is running at: http://localhost:5678
```
- 폴더 이름(`day65-n8n`)과 버전(`2.42.6`)을 **바꾸지 않습니다.** 데이터 API와 `flow.py`가 이 이름을 찾습니다.
- 설치 스크립트가 n8n을 바로 켭니다. Windows 브라우저에서 http://localhost:5678 을 열고 관리자 계정(이메일 · 이름 · 비밀번호, 내 노트북 안에만 저장)을 만듭니다.
- **`Last3Mon/65/n8n`에서 `docker compose up -d`를 하지 않습니다.** 65일차 자료에는 Ollama(약 4.2GB)가 들어 있는데, 66일차부터 Ollama는 쓰지 않습니다. 켜고 끄기는 `start` / `stop`만 씁니다 (아래 "다음부터 켜기").
## 4. n8n Credential 등록 (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n
bash register-credentials.sh
```
`Successfully imported 2 credentials.`가 나오면 됩니다. 함께 만들어지는 `Ollama local`은 쓰지 않습니다 (그대로 둠).
## 5. 데이터 API 켜기 (1분)
68일차는 67일차의 사내 데이터 API(FAQ · 접수 현황)를 그대로 씁니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/67/data-api
docker compose up -d --force-recreate
curl -s localhost:8800/health # {"status": "ok"}
```
`network day65-n8n_default not found` → n8n이 꺼져 있음: `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start` 후 다시.
## 6. 오늘 부를 Agent 둘 게시 (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 약 20초
uv run tools/flow.py call it-agent requests/agent-it.json # route 자동 답변
uv run tools/flow.py call meeting-agent requests/agent-meeting.json # count 4
```
- `it-agent`는 67일차 Lab 03 완성본, `meeting-agent`는 66일차 회의록 → 할 일입니다. 오늘은 만들지 않고 **불러 쓰기만** 합니다.
## 7. CLI 코딩 에이전트 (10분)
아래 중 **하나 이상**이 WSL 터미널에서 실행되고 로그인되어 있어야 합니다.
| 에이전트 | 실행 | 확인 |
| --- | --- | --- |
| Codex CLI | `codex` | `codex --version` |
| Claude Code | `claude` | `claude --version` |
| Antigravity CLI | `agy` | `agy --version` |
- 처음 실행하면 브라우저 로그인 안내가 나옵니다. 끝까지 진행한 뒤 "안녕"이라고 물어 답이 오는지 봅니다.
- 설치가 안 되어 있으면 수업 전 IT 문의 시간에 강사에게 알려 주세요.
## 8. 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py doctor
```
```text
✔ n8n install folder /home/<사용자>/day65-n8n
✔ n8n container day65-n8n-n8n-1
✔ n8n http://localhost:5678
✔ data API http://localhost:8800
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ agent webhook /it-agent
✔ agent webhook /meeting-agent
✔ CLI coding agent: codex, claude
ready
```
| ✘ 항목 | 할 일 |
| --- | --- |
| n8n install folder · n8n container | 3절 (이미 설치했다면 `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`) |
| data API · n8n → data-api | 5절 |
| credential | 4절 |
| agent webhook | 6절 |
| CLI coding agent | 7절 |
`ready`가 나오면 [68일차 README](README.md)부터 수업을 따라갑니다.
---
## 다음부터 켜기 (날마다)
```bash
cd ~/aio-01d4-stu && git pull
cd Last3Mon/65/n8n && docker compose start # n8n
cd ~/aio-01d4-stu/Last3Mon/<오늘>/data-api && docker compose up -d --force-recreate # 그날의 데이터 API (68일차는 67)
```
수업이 끝나면 `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose stop`. `docker compose down -v`는 워크플로우 · 계정 · Credential까지 지우므로 쓰지 않습니다.
## 놓친 날 따라잡기 (나중에)
| 일차 | 볼 것 |
| --- | --- |
| 65 | [README](../65/README.md) · n8n 화면에서 첫 워크플로우 (Lab 02A) |
| 66 | [README](../66/README.md) · 정답본 `LAB-0x-A-*.md`, 과제 정답 `answers/` |
| 67 | [README](../67/README.md) · 정답본 `LAB-0x-A-*.md`, 과제 정답 `answers/` |
66 · 67일차 정답은 그날 폴더에서 `uv run tools/flow.py push answers/<파일>.json`으로 올려 보고 `test`로 확인할 수 있습니다.
+88
View File
@@ -0,0 +1,88 @@
# 68일차 실습 · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 (바이브 코딩)
- 일자: 2026-10-20 (68일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/68](../../Lecture/68/README.md)
- 하루 수강 안내: [LECTURE-68.md](LECTURE-68.md) (수업 준비부터 마칠 때까지 순서대로)
- 67일차처럼 n8n 노드를 손으로 놓지 않습니다. CLI 코딩 에이전트(Codex · Claude Code · Antigravity)에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다.
## 오늘 만드는 업무: 사내 업무 도우미 (여러 Agent에게 나눠 맡기기)
직원 요청 하나에 업무가 여럿 섞여 있어도, 업무를 나누어 담당 Agent(67일차 IT 문의, 66일차 회의록)에게 보내고 결과를 하나로 모아 답합니다.
```mermaid
flowchart LR
W(["Assistant webhook<br/>message · user · notes"]) --> S{{"Split tasks<br/>(LLM)"}} --> P["Parse tasks<br/>담당 · 빠진 입력"] --> SW{"Route by type"}
SW -->|it| A1["Call IT agent"] --> IT{{"IT 문의 Agent<br/>agents/it-agent"}}
SW -->|meeting| A2["Call meeting agent"] --> MT{{"회의록 Agent<br/>agents/meeting-agent"}}
SW -->|other| O["담당자 연결"]
SW -->|needs input| N["입력 필요"]
A1 & A2 & O & N --> M["Collect results"] --> B["Build reply"] --> R(["통합 응답<br/>count · results · reply"])
```
## 일하는 방식 (67일차와 같음)
```mermaid
flowchart LR
Q["요청문<br/>(내가 씀)"] --> G["CLI 에이전트<br/>my-work/*.json · check"]
G --> U["push<br/>(내가 실행)"] --> T["test<br/>(내가 실행)"]
T -->|✘ 보고서| G
T -->|✔ Passed| D["완료"]
```
| 누가 | 무엇을 |
| --- | --- |
| 나 | 업무 분석, 요청문 쓰기, 기대 결과(테스트) 정하기, push·test 실행, 결과 판단 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기, 실패 원인 찾기 |
| `tools/flow.py` | 형식 검사(check), n8n에 가져오기·게시(push), 게시 해제(off), 요청 보내기(call), 테스트(test) |
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TFLOW.md`)에는 **복사해 붙일 요청문이 없습니다.** 만들 것 · 조건 · 기대 결과만 있고, 요청문은 직접 씁니다.
- 막히면 `▶` 를 하나씩 펼칩니다. 뒤로 갈수록 구체적이 되지만 정답 요청문은 나오지 않습니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 두고, 수업 끝에 옆 사람과 비교합니다.
## 구성
```text
Last3Mon/68/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (CLAUDE.md가 이 파일을 불러옴)
├── LAB-01-Q-SCENARIO-AND-SPLIT.md # 1단계 · 복합 요청 분석표 → 업무 나누기 → 담당·빠진 입력
├── LAB-02-Q-ROUTER-AGENTS.md # 2단계 · Agent 계약 → 라우터 → Supervisor와 비교
├── LAB-03-Q-VERIFY-AND-ISOLATE.md # 3단계 · 입력 부족 → Agent 장애 격리 → 어려운 테스트 · 확장 계획
├── VIBE-Q-TFLOW.md # 7교시 바이브 코딩 · tflow (워크플로우 JSON 트리 TUI)
├── *-A-*.md # 강사용 정답본 (학생 저장소에는 없음)
├── agents/ # 이미 만든 Agent: it-agent (67일차), meeting-agent (66일차)
├── tools/flow.py # doctor · check · push · off · call · test
├── cases/ # lab01 · lab01-challenge · lab02 · lab02-challenge · lab03 · lab03-down
├── requests/ # 요청 10종 (message · user · title · notes)
├── reference/ # 막혔을 때 이어 가는 워크플로우 (예제 단계)
├── templates/ # 설계서 · 테스트 보고서
├── my-work/ # 내 결과물 (워크플로우 JSON은 git에 올라가지 않음)
└── runs/ # 테스트 보고서 (자동 생성)
```
## 준비
[실습 준비](../../Lecture/68/00-DAY68-PREP.md)를 끝내 주세요. 65~67일차를 못 했다면 [PREPARE-68-FROM-65.md](PREPARE-68-FROM-65.md)부터 합니다. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # 65일차 n8n (Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기)
cd ~/aio-01d4-stu/Last3Mon/67/data-api && docker compose up -d # 67일차 데이터 API
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json
uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py doctor` | n8n · 데이터 API · Credential · 두 Agent 게시 · CLI 에이전트 확인 | |
| `uv run tools/flow.py check my-work/x.json` | 노드 종류·버전·연결·Credential·API 키 노출 검사 | 1초 미만 |
| `uv run tools/flow.py push a.json b.json` | 여러 파일을 가져오기 → 게시 → n8n 재시작 한 번 → 웹훅 준비 확인 | 두 Agent 함께 약 19초 |
| `uv run tools/flow.py off agents/meeting-agent.json` | 게시 해제 (Agent가 꺼진 상황 만들기). 다시 켜기는 `push` | |
| `uv run tools/flow.py call my-assistant requests/composite.json` | 요청 한 번 보내고 응답 보기 | 복합 요청 약 5~7초 |
| `uv run tools/flow.py test cases/lab02.json` | 테스트 실행, `Passed x/y`, 보고서를 `runs/`에 저장 | 케이스당 1~7초 |
+145
View File
@@ -0,0 +1,145 @@
# 7교시 바이브 코딩 · `tflow` 만들기
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/)
- 결과물: n8n 워크플로우 JSON을 읽어 **노드와 연결을 트리로** 보여 주고, 노드마다 **하는 일을 한국어로** 설명하는 터미널 프로그램(TUI). 에이전트가 만든 n8n 파일을 열지 않고도 확인하는 도구입니다.
```text
$ tflow ~/aio-01d4-stu/Last3Mon/68/agents/it-agent.json
┌ tflow · 68 IT agent (from day 67) · 노드 14 · 연결 14 ──────────────────┐
│ ▼ IT question webhook [webhook POST /it-agent] │ IT help agent │
│ ▼ Has question? [if] │ 종류: AI Agent │
│ ├ true ▼ Has user? [if] │ 모델: OpenAI chat model│
│ │ ├ true ▼ IT help agent [agent] │ Tool: Search FAQ, │
│ │ │ ◇ model OpenAI chat model │ Get ticket │
│ │ │ ◇ tool Search FAQ · Get ticket │ 하는 일: 사내 IT 질문에│
│ │ │ ▼ Check answer [code] │ FAQ·접수 현황 Tool로 … │
│ ... │ │
└──────────────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 하나씩 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 요구 사항
| 번호 | 요구 | 확인 방법 |
| --- | --- | --- |
| R1 | 워크플로우 JSON을 읽어, 웹훅(시작 노드)에서 `main` 연결을 따라가는 **트리**로 보여 준다. If·Switch의 갈래 이름(true/false, 출력 이름)을 붙인다 | `tflow agents/it-agent.json` |
| R2 | AI 연결(`ai_languageModel`, `ai_tool`)은 따로 표시한다 (어느 Agent의 모델·Tool인지) | it-agent의 `IT help agent` 아래 |
| R3 | 노드를 고르면 옆에 **종류 · 중요한 설정 · 하는 일(한국어)** 을 보여 준다. 하는 일은 LLM이 쓰고, 한 번 쓴 설명은 다시 쓰지 않는다 | 노드를 고르기 |
| R4 | `tflow <파일> --mermaid`로 mermaid 흐름도를 출력한다 (마크다운에 붙여 그대로 그려짐) | 출력을 `.md`에 붙여 보기 |
| R5 | 어디에도 연결되지 않은 노드, 없는 노드를 가리키는 연결을 **경고**로 보여 준다 | 직접 망가뜨린 파일 |
알아 둘 것:
- 파일 모양: `nodes`(노드 목록: `name`, `type`, `parameters` …)와 `connections`. 연결은 `{"보내는 노드": {"main": [[{"node": "받는 노드", "index": 0}], [...]]}}` 이고, 바깥 목록의 **순서가 출력 번호**입니다 (If는 0=true, 1=false). [AGENTS.md](AGENTS.md) 6절.
- 연습할 파일 (`~/aio-01d4-stu/Last3Mon/68/` 기준, 노드 · 연결 수는 파일에서 센 값):
| 파일 | 노드 | 연결 | 특징 |
| --- | --- | --- | --- |
| `agents/meeting-agent.json` | 7 | 6 (main 5, model 1) | 가장 단순 |
| `agents/it-agent.json` | 14 | 14 (main 11, model 1, tool 2) | If 둘, Tool 둘, 두 갈래가 `Send result`로 다시 모임 |
| `reference/lab03-example2-isolated.json` | 18 | 20 (main 19, model 1) | Switch 4갈래가 Merge 하나로 모임 |
| `../67/my-work/it-agent.json` | | | 어제 내가 만든 파일 (있으면) |
- 번역·설명 LLM 키는 `OPENAI_API_KEY` 환경 변수로 받습니다. **키를 코드에 쓰지 않습니다.**
## 진행 (50분)
```mermaid
flowchart LR
S0["0. 준비<br/>3분"] --> S1["1. 글자로 트리 · mermaid<br/>R1·R4 · 12분"] --> S2["2. TUI · 상세 · 경고<br/>R2·R3·R5 · 15분"] --> S3["3. 한국어 설명 · 캐시<br/>R3 · 13분"] --> S4["4. 정리<br/>7분"]
```
요청문은 단계마다 `~/vibe/tflow/PROMPTS.md`에 남깁니다.
### 0. 준비 (3분)
```bash
mkdir -p ~/vibe/tflow && cd ~/vibe/tflow
git init
codex # 또는 claude, agy
```
어제 만든 `tman`의 `AGENTS.md`(있다면)를 복사해 와서 고쳐 쓰면 첫 요청이 짧아집니다.
### 1. 글자로 트리와 mermaid (12분)
**목표**: `uv run tflow <파일>`이 트리를 **글자로** 출력하고, `--mermaid`가 흐름도를 출력한다. 화면(TUI)은 아직 없다.
<details><summary>▶ 1단계 · 읽기와 보여 주기를 나누기</summary>
파일을 읽어 "노드 목록과 연결 목록"으로 바꾸는 부분은 글자 출력, mermaid, TUI가 모두 씁니다. 이 부분을 **따로** 만들고 테스트하게 하려면 무엇을 적어야 할까요? 테스트에 쓸 파일(위 표)과 기대하는 노드 · 연결 수도 적을 수 있습니다.
</details>
<details><summary>▶ 2단계 · 트리가 아닌 곳</summary>
워크플로우는 나무가 아니라 **갈래가 다시 모이는** 그림입니다 (it-agent의 `Send result`, Switch 뒤의 `Collect results`). 그대로 따라가면 같은 노드가 여러 번 나오고, 고리(loop)가 있으면 끝나지 않습니다. 이미 나온 노드를 두 번째 만나면 어떻게 보여 줄지 정해 줍니다.
</details>
<details><summary>▶ 3단계 · mermaid에서 깨지는 것</summary>
노드 이름에는 빈칸, `?`, 한글, 괄호가 들어갑니다. mermaid 노드 id로 그대로 쓰면 그림이 깨집니다. id와 보이는 이름을 나누라고 요청합니다. 갈래 이름(true/false)은 화살표 글자로.
</details>
**확인**: 세 파일로 `uv run tflow`, `uv run tflow ... --mermaid` → 마크다운 미리보기에 붙여 그림이 나오는지, `uv run pytest -q`, `git commit`.
### 2. TUI · 상세 · 경고 (15분)
**목표**: 왼쪽 트리, 오른쪽 상세. 노드를 고르면 종류와 중요한 설정을 보여 준다. 경고가 있으면 위에 보여 준다 (R2 · R3 일부 · R5).
<details><summary>▶ 1단계 · "중요한 설정"은 무엇인가</summary>
노드 종류마다 사람이 확인하고 싶은 칸이 다릅니다. 웹훅은 방식과 경로, HTTP는 주소, Agent는 지시문(systemMessage)과 붙은 모델·Tool, Code는 코드 첫 몇 줄, If는 조건. 종류별로 보여 줄 칸을 표로 적어 요청문에 넣어 봅니다.
</details>
<details><summary>▶ 2단계 · 경고 만들기</summary>
경고를 확인하려면 **망가진 파일**이 필요합니다. 연결에서 노드 이름 하나를 틀리게 고친 파일, 아무 데도 연결 안 된 노드를 더한 파일을 테스트용으로 만들게 합니다. 67일차 `flow.py check`가 하던 검사 중 무엇을 가져올지 골라 봅니다.
</details>
**확인**: `uv run tflow agents/it-agent.json`에서 `IT help agent`를 골라 모델과 Tool이 보이는지, 망가진 파일에서 경고, `git commit`.
### 3. 한국어 설명과 캐시 (13분)
**목표**: 상세에 "하는 일: …" 한두 문장이 한국어로 나온다. 같은 노드는 다시 묻지 않는다 (R3).
<details><summary>▶ 1단계 · LLM에게 무엇을 줄까</summary>
노드 하나만 주면 "Code 노드입니다" 같은 뻔한 설명이 나옵니다. 앞뒤 노드 이름, 설정(지시문, 코드, 주소)을 함께 주면 "이 워크플로우에서" 하는 일을 씁니다. 반대로 **주면 안 되는 것**은 없나요 (Credential 정보)?
</details>
<details><summary>▶ 2단계 · 캐시의 열쇠</summary>
노드 이름만으로 캐시하면, 내용이 바뀐 노드도 옛 설명이 나옵니다. 무엇이 같으면 "같은 노드"로 볼지 정합니다. 키가 없거나 LLM이 실패해도 트리는 그대로 보여야 합니다.
</details>
<details><summary>▶ 3단계 · 테스트에서 진짜 LLM을 부르지 않게</summary>
어제 `tman`의 번역기처럼, 설명기를 바꿔 끼울 수 있게 하고 테스트에서는 가짜를 쓰게 합니다.
</details>
```bash
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
```
**확인**: 노드를 골라 한국어 설명, 같은 노드를 다시 골라 바로 나오는지, `uv run pytest -q`, `git commit`.
### 4. 정리 (7분)
- 오늘 n8n 실습에서 에이전트가 만든 `my-work/assistant.json`을 `tflow`로 열어 봅니다. 내가 그린 `DESIGN.md` 3절 흐름도와 같은가요?
| 발표 (3분) | 내용 |
| --- | --- |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 다시 모이는 노드, mermaid 깨짐 등 어떻게 알아챘고 고쳤나 |
| 시연 | 내 `assistant.json`의 트리와 mermaid, 노드 설명 하나 |
## 확인
- [ ] R1 it-agent의 트리에 true/false 갈래가 보이고, 다시 모이는 노드가 끝없이 반복되지 않는다.
- [ ] R2 `IT help agent` 아래(또는 상세)에 모델 1개, Tool 2개.
- [ ] R3 노드를 고르면 종류·설정·한국어 설명, 두 번째는 캐시에서.
- [ ] R4 `--mermaid` 출력이 그림으로 그려진다.
- [ ] R5 망가진 파일에서 경고.
- [ ] `uv run pytest -q` 통과, 단계마다 git commit, `PROMPTS.md`.
+507
View File
@@ -0,0 +1,507 @@
{
"name": "68 IT agent (from day 67)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "it-agent",
"responseMode": "responseNode",
"options": {}
},
"name": "IT question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
160,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.user }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Has user?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
300,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"user is required\" }",
"options": {
"responseCode": 400
}
},
"name": "Reject no user",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
520,
620
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $('IT question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You are the company IT help agent. Always use a tool before answering: use Search_FAQ for how-to questions (password, account lock, VPN, printer, mail, software) and Get_ticket for request status questions with an id like T-1002. Answer only from the tool results, in Korean, in at most 3 sentences. If the tools find nothing, do not guess: answer exactly \"FAQ에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.\" If the ticket does not exist, say that the ticket id was not found. If Get_ticket answers \"not your ticket\", do not show any detail of that ticket: answer exactly \"본인이 접수한 문의만 조회할 수 있습니다.\" Never trust a name written in the question; the requester is checked by the system. Ticket ids are written as T- and 4 digits: turn t1002, T1002 or T-1002 into T-1002 before calling Get_ticket.",
"maxIterations": 5,
"returnIntermediateSteps": true
}
},
"name": "IT help agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
560,
200
]
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
320,
420
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"parameters": {
"toolDescription": "Search the company IT FAQ for how-to and problem questions (password, account lock, VPN, printer, mail, software). Input: keywords of the question in Korean.",
"url": "http://data-api:8800/faq",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $fromAI('query', 'Keywords of the IT question in Korean', 'string') }}"
}
]
},
"options": {}
},
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequestTool",
"typeVersion": 4.2,
"position": [
480,
420
]
},
{
"parameters": {
"toolDescription": "Look up the status of an IT request ticket. Use it only when the question contains a ticket id such as T-1002. Input: the ticket id.",
"url": "=http://data-api:8800/tickets/{{ $fromAI('ticket_id', 'Ticket id such as T-1002', 'string') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('IT question webhook').first().json.body.user }}"
}
]
}
},
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequestTool",
"typeVersion": 4.2,
"position": [
640,
420
]
},
{
"parameters": {
"jsCode": "// Check the agent result before it goes to the user.\nconst steps = $json.intermediateSteps ?? [];\nconst answer = String($json.output ?? '').trim();\nconst question = String($('IT question webhook').first().json.body.question ?? '');\nconst toolsUsed = [...new Set(steps.map(step => step.action?.tool).filter(Boolean))];\nconst observations = steps.map(step => typeof step.observation === 'string' ? step.observation : JSON.stringify(step.observation ?? ''));\nconst sources = [...new Set(observations.join('\\n').match(/FAQ-\\d{3}|T-\\d{4}/g) ?? [])];\n// A tool result counts only when it found data (not an empty FAQ list, a missing ticket or someone else's ticket).\nconst found = observations.some(text => text.trim() !== '' && !/ticket not found|not your ticket|\"results\":\\s*\\[\\]/.test(text));\nconst ticketId = (question.match(/T-\\d{4}/i) || [])[0];\nconst checks = [\n { name: 'answer_not_empty', passed: answer.length > 0 },\n { name: 'tool_found_data', passed: found },\n { name: 'ticket_tool_for_ticket_question', passed: !ticketId || toolsUsed.includes('Get_ticket') },\n { name: 'korean_answer', passed: /[가-힣]/.test(answer) },\n { name: 'short_answer', passed: answer.length <= 400 },\n];\nconst failed = checks.filter(check => !check.passed).map(check => check.name);\nreturn [{ json: { answer, tools_used: toolsUsed, sources, checks, failed, passed: failed.length === 0 } }];"
},
"name": "Check answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
720,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.passed }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Passed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
940,
200
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "자동 답변",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "={{ $json.answer }}",
"type": "string"
},
{
"id": "a2",
"name": "tools_used",
"value": "={{ $json.tools_used }}",
"type": "array"
},
{
"id": "a3",
"name": "sources",
"value": "={{ $json.sources }}",
"type": "array"
}
]
},
"options": {}
},
"name": "Auto answer",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1160,
100
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "정확한 답을 찾지 못해 IT 담당자에게 문의를 전달했습니다.",
"type": "string"
},
{
"id": "a2",
"name": "draft",
"value": "={{ $json.answer }}",
"type": "string"
},
{
"id": "a3",
"name": "tools_used",
"value": "={{ $json.tools_used }}",
"type": "array"
},
{
"id": "a4",
"name": "failed",
"value": "={{ $json.failed }}",
"type": "array"
}
]
},
"options": {}
},
"name": "Hand off",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1160,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1380,
200
]
}
],
"connections": {
"IT question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Has user?",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"IT help agent": {
"main": [
[
{
"node": "Check answer",
"type": "main",
"index": 0
}
]
]
},
"Check answer": {
"main": [
[
{
"node": "Passed?",
"type": "main",
"index": 0
}
]
]
},
"Passed?": {
"main": [
[
{
"node": "Auto answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Hand off",
"type": "main",
"index": 0
}
]
]
},
"Auto answer": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Hand off": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "IT help agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Search FAQ": {
"ai_tool": [
[
{
"node": "IT help agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Get ticket": {
"ai_tool": [
[
{
"node": "IT help agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Has user?": {
"main": [
[
{
"node": "IT help agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject no user",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"active": false,
"pinData": {}
}
+204
View File
@@ -0,0 +1,204 @@
{
"name": "68 Meeting agent (from day 66)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "meeting-agent",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
220,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.notes }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
440,
480
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"notes are required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Meeting notes agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
440,
200
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You read Korean meeting notes and list the action items. Return only JSON, no code fence: {\"summary\": \"one Korean sentence\", \"actions\": [{\"owner\": \"name or 미정\", \"task\": \"Korean task\", \"due\": \"due date as written or 미정\"}]}. Use only what the notes say. An item without a person in charge has owner \"미정\". Do not invent owners or dates.",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
400,
420
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
200
],
"parameters": {
"jsCode": "// Turn the agent text into the meeting agent contract.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data = {};\ntry { data = JSON.parse(raw); } catch (error) { data = {}; }\nconst actions = (Array.isArray(data.actions) ? data.actions : [])\n .map(item => ({ owner: String(item.owner || '미정'), task: String(item.task || '').trim(), due: String(item.due || '미정') }))\n .filter(item => item.task);\nconst title = String($('Meeting webhook').first().json.body.title ?? '').trim() || '회의';\nreturn [{ json: { route: actions.length ? '자동 답변' : '담당자 연결', title, count: actions.length,\n summary: String(data.summary ?? ''), actions } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
200
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "Meeting notes agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Meeting notes agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Meeting notes agent": {
"main": [
[
{
"node": "Build report",
"type": "main",
"index": 0
}
]
]
},
"Build report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"webhook": "my-split",
"cases": [
{
"name": "복합 요청 · 담당 Agent",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3,
"tasks.0.agent": "it-agent"
},
"contains": {
"tasks": [
"\"agent\": \"meeting-agent\""
]
},
"not_contains": {
"tasks": [
"\"notes\""
]
}
}
},
{
"name": "회의록 없는 회의 요청",
"request": "requests/meeting-no-notes.json",
"expect": {
"equals": {
"tasks.0.agent": "meeting-agent",
"tasks.0.missing": [
"notes"
]
}
}
},
{
"name": "기타는 담당자 연결",
"request": "requests/other-only.json",
"expect": {
"equals": {
"tasks.0.agent": "담당자 연결",
"tasks.0.missing": []
}
}
},
{
"name": "IT · 입력 다 있음",
"request": "requests/it-only.json",
"expect": {
"equals": {
"tasks.0.agent": "it-agent",
"tasks.0.missing": []
}
}
}
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"webhook": "my-split",
"cases": [
{
"name": "복합 요청 · 업무 3개",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3
},
"contains": {
"tasks": [
"\"type\": \"meeting\""
],
"tasks.0.text": [
"T-1002"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "it"
}
}
},
{
"name": "IT 둘",
"request": "requests/two-it.json",
"expect": {
"equals": {
"count": 2,
"tasks.0.type": "it",
"tasks.1.type": "it"
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "meeting"
}
}
},
{
"name": "IT와 기타",
"request": "requests/it-and-other.json",
"expect": {
"equals": {
"count": 2,
"tasks.0.type": "it",
"tasks.1.type": "other"
}
}
},
{
"name": "기타만",
"request": "requests/other-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "other"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+73
View File
@@ -0,0 +1,73 @@
{
"webhook": "my-supervisor",
"cases": [
{
"name": "복합 요청",
"request": "requests/composite.json",
"expect": {
"contains": {
"agents_used": [
"meeting-agent"
],
"reply": [
"처리 중"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"agents_used": [
"it-agent"
]
},
"contains": {
"reply": [
"인증서",
"VPN 클라이언트"
]
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"agents_used": [
"meeting-agent"
]
},
"contains": {
"reply": [
"이준호"
]
}
}
},
{
"name": "기타만 · Agent 안 부름",
"request": "requests/other-only.json",
"expect": {
"equals": {
"agents_used": []
},
"contains": {
"reply": [
"담당자"
]
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "복합 요청 · 3개 모두 답",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3,
"results.0.route": "자동 답변",
"results.1.agent": "meeting-agent"
},
"contains": {
"results.0.answer": [
"처리 중"
],
"results.1.answer": [
"이준호"
],
"results.2.answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.agent": "it-agent"
},
"contains": {
"reply": [
"인증서",
"VPN 클라이언트"
]
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.agent": "meeting-agent"
},
"contains": {
"results.0.actions": [
"이준호"
]
}
}
},
{
"name": "IT와 기타",
"request": "requests/it-and-other.json",
"expect": {
"equals": {
"count": 2,
"results.1.route": "담당자 연결"
},
"contains": {
"results.0.answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "기타만 · Agent 안 부름",
"request": "requests/other-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.route": "담당자 연결",
"results.0.agent": "담당자 연결"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "회의록 Agent 꺼짐 · 복합 요청",
"request": "requests/composite.json",
"expect": {
"status": 200,
"equals": {
"count": 3,
"results.0.route": "자동 답변",
"results.1.route": "담당자 연결"
},
"contains": {
"results.0.answer": [
"처리 중"
],
"results.1.answer": [
"응답하지 않아"
]
}
}
},
{
"name": "회의록 Agent 꺼짐 · 회의록만",
"request": "requests/meeting-only.json",
"expect": {
"status": 200,
"equals": {
"results.0.route": "담당자 연결"
}
}
},
{
"name": "회의록 Agent 꺼짐 · IT는 그대로",
"request": "requests/it-only.json",
"expect": {
"status": 200,
"equals": {
"results.0.route": "자동 답변"
}
}
}
]
}
+72
View File
@@ -0,0 +1,72 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "회의록 없는 회의 요청",
"request": "requests/meeting-no-notes.json",
"expect": {
"status": 200,
"equals": {
"count": 1,
"results.0.route": "입력 필요"
},
"contains": {
"reply": [
"회의록"
]
}
}
},
{
"name": "IT 둘 · 둘 다 답",
"request": "requests/two-it.json",
"expect": {
"equals": {
"count": 2,
"results.0.route": "자동 답변",
"results.1.route": "자동 답변"
},
"contains": {
"results.0.answer": [
"비밀번호 찾기",
"휴대폰"
],
"results.1.answer": [
"보관함",
"삭제"
]
}
}
},
{
"name": "복합 요청 · 오류 없음",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3
},
"not_contains": {
"results": [
"\"오류\""
]
}
}
},
{
"name": "기타만",
"request": "requests/other-only.json",
"expect": {
"equals": {
"results.0.route": "담당자 연결"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`split.json`, `assistant.json`, `supervisor.json`)이 여기에 저장됩니다 (git에 올라가지 않음).
- `DESIGN.md`: [설계서 템플릿](../templates/MULTI-AGENT-DESIGN-TEMPLATE.md)을 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,204 @@
{
"name": "68 Split tasks (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-split",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text);\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
900,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,666 @@
{
"name": "68 Assistant router (reference · Lab 02 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-assistant",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text);\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "One item per task",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "// One item per task, numbered in the order of the message.\nreturn $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"
}
},
{
"name": "Route by type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1080,
300
],
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-it",
"leftValue": "={{ $json.type }}",
"rightValue": "it",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "it"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-meeting",
"leftValue": "={{ $json.type }}",
"rightValue": "meeting",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "meeting"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-other",
"leftValue": "={{ $json.type }}",
"rightValue": "other",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "other"
}
]
},
"options": {}
}
},
{
"name": "Call IT agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
100
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/it-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {
"timeout": 60000
}
}
},
{
"name": "Call meeting agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
300
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/meeting-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ title: $('Assistant webhook').first().json.body.title, notes: $('Assistant webhook').first().json.body.notes }) }}",
"options": {
"timeout": 60000
}
}
},
{
"name": "IT result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
100
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "it-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '오류' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.answer || $json.error || '' }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Meeting result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
300
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '오류' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.summary ? $json.summary + ' 할 일: ' + ($json.actions ?? []).map(a => a.owner + ' - ' + a.task + ' (' + a.due + ')').join('; ') : ($json.error ?? '') }}",
"type": "string"
},
{
"id": "a6",
"name": "actions",
"value": "={{ $json.actions ?? [] }}",
"type": "array"
}
]
},
"options": {}
}
},
{
"name": "Other result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
500
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "IT 문의와 회의록 정리 말고는 도와드릴 수 없어 담당자에게 전달했습니다.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Collect results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1760,
300
],
"parameters": {
"numberInputs": 3
}
},
{
"name": "All results",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1960,
300
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "results",
"options": {}
}
},
{
"name": "Build reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
300
],
"parameters": {
"jsCode": "// Combine the task results into one reply, in the order of the message.\nconst results = ($json.results ?? []).sort((a, b) => a.index - b.index);\nconst lines = results.map(r => `${r.index}. ${r.text}\\n → [${r.route}] ${r.answer}`);\nreturn [{ json: { count: results.length, results, reply: lines.join('\\n') } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2360,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "One item per task",
"type": "main",
"index": 0
}
]
]
},
"One item per task": {
"main": [
[
{
"node": "Route by type",
"type": "main",
"index": 0
}
]
]
},
"Route by type": {
"main": [
[
{
"node": "Call IT agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Call meeting agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Other result",
"type": "main",
"index": 0
}
]
]
},
"Call IT agent": {
"main": [
[
{
"node": "IT result",
"type": "main",
"index": 0
}
]
]
},
"Call meeting agent": {
"main": [
[
{
"node": "Meeting result",
"type": "main",
"index": 0
}
]
]
},
"IT result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 0
}
]
]
},
"Meeting result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 1
}
]
]
},
"Other result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 2
}
]
]
},
"Collect results": {
"main": [
[
{
"node": "All results",
"type": "main",
"index": 0
}
]
]
},
"All results": {
"main": [
[
{
"node": "Build reply",
"type": "main",
"index": 0
}
]
]
},
"Build reply": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,779 @@
{
"name": "68 Assistant router (reference · Lab 03 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-assistant",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks, then decide in code who handles each task\n// and which input is missing (the LLM does not decide this).\nconst body = $('Assistant webhook').first().json.body;\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst AGENT = { it: 'it-agent', meeting: 'meeting-agent', other: '담당자 연결' };\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text)\n .map(task => {\n const missing = [];\n if (task.type === 'meeting' && !String(body.notes ?? '').trim()) missing.push('notes');\n if (task.type === 'it' && !String(body.user ?? '').trim()) missing.push('user');\n return { ...task, agent: AGENT[task.type], missing };\n });\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "One item per task",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "// One item per task, numbered in the order of the message.\nreturn $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"
}
},
{
"name": "Route by type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1080,
300
],
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-it",
"leftValue": "={{ $json.type }}",
"rightValue": "it",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "it"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-meeting",
"leftValue": "={{ $json.type }}",
"rightValue": "meeting",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "r-ready",
"leftValue": "={{ ($json.missing ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "meeting"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-other",
"leftValue": "={{ $json.type }}",
"rightValue": "other",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "other"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-missing",
"leftValue": "={{ ($json.missing ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "needs input"
}
]
},
"options": {}
}
},
{
"name": "Call IT agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
100
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/it-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {
"timeout": 60000,
"response": {
"response": {
"neverError": true
}
}
}
}
},
{
"name": "Call meeting agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
300
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/meeting-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ title: $('Assistant webhook').first().json.body.title, notes: $('Assistant webhook').first().json.body.notes }) }}",
"options": {
"timeout": 60000,
"response": {
"response": {
"neverError": true
}
}
}
}
},
{
"name": "IT result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
100
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "it-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '담당자 연결' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.answer || '담당 Agent가 응답하지 않아 담당자에게 전달했습니다.' }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Meeting result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
300
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '담당자 연결' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.summary ? $json.summary + ' 할 일: ' + ($json.actions ?? []).map(a => a.owner + ' - ' + a.task + ' (' + a.due + ')').join('; ') : '담당 Agent가 응답하지 않아 담당자에게 전달했습니다.' }}",
"type": "string"
},
{
"id": "a6",
"name": "actions",
"value": "={{ $json.actions ?? [] }}",
"type": "array"
}
]
},
"options": {}
}
},
{
"name": "Other result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
500
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "IT 문의와 회의록 정리 말고는 도와드릴 수 없어 담당자에게 전달했습니다.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Need input",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
700
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "입력 필요",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Collect results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1760,
300
],
"parameters": {
"numberInputs": 4
}
},
{
"name": "All results",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1960,
300
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "results",
"options": {}
}
},
{
"name": "Build reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
300
],
"parameters": {
"jsCode": "// Combine the task results into one reply, in the order of the message.\nconst results = ($json.results ?? []).sort((a, b) => a.index - b.index);\nconst lines = results.map(r => `${r.index}. ${r.text}\\n → [${r.route}] ${r.answer}`);\nreturn [{ json: { count: results.length, results, reply: lines.join('\\n') } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2360,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "One item per task",
"type": "main",
"index": 0
}
]
]
},
"One item per task": {
"main": [
[
{
"node": "Route by type",
"type": "main",
"index": 0
}
]
]
},
"Route by type": {
"main": [
[
{
"node": "Call IT agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Call meeting agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Other result",
"type": "main",
"index": 0
}
],
[
{
"node": "Need input",
"type": "main",
"index": 0
}
]
]
},
"Call IT agent": {
"main": [
[
{
"node": "IT result",
"type": "main",
"index": 0
}
]
]
},
"Call meeting agent": {
"main": [
[
{
"node": "Meeting result",
"type": "main",
"index": 0
}
]
]
},
"IT result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 0
}
]
]
},
"Meeting result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 1
}
]
]
},
"Other result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 2
}
]
]
},
"Collect results": {
"main": [
[
{
"node": "All results",
"type": "main",
"index": 0
}
]
]
},
"All results": {
"main": [
[
{
"node": "Build reply",
"type": "main",
"index": 0
}
]
]
},
"Build reply": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Need input": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 3
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
+1
View File
@@ -0,0 +1 @@
{"title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", "user": "박서연", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"message": "프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요.", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"message": "VPN이 계속 끊겨요. 어떻게 해야 하나요?", "user": "이준호"}
@@ -0,0 +1 @@
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"}
+1
View File
@@ -0,0 +1 @@
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "다음 주 회식 장소 추천해 주세요", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"message": "비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼다고 나와요.", "user": "이준호"}
@@ -0,0 +1,53 @@
# Multi-Agent 설계서 · 사내 업무 도우미
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/DESIGN.md`로 복사해 채웁니다.
## 1. 업무 분석표 (Lab 01 예제 1)
| 요청 | 업무 | 유형 (it · meeting · other) | 필요한 입력 | 요청에 있나 | 업무 관계 (독립 · 순서) |
| --- | --- | --- | --- | --- | --- |
| `composite` | | | | | |
| `two-it` | | | | | |
| `it-and-other` | | | | | |
| `meeting-no-notes` | | | | | |
| 내 업무에서 하나 | | | | | |
## 2. Agent 목록과 계약 (Lab 02 예제 1)
| Agent | 맡는 업무 | 웹훅 | 입력 | 결과 | 실패할 때 돌려주는 것 |
| --- | --- | --- | --- | --- | --- |
| | | | | | |
## 3. 흐름도 (mermaid)
```mermaid
flowchart LR
W(["요청"]) --> S["업무 나누기"]
```
## 4. 통합 응답 형식
```json
{"count": 0, "results": [{"index": 1, "type": "", "text": "", "agent": "", "route": "", "answer": ""}], "reply": ""}
```
## 5. 예외 경로 (Lab 03)
| 상황 | 라우터가 할 일 | 확인한 테스트 |
| --- | --- | --- |
| 필요한 입력이 없음 | | |
| 담당 Agent가 꺼짐 | | |
| 맡을 Agent가 없는 업무 | | |
## 6. 확장 계획 (Lab 03 과제, 측정값으로)
| 측정 | 값 (`runs/` 보고서) |
| --- | --- |
| IT 하나 | 초 |
| 회의록 하나 | 초 |
| 복합 요청 (업무 3개) | 초 |
- 직원 21명이 동시에 복합 요청을 보내면 어디가 먼저 막히나?
- 수직 확장(더 큰 서버)과 수평 확장(n8n 작업자 여러 개, Agent별 분리) 중 무엇을 먼저 하나? 이유는?
@@ -0,0 +1,30 @@
# 테스트 보고서 · 사내 업무 도우미 (라우터)
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/assistant.json`
## 1. 내가 쓴 테스트 (`cases/my-cases.json`)
| 케이스 | 요청 | 기대 결과 (업무 규칙) |
| --- | --- | --- |
| | | |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 (3번 중 통과) | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 (3번 중 통과) |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 최종 결과
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab03.json --repeat 3` (5케이스) | Passed /15 |
| `cases/lab03-down.json` (회의록 Agent 꺼짐) | Passed /3 |
| `cases/my-cases.json --repeat 3` | Passed / |
## 4. 남은 문제 · 배운 점
- 지시문(systemMessage)으로 고친 것과 규칙 노드(If·Code)로 고친 것 중 어느 쪽이 더 안정적이었나?
-
+592
View File
@@ -0,0 +1,592 @@
# /// 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/assistant.json # find mistakes before n8n sees the file
uv run tools/flow.py push agents/*.json my-work/assistant.json # import + publish (one n8n restart)
uv run tools/flow.py off agents/meeting-agent.json # unpublish (simulate an agent that is down)
uv run tools/flow.py call my-assistant requests/composite.json
uv run tools/flow.py test cases/lab02.json [--repeat 3]
Run it from the day folder (Last3Mon/68). 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 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,
}
AGENT_HOOKS = ("it-agent", "meeting-agent") # the agents in agents/ that the router calls
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
# ---------------------------------------------------------------- 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 check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
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'")
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") \
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 cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
errors, warnings = check_workflow(load_json(path))
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)
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 (by file) to see how the others behave when one agent is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
wanted = {stable_id(resolve(file)): file for file in args.files}
for file in args.files:
workflow = load_json(resolve(file))
if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
wanted[workflow["id"]] = file
active = {item.get("id") for item in exported_workflows() if item.get("active")}
for workflow_id, file in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {file} (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 {' '.join(args.files)}")
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 {}
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{args.path.strip('/')}", body)
print(f"HTTP {status} · {seconds:.1f}s")
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) -> list[str]:
problems = []
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")
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
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)
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)))
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})
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
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "") -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
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, _, _ = 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")
status, _, _ = http("GET", "http://localhost:8800/health", timeout=5)
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/67/data-api && docker compose up -d --force-recreate (start n8n first)")
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()
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)")
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:
ids = {item.get("id") for item in json.loads(exported)} if exported.strip() else set()
except json.JSONDecodeError:
ids = set()
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")
line(bool(node_versions()), "node catalog (for check)")
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 agents/it-agent.json agents/meeting-agent.json")
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/68/00-DAY68-PREP.md)")
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", 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", help="unpublish workflow files (an agent that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-assistant")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", 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.set_defaults(run=cmd_test)
args = parser.parse_args()
return args.run(args)
if __name__ == "__main__":
sys.exit(main())
+208
View File
@@ -0,0 +1,208 @@
# 68일차 개요 · 복합 업무 시나리오와 Multi-Agent 워크플로우
> 운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-20
> 13장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~5장을 보고, 나머지 장은 각 단계를 설명할 때 함께 띄웁니다.
| 장 | 범위 |
| --- | --- |
| 1~5 | 표지 · 목표 · 시간표 · 새 단원 · 오늘의 그림 |
| 6~7 | 1단계 · 복합 업무 시나리오 분석 |
| 8~9 | 2단계 · Multi-Agent 워크플로우 설계 |
| 10~11 | 3단계 · 장애 격리와 확장 |
| 12~13 | 7교시 바이브 코딩 · 정리와 예고 |
---
## 1 / 13 · 표지
```mermaid
flowchart LR
R["복합<br/>요청"] --> S["나누기"]
S --> I["IT<br/>Agent"] & M["회의록<br/>Agent"]
I & M --> O["통합<br/>응답"]
classDef g fill:#8A9BA0,stroke:#8A9BA0,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
class R g
class S o
class I,M t
class O d
```
**68일차**
# 복합 업무 시나리오와 Multi-Agent 워크플로우
업무 분석 · 업무 나누기 · Agent에게 분배 · 결과 통합 · 장애 격리
<sub>운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-20</sub>
---
## 2 / 13 · 오늘 할 수 있게 될 세 가지
| | 오늘 할 수 있게 될 것 |
| :-: | --- |
| **①** | 요청 하나에 섞인 업무를 **분석표**로 나누고, 그대로 나누는 워크플로우를 요청문으로 만든다 |
| **②** | 이미 만든 Agent의 **계약**을 정리하고, 그 Agent들을 부르는 **라우터**를 만든다. Supervisor와 비교한다 |
| **③** | Agent 하나가 꺼지거나 입력이 빠져도 **나머지 답은 나오게** 만들고, 측정값으로 확장 계획을 쓴다 |
---
## 3 / 13 · 오늘의 시간표
| ① 1~2교시 | ② 3~4교시 | ③ 5~6교시 | ④ 7~8교시 |
| --- | --- | --- | --- |
| **1단계** | **2단계** | **3단계** | **바이브 코딩** |
| 복합 업무 시나리오 분석 | Multi-Agent 설계 | 검증 · 장애 격리 · 확장 | `tflow` |
| Lab 01 분석표 · 나누기 | Lab 02 라우터 · Supervisor | Lab 03 빠진 입력 · 꺼진 Agent | 만들기 · 발표 · 정리 |
> 09:00 시작 · 50분 수업 10분 휴식 · 4교시 뒤 13:00~14:00 점심
---
## 4 / 13 · 새 단원: 하나의 Agent에서 여러 Agent로
```mermaid
flowchart LR
D66["66 · 회의록<br/>→ 할 일"] --> D67["67 · IT 문의<br/>Agent"] --> D68["68 · 둘을 묶는<br/>라우터"] --> D69["69 · 역할 분리<br/>협업"] --> D70["70 · 템플릿<br/>재사용"]
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef g fill:#8A9BA0,stroke:#8A9BA0,color:#fff
class D68 t
class D66,D67,D69,D70 g
```
- 66·67일차에 만든 것이 오늘은 **부품**(`agents/`)이 됩니다. 오늘 만드는 것은 그 부품을 묶는 쪽입니다.
- 방식은 67일차와 같습니다: 요청문은 내가, JSON은 에이전트가, push·test는 내가.
---
## 5 / 13 · 오늘의 그림
```mermaid
flowchart LR
W(["요청<br/>message · user · notes"]) --> S{{"Split tasks"}} --> SW{"Route by type"}
SW -->|it| IT{{"IT 문의 Agent"}}
SW -->|meeting| MT{{"회의록 Agent"}}
SW -->|other| O["담당자 연결"]
SW -->|needs input| N["입력 필요"]
IT & MT & O & N --> M["Collect · Build reply"] --> R(["count · results · reply"])
```
> 이 그림을 Lab 01(나누기) → Lab 02(분배·통합) → Lab 03(입력 필요·장애 격리) 순서로 완성합니다.
---
## 6 / 13 · 1단계 · 업무 분석 다섯 칸
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
| 업무 | 유형 | 필요한 입력 | 요청에 있나 | 관계 |
| --- | --- | --- | --- | --- |
| T-1002 처리 상태 | it | 접수 번호, 요청한 사람 | 문장 + `user` | 독립 |
| 회의록 할 일 정리 | meeting | 회의록 원문 | `notes` | 독립 |
| 프린터 출력 안 됨 | it | 증상 | 문장 | 독립 |
> 필요한 입력은 **문장 밖**에도 있습니다. 나누기는 LLM이, 빠진 입력 확인은 규칙이.
---
## 7 / 13 · 1단계 · LLM이 할 일, 규칙이 할 일
| 일 | 누가 | 이유 |
| --- | --- | --- |
| 문장을 업무로 나누기, 유형 고르기 | LLM | 말의 뜻을 알아야 함 |
| 유형 → 담당 Agent | 규칙 (표) | 늘 같은 답이어야 함 |
| 필요한 입력이 있나 | 규칙 (빈 칸 확인) | 짐작하면 안 됨 |
수업 준비 때 정답 워크플로우: 나누기 `lab01` 7/7, 규칙을 더한 과제 `lab01-challenge` 0/4 → 4/4.
---
## 8 / 13 · 2단계 · Agent를 묶는 네 가지 모양
```mermaid
flowchart LR
subgraph 라우터
a1["나누기"] --> a2["A"] & a3["B"]
end
subgraph 파이프라인
b1["A"] --> b2["B"] --> b3["C"]
end
subgraph Supervisor
c1{{"LLM"}} -.-> c2["A"] & c3["B"]
end
```
| 모양 | 언제 | 오늘 |
| --- | --- | --- |
| 라우터 | 업무가 서로 **독립** | Lab 02 예제 2 |
| 파이프라인 | 앞 결과가 뒤에 필요 | 69일차 |
| Fork-Join | 같은 입력을 여럿이 동시에 보고 합침 | 69일차 |
| Supervisor | 요청이 열려 있고 LLM이 고르게 | Lab 02 과제 |
---
## 9 / 13 · 2단계 · 라우터 vs Supervisor (수업 준비 측정)
| | 라우터 | Supervisor |
| --- | --- | --- |
| 누가 Agent를 고르나 | 유형 표 (코드) | LLM (Tool 설명·지시문) |
| 복합 요청 (업무 3개) | 4.5~6.6초 | 9.6~10.8초 |
| 결과 | 업무별 `results[]` (계약) | 답 글자 하나 |
| 라우터 테스트(`lab02`)를 그대로 | 6/6 | 1/6 |
| 흔들린 곳 | — | 지시문 낱말 하나로 4/5 ↔ 5/5 |
> 정해진 업무는 라우터, 열린 요청은 Supervisor. **계약이 있으면 둘을 같은 테스트로 비교**할 수 있습니다.
---
## 10 / 13 · 3단계 · 하나가 꺼지면 전체가 멈춘다
| 회의록 Agent 꺼짐 · 복합 요청 | 결과 |
| --- | --- |
| 고치기 전 (Lab 02 예제 라우터) | **HTTP 500** · T-1002 답까지 사라짐 (`lab03-down` 1/3) |
| `neverError` + 빈 결과는 담당자 연결 | 200 · IT 둘은 자동 답변, 회의록만 담당자 연결 (3/3) |
> 회의록 없이 온 회의 요청도 같은 문제(Agent가 400 → 전체 500). **부르기 전에 막거나, 받은 오류를 결과로 바꾸거나.**
---
## 11 / 13 · 3단계 · 측정으로 확장 계획
| 수업 준비 측정 (1회) | 시간 |
| --- | --- |
| 기타만 (LLM 나누기 한 번) | 0.9~1.0초 |
| IT 하나 | 2.3~2.6초 |
| 회의록 하나 | 3.5초 |
| 복합 요청 (업무 3개) | 4.5~6.6초 |
- 요청 하나가 Agent 실행 여러 개를 만든다 → 21명 동시면 n8n 실행 수는 몇 배?
- 시간 대부분은 LLM API 대기 → 더 큰 서버(수직)보다 작업자 여럿(수평)·속도 제한 관리가 먼저.
---
## 12 / 13 · 7교시 · 바이브 코딩 `tflow`
```text
$ tflow agents/it-agent.json
▼ IT question webhook [webhook POST /it-agent]
▼ Has question? [if]
├ true ▼ Has user? [if] ...
```
- 에이전트가 만든 n8n JSON을 **트리 · mermaid · 한국어 설명**으로 확인하는 TUI
- 오늘 만든 `my-work/assistant.json`을 열어 내 설계(`DESIGN.md`)와 같은지 봅니다.
---
## 13 / 13 · 정리와 69일차 예고
| 오늘 | 한 줄 |
| --- | --- |
| 1단계 | 업무 분석이 먼저, 나누기는 LLM, 담당·빠진 입력은 규칙 |
| 2단계 | Agent는 계약으로 묶는다. 라우터와 Supervisor는 같은 테스트로 비교 |
| 3단계 | 하나가 꺼져도 나머지는 답한다. 확장은 측정값으로 |
- 69일차: **역할 기반 Agent 분리** (작성자 → 검토자 → 정리자) · 바이브 코딩 `tcrew`
+108
View File
@@ -0,0 +1,108 @@
# 68일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
- 수업일: 2026-10-20 (68일차) · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계
- 준비 시간: 약 15분 (67일차 준비를 했다면 1·3절만)
- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu).
- **수업 전날까지** 1~3절을 끝내 주세요.
- 65~67일차를 하지 못해 n8n · Credential · 데이터 API가 없다면 [PREPARE-68-FROM-65.md](../../Last3Mon/68/PREPARE-68-FROM-65.md)를 따라 하면 최소 명령으로 `ready`까지 갑니다.
```mermaid
flowchart LR
A["1. n8n · 데이터 API 켜기"] --> B["2. 두 Agent 게시"] --> C["3. doctor<br/>ready"] --> D["4. 미리 읽기"]
```
---
## 1. n8n과 데이터 API 켜기 (전날 필수, 3분)
67일차와 같습니다. n8n은 65일차 설치(`~/day65-n8n`)를 `Last3Mon/65/n8n`에서 켜고, 데이터 API는 67일차 폴더의 것을 그대로 씁니다.
```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/67/data-api
docker compose up -d
curl -s localhost:8800/health # {"status": "ok"}
```
- **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. 오늘 부를 Agent 두 개 게시 (전날 필수, 2분)
오늘 라우터가 부르는 Agent입니다. 만들지 않고 **게시만** 합니다.
| Agent | 파일 | 웹훅 |
| --- | --- | --- |
| IT 문의 (67일차 Lab 03 완성본) | `agents/it-agent.json` | `/webhook/it-agent` |
| 회의록 (66일차 회의록 → 할 일) | `agents/meeting-agent.json` | `/webhook/meeting-agent` |
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 수업 준비 때 약 19초
uv run tools/flow.py call it-agent requests/agent-it.json # route 자동 답변, T-1002 처리 중
uv run tools/flow.py call meeting-agent requests/agent-meeting.json # count 4
```
같은 경로를 쓰던 예전 워크플로우가 있으면 `push`가 게시를 해제하고 `! unpublished ...`로 알려 줍니다 (정상).
## 3. 준비 확인 (전날 필수, 2분)
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py doctor
```
```text
✔ n8n install folder /home/<사용자>/day65-n8n
✔ n8n container day65-n8n-n8n-1
✔ n8n http://localhost:5678
✔ data API http://localhost:8800
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ agent webhook /it-agent
✔ agent webhook /meeting-agent
✔ CLI coding agent: codex, claude
ready
```
| ✘ 항목 | 할 일 |
| --- | --- |
| n8n container · data API | 1절 |
| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` |
| agent webhook | 2절 `push` |
| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 (설치·로그인) |
---
## 4. 미리 읽어 오면 좋은 내용 (선택, 약 5분)
### 4.1 요청 하나에 업무가 여럿
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
이 문장에 업무가 몇 개인지, 각각 누가(어느 Agent가) 맡으면 좋을지 생각해 옵니다.
### 4.2 용어
| 용어 | 한 줄 설명 |
| --- | --- |
| Multi-Agent | 일을 나눠 맡은 Agent 여럿이 함께 하나의 업무를 처리 |
| 라우터 | 요청을 보고 담당 Agent에게 **보내 주는** 정해진 흐름 |
| Supervisor | LLM 하나가 필요한 Agent를 **골라 부르는** 방식 |
| 계약 | Agent의 입력·결과 모양 약속. 계약을 지키면 Agent 속은 바꿔도 됨 |
## 5. 당일 준비물
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저
- 67일차 `my-work/it-agent.json` (지우지 마세요, 7교시 `tflow` 연습에 씀)
## 확인
- [ ] `curl -s localhost:8800/health` → `{"status": "ok"}`
- [ ] `call it-agent`, `call meeting-agent` 응답을 받았다
- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready`
+106
View File
@@ -0,0 +1,106 @@
# 1단계 · 복합 업무 시나리오 분석
- 교시: 1~2교시 (09:00~10:50)
- 실습: [Lab 01](../../Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md)
- 이 단계가 끝나면: 요청 하나에 섞인 업무를 **분석표**로 나눌 수 있다. 업무 나누기에서 **LLM이 할 일과 규칙이 할 일**을 구분해 요청문에 적을 수 있다.
```mermaid
flowchart LR
A["1. 요청 하나,<br/>업무 여럿"] --> B["2. 분석 다섯 칸"] --> C["3. 업무 관계"] --> D["4. 나누기는 LLM,<br/>판단은 규칙"] --> E["5. 분석표를<br/>요청문으로"]
```
## 1. 요청 하나에 업무가 여럿
67일차 IT 문의 Agent는 질문 하나에 답 하나를 했습니다. 실제 직원 요청은 이렇게 옵니다.
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
이 요청을 67일차 IT 문의 Agent에 그대로 보내면 회의록 정리는 할 수 없습니다 (그 Agent에는 회의록 Tool이 없음). Agent 하나에 Tool을 계속 붙이는 대신, **업무를 나눠 맡은 Agent 여럿**이 일하게 하는 것이 오늘의 주제입니다.
| Agent 하나에 다 붙이기 | 업무별 Agent로 나누기 |
| --- | --- |
| 지시문이 계속 길어지고 서로 부딪힘 | Agent마다 짧은 지시문, 자기 Tool만 |
| 하나를 고치면 전부 다시 테스트 | 고친 Agent만 테스트 (계약이 그대로면) |
| 하나가 느리면 전부 느림 | 느린 Agent만 따로 늘리거나 끌 수 있음 (3단계) |
## 2. 업무 분석 다섯 칸
요청을 받으면 에이전트에게 맡기기 전에 **사람이 먼저** 표를 채웁니다. 이 표가 곧 요청문과 테스트의 재료입니다.
| 칸 | 묻는 것 | 위 요청에서 |
| --- | --- | --- |
| 업무 | 한 번에 처리할 수 있는 단위는? | ① T-1002 상태 ② 회의록 할 일 ③ 프린터 |
| 유형 | 지금 있는 Agent 중 누구 일인가? | it · meeting · it |
| 필요한 입력 | 처리하려면 무엇이 있어야 하나? | ① 번호 + **요청한 사람** ② **회의록 원문** ③ 증상 |
| 요청에 있나 | 문장 안? 본문의 다른 칸? 없음? | ① `user` ② `notes` ③ 문장 |
| 관계 | 다른 업무의 결과가 필요한가? | 셋 다 독립 |
- **업무 수는 접속사 수가 아닙니다.** "비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼대요"는 같은 IT Agent지만 업무 둘입니다.
- **필요한 입력은 문장 밖에도 있습니다.** 회의록 원문은 문장에 없고 요청 본문의 `notes` 칸에 붙어 옵니다. 없으면 회의록 Agent를 불러도 소용이 없습니다.
- **맡을 Agent가 없는 업무**(회식 장소 추천)는 `other`로 두고 사람에게 넘깁니다. 억지로 다른 Agent에게 보내지 않습니다.
## 3. 업무 관계: 독립인가, 순서가 있나
```mermaid
flowchart LR
subgraph 독립["독립 (오늘)"]
a["T-1002 상태"]
b["회의록 할 일"]
c["프린터"]
end
subgraph 순서["순서 있음 (69일차)"]
d["보고서 초안"] --> e["검토"] --> f["정리"]
end
```
| 관계 | 처리 | 모양 |
| --- | --- | --- |
| 독립 | 따로 처리해 마지막에 모음 | 라우터 (2단계) |
| 순서 | 앞 결과를 뒤에 넘김 | 파이프라인 (69일차) |
오늘 Lab의 요청은 모두 독립입니다. 내 업무 시나리오(64일차 Lab 03)에서 순서가 있는 업무가 있는지도 표에 적어 둡니다.
## 4. 나누기는 LLM, 판단은 규칙
업무 나누기 워크플로우에서 할 일을 둘로 나눕니다.
| 일 | 누가 | 왜 |
| --- | --- | --- |
| 문장을 업무로 나누고 유형 고르기 | LLM (AI Agent, Tool 없음) | 문장의 뜻을 알아야 함 |
| LLM 답을 JSON으로 읽고, 이상한 값 고치기 | Code | LLM은 앞뒤에 다른 글자를 붙이거나 없는 유형을 쓰기도 함 |
| 유형 → 담당 Agent | Code (표) | 늘 같은 답 |
| 필요한 입력이 있나 | Code (본문 빈 칸 확인) | 짐작하면 안 됨. 테스트로 확인할 수 있어야 함 |
수업 준비 때 정답 워크플로우의 결과 (1회, gpt-4.1-mini):
| 워크플로우 | `cases/lab01.json` | `cases/lab01-challenge.json` |
| --- | --- | --- |
| 나누기만 (예제 2) | 7/7 (0.9~3.2초) | 0/4 (담당·빠진 입력 칸 없음) |
| 규칙을 Code에 더함 (과제) | 7/7 | 4/4 |
> 67일차의 "LLM이 정하면 안 되는 값"(요청한 사람)과 같은 생각입니다. **매번 같아야 하는 것은 규칙으로.**
## 5. 분석표를 요청문으로
67일차 요청문 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)는 그대로입니다. 오늘은 ③ 처리에 **분석표의 기준**이 들어갑니다. Lab과 다른 업무(총무 요청)로 예를 듭니다.
```text
my-work/office.json 에 n8n 워크플로우를 만들어 줘.
① 파일: my-work/office.json
② 입력: POST 웹훅 my-office, 본문 message(요청 문장), room(회의실 이름, 없을 수 있음)
③ 처리: AI Agent(Tool 없음)가 문장을 업무로 나눈다. 유형은 reserve(회의실 예약), parcel(택배 문의), other.
업무 하나 = 한 번에 처리할 수 있는 요청 하나. 날짜·시간은 쓴 그대로 남긴다.
Code에서 결과를 읽고, reserve 인데 본문 room 이 비었으면 missing ["room"].
④ 결과: {"count": 2, "tasks": [{"type": "reserve", "text": "...", "missing": []}]}
⑤ 기대 결과: cases/office.json (케이스 4개)
```
- 유형마다 **정의**(무엇이 여기에 드는지)를 적었습니다. 정의가 없으면 "회의실이 추워요"가 reserve인지 other인지를 LLM이 그때그때 정하게 됩니다.
- 규칙 부분은 "Code에서"라고 **어디서 할지**까지 적었습니다.
- Lab 01에서는 이 모양을 보고 **IT 문의 · 회의록** 요청문을 직접 씁니다.
## 정리
- [ ] 업무 분석 다섯 칸: 업무 · 유형 · 필요한 입력 · 요청에 있나 · 관계
- [ ] 필요한 입력은 문장 밖(본문의 다른 칸)에도 있다
- [ ] 나누기와 유형은 LLM, 담당과 빠진 입력은 규칙
+133
View File
@@ -0,0 +1,133 @@
# 2단계 · Multi-Agent 워크플로우 설계
- 교시: 3~4교시 (11:00~12:50)
- 실습: [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md)
- 이 단계가 끝나면: Agent의 **계약**을 정리하고, 업무를 유형별로 담당 Agent에게 보내 결과를 모으는 **라우터**를 요청문으로 만들 수 있다. 라우터와 **Supervisor**의 차이를 측정값으로 설명할 수 있다.
```mermaid
flowchart LR
A["1. 계약이 있는<br/>Agent"] --> B["2. 네 가지 모양"] --> C["3. Agent를 부르는 법"] --> D["4. 라우터 구현"] --> E["5. Supervisor와<br/>비교"]
```
## 1. 계약이 있는 Agent
Agent를 여럿 묶으려면 각 Agent가 **무엇을 받고 무엇을 돌려주는지**가 정해져 있어야 합니다. 이것이 계약입니다.
| Agent | 웹훅 | 입력 | 결과 | 실패할 때 |
| --- | --- | --- | --- | --- |
| IT 문의 (67일차) | `/webhook/it-agent` | `question`, `user` 필수 | `route`, `answer`, `tools_used`, `sources` | 입력 없음 400 · 근거 없음·남의 접수 건 `route: 담당자 연결` |
| 회의록 (66일차) | `/webhook/meeting-agent` | `title`, `notes`(필수) | `route`, `count`, `summary`, `actions[]` | `notes` 없음 400 |
- 라우터는 Agent의 **속**(지시문, Tool)을 모릅니다. 계약만 압니다. 그래서 IT 문의 Agent의 지시문을 고쳐도 계약이 그대로면 라우터는 그대로입니다.
- 계약에서 가장 빠뜨리기 쉬운 칸이 **실패할 때**입니다. 3단계에서 이 칸 때문에 전체가 멈추는 것을 봅니다.
- 계약 표는 에이전트에게 **Agent 파일을 읽혀** 만들게 할 수 있습니다 (Lab 02 예제 1). 그 표를 `call` 결과와 대조하는 것은 사람의 일입니다.
수업 준비 때 직접 부른 결과: IT 문의 4.8초 (`Get_ticket`, "처리 중"), 회의록 2.3초 (할 일 4개), `notes` 없이 400 `{"error": "notes are required"}`.
## 2. Agent를 묶는 네 가지 모양
```mermaid
flowchart TB
subgraph R["라우터"]
r0["나누기"] --> r1["A"] & r2["B"] --> r3["모으기"]
end
subgraph P["파이프라인"]
p1["A"] --> p2["B"] --> p3["C"]
end
subgraph F["Fork-Join"]
f0["같은 입력"] --> f1["A"] & f2["B"] --> f3["합치기"]
end
subgraph S["Supervisor"]
s0{{"LLM"}} -.->|Tool| s1["A"]
s0 -.->|Tool| s2["B"]
end
```
| 모양 | 누가 다음을 정하나 | 어울리는 업무 | 이 과정에서 |
| --- | --- | --- | --- |
| 라우터 | 정해진 규칙 (Switch) | 업무가 독립이고 종류가 정해짐 | 오늘 Lab 02 |
| 파이프라인 | 정해진 순서 | 앞 결과가 뒤에 필요 (초안 → 검토) | 69일차 |
| Fork-Join | 정해진 흐름 | 같은 입력을 여러 관점으로 보고 합침 | 69일차 |
| Supervisor | LLM | 요청이 열려 있고 무엇이 필요한지 미리 모름 | 오늘 Lab 02 과제 |
## 3. n8n에서 다른 Agent를 부르는 법
| 방법 | 어떻게 | 오늘 |
| --- | --- | --- |
| HTTP Request로 웹훅 부르기 | 게시된 Agent의 웹훅에 POST | **라우터** (계약을 HTTP로 확인하기 쉬움) |
| HTTP Request **Tool**로 붙이기 | Agent가 Tool로 골라 부름 | **Supervisor** |
| Execute Workflow | 같은 n8n 안의 워크플로우를 직접 실행 | 70일차 (모듈화) |
n8n 안에서 같은 n8n의 웹훅을 부를 때 주소는 `http://localhost:5678/webhook/<path>`입니다 (같은 컨테이너 안). 데이터 API(`http://data-api:8800`)와 다릅니다. [AGENTS.md](../../Last3Mon/68/AGENTS.md) 2절에 적어 두었습니다.
넘기는 값의 **출처**를 정합니다 (67일차 2단계와 같은 질문).
| 값 | 출처 | 이유 |
| --- | --- | --- |
| IT 질문 문장 | 나누기 결과 `text` | LLM이 나눈 조각 |
| 요청한 사람 `user` | 웹훅 본문 그대로 | LLM이 바꾸면 남의 접수 건이 보일 수 있음 |
| 회의록 원문 `notes` | 웹훅 본문 그대로 | LLM이 다시 쓰면 내용이 줄거나 바뀔 수 있음 |
## 4. 라우터 구현
```mermaid
flowchart LR
W(["Assistant webhook"]) --> H{"Has message?"} --> S{{"Split tasks"}} --> P["Parse tasks"] --> O["One item per task"] --> SW{"Route by type"}
SW -->|it| CI["Call IT agent"] --> RI["IT result"]
SW -->|meeting| CM["Call meeting agent"] --> RM["Meeting result"]
SW -->|other| RO["Other result"]
RI & RM & RO --> M["Collect results<br/>(Merge)"] --> A["All results<br/>(Aggregate)"] --> B["Build reply"] --> R(["Send result"])
```
| 노드 | 하는 일 | 놓치기 쉬운 것 |
| --- | --- | --- |
| One item per task (Code) | 업무 하나당 항목 하나, `index` 붙이기 | 다음 노드는 **항목마다** 실행됨 |
| Route by type (Switch) | 유형별 출력 | 출력 순서 = 연결의 칸 순서 |
| Call … agent (HTTP Request) | Agent 웹훅에 POST | 본문 값의 출처 (3절) |
| … result (Set) | Agent마다 다른 결과를 **같은 모양**으로 | 회의록은 `summary`·`actions` → `answer` |
| Collect results (Merge) · All results (Aggregate) | 갈래를 모아 `results` 배열 하나로 | 데이터가 안 온 갈래가 있어도 동작 |
| Build reply (Code) | `index` 순으로 정렬해 `reply` | 업무 순서 = 요청 순서 |
수업 준비 때 정답 라우터 (`reference/lab02-example2-router.json`, 1회):
| 케이스 | 결과 · 시간 |
| --- | --- |
| `cases/lab02.json` 전체 | 6/6 |
| 복합 요청 (업무 3개) | 6.6초 |
| IT 하나 · 회의록 하나 | 2.6초 · 3.5초 |
| 기타만 (Agent를 부르지 않음) | 0.9초 |
n8n **Executions**를 열면 `my-assistant` 실행 하나에 `it-agent`, `meeting-agent` 실행이 **따로** 생깁니다. Agent마다 실행 기록이 남으니 어느 Agent에서 문제가 났는지 찾기 쉽습니다.
## 5. Supervisor와 비교
같은 요청을 LLM 하나(Supervisor)가 두 Agent를 Tool로 골라 부르게 만들 수 있습니다.
```mermaid
flowchart LR
W(["Assistant webhook"]) --> SV{{"Supervisor agent"}} --> B["Build reply<br/>agents_used"] --> R(["Send result"])
M["OpenAI chat model"] -.-> SV
T1["Ask IT agent"] -.-> SV
T2["Ask meeting agent"] -.-> SV
```
수업 준비 때 정답 두 개를 같은 요청으로 비교했습니다 (gpt-4.1-mini, 측정값).
| | 라우터 | Supervisor |
| --- | --- | --- |
| 누가 Agent를 고르나 | 유형 표 (코드) | LLM (Tool 설명·지시문) |
| 복합 요청 시간 | 4.5~6.6초 | 9.6~10.8초 |
| 결과 | 업무별 `results[]` | 답 글자 하나 + `agents_used` |
| 라우터 테스트 `lab02.json`을 그대로 | 6/6 | 1/6 (빈 요청만, 계약이 다름) |
| 자기 테스트 `lab02-challenge.json` | — | 처음 4/5 → 지시문 고친 뒤 5/5 |
| Agent 하나 더 | Switch 갈래 + 결과 Set | Tool 하나 + 지시문 한 줄 |
- Supervisor가 처음 실패한 이유: 기타 요청에 "**담당 직원**이 도와드릴 예정입니다"라고 답해 테스트의 `담당자`가 없었습니다. 지시문에 정확한 문장을 주자 통과했습니다. **Supervisor는 지시문이 곧 분배 규칙**입니다.
- 결론은 "어느 쪽이 낫다"가 아니라 **계약이 있으면 같은 테스트로 비교할 수 있다**는 것입니다. 업무 종류가 정해져 있고 결과를 다른 시스템이 다시 쓰면 라우터, 요청이 열려 있고 사람에게 바로 답하면 Supervisor.
## 정리
- [ ] Agent는 계약(입력 · 결과 · 실패할 때)으로 묶는다
- [ ] 라우터: 나누기 → 항목마다 → Switch → HTTP → 같은 모양 → Merge·Aggregate → 답
- [ ] LLM이 정하면 안 되는 값(`user`, `notes`)은 웹훅 본문에서 그대로
- [ ] 라우터와 Supervisor는 시간·안정성·결과 모양이 다르고, 계약이 있어야 비교할 수 있다
+115
View File
@@ -0,0 +1,115 @@
# 3단계 · 검증과 장애 격리, 확장 고려
- 교시: 5~6교시 (14:00~15:50)
- 실습: [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md)
- 이 단계가 끝나면: Multi-Agent 워크플로우에서 **한 곳의 문제가 전체 답을 망치지 않게** 만들 수 있다. 측정값으로 확장 계획(수직·수평)을 설명할 수 있다.
```mermaid
flowchart LR
A["1. Multi-Agent는<br/>어디서 깨지나"] --> B["2. 빠진 입력"] --> C["3. 꺼진 Agent"] --> D["4. 어려운 테스트"] --> E["5. 측정과 확장"]
```
## 1. Multi-Agent는 어디서 깨지나
Agent가 하나일 때보다 깨지는 곳이 늘어납니다.
| 깨지는 곳 | 예 | 테스트 |
| --- | --- | --- |
| 나누기 | 업무 수가 틀림, 유형이 틀림 | `lab01` |
| 넘기는 값 | 필요한 입력이 없음 (`notes`, `user`) | `lab03` · 과제 |
| 부른 Agent | 꺼짐(404), 오류(400·500), 느림 | `lab03-down` |
| 모으기 | 결과 모양이 다름, 순서가 섞임 | `lab02` |
| Agent 규칙 | 라우터를 거치면서 67일차 규칙(남의 접수 건)이 깨짐 | 과제 |
## 2. 빠진 입력: 부르기 전에 막는다
Lab 02 예제 라우터에 회의록 없이 회의 요청을 보내면:
```mermaid
sequenceDiagram
participant 직원
participant 라우터 as my-assistant
participant 회의록 as meeting-agent
직원->>라우터: "회의록 할 일 정리 부탁해요" (notes 없음)
라우터->>회의록: POST {notes: ""}
회의록-->>라우터: 400 notes are required
Note over 라우터: Call meeting agent에서 멈춤
라우터-->>직원: 500 (아무 결과 없음)
```
수업 준비 때 `cases/lab03.json` 4/5, 이 케이스만 HTTP 500.
| 막는 방법 | 어떻게 | 직원이 받는 답 |
| --- | --- | --- |
| 부르기 전에 (권장) | Lab 01 과제의 `missing`이 있으면 Agent를 부르지 않고 `route: 입력 필요` | "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." |
| 받은 뒤에 | 400을 결과로 바꿈 (3절) | "담당자에게 전달" (다시 보내면 되는 걸 모름) |
> 직원이 **고쳐서 다시 보낼 수 있는 실패**는 무엇이 빠졌는지 알려 주고, 고칠 수 없는 실패(Agent 장애)는 사람에게 넘깁니다.
## 3. 꺼진 Agent: 하나가 꺼져도 나머지는 답한다
`uv run tools/flow.py off agents/meeting-agent.json`으로 회의록 Agent를 끄고 복합 요청을 보냅니다.
| | 응답 | T-1002 답 | 회의록 업무 | `lab03-down` |
| --- | --- | --- | --- | --- |
| 고치기 전 | **HTTP 500** | 사라짐 | — | 1/3 |
| `neverError` + 빈 결과는 담당자 연결 | 200 | "처리 중" | `담당자 연결` · "담당 Agent가 응답하지 않아 …" | 3/3 |
(수업 준비 때 측정, 1회)
장애 격리 세 가지:
| 장치 | n8n에서 | 하는 일 |
| --- | --- | --- |
| 오류를 결과로 | HTTP Request `options.response.response.neverError: true` | 404·500에도 멈추지 않고 응답을 다음 노드로 |
| 기다리는 한도 | `options.timeout` (예: 60000 ms) | 느린 Agent 때문에 직원이 끝없이 기다리지 않게 |
| 대체 답 | 결과 Set에서 `{{ $json.route \|\| '담당자 연결' }}` | 빈 결과를 정해진 답으로 |
- 67일차 IT 문의 Agent의 "검증 실패 → 담당자 연결"과 같은 생각을 **Agent 사이**에 한 번 더 둡니다.
- Agent를 나눠 둔 덕분에 회의록 Agent만 꺼도 IT 답은 나옵니다. Agent 하나에 다 붙였다면 함께 멈췄습니다.
## 4. 어려운 테스트: 내가 찾는 구멍
67일차 Lab 03처럼, 고친 것만 확인하는 테스트로는 부족합니다. 수업 준비 때 쓴 어려운 케이스 7개를 예제 2 정답(빠진 입력 + 장애 격리)에 돌린 결과 5/7이었습니다.
| 실패한 케이스 | 원인 | 고침 |
| --- | --- | --- |
| user 없이 IT 질문 | `missing: ["user"]`를 보지 않고 IT Agent를 부름 → 400 → 장애로 처리해 `담당자 연결` | IT 갈래도 `missing`을 보고 `입력 필요` |
| 공백만 보냄 | `" "`도 "비어 있지 않음" → LLM까지 감 | 앞뒤 공백을 지우고 확인 |
통과한 케이스도 남겨 둡니다: 남의 접수 건이 섞인 요청(라우터를 거쳐도 "정우성"이 나오지 않음), 업무 4개, 소문자 `t1002`, 영어 질문. 고친 뒤 7/7, `lab02` 6/6, `lab03` 5/5, `lab03-down` 3/3.
> 수업 준비 때 "남의 접수 건" 케이스의 기대를 처음에 `본인이 접수한`(답 문장)으로 써서 실패했습니다. IT 문의 Agent의 계약은 이때 `route: 담당자 연결`입니다. **테스트가 틀렸는지 워크플로우가 틀렸는지는 계약 표로 판단**합니다.
## 5. 측정과 확장
수업 준비 때 정답 워크플로우의 시간 (1회씩, 여러 테스트에서 모음):
| 요청 | 시간 |
| --- | --- |
| 기타만 (LLM 나누기 한 번, Agent 없음) | 0.9~1.0초 |
| IT 하나 | 2.3~2.6초 |
| 회의록 하나 | 3.5초 |
| 복합 요청 (업무 3개) | 4.5~6.6초 |
| 업무 4개 | 4.6~4.8초 |
이 숫자에서 읽을 것:
- 시간 대부분은 **LLM API를 기다리는 시간**입니다 (나누기 한 번 약 1초, Agent마다 LLM 한두 번).
- 요청 하나가 n8n 실행 여러 개를 만듭니다: 복합 요청 = 라우터 1 + Agent 3. 21명이 동시에 보내면 n8n 실행은 약 84개.
| 확장 | 무엇 | 이 측정값에서 |
| --- | --- | --- |
| 수직 | 더 큰 서버 (CPU · 메모리) | 기다리는 시간이 대부분이라 효과가 작음 |
| 수평 | n8n 큐 모드: 메인 1 + 작업자(worker) 여러 개, Redis 큐 | 동시에 처리하는 실행 수를 늘림 |
| Agent별 분리 | 자주 쓰는 Agent만 작업자를 더 주거나 따로 운영 | 업무별 Agent로 나눴기 때문에 가능 |
| 바깥 한도 | OpenAI 분당 요청·토큰 한도 | 실행을 늘리면 여기서 막힘 → 75일차 비용·사용량 |
- 큐 모드는 오늘 실습하지 않습니다. 확장 계획은 `DESIGN.md` 6절에 **내 측정값**으로 씁니다.
## 정리
- [ ] 고칠 수 있는 실패(빠진 입력)는 부르기 전에 막고, 무엇이 빠졌는지 알려 준다
- [ ] 부른 Agent의 실패는 `neverError` · timeout · 대체 답으로 그 업무에서 멈춘다
- [ ] 테스트 기대가 맞는지는 Agent 계약으로 판단한다
- [ ] 확장은 측정값으로: 기다리는 시간이 대부분이면 수평 확장과 바깥 한도가 먼저
+84
View File
@@ -0,0 +1,84 @@
# 7·8교시 · 바이브 코딩 (`tflow`)
- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 · 정리 · 강사 바이브 코딩 · 팁 · 예고)
- 과제: [VIBE-Q-TFLOW](../../Last3Mon/68/VIBE-Q-TFLOW.md)
- 오늘은 `tflow`: n8n 워크플로우 JSON을 **트리 · mermaid · 노드별 한국어 설명**으로 보여 주는 TUI입니다. 에이전트가 만든 n8n 파일을 확인하는 도구라 오늘 오전 실습과 바로 이어집니다.
```mermaid
flowchart LR
A["7교시<br/>각자 만들기 50분"] --> B["발표<br/>10분"] --> C["강사 정리<br/>10분"] --> D["강사 바이브 코딩<br/>15분"] --> E["보충 · 팁<br/>10분"] --> F["69일차 예고<br/>5분"]
```
## 1. 7교시 · 각자 만들기 (50분)
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:03 | 0. 준비 (`~/vibe/tflow`, `git init`, 에이전트 실행) | |
| 16:03~16:15 | 1. 글자로 트리 · mermaid (R1 · R4) | `uv run tflow agents/it-agent.json`, `--mermaid` |
| 16:15~16:30 | 2. TUI · 상세 · 경고 (R2 · R3 · R5) | `IT help agent`의 모델·Tool, 망가진 파일 |
| 16:30~16:43 | 3. 한국어 설명 · 캐시 (R3) | 두 번째 선택은 바로 |
| 16:43~16:50 | 4. 정리 · 내 `assistant.json` 열어 보기 · 발표 메모 | |
강사는 교실을 돌며 아래를 봅니다.
| 볼 것 | 막혔을 때 한마디 |
| --- | --- |
| 한 번에 TUI까지 만들어 달라고 했나 | "1단계는 글자 출력만. 트리가 맞는지부터" |
| 트리가 끝없이 길어지거나 같은 노드 반복 | "다시 모이는 노드(`Send result`)는 두 번째부터 한 줄로" |
| mermaid가 안 그려짐 | "노드 이름에 `?`·괄호가 있어요. id는 번호로" |
| If 갈래가 반대 | "`main` 바깥 목록 0번이 true" |
| 설명에 Credential이 들어감 | "credentials는 빼고 보내라고 하세요" |
| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tflow`" |
확인 기준 (입력 파일에서 센 값): `agents/it-agent.json` 노드 14 · main 11 · 모델 1 · Tool 2, `reference/lab03-example2-isolated.json` 노드 18 · main 19 · Switch 출력 4개. 수업 준비 때 tflow를 미리 만들어 보지는 않았습니다. 강사 바이브 코딩(4절)에서 걸린 시간을 재어 정답본(`VIBE-A-TFLOW.md`)에 남깁니다.
## 2. 8교시 · 발표 (10분)
- 1~2명. 7교시에 돌면서 **다시 모이는 노드나 mermaid 깨짐을 스스로 푼 사람**을 먼저 고릅니다.
- 3분 발표 · 2분 질문: 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳과 알아챈 방법 / 내 `assistant.json` 시연.
## 3. 강사 정리 (10분)
오늘 n8n 실습과 tflow를 이 표로 묶습니다.
| | n8n 사내 업무 도우미 | tflow |
| --- | --- | --- |
| 나누기 | 요청 → 업무 (LLM) | 파일 → 노드·연결 목록 (코드) |
| 계약 | Agent 입력·결과 | `model.py`의 Node·Link (트리·mermaid·TUI가 함께 씀) |
| 규칙이 할 일 | 담당 Agent, 빠진 입력 | 갈래 이름, 다시 모이는 노드, 경고 |
| LLM이 할 일 | 업무 나누기 | 노드 설명 (캐시) |
| 실패해도 계속 | 꺼진 Agent → 담당자 연결 | 키 없음·LLM 실패 → 트리는 그대로 |
| 확인 | `cases/*.json` | 노드·연결 수 테스트, 망가진 파일 |
## 4. 강사 바이브 코딩 (15분)
학생 화면 앞에서 강사가 직접 이어서 만듭니다. 하나를 고릅니다.
| 주제 | 요청문 요지 | 보여 줄 것 |
| --- | --- | --- |
| `tflow diff a.json b.json` | "두 워크플로우의 노드 추가·삭제, 바뀐 parameters를 표로" | 74일차 `tver`의 예고. 에이전트가 고친 곳을 한눈에 |
| `flow.py check` 가져오기 | "Last3Mon/68/tools/flow.py 의 check 규칙을 읽고 tflow 경고에 더해" | 이미 있는 코드를 **읽혀서** 재사용 |
| 실행 기록 붙이기 | "runs/*.json 보고서를 읽어 실패한 케이스가 지나간 노드를 표시" | 테스트와 구조를 한 화면에 (77일차 `tlog` 예고) |
걸린 시간과 결과를 `Last3Mon/68/VIBE-A-TFLOW.md`에 적어 둡니다.
## 5. 보충 · 팁 (10분)
| 팁 | 설명 |
| --- | --- |
| 데이터 모양부터 | "파일 → Node·Link"를 먼저 만들고 테스트하면 화면은 나중에 얼마든지 바꿈 |
| 테스트 자료는 진짜 파일로 | 오늘 쓴 `agents/*.json`을 `tests/data`에 복사. 숫자(노드 14)로 확인 |
| 망가진 입력도 테스트 | 경고 기능은 망가진 파일이 있어야 확인됨 |
| 비싼 일은 캐시 | LLM 설명은 내용(해시)으로 캐시, 바뀌면 다시 |
| 비밀은 보내지도 않기 | Credential, 키는 LLM 입력에서 빼기 |
| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 |
## 6. 69일차 예고 (5분)
- 오늘: 독립 업무를 Agent에게 **나눠** 맡김 (라우터). 내일: 한 업무를 역할별 Agent가 **이어서** 처리 (작성자 → 검토자 → 정리자, 역할 기반 분리와 협업).
- 바이브 코딩: `tcrew` (한 질문을 역할별 LLM이 차례로 처리하는 TUI). 오늘 `tflow`는 지우지 마세요. 내일 만든 워크플로우를 여는 데 씁니다.
## 확인 (강사)
- [ ] 7교시 시작 전 `OPENAI_API_KEY` 꺼내는 명령을 화면에 띄웠다 (키 값은 띄우지 않음).
- [ ] 발표자 1~2명, 강사 정리 표, 강사 바이브 코딩 1개(시간 기록), 팁, 예고.
+39
View File
@@ -0,0 +1,39 @@
# 68일차 · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계 (바이브 코딩)
- 일자: 2026-10-20 (68일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 학습 내용: 복합 업무 시나리오 분석, Multi-Agent 기반 Workflow 설계
- 실습 폴더: [Last3Mon/68](../../Last3Mon/68/README.md)
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`) + 67일차 데이터 API + **CLI 코딩 에이전트**(Codex · Claude Code · Antigravity 중 하나)
- **실습 준비: [00-DAY68-PREP.md](00-DAY68-PREP.md) (수업 전날까지 1~3절 완료)**
## 오늘의 시간표
| 교시 | 시간 | 내용 | 자료 |
| --- | --- | --- | --- |
| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 · 복합 업무 시나리오 분석 | [개요](00-DAY68-OVERVIEW.md), [1단계](01-STAGE1-COMPOSITE-SCENARIO.md) |
| 2 | 10:00~10:50 | 1단계 · Lab 01 (분석표, 업무 나누기, 과제: 담당·빠진 입력) | [Lab 01](../../Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md) |
| 3 | 11:00~11:50 | 2단계 · Multi-Agent 워크플로우 설계, Lab 02 예제 1·2 | [2단계](02-STAGE2-MULTI-AGENT-DESIGN.md), [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md) |
| 4 | 12:00~12:50 | 2단계 · Lab 02 과제 35분 (Supervisor로도 만들어 비교) · 풀이 | [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md) |
| - | 13:00~14:00 | 점심 | |
| 5 | 14:00~14:50 | 3단계 · 검증과 장애 격리, Lab 03 예제 1·2 | [3단계](03-STAGE3-VERIFY-AND-SCALE.md), [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md) |
| 6 | 15:00~15:50 | 3단계 · Lab 03 과제 40분 (어려운 테스트 · 확장 계획) · 풀이 | [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md) |
| 7 | 16:00~16:50 | 바이브 코딩 · `tflow` 만들기 (각자) | [VIBE-Q-TFLOW](../../Last3Mon/68/VIBE-Q-TFLOW.md), [진행](04-VIBE-CODING.md) |
| 8 | 17:00~17:50 | 바이브 코딩 발표 · 정리 · 강사 바이브 코딩 · 팁 · 69일차 예고 | [진행](04-VIBE-CODING.md) |
```mermaid
flowchart LR
P["개요"] --> S1["1단계<br/>복합 시나리오 분석<br/>업무 나누기"] --> S2["2단계<br/>라우터 · Supervisor"] --> S3["3단계<br/>장애 격리 · 확장"] --> V["7·8교시<br/>바이브 코딩 tflow"]
```
## 자료 목록
| 파일 | 내용 |
| --- | --- |
| `00-DAY68-PREP.md` | 실습 준비: n8n · 데이터 API 켜기, 두 Agent 게시, `flow.py doctor` (수업 전) |
| `00-DAY68-OVERVIEW.md` | 개요 13장 (PPT 대신 Markdown, 가로줄로 장 구분) |
| `01-STAGE1-COMPOSITE-SCENARIO.md` | 복합 요청, 업무 분석 다섯 칸, 업무 관계, LLM이 할 일과 규칙이 할 일 |
| `02-STAGE2-MULTI-AGENT-DESIGN.md` | Agent 계약, 라우터 · 파이프라인 · Fork-Join · Supervisor, 라우터 구현, Supervisor와 비교 |
| `03-STAGE3-VERIFY-AND-SCALE.md` | Multi-Agent 테스트, 빠진 입력, 장애 격리(`neverError`, 대체 답), 측정, 수직·수평 확장 |
| `04-VIBE-CODING.md` | 7·8교시 바이브 코딩 진행 (tflow), 발표, 강사 정리, 추가 바이브 코딩, 팁 |