把一个 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 校验失败、命中敏感词,这几类都应该进人工队列,同时把处理结果回流成评测样本。

人工队列要有响应时间。没有时限的兜底,本质上只是把用户挂起,等处理完已经失去了意义。

还有一件常被忽略的事:把提示词从代码里挪出来,放进版本化的配置,带上模型名和参数一起记录。线上出问题时,你需要知道当时跑的是哪一版提示词,而不是去翻某次提交。这一点和数据库迁移没什么区别,都是可回溯性的问题。