博客

差点就存在的 ai() 函数

作者:Alem Tuzlak,2025年12月26日

The ai() Function That Almost Was

我们花费了八天时间构建了一个不得不放弃的 API。以下是发生的事情。

梦想

一个函数来统治它们,一个函数来控制所有适配器,一个函数让一切都类型安全。

ts
import { ai } from '@tanstack/ai'
import { openaiText, openaiImage, openaiSummarize } from '@tanstack/ai-openai'

// text generation
ai({
  adapter: openaiText('gpt-4'),
  // ... text options
})

// image generation
ai({
  adapter: openaiImage('dall-e-3'),
  // ... image options
})

// summarization
ai({
  adapter: openaiSummarize('gpt-4'),
  // ... summary options
})

简单。单个函数。驱动所有与 AI 相关的功能。清晰的命名。你正在使用 AI。类型约束于每个适配器的能力。将图像选项传递给图像适配器,文本选项传递给文本适配器。

更改模型?如果不支持,则出现类型错误。更改适配器?如果不支持,则出现类型错误。

它感觉很强大。在适配器之间切换很快。我们很兴奋。

它失败了。

失败的原因

两件事扼杀了它:复杂性和 tree-shaking。

复杂性陷阱

对于最终用户而言,ai() 的简单性隐藏了巨大的实现复杂性。

尝试 1:函数重载

我们尝试使用函数重载来约束每个适配器的选项。场景太多了。重载解析为错误的签名。你最终可能会提供视频选项而不是图像选项。我们将其做到 99% 正常工作,但 1% 的错误感觉不对,而且比你想象的障碍更大。

拥有 10 多个重载很繁琐。顺序错误,一切都会崩溃。这将呈指数级地增加贡献的难度,并降低我们发布稳定版本的信心。

尝试 2:纯推断

我们尝试使用 TypeScript 推断。它实际上有效。一切都完美推断。类型约束于模型。生活很美好。椰子在海滩上滚动。

但是推断代码仅仅为了涵盖文本、图像和音频就需要 50-100 行代码。随着更多模态的增加,以及类型安全性的改进,它还会继续增长。经过彻底分析,几乎不可能对其进行推理。只需一眼,理解就消失了。

我们宁愿承担我们自己的复杂性,也不愿强迫你使用 as 类型转换或 any 类型。但这个 API 完全失败的地方在于我们的选项工厂。

aiOptions 的噩梦

我们添加了一个 createXXXOptions API。createTextOptionscreateImageOptions 等。你可以将选项构建为现成的代理,并将其传递到函数中,覆盖你需要的选项。

为了与主题保持一致,我们将其命名为 aiOptions。它会将一切约束到模态和提供者

ts
const opts = aiOptions({
  adapter: openaiText('gpt-4'),
})

ai(opts)

我们在这里遇到了瓶颈。

aiOptions 返回只读值时,将其展开到 ai() 中有效。但是 aiOptions 的类型比较宽松。你可以传递任何内容。

当我们修复 aiOptions 以仅接受有效属性时,展开操作会将 ai() 函数转换为 any。然后它会接受任何内容。

我们一直在循环。让一部分工作正常,破坏另一部分。修复那个,破坏第一部分。

我相信它可以完成。我们的方法可能只是错误了。系统中存在一个微妙的错误,导致一切崩溃。但这证明了这一点:它太复杂了,无法理解并找到根本原因。任何修复都需要在所有适配器中传播。成本很高。

我们花了将近一周的时间试图让这个 API 完美运行。我们失败了。也许再过一周就能完成。但是然后呢?我们如何修复这个脆弱类型系统中的错误?我们如何找到根本原因?

即使我们让它工作,还有一个问题。

Tree-Shaking

我们刚刚将适配器拆分为更小的部分,以便打包器可以 tree-shake 你不需要的内容。然后我们将所有这些复杂性都放回了 ai() 中。

我们不想成为 AI 库的 lodash,将你不需要的所有内容打包在一起,然后就完成了。如果捆绑所有内容的巨大适配器不可接受,那么执行相同操作的单个函数肯定不可接受。

我们错过的警告

这部分很刺痛。

LLM 无法编写它

我们在撤销之前与 API 进行了六天的斗争,然后又花了两天时间来取消它。总共八天。

我们错过的警告信号?LLM 无法可靠地生成此 API 的代码。

想想吧。我们正在构建 AI 工具,而 AI 无法弄清楚如何使用它们。这应该是一个巨大的线索,表明人类在没有帮助的情况下也不会可靠地编写此 API。

LLM 喜欢指示事情是什么的函数名称。ai()?谁知道。generateImage()?非常清楚。

当我们最终直接询问 LLM 对 API 的看法时,它们以 4-0 的比分反对 ai(),并支持我们最终采用的更具描述性的方法。

Agents 掩盖了痛苦

我们使用 agents 来完成实现工作。这向我们隐藏了斗争。

如果我们手动编写代码,我们会感受到与类型作斗争的挑战。这可能会及早阻止这个想法。

LLM 不会因为你让他们做疯狂的事情而吠叫。除非你要求它们,否则它们不会批评你的设计。它们只是尝试。尝试。最终产生技术上有效但不应该存在的东西。

我们跳过了验证

我们对设计过于自信,没有提出 RFC。没有获得外部反馈。没有让 LLM 本身来运行它。

这是经典的陷阱。房间里的聪明人,设计一些很酷的东西,互相拍拍后背,却不知道他们遗漏了关键的细节。去构建简单的新事物,然后变成一场噩梦。

这些情况几乎是不可避免的。唯一的优化是在早期将其切断。如果我们

  1. 在自动化之前手动编写代码
  2. 询问 LLM 对 API 的看法
  3. 提出 RFC 并获得反馈
  4. 注意到 agents 正在努力

我们探索的替代方案

在确定单独的函数之前,我们尝试了另一种方法:具有子属性的适配器。

ts
const adapter = openai()
adapter.image('model')
adapter.text('model')

看起来更好。感觉更统一。同样的问题:仍然捆绑所有内容。

我们可以在 TanStack Start 中进行自定义捆绑,以删除未使用的部分,但我们不希望强制你使用我们的框架才能获得最佳体验。这个库是为 Web 生态系统服务的,而不仅仅是 TanStack 用户。

我们最终确定

单独的函数。chat()generateImage()generateSpeech()generateTranscription()

ts
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

chat({
  adapter: openaiText('gpt-4'),
  temperature: 0.6,
})

它不像那么聪明。这就是重点。

你知道 chat() 做什么。你知道 generateImage() 做什么。LLM 知道它们做什么。你的捆绑包只包含你导入的内容。类型足够简单,可以进行推理。

像生活中的许多事情一样,需要在复杂性、DX 和 UX 之间进行权衡。我们决定保持核心简单,将功能拆分为单独的捆绑包,并使模态易于引入或忽略。

经验教训

  1. 如果 LLM 无法编写你的 API,请重新考虑。 这是一个信号,表明人类也会遇到困难。

  2. 不要让 agents 掩盖痛苦。 在自动化之前手动编写代码。自己感受摩擦。

  3. 对设计进行外部验证。 提出 RFC。获得反馈。询问 LLM 的看法。

  4. 简单明了胜过聪明。 API 不应该让你感到惊讶。函数名称应该说明它们的作用。

  5. 尽早切断。 这些陷阱几乎是不可避免的。胜利在于快速识别它们。

我们喜欢 ai() API。我们构建了它。我们不得不放弃它。有时就是这样。


准备好尝试我们发布的替代方案了吗?请阅读 TanStack AI Alpha 2:每种模态,更好的 API,更小的捆绑包