답변에서 저장까지 - Tool Call, Structured Output, SQLite로 이어진 3주

김태희·2026년 7월 29일

Agentic AI

목록 보기
1/3
post-thumbnail

카카오테크캠퍼스 1단계 프로젝트에서는 개인 메이트 나나(Nana)와 그룹 메이트 카나(Kana)를 만들고 있다.

처음 Agentic AI를 접했을 때는 LLM이 사용자의 말을 이해해서 일정을 알아서 저장해 주는 것이라고 단순하게 생각했다. 채팅창에서 "내일 오후 3시에 팀 회의 잡아줘"라고 말했을 때 자연스러운 답변이 돌아오면 기능이 잘 동작한다고 생각하기도 했다.

그런데 3주 동안 Tool Call, Structured Output, SQLite를 차례대로 붙여보니 실제 구조는 전혀 달랐다.

LLM은 일정을 직접 저장하지 않는다. 모델이 하는 일은 어떤 도구를 어떤 인자로 호출할지 결정하거나, 자연어를 프로그램이 읽을 수 있는 구조로 정리하는 것에 가깝다. 실제 실행과 저장은 우리가 작성한 코드와 데이터베이스가 담당한다.

사용자 자연어
  → LLM이 요청을 이해
  → Tool Call 또는 Structured Output 생성
  → Pydantic 스키마 검증
  → Python tool 실행
  → SQLite 저장
  → Tool Result를 모델에게 전달
  → 사용자에게 최종 답변

이번 글에서는 이 흐름을 직접 구현하면서 무엇을 배웠고, 어떤 부분에서 생각이 바뀌었는지 정리하려고 한다.


1. 모델의 답변과 실제 실행은 다른 일이었다

첫 주에는 LangChain의 @tool을 사용해 개인 일정 생성·조회·삭제 기능을 구현했다.

  • personal_create_schedule
  • personal_list_schedules
  • personal_delete_schedule

사용자가 일정 생성을 요청하면 모델은 personal_create_schedule이라는 tool과 필요한 arguments를 만든다. 하지만 모델이 일정을 저장하는 것은 아니다. 실제 함수가 실행되어 저장소에 payload를 추가하고, 그 결과가 Tool Result로 다시 모델에게 전달된다.

처음에는 최종 답변만 자연스러우면 잘 동작한다고 생각했다. 하지만 Gradio의 trace JSON을 열어보니 답변과 실제 상태가 다를 수 있다는 것을 알게 됐다.

예를 들어 모델은 "일정을 저장했어요"라고 답할 수 있지만 다음과 같은 문제가 숨어 있을 수 있다.

  • date가 잘못 계산됨
  • attendees가 누락됨
  • 다른 대화의 일정이 함께 조회됨
  • 삭제 tool이 잘못된 schedule_id를 받음
  • Tool Call은 만들어졌지만 저장소가 변경되지 않음

그래서 이후부터는 최종 답변보다 먼저 아래 세 가지를 확인하게 됐다.

  1. 어떤 tool_name이 선택됐는가
  2. 어떤 arguments가 전달됐는가
  3. 저장소의 실제 결과가 어떻게 바뀌었는가

특히 일정이 대화마다 섞이지 않도록 현재 session 범위 안에서 생성·조회·삭제되게 처리하면서 scope가 왜 필요한지도 체감했다. 같은 저장소를 사용하더라도 어떤 사용자의 어느 대화에서 만들어진 데이터인지 구분하지 않으면, 기능은 동작해도 서비스로는 사용할 수 없기 때문이다.


2. 자연어를 바로 실행하지 않고 구조화하기

다음 주에는 자연어 요청을 바로 tool로 실행하기보다 StructuredRequest라는 정해진 스키마로 변환했다.

일정 요청에는 생각보다 많은 정보가 들어간다.

제목 / 날짜 / 시작 시간 / 참석자 / 요청 종류 / 개인 또는 그룹 일정

사람은 "다음 주 화요일 세 시에 민수랑 회의"라는 문장을 자연스럽게 이해하지만, 뒤쪽 코드가 이 문장을 그대로 처리하기는 어렵다. 표현이 조금만 달라져도 조건문이 계속 늘어날 수 있기 때문이다.

Pydantic 모델로 필드와 타입을 고정하면 뒤쪽 로직은 자연어 전체를 다시 해석하지 않고 정해진 값을 사용할 수 있다.

"다음 주 화요일 세 시에 민수랑 회의"

          ↓

kind: group_schedule
title: 회의
date: 2026-07-14
start_time: 15:00
members:
  - 민수

처음에는 JSON 모양으로만 나오면 충분하다고 생각했다. 실제로는 상대 날짜와 분류 경계가 문제였다.

상대 날짜는 기준일이 필요했다

"내일", "다음 주 화요일" 같은 말은 기준 날짜가 없으면 계산할 수 없다. 그래서 프롬프트에 base_date를 명확하게 전달하고, 기준일이 2026-07-08일 때 "다음 주 화요일"이 2026-07-14로 나오는지 직접 확인했다.

모델이 날짜를 잘 계산할 것이라고 믿는 것과, 기준을 주고 결과를 검증하는 것은 다른 일이었다.

참석자가 있는데도 개인 일정으로 분류됐다

참석자가 있는 일정인데도 결과의 kind가 가끔 개인 일정으로 나왔다. 모델이 먼저 선택한 tool 이름에 영향을 받아 그룹 일정을 개인 일정처럼 분류하는 경우가 있었다.

그래서 참석자가 한 명이라도 있으면 group_schedule로 판단하라는 규칙을 프롬프트에 추가했다. 수정 후에는 같은 시나리오를 세 번씩 실행해 결과가 흔들리지 않는지 확인했다.

한 번 성공한 결과는 모델의 안정성을 보장하지 않는다. 같은 입력을 반복해서 실행해 본 것이 이번 과제에서 가장 중요한 검증 중 하나였다.

Structured Output인데도 Extra data 오류가 났다

모델이 JSON 뒤에 "일정 잡았어요" 같은 문장을 붙이면 StructuredOutputValidationError("Extra data")가 발생했다.

처음에는 Structured Output을 사용했는데 왜 JSON 파싱 오류가 발생하는지 이해되지 않았다. 결국 구조화 데이터 뒤에 자연어가 추가되면서 스키마 검증을 방해한다는 것을 알게 됐다.

그래서 ToolStrategy(StructuredRequestBatch)를 사용해 구조화 결과를 tool call 인자로 받도록 바꾸었다. 이후 반복 실행에서 같은 오류가 발생하지 않는 것을 확인했다.


3. 저장과 영속성은 다른 문제였다

세 번째 주에는 구조화된 요청을 SQLite에 저장하고 다시 조회·수정·삭제하는 흐름을 붙였다.

1주차에는 일정이 메모리 저장소에 들어갔다. 앱을 켜둔 동안에는 잘 보였지만 프로세스를 다시 시작하거나 새 대화를 만들면 데이터가 사라질 수 있었다.

처음에는 "리스트에 넣던 것을 DB에 넣으면 되는 것 아닌가"라고 생각했다. 직접 해보니 이번 주차의 핵심은 저장 방식보다 영속성이었다.

현재 대화에서 저장됨
        ≠
앱을 다시 켜거나 새 대화를 시작해도 남아 있음

구현한 주요 기능은 다음과 같다.

  • 구조화 요청 저장
  • 저장된 요청 목록·단건 조회
  • 일정 수정
  • 조건을 이용한 삭제
  • 레거시 payload 정규화

입력 검증, 실행, 저장의 역할을 나누기

이번에는 @tool(args_schema=...) 방식도 사용했다.

처음에는 tool 함수 안에서 값을 다시 Pydantic 모델로 만들어 한 번 더 검증해야 마음이 놓일 것 같았다. 하지만 코드를 따라가면서 역할이 다음처럼 나뉜다는 것을 이해했다.

Pydantic schema  → 입력 검증
Tool             → 실행 흐름
SQLite store     → 저장과 조회

각 계층이 같은 검증을 반복하는 것이 아니라 자기 책임을 분명하게 갖는 구조였다.

조건 없는 삭제는 막았다

가장 신경 쓴 부분은 삭제였다.

id, 날짜, 제목처럼 삭제 대상을 좁히는 조건이 하나도 없는데 삭제 요청이 실행되면 저장된 일정이 전부 사라질 수 있다. 기능을 편하게 만드는 것보다 실수로 데이터를 통째로 지우지 못하게 만드는 것이 더 중요하다고 판단했다.

그래서 삭제 조건이 비어 있으면 아무것도 삭제하지 않도록 막았다.

이 경험을 통해 Agentic AI에서 안전장치는 모델에게 "조심해서 실행해"라고 말하는 것이 아니라, 위험한 동작이 코드 수준에서 실행되지 못하도록 만드는 것에 가깝다는 생각이 들었다.


4. 새 대화를 만들어 직접 검증하기

SQLite 코드가 있다고 영속성이 증명되는 것은 아니다. 실제로 저장하고 새로운 대화에서 다시 읽히는지 확인해야 했다.

먼저 agent에 다음 요청을 보냈다.

"내일 오전 10시에 개인 코칭 일정 저장해줘"

trace에서는 다음 순서로 tool이 호출됐다.

extract_schedule_request
  → save_structured_request
  → SQLite 저장

그다음 agent를 새로 만들어 새 대화처럼 시작했다.

"내가 저장한 일정 보여줘"

이전 대화에서 저장한 개인 코칭 일정이 그대로 조회되는 것을 확인했다. 단순히 DB 파일에 행이 생긴 것을 보는 것보다, 실제 사용자 흐름으로 저장과 조회가 연결되는 것을 확인했을 때 가장 뿌듯했다.

경계 조건도 함께 확인했다.

  • 없는 id를 조회했을 때 row=None이 나오는가
  • 조건 없는 삭제가 거부되는가
  • None인 필드가 불필요하게 저장되지 않는가
  • 저장 → 목록 → 단건 조회 → 수정 → 삭제 순서가 모두 이어지는가

5. AI를 사용하면서도 직접 확인하려고 한 것

이번 과제들은 아직 Python과 LangChain이 익숙하지 않아 Claude Code의 도움을 많이 받았다.

AI가 제안한 코드를 활용한 것 자체를 숨기기보다, 그 코드를 어느 수준까지 이해하고 검증했는지가 더 중요하다고 생각했다.

그래서 다음 부분은 직접 확인하려고 했다.

  • 각 tool이 실제로 어떤 store 메서드를 부르는지 따라가기
  • 반환 JSON과 trace의 key를 눈으로 확인하기
  • 상대 날짜와 그룹 분류를 같은 입력으로 반복 실행하기
  • 임시 DB에서 CRUD 흐름을 순서대로 실행하기
  • agent를 새로 만들어 영속성을 사용자 흐름으로 검증하기

아직 Python 문법과 프레임워크 구조를 읽는 속도는 느리다. 다만 AI가 코드를 만들어주었다는 이유로 동작 원리를 건너뛰지 않고, 적어도 입력·실행·저장·결과가 어떻게 이어지는지는 설명할 수 있게 됐다.


6. 3주 동안 달라진 생각

처음에는 Agentic AI를 "대답을 잘하는 챗봇"에 가깝게 생각했다.

지금은 조금 다르게 보고 있다.

Agentic AI
= 자연어 이해
+ 구조화된 계약
+ 실제로 실행되는 코드
+ 저장소
+ 실패를 막는 안전장치
+ 결과를 확인하는 trace와 테스트

자연스러운 답변은 사용자 경험에 중요하지만, 실제 데이터를 다루는 서비스에서는 tool arguments와 저장 결과가 더 먼저 검증되어야 한다.

앞으로는 tool 하나를 처음부터 끝까지 AI 도움 없이 구현해 보고 싶다. 그리고 현재는 스모크 테스트로 확인한 경계 조건들을 단위 테스트로 고정해, 나중에 코드를 수정해도 같은 문제가 다시 생기지 않도록 만들 생각이다.


이번 학습의 KPT

Keep

  • 최종 답변보다 tool arguments와 저장소 결과를 먼저 확인한 점
  • 한 번 성공한 결과를 믿지 않고 같은 시나리오를 반복 실행한 점
  • 지난 회고에서 정한 "작업 전 최신 main 반영"을 실제 습관으로 옮긴 점

Problem

  • Python과 LangChain이 익숙하지 않아 AI가 작성한 코드를 이해하는 데 시간이 많이 걸렸다.
  • Structured Output을 사용하면 항상 안정적인 결과가 나온다고 단순하게 생각했다.
  • 스모크 테스트는 했지만 경계 조건을 자동으로 고정한 단위 테스트가 부족했다.

Try

  • tool 하나를 처음부터 끝까지 직접 구현하기
  • 조건 없는 삭제와 현재 세션 범위를 단위 테스트로 고정하기
  • 저장 결과가 최종 답변의 근거로 사용되는 과정까지 trace로 확인하기

관련 학습 기록

0개의 댓글