site logo

Marico's space

如何构建一个带系统提示缓存的共享 Claude Haiku 客户端用于批量 ETL

前端技术 2026-05-04 11:36:57 14

这个项目里有个不太起眼但挺有意思的技术决策:三个目录网站共用一个 Claude Haiku 客户端。它放在 packages/shared/src/claude/index.ts,所有的 ETL 任务——模型摘要、游戏推荐、开源对比——都走这个入口。有趣的地方不在单例本身,而是提示缓存的设置和容错处理。

为什么非得搞个共享库

每个应用都有自己的 ETL 目录,里面有各自的 generate-content.ts。但它们其实需要完全相同的两样东西:一是调用 claude-haiku-4-5-20251001 的统一方式,二是处理 API key 缺失情况的统一方式——毕竟本地开发、CI 运行时不一定需要生成内容。

在三个地方各写一个 new Anthropic({ apiKey }) 当然能跑,但这意味着模型名称要在三处改,响应错误要在三处处理,缓存配置也可能在不同地方慢慢出现偏差。所以我把它抽出来了。

这个共享函数的签名故意做得很简单:

export async function generate(opts: GenerateOptions): Promise<GenerateResult> {

GenerateOptions 有五个字段:systemPromptuserPromptmodelmaxTokenscacheSystem。调用方决定要不要缓存,库本身负责具体实现。

cacheSystem 模式

Claude 的提示缓存功能通过给消息块打上 cache_control: { type: "ephemeral" } 标记来实现。在同一个会话中(或者 5 分钟 TTL 内跨请求),如果后续请求包含相同的缓存块,Anthropic 就会按缓存读取的费率收费,而不是全额输入费率。

对于批量生成——循环处理 100 个模型,都用同一个系统提示——这能显著降低输入 token 的成本。目前我还没有 30 天的成本数据,所以先不瞎猜具体比例。但实现本身就两行代码:

const systemBlock = opts.cacheSystem
  ? [{ type: "text" as const, text: opts.systemPrompt, cache_control: { type: "ephemeral" as const } }]
  : opts.systemPrompt;

cacheSystem 为 false 时,system 直接传普通字符串,Anthropic SDK(应用程序接口)正常处理。当为 true 时,系统部分变成带缓存标记的单元素数组。messages.create 的其他部分完全一样。

那些循环处理大量条目的调用方——generate-content.tscompare.ts——都传了 cacheSystem: true。一次性调用或者每个请求的系统提示各不相同的地方,用处不大,所以会传 false 或者干脆不传。

还有一个我没接上的功能:记录响应里 res.usage 中的 cache_creation_input_tokenscache_read_input_tokens。这两个数值每次响应都会返回,能让我看到实际的缓存命中率。现在我只是把 usage 对象暴露在 GenerateResult 里,但下游没有任何地方读取它。这个得安排上。

防御性 JSON 解析

每个内容生成的提示都要求 Claude 返回一个带有特定键的 JSON 对象。大多数时候效果不错。但不是 100% 可靠。

Haiku 模型偶尔会把 JSON 包裹在 markdown 代码块里、加上一句开头的解释,或者——很少见——返回结构有效但缺少某个预期键的 JSON。所以我写了 parseOrFallback

function parseOrFallback(text: string, fb: GeneratedContent): GeneratedContent {
  try {
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (!jsonMatch) return fb;
    const parsed = JSON.parse(jsonMatch[0]);
    return {
      summary: parsed.summary ?? fb.summary,
      use_cases: Array.isArray(parsed.use_cases) ? parsed.use_cases : fb.use_cases,
      pros: Array.isArray(parsed.pros) ? parsed.pros : fb.pros,
      cons: Array.isArray(parsed.cons) ? parsed.cons : fb.cons,
    };
  } catch {
    return fb;
  }
}

正则表达式 \{[\s\S]*\} 从响应文本中提取第一个 JSON 对象,忽略周围的正文或代码块标记。然后逐个字段校验:如果 pros 字段存在但不是数组,就只对这个字段使用回退值,而不是丢弃整个响应。

回退内容(fallbackContent())用模型名称和流水线标签生成通用但语法有效的条目。这些内容会连同 model_used = 'fallback-template' 一起存入数据库,方便后续查询,等 API key 就绪或者提示优化后再重新生成。

没有 API key 也不崩溃

本地开发和不涉及内容生成的 CI 任务不会设置 ANTHROPIC_API_KEY。ETL 脚本通过 !!process.env.ANTHROPIC_API_KEY 检测这种情况,然后把所有行都路由到回退路径,而不是尝试调用 API。数据库照样能写入,流水线照样能跑,不需要 mock 或 stub 任何东西。

这意味着一个刚克隆下来的仓库可以跑 pnpm etl 并得到一个能正常运行的网站——只是内容是通用文本而不是 AI 生成的。对于原型设计新页面布局或测试 Turso(数据库)连接来说,这是个很合理的取舍。

model_used 列让这个模式很容易落地。第一次真实的内容生成跑完后,我可以这样查询:

SELECT model_used, COUNT(*) FROM model_content GROUP BY model_used;

直接就能看到有多少条目是真实生成的、多少是回退的。这个数字决定了要不要用更高的 ETL_LIMIT 重新跑。

如果重来会怎么做

最大的遗憾是缺失的用量日志。我把 res.usage 返回在 GenerateResult 里,但调用脚本都没有处理它。每次批量运行加一行 console.log 记录 cache_read_input_tokens 就能给我真实数据来展示。第一个月的成本报告之前我得把这个补上。

我还会考虑加一个 generateBatch 函数,接收一组用户提示并在单次函数调用中顺序发送。现在 generate-content.ts 是外部循环驱动的。把循环移进去会让在统一地方添加速率限制和重试逻辑变得更容易,而不是在每个调用方单独处理。

单例的 Anthropic 客户端对当前规模来说没问题——同一时间只跑一个 ETL 任务,没有并发。如果我以后要在同一个进程里同时跑两个 ETL 脚本,单例仍然是安全的,因为 Anthropic SDK 在请求之间是无状态的。但还是值得记在心上。

最后:系统提示目前在每个 generate-content.ts 里都是硬编码的字符串。如果重新做这个系统,我大概会把它们移到 prompts/ 目录里作为普通的 .txt 文件。这样更容易做 diff,非 TypeScript 开发者也更容易阅读,以后加版本控制也方便。

这个模式对它的定位来说效果不错——一个轻量级的共享抽象层,保持缓存逻辑一致、保持本地开发可用。第一个月的数字会告诉我缓存是否真的省下了有意义的成本,但防御性解析在第一次内容运行中已经防止了至少几行脏数据进入数据库。

这是持续 6 个月、运营三个 AI 策展目录网站实验的一部分。这里的技术主张是真实的;本文由 AI 辅助撰写。