结构化输出与提示词工程:让模型结果能直接进数据库

「请提取文章标题和作者,以 JSON 格式返回」——这句话在 demo 里能跑,在生产里会让你写一堆正则去收拾残局:有时候外面包了 ```json,有时候多了句解释,有时候字段名变成了中文。让模型输出能直接被程序消费,靠的不是更恳切的措辞,而是约束与验证。
一、把 schema 放在协议层,而不是提示词里
现在主流接口都支持 JSON 输出约束或 JSON Schema(部分实现通过约束解码在采样阶段保证格式合法)。能用的话优先用,这是从「希望它遵守」变成「不可能不遵守」的区别。
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}, "maxItems": 5},
"difficulty": {"type": "string", "enum": ["beginner", "intermediate", "advanced"]},
"summary": {"type": "string"},
"has_code": {"type": "boolean"}
},
"required": ["title", "tags", "difficulty", "summary", "has_code"],
"additionalProperties": False
}
字段设计有几个很实用的约定:
- 枚举优于自由文本:
difficulty用三个固定值,别让模型自由发挥成「中等偏上」; - 可空要明确:信息缺失时返回
null或固定哨兵值,并在描述里写清,避免模型编一个看起来合理的值; - 别塞置信度当摆设:如果不会用它做分流,就别让模型输出一个没有校准意义的 0.87;
- 字段名用 snake_case 且稳定,下游表结构直接对应。
二、少样本不是抄几句话,是覆盖边界
示例的作用是告诉模型「拿不准时怎么办」。所以挑样例时,优先放边界情况而不是最典型的成功案例:
| 样例类型 | 要教给模型的事 |
|---|---|
| 信息完整 | 常规字段怎么填 |
| 字段缺失 | 该返回 null 而不是编造 |
| 多个候选值 | 选哪一个、为什么 |
| 长文本 / 含代码块 | 别把正文混进摘要 |
| 明显无关的输入 | 走空结果或拒绝分支 |
示例数量一般 2~5 条就够,太多会挤占上下文,还会让模型过度拟合样例的措辞。
三、把提示词当代码管理
提示词一旦进了生产,就具备了代码的所有特征:会改、会回归、会影响下游、需要评审。最低限度的三件事:
- 版本化:提示词模板存成文件,带版本号,发布记录里能查到用的是哪版;
- 回归集:攒 30~50 条真实输入 + 期望输出,每次改提示词跑一遍,看字段准确率;
- 失败样本回流:线上解析失败或人工修正的 case,直接补进回归集。
prompts/
extract_meta.v3.txt
extract_meta.v4.txt
evals/
extract_meta_cases.jsonl # {"input": "...", "expect": {...}}
scripts/
run_eval.py # 输出字段级准确率与失败明细
有了这套东西,「把语气改得更强硬一点」这类改动才有依据判断是否真的变好,而不是凭感觉。
四、服务端解析与容错:PHP 版
即使开了 schema 约束,也建议保留一层防御性解析。下面是这类接口的骨架,重点是超时、状态码、JSON 校验和降级:
function extractMeta(string $text, string $prompt): array {
$payload = [
'model' => getenv('LLM_MODEL') ?: 'default',
'messages' => [
['role' => 'system', 'content' => $prompt],
['role' => 'user', 'content' => $text],
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0,
];
$ch = curl_init(getenv('LLM_ENDPOINT'));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json',
'Authorization: Bearer ' . getenv('LLM_KEY')],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false || $code < 200 || $code >= 300) return fallbackMeta($text);
$resp = json_decode($raw, true);
$content = $resp['choices'][0]['message']['content'] ?? '';
// 兜底:去掉偶发的 ```json 包裹
$content = preg_replace('/^```(?:json)?\s*|\s*```$/', '', trim($content));
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['title'])) return fallbackMeta($text);
return [
'title' => mb_substr((string)$data['title'], 0, 120),
'tags' => array_slice((array)($data['tags'] ?? []), 0, 5),
'source' => 'llm',
];
}
function fallbackMeta(string $text): array {
// 降级:取第一行做标题,保证下游永远拿得到结构
return ['title' => mb_substr(strtok($text, "\n") ?: '未命名', 0, 120),
'tags' => [], 'source' => 'fallback'];
}
几个容易被忽略的点:temperature 设 0 让提取类任务更稳定;密钥只从环境变量读,不进代码库;降级函数必须返回同构的结构,否则下游要写两套分支。
五、检查清单
- 能用 schema 约束就不用自然语言约束
- 枚举、可空、必填在 schema 里写死
- 少样本覆盖边界与失败分支
- 提示词有版本、有回归集
- 解析失败率、降级比例接入监控
结构化输出的目标不是「模型每次都对」,而是它出错时系统仍然可预期。把这两件事分开设计,后面接数据库、接缓存、接队列都会轻松很多。