02-任务描述设计

要点

  • 上一节确立了五条原则,这一节开始拆第一条:明确任务
  • 任务描述的核心问题是:模型读完这段话之后,知不知道该做什么、做到什么程度
  • 模糊的任务会让模型自行脑补——脑补的方向不可控
  • 三个层次:动词要可执行、对象要有边界、结果要可验证
  • 复杂任务需要拆解成步骤,但拆解方式会影响模型的行为
  • 任务描述不是一次写定的——它需要跟着测试集里的失败案例一起迭代

1. 一个典型的问题

先看一个实际场景。后端要做一个「智能客服摘要」功能:把一段客服对话总结成一条简短的摘要,存到数据库里供后续检索。

第一版 Prompt 可能是这样的:

// src/services/prompt/chat-summary.ts
const CHAT_SUMMARY_PROMPT = `
请总结以下客服对话。
`

这个 Prompt 的问题在哪?

  • 「总结」是一个开放动作——模型不知道你要 20 字还是 200 字,不知道重点放在用户诉求还是处理结果,不知道用什么人称
  • 没有指定输出长度,模型可能输出 300 字
  • 没有指定关注重点,模型可能关注了对话中的闲聊部分
  • 没有指定输出格式,可能是散文、可能是列表

换一版具体一点的:

const CHAT_SUMMARY_PROMPT = `
你是一个客服对话摘要助手。
 
输入:一段客服和用户的对话记录。
 
任务:用一句话总结用户的核心诉求和处理结果。
 
要求:
- 长度不超过 50 字
- 格式:「用户反馈{问题},{处理结果}」
- 如果问题未解决,处理结果写「待处理」
- 不要包含客服的寒暄和流程性话语
`

两版 Prompt 的差别不在于字数,而在于「模型读完之后,是否知道该做什么、做到什么程度」。第二版做到了。

2. 任务描述的三个层次

把任务描述的质量拆开看,可以分成三个层次,每个层次解决一个问题。

第一层:动词要可执行

模型需要知道该执行什么动作。「分析」「处理」「看看」这类动词太宽泛。换成具体的动作词:

模糊动词 ❌可执行动词 ✅
分析提取、分类、排序、对比、校验
处理翻译、改写、补全、格式化、脱敏
看看检查、验证、识别、匹配
优化精简(到 N 字以内)、扩展(补充三个示例)、重组(按时间排序)

来看一个对比:

// 模糊 ❌ — 「优化」是什么意思?
const prompt = '优化这段产品描述'
 
// 可执行 ✅ — 模型知道该做什么
const prompt = '将以下产品描述精简到 100 字以内,保留核心卖点,去掉修饰性形容词。'

第二层:对象要有边界

模型需要知道操作的对象是什么、范围在哪里。

// 对象不明确 ❌ — 「关键字段」是哪些?
const prompt = '提取订单中的关键字段'
 
// 对象明确 ✅ — 字段列表固定,模型不会自由发挥
const prompt = `
从以下 JSON 订单数据中提取这些字段:
- orderId:订单号
- userId:用户 ID
- totalAmount:总金额(分)
- status:订单状态
- createdAt:创建时间
 
以 JSON 对象输出,只包含以上五个字段。
`

对象没有边界时,模型会按自己的理解选。不同次调用可能选不同的字段,下游代码就炸了。

第三层:结果要可验证

模型输出完之后,你怎么判断它对不对?如果连你自己都说不清期望的输出是什么,模型更说不清。

// 不可验证 ❌ — 「合适」是什么标准?
const prompt = '给这段代码起一个合适的函数名'
 
// 可验证 ✅ — 有明确的命名规范可以对照
const prompt = `
根据以下命名规范,给函数起名:
- 动词开头(get/set/create/delete/update/check)
- camelCase 格式
- 名字要体现输入和输出
 
输入:一个用户 ID,查询该用户的所有订单
输出:订单数组
 
函数名:
`

「可验证」不意味着「唯一正确答案」,而是说「有一个标准可以判断输出是否合格」。命名规范就是一个标准——符合规范的通过,不符合的不通过。

3. 复杂任务的拆解

单个动作能描述清楚的任务不多。大部分实际业务需要多个步骤。这时候需要把任务拆解成子任务。

拆解方式会直接影响模型的执行效果。两种常见的拆解思路:

思路一:按步骤顺序拆

把任务拆成一个线性的步骤链,每一步依赖上一步的输出。

// src/services/prompt/article-writer.ts
const ARTICLE_WRITER_PROMPT = `
根据用户提供的主题,撰写一篇技术博客。按以下步骤执行:
 
步骤 1:列出 3-5 个候选标题
步骤 2:从候选标题中选择最适合的一个
步骤 3:根据选定的标题,写出文章大纲(3-5 个章节)
步骤 4:根据大纲,撰写每个章节的内容
步骤 5:检查全文,修正语法错误和不连贯的地方
 
每个步骤的输出格式:
步骤 1 输出:标题列表,每行一个
步骤 2 输出:选定的标题
步骤 3 输出:大纲,用 - 列出章节
步骤 4 输出:完整文章内容(Markdown 格式)
步骤 5 输出:修正后的完整文章内容
 
最终只输出步骤 5 的结果。
`

这种拆解适合步骤之间有严格依赖关系的场景。但有一个问题:模型在同一个回复里要执行所有步骤,中间步骤的输出会占用上下文窗口,而且模型可能跳过某些步骤。

思路二:按职责拆,每步独立调用

把每个子任务拆成独立的 Prompt,由后端代码控制调用链。

// src/services/prompt/article-writer-pipeline.ts
import { callModelAPI } from '@/lib/llm'
 
// 每个步骤一个独立的 Prompt 函数
async function generateTitles(topic: string): Promise<string[]> {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: '根据用户提供的主题,生成 5 个技术博客标题。每行一个标题。',
      },
      { role: 'user', content: topic },
    ],
    temperature: 0.8,
  })
  return response.choices[0].message.content.split('\n').filter(Boolean)
}
 
async function selectBestTitle(titles: string[]): Promise<string> {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: '从以下标题中选择最适合技术博客的一个。只输出标题文本,不要解释。',
      },
      { role: 'user', content: titles.join('\n') },
    ],
    temperature: 0,
  })
  return response.choices[0].message.content.trim()
}
 
async function generateOutline(title: string): Promise<string[]> {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: '根据文章标题,生成 3-5 个章节标题。每行一个章节。',
      },
      { role: 'user', content: title },
    ],
    temperature: 0.3,
  })
  return response.choices[0].message.content.split('\n').filter(Boolean)
}
 
async function writeSection(title: string, section: string): Promise<string> {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: `你正在撰写一篇标题为「${title}」的技术博客。请撰写「${section}」章节的内容。800-1200 字,包含代码示例。Markdown 格式。`,
      },
      { role: 'user', content: `请撰写「${section}」章节` },
    ],
    temperature: 0.5,
  })
  return response.choices[0].message.content
}
 
// Pipeline 编排
export async function writeArticle(topic: string): Promise<string> {
  const titles = await generateTitles(topic)
  const bestTitle = await selectBestTitle(titles)
  const outline = await generateOutline(bestTitle)
 
  // 每个章节可以并行写
  const sections = await Promise.all(
    outline.map((section) => writeSection(bestTitle, section))
  )
 
  return `# ${bestTitle}\n\n${sections.join('\n\n---\n\n')}`
}

这种拆解的好处:

  1. 每个 Prompt 职责单一——更容易调试,更容易写测试
  2. 中间结果可控——每一步的输出都能独立检查和记录
  3. Token 用量可控——不会在一个大 Prompt 里消耗大量上下文
  4. 可插拔——某一步效果不好,只改那一步的 Prompt,不影响其他步骤

代价是多次 API 调用带来的延迟和成本。但对大多数业务场景来说,可控性比速度更重要。

4. 任务描述的粒度

任务拆到什么粒度合适?没有一个固定的答案,但有几个判断标准。

标准一:一个 Prompt 只做一件事

如果一个 Prompt 里出现了「同时」「并且」「另外」这类连接词,可能意味着它承担了两个任务。

// 两件事混在一个 Prompt 里 ❌
const prompt = `
分析这段代码的性能问题,并且生成对应的单元测试。
`
 
// 拆成两个独立的 Prompt ✅
const perfPrompt = '分析这段代码的性能问题,列出 top 3 瓶颈。'
const testPrompt = '根据以下代码和性能分析结果,生成单元测试。'

标准二:失败时能定位

如果一个 Prompt 效果不好,你能不能定位到是哪部分指令的问题?如果整段 Prompt 是一个整体,改任何一句都可能影响其他部分,调试成本会很高。

标准三:能独立测试

每个子任务都应该能独立准备输入、独立验证输出。如果两个子任务共享同一个 Prompt,它们就没办法独立测试。

5. 动作词的语义系统

一个容易被忽略的细节:同一篇 Prompt 里,描述同类动作的词应该保持一致。

// 混用近义词 ❌ — 模型可能理解为不同的操作
const prompt = `
1. 检查代码中的类型问题
2. 检测性能瓶颈
3. 审查安全漏洞
4. 校验命名规范
`
 
// 统一动词 ✅ — 模型知道这四个都是「找问题」
const prompt = `
检查以下代码,按类别报告问题:
1. 类型安全:any 滥用、未标注类型、不安全断言
2. 性能问题:不必要的循环、内存泄漏、重复计算
3. 安全风险:SQL 注入、XSS、硬编码密钥
4. 命名违规:不符合项目命名规范的标识符
`

第二版里,四个检查项共享同一个动作「检查」,只是类别不同。模型更容易理解这是一个统一的扫描任务。

在工程实践中,建议先钉住一套动作词,后续新增 Prompt 时复用这套词系:

动作含义
检查扫描输入,找出不符合规则的地方
提取从输入中抽取特定信息
生成根据输入创建新内容
转换把输入从一种格式变成另一种格式
校验判断输入是否符合给定规则
分类把输入归入预定义的类别

6. 一个综合示例

把这一节的内容合在一起,写一个实际的任务描述:

// src/services/prompt/data-extractor.ts
 
// ❌ 模糊版本
const vaguePrompt = '从用户输入中提取订单信息'
 
// ✅ 具体版本
const EXTRACT_ORDER_PROMPT = `你是一个订单信息提取助手。
 
## 任务
从用户的自然语言输入中,提取订单相关字段。
 
## 需要提取的字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| product_name | string | 商品名称 |
| quantity | number | 数量,默认为 1 |
| unit_price | number | 单价,单位:分 |
| delivery_date | string | 期望配送日期,格式 YYYY-MM-DD |
| address | string | 收货地址 |
 
## 处理规则
- 数量用中文数字表示时,转成阿拉伯数字(「三个」→ 3)
- 金额用元表示时,转成分(「100 元」→ 10000)
- 日期用相对时间表示时,基于今天计算绝对日期
- 缺少的字段填 null
- 如果输入完全不包含订单信息,返回空对象 {}
 
## 输出格式
JSON 对象,只包含以上字段。不要输出其他说明文字。`
 
// Hono 路由
const app = new Hono()
 
app.post('/extract-order', async (c) => {
  const { text } = await c.req.json()
 
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: EXTRACT_ORDER_PROMPT },
      { role: 'user', content: text },
    ],
    temperature: 0,
    response_format: { type: 'json_object' },
  })
 
  return c.json({ order: JSON.parse(response.choices[0].message.content) })
})
 
export default app

这段代码体现了三个层次的到位:

  1. 动词可执行:「提取」→ 明确了从自然语言到结构化数据的转换
  2. 对象有边界:五个字段,每个字段的类型和含义都列出来了
  3. 结果可验证:处理规则明确,可以针对每条规则设计测试用例

7. 常见的任务描述反模式

反模式一:堆砌形容词

请给出一个详细的、全面的、深入的、专业的分析

这些形容词不携带信息。模型不会因为看到「深入的」就给出更深入的分析。把形容词删掉,换成具体的要求:

分析这段代码,覆盖以下三个维度:
1. 时间复杂度
2. 空间复杂度
3. 潜在的边界问题

反模式二:假设模型知道你的约定

按照我们的规范处理这段数据

模型不知道「我们的规范」是什么。要么把规范贴进 Prompt,要么引用一个具体的文档:

按照以下数据处理规范处理:
1. 字段名使用 snake_case
2. 时间字段统一使用 UTC ISO 8601 格式
3. 金额字段单位为分,类型为 integer

反模式三:任务描述和格式约束混在一起

分析这段代码并且输出 JSON 格式的结果要求包含 severity 和 message 两个字段另外还要检查性能问题

信息没有结构,模型很难从中提取出清晰的指令。应该用结构化的方式组织:

## 任务
分析代码问题

## 检查范围
- 类型安全
- 性能问题

## 输出格式
JSON 数组,每个元素包含 severity 和 message 字段

总结

回顾这一节的要点:

  • 任务描述的核心:模型读完之后知道该做什么、做到什么程度
  • 三个层次:动词可执行、对象有边界、结果可验证
  • 复杂任务需要拆解,按步骤拆或按职责拆,后者更可控
  • 一个 Prompt 只做一件事,失败时能定位,能独立测试
  • 动作词要统一,不要混用近义词
  • 反模式:堆砌形容词、假设模型知道你的约定、任务和格式混在一起

下一篇讲角色设定设计——system prompt 怎么写,才能让模型在不同场景下表现出一致的行为特征。