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

我们花费了八天时间构建了一个不得不放弃的 API。以下是发生的事情。
一个函数来统治它们,一个函数来控制所有适配器,一个函数让一切都类型安全。
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 完全失败的地方在于我们的选项工厂。
我们添加了一个 createXXXOptions API。createTextOptions、createImageOptions 等。你可以将选项构建为现成的代理,并将其传递到函数中,覆盖你需要的选项。
为了与主题保持一致,我们将其命名为 aiOptions。它会将一切约束到模态和提供者
const opts = aiOptions({
adapter: openaiText('gpt-4'),
})
ai(opts)
我们在这里遇到了瓶颈。
当 aiOptions 返回只读值时,将其展开到 ai() 中有效。但是 aiOptions 的类型比较宽松。你可以传递任何内容。
当我们修复 aiOptions 以仅接受有效属性时,展开操作会将 ai() 函数转换为 any。然后它会接受任何内容。
我们一直在循环。让一部分工作正常,破坏另一部分。修复那个,破坏第一部分。
我相信它可以完成。我们的方法可能只是错误了。系统中存在一个微妙的错误,导致一切崩溃。但这证明了这一点:它太复杂了,无法理解并找到根本原因。任何修复都需要在所有适配器中传播。成本很高。
我们花了将近一周的时间试图让这个 API 完美运行。我们失败了。也许再过一周就能完成。但是然后呢?我们如何修复这个脆弱类型系统中的错误?我们如何找到根本原因?
即使我们让它工作,还有一个问题。
我们刚刚将适配器拆分为更小的部分,以便打包器可以 tree-shake 你不需要的内容。然后我们将所有这些复杂性都放回了 ai() 中。
我们不想成为 AI 库的 lodash,将你不需要的所有内容打包在一起,然后就完成了。如果捆绑所有内容的巨大适配器不可接受,那么执行相同操作的单个函数肯定不可接受。
这部分很刺痛。
我们在撤销之前与 API 进行了六天的斗争,然后又花了两天时间来取消它。总共八天。
我们错过的警告信号?LLM 无法可靠地生成此 API 的代码。
想想吧。我们正在构建 AI 工具,而 AI 无法弄清楚如何使用它们。这应该是一个巨大的线索,表明人类在没有帮助的情况下也不会可靠地编写此 API。
LLM 喜欢指示事情是什么的函数名称。ai()?谁知道。generateImage()?非常清楚。
当我们最终直接询问 LLM 对 API 的看法时,它们以 4-0 的比分反对 ai(),并支持我们最终采用的更具描述性的方法。
我们使用 agents 来完成实现工作。这向我们隐藏了斗争。
如果我们手动编写代码,我们会感受到与类型作斗争的挑战。这可能会及早阻止这个想法。
LLM 不会因为你让他们做疯狂的事情而吠叫。除非你要求它们,否则它们不会批评你的设计。它们只是尝试。尝试。最终产生技术上有效但不应该存在的东西。
我们对设计过于自信,没有提出 RFC。没有获得外部反馈。没有让 LLM 本身来运行它。
这是经典的陷阱。房间里的聪明人,设计一些很酷的东西,互相拍拍后背,却不知道他们遗漏了关键的细节。去构建简单的新事物,然后变成一场噩梦。
这些情况几乎是不可避免的。唯一的优化是在早期将其切断。如果我们
在确定单独的函数之前,我们尝试了另一种方法:具有子属性的适配器。
const adapter = openai()
adapter.image('model')
adapter.text('model')
看起来更好。感觉更统一。同样的问题:仍然捆绑所有内容。
我们可以在 TanStack Start 中进行自定义捆绑,以删除未使用的部分,但我们不希望强制你使用我们的框架才能获得最佳体验。这个库是为 Web 生态系统服务的,而不仅仅是 TanStack 用户。
单独的函数。chat()、generateImage()、generateSpeech()、generateTranscription()。
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
chat({
adapter: openaiText('gpt-4'),
temperature: 0.6,
})
它不像那么聪明。这就是重点。
你知道 chat() 做什么。你知道 generateImage() 做什么。LLM 知道它们做什么。你的捆绑包只包含你导入的内容。类型足够简单,可以进行推理。
像生活中的许多事情一样,需要在复杂性、DX 和 UX 之间进行权衡。我们决定保持核心简单,将功能拆分为单独的捆绑包,并使模态易于引入或忽略。
如果 LLM 无法编写你的 API,请重新考虑。 这是一个信号,表明人类也会遇到困难。
不要让 agents 掩盖痛苦。 在自动化之前手动编写代码。自己感受摩擦。
对设计进行外部验证。 提出 RFC。获得反馈。询问 LLM 的看法。
简单明了胜过聪明。 API 不应该让你感到惊讶。函数名称应该说明它们的作用。
尽早切断。 这些陷阱几乎是不可避免的。胜利在于快速识别它们。
我们喜欢 ai() API。我们构建了它。我们不得不放弃它。有时就是这样。
准备好尝试我们发布的替代方案了吗?请阅读 TanStack AI Alpha 2:每种模态,更好的 API,更小的捆绑包。