ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain FewShotPromptTemplate:少样本提示的工程化实践与动态示例选择

LangChain FewShotPromptTemplate:少样本提示的工程化实践与动态示例选择

开场

做提示工程做久了,你会发现一个特别有意思的现象:同一段指令,你写“请严格按照以下格式输出”,模型八成给你加一堆客套话;你把同样的话改成“比如下面这样:输入A,输出B,再来一个:输入C”,它立刻老老实实照着排。这个“给几个例子”的动作,就是少样本提示(few-shot prompting)。而LangChain里的FewShotPromptTemplate,就是把这个动作从“手搓字符串”变成“可管理、可复用、可动态挑选”的工程化组件,属于智能提示工程里性价比最高的一环。

我最初也是直接把示例硬拼进 prompt 里,三个示例还行,到第八个示例、还要按用户输入动态换示例的时候,字符串拼接就开始失控了:变量名对不上、分隔符多了少了、改一个示例要动三处。这篇文章就把我踩过的这些东西整理出来,从FewShotPromptTemplate的设计意图、每个参数的取舍逻辑,到两版可以直接复现的实现(静态版和带语义选择器的动态版),再到实际跑起来会撞上的坑和排查路子,都摊开讲。适合已经能跑通基础的PromptTemplate、想把手上的提示词从“一次性”升级成“可维护资产”的读者,纯新手也能照着抄,因为我会把每一步的意图说清楚。

1. 为什么提示工程需要“少样本”这个中间态

1.1 零样本、少样本、微调三段式里,少样本卡在哪

先把这个谱系捋清楚,不然容易误用工具。零样本(zero-shot)就是你只给任务描述,不给任何参考,模型全凭预训练里学到的模式去猜。它的好处是 prompt 短、成本低、响应快;坏处是不稳定,尤其是输出格式要求严格、或者任务定义带点“行话”的时候,模型经常给你自由发挥。微调(fine-tuning)是另一头,拿几百上千条标注数据把权重改一遍,效果最稳,但成本高、迭代慢、换个小需求就得重训,而且对多数业务场景来说是拿大炮打蚊子。

少样本正好卡在中间:不动权重,只在上下文里塞几条“输入-输出”配对,让模型在推理时“看着办”。它的本质是用上下文做条件约束,把任务从“你猜我要什么”变成“你照这个样子做”。实测下来,在分类、抽取、格式转换、风格模仿这四类任务上,三到五个高质量示例带来的稳定性提升,往往比你把指令翻来覆去改十遍都明显。

但手工维护示例有三个绕不过去的问题:一是示例多了以后 prompt 会变得极长,拼接逻辑混乱;二是不同输入其实需要不同的示例(用户问的是退货,你给的全是物流示例就没用);三是示例和指令、和用户实际输入混在一坨字符串里,改一处错一处。FewShotPromptTemplate 干的就是把这三件事拆开管。

1.2 FewShotPromptTemplate 到底封装了什么

从结构上看它就干一件事:把“示例列表”按统一模板渲染成文本块,再和“前缀指令”“后缀待填输入”拼成一个完整的最终提示。听起来平平无奇,但拆开看,它把四样东西解耦了:

  • examples:示例数据本身,一堆字典,纯数据,和展示格式无关;
  • example_prompt:每个示例长什么样,一个独立的 PromptTemplate,负责把单个字典渲染成一段文本;
  • prefix/suffix:示例块前面和后面的固定内容,suffix 里通常留着真正需要模型回答的那个变量;
  • example_selector:可选,一个选择器对象,负责从大示例库里挑出最合适的几条。

这个拆法的价值在于:你改展示格式不用动数据,改数据不用动指令,换挑选策略不用动前两者。我在一个信息抽取的小项目里,一开始是 8 个示例硬编码在字符串里,后来要按输入语言切换示例集,改了两小时;换成模板化写法之后,只是把 examples 换成一个查表的结果,五分钟搞定。这就是工程化和手搓的差别。

1.3 它真正解决的三类现实问题

第一类是格式顽固型任务。比如要把一段自然语言简历压成固定的 JSON 结构,字段名不能变、不能多不能少。你只写指令,模型总会自作主张加个"summary"字段,或者把数组写成字符串。给两三个“输入→标准 JSON”的示例,格式跑偏率会肉眼可见地掉下来。

第二类是判定标准模糊型任务。比如情感分类里“中性”和“轻微负面”的边界,光靠文字描述永远说不清——“一般般吧”算中性还是负面?给两个边界样本,模型立刻就对齐了你的标准。这类任务里示例的作用不是教知识,是校准尺度的刻度。

第三类是风格与语气对齐。客服回复、文案改写、代码注释风格,这类东西你描述一百个字不如给一句范例。FewShotPromptTemplate 允许你把这批范例做成可切换的“风格包”,A 客户一套、B 客户一套,共用一个模板骨架。

注意:少样本不是越多越好。示例每加一条,输入 token 就涨一截,延迟和成本都跟着走,而且超过一定数量后模型会开始“过度模仿”最后几条示例,反而忽略了指令。我在分类任务上做过粗略对比,三到六条是甜点区,超过十条基本是浪费钱。

2. 核心组件与参数背后的设计逻辑

2.1 example_prompt 决定单个示例长什么样

example_prompt是一个普通的 PromptTemplate,input_variables必须和 examples 里字典的 key 完全对应。这一步是最容易出错的地方,也是整个模板的“细胞”。为什么单独抽出来?因为示例的呈现形式其实有讲究:用“输入: xxx / 输出: yyy”这种对齐格式,模型对结构的识别度通常比一大段自然语言描述高;而如果你要做的是对话式任务,示例就该渲染成多轮对话的样子,那就不能用一个简单模板,得换成带角色标记的写法。

有个细节值得说:示例里的“输出”部分,最好是你真正希望模型产出的完整形态,包括标点、缩进、大小写。我见过有人示例里写{"name": "张三"},指令里却要求输出不带空格的紧凑 JSON,结果模型学会的是带空格的样子。示例是“示范”,不是“示意”,模型会连格式细节一起抄。

另外,示例的数量和多样性要平衡。五条一模一样的示例等于一条。我的经验是:覆盖每个输出类别至少一条,再补一到两条边界样本,这个配置在多数分类和抽取任务上就够用了。

2.2 prefix、suffix、example_separator 的拼装顺序与冲突

最终文本的拼装顺序是:prefix→ 示例块(每个示例之间用example_separator连接,默认是两个换行)→suffix。理解这个顺序,才能理解为什么很多“格式跑偏”问题其实出在分隔符上。

prefix放任务指令和角色设定,suffix放真正要模型回答的那个输入,并且要以一个“待续”的姿态结尾——比如输入: {text}\n输出:,把冒号后面留空,模型自然就接着往下写。这个“留白引导”的技巧比写“请回答”有效得多,因为它把任务变成了续写而不是问答。

example_separator默认"\n\n",多数情况下够用。但如果你用的示例模板本身结尾就带换行,两层换行会变成三个四个空行,模型有时候会理解成“段落结束、重新开始”,输出里就多出一堆寒暄。我在一个项目里就是因为示例模板末尾手滑加了个\n,导致模型每条回答前面都带一句“好的,以下是结果:”,排查了半天才发现是分隔符叠加。

还有一个隐藏冲突:prefix和suffix里不能出现和示例字段同名的占位符,否则格式化时会报KeyError,或者更糟——不报错但把示例的值填进了指令里。这个在 4.1 节会细讲。

2.3 input_variables 的传递链路

这个参数是新手最容易懵的。FewShotPromptTemplate的input_variables指的是最终渲染时需要你提供的变量,也就是 suffix 里那些花括号占位符,不包括示例里的字段。示例字段由example_prompt自己消化掉了,它们不对外暴露。

举个例子:suffix 是"输入: {text}\n输出:",那input_variables=["text"],你调用时只传format(text=...)就行,示例里的input、output这些 key 不需要你传。很多人第一次写的时候把示例的 key 也塞进 input_variables,结果格式化直接崩。

如果你嫌手写这个列表麻烦,LangChain 提供了从模板自动推断的写法,但我不太推荐在初学阶段用——手动写一遍能帮你把“哪些变量对外、哪些对内”这件事想清楚,等熟练了再用自动推断省事。

2.4 example_selector:动态少样本的关键开关

静态示例的局限很明显:示例库一大,不可能全塞进去;而不同输入需要的示例又不一样。选择器就是解决这个的。常用的有三种:

选择器挑选依据适合场景主要参数
LengthBasedExampleSelector按字符长度累加,装不下就停输入长短差异大、要严格控制长度max_length、可自定义长度函数
SemanticSimilarityExampleSelector向量相似度,取最像的 k 条示例库大、输入主题分散k、embeddings、向量库类
MaxMarginalRelevanceExampleSelector相似度 + 多样性折中示例容易扎堆、怕同质化k、fetch_k

长度型选择器不需要向量模型,零额外成本,是性价比最高的起点。语义型选择器效果好,但要额外跑一次 embedding,首次还要建索引,延迟和费用都得算进去。我一般的做法是:示例库小于 20 条、任务比较单一时,用长度型甚至静态;示例库上百条、主题跨度大的时候才上语义型。

提示:语义选择器在建索引时会把所有示例向量化一遍,如果示例库是运行时才加载的,记得做缓存,不然每次请求都重建索引,延迟会难看到怀疑人生。

3. 从零跑通:静态到动态的两版实现

3.1 环境准备与版本差异

先把依赖装好。这里我不绑定具体的模型供应商,任何能提供对话补全和 embedding 的接口都能接进来,代码里用占位符代替,你换成手头的那套即可。

pip install langchain langchain-core

版本这块有个必须提前说的坑:LangChain 从 0.1 到 0.2 之后做了一次比较大的拆分,很多类的导入路径变了。老教程里写的from langchain.prompts import FewShotPromptTemplate在新版本上会给你一堆弃用警告,甚至直接报错。新路径大致是:

# 新版本推荐的导入方式 from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate from langchain_core.example_selectors import ( LengthBasedExampleSelector, SemanticSimilarityExampleSelector, )

如果你在跑别人的代码时看到ModuleNotFoundError或者ImportError,第一反应应该是查导入路径,而不是怀疑自己写错了逻辑。我建议在项目里把 LangChain 版本在依赖文件里锁死,这东西的小版本更新经常带破坏性改动。

模型和 embedding 的初始化我就不写具体厂商了,统一用两个占位变量:

# 换成你手头的任意对话模型与 embedding 接口 llm = your_chat_model embeddings = your_embedding_model

3.2 第一版:手写静态示例,跑通整条链路

先做一个最朴素的情感三分类,输出只允许positive/negative/neutral三个词。这个任务小到不能再小,但正好能看清模板的每个部件。

from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate # 1. 单个示例怎么渲染 example_prompt = PromptTemplate( input_variables=["text", "label"], template="文本: {text}\n标签: {label}", ) # 2. 示例数据,纯字典 examples = [ {"text": "这包装拆开就坏了", "label": "negative"}, {"text": "用了一周,一切都好", "label": "positive"}, {"text": "还行吧,没什么特别的", "label": "neutral"}, {"text": "客服态度不错,但物流太慢", "label": "neutral"}, ] # 3. 组装 few_shot_prompt = FewShotPromptTemplate( examples=examples, example_prompt=example_prompt, prefix="判断下面这段文本的情感倾向,只能输出 positive、negative、neutral 三者之一,不要输出任何解释。", suffix="文本: {text}\n标签:", input_variables=["text"], example_separator="\n\n", ) print(few_shot_prompt.format(text="东西是好东西,就是贵了点"))

跑出来的文本长这样,你可以对照着看拼装顺序:

判断下面这段文本的情感倾向,只能输出 positive、negative、neutral 三者之一,不要输出任何解释。 文本: 这包装拆开就坏了 标签: negative 文本: 用了一周,一切都好 标签: positive 文本: 还行吧,没什么特别的 标签: neutral 文本: 客服态度不错,但物流太慢 标签: neutral 文本: 东西是好东西,就是贵了点 标签:

这里有几个刻意的设计,值得逐条说。第一,prefix里明确写了“只能输出三者之一,不要解释”——这是给模型设边界,不给它留发挥空间。第二,suffix结尾的“标签:”后面故意留空,形成续写引导。第三,示例里特意放了“客服态度不错,但物流太慢 → neutral”这种带转折的边界样本,用来校准“混合情绪”的判定标准,这比写三行说明文字管用。

真正调模型的时候就是把这个字符串丢进去:

resp = llm.invoke(few_shot_prompt.format(text="东西是好东西,就是贵了点")) print(resp.content)

实操心得:第一版跑通之后先别急着加复杂度,拿二三十条真实输入测一遍,把输出不符合预期的样本记下来。这些“坏样本”就是下一版示例库的第一批候选,比你自己拍脑袋想示例靠谱得多。

3.3 第二版:接上语义相似度选择器

示例库涨到几十条之后,全塞进去就不现实了。这时候把examples换成选择器,模板其余部分基本不用动,这是这套设计最舒服的地方。

from langchain_core.example_selectors import SemanticSimilarityExampleSelector example_selector = SemanticSimilarityExampleSelector.from_examples( examples, # 大示例库 embeddings, # embedding 接口 vector_store_cls, # 向量库实现类 k=3, # 每次挑 3 条最相似的 ) dynamic_prompt = FewShotPromptTemplate( example_selector=example_selector, # 注意:有了选择器就不要传 examples example_prompt=example_prompt, prefix="判断下面这段文本的情感倾向,只能输出 positive、negative、neutral 三者之一,不要输出任何解释。", suffix="文本: {text}\n标签:", input_variables=["text"], )

有个硬性规则必须记牢:examples和example_selector二选一,不能同时传。同时传会直接报错,这算是框架给的善意提醒。很多人是从静态版改过来的,只加了一行没删原来那行,跑起来就懵了。

k的取值我一般从 3 起调。k=1 时最省,但风险是匹配到的那条示例恰好是个边界样本,会把模型带偏;k 太大又回到成本问题。3 到 5 在多数任务上是平衡点。判断方法很简单:固定一批测试输入,k 分别取 1/3/5,看准确率和平均 token 消耗的曲线,拐点就是你的答案。

语义选择器还有个细节:默认情况下它会用示例字典里的所有字段去算相似度,包括那个label字段。这意味着“negative”这个词本身也参与了向量匹配,可能引入噪声。规范做法是只拿输入字段去算:

example_selector = SemanticSimilarityExampleSelector.from_examples( examples, embeddings, vector_store_cls, k=3, input_keys=["text"], # 只用 text 字段做相似度匹配 )

这一行加上之后,我在一个主题跨度很大的示例库上看到召回质量明显变好,因为不再有“标签词”干扰。

3.4 长度控制型选择器:不花钱的那条路

如果你的场景对延迟敏感、或者不想为了选示例再跑一次 embedding,长度型选择器是个被低估的方案。它按顺序往 prompt 里塞示例,累加长度超过阈值就停,逻辑简单到没有黑箱。

from langchain_core.example_selectors import LengthBasedExampleSelector length_selector = LengthBasedExampleSelector( examples=examples, example_prompt=example_prompt, max_length=200, # 示例块的总长度上限 ) short_prompt = FewShotPromptTemplate( example_selector=length_selector, example_prompt=example_prompt, prefix="判断情感倾向,只输出 positive、negative、neutral。", suffix="文本: {text}\n标签:", input_variables=["text"], )

max_length的算法是把每个渲染后的示例长度累加,超过就截断。这里有个容易被忽略的点:它默认按字符数算,不是按 token 算。中文场景下字符和 token 大概是一比一到一比一点五的关系,所以实际 token 会比你按字符估算的多一些。如果你的示例有长有短、混着中英文,最好传一个自定义的长度计算函数,改成调 tokenizer 算真实 token,不然预估会偏。

它的短板也很明显:挑选逻辑是“按顺序装”,和输入内容无关。示例库如果本身是按类别聚在一起的,就会出现“前面全被负面示例占满”的情况。补救办法是在构造示例库时打散顺序,或者干脆做一个简单的轮转。

3.5 参数调优与 token 估算

写到这里得聊聊成本,因为很多人做 few-shot 的时候只盯效果不看账。粗略估算方法:一段中文示例渲染后大概 20 到 40 个字,加上分隔符,五条示例的示例块在 150 到 250 字之间;prefix 加 suffix 大概 50 到 100 字;用户输入按 100 字算。整体输入在 300 到 450 字,换算成 token 大概 300 到 600。这个量级对多数接口来说很轻,但如果你的示例是长文本(比如带完整 JSON 的抽取示例),一条就可能几百 token,五条下去直接破两千,成本和延迟都会翻倍。

我自己的调参顺序是这样的:先把示例数量从 8 往下砍,测 2/3/5/8 四档的准确率;如果 3 条和 8 条差不多,就用 3 条。然后在这个数量上调整示例的内容分布,补边界样本、删重复样本。最后才去动k或者选择器策略。顺序很重要——很多效果问题其实不是选择器不够聪明,而是示例库里根本没有覆盖那类输入。

还有一个实用技巧:把prefix里的指令压缩到极限。我见过 prefix 写了三百字的“角色设定 + 注意事项 + 输出要求”,其实核心就两句:任务是什么、输出长什么样。剩下的说明完全可以靠示例传达,示例本身就是最好的说明书,文字指令写多了反而稀释了示例的示范作用。

4. 常见问题与排查实录

4.1 变量名撞车导致的 KeyError

这是出现频率最高的一类报错,表现形式是format()的时候抛KeyError: 'xxx'。原因通常是 prefix 或 suffix 里出现了示例字典里同名的字段。比如示例的 key 叫input,而你的 suffix 写成"输入: {input}"——格式化时框架会拿示例的字段去填 suffix 的占位符,一旦某条示例没有这个 key,就崩了。

排查手法很土但有效:把format()的输出整个打印出来,看哪些位置是空的、哪些位置填错了值。我一般会养成一个习惯,示例字段和待填变量用不同的命名前缀,比如示例里用src/tgt,对外变量用user_text,从命名上就避开撞车的可能。这个习惯省下的调试时间,比多写几个字符的成本高得多。

另一个变体是input_variables写漏了。表现是format()报缺少参数,但你明明传了。检查方法:把 suffix 字符串单独拿出来,数一遍有几个花括号占位符,和input_variables的列表一一对上。

4.2 选择器没生效、或者选得莫名其妙

“我明明加了选择器,怎么感觉挑的示例还是不对?”这种情况我遇到过三次,原因各不相同。

第一次是k设太大,等于没筛。k=8 而示例库只有 10 条,那基本就是全塞进去了,选择器形同虚设。

第二次是忘了配input_keys,相似度被标签字段污染,导致“文本完全不像但标签词相同”的示例被排到前面。表现是模型的输出标签分布严重偏斜,某一种标签出现得异常频繁。

第三次最隐蔽:示例库本身质量差。选择器只是把最相似的挑出来,如果库里全是长句示例,而用户输入是短句,怎么挑都不对。这时候得回头补示例,而不是换选择器。

排查顺序建议固定成:先打印选择器实际选中的那几条(多数选择器有select_examples这类方法可以直接喂一个输入看结果),确认挑选逻辑符合直觉;再看召回内容是否真的相似;最后才怀疑向量模型。

4.3 输出格式依然跑偏

示例给了、指令也写了“不要解释”,模型还是给你来一句“好的,这段文本的情感是 positive”。这种“礼貌性前缀”特别顽固。我试过几种办法,按有效性排序:

最有效的是在示例的“输出”位置强化留白,也就是 suffix 结尾用标签:而不是标签:请回答,让任务变成续写。其次是在示例里放一个“错误示范-正确示范”的对比,但这会显著增加长度,慎用。最后才是在 prefix 里加重语气,比如“只输出一个词”“不要任何前缀和后缀”,效果有,但不如前两种稳。

如果模型还是加戏,可以在后处理层做一次清洗,正则截取第一个符合格式的片段。但这是兜底,不是解法,长期靠后处理说明提示词没写到位。我一般会把后处理当成监控手段:统计触发清洗的比例,比例高就说明提示词该改了。

4.4 成本、延迟与缓存的取舍

上了选择器之后,一次请求实际发生的事是:embedding 一次用户输入 → 向量检索 → 拼 prompt → 调模型。链路从一跳变三跳,延迟自然涨。如果示例库每次请求都重建索引,那延迟会难看到没法上线。

我的处理办法是:示例库在服务启动时构建一次索引,之后复用;用户输入的 embedding 结果按输入哈希做一层短时缓存,重复输入直接命中。这两步做完,增量延迟基本可以忽略。另外,如果k设为 3 已经够用,就别设 5,省下的不只是 token,还有检索时间。

注意:示例库不是静态资产。业务在变,示例也得跟着更新。我见过一个项目上线半年没动过示例库,结果新出现的输入类型全被映射到旧标签上,准确率悄悄掉了十几个点还没人发现。建议至少每月抽样复查一次。

4.5 常见问题速查表

现象大概率原因处理动作
KeyError: 'xxx'prefix/suffix 占位符与示例字段重名打印 format 结果,重命名示例字段
缺少参数报错input_variables漏写数一遍 suffix 里的花括号数量
同时传 examples 和 selector 报错二选一规则删掉旧的那行
选中的示例不相关未配input_keys,标签词污染相似度指定只用输入字段匹配
选择器形同虚设k接近示例库总数把 k 降到 3 到 5
输出带礼貌前缀suffix 结尾没有形成续写引导改成标签:留空,别写“请回答”
输出格式细节不对示例本身的格式就不规范示例输出按最终形态逐字符写
延迟明显变高每次请求重建向量索引启动时建索引 + 输入缓存
效果随业务下滑示例库长期未更新每月抽样复查,补新类型样本

5. 几个长期维护上的经验

5.1 把示例库当代码资产来管

一开始我也觉得示例就是几行文本,随手写在脚本里就完事。项目稍微大一点之后就会后悔:示例散落在各个文件、版本对不上、改了一条不知道影响哪些任务。后来我改成用一个独立的配置文件或者数据文件管示例,按任务分组,每条示例带一个自增的标识和一段备注(说明这条示例是为了校准什么边界),模板代码只负责读和渲染。

这个做法带来的最大好处是可回溯。当模型某天突然开始把某类输入判错,你可以翻记录看是不是前一天有人动过示例。没有记录的时候,这基本是黑箱排查。另外,示例最好也进版本管理,和代码一起走 review 流程——听起来夸张,但一条表述含糊的示例造成的线上偏差,修起来比一个函数 bug 更费劲,因为它不会报错。

5.2 用评估集代替感觉

“感觉效果还行”是提示工程里最危险的一句话。我现在的习惯是,任何一次示例改动之前,先固定一个三十到五十条的小评估集,覆盖每个标签、包含若干边界样本,改动前后各跑一遍,看准确率和格式合规率两个指标。样本量不用大,但这个对比能挡掉绝大部分“越改越差还不自知”的情况。

格式合规率这个指标特别值得单独统计,因为它比准确率更敏感。示例格式稍微松一点,合规率立刻掉,而准确率可能看不出变化——但下游解析会直接崩。我一般把“输出能否被下游无损解析”作为硬门槛,过不了这个门槛,准确率再高也不上线。

至于后续还能怎么扩,我觉得有两个方向值得试:一是把示例库和评估集联动起来,把评估集里反复出错的样本自动回流成候选示例,形成一个闭环;二是把选择器从“相似度”升级成“相似度 + 覆盖度”,既保证和当前输入相关,又保证挑出来的几条示例在标签上分布均衡,避免三条示例全是同一个类别。这两个方向我都在小范围试过,效果有,但还没到能直接抄的程度,等跑稳了再整理。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进