提示词工程完全指南:从零基础到高级优化

从零样本基础到思维树、DSPy 和自动化优化,掌握提示词工程。包含基准测试、代码示例和调试工具箱。

同样的模型,两种问法:一种在小学数学题上准确率 17%,另一种 78%。差别不是玄学,而是提示词工程。这篇文章系统梳理那些真正有效的技巧、它们为什么有效,以及如何在生产环境里把提示词当成一个工程问题来优化。

你会学到什么

  • 基础:零样本、少样本、多样本、任务分解,以及"五块结构"的提示词骨架。
  • 推理:Chain-of-Thought、Self-Consistency、Tree of Thoughts、Graph of Thoughts、ReAct。
  • 自动化:APE、DSPy、LLMLingua 压缩。
  • 实战模板:结构化输出、代码生成、数据抽取、多轮对话。
  • 评估与调试:指标选择、A/B 测试、错误归因、调试工具箱。

前置要求:会用 Python,调用过任何一家的 LLM API。不需要数学背景。


为什么提示词工程值得花时间

2020 年 GPT-3 刚出来的时候,研究者很快发现了一个反常识的现象:同一个模型,问法不同,结果天差地别。一个写得不好的提示词只能换来胡言乱语,一个精心设计的提示词却能解决复杂的推理任务。这不是 bug,而是这类模型学习语言的方式决定的。

传统编程是确定性的:写函数、定义入参出参,机器照做。语言模型不是这么工作的。它本质上是在根据万亿级 token 训练出的模式预测"下一个最有可能的词"。你的提示词不是在给模型下命令,而是在为它构造一个上下文,把它的概率分布往你想要的方向"推"一下。

把它想成米其林餐厅的菜单:如果你只写一句"给我做点好吃的",厨师只能凭直觉发挥;写清楚"清蒸鲈鱼,少盐,配芦笋",他才知道怎么端盘。提示词工程的本质,就是写出让模型不需要猜的菜单。

这件事的回报很高。一个写得好的提示词,可以通过更紧凑的上下文把 API 成本降到原来的十分之一;可以把一个复杂推理任务的准确率从 40% 拉到 90%。对于每月百万级请求的生产系统,这是真金白银。

一个生产级提示词的解剖

五块结构:role、context、instruction、examples、format

几乎所有上线的提示词都能拆成同样的五块:角色(role)上下文(context)指令(instruction)示例(examples)输出格式(format)。把它当骨架,每个任务换"血肉",骨架不动。这种统一结构会让评估、缓存、版本控制都变得容易。

基础技巧

零样本提示

零样本就是不给任何示例,直接让模型做事。完全靠它在预训练里见过的世界来理解你想干什么。

1
2
3
4
prompt = """请判断这条评论的情感:
"这电影又无聊又老套,不推荐。"

情感:"""

模型没看到任何情感分类的示例,但它能从"无聊"“不推荐"这些词里推断出是负面。

适合零样本的场景:模型在预训练阶段大量见过的简单任务(翻译、摘要、常识问答);约定俗成的标签体系(情感的"正/负/中”);快速验证一个想法。

不适合的场景:领域术语多的任务(医学、法律),指令本身就模糊,要求严格输出格式的任务。

GPT-3 论文里有个对照数据:自然语言推理任务上,零样本 59%,少样本 70%。差距就是没给示例的代价。

三个几乎总是有效的优化点

  1. 任务说清楚。不要写"看看这条评论",要写"把这条评论分类为正面/负面/中性"。
  2. 格式钉死。加一句"只回复一个词:positive、negative 或 neutral",模型就不会啰嗦。
  3. 加约束。一句"忽略反讽,只看字面情感"能避开最常见的翻车点。

一个能直接用的模板:

1
2
3
4
5
6
7
def zero_shot_classify(text: str, labels: list[str]) -> str:
    return f"""任务:把下面的文本分类到这些类别中的一个:
{', '.join(labels)}

文本:{text}

类别(只回复类别名称):"""

少样本提示

少样本就是在问问题之前,先给 2 到 10 个例子。准确率会肉眼可见地上升,因为模型看到了它该模仿的"模式"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
prompt = """判断下列评论的情感:

评论:"简直爱了!今年看过最好的电影。"
情感:positive

评论:"还行吧,没什么特别但也不差。"
情感:neutral

评论:"纯粹浪费时间。演技稀烂。"
情感:negative

评论:"摄影非常出色,剧情也抓人。"
情感:"""

这几个示例同时做了三件事:钉死了输出格式(一个小写单词),覆盖了边界情况(褒贬混杂归为 neutral),用相关的语义模式给模型"开了一道门"。

心智模型:少样本示例是一种软约束。模型的下一 token 预测会去匹配示例里的输入-输出模式,再套到新的输入上。本质上是在用示范来"编程"。

怎么挑示例。Liu et al. (2021) 的实证研究有几个结论很有用:

  • 多样性比数量重要。五个差异大的示例比二十个相似的更有用。
  • 难例值得加。模型最容易翻车的边界情况,恰恰最该出现在示例里。
  • 顺序有影响。和当前问题最相关的那个示例,放在最靠近问题的位置。

一个把"相关性"和"多样性"结合起来的简单选取器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from sklearn.metrics.pairwise import cosine_similarity

def select_examples(query: str, pool: list[dict], k: int = 5) -> list[dict]:
    """挑 k 个例子:既要和问题相关,又要彼此差异大。"""
    q_emb = get_embedding(query)
    embs = [get_embedding(ex["text"]) for ex in pool]

    sims = cosine_similarity([q_emb], embs)[0]
    candidates = sims.argsort()[-k * 2:][::-1]   # 先取 2k 个最相关的

    selected = [candidates[0]]
    for idx in candidates[1:]:
        if len(selected) >= k:
            break
        if all(cosine_similarity([embs[idx]], [embs[s]])[0][0] < 0.9
               for s in selected):
            selected.append(idx)
    return [pool[i] for i in selected]

SuperGLUE 上 GPT-3 的对照:零样本 69.5%,单样本 71.8%,32 样本 75.2%。大多数任务在 10–15 个示例之后就遇到边际递减了。

多样本提示

Anthropic 在 2024 年的工作显示,当上下文窗口够大(10 万 token 以上),可以塞进几百上千个示例。这种 many-shot 几乎能逼近微调的效果,又不需要训练流水线。

典型场景:你想做一个能识别公司内部反模式的代码评审器。微调要数据流水线、要长期维护,太重;不如直接把 200 条评审示例放进上下文里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
prompt = """请检查以下代码的反模式。

[示例 1]
代码:def get_data(): return db.query('SELECT * FROM users')
问题:原生 SQL,注入风险。改用参数化查询。
严重程度:高

[示例 2]
代码:result = api.call(); result.field
问题:没有错误处理。包一层 try/except。
严重程度:中

…… 还有 198 个示例 ……

现在请评审这段代码:
{user_code}

问题:"""

为什么有效。几百个示例足以让模型隐式地学到任务相关的分布——和微调相似,但不更新参数。示例越多,越能覆盖边角情况,模糊的地方越少。格式一致性也几乎完美,因为模型已经看过几十次同样的模板。

Anthropic 的发现:500-shot 在专门任务上可以逼近微调;200–300 个示例之后增益开始平台;最适合 Claude 的 200K 窗口。

代价。20 万 token 的提示词按 GPT-4 标价大概 2 美元一次,延迟也会显著上升。解药是 prompt caching(Claude、GPT-4 都支持):静态前缀缓存一次,后面的请求按折扣价复用。

任务分解

复杂任务失败的常见原因,是你试图让模型一口吃成胖子。把它拆开,每一步都简单到能验证:

不要写"把这份合同的所有义务条款抽出来",而要:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
sections = extract_sections(contract)               # 1) 拆章节

obligations = []                                    # 2) 逐节抽义务
for section in sections:
    prompt = f"""法律文本:{section}

列出所有义务,按以下格式:
[当事方] 必须 [行为] [条件]"""
    obligations.extend(llm_call(prompt))

classified = classify_obligations(obligations)      # 3) 按类型分类
summary = summarize_obligations(classified)         # 4) 汇总

为什么有用。每个子任务都简单,每一步的成功标准都清晰,中间结果可以直接看,出错时一眼就知道是哪一环坏了。GitHub Copilot Workspace 用的就是这套思路:理解代码库 → 找出受影响文件 → 生成单文件修改 → 合成完整方案,每一环都有自己的提示词。

三个值得放进工具箱的范式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def etl_pattern(data, extract_p, transform_p):
    extracted = llm_call(extract_p.format(data=data))
    return llm_call(transform_p.format(data=extracted))

def map_reduce(items, map_p, reduce_p):
    mapped = [llm_call(map_p.format(item=i)) for i in items]
    return llm_call(reduce_p.format(items=mapped))

def validate_retry(prompt, validator, max_retries=3):
    for _ in range(max_retries):
        result = llm_call(prompt)
        if validator(result):
            return result
        prompt += f"\n\n上一次结果不合法:{result}\n再试一次:"
    raise ValueError("超过最大重试次数")

三种范式的横向对比

Zero-shot vs Few-shot vs CoT:准确率与 token 成本的对照

同一道算术题三种问法。零样本最便宜,但在多步推理上最弱;少样本花约 8 倍 token 换来约 16 个百分点的提升;CoT 用比少样本还少一点的 token,把 GSM8K 量级问题的准确率拉到 78.7%(Wei et al., 2022;Kojima et al., 2022)。

进阶推理技巧

Chain-of-Thought(CoT)

CoT 让模型把思考过程写出来:先生成中间推理步骤,再给最终答案。这一个改动就能在数学、逻辑、多步推理上带来巨大提升。

CoT 推理流程:把中间状态写进输出,错误率显著下降

Wei et al. (2022) 发现,仅仅加上一句"让我们一步步思考",GSM8K 上的准确率就从 17.1% 跳到 78.2%。

不加 CoT:

1
2
3
4
prompt = """问:罗杰有 5 个网球。他又买了 2 罐网球,每罐 3 个。
他现在有多少个网球?
答:"""
# 经常输出:"10"(错)

加上 CoT:

1
2
3
4
5
6
7
8
prompt = """问:罗杰有 5 个网球。他又买了 2 罐网球,每罐 3 个。
他现在有多少个网球?
答:让我们一步步想。"""
# 输出:
# 罗杰一开始有 5 个。
# 又买了 2 罐,每罐 3 个 = 6 个。
# 总共 5 + 6 = 11 个。
# 答案:11(对了)

为什么 CoT 有效。可解释性研究里有个比较站得住脚的解释:模型在单次前向传播里能做的"隐式计算"是有上限的,每一层都在精修表示,但不能凭空多算。生成中间 token 等于让模型多走了几次前向——每个生成的 token 都是一次新的前向——把计算量摊开。把它想成模型的"工作记忆"。

三种值得记住的变体

Zero-shot CoT:什么例子都不给,加一句"让我们一步步想"。意外地有效:

1
2
def zero_shot_cot(question: str) -> str:
    return f"{question}\n\n 让我们一步步想。"

Few-shot CoT:示例里也带上推理过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
prompt = """问:5 台机器 5 分钟做 5 个零件,100 台机器做 100 个零件需要多久?
答:让我们一步步想。
   - 5 台机器 5 分钟做 5 个零件
   - 那么 1 台机器 5 分钟做 1 个零件
   - 100 台机器 5 分钟做 100 个零件
答案:5 分钟

问:农场主有 15 只羊,除了 8 只都死了,还剩几只?
答:让我们一步步想。
   - "除了 8 只都死了"意思是有 8 只活下来
   - 所以剩下 8 只
答案:8

问:{new_question}
答:让我们一步步想。"""

结构化 CoT:强制一个固定的推理模板:

1. 已知:……
2. 求:……
3. 用到的原理:……
4. 分步求解:……
5. 最终答案:……

Wei et al. (2022) 的对照实验

数据集BaselineCoT提升
GSM8K(数学)17.1%78.2%+61pp
SVAMP(数学)63.7%79.0%+15pp
CommonsenseQA72.5%78.1%+6pp
StrategyQA54.3%66.1%+12pp

CoT 适合:多步推理、需要中间计算的题、需要解释推理路径的场景、有 trade-off 的决策。

CoT 不适合:简单查询(“法国首都?")、模型本身缺乏知识的题(CoT 无中生不了有)、要求短答案的任务(推理 token 全是浪费)。

一个可复用的 CoT 引擎:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ChainOfThoughtEngine:
    def __init__(self, model, temperature: float = 0.7):
        self.model = model
        self.temperature = temperature

    def solve(self, question: str, mode: str = "zero-shot"):
        if mode == "zero-shot":
            prompt = f"{question}\n\n 让我们一步步想。"
        elif mode == "few-shot":
            prompt = self._build_few_shot_prompt(question)
        else:
            raise ValueError(f"未知模式:{mode}")

        response = self.model.generate(
            prompt, temperature=self.temperature, max_tokens=512,
        )
        return self._extract_answer(response), response

    def _extract_answer(self, response: str) -> str:
        for marker in ("答案:", "Answer:", "因此,", "所以"):
            if marker in response:
                tail = response.split(marker)[-1].strip()
                return tail.split("。")[0].split("\n")[0].strip()
        return response.strip().split("\n")[-1]

Self-Consistency:用多数投票降方差

单条 CoT 链可能走偏。Self-Consistency(Wang et al., 2022)的思路朴素得有点漂亮:多采样几条推理路径,对最终答案做多数投票

Self-consistency:采样多条路径,多数票胜出

直觉上完全是贝叶斯的:如果 10 条不同的推理路径里有 7 条得到同一个答案,这个答案对的概率就远高于任何一条单独路径。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from collections import Counter

def self_consistency(question: str, n: int = 5,
                     temperature: float = 0.7) -> tuple[str, float]:
    """采样 n 条推理链,对答案多数投票。"""
    prompt = f"{question}\n\n 让我们一步步想。"
    answers = []
    for _ in range(n):
        response = llm_call(prompt, temperature=temperature, max_tokens=512)
        answers.append(extract_answer(response))

    most_common, count = Counter(answers).most_common(1)[0]
    return most_common, count / n

一个真实的小例子:

问:"如果你超过了第二名,你现在第几?"

链 A:"超过第二,所以你是第二。"      -> 第二  ✓
链 B:"你原来在第二之后,超过后是第一。" -> 第一  ✗
链 C:"超过第二就占据他的位置。"      -> 第二  ✓
链 D:"你超过了第二的人,你现在就是第二。" -> 第二  ✓
链 E:"超过第二,你变成第一。"        -> 第一  ✗

多数票:第二 (3/5 = 0.6 置信度)

Wang et al. (2022) 的数字:GSM8K 上单次 CoT 74.4%,Self-Consistency(n=40)83.7%;CommonsenseQA 78.1% → 81.5%。在硬任务上完全值回票价。

成本。Self-Consistency 把推理成本乘了 n 倍,按任务重要性挑 n:

任务级别推荐 n成本
探索性33x
生产55x
高风险10–2010–20x

更聪明的"自适应"版本:先用低 n,置信度低再加倍:

1
2
3
4
5
def adaptive_self_consistency(question: str, threshold: float = 0.7):
    answer, conf = self_consistency(question, n=3)
    if conf >= threshold:
        return answer, conf
    return self_consistency(question, n=10)

更进一步的加权投票会让模型同时给每条链的逻辑质量打分,再按质量加权——多花一次推理,但能自动给摇摆的推理打折。

Tree of Thoughts(ToT):把推理变成搜索

ToT(Yao et al., 2023)把 CoT 推到下一层:把推理建模为状态空间搜索。不再走单链,而是展开成一棵树,给每个分支打分,遇到死胡同就回溯。

Tree of Thoughts:在 24 点上分支、打分、回溯

一个简化的深度优先实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def tree_of_thoughts(problem: str, depth: int = 3, breadth: int = 3):
    def explore(state: str, d: int) -> int:
        if d >= depth:
            return evaluate_solution(state)
        thoughts = generate_thoughts(state, k=breadth)
        scored = [(t, explore(state + "\n" + t, d + 1)) for t in thoughts]
        return max(scored, key=lambda x: x[1])[1]

    return explore(problem, 0)


def generate_thoughts(state: str, k: int = 3) -> list[str]:
    prompt = f"""当前推理:
{state}

请生成 {k} 个可能的下一步:
1."""
    response = llm_call(prompt, temperature=0.8, max_tokens=300)
    return [t.strip() for t in response.split("\n") if t.strip()][:k]


def evaluate_solution(state: str) -> int:
    prompt = f"""按 1-10 给这段推理打分(逻辑连贯性、向解的进展、正确可能性):

{state}

分数(1-10 的整数):"""
    return int(llm_call(prompt, temperature=0, max_tokens=5))

24 点示例。用 4、9、10、13 加减乘除得到 24:

根:{4, 9, 10, 13}
├─ 13 - 9 = 4   -> {4, 4, 10}        v=8 (保留)
│  └─ 10 - 4 = 6   -> {4, 6}         v=9
│     └─ 6 * 4 = 24                  解出
├─ 10 - 4 = 6   -> {6, 9, 13}        v=5 (待探索)
└─ 9 + 10 = 19  -> {4, 13, 19}       v=2 (剪枝)

Yao et al. (2023) 的对照:

任务CoTToTΔ
24 点7.3%74%+66pp
创意写作7.37.9+0.6
填字游戏15.6%78%+62pp

代价。breadth=3、depth=4 一道题就要 ~80 次 LLM 调用。ToT 真正划算的前提有两个:解空间里确实存在多条可行路径,模型能可靠地给中间状态打分。组合优化、规划、约束满足类问题适合用;普通问答别上。

生产里推荐用带优先队列的最佳优先搜索,配一个硬性的调用次数上限,单题不能炸预算。

Graph of Thoughts(GoT):从树到 DAG

GoT(Besta et al., 2023)把 ToT 推广成任意有向无环图。思想可以合并(多分支汇总成一个),也可以迭代(一个思想跨轮次反复打磨),表达能力比树严格强。

最经典的例子是多文档摘要:

文档 -> 单文档摘要  ┐
       单文档摘要  ┼-> 主题合并 -> 最终综述
       单文档摘要  ┘

每个单文档摘要互相独立,可以并行。合并步把它们汇总。这是图,不是树。

1
2
3
4
5
def graph_of_thoughts_summarize(documents: list[str]) -> str:
    summaries = [llm_call(f"摘要:\n{d}") for d in documents]
    themes = [llm_call(f"提取主题:\n{s}") for s in summaries]
    merged = llm_call("合并以下主题:\n" + "\n\n".join(themes))
    return llm_call(f"基于以下内容写最终综述:\n{merged}")

在 32 个数字的排序任务上,Besta et al. 报告 GoT 准确率 89%,成本比 ToT 低 62%——合并节点把重复探索砍掉了。

ReAct:边想边做

ReAct(Yao et al., 2022)把推理行动交错起来。模型在思考和工具调用之间反复切换,每一步行动的结果都被观察并喂回去。

Thought: 先要知道巴黎人口。
Action: search("Paris population")
Observation: 216 万 (2019)
Thought: 再查东京人口。
Action: search("Tokyo population")
Observation: 3740 万 (2021)
Thought: 东京更大。
Action: finish("东京人口比巴黎多。")

ReAct 解决了纯语言模型的三个老问题:训练数据有截止时间(无法回答最新信息)、不擅长精确计算、不能访问私有数据。一个最小的 agent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ReActAgent:
    def __init__(self, model, tools: dict, max_steps: int = 10):
        self.model = model
        self.tools = tools
        self.max_steps = max_steps

    def run(self, task: str) -> str:
        trajectory = [f"Task: {task}"]
        for _ in range(self.max_steps):
            response = self.model.generate(
                self._build_prompt(trajectory), temperature=0,
            )
            thought, action, action_input = self._parse(response)
            trajectory.append(f"Thought: {thought}")
            trajectory.append(f"Action: {action}[{action_input}]")

            if action == "finish":
                return action_input

            tool = self.tools.get(action)
            obs = tool(action_input) if tool else f"未知动作:{action}"
            trajectory.append(f"Observation: {obs}")
        return "步数耗尽,未完成。"

HotpotQA(多跳问答)上:标准提示 28.7% → CoT 32.9% → ReAct 37.4%。AlfWorld(交互环境)上:12% → 34%。

实战要点

  • 工具描述要写好。模型靠 docstring 选工具。
  • 观察结果要截断。搜索引擎可能返回几千字,会撑爆上下文。
  • 强制步数上限。防止死循环。
  • 错误信息要描述性。让模型有机会自我纠正,而不是一崩到底。

提示词默认是脆弱的

提示词敏感性:仅改变格式或示例顺序,准确率可能波动 20+ 个百分点

同一个模型、同一组示例、同一个任务。仅仅改变示例的格式(Q:/A: 还是 Input/Output)或者它们的顺序,准确率可能波动 20+ 个百分点(Lu et al., 2022;Sclar et al., 2024)。这就是为什么经验性评估不能省。在宣布"找到最优提示词"之前,至少在多种顺序下都跑一遍。

自动化与工程化

手工调提示词不可持续。下面这些方法把这件事工程化。

Automatic Prompt Engineering(APE)

APE(Zhou et al., 2022)把"找最优提示词"当成一个搜索问题:

  1. 生成:让 LLM 根据任务描述和少量示例,自动生成几十个候选提示词。
  2. 评估:每个候选在验证集上跑一遍。
  3. 选择:留下表现最好的那一个。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def automatic_prompt_engineering(task_description, train_examples,
                                 val_examples, num_candidates=20):
    meta_prompt = f"""任务:{task_description}

示例:
{format_examples(train_examples[:5])}

请生成 {num_candidates} 个不同的提示词,用不同的措辞和角度。

提示词:"""
    candidates = parse_prompts(
        llm_call(meta_prompt, temperature=1.0, max_tokens=2000)
    )

    results = []
    for prompt in candidates:
        correct = sum(
            normalize(llm_call(f"{prompt}\n\n 输入:{x}\n 输出:",
                               temperature=0)) == normalize(y)
            for x, y in val_examples
        )
        results.append((prompt, correct / len(val_examples)))

    return max(results, key=lambda x: x[1])

Zhou et al. 发现 APE 找到的提示词常常比人写的高 3–8 个百分点。原因不复杂:APE 探索人类不会想到的措辞,直接在你的数据上优化,而且能轻松测试上百个候选。

迭代版会把当前最优作为基准再生成变体,相当于在提示词空间里做爬山。

DSPy:把提示词当代码写

DSPy(Khattab et al., 2023)把提示词工程当成编程问题。你不再手写提示词字符串,而是写一个生成提示词的程序,由编译器自动调优。

三个核心抽象:

  • Signature:带类型标注的输入输出签名。
  • Module:可组合的提示词模板。
  • Optimizer:自动选择 demonstration 和 instruction 的优化器。

一个情感分类器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import dspy

class SentimentSignature(dspy.Signature):
    """对文本做情感分类。"""
    text = dspy.InputField()
    sentiment = dspy.OutputField(desc="positive、negative 或 neutral")

class SentimentClassifier(dspy.Module):
    def __init__(self):
        super().__init__()
        self.predictor = dspy.Predict(SentimentSignature)

    def forward(self, text: str):
        return self.predictor(text=text)

dspy.settings.configure(lm=dspy.OpenAI(model="gpt-3.5-turbo"))
classifier = SentimentClassifier()
print(classifier("这部电影简直太棒了!").sentiment)  # positive

DSPy 能从训练集自动 bootstrap 出 demonstration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from dspy.teleprompt import BootstrapFewShot

train_data = [
    dspy.Example(text="产品很赞!", sentiment="positive"),
    dspy.Example(text="服务太差。", sentiment="negative"),
    # ...
]

optimized = BootstrapFewShot(metric=exact_match).compile(
    SentimentClassifier(), trainset=train_data,
)

多阶段流水线也很自然:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MultiHopQA(dspy.Module):
    """需要多步推理的问答。"""
    def __init__(self):
        super().__init__()
        self.retrieve = dspy.Retrieve(k=3)
        self.gen_query = dspy.ChainOfThought("question -> search_query")
        self.answer = dspy.ChainOfThought("context, question -> answer")

    def forward(self, question: str):
        q = self.gen_query(question=question).search_query
        ctx = self.retrieve(q).passages
        return self.answer(context=ctx, question=question).answer

DSPy 编译器会同时优化三个子提示词。代价也是真实的:有学习曲线,对最终措辞的控制力会下降,前期需要一笔编译预算。当你已经有稳定的训练集和靠谱的评估指标时,DSPy 是最划算的选择。

LLMLingua:给提示词"瘦身”

LLMLingua(Jiang et al., 2023)通过删除信息量低的 token 来压缩提示词。一个小模型给每个 token 打一个"重要性分",分数低的删掉。

1
2
3
4
5
6
7
8
from llmlingua import PromptCompressor

compressor = PromptCompressor()
compressed = compressor.compress(
    original_prompt,
    rate=0.5,            # 目标压缩率 50%
    target_token=200,    # 或者直接给一个 token 预算
)

底层用的是条件困惑度:删掉某个 token,看下一个 token 的预测困惑度增加多少;增加越多,说明这个 token 越重要,越该保留。

实测影响:

  • 问答任务 2x 压缩:准确率掉 2–3%,成本省 50%,延迟降 1.4x。
  • RAG 任务 4x 压缩:准确率掉 5–7%,成本省 75%。

适合:长上下文场景(RAG、文档分析),且能接受一点准确率损失。慎用:法律、医疗等每个字都可能有责任的场景。

一个自适应压缩器的草图,按重要性给不同段分配预算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class AdaptiveCompressor:
    def __init__(self, base): self.base = base

    def compress(self, prompt: str, budget: int) -> str:
        sections = split_sections(prompt)
        budgets = {
            "instruction": int(budget * 0.3),  # 永远不饿死
            "examples":    int(budget * 0.4),
            "context":     budget - int(budget * 0.7),  # 压得最狠
        }
        return merge({
            k: self.base.compress(sections[k], target_token=b)
            for k, b in budgets.items()
        })

实战模板

同一套五块骨架,按任务替换内容

这张图展示了同一套五块骨架在六类常见任务上的特化。复用骨架的好处不仅仅是看起来整齐——它让评估、缓存、版本控制都变得容易。

结构化输出

让 LLM 稳定输出合法 JSON 是一件出名的麻烦事。三种策略,鲁棒性递增:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def generate_structured(data: str, schema: dict) -> dict:
    """Schema 优先 + 失败时带反馈重试。"""
    prompt = f"""根据下面的 schema 生成一个 JSON 对象:
{json.dumps(schema, indent=2)}

要求:
- 必填字段不能漏
- 类型要对
- 枚举字段必须用允许的值
- 不要加多余字段

输入数据:
{data}

只输出合法 JSON:"""
    response = llm_call(prompt, temperature=0)
    try:
        parsed = json.loads(response)
        validate_against_schema(parsed, schema)
        return parsed
    except Exception as e:
        return retry_with_feedback(prompt, response, str(e))

策略 2 是给几个合法 JSON 示例做 few-shot——简单 schema 下足够。策略 3 是用 OpenAI/Anthropic 的原生 function/tool calling,由 API 保证 JSON 合法,是最稳的。

代码生成 + 自检

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def generate_code(task: str, language: str = "python", tests=None) -> str:
    prompt = f"""请用 {language} 完成下面的任务。

任务:{task}

要求:
- 注释清晰
- 处理边界情况
- 遵循 {language} 最佳实践
{format_test_cases(tests) if tests else ''}

提供完整可运行的代码:
```{language}
"""
    code = extract_code_block(llm_call(prompt, temperature=0.3))
    if tests:
        results = run_tests(code, tests, language)
        if not all(r.passed for r in results):
            code = debug_and_fix(code, results, prompt)
    return code

模式是 生成 → 测试 → 修复。修复 prompt 把失败的测试反馈给模型,原始指令保持不变。

多轮对话管理

长对话会撑爆上下文。常见做法:保留最近若干轮的滑动窗口,老对话用 LLM 自己生成的摘要替换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class ConversationManager:
    def __init__(self, model, system_prompt: str, max_tokens: int = 4000):
        self.model = model
        self.system_prompt = system_prompt
        self.max_tokens = max_tokens
        self.history: list[dict] = []

    def chat(self, user_message: str) -> str:
        self.history.append({"role": "user", "content": user_message})
        context = self._get_context()
        response = self.model.generate(self._build_prompt(context))
        self.history.append({"role": "assistant", "content": response})
        return response

    def _get_context(self) -> list[dict]:
        ctx = [{"role": "system", "content": self.system_prompt}]
        used = count_tokens(self.system_prompt)
        for msg in reversed(self.history):
            t = count_tokens(msg["content"])
            if used + t > self.max_tokens:
                break
            ctx.insert(1, msg)
            used += t
        return ctx

    def summarize_old_turns(self) -> None:
        if len(self.history) < 10:
            return
        old = self.history[:6]
        summary = self.model.generate(
            f"简明地总结以下对话:\n{format_messages(old)}\n\n 总结:"
        )
        self.history = (
            [{"role": "system", "content": f"早期对话摘要:{summary}"}]
            + self.history[6:]
        )

评估与调试

提示词工程是经验科学,没有指标就只能凭感觉。

几种指标,按成本排序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def exact_match(pred, truth):
    return normalize(pred) == normalize(truth)

def f1(pred, truth):
    p = set(normalize(pred).split())
    t = set(normalize(truth).split())
    if not p or not t:
        return float(p == t)
    common = p & t
    if not common:
        return 0.0
    prec = len(common) / len(p)
    rec = len(common) / len(t)
    return 2 * prec * rec / (prec + rec)

def semantic_similarity(pred, truth, model="all-MiniLM-L6-v2"):
    from sentence_transformers import SentenceTransformer, util
    m = SentenceTransformer(model)
    return util.cos_sim(m.encode(pred), m.encode(truth)).item()

def llm_as_judge(pred, truth, criteria):
    return parse_score(llm_call(f"""请评估以下输出。
任务:{criteria['task']}
期望:{truth}
实际:{pred}

请给 0-10 分并简要说明:"""))

挑能反映你真正在意的东西的最便宜的指标。

系统化的 A/B 测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class PromptExperiment:
    def __init__(self, test_set, metrics):
        self.test_set = test_set
        self.metrics = metrics

    def evaluate(self, prompt_fn):
        return {
            m.__name__: sum(
                m(llm_call(prompt_fn(x), temperature=0), y)
                for x, y in self.test_set
            ) / len(self.test_set)
            for m in self.metrics
        }

    def compare(self, variants: dict):
        import pandas as pd
        rows = [{"prompt_name": name, **self.evaluate(fn)}
                for name, fn in variants.items()]
        df = pd.DataFrame(rows)
        return df.sort_values(by=df.columns[1], ascending=False)

测试集一定要是提示词作者没看过的数据。

提示词调试清单

现象可能原因解法
输出含糊、跑题指令模糊加具体约束和示例
某条要求被忽略指令互相矛盾解决冲突,明确优先级
即使尝试也答错缺少上下文提供事实或检索段落
输出格式不对没指定格式给 schema 和示例
每次答案不一样任务对单次推理太复杂拆步骤

一个能扫出常见问题的小检查器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class PromptDebugger:
    AMBIGUOUS = {"相关", "合适", "好", "差", "一些", "若干", "大量",
                 "stuff", "things"}
    CONFLICT  = {"但", "然而", "尽管", "除了"}

    def check_ambiguity(self, prompt: str) -> bool:
        return any(w in prompt for w in self.AMBIGUOUS)

    def check_conflicts(self, prompt: str) -> bool:
        return sum(w in prompt for w in self.CONFLICT) >= 2

    def infer_format(self, text: str) -> str:
        t = text.strip()
        if t.startswith("{") and t.endswith("}"): return "json_object"
        if t.startswith("[") and t.endswith("]"): return "json_array"
        if "\n-" in t or "\n*" in t or "\n1." in t: return "list"
        return "prose"

错误归因

把失败案例分桶。下面这套类别能覆盖大多数错误模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def categorize_error(pred: str, truth: str) -> str:
    p, t = normalize(pred), normalize(truth)
    if not p:
        return "empty_output"
    if p in t or t in p:
        return "partial_match"
    p_w, t_w = set(p.split()), set(t.split())
    overlap = len(p_w & t_w) / max(len(p_w), len(t_w))
    if overlap > 0.5:
        return "semantic_error"
    if overlap > 0:
        return "partial_hallucination"
    return "complete_hallucination"

然后从最大的桶开始改。

常见坑与解法

解法
指令模糊列出要优化的具体维度:清晰度、长度、语气、格式。
假设模型知道你脑子里的东西把代码、文档、数据补到提示词里。
提示词太长拆段、摘要,或用 RAG。“lost in the middle"是真实的。
不指定输出格式显式给 schema、单位、语言,并配示例。
没有验证包一层"验证 → 重试 → 失败"循环。

常见问题

温度参数怎么选?

0:分类、抽取、数学、代码——所有要求一致性的场景。0.7–0.8:写作、头脑风暴、营销文案。1.0+ 几乎用不上。结构化任务默认 0,创意任务默认 0.7。

少样本应该给几个例子?

简单任务 2–3 个;多数任务 5–7 个最合适;10+ 只有在示例足够多样时才有用。超过 50 就该考虑微调了。

什么时候该微调?

已经有 1000+ 高质量标注数据、任务高度专业化、延迟/成本是关键约束、提示词工程已经触顶。其他情况都先用提示词,因为迭代速度快一千倍。

怎么减少幻觉?

用检索的上下文 grounding,明确告诉模型"不知道就说不知道”,要求带引用,把温度调到 0,加一道验证。

长文档怎么处理?

chunk + map-reduce、检索增强生成、分层摘要,或者直接用大上下文模型(Claude 3 200K,Gemini 1.5 1M)。RAG 通常是默认正确答案。

提示词能在不同模型间复用吗?

通用技巧(清晰指令、少样本、格式说明、CoT)能复用;具体措辞、格式偏好(Claude 偏爱 XML)、tool calling 语法不能。务必在目标模型上重新测。

能自动优化吗?

能。APE、DSPy、遗传搜索都可以。先手工调到能用,再自动化。

XML、JSON、纯文本怎么选?

简单提示词用纯文本;结构化输入输出用 JSON;复杂多块提示词用 XML(特别是 Claude)。三个都行,取决于下游解析器。

CoT、ToT、GoT 什么时候用哪个?

技术结构适合成本
CoT线性链多步推理、数学、逻辑1–2x
ToT树形搜索多解空间、规划、谜题5–50x
GoT任意 DAG并行处理、合并洞察视情况而定

未来方向?

多模态提示词、自动化更紧(DSPy、APE)、激进压缩、meta-prompting(用提示词生成提示词)、具身 agent。这门技能不会消失,只会从手工措辞往优化目标设计、评估框架搭建、系统编排迁移。

收束

提示词工程从最初的试错,已经成长为一个有研究支撑、有可复用工具的工程学科。基础——清晰的指令、合适的示例、稳定的输出格式——放之四海而皆准。Chain-of-Thought、Tree of Thoughts 这类进阶技术,把"裸跑"做不到的能力解锁出来。APE、DSPy 这类框架把这些实践搬上生产。

但仅有技术不够。真正有效的提示词工程需要四样东西:

  • 经验主义:什么都要测。在某个模型某个任务上有效的方法,换一个未必有效。
  • 迭代:第一版几乎不可能是最好的版本。基于真实失败修。
  • 评估:没有指标就只是在猜。
  • 背景判断:理解模型擅长什么、任务要什么、在成本/延迟/质量之间怎么取舍。

简单开始。持续度量。反复迭代。最好的提示词,不是最巧妙的,而是能稳定解决你问题的那一个。

参考文献

  • Brown et al., 2020. Language Models are Few-Shot Learners. NeurIPS.
  • Wei et al., 2022. Chain-of-Thought Prompting Elicits Reasoning in Large Language Models. NeurIPS.
  • Kojima et al., 2022. Large Language Models are Zero-Shot Reasoners. NeurIPS.
  • Wang et al., 2022. Self-Consistency Improves Chain of Thought Reasoning in Language Models. ICLR.
  • Yao et al., 2023. Tree of Thoughts: Deliberate Problem Solving with Large Language Models. NeurIPS.
  • Besta et al., 2023. Graph of Thoughts: Solving Elaborate Problems with Large Language Models. AAAI.
  • Yao et al., 2022. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR.
  • Zhou et al., 2022. Large Language Models Are Human-Level Prompt Engineers. ICLR.
  • Khattab et al., 2023. DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines. arXiv.
  • Jiang et al., 2023. LLMLingua: Compressing Prompts for Accelerated Inference of Large Language Models. EMNLP.
  • Liu et al., 2021. What Makes Good In-Context Examples for GPT-3? arXiv.
  • Lu et al., 2022. Fantastically Ordered Prompts and Where to Find Them. ACL.
  • Sclar et al., 2024. Quantifying Language Models’ Sensitivity to Spurious Features in Prompt Design. ICLR.
  • Min et al., 2022. Rethinking the Role of Demonstrations: What Makes In-Context Learning Work? EMNLP.

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub