Unity集成Ollama流式AI对话:实时交互与JSON解析实战
2026/8/10 11:15:30 网站建设 项目流程

1. 项目概述:为什么要在Unity里搞流式AI对话?

如果你正在开发一款需要智能对话的游戏,比如一个能和玩家深度互动的NPC,或者一个内置了AI助手的虚拟世界,你肯定不希望玩家问完问题后,对着一个空白的对话框干等十几秒,然后“唰”一下蹦出一大段完整的回复。这种体验太割裂了,一点也不“实时”。这就是我们今天要解决的问题:在Unity里,如何像刷短视频一样,让AI的回复一个字一个字地“流”出来,实现真正的实时交互。

这个项目的核心,就是打通Unity这个强大的游戏引擎与Ollama这个可以本地运行大模型的工具。Ollama提供了标准的HTTP API,特别是它的流式(stream)响应模式,允许服务器一边生成文本,一边分片发送给客户端。我们的任务,就是让Unity能稳稳地接住这些“数据碎片”,并流畅地拼装、清洗、展示给玩家。我最近在几个独立游戏项目中集成了这个功能,实测下来,它能极大提升对话的沉浸感和响应感。无论你是想做个AI桌宠、剧情生成器,还是复杂的游戏内引导系统,这套方案都能作为坚实的技术底座。

2. 核心思路与架构设计:不止是发个请求那么简单

很多人第一次尝试时,会想当然地用UnityWebRequest发个POST请求,然后等downloadHandler.text返回完整结果。这在非流式模式下没问题,但一旦开启流式,这条路就走不通了。流式响应本质是一个长连接,数据像溪流一样持续涌来,我们必须设计一个能持续处理“水滴”的管道。

2.1 技术栈选型与考量

首先看我们的技术栈:Unity端,我们使用UnityWebRequest进行网络通信,这是Unity官方推荐且兼容性最好的方案,比旧的WWW类更高效,也比直接使用.NET的HttpClient在WebGL平台更稳妥。序列化库选择了Newtonsoft.Json(即Json.NET),因为它功能强大,能轻松处理可能不完整的JSON片段。UI方面,使用TextMeshPro(TMP)来显示文本,这是Unity现代UI的标配,渲染效果好,支持富文本。

在Ollama端,关键是要调用/api/generate接口,并将参数stream设置为true。这里有个容易踩的坑:Ollama的流式响应,每一块(chunk)都是一个独立的、完整的JSON对象,而不是一个完整JSON被切分。这意味着我们收到的数据可能是这样的:

{"model":"...","response":"你","done":false} {"model":"...","response":"好","done":false} {"model":"...","response":"吗?","done":true}

每一行都是一个合法的JSON。但问题在于,网络传输可能不会那么规整地按行送达,多个JSON对象可能粘在一起,或者一个JSON对象被拆到两个TCP包里。因此,我们的核心挑战就变成了:如何从可能混乱的字节流中,准确地剥离出一个个完整的JSON对象进行解析。

2.2 核心流程设计

整个流程可以分解为以下几个关键环节,我画了一个简单的示意图来帮助理解:

flowchart TD A[用户输入文本] --> B[Unity构造JSON请求] B --> C[发起带Stream标志的POST请求] C --> D{Ollama API<br>流式响应} D -- 持续返回数据块 --> E[自定义DownloadHandler<br>逐块接收字节流] E --> F[JSON缓冲区拼接] F --> G{缓冲区中能否<br>提取完整JSON?} G -- 否 --> F G -- 是 --> H[解析JSON,提取response字段] H --> I[清洗文本<br>(去除标记、转义符)] I --> J[追加到最终回复构建器] J --> K{done字段是否为true?} K -- 否 --> D K -- 是 --> L[更新UI,显示完整回复]

这个流程图中,橙色菱形决策框蓝色处理框是整个系统的核心。JSON缓冲区拼接提取完整JSON这两个环节,是保证数据不丢失、不错乱的关键,也是我们后面要重点剖析的部分。

3. 关键代码深度解析:从字节流到屏幕文字

理解了整体设计,我们深入到代码层,看看每一个环节具体是怎么实现的,以及为什么要这么做。

3.1 请求的发起与自定义下载处理器

发送请求的协程SendPromptToOllamaStream是起点。这里有几个细节值得注意:

IEnumerator SendPromptToOllamaStream(string prompt) { var requestJson = new RequestModel { model = "deepseek-r1:7b-Quantization", // 模型名称需与Ollama中拉取的模型一致 prompt = prompt, stream = true // 必须设置为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); // 关键:使用自定义的DownloadHandler request.downloadHandler = new CustomDownloadHandler(ProcessStreamChunk); request.SetRequestHeader("Content-Type", "application/json"); yield return request.SendWebRequest(); // ... 错误处理和最终收尾 } }

为什么用UploadHandlerRaw和自定义DownloadHandlerUploadHandlerRaw用于设置请求体数据,简单直接。而系统自带的DownloadHandlerBufferDownloadHandlerTexture都是等整个请求完成后才提供数据,不符合流式需求。我们必须继承DownloadHandler类,重写其ReceiveData方法,这个方法会在数据到达时被多次调用。

我们的CustomDownloadHandler类非常简单,它只是一个将收到的字节数据转发给回调函数的通道:

public class CustomDownloadHandler : DownloadHandlerScript { private Action<byte[]> onDataReceived; public CustomDownloadHandler(Action<byte[]> onDataReceivedCallback) : base(new byte[4096]) // 可以提供一个初始缓冲区 { onDataReceived = onDataReceivedCallback; } protected override bool ReceiveData(byte[] data, int dataLength) { if (data == null || data.Length == 0) return false; // 复制有效数据部分,避免传入整个可能未填满的数组 byte[] trimmedData = new byte[dataLength]; System.Array.Copy(data, trimmedData, dataLength); onDataReceived?.Invoke(trimmedData); return true; } }

注意:ReceiveDatadata参数是Unity复用的一块缓冲区,其dataLength才是本次接收的实际数据长度。直接处理整个data数组可能会导致包含上一次的残留数据,所以必须使用dataLength进行截取。这是我调试了半小时才发现的坑。

3.2 数据流的核心:JSON缓冲与解析算法

ProcessStreamChunk方法是整个系统的心脏。它接收来自CustomDownloadHandler的原始字节数组,并将其转换为字符串,追加到jsonBuffer(一个StringBuilder)中。

核心算法:如何从字符串缓冲区中提取完整JSON?我们不能简单按换行符分割,因为TCP流不保证按“行”到达。我们采用的是一种括号匹配算法

  1. 寻找起始点:在缓冲区字符串中,找到第一个{字符的位置。这标志着一个JSON对象的开始。
  2. 括号匹配:从这个{开始,向后遍历字符串。维护一个计数器depth,遇到{就加1,遇到}就减1。当depth减回0时,说明找到了与起始{匹配的结束}
  3. 提取与移除:如果找到了匹配的结束位置,那么从起始到结束(包含})的子串就是一个完整的JSON对象。将其提取出来进行解析,然后从缓冲区中移除这部分已处理的数据。
  4. 循环处理:重复步骤1-3,直到缓冲区中再也找不到完整的JSON对象为止。剩下的不完整片段会留在缓冲区里,等待下一次数据到达。

这个算法的实现在FindMatchingBrace方法中。它非常健壮,能处理JSON嵌套的情况(例如AI返回的JSON里可能又包含一个JSON字符串)。

private int FindMatchingBrace(string str, int start) { int depth = 1; // 因为start是'{'的位置,所以初始深度为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; // 返回结束大括号后一位的索引,方便Substring或Remove操作 } } return -1; // 未找到匹配的结束括号 }

ProcessStreamChunk中,我们循环调用这个算法:

while (true) { int startIndex = jsonBuffer.ToString().IndexOf('{'); if (startIndex == -1) break; // 缓冲区里没有新的JSON开始了 int endIndex = FindMatchingBrace(jsonBuffer.ToString(), startIndex); if (endIndex == -1) break; // 找到了开始,但没有完整的结束,跳出循环等待更多数据 // 提取完整JSON字符串 string jsonString = jsonBuffer.ToString(startIndex, endIndex - startIndex); // ... 解析jsonString // 从缓冲区移除已处理的部分 jsonBuffer.Remove(0, endIndex); }

3.3 响应数据的清洗与UI更新

解析出OllamaStreamResponse对象后,我们拿到response字段。这个字段通常就是AI返回的文本,但有时会包含一些我们不需要的标记或转义字符。例如,某些模型会在思考过程中输出<|t|></think>等内部标记,或者将<>转义为\u003c\u003e

CleanResponseSegment方法就是用来处理这些情况的。它通过一系列的字符串替换,将这些“噪音”清除,得到干净的文本。这里需要根据你实际使用的模型输出进行微调,没有一刀切的方案。

private string CleanResponseSegment(string segment) { if (string.IsNullOrEmpty(segment)) return ""; // 1. 处理Unicode转义字符(常见于JSON序列化) segment = segment.Replace("\\u003c", "<").Replace("\\u003e", ">"); // 2. 去除模型特定的特殊标记 segment = segment.Replace("<|t|>", "").Replace("|>", "").Replace("<|", ""); // 3. 去除可能的中文思考标记 segment = segment.Replace("<think>", "").Replace("</think>", ""); return segment.Trim(); }

清理后的文本片段,会被追加到一个全局的StringBuilder(代码中的TempResponseBuilder)中。这里我使用了静态的TempResponseBuilder,主要是为了在协程和回调方法之间方便地共享这个“正在构建的回复”。但更好的做法是将其作为类的成员变量,避免使用静态变量,以提高代码的可测试性和模块化。

当解析到某个JSON块的done字段为true时,说明AI已经生成完毕。此时,我们将TempResponseBuilder中的完整内容赋给最终的currentResponseBuilder,并调用UpdateUIText方法更新UI。

UI更新的性能考量:在流式接收过程中,我们每收到一个有效的response片段就更新一次UI(responseText.text += cleanedResponse)。在PC或主机上这没问题,但在移动设备或WebGL上,频繁设置TextMeshProtext属性可能引发性能问题(频繁的网格重建)。一个优化策略是使用一个计时器或计数器,累积一定数量的字符(比如10个)或等待一个极短的时间间隔(如0.05秒)再批量更新一次UI,可以显著降低渲染开销。

4. 完整实现与代码组织

将上述所有部分组合起来,就得到了一个完整的OllamaStreamingClientMonoBehaviour类。为了代码清晰和可维护性,我建议将数据模型类(RequestModel,OllamaStreamResponse)和自定义处理器(CustomDownloadHandler)放在独立的文件中,或者至少放在同一个类文件的不同区域。

这里再强调一下类的核心成员:

  • userInputField(TMP_InputField): 用户输入框。
  • sendButton(Button): 发送按钮。
  • responseText(TMP_Text): 显示AI回复的文本框。
  • OLLAMA_URL(const string): Ollama API地址,默认是http://127.0.0.1:11434/api/generate。如果你部署在远程服务器,需要修改此地址。
  • currentResponseBuilder(StringBuilder): 存储当前请求的最终完整回复。
  • jsonBuffer(StringBuilder): 用于拼接原始网络数据流,以便从中提取完整JSON。
  • TempResponseBuilder(static StringBuilder): (可优化为非静态)临时存储流式回复的构建过程。

Start方法中,绑定了按钮点击和输入框结束编辑的事件。OnSendButtonClicked是触发请求的入口,它会清空UI和缓冲区,然后启动发送协程。

5. 部署、调试与实战避坑指南

理论说完,我们来点实在的。要让这个系统跑起来,你还需要几步。

5.1 Ollama本地部署与模型拉取

首先,你需要在你的开发机或服务器上安装并运行Ollama。去官网下载安装包,安装后打开终端(或PowerShell),运行ollama run deepseek-r1:7b(或其他你想要的模型,如llama3.2qwen2.5等)。第一次运行会自动拉取模型,这可能需要一些时间,取决于你的网络和模型大小。

避坑提示:Ollama下载慢怎么办?这是国内开发者最常遇到的问题。Ollama默认从国外仓库拉取模型,速度可能极慢甚至失败。解决方案是配置镜像源。对于macOS/Linux,可以在终端执行:

export OLLAMA_HOST=0.0.0.0 # 可选,让Ollama监听所有网络接口 export OLLAMA_MODELS=registry.cn-hangzhou.aliyuncs.com/modelscope-repo/modelscope:latest # 使用国内镜像

对于Windows,可以在系统环境变量中添加OLLAMA_MODELS,值为上述镜像地址。或者,更直接的方法是,修改Ollama的配置文件(通常位于C:\Users\<你的用户名>\.ollama\config.json),加入"registry": "registry.cn-hangzhou.aliyuncs.com/modelscope-repo/modelscope"。配置完成后,再次运行ollama run命令,速度会有质的提升。

确保Ollama服务正在运行(通常会在11434端口监听)。你可以在浏览器中访问http://127.0.0.1:11434,如果看到Ollama的API文档页面,说明服务正常。

5.2 Unity项目配置与常见错误排查

在Unity中,你需要做以下准备:

  1. 导入Newtonsoft.Json:如果你没有使用Unity的Package Manager安装Newtonsoft.Json,可以通过Window -> Package Manager -> Add package from git URL...,输入com.unity.nuget.newtonsoft-json来添加。
  2. 创建UI:在Canvas上创建一个TMP_InputField(用于输入)、一个Button(用于发送)、一个TMP_Text(用于显示回复)。将我们的OllamaStreamingClient脚本挂载到一个GameObject上,并在Inspector面板中将这三个UI组件拖拽赋值给对应的公共字段。
  3. API地址:如果Ollama运行在其他机器,记得修改代码中的OLLAMA_URL常量。

运行时常见问题与解决方案:

问题现象可能原因排查步骤与解决方案
Unity报错:UnityWebRequest error: Cannot connect to destination host1. Ollama服务未启动。
2. 防火墙阻止了连接。
3. URL或端口错误。
1. 检查终端,确认Ollama进程正在运行。
2. 在浏览器访问http://127.0.0.1:11434/api/tags,看是否能返回已安装模型列表(一个JSON)。
3. 确认Unity代码中的URL和端口与Ollama服务一致。
能连接,但收不到流式响应,UI一直不更新1. 请求未设置stream: true
2. 自定义DownloadHandlerReceiveData方法未被调用或数据未转发。
3. JSON解析失败,数据被丢弃。
1. 在RequestModel中确认stream设为true
2. 在CustomDownloadHandlerReceiveDataProcessStreamChunk开头加Debug.Log,看数据流是否进来。
3. 检查jsonBuffer的内容,看是否收到了数据但格式非预期(如非JSON错误信息)。可能是模型名错误导致Ollama返回错误。
UI更新卡顿,文字跳动每收到一个字符就更新一次UI,性能开销大。实现一个简单的防抖或节流机制。例如,在ProcessStreamChunk中,将清理后的文本先存入一个队列,然后使用InvokeRepeating或协程每隔0.05秒从队列中取出一批字符更新到UI。
**回复内容包含奇怪标记如`<t>\u003c`**
WebGL构建后无法连接localhost浏览器安全限制,WebGL不能直接访问localhost127.0.0.11.开发时:使用localhost或本机IP,并在浏览器中运行Unity开发服务器时,通过http://localhost:11434访问可能需要配置CORS。Ollama默认不开启CORS,你需要启动Ollama时加上参数OLLAMA_ORIGINS="*"(不安全,仅用于开发)。
2.发布时:必须将Ollama部署在具有公网IP或与WebGL游戏同域的服务器上,并修改Unity代码中的URL为服务器地址。这是WebGL网络访问的硬性限制。

5.3 进阶优化与功能扩展

基础功能跑通后,你可以考虑以下优化,让系统更健壮、用户体验更好:

  1. 请求超时与重试:在网络不稳定的环境下,给UnityWebRequest设置一个超时时间(request.timeout),并在失败时提供重试按钮或自动重试逻辑(需谨慎,避免循环)。
  2. 上下文管理:实现一个简单的对话历史管理。将每次的用户输入和AI回复保存到一个列表中,在下次请求时,将整个对话历史作为prompt的一部分(或通过Ollama API的context参数)发送,这样AI就能记住之前的对话。
  3. 打字机效果:与其一次性追加文本,不如实现一个打字机动画。将每次收到的文本片段存入队列,用一个协程逐个字符地添加到UI文本中,并配上音效,沉浸感更强。
  4. 连接状态指示:在发送请求时,禁用输入框和按钮,并显示一个加载动画(比如旋转的圆圈)。收到done: true或请求完全结束后,再恢复交互。这能给用户明确的反馈。
  5. 错误友好提示:不要只在Console输出错误。将网络错误、模型错误等信息,以友好的方式(如红色提示文本)展示在UI上,让用户知道发生了什么。

这套Unity与Ollama流式交互的方案,我已经在几个小型叙事游戏和工具原型中成功应用。它最大的魅力在于,将强大的本地大模型能力无缝接入了实时、交互性强的游戏环境中。当你看到自己游戏里的角色,能像真人一样“边想边说”时,那种成就感是巨大的。希望这篇详细的拆解能帮你避开我踩过的那些坑,顺利实现你自己的AI交互创意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询