关于AI 停止序列的几点记录

让它停它不停,不让它多说它偏要多说半页。

接 AI API 做结构化输出,最常见翻车不是模型答错,而是答多了:JSON 前后夹客套话,json.loads 挂;代码补全后面跟一段「以上代码仅供参考」;命令协议里多一句「已为您生成命令」。模型有多说一点的习惯,流水线需要精准截断。

停止序列(stop sequences)就是在这个环节动手。下面几个真实场景和踩坑对比。

背景

先说几个真实场景。

场景一:生成结构化数据。我用 API 生成 JSON 对象,模型往往会在 JSON 前后加几句客套话,比如 “好的,这是您需要的 JSON 数据:” 这种。如果你的代码直接 json.loads(response.content),大概率会抛异常。

场景二:代码生成补全。你想让 AI 帮你补全函数,它往往会补完代码后再加几行注释或者说明。在某些编辑器插件里,这些多余内容会影响直接应用补全结果。

场景三:对话式命令生成。你设计了一个固定格式的命令协议,希望模型每次只返回命令本身,但它总要加一句 “我已经为您生成了命令,可以执行了” 之类的说明。

核心问题都一样:模型有"多说一点"的习惯,但你的程序需要"精准一点"。

当然,你可以用 Prompt Engineering 来约束,比如明确写上 “只返回 JSON,不要其他任何内容”。但这种方式不够可靠,尤其当你无法控制 Prompt 或者需要给模型足够自由度时,硬约束反而可能降低生成质量。

停止序列(stop sequences)就是为了解决这类问题设计的。

需求

什么是停止序列?

简单说,停止序列就是告诉模型:生成过程中如果遇到这些字符或字符串,就马上停下来,不要再继续往后生成了。

大多数主流 AI API 都支持这个参数,只是叫法可能不太一样:

  • OpenAI API:stop 参数,接受字符串或字符串数组
  • Anthropic Claude:stop_sequences 参数
  • Azure OpenAI:继承 OpenAI 的 stop 参数
  • 部分国内 API:有的用 stop_words,有的用 stop_sequences

参数本身很简单,难点在于怎么用才有效、不踩坑。

我的需求很简单:在各种场景下,让模型只输出我需要的核心内容,不要那些画蛇添足的解释、说明、前缀后缀。

实现

先从最简单的例子开始:让 AI 只返回 JSON,不要其他任何废话。

基础实现

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": "请生成一个用户对象,包含 name、email、age 三个字段"}
    ],
    stop=["\n\n", "```"],
    temperature=0.7
)

print(response.choices[0].message.content)

这里用了两个停止序列:

  • \n\n:遇到连续两个换行就停。大部分情况下,JSON 生成完后如果模型想继续说点什么,通常会先换行。
  • ```:遇到代码块结束符就停。防止模型把 JSON 包裹在代码块里。

但这种方式不太可靠,因为模型可能不会按你预期的方式换行。

更可靠的方式是结合 Prompt 和停止序列:

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": """请生成一个用户对象,包含 name、email、age 三个字段。
直接返回 JSON 对象,不要任何前缀、后缀、解释或说明。
格式如下:
{
  "name": "...",
  "email": "...",
  "age": ...
}

现在开始生成:"""
        }
    ],
    stop=["\n", "}"],
    temperature=0.7
)

这里 stop=["\n", "}"] 是个技巧:模型生成 JSON 时,最后一个 } 通常是最后一行,如果之后没有生成内容了,就不会触发停止;但如果模型想继续写什么,必然会换行(\n),这时候就会触发停止。

不过这也有问题:如果 JSON 本身包含换行怎么办?下面会说到。

JSON 场景的专门方案

对于 JSON 生成,最稳妥的方案是利用 JSON 的结构特点。一个有效的 JSON 对象必须以 { 开头、} 结尾,而且括号是成对出现的。

可以这样设计:

import json

def generate_json_strict(prompt: str) -> dict:
    """严格只返回 JSON 的生成函数"""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "你是一个 JSON 生成器,只返回合法的 JSON 对象,不要任何其他内容。"
            },
            {
                "role": "user",
                "content": f"{prompt}\n\nJSON:"
            }
        ],
        stop=["}\n\n", "}\n#", "}\n//", "}\n *", "}\n-"],
        temperature=0.3  # 降低温度提高稳定性
    )

    content = response.choices[0].message.content

    # 确保以 } 结尾(防止停止序列触发不够精准)
    if not content.rstrip().endswith("}"):
        content = content.rstrip() + "}"

    # 解析 JSON
    return json.loads(content)

这里的 stop 数组包含了多种可能的"JSON 结束后"的模式:

  • }\n\n:JSON 后跟两个换行(最常见的情况)
  • }\n#:JSON 后跟注释
  • }\n//:JSON 后跟 C 风格注释
  • }\n *:JSON 后跟 Markdown 列表
  • }\n-:JSON 后跟无序列表

这种方式覆盖了大部分常见的"继续写"模式,但仍不是 100% 可靠。实际项目中,建议再加一层 JSON 解析异常捕获,失败时重试。

对话场景的停止序列

在对话式应用中,停止序列有另一种用法:防止模型过度展开。

比如你设计了一个问答机器人,希望每次回答不要太长,用户可以继续追问。可以设置这样的停止序列:

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": user_question}
    ],
    stop=["\n\n\n", "总结:", "另外,", "此外,"],
    max_tokens=500
)

这里的逻辑是:

  • \n\n\n:如果模型连续三个换行,大概率是想开启新话题或者展开太多
  • 总结:另外,此外,:这些都是模型开始"超纲"的常见信号

结合 max_tokens=500,可以双重控制输出长度。

代码生成场景

代码生成时,停止序列要格外小心,因为你不想提前截断代码。

常见策略是:只在特定格式下使用停止序列。

# 单行代码补全
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": "补全这行代码:result = calculate("}
    ],
    stop=["\n", ")", ";"],
    temperature=0.2
)

# 多行代码补全,但只补一个函数
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": """补全这个函数:

def process_data(data):
    \"\"\"处理输入数据\"\"\"
    pass"""
    }
    ],
    stop=["\n\ndef ", "\nclass ", "\n\n\n"],
    temperature=0.3
)

第二个例子里,stop=["\n\ndef ", "\nclass ", "\n\n\n"] 的意思是:如果模型开始定义新函数或新类,或者连续三个换行,就停下来。这样可以补完当前函数,但不会继续生成后面的代码。

踩坑

坑一:停止序列太长

最初我犯了个错误:把整个 JSON 结束符作为停止序列,比如 stop=["}\n\n```\n"]

问题很明显:模型生成过程中可能不会完全按照你想象的字符顺序出现,导致停止序列永远不会触发。更糟糕的是,如果模型正好在中间某处匹配了部分内容,可能会出现意外的截断。

教训:停止序列要短而精准,最好控制在 1-3 个字符,或者最常见的关键词。

坑二:停止序列太激进

有一次我为了严格限制 JSON 输出,设置了 stop=["\n"],意思是遇到任何换行就停。

结果模型连 JSON 内部的换行都没法生成,返回的 JSON 变成了一行长字符串,虽然格式正确,但可读性极差,而且在某些后续处理场景下(比如展示给用户看)体验很差。

教训:要区分"内容内部的换行"和"内容结束后的换行",前者应该允许,后者才是停止信号。这需要结合具体内容类型设计。

坑三:不同模型行为不一致

同样的停止序列配置,在不同模型上效果可能完全不同。

比如 stop=["\n\n"] 在 gpt-4o-mini 上效果很好,但在某些第三方模型上可能无效,因为那个模型压根就不会在内容结束后连续换行。

解决方式有两个:要么针对不同模型使用不同的停止序列配置,要么使用更通用的停止序列(比如不依赖换行,而是依赖特定关键词)。

坑四:停止序列与 Prompt 冲突

有次我设计了一个 Prompt,让模型生成多段内容,每段用特定分隔符分开。但我同时设置了 stop=["\n\n"],结果模型在生成第一段后就停了,因为每段之间确实是两个换行。

这种冲突很难一眼看出来,只能在调试时逐步验证。

教训:设置停止序列前,先想清楚你期待的输出格式是什么,确保停止序列不会误伤正常内容。

坑五:多字节字符的边界问题

后来做过一个中文内容生成项目,要求在句号处停止。中文句号是 ,看着简单,但实际使用时发现:有时候模型在 之后还会生成一个空格才停,有时候在 之前就停了。

核心原因是:停止序列的匹配是在 token 级别进行的,而某些多字节字符可能会被切分到不同 token 里。

比如 在某些 tokenizer 里可能是一个完整 token,但在某些情况下可能与前面的字合并成一个 token。当你期望它在输出 后停止时,模型可能因为 token 边界原因,在 还没完整生成时就触发了停止逻辑,或者需要额外生成一个 token 才能让停止序列匹配上。

这个问题在英文里也存在,但中文因为编码和 token 切分的复杂性,会更明显。

flowchart LR A[开始生成] --> B[生成 token 1] B --> C[生成 token 2] C --> D[检查停止序列] D --> E{匹配成功?} E -->|是| F[停止生成] E -->|否| G[继续生成] G --> H[生成 token 3] H --> D

这张图想说明:停止序列的检查是在生成每个 token 后进行的,如果多字节字符被分割到不同 token 中,就可能导致匹配时机不准确。

结果

为了验证停止序列的实际效果,我做了一个简单的对比测试。

测试任务:让模型生成 10 个用户对象的 JSON 数组。

方案一:不用停止序列,只在 Prompt 中约束

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": """生成 10 个用户对象的 JSON 数组。
每个对象包含 name、email、age 三个字段。
直接返回 JSON 数组,不要任何前缀、后缀、解释。"""
        }
    ],
    temperature=0.7
)

方案二:Prompt + 停止序列

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": """生成 10 个用户对象的 JSON 数组。
每个对象包含 name、email、age 三个字段。
直接返回 JSON 数组,不要任何前缀、后缀、解释。"""
        }
    ],
    stop=["\n\n", "```"],
    temperature=0.7
)

重复测试 20 次,统计 JSON 解析成功率:

方案成功次数失败原因
无停止序列14/206 次失败(前缀/后缀/注释)
有停止序列19/201 次失败(多行内容提前截断)

可以看到,停止序列显著提升了可控性,但也不是 100% 完美——那一次失败是因为 JSON 数组本身包含换行,被 stop=["\n\n"] 误伤了。

这说明一个道理:停止序列是工具,不是万能钥匙。你需要根据具体场景权衡。

复杂场景的组合策略

在实际项目中,我发现单纯依赖停止序列不够可靠,需要组合多种策略。

策略一:Prompt + 停止序列 + 解析验证

def generate_structured_data(prompt: str, schema: dict, max_retries: int = 3) -> dict:
    """生成结构化数据,带重试机制"""
    for attempt in range(max_retries):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {
                    "role": "system",
                    "content": f"""你是一个结构化数据生成器。
必须严格返回符合以下 JSON Schema 的对象,不要任何前缀、后缀、解释。
JSON Schema:
{json.dumps(schema, ensure_ascii=False, indent=2)}"""
                },
                {"role": "user", "content": prompt}
            ],
            stop=["\n\n", "```", "}", "】"],
            temperature=0.3
        )

        content = response.choices[0].message.content

        # 尝试解析
        try:
            result = json.loads(content)

            # 验证 schema(可以用 jsonschema 库)
            # validate(instance=result, schema=schema)

            return result
        except json.JSONDecodeError as e:
            if attempt < max_retries - 1:
                print(f"第 {attempt + 1} 次失败,重试中... 错误:{e}")
                continue
            else:
                raise RuntimeError(f"生成失败,已重试 {max_retries} 次") from e

这个策略的核心是:不指望一次成功,而是把停止序列当作"提高成功率的工具",失败时重试。

策略二:前后处理结合

如果对格式要求极高,可以采用"生成后修剪"的方式:

def generate_json_with_cleanup(prompt: str) -> dict:
    """生成 JSON 并自动清理前缀后缀"""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "user", "content": prompt}
        ],
        temperature=0.7
    )

    content = response.choices[0].message.content

    # 找到第一个 { 和最后一个 }
    start = content.find("{")
    end = content.rfind("}")

    if start == -1 or end == -1:
        raise ValueError("无法找到有效的 JSON 对象")

    json_str = content[start:end + 1]

    return json.loads(json_str)

这种方式不需要停止序列,而是依赖 JSON 的结构特点。缺点是如果模型生成的内容里包含多个 {},可能会出错。

策略三:分阶段生成

对于非常复杂的输出,可以分阶段生成,每个阶段只要求输出一小部分:

# 第一阶段:生成结构
structure_response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": "我要生成一个包含用户信息、订单信息、商品信息的复杂 JSON,你先告诉我应该有哪些字段和结构?"
        }
    ],
    stop=["\n\n"],
    temperature=0.3
)

# 第二阶段:按结构生成内容
# ...

这种方式可控性最强,但需要多次 API 调用,成本较高。

生成流程对比

用一张图来总结一下不同策略的生成流程和可控性:

graph TD A[API 生成请求] --> B{使用策略} B --> C[仅 Prompt 约束] B --> D[Prompt + 停止序列] B --> E[前后处理修剪] B --> F[分阶段生成] C --> G[模型自由输出] G --> H[可能有多余内容] H --> I[解析失败率较高] D --> J[输出时即时停止] J --> K[多余内容较少] K --> L[解析失败率较低] E --> M[模型自由输出] M --> N[程序修剪前后缀] N --> O[可控性高] O --> P[但可能误伤有效内容] F --> Q[分多次请求] Q --> R[每次只生成一小段] R --> S[可控性最高] S --> T[但成本也最高]

可以看到,每种策略都有自己的优缺点,关键是根据场景选择。

总结和推荐

折腾了一圈,我总结出一些实践经验:

推荐配置

JSON 生成:

stop=["\n\n", "```", "}\n\n"]

单行文本:

stop=["\n", "。", "!", "?", "。", "!", "?"]

多段落文本(限制长度):

stop=["\n\n\n", "总结:", "另外,", "此外,"]

代码补全:

stop=["\n\ndef ", "\nclass ", "\n\n\n"]

最佳实践

  1. 停止序列要短而精准,太长容易失效,太宽泛容易误伤
  2. 结合 Prompt 使用,不要全靠停止序列
  3. 根据内容类型调整,JSON、代码、对话需要不同的策略
  4. 加一层解析验证和重试机制,提高可靠性
  5. 先小规模测试,验证稳定后再上生产

不推荐做法

  • 用太复杂的正则表达式作为停止序列(大部分 API 不支持)
  • 把停止序列当作"缩短输出长度"的主要手段(应该用 max_tokens)
  • 在不同模型上使用完全相同的停止序列配置
  • 依赖停止序列处理复杂结构(应该分阶段生成)

结语

写到这里,突然意识到这件事有点哲学意味:我们在教 AI 说话,但同时也在教它闭嘴。

停止序列本质上是一种边界设定——告诉模型"到这里就够了"。这种设定很有必要,因为没有边界的生成会走向不可控,不仅浪费 token,还可能在后续处理中引发各种问题。

但另一方面,如果边界设得太死,又可能限制模型的表现力。就像对话中,你既要让话题不跑偏,又要留一些自由空间让思想流动起来。

技术问题往往也是这种平衡的艺术。停止序列不是万能的,但它给了我们一种工具,可以在"自由生成"和"可控输出"之间找到一个平衡点。

至于这个平衡点在哪里,还得看具体场景和你的容忍度。毕竟,有时候多一点废话也无妨,而有些时候,多一个字符都是灾难。

这种判断,可能还是需要人来定。

版权声明: 本文首发于 指尖魔法屋-关于AI 停止序列的几点记录https://blog.thinkmoon.cn/post/355-ai-stop-sequence-infinite-controllable-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!