고객지원 AI를 평가하고 개선합니다 · v1
CONTOSO ATLAS CLOUD · 하나의 실습 경로
고객지원 AI에 질문 12개를 보내 채점하고, 지침을 고친 뒤 같은 질문으로 다시 비교합니다. Microsoft Azure·Microsoft Foundry 사용 경험은 필요하지 않습니다. 권장 경로는 GitHub Codespaces이며, 브라우저에서 준비된 터미널을 열어 시작합니다.
처음이면 01부터 시작하기 → 중단한 실습 재개하기 →
준비01–03 · 환경과 v1 Agent
평가04–07 · v1을 12문항으로 채점
개선·비교08–09 · 지침만 바꾼 v2
정리10 · 보관 후 삭제
시작 전 확인
- 구독·권한·비용: 본인이 사용할 Microsoft Azure 구독, 리소스 생성 권한과 역할 할당 권한, 비용 승인이 필요합니다.
- 실습 터미널: GitHub Codespaces(권장)는 GitHub 계정과 브라우저만 있으면 됩니다. 내 PC를 쓰면 Python 3.11–3.14·Git·Azure CLI를 설치합니다.
- 시간: 반나절 정도의 여유를 확보합니다. 권한·할당량 승인과 서비스 실행 시간은 별도입니다. 서비스 대기는 보통 짧지만(02의 생성 약 10분, 12문항 평가 몇 분, Optimizer 작업은 흔히 10~20분) 서비스가 붐비면 더 길어질 수 있습니다.
- 진행 원칙: 모든 실습 참여자가 본인 계정으로 준비부터 정리까지 직접 수행합니다. 다른 사람의 설정·로그인 세션·소유권 기록을 복사하거나 이미 완료한 작업을 다시 생성·제출하지 않습니다. 재개할 때는 재개 안내에 따라 자신의
config.json·manifest·receipt로 완료한 작업을 확인합니다.
브라우저를 닫아도 과금이 끝나지 않으므로 10의 정리까지 진행합니다. Microsoft Azure의 모델·Search·로그 비용과 GitHub Codespaces의 실행·저장소 비용은 별개입니다. 중간에 중단하더라도 생성한 자원이 있다면 보관·정리를 수행합니다.
용어와 범위
- Microsoft Azure는 Microsoft의 클라우드 서비스이고, Microsoft Foundry는 Microsoft Azure에서 AI 모델과 Agent를 만들고 평가하는 개발 플랫폼입니다.
- Agent(에이전트)는 모델에 지침과 도구를 연결한 AI입니다. 여기서는 가상의 Contoso Atlas Cloud 고객지원 Agent가 정책 문서를 검색해 답하게 합니다.
- v1은 변경 전 Agent, v2는 지침을 바꾼 비교 후보입니다. 두 번호는 Agent 전체 구성의 버전이며 모델·도구·출력 형식은 유지합니다. 개선이 없어서 v1을 유지해도 올바른 실습 결과이며, v2 생성은 운영용 게시 승인이 아닙니다.
- 학습은 평가 결과를 읽고 지침을 개선하는 과정이며 모델 가중치를 다시 학습시키는 과정은 아닙니다. 제공된 합성 정책·질문만 사용하고, 고객 원본 자료를 업로드하거나 평가 질문의 정답을 지침에 넣지 않습니다.
작업하는 세 곳
| 실행 위치 | 여기에서 하는 일 |
|---|---|
| Azure 포털 | 계정·구독·권한, 실제 리소스 목록과 삭제 범위를 확인합니다. |
| 실습 터미널 · Codespaces 또는 내 PC | 제공된 명령을 실행합니다. Codespaces에서는 내 PC에 Python·Git·Azure CLI를 설치하지 않습니다. |
| Microsoft Foundry | 프로젝트·Agent·정책 도구를 확인하고 평가·Optimizer·비교 결과를 읽습니다. |
가이드를 따라가는 방법: 각 단계의 실행 순서 → 작업 수행 → 완료 기준 확인 순서로 진행합니다. 처음에는 펼쳐진 본문을 따라가고, 권한·승인 등 필수 참고 링크도 확인합니다.
| 안내 | 해야 할 일 |
|---|---|
| 터미널에서 실행 | 선택한 실습 터미널에서 실행합니다. Codespaces를 쓰면 파일 편집도 그 안의 Visual Studio Code(VS Code)에서 합니다. |
| 포털 작업 | 별도 브라우저 탭에서 수행합니다. 그림의 계정·프로젝트 이름이 아니라 자신의 값을 사용합니다. |
| 선택 · 내부 동작 | 원리가 궁금할 때만 펼칩니다. 실제 구현 코드 · 읽기 전용은 참고용이며 복사해 실행하지 않습니다. |
| 생성·제출 | 코드로 생성한 항목은 포털에서 확인만 합니다. 평가·Optimizer는 안내된 포털 화면에서 한 번만 제출하며, 추측한 SDK 코드로 대신하지 않습니다. |
| 04–06 평가 생성 | 같은 평가 생성 화면에서 이어갑니다. 단계마다 새 평가를 만들지 않습니다. |
| 읽음 표시 | 개인 책갈피입니다. 실제 작업 완료 여부는 각 단계의 완료 기준으로 확인합니다. |
01. 계정·PC·권한을 확인합니다
이 단계에서 하는 일: 사용할 Microsoft Azure 구독·권한과 로컬 실행 환경을 확인합니다.
중요한 이유: 브라우저와 CLI가 다른 계정이나 구독을 사용하면 권한 오류가 나거나 잘못된 환경에 자원을 만들 수 있습니다.
진행 방법·위치: Azure 포털에서 구독과 접근 권한을 확인한 뒤 터미널에서 도구와 로그인 계정을 대조합니다. 아직 Microsoft Azure 리소스는 생성하지 않습니다.
실행 순서: 계정·권한 확인 → Codespaces 열기 · 권장 → 터미널 · 로그인. Codespaces를 사용할 수 없으면 내 PC 설치를 선택합니다.
처음 보는 용어가 있을 때만 · 계정·구독·프로젝트 구분
화면을 열기 전에 알아둘 여섯 가지입니다. 외울 필요 없이, 아래 역할만 구분합니다.
- 테넌트 · Tenant / Directory
- 로그인할 조직의 사용자·권한을 관리하는 경계입니다. 같은 계정으로 여러 조직에 접근할 수 있어 조직도 확인합니다.
- 구독 · Subscription
- Microsoft Azure 자원의 사용량과 요금이 묶이는 단위입니다. 실습 비용을 어느 구독에 청구할지 확인합니다.
- 리소스 그룹 · Resource group
- 함께 관리할 Microsoft Azure 자원을 묶는 그룹입니다. 실습 전용 그룹을 쓰면 종료 시 삭제 범위를 확인하기 쉽습니다.
- 프로젝트 · Project
- Microsoft Foundry 안에서 Agent·데이터·평가 작업을 구분하는 공간입니다. 이후 실습은 같은 프로젝트에서 진행합니다.
- 모델 · Model
- 답변을 생성하는 AI입니다. 모델을 호출할 수 있게 준비하는 작업을 배포(Deploy), 그 배포에 붙인 이름을 배포 이름이라고 합니다.
- 권한 · Role / Scope
- Role은 할 수 있는 일, Scope는 그 권한이 적용되는 범위입니다. 로그인 성공만으로 생성·평가·삭제 권한이 모두 생기지는 않습니다.
Microsoft Azure 계정과 구독을 확인합니다
- Azure 포털에 본인의 실습용 계정으로 로그인합니다. 사용할 계정·구독이 없다면 Microsoft Azure 계정 안내를 따라 준비합니다. 조직 구독은 본인이 사용할 범위의 접근·비용 승인을 먼저 확보합니다. 무료·체험 구독이라고 필요한 모델의 할당량까지 보장되지는 않습니다.
- 상단 검색창에 Subscriptions를 입력하고 사용할 구독을 엽니다. 포털 상태가 Active인지 보고, Subscription ID와 Directory/Tenant ID를 확인합니다. 같은 정상 구독은 CLI에서 Enabled로 표시합니다. 구독이 보이지 않으면 우측 상단 계정의 디렉터리·구독 필터를 확인합니다.
- Access control (IAM, 권한 관리) → Check access에서 본인의 역할과 적용 범위를 확인합니다. 이전 UI에서는 View my access로 표시할 수 있습니다. 이 실습은 본인이 리소스를 생성하고 리소스 범위 역할을 할당하므로 생성 권한과 역할 할당 권한이 모두 필요합니다. Contributor만으로는 역할 할당이 가능하지 않습니다. 작업별 필요 권한·공급자 등록을 확인합니다.
- 비용 한도, 사용 종료 시각, 전용 리소스 그룹의 삭제 또는 보존 범위를 정합니다. 권한·구독·비용 승인이 부족하면 조직 절차에 따라 본인에게 허용된 실행 범위를 확보한 뒤 계속합니다. 다른 사람에게 이후 실습을 대신 실행하게 하거나 구독 전체 Owner·보안 정책 해제로 우회하지 않습니다.
도구와 실습 파일을 준비합니다
권장: GitHub Codespaces를 사용합니다. GitHub 계정과 브라우저만 있으면 이 저장소의 Python·Git·Azure CLI·잠금 의존성을 갖춘 개발환경을 준비할 수 있습니다. Codespaces가 Microsoft Azure 구독·권한·모델 할당량까지 제공하는 것은 아닙니다. 조직에서 Codespaces를 허용하지 않으면 아래 내 PC 경로를 사용합니다.
명령 실행 규칙
- 한 줄씩 실행하고, 오류가 나면 다음 명령으로 넘어가지 않습니다. 긴 명령이 여러 줄로 보여도 중간에 줄바꿈을 넣지 않습니다.
- 복사 버튼은 상자 전체를 복사합니다. 여러 줄이면 편집기에 먼저 붙여넣고 한 명령씩 실행합니다.
YOUR_...는 자신의 값으로 바꾸고 큰따옴표는 유지합니다.공통 터미널상자는 모든 환경에서 사용합니다.Codespaces/macOS/Linux·Windows PowerShell상자는 자신의 환경에 해당하는 것만 실행합니다.
브라우저에서 GitHub Codespaces를 엽니다 · 권장
- 본인 GitHub 계정으로 이 저장소의 Codespace 생성 화면을 엽니다. 저장소의 Code → Codespaces → Create codespace on main으로도 접근할 수 있습니다.
- 사용할 계정의 Codespaces 허용 여부와 요금·포함 사용량을 확인한 뒤 Create codespace를 선택합니다. 무료 사용을 보장하지 않습니다.
- 브라우저 VS Code가 열리면 초기 컨테이너·패키지 준비가 끝날 때까지 기다립니다. 저장소는 자동으로 내려받으며
.devcontainer/devcontainer.json이 도구와.venv를 준비합니다. 생성 로그에 오류가 있으면 계속하지 않습니다. - Terminal → New Terminal을 엽니다. 아래 명령으로 준비 상태를 확인합니다. Codespaces는 Linux이므로 내 PC가 Windows여도 이후 Codespaces/macOS/Linux 명령을 사용합니다.
터미널에서 실행 · Codespaces/macOS/Linux
source .venv/bin/activate
python --version
git --version
az version
python -m lab --help
python scripts/build_datasets.py --language ko --check
Python 3.12.x, Git·Azure CLI 버전, 실습 명령 목록과 데이터 검사 성공을 확인합니다. 명령 목록이 길지만 이 가이드는 bootstrap, preflight, smoke, iq, native-agent, native-evals, cleanup만 사용하므로 나머지는 무시합니다. 도구 설치·clone·가상환경 생성을 다시 하지 않고 로그인으로 이동합니다. 초기 준비에 실패했으면 Codespaces 문제 해결을 확인합니다.
대기하는 동안 Codespace를 활성 상태로 유지합니다. 기본적으로 30분 동안 조작이 없으면 Codespace가 중지되고, 중지되면 실행 중이던 명령도 끝납니다. 02의 생성과 09의 재평가를 기다리는 동안에는 터미널을 지켜봅니다. 중지되면 Codespace를 다시 시작하고 Terminal → New Terminal을 연 뒤 재개 절차를 따릅니다. apply나 제출 명령을 반복하지 않고 상태를 먼저 조회합니다. 중지된 Codespace를 사용하지 않고 두면 기본 30일 뒤 자동 삭제되므로, 오래 쉬기 전에 10의 백업 절차로 .lab/을 보관합니다. 계정이나 조직에 따라 Codespace 수명 주기 규칙이 다를 수 있습니다.
내 PC에서 진행할 때만 · Windows/macOS/Linux 설치
로컬 설치 경로를 선택합니다
Python 3.11–3.14, Git, Azure CLI를 설치합니다. Python은 제공된 프로그램 실행용, Git은 실습 파일 내려받기용, Azure CLI는 az 명령으로 Microsoft Azure에 로그인·조회하는 도구입니다. 회사 PC에서 설치가 제한되면 담당자에게 요청합니다.
이미 설치했다면 설치 확인부터 진행하고, 정상인 도구는 다시 설치하지 않습니다. Windows·macOS의 아래 예시는 지원 범위를 벗어난 최신 Python이 선택되지 않도록 3.13을 지정합니다. 이미 다른 지원 버전을 사용한다면 py -3.13·python3.13을 해당 버전의 명령으로 바꾸고, 버전 확인과 가상환경 생성에 같은 Python을 사용합니다. 3.10 이하·3.15 이상은 이 실습의 지원 범위가 아닙니다.
먼저 내 PC의 터미널을 엽니다. Windows는 시작 메뉴에서 PowerShell, macOS는 Spotlight에서 Terminal(터미널), Linux는 앱 메뉴에서 터미널을 검색합니다. Azure 포털의 Cloud Shell이나 >>>가 보이는 Python 입력창은 이 가이드의 실행 위치가 아닙니다. 명령은 위의 명령 실행 규칙을 따릅니다.
Windows 설치 · macOS 설치 · Linux 설치 중 자신의 운영체제 하나만 따라 한 뒤 설치 확인으로 이동합니다.
Windows · PowerShell에서 설치합니다
PowerShell에서 winget --version을 실행합니다. 버전이 나오면 Windows 패키지 관리 도구인 WinGet으로 아래 명령을 한 줄씩 실행합니다. 설치 동의나 설치 권한 요청은 조직의 승인 범위에서 직접 확인합니다.
터미널에서 실행 · Windows PowerShell
winget install --exact --id Python.Python.3.13 --source winget
winget install --exact --id Git.Git --source winget
winget install --exact --id Microsoft.AzureCLI --source winget
WinGet이 없거나 사용할 수 없다면 공식 설치 파일을 사용합니다. 두 방법을 모두 실행할 필요는 없습니다.
| 도구 | 다운로드와 설치 화면에서 확인할 항목 |
|---|---|
| Python | Windows 다운로드에서 Python 3.13의 최신 패치 버전과 PC에 맞는 installer를 선택합니다. embeddable package가 아닌 일반 설치 파일에서 Add python.exe to PATH를 선택하고 pip·Python Launcher를 포함하여 설치합니다. |
| Git | Git for Windows에서 PC에 맞는 설치 파일을 받습니다. PATH 선택 화면의 Git from the command line and also from 3rd-party software를 유지하여 PowerShell에서도 사용할 수 있게 합니다. |
| Azure CLI | Windows 공식 설치 안내의 Microsoft Installer (MSI)를 사용합니다. 일반적인 x64 PC는 64-bit MSI를 선택합니다. |
설치 후 새 터미널에서 세 도구 확인으로 이동합니다. py가 인식되지 않으면 Python 설치의 Launcher 옵션을 확인합니다.
macOS · Homebrew로 설치합니다
터미널에서 brew --version을 실행합니다. 명령이 없으면 Homebrew 공식 설치 안내를 따라 설치하고, 마지막에 표시되는 Next steps의 PATH 설정까지 마친 뒤 새 터미널에서 다시 확인합니다. 회사 PC의 설치·Command Line Tools 승인 절차를 우회하지 않습니다.
터미널에서 실행 · Codespaces/macOS/Linux
brew update
brew install python@3.13 git azure-cli
Python은 Homebrew의 버전 지정 패키지, Azure CLI는 Microsoft의 macOS 설치 안내를 따릅니다. 이 경로에서는 python3.13을 사용합니다. 기존 python3는 macOS 기본 Python이나 다른 버전을 가리킬 수 있으므로 시스템 Python을 덮어쓰거나 강제로 연결하지 않습니다.
Linux · Ubuntu 24.04 LTS 예시입니다
다음은 기본 Python 3.12를 제공하는 Ubuntu 24.04 LTS 기준입니다. 다른 배포판은 해당 배포판의 Python·Git 설치 안내와 Azure CLI 공식 설치 안내를 따릅니다. Python 버전이 지원 범위 밖이면 시스템 Python을 교체하지 말고 담당자에게 지원되는 실행 환경을 요청합니다.
터미널에서 실행 · Codespaces/macOS/Linux
sudo apt update
sudo apt install python3 python3-venv python3-pip git curl
curl -fsSL 'https://azurecliprod.blob.core.windows.net/$root/deb_install.sh' -o install-azure-cli.sh
마지막 명령은 Microsoft의 Ubuntu/Debian용 설치 스크립트를 내려받기만 합니다. install-azure-cli.sh를 편집기로 열어 내용을 확인하고 필요한 시스템 설치 권한과 승인이 있는 경우에만 다음을 실행합니다. sudo 권한이 없으면 승인된 설치 경로를 확보한 뒤 진행합니다.
터미널에서 실행 · Codespaces/macOS/Linux
sudo bash install-azure-cli.sh
새 터미널에서 설치를 확인합니다
설치가 끝나면 열려 있던 터미널 창을 모두 닫고 다시 엽니다. PATH는 명령을 찾는 경로 목록이며, 기존 창에는 새 설치 경로가 반영되지 않을 수 있습니다. VS Code의 터미널을 사용한다면 VS Code도 종료한 뒤 다시 엽니다.
Windows PowerShell:
터미널에서 실행 · Windows PowerShell
py -3.13 --version
git --version
az version
macOS · 위 Homebrew 설치 경로:
터미널에서 실행 · Codespaces/macOS/Linux
python3.13 --version
git --version
az version
Linux · 위 Ubuntu 설치 경로:
터미널에서 실행 · Codespaces/macOS/Linux
python3 --version
git --version
az version
| 확인 항목 | 정상 출력과 진행 기준 |
|---|---|
| 실습용 Python | Python 3.13.x 같은 버전이 나옵니다. x는 실제 패치 번호이며 3.11·3.12·3.13·3.14 중 하나여야 합니다. |
| Git | git version 2.x...처럼 설치된 버전이 나옵니다. |
| Azure CLI | JSON에 "azure-cli": "2.x..."처럼 버전이 나옵니다. 이 확인에는 Microsoft Azure 로그인이 필요하지 않습니다. |
az version에 표시되는 Python은 Azure CLI 자체의 실행 환경이며, 실습용 Python 설치 확인을 대신하지 않습니다. 세 명령이 모두 성공한 뒤에만 파일 다운로드·가상환경 생성으로 진행합니다. command not found, not recognized, Python 대신 Microsoft Store가 열리는 경우는 설치·PATH 문제 해결을 확인합니다.
실습 파일을 내려받습니다
터미널에서 실행 · 공통 터미널
git clone --depth 1 https://github.com/junwoojeong100/foundry-evaluation-labs-v1.git
cd foundry-evaluation-labs-v1
--depth 1은 저장소 이력 없이 최신 파일만 내려받아 용량을 줄입니다. 저장소에는 시연 영상도 포함되어 있습니다. 이미 내려받았다면 clone을 반복하지 않고 해당 폴더로 이동합니다. GitHub의 Code → Download ZIP으로 받은 경우 먼저 압축을 풀고 pyproject.toml과 requirements.lock이 있는 폴더를 엽니다. HTML 파일 한 개만 내려받으면 코드·데이터·그림이 누락됩니다.
확인한 Python으로 가상환경을 만듭니다
이후 명령은 pyproject.toml과 requirements.lock이 있는 실습 폴더에서 실행합니다. .venv는 이 실습만의 Python 패키지 공간입니다. 앞에서 버전을 확인한 같은 Python으로 만들며, 아래 세 경로 중 자신의 운영체제에 맞는 하나만 실행합니다.
macOS · 위 Homebrew 설치 경로:
터미널에서 실행 · Codespaces/macOS/Linux
python3.13 -m venv .venv
source .venv/bin/activate
Linux · 위 Ubuntu 설치 경로:
터미널에서 실행 · Codespaces/macOS/Linux
python3 -m venv .venv
source .venv/bin/activate
Windows PowerShell에서는 다음을 실행합니다.
터미널에서 실행 · Windows PowerShell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
PowerShell 실행 정책 때문에 활성화가 차단되면 조직 정책을 변경하지 않습니다. 이후 명령의 python을 .\.venv\Scripts\python.exe로 바꾸어 실행합니다. 기존 .venv가 있다면 다른 작업의 환경인지 확인하고 임의로 덮어쓰지 않습니다.
가상환경을 준비한 뒤 다음 공통 명령을 실행합니다. 첫 두 명령에서 Python 3.11–3.14와 .venv 안의 pip 경로를 확인한 뒤 패키지를 설치합니다.
터미널에서 실행 · 공통 터미널
python --version
python -m pip --version
python -m pip install -r requirements.lock
python -m lab --help
python scripts/build_datasets.py --language ko --check
명령 목록과 데이터 검사 성공을 확인합니다. ModuleNotFoundError가 나오면 python -m pip --version으로 .venv의 Python을 사용하는지 확인합니다. 시스템 Python에 임의로 패키지를 설치하지 않습니다.
CLI 로그인 계정을 확인합니다
Codespaces 또는 선택한 내 PC 터미널에서 실행합니다. --use-device-code는 터미널에 표시된 Microsoft 로그인 주소를 브라우저에서 열고 일회용 코드를 직접 입력하는 방식입니다. 코드·토큰을 다른 사람에게 전달하지 않습니다. 포털과 같은 Microsoft Azure 계정으로 로그인한 뒤 아래 YOUR_SUBSCRIPTION_ID를 자신의 구독 ID로 바꿉니다.
터미널에서 실행 · 공통 터미널
az login --use-device-code
az account list --query "[].{name:name,id:id,state:state}" -o table
az account set --subscription "YOUR_SUBSCRIPTION_ID"
az account show --query "{user:user.name,tenant:tenantId,subscription:id,state:state}" -o json
마지막 출력의 state가 Enabled인지 확인하고 user, tenant, subscription을 포털과 대조합니다. 브라우저와 CLI의 로그인은 별개입니다. 다른 디렉터리가 선택됐다면 az login --use-device-code --tenant "YOUR_TENANT_ID"로 승인된 디렉터리를 지정합니다. 조직에서 device-code 로그인을 차단하면 우회하지 말고 로그인 문제 해결을 확인합니다.
언어와 기록 위치는 자동으로 적용됩니다
따로 실행할 설정 명령은 없습니다. 02의 생성 명령에서 --environment lab-ko를 사용하면 한국어와 해당 환경의 기록 위치를 자동으로 저장합니다. 영어 가이드는 lab-en을 사용합니다. 다음 단계부터는 명령에 있는 --config가 같은 설정을 읽으므로 새 터미널에서도 환경 변수를 다시 지정하지 않습니다.
같은 실습을 재개할 때는 기존 환경을 사용합니다. 별도 한국어 실습이면 lab-ko-02처럼 lab-ko-로 시작하는 이름을 사용하고 이후 경로도 함께 바꿉니다. 기록은 자동으로 저장되며 정리 전에 삭제하지 않습니다.
중단 후 재개할 때만 · 터미널 설정을 복원합니다
같은 실습을 새 터미널에서 재개합니다
먼저 기존 실습 폴더로 이동합니다. .venv가 이미 있으므로 clone·가상환경 생성·패키지 설치부터 반복하지 않습니다. 아래에서 자신의 운영체제 하나만 선택합니다. 환경 이름을 바꿨다면 원래 기록의 경로를 사용합니다.
Codespaces/macOS/Linux:
터미널에서 실행 · Codespaces/macOS/Linux
source .venv/bin/activate
Windows PowerShell:
터미널에서 실행 · Windows PowerShell
.\.venv\Scripts\Activate.ps1
PowerShell 활성화가 차단되면 조직 정책을 바꾸지 않고 이후 python을 .\.venv\Scripts\python.exe로 바꿉니다.
터미널에서 실행 · 공통 터미널
python --version
python -m lab --help
az account show --query "{user:user.name,tenant:tenantId,subscription:id,state:state}" -o json
Python 3.11–3.14와 명령 목록, 원래 사용자·테넌트·구독 및 Enabled를 확인합니다. 로그인이 만료됐거나 다르면 같은 계정으로 로그인한 뒤 다시 확인합니다.
언어·기록 폴더 설정은 반복하지 않습니다. 다음 명령의 원래 --config 경로를 유지하면 저장된 설정을 자동으로 사용합니다.
마지막으로 끝낸 단계는 읽기 전용 조회로 찾으며 메모 파일은 필요하지 않습니다. 환경 생성 상태는 02의 읽기 전용 status, 제출한 평가의 ID·상태는 06의 조회, 후보 재평가 수집은 09의 같은 receipt 재개로 확인합니다. 포털의 Evaluations와 Optimization runs 목록에서도 같은 작업을 볼 수 있습니다. 완료한 작업은 다시 제출하지 않고, 진행 중인 작업은 같은 ID로 조회합니다. 01에서는 Microsoft Azure 환경을 생성하거나 SDK를 호출하지 않습니다.
선택 · 내부 동작: 로그인과 로컬 설정
코드 ↔ 포털 · 로그인과 로컬 설정을 확인합니다
실행 위치: 위의 터미널 로그인 명령을 실행합니다. 언어·기록 자동 설정 안내는 설명이며 실행할 추가 명령이 아닙니다.
| 실행한 명령·설정값 | 지금 확인하는 내용 | 포털 조작·확인 위치 |
|---|---|---|
az login, az account set, az account show |
로그인 계정·테넌트·구독과 state: Enabled를 확인합니다. |
Azure 포털 우측 상단 계정의 Directory와 Subscriptions → 사용할 구독 → Overview의 ID·상태를 직접 대조합니다. |
--environment lab-ko → 이후 명령의 --config |
02에서 언어·기록 위치를 저장하고 이후 명령이 읽습니다. 터미널 변수 설정은 없습니다. | 포털 언어 선택과 무관한 실습 설정입니다. 한국어 자료와 해당 환경의 기록을 사용합니다. |
완료 기준: Python 3.11–3.14·Git·Azure CLI의 버전을 확인했고, 같은 사용자·테넌트·구독을 사용하며, 가상환경의 Python 명령과 한국어 데이터 검사가 성공합니다. 문제가 있으면 환경 오류 해결을 확인합니다.
다음: 02. Microsoft Foundry 환경 생성 →
02. Microsoft Foundry 환경을 생성합니다
이 단계에서 하는 일: 전용 리소스 그룹 안에 Microsoft Foundry 프로젝트·모델·Search·모니터링 기반을 준비합니다.
중요한 이유: 리소스 그룹은 비용과 정리 범위를 묶고, 프로젝트는 Agent·평가를 구분합니다. 빈 프로젝트 하나만으로 정책 검색까지 준비되지는 않습니다.
진행 방법·위치: 실습 터미널의 생성 명령 한 개가 계획·준비 검사·승인 기록·생성을 안내합니다. 완료 후 포털의 실제 배포와 런타임 검사를 확인합니다.
실행 순서: 터미널 · 빠른 생성 → 저장된 기록 → 포털·런타임 확인.
생성 명령 하나로 환경을 준비합니다
01의 로그인을 마쳤다면 바로 실행합니다. 구독·테넌트·사용자는 현재 CLI 로그인에서 읽고, 언어·기록 위치·자원 이름·설정 파일은 자동으로 정합니다. 별도 환경 변수나 언어 옵션을 입력하거나 JSON 승인서를 직접 작성할 필요가 없습니다.
터미널에서 실행 · 공통 터미널
python -m lab bootstrap setup --environment lab-ko
lab-ko는 한국어, lab-en은 영어입니다. lab-ko-02·lab-en-02 같은 별도 환경도 같은 규칙을 따릅니다. 한국어 실습의 명령 실행 결과는 .lab/lab-ko/artifacts/에 저장됩니다.
프로그램은 다음 순서로 안내합니다.
- 계획: 지역은
northcentralus, 모델은 이 실습의 고정 4개 배포이며 자동으로 다른 모델·지역을 선택하지 않습니다. Search와 로그도 이후 실습에 필요하므로 함께 준비합니다.- 네 배포의 역할: Agent(답변), Judge(채점), Optimizer/검색 planner(지침 개선·검색 계획), embedding(검색용 숫자 변환)
- 모델을 직접 만드는 실습이 아니라 준비된 모델을 호출할 환경을 만듭니다.
- 권한·모델·할당량 검사: 생성 전에 준비 상태를 확인합니다.
- 실제 승인 내용 입력: 준비 검사가 통과하면 아래 다섯 가지를 입력합니다.
- 생성: 마지막 확인에서 승인한 경우에만 만듭니다.
입력 전에: 01에서 실제로 승인받은 값을 사용합니다. 값이 정해지지 않았다면 Ctrl+C로 중단하고 승인을 확보합니다.
| 질문 | 입력할 내용 |
|---|---|
| 승인 통화 | USD·KRW 같은 3자리 통화 코드 |
| 승인 예산 금액 | 해당 통화의 양수 금액. 기본 금액을 임의로 승인하지 않습니다. |
| 최대 사용 시간 | 승인된 양의 정수 시간. 생성과 실습을 마칠 종료 시점을 포함합니다. |
| 모델 호출 한도 | 승인된 양의 정수. 12문항이라도 Agent·Judge·검색·Optimizer가 여러 번 호출될 수 있습니다. |
| 실제 승인 근거 | 본인이 승인할 권한이 있는 범위의 결정 또는 조직 승인 기록을 식별할 비공개 메모. 비밀번호·토큰은 넣지 않습니다. |
숫자를 정하는 방법: 시간은 생성(보통 약 10분), 실습 진행, 10단계 정리까지를 포함하고 자원도 그 시간만큼만 유지합니다. 가장 큰 지속 비용은 Azure AI Search(Basic)이며, 유휴 상태에서도 존재하는 동안 시간 단위로 청구됩니다. 이 가이드를 준비할 때 North Central US 기준 시간당 약 US$0.10, 하루 약 US$2.4였습니다. 모델 비용은 토큰 사용량과 현재 모델 단가에 따라 달라집니다. Microsoft Azure 가격 계산기, 현재 AI Search 가격, Microsoft Foundry 모델 단가로 예산을 직접 산정하고 예상하지 못한 재시도 여유를 더합니다. 프롬프트에 입력한 값은 기록이며 지출 상한이 아닙니다.
{: .note}
생성 전 마지막 확인: 출력된 계정·구독·지역·자원·모델과 한도를 읽습니다. 이 빠른 경로는 후보 최대 2개·작업 대기 최대 1시간·로그 보존 30일을 기록합니다. 다른 한도가 필요하면 아래 개별 설정 경로를 사용합니다. Global Standard의 전 세계 처리, 자원 생성·자원 범위 역할 할당, 지속 과금과 불확정 총비용을 실제로 승인한 경우에만 CREATE lab-ko를 직접 입력합니다.
확인 전에는 Microsoft Azure 자원을 생성하지 않으며 Enter로 취소할 수 있습니다. 확인 후에는 실제 생성·과금이 시작될 수 있습니다. 입력한 예산·호출 수·사용 시간은 자동 과금 차단 장치가 아니며, 삭제·학습·자동 재시도 승인도 아닙니다.
정상 결과
- 출력에
status: APPLIED가 표시되고.lab/lab-ko/.env가 생성됩니다. config.json·approval.json·manifest.json도 같은 폴더에 보관됩니다. 이 파일을 편집하여 검사를 우회하지 않습니다..env는 프로젝트 연결 주소(Project endpoint)와 모델 배포 이름을 담은 설정 파일이며 Python 패키지 폴더인.venv와 다릅니다. 이후 명령이--config로 읽으므로 직접 실행하거나source하지 않습니다. 생성이 실패했다면.env.example을 복사하여 대신하지 않습니다.- 오류가 있으면 생성 문제 해결로 이동합니다.
같은 명령을 다시 실행하면
- 아직 생성 시도가 없는 계획만 이어 준비합니다. 생성 시도 기록이 있으면 새 배포를 제출하지 않고 상태만 조회합니다. 생성 중·실패 상태를 성공으로 간주하지 않으며, 재개할 때는 status 조회로 확인합니다.
- 기존의 유효한 승인 파일이 있으면 입력을 반복하지 않고 기록된 한도를 확인합니다. 기존 계획·승인값을 새 기본값으로 바꾸거나 만료 시각을 자동 연장하지 않습니다.
이전 버전 환경을 쓰거나 환경 변수를 직접 설정한 적이 있을 때만
프로그램은 선택한 설정 파일의 언어·기록 위치를 자동으로 읽습니다. 셸에 남아 있는 과거 환경 변수보다 선택한 설정 파일이 우선합니다. 이전 버전에서 생성한 lab-ko·lab-en 환경도 파일을 고치거나 재생성하지 않고 사용할 수 있습니다. 다른 언어의 기존 기록을 다시 표시하거나 덮어쓰지는 않습니다.
실습 기록이 저장되는 곳
기록 파일을 따로 만들지 않아도 됩니다. 프로그램이 실행하면서 이후 단계에 필요한 내용을 .lab/lab-ko/에 자동으로 저장합니다. config.json·manifest.json·approval.json·.env와 artifacts/ 아래의 receipt입니다. 프로젝트 endpoint나 Agent 버전처럼 다시 필요한 값은 이 파일을 읽거나 읽기 전용 명령으로 조회하면 됩니다.
notes.md 같은 개인 메모는 선택 사항입니다. 쓴다면 결론을 정리하는 곳에만 짧게 적습니다. 07의 학습 노트, 09의 v1/v2 비교, 10의 마무리 노트입니다. 재개할 때는 메모에 의존하지 않고 01의 읽기 전용 확인을 사용합니다. 메모에는 비밀번호·토큰을 적지 않습니다. 더 긴 선택 점검표는 기록표를 참고하며, 실습 프로그램은 이 표를 읽지 않습니다.
이후 나오는 agents/native-v1.json, knowledge/config-snapshot.json 같은 실행 기록의 상대 경로는 .lab/lab-ko/artifacts/ 아래입니다. 반면 data/·prompts/·scripts/는 내려받은 실습 폴더 아래입니다.
생성이 끝났다면 포털·런타임 확인으로 이동합니다. 아래 개별 명령을 추가로 실행하지 않습니다.
선택 · 계획·TPM·승인을 개별 명령으로 확인할 때만
빠른 생성과 둘 중 한 경로만 사용합니다. 아래는 같은 bootstrap을 단계별로 실행하는 참고 절이며, 이미 생성한 환경을 다시 만드는 절차가 아닙니다. 기존 계획·승인을 검토하거나 다른 승인 한도를 직접 설정할 때 사용합니다.
- Microsoft Azure 구독사용량·비용 관리
- 리소스 그룹이번 실습의 자원 묶음
- Microsoft Foundry 리소스AI 서비스와 모델 배포
- 프로젝트Agent·데이터·평가 작업
Endpoint(엔드포인트)는 프로그램이 서비스에 연결할 때 쓰는 주소입니다. 이 실습은 프로젝트의 Project endpoint를 사용합니다. 옆에 있는 Azure OpenAI endpoint와 서로 바꾸어 사용하지 않습니다.
로컬 생성 계획을 만듭니다
az account show에서 확인한 값(필요하면 다시 실행합니다)을 아래 표대로 교체합니다. ID는 이름이 아니라 자원을 식별하는 값입니다. 구독의 표시 이름을 ID 자리에 넣지 않습니다.
| 교체할 부분 | 가져올 값 |
|---|---|
YOUR_SUBSCRIPTION_ID |
az account show의 subscription |
YOUR_TENANT_ID |
같은 출력의 tenant |
YOUR_SIGN_IN_NAME |
같은 출력의 user |
이 명령은 내 PC의 .lab/lab-ko/에 계획·미승인 승인서 예시만 만들고 Microsoft Azure를 호출하지 않습니다.
터미널에서 실행 · 공통 터미널
python -m lab bootstrap plan --subscription "YOUR_SUBSCRIPTION_ID" --tenant "YOUR_TENANT_ID" --expected-user "YOUR_SIGN_IN_NAME" --environment lab-ko --location northcentralus
plan_status: CREATED_LOCAL_ONLY, mutations_performed: false와 config_path를 확인합니다. BLOCKED_AWAITING_APPROVAL은 아직 비용 승인이 없다는 뜻이며 생성 실패나 Microsoft Azure 생성 완료가 아닙니다.
| 생성 파일 | 확인할 내용 |
|---|---|
.lab/lab-ko/config.json |
구독·테넌트·사용자, 자동 생성 리소스 이름, 모델·버전·용량입니다. 직접 수정하지 않습니다. |
.lab/lab-ko/approval.example.json |
아직 승인되지 않은 비용·변경 범위입니다. |
.lab/lab-ko/manifest.json |
생성·소유권·중단 상태를 추적하는 기록입니다. 보존합니다. |
.lab/lab-ko/.env |
아직 없습니다. 실제 생성이 완료된 뒤 만들어지는 런타임 설정입니다. |
파일은 텍스트 편집기의 파일 열기로 확인합니다. .lab은 실습 기록 폴더이며 운영체제에 따라 숨김 폴더로 보일 수 있습니다. JSON은 설정을 이름: 값으로 저장하는 형식입니다. Word 문서로 변환하거나 파일 확장자를 바꾸지 않습니다.
구현 참고: plan()은 이름·모델·해시를 정하고 로컬 파일만 씁니다. Microsoft Azure 생성 호출은 없습니다. plan 뒤 포털에 자원이 나타나지 않는 것은 정상입니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:plan
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 661–737 · GitHub에서 보기
def plan(
*, subscription_id: str, tenant_id: str, expected_user: str,
environment: str | None = None, root: Path | str = Path(".lab"),
location: str = REGION, retention_days: int = 30, models: list[dict] | None = None,
resource_group: str | None = None, agent_sku: str | None = None,
) -> dict:
"""Create a private local plan only; never query Azure or overwrite .env/artifacts."""
from lab.config import LabError, environment_language
from lab.content import selected_language
instance = uuid4()
suffix = instance.hex[:8]
date = _now().strftime("%Y%m%d")
environment = environment or f"lab-{date}-{suffix}"
try:
language = environment_language(environment) or selected_language()
except LabError as exc:
raise BootstrapError(str(exc)) from exc
root = Path(root)
if root.is_symlink() or any(parent.is_symlink() for parent in root.parents):
raise BootstrapError("Environment root must not traverse symlinks.")
directory = root.resolve() / environment
selected = deepcopy(list(DEFAULT_MODELS) if models is None else models)
if agent_sku is not None and agent_sku not in {"Standard", "GlobalStandard"}:
raise BootstrapError("Explicit agent SKU must be Standard or GlobalStandard.")
for model in selected:
if not isinstance(model, dict) or not isinstance(model.get("roles"), list) or not model["roles"]:
raise BootstrapError("Each model requires a nonempty roles array.")
model.setdefault("sku", "GlobalStandard")
if agent_sku is not None and "agent" in model["roles"]:
model["sku"] = agent_sku
model.setdefault("deployment", f"lab-{model.get('roles', ['model'])[0]}-{suffix}")
config = {
"schema_version": SCHEMA_VERSION, "instance_id": str(instance), "created_at": _stamp(),
"environment": environment, "environment_dir": str(directory), "language": language,
"subscription_id": subscription_id, "tenant_id": tenant_id, "expected_user": expected_user,
"location": location, "retention_days": retention_days, "models": selected,
"deployment_name": f"lab-bootstrap-{suffix}",
"template_sha256": _file_digest(TEMPLATE),
"names": {
"resource_group": resource_group or f"rg-foundry-eval-v11-{date}-{suffix}",
"account": f"ai-fev11-{date}-{suffix}", "project": f"proj-fev11-{suffix}",
"search": f"srch-fev11-{date}-{suffix}", "workspace": f"log-fev11-{date}-{suffix}",
"insights": f"appi-fev11-{date}-{suffix}",
},
}
config["scope_sha256"] = _scope(config)
_validate(config)
try:
root.mkdir(parents=True, exist_ok=True, mode=0o700)
directory.mkdir(mode=0o700)
except FileExistsError as exc:
raise BootstrapError("Environment already exists; use its config to inspect/resume, never overwrite it.") from exc
_write(directory / ".gitignore", "*\n", text=True)
(directory / "artifacts").mkdir(mode=0o700)
(directory / "evidence").mkdir(mode=0o700)
manifest = {
"schema_version": SCHEMA_VERSION, "scope_sha256": config["scope_sha256"],
"phase": "planned", "created_at": _stamp(), "resources": _resources(config),
"attempts": [], "operator_principal_id": None, "env_sha256": None,
}
_write(directory / "config.json", config)
_write_bytes(directory / "template.json", TEMPLATE.read_bytes())
_write(directory / "approval.example.json", approval_template(config))
_write(directory / "group-authorization.example.json", group_authorization_template(config))
_write(directory / "manifest.json", manifest)
_write(directory / "cost-ledger.json", cost_ledger(manifest))
return {
"status": AWAITING_APPROVAL, "plan_status": "CREATED_LOCAL_ONLY",
"approval_status": "MISSING", "live_status": "NOT_RUN", "mutations_performed": False,
"scope_sha256": config["scope_sha256"],
"config_path": str(directory / "config.json"),
"approval_example_path": str(directory / "approval.example.json"),
"env_path": str(directory / ".env"), "artifacts_dir": str(directory / "artifacts"),
"models": selected,
"warning": "No Azure calls made. Candidate versions/SKUs/quota require fresh preflight and explicit approval.",
}
모델 역할은 답변 생성(Agent), 채점(Judge), 지침 제안·검색 계획(Optimizer/planner), 검색용 숫자 변환(embedding)으로 나눕니다. 여기서 모델 자체를 직접 개발하지는 않습니다.
이 저장소의 기본 생성 지역은 North Central US (northcentralus)입니다. 기본 배포는 Agent gpt-6-sol, Judge gpt-6-luna, Optimizer/검색 planner gpt-5.5, embedding text-embedding-3-large입니다. 정확한 버전·SKU(배포 유형)·요청 용량은 모델 표와 계획 파일에서 확인합니다. 다른 지역·모델로 조용히 대체하지 않습니다.
모델별 최소 권장 TPM을 확보합니다
TPM(Tokens Per Minute)은 배포별 분당 토큰 처리 한도이며, 모델의 최대 입력 길이나 실제 처리 속도와는 다릅니다. 아래는 한 환경에서 평가·Optimizer를 한 번에 하나씩 실행하는 12문항 실습의 시작 기준입니다. 모든 요청에서 입증된 절대 최소치나 429 오류가 없다는 보장이 아니라, 평가기·검색 호출을 고려한 여유 포함 최소 권장값입니다.
| 역할·모델 | 실습 시작 최소 권장 TPM | 새 기본 계획의 Azure Resource Manager(ARM) 용량 |
|---|---|---|
Agent · gpt-6-sol |
100,000 | 100 |
Judge · gpt-6-luna |
100,000 | 100 |
Optimizer / 검색 planner · gpt-5.5 |
100,000 | 100, 두 역할이 같은 배포를 공유 |
Embedding · text-embedding-3-large |
10,000 | 10 |
새 bootstrap plan은 이 용량으로 계획을 만듭니다. ARM 용량은 모델·SKU별 단위이므로 다른 모델에 무조건 1,000을 곱하지 않습니다. 생성 후 실제 배포의 TPM을 아래 runtime preflight로 확인합니다. 구독에 남은 할당량이 있어도 해당 배포에 충분히 할당되지 않았다면 준비 완료가 아닙니다.
기존 계획·배포는 자동으로 상향되지 않습니다. 자신의 실제 배포와 승인 범위는 TPM 설정 참고로 확인합니다. config.json·승인서·manifest를 직접 고쳐 검사를 우회하지 않습니다.
여러 사용자가 같은 배포를 공유하거나 작업을 겹치면 추가 용량이 필요합니다. Microsoft Azure는 실제 청구 토큰뿐 아니라 입력·최대 출력 설정으로 추정한 토큰을 제한에 반영하며, 별도의 RPM(분당 요청 수)·짧은 구간의 요청 집중도도 제한합니다. 공식 TPM·RPM 설명을 참고합니다.
준비 상태와 비용 승인을 확인합니다
터미널에서 실행 · 공통 터미널
python -m lab bootstrap preflight --config .lab/lab-ko/config.json
Preflight는 실행 전 준비 상태 검사입니다. readiness_status: READY인지 확인합니다. 할당량(quota)은 구독에 허용된 사용 한도, 용량(capacity)은 해당 지역의 실제 제공 여력입니다. 막히면 reason을 읽고 생성 오류 대응으로 이동합니다. 아직 승인서가 없으므로 READY여도 전체 상태는 승인 대기일 수 있습니다. 지역이나 환경 이름을 바꾸어 반복 생성하지 않습니다.
원문에서 cognitiveservices model list, usage list, ARM의 modelCapacities를 조회하는 부분을 읽습니다. 이 검사는 모델을 호출하거나 배포하는 코드가 아닙니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:_preflight
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 1318–1350 · GitHub에서 보기
def _preflight(config: dict, manifest: dict, run: Run) -> tuple[dict, dict, dict]:
identity = _identity(config, manifest, run)
prerequisites = _prerequisites(config, run)
args = ["--location", REGION, "--subscription", config["subscription_id"]]
catalog = run(["cognitiveservices", "model", "list", *args])
usage = run(["cognitiveservices", "usage", "list", *args])
if not isinstance(catalog, list) or not isinstance(usage, list):
raise BootstrapError("Regional model catalog/quota response is invalid.")
capacities = {}
for model in config["models"]:
query = urlencode({
"api-version": "2024-10-01", "modelFormat": "OpenAI",
"modelName": model["name"], "modelVersion": model["version"],
})
capacities[model["deployment"]] = run([
"rest", "--method", "GET", "--url",
f"{ARM}/subscriptions/{config['subscription_id']}/providers/Microsoft.CognitiveServices/locations/{REGION}/modelCapacities?{query}",
])
observed = _inspect(config, manifest, run)
checks = _model_checks(config, catalog, usage, capacities, observed)
report = {
"status": "READY", "scope_sha256": config["scope_sha256"], "checked_at": _stamp(),
"region": REGION, "identity_verified": True, **prerequisites, "models": checks,
"owned_resource_count": len(observed), "mutations_performed": False,
"not_verified": [
"No paid inference, training, runtime data-plane access, or RBAC propagation tested.",
"MCP principal is not inferred from matching subscription/tenant.",
"Available quota is unallocated capacity, not free pricing or a spend cap.",
"Fine-tuning region, training quota, deployment availability, and cost require separate preflight/approval.",
],
}
raw = {"identity": identity, "catalog": catalog, "usage": usage, "capacities": capacities, "observed": observed}
return report, raw, observed
본인이 사용할 비용·변경 범위의 승인을 확인한 뒤 .lab/lab-ko/approval.example.json을 편집기에서 열고 다른 이름으로 저장하여 .lab/lab-ko/approval.json을 만듭니다. 본인 소유 구독에서 지출을 결정할 권한이 있다면 한도를 직접 정하고 기록합니다. 조직 구독에서는 실제 승인 절차를 따릅니다. 원본 scope_sha256, models, retention_days는 유지하고 승인서 작성표의 모든 필드를 채웁니다.
필수 참고: 승인서 작성표를 별도 탭에 열어 작성한 뒤 이 절로 돌아옵니다. approval.example.json 원본이 아니라 같은 폴더의 approval.json으로 저장됐는지 확인하고 아래 검사 명령을 실행합니다. 참고 문서의 환경 생성 명령까지 중복 실행하지 않습니다.
approved_by에는 계획의 expected_user와 같은 본인 로그인 이름을 적습니다. 이 값은 실행 신원을 연결하는 필드이지 조직 승인자의 전자서명이 아닙니다. 실제 승인 근거·현재 유효한 시작/만료 시각·통화/예산·대기/보관 한도·생성/RBAC/Global 처리 동의를 확인합니다. approved: true만 바꾸면 완료되지 않으며, 예산 숫자는 Microsoft Azure의 자동 과금 차단 장치가 아닙니다.
터미널에서 실행 · 공통 터미널
python -m lab bootstrap preflight --config .lab/lab-ko/config.json --approval .lab/lab-ko/approval.json
readiness_status: READY와 status: READY_FOR_APPROVED_APPLY를 모두 확인한 뒤에만 다음 생성 명령을 실행합니다.
승인한 환경을 생성하고 포털에서 확인합니다
터미널에서 실행 · 공통 터미널
python -m lab bootstrap apply --config .lab/lab-ko/config.json --approval .lab/lab-ko/approval.json
이 명령은 리소스·모델·연결·리소스 범위 역할을 실제로 생성합니다. 시간이 걸릴 수 있으므로 다른 창에서 같은 apply를 실행하지 않습니다. 정상 종료하면 APPLIED가 표시됩니다. 대기 초과·응답 불명일 때도 apply를 다시 제출하지 말고 아래 조회부터 수행합니다.
생성 상태를 조회합니다 · 재개할 때도 같은 명령입니다
터미널에서 실행 · 공통 터미널
python -m lab bootstrap status --config .lab/lab-ko/config.json --approval .lab/lab-ko/approval.json
phase: succeeded, 대상 리소스의 존재, .lab/lab-ko/.env 생성을 확인합니다. 진행 중이거나 실패했다면 생성 재개 절차를 따릅니다. 이 조회는 새 배포를 제출하지 않습니다.
여기서 생성된 .env가 이후 명령의 연결 설정입니다. 사용자·테넌트·구독과 실제 프로젝트·모델 배포 값이 들어 있습니다. 설정 파일이므로 source나 PowerShell 스크립트로 실행하지 않고, 명령의 --config로 지정합니다. 아직 없다면 .env.example을 복사해 생성 완료로 간주하지 않습니다.
선택 · 내부 동작: 환경 생성과 상태 조회
코드 ↔ 포털 · 생성한 자원과 배포를 확인합니다
실행 위치: 승인 후 위의 bootstrap apply·status 명령을 실행하고 아래의 포털·런타임 검사로 확인합니다. 포털에서는 생성 결과를 조회하며 같은 자원을 다시 만들지 않습니다.
| 코드·입력값 | 실제 동작 | 포털 조작·확인 위치 |
|---|---|---|
bootstrap setup |
같은 plan·preflight·승인 검사·apply를 안내하며, 기존 생성 시도는 status로만 조회합니다. | 아래 개별 명령과 같은 전용 환경입니다. 생성 후 포털에서 확인하며 중복 생성하지 않습니다. |
config["names"], ARM PUT의 location·tags |
이름 충돌을 검사하고 새 전용 리소스 그룹을 생성합니다. | Azure 포털 → Resource groups → 해당 그룹 → Overview에서 이름·지역·Tags를 확인합니다. 같은 이름으로 새 그룹을 만들지 않습니다. |
deployment group create, template.json, _parameters(...) |
실제 ARM 템플릿에 정의된 Microsoft Foundry·프로젝트·Search·관측 자원·모델·연결·역할을 배포합니다. | 그룹의 Deployments → 해당 배포 → Deployment details/Inputs/Outputs를 열어 템플릿 입력과 생성 항목을 대조합니다. |
config["models"]의 모델·버전·SKU·capacity |
네 모델 배포를 계획 그대로 생성합니다. | Microsoft Foundry Home → View deployments → 배포 → Details에서 모델·버전·배포 유형·Tokens per Minute Rate Limit을 확인합니다. |
bootstrap status의 기존 그룹·배포·리소스 조회 |
같은 계획의 실제 상태를 읽고 로컬 기록과 대조합니다. 새 ARM 배포를 제출하지 않습니다. | 같은 그룹 Deployments → 기존 배포와 실제 자원 목록을 새로고침합니다. Create로 다시 제출하지 않습니다. |
.env의 AZURE_AI_PROJECT_ENDPOINT |
생성 완료 후 실제 프로젝트 연결 설정을 저장합니다. | Microsoft Foundry의 Home → Project endpoint를 대조합니다. Azure OpenAI endpoint를 대신 쓰지 않습니다. |
| 역할 할당과 프로젝트·Search 관리 ID | 사용자와 서비스에 서로 다른 범위의 권한을 설정합니다. | Azure 포털의 해당 자원 Identity, Access control (IAM) → Check access에서 신원·Scope를 확인합니다. 이 확인을 위해 역할을 다시 추가하지 않습니다. |
아래 apply()에서 그룹 생성 → ARM 검증·배포 → 완료 상태 조회 → 인벤토리 대조 → .env 저장 순서를 읽습니다. bounded(...)는 승인 유효기간·대기 한도를 확인한 뒤 원문에 표시된 Azure CLI 인자를 실행합니다. 로컬 파일·재개·소유권 보호도 실제 구현의 일부이므로 생략하지 않습니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:apply
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 1917–2108 · GitHub에서 보기
def apply(
config_path: Path | str, approval_path: Path | str | None = None, *,
run: Run = az_json, what_if: bool = False, retry: bool = False,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> dict:
"""Provision only this plan's owned scope. Calling this function can incur cost."""
directory, config, manifest = _load(config_path)
if what_if and retry:
raise BootstrapError("--retry cannot be combined with read-only --what-if.")
if not what_if:
approval = _approved_record(config, approval_path)
else:
approval = {}
with _lock(directory):
directory, config, manifest = _load(config_path)
_check_outputs(directory, manifest)
try:
report, raw, observed = _preflight(config, manifest, run)
except BootstrapError as exc:
_record_failure(directory, "pre-apply", exc)
raise
_write(directory / "evidence" / f"pre-apply-{uuid4().hex}.local.json", raw)
if what_if:
if "resource_group" not in observed:
raise BootstrapError("Read-only group what-if needs the already-owned group; it never creates a group.")
elif any(m["deprecated"] for m in report["models"]) and not approval["accept_deprecated_models"]:
raise ApprovalError("A selected model is deprecating; explicit acceptance or a newly approved plan is required.")
principal = raw["identity"]["user"]["id"]
if what_if:
path = directory / "evidence" / f"what-if-parameters-{uuid4().hex}.local.json"
_write(path, _parameters(config, principal))
result = run([
"deployment", "group", "what-if", "--subscription", config["subscription_id"],
"--resource-group", config["names"]["resource_group"], "--name", config["deployment_name"],
"--template-file", str(directory / "template.json"), "--parameters", f"@{path}", "--mode", "Incremental",
"--no-pretty-print",
])
_write(directory / "evidence" / f"what-if-{uuid4().hex}.local.json", {"result": result})
return {"status": "WHAT_IF_ONLY", "scope_sha256": config["scope_sha256"], "mutations_performed": False}
start = clock()
deadline = start + approval["max_wait_seconds"]
def bounded(args: list[str]) -> Any:
try:
validate_approval(config, approval)
except BootstrapError as exc:
raise ApprovalError(str(exc)) from exc
remaining = deadline - clock()
if remaining <= 0:
raise BootstrapError("Approved wait exhausted; inspect status and resume, never delete or recreate blindly.")
return az_json(args, timeout=min(remaining, 120)) if run is az_json else run(args)
manifest["operator_principal_id"] = principal
manifest["approved_currency"] = approval["currency"]
manifest["approved_budget"] = approval["budget_amount"]
manifest["approved_budget_policy"] = approval.get("budget_policy", "BOUNDED")
initial_attempts = len(manifest["attempts"])
stage = "ownership"
active_command = None
submitted_attempt = None
try:
if "resource_group" not in observed:
if manifest["resources"]["resource_group"]["status"] not in {"planned", "pending"}:
raise BootstrapError("Previously owned resource group is missing; automatic restore/recreation is forbidden.")
if manifest["resources"]["resource_group"]["status"] == "pending":
raise ProvisioningRetryError(
"UNKNOWN_GROUP_SUBMISSION",
"A pending resource-group creation intent exists but the group is absent. "
"Inspect original submission/activity evidence; do not issue another create blindly.",
)
# Recheck immediately before conditional create; az group create would silently upsert.
if _get(manifest["resources"]["resource_group"], bounded) is not None:
raise BootstrapError("Resource group name collision; no adoption or overwrite.")
_pending(directory, manifest, ["resource_group"], "creating_group", approval)
group = manifest["resources"]["resource_group"]
stage = "group-create"
active_command = [
"rest", "--method", "PUT", "--url", f"{ARM}{group['id']}?api-version={group['api_version']}",
"--headers", "If-None-Match=*", "--body", _json({"location": REGION, "tags": _tags(config)}),
]
bounded(active_command)
remote = _get(group, bounded)
if remote is None:
raise BootstrapError("Group creation is not yet observable; use status before resume.")
_owned(config, "resource_group", group, remote, manifest, {})
manifest["resources"]["resource_group"]["status"] = "present"
_save(directory, manifest)
observed = _inspect(config, manifest, bounded)
if "resource_group" not in observed:
raise BootstrapError("Owned group must exist before any ARM deployment.")
arm = observed.get("arm_deployment", {}).get("properties", {}).get("provisioningState")
stage, active_command = "arm-retry-check", None
if _submission_required(manifest, observed, approval, retry):
stage = "arm-validation"
validation = _validate_arm(directory, config, principal, bounded)
manifest["last_validation"] = validation
parameter_path = Path(validation["parameters_path"])
observed = _inspect(config, manifest, bounded)
if not _submission_required(manifest, observed, approval, retry):
raise BootstrapError("Deployment state changed during validation; inspect status instead of issuing another create.")
_pending(directory, manifest, [k for k in manifest["resources"] if k != "resource_group"], "deploying", approval)
submitted_attempt = manifest["attempts"][-1]
submitted_attempt.update(
submission_outcome="pending", retry_requested=retry,
validation_evidence_file=validation["evidence_file"],
)
_save(directory, manifest)
stage = "arm-create"
active_command = [
"deployment", "group", "create", "--subscription", config["subscription_id"],
"--resource-group", config["names"]["resource_group"], "--name", config["deployment_name"],
"--template-file", str(directory / "template.json"), "--parameters", f"@{parameter_path}",
"--mode", "Incremental", "--no-wait",
]
response = bounded(active_command)
submitted_attempt["submission_outcome"] = "submitted"
_save(directory, manifest)
_write(directory / "evidence" / f"create-{uuid4().hex}.local.json", {
"command": active_command, "response": response, "recorded_at": _stamp(),
"status": "SUBMITTED_NOT_VERIFIED",
})
arm = None
stage = "arm-status"
while arm != "Succeeded":
deployment = _get(manifest["resources"]["arm_deployment"], bounded)
arm = (deployment or {}).get("properties", {}).get("provisioningState")
if arm in {"Failed", "Canceled", "Cancelled"}:
payload = (deployment or {}).get("properties", {}).get("error")
if payload:
spec = manifest["resources"]["arm_deployment"]
raise _azure_error("", _json(deployment), [
"rest", "--method", "GET", "--url",
f"{ARM}{spec['id']}?api-version={spec['api_version']}",
])
raise AzureCommandError("DeploymentFailed", payload=deployment, message="ARM deployment failed; inspect saved results before an explicitly budgeted retry.")
if arm != "Succeeded":
remaining = deadline - clock()
if remaining <= 0:
raise BootstrapError("Approved wait exhausted; deployment may still run. Inspect status.")
sleep(min(5, remaining))
observed = _inspect(config, manifest, bounded)
if set(observed) != set(manifest["resources"]):
raise BootstrapError("ARM succeeded but the complete owned inventory is not observable; do not claim success.")
routing = observed["insights_connection"].get("properties", {}).get("metadata", {}).get(TRACE_ROUTING_KEY)
expected_routing = observed["insights"].get("properties", {}).get("ConnectionString")
if not expected_routing or routing != expected_routing:
raise BootstrapError("ARM success is insufficient: trace routing metadata must match the owned component's ConnectionString.")
for key, remote in observed.items():
state = remote.get("properties", {}).get("provisioningState")
if state is not None and str(state).lower() != "succeeded":
raise BootstrapError(f"Resource is not ready: {key}.")
if state is None and (key in RESOURCE_TYPES or key.startswith("model:")):
raise BootstrapError(f"Resource readiness is missing: {key}.")
manifest["resources"][key]["status"] = "succeeded"
content = _env(config, observed["insights"], approval_path)
if not (directory / ".env").exists():
manifest["env_sha256"] = hashlib.sha256(content.encode()).hexdigest()
manifest["phase"] = "writing_env"
_save(directory, manifest)
_write(directory / ".env", content, text=True)
manifest["env_sha256"] = _file_digest(directory / ".env")
manifest["phase"] = "succeeded"
_save(directory, manifest)
return {
"status": "APPLIED", "scope_sha256": config["scope_sha256"],
"env_path": str(directory / ".env"), "artifacts_dir": str(directory / "artifacts"),
"resource_count": len(observed), "data_plane_verified": False,
"live_status": "NOT_VERIFIED",
"server_side_trace_connection": "CONFIGURED_PROJECT_MANAGED_IDENTITY",
"trace_ingestion_verified": False,
}
except BootstrapError as exc:
if submitted_attempt is not None and submitted_attempt.get("submission_outcome") == "pending":
submitted_attempt["submission_outcome"] = "unknown"
evidence_file = _record_failure(directory, stage, exc, command=active_command)
if submitted_attempt is not None:
submitted_attempt["failure_evidence_file"] = evidence_file
manifest.setdefault("failures", []).append({
"recorded_at": _stamp(), "stage": stage, "error_code": getattr(exc, "code", None),
"evidence_file": evidence_file,
})
if isinstance(exc, ApprovalError) and len(manifest["attempts"]) > initial_attempts:
exc.mutations_performed = None
manifest["phase"] = "interrupted"
manifest["last_error"] = str(exc)
manifest["last_error_code"] = getattr(exc, "code", None)
manifest["last_error_evidence_file"] = evidence_file
if getattr(exc, "validation_evidence_file", None):
manifest["last_validation"] = {"status": "FAILED", "evidence_file": exc.validation_evidence_file}
_save(directory, manifest)
raise
ARM 템플릿에 전달하는 실제 입력도 확인합니다. names·models·사용자 ID·리소스 범위 역할·tags·보존 기간이 이 객체로 전달됩니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:_parameters
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 1402–1411 · GitHub에서 보기
def _parameters(config: dict, principal: str) -> dict:
return {
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {key: {"value": value} for key, value in {
"location": REGION, "names": config["names"], "tags": _tags(config),
"models": config["models"], "roles": _roles(config),
"operatorPrincipalId": principal, "retentionDays": config["retention_days"],
}.items()},
}
status() 원문도 함께 확인합니다. apply가 대기 초과됐을 때 어떤 원격 상태를 다시 읽는지 볼 수 있습니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:status
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 2111–2138 · GitHub에서 보기
def status(
config_path: Path | str, *, run: Run = az_json, approval_path: Path | str | None = None,
) -> dict:
"""Inspect only declared resources; no deletes, provider registration, or permission repair."""
directory, config, manifest = _load(config_path)
_identity(config, manifest, run)
try:
observed = _inspect(config, manifest, run)
except BootstrapError as exc:
return {"status": "BLOCKED", "scope_sha256": config["scope_sha256"], "reason": str(exc)}
result = {
**_authorization(config, approval_path),
"observation_status": "OBSERVED", "live_status": "NOT_VERIFIED",
"scope_sha256": config["scope_sha256"], "phase": manifest["phase"],
"resources": {key: {
"present": key in observed,
"provisioning_state": observed.get(key, {}).get("properties", {}).get("provisioningState", "UNKNOWN"),
"recorded_state": spec["status"], "sku": spec["sku"],
} for key, spec in manifest["resources"].items()},
"mutations_performed": False,
"warning": "Search and any separately deployed fine-tuned model may still incur continuous hosting costs.",
}
if result["approval_status"] == "APPROVED":
result["status"] = "OBSERVED_APPROVAL_VALID"
result["live_status"] = "PENDING_EXECUTION"
result["readiness_status"] = "NOT_CHECKED_BY_STATUS"
_write(directory / "evidence" / f"status-{uuid4().hex}.local.json", {"observed": observed, "summary": result})
return result
빠른 경로의 안내 함수도 같은 생성·소유권 검사를 재사용합니다. 별도 인프라 템플릿이나 우회 권한을 만들지 않습니다.
실제 구현 코드 · 읽기 전용 · lab/bootstrap.py:setup
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 2141–2256 · GitHub에서 보기
def setup(
environment: str, *, root: Path | str = Path(".lab"), run: Run = az_json,
read: Callable[[str], str] = input, write: Callable[[str], None] = print,
) -> dict:
"""Guide a participant through the existing exact-scope bootstrap, without retries."""
from lab.config import LabError, environment_language
from lab.content import selected_language, text as localized_text
try:
language = environment_language(environment) or selected_language()
except LabError as exc:
raise BootstrapError(str(exc)) from exc
if not re.fullmatch(r"[a-z][a-z0-9-]{2,23}", environment):
raise BootstrapError("Environment must be a lowercase 3–24 character lab prefix.")
directory = Path(root) / environment
if any(path.is_symlink() for path in (directory, *directory.parents)):
raise BootstrapError("Environment root must not traverse symlinks.")
config_path = directory / "config.json"
if not directory.exists():
account = run(["account", "show"])
if (
not isinstance(account, dict) or account.get("state") != "Enabled"
or not isinstance(account.get("user"), dict)
or str(account["user"].get("type", "")).lower() != "user"
or not isinstance(account["user"].get("name"), str)
or not _uuid(account.get("id")) or not _uuid(account.get("tenantId"))
):
raise BootstrapError("Sign in to Microsoft Azure with your own user and select an Enabled subscription first.")
plan(
subscription_id=account.get("id"), tenant_id=account.get("tenantId"),
expected_user=account["user"].get("name", ""), environment=environment, root=root,
)
directory, config, manifest = _load(config_path)
language = config.get("language", language)
text = partial(localized_text, language=language)
config_path = directory / "config.json"
approval_path = directory / "approval.json"
if manifest["phase"] != "planned" or manifest["attempts"]:
write(text(
"기존 생성 기록이 있습니다. 새 배포를 제출하지 않고 같은 환경의 상태만 조회합니다.",
"A creation record already exists. Inspecting the same environment without submitting another deployment.",
))
return status(config_path, run=run, approval_path=approval_path if approval_path.exists() else None)
write(text(
"권한·모델·할당량을 읽기 전용으로 검사합니다. 아직 Microsoft Azure 자원을 생성하지 않습니다.",
"Checking permissions, models, and quota read-only. No Microsoft Azure resources are being created yet.",
))
report = preflight(config_path, run=run)
if report["readiness_status"] != "READY":
return report
write(_json({
"config_path": str(config_path), "user": config["expected_user"],
"subscription": config["subscription_id"], "tenant": config["tenant_id"],
"location": config["location"], "resources": config["names"], "models": config["models"],
"language": language, "artifacts_dir": str(directory / "artifacts"),
"log_retention_days": config["retention_days"],
}))
write(text(
"이 계획은 전용 그룹, Microsoft Foundry 프로젝트·4개 모델 배포, Search·로그 및 자원 범위 역할을 만듭니다.\n"
"Global Standard의 전 세계 처리, 지속 과금, 확정되지 않은 총비용을 승인한 경우에만 계속합니다.\n"
"예산·호출 한도는 기록이며 포털 과금을 자동 차단하지 않습니다. 삭제·학습·재시도는 승인하지 않습니다.",
"This plan creates a dedicated group, Microsoft Foundry project/four model deployments, Search/logging, and resource-scoped roles.\n"
"Continue only with authorization for Global Standard worldwide processing, continuous hosting, and an unconfirmed total cost.\n"
"Budget/call limits are records, not automatic portal spending cutoffs. Deletion, training, and retries are not authorized.",
))
existing_approval = approval_path.exists()
try:
if existing_approval:
approval = _approved_record(config, approval_path)
write(text("기존의 유효한 승인을 재사용합니다. 한도·만료를 늘리지 않습니다.", "Reusing valid existing authorization without extending limits or expiry."))
else:
approval = approval_template(config)
currency = read(text("승인 통화 (예: USD, KRW): ", "Approved currency (for example USD, KRW): ")).strip().upper()
try:
amount = float(read(text("승인 예산 금액 (양수): ", "Approved budget amount (positive number): ")).strip())
hours = int(read(text("승인된 최대 사용 시간 (양의 정수, 시간): ", "Approved maximum hosting duration (positive integer hours): ")).strip())
calls = int(read(text("승인된 모델 호출 한도 (양의 정수): ", "Approved model-call allowance (positive integer): ")).strip())
except ValueError as exc:
raise BootstrapError("Enter a numeric budget and positive integer hosting/call limits; no approval was saved.") from exc
evidence = read(text("실제 승인 근거 (비공개 메모, 비밀번호·토큰 제외): ", "Actual authorization reference (private note, no passwords/tokens): ")).strip()
if not _integer(hours, 1) or not _integer(calls, 1) or not evidence:
raise BootstrapError("Positive hosting/call limits and actual authorization evidence are required.")
start = _now()
try:
end = start + timedelta(hours=hours)
except OverflowError as exc:
raise BootstrapError("The hosting duration is too large.") from exc
approval.update(
approved=True, approved_by=config["expected_user"], approved_at=_stamp(start), expires_at=_stamp(end),
currency=currency, budget_amount=amount, max_hosting_hours=hours, max_calls=calls,
max_candidates=2, max_wait_seconds=3600, request_evidence=evidence,
allow_global_inference=True, allow_resource_creation=True, allow_rbac_assignments=True,
acknowledge_continuous_hosting=True, acknowledge_unknown_cost=True,
)
validate_approval(config, approval)
write(_json({key: approval[key] for key in (
"currency", "budget_amount", "expires_at", "max_hosting_hours", "max_calls",
"max_candidates", "max_wait_seconds", "retention_days",
)}))
confirmation = f"CREATE {environment}"
answer = read(text(
f"위 자원 생성·역할 할당·처리/비용 범위를 실제로 승인했다면 {confirmation} 입력, 아니면 Enter로 취소합니다: ",
f"If you actually authorize the resources, role assignments, processing and cost scope above, type {confirmation}; otherwise press Enter to cancel: ",
))
if answer.strip() != confirmation:
return {"status": "CANCELLED_LOCAL_ONLY", "config_path": str(config_path), "mutations_performed": False}
except (EOFError, KeyboardInterrupt):
return {"status": "CANCELLED_LOCAL_ONLY", "config_path": str(config_path), "mutations_performed": False}
if not existing_approval:
_write(approval_path, approval)
write(text(
"승인된 계획을 생성합니다. 다른 터미널에서 같은 작업을 실행하지 않습니다. 중단되면 status로 조회합니다.",
"Applying the authorized plan. Do not start the same work in another terminal; inspect status if interrupted.",
))
return apply(config_path, approval_path, run=run)
생성된 환경과 런타임 설정을 확인합니다
- Azure 포털 → Resource groups에서
config.json의names.resource_group을 검색합니다. 구독·지역·리소스 목록을 확인합니다. - Microsoft Foundry를 열고 New Foundry를 사용합니다. Select a project to continue가 나타나면
names.project와 같은 프로젝트를 선택하고 Let's go를 누릅니다. 환영 안내가 나타나면 읽고 Close로 닫습니다. 이미 New Foundry라면 좌측 상단 프로젝트 선택을 사용합니다. Classic의 hub 기반 프로젝트와 혼동하지 않습니다. - 프로젝트의 Home(일부 UI의 Overview)에서 Project endpoint를 확인합니다.
.env의AZURE_AI_PROJECT_ENDPOINT와 같은https://계정명.services.ai.azure.com/api/projects/프로젝트명형식이어야 합니다. - Models + endpoints 또는 Build → Models에서
.env의MODEL_DEPLOYMENT,JUDGE_DEPLOYMENT,OPTIMIZER_DEPLOYMENT,EMBEDDING_DEPLOYMENT에 대응하는 실제 배포를 확인합니다. 메뉴 명칭이 달라지면 화면 위치 안내를 참고합니다.
names.project와 대조하고 Let's go로 엽니다. 이 화면은 기존 프로젝트 선택이며 추가 생성이 아닙니다. 이미 bootstrap을 완료했다면 Create a new project로 중복 생성하지 않습니다. 원본 크기로 보기
터미널에서 신원·배포·TPM을 검사합니다
환경 생성이 완료되어 .lab/lab-ko/.env가 있을 때 다음 읽기 전용 명령을 실행합니다. 신원 확인도 이 명령 안에서 수행합니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env preflight
checks의 agent_tpm, judge_tpm, optimizer_tpm, iq_planner_tpm, embedding_tpm을 확인합니다. 각 항목의 observed는 실제 배포 TPM, expected는 모델별 최소 권장값입니다. 부족하거나 토큰 제한을 확인할 수 없으면 BLOCKED이며 다음 호출 단계로 진행하지 않습니다. 배포 할당량을 준비한 뒤 같은 읽기 전용 preflight를 다시 실행합니다.
선택 · 내부 동작: 신원·배포·TPM 검사
코드 ↔ 포털 · 생성된 설정과 실행 신원을 대조합니다
실행 위치: 위의 런타임 검사 명령을 실행합니다. 프로그램이 생성된 설정 파일과 현재 CLI 신원을 비교하므로, 신원 검사 함수를 별도로 입력하지 않습니다.
| 명령·설정값 | 실제 동작 | 포털 확인 위치 |
|---|---|---|
--config .lab/lab-ko/.env의 AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, EXPECTED_AZURE_USER |
생성된 환경에 기록한 구독·테넌트·사용자를 읽습니다. | 01에서 확인한 Azure 포털 계정의 Directory와 구독 Overview를 대조합니다. |
check_identity()의 az account show --subscription ... |
설정값과 CLI의 사용자·테넌트·구독·Enabled 상태를 자동 대조합니다. 불일치하면 자원 조회를 진행하지 않고 중단합니다. | 같은 계정·구독인지 확인합니다. 로그인 성공만으로 다른 신원을 허용하지 않습니다. |
아래 함수가 이 명령 안에서 수행하는 신원 검사입니다. 구독 선택만으로 테넌트 확인을 대신하지 않습니다. 이 검사는 SDK 토큰 요청이나 모델 호출이 아닙니다.
실제 구현 코드 · 읽기 전용 · lab/preflight.py:check_identity
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 80–105 · GitHub에서 보기
def check_identity(config: Config, run: Callable = az_json) -> dict:
account = run(["account", "show", "--subscription", config.subscription_id])
if not isinstance(account, dict):
raise LabError(text(
"az account show 응답 형식이 올바르지 않습니다.",
"The az account show response has an unexpected format.",
))
actual_user = account.get("user", {}).get("name", "")
checks = {
"subscription": account.get("id") == config.subscription_id,
"tenant": account.get("tenantId") == config.tenant_id,
"user": actual_user.lower() == config.expected_user.lower(),
"enabled": account.get("state") == "Enabled",
}
if not all(checks.values()):
failed = ", ".join(key for key, ok in checks.items() if not ok)
raise LabError(text(
f"로그인 환경 불일치({failed}). 작업을 중단했습니다. "
f"az login --tenant {config.tenant_id} 후 지정 계정으로 로그인해야 합니다. "
"SDK는 다른 환경 자격 증명으로 자동 전환하지 않습니다.",
f"The sign-in environment does not match ({failed}). Work stopped. "
f"Run az login --tenant {config.tenant_id} and sign in with the designated account. "
"The SDK never switches to another environment's credentials automatically.",
language=config.language,
))
return {"user": actual_user, "subscription": account["id"], "tenant": account["tenantId"]}
아래 런타임 run_preflight()는 .env의 대상 자원·배포를 조회합니다. rateLimits의 key: token을 읽거나 원시 ARM 메타데이터를 다시 조회하는 부분을 포털의 TPM 표시와 연결합니다. 구독 할당량 확인과 배포의 실제 TPM 확인은 다른 검사입니다.
실제 구현 코드 · 읽기 전용 · lab/preflight.py:run_preflight
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 108–258 · GitHub에서 보기
def run_preflight(config: Config, *, run: Callable = az_json) -> dict:
identity = check_identity(config, run)
account = run([
"cognitiveservices", "account", "show", "--subscription", config.subscription_id,
"--resource-group", config.resource_group, "--name", config.account,
])
project = run([
"resource", "show", "--ids", config.project_id, "--api-version", "2025-06-01",
])
search = run([
"search", "service", "show", "--subscription", config.subscription_id,
"--resource-group", config.resource_group, "--name", config.search_service,
])
deployments = run([
"cognitiveservices", "account", "deployment", "list",
"--subscription", config.subscription_id, "--resource-group", config.resource_group,
"--name", config.account,
])
checks: list[dict] = []
for name, resource in (("foundry", account), ("project", project), ("search", search)):
region = str(resource.get("location", "")).lower().replace(" ", "")
checks.append({
"name": f"{name}_region",
"status": "PASS" if region == config.location else "BLOCKED",
"observed": region,
"expected": config.location,
})
endpoint = project.get("properties", {}).get("endpoints", {}).get("AI Foundry API")
checks.append({
"name": "project_endpoint",
"status": "PASS" if endpoint == config.project_endpoint else "BLOCKED",
"observed": endpoint,
})
deployment_map = {d["name"]: d for d in deployments}
model_evidence = {}
roles = (
("agent", config.model), ("judge", config.judge), ("optimizer", config.optimizer),
("iq_planner", config.planner), ("embedding", config.embedding),
)
rate_limit_sources = {}
for name in dict.fromkeys(name for _, name in roles):
deployment = deployment_map.get(name)
if not deployment:
continue
limits = deployment.get("properties", {}).get("rateLimits")
source = "Azure CLI deployment metadata"
if not isinstance(limits, list) or not any(
isinstance(limit, dict) and limit.get("key") == "token" for limit in limits
):
# Use raw ARM when CLI metadata lacks token/request labels instead of inferring TPM.
identifier = f"{config.account_id}/deployments/{quote(name, safe='')}"
deployment = run([
"rest", "--method", "GET", "--url",
f"https://management.azure.com{identifier}?api-version=2025-06-01",
])
if (
not isinstance(deployment, dict) or deployment.get("name") != name
or str(deployment.get("id", "")).lower() != identifier.lower()
or not isinstance(deployment.get("properties"), dict)
):
raise LabError(text(
"TPM 조회 응답이 요청한 모델 배포와 다릅니다.",
"The TPM lookup response does not match the requested model deployment.",
))
deployment_map[name] = deployment
source = "Raw ARM deployment metadata"
rate_limit_sources[name] = source
for role, name in roles:
deployment = deployment_map.get(name)
ready = deployment and deployment.get("properties", {}).get("provisioningState") == "Succeeded"
checks.append({
"name": f"{role}_deployment", "status": "PASS" if ready else "BLOCKED",
"observed": name,
})
if deployment:
limits = deployment.get("properties", {}).get("rateLimits")
minimum_tpm = LAB_MINIMUM_TPM[role]
try:
observed_tpm = _tokens_per_minute(limits)
sufficient = observed_tpm >= minimum_tpm
reason = (
"Meets the lab starting TPM requirement; RPM and shared load still apply."
if sufficient else
f"Allocate at least {minimum_tpm:,} TPM with operator approval, then rerun preflight."
)
except ValueError as exc:
observed_tpm, sufficient, reason = None, False, str(exc)
checks.append({
"name": f"{role}_tpm", "status": "PASS" if sufficient else "BLOCKED",
"observed": observed_tpm, "expected": minimum_tpm, "reason": reason,
})
model_evidence[role] = {
"deployment": name,
"model": deployment.get("properties", {}).get("model"),
"sku": deployment.get("sku"),
"tokens_per_minute": observed_tpm,
"rate_limit_source": rate_limit_sources[name],
}
if role == "iq_planner":
observed_model = (deployment or {}).get("properties", {}).get("model", {}).get("name")
checks.append({
"name": "iq_planner_supported_model",
"status": "PASS" if observed_model == "gpt-5.5" else "BLOCKED",
"observed": observed_model,
"expected": "gpt-5.5",
})
if role == "embedding":
observed_model = (deployment or {}).get("properties", {}).get("model", {}).get("name")
checks.append({
"name": "embedding_supported_model",
"status": "PASS" if observed_model == "text-embedding-3-large" else "BLOCKED",
"observed": observed_model, "expected": "text-embedding-3-large",
})
public_access = account.get("properties", {}).get("publicNetworkAccess")
if public_access == "Disabled":
checks.append({
"name": "network", "status": "BLOCKED",
"observed": text(
"PublicNetworkAccess=Disabled: 승인된 VNet 연결 환경에서 실행해야 합니다.",
"PublicNetworkAccess=Disabled: run from an approved VNet-connected environment.",
),
})
return {
"checked_at": datetime.now(timezone.utc).isoformat(),
"kind": "read-only-management-preflight",
"status": "PASS" if all(c["status"] == "PASS" for c in checks) else "BLOCKED",
"identity": identity,
"checks": checks,
"models": model_evidence,
"identities": {
"foundry_project_managed_identity": project.get("identity", {}).get("principalId"),
"search_managed_identity": search.get("identity", {}).get("principalId"),
},
"not_verified": [
text("데이터 평면 RBAC와 모델 추론 성공", "Data-plane RBAC and successful model inference"),
text("Foundry IQ 지식 베이스 생성·검색 권한", "Foundry IQ knowledge-base creation and retrieval permissions"),
text(
"Prompt Optimizer 및 Frontier Tuning 테넌트 접근 권한",
"Tenant access to Prompt Optimizer and Frontier Tuning",
),
text(
"Global Standard는 North Central US 내부 처리를 보장하지 않습니다.",
"Global Standard does not guarantee processing inside North Central US.",
),
text(
"TPM 충족은 남은 토큰, 공유 부하, RPM·버스트 제한 또는 429 없는 실행을 보장하지 않습니다.",
"Meeting the TPM minimum does not guarantee remaining tokens, absence of shared load, "
"RPM or burst limits, or a run without 429 errors.",
),
],
}
완료 기준: 런타임 preflight 전체와 다섯 TPM 항목이 PASS이고, 자신의 프로젝트와 역할별 배포를 확인합니다. 이는 읽기 전용 구성 확인이며 실제 모델 응답 성공은 다음 단계에서 확인합니다.
03. 정책을 연결하고 Agent를 생성합니다
이 단계에서 하는 일: 합성 정책을 검색하는 읽기 전용 도구와 비교 기준인 v1 Agent를 연결합니다.
중요한 이유: 모델이 기억으로 답하는 것과 실제 정책을 검색해 답하는 것은 다릅니다. 검색이 사용자 권한으로 성공해도 Agent의 관리 ID 권한은 별도로 확인해야 합니다.
진행 방법·위치: 터미널에서 모델·검색·Agent를 준비하고 Microsoft Foundry에서 지침·도구와 실제 응답을 확인합니다. 데이터 전송·호출은 승인된 범위에서 한 번씩 수행합니다.
실행 순서: 터미널 · 모델 확인 → 정책 업로드 → 검색 확인 → v1 생성 → Microsoft Foundry · 응답 확인.
모델과 정책 검색을 확인합니다
이 세 명령은 실제 업로드·모델 호출을 수행하며 비용이 발생할 수 있습니다. 본인이 각 명령의 정상 결과를 확인한 뒤 다음 명령으로 이동합니다. 재개할 때는 저장된 receipt에서 이미 완료한 작업을 확인하고 불필요한 호출을 반복하지 않습니다.
1. 모델이 실제로 응답하는지 확인합니다
smoke는 짧은 동작 확인입니다.
먼저 배포 TPM을 포함한 02의 런타임 검사를 통과해야 합니다. 짧은 응답 한 번의 성공만으로 전체 평가에 필요한 처리량을 확보했다고 판단하지 않습니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env smoke --run-id model-smoke --confirm
출력의 status: completed를 확인합니다.
선택 · 내부 동작: SDK 인증과 모델 호출
코드 ↔ 포털 · 확인한 CLI 신원으로 SDK를 연결합니다
실행 위치: 위의 모델 연결 확인 명령을 실행합니다. 아래 인증 함수는 실제 SDK 호출 안에서 자동으로 사용되며, 별도 터미널 명령이 아닙니다.
| 실제 코드 | 실제 동작 | 포털 확인 위치 |
|---|---|---|
credential_for()의 AzureCliCredential(subscription=...) |
02의 설정으로 CLI 신원을 다시 확인하고 자격 증명 객체를 만듭니다. SDK가 서비스를 호출할 때 그 신원으로 토큰을 요청합니다. API key나 다른 자격 증명으로 전환하지 않습니다. | Microsoft Foundry 우측 상단 로그인 계정과 프로젝트의 구독을 설정값과 대조합니다. 토큰을 포털에 붙여넣는 작업은 없습니다. |
아래 첫 함수는 SDK에 전달할 자격 증명을 만들고, 다음 함수는 실제 모델 요청을 보냅니다. 인증 연결과 모델 응답 확인은 이 단계에서 함께 수행합니다.
실제 구현 코드 · 읽기 전용 · lab/auth.py:credential_for
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 12–16 · GitHub에서 보기
def credential_for(config: Config) -> AzureCliCredential:
check_identity(config)
# Azure CLI rejects get-access-token when both tenant and subscription are
# supplied. The identity check above binds the subscription to the tenant.
return AzureCliCredential(subscription=config.subscription_id, process_timeout=30)
smoke_model() 안의 client.responses.create(model=config.model, ...)가 실제 Sol 모델 호출입니다. Agent를 아직 생성하지 않고 짧은 입력·최대 출력 128토큰으로 연결을 확인하며, 호출 전에 기록을 써서 결과 불명인 요청을 반복하지 않습니다.
실제 구현 코드 · 읽기 전용 · lab/agents.py:smoke_model
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 168–216 · GitHub에서 보기
def smoke_model(config: Config, run_id: str) -> dict:
directory = safe_run_dir(run_id)
path = directory / "model-smoke.json"
if path.exists():
previous = read_json(path)
require_content_language(previous)
if previous.get("project_endpoint") != config.project_endpoint or previous.get("deployment") != config.model:
raise LabError(text(
"기존 model smoke의 환경/배포가 다릅니다.",
"The environment or deployment of the earlier model smoke check differs.",
))
if previous.get("status") != "completed":
raise LabError(text(
"이 model smoke는 실패 또는 결과 불명입니다. 원본을 확인하기 전 재호출하지 않습니다.",
"This model smoke check failed or its outcome is unknown. Do not call it again before checking the original record.",
))
return previous
snapshot = model_snapshot(config, config.model)
record = {
**language_metadata(),
"kind": "LIVE_MODEL_SMOKE_NOT_QUALITY_EVALUATION", "status": "submitting",
"started_at": datetime.now(timezone.utc).isoformat(),
"code": code_provenance(),
"project_endpoint": config.project_endpoint, "deployment": config.model,
"model_snapshot": snapshot, "response_id": None, "cost": {"status": "NOT_OBSERVED"},
"request": {"input": "Synthetic Contoso lab connectivity check. Reply with the single word READY.", "max_output_tokens": 128},
}
write_once_json(path, record)
started = time.perf_counter()
with credential_for(config) as credential:
with AIProjectClient(endpoint=config.project_endpoint, credential=credential, retry_total=0) as project:
with project.get_openai_client(max_retries=0, timeout=120.0) as client:
response = client.responses.create(model=config.model, **record["request"], store=True)
record["response_id"] = response.id
record["response"] = response.model_dump(mode="json")
record["usage"] = record["response"].get("usage")
record["latency_ms"] = (time.perf_counter() - started) * 1000
record["status"] = "completed" if (
record["response"].get("status") == "completed"
and isinstance(response.id, str) and response.id.strip()
and isinstance(response.output_text, str) and response.output_text.strip()
) else "failed"
save_json(path, record)
if record["status"] != "completed":
raise LabError(text(
"모델 smoke가 완료된 비어 있지 않은 응답을 반환하지 않았습니다.",
"The model smoke check did not return a completed, non-empty response.",
))
return record
2. 정책 검색을 준비합니다
제공된 합성 정책 8개를 검색 서비스에 올립니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env iq prepare --confirm
출력의 uploaded_documents: 8을 확인합니다. 프로그램이 검색 인덱스(검색용 문서 모음), knowledge base(지식 베이스), MCP 연결을 준비합니다. MCP(Model Context Protocol)는 Agent와 도구를 연결하는 규약입니다. 여기서는 정책을 읽는 검색 도구만 연결하며 내부 코드를 수정할 필요는 없습니다.
선택 · 내부 동작: 정책 업로드와 검색 연결
코드 ↔ 포털 · 정책 검색을 구성합니다
실행 위치: 터미널에서 위의 iq prepare 다음 아래의 iq probe를 실행합니다. 아래 패널은 두 명령의 내부 구현이며 별도로 실행하지 않습니다.
| 실제 코드·설정 | 실제 동작 | 포털 조작·확인 위치 |
|---|---|---|
knowledge_payloads()["index"]의 fields, vectorSearch, semantic |
정책 필드·3072차원 벡터·HNSW·embedding·semantic 설정을 정의합니다. | Azure 포털 → 해당 Search → Indexes → 생성된 인덱스 → Fields/JSON에서 필드와 검색 구성을 확인합니다. 인덱스를 다시 만들지 않습니다. |
embed()의 client.embeddings.create(...) |
8개 정책의 제목·내용을 실제 embedding 배포에 보냅니다. | Microsoft Foundry View deployments → embedding 배포 → Details에서 모델·배포 이름을 확인합니다. 벡터 값 자체는 Search 인덱스에 저장되며 Agent의 Playground에 표시되는 설정이 아닙니다. |
prepare_knowledge()의 PUT·POST |
인덱스 생성, 문서 업로드, knowledge source·knowledge base·프로젝트 연결을 순서대로 생성합니다. | Search의 Indexes, Microsoft Foundry Build → Knowledge → Knowledge bases에서 이름·Knowledge sources를 대조합니다. |
connection.authType, audience, target |
프로젝트 관리 ID로 Search의 MCP endpoint에 접근하는 연결입니다. | Microsoft Foundry의 Knowledge에서 Connection을 확인합니다. UI에 authType·audience 편집란이 없다면 knowledge/config-snapshot.json을 읽으며 임의 값을 넣지 않습니다. |
probe_knowledge()의 /retrieve·references·activity |
실제 질문으로 정책 검색을 확인합니다. | 포털의 Active 상태와 구분합니다. 실제 검색 결과는 probe 출력·기록이고, Agent 경유 호출은 아래 Playground에서 별도로 확인합니다. |
먼저 서비스에 보내는 설정 객체 전체, 이어서 그 설정을 만들고 업로드하는 실제 코드를 읽습니다. config.*는 02의 .env, names는 자신의 기록에 있는 실제 객체 이름입니다.
실제 구현 코드 · 읽기 전용 · lab/knowledge.py:knowledge_payloads
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 33–108 · GitHub에서 보기
def knowledge_payloads(config: Config, names: dict, planner_model: str) -> dict:
if not config.embedding:
raise LabError(text(
"벡터 검색을 위해 EMBEDDING_DEPLOYMENT를 명시해야 합니다.",
"EMBEDDING_DEPLOYMENT must be set explicitly for vector search.",
))
analyzer = f"{selected_language()}.microsoft"
return {
"index": {
"name": names["index"],
"fields": [
{"name": "id", "type": "Edm.String", "key": True, "filterable": True, "retrievable": True},
{"name": "title", "type": "Edm.String", "searchable": True, "retrievable": True, "analyzer": analyzer},
{"name": "content", "type": "Edm.String", "searchable": True, "retrievable": True, "analyzer": analyzer},
{"name": "effective_date", "type": "Edm.String", "filterable": True, "retrievable": True},
{"name": "content_vector", "type": "Collection(Edm.Single)", "searchable": True,
"retrievable": False, "dimensions": DIMENSIONS, "vectorSearchProfile": "contoso-vector"},
],
"vectorSearch": {
"algorithms": [{"name": "contoso-hnsw", "kind": "hnsw", "hnswParameters": {"metric": "cosine"}}],
"profiles": [{"name": "contoso-vector", "algorithm": "contoso-hnsw", "vectorizer": "contoso-embedding"}],
"vectorizers": [{
"name": "contoso-embedding", "kind": "azureOpenAI",
"azureOpenAIParameters": {
"resourceUri": config.openai_endpoint, "deploymentId": config.embedding,
"modelName": "text-embedding-3-large",
},
}],
},
"semantic": {
"defaultConfiguration": "support-semantic",
"configurations": [{
"name": "support-semantic",
"prioritizedFields": {
"titleField": {"fieldName": "title"},
"prioritizedContentFields": [{"fieldName": "content"}],
},
}],
},
},
"source": {
"name": names["source"],
"kind": "searchIndex",
"searchIndexParameters": {
"searchIndexName": names["index"],
"semanticConfigurationName": "support-semantic",
"sourceDataFields": [{"name": field} for field in ("id", "title", "content", "effective_date")],
"searchFields": [{"name": field} for field in ("title", "content", "content_vector")],
},
},
"base": {
"name": names["base"],
"description": "Synthetic Contoso policies. Stable ATLAS document IDs are in titles and source data.",
"knowledgeSources": [{"name": names["source"]}],
"models": [{
"kind": "azureOpenAI",
"azureOpenAIParameters": {
"resourceUri": config.openai_endpoint,
"deploymentId": config.planner,
"modelName": planner_model,
},
}],
"retrievalReasoningEffort": {"kind": "low"},
"outputMode": "extractiveData",
},
"connection": {
"properties": {
"authType": "ProjectManagedIdentity",
"category": "RemoteTool",
"target": search_url(config, "knowledgebases", names["base"], "/mcp"),
"isSharedToAll": False,
"audience": "https://search.azure.com/",
"metadata": {"ApiType": "Azure"},
},
},
}
다음 짧은 함수가 실제 GET → 생성 전용 PUT → 소유 기록 저장입니다. 이어지는 prepare_knowledge()가 인덱스·source·base·connection에 이 동작을 적용합니다.
실제 구현 코드 · 읽기 전용 · lab/knowledge.py:_ensure_created
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 111–134 · GitHub에서 보기
def _ensure_created(config: Config, http: JsonHttp, kind: str, name: str, url: str, payload: dict) -> dict:
state = workspace(config)
owned = [item for item in state["created"] if item.get("kind") == kind and item.get("name") == name]
try:
existing = http.request("GET", url)
except CloudRequestError as exc:
if exc.status != 404:
raise
existing = None
if existing is not None:
if not owned:
raise LabError(text(
f"{name}이 이미 존재하지만 이 실습의 생성 기록이 없습니다. 덮어쓰지 않습니다.",
f"{name} already exists, but this lab has no creation record for it. Not overwriting.",
))
return existing.body
result = http.request("PUT", url, payload, create_only=True)
if result.status not in {200, 201}:
raise LabError(text(
f"{name}: 비동기/예상 밖 상태 {result.status}. 완료로 기록하지 않습니다.",
f"{name}: asynchronous or unexpected status {result.status}. Not recorded as complete.",
))
record_created(config, {"kind": kind, "name": name, "url": url, "etag": result.etag})
return result.body
실제 구현 코드 · 읽기 전용 · lab/knowledge.py:prepare_knowledge
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 137–274 · GitHub에서 보기
def prepare_knowledge(config: Config) -> dict:
state = workspace(config, create=True)
names = resource_names(config, state)
documents_path = content_path(ROOT, "data/knowledge/documents.json")
documents = read_json(documents_path)
content_hash = sha256_file(documents_path)
setup_path = artifacts_dir() / "knowledge/setup.json"
previous = None
if setup_path.exists():
previous = read_json(setup_path)
require_content_language(previous)
if previous.get("documents_sha256") != content_hash or previous.get("names") != names:
raise LabError(text(
"기존 지식 준비와 원본/범위가 다릅니다. 다른 실험을 기존 인덱스에 덮어쓰지 않습니다.",
"The source or scope differs from the earlier knowledge preparation. A different experiment is never written over the existing index.",
))
if previous.get("status") in {"created_not_retrieval_tested", "retrieval_verified"}:
retrieval = previous.get("retrieval", {})
if (
retrieval.get("embedding_deployment") != config.embedding
or retrieval.get("embedding_model") != "text-embedding-3-large"
or retrieval.get("dimensions") != DIMENSIONS
):
raise LabError(text(
"준비된 인덱스의 embedding 계약이 다릅니다. 새 실험을 생성해야 합니다.",
"The embedding contract of the prepared index differs. Create a new experiment.",
))
return previous
with credential_for(config) as credential:
planner = az_json([
"cognitiveservices", "account", "deployment", "show",
"--subscription", config.subscription_id, "--resource-group", config.resource_group,
"--name", config.account, "--deployment-name", config.planner,
])
model = planner.get("properties", {}).get("model", {})
if not model.get("name"):
raise LabError(text(
"IQ planner의 실제 모델 이름을 확인할 수 없습니다.",
"Could not confirm the actual model name of the IQ planner.",
))
# This workshop's main lane was researched for gpt-5.5, not arbitrary
# answer models that may be unsupported by the knowledge-base planner.
if model["name"] != "gpt-5.5":
raise LabError(text(
"이 경로의 IQ planner는 확인된 gpt-5.5 배포를 요구합니다. 다른 모델로 자동 전환하지 않습니다.",
"The IQ planner on this path requires a verified gpt-5.5 deployment. It never switches to another model automatically.",
))
payloads = knowledge_payloads(config, names, model["name"])
payload_hash = hashlib.sha256(json.dumps(payloads, sort_keys=True).encode()).hexdigest()
if previous and previous.get("payload_sha256") != payload_hash:
raise LabError(text(
"미완료 검색 설정의 구성 계약이 바뀌었습니다. 같은 인덱스에 덮어쓰지 않습니다.",
"The configuration contract of the unfinished search setup changed. Not overwriting the same index.",
))
setup = {
**language_metadata(),
"status": "preparing", "workspace_id": state["workspace_id"], "names": names,
"project_id": config.project_id, "documents_sha256": content_hash,
"planner": {"deployment": config.planner, "model": model},
"search_api_version": SEARCH_API,
"payload_sha256": payload_hash,
"retrieval": {
"reasoning": "low", "output": "extractiveData",
"embedding_model": "text-embedding-3-large", "embedding_deployment": config.embedding,
"dimensions": DIMENSIONS, "index_algorithm": "hnsw", "metric": "cosine",
"query_modes": ["vector", "hybrid", "agentic-iq-mcp"],
"direct_probe_is_agent_evidence": False,
},
}
save_json(setup_path, setup)
save_json(artifacts_dir() / "knowledge/config-snapshot.json", payloads)
search = JsonHttp(credential, scope=SEARCH_SCOPE, allowed_origin=config.search_endpoint)
index_url = search_url(config, "indexes", names["index"])
_ensure_created(config, search, "search_index", names["index"], index_url, payloads["index"])
embedding = embed(
config, [f"{doc['title']}\n{doc['content']}" for doc in documents],
artifacts_dir() / "knowledge/document-embeddings.json",
)
vectors = validate_vectors(embedding["response"], len(documents))
uploaded = [{
"@search.action": "mergeOrUpload",
"id": doc["id"], "title": f"{doc['id']} | {doc['title']}",
"content": f"[{doc['id']}]\n{doc['content']}",
"effective_date": doc["effective_date"],
"content_vector": vector,
} for doc, vector in zip(documents, vectors, strict=True)]
upload_path = artifacts_dir() / "knowledge/upload-response.json"
upload_contract = {"documents_sha256": content_hash, "payload_sha256": payload_hash,
"embedding_sha256": sha256_file(artifacts_dir() / "knowledge/document-embeddings.json")}
if upload_path.exists():
upload_record = read_json(upload_path)
if upload_record.get("contract") != upload_contract or upload_record.get("status") != "completed":
raise LabError(text(
"업로드 계약 변경 또는 결과 불명입니다. 업로드를 반복하지 않고 실제 인덱스 상태를 확인해야 합니다.",
"The upload contract changed or its outcome is unknown. Do not repeat the upload; check the actual index state.",
))
upload_body = upload_record["response"]
else:
write_once_json(upload_path, {"status": "submitting", "contract": upload_contract})
upload = search.request(
"POST", search_url(config, "indexes", names["index"], "/docs/index"), {"value": uploaded},
)
upload_body = upload.body
save_json(upload_path, {"status": "response_received", "contract": upload_contract, "response": upload_body})
statuses = upload_body.get("value", [])
if (
len(statuses) != len(uploaded)
or {item.get("key") for item in statuses} != {doc["id"] for doc in documents}
or any(item.get("status") is not True for item in statuses)
):
save_json(artifacts_dir() / "knowledge/upload-error.json", upload_body)
raise LabError(text(
"일부 문서 업로드가 실패했습니다. knowledge/upload-error.json을 확인해야 합니다.",
"Some document uploads failed. Check knowledge/upload-error.json.",
))
save_json(upload_path, {"status": "completed", "contract": upload_contract, "response": upload_body})
_ensure_created(
config, search, "knowledge_source", names["source"],
search_url(config, "knowledgesources", names["source"]), payloads["source"],
)
_ensure_created(
config, search, "knowledge_base", names["base"],
search_url(config, "knowledgebases", names["base"]), payloads["base"],
)
connection_url = (
f"https://management.azure.com{config.project_id}/connections/"
f"{names['connection']}?api-version={CONNECTION_API}"
)
arm = JsonHttp(credential, scope=ARM_SCOPE, allowed_origin="https://management.azure.com")
_ensure_created(config, arm, "project_connection", names["connection"], connection_url, payloads["connection"])
setup["status"] = "created_not_retrieval_tested"
setup["mcp_endpoint"] = payloads["connection"]["properties"]["target"]
setup["connection_name"] = names["connection"]
setup["uploaded_documents"] = len(uploaded)
setup["embedding_usage"] = embedding["response"].get("usage")
save_json(setup_path, setup)
save_json(artifacts_dir() / "knowledge/documents-snapshot.json", documents)
return setup
embedding 호출도 감춰진 로컬 점수 계산이 아닙니다. 다음 함수가 Azure OpenAI의 embedding API를 호출하고 원본 응답·입력 해시·모델·토큰 사용량을 보관합니다.
실제 구현 코드 · 읽기 전용 · lab/embeddings.py:embed
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 51–122 · GitHub에서 보기
def embed(config: Config, texts: list[str], cache_path: Path) -> dict:
if not config.embedding or not texts or any(not isinstance(text, str) or not text.strip() for text in texts):
raise LabError(localized_text(
"EMBEDDING_DEPLOYMENT와 비어 있지 않은 embedding 입력이 필요합니다.",
"EMBEDDING_DEPLOYMENT and non-empty embedding input are required.",
))
deployment = model_snapshot(config, config.embedding)
if deployment["model"].get("name") != "text-embedding-3-large":
raise LabError(localized_text(
"이 인덱스의 동결 계약은 text-embedding-3-large/3072입니다. 자동 대체하지 않습니다.",
"This index's frozen contract is text-embedding-3-large/3072. It is never replaced automatically.",
))
contract = {
"endpoint": config.openai_endpoint, "deployment": deployment,
"dimensions": DIMENSIONS,
"input_sha256": hashlib.sha256(json.dumps(texts, ensure_ascii=False).encode()).hexdigest(),
"count": len(texts),
}
if cache_path.exists():
previous = read_json(cache_path)
if previous.get("contract") != contract:
raise LabError(localized_text(
"기존 embedding 실행과 입력/모델/범위가 다릅니다. 기존 기록을 보존해야 합니다.",
"The input, model or scope differs from the earlier embedding run. Preserve the existing record.",
))
if previous.get("status") != "completed":
raise LabError(localized_text(
"이 embedding 요청은 실패 또는 결과 불명입니다. 원격 요청을 확인하기 전 재호출하지 않습니다.",
"This embedding request failed or its outcome is unknown. Do not call it again before checking the remote request.",
))
validate_vectors(previous["response"], len(texts))
return previous
from azure.identity import get_bearer_token_provider
from openai import APIError, OpenAI
from azure.core.exceptions import AzureError
from lab.auth import credential_for
record = {"kind": "LIVE_AZURE_EMBEDDING", "contract": contract, "status": "submitting",
"code": code_provenance(),
"started_at": datetime.now(timezone.utc).isoformat(), "cost": {"status": "NOT_OBSERVED"}}
cache_path.parent.mkdir(parents=True, exist_ok=True)
try:
with cache_path.open("x", encoding="utf-8") as stream:
json.dump(record, stream, ensure_ascii=False, indent=2)
except FileExistsError as exc:
raise LabError(localized_text(
"동시 embedding 실행을 차단했습니다. 기존 기록을 확인해야 합니다.",
"A concurrent embedding run was blocked. Check the existing record.",
)) from exc
started = time.perf_counter()
try:
with credential_for(config) as credential:
with OpenAI(
base_url=f"{config.openai_endpoint}/openai/v1/",
api_key=get_bearer_token_provider(credential, "https://ai.azure.com/.default"),
max_retries=0, timeout=120,
) as client:
response = client.embeddings.create(
model=config.embedding, input=texts, dimensions=DIMENSIONS, encoding_format="float",
)
record["response"] = response.model_dump(mode="json")
record["request_id"] = getattr(response, "_request_id", None)
save_json(cache_path, record)
validate_vectors(record["response"], len(texts))
except (APIError, AzureError, LabError) as exc:
record.update(status="failed_or_unknown", error={"type": type(exc).__name__, "message": str(exc)})
save_json(cache_path, record)
raise
record.update(status="completed", latency_ms=(time.perf_counter() - started) * 1000,
completed_at=datetime.now(timezone.utc).isoformat())
save_json(cache_path, record)
return record
3. 질문으로 정책이 검색되는지 확인합니다
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env iq probe --confirm
status: retrieval_verified와 비어 있지 않은 출처를 확인합니다. created_not_retrieval_tested는 생성만 성공했다는 뜻입니다. 오류가 발생하면 기록을 지우지 않고 검색 오류 해결을 확인합니다.
아래 POST .../retrieve의 질문·출처 요청과 실제 응답 검사를 읽습니다. 직접 검색 성공은 로그인한 사용자 경로의 근거이며, 프로젝트 관리 ID의 Agent 도구 호출 근거와 같지 않습니다.
실제 구현 코드 · 읽기 전용 · lab/knowledge.py:probe_knowledge
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 294–343 · GitHub에서 보기
def probe_knowledge(config: Config, query: str) -> dict:
setup = load_knowledge(config)
if not query.strip():
raise LabError(text("지식 검색 질문이 비어 있습니다.", "The knowledge retrieval question is empty."))
names = setup["names"]
request = {
"messages": [{"role": "user", "content": [{"type": "text", "text": query}]}],
"outputMode": "extractiveData",
"maxOutputSize": 6000,
"knowledgeSourceParams": [{
"knowledgeSourceName": names["source"], "kind": "searchIndex",
"includeReferences": True, "includeReferenceSourceData": True,
"resultsProcessing": "rerank", "failOnError": True,
}],
}
key = hashlib.sha256(query.encode("utf-8")).hexdigest()[:16]
directory = artifacts_dir() / "knowledge/probes" / key
intent_path, response_path = directory / "iq-request.json", directory / "iq-response.json"
if response_path.exists():
if read_json(intent_path) != request:
raise LabError(text(
"기존 IQ 요청 계약이 다릅니다. 원본 기록을 보존해야 합니다.",
"The earlier IQ request contract differs. Preserve the original record.",
))
body = read_json(response_path)
else:
if intent_path.exists():
raise LabError(text(
"이 IQ probe는 결과 불명입니다. planner를 다시 유료 호출하지 않고 원격 요청을 확인해야 합니다.",
"This IQ probe's outcome is unknown. Do not call the planner again at a cost; check the remote request.",
))
write_once_json(intent_path, request)
with credential_for(config) as credential:
http = JsonHttp(credential, scope=SEARCH_SCOPE, allowed_origin=config.search_endpoint)
result = http.request(
"POST", search_url(config, "knowledgebases", names["base"], "/retrieve"), request,
)
body = result.body
save_json(response_path, body)
if not (artifacts_dir() / "knowledge/retrieve-response.json").exists():
save_json(artifacts_dir() / "knowledge/retrieve-response.json", body)
if not body.get("response") or not body.get("references") or not body.get("activity"):
raise LabError(text(
"검색 응답·출처·활동 중 일부가 없습니다. 보존한 iq-response.json을 확인해야 하며 완료 처리하지 않습니다.",
"The search response, references or activity is missing. Check the preserved iq-response.json; this is not recorded as complete.",
))
setup["status"] = "retrieval_verified"
setup["probe_scope"] = "Human CLI identity; deployed-agent managed identity must still be verified by an agent call."
save_json(artifacts_dir() / "knowledge/setup.json", setup)
return {"status": setup["status"], "references": body["references"], "activity": body["activity"], "scope": setup["probe_scope"]}
포털에서도 Build → Knowledge → Knowledge bases를 열고 Connection과 생성된 지식 베이스·Knowledge sources를 확인합니다. 목록의 지식 베이스 한 개가 정책 문서 한 개를 뜻하지는 않습니다.
iq probe로 확인합니다. 무료 검색 배너는 전체 실습이 무료라는 뜻이 아니며, 준비 확인을 위해 요금제 변경 버튼을 누르지 않습니다. 원본 크기로 보기같은 조건으로 평가할 v1을 만듭니다
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env native-agent --version 1 --confirm
이 명령은 prompts/baseline.txt의 지침과 방금 확인한 정책 도구로 lab-ko-iq 버전 1을 만듭니다. 출력의 agent_name, version, receipt를 확인합니다. Receipt는 실행 결과를 저장한 기록 파일이라 Agent 이름과 버전을 나중에도 읽을 수 있습니다. 같은 소유 환경·동일 구성이면 기존 v1을 재사용하며, 다른 Agent를 인수하거나 v3를 만들지 않습니다.
선택 · 내부 동작: Agent 구성과 고정 버전
코드 ↔ 포털 · Agent의 전체 구성을 확인합니다
실행 위치: 위의 v1 생성 명령을 실행한 뒤 아래의 Playground에서 버전·설정·실제 응답을 확인합니다. 포털에서 다시 생성하거나 새 버전으로 저장하지 않습니다.
| 실제 코드·설정 | 포털 조작·확인 위치 |
|---|---|
definition["model"] = config.model |
Build → Agents → 실제 Agent → Version 1 → Model에서 같은 배포를 확인합니다. |
definition["instructions"] = prompt.read_text(...) |
같은 화면의 Instructions를 원본 지침과 대조합니다. 확인 중 Save/새 버전을 누르지 않습니다. |
knowledge_tool()의 MCP endpoint·allowed_tools·project_connection_id |
Knowledge/Tools에서 정책 연결을 확인합니다. 허용 도구는 knowledge_base_retrieve이며 도구가 실제 실행됐는지는 응답의 실행 상세에서 확인합니다. |
native_response_format()의 json_schema, strict |
생성된 Agent의 구조화 출력 설정입니다. UI가 전체 스키마를 보여주지 않으면 agents/native-v1.json의 agent.definition.text를 읽습니다. 자연어 지침으로 같은 설정을 추측해 다시 만들지 않습니다. |
project.agents.create_version(...), draft=False |
생성 후 포털의 Version에서 실제 1을 확인합니다. 새 버전 생성은 운영용 게시가 아닙니다. |
먼저 연결한 도구와 Agent 정의를 읽고, 다음 함수의 실제 project.agents.create_version 호출까지 확인합니다. 두 함수 사이의 고정 버전·소유권·같은 구성 재사용 검사도 실제 동작입니다.
실제 구현 코드 · 읽기 전용 · lab/knowledge.py:knowledge_tool
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 400–414 · GitHub에서 보기
def knowledge_tool(config: Config) -> MCPTool:
setup = load_knowledge(config)
if setup["status"] != "retrieval_verified":
raise LabError(text(
"먼저 python -m lab iq probe --confirm 으로 실제 검색을 확인해야 합니다.",
"First verify real retrieval with python -m lab iq probe --confirm.",
))
return MCPTool(
server_label="contoso-knowledge",
server_url=setup["mcp_endpoint"],
server_description="Read-only Contoso Atlas Cloud policies. Cite stable ATLAS document IDs shown in the returned title/content, not numeric retrieval reference IDs.",
allowed_tools=["knowledge_base_retrieve"],
require_approval="never",
project_connection_id=setup["connection_name"],
)
실제 구현 코드 · 읽기 전용 · lab/agents.py:create_native_agent
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 124–165 · GitHub에서 보기
def create_native_agent(config: Config, version: str, prompt: Path) -> dict:
"""Connect the prepared policy tool to a fixed, strict-output workshop Agent."""
from lab.knowledge import knowledge_tool
if version not in {"1", "2"}:
raise LabError("The native workshop Agent permits only versions 1 and 2.")
if not prompt.is_file() or not prompt.read_text(encoding="utf-8").strip():
raise LabError(f"A nonempty instruction file is required: {prompt}")
state = workspace(config)
tool = knowledge_tool(config)
snapshot = model_snapshot(config, config.model)
snapshot_path = artifacts_dir() / "agents/native-model.json"
if snapshot_path.exists():
if read_json(snapshot_path) != snapshot:
raise LabError("The Agent model deployment changed after setup; preserve the original comparison.")
elif version == "2":
raise LabError("Create and verify native Agent v1 before preparing v2.")
else:
write_once_json(snapshot_path, snapshot)
definition = {
"kind": "prompt",
"model": config.model,
"instructions": prompt.read_text(encoding="utf-8"),
"tools": [tool.as_dict()],
"text": native_response_format(),
}
receipt = artifacts_dir() / "agents" / f"native-v{version}.json"
with credential_for(config) as credential:
with AIProjectClient(endpoint=config.project_endpoint, credential=credential, retry_total=0) as project:
result = ensure_fixed_release(
project, project_endpoint=config.project_endpoint, agent_name=config.agent_name("iq"),
version=version, definition=definition, receipt=receipt,
workspace_id=state["workspace_id"],
)
created = {"kind": "agent_version", "name": config.agent_name("iq"), "version": version}
if created not in workspace(config)["created"]:
record_created(config, created)
return {
"status": result["status"], "agent_name": config.agent_name("iq"), "version": version,
"receipt": str(receipt), "model_snapshot": snapshot,
"evaluation_status": "NOT_RUN", "production_approval": "NOT_GRANTED",
}
실제 구현 코드 · 읽기 전용 · lab/agents.py:ensure_fixed_release
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 49–121 · GitHub에서 보기
def ensure_fixed_release(project: AIProjectClient, *, project_endpoint: str, agent_name: str,
version: str, definition: dict, receipt: Path,
workspace_id: str | None = None) -> dict:
if version not in {"1", "2"}:
raise LabError("This workshop permits only released v1 and v2; use drafts for experiments.")
if definition.get("kind") != "prompt" or not isinstance(definition.get("instructions"), str) or not definition["instructions"].strip():
raise LabError("A nonempty prompt-agent definition is required.")
expected_definition = _definition_for_comparison(definition)
intent = {
"project_endpoint": project_endpoint, "agent_name": agent_name, "version": version,
"definition_sha256": hashlib.sha256(
json.dumps(definition, sort_keys=True, separators=(",", ":")).encode()
).hexdigest(),
}
if workspace_id is not None:
intent["workspace_id"] = workspace_id
previous = read_json(receipt) if receipt.exists() else None
if previous is not None and previous["intent"] != intent:
raise LabError("The fixed-version receipt belongs to a different definition or environment.")
try:
existing = project.agents.get_version(agent_name=agent_name, agent_version=version).as_dict()
except ResourceNotFoundError:
existing = None
if existing is not None:
if workspace_id is not None and (existing.get("metadata") or {}).get("workspace") != workspace_id:
raise LabError("The existing Agent version belongs to a different workspace; no changes were made.")
if _definition_for_comparison(existing["definition"]) != expected_definition:
raise LabError("The released version is immutable and differs from this source; no new version was created.")
record = {"intent": intent, "status": "verified", "agent": existing}
if previous is None:
write_once_json(receipt, record)
else:
save_json(receipt, record)
return record
if previous is not None:
raise LabError("The creation outcome is unknown; inspect the receipt and remote state, do not resubmit.")
try:
releases = [
item.as_dict() for item in project.agents.list_versions(agent_name=agent_name)
if not item.draft and not item.version.startswith("draft-")
]
except ResourceNotFoundError:
releases = []
if {item["version"] for item in releases} != ({"1"} if version == "2" else set()):
raise LabError("Unexpected release history; refusing to create v3 or reset existing versions.")
if version == "2":
if workspace_id is not None and (releases[0].get("metadata") or {}).get("workspace") != workspace_id:
raise LabError("The baseline Agent belongs to a different workspace; no changes were made.")
baseline = _definition_for_comparison(releases[0]["definition"])
if {key: value for key, value in baseline.items() if key != "instructions"} != {
key: value for key, value in expected_definition.items() if key != "instructions"
}:
raise LabError("V1 and v2 must keep the same model, tools and generation settings.")
if baseline["instructions"] == definition["instructions"]:
raise LabError("Identical instructions are not a new improvement candidate.")
record = {"intent": intent, "status": "creating", "agent": None}
write_once_json(receipt, record)
metadata = {"lab": "foundry-evaluation-optimizer", "release_policy": "fixed-v1-v2"}
if workspace_id is not None:
metadata["workspace"] = workspace_id
created = project.agents.create_version(
agent_name=agent_name, definition=PromptAgentDefinition(definition), draft=False,
metadata=metadata,
description=f"Fixed workshop v{version}; separate managed evaluation is required.",
).as_dict()
record.update(status="created", agent=created)
save_json(receipt, record)
if (
created["version"] != version or _definition_for_comparison(created["definition"]) != expected_definition
or (workspace_id is not None and (created.get("metadata") or {}).get("workspace") != workspace_id)
):
raise LabError("The created version differs from the requested fixed version; receipt preserved.")
return record
Microsoft Foundry에서 v1의 응답과 도구 호출을 확인합니다
포털에서 Build → Agents → lab-ko-iq → 버전 1을 엽니다. 표시 이름은 명령 출력이 기준입니다. 이 단계에서는 Model·Instructions·Knowledge를 편집하거나 새 버전으로 저장하지 않습니다. Test/Playground에서 다음 질문을 한 번 보냅니다.
2026년 9월에 처음 월 구독을 결제했습니다. 환불 신청 조건이 궁금합니다.
답변은 일반 채팅 문장 대신 네 항목의 JSON으로 표시됩니다. 화면에 중괄호와 따옴표가 보여도 오류가 아닙니다.
| 응답 항목 | 읽는 방법 |
|---|---|
answer |
사용자에게 전달할 한국어 답변입니다. |
citations |
답변 근거인 ATLAS-* 정책 ID 목록입니다. |
route |
answer 답변, clarify 추가 질문, escalate 사람에게 인계, refuse 거절 중 하나입니다. |
needs_human |
사람 인계가 필요한지 나타내는 true/false입니다. route가 escalate일 때만 true입니다. |
실행 상세에서 knowledge_base_retrieve의 실제 호출·응답을 확인하고 정책 ID와 답변 근거를 대조합니다. Agent의 관리 ID는 Microsoft Azure 서비스가 사용하는 신원으로, 로그인한 본인의 신원과 다릅니다. 직접 검색이 성공해도 Agent의 검색 권한은 별도로 확인합니다.
완료 기준: 자신의 v1이 실제 질문에 응답하고 정책 도구 호출이 확인됩니다. JSON 형식이나 연결 성공만으로 품질이 검증됐다고 표시하지 않습니다. 다른 구성 요소를 수정하는 실습은 추가하지 않으며 다음 단계부터 관리형 평가에 집중합니다.
04. 데이터셋을 등록합니다
이 단계에서 하는 일: 같은 질문으로 비교할 수 있도록 한국어 dev12 파일을 한 번 등록합니다.
중요한 이유: 질문이나 참고 답변이 달라지면 지침 변경의 효과를 분리할 수 없습니다. 참고 답변을 Agent에게 미리 주어도 공정한 비교가 아닙니다.
진행 방법·위치: 터미널에서 행 수·해시를 확인하고 Microsoft Foundry 평가 마법사에서 원본 파일을 등록하거나 같은 버전을 재사용합니다.
실행 순서: 터미널 · 원본 확인 → Codespaces이면 파일 다운로드 → Microsoft Foundry · 등록·선택 → 05 · 같은 화면에서 기준 설정.
같은 평가 생성 과정의 1/3입니다. 04 데이터 선택 → 05 채점 기준 설정 → 06 제출 순서입니다. 이 단계에서는 아직 Submit을 누르지 않습니다.
데이터셋은 평가에 사용할 질문 묶음입니다. 이 실습에서는 data/optimizer/dev.jsonl의 JSONL 12행을 사용하며, 이를 줄여 dev12라고 부릅니다. 한 줄이 질문 한 건인 JSON 형식입니다. 편집하거나 Excel·CSV·JSON 배열로 변환하지 않습니다.
| 열 | 형식 | 용도 |
|---|---|---|
query |
문자열 | Agent에 보내는 유일한 입력 |
context |
문자열 | 지원 평가기와 사례 검토용 정책 참고 자료 |
ground_truth |
JSON 문자열 | 구조화 참고 답변이며 생성 프롬프트가 아닙니다. |
질문만 Agent에 전달하고, context·ground_truth는 지원 평가기와 사람의 사례 검토에 사용합니다. 제공된 나머지 데이터 파일은 이번 필수 실습에서 사용하지 않습니다.
- 질문 · query데이터 한 행씩 전달
- Agent모델 + 지침 + 정책 검색
- 실제 답변answer 등 네 항목
- 평가기 + Judge점수와 채점 이유
터미널에서 원본의 행 수와 해시를 확인합니다
터미널에서 실행 · 공통 터미널
python -c "import hashlib,pathlib; p=pathlib.Path('data/optimizer/dev.jsonl'); print('rows =',len(p.read_text(encoding='utf-8').splitlines())); print('sha256 =',hashlib.sha256(p.read_bytes()).hexdigest())"
rows = 12를 확인하고 SHA-256 값을 눈여겨봅니다. SHA-256(해시)은 파일 내용의 지문으로, 이후에도 같은 파일인지 확인하는 값입니다. 외우거나 직접 입력할 필요 없이, 이 명령을 다시 실행하면 언제든 같은 값을 확인할 수 있습니다.
선택 · 내부 동작: 로컬 파일 확인과 포털 등록의 차이
코드 ↔ 포털 · 파일 확인과 등록을 구분합니다
실행 위치: 위의 터미널 명령은 행 수·해시만 확인합니다. 실제 업로드·선택은 아래의 Microsoft Foundry 등록 절차에서 수행합니다.
| 명령의 실제 코드 | 실제 동작 | 포털 조작·확인 위치 |
|---|---|---|
pathlib.Path('data/optimizer/dev.jsonl') |
내 PC에서 업로드할 원본 파일을 선택합니다. | 아래 Upload new dataset → Browse에서 정확히 같은 파일을 고릅니다. |
p.read_text(...).splitlines(), len(...) |
로컬 JSONL의 전체 행 수를 셉니다. | 업로드 Preview는 일부 행만 보여줄 수 있습니다. 등록 파일과 실행 범위는 원본 12행이어야 합니다. |
hashlib.sha256(p.read_bytes()).hexdigest() |
파일 바이트의 지문을 계산합니다. | 포털에 SHA-256 확인란이 없으면 필요할 때 위 명령을 다시 실행해 비교합니다. 화면에 없는 해시를 확인했다고 표시하지 않습니다. |
| 이후 포털의 Upload/Existing dataset | 실제 데이터셋 등록·선택은 포털에서 수행합니다. | 자신의 프로젝트에 lab-ko-dev12 버전 1을 한 번 등록합니다. 위 Python 명령은 업로드나 평가 제출을 하지 않습니다. |
Codespaces 파일을 포털에서 업로드할 수 있게 준비합니다
Codespaces 파일은 원격 개발환경에 있고, 포털의 Browse는 내 PC의 파일을 선택합니다. Codespaces의 VS Code Explorer에서 data/optimizer/dev.jsonl을 찾아 우클릭 → Download로 내 PC에 받습니다. 파일을 열어 다시 저장하거나 CSV로 바꾸지 않습니다. 같은 이름의 파일이 있다면 방금 받은 한국어 파일을 구분합니다. 내 PC에서 실습 중이면 다운로드 없이 기존 파일을 사용합니다.
Microsoft Foundry에서 데이터셋을 등록하거나 선택합니다
자신의 프로젝트에서 다음 순서로 선택합니다. 환경 이름을 바꾸었다면 예시 lab-ko-iq 대신 03의 명령이 출력한 실제 Agent 이름을 사용합니다.
- Build → Evaluations → Create → Create new evaluation을 엽니다. 메뉴가 단수 Evaluation으로 보이면 같은 평가 메뉴입니다.
- 대상 유형 Agent에서
lab-ko-iq, 버전 1을 선택합니다. Pin currently latest는 최신 버전이 실제로 1일 때만 사용합니다. 버전을 바꾼 뒤 대상 체크가 풀렸다면 다시 체크하고 대상 한 개인지 확인합니다. - Individual turns(질문별 응답 평가), One time(일회성 실행)을 선택합니다. 새 질문을 생성하는 Synthetic data는 선택하지 않습니다.
- Upload new dataset → Browse에서 확인한
dev.jsonl을 고릅니다. Codespaces 사용자는 방금 내 PC에 다운로드한 파일, 로컬 사용자는 실습 폴더의data/optimizer/dev.jsonl입니다. 이름은lab-ko-dev12, 첫 버전은1로 지정하고 등록 완료를 기다립니다. 이미 등록되어 있으면 Existing dataset에서 같은 이름·버전을 선택합니다. query,context,ground_truth열을 확인합니다. 5행 미리 보기는 전체 건수가 아닙니다. 원본은 12행이며, 데이터셋 이름·버전을 확인한 뒤 같은 생성 화면에서 05로 이어갑니다.
완료 기준: 명시적 v1과 변경 없는 12행 데이터셋을 선택하고 건수·버전·해시를 확인했습니다. 데이터 계약.
05. 평가 기준을 선택합니다
이 단계에서 하는 일: Relevance·TaskAdherence와 실제 Judge 배포를 선택합니다.
중요한 이유: 점수의 척도·통과 기준·입력 매핑을 먼저 정해야 결과를 본 뒤 기준을 바꾸는 일을 피할 수 있습니다. 두 평가기는 척도가 서로 다릅니다.
진행 방법·위치: Microsoft Foundry의 Criteria에서 평가기별 설정을 열고 임계값과 Judge를 확인합니다. Agent에는 query만 전달합니다.
실행 순서: Microsoft Foundry · 입력·평가기 설정 → 두 기준·Judge 확인 → 06 · 검토·제출.
같은 평가 생성 과정의 2/3입니다. 04에서 열어 둔 화면을 이어서 사용합니다. 아직 새 평가를 만들거나 Submit을 누르지 않습니다.
평가기(Evaluator)는 채점 기준, Judge는 그 기준으로 채점하는 AI 모델입니다. 관리형 평가는 내 PC가 아니라 Microsoft Foundry 서비스가 실행·결과를 관리한다는 뜻입니다.
같은 생성 화면에서 입력과 채점 기준을 설정합니다
- Configure agents에서 지침을 덮어쓰는 custom prompt override는 비워 둡니다. 사용자 입력은
{{item.query}}만 사용합니다. 이는 각 행의 질문을 넣는 템플릿이므로 중괄호까지 그대로 두고, 자신의 질문이나 참고 답변으로 바꾸지 않습니다. - 필드 매핑 화면이 나타나면 입력
query를 데이터의query열과 연결합니다. 매핑은 입력 칸과 데이터 열의 짝을 정하는 설정입니다.context·ground_truth를 Agent 입력에 붙이지 않습니다. - Criteria → Add evaluators에서 아래 두 평가기만 남기고 다른 기본 선택은 해제합니다. TaskAdherence는 Task Adherence로 표시될 수도 있습니다.
- 각 평가기의 설정을 열어 아래 기준을 적용하고 Apply를 누릅니다. Judge model에는 자신의
.lab/lab-ko/.env에서JUDGE_DEPLOYMENT에 해당하는 배포 이름을 선택합니다.
| 평가기 | 의미 | 설정 |
|---|---|---|
| Relevance | 질문에 관련된 답변인지, 1–5점 | Threshold 4: 4·5점은 통과, 1–3점은 미통과입니다. |
| TaskAdherence | 과제 지시를 따르는지, 이진 0/1 Pass/Fail | 통과 1, 미통과 0입니다. 임계값 4가 아닙니다. |
응답(response)의 서비스 자동 매핑은 그대로 유지합니다. 응답 칸이 Unassigned이거나 자신의 Judge가 목록에 없다면 매핑 점검과 평가 문제 해결을 따라 Agent·데이터 열·배포를 직접 대조합니다. 해결 전에는 임의 값을 넣거나 제출하지 않습니다.
선택 · 내부 동작: 평가기·Judge·응답 매핑
코드 ↔ 포털 · 채점 설정을 연결합니다
실행 위치: 위의 Microsoft Foundry 설정 절차를 수행합니다. 이 단계에는 별도 터미널 명령이 없습니다. 아래 함수는 09의 재평가 helper에서 자동 호출되는 검증 코드입니다.
| 코드의 원격 평가 필드 | 포털에서 정하는 값 |
|---|---|
evaluator_name: builtin.relevance, threshold: 4 |
Criteria → Relevance → Threshold 4 → Apply |
evaluator_name: builtin.task_adherence, threshold: 1 |
Criteria → TaskAdherence → 이진 통과 1 → Apply |
initialization_parameters.deployment_name |
Judge model에 자신의 JUDGE_DEPLOYMENT 배포를 선택합니다. |
data_mapping.query = {{item.query}} |
Configure agents → User input에서 query만 전달합니다. |
Relevance의 {{sample.output_text}}, TaskAdherence의 {{sample.output_items}} |
서비스 생성 응답 매핑입니다. 두 번째 값에는 지침·도구 호출 등 대화 항목이 포함됩니다. JSONL에 같은 이름의 열을 만들지 않습니다. |
이 단계는 포털 설정이며 아래 함수를 실행하지 않습니다. 원문은 09의 helper가 이미 저장된 원격 정의를 검증하는 코드입니다. UI에서 정한 값이 어떻게 testing_criteria·Judge·매핑으로 검사되는지 보여주며, 로컬 Judge나 별도 평가 생성 코드가 아닙니다.
실제 구현 코드 · 읽기 전용 · scripts/add_foundry_eval_run.py:evaluation_contract
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 42–83 · GitHub에서 보기
def evaluation_contract(evaluation: dict) -> dict:
expected = {
"Relevance": ("builtin.relevance", 4, "{{sample.output_text}}"),
"TaskAdherence": ("builtin.task_adherence", 1, "{{sample.output_items}}"),
}
criteria = evaluation.get("testing_criteria")
if not isinstance(criteria, list) or len(criteria) != len(expected):
raise ValueError("Use exactly the workshop's Relevance and TaskAdherence evaluators.")
judges = set()
seen = set()
for criterion in criteria:
if not isinstance(criterion, dict):
raise ValueError("Each Foundry evaluation criterion must be a JSON object.")
name = criterion.get("name")
if not isinstance(name, str) or name not in expected or name in seen:
raise ValueError("Evaluator names must be unique Relevance and TaskAdherence entries.")
seen.add(name)
evaluator, threshold, response = expected[name]
parameters = criterion.get("initialization_parameters") or {}
mapping = criterion.get("data_mapping") or {}
if (
criterion.get("type") != "azure_ai_evaluator"
or criterion.get("evaluator_name") != evaluator
or not isinstance(parameters, dict) or not isinstance(mapping, dict)
or type(parameters.get("threshold")) not in (int, float)
or parameters["threshold"] != threshold
or mapping.get("query") != "{{item.query}}"
or mapping.get("response") != response
):
raise ValueError(f"{name} has a different evaluator, threshold, or response mapping.")
judge = parameters.get("deployment_name")
if not isinstance(judge, str) or not judge.strip():
raise ValueError(f"{name} has no explicit Judge deployment in the remote definition.")
judges.add(judge)
if len(judges) != 1:
raise ValueError("Both workshop evaluators must use the same explicit Judge deployment.")
return {
"judge_deployment": judges.pop(),
"thresholds": {"Relevance": 4, "TaskAdherence": 1},
"source": "existing_remote_evaluation_definition_not_local_environment_defaults",
"testing_criteria_sha256": digest({"criteria": criteria}),
}
| 역할 | 모델·버전 | 배포 |
|---|---|---|
| Agent | gpt-6-sol / 2026-09-22 | 자신의 MODEL_DEPLOYMENT 값입니다. |
| 평가 Judge | gpt-6-luna / 2026-09-22 | 자신의 JUDGE_DEPLOYMENT 값입니다. |
| Optimizer 생성 | gpt-5.5 / 2026-04-24 | 자신의 OPTIMIZER_DEPLOYMENT 값입니다. |
Agent·Judge·Optimizer는 역할별 지원 모델이 다릅니다. Optimizer 지원 목록을 확인하고 각 역할에 준비한 배포를 선택합니다.
JUDGE_DEPLOYMENT에 해당하는 배포를 선택합니다. 원본 크기로 보기완료 기준: 아직 제출하지 않은 같은 생성 화면에서 두 평가기 척도·임계값·매핑·실제 Judge를 확인하고 기록했습니다. 원격 평가 정의는 06에서 제출한 뒤 생성됩니다. 이후 재평가는 그 저장된 정의를 사용합니다.
다음: 06. Microsoft Foundry 평가 실행 →
06. Microsoft Foundry 평가를 실행합니다
이 단계에서 하는 일: 고정된 v1의 실제 답변을 생성하고 관리형 평가 점수·이유를 수집합니다.
중요한 이유: 이후 후보와 비교할 출발점이 필요합니다. Completed는 처리가 끝났다는 뜻이지 모든 답변이 정확하다는 뜻은 아닙니다.
진행 방법·위치: Microsoft Foundry에서 검토 후 한 번 제출하고 전체 12건을 확인합니다. 터미널의 읽기 전용 조회로 실제 evaluation ID와 run ID를 가져옵니다.
실행 순서: Microsoft Foundry · 검토·제출 → 12건·완료 상태 확인 → 터미널 · ID 조회.
같은 평가 생성 과정의 3/3입니다. 04–05에서 정한 설정을 검토하고 여기서 한 번만 Submit합니다. 제출하면 실제 모델 호출 비용이 발생합니다.
Evaluation은 저장된 평가 설정, run은 그 설정으로 수행한 한 번의 실행입니다. v1의 첫 실행을 기준선(baseline)으로 삼습니다.
Microsoft Foundry에서 검토하고 한 번 제출합니다
- Review에서 v1 + 원본 dev12 + query 전용 입력 + 두 평가기 + 자신의 Luna Judge를 확인합니다.
- 평가 이름은
lab-ko-learning-loop, run 이름을 지정할 수 있으면baseline-v1로 입력합니다. 다른 이름을 선택했다면 그 이름을 기억해 두고 이후 조회에도 같은 이름을 사용합니다. 자신의 기준선 run이 이미 완료됐다면 다시 제출하지 않고 그 결과를 엽니다. - 아직 실행하지 않은 승인된 작업만 Submit으로 한 번 제출합니다. 단추를 누른 직후가 아니라 아래의 Completed와 결과 12건을 확인해야 실행 완료입니다.
같은 run의 완료 상태와 12건을 확인합니다
Evaluations → 방금 지정한 평가 이름 → Evaluation runs → 기준선 run을 엽니다.
| 화면 상태 | 다음 행동 |
|---|---|
| Running / In Progress | 같은 run을 새로고침하며 기다립니다. 새로 제출하지 않습니다. |
| Completed | 전체 12건과 오류 건수를 확인한 뒤 아래 ID 조회까지 마치고 07로 진행합니다. 모든 답변이 정답이라는 뜻은 아닙니다. |
| Failed / Partial 또는 결과 누락 | 오류와 run ID를 보존하고 평가 문제 해결을 확인합니다. 완료로 표시하지 않습니다. |
30분 또는 승인한 대기 한도 중 짧은 시간이 지나도 끝나지 않으면 현재 상태·run ID·오류를 확인한 뒤 평가 재개 절차를 따릅니다. 이는 상태를 점검할 시점이지 서비스의 완료 보장 시간이 아닙니다. 기다림을 중단해도 원격 작업은 자동 취소되지 않습니다.
터미널에서 실제 evaluation ID와 run ID를 조회합니다
다음 읽기 전용 명령으로 이름이 같은 평가의 실제 ID·run 목록을 확인합니다. 이름이 다르면 --name을 자신이 입력한 정확한 이름으로 바꿉니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env native-evals --name lab-ko-learning-loop
같은 이름의 평가가 여러 개면 포털의 생성 시각·Agent·run을 대조하여 자신의 실행을 선택합니다. 다음 두 ID는 따로 구분해 둡니다. 09에서 사용하며, 이 명령을 다시 실행하면 언제든 같은 값을 읽을 수 있습니다. 이 명령은 새 평가를 제출하지 않습니다.
| 값 | 선택 기준 | 09에서 사용할 자리 |
|---|---|---|
evaluation_id · eval_... |
자신이 생성한 평가입니다. | YOUR_EVALUATION_ID |
run_id · evalrun_... |
그 평가 안의 agent_version: "1", status: completed인 run입니다. |
YOUR_BASELINE_RUN_ID |
선택 · 내부 동작: 평가 제출과 ID 조회의 차이
코드 ↔ 포털 · 제출과 조회를 구분합니다
실행 위치: Microsoft Foundry에서 Submit으로 제출하고, 위의 native-evals 명령으로 ID를 조회합니다. 조회 명령은 평가를 새로 제출하지 않습니다.
| 실제 동작·코드 | 포털 조작·확인 위치 |
|---|---|
| 기준선 실행은 포털의 Submit | 04–05의 같은 생성 화면에서 Review → Submit을 한 번 누릅니다. 아래 조회 코드가 대신 제출하는 것은 아닙니다. |
client.evals.list(...), 평가 이름 비교 |
Build → Evaluations → 자신의 평가 이름과 대조합니다. 이름이 같은 다른 평가를 섞지 않습니다. |
client.evals.runs.list(eval_id=...) |
같은 평가의 Evaluation runs에서 버전·run 이름·상태를 대조합니다. |
run.id, target.version, result_counts |
실제 v1 run ID, 완료 상태, 전체 12건·오류 수를 읽습니다. 결과의 질과 실행 완료는 다른 값입니다. |
아래가 native-evals --name ...의 실제 SDK 조회 구현입니다. list만 있고 create가 없다는 점을 확인합니다.
실제 구현 코드 · 읽기 전용 · lab/managed_eval.py:list_native_evaluations
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 89–112 · GitHub에서 보기
def list_native_evaluations(config: Config, name: str) -> dict:
"""Read portal-created evaluation identities without submitting or saving runs."""
if not name.strip():
raise LabError("Specify the exact evaluation name shown in the Foundry portal.")
evaluations = []
with credential_for(config) as credential:
with AIProjectClient(endpoint=config.project_endpoint, credential=credential, retry_total=0) as project:
with project.get_openai_client(max_retries=0, timeout=60) as client:
for evaluation in client.evals.list(limit=100):
if evaluation.name != name:
continue
runs = []
for run in client.evals.runs.list(eval_id=evaluation.id, limit=100):
value = run.model_dump(mode="json", warnings=False)
target = (value.get("data_source") or {}).get("target") or {}
runs.append({
"run_id": run.id, "name": run.name, "status": run.status,
"agent_name": target.get("name"), "agent_version": target.get("version"),
"result_counts": value.get("result_counts"),
})
evaluations.append({"evaluation_id": evaluation.id, "name": evaluation.name, "runs": runs})
if not evaluations:
raise LabError("No evaluation with that exact name exists in this project; check the portal name and project.")
return {"mode": "READ_ONLY", "evaluations": evaluations}
완료 기준: 실제 Microsoft Foundry run이 Completed이고 결과 12건을 확인할 수 있습니다. result_counts의 오류·실패도 확인합니다. 실패·부분 실행을 그대로 보존하며 관리형 평가를 자체 로컬 Judge로 대신하지 않습니다.
07. 점수와 이유를 읽습니다
이 단계에서 하는 일: 점수 뒤의 실제 답변과 평가 이유를 정책 근거에 연결합니다.
중요한 이유: 평균만 보면 특정 날짜·인용·분류 오류가 가려집니다. 무엇이 잘못됐는지 설명해야 바꿀 지침도 정할 수 있습니다.
진행 방법·위치: Microsoft Foundry의 상세 지표·User view와 원본 정책을 나란히 읽은 뒤 개선 가설과 유지할 행동을 짧은 학습 노트로 정리합니다.
실행 순서: Microsoft Foundry · 답변·이유 확인 → 편집기 · 개선 가설 기록. 별도 모델 호출은 하지 않습니다.
Microsoft Foundry에서 답변·점수·정책을 대조합니다
- 06에서 완료된 기준선 run을 열고 요약의 통과·미통과·오류 건수를 확인합니다.
- Detailed metrics result에서 실패 또는 최저점 행을 선택하고
Relevance.reason,TaskAdherence.reason을 읽습니다.reason은 왜 그 점수를 줬는지 설명하는 항목입니다. - 그 행의
conversation_id → User view로 실제 질문·응답을 엽니다. 채점 이유는 앞의 상세 지표 화면에 있으므로 두 화면을 오가며 대조합니다. - 응답의 정책 ID를 정책 원문에서 찾습니다. 잘한 사례도 같은 순서로 읽습니다. 실패가 없다면 그 사실을 기록하며 실패를 만들려고 v1을 약화하지 않습니다.
Relevance 4/5를 정확도 80%로 해석하지 않습니다. TaskAdherence 1은 통과이지 낮은 5점 척도 점수가 아닙니다. 누락은 0점이나 성공 행이 아니며 범용 평가기가 모든 업무 규칙을 인증하지는 않습니다.
선택 · 내부 동작: JSON 응답 형식과 채점 결과
코드 ↔ 포털 · 응답 구조와 품질을 함께 읽습니다
실행 위치: 위의 Microsoft Foundry 결과 확인 후 아래의 학습 노트를 작성합니다. 이 단계에는 별도 터미널 명령이나 로컬 채점이 없습니다.
| 실제 설정·결과 | 포털 조작·확인 위치 |
|---|---|
answer, citations, route, needs_human |
행의 conversation_id → User view에서 네 항목의 실제 응답을 읽습니다. |
Relevance.reason, TaskAdherence.reason |
Detailed metrics result에서 채점 이유를 읽습니다. JSON 스키마에 정의된 필드가 아닙니다. |
native_response_format()의 strict: True |
Agent 생성 시 지정한 JSON 출력 제한입니다. 형식이 유효해도 정책 사실이 맞다는 뜻은 아닙니다. |
원본 스키마의 uniqueItems, allOf |
인용 중복·route/needs_human 일관성 계약입니다. 아래 변환 코드가 생성 SDK의 지원 범위와 구분하는 부분을 읽습니다. |
다음은 03에서 실제 사용하는 출력 설정 변환과 원본 응답 계약입니다. 07은 포털에서 결과를 읽는 단계이며 이 코드를 다시 실행하거나 로컬 채점 결과로 대신하지 않습니다.
실제 구현 코드 · 읽기 전용 · lab/agents.py:native_response_format
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 21–34 · GitHub에서 보기
def native_response_format() -> dict:
contract = read_json(ROOT / "schemas/response.schema.json")
schema = {
key: deepcopy(contract[key])
for key in ("type", "additionalProperties", "required", "properties")
}
# Routing conditions and citation uniqueness remain validation requirements;
# the model's structured-output subset does not support allOf/uniqueItems.
schema["properties"]["citations"].pop("uniqueItems")
schema["properties"]["route"]["type"] = "string"
return {"format": {
"type": "json_schema", "name": "contoso_support_response", "strict": True,
"schema": schema,
}}
실제 구현 코드 · 읽기 전용 · schemas/response.schema.json
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:foundry-workshop:response:v1",
"title": "Workshop agent response",
"description": "Exactly one unfenced JSON object. Citation IDs are lexical references, not proof of semantic groundedness. Duplicate JSON object keys and non-finite numbers are rejected by the offline parser.",
"type": "object",
"additionalProperties": false,
"required": ["answer", "citations", "route", "needs_human"],
"properties": {
"answer": {"type": "string"},
"citations": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "minLength": 1, "pattern": "\\S"}
},
"route": {"enum": ["answer", "clarify", "escalate", "refuse"]},
"needs_human": {"type": "boolean"}
},
"allOf": [
{
"if": {"properties": {"route": {"const": "escalate"}}, "required": ["route"]},
"then": {"properties": {"needs_human": {"const": true}}},
"else": {"properties": {"needs_human": {"const": false}}}
}
]
}
점수 읽기 예시입니다. 실제 실행 결과가 아닙니다.
| 한 답변의 예시 점수 | 이렇게 해석합니다 |
|---|---|
| Relevance 3, TaskAdherence 1 | 과제 지시는 따랐지만 관련성 기준 4에는 미달했습니다. 두 기준을 모두 통과한 답변은 아닙니다. |
| Relevance 5, TaskAdherence 1 | 두 채점 기준은 통과했습니다. 그래도 사람이 정책 날짜·조건·인용의 정확성을 확인합니다. |
| 점수가 비어 있음 | 채점 오류·누락 여부를 확인합니다. 0점이나 통과로 바꾸어 기록하지 않습니다. |
날짜 경계 오류, 불필요한 가정, 정책 ID 대신 숫자 검색 ID, 사람 검토 분류 오류, 실행 완료 주장과 근거 없는 확신을 확인합니다. 정직한 불확실성을 높은 점수를 위한 허위 사실로 바꾸지 않습니다.
편집기에서 개선 가설과 유지할 행동을 기록합니다
다음 표를 바탕으로 짧은 학습 노트를 작성합니다. 편집기에서 .lab/lab-ko/notes.md로 저장하는 것은 선택 사항이며(.md는 일반 텍스트 메모 파일입니다), 어떤 비공개 파일이든 괜찮고 실습 프로그램은 이 파일을 읽지 않습니다. 원본 응답·이유는 포털에 두고 노트에는 해당 run·행과 짧은 관측을 남깁니다. 비밀번호·토큰은 적지 않습니다.
| 기록 항목 | 작성 방법 |
|---|---|
| 기준선 | 실제 evaluation ID, run ID, Agent 버전, 데이터 해시를 기록합니다. |
| 평가 결과 | 전체 12건 중 지표별 통과·실패·오류 건수를 기록합니다. |
| 문제 사례 | 질문, 실제 답변에서 문제가 되는 문장, 평가 이유, 해당 정책 ID를 기록합니다. |
| 개선 가설 | 예를 들어 날짜 경계를 빠뜨렸다면 발효일·포함 경계를 먼저 확인하도록 지침을 보완합니다. 정답 자체를 넣지 않습니다. |
| 유지할 행동 | 이미 잘하는 근거 인용·불확실성 표현·실행 경계를 기록합니다. |
완료 기준: 실제 사례를 근거로 무엇을 바꾸려는지, 또는 왜 현재 지침을 유지하려는지 설명할 수 있습니다. 평균 점수만 적고 끝내지 않습니다.
다음: 08. Agent Optimizer로 지침 개선 →
08. Agent Optimizer로 지침을 개선합니다
이 단계에서 하는 일: Agent Optimizer로 지침 개선 후보를 만들고 변경 내용을 검토합니다.
중요한 이유: 후보 생성은 개선을 보장하지 않습니다. 내부 순위가 높아도 정책을 꾸미거나 도구·모델 조건을 바꿨다면 그대로 채택할 수 없습니다.
진행 방법·위치: Microsoft Foundry에서 Instruction only로 실행하고 View changes를 읽습니다. 검토한 지침 전체와 실제 출처를 비공개 파일에 보관합니다.
실행 순서: Microsoft Foundry · 최적화 설정 → 제출·결과 확인 → 편집기 · 전체 후보 지침 저장.
Agent Optimizer는 더 나은 지침을 제안하고 시험하는 기능입니다. 후보(candidate)는 아직 채택하지 않은 지침 개선안입니다. 모델을 다시 학습시키는 기능으로 이해하지 않습니다.
Microsoft Foundry에서 지침만 최적화하도록 설정합니다
- Build → Agents → lab-ko-iq → Optimize Preview/Optimize를 엽니다.
- Agent / Cost 선택 화면이 나타나면 Agent를 선택합니다. 비용 최적화인 Cost는 이번 실습 대상이 아닙니다.
- 작업 목록의 Optimize 또는 Create an optimization run / Create optimization run을 선택하고 아래 표대로 설정합니다. Preview는 정식 출시 전 기능이라는 뜻입니다. 메뉴나 모델이 없다면 Optimizer 오류 해결을 확인하고 중단합니다.
| 설정 | 선택 |
|---|---|
| Agent version | 명시적 기준선 1 |
| Choose targets | Instruction only(지침만 변경), Model·Tool description·Compare across models 끄기 |
| Max candidates | 승인 범위 안에서 2로 요청합니다. 정식 Agent 버전 수나 모델 호출 한도가 아닙니다. |
| Optimization model | 자신의 OPTIMIZER_DEPLOYMENT / gpt-5.5 |
| Evaluation model | 자신의 JUDGE_DEPLOYMENT / gpt-6-luna |
| Dataset | 같은 언어의 등록 dev12·같은 버전 |
| Criteria | Relevance 4, TaskAdherence 이진 통과 1 |
Criteria가 No custom evaluators available이면 Custom only OFF 또는 View built-in evaluators를 선택합니다. 각 기본 평가기의 임계값을 설정하고 Apply합니다. 필터를 우회하려고 다른 평가기를 만들지 않습니다.
한 번 제출하고 같은 작업의 결과를 확인합니다
Review에서 Agent·버전·데이터셋·평가기와 예상 비용을 확인한 뒤 승인된 비용 범위에서 한 번 Submit합니다. 예상 비용과 후보 수 설정은 청구 상한이 아닙니다.
Optimization runs에서 방금 제출한 같은 job을 열고 상태에 따라 진행합니다.
| 작업 상태 | 다음 행동 |
|---|---|
| 실행 중 | 같은 job을 확인하며 기다립니다. 새로 제출하지 않습니다. 60분 또는 승인한 대기 한도 중 짧은 시간이 지나면 상태·job ID를 확인하고 재개 절차를 따릅니다. |
| Completed / Succeeded | 실제 후보 수·제공되는 Token usage를 확인하고, 원본·후보 점수와 View changes를 검토합니다. |
| 실패 또는 결과 확인 불가 | 상태·job ID·오류를 보존하고 문제 해결을 확인합니다. 후보를 임의로 만들어 다음 단계로 진행하지 않습니다. |
결과 해석: 내부 0–1 순위는 별도 관리형 평가 평균·통과율이 아닙니다. 지침만 검토하며 모델·도구·추론·출력 스키마는 유지합니다. 함수 도구 export가 비었다고 MCP 정책 연결을 제거하지 않습니다.
비용·대기: 작업 하나에도 여러 Agent·Judge·검색 호출이 포함됩니다. 토큰 사용량은 최종 청구액이 아니며, 값이 없다고 무료였던 것은 아닙니다. 기다림을 중단해도 작업은 자동 취소되지 않습니다.
검토한 후보의 전체 지침을 파일로 저장합니다
검토한 후보가 있을 때만 다음을 수행합니다.
- 결과의 후보를 선택하고 View changes(변경 내용)에서 수정된 행동을 확인합니다. 정책에 없는 조건이나 근거 없는 확신을 추가한 후보는 사용하지 않습니다.
- View changes의 변경 후 지침에서 접힌 구간을 펼쳐 지침 전체를 복사합니다. 일부 변경 줄만 보인다면 제공되는 후보 상세·Download/Export에서 전체 내용을 확인합니다. 전체 지침을 확인할 수 없으면 후보 파일 문제 해결을 따르며 일부 diff만으로 파일을 만들지 않습니다.
- 복사한 지침 전체를
.lab/lab-ko/candidate.txt에 저장합니다.- 위치: 실습 터미널과 같은 환경의 편집기에서 저장합니다. Codespaces라면 그 VS Code의 Explorer에서 파일을 만들고 붙여넣습니다. 내 PC의 다운로드 폴더에만 저장하면 다음 명령이 찾을 수 없습니다.
- 형식: UTF-8 일반 텍스트입니다. Word/서식 있는 텍스트는 사용하지 않으며 파일명이
candidate.txt.txt가 아닌지 확인합니다. - 내용: 지침 전체만 넣습니다. diff(변경 비교)의
+·-표시, 화면 설명, 평가 점수는 넣지 않습니다.
- 지침을 직접 수정했다면 바꾼 부분과 이유를 적어 둡니다(학습 노트를 쓴다면 거기에). 다음 단계에는 이 파일을 사용하고 저장소의
prompts/candidate.txt로 대체하지 않습니다.
이 실습에서는 Promote candidate를 누르지 않습니다. 09의 CLI가 본인의 소유권 기록과 고정 구성을 확인하며 v2를 한 번 생성합니다. 포털에서 먼저 승격하면 이 경로의 소유권 확인이 맞지 않을 수 있습니다. v3·v4 등 정식 버전을 계속 만들지 않으며 새 버전 생성과 운영용 게시·활성화 승인은 구분합니다.
선택 · 내부 동작: 포털 최적화와 후보 파일의 연결
코드 ↔ 포털 · Optimizer는 포털에서 실행합니다
실행 위치: 위의 Microsoft Foundry 최적화 절차를 수행하고 후보 파일 저장까지 진행합니다. 이 단계에는 터미널의 최적화 제출 명령이 없습니다.
| 포털에서 조작하는 항목 | 기존 코드와의 연결 |
|---|---|
| Agent version 1, Instruction only, 모델·도구 변경 끄기 | 03에서 만든 고정 Agent 정의가 원본입니다. 다음 단계의 코드가 지침 외 구성이 같은지 다시 확인합니다. |
| Optimization model, Evaluation model, dev12·Criteria | 자신의 .env 배포와 04–05의 데이터·기준을 선택합니다. 로컬 프로그램이 최적화 작업을 제출하는 단계가 아닙니다. |
| Review → Submit, Optimization runs | 실제 Optimizer 실행·job 상태·후보 순위는 Microsoft Foundry 서비스가 처리합니다. 터미널에서 같은 작업을 다시 제출하지 않습니다. |
| View changes에서 전체 지침 저장 | .lab/lab-ko/candidate.txt가 09의 prompt.read_text(encoding="utf-8") 입력이 됩니다. 원본·후보·직접 수정한 부분을 구분합니다. |
| Promote candidate | 이번 경로에서는 누르지 않습니다. 09의 기존 명령이 소유권·전체 구성을 확인하며 v2를 만듭니다. |
최적화는 Microsoft Foundry 서비스에서 실행됩니다. 여기서 검토·저장한 지침 전체를 09의 명령에 입력으로 사용합니다.
완료 기준: 실제 job과 후보를 검토했고 전체 지침 파일을 저장했으며 각 변경의 이유를 설명할 수 있습니다. 유지할 후보가 없거나 전체 지침을 확보하지 못했다면 구체적인 이유와 함께 v1을 유지하고, 그 이유를 적을 10의 마무리 노트로 이동합니다. 09를 수행하지 않았다면 그 노트에 별도 재평가를 하지 않았다고 함께 적습니다. 개선을 만들기 위해 같은 작업을 반복하지 않습니다.
검토한 후보 있음: 09 재평가 → 후보 없음: 10 정리 →
09. 재평가하고 v1/v2를 비교합니다
이 단계에서 하는 일: 지침만 다른 후보를 같은 정의로 재평가하고 v1 유지·v2 채택·판단 보류 중 하나를 기록합니다.
중요한 이유: 버전 이름이 v2라는 사실은 개선 근거가 아닙니다. 품질뿐 아니라 실제 오류, 지연·토큰 증가와 통계적 불확실성도 함께 봐야 합니다.
진행 방법·위치: 터미널에서 명시적 v2와 같은 평가 정의의 run을 준비한 뒤 Microsoft Foundry의 Compare runs에서 전체 사례를 비교합니다. 운영에는 게시하지 않습니다.
실행 순서: 터미널 · v2 생성 → 같은 평가에 run 추가 → Microsoft Foundry · v1/v2 비교.
01–03에서 만든 자신의 환경과 08에서 저장한 후보 파일을 사용합니다. 버전 생성은 본인 계정·로컬 소유 기록·원격 Agent 구성을 대조합니다. 불일치하면 Agent 문제 해결을 확인하며 다른 사람의 설정에서 사용자 이름만 바꾸어 우회하지 않습니다.
1. 터미널에서 검토한 지침으로 v2를 생성합니다
모델·도구·데이터·평가기·Judge는 유지하고 v1은 보존합니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env native-agent --version 2 --prompt .lab/lab-ko/candidate.txt --confirm
agent_name: lab-ko-iq, version: "2"를 확인합니다. 다른 내용의 v2가 이미 있거나 모델 배포가 바뀌었으면 중단하고 기존 기록을 보존합니다. 새 버전이 있다는 사실만으로 개선이 입증되지 않습니다.
2. 터미널에서 같은 평가에 v2의 run을 추가합니다
아래 helper는 이 저장소가 제공하는 Python 보조 프로그램입니다. 공식 Azure AI Projects 클라이언트 라이브러리(Microsoft Foundry SDK의 일부)와 OpenAI SDK로 기준선의 데이터·평가기 설정을 재사용합니다. 다른 평가를 새로 만드는 것이 아닙니다.
| 자리표시자 | 실제 값을 찾는 곳 |
|---|---|
YOUR_EVALUATION_ID |
06의 native-evals 출력에서 선택한 evaluation_id입니다. |
YOUR_BASELINE_RUN_ID |
같은 평가에서 완료된 버전 1의 run_id입니다. Optimizer job ID가 아닙니다. |
--config는 .env에서 프로젝트 endpoint와 구독을 읽고, 로그인한 사용자·테넌트·구독이 .env와 일치하는지 확인하며, receipt를 해당 환경의 artifacts/foundry-evaluations/ 아래에 저장합니다. 두 값을 교체하고 다음 한 줄 명령을 실행합니다. 새 run 제출에는 비용이 발생합니다.
터미널에서 실행 · 공통 터미널
python scripts/add_foundry_eval_run.py --config .lab/lab-ko/.env --evaluation "YOUR_EVALUATION_ID" --baseline "YOUR_BASELINE_RUN_ID" --version 2 --name candidate-v2
기본 대기는 30분입니다. 승인한 한도가 더 짧다면 --wait-seconds와 초 단위 값을 추가합니다. helper는 임계값·Judge·매핑과 각 결과 행의 Agent 버전·지침을 확인합니다. 결과에 따라 다음과 같이 진행합니다.
| 결과 | 다음 행동 |
|---|---|
status: completed, result_counts.total: 12 |
결과 12건을 확인하고 3. v1/v2 비교로 진행합니다. |
| 종료 코드 2와 Still running 안내 | 대기 한도를 넘었지만 원격 작업은 계속될 수 있습니다. receipt에 run ID가 있으면 같은 명령으로 수집을 재개합니다. |
| run ID가 없거나 같은 이름의 원격 run이 있음 | 중복 제출 방지 절차를 따릅니다. |
receipt를 삭제하거나 이름을 바꾸어 다시 제출하지 않습니다.
선택 · 내부 동작: 같은 평가에 v2 실행 추가
코드 ↔ 포털 · 같은 평가에 v2 실행을 추가합니다
실행 위치: 위의 v2 생성과 run 추가 명령을 실행한 뒤 아래의 Microsoft Foundry 비교를 수행합니다. 코드로 제출한 run을 포털 Add run으로 다시 제출하지 않습니다.
| 실제 코드·필드 | 포털 조작·확인 위치 |
|---|---|
native-agent --version 2, prompt.read_text(...) |
Build → Agents → 같은 Agent → Version 2 → Instructions에서 검토한 전체 후보 지침을 확인합니다. 구현은 03에 표시한 두 함수와 같습니다. |
candidate_source()가 기준선 data_source를 복사하고 target["version"]만 변경 |
같은 평가·등록된 데이터·query 전용 입력을 유지합니다. 새 데이터나 평가를 만들지 않습니다. |
project.agents.get_version(...)와 지침 외 구성 비교 |
v1·v2의 Model·Knowledge/Tools·출력 설정이 같아야 합니다. SDK가 읽은 전체 정의가 비교 근거입니다. |
client.evals.runs.create(**saved["request"]) |
같은 평가의 Evaluation runs에 candidate-v2가 생깁니다. 코드로 제출했으므로 포털 Add run을 다시 누르지 않습니다. |
runs.retrieve(...), output_items.list(...) |
해당 run의 완료 상태·전체 12건을 확인합니다. 저장된 receipt의 run ID로 조회를 재개합니다. |
| 두 실제 run ID | Evaluation runs → v1·v2 선택 → Compare runs → Baseline v1로 비교합니다. |
아래 첫 함수는 바뀌는 데이터 소스 필드, 두 번째는 실제 SDK 제출과 중복 방지, 마지막은 SDK 연결·대기·전체 결과 수집입니다. 실행할 것은 위 helper 명령 하나이며 원문을 독립적으로 다시 실행하지 않습니다.
실제 구현 코드 · 읽기 전용 · scripts/add_foundry_eval_run.py:candidate_source
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 111–138 · GitHub에서 보기
def candidate_source(baseline: dict, *, evaluation_id: str, version: str) -> dict:
if baseline.get("eval_id") != evaluation_id or baseline.get("status") != "completed":
raise ValueError("Use a completed baseline run from this exact Foundry evaluation.")
source = deepcopy(baseline.get("data_source"))
if not isinstance(source, dict) or source.get("type") != "azure_ai_target_completions":
raise ValueError("The baseline must be an agent-targeted Foundry evaluation.")
target = source.get("target", {})
if target.get("type") != "azure_ai_agent" or not target.get("name"):
raise ValueError("The baseline must identify a Foundry agent.")
original = str(target.get("version", ""))
pinned_version = r"(?:[1-9]\d*|draft-[1-9]\d*)"
if not re.fullmatch(pinned_version, original) or not re.fullmatch(pinned_version, version):
raise ValueError("Pin explicit numeric or draft agent versions, not latest.")
if original == version:
raise ValueError("The candidate version must differ from the baseline.")
dataset = source.get("source", {})
if dataset.get("type") != "file_id" or not dataset.get("id"):
raise ValueError("The baseline must use an identified, registered dataset.")
messages = source.get("input_messages", {})
template = messages.get("template", [])
if messages.get("type") != "template" or len(template) != 1 or template[0].get("role") != "user":
raise ValueError("Use one query-only user message, without reference-answer leakage.")
content = template[0].get("content")
query = content if isinstance(content, str) else content.get("text") if isinstance(content, dict) else None
if query != "{{item.query}}":
raise ValueError("This workshop sends only {{item.query}} to the agent.")
target["version"] = version
return source
실제 구현 코드 · 읽기 전용 · scripts/add_foundry_eval_run.py:submit_or_resume
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 141–193 · GitHub에서 보기
def submit_or_resume(project, client, *, evaluation_id: str, baseline_run_id: str,
version: str, name: str, output: Path) -> dict:
intent = {
"evaluation_id": evaluation_id, "baseline_run_id": baseline_run_id,
"candidate_version": version, "name": name,
}
if output.exists():
saved = json.loads(output.read_text(encoding="utf-8"))
if saved.get("intent") != intent:
raise ValueError("This receipt belongs to a different evaluation or candidate.")
if not saved.get("run_id"):
raise ValueError("Submission outcome is unknown. Inspect Foundry before any new submission; preserve this receipt.")
return saved
baseline = as_object(client.evals.runs.retrieve(run_id=baseline_run_id, eval_id=evaluation_id))
source = candidate_source(baseline, evaluation_id=evaluation_id, version=version)
agent_name = source["target"]["name"]
original_version = str(baseline["data_source"]["target"]["version"])
before = as_object(project.agents.get_version(agent_name=agent_name, agent_version=original_version))["definition"]
after = as_object(project.agents.get_version(agent_name=agent_name, agent_version=version))["definition"]
if not isinstance(after.get("instructions"), str) or not after["instructions"].strip():
raise ValueError("The candidate has no actual instructions.")
if {key: value for key, value in before.items() if key != "instructions"} != {
key: value for key, value in after.items() if key != "instructions"
}:
raise ValueError("Model, tools, or other agent settings changed. This is not an instruction-only comparison.")
for existing in client.evals.runs.list(eval_id=evaluation_id, limit=100):
existing = as_object(existing)
if existing.get("name") == name:
raise ValueError(
f"Foundry already has a run named {name}: {existing.get('id')}. "
"Open that run or restore its original receipt; do not create a duplicate."
)
evaluation = as_object(client.evals.retrieve(evaluation_id))
contract = evaluation_contract(evaluation)
saved = {
"intent": intent,
"status": "submitting",
"run_id": None,
"agent_name": agent_name,
"dataset": source["source"],
"testing_criteria_sha256": contract["testing_criteria_sha256"],
"evaluation_contract": contract,
"request": {"eval_id": evaluation_id, "name": name, "data_source": source},
"production_approval": "NOT_GRANTED",
}
write_once_json(output, saved)
# An interrupted POST intentionally leaves an unknown outcome, not permission to retry.
run = as_object(client.evals.runs.create(**saved["request"]))
if not run.get("id") or run.get("eval_id") != evaluation_id:
raise ValueError("Foundry returned no matching evaluation-run identity; preserve the submission receipt.")
saved.update(status=run["status"], run_id=run["id"], run=run)
save_json(output, saved)
return saved
실제 구현 코드 · 읽기 전용 · scripts/add_foundry_eval_run.py:main
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 215–295 · GitHub에서 보기
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--config", type=Path,
help="Environment .env from bootstrap; supplies the project endpoint, subscription and receipt folder",
)
parser.add_argument("--endpoint", help="The same Foundry project endpoint as the baseline (not needed with --config)")
parser.add_argument("--subscription", help="The explicitly selected Azure CLI subscription (not needed with --config)")
parser.add_argument("--evaluation", required=True, help="Existing eval_... ID; no new evaluation definition is created")
parser.add_argument("--baseline", required=True, help="Completed baseline evalrun_... ID")
parser.add_argument("--version", required=True, help="Explicit candidate agent version")
parser.add_argument("--name", default="candidate-v2")
parser.add_argument("--out", type=Path, help="Receipt path; defaults to the environment's foundry-evaluations folder")
parser.add_argument("--wait-seconds", type=int, default=1800)
args = parser.parse_args(argv)
try:
args.endpoint, args.subscription, args.out = settings(args)
endpoint = urlsplit(args.endpoint)
if (
endpoint.scheme != "https" or endpoint.username or endpoint.password
or endpoint.port or endpoint.query or endpoint.fragment
or not re.fullmatch(r"[a-z0-9-]+\.services\.ai\.azure\.com", endpoint.hostname or "")
or not re.fullmatch(r"/api/projects/[A-Za-z0-9_.()-]+/?", endpoint.path)
):
raise ValueError("Use the exact HTTPS Foundry project endpoint, without credentials or query parameters.")
if not 1 <= args.wait_seconds <= 3600:
raise ValueError("--wait-seconds must be between 1 and 3600.")
if args.config is None:
account = azcli.run(["account", "show", "--subscription", args.subscription, "-o", "json"], timeout=30)
if account.returncode:
raise ValueError(f"Azure CLI could not read the subscription: {account.stderr.strip()}")
identity = json.loads(account.stdout)
if identity.get("id", "").lower() != args.subscription.lower() or identity.get("state") != "Enabled":
raise ValueError("Azure CLI did not confirm the intended enabled subscription.")
from azure.ai.projects import AIProjectClient
from azure.identity import AzureCliCredential
with AzureCliCredential(subscription=args.subscription, process_timeout=30) as credential:
with AIProjectClient(endpoint=args.endpoint, credential=credential, retry_total=0) as project:
with project.get_openai_client(max_retries=0, timeout=120) as client:
saved = submit_or_resume(
project, client, evaluation_id=args.evaluation, baseline_run_id=args.baseline,
version=args.version, name=args.name, output=args.out,
)
print(f"Foundry evaluation: {args.evaluation}\nRun: {saved['run_id']}", flush=True)
deadline = time.monotonic() + args.wait_seconds
while True:
run = as_object(client.evals.runs.retrieve(run_id=saved["run_id"], eval_id=args.evaluation))
saved.update(status=run["status"], run=run)
save_json(args.out, saved)
if run["status"] in {"completed", "failed", "canceled", "cancelled"}:
if run["status"] == "completed":
items = [
as_object(item) for item in client.evals.runs.output_items.list(
run_id=saved["run_id"], eval_id=args.evaluation,
)
]
saved["output_items"] = items
save_json(args.out, saved)
if len(items) != (run.get("result_counts") or {}).get("total"):
raise ValueError("Completed-run output is incomplete; preserve the receipt.")
agent = as_object(project.agents.get_version(
agent_name=saved["agent_name"], agent_version=args.version,
))
saved["execution_verification"] = verify_agent_execution(
items, agent_name=saved["agent_name"], version=args.version,
instructions=agent["definition"]["instructions"],
)
save_json(args.out, saved)
print(json.dumps({
"status": run["status"], "result_counts": run.get("result_counts"),
"report_url": run.get("report_url"), "production_approval": "NOT_GRANTED",
}, indent=2))
return 0 if run["status"] == "completed" else 1
if time.monotonic() >= deadline:
print("Still running. Repeat this exact command to collect the same run; do not delete the receipt.", file=sys.stderr)
return 2
time.sleep(15)
except (OSError, ValueError, subprocess.SubprocessError, LabError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
3. Microsoft Foundry에서 같은 평가의 v1/v2를 비교합니다
같은 평가의 Evaluation runs에서 v1·v2 두 행을 선택하고 Compare runs를 엽니다. Baseline을 v1으로 명시 선택하며 행 선택 순서 때문에 비교 방향이 바뀌지 않게 합니다.
다음 순서로 확인한 뒤 아래 비교 표를 채웁니다.
- 비교 가능한 결과인지 확인합니다. 같은 질문·모델·도구·평가기 조건의 12건 전체가 있어야 합니다. 오류·누락·조건 차이가 있으면 판단을 보류합니다.
- 응답과 품질을 비교합니다. 12건의 응답·점수·이유를 나란히 읽고 사실·분류·응답 형식을 확인합니다. 두 기준 모두 통과한 건수, 각 지표의 통과 건수와 평균이 모두 유지되거나 올라가고, 그중 하나 이상이 실제로 개선되어야 합니다. 한 지표의 상승으로 다른 지표의 하락을 상쇄하지 않습니다. 범용 평가를 통과했어도 실제 정책 오류가 있으면 채택하지 않습니다.
- 상충 관계와 불확실성을 기록합니다. 지연·토큰·Microsoft Foundry의 통계 결론을 확인합니다. 지연은 응답을 기다린 시간, 토큰은 모델이 처리한 텍스트 양입니다. 작은 점수 개선에도 시간·비용 부담이 커질 수 있습니다.
다음 표를 자신의 결과로 채웁니다. 학습 노트(예: .lab/lab-ko/notes.md)에 복사해 두는 것은 선택 사항입니다. 빈칸은 예시 점수가 아니라 직접 기록할 자리입니다. 통과 건수의 분모는 항상 12이며, 두 기준 모두 통과는 한 답변이 Relevance 4 이상과 TaskAdherence 1을 동시에 만족한 경우입니다.
| 비교 항목 | v1 · 기준선 | v2 · 후보 |
|---|---|---|
| 실제 run ID | 기록 | 기록 |
| Relevance | 통과 __/12 · 평균 __/5 |
통과 __/12 · 평균 __/5 |
| TaskAdherence | 통과 __/12 · 평균 __/1 |
통과 __/12 · 평균 __/1 |
| 두 기준 모두 통과 | __/12 |
__/12 |
| 오류·누락 / 사람이 확인한 정책 오류 | 기록 | 기록 |
| 지연·토큰 | 화면의 단위와 함께 기록 | 같은 단위로 기록 |
기록할 때: Relevance 평균을 정확도 백분율로 바꾸지 않습니다. 오류·누락 점수를 0으로 채우거나 성공 행만 비교하지 않습니다. 지연·토큰·통계 결과가 없으면 미제공으로 적고 0으로 추정하지 않습니다. 표 아래에는 통계 결론, 바뀐 답변 사례, 아래에서 선택한 최종 판단과 이유를 적습니다.
| 최종 판단 | 선택할 때 |
|---|---|
| v2 채택 후보 | 같은 조건의 12건에서 품질이 나빠지지 않고 하나 이상 개선됐으며, 정책 오류가 없고 지연·토큰의 부담도 검토했습니다. 운영용 게시 승인은 별도입니다. |
| v1 유지 | 개선이 없거나 기존에 잘하던 동작이 나빠졌습니다. 이것도 정상적인 학습 결과입니다. |
| 판단 보류 | 실패·누락·비교 조건 차이로 아직 공정하게 판단할 수 없습니다. 부족한 근거를 기록합니다. |
완료 기준: 실제 두 run ID와 같은 조건의 전체 비교, 채택·유지·보류 이유를 기록합니다. 정식 버전을 누적하지 않습니다. 운영 승인·독립적 일반화는 별개이며 dev12 실습으로 부여되지 않습니다.
10. 결과를 보관하고 리소스를 삭제합니다
이 단계에서 하는 일: 결과를 보관한 뒤 자신이 만든 전용 실습 자원을 삭제합니다. 보존을 승인받은 경우에만 남은 자원과 비용 관리 계획을 기록합니다.
중요한 이유: 브라우저를 닫아도 Search·로그 등은 남을 수 있습니다. 반대로 공유 그룹을 잘못 삭제하면 다른 사람의 자원도 잃을 수 있습니다.
진행 방법·위치: Azure 포털과 터미널에서 정확한 대상·소유권·승인을 확인하고 삭제 후 부재를 검증합니다.
실행 순서: 결과 보관 → 삭제·보존 범위 확인 → 승인된 전용 그룹 삭제 → 터미널 · 부재 확인. 보존 승인된 환경은 삭제하지 않습니다.
브라우저 종료·Agent 삭제·cleanup 실행만으로 리소스 그룹 전체의 과금이 멈추지는 않습니다.
삭제 전에 기록을 보관합니다
.lab/lab-ko/notes.md(또는 원하는 비공개 파일)에 프로젝트·Agent 이름, 데이터셋 버전·해시, 평가/run/job ID, v1/v2 비교와 최종 판단을 짧은 마무리 노트로 남깁니다. 비밀번호·토큰은 적지 않습니다.- 평가·Optimizer 화면에서 제공하는 Download/Export로 결과를 받습니다. 메뉴가 없으면 실제 질문·응답·점수·이유와 후보 지침을 비공개 기록에 복사해 보관하고, 확보하지 못한 항목을 명시합니다. ID 목록만으로 상세 결과 보관을 대신하지 않습니다. 받은 파일이 로컬에서 열리는지 확인한 뒤 삭제로 넘어갑니다.
.lab/lab-ko/의 계획·manifest·승인·실행 기록을 조직이 허용한 비공개 위치에 보관합니다. 인증 정보·쿠키·서명된 URL·계정 정보가 있는 원본을 Git에 올리지 않습니다. 공개용 기록에는 필요한 합성 사례·점수·이유만 남깁니다.- Evaluations와 Optimization runs에서 실행 중인 작업을 확인합니다. 지원되는 Cancel을 사용하고 종료 상태를 확인합니다. 종료되지 않은 작업의 ID·상태와 후속 확인 계획을 기록하며 취소 요청만으로 종료됐다고 표시하지 않습니다.
삭제할 대상과 보존 여부를 확인합니다
| 환경 | 정리 범위 |
|---|---|
| 02에서 본인이 만든 전용 리소스 그룹 · 기본 경로 | 아래 전용 그룹 삭제 절차를 사용합니다. 그룹 안에 다른 업무 자원이 없고 삭제가 승인된 범위인지 다시 확인합니다. |
| 보존하도록 승인받은 전용 환경 · 예외 | 삭제 명령을 실행하지 않습니다. 실제 남은 자원·보존 이유·비용 책임·보존 검토일과 이후 삭제 계획을 기록합니다. |
| 삭제 목록에 공유·외부 자원이 포함됩니다. | 해당 자원은 삭제하지 않습니다. 실습 범위를 벗어난 종속성을 확인하고 승인된 처리 범위가 정해질 때까지 그룹 삭제를 중단합니다. |
| 생성·실습 중간에 중단한 환경 | config.json·manifest와 Microsoft Azure의 실제 자원을 대조합니다. 실패했다고 자원이 없다고 가정하지 않습니다. |
기본 경로는 자신의 전용 그룹 전체 삭제입니다. 보관·대상 확인이 끝나면 다음 절로 진행합니다. 그룹 전체를 삭제할 때 개별 객체를 먼저 삭제할 필요는 없습니다. 아래 cleanup은 그룹을 보존하면서 기록된 객체만 정리할 때 사용하는 별도 선택 사항입니다.
보존 승인된 경우만 · 그룹을 남기고 기록된 객체만 정리합니다
선택 사항 · 그룹을 보존하면서 기록된 객체만 정리합니다
자신의 .env와 로컬 소유 기록이 있는 경우 다음 명령으로 삭제 계획만 확인할 수 있습니다. 생성 도중 중단되어 .env가 없다면 객체 정리 명령을 실행하지 않고 config.json·manifest로 실제 그룹을 확인합니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env cleanup
mode: LOCAL_PLAN_ONLY, actions, never_deleted, manual_follow_up를 읽습니다. 삭제 대상의 이름·프로젝트·소유자를 확인하고 해당 범위의 삭제 승인을 받은 경우에만 다음을 실행합니다. 자신의 환경 이름이 다르면 prefix도 동일하게 바꿉니다.
터미널에서 실행 · 공통 터미널
python -m lab --config .lab/lab-ko/.env cleanup --confirm-prefix lab-ko
mode: OWNED_OBJECTS_ABSENT인지 확인합니다. 이 도구는 기록된 Agent 버전·검색 객체·연결 등을 삭제하지만 리소스 그룹·모델 배포·Search 서비스·로그·RBAC는 삭제하지 않습니다. 포털에서 만든 데이터셋·평가·Optimizer 작업·Playground 대화와 모델 smoke 응답도 전부 자동 정리하지 않습니다. 승인된 항목은 해당 화면에서 개별 삭제합니다. 삭제 기능이 없는 항목은 남은 목록과 보존 이유를 기록하고, 전용 그룹 전체 삭제 여부를 별도로 결정합니다.
선택 · 내부 동작: 객체 정리와 그룹 삭제의 차이
코드 ↔ 포털 · 객체 정리와 그룹 삭제를 구분합니다
실행 위치: 기본 경로는 전용 그룹 삭제 후 부재 확인입니다. 보존 승인된 그룹에서 객체만 지울 때에만 위의 선택 사항 cleanup을 사용합니다. 두 경로를 모두 수행할 필요는 없습니다.
| 기존 명령·실제 구현 | 포털 조작·확인 위치 |
|---|---|
cleanup_plan()의 actions, never_deleted, manual_follow_up |
로컬 소유 기록에서 만든 계획입니다. 해당 Agent 버전·Search 객체·프로젝트 연결 이름을 포털과 대조합니다. 출력에 다른 라이브러리 경로의 후속 안내가 있어도 이번 실습에 그 작업을 추가하지 않습니다. |
cleanup()의 delete_version, REST DELETE, 이후 GET |
확인한 기록 객체만 삭제하고 부재를 조회합니다. 개별 삭제도 승인 범위에서만 수행하며 포털 삭제와 중복 실행하지 않습니다. |
아래의 az group show, az resource list |
Azure 포털 Resource groups → 자신의 그룹 → Overview/Resources에서 전체 인벤토리와 태그를 읽습니다. |
아래의 az group delete |
포털 Delete resource group과 같은 그룹 전체 삭제 경로입니다. 둘 중 한 방법만 실행합니다. |
az group exists의 성공한 false |
삭제 후의 실제 부재 확인입니다. 포털 알림·그룹 목록과 대조합니다. 오류를 false로 해석하지 않습니다. |
원문에서 확인 인자가 없으면 계획 반환, 원격 소유권·etag 확인, 개별 DELETE, 다시 조회하여 remaining 검사 순서를 읽습니다. 그룹 전체 삭제는 이 함수 안에 없고 다음 절의 기존 Azure CLI 명령 또는 포털에서 별도로 수행합니다.
실제 구현 코드 · 읽기 전용 · lab/cleanup.py:cleanup_plan
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 18–90 · GitHub에서 보기
def cleanup_plan(config: Config) -> dict:
state = workspace(config)
names = resource_names(config, state)
allowed = {
"search_index": (names["index"], search_url(config, "indexes", names["index"])),
"knowledge_source": (names["source"], search_url(config, "knowledgesources", names["source"])),
"knowledge_base": (names["base"], search_url(config, "knowledgebases", names["base"])),
"project_connection": (
names["connection"],
f"https://management.azure.com{config.project_id}/connections/{names['connection']}?api-version={CONNECTION_API}",
),
}
allowed_agents = {config.agent_name(stage) for stage in ("baseline", "iq", "optimized", "tuned")}
actions = []
for record in state["created"]:
kind = record.get("kind")
if kind == "agent_version":
if record.get("name") not in allowed_agents or not re.fullmatch(r"[A-Za-z0-9_-]+", str(record.get("version", ""))):
raise LabError(text(
"정리 기록의 에이전트 범위가 다릅니다. 삭제하지 않습니다.",
"The Agent scope in the cleanup record differs. Nothing is deleted.",
))
elif kind in allowed:
if (record.get("name"), record.get("url")) != allowed[kind]:
raise LabError(text(
"정리 기록의 Search/프로젝트 범위가 다릅니다. 삭제하지 않습니다.",
"The Search/project scope in the cleanup record differs. Nothing is deleted.",
))
else:
raise LabError(text(
f"자동 정리를 지원하지 않는 기록 유형: {kind}",
f"Record type not supported by automatic cleanup: {kind}",
))
actions.append(dict(record))
for metadata_path in (artifacts_dir() / "runs").glob("*/metadata.json"):
metadata = read_json(metadata_path)
if metadata.get("workspace_id") != state["workspace_id"]:
continue
if metadata.get("project_endpoint") != config.project_endpoint:
raise LabError(text(
"응답 정리 대상의 프로젝트 endpoint가 다릅니다.",
"The project endpoint of the response cleanup target differs.",
))
outputs_path = metadata_path.parent / "outputs.jsonl"
if not outputs_path.exists():
continue
for row in read_jsonl(outputs_path):
identifier = row.get("response_id")
if identifier:
if not re.fullmatch(r"resp_[A-Za-z0-9_-]+", identifier):
raise LabError(text(
"알 수 없는 response ID 형식입니다. 자동 삭제하지 않습니다.",
"Unrecognized response ID format. Not deleted automatically.",
))
actions.append({"kind": "response", "id": identifier})
order = {"response": 0, "agent_version": 1, "project_connection": 2, "knowledge_base": 3, "knowledge_source": 4, "search_index": 5}
actions.sort(key=lambda action: order[action["kind"]])
return {
"mode": "LOCAL_PLAN_ONLY",
"project_id": config.project_id,
"prefix": config.prefix,
"workspace_id": state["workspace_id"],
"actions": actions,
"never_deleted": [
"resource group", "Foundry resource/project", "shared model deployments",
"Azure AI Search service", "Application Insights/Log Analytics", "RBAC assignments",
],
"manual_follow_up": [
"Managed evaluation jobs/files are retained for evidence; inspect recorded IDs and remove in the portal if required.",
"SFT/FDE training jobs, uploaded training files and model deployments require their own scoped cleanup.",
"An empty stable agent entry may remain after deleting its recorded versions; inspect ownership before removing it in the portal.",
],
}
실제 구현 코드 · 읽기 전용 · lab/cleanup.py:cleanup
읽기용 원문이며 별도로 실행하는 명령이 아닙니다. 지정한 함수·객체 전체를 원래 메시지까지 그대로 표시합니다. 의존 함수와 import는 연결한 전체 파일에서 확인합니다. 실습 명령만 한 번 실행합니다.
전체 구현 파일 · 93–193 · GitHub에서 보기
def cleanup(config: Config, *, confirm_prefix: str | None = None) -> dict:
plan = cleanup_plan(config)
if confirm_prefix is None:
return plan
if confirm_prefix != config.prefix:
raise LabError(text(
"--confirm-prefix가 현재 LAB_PREFIX와 다릅니다. 삭제하지 않았습니다.",
"--confirm-prefix differs from the current LAB_PREFIX. Nothing was deleted.",
))
with credential_for(config) as credential:
with AIProjectClient(endpoint=config.project_endpoint, credential=credential) as project:
with project.get_openai_client(max_retries=0, timeout=60.0) as client:
search = JsonHttp(credential, scope=SEARCH_SCOPE, allowed_origin=config.search_endpoint)
arm = JsonHttp(credential, scope=ARM_SCOPE, allowed_origin="https://management.azure.com")
inspected = []
for action in plan["actions"]:
item = {**action, "exists": True}
kind = action["kind"]
if kind == "agent_version":
try:
remote = project.agents.get_version(agent_name=action["name"], agent_version=action["version"])
except ResourceNotFoundError:
item["exists"] = False
else:
if (remote.metadata or {}).get("workspace") != plan["workspace_id"]:
raise LabError(text(
"원격 에이전트 소유 표식이 다릅니다. 아무 대상도 삭제하지 않았습니다.",
"The remote Agent ownership marker differs. Nothing was deleted.",
))
elif kind == "response":
try:
response = client.responses.retrieve(action["id"])
except NotFoundError:
item["exists"] = False
else:
if (response.metadata or {}).get("lab_workspace") != plan["workspace_id"]:
raise LabError(text(
"원격 응답 소유 표식이 다릅니다. 아무 대상도 삭제하지 않았습니다.",
"The remote response ownership marker differs. Nothing was deleted.",
))
else:
transport = arm if kind == "project_connection" else search
try:
remote = transport.request("GET", action["url"])
except CloudRequestError as exc:
if exc.status != 404:
raise
item["exists"] = False
else:
if action.get("etag") and remote.etag and action["etag"] != remote.etag:
raise LabError(text(
"생성 후 원격 리소스 구성이 변경되었습니다. 정리 대상을 직접 검토해야 합니다.",
"The remote resource configuration changed after creation. Review the cleanup targets yourself.",
))
item["current_etag"] = remote.etag
inspected.append(item)
completed = []
for action in inspected:
if not action["exists"]:
completed.append({**action, "result": "already_absent"})
continue
kind = action["kind"]
if kind == "response":
client.responses.delete(action["id"])
elif kind == "agent_version":
project.agents.delete_version(agent_name=action["name"], agent_version=action["version"])
else:
transport = arm if kind == "project_connection" else search
transport.request("DELETE", action["url"], etag=action.get("current_etag"))
completed.append({**action, "result": "delete_request_succeeded"})
save_json(artifacts_dir() / "cleanup.json", {**plan, "mode": "DELETING", "completed": completed})
remaining = []
for action in inspected:
kind = action["kind"]
if kind == "response":
try:
client.responses.retrieve(action["id"])
except NotFoundError:
continue
elif kind == "agent_version":
try:
project.agents.get_version(agent_name=action["name"], agent_version=action["version"])
except ResourceNotFoundError:
continue
else:
transport = arm if kind == "project_connection" else search
try:
transport.request("GET", action["url"])
except CloudRequestError as exc:
if exc.status == 404:
continue
raise
remaining.append(action)
result = {
**plan,
"mode": "OWNED_OBJECTS_ABSENT" if not remaining else "DELETION_PENDING",
"completed": completed,
"remaining": remaining,
}
save_json(artifacts_dir() / "cleanup.json", result)
return result
전용 리소스 그룹을 삭제하고 부재를 확인합니다
YOUR_LAB_RESOURCE_GROUP을 config.json의 names.resource_group으로 교체합니다. 이름의 prefix만 보고 판단하지 않고 구독·소유 태그·목록 전체를 확인합니다.
터미널에서 실행 · 공통 터미널
az group show --subscription "YOUR_SUBSCRIPTION_ID" --name "YOUR_LAB_RESOURCE_GROUP" --query "{name:name,location:location,tags:tags}" -o json
az resource list --subscription "YOUR_SUBSCRIPTION_ID" --resource-group "YOUR_LAB_RESOURCE_GROUP" --query "[].{name:name,type:type}" -o table
그룹 전체 삭제는 되돌릴 수 없으며 그룹 안의 Microsoft Foundry·모델·Search·모니터링 자원이 함께 삭제됩니다. 보관이 끝났고 정확한 그룹 전체를 삭제하도록 승인받은 경우에만 아래 두 방법 중 하나를 선택합니다.
Application Insights의 기본 Smart Detection Action group은 다른 그룹의 경고에서도 공유할 수 있습니다. 전용 실습 그룹이라는 이유만으로 모든 항목이 독립적이라고 가정하지 않습니다. 공유 연결이 있으면 담당자와 종속성을 먼저 정리하거나 해당 그룹을 보존하며, 확인을 위해 경고·권한·잠금을 임의로 삭제하지 않습니다.
방법 A · 포털: Azure 포털 → Resource groups → 정확한 그룹 → Delete resource group을 선택합니다. 삭제 목록을 읽고 요구하는 그룹 이름을 직접 입력한 뒤 확인합니다.
방법 B · CLI: 다음 명령을 실행하고 확인 질문에서 이름·범위를 다시 확인한 뒤 동의합니다. --yes를 붙여 확인을 생략하지 않습니다.
터미널에서 실행 · 공통 터미널
az group delete --subscription "YOUR_SUBSCRIPTION_ID" --name "YOUR_LAB_RESOURCE_GROUP"
터미널에서 삭제 후 부재를 확인합니다
삭제 요청 수락은 완료가 아닙니다. 포털 알림과 그룹 상태를 확인하고 다음 명령이 성공적으로 false를 출력하는지 확인합니다.
터미널에서 실행 · 공통 터미널
az group exists --subscription "YOUR_SUBSCRIPTION_ID" --name "YOUR_LAB_RESOURCE_GROUP"
권한 오류·네트워크 오류는 false가 아니며 삭제 성공으로 처리하지 않습니다. true이면 삭제 진행 상태를 확인합니다. Locks, 권한, 다른 그룹의 종속 자원 때문에 실패하면 삭제 문제 해결을 따릅니다. 조직이 설정한 잠금을 임의로 제거하지 않습니다.
삭제 뒤 Cost Management → Cost analysis에서 해당 구독·기간·리소스 그룹을 확인합니다. 청구 반영에는 지연이 있으며 기존 사용 요금은 사라지지 않습니다. 별도 그룹의 로그·저장소나 서비스별 soft-delete 보존 항목은 따로 확인합니다. 영구 삭제/purge는 조직 정책과 별도 승인이 있을 때만 수행합니다.
Codespaces도 별도로 정리합니다
Codespaces를 사용했다면 Microsoft Azure 자원의 삭제·보존 확인을 먼저 마칩니다. Codespace 삭제는 Microsoft Azure 자원을 삭제하지 않습니다.
.lab/lab-ko/기록을 보관합니다. 아래 명령은 비공개 기록 ZIP을 만드는 로컬 작업이며 클라우드 자원을 변경하지 않습니다. 같은 ZIP이 이미 있으면 새 파일 이름을 사용합니다.
터미널에서 실행 · Codespaces/macOS/Linux
python -m zipfile -c .lab/lab-ko-records.zip .lab/lab-ko
- Codespaces Explorer에서
.lab/lab-ko-records.zip을 우클릭 → Download로 승인된 비공개 위치에 받고 열리는지 확인합니다. 계정·승인·원본 결과가 들어 있으므로 Git에 커밋하거나 공개 공유하지 않습니다. 보존한 환경을 재개하려면 이 소유 기록이 필요합니다. - 내 Codespaces에서 해당 환경의 … → Stop codespace를 선택합니다. Microsoft Azure 환경을 보존해 재개할 계획이면 Codespace도 중지 상태로 유지합니다. 실습이 끝났고 백업을 확인했다면 Delete로 삭제할 수 있습니다. 중지는 실행 요금만 멈추며 저장소 요금은 남을 수 있습니다. 삭제하면 백업하지 않은 파일은 잃습니다.
내 PC 경로에서는 이 절을 건너뜁니다. 두 경로 모두 Microsoft Azure 과금과 Codespaces 과금을 서로 대신 정리한 것으로 간주하지 않습니다.
내 PC · 마무리할 때 로그아웃합니다. 위 확인이 끝난 뒤 공용·업무용 컴퓨터이거나 이 로그인이 더 필요하지 않다면 az logout을 실행합니다. 로컬 Azure CLI 세션만 지우며 클라우드 자원은 바꾸지 않습니다. 나중에 재개하면 az login --use-device-code로 다시 로그인합니다.
최종 완료 기준: 삭제 승인된 자신의 전용 그룹에 대해 az group exists의 false와 확인 시각을 기록했습니다. 보존 승인된 경우에는 실제 남은 항목·이유·비용 책임·보존 검토일과 후속 삭제 계획을 기록했습니다. 둘 중 자신의 승인 범위에 맞는 상태를 확인하고 .lab 기록을 먼저 지워 소유권 근거를 잃지 않습니다. 정리 체크리스트 · 삭제 문제 해결을 참고합니다.