<?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/"><channel><title>Function Calling on Chico's Tech Blog</title><link>https://realtime-ai.chat/tags/function-calling/</link><description>Recent content in Function Calling on Chico's Tech Blog</description><image><title>Chico's Tech Blog</title><url>https://github.com/chicogong.png</url><link>https://github.com/chicogong.png</link></image><generator>Hugo</generator><language>zh-cn</language><lastBuildDate>Sun, 17 May 2026 11:00:00 +0800</lastBuildDate><atom:link href="https://realtime-ai.chat/tags/function-calling/index.xml" rel="self" type="application/rss+xml"/><item><title>给 Agent 写工具:一个好 tool 长什么样</title><link>https://realtime-ai.chat/posts/agent-tool-design/</link><pubDate>Sun, 17 May 2026 11:00:00 +0800</pubDate><guid>https://realtime-ai.chat/posts/agent-tool-design/</guid><description>Agent 跑不好,常常不是模型不行,是工具设计得差。这篇讲清工具描述、参数、返回值、错误回传、粒度切分该怎么做,每条都配正反例。</description><content:encoded><![CDATA[<p>我见过一个团队为了让 Agent &ldquo;更聪明&rdquo;,把模型从中杯换成大杯,账单翻了三倍,效果几乎没动。后来定位下来,问题出在一个叫 <code>query</code> 的工具上:它的描述只有一句&quot;查询数据库&quot;,返回的是一坨 4000 行的 JSON,里面塞满了 <code>created_at_unix</code>、<code>tenant_uuid</code>、<code>row_version</code> 这种字段。模型不是不聪明,是它每次调用完都得在一堆噪声里捞针,然后经常捞错。</p>
<p>把这个工具拆成两个、描述写清楚、返回值砍掉八成,中杯模型的表现就超过了原来大杯的版本。</p>
<p>这不是个例。<strong>Agent 能力的天花板,很多时候是工具设计,不是模型。</strong> 模型是你换不动的那部分——它由 Anthropic、OpenAI 训练,你只能选型;工具是你完全能控制的那部分。把精力花在能控制的地方,回报率高得多。</p>
<p>Anthropic 在 2026 年那篇《Writing effective tools for AI agents》里有一句话我很认同:工具是一种新的软件形态,它是<strong>确定性系统和非确定性 Agent 之间的契约</strong>。你不能再按&quot;给另一个程序员写 API&quot;的思路写工具——调用方变了,设计原则就得跟着变。</p>
<h2 id="工具描述你在跟模型招标">工具描述:你在跟模型&quot;招标&quot;</h2>
<p>模型面对一组工具,做的事情和招标差不多:读每个工具的描述,判断&quot;这个活该派给谁&quot;。描述写得含糊,它就选错;描述之间边界不清,它就来回横跳。</p>
<p>最常见的坏味道是<strong>用实现细节代替使用场景</strong>。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl"># 反例
</span></span><span class="line"><span class="cl">{
</span></span><span class="line"><span class="cl">  &#34;name&#34;: &#34;db_query&#34;,
</span></span><span class="line"><span class="cl">  &#34;description&#34;: &#34;对主库执行 SQL 查询&#34;
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 正例
</span></span><span class="line"><span class="cl">{
</span></span><span class="line"><span class="cl">  &#34;name&#34;: &#34;search_orders&#34;,
</span></span><span class="line"><span class="cl">  &#34;description&#34;: &#34;按用户 ID、时间范围或订单状态查询订单。
</span></span><span class="line"><span class="cl">                  用于回答&#39;用户买过什么&#39;&#39;某笔订单到哪了&#39;这类问题。
</span></span><span class="line"><span class="cl">                  不要用它查商品库存——那是 search_inventory 的活。&#34;
</span></span><span class="line"><span class="cl">}
</span></span></code></pre></td></tr></table>
</div>
</div><p>差别在哪?反例描述的是&quot;工具内部怎么干活&quot;(执行 SQL),模型并不关心这个;它关心的是&quot;什么时候该用我&quot;。正例直接给出<strong>触发场景</strong>,还顺手划清了和邻居工具的边界。</p>
<p>这里有个容易被忽略的点:<strong>当你有多个相似工具时,描述里必须明确&quot;我不是谁&quot;。</strong> Anthropic 的建议是用命名空间区分,比如 <code>asana_search</code> 和 <code>jira_search</code>,或者更细的 <code>asana_projects_search</code>、<code>asana_users_search</code>。前缀本身就是一种边界声明。光靠名字还不够时,就在描述里直接写&quot;查 X 用我,查 Y 请用那个工具&quot;。</p>
<p>另一个实战技巧:<strong>在描述里塞一两个使用示例</strong>。模型在互联网文本里见过的函数,旁边大多带着调用例子,这种格式它最熟。一个 <code>search_orders(user_id=&quot;u_123&quot;, status=&quot;shipped&quot;)</code> 的示例,比三行抽象说明管用。2026 年 Anthropic 的 Claude API 干脆把这个能力产品化了,叫 Tool Use Examples——可见示例不是锦上添花,是正经手段。</p>
<h2 id="参数让模型填得对而不是填得全">参数:让模型&quot;填得对&quot;,而不是&quot;填得全&quot;</h2>
<p>参数设计的核心矛盾是:你想要灵活,模型想要明确。这两者经常打架,而你应该站在模型这边。</p>
<p><strong>第一,别用裸字符串当枚举。</strong> 一个 <code>status</code> 参数,如果你在描述里写&quot;传订单状态&quot;,模型可能传 <code>&quot;已发货&quot;</code>、<code>&quot;shipped&quot;</code>、<code>&quot;SHIPPED&quot;</code>、<code>&quot;发货中&quot;</code>——四种写法,你的代码能认几种?直接用枚举把可选值锁死:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># 反例:status 是 str,模型自由发挥</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">search_orders</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">status</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span> <span class="o">...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 正例:枚举,模型只能在合法值里选</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">enum</span> <span class="kn">import</span> <span class="n">Enum</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">OrderStatus</span><span class="p">(</span><span class="nb">str</span><span class="p">,</span> <span class="n">Enum</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">PENDING</span> <span class="o">=</span> <span class="s2">&#34;pending&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">SHIPPED</span> <span class="o">=</span> <span class="s2">&#34;shipped&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">DELIVERED</span> <span class="o">=</span> <span class="s2">&#34;delivered&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">CANCELLED</span> <span class="o">=</span> <span class="s2">&#34;cancelled&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">search_orders</span><span class="p">(</span><span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">status</span><span class="p">:</span> <span class="n">OrderStatus</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">):</span> <span class="o">...</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>第二,能有默认值就别让模型填。</strong> 每多一个必填参数,就多一个模型出错的机会。分页的 <code>page_size</code>、排序的 <code>order_by</code>,给个合理默认值,模型大多数时候根本不用碰它。</p>
<p><strong>第三,警惕&quot;看起来很像&quot;的参数。</strong> 一个工具同时收 <code>start_date</code> 和 <code>end_date</code>,模型偶尔会填反。如果业务允许,合并成一个 <code>time_range</code> 枚举(<code>last_7_days</code>、<code>last_30_days</code>、<code>this_month</code>)往往更稳——你把&quot;理解日期区间&quot;这件事从模型手里拿回来了。当然,需要精确区间时该用两个还得用两个,这是取舍,不是教条。</p>
<p>一个判断标准:<strong>如果一个参数,你自己都要想三秒才知道该填什么,模型只会比你更糊涂。</strong></p>
<h2 id="返回值给模型能用的信息不是给它一份数据库导出">返回值:给模型能用的信息,不是给它一份数据库导出</h2>
<p>这是我见过踩坑最多的地方,值得单独讲。</p>
<p>工具的返回值会<strong>原封不动进入模型的上下文窗口</strong>。这意味着两件事:一是它占 token,占的还是最贵的那部分;二是模型要从里面提取信息做下一步决策。所以返回值的设计目标只有一个——<strong>高信噪比</strong>。</p>
<p>反例长这样:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;order_id&#34;</span><span class="p">:</span> <span class="s2">&#34;ord_8f3a2b1c-9d4e-4f5a-8b6c-1d2e3f4a5b6c&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;tenant_uuid&#34;</span><span class="p">:</span> <span class="s2">&#34;tn_a1b2c3d4&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;created_at_unix&#34;</span><span class="p">:</span> <span class="mi">1747300800</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updated_at_unix&#34;</span><span class="p">:</span> <span class="mi">1747387200</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;row_version&#34;</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;status_code&#34;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;_internal_flags&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;is_migrated&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;shard&#34;</span><span class="p">:</span> <span class="mi">3</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="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>模型看到这个,得自己去想:<code>status_code: 2</code> 是什么意思?<code>created_at_unix</code> 怎么换算成人话?<code>tenant_uuid</code> 要不要在下一步带上?这些都是噪声,而且每一条都是潜在的出错点。</p>
<p>Anthropic 的原则说得很直白:<strong>返回人类可读的字段,别返回底层技术标识符。</strong> <code>name</code>、<code>status</code>、<code>created_at</code>(写成可读时间)这种字段能直接指导模型的下一步动作;<code>uuid</code>、<code>mime_type</code>、<code>row_version</code> 不能,它们只是占地方。</p>
<p>正例:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;orders&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;ord_8f3a2b1c&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;shipped&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;created_at&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-05-15 14:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;total&#34;</span><span class="p">:</span> <span class="s2">&#34;¥299.00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;items_summary&#34;</span><span class="p">:</span> <span class="s2">&#34;无线耳机 x1&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;total_count&#34;</span><span class="p">:</span> <span class="mi">47</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;showing&#34;</span><span class="p">:</span> <span class="s2">&#34;1-10&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;hint&#34;</span><span class="p">:</span> <span class="s2">&#34;还有 37 条,加 status 或更窄的时间范围可缩小结果&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>注意最后那个 <code>hint</code> 字段。<strong>返回值不只是数据,也是给模型的下一步提示。</strong> 当结果太多时,与其返回 47 条把上下文撑爆,不如返回 10 条加一句&quot;还有 37 条,这样筛&quot;。Anthropic 把这类机制叫分页、范围过滤、截断,核心思想一致:别让模型被数据淹没,主动引导它做更窄、更省 token 的查询。</p>
<p>下面这张图是返回值设计的取舍:</p>
<pre class="mermaid">flowchart TD
  A[工具拿到原始结果] --> B{结果量大吗?}
  B -->|小| C[直接返回可读字段]
  B -->|大| D[截断 + 分页]
  D --> E[附 hint:怎么缩小范围]
  C --> F[剔除 uuid/时间戳/内部 flag]
  E --> F
  F --> G[进入模型上下文]
  style F fill:#fde7c2,stroke:#e8b23c
  style E fill:#fde7c2,stroke:#e8b23c
</pre><p>橙色那两块——<strong>剔除噪声字段</strong>和<strong>附带引导提示</strong>——是最容易省略、又最影响效果的环节。</p>
<h2 id="错误怎么回错误信息是给模型的操作手册">错误怎么回:错误信息是给模型的&quot;操作手册&quot;</h2>
<p>工具调用失败是常态,不是异常。模型填错参数、查的资源不存在、触发了限流——这些每天都在发生。真正决定 Agent 韧性的,是<strong>出错之后它能不能自己爬起来</strong>。而它能不能爬起来,取决于你的错误信息写成什么样。</p>
<p>反例:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Invalid input&#34;</span><span class="p">)</span>          <span class="c1"># 模型:啥 input?哪儿错了?</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;ERR_4012&#34;</span><span class="p">}</span>                 <span class="c1"># 模型:4012 是什么我怎么知道</span>
</span></span><span class="line"><span class="cl"><span class="k">raise</span> <span class="ne">Exception</span><span class="p">(</span><span class="n">traceback</span><span class="o">...</span><span class="p">)</span>                <span class="c1"># 模型:吞掉半屏 token,然后还是不知道咋办</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>这三种回法的共同问题是:<strong>模型读完不知道下一步该干什么。</strong> 它要么放弃,要么用同样的错参数原样重试,卡进死循环。</p>
<p>好的错误信息要满足一个标准——<strong>模型读完就知道怎么改</strong>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># 正例:说清错在哪 + 给出可执行的下一步</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;参数 status 的值 &#39;发货中&#39; 不合法&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;valid_values&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;shipped&#34;</span><span class="p">,</span> <span class="s2">&#34;delivered&#34;</span><span class="p">,</span> <span class="s2">&#34;cancelled&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;hint&#34;</span><span class="p">:</span> <span class="s2">&#34;你可能想用 &#39;shipped&#39;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;未找到 user_id &#39;u_999&#39; 对应的用户&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;hint&#34;</span><span class="p">:</span> <span class="s2">&#34;确认 ID 是否正确,或先用 search_users 按用户名查到 ID&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Anthropic 的说法是:你可以<strong>对错误信息做提示工程</strong>,把它写成清晰、可执行的改进建议,而不是不透明的错误码或堆栈。一条好的错误信息会顺手告诉模型&quot;下一步该调哪个工具&quot;——上面那个 <code>search_users</code> 的提示就是。这等于把错误信息也当成了引导模型的一个入口。</p>
<p>还有个常被忽略的点:<strong>错误也要省 token。</strong> 别把整个 Python traceback 塞回去,那几百个 token 对模型几乎没有信息价值。给一句人话就够了。</p>
<h2 id="工具粒度太细太粗都不行">工具粒度:太细太粗都不行</h2>
<p>最后一个,也是最难的——工具切多大。</p>
<p><strong>切太细的坑。</strong> 把 <code>get_user</code>、<code>get_user_orders</code>、<code>get_order_detail</code> 拆成三个独立工具,听起来很&quot;单一职责&quot;。但 Agent 要回答&quot;用户最近这单到哪了&quot;,得连着调三次:第一次拿 user,第二次拿 order 列表,第三次拿 detail。三次往返,三段返回值堆进上下文,任何一步选错都得重来。<strong>工具太细,模型就被迫去干编排的活,而编排正是它最容易出错的地方。</strong></p>
<p><strong>切太粗的坑。</strong> 反过来做一个万能的 <code>manage_order</code>,靠一个 <code>action</code> 参数切换&quot;查询/创建/退款/改地址&quot;。模型每次都要先想清楚 <code>action</code> 填什么、对应又该带哪些参数,描述也长得没法读。而且一个工具权限太大,审计和兜底都难做——你没法只给某个 Agent &ldquo;查询&quot;权限而不给&quot;退款&quot;权限。</p>
<p>我的经验法则是:<strong>按&quot;用户意图&quot;切,不按&quot;数据库表&quot;切,也不按&quot;一个超级动作&quot;切。</strong></p>
<table>
  <thead>
      <tr>
          <th>切法</th>
          <th>例子</th>
          <th>问题</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>按表切(太细)</td>
          <td><code>get_user</code> / <code>get_orders</code> / <code>get_items</code></td>
          <td>模型被迫多次编排,易错</td>
      </tr>
      <tr>
          <td>按超级动作切(太粗)</td>
          <td><code>manage_order(action=...)</code></td>
          <td>参数耦合、描述爆炸、权限难控</td>
      </tr>
      <tr>
          <td><strong>按意图切(推荐)</strong></td>
          <td><code>get_order_status(order_id)</code> 一次返回订单+物流+商品摘要</td>
          <td>一次调用解决一个完整问题</td>
      </tr>
  </tbody>
</table>
<p>判断方法很简单:<strong>想象一个真实的用户问题,数一数 Agent 要调几次工具才能答上。</strong> 如果一个常见问题要调四五次,你的工具大概率切太细了;如果一个工具的描述你得写满一屏才说得清,那它八成切太粗了。</p>
<p>Anthropic 反复强调的&quot;evaluation-driven development&quot;在这里特别管用:先拿真实任务跑一批评测,看 Agent 卡在哪、绕了多少弯路,再回头调工具的粒度。工具设计不是一次写对的,是测出来、改出来的。</p>
<h2 id="几条收尾的话">几条收尾的话</h2>
<p>把上面的拆开看是五个话题,合起来其实是一个视角的转变:<strong>你不是在给程序写接口,你是在给一个会读字、会犯错、上下文有限的&quot;实习生&quot;写操作手册。</strong></p>
<p>落到日常,优先级我会这么排:</p>
<ol>
<li><strong>先治返回值。</strong> 砍掉 uuid、时间戳、内部 flag,只留可读字段。这一步零成本,收益立竿见影。</li>
<li><strong>再治错误信息。</strong> 把每条错误都改成&quot;说清错在哪 + 下一步怎么办&rdquo;。Agent 的韧性主要靠这个。</li>
<li><strong>然后理顺粒度。</strong> 按意图切,用真实任务量一量调用次数。</li>
<li><strong>最后打磨描述和参数。</strong> 加示例、上枚举、给默认值。</li>
</ol>
<p>别一上来就盯着换模型。先把你能 100% 控制的那部分——工具——做扎实了,再去谈模型选型。很多时候,中杯配一组好工具,比大杯配一组烂工具跑得稳得多,还便宜。</p>
<hr>
<p><strong>参考资料</strong></p>
<ul>
<li><a href="https://www.anthropic.com/engineering/writing-tools-for-agents">Writing effective tools for AI agents — Anthropic Engineering</a></li>
<li><a href="https://www.anthropic.com/engineering/advanced-tool-use">Introducing advanced tool use on the Claude Developer Platform — Anthropic</a></li>
<li><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents">Effective context engineering for AI agents — Anthropic</a></li>
<li><a href="https://modelcontextprotocol.info/docs/tutorials/writing-effective-tools/">Writing Effective Tools for Agents: Complete MCP Development Guide</a></li>
</ul>
]]></content:encoded></item><item><title>让 LLM 输出可靠的结构化数据</title><link>https://realtime-ai.chat/posts/structured-output/</link><pubDate>Wed, 06 May 2026 11:00:00 +0800</pubDate><guid>https://realtime-ai.chat/posts/structured-output/</guid><description>LLM 接进系统最常踩的坑,是它返回的 JSON 时好时坏。从 prompt 约束到约束解码,逐个拆解几种方案的真实代价、schema 设计与流式场景的兜底。</description><content:encoded><![CDATA[<p>你写了个 prompt,让 LLM 把一段用户评论解析成 JSON:情感、评分、关键词。本地跑了二十次,完美。上线。</p>
<p>三天后告警响了。某条响应里,LLM 在 JSON 后面多写了一句&quot;希望这个分析对你有帮助!&quot;。你的 <code>json.loads()</code> 当场抛异常,整条链路挂掉。</p>
<p>这不是小概率事件,是<strong>结构性问题</strong>。只要你还在用&quot;自由文本里夹一段 JSON&quot;的方式跟 LLM 要数据,这种崩溃就是迟早的——区别只是它发生在测试环境还是生产环境。</p>
<p>这篇讲清楚:为什么自由文本提 JSON 天生不可靠,2026 年有哪几种正经方案、各自的代价是什么,schema 怎么设计才不坑自己,以及最难的那块——流式场景下怎么拿到结构化数据。</p>
<h2 id="为什么让它输出-json本身就是错的">为什么&quot;让它输出 JSON&quot;本身就是错的</h2>
<p>先理解 LLM 在干什么。它做的是一件事:根据前面所有 token,预测下一个 token 的概率分布,然后采样。它<strong>没有</strong>&ldquo;我现在要写一个合法 JSON&quot;这种全局意识。</p>
<p>所以当你在 prompt 里写&quot;请只返回 JSON,不要有多余文字&rdquo;,你是在用一句话,对抗模型训练数据里成千上万条&quot;先解释再给结果&quot;的对话样本。大多数时候它听话,因为你的指令把概率压过去了。但只要某次采样,在该写 <code>}</code> 的位置,&ldquo;希望&quot;这个 token 的概率偶然爬到了第一,它就会写下去——而且一旦写下去,后面就会顺着&quot;希望这个分析对你有帮助&quot;这条最自然的路径滑下去。</p>
<p>常见的失败长这样:</p>
<ul>
<li>JSON 外面套了 <code>```json</code> 代码块,或者前后有一段自然语言</li>
<li>字符串里有没转义的换行、引号</li>
<li>该是数字的字段写成了 <code>&quot;4.5&quot;</code>(带引号),或者写成 <code>4.5分</code></li>
<li>嵌套对象少了一个括号,尤其是输出很长的时候</li>
<li>枚举字段返回了你没定义的值——你要 <code>positive/negative/neutral</code>,它给你个 <code>mixed</code></li>
</ul>
<p>这些都不是模型&quot;笨&rdquo;,是概率采样的必然结果。<strong>你不可能靠把 prompt 写得更恳切来根治它</strong>,你只能降低概率,没法归零。要归零,得换思路:不是请求它输出合法结构,而是从机制上让它<strong>没法输出</strong>不合法的结构。</p>
<h2 id="五种方案以及它们各自的代价">五种方案,以及它们各自的代价</h2>
<p>2026 年,从最弱到最强,实际可用的方案是这五种。关键不是&quot;哪个最好&quot;,是搞清楚每个的边界。</p>
<pre class="mermaid">flowchart TB
  A["LLM 要输出结构化数据"] --> B{"模型在哪?"}
  B -->|"闭源 API"| C{"要不要 100% 保证 schema?"}
  B -->|"自己部署的开源模型"| D["约束解码<br/>xgrammar / llguidance"]
  C -->|"要"| E["Structured Outputs / strict 工具"]
  C -->|"差不多就行"| F["JSON Mode（已是 legacy）"]
  E --> G["拿到必定合法的 JSON"]
  D --> G
  F --> H["只保证语法合法，schema 靠运气"]
  style E fill:#fde7c2,stroke:#e8b23c
  style D fill:#fde7c2,stroke:#e8b23c
</pre><p><strong>1. 纯 prompt 约束。</strong> 就是开头那种&quot;请只返回 JSON&quot;。它的唯一价值是当作其他方案的补充——把字段含义、示例写清楚能提升内容质量。但<strong>别拿它当结构保证</strong>。如果你现在生产环境还在裸用这个,这篇文章后面的部分就是为你写的。</p>
<p><strong>2. JSON Mode。</strong> OpenAI 最早的尝试,<code>response_format: {&quot;type&quot;: &quot;json_object&quot;}</code>。它保证一件事:输出是<strong>语法合法</strong>的 JSON——不会有代码块包裹,不会有多余文字,括号配对。但它<strong>不管 schema</strong>:字段名、字段类型、枚举值、必填项,一概不保证。所以你还是得做完整校验。2026 年它基本算 legacy 了,OpenAI 自己的文档也把它标成旧特性,纯 JSON Mode 在生产里早就没人用了。</p>
<p><strong>3. 约束解码 / Grammar(constrained decoding)。</strong> 这是真正解决问题的机制,也是后面几种方案底层共用的东西。原理:在每一步采样时,根据&quot;目前已经生成的部分 + 目标 schema&quot;,算出<strong>下一个 token 哪些是合法的</strong>,把所有非法 token 的概率直接屏蔽(mask 成负无穷),只在合法集合里采样。</p>
<p>举例:已经生成到 <code>{&quot;rating&quot;:</code>,那么下一个 token 只能是数字、<code>-</code>、空格——模型这一步<strong>根本采样不到</strong> <code>&quot;</code> 或者字母。它不是&quot;被劝住了&quot;,是那条路被物理封死了。</p>
<p>开源世界里这块 2026 年很成熟。<code>xgrammar</code> 是目前 vLLM、SGLang、TensorRT-LLM 的默认结构化生成后端,支持完整的上下文无关文法(JSON、正则、自定义 CFG),每 token 开销做到了 40 微秒以下,几乎不影响吞吐;<code>llguidance</code> 是另一个主力,OpenAI 2025 年公开说过自家实现的底层借鉴了它。早期的 <code>outlines</code> 用有限状态机思路,开了这个方向,但碰到递归 schema(比如树形结构、嵌套评论)会很吃力,编译能慢到几十秒甚至几分钟,递归类结构现在更推荐用 xgrammar、llguidance 这种 CFG 引擎。</p>
<p><strong>4. Structured Outputs API。</strong> 这是闭源厂商把约束解码包装成的产品功能。OpenAI 的 <code>response_format: {&quot;type&quot;: &quot;json_schema&quot;, strict: true}</code> 就是它——你传一个 JSON Schema,模型底层用约束解码,<strong>输出必定符合 schema</strong>:每个必填字段都在、类型都对、枚举值都合法。可用模型是 gpt-4o-2024-08-06 之后的版本、GPT-4.1 全系、GPT-5 和 o 系列。2026 年它是数据抽取、Agent 场景的生产默认。</p>
<p><strong>5. Function Calling / Tool Use。</strong> 你定义工具,带 input schema,模型返回一个符合 schema 的工具调用。本质上跟 Structured Outputs 是同一套约束解码机制,只是包装成了&quot;调用工具&quot;的语义。它适合两类场景:一是模型真的要去调外部 API;二是你给了多个工具让模型自己选(多 Agent、路由)。Anthropic 的 Claude 走的就是 tool use 这条路,且复杂嵌套 schema 下也很稳;Gemini 这边把 JSON 模式和结构化输出合并成了一个特性。</p>
<p>一句话总结取舍:</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>保证语法合法</th>
          <th>保证 schema</th>
          <th>适用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>纯 prompt 约束</td>
          <td>否</td>
          <td>否</td>
          <td>只用来补充内容质量,别单用</td>
      </tr>
      <tr>
          <td>JSON Mode</td>
          <td>是</td>
          <td>否</td>
          <td>已 legacy,不推荐新项目</td>
      </tr>
      <tr>
          <td>约束解码(xgrammar 等)</td>
          <td>是</td>
          <td>是</td>
          <td>自己部署开源模型</td>
      </tr>
      <tr>
          <td>Structured Outputs API</td>
          <td>是</td>
          <td>是</td>
          <td>闭源 API,要纯数据返回</td>
      </tr>
      <tr>
          <td>Function Calling</td>
          <td>是</td>
          <td>是</td>
          <td>要调外部工具,或多工具选择</td>
      </tr>
  </tbody>
</table>
<p><strong>选型其实很简单</strong>:自己部署模型,上 xgrammar;用闭源 API 且只想要一段数据,用 Structured Outputs;用闭源 API 且模型要决策调哪个工具,用 Function Calling。剩下两个,知道它们存在就行。</p>
<h2 id="schema-设计决定成败的地方往往不是代码">schema 设计:决定成败的地方往往不是代码</h2>
<p>很多人以为开了 Structured Outputs 就万事大吉了。不是。约束解码保证模型输出<strong>符合</strong>你的 schema,但如果你的 schema 设计得烂,模型会在&quot;合法&quot;的范围内给你垃圾。</p>
<p>几条我踩过坑后总结的硬规则:</p>
<p><strong>用枚举,别用开放字符串。</strong> 情感字段写成 <code>&quot;sentiment&quot;: string</code> 模型可能给你 <code>非常正面</code>、<code>positive</code>、<code>POSITIVE</code> 三种花样。写成 <code>enum: [&quot;positive&quot;, &quot;negative&quot;, &quot;neutral&quot;]</code>,约束解码会保证它只能落在这三个里。能枚举的一律枚举。</p>
<p><strong>给每个字段写 description。</strong> schema 里的 <code>description</code> 不是注释,模型会读。<code>&quot;score&quot;</code> 含糊,<code>&quot;score: 1-5 整数,5 表示强烈推荐,严格保守打分&quot;</code> 就清楚得多。约束解码管类型,不管&quot;打分准不准&quot;,后者靠 description。</p>
<p><strong>注意 strict 模式的限制。</strong> OpenAI 的 strict 模式有几条硬约束容易绊人:所有字段都必须列进 <code>required</code>(想要可选字段,得把类型写成联合类型带 <code>null</code>);不支持任意的 <code>dict[str, Any]</code>,key 不确定的字典它接不了;日期时间得用 ISO 字符串表示。设计前先翻一遍文档的限制清单,别等运行时报错。</p>
<p><strong>给模型一条&quot;我不知道&quot;的出路。</strong> 这条最容易被忽略。如果信息缺失,你又强迫模型必须填某个字段,约束解码会逼它<strong>编一个</strong>——它在合法 token 里硬凑,于是你拿到一个格式完美的幻觉。正确做法是显式留口子:加 <code>confidence</code> 字段,或者让关键字段可空,或者加一个 <code>&quot;status&quot;: [&quot;ok&quot;, &quot;insufficient_info&quot;]</code>。<strong>结构合法不等于内容可信</strong>,这是约束解码救不了你的部分。</p>
<p><strong>别一次榨太多。</strong> 一个 schema 里塞二十个字段,还层层嵌套,模型质量会肉眼可见地掉。能拆成两次调用就拆。</p>
<h2 id="出错了怎么兜底">出错了怎么兜底</h2>
<p>上了 Structured Outputs,JSON 解析层面的错确实没了。但还有别的会出问题,得有兜底。</p>
<p>第一类,<strong>API 层面的失败</strong>:超时、限流、网络抖动。这跟结构化无关,但既然你依赖一个必定返回结构的接口,它一旦不返回,你的下游就断了。退避重试,该做做。</p>
<p>第二类,<strong>约束解码碰上 token 上限</strong>。约束解码保证&quot;如果生成完成,结构一定合法&quot;,但它<strong>不保证一定能生成完成</strong>。如果 <code>max_tokens</code> 设小了,模型在一个深层嵌套里被强行截断,你拿到的是一段合法但<strong>不完整</strong>的 JSON。对策:嵌套深、字段多的 schema,把 <code>max_tokens</code> 给足;并且检查 finish reason 是不是 <code>length</code>,是的话当失败处理。</p>
<p>第三类,<strong>内容兜底</strong>——前面说的幻觉。schema 里留了 <code>confidence</code> 或 <code>status</code>,这里就要用上:低于阈值的结果不直接进库,转人工或走降级逻辑。</p>
<p>一个实战习惯:<strong>就算用了 Structured Outputs,落库前也做一次业务校验。</strong> 不是不信约束解码,是 schema 只能表达&quot;类型和结构&quot;,表达不了&quot;评分必须在 1 到 5 之间且这条订单的金额不能是负数&quot;这种业务约束。两层防线:schema 管结构,代码管语义。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># 闭源 API:Structured Outputs 保证结构,代码补业务校验</span>
</span></span><span class="line"><span class="cl"><span class="n">resp</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="o">=</span><span class="s2">&#34;gpt-4.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="p">[</span><span class="o">...</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">response_format</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;json_schema&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;json_schema&#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;review&#34;</span><span class="p">,</span> <span class="s2">&#34;strict&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;schema&#34;</span><span class="p">:</span> <span class="n">SCHEMA</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">max_tokens</span><span class="o">=</span><span class="mi">800</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="k">if</span> <span class="n">resp</span><span class="o">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">finish_reason</span> <span class="o">==</span> <span class="s2">&#34;length&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="n">TruncatedError</span><span class="p">(</span><span class="s2">&#34;被 token 上限截断,当失败重试&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">content</span><span class="p">)</span>  <span class="c1"># 这里几乎不会再抛</span>
</span></span><span class="line"><span class="cl"><span class="n">validate_business_rules</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>  <span class="c1"># 评分范围、字段间一致性等,schema 管不到</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="流式场景最难啃的一块">流式场景:最难啃的一块</h2>
<p>到这里都还好。真正难的是这个:你要<strong>流式</strong>输出,<strong>同时</strong>要结构化数据。</p>
<p>比如一个 UI,要边生成边把解析结果填进表单——名字一出来就显示名字,地址一出来就显示地址。但 LLM 是一个 token 一个 token 吐的,而 JSON 的<strong>任何中间状态都是语法非法的</strong>:</p>
<pre class="mermaid">flowchart LR
  T1["{"] --> T2["{\"name\""] --> T3["{\"name\":\"张"] --> T4["{\"name\":\"张三\"}"]
  T1 -.->|"json.loads"| X1["报错"]
  T2 -.->|"json.loads"| X2["报错"]
  T3 -.->|"json.loads"| X3["报错"]
  T4 -.->|"json.loads"| OK["成功"]
  style X1 fill:#f8d7da,stroke:#c33
  style X2 fill:#f8d7da,stroke:#c33
  style X3 fill:#f8d7da,stroke:#c33
  style OK fill:#d4edda,stroke:#3a3
</pre><p>你不能等整段 JSON 攒齐——那就退化成非流式,流式的意义没了。也不能拿 <code>json.loads()</code> 去解每个中间态——它每次都抛异常。</p>
<p>可行的有两条路:</p>
<p><strong>一是分隔符切块。</strong> 别要一个大 JSON,让模型按 <code>JSON Lines</code> 输出——每行一个独立的小对象,中间用换行分隔。每收到一个完整换行,就解析这一行。这等于把&quot;一个大结构&quot;拆成&quot;很多个小结构&quot;,每个小结构一旦完整就立刻可用。简单、稳,适合&quot;一批结果&quot;型的输出。</p>
<p><strong>二是容错增量解析。</strong> 用一个能处理<strong>残缺 JSON</strong> 的解析器,把每个中间态尽力补全成一个带类型的部分对象——<code>{&quot;name&quot;:&quot;张</code> 直接解析成 <code>{name: &quot;张&quot;}</code>,字段还没出现的就当缺失。这条路上 2026 年比较成熟的是 <code>BAML</code> 这类工具,它内置了一个容错 parser,专门把破碎的部分 JSON 实时转成带类型的对象,既保住流式的体验,又拿到渐进的结构化数据。</p>
<p>选哪条:输出是&quot;一组同类项&quot;,用 JSON Lines;输出是&quot;一个有很多字段的大对象&quot;,且 UI 要逐字段渐进填充,用容错增量解析。</p>
<p>还有个常被忽略的点:<strong>流式 + 约束解码可以同时用</strong>。约束解码是逐 token 工作的,本来就和流式天然兼容——vLLM 这类引擎流式吐 token 的同时,xgrammar 在每一步做 mask。所以&quot;用了约束解码就不能流式&quot;是个误解,两者是正交的。难的从来不是生成端,是<strong>消费端</strong>怎么解析这些中间态。</p>
<h2 id="最后把它当工程问题别当-prompt-问题">最后:把它当工程问题,别当 prompt 问题</h2>
<p>如果只留一句话:<strong>结构化输出的可靠性,不该靠 prompt 写得好,该靠机制保证。</strong></p>
<p>很多团队卡在&quot;再调调 prompt 让它别输出多余文字&quot;。这是把一个工程问题误当成了文案问题。prompt 能把失败率从 5% 压到 0.5%,但压不到 0;而约束解码这类机制能压到 0。你的优先级应该是:</p>
<ol>
<li><strong>先换机制</strong>——自部署上 xgrammar,用 API 上 Structured Outputs 或 Function Calling。这一步把&quot;JSON 语法错&quot;和&quot;schema 不符&quot;两类问题直接归零,收益最大。</li>
<li><strong>再认真设计 schema</strong>——枚举、description、给&quot;不知道&quot;留出路。约束解码管不到的内容质量,靠这一步。</li>
<li><strong>最后补业务校验和流式兜底</strong>——schema 管结构,代码管语义;流式场景按数据形态选 JSON Lines 或容错解析。</li>
</ol>
<p>机制定了下限,prompt 和 schema 决定上限。顺序别搞反。</p>
<hr>
<p>参考:
<a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI Structured Outputs 文档</a>、
<a href="https://openai.com/index/introducing-structured-outputs-in-the-api/">Introducing Structured Outputs in the API</a>、
<a href="https://github.com/mlc-ai/xgrammar">xgrammar</a>、
<a href="https://github.com/guidance-ai/llguidance">llguidance</a>、
<a href="https://developers.redhat.com/articles/2025/06/03/structured-outputs-vllm-guiding-ai-responses">Structured Outputs in vLLM</a>、
<a href="https://nitishagar.medium.com/streaming-structured-data-from-llms-is-harder-than-you-think-6f2ee976fe5f">Streaming structured data from LLMs is harder than you think</a>、
<a href="https://www.vellum.ai/blog/when-should-i-use-function-calling-structured-outputs-or-json-mode">When should I use function calling, structured outputs or JSON mode</a></p>
]]></content:encoded></item></channel></rss>