前言
本文是 SeaPack 项目技术系列的第七篇。前几篇我们搞定了「怎么和大模型聊天」(第五篇)和「怎么让大模型翻书回答」(第六篇),这一篇来解决一个更实际的问题:你每次让 AI 干活,是不是都在重复写差不多的 Prompt? 比如每次分析股票都要手动输入「你是一位专业分析师,请从技术面、基本面、资金面三个维度分析以下股票......」,换个人来问,又要重新打一遍。
提示词模板要解决的就是这件事------把你精心打磨的 Prompt 存下来,下次只需要填几个变量就能一键执行。就像做菜不用每次都从头写菜谱,直接拿出菜谱模板,今天炒个辣椒放进去,明天换成豆豉就行。
访问地址 :http://124.222.194.201/
前端代码 :github.com/seapack-hub...
后端代码 :github.com/seapack-hub...
一、为什么需要模板?聊聊 Prompt 的「复用困境」
你有没有过这样的经历:
花了半小时反复调试,终于写出一个效果很好的 Prompt。然后第二天,同事问你:「哎,昨天那个股票分析的 Prompt 能发我一下吗?」你复制粘贴给他。第三天,另一个同事也要。一周后,你发现团队里有 5 个版本的「股票分析 Prompt」,每个都稍微改了一点点,但谁也说不清楚哪个版本效果最好。
这就是 Prompt 的「复用困境」------好 Prompt 全靠复制粘贴传播,版本失控,质量参差不齐。
提示词模板的思路很朴素:把 Prompt 当成「填空题」来管理。 固定的部分写死在模板里,每次变化的部分做成变量,用的时候填进去就行。
举个例子,一个股票技术分析的 Prompt 可以长这样:
plain
你是一位资深的股票分析师,请对 {{stockCode}}({{stockName}})进行技术面分析。
分析要求:
1. 近 20 个交易日的 K 线形态
2. MACD、KDJ、RSI 等技术指标解读
3. 成交量变化趋势
4. 关键支撑位和压力位
输出格式:{{outputStyle}}
这里的 {{stockCode}}、{{stockName}}、{{outputStyle}} 就是变量。每次用的时候,只需要填上「600519」「贵州茅台」「Markdown 格式」,系统就会自动把占位符替换掉,生成一份完整的 Prompt 发给大模型。
看起来简单对吧?但要把这件事做好,需要解决几个有意思的问题------变量怎么定义才能让用户填得明白?模板怎么存才能被多个模块复用?更妙的是,Agent 怎么从一堆模板里自动选出最合适的那个?
二、整体思路
2.1 模板如何设计
在 SeaPack 里,一个模板不只是「一段 Prompt 文本」。它更像是一个「带表单的 Prompt」------正文里有填空题,每个空都有说明(这个空是股票代码,请输入 6 位数字),有类型(是文本框还是下拉选择),甚至有默认值。
这种设计来自一个很实际的考量:模板不是给程序员用的,是给业务人员用的。 如果变量定义不清晰,业务人员填错格式,生成的 Prompt 就会乱七八糟。
所以我们把变量的所有元信息(名称、标签、类型、是否必填、选项列表)都单独管理起来,前端根据这些信息自动渲染出对应的输入控件------字符串变成输入框,枚举变成下拉菜单,布尔值变成开关。
2.2 模板的两种消费方式
模板做好了,谁来用?
第一种:人直接用。 在模板管理界面,点「调试」,填几个变量值,点「调用 LLM」,马上看到效果。这就像在 IDE 里调试代码一样,改改参数看看输出,直到满意为止。
第二种:Agent 自动用。 Agent 在执行四步流水线的时候,Step 1 就是从一堆关联模板中选出最合适的,拼装进系统提示词。这不是人手动选的,而是让 LLM 根据用户的问题来智能选择------你说「分析茅台的技术面」,LLM 就帮你挑出技术分析模板;你说「帮我写一篇关于人工智能的文章」,LLM 就选内容生成模板。
plain
模板的两条路径:
人用: 打开模板 → 填变量 → 点按钮 → 拿结果(所见即所得)
Agent 用:用户提问 → Agent 思考该用哪个模板 → 自动选中 → 拼进提示词 → 大模型按模板框架回答
三、后端怎么实现的
3.1 几个关键文件,各干各的活
后端这件事拆成了 8 个核心文件,每个文件的职责可以用一句话概括:
| 文件 | 一句话职责 |
|---|---|
PromptTemplate.java |
模板的「身份证」------存名称、正文、分类等基本信息 |
TemplateVariable.java |
变量的「说明书」------告诉前端这个空该怎么填 |
PromptTemplateMapper.xml |
数据库翻译官------定义怎么查、怎么存模板数据 |
TemplateVariableMapper.xml |
变量的数据管家------管理变量的增删改查 |
PromptTemplateService.java |
业务大管家------模板的增删改查、复制、执行全归它管 |
AiExecuteHelper.java |
万能工具箱------变量替换和 LLM 调用这两个活,谁需要谁来借 |
PromptTemplateController.java |
门面------把后端能力翻译成 HTTP 接口给前端调 |
AgentPrompt.java |
Agent 和模板之间的「红娘」------记录哪个 Agent 关联了哪些模板 |
这里有个设计上的小聪明:AiExecuteHelper 是一个纯静态工具类,谁都能用。模板执行要用它(替换变量 + 调 LLM),技能执行也要用它(后面技能篇会讲),不用每个 Service 都写一遍同样的逻辑。这种「公共设施」式的设计,后面会越来越感受到好处。
3.2 数据库:为什么要分两张表?
很多人的第一反应是:一个模板不就是一段文本吗,一张表不就搞定了?确实,如果模板只是存储和展示,一张表就够了。但我们的模板要支持一个很酷的功能:根据变量类型自动渲染不同的输入控件。
这就意味着,每个变量不仅要记住「名字是什么」,还要记住「它是文本框还是下拉菜单」「是不是必填」「有没有默认值」「下拉菜单的选项有哪些」。如果把这些信息都塞进模板正文的注释里(比如 {{stockCode|text|股票代码|必填}}),解析起来会非常痛苦,而且前端在不解析正文的情况下根本不知道该渲染什么控件。
所以,最干净的做法就是变量独立成表 。模板正文保持纯净(只有 {{变量名}}),变量的元信息放在另一张表里,通过 templateId 关联。这样一来:
plain
模板表(ai_prompt_template) 变量表(ai_template_variable)
┌────────────────────────────┐ ┌─────────────────────────────┐
│ 你是一位分析师,请分析 │ │ var_name = "stockCode" │
│ {{stockCode}} {{stockName}}│ │ label = "股票代码" │
│ 的技术面... │ │ var_type = "string" │
│ │ │ required = 1(必填) │
│ code = "stock_tech" │ │ placeholder = "请输入6位代码"│
│ category = "stock" │ └─────────────────────────────┘
└────────────────────────────┘
↑ 两个表通过 template_id 关联
前端的自动检测 :用户在编辑模板正文时写了 {{stockCode}},前端会立刻用正则表达式扫出来,提示「已识别 1 个变量」。用户不用手动去变量表里新建一条记录,只要在正文里写占位符,系统就会自动感知。这种「你写了我就认」的体验,比手动维护两个地方的对应关系舒服多了。
实体代码
模板实体有个值得注意的设计:variables 字段用了 @Transient 注解,意思是「这个字段不对应数据库列」。列表查询时不加载变量(保持轻量),详情查询时通过 MyBatis 嵌套查询自动填充------查模板的同时顺便把关联的变量也查出来:
java
@Entity
@Data
@Table(name = "ai_prompt_template")
public class PromptTemplate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String code; // 唯一编码,用于跨模块引用
@Column(columnDefinition = "TEXT")
private String content; // 模板正文,含 {{变量名}} 占位符
private String category; // 分类:stock_analysis / content_gen / ...
private String description;
private String outputFormat; // markdown/json/text/html
private String version;
private Integer useCount;
private Integer status; // 1启用 0禁用
private Long createdBy;
@Transient // 非数据库字段,联查时填充
private List<TemplateVariable> variables;
}
变量实体也有个有意思的细节:options 字段存的是 JSON,但前端传过来的可能是字符串也可能是对象数组。所以 setOptions() 方法要处理两种格式------不管前端传什么花样,最终都给你序列化成 JSON 字符串:
java
@Entity
@Data
@Table(name = "ai_template_variable")
public class TemplateVariable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long templateId;
private String varName; // 变量名,对应 {{var_name}}
private String label; // 显示标签,如"股票代码"
private String varType; // string/number/boolean/select/date
private Integer required; // 1必填 0选填
private String defaultValue;
@Setter(AccessLevel.NONE)
@Column(columnDefinition = "JSON")
private String options; // select 类型选项 [{label,value}]
private String placeholder;
private Integer sortOrder;
// options 支持 String 和 Object 两种 setter
public void setOptions(Object options) {
if (options == null) {
this.options = null;
} else if (options instanceof String) {
this.options = (String) options;
} else {
this.options = OPTIONS_MAPPER.writeValueAsString(options);
}
}
}
MyBatis 嵌套查询
这里用到了 MyBatis 的 <collection> 嵌套查询,翻译成人话就是:查模板的时候顺便把变量也查了,不用你自己手动发两次 SQL。 执行 selectById(1) 时,MyBatis 会先查模板主表,然后自动拿着模板 ID 去变量表再查一次,把结果塞进 variables 字段:
plain
selectById(1)
│
├── 1. SELECT * FROM ai_prompt_template WHERE id = 1
│ → 拿到模板基础信息
│
└── 2. SELECT * FROM ai_template_variable WHERE template_id = 1 ORDER BY sort_order
→ 拿到变量列表,自动塞进 PromptTemplate.variables
xml
<!-- PromptTemplateMapper.xml -->
<resultMap id="DetailMap" type="PromptTemplate" extends="BaseMap">
<collection property="variables" ofType="TemplateVariable"
column="id"
select="TemplateVariableMapper.selectByTemplateId"/>
</resultMap>
<select id="selectById" resultMap="DetailMap">
SELECT * FROM ai_prompt_template WHERE id = #{id}
</select>
设计要点 :列表查询用 BaseMap(不加载变量,快),详情查询用 DetailMap(加载变量,全)。就像点外卖时「只要套餐」和「套餐+饮料+甜品」的区别------按需加载,不浪费。
前端的自动检测 :用户在编辑模板正文时写了 {{stockCode}},前端会立刻用正则表达式扫出来,提示「已识别 1 个变量」。用户不用手动去变量表里新建一条记录,只要在正文里写占位符,系统就会自动感知。
3.3 模板执行(调试)
模板执行是整个系统的核心------用户填好变量,点一下按钮,几秒钟后拿到大模型的回答。背后发生了什么?
简单说就是三步:找模板 → 填空 → 发给大模型。
plain
用户点「调用 LLM」
│
▼
① 根据 templateId 从数据库找到模板 → 拿到正文和变量定义
│
▼
② 把用户填的值替换进正文
│ 输入: "你是一位分析师,请分析 {{stockCode}} {{stockName}} 的技术面"
│ 参数: {stockCode: "600519", stockName: "贵州茅台"}
│ 输出: "你是一位分析师,请分析 600519 贵州茅台 的技术面"
│
▼
③ 把渲染好的 Prompt 发给大模型(非流式,等它完整回答)
│
▼
④ 拿到结果,连同耗时、Token 消耗一起返回给前端
这个「填空」操作的实现其实很直白------用正则扫描正文中的 {{xxx}},从参数 Map 里找到对应的值替换掉。有个小细节:正则允许写成 {{ stockCode }}(带空格),这样写模板的时候不用太小心翼翼。
java
// AiExecuteHelper.java --- 变量替换
public static String replacePlaceholders(String template, Map<String, Object> params) {
if (template == null || params == null) {
return template;
}
// 正则匹配 {{variable}} 和 {{ variable }}(带空格也行)
Pattern pattern = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
Matcher matcher = pattern.matcher(template);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String key = matcher.group(1); // 比如 "stockCode"
Object value = params.get(key); // 从用户填的值里找
String replacement = value != null ? value.toString() : "";
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(sb);
return sb.toString();
}
有个容易踩的坑 :quoteReplacement 这个调用不能省。如果你的变量值里包含 $ 或 \(比如一段 JSON),没有这个保护的话,正则引擎会把它们当成特殊字符处理,替换结果就乱了。另外 appendTail 确保模板末尾没被匹配到的文本也能保留------少写了这行,你的模板结尾会神秘消失。
替换完占位符后,就该调用 LLM 了。callLLM() 构建的是 OpenAI 兼容格式的请求,把渲染好的 Prompt 作为 system 消息发送(不是 user 消息,因为这是「角色设定 + 任务指令」,不是用户直接说的话):
java
// AiExecuteHelper.java --- LLM 调用
public static AiExecuteResult callLLM(String filledPrompt,
BigDecimal temperature,
Integer maxTokens,
RestTemplate restTemplate,
AIProperties aiProperties) {
long startTime = System.currentTimeMillis();
// 获取当前激活的 AI 提供商配置
String providerName = aiProperties.getActiveProvider();
AIProperties.ProviderConfig config = aiProperties.getProviders().get(providerName);
// 构建 OpenAI 兼容格式请求
String url = config.getBaseUrl().replaceAll("/+$", "") + "/chat/completions";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", config.getChatModel());
requestBody.put("messages", List.of(
Map.of("role", "system", "content", filledPrompt) // 作为 system 消息
));
requestBody.put("stream", false); // 非流式,一次性拿完整结果
// 发送请求
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> apiResponse = restTemplate.postForObject(url, entity, Map.class);
long durationMs = System.currentTimeMillis() - startTime;
// 解析响应,组装结果
AiExecuteResult result = new AiExecuteResult();
result.setRenderedPrompt(filledPrompt);
result.setOutput(output);
result.setTokensPrompt(promptTokens);
result.setTokensCompletion(completionTokens);
result.setDurationMs((int) durationMs);
return result;
}
为什么用「非流式」调用? 第五篇的通用对话用的是流式(streaming),这里却用非流式(stream: false)。原因是模板执行是一个「一次性」操作------用户想看到完整的回答,而不是一行行蹦出来。而且模板执行的场景更像是「提问 → 拿答案」,不像对话场景需要实时交互。
3.4 复制功能
模板管理界面有个「复制」按钮。这个功能的使用场景是这样的:你有一个效果不错的「股票技术分析模板」,想基于它做一个「股票基本面分析模板」,它们 80% 的内容是一样的,只是分析维度不同。这时候复制一下,改改不同之处就行了,不用从头写。
java
// PromptTemplateService.java --- 复制模板
@Transactional
public PromptTemplate copy(Long id) {
// 1. 查询源模板(含变量)
PromptTemplate source = templateMapper.selectById(id);
// 2. 创建副本:名称追加"(副本)",编码追加"_copy"
PromptTemplate copy = new PromptTemplate();
copy.setName(source.getName() + "(副本)");
copy.setCode(source.getCode() + "_copy");
copy.setContent(source.getContent());
// ... 复制其他字段
templateMapper.insert(copy);
// 3. 复制变量定义(清掉 ID,关联到新模板)
if (source.getVariables() != null && !source.getVariables().isEmpty()) {
List<TemplateVariable> copiedVars = source.getVariables().stream()
.map(v -> { v.setId(null); v.setTemplateId(copy.getId()); return v; })
.toList();
variableMapper.batchInsert(copiedVars);
}
return copy;
}
复制的时候,名称会自动加「(副本)」后缀,编码加 _copy,变量也会一起复制过来。就像手机里的「克隆 App」,复制出来的是一个完整的独立副本,改了不影响原来的。
3.5 Agent 怎么自动选模板?(用 AI 管 AI)
这是整个模板系统最有意思的部分。
假设一个 Agent 关联了 5 个模板:股票技术分析、股票基本面分析、内容生成、数据问答、文本润色。
当用户问「帮我分析一下茅台最近的走势」,Agent 应该选哪个?
如果用户又问「帮我写一篇关于人工智能的文章」,又该选哪个?
用硬编码规则? 比如匹配关键词「分析」就选技术分析模板?太脆弱了------「帮我分析一下这篇文章的写作风格」显然不该触发股票分析模板。
我们的做法是:让 LLM 来选。 把所有模板的名称和前 100 个字预览发给 LLM,让它根据用户的问题判断该用哪些模板,返回一个 ID 列表。
java
// AgentTestChatService.java --- selectPromptsByLLM
// 给 LLM 的 Prompt 长这样:
String systemPrompt = "你是一个模板选择器。根据用户消息,从模板列表中选出与用户意图最相关的模板。\n\n" +
"可用模板:\n" + templateListDesc + "\n\n" +
"规则:\n" +
"1. 只返回 JSON 数组,包含选中模板的 ID,如 [1, 3]\n" +
"2. 根据用户意图选择最相关的模板,可以选多个\n" +
"3. 如果用户意图不明确或与所有模板无关,返回所有模板的 ID\n" +
"4. 不要返回任何解释文字、markdown 标记或其他内容\n\n" +
"用户消息:" + userMessage;
// temperature=0,确保每次选择结果稳定
requestBody.put("temperature", 0);
// 解析 LLM 返回的 ID 列表
List<Integer> selectedIds = objectMapper.readValue(content, List.class);
流程如下:
plain
给 LLM 的 Prompt 大致是这样:
你是一个模板选择器。根据用户消息,选出最相关的模板。
可用模板:
[{"id":1, "name":"股票技术分析", "description":"你是一位分析师,请分析..."},
{"id":2, "name":"内容生成", "description":"你是一位专业的文章写手..."},
{"id":3, "name":"文本润色", "description":"请对以下文本进行润色..."}]
规则:只返回 JSON 数组,如 [1, 3]
用户消息:帮我分析一下茅台最近的走势
LLM 返回:[1] ← 选中了股票技术分析模板
这里用了 temperature=0,确保每次选择的结果是稳定的,不会今天选模板 1,明天选模板 3。
如果 LLM 挂了怎么办? 降级。直接把所有模板都加载上,宁可多消耗一点 Token,也不能让功能中断。
这是做 AI 应用的一条铁律:大模型是不可靠的队友,你必须随时准备兜底方案。
完整的组装流程:
plain
Agent 提示词组装流程:
① 先加载 Agent 自己的基础提示词("你是一个股票分析助手......")
↓
② 查出 Agent 关联的所有启用模板
↓
③ 判断模板数量
├── 只有 0~1 个 → 不用选了,直接用
└── 有多个 → 让 LLM 帮忙选(temperature=0)
↓
④ 把选中的模板正文按顺序拼接进系统提示词
↓
⑤ 拼好的完整提示词传给下一步(知识库检索)
四、前端怎么做的
4.1 页面样式
打开模板管理页面,你会看到一个卡片式的列表,每张卡片是渐变色背景 + 图标 + 模板名称 + 描述 + 分类标签。右上角可以切换成表格视图。两种视图共享同一套数据,切换是丝滑的过渡动画。
每张卡片底部有几个小按钮:查看详情、调试(打开预览弹窗)、复制、删除。还有个开关可以直接启用/禁用模板。

4.2 编辑弹窗
点击「新增模板」或「编辑」,弹出一个表单弹窗。上半部分是基本信息(名称、编码、分类、描述),中间是一个大的文本编辑框用来写 Prompt 正文。
关键来了:当你在正文里写下 {{stockCode}},文本框下面会实时提示「已识别 1 个变量」。继续写 {{stockName}},变成「已识别 2 个变量」。然后在下方的「变量管理」表格里,可以为每个变量配置:它是文本框还是下拉菜单?是不是必填?默认值是什么?

这个交互的核心是 Vue 的 computed 属性------每次正文内容变化,正则都会重新扫描,自动检测变量:
typescript
const detectedVars = computed(() => {
const content = form.value.content || ''
const matches = content.match(/\{\{(\w+)\}\}/g) || []
return [...new Set(matches.map(m => m.replace(/\{\{|\}\}/g, '')))]
})
一个贴心的细节 :变量子弹窗(新增/编辑变量的那个小弹窗)里,如果变量类型是 select(下拉选择),会多出一个「选项列表」的编辑区域,让你添加 正式商务 → formal、轻松活泼 → casual 这样的选项对。保存后,这些选项会序列化成 JSON 存到数据库里,预览的时候就会渲染成一个真正的下拉菜单。
4.3 预览弹窗
保存之前,你一定想先看看效果。预览弹窗就是干这个的------左边填变量值,右边实时展示渲染结果,还能直接调用 LLM 看看实际输出。
弹窗打开后,左侧会根据变量定义自动渲染表单:字符串变量变成输入框,数字变量变成数字选择器,布尔变量变成开关,下拉变量变成带选项的下拉菜单。你不需要手动写任何表单代码,变量定义里写了什么类型,前端就渲染什么控件。
底部有两个按钮:「预览渲染」只做变量替换(不调 LLM),「调用 LLM」则会真正发送请求。

调用 LLM 的时候,界面会展示一个三阶段的动画------「连接 AI 服务」→「AI 生成中」→「处理结果」,配合一个实时跳动的计时器。为什么要做这个?因为大模型的响应时间通常在 10~60 秒之间,如果界面什么动静都没有,用户很可能以为系统挂了。这个动画就是一个「安心丸」------告诉你系统在干活,耐心等一下。

结果出来后,底部会展示三个指标:耗时多少毫秒、输入消耗了多少 Token、输出消耗了多少 Token。这些数据对于优化 Prompt 很有用------如果输入 Token 太多,说明 Prompt 太长了,可以精简。

五、变量类型:不只是文本框
变量类型系统是模板好用的关键。不同的变量类型,前端会渲染不同的输入控件,让填表的人不用理解「什么是字符串、什么是枚举」这些概念,只需要看控件就知道该怎么填。
| 类型 | 前端长什么样 | 适合填什么 | 举例 |
|---|---|---|---|
| 字符串 | 普通输入框 | 短文本 | 股票代码「600519」 |
| 数字 | 带加减按钮的数字框 | 数值 | 字数要求「500」 |
| 布尔 | 开关按钮(开/关) | 是或否 | 是否包含图表「开」 |
| 下拉选择 | 展开的下拉菜单 | 从预设中选一个 | 文章风格「正式/活泼/幽默」 |
| 日期 | 日期选择器 | 日期 | 截止日期「2024-01-01」 |
| 多行文本 | 多行文本框 | 长文本 | 文章正文 |
| JSON | 代码编辑器 | 结构化数据 | API 返回的原始数据 |
一个实用建议:如果一个变量的值是固定的几个选项(比如分析维度:技术面/基本面/资金面),强烈建议用「下拉选择」而不是「字符串」。这样用户只能从预设选项里选,不会出现填了「技术」而不是「技术面」导致 Prompt 不通顺的情况。
六、API 接口一览
后端暴露的接口很规整,基本就是标准的 CRUD 加一个执行接口:
模板管理(常规操作):
| 干什么 | 接口 | 方法 |
|---|---|---|
| 分页列表 | /ai/prompt-templates/page/list |
GET |
| 全量列表(下拉用) | /ai/prompt-templates/all |
GET |
| 查看详情(带变量) | /ai/prompt-templates/detail/{id} |
GET |
| 新增 | /ai/prompt-templates/insert |
POST |
| 编辑 | /ai/prompt-templates/update |
POST |
| 删除 | /ai/prompt-templates/delete/{id} |
DELETE |
| 复制 | /ai/prompt-templates/copy/{id} |
POST |
| 启用/禁用 | /ai/prompt-templates/updateStatus/{id} |
PUT |
模板执行(核心操作):
| 干什么 | 接口 | 方法 |
|---|---|---|
| 执行模板 | /ai/prompt-templates/execute |
POST |
执行接口的请求长这样:
json
{
"templateId": 1,
"params": { "stockCode": "600519", "stockName": "贵州茅台" },
"userMessage": "请重点关注近期走势"
}
返回里除了 LLM 的回答(output),还有渲染后的完整 Prompt(renderedPrompt)和 Token 消耗统计。renderedPrompt 很有用------你可以检查变量替换后的 Prompt 是不是你想要的样子,方便调试。
七、从创建到执行:一个模板的一生
把前后端串起来看,一个模板从诞生到被使用,经历了这样的旅程:
plain
创建阶段 使用阶段
────────── ──────────
用户:打开模板管理页 用户:打开预览弹窗
↓ ↓
写 Prompt 正文 填变量值(下拉选、输入框)
↓ ↓
系统:「检测到 3 个变量」 点「调用 LLM」
↓ ↓
配置变量属性 界面:三阶段动画 + 计时器
(类型、标签、是否必填) ↓
↓ 后端:加载模板 → 填空 → 发给大模型
点「保存」 ↓
↓ 拿到回答 + Token 统计
后端:存模板 + 存变量 ↓
↓ 展示结果(渲染Prompt + LLM输出)
刷新列表,看到新模板
或者,在 Agent 的世界里,模板还有另一条路:
plain
用户:「帮我分析一下茅台最近的走势」
↓
Agent:Step 1 - 提示词组装
├── 加载自己的基础提示词
├── 查出关联的 5 个模板
├── 让 LLM 从中选最相关的 → [1](技术分析模板)
├── 拼接:基础提示词 + 技术分析模板正文
└── 完整的 system prompt 传给 Step 2
↓
Agent:Step 2 - 知识库检索(第六篇的内容)
↓
Agent:Step 3 - 技能执行
↓
Agent:Step 4 - 大模型流式回答(第五篇的内容)
八、设计上的一些思考
为什么不用流式调用?
第五篇的通用对话用了流式(streaming),这里模板执行却用非流式。原因很简单:通用对话是「聊天」,需要实时交互;模板执行是「提问拿答案」,更像是一次性的搜索查询。而且模板执行的结果需要一次性展示完整的 Prompt 渲染结果和 Token 统计,流式的话这些信息不好组织。
为什么让 LLM 来选模板?
你可能会问:用关键词匹配不行吗?比如包含「分析」就选分析模板?问题在于关键词太粗了------「分析一下这篇文章的语法错误」和「分析一下茅台的走势」都包含「分析」,但需要的模板完全不同。
让 LLM 来选,它能理解语义。「分析走势」→ 技术分析;「分析语法」→ 文本处理。而且当用户的问题涉及多个领域时(比如「从技术面和基本面两个角度分析茅台」),LLM 可以同时选中两个模板,这是关键词匹配很难做到的。
代价是什么? 多了一次 LLM 调用,多消耗一些 Token,多花几秒钟。但在 Agent 场景下,这次调用的耗时相对于后面的完整回答来说微不足道,换来的是更精准的模板匹配,值得。
八、核心文件速查
后端
| 文件 | 路径 | 干什么 |
|---|---|---|
| PromptTemplate.java | org.seaPack.model.ai |
模板实体 |
| TemplateVariable.java | org.seaPack.model.ai |
变量实体 |
| AgentPrompt.java | org.seaPack.model.ai |
Agent-模板关联表 |
| PromptTemplateMapper.xml | resources/mapper/ai |
模板 SQL(含嵌套查询) |
| TemplateVariableMapper.xml | resources/mapper/ai |
变量 SQL |
| PromptTemplateService.java | org.seaPack.service.ai |
模板业务逻辑 |
| AiExecuteHelper.java | org.seaPack.service.ai |
变量替换 + LLM 调用 |
| PromptTemplateController.java | org.seaPack.controller.ai |
HTTP 接口 |
前端
| 文件 | 路径 | 干什么 |
|---|---|---|
| index.vue | views/aiModule/promptTemplate/ |
主页面(卡片/列表) |
| PromptFormDialog.vue | components/ |
新增/编辑弹窗 |
| PromptPreviewDialog.vue | components/ |
预览/测试弹窗 |
| PromptTemplateCard.vue | components/ |
卡片组件 |
| usePromptTemplate.ts | utils/ |
业务逻辑 Composable |
| promptTemplate.ts | api/ai/ |
API 接口定义 |
其他系列文章:
第六篇:RAG 知识库构建与检索全链路前言 访问地址:http://124.222.194.201/ 前端代码:http - 掘金 (juejin.cn)
第五篇:通用 LLM 流式对话:前后端联接的完整实现前言 访问地址:http://124.222.194.201/ 前 - 掘金 (juejin.cn)
第四篇:AI 模块架构设计:多 Provider 切换、RAG 知识库与 Agent 编排前言 访问地址:http:// - 掘金 (juejin.cn)
第三篇:组件化实践,SpTable 通用表格组件设计前言 访问地址:http://124.222.194.201/ 前端 - 掘金 (juejin.cn)
第二篇:SeaPack 权限体系:从"谁都能看"到"该看什么看什么"写在前面 上一篇聊了项目初始化和工程规范,这篇来聊一 - 掘金 (juejin.cn)
第一篇:SeaPack 全栈项目工程化实践写在前面 这篇文章是 SeaPack 项目技术系列的第一篇。在写代码之前,我想 - 掘金 (juejin.cn)