본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 20. 10:19

LLM 출력값과 씨름하는 것을 멈추고 구조화된 추출 (Structured Extraction)을 사용하기 시작한 방법

요약

LLM을 사용하여 송장 데이터를 추출할 때 발생하는 JSON 파싱 오류와 일관성 문제를 해결하는 방법을 다룹니다. 단순 프롬프팅 대신 OpenAI의 Tool Calling(Function Calling)을 활용하여 엄격한 스키마를 보장하는 구조화된 추출 기법을 소개합니다.

핵심 포인트

  • 단순 프롬프팅은 키 불일치, 불필요한 텍스트, 출력 끊김 등의 문제를 야기함
  • LLM을 단순 API처럼 취급하기보다 언어 모델의 특성을 이해해야 함
  • Tool Calling을 사용하면 모델이 스키마에 맞춰 인자를 채우도록 강제할 수 있음
  • Pydantic 등을 활용한 구조화된 데이터 추출이 신뢰성을 높임

저는 주말 내내 송장 파서(invoice parser)를 만드는 데 시간을 보냈습니다. 3일 후, 저는 약 60%의 확률로 작동하고 나머지 40%는 처참하게 실패하는 결과물을 얻었습니다. 문제는 모델이 아니었습니다. 모델에게 어떻게 응답하도록 요청하느냐의 문제였습니다.

시작은 꽤 순수했습니다. 여러 업체로부터 받은 PDF 송장 더미가 있었고, 저는 송장 번호, 날짜, 총액, 품목(line items)과 같은 필드를 추출하고 싶었습니다. 저의 첫 번째 시도는 간단했습니다. 텍스트를 GPT-3.5-turbo에 입력하고, JSON을 반환하도록 요청한 다음, 응답을 파싱(parse)하는 것이었습니다. 전형적인 접근 방식이죠, 그렇지 않나요?

import openai

prompt = f"""Extract invoice details from the following text. Return JSON with fields: invoice_number, date, total, currency, vendor_name, line_items (array of objects with description, quantity, unit_price, amount).
...

처음 몇 개의 결과는 훌륭해 보였습니다. 그러다 지저분한 현실이 닥쳐왔습니다.

무엇이 잘못되었나

  • 일관성 없는 키 (Inconsistent keys): 어떤 때는 invoice_number를 반환하고, 어떤 때는 invoiceNumberInvoice No.를 반환했습니다.
  • 불필요한 군더더기 (Extra fluff): 실제 출력값 앞에 "Here is the JSON:" 같은 문구가 붙었습니다.
  • 부분적인 출력 (Partial outputs): 송장 내용이 길어지면 응답이 객체 중간에 끊겨버렸습니다.
  • 비용 (Cost): 스키마(schema)가 고정되어 있음에도 불구하고 송장 텍스트를 반복해서 보내야 했기 때문에, 매 실행마다 큰 컨텍스트 윈도우(context window)를 사용했습니다.

저는 다음과 같은 방법들을 시도했습니다:

  • 마크다운 코드 블록을 제거하기 위한 정규 표현식(Regex) 사용.
  • 모델에게 "유효한 JSON만 출력하라"고 요청하기.
  • 엄격한 스키마를 포함한 시스템 메시지(system message) 사용.
  • 심지어 더 작은 모델을 파인튜닝(fine-tuned)하기도 했습니다 (특정 업체에는 작동했지만, 다른 업체에는 작동하지 않았습니다).

그 어떤 것도 제가 필요로 하는 신뢰성을 제공하지 못했습니다. 핵심 문제는 제가 LLM을 구조화된 API처럼 취급했다는 점입니다. 하지만 LLM은 언어 모델(language model)입니다. 즉, 직렬화(serialize)가 아니라 대화를 하고 싶어 하는 존재입니다.

전환점: 함수 호출 (Function Calling, Tools API)

OpenAI가 함수 호출 (Function Calling, 현재는 Tool Calling으로 불림) 기능을 도입했을 때, 비로소 눈이 번쩍 뜨였습니다. 산문(prose)과 씨름하는 대신, 모델이 출력(output)하는 것이 아니라 채워 넣을(fill in) 수 있는 엄격한 스키마 (schema)를 정의할 수 있게 된 것입니다. 모델은 어떤 함수를 호출할지 결정하고 인자 (arguments)를 제공하며, 이 인자들은 반드시 스키마를 따름이 보장됩니다.

현재 제 코드는 다음과 같은 모습입니다:

import json
from pydantic import BaseModel, Field
from typing import List, Optional
...

이것이 작동하는 이유

모델은 JSON을 텍스트로 생성하는 것이 아니라, 구조화된 **함수 호출 (function call)**을 생성합니다. API는 출력이 스키마와 일치하는 유효한 JSON 객체임을 보장합니다. 더 이상 파싱 (parsing) 문제로 머리 아플 일이 없습니다. 스키마가 복잡하더라도 전체 내용을 얻을 수 있으며, 모델은 필요한 경우 한 번의 턴 (turn) 내에서 여러 함수를 호출할 수도 있습니다.

실제 환경에서의 트레이드오프 (Trade-offs)

  • 지연 시간 (Latency): 모델이 스키마를 고려하는 데 비용을 지불해야 합니다. 하지만 gpt-4o-mini와 같은 작은 모델을 사용하면 보통 1초 미만입니다.
  • 비용 (Cost): 프롬프트 (prompt)가 더 압축적이기 때문에 (JSON 스키마를 산문으로 설명할 필요가 없음), 토큰 (tokens)을 절약할 수 있습니다. 함수 스키마는 시스템 메시지 (system message)에 한 번 전송되지만, 반복적인 추출 작업의 경우 이를 캐싱 (cache)할 수 있습니다.
  • 유연성 (Flexibility): 모든 제공업체가 Tool Calling을 동일하게 지원하는 것은 아닙니다. Anthropic의 Tool Use는 유사하지만, Llama와 같은 모델은 다른 처리가 필요할 수 있습니다. 여러 제공업체를 지원해야 한다면, 게이트웨이 레이어 (gateway layer, 예: https://ai.interwestinfo.com/)를 통해 백엔드 간의 차이를 정규화할 수 있습니다. 저는 단순함을 위해 OpenAI 직접 호출로 결정하기 전까지 한동안 이를 사용했습니다.
  • 복잡성 (Complexity): 이제 각 추출 작업에 대해 Pydantic 모델을 정의해야 합니다. 이는 파싱 규칙을 반복적으로 개선해야 할 때 보상으로 돌아오는 초기 투자입니다.

제가 시도했던 대안들 (그리고 그것들이 유효한 경우)

  1. JSON mode (OpenAI의 response_format={ "type": "json_object" }): 간단한 추출에는 효과적이지만, 특정 스키마 (Schema)를 강제하지는 않습니다. 즉, 여전히 사용자 측에서 검증 (Validation)을 수행해야 합니다.
  2. Instructor (Python 라이브러리): 함수 호출 (Function calling)을 깔끔한 Pydantic 인터페이스로 감싸줍니다. OpenAI를 사용 중이라면 시간을 크게 절약할 수 있는 훌륭한 도구입니다. 저는 프로토타입 제작에 사용했지만, 우선은 가공되지 않은 내부 동작 원리를 먼저 이해하고 싶었습니다.
  3. 로컬 모델 (예: llama.cpp): 문법 (Grammars)을 사용하여 JSON 출력을 강제할 수 있습니다. 더 복잡하고 비용은 들지 않지만, 프로덕션 (Production) 환경에서는 속도가 느립니다.

제 송장 파서 (Invoice parser)의 경우, 함수 호출 (Function calling)이 가장 적절한 해결책이었습니다. 저는 이제 이메일에서 연락처 정보 추출, 로그 엔트리 (Log entries) 파싱, 심지어 자연어 명령을 API 호출로 변환하는 작업 등 모든 구조화된 추출 (Structured extraction) 작업에 이를 사용합니다.

다음에 다시 한다면 다르게 할 점

저는 첫날부터 바로 함수 호출 (Function calling)로 시작했을 것입니다. 비구조화된 (Unstructured) 출력을 구조화된 형태로 유도하려고 너무 많은 시간을 낭비했습니다. 또한, 스키마 (Schema)를 가능한 한 엄격하게 정의했을 것입니다. 고정된 값에는 열거형 (Enums)을 사용하고, 설명 (Descriptions)이 포함된 필수 필드를 지정하며, 환각 (Hallucination)된 키 생성을 방지하기 위해 항상 additionalProperties: false를 설정했을 것입니다.

또 다른 교훈은 엣지 케이스 (Edge cases)로 테스트를 해야 한다는 점입니다. 송장에 누락된 필드가 있으면 어떻게 될까요? 여러분의 Pydantic 모델은 기본값 (Default values)이나 선택적 타입 (Optional types)을 통해 이를 처리해야 합니다. 모델은 필수 필드를 찾지 못하면 건너뛰게 되며, 이 경우 도구 호출 (Tool calling)은 에러와 함께 실패합니다. 따라서 무엇이 선택 사항인지 명확하게 정의해야 합니다.

더 큰 그림

구조화된 추출 (Structured extraction)은 더 깊은 패턴의 한 예일 뿐입니다. 그것은 바로 LLM을 텍스트 완성 도구 (Text completers)가 아닌 함수 호출기 (Function callers)로 취급하는 것입니다. 이와 동일한 패턴이 에이전트 프레임워크 (Agent frameworks, 예: OpenAI의 assistants, LangChain agents 등)를 구동합니다. 모델에게 명확하게 정의된 도구 세트를 제공함으로써, 모델이 올바른 일을 하기를 바라는 단계에서 모델이 오직 올바른 일만 할 수 있는 시스템을 구축하는 단계로 넘어갈 수 있습니다.

만약 여러분이 여전히 정규 표현식 (Regex)으로 가공되지 않은 LLM 출력값을 파싱하고 있다면, 이제 그만하십시오. 여러분은 더 나은 방식을 누릴 자격이 있습니다. 오늘 오후 시간을 내어 스키마를 정의하고 도구 호출 (Tool calling)로 전환하십시오. 미래의 여러분이 고마워할 것입니다.

LLM 출력값에 구조를 강제하기 위해 여러분이 주로 사용하는 방법은 무엇인가요? 저는 더 빠른 폴백 (fallback)을 위해 JSON 모드 (JSON mode)와 도구 호출 (tool calling)을 결합한 하이브리드 접근 방식을 여전히 탐색 중입니다. 여러분의 스택 (stack)에서 무엇이 잘 작동하고 있는지(혹은 작동하지 않는지) 듣고 싶습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0