🎬 이 글은 Google ADK로 YouTube Shorts를 생성하는 멀티 에이전트 시스템을 만들며 익힌 개념 — Workflow Agent(Sequential · Parallel · Loop), 구조화 출력(output_schema), State · Artifacts, Callback 기반 Guardrail — 을 정리한 기록입니다.
이 시스템은 8개의 에이전트가 계층적으로 협력한다. 최상위 ShortsProducerAgent가 세 단계(기획 → 에셋 생성 → 영상 합성)를 조율하고, 각 단계는 다시 Workflow Agent(Parallel · Sequential)와 Tool을 가진 LlmAgent로 구성된다.
graph TD
A["ShortsProducerAgent<br>(LlmAgent · 오케스트레이터)"] -->|AgentTool| B["ContentPlannerAgent<br>(LlmAgent · output_schema)"]
A -->|AgentTool| C["AssetGeneratorAgent<br>(ParallelAgent)"]
A -->|AgentTool| D["VideoAssemblerAgent<br>(LlmAgent + assemble_video)"]
C --> E["ImageGeneratorAgent<br>(SequentialAgent)"]
C --> F["VoiceGeneratorAgent<br>(LlmAgent + generate_narrations)"]
E --> G["PromptBuilderAgent<br>(LlmAgent · output_schema)"]
E --> H["ImageBuilder<br>(LlmAgent + generate_images)"]💬 데이터 흐름 — 각 에이전트의 결과는
output_key로 state에 쌓이고(content_planner_output,prompt_builder_output…), 생성된 이미지·오디오·영상은 Artifact(scene_N_image.jpeg,scene_N_narration.mp3,youtube_short_final.mp4)로 저장된다. 뒤 단계 에이전트는 이 state와 artifact를 읽어 작업을 이어간다.
| 에이전트 | 타입 | 역할 · 지침 요약 |
|---|---|---|
| ShortsProducer | LlmAgent (오케스트레이터) | 사용자 요구사항을 수집하고, 5단계 워크플로우로 3개 서브에이전트를 순서대로(ContentPlanner → AssetGenerator → VideoAssembler) 조율. 진행상황 보고 후 최종 세로 MP4 전달 |
| ContentPlanner | LlmAgent (output_schema) | 주제를 받아 최대 20초 스크립트(JSON) 생성. 3~6개 장면의 나레이션·비주얼 설명·오버레이 텍스트(+위치)·길이를 구조화 |
| AssetGenerator | ParallelAgent | 이미지 생성과 음성 생성을 동시 병렬 실행. ContentPlanner 완료 후에만 사용 |
| ImageGenerator | SequentialAgent | PromptBuilder → ImageBuilder를 순차 실행 |
| PromptBuilder | LlmAgent (output_schema) | visual_description을 GPT-Image-1용 최적화 프롬프트로 변환(9:16·1080x1920·텍스트 오버레이·스타일 일관성) |
| ImageBuilder | LlmAgent + tool | 프롬프트마다 OpenAI 이미지 모델 호출 → scene_N_image.jpeg 아티팩트 저장 |
| VoiceGenerator | LlmAgent + tool | 콘텐츠 톤에 맞는 voice 선택 후 OpenAI TTS 호출 → scene_N_narration.mp3 아티팩트 저장 |
| VideoAssembler | LlmAgent + tool | FFmpeg로 이미지+오디오를 1080x1920 MP4로 합성 → youtube_short_final.mp4 아티팩트 |
💡 Workflow Agent는 LLM이 아니다. Sequential·Parallel·Loop 세 종류가 있으며, AI 모델 없이 서브에이전트의 실행 순서만 결정론적으로 제어하는 함수에 가깝다. (LLM Agent는 모델이 도구 호출·응답을 스스로 판단하는 것과 대조적)
workflow 에이전트는 서브에이전트들의 실행 흐름을 제어할 수 있게 도와준다.
workflow agent의 예시는 아래와 같다.
본 프로젝트에서도 두 가지를 실제로 쓴다. 에셋 생성은 이미지 생성과 음성 생성을 병렬로(ParallelAgent) 돌리고, 그 안의 이미지 생성은 프롬프트 최적화 → 이미지 생성 순서로(SequentialAgent) 돈다.
# asset_generator/agent.py — 이미지·음성을 병렬 실행
from google.adk.agents import ParallelAgent
asset_generator_agent = ParallelAgent(
name="AssetGeneratorAgent",
description=ASSET_GENERATOR_DESCRIPTION,
sub_agents=[image_generator_agent, voice_generator_agent],
)# image_generator/agent.py — 프롬프트 빌더 → 이미지 빌더 순차 실행
from google.adk.agents import SequentialAgent
image_generator_agent = SequentialAgent(
name="ImageGeneratorAgent",
sub_agents=[prompt_builder_agent, image_builder_agent],
)💡 왜 SequentialAgent로 나누었나 — 이미지 생성을 하나의 거대한 프롬프트로 처리하는 대신 프롬프트 최적화(PromptBuilder)와 실제 이미지 생성(ImageBuilder)을 두 단계로 분리해 SequentialAgent로 묶었다. 각 단계가 단순해져 디버깅·재사용이 쉬워진다.
역할 요약 — 주제를 받아 최대 20초 분량의 YouTube Shorts 스크립트를 만든다. 3~6개 장면으로 나누고, 각 장면마다 나레이션·비주얼 설명·오버레이 텍스트(+위치)·길이를 정해 JSON으로 출력한다.
여기서 핵심은 **output_schema**다.
Pydantic BaseModel로 출력 구조를 정의하면 에이전트가 그 형식의 JSON만 내도록 강제된다. 결과는 output_key로 state(content_planner_output)에 저장되어 뒤 단계가 참조한다.
# content_planner/agent.py
from pydantic import BaseModel, Field
class SceneOutput(BaseModel):
id: int = Field(description="Scene ID number")
narration: str = Field(description="Narration text for the scene")
visual_description: str = Field(description="Detailed description for image generation")
embedded_text: str = Field(description="Text overlay for the image")
embedded_text_location: str = Field(description="Where to position the text (e.g., 'top center')")
duration: int = Field(description="Duration in seconds for this scene")
class ContentPlanOutput(BaseModel):
topic: str
total_duration: int = Field(description="Total video duration in seconds (max 20)")
scenes: list[SceneOutput]
content_planner_agent = Agent(
name="ContentPlannerAgent",
description=CONTENT_PLANNER_DESCRIPTION,
instruction=CONTENT_PLANNER_PROMPT,
model=MODEL,
output_schema=ContentPlanOutput, # 출력 구조 강제
output_key="content_planner_output", # 결과를 state에 저장
)⚠️ **
output_schema**를 설정하면 그 에이전트는 tool을 쓸 수 없다. 구조화된 JSON 출력과 도구 호출은 동시에 불가능하며, 모델은 오직 자체 추론으로만 응답한다. (ADK 공식 문서 확인) 그래서 ContentPlanner·PromptBuilder처럼 구조화 출력만 하는 에이전트에는 tool이 없다.
역할 요약 — ContentPlanner가 만든 각 장면의 visual_description을 가져와 이미지 생성 모델(GPT-Image-1)에 맞게 기술 사양(9:16·1080x1920)·텍스트 오버레이·스타일 일관성을 더해 최적화된 프롬프트로 변환한다.
이 역시 output_schema(PromptBuilderOutput)로 구조를 강제하고 prompt_builder_output으로 state에 저장한다.
💡 프롬프트에서 state 직접 참조 — instruction 문자열에
{content_planner_output}처럼 중괄호로 state 키를 넣으면, ADK가 실행 시 그 값을 자동으로 채워 넣는다. 앞 에이전트가output_key로 저장한 결과를 다음 에이전트가 프롬프트에서 그대로 끌어다 쓰는 방식이다.
# prompt_builder/prompt.py (발췌)
PROMPT_BUILDER_PROMPT = """
You are the PromptBuilderAgent ...
## Your Task:
Take the structured content plan: {content_planner_output} and create
optimized vertical image generation prompts for each scene ...
"""역할 요약 — PromptBuilder가 만든 최적화 프롬프트를 하나씩 돌며 OpenAI 이미지 모델을 호출해 세로 이미지를 만들고, 각 장면 이미지를 Artifact(scene_N_image.jpeg)로 저장한다. ImageBuilder는 generate_images tool을 가진 LlmAgent다.
# image_builder/tools.py (발췌)
async def generate_images(tool_context: ToolContext):
# state에서 최적화된 프롬프트를 가져온다
optimized_prompts = tool_context.state.get("prompt_builder_output").get("optimized_prompts")
# 이미 만든 이미지는 재생성하지 않도록 기존 아티팩트 확인 (멱등성)
existing_artifacts = await tool_context.list_artifacts()
for prompt in optimized_prompts:
filename = f"scene_{prompt['scene_id']}_image.jpeg"
if filename in existing_artifacts:
continue
image = client.images.generate(
model="gpt-image-1.5", prompt=prompt["enhanced_prompt"],
n=1, quality="low", output_format="jpeg", size="1024x1536",
)
image_bytes = base64.b64decode(image.data[0].b64_json) # base64 디코딩
artifact = types.Part(inline_data=types.Blob(mime_type="image/jpeg", data=image_bytes))
await tool_context.save_artifact(filename=filename, artifact=artifact)
...✅ 멱등성 팁 — 에이전트가 tool을 여러 번 호출할 수 있으므로,
list_artifacts()로 이미 생성된 이미지가 있으면 건너뛴다. 동일 이미지를 재생성·재과금하지 않기 위한 패턴이다.
역할 요약 — 콘텐츠 톤에 맞는 목소리(voice)를 선택하고(alloy·echo·fable·onyx·nova·shimmer), 각 장면 나레이션을 OpenAI TTS로 합성해 Artifact(scene_N_narration.mp3)로 저장한다. VoiceGenerator는 generate_narrations tool을 가진 LlmAgent이며, 프롬프트에서 {content_planner_output}을 참조해 장면별 텍스트·길이를 읽는다.
# voice_generator/tools.py (발췌)
async def generate_narrations(tool_context, voice, voice_instructions):
existing_artifacts = await tool_context.list_artifacts()
for instruction in voice_instructions:
filename = f"scene_{instruction['scene_id']}_narration.mp3"
if filename in existing_artifacts:
continue
with client.audio.speech.with_streaming_response.create(
model="gpt-4o-mini-tts", voice=voice,
input=instruction["input"], instructions=instruction["instructions"],
) as response:
audio_data = response.read()
artifact = types.Part(inline_data=types.Blob(mime_type="audio/mpeg", data=audio_data))
await tool_context.save_artifact(filename=filename, artifact=artifact)
...역할 요약 — 마지막 단계. state의 타이밍 정보와, 그동안 저장된 이미지·오디오 Artifact를 모두 불러와 FFmpeg로 1080x1920 세로 MP4를 만들고 youtube_short_final.mp4로 저장한다. VideoAssembler는 assemble_video tool을 가진 LlmAgent다.
💡 FFmpeg — 오디오·비디오를 변환·합성하는 사실상의 표준 도구다. 포맷 변환, 오디오 추출, 자막 삽입 등 영상 관련 대부분의 작업이 가능하다. (대신 명령어와 영상 지식이 필요하다.)
# video_assembler/tools.py (발췌)
async def assemble_video(tool_context: ToolContext) -> str:
# state에서 장면 타이밍을 읽고
scenes = tool_context.state.get("content_planner_output", {}).get("scenes", [])
# 저장된 모든 아티팩트(scene_N_image.jpeg / scene_N_narration.mp3)를 불러온다
existing_artifacts = await tool_context.list_artifacts()
...
image_artifact = await tool_context.load_artifact(filename=image_name)
...
# FFmpeg로 이미지+오디오를 장면 길이에 맞춰 합성 → 1080x1920 MP4
subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=True)
# 결과 영상을 최종 아티팩트로 저장
await tool_context.save_artifact(filename="youtube_short_final.mp4", artifact=artifact)💡 Callback은 OpenAI Agents SDK의 Hook과 비슷하다. 에이전트 실행 과정의 특정 시점(무언가 일어나기 전/후)에 끼어들어 무슨 일이 일어났는지 관찰하고, 기본 동작을 그대로 둘지 막을지 결정할 수 있게 해준다. ADK에는 OpenAI SDK의
input_guardrail같은 전용 기능이 없는데, 이 Callback으로 Guardrail을 직접 구현할 수 있다.
ADK 콜백은 에이전트 · 모델 · 도구 세 실행 지점마다 before / after 한 쌍씩, 총 6종으로 나뉜다.
| 지점 | Before (실행 전) | After (실행 후) | 주로 하는 일 |
|---|---|---|---|
| Agent | before_agent_callback | after_agent_callback | 에이전트 전체 실행을 감싸 진입/종료 제어 |
| Model | before_model_callback | after_model_callback | LLM 호출 직전 요청 검사·차단(입력 Guardrail), 직후 응답 검사·가공(출력 Guardrail) |
| Tool | before_tool_callback | after_tool_callback | 도구 호출 인자 검증·차단, 도구 결과 후처리 |
아래 다이어그램은 이 6개 콜백이 에이전트 실행 흐름 어디에 끼어드는지 보여준다.

callback이 어떻게 흐름에 끼어드는지 단계로 정리하면 다음과 같다.
None을 반환하면 "문제 없으니 정상 진행하라"는 뜻이라 기본 동작(실제 모델 호출 등)이 그대로 이어진다.💬 핵심은 반환 값이다.
before_model_callback이LlmResponse를 반환하면 ADK는 실제 LLM 호출을 건너뛰고 반환된 값을 진짜 모델 응답인 것처럼 처리한다. 바로 이 성질을 이용해 "위험한 입력이면 모델을 부르지 말고 차단 응답으로 대체"하는 입력 Guardrail을 만들 수 있다.
현재 프로젝트에서는 루트 에이전트에 before_model_callback을 달아 간단한 Guardrail을 구현했다. 특정 키워드가 들어오면 LLM을 호출하지 않고 곧바로 차단 응답을 돌려준다.
# agent.py
def before_model_callback(callback_context: CallbackContext, llm_request: LlmRequest):
last_message = llm_request.contents[-1]
if last_message.role == "user":
text = last_message.parts[0].text
if "hummus" in text: # 차단 키워드 예시
return LlmResponse(content=types.Content(
parts=[types.Part(text="Sorry I can't help with that.")], role="model",
))
return None # None이면 정상 동작(모델 호출 진행)
shorts_producer_agent = Agent(
name="ShortsProducerAgent",
...
before_model_callback=before_model_callback,
)✅ 검증 —
before_model_callback이LlmResponse를 반환하면 실제 LLM 호출을 건너뛰고 그 응답이 모델의 출력인 것처럼 처리된다.None을 반환하면 정상적으로 모델 호출이 진행된다. ADK에 별도 input guardrail 기능이 없어도 이 콜백으로 Guardrail을 만들 수 있다.