SJ_Koding

[LLM] LLM Response 실시간 출력 & Markdown 적용하기 (langchain, showdown) 본문

LLM

[LLM] LLM Response 실시간 출력 & Markdown 적용하기 (langchain, showdown)

성지코딩 2024. 5. 28. 16:14

LLM 모델을 기능에 따라 파인튜닝 시켜 챗봇 시스템을 구축하고, 웹 개발팀에 넘기기 전 LLM의 실시간 스트리밍 출력과, 출력이 Markdown언어일 때 ChatGPT처럼 실시간으로 Markdown문법이 적용되게 끔 구현해보았다.

실시간 Markdown to HTML로 변환되는 모습

웹 개발자가 아니어서 가장 Basic한 언어를 사용했다. 

프론트 : HTML, CSS, JavaScript
백엔드: FastAPI

만약 ChatGPT처럼 가독성 좋게 답변하길 원한다면, 아래 과정을 거치기 전에 System 프롬프트 튜닝으로 "markdown 형식으로 가독성 좋게 답변해줘" 식으로  프롬프트를 추가해주자.

# 내용
- 그러면 이런식으로
- markdown 문법에 따라 그대로 변환없이 반환할텐데

## 변환 방법
- 그 방법을 아래에 소개하겠다.

 

MD변환 방법론 요약:

스트리밍 출력에서 한 번 모델이 내뱉은 토큰을 주로 청크(chunk)라고 부르는데, 웹 서버에서 청크를 가져올 때 마다 청크에 MD2HTML(Markdown to HTML)을 적용하게 되면, 적용이 당연히 안된다. 왜? 거의 한 두 글자씩 가져와서 이를 변환하면 큰 맥락을 보지 못하고 이에따라 변환도 되지 않기 때문이다. 운이 좋아서 '# 안' 이 하나의 청크에 담겼다고 할때는 저 '안'이라는 글자만 굉장히 크게 나올 것이다. 대충 아래처럼말이다.

따라서, 정말 비효율적이겠지만 적절한 방법이 떠오르지 않아. 청크를 받아올 때 마다 청크를 기존 출력에 더한 후 통째로 HTML로 변환하는 로직을 수행했다. 줄 바꿈이 될 때 마다 변환되지 않은 문장을 변환하자니 테이블형태를 완성시킬 수 없고 숫자리스트의 숫자가 이어지지 않을 것이다. (문법 변환 시, 이전 맥락을 기억하지 않으므로)

 

준비사항

각자 상황에 따른 코드를 분석해야한다. 어떤 곳에서 스트리밍 출력을 완성 시키고, 어떤변수가 어떻게 청크를 받아오며, 화면에 어떻게 나타내는지 분석이 필요하다.

 

Streaming & Markdown정리: 순서대로 보는게 아니라 흐름을 파악한 후 적용

HTML

<Head> 내부에 아래와 같은 스크립트를 넣어주자. showdown을 사용할 수 있게된다.

<script src="https://cdn.jsdelivr.net/npm/showdown@1.9.1/dist/showdown.min.js"></script>

 

JavaScript

var converter = new showdown.Converter();  // Showdown Converter 인스턴스 생성

var converter = new showdown.Converter();  // Showdown Converter 인스턴스 생성

converter를 전역변수로 설정해준다.

 

Python (FastAPI)

from langchain.memory import ConversationBufferWindowMemory
from langchain.chains import LLMChain

lagnchain의 ConversationBufferWindowMemory를 통해 대화 내역을 기록했으며,

langchain의 LLMChain을 사용하여 실시간으로 스트리밍된 청크를 보내기 위해 astream_log를 사용할 수 있다.

그리고 이를 async로 불러와서 SteramingResponse를 사용해 웹서버에 뿌려주는 형태이다.

예를들어 아래의 흐름과 같다. 환경에 맞게 적절히 변형해보자.

llm = LlamaCpp(
    model_path = '@@@',
    temperature=@@@,
    max_tokens=2048,  #모델이 한번에 만들 수 있는 총 토큰 수
    top_p=@@@,
    callback_manager=callback_manager,
    streaming=True,
    stop=['Human:'],
    n_ctx = 32000,    #한번에 들어갈 수 있는 총 토큰 수
    n_threads=8,
    n_gpu_layers=@@@,
    n_batch=32
) # LLM model load (langchain 사용)

위와 같이 모델을 로드하고

prompt = ChatPromptTemplate.from_messages( # 오픈소스를 확인하여 format을 반드시 확인!
    [
        SystemMessage(
            content= "<시스템 프롬프트> 사용하는 모델의 format에 맞춰야 함!"
        ),
        MessagesPlaceholder(
            variable_name="chat_history"
        ), 
        HumanMessagePromptTemplate.from_template(
            "<사용자 프롬프트> 사용하는 모델의 format에 맞춰야 함!"
        ),  
        AIMessagePromptTemplate.from_template(
            "<답변> 사용하는 모델의 format에 맞춰야 함!"
        )
    ]
)

memory = ConversationBufferWindowMemory(memory_key='chat_history', return_messages=True)
llm_chain = LLMChain(llm=llm, prompt=prompt, memory=memory, verbose=True)

챗 리스트를 저장할 메모리를 만든 후, LLMChain 형성

async def gen(x, temp):
    chunks = '' # 챗 리스트에 최종 응답을 저장하기위한 변수
    x = temp + add_text(x)
    async for chunk in llm_chain.astream_log(input=x): # 모델의 답변 streaming load
        if type(chunk.ops[0]['value']) == type(' '): # astream_log의 가장 첫 번째 청크는 모델의 응답이 아님.
            yield json.dumps(
                {
                    "code": 200,
                    "message": "success",
                    "data": [
                        {
                            "delta": {"content": chunk.ops[0]['value']},
                            "logprobs": None,
                            "index": 0,
                            "finish_reason": None,
                        }
                    ],
                },
                ensure_ascii=False,
            )
            
            chunks += chunk.ops[0]['value']
            await asyncio.sleep(0.03) # 응답속도 조절

    memory.save_context({'user':x},{'assistant':chunks})

비동기식으로 모델의 output을 atream_log를 사용해서 chunk로 하나하나 가져온 후 yield로 매번 뿌림 (json형태 사용)

from fastapi.responses import StreamingResponse

async def generate_report(request: Request):
    data = await request.json()
    user_input = data.get("input", "")
    input_check = check(user_input)

    if input_check == 'temp':
        print('report')
        return StreamingResponse(gen(user_input,  temp), media_type="application/json")

yield로 뿌려진 chunk를 가져와서 StreamingResponse로 뿌려줌으로써 실시간으로 모델의 응답을 웹서버로 전송하게된다.

 

다시 JavaScript 로 돌아와서. Back에서 뿌린 청크를 받는 곳을 찾거나

구현하여 웹사이트에 나타내기 전에 앞서 선언한 converter의 makeHtml을 호출하여 innerHTML값 에 대입해 출력물을 실시간으로 변환할 수 있다.

temp += content // content가 chunk임
aiMessageElement.innerHTML = converter.makeHtml(temp)

여기서 makeHtml은 인자로 받은 문자열을 markdown에 따라 변환한 후 <p>태그를 추가하기 때문에, 만약. innerHtml자체를 넣는다면 한 글자 한 글자 들어올 때 마다 <p>가 추가되기 때문에

청크마다 줄바꿈이 되버리는 현상을 겪을 수 있다. 따라서, 

let temp = '<h4>응답:</h4>'

과 같이 temp를 초기화를 해주고, 이 temp는 문자열만 저장하는 임시변수역할이 되어 

청크가 쌓일 때 마다 temp를 통째로 HTML로 변환을 할 수 있는것이다.

 

결과적으로 상단부 GIF처럼 출력이 완성된다. 본인의 코드에 맞게 많은 것을 수정해야할 필요가 있다.