Unity C#에서 Local LLM(Ollama·LM Studio) API를 비동기 스트리밍으로 연동하는 방법

Unity C#에서 Local LLM(Ollama·LM Studio) API를 비동기 스트리밍으로 연동하는 방법

Unity C# 클라이언트에서 Ollama와 LM Studio의 로컬 LLM API를 통합하고 HttpClient·CancellationToken·청크 파싱으로 UI 텍스트를 끊김 없이 비동기 스트리밍하는 구현 방법을 정리합니다.

TL;DR

Unity에서는 HttpClient로 로컬 LLM 서버에 POST 요청을 보내고 응답 스트림을 HttpCompletionOption.ResponseHeadersRead로 즉시 읽으면 생성 중인 토큰을 UI에 순차 반영할 수 있다.

Ollama는 NDJSON 기반 /api/chat 스트림을 LM Studio는 OpenAI 호환 SSE 기반 /v1/chat/completions 스트림을 주로 제공하므로 응답 파서만 분리하면 하나의 C# 인터페이스로 통합할 수 있다.

NDJSON(Newline Delimited JSON)은 각 줄이 독립적인 JSON 객체로 이루어져 있고 줄바꿈 문자(\n)로 구분되는 텍스트 형식입니다. JSON Lines(JSONL)라고도 부릅니다.

Local LLM을 게임 클라이언트에 연결하는 이유

로컬 LLM은 NPC 대화, 개발용 퀘스트 초안 생성, 플레이 테스트용 로그 요약처럼 네트워크 지연이나 외부 API 비용을 줄이고 싶은 기능에 적합하다. Ollama와 LM Studio는 모두 PC에서 모델 서버를 실행할 수 있지만 HTTP API의 요청 형식과 스트리밍 형식이 다르다.

항목OllamaLM Studio
대표 엔드포인트/api/chat/v1/chat/completions
요청 형식자체 JSON 형식OpenAI Chat Completions 호환 형식
스트리밍 형식줄 단위 NDJSONdata: 접두사가 붙은 SSE
스트림 종료done: truedata: [DONE]
Unity 구현 핵심한 줄씩 JSON 파싱data: 제거 후 JSON 파싱

서버 주소는 개발 환경에서만 설정 파일로 관리하고 배포 빌드에는 사용자가 서버를 실행하지 않은 상태도 고려해야 한다. 특히 모바일·콘솔 빌드에서 로컬 서버 접근을 전제로 설계하면 동작하지 않을 수 있다.

Unity 클라이언트가 Ollama와 LM Studio 로컬 서버에 요청하고 스트리밍 응답을 UI에 반영하는 구조

flowchart LR
    U[Unity UI] --> C[C# LocalLlmClient]
    C -->|HTTP POST| P{Provider}
    P -->|NDJSON| O[Ollama /api/chat]
    P -->|SSE| L[LM Studio /v1/chat/completions]
    O --> C
    L --> C
    C -->|토큰 청크| U

Unity C# 비동기 텍스트 스트리밍은 어떻게 구현할까?

핵심은 응답 본문 전체를 기다리는 PostAsync 기본 흐름 대신, 헤더를 받는 즉시 본문을 읽는 것이다. HttpCompletionOption.ResponseHeadersRead를 사용하고 ReadLineAsync로 스트림을 소비한다.

1. 공통 메시지와 스트리밍 인터페이스를 정의한다

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

public readonly record struct ChatMessage(string Role, string Content);

public interface ILocalLlmClient
{
    Task StreamChatAsync(
        IReadOnlyList<ChatMessage> messages,
        Action<string> onToken,
        CancellationToken cancellationToken);
}

onToken은 모델이 생성한 텍스트 조각을 받는다. UI 갱신, 로그 기록, 음성 합성 큐 추가를 호출자 쪽에서 선택할 수 있어 게임플레이 코드와 HTTP 구현이 분리된다.

2. Ollama NDJSON 응답을 한 줄씩 처리한다

Ollama의 /api/chat 요청에는 stream: true를 넣는다. 각 줄은 독립적인 JSON 객체이며 마지막 객체에는 일반적으로 done: true가 포함된다.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public sealed class OllamaClient : ILocalLlmClient
{
    private readonly HttpClient _httpClient;
    private readonly string _model;

    public OllamaClient(HttpClient httpClient, string model)
    {
        _httpClient = httpClient;
        _model = model;
    }

    public async Task StreamChatAsync(
        IReadOnlyList<ChatMessage> messages,
        Action<string> onToken,
        CancellationToken cancellationToken)
    {
        var payload = new
        {
            model = _model,
            stream = true,
            messages
        };

        using var request = new HttpRequestMessage(
            HttpMethod.Post,
            "http://127.0.0.1:11434/api/chat")
        {
            Content = new StringContent(
                JsonSerializer.Serialize(payload),
                Encoding.UTF8,
                "application/json")
        };

        using var response = await _httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);

        response.EnsureSuccessStatusCode();

        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new System.IO.StreamReader(stream);

        while (!reader.EndOfStream)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var line = await reader.ReadLineAsync();

            if (string.IsNullOrWhiteSpace(line))
                continue;

            using var json = JsonDocument.Parse(line);
            var root = json.RootElement;

            if (root.TryGetProperty("message", out var message) &&
                message.TryGetProperty("content", out var content))
            {
                onToken(content.GetString() ?? string.Empty);
            }

            if (root.TryGetProperty("done", out var done) && done.GetBoolean())
                break;
        }
    }
}

3. LM Studio SSE 응답에서 data:를 제거해 파싱한다

LM Studio의 OpenAI 호환 서버는 stream: true일 때 SSE 형태의 줄을 반환한다. 일반 데이터 줄은 data: {JSON}이며 종료 신호는 data: [DONE]이다.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public sealed class LmStudioClient : ILocalLlmClient
{
    private readonly HttpClient _httpClient;
    private readonly string _model;

    public LmStudioClient(HttpClient httpClient, string model)
    {
        _httpClient = httpClient;
        _model = model;
    }

    public async Task StreamChatAsync(
        IReadOnlyList<ChatMessage> messages,
        Action<string> onToken,
        CancellationToken cancellationToken)
    {
        var payload = new
        {
            model = _model,
            stream = true,
            messages
        };

        using var request = new HttpRequestMessage(
            HttpMethod.Post,
            "http://127.0.0.1:1234/v1/chat/completions")
        {
            Content = new StringContent(
                JsonSerializer.Serialize(payload),
                Encoding.UTF8,
                "application/json")
        };

        using var response = await _httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);

        response.EnsureSuccessStatusCode();

        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new System.IO.StreamReader(stream);

        while (!reader.EndOfStream)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var line = await reader.ReadLineAsync();

            if (!line.StartsWith("data: ", StringComparison.Ordinal))
                continue;

            var data = line[6..];
            if (data == "[DONE]")
                break;

            using var json = JsonDocument.Parse(data);
            var choices = json.RootElement.GetProperty("choices");

            if (choices.GetArrayLength() == 0)
                continue;

            var delta = choices[0].GetProperty("delta");
            if (delta.TryGetProperty("content", out var content))
                onToken(content.GetString() ?? string.Empty);
        }
    }
}

Unity UI에 스트리밍 토큰을 안전하게 표시하려면?

HttpClient의 비동기 연속 작업이 Unity 메인 스레드에서 실행된다고 가정하지 않는 편이 안전하다. UI 객체는 메인 스레드에서만 갱신하도록 토큰을 큐에 넣고 Update()에서 소비한다.

using System.Collections.Concurrent;
using TMPro;
using UnityEngine;

public sealed class StreamingTextView : MonoBehaviour
{
    [SerializeField] private TMP_Text outputText;
    private readonly ConcurrentQueue<string> _pendingTokens = new();
    private string _fullText = string.Empty;

    public void EnqueueToken(string token)
    {
        _pendingTokens.Enqueue(token);
    }

    private void Update()
    {
        var changed = false;

        while (_pendingTokens.TryDequeue(out var token))
        {
            _fullText += token;
            changed = true;
        }

        if (changed)
            outputText.text = _fullText;
    }

    public void Clear()
    {
        _fullText = string.Empty;
        outputText.text = string.Empty;
    }
}

매 토큰마다 긴 문자열을 반복 연결하면 GC 할당량이 커질 수 있다. 응답이 길거나 동시 대화가 많은 게임이라면 StringBuilder를 사용하고 한 프레임에 처리할 토큰 수를 제한하거나 일정 문자 수마다 UI를 갱신한다.

스트리밍 토큰 큐를 Update에서 소비해 TMP 텍스트를 메인 스레드에서 갱신하는 Unity UI 흐름

취소와 타임아웃은 왜 반드시 처리해야 하는가?

플레이어가 대화창을 닫거나 NPC 상호작용 대상이 바뀌면 진행 중인 생성 요청도 즉시 중단해야 한다. 그렇지 않으면 이전 대화의 토큰이 새 UI에 섞이고 불필요한 CPU·GPU 추론이 계속된다.

  1. 대화 요청마다 CancellationTokenSource를 새로 만든다.
  2. 새 요청 시작 전 기존 CancellationTokenSource.Cancel()을 호출한다.
  3. OnDisable 또는 씬 전환 시에도 취소하고 Dispose()한다.
  4. HttpClient.Timeout 또는 별도의 CancelAfter로 서버 무응답 시간을 제한한다.
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public sealed class NpcChatController : MonoBehaviour
{
    private CancellationTokenSource _requestCts;

    public async Task AskAsync(ILocalLlmClient client, StreamingTextView view)
    {
        _requestCts?.Cancel();
        _requestCts?.Dispose();
        _requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(60));

        view.Clear();

        try
        {
            await client.StreamChatAsync(
                new[] { new ChatMessage("user", "안녕. 오늘 마을에 무슨 일이 있었어?") },
                view.EnqueueToken,
                _requestCts.Token);
        }
        catch (OperationCanceledException)
        {
            // 대화 종료, 새 요청, 타임아웃은 정상 흐름으로 처리한다.
        }
        catch (Exception exception)
        {
            Debug.LogException(exception);
        }
    }

    private void OnDisable()
    {
        _requestCts?.Cancel();
    }
}

Ollama와 LM Studio를 하나의 설정으로 전환하는 방법

프로바이더별 차이는 URL과 스트림 파서에 집중되어 있다. 게임 설정에서 LocalLlmProvider를 선택하고 인터페이스 구현체만 교체하면 NPC, 디버그 콘솔, 퀘스트 도구는 같은 호출 코드를 사용할 수 있다.

체크 항목권장 처리
서버 미실행연결 실패 메시지와 재시도 버튼 표시
모델 미로드서버 상태 확인 후 모델 선택 UI 제공
프롬프트 길이대화 이력 개수와 문자 수 상한 설정
생성 길이max_tokens 또는 서버별 출력 길이 제한 설정
UI 갱신 빈도프레임마다 큐를 일괄 반영
대화 취소요청별 CancellationTokenSource 관리
민감 정보로컬 서버라도 프롬프트와 로그 저장 정책 점검

자주 묻는 질문 (FAQ)

UnityWebRequest 대신 HttpClient를 사용해도 되는가?

가능하다. 스트림을 줄 단위로 읽고 취소 토큰을 연결하는 코드에서는 HttpClient가 간결하다. 다만 프로젝트의 Unity 버전과 대상 플랫폼에서 HttpClient 지원 상태를 먼저 확인하고 기존 네트워크 계층이 UnityWebRequest 중심이라면 그 구조에 맞춰 통일하는 편이 좋다.

스트리밍 응답이 한 글자씩 오지 않는 이유는 무엇인가?

LLM 서버는 항상 문자 단위가 아니라 토큰 또는 전송 청크 단위로 데이터를 보낸다. 따라서 콜백에서 받은 문자열을 그대로 누적해야 하며 청크 경계만 보고 단어 경계나 문장 종료를 판단하면 안 된다.

로컬 LLM을 실제 게임에 넣어도 되는가?

PC용 개발 도구나 선택 기능에는 적합하지만 모든 플레이어의 PC에서 같은 모델·성능·서버 실행 상태를 보장하기는 어렵다. 출시 기능이라면 최소 사양, 모델 배포 용량, 오프라인 실패 처리, 생성 시간 제한을 명확히 설계해야 한다.

정리

Local LLM 스트리밍 구현의 본질은 서버가 생성 중인 조각을 즉시 읽고 Unity UI에는 메인 스레드에서 안전하게 반영하는 것이다. Ollama의 NDJSON과 LM Studio의 SSE를 각각 파싱하되 ILocalLlmClient로 감싸면 모델 서버를 바꾸더라도 게임 클라이언트의 대화 기능을 안정적으로 유지할 수 있다.

#Unity#C##Local LLM#Ollama#LM Studio#비동기 스트리밍#게임 AI

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs