DL/LLM

[LLM] Python LLM 출력 JSON 파싱 팁. (Langchain- JsonOutputParser, RunnableLambda 활용)

moonzoo 2025. 4. 18. 17:42

 

LLM을 활용하여 개발하다 보면, 구조화된 데이터를 얻기 위해 출력 형식을 JSON으로 지정하는 경우가 많습니다.

저 역시 LLM에게 특정 역할을 부여하고 그 결과를 JSON으로 받아 후처리하는 작업을 진행하고 있었는데요, 예상치 못한 JSONDecodeError 때문에 코드를 여러번 수정한 경험이 있었습니다.

 

오늘은 제가 겪었던 문제 상황과 이를 해결하기 위해 여러 차례 시도했던 과정, 그리고 최종적으로 LangChain의 RunnableLambda와 개선된 사용자 정의 함수(JSON Parser)를 활용하여 파싱 에러 0%를 달성한 과정을 공유하며, LLM의 JSON 출력 오류를 극복하는 팁을 나누고자 합니다.

 


1. 처음 : 프롬프트로 JSON 출력 요청하기

처음에는 간단하게 생각했습니다. LLM에게 원하는 작업과 함께 출력 형식을 명확하게 JSON으로 지정해주면 될 것이라고요. LangChain을 사용하고 있었기에, LLM 호출 후 JsonOutputParser()를 연결하여 손쉽게 파싱할 계획이었습니다.

(실제로 사용한 출력 형식 포맷은 좀 더 복잡하여, 뉴스 분석 시나리오를 기반으로 출력 형식을 새로 정의한 예시 프롬프트입니다.)

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser

example_prompt = ChatPromptTemplate.from_template(template="""

...자세한 건 생략

### [출력 형식]
- **반드시** 아래 JSON 포맷만 출력해야 하며, 다른 어떠한 텍스트나 공백, 설명도 포함하면 안 됩니다.
{{
  "initial_topic_classification": "분석 대상 미국 뉴스 기사의 가장 포괄적인 주제 또는 성격에 대한 초기 분류 (예: 정치, 경제, 사회, 기술, 정보성 기사, 오피니언 등) (해당 분류가 없으면 빈 문자열)",
  "identified_topics_entities": [
    "initial_topic_classification에 해당하는 분류를 포함하여, 기사 본문에서 식별된 구체적인 하위 토픽, 주요 인물, 기관, 장소, 사건 등의 목록"
  ],
  "primary_article_focus": "기사가 가장 중점적으로 다루고 있는 핵심 주제 또는 개체 (identified_topics_entities 목록 중 가장 대표적인 단일 항목)",
  "topic_entity_analysis": {{
    "<토픽_또는_개체명1>": {{
      "objective_summary": "해당 토픽/개체에 대해 기사가 객관적으로 전달하는 사실 관계 및 주요 정보 요약",
      "sentiment_summary": "해당 토픽/개체에 대한 기사의 논조 또는 감성(긍정/부정/중립 등) 분석 결과 및 판단 근거 요약"
    }},
    "<토픽_또는_개체명2>": {{
      "objective_summary": "두 번째 토픽/개체 관련 객관적 사실 및 정보 요약",
      "sentiment_summary": "두 번째 토픽/개체 관련 기사 논조/감성 분석 요약"
    }}
  }},
  "topic_entity_keywords": {{
    "<토픽_또는_개체명1>": ["해당 토픽/개체를 대표하는 핵심 키워드 목록", "키워드2", "키워드3"],
    "<토픽_또는_개체명2>": ["두 번째 토픽/개체 관련 핵심 키워드 목록", "키워드5"]
  }},
  "key_information_snippets": {{
    "<토픽_또는_개체명1>": [
      {{
        "snippet": "해당 토픽/개체와 직접적으로 관련된 기사 본문 내 핵심 문장 또는 정보 조각 (인용 부호 없이 내용만 기술)",
        "context": "snippet 정보가 기사 내 어떤 맥락에서 나왔는지 설명 (필요시 작성)",
        "info_type": "추출된 정보의 성격 분류 (예: 인물 발언, 통계 수치, 사건 개요, 정책 내용, 배경 설명 등)"
      }}
    ],
    "<토픽_또는_개체명2>": [
      {{
        "snippet": "두 번째 토픽/개체 관련 기사 본문 내 핵심 문장 또는 정보 조각",
        "context": "snippet 정보의 기사 내 맥락",
        "info_type": "추출 정보 성격 분류"
      }}
    ]
  }}
}}"""
)


# 초기 시도: LLM 호출 후 바로 JsonOutputParser 사용
chain_v1 = (
    {"question": itemgetter("question")}
    | example_prompt # JSON 출력을 명시한 프롬프트
    | llms
    | JsonOutputParser() # 결과를 JSON으로 파싱
)

# 기대: chain_v1.invoke({"question": "..."}) 실행 시 깔끔한 JSON 딕셔너리 반환

(저는 꽤 구체적으로 프롬프트에 JSON 형식과 규칙을 지정했습니다. 출력형식 외에도 [규칙] 파트에 명시도 했습니다.)

2. 문제 발생: JSONDecodeError 발생

하지만 현실은 달랐습니다. 특히 파라미터 수가 적은 경량화된 로컬 LLM 모델을 사용할 때, 다음과 같은 JSONDecodeError가 빈번하게 발생했습니다.

# 에러 발생 예시
JSONDecodeError: Expecting value: line 1 column 1 (char 0)

# 또는
Error in stt_category_processing: Invalid json output: 

..........

For troubleshooting, visit: https://python.langchain.com/docs/troubleshooting/errors/OUTPUT_PARSING_FAILURE

분명 프롬프트에는 JSON 형식만 출력하라고 명시했지만, JsonOutputParser는 이를 제대로 처리하지 못했습니다.

흥미로운 점은, 동일한 프롬프트를 사용하더라도 Gemini나 ChatGPT와 같이 파라미터 규모가 큰 모델에서는 이런 문제가 거의 발생하지 않았다는 것입니다. 이는 경량화 모델이 프롬프트의 출력 형식 제약 조건을 엄격하게 지키지 못하고, JSON 외의 불필요한 텍스트(예: "알겠습니다. 요청하신 JSON 형식으로 출력합니다:", 시스템 프롬프트 내용 일부 등)를 함께 출력하는 경향, ","을 빼먹거는 경우, response token이 함께 등장하는 등 여러가지 변수가 존재했습니다, JsonOutputParser는 순수한 JSON 문자열을 기대하는데, 추가 텍스트가 섞여 있으니 파싱에 실패하는 것이죠.

 

3. 원인 분석 및 해결 아이디어 탐색

문제의 원인은 명확해졌는데요. LLM의 출력 결과가 순수한 JSON 문자열이 아니라, 다른 문자열이 포함된 텍스트 형태라는 것. 여기서 핵심은 **LLM의 응답에서 "JSON 부분만 정확히 추출"**하는 것이었습니다.

 

(","을 빼먹거나 JSON 내부 형식을 잘못 생성한 것은 프롬프트의 출력 형식에서 조정했고, response token은 후처리를 진행했습니다.)

 

3-1. 시도 1: StrOutputParser + 기본 JSON 추출 함수 (해결 X)

첫 번째 개선 시도로, LLM의 출력을 먼저 문자열로 변환한 뒤, 여기서 JSON 부분만 찾아내는 방법을 고안했습니다. LangChain의 StrOutputParser를 사용하여 LLM 응답 객체를 강제로 문자열로 만들고, 직접 만든 간단한 추출 함수를 RunnableLambda로 적용했습니다.

[시도 1 코드]

from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda
import json

# 시도 1에서 사용한 간단한 JSON 추출 함수
def extract_json_part_v1(text: str) -> str:
    # LLM 출력이 프롬프트를 포함하고 JSON이 끝에 오는 경우 등을 가정
    # 가장 마지막 '{'와 '}' 사이의 내용을 추출 시도
    start = text.rfind('{')
    end = text.rfind('}')
    if start != -1 and end != -1 and start < end:
        potential_json = text[start:end+1]
        try:
            # 추출한 부분이 유효한 JSON인지 확인
            json.loads(potential_json)
            return potential_json
        except json.JSONDecodeError:
            raise ValueError(f"Extracted text is not valid JSON: {potential_json}")
    elif text.strip() == '{}': # 빈 JSON 객체 처리
         return '{}'
    # JSON 부분을 찾지 못하면 에러 발생
    raise ValueError(f"Could not find JSON object in LLM output: {text}")

# 시도 1 체인 구성
chain_v2 = (
    {"question": itemgetter("question")}
    | example_prompt # JSON 출력을 요청하는 프롬프트
    | llms
    | StrOutputParser() # 1. LLM의 전체 출력을 문자열로 받음
    | RunnableLambda(extract_json_part_v1) # 2. 문자열에서 JSON 부분만 추출 (간단 버전)
    | JsonOutputParser() # 3. 추출된 JSON 문자열을 파싱
)

이 방식은 LLM이 JSON 앞부분에 다른 텍스트를 출력하는 경우에 어느 정도 효과가 있었습니다. StrOutputParser로 문자열을 받고, extract_json_part_v1 함수가 문자열 끝에서부터 { 와 } 를 찾아 JSON으로 보이는 부분을 추출했기 때문입니다.

하지만 이 방법도 완벽하지 않았습니다. 여전히 파싱 에러가 발생하는 경우가 있었는데, 주된 이유는 다음과 같습니다.

  • 마크다운 형식 미처리: LLM이 json ... 와 같이 마크다운 코드 블록으로 JSON을 감싸서 출력하는 경우, extract_json_part_v1 함수는 이를 제대로 처리하지 못했습니다. rfind('{')가 코드 블록 외부의 다른 중괄호를 찾거나, 코드 블록 기호(```) 때문에 파싱에 실패할 수 있습니다.
  • 출력 형식의 불안정성: LLM 출력이 항상 AIMessage 객체가 아닐 수도 있고, 때로는 예상치 못한 메타데이터나 추가 정보가 문자열 변환 시 포함될 수 있습니다. StrOutputParser가 이를 어떻게 처리할지 예측하기 어려울 때가 있습니다.
  • 단순한 추출 로직의 한계: rfind를 이용한 방식은 문자열 내에 여러 개의 중괄호 쌍({})이 복잡하게 포함된 경우, 의도치 않은 부분을 JSON으로 오인할 가능성이 있습니다.

결국, 좀 더 안정적이고 다양한 출력 시나리오에 대응할 수 있는 방법이 필요했습니다.

4. 최종 해결책: RunnableLambda

여러 시행착오 끝에 도달한 최종 해결책은 다음과 같습니다.

  1. LLM 호출: 먼저 LLM을 호출하여 응답을 받습니다 (보통 AIMessage).
  2. content 직접 추출 (RunnableLambda 사용): StrOutputParser 대신, RunnableLambda를 사용하여 AIMessage 객체에서 직접 content 속성만 추출합니다. 이는 LLM의 핵심 텍스트 응답에 더 가깝게 접근하는 방식입니다. 예상치 못한 타입에 대비해 기본값(빈 문자열) 처리도 포함합니다.
  3. 강화된 JSON 추출 (RunnableLambda + 개선된 함수 사용): 마크다운 코드 블록 처리 기능까지 추가된 개선된 사용자 정의 함수(extract_json_part_final)를 RunnableLambda로 적용하여 content 문자열에서 JSON 부분을 추출합니다.
  4. 최종 파싱 (JsonOutputParser 사용): 이제 훨씬 정제된 JSON 문자열을 JsonOutputParser로 안전하게 파싱합니다.

[최종 해결 코드]

 
from langchain_core.messages import AIMessage

# --- 최종 해결: 개선된 JSON 추출 함수 ---
def extract_json_part_final(text: str) -> str:
    """
    문자열에서 JSON 부분을 추출하는 개선된 함수.
    마크다운 코드 블록(```json ... ```)과 일반 텍스트 내의 {...} 구조를 모두 처리.
    """
    if isinstance(text, str):
        # 1순위: 마크다운 JSON 블록 처리
        if text.strip().startswith("```json"):
            start_block = text.find('{')
            end_block = text.rfind('}')
            if start_block != -1 and end_block != -1 and start_block < end_block:
                potential_json = text[start_block:end_block+1]
                try:
                    json.loads(potential_json)
                    # print("Extracted JSON from Markdown block.") # 디버깅용
                    return potential_json # 성공 시 JSON 문자열 반환
                except json.JSONDecodeError:
                    print("Warning: Markdown block found, but content is not valid JSON.")
                    # 마크다운 블록 실패 시 아래 일반 로직으로 넘어감

        # 2순위: 일반 텍스트에서 마지막 '{'와 '}' 쌍 찾기
        start = text.rfind('{')
        end = text.rfind('}')
        if start != -1 and end != -1 and start < end:
            potential_json = text[start:end+1]
            try:
                json.loads(potential_json)
                return potential_json # 성공 시 JSON 문자열 반환
            except json.JSONDecodeError as e:
                raise ValueError(f"Extracted text is not valid JSON: {potential_json[:100]}... Error: {e}") from None
        elif text.strip() == '{}': # 빈 JSON 객체 처리
            return '{}'
        else:
            raise ValueError(f"Could not find valid JSON object brackets '{{' and '}}' in LLM output: {text[:100]}...")
    else:
        raise ValueError(f"Input to extract_json_part was not a string: {type(text)}")


# --- 최종 해결: 개선된 처리 체인 ---
final_chain = (
    {"question": itemgetter("question")}
    | example_prompt # JSON 출력을 요청하는 상세 프롬프트
    | llms # 1. LLM 호출 (AIMessage 반환)
    | RunnableLambda(lambda msg: getattr(msg, 'content', "") if isinstance(msg, AIMessage) else str(msg)) # 2. AIMessage에서 content 직접 추출 (안정성 강화)
    | RunnableLambda(extract_json_part_final) # 3. content 문자열에서 JSON 부분 추출 (개선된 함수)
    | JsonOutputParser() # 4. 추출된 JSON 문자열 파싱
)

개선된 점:

  • 직접적인 content 접근: StrOutputParser를 거치지 않고 AIMessage의 content를 바로 사용함으로써 불필요한 변환 과정을 줄이고 LLM의 핵심 출력에 더 집중합니다.
  • 마크다운 처리: extract_json_part_final 함수는 LLM이 json ... 형식으로 출력하는 경우를 명시적으로 처리하여 안정성을 높였습니다.
  • 단계적 추출: 마크다운 블록을 먼저 확인하고, 실패 시 일반 텍스트에서 찾는 방식으로 더 많은 케이스에 대응합니다.

5. 결과: 파싱 에러 0% 달성

이 최종 방식을 적용한 후, 경량 로컬 LLM에서도 더 이상 JSONDecodeError 때문에 골치 아픈 일은 사라졌습니다. LLM이 JSON 앞뒤로 약간의 불필요한 텍스트를 출력하거나 마크다운으로 감싸더라도, 개선된 extract_json_part_final 함수가 효과적으로 JSON 부분만 걸러내 주었고, JsonOutputParser는 안정적으로 파싱을 수행할 수 있었습니다. 적용 이후 파싱 에러 발생률은 0%로 유지중입니다. (또, 예외가 나오면 바로 처리해줘야 겠죠...)

 

물론, LLM의 출력이 너무 심하게 손상되었거나 안전 설정 등에 의해 차단된 경우에는 여전히 에러가 발생할 수 있습니다. 이런 경우를 대비하여 디버깅 함수(사용자 제공 코드의 inspect_and_print_error_details 등)를 추가하여 LLM의 원본 응답과 메타데이터를 확인하는 것이 좋습니다.

 

JSON 출력을 요청하기 위해 사실 프롬프트를 잘 짜는 것이 제일 중요하긴 합니다. Gemini나 ChatGPT 등의 파라미터가 큰 LLM에서는 대충 JSON으로 반환해달라 해도 대부분 큰 문제 없이 변환이 되기는 하는데, 파라미터가 작은 LLM은 아무래도 역할, 목표, 절차, 목록, 규칙 등을 상세하게 제공하여 최대한 원하는 형식으로 출력하도록 유도하는게 필요하거든요. 그래서 프롬프트가 200줄이 넘어가는 경우도 많습니다.

아무튼 이번 글에서는 JSON 출력 방식 팁을 간단하게 공유드렸습니다. 이제 마무리로 가보겠습니다~!
 
 

 

6. 마무리

LLM에게 원하는 형식의 출력을 얻는 것은 때로는 실험이 필요한 작업일 수 있습니다. 특히 모델의 특성이나 환경 제약에 따라 예상치 못한 문제가 발생하기도 합니다. 하지만 LangChain의 RunnableLambda와 같은 유연한 도구를 활용하면, LLM의 출력을 단계적으로 가공하고 후처리하여 안정성을 크게 높일 수 있습니다.

여러 차례의 시도와 개선을 통해 얻은 저의 경험이, 혹시 비슷한 JSONDecodeError 문제로 어려움을 겪고 계신 분들께 조금이나마 도움이 되기를 바랍니다. LLM 응답에서 필요한 부분만 추출하는 로직을 RunnableLambda로 겪고 계시는 문제를 해결해보세요! (커스터마이징은 필수입니다 ㅎㅎ)