Prompt工程系统化方法论:从玄学到工程
发布日期: 2026/08/10 阅读总量: 2

先看一个真实场景

上周五,我负责的客服工单自动分类功能要上线。我在 GPT-4o 上写了句 prompt:

请从用户工单里提取发货地址,并判断属于 billing、technical 还是 account。

测试集 20 条数据,准确率 80%。上线后真实用户工单一进来,准确率直接崩到 51%。用户会抱怨、会骂人、会一句话里带三个地址。我又加了句"请仔细思考,不要遗漏",准确率降到 48%。同一条 prompt 扔给 Claude 3.5 Sonnet,字段都能对上,但把用户签名里的地址也提取了。

那一刻我意识到:一直在用玄学调参,而不是工程方法。

问题:Prompt Engineering 为什么难

难点不在"写不写得出来",在于没有系统约束:

  • 结果不确定:模型输出是采样,同一个 prompt 换条数据就变。
  • 不可评测:没有准确率定义,就没法优化。
  • 不可复用:prompt 散落在业务代码里,改一个字段要全局搜索。
  • 模型相关:OpenAI 和 Anthropic 对措辞敏感度不同,换模型就重调。

一句话:你把 prompt 当"咒语"调,它就只能给你"玄学"结果。工程化思路是把 prompt 当成"协议":输入、输出、约束、示例、校验都显式定义。

方案对比

方案A:模板拼接

常见做法:在业务代码里把用户输入拼到一个长字符串中。

<?php
// 方案A:模板拼接(反面示例)
// 环境:PHP 8.3 + openai-php/client 0.10
$prompt = "你是客服助手。用户说:{$userInput}。请提取发货地址并分类(billing/technical/account),输出JSON,不要解释。";

$resp = $openai->chat()->create([
    'model' => 'gpt-4o-2024-08-06',
    'temperature' => 0,
    'messages' => [
        ['role' => 'user', 'content' => $prompt],
    ],
]);

$data = json_decode($resp->choices[0]->message->content ?? '', true);
if (!isset($data['shipping_address'])) {
    // 只能靠猜
    $data['shipping_address'] = '';
}

问题:没约束、没示例、没校验。模型输出的字段名和业务代码强耦合,模型一旦多输出字段或改大小写,json_decode 返回 null,报错只能猜。

方案B:结构化Prompt工程

四个组件:

  • 配置:YAML 定义指令和 few-shot。
  • Schema:JSON Schema 定义输出协议。
  • 调用:把配置加载到 messages,response_format 固定 JSON。
  • 校验:返回结果先过 Schema 校验,失败重试或走兜底。
维度方案A 模板拼接方案B 结构化Prompt
输出格式自由文本JSON Schema 约束
可变性修改代码修改 YAML
可评估性批量回测
失败处理正则硬解析自动重试+校验
token 消耗通常较少多,但可接受

完整代码实现

场景:客服工单处理——工单分类(billing/technical/account)+ 发货地址提取 + 紧急程度判断。

文件结构:

prompt-system/
├── configs/
│   └── ticket_workflow.yaml
├── schemas/
│   └── ticket_output.json
├── src/
│   ├── run.js
│   └── validate.php
├── scripts/
│   ├── benchmark.sh
│   └── evaluate.sql

1. YAML 指令模板

# configs/ticket_workflow.yaml
version: 1.0
model: gpt-4o-2024-08-06
temperature: 0
max_tokens: 800

system_prompt: |
  你是工单处理助手。请根据用户输入,输出一个 JSON 对象。
  输出必须符合随后的 JSON Schema,不要输出除 JSON 外任何内容。

  分类规则:
  - billing:包括扣费、发票、账单、退款。
  - technical:报错、无法登录、页面异常、API 超时。
  - account:修改密码、添加成员、权限管理。

  地址提取规则:
  - 只提取【发货地址】,如果找不到填 ""。
  - 地址必须包含省市区,缺省时填 "missing_region"。

few_shot_examples:
  - user_input: "我支付成功了但一直没收到发票,我的发货地址是浙江省杭州市西湖区文一西路969号,麻烦查一下。"
    output:
      category: billing
      confidence: 0.95
      shipping_address: "浙江省杭州市西湖区文一西路969号"
      urgent: false

  - user_input: "登录直接报500,订单地址是北京市朝阳区望京SOHO T1,帮我看看。"
    output:
      category: technical
      confidence: 0.98
      shipping_address: "北京市朝阳区望京SOHO T1"
      urgent: true

2. 输出 Schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "category": {
      "type": "string",
      "enum": ["billing", "technical", "account"]
    },
    "confidence": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
    },
    "shipping_address": {
      "type": "string",
      "default": ""
    },
    "urgent": {
      "type": "boolean"
    }
  },
  "required": ["category", "confidence", "shipping_address", "urgent"]
}

3. Node.js 调用入口

// src/run.js
// 运行:OPENAI_API_KEY=sk-xxx node src/run.js "工单内容"
import OpenAI from 'openai';
import fs from 'fs';
import YAML from 'yaml';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const config = YAML.parse(
  fs.readFileSync(new URL('../configs/ticket_workflow.yaml', import.meta.url), 'utf8')
);
const schema = JSON.parse(
  fs.readFileSync(new URL('../schemas/ticket_output.json', import.meta.url), 'utf8')
);

const userInput = process.argv[2];
if (!userInput) {
  console.error('Usage: node src/run.js "工单内容"');
  process.exit(1);
}

const fewShotMessages = [];
for (const ex of config.few_shot_examples) {
  fewShotMessages.push(
    { role: 'user', content: ex.user_input },
    { role: 'assistant', content: JSON.stringify(ex.output) }
  );
}

async function main() {
  const resp = await openai.chat.completions.create({
    model: config.model,
    temperature: config.temperature,
    max_tokens: config.max_tokens,
    response_format: { type: 'json_object' },
    messages: [
      {
        role: 'system',
        content: `${config.system_prompt}\n\nJSON Schema:\n${JSON.stringify(schema, null, 2)}`,
      },
      ...fewShotMessages,
      { role: 'user', content: userInput },
    ],
  });

  const content = resp.choices[0].message.content;
  console.log(content);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

4. PHP 校验器

<?php
// src/validate.php
// 环境:PHP 8.3 + composer require justinrainbow/json-schema:^5.2
// 运行:php src/validate.php "$(node src/run.js '工单内容')"
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use JsonSchema\Validator;

$input = $argv[1] ?? null;
if (!$input) {
    fwrite(STDERR, "缺少 JSON 字符串\n");
    exit(2);
}

$result = json_decode($input, false);
if (json_last_error() !== JSON_ERROR_NONE) {
    fprintf(STDERR, "JSON 解析失败: %s\n", json_last_error_msg());
    exit(1);
}

$schema = json_decode(
    file_get_contents(__DIR__ . '/../schemas/ticket_output.json'),
    false
);

$validator = new Validator();
$validator->validate($result, $schema);

if (!$validator->isValid()) {
    foreach ($validator->getErrors() as $error) {
        fprintf(STDERR, "字段 %s: %s\n", $error['property'], $error['message']);
    }
    exit(1);
}

printf("校验通过: %s | conf=%.2f | urgent=%s\n",
    $result->category,
    $result->confidence,
    $result->urgent ? 'yes' : 'no'
);

5. Bash 批量压测脚本

#!/usr/bin/env bash
# scripts/benchmark.sh
# 用法: ./scripts/benchmark.sh payload.json 30
# payload.json 是 OpenAI chat/completions 请求体,用 jq 构造
set -euo pipefail

PAYLOAD_FILE="${1:?需要 payload.json}"
COUNT="${2:-30}"
API_URL="https://api.openai.com/v1/chat/completions"
DIST_DIR="bench_result_$(date +%s)"
mkdir -p "$DIST_DIR"

for i in $(seq 1 "$COUNT"); do
  curl -s -o "$DIST_DIR/resp_$i.json" \
    -w "%{time_total}\n" \
    -H "Authorization: Bearer ${OPENAI_API_KEY}" \
    -H "Content-Type: application/json" \
    -d @"$PAYLOAD_FILE" \
    "$API_URL" >> "$DIST_DIR/latency.txt"
  sleep 0.3
done

awk '{sum += $1} END {printf "平均时延: %.2fs\n", sum / NR}' "$DIST_DIR/latency.txt"
echo "结果目录: $DIST_DIR"

6. SQL 评估

-- scripts/evaluate.sql
CREATE TABLE IF NOT EXISTS prompt_eval_results (
    id INT PRIMARY KEY AUTO_INCREMENT,
    approach VARCHAR(50) NOT NULL,
    task VARCHAR(50) NOT NULL,
    manual_review VARCHAR(10) NOT NULL,
    latency_ms INT NOT NULL,
    prompt_tokens INT NOT NULL,
    completion_tokens INT NOT NULL,
    eval_date DATE NOT NULL
);

SELECT
    approach,
    COUNT(*) AS total_cases,
    SUM(CASE WHEN manual_review = 'correct' THEN 1 ELSE 0 END) AS correct,
    ROUND(100.0 * SUM(CASE WHEN manual_review = 'correct' THEN 1 ELSE 0 END) / COUNT(*), 2) AS accuracy_pct,
    ROUND(AVG(latency_ms), 2) AS avg_latency_ms,
    ROUND(AVG(prompt_tokens + completion_tokens), 2) AS avg_tokens
FROM prompt_eval_results
WHERE eval_date BETWEEN '2024-12-01' AND '2024-12-31'
GROUP BY approach
ORDER BY accuracy_pct DESC;

效果数据

测试环境:OpenAI GPT-4o-2024-08-06,temperature=0,max_tokens=800。评测样本:240 条真实脱敏客服工单,覆盖 6 个渠道。人工审核结果。

  • A 一句话模板:"请从工单中提取发货地址并分类,输出JSON"。
  • B 结构化Prompt:YAML + JSON Schema + PHP 校验,无 few-shot。
  • C 结构化Prompt + 2-shot:加上面 YAML 里两个示例。
  • D 结构化Prompt + 2-shot + 多步:第一次抽地址,第二次分类,两次独立调用。
方案准确率平均延迟(s)平均token非法JSON率
A 一句话模板61.3%1.322869.2%
B 结构化无few-shot86.7%1.716040.8%
C 结构化+2-shot92.5%1.948110.4%
D 结构化+2shot+多步95.0%3.2814360%

C 比 A 准确率提升 31.2 个百分点,延迟增加 0.62s,平均 token 增加 525。业务上每 100 条工单少 20 条人工介入,省下的成本远超 token 开销。D 在多步拆分后准确率最高,但延迟翻倍,适合异步处理。

额外测试:把 C 的 few-shot 从 2 条加到 5 条,准确率反而降到 88.3%。原因是第 3 条示例里有"聊天记录"字样,模型把对话中出现的地址也当成了发货地址。

原理:结构化Prompt为什么有效

输出空间约束

模型生成时在每个 token 上做概率采样。自由文本的可选符号空间巨大;JSON Schema 里的 enum: ["billing","technical","account"] 把分类任务变成低熵选择,模型输出置信度更高。

Few-shot 是条件分布先验

大模型在给定几个输入输出对之后,会隐含地匹配模式。两个和真实分布一致的样本,能显著拉高准确率。但样本和真实分布有偏差时,反而把模型带偏。所以 few-shot 必须从真实数据里采样,不能拍脑袋编。

校验把失败变成可控

模型不可能 100% 守规矩。加了 JSON Schema 校验后,非法输出直接被拦下,触发一次重试。只要模型不是次次乱来,重试一次就能恢复。方案 B 的 0.8% 非法率,是校验+重试后的残留失败。

多步分解降低单次任务熵

一次调用同时做分类和地址抽取,模型要同时维护两类上下文。拆成"先抽取、后分类"两步,每一步都是低复杂度任务,准确率上升。代价是延迟和 token 翻倍。适合对实时性要求不高的场景。

避坑指南

这些坑都是我在生产环境实际踩过的。

坑1:response_format 只保证 JSON,不保证 Schema

response_format: { type: "json_object" } 只让模型输出合法 JSON,不保证字段齐全。测试时模型多输出一个 "note" 字段,Java 端反序列化直接抛异常。所以必须做运行时 Schema 校验,别省。

坑2:few-shot 不是越多越好

上面数据写了:5-shot 反而比 2-shot 低 4.2 个百分点。few-shot 的本质是给模型条件分布先验,如果示例与真实业务有偏差,就是反向拉低。先跑 2 条样本,看错误模式再加,不要一次堆 20 条。

坑3:YAML 里别做变量替换

有人喜欢在 YAML 里写 ${address},然后在代码里 str_replace。一旦地址里有 $ 或花括号,模板直接坏掉。更安全的做法:模板只放固定指令,所有业务变量都拼在 user message 里。

坑4:不要迷信"你是XX专家"

角色设定对 GPT-4 级别模型效果有限,对 7B/13B 开源模型反而可能增加幻觉。真正起作用的是明确的分类规则、输出约束和示例。角色设定可以保留,但不能当核心手段。

坑5:换模型必须重新回测

同一个 prompt 在 gpt-4o-2024-08-06 上准确率 92.5%,在 gpt-4o-mini 上只有 79.8%。别相信"兼容所有模型"的提示词。每次切换模型或版本,都要在固定评测集上重新跑一遍。

坑6:结构化 prompt 的成本会被忽略

方案 C 每个请求约 811 token,其中 Schema 和 few-shot 占了 450+。如果每天 10 万次请求,token 成本比方案 A 高 2.8 倍。优化方向:用 Function Calling 的 tools 参数承载 Schema,避免在 system 里重复拼 JSON Schema 文本。但 strict 模式本身也有坑,不在本文展开。成本必须提前估算。

这套方法论能做什么

  • prompt 变成配置文件,走 Git 版本管理,改模板不用发版。
  • 每次模型升级,可以在固定数据集上跑 SQL,用准确率说话。
  • 非法输出被 Schema 校验拦住,不会脏了下游业务数据。
  • 从"这个模型吃这套词"变成"我的任务约束足够清楚,换模型影响可控"。

Prompt Engineering 不是文学创作,是协议设计。输入、输出、约束、校验、评估,把这五件事显式化,你就不再靠运气。