Files
aio-01d4-stu/Last3Mon/74/LAB-02-Q-RELEASE-GATE.md

406 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lab 02 · 릴리스 고르기, 후보 시험, 배포 전 게이트
- 관련 강의: [2단계 · 릴리스와 배포 전 게이트](../../Lecture/74/02-STAGE2-RELEASE-GATE.md)
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 8분 · 예제 3 7분 · 과제 1 13분 · 과제 2 10분
- 준비: Lab 01의 `my-work/helpdesk.json` (없으면 `cp reference/lab01-example2-version.json my-work/helpdesk.json` 후 push. 지시문 저장소 · 기본 지시문은 Lab 01 과제 1 · 2를 끝낸 것)
- 목표: 워크플로우가 **운영 중인 릴리스**를 데이터 API에서 읽게 하고, 후보 릴리스를 운영을 바꾸지 않고 **고정해서 시험**하고, 배포 전에 **게이트**로 통과 여부를 숫자로 정한다. 게이트의 기준은 재서 정하고, 나쁜 릴리스를 못 막는 게이트를 고치고, **망가진 릴리스**는 장애와 구분해 크게 실패하게 한다.
```mermaid
flowchart LR
E1["예제 1<br/>릴리스 읽기 ·<br/>후보 고정"] --> E2["예제 2<br/>게이트로<br/>네 릴리스 재기"] --> E3["예제 3<br/>기준선 재기<br/>시간 · 흔들림"] --> T1["과제 1<br/>나쁜 릴리스를<br/>막는 게이트"] --> T2["과제 2<br/>망가진 릴리스는<br/>400"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 기록은 `cp templates/RELEASE-CHECKLIST-TEMPLATE.md my-work/RELEASE.md`에.
## 예제 1 · 운영 중인 릴리스를 읽고, 후보는 고정해서 (12분)
```bash
uv run tools/flow.py release helpdesk # active 1.0.0, 릴리스 다섯 개
uv run tools/flow.py test cases/lab02.json -v # 고치기 전
```
**만들 것**: 릴리스 정보(Set)를 데이터 API(`GET /releases/helpdesk`)로 바꾸기. 본문에 `release`가 있으면 그 릴리스로 **고정**.
| 조건 | 값 |
| --- | --- |
| 정하는 순서 | 본문 `release`(고정, `channel: pinned`) → 없으면 `active`(`channel: stable`) |
| 없는 릴리스 | HTTP 400 `{"error": "unknown release", "release", "known"}` |
| 지시문 · 모델 | 고른 릴리스의 `prompt_version` · `model` |
| 기대 결과 | `cases/lab02.json` 5개, `lab01-challenge` · `lab01-challenge2`도 그대로 |
### 이해하기
**지금 무엇이 문제인가.** Lab 01의 릴리스 정보는 워크플로우 **안의** Set 노드입니다. 1.1.0을 내보내려면 Set의 값을 고치고 push 해야 합니다. push 하면 n8n이 재시작되고, "무엇이 바뀌었나"에 워크플로우 변경이 섞이고, 되돌릴 때도 다시 push 합니다. 그리고 1.1.0을 **운영에 내보내기 전에** 시험해 볼 방법이 없습니다.
```mermaid
flowchart LR
subgraph 지금
S1["Release info (Set)<br/>1.0.0 고정"] --> A1{{"Answer"}}
end
subgraph 바꾼 뒤
G2["GET /releases/helpdesk"] --> P2{"Pick release<br/>본문 release?"}
P2 -->|있음| F2["그 릴리스 · pinned"]
P2 -->|없음| S2["active · stable"]
F2 --> A2{{"Answer"}}
S2 --> A2
end
```
**배포 = 데이터 API의 값 하나.** 운영 중인 릴리스(`active`)를 워크플로우가 **매번 읽게** 하면, 배포는 그 값을 바꾸는 일이 됩니다 (3단계 `flow.py release`). 워크플로우는 그대로, push도 재시작도 없습니다. 이것을 **설정과 코드의 분리**, 또는 원격 설정(remote config)이라고 합니다.
**고정(pin)은 후보 시험의 문.** 본문에 `release: "1.1.0"`을 넣으면 운영과 상관없이 그 릴리스로 답합니다. 운영 사용자는 계속 1.0.0을 받고, 테스트만 1.1.0을 받습니다. `flow.py test ... --set release=1.1.0`이 모든 요청에 이 칸을 더해 줍니다. 실제 서비스에서는 이 문을 **운영자 · 테스트에만** 엽니다 (73일차: 누구나 쓰게 두면 공격면).
**`channel` 세 가지.**
| `channel` | 언제 | 예제 |
| --- | --- | --- |
| `stable` | 본문에 `release`가 없고 카나리 대상도 아님 | 오늘 예제 1 |
| `pinned` | 본문 `release`로 고정 | 오늘 예제 1 |
| `canary` | 카나리 대상 직원 (Lab 03 예제 2) | 3단계 |
**없는 릴리스는 400.** `release: "9.9.9"`처럼 모르는 이름을 고정하면, 그냥 active로 답해 버리지 말고 **400으로 거절**합니다. 테스트가 엉뚱한 릴리스를 시험하고 PASS를 받는 일을 막습니다.
**테스트 케이스 (`lab02`).**
| 케이스 | 보내는 것 | 기대 |
| --- | --- | --- |
| 운영 중인 릴리스 (active) | VPN | 1.0.0 · `stable` · p1 |
| 후보 시험 · 1.1.0 고정 | VPN + `release 1.1.0` | 1.1.0 · `pinned` · p2, `[FAQ-003]` |
| 후보 시험 · 2.0.0 (작은 모델) | VPN + `release 2.0.0` | 2.0.0 · `gpt-5.4-nano` |
| 없는 릴리스 | `release 9.9.9` | 400, `known`에 1.0.0 |
| 빈 질문 | | 400 |
**기대 결과.** 고치기 전에는 고정 케이스들이 ✘(늘 1.0.0). 고친 뒤 5/5, `lab01-challenge` 3/3, `lab01-challenge2` 4/4.
<details><summary>▶ 1단계 · 왜 밖에서 읽나</summary>
릴리스를 바꿀 때마다 push 하면 n8n이 재시작되고, "무엇이 바뀌었나"에 워크플로우 변경이 섞입니다. 밖에서 읽으면 **배포 = 데이터 API의 값 하나**를 바꾸는 일이 됩니다 (3단계). `curl -s localhost:8800/releases/helpdesk`로 무엇이 오는지 먼저 봅니다.
</details>
<details><summary>▶ 2단계 · 고르는 일은 코드가</summary>
"본문에 release가 있으면 그것, 없으면 active"는 틀리면 안 되는 규칙이라 LLM이 아니라 Code 노드가 정합니다 (AGENTS.md 5절 "릴리스 고르기"). 고른 릴리스의 `prompt_version` · `model`을 다음 노드들이 읽습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바꿀 노드와 새 노드 ② 고르는 순서와 channel ③ 모르는 릴리스 ④ 다음 노드들이 읽을 곳 ⑤ 그대로 둘 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Release info(Set) 를 Get release(HTTP GET http://data-api:8800/releases/helpdesk) → Pick release(Code) → Known release?(If) 로 바꿔 줘.
Pick release: 본문 release 가 있으면 그 릴리스 · channel ____① , 없으면 active · channel ____② . 고른 릴리스의 prompt_version · model 을 돌려준다.
releases 에 없으면 Known release? false → Reject release(400, {error: 'unknown release', release, ____③ }).
Get prompt · Answer model · Build result 는 Release info 대신 ____④ 를 읽게. 기본 지시문(Fallback prompt)은 그대로.
기대 결과 cases/lab02.json, cases/lab01-challenge.json, cases/lab01-challenge2.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① · ② 위 이해하기 "`channel` 세 가지" 표.
- ③ 400 응답에 "알려진 릴리스 목록"을 담는 칸 이름입니다 (`cases/lab02.json`의 "없는 릴리스" 기대, AGENTS.md 3절).
- ④ 릴리스를 고른 노드의 이름입니다 (이 요청문의 첫 줄).
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py test cases/lab01-challenge.json # 원래 것도
uv run tools/flow.py test cases/lab01-challenge2.json
```
## 예제 2 · 게이트로 네 릴리스를 재기 (8분, 에이전트 없이)
`cases/golden.json`은 운영팀이 "배포 전에 꼭 돌리는" 테스트입니다. 열어 읽어 봅니다.
```bash
uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --max-p95 3
uv run tools/flow.py gate cases/golden.json --set release=1.1.0 --max-p95 3
uv run tools/flow.py gate cases/golden.json --set release=1.2.0 --max-p95 3
uv run tools/flow.py gate cases/golden.json --set release=2.0.0 --max-p95 3
uv run tools/flow.py call my-helpdesk '{"question": "프린터 출력이 안 돼요", "user": "최유진", "release": "1.2.0"}' -v
```
`RELEASE.md` 1절의 "약한 golden" 칸을 채웁니다. 1.2.0의 답을 직접 읽어 보면 내보내도 될까요?
### 이해하기
**게이트(gate)는 문지기입니다.** 배포 전에 정해 둔 테스트를 돌리고, 기준을 넘으면 PASS(문이 열림), 못 넘으면 FAIL(문이 닫힘). 사람의 "괜찮아 보이는데요"가 아니라 **숫자**로 정합니다.
```mermaid
flowchart LR
C(["후보 1.2.0"]) --> T["golden.json<br/>--set release=1.2.0"] --> J{"통과율 ≥ 100%<br/>p95 ≤ 3초"}
J -->|예| P(["PASS · 종료 코드 0"])
J -->|아니오| F(["FAIL · 종료 코드 1"])
```
| 게이트 기준 | 오늘 | 뜻 |
| --- | --- | --- |
| 통과율 | `--min-pass 1.0` (기본) | 케이스가 모두 통과해야 |
| 처리시간 | `--max-p95 3` | 케이스 시간의 p95가 3초 이내 |
| 결과 | PASS / FAIL, 종료 코드 0 / 1 | 자동화(CI)가 종료 코드로 다음 단계를 정함 |
| 기록 | `/gates/helpdesk`에 한 줄 | 3단계에서 "게이트를 지난 것만 배포"에 씀 |
**네 릴리스는 한 가지씩만 다릅니다.** 1.1.0은 지시문만(출처 번호), 1.2.0은 지시문만(한 문장으로 짧게), 2.0.0은 모델만(작은 모델) 바뀌었습니다. 그래서 1.0.0과 결과가 다르면 **그 한 가지** 때문이라고 말할 수 있습니다. Lab 01 예제 3에서 v2로는 할 수 없던 일입니다.
**golden이 무엇을 확인하나 읽어 봅니다.** `golden.json`의 케이스 6개는 `status: 200`과 `route: 자동 답변`만 봅니다. 답의 **내용**은 보지 않습니다. 이 게이트가 PASS라는 말은 "200으로 답이 나왔다"는 뜻일 뿐입니다.
**기대 결과.** 네 릴리스 모두 PASS. 그런데 1.2.0의 답은 "IT 포털에서 확인해 주세요" 같은 떠넘기기입니다. **게이트는 테스트만큼만 막습니다.**
<details><summary>▶ 1단계 · 게이트가 PASS인데</summary>
게이트는 테스트를 돌릴 뿐입니다. golden이 무엇을 확인하는지(상태 코드? 답의 내용?) 보면 PASS의 뜻이 보입니다. 1.2.0의 지시문은 `curl -s localhost:8800/prompts/helpdesk/p3`.
</details>
## 예제 3 · 기준선 재기: 시간과 흔들림 (7분, 에이전트 없이)
예제 2의 `--max-p95 3`은 누가 정한 숫자일까요? 기준은 **지금(1.0.0)을 재서** 정합니다.
```bash
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=1.0.0
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=2.0.0
uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --repeat 3 --max-p95 3
```
`RELEASE.md` 1절 아래에 "기준선" 한 줄(1.0.0 p50 · p95, 2.0.0 p50 · p95, 3번 반복 통과 수)을 적습니다.
### 이해하기
**기준선(baseline)이 없으면 기준도 없습니다.** "3초 안"이라는 기준은 지금 운영 중인 1.0.0이 보통 몇 초인지 알아야 의미가 있습니다. 1.0.0이 늘 1초면 3초는 너무 느슨하고, 늘 2.8초면 너무 빡빡해서 같은 릴리스도 FAIL이 납니다. 기준은 **재서 정하고, 여유를 둡니다.**
| 정하는 법 | 예 | 좋은 점 · 나쁜 점 |
| --- | --- | --- |
| 감으로 | "3초면 되겠지" | 쉽지만 근거 없음 |
| 기준선 × 여유 | 1.0.0 p95 × 1.5 | 지금보다 많이 나빠지는 것만 막음 |
| 서비스 목표(SLO)에서 | "직원 문의 95%는 5초 안" | 사용자 쪽 약속에서 내려옴 (78일차) |
**흔들림도 기준선입니다.** 같은 릴리스로 같은 테스트를 3번 돌려 **3번 다 통과하는지** 봅니다. 1.0.0이 가끔 FAIL이면, 후보가 FAIL 했을 때 그것이 후보 탓인지 흔들림 탓인지 모릅니다. 흔들리는 테스트(flaky test)는 게이트를 믿지 못하게 만듭니다. 고치거나(기대 낱말을 데이터에 맞게), 반복을 기준에 넣습니다 (`--repeat 3`).
**작은 모델의 시간.** 2.0.0은 지시문은 1.1.0과 같고 모델만 작습니다. 빨라졌다면 모델 덕분이라고 말할 수 있습니다 (한 가지만 바꿨으니까). 75일차에는 같은 비교를 **비용**으로 합니다.
**기대 결과.** 1.0.0 · 2.0.0의 p50 · p95 (실행마다 다름), 1.0.0의 golden 3번 반복 18/18.
<details><summary>▶ 1단계 · p95는 다섯 번으로 충분한가</summary>
`-n 5`면 p95는 다섯 개 중 가장 느린 값입니다 (72일차 nearest-rank). 한 번 튄 값이 그대로 p95가 됩니다. 기준을 정할 때는 더 많이(`-n 20`) 재는 것이 좋지만 오늘은 시간과 비용 때문에 5번입니다.
</details>
## 과제 1 · 나쁜 릴리스를 막는 게이트 (13분)
> "어제 1.2.0을 게이트 통과로 내보냈다가 '포털 보세요'만 하는 답 때문에 문의가 쏟아졌어요. 게이트가 이런 릴리스를 막게 해 주세요. 대신 1.0.0 · 1.1.0은 지금처럼 통과해야 합니다. 그리고 작은 모델(2.0.0)로 바꿔도 되는지도 같은 게이트로 알려 주세요."
**만들 것**: `cases/my-golden.json` — 1.0.0 · 1.1.0은 PASS, 1.2.0은 FAIL이 되는 게이트 테스트. 그 게이트로 2.0.0을 판단.
| 조건 | 값 |
| --- | --- |
| 바꾸지 않을 것 | 워크플로우 (`my-work/helpdesk.json`) — **테스트만** 고친다 |
| 기대 | `gate cases/my-golden.json --set release=1.0.0` · `1.1.0` PASS, `1.2.0` FAIL |
| 판단 | 2.0.0의 게이트 결과 · p95를 `RELEASE.md`에, 내보낼지 한 줄 |
**먼저 생각해 보기 (3분)**: 1.2.0의 답에 **없는** 것은 무엇인가요? 질문마다 "이 말이 없으면 답이 아니다"인 낱말을 정답표처럼 정합니다 (71일차).
### 이해하기
**테스트를 강하게.** 약한 golden은 "답이 나왔나"만 봤습니다. 강한 golden은 "**맞는** 답이 나왔나"를 봅니다. 그 기준을 어디서 가져오느냐가 핵심입니다.
```mermaid
flowchart LR
D[("FAQ · 접수 건 데이터")] --> K["질문마다<br/>꼭 있어야 할 낱말"] --> G["my-golden.json<br/>contains · not_contains"]
G --> X{"게이트"}
X -->|1.0.0 · 1.1.0| P(["PASS"])
X -->|1.2.0| F(["FAIL"])
```
**기대는 데이터에서, 사람이.** 기대 낱말을 정하는 방법은 둘입니다.
| | 지금 답을 보고 기대를 만듦 | 데이터를 보고 기대를 정함 |
| --- | --- | --- |
| 방법 | 1.0.0의 답을 복사해 기대로 | FAQ의 해결 방법 · 접수 건의 상태 · 예정일에서 |
| 문제 | 1.0.0이 틀린 것도 정답이 됨 | 사람이 시간을 씀 |
| 오늘 | 하지 않음 | 이렇게 |
`curl -s 'localhost:8800/faq?q=프린터'`, `curl -s 'localhost:8800/tickets/T-1002'`로 데이터를 봅니다.
**무엇을 확인하나.**
| 질문 | 확인할 것 | 판정 칸 |
| --- | --- | --- |
| FAQ 질문 4개 | 해결 방법의 핵심 낱말 (예: VPN은 "인증서") | `contains` |
| 접수 질문 2개 | 상태("처리 중" · "완료"), 예정일 | `contains` |
| 떠넘기기 | "포털을 확인" 같은 말이 없음 | `not_contains` |
`contains`는 목록의 낱말 중 **하나라도** 있으면 통과입니다. 같은 뜻의 다른 말(예: "최신" · "업데이트")을 같이 적으면 흔들림이 줄어듭니다.
**금지 문구는 글자가 정확해야.** `not_contains: ["포털을 참고"]`를 적었는데 1.2.0이 "포털**에서** … 확인해 주세요"라고 쓰면 걸리지 않습니다. 떠넘기기를 막는 데는 금지 문구보다 **해결 낱말이 있는지**(`contains`)가 더 튼튼합니다.
**작은 모델 판단.** 2.0.0이 PASS라면 "게이트가 보는 것은 괜찮다"는 뜻입니다. 말투 · 길이 · 드문 질문 · 비용은 게이트가 보지 않습니다. 그래서 바로 전원에게 내보내지 않고 카나리로 먼저 (3단계).
**기대 결과.** 1.0.0 · 1.1.0 PASS, 1.2.0 FAIL (해결 낱말 케이스 대부분 ✘), 2.0.0은 결과를 보고 판단.
<details><summary>▶ 1단계 · 무엇을 확인하나</summary>
FAQ 질문마다 해결 방법의 핵심 낱말, 접수 질문마다 상태 · 예정일. 그리고 "포털을 보라"로 떠넘기지 않는지(`not_contains`). `cp cases/golden.json cases/my-golden.json`에서 시작해 `expect`만 바꿉니다.
</details>
<details><summary>▶ 2단계 · 에이전트에게 맡길 수 있나</summary>
테스트를 에이전트가 써도 됩니다. 단, **기대 낱말은 내가 데이터(FAQ · 접수 건)를 보고 정한 것**이어야 합니다. 에이전트가 1.0.0의 답을 보고 기대를 만들면, 1.0.0이 틀린 것도 정답이 됩니다.
</details>
<details><summary>▶ 3단계 · 1.0.0도 FAIL이면</summary>
기대 낱말이 너무 좁습니다. 1.0.0의 실패 줄을 보고, 답이 정말 틀렸는지(데이터와 다름) 아니면 같은 뜻을 다른 말로 썼는지 가립니다. 같은 뜻이면 그 말을 목록에 더합니다. **틀렸다면** 기대를 고치지 말고 1.0.0의 문제로 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 만들 파일과 시작점 ② 바꾸지 말 것 ③ 질문마다 기대 낱말(내가 데이터에서 정한 것) ④ 떠넘기기 금지 ⑤ 기대**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
cases/golden.json 을 cases/my-golden.json 으로 복사해 expect 만 강하게 바꿔 줘. 워크플로우는 ____① .
질문마다 답에 꼭 있어야 할 낱말을 ____② 로 (아래는 내가 FAQ · 접수 건 데이터에서 정한 것):
VPN 인증서 / VPN 업데이트 · 최신 / 비밀번호 비밀번호 찾기 · 휴대폰 / 프린터 대기열 · 전원 / 메일함 보관함 · 삭제 /
T-1002 처리 중 / T-1002 10월 20일 · 20일 / T-1001 처리 완료 · 완료.
프린터 질문에는 " ____③ " 같은 떠넘기기 말이 없어야 한다 (not_contains).
기대: gate --set release=1.0.0 · 1.1.0 PASS, release= ____④ FAIL.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 과제 조건 표의 "바꾸지 않을 것".
- ② 목록의 낱말 중 하나라도 있으면 통과인 판정 칸 이름입니다 (AGENTS.md 8절, 위 "무엇을 확인하나" 표).
- ③ 1.2.0의 지시문(`/prompts/helpdesk/p3`)이 시키는 말입니다. 실제 1.2.0 답의 글자를 예제 2의 `call -v`에서 찾습니다.
- ④ 막아야 할 릴리스입니다 (팀장의 말).
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다.
</details>
```bash
uv run tools/flow.py gate cases/my-golden.json --set release=1.0.0 --max-p95 3
uv run tools/flow.py gate cases/my-golden.json --set release=1.1.0 --max-p95 3
uv run tools/flow.py gate cases/my-golden.json --set release=1.2.0 --max-p95 3 # FAIL이어야
uv run tools/flow.py gate cases/my-golden.json --set release=2.0.0 --max-p95 3
```
## 과제 2 · 망가진 릴리스는 크게 실패하게 (10분)
운영팀이 새 릴리스 2.1.0을 데이터 API에 올렸습니다.
```bash
uv run tools/flow.py release helpdesk # 2.1.0의 prompt 칸을 봅니다
curl -s localhost:8800/prompts/helpdesk # 저장소에 있는 지시문 버전
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전
uv run tools/flow.py gate cases/my-golden.json --set release=2.1.0 --max-p95 3
```
> "2.1.0이 게이트를 통과했대서 내보내려는데, 지시문 이름이 저장소에 없는 것 같아요. 그런데 왜 답은 멀쩡히 나오죠? 이러다 진짜 1.1.0의 출처 번호도 없이 기본 지시문으로 계속 나가는 건 아니죠?"
**만들 것**: 저장소가 **404(그런 지시문이 없음)** 를 주면 기본 지시문으로 넘어가지 말고 HTTP 400 `release broken`. 저장소 **장애(500 · 시간 초과)** 일 때만 기본 지시문.
| 조건 | 값 |
| --- | --- |
| 404 | HTTP 400 `{"error": "release broken", "release", "prompt_version"}` |
| 500 · 시간 초과 | 지금처럼 기본 지시문, `prompt_source: fallback` |
| 200 | 저장소 지시문, `prompt_source: registry` |
| 기대 결과 | `cases/lab02-challenge2.json` 5개, `gate my-golden --set release=2.1.0` FAIL, `lab02`도 그대로 |
### 이해하기
**Lab 01 과제 2의 기본 지시문이 무엇을 숨겼나.** Lab 01 과제 2에서 "저장소가 실패하면 기본 지시문으로"를 만들었습니다. 그런데 "실패"에는 두 종류가 있습니다.
| 실패 | 저장소의 답 | 뜻 | 맞는 대응 |
| --- | --- | --- | --- |
| 장애 | 500, 시간 초과 | 저장소가 **잠깐** 아픔. 다시 하면 될 수도 | 기본 지시문으로 계속 (graceful degradation) |
| 설정 오류 | **404** `prompt not found` | 릴리스가 **없는 지시문**을 가리킴. 다시 해도 안 됨 | 크게 실패 (fail fast) |
2.1.0은 `p9`를 가리키는데 저장소에는 p1~p3만 있습니다. 지금 워크플로우는 404도 "실패"로 보고 기본 지시문(p1)으로 답합니다. 그래서 2.1.0은 **겉으로 멀쩡하고**, 강한 golden도 통과합니다(p1의 답이니까). 그대로 배포하면 운영팀은 2.1.0의 새 지시문이 나간다고 믿지만, 실제로는 1.0.0의 지시문이 나갑니다. **장애를 견디려고 만든 장치가 잘못된 설정을 숨긴 것**입니다.
```mermaid
flowchart LR
G["Get prompt<br/>timeout 2초 · 응답 코드도 받기"] --> C{"응답 코드"}
C -->|200| R(["저장소 지시문 · registry"])
C -->|404| B(["400 release broken<br/>(설정 오류 · 크게 실패)"])
C -->|500| F(["기본 지시문 · fallback"])
G -->|시간 초과 · 오류 출력| F
```
**크게 실패해야 게이트가 잡습니다.** 404에서 400 `release broken`을 내면, 2.1.0을 고정한 게이트 테스트가 모두 400을 받아 **FAIL**이 됩니다. 문제는 배포 **전에**, 사람이 아니라 게이트가 찾습니다. "조용히 넘어가는 오류"가 가장 비쌉니다.
**응답 코드를 읽으려면.** HTTP 노드는 기본으로 4xx · 5xx를 **오류**로 보고 오류 출력으로 보냅니다. 그러면 404와 500이 같은 길로 갑니다. `neverError`(오류로 보지 않음) + `fullResponse`(응답 코드 · 본문을 같이 받음)를 켜면 다음 Code가 `$json.statusCode`로 가를 수 있습니다. 시간 초과는 응답 코드가 없으므로 지금처럼 오류 출력으로 갑니다 ([AGENTS.md](AGENTS.md) 5절 "응답 코드로 가르기").
**장애일 때 없는 릴리스는?** 저장소 장애 중에도 `release: "9.9.9"`는 400 `unknown release`여야 합니다. 릴리스 고르기(Pick release)는 지시문을 가져오기 **전**이라 영향이 없습니다. 테스트가 이것도 봅니다.
**테스트 케이스 (`lab02-challenge2`).**
| 케이스 | 고장 | 기대 |
| --- | --- | --- |
| 망가진 릴리스 2.1.0 | 없음 | 400 `release broken`, `prompt_version` p9 |
| 평소 · 1.1.0 | 없음 | `registry` · p2, `[FAQ-003]` |
| 저장소 고장 · 1.1.0 | `prompts error` | 200, 1.1.0, `fallback`, "인증서" |
| 저장소 고장 · 없는 릴리스 | `prompts error` | 400 `unknown release` |
| 운영(active 1.0.0) | 없음 | 1.0.0 · `registry`, "대기열" |
**기대 결과.** 고치기 전: 2.1.0 케이스 ✘(200으로 기본 지시문 답), 나머지 ✔. `gate my-golden 2.1.0`은 **PASS**(위험!). 고친 뒤: 5/5, `gate my-golden 2.1.0` FAIL.
<details><summary>▶ 1단계 · 2.1.0이 왜 멀쩡해 보이나</summary>
고치기 전 `test -v`에서 2.1.0 케이스의 응답 `version.prompt_source`를 봅니다. `curl -s localhost:8800/prompts/helpdesk/p9`는 무엇을 주나요?
</details>
<details><summary>▶ 2단계 · 두 실패를 가르는 곳</summary>
`Get prompt`의 성공 쪽 다음 노드(지금 `Registry prompt`)를 Code로 바꿔 응답 코드로 셋으로 가르고(200 · 404 · 그 밖), 404면 If로 400 응답. 실패 쪽(시간 초과)은 그대로 `Fallback prompt`.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① HTTP 노드 응답 옵션 ② 응답 코드별로 할 일 ③ 400 응답 모양 ④ 그대로 둘 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Get prompt 에 options.response.response 로 neverError true, ____① true 를 더해 줘 (timeout 2000 · onError 는 그대로).
성공 쪽 Registry prompt 를 Code 로 바꿔: statusCode 200 이면 {state: 'registry', text: body.text}, ____② 이면 {state: 'broken'}, 그 밖이면 {state: 'fallback', text: 기본 지시문}.
Prompt broken?(If, state == 'broken') true → Reject broken(HTTP ____③ , {error: 'release broken', release, prompt_version}), false → Search FAQ.
prompt_source 는 Registry prompt 의 state 가 'registry' 면 registry, 아니면 fallback. 실패 쪽(시간 초과) Fallback prompt 는 ____④ .
기대 결과 cases/lab02-challenge2.json, cases/lab02.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 응답 코드와 본문을 같이 받는 옵션 이름입니다 (위 이해하기 "응답 코드를 읽으려면").
- ② 저장소가 "그런 지시문이 없다"고 할 때의 응답 코드입니다 (`curl -s -o /dev/null -w '%{http_code}' localhost:8800/prompts/helpdesk/p9`).
- ③ `cases/lab02-challenge2.json`의 첫 케이스 `status`.
- ④ 장애는 지금처럼 견뎌야 합니다. 바꿀까요, 둘까요?
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py gate cases/my-golden.json --set release=2.1.0 --max-p95 3 # FAIL이어야
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py test cases/lab01-challenge2.json
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 늘 `1.0.0` | 본문 `release`를 읽는지, `--set release=...`를 붙였는지 |
| 2.0.0인데 `version.model`이 mini | 모델 노드가 고른 릴리스의 `model`을 읽는지 |
| 없는 릴리스가 500 | 모르는 릴리스면 If로 400 갈래 |
| 게이트가 1.0.0도 FAIL | 기대 낱말이 너무 좁음. 1.0.0의 실패 줄을 보고 데이터의 낱말로 |
| 2.1.0이 여전히 200 | `fullResponse`가 켜졌는지 (`$json.statusCode`가 있는지 `call -v`로), 404 갈래가 If로 이어졌는지 |
| 저장소 고장인데 400 | 500을 `broken`으로 본 것. 404만 `broken` |
| `fault` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault prompts ok` |
| 계속 안 됨 | `cp reference/lab02-example1-release.json my-work/helpdesk.json` 후 push |
## 확인
- [ ] 예제 1: `cases/lab02.json` 5/5, `lab01-challenge` · `lab01-challenge2`도 통과.
- [ ] 예제 2: 네 릴리스의 golden 게이트 결과를 `RELEASE.md` 1절에, 1.2.0의 답을 읽어 봄.
- [ ] 예제 3: 1.0.0 · 2.0.0의 p50 · p95, 1.0.0 golden 3번 반복 (기준선).
- [ ] 과제 1: `my-golden`으로 1.0.0 · 1.1.0 PASS, 1.2.0 FAIL, 2.0.0 판단 한 줄.
- [ ] 과제 2: `cases/lab02-challenge2.json` 5/5, `gate my-golden 2.1.0` FAIL (고치기 전 PASS였음을 `RELEASE.md`에).