
음성 에이전트로 브라우저 제어하기
요약
OpenAI Realtime API와 Playwright를 결합하여 음성 명령으로 브라우저를 제어하는 에이전트 구축 가이드를 제공합니다. Vision Agents와 Stream을 활용해 실시간 음성 인터랙션과 웹 탐색 기능을 구현하는 방법을 다룹니다.
핵심 포인트
- OpenAI Realtime API를 통한 실시간 음성 입출력 구현
- Playwright를 활용한 브라우저 탐색, 클릭, 타이핑 자동화
- Vision Agents 프레임워크를 이용한 에이전트 제어
- Stream을 통한 WebRTC 기반 오디오 및 비디오 스트리밍
손이 묶여 있는 상황(운전 중, 요리 중, 또는 긴박한 리뷰를 위해 서성이는 중)에서 "어제 스프레드시트 열어서 그 숫자를 Slack에 붙여넣어 줘"라고 말하며, 상상 속의 클릭을 묘사하는 대신 실제 탭을 움직여 줄 무언가를 믿고 통화할 수 있기를 바란 적이 있나요? 화상 회의에서 이미 사용 중인 팀원을 떠올려 보세요. 문서 질문에 답하거나 스레드를 요약해 주는 그 팀원이, 당신이 로그인한 사이트를 열고, 보이는 내용을 훑어보고, 당신이 이름 붙인 컨트롤을 클릭하고, 당신이 가리킨 곳에 타이핑하며, 무언가 실패했을 때 솔직하게 말해줄 수 있다고 상상해 보세요.
이 가이드는 브라우저와 연결된 음성 참가자를 구축합니다. 실시간 모델(realtime model)을 통한 음성 입력(speech in) 및 음성 출력(speech out)을 지원하며, 모든 탐색(navigate), 읽기(read), 클릭(click), 타이핑(type)은 Playwright의 Python 함수를 통해 라우팅됩니다.
우리는 음성 에이전트 프레임워크로 Vision Agents를, WebRTC 오디오 및 비디오를 위해 Stream을, 모델이 음성으로 듣고 답변할 수 있도록 OpenAI Realtime을, 복제된 Chrome 인스턴스 내의 지속적인 세션을 위해 Playwright를 사용하며, 어시스턴트가 일반 텍스트만으로 셀렉터(selector)를 즉흥적으로 생성하지 않도록 open_chrome부터 run_browser_sequence까지 약 12개의 명시적인 도구(tools)를 연결합니다.
데모 (Demo)
사전 요구 사항 (Prerequisites)
구현을 시작하려면 다음 사항이 필요합니다:
목차 (Table Of Contents)
- 애플리케이션 설정
- 환경 변수 설정
- Stream 애플리케이션 생성
- OpenAI 설정
- Chrome 실행 파일 및 프로필 (선택 사항)
- 지침(Instructions)을 통한 에이전트 동작 정의
- 등록된 도구(Tools)로서 브라우저 작업 노출
- Playwright 세션 및 지속성 Chrome (Persistent Chrome)
- Chrome 실행, 탭 관리, 뒤로 가기 및 클릭
- 탭 라우팅, DOM 스크래핑, 선택적 도메인 필터
- 양식(Forms) 및 입력 필드 채우기
- run_browser_sequence를 이용한 다단계 배치 작업
애플리케이션 설정
다음 명령어를 실행하여 GitHub 저장소에서 코드를 클론(Clone)하세요:
git clone https://github.com/rishi-raj-jain/control-agent
uv sync
이제 프로젝트 루트 디렉토리에 .env 파일을 생성합니다. 아래 섹션에서 설명하는 항목들을 여기에 추가하게 됩니다.
환경 변수 설정
애플리케이션을 구성하려면 각 통합(Integration)에 필요한 환경 변수를 설정해야 합니다. 각 제공업체별로 다음 단계를 따르세요:
Stream 애플리케이션 생성
- Stream 대시보드로 이동합니다.
-
- Create an App을 선택합니다.
- 대화 상자에 애플리케이션 이름을 입력하고, 에지 서버(Edge-server) 위치를 위한 지역(Region)을 선택합니다.
- 생성 후, Your Credentials 항목 아래에서 API Key와 Secret을 확인합니다.
- 이 자격 증명(Credentials)을
.env파일에 다음과 같이 추가합니다:
STREAM_API_KEY="your-api-key"
STREAM_API_SECRET="your-api-secret"
OpenAI 설정
- OpenAI API Key 대시보드에 접속합니다.
-
- Create new secret key를 클릭합니다.
.env파일에 추가합니다:
OPENAI_API_KEY="your-openai-api-key"
귀하의 조직이 Realtime API와 코드에서 선택한 모델(gpt-realtime-1.5)을 사용할 수 있는지 확인하세요.
Chrome 실행 파일 및 프로필 (선택 사항)
대부분의 설정은 코드에 정의된 MacOS 기본값으로 작동합니다. Chrome이 표준이 아닌 경로에 있거나 특정 프로필 폴더를 복제하려는 경우에만 다음 설정을 조정하세요:
CHROME_PATH— Chrome 바이너리의 전체 경로 (기본 Apple Silicon macOS 번들 경로가 아닌 경우).CHROME_USER_DATA_DIR— Playwright가 시드(seed)를 가져올 소스 사용자 데이터(User Data) 디렉토리 (OS 기본 Chrome 프로필 디렉토리를 감지하려면 생략).CHROME_AGENT_USER_DATA_DIR— 에이전트 격리 프로필 루트 (~/.control-agent/chrome을 사용하려면 생략).CHROME_PROFILE_DIRECTORY— User Data 아래에서 읽어올 이름이 지정된 프로필 폴더 (Chrome의Local State파일에서 마지막으로 사용된 프로필을 추론하려면 생략).
필요한 경우 .env 파일에 추가하세요:
## Optional Chrome (sensible defaults를 사용하려면 생략)
# CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
...
이를 종합하면, 최소한의 .env 파일은 다음과 같은 모습이어야 합니다:
# .env
## Stream
...
환경 변수 설정은 이것으로 끝입니다. 이어지는 섹션에서는 Agent.instructions, 등록된 도구 인터페이스(tool surface), Playwright 기반의 Chrome 제어, 그리고 main.py의 구체적인 흐름을 살펴봅니다.
지침(instructions)을 통한 에이전트 동작 정의
Realtime 음성 모델은 대화에는 뛰어나지만, 브라우저 상태(state)는 전사(transcript)에 포함되지 않습니다. 단순히 "도움이 되어줘"라고만 말하면, 모델은 클릭 동작을 말로 설명하거나 탭을 닫는 것과 Chrome을 종료하는 것을 혼동할 수 있습니다. Agent에 정교한 instructions를 제공하면 그러한 모호한 의도를 반복 가능한 단계로 바꿀 수 있습니다. 예를 들어, 항상 open_chrome으로 먼저 연결하고, 화면에 무엇이 있는지 주장하기 전에 read_page를 사용하며, go_back은 (탭 전환이 아닌) 히스토리용으로 남겨두고, 사용자가 짧은 워크플로우를 요청할 때는 run_browser_sequence로 단계를 일괄 처리하도록 설정하는 식입니다.
아래 Agent(..., instructions=...)에 전달되는 문자열은 도구(tools)의 이름을 지정하고, close_tab과 close_chrome을 구분하며, 모델에게 구어체로 말하는 사이트 이름을 URL로 정규화(normalize)하도록 지시합니다. 또한, 스키마를 즉석에서 임의로 생성하지 않도록 다단계 흐름(multi-step flows)을 위한 복사-붙여넣기 가능한 JSON 예시를 포함합니다.
async def create_agent(kwargs) -> Agent:
agent = Agent(
edge=getstream.Edge(),
...
이제 다음 섹션으로 넘어가서 AI 에이전트가 브라우저를 어떻게 제어하는지 알아보겠습니다.
브라우저 작업을 등록된 도구로 노출하기
흔히 빠지는 함정은 STT(Speech-to-Text) → LLM(Large Language Model) → TTS(Text-to-Speech) 과정에서 모델이 무엇을 클릭할지 '설명'만 하는 방식입니다. 도구 호출(Tool calling)은 이를 뒤집어, 모델이 인자(label, url, 선택 사항인 domain)를 포함한 이름이 지정된 작업(named operations)을 출력하게 합니다. 그러면 사용자의 Python 코드가 Playwright를 실행하고, 반환된 문자열은 모델이 다음에 할 말(성공, 오류, 또는 레이블이 모호할 때의 "가장 유사한 일치 항목")에 대한 모델의 실제 근거(ground truth)가 됩니다.
| 단계 | 책임 |
|---|---|
| 1 | 사용자가 말하면, Stream이 오디오를 전달하고 OpenAI Realtime이 음성 이해 및 응답을 처리합니다. |
| ... |
Vision Agents는 @agent.llm.register_function(모델을 위한 라우팅 힌트)과 BrowserController에 위임하는 얇은 비동기 래퍼(async wrappers)를 사용하여 이러한 함수들을 에이전트에 노출합니다:
@agent.llm.register_function(
description="Launch a dedicated Chrome window for the agent with the default profile and connect to it."
)
...
이제 다음 섹션으로 넘어가서 AI 에이전트에게 Chrome 제어 권한을 실제로 부여하는 핵심 요소, 즉 Playwright에 대해 알아보겠습니다.
Playwright 세션 및 지속성 있는 Chrome
Playwright는 Vision Agents의 도구 핸들러 (tool handlers)와 일치하는 비동기 API (async APIs)를 사용하여 실제 Chromium 세션을 구동하며, 취약한 "픽셀 (x,y) 클릭" 방식의 해킹 대신 page.goto, go_back, get_by_role, get_by_label, fill, click과 같은 의미론적 로케이터 (semantic locators)를 노출합니다. 여기서 Chromium은 launch_persistent_context를 통해 실행되며, 이는 에이전트 전용 사용자 데이터 디렉토리(기본적으로 ~/.control-agent/chrome 아래)를 사용하여 쿠키가 실행 간에도 유지되도록 합니다. 이는 파일 앞부분에 정의된 사용자 Chrome 프로필로부터 선택적인 파일들을 복사하는 방식(_prepare_agent_profile, _ensure_agent_local_state, PROFILE_SYNC_FILES)으로 초기화됩니다.
BrowserController는 하나의 BrowserContext와 asyncio.Lock을 소유하므로, 동시적인 실시간 도구 호출이 Chrome을 중복 실행하는 것을 방지하며, 핸들 (handle)이 만료되었을 때 _ensure_connected가 다시 엽니다.
async def _launch_agent_chrome(self) -> str:
await self._close_agent_chrome()
...
이제 탭의 열기 및 닫기와 같이 브라우저가 어떻게 조작되는지 이해하기 위해 다음 섹션으로 넘어가겠습니다.
Chrome 실행, 탭 관리, 뒤로 가기 및 클릭
열기/닫기 작업은 컨트롤러 락 (controller lock) 하에서 실행되며, 기존 컨텍스트 (context)를 확인하거나 _launch_agent_chrome을 호출하는 방식, 또는 BrowserContext를 닫는 방식으로 이루어집니다.
async def open_chrome(self) -> str:
async with self._lock:
if self._is_agent_chrome_connected():
...
새 탭은 context.new_page()를 통해 생성되며, domcontentloaded와 함께 goto를 수행하고 bring_to_front를 호출하여 사람이 기대하는 포커스된 화면과 일치하도록 합니다.
async def open_tab(self, url: str) -> str:
try:
target = _normalize_url(url)
...
go_back은 탭을 전환하는 것이 아니라 탭별 브라우저 히스토리 (browser history)를 따르며, 이는 switch_tab과 대조되는 지침과 일치합니다.
async def go_back(self, domain: str | None = None) -> str:
try:
page = await self._page_for(domain)
...
click_on_page는 페이지 내에서 수집된 가시적인 레이블(visible labels)을 화자가 말한 내용과 비교하여 점수를 매기며(_score_label_match + CLICK_MATCH_THRESHOLD 가드레일 사용), 그 후 _click_target을 호출합니다. _click_target은 get_by_role (button, link, …)을 우선적으로 사용하며, 실패 시 get_by_text로 대체(fallback)합니다.
async def _click_target(self, page: Page, text: str) -> None:
for role in ("button", "link", "menuitem", "tab", "option"):
locator = page.get_by_role(role, name=re.compile(re.escape(text), re.IGNORECASE))
...
async def click_on_page(self, label: str, domain: str | None = None) -> str:
try:
page = await self._page_for(domain)
...
탭 라우팅(Tab routing), DOM 스크래핑(scraping the DOM), 선택적 도메인 필터
선택적 domain 인자는 read_page, click_on_page, type_into_field, go_back 함수를 _page_for를 통해 필터링합니다. 열려 있는 탭 중에서 URL이 _domain_matches를 만족하는 마지막 탭을 선택함으로써, Gmail과 Slack이 모두 열려 있을 때 발생할 수 있는 "잘못된 탭" 선택 실수를 제한합니다.
async def _page_for(self, domain: str | None = None) -> Page:
pages = await self._pages()
if not pages:
...
switch_tab은 go_back과는 독립적으로(orthogonal), 어떤 탭에 포커스를 둘지 전환합니다 (next, previous, 숫자 인덱스, 또는 도메인/제목 일치).
async def switch_tab(self, target: str) -> str:
pages = await self._pages()
if not pages:
return "No open tabs were found."
cleaned = target.strip().lower()
if cleaned in {"next", "forward"}:
current = pages[-1]
index = pages.index(current)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기