logo
홈블로그소개
4,093

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
AIPython

Google ADK 멀티 에이전트: Workflow Agent · 구조화 출력 · Callback으로 만드는 YouTube Shorts 메이커

Toma
2026년 6월 26일
약 24분
목차
🏗️ 전체 아키텍처
🧩 에이전트 한눈에 보기 (역할 · 지침 요약)
🔄 Workflow Agent — 흐름을 제어하는 "함수" 에이전트
📝 Content Planner — 구조화된 스크립트 생성 (output_schema)
🎨 Prompt Builder — state를 프롬프트에 주입하기
🖼️ Image Builder — 이미지 생성 + Artifact 저장
🔊 Audio Narration — TTS 음성 생성
🎞️ Video Assembly — FFmpeg로 최종 영상 합성
🪝 Callbacks — Guardrail 구현하기
🗂️ 콜백 종류 — 3개 지점 × 전/후
⚙️ 동작 방식 — return 값이 곧 결과
이전 포스트OpenAI Agents SDK 실전: Context · Guardrails · Handoff로 고객지원 에이전트 만들기

목차

🏗️ 전체 아키텍처
🧩 에이전트 한눈에 보기 (역할 · 지침 요약)
🔄 Workflow Agent — 흐름을 제어하는 "함수" 에이전트
📝 Content Planner — 구조화된 스크립트 생성 (output_schema)
🎨 Prompt Builder — state를 프롬프트에 주입하기
🖼️ Image Builder — 이미지 생성 + Artifact 저장
🔊 Audio Narration — TTS 음성 생성
🎞️ Video Assembly — FFmpeg로 최종 영상 합성
🪝 Callbacks — Guardrail 구현하기
🗂️ 콜백 종류 — 3개 지점 × 전/후
⚙️ 동작 방식 — return 값이 곧 결과

🎬 이 글은 Google ADK로 YouTube Shorts를 생성하는 멀티 에이전트 시스템을 만들며 익힌 개념 — Workflow Agent(Sequential · Parallel · Loop), 구조화 출력(output_schema), State · Artifacts, Callback 기반 Guardrail — 을 정리한 기록입니다.


🏗️ 전체 아키텍처

이 시스템은 8개의 에이전트가 계층적으로 협력한다. 최상위 ShortsProducerAgent가 세 단계(기획 → 에셋 생성 → 영상 합성)를 조율하고, 각 단계는 다시 Workflow Agent(Parallel · Sequential)와 Tool을 가진 LlmAgent로 구성된다.

mermaid
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를 읽어 작업을 이어간다.

🧩 에이전트 한눈에 보기 (역할 · 지침 요약)

에이전트타입역할 · 지침 요약
ShortsProducerLlmAgent (오케스트레이터)사용자 요구사항을 수집하고, 5단계 워크플로우로 3개 서브에이전트를 순서대로(ContentPlanner → AssetGenerator → VideoAssembler) 조율. 진행상황 보고 후 최종 세로 MP4 전달
ContentPlannerLlmAgent (output_schema)주제를 받아 최대 20초 스크립트(JSON) 생성. 3~6개 장면의 나레이션·비주얼 설명·오버레이 텍스트(+위치)·길이를 구조화
AssetGeneratorParallelAgent이미지 생성과 음성 생성을 동시 병렬 실행. ContentPlanner 완료 후에만 사용
ImageGeneratorSequentialAgentPromptBuilder → ImageBuilder를 순차 실행
PromptBuilderLlmAgent (output_schema)visual_description을 GPT-Image-1용 최적화 프롬프트로 변환(9:16·1080x1920·텍스트 오버레이·스타일 일관성)
ImageBuilderLlmAgent + tool프롬프트마다 OpenAI 이미지 모델 호출 → scene_N_image.jpeg 아티팩트 저장
VoiceGeneratorLlmAgent + tool콘텐츠 톤에 맞는 voice 선택 후 OpenAI TTS 호출 → scene_N_narration.mp3 아티팩트 저장
VideoAssemblerLlmAgent + toolFFmpeg로 이미지+오디오를 1080x1920 MP4로 합성 → youtube_short_final.mp4 아티팩트

🔄 Workflow Agent — 흐름을 제어하는 "함수" 에이전트

💡 Workflow Agent는 LLM이 아니다. Sequential·Parallel·Loop 세 종류가 있으며, AI 모델 없이 서브에이전트의 실행 순서만 결정론적으로 제어하는 함수에 가깝다. (LLM Agent는 모델이 도구 호출·응답을 스스로 판단하는 것과 대조적)

workflow 에이전트는 서브에이전트들의 실행 흐름을 제어할 수 있게 도와준다.

workflow agent의 예시는 아래와 같다.

  1. sequential agent : 서브에이전트들을 순차적으로 실행하는 에이전트(사실 함수)
  2. loop agent : 서브 에이전트들을 반복해서 실행하는 에이전트. 조건을 만족하거나 max_iteration에 도달하기 전까지 반복.
  3. parallel agent : 서브 에이전트 목록을 받아서 서브 에이전트들을 병렬로 실행.

본 프로젝트에서도 두 가지를 실제로 쓴다. 에셋 생성은 이미지 생성과 음성 생성을 병렬로(ParallelAgent) 돌리고, 그 안의 이미지 생성은 프롬프트 최적화 → 이미지 생성 순서로(SequentialAgent) 돈다.

python
# 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],
)
python
# 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로 묶었다. 각 단계가 단순해져 디버깅·재사용이 쉬워진다.

📝 Content Planner — 구조화된 스크립트 생성 (output_schema)

역할 요약 — 주제를 받아 최대 20초 분량의 YouTube Shorts 스크립트를 만든다. 3~6개 장면으로 나누고, 각 장면마다 나레이션·비주얼 설명·오버레이 텍스트(+위치)·길이를 정해 JSON으로 출력한다.

여기서 핵심은 **output_schema**다.

Pydantic BaseModel로 출력 구조를 정의하면 에이전트가 그 형식의 JSON만 내도록 강제된다. 결과는 output_key로 state(content_planner_output)에 저장되어 뒤 단계가 참조한다.

python
# 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이 없다.

🎨 Prompt Builder — state를 프롬프트에 주입하기

역할 요약 — 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로 저장한 결과를 다음 에이전트가 프롬프트에서 그대로 끌어다 쓰는 방식이다.

python
# 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 ...
"""

🖼️ Image Builder — 이미지 생성 + Artifact 저장

역할 요약 — PromptBuilder가 만든 최적화 프롬프트를 하나씩 돌며 OpenAI 이미지 모델을 호출해 세로 이미지를 만들고, 각 장면 이미지를 Artifact(scene_N_image.jpeg)로 저장한다. ImageBuilder는 generate_images tool을 가진 LlmAgent다.

python
# 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()로 이미 생성된 이미지가 있으면 건너뛴다. 동일 이미지를 재생성·재과금하지 않기 위한 패턴이다.

🔊 Audio Narration — TTS 음성 생성

역할 요약 — 콘텐츠 톤에 맞는 목소리(voice)를 선택하고(alloy·echo·fable·onyx·nova·shimmer), 각 장면 나레이션을 OpenAI TTS로 합성해 Artifact(scene_N_narration.mp3)로 저장한다. VoiceGenerator는 generate_narrations tool을 가진 LlmAgent이며, 프롬프트에서 {content_planner_output}을 참조해 장면별 텍스트·길이를 읽는다.

python
# 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)
    ...

🎞️ Video Assembly — FFmpeg로 최종 영상 합성

역할 요약 — 마지막 단계. state의 타이밍 정보와, 그동안 저장된 이미지·오디오 Artifact를 모두 불러와 FFmpeg로 1080x1920 세로 MP4를 만들고 youtube_short_final.mp4로 저장한다. VideoAssembler는 assemble_video tool을 가진 LlmAgent다.

💡 FFmpeg — 오디오·비디오를 변환·합성하는 사실상의 표준 도구다. 포맷 변환, 오디오 추출, 자막 삽입 등 영상 관련 대부분의 작업이 가능하다. (대신 명령어와 영상 지식이 필요하다.)

python
# 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)

🪝 Callbacks — Guardrail 구현하기

💡 Callback은 OpenAI Agents SDK의 Hook과 비슷하다. 에이전트 실행 과정의 특정 시점(무언가 일어나기 전/후)에 끼어들어 무슨 일이 일어났는지 관찰하고, 기본 동작을 그대로 둘지 막을지 결정할 수 있게 해준다. ADK에는 OpenAI SDK의 input_guardrail 같은 전용 기능이 없는데, 이 Callback으로 Guardrail을 직접 구현할 수 있다.

🗂️ 콜백 종류 — 3개 지점 × 전/후

ADK 콜백은 에이전트 · 모델 · 도구 세 실행 지점마다 before / after 한 쌍씩, 총 6종으로 나뉜다.

지점Before (실행 전)After (실행 후)주로 하는 일
Agentbefore_agent_callbackafter_agent_callback에이전트 전체 실행을 감싸 진입/종료 제어
Modelbefore_model_callbackafter_model_callbackLLM 호출 직전 요청 검사·차단(입력 Guardrail), 직후 응답 검사·가공(출력 Guardrail)
Toolbefore_tool_callbackafter_tool_callback도구 호출 인자 검증·차단, 도구 결과 후처리

아래 다이어그램은 이 6개 콜백이 에이전트 실행 흐름 어디에 끼어드는지 보여준다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-06-28_15.59.20.png

⚙️ 동작 방식 — return 값이 곧 결과

callback이 어떻게 흐름에 끼어드는지 단계로 정리하면 다음과 같다.

  1. 이벤트(에이전트·모델·도구 호출 전/후)가 발생하면 callback이 그 지점에 intercept(끼어듦)한다.
  2. callback은 직전·직후에 무슨 일이 일어나는지 관찰하고 앞으로 일어날 기본 동작을 계속할지 막을지 결정한다.
  3. callback이 반환하는 값이 곧 그 단계의 결과가 된다 — 프레임워크가 마치 에이전트(또는 모델)가 직접 반환한 것처럼 처리한다.
  4. None을 반환하면 "문제 없으니 정상 진행하라"는 뜻이라 기본 동작(실제 모델 호출 등)이 그대로 이어진다.
  5. 값을 반환하면 기본 동작을 건너뛰고(override) 그 값으로 대체한다.

💬 핵심은 반환 값이다. before_model_callback이 LlmResponse를 반환하면 ADK는 실제 LLM 호출을 건너뛰고 반환된 값을 진짜 모델 응답인 것처럼 처리한다. 바로 이 성질을 이용해 "위험한 입력이면 모델을 부르지 말고 차단 응답으로 대체"하는 입력 Guardrail을 만들 수 있다.

현재 프로젝트에서는 루트 에이전트에 before_model_callback을 달아 간단한 Guardrail을 구현했다. 특정 키워드가 들어오면 LLM을 호출하지 않고 곧바로 차단 응답을 돌려준다.

python
# 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을 만들 수 있다.