📑 本页目录(点开跳转)
16c · 接进真实产品
⏱ 80 分钟 | ⚠️ 不管用不用框架,这三件事都得你自己处理
🎯 一句话
Demo 里模型只要「说得对」就赢了,产品里模型必须「说得能被代码接住、能被用户等到、能被拦在闯祸之前」。 这三件事分别叫结构化输出、流式输出、护栏。它们不属于任何框架——框架帮你少写几行胶水,但出事时坐在告警前面的是你。
上一章讨论的是「要不要用框架」。这一章讨论的是选了框架也躲不掉的那部分。
先说清和别的章的分工
| 这一章不讲 | 在哪讲 |
|---|---|
| 提示注入怎么攻、沙箱双边界、威胁建模四步 | 第 15 章。这里只讲护栏在产品链路里站哪个位置、什么时候做、拦下来之后怎么办 |
| 工具本身怎么设计(描述、防呆、错误信息四要素) | 第 7 章。这里借用它的原则,用在输出通道上 |
| 评测集怎么建、评分器怎么校准、读转录 | 第 14 章。这里只讲审核模型自己也要被评这一条 |
| 成本怎么算、组织怎么推、合规怎么过 | 第 16 章 |
📦 一、结构化输出:让模型的话能被代码接住
为什么「让它输出 JSON」这句提示词不够
你在提示词末尾加一句「请以 JSON 格式返回」,跑十次都对,上线第三天挂了。模型可能:
- 在 JSON 前面加一句「好的,以下是结果:」
- 用
```json围栏包起来(还可能是```、~~~、或者忘了闭合) - 把
status写成Status、把null写成"无" - 数组里最后一项多一个逗号
- 输出超长被截断,右括号永远等不到
这些错误的共同点:每一种单独看概率都很低,加起来就不低了。一天十万次调用,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 章那条「错误信息是写给模型看的教学材料」,只不过那一章用在工具返回上,这里用在输出校验上。判断标准一样:模型看完这条错误,能不能一次就改对?
重试还有两件事容易忘:
- 重试要计入 token 预算和延迟预算。20% 的重试率意味着你的 p95 延迟是你以为的两倍。
- 重试率本身要进监控。它突然从 2% 涨到 15%,通常不是用户变奇怪了,是你上周改了 prompt 或者供应商换了模型版本。
枚举、可选字段、嵌套深度的取舍
| 设计 | 好处 | ⚠️ 代价 |
|---|---|---|
| 枚举替代自由字符串 | 拼错直接被拦 | 枚举外的真实情况会被硬塞进最近的一项。必须留 "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 秒,但你把「已经漏出去」变成了「还在缓冲区里」。
⚠️ 审核模型本身也会错
审核器是另一个模型,它有两类错误,代价完全不同:
- 假阳性(FPR):正常内容被拦。用户体验崩塌,更糟的是用户会学会绕着说话,你的输入分布被自己污染了。
- 假阴性(FNR):有害内容放行。这才是你上护栏的原因。
怎么量这两类错误率(一套能真跑起来的做法):
| 步骤 | 做什么 |
|---|---|
| 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 · 怎么用这份教程 | 本章之外的那一整圈:后端骨架、数据库、认证多租户、队列、成本护栏、容器化部署、可观测。「模型已经好了但产品上不了线」的问题全在那个板块 |
| --- |
✅ 检查点
- 「让它输出 JSON」这句提示词最典型的三种翻车方式是什么?
- 结构化输出三档做法各能保证什么、不能保证什么?为什么说不是「越高越好」?
- Schema 强约束通过了,还可能出什么错?校验必须分哪两层?
- 校验失败时该怎么重试?为什么重试次数不该无上限、也不该指数退避?
- 为什么流式和结构化输出天生冲突?两种务实解法是什么?
- 「首 token 是一条分界线」——这条线的两侧分别能做什么、不能做什么?它的直接推论是什么?
- 三层护栏中哪一层最重要?为什么?它和第 15 章的哪条铁律是同一件事?
- 流式前审核和流式中审核各自的代价是什么?折中方案是什么?
- 怎么量审核模型的假阳性率和假阴性率?选阈值的依据是什么?
- 两起事故复盘各自的「没被发现的原因」是什么?
👀 答案
- 加开场白、套代码围栏、字段名和类型漂移(
Status、"无"当null)、超长被截断导致右括号永远等不到。单看概率都低,十万次调用 0.5% 就是 500 次异常。 - ① 提示词约束:大概率像 JSON,保不住前后缀和字段名;② JSON mode:
json.loads一定成功,但保不住字段和类型;③ Schema 强约束:字段齐全、类型对、枚举合法,但保不住值是不是真的。不是越高越好——schema 越严,模型能表达的越少,它没法说「我判断不了」,除非枚举里留了那一项。 - 值是编的:订单号被当成金额、
user_id不存在、引用了不存在的文档。校验分结构校验(schema 能管)和语义校验(值域、跨字段一致、外键存在、和上游事实对得上——只有你的代码能管)。 - 把错误信息回传给模型(「
$.refund_amount=998800超过上限」),它才能一次改对;上限 2 次然后走降级。不退避是因为这不是限流问题、是能力问题,第 3 次突然对的概率极低。另外重试要计入 token/延迟预算,重试率本身要进监控。 - 因为 JSON 没输出完就不是合法 JSON,你不能同时要「一开口有字」和「拿到完整结构」。解法:① 先流文本再补结构;② 只对最终字段流式——把长文本字段放在 schema 最后,只增量吐它。容错解析可以驱动 UI 预览,不能驱动决策。
- 首 token 之前可以随便静默重试;之后重试就是推翻用户已经看见的内容。推论:所有可能失败的准备工作(鉴权、取上下文、输入侧护栏)都必须前置到第一个 token 之前。
- 动作侧。因为能说错话和能做错事代价差几个量级——说错话可以道歉,转错钱/删错表/发错邮件没有撤销键。对应第 15 章:概率性的模型层防御必有漏网,确定性的环境层边界才是底线;输入输出侧是概率性的,动作侧的
if不是。 - 流式前审核零漏出但 TTFT = 全文时长,牺牲流式;流式中审核保留流式但只看片段、误判率高,且命中前已经吐出去的收不回来。折中:延迟发送——缓冲约 100 字符(约 0.5 s TTFT)换一个永远领先用户视线的审核窗口。
- 建 500 条真实流量的正常集和 200 条红队写的对抗集,FPR = 被拦/500,FNR = 被放行/200;再做阈值扫描画 FPR–FNR 曲线;线上每天抽 50 条拦截 + 50 条放行人工复核对齐。选阈值的依据不是「哪里最准」,是「哪类错更贵」。
- 事故一:监控盯的是「校验失败率」而 47 笔越界退款全部校验通过,评测集里一条越界样本都没有。事故二:审核 F1 0.94,但断言只覆盖「最终消息」,评测跑在非流式模式下,「用户中途看到了什么」这个维度不存在。
🛑 可以停在这里
⚡ 走神救援
三件事,用不用框架都得自己处理。
① 结构化输出:「请输出 JSON」不够。三档从提示词约束到 Schema 强约束——⭐ 越严的档只保证格式对,不保证内容对,而且越严越窄(记得给枚举留
other、少用可选字段,它们会被瞎编)。⭐ 校验必须两层:结构校验 schema 能管,语义校验(值域、外键、和上游事实对不对得上)只有你的代码能管。重试要带上「你上次错在哪」,两次之后降级。💀 事故一:schema 没写上界,一个订单号被当成金额,十几个小时里连续越界退款几十万——⭐ 全部校验通过,监控盯错了指标。
② 流式:首字延迟比总时长更影响体感。⚠️ 状态码在第一帧就定死 200,所以错误只能走流内的 error 事件,而半截内容收不回来、必须显式标记。⭐⭐ 首 token 是分界线:在它之前能静默重试,在它之后重试就是推翻用户已经看到的东西——推论是所有可能失败的准备工作都要前置。⚠️ 流式和结构化天生冲突(半截 JSON 不合法),解法是先流文本再补结构。⚠️ 取消要穿透整条栈,否则用户关了页面你还在烧 token。
③ 护栏分输入侧、输出侧、动作侧,⭐ 动作侧最重要,因为代价差几个量级——说错话能道歉,转错钱没有撤销键。
⚠️ 输出审核有个两难:流式前审零漏出但没了流式,流式中审保住流式但漏出已发生;折中是缓冲一小段延迟发送。⭐ 审核器本身也会错,而两类错的代价不同:误报让用户绕着说话,漏报放行有害内容——阈值要按「哪类错更贵」选,不是按「哪个最准」选。
💀 事故二最该记:审核只在末尾做,有害内容已经渲染了几百字、好几秒,而评测 F1 很高、全绿——⭐ 因为评测跑在非流式模式,用户活在流式模式。
下一节 👉 17-实战项目.md