話:實(shí)時(shí)交互與JSON解析實(shí)戰(zhàn))
1. 項(xiàng)目概述為什么要在Unity里搞流式AI對(duì)話如果你正在開發(fā)一款需要智能對(duì)話的游戲比如一個(gè)能和玩家深度互動(dòng)的NPC或者一個(gè)內(nèi)置了AI助手的虛擬世界你肯定不希望玩家問(wèn)完問(wèn)題后對(duì)著一個(gè)空白的對(duì)話框干等十幾秒然后“唰”一下蹦出一大段完整的回復(fù)。這種體驗(yàn)太割裂了一點(diǎn)也不“實(shí)時(shí)”。這就是我們今天要解決的問(wèn)題在Unity里如何像刷短視頻一樣讓AI的回復(fù)一個(gè)字一個(gè)字地“流”出來(lái)實(shí)現(xiàn)真正的實(shí)時(shí)交互。這個(gè)項(xiàng)目的核心就是打通Unity這個(gè)強(qiáng)大的游戲引擎與Ollama這個(gè)可以本地運(yùn)行大模型的工具。Ollama提供了標(biāo)準(zhǔn)的HTTP API特別是它的流式stream響應(yīng)模式允許服務(wù)器一邊生成文本一邊分片發(fā)送給客戶端。我們的任務(wù)就是讓Unity能穩(wěn)穩(wěn)地接住這些“數(shù)據(jù)碎片”并流暢地拼裝、清洗、展示給玩家。我最近在幾個(gè)獨(dú)立游戲項(xiàng)目中集成了這個(gè)功能實(shí)測(cè)下來(lái)它能極大提升對(duì)話的沉浸感和響應(yīng)感。無(wú)論你是想做個(gè)AI桌寵、劇情生成器還是復(fù)雜的游戲內(nèi)引導(dǎo)系統(tǒng)這套方案都能作為堅(jiān)實(shí)的技術(shù)底座。2. 核心思路與架構(gòu)設(shè)計(jì)不止是發(fā)個(gè)請(qǐng)求那么簡(jiǎn)單很多人第一次嘗試時(shí)會(huì)想當(dāng)然地用UnityWebRequest發(fā)個(gè)POST請(qǐng)求然后等downloadHandler.text返回完整結(jié)果。這在非流式模式下沒(méi)問(wèn)題但一旦開啟流式這條路就走不通了。流式響應(yīng)本質(zhì)是一個(gè)長(zhǎng)連接數(shù)據(jù)像溪流一樣持續(xù)涌來(lái)我們必須設(shè)計(jì)一個(gè)能持續(xù)處理“水滴”的管道。2.1 技術(shù)棧選型與考量首先看我們的技術(shù)棧Unity端我們使用UnityWebRequest進(jìn)行網(wǎng)絡(luò)通信這是Unity官方推薦且兼容性最好的方案比舊的WWW類更高效也比直接使用.NET的HttpClient在WebGL平臺(tái)更穩(wěn)妥。序列化庫(kù)選擇了Newtonsoft.Json即Json.NET因?yàn)樗δ軓?qiáng)大能輕松處理可能不完整的JSON片段。UI方面使用TextMeshProTMP來(lái)顯示文本這是Unity現(xiàn)代UI的標(biāo)配渲染效果好支持富文本。在Ollama端關(guān)鍵是要調(diào)用/api/generate接口并將參數(shù)stream設(shè)置為true。這里有個(gè)容易踩的坑Ollama的流式響應(yīng)每一塊chunk都是一個(gè)獨(dú)立的、完整的JSON對(duì)象而不是一個(gè)完整JSON被切分。這意味著我們收到的數(shù)據(jù)可能是這樣的{model:...,response:你,done:false} {model:...,response:好,done:false} {model:...,response:嗎,done:true}每一行都是一個(gè)合法的JSON。但問(wèn)題在于網(wǎng)絡(luò)傳輸可能不會(huì)那么規(guī)整地按行送達(dá)多個(gè)JSON對(duì)象可能粘在一起或者一個(gè)JSON對(duì)象被拆到兩個(gè)TCP包里。因此我們的核心挑戰(zhàn)就變成了如何從可能混亂的字節(jié)流中準(zhǔn)確地剝離出一個(gè)個(gè)完整的JSON對(duì)象進(jìn)行解析。2.2 核心流程設(shè)計(jì)整個(gè)流程可以分解為以下幾個(gè)關(guān)鍵環(huán)節(jié)我畫了一個(gè)簡(jiǎn)單的示意圖來(lái)幫助理解flowchart TD A[用戶輸入文本] -- B[Unity構(gòu)造JSON請(qǐng)求] B -- C[發(fā)起帶Stream標(biāo)志的POST請(qǐng)求] C -- D{Ollama APIbr流式響應(yīng)} D -- 持續(xù)返回?cái)?shù)據(jù)塊 -- E[自定義DownloadHandlerbr逐塊接收字節(jié)流] E -- F[JSON緩沖區(qū)拼接] F -- G{緩沖區(qū)中能否br提取完整JSON?} G -- 否 -- F G -- 是 -- H[解析JSON提取response字段] H -- I[清洗文本br去除標(biāo)記、轉(zhuǎn)義符] I -- J[追加到最終回復(fù)構(gòu)建器] J -- K{done字段是否為true?} K -- 否 -- D K -- 是 -- L[更新UI顯示完整回復(fù)]這個(gè)流程圖中橙色菱形決策框和藍(lán)色處理框是整個(gè)系統(tǒng)的核心。JSON緩沖區(qū)拼接和提取完整JSON這兩個(gè)環(huán)節(jié)是保證數(shù)據(jù)不丟失、不錯(cuò)亂的關(guān)鍵也是我們后面要重點(diǎn)剖析的部分。3. 關(guān)鍵代碼深度解析從字節(jié)流到屏幕文字理解了整體設(shè)計(jì)我們深入到代碼層看看每一個(gè)環(huán)節(jié)具體是怎么實(shí)現(xiàn)的以及為什么要這么做。3.1 請(qǐng)求的發(fā)起與自定義下載處理器發(fā)送請(qǐng)求的協(xié)程SendPromptToOllamaStream是起點(diǎn)。這里有幾個(gè)細(xì)節(jié)值得注意IEnumerator SendPromptToOllamaStream(string prompt) { var requestJson new RequestModel { model deepseek-r1:7b-Quantization, // 模型名稱需與Ollama中拉取的模型一致 prompt prompt, stream true // 必須設(shè)置為true }; string jsonData JsonConvert.SerializeObject(requestJson); using (UnityWebRequest request new UnityWebRequest(OLLAMA_URL, POST)) { byte[] bodyRaw Encoding.UTF8.GetBytes(jsonData); request.uploadHandler new UploadHandlerRaw(bodyRaw); // 關(guān)鍵使用自定義的DownloadHandler request.downloadHandler new CustomDownloadHandler(ProcessStreamChunk); request.SetRequestHeader(Content-Type, application/json); yield return request.SendWebRequest(); // ... 錯(cuò)誤處理和最終收尾 } }為什么用UploadHandlerRaw和自定義DownloadHandlerUploadHandlerRaw用于設(shè)置請(qǐng)求體數(shù)據(jù)簡(jiǎn)單直接。而系統(tǒng)自帶的DownloadHandlerBuffer或DownloadHandlerTexture都是等整個(gè)請(qǐng)求完成后才提供數(shù)據(jù)不符合流式需求。我們必須繼承DownloadHandler類重寫其ReceiveData方法這個(gè)方法會(huì)在數(shù)據(jù)到達(dá)時(shí)被多次調(diào)用。我們的CustomDownloadHandler類非常簡(jiǎn)單它只是一個(gè)將收到的字節(jié)數(shù)據(jù)轉(zhuǎn)發(fā)給回調(diào)函數(shù)的通道public class CustomDownloadHandler : DownloadHandlerScript { private Actionbyte[] onDataReceived; public CustomDownloadHandler(Actionbyte[] onDataReceivedCallback) : base(new byte[4096]) // 可以提供一個(gè)初始緩沖區(qū) { onDataReceived onDataReceivedCallback; } protected override bool ReceiveData(byte[] data, int dataLength) { if (data null || data.Length 0) return false; // 復(fù)制有效數(shù)據(jù)部分避免傳入整個(gè)可能未填滿的數(shù)組 byte[] trimmedData new byte[dataLength]; System.Array.Copy(data, trimmedData, dataLength); onDataReceived?.Invoke(trimmedData); return true; } }注意ReceiveData的data參數(shù)是Unity復(fù)用的一塊緩沖區(qū)其dataLength才是本次接收的實(shí)際數(shù)據(jù)長(zhǎng)度。直接處理整個(gè)data數(shù)組可能會(huì)導(dǎo)致包含上一次的殘留數(shù)據(jù)所以必須使用dataLength進(jìn)行截取。這是我調(diào)試了半小時(shí)才發(fā)現(xiàn)的坑。3.2 數(shù)據(jù)流的核心JSON緩沖與解析算法ProcessStreamChunk方法是整個(gè)系統(tǒng)的心臟。它接收來(lái)自CustomDownloadHandler的原始字節(jié)數(shù)組并將其轉(zhuǎn)換為字符串追加到j(luò)sonBuffer一個(gè)StringBuilder中。核心算法如何從字符串緩沖區(qū)中提取完整JSON我們不能簡(jiǎn)單按換行符分割因?yàn)門CP流不保證按“行”到達(dá)。我們采用的是一種括號(hào)匹配算法尋找起始點(diǎn)在緩沖區(qū)字符串中找到第一個(gè){字符的位置。這標(biāo)志著一個(gè)JSON對(duì)象的開始。括號(hào)匹配從這個(gè){開始向后遍歷字符串。維護(hù)一個(gè)計(jì)數(shù)器depth遇到{就加1遇到}就減1。當(dāng)depth減回0時(shí)說(shuō)明找到了與起始{匹配的結(jié)束}。提取與移除如果找到了匹配的結(jié)束位置那么從起始到結(jié)束包含}的子串就是一個(gè)完整的JSON對(duì)象。將其提取出來(lái)進(jìn)行解析然后從緩沖區(qū)中移除這部分已處理的數(shù)據(jù)。循環(huán)處理重復(fù)步驟1-3直到緩沖區(qū)中再也找不到完整的JSON對(duì)象為止。剩下的不完整片段會(huì)留在緩沖區(qū)里等待下一次數(shù)據(jù)到達(dá)。這個(gè)算法的實(shí)現(xiàn)在FindMatchingBrace方法中。它非常健壯能處理JSON嵌套的情況例如AI返回的JSON里可能又包含一個(gè)JSON字符串。private int FindMatchingBrace(string str, int start) { int depth 1; // 因?yàn)閟tart是{的位置所以初始深度為1 for (int i start 1; i str.Length; i) { if (str[i] {) depth; else if (str[i] }) depth--; if (depth 0) { return i 1; // 返回結(jié)束大括號(hào)后一位的索引方便Substring或Remove操作 } } return -1; // 未找到匹配的結(jié)束括號(hào) }在ProcessStreamChunk中我們循環(huán)調(diào)用這個(gè)算法while (true) { int startIndex jsonBuffer.ToString().IndexOf({); if (startIndex -1) break; // 緩沖區(qū)里沒(méi)有新的JSON開始了 int endIndex FindMatchingBrace(jsonBuffer.ToString(), startIndex); if (endIndex -1) break; // 找到了開始但沒(méi)有完整的結(jié)束跳出循環(huán)等待更多數(shù)據(jù) // 提取完整JSON字符串 string jsonString jsonBuffer.ToString(startIndex, endIndex - startIndex); // ... 解析jsonString // 從緩沖區(qū)移除已處理的部分 jsonBuffer.Remove(0, endIndex); }3.3 響應(yīng)數(shù)據(jù)的清洗與UI更新解析出OllamaStreamResponse對(duì)象后我們拿到response字段。這個(gè)字段通常就是AI返回的文本但有時(shí)會(huì)包含一些我們不需要的標(biāo)記或轉(zhuǎn)義字符。例如某些模型會(huì)在思考過(guò)程中輸出|t|、/think等內(nèi)部標(biāo)記或者將、轉(zhuǎn)義為\u003c、\u003e。CleanResponseSegment方法就是用來(lái)處理這些情況的。它通過(guò)一系列的字符串替換將這些“噪音”清除得到干凈的文本。這里需要根據(jù)你實(shí)際使用的模型輸出進(jìn)行微調(diào)沒(méi)有一刀切的方案。private string CleanResponseSegment(string segment) { if (string.IsNullOrEmpty(segment)) return ; // 1. 處理Unicode轉(zhuǎn)義字符常見于JSON序列化 segment segment.Replace(\\u003c, ).Replace(\\u003e, ); // 2. 去除模型特定的特殊標(biāo)記 segment segment.Replace(|t|, ).Replace(|, ).Replace(|, ); // 3. 去除可能的中文思考標(biāo)記 segment segment.Replace(think, ).Replace(/think, ); return segment.Trim(); }清理后的文本片段會(huì)被追加到一個(gè)全局的StringBuilder代碼中的TempResponseBuilder中。這里我使用了靜態(tài)的TempResponseBuilder主要是為了在協(xié)程和回調(diào)方法之間方便地共享這個(gè)“正在構(gòu)建的回復(fù)”。但更好的做法是將其作為類的成員變量避免使用靜態(tài)變量以提高代碼的可測(cè)試性和模塊化。當(dāng)解析到某個(gè)JSON塊的done字段為true時(shí)說(shuō)明AI已經(jīng)生成完畢。此時(shí)我們將TempResponseBuilder中的完整內(nèi)容賦給最終的currentResponseBuilder并調(diào)用UpdateUIText方法更新UI。UI更新的性能考量在流式接收過(guò)程中我們每收到一個(gè)有效的response片段就更新一次UIresponseText.text cleanedResponse。在PC或主機(jī)上這沒(méi)問(wèn)題但在移動(dòng)設(shè)備或WebGL上頻繁設(shè)置TextMeshPro的text屬性可能引發(fā)性能問(wèn)題頻繁的網(wǎng)格重建。一個(gè)優(yōu)化策略是使用一個(gè)計(jì)時(shí)器或計(jì)數(shù)器累積一定數(shù)量的字符比如10個(gè)或等待一個(gè)極短的時(shí)間間隔如0.05秒再批量更新一次UI可以顯著降低渲染開銷。4. 完整實(shí)現(xiàn)與代碼組織將上述所有部分組合起來(lái)就得到了一個(gè)完整的OllamaStreamingClientMonoBehaviour類。為了代碼清晰和可維護(hù)性我建議將數(shù)據(jù)模型類RequestModel,OllamaStreamResponse和自定義處理器CustomDownloadHandler放在獨(dú)立的文件中或者至少放在同一個(gè)類文件的不同區(qū)域。這里再?gòu)?qiáng)調(diào)一下類的核心成員userInputField(TMP_InputField): 用戶輸入框。sendButton(Button): 發(fā)送按鈕。responseText(TMP_Text): 顯示AI回復(fù)的文本框。OLLAMA_URL(const string): Ollama API地址默認(rèn)是http://127.0.0.1:11434/api/generate。如果你部署在遠(yuǎn)程服務(wù)器需要修改此地址。currentResponseBuilder(StringBuilder): 存儲(chǔ)當(dāng)前請(qǐng)求的最終完整回復(fù)。jsonBuffer(StringBuilder): 用于拼接原始網(wǎng)絡(luò)數(shù)據(jù)流以便從中提取完整JSON。TempResponseBuilder(static StringBuilder): 可優(yōu)化為非靜態(tài)臨時(shí)存儲(chǔ)流式回復(fù)的構(gòu)建過(guò)程。在Start方法中綁定了按鈕點(diǎn)擊和輸入框結(jié)束編輯的事件。OnSendButtonClicked是觸發(fā)請(qǐng)求的入口它會(huì)清空UI和緩沖區(qū)然后啟動(dòng)發(fā)送協(xié)程。5. 部署、調(diào)試與實(shí)戰(zhàn)避坑指南理論說(shuō)完我們來(lái)點(diǎn)實(shí)在的。要讓這個(gè)系統(tǒng)跑起來(lái)你還需要幾步。5.1 Ollama本地部署與模型拉取首先你需要在你的開發(fā)機(jī)或服務(wù)器上安裝并運(yùn)行Ollama。去官網(wǎng)下載安裝包安裝后打開終端或PowerShell運(yùn)行ollama run deepseek-r1:7b或其他你想要的模型如llama3.2、qwen2.5等。第一次運(yùn)行會(huì)自動(dòng)拉取模型這可能需要一些時(shí)間取決于你的網(wǎng)絡(luò)和模型大小。避坑提示Ollama下載慢怎么辦這是國(guó)內(nèi)開發(fā)者最常遇到的問(wèn)題。Ollama默認(rèn)從國(guó)外倉(cāng)庫(kù)拉取模型速度可能極慢甚至失敗。解決方案是配置鏡像源。對(duì)于macOS/Linux可以在終端執(zhí)行export OLLAMA_HOST0.0.0.0 # 可選讓Ollama監(jiān)聽所有網(wǎng)絡(luò)接口 export OLLAMA_MODELSregistry.cn-hangzhou.aliyuncs.com/modelscope-repo/modelscope:latest # 使用國(guó)內(nèi)鏡像對(duì)于Windows可以在系統(tǒng)環(huán)境變量中添加OLLAMA_MODELS值為上述鏡像地址。或者更直接的方法是修改Ollama的配置文件通常位于C:\Users\你的用戶名\.ollama\config.json加入registry: registry.cn-hangzhou.aliyuncs.com/modelscope-repo/modelscope。配置完成后再次運(yùn)行ollama run命令速度會(huì)有質(zhì)的提升。確保Ollama服務(wù)正在運(yùn)行通常會(huì)在11434端口監(jiān)聽。你可以在瀏覽器中訪問(wèn)http://127.0.0.1:11434如果看到Ollama的API文檔頁(yè)面說(shuō)明服務(wù)正常。5.2 Unity項(xiàng)目配置與常見錯(cuò)誤排查在Unity中你需要做以下準(zhǔn)備導(dǎo)入Newtonsoft.Json如果你沒(méi)有使用Unity的Package Manager安裝Newtonsoft.Json可以通過(guò)Window - Package Manager - Add package from git URL...輸入com.unity.nuget.newtonsoft-json來(lái)添加。創(chuàng)建UI在Canvas上創(chuàng)建一個(gè)TMP_InputField用于輸入、一個(gè)Button用于發(fā)送、一個(gè)TMP_Text用于顯示回復(fù)。將我們的OllamaStreamingClient腳本掛載到一個(gè)GameObject上并在Inspector面板中將這三個(gè)UI組件拖拽賦值給對(duì)應(yīng)的公共字段。API地址如果Ollama運(yùn)行在其他機(jī)器記得修改代碼中的OLLAMA_URL常量。運(yùn)行時(shí)常見問(wèn)題與解決方案問(wèn)題現(xiàn)象可能原因排查步驟與解決方案Unity報(bào)錯(cuò)UnityWebRequest error: Cannot connect to destination host1. Ollama服務(wù)未啟動(dòng)。2. 防火墻阻止了連接。3. URL或端口錯(cuò)誤。1. 檢查終端確認(rèn)Ollama進(jìn)程正在運(yùn)行。2. 在瀏覽器訪問(wèn)http://127.0.0.1:11434/api/tags看是否能返回已安裝模型列表一個(gè)JSON。3. 確認(rèn)Unity代碼中的URL和端口與Ollama服務(wù)一致。能連接但收不到流式響應(yīng)UI一直不更新1. 請(qǐng)求未設(shè)置stream: true。2. 自定義DownloadHandler的ReceiveData方法未被調(diào)用或數(shù)據(jù)未轉(zhuǎn)發(fā)。3. JSON解析失敗數(shù)據(jù)被丟棄。1. 在RequestModel中確認(rèn)stream設(shè)為true。2. 在CustomDownloadHandler的ReceiveData和ProcessStreamChunk開頭加Debug.Log看數(shù)據(jù)流是否進(jìn)來(lái)。3. 檢查jsonBuffer的內(nèi)容看是否收到了數(shù)據(jù)但格式非預(yù)期如非JSON錯(cuò)誤信息??赡苁悄P兔e(cuò)誤導(dǎo)致Ollama返回錯(cuò)誤。UI更新卡頓文字跳動(dòng)每收到一個(gè)字符就更新一次UI性能開銷大。實(shí)現(xiàn)一個(gè)簡(jiǎn)單的防抖或節(jié)流機(jī)制。例如在ProcessStreamChunk中將清理后的文本先存入一個(gè)隊(duì)列然后使用InvokeRepeating或協(xié)程每隔0.05秒從隊(duì)列中取出一批字符更新到UI。**回復(fù)內(nèi)容包含奇怪標(biāo)記如t或\u003c**WebGL構(gòu)建后無(wú)法連接localhost瀏覽器安全限制WebGL不能直接訪問(wèn)localhost或127.0.0.1。1.開發(fā)時(shí)使用localhost或本機(jī)IP并在瀏覽器中運(yùn)行Unity開發(fā)服務(wù)器時(shí)通過(guò)http://localhost:11434訪問(wèn)可能需要配置CORS。Ollama默認(rèn)不開啟CORS你需要啟動(dòng)Ollama時(shí)加上參數(shù)OLLAMA_ORIGINS*不安全僅用于開發(fā)。2.發(fā)布時(shí)必須將Ollama部署在具有公網(wǎng)IP或與WebGL游戲同域的服務(wù)器上并修改Unity代碼中的URL為服務(wù)器地址。這是WebGL網(wǎng)絡(luò)訪問(wèn)的硬性限制。5.3 進(jìn)階優(yōu)化與功能擴(kuò)展基礎(chǔ)功能跑通后你可以考慮以下優(yōu)化讓系統(tǒng)更健壯、用戶體驗(yàn)更好請(qǐng)求超時(shí)與重試在網(wǎng)絡(luò)不穩(wěn)定的環(huán)境下給UnityWebRequest設(shè)置一個(gè)超時(shí)時(shí)間request.timeout并在失敗時(shí)提供重試按鈕或自動(dòng)重試邏輯需謹(jǐn)慎避免循環(huán)。上下文管理實(shí)現(xiàn)一個(gè)簡(jiǎn)單的對(duì)話歷史管理。將每次的用戶輸入和AI回復(fù)保存到一個(gè)列表中在下次請(qǐng)求時(shí)將整個(gè)對(duì)話歷史作為prompt的一部分或通過(guò)Ollama API的context參數(shù)發(fā)送這樣AI就能記住之前的對(duì)話。打字機(jī)效果與其一次性追加文本不如實(shí)現(xiàn)一個(gè)打字機(jī)動(dòng)畫。將每次收到的文本片段存入隊(duì)列用一個(gè)協(xié)程逐個(gè)字符地添加到UI文本中并配上音效沉浸感更強(qiáng)。連接狀態(tài)指示在發(fā)送請(qǐng)求時(shí)禁用輸入框和按鈕并顯示一個(gè)加載動(dòng)畫比如旋轉(zhuǎn)的圓圈。收到done: true或請(qǐng)求完全結(jié)束后再恢復(fù)交互。這能給用戶明確的反饋。錯(cuò)誤友好提示不要只在Console輸出錯(cuò)誤。將網(wǎng)絡(luò)錯(cuò)誤、模型錯(cuò)誤等信息以友好的方式如紅色提示文本展示在UI上讓用戶知道發(fā)生了什么。這套Unity與Ollama流式交互的方案我已經(jīng)在幾個(gè)小型敘事游戲和工具原型中成功應(yīng)用。它最大的魅力在于將強(qiáng)大的本地大模型能力無(wú)縫接入了實(shí)時(shí)、交互性強(qiáng)的游戲環(huán)境中。當(dāng)你看到自己游戲里的角色能像真人一樣“邊想邊說(shuō)”時(shí)那種成就感是巨大的。希望這篇詳細(xì)的拆解能幫你避開我踩過(guò)的那些坑順利實(shí)現(xiàn)你自己的AI交互創(chuàng)意。