做 Unity 项目的朋友,最近应该都在聊怎么把大模型塞进游戏里。我折腾了一段时间,把 DeepSeek 的 API 接进了 Unity 工程,并且实现了数据流式传输——就是那种 AI 对话不是干等好几秒然后一次性蹦出来,而是像 ChatGPT 网页版一样逐字往外冒的效果。这篇东西就聊聊我完整趟完这条路之后的技术细节和踩坑记录。
先说结论:Unity 里直接调大模型 API 并不复杂,真正麻烦的是“流式传输”这件事。Unity 的UnityWebRequest封装得比较“整块”,想做真正的流式读取需要换思路。我会从最基础的 API 调用讲到流式方案的底层原理,再给出一套能直接跑进项目的完整实现,最后把我在实测中遇到的坑和优化手段一并倒出来。
1. 为什么非要在 Unity 里接 DeepSeek,以及流式到底解决了什么
1.1 不只是一个“聊天机器人”
很多人一听在游戏里接 AI,第一反应是做 NPC 对话。这当然是最直接的应用场景,但实际接到项目里之后,你会发现可用范围比想象中大得多:游戏内的新手引导助手、帮助玩家生成自定义任务、战斗结束后的对局分析与打法建议、甚至用 AI 帮你写剧情草稿再人工润色。Unity 本身就是内容生成工具,把大模型当成一个“实时内容生成后端”,能玩的花样非常多。
DeepSeek 在这一轮里被大家盯上主要是三点:价格便宜、中文效果好、API 兼容 OpenAI 的格式。意味着你之前调 OpenAI 接口的代码逻辑,换一下base_url和 key 基本就能复用。对于个人开发者或者中小团队来说,这是非常务实的选择。
1.2 流式传输不是“锦上添花”,而是体验底线
我最早偷懒用的是非流式调用:发一个请求过去,等个三五秒,然后整段内容一次性吐出来。放在后台工具脚本里问题不大,但一旦面向玩家,体验就很糟糕。玩家会怀疑是不是卡死了、网络是不是断了,尤其对话内容一长,等待时间线性拉长。
流式传输的本质是让服务端边生成边把内容推给你,客户端拿到一段就显示一段。玩家看到的是“正在打字”的 AI,而不是“正在转圈的加载”。这种体验上的差距,在交互型应用里就是天壤之别。
从技术角度说,DeepSeek 官方对 OpenAI 兼容接口的流式实现走的是 SSE(Server-Sent Events)协议,也叫 server-send-events。它建立在 HTTP 基础之上,服务端可以持续往同一个连接里写数据,客户端不需要轮询。理解这一点,是接下来所有实现的前提。
2. 接入前的准备工作:环境、账号和基础调用格式
2.1 开发环境与账号准备
我这边用的 Unity 版本是 2021.3 LTS,虽然 Demo 代码大部分在 Unity 2020 上也能用,但建议至少 2021 以上,原因后面讲纹理和编码处理时会提到。申请 API key 这一步没啥好说的,平台上注册后拿一串 key 就行了。注意这串 key 要保管好,别硬编码在最终包里——至少放到一个配置文件里,最好是启动时从远程拉取,哪怕只是个人项目也应该有这个意识。
这里有一个分工建议:业务逻辑层放在 C# 脚本里,但网络层不要直接塞进 MonoBehaviour,尽量拆成一个独立的 HTTP 请求工具类。这既方便单独测试,也避免场景重载时请求生命周期失控。
2.2 先跑通一次非流式请求,确认链路是通的
工程里我先写了一个最原始的UnityWebRequest调用,把请求体发到 DeepSeek 的接口,拿到完整 JSON 再说。DeepSeek 的接口地址是https://api.deepseek.com/chat/completions,请求体和 OpenAI 格式一致:
{ "model": "deepseek-chat", "messages": [ { "role": "user", "content": "你好,简单介绍一下你自己" } ], "stream": false }注意"stream": false意味着非流式返回,返回 JSON 里choices[0].message.content就是完整回答。这一步先把链路调通,能拿到正确结果再做流式改造——我见过不少人一上来直接上流式,结果分不清问题是在网络层还是解析层,排查起来非常痛苦。
2.3 遇到最常规的一个坑:Unity 编辑器里的平台限制
在 Unity 编辑器里,有个很隐蔽的问题需要提前说:UnityWebRequest跑 http 协议会有安全限制,Windows 编辑器下默认禁止 HTTP 明文请求,直接给你一个 SSL 相关的报错。DeepSeek 接口是 HTTPS 的,所以没这个问题。但如果你在公司内网做调试用的中转服务是 HTTP,就得去 Player Settings 里把Allow downloads over HTTP挑成Always allowed。这个配置项小,但卡住过不少人。
非流式请求能通之后,我已经能确定:网络没问题、key 没问题、模型参数格式没问题。接下来才是真正的重头戏——流式传输。
3. SSE 协议基本原理,和 Unity 里实现流式的方案选型
3.1 SSE 报文到底长什么样
SSE 和普通 HTTP 响应最直观的区别在 Content-Type。普通响应的Content-Type是application/json,流式响应是text/event-stream。响应体不是一整块 JSON,而是一连串事件,每个事件由若干行key: value组成,事件之间用空行分隔。在 DeepSeek 的流式返回里,核心字段是data::
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"role":"assistant","content":""},"index":0}]} data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你好"},"index":0}]} data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":",我是"},"index":0}]} data: [DONE]每一条data:后面跟着的是一段独立的 JSON,增量内容就在choices[0].delta.content里。到最后会有一个特殊的data: [DONE]表示结束。理解了这套报文格式,解析代码就只是一层薄薄的壳了。
3.2 Unity 里三条流式实现路线对比
这一步是我花时间最多的部分,把三种方案实际写出来跑过一遍之后才定下来最终的路线。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
UnityWebRequest+DownloadHandler | 等待服务器关闭连接后才拿完整数据 | 接入简单,少量代码 | 不是真正的流式,无法做到逐 token 展示 |
HttpClient+StreamReader | 底层分块读取 HTTP 响应体,边读边解析 | 真正的逐 token 处理,支持取消、超时控制,代码清晰 | 需要自己管理线程,注意跨线程回调 |
裸TcpClient手写 HTTP 解析 | 直接在 TCP 层解析报文 | 最底层控制,没有任何中间依赖 | 开发量大,HTTP/1.1 的 chunked 编码要自己处理,容易出边界 bug |
我第一次验证流式效果时用的是HttpClient,后来在真机上跑了好几个版本,最终也一直用它。原因很简单:UnityWebRequest压根没给流式读取的接口,它在DownloadHandler层面把数据缓存在内存里,只有在请求生命周期结束(连接断开)后才能拿到完整内容。有人说我用UnityWebRequest也能收到多次回调,其实那是把DownloadHandlerBuffer与协程轮询配合的伪流式,数据的实际到达时间和内容连续性与真正的流式有明显差异,体验不够稳定。
HttpClient方案里我搭配了StreamReader.ReadLineAsync(),这可以在服务端每推送一 event 时立刻返回一行data:,解析进度和生成进度是同步的,这才是真正的流式。
3.3 为什么 Unity 主线程限制让流式实现多了不少活
Unity 的规则大家都知道:引擎 API(比如 UI 文本更新)只能在主线程调用,网络回调往往在子线程。用HttpClient发起异步请求后,拿到数据回调时的线程上下文不一定是主线程,所以不能直接在回调里改Text.text。我在工程里做了一个很轻量的MainThreadDispatcher工具类:网络子线程拿到增量文本后,把它塞进一个ConcurrentQueue<string>,主线程在Update()里统一取队列并刷新 UI。这个模式简单可靠,也比UnityMainThreadDispatcher组件少一层依赖。
4. 完整实现:Unity 流式接入 DeepSeek 的实战代码
4.1 数据模型定义
我习惯在工程里单独建一个ChatModels.cs用来放请求和响应的数据结构。Unity 里可以直接用Newtonsoft.Json(在 Package Manager 里搜com.unity.nuget.newtonsoft-json装官方移植版),也可以直接用JsonUtility。不过流式场景里需要解析数据分段,建议直接用JsonSerializer(灵活、性能好),后面会讲为什么要这样做。
请求实体就是标准 OpenAI 格式:
[Serializable] public class ChatRequest { public string model = "deepseek-chat"; public List<ChatMessage> messages = new List<ChatMessage>(); public bool stream = true; public float temperature = 0.7f; } [Serializable] public class ChatMessage { public string role; public string content; public ChatMessage(string role, string content) { this.role = role; this.content = content; } }Stream 响应这边,我只需要解析choices[0].delta.content,用一个轻量的 DTO 接住就可以:
[Serializable] public class StreamChunk { public List<StreamChoice> choices; } [Serializable] public class StreamChoice { public StreamDelta delta; } [Serializable] public class StreamDelta { public string content; }4.2 核心方法论:HttpClient 流式请求封装
为了让代码能直接在别人的项目里复用,我写了一个不依赖 MonoBehaviour 的DeepSeekClient类。构造函数接收 API key,并对外暴露三个事件:OnChunkReceived(每收到一个增量片段)、OnComplete(请求完成)、OnError(出错)。
public class DeepSeekClient { private readonly HttpClient _httpClient; private readonly string _apiKey; public event Action<string> OnChunkReceived; public event Action<string> OnComplete; // 传入完整拼接结果 public event Action<string> OnError; public DeepSeekClient(string apiKey) { _apiKey = apiKey; _httpClient = new HttpClient(); _httpClient.Timeout = TimeSpan.FromSeconds(60); } public async Task StreamChatAsync(string userMessage, List<ChatMessage> history = null) { var request = new ChatRequest(); request.messages = new List<ChatMessage>(); if (history != null) request.messages.AddRange(history); request.messages.Add(new ChatMessage("user", userMessage)); var requestJson = JsonConvert.SerializeObject(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.deepseek.com/chat/completions"); httpRequest.Headers.Add("Authorization", $"Bearer {_apiKey}"); httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); OnError?.Invoke($"HTTP {(int)response.StatusCode}: {errorBody}"); return; } using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream, Encoding.UTF8); var builder = new StringBuilder(); string line; while ((line = await reader.ReadLineAsync()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; if (!line.StartsWith("data:")) continue; var jsonData = line.Substring(5).Trim(); if (jsonData == "[DONE]") { OnComplete?.Invoke(builder.ToString()); return; } var chunk = JsonConvert.DeserializeObject<StreamChunk>(jsonData); if (chunk?.choices != null && chunk.choices.Count > 0) { var delta = chunk.choices[0].delta; if (!string.IsNullOrEmpty(delta?.content)) { builder.Append(delta.content); OnChunkReceived?.Invoke(delta.content); } } } } }这段代码几个关键点解释一下:
HttpCompletionOption.ResponseHeadersRead是最关键的一行。它告诉HttpClient:只等响应头到达就立即返回,不要等整块 body 下载完。少了这个参数,await会一直挂到流结束,又回到非流式。StreamReader.ReadLineAsync()按行读取 SSE 的data:数据。每一行都是一个事件,返回时机与服务端推送时机基本同步。- 用
StringBuilder累积完整回答,最终在[DONE]时一次性输出,方便后续做 token 数统计或保存记录。
4.3 增量 JSON 解析里的细节
解析时有个很常见的现象:SSE 的data:行几乎是瞬时分片的,拿到的 JSON 字符串虽然每行是独立的,但里面可能包含转义字符、超长中文字符串等。用JsonConvert.DeserializeObject<StreamChunk>解析此段数据时,理论上每一行都是完整 JSON,实践中实测验证如此。不过我遇到过一种情况:服务端在极端情况下可能会把一个事件的数据跨行拆开,或者空行被意外挤压。出于稳妥,我在解析层做了一个“当前缓冲 + 等待完整闭合”的防御:
private StringBuilder _lineBuffer = new StringBuilder(); private StreamChunk TryParseChunk(string rawData) { _lineBuffer.Append(rawData); var text = _lineBuffer.ToString(); // 简单判断:包含完整的选择器结构才尝试解析 if (!text.Contains("\"choices\"")) return null; try { var chunk = JsonConvert.DeserializeObject<StreamChunk>(text); _lineBuffer.Clear(); return chunk; } catch (JsonException) { // 半包,等待下一段拼进来 return null; } }这个缓冲逻辑意义在于:宁可多等几毫秒,绝不让解析异常导致整个流中断。此外要提防JsonConvert的默认日期解析在一些 Unity 版本里会对字符串内容做奇怪的时间转换,遇到异常直接 catch 住就好。
4.4 主线程回调工具
我在场景里挂了一个MainThreadDispatcher单例,作用是收集子线程推送的文本并排队:
public class MainThreadDispatcher : MonoBehaviour { public static MainThreadDispatcher Instance { get; private set; } private readonly ConcurrentQueue<string> _chunkQueue = new ConcurrentQueue<string>(); private readonly ConcurrentQueue<string> _completeQueue = new ConcurrentQueue<string>(); private readonly ConcurrentQueue<string> _errorQueue = new ConcurrentQueue<string>(); public event Action<string> OnChunkReceived; public event Action<string> OnComplete; public event Action<string> OnError; private void Awake() { if (Instance == null) Instance = this; } public void EnqueueChunk(string chunk) => _chunkQueue.Enqueue(chunk); public void EnqueueComplete(string result) => _completeQueue.Enqueue(result); public void EnqueueError(string error) => _errorQueue.Enqueue(error); private void Update() { while (_chunkQueue.TryDequeue(out var chunk)) OnChunkReceived?.Invoke(chunk); while (_completeQueue.TryDequeue(out var result)) OnComplete?.Invoke(result); while (_errorQueue.TryDequeue(out var error)) OnError?.Invoke(error); } }这样 Unity 主线程Update()里自动取出队列内容刷新 UI,完全不触碰跨线程 API 的雷区。
5. 实测表现:从等待加载到逐字输出,体验差距有多大
5.1 实测数据
我在同一台机器、同一段 prompt 下分别测了非流式和流式两种方式。测试 prompt 是让 DeepSeek 写一份 500 字左右的游戏剧情简介。测试配置就是上面那段代码,Unity 2021.3,Windows 10。
非流式:按下按钮后,界面“请求中”状态维持了大约 6.2 秒,然后整段文字一次性显示。流式:按下按钮后大约 1.4 秒出现第一个字符,后续每个片段间隔极短,约 4.8 秒内全部展示完毕。注意,两者总完成时间差别不大,但是“首次响应时间”(TTFT,Time To First Token)从 6.2 秒降到了 1.4 秒,用户在 1 秒多后就看到文字开始跳动了,主观等待感大幅缩小。
这个测试告诉我们:流式传输不是让 AI 生成得更快,而是让“等待结果”的感觉更快结束,本质上是把等待时间切碎并分摊到接收过程中。这个思路放在任何面向用户的内容生成场景里都成立。
5.2 刷新频率与节流
逐字刷新的情况下,如果每收到一个 chunk 就直接改 UI 文本,在某些低端安卓机上会出现 Text 重建频繁导致的卡顿。实测在 PC 上无感,但在 Pico 4 这类安卓头显上就明显掉帧。我的处理是在MainThreadDispatcher里加一个累积字符串 + 刷新间隔控制:
private string _pendingChunk = ""; private float _lastFlushTime; private void Update() { while (_chunkQueue.TryDequeue(out var chunk)) { _pendingChunk += chunk; } if (Time.time - _lastFlushTime >= 0.08f && !string.IsNullOrEmpty(_pendingChunk)) { OnFlushedChunk?.Invoke(_pendingChunk); // 每 80ms 批量刷新一次 _pendingChunk = ""; _lastFlushTime = Time.time; } }80ms 的刷新间隔肉眼几乎察觉不到,但 UI 压力降低了十倍以上。
5.3 成本控制从设计阶段就要想
DeepSeek 的 API 是按 token 计费的,流式返回时响应里的usage字段只在非流式模式出现。流式模式为了逐字快响,并不会提前告知总 token 数。这意味着你如果完全依赖流式接口,单次请求的成本统计会缺失——但可以通过拼接完整回答自己做字数估算,或者用非流式的 usage 字段做对账。
我实测过中文字数到 token 数的大致比例:1 个汉字约等于 1.2~1.5 token(和上下文窗口、特殊字符有关)。游戏对话频繁的玩家一天可能发起上百次请求,如果回答都很长,这个成本不能忽略。体量小的时候,我会在前端做“最大生成长度”限制,把max_tokens设为 256~512 之间,保证对话简短、成本可控。
6. Unity 接入 DeepSeek 流式传输的常见坑:完整排查链路
6.1 坑一:子线程异常不弹到主线程,请求“莫名失败”
我最早跑流式代码时,在StreamChatAsync里忘了包 try-catch,结果发出请求后 UI 上什么都没发生,编辑器控制台也完全没日志。后来查了半天发现是HttpClient.SendAsync抛的异常发生在子线程中,Unity 的主线程日志系统默认捕获不到。这种情况下的表现就是“点击按钮后石沉大海”,非常劝退。我的做法是在入口处把整个流程包成 try-catch,并把异常信息塞进OnError回调:
public async Task StreamChatAsync(string userMessage, List<ChatMessage> history = null) { try { // 网络请求主流程 } catch (Exception ex) { OnError?.Invoke(ex.Message); } }只要养成这个习惯,后面所有暗坑都能第一时间暴露在 UI 上,而不是沉到编辑器日志深处。
6.2 坑二:中文乱码与编码不一致
有一次在 Windows 编辑器下跑得挺正常,打包到 Android 真机后返回的中文变成了 Unicode 转义形式的字符串,看起来像\u4f60\u597d。排查下来是StreamReader没指定 UTF-8,在某些 Android 运行时下默认编码行为不一致。解决办法很简单:创建StreamReader时第二参数强制传Encoding.UTF8:
using var reader = new StreamReader(stream, Encoding.UTF8);这算是个小细节,但跨平台项目里这类问题最容易在最后阶段冒出来。
6.3 坑三:HttpClient 在部分 Android 机型上的 TLS 限制
实测发现部分老旧 Android 设备(系统 API level 可能偏低)内置的 TLS 版本较低,连 HTTPS 接口时可能出现 “The SSL connection could not be established” 之类的异常。如果你的目标设备包含比较老的安卓机,建议在打包设置里启用可用的现代 TLS 支持,或者干脆在接口层做应答失败后的二级降级方案:改用一条兼容性更好的UnityWebRequest非流式调用作为兜底。流式失败是偶发场景时,界面不应直接报错,而是切到“请求中”状态然后一次性展示完整回答。
6.4 坑四:协程与 async/await 的混用冲突
Unity 异步支持已经成熟了,但老项目里大量存在那种用协程处理请求的代码:yield return new WaitForSeconds()配UnityWebRequest.SendWebRequest()。把协程和async/await混在一个MonoBehaviour生命周期里,会碰到一个典型问题:协程无法直接await一个Task。我最初的方案是在协程里yield return new WaitUntil(() => task.IsCompleted),看起来能跑,但没法写超时控制,而且 task 在IsCompleted前出现异常也不会自动抛出。最终我干脆把请求逻辑全部放在独立类里,用StartCoroutine(DoRequest().AsCoroutine())的方式包装Task,或者直接抛弃协程改用异步方法 + 状态机。项目越老,越建议在这条路径上做干净彻底的替换。
6.5 坑五:连续快速发送请求导致回调错乱
玩家短时间内连续点击发送,或者同一会话多个并发请求时,如果没有做请求 ID 隔离,会出现“第二个问题的回答跑到第一个问题 UI 里”这种诡异体验。我加了一个简单的自增 requestId 参数,在回调事件里带回去,UI 层比对当前显示的是不是最新请求,不是就直接丢弃:
public event Action<int, string> OnChunkReceived; // requestId, delta // UI 层 private void HandleChunk(int requestId, string delta) { if (requestId != _currentRequestId) return; _chatText.text += delta; }这个坑在做多轮对话时非常值得提前防住。
7. 工程化落地:把流式对话做成正式功能的优化建议
7.1 系统架构分层与替换扩展
演示项目可以一个脚本写完,但要做成正式功能,我会把系统拆成三层:接入层(DeepSeekClient)、业务层(对话状态机 + 历史管理)、表现层(UI 刷新、音频朗读)。接入层和业务层之间用接口隔离,这样以后如果从 DeepSeek 切换到别的模型(或者公司内部部署的私有模型),只需要换一个IChatClient实现,UI 完全不受影响。
7.2 多轮对话的记忆管理
每个会话请求必须把历史消息都带上去,否则多轮对话会失忆。但历史消息无限增长会让 token 数快速膨胀,成本也不可控。我的方式是维护一个“对话窗口”:最近 8 轮消息全部携带,超过之后把更早的消息压缩成一条摘要塞进system消息。摘要由 DeepSeek 自己生成,用一个低成本的deepseek-chat调用完成。这样长对话既有上下文连续性,又不会让单次请求的 token 数爆炸。
7.3 超时、重试与离线兜底
大模型服务虽然稳定,但玩家网络环境千奇百怪,必须做三重保护:60 秒无响应整体超时、遇到网络错误自动重试一次(指数退避)、连续两次失败则走本地兜底回复(比如“我这边网络有点卡,请稍后再试”)。这里的重点是兜底文案也要设计成符合角色设定的口吻,而不是直接抛出技术错误码。游戏里的 AI 角色的“人设”不能因为一个异常就被击穿。
7.4 本地部署的进阶方向
如果项目对数据隐私要求很高,或者大量调用导致 API 成本实在控不住,还有一条路:本地部署 DeepSeek 模型。基于 vLLM 加载量化后的轻量模型,在内网提供兼容 OpenAI 格式的接口,Unity 端代码可以原样复用。本地部署以后,流式传输依然适用,因为 vLLM 本身就支持 SSE 流式输出。不过硬件门槛和运维成本需要先估算清楚,这里只做提醒,不展开具体部署步骤。
7.5 语音朗读与流式的配合
游戏 NPC 对话如果配上语音,流式还能自动解决“什么时候开始读”的问题——第一个 token 到达后就可以让语音播报开始,收到的文字增量同步供语音合成使用。实现在本地 TTS 引擎上,把一次性等待改成了边收边合成,玩家的感官体验会再上一个台阶。我在一个 Demo 里试过这个玩法,效果比“先显示完再朗读”要自然得多,值得往这个方向投入。
8. 一个容易被忽略的问题:流式场景下的 token 统计与用量对账
8.1 流式响应不回传 usage,怎么对账
非流式接口的响应体里会带一个usage字段,包含prompt_tokens和completion_tokens,方便做费用核算。但流式接口为了逐字快传,响应体里每一条 chunk 里都没有 usage,只有[DONE]之前也不会汇总统计。这意味着你如果全走流式,平台后台能看到每个 key 的调用量和计费,但你在业务层没法精确到具体某一次会话的 token 消耗。
解决办法是前端自己做估算。我用的方法是拿到完整回答后做一次字符长度换算,并在 UI 日志里记录每轮对话的估算 token 数。准确率虽不如官方 usage,但做成本预警已经够用。注意,这不影响计费本身——计费由服务端核算,前端对账只是为了自己心里有数。
8.2 控制 single-turn 生成长度
DeepSeek API 的参数里,max_tokens控制单次输出最大 token 数。你不设置时,模型可能会按默认上限生成很长的回答。游戏场景里大部分对话响应不应该超过 200~300 token。我统一设置max_tokens = 512,在某些特定场景(比如剧情生成)按需单独调大。这个参数对成本和响应速度都影响显著,别嫌麻烦,每个场景都值得独立配置。
9. 我最终落地时保留的架构与后续规划
写完这一整套方案后,我回头把代码重新整理了一遍,最后落地的结构比最初演示版复杂一些,但每一层都有清晰的职责:
ChatModels.cs:所有请求/响应 DTO,单纯数据结构。DeepSeekClient.cs:只负责 HTTP 通信和事件回调,不碰 Unity API。MainThreadDispatcher.cs:负责线程调度和 UI 刷新节流。ChatSession.cs:管理多轮对话历史、token 预算、请求 ID 冲突。UIChatPanel.cs:负责可视化输出,对接按钮、文本、语音播放。
这套结构最大的好处是整体可以脱离 Unity 编辑器做单元测试。网络请求层的事,我用了一个简单的 C# 控制台测试程序去验证流式行为,确认无误后再挂回 Unity 场景。省下了大量在编辑器里反复点击调试的时间。
后续我准备扩展的方向有两个。第一个是在流式输出期间支持“打断”:玩家点一下聊天框,当前请求立刻取消并保留已生成内容,让 AI 根据新输入继续回答。这需要在HttpClient层传入CancellationToken,Unity 里用CancellationTokenSource管理。第二个是在 NPC 对话表现层做更精细的情绪状态机:流式接收的同时,根据内容关键词驱动角色的表情和动作切换,让 AI 对话不只是“文字冒泡”,而是真正的“角色演出”。
Unity 接 DeepSeek 这件事,技术上的门槛其实已经降得很低了,难点集中在流式传输的正确选型、跨线程处理、以及工程化的边界控制。希望这篇记录能帮你少走一些弯路。这里额外分享一个我自己的习惯——写流式请求时,先在命令行用curl调一遍原生 SSE 接口,确认服务端响应正常后再动 Unity 代码。别小看这一步,它能帮你把网络层问题和 Unity 层问题快速切分开。