1. 从第一版提示词到可维护的提示词工程
先说我自己的真实经历。最早用大模型写应用,提示词就是一个超长的字符串,直接怼在model.invoke()里。第一版跑通了,挺爽。第二天需求变了,要把系统提示里的角色名从“客服助手”换成“资深销售顾问”,我全局搜替换的时候心里就在犯嘀咕。
真正让我下定决心用模板系统管理的,是一次调接口的意外。明明换了模型,输出格式却乱得离谱,排查半天发现是几个Prompt里有两个空格不一致,导致few-shot示例的结构识别错了。那时候我才意识到:提示词一旦超过50行,字符串拼接的方式就已经失控了,它应该被当作代码一样去管理。
LangChain的Prompts模块,解决的就是这个问题。它不是一个花哨的“魔法提示词生成器”,而是一套帮你把提示词从“一次性草稿”变成“可复用、可测试、可版本化的工程资产”的机制。核心就三件事:模板化、变量管理、分角色组装。这篇文章我直接分享我在项目里实际用下来的经验,包括模板怎么设计不踩坑、ChatPromptTemplate比普通Template好在哪、以及几个让我浪费了一整天的疑难杂症。
如果你是刚接触LangChain,或者写过一段时间但总觉得提示词管理很别扭的开发,这篇文章应该能帮上忙。
2. 为什么提示词需要模板而不是字符串拼接
2.1 模板化的核心动机:变与不变分离
先想一个场景。你在做一个“简历优化助手”,系统提示基本固定,只有用户上传的简历内容和目标岗位是动态的。用传统方式写:
prompt = f""" 你是一个专业的简历优化助手,请根据以下岗位要求,优化用户提供的简历内容。 岗位要求: {job_requirement} 用户简历: {resume_text} 请输出优化后的完整简历。 """这段代码能跑,但有两个隐患。
第一,变量嵌套顺序完全依赖人肉维护。等你在这个字符串里加了五六个变量,谁先谁后、哪里漏了花括号,出了问题只能靠肉眼debug。
第二,模板和业务逻辑耦合。模板一旦需要调整措辞,你得在Python代码里改字符串,而这段代码可能已经被别的业务逻辑层层包围。我见过有人把提示词模板直接写在了API路由函数里,每次改文案都要动业务代码,风险极高。
模板化的思路,就是把“固定不变的部分”和“会变的部分”分离。固定部分是骨架,变量是插槽。LangChain里的PromptTemplate干的就是这件事:
from langchain_core.prompts import PromptTemplate template = """ 你是一个专业的简历优化助手。 请根据以下岗位要求,优化用户提供的简历内容。 岗位要求: {job_requirement} 用户简历: {resume_text} 请输出优化后的完整简历。 """ prompt = PromptTemplate.from_template(template)注意from_template这个类方法。它会自动解析模板字符串里的{变量名},生成对应的input_variables。少写了变量,或者模板里出现了未定义的变量,它会直接报错。这一下就把“变量有没有写对”从运行时排查变成了启动时检查。
2.2 变量校验:宁可启动时报错,不要运行时崩
PromptTemplate默认要求模板里的所有变量都必须被填充。如果你构造的时候漏了参数,调用时会抛出KeyError。这个设计我一开始觉得烦,后来真香。
举个例子。你定义了一个模板:
prompt = PromptTemplate.from_template("请帮我写一份关于{topic}的{style}文案,字数{word_count}字。")如果你只传了topic和style,漏了word_count,LangChain不会用“空字符串”静默替代,而是直接抛异常。这种“严格模式”在工程上是救命的,因为它逼着你在开发阶段就把所有变量补全,而不是等到用户真正调用接口时才发现输出里缺了一段。
这里有一个细节值得单独提:模板中不要出现不必要的花括号。比如你想在提示词里给模型展示JSON示例,直接写{"key": "value"},会被模板解析器当成变量定义。第一次踩这个坑的人绝对不少。解决办法有两个:一个是把JSON示例里的花括号用{{}}转义,另一个是改用ChatPromptTemplate的分消息结构,避免在字符串层面处理这类问题。后面我会细讲第二种方案的用法。
3. 核心组件拆解:PromptTemplate、ChatPromptTemplate与Message
3.1 从字符串模板到消息模板
PromptTemplate处理的是“单段文本”,适合补全、生成这类场景。但真正在做聊天机器人、Agent任务时,你面对的是多轮对话结构——系统设定、用户输入、历史消息、模型回复,它们是不同角色的消息,混在一起反而丢掉了语义信息。这时候就需要ChatPromptTemplate。
from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的数学辅导老师,擅长用逐步推理的方式讲解题目。"), ("user", "请帮我解这道题:{question}"), ])这里每个元素是一个元组,第一个元素是消息角色,第二个是内容模板。角色包括system、user、assistant、human、ai等。用这种方式组织提示词,信息层次非常清楚:系统设定和用户问题是分离的,模型在训练时见过的就是这个格式,所以理解起来比一段混合文本更稳定。
实际项目里,我几乎不直接传字符串模板给ChatPromptTemplate。我习惯把系统提示和用户提示分成两个模板类,分别定义在独立的模块里,再组合成最终的ChatPromptTemplate。比如:
from langchain_core.prompts import ChatPromptTemplate system_template = "你是一个{role},擅长{skill}。请遵循以下原则:{principles}" user_template = "我的需求是:{request}。背景信息:{context}" prompt = ChatPromptTemplate.from_messages([ ("system", system_template), ("user", user_template), ])这样改系统设定时不需要动用户模板,改用户模板时也不会影响系统策略。后来我维护一个多语言版本的对话应用,这种拆分方式省了大力气——每种语言只需要替换各自的模板文件,组装逻辑完全不变。
3.2 Partial变量:提前固定,延迟注入
PromptTemplate和ChatPromptTemplate都支持partial_variables。这个功能在两种场景下特别有用。
第一种场景,是变量在模板生成前就已经确定,但你不希望每次调用时都重复传。比如系统提示里固定要包含当前日期:
from datetime import datetime def get_current_date(): return datetime.now().strftime("%Y年%m月%d日") prompt = PromptTemplate.from_template( "今天是{date},请根据今天的日期,帮我安排{task}。" ).partial(date=get_current_date)调用的时候只需要传task:
formatted = prompt.format(task="下周的工作计划")第二种场景更常见——模板组合。你构造了一个子模板作为另一个模板的一部分,子模板的变量需要提前固定,避免在最终调用时暴露过多参数。比如多智能体系统里,每个智能体的角色描述是固定的,但任务内容是动态的。你可以把角色描述通过partial注入,然后在组装大模板时只暴露任务相关变量。
需要注意一个问题:partial变量和模板字符串中的变量名必须完全一致,否则format时依然会报缺变量。这个错误信息很明确,一般不会卡太久。
3.3 from_template的边界:什么时候不适合用字符串模板
from_template很好用,但它擅长的是“模板字符串中使用变量”的场景。一旦变量内容本身是结构化数据(比如一个对象、一个JSON片段),在模板里用{变量}的方式注入,经常会出现格式问题。
我举个例子。你在做一个数据分析助手,需要把数据库返回的记录转成提示词上下文。如果记录是一个列表,直接用{records}注入,出来的文本可能是Python的repr格式——带引号、带括号,根本不是自然语言。模型虽然能硬着头皮读,但效果时好时坏。
这种情况下,我建议在组装模板之前先用代码处理好变量格式,把它转成字符串再注入:
records_text = "\n".join( f"记录{idx}:名称={r['name']},数值={r['value']}" for idx, r in enumerate(records) ) prompt = ChatPromptTemplate.from_messages([ ("system", "你是数据分析助手,根据用户提供的记录回答问题。"), ("user", "以下是相关记录:\n{records}\n\n请回答:{question}"), ])这是我在实际项目里踩过坑之后养成的习惯:模板只负责组装结构,数据预处理必须在外部完成。不要在模板里塞repr、列表推导、条件判断,这些逻辑放代码里更清晰、更可测试。
4. 新手必看:变量、参数与输出解析的深度绑定
4.1 模板变量与输出格式的联动设计
我先问你一个问题:你写提示词时,有没有把“输出格式”写进模板里?
很多初学者写提示词,只写任务目标,不限定输出格式。模型自由发挥的结果就是,同一套逻辑下一会儿输出Markdown列表,一会儿输出JSON,一会儿又是散文。这在大模型应用里是致命伤——因为后续代码要解析输出,格式不稳定等于没法解析。
更聪明的做法,是把输出格式直接设计成模板的一部分。比如:
answer_template = """ 你是智能客服,请根据用户问题给出简洁回答。 用户问题:{question} 请以JSON格式输出,包含两个字段: - "answer": 回答内容 - "confidence": 置信度,取值范围0到1 直接输出JSON,不要输出解释。 """有了这个模板,配合StructuredOutputParser使用,就能把模型输出直接解析成结构化数据。
但这里有个容易被忽略的点:模板里约定的格式越具体,模型输出就越稳定。只写“输出JSON”和写出完整的字段说明,效果差别很大。我实测下来的经验是,给出示例值比只写字段名更管用:
- "answer": "请前往门店出示会员码" - "confidence": 0.92模型在few-shot示例的引导下,输出格式的错误率会明显降低。这一条建议值得直接抄进你自己的模板设计规范里。
4.2 OutputParser与模板变量的配合
LangChain里,输出解析器和提示词模板往往是成对出现的。比较常用的是PydanticOutputParser,它用一个Pydantic模型定义输出的结构,然后自动生成格式说明并追加到提示词里。
from pydantic import BaseModel, Field from langchain_core.output_parsers import PydanticOutputParser class AnswerModel(BaseModel): answer: str = Field(description="回答内容") confidence: float = Field(description="置信度,0到1之间") parser = PydanticOutputParser(pydantic_object=AnswerModel) prompt = ChatPromptTemplate.from_messages([ ("system", "你是智能客服,输出要求如下:\n{format_instructions}"), ("user", "用户问题:{question}"), ])format_instructions可以通过parser.get_format_instructions()获取,然后通过partial_variables注入模板。这样提示词里会自动包含“必须输出合法JSON,字段为answer和confidence”的说明,你不用手写这段JSON格式提示。
这里有个细节值得注意:format_instructions生成的内容通常包含了“必须符合给定JSON schema”这类说明,加在system消息里比加在user消息里效果好。我一开始放在user消息里,模型偶发会把这个说明误认为是用户需求的一部分,输出里就出现了多余的废话。放到system消息后,这类问题明显少了。原理上,system消息对模型行为的约束力更强,应该装“规矩”,而user消息只放“任务”。
4.3 变量过多时如何保持模板可读性
当一个提示词模板的变量超过6个,可读性会急剧下降。你光看模板字符串,根本不知道每个变量会渲染成什么内容,更别提调试了。
我的习惯是,变量超过4个就做一次“模板拆分”。比如一个复杂的文案生成任务,我会拆成_template加_context两个部分:
prompt = ChatPromptTemplate.from_messages([ ("system", "你是{domain}领域的资深文案专家。"), ("user", "请基于以下产品信息生成{style}风格的文案。\n\n产品信息:\n{product_info}\n\n目标用户:\n{target_audience}\n\n重点要求:\n{requirements}"), ])虽然变量总数还是5个,但布局是分层的:第一段定义角色,第二段是任务和上下文。后续调试时,我只需要检查product_info和target_audience这两块核心内容是否正确组装。
另外,我强烈建议在模板字符串里保留清晰的段落分隔符和字段名。比如“产品信息:”“目标用户:”这样的显式标签,不只是给人看的,也是给模型看的。模型在填充时更容易区分每条信息的边界,输出更稳定。
5. Few-shot示例的高级玩法:从示例选择到动态组装
5.1 Few-shot模板的基本结构
Few-shot是提升模型输出质量的高性价比手段。LangChain里,FewShotChatMessagePromptTemplate是专门干这个的。它的基本结构分为:示例集、示例提示词模板、主消息模板。
from langchain_core.prompts import ( ChatPromptTemplate, FewShotChatMessagePromptTemplate, ) examples = [ {"input": "这家餐厅的菜真难吃", "sentiment": "负面"}, {"input": "快递小哥服务特别好", "sentiment": "正面"}, {"input": "电影一般般吧", "sentiment": "中性"}, ] example_prompt = ChatPromptTemplate.from_messages([ ("user", "{input}"), ("assistant", "{sentiment}"), ]) few_shot_prompt = FewShotChatMessagePromptTemplate( example_prompt=example_prompt, examples=examples, ) final_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个情感分析助手,请判断用户输入的情感倾向。"), few_shot_prompt, ("user", "{input}"), ])这样构造出来的消息序列是:
- system:情感分析助手
- user:这家餐厅的菜真难吃
- assistant:负面
- user:快递小哥服务特别好
- assistant:正面
- user:电影一般般吧
- assistant:中性
- user:{input}
模型看到的是完整的“问答”模式,比你只丢一句“请判断情感倾向”要稳得多。
5.2 示例选择的坑:不是越多越好
我试过把几十个示例全都塞进提示词里,结果有两个副作用:一是Token消耗暴增,二是模型注意力被大量示例稀释,反而抓不住核心规律。实际项目中,5个左右高质量示例基本就够了,关键是示例要覆盖不同的边缘场景。
比如情感分析任务,如果示例全是负面评价,模型对正面判断的准确率会明显偏低。所以我至少保证正面、负面、中性、带讽刺语气、带反问语气各一个示例。覆盖面比数量重要。
5.3 按需动态选择示例:向量检索 + 语义相似度
固定示例的缺陷在于无法适配所有输入。如果你想根据用户的每个具体问题动态选择最相似的几个示例,LangChain里也有对应方案——FewShotPromptTemplate的example_selector。
from langchain_core.example_selectors import SemanticSimilarityExampleSelector from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import InMemoryVectorStore example_selector = SemanticSimilarityExampleSelector.from_examples( examples, OpenAIEmbeddings(), InMemoryVectorStore, k=3, )思路很简单:把每个示例向量化,用户输入来了之后取向量相似度最高的前k个示例,动态组装成提示词。这个方案的优势非常明显——对于任务类型复杂的系统,不同输入匹配到的示例不同,提示词每次都是“量身定制”的。
但要注意,这个方案有额外的延迟和成本,因为它要做一次向量检索。对于高并发的接口,我通常会在检索结果上做缓存,比如对用户输入做哈希,短时间内相同输入直接复用检索结果。缓存过期的策略可以根据业务场景自己调,但核心思想是别让每次请求都重复算一遍向量相似度。
6. 实践案例:一个可维护的“智能客服”提示词工程
6.1 从需求分析到模板设计
我拿一个实际项目来演示完整流程。需求是做一个电商智能客服,能回答订单、物流、退换货相关问题,且必须输出JSON格式供前端渲染。
第一步,确定消息角色分工:
- system:身份和规则。
- user:用户当前问题和上下文信息。
- assistant(可选):上一轮模型回复,用于多轮对话。
第二步,设计模板。我的做法是拆分模块,让每个部分可以独立修改:
system_base = "你是一个电商平台客服助手,请根据用户问题和订单信息给出准确回复。" system_rules = """ 回复规则: 1. 不得编造订单信息,不确定的信息必须说明“需要核实”。 2. 回答要简洁,不超过200字。 3. 输出必须为JSON格式,包含answer、intent两个字段。 """ order_context = """ 订单信息参考(可能不完整,仅供参考): {order_info} """第三步,组装:
prompt = ChatPromptTemplate.from_messages([ ("system", system_base + "\n" + system_rules), ("user", order_context + "\n\n用户问题:{user_input}"), ])order_info和user_input是核心变量。order_info在调用前由后端查询并格式化,模板里不做任何处理。
6.2 Few-shot示例如何嵌入客服场景
给客服场景加了几个典型示例,效果提升非常直接。比如用户问“我的快递到哪了”,如果只有纯模板,模型可能输出一大段查询建议。但如果你给了一个示例:
- user:我的快递到哪了?
- assistant:{"answer": "您的订单正在运输中,预计明天送达。", "intent": "order_status"}
模型就会学着用同样简洁、同样JSON的格式回复。这就是示例的力量。
如果感觉示例太多太长,也可以像前面说的用动态示例选择,把示例库放在向量存储里按需提取。在客服场景中,我建议把高频问题做成示例库,比如订单查询、退款进度、物流异常,每个类别放2个示例,效果和性价比比较平衡。
6.3 LangSmith追踪与可视化调试
当你开始接触复杂的Prompt组合,肉眼检查已经不够用了。LangSmith可以记录每一次调用的完整链路,包括Prompt模板的渲染结果、模型的原始输出、解析后的结构化数据。调试的时候直接看Link里的渲染结果,比在本地一遍遍打印要高效得多。
实际排查一个“模型输出JSON格式但解析失败”的问题时,我在LangSmith里看到了完整渲染后的Prompt,发现是order_info那段文本太长,把JSON格式说明挤到了模型注意力的边缘。后来我把格式说明在模板里重复了两次,一次在system里,一次在示例里,解析失败率直接降了一半。这个经验,不用追踪工具很难发现。
7. 常见报错与实战速查表
7.1 Missing variables:变量缺失与拼写错误
报错形式通常是:
KeyError: 'Missing variables: xxx'解决思路:检查模板字符串里{}中的变量名和format/invoke时传的Key是否一致。特别注意带下划线的变量名,比如user_input和userinput,肉眼很难分辨。我一开始经常卡在这种低级错误上,后来习惯是模板里所有的变量名统一用蛇形命名,在代码里定义常量,杜绝手写字符串。
7.2 字面花括号被误解析
在模板里写JSON示例时,用{{和}}转义:
template = "请输出JSON格式:{{'answer': '{answer}'}}"如果不转义,{'answer': ...}会被当成变量定义导致报错。这个坑相当常见,尤其是从普通字符串拼接转LangChain模板的第一天。
7.3 模型输出包含Markdown代码块
即使模板里说了“不要输出解释”,模型依然可能输出:
```json {"answer": "..."}这种输出直接交给我解析,十有八九要出问题。解决办法是用`StructuredOutputParser`配套处理,或者在解析前做一次字符串清理。我习惯写一个通用函数,把首尾的```json标记剥掉再`json.loads`。注意,**不要指望模型100%遵守格式规则,输出解析这层防御必须自己做**。 ### 7.4 长提示词截断 模板里的示例太多,或者`order_info`过长,容易触发上下文窗口限制。模型表现出的症状不是报错,而是输出内容变短、格式乱、甚至直接复述模板里的话。排查时先确认Token用量,再考虑精简示例或压缩上下文。 一个实用的压缩方案:`contextual_compression`检索器可以把长文档压缩成与问题最相关的几个句子。但在客服这种强上下文场景里,我更多是建议业务侧精简订单信息字段,只保留和用户问题相关的部分,不要一股脑全塞进去。 ### 7.5 问题速查表 | 症状 | 可能原因 | 快捷排查方法 | | --- | --- | --- | | 模型输出全是模板原话 | 变量没注入成功,模板里有未定义变量 | 检查`invoke`参数与`input_variables` | | JSON解析报错 | 输出被Markdown包裹或有前缀说明 | 添加输出解析器,增加cleaning步骤 | | 输出风格漂移 | 系统规则写得太泛 | 细化system消息,加few-shot示例 | | 响应长度不稳定 | 模板里没限制长度或示例长度差异过大 | 在system里显式写“不超过xx字” | | 变量被吞掉 | 模板字符串里空格或换行导致变量拼接异常 | 检查模板渲染结果,用LangSmith定位 | ## 8. 模板版本管理和多环境部署心得 ### 8.1 把模板当作代码来管理 我见过很多团队,提示词散落在代码、配置文件、数据库甚至某个不为人知的在线文档里。这种管理方式在早期能跑,但只要业务一复杂,马上失控。 我的做法是:**把模板文件单独放一个目录,按领域划分**,比如: ```text prompts/ ├── customer_service/ │ ├── order_query.py │ ├── return_refund.py └── content_generation/ ├── ad_copy.py ├── blog_outline.py每个模块里定义模板对象,不直接在其他业务代码里写模板字符串。这样模板的修改可以通过代码审查和版本控制来把关,不会出现“线上的提示词到底是谁改的”这种问题。
8.2 模板内容的灰度调整
提示词改动往往影响面很大。我在这方面踩过坑:一次性把客服的提示词全换成了新的,结果输出风格突变,用户反馈“感觉换了个客服”。后来改成灰度策略:系统里同时保留新旧两个模板,用流量比例控制逐渐切换。LangChain的模板对象是普通的Python对象,完全可以用代码来控制路由逻辑,不会增加额外成本。
8.3 测试用例沉淀
建议给每个模板配一个小型测试集,包含典型的输入、期望的输出字段、必现的格式规则。测试方法很简单——用固定模型跑几个用例,检查输出能否被解析器正常解析。随着模板迭代,这些测试用例会变成你最宝贵的回归保障。
9. 我最后想强调的几个细节
回到开头那个场景。如果你现在还在用字符串拼接写提示词,我给的建议很直接:花半天时间把现有代码里的Prompt都改成PromptTemplate或ChatPromptTemplate,你会感受到两个实实在在的变化。
第一,变量错误从“运行时才知道”变成了“启动时就被拦截”。第二,模板和业务代码分离以后,改提示词不再需要动主流程,哪怕是非技术人员也能通过修改模板文件参与调优。
关于变量命名,再多说一句。模板里的变量名要有语义,{question}比{q}合适,{order_info}比{info}合适。因为模板字符串会被模型读到,语义清晰的变量名有助于模型理解任务结构。
最后分享一个技巧:调试模板时不要直接丢给模型试,先把模板渲染结果打出来看看。
print(prompt.format_messages(order_info="...", user_input="..."))先确认渲染结果没问题,再调用模型。这一步能帮你省掉大量无效排查。自己看一眼渲染后的文本,很多问题一眼就能发现。祝你的提示词工程越写越顺。