<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Structured Output on KbWen Blog</title>
    <link>https://www.kbwen.com/tags/structured-output/</link>
    <description>KbWen is a practical technology blog about AI systems, machine learning, Python, data engineering, and software development.</description>
    <generator>Hugo</generator>
    <language>zh-tw</language>
    <image>
      <url>https://www.kbwen.com/images/og-default.png</url>
      <title>KbWen Blog</title>
      <link>https://www.kbwen.com/</link>
    </image>
    
    <lastBuildDate>Mon, 12 Oct 2026 10:10:00 +0800</lastBuildDate><atom:link href="https://www.kbwen.com/tags/structured-output/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>JSON schema 怎麼限制 LLM 的輸出</title>
      <link>https://www.kbwen.com/structured-output-token-mask/</link>
      <pubDate>Mon, 12 Oct 2026 10:10:00 +0800</pubDate><dc:creator>KbWen</dc:creator>
      <guid>https://www.kbwen.com/structured-output-token-mask/</guid>
      <description>我們用 xgrammar 看看給了 JSON schema 之後，模型會受到甚麼影響。</description>
      <content:encoded><![CDATA[<p>用 API 呼叫模型的時候，常常會希望它回傳 JSON，我們就可以讓程式直接讀取、操作。只在 prompt 裡寫「請用 JSON 格式回答」，大部分時候是可以的，但偶爾前面會多一句說明、少了一個括號或是多了個符號，就會解析失敗。所以現在很多 API 和推論引擎都有 JSON mode 或結構化輸出（structured output）的選項，讓回來的內容通常照著格式處理。</p>
<p>模型產生文字的方式，是每一步替詞彙表裡所有的 token 計算分數，再從裡面挑出下一個 token。constrained decoding 則是在選擇挑取之前先把「選了就會讓格式壞掉」的 token 分數改成負無限大，這些 token 就不可能被選到，模型就被限縮在剩下的選項中挑選。</p>
<p>參考之前寫的相關文章：<a href="/llm-predicts-next-token/">大語言模型 LLM：其實做的事情比你想像中更單純</a></p>
<h2 id="剩下幾個-token">剩下幾個 token</h2>
<p><a href="https://github.com/mlc-ai/xgrammar">xgrammar</a> 是專門做這件事的開源函式庫，而且可以拿 tokenizer 來跑，不需要載入模型，因為要劃掉哪些 token 只跟 schema、詞彙表和目前進度到哪裡有關，跟模型的權重無關(感覺很反直覺)。下面用 Qwen2.5-0.5B-Instruct 的 tokenizer 來測試。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">torch</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">xgrammar</span> <span class="k">as</span> <span class="nn">xgr</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">transformers</span> <span class="kn">import</span> <span class="n">AutoTokenizer</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">tok</span> <span class="o">=</span> <span class="n">AutoTokenizer</span><span class="o">.</span><span class="n">from_pretrained</span><span class="p">(</span><span class="s2">&#34;Qwen/Qwen2.5-0.5B-Instruct&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">info</span> <span class="o">=</span> <span class="n">xgr</span><span class="o">.</span><span class="n">TokenizerInfo</span><span class="o">.</span><span class="n">from_huggingface</span><span class="p">(</span><span class="n">tok</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">schema</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;properties&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span><span class="p">},</span> <span class="s2">&#34;age&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;integer&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;required&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="s2">&#34;age&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;additionalProperties&#34;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">compiled</span> <span class="o">=</span> <span class="n">xgr</span><span class="o">.</span><span class="n">GrammarCompiler</span><span class="p">(</span><span class="n">info</span><span class="p">)</span><span class="o">.</span><span class="n">compile_json_schema</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">schema</span><span class="p">),</span> <span class="n">any_whitespace</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">matcher</span> <span class="o">=</span> <span class="n">xgr</span><span class="o">.</span><span class="n">GrammarMatcher</span><span class="p">(</span><span class="n">compiled</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bitmask</span> <span class="o">=</span> <span class="n">xgr</span><span class="o">.</span><span class="n">allocate_token_bitmask</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">info</span><span class="o">.</span><span class="n">vocab_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;vocab_size&#34;</span><span class="p">,</span> <span class="n">info</span><span class="o">.</span><span class="n">vocab_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">tok</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;{&#34;name&#34;: &#34;Ada&#34;, &#34;age&#34;: 36}&#39;</span><span class="p">,</span> <span class="n">add_special_tokens</span><span class="o">=</span><span class="kc">False</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">matcher</span><span class="o">.</span><span class="n">fill_next_token_bitmask</span><span class="p">(</span><span class="n">bitmask</span><span class="p">)</span>          <span class="c1"># 算出還能用的 token</span>
</span></span><span class="line"><span class="cl">    <span class="n">logits</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">info</span><span class="o">.</span><span class="n">vocab_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">xgr</span><span class="o">.</span><span class="n">apply_token_bitmask_inplace</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">bitmask</span><span class="p">)</span>  <span class="c1"># 不能用的分數改成 -inf</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">tok</span><span class="o">.</span><span class="n">decode</span><span class="p">([</span><span class="n">t</span><span class="p">])</span><span class="si">!r:</span><span class="s2">8</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">torch</span><span class="o">.</span><span class="n">isfinite</span><span class="p">(</span><span class="n">logits</span><span class="p">)</span><span class="o">.</span><span class="n">sum</span><span class="p">())</span><span class="si">:</span><span class="s2">&gt;7</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">matcher</span><span class="o">.</span><span class="n">accept_token</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
</span></span></code></pre></div><p>環境是 Python 3.12.13、xgrammar 0.2.8、torch 2.14.1、transformers 5.19.0。左邊是送進去的 token，右邊是當下還可以使用的 token 數量：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vocab_size 151665
</span></span><span class="line"><span class="cl">&#39;{&#34;&#39;           2
</span></span><span class="line"><span class="cl">&#39;name&#39;         4
</span></span><span class="line"><span class="cl">&#39;&#34;:&#39;           2
</span></span><span class="line"><span class="cl">&#39; &#34;&#39;         121
</span></span><span class="line"><span class="cl">&#39;Ada&#39;     147057
</span></span><span class="line"><span class="cl">&#39;&#34;,&#39;      147057
</span></span><span class="line"><span class="cl">&#39; &#34;&#39;           2
</span></span><span class="line"><span class="cl">&#39;age&#39;          3
</span></span><span class="line"><span class="cl">&#39;&#34;:&#39;           2
</span></span><span class="line"><span class="cl">&#39; &#39;            2
</span></span><span class="line"><span class="cl">&#39;3&#39;           11
</span></span><span class="line"><span class="cl">&#39;6&#39;           11
</span></span><span class="line"><span class="cl">&#39;}&#39;           11
</span></span></code></pre></div><p>一開始只能從兩個 token 裡選，<code>{</code> 或是 <code>{&quot;</code>，詞彙表裡其他選擇都被無視，因為 JSON 物件只能用左大括號開頭。接下來是 key，xgrammar 會照 schema 裡欄位寫的順序產生，我們看到第一個是 <code>name</code>，所以剩下的只有 <code>n</code>、<code>na</code>、<code>nam</code>、<code>name</code> 這四種組合；拼寫處理完之後是 <code>&quot;</code> 或 <code>&quot;:</code>，此時又回到兩個選擇。可以看到在這一階段，模型其實沒有什麼需要決定的，也沒有甚麼可選的，因為不管怎麼走，最後都會是 <code>{&quot;name&quot;:</code>。</p>
<p>但接下來到了 name 的值，可選的數字跳到 147,057 個，瞬間變多了，也就代表詞彙表裡大部分的 token 都可以用。而被劃掉的四千多個裡面，其中多數是含有換行或 tab 的 token。也就是說，這裡要填 <code>Ada</code>、<code>Bob</code> 還是一整句中文，都是模型自行挑選的，也不會被劃掉。後面 age 的位置，能用的只剩 0 到 9 的數字和負號，但到底要寫 36 還是 360，對規則來說都一樣。所以仔細看看可以發現，schema 負責規範的是格式，括號、引號、欄位名稱和型別。</p>
<h2 id="schema-和-prompt">schema 和 prompt</h2>
<p>那模型怎麼知道 <code>name</code> 要填名字、<code>age</code> 要填年紀？從剛剛過程來看，處理的都是劃掉不合格式的 token，並沒有告訴模型每個欄位是什麼意思。<a href="https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md">llama.cpp 的 grammar 文件</a>寫到，JSON schema 只用來限制輸出，不會放進 prompt，模型本身看不到 schema；但工具呼叫（tool calling）則是例外，schema 會被放進 prompt。</p>
<p>因此在用這類功能的時候，還是記得要在 prompt 裡把每個欄位的意思寫清楚，像是 <code>age</code> 是實際年齡、單位是年，不然模型也會照格式輸出整數，只是這個數字你也不知道可不可信。至於其他服務會不會另外把 schema 放進 prompt，當成一併處理的資訊，就要看各自的文件以及其發展。</p>
<p>不過還是說明現今的AI模型發展很快，每家廠商的做法和服務都不一樣，可能這個資訊很快就會過時了，就當作一個簡易分享囉。</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
