LLM 流式输出护栏:发出去的 token 收不回来

LLM 流式输出护栏:已发出的 token 无法撤回。NeMo、Guardrails AI 与 Bedrock 如何权衡速度与安全,句子闸门如何逐句检查论断。

LLM 流式输出的 token 无法撤回。文本一旦到达读者眼前,即使护栏对它有异议,也只能拦住后面的内容。因此,给流式输出加护栏,真正要做的决定只有一个:要么先检查再放行,让读者等待;要么先放行,并接受被拦截的回答已经被人读过。可行的做法有三种:放行后再检查、按固定大小的块把关,或按完整的句子把关。我为本站构建的助手采用按句把关。代价是多等一个句子的时间;局限在于,它检查的是论断有没有依据,而不是论断是否属实。

本文讨论有来源依据的回答在放行前的最后一道检查。在它之前的各层,从拒答超出范围的问题到检索分数下限,见 RAG 幻觉护栏:五层设计。

为什么流式输出的 token 无法撤回

事后校验器只有在读者已经读过之后,才看得到完整的回答。如果第三句编造了一个日期,事后把它删掉,改变的只是记录,而不是读者已经读到的内容。Christopher M. Frost 最近在 arXiv 上发布的一篇预印本精确地划出了这条边界:“一项控制措施如果在放行了使违禁模式完整出现的那个块之后才检测到该模式,就无法再将其撤回”。

厂商文档也写明了同样的限制。Azure OpenAI 的异步过滤器需要主动启用,它会立即返回文本,并且只保证在距违规内容约 1,000 个字符的范围内发出违规信号。它的故障排除建议是“在你的应用中实现内容删除,以移除已经显示的内容”。OWASP 的 LLM09:2025 条目建议借助工具和流程“自动校验关键输出”。真正悬而未决的问题是:校验相对于放行,究竟在什么时候运行。

流式输出的三种防护方式:事后检查、按块把关、按句把关

方式

读者何时看到文本

被拦截的回答

示例

事后检查(先放行)

检查之前(NeMo:每个块缓冲完即发出;Azure 与 Bedrock 异步模式:边生成边显示)

停止前已被读到一部分

开启 stream_first 的 NeMo(其默认设置);Azure 与 Bedrock 的异步模式

按块把关

每个块通过检查之后

被拦截的块从不显示

Azure 与 Bedrock 的默认设置;关闭 stream_first 的 NeMo

按句把关

每个句子通过检查之后

被拦截的句子从不显示

本站助手

NeMo Guardrails 中 stream_first 的取舍

NeMo Guardrails 的输出护栏流式处理(output-rail streaming)默认关闭。启用之后,它会按块运行输出护栏(即 NeMo 对生成文本所做的检查),默认每块 200 个 token,并把 50 个 token 的上下文带入下一个块。stream_first 标志默认为 True:“客户端会在输出护栏处理每个 token 块之前收到该块。这能缩短首 token 时间,但如果某个护栏拦截了内容,用户已经收到了这些 token。”随后,流会以一个 JSON 错误结束。

设为 False 时,“用户永远看不到被拦截的内容,但首 token 时间会增加每个块的护栏执行时间”。取舍就在这里:一边是首 token 时间(TTFT),一边是被拦截的文本会不会到达读者。启用输出护栏流式处理之后,NeMo 默认先放行;下文的两个云端过滤器则默认先检查。如果某个护栏的用途就是拦住绝不能被看到的内容,就应当改掉这个默认值。

Guardrails AI 句子级校验器的取舍

Guardrails AI 选择以句子为单位:“默认情况下,校验器会等到从 LLM 累积了一个句子的内容之后,才运行校验。”只校验单个句子而不是整个回答,“有助于为需要昂贵推理的校验器节省成本”。

并非所有 on_fail 类型都支持流式处理;多个校验器给出的修复要等“所有校验器都累积到足够的块”之后才会合并。文档没有明确说明原始 token 是否会在校验之前到达用户,所以请在你自己的集成中核实这一点。

默认先缓冲:Azure OpenAI 与 Amazon Bedrock

Azure OpenAI 默认会缓冲补全文本,对缓冲内容进行过滤,然后以“内容块”而不是单个 token 的形式返回。Amazon Bedrock Guardrails 在默认的同步模式下会先扫描块再发送,这“会引入一些延迟”,但“准确性更高”。在 Bedrock 的异步模式下,块“在护栏扫描完成之前可能包含不当内容”,而且无法对敏感信息做掩码处理。

句子闸门如何校验流式输出

本站的助手只依据本站自己的内容作答;与我无关、又没有任何段落支撑的问题,根本不会送到模型那里。它以第三人称谈论我。在一个署着我名字的网站上,最糟糕的失败是编造出来的资历,而资历本身就是一条论断。句子是能承载一条论断的最小单位,所以闸门会先把文本缓冲到句子边界,做完检查,然后才放行。实验室里的“有检索 vs 没检索”演示调用的也是同一个流式接口。

句子闸门的时间线:token 在缓冲区中累积,直到一个句子结束;第一句通过四项检查,去掉标记后显示;第二句含有来源中没有的值,因此被扣下,流随之停止。

token 仍在陆续到达时,句子在哪里结束

  • 终止符: . ! ? …,以及阿拉伯字母的问号 ؟ 和句号 ۔;对于中文和印地语,还有 。 ! ?,以及天城文的句末竖线 ।(danda)和双竖线 ॥。像 ?! 这样连在一起的一串终止符算作一个边界。阿拉伯字母的分号 ؛ 用于连接分句,因此特意不算在内。

  • 后面必须跟空白字符。 单凭这一条规则,3.14 和 example.com 就不会被拆开。中文的句号、感叹号和问号例外,它们后面不跟空格。它们出现在引语里时,句子在引语闭合处才结束;如果后面紧跟逗号,句子就没有结束。

  • 并非每个句点都结束句子。 后面跟小写字母的句点不结束句子(et al. and),一份简短清单里的缩写,例如 e.g. 或德语的 bzw.,其句点也不结束句子,德语日期里日数后面的句点(am 17. September)同样如此。

  • 由下一个字符决定。 只有既不是空白、也不是引用标记的字符到达,边界才算确定,因为句号后面的标记属于它们前面的那个句子:全部都算,[S1][S2] 也好,[S1] [S2] 也好,而不只是第一个。

  • 边界之后的列表序号不算句子。 否则,出现在回答开头或新句子开头的 1. 会被当成没有引用的论断,让一个有来源依据的回答停在第一个列表项上。

  • 设置上限。 超过一千个字符仍然没有边界时,缓冲区会在最后一个空格处截断,照样接受检查。

  • 空行会被保留。 一个句子如果前面有空行,放行时前面也会留出空行,所以分成两段的回答仍然是两段。

等待下一个字符,通常只带来一个字符的延迟;句号后面跟着标记时,会稍多一些。最后一个句子在流结束时接受检查。比较时会对波斯语数字和分隔符(۳٫۱۴ 与 3.14 视为一致)以及阿拉伯字母的字形变体做归一化,去掉阿拉伯语的短元音符号,并把零宽不连字(ZWNJ)当作空格。这些正是波斯语 RAG 的混合检索在检索一侧处理的同一批变体。

四项确定性检查:引用、值与人称

  1. 引用范围。 如果只提供了六个来源,[S7] 这样的标记指向的就是一个编造的来源。

  2. 必须有引用。 八个词及以上的句子,或者含有任何数字、URL 或电子邮件地址的句子,都需要标记。简短的过渡句可以通过。

  3. 逐字支撑。 每个数字、URL 和电子邮件地址都必须原样出现在该句所引用的某个来源中。匹配会遵守数字边界,所以来源中的某个年份,不能为末尾几位数字与它相同的百分比作证;只在末尾的零上有差别的小数算同一个值:20.00 就是 20。不用来分隔千位的逗号是小数点:2,5 就是 2.5,而不是 25。

  4. 人称。 一旦出现第一人称词语,流就会停止。英语词表简短且固定:第一人称代词及其缩写形式。波斯语词表经过人工整理:代词,外加少数高风险的动词形式,例如波斯语中表示“我是”和“我有”的形式。波斯语用动词词尾标示人称,而普通名词也可能带有同样的词尾,所以一条宽泛到能抓住所有这类词尾的规则,也会把名词一并抓进来。本站其他语言的词表收录人称代词和物主代词,西班牙语、葡萄牙语、俄语、阿拉伯语和印地语的词表还加入了少数动词,因为这些语言通常只用动词本身来表达“我”;中文则检查句子中任何位置是否出现“我”字,只有以“自我”(“自身”)开头的某些复合词除外,例如“自我修正”。适用哪些词表,取决于回答是用哪种语言请求的。英语、波斯语、阿拉伯语、俄语、印地语和中文的词表适用于每个回答:英语是模型指令所用的语言,因此偏离所请求语言的回答最可能滑向英语;其余五种语言所用的文字,任何拉丁字母的名字或缩写都拼不出其中的词。唯一的例外是用阿拉伯语请求的回答:其中不查波斯语的“我”和“我们”,因为同样的词在阿拉伯语中意为“从”和“什么”。德语、法语、西班牙语和葡萄牙语的词表只用于以该语言请求的回答:它们的短词同时也是人名,或是其他语言里的普通词,例如“Ma et al.”中的“Ma”。在读取任何词表之前,全大写的缩写、“et al.”之前的姓氏,以及“附件 I”中的罗马数字都会先被排除。

句子没有通过检查时会发生什么

  • 失败之前已经通过的每个句子都会放行,包括同一批次中排在前面的句子。

  • 闸门停止,并且保持停止:一个无依据的句子,会让其余内容都失去可信度。

  • 一个 guard 事件会携带违规原因,以及一段固定的本地化文案,说明其余内容已被扣下;最后的 done 事件会记录这次停止。被拦截的句子从不发送,服务器只在日志中记录它的长度。

  • 服务商的流会被关闭,这个设计正是借此取消上游请求。截断之前已经生成的内容仍在账单上,所以同样计入每月支出上限。两家服务商的协议都在一个最终事件里上报输出用量,而被切断的流永远到不了那个事件;因此服务会保留收到的文本,服务商没有上报的部分按每个 UTF-8 字节一个 token 补上,这个计数宁可偏高。推理模型用于思考的 token 从不以文本形式到达,所以这个估算不包括它们。五层设计那篇文章讨论了支出上限。

聊天面板会把这一轮标记为已扣下,并把已放行的文本保留在对话历史中,因为在停止点之前,这些文本都有依据。

截断是另一种失败,而这个助手的早期版本曾把两者混为一谈。模型的输出空间用尽时,闸门把被截断的片段当成完整的句子来评判,并以缺少引用为由将其扣下。读者得到的说法是这个回答无法得到来源支持,而它其实只是被打断了。如果一段文本都没有返回,这一轮就会直接消失,不留任何提示。现在,未完成的片段会不经评判直接丢弃,并显示一条回答被截断的提示。被打断的句子,不等于无依据的句子。如果两者同时发生,以闸门停止为准。

引用标记只在内部使用

提示词要求每个陈述事实的句子都带上 [Sn] 标记,闸门则对每个实质性句子执行引用规则。句子通过后,其中的标记会被移除,空格也会随之修复,包括波斯语标点前的空格,所以读者看到的是正文,而不是标记。反过来,如果改为从提示词中去掉标记,每个实质性句子都会在引用规则上失败。

模型无话可说时

当来源无法回答问题时,提示词要求模型输出一个固定的 token,而不是一个句子。由模型自己写出的拒答,和其他任何论断一样也是一条论断,而且没有引用:闸门会把它拦下,并显示一条把责任推给来源的提示,可这个问题本来就不该由来源回答。

闸门从不放行这个 token。只要目前收到的文本还可能变成这个 token,就先扣住;一旦出现排除这种可能的字符,文本就按常规路径处理,所以普通回答不会多等。如果回答以这个 token 开头,服务会把它替换成本站固定的拒答文案,与其他拒答显示的文字相同。如果这个 token 出现在已放行的句子之后,那些句子保留,回答就此结束。

检查“有依据”不等于检查“属实”

在一项由斯坦福大学牵头的研究中,Magesh 及其同事开展了他们所称的首个针对 AI 法律研究工具的预注册评估。LexisNexis 和 Thomson Reuters 的 AI 研究工具,幻觉率各自介于 17% 和 33% 之间。一个回答只要“不正确或依据错误(misgrounded)”,就算作幻觉;所谓依据错误,是指“关键事实论断给出了引用,却曲解了来源,或引用了并不适用的来源”。依据错误的回答本身是带着引用的。这对法律 AI 产品意味着什么,另有一篇文章专门讨论。

基于字符串的闸门看不出依据错误,而这道闸门还有一些已知的盲区:

  • 引用了两个来源的句子,可以从其中任何一个取值。 检查以句子为单位,而不是以分句为单位。

  • 小数点算作边界。 来源中的 14.52 可以为 52% 或 14 作证,千位分隔符也一样:1,200 能为 200 作证。

  • 词语不做检查。 一个不含数字、却换掉了角色或机构的句子,只要带着任意一个范围内的标记,就能通过。

  • 没有引用的短句可以通过。 不足八个词、且不含数字、URL 或地址的句子,不需要标记。中文词与词之间没有空格,所以中文改为按字计数,每三个字算作两个词:只由汉字组成的句子,不足十二个字时不需要标记。

  • 固定词表有漏洞。 在英语中,we'd 和 let's 可以通过;全部用大写字母书写的代词(如 WE 或 US)也会通过,因为它会被当作缩写。波斯语的第一人称覆盖主要依靠提示词:不在人工整理词表中的第一人称动词会直接通过。其他语言的词表同样是人工整理的;在以另一种语言请求的回答中,德语、法语、西班牙语或葡萄牙语的第一人称则交由提示词来约束。

  • 收尾标点可能让句子合并。 终止符后面是收尾引号时,比如 ?» 或 .",只有后面出现大写字母或换行,句子才算结束;是括号时,比如 .),只有换行或引用标记才算结束。因此两个句子可能被当成一个来评判:.) 后面跟大写字母时;阿拉伯语、波斯语和印地语里的 ؟» 之后,因为这些语言没有大写字母;以及德语引号 „…?“ 和 »…?« 之后,切分器根本不把它们当作收尾引号。中文的句末标点仍按上文它们自己的规则处理。

误判也会朝另一个方向发生。不在清单上的缩写后面跟一个大写字母,会让一个“句子”提前结束,比如人名前的 Mr.,引号里的问句后面跟一个人名时也是这样,在德语里跟任何名词时都是这样;如果这个片段有八个词或以上且没有标记,一个有来源依据的回答就会被扣下。由一行以冒号结尾的文字引出的列表,在第一个列表序号之前没有边界。切分器会紧跟在 1. 之后切开,而那个片段无法通过引用规则。在这两种情况下,闸门都是“失败即关闭”(fail closed):宁可扣下有依据的回答,也不放行未经检查的回答。

语料库也在变化。已发布的文章会被索引进助手的语料库,所以文章中的数字,包括本文的 17% 和 33%,都会变成闸门愿意为之作证的值。“有依据”的意思是“在该句引用的内容里某处出现过”,而不是“说的就是这件事”。

闸门是放行前的最后一道检查,而不是唯一一道。Joren 及其同事发现,在上下文不充分时,较大的模型“往往输出错误答案,而不是拒绝作答”;Google Research 也指出,RAG 虽然通常能提升整体表现,却“反常地削弱了模型在适当时候拒绝作答的能力”。所以,拒答和分数下限要放在前面,正如五层设计所述。Frost 的预印本在说明自身结论的局限时所用的话,同样适用于这道闸门:这是“确定性兜底机制的策略符合性,而不是语义安全或策略完备性的证据”。

用 SSE 实现 LLM 流式输出:fetch、代理与取消

句子闸门要发挥作用,每个放行的句子都必须立刻到达读者。这取决于四个传输细节。

为什么用 fetch 和 ReadableStream,而不是 EventSource

WHATWG 规范规定,EventSource 构造函数只接受一个 URL 和一个选项 withCredentials:不能指定请求方法,也不能带请求体。一个问题连同它的语言区域和对话历史,就是一个请求体,所以这个接口是一个返回 Server-Sent Events 的 POST 接口。浏览器用 fetch、一个 ReadableStream 读取器和一个 AbortController 来读取它;用 MDN 的话说,AbortController 的 abort()“能够中止 fetch 请求、对任何响应体的读取以及流”。

手写的解析器需要遵守这些规则:

  • 跨多次读取做缓冲。 一个块不等于一条消息。

  • 遇到空行才分发事件。 空行到来之前,事件都不完整。

  • 忽略注释行,也就是以 : 开头的行。

  • 合并多行数据: 同一事件中的所有 data: 行用换行符(LF)连接起来。

  • 接受 CRLF、LF 和 CR 作为行结束符。

  • 去掉冒号后面的一个空格,而且只去掉一个。

解码时使用 TextDecoder 并传入 {stream: true},MDN 说明这个设置用于“分块处理数据时”。否则,一个被拆到两个块里的波斯语字符,会被解码成替换字符。

代理缓冲:nginx 与 Caddy

nginx 默认会缓冲被代理的响应,而 X-Accel-Buffering 响应头可以关闭这一行为。助手的流会发送 X-Accel-Buffering: no 和 Cache-Control: no-cache, no-store, no-transform;之所以加上 no-transform,是因为会重新压缩响应体的代理可能把多个块合并在一起。Caddy 会立即刷新 text/event-stream 响应,本站的 Caddy 配置还在聊天路由上设置了 flush_interval -1。Service Worker 没有为这个流注册任何路由,因为缓存策略会一直扣住响应,直到它结束。

在已打开的流中保活与传递错误

SSE 规范建议“每隔 15 秒左右”发送一行注释,以防旧式代理在短暂超时后断开连接;服务器就按这个间隔发送 ping。第一个字节发出时,状态码已经是 200,所以失败要作为流中的 error 事件传递。

取消请求,也要停止计费

浏览器端一停止,fetch 就会被中止。服务器在每个 token 到达时检查连接是否已断开;一旦发现断开,就跳出循环并关闭服务商的流,在这个设计中,这一步会取消上游请求。有两件事值得实际测试,而不是想当然。第一,浏览器与你的 API 之间的每一层代理和 CDN,是否都会把客户端断开连接的信号传递过去。请测试这条路径,不要轻信配置。第二,你的服务商在什么时候上报用量:如果输出用量只随最后一个块到达,那么提前关闭的流永远收不到它,按已上报用量计算的支出上限,就会少算实际生成的内容。

如何测试流式输出护栏

测试从三个层面覆盖这道闸门:

  • 基于固定来源的闸门单元测试。 编造的值、超出范围的标记、没有引用的论断和第一人称词语(本站的各种语言)会让流停止;有依据的值和简短的过渡句可以通过;前面合格的句子不会因为后面出现不合格的句子而丢失。

  • 切分器测试。 小数和域名不是边界,句末的标记跟随所在的句子,؟ 会结束一个句子,中文文本无需空格也能切分成句子。最值得照搬的一条是:按每段三个字符输入的文本,切分结果必须与整段文本完全一致。

  • 使用脚本化服务商的流测试,把一个回答切成小段重放:编造的值永远不会发送出去,闸门停止或连接断开都会关闭上游的流,截断会被报告为截断。

下面是两个核心测试的示意代码,为本文专门编写,并非取自本站:

# Sketch only: SentenceSplitter and SentenceGate stand for your own classes.
import pytest

ANSWER = "The reading room opens early [S1]. Is the café open? Yes, on weekdays [S2]."

def pieces(text, size):
    return [text[i:i + size] for i in range(0, len(text), size)]

def split_streamed(text, size):
    splitter = SentenceSplitter()
    sentences = [s for piece in pieces(text, size) for s in splitter.feed(piece)]
    tail = splitter.flush()
    return sentences + ([tail] if tail else [])

@pytest.mark.parametrize("size", [1, 2, 3, 7])
def test_piece_size_does_not_change_sentences(size):
    assert split_streamed(ANSWER, size) == split_streamed(ANSWER, len(ANSWER))

def test_unsupported_value_never_reaches_the_reader():
    gate = SentenceGate(sources=["The library opens at nine."])
    released = ""
    for piece in pieces("It opens at nine [S1]. Late fees ended in 2019 [S1]. More", 7):
        outcome = gate.feed(piece)
        released += outcome.text
        if outcome.stopped:
            break
    assert "2019" not in released and gate.stopped

这些都不构成对真实运行模型的红队测试。我衡量过的是闸门自己的错误:在生产环境记录下的回答,离线重放,找出它在哪里拦下了有来源依据的回答。这统计的是一个模型、一组问题上的误拦,而不是真实模型编造内容的频率,所以我不把它当作一个比率公布。

检查清单:为流式回答上线护栏

  • 先确定哪些内容绝不能被看到。 这决定了是先检查还是先放行,不管你的框架默认怎么做。

  • 选定检查单位,并说清读者要等的是什么: 一个固定大小的块,或者一个句子。

  • 字符串足以判断的地方,就用确定性检查,并先对数字和字母字形做归一化。基于模型的护栏,会给每个单位都加上它自己的执行时间。

  • 只有下一个字符到达后,才认定边界确定,并给缓冲区设置上限。

  • 出现违规时,放行已通过的内容,停止,显示固定文案,绝不回显被拦截的文本,并关闭上游的流。

  • 把截断与违规区分开,并统计实际生成的内容,而不是显示出来的内容。

  • 传输: 用 fetch 发送 POST,配合流读取器和 AbortController;使用流式解码器;用注释行做 ping;把错误作为事件发送。

  • 代理: X-Accel-Buffering: no、no-transform、event-stream 立即刷新、经过测试的断连路径,以及流的路径上不注册 Service Worker。

  • 测试: 把回答切成小段重放,并断言被拦截的值永远不会发送出去。

如果你正在设计一个依据来源作答的助手,希望有人再帮你审视一遍它的放行路径,这样的交流可以从项目诊断页面开始。

常见问题

能在不放弃流式输出的前提下校验 LLM 输出吗?

可以。把文本缓冲到一个能够检查的单位,每个单位通过后就放行。读者看到文本逐句或逐块出现,而不是逐个 token 出现。

缓冲到整句会拖慢首 token 时间吗?

它会让可见输出推迟一个句子:第一个句子完整并通过检查后,文本才会出现。与生成相比,字符串检查的开销很小,但我没有在本站测量过两者的差异,所以不给出数字。

NeMo Guardrails 的 stream_first 应该关闭吗?

如果被拦截的块绝不能被看到,就关闭它。NeMo 在文档中写明了代价:首 token 时间会增加每个块的护栏执行时间。只有当你的产品能接受读者看到随后被护栏拦截的 token 时,才保留默认设置。

LLM 流式输出为什么不用 EventSource?

EventSource 接受的是一个 URL 和一个凭据标志,因此无法发送请求体。带对话历史的聊天请求是 POST 请求,所以应当使用 fetch 加流读取器,这样还能通过 AbortController 实现取消。

引用检查能阻止幻觉吗?

不能。它能拦住编造的来源编号、没有引用的实质性句子,以及句子所引用的来源中没有的值。它无法判断被引用的来源是否真的支持该论断,而这正是依据错误这类失败。应当把它当作拒答和检索分数下限之后的兜底机制。

参考来源