发布于

Prompt Engineering一个「简单」的摘要生成管道

作者
  • avatar
    名称
    宋元平
    Twitter

TL;DR: 我做了一个Chrome扩展,能从AI对话中自动生成可点击的目录大纲。摘要生成听起来很简单——这可以说是LLM应用的"hello world"。但实际上,要让它在1K到100K+ token的对话中稳定可靠,需要真正的工程化:基于任务的评估框架、将输入token减少63%的结构化压缩、动态长度预算,以及应对长上下文指令遵循的prompt sandwich技巧。这篇文章分享了真正有效的技术,以及让每次改动都可信赖的评估驱动工作流。

任务:比想象中难得多

Chat Navigator 是我正在开发的Chrome扩展。它能读取你的AI对话——ChatGPT、Claude、Gemini、DeepSeek——并生成可点击的目录大纲,让你能快速跳转到长对话中的特定主题。

核心管道很简单:消息输入,结构化TOC输出。单次LLM调用。

但质量要求是具体的:

  • 覆盖率:捕捉对话中的重要转折点,而不仅仅是开头的主题
  • 锚点准确性:每个TOC条目都有一个指向特定消息的refId——点击后应该跳转到正确的位置
  • 标签质量:主题标签需要简洁、易扫描、容易辨认
  • 长度控制:5条消息的对话不应该生成12个主题,200条消息的对话也不应该只有3个

"总结这段对话"是LLM应用的hello world。"生成一个带有精准跳转锚点、动态长度、在1K-100K token输入中保持一致质量的层级大纲"——这完全是另一个任务。

我花了大约三天时间(2025年12月30日到2026年1月1日)集中进行这个管道的prompt engineering,跑了20多次评估,覆盖8个管道版本。这篇文章把我学到的东西提炼成了真正有效的技术。

先建评估:确立北极星

最大的突破不是某个prompt技巧,而是在开始迭代prompt之前先搭建评估体系。

回头看这很明显,但我见过很多prompt engineering的实践(包括我自己早期的尝试),工作循环是这样的:改prompt → 看几个输出 → 感觉是不是"好了一点"。这种方式大概两轮迭代之后就会迷失方向——你完全不知道自己到底在进步还是退步。

基于任务的评估,而非字符串相似度

我没有可以对比的标准答案TOC。即使有,字符串相似度也是很差的衡量方式——同一段对话有很多合理的大纲组织方式。

我设计的是导航任务。评估数据集中的每段对话(共26段,难度范围很广)都有一组nav_tasks:用户回顾对话时可能想要找到的内容。每个任务包含:

  • 描述(例如:"找到他们讨论延迟和准确性权衡的地方")
  • target_refId——这个主题所在的消息
  • importance权重(1、2或3)

LLM评判器评估的是:用户能在TOC中找到这个任务吗?如果能,锚点是否跳转到了正确的位置?

这直接对应了产品层面真正重要的东西。我不在乎大纲用的词跟某个参考答案是否一样——我在乎的是用户能不能用它来导航。

评判器Prompt

评判器从六个维度评分,权重反映产品优先级:

task_coverage以45%占据主导,因为它就是产品本身:用户能找到他们要找的东西吗?anchor_accuracy以20%紧随其后,因为跳转到错误的消息是糟糕的体验。label_qualitystructure_quality各占15%,关系到可扫描性。concisenessfaithfulness是小的护栏。

评判器使用了带有明确容差范围的匹配协议:

等级距离描述
exactrefId == target完美锚点
near|refId - target| <= 2足够接近
acceptable|refId - target| <= 5可用但不精确
far> 5视为weak hit或miss

每个任务评分为hit(1.0)、weak_hit(0.5)或miss(0.0),按importance加权。

有一个我特别满意的设计选择:评判器输出结构化的task_results,包含每个任务的推理过程。以下是输出schema的精简片段:

{
  "task_results": [
    {
      "task_id": "T1",
      "importance": 3,
      "outcome": "hit",
      "matched_path": "Authentication Setup > JWT Token Strategy",
      "anchor_grade": "near",
      "label_grade": "good",
      "notes": "Label is specific and refId lands 1 message from target"
    }
  ],
  "top_fixes": [
    "Add a subtopic for the Redis caching decision at message #47",
    "Split 'Backend Architecture' — it covers 3 unrelated topics"
  ]
}

当分数下降时,我能看到具体哪些任务从hit变成了miss,以及原因。这把模糊的"分数降了"变成了可操作的"任务T7回归了,因为模型把两个主题合并成了一个笼统的分类"。top_fixes字段直接给出了下一步要做什么。

基于程序的护栏指标

除了LLM评判器,我还追踪了三个确定性指标:

  • length_top_topics:主题数量与目标的接近程度
  • length_total_nodes:大纲总节点数与目标的接近程度
  • compression_ratio:输入token / 输出标签token

这些能捕获评判器可能遗漏的失败模式:"覆盖率不错但大纲太长了"或者"标签还行但漏掉了一半对话内容"。计算成本很低,也不消耗API额度。

一个关键的设计选择:评估中的长度目标和管道自身prompt中的目标由同一个 outlineBudget() 函数推导而来。这防止了评估器/管道之间的偏差——评估器永远不会用管道不知道的目标来打分。

迭代循环

每次prompt修改都经过:

  1. 查看每个任务的推理日志——哪些任务是miss?
  2. 修改prompt、schema或输入管道
  3. 重新运行评估
  4. 检查:具体的miss有没有改善?其他地方有没有回归?

这就是让后文中每项技术都可信赖的基础。没有这个,我无法区分"我检查的两个例子上感觉好了一点"和"在26段不同对话上确实有了提升"。

技巧一:结构化输入压缩

一个数字:从管道v3.1到v3.7,每次LLM调用的平均输入token从31,704降到了11,554——减少了63%

核心机制是一个叫extractMarkdownStructure的函数。在对话送入LLM之前,每条较长的assistant消息会被压缩:

  • 标题始终保留——它们是大纲生成最强的结构信号
  • 列表项采样——保留前N项,加上截断标记
  • 代码块和表格替换为简短标记
  • 长段落截断到token预算(保留首尾)

压缩级别是可调的。我做了消融实验:

  • 每段保留多少token
  • 保留1个还是3个列表项
  • 对表格的删减力度

63%是最佳平衡点——可以更激进,但再往下质量就会开始下降。

这是一个三重收益:

  1. 成本:更少的输入token = 更低的API调用成本
  2. 延迟:更少的输入token = 更快的首token响应时间
  3. 质量:模型注意力机制的噪音更少 = 输出质量略有提升

第三点是一致的但不是魔法——就是更少的干扰。一段50,000 token的对话如果包含完整的代码块、表格和多段解释,模型就有更多机会分心。把这些精简到结构信号,模型就能集中在大纲生成真正需要的信息上。它不需要读200行代码才能知道某个章节是关于"实现认证流程"的。

关键是,prompt知道压缩的存在。在v3.6中,我加入了明确的# Omissions部分:

# Omissions
- You're provided the general structure of the conversation. Tables, code blocks,
  long paragraphs, long lists, and other non-textual content are omitted from the
  assistant's messages. You can assume that the assistant's messages are
  well-formatted. Headings are 100% provided. Use these as your clue.
- You should focus on the structure, logic, and flow of the conversation.
  Do not try to fill in omitted details.

告诉模型"标题100%保留了,用这些作为线索"很重要。这防止模型花费算力去推测被删掉的内容,把注意力引导到最有用的剩余信号上。

技巧二:动态预算注入

早期版本有一个静态指令:"目标3-8个主题"。这显然不对——5条消息的对话和200条消息的对话不应该使用相同的目标范围。

我改成了动态预算计算。根据对话中assistant的token量(T)和轮次数,管道在每次调用前计算具体目标:

// 主题数对数增长——短对话2-3个,长对话上限约10-12个
rawTop = 1 + 2.1 * log2(T / 1500 + 1)
targetTopTopics = clamp(round(rawTop), 1, 12)

// 总节点数线性增长——保持信息密度大致一致
rawTotalNodes = T / 350
targetTotalNodes = clamp(round(rawTotalNodes), targetTopTopics * 2, 72)

targetAvgSubtopics = clamp(round(targetTotalNodes / targetTopTopics - 1), 2, 5)

这些目标被直接注入到prompt指令中:

# Length Target:
- You should generate 7 topics and 38 total subtopics.
- Each topic should on average have around 4 subtopics.
- Allocate more subtopics to high-signal topics and fewer to low-signal topics.

主题数的对数缩放是经验调优的——我试了线性、平方根和对数,log2在评估数据集上产生了最自然的大纲。总节点数的线性缩放意味着内容量翻倍的对话大约获得翻倍的大纲节点数,符合直觉。

v3.5中的一个微妙改进:我在schema中加入了index字段(主题和子主题都有index: 1, 2, 3...),并告诉模型:

"You can use the topic index and subtopic index as counters to keep track of your progress toward the target."

这给了模型一个生成过程中的计数机制。没有它,模型往往在第5-6个主题时就会迷失方向,要么过早停止,要么生成过多。index字段充当了内建计数器。

技巧三:Prompt Sandwich

当对话历史很长——10K、20K甚至50K token——放在开头的指令会失去影响力。模型处理了数千token的对话内容后,到开始生成时,最初的约束已经淡化了。

实践中我观察到:

  • 主题数量偏离目标
  • 输出语言切换(在双语对话中很常见)
  • 锚点纪律性下降

修复方法在结构上很简单:在payload之后重复你的关键动态约束。

Prompt组装变成了刻意的三段式:

┌─────────────────────────────────────────┐
│  Developer message(前)                 │  ← 完整任务契约,所有规则
├─────────────────────────────────────────┤
│  User message(中)                      │  ← 对话内容:5K-50K token
│  <conversation>...</conversation>       │     的聊天消息
├─────────────────────────────────────────┤
│  Developer message(后)                 │  ← 简洁的最终提醒
│  <final_reminder>                       │
│    - 用中文输出                           │
│    - 7个顶层主题                          │
│    - 共38个子主题                         │
│  </final_reminder>                      │
└─────────────────────────────────────────┘

尾部提醒只包含最可能偏移的动态约束:语言主题数子主题数。完整规则集留在前面。后面只是重新锚定模型在处理长payload后最容易忘记的三个数字。

我还配合了第二层强化:在结构化输出schema中加入reasoning字段。

const TocSchema = z.object({
  reasoning: z.string().describe(
    "Think through the key requirements of the task: " +
    "length target for topics, length target for subtopics, " +
    "and output language requirement."
  ),
  toc: z.array(/* ... topic schema ... */)
})

模型必须在生成toc之前填写reasoning。这迫使它在开始结构化输出生成时再次重述约束。

两层强化:

  1. 尾部developer message在长payload之后重述约束
  2. Schema要求的reasoning在生成输出之前重新锚定约束

两者结合,可测量地提升了指令遵循。这在转向单次one-shot生成后尤其重要——没有第二次聚合传递来纠正结构错误。

技巧四:锚点语义——从UX角度思考

一个影响超出预期的小改动。在v3.1中,我把refId的指令从:

refId MUST be the earliest message id introducing the idea.

改为:

refId MUST be the earliest message id where a reader can start consuming the content for this item. Prefer anchoring to the assistant's answer if the user asks a question and the answer immediately follows.

区别在于:"这个想法在哪里第一次被提到" vs "用户应该跳转到哪里"。

如果用户在第14条消息问"怎么设置认证?",assistant在第15条消息回答,那refId应该是15。用户想看的是答案,不是他们自己的问题。

这是在prompt语义中编码UX意图。模型天生不知道你的产品是一个导航工具。把"锚点"重新定义为"从哪里开始阅读"而不是"想法从哪里起源",就能让模型的行为与用户点击TOC条目时的心理模型对齐。

结果

使用同一个LLM评判器prompt、全部运行在gpt-4.1-mini上的各管道版本质量分数:

版本toc_quality关键变化
baseline0.831One-shot前的分块管道(gpt-4.1-mini
one-shot-v10.807初始one-shot架构(质量下降)
v3.10.819动态预算,refId锚点语义
v3.20.808加入输入压缩(extractMarkdownStructure
v3.40.808压缩参数调优
v3.60.826Omissions部分,prompt sandwich,index计数器
v3.70.863Schema reasoning强化,prompt打包

Token效率轨迹:

版本平均输入Token相比v3.1变化
v3.131,704
v3.213,118-59%
v3.410,633-67%
v3.711,554-64%

这两张图放在一起讲述的故事是:质量和效率同时提升了,但通过不同时间点的不同机制。

v3.2是token效率的拐点——输入压缩减少了59%的token,同时质量保持平稳。质量没有下降,因为压缩是保结构的,而那些代码块和表格本来就对大纲生成没有太大帮助。

v3.6/v3.7是质量的拐点——prompt sandwich、reasoning schema和omissions部分改善了指令遵循,而token成本几乎没有变化。

与baseline相比,最终的v3.7管道得分0.863 vs 0.831——有意义的提升——同时使用的输入token比one-shot起步时减少了64%。这种结果只有在把整个管道作为系统来对待时才能实现——输入整形、prompt结构、schema设计、评估框架——而不仅仅是重写指令文本。

模型选择

在开始prompt迭代之前,我在相同条件下做了多模型对比:

模型toc_qualityn
gpt-4.1-mini0.83126
gpt-5.20.84225
x-ai/grok-4.1-fast0.82523
gpt-4.10.82326
gpt-4o-mini0.74526
gpt-4.1-nano0.56226

第一梯队(gpt-4.1-mini、gpt-5.2、grok、gpt-4.1)彼此相差约2个百分点。之后质量断崖式下跌——gpt-4o-mini掉了8个点,nano在这个任务上基本不可用。

gpt-4.1-mini拥有最佳的质量/成本/可靠性权衡。启示是:用你的评估体系来实证选择模型,然后把精力放在管道工程上,而不是不断换模型。gpt-4.1-mini和gpt-5.2之间的差距,比我后来仅通过prompt和管道改进缩小的差距还小。

Prompt演进一览

对于好奇各版本中prompt文本到底改了什么的读者:

版本Prompt变化管道/Schema变化
v1(分块)3个独立prompt:chunk、aggregate、full两阶段chunk-then-merge管道
one-shot-v1单个prompt,静态"target 3-8 topics"One-shot架构
v3动态${targetTopics}${targetSubtopics}注入outlineBudget()函数
v3.1refId = "reader开始阅读的位置";需求提取规则
v3.2–v3.4(未改动)extractMarkdownStructure、压缩调优
v3.5加入用于计数的index字段Schema更新
v3.6加入# Omissions部分;<final_reminder>Prompt sandwich(三段式结构)
v3.7(未改动)Schema中加入reasoning字段

一个有趣的规律:v3.2到v3.4没有任何prompt文本改动——所有改进都来自管道层面的输入整形。而最大的质量跃升(v3.6到v3.7)来自结构性技术(prompt排序、schema设计),而不是重写核心指令文本。

这说明在很多情况下,杠杆不在于找到更好的措辞,而在于塑造措辞周围的一切。

总结

1. 在迭代prompt之前先建评估体系。 这不是新建议,但具体设计很关键。基于任务的评估(不是字符串相似度)配合每个任务的推理,给了我一个调试工具,而不只是一个数字。当某些东西回归时,我知道具体哪些任务坏了、为什么坏了。评估体系让每次决策都成为可衡量的实验。

2. 塑造模型输入,而不仅仅是模型指令。 结构化输入压缩产生了这个项目中最大的单项效率提升:减少63%的token。它还通过减少噪音改善了质量。如果你在处理长上下文输入,认真审视你发送给模型的内容。很可能有你可以压缩的结构化内容——表格、代码、重复列表——而不会丢失模型在你的任务中需要的信号。

3. 对于长上下文结构化输出,使用prompt sandwich。 在payload之后重复你的关键动态约束。配合schema层面的reasoning,迫使模型在生成输出前重述这些约束。成本低、易实现,且可测量地提升指令遵循。

4. 让你的结构化输出schema发挥作用。 reasoning字段和index计数器不是装饰品。它们给了模型在生成过程中自我监控的脚手架。如果你在使用结构化输出,想想你可以加入哪些字段来帮助模型保持正轨——不仅仅是你在最终输出中需要的字段。

5. 在prompt语义中编码UX意图。 当你的prompt定义"锚点"或"引用"这样的术语时,从终端用户的角度思考你实际想要什么行为。"想法首次出现的地方"和"用户应该跳转到的地方"是不同的行为,需要不同的prompt定义。

6. 设计匹配你产品的评估指标,而不是学术基准。 我的评估把task coverage的权重设为45%,faithfulness只有2%。这个权重反映了对导航工具真正重要的东西。你的产品有不同的优先级——让你的评估体系反映这些优先级。

这整个过程的元教训:生产级结构化输出的prompt engineering不是在寻找魔法词语,而是系统工程。Prompt、输入管道、输出schema、token预算和评估框架必须共同成熟。单独改进任何一个很快就会遇到收益递减。把它们作为一个系统来改进,才能同时获得更好的质量和更低的成本。