停止序列的“自毁”陷阱:当用户输入与分隔符发生冲突
一位用户将一段 Markdown 粘贴到你的支持代理中。他们粘贴内容中的第一个标题是 ### Steps I tried。你的提示词模板(prompt template)使用 ### 作为停止序列(stop sequence)。模型尽职地读取了用户的输入,开始回答,并生成了 ### 作为其结构化响应的一部分——结果 API 返回了两句自信的回复,随后便是沉默。工单以“模型质量退化”的名义进入你的队列。其实不然。修复方法只是网关中的一行代码。
停止序列是生产级 LLM 技术栈中极其关键却又常被忽视的调节开关。它们通常是在最初编写提示词的那一周选定的,那时输入还是整洁的工程示例,还没有人粘贴过 JIRA 工单的堆栈信息。十二个月后,用户内容的分布已经远远超出了提示词作者的想象,曾经整洁的分隔符现在变成了潜伏在每三百个用户粘贴中就有一个的隐患。没有任何告警。评估套件(eval suite)依然能够通过。受影响部分的 CSAT 指标下降了 0.5 分并维持在那里。
这不是模型的问题。这是一个伪装成模型问题的输入契约 (input-contract)问题,它的形态类似于典型的分布式系统 Bug:为一方的内容分布选择的分隔符被强制应用于另一方的内容分布,且在边界处没有任何监控。
为什么哨兵符在第一天是合理的
停止序列的存在有其充分的理由。它们让模型在逻辑边界处结束,而不是在 Token 计数的悬崖边戛然而止。它们通过在有用输出完成后立即修剪生成过程来降低每次调用的成本。OpenAI API 最多允许设置四个;Anthropic 的 Messages API 则接受一个列表。其机制很简单:当模型输出配置的字符串时,服务器停止流式传输,将 finish_reason 设置为 stop(在 Anthropic 上为 stop_sequence)并返回。
问题在于,哨兵符(sentinel)的选择很少被重新审视。团队通常会选择 ###、User:、</response>、右大括号或 Markdown 水平线,因为这些 Token 出现在原始提示词的边界处,显得很自然。随后,用户内容加入了提示词——有时是粘贴的内容,有时是引用的段落,有时是附件,其中的文本是由 AI 团队以外的人负责的预处理步骤提取的。用户内容分布包含了 Markdown 标题、角色扮演对话、带有 User: 和 Assistant: 标签的转录文本、带有右大括号的代码,以及与提示词模板使用相同标签的 HTML。给定用户负载包含哨兵符的概率在单次请求中虽然很小,但在规模化运行下却是必然发生的。
这对模型的影响是微妙的。哨兵符可以通过两种方式触发。模型可能会将哨兵符作为其自身输出的一部分进行回显(当用户输入包含它且模型正在进行摘要或引用时),从而导致立即截断。模型也可能自然地生成哨兵符,因为它的输出风格受到了用户内容的引导(大量的 Markdown 输入通常会产生大量的 Markdown 输出,而将 ### 设置为停止序列的提示词隐含地要求模型不要生成 H3 标题,这在用户要求生成 H3 标题之前都没问题)。
评估陷阱
标准的评估套件无法捕捉到这一点。评估案例要么来自合成的测试固件,要么来自经过筛选的过往生产流量子集,在这两种情况下,输入都是“卫生”的。评估团队没有理由在测试工单中植入 ### —— 测试工单关注的是模型是否正确摘要,而不是 I/O 契约在对抗性空格(adversarial whitespace)下是否依然稳健。模型通过了每一个评估案例,截断仅在生产环境中显现,评估与生产行为之间的差距被解读为“模型质量漂移”,因为仪表板没有将“因哨兵符截断”与“因 Token 限制截断”区分开来。
弥合这一差距的仪表监测(instrumentation)并不深奥,但必须有意识地去构建。三个信号至关重要。
第一个是 finish_reason(或 stop_reason)直方图,按路由、模型、提示词版本进行切分,并且——关键在于——按用户输入是否包含任何已配置的哨兵符进行切分。与包含哨兵符的输入相关的“因哨兵符停止”响应激增,就是问题的关键证据(smoking gun)。大多数团队根本不会记录输入中是否出现了哨兵符,因为输入清理层不知道提示词的停止配置是什么,而提示词层又不 知道输入包含了什么。这正是 Bug 滋生的缝隙。
第二个是输出 Token 计数的长度百分位监控,同样按输入中是否包含哨兵符进行切分。被截断的输出位于低分位尾部;如果你比较“存在哨兵符”的 p10 和“不存在哨兵符”的 p10,两者之间的差距就是该 Bug 的影响规模。
第三个是按用户细分的重试率。遇到截断的用户很少会默默接受;他们会重试,通常是通过重新表述。这种重试会使受影响部分的账单翻倍,并增加了先前交互已经失败的用户的延迟。追踪“短响应后的重试”是一个领先指标,它从另一个维度指向同一个根本原因。
预留命名空间规范
结构化的修复方案是从一个用户无法意外生成的命名空间中提取停止序列。这里有三种可行的方法,它们在不同维度上各有权衡。
模型特定的特殊 Token 是当模型暴露它们时最优雅的选择。许多经过对话调优的模型会预留像“轮次结束”或“消息结束”之类的 Token 专门用于此目的,这些 Token 不会出现在用户粘贴的内容中,因为分词器(tokenizer)对字节序列的编码方式不同 —— 用户无法输入任何能被解码为这些特殊 Token 的 UTF-8 字符串。这与分词器防止系统提示词被逐字符注入的原理相同,并且可以自然地推广到定界符(delimiters)。其权衡在于,你必须放弃跨模型提供商的一些可移植性,并且需要跟踪每个模型版本的特殊 Token 映射。
UUID 哨兵 在任何地方都有效,且不需要模型的配合。为每个请求生成一个新的 UUID,将其作为定界符嵌入到 提示词中,并将其作为停止序列传递。任何用户内容包含一个随机生成的全新 UUID 的概率几乎为零,且这种技术在更换服务商时依然适用。其代价是每个请求会多消耗几个 Token,以及少量的提示词模板底层逻辑。
Unicode 私有使用区(private-use)字符 介于两者之间。它们是标准预留给特定应用程序使用的 Unicode 码点,因此规范的输入管道永远不会产生它们。它们很短(通常只需一到两个 Token),在大多数渲染管道中是不可见的,并且可以跨提供商免费使用。风险在于,上游某个有 Bug 的预处理步骤可能会决定“规范化”掉这些字符,导致你的定界符消失,整个提示词解析失败。在采用之前,请务必测试你的完整输入管道。
一个错误但极具诱惑力的做法是:保留人类可读的哨兵,并增加一个输入清洗(sanitization)环节,用于转义或从用户内容中剥离哨兵。这种方法在清洗器出 Bug 之前、在绕过清洗器的新摄取路径被添加之前、或者在下游消费者依赖未经清洗的文本之前都是有效的。深度防御(Defense in depth)值得去做,但首要防御措施应该是用户无法生成的定界符,而不是一个你希望能够洗掉的用户可生成的定界符。
对抗性评估种子
即使有了预留命名空间的定界符,评估套件(eval suite)仍然需要种子化的对抗性输入。其目的不仅是捕获当前的 Bug,还要捕获下一个 —— 未来的工程师可能会添加一个带有新停止序列的新提示词模板,如果评估集没有预先测试哨兵冲突,同样的回归测试失败就会发生。
构建一个对 抗性输入语料库,包含提示词目录中所有已知的哨兵,加上常见的类似符号(####、**、---、代码围栏、所有标准角色标签、常见的闭合标签)。在 CI 中针对每个提示词版本运行该语料库。信号不在于“模型是否给出了好的回答”,而在于“响应是否在达到配置的最小长度之前就截断了”。截断检测评分器比质量评分器便宜得多,且能直接捕获失效模式。
当目录发生变化时,语料库必须同步更新。一种轻量级的规范是在 CI 步骤中对比当前的停止序列目录与语料库,如果对抗性集合中缺失任何哨兵,则构建失败。这将“更改提示词时记得更新评估”转变为一种不依赖记忆的构建时检查。
为什么账单比看起来更糟糕
这个 Bug 的成本结构是双峰态的,值得深入理解。显性成本是截断后的响应本身:用户得到了较差的回答,有时能察觉到,有时察觉不到。隐性成本是重试 —— 当用户得到一个只思考了一半的回答时,重试率会攀升,而每次重试都要支付全额推理成本。在受影响的样本中,人均推理账单大约会翻倍,而这种翻倍集中在那些粘贴了大量上下文的用户身上,他们往往是意图最高的高级用户(power users)。
这种成本在不同层级的用户之间也是不对称的。免费层级的用户可能根本不会重试,而是直接流失。企业级用户会不断重试直到获得答案,然后提交工单。免费层级的信号是不可见的(用户已经离开了),企业级用户的信号是延迟的(工单会在一周 后通过客服送达),而且这两者都不会出现在 TianPan.co 团队实时监控的仪表盘上。仪表盘显示“平均响应长度看起来很稳定”,因为分布的其他部分是正常的,于是 Bug 就这样持续存在数月。
架构重构
更深层的意识是,停止序列是你与模型之间输入契约的一部分,而不仅仅是一个输出格式的选择。对待它就像对待解析器中的任何其他定界符一样:预留、在边界处转义、监控冲突,并针对对抗性输入进行测试。如果一个团队将其视为输出旋钮 —— 即提示词作者在想要缩短回复时调整的东西 —— 那么他们交付的解析器实际上建立在一个从未测试过的内容分布之上。
这可以推广到更多场景。结构化生成与用户提供文本相遇的每个地方都是解析器边界,每个解析器边界都需要同样的卫生习惯:用户无法生成的定界符、缝隙处的校验,以及在冲突变成支持工单之前就将其暴露出来的监测手段。提示词不是一个插入了用户数据的格式化字符串;它是一种具有两个不同信任区的传输协议,而强化两者之间的契约是团队的责任。
好消息是,一旦发现 Bug,修复它的工作量很小。一个 UUID 哨兵和一个按“输入中包含哨兵”切分的 finish_reason 仪表盘就能捕捉大部分损害并防止再次发生。坏消息是,如果没有那个仪表盘,这个 Bug 就是隐形的,而且大多数团队在被迫采取行动之前都不会去构建这个仪表盘。这种“强制函数”通常是一个注意到了截断、提交了工单,并等待了 AI 团队一个月才追踪到网关处的客户。先构建仪表盘,你就可以省去那一个月的时间。
- https://www.vellum.ai/llm-parameters/stop-sequence
- https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
- https://help.openai.com/en/articles/5072263-how-do-i-use-stop-sequences-in-the-openai-api
- https://medium.com/@bateiko/stop-sequences-in-llms-a-minor-adjustment-with-significant-consequences-e14e7b9510f6
- https://stackviv.ai/blog/max-tokens-stop-sequences-llm
- https://www.rohan-paul.com/p/stop-sequences-in-llms-concept-and
- https://www.gocodeo.com/post/how-to-defend-against-prompt-injection-from-delimiters-to-ai-based-detection
- https://owasp.org/www-community/attacks/PromptInjection
