大模型应用上线的 8 个坑:都是踩过才知道的
坑 1:没有超时和重试,一个慢请求拖垮整个队列
现象:监控显示偶发 60 秒响应,用户以为页面卡死。
原因:默认 SDK 的 timeout 往往很长甚至没有,模型侧偶发排队时,请求会一直挂着,占住你的 worker。
client = OpenAI(
api_key=KEY,
timeout=30.0, # 总超时,必须设
max_retries=2, # 只重试「可重试」的错误
)
要点:
- 超时要小于你上游(网关/前端)的超时,否则上游先断开,你白花钱
- 只重试 429(限流)、5xx、超时;不要重试 400(参数错,重试还是错)
- 重试要指数退避 + 抖动,否则限流时会形成雪崩
def backoff(i):
return min(2 ** i, 8) + random.uniform(0, 0.5)
坑 2:把幻觉当"偶发问题"
现象:「模型有时候会编造不存在的信息,概率不高,先上线吧。」
问题:幻觉不是随机噪声,它集中在模型不确定的地方——也就是你最需要准确的地方(罕见实体、数字、日期、引用)。上线后用户信任一次崩塌就很难挽回。
三条硬要求:
- 结构化任务必须 schema 校验,不合法就重试或降级
- 给"我不知道"的合法出口(见第 3 篇专栏文章)
- 要求引用来源,没有来源的结论在 UI 上标注「未核实」
resp = client.chat.completions.create(
model=MODEL,
messages=msgs,
response_format={"type": "json_object"}, # 强制 JSON
)
try:
data = json.loads(resp.choices[0].message.content)
except json.JSONDecodeError:
data = fallback() # 降级,而不是把脏数据塞给用户
坑 3:JSON 输出不合法,只在 5% 的请求上
现象:json.loads 偶尔失败,错误日志里是 Expecting value: line 1 column 1 或输出被 ```json 包裹。
原因:模型可能加解释性文字、加 Markdown 代码块、或截断(max_tokens 不够)。
处理顺序:
- 用
response_format/ JSON schema(首选,最可靠) - prompt 里给完整示例而不是描述
- 容错解析:剥掉 ``` 包裹、找第一个
{到最后一个}、修复尾逗号 - 仍失败 → 重试一次(带"上次输出不合法"的提示)→ 再失败就降级
- 监控重试率:超过 5% 说明 prompt 或 schema 有问题,别当正常波动
坑 4:流式输出没处理"半截失败"
现象:开了流式,用户看到一半文字突然停住,或者收到一段乱码。
原因:
- 流式过程中连接断了,已发送的内容无法撤回
- 没处理 SSE 的
data: [DONE]与心跳 - 把 chunk 的增量当完整句子处理
stream = client.chat.completions.create(model=MODEL, messages=msgs, stream=True)
buf = []
try:
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
if delta:
buf.append(delta)
yield delta
except Exception:
# 半截失败:要么补一句提示,要么标记本条回答不完整
yield "
[回答中断,请重试]"
要点:流式必须在前端支持"中断重试",并且记录是否完整,否则统计数据会失真。
坑 5:撞限流(429)才发现没做队列
现象:上线当天调用量上来,大量 429,用户看到报错。
原因:厂商的 RPM/TPM 限制,免费额度和正式配额差别巨大;并发一上来就打满。
做法:
- 服务端加令牌桶 / 信号量控制并发,别指望客户端自觉
- 429 时排队而不是直接失败(可以接受延迟的请求)
- 关键路径准备备选模型(同厂不同型号 / 另一家厂商)
sem = asyncio.Semaphore(8) # 按你的配额设
async def call(msgs):
async with sem:
return await do_request(msgs)
坑 6:模型版本悄悄变了,效果跟着变
现象:「上周还好好的一天,这周准确率掉了 10%,代码一行没改。」
原因:厂商灰度更新了底层模型(同名不同版本),或你的代码用了"最新版本"别名而不是固定版本号。
做法:
- 固定版本号(能用具体版本就别用
-latest) - 每周跑一次固定评测集,准确率下降就告警
- 关键业务保留回滚路径(能切回旧版本或旧厂商)
坑 7:成本失控,月底账单吓人
现象:测试环境被脚本刷了几百万 token;或生产上某个循环调用把成本放大 100 倍。
做法:
- 每个环境独立密钥,测试密钥设低额度
- 服务端设单用户/单 IP 的日调用上限
- 监控每小时的 token 消耗,异常陡增就告警
- 别把密钥放前端(哪怕只读)——永远不要
坑 8:没有固定评测集,改动全靠感觉
现象:「我觉得这版更好」「我觉得变差了」,团队无法对齐。
做法(最低成本版):
# 20~50 条真实样本,覆盖边界:空输入、超长、歧义、恶意注入
EVAL = [{"input": "...", "expect_contains": ["..."], "must_json": True}, ...]
def run_eval(build_prompt):
passed = 0
for c in EVAL:
out = call_model(build_prompt(c["input"]))
if c["must_json"] and not is_json(out):
continue
if all(k in out for k in c["expect_contains"]):
passed += 1
return passed / len(EVAL)
每次改 prompt、换模型、升版本,都跑一遍。没有评测集,所有"优化"都是赌博。
上线前的一张检查表
- [ ] 超时已设,且小于上游超时
- [ ] 重试只针对 429/5xx/超时,带指数退避 + 抖动
- [ ] 结构化输出用 schema 校验,有降级路径
- [ ] 流式有中断处理,且记录完整性
- [ ] 服务端有并发/限流控制
- [ ] 模型版本号固定,有回滚路径
- [ ] 密钥分环境、不在前端、有额度上限
- [ ] 有固定评测集,改动前后都跑
- [ ] 监控:错误率、P95 延迟、重试率、token 消耗