什么是 Prompt Template?#

Prompt Template 允许开发者定义一个包含占位符 (placeholders) 的结构化字符串。运行时,这些占位符会被实际的数据(如用户输入、数据库内容、应用上下文等)填充,从而生成一个完整、复杂且动态的提示,发送给 LLM 模型。

为什么使用 Prompt Template?#

使用 Prompt Template 有助于:

  • 提高一致性:确保所有生成的提示遵循相同的格式和结构。
  • 简化复杂提示的创建:通过模板化的方式,轻松创建复杂的提示,而无需手动拼接字符串。
  • 增强可维护性:当需要修改提示结构时,只需更新模板,而无需更改所有使用该提示的代码

Prompt Engineering#

提示工程 (Prompt Engineering)有专门的指南, 如https://www.promptingguide.ai/,来帮助用户掌握prompt的设计技巧。

基本上任何AI入门书籍都会提到prompt设计的重要性。一个好的prompt可以显著提升AI生成内容的质量。

Spring AI 中的 Prompt Template#

Prompt Template

可以让用户来输入自己的生日,然后通过 Prompt Template 来生成一个完整的算运势的prompt,并发送给AI模型进行处理。

import java.time.LocalDate;
import java.util.Objects;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class SpringAiFortuneTodayService implements FortuneTodayService {

  private final ChatClient chatClient;

  private static final String TEMPLATE = """
      你是一位专业的星座和生肖运势分析师。请根据以下信息生成今日运势:

      用户姓名:{name}
      出生日期:{birthday}

      请按以下格式输出:

      1. **基本信息**
        - 姓名:{name}
        - 星座:[根据出生日期计算]
        - 生肖:[根据出生年份计算]

      2. **今日综合运势** ( 今天是{today_date} )
        - 整体运势:[1-5颗星,用⭐表示]
        - 运势简评:[一句话概括今日运势]

      3. **详细运势分析**
        - 爱情运势:[50-80字的详细分析]
        - 事业运势:[50-80字的详细分析]
        - 财运:[50-80字的详细分析]
        - 健康运势:[50-80字的详细分析]

      4. **幸运元素**
        - 幸运颜色:
        - 幸运数字:
        - 幸运方位:

      5. **今日建议**
        [给出2-3条针对性的建议]

      请用温暖、积极的语气,让运势分析既专业又富有启发性。
      """;

  public SpringAiFortuneTodayService(ChatClient.Builder chatClientBuilder) {
    this.chatClient = chatClientBuilder.build();
  }

  @Override
  public FortuneTodayResponse getFortuneToday(UserInfo userInfo) {

    var fortuneText = chatClient.prompt()
        .user(userSpec -> userSpec.text(TEMPLATE).param("name", userInfo.fullName())
        .param("birthday", userInfo.birthDate()).param("today_date", Objects.requireNonNull(String.valueOf(LocalDate.now()))))
        .call()
        .content();

    return new FortuneTodayResponse(fortuneText);
  }
}

在上面的代码中,我们定义了一个包含占位符的Prompt模板 (TEMPLATE)。在运行时,我们使用 chatClient.prompt().user(...) 方法来填充这些占位符,生成最终的提示内容,并将其发送给AI模型进行处理。

这里的模版符合Prompt Engineering的一些最佳实践:

  • 明确角色设定:指定AI模型扮演专业的星座和生肖运势分析师角色。
  • 结构化输出:要求AI模型按照特定的格式输出结果,确保信息的完整性和可读性。
  • 具体细节:提供了详细的运势分析要求,确保生成内容的深度和广度。
  • 积极语气:要求使用温暖、积极的语气,使内容更具吸引力。
  • 动态参数:通过占位符动态传递用户信息和当前日期,使提示更加个性化。
  • 清晰指令:明确告诉AI模型需要生成哪些具体内容,避免模糊不清的回答。
  • 任务分解:将复杂的任务分解为多个具体部分,帮助AI模型更好地组织回答内容。
  • 上下文提供:通过提供用户的姓名和出生日期,帮助AI模型生成更相关的内容。

总结: 这个模版是一个典型的 “指令+结构+约束+上下文+风格”的组合。

今日运势结果

优化#

外部化 Prompt Template#

如果Prompt template的内容比较多,还可以将其放到一个单独的文件中,比如fortune_today_prompt.txt,然后通过Spring的资源加载机制来加载这个文件的内容。

  private final ChatClient chatClient;

  private final @NonNull Resource templateResource;

  public SpringAiFortuneTodayService(ChatClient.Builder chatClientBuilder,
      @Value("classpath:/promptTemplates/fortune_today.st") Resource templateResource) {
    this.chatClient = chatClientBuilder.build();
    this.templateResource = Objects.requireNonNull(templateResource, "templateResource must not be null");
  }

  @Override
  public FortuneTodayResponse getFortuneToday(UserInfo userInfo) {
    var fortuneText = chatClient.prompt()
        .user(userSpec -> userSpec.text(templateResource).param("name", userInfo.fullName())
        .param("birthday", userInfo.birthDate()).param("today_date", Objects.requireNonNull(String.valueOf(LocalDate.now()))))
        .call()
        .content();

    return new FortuneTodayResponse(fortuneText);
  }

使用context来避免幻觉(hallucinations)#

上面的执行结果中,有个问题,1990-05-15这个生日信息,应该是金牛座,而不是天秤座。本地的大模型mistral:7b不清楚什么原因出现了幻觉。

 ollama run mistral:7b  
>>> 1990年5月15日出生的人是什么星座?
 根据天文学计算,1990年5月15日是白羊座

处理幻觉的方式有几种,包括

  • 训练你自己的模型
  • 对已有的模型进行微调(fine-tuning)
  • 在prompt中提供附加的上下文(context)

虽然训练微调模型可以说是避免幻觉的最佳方法(更不用说它们允许你基于专有信息创建模型),但它们很困难,需要数据科学领域的技能,而不是软件开发领域的技能。此外,为了正确地完成训练和微调,需要大量的训练数据。它们也非常耗时,可能需要几个小时、几天甚至几周。

相比之下,在prompt中添加一些上下文与添加各种条件限制本身没有太大区别,它是在提交prompt时即时发生的。因此,它比训练和微调模型要简单得多。

针对上面的星座错误的问题,我门不要让 AI 去算星座(它不擅长数学和日期逻辑),直接在 Java 里算好,把星座作为参数传给 AI。


public class ZodiacUtils {
  private ZodiacUtils() {
    throw new IllegalStateException("Utility class");
  }

  public static String getZodiacSign(LocalDate date) {
    MonthDay md = MonthDay.from(date);
    Object[][] zodiacSigns = {
      {MonthDay.of(3, 21), MonthDay.of(4, 19), "白羊座"},
      {MonthDay.of(4, 20), MonthDay.of(5, 20), "金牛座"},
      {MonthDay.of(5, 21), MonthDay.of(6, 20), "双子座"},
      {MonthDay.of(6, 21), MonthDay.of(7, 22), "巨蟹座"},
      {MonthDay.of(7, 23), MonthDay.of(8, 22), "狮子座"},
      {MonthDay.of(8, 23), MonthDay.of(9, 22), "处女座"},
      {MonthDay.of(9, 23), MonthDay.of(10, 22), "天秤座"},
      {MonthDay.of(10, 23), MonthDay.of(11, 21), "天蝎座"},
      {MonthDay.of(11, 22), MonthDay.of(12, 21), "射手座"},
      {MonthDay.of(12, 22), MonthDay.of(1, 19), "摩羯座"},
      {MonthDay.of(1, 20), MonthDay.of(2, 18), "水瓶座"},
      {MonthDay.of(2, 19), MonthDay.of(3, 20), "双鱼座"}
    };

    for (Object[] zodiac : zodiacSigns) {
      MonthDay start = (MonthDay) zodiac[0];
      MonthDay end = (MonthDay) zodiac[1];
      String sign = (String) zodiac[2];
      if ((md.isAfter(start) || md.equals(start)) && (md.isBefore(end) || md.equals(end))) {
        return sign;
      }
    }
    return "";
  }
}
@Override
  public FortuneTodayResponse getFortuneToday(UserInfo userInfo) {
    // 解析日期
    LocalDate birthDate = LocalDate.parse(userInfo.birthDate());
    // 计算星座
    String zodiacSign = Objects.requireNonNull(ZodiacUtils.getZodiacSign(birthDate), "zodiacSign must not be null");

    var fortuneText = chatClient.prompt()
        .user(userSpec -> userSpec.text(templateResource)
        .param("name", userInfo.fullName())
        .param("birthday", userInfo.birthDate())
        .param("zodiac", zodiacSign)// 传入计算好的星座
        .param("today_date", Objects.requireNonNull(String.valueOf(LocalDate.now()))))
        .call()
        .content();

    return new FortuneTodayResponse(fortuneText);
  }

zodiac

认真看的话,生肖也有一样的问题,正确的生肖应该属马。可以用类似的方法来解决。

Prompt消息角色#

许多 LLM,包括来自 OpenAI、MistralAI 和 Anthropic 的模型,都支持将一个提示拆分成多条消息,每条消息都属于一个特定的角色。常用的角色包括:

  • User (用户): 消息包含由应用程序用户(或代表用户)提出的问题或陈述。
  • System (系统): 消息包含由应用程序本身提供给 LLM 的指令。
  • Assistant (助手): 消息包含来自 LLM 的回复。
  • Tool (工具): 消息包含调用工具执行某些操作或获取额外上下文信息的指令。
角色 (Role) 目的/用途 谁发送 (Sender)
System 设置模型身份、行为、约束和提供全局上下文(如规则)。 应用程序/开发者
User 提出实际问题、发起对话或提供数据。 最终用户
Assistant LLM 的历史回复。用于多轮对话中保持上下文。 LLM/AI 模型
Tool 用于函数调用(Function Calling)或 RAG 场景中,提供工具的输出或检索到的数据。 应用程序/工具

在 Spring AI 中,可以使用 chatClient.prompt().user(...)chatClient.prompt().system(...)chatClient.prompt().assistant(...) 方法来创建不同角色的消息。

interface ChatClientRequestSpec {

		/**
		 * Return a {@link ChatClient.Builder} to create a new {@link ChatClient} whose
		 * settings are replicated from this {@link ChatClientRequest}.
		 */
		Builder mutate();

		ChatClientRequestSpec advisors(Consumer<AdvisorSpec> consumer);

		ChatClientRequestSpec advisors(Advisor... advisors);

		ChatClientRequestSpec advisors(List<Advisor> advisors);

		ChatClientRequestSpec messages(Message... messages);

		ChatClientRequestSpec messages(List<Message> messages);

		<T extends ChatOptions> ChatClientRequestSpec options(T options);

		ChatClientRequestSpec toolNames(String... toolNames);

		ChatClientRequestSpec tools(Object... toolObjects);

		ChatClientRequestSpec toolCallbacks(ToolCallback... toolCallbacks);

		ChatClientRequestSpec toolCallbacks(List<ToolCallback> toolCallbacks);

		ChatClientRequestSpec toolCallbacks(ToolCallbackProvider... toolCallbackProviders);

		ChatClientRequestSpec toolContext(Map<String, Object> toolContext);

		ChatClientRequestSpec system(String text);

		ChatClientRequestSpec system(Resource textResource, Charset charset);

		ChatClientRequestSpec system(Resource text);

		ChatClientRequestSpec system(Consumer<PromptSystemSpec> consumer);

		ChatClientRequestSpec user(String text);

		ChatClientRequestSpec user(Resource text, Charset charset);

		ChatClientRequestSpec user(Resource text);

		ChatClientRequestSpec user(Consumer<PromptUserSpec> consumer);

		ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer);

		CallResponseSpec call();

		StreamResponseSpec stream();

	}

注意: 并非所有 LLM API 都支持相同的消息角色。当 API 不支持 System 角色时,Spring AI 会简单地将原本用于 System 角色的文本添加到 User 消息中。

源码#

Spring AI Demo