一份 system prompt 走天下?我们最后拆成了 23 个

让 LLM 接管游戏决策的早期版本,system prompt 写得相当”全能”——一份大 prompt,把规则、角色、所有可能遇到的场景全塞进去。开发的时候图省事,反正一份配置走遍全场。

跑了一段时间,发现这哥们儿”全能”得有点像班级里那种啥都会一点、啥都不精的同学。

最后的版本拆成了 23 个 prompt 模板。听起来夸张,但真正动手拆完,回头看那个万能 prompt——只能说”它能跑”已经是项目早期最大的功劳了。

万能 prompt 是怎么烂掉的

最开始那份 prompt 大概长这样:

你是一个游戏决策助手。游戏规则是 XXX。当前回合可能处于以下几种状态: - 出牌阶段:…… - 咨询阶段:…… - 课程选择阶段:…… - 奖励选择阶段:…… - 对话阶段:……

请根据当前状态做出合理决策。

这份 prompt 用了一段时间,问题逐渐暴露:

第一,模型注意力被稀释。 当前明明是”奖励选择”阶段,但 prompt 里同时挂着”出牌策略要点”,模型有时候会把出牌的逻辑误用过来——比如在选奖励的时候开始算”体力剩余”,但奖励选择压根不消耗体力。

第二,无关示例污染输出。 prompt 里给”出牌阶段”配了几个示例,但模型在做”对话选择”的时候会被那些示例的格式带跑,输出一堆莫名其妙的字段。

第三,迭代起来灾难性。 你想改”奖励选择”的策略,得在那一大坨 prompt 里翻定位、改完整体测一遍。改一个地方,怕影响另外八个场景。慢慢就不敢动了——典型的”屎山 prompt”。

拆开之后变成什么

游戏的决策本质上是个状态机。每个状态需要的上下文、可选动作、决策规则差别都很大。我们最后按 phase 把 prompt 全拆了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
gameplay/llm/prompts/
├── _common_terms.j2 # 公共词汇表,每个 prompt 都引一下
├── system_default.j2 # 兜底
├── system_consult.j2 # 咨询
├── system_dialogue.j2 # 对话
├── system_exam.j2 # 考试
├── system_lesson.j2 # 课程
├── system_p_drink.j2 # 饮料使用
├── system_schedule.j2 # 日程规划
├── system_skill_reward.j2 # 技能奖励
├── system_item_select.j2 # 道具选择
├── system_insight_generator.j2 # 经验生成
├── system_insight_reviewer.j2 # 经验复盘
├── system_insight_selector.j2 # 经验选择
├── system_session_memory_compactor.j2 # 会话压缩
├── action_select.j2 # 通用动作选择模板
├── insight_phase.j2
├── insight_step.j2
├── insight_review.j2
├── insight_select.j2
├── state_snapshot.j2 # 状态快照
└── ...

23 个 Jinja2 模板。每个只管一件事。

第一反应:这么多模板,维护成本不爆炸?

实际跑下来,维护成本反而降了。因为每个 prompt 只服务一个场景,改它的时候心理负担是零——改坏了顶多影响这一个场景,不会牵连别的。

为什么是 phase,而不是别的拆法

拆 prompt 的方式很多——按角色、按任务、按难度。我们最后选按 phase(也就是游戏当前所处的阶段),是因为:

1. phase 切换是离散事件,可以由代码确定

每个 phase 用哪个 prompt,是上层调度代码根据状态决定的,模型自己不参与”该用哪个 prompt”的判断。这一点很关键——让模型选 prompt 等于让模型给自己安排任务,可控性立马崩。

2. 每个 phase 的输出 schema 不一样

出牌阶段输出”选哪张卡”,对话阶段输出”选哪个选项”,课程选择输出”去哪个教室”。schema 不一样,prompt 自然不一样——硬要塞进同一份 prompt 还得用条件判断在 prompt 里翻译,反而把模型搞晕。

3. 每个 phase 的”重要约束”不重合

出牌阶段要算资源、留 buff,得反复强调”不要浪费集中”。 课程选择阶段要看周次、看角色成长曲线,得强调”别在最后周做没收益的事”。

这些约束塞在一起会互相干扰——出牌的时候被”考虑长期成长”带跑,开始打留长线的牌;选课程的时候被”高效用牌”带跑,开始算单次收益。各管各的反而专注。

公共词汇表怎么处理

拆完之后有个新问题——每个 prompt 都得解释一遍”集中是什么”“体力是什么”“buff 是什么”,重复且容易写歪。

解决办法是抽一个 _common_terms.j2,把游戏里的核心概念定义全放进去:

1
2
3
4
5
6
7
8
9
10
{# _common_terms.j2 #}
## 核心概念
- **集中**:决定输出倍率的资源,每点集中 +20% 输出
- **体力**:每回合行动消耗,归零强制结束
- **buff/debuff**:……

## 输出格式约束
- 所有数值用阿拉伯数字
- 决策理由控制在 50 字以内
- ……

每个具体的 system prompt 第一句就 include 它:

1
2
3
4
5
{% include "_common_terms.j2" %}

## 当前场景:技能奖励选择
你正在为这次育成选择一个技能奖励。
...

改”集中”的定义?改一处,23 份 prompt 全跟着变。这种小重构在万能 prompt 的时代是不敢想的。

几个具体收益

调试不再是猜谜

模型决策错了,能立刻定位是哪个 phase 的 prompt 出问题。日志里把当前调用用的 prompt 名字记下来:

1
2
[exam phase] -> system_exam.j2 + action_select.j2
[reward phase] -> system_skill_reward.j2

出问题打开对应的模板看就行。万能 prompt 时代是这样的:模型出 bug 了,你只能盯着一大坨 prompt 猜哪几行影响了它。

不同 phase 可以用不同模型

这个收益是拆完之后才意识到的——既然 prompt 都拆了,调用的时候完全可以按需要换模型。

简单的对话选择,丢给小模型,速度起飞、成本砍半。 复杂的出牌决策,留给大模型,质量优先。 经验复盘那种容错率高的,甚至可以用本地部署的开源模型。

万能 prompt 时代是不可能这么干的——一份 prompt 适配所有 phase,等于所有 phase 都得用最强模型,浪费严重。

多人协作变得可能

拆开之后,团队里不同人可以并行迭代不同 phase 的 prompt,互不干扰。改 system_skill_reward.j2 的人不需要去看 system_exam.j2,review 的时候 diff 也清爽。

万能 prompt 时代经常出现”你刚改完那段我刚改完这段,merge 完两个都不对了”的情况。

拆 prompt 的几条原则(踩出来的)

写下来给后面的人参考:

一份 prompt 解决一个明确的决策。 如果你发现自己在写 “如果是 A 情况则 X,如果是 B 情况则 Y” 这种分支,大概率就该拆了。

phase 切换在代码里做,不在 prompt 里做。 让模型负责”在这个 phase 下怎么决策”就够了,不要让它判断”现在是哪个 phase”。

公共概念抽到共享模板,但只抽真正稳定的部分。 那些”可能这个场景适用、可能那个场景不适用”的边界规则就别抽了,留在各自 prompt 里更安全。

每个 prompt 都有自己的输出 schema,文档化。 这一条非常重要——schema 写清楚,下游解析才稳。

prompt 文件命名要能从名字看出用途。 system_exam.j2prompt1.j2 强一万倍。看似废话,真有人会图省事用后者。

回头看

把万能 prompt 拆成 23 个之前,每次给模型加新场景都像在做”开颅手术”——动哪儿都怕带坏全身。拆完之后变成”加个新文件就行”,新增成本几乎归零。

这事儿和我们写代码非常像——一开始一个文件搞定,到了一定规模就得按职责拆模块。prompt 也是代码,只不过它是写给模型看的代码,但同样适用单一职责、显式依赖、可测试性这些老规矩。

下次再听到”一份 system prompt 就够了”这种话,可以友善地笑一下。

欢迎关注我的其它发布渠道