可靠性设计
把 LLM 当成一个「聪明但会撒谎、会宕机」的外部依赖——幻觉分类、校验循环、降级矩阵、幂等续跑,用分布式系统的老工具箱配上两件新武器。
换个姿势看 LLM
做后端的人都懂一个常识:任何网络调用都不可信。数据库会慢,下游服务会挂,返回的数据可能是垃圾。于是我们发明了超时、重试、熔断、降级、幂等——一整套分布式系统的可靠性工具箱。
现在把 LLM 摆进这个图里。它是什么?一个 P99 延迟几十秒、会一本正经地编造事实、输出格式看心情、还会限流和宕机的远程服务。 它很聪明,但「聪明」不改变它的可靠性本质。这一章的核心观点就一句话:
不要发明新的可靠性理论。把分布式系统的老工具箱搬到 LLM 上,再补上两件它特有的新武器:幻觉治理和输出校验。
下面分四块讲透:幻觉治理、输出校验与修复、降级策略、幂等与断点续跑。每一块都会聊到「手段的有效性边界」——知道一个工具什么时候失效,比会用这个工具更重要。
幻觉治理:先搞清楚「幻觉」到底是哪种病
幻觉分类学:事实性 vs 忠实性
「幻觉」是个筐,什么错都往里装,就没法治了。学界常用的分类(见幻觉综述 arXiv:2311.05232)把它拆成两类:
- 事实性幻觉(Factuality):模型说的与世界事实不符。比如「鲁迅获得了诺贝尔文学奖」——这是模型参数里的知识错了或根本没有
- 忠实性幻觉(Faithfulness):模型说的与给它的上下文不符。比如你把一篇文档喂给它做摘要,它编了文档里没有的数字——世界知识可能对,但它不忠于输入
这个区分不是学术洁癖,因为两类幻觉的治理手段完全不同:
| 幻觉类型 | 病根 | 有效手段 | 无效手段 |
|---|---|---|---|
| 事实性 | 参数知识缺失/过时 | 检索接地、联网搜索 | 校验引用(它引用的文档可能就是错的) |
| 忠实性 | 生成时偏离上下文 | 引用校验、nli 一致性检查 | 再检索一遍(文档没问题,是它没看) |
检索接地(Grounding):治事实性幻觉的主力
RAG 的思路一句话:先给证据,再让说话。回答前先把相关资料检索出来塞进上下文,把闭卷考试变成开卷考试。开卷和闭卷的编故事概率,完全是两个量级。
但 RAG 不是银弹,它的有效性边界很清晰:
- 检索质量决定上限。模型再强,检索回来的文档是错的或不相关,答案照样错——甚至会因为「文档权威感」编得更自信。embedding 模型、rerank、chunk 切分都要当成独立子系统优化,别把精力全花在 prompt 上
- 检索不到的领域无能为力。纯推理任务(数学证明、代码逻辑)没有「文档」可检,RAG 帮不上忙
- 必须在 prompt 里给退路:「资料中没有就直接说不知道」。不给这条退路,模型会把不相关的文档硬掰成答案——这就是忠实性幻觉开始滋生
引用(Citation):让忠实性幻觉可以被「检查」
治忠实性幻觉的关键是把「信不信」变成「查不查得到」:让模型的每个关键结论带回出处——引用了第几篇文档、哪个段落。好处有两层:
- 用户可以点过去核对,信任感完全不同
- 程序化校验成为可能:把模型引用的段落和源文档做一致性检查(字符串匹配、nli 蕴含判断、或者再发一个 LLM 调用问「这段原文是否支持这个结论」),对不上 = 大概率幻觉,打回重答
注意边界:引用校验只能保证「忠于文档」,不能保证文档本身是对的。垃圾进、看起来很有出处的垃圾出。
Self-RAG 与 CRAG:论文里的校验回路长什么样
两篇论文值得精读,它们代表了「校验回路」的两种设计哲学。
Self-RAG(arXiv:2310.11511)的思路是把「该不该检索、答案靠不靠谱」变成模型自己生成的特殊 token。它训练模型在生成过程中穿插四类反思标记:
Retrieve:这个问题需不需要检索?(不需要就别浪费检索成本)ISREL:检索回来的段落和问题相关吗?ISSUP:我生成的这句话被段落支持吗?(忠实性打分)ISUSE:整体回答有用吗?
推理时按这些标记打分、筛选、重排序,选出「最被证据支持」的生成路径。实验结论(论文摘要层面):在开放域问答、事实验证类任务上显著超过普通检索增强的 Llama2-chat,长文生成的事实性和引用准确率超过当时的 ChatGPT。关键启示:校验不是外挂的补丁,可以内化到生成过程里。 代价是要专门训练/微调,工程门槛高。
CRAG(arXiv:2401.15884)则相反,校验是完全外挂的轻量模块,不改模型:在检索器和生成器之间加一个「检索评估器」,把检索结果分成三档——
- Correct:文档靠谱,走正常 RAG,并对文档做「拆解-筛选-重组」提纯
- Incorrect:文档垃圾,果断扔掉,触发联网搜索补充
- Ambiguous:不确定,两者结合
实验上 CRAG 在 PopQA、PubHealth 等数据集上明显好于朴素 RAG 和 Self-RAG 的对照设置。关键启示:一个便宜的小评估器 + 明确的三分支决策,往往比端到端大方案更好落地。 大多数工程团队的起点应该是 CRAG 式的外挂校验,而不是 Self-RAG 式的训练方案。
两条路线怎么选?实践中往往是组合:主干用 CRAG 式的外挂校验(便宜、可插拔、模型升级不返工),只在核心高价值场景为 Self-RAG 式的内化能力做专项微调。 还有一个经常被忽略的工程细节:校验器本身也会错(误判率不为零),所以校验结果应该影响「是否重试/拒答」这类可逆决策,而不要直接改写给用户的最终答案。
输出校验与修复:永远别信模型的格式
你要的是 JSON,模型给你的是「好的!以下是你要的 JSON:```json …」。你要的字段叫 user_name,它心情一好写成 userName。这是每天真实发生的事。
Schema 校验生态:三层工具
第一层:声明式 schema 库。 TypeScript 用 Zod,Python 用 Pydantic——定义一次结构,同时获得校验器和可读的错误信息。错误信息很重要,因为它本身就是给模型的修复指令。
第二层:约束解码。 OpenAI 的 Structured Outputs、各家的 JSON Mode,按 JSON Schema 在解码阶段直接约束模型只能输出合法结构,把格式问题消灭在生成时。能用就优先用,schema 校验退为兜底。注意它只保证「形状合法」,不保证「内容正确」。
第三层:校验即交互的封装库。 Python 的 Instructor 是代表作:它 patch 模型客户端,让你直接返回一个 Pydantic 对象,校验失败时自动把错误信息拼进上下文重试。看它的用法就明白整个模式:
import instructor
from pydantic import BaseModel, Field
class Answer(BaseModel):
summary: str
score: float = Field(ge=0, le=10)
citations: list[str] = Field(min_length=1)
client = instructor.from_provider("openai/gpt-4o-mini")
answer = client.chat.completions.create(
response_model=Answer, # 校验失败会自动带错误重试
max_retries=3,
messages=[{"role": "user", "content": "总结这篇文档并打分"}],
)
# 拿到的是校验通过的 Answer 实例,不是裸字符串
TypeScript 侧手写这个循环也不复杂:
async function generateWithRepair<T>(
prompt: string,
schema: ZodType<T>,
maxRetries = 3,
): Promise<T> {
let feedback = "";
for (let attempt = 0; attempt < maxRetries; attempt++) {
const raw = await llm.chat(prompt + feedback);
const result = schema.safeParse(extractJson(raw));
if (result.success) return result.data;
feedback = `\n上次输出校验失败:${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}\n请只修正这些字段后重新输出完整 JSON。`;
}
throw new Error("输出修复超限,转降级流程");
}
修复循环的收敛性:它为什么不保证成功
「校验失败 → 反馈错误 → 重新生成」这个循环没有收敛保证。实践中会看到三种失败模式:
- 振荡:这次修好字段 A 弄坏字段 B,下次修好 B 又弄坏 A,来回横跳
- 躺平:模型开始输出空对象、空数组——校验上「合法」,业务上是垃圾(所以 schema 里要加
min_length这类业务约束,不能只校验类型) - 死结:任务本身超出模型能力,比如要求它输出一个它根本不知道的值
对策都是工程手段而非祈祷:重试硬性设上限(2~3 次足够,再多边际收益骤降);反馈要具体(只说「错了重试」成功率低很多,带上字段路径和错误原因);超限后必须走降级而不是无限循环。把修复循环的命中率埋点监控起来——命中率突然下降,通常是模型版本升级或 prompt 被改动的信号。
Self-Critique 之争:到底管不管用
生成完再让模型自己挑一遍毛病(self-critique / reflection),是 Agent 框架的标配动作。但它有没有用,社区是有真争议的,正反方都有论文:
- 反方:Google DeepMind 的《Large Language Models Cannot Self-Correct Reasoning Yet》(arXiv:2310.01798)发现,在没有外部反馈的情况下,让模型自我纠正推理错误,多数时候反而让正确率下降——模型不知道自己不知道,「挑毛病」常常把对的改错
- 正方:CRITIC(arXiv:2305.11738)等工作证明,当 critique 能调用外部工具验证时(跑代码看报错、搜索核对事实、查计算器),自我修正确实显著提升表现
两边的结论拼起来其实一致,也是工程上的行动指南:self-critique 的价值取决于有没有外部锚点。 纯内省的「你再检查一遍」基本无效甚至有害;能接外部信号(工具执行结果、检索证据、单元测试)的 critique 才值得放进 pipeline。另外它对知识类幻觉天然无力——那正是引用校验的活。
降级策略:模型挂了,服务不能挂
线上真相:各家模型 API 都会挂,限流更是家常便饭。你的服务 SLA 不能建立在「模型永远在线」这个假设上。
决策矩阵:什么错误配什么动作
降级的核心不是「换个模型」四个字,而是一张错误类型 × 应对动作的决策表。直接用这张当起点:
| 触发信号 | 典型含义 | 应对动作 | 千万别做 |
|---|---|---|---|
| 超时(>2~3 倍 P99) | 网络抖动或模型过载 | 换备用模型重试 1 次 | 同模型立刻重试(大概率再超时) |
| 429 限流 | 触达速率配额 | 指数退避重试;常态化则换模型/加配额 | 无退避猛刷,触发更严限流 |
| 5xx | 服务商故障 | 熔断 + 切备用模型 | 重试轰炸(对面正在救火) |
| 400 / 上下文超长 | 你的请求有问题 | 截断/压缩上下文后重建请求 | 原样重试(确定性失败) |
| 内容审核拦截 | 输入或输出触线 | 换措辞重试或直接拒答 | 换模型绕过审核 |
| 输出校验连续失败 | 模型能力与任务不匹配 | 升级更强的模型重试,再败则降级 | 放宽 schema 放水 |
按兜底程度从高到低,完整的降级链是四层:
- 换模型:主模型失败 → 备用模型(别家同档,或自家小模型)。定义好优先级列表和切换条件
- 换能力:生成式做不了 → 退回检索直出(把 top-1 文档摘要直接展示),功能降级但结果可信
- 退模板:LLM 全挂 → 缓存的常见答案、预设模板、规则系统顶上
- 坦诚失败:明确告诉用户「智能服务暂不可用」,而不是让前端转圈三分钟
超时、重试、熔断:参数经验值
这些参数没有理论最优解,但社区有踩坑踩出来的经验区间,照抄再按监控调:
- 超时:取你业务 P99 延迟的 2
3 倍。流式场景要区分「首 token 超时」(通常 510s)和「总时长超时」,不设超时的流式请求能把连接池耗死 - 重试:最多 2~3 次;指数退避 base 1s(1s → 2s → 4s)+ full jitter(在
[0, delay]区间随机取),防止大量客户端同步重试形成惊群;给重试设全局预算(如重试请求不超过总请求量的 10%),避免故障期流量放大 - 熔断:滚动窗口内(如最近 10
20 次请求)错误率 >50% 或连续失败 5 次 → 断开 3060s;之后放 1 个探测请求(半开),成功则恢复,失败则继续断。熔断的价值是故障期把钱和连接省下来,还能给上游留恢复时间
const models = ["claude-sonnet", "gpt-4o-mini", "local-qwen"]; // 按优先级
async function chatWithFallback(prompt: string) {
for (const model of models) {
if (breaker.isOpen(model)) continue; // 熔断中的模型直接跳过
try {
const result = await llm.chat(prompt, { model, timeout: 30_000 });
breaker.onSuccess(model);
return result;
} catch (err) {
breaker.onFailure(model, err);
console.warn(`模型 ${model} 失败,切换下一个`, err);
}
}
return getTemplateAnswer(prompt); // 最后一层:模板兜底
}
最后强调:降级路径不演练等于没有。 定期在预发手动掐掉模型调用,观察系统是否如设计表现;降级时的答案质量、延迟、成本都要进监控面板。
幂等与断点续跑:长任务不怕断
Agent 类任务动辄跑几十分钟、调几十次工具、烧掉几美元 token。跑到第 40 步进程挂了从头再来是不可接受的——不光浪费钱,有些工具调用(发邮件、写库、调支付)重复执行会出事故。好消息是:分布式系统三十年前就把这个问题研究透了,直接搬。
三个经典模式,搬到 Agent 场景
幂等键(Idempotency Key)。 给每个关键操作分配唯一键,执行前先查「这个键做过没有」:
def send_report(task_id: str, report: str):
key = f"send-report:{task_id}"
if store.exists(key):
return store.get(key) # 已经发过了,返回上次结果
result = email_api.send(report, idempotency_key=key)
store.set(key, result)
return result
原则:所有有副作用的工具调用(写操作)都要幂等化,读操作随意。Stripe 的 API 就是这个设计——重试网络超时的扣款不会扣两次。你的 Agent 工具层应该长一样。
去重表(Dedup Table)。 消息队列和事件流场景的经典做法:消费前把消息 ID 插进一张带唯一约束的去重表,插入冲突说明处理过,直接跳过。搬到 Agent 场景:每一步工具调用落一条记录 (task_id, step_no, tool, args_hash, result),唯一约束天然防重放。它和幂等键是同一思想的两种存储形态——幂等键防的是「外部副作用重复」,去重表防的是「内部步骤重复执行」。
租约与 fencing token(Lease / Fencing)。 长任务最怕的不是挂,是假死:worker A 卡住了,调度器把任务派给 worker B,结果 A 又活过来,两个 worker 同时写结果。经典解法是租约(worker 持有带 TTL 的租约,定期续约)加 fencing token(每次派发生成一个单调递增的编号,存储层只接受编号更大的写入,过期 worker 的迟来写入被拒)。Agent 场景的适配:每次断点恢复生成新 epoch,checkpoint 和工具结果都带 epoch 写入——僵尸 worker 恢复后的旧 epoch 写入直接失败,不会造成「两个 Agent 同时给用户发邮件」。
断点续跑:状态外置,步骤幂等
有了上面三件套,断点续跑就是很薄的编排层:把每一步状态(进度、中间结果、已完成操作的幂等键)持久化,崩溃恢复时——
加载存档 → 跳过幂等键命中/去重表已有的步骤 → 从断点继续跑(新 epoch)
LangGraph 的 checkpointer、Temporal 的 workflow replay,本质都是这个思想。两个落地建议:
- checkpoint 的粒度对齐「工具调用边界」,而不是 token 边界——一次工具调用要么完整落档要么不落,避免恢复时 replay 半个副作用
- 把人工审批也当成一种 checkpoint:高风险操作(发邮件、动生产库)前暂停任务、等待人工确认、确认结果写进存档再续跑。审批状态进了存档,恢复后就不会重复弹审批,也不会跳过审批直接执行
一个反直觉的附加好处:断点续跑还顺便缓解「上下文超长」——长任务可以把早期步骤的结论固化到存档里按需取回,而不是一直堆在 prompt 里。
常见误区
- 用提示词治幻觉:「请不要编造信息」的约束力约等于贴在电脑上的「不许死机」便利贴。幻觉要靠检索、引用、校验这些系统机制来治
- 分不清两类幻觉就下药:事实性问题去做引用校验(文档本身就是错的),忠实性问题去加重检索(它根本没看文档)——药不对症
- 无外部锚点的 self-critique:「你再检查一遍」多数时候把对的改错,给它工具、证据、测试用例才有意义
- 只校验不拒答:校验发现答案不靠谱,正确动作是拒答或重试,不是「加一句免责声明然后照发」
- 重试不设幂等:重试网络超时的写操作,可能把同一封邮件发三遍。重试之前先想清楚这个操作幂等吗
- 降级只在 PPT 上:不演练的降级路径等于没有
- 拿平均值做容量规划:LLM 延迟分布尾巴极长,按平均延迟设超时和并发,等于按晴天设计雨伞
术语表
| 名词 | 定义 | 一句话直觉 | 常见混淆 |
|---|---|---|---|
| 幻觉(Hallucination) | 模型生成看似合理但无依据的内容 | 自信的编故事 | 不等于「模型犯错」——逻辑错误、格式错误不算幻觉 |
| 事实性幻觉 | 生成内容与世界事实不符 | 它记忆里的知识是错的 | 与忠实性幻觉的病根和药方完全不同 |
| 忠实性幻觉 | 生成内容与给定上下文不符 | 开卷考试还不看卷子 | 不是知识缺失,是没忠于输入 |
| 检索接地(Grounding / RAG) | 先检索证据再生成 | 闭卷改开卷 | 只能提事实性,管不了忠实性;检索错了照样编 |
| 引用校验 | 核对生成结论是否被引用源支持 | 每句话查脚注 | 能证「忠于文档」,不能证「文档为真」 |
| Self-RAG | 用反思 token 把检索/自评内化进生成的训练方案 | 模型边写边给自己打分 | 与 CRAG 路线相反:要训练 vs 纯外挂 |
| CRAG | 外挂检索评估器,按 Correct/Incorrect/Ambiguous 三分支纠错 | 检索结果先过安检 | 不是又一种检索器,是检索质量的裁判 |
| Schema 校验 | 用声明式结构约束并验证模型输出 | 先验货再签收 | 约束解码(JSON Mode)只保证形状,不保证内容正确 |
| Self-Critique | 模型对自己的输出进行评审修订 | 自己给自己改作业 | 无外部锚点时常常越改越错;要接工具反馈才有效 |
| 降级(Fallback) | 主路径失败时切换到次优但可用的路径 | 主厨请假,学徒顶上 | 不是「报错给用户」,降级成功用户无感 |
| 熔断(Circuit Breaker) | 连续失败时短期切断调用,定期探测恢复 | 保险丝:先断电,再试送 | 与重试互补不矛盾:熔断防雪崩,重试抗抖动 |
| 幂等键(Idempotency Key) | 操作的唯一标识,重复执行只生效一次 | 同一订单号提交多次只扣一次款 | 重试 ≠ 幂等:重试是动作,幂等是动作安全的前提 |
| 去重表(Dedup Table) | 用唯一约束记录已处理消息/步骤的表 | 盖过章的票不能再用 | 与幂等键同思想:一个防外部副作用,一个防内部重放 |
| Fencing Token | 单调递增的授权编号,拒绝过期写入 | 旧钥匙开不了新锁 | 解决「假死复活」,不是解决「并发争抢」 |
| 断点续跑(Checkpointing) | 步骤状态外置持久化,崩溃后从断点恢复 | 游戏存档 | 不是「记住对话历史」,是「记住执行进度」 |
参考材料
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — RAG 原始论文
- Survey of Hallucination in Natural Language Generation — 幻觉分类学的系统综述,事实性/忠实性划分的出处
- Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection — 反思 token 机制与实验结论
- Corrective Retrieval Augmented Generation (CRAG) — 外挂式检索评估与纠错
- Large Language Models Cannot Self-Correct Reasoning Yet — self-critique 反方证据
- CRITIC: Large Language Models Can Self-Correct with Tool-Interactive Critiquing — self-critique 正方证据
- OpenAI Structured Outputs 文档 — 用 JSON Schema 约束解码
- Instructor — 「校验即交互」封装库的代表作
- Making retries safe with idempotent APIs — AWS Builders' Library,幂等设计经典教材
- Google SRE Book — 分布式系统可靠性的圣经
小结
四句话总结这一章:
- 幻觉治不了,只能兜——先分清事实性还是忠实性,前者靠检索接地,后者靠引用校验,校验回路(Self-RAG 内化 / CRAG 外挂)在发出前拦最后一道
- 输出永远要校验——schema 管形状,带外部锚点的 critique 管内容;修复循环不保证收敛,上限、具体反馈、降级出口一个不能少
- 降级是必修课——按错误类型查决策矩阵,超时重试熔断用经验参数起步,降级链不演练等于没有
- 长任务先想断怎么办——幂等键防副作用重复,去重表防步骤重放,fencing token 防僵尸复活,三者齐了断点续跑才真的安全
把 LLM 从「神奇的黑盒」降级为「一个不太靠谱的下游依赖」,你的系统反而可靠了——这正是 AI Native 工程师的成熟标志。