<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>推荐智能手记 — AI × 开发者博客</title>
  <subtitle>GLM API、Node.js 大模型接入与 AI 工程实践原创文章</subtitle>
  <link href="https://www.githubmissyang.cn/blog" rel="alternate" type="text/html"/>
  <link href="https://www.githubmissyang.cn/feed.xml" rel="self" type="application/atom+xml"/>
  <id>https://www.githubmissyang.cn/feed.xml</id>
  <updated>2026-09-27T11:50:45.304Z</updated>
  <author><name>推荐智能手记</name></author>
  <rights>© 2026 推荐智能手记</rights>
  <entry>
    <title>技术文章过期检测：版本监控、复审队列与更新策略</title>
    <link href="https://www.githubmissyang.cn/articles/technical-content-freshness-version-monitoring-review-queue" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/technical-content-freshness-version-monitoring-review-queue</id>
    <updated>2026-08-13T15:30:00.000Z</updated>
    <published>2026-08-13T15:30:00.000Z</published>
    <summary>建立可执行的技术文章过期检测机制：用 reviewedAt 与 nextReviewAt 管理复审周期，监听官方版本变更，自动检查链接和代码，并判断更新旧 URL 还是发布新文章。</summary>
    <content type="html"><![CDATA[<h1>技术文章过期检测：版本监控、复审队列与更新策略</h1>
<p>技术文章最危险的状态，不是明显报错，而是<strong>看起来仍然合理，关键步骤却已经失效</strong>。模型名下线、SDK 参数改名、官方页面迁移、示例依赖升级，都可能让一篇曾经正确的教程逐渐误导读者。只在文章底部写一个发布时间，无法回答它现在是否仍可信。</p>
<p>可持续的技术内容需要一条维护流水线：记录上次核验时间，监听外部变化，自动执行低成本检查，把风险文章送入人工复审队列，再根据搜索意图决定更新原 URL 还是另写新文。本文给出一套可落地的 <strong>技术文章过期检测</strong> 方法，并明确一个容易被滥用的边界：不能只改日期，假装内容已经更新。</p>
<h2>一、先区分“时间旧”和“内容过期”</h2>
<p>文章发布得早，不等于内容错误；昨天发布，也可能引用了已经废弃的接口。过期判断应基于证据，而不是只计算文章年龄。可以把信号分为四类：</p>
<ul>
<li><strong>时间信号</strong>：超过计划复审日期，尚未重新核验；</li>
<li><strong>依赖信号</strong>：模型、API、SDK、框架或运行时发布新版本，或者旧版本进入弃用周期；</li>
<li><strong>可执行信号</strong>：链接失效、代码构建失败、响应结构变化、截图与当前界面不一致；</li>
<li><strong>用户信号</strong>：站内搜索无结果、读者反馈、错误日志或客服问题指向同一篇文章。</li>
</ul>
<p>时间信号适合提醒，其他三类更接近真实风险。维护系统不应在 <code>nextReviewAt</code> 到期时自动修改正文，而应生成待办并保留检测证据。</p>
<h2>二、为每篇文章保存 reviewedAt 与 nextReviewAt</h2>
<p>最小内容记录至少包含：</p>
<pre><code class="language-json">{
  &quot;contentVersion&quot;: &quot;1.3&quot;,
  &quot;publishedAt&quot;: &quot;2026-05-10T02:00:00.000Z&quot;,
  &quot;updatedAt&quot;: &quot;2026-07-18T08:30:00.000Z&quot;,
  &quot;reviewedAt&quot;: &quot;2026-07-18T08:30:00.000Z&quot;,
  &quot;nextReviewAt&quot;: &quot;2026-10-18&quot;,
  &quot;reviewedBy&quot;: &quot;editor-id&quot;,
  &quot;reviewStatus&quot;: &quot;verified&quot;
}
</code></pre>
<p>这些字段职责不同：</p>
<ul>
<li><code>publishedAt</code> 是首次发布时刻，通常不应改写；</li>
<li><code>updatedAt</code> 表示正文或关键页面信息最近一次实质修改；</li>
<li><code>reviewedAt</code> 表示最近一次完成事实、链接和示例核验；</li>
<li><code>nextReviewAt</code> 是内部任务计划，不是搜索引擎指令；</li>
<li><code>contentVersion</code> 用于关联修改记录、测试结果和回滚。</li>
</ul>
<p>只打开文章看一眼，不应直接把 <code>reviewedAt</code> 往后推。复审动作应有检查项与结论，例如“官方 API 文档已核对、三个外链可访问、示例在 Node.js 22 执行通过”。如果检查失败，将状态改为 <code>needs_update</code> 或 <code>blocked</code>，而不是继续显示“已验证”。</p>
<p>复审频率按变化风险设置，比全站统一 90 天更合理。例如模型 API、价格和 SDK 教程可每月或每季度检查；基础算法概念可以半年或一年检查；出现官方弃用公告、代码失败或读者反馈时则立即复审。这里的周期只是站内策略，不是 SEO 排名规则。</p>
<h2>三、从正文提取依赖，监听官方版本变更</h2>
<p>版本监控不能只订阅新闻。发布时应把文章依赖转成结构化清单：</p>
<pre><code class="language-json">{
  &quot;articleSlug&quot;: &quot;openai-compatible-multi-model-config-nodejs&quot;,
  &quot;dependencies&quot;: [
    { &quot;type&quot;: &quot;npm&quot;, &quot;name&quot;: &quot;openai&quot;, &quot;tested&quot;: &quot;5.x&quot; },
    { &quot;type&quot;: &quot;runtime&quot;, &quot;name&quot;: &quot;node&quot;, &quot;tested&quot;: &quot;22.x&quot; },
    { &quot;type&quot;: &quot;api&quot;, &quot;name&quot;: &quot;chat-completions&quot;, &quot;tested&quot;: &quot;2026-08&quot; }
  ]
}
</code></pre>
<p>监控来源优先使用官方渠道：GitHub Releases API、官方 changelog、npm 包元数据、产品弃用公告和状态页。<code>npm outdated</code> 可以辅助发现本地依赖版本差异，但“出现新版本”并不等于文章已经过期。系统应先比较变化类型：</p>
<ol>
<li>补丁更新且没有影响示例的变更，记录事件即可；</li>
<li>次版本新增能力，检查文章是否需要补充；</li>
<li>主版本、弃用通知或接口字段变化，提高复审优先级；</li>
<li>文章指定的版本仍受支持且搜索意图是旧版维护，可保留并明确版本范围。</li>
</ol>
<p>不要让模型只根据版本号自动重写文章。模型可以总结 changelog、定位可能受影响的段落，但最终结论必须回到官方说明和实际测试。多模型配置的具体实现可以参考<a href="/articles/openai-compatible-multi-model-config-nodejs">OpenAI 兼容 API 多模型统一配置</a>，其中的 provider、模型名和端点都应该成为可监控依赖。</p>
<h2>四、链接检查不仅看 HTTP 200</h2>
<p>定时任务可从 Markdown、HTML 和 <code>sources</code> 数组提取 URL，检查以下状态：</p>
<ul>
<li>404、410：目标已移除，应查找官方替代页；</li>
<li>301、308：记录最终地址，确认不是跳到无关首页；</li>
<li>401、403、429：可能是权限或反爬限制，不能直接判定内容不存在；</li>
<li>200：仍要检查最终 URL、内容类型和页面标题；</li>
<li>超时、DNS、TLS 错误：进入重试队列，连续失败后人工确认。</li>
</ul>
<p>一个谨慎的检查器会限制并发、设置超时、遵守站点规则，并保留检测时间与最终跳转地址：</p>
<pre><code class="language-js">async function inspectLink(url) {
  const startedAt = Date.now();
  try {
    const response = await fetch(url, {
      method: &#39;HEAD&#39;,
      redirect: &#39;follow&#39;,
      signal: AbortSignal.timeout(8000),
      headers: { &#39;user-agent&#39;: &#39;ContentFreshnessBot/1.0&#39; }
    });

    return {
      url,
      status: response.status,
      finalUrl: response.url,
      checkedAt: new Date().toISOString(),
      durationMs: Date.now() - startedAt
    };
  } catch (error) {
    return { url, error: error.name, checkedAt: new Date().toISOString() };
  }
}
</code></pre>
<p>有些服务器不支持 <code>HEAD</code>，出现 405 时需要用受限的 <code>GET</code> 复查。链接变更后还要确认新页面确实支持原文结论；把失效来源换成主题相近的页面，并不能完成事实核验。站内链接则应在每次构建时检查，具体的孤儿页与锚文本治理可参考<a href="/articles/technical-blog-internal-link-topic-cluster-anchor-orphan-pages">技术博客文章内链设计</a>。</p>
<h2>五、代码回归要固定环境、输入与断言</h2>
<p>代码块语法正确，不代表教程还能完成目标。可执行示例应拆成独立 fixture，记录运行环境和期望结果，并在依赖变更或复审时执行：</p>
<pre><code class="language-js">import assert from &#39;node:assert/strict&#39;;
import { normalizeModelConfig } from &#39;./example.mjs&#39;;

const result = normalizeModelConfig({
  provider: &#39;compatible&#39;,
  baseUrl: &#39;https://example.invalid/v1/&#39;,
  model: &#39;demo-model&#39;
});

assert.equal(result.baseUrl, &#39;https://example.invalid/v1&#39;);
assert.equal(result.model, &#39;demo-model&#39;);
</code></pre>
<p>回归记录应包含 commit、Node.js 与依赖版本、测试命令、退出码以及失败摘要。涉及真实 API 时，不要把密钥写进仓库；使用隔离测试账号、最小请求和费用上限。无法稳定调用外部服务时，可以分别做 schema 校验、模拟响应测试和少量人工连通测试。</p>
<p>当回归失败时，先区分文章错误、示例仓库错误和上游临时故障。对 AI 接口的 429、超时与 5xx，可以沿用<a href="/articles/llm-api-429-timeout-5xx-retry-nodejs">大模型 API 429、超时和 5xx ]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="技术文章过期检测"/>
    <category term="内容维护"/>
    <category term="版本监控"/>
    <category term="SEO"/>
    <category term="Node.js"/>
  </entry>
  <entry>
    <title>AI 内容管线可观测性：如何记录模型、Token、耗时、Trace 与告警</title>
    <link href="/articles/ai-content-pipeline-observability-logs-metrics-traces-alerts" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-content-pipeline-observability-logs-metrics-traces-alerts</id>
    <updated>2026-08-13T14:00:00.000Z</updated>
    <published>2026-08-13T14:00:00.000Z</published>
    <summary>为 AI 内容管线建立可审计的日志、指标、追踪与告警：记录实际使用模型、Token、阶段耗时和失败类型，用 Trace ID 串联抓取到发布，并依据真实历史基线设定阈值。</summary>
    <content type="html"><![CDATA[<h1>AI 内容管线可观测性：如何记录模型、Token、耗时、Trace 与告警</h1>
<p>一条 AI 内容管线可能依次经过抓取、清洗、去重、分类、总结、审核和发布。页面最后只显示一篇文章，但后台真正需要回答的是：这次总结实际用了哪个模型？消耗了多少 Token？慢在抓取还是生成？一次请求失败后切换过模型吗？发布异常能否追回最初的输入批次？</p>
<p>如果记录里只有一行 <code>summary failed</code>，就算任务最终重试成功，也无法判断成本、质量和稳定性。本篇聚焦 <strong>AI 内容管线可观测性</strong>：用日志、指标、追踪和告警还原每次真实执行。任务如何入队和重试可参考<a href="/articles/ai-content-pipeline-sync-vs-job-queue">AI 内容流水线与 BullMQ 任务队列对比</a>；429、超时和 5xx 的处置则见<a href="/articles/llm-api-429-timeout-5xx-retry-nodejs">大模型 API 429、超时和 5xx 排查</a>。</p>
<h2>一、先定义要回答的问题，而不是先装监控组件</h2>
<p>可观测性不是“日志越多越好”。设计前先列出生产排查必须回答的问题：</p>
<ol>
<li>哪个业务任务、哪一篇文章、哪个阶段出了问题？</li>
<li>配置选中的模型与请求实际命中的模型是否一致？</li>
<li>是否发生过重试、轮询、降级或故障切换？</li>
<li>输入、输出与总 Token 是多少，供应商是否返回可信用量？</li>
<li>模型调用耗时、完整任务耗时和排队等待分别是多少？</li>
<li>失败属于配置、限流、网络、HTTP、解析、质量门禁还是发布？</li>
<li>同类异常影响一个任务、一个模型，还是整个管线？</li>
<li>记录能否排查问题，同时不泄露 API Key、提示词与用户内容？</li>
</ol>
<p>这组问题决定字段和信号。日志适合保留离散事件与上下文，指标适合看趋势和触发告警，Trace 适合还原跨阶段路径。三者应通过同一个 <code>traceId</code> 或业务任务 ID 关联，而不是各自生成互不相干的编号。</p>
<h2>二、记录“实际使用模型”，不能只读当前配置</h2>
<p>后台配置会变化。任务开始时选择了模型 A，执行中可能重试到模型 B；管理员随后又把默认模型改成 C。如果任务详情只读取当前配置，它会错误地显示“本次使用 C”。</p>
<p>每次执行应保存不可变快照，至少区分四个概念：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
<th>示例用途</th>
</tr>
</thead>
<tbody><tr>
<td><code>configuredModelId</code></td>
<td>任务创建时选中的内部配置 ID</td>
<td>追查当时的配置选择</td>
</tr>
<tr>
<td><code>attemptedModelId</code></td>
<td>本次尝试准备调用的配置 ID</td>
<td>分析轮询与重试路径</td>
</tr>
<tr>
<td><code>requestedModel</code></td>
<td>发给接口的 <code>model</code> 值</td>
<td>发现模型名配置错误</td>
</tr>
<tr>
<td><code>responseModel</code></td>
<td>响应中供应商返回的模型值</td>
<td>确认实际服务版本；无值则记 <code>null</code></td>
</tr>
</tbody></table>
<p>内部配置 ID 比展示名称稳定。供应商、base URL 主机、模型名、配置版本可以保存；API Key 和完整鉴权头不能保存。base URL 如果包含租户、部署或查询参数，也应先规范化，只保留排查所需部分。</p>
<p>一次调用的结构化记录可以长这样：</p>
<pre><code class="language-json">{
  &quot;event&quot;: &quot;ai_model_attempt_finished&quot;,
  &quot;traceId&quot;: &quot;4bf92f3577b34da6a3ce929d0e0e4736&quot;,
  &quot;jobId&quot;: &quot;daily-2026-08-13&quot;,
  &quot;stage&quot;: &quot;summarize&quot;,
  &quot;attempt&quot;: 2,
  &quot;providerConfigId&quot;: &quot;model-config-02&quot;,
  &quot;requestedModel&quot;: &quot;example-model&quot;,
  &quot;responseModel&quot;: null,
  &quot;outcome&quot;: &quot;timeout&quot;,
  &quot;durationMs&quot;: 30124
}
</code></pre>
<p>这里的 <code>responseModel: null</code> 表示供应商没有返回或响应未完成，不能用请求模型回填并伪装成响应事实。多模型配置和轮询策略可继续阅读<a href="/articles/openai-compatible-multi-model-config-nodejs">OpenAI 兼容 API 多模型统一配置</a>。</p>
<h2>三、日志：每个阶段记录开始、结果和决策</h2>
<p>建议为抓取、去重、分类、总结、审核、发布定义统一事件，而不是让每个模块自由拼字符串：</p>
<ul>
<li><code>pipeline_started</code>：任务来源、配置版本和输入批次；</li>
<li><code>stage_started</code>、<code>stage_finished</code>：阶段名、耗时、输入与输出数量；</li>
<li><code>ai_model_attempt_started</code>、<code>ai_model_attempt_finished</code>：模型快照、尝试序号、用量和结果；</li>
<li><code>model_route_decided</code>：选择、轮询、降级或切换的原因代码；</li>
<li><code>quality_gate_finished</code>：通过、拒绝或进入人工审核，以及规则版本；</li>
<li><code>article_published</code>：文章 ID、slug 和内容版本；</li>
<li><code>pipeline_finished</code>：最终状态、总耗时和失败阶段。</li>
</ul>
<p>错误不要只保留 <code>message</code>，应使用有限枚举的 <code>errorType</code>：</p>
<pre><code class="language-js">const ERROR_TYPES = new Set([
  &#39;auth&#39;, &#39;invalid_request&#39;, &#39;rate_limit&#39;, &#39;timeout&#39;,
  &#39;network&#39;, &#39;provider_5xx&#39;, &#39;response_parse&#39;,
  &#39;quality_rejected&#39;, &#39;storage&#39;, &#39;publish&#39;, &#39;unknown&#39;
]);
</code></pre>
<p>同时保存供应商状态码和经过清洗的错误代码。这样既能按稳定维度聚合，又不会丢失定位线索。不要把原始异常文本直接作为指标标签：它可能含敏感内容，而且高基数会让时序数据库产生大量序列。</p>
<h2>四、Token：以响应事实为准，缺失就是缺失</h2>
<p>支持用量字段的接口通常会提供输入、输出和总 Token，但字段名与口径可能不同。接入层应先保留供应商原始值，再映射到内部字段：</p>
<pre><code class="language-js">function normalizeUsage(rawUsage) {
  if (!rawUsage) {
    return { inputTokens: null, outputTokens: null, totalTokens: null, source: &#39;missing&#39; };
  }

  return {
    inputTokens: rawUsage.prompt_tokens ?? rawUsage.input_tokens ?? null,
    outputTokens: rawUsage.completion_tokens ?? rawUsage.output_tokens ?? null,
    totalTokens: rawUsage.total_tokens ?? null,
    source: &#39;provider_response&#39;
  };
}
</code></pre>
<p>需要注意：</p>
<ul>
<li>流式接口的用量可能只出现在结束事件，连接中断时可能拿不到；</li>
<li>本地 tokenizer 的估算可用于请求前限长，但必须标为 <code>estimated</code>，不能混进供应商返回的实测值；</li>
<li>不同供应商对缓存 Token、推理 Token、多模态输入的计量口径不同；</li>
<li>成本应根据当时的价格版本单独计算，不能用今天的价格回算并声称是历史账单；</li>
<li>若请求失败但供应商可能已经处理，Token 记录应为未知，不能直接写零。</li>
</ul>
<p>建议同时记录输入条目数、输出字符数等非计费指标，它们能帮助发现“输入数量没变但 Token 突增”的模板或内容异常，但不能替代官方计费用量。</p>
<h2>五、指标：控制标签基数，分别看阶段与模型</h2>
<p>一组实用的最小指标可以包括：</p>
<pre><code class="language-text">ai_pipeline_runs_total{result,failed_stage}
ai_pipeline_stage_duration_seconds{stage,result}
ai_model_requests_total{provider,model,result,error_type}
ai_model_request_duration_seconds{provider,model,result}
ai_model_tokens_total{provider,model,direction,usage_source}
ai_pipeline_items_total{stage,direction}
ai_pipeline_inflight{stage}
ai_pipeline_queue_age_seconds
</code></pre>
<p><code>articleId</code>、<code>jobId</code>、<code>traceId</co]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 内容管线"/>
    <category term="可观测性"/>
    <category term="OpenTelemetry"/>
    <category term="结构化日志"/>
    <category term="告警"/>
  </entry>
  <entry>
    <title>生产环境 Prompt 版本管理：变更记录、灰度、评估与回滚</title>
    <link href="https://www.githubmissyang.cn/articles/production-prompt-version-management-canary-evaluation-rollback" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/production-prompt-version-management-canary-evaluation-rollback</id>
    <updated>2026-08-13T12:00:00.000Z</updated>
    <published>2026-08-13T12:00:00.000Z</published>
    <summary>在 Node.js AI 内容管线中，把 Prompt 当作可发布的软件制品管理：为版本建立不可变 ID，记录输入哈希与配置快照，通过确定性灰度、离线评估和审计日志控制风险，并支持一键回滚。</summary>
    <content type="html"><![CDATA[<h1>生产环境 Prompt 版本管理：变更记录、灰度、评估与回滚</h1>
<p>Prompt 一旦进入生产环境，就不再只是写在代码里的几段文字。它和模型、采样参数、输出结构共同决定内容管线的行为。只修改一句指令，也可能让分类标签漂移、JSON 解析失败，或让文章摘要的事实边界发生变化。</p>
<p>因此，生产环境的 <strong>Prompt 版本管理</strong> 应解决五个问题：这次请求用了哪个版本、版本包含什么配置、哪些流量收到了新版本、结果是否通过评估，以及出现问题时能否快速恢复。</p>
<p>本文以 Node.js AI 内容管线为例，给出一套不依赖特定模型厂商的实现框架。它关注发布控制和可追溯性，不重复讨论总结质量指标的具体设计；关于评分样本和回归集，可继续阅读<a href="/articles/ai-summary-quality-evaluation-regression-testing">AI 总结质量评估与回归测试</a>。</p>
<h2>一、不要用 <code>latest</code> 作为版本</h2>
<p><code>latest</code>、<code>production</code> 只是可变别名，不能回答历史请求究竟执行了什么。每个可发布 Prompt 应有不可变的 <code>promptVersionId</code>，并把实际内容、变量定义和运行配置固化为快照。</p>
<p>一个可读且稳定的版本 ID 可以由业务名、语义版本和内容短哈希组成：</p>
<pre><code class="language-js">import { createHash } from &#39;node:crypto&#39;;

function stableJson(value) {
  if (Array.isArray(value)) return `[${value.map(stableJson).join(&#39;,&#39;)}]`;
  if (value &amp;&amp; typeof value === &#39;object&#39;) {
    return `{${Object.keys(value).sort().map(
      key =&gt; `${JSON.stringify(key)}:${stableJson(value[key])}`
    ).join(&#39;,&#39;)}}`;
  }
  return JSON.stringify(value);
}

function sha256(value) {
  return createHash(&#39;sha256&#39;).update(value).digest(&#39;hex&#39;);
}

function buildPromptVersion(spec) {
  const snapshot = stableJson(spec);
  const digest = sha256(snapshot);
  return {
    promptVersionId: `${spec.name}@${spec.version}+${digest.slice(0, 12)}`,
    promptHash: digest,
    snapshot
  };
}
</code></pre>
<p>语义版本方便人阅读，哈希负责验证内容是否被静默修改。已经发布的版本应只读；需要改一个标点，也创建新版本，而不是覆盖旧记录。</p>
<h2>二、配置快照必须覆盖完整执行上下文</h2>
<p>只保存 system prompt 不足以复现一次调用。建议快照至少包含：</p>
<ul>
<li>system、user 模板及模板变量的 schema；</li>
<li>模型供应商、模型名、接口协议版本；</li>
<li>temperature、top_p、max_tokens 等采样参数；</li>
<li>JSON Schema、解析器版本及后处理规则；</li>
<li>安全规则、分类词表、知识库或检索配置版本；</li>
<li>创建人、评审人、变更原因和关联工单。</li>
</ul>
<p>API Key 不应进入快照。只记录密钥引用名或供应商配置 ID，敏感值继续保存在密钥系统中。模型名也不能只从当前后台配置反查，因为后台配置随后可能变化。</p>
<p>在每次任务入队时，把 <code>promptVersionId</code> 写入任务，而不是等 Worker 执行时读取 <code>production</code> 别名。这样即使发布切换发生在排队期间，同一任务仍使用入队时确定的版本。</p>
<h2>三、输入哈希让请求可以核对，而不是泄露原文</h2>
<p>审计日志需要判断两次调用是否针对同一输入，但原始文章可能包含未发布内容或个人信息。可以对规范化输入计算 <code>inputHash</code>：</p>
<pre><code class="language-js">function normalizeInput(input) {
  return input.normalize(&#39;NFKC&#39;).replace(/\r\n/g, &#39;\n&#39;).trim();
}

function inputHash(input) {
  return sha256(normalizeInput(input));
}
</code></pre>
<p>哈希不是匿名化的万能方案：短文本和可枚举内容仍可能被猜测。生产日志应遵守最小化原则，正文放在受控存储中，审计表只保存哈希、对象 ID、长度和必要的脱敏元数据。若需要跨环境防止字典猜测，可使用服务端 HMAC，并把密钥独立保管。</p>
<p>一次调用建议关联以下标识：</p>
<pre><code class="language-json">{
  &quot;requestId&quot;: &quot;req_...&quot;,
  &quot;articleId&quot;: &quot;article_...&quot;,
  &quot;inputHash&quot;: &quot;sha256:...&quot;,
  &quot;promptVersionId&quot;: &quot;article-summary@2.1.0+...&quot;,
  &quot;modelConfigId&quot;: &quot;glm-flash-prod-3&quot;,
  &quot;releaseId&quot;: &quot;rel_...&quot;
}
</code></pre>
<p>这组字段可以把内容对象、输入、Prompt、模型配置和发布动作串成一条审计链。</p>
<h2>四、用确定性分桶做灰度</h2>
<p>灰度发布不能用每次请求都重新抽签的 <code>Math.random()</code>。否则同一篇文章重试时可能在新旧版本之间跳动，结果难以比较。应对稳定业务键做确定性分桶：</p>
<pre><code class="language-js">function bucket(key, releaseId) {
  const hex = sha256(`${releaseId}:${key}`).slice(0, 8);
  return Number.parseInt(hex, 16) % 10000;
}

function selectPrompt({ articleId, release }) {
  const inCanary = bucket(articleId, release.id) &lt; release.canaryBasisPoints;
  return inCanary ? release.candidateVersionId : release.baselineVersionId;
}
</code></pre>
<p><code>canaryBasisPoints</code> 用万分比表示灰度比例。发布记录还应支持白名单，让内部样本和指定文章优先进入候选版本。分桶键通常选文章 ID 或任务所属租户；选择 requestId 会失去重试黏性。</p>
<p>发布过程可以按“内部样本 → 小比例 → 扩大比例 → 全量”推进，但具体阈值必须由真实流量、风险等级和样本量决定，不应照搬固定数字。每次扩大灰度都生成审计事件，不直接修改一条没有历史的配置。</p>
<h2>五、评估要绑定版本和同一批样本</h2>
<p>灰度期间至少同时观察三类信号：</p>
<ol>
<li><strong>系统可靠性</strong>：请求成功、超时、限流、解析与 schema 校验结果；</li>
<li><strong>内容约束</strong>：必填字段、引用格式、长度、敏感词和分类合法性；</li>
<li><strong>业务质量</strong>：人工审核结果及由团队定义的任务指标。</li>
</ol>
<p>比较基线版与候选版时，应使用相同或可比的输入集合，并把评估规则版本一并记录。否则，流量主题变化可能被误认为 Prompt 改进。线上监控负责发现风险，离线固定回归集负责减少样本差异；两者不能互相替代。</p>
<p>如果模型供应商、模型版本或后处理器同时变化，就无法把结果变化单独归因给 Prompt。必须同时变更时，应把它们归入同一个发布单元，并在结论中明确这是组合变更。多模型配置方式可参考<a href="/articles/openai-compatible-multi-model-config-nodejs">OpenAI 兼容 API 多模型统一配置</a>。</p>
<h2>六、回滚切换别名，不删除问题版本</h2>
<p>回滚的核心是让新的任务重新指向已验证的基线版本。不要删除候选版本或改写历史日志，因为它们是定位问题的证据。</p>
<pre><code class="language-js">async function rollbackRelease(store, releaseId, actor, reason) {
  const release = await store.getRelease(releaseId);
  await store.transaction(async tx =&gt; {
    await tx.setAlias(release.promptName, &#39;production&#39;, release.baselineVersionId);
    await tx.disableRelease(releaseId);
    await tx.appendAudit({
      type: &#39;prompt.release.rolled_back&#39;,
      releaseId,
      from: release.candidateVersionId,
      to: release.baselineVersionId,
      actor,
      reason,
      occurredAt: new Date().toISOString()
    });
  });
}
</code></pre>
<p>回滚只影响尚未锁定版本的新任务。对于已经入队、执行中或等待人工审核的内容，需要明确策略：继续完成、取消重跑，还是标记为候选版本产物等待复核。该策略应进入操作手册，避免事故发生后临时决定。</p>
<h2>七、审计日志应追加，不应覆盖</h2>
<p>建议将版]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Prompt 版本管理"/>
    <category term="Node.js"/>
    <category term="AI 内容管线"/>
    <category term="灰度发布"/>
    <category term="可观测性"/>
  </entry>
  <entry>
    <title>AI 内容去重实战：URL 规范化、标题相似度与内容指纹</title>
    <link href="https://www.githubmissyang.cn/articles/ai-content-dedup-nodejs-url-title-fingerprint" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-content-dedup-nodejs-url-title-fingerprint</id>
    <updated>2026-08-13T12:00:00.000Z</updated>
    <published>2026-08-13T12:00:00.000Z</published>
    <summary>面向 Node.js 抓取与内容管线，组合 URL 规范化、标题相似度、正文指纹、幂等写入和人工复核，建立可追溯的分层去重流程，并说明 canonical 不能替代数据去重与唯一约束。</summary>
    <content type="html"><![CDATA[<h1>AI 内容去重实战：URL 规范化、标题相似度与内容指纹</h1>
<p>一个内容管线同时抓取 RSS、站点列表页和公众号精选时，同一篇文章很容易以不同形式进入系统：URL 带着不同跟踪参数、标题增加栏目后缀、正文被转载或更新，定时任务重跑也可能再次写入。若直接把每条抓取结果交给模型总结，不仅浪费调用资源，还会让文章列表、专题页和站点地图出现重复内容。</p>
<p>可靠的去重不能只靠一个规则。更适合工程落地的方案是分层判断：先用规范 URL 拦截确定重复，再用标题相似度筛选候选，最后用正文指纹提供内容证据；无法确定的条目进入人工复核，而不是静默删除。</p>
<p>本文聚焦 <strong>AI 内容去重 Node.js</strong> 管线。任务队列与抓取解耦可参考<a href="/articles/ai-content-pipeline-sync-vs-job-queue">AI 内容流水线：同步脚本还是任务队列</a>，数据持久化升级可继续阅读<a href="/articles/nodejs-content-pipeline-json-to-sqlite-migration">Node.js 内容管线从 JSON 迁移到 SQLite</a>。</p>
<h2>一、先区分数据去重和搜索引擎 canonical</h2>
<p><code>rel=&quot;canonical&quot;</code> 用来向搜索引擎表达一组相似页面中偏好的规范网址。它是索引信号，不会阻止你的抓取程序保存两条记录，也不会替数据库执行唯一约束。</p>
<p>两者职责应明确分开：</p>
<ul>
<li><strong>管线去重</strong>：决定一条抓取结果是新增、更新、疑似重复还是跳过；</li>
<li><strong>数据库幂等</strong>：确保相同任务重试不会重复插入；</li>
<li><strong>站内 canonical</strong>：文章详情页输出唯一、可访问的首选 URL；</li>
<li><strong>重定向</strong>：旧 slug 或别名确认迁移后，用 301 指向规范页面；</li>
<li><strong>站点地图与内链</strong>：只使用规范 URL，避免持续向搜索引擎发送冲突信号。</li>
</ul>
<p>因此，页面已经有 canonical，并不代表后台可以不做去重。相反，如果后台误合并两篇不同文章，再正确的 canonical 也无法找回被覆盖的数据。</p>
<h2>二、建立稳定的规范 URL</h2>
<p>URL 第一层适合处理确定性重复，例如 <code>utm_source</code> 不同、主机名大小写差异、默认端口或 fragment 不同。Node.js 的 <code>URL</code> 类可以承担解析工作：</p>
<pre><code class="language-js">const TRACKING_PARAMS = new Set([
  &#39;utm_source&#39;, &#39;utm_medium&#39;, &#39;utm_campaign&#39;,
  &#39;utm_term&#39;, &#39;utm_content&#39;, &#39;spm&#39;, &#39;from&#39;
]);

export function normalizeUrl(input) {
  const url = new URL(input);
  url.hash = &#39;&#39;;
  url.hostname = url.hostname.toLowerCase();

  for (const key of [...url.searchParams.keys()]) {
    if (TRACKING_PARAMS.has(key.toLowerCase())) {
      url.searchParams.delete(key);
    }
  }

  url.searchParams.sort();
  if (url.pathname !== &#39;/&#39;) {
    url.pathname = url.pathname.replace(/\/+$/, &#39;&#39;);
  }
  return url.toString();
}
</code></pre>
<p>不要全局删除所有查询参数。<code>?page=2</code>、<code>?lang=en</code>、文章 ID 或版本参数可能对应不同内容。更稳妥的做法是维护通用跟踪参数白名单，再为已确认的来源配置站点级规则。</p>
<p>还要单独保存 <code>sourceUrl</code> 和 <code>normalizedUrl</code>。前者保留证据，后者用于查重；不要为了规范化而丢失原始地址。</p>
<h2>三、用标题相似度寻找候选，而不是直接删除</h2>
<p>完全相同的标题可以快速匹配，但转载标题常会增加前后缀，例如“深度解读”“今日精选”或媒体名称。可以先做标题清洗，再使用 token 集合的 Jaccard 相似度筛选候选：</p>
<pre><code class="language-js">function normalizeTitle(value) {
  return String(value)
    .normalize(&#39;NFKC&#39;)
    .toLowerCase()
    .replace(/[\s\p{P}\p{S}]+/gu, &#39; &#39;)
    .trim();
}

function bigrams(value) {
  const text = normalizeTitle(value).replace(/\s+/g, &#39;&#39;);
  const result = new Set();
  for (let i = 0; i &lt; text.length - 1; i += 1) {
    result.add(text.slice(i, i + 2));
  }
  return result;
}

function jaccard(left, right) {
  const a = bigrams(left);
  const b = bigrams(right);
  if (!a.size &amp;&amp; !b.size) return 1;
  const intersection = [...a].filter(token =&gt; b.has(token)).length;
  return intersection / new Set([...a, ...b]).size;
}
</code></pre>
<p>这个分数只能表达字符片段的重合程度，不能证明语义相同。“模型 A 对比模型 B”和“模型 B 对比模型 A”可能高度相似，但文章观点、时间或测试版本不同。标题分数适合缩小正文比对范围，不适合单独触发永久删除。</p>
<p>阈值也不应凭感觉写死。应从本站历史文章中抽取一批候选，由编辑标注“重复、相关但不同、完全不同”，再观察不同阈值下的误判和漏判。没有完成标注前，应将中间区域全部送入复核。</p>
<h2>四、正文指纹提供更强的内容证据</h2>
<p>对正文去除 HTML、导航、空白差异和常见模板后，可以计算 SHA-256。完全一致的清洗正文会得到相同指纹：</p>
<pre><code class="language-js">import { createHash } from &#39;node:crypto&#39;;

export function contentFingerprint(text) {
  const normalized = String(text)
    .normalize(&#39;NFKC&#39;)
    .replace(/&lt;[^&gt;]+&gt;/g, &#39; &#39;)
    .replace(/\s+/g, &#39; &#39;)
    .trim();

  return createHash(&#39;sha256&#39;)
    .update(normalized, &#39;utf8&#39;)
    .digest(&#39;hex&#39;);
}
</code></pre>
<p>密码学哈希适合判断规范化后完全一致的正文，但只改一个字，哈希也会完全不同。转载时增加编辑导语、免责声明或相关推荐，单一 SHA-256 就无法识别近似重复。</p>
<p>工程上可以同时保存：</p>
<ol>
<li><code>contentHash</code>：完整清洗正文的精确指纹；</li>
<li><code>paragraphHashes</code>：去重后主要段落的指纹集合；</li>
<li><code>contentLength</code>：辅助发现抓取缺失或模板污染；</li>
<li><code>extractorVersion</code>：记录正文清洗规则版本。</li>
</ol>
<p>比较段落集合可以识别“正文大部分相同但前后增加模板”的情况。若要引入 SimHash、MinHash 或向量相似度，也应把它们当作候选信号，并通过标注语料验证，不能把相似度直接包装成事实。</p>
<h2>五、组合证据形成可解释决策</h2>
<p>去重服务不应只返回 <code>true</code> 或 <code>false</code>，而应输出决策、理由和证据：</p>
<pre><code class="language-js">function decideDuplicate(candidate, existing) {
  if (candidate.normalizedUrl === existing.normalizedUrl) {
    return { decision: &#39;duplicate&#39;, reason: &#39;same_normalized_url&#39; };
  }
  if (candidate.contentHash === existing.contentHash) {
    return { decision: &#39;duplicate&#39;, reason: &#39;same_content_hash&#39; };
  }

  const titleScore = jaccard(candidate.title, existing.title);
  if (titleScore &gt;= candidate.reviewThreshold) {
    return {
      decision: &#39;review&#39;,
      reason: &#39;similar_title&#39;,
      titleScore
    };
  }
  return { decision: &#39;new&#39;, reason: &#39;no_strong_match&#39; };
}
</code></pre>
<p>实际系统还应限制候选范围，例如只比较相近发布日期、相同语言或相同主题中的记录，避免全表两两计算。所有阈值和规则版本都应写入判定记录，方便日后解释为什么某篇文章没有发布。</p>
<p>建议使用四种状态：</p>
<ul>
<li><code>new</code>：没有强匹配，可以进入后续处理；</l]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 内容去重"/>
    <category term="Node.js"/>
    <category term="URL 规范化"/>
    <category term="内容指纹"/>
    <category term="幂等"/>
  </entry>
  <entry>
    <title>大模型 API 429、超时和 5xx 怎么排查？Node.js 重试决策表与安全实现</title>
    <link href="/articles/llm-api-429-timeout-5xx-retry-nodejs" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/llm-api-429-timeout-5xx-retry-nodejs</id>
    <updated>2026-08-13T12:00:00.000Z</updated>
    <published>2026-08-13T12:00:00.000Z</published>
    <summary>用一张决策表区分大模型 API 的 429、超时、5xx 与不可重试错误，并在 Node.js 中正确处理 Retry-After、指数退避、随机抖动、幂等和日志脱敏。</summary>
    <content type="html"><![CDATA[<h1>大模型 API 429、超时和 5xx 怎么排查？Node.js 重试决策表与安全实现</h1>
<p>调用大模型 API 时，最危险的处理方式不是完全不重试，而是遇到任何失败都立刻重试。429 可能意味着请求频率或额度受限；超时可能发生在服务端已经生成结果之后；500、502、503 和 504 的含义也不完全相同。没有分类的重试会放大流量、重复计费，还可能让原本短暂的故障变成重试风暴。</p>
<p>本文只解决一个问题：<strong>大模型 API 429 重试以及相邻的超时、5xx 应该怎样判断和执行</strong>。多模型的选择、轮询与故障切换是另一个层次，可另见<a href="/articles/nodejs-multi-model-round-robin-failover">Node.js 多模型轮询与故障切换</a>；统一配置多个 OpenAI 兼容模型则参考<a href="/articles/openai-compatible-multi-model-config-nodejs">OpenAI 兼容 API 多模型统一配置</a>。</p>
<h2>一、先判断失败发生在哪一层</h2>
<p>排查时不要只记一行“调用失败”。至少区分四层：</p>
<ol>
<li><strong>请求构造层</strong>：URL、鉴权、模型名、JSON 或参数不合法；</li>
<li><strong>网络传输层</strong>：DNS、连接建立、TLS、连接重置或客户端超时；</li>
<li><strong>HTTP 协议层</strong>：服务返回 4xx 或 5xx，并可能附带 <code>Retry-After</code>；</li>
<li><strong>业务响应层</strong>：HTTP 200，但响应不是预期 JSON、流式内容中断或业务字段表示失败。</li>
</ol>
<p>这四层需要不同证据。网络错误没有 HTTP 状态码；HTTP 错误应保存状态码、响应头和经过清洗的错误类型；业务解析错误则要记录响应格式、解析阶段和请求关联 ID。把它们都压成字符串 <code>AI error</code>，后续无法判断该修配置还是重试。</p>
<h2>二、重试决策表</h2>
<p>下面是默认决策起点，不替代具体供应商文档：</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>常见含义</th>
<th align="right">默认是否重试</th>
<th>建议动作</th>
</tr>
</thead>
<tbody><tr>
<td>400 / 422</td>
<td>参数、JSON 或上下文不合法</td>
<td align="right">否</td>
<td>修正请求；不要用重试掩盖校验错误</td>
</tr>
<tr>
<td>401</td>
<td>Key 无效或鉴权格式错误</td>
<td align="right">否</td>
<td>检查密钥、请求头和部署环境</td>
</tr>
<tr>
<td>403</td>
<td>权限、区域或模型访问受限</td>
<td align="right">否</td>
<td>核对账号权限和模型授权</td>
</tr>
<tr>
<td>404</td>
<td>URL、部署名或模型名错误</td>
<td align="right">通常否</td>
<td>核对 endpoint 与 model；仅配置刚发布且文档明确时再判断</td>
</tr>
<tr>
<td>408</td>
<td>对方认为请求超时</td>
<td align="right">有条件</td>
<td>确认请求可安全重复，再退避重试</td>
</tr>
<tr>
<td>409</td>
<td>状态冲突</td>
<td align="right">依接口而定</td>
<td>只有官方说明可重试时才重试</td>
</tr>
<tr>
<td>429</td>
<td>频率、并发、令牌或额度受限</td>
<td align="right">有条件</td>
<td>优先遵循 <code>Retry-After</code>，同时检查配额是否已耗尽</td>
</tr>
<tr>
<td>500</td>
<td>服务内部错误</td>
<td align="right">通常可有限重试</td>
<td>退避并设置总时间预算</td>
</tr>
<tr>
<td>502 / 503 / 504</td>
<td>网关或服务暂时不可用</td>
<td align="right">通常可有限重试</td>
<td>退避加 jitter，持续失败时停止并告警</td>
</tr>
<tr>
<td>DNS / TLS / 连接拒绝</td>
<td>本地网络、域名、证书或服务不可达</td>
<td align="right">有条件</td>
<td>先分类；配置与证书错误不应盲目重试</td>
</tr>
<tr>
<td>客户端超时 / 连接重置</td>
<td>结果状态不确定</td>
<td align="right">有条件</td>
<td>按幂等边界决定，记录“结果未知”</td>
</tr>
<tr>
<td>响应解析失败</td>
<td>响应截断、格式变化或非 JSON</td>
<td align="right">有条件</td>
<td>保存脱敏元数据；先判断是否为临时网关响应</td>
</tr>
</tbody></table>
<p>“可重试”不等于“无限重试”。每次调用都需要最大尝试次数、单次超时和总时间预算。队列任务还要设置任务级截止时间，否则应用层和队列层叠加重试后，实际尝试次数会成倍增加。</p>
<h2>三、429：先看 Retry-After，再看配额类型</h2>
<p>HTTP 标准允许服务器通过 <code>Retry-After</code> 告诉客户端多久之后再尝试。它可能是秒数，也可能是 HTTP 日期：</p>
<pre><code class="language-js">function parseRetryAfter(value, now = Date.now()) {
  if (!value) return null;

  const seconds = Number(value);
  if (Number.isFinite(seconds) &amp;&amp; seconds &gt;= 0) {
    return Math.ceil(seconds * 1000);
  }

  const date = Date.parse(value);
  if (Number.isFinite(date)) {
    return Math.max(0, date - now);
  }

  return null;
}
</code></pre>
<p>收到 429 后先读取它，不要无条件固定等待一秒。但 <code>Retry-After</code> 也不是重试许可：如果错误体明确表示余额、月度额度、账号权限或硬配额耗尽，等待几秒通常没有意义，应停止任务并通知管理员。</p>
<p>同一供应商还可能分别限制每分钟请求数、每分钟令牌数和并发数。仅降低请求次数不一定能解决令牌限流；请求很长时，应同时检查输入长度、输出上限、并发和批处理策略。错误体结构因供应商而异，解析器应允许未知字段，不能把某一家供应商的 <code>code</code> 写死成通用标准。</p>
<h2>四、指数退避必须加入随机抖动</h2>
<p>多个任务在同一时间收到 429 或 503，如果都按 1、2、4、8 秒重试，它们仍会在相同时间再次冲击服务。随机抖动（jitter）的作用是把重试时刻打散。</p>
<p>下面使用 full jitter：先算指数上限，再在零到上限之间随机取值。若服务器返回有效 <code>Retry-After</code>，则以它为最低等待基准，同时仍限制最大等待：</p>
<pre><code class="language-js">function backoffDelay(attempt, { baseMs = 500, capMs = 20_000 } = {}) {
  const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
  return Math.floor(Math.random() * (ceiling + 1));
}

function chooseDelay({ attempt, retryAfterMs, capMs = 20_000 }) {
  const jitterMs = backoffDelay(attempt, { capMs });
  if (retryAfterMs == null) return jitterMs;
  return Math.min(capMs, Math.max(retryAfterMs, jitterMs));
}
</code></pre>
<p>如果供应商要求的 <code>Retry-After</code> 超过客户端允许等待的总预算，不要偷偷缩短后继续撞限流；应结束本次同步请求，或把任务延后到队列中。同步页面请求尤其不能为了重试让用户连接长时间悬挂。</p>
<h2>五、Node.js 可审计的重试实现</h2>
<p>以下示例把“是否可重试”和“等待多久”分开。它不包含真实 API Key，也不绑定某个模型供应商：</p>
<pre><code class="language-js">import { setTimeout as sleep } from &#39;node:timers/promises&#39;;

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

function isRetryableNetworkError(error) {
  return [&#39;ECONNRESET&#39;, &#39;ETIMEDOUT&#39;, &#39;EAI_AGAIN&#39;].includes(error?.cause?.code);
}

function sanitizeErrorBody(text) {
  return String(text)
    .replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, &#39;Bearer [REDACTED]&#39;)
    .slice(0, 1000);
}

export async function requestWithRetry(url, requestInit, options = {}) {
  const {
    maxAttempts = 4,
    attemptTimeoutMs = 30_000,
    totalBudgetMs = 70_000,
    onAttempt = () =&gt; {}
  } = options;

  const startedAt = Date.now();
  let lastError;

  for (let attempt = 0; attempt &lt; maxAttempts; attempt += 1) {
    const remainingMs = totalBudgetMs - (Date.now() - startedAt);
    if (remaining]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="大模型 API"/>
    <category term="429 重试"/>
    <category term="Node.js"/>
    <category term="指数退避"/>
    <category term="故障排查"/>
  </entry>
  <entry>
    <title>大模型 API Key 安全：Node.js 服务端存储、加密、轮换与泄露应急</title>
    <link href="https://www.githubmissyang.cn/articles/llm-api-key-security-nodejs-encryption-rotation-incident-response" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/llm-api-key-security-nodejs-encryption-rotation-incident-response</id>
    <updated>2026-08-13T10:00:00.000Z</updated>
    <published>2026-08-13T10:00:00.000Z</published>
    <summary>面向 Node.js 与容器部署，系统说明大模型 API Key 的服务端边界、静态加密、主密钥管理、日志脱敏、最小权限、轮换流程和泄露应急，给出可落地但不过度承诺的安全方案。</summary>
    <content type="html"><![CDATA[<h2>先给结论</h2>
<p>大模型 API Key 应当只存在于服务端的受控运行环境中。环境变量可以避免把密钥写进代码，但它不是完整的密钥管理方案；AES-GCM 等静态加密可以降低配置文件泄露的风险，但如果密文、主密钥和应用权限同时失守，攻击者仍可能取得明文。真正有效的防护来自分层控制：服务端隔离、最小权限、独立主密钥、日志脱敏、用量告警、定期轮换和可执行的泄露处置流程。</p>
<p>本文以 Node.js 和 Docker 容器部署为例，目标不是宣称“绝对安全”，而是缩小密钥暴露面，并让泄露能够更快被发现和止损。</p>
<h2>先画清 API Key 的安全边界</h2>
<p>典型调用链应该是：</p>
<pre><code class="language-text">浏览器或移动端 -&gt; 自己的 Node.js 后端 -&gt; 大模型 API
</code></pre>
<p>前端只携带本站登录态或短期会话凭证，不能收到上游模型的 API Key。以下位置都不适合保存长期密钥：</p>
<ul>
<li>前端 JavaScript、HTML、Source Map 和移动端安装包；</li>
<li>Git 仓库、镜像构建参数、Dockerfile 的 <code>ENV</code> 指令；</li>
<li>可公开下载的配置文件和对象存储；</li>
<li>错误堆栈、请求日志、分析平台和聊天记录；</li>
<li>为排错而保存的完整 HTTP 请求头。</li>
</ul>
<p>即使前端做了混淆或加密，只要浏览器最终需要解密并发送密钥，访问者就能从运行时或网络请求中取得它。正确做法是由后端代理调用，并在本站接口增加身份验证、限流、参数白名单和额度控制。</p>
<h2>环境变量能解决什么，不能解决什么</h2>
<p>Node.js 从环境变量读取密钥很简单：</p>
<pre><code class="language-javascript">const apiKey = process.env.LLM_API_KEY;
if (!apiKey) throw new Error(&#39;缺少 LLM_API_KEY&#39;);
</code></pre>
<p>它能避免密钥进入源码，也方便部署系统注入不同环境的配置。不过，环境变量仍可能被同权限进程、调试工具、容器配置查看权限或错误诊断信息读取。不要把 <code>process.env</code> 整体打印到日志，也不要把生产环境的 <code>.env</code> 文件提交到仓库。</p>
<p>容器场景中还应注意：</p>
<ul>
<li>不要在 Dockerfile 中使用 <code>ENV LLM_API_KEY=...</code>，否则密钥可能进入镜像层或构建记录；</li>
<li>优先使用部署平台的 Secret、Docker Secret 或外部密钥管理服务，在运行时挂载或注入；</li>
<li>限制谁能查看容器详情、进入容器、读取 Secret 挂载目录和拉取生产镜像；</li>
<li>开发、测试和生产使用不同密钥，避免一个低权限环境牵连生产。</li>
</ul>
<p>环境变量是交付密钥的一种通道，不应被视为保险箱。</p>
<h2>静态加密与主密钥如何分离</h2>
<p>如果管理后台需要持久保存多个模型配置，可以使用认证加密算法加密 API Key，例如 AES-256-GCM。认证加密不仅隐藏明文，也能发现密文或附加数据被篡改。每次加密应生成新的随机 IV，并同时保存认证标签。</p>
<p>下面是一个简化示例：</p>
<pre><code class="language-javascript">import crypto from &#39;node:crypto&#39;;

const ALGORITHM = &#39;aes-256-gcm&#39;;

export function encryptSecret(plaintext, masterKeyBase64) {
  const key = Buffer.from(masterKeyBase64, &#39;base64&#39;);
  if (key.length !== 32) throw new Error(&#39;MASTER_KEY 必须解码为 32 字节&#39;);

  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, &#39;utf8&#39;), cipher.final()]);
  const tag = cipher.getAuthTag();

  return {
    version: 1,
    algorithm: ALGORITHM,
    iv: iv.toString(&#39;base64&#39;),
    tag: tag.toString(&#39;base64&#39;),
    ciphertext: ciphertext.toString(&#39;base64&#39;),
  };
}
</code></pre>
<p>实现时还需要做到：</p>
<ol>
<li>主密钥不能与密文放在同一个配置文件或同一 Git 仓库中；</li>
<li>主密钥由部署平台 Secret 或 KMS 提供，应用启动时读取；</li>
<li>解密只发生在真正发起模型请求的服务进程内，明文不写回磁盘；</li>
<li>配置接口只返回 <code>hasKey</code>、末尾少量字符或密钥版本，不返回完整明文；</li>
<li>为密文记录格式版本，便于以后更换算法或重新加密；</li>
<li>主密钥变更必须配套重加密流程，不能直接替换后让旧数据无法解密。</li>
</ol>
<p>静态加密主要保护备份、磁盘或配置文件被单独复制的场景。它无法抵御已经取得应用执行权限、主密钥和密文的攻击者，因此还必须限制运行身份和后台管理权限。</p>
<h2>日志脱敏要在入口完成</h2>
<p>只靠开发者“记得不要打印”并不可靠，应在日志基础设施统一过滤敏感字段：</p>
<pre><code class="language-javascript">const SECRET_FIELDS = new Set([
  &#39;authorization&#39;, &#39;apiKey&#39;, &#39;api_key&#39;, &#39;token&#39;, &#39;secret&#39;, &#39;masterKey&#39;,
]);

function redact(value) {
  if (Array.isArray(value)) return value.map(redact);
  if (!value || typeof value !== &#39;object&#39;) return value;

  return Object.fromEntries(Object.entries(value).map(([key, item]) =&gt; [
    key,
    SECRET_FIELDS.has(key) || SECRET_FIELDS.has(key.toLowerCase())
      ? &#39;[REDACTED]&#39;
      : redact(item),
  ]));
}
</code></pre>
<p>还要检查反向代理访问日志、APM、错误监控和任务队列失败记录。对于 <code>Authorization: Bearer ...</code> 这类嵌在字符串中的内容，应增加模式过滤，但不要只依赖正则；优先从源头禁止记录请求头和敏感配置。</p>
<p>安全日志应保留模型名、密钥标识、请求 ID、HTTP 状态、Token 用量、延迟和调用业务，但不能保留完整密钥。密钥标识可以是供应商提供的 Key ID，或内部随机配置 ID，不建议把密钥本身做无盐哈希后公开展示。</p>
<h2>用最小权限限制泄露影响</h2>
<p>如果模型平台支持项目、角色、额度或来源限制，应为每个环境和业务创建独立密钥：</p>
<ul>
<li>日报总结和后台测试不要共用一把密钥；</li>
<li>生产服务只获得所需模型和接口权限；</li>
<li>设置单日或单月预算、速率限制和异常用量告警；</li>
<li>后台配置接口使用强认证，并记录增删改审计事件；</li>
<li>服务进程使用非 root 用户，配置文件只允许该运行身份读取；</li>
<li>CI/CD 只在必要阶段获得 Secret，避免传给不受信任的脚本或分支构建。</li>
</ul>
<p>当供应商无法提供细粒度权限时，可以在自己的模型网关实施模型白名单、最大 Token、并发数和业务配额。密钥泄露无法完全避免，但独立密钥与额度控制能显著缩小影响范围。</p>
<h2>建立不中断业务的轮换流程</h2>
<p>轮换不应只是“删除旧 Key 再创建新 Key”。更稳妥的是双密钥交叠：</p>
<ol>
<li>创建新 Key，权限和额度从最小集合开始；</li>
<li>将新 Key 作为新版本写入 Secret 系统或加密配置；</li>
<li>重启或热加载一小部分实例，完成连通性和业务校验；</li>
<li>逐步切换全部实例，观察错误率、调用量和供应商控制台；</li>
<li>确认没有旧 Key 流量后撤销旧 Key；</li>
<li>记录操作者、时间、旧新 Key ID、验证结果和回滚窗口。</li>
</ol>
<p>应用配置可以保存 <code>keyVersion</code> 和 <code>activatedAt</code>，日志只记录版本，不记录密钥。若供应商只允许一把有效密钥，就需要准备短维护窗口和明确回滚路径。</p>
<p>轮换频率应结合供应商能力、暴露风险和团队运维成本制定；比固定天数更重要的是在人员离职、权限变化、仓库误提交、日志误记录或依赖供应链事件后立即轮换。</p>
<h2>泄露后的应急处置顺序</h2>
<p>发现密钥出现在 Git、日志、截图或未知调用中时，应把它视为已经泄露。仅删除文件或重写 Git 历史不够，因为密钥可能已被复制。建议按以下顺序处理：</p>
<ol>
<li><strong>立即撤销或禁用旧 Key</strong>：先止损，不等待原因调查结束；</li>
<li><strong>启用备用 Key 并恢复业务</strong>：使用预演过的轮换流程，避免把旧 Key 再次上线；</li>
<li><strong>检查供应商调用记录和账单</strong>：确定异常时间、模型、用量、来源 IP 和请求特征；</li>
<li><strong>隔离泄露路径</strong>：关闭公开文件、限制日志访问、暂停可疑构建或令牌；</li>
<li><strong>搜索扩散范围</strong>：检查 Git 历史、镜像层、CI 日志、制品、工单、聊天和备份；</li>
<li><strong>保留审计证据</strong>：记录发现时间、处置动作和相关 ID，但不要再次复制明文密钥；</li>
<li><strong>修复根因并复盘</strong>：增加 Secret 扫描、权限控制、脱敏测试和告警；</li>
<li><strong>按合同和法规评估通知义务</strong>：涉及用户数据或重大费]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="API Key 安全"/>
    <category term="Node.js"/>
    <category term="密钥轮换"/>
    <category term="容器安全"/>
    <category term="大模型 API"/>
  </entry>
  <entry>
    <title>AI 总结质量怎么测？从人工评分到自动回归测试的完整方案</title>
    <link href="https://www.githubmissyang.cn/articles/ai-summary-quality-evaluation-regression-testing" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-summary-quality-evaluation-regression-testing</id>
    <updated>2026-08-13T10:00:00.000Z</updated>
    <published>2026-08-13T10:00:00.000Z</published>
    <summary>给出一套可重复的 AI 总结质量评估流程：固定测试集、人工评分量表、事实一致性检查、结构化断言、版本对比与发布门禁，帮助在模型、提示词或清洗规则变更后发现质量退化。</summary>
    <content type="html"><![CDATA[<h1>AI 总结质量怎么测？从人工评分到自动回归测试的完整方案</h1>
<p>AI 总结功能能返回文字，不等于它已经可用。模型更换、提示词调整、正文清洗规则变化，甚至同一模型的服务版本更新，都可能让总结悄悄变长、漏掉关键事实、混入原文没有的结论，或者破坏前端依赖的 JSON 格式。</p>
<p>因此，AI 总结不能只靠管理员偶尔阅读几条结果。更稳妥的方式是建立一套回归测试：固定一批有代表性的原文，为每篇文章保存人工评审依据，每次修改模型或提示词后重新生成，再比较质量与格式是否退步。</p>
<p>本文关注的是“怎样验证总结质量”，不重复讲抓取和任务调度。流水线拆分可先阅读<a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">Node.js AI 总结管线设计</a>，多模型切换则参考<a href="/articles/nodejs-multi-model-round-robin-failover">Node.js 多模型轮询与故障切换</a>。</p>
<h2>一、先定义什么是合格总结</h2>
<p>如果团队只说“读起来不错”，评审结果很难复现。可以把质量拆成六个可观察维度：</p>
<ol>
<li><strong>事实一致性</strong>：人物、产品、版本、时间和数字是否来自原文；</li>
<li><strong>关键点覆盖</strong>：是否保留文章最重要的结论与限制条件；</li>
<li><strong>信息密度</strong>：有没有重复、套话和不必要的背景；</li>
<li><strong>表达清晰度</strong>：句子是否自然，主语和指代是否明确；</li>
<li><strong>边界意识</strong>：原文不确定的内容，是否被模型写成确定事实；</li>
<li><strong>格式合规</strong>：字段、长度、条目数量和数据类型是否符合程序约束。</li>
</ol>
<p>其中，格式可以自动检查；事实一致性和关键点覆盖仍需要人工评审或有证据约束的辅助检查。不要把“模型自评 0.95”直接当作准确率。</p>
<h2>二、建立固定且可追溯的测试集</h2>
<p>测试集不应只选结构工整的短文。它需要覆盖线上真实会遇到的困难样本，例如：</p>
<ul>
<li>包含多个相近产品名或模型名；</li>
<li>有版本号、日期、价格或百分比；</li>
<li>标题带有夸张表述，但正文结论更克制；</li>
<li>中文正文中混有英文代码和引用；</li>
<li>页面正文很短，或抓取后残留导航文本；</li>
<li>多个来源报道同一事件，但细节不同；</li>
<li>原文明确表示尚未确认或仅为预测。</li>
</ul>
<p>每个样本至少保存稳定 ID、原文快照、来源 URL、抓取时间、正文哈希、关键事实清单和人工参考摘要。保存原文快照很重要：如果只保留 URL，上游页面更新后，同一次回归测试的输入就已经不同。</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;fixture-001&quot;,
  &quot;sourceUrl&quot;: &quot;https://example.com/article&quot;,
  &quot;contentHash&quot;: &quot;sha256:...&quot;,
  &quot;mustInclude&quot;: [&quot;产品名称&quot;, &quot;主要变化&quot;],
  &quot;mustNotClaim&quot;: [&quot;原文没有确认的发布日期&quot;],
  &quot;referenceSummary&quot;: &quot;由编辑审核的参考摘要&quot;
}
</code></pre>
<p>参考摘要不是唯一标准答案。它的作用是帮助评审者对齐关键事实，而不是要求模型逐字复现。</p>
<h2>三、用评分量表减少主观差异</h2>
<p>可以给每个维度设置 0 到 2 分：</p>
<table>
<thead>
<tr>
<th>分值</th>
<th>事实一致性</th>
<th>关键点覆盖</th>
<th>清晰度</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>存在关键事实错误或无依据结论</td>
<td>遗漏核心结论</td>
<td>难以理解或严重重复</td>
</tr>
<tr>
<td>1</td>
<td>次要表述不够准确</td>
<td>覆盖部分关键点</td>
<td>基本可读但需要编辑</td>
</tr>
<tr>
<td>2</td>
<td>未发现与原文冲突的陈述</td>
<td>覆盖人工清单中的关键点</td>
<td>简洁、明确、可直接使用</td>
</tr>
</tbody></table>
<p>上线门禁不应只看总分。一个总结即使其他维度得分较高，只要出现关键事实错误，也应进入人工复核。具体分值、权重与通过条件必须用真实样本校准，本文不把示例阈值当作本站实测结论。</p>
<p>如果多人参与评审，先让两位评审者独立打分，再讨论差异较大的案例。这样比共同阅读后直接达成一个分数，更容易发现量表定义是否含糊。</p>
<h2>四、把确定性规则变成自动断言</h2>
<p>程序最适合检查明确约束，例如字段是否存在、摘要是否为空、条目数量是否越界、是否泄露 HTML、是否重复标题。</p>
<pre><code class="language-js">import assert from &#39;node:assert/strict&#39;;

export function assertSummaryShape(result) {
  assert.equal(typeof result.intro, &#39;string&#39;);
  assert.ok(result.intro.trim().length &gt; 0, &#39;intro 不能为空&#39;);
  assert.ok(Array.isArray(result.picks), &#39;picks 必须是数组&#39;);
  assert.ok(result.picks.length &gt;= 1, &#39;至少需要一条精选&#39;);

  for (const item of result.picks) {
    assert.equal(typeof item.title, &#39;string&#39;);
    assert.equal(typeof item.summary, &#39;string&#39;);
    assert.ok(!/&lt;script|&lt;iframe/i.test(item.summary), &#39;摘要包含危险 HTML&#39;);
  }
}
</code></pre>
<p>如果模型返回 JSON，还应使用 JSON Schema 或等价校验器约束类型、必填字段和额外属性。具体解析与修复策略可参考<a href="/articles/glm-4-7-flash-json-output-nodejs">GLM-4.7-Flash 结构化 JSON 输出</a>。</p>
<p>长度规则要以字符或分词后的单位明确计算，不能只在提示词里写“简短”。同样，不应自动断言某个总结必须包含全部原文数字，因为有些数字并非关键信息。适合写成规则的，必须是业务真正确定的约束。</p>
<h2>五、记录每次生成的完整实验信息</h2>
<p>如果只保存最终摘要，发生退化时很难定位原因。每次回归运行建议记录：</p>
<pre><code class="language-js">const runRecord = {
  runId: crypto.randomUUID(),
  fixtureId: fixture.id,
  inputHash: fixture.contentHash,
  provider: model.provider,
  model: model.name,
  promptVersion: &#39;summary-v3&#39;,
  configVersion: &#39;2026-08-13&#39;,
  startedAt: new Date().toISOString(),
  output: result,
  validation: { passed: true, errors: [] },
  usage: response.usage ?? null
};
</code></pre>
<p>记录模型配置 ID 即可，不要保存 API Key。供应商名称、模型名、提示词版本、输入哈希和运行时间，都是复现问题所需的信息。后台显示“当前使用哪个模型总结”时，也应来自这份执行记录，而不是只读取当前配置，因为配置可能在任务完成后被修改。</p>
<h2>六、比较候选版本，而不是覆盖基线</h2>
<p>设当前线上组合为基线 <code>baseline</code>，待发布的模型或提示词为 <code>candidate</code>。对同一批固定输入分别生成结果，再按样本展示差异。</p>
<pre><code class="language-js">function compareRuns(baseline, candidate) {
  return candidate.map(next =&gt; {
    const prev = baseline.find(x =&gt; x.fixtureId === next.fixtureId);
    return {
      fixtureId: next.fixtureId,
      formatChanged: JSON.stringify(prev?.validation) !== JSON.stringify(next.validation),
      previousOutput: prev?.output ?? null,
      candidateOutput: next.output
    };
  });
}
</code></pre>
<p>纯文本 diff 能显示词句变化，却无法判断变化是否更准确。因此后台最好同时展示原文证据、旧总结、新总结、自动校验结果和人工评分。不要让候选结果直接覆盖基线；只有审核通过后，才更新生产配置和基线版本。</p>
<h2>七、为事实核验建立证据链</h2>
<p>完全依赖另一个模型判断“是否忠实”仍可能产生误判。更可审核的方式，是让总结中的关键陈述附带原文证据位置，然后由程序或人工核对。</p>
<pre><code class="language-json">{
  &quot;summary&quot;: &quot;某产品增加了结构化输出能力。&quot;,
  &quot;claims&quot;: [
    {
      &quot;text&quot;: &quot;增加了结构化输出能力&quot;,
      &quot;evidence&quot;: [&quot;paragraph-12&quot;]
    }
  ]
}
</code></pre>
<p>证据位置只能证明模型指向了某段原文，不能自动证明它理解正确，但能显著降低人工回查成本。涉及数字、否定词、时间范围和对比结论时，应优先进入严格核验。</p>
<h2>八、设置分层发布门禁</h2>
<p>一次回归测试可以分三层：]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 总结"/>
    <category term="质量评估"/>
    <category term="回归测试"/>
    <category term="Node.js"/>
    <category term="内容工程"/>
  </entry>
  <entry>
    <title>AI 辅助原创文章工作流：从选题、资料和实测到事实核验与版本复审</title>
    <link href="https://www.githubmissyang.cn/articles/ai-original-article-workflow-research-testing-fact-check-review" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-original-article-workflow-research-testing-fact-check-review</id>
    <updated>2026-08-13T09:00:00.000Z</updated>
    <published>2026-08-13T09:00:00.000Z</published>
    <summary>面向技术博客建立 AI 辅助原创工作流：从搜索意图、证据台账和可复现实测出发，经过事实核验、人工审核、内容版本与定期复审，避免把未经验证的生成内容当作经验或结论。</summary>
    <content type="html"><![CDATA[<h1>AI 辅助原创文章工作流：从选题、资料和实测到事实核验与版本复审</h1>
<p>AI 可以帮助整理资料、发现结构缺口和生成初稿，但它不能替作者确认事实，也不能把没有发生过的测试变成经验。对技术网站而言，真正可持续的原创不是每天批量改写同一批新闻，而是持续解决具体问题，并保留来源、测试过程、审核结论和更新记录。</p>
<p>本文给出一套可落地的 <strong>AI 原创文章工作流</strong>。它把模型放在研究与编辑环节，把选题判断、事实确认、测试执行和最终发布责任留给人。文章产量可以并行扩大，但证据门槛不能随之降低。</p>
<h2>一、先定义什么算本站原创</h2>
<p>原创不等于文字第一次出现。把多个页面交给模型重新措辞，仍可能没有新增信息。技术文章至少应提供一种可识别的增量：</p>
<ul>
<li>针对真实问题完成配置、排错或对比；</li>
<li>给出可复现的代码、输入条件和失败边界；</li>
<li>将分散的官方信息组织成清楚的决策路径；</li>
<li>记录本站实际实现中的取舍和复盘；</li>
<li>在已有方法上补充新的验证、限制或版本变化。</li>
</ul>
<p>Google Search Central 关于实用内容和生成式 AI 内容的说明，都把重点放在内容是否对读者有帮助、是否准确以及是否提供上下文，而不是使用了哪一种生产工具。因此，后台的“AI 生成”只能表示稿件来源环节，不能代替原创判断。</p>
<h2>二、用选题卡阻止无目的写作</h2>
<p>每篇文章开始前先建立选题卡，不要直接让模型“写一篇 SEO 文章”。选题卡至少回答六个问题：</p>
<ol>
<li>读者正在完成什么任务，卡在哪里？</li>
<li>主关键词背后是教程、排错、对比还是配置意图？</li>
<li>站内已有文章是否已经完整回答？</li>
<li>本文准备新增什么证据或经验？</li>
<li>哪些结论必须查官方资料，哪些必须亲自测试？</li>
<li>发布后应该链接到哪个专题和哪些下一步文章？</li>
</ol>
<p>例如“AI 总结不好用”太宽泛，可以拆成“如何建立固定样例做回归测试”“模型切换后如何判断摘要是否退化”“结构化输出失败如何定位”。第一个问题已有<a href="/articles/ai-summary-quality-evaluation-regression-testing">AI 总结质量评估与回归测试</a>承接；如果新文章不能提供不同的搜索意图或新证据，应更新旧文，而不是制造相互竞争的页面。</p>
<p>选题卡还应记录暂定标题、主关键词、目标读者、预期结论、必要测试、负责人和截止日期。AI 可以据此提出子问题，但作者要删除无法验证、与本站无关或只是追逐热词的方向。</p>
<h2>三、建立来源台账，而不是堆参考链接</h2>
<p>资料收集应围绕“哪条结论由什么证据支持”。建议把来源分成四级：</p>
<ul>
<li>产品官方文档、标准、规范和源代码；</li>
<li>官方公告、版本日志与状态页；</li>
<li>原始研究、作者说明和可复现仓库；</li>
<li>第三方教程、社区讨论和搜索摘要。</li>
</ul>
<p>前两级优先用于接口参数、限制、版本和行为结论。社区资料适合发现问题，不应在未经验证时成为唯一依据。搜索结果摘要只用于定位原页面，不能代替打开并阅读来源。</p>
<p>每条来源在台账中记录 URL、页面标题、访问日期、支持的结论、适用版本和是否需要复测。模型可以从资料中提取候选结论，但作者必须回到原页面核对。引用页面也可能过期，因此“有链接”不等于“事实已确认”。</p>
<p>一个轻量台账可以这样记录：</p>
<table>
<thead>
<tr>
<th>待核验结论</th>
<th>证据类型</th>
<th>当前状态</th>
<th>发布处理</th>
</tr>
</thead>
<tbody><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>不成立</td>
<td>不发布承诺</td>
</tr>
</tbody></table>
<h2>四、把“实测”写成可复现记录</h2>
<p>只有真正执行过，才能写“实测”。一次合格的技术测试应记录：</p>
<ul>
<li>日期、运行环境、依赖和模型版本；</li>
<li>使用的配置与关键输入，敏感信息需脱敏；</li>
<li>预期结果、实际结果和判断规则；</li>
<li>成功样例、失败样例与重复次数；</li>
<li>哪些变量未控制，结论能外推到哪里。</li>
</ul>
<p>例如测试大模型 API 重试时，应在受控环境模拟 429、503、超时和不可重试的 4xx，而不是等待生产偶发故障。具体错误分类可参考<a href="/articles/llm-api-429-timeout-5xx-retry-nodejs">大模型 API 429、超时和 5xx 排查</a>。若只验证了一个模型、一个地区和少量请求，结论必须限定在该环境，不能写成“所有 OpenAI 兼容接口都支持”。</p>
<p>测试失败也有内容价值。记录失败条件、日志和修正过程，通常比只展示最终成功截图更能帮助读者。无法公开的数据可以描述采集方法和字段，但不能用模型生成一组看似合理的数字填空。</p>
<h2>五、先写证据稿，再让 AI 帮助成稿</h2>
<p>推荐把草稿分为两层。第一层是证据稿，只包含已经确认的事实、测试记录、代码、待核验项和来源映射。第二层才是面向读者的成稿。</p>
<p>AI 在成稿阶段适合执行以下任务：</p>
<ul>
<li>根据选题卡整理章节顺序；</li>
<li>找出概念跳跃、重复段落和缺少前提的位置；</li>
<li>将日志与测试笔记转成易读说明；</li>
<li>检查术语是否前后一致；</li>
<li>生成标题和描述候选，供作者选择；</li>
<li>根据已确认内容提出需要补充的问题。</li>
</ul>
<p>提示词应明确禁止新增事实、数字、引用和测试结果。模型遇到证据不足时应输出“待核验”，而不是补全。涉及代码时，代码仍需运行、语法检查或最小测试；涉及模型输出质量时，可使用固定样例和评分规则，避免只凭阅读感觉。</p>
<h2>六、逐条事实核验，不只检查错别字</h2>
<p>成稿后提取所有可验证陈述，按类型复核：</p>
<ul>
<li><strong>版本事实</strong>：模型名、API 路径、参数和发布时间；</li>
<li><strong>行为事实</strong>：接口是否兼容、错误码如何返回、字段是否必填；</li>
<li><strong>数量事实</strong>：价格、限制、耗时、成功率和页面数量；</li>
<li><strong>因果陈述</strong>：某项修改是否真的导致收录、性能或质量变化；</li>
<li><strong>引用陈述</strong>：来源是否确实支持正文，而非只与主题相关；</li>
<li><strong>实测陈述</strong>：是否有原始记录，环境和边界是否写清楚。</li>
</ul>
<p>数量和绝对化词语要重点搜索，例如“全部”“一定”“免费”“最快”“提升了 30%”。没有证据就删除或改成有限条件下的观察。SEO 描述、标题、图注和结构化数据也属于文章事实范围，不能正文谨慎而摘要夸大。</p>
<p>技术文章中的链接还要检查目标 slug、锚文本和上下文。可以结合<a href="/articles/technical-blog-internal-link-topic-cluster-anchor-orphan-pages">技术博客文章内链设计</a>检查孤儿页与失效链接，但相关链接只在确实帮助读者完成下一步时添加。</p>
<h2>七、人工复审决定能否发布</h2>
<p>最终复审人不应只点“通过”，而应明确检查：</p>
<ul>
<li>文章是否回答选题卡中的真实任务；</li>
<li>主要结论能否追溯到来源或测试；</li>
<li>AI 是否添加了证据稿中不存在的事实；</li>
<li>代码能否运行，示例是否泄露密钥或个人数据；</li>
<li>标题、摘要与正文是否一致；</li>
<li>限制、失败情况和适用版本是否清楚；</li>
<li>分类、专题、内链、canonical 和 Article 结构化数据是否正确；</li>
<li>是否需要专业人员进行更高等级审核。</li>
</ul>
<p>后台可设置 <code>draft → evidence_ready → ai_edited → fact_checked → reviewed → published</code> 状态。每个状态保存操作者、时间和备注。模型可以建议状态，但不能自行跨过 <code>fact_checked</code> 与 <code>reviewed</code>。并行 Agent 写作也应遵守同一规则：一个 Agent 负责一个有边界的选题，统一由人工编辑合并、查重和发布。</p>
<h2>八、发布不是终点，要按触发条件复审</h2>
<p>技术内容会因模型、SDK、价格、接口和站点实现变化而过期。每篇文章应保存 <code>contentVersion</code>、<code>reviewedAt</code> 和 <code>nextReviewAt</code>，并配置事件触发复审：</p>
<ul>
<li>官方文档或 API 版本更新；</li>
<li>依赖升级导致示例失效；</li>
<li>读者反馈与线上日志证明结论不完整；</li>
<li>站内 URL、分类或专题结构调整；</li>
<li>搜索意图发生变化，文章与标题不再匹配。</li>
</ul>
<p>复审时不要仅修改日期。应重新打开关键来源、运行核心示例、检查全部内链，并记录修改了什么。若结论已经失效，显著标注旧版本适用范围；若新旧内容的搜索意图相同，应更新原 URL，而不是保留两个互相重复的版本。</p>
<h2>九、可直接执行的发布清单</h2>
<h3>选题与证据</h3>
<ul>
<li><input disabled="" type="checkbox"> 选题卡写明读者任务、搜索意图与新增价值；</li>
<li><input disabled="" type="checkbox"> 主要结论已映射到官方来源或真实测试；</li>
<li><input disabled="" type="checkbox"> 未执行的测试均标记为待验证；</li>
<li><input disabled="" type="checkbox"> 与站内旧文不存在同意图重复。</li>
</ul>
<h3>成稿与核验</h3>
<ul>
<li><input disabled="" type="checkbox"> AI 未新增无法追溯的数字、引用和产品行为；</li>
<li><input disabled="" type="checkbox"> 代码已经运行或明确标注为示意；</li>
<li><input disabled="" type="checkbox"> 实测]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 原创文章工作流"/>
    <category term="事实核验"/>
    <category term="技术写作"/>
    <category term="人工审核"/>
    <category term="内容复审"/>
  </entry>
  <entry>
    <title>AI 内容流水线要不要上任务队列？同步执行、后台任务与 BullMQ 对比</title>
    <link href="https://www.githubmissyang.cn/articles/ai-content-pipeline-sync-vs-job-queue" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-content-pipeline-sync-vs-job-queue</id>
    <updated>2026-08-13T08:00:00.000Z</updated>
    <published>2026-08-13T08:00:00.000Z</published>
    <summary>针对 AI 内容流水线从小规模到多人协作的演进，比较同步请求、进程内后台任务和 BullMQ 队列在超时、重试、幂等、并发、可观测性及故障恢复上的取舍，并给出按任务规模选择架构的边界。</summary>
    <content type="html"><![CDATA[<h1>AI 内容流水线要不要上任务队列？同步执行、后台任务与 BullMQ 对比</h1>
<p>一条典型的 AI 内容流水线可能包含：</p>
<pre><code class="language-text">抓取来源 → 清洗与去重 → AI 总结 → 自动分类 → 人工审核 → 发布
</code></pre>
<p>开发初期，最自然的写法是让管理后台发送请求，服务器依次完成所有步骤，最后把结果返回页面。这种同步执行简单直观，但随着来源增加、模型响应变慢或中途出现故障，一个请求会承担越来越多职责。</p>
<p>是不是应该马上引入 Redis 和 BullMQ？答案通常是否定的。任务队列解决的是可靠调度、并发控制和故障恢复问题，但也会增加部署组件和维护成本。更合理的做法，是根据任务规模从同步请求、进程内后台任务逐步演进到持久化队列。</p>
<h2>一、方案一：一个请求执行完整流水线</h2>
<pre><code class="language-js">app.post(&#39;/api/admin/run&#39;, async (req, res) =&gt; {
  try {
    const items = await fetchSources();
    const cleaned = deduplicate(items);
    const summarized = await summarize(cleaned);
    const classified = await classify(summarized);
    await publish(classified);
    res.json({ ok: true, count: classified.length });
  } catch (error) {
    res.status(500).json({ ok: false, error: error.message });
  }
});
</code></pre>
<p>当数据量较小、执行时间可控、只有管理员偶尔手动触发时，同步请求代码路径短、调试方便，也不需要额外组件。</p>
<p>它的问题是请求生命周期与整个任务绑定。浏览器、反向代理和应用服务器都可能有超时限制。HTTP 请求失败也不代表服务器工作一定停止，管理员再次点击后可能产生重复任务。如果所有步骤都在一个 <code>try/catch</code> 中，分类失败还可能使抓取结果无法落盘。因此，即使暂时使用同步模式，也应尽早分别保存每一步的结果。</p>
<h2>二、方案二：请求立即返回，任务在进程内继续</h2>
<pre><code class="language-js">const jobs = new Map();

app.post(&#39;/api/admin/run&#39;, (req, res) =&gt; {
  const jobId = crypto.randomUUID();
  jobs.set(jobId, { status: &#39;queued&#39;, step: &#39;waiting&#39; });
  setImmediate(() =&gt; {
    runPipeline(jobId).catch(error =&gt; {
      jobs.set(jobId, { ...jobs.get(jobId), status: &#39;failed&#39;, error: error.message });
    });
  });
  res.status(202).json({ jobId });
});
</code></pre>
<p>后台拿到 <code>jobId</code> 后查询状态，就可以显示“正在抓取”“正在总结”或“处理失败”，不再长时间等待一个 HTTP 请求。这是同步执行到正式队列之间很实用的一步，适合单实例、低频任务。</p>
<p>但 <code>Map</code> 中的状态只存在内存里。应用重启、容器更新或进程崩溃后，任务记录会消失。多实例部署时，状态查询也可能落到另一个进程。因此它不是可靠队列。</p>
<h2>三、方案三：使用 BullMQ 和 Redis</h2>
<p>当任务需要持久化、自动重试或横向扩展时，可以使用 BullMQ。生产者只负责创建任务：</p>
<pre><code class="language-js">import { Queue } from &#39;bullmq&#39;;

const connection = { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) };
const contentQueue = new Queue(&#39;content-pipeline&#39;, { connection });

const job = await contentQueue.add(&#39;summarize&#39;, { batchId }, {
  jobId: `summarize:${batchId}`,
  attempts: 3,
  backoff: { type: &#39;exponential&#39;, delay: 2000 }
});
</code></pre>
<p>Worker 负责执行：</p>
<pre><code class="language-js">import { Worker } from &#39;bullmq&#39;;

const worker = new Worker(&#39;content-pipeline&#39;, async job =&gt; {
  if (job.name === &#39;summarize&#39;) return summarizeBatch(job.data.batchId);
  throw new Error(`未知任务类型：${job.name}`);
}, { connection, concurrency: 2 });
</code></pre>
<p>示例中的重试次数、退避时间和并发数只是演示值。上线前需要结合模型限流、单次任务时长和服务器资源进行测试。</p>
<p>队列可以让任务独立保存，让 Worker 单独扩容并限制并发。失败任务可以自动重试并保留原因，管理员不必重新运行整个流水线。不过，拆分越细，状态流转越复杂；小型网站没有必要一开始就建立多个队列。</p>
<h2>四、任务队列不等于 Worker Threads</h2>
<p>Node.js 的 <code>worker_threads</code> 和 BullMQ Worker 名称相似，但解决的问题不同。<code>worker_threads</code> 适合 CPU 密集型 JavaScript 计算，对以网络 I/O 为主的网页抓取和模型 API 调用帮助有限。</p>
<p>内容流水线更需要的是任务持久化、重试与退避、并发限制、状态查询以及多进程消费，这些属于任务队列解决的范围。</p>
<h2>五、无论是否上队列，都要先解决幂等</h2>
<p>业务代码不能假设每个任务永远只运行一次。文章抓取可使用规范化后的原始 URL 作为唯一键；总结任务则可以根据文章 ID、正文哈希、提示词版本和模型名称构造任务键：</p>
<pre><code class="language-js">const jobKey = [&#39;summary&#39;, article.id, article.contentHash, promptVersion, modelName].join(&#39;:&#39;);
</code></pre>
<p>这样，相同输入重复提交时不会产生两份相同结果；正文、提示词或模型变化时，又允许生成新版本。发布前还应检查稳定 ID 或 slug，避免重试生成两篇公开文章。</p>
<h2>六、重试不是越多越好</h2>
<p>适合重试的通常是网络中断、上游暂时不可用、明确可恢复的限流和短暂超时。API Key 无效、参数错误、输入超限和代码逻辑错误则不适合盲目重试。</p>
<pre><code class="language-js">function isRetryable(error) {
  return [&#39;ETIMEDOUT&#39;, &#39;ECONNRESET&#39;, &#39;EAI_AGAIN&#39;].includes(error.code) ||
    error.status === 429 || error.status &gt;= 500;
}
</code></pre>
<p>真实错误格式应以供应商文档和实际响应为准。重试还应使用退避和抖动，避免多个任务同时失败后又集中请求上游服务。</p>
<h2>七、保存中间结果，才能从失败处恢复</h2>
<p>抓取完成后应立即保存原始数据。总结失败时，管理员可以对同一批数据重新执行总结，而不是重新访问全部来源。具体拆分方法可继续阅读<a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">Node.js 抓取与 AI 总结拆分实践</a>。</p>
<p>每次模型调用建议记录模型配置 ID、模型名称、起止时间、状态、错误码、输入哈希、提示词版本和输出校验结果，但不要记录 API Key。结构化输出可参考<a href="/articles/glm-4-7-flash-json-output-nodejs">GLM JSON 结构化输出</a>；单个模型持续不可用时，再进入<a href="/articles/nodejs-multi-model-round-robin-failover">多模型重试、轮询与熔断</a>流程。</p>
<h2>八、如何决定现在是否需要 BullMQ</h2>
<p>可以检查以下问题：</p>
<ul>
<li>Web 请求是否经常接近代理超时？</li>
<li>服务重启后，等待中的任务是否必须保留？</li>
<li>是否需要多个 Worker 并发处理？</li>
<li>是否需要自动重试、延迟任务或优先级？</li>
<li>是否存在多个应用实例？</li>
<li>是否需要查看历史任务和失败原因？</li>
</ul>
<p>如果多数答案是否定的，进程内后台任务加持久化状态可能已经足够。如果任务不能丢失、需要跨进程消费或持续积压，队列的价值会明显增加。Redis 的备份、内存、连接、监控和升级也需要维护，不能忽略这部分成本。</p>
<h2>九、推荐的渐进式演进路线</h2>
<p>第一阶段使用同步执行，但拆分 <code>fetch()</code>、<code>summarize()</code>、<code>classify()</code> 和 <code>publish()</code>。模型调用可参考<a href="/articles/glm-4-7-flash-api-nodejs-guide">GLM-4.7-Flash Node.js 接入</a>。</p>
<p>第二阶段让请求立即返回任务 ID，页面轮询状态，中间结果写入持久化存储。</p>
<p>第三阶段在可靠性需求增加后接入 BullMQ 与 Redis，优先迁移最慢、最容易失败的 AI 总结步骤。</p>
<p>第四阶段将 Web 服务和 Worker 分开部署，监控队列积压、失败量、处理时长与上游错误，并建立失败任务重放入口。更多内容可查看 <a href="/topics/]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 内容流水线"/>
    <category term="BullMQ"/>
    <category term="Node.js"/>
    <category term="任务队列"/>
    <category term="AI 总结"/>
  </entry>
  <entry>
    <title>Node.js 内容管线从 JSON 迁移到 SQLite：表结构、事务、备份与回滚</title>
    <link href="https://www.githubmissyang.cn/articles/nodejs-content-pipeline-json-to-sqlite-migration" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/nodejs-content-pipeline-json-to-sqlite-migration</id>
    <updated>2026-08-13T15:30:00+08:00</updated>
    <published>2026-08-13T15:30:00+08:00</published>
    <summary>一套适合小型 Node.js 内容站的渐进式迁移方案：把 JSON 文件中的文章移入 SQLite，补齐约束、事务、备份、校验和可回滚切换，同时保留 JSON 导出能力。</summary>
    <content type="html"><![CDATA[<h1>Node.js 内容管线从 JSON 迁移到 SQLite：表结构、事务、备份与回滚</h1>
<p>JSON 文件很适合内容站的第一版：无需数据库服务、可直接查看，也方便放进备份。但当抓取、AI 总结、人工审核和定时发布同时读写一个文件时，问题会逐渐出现：一次写入可能覆盖另一个任务的结果，按标签或状态查询需要每次加载全部数据，唯一性只能依赖应用代码，失败后的恢复也常常是整文件回退。</p>
<p>SQLite 仍然是单文件，却能提供事务、索引、唯一约束和成熟的备份工具。对单机部署的 Node.js 内容管线，它通常是从 JSON 向数据库演进时成本较低的一步。本文给出一条可暂停、可校验、可回滚的迁移路径，不假设迁移后性能一定提升，也不把示例结果冒充线上实测。</p>
<h2>什么时候值得迁移</h2>
<p>不要只因为文章数量增长就立即换数据库。以下信号同时出现两项以上，迁移通常更有价值：</p>
<ul>
<li>抓取任务、AI 归纳和后台编辑可能并发写入；</li>
<li>需要按 <code>status</code>、<code>workflowStatus</code>、分类或发布时间组合查询；</li>
<li>slug、外部文章 URL 或任务 ID 必须唯一；</li>
<li>一篇文章更新失败时，不希望影响整个内容文件；</li>
<li>发布前需要稳定快照，出错后要快速恢复；</li>
<li>JSON 文件已经频繁产生难以审阅的大段 diff。</li>
</ul>
<p>如果站点始终是单进程、少量内容、只由一个管理员更新，继续使用 JSON 并采用“临时文件写入后原子替换”也完全合理。</p>
<h2>先定义迁移边界</h2>
<p>这次只替换文章的持久化层，不同时重写抓取器、模型调用和页面渲染。先抽出仓储接口：</p>
<pre><code class="language-js">export interface ArticleRepository {
  list(options): Promise&lt;Array&gt;;
  getBySlug(slug): Promise&lt;object | null&gt;;
  upsert(article): Promise&lt;void&gt;;
  publish(id): Promise&lt;void&gt;;
}
</code></pre>
<p>JavaScript 本身不识别上面的 <code>interface</code>，它只是接口约定。如果项目使用纯 JavaScript，可以直接实现同名方法；使用 TypeScript 时再写成正式类型。迁移期间保留 <code>JsonArticleRepository</code>，新增 <code>SqliteArticleRepository</code>，由环境变量或配置选择。这样回滚不需要改业务调用链。</p>
<h2>表结构：稳定字段列化，扩展字段保留 JSON</h2>
<p>下面示例使用 Node.js 22.5 以后提供的 <code>node:sqlite</code>。该模块在不同 Node 版本中的稳定性标记和 API 可能不同，生产使用前应核对当前运行时文档；如果项目使用 LTS 旧版本，也可以把数据库调用替换为 <code>better-sqlite3</code>，表结构和迁移原则不变。</p>
<pre><code class="language-sql">PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS articles (
  id TEXT PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  summary TEXT NOT NULL DEFAULT &#39;&#39;,
  content TEXT NOT NULL DEFAULT &#39;&#39;,
  source TEXT NOT NULL,
  author TEXT NOT NULL DEFAULT &#39;&#39;,
  category TEXT NOT NULL DEFAULT &#39;&#39;,
  status TEXT NOT NULL CHECK (status IN (&#39;active&#39;, &#39;draft&#39;, &#39;archived&#39;)),
  workflow_status TEXT NOT NULL,
  content_type TEXT NOT NULL DEFAULT &#39;guide&#39;,
  primary_keyword TEXT NOT NULL DEFAULT &#39;&#39;,
  search_intent TEXT NOT NULL DEFAULT &#39;&#39;,
  article_url TEXT NOT NULL DEFAULT &#39;&#39;,
  cover_url TEXT NOT NULL DEFAULT &#39;&#39;,
  published_at TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  next_review_at TEXT,
  content_version TEXT NOT NULL DEFAULT &#39;1.0&#39;,
  reviewed_by TEXT NOT NULL DEFAULT &#39;&#39;,
  tags_json TEXT NOT NULL DEFAULT &#39;[]&#39;,
  sources_json TEXT NOT NULL DEFAULT &#39;[]&#39;,
  test_notes_json TEXT NOT NULL DEFAULT &#39;[]&#39;,
  related_site_ids_json TEXT NOT NULL DEFAULT &#39;[]&#39;
);

CREATE INDEX IF NOT EXISTS idx_articles_publish
  ON articles(status, workflow_status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_articles_category
  ON articles(category, published_at DESC);
</code></pre>
<p>标题、状态、发布时间等高频过滤字段适合独立列；标签、资料来源等目前只需整体读写的数组可以先存 JSON 文本。不要把整篇文章继续塞进一个 JSON 列，否则会失去约束与索引的主要收益。</p>
<p>时间统一保存 ISO 8601 字符串，并明确时区。SQLite 没有专用日期类型，混用本地时间和 UTC 会让排序与边界查询变得不可预测。</p>
<h2>导入脚本：一次事务，要么全部成功</h2>
<p>迁移依赖建议固定版本并进入锁文件。以下脚本以 Node 内置 SQLite API 为例，读取旧文件、校验基本结构，再在单个事务中导入：</p>
<pre><code class="language-js">// scripts/import-articles-to-sqlite.mjs
import { readFile } from &#39;node:fs/promises&#39;;
import { DatabaseSync } from &#39;node:sqlite&#39;;

const input = process.argv[2] ?? &#39;data/articles.json&#39;;
const output = process.argv[3] ?? &#39;data/content.db&#39;;
const parsed = JSON.parse(await readFile(input, &#39;utf8&#39;));
const articles = Array.isArray(parsed) ? parsed : parsed.articles;
if (!Array.isArray(articles)) throw new Error(&#39;articles 必须是数组&#39;);

const db = new DatabaseSync(output);
db.exec(&#39;PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;&#39;);
db.exec(`CREATE TABLE IF NOT EXISTS articles (
  id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL,
  summary TEXT NOT NULL DEFAULT &#39;&#39;, content TEXT NOT NULL DEFAULT &#39;&#39;,
  source TEXT NOT NULL, author TEXT NOT NULL DEFAULT &#39;&#39;,
  category TEXT NOT NULL DEFAULT &#39;&#39;, status TEXT NOT NULL,
  workflow_status TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT &#39;guide&#39;,
  primary_keyword TEXT NOT NULL DEFAULT &#39;&#39;, search_intent TEXT NOT NULL DEFAULT &#39;&#39;,
  article_url TEXT NOT NULL DEFAULT &#39;&#39;, cover_url TEXT NOT NULL DEFAULT &#39;&#39;,
  published_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
  next_review_at TEXT, content_version TEXT NOT NULL DEFAULT &#39;1.0&#39;,
  reviewed_by TEXT NOT NULL DEFAULT &#39;&#39;, tags_json TEXT NOT NULL DEFAULT &#39;[]&#39;,
  sources_json TEXT NOT NULL DEFAULT &#39;[]&#39;, test_notes_json TEXT NOT NULL DEFAULT &#39;[]&#39;,
  related_site_ids_json TEXT NOT NULL DEFAULT &#39;[]&#39;
)`);

const insert = db.prepare(`INSERT INTO articles (
  id, slug, title, summary, content, source, author, category, status,
  workflow_status, content_type, primary_keyword, search_intent, article_url,
  cov]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Node.js"/>
    <category term="SQLite"/>
    <category term="数据迁移"/>
    <category term="内容工程"/>
  </entry>
  <entry>
    <title>技术博客文章内链怎么做：主题集群、锚文本与孤儿页检查</title>
    <link href="https://www.githubmissyang.cn/articles/technical-blog-internal-link-topic-cluster-anchor-orphan-pages" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/technical-blog-internal-link-topic-cluster-anchor-orphan-pages</id>
    <updated>2026-08-13T07:00:00.000Z</updated>
    <published>2026-08-13T07:00:00.000Z</published>
    <summary>以本站 GLM API 与 AI 内容工程专题为例，说明如何用主题集群、上下文锚文本、系列页和脚本化孤儿页检查建立可维护的内链路径，同时避免堆砌关键词或承诺内链必然带来排名增长。</summary>
    <content type="html"><![CDATA[<h1>技术博客文章内链怎么做：主题集群、锚文本与孤儿页检查</h1>
<p>技术博客的文章越写越多，常见问题不是没有内容，而是内容彼此断开：新文章只出现在列表第一页，旧文章没有入口；同一主题写了多篇，却无法让读者继续深入；锚文本全部写成“点击这里”或堆叠关键词。这样的站点对人不友好，也增加了搜索引擎发现和理解页面关系的难度。</p>
<p>技术博客文章内链的目标不是给每篇文章机械塞入固定数量的链接，而是建立清楚的知识路径：读者从问题入口进入，先理解全貌，再按需要走到配置、实现、测试和排错页面。本文以本站已经发布的 GLM API 与“AI 内容工程”专题为真实案例，说明如何设计主题集群、选择锚文本，并用脚本发现孤儿页。</p>
<h2>一、先明确内链解决的三个问题</h2>
<p>第一是发现。Google Search Central 说明，链接通常应使用可抓取的 <code>&lt;a&gt;</code> 元素和可解析的 <code>href</code>。如果文章只依赖 JavaScript 点击事件、站内搜索或后台入口，爬虫和读者都可能难以发现它。</p>
<p>第二是理解。文章之间的链接文字、上下文和层级可以表达关系。例如“GLM-4.7-Flash API 配置”比“更多内容”更能说明目标页面解决什么问题，但锚文本仍应自然，不能为了关键词把一句话写得生硬。</p>
<p>第三是导航。读者完成基础接入后，通常会继续关心结构化输出、多模型容错和总结质量。如果正文恰好提供下一步入口，就能减少返回列表反复寻找的成本。</p>
<p>内链只是信息架构的一部分。它不能替代原创内容、规范 URL、canonical、站点地图或页面性能，也不应被描述为必然提升排名的技巧。</p>
<h2>二、用主题集群组织技术文章</h2>
<p>一个可维护的主题集群通常包含三种页面：</p>
<ol>
<li><strong>专题页或支柱页</strong>：概述主题范围，列出学习顺序和核心文章。</li>
<li><strong>问题型文章</strong>：针对一个明确搜索意图给出完整答案。</li>
<li><strong>关联文章</strong>：覆盖实现后的测试、排错、迁移和运维问题。</li>
</ol>
<p>以本站“AI 内容工程”专题为例，可以把内容路径设计为：</p>
<ul>
<li>入口：<a href="/articles/glm-4-7-flash-api-nodejs-guide">GLM-4.7-Flash API 配置与 Node.js 接入</a>；</li>
<li>数据可靠性：<a href="/articles/glm-4-7-flash-json-output-nodejs">GLM-4.7-Flash 结构化 JSON 输出</a>；</li>
<li>配置扩展：<a href="/articles/openai-compatible-multi-model-config-nodejs">OpenAI 兼容 API 多模型统一配置</a>；</li>
<li>运行可靠性：<a href="/articles/nodejs-multi-model-round-robin-failover">Node.js 多模型轮询与故障切换</a>；</li>
<li>内容生产：<a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">Node.js AI 总结管线设计</a>；</li>
<li>质量保障：<a href="/articles/ai-summary-quality-evaluation-regression-testing">AI 总结质量评估与回归测试</a>；</li>
<li>内容组织：<a href="/articles/nodejs-ai-content-classification-confidence-review">可审核的 AI 自动分类</a>。</li>
</ul>
<p>这不是按发布时间排列，而是按用户任务排列。专题页负责展示全貌，具体文章互相链接到“必要前置”和“自然下一步”。例如 API 接入文可以链接结构化输出，结构化输出文可以链接总结管线；总结管线再链接质量评估，而不需要每篇文章都链接整个集群。</p>
<h2>三、锚文本应该说明目标页的价值</h2>
<p>好的锚文本让读者在点击前知道会看到什么。可以采用以下写法：</p>
<pre><code class="language-md">如果接口已经接通，但 JSON 偶尔解析失败，可继续查看
[GLM-4.7-Flash 结构化 JSON 输出与校验](/articles/glm-4-7-flash-json-output-nodejs)。
</code></pre>
<p>相比之下，下面两种写法都不理想：</p>
<pre><code class="language-md">[点击这里](/articles/glm-4-7-flash-json-output-nodejs)
`技术博客文章内链 技术博客文章内链 最佳内链（错误示例）`
</code></pre>
<p>前者缺少目的，后者是关键词堆叠。实操时遵循四条规则：</p>
<ul>
<li>把目标页面的主题融入自然句子；</li>
<li>同一目标页允许使用符合上下文的不同表述；</li>
<li>不用完整 URL 作为可见文本，除非读者确实需要复制地址；</li>
<li>链接附近补充“为什么值得继续读”，不要只放孤立标题。</li>
</ul>
<p>站内链接优先使用稳定的相对路径 <code>/articles/&lt;slug&gt;</code>。修改标题通常不影响路径；如果必须修改 slug，应建立永久重定向，同时更新正文、专题页、站点地图和 canonical。</p>
<h2>四、每篇文章如何安排链接</h2>
<p>发布前先回答三个问题：读者需要什么前置知识？完成本文后最可能做什么？本文属于哪个专题？答案决定链接，而不是预设“必须放五条”。</p>
<p>以“AI 总结质量怎么测”为例，开头可以链接<a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">抓取与总结分离的流水线设计</a>，说明测试对象从哪里产生；正文谈到供应商差异时，可以链接<a href="/articles/openai-compatible-multi-model-config-nodejs">多模型统一配置</a>；结尾则回到 AI 内容工程专题，让读者选择分类或存储迁移方向。</p>
<p>建议把链接分散在真正相关的段落，同时保留文末“相关文章”作为补充。只依赖文末推荐组件并不稳妥，因为组件可能基于标签自动变化，而正文链接表达的是作者确认过的语义关系。</p>
<h2>五、用 Node.js 检查孤儿页</h2>
<p>孤儿页是没有其他可抓取站内页面链接到它的页面。文章可能仍在 sitemap 中，也可能通过外链被发现，但站内导航关系已经断开。检查时应分别统计正文链接、专题页入口和列表页入口，不要简单认为“进了 sitemap 就不是孤儿页”。</p>
<p>假设文章数据是一个 JSON 数组，正文位于 <code>content</code>，slug 位于 <code>slug</code>，可以先检查文章之间的链接：</p>
<pre><code class="language-js">import fs from &#39;node:fs&#39;;

const raw = JSON.parse(fs.readFileSync(&#39;./data/articles.json&#39;, &#39;utf8&#39;));
const articles = Array.isArray(raw) ? raw : raw.articles;
const active = articles.filter(item =&gt;
  item.source === &#39;original&#39; &amp;&amp; item.status === &#39;active&#39;
);
const known = new Set(active.map(item =&gt; item.slug));
const incoming = new Map(active.map(item =&gt; [item.slug, new Set()]));
const broken = [];
const pattern = /\/articles\/([a-z0-9-]+)/g;

for (const article of active) {
  for (const match of article.content.matchAll(pattern)) {
    const target = match[1];
    if (!known.has(target)) {
      broken.push({ from: article.slug, target });
      continue;
    }
    if (target !== article.slug) incoming.get(target).add(article.slug);
  }
}

const orphans = active
  .filter(item =&gt; incoming.get(item.slug).size === 0)
  .map(item =&gt; item.slug);

console.log({ orphans, broken });
</code></pre>
<p>这个脚本只检查 Markdown 正文。实际项目还应解析专题配置、导航和服务端渲染结果，并设置允许列表：新发布但尚未加入集群的草稿、法律声明等页面可能不适用相同规则。</p>
<p>还要检查三类异常：链接指向不存在的 slug；文章链接到自己；旧 slug 已重定向但正文仍未更新。对生产站点，可以把检查放进发布流程，发现失效链接时阻止发布，发现孤儿页时给出警告并要求人工确认。</p>
<h2>六、建立可持续的发布审核表</h2>
<p>每篇原创文章发布前执行以下检查：</p>
<ul>
<li>已归入一个明确专题，并能从专题页进入；</li>
<li>正文至少有一个必要前置或相关问题入口；</li>
<li>已有文章中至少有一篇能自然链接回新文章；</li>
<li>锚文本可独立说明目标，不使用“这里”“更多”作为唯一信息；</li>
<li>所有 <code>/articles/&lt;slug&gt;</code> 均存在且状态可公开；</li>
<li>sitemap、canonical 和结构化数据使用统一的首选域名；</li>
<li>更新旧文时同步检查原有链接是否仍准确。</li>
</ul>
<p>最容易遗漏的是第三项：新文章链接到多篇旧文，并不代表新文章获得了入链。发布新文时，应选择一至两篇最相关的旧文加入自然链接，或者把它加入专题页的清晰位置。这样才能避免内容持续增长后产生新的孤儿页。</p>
<h2>七、衡量时关注结构健康，而非承诺排名</h2>
<p>内链优化可以记录可验证的工程指标：原创页面总数、孤儿页数量、失效站内链接数量、每个专题覆盖的文章数，以及搜索引擎抓取报告中是否出现异常。还可以观察 Search Console 中页面发现与索引状态的长期变化。</p>
<p>这些数据用于发现问题，不应被包装成因果承诺。某篇文章是否获得展示和访问，还与搜索需求、内容独特性、页面质量及竞争环境有关。可靠的目标是：让每个]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="技术博客文章内链"/>
    <category term="主题集群"/>
    <category term="锚文本"/>
    <category term="孤儿页"/>
    <category term="技术 SEO"/>
  </entry>
  <entry>
    <title>OpenAI 兼容 API 多模型统一配置：provider、baseURL、模型名与密钥安全</title>
    <link href="https://www.githubmissyang.cn/articles/openai-compatible-multi-model-config-nodejs" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/openai-compatible-multi-model-config-nodejs</id>
    <updated>2026-08-13T04:00:00.000Z</updated>
    <published>2026-08-13T04:00:00.000Z</published>
    <summary>用一套可验证的配置结构管理多个 OpenAI 兼容模型，厘清 provider、baseURL、model 与 API Key 的职责，并实现安全存储、连通测试和后台脱敏展示。</summary>
    <content type="html"><![CDATA[<h1>OpenAI 兼容 API 多模型统一配置：provider、baseURL、模型名与密钥安全</h1>
<p>很多模型服务都提供与 OpenAI Chat Completions 相近的请求格式：发送 <code>model</code> 和 <code>messages</code>，再从 <code>choices[0].message.content</code> 读取结果。接口外形相似，不代表所有配置可以混在一起。</p>
<p>最常见的接入错误不是提示词写得不好，而是把供应商名称、接口地址、模型名和密钥的职责弄混：有人把完整 <code>/chat/completions</code> 地址当成 SDK 的 base URL，有人更换了 provider 却继续使用旧密钥，还有人只测试 HTTP 200，没有检查响应中是否真的有正文。</p>
<p>本文只解决一件事：建立一套可管理、可验证、不会把密钥暴露给前端的多模型配置。模型轮询、自动切换与熔断属于运行策略，可另见<a href="/articles/nodejs-multi-model-round-robin-failover">Node.js 多模型重试、轮询与熔断</a>。</p>
<h2>一、四个字段分别负责什么</h2>
<p>一条模型配置至少包含以下字段：</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;glm-flash-production&quot;,
  &quot;provider&quot;: &quot;zhipu&quot;,
  &quot;name&quot;: &quot;智谱 GLM Flash&quot;,
  &quot;baseUrl&quot;: &quot;https://open.bigmodel.cn/api/paas/v4&quot;,
  &quot;model&quot;: &quot;请填写控制台当前提供的准确模型标识&quot;,
  &quot;enabled&quot;: true,
  &quot;apiKeyRef&quot;: &quot;MODEL_KEY_GLM_FLASH&quot;
}
</code></pre>
<ul>
<li><code>provider</code> 是供应商标识，用于后台筛选、显示和应用供应商差异，不应直接代替模型名。</li>
<li><code>baseUrl</code> 是接口根地址，或者在自建实现中明确约定为完整端点。整个系统必须统一一种约定。</li>
<li><code>model</code> 是请求体传给上游的精确模型标识，不能使用后台展示名称代替。</li>
<li><code>apiKeyRef</code> 指向服务端密钥，不保存密钥明文。</li>
</ul>
<p><code>name</code> 只是给管理员看的名称。即使两条配置使用相同模型，也可以分别命名为“生产总结”和“内容分类”，但它们应拥有不同的稳定 <code>id</code>。</p>
<h2>二、先统一 baseURL 约定</h2>
<p>使用原生 <code>fetch</code> 时，可以把配置约定为 API 根地址，然后由代码拼接固定路径：</p>
<pre><code class="language-js">function normalizeBaseUrl(value) {
  const url = new URL(String(value).trim());
  if (url.protocol !== &#39;https:&#39; &amp;&amp; url.hostname !== &#39;127.0.0.1&#39; &amp;&amp; url.hostname !== &#39;localhost&#39;) {
    throw new Error(&#39;公网模型接口必须使用 HTTPS&#39;);
  }
  return url.toString().replace(/\/$/, &#39;&#39;);
}

function chatCompletionsUrl(baseUrl) {
  const normalized = normalizeBaseUrl(baseUrl);
  return normalized.endsWith(&#39;/chat/completions&#39;)
    ? normalized
    : `${normalized}/chat/completions`;
}
</code></pre>
<p>按照这一约定，智谱开放平台常见的 Chat Completions 完整地址是：</p>
<pre><code class="language-text">https://open.bigmodel.cn/api/paas/v4/chat/completions
</code></pre>
<p>因此配置根地址时填写：</p>
<pre><code class="language-text">https://open.bigmodel.cn/api/paas/v4
</code></pre>
<p>如果现有系统的 <code>baseUrl</code> 字段明确要求完整端点，就应填写包含 <code>/chat/completions</code> 的地址，并确保调用层不再重复拼接。关键不在字段名字，而在全站只有一种明确约定。</p>
<p>还要注意，部分 OpenAI SDK 的 <code>baseURL</code> 可能要求 API 根路径，第三方 SDK 或代理又可能有不同规则。复制配置前必须查看当前所用客户端的文档，不能只看浏览器中能否打开 URL。</p>
<h2>三、配置与密钥必须分开保存</h2>
<p>非敏感信息可以进入普通配置文件：</p>
<pre><code class="language-json">{
  &quot;models&quot;: [
    {
      &quot;id&quot;: &quot;glm-flash-production&quot;,
      &quot;provider&quot;: &quot;zhipu&quot;,
      &quot;baseUrl&quot;: &quot;https://open.bigmodel.cn/api/paas/v4&quot;,
      &quot;model&quot;: &quot;控制台中的准确模型标识&quot;,
      &quot;apiKeyRef&quot;: &quot;MODEL_KEY_GLM_FLASH&quot;,
      &quot;enabled&quot;: true
    }
  ]
}
</code></pre>
<p>密钥则只存在服务端环境变量、加密配置或专用密钥管理系统中：</p>
<pre><code class="language-js">function resolveApiKey(modelConfig) {
  const key = process.env[modelConfig.apiKeyRef];
  if (!key) throw new Error(`缺少模型密钥：${modelConfig.apiKeyRef}`);
  return key;
}
</code></pre>
<p>不要把 API Key 写进 <code>admin.js</code>、HTML、公开 JSON、日志、错误消息或 Git 仓库。管理后台读取配置时，只返回 <code>hasKey: true</code> 和脱敏提示，不返回可逆的密钥内容：</p>
<pre><code class="language-js">function toPublicModel(model, secrets) {
  return {
    id: model.id,
    name: model.name,
    provider: model.provider,
    baseUrl: model.baseUrl,
    model: model.model,
    enabled: model.enabled,
    hasKey: Boolean(secrets[model.id])
  };
}
</code></pre>
<p>编辑配置时，空白密钥应表示“保留原密钥”，而不是覆盖为空。只有管理员明确点击“替换密钥”或“删除密钥”时才修改秘密存储。日志应记录配置 ID 和操作人，不应记录密钥值。</p>
<h2>四、建立一层统一配置解析器</h2>
<p>调用业务不应到处判断 <code>provider === &#39;zhipu&#39;</code>。先把保存格式转换为统一的运行时配置：</p>
<pre><code class="language-js">function buildRuntimeConfig(saved, secretStore) {
  if (!saved.id || !saved.provider || !saved.model) {
    throw new Error(&#39;模型配置缺少 id、provider 或 model&#39;);
  }

  return {
    id: saved.id,
    provider: saved.provider,
    endpoint: chatCompletionsUrl(saved.baseUrl),
    model: saved.model,
    apiKey: secretStore.get(saved.id),
    headers: { &#39;Content-Type&#39;: &#39;application/json&#39; }
  };
}
</code></pre>
<p>供应商确实需要额外请求头或参数时，可以在适配器层声明白名单，而不是允许管理员保存任意 JavaScript：</p>
<pre><code class="language-js">const PROVIDER_ADAPTERS = {
  openai: config =&gt; config,
  zhipu: config =&gt; config,
  internal: config =&gt; ({
    ...config,
    headers: { ...config.headers, &#39;X-Client&#39;: &#39;ai-daily&#39; }
  })
};
</code></pre>
<p>配置抽象的目标不是假设所有供应商完全相同，而是把共同字段统一，把少量差异限制在可审计的位置。</p>
<p>如果你是在 Agent Harness 中配置公司网关，可参考<a href="/articles/deepseek-harness-chinese-guide-install-provider-plugin">DeepSeek Harness 的自定义 Provider 与兼容参数实测</a>。需要特别核对 <code>developer</code> 角色、<code>max_tokens</code> 与 <code>max_completion_tokens</code> 等请求形状差异，不能只确认地址和密钥有效。</p>
<h2>五、“测试模型”要测试什么</h2>
<p>保存成功只说明 JSON 能写入，不能说明模型可用。后台应提供独立的]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="OpenAI 兼容 API"/>
    <category term="多模型配置"/>
    <category term="Node.js"/>
    <category term="API Key 安全"/>
    <category term="GLM"/>
  </entry>
  <entry>
    <title>大模型 Token 成本核算与预算控制：从 usage 到每批任务成本</title>
    <link href="https://www.githubmissyang.cn/articles/llm-token-cost-calculation-usage-batch-budget-control" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/llm-token-cost-calculation-usage-batch-budget-control</id>
    <updated>2026-08-13T10:00:00+08:00</updated>
    <published>2026-08-13T10:00:00+08:00</published>
    <summary>一套不依赖固定单价的大模型 Token 成本核算方法：统一供应商 usage 口径，按价格版本计算每次调用与每批任务成本，并处理缓存 Token、推理 Token、失败请求和真实账单对账。</summary>
    <content type="html"><![CDATA[<p>大模型接入生产系统后，成本问题很快会从“一个 Token 多少钱”变成更难回答的工程问题：一次任务可能调用多个模型，输入里既有固定 Prompt 又有动态正文，供应商对缓存和推理 Token 的定义不同，失败请求甚至拿不到 usage。要让成本可控，不能只在月底查看账单，而要建立一条从原始响应到任务、批次、预算和账单的核算链路。</p>
<p>本文不记录任何具体单价。模型价格会调整，同名模型也可能出现不同版本、区域或计费层级；把某个时间点的价格写死在业务代码或文章中，反而容易制造错误。正确做法是保存带生效时间的价格版本，并以供应商当前官方价格页和最终账单为准。</p>
<h2>一、先定义要回答的三个成本问题</h2>
<p>一个可用的核算系统至少要回答：</p>
<ol>
<li>单次调用预计花费多少？</li>
<li>一篇文章、一次日报或一批抓取任务总共花费多少？</li>
<li>内部估算与供应商真实账单为什么存在差异？</li>
</ol>
<p>这三个问题对应三层数据：调用层记录 usage，任务层聚合业务成本，账单层负责最终校准。不要只保存每天的 Token 总量，否则无法定位是哪类内容、哪个模型或哪次重试推高了成本。</p>
<p>如果系统还没有稳定的任务边界，可以先参考<a href="/articles/ai-content-pipeline-observability-logs-metrics-traces-alerts">AI 内容管线可观测性</a>，为一次业务运行分配 <code>runId</code>，再为批次分配 <code>batchId</code>。</p>
<h2>二、不要假设所有供应商的 usage 字段相同</h2>
<p>OpenAI 兼容接口通常返回 <code>prompt_tokens</code>、<code>completion_tokens</code> 和 <code>total_tokens</code>，但“兼容”不等于计费语义完全一致。不同供应商或接口版本还可能提供缓存命中、缓存写入、推理 Token、音频 Token 等明细。</p>
<p>因此，建议同时保存两份数据：</p>
<ul>
<li><code>rawUsage</code>：原样保存供应商响应中的 usage，便于追溯；</li>
<li><code>normalizedUsage</code>：映射到站内统一字段，用于聚合和告警。</li>
</ul>
<p>统一结构可以从下面这组字段开始：</p>
<pre><code class="language-js">function normalizeUsage({ provider, model, usage }) {
  return {
    provider,
    model,
    inputTokens: usage?.prompt_tokens ?? usage?.input_tokens ?? null,
    outputTokens: usage?.completion_tokens ?? usage?.output_tokens ?? null,
    cachedInputTokens:
      usage?.prompt_tokens_details?.cached_tokens ??
      usage?.cache_read_input_tokens ?? null,
    cacheWriteTokens: usage?.cache_creation_input_tokens ?? null,
    reasoningTokens:
      usage?.completion_tokens_details?.reasoning_tokens ?? null,
    totalTokens: usage?.total_tokens ?? null,
    rawUsage: usage ?? null
  };
}
</code></pre>
<p>字段缺失时保留 <code>null</code>，不要擅自填成 0。<code>null</code> 表示未知，0 表示供应商明确报告没有产生该类 Token，两者在成本审计时含义完全不同。</p>
<h2>三、价格必须版本化，而不是散落在代码里</h2>
<p>价格表至少需要模型、币种、单位、生效区间、输入价、输出价和特殊 Token 价格。每次计算都保存所使用的 <code>priceVersion</code>，这样即使下个月价格改变，也能复算历史任务。</p>
<pre><code class="language-json">{
  &quot;provider&quot;: &quot;example&quot;,
  &quot;model&quot;: &quot;model-version-id&quot;,
  &quot;currency&quot;: &quot;USD&quot;,
  &quot;unitTokens&quot;: 1000000,
  &quot;effectiveFrom&quot;: &quot;YYYY-MM-DDT00:00:00Z&quot;,
  &quot;effectiveTo&quot;: null,
  &quot;inputRate&quot;: null,
  &quot;outputRate&quot;: null,
  &quot;cachedInputRate&quot;: null,
  &quot;cacheWriteRate&quot;: null,
  &quot;reasoningBillingMode&quot;: &quot;provider-defined&quot;,
  &quot;sourceUrl&quot;: &quot;供应商官方价格页&quot;
}
</code></pre>
<p>示例故意不填单价。部署时应由管理员根据官方页面录入并复核，而不是从第三方文章复制。模型别名也要解析到实际计费版本；如果响应能返回具体模型版本，优先记录响应值。</p>
<p>一次调用的基础计算可以写成纯函数：</p>
<pre><code class="language-js">function tokenCost(tokens, rate, unitTokens) {
  if (tokens == null || rate == null) return null;
  return tokens / unitTokens * rate;
}

function estimateCost(usage, price) {
  const uncachedInput = usage.inputTokens == null
    ? null
    : Math.max(0, usage.inputTokens - (usage.cachedInputTokens ?? 0));

  const parts = {
    input: tokenCost(uncachedInput, price.inputRate, price.unitTokens),
    cachedInput: tokenCost(usage.cachedInputTokens, price.cachedInputRate, price.unitTokens),
    cacheWrite: tokenCost(usage.cacheWriteTokens, price.cacheWriteRate, price.unitTokens),
    output: tokenCost(usage.outputTokens, price.outputRate, price.unitTokens)
  };

  const known = Object.values(parts).filter(Number.isFinite);
  return {
    parts,
    estimatedCost: known.length === Object.keys(parts).length
      ? known.reduce((sum, value) =&gt; sum + value, 0)
      : null,
    complete: known.length === Object.keys(parts).length
  };
}
</code></pre>
<p>实际实现必须按供应商规则调整。尤其要确认缓存 Token 是包含在输入 Token 中还是独立计数，以及推理 Token 是否已经包含在输出 Token 中。没有看懂官方口径之前，不要简单相加，否则可能重复计费。</p>
<h2>四、缓存 Token 与推理 Token 要单独建模</h2>
<p>缓存命中通常不能直接等同于“免费”。有的供应商分别计价缓存读取和缓存写入，有的按折扣输入计费，还有的对缓存有效期或最小前缀有要求。核算时至少记录：</p>
<ul>
<li>缓存写入 Token；</li>
<li>缓存读取或命中 Token；</li>
<li>未缓存输入 Token；</li>
<li>对应价格规则和价格版本。</li>
</ul>
<p>推理 Token 也不能一概而论。部分推理模型会在 usage 明细中报告 reasoning tokens，但最终计费字段、可见输出字段与总输出字段之间的关系由供应商定义。正确的策略是保存原始明细，使用供应商文档规定的计费口径，并通过账单对账验证，而不是自行推断。</p>
<h2>五、失败请求不能默认成本为零</h2>
<p>请求失败有多种阶段：连接建立前失败、供应商接受后超时、流式输出中断、返回错误状态、客户端拿到内容但没有 usage。只有供应商明确说明未计费时，才能把成本认定为零。</p>
<p>建议给每次调用增加成本状态：</p>
<ul>
<li><code>calculated</code>：usage 完整且价格版本匹配；</li>
<li><code>estimated</code>：使用本地 Token 估算或不完整口径；</li>
<li><code>unknown</code>：请求可能已被处理，但没有可靠 usage；</li>
<li><code>reconciled</code>：已与供应商账单核对。</li>
</ul>
<p>对于 <code>unknown</code>，预算系统可采用保守占用：按请求前估算的输入 Token 加最大输出上限预留预算，等账单到达后再释放或修正。这样不会因为大量超时和重试，让实时仪表盘错误地显示“零成本”。重试策略还应记录 <code>attempt</code> 和幂等业务标识，具体做法可参考<a href="/articles/llm-api-429-timeout-5xx-retry-nodejs">大模型 API 429、超时和 5xx 排查</a>。</p>
<h2>六、从单次调用聚合到每批任务成本</h2>
<p>每条成本记录应带上业务维度，而不只是模型维度：</p>
<pre><code class="language-json">{
  &quot;requestId&quot;: &quot;供应商或本地请求标识&quot;,
  &quot;runId&quot;: &quot;一次业务运行&quot;,
  &quot;batchId&quot;: &quot;一批任务&quot;,
  &quot;articleId&quot;: &quot;可选的内容标识&quot;,
  &quot;operation&quot;: &qu]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Token 成本"/>
    <category term="大模型 API"/>
    <category term="预算控制"/>
    <category term="成本治理"/>
    <category term="Node.js"/>
  </entry>
  <entry>
    <title>用 Node.js 搭建可审核的 AI 自动分类：候选标签、置信度与人工兜底</title>
    <link href="https://www.githubmissyang.cn/articles/nodejs-ai-content-classification-confidence-review" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/nodejs-ai-content-classification-confidence-review</id>
    <updated>2026-08-13T00:00:00.000Z</updated>
    <published>2026-08-13T00:00:00.000Z</published>
    <summary>面向 Node.js 内容管线，说明如何把候选标签、受约束 JSON、置信度阈值、人工复核和审计记录串成可回滚的 AI 自动分类流程，避免自由生成标签造成分类漂移与误发布。</summary>
    <content type="html"><![CDATA[<h2>为什么不能直接让模型自由分类</h2>
<p>最简单的提示词通常是“请给这篇文章生成一个分类和几个标签”。这种方式适合演示，却不适合长期运行。模型可能今天返回“AI 工具”，明天返回“人工智能应用”，下周又返回“开发工具”。名称意思接近，但网站会因此生成多个分类页，稀释内部链接。</p>
<p>生产级自动分类至少要解决四件事：</p>
<ol>
<li>分类只能从固定集合中选择；</li>
<li>输出必须能被程序解析和校验；</li>
<li>低把握结果不能直接发布；</li>
<li>每次运行需要保留模型、时间和原始结果。</li>
</ol>
<pre><code class="language-js">const CATEGORIES = [
  &#39;AI 工具与应用&#39;,
  &#39;模型与研究&#39;,
  &#39;编程与架构&#39;,
  &#39;行业观察&#39;,
  &#39;产品与创业&#39;,
  &#39;教程与实践&#39;,
  &#39;开源项目&#39;,
  &#39;其他&#39;
];
</code></pre>
<p>分类体系不应频繁变化。内容数量还不多时，少量明确的一级分类比大量空分类页更容易维护。</p>
<h2>第一步：准备最小分类输入</h2>
<p>不要把导航、广告、相关推荐和全部 HTML 都交给模型。分类通常只需要标题、摘要和清洗后的正文片段。</p>
<pre><code class="language-js">function buildArticleInput(article) {
  return {
    id: article.id,
    title: String(article.title || &#39;&#39;).trim(),
    summary: String(article.summary || &#39;&#39;).trim(),
    excerpt: String(article.content || &#39;&#39;)
      .replace(/&lt;[^&gt;]+&gt;/g, &#39; &#39;)
      .replace(/\s+/g, &#39; &#39;)
      .trim()
      .slice(0, 4000)
  };
}
</code></pre>
<p><code>4000</code> 只是示例上限，应结合文章长度、模型上下文和实际账单调整。</p>
<h2>第二步：要求模型返回受约束的 JSON</h2>
<p>提示词应列出允许的分类，并要求返回主分类、候选分类、标签、置信度和简短理由。置信度只是模型对自身判断的描述，不等同于经过统计校准的正确率，只能作为分流信号。JSON 解析细节可阅读<a href="/articles/glm-4-7-flash-json-output-nodejs">GLM 结构化 JSON 输出与解析</a>。</p>
<pre><code class="language-js">function buildPrompt(article) {
  return `你是中文技术内容编辑。
可选分类：${CATEGORIES.join(&#39;、&#39;)}
category 必须选择一个可选分类；tags 返回 2 到 4 个；
confidence 是 0 到 1 的数字；reason 不超过 50 个汉字；
只输出 JSON，不要输出 Markdown。
文章：${JSON.stringify(article)}`;
}
</code></pre>
<h2>第三步：调用 OpenAI 兼容接口</h2>
<p>API Key 必须从服务端配置读取，不要写进前端代码或提交到 Git。</p>
<pre><code class="language-js">async function classifyWithModel(article, config) {
  const response = await fetch(config.baseUrl, {
    method: &#39;POST&#39;,
    headers: {
      Authorization: `Bearer ${config.apiKey}`,
      &#39;Content-Type&#39;: &#39;application/json&#39;
    },
    body: JSON.stringify({
      model: config.model,
      temperature: 0,
      messages: [{ role: &#39;user&#39;, content: buildPrompt(buildArticleInput(article)) }]
    }),
    signal: AbortSignal.timeout(30_000)
  });

  const rawText = await response.text();
  if (!response.ok) {
    throw new Error(`分类请求失败：HTTP ${response.status}，${rawText.slice(0, 300)}`);
  }
  const payload = JSON.parse(rawText);
  const content = payload.choices?.[0]?.message?.content;
  if (!content) throw new Error(&#39;模型响应中没有正文&#39;);
  return { parsed: JSON.parse(content), rawResponse: content, usage: payload.usage || null };
}
</code></pre>
<p>部分服务支持 JSON Schema 或 <code>response_format</code>，应以供应商当前官方文档为准。即使开启结构化输出，服务端仍需保留校验。</p>
<h2>第四步：写入数据前严格校验</h2>
<pre><code class="language-js">function validateClassification(result) {
  const errors = [];
  if (!CATEGORIES.includes(result.category)) errors.push(&#39;category 不在允许列表中&#39;);
  if (!Array.isArray(result.tags) || result.tags.length &lt; 2 || result.tags.length &gt; 4) {
    errors.push(&#39;tags 数量必须为 2 到 4 个&#39;);
  }
  if (typeof result.confidence !== &#39;number&#39; || result.confidence &lt; 0 || result.confidence &gt; 1) {
    errors.push(&#39;confidence 必须为 0 到 1 之间的数字&#39;);
  }
  return { ok: errors.length === 0, errors };
}
</code></pre>
<p>还可以增加标签长度、兜底分类、新标签审批和历史结果保留规则。</p>
<h2>第五步：设置人工复核队列</h2>
<pre><code class="language-js">function decideReview(result, validation) {
  if (!validation.ok) return { status: &#39;rejected&#39;, reason: validation.errors.join(&#39;；&#39;) };
  if (result.category === &#39;其他&#39;) return { status: &#39;needs_review&#39;, reason: &#39;兜底分类&#39; };
  if (result.confidence &lt; 0.75) return { status: &#39;needs_review&#39;, reason: &#39;低于示例阈值&#39; };
  return { status: &#39;auto_approved&#39;, reason: &#39;通过格式和阈值检查&#39; };
}
</code></pre>
<p><code>0.75</code> 只是演示值，不是通用最佳阈值。应使用人工标注文章观察错误，再决定自动通过条件。建议保存文章 ID、模型、供应商、提示词版本、执行时间、解析结果、原始响应和 usage。</p>
<h2>第六步：避免覆盖人工修改</h2>
<p>后台应区分“AI 建议分类”和“当前正式分类”。编辑确认后设置 <code>classificationLocked</code>，重新运行任务时跳过锁定文章。批处理可沿用<a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">抓取、总结和失败重试拆分的管线设计</a>，并按需加入<a href="/articles/nodejs-multi-model-round-robin-failover">多模型轮询与故障切换</a>。</p>
<h2>如何进行可核验的分类评估</h2>
<p>不要凭感觉写“准确率很高”。更可靠的方法是：</p>
<ol>
<li>从不同栏目抽取真实文章；</li>
<li>隐藏原分类，由人工重新标注；</li>
<li>固定模型名、提示词版本和测试时间；</li>
<li>比较模型主分类与人工分类；</li>
<li>分别统计自动通过、进入复核和格式失败；</li>
<li>保存原始结果，而不只保留汇总数字。</li>
</ol>
<p>完成真实测试以前，不应发布推测出来的准确率、成本或性能数字。</p>
<h2>上线前检查清单</h2>
<ul>
<li>分类名称来自固定白名单；</li>
<li>API Key 只保存在服务端；</li>
<li>输出经过 JSON 和业务规则双重校验；</li>
<li>人工确认的分类不会被覆盖；</li>
<li>失败记录可查看并可单独重跑；</li>
<li>保存模型名、提示词版本和执行时间；</li>
<li>内容不足的分类页不应批量开放索引。</li>
</ul>
<h2>FAQ</h2>
<h3>为什么要求 JSON 后还需要再次校验？</h3>
<p>提示词不是强制协议。模型、代理或网络异常都可能返回缺字段、代码块甚至错误页面。服务端校验是写入前最后一道保护。</p>
<h3>置信度低于多少必须人工审核？</h3>
<p>没有通用数字。应根据人工标注样本和可接受的错误成本确定。</p>
<h3>可以让模型自由创建新分类吗？</h3>
<p>不建议直接创建正式分类。可以把建议保存到候选区，由编辑合并、改名或拒绝。</p>
<h3>更换模型后需要重新分类全部文章吗？</h3>
<p>不一定。先用固定样本比较新旧模型，只重跑未锁定、低置信度或需要复审的文章。</p>
<h2>参考资料</h2>
<p>实现前应核对所用模型供应商的官方 API 文档、<a href="https://json-schema.org]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Node.js"/>
    <category term="AI 自动分类"/>
    <category term="内容工程"/>
    <category term="结构化输出"/>
  </entry>
  <entry>
    <title>Node.js AI 总结管线设计：抓取、总结分离与失败重跑</title>
    <link href="https://www.githubmissyang.cn/articles/nodejs-ai-summary-pipeline-fetch-retry" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/nodejs-ai-summary-pipeline-fetch-retry</id>
    <updated>2026-08-12T17:45:47.480Z</updated>
    <published>2026-08-12T17:45:47.480Z</published>
    <summary>基于本站生产任务的真实 429 故障，讲解如何把内容抓取和 AI 总结拆成可独立执行的阶段，保存原始快照、显示实际模型、校验结构化输出，并在限流或解析失败后安全重跑。</summary>
    <content type="html"><![CDATA[<h2>一句话结论</h2>
<p>内容抓取和 AI 总结必须拆成两个可独立执行、可重复运行的阶段。本站 2026 年 8 月 12 日的真实任务中，抓取阶段成功保存了 3 个来源共 26 条内容，随后 GLM-4.7-Flash 返回 HTTP 429；由于原始数据已经先落盘，故障只影响总结，可以稍后单独重跑，不需要重新访问所有来源。</p>
<p>如果你正在处理模型接入和结构化输出，可先阅读 <a href="/articles/glm-4-7-flash-api-nodejs-guide">GLM-4.7-Flash API 配置与实测</a> 和 <a href="/articles/glm-4-7-flash-json-output-nodejs">GLM-4.7-Flash JSON 输出实测</a>。</p>
<h2>为什么一个“立即生成日报”按钮不够</h2>
<p>最简单的日报程序会顺序执行抓取、调用模型、写入结果。只要模型超时、限流或 JSON 解析失败，整个任务就被标记为失败。管理员无法快速判断是数据源故障还是模型故障，也无法只重试失败环节。</p>
<p>更稳妥的数据流是：</p>
<pre><code class="language-text">数据源 → 抓取并校验 → 保存原始 sections
                         ↓
                 选择模型并总结
                         ↓
                 校验结构化结果
                         ↓
                    发布日报
</code></pre>
<p>抓取成功是一个独立检查点。后面的模型调用失败时，不能删除或覆盖已经保存的原始数据。</p>
<h2>本站生产故障记录</h2>
<p>8 月 12 日任务保存了以下数据：</p>
<table>
<thead>
<tr>
<th>来源</th>
<th align="right">条数</th>
</tr>
</thead>
<tbody><tr>
<td>Hacker News（AI）</td>
<td align="right">10</td>
</tr>
<tr>
<td>GitHub Trending</td>
<td align="right">8</td>
</tr>
<tr>
<td>GitHub AI 项目</td>
<td align="right">8</td>
</tr>
<tr>
<td>合计</td>
<td align="right">26</td>
</tr>
</tbody></table>
<p>总结阶段使用 <code>glm-4.7-flash</code>，接口返回 HTTP 429，业务错误码为 1305，含义是模型当前访问量较大。这个错误不应导致重新抓取，因为 26 条输入已经完整保存在当天记录中。正确操作是等待、切换健康模型或稍后点击“仅 AI 总结”。</p>
<h2>推荐的数据结构</h2>
<p>每天只保留一个记录，并允许抓取和总结分别更新自己的字段：</p>
<pre><code class="language-json">{
  &quot;date&quot;: &quot;2026-08-12&quot;,
  &quot;generatedAt&quot;: &quot;2026-08-12T00:00:00.000Z&quot;,
  &quot;sections&quot;: [
    { &quot;source&quot;: &quot;Hacker News (AI)&quot;, &quot;items&quot;: [] }
  ],
  &quot;ai&quot;: {
    &quot;model&quot;: &quot;glm-4.7-flash&quot;,
    &quot;summarizedAt&quot;: &quot;2026-08-12T00:00:06.000Z&quot;,
    &quot;error&quot;: &quot;AI HTTP 429&quot;
  }
}
</code></pre>
<p>重新抓取时只替换 <code>sections</code>，保留当天已有总结直到新总结成功；重新总结时只读取已保存的 <code>sections</code>，不访问外部数据源。</p>
<h2>抓取阶段的 Node.js 实现</h2>
<pre><code class="language-javascript">async function fetchDailyData(date) {
  const sections = [];

  for (const source of enabledSources) {
    const result = await fetchSource(source);
    if (result.ok &amp;&amp; result.items.length) {
      sections.push({ source: source.name, items: result.items });
    } else {
      log({ step: &#39;fetch&#39;, source: source.id, error: result.error });
    }
  }

  const totalItems = sections.reduce(
    (total, section) =&gt; total + section.items.length,
    0,
  );
  if (!totalItems) throw new Error(&#39;所有数据源均未抓取到内容&#39;);

  const archive = loadArchive();
  const previous = archive.days.find(day =&gt; day.date === date) ?? {};
  const report = {
    ...previous,
    date,
    generatedAt: new Date().toISOString(),
    sections,
  };

  upsertDay(archive, report);
  saveArchiveAtomically(archive);
  return { date, totalItems, sections: sections.length };
}
</code></pre>
<p>如果部分来源失败但仍有足够内容，可以继续保存并在后台显示失败来源；只有全部来源都为空时才终止。</p>
<h2>总结阶段只读取快照</h2>
<pre><code class="language-javascript">async function summarizeDaily(date) {
  const archive = loadArchive();
  const report = archive.days.find(day =&gt; day.date === date);
  if (!report?.sections?.length) {
    throw new Error(&#39;没有可总结的数据，请先执行抓取&#39;);
  }

  const model = selectHealthyModel();
  try {
    const ai = await summarizeWithAI(model, report.sections);
    report.ai = {
      ...validateSummary(ai),
      model: model.model,
      modelId: model.id,
      summarizedAt: new Date().toISOString(),
    };
    saveArchiveAtomically(archive);
    return report.ai;
  } catch (error) {
    report.ai = {
      error: sanitizeError(error),
      model: model.model,
      summarizedAt: new Date().toISOString(),
    };
    saveArchiveAtomically(archive);
    throw error;
  }
}
</code></pre>
<p>后台应该明确显示本次使用的模型、执行时间、失败原因和抓取条数，避免把“AI 总结”误解成只选一条内容。</p>
<h2>429 应该怎么处理</h2>
<p>429 不只有一种原因。智谱错误码可能表示访问量过大、并发超额、余额不足或账户状态问题。处理前应同时记录 HTTP 状态和响应体业务码。</p>
<ul>
<li>临时访问量过大：带随机抖动等待后重试一次；</li>
<li>并发超额：降低并发，进入任务队列；</li>
<li>余额或账户问题：立即停止重试并告警；</li>
<li>多模型已配置：切到健康备用模型；</li>
<li>所有模型失败：保留原始数据，允许人工重跑。</li>
</ul>
<p>不要无限重试，也不要在一个 HTTP 请求中等待几分钟。后台接口可以立即返回“任务已开始”，再通过状态接口显示进度。</p>
<h2>防止失败覆盖上一版日报</h2>
<p>模型返回 HTTP 200 后仍可能出现空 content、Markdown 围栏或字段缺失。建议先在内存中完成四层验证：</p>
<ol>
<li>传输成功：HTTP 和 choices 正常；</li>
<li>解析成功：正文可以解析为 JSON；</li>
<li>Schema 成功：summary、highlights、columns 类型正确；</li>
<li>业务成功：链接来自抓取数据，条数和分类满足配置。</li>
</ol>
<p>全部通过后才能替换已发布的 <code>ai</code> 结果。如果验证失败，保留上一版成功内容，并把错误写入单独的运行状态，而不是把公开日报变成空白页。</p>
<h2>文件写入也需要可靠性</h2>
<p>JSON 文件适合当前规模，但写入时应先写临时文件，再原子重命名，避免进程中断留下半个 JSON。多实例部署不能同时直接写同一个文件，应迁移到 SQLite 或 PostgreSQL，并用任务 ID 保证幂等。</p>
<p>同一天重复执行抓取或总结时，应更新同一条日期记录，而不是追加重复日报。归档可以只保留最近 30 天，但成功结果和运行日志要分开保存。</p>
<h2>后台需要显示哪些状态</h2>
<ul>
<li>最近一次抓取时间、来源数和总条数；</li>
<li>各来源成功或失败状态；</li>
<li>最近一次总结时间及实际模型；</li>
<li>延迟、结束原因和 Token 用量；</li>
<li>当前任务是否运行中；</li>
<li>可操作按钮：仅抓取、仅总结、完整执行；</li>
<li>失败后明确提示下一步，而不是只显示“生成失败”。</li>
</ul>
<h2>上线前测试清单</h2>
<ol>
<li>让一个数据源超时，确认其他来源仍能落盘；</li>
<li>模拟全部来源为空，确认不会调用模型；</li>
<li>模拟模型 429，确认原始 sections 保留；</li]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Node.js"/>
    <category term="AI 总结"/>
    <category term="内容抓取"/>
    <category term="任务重试"/>
    <category term="GLM-4.7-Flash"/>
  </entry>
  <entry>
    <title>GLM-4.7-Flash 结构化 JSON 输出实测：Node.js 解析、校验与重试</title>
    <link href="https://www.githubmissyang.cn/articles/glm-4-7-flash-json-output-nodejs" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/glm-4-7-flash-json-output-nodejs</id>
    <updated>2026-08-12T16:40:36.212Z</updated>
    <published>2026-08-12T16:40:36.212Z</published>
    <summary>生产环境对比“只靠提示词”和 response_format JSON 模式，解释 Markdown 围栏、answer 外层、content 为空等问题，并给出 Node.js 解析、分类白名单、字段校验和有限重试实现。</summary>
    <content type="html"><![CDATA[<h2>一句话结论</h2>
<p>使用 GLM-4.7-Flash 做文章分类、AI 摘要或数据提取时，不要只在提示词里写“只输出 JSON”。本站生产环境实测发现，这种方式可能返回 Markdown 代码围栏，导致 <code>JSON.parse</code> 直接失败。启用 <code>response_format: { type: &quot;json_object&quot; }</code> 能得到合法 JSON，但仍要验证字段结构和业务取值。</p>
<p>如果你还没有完成模型接入，请先看 <a href="/articles/glm-4-7-flash-api-nodejs-guide">GLM-4.7-Flash API 配置与实测</a>；需要多个模型容错时，可继续阅读 <a href="/articles/nodejs-multi-model-round-robin-failover">Node.js 多模型轮询与故障切换</a>。</p>
<h2>两种方式的真实对比</h2>
<p>测试时间为 2026 年 8 月 13 日，环境为本站生产服务器 Node.js 20 容器，模型为 <code>glm-4.7-flash</code>，关闭思考模式。</p>
<table>
<thead>
<tr>
<th>调用方式</th>
<th align="right">HTTP</th>
<th align="right">延迟</th>
<th>能否直接 JSON.parse</th>
<th>实际表现</th>
</tr>
</thead>
<tbody><tr>
<td>只用提示词要求 JSON</td>
<td align="right">200</td>
<td align="right">1995 ms</td>
<td>否</td>
<td>返回了 Markdown 代码围栏</td>
</tr>
<tr>
<td>response_format JSON 模式</td>
<td align="right">200</td>
<td align="right">1547 ms</td>
<td>是</td>
<td>返回合法 JSON，但增加了 answer 外层</td>
</tr>
</tbody></table>
<p>这说明接口成功、语法合法和字段符合预期是三件不同的事。JSON 模式解决的是语法问题，不会自动保证对象结构完全符合你的程序。</p>
<h2>正确的 Node.js 请求方式</h2>
<pre><code class="language-javascript">const response = await fetch(
  &#39;https://open.bigmodel.cn/api/paas/v4/chat/completions&#39;,
  {
    method: &#39;POST&#39;,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
      Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
    },
    body: JSON.stringify({
      model: &#39;glm-4.7-flash&#39;,
      messages: [
        {
          role: &#39;system&#39;,
          content: &#39;你是文章分类器。必须输出有效 JSON。&#39;,
        },
        {
          role: &#39;user&#39;,
          content: &#39;将文章分到允许分类中，并给出不超过3个标签。&#39;,
        },
      ],
      response_format: { type: &#39;json_object&#39; },
      thinking: { type: &#39;disabled&#39; },
      temperature: 0.2,
      max_tokens: 300,
    }),
    signal: AbortSignal.timeout(30_000),
  },
);

if (!response.ok) {
  throw new Error(`模型请求失败：${response.status}`);
}

const payload = await response.json();
const content = payload.choices?.[0]?.message?.content;
if (!content) throw new Error(&#39;模型返回内容为空&#39;);

const result = JSON.parse(content);
</code></pre>
<p>系统提示词中仍应明确要求 JSON。智谱官方文档说明 JSON 模式会返回有效 JSON，但程序不能因此跳过后续校验。</p>
<h2>为分类结果做业务校验</h2>
<p>假设网站只允许四个分类：</p>
<pre><code class="language-javascript">const allowedCategories = new Set([
  &#39;AI 开发教程&#39;,
  &#39;AI 工程实践&#39;,
  &#39;AI 工具与应用&#39;,
  &#39;行业观察&#39;,
]);

function validateClassification(value) {
  const candidate = value.answer ?? value;
  if (!candidate || typeof candidate !== &#39;object&#39;) {
    throw new Error(&#39;分类结果不是对象&#39;);
  }
  if (!allowedCategories.has(candidate.category)) {
    throw new Error(`不允许的分类：${candidate.category}`);
  }
  if (!Array.isArray(candidate.tags)) {
    throw new Error(&#39;tags 必须是数组&#39;);
  }
  const tags = candidate.tags
    .filter(tag =&gt; typeof tag === &#39;string&#39;)
    .map(tag =&gt; tag.trim())
    .filter(Boolean)
    .slice(0, 3);
  return { category: candidate.category, tags };
}
</code></pre>
<p>这里兼容了实测出现的 <code>answer</code> 外层，但更稳妥的做法是使用 JSON Schema 校验库，并把允许分类动态写进提示词。</p>
<h2>兼容已有的 Markdown 输出</h2>
<p>旧提示词或其他模型可能继续返回代码围栏。迁移期间可以做一次保守清理：</p>
<pre><code class="language-javascript">function parseModelJson(text) {
  const cleaned = String(text)
    .trim()
    .replace(/^```jsons*/i, &#39;&#39;)
    .replace(/s*```$/, &#39;&#39;);
  return JSON.parse(cleaned);
}
</code></pre>
<p>不要用正则从任意长文本中贪婪截取第一对花括号，这容易把解释文字或嵌套对象截坏。解析失败时应保留脱敏后的原始输出用于排查，然后有限重试或进入人工审核。</p>
<h2>AI 摘要应该校验哪些字段</h2>
<p>日报或文章摘要不应只检查“有内容”。至少验证：</p>
<ul>
<li><code>intro</code> 是非空字符串，并限制最大长度；</li>
<li><code>highlights</code> 是数组，条数符合后台配置；</li>
<li>每条精选包含标题、摘要和来源 ID；</li>
<li>来源 ID 必须存在于本次抓取数据，避免模型编造链接；</li>
<li>分类必须属于网站的固定分类集合；</li>
<li>输出中不能包含 API Key、后台 Token 等敏感信息。</li>
</ul>
<p>当某一字段不合格时，优先让模型只修复该字段，而不是重新生成整篇日报。这样更节省 Token，也减少已经合格内容发生变化。</p>
<h2>为什么 content 有时是空的</h2>
<p>开启思考模式后，模型可能先把 Token 用在 <code>reasoning_content</code>。如果 <code>max_tokens</code> 太小，最终 <code>content</code> 可能为空。分类、标签和固定结构提取通常不需要复杂推理，可以关闭思考模式；如果必须开启，则同时检查 <code>reasoning_content</code>、<code>finish_reason</code> 和 Token 用量。</p>
<h2>重试策略</h2>
<p>以下情况可以重试一次：网络超时、临时 5xx、JSON 被截断。以下情况不应该原样重试：提示词没有给出允许字段、分类集合不完整、Schema 与程序代码不一致。后者必须先修正请求，否则多模型轮询只会重复产生错误结果。</p>
<p>建议每次请求记录：配置模型、响应模型、HTTP 状态、<code>finish_reason</code>、解析结果、Schema 校验结果、延迟和 Token 用量。日志中不要写入 API Key，也尽量避免保存包含个人信息的完整正文。</p>
<h2>适合本站后台的落地流程</h2>
<ol>
<li>抓取阶段只保存原始数据，不调用模型；</li>
<li>AI 总结阶段使用 JSON 模式生成结构化结果；</li>
<li>服务端解析并执行字段、分类和来源 ID 校验；</li>
<li>校验失败只重试一次，并显示实际使用模型；</li>
<li>仍失败则保留抓取数据，允许管理员重新总结；</li>
<li>通过后再写入日报或文章库，避免半成品覆盖旧内容。</li>
</ol>
<h2>最终建议</h2>
<p>GLM-4.7-Flash 的 JSON 模式适合自动分类、摘要和字段提取，但它只是可靠链路的一环。生产环境应同时使用明确提示词、<code>response_format</code>、关闭不必要的思考、服务端 Schema 校验、允许值集合和有限重试。只有这些步骤全部通过，模型输出才能安全落库。</p>
<h2>常见问题</h2>
<h3>response_format 能保证字段完全正确吗？</h3>
<p>不能。它主要保证 JSON 语法有效，模型仍可能增加外层对象、遗漏字段或返回不允许的分类，因此服务端必须继续做 Schema 和白名单校验。</p>
<h3>为什么提示词要求 JSON 仍会返回代码围栏？</h3>
<p>自然语言约束不是协议约束，模型可能按常见写作格式包裹 Markdown。应使用 JS]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="GLM-4.7-Flash"/>
    <category term="JSON 输出"/>
    <category term="Node.js"/>
    <category term="AI 自动分类"/>
    <category term="AI 总结"/>
  </entry>
  <entry>
    <title>Node.js 多模型轮询与故障切换：重试、熔断和监控实现</title>
    <link href="https://www.githubmissyang.cn/articles/nodejs-multi-model-round-robin-failover" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/nodejs-multi-model-round-robin-failover</id>
    <updated>2026-08-12T16:09:06.217Z</updated>
    <published>2026-08-12T16:09:06.217Z</published>
    <summary>面向生产环境的 Node.js 多模型调用方案：区分轮询与故障切换，给出可运行模型池代码，并讲清 401、429、5xx 的处理、指数退避、熔断、任务路由、输出校验和监控指标。</summary>
    <content type="html"><![CDATA[<h2>一句话结论</h2>
<p>多模型配置不能只做“每次换一个模型”。真正可用于生产的方案需要把负载分配、故障切换、重试、熔断和质量监控分开：轮询负责分流，可重试错误触发有限重试，连续失败触发熔断，模型输出还必须经过业务校验。</p>
<p>如果你还没有完成单模型连通，可以先看站内的 <a href="/articles/glm-4-7-flash-api-nodejs-guide">GLM-4.7-Flash API 配置与实测</a>。本文默认每个模型已经能独立返回正常响应。</p>
<h2>轮询和故障切换不是一回事</h2>
<p>轮询（round-robin）按顺序把新请求交给不同模型，主要解决流量分配问题。故障切换（failover）则是在当前模型无法完成任务时，选择另一个健康模型继续请求。只实现轮询会有三个问题：</p>
<ol>
<li>已经故障的模型仍会周期性收到请求；</li>
<li>同一个请求失败后不会自动换模型；</li>
<li>不同模型的输出质量和参数兼容性没有被处理。</li>
</ol>
<p>因此，模型池至少要记录 <code>enabled</code>、失败次数、熔断截止时间和最近一次错误，而轮询游标不应该写入长期配置。</p>
<h2>一个可运行的 Node.js 模型池</h2>
<p>下面的实现使用 OpenAI 兼容的 Chat Completions 接口。API Key 从环境变量读取，模型配置中不保存明文密钥。</p>
<pre><code class="language-javascript">const models = [
  {
    id: &#39;glm-flash&#39;,
    model: &#39;glm-4.7-flash&#39;,
    url: &#39;https://open.bigmodel.cn/api/paas/v4/chat/completions&#39;,
    apiKey: process.env.ZHIPU_API_KEY,
    enabled: true,
    failures: 0,
    openUntil: 0,
  },
  {
    id: &#39;backup&#39;,
    model: process.env.BACKUP_MODEL,
    url: process.env.BACKUP_URL,
    apiKey: process.env.BACKUP_API_KEY,
    enabled: true,
    failures: 0,
    openUntil: 0,
  },
];

let cursor = 0;

function healthyModels() {
  const now = Date.now();
  return models.filter(m =&gt; m.enabled &amp;&amp; m.apiKey &amp;&amp; m.url &amp;&amp; m.openUntil &lt;= now);
}

function orderedCandidates() {
  const healthy = healthyModels();
  if (!healthy.length) throw new Error(&#39;没有可用模型&#39;);
  const start = cursor++ % healthy.length;
  return [...healthy.slice(start), ...healthy.slice(0, start)];
}

function isRetryable(status) {
  return status === 408 || status === 429 || status &gt;= 500;
}

async function callOne(config, messages) {
  const response = await fetch(config.url, {
    method: &#39;POST&#39;,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
      Authorization: `Bearer ${config.apiKey}`,
    },
    body: JSON.stringify({
      model: config.model,
      messages,
      temperature: 0.2,
      max_tokens: 800,
    }),
    signal: AbortSignal.timeout(30_000),
  });

  const raw = await response.text();
  if (!response.ok) {
    const error = new Error(`${config.id} returned ${response.status}`);
    error.status = response.status;
    error.retryable = isRetryable(response.status);
    error.detail = raw.slice(0, 300);
    throw error;
  }

  const payload = JSON.parse(raw);
  const content = payload.choices?.[0]?.message?.content?.trim();
  if (!content) throw new Error(`${config.id} returned empty content`);
  return { content, usage: payload.usage, responseModel: payload.model };
}

export async function generate(messages) {
  const errors = [];
  for (const model of orderedCandidates()) {
    try {
      const result = await callOne(model, messages);
      model.failures = 0;
      return { ...result, configuredModel: model.id };
    } catch (error) {
      errors.push({ model: model.id, message: error.message });
      model.failures += 1;
      if (model.failures &gt;= 3) {
        model.openUntil = Date.now() + 60_000;
      }
      if (!error.retryable &amp;&amp; error.status) break;
    }
  }
  throw new AggregateError(errors, &#39;所有候选模型均调用失败&#39;);
}
</code></pre>
<p>这段代码展示的是最小骨架。多进程或多实例部署时，内存中的游标和熔断状态彼此不可见，应改用 Redis 或数据库保存共享状态。</p>
<h2>哪些错误应该重试</h2>
<p>智谱官方错误码说明中，401 通常表示鉴权问题，429 可能代表并发超额、余额不足或账户异常，500 表示服务端错误。因此不能看到 429 就无限重试。</p>
<table>
<thead>
<tr>
<th>状态</th>
<th>建议处理</th>
</tr>
</thead>
<tbody><tr>
<td>400</td>
<td>参数或输入错误，不换模型盲目重试</td>
</tr>
<tr>
<td>401</td>
<td>停止请求并告警，检查对应模型密钥</td>
</tr>
<tr>
<td>408/网络超时</td>
<td>退避后重试一次，再切备用模型</td>
</tr>
<tr>
<td>429</td>
<td>读取业务错误；并发超额可退避，余额或账户问题应熔断</td>
</tr>
<tr>
<td>500/502/503</td>
<td>短暂退避，失败后切换健康模型</td>
</tr>
</tbody></table>
<p>重试会增加请求量，因此每个业务请求建议最多尝试两到三个模型，并设置总时间预算。例如总预算 45 秒时，不应让每个候选模型都等待 30 秒。</p>
<h2>指数退避要加随机抖动</h2>
<p>多个任务同时收到 429 后，如果都在固定的一秒后重试，会再次形成流量尖峰。可以使用带随机抖动的退避：</p>
<pre><code class="language-javascript">const wait = ms =&gt; new Promise(resolve =&gt; setTimeout(resolve, ms));

async function backoff(attempt) {
  const base = Math.min(500 * 2 ** attempt, 8_000);
  await wait(base + Math.random() * 300);
}
</code></pre>
<p>退避只适用于可能自行恢复的错误。错误密钥、非法参数和内容校验失败需要分别处理。</p>
<h2>熔断器如何设计</h2>
<p>一个实用的轻量规则是：同一模型连续失败三次后熔断 60 秒；熔断结束只放行少量探测请求；探测成功关闭熔断，失败则延长冷却时间。生产环境还应区分：</p>
<ul>
<li>接口可用性故障：超时、5xx、连接错误；</li>
<li>配额故障：并发、余额或套餐问题；</li>
<li>内容质量故障：空输出、JSON 无法解析、字段缺失；</li>
<li>安全故障：密钥失效、输入或输出触发策略。</li>
</ul>
<p>这些问题的恢复方式不同，不应只累计一个模糊的失败数字。</p>
<h2>轮询策略怎么选</h2>
<h3>固定选中模型</h3>
<p>适合需要结果稳定、便于评测和排查的摘要、日报生成。默认只使用一个经过验证的模型，故障时才切换备用模型。</p>
<h3>普通轮询</h3>
<p>适合能力、价格和输出结构相近的模型。每个请求轮换一次，但仍需跳过熔断中的节点。</p>
<h3>加权轮询</h3>
<p>模型成本或容量不同的时候更合适。例如权重 3:1 表示主模型大约承担四分之三请求。权重应基于实际成功率、延迟和成本调整，而不是只看模型宣传参数。</p>
<h3>按任务路由</h3>
<p>通常比纯轮询更有价值：分类与摘要使用快速模型，复杂代码审查使用强模型，敏感任务走人工审核。先路由任务，再在同一等级的健康模型之间轮询。</p>
<h2>输出校验决定切换是否真的有效</h2>
<p>HTTP 200 不代表任务成功。文章分类至少要检查分类是否属于允许集合；JSON 摘要要验证必填字段、类型和条数；代码任务可以运行语法检查或测试。校验失败后是否切模型，取决于失败原因：提示词导致所有模型都错时，轮询只会浪费费用。</p>
<p>建议把结果分为 <code>transport_ok</code>、<code>parse_ok]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="多模型配置"/>
    <category term="Node.js"/>
    <category term="故障切换"/>
    <category term="熔断"/>
    <category term="大模型 API"/>
  </entry>
  <entry>
    <title>GLM-4.7-Flash API 配置与实测：Node.js 接入、常见坑和生产建议</title>
    <link href="https://www.githubmissyang.cn/articles/glm-4-7-flash-api-nodejs-guide" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/glm-4-7-flash-api-nodejs-guide</id>
    <updated>2026-08-12T15:09:54.706Z</updated>
    <published>2026-08-12T15:09:54.706Z</published>
    <summary>基于生产服务器真实调用，讲清 GLM-4.7-Flash 的正确 API 地址、Node.js 接入方式、思考模式导致 content 为空的原因，以及超时、JSON 校验、重试和密钥安全等生产配置。</summary>
    <content type="html"><![CDATA[<h2>一句话结论</h2>
<p>如果你的程序已经按 OpenAI 的 Chat Completions 格式调用模型，接入 GLM-4.7-Flash 只需要替换 API Key、模型名和接口地址。本站在 2026 年 8 月 12 日从生产服务器完成了三组非流式请求，均返回 HTTP 200；关闭思考模式后，端到端响应时间为 618～818 毫秒，适合摘要、分类、结构化提取和轻量代码任务。</p>
<h2>正确的接口配置</h2>
<p>智谱通用对话补全接口是：</p>
<pre><code class="language-text">https://open.bigmodel.cn/api/paas/v4/chat/completions
</code></pre>
<p>请求使用 <code>POST</code>，并通过请求头传入密钥：</p>
<pre><code class="language-text">Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
</code></pre>
<p>模型名应填写 <code>glm-4.7-flash</code>。如果使用 OpenAI SDK，<code>baseURL</code> 通常填写到版本目录 <code>https://open.bigmodel.cn/api/paas/v4/</code>，SDK 会自行拼接 <code>chat/completions</code>；如果直接使用 <code>fetch</code> 或 curl，则填写完整接口地址。不要把两种写法混用，否则容易得到重复路径。</p>
<h2>Node.js 最小可运行示例</h2>
<p>下面使用 Node.js 18 及以上版本自带的 <code>fetch</code>，不需要安装额外依赖：</p>
<pre><code class="language-javascript">const response = await fetch(
  &#39;https://open.bigmodel.cn/api/paas/v4/chat/completions&#39;,
  {
    method: &#39;POST&#39;,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
      Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
    },
    body: JSON.stringify({
      model: &#39;glm-4.7-flash&#39;,
      messages: [
        { role: &#39;user&#39;, content: &#39;用一句话解释什么是 RAG&#39; },
      ],
      thinking: { type: &#39;disabled&#39; },
      temperature: 0.2,
      max_tokens: 300,
    }),
    signal: AbortSignal.timeout(30_000),
  },
);

if (!response.ok) {
  throw new Error(`GLM 请求失败：${response.status} ${await response.text()}`);
}

const result = await response.json();
console.log(result.choices[0].message.content);
</code></pre>
<p>API Key 应存放在服务器环境变量或加密的密钥存储中，不能写入前端 JavaScript、公开仓库或日志。</p>
<h2>本站真实调用结果</h2>
<p>测试环境为生产服务器、Node.js 20 容器、智谱通用 API，使用 <code>thinking: { type: &#39;disabled&#39; }</code>、<code>temperature: 0.2</code>，每次请求超时设为 30 秒。</p>
<table>
<thead>
<tr>
<th>任务</th>
<th align="right">HTTP 状态</th>
<th align="right">延迟</th>
<th align="right">总 Token</th>
<th>结果</th>
</tr>
</thead>
<tbody><tr>
<td>中文短摘要</td>
<td align="right">200</td>
<td align="right">818 ms</td>
<td align="right">58</td>
<td>正确压缩为一句话</td>
</tr>
<tr>
<td>JavaScript 去重函数</td>
<td align="right">200</td>
<td align="right">618 ms</td>
<td align="right">39</td>
<td>返回 Set 实现</td>
</tr>
<tr>
<td>JSON 对象生成</td>
<td align="right">200</td>
<td align="right">635 ms</td>
<td align="right">54</td>
<td>字段和值正确</td>
</tr>
</tbody></table>
<p>三次响应中的模型字段均为 <code>glm-4.7-flash</code>，结束原因为 <code>stop</code>。这组结果只反映测试时刻的小请求表现，延迟会受网络、请求长度、思考模式和平台负载影响，不能当作长期性能承诺。</p>
<h2>为什么请求成功却拿不到 content</h2>
<p>GLM-4.7-Flash 支持思考内容。开启思考模式时，模型可能先在 <code>reasoning_content</code> 中输出推理，而较小的 <code>max_tokens</code> 可能在最终答案写入 <code>content</code> 前就用完。因此，出现 HTTP 200 但 <code>content</code> 为空时，优先检查：</p>
<ol>
<li><code>choices[0].message.reasoning_content</code> 是否有内容；</li>
<li><code>finish_reason</code> 是否表示长度受限；</li>
<li><code>max_tokens</code> 是否设置得过小；</li>
<li>当前任务是否真的需要思考模式。</li>
</ol>
<p>摘要、分类和固定 JSON 提取通常可以关闭思考模式，以降低延迟并让输出更稳定。复杂推理任务再开启，并为推理和最终答案预留足够 Token。</p>
<h2>JSON 输出不要只依赖提示词</h2>
<p>实测中，仅要求“只输出 JSON”，模型仍可能附带 Markdown 代码围栏。生产程序可以使用接口支持的 JSON 输出模式，并且无论如何都应在服务端执行解析和字段校验。建议流程是：解析失败时移除代码围栏再尝试一次；仍失败则记录请求 ID 并重试，而不是把未经验证的字符串直接写入数据库。</p>
<h2>生产环境配置清单</h2>
<ul>
<li>API Key 只保存在服务端，并对后台接口做鉴权；</li>
<li>设置 20～60 秒超时，防止任务永久挂起；</li>
<li>记录 HTTP 状态、模型名、延迟、Token 用量和请求 ID，但不要记录密钥；</li>
<li>对 429 和 5xx 使用指数退避，限制最大重试次数；</li>
<li>为摘要、分类等任务规定 JSON Schema，并在落库前验证；</li>
<li>多模型轮询时分别统计成功率，连续失败的模型应暂时熔断；</li>
<li>定期用固定样例回归，模型或提示词变化后对比质量；</li>
<li>为长文控制输入长度，避免来源正文和提示词挤占输出空间。</li>
</ul>
<h2>常见配置错误</h2>
<h3>把完整接口当成 SDK 的 baseURL</h3>
<p>OpenAI SDK 的 <code>baseURL</code> 应停在 <code>/paas/v4/</code>。直接 HTTP 请求才使用完整的 <code>/chat/completions</code> 地址。</p>
<h3>模型名称写错</h3>
<p>配置值是 <code>glm-4.7-flash</code>，不要写成展示名称或自行加入版本前缀。应同时检查响应中的 <code>model</code> 字段，确认网关没有切换到其他模型。</p>
<h3>前端直接请求模型</h3>
<p>这样会暴露 API Key，也难以统一做限流、重试和日志。正确结构是浏览器请求自己的后端，再由后端调用智谱。</p>
<h3>把 HTTP 200 当成业务成功</h3>
<p>还要检查 <code>choices</code>、<code>finish_reason</code> 和目标字段，并验证 JSON 或正文是否为空。网络成功不等于内容可用。</p>
<h2>适合哪些任务</h2>
<p>根据本次小样本实测，GLM-4.7-Flash 适合成本和响应速度优先的中文摘要、文章分类、标签生成、格式转换、基础代码片段和后台批处理。对于高风险决策、复杂代码修改或需要严谨引用的内容，应增加更强模型复核、规则校验或人工审核。</p>
<p>进一步构建完整内容业务时，可参考 <a href="/articles/nodejs-ai-summary-pipeline-fetch-retry">Node.js AI 总结管线设计</a>，了解如何保存抓取快照并在模型失败后单独重跑。</p>
<p>如果任务要求稳定返回分类或摘要字段，请继续阅读 <a href="/articles/glm-4-7-flash-json-output-nodejs">GLM-4.7-Flash 结构化 JSON 输出实测</a>，不要只依赖提示词约束格式。</p>
<h2>最终建议</h2>
<p>端点 <code>https://open.bigmodel.cn/api/paas/v4/chat/completions</code> 配置正确。真正容易出问题的不是 URL，而是 SDK 与完整路径混淆、思考 Token 挤占最终回答、未校验结构化输出，以及密钥暴露。先用关闭思考的小请求完成连通性测试，再逐步加入 JSON 约束、超时、重试和监控，接入会更稳。</p>
<h2>常见问题</h2>
<h3>GLM-4.7-Flash 的完整接口地址是什么？</h3>
<p>直接 HTTP 请求使用 <code>https://open.bigmodel.cn/api/paas/v4/chat/completions</code>；OpenAI SDK 的 baseURL 通常填写 <code>https://open.bigmodel.cn/api/paas/v4/</code>。</p>
<h3>HTTP 200 但 content 为空怎么办？</h3>
<p>检查 reasoning_content、finish_reason 和 max_tokens。摘要与分类任务可以关闭思考模式，避免推理内容占满输出预算。</p>
<h3>API Key ]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="GLM-4.7-Flash"/>
    <category term="智谱 API"/>
    <category term="Node.js"/>
    <category term="大模型接入"/>
  </entry>
  <entry>
    <title>AI 与大数据岗位会怎样变化：角色重组、能力迁移与团队设计</title>
    <link href="https://www.githubmissyang.cn/articles/ai-big-data-job-changes-skills-team-design" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-big-data-job-changes-skills-team-design</id>
    <updated>2026-09-21T09:30:00+08:00</updated>
    <published>2026-09-21T09:30:00+08:00</published>
    <summary>分析数据分析师、数据工程师、算法工程师、数据科学家、产品经理与治理岗位的任务变化，给出未来能力栈、个人转型顺序、责任划分、跨职能团队协作模型，以及衡量组织转型效果的具体方法。</summary>
    <content type="html"><![CDATA[<p>AI 更可能先重组岗位里的任务，再改变岗位名称。重复取数、固定报表和模板化文档会被压缩；定义问题、治理语义、验证模型和承担业务结果的工作会增加。</p>
<p>世界经济论坛《Future of Jobs Report 2025》把大数据专家、金融科技工程师、AI 与机器学习专家列为快速增长角色。该报告来自雇主调查，适合观察方向，不能直接当作某个地区或公司的招聘预测。</p>
<h2>一、六类岗位的变化</h2>
<table>
<thead>
<tr>
<th>角色</th>
<th>被自动化较多的任务</th>
<th>更重要的新任务</th>
</tr>
</thead>
<tbody><tr>
<td>数据分析师</td>
<td>重复 SQL、固定图表、周报初稿</td>
<td>指标定义、实验设计、因果判断、AI 结果复核</td>
</tr>
<tr>
<td>数据工程师</td>
<td>样板管道、字段映射、基础文档</td>
<td>数据产品、流批一致、契约、质量、成本与平台工程</td>
</tr>
<tr>
<td>数据科学家</td>
<td>基础特征尝试、报告整理</td>
<td>问题建模、评测设计、不确定性、上线监测</td>
</tr>
<tr>
<td>算法/ML 工程师</td>
<td>单模型接口封装</td>
<td>评测、模型路由、特征与检索、推理系统可靠性</td>
</tr>
<tr>
<td>数据产品经理</td>
<td>需求转交与排期</td>
<td>价值闭环、语义设计、权限风险、人机流程</td>
</tr>
<tr>
<td>治理与安全</td>
<td>手工盘点和抽查</td>
<td>自动策略、模型风险分级、持续审计和证据管理</td>
</tr>
</tbody></table>
<h2>二、岗位边界从工具划分转向责任划分</h2>
<p>过去常按 SQL、Python、BI 或 Spark 分工。AI 数据团队更适合按责任划分：谁负责数据产品质量，谁负责模型效果，谁负责业务流程，谁批准高风险使用，谁处理线上事故。</p>
<p>工具会继续变化，责任不能消失。大模型能生成 SQL，但不能替业务负责人定义“有效客户”；它能生成质量规则候选，但不能决定错误数据是否允许进入财务口径。</p>
<h2>三、未来更值钱的五组能力</h2>
<ol>
<li><strong>业务与指标语义</strong>：把模糊问题变成可计算定义，识别代理指标和目标冲突。</li>
<li><strong>数据工程与软件工程</strong>：版本、测试、契约、可观测性、权限、成本和故障恢复。</li>
<li><strong>统计与评测</strong>：基线、抽样、时间切分、误差分析、置信区间和线上实验。</li>
<li><strong>AI 系统能力</strong>：检索、结构化输出、工具调用、模型路由、提示词与评测集。</li>
<li><strong>治理与沟通</strong>：记录证据、解释限制、识别受影响人群，并把风险交给正确的责任人。</li>
</ol>
<p>“会写提示词”会逐渐成为通用技能，而不是独立岗位护城河。能把模型放进可靠数据和业务闭环的人更难替代。</p>
<h2>四、团队如何重组</h2>
<p>小团队可以采用一个跨职能单元：业务负责人、分析/数据产品、数据工程、AI/ML 工程和风险接口人，共同负责一个场景。平台团队提供身份、目录、计算、模型网关、评测和监控等公共能力。</p>
<p>规模增大后，可按领域建设数据产品团队，中央平台负责自助能力，治理委员会定义最低标准。每个 AI 用例都应有业务所有者、数据所有者、技术所有者和风险所有者。</p>
<h2>五、个人转型路线</h2>
<p>分析师可从指标语义、实验和 AI 结果验证切入；数据工程师可强化数据契约、平台工程和模型数据链路；算法人员应补齐软件可靠性、成本和业务验收；管理者则要学会用风险等级决定自动化权限。</p>
<p>下一篇给出一套<a href="/articles/enterprise-ai-data-system-roadmap">从试点到规模化的落地路线图</a>，将技术、组织和治理放进同一张实施计划。</p>
<h2>六、衡量转型是否有效</h2>
<p>培训完成数和工具使用次数不能证明岗位升级。更有用的指标是：从需求到可信数据产品的交付时间、重复报表减少量、评测发现的失败数、人工复核负担、事故恢复时间和业务指标改善。</p>
<p>团队负责人可以把这些指标放入<a href="/articles/enterprise-ai-data-system-roadmap">企业 AI 数据体系落地路线图</a>的每个阶段，让人员成长与系统成熟度共同推进。</p>
<h2>实践检查清单</h2>
<p>在评审方案时，要求团队画出从原始数据到最终用户的链路，并在每个节点标明输入、输出、负责人、质量阈值、访问权限、版本和失败处理。随机选择一条历史结果，确认可以根据日志与快照完整重放。</p>
<p>同时保留一个不使用复杂 AI 的基线方案。只有当新方案在质量、时效、成本或人工负担上产生可重复的改善，并且没有越过风险阈值，才扩大使用范围。若效果下降，应能快速切回规则、旧模型或人工流程，并把失败样本加入后续评测。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 岗位"/>
    <category term="数据工程师"/>
    <category term="数据分析师"/>
    <category term="组织设计"/>
  </entry>
  <entry>
    <title>AI 在数据中的应用：从智能分析到决策自动化的完整地图</title>
    <link href="https://www.githubmissyang.cn/articles/ai-data-applications-complete-map" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-data-applications-complete-map</id>
    <updated>2026-09-21T09:00:00+08:00</updated>
    <published>2026-09-21T09:00:00+08:00</published>
    <summary>系统梳理 AI 在数据采集、治理、分析、预测、知识检索和决策执行中的应用，给出价值分层、场景选择、系统协作方式、验证方法与风险边界，帮助团队从单点演示走向可审计的业务闭环。</summary>
    <content type="html"><![CDATA[<p>AI 与数据结合，真正改变的不是“多了一个聊天框”，而是数据从产生到决策的整条链路。过去的数据平台主要回答发生了什么；新的 AI 数据系统还要解释原因、生成假设、预测变化，并在可控条件下触发动作。</p>
<p>这篇先建立全景。后续文章会分别展开大数据底座、架构演进、岗位变化和落地路线。</p>
<h2>一、AI 进入数据链路的七个位置</h2>
<table>
<thead>
<tr>
<th>环节</th>
<th>AI 能做什么</th>
<th>典型输出</th>
<th>必须保留的控制</th>
</tr>
</thead>
<tbody><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>SQL、图表、分析草稿</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>
</tbody></table>
<p>这七层不要求同时建设。多数团队最合适的起点是“带证据的检索”和“分析辅助”，因为输出仍由人复核，错误影响相对可控。</p>
<h2>二、三类 AI 不应混成一个模型</h2>
<p><strong>生成式 AI</strong>适合处理非结构化文本、解释字段、生成查询草稿和总结结果。它输出灵活，但存在幻觉和不稳定性。</p>
<p><strong>传统机器学习</strong>适合对结构化历史数据做分类、排序、预测和异常检测。它更容易离线评估，却仍会受到数据漂移、标签偏差和泄漏影响。</p>
<p><strong>规则与优化算法</strong>适合执行权限、额度、库存、排班和资源约束。它们可测试、可审计，应当承担最后的硬限制。</p>
<p>一个可靠系统通常是三者协作：大模型理解意图，数据与机器学习给出证据和分数，规则引擎决定哪些动作允许执行。</p>
<h2>三、从展示价值走向业务价值</h2>
<p>第一层价值是<strong>节省查找时间</strong>，例如问数、文档检索和会议资料汇总。第二层是<strong>减少分析成本</strong>，例如生成 SQL 草稿、解释异常和自动形成日报。第三层是<strong>提高决策质量</strong>，例如需求预测、客户流失预警和风险排序。第四层才是<strong>有限自动执行</strong>，例如低风险补货建议、告警分派和数据质量修复工单。</p>
<p>价值越靠后，越需要稳定的数据契约、离线评测、线上监控和人工接管。没有可信数据底座时，智能体只是把不确定性更快地传播到下游。</p>
<h2>四、选择第一个场景的四个条件</h2>
<ol>
<li>输入数据已有明确负责人和使用权限。</li>
<li>输出可以用历史样本或人工标准验证。</li>
<li>错误不会直接造成不可逆的资金、健康或权益影响。</li>
<li>现有流程有清晰基线，例如平均处理时长、错误率或人工成本。</li>
</ol>
<p>适合起步的项目包括内部知识问答、指标解释助手、数据质量告警归类和分析报告草稿。直接自动审批、自动定价或自动影响个人权益的场景，需要更严格的评估与治理。</p>
<h2>五、完整闭环</h2>
<p>一个 AI 数据产品至少要保留：数据来源与版本、转换代码、特征或检索版本、模型与提示词版本、输出证据、人工反馈、业务结果和回滚记录。NIST AI RMF 将风险管理组织为 Govern、Map、Measure、Manage 四类活动，提醒团队把治理放入整个生命周期，而不是上线前补一张检查表。</p>
<p>下一篇从<a href="/articles/big-data-foundation-for-ai-lakehouse-governance">大数据底座与湖仓分层</a>开始，把数据如何进入、变干净、可复用和可追溯讲清楚。</p>
<h2>六、常见失败信号</h2>
<p>如果团队无法回答数据来自哪里、指标由谁定义、输出怎样验收，就不应先增加模型复杂度。只统计调用量而不看业务结果，会把使用热度误当成价值；让同一个模型负责理解、预测和最终审批，则会把不稳定输出直接带入关键流程。</p>
<p>可以先对照<a href="/articles/enterprise-ai-data-system-roadmap">企业 AI 数据体系落地路线图</a>建立基线和门禁，再决定是否扩展自动化范围。每增加一种数据源、模型或行动工具，都要补充负责人、评测样本、权限和停止条件。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 数据"/>
    <category term="大数据"/>
    <category term="智能分析"/>
    <category term="数据治理"/>
  </entry>
  <entry>
    <title>数据架构如何因 AI 演进：从数仓与湖仓到数据产品和智能体</title>
    <link href="https://www.githubmissyang.cn/articles/ai-data-architecture-evolution-agents-data-products" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-data-architecture-evolution-agents-data-products</id>
    <updated>2026-09-21T09:20:00+08:00</updated>
    <published>2026-09-21T09:20:00+08:00</published>
    <summary>梳理传统数仓、数据湖、Lakehouse、数据网格到 AI 原生数据平台的演进，解释变化背后的组织与业务原因，并给出智能体时代的数据平面、语义层、控制面和行动层设计。</summary>
    <content type="html"><![CDATA[<p>AI 对数据架构的核心要求，是让机器也能理解数据含义、判断证据质量，并在权限范围内调用数据和业务动作。存储规模仍然重要，但语义、上下文、评测和控制面开始成为架构中心。</p>
<h2>一、五个阶段</h2>
<h3>阶段 1：企业数仓</h3>
<p>结构化数据经过 ETL 进入主题模型，主要服务固定报表和经营分析。优势是口径稳定；局限是接入非结构化数据和实验型机器学习较慢。</p>
<h3>阶段 2：数据湖</h3>
<p>对象存储承接日志、文件、图片和原始数据，计算与存储分离。它提高了扩展性，但如果缺少目录、表格式、质量和负责人，容易形成难以发现与信任的数据沼泽。</p>
<h3>阶段 3：Lakehouse</h3>
<p>湖仓把开放存储与表管理、事务、模式约束和多种计算引擎结合，让 BI 与机器学习共享更多数据资产。架构重点从“放得下”转向“可信地复用”。</p>
<h3>阶段 4：数据产品与数据网格</h3>
<p>当中央数据团队成为瓶颈，领域团队可以负责客户、订单、供应链等数据产品，平台团队提供自助基础设施，组织层面维持统一的安全与治理标准。数据网格同时是组织设计，不能通过安装一个目录产品完成。</p>
<h3>阶段 5：AI 原生数据平台</h3>
<p>平台增加语义层、特征、向量索引、模型网关、评测与智能体工具。数据消费者从人和报表扩展到模型与智能体；每次回答和行动都要能定位数据、模型、规则与授权。</p>
<h2>二、AI 原生架构的四个平面</h2>
<p><strong>数据平面</strong>保存表、流、文档、向量、特征和关系。</p>
<p><strong>语义平面</strong>维护业务术语、指标、实体、知识关系和数据契约，让自然语言问题映射到可验证的数据对象。</p>
<p><strong>智能平面</strong>负责模型路由、检索、提示词、工具调用、评测和反馈。这里不直接绕过数据权限。</p>
<p><strong>控制平面</strong>统一身份、策略、血缘、审计、成本、质量和风险分级。NIST AI RMF 的 Govern、Map、Measure、Manage 可以用来检查这些能力是否贯穿系统生命周期。</p>
<h2>三、智能体使“读数据”变成“用工具”</h2>
<p>传统问数只生成查询；智能体可能继续创建工单、发送通知或修改配置。架构上应把每项动作封装成窄接口：明确输入模式、权限范围、幂等键、超时、审批级别和补偿动作。</p>
<p>智能体不应直接持有全库权限。它先从语义目录发现获准的数据产品，再通过受控查询服务获取结果，最后把行动建议交给策略引擎。高影响动作需要人工确认。</p>
<h2>四、迁移策略</h2>
<p>不要以“重建平台”为目标。先选择一个业务链路，记录当前数据时效、质量、交付时间和成本；补齐数据契约与负责人；让 BI、模型和知识检索复用同一可信数据；最后才增加智能体行动。</p>
<p>只有当领域数量、发布频率和中央团队排队时间形成真实瓶颈时，才需要把数据产品责任下放。架构演进的证据应来自交付指标，而不是概念成熟度图。</p>
<p>下一篇讨论<a href="/articles/ai-big-data-job-changes-skills-team-design">数据岗位如何变化</a>，把新的职责、技能组合和团队边界落到人。</p>
<h2>五、架构决策的判断问题</h2>
<p>每次引入新层或新平台前，都应回答：当前瓶颈是存储、计算、数据质量、交付组织还是模型运行？新组件替代什么旧流程？谁负责运行？故障时怎样降级？三个月后用哪个指标判断它值得保留？</p>
<p>还可以结合<a href="/articles/big-data-foundation-for-ai-lakehouse-governance">大数据底座设计</a>检查数据分层与治理能力，避免在可信数据尚未建立前直接搭建复杂智能体。</p>
<h2>实践检查清单</h2>
<p>在评审方案时，要求团队画出从原始数据到最终用户的链路，并在每个节点标明输入、输出、负责人、质量阈值、访问权限、版本和失败处理。随机选择一条历史结果，确认可以根据日志与快照完整重放。</p>
<p>同时保留一个不使用复杂 AI 的基线方案。只有当新方案在质量、时效、成本或人工负担上产生可重复的改善，并且没有越过风险阈值，才扩大使用范围。若效果下降，应能快速切回规则、旧模型或人工流程，并把失败样本加入后续评测。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="数据架构"/>
    <category term="Lakehouse"/>
    <category term="数据网格"/>
    <category term="AI Agent"/>
  </entry>
  <entry>
    <title>AI 在医疗领域有哪些应用？临床、科研与医院运营全景指南</title>
    <link href="https://www.githubmissyang.cn/articles/ai-healthcare-applications-clinical-research-operations" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-healthcare-applications-clinical-research-operations</id>
    <updated>2026-09-09T08:00:00.000Z</updated>
    <published>2026-09-09T08:00:00.000Z</published>
    <summary>系统梳理 AI 在医疗影像、临床决策支持、病历文书、患者服务、药物研发和医院运营中的典型应用，区分辅助工具与医疗器械，并给出从价值、风险、证据到持续监测的落地判断框架。</summary>
    <content type="html"><![CDATA[<h1>AI 在医疗领域有哪些应用？临床、科研与医院运营全景指南</h1>
<p>AI 在医疗领域的应用，不只是“让模型看病”。它更常见的价值，是在明确任务和人工责任边界下，帮助医务人员识别信息、生成文书、安排资源、发现研究线索或持续监测风险。判断一个项目是否值得做，关键不在模型参数，而在它改变了哪个医疗工作流、错误会伤害谁，以及效果能否被本地数据验证。</p>
<p>WHO 将医疗大模型的潜在应用概括为诊断与临床照护、患者使用、行政文书、医学教育以及科研和药物研发等方向。同时，WHO 强调人的自主权、隐私、公平、透明和问责必须贯穿设计与部署。本文只讨论应用与工程治理，不构成医疗建议。</p>
<p>如果你已经确定具体场景，可直接使用<a href="/articles/medical-ai-evaluation-governance-bias-monitoring">医疗 AI 评估与治理指南</a>建立本地验证、亚组评估与上线监测门禁。</p>
<h2>一、医疗 AI 的六类主要应用</h2>
<table>
<thead>
<tr>
<th>应用方向</th>
<th>典型任务</th>
<th>AI 的角色</th>
<th>主要风险</th>
</tr>
</thead>
<tbody><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>回答流程性问题并升级高风险请求</td>
<td>延误就医、错误分诊、语言与无障碍差异</td>
</tr>
<tr>
<td>科研与药物研发</td>
<td>文献筛选、队列发现、分子设计、试验匹配</td>
<td>缩小候选范围、辅助分析</td>
<td>数据偏倚、不可复现、把相关性当因果</td>
</tr>
<tr>
<td>医院运营</td>
<td>床位预测、排班、库存、拒付分析、质控</td>
<td>预测需求、自动整理信息</td>
<td>优化指标伤害公平性、流程锁定、监控不足</td>
</tr>
</tbody></table>
<p>这些方向不能使用同一套验收标准。预约问答可以重点验证答案正确率和转人工率；影像诊断支持则需要临床参考标准、敏感度与特异度、亚组表现、读片工作流和上市后监测。</p>
<h2>二、医疗影像：应用成熟不等于可以跳过本地验证</h2>
<p>AI 可以在放射、病理、眼科和超声等图像任务中完成检测、分类、分割、重建或检查队列排序。FDA 维护的 AI-enabled medical devices 清单，能够帮助团队查看在美国获得市场授权的设备及其公开决定信息；该清单本身不是产品排行榜，也不能证明某设备适合另一家医院的人群、设备或流程。</p>
<p>引入影像 AI 前至少应回答：</p>
<ol>
<li>预期用途是辅助发现、优先排序还是给出诊断结论？</li>
<li>训练与验证数据是否覆盖本地设备、协议、疾病谱和患者亚组？</li>
<li>错误结果在界面中如何呈现，医生能否识别并覆盖？</li>
<li>上线后如何监测漏报、误报、漂移和不可用状态？</li>
</ol>
<p>不要只比较总体 AUC。对于筛查任务，应同时查看敏感度、特异度、阳性预测值、阴性预测值和不同阈值下的工作量；对于分割任务，还要评估结果是否真正改善后续测量与决策。</p>
<h2>三、临床决策支持：评估的是“人机团队”</h2>
<p>临床决策支持可以做风险预警、药物相互作用提示、指南检索或诊疗路径提醒。但模型输出越接近诊断和治疗决策，错误后果越严重，证据和监管要求通常也越高。</p>
<p>FDA、Health Canada 与 MHRA 的机器学习医疗器械透明度原则强调，应说明预期用途、目标人群、输入输出、性能、局限、已知偏倚和生命周期监测，并关注人机团队的表现。实际验收因此不能只让模型离线答题，还要观察：提示是否在正确时间出现、是否造成告警疲劳、医务人员能否理解适用边界，以及覆盖模型建议时会发生什么。</p>
<h2>四、生成式 AI：先从可复核的文书任务开始</h2>
<p>生成式 AI 适合把访谈、检查结果和既有记录整理成结构化草稿，例如就诊摘要、出院记录、患者教育材料或编码候选。它能降低重复录入，但不能因为文本流畅就被当作事实正确。</p>
<p>更安全的落地顺序是：</p>
<ol>
<li>从不直接触发诊疗行为的内部草稿开始；</li>
<li>限制模型只能使用当前患者的授权上下文；</li>
<li>对姓名、药物、剂量、时间和否定词做确定性校验；</li>
<li>要求具备权限的专业人员逐项确认后再写回系统；</li>
<li>保留输入版本、模型版本、输出、修改和签署记录。</li>
</ol>
<p>具体工程流程可继续阅读<a href="/articles/generative-ai-clinical-documentation-medical-record-summary">医疗生成式 AI 病历摘要与临床文书落地指南</a>。</p>
<h2>五、患者服务：必须设计清晰的升级路径</h2>
<p>患者侧 AI 可以回答预约、科室位置、检查准备等低风险问题，也可以辅助健康教育和随访提醒。但症状问答和分诊会直接影响就医行为，必须明确它不是急救通道，并针对胸痛、呼吸困难、意识改变、自伤风险等高风险表达配置确定性升级规则。</p>
<p>一个可接受的患者服务系统至少要做到：说明身份与能力边界；展示信息来源和更新时间；允许快速转人工；不要求用户在非授权渠道提交敏感健康信息；对弱势人群、方言、低健康素养和无障碍场景单独测试。</p>
<h2>六、科研和药物研发：AI 用于缩小空间，不用于跳过实验</h2>
<p>AI 可帮助检索文献、匹配临床试验、发现患者队列、预测分子性质或提出实验候选。它的价值通常是提高筛选效率，而不是替代统计设计、实验验证、伦理审查和监管证据。</p>
<p>科研团队应记录数据来源、纳排标准、缺失处理、代码与模型版本，区分探索性结果和验证性结果。生成模型提出的靶点、分子或因果解释，都需要独立实验或临床研究支持。</p>
<h2>七、判断项目是否值得做的四个问题</h2>
<p><strong>第一，目标是否可测？</strong> 将“提升效率”改成可验证指标，例如平均文书时间、需人工修改的事实错误数、告警响应时间或单位成功任务成本。</p>
<p><strong>第二，风险是否可控？</strong> 先描述最坏错误，再决定人工复核、拒答、回退和停用机制，而不是上线后再补安全策略。</p>
<p><strong>第三，证据是否匹配场景？</strong> 供应商演示、公开论文、外院结果和本地真实工作流是不同层级的证据。高风险用途需要更接近本地临床环境的验证。</p>
<p><strong>第四，责任是否明确？</strong> 产品负责人、临床负责人、数据保护、信息安全、法务和运维必须知道各自何时审批、监测、响应和停止系统。</p>
<h2>八、医疗 AI 项目落地清单</h2>
<ul>
<li>写清预期用途、非预期用途、用户和受影响患者；</li>
<li>判断软件功能是否可能属于医疗器械或受其他行业规则约束；</li>
<li>建立数据授权、最小化、脱敏、访问控制和留存策略；</li>
<li>用本地数据验证总体和关键亚组表现；</li>
<li>评估人机工作流，而非只测离线模型；</li>
<li>对高风险输出设置人工确认和安全回退；</li>
<li>记录模型、Prompt、知识库和配置版本；</li>
<li>监测质量、漂移、投诉、安全事件和人工覆盖；</li>
<li>为更新、回滚、暂停和退出准备明确流程。</li>
</ul>
<p>医疗 AI 的合理起点不是“能否接入一个大模型”，而是找到一个边界清楚、结果可复核、错误可恢复的任务。价值证据与风险证据应同时增长，规模化部署应发生在本地验证之后。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="医疗 AI"/>
    <category term="生成式 AI"/>
    <category term="临床决策支持"/>
    <category term="医疗影像"/>
    <category term="医院数字化"/>
  </entry>
  <entry>
    <title>AI 组合优化与风险控制：从预测分数到可执行仓位</title>
    <link href="https://www.githubmissyang.cn/articles/ai-portfolio-optimization-risk-control" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-portfolio-optimization-risk-control</id>
    <updated>2026-09-17T09:40:00+08:00</updated>
    <published>2026-09-17T09:40:00+08:00</published>
    <summary>解释如何把机器学习预测转换为受约束的股票组合，覆盖风险模型、行业与个股暴露、换手和流动性、压力测试、拒单规则与人工审批，并给出从研究建议到有限自动执行的分级路径。</summary>
    <content type="html"><![CDATA[<p>模型输出“股票 A 得分 0.73”之后，距离可执行组合还有很长一段路。组合层的任务是把不稳定的预测压进明确的风险预算、交易成本和操作限制中。上游信号应先通过<a href="/articles/machine-learning-stock-signal-backtest-guide">机器学习股票信号与回测</a>中的样本外验证。</p>
<h2>一、分开信号、风险和约束</h2>
<p>信号模型估计相对吸引力，风险模型估计共同因子和个体波动，优化器在约束下寻找目标权重。三者必须独立版本化。若同一个模型同时决定预期收益、风险和仓位，错误会被集中放大。</p>
<p>基本输入包括当前持仓、预测分数、协方差或因子风险、基准权重、价格与成交量、费用模型和账户限制。</p>
<h2>二、先把分数校准成可比较尺度</h2>
<p>模型分数不一定是收益率。可以按每个交易日做分位数、z-score 或基于历史样本外结果做单调校准。校准过程也只能使用过去数据，并按行业或规模检查系统性偏差。</p>
<p>更稳健的做法是压缩极端值，让单个异常预测不会控制整个组合。模型置信度低、数据缺失或分布外时，可把目标信号收缩到零。</p>
<h2>三、把重要限制写进优化问题</h2>
<p>常见约束包括：</p>
<ul>
<li>单只股票最大和最小权重；</li>
<li>行业、风格和市场暴露区间；</li>
<li>总杠杆、现金和空头限制；</li>
<li>单次与累计换手上限；</li>
<li>按成交量计算的参与率；</li>
<li>禁买、停牌、价格限制和名单规则；</li>
<li>相对基准的跟踪误差或绝对风险预算。</li>
</ul>
<p>目标函数可以权衡预测收益、风险、交易成本和持仓变化，但系数不能只为美化历史结果调参。每个约束都应对应一个明确风险或业务原因。</p>
<h2>四、压力测试比单一波动率更重要</h2>
<p>历史协方差可能低估结构突变。应增加行业冲击、流动性收缩、相关性上升、价格跳空、数据中断和模型全部失效的情景。观察组合损失、保证金、集中度和是否触发强制动作。</p>
<p>风险报告至少展示总暴露、净暴露、前十大持仓、行业与风格暴露、预期波动、历史与情景损失、换手、成本和流动性天数。FINRA 也强调所有投资都有风险，分散化只能管理部分风险，不能保证避免损失。</p>
<h2>五、建立订单前的确定性闸门</h2>
<p>无论信号来自 LLM、树模型还是强化学习，下单前都应执行同一套代码规则：价格是否过期、数量是否合法、是否超过仓位和日额度、是否重复订单、市场是否开放、证券是否可交易、账户状态是否正常。</p>
<p>任何规则失败都应拒单并记录原因。大模型可以解释告警，但不能覆盖硬性限制。紧急停止开关应独立于模型服务，即使模型和数据库故障也能阻止新订单。</p>
<h2>六、按阶段提高自动化等级</h2>
<p>推荐四级：只读研究、生成建议、人工审批后执行、在窄范围内自动执行。每次升级都需要新的证据：仿真偏差、异常处理、最大损失、回滚和人工接管都通过验收。</p>
<p>自动化工具可能没有掌握用户全部财务状况、税务、流动性需求和风险目标。个人工具不应仅凭几道问题就假设能够提供适合的组合。涉及真实资金和对外服务时，还需由适用地区的法律、合规和持牌专业人员确认要求。</p>
<h2>七、记录每次组合变化的原因</h2>
<p>每次再平衡都保存旧权重、目标权重、信号贡献、风险贡献、成本估计、约束命中和最终订单差异。若优化器因为约束无法求解，系统应回退到上一有效组合或无交易状态，不能临时删除约束换取一个数学解。</p>
<p>组合审批页面应突出新增风险，而不是只展示预期收益：哪些持仓变大、行业暴露如何变化、成本来自哪里、在压力情景下可能损失多少。审批人需要看到原始模型版本和数据时间，之后才能重放当时决定。</p>
<p>完成组合层后，可进一步了解<a href="/articles/reinforcement-learning-multi-agent-stock-trading">强化学习与多智能体交易系统</a>，但其上线门槛会显著提高。完整层级关系见<a href="/articles/ai-stock-market-applications-roadmap">AI 股票应用路线图</a>。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 股票"/>
    <category term="组合优化"/>
    <category term="风险控制"/>
    <category term="量化交易"/>
  </entry>
  <entry>
    <title>用 AI 读股票公告和财报：可追溯 RAG 研究助手实战</title>
    <link href="https://www.githubmissyang.cn/articles/ai-stock-filings-research-assistant-rag" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-stock-filings-research-assistant-rag</id>
    <updated>2026-09-17T09:10:00+08:00</updated>
    <published>2026-09-17T09:10:00+08:00</published>
    <summary>从官方披露采集、文档切分、混合检索、财务字段提取到引用核验，搭建一个不会把模型记忆冒充公司事实的股票公告与财报研究助手，并用点时数据、拒答和反例测试有效控制幻觉。</summary>
    <content type="html"><![CDATA[<p>让 AI 帮你“看财报”很容易，让它给出的每个结论都能回到原文则难得多。可靠的研究助手不应该自由回答公司事实，而应先找到官方披露，再根据证据生成有限答案。</p>
<h2>一、先限定助手能回答什么</h2>
<p>适合的任务包括：列出收入分部、比较两个报告期、定位风险因素变化、提取资本开支与现金流、整理管理层对业务变化的解释。不适合直接交给模型的任务包括预测股价、判断买卖点、推断未披露事项和把会计指标自动变成个性化建议。</p>
<p>问题模板可以固定为：</p>
<pre><code class="language-json">{
  &quot;company&quot;: &quot;公司唯一标识&quot;,
  &quot;question&quot;: &quot;本期经营现金流下降的已披露原因是什么？&quot;,
  &quot;asOf&quot;: &quot;研究截止时间&quot;,
  &quot;allowedDocumentTypes&quot;: [&quot;annual-report&quot;, &quot;quarterly-report&quot;, &quot;announcement&quot;]
}
</code></pre>
<p><code>asOf</code> 是防止未来信息泄漏的第一道门。系统只能检索在该时点已经公开的文档版本。</p>
<h2>二、建立可信数据入口</h2>
<p>美国上市公司可从 SEC EDGAR 的 submissions 与 XBRL APIs 获取申报历史和结构化财务事实；SEC 说明这些公开数据接口无需 API Key，但自动访问仍要遵守其访问政策。A 股研究应从巨潮资讯、交易所或公司指定披露渠道取得原文，并保存公告时间、证券代码、公告编号和原始链接。</p>
<p>每份文档至少保存：</p>
<pre><code class="language-json">{
  &quot;documentId&quot;: &quot;source-company-accession&quot;,
  &quot;companyId&quot;: &quot;稳定公司标识&quot;,
  &quot;documentType&quot;: &quot;annual-report&quot;,
  &quot;publishedAt&quot;: &quot;带时区的首次公开时间&quot;,
  &quot;periodStart&quot;: &quot;报告期开始&quot;,
  &quot;periodEnd&quot;: &quot;报告期结束&quot;,
  &quot;sourceUrl&quot;: &quot;官方原文&quot;,
  &quot;sha256&quot;: &quot;原文件摘要&quot;,
  &quot;retrievedAt&quot;: &quot;抓取时间&quot;
}
</code></pre>
<p>同一公告可能出现 HTML、PDF、XBRL 和更正版。不要用文件名当唯一标识，也不要让更正版静默覆盖历史版本。</p>
<h2>三、按财务语义切分，而不是机械截字</h2>
<p>年报适合按标题、表格和页码切分。一个块应保留章节路径，例如“管理层讨论 &gt; 流动性 &gt; 经营现金流”，并附带页码、表格标题与单位。跨页表格需要先还原表头，否则“12.6”脱离“亿元、同比、报告期”后没有意义。</p>
<p>检索可采用关键词与向量的混合排序：证券简称、会计科目和公告编号更适合关键词匹配，解释性段落适合语义检索。最终重排时加入文档类型、发布日期和报告期过滤。</p>
<h2>四、把回答拆成事实、解释和未知</h2>
<p>推荐的结构化输出：</p>
<pre><code class="language-json">{
  &quot;answer&quot;: &quot;基于已检索披露的简短回答&quot;,
  &quot;facts&quot;: [{
    &quot;claim&quot;: &quot;事实陈述&quot;,
    &quot;value&quot;: null,
    &quot;unit&quot;: null,
    &quot;period&quot;: null,
    &quot;documentId&quot;: &quot;...&quot;,
    &quot;page&quot;: 42,
    &quot;quote&quot;: &quot;最短必要原文&quot;
  }],
  &quot;interpretations&quot;: [&quot;研究者仍需验证的解释&quot;],
  &quot;unknowns&quot;: [&quot;披露中未找到的信息&quot;],
  &quot;confidence&quot;: &quot;high|medium|low&quot;
}
</code></pre>
<p>数字提取必须校验单位、币种、期间、单季或累计、合并或母公司口径。对于同名指标，优先采用结构化财务事实，再用原文表格复核；若两个来源不一致，应展示冲突而不是自动挑一个。</p>
<h2>五、增加引用核验器</h2>
<p>生成答案后逐条检查：引用文档是否早于 <code>asOf</code>，引用片段是否包含关键主体与数字，答案中的数值是否逐字出现在证据或可由明确公式计算，原文是否被否定词改变含义。</p>
<p>还可以要求第二个模型只做“证据是否支持主张”的判定，但最终仍要保留确定性校验。例如金额和百分比用程序解析，页码和文档哈希由系统填入，模型不能自造。</p>
<h2>六、用一组反例验收</h2>
<p>测试集至少覆盖：同名公司、币种变化、单位从万元变为亿元、报告期重述、更正公告、扫描版 PDF、跨页表格、负数括号、同比基数为负、问题超出披露范围。优质助手应在证据不足时拒绝回答。</p>
<p>上线前记录检索召回率、引用正确率、数值一致率和拒答准确率。回答流畅度不能替代这些指标。完整学习顺序可先看<a href="/articles/ai-stock-market-applications-roadmap">AI 股票应用路线图</a>，完成这一层后，再把文档事件接入<a href="/articles/llm-stock-news-event-sentiment-pipeline">新闻与公告事件流水线</a>，形成持续监控。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 股票"/>
    <category term="RAG"/>
    <category term="财报分析"/>
    <category term="公告检索"/>
  </entry>
  <entry>
    <title>AI 在股市中能做什么：从研究助手到量化交易的完整路线图</title>
    <link href="https://www.githubmissyang.cn/articles/ai-stock-market-applications-roadmap" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/ai-stock-market-applications-roadmap</id>
    <updated>2026-09-17T09:00:00+08:00</updated>
    <published>2026-09-17T09:00:00+08:00</published>
    <summary>系统梳理 AI 在股票研究中的六层应用：公告检索、财务提取、新闻事件、量化信号、组合优化与交易监控，并给出从个人研究工具走向生产系统的学习顺序、验证方法、技术选型和风险边界。</summary>
    <content type="html"><![CDATA[<p>AI 在股市里最有价值的用途，通常不是回答“明天哪只股票涨”，而是把大量公开资料变成可追溯的研究记录，把重复的数据处理变成稳定流程，并在每一步保留人工判断和风险限制。</p>
<p>本文先画出完整地图。后续文章会依次实现公告研究、新闻事件提取、量化回测、组合与智能体系统。这里的“股票研究”包括信息整理、假设验证和风险监控，不等于投资建议。</p>
<h2>一、六层应用地图</h2>
<h3>第 1 层：阅读和解释</h3>
<p>最简单的用法是解释财务术语、整理行业概念、生成公告阅读清单。它适合学习，但不能把模型记忆当事实。关键数字、日期和公司表述必须回到交易所公告、公司定期报告或监管披露原文。</p>
<h3>第 2 层：检索和结构化</h3>
<p>把年报、季报、问询函和公告建立索引，AI 只在检索到的原文片段上回答，并输出页码、段落和链接。进一步可把营收、利润、现金流、风险因素和管理层口径抽成 JSON，供表格或数据库比较。</p>
<h3>第 3 层：事件和情绪</h3>
<p>对新闻与公告进行去重、实体识别、事件分类和影响窗口标注。比“正面/负面”更有用的输出是：发生了什么、涉及谁、信息何时首次公开、证据在哪里、哪些字段仍不确定。</p>
<h3>第 4 层：量化信号</h3>
<p>机器学习可以把价格、成交量、基本面和事件特征映射为排序分数、波动率估计或风险标签。模型输出应当被视为需要验证的信号，不是交易指令。时间切分、幸存者偏差、未来数据泄漏、手续费和滑点会显著改变回测结果。</p>
<h3>第 5 层：组合与执行</h3>
<p>高级系统会在收益假设之外加入仓位上限、行业暴露、换手率、流动性和最大回撤限制，再生成目标组合。真实下单还需要交易时段、价格保护、幂等、防重复订单、撤单和紧急停机机制。</p>
<h3>第 6 层：持续治理</h3>
<p>生产系统需要监测数据延迟、特征漂移、模型版本、提示词版本、异常订单、风险暴露和人工接管。任何能影响资金的模型都要留下输入、输出、证据、规则命中和审批记录。</p>
<h2>二、按难度选择第一个项目</h2>
<table>
<thead>
<tr>
<th>难度</th>
<th>项目</th>
<th>输入</th>
<th>输出</th>
<th>主要风险</th>
</tr>
</thead>
<tbody><tr>
<td>入门</td>
<td>公告问答助手</td>
<td>官方公告</td>
<td>带引用的摘要</td>
<td>幻觉、漏读</td>
</tr>
<tr>
<td>初级</td>
<td>财务指标提取</td>
<td>定期报告/XBRL</td>
<td>结构化表格</td>
<td>口径混用</td>
</tr>
<tr>
<td>中级</td>
<td>事件监控器</td>
<td>公告与新闻</td>
<td>事件卡片</td>
<td>时间戳错误、重复</td>
</tr>
<tr>
<td>中高级</td>
<td>股票横截面排序</td>
<td>点时数据</td>
<td>每期分数</td>
<td>泄漏、过拟合</td>
</tr>
<tr>
<td>高级</td>
<td>组合研究台</td>
<td>信号与约束</td>
<td>目标权重</td>
<td>成本、容量、风险集中</td>
</tr>
<tr>
<td>生产级</td>
<td>人机协同交易</td>
<td>全链路数据</td>
<td>审批后的订单</td>
<td>系统、合规与资金风险</td>
</tr>
</tbody></table>
<p>第一次实践建议从<a href="/articles/ai-stock-filings-research-assistant-rag">公告与财报研究助手</a>开始。这个项目能训练数据来源、引用、结构化输出和事实核验，又不会直接触碰自动下单。</p>
<h2>三、LLM、传统机器学习和规则分别做什么</h2>
<ul>
<li><strong>LLM</strong>：处理长文本、语言差异、事件抽取、研究问题分解和报告草稿。</li>
<li><strong>传统机器学习</strong>：处理数值特征、分类、排序、波动率和概率估计。</li>
<li><strong>优化算法</strong>：在明确约束下计算组合权重与再平衡方案。</li>
<li><strong>确定性规则</strong>：执行仓位、价格、额度、交易时段和审批限制。</li>
</ul>
<p>不要让一个聊天模型同时承担全部职责。自然语言模型擅长提出和整理候选解释，资金约束与订单校验更适合可测试的代码规则。</p>
<h2>四、最小可信闭环</h2>
<p>一个可复核的研究闭环至少包含：官方原始数据、带时间的快照、可重复的转换代码、模型与提示词版本、输出证据、人工复核结果和失效条件。可以用下面七个问题验收：</p>
<ol>
<li>每个数字能否定位到原始披露？</li>
<li>当时不可获得的数据是否被排除？</li>
<li>训练、验证和测试是否按时间分开？</li>
<li>是否计入手续费、滑点、涨跌停和流动性限制？</li>
<li>是否与简单基线比较？</li>
<li>哪些情形会拒绝输出或转人工？</li>
<li>系统能否一键停止，并重放一次决策？</li>
</ol>
<h2>五、必须保留的边界</h2>
<p>Investor.gov 提醒投资者，不应只依赖 AI 生成的信息作投资决定，自动化工具的假设和输入限制也会直接影响输出。实际系统应展示不确定性和证据，而不是包装成“稳赚模型”。任何承诺高收益、低风险或保证选出赢家的说法都应视为危险信号。</p>
<p>接下来可按专题顺序阅读：先做<a href="/articles/ai-stock-filings-research-assistant-rag">公告检索与财务提取</a>，再做<a href="/articles/llm-stock-news-event-sentiment-pipeline">新闻事件流水线</a>，然后进入<a href="/articles/machine-learning-stock-signal-backtest-guide">机器学习信号与回测</a>。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="AI 股票"/>
    <category term="量化研究"/>
    <category term="投资研究"/>
    <category term="风险管理"/>
  </entry>
  <entry>
    <title>面向 AI 的大数据底座：湖仓、流批一体、数据治理与知识层</title>
    <link href="https://www.githubmissyang.cn/articles/big-data-foundation-for-ai-lakehouse-governance" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/big-data-foundation-for-ai-lakehouse-governance</id>
    <updated>2026-09-21T09:10:00+08:00</updated>
    <published>2026-09-21T09:10:00+08:00</published>
    <summary>从数据源、采集、湖仓分层、实时处理、语义指标、向量检索到治理与可观测性，设计一套能同时服务 BI、机器学习和生成式 AI 的大数据底座，并说明各层的责任、边界与渐进建设顺序。</summary>
    <content type="html"><![CDATA[<p>AI 项目常从模型演示开始，却经常卡在数据找不到、口径不一致、权限说不清和结果无法重放。面向 AI 的大数据底座需要同时服务报表、特征训练、实时决策和知识检索，并让每个结果回到原始证据。</p>
<h2>一、六层逻辑架构</h2>
<pre><code class="language-text">业务系统 / 文件 / 日志 / IoT / 第三方数据
                ↓
采集层：批量同步、CDC、消息与文档解析
                ↓
存储计算层：对象存储 + 表格式 + SQL/Spark/流计算
                ↓
可信数据层：原始区 → 清洗区 → 业务数据产品
                ↓
智能数据层：语义指标、特征、向量索引、知识图谱
                ↓
服务层：BI、API、模型训练、RAG、智能体
                ↓
横切能力：目录、血缘、权限、质量、成本、可观测性
</code></pre>
<p>逻辑分层比具体产品名更重要。小团队可以用一个数据库和对象存储实现这些职责；数据量、团队和延迟要求增长后，再拆分计算引擎与服务。</p>
<h2>二、原始、可信与业务三层</h2>
<p>常见的奖牌分层把数据分为 Bronze、Silver、Gold。原始层保留到达时的内容和元数据；可信层完成去重、标准化、主键处理和质量校验；业务层按订单、客户、资产等主题形成可消费的数据产品。</p>
<p>Microsoft 的 Databricks 文档把这一模式描述为逐层提升数据结构与质量的设计方法，并明确它是推荐模式而非强制要求。真正的验收标准应是：能否重放、是否保留错误记录、业务定义是否一致、下游变更是否可控。</p>
<h2>三、流批一体不是所有数据都实时</h2>
<p>实时化应由业务时效决定。月度经营分析适合批处理；欺诈告警、设备异常和在线推荐可能需要秒级或分钟级处理。同一套数据契约可以同时支持历史回放和实时增量，避免流与批生成两套不同口径。</p>
<p>每条事件至少包含事件时间、处理时间、来源、唯一键和模式版本。还要处理迟到、乱序、重复、重试与幂等，否则“实时”只会更快地产生不一致。</p>
<h2>四、给 AI 增加三类数据产品</h2>
<p><strong>语义指标层</strong>统一收入、活跃、留存等定义，让人、BI 和问数助手使用相同口径。</p>
<p><strong>特征层</strong>保存可复用的训练与推理特征，并控制时间穿越。离线训练和在线推理必须对同一特征有一致定义。</p>
<p><strong>知识层</strong>把文档切分、元数据、向量与原文引用组织起来。索引必须继承源系统权限，并记录解析器、嵌入模型和切分版本。</p>
<h2>五、治理要成为运行能力</h2>
<p>数据目录负责发现，血缘负责解释来源，质量规则负责阻断错误，访问控制负责最小权限，审计日志负责回答谁在何时使用了什么。模型输入和输出也属于治理对象：训练集、评测集、提示词、检索结果和人工反馈都应有版本。</p>
<p>Google Cloud 对数据网格的定义把数据视为由最了解它的领域团队负责的数据产品，同时遵守组织统一治理标准。它适合多领域、多团队组织；小团队不必为了概念完整而过早拆出复杂平台。</p>
<h2>六、最小可行底座</h2>
<p>第一阶段只需打通一个高价值主题：保留原始快照，形成一张可信明细表、一组业务指标、一个带权限的知识索引，并加上质量和成本监控。随后再按真实瓶颈扩展 CDC、流处理、特征服务和数据网格。</p>
<p>底座完成后，继续阅读<a href="/articles/ai-data-architecture-evolution-agents-data-products">AI 数据架构如何演进</a>，理解传统数仓怎样走向面向智能体的数据平台。</p>
<h2>七、底座验收指标</h2>
<p>平台验收不能只看吞吐量。还应记录数据到达延迟、质量规则通过率、血缘覆盖率、权限申请时间、查询与训练成本、故障恢复时间，以及数据产品被多少真实流程复用。</p>
<p>若团队还没有清晰的应用目标，可先回到<a href="/articles/ai-data-applications-complete-map">AI 在数据中的应用地图</a>选择一个可验证场景。底座建设应随着场景逐步扩展，避免一次性复制大型组织的全部组件。</p>
<h2>实践检查清单</h2>
<p>在评审方案时，要求团队画出从原始数据到最终用户的链路，并在每个节点标明输入、输出、负责人、质量阈值、访问权限、版本和失败处理。随机选择一条历史结果，确认可以根据日志与快照完整重放。</p>
<p>同时保留一个不使用复杂 AI 的基线方案。只有当新方案在质量、时效、成本或人工负担上产生可重复的改善，并且没有越过风险阈值，才扩大使用范围。若效果下降，应能快速切回规则、旧模型或人工流程，并把失败样本加入后续评测。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="大数据架构"/>
    <category term="Lakehouse"/>
    <category term="数据治理"/>
    <category term="RAG"/>
  </entry>
  <entry>
    <title>Codex CLI 中文实战：AGENTS.md、沙箱权限、测试与生产部署闭环</title>
    <link href="https://www.githubmissyang.cn/articles/codex-cli-agents-md-sandbox-deploy-workflow" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/codex-cli-agents-md-sandbox-deploy-workflow</id>
    <updated>2026-09-15T04:00:00.000Z</updated>
    <published>2026-09-15T04:00:00.000Z</published>
    <summary>基于 Codex CLI 0.154.0 的真实站点任务，拆解如何用 AGENTS.md 固化规则、在沙箱中修改代码、回放测试、处理权限审批，并通过文件哈希与线上探针完成可审计部署。</summary>
    <content type="html"><![CDATA[<p>Codex CLI 真正有价值的地方，不是“能不能生成一段代码”，而是能否在已有仓库规则、未提交改动、权限边界和生产验证之间完成一个可审计闭环。</p>
<p>本文记录一次真实任务：分析网站访问量突增后，为第一方统计加入自动化流量去噪，并部署到生产环境。使用环境为 macOS arm64、Codex CLI 0.154.0、Node.js/Express 项目。任务最终上线，但中间也出现了一次部署路径错误；正是验收步骤把它拦在了“宣告成功”之前。</p>
<h2>先看结果</h2>
<table>
<thead>
<tr>
<th>环节</th>
<th>真实结果</th>
</tr>
</thead>
<tbody><tr>
<td>项目约束</td>
<td>读取仓库 <code>AGENTS.md</code> 与站点维护 Skill</td>
</tr>
<tr>
<td>代码修改</td>
<td>新增明确 UA 与行为阈值过滤，后台展示自动化请求</td>
</tr>
<tr>
<td>静态验证</td>
<td><code>node --check</code>、<code>git diff --check</code> 通过</td>
</tr>
<tr>
<td>隔离回放</td>
<td>14 次请求拆成 10 次有效 PV、4 次自动化请求</td>
</tr>
<tr>
<td>部署</td>
<td>范围化上传三个文件，Docker 重建</td>
</tr>
<tr>
<td>故障拦截</td>
<td>哈希发现 <code>server/app.js</code> 上传到了错误目录</td>
</tr>
<tr>
<td>最终验收</td>
<td>容器内外哈希一致，核心路由 200，自动化计数生效</td>
</tr>
</tbody></table>
<p>这组结果只能证明 Codex 在本次任务中完成了“理解—修改—测试—部署—复验”。它不是通用性能排行，也不能替代人工对业务口径的判断。</p>
<h2>Codex CLI 是什么</h2>
<p><a href="https://github.com/openai/codex">OpenAI 官方仓库</a>将 Codex CLI 定义为运行在本地终端中的开源编程 Agent。它可以读取仓库、提出或应用修改、运行命令，并根据当前沙箱和审批策略请求更高权限。</p>
<p>安装后先记录版本，而不是直接复制旧教程中的参数：</p>
<pre><code class="language-bash">codex --version
codex --help
</code></pre>
<p>本文核验到的版本是：</p>
<pre><code class="language-text">codex-cli 0.154.0
</code></pre>
<p>版本号必须进入测试记录。CLI、模型入口、协作方式和权限能力都可能变化，旧截图不能证明当前行为。</p>
<h2>为什么先写 AGENTS.md</h2>
<p>Agent 不会天然知道团队约束。这个仓库的 <code>AGENTS.md</code> 明确了三类信息：</p>
<ul>
<li>架构：静态前端、Express 后端、Docker 部署以及数据文件位置；</li>
<li>安全：密钥不能进入 Git，生产配置不能被部署覆盖；</li>
<li>工作方式：保留脏工作区中的用户改动，修改后要验证并记录优化历史。</li>
</ul>
<p>这些规则把“请优化统计”转换成可执行边界。Codex 因此没有重写历史访问数据，也没有用完整部署脚本上传工作区中其他未提交文件，而是只发布本次涉及的文件。</p>
<p>一个实用的项目规则文件至少应回答：</p>
<ol>
<li>项目如何启动、测试和部署？</li>
<li>哪些目录或数据绝不能覆盖？</li>
<li>哪些验证是完成条件？</li>
<li>工作区不干净时如何处理？</li>
<li>哪些外部操作需要审批？</li>
</ol>
<h2>沙箱和审批如何配合</h2>
<p>Codex 的本地文件权限与网络、进程权限可以分开控制。本次修改允许写入仓库，但本地监听端口和生产 SSH 需要显式批准。</p>
<p>推荐做法是按风险逐层放开：</p>
<ul>
<li>代码阅读、搜索和 Diff 检查保持只读；</li>
<li>修改限制在当前仓库；</li>
<li>本地服务使用独立端口、测试 Token 和 <code>/tmp</code> 统计文件；</li>
<li>连接生产、上传文件和重建容器单独审批；</li>
<li>删除、覆盖或批量同步前先解析出精确目标。</li>
</ul>
<p>沙箱不是“阻止工作”，而是让权限提升与具体动作绑定。不要为了减少一次确认，就把整个主目录或生产主机交给无限制命令。</p>
<h2>如何设计可回放的 Agent 任务</h2>
<p>本次统计规则包含两层：明确自动化 User-Agent，以及十分钟窗口内重复同一路径或批量遍历页面的行为阈值。仅做语法检查无法证明统计口径正确，因此使用了隔离回放：</p>
<pre><code class="language-text">正常访客：2 次跨页请求
明确自动化：SiteAuditBot 与 Undici 各 1 次
伪装浏览器：同一路径连续请求 10 次
</code></pre>
<p>回放结果为 10 次有效 PV、4 次自动化请求。正常访客仍被识别为一位 UV 和一位多页访客；超过第八次的同路径请求才进入自动化计数。</p>
<p>一个适合 Coding Agent 的验证任务应具备：输入可控、成功条件机器可读、数据与生产隔离、失败后可以重跑。只有“页面看起来没问题”通常不够。</p>
<h2>一次真实部署失误如何被发现</h2>
<p>首次范围化上传把三个文件放到远端项目根目录。<code>admin.js</code> 和 <code>admin.html</code> 本来就在根目录，因此更新成功；但本地的 <code>server/app.js</code> 被 SCP 按 basename 写成了远端 <code>/opt/ai-daily/app.js</code>，真正的 <code>/opt/ai-daily/server/app.js</code> 没有变化。</p>
<p>如果只看 Docker 输出，容器确实“重建成功”，很容易误报部署完成。验收继续比较三层哈希：</p>
<pre><code class="language-text">本地文件 → 服务器工作目录 → 运行容器
</code></pre>
<p>哈希不一致后，误传文件被移动为可恢复备份，随后使用精确目标重新上传：</p>
<pre><code class="language-bash">scp server/app.js root@example:/opt/ai-daily/server/app.js
</code></pre>
<p>第二次重建后，服务器与容器中的哈希都和本地一致。再从容器内部发送 <code>SiteAuditBot/1.0</code> 请求，自动化计数由 0 增加到 1，才算完成。</p>
<h2>Codex、OpenCode 与 ZCode 怎么选</h2>
<table>
<thead>
<tr>
<th>需求</th>
<th>优先评估</th>
</tr>
</thead>
<tbody><tr>
<td>已有 ChatGPT/Codex 工作流，希望使用仓库规则、沙箱和审批</td>
<td>Codex</td>
</tr>
<tr>
<td>希望自由切换多家模型或自建 OpenAI-compatible Provider</td>
<td><a href="/articles/opencode-chinese-guide-install-glm-openai-compatible">OpenCode</a></td>
</tr>
<tr>
<td>希望使用围绕 GLM 深度整合的桌面 ADE、浏览器预览与长任务</td>
<td><a href="/articles/zcode-chinese-guide-install-model-permission-goal">ZCode</a></td>
</tr>
<tr>
<td>需要自行组合 Agent Loop、模型适配器与插件</td>
<td><a href="/articles/deepseek-harness-chinese-guide-install-provider-plugin">DeepSeek Harness</a></td>
</tr>
</tbody></table>
<p>选择工具时先固定同一个仓库、任务、验证命令和权限范围，再比较成功率、人工接管次数、墙钟时间与费用。不同模型和不同任务混在一起的排行榜，很难指导实际采购。</p>
<h2>最终建议</h2>
<p>使用 Codex CLI 时，把完成条件从“代码改了”提高到“约束被遵守、测试可复现、部署目标精确、线上行为可观测”。</p>
<p>先用 <code>AGENTS.md</code> 写清项目事实和禁区；再让测试数据、端口和密钥与生产隔离；外部操作按具体动作审批；最后用文件哈希、HTTP 状态、业务探针和容器日志交叉验证。Agent 仍然会犯普通工程错误，但一条可靠的验收链能够让错误暴露在报告成功之前。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="Codex CLI"/>
    <category term="AGENTS.md"/>
    <category term="AI 编程助手"/>
    <category term="沙箱"/>
    <category term="部署验证"/>
    <category term="Node.js"/>
  </entry>
  <entry>
    <title>DeepSeek Harness 中文实测：安装、模型配置、插件机制与 OpenCode 对比</title>
    <link href="https://www.githubmissyang.cn/articles/deepseek-harness-chinese-guide-install-provider-plugin" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/deepseek-harness-chinese-guide-install-provider-plugin</id>
    <updated>2026-08-22T01:00:00.000Z</updated>
    <published>2026-08-22T01:00:00.000Z</published>
    <summary>保留 DeepSeek Harness 0.1.1-rc.2 的安装、CLI、Web UI、插件与真实模型任务证据，并结合 0.1.6-alpha.1 官方发布说明解释版本迁移、安全边界和重新验证要求。</summary>
    <content type="html"><![CDATA[<p>DeepSeek Harness 刚出现时，最容易产生两个误解：一是把它当成“DeepSeek 模型的另一个聊天界面”，二是因为官方口号是“一切皆插件”，就把它等同于普通插件市场。</p>
<p>实际上，DeepSeek Harness（命令名 <code>dsh</code>）更接近一个可组合的 Agent 运行框架。模型适配器、工具、会话日志、沙箱、审批策略、Web UI，甚至 Agent Loop 本身都可以由插件组成和替换。它可以使用 DeepSeek，也支持 Anthropic、OpenAI 和自定义 OpenAI-compatible Provider。</p>
<p>我在隔离目录测试了 0.1.1-rc.2：CLI 可以运行，Web UI 成功启动并返回 200，一个最小 TypeScript 插件也成功加载。但安装和参数行为并非完全顺滑，官方也明确把当前版本标记为 Developer Preview，并警告未来会有破坏兼容性的变更。</p>
<h3>版本复核（2026-09-23）</h3>
<p>下文的安装、插件和模型任务记录仅适用于 2026-08-22 测试的 <code>0.1.1-rc.2</code>，不能代替当前版本的结论。2026 年 9 月 23 日复核时，官方最新预发布已到 <code>0.1.7-alpha.2</code>；这一代版本已经出现 Agent Team、后台任务与工作流、性能与用量面板、会话 V4、Headless JSON 事件、权限预设和更完整的插件配置能力。官方仓库仍将项目标为 Developer Preview，并明确提醒可能出现破坏兼容性的变更。</p>
<p>这意味着旧版命令、Profile、Patch、默认工具和会话格式都不能直接假设在新版保持不变。本次复核只核对了官方仓库和发布说明，没有安装 <code>0.1.7-alpha.2</code> 或复跑模型任务；升级前仍应在隔离目录重新验证 CLI 帮助、Web 启动、插件加载、Provider、审批与沙箱边界，再决定是否迁移。</p>
<h2>先看实测结论</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>实际结果</th>
</tr>
</thead>
<tbody><tr>
<td>系统</td>
<td>macOS arm64</td>
</tr>
<tr>
<td>Node.js</td>
<td>25.9.0</td>
</tr>
<tr>
<td>pnpm</td>
<td>11.2.2</td>
</tr>
<tr>
<td>DeepSeek Harness</td>
<td>0.1.1-rc.2</td>
</tr>
<tr>
<td>安装结果</td>
<td>解析 504 个包，安装 446 个包</td>
</tr>
<tr>
<td>CLI</td>
<td><code>--version</code>、<code>--help</code> 正常</td>
</tr>
<tr>
<td>Web UI</td>
<td>绑定 <code>127.0.0.1:3196</code>，首页 HTTP 200</td>
</tr>
<tr>
<td>插件</td>
<td>最小 TypeScript 插件成功加载</td>
</tr>
<tr>
<td>模型调用</td>
<td>首次余额不足；充值后 15.84 秒完成修复与测试</td>
</tr>
</tbody></table>
<p>这次测试证明了“安装—启动—加载插件”的最小闭环，不证明模型质量，也不证明它适合直接进入生产环境。</p>
<h3>真实模型任务：先遇到余额不足，充值后跑通</h3>
<p>随后我使用权限为 <code>600</code> 的临时凭据文件，在同一个隔离项目中调用 Harness 默认路由 <code>deepseek-official/deepseek-v4-flash</code>。任务要求 Agent 先运行失败测试，只修复 <code>sum()</code> 的加减号，再重新运行测试。</p>
<p>请求在 <strong>1.77 秒</strong>后结束，Harness 返回：</p>
<pre><code class="language-text">dsh: QUOTA: Insufficient Balance
</code></pre>
<p>这说明请求已经进入 DeepSeek Provider 的真实认证与配额检查，但账户余额不足，模型没有生成回复，也没有触发读文件、改代码或运行命令。测试后再次执行 <code>npm test</code>，结果仍是 <code>-10 !== 10</code>，<code>math.js</code> 保持原样；临时凭据文件随后已删除。</p>
<p>账户充值后，我没有换题、放宽权限或修改测试，而是用相同项目和提示再次运行。第二次 Headless 任务在 <strong>15.84 秒</strong>内完成，模型把 <code>math.js</code> 中唯一的减号改为加号：</p>
<pre><code class="language-js">return values.reduce((total, value) =&gt; total + value, 0)
</code></pre>
<p>Harness 的最终报告说明修改前是 <code>-10 !== 10</code>，修改后输出 <code>tests passed</code>，并声明没有改动 <code>package.json</code> 与 <code>test.js</code>。为了避免只相信模型自报结果，我在进程退出后独立执行 <code>npm test</code>，同样得到：</p>
<pre><code class="language-text">tests passed
</code></pre>
<p>目录中仍只有原来的三个文件，<code>test.js</code> 仍检查 <code>sum([2, 3, 5]) === 10</code>。这补齐了模型—工具—编辑—测试的最小闭环。它只能代表当前版本、当前模型和这个单行错误，不能外推到大型仓库。</p>
<p>本次 Headless 标准输出没有提供 Token 或费用字段，因此本文不填写估算数字；实际费用应以 DeepSeek 控制台账单为准。测试结束后，权限为 <code>600</code> 的临时凭据文件已删除。</p>
<h2>DeepSeek Harness 到底是什么</h2>
<p>传统 Coding Agent 往往先提供一个完整产品：终端或桌面客户端、内置工具、固定的 Agent Loop，再开放少量扩展点。DeepSeek Harness 的思路更底层：运行中的 <code>dsh</code> 是一棵插件树，由 Profile、Bundle 和 Patch 逐层组合。</p>
<p>官方架构文档把核心能力拆成多个可替换部分：</p>
<ul>
<li><code>session</code> 记录仅追加的会话事件；</li>
<li><code>system-prompt</code> 组装提示词和工具 Schema；</li>
<li><code>tools</code> 管理工具注册与执行流水线；</li>
<li><code>agent-loop</code> 驱动模型请求、工具调用与轮次；</li>
<li>文件系统、Shell、沙箱和子 Agent 都通过各自的能力接口接入。</li>
</ul>
<p>所以“一切皆插件”的实际价值不是装主题，而是允许开发者替换模型适配器、执行环境、工具、安全策略和会话行为。代价也很直接：概念和配置层次比即装即用的聊天客户端更多。</p>
<h2>安装前先检查 Node.js</h2>
<p>当前仓库 <code>package.json</code> 声明的 Node.js 要求是：</p>
<pre><code class="language-text">^22.19.0 || &gt;=24.0.0
</code></pre>
<p>先检查本机环境：</p>
<pre><code class="language-bash">node --version
npm --version
</code></pre>
<p>官方最短启动命令是：</p>
<pre><code class="language-bash">npx @deepseek-ai/dsh web
</code></pre>
<p>它默认在 <code>http://127.0.0.1:3080</code> 启动 Web UI，并尝试打开浏览器。服务器或 SSH 环境建议关闭自动打开：</p>
<pre><code class="language-bash">npx @deepseek-ai/dsh web --no-open
</code></pre>
<p>不要为了远程访问直接把监听地址改成 <code>0.0.0.0</code> 并暴露到公网。Agent 能读写工作区、执行命令并接触模型凭据，远程访问应放在经过认证和网络访问控制的环境后面。</p>
<h2>本次安装遇到的两个真实问题</h2>
<h3>npm 长时间没有完成</h3>
<p>本机默认 npm 缓存里存在历史遗留的 root-owned 文件，第一次 <code>npm install</code> 长时间停留在依赖解析阶段，目标目录仍为空。<code>npm cache verify</code> 给出了权限错误。</p>
<p>我没有直接修改整个用户缓存的所有权，而是先换成隔离缓存排除环境污染。随后改用项目本身采用的 pnpm，在临时目录完成安装。</p>
<p>这不是 DeepSeek Harness 必然存在的错误，但说明排错时要区分“包本身失败”和“本机缓存权限异常”。不要看到安装卡住就反复使用 <code>sudo npm install</code>，否则会继续制造 root-owned 文件。</p>
<h3>pnpm 提示构建脚本被忽略</h3>
<p>安装完成时，pnpm 报告部分依赖的构建脚本未获批准，包括 <code>node-pty</code>、<code>koffi</code> 等。尽管本次 CLI、Web UI 和最小插件仍可运行，但涉及原生终端或其他原生能力时，不能据此断言所有功能都完整。</p>
<p>应先审查脚本来源，再根据团队供应链策略决定是否批准，而不是为消除警告无条件运行所有安装脚本。</p>
<h2>启动 Web UI</h2>
<p>为了不读取个人配置，我使用了独立的 Harness Home：</p>
<pre><code class="language-bash">export DSH_HOME=&quot;/path/to/an/isolated/dsh-home&quot;
dsh web --host 127.0.0.1 --port 3196 --no-open
</code></pre>
<p>进程输出：</p>
<pre><code class="language-text">dsh web: http://127.0.0.1:3196
</code></pre>
<p>首页实际返回 HTTP 200，标题为 <code>DeepSeek Harness</code>。启动目录会作为默认文件系统位置，但新 Web UI 不会自动选中工作区；进入界面后仍需显式添加和选择工作区，之后输入框才可用。</p>
<p>第一次测试不要指向包含生产密钥、客户代码或个人]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="DeepSeek Harness"/>
    <category term="Agent Harness"/>
    <category term="AI Agent"/>
    <category term="OpenAI 兼容 API"/>
    <category term="插件开发"/>
    <category term="Node.js"/>
  </entry>
  <entry>
    <title>DeepSeek Harness 高阶 02：Cordis 插件、事件与自定义工具开发</title>
    <link href="https://www.githubmissyang.cn/articles/deepseek-harness-cordis-plugin-tool-development" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/deepseek-harness-cordis-plugin-tool-development</id>
    <updated>2026-09-23T09:20:00.000Z</updated>
    <published>2026-09-23T09:20:00.000Z</published>
    <summary>从 apply(ctx)、inject、生命周期和四种事件模式出发，讲清实时事件与持久记录的区别，以及如何设计输入受限、资源可清理、行为可测试的 Harness 插件和自定义工具。</summary>
    <content type="html"><![CDATA[<p>很多插件的第一版都能运行，第二版开始却出现重复监听、卸载后仍占资源、服务加载顺序不稳定。原因通常不是 TypeScript 写错，而是把 Cordis 当成普通“初始化脚本”了。</p>
<p>在 DeepSeek Harness 中，Cordis 是能力组合层。插件通过 <code>ctx</code> 获取服务、注册事件和贡献能力；依赖、生命周期与清理由框架管理。</p>
<h2>先建立四个概念</h2>
<p><code>apply(ctx)</code> 是插件入口；<code>ctx.&lt;key&gt;</code> 是稳定服务接缝；<code>inject</code> 声明依赖；effect 是随插件装载和卸载而存在的副作用。</p>
<p>一个只观察工具结果的插件可以很小：</p>
<pre><code class="language-ts">import type { Context } from &#39;@deepseek-ai/cordis&#39;
import &#39;@deepseek-ai/dsh-tools&#39;

export const name = &#39;tool-audit&#39;
export const inject = [&#39;tools&#39;]

export function apply(ctx: Context) {
  ctx.on(&#39;tools/result&#39;, (exec, result) =&gt; {
    console.log(JSON.stringify({ tool: exec.name, blocks: result.content.length }))
  })
}
</code></pre>
<p><code>ctx.on()</code> 注册的监听器会在插件卸载时自动移除。数据库连接、Socket、子进程等无法自动回收的资源，应通过 <code>ctx.effect()</code> 返回清理函数。</p>
<h2>选错事件，比没有事件更危险</h2>
<p>Cordis 提供多种调度语义：</p>
<ul>
<li><code>emit</code> 是广播，所有监听器同步收到；</li>
<li><code>bail</code> 遇到第一个有效结果就停止；</li>
<li><code>serial</code> 按顺序等待，可提前结束；</li>
<li><code>waterfall</code> 是可包装的处理链，监听器必须调用 <code>next()</code> 才会继续。</li>
</ul>
<p>官方特别提醒 waterfall 不调用 <code>next()</code> 会短路。这适合网关和策略拦截，但也可能让模型请求或工具执行悄悄停在你的插件里。开发拦截器时要分别测试“放行、改写、拒绝、下游报错”四条路径。</p>
<h2>实时事件和持久事件不要混淆</h2>
<p><code>agent/request</code>、<code>tools/result</code> 等是进程内 Cordis 事件；<code>turn/*</code>、<code>step/*</code>、<code>tool/call</code>、<code>tool/result</code> 等是写入 Session 的持久记录。想在当前进程做监控，可监听实时事件；想在重启后重建事实，应监听 <code>session/event</code> 并检查 <code>event.type</code>，或直接使用 Session 的投影能力。</p>
<p>一个实用判断是：如果这条信息决定恢复后的行为，它就不应只存在内存事件中。</p>
<h2>自定义工具的设计顺序</h2>
<p>先定义最小输入 Schema，再定义执行边界，最后设计展示。工具描述要告诉模型什么时候用、何时不要用；参数要尽量封闭，避免让模型传任意 Shell 字符串；结果要有稳定结构，并限制返回体大小。</p>
<p>例如“查询构建状态”应接收 <code>buildId</code>，而不是接收任意 URL；“发布版本”应拆成查询与变更两个工具，让只读操作和外部写操作拥有不同审批策略。</p>
<h2>不要轻易替换 Agent Loop</h2>
<p>官方架构把 Session、System Prompt、Tools、Agent、Agent Loop 和 LLM 适配拆成独立接缝。大多数需求都可以通过工具、提示段、事件或能力 Provider 完成。只有标准的“模型请求—工具执行—继续”生命周期本身不适用时，才值得替换 Loop。</p>
<p>扩展越靠近核心，兼容和测试成本越高。预览版阶段尤其要优先使用公开服务与事件，而不是导入内部文件路径。</p>
<h2>插件验收清单</h2>
<p>至少测试：重复加载是否只注册一次；卸载后监听和资源是否释放；缺少依赖时是否明确失败；错误配置是否在启动期被拒绝；工具异常是否转成可诊断结果；大输出是否截断；敏感字段是否进入日志；版本升级后 Schema 是否变化。</p>
<h2>最终建议</h2>
<p>先写观察型插件，再写只读工具，最后才写带副作用的工具和拦截器。每个插件只占据一个清晰接缝，依赖显式声明，资源有对应清理，行为有四路径测试。</p>
<p>真正成熟的 Harness 插件，不只是“模型能调用”，而是能加载、能卸载、能失败、能审计，也能在升级时快速判断哪里变了。</p>
<p>继续阅读：<a href="/articles/deepseek-harness-subagent-workflow-goal-ralph">Subagent、Workflow、Goal 与 Ralph 怎么选</a>。需要回看完整顺序时，返回<a href="/topics/deepseek-harness">DeepSeek Harness 专题路线</a>。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="DeepSeek Harness"/>
    <category term="Cordis"/>
    <category term="插件开发"/>
    <category term="自定义工具"/>
    <category term="TypeScript"/>
    <category term="Agent"/>
  </entry>
  <entry>
    <title>DeepSeek Harness 高阶 01：Headless、JSON 事件与 CI 自动化</title>
    <link href="https://www.githubmissyang.cn/articles/deepseek-harness-headless-ci-json-events" rel="alternate" type="text/html"/>
    <id>https://www.githubmissyang.cn/articles/deepseek-harness-headless-ci-json-events</id>
    <updated>2026-09-23T09:10:00.000Z</updated>
    <published>2026-09-23T09:10:00.000Z</published>
    <summary>系统讲解 DeepSeek Harness Headless 单任务、stdin、NDJSON 事件、会话续跑与退出码，并给出从只读审查、受控修改到独立测试的 CI 分层方法和安全边界。</summary>
    <content type="html"><![CDATA[<p>Web UI 里完成一次代码修改很直观，但团队真正想要的是：PR 创建后自动让 Agent 做检查，把工具调用、最终结论和退出状态交给流水线，而不是让人盯着浏览器。</p>
<p>DeepSeek Harness 的 Headless Profile 就是这个入口。官方定义很清楚：一次调用只执行一个任务，不开 GUI、不启服务、不留后台进程；成功退出码为 <code>0</code>，中止或错误为 <code>1</code>。</p>
<h2>最小调用</h2>
<pre><code class="language-bash">dsh --profile headless &quot;运行测试，只报告失败原因，不修改文件&quot;
</code></pre>
<p>参数任务适合短提示词。长任务建议从标准输入传入：</p>
<pre><code class="language-bash">{
  echo &quot;审查当前改动：&quot;
  git diff --stat
} | dsh --profile headless
</code></pre>
<p>官方说明位置参数存在时不会读取 stdin；空参数和空管道会在执行前失败。脚本里不要同时传两份任务，再猜哪一份生效。</p>
<h2>为什么自动化要用 JSON</h2>
<p>默认模式把最终回答写到 stdout，把推理提示和诊断写到 stderr。机器消费时应启用 <code>--json</code>，获得一行一个对象的 NDJSON 流：</p>
<pre><code class="language-bash">dsh --profile headless --json &quot;运行测试并总结&quot; &gt; dsh-events.ndjson
</code></pre>
<p>事件从 <code>session</code> 开始，以 <code>final</code> 结束，中间可能有 <code>status</code>、<code>text</code>、<code>thinking</code>、<code>tool_call</code> 和 <code>tool_result</code>。注意它是面向运行展示的投影，不是完整 Session 日志；除最终事件外，字符串还可能被限制长度。审计系统不能把它当作无损事件源。</p>
<p>流水线至少检查三层结果：进程退出码、是否收到 <code>final</code>、任务自己的验收证据。例如 Agent 说“测试通过”时，CI 仍应独立再跑一次测试。</p>
<h2>一个可靠的 CI 分层</h2>
<p>第一层只读：让 Agent 分析 Diff、测试失败和风险，不允许写工作区。第二层受控修改：只在临时分支或一次性工作目录中运行。第三层独立验证：由 CI 自己执行 lint、test、build 和安全扫描。</p>
<p>不要把“Agent 退出码为 0”解释为“业务验收通过”。Headless 的成功表示这次任务完成，不替代你的测试断言。</p>
<h2>会话续跑什么时候有用</h2>
<p>每次调用默认创建新的 <code>session-&lt;uuid&gt;</code>。启用 JSON 后，首个事件会给出会话 ID；后续可以通过：</p>
<pre><code class="language-bash">dsh --profile headless --session-id &quot;session-...&quot; &quot;根据上次结果继续&quot;
</code></pre>
<p>官方实现会拒绝不存在的会话，也会核对持久化、工作目录、Preset 和会话类型，避免在不匹配的组合中静默开空会话。续跑适合“分析—确认—修复”两阶段工作，不适合无限复用一个历史越来越长的万能会话。</p>
<h2>提示词要写成验收协议</h2>
<p>自动化任务建议包含四部分：允许范围、禁止范围、验证命令、输出格式。例如：</p>
<pre><code class="language-text">只检查 src/ 与 test/。
不要修改文件，不要安装依赖，不要访问网络。
执行现有测试；若环境缺依赖，报告 blocker，不要自行绕过。
最终输出：结论、证据、风险、建议下一步。
</code></pre>
<p>这比“帮我看看代码”更容易稳定回归，也更容易判断模型、工具或版本变化是否造成退化。</p>
<h2>CI 中最容易踩的坑</h2>
<ul>
<li>把长期 API Key 写进仓库变量或日志；</li>
<li>允许 Agent 直接推送默认分支；</li>
<li>使用可写缓存，让不同 PR 互相污染；</li>
<li>只保存最终自然语言，不保存测试与 Diff；</li>
<li>自动重试所有失败，导致重复修改或费用失控；</li>
<li>把 NDJSON 投影误当完整审计日志。</li>
</ul>
<h2>最终建议</h2>
<p>先用只读任务接入 CI，确认退出码、JSON 解析和独立测试三条链路。再开放临时工作区写权限，并为每次运行设置时间、Token、并发和重试上限。</p>
<p>Headless 的价值不是“没有界面”，而是把 Agent 变成一个有输入、有事件、有退出状态、可被其他系统约束的执行单元。</p>
<p>继续阅读：<a href="/articles/deepseek-harness-cordis-plugin-tool-development">Cordis 插件、事件与自定义工具开发</a>。需要回看完整顺序时，返回<a href="/topics/deepseek-harness">DeepSeek Harness 专题路线</a>。</p>
]]></content>
    <author><name>推荐智能手记</name></author>
    <category term="DeepSeek Harness"/>
    <category term="Headless"/>
    <category term="CI/CD"/>
    <category term="NDJSON"/>
    <category term="自动化"/>
    <category term="Agent"/>
  </entry>
</feed>