在 Character.AI,掌握提示工程的艺术和科学至关重要。随着每日生成数十亿个提示,采用一种强大且可扩展的提示设计方法显得尤为必要。本文作者提倡从传统的“提示工程”转向“提示设计”,通过开发 Prompt Poet 工具,使开发者和非技术用户能够更高效地设计和管理提示,提升用户与 AI 模型的交互质量。

  • 提示设计的重要性:构建有效的提示需要考虑多种因素,包括对话模式、实验、角色、用户属性、记忆和整个对话历史等。随着 LLM(大语言模型)上下文窗口的扩大,提升提示设计的效率显得尤为重要。

  • Prompt Poet 工具:该工具结合了 Python 的 f-strings 和 YAML,使提示的设计和管理更加灵活和易于使用。Prompt Poet 允许用户在不编写代码的情况下高效创建和迭代提示模板,节省了大量的字符串操作时间。

  • 模板处理过程:提示模板的处理分为两个主要阶段:

    1. 渲染:使用 Jinja2 处理输入数据,执行控制流逻辑并验证数据。
    2. 加载:输出为结构化的 YAML 文件,便于管理和使用。
  • 示例模板:提供了基本的问答机器人模板,展示了如何使用 Jinja2 语法和 YAML 结构来创建灵活的提示。

  • 上下文长度管理:通过设置截断优先级,Prompt Poet 可以有效管理对话历史,确保在上下文长度受限时保留重要信息。

  • 设计选择:Prompt Poet 库提供了多种功能,包括提示属性的设置、令牌化和截断等,优化了响应的效率和延迟。

  • 结论:Prompt Poet 代表了提示工程的重大进步,简化了复杂和个性化提示的创建过程,使开发者和用户能够更专注于提示设计,推动 AI 交互的高效和直观化。

Character.AI 的提示词设计

作者: James Groeneveld
Github: https://github.com/character-ai/prompt-poet
PyPi: https://pypi.org/project/prompt-poet/

在 Character.AI,掌握 Prompt Engineering(提示工程)的艺术和科学至关重要。构建生产环境中的提示需要考虑大量数据和因素:当前对话模式、正在进行的实验、涉及的角色、聊天类型、各种用户属性、固定记忆、用户角色和整个对话历史等。我们每天生成数十亿个提示,需要充分利用不断扩展的大语言模型 (LLM) 上下文窗口,并且我们的使用场景非常多样化,因此需要一种强大且可扩展的提示设计方法。我们主张从传统的“提示工程”转向“提示设计”,从繁琐的字符串操作转变为设计精确、引人入胜的提示。这篇文章介绍了我们开发的 Prompt Poet,它是我们为此目的开发的工具。

简要概述

Python 的 f-strings(及其封装)现在是提示工程师的行业标准。使用 f-strings 可以简单到将用户查询直接插入到字符串中,但也可能变得非常复杂,涉及大量手动字符串操作来创建最终提示。这也使得提示的迭代对于非技术人员来说不太友好,因为需要编写代码。

我们认为可以有更好的方法。因此,我们开发了 Prompt Poet (Github / PyPi),一个允许开发者和非技术用户高效设计和管理生产提示的工具。它节省了在字符串操作上的工程时间,让大家能更专注于为用户打造最佳提示。

借鉴 UI 设计的概念,我们将提示 P 视为运行时状态的函数——包括提示模板、数据、Token 限制等。

提示是状态的函数。

基本用法

import os
import getpass

from prompt_poet import Prompt
from langchain import ChatOpenAI

# Uncomment if you need to set OPENAI_API_KEY.
# os.environ["OPENAI_API_KEY"] = getpass.getpass()

raw_template = """
- name: system instructions
  role: system
  content: |
    Your name is {{ character_name }} and you are meant to be helpful and never harmful to humans.
- name: user query
  role: user
  content: |
    {{ username}}: {{ user_query }}
- name: response
  role: user
  content: |
    {{ character_name }}:
"""

prompt = Prompt(
    raw_template=raw_template,
    template_data={
        "character_name": "Character Assistant",
        "username": "Jeff",
        "user_query": "Can you help me with my homework?"
    }
)

prompt.messages
>>> [{'role': 'system', 'content': 'Your name is Character Assistant and you are meant to be helpful and never harmful to humans.'}, {'role': 'user', 'content': 'Jeff: Can you help me with my homework?'}, {'role': 'user', 'content': 'Character Assistant:'}]

model = ChatOpenAI(model="gpt-4o-mini")
response = model.invoke(prompt.messages)
response
>>> AIMessage(content='Of course, Jeff! I’d be happy to help you with your homework. What subject are you working on, and what do you need assistance with?', response_metadata={'token_usage': {'completion_tokens': 31, 'prompt_tokens': 47, 'total_tokens': 78}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0f03d4f0ee', 'finish_reason': 'stop', 'logprobs': None}, id='run-5fff6ab5-5dee-40d8-b11c-7a9637406c36-0', usage_metadata={'input_tokens': 47, 'output_tokens': 31, 'total_tokens': 78})

提示模板

有了 Prompt Poet,过去你在提示工程上花费的时间现在可以用来进行提示设计,使你能够迭代模板而不是代码。这些模板混合使用 YAMLJinja2,既灵活又易于组合。这种方法使开发者和非技术用户能够高效地创建和管理提示。模板处理分为两个主要阶段:

  1. 渲染:最初,Jinja2 处理输入数据。在此阶段,控制流逻辑被执行,数据被验证并绑定到变量,模板中的函数被执行。
  2. 加载:渲染后,输出是一个结构化的 YAML 文件。这个 YAML 结构由重复的块或部分组成,每个部分都封装成一个 Python 数据结构。这些部分具有以下属性:
    • 名称:部分的清晰、易懂的标识符。
    • 内容:构成提示的实际字符串。
    • 角色(可选):指定参与者的角色,有助于区分不同的用户或系统组件。
    • 截断优先级(可选):决定必要时的截断顺序,具有相同优先级的部分按出现顺序截断。

示例:基本的问答机器人

- name: system instructions
  role: system
  content: |
    Your name is {{ character_name }} and you are meant to be helpful and never harmful to humans.

- name: user query
  role: user
  content: |
   {{ username}}: {{ user_query }}

- name: response
  role: user
  content: |
    {{ character_name }}:

一个问答机器人模板的基本示例。

插入列表

{% for message in current_chat_messages %}
- name: chat_message
  role: user
  content: |
    {{ message.author }}: {{ message.content }}
{% endfor %}

如果你有列表中的元素(例如消息),可以这样将它们解析到模板中。

截断旧消息

{% for message in current_chat_messages %}
- name: chat_message
  role: user
  truncation_priority: 1
  content: |
    {{ message.author }}: {{ message.content }}
{% endfor %}

由于上下文长度有限,无法始终保存整个聊天记录,所以我们可以设置消息部分的截断优先级,Prompt Poet 会按它们出现的顺序截断(从最旧到最新)。

适应用户模式

{% if modality == "audio" %}
- name: special audio instruction
  role: system
  content: |
    {{ username }} is currently using audio. Keep your answers succinct.
{% endif %}

根据用户当前的模式(音频或文本)定制指令。

针对特定查询

{% if extract_user_query_topic(user_query) == "homework_help" %}
{% for homework_example in fetch_few_shot_homework_examples(username, character_name) %}
- name: homework_example_{{ loop.index }}
  role: user
  content: |
    {{ homework_example }}
{% endfor %}
{% endif %}

例如在需要时提供作业帮助等上下文相关的示例。

处理空白

- name: system instructions
  role: system
  content: |
    Your name is {{ character_name }} and you are meant to be helpful and never harmful to humans.

- name: user query
  role: user
  content: |
   <|space|>{{ username}}: {{ user_query }}

Prompt Poet 默认会去除空白,以避免在最终提示中出现不必要的换行。如果想明确包含空格,可以使用内置的空格标记“<|space|>”以确保正确格式。

整合

- name: system instructions
  role: system
  content: |
    Your name is {{ character_name }} and you are meant to be helpful and never harmful to humans.

{% if modality == "audio" %}
- name: special audio instruction
  role: system
  content: |
    {{ username }} is currently using audio modality. Keep your answers succinct and to the point.
{% endif %}

{% if extract_user_query_topic(user_query) == "homework_help" %}
{% for homework_example in fetch_few_shot_homework_examples(username, character_name) %}
- name: homework_example_{{ loop.index }}
  role: user
  content: |
    {{ homework_example }}
{% endfor %}
{% endif %}

{% for message in current_chat_messages %}
- name: chat_message
  role: user
  truncation_priority: 1
  content: |
    {{ message.author }}: {{ message.content }}
{% endfor %}

- name: user query
  role: user
  content: |
   {{ username}}: {{ user_query }}

- name: reply_prompt
  role: user
  content: |
    {{ character_name }}:

组合性是 Prompt Poet 模板的核心优势,使得创建复杂和动态的提示成为可能。

分解为部分

{% include 'sections/system_instruction.yml.j2' %}

{% include 'sections/audio_instruction.yml.j2' %}

{% if extract_user_query_topic(user_query) == "homework_help" %}
{% include 'sections/homework_examples.yml.j2' %}
{% endif %}

{% include 'sections/chat_messages.yml.j2' %}

{% include 'sections/user_query.yml.j2' %}

{% include 'sections/reply_prompt.yml.j2' %}

为了在模板中保持 DRY 原则,可以将模板分解为可复用的部分,例如在 A/B 测试新提示时。

这只是 Prompt Poet 模板可以实现的开始,我们期待看到你的创意!

设计选择

Prompt Poet 库

Prompt Poet 库提供了各种功能和设置,包括提示属性。关键功能如分词和截断有助于高效缓存和低延迟响应,详见 优化推理

prompt.tokenize()
prompt.truncate(token_limit=TOKEN_LIMIT, truncation_step=TRUNCATION_STEP)

# Inspect prompt as a raw string.
prompt.string: str
>>> "..."

# Inpsect the prompt as raw tokens.
prompt.tokens: list[int]
>>> [...]

# Inspect the prompt as LLM API message dicts.
prompt.messages: list[dict]
>>> [...]

# Inspect the prompt as first class parts.
prompt.parts: list[PromptPart]
>>> [...]

模板语言

Jinja2 和 YAML 结合提供了非常可扩展和表达力强的模板语言。Jinja2 促进了直接数据绑定、任意函数调用和基本的控制流。YAML 提供了模板的结构(深度=1),使我们能够在达到 Token 限制时执行复杂的截断。这种 Jinja2 和 YAML 的配对并不唯一——最著名的例子是 Ansible

提示的可移植性

在 Character.AI,我们不断改进模型,使其更好地符合用户偏好。为此,我们需要在离线过程中重建生产提示,如用于评估和训练后工作。将我们的提示模板化,可以让我们轻松地在团队之间共享模板文件,而不必拼凑我们不断变化的代码库的不同部分。

模板内置函数调用

Jinja2 的一个突出特点是能够在模板中直接调用任意 Python 函数。这对于实时的数据检索、操作和验证至关重要,简化了提示的构建过程。这里的 `extract_user_query_topic` 可以在模板的控制流中对用户查询进行任意处理,例如通过一个主题分类器的往返。

{% if extract_user_query_topic(user_query) == "homework_help" %}
{% for homework_example in fetch_few_shot_homework_examples(username, character_name) %}
- name: homework_example_{{ loop.index }}
  role: user
  content: |
    {{ homework_example }}
{% endfor %}
{% endif %}

使用extract_user_query_topic进行模板内置函数调用的示例。

自定义编码函数

默认情况下,Prompt Poet 使用 TikToken 的“o200k_base”分词器,不过可以在顶层 `tiktoken_encoding_name` 中提供其他编码名称。或者,用户可以在顶层 `encode_func: Callable[[str], list[int]]` 中提供自己的编码函数。

from tiktoken import get_encoding
encode_func = get_encoding("o200k_base")

prompt = Prompt(
    raw_template=raw_template,
    template_data=template_data,
    encode_func=encode_func
)
prompt.tokenize()
prompt.tokens
>>> [...]

将自定义编码函数传递给提示构建。

截断

如果你的 LLM 提供商支持 GPU 亲和性和前缀缓存,请利用 Character.AI 的截断算法来最大化前缀缓存率。前缀缓存率定义为从缓存中检索的提示 Token 数量与提示 Token 总数的比率。找到适合你用例的截断步长和 Token 限制的最佳值。随着截断步长的增加,前缀缓存率也会增加,但提示中会截断更多 Token。

TOKEN_LIMIT = 128000
TRUNCATION_STEP = 4000

# Tokenize and truncate the prompt.
prompt.tokenize()
prompt.truncate(token_limit=TOKEN_LIMIT, truncation_step=TRUNCATION_STEP)

response = model.invoke(prompt.messages)

具有指定 Token 限制和截断步长的缓存感知提示截断示例。

缓存感知截断解释

我们的截断策略在帮助我们通过优化消息的截断方式达到令人印象深刻的 95% 缓存率方面发挥了重要作用。简而言之,每次截断时,我们都会截断到一个固定的截断点——平均每 k 次对话才移动这个截断点。这使我们能够最大限度地利用 优化推理 中描述的 GPU 前缀缓存。如果我们简单地截断到 Token 限制(L),截断点将在每次对话中移动。这种方法的权衡是我们往往会截断比严格需要的更多。

简单截断

考虑以下每回合的示例,其中 M1…M10 是给定聊天中的当前消息。如果我们简单地截断到略低于 Token 限制,我们的截断点每次对话都会移动,只留下一小部分前缀可以从缓存中检索,这会导致显著的重新计算成本。

缓存感知截断

Character.AI 的缓存感知截断算法在每 k 次对话中截断到同一个固定的截断点。这意味着直到最近的消息为止的 Token 序列保持不变,使我们能够重复使用从上一次对话中存储在 GPU 前缀缓存中的计算。请注意,k 不是直接可控的,而是截断步长和被截断的消息的平均 Token 数量的函数。

结论

Prompt Poet 在提示工程领域代表了一个重大飞跃,将重点从繁琐的手动字符串操作转移到更简化、设计导向的方法。通过借鉴 UI 设计的原则并将其应用于提示构建,该工具简化了复杂且个性化提示的创建,提升了用户与 AI 模型之间的互动质量。

通过 Prompt Poet,开发者和非技术用户都能够更加专注于提示设计而非提示工程。这种从工程到设计的转变有可能重新塑造我们与 AI 的互动方式,使这些互动更加高效、直观,并且更符合用户需求。随着我们继续探索大语言模型的潜力并拓展其应用,像 Prompt Poet 这样的工具将在以用户为中心的方式中发挥关键作用,帮助我们充分利用这些潜力。

  • Priompt:Priompt(priority + prompt)是一个基于 JSX 的提示库。它通过优先级来决定在上下文窗口中包含什么。该项目通过将模板层与基于 TypeScript 的逻辑构建层分离,实现了类似的目标。
  • dspy:提供了一种自动优化不同模型提示的好方法,但缺乏对提示的确定性控制,这对于缓存和高吞吐量、低延迟的生产系统来说至关重要。
  • Prompt Engine:源于生产提示工程中的一个常见问题,需要大量代码来操作和更新字符串。这个 TypeScript 包同样为提示模板化过程增加了结构——虽然有些固执己见,会基于特定用例进行假设。最后一次提交是在 2 年前,这个包似乎不再活跃开发。
  • llm:允许在 YAML 中定义基本提示,并支持 Jinja2 的动态控制流、函数调用和数据绑定功能。
  • 原始 Python f-strings:有几个项目采用稍有不同的方法来包装 f-strings 进行提示模板化:
    • LangChain:LangChain 的范围远超提示模板,尽管它确实提供了一些基本的模板化抽象。适合简单的模板化场景,但随着提示复杂性的增加,使用起来会变得笨重。
    • LlamaIndex:类似 LangChain,LlamaIndex 的范围远超提示模板,尽管它也提供了一些基本的模板化抽象。
    • Mirascope:采用了一种新颖的提示模板化方法,通过将所有内容封装在一个 Python 类中,并使用类的文档字符串作为绑定数据的 f-string。