1. 先想清楚:Godot AI插件到底解决什么问题
做过独立游戏的人大概都有过这种体验:脑子里有一百个想法,但真正卡住你的从来不是创意,而是一堆重复到令人窒息的体力活。剧情分支要写几百句对白,UI要一个个摆节点,配置文件要手写几十份,GDScript里那些模板化的状态机代码复制粘贴到手指发麻。这类工作不难,但极其消耗耐心,而耐心恰恰是一个人做独立项目最稀缺的资源。
Godot AI 插件这个方向之所以这两年被反复提起,核心就在于它把"重复劳动"和"需要判断的工作"切开了。插件做的是前者:把大模型的生成能力嵌进编辑器,让写对白、搭节点树、生成样板脚本这些事从"手工敲"变成"提需求+审结果"。你依然要判断、要改、要验收,但起手的那份草稿不用再从零开始。这篇文章讲的不是某个现成插件怎么点按钮,而是从零搭一套能长期用的插件,包含骨架、通信层、落盘逻辑和三个高频场景的落地方法,适合会写一点 GDScript、想在编辑器里做自动化的中高级开发者,也适合刚接触 Godot 4.x、想搞明白编辑器插件机制的新手照着自己跑一遍。
我先把一个前提说透:这类插件的价值不在"AI很聪明",而在"AI离你的工作流足够近"。你打开浏览器、复制需求、粘贴结果、再手动整理格式回项目里,这一套动作看着只有几十秒,但它打断了你的心流,而且极易产生格式错误。插件真正省下的是这部分摩擦成本。理解了这一点,后面的所有设计取舍就都有依据了——为什么用侧边停靠面板而不是独立窗口,为什么要把结果直接写到 res:// 而不是让你复制粘贴,为什么提示词模板要固化在插件里而不是每次现敲,全都是同一个逻辑。
1.1 三类常见需求与匹配的插件形态
在动手写第一行代码之前,我建议先把自己的需求归类,因为不同类型的需求对应的插件形态差别很大,选错了后面返工成本极高。
| 需求类型 | 典型表现 | 合适的插件形态 | 关键依赖 |
|---|---|---|---|
| 文本生成型 | NPC对白、任务描述、道具文案、本地化润色 | 编辑器侧边面板,输入需求输出文本,一键写入资源 | 文本类接口 + 文本资源读写 |
| 场景构建型 | 按描述批量生成节点树、批量设置属性 | 编辑器面板 + 场景树操作 + 撤销栈集成 | EditorInterface + UndoRedo |
| 代码辅助型 | 生成样板 GDScript、审查现有脚本、补注释 | 面板 + 当前脚本读取 + 差异对比视图 | ScriptEditor 接口 + 文本比对 |
这三种形态在技术实现上有重叠,但重心完全不同。文本生成型最轻,只需要一个输入框、一个请求、一个保存按钮,一两天就能跑通闭环。场景构建型最麻烦,因为一旦你要程序化地修改场景树,就必须考虑撤销、序列化、节点引用失效这一堆问题,稍不注意用户在编辑器里按一下 Ctrl+Z 就崩了。代码辅助型的难点在验收环节,模型给出的代码看着对、跑起来错是常态,所以差异对比和"仅替换选中片段"这种保守策略比"一键应用整份文件"重要得多。
我的建议是:第一次做插件,从文本生成型入手,把请求管线、错误处理、配置管理这三块地基打好,再往上叠场景和代码能力。很多人一上来就想做"全能助手",结果三周过去连一个稳定能用的对白生成都没跑通。
注意:不要试图让插件覆盖所有需求。插件越全能,越容易变成一个你不敢用的黑盒。每增加一个能力,都要问自己"这个功能我一周会用超过三次吗"。
1.2 为什么不直接用网页版AI,而要做进编辑器
这个问题我被问过很多次,答案其实很朴素:上下文。网页版对你项目的了解是零,你得每次都把背景交代一遍,交代完还要担心它记不住。插件不一样,它能直接读到你的项目结构、当前打开的场景、选中的节点、正在编辑的脚本,这些信息构成了天然的高质量上下文,不需要你手动搬运。
举个具体例子。你想给某个关卡批量生成十句环境描述。在网页里,你得描述这是个什么游戏、什么美术风格、什么世界观、这个关卡在哪、有哪些元素,写完这段背景可能比生成出来的十句话还长。而在插件里,这些信息可以由代码自动采集:读取项目的主题配置、读取当前场景根节点的名字和子节点、读取一份你预先写好的风格说明文件,拼成一个提示词模板。你只需要输入"生成十句环境描述",剩下的自动补齐。这个差距不是效率问题,是可用性的分水岭。
另一个被低估的原因是一致性。做内容量大的项目,术语统一、格式统一、语气统一极其重要。插件可以把输出格式强约束成 JSON 或者特定分隔符,直接反序列化进数据结构,省掉格式清洗这一步。网页输出的东西,你还得手动调整缩进、去掉多余的客套话、检查有没有夹带解释性文字。
当然也有代价。插件要维护,接口变了要改,提示词效果差了要调,这些都是持续成本。所以我的判断标准是:如果你这个项目的内容产出量超过几千条对白/配置,或者你每天要在编辑器里待六小时以上,那这个投入绝对值得;如果只是做个两小时的小 Demo,老老实实用网页更快。
2. 技术选型与整体架构拆解
把插件拆开看,它本质上只有四层:编辑器集成层、交互层、通信层、落盘层。这四层怎么切、切多细,决定了你后续改需求时是"改三行"还是"推倒重来"。
编辑器集成层负责把面板挂进编辑器、监听选择变化、拿到当前编辑的场景和脚本。这一层的能力全部来自EditorPlugin和EditorInterface,说白了你就是在跟编辑器对话,问它"现在用户在干什么"。交互层是 UI,输入框、按钮、下拉框、日志区,没什么玄学,但布局细节会直接影响手感。通信层是网络请求、超时、重试、流式解析。落盘层负责把结果变成项目里真实存在的文件或节点。
这个分层最关键的意义是可替换性。通信层抽出来之后,你换服务商、换模型、换接口协议,只改这一层,上面三层完全不动。落盘层抽出来之后,输出从纯文本变成 JSON,也只需要加一个解析器。我在第二个项目上就是因为第一版把请求逻辑直接写在 UI 按钮的回调里,后来换接口协议改了整整两天。
2.1 编辑器插件 vs 运行时插件:两条完全不同的路
这两个概念经常被混为一谈,但它们在技术上属于两个世界,混用会出大问题。
编辑器插件跑在编辑器进程里,用@tool标注,生命周期由_enter_tree和_exit_tree控制。它能访问EditorInterface,能改场景树,能读写项目文件,能力非常强。缺点是它的所有代码都在编辑器的主线程上跑,一旦你写了个阻塞操作,整个编辑器就卡住,严重的话用户会以为崩溃了直接强杀进程。
运行时插件是游戏跑起来才生效的东西,比如对话系统、存档管理器。它不该依赖任何EditorInterface,因为它打包出去之后根本不存在这些类。你如果在一个会被打包进游戏导出的脚本里写了EditorInterface,导出会直接报错。
我的做法是在物理目录上就分开:addons/下面只放编辑器侧的东西,运行时组件放在项目正常的res://systems/或者res://scripts/里,两边通过共享的数据格式(比如 JSON 配置、自定义 Resource)通信,绝不互相 import 对方的脚本。这条纪律看起来多余,但能省下大量"导出才发现报错"的时间。
还有一个实际约束:编辑器插件里的_process也是会跑的。如果你在_process里做轮询、做请求状态检查,务必保证它轻量,最好像我下面 2.2 讲到的那样,只在有活跃请求时才开启处理。
提示:判断一个脚本该放哪,看它需不需要知道"编辑器"这个概念。需要,就放 addons;不需要,就放项目正常目录。这个标准比任何架构文档都简单好用。
2.2 通信层设计:HTTP 轮询、流式响应与超时控制
Godot 里做 HTTP 请求有两条路,很多人只知道第一条。
第一条是HTTPRequest节点。用法极简:new 出来、add_child、连接request_completed信号、调用request()。它的优点是封装好,自动处理连接和重定向,缺点是不支持流式读取——必须等整个响应体接收完毕才会触发一次完成信号。这意味着如果你想做"AI 边生成边打字"的效果,用它就只能等全部返回再一次性显示,长文本场景下用户会盯着空白界面等十几秒,体验很糟。
第二条是HTTPClient的低层接口,配合request_raw和手动read_response_body_chunk(),可以实现真正的流式解析。代价是你得自己管理连接状态、处理分块边界、应对粘包。实现难度显著上升,但如果你做的是对话式界面,这个投入是值得的。
我实际项目里的取舍是:非流式的主路径用HTTPRequest,流式单独写一个StreamClient类。原因很简单,非流式路径占了 80% 的使用场景(批量生成配置、批量生成文本),用成熟封装最省心;流式只用在真正的对话交互上,代码量可控。
超时这一块,HTTPRequest自带timeout属性,单位是秒。默认值偏大,我会显式设成 30 秒。为什么是 30?因为实测下来,多数文本生成请求在 3 到 15 秒之间返回,超过 30 秒基本可以判定为网络异常或者服务端排队严重,继续等下去只会让用户误以为卡死。对于长文本生成(比如一次要几千字),我会单独做一条路径把超时提到 90 秒,并且在 UI 上明确显示"预计需要较长时间"。
重试策略也要想清楚。不是所有失败都值得重试:限流(429)和网络超时值得退避重试,参数错误(400)和鉴权失败(401)重试一百次也没用,只会白白烧掉配额。我用的规则是:只对超时和 5xx 类状态重试,最多两次,间隔按 2 秒、6 秒递增。这个"指数退避"的逻辑很土,但能挡掉绝大多数瞬时抖动。
2.3 密钥与配置的管理方式
这一块是最容易出安全事故的地方,我见过不止一次有人把密钥硬编码在脚本里,然后整个项目推到公开仓库。
硬性底线是:密钥绝对不能出现在会被提交的文件里。具体做法我推荐三层:
第一层,本地不提交的配置文件。在user://目录下放一个ai_config.json,里面存接口地址、密钥、模型名。user://在 Windows 上对应%APPDATA%下的项目目录,在 Linux 上是~/.local/share/,天然不在项目目录内,不会被 git 跟踪。插件启动时读它,读不到就弹出配置面板让用户填。
第二层,环境变量兜底。OS.get_environment()读一个约定好的变量名,适合 CI 场景或者多台机器同步配置。
第三层,EditorSettings里存非敏感项。接口地址、默认模型名、超时时间这些不敏感的东西放这里很合适,但密钥别放——EditorSettings是存在用户全局配置里的,虽然不随项目走,但明文躺在磁盘上,被其他脚本读到的风险更高。
配置读取的代码大概长这样:
const CONFIG_PATH := "user://ai_config.json" static func load_config() -> Dictionary: if not FileAccess.file_exists(CONFIG_PATH): return {} var f := FileAccess.open(CONFIG_PATH, FileAccess.READ) if f == null: return {} var text := f.get_as_text() f.close() var parsed = JSON.parse_string(text) if parsed is Dictionary: return parsed return {} func get_api_key() -> String: var from_env := OS.get_environment("AI_ASSISTANT_KEY") if from_env != "": return from_env var cfg := load_config() return String(cfg.get("api_key", ""))这里有个细节值得说:JSON.parse_string在解析失败时返回null,不会抛异常,所以必须做类型判断。我早期版本直接把它当字典用,配置文件里多一个逗号,插件一启动就报一堆莫名其妙的错,排查了很久才发现根因在配置文件格式。
注意:插件发布给别人用的时候,文档里一定要写清楚配置文件的位置和格式,否则用户第一步就卡住。我第一版发布时忘了说,收到的第一条反馈就是"装完没反应"。
3. 第一步:把插件骨架搭起来
这一步的目标很单纯:让编辑器认识你的插件,能在侧边看到一个面板,开关一次不报错。听起来简单,但这里有三个地方最容易翻车,我一个个说。
Godot 4.x 的插件目录结构是固定的,addons文件夹名不能改,里面每个插件一个子目录,目录里必须有plugin.cfg。这个配置文件是编辑器识别插件的唯一凭据,格式是 INI 风格,字段就那么几个:
[plugin] name="AI Assistant" description="在编辑器内直接调用生成能力,辅助对白、节点树与脚本的产出。" author="某开发者" version="1.0.0" script="ai_plugin.gd"script字段指向的脚本必须直接继承EditorPlugin,而且路径是相对于插件目录的,不是res://绝对路径。这个细节坑过不少人,写成绝对路径编辑器会静默不识别,也不报错,你在插件列表里根本看不到它,只能干瞪眼。
目录建议这样组织:
addons/ai_assistant/ ├── plugin.cfg ├── ai_plugin.gd # EditorPlugin 入口 ├── ui/ │ ├── ai_dock.tscn │ └── ai_dock.gd ├── core/ │ ├── request_client.gd # 通信层 │ ├── stream_client.gd # 流式客户端 │ └── config.gd # 配置读写 └── prompts/ └── dialogue_template.txt把提示词模板单独放一个目录,这个习惯强烈建议养成。模板会频繁改动,混在代码里每次都要翻文件,抽出来之后调整语气、加约束条件方便得多,而且方便做多语言或者多风格版本。
3.1 EditorPlugin 脚本的最小实现
入口脚本要处理的核心只有两件事:进树时挂载 UI,出树时干净卸载。最简版本大概二十行:
@tool extends EditorPlugin const DOCK_SCENE := "res://addons/ai_assistant/ui/ai_dock.tscn" var _dock: Control func _enter_tree() -> void: var packed := load(DOCK_SCENE) if packed == null: push_error("[AI Assistant] 面板场景加载失败") return _dock = packed.instantiate() add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock) func _exit_tree() -> void: if is_instance_valid(_dock): remove_control_from_docks(_dock) _dock.queue_free() _dock = null@tool是必须的,否则这段代码在编辑器里根本不会执行。add_control_to_dock的第一个参数决定面板停靠的位置,DOCK_SLOT_RIGHT_UL是右侧区域的上半部分,我个人偏好这个位置,因为左侧是场景树和文件系统,右边放辅助工具不挡主要视线。
_exit_tree里的is_instance_valid判断不是可有可无的。编辑器在关闭项目、重新加载插件的时候,_exit_tree可能被调用不止一次,或者调用时_dock已经被释放。少了这个判断,你会在输出面板看到一堆 "previously freed instance" 的红色报错,虽然不影响功能,但很影响心情。
还有一种更隐蔽的情况:_enter_tree被调用了两次。这通常发生在你在编辑器里手动点"重新加载插件"之后又切换了一次项目。结果就是右侧出现两个一模一样的面板。防这个的办法是在_enter_tree开头加一句"如果已经有实例就先清理",或者用一个静态标志位拦住。
3.2 面板脚本里必须注意的几个点
面板脚本本身也是@tool的,因为它要在编辑器里跑。这意味着它里面的_ready、_process、信号回调全都会在编辑器进程里执行。这个认知非常关键,直接决定了几件事:
第一,所有await之后的操作都要重新检查节点有效性。你发一个请求,等了五秒返回,这五秒里用户可能已经关掉了面板或者切了场景,await回来的时候self可能已经是个失效实例了。标准写法是if not is_instance_valid(self): return,虽然啰嗦,但能挡掉一大片崩溃。
第二,绝对不要在回调里做长时间的同步循环。哪怕只是遍历一万个元素去拼字符串,也可能让编辑器卡顿零点几秒,用户体验上就是"打字一顿一顿的"。批量操作要拆帧,用await get_tree().process_frame让出主线程。
第三,UI 状态的持久化要想清楚。用户输入了一半的需求文本,切了个场景,面板还在,文本应该还在。这个用EditorSettings.set_project_metadata()或者自己写一个小 json 都行,成本很低但体验差别很大。
面板的信号连接我建议全部用代码连,不要依赖场景文件里存的连接关系。原因是场景文件里的连接一旦目标方法改名,编辑器只会给个警告,运行时才炸,而且@tool脚本炸的时候表现是面板直接不响应,你会以为是插件挂了。
3.3 停用与热重载的坑
插件开发的迭代速度很大程度上取决于你的调试效率。改一行代码要重启编辑器才能生效,这个循环太慢,一天下来能改的东西很有限。
好消息是 Godot 4.x 对@tool脚本的热重载支持已经不错了。改完保存,编辑器会自动重新加载脚本,插件通常不用重启就能看到效果。但有几个例外:plugin.cfg改了必须重启或者手动在插件列表里关掉再打开;新增的文件如果没被扫描到,需要在文件系统面板里刷新一下;类名、class_name的改动有时也不会立即生效。
我的习惯是准备两套调试方式。日常改 UI 逻辑靠热重载,速度很快。涉及插件入口、目录结构、新增依赖的时候,直接重启编辑器,别跟它较劲。重启一次大概十几秒,比在那儿猜"为什么改了没生效"要快得多。
还有一个小技巧:把插件的日志统一走print_rich()加个前缀,比如[AI]。编辑器输出面板内容一多,不加前缀根本找不到自己的日志。同理,错误统一用push_error(),这样在编辑器里会显示成红色,还会在调试器的错误列表里汇总,比print醒目得多。
4. 第二步:接入AI能力——请求管线的实现
骨架有了,接下来是真正干活的部分。请求管线听起来就是个"发请求、收结果",但要做得稳、做得快、做得不出洋相,需要考虑的东西比想象中多。
我把这条管线分成四段:组装请求、发送、解析、落盘。每一段都有独立的失败可能,所以要分开处理,不要写成一个两百行的大函数。这一点在调试时体现得特别明显:出问题时你能立刻定位到是哪一段,而不是靠print一行行试。
组装请求这一段,核心是提示词模板。模板里应该包含三类信息:角色设定(你是一个游戏文案助手)、约束条件(输出格式、字数、语气、禁止项)、动态上下文(项目信息、已有内容、用户输入)。前两类是固定的,放在prompts/目录下的文本文件里;第三类是运行时拼的。
这里有个我踩过的坑值得单独讲:模板里一定要显式要求"只输出结果,不要输出解释"。不写这句话,模型十有八九会在开头加一段"好的,以下是为你生成的十句对白:",结尾再加一段"希望这些内容对你有帮助"。这些废话在聊天窗口里无所谓,但你要把它写进资源文件,就是纯噪音,还得额外写清洗逻辑。与其事后清洗,不如事前约束。
4.1 请求参数与提示词模板的写法
参数这一块,实际用起来需要调的其实就几个:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 温度 | 0.6 ~ 0.8 | 文案生成偏高一点更有变化,配置生成建议压到 0.3 |
| 最大输出长度 | 按场景设 | 批量生成设大一些,单句润色设小一些省时间 |
| 超时 | 30s / 90s | 短文本 30 秒,长文本 90 秒 |
| 重试次数 | 2 | 仅对超时和 5xx 生效 |
温度这个参数很多人喜欢直接拉满,觉得"越有创意越好"。实际做游戏内容时,温度太高会导致同一个 NPC 前后语气不一致,同一批道具描述风格跳脱。做世界观和剧情脑暴可以拉到 0.9,做正式内容我一般压在 0.7 以下。配置类的生成更是要压到 0.3 甚至 0.2,因为它需要的是稳定复现,不是惊喜。
提示词模板我习惯写成带占位符的纯文本,用简单的字符串替换:
你是一名资深游戏文案,正在为一个{genre}项目撰写内容。 风格要求:{style} 字数要求:每条不超过 {max_len} 字 输出格式:严格输出 JSON 数组,每个元素是一个对象,包含 "id" 和 "text" 两个字段。 禁止事项:不要输出任何解释、前言、结语,不要使用 Markdown 代码块包裹。 以下是需要在设定上保持一致的已有内容示例: {examples} 现在请为以下场景生成 {count} 条内容: {user_input}这份模板里我把"禁止输出 Markdown 代码块包裹"也写进去了。原因很实际:现在很多模型默认会把 JSON 用 ```json 包起来,你直接JSON.parse_string会失败。少了这句约束,你就得在解析前写正则剥壳,多一层不必要的脆弱逻辑。
解析这一段要做三件事:剥掉可能的代码块标记、JSON.parse_string、类型校验。三段都要做,缺一不可。类型校验尤其容易被忽略——模型偶尔会把数组返回成对象,或者把数字返回成字符串,你不校验就直接用,报错信息会指向十万八千里之外的地方。
static func parse_json_array(raw: String) -> Array: var text := raw.strip_edges() if text.begins_with("```"): var lines := text.split("\n") lines.remove_at(0) if lines.size() > 0 and lines[lines.size() - 1].strip_edges() == "```": lines.remove_at(lines.size() - 1) text = "\n".join(lines) var parsed = JSON.parse_string(text) if not (parsed is Array): push_error("[AI] 期望数组,实际类型:%s" % typeof(parsed)) return [] return parsed4.2 流式响应与 UI 刷新
如果你做的是对话式界面,流式几乎是必需的。原因不是技术炫技,而是心理预期管理:用户看到文字一个个蹦出来,会觉得"它在工作";盯着一个转圈图标二十秒,会觉得"卡死了"。
用HTTPClient做流式的核心循环大概是这样:
func stream_request(url: String, headers: PackedStringArray, body: String) -> void: var client := HTTPClient.new() var err := client.connect_to_host(url) if err != OK: emit_signal("failed", "连接失败") return while client.get_status() in [HTTPClient.STATUS_RESOLVING, HTTPClient.STATUS_CONNECTING]: client.poll() await get_tree().process_frame var req_err := client.request(HTTPClient.METHOD_POST, "/v1/chat", headers, body) if req_err != OK: emit_signal("failed", "请求构造失败") return while client.get_status() == HTTPClient.STATUS_REQUESTING: client.poll() await get_tree().process_frame while client.get_status() == HTTPClient.STATUS_BODY: client.poll() var chunk := client.read_response_body_chunk() if chunk.size() > 0: emit_signal("chunk_received", chunk.get_string_from_utf8()) await get_tree().process_frame client.close()这段代码有三个必须注意的地方。第一,所有poll()后面都要await get_tree().process_frame,否则就是一个死循环,编辑器直接无响应。第二,read_response_body_chunk返回的是字节不是字符,直接转字符串可能在多字节字符中间切断,中文场景下会出现乱码。稳妥的做法是维护一个字节缓冲区,按完整的行或者完整的事件边界切分后再解码。
第三,也是最重要的一点:流式返回的协议格式各家不同,多数采用"每行一个 JSON 对象"的形式,需要你自己按换行切分、逐行解析、取出增量字段。这部分代码不难但很琐碎,建议单独写一个解析器类,并且用真实返回的样本做单元测试——把样本存成文件,写个脚本逐行喂进去看输出,比在真实网络环境里调试快十倍。
UI 刷新这块有个细节:不要每收到一个 chunk 就重排文本控件。文本控件重排是有成本的,高频刷新会导致界面抖动甚至掉帧。我的做法是用一个短定时器(0.1 秒)批量刷新,积累的增量先拼到缓冲区,定时器触发时一次性更新。
4.3 把结果落到项目里:资源写入与场景生成
这一步决定了插件是"玩具"还是"工具"。停在 UI 里的结果只是文本,写进项目的结果才是资产。
文本类结果落盘最简单,两种方式:写.txt/.json,或者写自定义Resource(.tres)。前者可读性好、方便外部编辑;后者能被preload直接引用、类型安全。我的建议是配置数据用 JSON,需要在游戏里引用的用 Resource。比如对白表用 JSON,因为量大且经常被外部工具处理;而单个 NPC 的对话配置用 Resource,因为它需要在场景里挂载。
写文件必须用FileAccess,且写之前要确保目录存在:
func save_text(rel_path: String, content: String) -> bool: var dir := rel_path.get_base_dir() if not DirAccess.dir_exists_absolute(dir): var err := DirAccess.make_dir_recursive_absolute(dir) if err != OK: push_error("[AI] 创建目录失败:%s" % dir) return false var f := FileAccess.open(rel_path, FileAccess.WRITE) if f == null: push_error("[AI] 写入失败:%s" % FileAccess.get_open_error()) return false f.store_string(content) f.close() return true写完一定要通知编辑器刷新文件系统,否则新文件不会出现在文件面板里,你得手动右键刷新。调用EditorInterface.get_resource_filesystem().scan()即可,但注意这个调用本身有成本,批量写多个文件时应该写完最后一次再调用,不要每写一个调一次。
场景生成是另一回事,复杂度高一个量级。核心接口是EditorInterface.get_edited_scene_root()拿到当前场景根节点,然后用Node.new()或者preload实例化子节点、设置属性、add_child。这里必须接上撤销栈,否则用户一个误操作就没法回退,这在编辑场景时是不可接受的。
Godot 4.x 的做法是通过EditorInterface.get_editor_undo_redo()拿到EditorUndoRedoManager,用create_action/add_do_method/add_undo_method/commit_action这一套。代码写起来比较啰嗦,但这是必须付出的代价——一个不接撤销栈的场景操作插件,用两次就没人敢碰了。
4.4 参数计算:预算、上下文与成本控制
做批量生成之前,必须算清楚预算,否则很容易在跑一个五百条的批量任务时发现上下文超限,或者账单超预期。
上下文预算的估算方法:中文大致按 1 个字约等于 1.5 到 2 个 token 粗算,英文按 4 个字符约等于 1 个 token 粗算。这个换算不精确,但足够用来做容量规划。假设你的上下文上限是 8K token,模板占了 500,示例内容占 1000,那你留给用户输入和输出的空间就是 6500 左右。
这里有个反直觉的结论:批量生成时,单次请求的条数不是越多越好。一次生成 50 条,失败一次全部重来;分成 5 次每次 10 条,失败一次只损失 10 条,而且单次响应时间短,用户能更早看到结果开始审阅。我实测下来,每次 8 到 15 条是比较舒服的区间,再往上错误率和等待时间都不划算。
另外要预留重试预算。你算了 100 次请求的量,实际可能因为限流、超时重试变成 130 次。留 30% 余量是稳妥的。
5. 第三步:三个高频场景的落地
理论讲完,落到具体场景才有意义。这三个是我自己项目里用得最多的,按使用频率排序。
5.1 批量生成NPC对白与本地化文本
这是投入产出比最高的场景。做法是把"角色设定 + 场景上下文 + 已有示例"打包成模板,一次生成一批,输出 JSON,写入对白表。
关键在"已有示例"。一定要从项目里自动采集几条已有的、质量过关的对白作为 few-shot 示例塞进请求,原因很实际:模型对你的项目世界观一无所知,光靠文字描述风格,出来的东西永远差一口气;给它三条真实存在的对白,它能立刻抓到句式长度、用词习惯、标点风格。我做过对比,加示例和不加示例,人工返修率差了三倍以上。
具体实现上,我会在对白资源的目录里扫一遍,随机抽 3 条,拼进模板的{examples}位置。随机而不是固定取前三条,是为了避免每次都喂同样的示例导致输出风格被过度收敛。
批量生成的 UI 要有一个进度条和"中止"按钮。这不是锦上添花,五百条的任务跑到一半发现提示词写错了,没有中止按钮你就只能强杀编辑器。
5.2 场景与节点树的自动化搭建
这个场景的典型用法是"按描述批量建节点"。比如你要做一个设置界面,八个选项分组,每组标题加滑条加数值标签。手点要十几分钟,用插件描述一句"生成八组设置项,每组包含标题、滑条、数值显示",让它把节点树搭出来,再手动微调,两三分钟搞定。
实现上要特别注意节点命名的规范。模型的建议命名往往跟你的项目约定不一致,所以要么在提示词里固定命名规则,要么在代码里做一次重命名。我选后者,代码里统一处理更可控。
另一个必须做的是属性白名单。不要允许模型返回任意属性名然后直接set(),风险太大——它可能设一个影响布局的隐藏属性,把整个界面搞乱。我的做法是维护一个允许设置的属性列表(custom_minimum_size、text、size_flags_horizontal之类),列表外的直接跳过并记一条日志。
5.3 GDScript 生成与代码审查
代码这块我用得最保守,因为它出错的代价最高。我的策略是"生成片段,不生成文件":选中一段代码,让插件在选中范围内做改写或者补全,绝不整份覆盖。
实用度最高的其实是审查,而不是生成。选中一个函数,让插件检查几类固定问题:有没有可能的空引用、有没有在_process里做重活、信号连接是否可能重复、类型标注是否齐全。这类检查规则明确,模型的准确率明显高于凭空写代码。
差异对比视图建议直接做,不要省。两个文本框并排放,左边原文右边建议,加"应用"/"丢弃"两个按钮。看着原始,但比任何花哨的交互都可靠——你能一眼看出它改了哪里,而不是信任它然后跑一遍测试。
6. 常见问题与排查技巧实录
这一节基本都是踩出来的。我按症状整理成一张速查表,遇到问题先对号入座。
| 症状 | 最可能的原因 | 处理方式 |
|---|---|---|
| 插件列表里看不到 | plugin.cfg的 script 路径写成绝对路径,或入口脚本没继承 EditorPlugin | 改成相对插件目录的路径,确认extends EditorPlugin |
| 面板出现两个 | _enter_tree被重复调用 | 入口处检查是否已存在实例,先清理再创建 |
| 编辑器无响应 | 回调里有同步循环或阻塞等待 | 拆帧处理,所有轮询后加await process_frame |
| 请求一直不返回 | 超时没设,或服务端排队 | 显式设 timeout,加中止按钮 |
| 中文输出乱码 | 流式按字节切分后直接转字符串 | 维护字节缓冲,按完整边界切分再解码 |
| JSON 解析失败 | 输出被代码块包裹,或多出前言 | 模板里加禁止项,解析前剥壳 |
| 新文件不显示 | 没触发文件系统扫描 | 批量写完后调用一次 scan |
| 撤销无效 | 场景修改没接撤销栈 | 用 EditorUndoRedoManager 组织操作 |
| 导出后报错 | 运行时脚本引用了 EditorInterface | 编辑器侧和运行时侧物理隔离 |
6.1 几个排查思路比结论更有用
遇到"插件装了没反应"这种问题,第一步永远是看输出面板有没有[AI]前缀的日志。没有日志,说明_enter_tree压根没执行,问题在插件识别阶段;有日志但面板不出现,问题在 UI 加载阶段;面板出现但按钮没反应,问题在信号连接。
这个"用日志定位到阶段"的思路比盲目改代码有效得多。我一开始遇到问题就乱猜,改了半天发现是配置文件路径写错了一个字符。后来养成习惯:在四个阶段(识别、加载、交互、请求)的入口各打一条日志,出问题先看哪一步断了。
还有一个经典问题:面板能打开,点生成按钮毫无反应,也没有报错。这种情况八成是信号连错了对象,或者是await之后的逻辑因为实例失效被静默 return 了。排查办法是在按钮回调的第一行和await之后各打一条日志,看是哪一步断的。
6.2 几条不值得商量的纪律
第一条,密钥永不入项目。前面说过,这里再强调一次。加到.gitignore里还不够,最好在插件启动时主动检查项目目录下有没有配置文件残留,有就警告用户。
第二条,批量操作必须有中止。任何超过 10 次的循环请求,都要能中途停下。用户改动需求比你想象中频繁得多。
第三条,UI 上永远显示当前请求的状态。空闲、请求中、解析中、完成、失败,五个状态至少要有。用户最烦的不是慢,是不知道在干什么。
第四条,不要在主线程做大批量字符串拼接。这条我吃过亏,一次生成两千条对白后统一拼成一个大字符串再写文件,编辑器卡了将近两秒,用户以为死机了。后来改成流式写入,一条一条 append,问题消失。
第五条,保留原始响应。每次请求的原始返回内容都存一份到缓存目录,带上时间戳。调试解析问题时,有这个记录能省下巨量时间,因为你可以离线复现,不用反复请求。
7. 成本控制与后续扩展方向
最后聊点实际的账。这类插件的成本主要是两块:请求费用和时间成本。
费用这块,最关键的不是单价,而是无效请求的比例。我统计过自己第一个版本的使用情况,接近四成的请求是无效的:提示词写错、参数设错、点了两次按钮、生成出来不满意重新生成。把这四成压下去,比换更便宜的模型有效得多。具体做法是加一个"预览提示词"的按钮,让用户在发请求前能看到完整拼装后的提示词,看着不对就别发。这个按钮加上之后,无效请求直接降了一半。
时间成本这块,指的是你审阅输出的时间。插件生成得越快,如果质量不稳定,你花在返修上的时间反而更多。所以别追求一次生成几百条,宁可按小批次跑、边跑边看、及时调模板。我的经验是每批 10 到 15 条,边看边调,第二批次的质量就明显比第一批好。
扩展方向上有几个我觉得值得做,但还没做的:一是把常用的提示词模板存成可命名的预设,不同项目不同场景直接切换;二是加一个"历史记录"面板,能翻出上周生成的某一段重新用;三是做一个简单的质量打分,人工标"好/差",攒够数据后用来筛选模板。第三个听起来最复杂,但其实只需要存个标签,后续靠人工回看就能发现哪种模板效果好,不需要任何模型参与。
还有一个容易忽略的点:插件本身也需要文档。不是给别人看的文档,是给自己看的。我每个插件目录下放一个NOTES.md,记录当前版本哪些功能能用、哪些是半成品、哪些接口变更过。隔两个月回来改,这份笔记能救命。