译:LLM API 其实是个同步问题
原文: https://lucumr.pocoo.org/2025/11/22/llm-apis/
作者: Armin Ronacher
译者: Gemini 2.5 Pro
我用服务商提供的 API 来跟大语言模型打交道越多,就越觉得我们构建的这套 API 接口实在有点不幸。它可能根本不是底层运行机制的正确抽象。我现在倾向于把这个问题看作一个分布式状态同步问题。
从核心上讲,一个大语言模型接收文本,将其分词(tokenize)成数字,然后把这些 token 送入 GPU 上的一堆矩阵乘法和注意力层中进行处理。它利用一大组固定的权重,产生活化值并预测下一个 token。如果不是因为 temperature(随机性)的存在,你至少在原则上可以认为它有潜力成为一个确定性高得多的系统。
对于模型核心而言,“user 文本”和“assistant 文本”之间没有本质区别——一切都只是 token。唯一的区别来自于特殊的 token 和格式,它们通过 prompt 模板注入到数据流中,用来编码角色(系统、用户、助手、工具)。你可以看看 Ollama 上不同模型的系统 prompt 模板,就能对此有个概念。
Agent 的基本状态
我们先不管现有的 API,只考虑在一个 agent 系统中通常会发生什么。如果我让我的 LLM 在本地同一台机器上运行,虽然仍有状态需要维护,但这个状态对我来说是完全本地的。你会在内存中以 token 的形式维护对话历史,而模型则会在 GPU 上保留一个派生出来的“工作状态”——主要是由这些 token 构建的注意力键/值缓存(attention key/value cache)。权重本身保持不变;每一步变化的是激活值和 KV 缓存。
需要进一步说明的是:当我谈论状态时,我指的不仅仅是可见的 token 历史,因为模型还有一个内部工作状态,这个状态是无法通过简单地重发 token 来捕捉的。换句话说:你可以重放这些 token 并恢复文本内容,但你无法恢复模型已经建立的那个精确的派生状态。
从心智模型的角度来看,缓存意味着“记住你已经为某个前缀所做的计算,这样就不用再重复了”。在内部,这通常意味着在服务器上存储这些前缀 token 的注意力 KV 缓存,并让你能够复用它,而不是真的把原始的 GPU 状态交给你。
这里面可能还有些我没注意到的细节,但我认为用这个模型来思考问题是相当不错的。
Completion API
一旦你开始使用像 OpenAI 或 Anthropic 那样的 completion 风格的 API,事情就和这个非常简单的系统有点不一样了,因为引入了一些抽象层。第一个区别是,你实际上并不是在传递原始的 token。GPU 看待对话历史的方式和你看待它的方式,在抽象层级上完全不同。虽然你可以在自己这边计算和操作 token,但实际上有一些你看不到的额外 token 被注入到了数据流中。其中一些 token 来自于将 JSON 消息表示转换为喂给机器的底层输入 token。但还有像工具定义这样的东西,它们以专有方式被注入到对话中。此外,还有一些带外信息,比如缓存点。
除此之外,还有一些你永远看不到的 token。例如,对于推理模型,你通常看不到任何真正的推理 token,因为一些 LLM 服务商会尽可能地隐藏这些信息,这样你就无法用它们的推理状态来重新训练自己的模型。另一方面,他们可能会给你一些别的信息文本,好让你有东西可以展示给用户。模型服务商还喜欢隐藏搜索结果以及这些结果是如何被注入到 token 流中的。取而代之的是,你只会收到一个加密的数据块(blob),你必须把它发回去才能继续对话。突然之间,你需要把你这边的某些信息再传回服务器,以便双方的状态能够达成一致。
在 completion 风格的 API 中,每一轮新的对话都需要重新发送整个 prompt 历史。每个请求的大小随对话轮数线性增长,但在一次长对话中,发送的数据总量是二次方增长的,因为每个线性大小的历史记录在每一步都被重新传输。这就是为什么长时间的聊天会话感觉越来越昂贵的原因之一。在服务器端,模型在处理该序列时的注意力成本也与序列长度呈二次方关系增长,这就是为什么缓存变得重要的原因。
Responses API
OpenAI 试图解决这个问题的方法之一是引入 Responses API,它在服务器上维护对话历史(至少在带有保存状态标志的版本中是这样)。但现在你处在一个奇怪的境地:你完全是在处理状态同步问题。服务器上有隐藏状态,你这边也有状态,但 API 给你的同步能力却非常有限。到目前为止,我仍然不清楚你到底能把这样的对话持续多久。如果出现状态分歧或损坏,会发生什么也不清楚。我曾见过 Responses API 卡死到无法恢复的情况。如果发生网络分区,或者一方收到了状态更新而另一方没有,会发生什么也不清楚。带有保存状态功能的 Responses API,至少就目前暴露出的接口来看,使用起来要困难得多。
显然,这对 OpenAI 来说是件好事,因为它允许他们隐藏更多幕后状态,否则这些状态就必须在每条对话消息中传来传去。
状态同步 API
无论你用的是 completion 风格的 API 还是 Responses API,服务商总得在幕后注入额外的上下文——prompt 模板、角色标记、系统/工具定义,有时甚至是服务商端的工具输出——这些东西从不会出现在你可见的消息列表中。不同的服务商处理这种隐藏上下文的方式各不相同,也没有一个通用的标准来规定如何表示或同步它。底层的现实比基于消息的抽象看起来要简单得多:如果你自己运行一个开源权重的模型,你可以直接用 token 序列来驱动它,并设计出比我们现在标准化的那些基于 JSON 消息的接口干净得多的 API。当你通过像 OpenRouter 这样的中间商或像 Vercel AI SDK 这样的 SDK 时,复杂性会变得更糟。它们试图掩盖不同服务商的差异,但无法完全统一每个服务商维护的隐藏状态。在实践中,统一 LLM API 最难的部分不是用户可见的消息,而是每个服务商都以不兼容的方式管理着自己那部分隐藏状态。
问题最终归结为如何以某种形式传递这些隐藏状态。我理解从模型服务商的角度来看,能够向用户隐藏一些东西是件好事。但同步隐藏状态是件棘手的事,而且据我所知,这些 API 没有一个是抱着这种心态去构建的。也许是时候开始思考一个状态同步 API 应该是什么样子,而不是一个基于消息的 API。
我跟这些 agent 打交道越多,就越觉得我其实并不需要一个统一的消息 API。它目前这种基于消息的核心思想本身就是一种抽象,一种可能经不起时间考验的抽象。
向 Local-First 学习?
有一个完整的生态系统曾经处理过这类烂摊子:local-first 运动。那些人花了十年时间研究如何在互不信任、会掉线、会分叉、合并和修复的客户端与服务器之间同步分布式状态。点对点同步(Peer-to-peer sync)和无冲突复制存储引擎(conflict-free replicated storage engines)之所以存在,都是因为“有间隙和分歧的共享状态”是一个难题,没人能用天真的消息传递来解决。他们的架构明确区分了规范状态(canonical state)、派生状态(derived state)和传输机制——这正是当今大多数 LLM API 所缺少的。
其中一些想法惊人地适用于模型:KV 缓存类似于可以设置检查点和恢复的派生状态;prompt 历史实际上是一个只追加的日志(append-only log),可以增量同步而不是每次都完整重发;服务商端的不可见上下文就像一个带有隐藏字段的复制文档。
但与此同时,如果远程状态因为对方不想保存那么久而被清除了,我们希望能够完全从头开始重放它——例如,今天的 Responses API 就不允许这样做。
未来的统一 API
关于统一基于消息的 API 的讨论已经很多了,尤其是在 MCP(模型上下文协议)出现之后。但如果我们真的要标准化什么东西,它应该从这些模型实际的工作方式出发,而不是从我们继承下来的那些表面惯例出发。一个好的标准应该承认隐藏状态、同步边界、重放语义和失败模式的存在——因为这些都是真实存在的问题。我们总是有可能仓促地将当前的抽象形式化,从而把它们的弱点和缺陷也固定下来。我不知道正确的抽象应该是什么样子,但我越来越怀疑,目前的解决方案并非良配。