提示工程,在某种程度上,可以说是“让人既爱又恨”的领域。对许多计算机科学家来说,这种“输入正确词语,期待它输出正确结果”的方式,总觉得有些不够严谨。然而不可否认,在当前大语言模型应用中,高质量的提示词至关重要,因此我们急需趁手的工具和框架来简化这一过程。最近,这个方向迎来了一位有趣的新成员——来自 Character.ai 的 PromptPoet。这家与 Google 达成战略合作的 AI 初创公司,推出了一个专为设计和集成高质量提示而打造的灵活框架。

PromptPoet 的定位十分清晰:帮助开发者及非技术用户,在不与复杂字符串操作纠缠的前提下,高效地设计并管理提示。其核心理念是让用户专注于“如何写出有效提示”本身,而非被编码细节困扰。
该框架的精髓之一在于对 Python f-strings 的运用。这对很多开发者而言并不陌生,但问题在于,当 f-strings 变得复杂时,构建一个完善的提示仍需大量手动劳动。这种复杂性对非技术用户而言,无疑是一道门槛。那么 PromptPoet 如何化解这个难题呢?
一个简单的例子足以说明:
- 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 }}:
这里的核心理念是从 UI 设计中汲取灵感,将提示视为其运行时状态的动态函数——模板、数据、令牌限制等元素,成为它的“构建块”。
提示模板
PromptPoet 真正想做的事,是将关注点从“提示工程”转移到“提示设计”上。用户可以在模板上反复迭代,而非在代码上反复调试。它采用 YAML 与 Jinja2 的组合,使模板既灵活又易于适应。整个处理分两个阶段:
- 渲染:该初级阶段由 Jinja2 负责。它处理输入数据,执行控制逻辑,验证数据,绑定变量,并在模板内调用函数。
- 加载:渲染完成后,输出会被组织成 YAML 文件。该文件由重复的段落构成,每个段落最终都会转换为一个 Python 数据结构。每个段落拥有几个关键属性:
- 名称:段落的清晰标识。
- 内容:构成提示核心的字符串内容。
- 角色(可选):指定参与者角色,用于区分用户和系统组件。
- 截断优先级(可选):在需要截断时,定义段落的先后顺序。优先级相同的段落会按顺序被截断。
这个库还提供了不少实用功能,比如分词和截断,它们主要用于提升缓存效率与响应速度。来看几个具体用法:
prompt.tokenize()
prompt.truncate(token_limit=TOKEN_LIMIT, truncation_step=TRUNCATION_STEP)
# 将提示内容输出为纯文本字符串
prompt.string: str
>>>> "..."
# 查看提示的原生 token 序列
prompt.tokens: list[int]
>>>> [...]
# 将提示内容转为 LLM API 所需的消息字典格式
prompt.messages: list[dict]
>>>> [...]
# 将提示内容以段落(PromptPart)的形式展开
prompt.parts: list[PromptPart]
>>>> [...]
模板语言
将 Jinja2 与 YAML 结合使用,效果十分显著。Jinja2 允许你在模板内直接绑定数据、调用函数、执行基本的控制流;而 YAML 则负责提供结构,让那些超出 token 限制时的复杂截断成为可能。这种方式,与 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 %}
自定义编码选项
默认情况下,PromptPoet 使用 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
>>>> [...]
解释缓存感知截断
聊完模板,再来看另一个关键点:截断策略。在 Character.AI 的实践中,截断的核心目标是通过优化消息截断,来最大化 GPU 的前缀缓存命中率。简单来说,就是通过调整截断步骤与 token 限制,让缓存提示 token 占总提示 token 的比例尽可能高。但这里有个平衡:增加截断步骤,可能会带来更多 token 被截掉。
传统做法的弊端很明显。
简单截断问题
想象一个包含 M1 到 M10 消息的聊天场景。如果每次对话都简单地在某一轮截断到固定的 token 限制,那么截断点会频繁移动。这直接导致缓存效率降低,计算成本飙升。
缓存感知截断的好处
Character.AI 的方法是,每隔几轮才移动一次截断点,让它稳定在一个位置上。这样,就能保持一条从开头到最新消息的连续 token 序列。最关键的是,这个方案允许复用前几轮已经完成的缓存计算结果,从而大幅提升效率。其中的参数 k,实际上反映了截断步长与平均 token 数量之间的平衡,当然,你无法直接控制它。
总的来说,PromptPoet 现已开源,这应该会吸引不少开发者在它的基础上进行构建。至少,它已经把 Character.ai 在提示设计领域积累的经验教训,很好地封装了起来。无论如何,它对提示工程这个领域来说,都是一个令人欣喜的补充。
