用Grammar约束Qwen3.6冗长think内容

强制 Qwen3 thinking 模型按「目标→状态→算法→边界→验证→代码」的结构化格式输出。


背景

Qwen3 等 thinking 模型会在生成答案前先输出一段内部推理过程,包裹在 <think>...</think> 标签中。默认的思考过程是自由文本,格式不固定。

通过 GBNF(GGML BNF)语法约束,可以让模型按照固定的结构化格式来思考,例如:

<think>
GOAL: 用 Python 实现二分查找
STATE: 已排序数组,需返回目标索引
ALGO: 双指针法,每次取中点比较
EDGE: 空数组、目标不存在、重复元素
VERIFY: 遍历边界测试用例
</think>

# 然后是代码...
def binary_search(arr, target):
    ...

这样做的好处:

  • 思考过程可解析、可验证
  • 下游系统能提取 GOAL/STATE 等字段做日志或审计
  • 减少模型"过度思考"或"跑偏"

一、llama.cpp 方案

GBNF 语法文件

创建 think.gbnf

root  ::= think code
think ::= "<think>\n" "GOAL: " line "STATE: " line "ALGO: " line "EDGE: " line "VERIFY: " line "</think>\n\n"
line  ::= [^\n]+ "\n"
code  ::= [\x09\x0A\x0D\x20-\x7E\u3000-\u303F\u4E00-\u9FFF\uFF00-\uFFEF]+

规则说明:

规则 含义
root 整体输出 = think 块 + code 块
think 含 5 个必填字段的 <think> 标签
line 一行非空文本(天然支持中文)
code 可打印 ASCII + CJK 汉字 + 全角标点

\u4E00-\u9FFF 覆盖常用汉字,\u3000-\u303F 覆盖 CJK 标点(如 、。「」),\uFF00-\uFFEF 覆盖全角字符。

使用方式

方式一:llama-cli 命令行

llama-cli -m Qwen3.6-35B-A3B.gguf \
  -n 1024 \
  --grammar-file think.gbnf \
  -p "用 Python 写一个二分查找,带中文注释"

方式二:llama-server

启动服务:

llama-server -m Qwen3.6-35B-A3B.gguf --port 8080

请求时传入 grammar:

curl -s 'http://localhost:8080/completion' \
  -H 'Content-Type: application/json' \
  -d '{
    "prompt": "用 Python 写一个二分查找,带中文注释",
    "n_predict": 1024,
    "grammar": "root ::= think code\nthink ::= \"<think>\\nGOAL: \" [^\\n]+ \"\\nSTATE: \" [^\\n]+ \"\\nALGO: \" [^\\n]+ \"\\nEDGE: \" [^\\n]+ \"\\nVERIFY: \" [^\\n]+ \"\\n</think>\\n\\n\"\ncode ::= [ -~\\u3000-\\u303f\\u4e00-\\u9fff\\uff00-\\uffef]+"
  }'

llama.cpp 的 <think> 标签作为普通字符串匹配,直接生效,无需额外配置。


二、vLLM 方案

启动配置(关键)

vLLM 默认只约束 content 字段,不约束 reasoning(思考部分)。必须启动时显式开启:

vllm serve Qwen3.6-35B-A3B \
  --structured-outputs-config '{"enable_in_reasoning": true}'

请求方式

curl -s 'http://localhost:8000/v1/chat/completions' \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen",
    "messages": [
      {"role": "user", "content": "用 Python 写一个二分查找,带中文注释"}
    ],
    "max_tokens": 1024,
    "chat_template_kwargs": {"enable_thinking": true},
    "structured_outputs": {
      "grammar": "root ::= think code\nthink ::= \"<think>\\nGOAL: \" [^\\n]+ \"\\nSTATE: \" [^\\n]+ \"\\nALGO: \" [^\\n]+ \"\\nEDGE: \" [^\\n]+ \"\\nVERIFY: \" [^\\n]+ \"\\n</think>\\n\\n\"\ncode ::= [ -~\\u3000-\\u303f\\u4e00-\\u9fff\\uff00-\\uffef]+"
    }
  }'

Python SDK 版本:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="not-needed"
)

grammar = r"""
root  ::= think code
think ::= "<think>\nGOAL: " [^\n]+ "\nSTATE: " [^\n]+ "\nALGO: " [^\n]+ "\nEDGE: " [^\n]+ "\nVERIFY: " [^\n]+ "\n</think>\n\n"
code  ::= [ -~\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]+
"""

response = client.chat.completions.create(
    model="qwen",
    messages=[
        {"role": "user", "content": "用 Python 写二分查找"}
    ],
    max_tokens=1024,
    extra_body={
        "chat_template_kwargs": {"enable_thinking": True},
        "structured_outputs": {"grammar": grammar}
    }
)

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

三、关键差异对照

项目 llama.cpp vLLM
参数名 grammar--grammar-file structured_outputs.grammar
约束 thinking 默认约束全部输出 enable_in_reasoning: true
<think> 标签 纯字符串,直接用 特殊 token,需 enable_thinking: true
换行符 \n 在引号内直接写 JSON 中需要双重转义 \\n
Unicode 转义 \x \uXXXX 全支持 \uXXXX 支持,\x 需映射为字符
JSON Schema json_schema 参数 structured_outputs.jsonguided_json

四、踩坑记录

1. 参数名不对(vLLM)

# ❌ 错误 — vLLM 不识别
"guided_grammar": "..."

# ✅ 正确
"structured_outputs": {"grammar": "..."}

2. grammar 不生效,模型自由输出

vLLM 默认 enable_in_reasoning: false,grammar 只约束 content。如果 thinking 模式开启,模型所有 token 都消耗在 reasoningcontent 为空,看起来就像 grammar 没生效。

解决:启动时加 --structured-outputs-config '{"enable_in_reasoning": true}'

3. <think> 标签被剥离

vLLM 的 Qwen3 模板会把 <think>...</think> 之间的内容抽到 reasoning 字段。如果 enable_in_reasoning: false,grammar 中写 <think> 就永远匹配不到。

解决:开启 enable_in_reasoning: true 后,grammar 能直接匹配 raw token stream 中的 <think> 标签。

4. max_tokens 不足

thinking 模式每次推理会额外消耗 token。如果 max_tokens 设得太小(如 128),思考过程就耗光了,代码部分来不及生成。

建议max_tokens ≥ 1024。

5. 可选重复的性能陷阱(llama.cpp)

# ❌ 慢 — N 个可选分支指数级展开
root ::= item? item? item? item? item?

# ✅ 快 — 显式指定次数范围
root ::= item{0,5}

五、扩展:中文标签版

如果希望思考字段也用中文:

root  ::= think code
think ::= "<think>\n目标: " line "状态: " line "算法: " line "边界: " line "验证: " line "</think>\n\n"
line  ::= [^\n]+ "\n"
code  ::= [\x09\x0A\x0D\x20-\x7E\u3000-\u303F\u4E00-\u9FFF\uFF00-\uFFEF]+

六、总结

┌─────────────────────────────────────────────────────────┐
│                    Grammar 约束流程                       │
├─────────────────────────────────────────────────────────┤
│  1. 写 .gbnf 文件(或内联 grammar 字符串)               │
│  2. llama.cpp: 直接用,开箱即支持 thinking               │
│  3. vLLM: 启动加 --structured-outputs-config              │
│     '{"enable_in_reasoning":true}'                       │
│  4. 请求时传 structured_outputs.grammar                  │
│  5. max_tokens 给够(≥1024)                            │
│  6. 享受结构化思考输出 🎉                                │
└─────────────────────────────────────────────────────────┘
posted @ 2026-04-30 12:27  索美不达米亚  阅读(199)  评论(0)    收藏  举报