LLM 응답에서 JSON 추출하기

김정래·2025년 2월 14일

[LLM]

목록 보기
1/1

문제 상황

llm을 통해서 데이터를 생성해낼 때, 응답값을 JSON과 같은 형식으로 받고자 하는 상황이 있다. 이떄 LLM 응답을 JSON으로 하라는 프롬프트를 추가하는 방식을 통해 처리를 하려했지만, LLM 특성상 항상 정확한 응답을 생성하지는 않았다.

대표적인 문제 상황들은 다음과 같다.

  1. Markdown code fence를 포함하는 응답
    사진

llm 이 응답시에 ```json 과 같이 마크다운 코드블럭안에 들어가 있는 경우가 있다.

  1. JSON 문자열과 일반 텍스트가 혼용되어 있는 경우
    사진

코드 블럭 형식은 아니지만, 일반 문자열이 함께 포함되어 있어 정확하게 JSON 부분만 구분하기 어려운 경우이다.

    1. 과 2.가 동시에 등장하는 유형

이런 응답이 발생할 경우 단순하게 JSON.parse() 등으로 처리가 불가능한 어려움이 있어,응답에 프로그래밍적인 가공을 추가하여서, 정확하게 JSON 응답을 추출해보려 한다.

문제 원인 분석

1, 2, 3은 드러나는 형태는 다르지만 원인은 JSON의 중괄호 블럭 뿐만 아니라 다른 응답이 전후에 추가되어 있다. 정도로 정리할 수 있을 것 같다.

엄밀하게 말하자면 JSON 문법에는 일반 문자열이나, 단일 배열, boolean 값 등도 포함된다. 우선 해당 글에서는 { [key: string] : value } 형태의 중괄호 블럭을 JSON이라 부르도록 한다.

해결 방안 고려

전체 문자열에서 JSON 블록을 찾아서 해당 부분을 추출하는 방식을 사용할 것이다.

JSON 블록이 성립하려면 어떤 기준이 필요한지를 먼저 정리를 하자.

JSON 조건

  1. '{' 로 시작하여, '}'로 끝나야한다.
  2. 여는 중괄호와 닫는 중괄호 쌍이 맞아야한다.
  3. 여는 중괄호 다음에는 반드시 "key": 가 등장하고, 다음 중괄호가 나올 수 있다.

여기서 2번 조건에서 문제를 해결할 아이디어를 얻을 수 있었다.
스택을 공부할 때, 괄호쌍을 맞추는 프로그래머스-올바른괄호 와 같은 문제를 쉽게 접할 수 있다.
이러한 문제를 풀었던 기억을 바탕으로 코드를 구현할 수 있을 것 같다.

내가 생각한 알고리즘은 다음과 같다.

간이 JSON 블록 추출 알고리즘

  1. 전체 문자열을 순회하며 '{' 가 나오면 openCount 값을 1 늘리고, '}'가 나오면 openCount를 1 낮춘다.
  2. 처음 '{' 를 만나면 startIndex를 기록한다.

    처음 만난다는 것은 openCount가 0일때 만나는 경우일 것이다.

  3. 마지막 '}' 를 만나면 현재위치를 endIndex로 한다. startIndex부터 endIndex까지 문자열을 substring 한다.

    마지막 '}' 란 openCount가 0이 될 때가 될것이다.

  4. 중괄호를 만나면 inKey를 true로 설정한다. 이후 \"": 이 나와 key 값이 완성되면 inKey를 false로 한다.
  5. inKey 상태에서 중괄호가 등장하면 오류이다. 상태를 초기화한다.
  6. 문자열 순회가 끝났는데 openCount가 0이 아닐 경우 역시 상태 초기화이다.

구현 사항

우선 테스트 코드는 다음과 같이 구성하였다.

json.test.js

const { extractJsonBlock } = require("./json");

describe("extractJsonBlock 함수", () => {
    describe("성공", () => {
        test("단순 JSON 블록 추출", () => {
            // Given
            const text = '{ "key": "value" }';
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBe('{ "key": "value" }');
        });

        test("중첩 JSON 블록 추출", () => {
            // Given
            const text = '{ "key": { "innerKey": "innerValue" } }';
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBe('{ "key": { "innerKey": "innerValue" } }');
        });

        test("혼합 코드 중 JSON 블록 추출", () => {
            // Given
            const text = `
        function test() {
          console.log("Hello, world!");
        }
        { "key": "value", "number": 123 }
        const a = 5;
      `;
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBe('{ "key": "value", "number": 123 }');
        });

        test("마크다운 JSON 코드펜스 내 JSON 블록 추출", () => {
            // Given
            const text = `
            # 예제 마크다운 문서
            다음은 JSON 코드 예시입니다:
            \`\`\`json
            { "key": "value", "array": [1, 2, 3] }
            \`\`\`
            위 코드는 예시로 사용됩니다.
          `;
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBe('{ "key": "value", "array": [1, 2, 3] }');
        });
    });

    describe("실패", () => {
        test("닫는 중괄호가 없는 경우", () => {
            // Given
            const text = '{ "key": "value"';
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBeNull();
        });

        test("JSON 블록이 없는 경우", () => {
            // Given
            const text = "이 텍스트에는 JSON이 없습니다.";
            // When
            const result = extractJsonBlock(text);
            // Then
            expect(result).toBeNull();
        });
    });
});

이에 맞춰 작성한 extractJsonBlock 메서드는 다음과 같다.

json.js

/**
 * @typedef {Object} State
 * @property {number} openCount - 여는 중괄호의 개수
 * @property {number} startIndex - JSON 블록의 시작 인덱스
 * @property {number} endIndex - JSON 블록의 종료 인덱스
 * @property {boolean} inKey - 현재 키 처리가 진행 중인지 여부
 */

/**
 * 초기값을 가진 새로운 상태 객체를 반환
 *
 * @returns {State} 초기화된 상태 객체
 */
const _resetState = () => ({
    openCount: 0,
    startIndex: -1,
    endIndex: -1,
    inKey: false,
});

/**
 * 여는 중괄호 '{'를 처리하고 새로운 상태 객체를 반환
 * 만약 inKey 상태에서 호출되면 초기 상태를 반환
 *
 * @param {State} state - 현재 상태 객체
 * @param {number} index - 여는 중괄호의 인덱스
 * @returns {State} 갱신된 상태 객체
 */
const _handleOpeningBrace = (state, index) => {
    if (state.inKey) {
        return _resetState();
    }
    const newState = { ...state };
    if (newState.openCount === 0) {
        newState.startIndex = index;
    }
    newState.openCount += 1;
    newState.inKey = true;
    return newState;
};

/**
 * 닫는 중괄호 '}'를 처리하고 새로운 상태 객체를 반환
 * 만약 inKey 상태에서 호출되면 초기 상태를 반환

 * @param {State} state - 현재 상태 객체
 * @param {number} index - 닫는 중괄호의 인덱스
 * @returns {State} 갱신된 상태 객체
 */
const _handleClosingBrace = (state, index) => {
    if (state.inKey) {
        return _resetState();
    }
    const newState = { ...state };
    newState.openCount -= 1;
    if (newState.openCount === 0) {
        newState.endIndex = index;
    }
    return newState;
};

/**
 * 주어진 텍스트에서 첫 번째 유효한 JSON 블록을 추출
 *
 * @param {string} text - 입력 텍스트.
 * @returns {string|null} 추출된 JSON 블록 문자열, 유효한 블록이 없으면 null을 반환.
 */
const extractJsonBlock = (text) => {
    let state = /** @type {State} */ ({
        openCount: 0,
        startIndex: -1,
        endIndex: -1,
        inKey: false,
    });

    for (let i = 0; i < text.length; i += 1) {
        const ch = text[i];

        if (ch === "{") {
            state = _handleOpeningBrace(state, i);
        } else if (ch === ":") {
            state = { ...state, inKey: false };
        } else if (ch === "}") {
            state = _handleClosingBrace(state, i);
            if (state.endIndex !== -1) {
                return text.substring(state.startIndex, state.endIndex + 1);
            }
        }
    }

    return null;
};

module.exports = { extractJsonBlock };

결과

테스트는 전부 잘 성공했고, 해당 코드로 json 을 추출한 후 JSON.parse시 오류 발생률이 현저하게 감소하였다.

alt text

추가 고려 사항

현자 key 처리를 매우 간소화하였으며, 실제 JSON 정의와도 맞지 않는 부분이 있다.
이러한 처리는 실제 요구사항과 상태에 맞게 추가적인 처리가 필요할 것이다.

profile
https://github.com/Jeong-Rae/Jeong-Rae

0개의 댓글