三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

LangChain提示词工程:从字符串拼接升级到组合流水线开发范式

LangChain提示词工程:从字符串拼接升级到组合流水线开发范式

1. 项目概述:从“字符串拼接”到“组合流水线”的范式跃迁

如果你正在用LangChain构建大语言模型应用,并且还在用Python的f-string或者简单的字符串拼接来组装提示词,那么这篇文章就是为你准备的。我经历过那个阶段:为了给模型一个清晰的指令,代码里充斥着各种{variable}的占位符和+操作符,调试起来像在玩“大家来找茬”,一旦需求变动,改起来更是牵一发而动全身。这本质上还是手工作坊式的开发。

直到我系统性地将提示词模板升级为“组合流水线”模式,整个开发体验和应用的健壮性才发生了质变。这不仅仅是换个写法,而是一种工程思维的转变。简单来说,提示词模板(Prompt Template)是LangChain提供的一个基础组件,它帮你把动态变量和固定指令模板分离开,避免了硬编码。而组合流水线(Composition Pipeline)则是更高阶的用法,它通过将多个模板、处理步骤(如格式转换、示例选择)、甚至其他LangChain组件(如检索器、输出解析器)串联起来,形成一个可复用、可测试、可维护的工作流。

为什么这个转变如此重要?想象一下,一个复杂的问答系统,提示词可能包含:系统角色设定、从知识库检索到的上下文、用户的历史对话、以及当前问题。如果全靠拼接,代码会臃肿且脆弱。而流水线模式允许你将“构建系统指令”、“注入上下文”、“格式化历史记录”和“组装最终提问”这几个步骤解耦,每个步骤独立可控,就像工厂的装配线,清晰、高效且易于扩展。这正是LangChain设计哲学的核心——通过组合(Composition)来构建复杂应用。接下来,我将带你从最基础的模板使用,一步步拆解如何搭建属于你自己的提示词流水线。

2. 核心需求解析:我们为何需要超越字符串拼接?

在深入技术细节前,我们必须先厘清一个根本问题:为什么简单的字符串拼接在LLM应用开发中会迅速变得捉襟见肘?这源于LLM应用本身复杂度的几个内在维度。

2.1 动态性与上下文依赖一个实用的LLM应用,其提示词极少是静态的。它通常依赖多种动态输入:

  • 用户输入:最直接的变量。
  • 检索到的上下文(RAG场景):从向量数据库查出的相关文档片段,每次查询都不同。
  • 对话历史:在多轮对话中,需要将历史消息以特定格式(如Human: ...\nAssistant: ...)嵌入当前提示。
  • 工具/函数调用结果(Agent场景):Agent执行工具后,需要将结果格式化并反馈给LLM进行下一步推理。
  • 系统变量:如当前日期、用户名、会话ID等。

用字符串拼接处理这些,代码会迅速退化为难以阅读和维护的“面条代码”。你需要小心翼翼地处理换行符、缩进和JSON格式,一个引号错误就可能导致模型输出完全偏离预期。

2.2 可维护性与团队协作当提示词逻辑变得复杂时,比如需要根据不同场景(简单问答、深度分析、代码生成)切换不同的指令模板,或者需要为同一功能提供中英文版本。如果这些逻辑散落在各个业务函数中,任何修改都将是灾难。你需要一个中心化的方式来管理这些模板,确保一致性。此外,清晰的模块化划分也让团队协作更顺畅,前端工程师、算法工程师和产品经理可以更清晰地定义接口。

2.3 可测试性与迭代优化提示词工程本身是一个需要不断实验和迭代的过程。你需要能方便地A/B测试不同版本的提示词,观察哪个能带来更高的回答准确率或更低的Token消耗。当提示词是硬编码的字符串时,替换和对比测试非常笨拙。而模板化之后,你可以将模板视为可配置的“参数”,轻松进行批量测试和效果评估。

2.4 结构化输出与后处理现代LLM应用往往不满足于获取一段自由文本。我们可能需要模型输出结构化的数据,比如一个JSON对象,包含answerconfidencecitations字段。字符串拼接很难优雅地引导模型输出固定格式,并对输出进行解析和验证。这需要将提示词模板与输出解析器(Output Parser)组合使用,形成一个完整的“输入-处理-输出”流水线。

基于以上痛点,LangChain的提示词模板和组合流水线模式,正是为了解决这些工程化问题而生的。它不是“语法糖”,而是构建生产级LLM应用的必备基础设施。

3. LangChain提示词模板基础与实战

理解了“为什么”,我们来看“怎么做”。首先从最基础的PromptTemplate开始。很多人以为它只是个简单的格式化工具,其实不然,它的设计蕴含了组合性的基因。

3.1 基础PromptTemplate:告别f-string最基本的用法是定义一个带有占位符的模板字符串。

from langchain_core.prompts import PromptTemplate # 定义一个简单的模板 template = “你是一个专业的翻译助手。请将以下英文翻译成中文:\n\n英文:{input_text}” prompt_template = PromptTemplate.from_template(template) # 格式化生成最终提示词 formatted_prompt = prompt_template.format(input_text=“Hello, world!”) print(formatted_prompt)

输出:

你是一个专业的翻译助手。请将以下英文翻译成中文: 英文:Hello, world!

这看起来和f-string (f“你是一个...{input_text}”) 效果一样。但关键区别在于,PromptTemplate是一个对象。它可以被序列化、存储、传递,并且能与LangChain的其他组件(如LLM、Chain)无缝集成。这是构建流水线的第一步——将提示词“组件化”。

3.2 多变量与部分格式化实际场景中,变量往往不止一个。模板可以轻松处理多个变量。

qa_template = “””基于以下上下文,回答问题。如果你不知道答案,就说不知道,不要编造。 上下文: {context} 问题:{question} 答案:””” prompt = PromptTemplate.from_template(qa_template) # 假设我们从数据库获取了context retrieved_context = “LangChain是一个用于开发由语言模型驱动的应用程序的框架...” user_question = “LangChain是什么?” final_prompt = prompt.format(context=retrieved_context, question=user_question)

更强大的功能是部分格式化(Partial)。有些变量你可能在流水线的早期阶段就已知,而另一些则需要稍后填充。例如,系统指令可能一开始就固定了,但用户问题需要运行时获取。

from langchain_core.prompts import PromptTemplate # 定义一个包含多个变量的模板 template = “系统指令:{system_message}\n用户输入:{user_input}” prompt = PromptTemplate.from_template(template) # 部分格式化:先提供system_message partial_prompt = prompt.partial(system_message=“你是一个乐于助人的助手。”) # 此时partial_prompt是一个新的模板,它只剩下user_input一个待填变量 # 在后续流程中,再填入user_input final_prompt = partial_prompt.format(user_input=“今天天气怎么样?”)

这个特性在构建链(Chain)时极其有用,它允许你预先配置好链的部分参数。

3.3 模板的序列化与持久化对于需要团队共享或频繁迭代的提示词,将其存储在代码之外是更好的实践。LangChain支持将PromptTemplate保存为JSON或YAML文件。

# 保存模板 prompt.save(“awesome_prompt.json”) # 在其他地方加载模板 loaded_prompt = PromptTemplate.load(“awesome_prompt.json”)

你可以将这些JSON文件纳入版本控制(如Git),方便追踪每次提示词优化的变更历史,实现真正的“提示词即代码(Prompt as Code)”。

注意PromptTemplate默认使用Jinja2语法,这意味着你可以在模板中使用一些简单的控制逻辑(如if-else判断),但这会增加复杂度,通常建议将复杂逻辑放在Python代码中,保持模板的简洁和可读性。

4. 构建组合流水线:从模板到工作流

单个模板解决了基础格式化问题,但真正的威力在于组合。LangChain的核心抽象——链(Chain),就是用来将多个组件(包括提示词模板、LLM、输出解析器等)连接成流水线的工具。

4.1 最简单的链:LLMChainLLMChain是提示词模板与大语言模型的第一次组合。它抽象了“格式化提示词 -> 调用LLM -> 获取输出”这个过程。

from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate # 更适用于聊天模型的模板 from langchain.chains import LLMChain # 1. 定义聊天提示模板(ChatPromptTemplate更擅长处理消息角色) prompt = ChatPromptTemplate.from_messages([ (“system”, “你是一个严谨的历史学家。”), (“human”, “请用{style}的风格,简述{topic}的历史。”) ]) # 2. 初始化LLM llm = ChatOpenAI(model=“gpt-4”, temperature=0.7) # 3. 创建链 chain = LLMChain(llm=llm, prompt=prompt) # 4. 运行链 result = chain.invoke({“style”: “生动有趣”, “topic”: “罗马帝国”}) print(result[“text”]) # LLMChain的返回结果是一个字典,包含输入、提示词和输出文本

LLMChain将模板和模型绑定在一起,你只需要关心输入变量。这已经比手动调用前进了一大步。

4.2 引入输出解析器:获得结构化结果很多时候,我们需要模型输出JSON、列表等结构化数据,而不是纯文本。OutputParser就派上用场了。我们可以创建一个“提示词模板 + LLM + 输出解析器”的流水线。

from langchain_core.output_parsers import PydanticOutputParser from langchain_core.pydantic_v1 import BaseModel, Field from langchain_core.prompts import PromptTemplate # 1. 定义我们期望的数据结构 class BookSummary(BaseModel): title: str = Field(description=“书名”) author: str = Field(description=“作者”) summary: str = Field(description=“不超过100字的摘要”) genre: str = Field(description=“书籍类别”) # 2. 基于Pydantic模型创建解析器 parser = PydanticOutputParser(pydantic_object=BookSummary) # 3. 构建提示词模板,并自动注入输出格式指令 template = “””请根据用户描述,提取书籍信息。 {format_instructions} 用户描述:{description} “”” # get_format_instructions()方法会生成一段告诉模型如何输出格式的文本 prompt = PromptTemplate( template=template, input_variables=[“description”], partial_variables={“format_instructions”: parser.get_format_instructions()} ) # 4. 创建链(这里我们手动串联,实际可用LCEL) from langchain_openai import ChatOpenAI llm = ChatOpenAI(model=“gpt-3.5-turbo”) # 链的逻辑:prompt -> llm -> parser chain = prompt | llm | parser # 使用LangChain表达式语言(LCEL) # 5. 运行 description = “这是一本刘慈欣写的科幻小说,讲的是地球人类文明和三体文明的信息交流、生死搏杀及两个文明在宇宙中的兴衰历程。” result = chain.invoke({“description”: description}) print(result) # 输出:BookSummary(title=‘三体’, author=‘刘慈欣’, summary=‘...’, genre=‘科幻小说’)

这个流水线确保了输出永远是我们定义的BookSummary对象,后续代码可以直接使用result.titleresult.genre等属性,极大地提升了下游处理的可靠性。

4.3 使用LangChain表达式语言(LCEL)构建复杂流水线LCEL是LangChain中声明式组合组件的推荐方式,它使用管道操作符|,让流水线的构建像搭积木一样直观且强大。上面的例子已经初步展示了LCEL。让我们看一个更复杂的例子,包含条件判断和分支。 假设我们有一个需求:根据用户问题的复杂度,决定是直接回答,还是先联网搜索再回答。

from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from langchain_community.tools import DuckDuckGoSearchRun from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnableBranch, RunnableLambda # 定义组件 llm = ChatOpenAI(model=“gpt-3.5-turbo”) search = DuckDuckGoSearchRun() output_parser = StrOutputParser() # 子链1:直接回答的链 direct_template = ChatPromptTemplate.from_messages([ (“system”, “你是一个知识渊博的助手。”), (“human”, “{question}”) ]) direct_chain = direct_template | llm | output_parser # 子链2:先搜索再回答的链 search_template = ChatPromptTemplate.from_messages([ (“system”, “你是一个助手。请根据以下搜索结果为用户提供答案。如果搜索结果不相关或不足,请基于你自己的知识回答。\n\n搜索结果:{search_results}”), (“human”, “问题:{question}”) ]) def get_search_results(question: str) -> dict: “”“执行搜索并返回结果。”“” search_result = search.run(question) return {“search_results”: search_result, “question”: question} # 组合:获取搜索 -> 格式化提示词 -> 调用LLM -> 解析输出 search_chain = RunnableLambda(get_search_results) | search_template | llm | output_parser # 路由链:判断问题是否需要搜索 def route_question(state: dict): “”“根据问题内容决定路由。这是一个简单的启发式规则。”“” question = state[“question”].lower() need_search_keywords = [“最新”, “新闻”, “2024”, “今天”, “当前”] if any(keyword in question for keyword in need_search_keywords): return “search” else: return “direct” # 使用RunnableBranch构建分支流水线 branch = RunnableBranch( (lambda x: route_question(x) == “search”, search_chain), direct_chain ) # 主流水线:输入问题 -> 分支路由 -> 执行对应链 full_chain = {“question”: lambda x: x[“question”]} | branch # 运行 result1 = full_chain.invoke({“question”: “勾股定理是什么?”}) print(f“直接回答:{result1}”) result2 = full_chain.invoke({“question”: “今天国际油价最新行情是多少?”}) print(f“搜索后回答:{result2}”)

这个例子展示了LCEL的强大之处:通过RunnableBranchRunnableLambda,我们可以构建有判断、有分支、有组合的复杂工作流,并且每个步骤都清晰可辨。这才是真正的“组合流水线”。

5. 高级模式与最佳实践

掌握了基础组合后,我们来看一些能进一步提升效率和质量的高级模式和实战心得。

5.1 少量示例(Few-Shot)模板对于需要引导模型输出特定格式或风格的任务,在提示词中提供几个示例(Few-Shot)非常有效。FewShotPromptTemplate可以优雅地管理这些示例。

from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate # 1. 定义示例集合 examples = [ { “input”: “这部电影的视觉效果很棒,但剧情太拖沓。”, “output”: “正面:视觉效果。负面:剧情拖沓。” }, { “input”: “电池续航惊人,系统流畅,就是屏幕有点小。”, “output”: “正面:电池续航,系统流畅。负面:屏幕小。” } ] # 2. 定义单个示例的格式化模板 example_prompt = PromptTemplate( input_variables=[“input”, “output”], template=“输入:{input}\n输出:{output}” ) # 3. 创建FewShotPromptTemplate few_shot_prompt = FewShotPromptTemplate( examples=examples, example_prompt=example_prompt, prefix=“你的任务是将用户评论总结为正面和负面要点。请参考以下示例:”, suffix=“输入:{user_input}\n输出:”, input_variables=[“user_input”], example_separator=“\n\n” # 示例之间的分隔符 ) # 4. 使用 formatted_prompt = few_shot_prompt.format(user_input=“餐厅环境优雅,服务热情,但菜品偏咸。”) print(formatted_prompt)

这种方式将示例数据与提示词逻辑分离,方便你通过增删示例来调整模型行为,而无需改动核心代码。

5.2 动态示例选择器当示例库很大时,并非所有示例都相关。ExampleSelector可以根据输入动态选择最相关的几个示例,提升效果并节省Token。

from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate from langchain_core.example_selectors import SemanticSimilarityExampleSelector from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma # ... 假设我们有大量的examples ... # 基于语义相似度选择示例 example_selector = SemanticSimilarityExampleSelector.from_examples( examples, OpenAIEmbeddings(), Chroma, k=2 # 每次选择2个最相关的示例 ) dynamic_prompt = FewShotPromptTemplate( example_selector=example_selector, # 使用选择器而非固定示例列表 example_prompt=example_prompt, prefix=“...”, suffix=“...”, input_variables=[“user_input”] ) # 对于不同的user_input,动态生成的提示词中将包含不同的示例。

5.3 提示词管理:外部化与版本控制对于生产环境,强烈建议将提示词模板外部化。你可以:

  1. 使用配置文件(YAML/JSON):将模板字符串和变量定义放在配置文件中。
  2. 使用数据库:对于需要动态更新、多租户或A/B测试的场景,将模板存储在数据库中是更灵活的选择。
  3. 结合LangChain Hub(实验性):LangChain社区提供了一个共享提示词的平台,你可以从中查找和加载他人分享的优质模板。

一个简单的YAML配置示例prompts/config.yaml

translation_prompt: template: | 你是一个专业的{domain}翻译。请将以下{source_lang}文本翻译成{target_lang},并保持专业术语准确。 原文:{text} 翻译: input_variables: [“domain”, “source_lang”, “target_lang”, “text”] default_values: domain: “通用”

然后在代码中加载:

import yaml with open(“prompts/config.yaml”, ‘r’) as f: prompt_configs = yaml.safe_load(f) template_str = prompt_configs[‘translation_prompt’][‘template’] # ... 创建PromptTemplate ...

5.4 性能优化与调试

  • Token计数与成本控制:在调用LLM前,使用get_num_tokens方法估算提示词的Token消耗,对于长上下文管理至关重要。
    from langchain_openai import ChatOpenAI llm = ChatOpenAI() num_tokens = llm.get_num_tokens(formatted_prompt) print(f“提示词大约消耗 {num_tokens} tokens。”)
  • 结构化日志与追踪:使用LangSmith(LangChain官方平台)或自定义日志,记录每次链调用时的输入、输出和中间步骤(尤其是格式化后的提示词)。这是调试复杂流水线不可或缺的。
  • 缓存:对于频繁出现且结果不变的子步骤(如某些固定的信息提取),可以考虑引入缓存机制(如LangChainRunnableWithFallbacks或外部缓存如Redis)来降低成本和延迟。

6. 常见陷阱与排查指南

在实际构建流水线的过程中,我踩过不少坑。这里总结几个最常见的问题和解决方法。

6.1 变量不匹配错误

  • 问题:运行chain.invoke时抛出KeyError,提示某个变量在模板中未找到。
  • 原因:提供给formatinvoke的输入字典的键,与模板中定义的input_variables不匹配。
  • 排查
    1. 打印出你的PromptTemplate对象,检查input_variables属性。
    2. 确保你传入的字典包含了所有必需的变量。对于使用partial的模板,确认哪些变量已被填充,哪些仍需提供。
    3. 注意变量名拼写和大小写,必须完全一致。

6.2 提示词格式化后格式混乱

  • 问题:模型输出不符合预期,可能是提示词中的换行、缩进在格式化后被破坏了。
  • 原因:在模板字符串中使用三重引号时,Python会保留所有缩进。如果模板定义在函数内,函数体的缩进会混入模板。
  • 解决:使用textwrap.dedent或在模板字符串起始处使用反斜杠\来消除不必要的缩进。
    from textwrap import dedent template = dedent(“”” 你是一个助手。 请回答以下问题: {question} “””) # dedent会移除每行开头共同的空白符
    或者:
    template = ( “你是一个助手。\n” “请回答以下问题:\n” “{question}” )

6.3 LCEL链调试困难

  • 问题:一个复杂的LCEL流水线报错,难以定位是哪个环节出了问题。
  • 排查
    1. 使用.stream().invoke()中间结果:将长链拆分成小段,分别测试每段的输入输出。
    2. 利用with_config添加元数据:为链的某个步骤添加标签,方便在LangSmith等追踪工具中识别。
      from langchain_core.runnables import RunnableConfig debug_chain = some_component.with_config(run_name=“MyDebugStep”)
    3. 捕获和检查中间状态:对于RunnableLambda,确保函数有清晰的日志输出或能处理异常。

6.4 输出解析失败

  • 问题OutputParser抛出OutputParserException,提示无法解析LLM的输出。
  • 原因:LLM没有严格按照format_instructions中的要求输出。可能是指令不够清晰,或任务对模型来说太复杂。
  • 解决
    1. 强化指令:在提示词中更明确地强调输出格式,甚至使用“你必须输出一个合法的JSON字符串”等强约束语句。
    2. 提供更清晰的示例:在Few-Shot示例中展示完美的输出格式。
    3. 使用更强大的模型:对于复杂的结构化输出任务,GPT-4通常比GPT-3.5表现更稳定。
    4. 使用容错性更强的解析器:如JsonOutputParser(尝试解析JSON)配合OutputFixingParser,后者可以尝试让另一个LLM来修复格式错误的输出。
      from langchain.output_parsers import OutputFixingParser from langchain_openai import ChatOpenAI fixing_parser = OutputFixingParser.from_llm(parser=original_parser, llm=ChatOpenAI())

6.5 性能瓶颈

  • 问题:流水线运行缓慢。
  • 排查
    1. 分析各步骤耗时:使用追踪工具或简单计时,找出是哪个组件(LLM调用、检索、复杂计算)最耗时。
    2. 优化提示词长度:冗长的提示词会增加Token消耗和LLM响应时间。定期审查模板,移除不必要的指令和示例。
    3. 并行化:如果流水线中有多个独立的步骤(例如,同时调用多个不同的工具),可以考虑使用RunnableParallel来并行执行。
    4. 缓存:如前所述,对确定性高的步骤引入缓存。

从字符串拼接到组合流水线,不仅仅是技术的升级,更是开发范式的转变。它迫使你将LLM应用视为由一个个可测试、可复用、可监控的组件构成的数据流,而非一堆胶水代码。我个人最大的体会是,一旦习惯了这种思维,开发复杂AI应用的信心和效率会成倍增长。开始可能会觉得LCEL或Runnable抽象有些绕,但一旦上手,你就会发现它带来的清晰度和可维护性是原始拼接方式无法比拟的。不妨从将一个现有的字符串拼接提示词改写成PromptTemplate开始,再逐步尝试将其与一个LLM和简单的Parser组合成链,你会立刻感受到其中的差别。

← 返回列表