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

文章来源声明: 原文作者:烈风逍遥; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687914732575785000; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把 Prompt 复用、变量表单和 Agent 自动选模板串成可落地方案,适合 AI 应用、Agent 编排与提示词治理团队参考,尤其利于业务人员低成本复用优质 Prompt。

前言 --

本文是 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.java`Agent 和模板之间的「红娘」——记录哪个 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 嵌套查询自动填充——查模板的同时顺便把关联的变量也查出来:

<span>@Entity</span>
<span>@Data</span>
<span>@Table(name = "ai_prompt_template")</span>
<span>public</span> <span>class</span> <span>PromptTemplate</span> {
    <span>@Id</span>
    <span>@GeneratedValue(strategy = GenerationType.IDENTITY)</span>
    <span>private</span> Long id;

    <span>private</span> String name;
    <span>private</span> String code;           <span>// 唯一编码,用于跨模块引用</span>

    <span>@Column(columnDefinition = "TEXT")</span>
    <span>private</span> String content;        <span>// 模板正文,含 {{变量名}} 占位符</span>

    <span>private</span> String category;       <span>// 分类:stock_analysis / content_gen / ...</span>
    <span>private</span> String description;
    <span>private</span> String outputFormat;   <span>// markdown/json/text/html</span>
    <span>private</span> String version;
    <span>private</span> Integer useCount;
    <span>private</span> Integer status;        <span>// 1启用 0禁用</span>
    <span>private</span> Long createdBy;

    <span>@Transient</span>                    <span>// 非数据库字段,联查时填充</span>
    <span>private</span> List<TemplateVariable> variables;
}

变量实体也有个有意思的细节:options 字段存的是 JSON,但前端传过来的可能是字符串也可能是对象数组。所以 setOptions() 方法要处理两种格式——不管前端传什么花样,最终都给你序列化成 JSON 字符串:

<span>@Entity</span>
<span>@Data</span>
<span>@Table(name = "ai_template_variable")</span>
<span>public</span> <span>class</span> <span>TemplateVariable</span> {
    <span>@Id</span>
    <span>@GeneratedValue(strategy = GenerationType.IDENTITY)</span>
    <span>private</span> Long id;

    <span>private</span> Long templateId;
    <span>private</span> String varName;        <span>// 变量名,对应 {{var_name}}</span>
    <span>private</span> String label;          <span>// 显示标签,如"股票代码"</span>
    <span>private</span> String varType;        <span>// string/number/boolean/select/date</span>
    <span>private</span> Integer required;      <span>// 1必填 0选填</span>
    <span>private</span> String defaultValue;

    <span>@Setter(AccessLevel.NONE)</span>
    <span>@Column(columnDefinition = "JSON")</span>
    <span>private</span> String options;        <span>// select 类型选项 [{label,value}]</span>

    <span>private</span> String placeholder;
    <span>private</span> Integer sortOrder;

    <span>// options 支持 String 和 Object 两种 setter</span>
    <span>public</span> <span>void</span> <span>setOptions</span><span>(Object options)</span> {
        <span>if</span> (options == <span>null</span>) {
            <span>this</span>.options = <span>null</span>;
        } <span>else</span> <span>if</span> (options <span>instanceof</span> String) {
            <span>this</span>.options = (String) options;
        } <span>else</span> {
            <span>this</span>.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

<span><!-- PromptTemplateMapper.xml --></span>
<span><<span>resultMap</span> <span>id</span>=<span>"DetailMap"</span> <span>type</span>=<span>"PromptTemplate"</span> <span>extends</span>=<span>"BaseMap"</span>></span>
    <span><<span>collection</span> <span>property</span>=<span>"variables"</span> <span>ofType</span>=<span>"TemplateVariable"</span>
                <span>column</span>=<span>"id"</span>
                <span>select</span>=<span>"TemplateVariableMapper.selectByTemplateId"</span>/></span>
<span></<span>resultMap</span>></span>
<span><<span>select</span> <span>id</span>=<span>"selectById"</span> <span>resultMap</span>=<span>"DetailMap"</span>></span>
    SELECT * FROM ai_prompt_template WHERE id = #{id}
<span></<span>select</span>></span>


设计要点:列表查询用 BaseMap(不加载变量,快),详情查询用 DetailMap(加载变量,全)。就像点外卖时「只要套餐」和「套餐+饮料+甜品」的区别——按需加载,不浪费。

前端的自动检测:用户在编辑模板正文时写了 {{stockCode}},前端会立刻用正则表达式扫出来,提示「已识别 1 个变量」。用户不用手动去变量表里新建一条记录,只要在正文里写占位符,系统就会自动感知。

3.3 模板执行(调试)

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

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

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

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

<span>// AiExecuteHelper.java — 变量替换</span>
<span>public</span> <span>static</span> String <span>replacePlaceholders</span><span>(String template, Map<String, Object> params)</span> {
    <span>if</span> (template == <span>null</span> || params == <span>null</span>) {
        <span>return</span> template;
    }
    <span>// 正则匹配 {{variable}} 和 {{ variable }}(带空格也行)</span>
    <span>Pattern</span> <span>pattern</span> <span>=</span> Pattern.compile(<span>"\\{\\{\\s*(\\w+)\\s*}}"</span>);
    <span>Matcher</span> <span>matcher</span> <span>=</span> pattern.matcher(template);

    <span>StringBuffer</span> <span>sb</span> <span>=</span> <span>new</span> <span>StringBuffer</span>();
    <span>while</span> (matcher.find()) {
        <span>String</span> <span>key</span> <span>=</span> matcher.group(<span>1</span>);          <span>// 比如 "stockCode"</span>
        <span>Object</span> <span>value</span> <span>=</span> params.get(key);          <span>// 从用户填的值里找</span>
        <span>String</span> <span>replacement</span> <span>=</span> value != <span>null</span> ? value.toString() : <span>""</span>;
        matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
    }
    matcher.appendTail(sb);
    <span>return</span> sb.toString();
}

有个容易踩的坑quoteReplacement 这个调用不能省。如果你的变量值里包含 $\(比如一段 JSON),没有这个保护的话,正则引擎会把它们当成特殊字符处理,替换结果就乱了。另外 appendTail 确保模板末尾没被匹配到的文本也能保留——少写了这行,你的模板结尾会神秘消失。

替换完占位符后,就该调用 LLM 了。callLLM() 构建的是 OpenAI 兼容格式的请求,把渲染好的 Prompt 作为 system 消息发送(不是 user 消息,因为这是「角色设定 + 任务指令」,不是用户直接说的话):

<span>// AiExecuteHelper.java — LLM 调用</span>
<span>public</span> <span>static</span> AiExecuteResult <span>callLLM</span><span>(String filledPrompt,
                                       BigDecimal temperature,
                                       Integer maxTokens,
                                       RestTemplate restTemplate,
                                       AIProperties aiProperties)</span> {
    <span>long</span> <span>startTime</span> <span>=</span> System.currentTimeMillis();

    <span>// 获取当前激活的 AI 提供商配置</span>
    <span>String</span> <span>providerName</span> <span>=</span> aiProperties.getActiveProvider();
    AIProperties.<span>ProviderConfig</span> <span>config</span> <span>=</span> aiProperties.getProviders().get(providerName);

    <span>// 构建 OpenAI 兼容格式请求</span>
    <span>String</span> <span>url</span> <span>=</span> config.getBaseUrl().replaceAll(<span>"/+$"</span>, <span>""</span>) + <span>"/chat/completions"</span>;
    Map<String, Object> requestBody = <span>new</span> <span>HashMap</span><>();
    requestBody.put(<span>"model"</span>, config.getChatModel());
    requestBody.put(<span>"messages"</span>, List.of(
        Map.of(<span>"role"</span>, <span>"system"</span>, <span>"content"</span>, filledPrompt)  <span>// 作为 system 消息</span>
    ));
    requestBody.put(<span>"stream"</span>, <span>false</span>);  <span>// 非流式,一次性拿完整结果</span>

    <span>// 发送请求</span>
    HttpEntity<Map<String, Object>> entity = <span>new</span> <span>HttpEntity</span><>(requestBody, headers);
    Map<String, Object> apiResponse = restTemplate.postForObject(url, entity, Map.class);
    <span>long</span> <span>durationMs</span> <span>=</span> System.currentTimeMillis() - startTime;

    <span>// 解析响应,组装结果</span>
    <span>AiExecuteResult</span> <span>result</span> <span>=</span> <span>new</span> <span>AiExecuteResult</span>();
    result.setRenderedPrompt(filledPrompt);
    result.setOutput(output);
    result.setTokensPrompt(promptTokens);
    result.setTokensCompletion(completionTokens);
    result.setDurationMs((<span>int</span>) durationMs);
    <span>return</span> result;
}

为什么用「非流式」调用? 第五篇的通用对话用的是流式(streaming),这里却用非流式(stream: false)。原因是模板执行是一个「一次性」操作——用户想看到完整的回答,而不是一行行蹦出来。而且模板执行的场景更像是「提问 → 拿答案」,不像对话场景需要实时交互。

3.4 复制功能

模板管理界面有个「复制」按钮。这个功能的使用场景是这样的:你有一个效果不错的「股票技术分析模板」,想基于它做一个「股票基本面分析模板」,它们 80% 的内容是一样的,只是分析维度不同。这时候复制一下,改改不同之处就行了,不用从头写。

<span>// PromptTemplateService.java — 复制模板</span>
<span>@Transactional</span>
<span>public</span> PromptTemplate <span>copy</span><span>(Long id)</span> {
    <span>// 1. 查询源模板(含变量)</span>
    <span>PromptTemplate</span> <span>source</span> <span>=</span> templateMapper.selectById(id);

    <span>// 2. 创建副本:名称追加"(副本)",编码追加"_copy"</span>
    <span>PromptTemplate</span> <span>copy</span> <span>=</span> <span>new</span> <span>PromptTemplate</span>();
    copy.setName(source.getName() + <span>"(副本)"</span>);
    copy.setCode(source.getCode() + <span>"_copy"</span>);
    copy.setContent(source.getContent());
    <span>// ... 复制其他字段</span>
    templateMapper.insert(copy);

    <span>// 3. 复制变量定义(清掉 ID,关联到新模板)</span>
    <span>if</span> (source.getVariables() != <span>null</span> && !source.getVariables().isEmpty()) {
        List<TemplateVariable> copiedVars = source.getVariables().stream()
            .map(v -> { v.setId(<span>null</span>); v.setTemplateId(copy.getId()); <span>return</span> v; })
            .toList();
        variableMapper.batchInsert(copiedVars);
    }
    <span>return</span> copy;
}

复制的时候,名称会自动加「(副本)」后缀,编码加 _copy,变量也会一起复制过来。就像手机里的「克隆 App」,复制出来的是一个完整的独立副本,改了不影响原来的。

3.5 Agent 怎么自动选模板?(用 AI 管 AI)

这是整个模板系统最有意思的部分。

假设一个 Agent 关联了 5 个模板:股票技术分析、股票基本面分析、内容生成、数据问答、文本润色。

当用户问「帮我分析一下茅台最近的走势」,Agent 应该选哪个?

如果用户又问「帮我写一篇关于人工智能的文章」,又该选哪个?

用硬编码规则? 比如匹配关键词「分析」就选技术分析模板?太脆弱了——「帮我分析一下这篇文章的写作风格」显然不该触发股票分析模板。

我们的做法是:让 LLM 来选。 把所有模板的名称和前 100 个字预览发给 LLM,让它根据用户的问题判断该用哪些模板,返回一个 ID 列表。

<span>// AgentTestChatService.java — selectPromptsByLLM</span>
<span>// 给 LLM 的 Prompt 长这样:</span>
<span>String</span> <span>systemPrompt</span> <span>=</span> <span>"你是一个模板选择器。根据用户消息,从模板列表中选出与用户意图最相关的模板。\n\n"</span> +
    <span>"可用模板:\n"</span> + templateListDesc + <span>"\n\n"</span> +
    <span>"规则:\n"</span> +
    <span>"1. 只返回 JSON 数组,包含选中模板的 ID,如 [1, 3]\n"</span> +
    <span>"2. 根据用户意图选择最相关的模板,可以选多个\n"</span> +
    <span>"3. 如果用户意图不明确或与所有模板无关,返回所有模板的 ID\n"</span> +
    <span>"4. 不要返回任何解释文字、markdown 标记或其他内容\n\n"</span> +
    <span>"用户消息:"</span> + userMessage;

<span>// temperature=0,确保每次选择结果稳定</span>
requestBody.put(<span>"temperature"</span>, <span>0</span>);

<span>// 解析 LLM 返回的 ID 列表</span>
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 属性——每次正文内容变化,正则都会重新扫描,自动检测变量:

<span>const</span> detectedVars = <span>computed</span>(<span>() =></span> {
  <span>const</span> content = form.<span>value</span>.<span>content</span> || <span>''</span>
  <span>const</span> matches = content.<span>match</span>(<span>/\{\{(\w+)\}\}/g</span>) || []
  <span>return</span> [...<span>new</span> <span>Set</span>(matches.<span>map</span>(<span><span>m</span> =></span> m.<span>replace</span>(<span>/\{\{|\}\}/g</span>, <span>''</span>)))]
})

一个贴心的细节:变量子弹窗(新增/编辑变量的那个小弹窗)里,如果变量类型是 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

执行接口的请求长这样:

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

返回里除了 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.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)