☰
从零手搓AI游戏:大模型驱动NPC对话与任务生成实战
2026/10/1 5:35:09 网站建设 项目流程

1. 为什么我选择从零手搓一个AI游戏

去年年底我花了大概三周时间,从零开始做了一个能跑起来的AI小游戏,核心玩法是玩家用自然语言跟NPC对话,NPC根据对话内容动态生成任务、线索和剧情走向。整个项目从立项到能玩,代码量不到两千行,但踩的坑比我预想的多得多。这篇文章就是把这整个过程拆开揉碎讲清楚,包括技术选型、架构设计、核心代码、调试技巧,以及那些只有真正动手做过才会知道的细节。

先说清楚这个项目是什么。它是一个网页端的文字冒险游戏,玩家扮演一个进入神秘小镇的旅人,镇上有七八个NPC,每个NPC都有自己的性格设定和知识边界。玩家可以自由输入任何话跟NPC聊天,NPC的回复不是预设的台词树,而是由大模型实时生成的。同时,NPC会根据对话内容判断是否给玩家发布任务,任务目标、奖励、甚至任务本身的剧情都是动态生成的。整个游戏没有固定的通关路线,玩家聊出来的内容就是游戏内容。

这个项目适合谁参考?如果你是一个想入门AI应用开发的程序员,或者是一个想给自己的游戏加上AI能力的独立开发者,再或者你只是好奇大模型到底怎么跟游戏结合,这篇文章应该都能给你一些可以直接抄的东西。我不讲虚的,所有代码逻辑和参数选择都会说清楚为什么这么定。

技术栈方面,我选的是Godot 4做前端渲染和游戏逻辑,Python FastAPI做后端服务,大模型API做对话和任务生成。为什么不用Unity?因为Godot更轻,导出网页版更方便,而且GDScript写起来快,适合快速验证想法。为什么后端用Python?因为AI相关的库生态最全,FastAPI的异步性能也够用。为什么不让前端直接调大模型API?因为API密钥不能暴露在客户端,而且需要在后端做对话历史管理、内容过滤和任务状态机。

注意:大模型API的选择上,我建议优先考虑响应速度快、支持流式输出的服务。游戏场景下玩家等待超过3秒就会觉得卡,流式输出能让首字响应控制在1秒以内,体验会好很多。

整个项目的核心思路其实就一句话:把大模型当成一个能理解上下文、能生成结构化数据的函数来用。不是让它自由发挥写小说,而是通过精心设计的提示词,让它输出JSON格式的任务数据、对话回复和状态变更指令。这样游戏逻辑才能可靠地处理AI的输出。

2. 整体架构设计与核心思路拆解

2.1 前后端分离的架构选择

我一开始想过把所有逻辑都塞进Godot里,用GDScript直接调API。试了半天发现不行,原因有三个:第一,API密钥硬编码在客户端等于公开,任何人抓包就能拿到;第二,对话历史管理需要持久化,客户端做这个很别扭;第三,任务状态机需要跟对话内容联动,放在后端统一管理更清晰。

所以最终架构是这样的:Godot客户端负责渲染UI、处理玩家输入、播放动画和音效;Python后端负责调用大模型、管理对话历史、维护任务状态、做内容安全过滤。两边通过WebSocket通信,因为WebSocket支持双向推送,后端可以主动把NPC的流式回复推给前端,不用前端轮询。

WebSocket的消息格式我定义了一套简单的协议:

{ "type": "player_input", "npc_id": "blacksmith_01", "content": "你这里有没有适合新手的武器?" }

后端处理完返回:

{ "type": "npc_reply", "npc_id": "blacksmith_01", "content": "新手啊?那你看看这把短剑...", "task_triggered": { "task_id": "task_003", "title": "铁匠的考验", "description": "帮铁匠找到三块铁矿石", "reward": "短剑一把" } }

这种结构化输出的好处是前端不用做任何解析,直接按字段渲染就行。任务触发是独立字段,前端可以弹任务面板,跟对话内容解耦。

2.2 大模型在游戏中的三个角色

在这个项目里,大模型其实扮演了三个不同的角色,每个角色的提示词设计思路完全不一样。

第一个角色是对话生成器。玩家说一句话,NPC要生成符合人设的回复。这个角色的提示词重点是人格设定和知识边界。比如铁匠NPC的设定是“粗犷、话少、对武器了如指掌、对魔法一窍不通”,那提示词里就要明确写“如果玩家问魔法相关的问题,你要表示不懂并转移话题”。

第二个角色是任务生成器。当对话达到某个条件时,比如玩家问了三次关于武器的问题,或者提到了某个关键词,后端会触发一次任务生成调用。这个角色的提示词重点是输出格式约束,必须让模型返回严格的JSON,包含任务标题、描述、目标、奖励。

第三个角色是状态判断器。玩家完成任务后回来交任务,模型要判断玩家是否真的完成了。比如任务要求“找到三块铁矿石”,玩家说“我找到了”,模型需要检查对话历史里玩家是否真的去过矿洞、是否真的捡到了矿石。这个角色的提示词重点是逻辑推理和防作弊。

实操心得:三个角色不要用同一个提示词模板,分开写效果差很多。我试过用一个通用提示词让模型自己判断当前该干什么,结果它经常在该生成任务的时候继续闲聊,在该闲聊的时候突然蹦出一个任务。分开之后准确率提升非常明显。

2.3 为什么不用微调

很多人第一反应是“我要微调一个自己的模型”。我一开始也想过,但算了一笔账:微调需要准备至少几千条高质量的对话数据,标注成本高;微调后的模型部署需要GPU服务器,成本高;而且游戏内容会不断迭代,每次改人设都要重新微调,周期太长。

相比之下,提示词工程加少量示例的方案灵活得多。改人设只需要改一段文字,加新NPC只需要加一个配置文件。对于独立开发者和小团队来说,这个方案性价比最高。等游戏上线了、数据攒够了,再考虑微调也不迟。

3. 核心细节解析与实操要点

3.1 NPC人设配置表的设计

每个NPC的人设我放在一个独立的JSON文件里,结构是这样的:

{ "npc_id": "blacksmith_01", "name": "老铁", "role": "铁匠", "personality": "粗犷、话少、直来直去、对武器有狂热的热爱", "knowledge": ["武器锻造", "矿石鉴定", "战斗技巧"], "unknown": ["魔法", "政治", "历史"], "speech_style": "短句为主,偶尔爆粗口但会被过滤,喜欢用打铁做比喻", "task_pool": ["task_003", "task_007"], "greeting": "要打东西?还是随便看看?" }

这个配置表最关键的是unknown字段。如果不明确告诉模型“你不知道魔法”,玩家问“你能附魔吗”的时候,模型很可能会编一个附魔功能出来,导致游戏逻辑混乱。明确知识边界之后,模型会自然地说“附魔?那是法师的事,我只管打铁”。

speech_style字段也很重要。我试过不写这个字段,结果所有NPC说话都一个味儿,像同一个人换了身衣服。加上风格描述之后,铁匠说话短促有力,酒馆老板说话啰嗦热情,守卫说话公事公办,辨识度一下子就上来了。

3.2 对话历史的截断策略

大模型的上下文窗口是有限的,不可能把玩家所有的对话历史都塞进去。我的策略是滑动窗口加摘要:保留最近10轮完整对话,更早的对话用模型生成一段摘要,放在提示词最前面。

具体实现是每5轮对话触发一次摘要生成,把前5轮的对话压缩成两三句话。比如:

玩家之前询问了武器价格,表示自己没钱,铁匠建议他去矿洞找矿石来换。

这样即使玩了几个小时,提示词长度也能控制在合理范围内。摘要的提示词很简单:“用两三句话总结以下对话的关键信息,保留人物、地点、任务相关的内容,忽略寒暄。”

注意:摘要生成也会消耗token,不要每轮都做。我实测每5轮做一次是成本和效果的平衡点。如果游戏节奏很快,可以改成每8轮。

3.3 任务触发的条件判断

任务触发不能全靠模型判断,那样太不稳定。我的做法是规则引擎加模型确认两层判断。

规则引擎负责初筛:玩家跟某个NPC的对话轮数超过阈值、对话中出现了特定关键词、玩家当前没有进行中的任务。这三个条件同时满足时,才调用模型生成任务。

模型确认这一步是让模型判断“当前对话情境下,这个NPC是否适合发布任务”。比如玩家正在跟铁匠聊天气,虽然聊了10轮,但模型会判断“当前不适合发布任务”。只有玩家明确表达了需求,比如“我想找点事做”“有什么需要帮忙的吗”,模型才会生成任务。

这个两层判断把误触发率从30%降到了5%以下。规则引擎的代码很简单:

def should_trigger_task(npc_id, dialogue_history, active_tasks): if active_tasks: return False npc_dialogue_count = count_dialogue_with_npc(npc_id, dialogue_history) if npc_dialogue_count < 5: return False recent_content = get_recent_content(dialogue_history, npc_id, 3) keywords = ["帮忙", "任务", "需要", "做什么", "有事"] if not any(kw in recent_content for kw in keywords): return False return True

3.4 内容安全过滤的实现

游戏里玩家可以自由输入,必须做内容过滤。我的方案是本地关键词过滤加模型审核双保险。

本地过滤用一个敏感词库,命中就直接返回“这个话题我们换个时间再聊”。这个速度很快,能挡住大部分明显违规的内容。

模型审核是在生成回复之前,把玩家输入发给模型做一次判断:“以下内容是否适合在全年龄向游戏里出现?只回答yes或no。”如果返回no,就返回预设的拒绝回复。这一步会增加一次API调用,但为了安全值得。

实操心得:模型审核的提示词要写得非常明确,不要给模型自由发挥的空间。我一开始写的是“判断内容是否合适”,结果模型经常返回“可能不太合适”这种模糊答案。改成“只回答yes或no”之后,判断准确率大幅提升。

4. 实操过程与核心环节实现

4.1 环境搭建与项目初始化

先装Godot 4,官网直接下载,解压就能用,不需要安装。然后建一个空项目,场景结构是这样的:

Main (Node2D) ├── DialogueUI (CanvasLayer) │ ├── NPCPortrait (TextureRect) │ ├── DialogueText (RichTextLabel) │ ├── InputField (LineEdit) │ └── SendButton (Button) ├── TaskPanel (CanvasLayer) │ ├── TaskTitle (Label) │ ├── TaskDescription (RichTextLabel) │ └── TaskReward (Label) └── WebSocketClient (Node)

后端用FastAPI,依赖就三个:fastapi、uvicorn、websockets。大模型SDK用官方的Python包。整个后端代码量大概800行,核心就是WebSocket连接管理和三个提示词模板。

4.2 WebSocket通信的完整实现

Godot端的WebSocket客户端代码:

extends Node var socket = WebSocketPeer.new() var server_url = "ws://127.0.0.1:8000/ws" func _ready(): socket.connect_to_url(server_url) func _process(_delta): socket.poll() var state = socket.get_ready_state() if state == WebSocketPeer.STATE_OPEN: while socket.get_available_packet_count(): var packet = socket.get_packet() var message = packet.get_string_from_utf8() handle_message(message) func send_player_input(npc_id: String, content: String): var data = { "type": "player_input", "npc_id": npc_id, "content": content } socket.send_text(JSON.stringify(data)) func handle_message(message: String): var data = JSON.parse_string(message) match data.type: "npc_reply": update_dialogue_ui(data.content) if data.has("task_triggered"): show_task_panel(data.task_triggered) "error": show_error(data.message)

后端FastAPI的WebSocket处理:

from fastapi import FastAPI, WebSocket import json app = FastAPI() @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() session = GameSession() try: while True: raw = await websocket.receive_text() data = json.loads(raw) if data["type"] == "player_input": reply = await session.handle_input( data["npc_id"], data["content"] ) await websocket.send_text(json.dumps(reply)) except Exception as e: print(f"Connection closed: {e}")

这个结构很清晰,前端只管发玩家输入和收NPC回复,所有复杂逻辑都在后端。

4.3 对话生成提示词的完整模板

这是对话生成器的提示词模板,我直接贴出来:

你正在扮演一个游戏中的NPC,请根据以下设定和对话历史,生成NPC的下一句回复。 NPC设定: - 姓名:{name} - 身份:{role} - 性格:{personality} - 擅长话题:{knowledge} - 不擅长话题:{unknown} - 说话风格:{speech_style} 对话历史摘要: {summary} 最近对话: {recent_dialogue} 玩家刚刚说:{player_input} 要求: 1. 回复必须符合NPC的性格和说话风格 2. 如果玩家问到不擅长的话题,表示不懂并自然转移话题 3. 回复长度控制在50字以内 4. 不要替玩家做决定 5. 不要生成任何任务相关的信息,任务由系统单独处理 NPC回复:

这个模板里每一条要求都是踩坑之后加的。比如第4条“不要替玩家做决定”,是因为模型经常生成“你决定去矿洞看看”这种话,但玩家可能根本不想去。第5条是因为模型有时候会在闲聊里突然说“我有个任务给你”,但任务系统还没触发,导致前端显示混乱。

4.4 任务生成的JSON输出约束

任务生成器的提示词重点是格式约束:

根据以下对话情境,判断是否应该生成一个任务。如果应该,输出JSON格式的任务数据;如果不应该,输出{"task": null}。 对话情境: {context} 任务必须符合以下JSON格式: { "task": { "title": "任务标题,10字以内", "description": "任务描述,50字以内", "objective": "任务目标,明确可验证", "reward": "任务奖励,具体物品或信息", "difficulty": "easy/medium/hard" } } 要求: 1. 任务必须与当前对话内容相关 2. 任务目标必须是可以被验证的,比如"找到某物品""跟某人对话" 3. 奖励要合理,不要给太好的东西 4. 如果当前情境不适合发布任务,返回{"task": null} 输出:

这个提示词的关键是给出明确的JSON schema,并且用{"task": null}作为否定情况的输出。如果不给这个选项,模型会强行编一个任务出来。

4.5 任务完成验证的逻辑

玩家交任务的时候,后端会把任务目标和最近的对话历史一起发给模型:

任务目标:{objective} 玩家最近的对话和行为记录:{recent_history} 请判断玩家是否完成了任务目标。只回答"完成"或"未完成"。 判断标准: 1. 玩家必须明确表示已经完成了目标 2. 对话历史中必须有对应的行为记录 3. 如果玩家只是说"我完成了"但没有行为记录,判定为未完成 回答:

这个逻辑能挡住大部分“口头交任务”的作弊行为。我测试的时候故意说“我找到矿石了”但没去矿洞,模型正确判断为未完成。

5. 常见问题与排查技巧实录

5.1 模型输出格式不稳定的排查

这是最常见的问题。明明提示词里写了要输出JSON,模型有时候就是给你加一段解释文字,或者JSON格式不对。我的解决方案是三层处理:

第一层,提示词里用response_format参数强制JSON模式(如果API支持)。第二层,解析失败时用正则提取JSON部分。第三层,如果还是失败,重试一次,重试时在提示词里加一句“上次输出格式错误,请只输出纯JSON,不要任何其他文字”。

import re import json def parse_model_json(text): try: return json.loads(text) except json.JSONDecodeError: pass match = re.search(r'\{.*\}', text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass return None

实测下来,加了response_format之后,格式错误率从15%降到了2%以下。

5.2 对话重复和遗忘的解决

模型有时候会重复之前说过的话,或者忘记之前发生的事。这个问题主要靠对话历史管理来解决。我的经验是:摘要要写得具体,不要写“玩家和铁匠聊了武器”,要写“玩家询问了短剑价格,铁匠报价50金币,玩家表示太贵”。

另外,在提示词里加一句“不要重复之前已经说过的内容”也有帮助。如果还是重复,可以在生成之后做一个相似度检查,跟最近三轮的NPC回复对比,相似度超过80%就重新生成。

5.3 响应速度优化的几个手段

玩家等待超过3秒就会觉得卡。我做了这几件事来优化:

第一,用流式输出。模型生成一个字就推一个字给前端,首字响应能控制在1秒以内。第二,对话生成和任务判断并行调用。这两个调用互不依赖,可以同时发出去。第三,缓存常见问题的回复。比如“你好”“你是谁”这种高频问题,直接返回缓存,不走模型。

实操心得:流式输出在Godot里处理要注意,WebSocket消息会变得很频繁。我的做法是前端攒够5个字符或者间隔超过100毫秒才更新一次UI,避免频繁重绘导致卡顿。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
NPC回复格式错误提示词约束不够强打印模型原始输出加response_format参数,加重试逻辑
任务不触发规则引擎条件太严打印规则引擎各条件状态放宽关键词匹配,降低轮数阈值
对话重复历史摘要不够具体检查摘要内容摘要要求写具体事件而非概括
响应慢串行调用太多打日志看各步骤耗时并行调用,加缓存,用流式输出
玩家输入被误过滤敏感词库太激进打印命中词调整词库,加白名单
NPC人设崩坏提示词人格描述太模糊检查人设配置加具体的行为示例

5.5 几个只有踩过才知道的坑

第一个坑:不要用模型的默认温度参数。默认温度通常是0.7或1.0,生成的内容太随机。游戏场景下我建议调到0.3到0.5之间,既有变化又不会太离谱。任务生成的时候可以调到0.2,保证格式稳定。

第二个坑:中文提示词里不要用英文标点。我试过在提示词里混用中英文标点,模型有时候会理解偏差。统一用中文标点之后稳定很多。

第三个坑:WebSocket要加心跳。长时间没有消息的时候,有些网络环境会断开连接。我加了一个每30秒发一次ping的机制,断线自动重连。

第四个坑:Godot的RichTextLabel更新文本要用append_text而不是直接赋值。直接赋值会导致滚动位置重置,玩家体验很差。用append_text然后手动滚动到底部。

第五个坑:后端要限制单个玩家的请求频率。不加限制的话,玩家疯狂点发送按钮会把API额度瞬间耗尽。我加了一个简单的令牌桶限流,每秒最多3次请求。

6. 后续扩展方向与个人体会

这个项目做完之后,我最大的体会是:AI游戏的核心不是AI,是游戏设计。模型再强,如果游戏本身不好玩,玩家也不会留下来。AI在这里的作用是让内容更丰富、更个性化,但它替代不了核心玩法的设计。

后续我打算在这几个方向继续扩展:一是加入多NPC之间的互动,让NPC之间也能互相聊天,玩家可以偷听;二是加入物品系统,让模型生成的物品有实际属性;三是做一个简单的战斗系统,用模型来判断战斗结果而不是纯数值计算。

如果你也想做类似的项目,我的建议是从最小的可玩原型开始。不要一上来就设计庞大的世界观和几十个NPC,先做一个NPC、一个任务、一个完整的对话循环,跑通了再往上加。我第一版就只有一个铁匠NPC,能聊天、能发任务、能交任务,总共不到500行代码。那个版本虽然简陋,但验证了核心思路是可行的。

另外,提示词一定要版本管理。我一开始改提示词很随意,改完发现效果变差了想回滚,结果找不到之前的版本。后来用Git管理提示词文件,每次改动都写清楚改了什么、为什么改、效果如何。这个习惯帮我省了很多时间。

最后分享一个提升NPC真实感的小技巧:在提示词里给NPC加一个“当前心情”状态,根据对话内容动态变化。比如玩家态度好,心情就变好,回复更热情;玩家态度差,心情变差,回复更冷淡。这个状态不需要模型判断,用简单的关键词匹配就能实现,但效果非常明显,玩家会觉得NPC真的在跟自己互动。

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

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

立即咨询