Skip to main content
단일 함수 호출은 쉽습니다. 흥미로운 부분은 그 주위를 도는 루프입니다. 모델이 첫 번째 호출만으로 필요한 것을 얻는 경우는 드물기 때문입니다. 모델은 무언가를 조회하고, 결과를 보고, 다음에 무엇을 요청할지 결정합니다. 이 튜토리얼에서는 한 번도 본 적 없는 SQLite 데이터베이스에 관한 질문에 답하는 커맨드라인 에이전트를 만듭니다. 프롬프트에는 스키마가 없습니다. 읽기 전용 도구 세 개만 주어지고, 나머지는 스스로 알아냅니다:
과정 중에 다음을 다룹니다:
  1. 모델에 데이터베이스와 이를 읽는 세 가지 도구를 제공하기
  2. 모델이 각 도구를 언제 사용해야 할지 알 수 있도록 도구를 설명하기
  3. 도구 호출을 도구 결과로 바꾸는 루프 실행하기
  4. 모델이 여러 도구를 한 번에 요청하는 모습 관찰하기
  5. 오류를 예외로 발생시키는 대신 모델에게 돌려주기
  6. 모델이 하지 않으려는 것과 모델이 할 수 없는 것 사이의 경계 긋기
함수 호출 가이드는 요청 자체의 형태를 다룹니다. 이 페이지는 첫 응답이 돌아온 이후에 무엇이 일어나는지에 관한 것입니다.

준비

Python 3.9 이상, requests 패키지, 그리고 Venice API 키가 필요합니다. 키가 없다면 API 키 생성을 참조하세요. 그 외의 것은 모두 표준 라이브러리에 있습니다.
agent.py를 만들고 모든 호출이 재사용할 임포트와 헤더 블록을 작성합니다:
모든 모델이 도구를 호출할 수 있는 것은 아니고 모델 ID도 변하기 때문에, 세월이 지나면 무너질 이름을 고정하기보다는 API에 어떤 모델을 사용할지 물어보세요:
GET /models/traits는 안정적인 특성 이름을 현재 그 역할을 담당하는 모델에 매핑합니다. 시작 시 function_calling_default를 읽어두면 내부 모델이 교체되어도 에이전트가 계속 작동합니다. 전체 특성 목록은 Models를 참조하세요.

1. 물어볼 만한 가치가 있는 데이터베이스

어떤 SQLite 파일이든 상관없습니다. 이 예시는 고객, 제품, 그리고 그들을 연결하는 주문이 있는 작은 상점입니다. 실제 질문에 조인과 집계가 필요할 만큼의 규모입니다:

2. 모델이 사용할 수 있는 세 가지 도구

이 도구들은 사람이 처음 보는 데이터베이스를 마주할 때의 방식을 그대로 반영합니다: 안에 무엇이 있는지 파악하고, 한 테이블을 자세히 살펴본 뒤, 쿼리를 실행합니다.
모든 함수는 실패 시에도 JSON 문자열을 반환합니다. 이는 의도적이며, 그 이유는 5절에서 다룹니다. 이제 이들을 모델에게 설명합니다. description은 주석이 아닙니다. 어떤 도구를 호출할지, 그리고 그 안에 무엇을 넣을지를 결정할 때 모델이 읽는 유일한 자료입니다:

3. 루프

함수 호출은 요청이 아니라 대화입니다. 모델이 도구 호출로 답하면, 여러분이 그것을 실행하고, 결과를 이어 붙인 뒤 다시 요청합니다. 모델이 호출 대신 콘텐츠로 응답할 때 종료됩니다.
이 루프에는 보이는 것보다 훨씬 중요한 세 가지 세부 사항이 있습니다. 수정하지 않은 어시스턴트 메시지가 결과보다 먼저 messages에 다시 들어갑니다. 이 메시지에는 결과가 응답하고 있는 tool_calls가 담겨 있고, 추론 모델의 경우 reasoning_content 필드도 함께 담겨 있습니다. 메시지를 손으로 다시 구성하면서 예상치 못한 필드를 떨어뜨리는 것이 두 번째 라운드를 망치는 가장 흔한 방식입니다. 각 결과는 tool_call_id로 자신의 호출과 매칭됩니다. 이것 외에는 결과를 식별할 수 있는 것이 없습니다. max_rounds는 형식적인 값이 아니라 실제 한도입니다. 결론에 이르지 못한 채 계속 질의하는 모델은 여러분의 인내심이나 크레딧이 바닥날 때까지 반복합니다.
도구 호출에는 index 필드도 있는데, 이를 이용해 결과와 호출을 정렬하고 싶어질 수 있습니다. 그러면 안 됩니다. 모델이 도구 세 개를 한 번에 요청하면 세 개 모두 동일한 index로 도착할 수 있습니다. index는 호출 자체가 아니라 어시스턴트 턴의 번호를 매기기 때문입니다. 오직 id만이 고유합니다.

4. 실제 동작 살펴보기

메인 블록을 붙여서 실행합니다:
도구 호출은 발생 시점에 stderr로 출력되므로, 작동 과정을 관찰할 수 있습니다:
이 과정은 다섯 라운드가 걸렸습니다. 라운드의 흐름을 자세히 읽어볼 가치가 있습니다. 이것이 곧 루프의 존재 이유이기 때문입니다: 라운드 4는 단일 함수 호출로는 할 수 없는 부분입니다. 모델은 그 이전 쿼리의 답을 본 뒤에야 이 쿼리를 작성할 수 있었습니다. 여러분의 실행 결과는 이 예시와 호출 단위로 정확히 일치하지 않을 것입니다. 모델은 때때로 세 테이블을 한 번에 설명하고, 때로는 하나씩 설명합니다. 가끔은 list_tables를 건너뛰고 이름을 추측하기도 합니다. 수치는 데이터베이스에서 오기 때문에 안정적이지만, 거기까지 가는 경로는 그렇지 않습니다.
라운드 2에서 한 응답에 세 개의 도구 호출이 반환되었고, 위 루프는 이를 하나씩 순서대로 실행합니다. 이들은 독립적이므로 도구가 실제 I/O를 수행하는 순간부터 ThreadPoolExecutor를 도입하는 것이 좋습니다. tool 메시지는 이를 생성한 호출과 동일한 순서로 유지하세요.
각 라운드마다 전체 대화가 다시 전송되므로 에이전트가 작업할수록 프롬프트가 커집니다. Venice는 안정적인 접두부를 자동으로 캐시하며, usage 블록에서 그 효과를 확인할 수 있습니다:
마지막 라운드에서는 1020개의 프롬프트 토큰 중 960개가 캐시에서 제공되었습니다. 이 접두부를 안정적으로 유지하는 방법은 프롬프트 캐싱에서 다룹니다.

5. 오류가 모델에 도달하도록 하기

잘못된 쿼리에 대해 예외를 던지고 싶어질 수 있습니다. 참으세요. 오류는 정보이고, 모델은 그 위에서 행동할 수 있습니다. 존재하지 않는 테이블을 요청해 봅시다:
첫 번째 쿼리는 실패했습니다. run_query가 예외를 발생시키는 대신 {"error": "OperationalError: no such table: purchases"}를 평범한 도구 결과로 반환했기 때문에, 모델은 그것을 읽고 list_tables를 호출해 실제로 무엇이 있는지 알아본 뒤 스스로 수정했습니다. 만약 예외가 전파되었다면 스크립트는 오타 하나에 죽어버렸을 것입니다. 이것이 모든 도구가 실패 경로에서도 JSON을 반환하는 이유입니다. 규칙은 간단합니다: 여러분의 도구를 디버깅하는 사람이 보고 싶어할 메시지라면, 모델도 그것을 보고 싶어합니다.

6. 모델이 하지 않을 일과 할 수 없는 일

에이전트에게 무언가를 파괴해 달라고 요청해 보세요:
두 번 실행하면 다른 결과가 나올 수 있습니다. 한 번은 도구를 건드리기 전에 거절했습니다:
또 다른 실행에서는 먼저 조사를 나가, 스페인 고객에 대해 SELECT를 실행했고, 해당 컬럼이 Spain이 아니라 ES를 저장한다는 이유로 아무도 찾지 못해 그 사실을 대신 보고했습니다:
둘 다 합리적입니다. 그 어느 쪽도 보안 통제가 아닙니다. 모델은 도구 설명에서 “read-only”라는 단어를 읽고 그것을 존중하기로 선택했을 뿐이며, 다른 모델, 더 긴 대화, 혹은 더 집요한 사용자는 다른 선택을 이끌어 낼 수 있습니다. run_query 내부의 가드는 선택에 의존하지 않는 부분입니다:
설명은 모델이 좀처럼 시도하지 않도록 작성하세요. 가드는 시도하더라도 문제가 되지 않도록 작성하세요.
두 번째 줄이 run_querysqlite3.Error뿐 아니라 sqlite3.Warning도 함께 잡는 이유입니다. Python 드라이버는 스택된 문장을 거부하지만, 이를 Warning으로 발생시키고, WarningError의 서브클래스가 아닙니다. sqlite3.Error만 잡으면 스택된 문장이 핸들러를 빠져나가 루프를 죽여버리고, 모델이 읽을 수 있는 메시지 대신 프로세스가 종료됩니다.
접두사 검사는 쓰기를 막지만, 읽기에 대해서는 아무 말도 하지 않습니다. 모델이 작성하는 어떤 SELECT든 파일 내 모든 테이블에 도달할 수 있으며, 이는 여러분이 노출할 의도가 없던 테이블도 포함합니다. 이 코드가 실제 데이터에 닿기 전에 두 가지 변경이 필요합니다: sqlite3.connect("file:shop.db?mode=ro", uri=True)로 데이터베이스를 읽기 전용으로 여세요. 이렇게 하면 문자열 검사가 놓치는 무엇이든 관계없이 attempt to write a readonly database로 쓰기가 실패합니다. 그리고 에이전트가 볼 수 있어야 할 컬럼만 담긴 데이터베이스 또는 뷰 집합을 가리키게 하세요.

도구 사용 시점 제어하기

tool_choice는 모델에게 얼마만큼의 재량을 줄지 결정합니다: "required"는 겉보기보다 훨씬 무딘 도구입니다. 이 에이전트에게 tool_choice"required"로 두고 What is 2 + 2?를 물으면, 모델은 아무 쓸모도 없는 데이터베이스에 대해 list_tables를 호출한 뒤 다음 라운드에서 4라고 답합니다. "auto"에서는 아무것도 호출하지 않고 즉시 4라고 답합니다. "required"는 요청을 로깅하는 경우처럼 도구가 반드시 실행되어야 할 때만 사용하고, 그 외에는 그냥 두세요.

에이전트 튜닝하기

다음 단계

여러분이 지금 손에 쥔 루프는 대부분의 에이전트 뒤에 있는 바로 그 루프입니다. 도구만 바뀔 뿐입니다.

함수 호출

tools 배열과 tool_choice에 대한 레퍼런스.

구조화된 응답

최종 답변을 JSON 스키마에 맞추어 제약합니다.

프롬프트 캐싱

커져가는 대화를 저렴하게 유지합니다.

프라이빗 리서치 에이전트

웹 도구와 플래너가 있는 동일한 루프.