- 모델에 데이터베이스와 이를 읽는 세 가지 도구를 제공하기
- 모델이 각 도구를 언제 사용해야 할지 알 수 있도록 도구를 설명하기
- 도구 호출을 도구 결과로 바꾸는 루프 실행하기
- 모델이 여러 도구를 한 번에 요청하는 모습 관찰하기
- 오류를 예외로 발생시키는 대신 모델에게 돌려주기
- 모델이 하지 않으려는 것과 모델이 할 수 없는 것 사이의 경계 긋기
준비
Python 3.9 이상,requests 패키지, 그리고 Venice API 키가 필요합니다. 키가 없다면 API 키 생성을 참조하세요. 그 외의 것은 모두 표준 라이브러리에 있습니다.
agent.py를 만들고 모든 호출이 재사용할 임포트와 헤더 블록을 작성합니다:
GET /models/traits는 안정적인 특성 이름을 현재 그 역할을 담당하는 모델에 매핑합니다. 시작 시 function_calling_default를 읽어두면 내부 모델이 교체되어도 에이전트가 계속 작동합니다. 전체 특성 목록은 Models를 참조하세요.1. 물어볼 만한 가치가 있는 데이터베이스
어떤 SQLite 파일이든 상관없습니다. 이 예시는 고객, 제품, 그리고 그들을 연결하는 주문이 있는 작은 상점입니다. 실제 질문에 조인과 집계가 필요할 만큼의 규모입니다:2. 모델이 사용할 수 있는 세 가지 도구
이 도구들은 사람이 처음 보는 데이터베이스를 마주할 때의 방식을 그대로 반영합니다: 안에 무엇이 있는지 파악하고, 한 테이블을 자세히 살펴본 뒤, 쿼리를 실행합니다.description은 주석이 아닙니다. 어떤 도구를 호출할지, 그리고 그 안에 무엇을 넣을지를 결정할 때 모델이 읽는 유일한 자료입니다:
3. 루프
함수 호출은 요청이 아니라 대화입니다. 모델이 도구 호출로 답하면, 여러분이 그것을 실행하고, 결과를 이어 붙인 뒤 다시 요청합니다. 모델이 호출 대신 콘텐츠로 응답할 때 종료됩니다.messages에 다시 들어갑니다. 이 메시지에는 결과가 응답하고 있는 tool_calls가 담겨 있고, 추론 모델의 경우 reasoning_content 필드도 함께 담겨 있습니다. 메시지를 손으로 다시 구성하면서 예상치 못한 필드를 떨어뜨리는 것이 두 번째 라운드를 망치는 가장 흔한 방식입니다.
각 결과는 tool_call_id로 자신의 호출과 매칭됩니다. 이것 외에는 결과를 식별할 수 있는 것이 없습니다.
max_rounds는 형식적인 값이 아니라 실제 한도입니다. 결론에 이르지 못한 채 계속 질의하는 모델은 여러분의 인내심이나 크레딧이 바닥날 때까지 반복합니다.
4. 실제 동작 살펴보기
메인 블록을 붙여서 실행합니다:stderr로 출력되므로, 작동 과정을 관찰할 수 있습니다:
라운드 4는 단일 함수 호출로는 할 수 없는 부분입니다. 모델은 그 이전 쿼리의 답을 본 뒤에야 이 쿼리를 작성할 수 있었습니다.
여러분의 실행 결과는 이 예시와 호출 단위로 정확히 일치하지 않을 것입니다. 모델은 때때로 세 테이블을 한 번에 설명하고, 때로는 하나씩 설명합니다. 가끔은
list_tables를 건너뛰고 이름을 추측하기도 합니다. 수치는 데이터베이스에서 오기 때문에 안정적이지만, 거기까지 가는 경로는 그렇지 않습니다.
라운드 2에서 한 응답에 세 개의 도구 호출이 반환되었고, 위 루프는 이를 하나씩 순서대로 실행합니다. 이들은 독립적이므로 도구가 실제 I/O를 수행하는 순간부터
ThreadPoolExecutor를 도입하는 것이 좋습니다. tool 메시지는 이를 생성한 호출과 동일한 순서로 유지하세요.usage 블록에서 그 효과를 확인할 수 있습니다:
5. 오류가 모델에 도달하도록 하기
잘못된 쿼리에 대해 예외를 던지고 싶어질 수 있습니다. 참으세요. 오류는 정보이고, 모델은 그 위에서 행동할 수 있습니다. 존재하지 않는 테이블을 요청해 봅시다:run_query가 예외를 발생시키는 대신 {"error": "OperationalError: no such table: purchases"}를 평범한 도구 결과로 반환했기 때문에, 모델은 그것을 읽고 list_tables를 호출해 실제로 무엇이 있는지 알아본 뒤 스스로 수정했습니다. 만약 예외가 전파되었다면 스크립트는 오타 하나에 죽어버렸을 것입니다.
이것이 모든 도구가 실패 경로에서도 JSON을 반환하는 이유입니다. 규칙은 간단합니다: 여러분의 도구를 디버깅하는 사람이 보고 싶어할 메시지라면, 모델도 그것을 보고 싶어합니다.
6. 모델이 하지 않을 일과 할 수 없는 일
에이전트에게 무언가를 파괴해 달라고 요청해 보세요:SELECT를 실행했고, 해당 컬럼이 Spain이 아니라 ES를 저장한다는 이유로 아무도 찾지 못해 그 사실을 대신 보고했습니다:
run_query 내부의 가드는 선택에 의존하지 않는 부분입니다:
두 번째 줄이
run_query가 sqlite3.Error뿐 아니라 sqlite3.Warning도 함께 잡는 이유입니다. Python 드라이버는 스택된 문장을 거부하지만, 이를 Warning으로 발생시키고, Warning은 Error의 서브클래스가 아닙니다. sqlite3.Error만 잡으면 스택된 문장이 핸들러를 빠져나가 루프를 죽여버리고, 모델이 읽을 수 있는 메시지 대신 프로세스가 종료됩니다.도구 사용 시점 제어하기
tool_choice는 모델에게 얼마만큼의 재량을 줄지 결정합니다:
"required"는 겉보기보다 훨씬 무딘 도구입니다. 이 에이전트에게 tool_choice를 "required"로 두고 What is 2 + 2?를 물으면, 모델은 아무 쓸모도 없는 데이터베이스에 대해 list_tables를 호출한 뒤 다음 라운드에서 4라고 답합니다. "auto"에서는 아무것도 호출하지 않고 즉시 4라고 답합니다. "required"는 요청을 로깅하는 경우처럼 도구가 반드시 실행되어야 할 때만 사용하고, 그 외에는 그냥 두세요.
에이전트 튜닝하기
다음 단계
여러분이 지금 손에 쥔 루프는 대부분의 에이전트 뒤에 있는 바로 그 루프입니다. 도구만 바뀔 뿐입니다.- SQL 도구를 HTTP 호출로 바꾸면 API 에이전트가 됩니다.
- 웹 검색과 스크래핑을 도구로 추가하면 답변 도중에 실시간 웹을 확인할 수 있습니다.
- 구조화된 응답으로 산문 대신 타입이 지정된 결과를 요청하세요.
- 이 패턴의 더 큰 버전은 프라이빗 리서치 에이전트에서 확인할 수 있습니다.
함수 호출
tools 배열과 tool_choice에 대한 레퍼런스.
구조화된 응답
최종 답변을 JSON 스키마에 맞추어 제약합니다.
프롬프트 캐싱
커져가는 대화를 저렴하게 유지합니다.
프라이빗 리서치 에이전트
웹 도구와 플래너가 있는 동일한 루프.