07-Few-shot示例设计

要点

  • 前面六节都是在用文字告诉模型该怎么做——但有时候「给模型看一个例子」比「写一段解释」更有效
  • Few-shot 是在 Prompt 里放一两个输入输出对,让模型从示例中学到格式、风格和判断标准
  • 示例的选择比数量更重要:两个好示例胜过十个普通示例
  • 示例需要覆盖正常情况和边界情况,但不能互相矛盾
  • 动态示例选择:根据用户输入从示例库中检索最相关的示例,而不是固定写死

1. 为什么 Few-shot 有效

先看一个对比。任务是把用户的自然语言翻译成 SQL。

Zero-shot(没有示例)

const prompt = `
将用户的自然语言翻译成 SQL 查询。
数据库表结构:
- users(id, name, email, created_at)
- orders(id, user_id, amount, status, created_at)
 
用户输入:最近 7 天注册了多少用户
SQL:
`

模型可能输出:

SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL 7 DAY

大部分时候这是对的。但如果用户的表述更复杂一些:

用户输入:上个月消费超过 1000 元的用户有几个

模型可能会在 amount 的单位上犯错(元 vs 分),或者在时间范围上算错。

Few-shot(加两个示例)

const prompt = `
将用户的自然语言翻译成 SQL 查询。
数据库表结构:
- users(id, name, email, created_at)
- orders(id, user_id, amount, status, created_at)
 
注意:amount 单位是分,需要除以 100 才是元。
 
示例:
输入:「最近 3 天注册的用户数」
SQL:SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '3 days'
 
输入:「所有已取消订单的总金额(以元为单位)」
SQL:SELECT SUM(amount) / 100.0 FROM orders WHERE status = 'cancelled'
 
输入:上个月消费超过 1000 元的用户有几个
SQL:
`

两个示例起到了三个作用:

  1. 格式对齐——模型知道 SQL 该怎么写、时间函数用什么语法
  2. 单位提醒——示例里展示了 / 100.0 的转换,模型会复用这个模式
  3. 边界提示——「已取消订单」暗示 status 字段有枚举值

这就是 Few-shot 的价值:用具体的例子告诉模型「这样做是对的」,比用文字解释规则更直接。

2. 示例的数量

一个常见的问题是:放几个示例合适?

经验规律:

示例数量适用场景
0 个(Zero-shot)任务简单明确,模型本身就能做好
1-2 个需要对齐格式或风格
3-5 个任务有一定复杂度,需要覆盖多种情况
5 个以上任务非常复杂,或者需要覆盖很多边界情况

超过 5 个示例时,要警惕两个问题:

  1. Token 消耗——每个示例都会占用上下文窗口。示例太长太多,留给用户输入和模型输出的空间就不够了
  2. 示例之间可能矛盾——示例越多,两个示例暗示不同处理方式的概率越高,模型会困惑

示例数量的判断标准

放示例之前先问自己:这个示例是否在教模型一件它本来不知道的事?

  • 如果任务本身很直观(比如翻译),不需要示例
  • 如果任务需要特定的格式或领域知识(比如 SQL 方言、内部 API 调用规范),需要示例
  • 如果任务的边界情况容易出错(比如金额单位转换),需要覆盖边界情况的示例

3. 示例的选择

示例的质量比数量重要。好的示例应该满足:

标准一:示例要典型

示例应该代表最常见的输入类型,而不是特殊情况。

// ❌ 示例太极端
const examples = [
  { input: '帮我退掉那个我三年前买的、中间退过两次后来又买回来的东西', output: '...' },
]
 
// ✅ 示例典型
const examples = [
  { input: '退订单 ORD-001', output: '{"intent": "refund", "orderId": "ORD-001"}' },
  { input: '查一下 ORD-002 到哪了', output: '{"intent": "query_order", "orderId": "ORD-002"}' },
]

标准二:示例要覆盖关键差异

如果任务有几种不同的处理方式,每种至少给一个示例。

// ✅ 覆盖不同意图
const examples = [
  { input: '退订单 ORD-001', output: '{"intent": "refund"}' },       // 退款
  { input: 'ORD-002 到哪了', output: '{"intent": "query_order"}' },   // 查询
  { input: '取消 ORD-003', output: '{"intent": "cancel"}' },          // 取消
  { input: '这个质量太差了', output: '{"intent": "complaint"}' },      // 投诉
]

如果只给「退款」的示例,模型遇到「投诉」就不知道怎么处理。

标准三:示例之间不矛盾

// ❌ 示例矛盾
const examples = [
  { input: '退订单', output: '{"action": "refund"}' },      // 输出 action 字段
  { input: '查订单', output: '{"intent": "query"}' },        // 输出 intent 字段
]
 
// ✅ 示例一致
const examples = [
  { input: '退订单', output: '{"intent": "refund"}' },
  { input: '查订单', output: '{"intent": "query"}' },
]

字段名不一致会让模型困惑——它不知道该用 action 还是 intent

标准四:示例要简短

示例越长,Token 消耗越大,而且模型更容易从短示例中学到核心模式。

// ❌ 示例太长
const examples = [
  {
    input: '你好,我想问一下,就是我之前下了一个订单,订单号好像是 ORD-001 吧,然后我想知道这个订单现在是什么状态了,能不能帮我查一下',
    output: '{"intent": "query_order", "orderId": "ORD-001"}',
  },
]
 
// ✅ 示例简短
const examples = [
  { input: '查 ORD-001 的状态', output: '{"intent": "query_order", "orderId": "ORD-001"}' },
]

4. 示例在 Prompt 中的组织方式

方式一:输入输出交替

const prompt = `
意图分类。
 
输入:退订单 ORD-001
输出:{"intent": "refund", "orderId": "ORD-001"}
 
输入:ORD-002 到哪了
输出:{"intent": "query_order", "orderId": "ORD-002"}
 
输入:${userInput}
输出:
`

最常见的方式,简单直接。

方式二:用角色消息包装

const messages = [
  { role: 'system', content: '你是一个意图分类助手。' },
  // 示例用 user-assistant 对表示
  { role: 'user', content: '退订单 ORD-001' },
  { role: 'assistant', content: '{"intent": "refund", "orderId": "ORD-001"}' },
  { role: 'user', content: 'ORD-002 到哪了' },
  { role: 'assistant', content: '{"intent": "query_order", "orderId": "ORD-002"}' },
  // 实际输入
  { role: 'user', content: userInput },
]

这种方式更接近模型训练时的对话格式,有些场景下效果更好。但要注意:示例中的 assistant 消息会占用对话轮次,太多的话需要截断。

方式三:XML 标签分隔

const prompt = `
意图分类。
 
<example>
<input>退订单 ORD-001</input>
<output>{"intent": "refund", "orderId": "ORD-001"}</output>
</example>
 
<example>
<input>ORD-002 到哪了</input>
<output>{"intent": "query_order", "orderId": "ORD-002"}</output>
</example>
 
<input>${userInput}</input>
<output>
`

XML 标签让示例的结构更清晰,模型更容易区分示例和实际输入。适合示例比较复杂的场景。

5. 动态示例选择

固定的示例有两个问题:

  1. 示例和用户输入不相关——用户问退款,示例里却没有退款的案例
  2. Token 浪费——固定示例不管用不用得到,都占上下文

更好的方式是动态选择:根据用户输入,从示例库中检索最相关的示例。

// src/services/prompt/example-selector.ts
 
type Example = {
  input: string
  output: string
  tags: string[]  // 用于匹配的标签
}
 
const EXAMPLE_LIBRARY: Example[] = [
  { input: '退订单 ORD-001', output: '{"intent": "refund"}', tags: ['refund'] },
  { input: '查 ORD-002 的状态', output: '{"intent": "query_order"}', tags: ['query'] },
  { input: '取消 ORD-003', output: '{"intent": "cancel"}', tags: ['cancel'] },
  { input: '商品质量太差了', output: '{"intent": "complaint"}', tags: ['complaint'] },
  { input: '发错货了怎么办', output: '{"intent": "complaint"}', tags: ['complaint'] },
  { input: '能改收货地址吗', output: '{"intent": "other"}', tags: ['other'] },
]
 
// 简单的标签匹配
function selectExamples(userInput: string, maxCount: number = 2): Example[] {
  // 根据关键词匹配标签
  const keywordTagMap: Record<string, string> = {
    '退': 'refund', '退款': 'refund', '退回': 'refund',
    '查': 'query', '状态': 'query', '到哪': 'query',
    '取消': 'cancel',
    '差': 'complaint', '投诉': 'complaint', '错': 'complaint',
  }
 
  const matchedTags = new Set<string>()
  for (const [keyword, tag] of Object.entries(keywordTagMap)) {
    if (userInput.includes(keyword)) {
      matchedTags.add(tag)
    }
  }
 
  // 按匹配度排序,取 top N
  const scored = EXAMPLE_LIBRARY.map((ex) => ({
    example: ex,
    score: ex.tags.filter((t) => matchedTags.has(t)).length,
  }))
 
  return scored
    .sort((a, b) => b.score - a.score)
    .slice(0, maxCount)
    .map((s) => s.example)
}
 
// 使用
function buildPrompt(userInput: string): string {
  const examples = selectExamples(userInput, 2)
 
  const exampleText = examples
    .map((ex) => `输入:${ex.input}\n输出:${ex.output}`)
    .join('\n\n')
 
  return `
意图分类。
 
${exampleText}
 
输入:${userInput}
输出:
`
}

更复杂的场景可以用 embedding 做语义相似度匹配——这就是 RAG 的思路,第 14 章会讲。

6. Few-shot 的常见陷阱

陷阱一:示例太多,Token 爆了

// ❌ 10 个示例,每个 100 字 → 1000 字纯示例
const prompt = `
${examples.map(e => `输入:${e.input}\n输出:${e.output}`).join('\n\n')}
 
输入:${userInput}
输出:
`

示例占了大部分上下文,留给用户输入和模型输出的空间不够。

解决方案:控制示例数量在 2-3 个,用动态选择提高示例的相关性。

陷阱二:示例暗示了错误的模式

// ❌ 示例里的订单号都是 ORD-001,模型可能以为所有订单号都是 ORD-001
const examples = [
  { input: '退 ORD-001', output: '{"orderId": "ORD-001"}' },
  { input: '查 ORD-001', output: '{"orderId": "ORD-001"}' },
]

模型可能从示例中学到「订单号总是 ORD-001」,而不是「订单号从输入中提取」。

解决方案:示例里的具体值要有变化。

陷阱三:示例和规则矛盾

const prompt = `
## 规则
金额单位是分,输出时要转换成元。
 
## 示例
输入:退款 100 元
输出:{"amount": 100}  // ❌ 示例没有做转换,和规则矛盾
`

模型不知道该听规则的还是听示例的。

解决方案:确保示例和规则一致。如果规则说「转换单位」,示例里的金额也要转换。

7. 一个完整的 Few-shot 示例

// src/services/prompt/sql-generator.ts
 
const SQL_EXAMPLES = [
  {
    input: '最近 7 天注册了多少用户',
    output: "SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'",
  },
  {
    input: '消费超过 1000 元的用户',
    output: 'SELECT DISTINCT user_id FROM orders WHERE amount > 100000',
  },
  {
    input: '上个月退款的订单数',
    output: "SELECT COUNT(*) FROM orders WHERE status = 'refunded' AND created_at >= CURRENT_DATE - INTERVAL '30 days'",
  },
]
 
const SQL_GENERATOR_PROMPT = `你是一个 SQL 查询生成助手。
 
## 数据库表结构
- users(id, name, email, created_at)
- orders(id, user_id, amount, status, created_at)
  - amount 单位是分(integer)
  - status 可选值:pending / shipped / completed / cancelled / refunded
 
## 注意事项
- 时间函数使用 PostgreSQL 语法
- 金额以元为单位输入时,在 SQL 中需要乘以 100 转换成分
 
## 示例
${SQL_EXAMPLES.map((e) => `输入:「${e.input}」\nSQL:${e.output}`).join('\n\n')}
 
## 任务
将用户输入翻译成 SQL 查询。只输出 SQL,不要解释。
`
 
export async function generateSQL(userInput: string): Promise<string> {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: SQL_GENERATOR_PROMPT },
      { role: 'user', content: userInput },
    ],
    temperature: 0,
  })
 
  return response.choices[0].message.content.trim()
}

总结

回顾这一节的要点:

  • Few-shot 用具体示例教模型格式、风格和判断标准,比文字解释更直接
  • 示例数量:2-3 个覆盖主要情况即可,不超过 5 个
  • 示例质量:典型、覆盖关键差异、互相不矛盾、简短
  • 三种组织方式:输入输出交替、角色消息包装、XML 标签分隔
  • 动态示例选择比固定示例更高效——根据用户输入检索最相关的示例
  • 常见陷阱:示例太多爆 Token、示例暗示错误模式、示例和规则矛盾

下一篇讲 Chain of Thought 使用边界——什么时候该让模型「想一想」,什么时候不该。