大模型输出不稳定,生产级 AI 系统如何保证业务可靠性?

芒格安全边际模型:大模型是概率组件,不是业务引擎

模型可以犯错,业务不能因此失控

[读者] 正在把大模型接进资金、订单、工单链路的架构师
[痛点] 模型抖一下,线上就是一次事故复盘
[现在读] 退款这种真金白银的场景,最容易被拿来试错
[读完] 拿到一套五道防线与 V1→V2 的可落地分层
[旧方案] 模型输出 JSON,后端直接解析并执行
    |
    v
[新需求] 意图理解要自动化,资金动作要可审计
    |
    v
[冲突] 概率性输出 vs 确定性事务
    |
    v
[后果] 重复退款、超额退款、越权退款

我是老李,这几年主要做交易与客服链路的架构。写过不少状态机、对账和幂等表,也踩过把不确定性系统当成确定性组件用的坑。

去年团队接了一个需求:用大模型做客服退款的第一步,把用户那句“这个单子我要退了”变成一张可执行的退款受理单。听起来是个理解问题,落地才发现是个资金安全问题。

我们当时的方案很直接:模型输出结构化 JSON,后端解析后调退款接口。链路只有三步,代码不到一百行,上线第三天就出了重复退款。

模型输出错了,为什么最后错的是业务系统?

给模型加校验,会不会把自动化率又打回人工时代?

五道防线里,哪一道才是真正不能省的那道?

01、故事

现场是一家电商的客服中心。用户发起退款诉求,坐席在后台确认订单状态和金额,点击确认,订单中心执行退款。整个链路是确定的:状态可查、金额可算、动作可审计。

任务是:把“用户表达退款诉求”到“生成退款受理单”这一段自动化,让简单退款不再占用坐席。

V1 的选型很自然:接入大模型做意图识别,顺手让它把订单号和金额一起吐出来,后端拿到 JSON 直接调退款接口。团队当时的理由很朴素——模型连话都听得懂,输出个订单号和金额算什么难事。

变化发生在业务侧:客服单量上行,人工审单成了瓶颈,产品希望把状态正常、无争议的简单退款先自动化掉。自动化率一被写进目标,链路就被压成了一根直筒:模型输出即指令。

冲突也在这时候显形。模型是概率组件,它给出的是一个分布里的采样结果,不是事实;退款是确定性事务,它要求每一次执行都有唯一、可追溯、可对账的因果。两者直接对接,等于把资金动作的触发权交给了一次采样。

旧路曾可行
新求已变形
一抖失千金
方知界要清

02、问题

旧方案在三件事上同时失效。

第一件是解析失效。模型偶尔返回带 Markdown 包裹的文本或者缺字段的对象,json.loads 直接抛异常,接口 500。用户看到的是“系统繁忙”,工单悬空,没人知道该不该重发。

第二件是裁决失效。模型给出的金额来自语言上下文,不来自订单事实。用户说“退我三百”,模型可能输出 300;订单实付 899,可退金额得由订单状态和已退金额算出来,模型完全没有这个输入。

第三件是执行失效。链路没有幂等键、没有状态机、没有重试策略。上游超时重试,下游就再执行一次。资金动作是副作用,重放一次就多退一笔。

业务影响是直接的:重复退款要人工追回,超额退款要走风控,非法输出产生的 500 会污染客服的工单口径。更麻烦的是信任成本——业务方从此不敢让这条链路碰真钱。

技术上要给出可验证的完成标准,而不是“我们加了校验”:

  • 同一个业务请求键,在任意重试次数下最多产生一次资金动作;
  • 退款金额只能由订单事实推导,模型输出不得参与金额裁决;
  • 模型返回任何非法结构,都必须落到确定性的失败码,且不产生资金动作;
  • 任一环节超时或失败,单据必须落到明确终态(受理 / 拒绝 / 人工),不能悬空;
  • 每次自动化决策可回溯:模型原始输出、规则判定结果、执行结果三段证据齐全。

失效非偶发
影响在资金
标准要可验
问题才叫真

03、原理

先把两类组件的性质摆清楚。

确定性业务引擎的性质是:给定同样的输入和状态,输出唯一,且可被断言、可被对账。状态机、规则引擎、事务服务都属于这一类,它们的正确性靠约束保证。

概率性决策组件的性质是:给定同样的输入,输出是一个分布上的采样,尾部永远存在。你可以让尾部更瘦,但不能让它消失。提示词约束、低温度、小样本示例,都只是改变分布形状,不是消除分布。

反直觉判断:提升模型准确率,不是解决资金安全问题的路径;缩小模型权限,才是。 准确率从 95% 提到 99%,剩下那 1% 照样能造成不可逆的资金动作。而如果把模型权限收缩到“只能提出建议、不能决定金额和动作”,那 1% 就退化成一次普通的人工接管。

反直觉判断:把大模型放在链路最上游、权限最大的位置,是最差的布局。 最稳的布局恰恰相反——模型放在口径最模糊、最需要语言理解的那一层(意图识别、理由归纳),把最需要确定性的那一层(金额计算、权限判定、事务执行)留给规则和状态机。

芒格讲安全边际,说的是桥梁限重不按平均车重设计,而按最坏情况留余量。系统里的安全边际不是“多加几个 if”,而是让失败落在可承受的范围内:模型可以错,但错误只能表现为一次拒绝、一次转人工、一次重放被拦,绝不能表现为一笔不可逆的资金动作。

微软的 AI agent 设计模式指南(Microsoft Learn)把 agent 落地归纳为角色定义、工具使用与多 agent 编排等模式族,并单列可靠性、安全与评测的考量,其中编排模式覆盖顺序、并发、群聊、交接等形态;具体分类名称与覆盖范围请以该文档当前版本为准(需按当前官方文档核验)。本篇关心的正是它反复强调的一点:模型之外的约束层,才是 agent 进生产的关键。

概率非确定
权限是缰绳
越小越安全
越准越危险

04、架构

[输入] 用户退款诉求(自然语言)
    |
    v
[模块] 意图理解 Agent(LLM,只输出结构化建议)
    |
    v
[数据/状态] 订单与退款单据状态机 + 幂等键表
    |
    v
[处理] 规则引擎核验(金额上限/权限/订单状态)→ 事务服务执行
    |
    v
[输出] 受理 / 拒绝 / 转人工 + 监控与评测事件

这个架构的边界很清楚:模型只负责“用户想干什么、为什么”,不负责“能不能退、退多少、退给谁”。金额由规则引擎从订单事实上算,执行由事务服务在状态机约束下完成,幂等键由接入层生成并贯穿全链路。

五道防线按顺序排开:

  1. 结构化输出校验:JSON 可解析、字段类型正确、枚举在白名单内、长度有上限。校验失败即拒绝,不进业务逻辑。
  2. 业务规则校验:订单存在、状态允许退款、可退金额大于零、退款额度不超过订单实付与授信上限,权限与操作角色匹配。
  3. 超时重试与幂等:只有幂等安全的操作才允许重试;每次资金动作必须携带幂等键,重复键返回首次结果而不是再执行一次。
  4. 降级与人工接管:模型不可用、超时、校验失败、规则判定为“不确定”,统一落到人工队列,而不是落到默认放行。
  5. 版本化评测与监控:模型版本、提示词版本、规则版本都参与评测集回归;线上监控覆盖结构化失败率、规则拦截率、人工接管率、重复执行拦截数。

收益是边界明确:模型换版本、换供应商,都不影响资金动作的正确性,因为资金动作从不依赖模型的自由文本。代价是链路变长、延迟增加、自动化率短期下降,且要额外维护幂等表和评测集。

适用条件是:只要链路末端存在不可逆副作用(资金、库存、权限、消息下发),这个分层就必须存在;反过来,纯生成、无副作用的场景,可以不必上满五道。

分层定边界
每层守一关
模型只建议
事务不可犯

05、实战一次

先把 V1 跑起来,把问题留在明面上。

环境与版本:Python 3.11,仅使用标准库,无第三方依赖,无需配置文件。

核心实现(V1,反面教材):

# v1_refund.py —— 把大模型当作确定性业务引擎
import json
import uuid

ORDERS = {
    "O-1001": {"paid": 899.0, "refunded": 0.0, "status": "PAID", "user_id": "U-1"},
}


def execute_refund(order_id: str, amount: float) -> dict:
    order = ORDERS[order_id]
    order["refunded"] += amount
    return {"refund_id": str(uuid.uuid4()), "order_id": order_id, "amount": amount}


class FakeLLM:
    """用固定文本模拟一次概率性输出,便于复现问题。"""

    def __init__(self, raw: str) -> None:
        self.raw = raw

    def suggest(self, message: str) -> str:
        return self.raw


def handle(message: str, llm: FakeLLM) -> dict:
    raw = llm.suggest(message)        # 1) 取模型输出
    payload = json.loads(raw)         # 2) 直接解析,没有结构校验
    return execute_refund(            # 3) 直接执行,没有规则校验,没有幂等
        payload["order_id"],
        payload["amount"],
    )


if __name__ == "__main__":
    llm = FakeLLM('{"order_id": "O-1001", "amount": 899.0}')
    for i in range(2):
        print(f"第{i + 1}次:", handle("这个订单我要退款", llm))
    print("订单终态:", ORDERS["O-1001"])

启动与请求:

$ python v1_refund.py

未在当前环境实测,以下为预期结果(示例输出):

第1次: {'refund_id': '...', 'order_id': 'O-1001', 'amount': 899.0}
第2次: {'refund_id': '...', 'order_id': 'O-1001', 'amount': 899.0}
订单终态: {'paid': 899.0, 'refunded': 1798.0, 'status': 'PAID', 'user_id': 'U-1'}

验证到的三件事:同一个请求被处理两次,资金动作发生两次;执行层没有幂等保护;金额完全来自模型输出,订单事实只被当作一个可写对象。V1 跑通了,但它跑通的是“危险”本身。

先跑通最小
再留下旧伤
不测不知错
一跑见真章

06、排查

诊断链一:订单出现两笔退款

现象:同一订单号出现两笔退款流水,金额相同,时间相差 3 秒。

怀疑:模型重复输出了两次指令。

检查:拉取网关 access log 与链路 trace,发现只有一个用户请求,但下游退款接口被调用两次,两次调用携带的 request_id 不同。

证据:上游调用 LLM 的请求超时(超过网关设定阈值)后触发自动重试,第一次请求实际成功但响应丢失,第二次重试再次进入执行分支;执行层既没有幂等键,也没有“先查后写”。

根因:可靠性被寄希望于重试,而重试只对幂等操作安全。

修复:接入层为每次业务请求生成幂等键并透传到底;执行层以幂等键为唯一写入口,重复键返回首次结果并标记 replayed。

错误尝试: 第一反应是把重试次数改为 0,靠“不重试”避免重复。这是错的——重试是应对网络抖动的必要手段,砍掉重试会把偶发超时变成用户可见失败,而且上游任何一层(网关、宿主机、SDK)都可能自发重试,你控制不了。正确做法是让执行幂等,而不是让重试消失。

诊断链二:退款金额与订单实付不一致

现象:一笔订单实付 899,模型输出金额 8990,规则层当时没有拦截,资金侧对账才发现差异。

怀疑:提示词不够严格,模型在“幻觉”。

检查:回放该会话上下文,发现用户消息里出现过“8990”这个数字(用户在说另一件事),模型把它当成了退款金额;同时校验层只检查了字段存在,没有检查金额来源。

证据:模型在同一个提示词下多次重放,金额取值在几个数字之间漂移,说明它在做语言层面的概率推断,而不是在做金额计算。

根因:把“事实计算”交给了一个没有事实输入的组件。金额必须由订单状态和已退金额推导,模型全程不参与。

修复:模型输出结构中删除 amount 字段(或保留但忽略),可退金额由规则引擎按订单事实计算,并加上限校验。

错误尝试: 第二个反应是把 temperature 设为 0,并在提示词里加“请勿编造金额”。这同样是错的——温度只影响采样分布,提示词只影响先验,两者都不能给出确定性保证;把安全寄托在“模型这次应该不会错”上,等于没有防线。

诊断链三:非法输出直接 500

现象:部分请求返回 500,日志里是 JSONDecodeError。

怀疑:模型服务不稳定。

检查:抓取模型原始响应,发现返回内容被 Markdown 代码块包裹,或字段缺失。

证据:异常发生在解析阶段,属于本地代码对输入分布假设过强。

根因:缺少结构化输出校验这一层,解析失败没有转成确定性失败码。

修复:解析层统一做“可解析、是对象、枚举合法、字段类型正确”四步校验,失败即返回确定失败码并转人工,绝不进入业务分支。

现象先复现
证据再开口
根因不猜出
修在最小处

07、优化

按第 06 章的三条根因,逐条落到 V2 的代码上。

根因一(无幂等)→ 引入幂等键与幂等表;原因:资金动作必须满足“同键一次”;新行为:重复键返回首次结果并标记 replayed;验证:同一幂等键连续调用两次,只产生一笔退款,第二次返回 replayed=True。

根因二(模型裁决金额)→ 模型输出结构中去掉金额裁决权;原因:事实必须来自事实源;新行为:金额由规则引擎按订单实付与已退金额计算;验证:即使模型输出 amount=89900,最终执行金额仍由订单事实决定。

根因三(无结构校验)→ 增加解析层校验与失败码;原因:解析失败是输入问题,不是系统故障;新行为:非法输出落到 MANUAL 终态;验证:传入被 Markdown 包裹的字符串,返回失败码而非异常。

V2 核心实现:

# v2_refund.py —— 模型只建议,规则裁决,事务执行
import json
import uuid

ORDERS = {"O-1001": {"paid": 899.0, "refunded": 0.0, "status": "PAID", "user_id": "U-1"}}
SEEN: dict = {}                      # 幂等表:idempotency_key -> 首次结果
ALLOWED_INTENTS = {"refund", "query", "unknown"}


# 第 1 道防线:结构化输出校验
def parse_model_output(raw: str) -> dict:
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ValueError("MODEL_OUTPUT_NOT_JSON") from exc
    if not isinstance(data, dict):
        raise ValueError("MODEL_OUTPUT_NOT_OBJECT")
    intent = data.get("intent")
    if intent not in ALLOWED_INTENTS:
        raise ValueError("MODEL_INTENT_NOT_ALLOWED")
    order_id = data.get("order_id")
    if not isinstance(order_id, str) or not order_id:
        raise ValueError("MODEL_ORDER_ID_INVALID")
    return {"intent": intent, "order_id": order_id, "reason": str(data.get("reason", ""))[:200]}


# 第 2 道防线:业务规则校验,金额由订单事实计算
def decide(payload: dict) -> dict:
    order = ORDERS.get(payload["order_id"])
    if order is None:
        return {"decision": "REJECT", "code": "ORDER_NOT_FOUND"}
    if order["status"] != "PAID":
        return {"decision": "MANUAL", "code": "ORDER_STATUS_NOT_REFUNDABLE"}
    refundable = round(order["paid"] - order["refunded"], 2)
    if refundable <= 0:
        return {"decision": "REJECT", "code": "NOTHING_TO_REFUND"}
    return {"decision": "ACCEPT", "code": "OK", "amount": refundable}


# 第 3 道防线:幂等执行,重试只对幂等操作安全
def execute(order_id: str, amount: float, idempotency_key: str) -> dict:
    if idempotency_key in SEEN:
        return {**SEEN[idempotency_key], "replayed": True}
    result = {
        "refund_id": str(uuid.uuid4()),
        "order_id": order_id,
        "amount": amount,
        "replayed": False,
    }
    ORDERS[order_id]["refunded"] += amount
    SEEN[idempotency_key] = result
    return result


# 第 4 道防线:降级与人工接管
def handle(message: str, raw_model_output: str, idempotency_key: str) -> dict:
    try:
        payload = parse_model_output(raw_model_output)
    except ValueError as exc:
        return {"decision": "MANUAL", "code": str(exc)}
    verdict = decide(payload)
    if verdict["decision"] != "ACCEPT":
        return verdict
    return {"decision": "ACCEPT", **execute(payload["order_id"], verdict["amount"], idempotency_key)}


if __name__ == "__main__":
    raw = '{"intent": "refund", "order_id": "O-1001", "amount": 89900, "reason": "用户要求退款"}'
    print("首次:", handle("退款", raw, "req-1"))
    print("重放:", handle("退款", raw, "req-1"))
    print("再退:", handle("退款", raw, "req-2"))
    print("脏数据:", handle("退款", '```json\n{}', "req-3"))

未在当前环境实测,以下为预期结果(示例输出):

首次: {'decision': 'ACCEPT', 'refund_id': '...', 'order_id': 'O-1001', 'amount': 899.0, 'replayed': False}
重放: {'decision': 'ACCEPT', 'refund_id': '...', 'order_id': 'O-1001', 'amount': 899.0, 'replayed': True}
再退: {'decision': 'REJECT', 'code': 'NOTHING_TO_REFUND'}
脏数据: {'decision': 'MANUAL', 'code': 'MODEL_OUTPUT_NOT_JSON'}

第 5 道防线是评测与监控,它不是一个函数,而是一套流程:模型版本、提示词版本、规则版本一起进评测集;线上按结构化失败率、规则拦截率、人工接管率、幂等重放命中数四条指标观察,任何一条异常都回滚到上一版本。

根因既已定
改动不加戏
新行为可验
才算真优化

08、演进

[同一输入]
    |
    +--[V1] 模型输出即指令 / 代码最简、延迟最低 / 代价:重复退款、金额失真、非法输出可致 500
    |
    +--[V2] 模型只出建议,规则裁决,事务幂等执行 / 代价:链路变长、需维护幂等表与评测集
    |
[Trade-off]
得到:资金动作的确定性与可审计;失去:一部分即时自动化率与最短路径的简洁;适用边界:末端存在不可逆副作用时必选,纯生成场景可不选

正确性上,V1 的正确性依赖模型这一次采样对不对,属于概率保证;V2 的正确性由规则与状态机保证,模型错只影响“是否转人工”,不影响“是否多退一笔”。

稳定性上,V1 的故障面在解析层和执行层同时暴露,一个异常直接变成用户可见失败;V2 把异常收敛成确定性失败码,链路的失败模式变成有限的几种终态。

复杂度上,V2 明显更高:多了一层的校验、多了一张幂等表、多了人工队列和评测集。这部分复杂度不是设计者的洁癖,而是把不可逆副作用的代价提前支付掉。

成本与适用范围上,V2 增加的是存储与运维成本,换回的是“模型可以随便换版本”的自由;对无副作用的场景(摘要、归类、草稿),上满五道防线反而拖慢迭代,只需要保留结构校验和评测。

遗留问题也很清楚:人工接管队列的容量与响应时长会成为新瓶颈;幂等表的清理策略需要与业务对账周期对齐;评测集的覆盖度决定了“版本回滚”能不能真的挡住回归。

同入不同出
取舍在边界
得到亦失去
演进即权衡

09、洞见

9.1 概率组件的正确用法是收缩权限,不是提高准确率

反直觉判断:在资金链路里,把模型的准确率从 95% 提到 99%,收益远小于把它的权限从“决定金额”降到“只提意图”。 前者是在移动分布,后者是在切除风险面。工程上可执行的动作永远是后者:删字段、改职责、换位置。

9.2 校验层的直接效果是自动化率下降,这是收益不是损失

加了五道防线之后,短期自动化率一定下降,因为不合格的输出被拦成了人工单。但被拦下来的是“本来会出错的那部分”,它们原本并不创造价值,只是在积累事故。反直觉判断:校验拦掉的每一单,都对应一笔曾经会被错误执行的资金动作。

9.3 重试不是可靠性手段,幂等才是

重试解决的是“响应丢失”,代价是“重复执行”。只有当操作幂等,重试才是净收益。因此正确的顺序是:先给副作用加幂等键,再打开重试开关;反过来做,就是在给系统埋重复副作用。

9.4 安全边际不是保守,是把失败限制在可承受范围内

安全边际不是拒绝自动化,而是规定失败的表现形式:模型可以错,但错误只能表现为拒绝、转人工、重放被拦这三种可承受形态。设计时先问“最坏情况是什么样”,再问“这条链路能不能承受”,比先问“准确率多少”有用得多。

洞见非心得
来自前文证
一句一判断
可复用才行

10、系统落地

原来有什么:一条只有三步的直筒链路——模型输出 JSON,后端解析,执行退款。它有一个隐含假设:模型的输出等价于业务事实。

本篇新增了什么:一,意图理解 Agent 的职责收缩,只输出 intent、order_id 与理由,不再输出金额;二,解析层的结构校验与确定失败码;三,规则引擎作为金额与权限的唯一裁决者;四,以幂等键为唯一写入入口的事务执行;五,人工接管队列与四条线上指标。

现在能做什么:模型换版本、换供应商、甚至临时不可用,资金动作的正确性都不受影响;每一笔自动受理都能回溯出模型原始输出、规则判定与执行结果三段证据;重复请求与脏输出都有确定的终态,不再产生悬空单据。

还缺什么:人工接管队列的容量规划与 SLA 尚未定义;幂等表与业务对账的清理周期未对齐;评测集的覆盖度还没有覆盖到多轮追问、跨订单、部分退款这些边角场景;模型侧的成本与延迟尚未纳入同一套指标。

下一步如何演进:把评测集与线上指标接进同一条发布流水线,让模型版本、提示词版本、规则版本一起做灰度和回滚;把人工接管的判定规则从“校验失败”扩展为“置信度与业务金额的组合阈值”;把幂等键的生成口径从接入层下沉到统一网关,让所有不可逆操作共享同一套幂等基础设施。

原来有何物
新增在何处
能做与不能
下一步看清

11、小结

Q1 → 因为 V1 把资金动作的触发权交给了一次概率采样,模型错在源头,失控发生在末端
Q2 → 会短期下降,但被拦截的是本来就会出错的部分,收益是可承受的失败形态
Q3 → 最不能省的是幂等执行,它决定错误能否被限制为一次;其余四道决定错误能否被提前发现
状态 → 一条模型可替换、资金动作确定、失败有终态、决策可回溯的退款受理链路

三问皆有答
一态可交付
边界已划清
误差有归处

12、作业

12.1 理解题:为什么“把 temperature 调成 0 并加一句请勿编造金额”不构成一道防线?

参考答案:温度只改变采样分布的形状,提示词只改变先验概率,二者都不提供确定性保证,尾部样本始终存在。防线必须由外部约束构成——结构校验、规则裁决、幂等执行,它们不依赖模型这一次输出是否正确。

12.2 实战题:把 V2 的幂等表从进程内字典换成一张持久化表,需要改哪些地方?

参考答案:把 SEEN 换成带唯一索引的表,以 idempotency_key 作为主键;执行前先尝试插入占位记录,插入成功者才有权执行资金动作,冲突者读取首次结果返回;资金动作与占位记录的写入需要在同一事务或通过状态字段(PENDING / DONE)达成最终一致;同时增加超时未完成记录的清理与补偿扫描。

12.3 排障题:线上出现“同一订单两笔退款”,但幂等键不同,如何定位?

参考答案:先确认两次调用的幂等键来源——若幂等键由接入层按请求生成,说明业务侧产生了两个请求(用户重复提交、客户端重试、上游消息重复投递),属于请求去重问题;若幂等键由下游按调用生成,说明幂等键的生成位置错了,必须上移到请求入口。定位路径是:取两笔流水的时间戳、request_id、上游 trace,判断分叉点在网关之前还是之后。

12.4 架构判断题:一个纯文案生成场景,是否也要上满五道防线?

参考答案:不必。防线的数量应当与末端副作用的不可逆程度匹配。纯生成、无副作用、可人工复核的场景,保留结构化输出校验与版本化评测即可;规则校验、幂等执行、人工接管是为不可逆动作准备的,强行上满只会拖慢迭代并增加无谓的运维成本。

理解在题中
实战在手里
排障在证据
判断在取舍

13、思考

回到本篇的核心冲突:概率性输出与确定性事务直接对接,错误一定会从模型侧渗透到业务侧。安全边际模型的要义,不是让模型不犯错,而是规定它犯错之后系统会怎样——错误只能表现为拒绝、转人工、重放被拦,绝不能表现为一笔已经发生的不可逆动作。

这套判断可以迁移到任何接入大模型的链路:先问末端的副作用是否可逆,再决定模型能拿到多大权限;先问失败的表现形式是否可承受,再讨论准确率和自动化率。模型能力会继续变强,但概率组件的性质不会因此改变,约束层的价值也不会因此消失。

概率终有尾
边界定生死
模型可犯错
业务不失守

posted @ 2026-10-10 21:57  李福春  阅读(5)  评论(1)    收藏  举报