调用大模型 API 时,少量场景直接拼接字符串确实够快;但一旦 Prompt 数量上来,变量替换、语言切换和历史版本都会把代码拖进维护泥潭。更稳妥的做法,是把 Prompt 当成工程资产来管理:先用模板约束变量,再把多语言和版本控制收进统一的加载与渲染流程里,这样后续改文案、做 A/B 测试或排查效果波动时,才有清晰抓手。
为什么 Prompt 不能一直靠字符串拼接
先看一个常见写法:
prompt := fmt.Sprintf("请把以下文本翻译成%s:n%s", targetLang, text)
单个调用看起来没问题,但项目里如果出现几十个类似 Prompt,问题会很快暴露出来:
- 变量散落:
targetLang、text这些参数来自哪里,是否完整,靠调用方自己保证。 - 修改成本高:只要想调整一句措辞,就得回代码里全局搜索,测试也要跟着改。
- 多语言扩展困难:Prompt 既要有中文,也要有英文时,逻辑层很容易堆出一串
if-else。 - 版本不可追踪:今天改完有效,明天效果下降,却很难快速定位到底是哪次 Prompt 调整引起的。
本质上,Prompt 已经不只是一个普通字符串,而是会直接影响模型输出质量的“配置代码”。既然它会演进、复用和回滚,就应该像代码一样被组织起来,而不是散落在业务逻辑里。
先把变量替换收进模板系统
用 text/template 代替手写拼接
在 Go 里,这一步不需要额外引入复杂依赖,标准库 text/template 就能完成大部分需求。先定义模板,再传入结构体参数:


const translatePrompt = `请把以下文本翻译成{{.TargetLang}}:
{{.Text}}
翻译要求:保持原文语气`
type TranslateParams struct {
TargetLang string
Text string
}
// 渲染:params 传入结构体,返回填充后的字符串
prompt, _ := RenderPrompt(translatePrompt, params)
这样改完后,Prompt 和逻辑代码开始分层,收益也比较直接:
- 类型更清晰:
TranslateParams把变量定义集中起来,字段名写错更容易在编译期暴露。 - 模板更好维护:以后改措辞,通常只需要改模板内容,不必碰业务流程。
- 复用更自然:同一份模板只要参数不同,就可以在多个请求里重复渲染。
补上默认值和必填校验
仅有模板替换还不够。实际项目里,有些变量允许省略并使用默认值,有些变量缺了就必须立即报错。可以在模板外再包一层约束:

type PromptTemplate struct {
template *template.Template
required []string // 必填字段
defaults map[string]any // 默认值
}
// 创建模板:Question 必填,Role 和 Style 有默认值
tmpl, _ := NewPromptTemplate(
"你是一个{{.Role}},请用{{.Style}}风格回答:n{{.Question}}",
[]string{"Question"},
map[string]any{"Role": "AI助手", "Style": "专业"},
)
prompt, _ := tmpl.Render(map[string]any{"Question": "什么是Go语言?"})
Render 内部可以按固定顺序处理:先检查必填字段,再合并默认值,最后执行模板渲染。这样调用方只传核心参数即可,既减少重复,也让 Prompt 的输入边界更明确。
把重复片段做成嵌套模板
当 Prompt 变长以后,常常会出现重复段落,比如输出格式要求、角色设定、注意事项。这类内容适合抽成公共片段:
const formatInstruction = `输出格式要求:使用 JSON 格式,字段包括:{{.Fields}}`
const mainPrompt = `{{template "format" .}}
任务:{{.Task}}
{{template "format" .}}`
// 渲染时先注册 helper 模板,再解析主模板
借助 {{template "name" .}},一处修改就能同步到所有主模板,避免多个 Prompt 各自维护一份近似内容。对于已经有“固定输出格式”或“统一安全提示”的应用,这一步尤其省事。
多语言 Prompt 怎么组织才不乱
按语言拆目录,比逻辑分支更稳
如果产品本身支持中英文,Prompt 往往也要跟着切换。与其把不同语言硬塞进代码分支里,不如直接用目录结构管理:
prompts/
├── zh/
│ ├── translate.yaml
│ └── summarize.yaml
└── en/
├── translate.yaml
└── summarize.yaml
每个 YAML 文件只描述一份模板配置,例如:
# prompts/zh/translate.yaml
name: translate
version: "1.0"
template: |
请把以下文本翻译成{{.TargetLang}}:
{{.Text}}
required:
- TargetLang
- Text
defaults:
TargetLang: 英语
# prompts/en/translate.yaml
name: translate
version: "1.0"
template: |
Please translate the following text into {{.TargetLang}}:
{{.Text}}
required:
- TargetLang
- Text
defaults:
TargetLang: Chinese
这种方式的好处,是语言资源和模板本体天然绑定。后续新增日语、法语时,扩展路径也很明确,不会侵入业务代码。
用管理器统一加载和渲染
模板文件拆出来后,下一步是把读取、解析、缓存和渲染收口。一个简单的管理器就够用:
type PromptManager struct {
templates map[string]map[string]*PromptTemplate // lang -> name -> template
baseDir string
}
// 加载:读取 YAML → 解析配置 → 创建 PromptTemplate
func (pm *PromptManager) Load(lang, name string) error { ... }
// 渲染:根据语言和模板名找到模板,调用 Render
func (pm *PromptManager) Render(lang, name string, params map[string]any) (string, error) { ... }
使用时可以先按语言加载,再按名称渲染:
pm := NewPromptManager("./prompts")
pm.Load("zh", "translate")
pm.Load("en", "translate")
// 中文 Prompt
promptZh, _ := pm.Render("zh", "translate", map[string]any{
"TargetLang": "日语",
"Text": "你好,世界",
})
// 英文 Prompt
promptEn, _ := pm.Render("en", "translate", map[string]any{
"TargetLang": "Japanese",
"Text": "Hello, World",
})
这样处理后,业务层只知道“我要渲染某个语言下的某个 Prompt”,而不需要关心 YAML 在哪里、模板如何解析、默认值怎么补齐。
把语言切换放进服务层
真实业务里,当前语言一般来自用户偏好、请求头或上下文。比较合适的做法,是在服务层做一次封装:
type PromptService struct {
manager *PromptManager
}
func (ps *PromptService) Translate(ctx context.Context, targetLang, text string) (string, error) {
lang := GetLangFromContext(ctx) // 从上下文获取语言偏好,默认 "zh"
return ps.manager.Render(lang, "translate", map[string]any{
"TargetLang": targetLang,
"Text": text,
})
}
这样一来,语言决策点被固定在入口附近,后面的业务调用就只关心“翻译什么、翻译到哪种语言”。随着 Prompt 数量增加,这种分层会比四处判断语言更容易维护。
版本化管理:让 Prompt 可比较、可回滚
为什么 Prompt 也要像代码一样做版本控制
Prompt 改一个词,模型输出就可能出现明显变化,所以它本质上就是 AI 应用里的重要行为配置。做版本化,主要是为了解决四类问题:
- 效果对比:同一任务下,需要比较新旧 Prompt 的输出差异。
- 问题排查:线上效果突然变差时,可以快速追到具体版本。
- A/B 测试:同时跑多个 Prompt 版本,观察哪个结果更稳定。
- 合规审计:某些业务需要保留 Prompt 变更历史,说明何时、因何调整。
版本号可以放在文件名,也可以放在配置里
如果追求简单,直接把版本写进文件名即可:
prompts/
├── zh/
│ ├── translate_v1.yaml
│ ├── translate_v2.yaml
│ └── translate_v3.yaml
如果希望一个文件里保留完整演进记录,也可以把版本历史集中写进配置:
# prompts/zh/translate.yaml
current: "3"
versions:
"1":
template: "翻译成{{.TargetLang}}:{{.Text}}"
created_at: "2024-01-01"
"2":
template: "请把以下文本翻译成{{.TargetLang}}:n{{.Text}}"
created_at: "2024-02-01"
"3":
template: "请把以下文本翻译成{{.TargetLang}}:n{{.Text}}n翻译要求:保持原文语气"
created_at: "2024-03-01"
两种方式没有绝对高下。文件名版本更直观,适合小团队快速演进;配置内维护历史更集中,适合希望把“当前版本”和“历史版本”一起管理的场景。
让管理器支持 latest、指定版本和回滚
在已有 PromptManager 的基础上,再加一层版本维度即可:
type VersionedPromptManager struct {
templates map[string]map[string]map[string]*PromptTemplate // lang -> name -> version -> template
}
// 渲染:version 为空或 "latest" 时使用最新版本
func (vpm *VersionedPromptManager) Render(lang, name, version string, params map[string]any) (string, error) {
if version == "" || version == "latest" {
version = vpm.getLatestVersion(lang, name)
}
return vpm.templates[lang][name][version].Render(params)
}
调用时就可以同时覆盖日常发布和实验需求:
// 使用最新版本
prompt, _ := vpm.Render("zh", "translate", "latest", params)
// 使用指定版本(用于 A/B 测试或回滚)
prompt, _ := vpm.Render("zh", "translate", "2", params)
这一步最关键的意义,不只是“能回滚”,而是让 Prompt 变更拥有可追踪的发布面。只要线上日志里记录了模板名和版本号,后续定位问题会省下很多时间。
落地时的取舍:Go 不花哨,但足够稳
很多团队在做 AI 应用时,第一反应是用 Python 处理一切;但如果你的服务本来就跑在 Go 里,Prompt 管理并不一定要额外引入一套重工具链。用 text/template 打底,补上默认值、必填校验、多语言目录和版本管理,已经能解决大多数工程化问题。
它的优势不在于“功能最强”,而在于足够贴近 Go 项目已有的组织方式。对于需要长期维护、逐步演化的 AI 服务来说,Prompt 越早从业务代码里拆出来,后面做迭代、测试和回滚时就越从容。













