项目最初只有一个 Prompt,写在哪里都能工作。

几周后,客服、翻译、摘要和代码审核都有了自己的模板。每个部门又分普通用户、企业用户和内部人员。为了复用,大家复制一份旧 Prompt 再修改两句。

很快就出现了十几个几乎相同的版本。

有人修复了安全约束,却只改了其中三份。有人调整变量名,线上模板还在使用旧参数。多轮历史被手工拼在字符串末尾,测试环境与生产环境甚至拉取了不同版本。

Prompt 一旦进入业务,就不再只是文案。它有输入变量、角色、版本、测试、发布和回滚,也需要工程化治理。

本文重点介绍 partial()MessagesPlaceholder、模板组合和模板库,再把它们与 LangSmith 的 Prompt Commit、staging 和 production 环境连接起来。

先让一个模板真正跑起来

下面的第一个示例故意写得完整一些。它从依赖、环境变量、模型初始化到调用都包含在内,读者不需要先看其他文章。

pip install -U langchain langchain-openai "langsmith>=0.7.0" python-dotenv pydantic

在项目根目录创建 .env,只在本机保存密钥,不要提交到版本库。

OPENAI_API_KEY=replace_with_your_openai_key

# 只有后文需要从 LangSmith 拉取 Prompt 时才需要这一项。
LANGSMITH_API_KEY=replace_with_your_langsmith_key
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate


load_dotenv()

model = init_chat_model("openai:gpt-5.4-mini", temperature=0)

base_template = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是{role}。目标受众是{audience}。"
            "回答应准确、简洁,无法确认时说明依据不足。",
        ),
        ("human", "{task}"),
    ]
)

support_template = base_template.partial(
    role="售后支持专员",
    audience="普通用户",
)

response = (support_template | model).invoke(
    {"task": "解释为什么退款仍在处理中。"}
)

print(response.content)

输出的具体措辞会随模型变化,但它应是一段面向普通用户的退款处理说明。这里使用 OpenAI 集成;若换成其他供应商,需要安装对应集成包、配置对应环境变量,并更换模型标识。后面的局部示例只聚焦模板行为,真正调用模型时可以沿用这一段初始化方式。

先区分三类输入

一个聊天模板中的数据通常来自三个不同生命周期。

固定配置
角色、品牌、语言

ChatPromptTemplate

会话状态
历史消息

本次输入
问题、任务

最终消息列表

固定配置适合预填。历史消息适合消息占位符。本次问题适合普通模板变量。

如果把三类数据都塞进一个字符串变量,模板会失去角色边界,也很难测试。

使用 partial 预填固定变量

假设多个客服场景共享同一模板,但角色和受众不同。

from langchain_core.prompts import ChatPromptTemplate


base_template = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是{role}。目标受众是{audience}。"
            "回答应准确、简洁,无法确认时说明依据不足。",
        ),
        ("human", "{task}"),
    ]
)

customer_support_template = base_template.partial(
    role="售后支持专员",
    audience="普通用户",
)

messages = customer_support_template.format_messages(
    task="解释为什么退款仍在处理中。"
)

partial() 返回一个预填了部分变量的新模板,不会修改原模板。这样可以从同一基础定义派生多个稳定变体。

固定变量不等于秘密。不要把 API Key、访问令牌或用户密码放进 Prompt 模板。模型不需要知道的敏感信息不应进入上下文。

partial 也可以接受动态值

部分变量可以由函数在格式化时生成,例如当前日期。

from datetime import date


def current_date() -> str:
    """返回运行当天日期,供模板格式化时动态填充。"""
    return date.today().isoformat()


policy_template = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是政策问答助理,目标受众是企业用户。当前日期是{today}。",
        ),
        ("human", "{task}"),
    ]
)

dated_template = policy_template.partial(
    today=current_date,
)

messages = dated_template.format_messages(
    task="说明当前申请流程。"
)

时间、地区和用户权限若会影响业务结论,最好作为结构化输入显式传入,而不是在模板内部隐式读取全局状态。这样测试才能固定这些条件。

使用 MessagesPlaceholder 插入消息历史

多轮历史已经是消息列表,不应该再转成一段带 Human:AI: 标签的字符串。

from langchain.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


chat_template = ChatPromptTemplate.from_messages(
    [
        ("system", "你是订单支持助手,只根据已提供信息回答。"),
        MessagesPlaceholder(variable_name="history", optional=True),
        ("human", "{question}"),
    ]
)

history = [
    HumanMessage("订单 A-100 已发货。"),
    AIMessage("我已经记下订单状态。"),
]

messages = chat_template.format_messages(
    history=history,
    question="我刚才说的是哪张订单?",
)

MessagesPlaceholder 保留了每条历史消息的角色、工具调用和内容块。optional=True 允许首次对话不传历史。

System 模板

最终消息列表

MessagesPlaceholder
历史消息列表

本轮 Human 模板

ChatModel

消息占位符不会自动裁剪历史。传入前仍应执行 token 预算、摘要和工具消息完整性检查。

简写 placeholder 与显式对象

模板支持简写。

template = ChatPromptTemplate.from_messages(
    [
        ("system", "你是简洁的助手。"),
        ("placeholder", "{history}"),
        ("human", "{question}"),
    ]
)

显式 MessagesPlaceholder 更容易看出变量名和是否可选,适合团队代码。简写适合结构非常简单、团队已经统一规范的场景。

模板组合应该组合职责,而不是拼碎片

模板可以用 + 组合。

role_template = ChatPromptTemplate.from_messages(
    [("system", "你是数据库专家。")]
)

task_template = ChatPromptTemplate.from_messages(
    [("human", "请评审下面的 SQL,重点检查{focus}。\n\n{sql}")]
)

review_template = role_template + task_template

组合适合复用具有明确职责的消息片段。不要把一句 Prompt 拆成十几个字符串组件,只为了追求抽象。碎片过多会让最终指令难以阅读,也更难判断冲突顺序。

一个实用标准是,独立片段是否有自己的角色、测试和复用场景。如果没有,保持在同一个模板里通常更清楚。

建立模板库

模板库不必从一个庞大类开始。可以使用按领域拆分的模块和创建函数。

prompts/
├── __init__.py
├── support.py
├── translation.py
├── summarization.py
└── review.py
# prompts/support.py
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


def build_support_prompt() -> ChatPromptTemplate:
    """构建售后问答模板,不在此处绑定具体模型或用户数据。"""
    return ChatPromptTemplate.from_messages(
        [
            ("system", "你是售后支持助理,只依据传入的政策和订单信息回答。"),
            MessagesPlaceholder("history", optional=True),
            ("human", "政策:\n{policy}\n\n用户问题:\n{question}"),
        ]
    )

创建函数比全局可变模板更容易测试和扩展。模板模块只负责消息结构,模型选择、用户授权和知识检索放在其他层。

给模板定义输入契约

Prompt 变量不是随意字典。可以用 Pydantic 在进入模板前校验。

from pydantic import BaseModel, Field


class SupportPromptInput(BaseModel):
    policy: str = Field(min_length=1, max_length=8_000)
    question: str = Field(min_length=1, max_length=1_000)


validated = SupportPromptInput(
    policy="签收后七天内可以申请退货。",
    question="我昨天签收,可以退货吗?",
)

messages = build_support_prompt().format_messages(
    policy=validated.policy,
    question=validated.question,
    history=[],
)

长度限制不是安全方案的全部,但可以避免空输入、意外大文本和明显超出上下文预算的数据。

Prompt 版本不应靠文件名猜

当模板进入多人协作和生产环境,仅靠 prompt_final_v3_really_final.py 管理版本会很快失控。

LangSmith Prompt Management 将每次保存视为 Prompt Commit。普通 Commit tag 指向一个确定版本;stagingproduction 则是受平台管理的保留环境标签,应通过 Promote 流程推广 Commit,而不是在自由标签列表里随手移动。

通过

验证通过

失败

修改 Prompt

Prompt Commit

Playground 测试

Dataset Experiment

staging

production

先把远端 Prompt 的输入契约固定下来。下面的脚本只需在受控的管理环境运行一次,它会创建一个只接受 {question} 的私有 Prompt。随后在 LangSmith 的 Prompt 详情页把该 Commit 推广到 Production,运行时才引用 support-answer:production

from langchain_core.prompts import ChatPromptTemplate
from langsmith import Client


managed_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是售后支持助理,只依据传入信息回答。"),
        ("human", "{question}"),
    ]
)

Client().push_prompt("support-answer", object=managed_prompt)

远端 Prompt 应被当作可执行配置,而不是任意文本。只拉取经过审查的本工作区 Prompt,不要把未知公共 Prompt 直接接入生产请求。更重要的是,拉取模板和内置兜底模板必须使用同一组输入变量,否则版本切换会在运行时变成一次隐藏的接口变更。

动态拉取 Prompt 很方便,也引入运行时依赖。真实服务里,远端暂时不可用不该让每个用户请求都失败;但永久缓存又会让 production 标签更新后长期不生效。LangSmith SDK 自身默认也有全局 Prompt 缓存,因此下面的示例显式关闭 SDK 缓存,只保留一个行为清楚的五分钟本地缓存、最后一次成功版本和内置兜底模板。

import logging
from time import monotonic

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langsmith import Client


logger = logging.getLogger(__name__)
EXPECTED_INPUT_VARIABLES = {"question"}
FALLBACK_PROMPT = ChatPromptTemplate.from_messages(
    [
        ("system", "你是售后支持助理,只依据传入信息回答。"),
        ("human", "{question}"),
    ]
)


class PromptResolver:
    """按固定时间刷新远端 Prompt,失败时保留最后一次成功版本。"""

    def __init__(self, reference: str, refresh_seconds: float = 300) -> None:
        # 只使用本类的缓存策略,避免 SDK 全局缓存延长 production 切换延迟。
        self._client = Client(disable_prompt_cache=True)
        self._reference = reference
        self._refresh_seconds = refresh_seconds
        self._cached_prompt = None
        self._refresh_after = 0.0

    @staticmethod
    def _validate_input_contract(prompt) -> None:
        """拒绝输入变量发生漂移的远端模板,避免调用点在运行时失配。"""
        actual_variables = set(prompt.input_variables)
        if actual_variables != EXPECTED_INPUT_VARIABLES:
            raise ValueError(
                f"Prompt 输入变量应为 {EXPECTED_INPUT_VARIABLES},实际为 {actual_variables}"
            )

    def get(self):
        """返回当前可用模板,不把一次远端抖动放大成用户请求失败。"""
        now = monotonic()
        if self._cached_prompt is not None and now < self._refresh_after:
            return self._cached_prompt

        try:
            prompt = self._client.pull_prompt(self._reference, skip_cache=True)
            self._validate_input_contract(prompt)
        except Exception:
            # 这里只保护远端拉取边界,日志不记录用户问题或 Prompt 正文。
            logger.warning("无法刷新远端 Prompt,继续使用可用版本", exc_info=True)
            self._refresh_after = now + self._refresh_seconds
            if self._cached_prompt is not None:
                return self._cached_prompt
            return FALLBACK_PROMPT

        self._cached_prompt = prompt
        self._refresh_after = now + self._refresh_seconds
        return prompt


load_dotenv()
resolver = PromptResolver("support-answer:production")
model = init_chat_model("openai:gpt-5.4-mini", temperature=0)

response = (resolver.get() | model).invoke(
    {"question": "退款为什么还没有到账?"}
)
print(response.content)

support-answer 改成工作区中真实的 Prompt 标识,并保证其输入变量仍然只有 question。这个例子每五分钟尝试刷新一次,拉取失败或输入契约漂移时优先使用最后一次成功模板,第一次就失败才使用内置模板。若业务要求一次发布中的结果绝对固定,应改为拉取明确 Commit ID,并把更新当作一次受控部署;若希望不发版切换,则引用 :production,在管理界面完成推广或回滚。两种策略都成立,区别在于是否接受运行中的版本变动。

无论选择哪种方式,都不要在每次用户请求时联网拉取。关键版本还可以与自己的 Git 仓库和 CI/CD 流程同步。

模板测试分三层

结构测试

检查角色顺序、必填变量和历史插入位置。

行为评测

用固定数据集比较回答正确性、格式、品牌风格和拒答行为。

生产监控

按 Prompt 版本记录 Trace、延迟、成本、用户反馈和失败案例。

结构测试

离线数据集评测

staging 验证

production 监控

失败 Trace 回流数据集

Prompt 工程化不是把几句话放进更复杂的类。它是让固定配置、会话历史和本轮输入各归其位,让版本可追踪,让修改可评测,让发布可回滚。

当 Prompt 能被测试和治理,它才真正成为软件的一部分。

延伸阅读

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐