1. 시작하기 전에
대부분의 티켓 판매 사이트에서는 서버 다운을 방지하고 높은 수요를 관리하기 위해 구매자를 대기열에 배치합니다. 구매를 시작했는데 14,000명이 대기 중입니다. 대기열은 40분 동안 이동합니다. 내 차례가 되면 좌석을 선택하고 결제할 수 있는 시간이 짧습니다. 이 기간을 놓치면 순번이 사라집니다.
많은 실제 작업은 단일 대화보다 훨씬 오래 걸리고 사용자가 제어하지 않는 외부 시스템에 대해 실행되는 작업이라는 패턴을 따릅니다. 예로는 공급업체의 재입고를 기다리거나, 보상 승인을 기다리거나, 대기열에서 기다리는 경우가 있습니다.
표준 AI 에이전트는 몇 초 동안 지속되고 순전히 반응형인 단일 채팅 턴 내에 있습니다. 40분을 기다리거나 자율적으로 행동할 수 없습니다. 이 문제를 해결하려면 장기 실행 에이전트가 필요합니다. 장기 실행 에이전트는 긴 대기 시간 동안 휴면 상태를 유지하고, 프로세스 재시작 시 컨텍스트를 유지하며, 외부 트리거에 의해 깨어날 때 자동으로 다시 시작되는 시스템입니다.
이 워크숍에서는 Google Cloud에서 장기 실행 에이전트에 필요한 핵심 아키텍처 패턴을 다루면서 콘서트 티켓 대기열 에이전트를 빌드하는 과정을 안내합니다.
빌드할 항목

이 워크숍에서는 주요 에이전트형 소프트웨어 패턴을 학습하면서 장기 실행 티켓 예약 에이전트를 빌드합니다.
- 프로세스 일시중지 및 재개: 긴 대기 시간 동안 에이전트 호출을 일시중지하고 외부 트리거를 통해 실행을 재개합니다.
- 영구 상태 스토리지: SQLite/Cloud SQL 및 Cloud Storage를 사용하여 프로세스 다시 시작 시 대화 상태와 아티팩트를 유지합니다.
- 비활성 가드 및 동일성: 조치를 취하기 전에 동적 데이터 (예: 좌석 가용성)를 확인하고 동일성 키를 사용하여 중복 거래를 방지합니다.
- ADK 워크플로 그래프: 절차적 규칙에는 결정론적 코드 노드를 사용하고 컨텍스트 기반 의사 결정에는 에이전트 노드를 사용하여 실행을 구조화합니다.
- Human-in-the-Loop 승인: 사용자의 명시적 지출 한도를 수집하고 유지하기 위해 실행을 일시중지합니다.
- 클라우드 배포: Cloud Scheduler 및 Pub/Sub 트리거를 사용하여 에이전트를 컨테이너화하고 Cloud Run에 배포합니다.
설정
먼저 환경을 준비하고 Google Cloud에 액세스할 수 있는지 확인합니다. 필요한 모든 도구가 이미 있는 Cloud Shell에서 실행하는 것이 좋습니다. 공간이 충분하고 계정에서 프로젝트를 만들 수 있는지 확인하기만 하면 됩니다.
👉💻 저장소를 클론하고 설정을 실행합니다.
cd ~
git clone https://github.com/gca-americas/monstertix
cd ~/monstertix
./setup.sh
다음과 같은 메시지가 한 번 표시됩니다.
Which Google Cloud project should this workshop use?
Find yours at https://console.cloud.google.com (top-left picker)
Project id [YOUR_PROJECT_ID]:
그런 다음 다음 작업을 순서대로 실행합니다.
virtualenv |
| |
로그인 | 아직 사용자 인증 정보가 없는 경우 | |
project |
| |
API | 워크숍에 필요한 10개를 사용 설정합니다. | |
| 모든 스크립트와 | |
모델 확인 | Gemini에 실제 호출을 한 번 실행하므로 잘못된 프로젝트 또는 모델 ID는 여기서 실패합니다. | |
seed | 이미 2일이 지난 세션을 빌드합니다. 3단계에서 이 세션을 엽니다. | |
Cloud SQL | 백그라운드에서 Postgres 인스턴스를 시작합니다. | |
venue | Cloud Run에 자체 모의 티켓 판매자를 배포합니다. | |
👉💻 환경을 활성화합니다.
cd ~/monstertix
source .venv/bin/activate
이제 프롬프트가 (.venv)로 시작됩니다.
시나리오 개요
이 워크숍 시나리오에서 에이전트는 암스테르담에서 열리는 콘서트 ('The Midnight Signal') 티켓 2장을 예약합니다. 기록 메모리에 저장된 사용자의 환경설정은 다음과 같습니다.
- 주말에 진행되는 쇼여야 합니다 (샘은 평일 저녁에 참석할 수 없음).
- 지고 돔의 상단 좌석은 피하세요 (시야가 좋지 않음).
- 타겟 가격은 항공권당 약 200달러이며 최대 한도는 250달러입니다.

설치 확인
👉💻 하나의 명령어, 8개의 틱:
./verify.sh
Checking the rig
✓ adk installed adk, version 2.6.2
✓ adk 2 apis App + ResumabilityConfig
✓ workflow graphs Workflow + node
✓ async sqlite drivers sqlalchemy + aiosqlite + greenlet
✓ project your-project-id
✓ vertex ai no API keys anywhere
✓ seeded session 13 events, two days old
✓ venue https://venue-you-xxxx.us-central1.run.app
Ready. Cloud SQL is still building in the background and nothing
before step 10 needs it.
👉 두 번째 브라우저 탭을 엽니다.
장소 | 정의 |
| 제어판, 버튼 |
2. 프롬프트로 자율성을 확보할 수 있을까요?
프롬프트는 자체적으로 시작할 수 없음
프롬프트는 통화가 발생할 때 AI 모델에 전달되는 텍스트일 뿐입니다. 스스로 실행되거나, 절전 모드를 해제하거나, 새 전화를 시작할 수 없습니다.
프롬프트에서 에이전트에게 '10분 후에 다시 확인해'라고 말하면 현재 턴이 끝난 후 실제로 아무 일도 일어나지 않습니다. 모델에는 내부 시계나 루프가 없습니다. 에이전트를 자율적으로 만들려면 에이전트 외부의 무언가가 에이전트를 깨우고 호출해야 합니다.

리그 시작
먼저 개발자 도구를 테스트합니다. adk web는 ADK와 함께 제공되는 개발 인터페이스인 ADK 웹 UI를 시작합니다. 프런트엔드를 먼저 작성하지 않고도 빌드 중인 에이전트와 대화할 수 있으며, 채팅 창에서 표시할 수 없는 모든 도구 호출 및 인수, 변경되는 세션 상태, 에이전트가 저장한 아티팩트, 기본 원시 이벤트와 같은 항목을 표시합니다. 프로덕션에서는 이를 노출하지 않습니다. 학습 중에 실행 중인 에이전트를 가장 잘 파악할 수 있습니다. 3단계에서는 실제로 노출할 프런트엔드를 빌드하고 마지막 단계에서는 이를 배포합니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 터미널 1 - 이 단계의 코드를 로드하고 ADK 웹 UI를 시작합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 2 --force
adk web agent --port 8000 --allow_origins "*"
use-solution.sh는 단계의 완료된 코드를 작업 공간인 agent/에 복사합니다. 모든 단계는 이 명령어로 시작하며 변경된 파일을 출력하므로 읽어야 할 파일을 알 수 있습니다.
시작할 때 필요한 사항:
파일 | 정의 |
| 에이전트 - 이름, 모델, 지침, 도구 목록 |
|
|
| 장소의 HTTP 클라이언트입니다. 흥미로운 내용 없음 |
| 사용할 모델 및 사용자 인증 정보가 작동하는지 확인 |
. ./set_env.sh는 virtualenv를 활성화하고, 프로젝트와 모델을 로드하고, $WORKSHOP(~/monstertix의 절대 경로)를 내보냅니다. 열려 있는 모든 터미널에서 실행합니다. 설정된 내용을 출력합니다.
folder /home/you/monstertix
project your project id
model gemini-2.5-flash (We'll avoid using 3.x for now to avoid high usage demands)
venue https://venue-yourname-xxxx.run.app
👉 첫 번째 브라우저 탭을 엽니다.
장소 | 정의 |
ADK 웹 UI |
|
말 걸기
👉✨ ADK 웹 UI에 다음을 입력합니다.
I want to see The Midnight Signal. I'm in Amsterdam, going with Sam, budget around $200 each.
여행을 검색하고, 좌석 지도를 읽고, 예산과 회사에 대해 추론합니다. 정말 유능한 어시스턴트입니다. 하지만 사전 판매가 10시에 시작되면 프로세스나 백그라운드 트리거가 실행되지 않으므로 에이전트가 자동으로 실행되지 않습니다.
방금 일어난 일 읽기
화면 중앙은 채팅 로그뿐만 아니라 턴의 모든 단계에는 번호가 매겨져 있으며 도구 호출도 포함됩니다.
▸ I want to see The Midnight Signal. I'm in Amsterdam... ← user input
⚡ search_events({"city": "Amsterdam"}) ← tool execution
✓ search_events ← venue API response
Two Amsterdam shows: Tuesday 10 Nov and Saturday 14 ... ← model response
👉 ⚡ 행을 클릭합니다. 왼쪽 패널에는 모델이 선택한 주장이 표시됩니다. 반환된 JSON 아래의 ✓를 클릭합니다.
투어 정보가 모델의 학습 데이터 또는 프롬프트에 없습니다. 에이전트는 장소 API를 쿼리하는 search_events 도구를 실행하여 이 데이터를 가져왔습니다.
방금 실행한 코드 열기
작동하는 것을 확인했고 트레이스도 확인했습니다. 이제 코드 자체를 살펴보겠습니다.
agent/concert/
├── agent.py the agent — about twenty lines
├── tools.py what it can do
├── venue.py an httpx wrapper, nothing interesting
└── config.py which model, and a credentials check
👉💻 agent/concert/agent.py을 엽니다. 전체 에이전트는 파일 하단에 있습니다.
INSTRUCTION = """
You help someone plan a trip to see a band on tour.
You can search tour dates and read seat maps. Be concrete: name the show, the
city, the section, and the price...
"""
root_agent = Agent(
name="concert",
model=MODEL,
instruction=INSTRUCTION,
tools=[search_events, get_seatmap],
)
이름, 모델, 에이전트의 행동 방식에 관한 설명, Python 함수 목록 이것이 Agent의 전부입니다. 파일 이름이 agent.py이고 변수 이름이 root_agent이기 때문에 adk web이 이를 찾았습니다.
👉💻 현재 agent/concert/tools.py:
def search_events(city: str = "", weekday: str = "") -> dict:
"""List tour dates. Call with no arguments to see the whole tour.
This venue sells one artist and one tour, so there is nothing to search by
name — never ask the user which artist they mean.
Args:
city: Optional. Filter to one city, e.g. "Amsterdam".
weekday: Optional. Filter to one weekday, e.g. "Saturday".
"""
return venue.get("/events", city=city, weekday=weekday)
일반 함수입니다. 데코레이터도, 등록도, 작성할 스키마도 없습니다. ADK는 서명과 독스트링을 읽고 모델에 표시되는 도구 정의를 빌드합니다. 따라서 독스트링이 중요한 역할을 하며 인수를 삭제하면 모델의 동작이 변경됩니다.
모델은 코드를 실행하지 않습니다. search_events({"city": "Amsterdam"})가 표시될 때 모델은 해당 호출을 요청하고 있었습니다. ADK가 함수를 실행하고 결과를 다시 전달했으며 모델이 이에 관한 문장을 작성했습니다. 루프는 다음과 같습니다.
👉 왼쪽 가장자리에 있는 아이콘은 패널에 표시되는 내용을 전환합니다. 두 번째는 에이전트 그래프입니다. 사용해 보면 두 가지 도구가 표시됩니다.

따라서 절전 모드로 전환되지 않도록 설정하세요.
이 문제를 해결하기 위해 무엇을 할 수 있나요? 대부분의 사람이 가장 먼저 찾는 것을 시도해 보세요. 1단계에서 INSTRUCTION를 떠올리고, 턴마다 모델에 전달된 산문을 다시 작성합니다.
👉💻 agent/concert/agent.py을 엽니다. 아래 INSTRUCTION에는 이미 작성된 두 번째 자기소개서가 있습니다. 원하는 경우 직접 변경해 보세요.
PROACTIVE_INSTRUCTION = """
You are a proactive ticket-buying assistant.
Monitor the presale for The Midnight Signal. The moment it opens at 10:00 on
Tuesday, buy two tickets for the Amsterdam Saturday show — do not wait to be
asked, and do not wait for me to say anything. Act on your own.
Keep checking until the tickets are bought.
"""
# agent/concert/agent.py
root_agent = Agent(
name="concert",
model=MODEL,
instruction=PROACTIVE_INSTRUCTION, # ← was INSTRUCTION
tools=[search_events, get_seatmap],
)
👉💻 터미널 1에서 다시 시작합니다.
cd ~/monstertix
adk web agent --port 8000 --allow_origins "*"
👉✨ 사람에게 말하듯이 계획을 정확하게 말합니다.
I want to see The Midnight Signal in Amsterdam with Sam. The presale opens at 10:00 on Tuesday. Grab us two tickets the moment it opens.
동의할 것이고, 진심으로 동의하는 것처럼 들릴 것입니다.
👉 이제 기다립니다. 다른 내용은 입력하지 마세요. 패널을 시청하세요.
잠시만 기다려 주세요. 아무 일도 일어나지 않습니다.
이 프롬프트가 작동하지 않는 이유
새로운 시각으로 다시 읽어 보세요. 모니터링. 열리는 순간 계속 확인해 주세요. 이러한 모든 항목은 시간이 지남에 따라 발생하는 상황을 설명합니다.
이제 instruction=가 실제로 무엇인지 생각해 보세요. 는 누군가 메시지를 보내는 순간 요청의 일부로 모델에 전달되는 문자열입니다. 턴 사이에 아무것도 읽지 않습니다.
# agent/concert/agent.py
root_agent = Agent(
instruction=PROACTIVE_INSTRUCTION, # read on invocation
... # ...and there was no invocation
)
명령어는 명령어가 작성된 함수를 호출할 수 없습니다.
실패한 프롬프트 변형
프롬프트 명령어를 통해 자율 실행을 강제하려고 하면 문구와 관계없이 실패합니다. 일반적인 프롬프트 시도(예:
- "화요일 10시에 타이머를 설정하고 그때 티켓을 구매해 줘."
- '선예매가 시작되었는지 5분마다 확인해 줘.'
- '사용자 입력을 기다리지 않고 자율적으로 행동해'
모두 동일한 이유로 실패합니다. 호출이 발생하면 프롬프트가 읽기 전용이 되기 때문입니다. 외부 호출자가 없으면 턴 사이에 코드가 실행되지 않습니다.
3. 이벤트 기반 휴면
에이전트는 영원히 깨어 있지 않습니다.
프로세스를 계속 실행하여 폴링하면 메모리가 낭비되고, 비용이 소모되며, 노트북이 절전 모드로 전환되거나 다시 시작되면 다운됩니다.
대기하는 동안 에이전트를 계속 실행하는 대신 장기 실행 에이전트는 완전히 휴면 상태로 전환되어야 합니다. 외부 이벤트 (예: 타이머, 웹훅, 메시지)는 실제로 해야 할 작업이 있을 때만 에이전트를 깨웁니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 이 단계의 코드를 로드합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 3 --force
설치된 항목을 확인하세요. monstertix/서버, 시계, 아직 보지 못한 항목이 하나 더 있습니다. 이 단계에서는 에이전트에 관한 내용이 변경되지 않으므로 agent/가 그대로 유지되는지 확인합니다. 이 점을 기억하세요.
파일 | 정의 |
| 새로워서 좋습니다 러너가 뒤에 있는 웹 서버 |
| 새로워서 좋습니다 시간을 알고 서버를 호출하는 항목 |
두 부분으로 구성되어 있으며 클라우드까지 두 파일로 유지됩니다. 
10단계에서 | |||
트리거 | 에이전트를 실행할 수 있는 엔드포인트 | Cloud Run | |
트리거 사용자 | 시계가 있는 항목이 이를 호출합니다. | Cloud Scheduler | |
트리거 - 자체 웹 서버
예, 웹 서버입니다. 무언가가 호출되기 전에 수신 대기해야 합니다. 이전 단계의 ADK 웹 UI는 에이전트를 실행할 수 있는 개발 인터페이스입니다.
👉💻 monstertix/server.py 열기:
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from concert.agent import root_agent
session_service = InMemorySessionService()
runner = Runner(
agent=root_agent,
app_name="concert",
session_service=session_service,
)
@app.post("/wake")
async def wake(payload: dict = Body(default={})):
session_id = f"wake-{uuid.uuid4().hex[:8]}" # brand new, every time
await session_service.create_session(...)
async for event in runner.run_async(
user_id=USER_ID, session_id=session_id,
new_message=types.Content(role="user",
parts=[types.Part(text=payload["message"])]),
):
...
Agent는 자체적으로 실행되지 않습니다. 이것은 설명입니다. 이름, 모델, 지침, 도구 목록입니다. 수신 메시지를 가져와 올바른 세션을 찾고, 모델에 지금까지의 대화를 전달하고, 요청된 도구를 실행하고, 결과를 다시 제공하고, 도구 호출이 중지될 때까지 루프를 실행하고, 이 모든 단계를 저장소에 써야 합니다. 이것이 바로 Runner입니다.

ADK 웹 UI에는 기본값이 있습니다. 여기에서는 직접 빌드하며, 이것이 ADK 웹 UI와 배포할 수 있는 항목 간의 전체 차이점입니다.
👉💻 터미널 2 — 시작합니다.
러너를 시작합니다.
cd ~/monstertix
. ./set_env.sh
python -m monstertix.server
[server] agent concert · gemini-x-flash
[server] listening on http://xxxx_URL/wake
[server] nothing will happen until something calls it. Ctrl-C to stop.
마지막 줄을 읽어 줘. 트리거가 실행되고 에이전트가 로드되었지만 아무 일도 일어나지 않습니다. 호출 가능과 호출됨이라는 두 가지 아이디어를 구분합니다.
현관문도 있어야 합니다.
adk web는 훌륭한 개발 도구이지만 제공해서는 안 됩니다. 프로덕션에 배포할 때는 사용 설정하면 안 되며, 사용 설정된 경우 고객에게 이를 가리키면 안 됩니다. 따라서 동일한 서버가 페이지도 제공합니다.
👉 브라우저에서 http://127.0.0.1:8090을 엽니다. 주소는 동일합니다.
MonsterTix 소개 채팅 창, 모서리에 있는 이름, 두 개의 버튼 /wake에 게시하고 반환되는 항목을 렌더링하며 동일한 방식으로 응답하는 엔드포인트에 대해 변경 없이 작동합니다.
페이지에서 | 아래에 있는 내용 |
대화 |
|
+ 새 세션 | 새 세션 ID - 새 대화, 나중에 새 예산 |
메모리 재설정 | 모든 세션과 메모리 파일을 삭제합니다. 먼저 질문하고 해당 상태가 있는 위치를 정확하게 보여줍니다. |
👉✨ What Amsterdam dates are there?에 말을 걸고 터미널 2를 확인합니다. Runner, 에이전트, 로그 줄이 동일합니다. 호출자만 변경되었습니다.
트리거 - 시간을 아는 항목
👉💻 monstertix/clock.py을 엽니다. 코드를 확인합니다.
time.sleep(args.delay) # wait
while True:
client.post(f"{TRIGGER_URL}/wake", # knock
json={"message": args.message})
if not args.every:
return # once, unless told otherwise
time.sleep(args.every) # or again, forever
매우 간단한 메커니즘입니다. 절전 모드, POST, 반복하면 됩니다. 사용해 본 모든 스케줄러는 동일한 작업을 실행하며, 재시도, 시간대, 다시 시작 후에도 유지된다는 보장도 제공합니다.
👉💻 터미널 3 - 지금부터 10초 후에 한 번 실행:
cd ~/monstertix
. ./set_env.sh
python -m monstertix.clock --in 10
[clock] target http://127.0.0.1:8090/wake
[clock] first fire in 60s, once only
[clock] go and watch the other terminal — do not touch the keyboard
👉 이제 입력을 중지합니다. 손을 내립니다. 터미널 2를 확인합니다.
1분 후 키보드에 아무도 없는 상태에서 다음이 표시됩니다.
[clock] firing → POST /wake
[server] woken · session wake-23930983
[server] called search_events
[server] called purchase
[server] said I've purchased 2 tickets for The Midnight Signal in
Amsterdam on Saturday, section A. Your total is 420.
[clock] done. the agent ran and you were not involved.
이것이 2단계의 답입니다. 에이전트 외부의 무언가가 이를 호출했으며, 그 무언가는 sleep 및 HTTP 요청이었습니다.
👉💻 Ctrl-C를 모두 표시합니다. 반복해서 보려면 먼저 --every 30를 사용하세요.
이제 말한 내용을 읽어 보세요
"암스테르담에서 열리는 The Midnight Signal 공연 티켓 2장을 구입했어. A구역이고 가격은 420달러야."
👉🔴 장소 패널을 확인하면 티켓이 구매된 것을 확인할 수 있습니다.
여전히 잘못된 부분은 무엇인가요?
무언가를 구매했습니다. 추측해야 했던 내용을 확인하세요. 처음 발견한 것이 암스테르담과 A 섹션이었기 때문에 이를 선택했고, 에이전트에게 직접 구매하라고 말했기 때문에 샘이 평일에는 갈 수 없다는 것을 알지 못합니다. 오전 3시에 추측에 따라 행동하는 에이전트는 아무것도 하지 않는 에이전트보다 나쁩니다.
일어나는 것은 쉬운 일입니다. 아무것도 없는 상태(기록, 환경설정, 청취자 없음)로 절전 모드에서 해제되는 에이전트는 아직 절전 모드에서 해제할 가치가 없습니다.
4. 컨텍스트 수명 관리
정보가 다양한 기간 동안 다양한 위치에 저장됨
에이전트는 채팅 대화, 세션 상태, 파일 (아티팩트) 또는 장기 사용자 메모리 등 여러 위치에 사실을 저장합니다. 각 장소는 서로 다른 시간 동안 지속됩니다.
정보를 잘못된 위치에 입력한 경우:
- 너무 빨리 사라짐: 채팅이 종료되면 중요한 세부정보가 삭제됩니다.
- 너무 오래 지속됨: 일시적인 사실이 장기 메모리를 오염시켜 향후 대화에 혼란을 줍니다.
각 정보를 올바른 저장 위치 및 수명과 일치시켜야 합니다.
코드 로드
👉🔴 장소 패널에서 장소 재설정을 누릅니다.
👉💻 터미널 1에서 다음을 실행합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 4 --force
마지막 단계 이후 변경된 사항:
파일 | 변경사항 |
|
|
| 새로워서 좋습니다 Google 자체 메모리 스토어 |
| 목록에 도구가 3개 더 있고 요청 사항에서 에이전트에게 이를 사용하도록 지시합니다. |
👉💻 터미널 1에서 에이전트를 다시 시작합니다.
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
이전에 사용하지 않은 플래그 2개:
플래그 | 정의 |
| ADK는 모든 메시지, 도구 호출, 상태 변경을 행 (SQLite 데이터베이스)으로 작성합니다. |
| 에이전트가 저장하는 파일입니다.(파일) |
이러한 파일이 없으면 ADK는 모든 것을 메모리에 보관하고 프로세스를 중지하면 삭제합니다. 이 두 설정이 지속성을 유지합니다.
데이터베이스 sessions.db에는 기록된 이벤트가 13개인 시드된 이전 대화 (two-days-ago)가 포함되어 있습니다.
you We're thinking about seeing The Midnight Signal. Me, Sam, and maybe Priya.
you Sam can't do weeknights though, bailed on every single one.
you Priya's out, she's away that weekend. Just me and Sam.
agent Two it is. Still Saturday the 14th, still Amsterdam.
you You said the upper bowl at Ziggo was bad last time?
이전 예약 3개의 메모리 파일 memory/userx.md(편집기에서 열 수 있음):
- Sam bails on weeknights. Every weeknight show we booked, Sam cancelled.
- Hates the upper bowl at Ziggo Dome, "couldn't see a thing" from section B.
- Comfortable spend is around $200 per ticket. $250 is the hard ceiling.
- Always books two seats, always together.
이러한 이유로 야간에 노란색으로 표시되는 행이 중요합니다. 평일 저녁 공연은 좌석당 10달러 저렴하고, 남은 좌석이 더 좋으며, 목록의 맨 위에 있지만 이 사람에게는 잘못된 답변입니다. 상담사가 이를 알 수 있는 유일한 이유는 마지막 예약 후에 작성된 파일입니다. 이 사실이 유지되는지 확인하는 것이 4단계와 5단계입니다.
추가된 세 가지 도구

note_companion는 누가 오고 있는지와 오지 못하는 이유를 기록합니다.
# agent/concert/tools.py
def note_companion(name: str, constraint: str, tool_context: ToolContext) -> dict:
prefs = dict(tool_context.state.get("user:prefs", {}))
prefs[name] = constraint
tool_context.state["user:prefs"] = prefs
return {"companions": prefs}
다음 주에도 '샘은 평일에 안 돼'가 사실이어야 하므로 추가합니다. user:prefs에 작성하여 이를 실행하고 이름의 user: 부분이 작업을 실행하는 것을 확인합니다. 아래에서 다시 살펴보겠습니다.
recall
및
remember은 텍스트 파일 memory/userx.md을 읽고 씁니다. 잠시 자세를 유지하세요.
👉 이제 상담사가 컨텍스트를 저장하는 두 위치를 살펴보세요.
위치 | 보관하는 항목 |
상태 | 이 세션의 메모리(키-값 쌍)입니다. 4단계에서 여기에 있습니다. |
아티팩트 | 에이전트가 저장한 파일로, 프롬프트에 입력되지 않습니다. |
get_seatmap
. 이전에는 전체 좌석 지도를 반환했습니다. 이제 좌석 지도를 파일에 저장하고 간단한 요약을 반환합니다.
# agent/concert/tools.py
async def get_seatmap(event_id: str, tool_context: ToolContext) -> dict:
seatmap = venue.get(f"/events/{event_id}/seatmap") # about 6 KB of JSON
part = types.Part(inline_data=types.Blob(
mime_type="application/json",
data=json.dumps(seatmap, indent=2).encode()))
await tool_context.save_artifact(f"seatmap_{event_id}.json", part)
summary = {"event_id": ..., "sections": [...], "captured_at": ...}
tool_context.state["temp:seatmap"] = summary
return summary
이 변경사항이 중요한 이유는 다음과 같습니다.
모든 도구 반환 페이로드는 대화 기록에 추가되고 후속 턴에서 모델에 다시 전송됩니다. 큰 JSON 객체 (예: 6KB 좌석 지도)를 반환하면 후속 턴마다 토큰 소비가 증가합니다.
파일에 저장하고 파일 이름 하나만 결제하세요. 상담사가 필요한 경우 load_artifact를 사용하여 세부정보를 다시 요청하도록 하고 대부분의 경우 필요하지 않을 것으로 예상합니다.
파일이 이동하는 위치: 아티팩트 서비스
agent/concert/tools.py에서 get_seatmap의 줄을 다시 확인합니다.
await tool_context.save_artifact(f"seatmap_{event_id}.json", part)
tool_context는 ADK에서 가져오며, ADK는 이를 인수로 도구에 전달하고 save_artifact은 의 메서드입니다. 이 줄은 도구가 바이트를 ADK에 전달하는 위치입니다.
그런 다음 ADK는 시작된 아티팩트 서비스를 사용하여 이를 작성합니다. 다음과 같은 플래그를 선택했습니다.
--artifact_service_uri="file://$WORKSHOP/artifacts"
ADK에는 이를 저장할 수 있는 세 가지 위치가 있습니다.
URI | 바이트가 이동하는 위치 |
| RAM 프로세스를 중지하면 사라집니다. 플래그를 전달하지 않으면 기본값입니다. |
| 디스크의 폴더 현재 사용 중인 기기 |
| Cloud Storage로 가져올 수 있습니다 배포된 에이전트가 마지막 단계에서 사용하는 항목 |
👉💻 여기에서 파일을 선택합니다.
ls -R ~/monstertix/artifacts
노트북에서 Cloud Storage로 이동하려면 file://을 gs://로 변경합니다. (10단계에서 이를 확인합니다.)
메모리 저장소

ADK는 BaseMemoryService라는 장기 메모리 인터페이스를 제공합니다. 여기에는 두 가지 메서드가 있습니다.
# google.adk.memory.BaseMemoryService — ADK's interface, not your file
async def add_session_to_memory(session) # take a conversation in
async def search_memory(*, app_name, user_id, query) # hand memories back
ADK는 다음 세 가지 구현을 제공합니다.
클래스 | URI | 필요한 사항 |
|
| 파이프라인을 빌드할 필요가 없습니다 프로세스를 중지하면 모든 항목이 손실됨 |
|
| Vertex AI RAG 코퍼스 |
|
| Agent Engine 리소스 |
이 워크숍에서는 자체적으로 작성합니다. 프로세스를 중지하면 모든 것을 잊어버리는 InMemoryMemoryService는 제외하고, 생성하는 데 몇 분이 걸리는 클라우드 리소스가 필요한 다른 두 가지도 제외합니다. 파일을 선택하세요. 미리 시드하고 편집기에서 읽을 수 있습니다. memory.py를 자체 구현으로 사용하고 모든 것을 마크다운 파일에 저장합니다.
class MarkdownMemoryService(BaseMemoryService):
async def search_memory(self, *, app_name, user_id, query) -> SearchMemoryResponse:
path = self._path(user_id) # memory/userx.md
return SearchMemoryResponse(memories=[
MemoryEntry(content=types.Content(
role="user", parts=[types.Part(text=path.read_text())]))])
SearchMemoryResponse 및 MemoryEntry는 ADK의 유형이며 메모리 뱅크는 동일한 객체를 반환합니다. 하나는 관리형 서비스가 있습니다. Google의 경우 파일이 있습니다.
파일을 선택하는 이유는 파일을 열어 읽을 수 있기 때문입니다. 에이전트의 전체 장기 메모리는 텍스트 페이지입니다.
어디를 향해야 할까요?
이 워크숍 구현에서는 세 서비스 중 두 개가 명령줄에서 선택됩니다. 세 번째는 위의 이유로 자체 구현했습니다.
서비스 | 설정 위치 | 이 워크숍 |
세션 |
|
|
아티팩트 |
|
|
메모리 | 명령줄의 어디에도 없음 |
|
따라서 memory.py는 환경 변수에서 서비스를 직접 구성합니다.
# agent/concert/memory.py
MEMORY_DIR = pathlib.Path(os.environ.get("MEMORY_DIR", "./memory"))
MEMORY_USER = os.environ.get("MEMORY_USER", "userx")
memory_service = MarkdownMemoryService(MEMORY_DIR)
. ./set_env.sh은 두 가지를 모두 내보냅니다. MEMORY_DIR는 에이전트가 메모리를 읽어오는 위치를 설정하고 MEMORY_USER는 파일을 결정합니다.
물건이 어디에 있는지 찾아보세요.
이제 사실을 저장할 수 있는 네 가지 장소가 있습니다. 네 가지를 모두 살펴보세요.
먼저 내 세션
👉 상단 표시줄에서 새 세션을 클릭합니다.
👉✨ 다음 세 가지를 차례로 말합니다.
Sam can't do weeknights, by the way.

Show me the seat map for the Amsterdam Saturday show.

Actually, hold that thought — what else is on in Amsterdam?
세 번째 메시지가 중요합니다. 좌석 지도를 읽은 턴이 종료되며, 턴이 종료되면 temp:가 사라집니다.
세 가지 항목이 세 가지 다른 위치에 저장되었습니다.
저장된 항목 | 어떤 도구가 | 어디로 이동했는지 | |
1 | 샘은 평일에는 안 돼 |
|
|
2 | 전체 좌석 지도, 6KB |
| 아티팩트 서비스 |
3 | 한 줄 좌석 지도 요약 |
| nowhere |
👉 이제 확인합니다. 상태 탭과 아티팩트 탭은 adk web 왼쪽에 있습니다.


상태 탭에서 temp:seatmap가 생략된 것을 확인할 수 있습니다. temp: 접두사가 있는 상태 키는 활성 호출 중에만 존재하며 세션 상태를 데이터베이스에 유지하기 전에 ADK에 의해 자동으로 삭제됩니다.
이전 대화
👉 상단의 세션 드롭다운에서 two-days-ago로 전환합니다.
이 세션에는 데이터베이스에 미리 채워진 대화 기록이 포함되어 있습니다.

👉 이벤트 스트림 (가운데 열)을 확인합니다. 이전 턴이 요약된 이벤트 블록으로 대체되었습니다.

👉 상태 탭을 확인합니다. 세 가지 종류의 키가 함께 있습니다.
target_event_id ms-ams-01 ← plain: this booking only
party_size 2 ← plain: this booking only
user:prefs {"excluded_weekdays": [...]} ← the person, every session
user: 접두사 (예: user:prefs)는 지정된 사용자의 모든 세션에 걸쳐 상태를 범위 지정합니다. 반면 접두사가 없는 키 (
target_event_id
,
party_size
)는 활성 세션으로 범위가 엄격하게 지정되고 세션이 종료되면 삭제되는 일반 세션 상태를 나타냅니다.
👉 이제 두 상태 탭을 비교합니다. user:prefs 및 저장된 아티팩트는 유지되지만 temp:seatmap는 두 세션 모두에 없습니다. temp: 접두사가 있는 상태 키는 엄격히 임시이며 데이터베이스에 유지되지 않습니다.
그런 다음 파일
👉💻 편집기에서 엽니다.
cloudshell edit ~/monstertix/memory/userx.md
1월부터 3건의 예약이 있습니다. 에이전트는 recall()를 호출할 때 이를 읽으며 세션 데이터가 손실되더라도 계속 유지됩니다.
4개 장소
저장 위치 | 다음 메시지에서 살아남나요? | 다시 시작해도 유지되나요? | 세션 삭제 후에도 유지되나요? |
| 없음 | 아니요 | 아니요 |
| 예 | 예 | 아니요 |
| 예 | 예 | 예 |
| 예 | 예 | 예, 데이터베이스 삭제 후에도 유지됩니다. |
아티팩트 | 예 | 예 | 서비스에 따라 다름 |
표를 내용물이 아닌 컨테이너에 관한 것으로 읽으세요.memory/userx.md는 모든 것을 견디며 remember()가 넣기로 한 것만 포함합니다. 현재 대화가 여기에 작성되지 않습니다. 따라서 세션보다 오래 지속되는 파일은 에이전트가 방금 이야기한 내용을 기억하는 것과 같지 않으며, 5단계에서 이러한 차이가 드러납니다.

상태란
상태는 세션과 함께 이동하는 딕셔너리로 생각하면 됩니다. tool_context.state를 통해 도구에서 읽고 쓰면 ADK가 대화와 함께 저장합니다.
ADK는 접두사의 의미를 할당합니다. 어디에도 정의하지 않습니다. ADK는 키 이름의 첫 번째 부분을 읽고 이를 사용하여 값이 어디로 이동하고 얼마나 지속되는지 결정합니다.
호출된 키 | ADK가 저장합니다. | 지속 시간 |
| 세션과 함께 | 세션이 삭제될 때까지 |
| 사용자에 대한 | 사용자가 시작한 모든 세션에서 |
| 앱에 대한 | 모든 사용자에게 |
| 해당 사항 없음 | 하나의 호출 |
호출은 모든 모델 추론, 도구 실행, 최종 응답을 포함하는 단일 요청-응답 주기입니다.
호출이 종료되면 ADK는 새 상태를 데이터베이스에 쓰고 중간에 있는 모든 temp: 키를 삭제합니다.
설명되지 않은 한 가지
two-days-ago에서는 누군가의 첫 몇 턴이 있던 자리에 요약이 표시되었습니다. 대화가 충분히 길어지면 ADK가 자동으로 작성했습니다.
모든 사실에는 유효 기간이 있습니다
5. 컨텍스트 저하 및 압축
요약 세부정보는 잊으세요
대화가 너무 길어지면 시스템은 공간과 토큰을 절약하기 위해 오래된 메시지를 요약합니다.
요약은 일반적인 요지를 파악하는 데 유용하며 토큰 사용량을 절약하는 데도 좋습니다.

👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 이 단계의 코드로 이동합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 5 --force
마지막 단계 이후 변경된 사항:
파일 | 변경사항 |
| 에이전트가 압축이 사용 설정된 |
기타 모든 항목 | 변경 안됨 |
agent.py이 더 이상 root_agent로 끝나지 않습니다. 다음과 같이 끝납니다.
app = App(
name="concert",
root_agent=root_agent,
events_compaction_config=EventsCompactionConfig(
compaction_interval=3, # summarise after every 3 turns
overlap_size=1, # re-read 1 turn either side when summarising
),
)
adk web는 먼저 app라는 변수를 찾고 root_agent로 대체됩니다. 하나의 에이전트가 아닌 전체 애플리케이션에 적용되는 항목(여기서는 압축, 나중 단계에서는 재개 가능성)이 필요한 경우 에이전트를 App로 래핑합니다.
compaction_interval=3은 ADK가 3턴마다 요약한다는 의미입니다. 한 차례는 내가 무언가를 말하고 에이전트가 그에 대한 대답을 완료하는 것입니다. 이 과정에서 에이전트가 호출한 도구의 수는 상관없습니다. 3은 작으므로 워크숍 중에 실행됩니다. 실제 앱에서는 훨씬 큰 숫자를 사용합니다.
overlap_size=1은 각 요약이 엄격하게 필요한 것보다 한 턴 일찍 시작됨을 의미합니다.
ADK가 1, 2, 3턴을 요약합니다. 3턴 후에 다음 배치를 요약합니다. overlap_size=1를 사용하면 두 번째 요약이 4번째 턴이 아닌 3번째 턴에서 다시 시작됩니다.

3번 턴이 두 번 요약됩니다. 이는 의도된 것입니다. 3번 턴에서 질문을 하고 4번 턴에서 에이전트가 답변한 경우 깔끔하게 분할하면 질문이 한 요약에, 답변이 다른 요약에 배치되어 둘 다 혼란스러워집니다. 턴을 반복하는 것은 생각을 중간에 끊는 것을 방지하는 저렴한 보험입니다.
두 필드를 모두 설정합니다. ADK에는 두 가지 모두에 대한 기본값이 없습니다.
압축과 메모리 서비스가 만날 수 없음
두 단어가 관련이 있는 것처럼 들리지만 시스템은 그렇지 않으므로 명시하는 것이 좋습니다.
EventsCompactionConfig는 agent.py의 App에 root_agent 옆에 설정됩니다. 서비스가 아닌 애플리케이션의 속성입니다.
# agent/concert/agent.py
app = App(
name="concert",
root_agent=root_agent,
events_compaction_config=EventsCompactionConfig(
compaction_interval=3,
overlap_size=1,
),
)
요약은 대화를 다시 작성합니다. 파일을 다시 쓰지 않습니다.
이것이 바로 remember()가 의도적인 호출로 존재하는 이유입니다. 스크립트에 남아 있는 내용은 결국 다른 사람이 작성한 의역으로 읽히고, 파일에 작성된 내용은 내일과 다음 주에 저장된 그대로 읽힙니다. 이 단계의 끝에서 그 차이를 확인합니다.
요약은 어디에 보관되나요?
sessions.db에서 events 표에서 압축 레코드가 다른 턴 이벤트와 마찬가지로 타임스탬프와 함께 에이전트에 의해 이벤트 행에 작성됩니다.
일반 이벤트는 content 필드를 포함하고 요약은 actions.compaction 아래에 있습니다.
👉💻 자체 데이터베이스에서 확인하세요.
cd ~/monstertix
. ./set_env.sh
sqlite3 sessions.db \
"select json_extract(event_data,'\$.actions.compaction.compacted_content.parts[0].text')
from events where event_data like '%compaction%';"
이벤트의 모양은 다음과 같습니다.
events row
└── event_data (JSON)
├── id, author, timestamp, invocation_id ← like every other event
├── content ← ABSENT on this one
└── actions
└── compaction
├── start_timestamp ← the window it replaced
├── end_timestamp
└── compacted_content.parts[0].text ← the summary itself
이벤트 스트림은 콘텐츠에서 각 이벤트를 가져옵니다. 데이터베이스에 요약되어 있고 매 턴마다 모델에 전송됩니다.
두 번째 상담사
budget_split은 도구가 아닌 완전히 별개의 에이전트입니다.
# agent/concert/agent.py
budget_split = Agent(
name="budget_split",
model=MODEL,
include_contents="none", # sees none of the conversation
instruction="You do ticket arithmetic and nothing else...", #just the budget
output_key="budget_plan", # its answer goes into state
)
root_agent = Agent(
...,
tools=[..., AgentTool(agent=budget_split)],
)
일반 함수가 아닌 에이전트를 사용하는 이유는 무엇인가요? 작업에 모델이 필요하기 때문입니다. '이 섹션 중 4인 예산 200달러에 맞는 섹션은 무엇이고 총액은 얼마야?'를 판단이 포함된 산수로 읽고 사람이 읽을 수 있는 답변을 제공합니다. Python 함수가 곱하도록 하고 언급할 만한 옵션을 결정하도록 요청하지 마세요.
그렇다면 기본 상담사가 처리하도록 하면 되지 않나요? 다음 줄을 보면 include_contents="none"는 이 에이전트에게 대화가 none으로 전달됨을 의미합니다. 그룹 채팅도 없고, 선호도도 없고, 40개의 투어 날짜도 없습니다. 요청만 가져옵니다. 이 플래그는 Agent에만 존재하며, 이것이 함수가 아닌 에이전트인 실제 이유입니다.

짧은 프롬프트가 더 저렴하고 빠르며 탈선하기가 훨씬 더 어렵습니다. 20턴 전에 한 말은 산술 연산에 영향을 미치지 않습니다. 산술 연산은 이를 인식하지 못하기 때문입니다.
이유
AgentTool
? 기본 에이전트가 다른 도구처럼 호출할 수 있도록 에이전트를 래핑합니다. 모델 측에서는 budget_split을 search_events과 동일한 것으로 읽습니다.
그리고
output_key="budget_plan"는 대화에 답변을 그대로 두는 대신 해당 이름으로 세션 상태에 답변을 넣습니다. 이것이 중요한 이유를 알아보세요.
실행하기
👉💻 터미널 1에서 Ctrl-C를 누르고 동일한 플래그로 다시 시작합니다.
cd ~/monstertix
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉 새 세션을 시작하고 실제 대화를 나눕니다. 5~6회 회전합니다. 다음은 작동합니다.
hello
what's on in Amsterdam?
Sam is coming with us, and he can't do weeknights
there are 4 of us and I can spend about $200 a head. What fits?
👉 상태 탭을 엽니다.
산술 에이전트의 전체 대답을 보유하는 budget_plan를 찾습니다.

👉✨ 이제 숫자를 변경합니다.
what if 10 people come and I can only do $120 each?
👉 budget_plan을 다시 확인합니다. 추가된 것이 아니라 덮어쓰기되었습니다. output_key에 최신 값이 저장되기 때문입니다.

볼 수 없는 항목: 요약
이러한 대화 중 어딘가에서 ADK가 이전 대화를 요약했습니다.
👉💻 새 터미널에서 다음을 실행하여 직접 읽습니다.
cd ~/monstertix
. ./set_env.sh
./show-compaction.sh
AI가 작성한 내용 읽기
다음은 실제 사례를 간략하게 정리한 것입니다.
── summary 1 ──────────────────────────────────────────────────────
Conversation Language: English
**User Request:**
The user initially wanted to know what concerts are playing in Amsterdam.
Subsequently, the user provided a constraint regarding a companion, Sam.
**Context Summary:**
The user is planning a concert trip. The agent identified two events in
Amsterdam: "The Midnight Signal" at the Ziggo Dome on Tuesday, November
10 and Saturday, November 14, 2026. The user then informed the agent that a
companion named "Sam" will be joining, but "he can't do weeknights." The
agent has successfully noted this constraint for Sam.
**Key Decisions/Information Obtained:**
* **City of interest:** Amsterdam
* **Events found:** "The Midnight Signal" at the Ziggo Dome, Tuesday
10 November and Saturday 14 November 2026.
* **Companion:** Sam
* **Companion constraint:** Sam cannot attend concerts on weeknights.
**Unresolved Questions/Tasks:**
* The agent has not explicitly confirmed if the found event (which is
on a Saturday) is suitable given Sam's constraint, nor has it asked the
user for the next steps regarding this event or further event searches.
**Tools Used:**
* `recall`
* `search_events`
* `note_companion`
가격, 섹션, 도시, 선호도를 유지하고 아직 결정된 사항이 없음을 올바르게 언급했습니다.
여기에서 세 가지 사항을 알 수 있습니다.
의역입니다. 여기에는 아무도 말하지 않은 내용이 있습니다. 모델이 가끔 잘못된 결과를 생성하는 것과 동일한 종류의 프로세스로 생성된 발언에 대한 설명입니다.
참고: 요약 문자열은 내부 세션 이벤트 레코드에 저장되며 별도의 UI 탭으로 직접 노출되지 않습니다.
3턴마다 이전 요약으로 부분적으로 구성된 대화에서
반복하지 않고 예약해 줘
새 세션에서 새 세션을 시작하지 마세요. 방금 읽은 요약은 이 세션의 이벤트에 있으며, 상담사가 암스테르담에 대해 여전히 알고 있는 유일한 이유입니다.
👉✨ 새로운 것을 제공하지 마세요.
Book us something.
상담사가 어떤 프로그램, 어떤 섹션, 몇 명인지 묻는 것을 확인하세요. 올바름으로 표시: 요약에 사용자가 아무것도 확인하지 않았다고 나와 있으므로 상담사가 질문했습니다. 압축을 사용하세요.
압축이 성공하더라도 이전 턴을 요약하면 에이전트가 사용자와 제약 조건을 다시 확인해야 하는 모호성이 발생할 수 있습니다.
👉✨ 답변하고 예약 진행 허용:
Amsterdam Saturday, section C, 8 people
이 코드는 비용을 지출하기 전에 주문을 다시 읽습니다.
'11월 14일 토요일 암스테르담에서 열리는 The Midnight Signal 공연의 C 섹션 티켓 8장을 예약하고 싶어. 총 비용은 $760입니다. 이 티켓을 구매해 드릴까요?'
👉✨ 확인:
yes
이제 작동했음을 알려줍니다.
'11월 14일 토요일 암스테르담에서 열리는 The Midnight Signal 공연의 C구역 티켓 8매 구매가 완료되었습니다. 총 금액은 760달러입니다. 주문 ID는 ord_227877fe입니다.'
이동하여
👉 장소 패널로 전환합니다. 상담사의 말을 무턱대고 믿지 마세요.

패널에서 | 표시되는 내용 |
구매한 티켓 |
|
아래쪽 순서 |
|
The tour, 암스테르담 토요일 | 섹션 C가 8만큼 감소했습니다. |
활동 |
|
패널에서 티켓 판매를 확인합니다.
user:prefs이 적합한 선택인 이유
note_companion에서 마지막 단계에서 샘의 제약 조건을 user:prefs에 작성한 것을 기억하시나요? 에이전트가 기억할 수 있는데 도구를 사용하는 이유가 무엇인지에 대한 명백한 반박이 있습니다.
이유는 다음과 같습니다. 상태는 대화에서 제외하고 요약은 상태를 건드리지 않습니다. user:prefs와 budget_plan가 입력된 방식과 비교하여 변경되지 않았음을 확인합니다. 그런 다음 스크립트를 확인합니다.
요약은 일반적인 모양을 유지하고 세부정보는 손실됩니다. 잃으면 안 되는 내용은 대화가 아닌 다른 곳에 저장하세요.
6. 프로세스 재개
온라인 상태를 유지하지 않고 40분 동안 기다리기
상담사가 40분 티켓 대기열에서 기다려야 하는 경우 Python 프로세스를 활성 상태로 유지하고 기다리는 것은 불안정하고 비용이 많이 듭니다.
긴 대기 시간을 적절하게 처리하려면 다음 단계를 따르세요.
- 데이터베이스에 상태 저장: 프로세스가 안전하게 종료될 수 있도록 에이전트가 일시중지된 위치를 저장합니다.
- 주문형 절전 모드 해제: 대기가 끝나면 외부 신호가 에이전트를 다시 시작하고 중단된 지점에서 실행을 재개합니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 이 단계의 코드로 이동합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 6 --force
실행하기
마지막 단계 이후 변경된 사항:
파일 | 변경사항 |
|
|
|
|
join_queue는 차단하는 대신 티켓과 함께 바로 반환되고 ResumabilityConfig는 실행이 일시중지된 후 다시 시작할 수 있습니다.

👉💻 터미널 1 - 상담사, 변경되지 않음:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉✨ 에이전트에게 구매 요청하기:
Get us two tickets to the Amsterdam show.
recall, search_events, join_queue를 차례로 호출하고 다음과 유사한 답변을 제공합니다.
You are in the queue for The Midnight Signal at Ziggo Dome, Amsterdam, on Saturday, November 14. Your position is 14203.
묻지 않고 토요일을 선택한 것을 확인할 수 있습니다. 암스테르담 공연은 두 개가 있고 17일은 화요일이며, 마지막 단계의 메모리 파일에 샘이 평일에는 공연하지 않는다고 나와 있습니다. 이전 예약의 사실이 이번 예약의 질문을 결정합니다.
또한 단일 좌석이나 가격을 살펴보기 전에 대기열에 참여했으며 둘 다에 대해 아무 말도 하지 않았습니다. 이것은 공연장의 규칙입니다. 줄 맨 앞에 도달할 때까지 티켓을 구매하므로 일찍 도착해서 얻을 수 있는 유일한 것은 줄에 서는 것입니다. 어떤 자리에 앉을지는 40분 후에 결정할 문제입니다.
👉 제어판을 확인합니다. 배너에는 다음과 같이 표시됩니다.

Agent is waiting in line — #14,203
About 2400s at 1×. Safe to kill the agent right now — it will still be here.
대기열은 실제로 40분 길이이며 사용자가 작업하는 동안 사라지지 않습니다.
👉💻 에이전트를 종료합니다.:
Ctrl-C
👉💻 터미널 1에서 다시 시작합니다.
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
👉✨ 동일한 세션에서 상담사에게 대기열의 위치를 묻습니다.
Where am I in line?
You are currently at position 12982 in the queue. It is not your turn yet.

티켓을 비교하고 아무것도 실행되지 않는 동안 포지션이 계속 떨어지는 것을 확인합니다. 에이전트가 종료되었음을 알지 못합니다. 이 티켓이 디스크에 있었기 때문에 다시 시작 전에 가져온 티켓으로 check_queue를 호출했습니다.
👉💻 이유 알아보기:
cd ~/monstertix
sqlite3 sessions.db "select name from pragma_table_info('events');"
id
app_name
user_id
session_id
invocation_id
timestamp
event_data
모든 대화의 모든 턴은 하나의 행이며 턴 자체, 누가 말했는지, 어떤 도구를 호출했는지, 무엇이 반환되었는지, 메모는 event_data 내부에 있는 JSON입니다.
👉💻 대기열 티켓을 찾습니다.
sqlite3 sessions.db "select event_data from events;" \
| grep -o '"ticket": "q_[a-z0-9]*", "position": [0-9]*' | tail -1
"ticket": "q_8cc2c6f9", "position": 14203
join_queue가 반환되면 티켓이 데이터베이스에 직접 저장됩니다. 다시 시작하면 ADK가 디스크에서 이전 턴과 도구 결과를 다시 로드하므로 에이전트가 중단된 부분부터 계속할 수 있습니다.
다시 시작하지 않은 항목을 확인하세요. 에이전트가 처음부터 워크플로를 다시 재생하는 대신 바로 재개됩니다. LongRunningFunctionTool와 ResumabilityConfig는 실행 상태를 디스크에 유지하므로 ADK는 에이전트가 이미 대기 중임을 알고 있어 투어 날짜를 다시 검색하거나 중복 티켓을 요청하지 않습니다.

check_queue이 아닌 join_queue인 이유
두 함수 모두 밀리초 단위로 반환됩니다. 기본 작업이 함수가 반환될 때 완료되는지 여부가 차이점입니다.
반환하는 값 | 작업이 완료되었나요? | |
| 대기열 티켓 및 초기 위치 | 아니요. 외부 시스템에서 비동기식으로 처리가 계속됩니다. |
| 현재 대기열 상태 | 정답입니다. 즉각적인 시점 답변을 반환합니다. |
LongRunningFunctionTool는 대기 중인 작업의 핸들을 반환하는 작업을 위해 설계되었습니다. join_queue를 래핑하면 외부 큐가 진행되는 동안 에이전트가 실행을 일시중지할 수 있습니다.
반면 check_queue는 동기식이고 완전합니다. 래핑하면 에이전트가 대기열의 맨 앞에 도달하여 즉시 구매를 완료해야 하는 경우에도 상태를 확인할 때마다 실행을 잘못 일시중지합니다.

이러한 효과를 낸 요인
세 가지 구성요소를 통해 실행 중단 및 복구가 가능합니다.
# agent/concert/agent.py
LongRunningFunctionTool(func=join_queue) # the tool returns without finishing
ResumabilityConfig(is_resumable=True) # the run may pause and be picked up
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db"
LongRunningFunctionTool: 도구를 차단되지 않는 것으로 표시하여 활성 핸들을 반환하므로 에이전트가 스레드를 중단하지 않고 기다릴 수 있습니다.ResumabilityConfig: 불완전한 작업에서 실행 수명 주기를 일시중지하고 마지막으로 기록된 상태에서 다시 시작하도록 런타임에 지시합니다.Session Storage URI (--session_service_uri): 프로세스 메모리 대신 외부 스토리지 (예: SQLite, Cloud SQL)에 상태를 유지하도록 지시하여 다시 시작할 때 데이터가 손실되지 않도록 합니다.
7. 비활성 데이터에 대한 조치
기다리는 동안 정보가 오래됨
지속된 대화 기록은 실시간 상태가 아닙니다. 대기 시간이 길어지면 상담사가 모르는 사이에 티켓 이용 가능 여부나 가격과 같은 외부 데이터가 변경될 수 있습니다.
정보가 누락된 상담사는 명확한 설명을 요청하기 위해 일시중지하지만, 오래된 데이터가 있는 상담사는 오래된 가정을 바탕으로 즉시 조치를 취합니다. 잘못된 작업을 방지하려면 중요한 작업을 실행하기 직전에 시간에 민감한 데이터를 다시 확인해야 합니다.
아직 새 코드를 로드하지 마세요. 이전 단계의 에이전트를 유지합니다 (티켓을 구매할 수 있지만 현재 오래된 데이터나 동일성 보호 기능이 없음).
👉🔴 장소 재설정을 누른 다음 adk web에서 새 세션을 시작합니다. 채팅 위에 있는 + 새 세션 버튼을 누르면 됩니다.
새 세션을 시작하여 이전 대기열 티켓과 대기 중인 구매 상태를 지웁니다. 이 단계에서는 오래된 데이터를 올바르게 처리하는 방법을 보여주기 위해 깨끗한 실행 컨텍스트가 필요합니다.
스냅샷이 거짓입니다.
👉✨ 이전과 동일한 프로그램을 요청합니다.
Get us two tickets to the Amsterdam show.
👉✨ 이제 줄을 서서 기다리는 동안 좌석에 대해 물어보세요.
How much are the seats, and what's still available?
Section A: $210 per ticket, with 400 seats available.
Section B: $145 per ticket, with 900 seats available.
Section C: $95 per ticket, with 1200 seats available.
👉🔴 좋은 좌석 판매를 누릅니다. 섹션 A가 0으로 떨어집니다.
👉🔴 대기 건너뛰기를 눌러 상담사를 대기열의 맨 앞으로 보냅니다.

👉✨ 구매 요청하기:
Buy the two section A seats.
좌석 이용 가능 여부를 다시 확인하지 않고 purchase(section="A")를 실행하므로 공연장 API가 오류 응답을 반환합니다.
{"error": "sold_out", "available": 0,
"message": "those seats are gone — re-fetch the seatmap before buying"}
Oh no! It looks like the Section A tickets just sold out. Let me get an
updated seat map to see what's still available.

에이전트는 API 오류 응답을 받은 후에만 좌석 지도를 다시 요청했습니다. 근본 원인은 오래된 컨텍스트입니다. 세션 초반에 상담사가 '섹션 A: 400개 사용 가능'이라고 기록했는데, 이 턴이 모델에 전달된 대화 기록에 남아 있습니다.
이전 정보가 저장되는 위치를 명확히 하세요. 이전 정보는 대화 기록에 유지되며 temp:와 같은 상태 접두사로 재정의할 수 없습니다.

해결 방법
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 이제 이 단계의 코드를 로드합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 7 --force
👉💻 터미널 1에서 Ctrl-C를 누르고 다시 시작합니다.
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
변경사항:
파일 | 변경사항 |
| 새로워서 좋습니다 구매 직전에 실행되며 구매를 차단할 수 있습니다. |
|
|
| 이제 |
fence.py는 도구 호출 직전에 ADK가 실행하는 콜백입니다.
root_agent = Agent(
...,
before_tool_callback=refresh_before_purchase, # re-read inventory, then decide
)
증명
👉🔴 장소 재설정 대기열에 추가하고 대기 중에 좋은 좌석 판매를 누른 다음 A 섹션을 구매하라고 말합니다.
이번에는 자동으로 중지됩니다. 에이전트가 다음과 같이 말하는 것을 확인합니다.
'A 구역이 매진된 것 같습니다. 좌석 지도를 다시 읽어야 해."
그런 다음 프롬프트 없이 get_seatmap를 다시 호출하고 섹션 C를 대신 제공하는 것을 확인합니다.
👉🔴 장소의 활동 피드를 주의 깊게 읽으세요. 전반과 후반이 모두 표시됩니다.
error purchase failed: Section A has 0 seats left, 2 requested. Re-read the
seat map and pick again.
agent calling purchase

대시보드에 구매가 표시되지만 티켓 카운터는 0으로 유지됩니다. 이는 도구가 실행되기 전에 fence.py가 가드 역할을 하기 때문입니다.
- 상담사가 구매 도구를 호출하려고 시도합니다.
fence.py가 먼저 요청을 포착하고 라이브 시트 사용 가능 여부를 확인합니다.- 좌석이 매진된 것을 확인한
fence.py는 API가 호출되기 전에 구매를 취소하면서 오류를 직접 반환합니다.
프롬프트 요청 사항은 LLM이 매진된 좌석을 구매하려고 시도하지 않는다는 보장이 없습니다. 코드 가드 (before_tool_callback)는 잘못된 구매가 실제 환경에 도달하지 않도록 보장합니다.
👉🔴 장소 재설정을 누른 다음 다음 구매 중단을 누릅니다.
👉✨ 구매를 요청하고 원하는 만큼 다시 시도하도록 합니다.

주문 수는 1로 유지됩니다. 장소에서 재시도 시 동일한 idempotency 키를 수신했고 중복 주문을 생성하는 대신 원래 확인을 반환했기 때문입니다.
8. 자율 워크플로
LLM은 매번 엄격한 규칙을 따르는 데 취약함
LLM은 사용자 환경설정을 기반으로 프로그램을 선택하는 것과 같은 결정을 내리는 데 유용합니다. 하지만 LLM이 엄격한 단계별 규칙('좌석을 확인하기 전에 항상 대기열에 참여' 등)을 따르도록 하는 것은 때때로 단계를 건너뛰거나 순서를 엉망으로 만들 수 있다는 의미입니다.
이 문제를 해결하려면 워크플로를 다음 두 가지 유형의 단계로 분할하세요.
- 고정 규칙 (코드 함수): 매번 정확히 동일한 방식으로 실행되어야 하는 단계 (대기열 티켓 가져오기, 위치 확인)는 일반 코드 노드로 작성됩니다.
- 결정 (에이전트 노드): 판단이 필요한 단계 (좌석 섹션 선택, 사용자 환경설정 확인)는 AI 에이전트에게 제공됩니다.
이 흐름에 실제로 포함된 내용을 확인합니다. 일부에는 판단이 전혀 없습니다.
단계 | 판단? |
대기열 티켓을 정확히 하나 가져옵니다. | 아니요. 항상 하나이며 구매 전에 항상 확인합니다. |
내 차례인지 확인 | 아니요. 질문하기, 대답 읽기 |
구매자용 브리프 작성 | 아니요. 매번 동일한 문장, 동일한 모양 |
표시할 섹션 | yes. 가격, 요일, 참석자, 싫어하는 것 |
사용자 환경설정 및 제한사항 | 예. |
엄격한 절차는 LLM에 의존해서는 안 됩니다. 확률적 모델은 모니터링되지 않으면 결국 엄격한 순서를 따르지 못하기 때문입니다.
ADK의 Workflow는 결정적 실행을 AI 추론과 분리합니다.
- 함수 노드는 매번 일관되게 고정된 규칙 (예: 대기열에 참여하거나 위치 확인)을 적용합니다.
- 에이전트 노드는 컨텍스트 기반 결정 (예: 사용자 환경설정에 따라 좌석 선택)을 처리합니다.
- 대기 노드는 메모리에서 프로세스를 활성 상태로 유지하지 않고 실행을 일시 중지합니다.
원본 에이전트는 대체되지 않습니다. 워크플로 그래프의 최종 노드가 되어 도구, 오래된 데이터 확인, 동일성 보호를 유지합니다. 그래프는 실행 순서를 제어하고 에이전트는 구매 결정을 실행합니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다.
👉💻 이 단계의 코드로 이동합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 8 --force
실행하기
👉💻 터미널 1에서 Ctrl-C를 누르고 다시 시작합니다. 언제나와 같은 명령어를 사용합니다.
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
마지막 단계 이후 변경된 사항:
파일 | 변경사항 |
| 새로워서 좋습니다 무인 실행이 ADK 2 |
|
|
| 또한 |
그래프 실행 - 세 개의 메시지가 순서대로 표시됨
nightly Python에서 다음 작업을 모두 실행합니다 (그래프가 있는 위치). 각 턴에서 동일한 입력을 전송하여 상태를 통해 워크플로 그래프 전환을 관찰합니다.
1. 지금 말하고 있는 대상 확인
👉 ADK 웹 UI를 새로고침합니다.
# concert/agent.py, at the bottom
buyer_agent = Agent(name="concert", ...) # ← everything you built, renamed
from .nightly import nightly
root_agent = nightly # ← what adk web actually runs
adk web는 root_agent에 할당된 객체를 실행합니다. 여기서 root_agent는 nightly 워크플로 그래프를 가리키며 buyer_agent는 그래프의 최종 노드로 구성됩니다.
2. 시작
👉✨ 다음을 입력하고 Enter 키를 누릅니다.
go
참고: 자동 워크플로 그래프에서 초기 실행은 사용자 입력을 기다리지 않고 시작됩니다. adk web에서 메시지를 보내면 초기 실행이 트리거됩니다.
👉 이제 웹브라우저를 확인합니다. 왼쪽 패널에는 그래프가 그려지고 노드가 실행될 때 노드에 색상이 지정됩니다. 오른쪽 패널은 이벤트 스트림으로, 결정이 어떻게 이루어지는지 확인할 수 있습니다.

queue_up가 세션 상태에 queue_ticket 및 queue_event_id을 쓰는 것을 확인합니다.

adk_request_input라는 함수 호출이 답변을 기다리고 있습니다. 그러면 조용해지고 러닝이 종료됩니다. 그래프가 check_front에 도달하여 14,203번째를 찾고 중지되었습니다.
👉 장소 패널 확인: 대기열 티켓 1개, 실행 중인 항목 없음

3. 절전 모드 해제 - 입력하는 상자 확인
이벤트 스트림을 adk_request_input 이벤트로 스크롤하고 해당 이벤트와 연결된 입력 상자를 찾습니다.

이제 두 곳에 입력할 수 있으며, 입력하는 곳에 따라 다른 작업을 수행합니다.
입력하는 위치 | 결과 |
| 인터럽트에 응답합니다. |
하단의 기본 채팅 상자 | 새 호출을 시작합니다. 그래프가 |
👉✨ Enter your response... 상자에 원하는 내용을 입력하고 화살표를 누릅니다.
👉 터미널 1:
[check_front] #14,197 — not yet. Pausing.
한 줄 두 번째 프로그램을 선택하지 않았고 두 번째 대기열 티켓을 가져가지 않았습니다. 두 번째 대기열 티켓을 가져갔다면 14,203명의 대기열 뒤에 서게 됩니다.
[open] 및 [pick_show]이 다시 실행되면 채팅 상자에 입력한 것입니다. 장소 재설정을 누르고 단계를 다시 시작합니다.
👉 장소 패널을 다시 확인합니다. 여전히 티켓이 1개이며, 내 티켓은 없는데 장소의 시계는 계속 작동했기 때문에 순위가 약간 떨어졌습니다.

4. 맨 앞으로 이동한 후 완료
👉🔴 공연장 패널에서 대기 건너뛰기를 누릅니다.

👉✨ 최신 adk_request_input 이벤트에 마지막으로 한 번 대답합니다. 채팅 상자가 아닌 자체 상자에 대답해야 합니다. 
이번에는 check_front가 앞에서 나를 찾고 떨어집니다. 마지막 두 노드인 brief와 concert에 불이 들어오는 왼쪽 패널을 확인합니다.

👉 브라우저를 확인합니다. 러닝이 종료되었으며 읽어볼 만한 내용이 4가지 있습니다.

그래프가 완전히 녹색이고 가장자리가 계산됩니다. brief 및 concert이 켜져 있으며 이제 pick_show, queue_up, check_front으로 연결되는 가장자리에 배지가 표시됩니다.
2x
는 그래프가 절전 모드 해제당 한 번씩 해당 가장자리를 통과한 횟수입니다. 상단의 2는 재개 가격입니다. 스케줄러가 매번 처음부터 그래프에 다시 진입합니다. 작업이 세 번 발생했다는 의미는 아니며, 장소 패널이 이를 증명합니다. 대기열 티켓이 2개가 아닌 1개입니다. @node(rerun_on_resume=False)가 그 이유입니다.
그래프

# agent/concert/nightly.py
nightly = Workflow(
name="concert_nightly",
edges=[(START, open_the_night, pick_show, queue_up,
check_front, brief, buyer_agent)],
)
두 노드는 에이전트이고 두 노드는 함수입니다. 어떤 노드가 어떤 노드인지 전체 설계 질문으로 취급하세요.
노드 | 이 모양을 선택한 이유 |
| agent. 다른 사용자의 기록에서 프로그램을 선택하는 것은 판단입니다. |
| 함수 대기열에 참여하는 것은 규칙입니다. 오전 3시에 동전 던지기를 해서는 안 됩니다. |
| 함수 '내가 앞에 있나?'에는 정답이 하나 있으며 장소에 있습니다. |
| agent. |
마지막 노드를 살펴보고 빌드한 에이전트를 찾습니다. 복사도 재작성도 아님:
# agent/concert/nightly.py
from .agent import buyer_agent
모든 것이 함께 제공됩니다. 모든 도구, fence.py의 좌석 지도 재읽기, purchase의 동일성 키가 포함됩니다. 하나만 있습니다.
대기하지 않고 중지된 이유
다음은 아무도 작성해서는 안 되는 버전입니다.
# what NOT to write — this is nobody's file
while not ready: # never do this
time.sleep(1)
아무것도 하지 않기 위해 40분 동안 프로세스를 활성 상태로 유지하고 터미널과 함께 종료되며 다시 시작할 수 없습니다. check_front은 대신 다음 작업을 실행합니다.
# agent/concert/nightly.py — check_front
if not status["ready"]:
return RequestInput(message=f"Still at #{position}. Wake me and I'll check.")
RequestInput은 인터럽트입니다. 그래프는 현재 위치에서 중지되고 호출은 세션 저장소로 이동하며 프로세스는 종료될 수 있습니다.
대기하는 동안에는 실행 비용이 들지 않으며, 구매한 티켓이라는 정확한 한 곳에서 종료됩니다. 하지만 밤새도록 여러 번 깨야 할 수도 있습니다.
9. 상담사와 상담하기
사람과 다시 확인하지 않고 돈을 쓰지 마세요
밤새 실행되는 자율 에이전트는 채팅 기록에서 추측되거나 모호한 선호도에 따라 비용이 많이 드는 재정적 선택을 해서는 안 됩니다.
중요한 작업 (예: 티켓 구매)을 실행하기 전에 에이전트는 일시중지하고 사용자에게 예산을 소리 내어 확인하도록 요청하고 구매하기 전에 명시적 확인을 저장해야 합니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 이 단계의 코드로 이동합니다.
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 9 --force
실행하기
👉💻 터미널 1에서 Ctrl-C를 누르고 다시 시작합니다. 이전과 동일한 명령어:
cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
--artifact_service_uri="file://$WORKSHOP/artifacts"
마지막 단계 이후 변경된 사항:
파일 | 변경사항 |
| 사용자가 동의한 내용을 사용자가 말한 문장으로 저장합니다. |
|
|
| 새로운 첫 번째 노드인 |
모델링하지 마세요. 문장을 저장합니다.
agent/concert/budget.py을 열고 전체를 읽습니다.
def load(state) -> str:
"""What they agreed, verbatim. Empty string means nobody has said."""
return str(state.get("budget") or "")
state["budget"] = "up to $100 for the upper bowl or general admission, up to
$250 for the lower bowl, and up to $300 for a Saturday show"
노드는 사용자가 말했는지와 사용자가 확인했는지라는 두 가지 사항을 확인합니다.
그런 다음 의도적으로 user: 아래가 아닌 세션 상태에 문장을 유지합니다. 예산이 하나의 예약에 대해 합의됩니다. 그날 밤 그 밴드에 대해 '좋은 밴드라면 250달러'라고 말했지만 티켓 예약을 관리하는 것과는 관련이 없습니다.
다른 모든 말도 마찬가지입니다.
예산에만 머무르지 마세요. 사람들은 결정을 내리는 동안 마음이 바뀌므로 상담사가 기억하는 내용에 의존하기보다는 여기에서 요청하는 모든 내용을 파악하세요.
"I live in NYC, I can only do weekends, I have 10 people coming with me"
# agent/concert/nightly.py
def what_they_asked_for(ctx) -> str:
"""Everything this person typed, joined, in their own words."""
said = []
for event in (ctx.session.events or []):
if event.author != "user":
continue
for part in (event.content.parts if event.content else []) or []:
text = (getattr(part, "text", None) or "").strip()
if text and text not in said:
said.append(text)
return "\n".join(said)
사용자가 작성한 이벤트만 읽기:
text
function_response에서 부분을 가져오므로 예산 질문 및 답변은 여기에 다시 표시되지 않습니다. 중복 없음
agree_budget는 첫 번째 실행뿐만 아니라 모든 실행에서 이를 호출하므로 상담사가 대기열에 있는 동안 입력한 수정사항도 적용됩니다. 그런 다음 open_the_night 선택기에게 두 문장, 요청, 예산을 모두 전달하고 선택기가 이를 읽습니다.
그래프, 노드 하나 더 길어짐
대화로 시작해 보겠습니다.
# agent/concert/nightly.py
nightly = Workflow(
name="concert_nightly",
edges=[(START, agree_budget, open_the_night, pick_show, queue_up,
check_front, brief, buyer_agent)],
)

두 노드가 중지되고 대기하며 동일한 메커니즘입니다. check_front은 장소를 기다리고 시계가 대답합니다. agree_budget는 사용자를 기다리며 사용자만 응답할 수 있습니다. 이 차이가 10단계 전체입니다.
단계별로 실행
1. 시작
👉 ADK 웹 UI를 새로고침하고 concert 에이전트를 선택합니다.
👉✨ 실제로 원하는 것을 요청하세요.
Get us two tickets to the Amsterdam show.

8단계에서는 그래프에 사용자가 없었으므로 입력한 내용은 모두 삭제되고 go가 다른 것과 마찬가지로 작동했습니다. 여기서 agree_budget는 첫 번째 줄에서 what_they_asked_for(ctx)를 호출하여 입력한 모든 내용을 수집하고 state["request"]에 넣습니다. 그런 다음 open_the_night가 예산과 함께 선택자에게 전달합니다.
요일을 지정하지 않았는데 암스테르담에 두 개의 공연이 있습니다. 따라서 선택기가 recall()로 돌아가는 것을 확인하고 '샘이 평일에 빠짐'을 찾아 토요일을 선택합니다.
브라우저에서는 adk_request_input 이벤트에서 실행이 중지됩니다.
그래프에서 질문을 했습니다. 8단계의 대기열 대기와 동일한 인터럽트이지만 이제 질문에 답변할 사람을 가리킵니다.
2. 사람들이 실제로 대답하는 방식으로 대답하기
👉✨ 하단의 채팅창이 아닌 Enter your response... 상자에 다음을 입력합니다.
About a 100 for the cheap seats. 250 if they're the good ones.
중지되고 확인되는 것을 확인합니다.
[agree_budget] reading back: About a hundred for the cheap seats. Two-fifty
if they're the good ones.
⚡ adk_request_input
So: About a hundred for the cheap seats. Two-fifty if they're the good
ones. Have I got that right?

3. 확인
👉✨ 동일한 상자:
Yes, that's right.
[agree_budget] agreed: About a hundred for the cheap seats. Two-fifty if
they're the good ones.
[open] Budget: About a hundred for the cheap seats. Two-fifty if
they're the good ones.
[pick_show] → {"section": "A", "reason": "..."}
[queue_up] ms-ams-01 section A — ticket q_5924edc2 at #14,203
[check_front] #14,203 — not yet. Pausing.
4개의 노드가 한 단어로 실행되었습니다. 👉 상태 탭을 열고 budget(문장 그대로)을 찾습니다.

접두사 없음 이것이 세션 상태이며, 새 세션을 시작하고 예산이 소진되면 다시 묻기 때문에 의도적으로 결정해야 합니다.
4. 맨 앞으로 이동하여 완료
👉🔴 공연장 패널에서 대기 건너뛰기를 누릅니다.
👉✨ 최신 adk_request_input에 해당 상자에 다시 답변합니다.
go

👉 장소 패널 확인: 한 번의 주문, 해당 좌석 유형에 동의한 가격

그래프에서 사용자에게 질문하는 방법
nightly.py를 열고 agree_budget을 읽습니다. 세 가지를 모두 참으로 만들고 그중 하나가 누락되면 동일한 오류가 발생합니다. 즉, 영원히 묻습니다.
@node(rerun_on_resume=True) # 1. run again when woken
def agree_budget(node_input, ctx):
answers = ctx.resume_inputs or {} # 3. where the answer arrives
said = answers.get(ASK_BUDGET)
if not said:
return RequestInput(
interrupt_id=ASK_BUDGET, # 2. STABLE id, not the default
message="What are you willing to spend?",
)
...
| 이 값이 없으면 노드가 완료된 것으로 처리되어 건너뜁니다. | |
안정적인 | 기본값은 실행당 새로운 UUID이므로 답변을 조회할 항목이 없습니다. | |
| 답변이 표시되는 위치입니다. | |
그래프
앞에 노드가 하나 더 있는 8단계 그래프입니다. 
사용자가 확인한 제약 조건은 세션 범위에 속하며 대화가 종료되면 만료됩니다. 세션 간에 임시 제한을 유지하면 일회성 결정이 의도치 않은 영구 기본값으로 바뀔 위험이 있습니다.
10. 클라우드 스택
노트북이 절전 모드로 전환되고 로컬 파일이 사라짐
노트북에서 에이전트는 컴퓨터가 절전 모드로 전환되지 않고 로컬 SQLite 데이터베이스와 로컬 파일에 의존합니다. 노트북 덮개를 닫거나 다시 시작되는 클라우드 컨테이너에 배포하면 로컬 파일과 메모리가 사라집니다.
프로덕션에서 안정적으로 실행하려면 다음을 충족해야 합니다.
- 클라우드 컴퓨팅: 온라인 상태를 유지하는 컨테이너 (예: Cloud Run)에서 에이전트를 실행합니다.
- 내구성이 뛰어난 클라우드 데이터베이스: 로컬 파일 및 SQLite를 클라우드 서비스 (세션 데이터용 Cloud SQL, 파일용 Cloud Storage)로 대체합니다.
- Cloud 타이머: 노트북이 꺼져 있을 때도 에이전트가 절전 모드에서 해제되도록 관리형 스케줄러 (예: Cloud Scheduler 및 Pub/Sub)를 사용합니다.
👉🔴 장소 패널에서 장소 재설정을 누릅니다. 모든 단계는 동일한 세계에서 시작됩니다. 8개의 쇼, 모든 좌석 사용 가능, 시계는 1배속입니다.
👉💻 최종 코드로 이동:
cd ~/monstertix
. ./set_env.sh
./use-solution.sh 10 --force
파일 | 변경사항 |
| 새로워서 좋습니다 채팅 페이지 및 스케줄러가 호출하는 엔드포인트를 제공하는 FastAPI |
| 새로워서 좋습니다 일반 python-slim 및 uvicorn ADK 관련 사항이 없습니다. |
| 복사되므로 페이지가 에이전트와 동일한 컨테이너에서 제공됩니다. |
| 변경되지 않습니다. 9단계를 완료한 폴더와 바이트 단위로 동일해야 합니다. |
이미 빌드한 페이지
** ADK 웹 UI** 는 개발 중에 상태, 이벤트 스트림, 워크플로 그래프를 검사하기 위한 로컬 디버깅 인터페이스입니다. 프로덕션 배포용으로 패키징되지 않았습니다.
프로덕션에서는 3단계에서 빌드한 독립형 채팅 프런트엔드를 사용합니다. 내부 디버깅 컨트롤을 노출하지 않고 /wake 엔드포인트를 통해 사용자 메시지 및 상호작용 인터럽트를 처리합니다.
로컬 상태를 클라우드 서비스로 마이그레이션
서버를 중지하고 서버가 작성한 내용을 확인합니다.
sessions.db a SQLite file next to the code
memory/userx.md a markdown file you can open in an editor
artifacts/ seat maps written to a folder
로컬 파일은 개발에 적합하지만 Cloud Run 컨테이너는 임시 인스턴스 로컬 스토리지를 사용하므로 프로덕션에서는 실패합니다.
Cloud Run에서 실행하는 경우:
- 인스턴스가 0으로 확장됨: 트래픽이 중지되면 컨테이너가 종료되고 로컬 파일 시스템이 삭제됩니다.
- 인스턴스가 독립적으로 확장됩니다. 여러 동시 인스턴스는 디스크 공간을 공유하지 않으며 각 인스턴스는 자체 격리된 파일 시스템으로 실행됩니다.
상담사가 대기열 티켓 또는 세션 상태를 로컬 SQLite 파일 (sessions.db)에 저장하면 새 컨테이너 인스턴스 또는 다른 컨테이너 인스턴스로 라우팅되는 후속 요청에서 빈 데이터베이스를 찾게 됩니다. 확장 및 다시 시작 시 내구성이 뛰어난 상태를 유지하려면 로컬 파일을 관리형 외부 서비스로 대체해야 합니다.
대신 하나의 컨테이너보다 오래 지속되는 상태가 필요합니다. 파일별로 하나씩 세 개의 서비스
노트북 | 클라우드 | 정의 |
| Cloud SQL | 관리형 Postgres 데이터베이스 실행 중인 항목이 있는지 여부와 관계없이 존재합니다. |
| Cloud Storage | 버킷을 사용합니다. 모든 인스턴스에서 한 번에 도달할 수 있는 객체로서의 파일 |
| Cloud Storage | 동일한 버킷 |
| Cloud Scheduler | Google에서 실행하는 cron입니다. 터미널은 알람 시계가 아닙니다. |
만들기
PostgreSQL 데이터베이스, 데이터베이스 사용자, Cloud Storage 버킷을 프로비저닝합니다. Cloud SQL 인스턴스 생성에는 몇 분이 걸리며 setup.sh 중에 백그라운드에서 시작되었습니다.
👉💻 수집하고 나머지를 만듭니다.
cd ~/monstertix
./setup-cloud-state.sh
→ instance RUNNABLE
→ database adk created
→ user adk created
→ bucket created
→ memory memory/userx.md → gs://your-project-agent-you/memory/
✓ durable state ready. Written to .env
이 스크립트는 위 표의 행당 하나씩 네 가지를 만들고 .env에 URI 두 개를 썼습니다.
CLOUD_SESSION_SERVICE_URI=postgresql+asyncpg://adk:...@/adk?host=/cloudsql/proj:region:instance
CLOUD_ARTIFACT_SERVICE_URI=gs://your-project-agent-you/artifacts
관리형 예약 트리거로 에이전트 배포
서비스가 준비되면 에이전트의 영구 홈을 지정합니다.
👉💻 3~4분 시작하고 계속 읽으세요.
cd ~/monstertix
. ./set_env.sh
./deploy-agent.sh
스크립트는 agent/에서 컨테이너 이미지를 빌드하고, Cloud Run에 배포하고, 지원 이벤트 기반 인프라를 프로비저닝합니다.
전체 배포는 다음과 같은 5가지 개별 구성요소로 구성됩니다.
1. gcloud run deploy the container: your agent, the graph, and the page
2. a Pub/Sub topic a queue for messages
3. a service account an identity, so the message is allowed in
4. a push subscription topic ──► /trigger/wake
5. a Cloud Scheduler job the alarm clock, on a cron
Cloud Scheduler를 트리거 소스로 사용
Cloud Scheduler는 로컬 터미널 세션과 독립적으로 유지되고 자동 재시도를 처리하며 시간대 예약을 지원하는 관리형 cron 서비스로 로컬 실행 타이머 (clock.py)를 대체합니다.
메시지 분리 레이어로서의 Pub/Sub
Cloud Scheduler와 Cloud Run 사이에 Pub/Sub 주제를 배치하면 다음과 같은 세 가지 아키텍처상의 이점이 있습니다.
- 메시지 내구성: Cloud Run 서비스가 콜드 스타트되거나, 재배포되거나, 일시적으로 사용할 수 없는 경우 수신 트리거 이벤트를 대기열에 추가합니다.
- 기본 재시도 정책: 맞춤 애플리케이션 수준 재시도 로직 없이 일시적인 전송 실패를 자동으로 재시도합니다.
- 팬아웃 확장성: 스케줄러를 재구성하지 않고 여러 다운스트림 서비스가 동일한 일정 이벤트에 구독할 수 있습니다.

trigger_sources=["pubsub"]를 구성하면 표준 Pub/Sub 봉투 페이로드와 호환되는 푸시 엔드포인트가 등록되어 수신 메시지가 에이전트 호출 이벤트로 직접 변환됩니다.
→ agent https://concert-you-xxxx.run.app
→ topic presale-you created
→ iam concert-trigger@your-project... may invoke concert-you
→ push → https://concert-you-xxxx.run.app/trigger/wake
→ schedule 0 3 * * *
말 걸기
👉 브라우저에서 에이전트의 URL을 엽니다. 👉✨ 예약 요청하기:
Book me 3 tickets for The Midnight Signal in Tokyo. Best seats in the house,
I don't want the cheap ones.
지출할 의향이 있는 금액을 묻습니다.
👉✨ 소리 내어 말하는 방식으로 대답한 다음 확인합니다.
up to 250 a seat for the good ones
현재 러닝이 대기열에 있습니다.
👉🔴 메시지가 도착할 때 대기열이 준비되도록 공연장 패널에서 대기 시간 건너뛰기를 누릅니다.
실제 스케줄에 따라 운영하려면 작업을 사용 설정해야 합니다. jobs run은 Job.state must be ENABLED이 있는 일시중지된 작업에서 거부됩니다.
gcloud scheduler jobs resume presale-$(gcloud config get-value account | cut -d@ -f1) \
--location=$GOOGLE_CLOUD_REGION
👉 몇 초 이내에 에이전트가 확인된 예산 한도 내에서 구매를 실행하고 주문 확인을 자동으로 반환합니다.
👉🔴 장소 패널을 확인합니다. 하나의 주문
5분마다 실행되도록 하는 방법은 다음과 같습니다.
gcloud scheduler jobs update pubsub presale-$(gcloud config get-value account | cut -d@ -f1) \
--location=$GOOGLE_CLOUD_REGION --schedule="*/5 * * * *"
삭제
배포된 두 가지 항목(장소와 그 옆에 있는 상담사)을 계산합니다. 둘 다 0으로 확장되므로 유휴 상태인 인스턴스에는 비용이 발생하지 않습니다. 무시하고 삭제합니다.
./destroy-agent.sh # service, topic, subscription, scheduler job
./destroy-venue.sh
데이터를 보유하고 둘 다 청구를 유지하므로 Cloud SQL과 버킷은 의도적으로 그대로 둡니다. 완료되었는지 확실히 확인한 후 다음 단계를 따르세요.
./destroy-agent.sh --all
그 외에는 모두 로컬입니다. 작업이 끝나면 폴더를 삭제하거나 원하는 만큼 자체 프로젝트에 대해 운영합니다.
에이전트, 도구, 콜백, 예산이 변경되지 않았습니다. URI 2개와 환경 변수 1개는 작동했습니다.
11. 시스템 개요 및 문제 해결 가이드
환경 차이: 로컬 아키텍처와 클라우드 아키텍처
사용자의 노트북 | 클라우드 | 변경사항 |
| Cloud SQL | 연결 문자열 |
|
| URI |
|
| 경로 |
| Cloud Run 서비스 1개 |
|
| Cloud Scheduler → Pub/Sub |
|
키보드에 앉아 있는 사용자 |
| 기본값으로, 요청별로 재정의됨 |
root_agent, 도구 정의, 콜백 가드, 예산 시행을 포함한 핵심 에이전트 구현은 완전히 변경되지 않습니다. solutions/step10_deploy/concert/를 9단계와 비교하면 정확한 바이트 단위 일치가 표시되어 ADK의 서비스 추상화가 인프라 구성과 에이전트 로직을 분리하는 방법을 검증합니다.
막힌 경우
언제든지 완전한 작업 코드를 확인하세요.
./use-solution.sh N --force # any step. resets memory + sessions + artifacts
./use-solution.sh N --force --keep # code only, leave state alone
문제가 발생한 경우
증상 | 수정 |
| 잘못된 ID입니다. |
모델이 404를 반환함 |
|
| 이미 포트 8000 또는 8090이 있습니다. |
상담사가 장소에 도착할 수 없음 |
|
대기열이 진행되지 않음 | 기다리지 않기를 누릅니다. 1×에서는 대기열이 자동으로 앞으로 이동하지 않습니다. |
두 번째 대기열 티켓이 패널에 표시됩니다. |
|
드롭다운이 비어 있거나 앱이 로드되지 않음 |
|
경고 | 예상됩니다. |
모든 것이 이상해 | 장소 재설정을 누른 다음 |
중복 예약이 가득한 메모리 파일 |
|
|
|
Cloud Shell이 타임아웃됨 |
|