본문 바로가기
iOS

openAI chat API SSE (text/event-stream) 적용해보기

by vapor3965 2023. 6. 22.

목차

     

    목차


      chatGPT 웹에서 대화시 텍스트가 따다다닥 박히는데, SSE 방식이라고 파트장님이 알려주셔서 한번 간단하게 적용해보았다. 

       

      느낀점 

      1. 재밌고 신선하다.

      어떻게보면 파일 송수신이나 같이 특별히 다른점은 없는데, text로 받아보는건 처음이라 굉장히 신선하다.

      텍스트를 기다리고 한번에 보여주냐, 기다리지 않고 하나하나 보여주냐 차이긴한데.. 서버에서도 이런걸 지원하면 재밌을것 같다. 


      2. chatGPT 최고..

      빠르게 확인하고 싶고, 코드 작성도 귀찮고 모르는부분은 chatGPT에 코드 물어봤는데 너무 잘 알려줌..

      요새 느끼지만 확실히 많은 도움이 되는 것 같다. 점점 코드를 작성하기보다는 코드를 어떻게 조립할지가 더 중요할 것 같다. 

      ( 물론 chatGPT가 항상 정답은 아니라서, 검증은 꼭 필요. ) 

       

      3. 프로토콜 규격이 중요하다~ 합의된 규약이 정말 중요하군 

       

       

       

       

      시작 


      보통 클라에서 http로 API호출할 경우 하나요청하고 하나 응답받고 끝나는데,

       

      아래 chatGPT 처럼 대화가 따다다닥 박히는 것은 다른 방식이여야 한다.

       

      하나 요청하고 하나 응답받는것도 가능하다. 대부분 openAI 이용한 써드파티앱들이 그러한것 같다. 

      대신 단점이 저 모든 텍스트들이 한번에 응답으로 들어오기때문에 사용자입장에서는 느리다는 기분이 든다.



      chatGPT웹처럼 바로바로 보여주면 얼마나 좋을까?!  (그냥 기분이 좋음)

       

      구동방식은 SSE (server-sent-event),  클라에서 요청없이 서버에서 보내는 이벤트다. 

       

      SSE구현을 위해 서버에서는 Content-Type이 text/event-stream 내려주는 것 같다. 

       

       

       

       

      요약 

      적용하는 건 간단하다. 

      openAI API 요청시 body에 stream: true 추가하고,

      session 유지해서
      들어오는 이벤트를 적절히 파싱해서 보여주면 끝!

       

       

       

       

      방법

      openAI 의 API를 이용하려면 우선 가입하고,

      paid account 추가해야한다.  ( 무료였던것 같은데 ㅠ) 
      ( paid account 추가하지 않으면 API 요청시 응답으로 billing확인하라는 에러 떨어짐. )

      암튼, paid account추가하고 5분정도? 뒤부터 잘 내려왔다. 

       

       

      그리고 key를 발급받는다. ( 다시는 안보여주니 잘 메모해야함. 새로 발급받아도 되고.. ) 

       

       

      openAI에 API관련은 아래에서 확인 가능하다.

      https://platform.openai.com/docs/api-reference/authentication 

       

      OpenAI Platform

      Explore developer resources, tutorials, API docs, and dynamic examples to get the most out of OpenAI's platform.

      platform.openai.com

       

      API도 다양한것 같고, 나는 chatGPT웹 처럼 대화방식을 원하므로, 왼쪽 리스트에서 Chat을 보면 된다. 

      ( Chat만 보면 안되고, 필수인 Authentication, Making requests 봐야함 ) 

       

       

      헤더에 키를 추가하고 ( Bearer 이 붙어야함) 

      body에 json형식으로 아래처럼 POST 메소드로 호출하면 응답이 꽂힌다! 

       

      --header 'Authorization: Bearer 키이름' \
      --header 'Content-Type: application/json' \
      --data '{
      "model": "gpt-3.5-turbo",
      "messages": [{"role": "user", "content": "오늘 날씨 알려줘"}],
      "temperature": 0.7,
      "n": 2
      }'

       

      Swift로는 아래처럼 작성하면 끝! ( ChatGPT가 작성해줌ㅎ ) 

       

      func sendPostRequest() {
          // 1. URL 생성
          if let url = URL(string: "https://api.openai.com/v1/chat/completions") {
              // 2. URLRequest 생성
              var request = URLRequest(url: url)
              request.httpMethod = "POST"
              
              // 3. HTTP 헤더 설정 (선택 사항)
              request.setValue("Bearer 키이름", forHTTPHeaderField: "Authorization")
              request.setValue("application/json", forHTTPHeaderField: "Content-Type")
              
              // 4. 요청 데이터 생성
              let jsonData: [String: Any] = [
                  "model": "gpt-3.5-turbo",
                  "messages": [
                          ["role": "user", "content": "오늘 날씨 알려줘"]
                  ],
                  "temperature": 0.7
              ]
              
              // 5. 요청 데이터를 JSON 형식으로 변환
              if let postData = try? JSONSerialization.data(withJSONObject: jsonData) {
                  // 6. 요청 데이터를 요청의 본문에 할당
                  request.httpBody = postData
                  
                  // 7. URLSession 생성
                  let session = URLSession.shared
                  
                  // 8. URLSessionDataTask 생성
                  let task = session.dataTask(with: request) { (data, response, error) in
                      if let error = error {
                          print("Error: \(error.localizedDescription)")
                          return
                      }
                      
                      // 9. 응답 처리
                      if let httpResponse = response as? HTTPURLResponse {
                          if let responseData = data {
                              // responseData를 사용하여 응답 데이터 처리
                              print("Response: \(String(data: responseData, encoding: .utf8) ?? "")")
                          }
                          
                          if httpResponse.statusCode == 200 {
                              // HTTP 요청이 성공한 경우
                              
                          } else {
                              // HTTP 요청이 실패한 경우
                              print("Error: \(httpResponse.statusCode)")
                          }
                      }
                  }
                  
                  // 10. 요청 시작
                  task.resume()
              }
          }
      }

       

       

       

       

      여기까지가 일반적인 http 통신방법으로 구현한거고, 

       

      SSE를 적용해보자. 

      API레퍼런스 - Chat에 보면 Body안에 stream 을 true로 넣으면 server-sent events로 토큰들이 내려온다한다. 

       

       

      위에서 적은 URLSession코드는 한번 요청하고 끝나는 코드라, 다른 방법이 필요하다. ( 역시 ChatGPT가 작성해줌.ㅎ.. ) 

      URLSessionDataDelegate를 이용해서 서버에서 계속해서 쏴주는 이벤트를 읽어들이면 된다. 

       

      class SSEClient: NSObject, URLSessionDataDelegate {
          var url: URL = URL(string: "https://api.openai.com/v1/chat/completions")!
          var session: URLSession?
          var task: URLSessionDataTask?
          
          var delegate:SSEClientDelegate?
          
          var content: String
          var temp: Double
          
          init(content: String, temp: Double) {
              self.content = content
              self.temp = temp
          }
          
          func start() {
              let sessionConfiguration = URLSessionConfiguration.default
              sessionConfiguration.timeoutIntervalForRequest = 60 // 선택 사항, 요청 타임아웃 설정
              
              session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil)
              var request = URLRequest(url: url)
              request.httpMethod = "POST"
              
              // 3. HTTP 헤더 설정 (선택 사항)
              request.setValue("Bearer 키이름", forHTTPHeaderField: "Authorization")
              request.setValue("application/json", forHTTPHeaderField: "Content-Type")
              
              // 4. 요청 데이터 생성
              let jsonData: [String: Any] = [
                  "model": "gpt-3.5-turbo",
                  "messages": [
                          ["role": "user", "content": self.content]
                  ],
                  "temperature": self.temp,
                  "stream": true,
                  "n": 1
              ]
              let postData = try! JSONSerialization.data(withJSONObject: jsonData)
              request.httpBody = postData
              
              task = session?.dataTask(with: request)
              task?.resume()
          }
          
          func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
              // SSE 이벤트 수신
              if let message = String(data: data, encoding: .utf8) {
                  print("Received SSE message:\n\(message)")
                  // 여기에서 수신한 이벤트 데이터를 원하는 방식으로 처리할 수 있습니다.
              }
          }
          
          func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
              // SSE 연결 종료
              if let error = error {
                  print("SSE connection error: \(error.localizedDescription)")
              } else {
                  print("SSE connection completed.")
              }
              
              // 재연결 논리를 추가하려면 여기에 적절한 로직을 구현할 수 있습니다.
          }
      }
      
      
      class SomeViewController: UIViewController, URLSessionDataDelegate {
      	...
      	let session = SSEClient(content: "날씨를 알려줄래?", temp: 0.7)
      	session.delegate = self
      	session.start()
          ...
      }

       

       

      start()하게되면 이제 로그콘솔에 따다다닥 꽂히는걸 볼수있다! 

      마지막에는 data: [DONE]으로 꽂히면서 연결이 끊긴다. 

       

      끝~~

       

       

       

       

      이라고 하기에는, 이슈가 두개정도 있었다.

      저렇게 꽂히는걸 잘 파싱해서 보기좋은 한글로 만들어줘야한다.

       

       

       

      이슈 ...💩

      구현자체는 간단한데, 읽어들일때가 문제다.

      아래처럼 요딴식으로 계속 꽂히는데, 

       

      data: {"id":"chatcmpl-7TrrqtceJvMF5Viuhby5OY8SWRgr3","object":"chat.completion.chunk","created":1687352934,"model":"gpt-3.5-turbo-0301","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}

       

       

      어 음, json 형식같네!  OK!

      ...

      (변환중)

      ...

       

      근데 자꾸 변환이 잘안되길래, 보니까, 맨앞에 data가 ""가 없다.  "data": 이래야하는데..

      또 전체적으로 감싸는 { } 이게 없었다.

       

      그래서 일단은 data: 요길이만큼 drop해서 json형식으로 변환이 가능해졌다. 

       

      잘읽어들여지나 싶다가도 뭔가 단어들이 빠지는 것 같다.

       

      다시 보니...  이렇게 두개가 한번에 꽂히는 경우가 있더라. 

       

      data: {"id":"chatcmpl-7TsExaw0Vn2g6tcJumX72e8V1g4Ew","object":"chat.completion.chunk","created":1687354367,"model":"gpt-3.5-turbo-0301","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

       

      data: {"id":"chatcmpl-7TsExaw0Vn2g6tcJumX72e8V1g4Ew","object":"chat.completion.chunk","created":1687354367,"model":"gpt-3.5-turbo-0301","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}

       

       

      ㅎㅎ.. 

       

       

      파싱을 어떻게 할까..하다가 문득 text/event-stream 생각났고, 이것의 규격이지 않을까?  (정답)

       

      utf-8로 인코딩된 text Data이여야하고,  각 메세지들은 줄바꿈 문자 두개로 구분된다! 

      각 메세지는 필드이름: 텍스트로 구성된다.

       

       

       

      Data를 String으로 만들어놓고,  메세지가 여러개있을 수 있으니, \n\n으로 구분해놓고, 

      필드값만 추출해야하니, 첫번째 콜론 : 가 속한 인덱스 이후부터 subString얻어서, codable로 만들었다.

       

       

      ( 대박... gpt가 Codable로 알아서 만들어주고.. 소름.  너가 개발자해..

      // JSON 데이터를 매칭할 Codable 구조체 정의
      struct Response: Codable {
          let id: String
          let object: String
          let created: Int
          let model: String
          let choices: [Choice]
          
          struct Choice: Codable {
              let index: Int
              let delta: Delta
              let finish_reason: String?
              
              struct Delta: Codable {
                  let content: String
              }
          }
      }
      
      // SSEClient 
      
      	func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
              // SSE 이벤트 수신
      
              print("🎉Event!")
              if let dataString = String(data: data, encoding: .utf8) {
                  let components = dataString.components(separatedBy: "\n\n")
                  
                  for component in components {
                      if let index = component.firstIndex(of: ":") {
                          let afterIndex = component.index(after: index)
                          let subString = component.suffix(from: afterIndex)
                          if let jsonData = subString.data(using: .utf8) {
                              DispatchQueue.main.async {
                                  if let jsonObject = try? JSONDecoder().decode(Response.self, from: jsonData)
                                  {
                                      let value = jsonObject.choices.first?.delta.content
                                      print(value)
                                  }
                              }
                          }
                      }
                  }
              }
          }

       

       

       

       

      끝! 

       

       

       

      ( 적용기 )

       

      댓글