第七篇:提示词模板管理与 Agent 提示词编排

0 阅读22分钟

前言

本文是 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 可以长这样:

你是一位资深的股票分析师,请对 {{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 就选内容生成模板。

模板的两条路径:

人用:  打开模板 → 填变量 → 点按钮 → 拿结果(所见即所得)

Agent 用:用户提问 → Agent 思考该用哪个模板 → 自动选中 → 拼进提示词 → 大模型按模板框架回答

三、后端怎么实现的

3.1 几个关键文件,各干各的活

后端这件事拆成了 8 个核心文件,每个文件的职责可以用一句话概括:

文件一句话职责
PromptTemplate.java模板的「身份证」——存名称、正文、分类等基本信息
TemplateVariable.java变量的「说明书」——告诉前端这个空该怎么填
PromptTemplateMapper.xml数据库翻译官——定义怎么查、怎么存模板数据
TemplateVariableMapper.xml变量的数据管家——管理变量的增删改查
PromptTemplateService.java业务大管家——模板的增删改查、复制、执行全归它管
AiExecuteHelper.java万能工具箱——变量替换和 LLM 调用这两个活,谁需要谁来借
PromptTemplateController.java门面——把后端能力翻译成 HTTP 接口给前端调
AgentPrompt.javaAgent 和模板之间的「红娘」——记录哪个 Agent 关联了哪些模板

这里有个设计上的小聪明:AiExecuteHelper 是一个纯静态工具类,谁都能用。模板执行要用它(替换变量 + 调 LLM),技能执行也要用它(后面技能篇会讲),不用每个 Service 都写一遍同样的逻辑。这种「公共设施」式的设计,后面会越来越感受到好处。

3.2 数据库:为什么要分两张表?

很多人的第一反应是:一个模板不就是一段文本吗,一张表不就搞定了?确实,如果模板只是存储和展示,一张表就够了。但我们的模板要支持一个很酷的功能:根据变量类型自动渲染不同的输入控件。


这就意味着,每个变量不仅要记住「名字是什么」,还要记住「它是文本框还是下拉菜单」「是不是必填」「有没有默认值」「下拉菜单的选项有哪些」。如果把这些信息都塞进模板正文的注释里(比如 {{stockCode|text|股票代码|必填}}),解析起来会非常痛苦,而且前端在不解析正文的情况下根本不知道该渲染什么控件。

所以,最干净的做法就是变量独立成表。模板正文保持纯净(只有 {{变量名}}),变量的元信息放在另一张表里,通过 templateId 关联。这样一来:

模板表(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 嵌套查询自动填充——查模板的同时顺便把关联的变量也查出来:

@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 字符串:

@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 字段:

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
<!-- 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 模板执行(调试)

模板执行是整个系统的核心——用户填好变量,点一下按钮,几秒钟后拿到大模型的回答。背后发生了什么?

简单说就是三步:找模板 → 填空 → 发给大模型。

用户点「调用 LLM」
    │
    ▼
① 根据 templateId 从数据库找到模板 → 拿到正文和变量定义
    │
    ▼
② 把用户填的值替换进正文
    │   输入:  "你是一位分析师,请分析 {{stockCode}} {{stockName}} 的技术面"
    │   参数:  {stockCode: "600519", stockName: "贵州茅台"}
    │   输出:  "你是一位分析师,请分析 600519 贵州茅台 的技术面"
    │
    ▼
③ 把渲染好的 Prompt 发给大模型(非流式,等它完整回答)
    │
    ▼
④ 拿到结果,连同耗时、Token 消耗一起返回给前端

这个「填空」操作的实现其实很直白——用正则扫描正文中的 {{xxx}},从参数 Map 里找到对应的值替换掉。有个小细节:正则允许写成 {{ stockCode }}(带空格),这样写模板的时候不用太小心翼翼。

// 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 消息,因为这是「角色设定 + 任务指令」,不是用户直接说的话):

// 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% 的内容是一样的,只是分析维度不同。这时候复制一下,改改不同之处就行了,不用从头写。

// 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 列表。

// 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);

流程如下:

给 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 应用的一条铁律:大模型是不可靠的队友,你必须随时准备兜底方案。


完整的组装流程:

Agent 提示词组装流程:

① 先加载 Agent 自己的基础提示词("你是一个股票分析助手……")
    ↓
② 查出 Agent 关联的所有启用模板
    ↓
③ 判断模板数量
    ├── 只有 0~1 个 → 不用选了,直接用
    └── 有多个 → 让 LLM 帮忙选(temperature=0)
    ↓
④ 把选中的模板正文按顺序拼接进系统提示词
    ↓
⑤ 拼好的完整提示词传给下一步(知识库检索)

四、前端怎么做的

4.1 页面样式

打开模板管理页面,你会看到一个卡片式的列表,每张卡片是渐变色背景 + 图标 + 模板名称 + 描述 + 分类标签。右上角可以切换成表格视图。两种视图共享同一套数据,切换是丝滑的过渡动画。

每张卡片底部有几个小按钮:查看详情、调试(打开预览弹窗)、复制、删除。还有个开关可以直接启用/禁用模板。

4.2 编辑弹窗

点击「新增模板」或「编辑」,弹出一个表单弹窗。上半部分是基本信息(名称、编码、分类、描述),中间是一个大的文本编辑框用来写 Prompt 正文。

关键来了:当你在正文里写下 {{stockCode}},文本框下面会实时提示「已识别 1 个变量」。继续写 {{stockName}},变成「已识别 2 个变量」。然后在下方的「变量管理」表格里,可以为每个变量配置:它是文本框还是下拉菜单?是不是必填?默认值是什么?

这个交互的核心是 Vue 的 computed 属性——每次正文内容变化,正则都会重新扫描,自动检测变量:

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/listGET
全量列表(下拉用)/ai/prompt-templates/allGET
查看详情(带变量)/ai/prompt-templates/detail/{id}GET
新增/ai/prompt-templates/insertPOST
编辑/ai/prompt-templates/updatePOST
删除/ai/prompt-templates/delete/{id}DELETE
复制/ai/prompt-templates/copy/{id}POST
启用/禁用/ai/prompt-templates/updateStatus/{id}PUT

模板执行(核心操作)

干什么接口方法
执行模板/ai/prompt-templates/executePOST

执行接口的请求长这样:

{
  "templateId": 1,
  "params": { "stockCode": "600519", "stockName": "贵州茅台" },
  "userMessage": "请重点关注近期走势"
}

返回里除了 LLM 的回答(output),还有渲染后的完整 Prompt(renderedPrompt)和 Token 消耗统计。renderedPrompt 很有用——你可以检查变量替换后的 Prompt 是不是你想要的样子,方便调试。

七、从创建到执行:一个模板的一生

把前后端串起来看,一个模板从诞生到被使用,经历了这样的旅程:

                  创建阶段                                    使用阶段
              ──────────                                  ──────────

  用户:打开模板管理页                        用户:打开预览弹窗
    ↓                                          ↓
  写 Prompt 正文                              填变量值(下拉选、输入框)
    ↓                                          ↓
  系统:「检测到 3 个变量」                   点「调用 LLM」
    ↓                                          ↓
  配置变量属性                                界面:三阶段动画 + 计时器
  (类型、标签、是否必填)                      ↓
    ↓                                        后端:加载模板 → 填空 → 发给大模型
  点「保存」                                    ↓
    ↓                                        拿到回答 + Token 统计
  后端:存模板 + 存变量                        ↓
    ↓                                        展示结果(渲染Prompt + LLM输出)
  刷新列表,看到新模板

或者,在 Agent 的世界里,模板还有另一条路:

  用户:「帮我分析一下茅台最近的走势」
    ↓
  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.javaorg.seaPack.model.ai模板实体
TemplateVariable.javaorg.seaPack.model.ai变量实体
AgentPrompt.javaorg.seaPack.model.aiAgent-模板关联表
PromptTemplateMapper.xmlresources/mapper/ai模板 SQL(含嵌套查询)
TemplateVariableMapper.xmlresources/mapper/ai变量 SQL
PromptTemplateService.javaorg.seaPack.service.ai模板业务逻辑
AiExecuteHelper.javaorg.seaPack.service.ai变量替换 + LLM 调用
PromptTemplateController.javaorg.seaPack.controller.aiHTTP 接口

前端

文件路径干什么
index.vueviews/aiModule/promptTemplate/主页面(卡片/列表)
PromptFormDialog.vuecomponents/新增/编辑弹窗
PromptPreviewDialog.vuecomponents/预览/测试弹窗
PromptTemplateCard.vuecomponents/卡片组件
usePromptTemplate.tsutils/业务逻辑 Composable
promptTemplate.tsapi/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)