先看一个真实场景
上周五,我负责的客服工单自动分类功能要上线。我在 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.32 | 286 | 9.2% |
| B 结构化无few-shot | 86.7% | 1.71 | 604 | 0.8% |
| C 结构化+2-shot | 92.5% | 1.94 | 811 | 0.4% |
| D 结构化+2shot+多步 | 95.0% | 3.28 | 1436 | 0% |
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 不是文学创作,是协议设计。输入、输出、约束、校验、评估,把这五件事显式化,你就不再靠运气。