Unity集成DeepSeek API:实现AI驱动的智能游戏对话系统
2026/7/22 12:12:58 网站建设 项目流程

1. 项目概述:为什么要在Unity里集成DeepSeek?

最近在捣鼓一个Unity项目,想给游戏里的NPC加点“灵魂”,让它们能跟玩家进行更自然、更有深度的对话。传统的对话树系统虽然稳定,但内容固定,玩几次就腻了。正好看到DeepSeek的API开放了,价格也相当亲民,就琢磨着能不能把它接到Unity里来,让AI来驱动游戏内的对话。

这个想法其实挺直接的:玩家在游戏里输入文本,Unity把文本发给DeepSeek的API,拿到AI生成的回复后,再通过Unity的UI或者语音合成(TTS)播出来。这样一来,每个NPC都能拥有近乎无限的对话可能性,玩家的每一次互动都是独一无二的。这不仅能用在RPG、AVG这类剧情向游戏里,对于教育模拟、虚拟助手甚至是一些创意工具类应用,都有很大的想象空间。

DeepSeek作为国内主流的AI模型服务,其API的稳定性和性价比是吸引我的关键。相比自己从头训练一个对话模型,直接调用成熟API无疑是更快速、更经济的方案。整个接入过程,核心就是处理好Unity(客户端)与DeepSeek API服务端之间的网络通信、数据封装和异步处理。

2. 核心思路与架构设计

要把DeepSeek接入Unity,不能蛮干,得先理清数据是怎么流动的。整个流程可以抽象为一个简单的“请求-响应”循环,但里面有几个关键环节需要仔细设计。

2.1 通信流程拆解

最核心的流程是这样的:

  1. 玩家输入:玩家在游戏内的输入框(UGUI)中输入问题或对话。
  2. Unity客户端封装请求:Unity脚本捕获输入,按照DeepSeek API要求的格式(JSON),组装成一个HTTP POST请求。这个请求体里至少要包含模型名称(如deepseek-chat)、消息列表(包含角色和内容)等参数。
  3. 发起网络请求:Unity使用UnityWebRequest或更现代的UnityWebRequest封装类,将上述JSON数据发送到DeepSeek的API端点(例如https://api.deepseek.com/chat/completions)。
  4. DeepSeek服务器处理:DeepSeek的服务器收到请求,调用其大语言模型进行处理,生成回复文本。
  5. 接收并解析响应:Unity接收到服务器返回的JSON格式响应,从中解析出AI生成的回复内容。
  6. Unity客户端呈现:将解析出的文本显示在游戏UI上,或者送入一个TTS系统转换为语音播放。

这里的关键在于,步骤3到步骤5是网络I/O操作,必然是异步的。在Unity的主线程里进行长时间的阻塞等待会导致游戏卡顿,所以必须使用协程(Coroutine)或者基于Task的异步编程模式来处理。

2.2 模块化设计

为了让代码清晰、易维护,最好进行模块化设计。我通常会规划这么几个核心脚本:

  • DeepSeekAPIManager(单例类):这是大脑。负责管理API密钥(安全地)、配置请求参数(如模型、温度、最大token数)、提供统一的调用接口。它内部封装了构建请求、发送请求、处理响应的具体逻辑。
  • DialogueUIHandler:这是脸面。负责管理游戏内的对话UI,包括输入框、发送按钮、显示回复的文本框(或滚动视图)。它监听玩家输入,调用DeepSeekAPIManager的接口,并将返回的结果更新到UI上。
  • Data Models(数据模型类):这是骨架。定义与DeepSeek API交互所需的数据结构,例如ChatMessage类(包含rolecontent属性)、ChatRequest类和ChatResponse类。使用[System.Serializable]特性让它们能被Unity的JsonUtility序列化和反序列化。
  • TTSManager(可选):如果需要有语音输出,可以增加这个模块。它接收文本,调用本地或在线的语音合成服务,播放音频。

这种设计做到了关注点分离:管理器管通信,UI处理器管交互,数据模型管格式。以后要换UI风格或者调整API参数,只需要改动对应的模块,不会牵一发而动全身。

注意:API密钥安全。绝对不要将你的DeepSeek API密钥硬编码在脚本里然后上传到Git等公共仓库。推荐的做法是:在Unity Editor中使用ScriptableObject创建一个配置文件,在构建项目时不包含此文件;或者通过环境变量、启动参数等方式在运行时传入。对于单机游戏,可以考虑在首次启动时让用户自行输入并本地加密存储。

3. 核心实现细节与代码解析

理论说完了,我们上干货,看看具体代码怎么写。这里我会以最常用的UnityWebRequest配合协程的方式为例。

3.1 定义数据模型

首先,我们需要定义和DeepSeek API对话的数据结构。DeepSeek的Chat Completion API和OpenAI的格式基本兼容,这很方便。

// ChatMessage.cs [System.Serializable] public class ChatMessage { public string role; // “system”, “user”, “assistant” public string content; } // ChatRequest.cs [System.Serializable] public class ChatRequest { public string model = "deepseek-chat"; // 指定模型 public List<ChatMessage> messages; public float temperature = 0.7f; // 创造性,0-2 public int max_tokens = 2048; // 回复最大长度 // 还可以有其他参数如 stream, top_p 等 } // ChatResponse.cs [System.Serializable] public class ChatResponse { public string id; public string @object; public long created; public string model; public List<Choice> choices; public Usage usage; [System.Serializable] public class Choice { public int index; public ChatMessage message; public string finish_reason; } [System.Serializable] public class Usage { public int prompt_tokens; public int completion_tokens; public int total_tokens; } }

定义这些可序列化类后,我们就可以用JsonUtility轻松地在对象和JSON字符串之间转换。

3.2 构建API管理器

这是核心中的核心。我们将创建一个单例管理器来负责所有与DeepSeek的通信。

// DeepSeekAPIManager.cs using UnityEngine; using UnityEngine.Networking; using System.Collections.Generic; using System.Text; using System; public class DeepSeekAPIManager : MonoBehaviour { public static DeepSeekAPIManager Instance { get; private set; } [Header("API 配置")] [SerializeField] private string apiKey = "YOUR_API_KEY_HERE"; // 警告:勿提交! [SerializeField] private string apiUrl = "https://api.deepseek.com/chat/completions"; [SerializeField] private string modelName = "deepseek-chat"; [Header("请求参数")] [SerializeField] private float temperature = 0.7f; [SerializeField] private int maxTokens = 1024; private List<ChatMessage> conversationHistory = new List<ChatMessage>(); void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } // 可以在这里添加System Prompt,设定AI角色 conversationHistory.Add(new ChatMessage { role = "system", content = "你是一个乐于助人且知识渊博的游戏内助手。" }); } // 主要的对话调用方法 public void SendChatMessage(string userInput, Action<string> onSuccess, Action<string> onError) { // 将用户输入加入历史 conversationHistory.Add(new ChatMessage { role = "user", content = userInput }); // 构建请求 ChatRequest request = new ChatRequest { model = modelName, messages = conversationHistory, temperature = temperature, max_tokens = maxTokens }; string jsonData = JsonUtility.ToJson(request); StartCoroutine(PostRequest(jsonData, onSuccess, onError)); } private IEnumerator PostRequest(string json, Action<string> onSuccess, Action<string> onError) { using (UnityWebRequest request = new UnityWebRequest(apiUrl, "POST")) { byte[] bodyRaw = Encoding.UTF8.GetBytes(json); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + apiKey); // 关键:添加认证头 yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { string responseJson = request.downloadHandler.text; ChatResponse response = JsonUtility.FromJson<ChatResponse>(responseJson); if (response.choices != null && response.choices.Count > 0) { string aiReply = response.choices[0].message.content; // 将AI回复加入历史 conversationHistory.Add(new ChatMessage { role = "assistant", content = aiReply }); onSuccess?.Invoke(aiReply); } else { onError?.Invoke("API响应格式异常,未获取到有效回复。"); } } else { string errorMsg = $"网络请求失败: {request.error}\n响应码: {request.responseCode}"; Debug.LogError(errorMsg); // 尝试解析错误信息(如果API返回了JSON错误) if (!string.IsNullOrEmpty(request.downloadHandler?.text)) { try { // 这里可以定义一个ErrorResponse类来解析,此处简单处理 errorMsg += $"\n详情: {request.downloadHandler.text}"; } catch { } } onError?.Invoke(errorMsg); } } } // 清空对话历史(除了system message) public void ClearConversationHistory() { ChatMessage systemMsg = conversationHistory.Find(m => m.role == "system"); conversationHistory.Clear(); if (systemMsg != null) { conversationHistory.Add(systemMsg); } Debug.Log("对话历史已清空。"); } }

代码要点解析:

  1. 单例模式:确保全局只有一个管理器实例,方便各处调用。
  2. 序列化字段:将API密钥、URL等配置暴露在Inspector面板,方便调试和切换环境(开发/生产)。但切记最终发布时要处理好密钥安全。
  3. 对话历史:维护一个List<ChatMessage>来保存整个对话上下文。每次发送新消息时,都将整个历史列表发送给API,这样AI才能理解之前的对话内容。System消息用于设定AI的初始角色和行为。
  4. 异步处理:使用StartCoroutine发起网络请求,避免阻塞主线程。通过回调函数Action<string>将成功结果或错误信息传递出去。
  5. 请求头Authorization请求头是认证的关键,格式必须是Bearer YOUR_API_KEY
  6. 错误处理:不仅检查request.result,还尝试解析响应体和状态码,能更精准地定位问题是网络超时、密钥错误还是参数问题。

3.3 创建简单的对话UI

有了管理器,我们需要一个界面来触发对话和显示结果。这里创建一个非常简单的UI。

// DialogueUIController.cs using UnityEngine; using UnityEngine.UI; using TMPro; // 如果使用TextMeshPro public class DialogueUIController : MonoBehaviour { [SerializeField] private TMP_InputField inputField; // 输入框 [SerializeField] private Button sendButton; // 发送按钮 [SerializeField] private TMP_Text replyText; // 显示回复的文本 [SerializeField] private ScrollRect scrollRect; // 用于自动滚动 void Start() { sendButton.onClick.AddListener(OnSendButtonClicked); // 也可以监听输入框的回车键 inputField.onSubmit.AddListener((text) => OnSendButtonClicked()); } void OnSendButtonClicked() { string userMessage = inputField.text.Trim(); if (string.IsNullOrEmpty(userMessage)) { return; } // 禁用按钮和输入框,防止重复发送 SetUIInteractable(false); // 可选:在输入框或某个地方显示“思考中...”的提示 replyText.text += $"\n\n[你]: {userMessage}\n[AI]: 思考中..."; Canvas.ForceUpdateCanvases(); // 强制UI更新 scrollRect.verticalNormalizedPosition = 0f; // 滚动到底部 // 调用API管理器 DeepSeekAPIManager.Instance.SendChatMessage( userMessage, (aiReply) => { // 成功回调 OnReceiveReply(aiReply); }, (error) => { // 失败回调 OnReceiveError(error); } ); inputField.text = ""; // 清空输入框 } void OnReceiveReply(string aiReply) { // 替换掉“思考中...”的文本 string currentText = replyText.text; int lastIndex = currentText.LastIndexOf("[AI]: 思考中..."); if (lastIndex != -1) { currentText = currentText.Substring(0, lastIndex); } replyText.text = currentText + $"[AI]: {aiReply}"; SetUIInteractable(true); // 再次滚动到底部 Canvas.ForceUpdateCanvases(); scrollRect.verticalNormalizedPosition = 0f; } void OnReceiveError(string error) { Debug.LogError(error); string currentText = replyText.text; int lastIndex = currentText.LastIndexOf("[AI]: 思考中..."); if (lastIndex != -1) { currentText = currentText.Substring(0, lastIndex); } replyText.text = currentText + $"[系统]: 请求出错 - {error}"; SetUIInteractable(true); } void SetUIInteractable(bool interactable) { inputField.interactable = interactable; sendButton.interactable = interactable; } }

这个UI控制器做了几件重要的事:处理用户点击、显示状态(“思考中…”)、调用API管理器、并在收到回复或错误后更新UI。ScrollRect的自动滚动保证了最新的对话内容总是可见的。

4. 高级功能与优化实践

基础功能跑通后,我们可以考虑一些增强体验和稳定性的方案。

4.1 实现流式输出

上面的例子是等AI完全生成完所有文本后再一次性返回。对于长回复,用户需要等待较长时间。流式输出(Streaming)可以像打字机一样,让回复一个字一个字地显示出来,体验好很多。

DeepSeek API支持在请求中设置"stream": true。这时,服务器会返回一个Server-Sent Events (SSE)格式的数据流。在Unity中处理SSE需要逐块读取响应。

// 在DeepSeekAPIManager中增加流式请求方法 public void SendChatMessageStream(string userInput, Action<string> onChunkReceived, Action onComplete, Action<string> onError) { conversationHistory.Add(new ChatMessage { role = "user", content = userInput }); ChatRequest request = new ChatRequest { model = modelName, messages = conversationHistory, temperature = temperature, max_tokens = maxTokens, stream = true // 启用流式 }; string jsonData = JsonUtility.ToJson(request); StartCoroutine(PostStreamRequest(jsonData, onChunkReceived, onComplete, onError)); } private IEnumerator PostStreamRequest(string json, Action<string> onChunkReceived, Action onComplete, Action<string> onError) { using (UnityWebRequest request = new UnityWebRequest(apiUrl, "POST")) { byte[] bodyRaw = Encoding.UTF8.GetBytes(json); request.uploadHandler = new UploadHandlerRaw(bodyRaw); // 使用DownloadHandlerScript进行流式处理 var downloadHandler = new DownloadHandlerBuffer(); request.downloadHandler = downloadHandler; request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + apiKey); // 可选:设置超时时间 request.timeout = 30; // 发送请求 var asyncOp = request.SendWebRequest(); StringBuilder fullReply = new StringBuilder(); string accumulatedData = ""; while (!asyncOp.isDone) { // 处理已下载的数据 string newData = downloadHandler.text; if (newData.Length > accumulatedData.Length) { string chunk = newData.Substring(accumulatedData.Length); accumulatedData = newData; // 解析SSE格式:以"data: "开头,以"\n\n"分隔事件 ProcessSSEChunk(chunk, onChunkReceived, ref fullReply); } yield return null; // 每帧检查一次 } if (request.result == UnityWebRequest.Result.Success) { // 处理最后一点数据 ProcessSSEChunk(downloadHandler.text.Substring(accumulatedData.Length), onChunkReceived, ref fullReply); // 将完整回复加入历史 conversationHistory.Add(new ChatMessage { role = "assistant", content = fullReply.ToString() }); onComplete?.Invoke(); } else { onError?.Invoke($"流式请求失败: {request.error}"); } } } private void ProcessSSEChunk(string chunk, Action<string> onChunkReceived, ref StringBuilder fullReply) { // 简化版的SSE解析,实际应用需要更健壮的解析器 string[] lines = chunk.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { if (line.StartsWith("data: ")) { string jsonData = line.Substring(6).Trim(); if (jsonData == "[DONE]") return; // 流结束标志 try { // 这里需要一个简化版的StreamResponse类来解析 var streamResp = JsonUtility.FromJson<StreamResponse>(jsonData); if (streamResp.choices != null && streamResp.choices.Count > 0 && streamResp.choices[0].delta != null) { string contentChunk = streamResp.choices[0].delta.content; if (!string.IsNullOrEmpty(contentChunk)) { fullReply.Append(contentChunk); onChunkReceived?.Invoke(contentChunk); } } } catch (Exception e) { Debug.LogWarning($"解析SSE数据块失败: {e.Message}"); } } } } // 用于解析流式响应delta的简化类 [System.Serializable] public class StreamResponse { public List<StreamChoice> choices; [System.Serializable] public class StreamChoice { public ChatMessage delta; // 注意:流式响应中是delta,不是message public int index; public string finish_reason; } }

在UI控制器中,你需要修改回调,将onChunkReceived连接到UI的增量更新上,而不是一次性替换整个文本。这能极大提升长文本交互的体验。

4.2 上下文长度管理与优化

大语言模型有上下文窗口限制(例如8K、32K tokens)。我们的conversationHistory列表会随着对话进行越来越长,最终可能超过限制,导致API调用失败或模型“遗忘”开头的对话。

常见的上下文管理策略:

  1. 固定窗口滑动:只保留最近N轮对话(例如最近10条消息)。这是最简单的方法。

    private void TrimConversationHistory(int maxRounds) { // 假设一条user和一条assistant为一轮 int totalMessagesToKeep = maxRounds * 2 + 1; // +1 是 system message if (conversationHistory.Count > totalMessagesToKeep) { // 保留第一条(system)和最后N轮 ChatMessage systemMsg = conversationHistory[0]; int startIndex = conversationHistory.Count - totalMessagesToKeep + 1; List<ChatMessage> recentMessages = conversationHistory.GetRange(startIndex, totalMessagesToKeep - 1); conversationHistory.Clear(); conversationHistory.Add(systemMsg); conversationHistory.AddRange(recentMessages); } }

    在每次添加新消息到历史后调用此方法。

  2. 基于Token数的智能截断:更精确,但更复杂。需要估算每条消息的token数(可以近似用字数/字符数乘以一个系数,或使用专门的tokenizer库)。当总token数接近限制时,优先移除中间轮次的对话,保留开头(system指令)和最近的对话。

  3. 总结压缩:当历史过长时,可以调用AI本身,让它对之前的对话历史进行总结,然后用这个总结替换掉大部分旧历史,只保留最近几轮详细对话。这需要额外的API调用和更复杂的逻辑。

对于大多数游戏场景,固定窗口滑动已经足够好用且实现简单。

4.3 网络稳定性与超时处理

移动网络或某些玩家网络环境可能不稳定。我们需要增强代码的健壮性。

  • 设置合理超时UnityWebRequest.timeout属性可以设置请求超时时间(秒)。对于对话接口,设置30-60秒比较合理。
  • 重试机制:对于因网络波动导致的失败(如超时、5xx错误),可以实现简单的重试逻辑。
    private IEnumerator PostRequestWithRetry(string json, Action<string> onSuccess, Action<string> onError, int maxRetries = 2) { int retryCount = 0; while (retryCount <= maxRetries) { // ... 发起请求的代码 ... yield return StartCoroutine(PostRequestCoroutine(json, (successResp) => { onSuccess?.Invoke(successResp); }, (errorResp) => { // 判断是否为可重试的错误(如网络超时、5xx) if (IsRetriableError(errorResp) && retryCount < maxRetries) { retryCount++; Debug.Log($"请求失败,第{retryCount}次重试..."); // 可以加一个指数退避的延迟 yield return new WaitForSeconds(Mathf.Pow(2, retryCount)); // 继续循环 } else { onError?.Invoke($"请求失败,已重试{retryCount}次。最终错误: {errorResp}"); } })); // 如果成功,跳出循环 yield break; } }
  • 离线/降级处理:在完全无法连接到API时,应该有一个降级方案。例如,可以准备一个本地的、简单的关键字回复库,或者直接显示一条友好的错误信息,提示玩家检查网络。

5. 实战踩坑与性能调优

在实际集成过程中,我遇到了不少坑,这里分享出来,希望能帮你省点时间。

5.1 Unity版本与.NET兼容性

  • 坑点:如果你使用的是较旧的Unity版本(如2019-2020),默认的.NET运行时可能是.NET Standard 2.0.NET 4.x的旧配置。一些用于JSON解析(如Newtonsoft.Json)或HTTP客户端的现代库可能无法直接使用。
  • 解决方案
    1. 优先使用Unity自带的UnityWebRequestJsonUtilityJsonUtility虽然功能不如Newtonsoft强大,但对于API通信的序列化/反序列化基本够用,且无需额外导入。
    2. 如果必须使用更复杂的JSON处理(例如流式响应解析),可以考虑导入Newtonsoft.Json for Unity的UPM包或Asset Store版本。
    3. 在Player Settings中,将Api Compatibility Level设置为.NET 4.x.NET Framework(如果可用),以获得更完整的库支持。

5.2 安卓/iOS平台权限

  • 坑点:在移动平台(Android/iOS)上构建后,网络请求失败,错误信息不明显。
  • 解决方案
    • Android:确保AndroidManifest.xml文件中包含了互联网权限。如果你使用的是Unity较新版本,在Player Settings -> Publishing Settings -> Build 中,勾选Custom Main ManifestCustom Main Gradle Template,然后在生成的AndroidManifest.xml中添加<uses-permission android:name="android.permission.INTERNET" />
    • iOS:通常不需要额外配置。但如果你的API URL不是HTTPS,或者使用了自签名证书,需要在Player Settings -> iOS -> Build 中处理ATS(App Transport Security)设置,但这在对接主流云服务时很少遇到。

5.3 异步处理与主线程更新

  • 坑点:在UnityWebRequest的协程回调中,直接尝试修改非UI物体的Transform或调用某些Unity API可能会报错,因为回调可能不在主线程(尽管UnityWebRequest的协程回调通常在主线程,但其他异步库不一定)。
  • 解决方案:养成好习惯,任何需要更新GameObject、UI、或调用Debug.Log的操作,都通过MainThreadDispatcher之类的机制抛回主线程执行。一个简单的模式是使用UnityEngine.Dispatchers(如果版本支持)或在Update中检查队列。
    // 一个简单的示例 public class MainThreadDispatcher : MonoBehaviour { private static MainThreadDispatcher _instance; private Queue<System.Action> _actions = new Queue<System.Action>(); void Awake() { _instance = this; } void Update() { lock (_actions) { while (_actions.Count > 0) { _actions.Dequeue()?.Invoke(); } } } public static void ExecuteOnMainThread(System.Action action) { lock (_instance._actions) { _instance._actions.Enqueue(action); } } } // 在回调中使用 onSuccess = (reply) => { MainThreadDispatcher.ExecuteOnMainThread(() => { // 在这里安全地更新UI replyText.text = reply; }); };

5.4 API调用频率与费用控制

  • 坑点:玩家可能疯狂点击发送按钮,或者在短时间内触发大量对话,导致API调用激增,产生意外费用或触发速率限制。
  • 解决方案
    1. UI防抖:在DialogueUIController的发送按钮点击事件中,发送后立即禁用按钮,直到收到回复或错误后再启用。
    2. 请求队列:在DeepSeekAPIManager中实现一个请求队列。新的请求进来先排队,同一时间只处理一个(或有限个)请求。这对于有对话历史的场景尤其重要,因为并发请求可能导致历史记录错乱。
    3. 本地缓存:对于一些常见的、固定的问题(如“怎么移动?”“这是什么游戏?”),可以先在本地字典中查找答案,直接回复,无需调用API。这既能提升响应速度,也能节省费用。
    4. 设置预算告警:在DeepSeek开放平台后台设置每日/每月使用预算和告警,这是最后一道防线。

5.5 文本处理与注入防范

  • 坑点:直接将未经处理的用户输入发送给AI,或者将AI回复直接显示在UI上,可能存在风险(如Prompt注入攻击、不友好内容、或破坏UI格式的超长文本)。
  • 解决方案
    1. 输入过滤:对用户输入进行基本的清理,比如过滤掉过长的内容、极端字符等。但注意不要过度过滤影响正常表达。
    2. System Prompt强化:在System消息中明确AI的角色和边界。例如:“你是一个友好的游戏向导,拒绝回答与游戏无关的问题,尤其不能生成任何有害、歧视性或违反法律的内容。”
    3. 输出检查:对AI返回的文本进行后处理。例如,检查是否包含明显的敏感词(可搭配简单的关键词过滤列表),或者截断超长的回复(避免刷屏)。
    4. UI文本安全:如果使用UGUI Text或TextMeshPro,注意超长文本可能破坏布局。可以考虑使用ContentSizeFitter或启用文本的“溢出”处理。对于富文本,要警惕AI回复中可能包含的HTML或Markdown标签,最好在显示前进行转义或清理。

集成第三方AI服务到Unity是一个充满乐趣和挑战的过程。从最基础的HTTP请求开始,逐步完善上下文管理、流式输出、错误处理和性能优化,最终能让你的游戏世界变得更加生动和智能。关键在于理解数据流动的每一个环节,并针对游戏这个特定场景做好细节打磨。

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

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

立即咨询