🏠 总目录📚 本教程 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_amountnumber但没写 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}

前端用 EventSourcefetch + 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 五态,只有 loadingdone 两态的前端接不住这个模型。


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


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

三层护栏,权重完全不同

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

能说错话和能做错事,代价差几个量级。 说错一句话,你道个歉、改个提示词;转错一笔钱、删掉一张表、发出去一封邮件——没有撤销键。 这就是第 15 章那条铁律在产品层的样子:确定性的环境层边界才是底线。输入侧和输出侧都是概率性的,必有漏网;动作侧的 if 不会。

护栏在链路里站哪个位置

时点 做什么 预算 / 约束
请求进入 输入侧分类 < 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 的数据来源,闭环靠它

下面这段把动作侧硬闸和审计留痕合在一起,是最该抄走的一段:

# 动作侧护栏:工具调用前的同步硬闸 + 审计留痕(是代码里的 if,不是提示词里的一句话)
import time
import uuid

POLICY = {
    "version": "2026-08-04.1",          # ⭐ 规则带版本号,改了才能和历史审计对上
    "refund": {"max_amount": 2000, "needs_human_above": 500},
}


def _deny(rec, audit, rule, human_msg):
    rec.update(action="deny", rule=rule)          # ⭐ 拦了什么、按哪条拦的,都要落库
    audit.append(rec)
    return {"ok": False, "trace_id": rec["trace_id"],
            "message": "这笔退款我不能直接处理(%s)。已转人工,编号 %s。"
                       % (human_msg, rec["trace_id"])}


def gate_tool_call(name, args, user, audit):
    """在【每一次】工具调用之前同步跑。不能异步,不能采样。"""
    rec = {"trace_id": uuid.uuid4().hex[:8], "layer": "action", "tool": name,
           "policy_version": POLICY["version"], "ts": time.time(), "user": user["id"]}
    if name == "refund":
        amt = args.get("amount")
        if isinstance(amt, bool) or not isinstance(amt, (int, float)) or amt <= 0:
            return _deny(rec, audit, "amount_invalid", "金额格式不对")
        if amt > POLICY["refund"]["max_amount"]:
            return _deny(rec, audit, "over_max", "超出单笔上限")
        if amt > args.get("order_paid", 0):        # ⭐ 和事实核对,不只和常量比
            return _deny(rec, audit, "over_paid", "退款不能大于订单实付")
        if amt > POLICY["refund"]["needs_human_above"]:
            rec["action"] = "escalate"
            audit.append(rec)
            return {"ok": False, "escalate": True, "trace_id": rec["trace_id"]}
    rec["action"] = "allow"
    audit.append(rec)
    return {"ok": True, "trace_id": rec["trace_id"]}

💀 事故复盘:审核只在末尾做,有害内容已经吐了 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」不够(开场白/围栏/字段漂移/截断)。三档——提示词约束→JSON mode(只保证能 loads)→Schema 强约束(保证格式对,不保证内容对);越严越窄,记得给枚举留 other、少用可选字段(会被瞎编)、别嵌套超过三层。校验必须两层:结构校验(schema 管)+ 语义校验(值域、外键、和上游事实对得上,只有你的代码能管)。重试要带上「你上次错在哪」,上限 2 次然后降级,重试率进监控。💀 事故:schema 没写 maximum,订单号 998800 被当成金额,18 小时 47 笔越界退款 ¥62 万——全部校验通过,监控盯错了指标。② 流式TTFT 比总时长更影响体感(0.4 s vs 12 s,总时长一样)。SSE 一帧一帧写,状态码在第一帧就定死 200——所以错误只能走流内 error 事件,半截内容收不回来必须显式标记。流式和结构化天生冲突(半截 JSON 不合法),解法是先流文本再补结构、或只对最后那个长文本字段流式。取消要穿透整条栈,否则用户关了页面你还在烧 token。⭐ 首 token 是分界线:之前能静默重试,之后重试就是推翻用户看到的东西——推论是所有可能失败的准备工作都要前置。工具调用让一次回答变成几段流拼接,前端要有五态状态机。③ 护栏:输入侧 / 输出侧 / 动作侧(最重要,代价差几个量级,说错话能道歉,转错钱没有撤销键)——对应第 15 章「确定性的环境层边界才是底线」。输出审核流式前零漏出但没流式,流式中保留流式但漏出已发生,折中是缓冲 100 字符延迟发送审核器也会错:FPR 让用户绕着说话,FNR 放行有害内容;用 500 条正常集 + 200 条对抗集量化,阈值按「哪类错更贵」选而不是按最准选。兜底文案要说人话 + 给下一步 + 给编号,但不能泄露规则细节。审计留痕必须带规则版本号和当时的阈值。💀 事故二:审核只在末尾做,有害内容已渲染 240 字符 / 3.2 秒,F1 0.94 的评测全绿——因为评测跑在非流式模式,用户活在流式模式

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

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