上个月接了个需求,要把游戏里一个只会说"今天天气真好"的NPC,改造成能回答玩家任意问题的角色。策划的原话是:"别加对话树了,玩家问什么它答什么。"我当时看了一眼编辑器里那几十个分支节点,心里已经把关键字匹配方案否掉了。做固定对话靠策划堆文本,做自由对话只能上大模型。
调研了一圈,最后选了DeepSeek。原因有三:接入成本低,API风格和OpenAI高度一致,文档里的请求示例直接抄就能跑;国内访问稳定,延迟基本在可接受范围内;游戏对话场景用deepseek-chat模型就够,回复质量和速度都合适。整条链路从零到跑通,大概花了一个下午加一个晚上调细节。
这篇文章就把完整过程写出来,包括工程怎么搭、请求怎么发、响应怎么解析、上下文怎么管理,以及我实际踩过的几个坑。适合正在考虑给Unity游戏接AI对话的开发者,无论你是做单机叙事、开放世界NPC还是小游戏,核心思路都通用。
1. 先搞清楚:在游戏里做AI对话,到底选哪条路
1.1 需求场景拆解
接到这类需求,第一步不是写代码,而是想清楚"AI对话"在这个游戏里到底是什么形态。
目前游戏里的对话系统大致有三类。第一类是传统对话树,策划维护一堆节点和选项,玩家只能在预设路径里跳转,这种方案优点是可控性强、好debug,缺点是内容量固定,玩家重复体验时会觉得"这NPC只会这几句"。第二类是关键字匹配,玩家输入文本,程序去匹配预设词表返回对应回答,这种方案本质还是查表,只是把入口从点击选项换成了输入框,稍微绕一点的问题就答不上来。第三类就是我这次要做的:真正接一个大模型API,把玩家输入发给模型,由模型根据角色设定生成回复。
你要先判断自己的项目到底需不需要第三类。如果游戏核心玩法是线性叙事,对话树完全够用;但如果你想让NPC拥有"无限可能"的对话体验,或者想让玩家每轮输入都不太一样,那大模型API基本是唯一解。
我这次做的项目是一个偏叙事的2D冒险游戏,NPC是村庄里的老学者,玩家可以问它关于世界观、任务线索、道具用途等问题。这些信息散布在几十段剧情里,用对话树维护会非常痛苦,接AI反而省事。
1.2 几种方案的真实对比
定了方向之后,我在本地模型、国外大模型API和DeepSeek API三者之间做了对比。
本地模型比如Llama系列,好处是数据不出设备、无接口费用,但代价是性能和打包体积。你要在PC上跑一个7B模型,至少需要十几GB内存和一块还行的显卡,在移动端基本不现实。Unity游戏的目标平台是手机和WebGL的话,本地模型可以直接排除。
国外主流大模型API体验不错,但延迟和支付门槛是硬伤。在国内网络环境下,哪怕请求能发出去,首字返回时间也经常到两三秒以上,游戏对话这种高频交互场景会很出戏。另外很多服务需要国外信用卡,独立开发者和中小团队光是开通账号就要折腾一阵。
DeepSeek API在这三者里算是比较均衡的选择。它的接口兼容OpenAI格式,也就是说你过去写的OpenAI请求代码,把base_url和model换掉就能跑。国内直连延迟表现也更好。费用方面,日常开发调试的成本很低,做成游戏后只要控制好对话长度和调用频率,成本可以接受。
1.3 DeepSeek接入的边界:客户端直调还是走服务端
这里要提前说清楚一个架构问题:客户端到底能不能直接调DeepSeek API?
原型阶段可以直调,我也推荐你这么做,因为调试最快。但到正式发布,尤其是WebGL和移动端,强烈建议你自建一个轻量后端做中转。原因有三:第一,API Key放在客户端包里就等于公开了,任何解包工具都能把你的Key挖出来,别人拿到就可以盗刷你的余额;第二,浏览器和微信小游戏有跨域限制,直连API可能被CORS拦截;第三,自建代理后,你可以在服务端做频率限制、内容过滤、缓存和日志,这些能力客户端直调很难实现。
我个人的节奏是:原型阶段全在Unity里跑通,确认体验没问题之后,再花半天用Node.js写一个转发服务,把Key放到服务端环境变量里。这个后续会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:Unity版本、依赖库、API Key这些别搞错
2.1 我用的Unity版本和工程结构
这次用的是Unity 2021.3 LTS。其实接入DeepSeek本身不挑Unity版本,只要支持UnityWebRequest和协程就能跑。如果你项目还在2019 LTS,也没问题,不过要注意部分API写法有差异,后面代码里我会标注。
工程结构建议这样建:
code复制Assets/
Scripts/
AI/
DeepSeekClient.cs // 核心请求客户端
ChatMessage.cs // 消息体
ChatRequest.cs // 请求包装
ChatResponse.cs // 响应包装
UI/
DialogueUI.cs // 输入框、发送按钮、回复展示
把AI相关代码和UI逻辑分开,后续不管是换模型还是换UI,改动范围都小。
2.2 为什么我不用JsonUtility,而是引入Newtonsoft.Json
Unity自带的JsonUtility很多初学者在用,但它有两个问题在接入第三方API时特别难受:一是序列化时只认[Serializable]的类,遇到复杂嵌套结构时不灵活;二是字段名严格区分大小写,而DeepSeek返回的JSON是小写字段,比如choices、message、content,你要么写一堆不优雅的中间类,要么忍受字段对不上时静默丢数据的诡异问题。
我测试时用JsonUtility解析过一次响应,结果choices里全是空对象,排查半天才发现是字段名大小写和多层嵌套的问题。换成Newtonsoft.Json后,几行代码就搞定了。
安装方式很简单:打开Package Manager,左上角加号选择"Add package by name",输入com.unity.nuget.newtonsoft-json,版本选3.2.1即可。这个包是Unity官方维护的Newtonsoft.Json版本,兼容性有保障。
2.3 API Key的申请和配置管理
DeepSeek的API Key在官网控制台里申请,创建后拿到一串sk-开头的字符串。创建Key的时候记得把额度设置好,新手建议设个每月限额,防止测试阶段意外超支。
代码层面,Key不要硬编码写在脚本里。我做了一个最简单的AppSettings ScriptableObject,里面存一个ApiKey字段,在Editor窗口里填好值,通过菜单生成一个Asset文件。生成后在.idea目录或.gitignore里忽略这个Asset。这样Key不会跟着代码仓库走,也方便后续切换测试Key和生产Key。
当然原型阶段直接写在代码里最省事,我理解,但发布前哪怕花十分钟把它挪到配置里也值得。
3. 核心链路:一条消息从输入框走到AI再走回来的完整代码
3.1 请求体结构:messages才是对话的灵魂
DeepSeek的接口路径是POST /chat/completions,请求体长这样:
json复制{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个老练的学者..."},
{"role": "user", "content": "这把钥匙是做什么的?"}
],
"temperature": 0.7,
"max_tokens": 512,
"stream": false
}
关键的字段就一个messages数组。数组里的每个元素有role和content,role分三种:system表示系统指令,用来设定角色身份和行为规则;user代表玩家输入;assistant代表模型之前的回复。多轮对话本质上就是不断往这个数组里追加消息,然后把整个数组发给模型。
游戏对话场景里,我的建议是max_tokens控制在256到512之间。游戏对话要的是短句和即时反馈,不是让模型写小作文。太长会增加等待时间,也会推高费用。
3.2 用UnityWebRequest发送POST请求
我先把消息体和请求类定义好:
csharp复制using System;
using System.Collections.Generic;
using Newtonsoft.Json;
[Serializable]
public class ChatMessage
{
[JsonProperty("role")] public string Role;
[JsonProperty("content")] public string Content;
public ChatMessage(string role, string content)
{
Role = role;
Content = content;
}
}
public class ChatRequest
{
[JsonProperty("model")] public string Model = "deepseek-chat";
[JsonProperty("messages")] public List<ChatMessage> Messages = new List<ChatMessage>();
[JsonProperty("temperature")] public float Temperature = 0.7f;
[JsonProperty("max_tokens")] public int MaxTokens = 512;
[JsonProperty("stream")] public bool Stream = false;
}
然后写核心的请求方法。Unity里发网络请求,我推荐直接用UnityWebRequest配合协程,理由后面会专门说:
csharp复制using System.Collections;
using System.Text;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.Networking;
public class DeepSeekClient : MonoBehaviour
{
private const string ApiUrl = "https://api.deepseek.com/chat/completions";
private string _apiKey = "sk-你的Key";
public IEnumerator SendChatRequestAsync(List<ChatMessage> messages, System.Action<string> onSuccess, System.Action<string> onError)
{
var payload = new ChatRequest { Messages = messages };
string json = JsonConvert.SerializeObject(payload);
using (var request = new UnityWebRequest(ApiUrl, "POST"))
{
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.SetRequestHeader("Authorization", "Bearer " + _apiKey);
request.timeout = 30;
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var response = JsonConvert.DeserializeObject<ChatResponse>(request.downloadHandler.text);
string reply = response.Choices[0].Message.Content;
onSuccess?.Invoke(reply);
}
else
{
onError?.Invoke(request.error + "\n" + request.downloadHandler.text);
}
}
}
}
这里有个细节:SetRequestHeader("Authorization", "Bearer " + _apiKey)是鉴权关键,格式必须是Bearer加空格再加Key,少了空格或者大小写不对,接口会直接拒绝。
3.3 响应解析:拿到choices里的content
响应的JSON结构大致如下:
json复制{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "deepseek-chat",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "这把钥匙是图书馆暗格里的..." },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 18, "completion_tokens": 23, "total_tokens": 41 }
}
对应的C#响应类:
csharp复制using System.Collections.Generic;
using Newtonsoft.Json;
public class ChatResponse
{
[JsonProperty("choices")] public List<Choice> Choices { get; set; }
}
public class Choice
{
[JsonProperty("message")] public ChatMessage Message { get; set; }
[JsonProperty("finish_reason")] public string FinishReason { get; set; }
}
拿到downloadHandler.text后,反序列化成ChatResponse,通过response.Choices[0].Message.Content就能取到模型生成的内容。注意Choices不是空数组,但稳妥起见建议加个判空,网络抖动或者返回异常时防止索引越界。
接口报错时,比如Key无效或者余额不足,HTTP状态码不是200,响应体里会有一个error字段,里面包含message和type。我在onError回调里把downloadHandler.text也打印出来,就是为了快速定位这类问题。
4. 把调用包装成好用的对话管理器
4.1 多轮上下文:别让AI失忆
单发一条消息很简单,但游戏对话是连续的。玩家问完"这把钥匙是做什么的",可能还会追问"那图书馆在哪"。如果每次都只发当前这句话,模型没有上文,回答就会前后矛盾。
解决办法是把历史消息都带上。我在DeepSeekClient之外加了一个DialogueManager,里面维护一个消息列表:
csharp复制public class DialogueManager : MonoBehaviour
{
private List<ChatMessage> _history = new List<ChatMessage>();
private const int MaxHistoryMessages = 20;
public List<ChatMessage> BuildRequestMessages(string userInput)
{
if (_history.Count == 0)
{
_history.Add(new ChatMessage("system", _systemPrompt));
}
_history.Add(new ChatMessage("user", userInput));
// 超出长度时,移除最早的对话(保留system)
while (_history.Count > MaxHistoryMessages)
{
_history.RemoveAt(1);
}
return _history;
}
public void AppendAssistantReply(string reply)
{
_history.Add(new ChatMessage("assistant", reply));
while (_history.Count > MaxHistoryMessages)
{
_history.RemoveAt(1);
}
}
}
这段代码有两个关键点。第一,RemoveAt(1)是在移除历史里最早的那条用户消息,索引0永远留给system,这样角色设定不会被挤掉。第二,收到模型回复后一定要调用AppendAssistantReply把assistant消息加回去,否则下一轮对话模型看到的还是发送前的历史,相当于它永远不知道自己上一句说过什么。
4.2 系统人设:用system prompt控制回答风格
system消息是控制角色行为最重要的手段。游戏里不同角色的人设,靠的就是这个。
我这次给老学者角色写的system prompt大概是:
code复制你是村里的一名老学者,性格温和、说话慢条斯理,喜欢用比喻解释问题。你了解村庄的历史、地理和人物的秘密。回答要简短、自然,像在跟老朋友聊天,不要超过80字。如果玩家问的事情你不知道,就诚实地说不清楚,不要编造。
这里面包含三个信息:角色身份和性格、问题的知识范围、回答格式约束。实际测试下来,格式约束特别重要,不加的话模型偶尔会产出几百字的长篇大论,在游戏对话里非常出戏。
配合temperature参数,可以进一步控制风格稳定性。想要更稳定、更符合人设,把temperature调到0.3到0.5;想要更有随机性、更像真人闲聊,调到0.8以上。游戏NPC我建议0.6左右,既不会太死板也不会太跳脱。
4.3 操作竞态:防止玩家连点按钮导致请求堆积
原型阶段我犯过一个错:玩家快速点击发送按钮,触发了好几个并发请求,模型的回复顺序乱了,后发的先返回,先发的后返回,UI上显示的内容跟玩家问题根本对不上。
解决办法是加一个发送锁:
csharp复制public bool IsWaiting { get; private set; }
public IEnumerator SendMessageCoroutine(string userInput)
{
if (IsWaiting) yield break;
IsWaiting = true;
var messages = _dialogueManager.BuildRequestMessages(userInput);
yield return StartCoroutine(_client.SendChatRequestAsync(messages,
reply =>
{
_dialogueManager.AppendAssistantReply(reply);
_dialogueUI.ShowReply(reply);
IsWaiting = false;
},
error =>
{
Debug.LogError(error);
_dialogueUI.ShowReply("(网络似乎不太顺畅,请稍后再试。)");
IsWaiting = false;
}));
if (IsWaiting) IsWaiting = false;
}
发送期间,UI层也要有反馈。我直接把发送按钮的interactable置为false,并显示一个"AI思考中"的转圈动画。等回调结束后再恢复,这样从交互层面彻底杜绝连点问题。
5. 联调阶段最容易被坑的四个地方
5.1 协程和async怎么选,以及Unity对象不能随意跨线程
很多新手在Unity里发HTTP请求会用HttpClient配async/await,然后发现报错:"Trying to access a Unity object from another thread." 这是因为HttpClient的回调不在Unity主线程上,而UI操作必须在主线程执行。
我的建议是能不用HttpClient就不用。UnityWebRequest本身提供了协程接口,用yield return request.SendWebRequest()等待结果,回调就在主线程上,完全不用操心线程切换。
如果你项目里已经大量使用async/await,也可以用,但要注意在继续执行UI操作前,切回主线程。最简单的写法是用UniTask配合UniTask.SwitchToMainThread(),否则就老老实实用协程。我这次全程协程,代码直白、不容易出问题。
5.2 WebGL和微信小游戏:跨域与密钥泄露
如果你发布目标是WebGL,或者后续想转微信小游戏,有两个问题必须提前处理。
第一个是CORS跨域。浏览器环境下,你的页面域名是localhost或者某网站域名,而API地址是api.deepseek.com,跨域请求如果没有相应响应头,浏览器会直接拦截。我在WebGL构建后第一次测试,Console里就是一大片CORS错误。
解决办法不是绕跨域,而是自建一个后端代理。我的方案很简单,用Node.js写一个转发接口,把Unity发来的消息先在后端换成正确的Key,再转发给DeepSeek,拿到结果后返回给Unity。这样Unity请求的是你自己的域名,不存在跨域问题,Key也不会暴露到浏览器端。
第二个是密钥泄露。WebGL包里的资源可以被轻易解包,脚本里的字符串一翻就能看到。只要走了自己的代理,这个问题就解决了。开发阶段在编辑器里直连API没问题,但打包出去之前一定要切到代理模式。
5.3 Android真机:网络权限和明文流量限制
Android平台上跑起来比编辑器里要谨慎一些。Unity打包Android时默认会带INTERNET权限,但如果你在Project Settings里做过裁剪,记得确认权限还在。
另一个坑是明文流量。Android 9开始,系统默认禁止应用发送明文HTTP请求。DeepSeek API本身是HTTPS,所以正常调用不受影响。但如果你自建代理测试时图省事用了http://,就会被系统直接拒绝,报错提示通常类似"Cleartext HTTP traffic not permitted"。这时候要么给代理配HTTPS证书,要么在AndroidManifest.xml里临时加android:usesCleartextTraffic="true"。我的建议是开发期可以用后者,正式版一定用HTTPS。
5.4 超时与错误处理:网络异常不能直接崩
游戏发布后会碰到各种网络状况,断网、弱网、代理超时都可能出现。我给请求设置了request.timeout = 30,超过30秒还没返回就视为失败。
错误处理要区分几种情况:网络层错误(request.result == UnityWebRequest.Result.ConnectionError)、HTTP错误码(比如401表示Key无效、429表示触发限流)、JSON解析异常。我在回调里统一走onError,并把原始响应体打出来。
有一个细节容易被忽略:onError里如果直接弹窗或者刷新UI,要考虑当前场景是否已经被卸载。比如玩家在请求发出后立刻切换了场景,协程恢复时可能发现UI对象已经销毁了。我在回调里加了if (this == null) return;和if (_dialogueUI == null) return;双重保护。
6. 从"能对话"到"像个角色":进阶体验优化
6.1 打字机效果:让AI回复更有"对话感"
从网络请求到拿到完整回复,直接用text.text = reply显示出来,虽然能用但很僵硬。像NPC说话一样逐字显示,体验会好很多。
实现一个简单的打字机效果:
csharp复制public IEnumerator TypewriterEffect(TextMeshProUGUI label, string content, float interval = 0.03f)
{
label.text = "";
foreach (char c in content)
{
label.text += c;
yield return new WaitForSeconds(interval);
}
}
再配合一个"跳过"功能,玩家点击对话区域时立即把剩余文本全部显示出来,体验更友好。注意打字机运行时要把IsWaiting锁继续持有,防止玩家在打字期间又发新请求。
6.2 流式输出(Stream)需要做的额外工作
前面所有代码用的是非流式响应,也就是等模型生成完整个回复,一次性返回。模型生成几百字可能需要几秒,玩家全程盯着转圈,体验一般。
DeepSeek支持stream: true,这时候接口会返回SSE格式的流,Unity端可以边接收边显示,首字到达时间会显著缩短。但实现起来复杂度上了一个台阶:不能再用DownloadHandlerBuffer一次性拿结果,要用DownloadHandlerScript逐帧接收数据,解析SSE格式里的data:前缀和[DONE]结束标记。
如果你跟我一样想先快速跑通,我建议第一版用非流式,等核心功能稳定后再做流式。原因是流式解析容易出边界问题,比如半包、断行、中文截断,调试成本不低。
6.3 记忆窗口控制:避免Token暴涨
多轮对话如果无脑累积历史,Token消耗会越来越大,费用涨得快,响应也可能变慢。游戏的对话场景,我一般把历史消息控制在20条以内,超过就从最早的非system消息开始丢。
更精细的方法是估算Token:中文场景下,一句话大约等于1.5到2个Token,英文约1个Token等于4个字符。你可以对消息内容做长度累加,超过某个阈值就触发裁剪。不用特别精确,够用就行。
6.4 可以继续扩展的方向
这套链路跑通后,后续扩展空间很大。比如给不同NPC配置不同的system prompt,做出性格各异的角色;把玩家和角色的历史对话存到存档文件里,实现跨会话记忆;接入TTS语音合成,让NPC把文字回复读出来,沉浸感直接上一个台阶;或者在服务端加入敏感词过滤、回答内容审核,保证社区安全。
我在实际项目中还发现一个实用技巧:在sytem提示词里直接写"当玩家问起某个关键道具时,主动提到图书馆二楼的线索",用这种方式引导剧情方向,比纯靠AI自由发挥要可控得多。这让AI对话能兼顾剧情推进和自由互动,算是我这次踩了很多坑之后最想分享的一点经验。
做AI NPC这件事,最大的感受是:技术链路本身并不神秘,一个POST请求加一个JSON解析就够了,真正花时间的反而是那些细节——上下文管理、人设控制、平台适配、异常处理。先把这些基础打牢,后面加什么功能都不慌。
