무엇을 만드나요
레퍼런스 구현은 모듈마다 하나의 역할을 가진 작은 Python 패키지입니다:
하나의 질문은 다음과 같이 흘러갑니다:
- 모델을 고정하지 않았다면, 현재 함수 호출 모델을 Venice에 요청합니다.
- Apify MCP 서버에 연결하고 도구 목록을 가져옵니다.
- 그 MCP 도구들을 OpenAI 호환 함수 정의로 다시 씁니다.
- 도구 목록을 첨부해 질문을 보냅니다.
- 모델이
tool_calls를 반환하면 Apify에 대해 실행하고 결과를tool메시지로 덧붙입니다. - 모델이 도구 호출 대신 텍스트로 답할 때까지 반복합니다.
이 에이전트는 여러분의 계정에서 Apify 컴퓨팅을 소비할 수 있습니다. 검색과 문서 도구만 원한다면
APIFY_TOKEN 없이 시작하고, 실제로 Actor를 실행할 의도가 생기기 전까지는 --yes를 켜지 마세요.프로젝트 설정하기
레퍼런스 프로젝트는 Python 3.12 이상과 uv를 사용합니다. 새 프로젝트를 만듭니다:httpx2는 httpx의 2.x 라인으로, openai와 mcp가 이미 의존하고 있습니다. 직접 설치하면 같은 환경에 HTTP 클라이언트가 두 개 생기는 일을 피할 수 있습니다.
그다음 .env 파일을 만듭니다:
VENICE_API_KEY는 Venice API 설정에서 가져옵니다. APIFY_TOKEN은 Apify Console에서 가져오며 선택 사항입니다 — 토큰 없이 무엇을 쓸 수 있는지는 잠시 뒤에 다룹니다.
설정 불러오기
다른 모든 모듈이 설정을 인자로 받기 때문에 설정이 가장 먼저입니다.pydantic-settings를 사용해 환경 변수, .env, CLI 플래그가 모두 하나의 검증된 객체에 담기게 합니다.
src/venice_terminal_agent/config.py에서 Settings(BaseSettings) 클래스가 중요한 필드를 담습니다:
venice_model은 모델 ID가 아닌 None인데, 그 이유는 다음 섹션에서 다시 다룹니다. 그리고 max_rounds와 max_tool_result_chars는 에이전트가 폭주하는 것을 막는 한계입니다. 첫 번째는 하나의 질문이 사용할 수 있는 도구 라운드 수를 제한하고, 두 번째는 스크래핑한 페이지 중 얼마만큼이 컨텍스트로 다시 들어가는지를 제한합니다.
이 모듈에서 흥미로운 함수는 URL 빌더입니다:
tools 쿼리 파라미터를 받습니다. APIFY_TOKEN이 없으면 인증 없이 작동하는 익명 도구 네 가지 — Actor 검색, Actor 상세 정보, 문서 검색, 문서 가져오기 — 를 요청합니다. 덕분에 누구든 프로젝트를 클론해 Venice 키만 추가해도 Apify Actor를 조사할 수 있는 작동하는 에이전트를 얻습니다. 다만 실행은 할 수 없을 뿐입니다.
Venice와 대화하기
Venice는 OpenAI 호환이므로 채팅 완성에는 OpenAI SDK를, 모델 탐색 호출에는 순수httpx를 사용할 수 있습니다.
src/venice_terminal_agent/venice.py를 만듭니다:
AsyncOpenAI는 스트리밍 헬퍼와 타입이 지정된 tool_calls를 공짜로 제공합니다. 순수 httpx 클라이언트는 OpenAI SDK가 알지 못하는 Venice 엔드포인트를 위한 것으로, 이 프로젝트에서는 /models/traits가 그것입니다.
채팅 타임아웃이 탐색 타임아웃보다 훨씬 긴 것은 의도적입니다. 웹 크롤링을 유발하는 질문은 정당하게 몇 분이 걸릴 수 있습니다.
런타임에 모델 찾기
Venice 모델 ID는 교체되며, 하나를 하드코딩하는 것은 한 달 안에 망가지는 에이전트를 배포하는 가장 빠른 길입니다.GET /models/traits는 안정적인 특성 이름을 현재 그 역할을 맡은 모델에 매핑하므로, 모델을 지명하는 대신 function_calling_default를 요청합니다:
--model 플래그가 이기고, 그다음이 환경 변수의 VENICE_MODEL, 그다음이 특성 조회입니다. 따라서 기본 경로는 아무 설정도 필요 없지만, 두 모델의 동작을 비교할 때는 여전히 모델을 고정할 수 있습니다.
완성 스트리밍하기
이제 완성 호출을 추가합니다:stream() 컨텍스트 매니저가 둘 다 처리합니다. content.delta 이벤트가 터미널 출력을 이끌고, get_final_completion()이 tool_calls가 이미 이어 붙여진 완전한 메시지를 돌려줍니다.
요청 자체는 테스트하기 쉽도록 별도 함수에서 만듭니다:
extra_body는 OpenAI SDK가 자신이 모델링하지 않는 필드를 통과시키는 방법이며, venice_parameters가 여기에 들어갑니다. include_venice_system_prompt를 false로 설정하면 Venice의 기본 어시스턴트 프롬프트가 대화에서 빠지므로, 우리 시스템 프롬프트가 모델이 받는 유일한 지침이 됩니다. 엄격한 도구 규칙을 가진 에이전트라면 그것이 원하는 바입니다.
tools와 tool_choice는 도구가 하나 이상 있을 때만 첨부하세요. 빈 tools 배열을 보내는 것은 모델을 쓸데없이 혼란스럽게 만드는 방법입니다.
이 모듈에는 APIStatusError나 httpx2.HTTPStatusError를 상태 코드와 응답 본문을 담은 한 줄 문자열로 바꾸는 format_http_error() 헬퍼도 있습니다. 에이전트는 다른 어디보다 API 경계에서 자주 실패하며, 거기서 읽기 좋은 메시지 하나가 많은 추측을 아껴줍니다.
MCP 도구를 Venice 도구로 변환하기
MCP 도구와 OpenAI 스타일 함수 도구는 같은 것을 다른 모양으로 기술합니다. 둘 다 이름, 설명, 인자에 대한 JSON Schema를 가집니다. 변환은 대부분 기계적이지만 한 가지 함정이 있습니다. Apify 도구 이름에는 함수 이름에 허용되지 않는 문자가 포함됩니다. Actor 도구는apify/rag-web-browser 같은 이름을 가질 수 있는데, 그 슬래시는 유효하지 않습니다.
그래서 나가는 길에 이름을 정제하고, 돌아오는 길에 복원할 수 있도록 매핑을 유지합니다.
src/venice_terminal_agent/tools.py에서 ToolCatalog가 변환을 수행하고 매핑을 보관합니다:
sanitize_tool_name()은 허용되지 않는 문자를 하이픈으로 바꾸고, 숫자로 시작하는 이름에 접두사를 붙이고, 64자로 자릅니다. unique_name()은 그 잘라내기 때문에 두 Actor가 충돌하면 숫자 접미사를 붙입니다 — 모델이 한 Actor를 호출했는데 다른 Actor가 실행되는, 정말 혼란스러운 버그를 막아줍니다. tool_input_schema()는 MCP 서버가 dict, Pydantic 모델, 또는 아무것도 아닌 것을 돌려주는 경우를 모두 처리합니다.
결과를 컨텍스트로 되돌려 포맷하기
도구 결과는 대화에 곧바로 들어가므로 문자열이어야 하고, 크기 제한이 필요합니다. 문서 사이트를 스크래핑하면 컨텍스트 윈도우가 담을 수 있는 것보다 많은 텍스트가 쉽게 돌아옵니다.format_tool_result()는 서버가 제공하면 structured_content를 우선하고, 아니면 콘텐츠 블록을 텍스트로 평탄화하면서 TextContent가 아닌 블록도 처리합니다. 마지막은 중요한 두 줄로 끝납니다:
{"error": "..."}로 감쌉니다. 실패한 도구 호출은 모델이 행동할 수 있는 정보입니다 — 다른 Actor를 고르거나 인자를 고칠 수 있는데, 실패가 일반 도구 결과로 전달될 때만 그렇게 할 수 있습니다.
비용이 드는 도구 표시하기
Apify 도구는 두 그룹으로 깔끔하게 나뉩니다. 메타데이터와 문서를 읽는 도구, 그리고 컴퓨팅을 시작하는 도구입니다. 두 번째 그룹에는 확인을 요구하고 싶으므로, 첫 번째 그룹을 허용 목록에 넣습니다:MCP로 Apify에 연결하기
Apify는 두 가지 진입로를 제공합니다.https://mcp.apify.com의 호스팅 서버는 Streamable HTTP를 사용하고, @apify/actors-mcp-server는 npx를 통해 stdio로 로컬에서 실행됩니다. 두 방식은 서로 다른 상황에 맞으므로 둘 다 지원합니다. 호스팅 방식은 Node.js가 필요 없고, stdio는 연결을 여러분의 머신 안에 유지합니다.
src/venice_terminal_agent/apify_mcp.py에서 ApifyMcp 클래스가 연결된 세션을 감쌉니다. call_tool()이 정제된 이름을 다시 되돌리는 곳입니다 — Venice는 apify-rag-web-browser를 보내고, Apify는 apify/rag-web-browser를 받습니다:
client.list_tools()에 대한 커서 루프가 필요합니다. 많은 Actor에 접근할 수 있는 토큰은 페이지네이션된 목록을 만들어 내기 때문입니다.
전송 소유하기
MCP 연결은 수명이 긴 비동기 리소스이고, 그 아래의 HTTP 클라이언트도 마찬가지입니다.ApifyMcpSession 비동기 컨텍스트 매니저가 둘 다 AsyncExitStack에 담고, 설정에 따라 전송을 고르고, 카탈로그를 불러옵니다. 따라 할 만한 디테일은 정리(cleanup)입니다:
except BaseException은 보기보다 중요합니다. 전송이 열린 뒤 도구 목록 가져오기가 실패하면, 이것 없이는 에이전트가 시작에 실패할 때마다 서브프로세스나 열린 소켓이 누수됩니다.
두 가지 전송은 다음과 같습니다:
APIFY_TOKEN만 받는다는 점도 눈여겨보세요. 여러분의 셸 환경 전체 — Venice 키를 포함해 — 를 받지 않습니다.
도구 호출 실행하기
이 모듈의 마지막 조각인execute_venice_tool_call()은 Venice 도구 호출을 문자열 결과로 바꿉니다. 두 종류의 실패 — 파싱할 수 없는 인자와 실패한 Apify 호출 — 를 예외로 던지는 대신 {"error": "..."}로 감쌉니다:
{"error": "invalid arguments: ..."}를 건네면 다음 라운드에서 수정된 호출을 얻지만, 예외를 던지면 세션이 죽고 대화를 잃습니다.
도구 루프 실행하기
이제src/venice_terminal_agent/agent.py의 에이전트 본체입니다. 시스템 프롬프트부터 시작합니다:
search-actors와 fetch-actor-details를 우선하라”는 규칙은 Actor의 입력 스키마를 추측하는 모델이 유료 실행을 낭비하기 때문에 존재합니다. 거절된 도구에 대한 줄은, 그것이 없으면 모델이 거절을 일시적 오류로 취급하고 곧바로 다시 시도하기 때문에 존재합니다.
Agent 클래스는 두 클라이언트, 모델, 라운드 제한, 그리고 세 개의 콜백을 받습니다:
on_tool은 도구 호출을 보고하고, on_text는 스트리밍 토큰을 받고, approve_tool은 확인 질문에 답합니다. 이것들만 갈아 끼우면 같은 에이전트가 웹 앱이나 챗봇 뒤에서도 작동합니다.
루프는 다음과 같습니다:
start 인덱스와 예외 핸들러 안의 del은 자세히 볼 가치가 있습니다. 질문이 중간에 실패하면 — 네트워크 오류, Ctrl+C, 라운드 제한 — 대화에는 결과를 만들어 내지 못한 도구를 요청한 어시스턴트 턴이 남습니다. tool_calls 턴 뒤에는 대응하는 tool 메시지가 와야 하므로 Venice는 다음 요청을 거부합니다. 질문이 시작된 지점까지 되돌리면 실패한 질문이 흔적을 남기지 않고 REPL을 계속 쓸 수 있습니다.
어시스턴트 턴 되돌려 보내기
다음 함수는 작지만 틀리기 쉽습니다:message.model_dump(exclude_none=True)인데, 이것은 도구 호출을 망가뜨립니다. 도구 호출 턴은 content: null을 가지며, 그 키를 제거하면 되돌려 보내는 메시지의 형태가 바뀝니다. 원하는 것은 exclude_unset=True입니다. 모델이 실제로 설정한 null 값은 유지하고, 모델이 보낸 적 없는 필드는 생략합니다.
이 방식은 OpenAI 스키마가 알지 못하는 필드도 보존합니다. 추론 모델은 reasoning_content와 reasoning_details를 반환하며, 이 필드들이 왕복에서 살아남아야 모델이 도구 라운드를 넘어 자신의 사고 흐름을 유지합니다.
호출 실행과 통제
모델은 한 턴에 여러 도구를 요청할 수 있고, 이를 하나씩 실행할 이유는 없습니다. 하지만 승인 요청은 순차적으로 하고 싶습니다. 확인 프롬프트가 뒤섞이면 읽을 수 없기 때문입니다. 그래서 먼저 계획하고, 그다음에 동시에 실행합니다:tool 메시지를 받습니다. 모든 tool_call_id에는 응답이 필요하고, 하나라도 건너뛰면 대화가 잘못된 형태가 됩니다. 그 응답이 사용자가 거절했다고 설명할 뿐입니다.
승인 검사 자체는 두 이름을 모두 확인합니다. 모델은 정제된 이름으로 작업하고 허용 목록은 MCP 이름을 사용하기 때문입니다:
CLI 추가하기
src/venice_terminal_agent/cli.py의 CLI는 Typer에 REPL을 더한 것이고, 이 프로젝트에서 가장 밋밋한 파일이지만 — 따라 할 가치가 있는 디테일이 세 가지 있습니다.
첫 번째는 Typer 옵션이 optional 타입에 기본값 None으로 선언되어, 설정 로더가 “전달되지 않음”과 “거짓 값이 전달됨”을 구분할 수 있다는 점입니다:
None 기본값이 load_settings()로 넘기는 과정을 안전하게 만듭니다. 사용하지 않은 플래그는 결코 환경 변수를 덮어쓰지 않습니다:
yes or None은 같은 아이디어를 불리언 플래그에 적용한 것입니다. --yes는 값을 설정하고, 생략하면 False가 아닌 None을 전달하므로 환경 변수의 AUTO_APPROVE_TOOLS가 살아남습니다.
두 번째는 시작 순서입니다. 모델을 결정하고, MCP 세션을 열고, 에이전트를 만듭니다 — 그리고 Venice 클라이언트는 finally에서 닫습니다. 질문의 성공 여부와 관계없이 MCP 세션과 HTTP 클라이언트 모두 정리가 필요하기 때문입니다:
isatty() 검사는 사람들이 잊는 부분입니다. cron이나 CI에서 에이전트를 실행하면 프롬프트에 답할 사람이 없으므로, 순진한 구현은 영원히 멈춰 있거나 조용히 승인해 버립니다. 여기서는 거절하고, 이유를 말하고, 모델이 읽기 전용 도구로 계속 진행하게 합니다. default=False 덕분에 실수로 누른 Enter가 유료 실행을 시작하지 않고, 프롬프트를 중단하는 것도 거절로 처리됩니다.
모듈의 나머지는 평범한 터미널 작업이므로 읽기보다는 무엇이 있는지 알아두면 됩니다. prompt_toolkit REPL 루프, 슬래시 명령어를 위한 _handle_command() 조회, Rich 헬퍼를 담은 render.py, 그리고 누락된 VENICE_API_KEY를 Pydantic 트레이스백 대신 읽기 좋은 메시지로 바꾸는 _settings_error()가 있습니다. 그중 셋은 결정을 담고 있습니다:
슬래시 명령어는
/help, /clear, /quit, 그리고 제 몫을 하는 둘입니다. /tools는 불러온 카탈로그를 출력하는데, 에이전트가 이상한 도구를 고른 이유를 대개 설명해 주며, /reload는 세션 도중 Apify 계정에 추가한 Actor를 다시 불러옵니다.
마지막으로 uv run venice-agent가 작동하도록 pyproject.toml에서 진입점을 연결합니다:
에이전트 실행하기
인터랙티브 세션을 시작합니다:APIFY_TOKEN이 로드되지 않은 것이며, 에이전트가 왜 Actor 실행을 거부하는지 10분간 고민한 뒤보다는 지금 알아차리는 편이 훨씬 낫습니다.
무엇이 필요한지 알 때는 도구 카탈로그를 제한하세요:
--tools는 선택지를 좁히는 가장 값싼 방법입니다.
호스팅 서버 대신 MCP 서버를 로컬에서 실행하려면:
npx를 통해 @apify/actors-mcp-server를 실행하므로 PATH에 Node.js가 필요하고, APIFY_TOKEN도 필요합니다 — 로컬 서버에는 익명 모드가 없습니다.
그리고 정말로 무인 Actor 실행을 원할 때는:
조각별로 테스트하기
여기서 흥미로운 로직은 어느 것도 네트워크가 필요 없습니다. 미리 정해 둔 응답 목록에서 하나씩 꺼내는FakeVenice와 SimpleNamespace 도구로 실제 ToolCatalog를 만드는 FakeApify만 있으면 완전한 도구 라운드를 구동하기에 충분합니다:
agent.messages를 검증합니다. Venice 오류로 실패했든 max_rounds를 소진했든, 실패한 실행이 히스토리를 ["system"]만 남도록 되돌리는지. 승인자가 False를 반환해도 읽기 전용 도구는 여전히 실행되는지. 그리고 거절된 유료 도구가 declined를 담은 tool 메시지를 남기고 apify.calls는 비어 있는지.
테스트 스위트는 다음으로 실행합니다:
프라이버시와 비용에 관한 메모
두 API에 접근하는 에이전트라면 정확히 짚어 둘 가치가 있습니다:
Venice의 데이터 무보관은 모델 측을 다룹니다. Apify는 다루지 않으며, Actor 실행은 결과를 여러분의 Apify 계정에 기록합니다. 특정 작업에서 그것이 문제가 된다면,
APIFY_TOKEN 없이 실행하고 익명 탐색 도구만 사용하세요.
비용에 관해서는 세 가지 습관이 큰 도움이 됩니다:
- 개발 중에는
--yes를 끄세요. 모델이 어떤 Actor를 실행하고 싶어 하는지 지켜보는 것 자체가 유익합니다. --tools로 카탈로그를 실제로 검토한 Actor로 좁히세요.max_rounds를 적당히 유지하세요. 리서치 작업에는 12 라운드면 충분하며, 상한을 낮추면 모델이 루프에 갇혔을 때 피해가 제한됩니다.
이 예제 확장하기
루프가 토대입니다. 일단 작동하면 다음과 같은 방향이 유용합니다:- 두 번째 MCP 서버를 추가하세요.
Agent에는 Apify 전용인 것이 없으므로, 여러 서버의 카탈로그를 병합하는 일은 대부분 도구 이름의 네임스페이스 처리입니다. - 세션을 재개하거나 Actor가 반환한 내용을 감사할 수 있도록 대화를 SQLite에 저장하세요.
- 매번 확인하는 대신 Actor 실행을 추적하고 상한에서 멈추는 도구별 예산을 추가하세요.
- 이름과 인자를 기준으로 도구 결과를 캐싱해, 반복되는 문서 조회가 다시 크롤링하지 않게 하세요.
--model로 모델을 고정하고function_calling_default와 도구 선택 품질을 비교해 보세요.- 승인자를 정책 함수로 바꿔 특정 인자를 가진 특정 Actor는 자동 승인하고 나머지는 모두 프롬프트로 물어보게 하세요.