系列「企业级 AI Agent 实现拆解」E33 篇,Part 9 起步篇第三章。E31 的 Agent 能跑了,E32,但 system prompt 是硬编码在
main.go里的字符串。这篇讲怎么用 Eino 的ChatTemplate管理 prompt,从硬编码走向可维护。
读完这篇你会知道
prompt.FromMessages+schema.FString:Eino 的模板 API 怎么用MessagesPlaceholder:怎么把历史对话注入模板- FString vs GoTemplate vs Jinja2:三种插值语法的差别
- 把模板从代码里分离:存文件 vs 存数据库
- 在 Chain 里使用模板:
AppendChatTemplate
硬编码的问题
E31 里的 Agent 是这样的:
go
sr, err := agent.Stream(ctx, []*schema.Message{
{Role: schema.System, Content: "你是一个助手,帮用户回答问题。"},
{Role: schema.User, Content: "北京今天天气怎么样?"},
})
两个问题:
- 不能动态填值。 如果 system prompt 里要填租户名、用户名、今天日期------只能字符串拼接,既丑又容易出 bug。
- 改 prompt 要改代码。 Prompt 调优是高频操作,每次改都要重新编译部署。
Eino 的 ChatTemplate 解决这两个问题。
基本用法
go
import (
"github.com/cloudwego/eino/components/prompt"
"github.com/cloudwego/eino/schema"
)
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage("你是一个{role},使用{style}的语气回答问题。"),
schema.UserMessage("问题:{question}"),
)
FromMessages 接收格式类型(这里是 FString)和若干 MessagesTemplate。每个 schema.SystemMessage、schema.UserMessage 都实现了 MessagesTemplate 接口。
格式化时传变量:
go
messages, err := tpl.Format(ctx, map[string]any{
"role": "程序员助理",
"style": "简洁专业",
"question": "Go 的接口怎么用?",
})
// messages = [
// {Role: system, Content: "你是一个程序员助理,使用简洁专业的语气回答问题。"},
// {Role: user, Content: "问题:Go 的接口怎么用?"},
// ]
Format 返回 []*schema.Message,直接传给 chatModel.Generate 或 agent.Stream。
FString 语法
FString 是 Python str.format() 的 Go 实现(底层用 pyfmt 库):
python
{variable} ← 直接替换
{name!r} ← repr 格式
花括号里只能是变量名,不能写表达式。这是 prompt 模板的合理约束------模板里塞逻辑是反模式。
如果变量名不存在,Format 返回 error,这是设计意图:fail fast,别让 Agent 拿着残缺的 prompt 去调 LLM。
GoTemplate 语法
当你需要条件判断时,换用 schema.GoTemplate:
go
tpl := prompt.FromMessages(schema.GoTemplate,
schema.SystemMessage(`你是{{.role}}。
{{- if .company}}你来自公司:{{.company}}。{{end}}
今天是{{.date}}。`),
)
messages, _ := tpl.Format(ctx, map[string]any{
"role": "助手",
"company": "DeepFlux",
"date": "2026-07-07",
})
GoTemplate 用 Go 标准库的 text/template,支持 {{if}}、{{range}}、{{with}}。变量访问用 .variable 而不是 {variable}。
FString 的适用场景: 简单变量替换,大多数 prompt 够用。 GoTemplate 的适用场景: 需要条件分支(比如有无用户画像时生成不同 prompt)。
MessagesPlaceholder:注入历史对话
多轮对话时,历史消息需要插入模板中间(在 system 之后、新消息之前):
go
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage("你是{role}。"),
schema.MessagesPlaceholder("history", true), // 第二个参数:true = 历史为空时不报错
schema.UserMessage("{question}"),
)
history := []*schema.Message{
{Role: schema.User, Content: "你好"},
{Role: schema.Assistant, Content: "你好!有什么可以帮你?"},
}
messages, _ := tpl.Format(ctx, map[string]any{
"role": "客服助手",
"history": history, // 类型必须是 []*schema.Message
"question": "退款流程是什么?",
})
// messages = [system, user(你好), assistant(你好!...), user(退款流程...)]
MessagesPlaceholder 的第二个参数控制历史消息是否可选:
true:history键不存在时,跳过(第一轮对话没有历史)false:history键不存在时,Format返回 error(强制要求传入)
在 Chain 里用模板
模板可以直接作为 Chain 的一个节点:
go
chain := compose.NewChain[map[string]any, *schema.Message]()
chain.
AppendChatTemplate(prompt.FromMessages(schema.FString,
schema.SystemMessage("你是{role}。"),
schema.UserMessage("{input}"),
)).
AppendChatModel(chatModel)
runner, _ := chain.Compile(ctx)
result, _ := runner.Invoke(ctx, map[string]any{
"role": "翻译官",
"input": "把'hello world'翻译成中文",
})
Chain 的输入类型是 map[string]any,AppendChatTemplate 节点把它格式化成 []*schema.Message,传给下一个 AppendChatModel 节点。
类型流:map[string]any → [ChatTemplate] → []*schema.Message → [ChatModel] → *schema.Message
把模板从代码里分离
硬编码模板的最大问题是:改 prompt 要重新部署。
方案一:存文件(适合小项目)
perl
prompts/
├── assistant.txt ← system prompt 模板
└── customer_service.txt
go
content, _ := os.ReadFile("prompts/assistant.txt")
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage(string(content)),
schema.MessagesPlaceholder("history", true),
schema.UserMessage("{input}"),
)
优点:改 prompt 只改文件,不改代码;文件可以纳入 git 版本管理。
方案二:存数据库(适合多租户 / 多 Agent)
DeepFlux 的做法:prompt 模板存在 agent_configs 表里,带版本号。
go
// 从数据库加载
cfg, _ := agentConfigRepo.Get(ctx, tenantID, configName)
tpl := prompt.FromMessages(schema.FString,
schema.SystemMessage(cfg.SystemPrompt), // 数据库里的模板字符串
schema.MessagesPlaceholder("history", true),
schema.UserMessage("{input}"),
)
这样不同租户、不同 Agent 配置用不同模板,运营人员可以在管理后台直接改 prompt,不需要工程师介入。
模板的边界
两件不要做的事:
不要在模板里塞太多逻辑。 {if user.is_premium}... {end} 这类逻辑放到模板里是错的------它属于业务层,应该在 Go 代码里处理后再填入模板。模板只负责格式化,不负责决策。
不要在模板里硬编码工具描述。 工具的描述由 utils.InferTool 从结构体 tag 自动生成,不应该手动写进 system prompt,否则工具接口一改就要同步改 prompt,是维护噩梦。
小结
prompt.FromMessages 三要素:
- 格式类型 :
FString(简单变量替换)或GoTemplate(需要条件判断) - 消息模板 :
schema.SystemMessage、schema.UserMessage承载带占位符的文本 - 历史占位 :
schema.MessagesPlaceholder把[]*schema.Message插入消息序列
从硬编码 → 模板变量 → 存文件/数据库,三步完成 prompt 的可维护化。模板负责格式化,变量从业务代码填入,两者职责分离。
下一篇是 E34,看"你好世界"背后发生了什么------从 chatModel.Generate() 一路追到 HTTP 请求,看看调用链怎么走。