🏠 总目录📚 本教程 16c · 接进真实产品 ← →
📑 本页目录(点开跳转)

16c · 接进真实产品

⏱ 80 分钟 | ⚠️ 不管用不用框架,这三件事都得你自己处理


🎯 一句话

Demo 里模型只要「说得对」就赢了,产品里模型必须「说得能被代码接住、能被用户等到、能被拦在闯祸之前」。 这三件事分别叫结构化输出、流式输出、护栏。它们不属于任何框架——框架帮你少写几行胶水,但出事时坐在告警前面的是你。

上一章讨论的是「要不要用框架」。这一章讨论的是选了框架也躲不掉的那部分。

先说清和别的章的分工

这一章不讲 在哪讲
提示注入怎么攻、沙箱双边界、威胁建模四步 第 15 章。这里只讲护栏在产品链路里站哪个位置、什么时候做、拦下来之后怎么办
工具本身怎么设计(描述、防呆、错误信息四要素) 第 7 章。这里借用它的原则,用在输出通道上
评测集怎么建、评分器怎么校准、读转录 第 14 章。这里只讲审核模型自己也要被评这一条
成本怎么算、组织怎么推、合规怎么过 第 16 章

📦 一、结构化输出:让模型的话能被代码接住

为什么「让它输出 JSON」这句提示词不够

你在提示词末尾加一句「请以 JSON 格式返回」,跑十次都对,上线第三天挂了。模型可能:

这些错误的共同点:每一种单独看概率都很低,加起来就不低了。一天十万次调用,0.5% 就是 500 次线上异常。

三档做法的对照

档位 做法 能保证 不能保证
① 提示词约束 「请只输出 JSON,不要解释」 大概率长得像 JSON 前后缀、围栏、字段名漂移、类型漂移
② JSON mode API 参数强制解码出合法 JSON json.loads 一定成功 字段是什么、有没有少、类型对不对
③ 工具调用 / Schema 强约束 ⭐ 把 schema 交给 API,解码阶段受约束 字段齐全、类型正确、枚举值在集合内 值是不是真的

⭐ 这三档不是「越高越好」的替换关系,是「越高越窄」的取舍: Schema 越严,模型能表达的东西越少。它没法说「这单我判断不了」——除非你在枚举里给它留了那一项。

还有一个反直觉的现实:开放式任务上强上 schema 会损失质量。模型被迫一边推理一边往格子里填,等于剥夺了它「先想清楚再下结论」的空间。这时更稳的是两段式——第一次调用让它自由地推理和讨论,第二次调用把上一段的结论抽成结构。多花一次调用,换来的是结论质量和格式正确率同时提升。什么时候该合成一次、什么时候该拆成两次,判断依据很简单:这个任务如果交给人,他需不需要先打草稿?

⚠️ Schema 强约束只保证「格式对」,不保证「内容对」

这是最贵的一个误解。看这个字段:

# schema 只是一份【形状说明书】,不是一份事实核验
import json

FIELD = {"type": "number", "minimum": 0}
print(json.dumps(FIELD))    # ⭐ 它保证 refund_amount 一定是 number,不保证它是【对的】number

模型完全可以:把订单号当成金额填进去、把「预计退款」当成「已退款」、给一个数据库里根本不存在的 user_id、给一个引用了不存在文档的 source_id。每一个都能通过 schema 校验。

所以校验永远是两层:

层 查什么 谁能管
结构校验 字段在不在、类型对不对、枚举合不合法 schema 能管
语义校验 ⭐ 值域、跨字段一致性、外键真的存在、和上游事实对得上 只有你写的代码能管

「和上游事实对得上」是最容易漏的一条:退款金额要 ≤ 订单实付金额,不是 ≤ 某个常量;引用的文档 id 要真的能在检索结果里找到,不是长得像 id 就行。

校验失败之后:重试的工程细节

做法 效果
❌ 原样再发一次 模型不知道自己错在哪,大概率再错一次
✅ 把错误信息回传给模型 ⭐ 「$.refund_amount=998800 超过上限 5000」——它能一次改对
❌ 无上限重试 一次请求烧十轮 token,且延迟不可控
✅ 上限 2 次,然后走降级路径 第 3 次突然对的概率极低——这不是限流问题,是能力问题,退避没有意义

这其实就是第 7 章那条「错误信息是写给模型看的教学材料」,只不过那一章用在工具返回上,这里用在输出校验上。判断标准一样:模型看完这条错误,能不能一次就改对?

重试还有两件事容易忘:

枚举、可选字段、嵌套深度的取舍

设计 好处 ⚠️ 代价
枚举替代自由字符串 拼错直接被拦 枚举外的真实情况会被硬塞进最近的一项。必须留 "other" + 一个 other_reason 自由字段
可选字段 灵活 模型倾向于「把表填满」,可选字段会被瞎编。能不设就不设;一定要设就在描述里明写「不确定时省略该字段」
嵌套 表达力强 嵌套越深错误率越高。三层以上明显变差。宁可扁平化 + 用 id 关联
数组 表达列表 不写长度上限就会失控。schema 里写 maxItems,校验里也查一遍

🔨 一段框架无关的可跑代码

定义 schema → 调用 → 校验 → 失败时带着错误重试。只用标准库:

# 结构化输出全流程:定义 → 调用 → 校验 → 带错误重试(框架无关,纯标准库)
import json
import re

SCHEMA = {
    "type": "object",
    "required": ["intent", "refund_amount", "reason"],
    "properties": {
        "intent": {"type": "string", "enum": ["refund", "exchange", "other"]},
        # ⭐ 值域直接写进 schema,别只写 {"type": "number"}
        "refund_amount": {"type": "number", "minimum": 0, "maximum": 2000},
        "reason": {"type": "string", "maxLength": 200},
    },
}


def validate(obj, schema, path="$"):
    """极简校验器:返回【人话错误列表】,空列表 = 通过。"""
    errs = []
    kind = schema.get("type")
    if kind == "object":
        if not isinstance(obj, dict):
            return ["%s 应该是对象,实际是 %s" % (path, type(obj).__name__)]
        for k in schema.get("required", []):
            if k not in obj:
                errs.append("%s.%s 缺失(这是必填字段)" % (path, k))
        for k, sub in schema.get("properties", {}).items():
            if k in obj:
                errs += validate(obj[k], sub, "%s.%s" % (path, k))
    elif kind == "number":
        if isinstance(obj, bool) or not isinstance(obj, (int, float)):
            errs.append("%s 应该是数字,实际是 %r" % (path, obj))
        else:
            lo, hi = schema.get("minimum"), schema.get("maximum")
            if lo is not None and obj < lo:
                errs.append("%s=%s 小于下限 %s" % (path, obj, lo))
            if hi is not None and obj > hi:
                errs.append("%s=%s 超过上限 %s" % (path, obj, hi))   # ⭐ 值域校验
    elif kind == "string":
        if not isinstance(obj, str):
            errs.append("%s 应该是字符串,实际是 %r" % (path, obj))
        else:
            allowed = schema.get("enum")
            if allowed and obj not in allowed:
                errs.append("%s=%r 不在允许取值 %s 内" % (path, obj, allowed))
            limit = schema.get("maxLength")
            if limit is not None and len(obj) > limit:
                errs.append("%s 超长(%d > %d)" % (path, len(obj), limit))
    return errs


def extract_json(text):
    """模型爱套代码围栏、爱加开场白,先抠出最外层大括号再解析。"""
    m = re.search(r"\{.*\}", text, re.S)
    if not m:
        raise ValueError("响应里找不到 JSON 对象")
    return json.loads(m.group(0))


def semantic_check(obj, order):
    """⭐ schema 管不了的那一层:和上游【事实】核对。"""
    errs = []
    if obj["intent"] == "refund" and obj["refund_amount"] > order["paid"]:
        errs.append("refund_amount=%s 大于订单实付 %s,不可能"
                    % (obj["refund_amount"], order["paid"]))
    return errs


def call_model(messages):
    """换成任意 SDK 都行,返回一整段文本。"""
    raise NotImplementedError("接你自己的模型调用")


def ask_structured(user_text, order, max_retry=2):
    messages = [{"role": "user", "content": user_text}]
    last = None
    for _ in range(max_retry + 1):
        raw = call_model(messages)
        try:
            obj = extract_json(raw)
            errs = validate(obj, SCHEMA) or semantic_check(obj, order)
        except Exception as e:
            obj, errs = None, ["JSON 解析失败:%s" % e]
        if not errs:
            return obj
        last = errs
        # ⭐ 关键:把「你上次输出了什么 + 错在哪 + 该怎么改」一起回传,不要原样重试
        messages += [
            {"role": "assistant", "content": raw},
            {"role": "user", "content": "上次输出不合法,只输出修正后的 JSON,不要解释:\n"
                                        + "\n".join("- " + e for e in errs)},
        ]
    # ⭐ 必须有出口:重试是有上限的,超了就走降级(转人工 / 返回兜底)
    raise ValueError("重试 %d 次仍不合法:%s" % (max_retry, last))

💀 事故复盘:只校验了格式,没校验值域

某电商客服 Agent,schema 保证 refund_amount 是 number,但没写 maximum,下游退款接口只判断「是数字且 > 0」。

发生了什么 模型把订单号 998800 当成金额填进了 refund_amount。同类错误在 18 小时内触发 47 笔越界退款,合计约 ¥62 万
为什么没被发现 监控盯的是「校验失败率」,一直稳定在 99.7% 通过——而这 47 笔全部校验通过。评测集里所有金额样本都在 0–2000 区间,一条越界样本都没有(这就是第 14 章「只测正例」的翻版,只不过发生在输出通道上)
代价 ¥62 万资金损失 + 3 天对账 + 追回率不到六成
该补什么 ① schema 里补 maximum;② 加语义校验层:金额必须 ≤ 订单实付金额(和事实比,不是和常量比);③ 超过 ¥500 的退款转人工(动作侧硬闸);④ 监控从「校验失败率」改成「关键字段的值分布」——分布突变比失败率更早报警

🔑 这起事故的一句话教训: 「校验通过率 99.7%」是一句没有信息量的话——它只告诉你格式对,不告诉你有没有在赔钱。


🛑 读到这里可以停 —— 前半章讲完了(约 29 分钟)。 后半章还有:流式输出:用户不该盯着空白屏幕等 · 护栏:模型会说不该说的话 回来的时候不用重读,直接从下一节接着看就行。


🌊 二、流式输出:用户不该盯着空白屏幕等

为什么要流式:TTFT 比总时长更影响体感

TTFT(Time To First Token,首字延迟) 是用户从点下发送到看见第一个字的时间。

方案 TTFT 总时长 用户感受
非流式 12 s 12 s 「是不是卡死了」——很多人 5 秒就重刷
流式 0.4 s 12 s 「它在思考,还挺快」——愿意读完

总时长一模一样。差别只在于用户有没有在等待期间获得反馈。这也是为什么优化 TTFT 的性价比通常远高于优化总吞吐——后者用户根本感知不到。

怎么流:SSE 的基本形态

服务端返回 Content-Type: text/event-stream,然后一帧一帧地往同一个连接里写,帧之间用空行分隔:

要点

event: delta

data: {"text": "退"}

event: delta

data: {"text": "款"}

event: done

data: {"chars": 128}

前端用 EventSource 或 fetch + ReadableStream 逐帧接收,把 text 累加进同一个气泡里。关键点:HTTP 状态码在第一帧发出去的那一刻就已经定死了 200——这条会在下面反复咬你。

前端拼接的三个坑(每一个都会让你怀疑人生半天):

坑 症状 修
帧边界 ≠ 字符边界 中文变成乱码方块 网络分片会把一个 UTF-8 多字节字符切成两半。必须按字节缓冲、用流式解码器逐步解码,不能拿到一段就 decode
中间层在缓冲 ⭐ 本地好好的,上了线变成「等 12 秒然后一次性全出现」 反向代理默认开缓冲(Nginx 的 proxy_buffering)、CDN 也可能攒包。流式坏掉最常见的原因不在你的代码里
空闲连接被掐 长思考时连接莫名断开 中间设备会掐掉几十秒无数据的连接。定期发一个心跳注释帧保活

⚠️ 流式和结构化输出是天生冲突的

JSON 没输出完就不是合法 JSON。 你没法把 {"intent": "ref 交给 json.loads。这意味着:你不能同时要「一开口就有字」和「拿到完整结构」。

两种务实解法:

解法 怎么做 适用
① 先流文本,再补结构 面向用户的自然语言部分流式吐出;结构化字段在流结束后用第二次调用(或同一次的 tool_use 块)产出 结构只给后端用、用户看不见时
② 只对最终字段流式 ⭐ schema 里把那个长文本字段(reply / summary)放在最后,用增量解析器只把这个字段的增量往外吐,其余字段等流结束再一次性校验 用户要看正文、后端也要结构时

还有一种「容错解析」的思路——边收边给残缺 JSON 补右括号强行解析。能用,但别拿它做决策:半截的枚举值 "ref" 和半截的数字 99(真值 998800)都是合法但错误的。用它驱动 UI 预览可以,用它触发退款不行。

⚠️ 流式下的错误处理:已经发出去的收不回来

非流式时出错很简单——返回 500,前端显示「失败,请重试」。流式时你已经回了 200 并且吐了 300 个字,这时上游断了。

问题 处理
状态码用不了了 错误必须走流内错误事件(event: error),前端要能处理「200 + 半截内容 + error 帧」这种组合
半截内容怎么办 要么在末尾追加明确的「以上回答未完成」标记,要么整块置灰。最糟的是什么都不做——用户会把半句话当成完整答案
谁断的要分清 「上游模型断了」要重试或降级,「客户端断了」应该立刻停止一切工作。两者的日志和指标必须分开,混在一起你会以为服务在抖

中断与取消:用户关了页面,后端还在烧 token

浏览器标签一关,TCP 连接断开,但你的后端协程可能毫不知情,继续把模型的 2000 个 output token 生成完。

粗糙的量级感:一次长回答 2000 output token,如果 10% 的会话被用户中途放弃且没有取消传播,你就白付了 10% 的输出成本——而且这部分完全不产生任何用户价值。

取消要穿透整条调用栈:HTTP 层检测到断开 → 通知模型调用层关闭上游流 → 正在跑的工具调用也要能被打断。

# 取消传播:客户端断开 → 关掉上游模型流 → 别再烧 token(框架无关)
import contextlib
import threading


class Cancellation:
    """一个能被多处观察的取消信号。HTTP 层 / 模型调用层 / 工具执行层共用同一个。"""

    def __init__(self):
        self._flag = threading.Event()

    def cancel(self):
        self._flag.set()

    @property
    def cancelled(self):
        return self._flag.is_set()

    def check(self):
        if self.cancelled:
            raise RuntimeError("客户端已断开")     # ⭐ 用异常让整条调用栈一起退出


def log_abandoned(info):
    """把「被放弃的生成」单独记一笔,才能算出白烧了多少钱。"""
    print("abandoned:", info)


@contextlib.contextmanager
def upstream_stream(open_stream, cancel, **kw):
    s = open_stream(**kw)                          # 换成任意 SDK 的 stream 入口
    try:
        yield s
    finally:
        s.close()                                  # ⭐ finally 里关:取消/异常/正常结束都会走到
        if cancel.cancelled:
            log_abandoned(kw)

⚠️ 流式让「重试」变复杂

非流式时重试是隐形的——用户只知道等了久一点。流式时重试意味着用户已经看见的内容要被推翻。

⭐ 首 token 是一条清晰的分界线: 首 token 之前,你可以随便静默重试;首 token 之后,重试就是在打用户的脸。

这条线的直接推论:所有可能失败的准备工作都必须挤在第一个 token 之前。 鉴权、取上下文、拉用户档案、输入侧护栏检查——全部前置。一旦开始吐字,你就失去了「假装什么都没发生」的权利。

首 token 之后真出错了,只有三个体面选项:追加一句说明并保留已发内容、把气泡置灰并给一个「重新生成」按钮(由用户发起,不是你偷偷替换)、或者对高风险内容整块清空并给兜底文案。

工具调用在流式里长什么样

一次「回答」往往不是一条流,而是几段流拼起来的:

阶段 前端该显示什么
流文本 正常打字机效果
遇到工具调用,模型停下 「正在查询订单…」——不是转圈,是具体在干什么
后端执行工具 同上;超时要有单独提示
结果回灌,继续流 接着刚才的位置继续吐字
结束 收尾

所以前端状态机至少要有 thinking / streaming / tool_running / done / error 五态,只有 loading 和 done 两态的前端接不住这个模型。


🛑 第二个歇脚处 —— 结构化输出和流式都讲完了(约 55 分钟)。 最后一节:护栏 —— 输入侧 / 输出侧 / 动作侧三层,以及为什么动作侧最要命。 这一节可以独立读,回来直接从下一节接着看就行。


🛡️ 三、护栏:模型会说不该说的话

三层护栏,权重完全不同

层 拦什么 典型手段 失手代价
输入侧 越权请求、超范围话题、提示注入 意图分类、黑名单、路由到拒答 中
输出侧 敏感信息回显、幻觉、不当内容 审核模型、PII 检测、引用核对 中
动作侧 ⭐ 工具调用的权限边界 代码里的 if、白名单、金额上限、二次确认 高,差几个量级

⭐ 能说错话和能做错事,代价差几个量级。 说错一句话,你道个歉、改个提示词;转错一笔钱、删掉一张表、发出去一封邮件——没有撤销键。 这就是第 15 章那条铁律在产品层的样子:确定性的环境层边界才是底线。输入侧和输出侧都是概率性的,必有漏网;动作侧可以确定性执行既定规则,但规则、可信输入、并发与实现也会出错,必须用负例验证。

护栏在链路里站哪个位置

时点 做什么 预算 / 约束
请求进入 输入侧分类 < 150 ms,否则直接吃掉你的 TTFT。可以和主调用并行发起,谁先返回不重要,主调用的第一个 token 等分类结论
首 token 之前 鉴权、取上下文、输入侧结论汇合 先完成已知的前置校验;流开始后仍可能失败,须有流中错误与清理协议
流式过程中 滑动窗口输出审核 只能「掐断」,不能「回收」
每次工具调用之前 ⭐ 动作侧硬闸 必须同步,绝不能异步或采样
流结束之后 全文复审 + 审计落库 可以异步

输入侧怎么做才不吃掉 TTFT:别串行等。请求一进来就同时发起「输入分类」和「主模型调用」,主调用的响应先攒在服务端不往外发;分类结论一到,是放行就把攒的内容一次性冲出去、后续转直吐,是拦截就把主调用取消掉、直接回兜底文案。这样护栏的耗时被主调用的首 token 时间吃掉了,用户感知不到。代价是被拦的那部分请求白跑了一小段生成——对绝大多数产品来说,这笔账划算得离谱。

输出审核放哪:两种位置的对照

流式前审核(等生成完再发) 流式中审核(滑动窗口)
体验 ❌ 牺牲流式,TTFT = 全文生成时长 ✅ 保留流式
判断质量 ✅ 看得到全文,最准 ⚠️ 只看片段,误判率高(一句话的后半截可能改变性质)
漏出风险 避免未经审核即发送;审核器仍可能漏判 💀 命中前已经吐出去的收不回来
适用 医疗、金融、对外发布内容 一般对话

⭐ 中间有一个很好用的折中:延迟发送。 先在服务端缓冲 100 字符左右再往外吐,用 0.5 秒左右的 TTFT 换来一个领先于已发送内容的审核窗口,但不能保证检测到所有问题。 用户几乎感知不到 0.5 秒,但你把「已经漏出去」变成了「还在缓冲区里」。

⚠️ 审核模型本身也会错

审核器是另一个模型,它有两类错误,代价完全不同:

怎么量这两类错误率(一套能真跑起来的做法):

步骤 做什么
1 建正常集:从线上真实流量抽 500 条已知无害的请求-响应对(不是编的,必须来自真实分布)
2 建对抗集:红队写 200 条已知有害的,按类别配比(不要全堆在一个类别上)
3 FPR = 正常集被拦条数 / 500;FNR = 对抗集被放行条数 / 200
4 阈值扫描 ⭐:把审核分数阈值从 0.1 扫到 0.9,得到一条 FPR–FNR 曲线。选点的依据不是「哪里最准」,是「哪类错更贵」
5 线上持续量:每天从拦截样本抽 50 条、从放行样本抽 50 条人工复核,算出线上 FPR/FNR,和离线值对齐。离线线上差太多说明你的集合不代表真实分布

⭐ 一句话:审核器也是一个需要评测的模型。 第 14 章那一整套——正反平衡、读转录、别信 3 个点以内的差距——原样适用,只是被评的对象从主模型换成了守门员。

拦截率还必须进监控(见下面的 🔗 表)。它突然翻倍,八成不是用户集体变坏了,是你上游改了什么。

兜底文案:被拦下时给用户看什么

❌ 常见写法 问题
「请求被拒绝」 用户不知道自己做错了什么,只会原样再发一次
「内容违规」 像是在指责用户
「因为您提到了『剂量』这个词」💀 泄露规则细节 = 教人绕过

好兜底文案的三要素:① 用人话说明发生了什么(但不透露规则)② 给一条真的能走的下一步 ③ 给申诉/转人工入口 + 一个可引用的编号。

示例:「这个问题涉及具体用药剂量,我不能给建议。我可以帮你整理一份要问医生的问题清单,或者转接人工。(编号 R-8f21,反馈时请报这个号)」

审计留痕:拦了什么、为什么拦,要能查

半年后有人问「三月那笔为什么被拦」,你要答得出来。所以每条拦截记录至少要有:

字段 为什么必须有
trace_id 用户报编号,你能查到
触发层(输入/输出/动作) 定位问题出在哪一环
规则 id + 规则版本 ⭐ 规则会改。没有版本号,历史记录复现不出来
命中片段(脱敏后) 人工复核要看证据
模型分数 + 当时的阈值 才能回答「当时为什么是这个判定」
处置动作(拦截/降级/放行加标)
人工复核结论 ⭐ 这就是上面 FPR/FNR 的数据来源,闭环靠它

下面的独立脚本演示执行前检查:未知工具默认拒绝;订单事实从服务端数据取得;金额使用整数分。user 必须来自服务器认证上下文,不能从模型参数反序列化而来。

import time
import uuid

POLICY = {"version": "2026-09-06.1", "max_cents": 200000, "approval_above_cents": 50000}
# 本地假订单;生产中在可信数据库中按租户、订单和用户查询。
ORDERS = {("tenant-a", "o1"): {"owner": "alice", "paid_cents": 100000, "refunded_cents": 10000}}

def gate_tool_call(name, args, user, audit):
    rec = {"trace_id": uuid.uuid4().hex, "policy_version": POLICY["version"],
           "ts": time.time(), "user": user.get("id"), "tool": name}
    def finish(action, rule):
        audit.append({**rec, "action": action, "rule": rule})
        return {"ok": action == "allow", "action": action, "rule": rule, "trace_id": rec["trace_id"]}
    if name != "refund":
        return finish("deny", "unknown_tool")
    if not isinstance(args, dict) or set(args) != {"order_id", "amount_cents"}:
        return finish("deny", "invalid_arguments")
    cents = args["amount_cents"]
    if type(cents) is not int or cents <= 0:  # 同时拒绝 bool、float、NaN
        return finish("deny", "invalid_amount")
    if not isinstance(args["order_id"], str):
        return finish("deny", "invalid_order_id")
    order = ORDERS.get((user.get("tenant"), args["order_id"]))
    if order is None or order["owner"] != user.get("id"):
        return finish("deny", "not_authorized")
    if cents > min(POLICY["max_cents"], order["paid_cents"] - order["refunded_cents"]):
        return finish("deny", "insufficient_refundable_balance")
    if cents > POLICY["approval_above_cents"]:
        return finish("requires_approval", "human_approval_required")
    return finish("allow", "precheck_passed")

if __name__ == "__main__":
    audit = []
    session_user = {"id": "alice", "tenant": "tenant-a"}
    print(gate_tool_call("refund", {"order_id": "o1", "amount_cents": 40000}, session_user, audit))
    print(gate_tool_call("unregistered", {}, session_user, audit))

第一条是允许进入下一步,第二条是拒绝。allow 不代表已退款,requires_approval 也不代表已经转人工。 此脚本没有执行退款、创建审批或持久化审计。

检查与执行之间,订单可能被其他请求修改。正式执行还要在事务中重新核对余额,以条件更新防并发超退,并将幂等记录与本地业务更新一起提交。远程支付不能被本地事务包住:要用下游幂等键、结果查询与对账。可实际重启、重复投递的例子在12b。

💀 事故复盘:审核只在末尾做,有害内容已经吐了 3.2 秒

某 C 端问答产品,输出审核放在「流结束后」做全文复审,而前端是边收边渲染的。

发生了什么 模型生成了一段包含自伤方式描述的内容。全文审核在 3.2 秒后判定拦截并把消息替换成兜底文案——但那 3.2 秒里前端已经渲染了约 240 个字符,并且有用户截了图
为什么没被发现 审核模型离线评测 F1 = 0.94,团队验证的断言是「最终消息里不含有害内容」,而且评测全程跑在非流式模式下——「用户中途看到了什么」这个维度压根不在评测里
代价 功能下线 5 天复盘 + 一次公关事件。技术上零 bug,评测全绿
该补什么 ① 高风险类别改成缓冲后发(攒 120 字符再吐,花 ~0.6 s TTFT 买断这个窗口);② 加流式中滑动窗口审核,命中立刻断流;③ 评测必须在流式模式下跑,断言改成「任意时刻已发送的前缀都不含有害内容」⭐;④ 前端收到 blocked 事件要清空已渲染内容,而不是在后面追加一段道歉

🔑 这起事故的一句话教训: 你的评测跑在非流式模式,而你的用户活在流式模式里。 评测和线上的形态不一致,F1 再高也保护不了任何人。


🔗 这一章连到哪里

去哪 为什么
模型上线之后 05 · 该监控什么 护栏拦截率、结构化校验失败率、重试率、TTFT p95 都是线上指标。指标该怎么挑、怎么分层、什么算健康在那一章
模型上线之后 08 · 告警为什么没人看 审核告警阈值定不好就是第 15 章「审批疲劳」的翻版——每次拦截都告警 = 没有告警
AI基础设施 20 · 推理服务化 ⭐ 本章的「取消」在推理引擎那一侧对应什么:那一章第六节把「超时与取消」列为生产必备五件事之一 —— 客户端一断开就要立刻停止生成并释放 KV Cache,否则用户关了页面、服务器还在为他生成 2000 个 token,占着槽位不放。⚠️ 分界:那边是自建推理服务要做的事,本章和《AI 全栈》05 是调用方要做的事
大模型全景导论 12 · 对齐与AI安全 护栏是「模型已经说错了再拦」,那一章讲的是模型为什么会说错,以及在训练阶段能做什么
16b · 框架这层皮 上一章讲「要不要用框架」;这三件事不管用不用框架都得自己处理
⭐ 《AI 全栈》05 · 流式输出 · 10 · 把流式接到界面上 本章讲流式的设计(为什么要、护栏怎么配合),那两章讲它的工程链路和坑:SSE 报文格式、⚠️ 反向代理/压缩中间件会把流吃掉、⚠️ 头已经发出去了没法再返回 500(所以错误只能作为流里的一帧)、浏览器端 EventSource 带不了 Authorization、边流边渲染 Markdown 必须净化。要真把流式做对,那两章是必读的另一半
⭐ 《AI 全栈》00 · 怎么用这份教程 本章之外的那一整圈:后端骨架、数据库、认证多租户、队列、成本护栏、容器化部署、可观测。「模型已经好了但产品上不了线」的问题全在那个板块
---

✅ 检查点

  1. 「让它输出 JSON」这句提示词最典型的三种翻车方式是什么?
  2. 结构化输出三档做法各能保证什么、不能保证什么?为什么说不是「越高越好」?
  3. Schema 强约束通过了,还可能出什么错?校验必须分哪两层?
  4. 校验失败时该怎么重试?为什么重试次数不该无上限、也不该指数退避?
  5. 为什么流式和结构化输出天生冲突?两种务实解法是什么?
  6. 「首 token 是一条分界线」——这条线的两侧分别能做什么、不能做什么?它的直接推论是什么?
  7. 三层护栏中哪一层最重要?为什么?它和第 15 章的哪条铁律是同一件事?
  8. 流式前审核和流式中审核各自的代价是什么?折中方案是什么?
  9. 怎么量审核模型的假阳性率和假阴性率?选阈值的依据是什么?
  10. 两起事故复盘各自的「没被发现的原因」是什么?
👀 答案
  1. 加开场白、套代码围栏、字段名和类型漂移(Status、"无" 当 null)、超长被截断导致右括号永远等不到。单看概率都低,十万次调用 0.5% 就是 500 次异常。
  2. ① 提示词约束:大概率像 JSON,保不住前后缀和字段名;② JSON mode:json.loads 一定成功,但保不住字段和类型;③ Schema 强约束:字段齐全、类型对、枚举合法,但保不住值是不是真的。不是越高越好——schema 越严,模型能表达的越少,它没法说「我判断不了」,除非枚举里留了那一项。
  3. 值是编的:订单号被当成金额、user_id 不存在、引用了不存在的文档。校验分结构校验(schema 能管)和语义校验(值域、跨字段一致、外键存在、和上游事实对得上——只有你的代码能管)。
  4. 把错误信息回传给模型(「$.refund_amount=998800 超过上限」),它才能一次改对;上限 2 次然后走降级。不退避是因为这不是限流问题、是能力问题,第 3 次突然对的概率极低。另外重试要计入 token/延迟预算,重试率本身要进监控。
  5. 因为 JSON 没输出完就不是合法 JSON,你不能同时要「一开口有字」和「拿到完整结构」。解法:① 先流文本再补结构;② 只对最终字段流式——把长文本字段放在 schema 最后,只增量吐它。容错解析可以驱动 UI 预览,不能驱动决策。
  6. 首 token 之前可以随便静默重试;之后重试就是推翻用户已经看见的内容。推论:所有可能失败的准备工作(鉴权、取上下文、输入侧护栏)都必须前置到第一个 token 之前。
  7. 动作侧。因为能说错话和能做错事代价差几个量级——说错话可以道歉,转错钱/删错表/发错邮件没有撤销键。对应第 15 章:概率性的模型层防御必有漏网,确定性的环境层边界才是底线;输入输出侧是概率性的,动作侧的 if 不是。
  8. 流式前审核零漏出但 TTFT = 全文时长,牺牲流式;流式中审核保留流式但只看片段、误判率高,且命中前已经吐出去的收不回来。折中:延迟发送——缓冲约 100 字符(约 0.5 s TTFT)换一个永远领先用户视线的审核窗口。
  9. 建 500 条真实流量的正常集和 200 条红队写的对抗集,FPR = 被拦/500,FNR = 被放行/200;再做阈值扫描画 FPR–FNR 曲线;线上每天抽 50 条拦截 + 50 条放行人工复核对齐。选阈值的依据不是「哪里最准」,是「哪类错更贵」。
  10. 事故一:监控盯的是「校验失败率」而 47 笔越界退款全部校验通过,评测集里一条越界样本都没有。事故二:审核 F1 0.94,但断言只覆盖「最终消息」,评测跑在非流式模式下,「用户中途看到了什么」这个维度不存在。

🛑 可以停在这里

⚡ 走神救援

三件事,用不用框架都得自己处理。

① 结构化输出:「请输出 JSON」不够。三档从提示词约束到 Schema 强约束——⭐ 越严的档只保证格式对,不保证内容对,而且越严越窄(记得给枚举留 other、少用可选字段,它们会被瞎编)。⭐ 校验必须两层:结构校验 schema 能管,语义校验(值域、外键、和上游事实对不对得上)只有你的代码能管。重试要带上「你上次错在哪」,两次之后降级。

💀 事故一:schema 没写上界,一个订单号被当成金额,十几个小时里连续越界退款几十万——⭐ 全部校验通过,监控盯错了指标。

② 流式:首字延迟比总时长更影响体感。⚠️ 状态码在第一帧就定死 200,所以错误只能走流内的 error 事件,而半截内容收不回来、必须显式标记。⭐⭐ 首 token 是分界线:在它之前能静默重试,在它之后重试就是推翻用户已经看到的东西——推论是所有可能失败的准备工作都要前置。⚠️ 流式和结构化天生冲突(半截 JSON 不合法),解法是先流文本再补结构。⚠️ 取消要穿透整条栈,否则用户关了页面你还在烧 token。

③ 护栏分输入侧、输出侧、动作侧,⭐ 动作侧最重要,因为代价差几个量级——说错话能道歉,转错钱没有撤销键。

⚠️ 输出审核有个两难:流式前审零漏出但没了流式,流式中审保住流式但漏出已发生;折中是缓冲一小段延迟发送。⭐ 审核器本身也会错,而两类错的代价不同:误报让用户绕着说话,漏报放行有害内容——阈值要按「哪类错更贵」选,不是按「哪个最准」选。

💀 事故二最该记:审核只在末尾做,有害内容已经渲染了几百字、好几秒,而评测 F1 很高、全绿——⭐ 因为评测跑在非流式模式,用户活在流式模式。

下一节 👉 17-实战项目.md

打卡记录保存在你的浏览器里,首页能看到总进度