把一个 LLM 接进产品,最危险的错觉是把它当成一个函数。函数有确定的输入输出,提示词没有。同样的输入,换个模型版本、换个温度、甚至只是前面多了一段上下文,结果就可能不一样。接受这一点之后,工程上的做法会完全不同。
结构化输出要约束,不要祈祷
我最开始的做法是在提示词里写请返回 JSON。它大部分时候有用,但总有那么几次会带上一句解释,或者把字段名写成中文。后来改成用模型支持的 JSON 模式,再叠一层 schema 校验。
const schema = {
type: 'object',
required: ['category', 'confidence'],
properties: {
category: { enum: ['billing', 'bug', 'feature', 'other'] },
confidence: { type: 'number', minimum: 0, maximum: 1 },
reason: { type: 'string', maxLength: 200 },
},
additionalProperties: false,
}
const res = await client.chat.completions.create({
model: 'gpt-4.1-mini',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: input },
],
})
const parsed = schema.safeParse(JSON.parse(res.choices[0].message.content))
if (!parsed.success) return fallback(input)
schema 校验不是形式主义。它把不确定的输出变成两种明确的结果:可用的数据,或者一个需要兜底的错误。
字段也要尽量少。每多一个字段,模型就多一个编造的机会,校验失败率跟着上升。能用枚举就别用自由文本,能用数字就别用自然语言描述。
重试要有边界
- 校验失败可以重试一次,把错误信息一起喂回去
- 超时和限流属于基础设施问题,走指数退避
- 重试两次还失败,就走降级路径,不要继续消耗配额
- 所有重试都要记日志,否则你永远不知道失败率
评测比调提示词重要
改提示词之前先有评测集。我从线上日志里抽两三百条真实输入,人工标好期望结果,每次改动跑一遍,看准确率和误判分布。没有这个集合,所谓优化就是凭感觉。
评测集要包含难例:空输入、超长文本、多语言混杂、明显越界的请求。这些样本往往只占百分之几,却决定了用户对产品的印象。
评测要多跑几遍。温度设成 0 也不代表完全确定,同一份样本跑三次取多数,才能分清哪些是偶发失败,哪些是稳定错误。
人工兜底不是失败
很多团队把人工介入当成技术不够好的证据。我的看法相反,能在置信度低的时候把请求转给人工,说明系统知道自己不知道。低置信度、schema 校验失败、命中敏感词,这几类都应该进人工队列,同时把处理结果回流成评测样本。
人工队列要有响应时间。没有时限的兜底,本质上只是把用户挂起,等处理完已经失去了意义。
还有一件常被忽略的事:把提示词从代码里挪出来,放进版本化的配置,带上模型名和参数一起记录。线上出问题时,你需要知道当时跑的是哪一版提示词,而不是去翻某次提交。这一点和数据库迁移没什么区别,都是可回溯性的问题。