ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手搓AI游戏:大模型驱动NPC对话与任务生成实战

从零手搓AI游戏:大模型驱动NPC对话与任务生成实战 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 True3.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(fConnection 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真的在跟自己互动。
返回列表