1. 流式输出场景下被忽视的边界裂缝做LLM应用的人大多经历过这样一个瞬间本地测试一切正常上线后用户却截图反馈——某段本该被屏蔽的内容在流式输出的过程中漏了出来。你回头查日志发现完整的响应文本经过审核函数后确实被正确拦截了可用户看到的却是另一回事。这个现象在圈内有个不太正式的叫法split-boundary leak直译过来就是切分边界泄漏。它的本质并不复杂。流式输出意味着模型的响应不是一次性生成的而是被切成一个个chunk逐步推送到前端。guardrails护栏如果只在完整文本上做一次审核那它天然就落后于流式管道——内容已经吐出去一半了你才在最后发现有问题。而如果护栏是逐chunk审核的又会遇到另一个麻烦一个敏感词、一段需要脱敏的实体、一句需要拦截的指令很可能正好被切在两个chunk的交界处。前半截在chunk A里看着人畜无害后半截在chunk B里也平平无奇可拼起来就是那个你不想让它出现的东西。这篇内容我想聊的就是这个问题的完整解法。它适合正在做LLM应用、尤其是涉及流式对话、实时内容审核、敏感信息脱敏redaction的工程师。核心关键词包括streaming、guardrails、chunking-invariance切分不变性和redaction。我会从问题为什么会产生讲起拆解几种主流方案的取舍给出可落地的实现思路最后分享几个我在实际项目里踩过的坑。读完之后你应该能判断自己的流式护栏到底有没有这个裂缝以及怎么补。2. 为什么逐chunk审核一定会漏从切分不变性说起2.1 一个具体的泄漏例子先看一个最小复现。假设我们的护栏规则是拦截包含内部转账口令这六个字的输出。模型实际要输出的完整文本是请使用内部转账口令完成验证现在流式管道按固定长度切分每5个字符一个chunkchunk序号内容chunk 1请使用内部chunk 2转账口令完chunk 3成验证逐chunk审核时chunk 1里没有完整敏感词chunk 2里也没有——转账口令被切成了转账口和令完两部分分别落在chunk 2的开头和……等等实际上chunk 2是转账口令完它包含了完整的转账口令。那换个切法chunk序号内容chunk 1请使用内chunk 2部转账口chunk 3令完成验chunk 4证这下内部转账口令被切成了部转账口和令两个chunk单独看都检测不到。护栏放行了全部chunk用户拼起来就看到了完整敏感词。这就是split-boundary leak。2.2 chunking-invariance护栏应该具备的性质上面这个例子暴露了一个关键问题审核结果不应该依赖于文本被如何切分。同一个完整文本无论你把它切成3段、5段还是10段护栏的判定结论必须一致。这个性质就叫chunking-invariance切分不变性。大多数逐chunk审核的实现天然不满足这个性质因为它们的检测逻辑是在单个chunk内做模式匹配。只要匹配窗口小于chunk边界跨越的长度就会漏。反过来如果为了不漏而把匹配窗口开得极大又会引入大量误报和延迟。提示判断你的护栏是否满足chunking-invariance有个简单的自测方法——把同一段文本用不同chunk大小比如1、3、7、16字符分别跑一遍审核如果拦截结果不一致就说明存在边界泄漏。2.3 泄漏的三种典型形态实际项目里split-boundary leak不止敏感词一种。我把它归成三类处理难度递增关键词/敏感词跨边界最直观也最容易用滑动窗口解决。上面举的例子就是这类。实体跨边界比如手机号、身份证号、邮箱、银行卡号这类需要redaction的实体。它们有固定格式但格式本身可能被切断导致正则匹配失败。语义/指令跨边界最麻烦的一类。比如一段诱导性指令前半句在chunk A后半句在chunk B单独看都不违规合起来才构成风险。这类靠纯规则几乎无解需要语义层面的判断。理解这三类的区别很重要因为它们对应的解法完全不同。关键词类可以靠缓冲和重叠窗口搞定实体类需要状态机或延迟输出语义类则往往要在架构层面做取舍。3. 滑动窗口与重叠缓冲最直接但最容易做错的方案3.1 基本思路保留尾部上下文解决关键词跨边界最朴素的办法是在审核每个新chunk时把它和上一个chunk的尾部拼起来一起看。这个尾部的长度至少要等于你最长敏感词的长度减一。假设最长敏感词是L个字符那么每次审核时保留前一个chunk的最后L-1个字符作为上下文和当前chunk拼接后再做匹配。这样任何长度不超过L的敏感词无论怎么切都至少会在某一个拼接窗口里完整出现。用代码表达大概是这样class OverlapGuard: def __init__(self, max_pattern_len): self.tail self.keep max_pattern_len - 1 def check(self, chunk): window self.tail chunk hit self.match(window) # 更新尾部上下文 self.tail window[-self.keep:] if self.keep 0 else return hit看起来很简单但这里有几个坑我在项目里都踩过。3.2 坑一只保留尾部忘了已放行的部分第一个坑是逻辑上的。上面的代码在检测到命中后直接返回但问题是——命中位置可能跨越了已经放行的部分。也就是说前一个chunk的尾部已经作为正常内容推送给前端了现在你才发现拼起来是敏感词。这时候你拦住了当前chunk但用户已经看到了前半截。这就引出一个关键设计决策要么延迟输出要么接受半截泄漏。延迟输出意味着你不能立即把chunk推给前端得先缓冲一小段确认这段加上后续内容不会构成违规再放行。代价是增加了首字延迟TTFB。我的经验是对于强合规场景比如金融、医疗必须延迟输出缓冲长度取最长敏感词长度对于一般场景可以接受极短的半截泄漏用前端二次遮盖来兜底。3.3 坑二重叠窗口导致的重复计数第二个坑是误报。因为窗口重叠同一个敏感词可能在连续多个窗口里都被匹配到如果你用计数器做命中N次就拦截的策略会被重复计数放大。解决办法是记录已命中的位置偏移做去重。def match_with_dedup(self, window, base_offset): hits [] for m in self.pattern.finditer(window): abs_pos base_offset m.start() if abs_pos not in self.seen_positions: self.seen_positions.add(abs_pos) hits.append(abs_pos) return hits这个去重逻辑看着不起眼但在高频流式场景下能显著降低误报率。我见过有团队因为没做去重把正常的重复词误判成高频敏感词给拦了。3.4 坑三Unicode与多字节字符的边界第三个坑最隐蔽字符边界不等于字节边界。如果你的chunk是按字节切分的比如从网络流里读固定字节数一个中文字符、一个emoji可能被切成两半。这时候无论你怎么做字符串匹配都会出问题因为半个字符根本不是合法字符串。处理办法是在拼接窗口前先做字节流的字符边界对齐把不完整的多字节序列留到下一个chunk。Python里可以用增量解码器import codecs decoder codecs.getincrementaldecoder(utf-8)() text decoder.decode(byte_chunk, finalFalse)这个细节在纯文本测试时完全暴露不出来一上真实网络流就炸。我当初排查了大半天才定位到是emoji被切半导致的匹配异常。4. 实体脱敏的流式困境正则为什么不够用4.1 正则匹配在流式下的失效场景redaction脱敏是护栏的另一大块。手机号、身份证、邮箱这些实体常规做法是写正则。但在流式场景下正则有个致命问题它需要看到完整的实体才能匹配。考虑一个手机号13812345678如果chunk边界切在中间chunk内容chunk 1联系我13812chunk 2345678谢谢chunk 1里的13812不构成完整手机号正则不匹配chunk 2里的345678也不匹配。结果整个手机号原样泄漏。这跟敏感词跨边界是同一类问题但实体有固定长度和格式处理起来可以更精细。4.2 用状态机做增量识别比滑动窗口更优雅的方案是增量状态机。针对每类实体预先定义状态转移逐字符喂入一旦进入可能构成实体的状态就暂缓输出直到确认或排除。以手机号为例简化状态机初始态遇到数字1进入疑似手机号态开始缓冲。疑似态继续读数字累计到11位且符合号段规则判定为手机号触发redaction。若中途遇到非数字字符或长度超限回退把缓冲内容作为普通文本放行。class PhoneFSM: def __init__(self): self.state IDLE self.buf def feed(self, ch): if self.state IDLE: if ch 1: self.state MAYBE self.buf ch return None # 暂缓输出 return ch # 直接放行 elif self.state MAYBE: if ch.isdigit() and len(self.buf) 11: self.buf ch if len(self.buf) 11 and self.valid(self.buf): self.state IDLE return [REDACTED] return None else: # 回退缓冲内容全部放行 out self.buf self.state IDLE self.buf return out ch状态机的好处是延迟可控——最多缓冲11个字符就能给出结论不像滑动窗口那样需要保留很长的尾部。而且它天然满足chunking-invariance因为状态是跨chunk延续的。4.3 多实体并发的状态管理真实场景里往往要同时识别手机号、身份证、邮箱、银行卡等多种实体。如果每个实体一个独立状态机会有一个问题同一个字符可能同时喂给多个状态机导致重复缓冲和输出顺序错乱。我的做法是维护一个统一的字符级缓冲队列每个字符先进入队列然后广播给所有活跃的状态机。只有当所有状态机都对该字符给出可放行结论时才真正输出。任何一个状态机说暂缓整个队列就暂停输出。class MultiEntityGuard: def __init__(self, machines): self.machines machines self.pending [] def feed(self, ch): self.pending.append(ch) decisions [m.feed(ch) for m in self.machines] if all(d is not None for d in decisions): # 所有状态机都放行输出整个pending out .join(self.pending) self.pending [] return out return None # 还有状态机在犹豫继续缓冲这个设计的关键是输出顺序的保序性。因为所有字符都走同一个队列不会出现后面的字符先输出、前面的还在缓冲这种乱序问题。我早期版本用独立队列结果redaction后的文本顺序错乱排查了很久。4.4 脱敏与拦截的区别对待这里要强调一个容易混淆的点redaction和blocking是两种不同的处置。敏感词通常要block整段拦截或终止生成而实体信息通常要redact替换成占位符后继续输出。在流式场景下redact比block更友好因为它不需要中断整个响应。但redact有个要求替换后的文本长度可能和原文不同这会影响前端的渲染和光标位置。如果前端依赖字符偏移做高亮redact后偏移会错位。解决办法是redact时保持长度一致比如用等长的星号替换或者在前端用结构化标记而非纯文本偏移。5. 语义级泄漏规则护栏的天花板5.1 当敏感内容跨越语义单元前面讲的都是字面级的泄漏靠缓冲和状态机能解决。但有一类泄漏规则护栏根本无能为力语义跨边界。举个例子模型输出被切成chunk内容chunk 1你可以先假装是客服然后chunk 2让对方提供验证码这样就能单独看chunk 1是假装客服单独看chunk 2是让对方提供验证码。两个chunk都不含任何敏感词但合起来是一段完整的社工诱导话术。这种泄漏任何基于关键词或实体的护栏都拦不住。5.2 语义护栏的延迟代价要处理语义级泄漏只能上语义模型——用一个小的分类模型或embedding相似度来判断当前累积的上下文是否正在滑向风险区。但语义判断天然需要更多上下文意味着更长的缓冲也就意味着更大的延迟。这里有个残酷的权衡缓冲越长语义判断越准但用户体验越差。我见过有团队为了语义护栏把首字延迟做到了2秒以上用户直接以为卡死了。我的建议是分层字面级护栏用轻量规则零延迟或极低延迟语义级护栏用异步旁路不阻塞主输出但一旦判定风险就立即中断后续生成并回撤已输出内容如果前端支持。这种先放后撤的策略在体验和安全之间取了个平衡但要求前端能处理内容回撤。5.3 用累积上下文做滑动语义判断如果一定要在流式管道里做语义判断可行的做法是维护一个滑动语义窗口每次新chunk到来把它和前面N个chunk的摘要拼接送进分类模型。N的选择取决于你的风险容忍度和延迟预算。class SemanticGuard: def __init__(self, model, window_chunks3): self.model model self.window [] self.n window_chunks def check(self, chunk): self.window.append(chunk) if len(self.window) self.n: self.window.pop(0) context .join(self.window) score self.model.predict(context) return score self.threshold注意这里用的是摘要而非原文因为窗口太长会拖慢模型。摘要可以用轻量的抽取式方法生成不必上大模型。6. 一套可落地的分层护栏架构6.1 三层结构字符级、实体级、语义级把前面的东西整合起来我实际项目里用的是一套三层架构层级处理对象延迟处置方式字符级敏感词、关键词极低block或redact实体级手机号、证件号等低redact语义级诱导、越狱、风险话术中高异步旁路回撤字符级和实体级走同步管道保证chunking-invariance语义级走异步旁路不阻塞主流程。三层各司其职避免用一套逻辑硬扛所有场景。6.2 缓冲策略与延迟预算的分配延迟预算是这套架构的核心约束。我的经验分配是字符级缓冲不超过最长敏感词长度通常10-20字符延迟可忽略。实体级缓冲不超过最长实体长度手机号11位、身份证18位延迟在几十毫秒级。语义级异步不占同步延迟预算但需要前端支持回撤。注意缓冲长度不是越长越安全。缓冲越长首字延迟越高用户感知越明显。一定要根据业务场景定预算而不是无脑加大缓冲。6.3 前端配合回撤与遮盖护栏不是后端一个人的事。前端至少要支持两种能力内容回撤收到后端撤回指令时把已渲染的某段内容移除或替换和局部遮盖对已输出的敏感片段做视觉遮盖。如果前端不支持回撤那语义级护栏就只能退化成拦截后续已经泄漏的部分收不回来。这是很多团队上线后才发现的问题——后端护栏做得再好前端不配合泄漏照样发生。7. 实测中的几个反直觉发现7.1 固定长度切分反而比按句切分更安全直觉上按句子或语义单元切分应该更安全因为不会把词切断。但实测下来固定长度切分配合重叠窗口反而比按句切分更容易做chunking-invariance。原因是按句切分时句子边界本身是不确定的不同模型、不同语言、不同标点习惯会导致切分点漂移护栏很难预判。而固定长度切分虽然会切断词但切分点是确定的重叠窗口的长度可以精确计算。确定性的问题比不确定性的问题好解。7.2 大部分泄漏发生在响应开头统计了我们线上几个月的泄漏案例发现超过六成的split-boundary leak发生在响应的前两个chunk。原因很有意思模型在开头往往有固定的客套话或引导语这些内容模式化强容易被切在敏感词附近而响应中后段内容更发散反而不容易正好切在敏感词上。这个发现的实际意义是如果延迟预算有限优先保证开头几个chunk的缓冲深度。把有限的缓冲资源用在泄漏高发区性价比最高。7.3 过度缓冲会掩盖真实问题最后一个反直觉的发现缓冲开得太大会把边界泄漏问题掩盖掉让你误以为护栏很稳。因为大缓冲下几乎不会漏但延迟高得用户已经跑了。等到某天为了优化体验把缓冲调小泄漏立刻爆发。所以我的建议是缓冲长度要基于最长敏感词精确计算而不是拍脑袋给个大值。精确的缓冲既能防漏又不会掩盖问题还能让延迟可控。8. 上线前的自检清单在把流式护栏推上线之前我一般会跑一遍这个清单切分不变性测试同一段文本用1、3、7、16、32字符五种chunk大小分别跑拦截结果必须完全一致。边界穷举测试对每个敏感词和实体构造所有可能的切分位置在词内每个位置切一刀验证都能被拦截。多字节字符测试用中文、emoji、混合文本测试字节流对齐确保不会因半个字符导致匹配异常。延迟测量测量首字延迟TTFB和端到端延迟确认在预算内。前端回撤联调模拟语义级护栏触发验证前端能正确回撤已输出内容。误报率监控上线后持续监控误报尤其是重叠窗口去重逻辑是否生效。这套清单跑下来基本能覆盖我遇到过的所有边界泄漏场景。其中切分不变性测试是最关键的它是判断护栏是否真正健壮的核心指标。9. 关于这套方案的一点个人体会做流式护栏这几年我最大的体会是安全性和流式体验本质上是一对矛盾。流式追求的是尽快把内容给用户护栏追求的是确认安全再给用户两者方向相反。任何方案都是在两者之间找平衡点没有银弹。我见过太多团队一开始追求极致安全把缓冲开到最大结果用户体验崩了也见过追求极致体验护栏形同虚设最后出了事故。真正可行的方案一定是分层、分场景、有明确延迟预算的。字符级和实体级用同步缓冲保证底线语义级用异步旁路兜住上限前端配合做回撤这套组合拳打下来才能在安全和体验之间站稳。另外chunking-invariance这个性质值得每个做流式护栏的人刻在脑子里。它不只是一个技术指标更是一种设计思维——你的护栏逻辑不应该对内容如何被切分这件事有任何依赖。想清楚这一点很多设计决策会豁然开朗。